Merge main into integration-batch-7

This commit is contained in:
aiongg
2026-10-23 20:37:05 +00:00
166 changed files with 4441 additions and 2029 deletions
+149 -185
View File
@@ -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
+2
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 }
+11 -10
View File
@@ -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()));
+9 -3
View File
@@ -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 })
+27 -1
View File
@@ -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"));
}
}
+22 -5
View File
@@ -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…)");
}
+13 -7
View File
@@ -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;
+2 -1
View File
@@ -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)?;
+11
View File
@@ -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
+8 -2
View File
@@ -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
}
+1
View File
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
+60 -17
View File
@@ -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 &deg 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
View File
@@ -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| {
+12
View File
@@ -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
View File
@@ -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;
}
}
+21 -3
View File
@@ -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.
+19 -4
View File
@@ -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;
+259 -82
View File
@@ -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
View File
@@ -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);
+4 -1
View File
@@ -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> {
+291
View File
@@ -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());
}
}
+80
View File
@@ -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);
}
}
+12 -1
View File
@@ -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,
+9
View File
@@ -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));
}
+16
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}
+18 -5
View File
@@ -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)
}
+74 -9
View File
@@ -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() {
+24 -1
View File
@@ -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 {
+3 -1
View File
@@ -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) {
+9 -4
View File
@@ -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"))?;
+3 -1
View File
@@ -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,
}
+19
View File
@@ -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]
+82 -45
View File
@@ -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)] {
+52 -3
View File
@@ -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) };
+21 -1
View File
@@ -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] {
+31 -2
View File
@@ -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 {
+34
View File
@@ -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.
+2 -1
View File
@@ -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;
+14 -4
View File
@@ -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
+1
View File
@@ -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.
+5 -4
View File
@@ -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) {
+17
View File
@@ -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
+3 -1
View File
@@ -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)
}
+1
View File
@@ -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;
+16 -6
View File
@@ -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}");
+463
View File
@@ -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());
}
}
+195 -6
View File
@@ -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(), &params, 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(), &params, 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(), &params, 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}");
}
}
}
+29 -25
View File
@@ -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}"));
}
}
}
+13
View File
@@ -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() {
+73 -4
View File
@@ -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)?;
+22 -2
View File
@@ -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)
}
+8 -1
View File
@@ -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;
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -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 }
+31 -7
View File
@@ -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
View File
@@ -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());
}
}
+83
View File
@@ -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.
+12 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+19 -2
View File
@@ -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]
+4
View File
@@ -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"
+10
View File
@@ -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 {
+8 -1
View File
@@ -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}");
+18 -2
View File
@@ -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()))
+3 -2
View File
@@ -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()))?;
+41
View File
@@ -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
View File
@@ -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] {
+23 -6
View File
@@ -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);
+5 -3
View File
@@ -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() {
+37 -2
View File
@@ -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");
}
}
+50 -2
View File
@@ -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
View File
@@ -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).
+2
View File
@@ -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;
+60
View File
@@ -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
View File
@@ -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
+62
View File
@@ -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>> {
+4 -4
View File
@@ -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
View File
@@ -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(&para_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, &paragraphs, "1")?;
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
let fitted = runs_per(runs, &paragraphs, "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, &paragraphs, "1")? } else { runs_to_length(runs, total, "1")? };
let fitted = if key.as_str() == "5" {
runs_per(runs, &paragraphs, "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, &paragraphs, "Length")?
runs_per(runs, &paragraphs, "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);
}
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+3 -3
View File
@@ -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`.
-1
View File
@@ -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");
}
}
-20
View File
@@ -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);
}
}
}
}
+5 -2
View File
@@ -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:?}");
});
}
+1 -6
View File
@@ -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());
}
}
}
}
+3 -98
View File
@@ -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]
+10 -4
View File
@@ -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");
});
-11
View File
@@ -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