mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
Merge main into integration-batch-7
This commit is contained in:
+149
-185
@@ -1,10 +1,14 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request. The Linux gate runs through the
|
||||
# suite's shared gate workflow in craft-common-actions (`cargo xtask ci`); the Windows legs, the
|
||||
# test variant and the corpus, web and fuzz jobs are PhotoCraft's own.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
# `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it.
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fuzz:
|
||||
@@ -15,6 +19,10 @@ on:
|
||||
description: "Seconds per fuzz target"
|
||||
default: "600"
|
||||
|
||||
# The token is only used to check out the code: no job writes to the repository.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
@@ -22,52 +30,72 @@ env:
|
||||
# bypasses that, lp fails loudly on a printer that does not exist.
|
||||
PHOTOCRAFT_NO_PRINT: "1"
|
||||
LPDEST: photocraft-no-such-printer
|
||||
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
||||
# debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
|
||||
# disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
|
||||
# that lands while main's run is in progress waits for it, so every main state that gets a run is
|
||||
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
|
||||
# A newer push to a PR branch cancels its older run. Every push to main gets a run of its own that
|
||||
# is never cancelled, so each state of main is checked to the end. Adding any label but `full-ci`
|
||||
# gets a group of its own, so it neither runs anything nor cancels a run.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
|
||||
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
|
||||
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
|
||||
# macOS is tested only by release.yml.
|
||||
# Whether the Windows legs and the web build run. main, manual runs and PRs labelled `full-ci`
|
||||
# get both. Other PRs get Windows when they touch code whose behaviour differs by platform: the
|
||||
# app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or the lockfile.
|
||||
# Format changes must exercise the Windows-only publication barriers (#8034). macOS is tested
|
||||
# only by release.yml.
|
||||
platforms:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
timeout-minutes: 10
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
legs: ${{ steps.pick.outputs.legs }}
|
||||
windows: ${{ steps.pick.outputs.windows }}
|
||||
web: ${{ steps.pick.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
||||
run: |
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
|
||||
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As
|
||||
# one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
|
||||
# the integration tests over a third runner (nextest --partition) saved ~2 min of wall
|
||||
# time but cost ~7 runner-minutes and one more Windows slot per PR.
|
||||
linux='{"os":"ubuntu-latest","part":"all"}'
|
||||
win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
|
||||
all="[$linux,$(win unit),$(win integration)]"
|
||||
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "windows=true" >> "$GITHUB_OUTPUT"
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -80,66 +108,82 @@ jobs:
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "windows=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
|
||||
echo "windows=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
|
||||
# The Linux gate. Its rust-cache entry is the one `test (without heif)` restores.
|
||||
gate:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# egui/winit/rfd.
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run. Same commit as release.yml.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
# Cargo.toml's dev profile already keeps line tables only.
|
||||
dev-debug: ""
|
||||
env: |
|
||||
PHOTOCRAFT_NO_PRINT=1
|
||||
LPDEST=photocraft-no-such-printer
|
||||
timeout-minutes: 100
|
||||
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
|
||||
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
|
||||
command: cargo xtask ci
|
||||
|
||||
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job
|
||||
# they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration
|
||||
# tests over a third runner (nextest --partition) saved ~2 min of wall time but cost ~7
|
||||
# runner-minutes and one more Windows slot per PR. They stay outside the shared gate for sccache,
|
||||
# which needs its own action step.
|
||||
windows:
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: needs.platforms.outputs.windows == 'true'
|
||||
timeout-minutes: 100
|
||||
name: test (windows-latest, ${{ matrix.part }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
|
||||
part: [unit, integration]
|
||||
env:
|
||||
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
|
||||
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
|
||||
# line-tables-only, so a backtrace with lines is one OS away).
|
||||
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
|
||||
# background: the space is free before the craft-fonts build needs it, and nothing waits.
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
components: clippy
|
||||
|
||||
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
|
||||
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
|
||||
# Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps main's.
|
||||
# A failing test still saves: the cache holds compiled dependencies, not test results.
|
||||
# The Windows legs share one key (same dependencies); the integration leg saves it,
|
||||
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
# Both legs share one key (same dependencies); the integration leg saves it, because it also
|
||||
# runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }}
|
||||
cache-on-failure: true
|
||||
|
||||
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
|
||||
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
|
||||
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
|
||||
# Test executables can't be cached, so a PR still links its tests.
|
||||
- name: Install sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
- name: Install sccache
|
||||
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.18.0
|
||||
|
||||
- name: Use sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
- name: Use sccache
|
||||
env:
|
||||
MAIN: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
@@ -148,149 +192,80 @@ jobs:
|
||||
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
|
||||
shell: bash
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
|
||||
- name: Clippy
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
if: matrix.part == 'integration'
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
if: matrix.part == 'integration'
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
|
||||
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-nextest@0.9.146
|
||||
tool: cargo-nextest@0.9.149
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
|
||||
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
|
||||
# they get their own step.
|
||||
#
|
||||
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
|
||||
# The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
|
||||
# integration-test target); the feature selection stays the same, so both legs share the
|
||||
# dependency cache. The `ci` profile fails fast and terminates hung tests
|
||||
# (.config/nextest.toml); assertion failures are not retried.
|
||||
# dependency cache.
|
||||
- name: Test
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
case "$PART" in
|
||||
unit) targets=(--lib --bins) ;;
|
||||
integration) targets=(--test '*') ;;
|
||||
*) targets=(--lib --bins --tests) ;;
|
||||
*) targets=(--test '*') ;;
|
||||
esac
|
||||
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
|
||||
shell: bash
|
||||
|
||||
# Every command against adversarial params must return Err, never panic or hang (AGENTS.md
|
||||
# Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
|
||||
# Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
|
||||
# features and recompile the whole graph. The `ci` profile terminates it if it hangs.
|
||||
- name: Panic hunt
|
||||
if: runner.os == 'Linux'
|
||||
run: >-
|
||||
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
|
||||
--run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
|
||||
|
||||
- name: Doctests
|
||||
if: matrix.part == 'all' || matrix.part == 'unit'
|
||||
if: matrix.part == 'unit'
|
||||
run: cargo test --workspace --locked --features heif --doc
|
||||
|
||||
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
|
||||
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
|
||||
# name reporting, so a required status check on it (none today) would keep working. It fails
|
||||
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
|
||||
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
|
||||
windows-gate:
|
||||
timeout-minutes: 5
|
||||
name: test (windows-latest)
|
||||
needs: [platforms, test, test-variants]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build" error
|
||||
# for distributors who leave it out. Runs beside the gate and restores the gate's dependency
|
||||
# cache without saving one: the dependencies are the same, and the 10 GB cache budget is tight
|
||||
# (#2688).
|
||||
test-without-heif:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
timeout-minutes: 60
|
||||
name: test (without heif)
|
||||
# The gate's runner: its cache entry is keyed on the OS.
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
# The gate sets these; rust-cache hashes CARGO_* and RUST* variables into its key.
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_NET_RETRY: 10
|
||||
steps:
|
||||
- env:
|
||||
PLATFORMS: ${{ needs.platforms.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
VARIANTS: ${{ needs.test-variants.result }}
|
||||
run: |
|
||||
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
|
||||
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
|
||||
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
|
||||
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
|
||||
# tight (#2688).
|
||||
test-variants:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: [without heif, with craft-fonts]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
- name: Install Linux build dependencies
|
||||
uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
# gate.yml keys its cache `<cache-version>-rust-<hash of Cargo.toml>-gate`.
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
shared-key: test
|
||||
prefix-key: v1-rust
|
||||
shared-key: ${{ hashFiles('Cargo.toml') }}-gate
|
||||
save-if: false
|
||||
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-nextest@0.9.146
|
||||
tool: cargo-nextest@0.9.149
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: matrix.variant == 'without heif'
|
||||
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
|
||||
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
|
||||
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
|
||||
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
|
||||
# Filter execution to the three crates that read the optional font input (#6127).
|
||||
- name: Check out craft-fonts
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: >-
|
||||
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
|
||||
corpus:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
timeout-minutes: 30
|
||||
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
||||
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
||||
@@ -306,7 +281,9 @@ jobs:
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
@@ -314,7 +291,7 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Restore test corpora
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: corpus/
|
||||
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
|
||||
@@ -323,23 +300,6 @@ jobs:
|
||||
- name: Corpus tests
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Check wasm-safe crates
|
||||
run: cargo xtask wasm
|
||||
|
||||
web:
|
||||
timeout-minutes: 60
|
||||
name: web build and wasm size gate
|
||||
@@ -351,7 +311,9 @@ jobs:
|
||||
# Match release.yml so PR results reflect what would actually ship.
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -420,7 +382,9 @@ jobs:
|
||||
- { crate: affinity, name: import_preview, args: "--features io" }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
@@ -434,7 +398,7 @@ jobs:
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
|
||||
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
|
||||
|
||||
@@ -9,6 +9,8 @@ name: FreeBSD
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
pull_request:
|
||||
|
||||
@@ -7,6 +7,8 @@ name: Windows ARM64
|
||||
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
|
||||
# change the Windows packaging itself.
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
|
||||
Generated
+35
-31
@@ -4638,7 +4638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4685,7 +4685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4695,11 +4695,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"rayon",
|
||||
"rustfft",
|
||||
"serde",
|
||||
@@ -4708,7 +4709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
@@ -4731,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4746,7 +4747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4754,7 +4755,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4775,7 +4776,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4783,7 +4784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-cms",
|
||||
@@ -4799,7 +4800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4810,7 +4811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chrono",
|
||||
@@ -4834,6 +4835,7 @@ dependencies = [
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
@@ -4850,7 +4852,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4873,14 +4875,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4898,14 +4900,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
@@ -4924,6 +4926,7 @@ dependencies = [
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
@@ -4935,18 +4938,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4957,12 +4960,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4972,7 +4976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4983,7 +4987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4994,7 +4998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -5009,7 +5013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -5021,7 +5025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"serde_json",
|
||||
@@ -5029,7 +5033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5044,7 +5048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
@@ -5087,7 +5091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5098,7 +5102,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5120,7 +5124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -8762,7 +8766,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
+7
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -72,15 +72,18 @@ unsafe_code = "forbid"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Dependencies carry no debug info: their frames in a backtrace lose file:line, and the target dir
|
||||
# and link times shrink. The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
|
||||
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
|
||||
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
|
||||
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
# time and PDB size for the test binaries (#2204). CI's Windows legs build with no debuginfo at all
|
||||
# (CARGO_PROFILE_DEV_DEBUG=0). To inspect locals in a debugger, build with
|
||||
# CARGO_PROFILE_DEV_DEBUG=true.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
|
||||
|
||||
@@ -34,7 +34,7 @@ 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", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -233,20 +233,21 @@ fn browser_languages() -> Vec<String> {
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
|
||||
/// Integrity value), the browser rejects a body whose hash differs.
|
||||
/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a
|
||||
/// Subresource Integrity value), the browser rejects a body whose hash differs.
|
||||
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
if !served_fonts::is_relative_path(url) {
|
||||
return Err("url must be a relative path".into());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let request = match integrity {
|
||||
Some(sri) => {
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_integrity(sri);
|
||||
window.fetch_with_str_and_init(url, &init)
|
||||
}
|
||||
None => window.fetch_with_str(url),
|
||||
};
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_mode(web_sys::RequestMode::SameOrigin);
|
||||
if let Some(sri) = integrity {
|
||||
init.set_integrity(sri);
|
||||
}
|
||||
let request = window.fetch_with_str_and_init(url, &init);
|
||||
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
|
||||
@@ -15,6 +15,8 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
|
||||
|
||||
/// A save appends one table; real files have one to a few hundred.
|
||||
const MAX_TABLES: usize = 4096;
|
||||
/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted.
|
||||
const MAX_ENTRIES: usize = 1 << 22;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
@@ -164,7 +166,7 @@ impl<'a> Archive<'a> {
|
||||
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
|
||||
let header = header(bytes)?;
|
||||
// Newest record per id: (table date, table index, record).
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Box<Entry>>)> = HashMap::new();
|
||||
let mut names: HashMap<u32, String> = HashMap::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut next = header.fat_offset;
|
||||
@@ -222,7 +224,7 @@ impl<'a> Archive<'a> {
|
||||
} else {
|
||||
stored
|
||||
};
|
||||
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
|
||||
record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression }));
|
||||
}
|
||||
2 => {}
|
||||
_ => return Err(Error::Malformed("unknown allocation record")),
|
||||
@@ -235,6 +237,10 @@ impl<'a> Archive<'a> {
|
||||
// Equal dates: the first table in the chain (the newest) wins, as in afread.
|
||||
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
|
||||
if replace {
|
||||
// Deletion records are five bytes each: cap what distinct ids may retain.
|
||||
if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) {
|
||||
return Err(Error::Limit("too many archive entries"));
|
||||
}
|
||||
latest.insert(id, (date, tables, record));
|
||||
}
|
||||
}
|
||||
@@ -248,7 +254,7 @@ impl<'a> Archive<'a> {
|
||||
for (id, (_, _, record)) in latest {
|
||||
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
|
||||
e.name = name.clone();
|
||||
entries.insert(e.name.clone(), e);
|
||||
entries.insert(e.name.clone(), *e);
|
||||
}
|
||||
}
|
||||
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
|
||||
|
||||
@@ -9,17 +9,21 @@ const MAX_NODES: usize = 4_000_000;
|
||||
const MAX_COMPOUND_DEPTH: usize = 64;
|
||||
const MAX_OUTLINE_VISITS: usize = 500_000;
|
||||
const MAX_OUTLINE_POINTS: usize = 4_000_000;
|
||||
/// Curve records decoded across the document. A shared curve is decoded again for each
|
||||
/// occurrence, and records that only move handles add no points.
|
||||
const MAX_OUTLINE_RECORDS: usize = 8_000_000;
|
||||
|
||||
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
|
||||
/// transform. Bound work across the entire document instead of memoizing by object id alone.
|
||||
pub(crate) struct Budget {
|
||||
visits_left: usize,
|
||||
points_left: usize,
|
||||
records_left: usize,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
|
||||
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +34,12 @@ impl Budget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge curve records before they are decoded.
|
||||
fn records(&mut self, n: usize) -> Result<(), &'static str> {
|
||||
self.records_left = self.records_left.checked_sub(n).ok_or("curve geometry budget exhausted")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
|
||||
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
|
||||
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
|
||||
@@ -152,6 +162,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
}
|
||||
if let Err(reason) = r.geometry_budget.records(records.len()) {
|
||||
r.warn(reason);
|
||||
return None;
|
||||
}
|
||||
let anchors = anchors(records)?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
@@ -258,3 +272,15 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn curve_records_are_charged_across_the_document() {
|
||||
let mut b = Budget::default();
|
||||
assert!(b.records(MAX_OUTLINE_RECORDS).is_ok());
|
||||
assert_eq!(b.records(1), Err("curve geometry budget exhausted"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,8 @@ const MAX_NODES: usize = 500_000;
|
||||
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
|
||||
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
|
||||
const MAX_CHILD_SLOTS: usize = 8_000_000;
|
||||
/// Bytes of layer names the model may retain across all occurrences.
|
||||
const MAX_NAME_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
@@ -304,6 +306,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
child_slots: usize,
|
||||
name_bytes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
pub(crate) geometry_budget: crate::geometry::Budget,
|
||||
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
|
||||
@@ -320,6 +323,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
child_slots: 0,
|
||||
name_bytes: 0,
|
||||
active: HashSet::new(),
|
||||
geometry_budget: crate::geometry::Budget::default(),
|
||||
pixels_left: limits.max_pixel_bytes,
|
||||
@@ -394,10 +398,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
// Charge every raw child slot (null slots included) against a document-wide budget
|
||||
// before filtering. A shared group with a large null-filled Chld array could otherwise
|
||||
// be rescanned through each reference without the node limit noticing.
|
||||
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
|
||||
if self.child_slots > MAX_CHILD_SLOTS {
|
||||
return Err(Error::Limit("too many child slots in the layer tree"));
|
||||
}
|
||||
self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?;
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
@@ -419,6 +420,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Ok((out, mask))
|
||||
}
|
||||
|
||||
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
|
||||
fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.child_slots = self.child_slots.saturating_add(n);
|
||||
if self.child_slots > MAX_CHILD_SLOTS {
|
||||
return Err(Error::Limit("too many child slots in the layer tree"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
|
||||
let s = self.s;
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
@@ -437,7 +447,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
|
||||
// A shared layer's name is copied for every occurrence, so charge the bytes first.
|
||||
let name = s.str(id, b"Desc").unwrap_or_default();
|
||||
self.name_bytes = self.name_bytes.saturating_add(name.len());
|
||||
if self.name_bytes > MAX_NAME_BYTES {
|
||||
return Err(Error::Limit("layer names too large"));
|
||||
}
|
||||
let name = name.to_string();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
@@ -460,6 +476,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let (mask, attached_mask) = self.attached_masks(id, world)?;
|
||||
let pixel_mask = attached_mask.or(child_mask);
|
||||
self.active.remove(&id);
|
||||
self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?;
|
||||
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
|
||||
self.warn("layer effects (shadows, glows, outlines, bevels…)");
|
||||
}
|
||||
|
||||
@@ -181,24 +181,30 @@ fn plane(
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
// Borrow the file-controlled arrays: collecting the whole state array first costs its full
|
||||
// length on every occurrence of a shared layer, however small the declared grid is.
|
||||
let states: &[Value] = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
};
|
||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||
let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) {
|
||||
Some(Value::Array(a)) => a.as_slice(),
|
||||
_ => &[],
|
||||
}
|
||||
.iter()
|
||||
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
|
||||
let row = (w as usize) * bps;
|
||||
let mut out = vec![0u8; row * h as usize];
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut next = 0usize;
|
||||
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||
let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 };
|
||||
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
|
||||
let fill = match state {
|
||||
0 | 1 => None,
|
||||
2 => Some(0xFF),
|
||||
4 => {
|
||||
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
next += 1;
|
||||
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
|
||||
continue;
|
||||
|
||||
@@ -332,7 +332,8 @@ impl Parser<'_> {
|
||||
0x2c => {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
let size = usize::from(self.c.u16()?);
|
||||
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
|
||||
// A zero width passes the length check for any count, then costs a Value per record.
|
||||
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
let mut v = self.vec(n)?;
|
||||
|
||||
@@ -302,6 +302,17 @@ mod archive {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() {
|
||||
// Four million records of no bytes each pass a length check that multiplies by the width.
|
||||
let mut f = vec![0x80 | 0x2c];
|
||||
f.extend(tag(b"Big_").0.to_le_bytes());
|
||||
f.extend(4_000_000u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]);
|
||||
assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
|
||||
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
|
||||
|
||||
@@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let mut v = Vec::new();
|
||||
// One past the allowance is enough to trip the check below; shared segments
|
||||
// repeated by reference must not expand beyond it first.
|
||||
let room = MAX_CHARS.saturating_sub(total).saturating_add(1);
|
||||
for seg in s.objs(glyphs, b"Mixd") {
|
||||
if v.len() >= room {
|
||||
break;
|
||||
}
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(room - v.len())));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len())));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -114,6 +114,21 @@ struct Variant {
|
||||
ok: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so
|
||||
/// thin images turned to different angles leave it mostly empty: the cost grows with the square
|
||||
/// of their length, not with their pixels.
|
||||
const MAX_MOSAIC_PIXELS: usize = 1 << 25;
|
||||
|
||||
/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None`
|
||||
/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows).
|
||||
fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> {
|
||||
let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0);
|
||||
let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?;
|
||||
let mw = w.checked_add(gap)?.checked_add(col_w)?;
|
||||
let mh = h.max(col_h);
|
||||
(mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh))
|
||||
}
|
||||
|
||||
/// A source buffer and which of its pixels may be sampled.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Src<'a> {
|
||||
@@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
|
||||
|
||||
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
|
||||
/// `Err` when cancelled.
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
||||
let Src { w, h, ch, .. } = src;
|
||||
/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels.
|
||||
fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) {
|
||||
let (s, c) = (-deg.to_radians()).sin_cos();
|
||||
// Output size: the rotated, scaled bounding box.
|
||||
let (fw, fh) = (w as f32 * scale, h as f32 * scale);
|
||||
let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1);
|
||||
let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1);
|
||||
(ow, oh)
|
||||
}
|
||||
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
||||
let Src { w, h, ch, .. } = src;
|
||||
// Output size: the rotated, scaled bounding box.
|
||||
let (ow, oh) = variant_dims(w, h, deg, scale);
|
||||
let (s, c) = (-deg.to_radians()).sin_cos();
|
||||
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
|
||||
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
|
||||
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
|
||||
@@ -243,17 +265,27 @@ fn fill_cow(
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
|
||||
let src = Src { w, h, ch, img: &img, ok: &ok };
|
||||
// Variants: the identity (which holds the hole) first, then the adaptations.
|
||||
let mut extra: Vec<Variant> = Vec::new();
|
||||
// Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are
|
||||
// expensive, so the mosaic budget is settled on their dimensions before any variant is built;
|
||||
// when the variants would not fit, the image itself is completed without them.
|
||||
let mut specs: Vec<(f32, f32, bool)> = Vec::new();
|
||||
if opts.mirror {
|
||||
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
|
||||
specs.push((0.0, 1.0, true));
|
||||
}
|
||||
for ° in &opts.rotations {
|
||||
extra.push(variant(src, deg, 1.0, false, ctl)?);
|
||||
specs.push((deg, 1.0, false));
|
||||
}
|
||||
if opts.scale {
|
||||
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
|
||||
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
|
||||
specs.push((0.0, 0.8, false));
|
||||
specs.push((0.0, 1.25, false));
|
||||
}
|
||||
let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect();
|
||||
let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) };
|
||||
let mut extra: Vec<Variant> = Vec::new();
|
||||
if size.is_some() {
|
||||
for &(deg, scale, mirror) in &specs {
|
||||
extra.push(variant(src, deg, scale, mirror, ctl)?);
|
||||
}
|
||||
}
|
||||
let role = |s: usize| {
|
||||
if hole[s] {
|
||||
@@ -265,14 +297,9 @@ fn fill_cow(
|
||||
}
|
||||
};
|
||||
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
|
||||
// Without variants it is the image itself.
|
||||
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
|
||||
(w, h, None, par::map(w * h, true, role))
|
||||
} else {
|
||||
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
|
||||
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
|
||||
let mw = w + gap + col_w;
|
||||
let mh = h.max(col_h);
|
||||
// Without variants it is the image itself; so it is when the variants would not fit
|
||||
// (`size` above is then None and `extra` is empty).
|
||||
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if let Some((mw, mh)) = size {
|
||||
let mut mimg = vec![0.0f32; mw * mh * ch];
|
||||
let mut roles = vec![Px::Void; mw * mh];
|
||||
for y in 0..h {
|
||||
@@ -297,6 +324,8 @@ fn fill_cow(
|
||||
oy += v.h + gap;
|
||||
}
|
||||
(mw, mh, Some(mimg), roles)
|
||||
} else {
|
||||
(w, h, None, par::map(w * h, true, role))
|
||||
};
|
||||
drop((extra, ok));
|
||||
ctl.check()?;
|
||||
@@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mosaic_size;
|
||||
|
||||
fn blank(w: usize, h: usize) -> (usize, usize) {
|
||||
(w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() {
|
||||
// 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels.
|
||||
assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None);
|
||||
assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None);
|
||||
assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Vertical stripes (period 6) in grey, with a hole in the middle.
|
||||
|
||||
+52
-4
@@ -87,6 +87,8 @@ impl Tiff<'_> {
|
||||
}
|
||||
}
|
||||
fn text(&self, typ: u16, count: u32, field: usize) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
TEXT_DECODES.with(|c| c.set(c.get() + 1));
|
||||
if typ != 2 {
|
||||
return None;
|
||||
}
|
||||
@@ -97,6 +99,9 @@ impl Tiff<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local!(static TEXT_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) });
|
||||
|
||||
/// Reads [`CameraInfo`] from a TIFF-structured EXIF payload (an `Exif\0\0` prefix is skipped).
|
||||
pub fn read(exif: &[u8]) -> CameraInfo {
|
||||
let b = exif.strip_prefix(b"Exif\0\0").unwrap_or(exif);
|
||||
@@ -117,15 +122,17 @@ pub fn read(exif: &[u8]) -> CameraInfo {
|
||||
seen.push(off);
|
||||
for (tag, typ, count, field) in t.ifd(off) {
|
||||
match tag {
|
||||
0x010F => info.make = t.text(typ, count, field),
|
||||
0x0110 => info.model = t.text(typ, count, field),
|
||||
// Duplicate TIFF text tags can point at the same large payload. Keep the first
|
||||
// recognized value and do not repeatedly decode/copy later duplicates.
|
||||
0x010F if info.make.is_none() => info.make = t.text(typ, count, field),
|
||||
0x0110 if info.model.is_none() => info.model = t.text(typ, count, field),
|
||||
0x829A => info.exposure_time = t.number(typ, count, field),
|
||||
0x829D => info.f_number = t.number(typ, count, field),
|
||||
0x8827 => info.iso = t.number(typ, count, field),
|
||||
0x920A => info.focal_length = t.number(typ, count, field),
|
||||
0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0),
|
||||
0xA434 => info.lens = t.text(typ, count, field),
|
||||
0x9003 => info.date_taken = t.text(typ, count, field),
|
||||
0xA434 if info.lens.is_none() => info.lens = t.text(typ, count, field),
|
||||
0x9003 if info.date_taken.is_none() => info.date_taken = t.text(typ, count, field),
|
||||
0x8769 => {
|
||||
if let Some(p) = t.u32(field) {
|
||||
queue.push(p as usize);
|
||||
@@ -252,6 +259,47 @@ mod tests {
|
||||
|
||||
/// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random
|
||||
/// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm).
|
||||
#[test]
|
||||
fn duplicate_text_tags_keep_the_first_value_without_copying_large_duplicates() {
|
||||
let value = vec![b'X'; 2 * 1024 * 1024];
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend_from_slice(&8u32.to_le_bytes());
|
||||
// IFD0: Make="M", a duplicate Make pointing at a 2 MiB payload, and the Exif IFD link.
|
||||
b.extend_from_slice(&3u16.to_le_bytes());
|
||||
b.extend_from_slice(&0x010Fu16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u32.to_le_bytes());
|
||||
b.extend_from_slice(b"M\0\0\0");
|
||||
b.extend_from_slice(&0x010Fu16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&(value.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&80u32.to_le_bytes());
|
||||
b.extend_from_slice(&0x8769u16.to_le_bytes());
|
||||
b.extend_from_slice(&4u16.to_le_bytes());
|
||||
b.extend_from_slice(&1u32.to_le_bytes());
|
||||
b.extend_from_slice(&50u32.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
// Exif IFD at 50: DateTimeOriginal="D" and the same large duplicate payload at offset 80.
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&0x9003u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u32.to_le_bytes());
|
||||
b.extend_from_slice(b"D\0\0\0");
|
||||
b.extend_from_slice(&0x9003u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&(value.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&80u32.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b.extend_from_slice(&value);
|
||||
|
||||
TEXT_DECODES.with(|c| c.set(0));
|
||||
let info = read(&b);
|
||||
assert_eq!(info.make.as_deref(), Some("M"));
|
||||
assert_eq!(info.date_taken.as_deref(), Some("D"));
|
||||
// One decode per tag: duplicates must be skipped before `text` copies their payload.
|
||||
assert_eq!(TEXT_DECODES.with(|c| c.get()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_payloads_never_panic() {
|
||||
let entry = |tag: u16, typ: u16, count: u32, value: u32| {
|
||||
|
||||
@@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
|
||||
|
||||
/// Bounds of the hole pixels of a role grid.
|
||||
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
|
||||
// A zero-width grid holds no pixels whatever its height; don't size a row table by it.
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// Per row the first and last hole column; the union of the rows' spans.
|
||||
let rows: Vec<Option<(usize, usize)>> = par::map(h, w * h >= PAR_MIN, |y| {
|
||||
let row = px.get(y * w..(y + 1) * w)?;
|
||||
@@ -2047,6 +2051,14 @@ mod tests {
|
||||
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
|
||||
}
|
||||
|
||||
/// A zero-width image has no pixels however tall it is claimed to be: no per-row table.
|
||||
#[test]
|
||||
fn zero_width_images_complete_without_sizing_by_height() {
|
||||
let p = CompleteParams::default();
|
||||
assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new()));
|
||||
assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new()));
|
||||
}
|
||||
|
||||
/// Smooth gradient plus deterministic fine noise.
|
||||
fn texture(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut rng = Rng::new(7);
|
||||
|
||||
+22
-11
@@ -561,7 +561,7 @@ pub enum FilterParams {
|
||||
impl FilterParams {
|
||||
/// Pixels read outside an output tile.
|
||||
pub fn halo(&self) -> Halo {
|
||||
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
|
||||
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
|
||||
match self {
|
||||
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
|
||||
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
|
||||
@@ -571,9 +571,9 @@ impl FilterParams {
|
||||
| FilterParams::Median { radius }
|
||||
| FilterParams::DustAndScratches { radius, .. }
|
||||
| FilterParams::Minimum { radius, .. }
|
||||
| FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
|
||||
| FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)),
|
||||
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
|
||||
FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1),
|
||||
FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)),
|
||||
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
|
||||
FilterParams::FindEdges => Halo::Radius(1),
|
||||
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
|
||||
@@ -698,7 +698,7 @@ impl FilterParams {
|
||||
|
||||
/// Halos of the second filter batch.
|
||||
fn halo_ext(p: &FilterParams) -> Halo {
|
||||
let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1);
|
||||
let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1));
|
||||
match p {
|
||||
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0),
|
||||
FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0),
|
||||
@@ -719,8 +719,9 @@ fn halo_ext(p: &FilterParams) -> Halo {
|
||||
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
|
||||
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
|
||||
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
|
||||
FilterParams::SmartBlur { radius, .. } => r(*radius),
|
||||
FilterParams::LensBlur { radius, .. } => r(*radius),
|
||||
// The kernels clamp the radius to 100 px: size the source window by what they use.
|
||||
FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)),
|
||||
FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)),
|
||||
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
|
||||
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
|
||||
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
|
||||
@@ -999,10 +1000,18 @@ pub fn apply_in_with(
|
||||
}
|
||||
|
||||
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
|
||||
/// re-read margin (and its cost) below ~2× the tile area.
|
||||
/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the
|
||||
/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not
|
||||
/// overflow in debug builds, and must not produce a tile bigger than the hard cap.
|
||||
fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
|
||||
match params.halo_for(bounds) {
|
||||
Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048),
|
||||
// The radius is input-derived: widen to i64 before the arithmetic and cap the
|
||||
// conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a
|
||||
// tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row).
|
||||
Halo::Radius(r) => {
|
||||
let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072);
|
||||
TILE.max(halo as i32).min(2048)
|
||||
}
|
||||
Halo::Bounds => TILE,
|
||||
}
|
||||
}
|
||||
@@ -1096,14 +1105,16 @@ fn apply_tiled_with_impl(
|
||||
return None;
|
||||
}
|
||||
let halo = params.halo_for(bounds);
|
||||
if matches!(params, FilterParams::UnsharpMask { .. }) {
|
||||
let Halo::Radius(reach) = halo else { return None };
|
||||
// Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is
|
||||
// caller-supplied, so a short parameter set can ask for a huge source window per tile.
|
||||
if let Halo::Radius(reach) = halo {
|
||||
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
|
||||
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
|
||||
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
|
||||
// Reject hostile source-window sizes before Image::read allocates them.
|
||||
// Ordinary filter parameters and automatic tiles fit these bounds.
|
||||
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
|
||||
let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576;
|
||||
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || too_many_tiles {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,11 +209,20 @@ pub fn auto_cell(bounds: Rect) -> f64 {
|
||||
if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 }
|
||||
}
|
||||
|
||||
/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB.
|
||||
const MAX_FIELD_NODES: usize = 1 << 24;
|
||||
|
||||
impl LiquifyField {
|
||||
pub fn new(bounds: Rect, cell: f64) -> Self {
|
||||
let cell = cell.clamp(1.0, 64.0);
|
||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
||||
let mut cell = cell.clamp(1.0, 64.0);
|
||||
let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2);
|
||||
let (mut w, mut h) = nodes(cell);
|
||||
// The extent is the document canvas, which a file declares: past the node budget the
|
||||
// field gets coarser (it is interpolated anyway) instead of asking for gigabytes.
|
||||
while w.saturating_mul(h) > MAX_FIELD_NODES {
|
||||
cell *= 2.0;
|
||||
(w, h) = nodes(cell);
|
||||
}
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||
}
|
||||
|
||||
@@ -1122,6 +1131,15 @@ mod tests {
|
||||
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() {
|
||||
// 400000² px at 2 px per node is 4·10¹⁰ nodes.
|
||||
let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0);
|
||||
assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len());
|
||||
assert!(f.cell > 64.0);
|
||||
assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
||||
// A segment that does not start where the last one ended begins a new run of dabs there.
|
||||
|
||||
@@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
|
||||
mod brush;
|
||||
mod morphology;
|
||||
|
||||
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
|
||||
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_surface, painted_tiles, stroke_area, swept_area};
|
||||
|
||||
/// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman:
|
||||
/// three comparisons per sample whatever the radius.
|
||||
@@ -413,6 +413,22 @@ where
|
||||
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
|
||||
}
|
||||
|
||||
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
|
||||
/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front
|
||||
/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection.
|
||||
pub const MAX_REFINE_AREA: u64 = 1 << 30;
|
||||
|
||||
/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over:
|
||||
/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty
|
||||
/// when `content` misses the canvas.
|
||||
pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect {
|
||||
let content = content.intersect(&canvas);
|
||||
if content.is_empty() {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas)
|
||||
}
|
||||
|
||||
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
|
||||
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
|
||||
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
|
||||
@@ -446,11 +462,10 @@ fn refine_mask_with<F>(
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
|
||||
{
|
||||
let content = content.intersect(&canvas);
|
||||
if content.is_empty() {
|
||||
let bbox = refine_area(content, canvas, p, brush);
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas);
|
||||
let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX));
|
||||
let mut tiles = Vec::new();
|
||||
let mut y = bbox.y0;
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution
|
||||
//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use super::box_mean;
|
||||
use crate::segment::RgbImage;
|
||||
use crate::selection::{MAX_PAINTED_TILES, clip_segment};
|
||||
|
||||
const TS: usize = TILE_SIZE as usize;
|
||||
|
||||
/// One Refine Edge Brush stroke, in document pixels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -29,26 +36,42 @@ pub struct EdgeStroke {
|
||||
/// Brush diameters the strokes may use.
|
||||
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
|
||||
|
||||
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
|
||||
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched), kept only for
|
||||
/// the tiles the strokes paint, so a thin stroke across a huge canvas costs those tiles, not its
|
||||
/// bounding box (#8783).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EdgeBrush {
|
||||
/// Where the strokes can paint.
|
||||
bbox: Rect,
|
||||
/// Coverage × 127, row-major over `bbox`.
|
||||
v: Vec<i8>,
|
||||
/// Coverage × 127 per painted tile, row-major over the tile.
|
||||
tiles: BTreeMap<TileCoord, Vec<i8>>,
|
||||
/// Bounds of the added (positive) coverage.
|
||||
added: Rect,
|
||||
/// Box radius of the colour estimate (pixels).
|
||||
window: usize,
|
||||
}
|
||||
|
||||
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
|
||||
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
|
||||
/// Distance from pixel centre `p` to the segment from `a` to `b`.
|
||||
fn segment_distance(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let len2 = dx * dx + dy * dy;
|
||||
let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
|
||||
let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt();
|
||||
(r + 0.5 - d).clamp(0.0, 1.0)
|
||||
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
|
||||
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
|
||||
(r + 0.5 - segment_distance(p, a, b)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Coverage (0–1) at distance `d` from a brush's path, for radius `r` and a solid core of radius
|
||||
/// `core` (hardness × `r`): full inside the core, then a smooth fall to zero half a pixel past `r`
|
||||
/// (a hard brush keeps just the one-pixel anti-aliased rim).
|
||||
fn soft_coverage(d: f32, r: f32, core: f32) -> f32 {
|
||||
let ramp = r + 0.5 - core;
|
||||
let t = ((r + 0.5 - d) / ramp.max(1.0)).clamp(0.0, 1.0);
|
||||
if ramp > 1.0 { t * t * (3.0 - 2.0 * t) } else { t }
|
||||
}
|
||||
|
||||
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
|
||||
@@ -64,27 +87,113 @@ pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64;
|
||||
|
||||
/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite.
|
||||
pub fn swept_area(strokes: &[EdgeStroke]) -> f64 {
|
||||
strokes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
|
||||
let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
|
||||
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
|
||||
})
|
||||
.sum()
|
||||
strokes.iter().map(|s| stroke_area(&s.points, s.size)).sum()
|
||||
}
|
||||
|
||||
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
|
||||
/// kept one are dropped, and long segments are split at most `r` long, so each segment's box
|
||||
/// stays close to the area it paints.
|
||||
fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
|
||||
let Some(&first) = points.first() else { return Vec::new() };
|
||||
/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size²;
|
||||
/// non-finite strokes count as infinite.
|
||||
pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 {
|
||||
let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
|
||||
let len: f64 = points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
|
||||
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
/// The tiles of `canvas` a round brush `size` px wide along `points` can paint, in order; `None`
|
||||
/// when that is more than `limit` (a thin stroke sweeps little but touches a whole tile wherever
|
||||
/// it passes, so this, not [`MAX_SWEPT_AREA`], bounds what painting it allocates; #8783).
|
||||
pub fn painted_tiles(points: &[(f32, f32)], size: f32, canvas: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
|
||||
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
|
||||
let mut set = BTreeSet::new();
|
||||
// Consecutive pieces mostly fall in the same tiles.
|
||||
let mut last: Option<Rect> = None;
|
||||
for (a, b) in segments(points, r, canvas) {
|
||||
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
|
||||
// The tile coordinates `seg` spans (inclusive).
|
||||
let span =
|
||||
Rect::new(seg.x0.div_euclid(TILE_SIZE), seg.y0.div_euclid(TILE_SIZE), (seg.x1 - 1).div_euclid(TILE_SIZE), (seg.y1 - 1).div_euclid(TILE_SIZE));
|
||||
if seg.is_empty() || last == Some(span) {
|
||||
continue;
|
||||
}
|
||||
last = Some(span);
|
||||
for tc in seg.tiles() {
|
||||
set.insert(tc);
|
||||
if set.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// One stroke's coverage (0–255) per tile of `canvas`: the most `cov(pixel centre, a, b)` gives
|
||||
/// over the path's pieces, so a stroke never paints past full. Only painted tiles are kept;
|
||||
/// `None` past `limit` tiles (see [`painted_tiles`]).
|
||||
fn paint_tiles(
|
||||
points: &[(f32, f32)],
|
||||
size: f32,
|
||||
canvas: Rect,
|
||||
limit: usize,
|
||||
cov: impl Fn((f32, f32), (f32, f32), (f32, f32)) -> f32,
|
||||
) -> Option<BTreeMap<TileCoord, Vec<u8>>> {
|
||||
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
|
||||
let mut tiles: BTreeMap<TileCoord, Vec<u8>> = painted_tiles(points, size, canvas, limit)?.into_iter().map(|tc| (tc, vec![0u8; TS * TS])).collect();
|
||||
for (a, b) in segments(points, r, canvas) {
|
||||
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
|
||||
for tc in seg.tiles() {
|
||||
let o = tc.rect();
|
||||
let Some(buf) = tiles.get_mut(&tc) else { continue };
|
||||
let tr = o.intersect(&seg);
|
||||
for y in tr.y0..tr.y1 {
|
||||
for x in tr.x0..tr.x1 {
|
||||
let c = (cov((x as f32 + 0.5, y as f32 + 0.5), a, b).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
if let Some(slot) = buf.get_mut((y - o.y0) as usize * TS + (x - o.x0) as usize)
|
||||
&& c > *slot
|
||||
{
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tiles.retain(|_, b| b.iter().any(|v| *v > 0));
|
||||
Some(tiles)
|
||||
}
|
||||
|
||||
/// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with
|
||||
/// `hardness` (0–100) paints along `points`, clipped to `canvas`, as a GRAY8 selection holding
|
||||
/// only the tiles it paints. Overlapping dabs keep the most coverage any of them gives, so a
|
||||
/// stroke never paints past full. `None` when it paints nothing, has non-finite points, sweeps
|
||||
/// more than [`MAX_SWEPT_AREA`] or paints more than [`MAX_PAINTED_TILES`] tiles.
|
||||
pub fn brush_surface(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option<Surface> {
|
||||
if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if stroke_area(points, size) > MAX_SWEPT_AREA {
|
||||
return None;
|
||||
}
|
||||
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
|
||||
let core = r * hardness.clamp(0.0, 100.0) / 100.0;
|
||||
let tiles = paint_tiles(points, size, canvas, MAX_PAINTED_TILES, |p, a, b| soft_coverage(segment_distance(p, a, b), r, core))?;
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
for (tc, bytes) in &tiles {
|
||||
s.write_interleaved(tc.rect(), bytes);
|
||||
}
|
||||
(s.tile_count() > 0).then_some(s)
|
||||
}
|
||||
|
||||
/// The stroke's path as short segments within reach of `clip`: points closer than a quarter of
|
||||
/// the radius to the last kept one are dropped, each segment is cut to `clip` grown by the brush
|
||||
/// (what lies beyond can't paint inside it), and long ones are split at most `r` long, so each
|
||||
/// piece's box stays close to the area it paints. The pieces are made as they are painted, never
|
||||
/// collected, and only where they can paint: a 1 px stroke zigzagging across 2 million px is over
|
||||
/// 100 million pieces in all, within [`MAX_SWEPT_AREA`] (the #8783 review).
|
||||
fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator<Item = ((f32, f32), (f32, f32))> {
|
||||
let min_step = (r / 4.0).max(0.5);
|
||||
let max_step = r.max(2.0);
|
||||
let mut kept = vec![first];
|
||||
for p in points.iter().skip(1) {
|
||||
let last = kept.last().copied().unwrap_or(first);
|
||||
if (p.0 - last.0).hypot(p.1 - last.1) >= min_step {
|
||||
let reach = clip.inflate((r + 2.0).ceil().clamp(0.0, 1e7) as i32);
|
||||
let mut kept: Vec<(f32, f32)> = Vec::new();
|
||||
for p in points {
|
||||
if kept.last().is_none_or(|l| (p.0 - l.0).hypot(p.1 - l.1) >= min_step) {
|
||||
kept.push(*p);
|
||||
}
|
||||
}
|
||||
@@ -93,25 +202,22 @@ fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
|
||||
{
|
||||
kept.push(end);
|
||||
}
|
||||
if kept.len() == 1 {
|
||||
return vec![(first, first)];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for w in kept.windows(2) {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
// A single point is one dab.
|
||||
let dab = if kept.len() == 1 { kept.first().map(|p| (*p, *p)) } else { None };
|
||||
let pairs: Vec<((f32, f32), (f32, f32))> = kept.windows(2).filter_map(|w| clip_segment(*w.first()?, *w.get(1)?, reach)).collect();
|
||||
dab.into_iter().chain(pairs.into_iter().flat_map(move |(a, b)| {
|
||||
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize;
|
||||
let at = |k: usize| {
|
||||
let at = move |k: usize| {
|
||||
let t = k as f32 / pieces as f32;
|
||||
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
|
||||
};
|
||||
out.extend((0..pieces).map(|k| (at(k), at(k + 1))));
|
||||
}
|
||||
out
|
||||
(0..pieces).map(move |k| (at(k), at(k + 1)))
|
||||
}))
|
||||
}
|
||||
|
||||
impl EdgeBrush {
|
||||
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
|
||||
/// than [`MAX_SWEPT_AREA`].
|
||||
/// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing,
|
||||
/// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles.
|
||||
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
|
||||
let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite());
|
||||
if swept_area(strokes) > MAX_SWEPT_AREA {
|
||||
@@ -126,61 +232,43 @@ impl EdgeBrush {
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bw = bbox.width() as usize;
|
||||
let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?];
|
||||
let mut cov: Vec<f32> = Vec::new();
|
||||
let mut tiles: BTreeMap<TileCoord, Vec<i8>> = BTreeMap::new();
|
||||
let mut max_add = 0.0f32;
|
||||
for s in strokes {
|
||||
let sb = stroke_bounds(s);
|
||||
if sb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
|
||||
if s.add {
|
||||
max_add = max_add.max(2.0 * r);
|
||||
}
|
||||
let sw = sb.width() as usize;
|
||||
cov.clear();
|
||||
cov.resize(sw * sb.height() as usize, 0.0);
|
||||
// The stroke's coverage: the most any of its segments gives (a single dab for one point).
|
||||
for (a, b) in segments(&s.points, r) {
|
||||
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb);
|
||||
for y in seg.y0..seg.y1 {
|
||||
for x in seg.x0..seg.x1 {
|
||||
let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r);
|
||||
if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize)
|
||||
&& c > *slot
|
||||
{
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cov = paint_tiles(&s.points, s.size, canvas, MAX_PAINTED_TILES, |p, a, b| capsule(p, a, b, r))?;
|
||||
let target = if s.add { 1.0 } else { -1.0 };
|
||||
for y in sb.y0..sb.y1 {
|
||||
for x in sb.x0..sb.x1 {
|
||||
let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0);
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) {
|
||||
let cur = f32::from(*slot) / 127.0;
|
||||
for (tc, c) in cov {
|
||||
let v = tiles.entry(tc).or_insert_with(|| vec![0i8; TS * TS]);
|
||||
for (slot, c) in v.iter_mut().zip(c) {
|
||||
if c > 0 {
|
||||
let (cur, c) = (f32::from(*slot) / 127.0, f32::from(c) / 255.0);
|
||||
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
|
||||
}
|
||||
}
|
||||
}
|
||||
if tiles.len() > MAX_PAINTED_TILES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if v.iter().all(|c| *c == 0) {
|
||||
tiles.retain(|_, v| v.iter().any(|c| *c != 0));
|
||||
if tiles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for (row, line) in v.chunks_exact(bw).enumerate() {
|
||||
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
|
||||
let y = bbox.y0 + row as i32;
|
||||
(x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1));
|
||||
let mut added = Rect::EMPTY;
|
||||
for (tc, v) in &tiles {
|
||||
let o = tc.rect();
|
||||
for (row, line) in v.as_chunks::<TS>().0.iter().enumerate() {
|
||||
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
|
||||
let y = o.y0 + row as i32;
|
||||
added = added.union(&Rect::new(o.x0 + first as i32, y, o.x0 + last as i32 + 1, y + 1));
|
||||
}
|
||||
}
|
||||
let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY };
|
||||
Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
|
||||
Some(EdgeBrush { bbox, tiles, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
|
||||
}
|
||||
|
||||
/// Where the strokes paint (added or restored).
|
||||
@@ -200,16 +288,32 @@ impl EdgeBrush {
|
||||
|
||||
/// Signed coverage at document pixel `(x, y)` (zero outside the painted area).
|
||||
pub fn at(&self, x: i32, y: i32) -> f32 {
|
||||
if !self.bbox.contains(x, y) {
|
||||
return 0.0;
|
||||
}
|
||||
let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize;
|
||||
self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0)
|
||||
let (tc, o) = (TileCoord::containing(x, y), (y.rem_euclid(TILE_SIZE) as usize) * TS + x.rem_euclid(TILE_SIZE) as usize);
|
||||
self.tiles.get(&tc).and_then(|v| v.get(o)).map_or(0.0, |c| f32::from(*c) / 127.0)
|
||||
}
|
||||
|
||||
/// Signed coverage over `r` (row-major).
|
||||
pub fn read(&self, r: Rect) -> Vec<f32> {
|
||||
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
|
||||
let w = r.width() as usize;
|
||||
let mut out = vec![0.0f32; w * r.height() as usize];
|
||||
for tc in r.tiles() {
|
||||
let Some(v) = self.tiles.get(&tc) else { continue };
|
||||
let (o, tr) = (tc.rect(), tc.rect().intersect(&r));
|
||||
for y in tr.y0..tr.y1 {
|
||||
let src = (y - o.y0) as usize * TS;
|
||||
let dst = (y - r.y0) as usize * w;
|
||||
let (Some(src), Some(dst)) = (
|
||||
v.get(src + (tr.x0 - o.x0) as usize..src + (tr.x1 - o.x0) as usize),
|
||||
out.get_mut(dst + (tr.x0 - r.x0) as usize..dst + (tr.x1 - r.x0) as usize),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
for (d, c) in dst.iter_mut().zip(src) {
|
||||
*d = f32::from(*c) / 127.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +380,10 @@ mod tests {
|
||||
use crate::segment::ImageSampler;
|
||||
use crate::selection::Region;
|
||||
|
||||
fn at(s: &Surface, x: i32, y: i32) -> f32 {
|
||||
s.pixel(x, y).first().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
|
||||
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
|
||||
#[test]
|
||||
@@ -330,4 +438,73 @@ mod tests {
|
||||
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
|
||||
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn brush_surface_paints_hard_and_soft_strokes() {
|
||||
let canvas = Rect::new(0, 0, 100, 50);
|
||||
let hard = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted");
|
||||
assert!(at(&hard, 50, 25) > 0.99 && at(&hard, 50, 33) > 0.99, "solid inside the radius");
|
||||
assert!(at(&hard, 50, 36) == 0.0 && at(&hard, 50, 10) == 0.0, "nothing past it");
|
||||
// A soft brush fades from the centre line to the rim.
|
||||
let soft = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted");
|
||||
let (centre, mid, rim) = (at(&soft, 50, 25), at(&soft, 50, 30), at(&soft, 50, 34));
|
||||
assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}");
|
||||
// A single point is one dab; clipped to the canvas.
|
||||
let dab = brush_surface(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted");
|
||||
let b = dab.content_bounds();
|
||||
assert_eq!((b.x0, b.y0), (0, 0));
|
||||
assert!(at(&dab, 1, 1) > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_surface_paints_nothing_for_hostile_strokes() {
|
||||
let canvas = Rect::new(0, 0, 100, 50);
|
||||
assert!(brush_surface(&[], 10.0, 100.0, canvas).is_none());
|
||||
assert!(brush_surface(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none());
|
||||
assert!(brush_surface(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none());
|
||||
assert!(brush_surface(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none());
|
||||
assert!(brush_surface(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas");
|
||||
assert!(brush_surface(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much");
|
||||
// Sizes and hardness outside their ranges are clamped, not rejected.
|
||||
assert!(brush_surface(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some());
|
||||
}
|
||||
/// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits
|
||||
/// into over 100 million pieces: painting it must not collect them all first (it allocated
|
||||
/// about 2 GB up front; the #8783 review).
|
||||
#[test]
|
||||
fn a_long_thin_zigzag_paints_without_collecting_its_pieces() {
|
||||
let canvas = Rect::new(0, 0, 64, 64);
|
||||
let points: Vec<(f32, f32)> = (0..135).map(|i| (if i % 2 == 0 { 0.0 } else { 2_000_000.0 }, 32.0 + (i % 3) as f32)).collect();
|
||||
assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA);
|
||||
// Only the pieces within reach of the canvas are made.
|
||||
assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count());
|
||||
let r = brush_surface(&points, 1.0, 100.0, canvas).expect("it paints its row");
|
||||
assert!(at(&r, 10, 32) > 0.5);
|
||||
let strokes = [EdgeStroke { points, size: 1.0, add: true }];
|
||||
assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5));
|
||||
}
|
||||
|
||||
/// A 1 px diagonal across a 100,000 px canvas sweeps about 141,000 px, well within
|
||||
/// [`MAX_SWEPT_AREA`], but its bounding box is 10 billion pixels: painting it must cost the
|
||||
/// tiles it crosses, not that box (it allocated 40 GB; the #8783 review).
|
||||
#[test]
|
||||
fn a_thin_diagonal_across_a_huge_canvas_paints_only_its_tiles() {
|
||||
let canvas = Rect::new(0, 0, 100_000, 100_000);
|
||||
let diagonal = [(0.0, 0.0), (99_999.0, 99_999.0)];
|
||||
assert!(stroke_area(&diagonal, 1.0) <= MAX_SWEPT_AREA);
|
||||
let tiles = painted_tiles(&diagonal, 1.0, canvas, MAX_PAINTED_TILES).expect("within the limit");
|
||||
assert!(tiles.len() < 1500, "{}", tiles.len());
|
||||
let s = brush_surface(&diagonal, 1.0, 100.0, canvas).expect("painted");
|
||||
assert!(s.tile_count() <= tiles.len());
|
||||
assert!(at(&s, 50_000, 50_000) > 0.5 && at(&s, 50_000, 40_000) == 0.0);
|
||||
let strokes = [EdgeStroke { points: diagonal.to_vec(), size: 1.0, add: true }];
|
||||
let b = EdgeBrush::new(&strokes, canvas).expect("painted");
|
||||
assert!(b.at(50_000, 50_000) > 0.5 && b.at(50_000, 40_000) == 0.0);
|
||||
assert!(b.read(Rect::new(49_990, 49_990, 50_010, 50_010)).iter().any(|v| *v > 0.5));
|
||||
// A zigzag crossing more tiles than the limit is refused rather than allocated.
|
||||
let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect();
|
||||
assert!(stroke_area(&zigzag, 1.0) <= MAX_SWEPT_AREA);
|
||||
assert!(painted_tiles(&zigzag, 1.0, canvas, MAX_PAINTED_TILES).is_none());
|
||||
assert!(brush_surface(&zigzag, 1.0, 100.0, canvas).is_none());
|
||||
assert!(EdgeBrush::new(&[EdgeStroke { points: zigzag, size: 1.0, add: true }], canvas).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+61
-12
@@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) {
|
||||
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
|
||||
}
|
||||
|
||||
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
|
||||
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
|
||||
/// The source samples worth a weight each: the edge, or the content with one sample of slack on
|
||||
/// each side to carry the weight of everything beyond it (read as the default pixel).
|
||||
fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) {
|
||||
let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1)));
|
||||
(i64::from(a), i64::from(b.max(a)))
|
||||
}
|
||||
|
||||
/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples
|
||||
/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot.
|
||||
fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) {
|
||||
let Some(last) = w.len().checked_sub(1) else { return };
|
||||
let each = v / (b - a).max(1) as f64;
|
||||
let end = lo + w.len() as i64;
|
||||
let (ia, ib) = (a.max(lo), b.min(end));
|
||||
if ia < ib {
|
||||
for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() {
|
||||
*slot += each;
|
||||
}
|
||||
}
|
||||
if a < lo {
|
||||
w[0] += each * (b.min(lo) - a) as f64;
|
||||
}
|
||||
if b > end {
|
||||
w[last] += each * (b - a.max(end)) as f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that
|
||||
/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale
|
||||
/// doesn't cost a weight per source sample it covers.
|
||||
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec<f64>) {
|
||||
match kernel {
|
||||
Kernel::Nearest => {
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
@@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
|
||||
}
|
||||
Kernel::Bilinear if scale < 0.5 => {
|
||||
let (a, b) = cover(o, scale);
|
||||
(a, vec![1.0 / (b - a) as f64; (b - a) as usize])
|
||||
let (ka, kb) = keep_range(edge, content);
|
||||
let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb));
|
||||
let mut w = vec![0.0; (hi - lo + 1) as usize];
|
||||
spread(&mut w, lo, i64::from(a), i64::from(b), 1.0);
|
||||
(lo as i32, w)
|
||||
}
|
||||
Kernel::Bilinear => {
|
||||
let u = position(o, scale, edge);
|
||||
@@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
|
||||
// Averaged down to four times the target first (pixels of `inner`), then filtered.
|
||||
let inner = 4.0 * scale;
|
||||
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
|
||||
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge);
|
||||
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4));
|
||||
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
|
||||
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
|
||||
let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0);
|
||||
let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
|
||||
let mut w = vec![0.0; (hi - lo).max(0) as usize];
|
||||
let (ka, kb) = keep_range(edge, content);
|
||||
let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0));
|
||||
let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32));
|
||||
let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb));
|
||||
let mut w = vec![0.0; (khi - klo + 1) as usize];
|
||||
for ((a, b), v) in boxes.into_iter().zip(kw) {
|
||||
for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
|
||||
*slot += v / (b - a) as f64;
|
||||
}
|
||||
spread(&mut w, klo, i64::from(a), i64::from(b), v);
|
||||
}
|
||||
(lo, w)
|
||||
(klo as i32, w)
|
||||
}
|
||||
Kernel::Keys { a, stretch } => {
|
||||
let u = position(o, scale, edge);
|
||||
@@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32
|
||||
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
|
||||
(dst0..dst1)
|
||||
.map(|o| {
|
||||
let (lo, w) = weights(o, scale, kernel, edge);
|
||||
let (lo, w) = weights(o, scale, kernel, edge, content);
|
||||
let hi = lo + w.len() as i32 - 1;
|
||||
// Fold the weights of taps beyond the edge onto the edge sample.
|
||||
let start = clamp(lo);
|
||||
@@ -724,6 +757,22 @@ mod tests {
|
||||
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
|
||||
}
|
||||
|
||||
/// A reduction by a tiny scale covers millions of source samples per output sample; only the
|
||||
/// ones in the content get a weight, the rest fold onto its ends or into `outside`.
|
||||
#[test]
|
||||
fn tiny_scales_do_not_get_a_weight_per_covered_sample() {
|
||||
for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] {
|
||||
for edge in [None, Some((0, 99))] {
|
||||
let t = taps(0, 3, 1e-6, kernel, edge, (0, 99));
|
||||
for t in &t {
|
||||
assert!(t.weights.len() <= 100, "{}", t.weights.len());
|
||||
let total = t.weights.iter().sum::<f32>() + t.outside;
|
||||
assert!((total - 1.0).abs() < 1e-3, "{total}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_edges_do_not_darken() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||
|
||||
@@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
|
||||
}
|
||||
|
||||
mod distance;
|
||||
mod lasso;
|
||||
mod marquee;
|
||||
|
||||
pub use distance::edt;
|
||||
pub use marquee::{combine_surfaces, ellipse_surface};
|
||||
pub(crate) use lasso::clip_segment;
|
||||
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
|
||||
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
|
||||
|
||||
/// Grows the selection by `r` pixels.
|
||||
pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Lasso shapes built straight into tiles (#8783): only the tiles an edge crosses are rasterised,
|
||||
//! and every tile wholly inside the polygon shares one solid tile, so a thin lasso across a huge
|
||||
//! canvas costs the tiles along its edges, not its bounding box. The bytes are those
|
||||
//! [`super::polygon`] gives over the canvas.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
use crate::photo_util::par_map;
|
||||
|
||||
const TS: usize = TILE_SIZE as usize;
|
||||
|
||||
/// Most partly covered tiles one painted shape (a lasso, a brush stroke) may hold: 2²⁸ pixels'
|
||||
/// worth, 256 MB as GRAY8. A thin shape touches a whole tile wherever it passes, so this, not
|
||||
/// its area, bounds what painting it allocates.
|
||||
pub const MAX_PAINTED_TILES: usize = (1 << 28) / (TS * TS);
|
||||
|
||||
/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it.
|
||||
pub(crate) fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let (mut t0, mut t1) = (0.0f32, 1.0f32);
|
||||
for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] {
|
||||
if p == 0.0 {
|
||||
if q < 0.0 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let t = q / p;
|
||||
if p < 0.0 {
|
||||
t0 = t0.max(t);
|
||||
} else {
|
||||
t1 = t1.min(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
(t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1)))
|
||||
}
|
||||
|
||||
/// The pixels the polygon through `points` can touch within `clip` (its bounding box, grown by a
|
||||
/// pixel, as the Lasso has always rasterised it).
|
||||
fn polygon_area(points: &[(f32, f32)], clip: Rect) -> Rect {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
for p in points {
|
||||
(x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1));
|
||||
}
|
||||
if points.len() < 3 || !(x0 <= x1 && y0 <= y1) {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32;
|
||||
Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&clip)
|
||||
}
|
||||
|
||||
/// The tiles of `clip` the edges of the polygon through `points` cross (with a pixel's margin),
|
||||
/// in order: the only ones whose pixels can differ. `None` when there are more than `limit`.
|
||||
pub fn polygon_edge_tiles(points: &[(f32, f32)], clip: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
|
||||
let area = polygon_area(points, clip);
|
||||
let mut set = BTreeSet::new();
|
||||
if area.is_empty() {
|
||||
return Some(set);
|
||||
}
|
||||
let step = TILE_SIZE as f32 / 4.0;
|
||||
for (&a, &b) in points.iter().zip(points.iter().cycle().skip(1)) {
|
||||
let Some((a, b)) = clip_segment(a, b, area.inflate(2)) else { continue };
|
||||
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / step).ceil().clamp(1.0, 1e6) as usize;
|
||||
let at = |k: usize| {
|
||||
let t = k as f32 / pieces as f32;
|
||||
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
|
||||
};
|
||||
for k in 0..pieces {
|
||||
let (p, q) = (at(k), at(k + 1));
|
||||
let lo = |u: f32, v: f32| u.min(v).floor() as i32 - 1;
|
||||
let hi = |u: f32, v: f32| u.max(v).floor() as i32 + 2;
|
||||
for tc in Rect::new(lo(p.0, q.0), lo(p.1, q.1), hi(p.0, q.0), hi(p.1, q.1)).intersect(&area).tiles() {
|
||||
set.insert(tc);
|
||||
if set.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// One polygon edge, as [`super::polygon`] keeps it.
|
||||
struct Edge {
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
a: (f32, f32),
|
||||
b: (f32, f32),
|
||||
}
|
||||
|
||||
/// The anti-aliased (even-odd) polygon through `points`, cut to `clip`, as a GRAY8 selection: the
|
||||
/// bytes of [`super::polygon`] over `clip`, quantised. Edge tiles are rasterised, tiles wholly
|
||||
/// inside share one solid tile. `None` when the edges cross more than [`MAX_PAINTED_TILES`] tiles.
|
||||
pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option<Surface> {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
let area = polygon_area(points, clip);
|
||||
if area.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
let edge_tiles = polygon_edge_tiles(points, clip, MAX_PAINTED_TILES)?;
|
||||
const SUB: usize = 4;
|
||||
// Sample rows as `polygon` over `clip` takes them, so the bytes match.
|
||||
let sample_y = |y: i32, k: usize| clip.y0 as f32 + (y - clip.y0) as f32 + (k as f32 + 0.5) / SUB as f32;
|
||||
let mut edges: Vec<Edge> = points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.filter_map(|(&a, &b)| {
|
||||
if a.1 < b.1 {
|
||||
Some(Edge { top: a.1, bottom: b.1, a, b })
|
||||
} else if b.1 < a.1 {
|
||||
Some(Edge { top: b.1, bottom: a.1, a, b })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
|
||||
let crossings = |e: &[&Edge], y: f32, xs: &mut Vec<f32>| {
|
||||
xs.clear();
|
||||
xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
};
|
||||
let full = s.solid_tile(&[1.0]);
|
||||
let blank = s.solid_tile(&[0.0]);
|
||||
let bands: Vec<i32> = Rect::new(area.x0, area.y0, area.x0 + 1, area.y1).tiles().map(|tc| tc.ty).collect();
|
||||
let built = par_map(bands.len(), |bi| -> Vec<(TileCoord, Arc<Tile>)> {
|
||||
let Some(&ty) = bands.get(bi) else { return Vec::new() };
|
||||
let rows = Rect::new(area.x0, ty * TILE_SIZE, area.x1, (ty + 1) * TILE_SIZE).intersect(&area);
|
||||
// In `x` order (`TileCoord` orders by `tx` first).
|
||||
let cols: Vec<TileCoord> = edge_tiles.iter().filter(|tc| tc.ty == ty).copied().collect();
|
||||
let mut out = Vec::new();
|
||||
let (first, last) = (sample_y(rows.y0, 0), sample_y(rows.y1 - 1, SUB - 1));
|
||||
let reach = edges.partition_point(|e| e.top <= last);
|
||||
let band_edges = edges.get(..reach).unwrap_or_default();
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
// Tiles no edge crosses are wholly in or out: one sample row decides them all.
|
||||
let y_mid = sample_y(rows.y0 + rows.height() as i32 / 2, 0);
|
||||
let mid: Vec<&Edge> = band_edges.iter().filter(|e| e.top <= y_mid && e.bottom > y_mid).collect();
|
||||
crossings(&mid, y_mid, &mut xs);
|
||||
for tc in rows.tiles() {
|
||||
if cols.binary_search(&tc).is_ok() {
|
||||
continue;
|
||||
}
|
||||
let tr = tc.rect().intersect(&rows);
|
||||
let cx = (tr.x0 + tr.x1) as f32 / 2.0;
|
||||
if xs.partition_point(|x| *x <= cx) % 2 == 0 {
|
||||
continue;
|
||||
}
|
||||
if tr == tc.rect() {
|
||||
out.push((tc, Arc::clone(&full)));
|
||||
} else {
|
||||
let mut tile = Tile::clone(&blank);
|
||||
fill(&mut tile, tc, tr, u8::MAX);
|
||||
out.push((tc, Arc::new(tile)));
|
||||
}
|
||||
}
|
||||
if cols.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// The edge tiles: `polygon`'s scanline, accumulated only over their columns.
|
||||
let x_of = |tc: &TileCoord| tc.rect().intersect(&rows);
|
||||
let spans: Vec<Rect> = cols.iter().map(x_of).collect();
|
||||
let mut acc = vec![0.0f32; cols.len() * TS];
|
||||
let mut tiles: Vec<Tile> = cols.iter().map(|_| Tile::clone(&blank)).collect();
|
||||
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
|
||||
let mut active: Vec<&Edge> = Vec::new();
|
||||
let origin = clip.x0 as f32;
|
||||
for y in rows.y0..rows.y1 {
|
||||
acc.fill(0.0);
|
||||
for k in 0..SUB {
|
||||
let sy = sample_y(y, k);
|
||||
while let Some(e) = pending.next_if(|e| e.top <= sy) {
|
||||
active.push(e);
|
||||
}
|
||||
active.retain(|e| e.bottom > sy);
|
||||
if active.is_empty() {
|
||||
continue;
|
||||
}
|
||||
crossings(&active, sy, &mut xs);
|
||||
for pair in xs.as_chunks::<2>().0 {
|
||||
let (x0, x1) = ((pair[0] - origin).max(0.0), (pair[1] - origin).min(clip.width() as f32));
|
||||
if x1 <= x0 {
|
||||
continue;
|
||||
}
|
||||
let (c0, c1) = (clip.x0 + x0.floor() as i32, clip.x0 + x1.ceil() as i32);
|
||||
let start = spans.partition_point(|r| r.x1 <= c0);
|
||||
for (ci, r) in spans.iter().enumerate().skip(start).take_while(|(_, r)| r.x0 < c1) {
|
||||
for x in c0.max(r.x0)..c1.min(r.x1) {
|
||||
let cx = (x - clip.x0) as f32;
|
||||
let cov = (cx + 1.0).min(x1) - cx.max(x0);
|
||||
if let Some(v) = acc.get_mut(ci * TS + (x - r.x0) as usize) {
|
||||
*v += cov.max(0.0) / SUB as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::<TS>().0) {
|
||||
let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize;
|
||||
let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue };
|
||||
for (d, v) in dst.iter_mut().zip(row) {
|
||||
*d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (tc, tile) in cols.into_iter().zip(tiles) {
|
||||
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
if hi > 0 {
|
||||
out.push((tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }));
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
s.put_tiles(built.into_iter().flatten());
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Sets `tr` (within tile `tc`) to `v`.
|
||||
fn fill(tile: &mut Tile, tc: TileCoord, tr: Rect, v: u8) {
|
||||
let o = tc.rect();
|
||||
for y in tr.y0..tr.y1 {
|
||||
let base = (y - o.y0) as usize * TS;
|
||||
if let Some(row) = tile.bytes_mut().get_mut(base + (tr.x0 - o.x0) as usize..base + (tr.x1 - o.x0) as usize) {
|
||||
row.fill(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The dense rasteriser over `clip`, quantised as the Lasso stores it.
|
||||
fn dense(points: &[(f32, f32)], clip: Rect) -> Surface {
|
||||
let m: Vec<u8> = super::super::polygon(points, clip, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
|
||||
let mut s = Surface::from_interleaved(PixelFormat::GRAY8, clip, &m);
|
||||
s.prune();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_dense_polygon() {
|
||||
let clip = Rect::new(-40, 10, 1300, 900);
|
||||
let shapes: [&[(f32, f32)]; 6] = [
|
||||
&[(10.5, 20.25), (1200.0, 40.0), (600.0, 880.7)],
|
||||
// Whole tiles inside, edges exactly on tile boundaries.
|
||||
&[(0.0, 256.0), (768.0, 256.0), (768.0, 768.0), (0.0, 768.0)],
|
||||
// Self-intersecting (even-odd), past the clip.
|
||||
&[(-300.0, -50.0), (1500.0, 950.0), (1500.0, -50.0), (-300.0, 950.0)],
|
||||
&[(100.0, 100.0), (101.0, 100.0), (100.5, 700.0)],
|
||||
&[(5.0, 15.0), (5.0, 15.0), (5.0, 15.0)],
|
||||
&[(2000.0, 2000.0), (2100.0, 2000.0), (2000.0, 2100.0)],
|
||||
];
|
||||
for pts in shapes {
|
||||
let s = polygon_surface(pts, clip).expect("within the tile limit");
|
||||
let d = dense(pts, clip);
|
||||
let area = clip.union(&s.tile_bounds());
|
||||
assert!(s.read_region(area) == d.read_region(area), "{pts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A thin sliver across a 100,000 px canvas: only the tiles along it are built (the dense
|
||||
/// rasteriser allocated its 10-billion-pixel bounding box; the #8783 review).
|
||||
#[test]
|
||||
fn a_thin_sliver_across_a_huge_canvas_costs_its_edge_tiles() {
|
||||
let clip = Rect::new(0, 0, 100_000, 100_000);
|
||||
let sliver = [(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)];
|
||||
let tiles = polygon_edge_tiles(&sliver, clip, MAX_PAINTED_TILES).expect("within the limit");
|
||||
assert!(tiles.len() < 2000, "{}", tiles.len());
|
||||
let s = polygon_surface(&sliver, clip).expect("within the limit");
|
||||
assert!(s.tile_count() <= tiles.len());
|
||||
assert!(s.pixel(50_000, 50_000)[0] > 0.0 && s.pixel(50_000, 40_000)[0] == 0.0);
|
||||
// A zigzag that crosses more tiles than the limit is refused.
|
||||
let zigzag: Vec<(f32, f32)> = (0..200).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 500.0)).collect();
|
||||
assert!(polygon_edge_tiles(&zigzag, clip, MAX_PAINTED_TILES).is_none());
|
||||
assert!(polygon_surface(&zigzag, clip).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_polygon_shares_one_tile_inside() {
|
||||
let clip = Rect::new(0, 0, 51_200, 51_200);
|
||||
let s = polygon_surface(&[(-10.0, -10.0), (60_000.0, -10.0), (60_000.0, 60_000.0), (-10.0, 60_000.0)], clip).expect("no edge tiles");
|
||||
let shared = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect::<BTreeSet<_>>();
|
||||
assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len());
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,60 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3
|
||||
out
|
||||
}
|
||||
|
||||
/// [`combine_surfaces`] for an `f` that keeps `a` wherever `b` reads 0 (Add, Subtract), visiting
|
||||
/// only `b`'s tiles within `area`: the rest of `a` (its other tiles and its untouched value) is
|
||||
/// kept as it is, so painting a thin stroke across a huge canvas costs the tiles it paints, not
|
||||
/// the canvas (#8783). Falls back to [`combine_surfaces`] over `area` for other inputs.
|
||||
pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
|
||||
let gray = PixelFormat::GRAY8;
|
||||
let lut: Vec<u8> = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect();
|
||||
let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0);
|
||||
if a.format() != gray || b.format() != gray || b.default_bytes() != [0] || !(0..=255u8).all(|v| pair(v, 0) == v) {
|
||||
return combine_surfaces(a, b, area, f);
|
||||
}
|
||||
let da = a.default_bytes().first().copied().unwrap_or(0);
|
||||
let mut out = a.clone();
|
||||
let (full, untouched) = (out.solid_tile(&[1.0]), out.solid_tile(&[decode8(da)]));
|
||||
let coords: Vec<TileCoord> = b.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&area).is_empty()).collect();
|
||||
// Each tile: `Some(tile)` to store, `None` when it reads as `a`'s untouched value.
|
||||
let built = par_map(coords.len(), |k| {
|
||||
let tc = *coords.get(k)?;
|
||||
let rect = tc.rect();
|
||||
let tr = rect.intersect(&area);
|
||||
let tb = b.tile(tc)?;
|
||||
let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched));
|
||||
let data = tile.bytes_mut();
|
||||
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
|
||||
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
|
||||
let (s, e) = (ly * TS + x0, ly * TS + x1);
|
||||
let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue };
|
||||
for (d, y) in dst.iter_mut().zip(src) {
|
||||
*d = pair(*d, *y);
|
||||
}
|
||||
}
|
||||
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
Some((
|
||||
tc,
|
||||
if lo == da && hi == da {
|
||||
None
|
||||
} else if lo == u8::MAX {
|
||||
Some(Arc::clone(&full))
|
||||
} else {
|
||||
Some(Arc::new(tile))
|
||||
},
|
||||
))
|
||||
});
|
||||
for (tc, tile) in built.into_iter().flatten() {
|
||||
match tile {
|
||||
Some(t) => out.put_tiles([(tc, t)]),
|
||||
// `take_tiles` only allocates for a tile that is missing; this one is there.
|
||||
None if out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -311,4 +365,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add and Subtract through only the new shape's tiles give the bytes of the whole-area
|
||||
/// combination, keep the old selection's other tiles shared, and keep an untouched value
|
||||
/// (a layer mask's reveal-all) outside them.
|
||||
#[test]
|
||||
fn sparse_combination_matches_the_whole_area_one() {
|
||||
let area = Rect::new(0, 0, 1100, 700);
|
||||
let mut rect = Surface::new(PixelFormat::GRAY8);
|
||||
rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]);
|
||||
let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true);
|
||||
let far = ellipse_surface(Rect::from_xywh(900, 20, 150, 150), area, true);
|
||||
let ops: [fn(f32, f32) -> f32; 2] = [|a, b| a.max(b), |a, b| (a - b).max(0.0)];
|
||||
for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell), (&rect, &far)] {
|
||||
for f in ops {
|
||||
let sparse = combine_sparse(a, b, area, f);
|
||||
assert!(sparse.read_region(area) == old_combine(a, b, area, f).read_region(area));
|
||||
assert!(sparse.tiles().all(|(_, t)| t.bytes().iter().any(|v| *v != 0)), "no empty tiles kept");
|
||||
}
|
||||
}
|
||||
let kept = combine_sparse(&rect, &far, area, |a, b| a.max(b));
|
||||
let tc = TileCoord::new(1, 1);
|
||||
assert!(Arc::ptr_eq(kept.tile(tc).expect("kept"), rect.tile(tc).expect("there")), "untouched tiles stay shared");
|
||||
let reveal = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
|
||||
let cut = combine_sparse(&reveal, &far, area, |a, b| (a - b).max(0.0));
|
||||
assert!(cut.pixel(5000, 5000)[0] == 1.0 && cut.pixel(975, 95)[0] == 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,25 @@ const MODELS: [ColorMode; 8] =
|
||||
#[test]
|
||||
fn swept_blocks_match_the_pixel_search() {
|
||||
let bounds = Rect::new(-13, -9, 31, 26);
|
||||
// Unless the heights are level-based, the colour mode reaches extrude only through the channel
|
||||
// count and whether the samples are CMYK ink, so modes that agree on both repeat those cases.
|
||||
// A run takes a quarter of the rest, a different quarter each day.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let sweep = photocraft_testkit::Sweep::new(4);
|
||||
let mut case = 0;
|
||||
for mode in MODELS {
|
||||
for alpha in [false, true] {
|
||||
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
let repeat = !seen.insert((n, alpha, mode == ColorMode::Cmyk));
|
||||
for kind in 0..5 {
|
||||
let src = image(bounds.inflate(20), n, kind);
|
||||
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
|
||||
for flags in 0..8 {
|
||||
for flags in (0..8).filter(|f| !repeat || f & 1 != 0) {
|
||||
case += 1;
|
||||
if !sweep.includes(case) {
|
||||
continue;
|
||||
}
|
||||
let spec = ExtrudeSpec {
|
||||
kind: ExtrudeType::Blocks,
|
||||
size,
|
||||
|
||||
@@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() {
|
||||
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blur_halos_follow_the_clamped_radius_and_never_overflow() {
|
||||
let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() };
|
||||
assert_eq!(smart(1e9).halo(), Halo::Radius(101));
|
||||
assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101));
|
||||
let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY };
|
||||
assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX));
|
||||
}
|
||||
|
||||
@@ -52,11 +52,19 @@ fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() {
|
||||
}
|
||||
cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1));
|
||||
cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15));
|
||||
// The channel count and alpha are independent of the band geometry: a run takes a quarter of
|
||||
// their combinations, a different quarter each day.
|
||||
let sweep = photocraft_testkit::Sweep::new(4);
|
||||
let mut case = 0;
|
||||
for ch in 1..=6 {
|
||||
let src = image(bounds.inflate(12), ch);
|
||||
for alpha in [false, true] {
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
|
||||
for p in &cases {
|
||||
case += 1;
|
||||
if !sweep.includes(case) {
|
||||
continue;
|
||||
}
|
||||
check(&src, bounds, &ctx, p);
|
||||
check(&src, Rect::new(-7, 17, 15, 39), &ctx, p);
|
||||
}
|
||||
@@ -146,6 +154,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
|
||||
selection.fill_rect(bounds, &[0.4]);
|
||||
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
|
||||
selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]);
|
||||
// Pixel formats and tile layouts are independent: a run takes a quarter of their combinations,
|
||||
// a different quarter each day.
|
||||
let sweep = photocraft_testkit::Sweep::new(4);
|
||||
let mut case = 0;
|
||||
for mode in [
|
||||
ColorMode::Rgb,
|
||||
ColorMode::Grayscale,
|
||||
@@ -167,6 +179,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
|
||||
assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius));
|
||||
for extent in [None, Some(bounds)] {
|
||||
for tile in [16, 47, 128] {
|
||||
case += 1;
|
||||
if !sweep.includes(case) {
|
||||
continue;
|
||||
}
|
||||
let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent);
|
||||
let mut want = surface.clone();
|
||||
for y in (area.y0..area.y1).step_by(tile as usize) {
|
||||
|
||||
+28
-1
@@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
|
||||
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
|
||||
/// the remaining tiles; the caller discards the result.
|
||||
/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx).
|
||||
const MAX_DST_TILES: u64 = 1 << 20;
|
||||
/// Source pixels one destination tile may read densely.
|
||||
const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25;
|
||||
|
||||
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
|
||||
let mut fmt = src.format();
|
||||
let converted;
|
||||
@@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
|
||||
y1 = y1.max(v.0[1]);
|
||||
}
|
||||
let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
|
||||
// Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range
|
||||
// would ask for millions of entries before drawing anything: draw nothing instead.
|
||||
let ts_px = photocraft_geom::TILE_SIZE as u64;
|
||||
if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES {
|
||||
return out;
|
||||
}
|
||||
// Triangles: (vertex indices), binned per destination tile.
|
||||
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
|
||||
let mut bins: Vec<Vec<[usize; 3]>> = vec![Vec::new(); tiles.len()];
|
||||
@@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
|
||||
return None;
|
||||
}
|
||||
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect);
|
||||
if foot.is_empty() || !src.has_tiles_in(foot) {
|
||||
// Sparse components far apart in the source can land in one tile: the dense window
|
||||
// between them is not read (the tile stays empty). The pixel-area cap is checked first:
|
||||
// `has_tiles_in` walks the source tile grid, so an oversized footprint must return
|
||||
// without paying for that scan.
|
||||
if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) {
|
||||
return None;
|
||||
}
|
||||
let mut px = src.read_region(foot);
|
||||
@@ -510,6 +525,18 @@ mod tests {
|
||||
surf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() {
|
||||
// About 8000 × 8000 destination tiles from three vertices.
|
||||
let src = sample(SampleType::U8);
|
||||
let far = 1_000_000.0;
|
||||
let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])];
|
||||
let t = std::time::Instant::now();
|
||||
let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest);
|
||||
assert!(out.content_bounds().is_empty());
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
|
||||
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
|
||||
|
||||
@@ -27,7 +27,8 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in
|
||||
sRGB. `content_hash()` compares bytes and suits cache keys only.
|
||||
sRGB. `same_bytes(&other)` compares the ICC bytes. `content_hash()` is a 64-bit hash of them
|
||||
that another profile can share: a cache key, confirmed with `same_bytes`.
|
||||
|
||||
### Evaluation
|
||||
|
||||
|
||||
+24
-2
@@ -293,15 +293,22 @@ fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge
|
||||
|
||||
/// For a flat run at the table start (`start = true`) the last index of the run, or for a flat
|
||||
/// run at the end the first index of it, as a normalised input.
|
||||
///
|
||||
/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long
|
||||
/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's
|
||||
/// length times the image's.
|
||||
fn edge_index(t: &[f32], start: bool) -> f32 {
|
||||
let n = t.len();
|
||||
if start {
|
||||
let v = t[0];
|
||||
let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1);
|
||||
// The first value that differs, given they do so from there on.
|
||||
let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7);
|
||||
let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) };
|
||||
i as f32 / (n - 1) as f32
|
||||
} else {
|
||||
let v = t[n - 1];
|
||||
let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1);
|
||||
// The start of the trailing run equal to the last value.
|
||||
let i = t.partition_point(|x| (*x - v).abs() > 1e-7);
|
||||
i as f32 / (n - 1) as f32
|
||||
}
|
||||
}
|
||||
@@ -362,6 +369,21 @@ mod tests {
|
||||
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() {
|
||||
// Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls.
|
||||
let mut t = vec![0.0f32; 4_000_000];
|
||||
t.push(1.0);
|
||||
let c = Curve::Table(t);
|
||||
let started = std::time::Instant::now();
|
||||
let mut last = 0.0;
|
||||
for _ in 0..200_000 {
|
||||
last = c.eval_inverse(0.0);
|
||||
}
|
||||
assert!(last > 0.99, "the flat start ends at the last zero: {last}");
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_table_matches_per_call_including_flat_runs() {
|
||||
// Batch inverse must equal calling eval_inverse64 per point. Include a table with flat
|
||||
|
||||
+102
-2
@@ -184,6 +184,7 @@ pub struct Profile {
|
||||
impl Profile {
|
||||
/// Parses ICC profile bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Profile, CmsError> {
|
||||
CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES));
|
||||
let mut p = parse_profile(bytes)?;
|
||||
p.bytes = Some(Arc::new(bytes.to_vec()));
|
||||
Ok(p)
|
||||
@@ -211,7 +212,9 @@ impl Profile {
|
||||
self.color_space.channels()
|
||||
}
|
||||
|
||||
/// Hash of the profile's ICC bytes (stable within a process; used as a cache key).
|
||||
/// Hash of the profile's ICC bytes (stable within a process; used as a cache key). It is a
|
||||
/// fixed-key 64-bit hash, so a crafted profile can share another's: equal hashes only narrow
|
||||
/// the search, [`Profile::same_bytes`] decides.
|
||||
pub fn content_hash(&self) -> u64 {
|
||||
*self.hash.get_or_init(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -221,6 +224,16 @@ impl Profile {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` have the same ICC bytes.
|
||||
pub fn same_bytes(&self, other: &Profile) -> bool {
|
||||
if let (Some(a), Some(b)) = (&self.bytes, &other.bytes)
|
||||
&& Arc::ptr_eq(a, b)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.content_hash() == other.content_hash() && self.to_bytes() == other.to_bytes()
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` describe the same colours: same colour space, and every sample
|
||||
/// of a device-value grid has the same colour through both (relative colorimetric, within
|
||||
/// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the
|
||||
@@ -228,7 +241,7 @@ impl Profile {
|
||||
/// parametric curves) differ in every byte. Colours are compared rather than round-tripped
|
||||
/// because CMYK → PCS → CMYK is not an identity even through a single profile.
|
||||
pub fn same_colors(&self, other: &Profile) -> bool {
|
||||
if self.content_hash() == other.content_hash() {
|
||||
if self.same_bytes(other) {
|
||||
return true;
|
||||
}
|
||||
let n = self.channels();
|
||||
@@ -573,6 +586,24 @@ fn parse_profile(bytes: &[u8]) -> Result<Profile, CmsError> {
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Sampled-curve entries one profile may decode in all (64 MB of `f32`). Tags and curve groups can
|
||||
/// point at the same encoded bytes, and each reference decodes its own table.
|
||||
const MAX_CURVE_SAMPLES: usize = 16 << 20;
|
||||
|
||||
thread_local! {
|
||||
/// What is left of [`MAX_CURVE_SAMPLES`] for the profile being parsed on this thread.
|
||||
static CURVE_SAMPLES_LEFT: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_CURVE_SAMPLES) };
|
||||
}
|
||||
|
||||
/// Charges `n` decoded curve samples to the profile being parsed.
|
||||
fn charge_curve_samples(n: usize) -> Result<(), CmsError> {
|
||||
CURVE_SAMPLES_LEFT.with(|left| {
|
||||
let rest = left.get().checked_sub(n).ok_or_else(|| CmsError::Invalid("the profile's curves hold too many samples".into()))?;
|
||||
left.set(rest);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a `curv` or `para` element; returns the curve and the element's byte length
|
||||
/// (unpadded).
|
||||
fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
|
||||
@@ -590,6 +621,7 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
|
||||
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
|
||||
_ => {
|
||||
let s = r.slice(12, n * 2)?;
|
||||
charge_curve_samples(n)?;
|
||||
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
|
||||
Ok((Curve::Table(t), len))
|
||||
}
|
||||
@@ -818,6 +850,7 @@ fn parse_text(d: &[u8]) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::builtin::Builtin;
|
||||
|
||||
fn be(v: u32) -> [u8; 4] {
|
||||
v.to_be_bytes()
|
||||
@@ -903,4 +936,71 @@ mod tests {
|
||||
d.extend(be(u32::MAX));
|
||||
assert_eq!(parse_text(&d), "");
|
||||
}
|
||||
|
||||
/// `p` with the content hash of `other`: the hash is a fixed-key 64-bit SipHash, so a
|
||||
/// crafted profile can have another's (#8613). Setting it stands in for finding one.
|
||||
fn with_hash_of(p: &Profile, other: &Profile) -> Profile {
|
||||
let p = Profile { hash: std::sync::OnceLock::from(other.content_hash()), ..p.clone() };
|
||||
assert_eq!(p.content_hash(), other.content_hash());
|
||||
p
|
||||
}
|
||||
|
||||
/// #8613: a profile that shares sRGB's hash is not taken for sRGB.
|
||||
#[test]
|
||||
fn a_hash_collision_is_not_the_same_colours() {
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb);
|
||||
assert!(!p3.same_bytes(srgb));
|
||||
assert!(!p3.same_colors(srgb));
|
||||
// Controls: equal bytes are the same, whatever object holds them.
|
||||
assert!(srgb.same_bytes(srgb));
|
||||
assert!(srgb.same_bytes(&Profile::parse(&srgb.to_bytes()).unwrap()));
|
||||
assert!(Builtin::DisplayP3.profile().same_colors(Builtin::DisplayP3.profile()));
|
||||
}
|
||||
|
||||
/// #8613: the transform cache does not hand a colliding profile the other's transform.
|
||||
#[test]
|
||||
fn a_hash_collision_does_not_get_the_cached_transform() {
|
||||
use crate::transform::{Transform, TransformOptions, cached};
|
||||
let (srgb, lab) = (Builtin::Srgb.profile(), Builtin::LabD50.profile());
|
||||
let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb);
|
||||
let opts = TransformOptions::default();
|
||||
let green = [0.0, 1.0, 0.0];
|
||||
let eval = |t: &Transform| {
|
||||
let mut out = [0f32; 3];
|
||||
t.eval(&green, &mut out);
|
||||
out
|
||||
};
|
||||
let want = eval(&Transform::with_options(Builtin::DisplayP3.profile(), lab, opts).unwrap());
|
||||
assert_ne!(want, eval(&Transform::with_options(srgb, lab, opts).unwrap()), "P3 green is not sRGB green");
|
||||
let sr = eval(&cached(srgb, lab, opts).unwrap());
|
||||
assert_eq!(eval(&cached(&p3, lab, opts).unwrap()), want);
|
||||
// Control: sRGB still gets its own transform after the colliding lookup.
|
||||
assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod curve_budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliased_curves_are_charged_for_each_decoded_table() {
|
||||
// One 1M-sample `curv` element referenced seventeen times decodes 17M samples.
|
||||
let mut element = b"curv\0\0\0\0".to_vec();
|
||||
element.extend((1u32 << 20).to_be_bytes());
|
||||
element.extend(std::iter::repeat_n(0u8, 2 << 20));
|
||||
CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES));
|
||||
let mut failed_at = None;
|
||||
for i in 0..17 {
|
||||
if parse_curve(&element).is_err() {
|
||||
failed_at = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(failed_at, Some(16), "sixteen tables fit the budget, the seventeenth does not");
|
||||
// Each profile parse starts with a fresh budget.
|
||||
CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES));
|
||||
assert!(parse_curve(&element).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,20 +703,33 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
|
||||
|
||||
type Key = (u64, u64, TransformOptions);
|
||||
|
||||
/// Process-wide cache of transforms keyed by profile content and options.
|
||||
/// A cached transform with the ICC bytes of the profiles it was built from.
|
||||
struct Entry {
|
||||
src: Arc<Vec<u8>>,
|
||||
dst: Arc<Vec<u8>>,
|
||||
transform: Arc<Transform>,
|
||||
}
|
||||
|
||||
/// Process-wide cache of transforms keyed by profile content and options. The key is the
|
||||
/// profiles' 64-bit hashes, which a crafted profile can share with another (#8613), so a hit
|
||||
/// counts only when the bytes match too; a colliding pair gets its own, uncached transform.
|
||||
pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Transform>>>> = OnceLock::new();
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Entry>>> = OnceLock::new();
|
||||
let key = (src.content_hash(), dst.content_hash(), opts);
|
||||
let (src_bytes, dst_bytes) = (src.to_bytes(), dst.to_bytes());
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Ok(t.clone());
|
||||
let hit = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key).map(|e| (e.src == src_bytes && e.dst == dst_bytes).then(|| e.transform.clone()));
|
||||
match hit {
|
||||
Some(Some(t)) => return Ok(t),
|
||||
Some(None) => return Ok(Arc::new(Transform::with_options(src, dst, opts)?)),
|
||||
None => {}
|
||||
}
|
||||
let t = Arc::new(Transform::with_options(src, dst, opts)?);
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 64 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, t.clone());
|
||||
c.insert(key, Entry { src: src_bytes, dst: dst_bytes, transform: t.clone() });
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -137,32 +137,39 @@ enum DataEnd {
|
||||
/// Walks the marker segments (skipped by their length) and the entropy-coded scan data
|
||||
/// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte),
|
||||
/// so an embedded thumbnail's end marker or data after the end of the image don't count.
|
||||
fn data_end(b: &[u8]) -> DataEnd {
|
||||
/// Also returns how many scan-data bytes the walk crossed: the truncated-input budget is
|
||||
/// measured on these, not on the file length, which APP padding can inflate without
|
||||
/// adding image data.
|
||||
fn data_end(b: &[u8]) -> (DataEnd, u64) {
|
||||
// Where the bytes run out: inside the image data once a scan has held some.
|
||||
let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty };
|
||||
let cut = |scan_data: bool, scanned: u64| (if scan_data { DataEnd::Truncated } else { DataEnd::Empty }, scanned);
|
||||
let mut i = 2; // past SOI
|
||||
let mut scan_data = false;
|
||||
let mut scanned = 0u64;
|
||||
loop {
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data) };
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data, scanned) };
|
||||
if byte != 0xFF {
|
||||
// Not where a marker should be: too odd to judge.
|
||||
return DataEnd::Complete;
|
||||
return (DataEnd::Complete, scanned);
|
||||
}
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data) };
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data, scanned) };
|
||||
match code {
|
||||
0xD9 => return DataEnd::Complete,
|
||||
0xD9 => return (DataEnd::Complete, scanned),
|
||||
0xFF => i += 1,
|
||||
0x01 | 0xD0..=0xD8 => i += 2,
|
||||
_ => {
|
||||
let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else {
|
||||
return cut(scan_data);
|
||||
return cut(scan_data, scanned);
|
||||
};
|
||||
i = i.saturating_add(2 + usize::from(len));
|
||||
if code == 0xDA {
|
||||
let start = i;
|
||||
loop {
|
||||
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else {
|
||||
return cut(scan_data || b.len() > start);
|
||||
// EOF inside this scan: everything from the scan start to the end
|
||||
// of the file is scan data (inside a declared segment it may not
|
||||
// be, so only count what remains).
|
||||
return cut(scan_data || b.len() > start, scanned + u64::try_from(b.len().saturating_sub(start)).unwrap_or(0));
|
||||
};
|
||||
i += p;
|
||||
match b.get(i + 1) {
|
||||
@@ -172,6 +179,7 @@ fn data_end(b: &[u8]) -> DataEnd {
|
||||
}
|
||||
}
|
||||
scan_data |= i > start;
|
||||
scanned += u64::try_from(i - start).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,8 +190,12 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few
|
||||
/// bits per 8×8 block, and truncated photographs are far above this.
|
||||
const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048;
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let end = data_end(bytes);
|
||||
let (end, scan_bytes) = data_end(bytes);
|
||||
if end == DataEnd::Empty {
|
||||
return Err(err("the file ends before any image data"));
|
||||
}
|
||||
@@ -204,6 +216,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
_ => (ColorSpace::RGB, ChannelLayout::Rgb),
|
||||
};
|
||||
limits.check(w, h, layout, SampleType::U8)?;
|
||||
// The backend fills whatever the scan no longer describes, so a short scan under a large
|
||||
// frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is
|
||||
// measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily
|
||||
// without describing one more pixel.
|
||||
if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes {
|
||||
return Err(err("the truncated scan is too short for the frame it declares"));
|
||||
}
|
||||
dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs));
|
||||
let mut px = dec.decode().map_err(err)?;
|
||||
let expected = w as usize * h as usize * layout.channels();
|
||||
@@ -435,6 +454,52 @@ mod tests {
|
||||
bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0")
|
||||
}
|
||||
|
||||
/// A tiny truncated scan under a frame patched to 20000×20000 is refused before the backend
|
||||
/// fills 400 million pixels from it; the same file with its real size still recovers.
|
||||
#[test]
|
||||
fn a_short_truncated_scan_cannot_declare_a_huge_frame() {
|
||||
let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap();
|
||||
let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let cut = &full[..full.len() - 4];
|
||||
assert_eq!(data_end(cut).0, DataEnd::Truncated);
|
||||
assert!(crate::decode_as(Format::Jpeg, cut).is_ok());
|
||||
let sof = cut.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
|
||||
let mut huge = cut.to_vec();
|
||||
huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]);
|
||||
let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err();
|
||||
assert!(e.to_string().contains("too short"), "{e}");
|
||||
}
|
||||
|
||||
/// The truncated-scan budget counts scan data, not the file length: APP padding before the
|
||||
/// window cannot admit an oversized output. The same padded file with its real size still
|
||||
/// decodes (with the truncation warning).
|
||||
#[test]
|
||||
fn app_padding_cannot_admit_an_oversized_truncated_frame() {
|
||||
let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap();
|
||||
let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let cut = &full[..full.len() - 4];
|
||||
// Four full-length COMMENT segments (~256 KiB) stand where APP padding would; they are
|
||||
// complete, so the file otherwise parses normally.
|
||||
let fill = [b'p'; 65533];
|
||||
let mut padded = cut[..2].to_vec();
|
||||
for _ in 0..4 {
|
||||
padded.extend_from_slice(&[0xFF, 0xFE]);
|
||||
padded.extend_from_slice(&0xFFFFu16.to_be_bytes());
|
||||
padded.extend_from_slice(&fill);
|
||||
}
|
||||
padded.extend_from_slice(&cut[2..]);
|
||||
assert_eq!(data_end(&padded).0, DataEnd::Truncated);
|
||||
// Well above the frame's pixels / budget, yet far above the scan data: the old
|
||||
// file-length bound admitted this frame.
|
||||
assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small");
|
||||
assert!(crate::decode_as(Format::Jpeg, &padded).is_ok());
|
||||
let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
|
||||
let mut huge = padded.clone();
|
||||
huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]);
|
||||
let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err();
|
||||
assert!(e.to_string().contains("too short"), "{e}");
|
||||
}
|
||||
|
||||
/// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker.
|
||||
#[test]
|
||||
fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() {
|
||||
|
||||
@@ -72,8 +72,31 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
return Err(bad("8-bit indexed PCX has no 256-colour palette"));
|
||||
}
|
||||
let image_end = if vga { b.len() - 769 } else { b.len() };
|
||||
// A 63-byte RLE packet takes 2 bytes; a trailing remainder of 1 byte below 0xC0
|
||||
// can be encoded as a 1-byte literal, while any larger remainder takes at least 2 bytes.
|
||||
// Because RLE packets may span planes within a scanline (`row_len`), compute the
|
||||
// minimum encoded bytes across the full scanline (`row_len`) and multiply by `h`.
|
||||
// Reject inputs that cannot possibly contain all scanlines before allocating
|
||||
// the decoded image buffer.
|
||||
let min_packets = (row_len / 63)
|
||||
.checked_mul(2)
|
||||
.and_then(|n| {
|
||||
n.checked_add(match row_len % 63 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
_ => 2,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| bad("compressed row size overflow"))?;
|
||||
let min_encoded = min_packets.checked_mul(h as usize).ok_or_else(|| bad("compressed image size overflow"))?;
|
||||
let encoded_len = image_end.saturating_sub(HEADER);
|
||||
if encoded_len < min_encoded {
|
||||
return Err(bad("PCX pixel data is too short for the declared image"));
|
||||
}
|
||||
let mut row = vec![0u8; row_len];
|
||||
let mut pixels = vec![0u8; total];
|
||||
let mut pixels = Vec::new();
|
||||
pixels.try_reserve_exact(total).map_err(|_| bad("PCX pixel buffer is too large"))?;
|
||||
pixels.resize(total, 0);
|
||||
let mut p = HEADER;
|
||||
let width = w as usize;
|
||||
for y in 0..h as usize {
|
||||
|
||||
@@ -85,7 +85,9 @@ pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result<Option<Vec<
|
||||
if out.len() >= MAX_GIF_FRAMES {
|
||||
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
|
||||
}
|
||||
if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
|
||||
// Every frame keeps its own copy of the file's ICC profile along with its pixels.
|
||||
let frame_bytes = (size as u64).saturating_add(icc.as_ref().map_or(0, |p| p.len() as u64));
|
||||
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
|
||||
}
|
||||
let delay_ms = match dec.read_frame(&mut buf) {
|
||||
|
||||
@@ -206,11 +206,16 @@ fn decode_lines(lines: &[Vec<u8>], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?;
|
||||
let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?];
|
||||
// Validate all source rows before reserving the decoded RGBA image. A malformed
|
||||
// short row must not be able to make a large header-sized allocation first.
|
||||
if lines[1 + colours..required].iter().any(|line| line.len() != row_len) {
|
||||
return Err(bad("XPM row has the wrong width"));
|
||||
}
|
||||
let total = width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?;
|
||||
let mut pixels = Vec::new();
|
||||
pixels.try_reserve_exact(total).map_err(|_| bad("XPM pixel buffer is too large"))?;
|
||||
pixels.resize(total, 0);
|
||||
for (y, line) in lines[1 + colours..required].iter().enumerate() {
|
||||
if line.len() != row_len {
|
||||
return Err(bad("XPM row has the wrong width"));
|
||||
}
|
||||
for x in 0..width {
|
||||
let key = &line[x * cpp..(x + 1) * cpp];
|
||||
let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?;
|
||||
|
||||
@@ -14,7 +14,9 @@ pub struct Limits {
|
||||
/// Maximum bytes for the decoded pixel buffer (and passed on as the
|
||||
/// allocation budget to underlying decoders).
|
||||
/// PNG also applies this to the aggregate decoded text/XMP UTF-8 bytes,
|
||||
/// including keywords, separately from pixels. This is not a total RSS cap.
|
||||
/// including keywords, separately from pixels. Animated WebP charges
|
||||
/// every retained frame's full pixel copy plus its own copy of the
|
||||
/// file's ICC profile against this budget. This is not a total RSS cap.
|
||||
pub max_alloc: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,25 @@ fn rejects_truncated_and_overshooting_rle_without_panicking() {
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
b[66..68].copy_from_slice(&0u16.to_le_bytes());
|
||||
assert_eq!(detect(&b), None, "invalid stride is not a PCX signature");
|
||||
|
||||
// A solid image with stride of the form 63k + 1 (64, 127) encodes as k full 63-byte runs
|
||||
// plus a 1-byte literal (2k + 1 bytes per plane row), which is valid and must decode.
|
||||
for width in [64u16, 127] {
|
||||
let mut solid = header(8, 3, width, 1, width);
|
||||
for _plane in 0..3 {
|
||||
let mut rem = usize::from(width);
|
||||
while rem >= 63 {
|
||||
solid.extend_from_slice(&[0xff, 0x00]); // 63-byte run of 0x00
|
||||
rem -= 63;
|
||||
}
|
||||
if rem == 1 {
|
||||
solid.push(0x00); // 1-byte literal 0x00
|
||||
}
|
||||
}
|
||||
let im = decode(&solid).unwrap();
|
||||
assert_eq!(im.dimensions(), (u32::from(width), 1));
|
||||
assert!(im.data().iter().all(|&v| v == 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,27 +21,38 @@ pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface {
|
||||
return out;
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let (b, f) = (base.read_region(area), filtered.read_region(area));
|
||||
let mut k = Vec::new();
|
||||
mask.values_into(area, &mut k);
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
|
||||
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if !fmt.alpha {
|
||||
for c in 0..n {
|
||||
op[c] = bp[c] + (fp[c] - bp[c]) * m;
|
||||
}
|
||||
// Tile by tile: the union of two sparse surfaces far apart is a rectangle of mostly nothing,
|
||||
// and transparent pixels in tiles neither surface has stay as they are.
|
||||
let whole = area;
|
||||
let same_default = base.default_pixel() == filtered.default_pixel();
|
||||
for t in whole.tiles() {
|
||||
let area = t.rect().intersect(&whole);
|
||||
let bare = !base.has_tiles_in(area) && !filtered.has_tiles_in(area);
|
||||
if area.is_empty() || (bare && (fmt.alpha || same_default)) {
|
||||
continue;
|
||||
}
|
||||
let a = n - 1;
|
||||
let oa = bp[a] + (fp[a] - bp[a]) * m;
|
||||
op[a] = oa;
|
||||
for c in 0..a {
|
||||
let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m;
|
||||
op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (b, f) = (base.read_region(area), filtered.read_region(area));
|
||||
let mut k = Vec::new();
|
||||
mask.values_into(area, &mut k);
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
|
||||
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if !fmt.alpha {
|
||||
for c in 0..n {
|
||||
op[c] = bp[c] + (fp[c] - bp[c]) * m;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = n - 1;
|
||||
let oa = bp[a] + (fp[a] - bp[a]) * m;
|
||||
op[a] = oa;
|
||||
for c in 0..a {
|
||||
let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m;
|
||||
op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
@@ -61,38 +72,51 @@ pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface {
|
||||
return out;
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let (b, x) = (base.read_region(area), mixed.read_region(area));
|
||||
let mut k = Vec::new();
|
||||
mask.values_into(area, &mut k);
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
|
||||
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if m < HIDDEN {
|
||||
// A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the
|
||||
// stored pixels don't grow past the content.
|
||||
if !fmt.alpha || bp[n - 1] > 0.0 {
|
||||
op.copy_from_slice(bp);
|
||||
// Tile by tile: the union of two sparse surfaces far apart is a rectangle of mostly nothing,
|
||||
// and transparent pixels in tiles neither surface has stay as they are.
|
||||
let whole = area;
|
||||
// Non-alpha still needs every tile whose defaults differ (an unmix through a non-unit mask
|
||||
// would change that default, and the clone already holds the old one).
|
||||
let same_default = base.default_pixel() == mixed.default_pixel();
|
||||
for t in whole.tiles() {
|
||||
let area = t.rect().intersect(&whole);
|
||||
let bare = !base.has_tiles_in(area) && !mixed.has_tiles_in(area);
|
||||
if area.is_empty() || (bare && (fmt.alpha || same_default)) {
|
||||
continue;
|
||||
}
|
||||
let (b, x) = (base.read_region(area), mixed.read_region(area));
|
||||
let mut k = Vec::new();
|
||||
mask.values_into(area, &mut k);
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
|
||||
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if m < HIDDEN {
|
||||
// A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the
|
||||
// stored pixels don't grow past the content.
|
||||
if !fmt.alpha || bp[n - 1] > 0.0 {
|
||||
op.copy_from_slice(bp);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !fmt.alpha {
|
||||
for c in 0..n {
|
||||
op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0);
|
||||
if !fmt.alpha {
|
||||
for c in 0..n {
|
||||
op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = n - 1;
|
||||
let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0);
|
||||
if fa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
op[a] = fa;
|
||||
for c in 0..a {
|
||||
let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m;
|
||||
op[c] = (pm / fa).clamp(0.0, 1.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = n - 1;
|
||||
let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0);
|
||||
if fa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
op[a] = fa;
|
||||
for c in 0..a {
|
||||
let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m;
|
||||
op[c] = (pm / fa).clamp(0.0, 1.0);
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
@@ -122,6 +146,19 @@ mod tests {
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixing_sparse_surfaces_far_apart_does_not_build_their_union() {
|
||||
// 10¹⁰ px between two single pixels would be 160 GB of floats read densely.
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
let (mut base, mut filtered) = (Surface::new(fmt), Surface::new(fmt));
|
||||
base.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]);
|
||||
filtered.write_pixel(100_000, 100_000, &[1.0, 0.0, 0.0, 1.0]);
|
||||
let mixed = mix(&base, &filtered, &LayerMask::reveal_all());
|
||||
assert_eq!(mixed.pixel(100_000, 100_000), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
let back = unmix(&base, &mixed, &LayerMask::reveal_all());
|
||||
assert_eq!(back.pixel(100_000, 100_000), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() {
|
||||
for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] {
|
||||
|
||||
@@ -151,6 +151,22 @@ fn scratch_reuse_preserves_pixels_and_bounds_retention() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Every layer the compositor draws, groups before their children: hidden layers and everything
|
||||
/// inside hidden groups are left out.
|
||||
pub(crate) fn visible_layers(layers: &[Layer]) -> Vec<&Layer> {
|
||||
fn rec<'a>(layers: &'a [Layer], out: &mut Vec<&'a Layer>) {
|
||||
for l in layers.iter().filter(|l| l.visible) {
|
||||
out.push(l);
|
||||
if let Some(children) = l.children() {
|
||||
rec(children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
rec(layers, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Composite the whole document over `rect`, in parallel 256² tiles on
|
||||
/// native targets (single-threaded on wasm).
|
||||
pub fn render(doc: &Document, rect: Rect) -> Buffer {
|
||||
@@ -417,7 +433,11 @@ pub fn flatten(doc: &Document) -> Buffer {
|
||||
}
|
||||
|
||||
/// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated.
|
||||
/// Rects past [`MAX_RENDER_PIXELS`] give an empty buffer.
|
||||
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
}
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
composite_stack_at(
|
||||
@@ -1754,14 +1774,34 @@ fn blend_if_level(x: f32) -> f32 {
|
||||
if (v - r).abs() < 1e-4 { r } else { v }
|
||||
}
|
||||
|
||||
/// Blend If groups inside one another whose own colour is still rendered separately. Each extra
|
||||
/// level multiplies the work by the tree's depth, so the cost is polynomial, not exponential.
|
||||
const MAX_BLEND_IF_GROUP_DEPTH: u32 = 2;
|
||||
|
||||
thread_local! {
|
||||
/// Blend If groups being rendered for their own colour on this thread.
|
||||
static BLEND_IF_GROUPS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn)
|
||||
/// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer.
|
||||
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
|
||||
/// every blend mode's source-over result is linear in the source alpha.
|
||||
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
|
||||
// "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
|
||||
// judged by their result.
|
||||
let own = if matches!(layer.content, LayerContent::Adjustment(_)) { None } else { render_content(layer, out.rect, cx) };
|
||||
// judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend
|
||||
// Ifs: a group's own colour re-composites its children, which doubles the work at every
|
||||
// level of Blend If groups inside one another.
|
||||
let is_group = matches!(layer.content, LayerContent::Group(_));
|
||||
let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get);
|
||||
let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) {
|
||||
None
|
||||
} else {
|
||||
BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group)));
|
||||
let own = render_content(layer, out.rect, cx);
|
||||
BLEND_IF_GROUPS.with(|d| d.set(nested));
|
||||
own
|
||||
};
|
||||
for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() {
|
||||
let this = match &own {
|
||||
Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0),
|
||||
@@ -2260,12 +2300,21 @@ fn fx_region_bytes(layers: &[Layer], canvas: Rect) -> u64 {
|
||||
bytes(layers, canvas, 0)
|
||||
}
|
||||
|
||||
/// Most bytes one layer's effect maps and source may take to build, whatever the cache budget.
|
||||
const MAX_FX_BUILD_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 512 << 20 };
|
||||
|
||||
/// The layer's effect maps over its region (layer bounds grown by the effect reach, within the
|
||||
/// canvas grown likewise; see [`effect_region`]), built once per layer state and shared by every
|
||||
/// tile. `rect` is what the caller composites the layer over (see [`effect_region`]).
|
||||
fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let (region, clipped) = effect_region(layer, rect, cx);
|
||||
let (mut region, clipped) = effect_region(layer, rect, cx);
|
||||
// Non-local effects keep their whole source region, which a few distant pixels stretch over
|
||||
// the canvas: past the cap the effects are left out instead of allocating it.
|
||||
let region_px = u64::from(region.width()) * u64::from(region.height());
|
||||
if (effects::map_count(layer) as u64 + 1).saturating_mul(region_px).saturating_mul(4) > MAX_FX_BUILD_BYTES {
|
||||
region = Rect::EMPTY;
|
||||
}
|
||||
static NO_CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) {
|
||||
let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
|
||||
@@ -112,7 +112,8 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document {
|
||||
/// with it. Empty vector masks have constant coverage, including inversion and density.
|
||||
/// Nonempty paths can still anchor fill layouts even when their mask density is zero.
|
||||
pub fn proxy_faithful(doc: &Document) -> bool {
|
||||
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty()))
|
||||
// Hidden layers (and what they hold) are not composited, so they don't count.
|
||||
crate::visible_layers(&doc.layers).iter().all(|l| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -263,6 +264,25 @@ mod tests {
|
||||
assert!(proxy_faithful(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_layer_with_effects_does_not_rule_out_the_proxy_or_the_uniform_shortcut() {
|
||||
let mut doc = two_colors(Size::new(32, 16), SampleType::U8);
|
||||
doc.layers[1].effects.items.push(Effect::default_drop_shadow());
|
||||
doc.layers[1].effects.enabled = true;
|
||||
assert!(!proxy_faithful(&doc));
|
||||
assert!(crate::uniform::UniformAreas::new(&doc).is_none());
|
||||
doc.layers[1].visible = false;
|
||||
assert!(proxy_faithful(&doc));
|
||||
assert!(crate::uniform::UniformAreas::new(&doc).is_some());
|
||||
// Inside a hidden group too.
|
||||
doc.layers[1].visible = true;
|
||||
let group = Layer::group("g", vec![doc.layers.remove(1)]);
|
||||
doc.layers.push(group);
|
||||
assert!(!proxy_faithful(&doc));
|
||||
doc.layers[1].visible = false;
|
||||
assert!(proxy_faithful(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vector_masks_keep_constant_coverage_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_doc::vector::ShapeLayer;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
|
||||
@@ -118,13 +118,42 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec
|
||||
if r.is_empty() {
|
||||
return;
|
||||
}
|
||||
let float = stroke.format().sample == SampleType::F32;
|
||||
// Tiles none of the three reaches read as the defaults; with transparent defaults the fit
|
||||
// leaves the stroke's default pixel there, so those tiles are exact skips and we can scan
|
||||
// just the union of the three surfaces' allocated tiles instead of every canvas tile (two
|
||||
// sparse regions on a 300k×300k document would otherwise walk ~1.4M empty tiles). A
|
||||
// non-transparent default is everywhere, so the scan falls back to the full rectangle.
|
||||
let whole = r;
|
||||
let bare_transparent = [fill, &*stroke, cache].iter().all(|s| s.default_pixel().iter().all(|v| *v == 0.0));
|
||||
if bare_transparent {
|
||||
let mut coords: Vec<TileCoord> = fill.tiles().map(|(c, _)| *c).chain(stroke.tiles().map(|(c, _)| *c)).chain(cache.tiles().map(|(c, _)| *c)).collect();
|
||||
coords.sort_unstable();
|
||||
coords.dedup();
|
||||
for c in coords {
|
||||
let r = c.rect().intersect(&whole);
|
||||
if !r.is_empty() {
|
||||
fit_stroke_rect(fill, stroke, cache, r, float);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for t in whole.tiles() {
|
||||
let r = t.rect().intersect(&whole);
|
||||
if !r.is_empty() {
|
||||
fit_stroke_rect(fill, stroke, cache, r, float);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`fit_stroke`] over one rectangle.
|
||||
fn fit_stroke_rect(fill: &Surface, stroke: &mut Surface, cache: &Surface, r: Rect, float: bool) {
|
||||
let n = r.width() as usize * r.height() as usize;
|
||||
let (mut f, mut s, mut c) = (vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n]);
|
||||
fill.read_rgba_into(r, &mut f);
|
||||
stroke.read_rgba_into(r, &mut s);
|
||||
cache.read_rgba_into(r, &mut c);
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
let float = stroke.format().sample == SampleType::F32;
|
||||
for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) {
|
||||
let mut p = *sp;
|
||||
if fp[3] < 1.0 - 1e-3 {
|
||||
|
||||
@@ -1761,6 +1761,20 @@ fn vector_mask_combines_with_pixel_mask() {
|
||||
assert!(close4(px(&d, 6, 1), [1.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_maps_over_distant_pixels_are_not_built_over_the_canvas() {
|
||||
// Two pixels 190000 apart: the effect source region spans 3.6·10¹⁰ px.
|
||||
let mut doc = Document::new("big", Size::new(200_000, 200_000), ColorMode::Rgb, SampleType::U8);
|
||||
let mut l = solid_layer("fx", Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(190_000, 190_000, 190_001, 190_001), &[1.0, 0.0, 0.0, 1.0]);
|
||||
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||||
doc.layers.push(l);
|
||||
let t = std::time::Instant::now();
|
||||
let px = render(&doc, Rect::new(0, 0, 4, 4));
|
||||
assert_eq!(px.px.len(), 16);
|
||||
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
|
||||
let mut doc = doc_white(64, 64);
|
||||
@@ -2350,6 +2364,26 @@ fn blend_if_this_layer_hides_by_the_layers_own_value() {
|
||||
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_blend_if_groups_do_not_double_the_work_at_every_level() {
|
||||
// Each Blend If group re-renders its children for its own colour; 40 levels would be 2^40.
|
||||
let mut inner = solid_layer("dot", Rect::new(0, 0, 4, 4), [1.0, 1.0, 1.0, 1.0]);
|
||||
inner.blend_if.set(0, [range([10, 10], [255, 255]), FULL]);
|
||||
let mut layers = vec![inner];
|
||||
for _ in 0..40 {
|
||||
let mut g = Layer::group("g", layers);
|
||||
g.blend_if.set(0, [range([10, 10], [255, 255]), FULL]);
|
||||
layers = vec![g];
|
||||
}
|
||||
// A black backdrop, not doc_white's white: the asserted white can then only come from the
|
||||
// inner layer rendered through the whole group chain, not from the backdrop itself.
|
||||
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::BLACK);
|
||||
d.layers.extend(layers);
|
||||
let t = std::time::Instant::now();
|
||||
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]));
|
||||
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_underlying_layer_hides_by_the_backdrop_value() {
|
||||
// Backdrop black on the left, white on the right; a red layer over all of it.
|
||||
|
||||
@@ -32,7 +32,8 @@ impl<'a> UniformAreas<'a> {
|
||||
return None;
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for (_, _, l) in doc.walk() {
|
||||
// Hidden layers are not composited: one with an effect must not rule the shortcut out.
|
||||
for l in crate::visible_layers(&doc.layers) {
|
||||
let effects = l.effects.enabled && !l.effects.items.is_empty();
|
||||
if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve {
|
||||
return None;
|
||||
|
||||
@@ -241,12 +241,22 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec<Rect> {
|
||||
}
|
||||
}
|
||||
// Merge vertically adjacent runs with the same columns.
|
||||
// `open` finds the slice a run continues by its columns and bottom edge: searching the output
|
||||
// for each run is quadratic in the runs, and bars crossing in a grid leave a million of them.
|
||||
let mut out: Vec<Rect> = Vec::new();
|
||||
let mut open: std::collections::HashMap<(i32, i32, i32), usize> = std::collections::HashMap::new();
|
||||
for r in runs {
|
||||
if let Some(prev) = out.iter_mut().find(|p| p.x0 == r.x0 && p.x1 == r.x1 && p.y1 == r.y0) {
|
||||
prev.y1 = r.y1;
|
||||
} else {
|
||||
out.push(r);
|
||||
match open.remove(&(r.x0, r.x1, r.y0)) {
|
||||
Some(i) => {
|
||||
if let Some(prev) = out.get_mut(i) {
|
||||
prev.y1 = r.y1;
|
||||
}
|
||||
open.insert((r.x0, r.x1, r.y1), i);
|
||||
}
|
||||
None => {
|
||||
open.insert((r.x0, r.x1, r.y1), out.len());
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -62,6 +62,7 @@ photocraft-ipp = { workspace = true }
|
||||
photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
# Synthetic camera raw files for the open tests.
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
photocraft-testkit = { workspace = true }
|
||||
# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
# Synthetic multi-part EXR fixtures for the parts-commands tests.
|
||||
|
||||
@@ -3154,13 +3154,14 @@ macro_rules! adjustment_event {
|
||||
|
||||
const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")];
|
||||
|
||||
/// Whether a Select and Mask step carries Refine Edge Brush strokes, which no descriptor holds.
|
||||
/// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool
|
||||
/// edits (Quick Selection, Brush, Lasso…), which no descriptor holds.
|
||||
fn has_refine_strokes(p: &Value) -> bool {
|
||||
p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty()))
|
||||
p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) || crate::select_mask_edits::has_edits(p)
|
||||
}
|
||||
|
||||
/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush, so a
|
||||
/// step with `refineStrokes` can't be exported faithfully. One with `sampleAllLayers` is not
|
||||
/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush or the
|
||||
/// workspace's tools, so a step with `refineStrokes` or `edits` can't be exported faithfully. One with `sampleAllLayers` is not
|
||||
/// exported here either: it exports as `smartBrushWorkspace`.
|
||||
fn refine_edge_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) {
|
||||
|
||||
@@ -2654,6 +2654,23 @@ fn refine_edge_with_brush_strokes_is_not_exported_without_them() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nor does it hold the workspace's tool edits (Quick Selection, Brush, Lasso…), with or without
|
||||
/// Sample All Layers, so a step with them is reported instead of exported without them.
|
||||
#[test]
|
||||
fn refine_edge_with_workspace_edits_is_not_exported_without_them() {
|
||||
let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4});
|
||||
for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] {
|
||||
let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() };
|
||||
assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}");
|
||||
let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap();
|
||||
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]);
|
||||
}
|
||||
for params in [json!({"radius": 1, "edits": []}), json!({"radius": 1, "edits": null})] {
|
||||
let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() };
|
||||
assert!(export("Audit", &[&plain], &[], false).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_resolution_only_does_not_gain_keep_print_size() {
|
||||
// The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not
|
||||
|
||||
@@ -358,7 +358,9 @@ fn write_shim(droplet: &str) -> Result<String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755));
|
||||
// The shim only has to run as the user who created it; 0o755 would also let every other
|
||||
// local account read and execute it.
|
||||
let _ = std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
Ok(shim)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ pub mod retouch_cmds;
|
||||
pub mod sample_cmds;
|
||||
pub mod sampler_cmds;
|
||||
pub mod select_extra_cmds;
|
||||
pub mod select_mask_edits;
|
||||
pub mod selection_cmds;
|
||||
pub mod shell_commands;
|
||||
pub mod shortcut_sets;
|
||||
|
||||
@@ -183,19 +183,29 @@ fn tiled_clear_and_fill_match_one_pass_over_the_area() {
|
||||
let sr = Rect::new(0, 0, 600, 400);
|
||||
soft.write_region(sr, &(0..sr.height()).flat_map(|_| (0..sr.width()).map(|x| (1.0 - (x as f32 - 256.0).max(0.0) / 300.0).max(0.0))).collect::<Vec<_>>());
|
||||
let canvas = Rect::new(0, 0, 700, 600);
|
||||
// Pixel formats and fill settings are independent: a run takes a quarter of their combinations,
|
||||
// a different quarter each day.
|
||||
let sweep = photocraft_testkit::Sweep::new(4);
|
||||
let mut case = 0;
|
||||
let mut runs = || {
|
||||
case += 1;
|
||||
sweep.includes(case)
|
||||
};
|
||||
for fmt in formats {
|
||||
let s = clear_layer(fmt);
|
||||
for sel in [None, Some(&soft)] {
|
||||
let area = sel.map_or(canvas, |m| m.content_bounds());
|
||||
let what = format!("{fmt:?} selection {}", sel.is_some());
|
||||
let mut got = s.clone();
|
||||
try_clear_surface(&mut got, area, sel).unwrap();
|
||||
assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}");
|
||||
if fmt.alpha {
|
||||
assert!(!same_pixels(&got, &s), "clear {what} changes the layer");
|
||||
if runs() {
|
||||
let mut got = s.clone();
|
||||
try_clear_surface(&mut got, area, sel).unwrap();
|
||||
assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}");
|
||||
if fmt.alpha {
|
||||
assert!(!same_pixels(&got, &s), "clear {what} changes the layer");
|
||||
}
|
||||
}
|
||||
for color in [[1.0, 1.0, 1.0, 1.0], [0.3, 0.5, 0.9, 1.0], [0.3, 0.5, 0.9, 0.4]] {
|
||||
for lock in [true, false] {
|
||||
for lock in [true, false].into_iter().filter(|_| runs()) {
|
||||
let mut got = s.clone();
|
||||
try_fill_surface(&mut got, area, color, sel, lock).unwrap();
|
||||
assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, Some((color, lock)))), "fill {color:?} lock {lock} {what}");
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
//! Select and Mask's own selection tools (#7069, #8148): the edits the workspace makes to the
|
||||
//! selection before it refines the edge, carried as `select.refineEdge`'s `edits` param.
|
||||
//!
|
||||
//! Photoshop's Select and Mask has a Quick Selection tool, a Brush, Object Selection, Lasso and
|
||||
//! Polygonal Lasso, a Select Subject button and Clear Selection / Invert. Here each use is one
|
||||
//! [`SelectionEdit`], applied in order to the selection (or layer mask) Select and Mask starts
|
||||
//! from. Keeping them as params rather than live edits of the document means Cancel discards
|
||||
//! them, OK is one history step, and the UI, CLI, MCP and the control channel all drive them the
|
||||
//! same way. [`apply`] is pure, so the workspace's preview runs exactly what OK commits.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use photocraft_algo::matting;
|
||||
use photocraft_algo::segment::{grabcut, quick, subject};
|
||||
use photocraft_algo::selection::{self as sel, Region, SelectionMode, combine_region};
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::smartselect_cmds::with_doc_sampler;
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const CMD: &str = "select.refineEdge";
|
||||
|
||||
/// Most edits one Select and Mask takes, and points across them.
|
||||
pub const MAX_EDITS: usize = 4096;
|
||||
pub const MAX_EDIT_POINTS: usize = 100_000;
|
||||
|
||||
/// One use of a Select and Mask tool. `add: false` is the tool's subtract mode (Alt).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SelectionEdit {
|
||||
/// Quick Selection: grows a region from the stroke out to the image's edges.
|
||||
Quick { points: Vec<(f32, f32)>, size: f32, add: bool, sample_all_layers: bool },
|
||||
/// Brush: paints the selection directly with a round brush.
|
||||
Brush { points: Vec<(f32, f32)>, size: f32, hardness: f32, add: bool },
|
||||
/// Lasso and Polygonal Lasso: the polygon through `points`.
|
||||
Lasso { points: Vec<(f32, f32)>, add: bool },
|
||||
/// Object Selection: the object in `rect` [x, y, w, h], or the one under `point`.
|
||||
Object { area: ObjectArea, add: bool, sample_all_layers: bool },
|
||||
/// The Select Subject button.
|
||||
Subject { mode: SelectionMode, sample_all_layers: bool },
|
||||
/// Global Refinements › Clear Selection.
|
||||
Clear,
|
||||
/// Global Refinements › Invert.
|
||||
Invert,
|
||||
}
|
||||
|
||||
/// Where Object Selection looks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ObjectArea {
|
||||
Rect(Rect),
|
||||
Point(f32, f32),
|
||||
}
|
||||
|
||||
fn bad(msg: &str) -> EngineError {
|
||||
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// A finite coordinate within the range strokes may use.
|
||||
fn coord(v: &Value) -> Option<f32> {
|
||||
v.as_f64().filter(|v| v.is_finite() && v.abs() <= 1e7).map(|v| v as f32)
|
||||
}
|
||||
|
||||
fn point(v: &Value) -> Option<(f32, f32)> {
|
||||
let xy = v.as_array().filter(|a| a.len() == 2)?;
|
||||
Some((coord(xy.first()?)?, coord(xy.get(1)?)?))
|
||||
}
|
||||
|
||||
fn points(o: &serde_json::Map<String, Value>, min: usize, total: &mut usize) -> Result<Vec<(f32, f32)>> {
|
||||
let list = o.get("points").and_then(Value::as_array).ok_or_else(|| bad("this edit needs a points array of [x,y] pairs"))?;
|
||||
if list.len() < min {
|
||||
return Err(bad(&format!("this edit needs at least {min} point{}", if min == 1 { "" } else { "s" })));
|
||||
}
|
||||
*total = total.saturating_add(list.len());
|
||||
if *total > MAX_EDIT_POINTS {
|
||||
return Err(bad(&format!("edits hold more than {MAX_EDIT_POINTS} points")));
|
||||
}
|
||||
list.iter().map(point).collect::<Option<Vec<_>>>().ok_or_else(|| bad("edit points must be [x,y] pairs of finite numbers"))
|
||||
}
|
||||
|
||||
/// A number in `range`, `default` when absent; anything else is an error naming `key`.
|
||||
fn number(o: &serde_json::Map<String, Value>, key: &str, default: f32, range: std::ops::RangeInclusive<f32>) -> Result<f32> {
|
||||
match o.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.map(|v| v as f32)
|
||||
.filter(|v| v.is_finite() && range.contains(v))
|
||||
.ok_or_else(|| bad(&format!("edit {key} must be a number in {}..{}", range.start(), range.end()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn flag(o: &serde_json::Map<String, Value>, key: &str, default: bool) -> Result<bool> {
|
||||
match o.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(&format!("edit {key} must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tools' `mode`: `add` (the default) or `subtract`.
|
||||
fn adds(o: &serde_json::Map<String, Value>) -> Result<bool> {
|
||||
match o.get("mode").map(Value::as_str) {
|
||||
None | Some(Some("add")) => Ok(true),
|
||||
Some(Some("subtract")) => Ok(false),
|
||||
Some(_) => Err(bad("edit mode must be add|subtract")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `edits` of `p`, in order (none when absent or null).
|
||||
pub fn parse(p: &Value) -> Result<Vec<SelectionEdit>> {
|
||||
let Some(v) = p.get("edits").filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let list = v.as_array().ok_or_else(|| bad("edits must be an array of {\"tool\":…} objects"))?;
|
||||
if list.len() > MAX_EDITS {
|
||||
return Err(bad(&format!("edits holds more than {MAX_EDITS} edits")));
|
||||
}
|
||||
let size_range = matting::BRUSH_SIZE_RANGE;
|
||||
let mut total = 0usize;
|
||||
// The area the Brush edits sweep in all, bounded as `refineStrokes`' is.
|
||||
let mut swept = 0.0f64;
|
||||
let mut edits = Vec::with_capacity(list.len());
|
||||
for e in list {
|
||||
let o = e.as_object().ok_or_else(|| bad("each edit must be an object with a tool"))?;
|
||||
let tool = o.get("tool").and_then(Value::as_str).ok_or_else(|| bad("each edit needs a tool: quick|brush|lasso|object|subject|clear|invert"))?;
|
||||
let edit = match tool {
|
||||
"quick" => SelectionEdit::Quick {
|
||||
points: points(o, 1, &mut total)?,
|
||||
size: number(o, "size", 30.0, size_range.clone())?,
|
||||
add: adds(o)?,
|
||||
sample_all_layers: flag(o, "sampleAllLayers", false)?,
|
||||
},
|
||||
"brush" => {
|
||||
let points = points(o, 1, &mut total)?;
|
||||
let size = number(o, "size", 30.0, size_range.clone())?;
|
||||
swept += matting::stroke_area(&points, size);
|
||||
if swept > matting::MAX_SWEPT_AREA {
|
||||
return Err(bad("brush edits paint too large an area"));
|
||||
}
|
||||
SelectionEdit::Brush { points, size, hardness: number(o, "hardness", 100.0, 0.0..=100.0)?, add: adds(o)? }
|
||||
}
|
||||
"lasso" => SelectionEdit::Lasso { points: points(o, 3, &mut total)?, add: adds(o)? },
|
||||
"object" => {
|
||||
let area = match (o.get("rect"), o.get("point")) {
|
||||
(Some(r), _) => {
|
||||
let v = r.as_array().filter(|a| a.len() == 4).ok_or_else(|| bad("object rect must be [x,y,w,h]"))?;
|
||||
let n = v.iter().map(coord).collect::<Option<Vec<_>>>().ok_or_else(|| bad("object rect must be four finite numbers"))?;
|
||||
let (x, y, w, h) = (n.first().copied(), n.get(1).copied(), n.get(2).copied(), n.get(3).copied());
|
||||
let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad("object rect must be [x,y,w,h]")) };
|
||||
// Negative sizes (dragging up or left) are normalised.
|
||||
let (x0, x1, y0, y1) = (x.min(x + w), x.max(x + w), y.min(y + h), y.max(y + h));
|
||||
ObjectArea::Rect(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32))
|
||||
}
|
||||
(None, Some(pt)) => {
|
||||
let (x, y) = point(pt).ok_or_else(|| bad("object point must be [x,y]"))?;
|
||||
ObjectArea::Point(x, y)
|
||||
}
|
||||
(None, None) => return Err(bad("object needs \"rect\": [x,y,w,h] or \"point\": [x,y]")),
|
||||
};
|
||||
SelectionEdit::Object { area, add: adds(o)?, sample_all_layers: flag(o, "sampleAllLayers", false)? }
|
||||
}
|
||||
"subject" => {
|
||||
let mode = match o.get("mode").map(Value::as_str) {
|
||||
None | Some(Some("replace")) => SelectionMode::Replace,
|
||||
Some(Some("add")) => SelectionMode::Add,
|
||||
Some(Some("subtract")) => SelectionMode::Subtract,
|
||||
Some(_) => return Err(bad("subject mode must be replace|add|subtract")),
|
||||
};
|
||||
SelectionEdit::Subject { mode, sample_all_layers: flag(o, "sampleAllLayers", true)? }
|
||||
}
|
||||
"clear" => SelectionEdit::Clear,
|
||||
"invert" => SelectionEdit::Invert,
|
||||
other => return Err(bad(&format!("unknown edit tool `{other}`: quick|brush|lasso|object|subject|clear|invert"))),
|
||||
};
|
||||
edits.push(edit);
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// Whether `p` carries Select and Mask tool edits.
|
||||
pub fn has_edits(p: &Value) -> bool {
|
||||
p.get("edits").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty()))
|
||||
}
|
||||
|
||||
fn mode(add: bool) -> SelectionMode {
|
||||
if add { SelectionMode::Add } else { SelectionMode::Subtract }
|
||||
}
|
||||
|
||||
/// Most tiles the Brush and Lasso edits may paint in all: four times what one may
|
||||
/// ([`sel::MAX_PAINTED_TILES`]), 1 GiB of GRAY8 tiles. Each is rasterised a tile at a time, so
|
||||
/// this, not the canvas size, bounds what they allocate (the #8783 review).
|
||||
pub const MAX_EDIT_TILES: usize = 4 * sel::MAX_PAINTED_TILES;
|
||||
|
||||
/// Refuses Brush and Lasso edits that would paint too many tiles of `canvas`, one by one or in
|
||||
/// all, before anything is painted.
|
||||
pub fn check_size(edits: &[SelectionEdit], canvas: Rect) -> Result<()> {
|
||||
let mut all = BTreeSet::new();
|
||||
for e in edits {
|
||||
let tiles = match e {
|
||||
SelectionEdit::Brush { points, size, .. } => matting::painted_tiles(points, *size, canvas, sel::MAX_PAINTED_TILES),
|
||||
SelectionEdit::Lasso { points, .. } => sel::polygon_edge_tiles(points, canvas, sel::MAX_PAINTED_TILES),
|
||||
_ => continue,
|
||||
};
|
||||
all.extend(tiles.ok_or_else(|| bad("a brush or lasso edit crosses too many tiles of the canvas"))?);
|
||||
if all.len() > MAX_EDIT_TILES {
|
||||
return Err(bad("brush and lasso edits cross too many tiles of the canvas"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuses Refine Edge Brush strokes that would paint more tiles of `canvas` than
|
||||
/// [`matting::EdgeBrush`] holds.
|
||||
pub fn check_strokes(strokes: &[matting::EdgeStroke], canvas: Rect) -> Result<()> {
|
||||
let refused = || EngineError::BadParams { cmd: CMD.into(), msg: "refineStrokes cross too many tiles of the canvas".into() };
|
||||
let mut all = BTreeSet::new();
|
||||
for s in strokes {
|
||||
all.extend(matting::painted_tiles(&s.points, s.size, canvas, sel::MAX_PAINTED_TILES).ok_or_else(refused)?);
|
||||
if all.len() > sel::MAX_PAINTED_TILES {
|
||||
return Err(refused());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `base` inverted over `canvas`; `None` when that leaves nothing selected. Tile by tile: the
|
||||
/// canvas takes the inverse of `base`'s untouched value (one tile shared by every fully covered
|
||||
/// tile, as `fill_rect` stores it), then only the tiles `base` holds are inverted, so neither a
|
||||
/// sparse selection nor a huge canvas costs a canvas-sized buffer (the #8783 review).
|
||||
fn invert(base: Option<&Surface>, canvas: Rect) -> Option<Surface> {
|
||||
let outside = base.map_or(0.0, |b| b.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0));
|
||||
let mut s = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
if outside < 1.0 {
|
||||
s.fill_rect(canvas, &[1.0 - outside]);
|
||||
}
|
||||
if let Some(b) = base {
|
||||
let ch = b.channels().max(1);
|
||||
for (tc, _) in b.tiles() {
|
||||
let r = tc.rect().intersect(&canvas);
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inverted: Vec<f32> = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect();
|
||||
s.write_region(r, &inverted);
|
||||
}
|
||||
}
|
||||
s.prune();
|
||||
(!s.content_bounds().is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// One edit applied to `base` (the selection so far; `None` = nothing selected) in `doc`, the
|
||||
/// sampling tools reading `layer` (or the composite with Sample All Layers). A tool that finds
|
||||
/// nothing leaves the selection as it was, as the same tools do outside Select and Mask.
|
||||
pub fn apply_one(doc: &Document, layer: Option<LayerId>, base: Option<Surface>, edit: &SelectionEdit) -> Option<Surface> {
|
||||
let canvas = doc.bounds();
|
||||
let combine = |region: Option<Region>, m: SelectionMode| match region {
|
||||
Some(r) => combine_region(base.as_ref(), Some(&r), m),
|
||||
None => base.clone(),
|
||||
};
|
||||
// A painted shape (tile-sparse) added or subtracted, a tile at a time.
|
||||
let paint = |shape: Option<Surface>, add: bool| match (shape.filter(|s| s.tile_count() > 0), base.as_ref()) {
|
||||
(None, _) => base.clone(),
|
||||
(Some(s), None) => add.then_some(s),
|
||||
(Some(s), Some(o)) => {
|
||||
let out = if add { sel::combine_sparse(o, &s, canvas, f32::max) } else { sel::combine_sparse(o, &s, canvas, |a, b| (a - b).max(0.0)) };
|
||||
(!out.content_bounds().is_empty()).then_some(out)
|
||||
}
|
||||
};
|
||||
match edit {
|
||||
SelectionEdit::Quick { points, size, add, sample_all_layers } => {
|
||||
let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| quick::quick_select(smp, doc.bounds(), points, *size));
|
||||
combine(region, mode(*add))
|
||||
}
|
||||
SelectionEdit::Brush { points, size, hardness, add } => paint(matting::brush_surface(points, *size, *hardness, canvas), *add),
|
||||
SelectionEdit::Lasso { points, add } => paint(sel::polygon_surface(points, canvas), *add),
|
||||
SelectionEdit::Object { area, add, sample_all_layers } => {
|
||||
let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| match *area {
|
||||
ObjectArea::Rect(r) => grabcut::object_select(smp, doc.bounds(), r, 160_000),
|
||||
ObjectArea::Point(x, y) => grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX),
|
||||
});
|
||||
combine(region, mode(*add))
|
||||
}
|
||||
SelectionEdit::Subject { mode, sample_all_layers } => {
|
||||
let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| subject::select_subject(smp, doc.bounds()));
|
||||
combine(region, *mode)
|
||||
}
|
||||
SelectionEdit::Clear => None,
|
||||
SelectionEdit::Invert => invert(base.as_ref(), canvas),
|
||||
}
|
||||
}
|
||||
|
||||
/// `edits` applied in order to `base`: the selection Select and Mask refines.
|
||||
/// A run of Inverts is one Invert or none (pairs cancel exactly), so thousands of them cost at
|
||||
/// most one pass each between other edits (the #8783 review).
|
||||
pub fn apply(doc: &Document, layer: Option<LayerId>, base: Option<Surface>, edits: &[SelectionEdit]) -> Option<Surface> {
|
||||
let mut sel = base;
|
||||
let mut invert = false;
|
||||
for e in edits {
|
||||
if *e == SelectionEdit::Invert {
|
||||
invert = !invert;
|
||||
continue;
|
||||
}
|
||||
if std::mem::take(&mut invert) {
|
||||
sel = apply_one(doc, layer, sel, &SelectionEdit::Invert);
|
||||
}
|
||||
sel = apply_one(doc, layer, sel, e);
|
||||
}
|
||||
if invert {
|
||||
sel = apply_one(doc, layer, sel, &SelectionEdit::Invert);
|
||||
}
|
||||
sel
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_every_tool() {
|
||||
let p = json!({"edits": [
|
||||
{"tool": "quick", "points": [[1, 2]], "size": 12, "mode": "subtract", "sampleAllLayers": true},
|
||||
{"tool": "brush", "points": [[1, 2], [3, 4]], "hardness": 50},
|
||||
{"tool": "lasso", "points": [[0, 0], [10, 0], [0, 10]]},
|
||||
{"tool": "object", "rect": [10, 10, -5, -5]},
|
||||
{"tool": "object", "point": [3, 4], "mode": "subtract"},
|
||||
{"tool": "subject", "mode": "add"},
|
||||
{"tool": "clear"},
|
||||
{"tool": "invert"},
|
||||
]});
|
||||
let edits = parse(&p).expect("valid edits");
|
||||
assert_eq!(edits.len(), 8);
|
||||
assert_eq!(edits[0], SelectionEdit::Quick { points: vec![(1.0, 2.0)], size: 12.0, add: false, sample_all_layers: true });
|
||||
assert_eq!(edits[1], SelectionEdit::Brush { points: vec![(1.0, 2.0), (3.0, 4.0)], size: 30.0, hardness: 50.0, add: true });
|
||||
assert_eq!(edits[3], SelectionEdit::Object { area: ObjectArea::Rect(Rect::new(5, 5, 10, 10)), add: true, sample_all_layers: false });
|
||||
assert_eq!(edits[5], SelectionEdit::Subject { mode: SelectionMode::Add, sample_all_layers: true });
|
||||
assert!(parse(&json!({})).expect("none").is_empty());
|
||||
assert!(parse(&json!({"edits": null})).expect("none").is_empty());
|
||||
assert!(has_edits(&p) && !has_edits(&json!({"edits": []})) && !has_edits(&json!({})));
|
||||
// One long brush edit within the area limit is fine; two of them (rejects_hostile_edits) are not.
|
||||
assert!(parse(&json!({"edits": [{"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}]})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_hostile_edits() {
|
||||
let many_points = vec![json!([1, 1]); MAX_EDIT_POINTS + 1];
|
||||
let many_edits = vec![json!({"tool": "clear"}); MAX_EDITS + 1];
|
||||
for bad in [
|
||||
json!("x"),
|
||||
json!([1]),
|
||||
json!([{}]),
|
||||
json!([{"tool": 7}]),
|
||||
json!([{"tool": "wand"}]),
|
||||
json!([{"tool": "quick"}]),
|
||||
json!([{"tool": "quick", "points": []}]),
|
||||
json!([{"tool": "quick", "points": [[1]]}]),
|
||||
json!([{"tool": "quick", "points": [["a", 1]]}]),
|
||||
json!([{"tool": "quick", "points": [[1e300, 1]]}]),
|
||||
json!([{"tool": "quick", "points": [[1, 1]], "size": 0}]),
|
||||
json!([{"tool": "quick", "points": [[1, 1]], "size": "big"}]),
|
||||
json!([{"tool": "quick", "points": [[1, 1]], "mode": "intersect"}]),
|
||||
json!([{"tool": "quick", "points": [[1, 1]], "sampleAllLayers": 1}]),
|
||||
json!([{"tool": "brush", "points": [[1, 1]], "hardness": 101}]),
|
||||
json!([{"tool": "lasso", "points": [[1, 1], [2, 2]]}]),
|
||||
json!([{"tool": "object"}]),
|
||||
json!([{"tool": "object", "rect": [1, 2, 3]}]),
|
||||
json!([{"tool": "object", "point": [1, "y"]}]),
|
||||
json!([{"tool": "subject", "mode": "intersect"}]),
|
||||
json!([{"tool": "brush", "points": many_points}]),
|
||||
json!(many_edits),
|
||||
// Each within the limit, together past it.
|
||||
json!(vec![json!({"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}); 2]),
|
||||
] {
|
||||
assert!(parse(&json!({"edits": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The dense inversion this replaced: the whole canvas read, inverted and written back.
|
||||
fn dense_invert(base: Option<&Surface>, canvas: Rect) -> Vec<u8> {
|
||||
let m: Vec<f32> = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect();
|
||||
bytes(Some(&sel::mask_to_surface(&m, canvas)), canvas)
|
||||
}
|
||||
|
||||
fn bytes(s: Option<&Surface>, canvas: Rect) -> Vec<u8> {
|
||||
sel::mask_from_surface(s, canvas).into_iter().map(|v| (v * 255.0).round() as u8).collect()
|
||||
}
|
||||
|
||||
/// Tile by tile, Invert gives exactly what the dense inversion gave: partial edge tiles, soft
|
||||
/// values, a selection reaching past the canvas, a mask whose untouched pixels are white, and
|
||||
/// nothing selected (everything, inverted).
|
||||
#[test]
|
||||
fn tiled_invert_matches_the_dense_one() {
|
||||
let canvas = Rect::new(0, 0, 600, 333);
|
||||
let mut soft = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
soft.fill_rect(Rect::new(250, 20, 900, 300), &[1.0]);
|
||||
soft.fill_rect(Rect::new(10, 10, 40, 30), &[0.4]);
|
||||
let mut white = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]);
|
||||
white.fill_rect(Rect::new(300, 100, 520, 140), &[0.0]);
|
||||
for base in [None, Some(soft), Some(white)] {
|
||||
let got = bytes(invert(base.as_ref(), canvas).as_ref(), canvas);
|
||||
assert_eq!(got, dense_invert(base.as_ref(), canvas));
|
||||
}
|
||||
// Inverting everything selects nothing.
|
||||
let mut all = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
all.fill_rect(canvas, &[1.0]);
|
||||
assert!(invert(Some(&all), canvas).is_none());
|
||||
}
|
||||
|
||||
/// A huge canvas inverts without a canvas-sized buffer, and a run of Inverts costs one.
|
||||
#[test]
|
||||
fn invert_on_a_huge_canvas_and_runs_of_inverts() {
|
||||
let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
small.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]);
|
||||
let t = std::time::Instant::now();
|
||||
let one = apply(&doc, None, Some(small.clone()), &[SelectionEdit::Invert]).expect("almost everything selected");
|
||||
assert!(one.sample_channel(15, 15, 0) < 0.01 && one.sample_channel(99_999, 99_999, 0) > 0.99 && one.sample_channel(50_000, 5, 0) > 0.99);
|
||||
let odd = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4095]).expect("inverted");
|
||||
assert!(odd.sample_channel(15, 15, 0) < 0.01 && odd.sample_channel(99_999, 99_999, 0) > 0.99);
|
||||
let even = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4096]).expect("the selection itself");
|
||||
assert!(even.sample_channel(15, 15, 0) > 0.99 && even.sample_channel(50_000, 50_000, 0) < 0.01);
|
||||
// Runs split by another edit invert on each side of it.
|
||||
let split = [SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Clear, SelectionEdit::Invert];
|
||||
let all = apply(&doc, None, Some(small), &split).expect("cleared, then everything");
|
||||
assert!(all.sample_channel(15, 15, 0) > 0.99 && all.sample_channel(70_000, 3, 0) > 0.99);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// The #8783 review: a size-1 Brush edit from corner to corner of a 100,000 px canvas sweeps
|
||||
/// about 141,000 px but its bounding box is 10 billion; it, a Lasso sliver along it and their
|
||||
/// subtraction paint only the tiles they cross, and keep the selection there was.
|
||||
#[test]
|
||||
fn thin_edits_across_a_huge_canvas_paint_only_their_tiles() {
|
||||
let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let canvas = doc.bounds();
|
||||
let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
small.fill_rect(Rect::new(10, 60, 20, 70), &[1.0]);
|
||||
let diagonal = vec![(0.0, 0.0), (99_999.0, 99_999.0)];
|
||||
let brush = SelectionEdit::Brush { points: diagonal.clone(), size: 1.0, hardness: 100.0, add: true };
|
||||
let lasso = SelectionEdit::Lasso { points: vec![(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)], add: true };
|
||||
assert!(check_size(&[brush.clone(), lasso.clone()], canvas).is_ok());
|
||||
let t = std::time::Instant::now();
|
||||
for edit in [&brush, &lasso] {
|
||||
let s = apply(&doc, None, Some(small.clone()), std::slice::from_ref(edit)).expect("painted");
|
||||
assert!(s.tile_count() < 1500, "{}", s.tile_count());
|
||||
assert!(s.sample_channel(15, 65, 0) > 0.99 && s.sample_channel(50_000, 50_000, 0) > 0.0 && s.sample_channel(50_000, 40_000, 0) == 0.0);
|
||||
}
|
||||
let erased =
|
||||
apply(&doc, None, Some(small.clone()), &[brush, SelectionEdit::Brush { points: diagonal, size: 9.0, hardness: 100.0, add: false }]).expect("kept");
|
||||
assert!(erased.sample_channel(15, 65, 0) > 0.99 && erased.sample_channel(50_000, 50_000, 0) == 0.0);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
// A zigzag across more tiles than one edit may paint is refused before painting.
|
||||
let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect();
|
||||
let over = [SelectionEdit::Brush { points: zigzag.clone(), size: 1.0, hardness: 100.0, add: true }];
|
||||
assert!(check_size(&over, canvas).is_err());
|
||||
assert!(check_size(&[SelectionEdit::Lasso { points: zigzag, add: true }], canvas).is_err());
|
||||
// So are many edits that each fit but cross too many tiles in all.
|
||||
let rows: Vec<SelectionEdit> = (0..48)
|
||||
.map(|i| SelectionEdit::Brush { points: vec![(0.0, i as f32 * 2000.0), (99_999.0, i as f32 * 2000.0)], size: 1.0, hardness: 100.0, add: true })
|
||||
.collect();
|
||||
assert!(rows.iter().all(|e| check_size(std::slice::from_ref(e), canvas).is_ok()));
|
||||
assert!(check_size(&rows, canvas).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::select_mask_edits;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -398,8 +399,9 @@ fn refine_strokes(p: &Value) -> Result<Vec<matting::EdgeStroke>> {
|
||||
Ok(strokes)
|
||||
}
|
||||
|
||||
/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on
|
||||
/// the active layer, a new layer or a new document (each with or without a mask).
|
||||
/// Select and Mask: applies the workspace's tool `edits` to the selection (or layer mask), refines
|
||||
/// its edge, then outputs to the selection, a layer mask on the active layer, a new layer or a new
|
||||
/// document (each with or without a mask).
|
||||
/// Decontaminating colours needs new pixels, so it upgrades `selection` / `layerMask` output to
|
||||
/// `newLayerWithMask` (as Photoshop does). New-layer outputs hide the source layer; new-document
|
||||
/// outputs leave the source document untouched.
|
||||
@@ -419,13 +421,30 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if decontaminate && matches!(output.as_str(), "selection" | "layerMask") {
|
||||
output = "newLayerWithMask".into();
|
||||
}
|
||||
let edits = select_mask_edits::parse(p)?;
|
||||
// Before anything is painted: a thin stroke across a huge canvas paints few pixels but many tiles.
|
||||
select_mask_edits::check_size(&edits, d.doc.bounds())?;
|
||||
select_mask_edits::check_strokes(&strokes, d.doc.bounds())?;
|
||||
let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| {
|
||||
let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface);
|
||||
let target_mask = p.get("target").and_then(Value::as_str) == Some("mask");
|
||||
let sel = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }?;
|
||||
let start = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) };
|
||||
// The workspace's tool edits change the selection before its edge is refined.
|
||||
let edited = if edits.is_empty() { None } else { select_mask_edits::apply(doc, active, start.cloned(), &edits) };
|
||||
let Some(sel) = (if edits.is_empty() { start } else { edited.as_ref() }) else { return Ok(None) };
|
||||
let brush = matting::EdgeBrush::new(&strokes, doc.bounds());
|
||||
matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms, brush.as_ref())
|
||||
})?;
|
||||
// The refined result is dense over the selection's bounds: refuse what would not fit
|
||||
// rather than allocate it (a thin edit across a huge canvas; the #8783 review).
|
||||
let content = sel.content_bounds();
|
||||
let area = matting::refine_area(content, doc.bounds(), ¶ms, brush.as_ref());
|
||||
if u64::from(area.width()) * u64::from(area.height()) > matting::MAX_REFINE_AREA {
|
||||
return Err(bad(
|
||||
"select.refineEdge",
|
||||
&format!("the selection is too large to refine ({} × {} px; at most {} px in all)", area.width(), area.height(), matting::MAX_REFINE_AREA),
|
||||
));
|
||||
}
|
||||
Ok(matting::refine_mask_brushed(smp, &matting::surface_reader(sel), content, doc.bounds(), ¶ms, brush.as_ref()))
|
||||
})??;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some());
|
||||
match output.as_str() {
|
||||
@@ -532,7 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"select.refineEdge",
|
||||
"Refine Edge",
|
||||
[],
|
||||
r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##,
|
||||
r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[],"edits":[{"tool":"quick","points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"brush","points":[[x,y],…],"size":px=30,"hardness":0..100=100,"mode":"add|subtract"="add"} | {"tool":"lasso","points":[[x,y],…≥3],"mode":"add|subtract"="add"} | {"tool":"object","rect":[x,y,w,h] | "point":[x,y],"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"subject","mode":"replace|add|subtract"="replace","sampleAllLayers":bool=true} | {"tool":"clear"} | {"tool":"invert"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there; edits: the workspace's Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert, applied in order to the selection before its edge is refined)"##,
|
||||
has_selection_or_mask,
|
||||
refine_edge
|
||||
),
|
||||
@@ -1065,4 +1084,174 @@ mod tests {
|
||||
s.execute("select.focusArea", json!({})).unwrap();
|
||||
assert!(cov(&s, 30, 50) >= 0.5 && cov(&s, 130, 50) < 0.5);
|
||||
}
|
||||
/// The selection as 8-bit coverage over `w × h`, to compare two selections exactly.
|
||||
fn sel_bytes(s: &Session, w: i32, h: i32) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect()
|
||||
}
|
||||
|
||||
/// The #8783 review: a refine whose result would be dense over more than
|
||||
/// [`matting::MAX_REFINE_AREA`] pixels is refused, not allocated: a thin Brush edit across a
|
||||
/// huge canvas (its edited selection spans the canvas) and a selection of the whole canvas.
|
||||
/// The selection stays as it was.
|
||||
#[test]
|
||||
fn refine_edge_refuses_a_selection_too_large_to_refine() {
|
||||
let mut s = Session::new();
|
||||
s.add_document(
|
||||
Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8),
|
||||
None,
|
||||
);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 60, "width": 10, "height": 10})).unwrap();
|
||||
let small = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
|
||||
let diagonal = json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]});
|
||||
let t = std::time::Instant::now();
|
||||
let e = s.execute("select.refineEdge", diagonal).expect_err("too large to refine");
|
||||
assert!(e.to_string().contains("too large to refine"), "{e}");
|
||||
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&small), "the selection is unchanged");
|
||||
// The small selection itself still refines.
|
||||
s.execute("select.refineEdge", json!({"radius": 4})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
let all = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
|
||||
assert!(s.execute("select.refineEdge", json!({"radius": 4})).is_err());
|
||||
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&all));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint
|
||||
/// too many tiles of a huge canvas are refused before anything is painted, and the selection
|
||||
/// stays as it was.
|
||||
#[test]
|
||||
fn refine_edge_refuses_edits_that_cross_too_many_tiles() {
|
||||
let mut s = Session::new();
|
||||
s.add_document(
|
||||
Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8),
|
||||
None,
|
||||
);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
let before = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
|
||||
let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0]).collect();
|
||||
for p in [
|
||||
json!({"edits": [{"tool": "brush", "points": zigzag, "size": 1}]}),
|
||||
json!({"edits": [{"tool": "lasso", "points": zigzag}]}),
|
||||
json!({"refineStrokes": [{"points": zigzag, "size": 1}]}),
|
||||
] {
|
||||
let e = s.execute("select.refineEdge", p.clone()).expect_err("refused");
|
||||
assert!(e.to_string().contains("too many tiles"), "{p}: {e}");
|
||||
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&before), "{p}: the selection is unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
/// Select and Mask's own tools (#7069, #8148): each edit changes the selection before the
|
||||
/// edge is refined, at every depth, and the whole run is one history step.
|
||||
#[test]
|
||||
fn refine_edge_edits_apply_each_tool_at_every_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = two_regions(depth);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
let start = sel_bytes(&s, 140, 100);
|
||||
let run = |s: &mut Session, edits: Value| {
|
||||
s.execute("select.refineEdge", json!({"edits": edits})).unwrap();
|
||||
let after = sel_bytes(s, 140, 100);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(sel_bytes(s, 140, 100), start, "depth {depth}: one undo restores the selection");
|
||||
after
|
||||
};
|
||||
let at = |b: &[u8], x: usize, y: usize| b[y * 140 + x];
|
||||
// Quick Selection grows across the cool region; subtracting it again leaves the start.
|
||||
let quick = run(&mut s, json!([{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}]));
|
||||
assert!(at(&quick, 120, 80) >= 128 && at(&quick, 5, 5) >= 128 && at(&quick, 40, 60) < 128, "depth {depth}: quick");
|
||||
let back = run(
|
||||
&mut s,
|
||||
json!([{"tool": "quick", "points": [[100, 50]], "size": 8}, {"tool": "quick", "points": [[100, 50]], "size": 8, "mode": "subtract"}]),
|
||||
);
|
||||
assert!(at(&back, 120, 80) < 128 && at(&back, 5, 5) >= 128, "depth {depth}: quick subtract");
|
||||
// The Brush paints exactly where it goes.
|
||||
let brush = run(&mut s, json!([{"tool": "brush", "points": [[30, 80], [60, 80]], "size": 10}]));
|
||||
assert!(at(&brush, 45, 80) == 255 && at(&brush, 45, 90) == 0, "depth {depth}: brush");
|
||||
let erased = run(&mut s, json!([{"tool": "brush", "points": [[10, 10]], "size": 6, "mode": "subtract"}]));
|
||||
assert!(at(&erased, 10, 10) == 0 && at(&erased, 2, 2) == 255, "depth {depth}: brush subtract");
|
||||
// Lasso adds and subtracts polygons.
|
||||
let lasso = run(&mut s, json!([{"tool": "lasso", "points": [[50, 50], [90, 50], [90, 90], [50, 90]]}]));
|
||||
assert!(at(&lasso, 70, 70) == 255 && at(&lasso, 95, 70) == 0 && at(&lasso, 5, 5) == 255, "depth {depth}: lasso");
|
||||
let cut = run(&mut s, json!([{"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}]));
|
||||
assert!(at(&cut, 5, 5) == 0 && at(&cut, 15, 15) == 255, "depth {depth}: lasso subtract");
|
||||
// Clear Selection, then Invert of nothing: everything.
|
||||
let all = run(&mut s, json!([{"tool": "clear"}, {"tool": "invert"}]));
|
||||
assert!(all.iter().all(|v| *v == 255), "depth {depth}: clear + invert");
|
||||
let inverted = run(&mut s, json!([{"tool": "invert"}]));
|
||||
assert!(at(&inverted, 5, 5) == 0 && at(&inverted, 100, 50) == 255, "depth {depth}: invert");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object Selection and Select Subject inside the workspace find what the standalone tools find.
|
||||
#[test]
|
||||
fn refine_edge_edits_object_and_subject() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, inside) = disc(depth);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"edits": [{"tool": "object", "rect": [40, 20, 120, 120]}, {"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}]})).unwrap();
|
||||
let v = sel_iou(&s, 200, 160, &inside);
|
||||
assert!(v > 0.95, "depth {depth}: object rect IoU {v}");
|
||||
s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}, {"tool": "object", "point": [3, 3]}, {"tool": "object", "point": [100, 80]}]}))
|
||||
.unwrap();
|
||||
let v = sel_iou(&s, 200, 160, &inside);
|
||||
assert!(v > 0.9, "depth {depth}: object point IoU {v} (the background point finds nothing)");
|
||||
}
|
||||
let (mut s, inside) = disc(8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"edits": [{"tool": "subject"}]})).unwrap();
|
||||
let v = sel_iou(&s, 200, 160, &inside);
|
||||
assert!(v > 0.9, "subject replaces the selection: IoU {v}");
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"edits": [{"tool": "subject", "mode": "add"}]})).unwrap();
|
||||
assert!(cov(&s, 5, 5) >= 0.5 && cov(&s, 100, 80) >= 0.5, "subject added to the selection");
|
||||
}
|
||||
|
||||
/// An edit gives what the standalone tool gives, then the edge is refined as usual, and the
|
||||
/// result reaches every output.
|
||||
#[test]
|
||||
fn refine_edge_edits_match_the_tools_and_reach_the_outputs() {
|
||||
let mut tool = two_regions(8);
|
||||
tool.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
tool.execute("select.quick", json!({"points": [[100, 50], [110, 50]], "size": 8})).unwrap();
|
||||
let mut ws = two_regions(8);
|
||||
ws.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
ws.execute("select.refineEdge", json!({"edits": [{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}]})).unwrap();
|
||||
assert_eq!(sel_bytes(&ws, 140, 100), sel_bytes(&tool, 140, 100));
|
||||
// Refinement runs on the edited selection: Shift Edge contracts the brushed bar too.
|
||||
let mut s = two_regions(8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})).unwrap();
|
||||
assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 88) < 0.5 && cov(&s, 60, 72) < 0.5, "the 20 px bar shrank");
|
||||
// Layer mask and new-layer outputs take the edited selection.
|
||||
let mut s = blurred_edge_selected();
|
||||
let r = s.execute("select.refineEdge", json!({"output": "layerMask", "edits": [{"tool": "invert"}]})).unwrap();
|
||||
assert_eq!(r["output"], "layerMask");
|
||||
let m = s.active().unwrap().doc.layers[0].mask.as_ref().unwrap();
|
||||
assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99);
|
||||
let mut s = blurred_edge_selected();
|
||||
let r = s.execute("select.refineEdge", json!({"output": "newLayerWithMask", "edits": [{"tool": "invert"}]})).unwrap();
|
||||
assert_eq!(r["output"], "newLayerWithMask");
|
||||
let d = s.active().unwrap();
|
||||
let m = d.doc.layers.iter().find_map(|l| l.mask.as_ref()).unwrap();
|
||||
assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99);
|
||||
// Clearing everything deselects.
|
||||
let mut s = blurred_edge_selected();
|
||||
let r = s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}]})).unwrap();
|
||||
assert_eq!(r["selected"], false);
|
||||
assert!(s.active().unwrap().doc.selection.is_none());
|
||||
}
|
||||
|
||||
/// Bad edits are refused before anything changes.
|
||||
#[test]
|
||||
fn refine_edge_rejects_bad_edits_and_keeps_the_selection() {
|
||||
let mut s = blurred_edge_selected();
|
||||
let before = sel_bytes(&s, 80, 40);
|
||||
for bad in
|
||||
[json!(7), json!([{"tool": "nope"}]), json!([{"tool": "lasso", "points": [[0, 0]]}]), json!([{"tool": "brush", "points": [[0, 0]], "size": -1}])]
|
||||
{
|
||||
assert!(s.execute("select.refineEdge", json!({"edits": bad})).is_err(), "{bad}");
|
||||
assert_eq!(sel_bytes(&s, 80, 40), before, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,32 @@ use serde_json::{Value, json};
|
||||
|
||||
/// Documents in different states: a Background only, a pixel layer active, a group active, a
|
||||
/// selection, no document.
|
||||
fn states() -> Vec<(&'static str, Session)> {
|
||||
let mut out = Vec::new();
|
||||
out.push(("no document", Session::new()));
|
||||
const STATES: [&str; 5] = ["no document", "background only", "pixel layer", "group, no background", "selection"];
|
||||
|
||||
/// A fresh session in `state`.
|
||||
fn state(state: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
out.push(("background only", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
out.push(("pixel layer", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.group", json!({})).unwrap();
|
||||
out.push(("group, no background", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
out.push(("selection", s));
|
||||
out
|
||||
if state == "no document" {
|
||||
return s;
|
||||
}
|
||||
let background = if state == "group, no background" { "transparent" } else { "white" };
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": background})).unwrap();
|
||||
match state {
|
||||
"pixel layer" => {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
}
|
||||
"group, no background" => {
|
||||
s.execute("layer.new.group", json!({})).unwrap();
|
||||
}
|
||||
"selection" => {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn params() -> Vec<Value> {
|
||||
@@ -63,13 +67,13 @@ fn precheck_agrees_with_execute() {
|
||||
let mut disagreements = Vec::new();
|
||||
for id in PRECHECKED {
|
||||
for p in params() {
|
||||
for (state, _) in states() {
|
||||
// A fresh copy of the state for each call: execute changes it.
|
||||
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
|
||||
for name in STATES {
|
||||
// A fresh session for each call: execute changes it.
|
||||
let mut s = state(name);
|
||||
let pre = s.precheck(id, &p).is_ok();
|
||||
let ran = s.execute(id, p.clone()).is_ok();
|
||||
if pre != ran {
|
||||
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
|
||||
disagreements.push(format!("{id} {p} on {name}: precheck {pre}, execute {ran}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,19 @@ fn keeps_the_original_file_mode() {
|
||||
assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_file_follows_the_process_umask() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = TempDir::new("umask");
|
||||
let probe = d.0.join("probe.psd");
|
||||
std::fs::File::create(&probe).unwrap();
|
||||
let expected = std::fs::metadata(&probe).unwrap().permissions().mode() & 0o777;
|
||||
let p = d.0.join("fresh.psd");
|
||||
atomic_write(&p, b"new").unwrap();
|
||||
assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, expected);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writes_through_a_symlink() {
|
||||
|
||||
@@ -20,7 +20,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use photocraft_doc::Document;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::write_atomic;
|
||||
use crate::{FormatError, PcraftWriter, Result, SaveOptions, SaveStats};
|
||||
|
||||
/// Sidecar describing an autosave.
|
||||
@@ -168,7 +167,7 @@ fn worker(rx: Receiver<Job>, bundle: PathBuf, sidecar: PathBuf, last: Arc<Mutex<
|
||||
let r = (|| {
|
||||
std::fs::create_dir_all(&bundle)?;
|
||||
let stats = writer.save_dir(&job.snapshot, &bundle, &job.opts)?;
|
||||
write_atomic(&sidecar, &serde_json::to_vec_pretty(&job.info)?)?;
|
||||
write_sidecar(&sidecar, &serde_json::to_vec_pretty(&job.info)?)?;
|
||||
Ok(stats)
|
||||
})();
|
||||
if let Ok(mut results) = completed.lock() {
|
||||
@@ -180,6 +179,72 @@ fn worker(rx: Receiver<Job>, bundle: PathBuf, sidecar: PathBuf, last: Arc<Mutex<
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sidecar(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
struct PrivateFs;
|
||||
impl crate::atomic::AtomicFs for PrivateFs {
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
crate::atomic::sync_file(&f)
|
||||
}
|
||||
fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.rename(from, to)
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.remove(path)
|
||||
}
|
||||
fn sync_dir(&self, dir: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.sync_dir(dir)
|
||||
}
|
||||
fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.copy_metadata(from, to)
|
||||
}
|
||||
}
|
||||
crate::atomic::atomic_write_with(&PrivateFs, crate::atomic::RenameRetry::platform(), path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
crate::store::write_atomic(path, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sidecar_bounded(path: &Path, max: usize) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
#[cfg(unix)]
|
||||
let mut file = {
|
||||
use rustix::fs::{Mode, OFlags, open};
|
||||
let fd = open(path, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).ok()?;
|
||||
let file = std::fs::File::from(fd);
|
||||
if !file.metadata().ok()?.is_file() {
|
||||
return None;
|
||||
}
|
||||
let _ = rustix::fs::fcntl_setfl(&file, OFlags::empty());
|
||||
file
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut file = {
|
||||
if !std::fs::symlink_metadata(path).ok()?.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if !file.metadata().ok()?.is_file() {
|
||||
return None;
|
||||
}
|
||||
file
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
(&mut file).take((max as u64).saturating_add(1)).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() > max {
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Recoverable documents in `recovery_dir`, newest first. A sidecar must be named after its own
|
||||
/// (sanitized) key, so a stray or hand-edited one can never point outside the directory.
|
||||
pub fn list_recovery(recovery_dir: &Path) -> Vec<RecoveryEntry> {
|
||||
@@ -189,8 +254,9 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec<RecoveryEntry> {
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().is_some_and(|x| x == "json")
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
if e.file_type().is_ok_and(|ft| ft.is_file())
|
||||
&& p.extension().is_some_and(|x| x == "json")
|
||||
&& let Some(bytes) = read_sidecar_bounded(&p, 1 << 20)
|
||||
&& let Ok(info) = serde_json::from_slice::<RecoveryInfo>(&bytes)
|
||||
&& sanitize(&info.key) == info.key
|
||||
&& p.file_stem().is_some_and(|stem| *stem == *info.key)
|
||||
@@ -402,6 +468,9 @@ fn untouched_for(path: &Path, min_age: std::time::Duration, missing_ok: bool) ->
|
||||
}
|
||||
|
||||
fn remove_entry(dir: &Path, key: &str) -> Result<()> {
|
||||
if sanitize(key) != key {
|
||||
return Err(FormatError::corrupt("recovery entry key contains path components"));
|
||||
}
|
||||
let bundle = dir.join(format!("{key}.pcraft"));
|
||||
if bundle.exists() {
|
||||
std::fs::remove_dir_all(bundle)?;
|
||||
|
||||
@@ -23,8 +23,28 @@ pub fn read_file(path: &Path) -> io::Result<Vec<u8>> {
|
||||
|
||||
/// [`read_file`] with a chosen read size (tests use small ones to exercise the loop).
|
||||
pub fn read_file_with(path: &Path, chunk: usize) -> io::Result<Vec<u8>> {
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
#[cfg(unix)]
|
||||
let (mut f, metadata) = {
|
||||
use rustix::fs::{Mode, OFlags, open};
|
||||
let fd = open(path, OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).map_err(io::Error::from)?;
|
||||
let f = std::fs::File::from(fd);
|
||||
let metadata = f.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file"));
|
||||
}
|
||||
rustix::fs::fcntl_setfl(&f, OFlags::empty()).map_err(io::Error::from)?;
|
||||
(f, metadata)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let (mut f, metadata) = {
|
||||
let f = std::fs::File::open(path)?;
|
||||
let metadata = f.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file"));
|
||||
}
|
||||
(f, metadata)
|
||||
};
|
||||
let len = metadata.len();
|
||||
read_all_with(&mut f, len, chunk)
|
||||
}
|
||||
|
||||
|
||||
@@ -598,7 +598,14 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atom
|
||||
let mut temporary = None;
|
||||
for _ in 0..8 {
|
||||
let candidate = parent.join(crate::atomic::temp_name(&name.to_string_lossy()));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) {
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
match opts.open(&candidate) {
|
||||
Ok(file) => {
|
||||
temporary = Some((candidate, file));
|
||||
break;
|
||||
|
||||
@@ -96,6 +96,14 @@ impl Rect {
|
||||
pub fn inflate(&self, d: i32) -> Rect {
|
||||
Rect::new(self.x0.saturating_sub(d), self.y0.saturating_sub(d), self.x1.saturating_add(d), self.y1.saturating_add(d))
|
||||
}
|
||||
/// How many tiles overlap this rect, without visiting them (a rect can span billions).
|
||||
pub fn tile_count(&self) -> u64 {
|
||||
if self.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let (a, b) = (TileCoord::containing(self.x0, self.y0), TileCoord::containing(self.x1 - 1, self.y1 - 1));
|
||||
((i64::from(b.tx) - i64::from(a.tx) + 1) as u64) * ((i64::from(b.ty) - i64::from(a.ty) + 1) as u64)
|
||||
}
|
||||
/// All tiles overlapping this rect.
|
||||
pub fn tiles(&self) -> impl Iterator<Item = TileCoord> + use<> {
|
||||
let (a, b) = if self.is_empty() {
|
||||
|
||||
+86
-16
@@ -581,38 +581,50 @@ impl BezierMesh {
|
||||
|
||||
/// Control points per row.
|
||||
pub fn nx(&self) -> usize {
|
||||
3 * (self.us.len() - 1) + 1
|
||||
3usize.saturating_mul(self.us.len().saturating_sub(1)).saturating_add(1)
|
||||
}
|
||||
/// Control point rows.
|
||||
pub fn ny(&self) -> usize {
|
||||
3 * (self.vs.len() - 1) + 1
|
||||
3usize.saturating_mul(self.vs.len().saturating_sub(1)).saturating_add(1)
|
||||
}
|
||||
pub fn point(&self, i: usize, j: usize) -> [f64; 2] {
|
||||
self.points[j * self.nx() + i]
|
||||
self.points.get(j.saturating_mul(self.nx()).saturating_add(i)).copied().unwrap_or([0.0; 2])
|
||||
}
|
||||
|
||||
/// Checks the invariants (knots and point count); malformed data from files is rejected.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let knots_ok = |k: &[f64]| k.len() >= 2 && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]);
|
||||
knots_ok(&self.us)
|
||||
&& knots_ok(&self.vs)
|
||||
&& self.points.len() == self.nx() * self.ny()
|
||||
&& self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite())
|
||||
let knots_ok = |k: &[f64]| (2..=65).contains(&k.len()) && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]);
|
||||
if !knots_ok(&self.us) || !knots_ok(&self.vs) {
|
||||
return false;
|
||||
}
|
||||
let Some(nx) = (self.us.len() - 1).checked_mul(3).and_then(|v| v.checked_add(1)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(ny) = (self.vs.len() - 1).checked_mul(3).and_then(|v| v.checked_add(1)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(expected) = nx.checked_mul(ny) else {
|
||||
return false;
|
||||
};
|
||||
self.points.len() == expected && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite())
|
||||
}
|
||||
|
||||
fn locate(knots: &[f64], s: f64) -> (usize, f64) {
|
||||
let n = knots.len() - 1;
|
||||
let s = s.clamp(0.0, 1.0);
|
||||
let mut i = 0;
|
||||
while i + 1 < n && s > knots[i + 1] {
|
||||
i += 1;
|
||||
if knots.len() < 2 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
let n = knots.len() - 1;
|
||||
let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let i = knots[1..n].partition_point(|&k| k < s);
|
||||
let w = knots[i + 1] - knots[i];
|
||||
(i, if w > 0.0 { (s - knots[i]) / w } else { 0.0 })
|
||||
}
|
||||
|
||||
/// Evaluates the surface at normalised `(s, t)`.
|
||||
pub fn eval(&self, s: f64, t: f64) -> [f64; 2] {
|
||||
if self.us.len() < 2 || self.vs.len() < 2 {
|
||||
return [0.0; 2];
|
||||
}
|
||||
let (pi, a) = Self::locate(&self.us, s);
|
||||
let (pj, b) = Self::locate(&self.vs, t);
|
||||
let (ba, bb) = (bernstein(a), bernstein(b));
|
||||
@@ -620,7 +632,10 @@ impl BezierMesh {
|
||||
let mut out = [0.0; 2];
|
||||
for (j, wb) in bb.iter().enumerate() {
|
||||
for (i, wa) in ba.iter().enumerate() {
|
||||
let p = self.points[(3 * pj + j) * nx + 3 * pi + i];
|
||||
let idx = (3 * pj + j).saturating_mul(nx).saturating_add(3 * pi + i);
|
||||
let Some(&p) = self.points.get(idx) else {
|
||||
return [0.0; 2];
|
||||
};
|
||||
let k = wa * wb;
|
||||
out[0] += p[0] * k;
|
||||
out[1] += p[1] * k;
|
||||
@@ -1066,8 +1081,29 @@ impl Warp {
|
||||
WarpStyle::Custom | WarpStyle::Cylinder => match &self.mesh {
|
||||
None => true,
|
||||
Some(m) => {
|
||||
let id = BezierMesh::fit(&|s, t| self.box_point(s, t), m.us.clone(), m.vs.clone());
|
||||
m.points.iter().zip(&id.points).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9)
|
||||
if !m.is_valid() {
|
||||
return false;
|
||||
}
|
||||
let nx = m.nx();
|
||||
let (pc, pr) = (m.us.len() - 1, m.vs.len() - 1);
|
||||
for pj in 0..pr {
|
||||
for j in 0..4 {
|
||||
let v = m.vs[pj] + (m.vs[pj + 1] - m.vs[pj]) * j as f64 / 3.0;
|
||||
for pi in 0..pc {
|
||||
for i in 0..4 {
|
||||
let u = m.us[pi] + (m.us[pi + 1] - m.us[pi]) * i as f64 / 3.0;
|
||||
let expected = self.box_point(u, v);
|
||||
let actual = m.points[(3 * pj + j) * nx + 3 * pi + i];
|
||||
// Relative to the magnitude so the fit's rounding at large origins isn't rejected.
|
||||
let tol = |e: f64| (1e-9 * e.abs().max(1.0)).min(1e-6);
|
||||
if (actual[0] - expected[0]).abs() >= tol(expected[0]) || (actual[1] - expected[1]).abs() >= tol(expected[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
},
|
||||
_ => self.style_warp().is_none(),
|
||||
@@ -1590,4 +1626,38 @@ mod tests {
|
||||
// The more it wraps, the less of the contents is on the back.
|
||||
assert!(CYLINDER_SHAPE.windows(2).all(|r| r[1][0] < r[0][0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_identity_rejects_malformed_custom_mesh_without_allocating() {
|
||||
let us: Vec<f64> = (0..=1000).map(|i| i as f64 / 1000.0).collect();
|
||||
let vs: Vec<f64> = (0..=1000).map(|i| i as f64 / 1000.0).collect();
|
||||
let malformed = BezierMesh { us, vs, points: Vec::new() };
|
||||
assert!(!malformed.is_valid());
|
||||
let w = Warp::custom(malformed.clone(), B);
|
||||
assert!(!w.is_identity());
|
||||
assert_eq!(malformed.eval(0.5, 0.5), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_binary_search_matches_linear_scan() {
|
||||
let knots = [0.0, 0.1, 0.25, 0.5, 0.85, 1.0];
|
||||
let linear = |s: f64| {
|
||||
let n = knots.len() - 1;
|
||||
let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let mut i = 0;
|
||||
while i + 1 < n && s > knots[i + 1] {
|
||||
i += 1;
|
||||
}
|
||||
let w = knots[i + 1] - knots[i];
|
||||
(i, if w > 0.0 { (s - knots[i]) / w } else { 0.0 })
|
||||
};
|
||||
for k in -10..=110 {
|
||||
let s = k as f64 / 100.0;
|
||||
assert_eq!(BezierMesh::locate(&knots, s), linear(s), "s={s}");
|
||||
}
|
||||
for &s in &knots {
|
||||
assert_eq!(BezierMesh::locate(&knots, s), linear(s), "knot={s}");
|
||||
}
|
||||
assert_eq!(BezierMesh::locate(&knots, f64::NAN), linear(f64::NAN));
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -361,8 +361,15 @@ struct Planner<'a> {
|
||||
cx: DocCtx<'a>,
|
||||
fx: Vec<FxLayer<'a>>,
|
||||
checkpoints: Vec<Checkpoint>,
|
||||
/// Layers planned so far, counting a layer each time it is planned (a clipped pass-through
|
||||
/// group is planned twice).
|
||||
visits: usize,
|
||||
}
|
||||
|
||||
/// Most layer plannings one document may take: a pass-through group with clipped layers is
|
||||
/// planned twice, so groups nested in one another double the work at every level.
|
||||
const MAX_PLAN_VISITS: usize = 100_000;
|
||||
|
||||
/// Coverage source of an effect paint (`kind` in `fs_fxpaint`); flags refine it.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Cov {
|
||||
@@ -455,7 +462,7 @@ pub const SHAPE_MAP: usize = usize::MAX;
|
||||
|
||||
impl<'a> Planner<'a> {
|
||||
fn new(cx: DocCtx<'a>) -> Self {
|
||||
Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new() }
|
||||
Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new(), visits: 0 }
|
||||
}
|
||||
|
||||
fn finish(self, root: Slot) -> Plan<'a> {
|
||||
@@ -607,6 +614,10 @@ impl<'a> Planner<'a> {
|
||||
|
||||
/// composite_layer, honouring the layer's channel restrictions.
|
||||
fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||||
self.visits += 1;
|
||||
if self.visits > MAX_PLAN_VISITS {
|
||||
return Err(Unsupported("too many nested layers to plan for the GPU (composited on the CPU)".into()));
|
||||
}
|
||||
self.check_advanced(layer, clipped)?;
|
||||
self.restricted(layer, backdrop, |s, b| s.layer_any(layer, clipped, b))
|
||||
}
|
||||
@@ -1876,6 +1887,23 @@ mod tests {
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
#[test]
|
||||
fn nested_pass_through_groups_with_clipped_layers_are_not_planned_exponentially() {
|
||||
let mut layers = vec![Layer::raster("leaf", photocraft_color::PixelFormat::RGBA8)];
|
||||
for _ in 0..30 {
|
||||
let group = Layer::group("g", layers);
|
||||
let mut clipped = Layer::raster("c", photocraft_color::PixelFormat::RGBA8);
|
||||
clipped.clipped = true;
|
||||
layers = vec![group, clipped];
|
||||
}
|
||||
let mut doc = Document::new("nest", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
doc.layers = layers;
|
||||
let started = std::time::Instant::now();
|
||||
let Err(error) = plan(&doc) else { panic!("2^30 plannings must be refused") };
|
||||
assert!(error.0.contains("too many nested layers"), "{}", error.0);
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_closer_than_one_lut_step_stay_on_the_cpu() {
|
||||
assert!(check_gradient_stops(&[(0.4999_f32, ()), (0.5, ())]).is_err());
|
||||
|
||||
@@ -54,4 +54,5 @@ photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
flate2 = { workspace = true }
|
||||
photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
photocraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -35,13 +35,20 @@ impl std::error::Error for NotFollowed {}
|
||||
/// a relative `document` is taken from the working directory, where it was opened from.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
|
||||
let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
|
||||
let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// document does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NoDocumentFolder);
|
||||
}
|
||||
Ok(path)
|
||||
craft_docpath::resolve_absolute(stored, document.as_deref()).map_err(|r| match r {
|
||||
// Without a document, a relative path resolves but stays relative.
|
||||
Refused::Invalid if document.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_form(&p)) => {
|
||||
NotFollowed::NoDocumentFolder
|
||||
}
|
||||
r => NotFollowed::Refused(r),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `path`, as `craft_docpath` writes it, has a Windows drive or share root (`C:x`, `C:\x`,
|
||||
/// `\\server\share\x`). Outside Windows no document folder makes such a path absolute.
|
||||
fn is_windows_form(path: &Path) -> bool {
|
||||
let s = path.as_os_str().as_encoded_bytes();
|
||||
s.starts_with(b"\\") || matches!(s, [l, b':', ..] if l.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -55,5 +62,22 @@ mod tests {
|
||||
assert_eq!(resolve("logo.png", None), Err(NotFollowed::NoDocumentFolder));
|
||||
assert_eq!(resolve(r"\\192.0.2.1\share\x.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve("//192.0.2.1/share/x.png", None), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve("jar:logo.png", None), Err(NotFollowed::Refused(Refused::Url)));
|
||||
// Colons in names: an alternate data stream or a URL on Windows, a plain character elsewhere.
|
||||
if cfg!(windows) {
|
||||
assert_eq!(resolve("img/fig:1.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid)));
|
||||
assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::Refused(Refused::Url)));
|
||||
} else {
|
||||
assert_eq!(resolve("img/fig:1.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("img").join("fig:1.png")));
|
||||
assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::NoDocumentFolder));
|
||||
}
|
||||
// Outside Windows a Windows path names no file: it is refused, not reported as relative.
|
||||
if !cfg!(windows) {
|
||||
for stored in [r"C:\art\logo.png", "C:logo.png"] {
|
||||
assert_eq!(resolve(stored, Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}");
|
||||
assert_eq!(resolve(stored, None), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}");
|
||||
}
|
||||
assert_eq!(resolve("../logo.png", None), Err(NotFollowed::NoDocumentFolder));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
-2
@@ -28,6 +28,48 @@ use crate::{ImportResult, IoError};
|
||||
pub const MAX_SIDE: u32 = 16_384;
|
||||
/// A rasterised fallback layer or a smart-object render never exceeds this many pixels.
|
||||
const MAX_RASTER_PIXELS: u64 = 24_000_000;
|
||||
/// Largest decoded byte size (RGBA8) accepted for an embedded raster image before resvg decodes
|
||||
/// it. resvg/tiny-skia allocate the full intrinsic image before any destination-size scaling,
|
||||
/// so a highly compressed PNG or WebP could otherwise trigger gigabyte-scale allocation and
|
||||
/// decompression that the destination pixmap's size check never sees.
|
||||
const MAX_EMBEDDED_IMAGE_BYTES: u64 = MAX_RASTER_PIXELS * 4;
|
||||
|
||||
/// The IHDR width/height of a PNG, read directly from its header (no decoding): the signature
|
||||
/// (8 bytes), the IHDR chunk's length and type (8 bytes), then big-endian width and height.
|
||||
fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
|
||||
let sig = data.get(0..8)?;
|
||||
if sig != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
|
||||
return None;
|
||||
}
|
||||
if data.get(12..16)? != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?);
|
||||
let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?);
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
/// Intrinsic dimensions of a raster image resvg can embed, from its header alone, for formats
|
||||
/// where decoding allocates the full image before any destination scaling (PNG, WebP).
|
||||
/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly,
|
||||
/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`)
|
||||
/// and its header is malformed, or `None` for other formats.
|
||||
fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option<Option<(u32, u32)>> {
|
||||
match mime {
|
||||
"image/png" => Some(png_dimensions(data)),
|
||||
"image/webp" => Some(photocraft_codecs::probe_size(data).ok()),
|
||||
"text/plain" => {
|
||||
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
Some(png_dimensions(data))
|
||||
} else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") {
|
||||
Some(photocraft_codecs::probe_size(data).ok())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Aggregate memory for retained fallback surfaces in the document's pixel format.
|
||||
const MAX_PIXEL_BYTES: u64 = 1 << 30;
|
||||
/// Estimated peak during raster conversion (retained surfaces + RGBA8 pixmap + f32 staging + tiles).
|
||||
@@ -139,9 +181,36 @@ fn system_generic_family(_generic: &str) -> Option<String> {
|
||||
/// `~/.ssh/...`) into the document. Embedded SVG images get no `<image>` resolution at all
|
||||
/// (`usvg` already blanks it for sub-SVGs).
|
||||
fn options(fontdb: Arc<usvg::fontdb::Database>) -> usvg::Options<'static> {
|
||||
let default_resolver = usvg::ImageHrefResolver::default_data_resolver();
|
||||
let resolve_data: usvg::ImageHrefDataResolverFn<'static> = Box::new(move |mime, data, opts| {
|
||||
// Reject embedded images whose decoded RGBA storage would exceed the budget before
|
||||
// resvg (which allocates the full intrinsic image before any destination scaling) ever
|
||||
// sees them. Formats without a cheap header probe fall through unchanged; their decode
|
||||
// path is bounded elsewhere or accepted as-is, matching the previous behavior.
|
||||
if let Some(probed) = embedded_image_dimensions(mime, &data) {
|
||||
let (w, h) = probed?;
|
||||
let decoded = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4);
|
||||
if decoded > MAX_EMBEDDED_IMAGE_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// An embedded `image/svg+xml` value can itself be gzip-compressed (SVGZ-in-data-URL).
|
||||
// The default resolver's sub-SVG loader checks for the gzip magic and decompresses
|
||||
// again with no byte limit of its own. Apply our bounded inflate first: if the data is
|
||||
// gzip-compressed, hand the default resolver the already-decompressed (and
|
||||
// size-checked) bytes, which no longer start with the gzip magic, so it parses them
|
||||
// directly instead of decompressing a second time.
|
||||
if (mime == "image/svg+xml" || mime == "text/plain") && data.starts_with(&[0x1f, 0x8b]) {
|
||||
return match inflate(&data) {
|
||||
Ok(xml) if !xml.starts_with(&[0x1f, 0x8b]) => default_resolver("image/svg+xml", std::sync::Arc::new(xml.into_owned()), opts),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
default_resolver(mime, data, opts)
|
||||
});
|
||||
usvg::Options {
|
||||
resources_dir: None,
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) },
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data, resolve_string: Box::new(|_, _| None) },
|
||||
fontdb,
|
||||
..usvg::Options::default()
|
||||
}
|
||||
@@ -174,7 +243,11 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Resul
|
||||
let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) };
|
||||
let fontless_text = has_text && fontdb.faces().next().is_none();
|
||||
let opt = options(fontdb);
|
||||
let tree = usvg::Tree::from_data(&xml, &opt).map_err(|e| IoError::Svg(e.to_string()))?;
|
||||
// Parse the already-bounded-and-decoded text with from_str, not from_data: from_data
|
||||
// re-checks the gzip magic and would decompress a second time without our size limit if
|
||||
// the inflated content happened to start with a nested gzip stream.
|
||||
let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?;
|
||||
let tree = usvg::Tree::from_str(text, &opt).map_err(|e| IoError::Svg(e.to_string()))?;
|
||||
Ok(Parsed { tree, fontless_text })
|
||||
}
|
||||
|
||||
@@ -1058,4 +1131,46 @@ mod tests {
|
||||
assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2));
|
||||
assert_eq!(w.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_data_rejects_oversized_and_malformed_embedded_png_and_webp() {
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()));
|
||||
let resolve = &opt.image_href_resolver.resolve_data;
|
||||
let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap();
|
||||
let valid_png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
assert!(resolve("image/png", Arc::new(valid_png.clone()), &opt).is_some());
|
||||
assert!(resolve("text/plain", Arc::new(valid_png.clone()), &opt).is_some());
|
||||
|
||||
let mut huge_png = valid_png;
|
||||
huge_png[16..20].copy_from_slice(&40_000u32.to_be_bytes());
|
||||
huge_png[20..24].copy_from_slice(&40_000u32.to_be_bytes());
|
||||
assert!(resolve("image/png", Arc::new(huge_png.clone()), &opt).is_none(), "oversized PNG rejected before decode");
|
||||
assert!(resolve("text/plain", Arc::new(huge_png), &opt).is_none(), "oversized mime-less PNG rejected before decode");
|
||||
|
||||
// Malformed WebP header under image/webp or text/plain must be rejected at the probe step.
|
||||
let bad_webp = b"RIFF\x10\x00\x00\x00WEBPVP8 ".to_vec();
|
||||
assert!(resolve("image/webp", Arc::new(bad_webp.clone()), &opt).is_none());
|
||||
assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() {
|
||||
use std::io::Write;
|
||||
fn gz(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
e.write_all(bytes).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()));
|
||||
let resolve = &opt.image_href_resolver.resolve_data;
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"4\"><rect width=\"4\" height=\"4\"/></svg>";
|
||||
let once = gz(svg);
|
||||
assert!(resolve("image/svg+xml", Arc::new(once.clone()), &opt).is_some());
|
||||
assert!(resolve("text/plain", Arc::new(once.clone()), &opt).is_some());
|
||||
|
||||
// Doubly gzip-compressed payload must be rejected rather than decompressed a second time by usvg.
|
||||
let twice = gz(&once);
|
||||
assert!(resolve("image/svg+xml", Arc::new(twice.clone()), &opt).is_none());
|
||||
assert!(resolve("text/plain", Arc::new(twice), &opt).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,31 @@ fn png_data_url() -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A small valid PNG whose IHDR claims `w`x`h`, far larger than its actual (tiny) image data.
|
||||
/// The header alone must be enough to reject it before any decode is attempted.
|
||||
fn png_data_url_with_claimed_size(w: u32, h: u32) -> String {
|
||||
let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]).unwrap();
|
||||
let mut bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
// IHDR starts right after the 8-byte signature and 8-byte chunk length/type: width at 16,
|
||||
// height at 20 (big-endian u32 each).
|
||||
bytes[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
bytes[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::from("data:image/png;base64,");
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = chunk.len();
|
||||
let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
if i <= n {
|
||||
out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_raster_images_become_pixel_layers() {
|
||||
let r = open(&format!(
|
||||
@@ -204,6 +229,18 @@ fn embedded_raster_images_become_pixel_layers() {
|
||||
assert!(close(px(d, 16, 16), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(d, 16, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_png_with_a_huge_claimed_size_is_not_decoded() {
|
||||
// The IHDR claims 40000x40000 (6.4 GB RGBA decoded) but the display size is tiny; the
|
||||
// header-only check must reject it before resvg ever decodes the actual (2x2) image data.
|
||||
let r = open(&format!(
|
||||
"<svg {NS} width=\"20\" height=\"20\"><image id=\"photo\" x=\"0\" y=\"0\" width=\"20\" height=\"20\" href=\"{}\"/></svg>",
|
||||
png_data_url_with_claimed_size(40_000, 40_000)
|
||||
));
|
||||
// Rejected at the resolver: no image layer is produced (resvg treats it as a missing image).
|
||||
assert!(r.document.layers.is_empty() || !matches!(r.document.layers[0].content, LayerContent::Raster(_)), "{:?}", r.document.layers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_becomes_outline_shapes_or_warns_without_a_font() {
|
||||
let r = open(&format!(
|
||||
@@ -245,6 +282,52 @@ fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A `data:` URL of `bytes` under `mime`, base64-encoded.
|
||||
fn base64_data_url(mime: &str, bytes: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = format!("data:{mime};base64,");
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = chunk.len();
|
||||
let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
if i <= n {
|
||||
out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_compressed_embedded_svg_decompresses_once_with_a_size_cap() {
|
||||
// A small gzip-compressed SVG embedded via data URL (SVGZ-in-data-URL) must still import.
|
||||
let inner = format!("<svg {NS} width=\"4\" height=\"4\"><rect width=\"4\" height=\"4\" fill=\"#00ff00\"/></svg>");
|
||||
let url = base64_data_url("image/svg+xml", &gzip(inner.as_bytes()));
|
||||
let r = open(&format!("<svg {NS} width=\"10\" height=\"10\"><image x=\"0\" y=\"0\" width=\"10\" height=\"10\" href=\"{url}\"/></svg>"));
|
||||
assert!(
|
||||
r.document.layers.iter().any(|l| matches!(l.content, LayerContent::Raster(_))),
|
||||
"valid embedded gzip SVG must decode into a raster layer: {:?}",
|
||||
r.document.layers
|
||||
);
|
||||
|
||||
// A gzip-compressed embedded SVG whose decompressed size exceeds the cap must be rejected
|
||||
// by our bounded inflate, not silently decompressed again without a limit by usvg.
|
||||
let mut bomb = format!("<svg {NS} width=\"4\" height=\"4\">").into_bytes();
|
||||
bomb.resize(usize::try_from(svg::MAX_INFLATED_BYTES).unwrap() + 1024, b' ');
|
||||
bomb.extend_from_slice(b"</svg>");
|
||||
let gz = gzip(&bomb);
|
||||
assert!(gz.len() < 2 << 20, "{} bytes compressed", gz.len());
|
||||
let url = base64_data_url("image/svg+xml", &gz);
|
||||
let start = std::time::Instant::now();
|
||||
let r = open(&format!("<svg {NS} width=\"10\" height=\"10\"><image x=\"0\" y=\"0\" width=\"10\" height=\"10\" href=\"{url}\"/></svg>"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "took {:?}", start.elapsed());
|
||||
// The embedded bomb is rejected; the outer drawing may still open (e.g. with a missing
|
||||
// image), but must not have decoded the bomb into a raster layer.
|
||||
assert!(r.document.layers.iter().all(|l| !matches!(l.content, LayerContent::Raster(_))), "{:?}", r.document.layers);
|
||||
}
|
||||
|
||||
/// 8 000 nested groups (70 KB) overflowed the XML parser's recursion on a 2 MB thread stack, an
|
||||
/// abort nothing can catch. It is an error now, found before the parser runs; the thread here
|
||||
/// has a small stack so a regression aborts the test instead of passing by luck.
|
||||
|
||||
@@ -333,21 +333,28 @@ fn damaged_layer_data_opens_flattened() {
|
||||
let r2 = import("x.tif", &bad).unwrap();
|
||||
assert_eq!(r2.document.layers.len(), 1);
|
||||
assert!(r2.warnings.iter().any(|w| w.contains("could not be read")), "{:?}", r2.warnings);
|
||||
// Every truncation and a sweep of bit flips through the whole file: never a panic.
|
||||
for cut in (0..r.bytes.len()).step_by(11) {
|
||||
// Truncations and bit flips through the whole file: never a panic. A run takes a quarter of
|
||||
// them, a different quarter each day.
|
||||
let sweep = photocraft_testkit::Sweep::new(4);
|
||||
let mut case = 0;
|
||||
let mut runs = || {
|
||||
case += 1;
|
||||
sweep.includes(case)
|
||||
};
|
||||
for cut in (0..r.bytes.len()).step_by(11).filter(|_| runs()) {
|
||||
let _ = import("x.tif", &r.bytes[..cut]);
|
||||
}
|
||||
for i in (0..r.bytes.len()).step_by(29) {
|
||||
for i in (0..r.bytes.len()).step_by(29).filter(|_| runs()) {
|
||||
let mut b = r.bytes.clone();
|
||||
b[i] ^= 0x5a;
|
||||
let _ = import("x.tif", &b);
|
||||
}
|
||||
for name in ["layered-le-8bit.tif", "layered-be-16bit.tif"] {
|
||||
let Some(bytes) = fixture(name) else { continue };
|
||||
for cut in (0..bytes.len()).step_by(7) {
|
||||
for cut in (0..bytes.len()).step_by(7).filter(|_| runs()) {
|
||||
let _ = import(name, &bytes[..cut]);
|
||||
}
|
||||
for i in (0..bytes.len()).step_by(5) {
|
||||
for i in (0..bytes.len()).step_by(5).filter(|_| runs()) {
|
||||
let mut b = bytes.clone();
|
||||
b[i] ^= 0xff;
|
||||
let _ = import(name, &b);
|
||||
|
||||
+13
-2
@@ -67,6 +67,8 @@ pub struct Printer {
|
||||
/// Timeouts: connecting, and each request (a Print-Job carries the whole PDF).
|
||||
const CONNECT: Duration = Duration::from_secs(3);
|
||||
const REQUEST: Duration = Duration::from_secs(60);
|
||||
/// The whole reply, however it trickles in: the timeouts above only cover each read.
|
||||
const REPLY_DEADLINE: Duration = Duration::from_secs(120);
|
||||
/// Largest reply read.
|
||||
const MAX_REPLY: usize = 16 << 20;
|
||||
/// Largest number of attributes taken from a reply.
|
||||
@@ -434,7 +436,13 @@ fn read_response(stream: &mut Stream) -> Result<Vec<u8>> {
|
||||
let mut body_at = None;
|
||||
let mut expected: Option<usize> = None;
|
||||
let mut chunked = false;
|
||||
// Where the chunk walk resumes, so each read costs its own bytes, not the whole body again.
|
||||
let mut chunk_at = 0usize;
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if started.elapsed() > REPLY_DEADLINE {
|
||||
return Err(Error::Protocol("the print server took too long to reply".into()));
|
||||
}
|
||||
let n = stream.read(&mut buf).map_err(|e| Error::Protocol(format!("reading: {e}")))?;
|
||||
if n == 0 {
|
||||
return Ok(raw);
|
||||
@@ -455,7 +463,7 @@ fn read_response(stream: &mut Stream) -> Result<Vec<u8>> {
|
||||
let body = raw.get(at..).unwrap_or_default();
|
||||
let complete = match expected {
|
||||
Some(len) if !chunked => body.len() >= len,
|
||||
_ if chunked => chunked_complete(body),
|
||||
_ if chunked => chunked_complete(body, &mut chunk_at),
|
||||
_ => false,
|
||||
};
|
||||
if complete {
|
||||
@@ -483,8 +491,11 @@ fn http_response(raw: &[u8]) -> Result<(u16, Headers, &[u8])> {
|
||||
/// `0\r\n\r\n` inside a chunk's payload (an attribute value can hold it) does not count. A
|
||||
/// malformed or impossible size counts as complete, so [`dechunk`] reports it instead of
|
||||
/// waiting for more.
|
||||
fn chunked_complete(mut b: &[u8]) -> bool {
|
||||
fn chunked_complete(body: &[u8], resume: &mut usize) -> bool {
|
||||
let mut b = body.get(*resume..).unwrap_or_default();
|
||||
loop {
|
||||
// Chunks already walked are not walked again: `resume` marks the start of this one.
|
||||
*resume = body.len() - b.len();
|
||||
let Some(line_end) = b.windows(2).position(|w| w == b"\r\n") else { return false };
|
||||
let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[]));
|
||||
let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16) else { return true };
|
||||
|
||||
+24
-5
@@ -429,10 +429,29 @@ fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() {
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].name, "Of\r\n0\r\n\r\nfice");
|
||||
// The boundary walk itself: complete only at a zero-size chunk, never on payload bytes.
|
||||
assert!(!chunked_complete(b"5\r\nab\r\n0"));
|
||||
assert!(!chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n"));
|
||||
assert!(chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n"));
|
||||
assert!(chunked_complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err());
|
||||
assert!(!complete(b"5\r\nab\r\n0"));
|
||||
assert!(!complete(b"7\r\n\r\n0\r\n\r\nx\r\n"));
|
||||
assert!(complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n"));
|
||||
assert!(complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err());
|
||||
}
|
||||
|
||||
/// [`chunked_complete`] on a whole body at once.
|
||||
fn complete(body: &[u8]) -> bool {
|
||||
chunked_complete(body, &mut 0)
|
||||
}
|
||||
|
||||
/// Fed a byte at a time, as a server dripping the reply would, the walk resumes where it stopped
|
||||
/// and agrees with the one-shot answer at every length.
|
||||
#[test]
|
||||
fn the_chunk_walk_resumes_instead_of_starting_over() {
|
||||
let body = b"3\r\nabc\r\n10\r\n0123456789abcdef\r\n0\r\n\r\n";
|
||||
let mut at = 0;
|
||||
for len in 0..=body.len() {
|
||||
let prefix = &body[..len];
|
||||
assert_eq!(chunked_complete(prefix, &mut at), complete(prefix), "{len}");
|
||||
assert!(at <= len);
|
||||
}
|
||||
assert!(at > 0, "it moved past the chunks already walked");
|
||||
}
|
||||
|
||||
/// A chunk size that cannot be satisfied (`usize::MAX`, `usize::MAX - 1`, anything past the
|
||||
@@ -440,7 +459,7 @@ fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() {
|
||||
#[test]
|
||||
fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() {
|
||||
for size_line in ["ffffffffffffffff", "fffffffffffffffe", "1000001"] {
|
||||
assert!(chunked_complete(format!("{size_line}\r\n").as_bytes()));
|
||||
assert!(complete(format!("{size_line}\r\n").as_bytes()));
|
||||
assert!(dechunk(format!("{size_line}\r\nab\r\n").as_bytes()).is_err());
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
@@ -40,7 +40,7 @@ impl GrayTile {
|
||||
self.data.iter().map(|&v| f32::from(v) / 65535.0).collect()
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.width > 0 && self.height > 0 && self.data.len() == self.width as usize * self.height as usize
|
||||
self.width > 0 && self.height > 0 && (self.width as usize).checked_mul(self.height as usize) == Some(self.data.len())
|
||||
}
|
||||
|
||||
/// A box-filtered copy whose larger side is at most `max_side` (the whole tile when it is
|
||||
@@ -157,7 +157,9 @@ impl<'de> Deserialize<'de> for GrayTile {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::Error;
|
||||
let t = GrayTileIn::deserialize(d)?;
|
||||
let n = t.width as usize * t.height as usize;
|
||||
let Some(n) = (t.width as usize).checked_mul(t.height as usize) else {
|
||||
return Err(D::Error::custom("tile dimensions overflow the address space"));
|
||||
};
|
||||
let data: Vec<u16> = match t.data {
|
||||
DataIn::B64(s) => {
|
||||
let b = b64_decode(&s).ok_or_else(|| D::Error::custom("invalid base64 in tile data"))?;
|
||||
@@ -232,6 +234,9 @@ pub struct Mips {
|
||||
|
||||
impl Mips {
|
||||
pub fn new(t: &GrayTile) -> Self {
|
||||
if !t.is_valid() {
|
||||
return Self { levels: vec![(1, 1, vec![0.0])] };
|
||||
}
|
||||
let mut levels = vec![(t.width as usize, t.height as usize, t.to_f32())];
|
||||
while let Some((w, h, v)) = levels.last() {
|
||||
if *w <= 1 && *h <= 1 {
|
||||
@@ -335,6 +340,18 @@ mod tests {
|
||||
let f: GrayTile = serde_json::from_str(r#"{"width":2,"height":1,"data":[0.0,1.0]}"#).unwrap();
|
||||
assert_eq!(f.data, vec![0, 65535].into());
|
||||
assert!(serde_json::from_str::<GrayTile>(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err());
|
||||
assert!(serde_json::from_str::<GrayTile>(r#"{"width":4294967295,"height":4294967295,"data":[0.0]}"#).is_err());
|
||||
let invalid = GrayTile { width: u32::MAX, height: u32::MAX, data: vec![1].into() };
|
||||
assert!(!invalid.is_valid());
|
||||
assert_eq!(Mips::new(&invalid).levels, vec![(1, 1, vec![0.0])]);
|
||||
}
|
||||
|
||||
/// `u32::MAX * u32::MAX` only overflows `usize` on 32-bit targets (wasm); 64-bit hits the length check.
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
#[test]
|
||||
fn oversized_dimensions_report_address_space_overflow() {
|
||||
let err = serde_json::from_str::<GrayTile>(r#"{"width":4294967295,"height":4294967295,"data":[0.0]}"#).unwrap_err();
|
||||
assert!(err.to_string().contains("tile dimensions overflow the address space"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,6 +24,10 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon = { workspace = true }
|
||||
|
||||
# Opening a plug-in file without blocking on a FIFO (the flag constant only).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# WebAssembly text -> binary for the hostile-module fixtures (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
|
||||
@@ -70,6 +70,9 @@ fn read_clamped(s: &Surface, rect: Rect, extent: Rect) -> Vec<f32> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Most bands one run is split into.
|
||||
const MAX_BANDS: u64 = 1 << 16;
|
||||
|
||||
impl Plugin {
|
||||
/// The rectangle a run writes: the layer's content (grown by the plug-in's overlap) or the
|
||||
/// canvas, limited to the selection's bounds.
|
||||
@@ -113,6 +116,13 @@ impl Plugin {
|
||||
}
|
||||
let mut bands = Vec::new();
|
||||
let (t0, t1) = (TileCoord::containing(area.x0, area.y0), TileCoord::containing(area.x1 - 1, area.y1 - 1));
|
||||
// Counted before any is made: a few distant tiles make `area` a grid of millions of
|
||||
// bands, each a call into the plug-in, planned before the deadline starts.
|
||||
let columns = (i64::from(t1.tx) - i64::from(t0.tx) + 1).max(0) as u64;
|
||||
let rows = (i64::from(t1.ty) - i64::from(t0.ty) + 1).max(0) as u64;
|
||||
if columns.div_ceil(across as u64).saturating_mul(rows) > MAX_BANDS {
|
||||
return Err(Error::Limit(format!("more than {MAX_BANDS} bands (the layer's content is spread too widely)")));
|
||||
}
|
||||
for ty in t0.ty..=t1.ty {
|
||||
let mut tx = t0.tx;
|
||||
while tx <= t1.tx {
|
||||
|
||||
@@ -398,8 +398,12 @@ fn visible(c: char) -> bool {
|
||||
!c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK)
|
||||
}
|
||||
|
||||
/// Names the host's `plugin.run` command takes for itself: a parameter of the same name would be
|
||||
/// read as the command's routing (which layer or channel is filtered), not as the plug-in's.
|
||||
const HOST_KEYS: [&str; 4] = ["id", "layer", "target", "channel"];
|
||||
|
||||
fn valid_key(k: &str) -> bool {
|
||||
(1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && k != "id" && k != "layer"
|
||||
(1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && !HOST_KEYS.contains(&k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -471,6 +475,9 @@ mod tests {
|
||||
r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#,
|
||||
r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#,
|
||||
r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#,
|
||||
// Names the host command routes by: a plug-in default must not steer the filter.
|
||||
r#"{"id":"a","name":"x","kind":"filter","params":{"target":{"type":"bool","default":false}}}"#,
|
||||
r#"{"id":"a","name":"x","kind":"filter","params":{"channel":{"type":"int","min":0,"max":3,"default":1}}}"#,
|
||||
r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#,
|
||||
] {
|
||||
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
|
||||
|
||||
@@ -54,19 +54,35 @@ pub fn revision() -> u64 {
|
||||
REVISION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Opens `path` for reading without waiting on a FIFO or device that has no writer.
|
||||
#[cfg(unix)]
|
||||
fn open_regular(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// O_NONBLOCK: opening a writer-less FIFO read-only returns at once; the caller then sees it
|
||||
// is not a regular file. Regular files ignore the flag.
|
||||
std::fs::OpenOptions::new().read(true).custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32).open(path)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn open_regular(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
std::fs::File::open(path)
|
||||
}
|
||||
|
||||
/// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit)
|
||||
/// and loads it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_file(path: &std::path::Path, limits: Limits) -> Result<Plugin> {
|
||||
use std::io::Read;
|
||||
let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
// Open first, without blocking on a FIFO, and judge the file that was opened: a name checked
|
||||
// and then opened again can be swapped for a special file in between.
|
||||
let f = open_regular(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
let meta = f.metadata().map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(Error::Io(format!("{} is not a file", path.display())));
|
||||
}
|
||||
if meta.len() > limits.max_module_bytes as u64 {
|
||||
return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes)));
|
||||
}
|
||||
let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
let mut bytes = Vec::new();
|
||||
f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string()))
|
||||
|
||||
@@ -184,8 +184,9 @@ impl Plugin {
|
||||
let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let mut store = Store::new(&self.engine, limits);
|
||||
store.limiter(|l| l);
|
||||
// The start function (if any) runs on this fuel.
|
||||
store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?;
|
||||
// The start function (if any) runs on this fuel. It cannot be interrupted to check the
|
||||
// deadline the way a call can, so it gets no more than one slice, however much a call may use.
|
||||
store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?;
|
||||
let linker = Linker::<StoreLimits>::new(&self.engine);
|
||||
let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?;
|
||||
let memory = instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?;
|
||||
|
||||
@@ -317,3 +317,44 @@ fn folder_loading_skips_bad_files() {
|
||||
assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_function_that_spins_is_cut_off_at_one_slice() {
|
||||
// `fuel_base` is what a call may use; the start function cannot be interrupted to check the
|
||||
// clock, so it gets one slice of it at most.
|
||||
let spin = "(func $spin (loop $l (br $l))) (start $spin)";
|
||||
let limits = Limits { fuel_base: u64::MAX / 4, ..Limits::default() };
|
||||
let (error, took) = run_err(&module(MANIFEST, 1, "(i32.const 0)", spin), limits);
|
||||
assert!(matches!(error, Error::Trap(_) | Error::Limit(_)), "{error:?}");
|
||||
assert!(took < Duration::from_secs(30), "{took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_spread_over_millions_of_bands_is_refused_before_planning_them() {
|
||||
let p = Plugin::load(INVERT, Limits::default()).unwrap();
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.write_pixel(0, 0, &[0.1, 0.2, 0.3, 1.0]);
|
||||
s.write_pixel(4_000_000, 4_000_000, &[0.1, 0.2, 0.3, 1.0]);
|
||||
let t = Instant::now();
|
||||
let e = p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({})).expect_err("a 15600 × 15600 tile grid is not one run");
|
||||
assert!(matches!(e, Error::Limit(_)), "{e:?}");
|
||||
assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_fifo_in_place_of_a_module_file_does_not_block_loading() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-plugin-fifo-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("evil.wasm");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
rustix::fs::mknodat(rustix::fs::CWD, &path, rustix::fs::FileType::Fifo, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, 0).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = path.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(photocraft_plugins::registry::load_file(&p, Limits::default()).map(|_| ()));
|
||||
});
|
||||
let r = rx.recv_timeout(Duration::from_secs(10)).expect("loading a FIFO must not block");
|
||||
assert!(matches!(r, Err(Error::Io(_))), "{r:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+40
-9
@@ -435,6 +435,7 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result
|
||||
return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion)));
|
||||
}
|
||||
let mut hierarchy = None;
|
||||
let mut patterns = PatternBudget::default();
|
||||
while r.left() >= 12 {
|
||||
let sig = r.arr::<4>()?;
|
||||
if &sig != b"8BIM" {
|
||||
@@ -458,16 +459,17 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result
|
||||
match &key {
|
||||
b"samp" => read_samp(data, out.subversion, out, budget)?,
|
||||
b"patt" => {
|
||||
// Patterns share the samples' budget, so neither can claim it twice.
|
||||
// Patterns share the samples' budget and one padding budget across every `patt`
|
||||
// section, so neither can be claimed twice and failed blocks still deduct their
|
||||
// decoded charges.
|
||||
let limit = u64::try_from(budget.0).unwrap_or(u64::MAX);
|
||||
let mut patterns = PatternBudget::with_decoded_limit(limit);
|
||||
patterns.cap_decoded_to(limit);
|
||||
let left = patterns.decoded_left();
|
||||
match parse_pattern_block_with(data, &mut patterns) {
|
||||
Ok(p) => {
|
||||
let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX);
|
||||
budget.spend(spent)?;
|
||||
out.patterns.extend(p);
|
||||
}
|
||||
let res = parse_pattern_block_with(data, &mut patterns);
|
||||
let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX);
|
||||
budget.spend(spent)?;
|
||||
match res {
|
||||
Ok(p) => out.patterns.extend(p),
|
||||
Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -706,10 +708,39 @@ mod tests {
|
||||
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0));
|
||||
// One byte short: the patterns no longer fit after the sample (they used not to count).
|
||||
let (out, left) = parse_within(189);
|
||||
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 89));
|
||||
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 14));
|
||||
assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_pattern_section_does_not_refund_budget_to_later_sections() {
|
||||
let p = PsdPattern {
|
||||
mode: 3,
|
||||
width: 5,
|
||||
height: 3,
|
||||
name: "p".into(),
|
||||
id: "id".into(),
|
||||
palette: None,
|
||||
depth: 8,
|
||||
channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]],
|
||||
alpha: None,
|
||||
};
|
||||
let valid = write_pattern_block(std::slice::from_ref(&p)).unwrap();
|
||||
let mut malformed = valid.clone();
|
||||
malformed.extend_from_slice(&100u32.to_be_bytes());
|
||||
|
||||
let mut body = 2u16.to_be_bytes().to_vec();
|
||||
section(&mut body, b"patt", &malformed);
|
||||
section(&mut body, b"patt", &valid);
|
||||
let mut out = AbrFile::default();
|
||||
let mut budget = Budget(60);
|
||||
parse_v6_within(&mut Rd::new(&body), &mut out, &mut budget).unwrap();
|
||||
|
||||
assert!(out.patterns.is_empty(), "a failed block is discarded atomically");
|
||||
assert_eq!(budget.0, 0, "the failed block's first 45 bytes and later partial 15-byte charge are retained");
|
||||
assert_eq!(out.warnings.iter().filter(|w| w.contains("embedded patterns unreadable")).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_v2_round_trip_raw_and_rle() {
|
||||
for version in [1u16, 2] {
|
||||
|
||||
@@ -56,6 +56,12 @@ impl PatternBudget {
|
||||
PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) }
|
||||
}
|
||||
|
||||
/// Cap both decoded and padded bytes to the amount still available to a containing file.
|
||||
pub fn cap_decoded_to(&mut self, decoded: u64) {
|
||||
self.decoded_left = self.decoded_left.min(decoded);
|
||||
self.padded_left = self.padded_left.min(decoded);
|
||||
}
|
||||
|
||||
/// Decoded bytes still available.
|
||||
pub fn decoded_left(&self) -> u64 {
|
||||
self.decoded_left
|
||||
@@ -425,9 +431,8 @@ pub fn parse_pattern_block(data: &[u8]) -> Result<Vec<PsdPattern>> {
|
||||
}
|
||||
|
||||
/// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit.
|
||||
/// The budget is charged only for patterns that are returned.
|
||||
/// Charges are monotonic even when a later record in the block fails.
|
||||
pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result<Vec<PsdPattern>> {
|
||||
let mut left = budget.clone();
|
||||
let mut r = Rd { b: data, p: 0 };
|
||||
let mut out = Vec::new();
|
||||
while r.p + 4 <= data.len() {
|
||||
@@ -436,11 +441,10 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu
|
||||
break;
|
||||
}
|
||||
let body = r.take(len)?;
|
||||
out.push(read_pattern(&mut Rd { b: body, p: 0 }, &mut left)?);
|
||||
out.push(read_pattern(&mut Rd { b: body, p: 0 }, budget)?);
|
||||
let pad = (4 - len % 4) % 4;
|
||||
r.p = (r.p + pad).min(data.len());
|
||||
}
|
||||
*budget = left;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -729,6 +733,18 @@ mod tests {
|
||||
block
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_pattern_block_keeps_partial_charge_for_the_next_block() {
|
||||
let one = padded_gray_block(100);
|
||||
let mut malformed = one.clone();
|
||||
malformed.extend_from_slice(&100u32.to_be_bytes());
|
||||
let mut budget = PatternBudget::with_decoded_limit(15_000);
|
||||
assert!(parse_pattern_block_with(&malformed, &mut budget).is_err());
|
||||
assert_eq!(budget.decoded_left(), 5_000);
|
||||
assert!(parse_pattern_block_with(&one, &mut budget).is_err());
|
||||
assert_eq!(budget.decoded_left(), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_budget_is_shared_by_every_pattern_of_a_parse() {
|
||||
// Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before
|
||||
@@ -741,8 +757,9 @@ mod tests {
|
||||
let two = [one.clone(), one.clone()].concat();
|
||||
let mut budget = PatternBudget::with_decoded_limit(15_000);
|
||||
assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_))));
|
||||
// A failed parse keeps nothing, so it charges nothing.
|
||||
assert_eq!(budget.decoded_left(), 15_000);
|
||||
// Charges are monotonic: the first pattern's 10000 bytes stay charged even when the second
|
||||
// record fails.
|
||||
assert_eq!(budget.decoded_left(), 5_000);
|
||||
|
||||
// Two blocks of one document share a budget too.
|
||||
let mut budget = PatternBudget::with_decoded_limit(15_000);
|
||||
|
||||
@@ -245,12 +245,14 @@ impl<'a> Layer<'a> {
|
||||
let rect = self.record.rect;
|
||||
let (w, h) = rect.size()?;
|
||||
let n = w.checked_mul(h).ok_or(PsdError::LimitExceeded("layer size overflow"))?;
|
||||
if n.checked_mul(4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) {
|
||||
return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"));
|
||||
}
|
||||
let depth = self.header.depth;
|
||||
let mode = self.header.color_mode;
|
||||
let cc = color_channel_count(mode)?;
|
||||
// The planes (one per colour channel, filled in for channels the layer lacks, and alpha)
|
||||
// coexist with the RGBA output, so the cap covers all of them, not the output alone.
|
||||
if n.checked_mul(cc + 2 + 4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) {
|
||||
return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"));
|
||||
}
|
||||
let mut color = Vec::with_capacity(cc);
|
||||
for id in 0..cc as i16 {
|
||||
let plane = if self.record.channel(id).is_some() {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip
|
||||
//! keep the raw resource while the slices are unchanged.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::io::{Reader, WriteExt};
|
||||
@@ -274,13 +276,22 @@ impl SlicesResource {
|
||||
&& let Ok(vd) = VersionedDescriptor::read(r)
|
||||
&& let Some(Value::List(items)) = vd.descriptor.get("slices")
|
||||
{
|
||||
// Index each slice ID to its first occurrence once, so matching trailing
|
||||
// descriptors against slices is linear rather than quadratic. Both the slice
|
||||
// count and the descriptor-ID list are file-controlled, so a plain
|
||||
// `find()` per descriptor is O(n*m) and lets a crafted resource burn CPU.
|
||||
// `or_insert` preserves the original first-match behavior for duplicate IDs.
|
||||
let mut id_to_index: HashMap<u32, usize> = HashMap::with_capacity(slices.len());
|
||||
for (i, s) in slices.iter().enumerate() {
|
||||
id_to_index.entry(s.id).or_insert(i);
|
||||
}
|
||||
for v in items {
|
||||
let Value::Descriptor(d) = v else { continue };
|
||||
// An entry without a usable id belongs to no slice (not to slice 0).
|
||||
let Some(id) = d_u32(d, "sliceID") else { continue };
|
||||
let extra = slice_from_descriptor(d);
|
||||
if let Some(s) = slices.iter_mut().find(|s| s.id == id) {
|
||||
s.outsets = extra.outsets;
|
||||
if let Some(&i) = id_to_index.get(&id) {
|
||||
slices[i].outsets = extra.outsets;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,4 +504,28 @@ mod tests {
|
||||
assert!(SlicesResource::from_bytes(&huge).is_err());
|
||||
assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_descriptor_applies_outsets_to_first_matching_slice() {
|
||||
// Two slices share id 1; the trailing descriptor must update the first one only,
|
||||
// matching the pre-index behavior of `iter_mut().find()`.
|
||||
let mut plain = SlicesResource {
|
||||
version: 6,
|
||||
bounds: [0, 0, 80, 100],
|
||||
group_name: "site".into(),
|
||||
slices: vec![
|
||||
SliceRecord { id: 1, origin: 0, kind: 1, rect: [0, 0, 10, 10], ..Default::default() },
|
||||
SliceRecord { id: 1, origin: 0, kind: 1, rect: [10, 10, 20, 20], ..Default::default() },
|
||||
],
|
||||
};
|
||||
for s in &mut plain.slices {
|
||||
s.outsets = [0; 4];
|
||||
}
|
||||
let mut bytes = plain.to_bytes();
|
||||
let extra = Descriptor::new("slice").with("sliceID", Value::Integer(1)).with("topOutset", Value::Integer(7));
|
||||
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(extra)]))).write(&mut bytes);
|
||||
let back = SlicesResource::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(back.slices[0].outsets, [7, 0, 0, 0], "first slice receives the outset");
|
||||
assert_eq!(back.slices[1].outsets, [0, 0, 0, 0], "later duplicate is untouched");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,7 +732,9 @@ impl Surface {
|
||||
/// Fallible counterpart to [`Surface::take_tiles`]. Reserves the complete result and any
|
||||
/// default tile before removing a tile, so a reservation failure leaves the surface intact.
|
||||
pub fn try_take_tiles(&mut self, r: Rect) -> Result<Vec<(TileCoord, Arc<Tile>)>, AllocationError> {
|
||||
let count = r.tiles().count();
|
||||
// Counted in u64 and checked before narrowing: on 32-bit targets the grid of a sparse
|
||||
// surface's bounds can pass `usize` (and visiting it to count it takes just as long).
|
||||
let count = usize::try_from(r.tile_count()).map_err(|_| AllocationError::SizeOverflow)?;
|
||||
let bytes = count.checked_mul(std::mem::size_of::<(TileCoord, Arc<Tile>)>()).ok_or(AllocationError::SizeOverflow)?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(count).map_err(|_| AllocationError::NotEnoughMemory { bytes })?;
|
||||
@@ -890,6 +892,10 @@ impl Surface {
|
||||
|
||||
// One tile row of the default pixel, to compare a finished tile with a row at a time.
|
||||
let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize);
|
||||
// Target tiles rebuilt from the same source tiles at the same offset are the same tile:
|
||||
// a bundle can point thousands of tile references at one payload, and a move would
|
||||
// otherwise copy it into a separate buffer for each.
|
||||
let built: std::sync::Mutex<std::collections::HashMap<Vec<usize>, Option<Arc<Tile>>>> = Default::default();
|
||||
let build_target = |dc: TileCoord| -> Option<(TileCoord, Arc<Tile>)> {
|
||||
let dr = dc.rect();
|
||||
let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg());
|
||||
@@ -904,6 +910,14 @@ impl Surface {
|
||||
// A tile of only the default pixel is left out, as `prune` would.
|
||||
return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared));
|
||||
}
|
||||
// The identities of the source tiles under a covered window (the offset is the same
|
||||
// for every target of this call).
|
||||
let memo_key: Option<Vec<usize>> = covered.then(|| sr.tiles().map(|sc| self.tiles.get(&sc).map_or(0, |t| Arc::as_ptr(t) as usize)).collect());
|
||||
if let Some(k) = &memo_key
|
||||
&& let Some(hit) = built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(k)
|
||||
{
|
||||
return hit.clone().map(|t| (dc, t));
|
||||
}
|
||||
|
||||
let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() };
|
||||
let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE));
|
||||
@@ -984,7 +998,11 @@ impl Surface {
|
||||
}
|
||||
// A target that got only default pixels isn't stored, as `prune` would drop it. Checked
|
||||
// after the copy, a row at a time: a tile with content stops at its first such row.
|
||||
tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| (dc, Arc::new(tile)))
|
||||
let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile));
|
||||
if let Some(k) = memo_key {
|
||||
built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(k, finished.clone());
|
||||
}
|
||||
finished.map(|t| (dc, t))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -1835,6 +1853,36 @@ mod tests {
|
||||
let _ = s.translated(i32::MIN + 1, 3, all);
|
||||
}
|
||||
|
||||
/// Many references to one payload, moved off the tile grid: the rebuilt tiles that read the
|
||||
/// same source tiles at the same offset are one tile, not a copy each.
|
||||
#[test]
|
||||
fn translated_shares_tiles_rebuilt_from_shared_sources() {
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let mut one = Surface::new(fmt);
|
||||
let r = Rect::new(0, 0, TILE_SIZE, TILE_SIZE);
|
||||
let px: Vec<f32> = (0..r.width() * r.height()).flat_map(|i| [(i % 251) as f32 / 255.0, (i % 7) as f32 / 7.0, 0.5, 1.0]).collect();
|
||||
one.write_region(r, &px);
|
||||
let payload = Arc::clone(one.tile(TileCoord::new(0, 0)).unwrap());
|
||||
let mut s = Surface::new(fmt);
|
||||
s.put_tiles((0..16).flat_map(|ty| (0..16).map(move |tx| TileCoord::new(tx, ty))).map(|c| (c, Arc::clone(&payload))));
|
||||
let moved = s.translated(7, 5, s.content_bounds());
|
||||
let distinct: std::collections::HashSet<_> = moved.tiles().map(|(_, t)| Arc::as_ptr(t)).collect();
|
||||
// The 15 × 15 interior tiles read the same four source tiles at the same offset, so they are
|
||||
// one tile; only the 64 border tiles (cut by the content's edge) are built one by one.
|
||||
assert!(moved.tile_count() == 289 && distinct.len() <= 64 + 5, "{} tiles, {} distinct", moved.tile_count(), distinct.len());
|
||||
let all = Rect::new(-50, -50, 16 * TILE_SIZE + 50, 16 * TILE_SIZE + 50);
|
||||
assert_eq!(moved.read_region(all), s.read_region(all.translate(-7, -5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surface_bounds_tile_count_is_counted_not_walked() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let far = Rect::new(0, 0, i32::MAX, i32::MAX);
|
||||
assert_eq!(far.tile_count(), u64::from(i32::MAX as u32 / TILE_SIZE as u32 + 1).pow(2));
|
||||
// Not allocated: 2^46 entries is far past any reservation.
|
||||
assert!(s.try_take_tiles(far).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_stores_no_tiles_of_only_the_default_pixel() {
|
||||
let only_default = |t: &Surface| {
|
||||
|
||||
+12
-1
@@ -305,6 +305,17 @@ impl<'a> Tiff<'a> {
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// [`Tiff::ascii`] for diagnostics: reads at most `limit` bytes (and a few more, so a
|
||||
/// character cut at the limit still decodes) of a value that may be gigabytes long and
|
||||
/// shared by many entries.
|
||||
pub fn ascii_prefix(&self, e: &Entry, limit: usize) -> Option<String> {
|
||||
let b = self.raw(e)?;
|
||||
let b = b.get(..b.len().min(limit.saturating_add(4)))?;
|
||||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||
let s = String::from_utf8_lossy(b.get(..end)?).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
pub fn tag_uint(&self, ifd: &Ifd, t: u16) -> Option<u32> {
|
||||
ifd.get(t).and_then(|e| self.uint(e, 0))
|
||||
}
|
||||
@@ -345,7 +356,7 @@ pub(crate) fn dump(data: &[u8]) -> String {
|
||||
break 'ifds;
|
||||
}
|
||||
let preview: String = if e.typ == 2 {
|
||||
let mut a = t.ascii(e).unwrap_or_default();
|
||||
let mut a = t.ascii_prefix(e, MAX_ASCII_PREVIEW).unwrap_or_default();
|
||||
if a.len() > MAX_ASCII_PREVIEW {
|
||||
// Truncate at a char boundary at or below the cap (lossy UTF-8 can hold
|
||||
// multi-byte replacement chars).
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
pub mod gpu;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod perf;
|
||||
pub mod sweep;
|
||||
|
||||
pub use gpu::gpu_lock;
|
||||
pub use sweep::Sweep;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Long test sweeps spread over several days.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The cases of a long sweep that this run covers. By default a run takes one of `parts` slices,
|
||||
/// chosen by the UTC date, so every case runs at least once every `parts` days.
|
||||
/// `PHOTOCRAFT_FULL_SWEEP=1` runs every case, and `PHOTOCRAFT_SWEEP_PART=<n>` replays slice `n`;
|
||||
/// [`Sweep::new`] prints the slice it picked.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Sweep {
|
||||
part: u64,
|
||||
parts: u64,
|
||||
}
|
||||
|
||||
impl Sweep {
|
||||
pub fn new(parts: u64) -> Self {
|
||||
if std::env::var_os("PHOTOCRAFT_FULL_SWEEP").is_some_and(|v| v == "1") {
|
||||
eprintln!("full sweep (PHOTOCRAFT_FULL_SWEEP=1)");
|
||||
return Self { part: 0, parts: 1 };
|
||||
}
|
||||
let parts = parts.max(1);
|
||||
let part = match std::env::var("PHOTOCRAFT_SWEEP_PART") {
|
||||
Ok(v) => {
|
||||
let p = v.parse::<u64>().unwrap_or(u64::MAX);
|
||||
assert!(p < parts, "PHOTOCRAFT_SWEEP_PART={v:?} is not a part number below {parts}");
|
||||
p
|
||||
}
|
||||
Err(_) => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts,
|
||||
};
|
||||
eprintln!("sweep part {part} of {parts}: replay with PHOTOCRAFT_SWEEP_PART={part}, run every part with PHOTOCRAFT_FULL_SWEEP=1");
|
||||
Self { part, parts }
|
||||
}
|
||||
|
||||
/// Whether case `i` runs. Cases go to slices by a hash of `i`, so each slice mixes every
|
||||
/// position of the sweep's inner loops instead of taking every `parts`-th one.
|
||||
pub fn includes(&self, i: u64) -> bool {
|
||||
self.parts == 1 || mix(i) % self.parts == self.part
|
||||
}
|
||||
}
|
||||
|
||||
/// splitmix64's finalizer.
|
||||
fn mix(mut x: u64) -> u64 {
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_parts_cover_every_case_once() {
|
||||
let parts: Vec<Sweep> = (0..4).map(|part| Sweep { part, parts: 4 }).collect();
|
||||
for i in 0..1000 {
|
||||
assert_eq!(parts.iter().filter(|s| s.includes(i)).count(), 1, "case {i}");
|
||||
}
|
||||
assert!(parts.iter().all(|s| (0..1000).filter(|&i| s.includes(i)).count() > 200));
|
||||
}
|
||||
}
|
||||
+34
-5
@@ -509,19 +509,48 @@ pub fn mac_pingfang() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Maximum collection faces and cumulative `name` records inspected by [`face_index_for_family`].
|
||||
const MAX_COLLECTION_FACES: usize = 256;
|
||||
const MAX_NAME_RECORDS: usize = 8192;
|
||||
|
||||
/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is
|
||||
/// empty or no face matches. Never panics on malformed data.
|
||||
pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 {
|
||||
use skrifa::{MetadataProvider, raw::FileRef, string::StringId};
|
||||
use skrifa::{
|
||||
MetadataProvider,
|
||||
raw::{FileRef, types::Tag},
|
||||
string::StringId,
|
||||
};
|
||||
if family.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 };
|
||||
for (i, f) in c.iter().enumerate() {
|
||||
let mut seen_faces = std::collections::HashSet::new();
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
let mut budget = MAX_NAME_RECORDS;
|
||||
for (i, f) in c.iter().take(MAX_COLLECTION_FACES).enumerate() {
|
||||
if let Some(off_bytes) = bytes.get(12 + i * 4..12 + (i + 1) * 4)
|
||||
&& let Ok(arr) = <[u8; 4]>::try_from(off_bytes)
|
||||
&& !seen_faces.insert(u32::from_be_bytes(arr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(f) = f else { continue };
|
||||
let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars())));
|
||||
if named {
|
||||
return i as u32;
|
||||
if let Some(rec) = f.table_directory.table_records().iter().find(|r| r.tag() == Tag::new(b"name"))
|
||||
&& !seen_names.insert((rec.offset(), rec.length()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for id in [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME] {
|
||||
for s in f.localized_strings(id) {
|
||||
let Some(rem) = budget.checked_sub(1) else {
|
||||
return 0;
|
||||
};
|
||||
budget = rem;
|
||||
if s.chars().eq(family.chars()) {
|
||||
return i as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
|
||||
@@ -298,6 +298,9 @@ impl FontDb {
|
||||
/// Registers every face in `blob`, under `family` when given, else under the family each
|
||||
/// face's `name` table gives.
|
||||
fn register_blob(&mut self, blob: Blob<u8>, family: Option<&str>) -> Vec<String> {
|
||||
let Some(blob) = sanitize_font_blob(blob) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() }));
|
||||
let mut names = Vec::new();
|
||||
for (id, _) in added {
|
||||
@@ -551,6 +554,65 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum faces and cumulative variation axes admitted from a single font blob.
|
||||
const MAX_COLLECTION_FACES: usize = 256;
|
||||
const MAX_COLLECTION_AXES: usize = 4096;
|
||||
|
||||
/// Validates `blob` before handing it to `fontique`, deduplicating repeated TTC table-directory
|
||||
/// offsets and rejecting collections with excessive faces or variation axes.
|
||||
fn sanitize_font_blob(blob: Blob<u8>) -> Option<Blob<u8>> {
|
||||
let data = blob.as_ref();
|
||||
if data.len() > 256 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
match FileRef::new(data).ok()? {
|
||||
FileRef::Font(font) => {
|
||||
if font.axes().len() > MAX_COLLECTION_AXES {
|
||||
return None;
|
||||
}
|
||||
Some(blob)
|
||||
}
|
||||
FileRef::Collection(collection) => {
|
||||
let n = usize::try_from(collection.len()).ok()?;
|
||||
if n == 0 || n > MAX_COLLECTION_FACES {
|
||||
return None;
|
||||
}
|
||||
let dir_end = 12usize.checked_add(n.checked_mul(4)?)?;
|
||||
let offsets_raw = data.get(12..dir_end)?;
|
||||
let mut unique_offsets: Vec<u32> = Vec::with_capacity(n);
|
||||
let mut total_axes = 0usize;
|
||||
for (i, chunk) in offsets_raw.as_chunks::<4>().0.iter().enumerate() {
|
||||
let off = u32::from_be_bytes(*chunk);
|
||||
if unique_offsets.contains(&off) {
|
||||
continue;
|
||||
}
|
||||
let idx = u32::try_from(i).ok()?;
|
||||
let Ok(font) = collection.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
total_axes = total_axes.checked_add(font.axes().len())?;
|
||||
if total_axes > MAX_COLLECTION_AXES {
|
||||
return None;
|
||||
}
|
||||
unique_offsets.push(off);
|
||||
}
|
||||
if unique_offsets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if unique_offsets.len() == n {
|
||||
return Some(blob);
|
||||
}
|
||||
let mut bytes = data.to_vec();
|
||||
bytes.get_mut(8..12)?.copy_from_slice(&(unique_offsets.len() as u32).to_be_bytes());
|
||||
for (slot, off) in unique_offsets.iter().enumerate() {
|
||||
let pos = 12 + slot * 4;
|
||||
bytes.get_mut(pos..pos + 4)?.copy_from_slice(&off.to_be_bytes());
|
||||
}
|
||||
Some(Blob::new(Arc::new(bytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a collection expose only the requested face without rewriting any font tables.
|
||||
/// TTC table offsets are absolute; changing its face directory preserves them verbatim.
|
||||
fn isolated_face(blob: Blob<u8>, index: u32) -> Option<Blob<u8>> {
|
||||
|
||||
@@ -606,12 +606,13 @@ impl Layouter {
|
||||
// fetches it (`served`); it joins the fallback stack once it arrives.
|
||||
crate::served::request_for_text(&ptext);
|
||||
let fallback: Vec<String> = fonts.fallback_stack().map(str::to_string).collect();
|
||||
let si0 = style_at(prange.start);
|
||||
let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len());
|
||||
// Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px).
|
||||
let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout<RunBrush> {
|
||||
let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false);
|
||||
// Paragraph-start style as the default (covers the direction mark and empty
|
||||
// paragraphs), then every run piece intersecting this paragraph.
|
||||
let si0 = style_at(prange.start);
|
||||
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) {
|
||||
b.push_default(p);
|
||||
}
|
||||
@@ -621,7 +622,7 @@ impl Layouter {
|
||||
if is_box {
|
||||
b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord));
|
||||
}
|
||||
for (ri, st) in out.styles.iter().enumerate() {
|
||||
for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) {
|
||||
let rs = run_starts[ri];
|
||||
let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
|
||||
if a >= z {
|
||||
@@ -684,8 +685,7 @@ impl Layouter {
|
||||
// (or area span) width divided by it (#5565). Mixed scales break at the largest, so
|
||||
// no line overflows the box.
|
||||
let break_scale = if wraps {
|
||||
let si0 = style_at(prange.start);
|
||||
(0..out.styles.len())
|
||||
(si0..si_end)
|
||||
.filter(|&ri| {
|
||||
let rs = run_starts.get(ri).copied().unwrap_or(0);
|
||||
let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
|
||||
+130
-54
@@ -473,15 +473,22 @@ fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> {
|
||||
(bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]).then_some(bounds)
|
||||
}
|
||||
|
||||
const MAX_ENGINE_RUNS: usize = 65_536;
|
||||
|
||||
pub(crate) fn arr_f(v: Option<&E>) -> Vec<f64> {
|
||||
v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default()
|
||||
v.and_then(E::as_array).map(|a| a.iter().take(MAX_ENGINE_RUNS).filter_map(E::as_f64).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths.
|
||||
/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths, stopping after the first run
|
||||
/// once `text` is exhausted so trailing runs past the text aren't processed.
|
||||
pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec<usize> {
|
||||
let mut out = Vec::with_capacity(lens.len());
|
||||
let mut out = Vec::with_capacity(lens.len().min(text.len().saturating_add(1)));
|
||||
let mut chars = text.chars().peekable();
|
||||
for &l in lens {
|
||||
let mut exhausted = false;
|
||||
for &l in lens.iter().take(MAX_ENGINE_RUNS) {
|
||||
if exhausted {
|
||||
break;
|
||||
}
|
||||
let mut units = l.max(0.0) as usize;
|
||||
let mut bytes = 0;
|
||||
while units > 0 {
|
||||
@@ -490,9 +497,15 @@ pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec<usize> {
|
||||
units = units.saturating_sub(c.len_utf16());
|
||||
bytes += c.len_utf8();
|
||||
}
|
||||
None => break,
|
||||
None => {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if chars.peek().is_none() {
|
||||
exhausted = true;
|
||||
}
|
||||
out.push(bytes);
|
||||
}
|
||||
out
|
||||
@@ -533,17 +546,22 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
|
||||
// Paragraph runs first: a character run's defaults depend on its paragraph.
|
||||
let prun = e.path(&["EngineDict", "ParagraphRun"]);
|
||||
let psheets: Vec<&E> =
|
||||
prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default();
|
||||
let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray")));
|
||||
let plens = utf16_to_byte_lengths(&ed_text, &plens16);
|
||||
let max_pruns = plens.len().max(1);
|
||||
let psheets: Vec<&E> = prun
|
||||
.and_then(|r| r.get("RunArray"))
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).take(max_pruns).collect())
|
||||
.unwrap_or_default();
|
||||
let plens16 = &plens16[..plens16.len().min(psheets.len())];
|
||||
let empty = E::Dict(Vec::new());
|
||||
// Per paragraph run: its merged base properties and its default character style.
|
||||
let para_bases: Vec<(E, Option<&E>)> = psheets
|
||||
// Per paragraph run: its borrowed base property layers and its default character style.
|
||||
let para_bases: Vec<([Option<&E>; 2], Option<&E>)> = psheets
|
||||
.iter()
|
||||
.map(|sheet| {
|
||||
let style = named(para_set, sheet).and_then(|i| para_set.get(i));
|
||||
let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]);
|
||||
let base = [style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))];
|
||||
let char_default = [Some(*sheet), style]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -555,15 +573,19 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
.iter()
|
||||
.zip(¶_bases)
|
||||
.zip(plens)
|
||||
.map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) })
|
||||
.map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(base, sheet.get("Properties").unwrap_or(&empty), k) })
|
||||
.collect();
|
||||
|
||||
// Character runs.
|
||||
let srun = e.path(&["EngineDict", "StyleRun"]);
|
||||
let ssheets: Vec<&E> =
|
||||
srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default();
|
||||
let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray")));
|
||||
let slens = utf16_to_byte_lengths(&ed_text, &slens16);
|
||||
let max_sruns = slens.len().max(1);
|
||||
let ssheets: Vec<&E> = srun
|
||||
.and_then(|r| r.get("RunArray"))
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).take(max_sruns).collect())
|
||||
.unwrap_or_default();
|
||||
// The paragraph run each character run starts in (UTF-16 offsets).
|
||||
let para_of = |at: f64| -> Option<usize> {
|
||||
let mut end = 0.0;
|
||||
@@ -576,14 +598,14 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
plens16.len().checked_sub(1)
|
||||
};
|
||||
let mut at16 = 0.0;
|
||||
let bases: Vec<E> = ssheets
|
||||
let bases: Vec<[Option<&E>; 3]> = ssheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sheet)| {
|
||||
let own = named(style_set, sheet).and_then(|i| style_set.get(i));
|
||||
let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c);
|
||||
at16 += slens16.get(i).copied().unwrap_or(0.0);
|
||||
merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData"))))
|
||||
[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData")))
|
||||
})
|
||||
.collect();
|
||||
let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect();
|
||||
@@ -592,12 +614,12 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
.zip(&bases)
|
||||
.zip(&slens)
|
||||
.map(|((d, base), len)| {
|
||||
let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
let auto = lookup(d, base, "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, base, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
(*len, auto, kern)
|
||||
})
|
||||
.collect();
|
||||
layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect();
|
||||
layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(base, d, &fonts, k) }).collect();
|
||||
layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming);
|
||||
|
||||
// Shape.
|
||||
@@ -612,26 +634,11 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dictionaries in `levels` as one: a key comes from the first level that has it.
|
||||
fn merged(levels: &[Option<&E>]) -> E {
|
||||
let mut out: Vec<(String, E)> = Vec::new();
|
||||
for level in levels.iter().flatten() {
|
||||
if let E::Dict(kv) = level {
|
||||
for (k, v) in kv {
|
||||
if !out.iter().any(|(o, _)| o == k) {
|
||||
out.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
E::Dict(out)
|
||||
fn lookup<'a>(run: &'a E, base: &[Option<&'a E>], key: &str) -> Option<&'a E> {
|
||||
run.get(key).or_else(|| base.iter().flatten().find_map(|b| b.get(key)))
|
||||
}
|
||||
|
||||
fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> {
|
||||
run.get(key).or_else(|| base.and_then(|b| b.get(key)))
|
||||
}
|
||||
|
||||
pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle {
|
||||
pub(crate) fn char_style(base: &[Option<&E>], d: &E, fonts: &[String], k: f32) -> CharStyle {
|
||||
let g = |key: &str| lookup(d, base, key);
|
||||
let num = |key: &str| g(key).and_then(E::as_f64);
|
||||
// An f32 that a huge finite f64 overflowed to infinity is dropped too.
|
||||
@@ -696,7 +703,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle {
|
||||
pub(crate) fn para_style(base: &[Option<&E>], d: &E, k: f32) -> ParagraphStyle {
|
||||
let num = |key: &str| lookup(d, base, key).and_then(E::as_f64);
|
||||
let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite());
|
||||
ParagraphStyle {
|
||||
@@ -1277,6 +1284,12 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
|
||||
/// own objects hold a few hundred (611 at most in the file from #7698).
|
||||
const TEMPLATE_NODES: usize = 4096;
|
||||
|
||||
/// Cumulative node and byte budgets across all run dictionaries cloned while refitting one `Txt2`
|
||||
/// object (`runs_per` and `runs_to_length`), so an edited object with a large preserved run and
|
||||
/// many output paragraphs falls back to a fresh object instead of amplifying memory.
|
||||
const MAX_REFIT_RUN_NODES: usize = 16_384;
|
||||
const MAX_REFIT_RUN_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over).
|
||||
fn fits(v: &E, budget: &mut usize) -> bool {
|
||||
let Some(left) = budget.checked_sub(1) else { return false };
|
||||
@@ -1288,6 +1301,27 @@ fn fits(v: &E, budget: &mut usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` fits within the remaining `(nodes, bytes)` budget, deducting its nodes and string
|
||||
/// bytes as it walks.
|
||||
fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool {
|
||||
let Some(left) = nodes.checked_sub(1) else { return false };
|
||||
*nodes = left;
|
||||
match v {
|
||||
E::Dict(items) => items.iter().all(|(k, x)| {
|
||||
let Some(rem) = bytes.checked_sub(k.len()) else { return false };
|
||||
*bytes = rem;
|
||||
fits_budget(x, nodes, bytes)
|
||||
}),
|
||||
E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)),
|
||||
E::String(s) | E::Name(s) => {
|
||||
let Some(rem) = bytes.checked_sub(s.len()) else { return false };
|
||||
*bytes = rem;
|
||||
true
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as
|
||||
/// Photoshop writes it.
|
||||
fn names_one_frame(object: &E) -> bool {
|
||||
@@ -1315,7 +1349,8 @@ fn new_object(template: &E, at: usize, text: &str) -> Option<E> {
|
||||
}
|
||||
}
|
||||
if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) {
|
||||
let fitted = runs_per(runs, ¶graphs, "1")?;
|
||||
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
|
||||
let fitted = runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)?;
|
||||
view.get_mut("1")?.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
@@ -1360,6 +1395,7 @@ fn edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let total = utf16_len(text) as usize;
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let E::Dict(entries) = model else { return None };
|
||||
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
|
||||
for (key, value) in entries.iter_mut() {
|
||||
if key.as_str() == "0" {
|
||||
*value = E::String(text.to_string());
|
||||
@@ -1368,7 +1404,11 @@ fn edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else {
|
||||
continue;
|
||||
};
|
||||
let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs, "1")? } else { runs_to_length(runs, total, "1")? };
|
||||
let fitted = if key.as_str() == "5" {
|
||||
runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)?
|
||||
} else {
|
||||
runs_to_length(runs, total, "1", &mut nodes, &mut bytes)?
|
||||
};
|
||||
value.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
@@ -1395,6 +1435,7 @@ fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
}
|
||||
let total = utf16_len(text) as usize;
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
|
||||
for (key, value) in entries.iter_mut() {
|
||||
if key.as_str() == "Text" {
|
||||
*value = E::String(text.to_string());
|
||||
@@ -1410,9 +1451,9 @@ fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let fitted = match runs {
|
||||
E::Array(runs) if runs.iter().all(|x| matches!(x, E::Dict(_)) && x.get("Length").is_some()) => {
|
||||
if key.as_str() == "ParagraphRun" {
|
||||
runs_per(runs, ¶graphs, "Length")?
|
||||
runs_per(runs, ¶graphs, "Length", &mut nodes, &mut bytes)?
|
||||
} else {
|
||||
runs_to_length(runs, total, "Length")?
|
||||
runs_to_length(runs, total, "Length", &mut nodes, &mut bytes)?
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
@@ -1423,13 +1464,18 @@ fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
}
|
||||
|
||||
/// `runs` (`<< … /1 length >>`, the length key named `len_key`) remade with the lengths `lens`:
|
||||
/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs`.
|
||||
fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option<Vec<E>> {
|
||||
/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs`
|
||||
/// or when the cumulative `nodes` / `bytes` budget is exceeded.
|
||||
fn runs_per(runs: &[E], lens: &[usize], len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option<Vec<E>> {
|
||||
let last = runs.last()?;
|
||||
lens.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &len)| {
|
||||
let mut r = runs.get(i).unwrap_or(last).clone();
|
||||
let src = runs.get(i).unwrap_or(last);
|
||||
if !fits_budget(src, nodes, bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut r = src.clone();
|
||||
r.set(len_key, E::Int(i64::try_from(len).ok()?));
|
||||
Some(r)
|
||||
})
|
||||
@@ -1438,14 +1484,18 @@ fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option<Vec<E>> {
|
||||
|
||||
/// `runs` (`<< … /1 length >>`, the length key named `len_key`) covering `total` UTF-16 units:
|
||||
/// the leading runs as they were, cut where `total` ends, the last kept run extended when `total`
|
||||
/// is longer. Empty runs are dropped. `None` when `runs` is empty or a length isn't a count.
|
||||
fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option<Vec<E>> {
|
||||
/// is longer. Empty runs are dropped. `None` when `runs` is empty, a length isn't a count, or the
|
||||
/// cumulative `nodes` / `bytes` budget is exceeded.
|
||||
fn runs_to_length(runs: &[E], total: usize, len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option<Vec<E>> {
|
||||
let mut out: Vec<(E, usize)> = Vec::new();
|
||||
let mut left = total;
|
||||
for r in runs {
|
||||
let len = r.get(len_key).and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?;
|
||||
let take = len.min(left);
|
||||
if take > 0 {
|
||||
if !fits_budget(r, nodes, bytes) {
|
||||
return None;
|
||||
}
|
||||
out.push((r.clone(), take));
|
||||
left -= take;
|
||||
}
|
||||
@@ -1453,7 +1503,13 @@ fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option<Vec<E>> {
|
||||
if left > 0 {
|
||||
match out.last_mut() {
|
||||
Some(last) => last.1 = last.1.checked_add(left)?,
|
||||
None => out.push((runs.last()?.clone(), left)),
|
||||
None => {
|
||||
let last = runs.last()?;
|
||||
if !fits_budget(last, nodes, bytes) {
|
||||
return None;
|
||||
}
|
||||
out.push((last.clone(), left));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
@@ -1799,6 +1855,26 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
|
||||
write_tysh(&TySh { transform: layer.transform, text, warp, bounds })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod run_length_safety_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utf16_lengths_stop_after_text_is_consumed() {
|
||||
assert_eq!(utf16_to_byte_lengths("é😀", &[1.0, 2.0, 1.0, 1.0e300]), [2, 4]);
|
||||
assert_eq!(utf16_to_byte_lengths("", &[1.0, 1.0e300]), [0]);
|
||||
assert_eq!(utf16_to_byte_lengths("abc", &[0.0, 1.0, 0.0, 1.0, 1.0, 1.0e300]), [0, 1, 0, 1, 1]);
|
||||
let zero_runs = vec![0.0; MAX_ENGINE_RUNS + 1];
|
||||
assert_eq!(utf16_to_byte_lengths("a", &zero_runs).len(), MAX_ENGINE_RUNS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_run_length_array_conversion_is_bounded() {
|
||||
let values = E::Array(vec![E::Real(0.0); MAX_ENGINE_RUNS + 1]);
|
||||
assert_eq!(arr_f(Some(&values)).len(), MAX_ENGINE_RUNS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod transform_safety_tests {
|
||||
use super::*;
|
||||
@@ -1826,17 +1902,17 @@ mod nonfinite_tests {
|
||||
#[test]
|
||||
fn non_finite_style_numbers_fall_back_to_defaults() {
|
||||
let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap();
|
||||
let s = char_style(None, &d, &[], 1.0);
|
||||
let s = char_style(&[], &d, &[], 1.0);
|
||||
let dflt = CharStyle::default();
|
||||
assert_eq!(s.size_pt, dflt.size_pt);
|
||||
assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0));
|
||||
assert_eq!(s.color, Color::BLACK);
|
||||
// A finite f64 too big for f32 is dropped as well.
|
||||
let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt);
|
||||
assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, dflt.size_pt);
|
||||
assert_eq!(para_style(&[], &d, 1.0).start_indent_pt, 0.0);
|
||||
let d = parse(b"<< /FontSize 24.0 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, 24.0);
|
||||
}
|
||||
|
||||
/// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back.
|
||||
@@ -1844,11 +1920,11 @@ mod nonfinite_tests {
|
||||
fn font_baseline_round_trips() {
|
||||
use photocraft_doc::text::FontBaseline;
|
||||
let d = parse(b"<< /FontBaseline 2 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Subscript);
|
||||
let d = parse(b"<< /FontBaseline 1 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Superscript);
|
||||
let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() };
|
||||
let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0);
|
||||
let back = char_style(&[], &style_sheet_data(&s, 0, 1.0), &[], 1.0);
|
||||
assert_eq!(back.baseline, FontBaseline::Subscript);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,9 +87,9 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0);
|
||||
let empty = E::dict();
|
||||
let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData"));
|
||||
let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k);
|
||||
let normal_char = char_style(&[], base_data.unwrap_or(&empty), &fonts, k);
|
||||
let base_props = pset.get(normal_p).and_then(|s| s.get("Properties"));
|
||||
let normal_para = para_style(None, base_props.unwrap_or(&empty), k);
|
||||
let normal_para = para_style(&[], base_props.unwrap_or(&empty), k);
|
||||
let mut out = PsdStyleSheets { normal_char, normal_para, ..Default::default() };
|
||||
for (i, s) in sset.iter().enumerate() {
|
||||
if i == normal_c {
|
||||
@@ -97,7 +97,7 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
}
|
||||
let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string();
|
||||
let data = s.get("StyleSheetData").unwrap_or(&empty);
|
||||
out.char_sheets.push((i, name, char_style(base_data, data, &fonts, k)));
|
||||
out.char_sheets.push((i, name, char_style(&[base_data], data, &fonts, k)));
|
||||
}
|
||||
for (i, s) in pset.iter().enumerate() {
|
||||
if i == normal_p {
|
||||
@@ -106,8 +106,8 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string();
|
||||
let props = s.get("Properties").unwrap_or(&empty);
|
||||
let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(sheet_index).filter(|i| *i != normal_c);
|
||||
let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k));
|
||||
out.para_sheets.push((i, name, para_style(base_props, props, k), cs));
|
||||
let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(&[base_data], d.get("StyleSheetData").unwrap_or(&empty), &fonts, k));
|
||||
out.para_sheets.push((i, name, para_style(&[base_props], props, k), cs));
|
||||
}
|
||||
let snames = names(res.get("StyleSheetSet"));
|
||||
let pnames = names(res.get("ParagraphSheetSet"));
|
||||
|
||||
+114
-21
@@ -21,6 +21,8 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0;
|
||||
pub const FAUX_BOLD_RADIUS: f32 = 0.036;
|
||||
/// Largest raster we produce (pixels), as a guard against absurd sizes.
|
||||
const MAX_PIXELS: u64 = 256 * 1024 * 1024;
|
||||
/// Cumulative coverage-pixel work budget across all colour and faux-bold passes of one render.
|
||||
const MAX_PASS_PIXELS: u64 = MAX_PIXELS * 4;
|
||||
/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the
|
||||
/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more
|
||||
/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does
|
||||
@@ -135,6 +137,54 @@ fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool {
|
||||
layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold
|
||||
}
|
||||
|
||||
fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) -> bool {
|
||||
let Some(face) = layout.faces.get(g.face as usize) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
|
||||
return false;
|
||||
};
|
||||
if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(w) = warp {
|
||||
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
if matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Distinct visible colours (`alpha > 0.0`) used by placed glyphs or decorations, each paired with
|
||||
/// whether any placed glyph of that colour uses faux/synthetic bold.
|
||||
fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) -> Vec<(Color, bool)> {
|
||||
let mut colors: Vec<(Color, bool)> = Vec::new();
|
||||
let mut record = |c: Color, bold: bool| {
|
||||
if !(c.alpha.is_finite() && c.alpha > 0.0) {
|
||||
return;
|
||||
}
|
||||
match colors.iter_mut().find(|(existing, _)| *existing == c) {
|
||||
Some((_, b)) => *b |= bold,
|
||||
None => colors.push((c, bold)),
|
||||
}
|
||||
};
|
||||
for g in &layout.glyphs {
|
||||
let Some(st) = layout.styles.get(g.style as usize) else { continue };
|
||||
if !(st.color.alpha.is_finite() && st.color.alpha > 0.0) {
|
||||
continue;
|
||||
}
|
||||
if glyph_is_placed(layout, g, warp) {
|
||||
record(st.color, is_bold(layout, g));
|
||||
}
|
||||
}
|
||||
for d in &layout.decorations {
|
||||
let Some(st) = layout.styles.get(d.style as usize) else { continue };
|
||||
record(st.color, false);
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) {
|
||||
for g in &layout.glyphs {
|
||||
let st = &layout.styles[g.style as usize];
|
||||
@@ -310,6 +360,19 @@ pub fn rasterize(layout: &TextLayout, transform: &Affine, format: PixelFormat, a
|
||||
|
||||
/// [`rasterize`] with the glyph outlines bent by `warp` (Type › Warp Text).
|
||||
pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFormat, antialias: AntiAlias, warp: Option<&Warp>) -> Rendered {
|
||||
rasterize_passes(layout, transform, format, antialias, warp, true)
|
||||
}
|
||||
|
||||
/// [`rasterize_warped`] with every colour's coverage either confined to that colour's own ink
|
||||
/// (`per_colour`) or spanning the whole text, the oracle the tests compare it with (#8602).
|
||||
pub(crate) fn rasterize_passes(
|
||||
layout: &TextLayout,
|
||||
transform: &Affine,
|
||||
format: PixelFormat,
|
||||
antialias: AntiAlias,
|
||||
warp: Option<&Warp>,
|
||||
per_colour: bool,
|
||||
) -> Rendered {
|
||||
let format = PixelFormat { alpha: true, ..format };
|
||||
let rect = ink_rect_warped(layout, transform, warp);
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
@@ -317,10 +380,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS {
|
||||
return Rendered { surface, rect: Rect::new(0, 0, 0, 0) };
|
||||
}
|
||||
let colors = visible_color_passes(layout, warp);
|
||||
if colors.is_empty() {
|
||||
return Rendered { surface, rect: Rect::new(0, 0, 0, 0) };
|
||||
}
|
||||
let n = format.mode.color_channels();
|
||||
let stride = n + 1;
|
||||
let bold = layout.glyphs.iter().any(|g| is_bold(layout, g));
|
||||
if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) {
|
||||
let any_bold = colors.iter().any(|&(_, b)| b);
|
||||
if !fits_raster_budget(w, h, stride, format.sample, any_bold, MAX_RASTER_BYTES) {
|
||||
return nothing(format);
|
||||
}
|
||||
// Premultiplied accumulation.
|
||||
@@ -328,26 +395,45 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
return nothing(format);
|
||||
};
|
||||
let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m));
|
||||
let mut colors: Vec<Color> = Vec::new();
|
||||
for st in &layout.styles {
|
||||
if !colors.contains(&st.color) {
|
||||
colors.push(st.color);
|
||||
let whole = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut pass_pixels = 0u64;
|
||||
for &(c, color_bold) in &colors {
|
||||
// Rasterise each colour only over its own glyphs (every offset of faux bold included):
|
||||
// a full-size pass per colour made text with many coloured runs cost colours x area
|
||||
// (#8602). Outside its ink a colour's coverage is zero, so the result is the same.
|
||||
let sub = if per_colour {
|
||||
let mut b = Bounds::default();
|
||||
draw_pass(layout, &xf, &mut b, Some(&c), warp, Pass::All);
|
||||
b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY).intersect(&whole)
|
||||
} else {
|
||||
whole
|
||||
};
|
||||
if sub.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for c in &colors {
|
||||
let Some(mut cov) = Coverage::try_new(w, h) else {
|
||||
let (sw, sh) = (sub.width() as usize, sub.height() as usize);
|
||||
let passes = 1 + if color_bold { BOLD_OFFSETS.len() } else { 0 };
|
||||
let Some(cost) = (sw as u64).checked_mul(sh as u64).and_then(|px| px.checked_mul(passes as u64)) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain);
|
||||
let Some(total) = pass_pixels.checked_add(cost).filter(|&t| t <= MAX_PASS_PIXELS) else {
|
||||
return nothing(format);
|
||||
};
|
||||
pass_pixels = total;
|
||||
let xf = Xform([1.0, 0.0, 0.0, 1.0, -sub.x0 as f64, -sub.y0 as f64]).mul(&xf);
|
||||
let Some(mut cov) = Coverage::try_new(sw, sh) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut cov, Some(&c), warp, Pass::Plain);
|
||||
let Some(mut cov) = cov.finish() else {
|
||||
return nothing(format);
|
||||
};
|
||||
if bold {
|
||||
if color_bold {
|
||||
for k in 0..BOLD_OFFSETS.len() {
|
||||
let Some(mut bc) = Coverage::try_new(w, h) else {
|
||||
let Some(mut bc) = Coverage::try_new(sw, sh) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k));
|
||||
draw_pass(layout, &xf, &mut bc, Some(&c), warp, Pass::Bold(k));
|
||||
let Some(bold_cov) = bc.finish() else {
|
||||
return nothing(format);
|
||||
};
|
||||
@@ -356,9 +442,9 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
}
|
||||
}
|
||||
}
|
||||
let comps = color_in(&format, c);
|
||||
let comps = color_in(&format, &c);
|
||||
let a = c.alpha.clamp(0.0, 1.0);
|
||||
composite(&mut acc, &cov, w, stride, &comps, a, antialias);
|
||||
composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias);
|
||||
}
|
||||
// Unpremultiply.
|
||||
unpremultiply(&mut acc, n, stride, format.sample);
|
||||
@@ -367,12 +453,19 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
Rendered { surface, rect }
|
||||
}
|
||||
|
||||
/// Composites one colour's coverage over the premultiplied accumulator, after [`shape_antialias_coverage`].
|
||||
/// A pixel's maths reads and writes only its own slots, so the rows are done in any
|
||||
/// order — one per thread — for exactly the same bytes.
|
||||
fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) {
|
||||
/// Composites one colour's coverage, covering `sub` of a `width`-pixel-wide premultiplied
|
||||
/// accumulator, after [`shape_antialias_coverage`]. A pixel's maths reads and writes only its own
|
||||
/// slots, so the rows are done in any order — one per thread — for exactly the same bytes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn composite(acc: &mut [f32], width: usize, sub: Rect, cov: &[f32], stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) {
|
||||
let n = comps.len();
|
||||
let (x0, x1) = (sub.x0.max(0) as usize * stride, sub.x1.max(0) as usize * stride);
|
||||
let (y0, sw, sh) = (sub.y0.max(0) as usize, sub.width() as usize, sub.height() as usize);
|
||||
if sw == 0 || sh == 0 {
|
||||
return;
|
||||
}
|
||||
let row = |acc: &mut [f32], cov: &[f32]| {
|
||||
let Some(acc) = acc.get_mut(x0..x1) else { return };
|
||||
for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) {
|
||||
let cv = shape_antialias_coverage(*cv, antialias);
|
||||
let s = cv * a;
|
||||
@@ -387,9 +480,9 @@ fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c));
|
||||
acc.par_chunks_mut(width * stride).skip(y0).take(sh).zip(cov.par_chunks(sw)).for_each(|(a, c)| row(a, c));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c));
|
||||
acc.chunks_mut(width * stride).skip(y0).take(sh).zip(cov.chunks(sw)).for_each(|(a, c)| row(a, c));
|
||||
}
|
||||
|
||||
/// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone.
|
||||
|
||||
+68
-10
@@ -40,25 +40,60 @@ pub struct ServedFont {
|
||||
pub fn parse_manifest(text: &str) -> (Vec<ServedFont>, Vec<String>) {
|
||||
let mut fonts = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let raw_fields: Vec<&str> = trimmed.split(" | ").collect();
|
||||
let f: Vec<&str> = raw_fields.iter().map(|s| s.trim()).collect();
|
||||
let raw_file = raw_fields.get(2).copied().unwrap_or("");
|
||||
let valid_file = raw_file == f.get(2).copied().unwrap_or("") && is_relative_path(raw_file);
|
||||
match f.as_slice() {
|
||||
[family, _style, file, rest @ ..] if !family.is_empty() && is_relative_path(file) => {
|
||||
[family, _style, file, rest @ ..] if !family.is_empty() && valid_file => {
|
||||
let scripts = rest.first().map(|s| s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default();
|
||||
let integrity = rest.get(3).and_then(|sha| sri(sha));
|
||||
fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string(), scripts, integrity });
|
||||
}
|
||||
[_, _, file, ..] if !is_relative_path(file) => skipped.push(format!("{line}: the file must be a relative path inside the site")),
|
||||
_ => skipped.push(format!("{line}: expected `family | style | file | …`")),
|
||||
[_, _, _, ..] if !valid_file => skipped.push(format!("{trimmed}: the file must be a relative path inside the site")),
|
||||
_ => skipped.push(format!("{trimmed}: expected `family | style | file | …`")),
|
||||
}
|
||||
}
|
||||
(fonts, skipped)
|
||||
}
|
||||
|
||||
/// A relative path that stays inside the site: no scheme, no leading `/`, no `..` segment, no
|
||||
/// query or fragment.
|
||||
fn is_relative_path(p: &str) -> bool {
|
||||
!p.is_empty() && !p.starts_with('/') && !p.contains(['\\', '?', '#', ':']) && p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
|
||||
/// query or fragment, no ASCII control characters or whitespace, and no percent-encoded control,
|
||||
/// whitespace or separator characters.
|
||||
pub fn is_relative_path(p: &str) -> bool {
|
||||
if p.is_empty()
|
||||
|| p.starts_with('/')
|
||||
|| p.bytes().any(|b| b <= b' ' || b == 0x7f || matches!(b, b'\\' | b'?' | b'#' | b':'))
|
||||
|| !p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let b = p.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%' {
|
||||
let Some(hex) = p.get(i + 1..i + 3) else { return false };
|
||||
let Ok(byte) = u8::from_str_radix(hex, 16) else { return false };
|
||||
// Never allow encoded path syntax (including '%' for a second decode), ASCII
|
||||
// separators, or controls. High bytes are allowed only as valid UTF-8 below.
|
||||
if byte <= b' ' || byte == 0x7f || matches!(byte, b'%' | b'/' | b'\\' | b'.' | b'?' | b'#' | b':') {
|
||||
return false;
|
||||
}
|
||||
decoded.push(byte);
|
||||
i = i.saturating_add(2);
|
||||
} else {
|
||||
let Some(&byte) = b.get(i) else { return false };
|
||||
decoded.push(byte);
|
||||
}
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
std::str::from_utf8(&decoded).is_ok()
|
||||
}
|
||||
|
||||
/// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers
|
||||
@@ -318,8 +353,31 @@ mod tests {
|
||||
assert_eq!(skipped.len(), 9, "{skipped:?}");
|
||||
}
|
||||
|
||||
/// One test for the whole queue: the catalog and the queue are process-wide, and parallel
|
||||
/// tests taking each other's requests would be flaky.
|
||||
#[test]
|
||||
fn rejects_encoded_path_separators_and_malformed_escapes() {
|
||||
for path in [
|
||||
"fonts/%2e%2e/private.ttf",
|
||||
"fonts%2fprivate.ttf",
|
||||
"fonts%5cprivate.ttf",
|
||||
"fonts/%00.ttf",
|
||||
"fonts/a%7F.ttf",
|
||||
"fonts/%20.ttf",
|
||||
"fonts/%zz.ttf",
|
||||
"fonts/%2.ttf",
|
||||
"fonts/%C0%AF.ttf",
|
||||
"fonts/%25%32%65%25%32%65/private.ttf",
|
||||
"fonts/%C3%28.ttf",
|
||||
] {
|
||||
assert!(!is_relative_path(path), "accepted {path}");
|
||||
}
|
||||
for path in ["fonts/OpenSans[wdth,wght].ttf", "fonts/é.ttf", "fonts/%C3%A9.ttf"] {
|
||||
assert!(is_relative_path(path), "rejected {path}");
|
||||
}
|
||||
let (fonts, skipped) = parse_manifest("A | Regular | fonts/%2e%2e/a.ttf\nB | Regular | fonts/%zz.ttf\n");
|
||||
assert!(fonts.is_empty());
|
||||
assert_eq!(skipped.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_limited_to_the_catalog_and_reach_layout() {
|
||||
let served = "Served Test Sans";
|
||||
|
||||
@@ -1075,7 +1075,7 @@ mod tests {
|
||||
#[test]
|
||||
fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() {
|
||||
let it = crate::i18n::Lang::from_code("it").expect("it registered");
|
||||
assert_eq!(crate::i18n::tr(it, "Green"), "Verde", "the test relies on the built-in label");
|
||||
let green = crate::i18n::tr(it, "Green");
|
||||
let _lang = crate::i18n::language_scope(it);
|
||||
let mut app = app_with_image();
|
||||
app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap();
|
||||
@@ -1083,13 +1083,13 @@ mod tests {
|
||||
let mut h = curves_harness(app);
|
||||
let names = popup_channels(&mut h);
|
||||
assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}");
|
||||
assert!(!names.iter().any(|n| n == "Verde"), "not translated: {names:?}");
|
||||
assert!(green == "Green" || !names.iter().any(|n| n == green), "not translated: {names:?}");
|
||||
// A targeted colour component keeps its translated built-in label.
|
||||
let mut colour = app_with_image();
|
||||
colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap();
|
||||
let mut h = curves_harness(colour);
|
||||
let names = popup_channels(&mut h);
|
||||
assert!(names.iter().any(|n| n == "Verde"), "built-in labels follow the language: {names:?}");
|
||||
assert!(names.iter().any(|n| n == green), "built-in labels follow the language: {names:?}");
|
||||
}
|
||||
|
||||
/// Curves open in a harness over `app`.
|
||||
|
||||
@@ -1378,7 +1378,6 @@ fn brush_sections_paint_simplified_chinese_labels() {
|
||||
for (name, _) in SECTIONS {
|
||||
let translated = crate::i18n::tr(zh, name);
|
||||
assert!(painted.iter().any(|s| s == translated), "{name} must paint as {translated}");
|
||||
assert!(!painted.iter().any(|s| s == name), "{name} must not paint in English");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,24 +154,4 @@ mod tests {
|
||||
assert_eq!(clear.shortcut, Some("Delete"));
|
||||
assert_eq!(delete.shortcut, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_strings_are_translated_in_every_supported_catalog() {
|
||||
for language in crate::i18n::LANGUAGES.iter().filter(|l| l.code != "en") {
|
||||
let lang = crate::i18n::Lang::from_code(language.code).unwrap();
|
||||
for key in [
|
||||
"Delete layer ‘{name}’?",
|
||||
"Delete {count} layers? {names}",
|
||||
"Delete layer?",
|
||||
"Don't show again",
|
||||
"The document is closed",
|
||||
"The document or selected layers changed. Please request deletion again.",
|
||||
"Missing delete command",
|
||||
"Invalid delete command",
|
||||
"Layer deleted, but confirmation preference could not be saved: {error}",
|
||||
] {
|
||||
assert_ne!(crate::i18n::tr(lang, key), key, "{}: {key}", language.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,8 +787,11 @@ mod tests {
|
||||
assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]);
|
||||
let german = crate::i18n::Lang::from_code("de").unwrap();
|
||||
crate::i18n::with_language(german, || {
|
||||
assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]);
|
||||
assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]);
|
||||
let shown: Vec<&str> = buttons.iter().map(|(label, _)| crate::i18n::tr(german, label)).collect();
|
||||
assert_eq!(button_labels(true, &buttons), shown);
|
||||
let labels = button_labels(false, &buttons);
|
||||
let appended: Vec<String> = shown.iter().zip(&buttons).map(|(text, (_, key))| format!("{text} ({})", key.map_or("", |k| k.name()))).collect();
|
||||
assert!(labels.iter().all(|label| label.starts_with('(')) || labels == appended, "{labels:?}");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -385,15 +385,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_mode_depth_labels_match_sample_types_and_are_translated() {
|
||||
fn image_mode_depth_labels_match_sample_types() {
|
||||
assert_eq!(
|
||||
DEPTHS.iter().map(|(sample, label, _)| (*sample, *label)).collect::<Vec<_>>(),
|
||||
[(SampleType::U8, "8 Bits/Channel"), (SampleType::U16, "16 Bits/Channel"), (SampleType::F32, "32 Bits/Channel"),]
|
||||
);
|
||||
for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") {
|
||||
for (_, label, id) in DEPTHS {
|
||||
assert_ne!(crate::i18n::tr_id(lang, id, label), *label, "{}: {label}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1396,14 +1396,10 @@ mod tests {
|
||||
assert_eq!(source_label("centerX"), "Center X");
|
||||
}
|
||||
|
||||
/// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out, and translated.
|
||||
/// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out.
|
||||
#[test]
|
||||
fn black_point_compensation_is_spelled_out() {
|
||||
assert_eq!(source_label("bpc"), "Black Point Compensation");
|
||||
// English is the source: it has no catalog.
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
|
||||
assert!(crate::i18n::has(lang, "Black Point Compensation"), "{lang:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn key() -> FilterPreviewKey {
|
||||
@@ -1855,105 +1851,14 @@ mod tests {
|
||||
assert!(!has_dialog("filter.stylize.findEdges"));
|
||||
}
|
||||
|
||||
/// Generated filter and adjustment labels (param names, symbolic choices) and gallery
|
||||
/// names that `lang` has no catalog row for.
|
||||
fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet<String> {
|
||||
let mut missing = std::collections::BTreeSet::new();
|
||||
let mut check = |s: String| {
|
||||
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
|
||||
missing.insert(s);
|
||||
}
|
||||
};
|
||||
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("filter.") || c.id.starts_with("image.adjustments.")) {
|
||||
for p in parse_spec(c.params) {
|
||||
if !p.key.chars().all(|c| c.is_ascii_alphanumeric()) || matches!(p.kind, Kind::Json) {
|
||||
continue;
|
||||
}
|
||||
check(source_label(&p.key));
|
||||
if let Kind::Choice { options, .. } = &p.kind {
|
||||
for choice in options {
|
||||
// Only symbolic options are UI choices; registry docs also contain
|
||||
// colour syntax and array notation, which are not translatable names.
|
||||
if choice.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
check(source_label(choice));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in photocraft_algo::GalleryFilter::ALL {
|
||||
check(f.name().to_string());
|
||||
}
|
||||
for cat in photocraft_algo::GALLERY_CATEGORIES {
|
||||
check(cat.to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// Select and Mask's Output To lists all six output targets.
|
||||
#[test]
|
||||
fn korean_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap());
|
||||
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap());
|
||||
assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("es-ES").unwrap());
|
||||
assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap());
|
||||
assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
|
||||
/// params, choices and title in every UI language, not just Korean.
|
||||
#[test]
|
||||
fn select_dialogs_are_translated_in_every_language() {
|
||||
let mut missing = std::collections::BTreeMap::<&str, std::collections::BTreeSet<String>>::new();
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
|
||||
let mut check = |s: String| {
|
||||
if !crate::i18n::has(lang, &s) {
|
||||
missing.entry(lang.code()).or_default().insert(s);
|
||||
}
|
||||
};
|
||||
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("select.") && has_dialog(c.id)) {
|
||||
let title = c.label.trim_end_matches('…');
|
||||
if !crate::i18n::has(lang, title) {
|
||||
check(format!("{title}…"));
|
||||
}
|
||||
for p in parse_spec(c.params) {
|
||||
check(source_label(&p.key));
|
||||
if let Kind::Choice { options: choices, .. } = p.kind {
|
||||
choices.iter().for_each(|choice| check(source_label(choice)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "untranslated Select dialog labels: {missing:#?}");
|
||||
}
|
||||
|
||||
/// Select and Mask's Output To lists all six of Photoshop's targets, each translated.
|
||||
#[test]
|
||||
fn select_and_mask_output_choices_are_translated() {
|
||||
fn select_and_mask_output_lists_all_six_targets() {
|
||||
let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params;
|
||||
let p = parse_spec(spec).into_iter().find(|p| p.key == "output").unwrap();
|
||||
let Kind::Choice { options: choices, .. } = p.kind else { panic!("output is not a choice") };
|
||||
let labels: Vec<String> = choices.iter().map(|c| source_label(c)).collect();
|
||||
assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]);
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
|
||||
for label in &labels {
|
||||
assert!(crate::i18n::has(lang, label), "{}: untranslated {label}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -297,8 +297,11 @@ mod tests {
|
||||
assert_eq!(result["info"]["lines"], json!(english));
|
||||
assert_eq!(crate::i18n::current().code(), "cs", "diagnostics restore the drawing language");
|
||||
let screen = system_info_ui(&app);
|
||||
assert!(screen.iter().any(|line| line == "Grafický adaptér: Test GPU"), "{screen:?}");
|
||||
assert!(screen.iter().any(|line| line.starts_with("Profil monitoru:")), "{screen:?}");
|
||||
let cs = crate::i18n::current();
|
||||
let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "Test GPU")]);
|
||||
assert!(screen.contains(&adapter), "{screen:?}");
|
||||
let profile = crate::i18n::tr(cs, "Monitor profile: {profile}").split('{').next().unwrap_or_default();
|
||||
assert!(screen.iter().any(|line| line.starts_with(profile)), "{screen:?}");
|
||||
assert!(english.iter().any(|line| line == "GPU backend preference: auto"));
|
||||
});
|
||||
}
|
||||
@@ -313,8 +316,11 @@ mod tests {
|
||||
app.perf.gpu_info.preference = "auto".into();
|
||||
app.perf.gpu_info.selected = "auto".into();
|
||||
assert_eq!(system_info_ui(&app), empty);
|
||||
assert!(empty.iter().any(|line| line == "Grafický adaptér: auto"));
|
||||
assert!(empty.iter().any(|line| line == "Preferovaný backend GPU: automaticky"), "{empty:?}");
|
||||
let cs = crate::i18n::current();
|
||||
let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "auto")]);
|
||||
assert!(empty.contains(&adapter), "{empty:?}");
|
||||
let preference = crate::i18n::fmt(crate::i18n::tr(cs, "GPU backend preference: {backend}"), &[("backend", crate::i18n::tr(cs, "auto"))]);
|
||||
assert!(empty.contains(&preference), "{empty:?}");
|
||||
assert_eq!(app.perf.gpu_info.preference, "auto");
|
||||
assert_eq!(app.perf.gpu_info.selected, "auto");
|
||||
});
|
||||
|
||||
@@ -187,17 +187,6 @@ mod tests {
|
||||
assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted");
|
||||
}
|
||||
|
||||
/// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that
|
||||
/// claims complete menus must still translate them.
|
||||
#[test]
|
||||
fn menu_labels_are_translated() {
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) {
|
||||
for &(label, _) in ITEMS.iter().filter(|(l, _)| *l != "---") {
|
||||
assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_document_only_close_is_enabled() {
|
||||
let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user