diff --git a/.config/nextest.toml b/.config/nextest.toml index 2ef6d6f215..a1e0a2ee0a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -11,7 +11,7 @@ nextest-version = "0.9.150" # # GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer # serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so -# this group caps them at 4. +# this group caps them at 4. scripts/ci-gpu-lavapipe.py reads this group's filters directly. [test-groups] gpu = { max-threads = 4 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c15bc9542b..2c3597f5ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,7 +151,7 @@ jobs: # egui/winit/rfd. packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev - libgtk-3-dev + libgtk-3-dev mesa-vulkan-drivers libvulkan1 # 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: b076417b5880bd4154bd22f00695b1d28c6bea83 @@ -164,7 +164,7 @@ jobs: 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 + command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py # 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 diff --git a/.github/workflows/house-rules.yml b/.github/workflows/house-rules.yml new file mode 100644 index 0000000000..de64ad3ee4 --- /dev/null +++ b/.github/workflows/house-rules.yml @@ -0,0 +1,28 @@ +name: House rules + +on: + push: + branches: [main] + paths: + - AGENTS.md + - CLAUDE.md + - .github/workflows/house-rules.yml + pull_request: + paths: + - AGENTS.md + - CLAUDE.md + - .github/workflows/house-rules.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: house-rules-${{ github.ref }} + cancel-in-progress: true + +jobs: + house-rules: + uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main + with: + rust: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f41a2e8c1..d1efb21c08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,14 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main release: + needs: credits # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: @@ -27,6 +34,7 @@ jobs: app-title: PhotoCraft craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -42,3 +50,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/AGENTS.md b/AGENTS.md index 32074b4e45..70cbd9e355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,56 @@ + +## House rules + +These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy. + +1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments. +2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish. +3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location. +4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix. +5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true. +6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why. +7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready. +8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception. +9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop. +10. **Keep secrets and personal details out** of the repository, logs and requests. +11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there. +12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it. +13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it. +14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit. +15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that. + + +## Rust Craft repositories + +These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib). + +1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix. +2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it. +3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository. +4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build. +5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md). +6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`. + +## Licence allowlist + +Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules. + +- `ISC` +- `MIT` +- `BSD-2-Clause` +- `BSD-3-Clause` +- `Apache-2.0` +- `OFL-1.1` +- `Ubuntu-font-1.0` +- `Bitstream-Vera` +- `CC0-1.0` +- `CC-BY-4.0` +- `CC-BY-SA-4.0` +- `LicenseRef-Public-Domain` + +`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so. + + # AGENTS.md: guide for AI agents and contributors PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`. @@ -70,7 +123,6 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file, 8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log. 9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green. -10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). ## 4. Picking work @@ -130,7 +182,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still - `docs/parity-checklist.md`: generated Photoshop menu coverage. - `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit). - `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`. -- `plan/` (local, gitignored): research, parity plan, execution plan, estimates. +- `plan/` is an optional gitignored notebook (research, parity notes, estimates). Do not ask for it and do not stop when it is absent. - `log/` (local, gitignored): the dev log. ## 8. Keeping the native format complete diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 700bccb19f..f11c7f30e0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,19 +25,28 @@ next to them. | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ar.tsv` | Arabic UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/az.tsv` | Azerbaijani (Latin script) UI translations | PhotoCraft contributors; AI contributor: OpenAI Codex | PhotoCraft English labels; Google Translate drafts with edited core terminology, contexts and plurals | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/bs.tsv` | Bosnian (Latin, Ijekavian) UI translations | Nine (@nine-codes) with Claude Code, AI-assisted; not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/he.tsv` | Hebrew UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ur.tsv` | Urdu UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/th.tsv` | Thai UI translations | @Thanawatttt and PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor | Machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ta.tsv` | Tamil UI translations | @newsbaba24x7 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ka.tsv` | Georgian UI translations | @devius with Claude Code, AI-assisted | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -55,6 +64,7 @@ next to them. | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/history-brush.svg` | History Brush tool glyph: Lucide `brush` plus counter-clockwise history rewind arrow | Lucide Icons and Contributors; arrow by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -96,6 +106,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/guide-position-*-tab.png` | New Guide before and after focusing Position with Tab (no artwork) | PhotoCraft contributors | Offscreen `snapshot` example with `ui.key Tab` | MIT OR Apache-2.0 | | `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 | | `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 | | `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index ab0417b79b..cec58ffb71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -751,6 +762,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -828,6 +848,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -947,6 +973,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.5.1" @@ -1051,6 +1086,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -1796,6 +1841,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "ecolor" version = "0.37.0" @@ -3010,6 +3064,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8" + [[package]] name = "icu_collections" version = "2.3.0" @@ -3018,6 +3094,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -3291,6 +3368,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3884,6 +3971,34 @@ dependencies = [ "imgref", ] +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags 2.13.2", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.5", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.21", + "ttf-parser", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -3911,6 +4026,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -4142,6 +4267,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -5076,15 +5212,18 @@ dependencies = [ "clap", "getrandom 0.3.4", "photocraft-codecs", + "photocraft-color", "photocraft-compose", "photocraft-doc", "photocraft-engine", "photocraft-format", "photocraft-geom", + "photocraft-gpu", "photocraft-io", "photocraft-psd", "photocraft-raster", "photocraft-raw", + "pollster 1.0.1", "rmcp", "rustix 1.1.5", "schemars", @@ -5092,6 +5231,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", + "wgpu", ] [[package]] @@ -5103,14 +5243,20 @@ dependencies = [ "clap_mangen", "photocraft-automation", "photocraft-codecs", + "photocraft-doc", "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-psd", + "photocraft-raster", "photocraft-raw", + "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "tokio", "winresource", + "xtask", ] [[package]] @@ -5219,6 +5365,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "wat", + "web-time", "winsafe", ] @@ -5270,6 +5417,7 @@ dependencies = [ "photocraft-vector", "pollster 1.0.1", "rayon", + "thiserror 2.0.21", "wgpu", ] @@ -5289,6 +5437,7 @@ dependencies = [ "crc32fast", "flate2", "imagesize", + "lopdf", "miniz_oxide 0.9.1", "photocraft-affinity", "photocraft-algo", @@ -5312,6 +5461,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tiff", + "ttf-parser", "zune-core 0.5.3", "zune-jpegxl", ] @@ -5379,6 +5529,7 @@ dependencies = [ "photocraft-geom", "proptest", "rayon", + "rustix 1.1.5", "serde", "sysinfo", "winsafe", @@ -5404,10 +5555,13 @@ name = "photocraft-tablet" version = "0.20.1" dependencies = [ "block2 0.6.2", + "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-graphics", "objc2-foundation 0.3.2", + "windows", + "wintab", "x11rb", ] @@ -5439,6 +5593,7 @@ name = "photocraft-ui-egui" version = "0.20.1" dependencies = [ "base64 0.23.1", + "chrono", "craft-print", "craft-ui", "craft-update", @@ -5448,6 +5603,8 @@ dependencies = [ "egui_kittest", "epaint_default_fonts", "flate2", + "icu_casemap", + "icu_locale_core", "log", "photocraft-algo", "photocraft-cms", @@ -5504,6 +5661,7 @@ dependencies = [ "photocraft-io", "photocraft-paint", "photocraft-psd", + "photocraft-raster", "photocraft-ui-egui", "rfd", "serde_json", @@ -6046,6 +6204,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "rav1e" version = "0.8.1" @@ -7274,6 +7438,17 @@ dependencies = [ "serde", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7931,6 +8106,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -9053,6 +9237,25 @@ version = "0.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" +[[package]] +name = "wintab" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e05efdae68aa69ef1f4bc0148a5c6cf7f2bc234963a723252fd75f2f9e70d86" +dependencies = [ + "windows", + "wintab-sys", +] + +[[package]] +name = "wintab-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3d0743ba07fe30ca1ccc1471ee50adf32bc70932b86a00c8f1051177950de2" +dependencies = [ + "windows", +] + [[package]] name = "winx" version = "0.36.4" @@ -9261,6 +9464,7 @@ dependencies = [ "chrono", "serde", "serde_json", + "tempfile", "toml", ] diff --git a/ROADMAP.md b/ROADMAP.md index 2aace61b67..70637897d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切; previously: Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -122,7 +122,10 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-11-02 | Layer Style: with Gradient Overlay selected, a drag on the image moves the gradient (its centre as a fraction of the layer's bounds, `params.offset`), as a drag moves a shadow (#10763) | +| 2026-11-02 | Load Actions: Mac Roman step names with é ä ö ü Ä or ° read as Mac Roman instead of windows-1252 or Cyrillic (#10774): windows-1252's rare 0x80–0x9F letters and a lone Cyrillic letter in a Latin word no longer count as common text, the degree sign does, and the file's own Unicode text settles a word-initial rare letter and breaks exact ties toward windows-1252 or Mac Roman | | 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). | +| 2026-10-22 | Print paper sizes: はがき (100 × 148 mm), 往復はがき (148 × 200), JIS B5 (182 × 257), JIS B4 (257 × 364), A6, L判 (89 × 127), 2L判 (127 × 178), KG判 (102 × 152), 六切 (203 × 254) and 四切 (254 × 305) are selectable in the Print dialog and `file.print`, matched by their customary names (`はがき`, `L判`, `JIS B5`…), and the PDF page takes their exact size. Existing papers unchanged. | | 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | @@ -137,6 +140,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Print: Japanese paper sizes (はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切) in the Print dialog, `file.print` and the PDF output | | 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) | | 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | | 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate | diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 94e04b518a..4542a71da9 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -7,9 +7,12 @@ license.workspace = true build = "build.rs" [features] +default = ["gpu"] # Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose # it; official builds (CI, packaging/, release) build with `--features heif`. heif = ["photocraft-codecs/heif"] +# Optional native GPU rendering backend, enabled by default in native CLI builds. +gpu = ["photocraft-automation/gpu"] [lints] workspace = true @@ -22,10 +25,16 @@ path = "src/lib.rs" name = "photocraft-cli" path = "src/main.rs" +[[example]] +name = "gpu_bench" +required-features = ["gpu"] + [dependencies] photocraft-automation = { path = "../../crates/automation" } +photocraft-doc = { workspace = true } photocraft-engine = { workspace = true } photocraft-io = { workspace = true } +photocraft-raster = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } clap = { workspace = true } @@ -34,12 +43,18 @@ thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] +serde = { workspace = true } +tempfile = "3" +# Reading back a saved PSD's merged-image flag (#8064). +photocraft-psd = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in # the binary. clap_complete = { workspace = true } clap_mangen = { workspace = true } +# The PSD write recipe runner (tests/it/psd_write_oracle.rs). +xtask = { path = "../../xtask" } [build-dependencies] # Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows. diff --git a/apps/photocraft-cli/examples/gpu_bench.rs b/apps/photocraft-cli/examples/gpu_bench.rs new file mode 100644 index 0000000000..ff88ff6eb7 --- /dev/null +++ b/apps/photocraft-cli/examples/gpu_bench.rs @@ -0,0 +1,221 @@ +//! Benchmark example measuring headless CPU vs GPU rendering and export performance. +//! +//! Usage: +//! ```sh +//! cargo run --release -p photocraft-cli --example gpu_bench -- [options] +//! ``` + +use std::path::PathBuf; +use std::time::Instant; + +use clap::Parser; +use photocraft_automation::files; +use photocraft_automation::gpu::GpuRenderer; +use photocraft_doc::{BlendMode, Color, ColorMode, Document, Layer, LayerContent, Rect, SampleType, Size, Surface}; +use photocraft_io::ExportOptions; + +#[derive(Parser, Debug)] +#[command(name = "gpu_bench", about = "Benchmark headless CPU vs GPU rendering and export performance")] +struct BenchArgs { + /// Input image or PSD file (optional; defaults to synthetic mockup) + #[arg(short, long)] + input: Option, + + /// Synthetic document width in pixels (1..=16384) + #[arg(short, long, default_value_t = 2048, value_parser = clap::value_parser!(u32).range(1..=16384))] + width: u32, + + /// Synthetic document height in pixels (1..=16384) + #[arg(short = 'H', long, default_value_t = 2048, value_parser = clap::value_parser!(u32).range(1..=16384))] + height: u32, + + /// Synthetic layer count (1..=128) + #[arg(short, long, default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=128))] + layers: u32, + + /// Number of warm GPU runs (0..=50) + #[arg(short = 'r', long = "warm-runs", default_value_t = 3, value_parser = clap::value_parser!(u32).range(0..=50))] + warm_runs: u32, +} + +fn main() -> Result<(), Box> { + let args = BenchArgs::parse(); + let layers = args.layers as usize; + let warm_runs = args.warm_runs as usize; + + println!("# PhotoCraft CLI Headless Rendering Benchmark"); + println!(); + + const MAX_SYNTHETIC_PIXELS: u64 = 256 * 1024 * 1024; + let doc = if let Some(path) = &args.input { + println!("- **Input Source**: file `{}`", path.display()); + let open_res = files::open(path)?; + open_res.document + } else { + let total_pixels = u64::from(args.width) * u64::from(args.height) * u64::from(args.layers); + if total_pixels > MAX_SYNTHETIC_PIXELS { + return Err(format!( + "requested synthetic benchmark volume ({}x{} x {} layers = {} total pixels) exceeds budget of {} pixels (~{} MB); please lower width, height, or layer count", + args.width, + args.height, + args.layers, + total_pixels, + MAX_SYNTHETIC_PIXELS, + (MAX_SYNTHETIC_PIXELS * 4) / (1024 * 1024) + ) + .into()); + } + println!("- **Input Source**: synthetic mockup ({}x{}, {} layers)", args.width, args.height, args.layers); + sample_doc(args.width, args.height, layers) + }; + + println!("- **Dimensions**: {}x{}", doc.size.width, doc.size.height); + println!("- **Layer Count**: {}", doc.layers.len()); + println!(); + + let tmp_dir = std::env::temp_dir().join(format!("pc-bench-{}", std::process::id())); + let _ = std::fs::create_dir_all(&tmp_dir); + let cpu_out = tmp_dir.join("cpu_out.png"); + let gpu_cold_out = tmp_dir.join("gpu_cold_out.png"); + let gpu_warm_out = tmp_dir.join("gpu_warm_out.png"); + + let opts = ExportOptions::default(); + + // 1. CPU Run + print!("Benchmarking CPU export... "); + let t0 = Instant::now(); + files::save_with_renderer(&doc, &cpu_out, Some("png"), &opts, None, None)?; + let cpu_dur = t0.elapsed(); + println!("{:.2} ms", cpu_dur.as_secs_f64() * 1000.0); + + // 2. GPU Cold Run + print!("Benchmarking GPU cold export... "); + let mut gpu_renderer = GpuRenderer::lazy(); + let t0 = Instant::now(); + let gpu_cold_res = files::save_with_renderer(&doc, &gpu_cold_out, Some("png"), &opts, None, Some(&mut gpu_renderer)); + let gpu_cold_dur = t0.elapsed(); + + let (gpu_succeeded, is_fallback) = match gpu_cold_res { + Ok(warnings) => { + let fallback = warnings.iter().any(|w| w.contains("falling back to CPU")); + if fallback { + println!("{:.2} ms (FALLBACK TO CPU: {:?})", gpu_cold_dur.as_secs_f64() * 1000.0, warnings); + (true, true) + } else if gpu_renderer.context().is_none() { + // The export never reached the renderer (e.g. a single-layer input takes the + // native path), so no GPU work ran: report it as CPU work, not a GPU result. + println!("{:.2} ms (renderer not used)", gpu_cold_dur.as_secs_f64() * 1000.0); + (false, true) + } else { + println!("{:.2} ms", gpu_cold_dur.as_secs_f64() * 1000.0); + let adapter = gpu_renderer.adapter_name().unwrap_or("unknown"); + let is_soft = if gpu_renderer.is_software() { " (software rasterizer)" } else { " (hardware)" }; + println!("- **GPU Adapter**: `{adapter}`{is_soft}"); + (true, false) + } + } + Err(e) => { + println!("FAILED ({e})"); + let _ = std::fs::remove_dir_all(&tmp_dir); + return Ok(()); + } + }; + + // 3. GPU Warm Runs + let mut warm_durations = Vec::with_capacity(warm_runs); + let mut warm_fallbacks = 0usize; + if gpu_succeeded && !is_fallback && warm_runs > 0 { + for run in 1..=warm_runs { + print!("Benchmarking GPU warm export (run {run}/{warm_runs})... "); + let t0 = Instant::now(); + let warm_res = files::save_with_renderer(&doc, &gpu_warm_out, Some("png"), &opts, None, Some(&mut gpu_renderer))?; + let d = t0.elapsed(); + if warm_res.iter().any(|w| w.contains("falling back to CPU")) { + warm_fallbacks += 1; + println!("{:.2} ms (FALLBACK TO CPU)", d.as_secs_f64() * 1000.0); + } else { + println!("{:.2} ms", d.as_secs_f64() * 1000.0); + warm_durations.push(d); + } + } + } + + println!(); + println!("### Summary Results"); + println!("| Mode | Duration | Relative to CPU | Note |"); + println!("|---|---|---|---|"); + println!("| CPU Baseline | {:.2} ms | 1.00x | Reference CPU compositor |", cpu_dur.as_secs_f64() * 1000.0); + if is_fallback { + println!("| GPU Cold | {:.2} ms | N/A | GPU not used (CPU fallback or a CPU-only export path); no GPU speedup |", gpu_cold_dur.as_secs_f64() * 1000.0); + println!("| GPU Warm | N/A | N/A | Not measured (GPU not used) |"); + } else { + let is_software = gpu_renderer.is_software(); + let adapter_note = if is_software { "Software rasterizer (e.g. lavapipe/llvmpipe)" } else { "Hardware GPU" }; + println!( + "| GPU Cold | {:.2} ms | {:.2}x | Cold adapter initialization + pipeline build ({adapter_note}) |", + gpu_cold_dur.as_secs_f64() * 1000.0, + cpu_dur.as_secs_f64() / gpu_cold_dur.as_secs_f64() + ); + if !warm_durations.is_empty() { + let avg_warm: f64 = warm_durations.iter().map(|d| d.as_secs_f64()).sum::() / (warm_durations.len() as f64); + // Runs that fell back to the CPU are excluded from the average: say how many. + let note = match warm_fallback_note(warm_runs, warm_fallbacks) { + Some(fallbacks) => format!("Reused device context and pipeline caches ({adapter_note}); {fallbacks} (excluded from the average)"), + None => format!("Reused device context and pipeline caches ({adapter_note})"), + }; + println!("| GPU Warm (avg) | {:.2} ms | {:.2}x | {note} |", avg_warm * 1000.0, cpu_dur.as_secs_f64() / avg_warm); + } else if let Some(note) = warm_fallback_note(warm_runs, warm_fallbacks) { + println!("| GPU Warm | N/A | N/A | {note} |"); + } else { + println!("| GPU Warm | N/A | N/A | Not measured (0 warm runs requested) |"); + } + } + + let _ = std::fs::remove_dir_all(&tmp_dir); + Ok(()) +} + +/// The warm-run summary note when every measured warm run fell back to the CPU. +fn warm_fallback_note(warm_runs: usize, warm_fallbacks: usize) -> Option { + (warm_fallbacks > 0).then(|| format!("{warm_fallbacks} of {warm_runs} warm runs fell back to CPU")) +} + +fn sample_doc(w: u32, h: u32, layer_count: usize) -> Document { + let mut doc = Document::with_background("bench", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let blend_modes = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::Darken, BlendMode::Lighten]; + + for idx in 0..layer_count { + let mut s = Surface::new(doc.pixel_format()); + let x0 = (idx as i32 * 20) % (w as i32 / 2).max(1); + let y0 = (idx as i32 * 20) % (h as i32 / 2).max(1); + let rw = (w as i32 - x0).max(1); + let rh = (h as i32 - y0).max(1); + s.fill_rect(Rect::new(x0, y0, x0 + rw, y0 + rh), &[0.3, 0.5, 0.7, 0.8]); + let mut layer = Layer::new(format!("Layer {}", idx + 1), LayerContent::Raster(s)); + layer.opacity = 0.85; + layer.blend = blend_modes[idx % blend_modes.len()]; + doc.layers.push(layer); + } + + doc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_documents_allow_one_pixel_dimensions() { + for (width, height) in [(1, 1), (1, 16), (16, 1)] { + let doc = sample_doc(width, height, 2); + assert_eq!(doc.size, Size::new(width, height)); + } + } + + #[test] + fn warm_summary_reports_fallbacks_instead_of_zero_runs() { + assert_eq!(warm_fallback_note(3, 3).as_deref(), Some("3 of 3 warm runs fell back to CPU")); + assert_eq!(warm_fallback_note(3, 1).as_deref(), Some("1 of 3 warm runs fell back to CPU")); + assert_eq!(warm_fallback_note(0, 0), None); + } +} diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index c3583bf83d..87c0b5599c 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -11,6 +11,7 @@ use std::fmt; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; +pub(crate) use crate::gpu::GpuArgs; use clap::error::ErrorKind; use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; @@ -86,6 +87,12 @@ pub(crate) struct ExportArgs { /// composite/full.png; it may be omitted. Check the manifest's full_composite member. #[arg(long)] pub(crate) pcraft_full_composite: bool, + /// A layered PSD/PSB stores Photoshop's white placeholder instead of the merged image + /// (Maximize Compatibility off): a smaller file and a faster save, but readers that show only + /// the merged image see white. Bitmap, Indexed Color and Duotone documents still write the + /// real image + #[arg(long)] + pub(crate) no_maximize_compatibility: bool, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -107,6 +114,8 @@ pub(crate) struct ConvertArgs { #[arg(long)] pub(crate) approve_high_memory: bool, #[command(flatten)] + pub(crate) gpu: GpuArgs, + #[command(flatten)] pub(crate) export: ExportArgs, } @@ -120,6 +129,9 @@ pub(crate) struct InfoArgs { /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget #[arg(long)] pub(crate) approve_high_memory: bool, + /// Also list every text run and paragraph run, anti-aliasing and full layer-effect settings + #[arg(long)] + pub(crate) detail: bool, } /// Open a file (or start a new document), run engine commands in order, save the result @@ -152,6 +164,8 @@ pub(crate) struct RunArgs { #[arg(long)] pub(crate) approve_high_memory: bool, #[command(flatten)] + pub(crate) gpu: GpuArgs, + #[command(flatten)] pub(crate) export: ExportArgs, /// `--cmd` and `--params` values in command-line order, set by [`parse`]. #[arg(skip)] @@ -210,6 +224,8 @@ pub(crate) struct BatchArgs { #[arg(long)] pub(crate) approve_high_memory: bool, #[command(flatten)] + pub(crate) gpu: GpuArgs, + #[command(flatten)] pub(crate) export: ExportArgs, } @@ -253,6 +269,8 @@ pub(crate) struct McpArgs { #[arg(skip)] pub(crate) bridge_from_env: bool, #[command(flatten)] + pub(crate) gpu: GpuArgs, + #[command(flatten)] pub(crate) control: ControlArgs, #[command(flatten)] pub(crate) automation: AutomationArgs, @@ -274,6 +292,8 @@ pub(crate) struct ServeArgs { #[arg(skip)] pub(crate) port_from_env: bool, #[command(flatten)] + pub(crate) gpu: GpuArgs, + #[command(flatten)] pub(crate) control: ControlArgs, #[command(flatten)] pub(crate) automation: AutomationArgs, @@ -311,6 +331,12 @@ enum ServerOptionError { /// authority the bridge can't give (#8035). #[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")] BridgeRoots, + /// Invalid value for PHOTOCRAFT_GPU. + #[error("invalid value '{0}' for 'PHOTOCRAFT_GPU': expected '1' or '0'")] + GpuVar(String), + /// Explicit GPU flags require headless MCP mode, not bridge mode. + #[error("configure rendering on the desktop app; --gpu and --no-gpu require headless MCP mode")] + BridgeGpu, /// A token flag without the bridge or port that reads it would be ignored (#8035). #[error("{flag} requires {transport}")] NeedsTransport { flag: &'static str, transport: &'static str }, @@ -319,8 +345,8 @@ enum ServerOptionError { impl ServerOptionError { fn into_clap(self, cmd: &mut clap::Command) -> clap::Error { let kind = match self { - Self::BridgeVar(_) => ErrorKind::ValueValidation, - Self::BridgeRoots => ErrorKind::ArgumentConflict, + Self::BridgeVar(_) | Self::GpuVar(_) => ErrorKind::ValueValidation, + Self::BridgeRoots | Self::BridgeGpu => ErrorKind::ArgumentConflict, Self::NeedsTransport { .. } => ErrorKind::MissingRequiredArgument, }; cmd.error(kind, self) @@ -425,7 +451,10 @@ pub(crate) fn parse(args: &[OsString]) -> Result { let mut cli = Cli::from_arg_matches(&matches)?; let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable); match (&mut cli.command, matches.subcommand()) { - (Command::Run(r), Some((_, m))) => r.steps = steps(m), + (Command::Run(r), Some((_, m))) => { + r.steps = steps(m); + check_gpu_env(&mut cmd, m)?; + } (Command::Mcp(a), Some((name, m))) => { a.bridge_from_env = from_env(m, "bridge"); if a.bridge_from_env @@ -448,8 +477,15 @@ pub(crate) fn parse(args: &[OsString]) -> Result { if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) { return Err(ServerOptionError::BridgeRoots.into_clap(sub)); } + if a.bridge.is_some() && (m.value_source("gpu") == Some(ValueSource::CommandLine) || m.value_source("no_gpu") == Some(ValueSource::CommandLine)) + { + return Err(ServerOptionError::BridgeGpu.into_clap(sub)); + } check_server(sub, m, a.bridge.is_some(), "--bridge")?; } + if a.bridge.is_none() { + check_gpu_env(&mut cmd, m)?; + } } (Command::Serve(a), Some((name, m))) => { a.port_from_env = from_env(m, "port"); @@ -460,12 +496,32 @@ pub(crate) fn parse(args: &[OsString]) -> Result { } check_server(sub, m, a.port.is_some(), "--port")?; } + check_gpu_env(&mut cmd, m)?; + } + (Command::Convert(_), Some((_, m))) | (Command::Batch(_), Some((_, m))) => { + check_gpu_env(&mut cmd, m)?; } _ => {} } Ok(cli) } +fn check_gpu_env(cmd: &mut clap::Command, m: &ArgMatches) -> Result<(), clap::Error> { + if m.value_source("gpu") == Some(ValueSource::CommandLine) || m.value_source("no_gpu") == Some(ValueSource::CommandLine) { + return Ok(()); + } + if let Some(v) = std::env::var_os("PHOTOCRAFT_GPU") { + // A non-UTF-8 value is still a value other than empty, "0" or "1": reject it rather + // than silently treating it as unset and running on the CPU. + let v = v.to_string_lossy(); + let trimmed = v.trim(); + if !trimmed.is_empty() && trimmed != "1" && trimmed != "0" { + return Err(ServerOptionError::GpuVar(trimmed.to_string()).into_clap(cmd)); + } + } + Ok(()) +} + /// Refuse a token flag without the bridge or port that reads it (as a flag or its variable). A /// token variable alone may be a stale export, so it isn't refused. fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, transport: &'static str) -> Result<(), clap::Error> { diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs index 062f2f2eef..34dbd1ce8a 100644 --- a/apps/photocraft-cli/src/automation.rs +++ b/apps/photocraft-cli/src/automation.rs @@ -34,7 +34,13 @@ pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { use std::sync::{Arc, Mutex}; - let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?))); + let mut h = Headless::with_workspace(a.automation.workspace()?); + let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(crate::CliError::Other)?; + policy.apply_to_headless(&mut h); + for d in h.drain_diagnostics() { + let _ = writeln!(err, "warning: {d}"); + } + let h = Arc::new(Mutex::new(h)); match a.port { Some(port) => { if a.port_from_env { @@ -75,7 +81,15 @@ pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result { let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?; PhotocraftMcp::bridge(&addr.to_string(), &token)? } - None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?), + None => { + let mut h = Headless::with_workspace(a.automation.workspace()?); + let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(crate::CliError::Other)?; + policy.apply_to_headless(&mut h); + for d in h.drain_diagnostics() { + let _ = writeln!(err, "warning: {d}"); + } + PhotocraftMcp::with_headless(h) + } }; let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; Ok(rt.block_on(server.serve_stdio())?) diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index 347f829443..0d05fd7e71 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -10,7 +10,7 @@ use crate::{ CliError, Result, actions::load_actions_file, args::{BatchArgs, DropletArgs}, - output::warn_all, + output::{warn_all, warn_dedup}, }; /// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a @@ -51,7 +51,8 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path()); let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?; set.iter_mut().for_each(strip_shell_steps); - warn_all(err, &warnings); + let mut seen_warnings = std::collections::HashSet::::new(); + warn_dedup(err, &warnings, &mut seen_warnings); let opts = ExportOptions::from(&a.export); // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). @@ -109,17 +110,20 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> } std::fs::create_dir_all(out_dir)?; let mut failures = Vec::new(); + let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(CliError::Other)?; + let mut renderer = policy.create_renderer(); for (input, target) in inputs.iter().zip(targets) { let input = Path::new(&input.path); // The selected format encodes the bytes, whatever extension `--naming` gives the file. // Without --format, a file PhotoCraft only opens (.kra, raw, …) is saved as PSD, the // same format `--naming`'s {ext} plans. let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.to_string_lossy()), str::to_owned); + let mut h = Headless::trusted_local(); + h.set_renderer(renderer.take()); let r = (|| -> Result> { if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } - let mut h = Headless::trusted_local(); // Steps that play another action find it in the set; the session's playback stack // stops an action that calls itself. h.session.actions.list = set.clone(); @@ -140,15 +144,30 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> let r = h.save(None, Some(&target), Some(&ext), &opts)?; Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default()) })(); + let diags = h.drain_diagnostics(); + // Release the item's GPU caches before the renderer moves on to the next file. + if h.session.active_index().is_some() { + let _ = h.close(None); + } + if let Some(recovered) = h.take_renderer() { + renderer = Some(recovered); + } match r { Ok(ws) => { ok += 1; let _ = writeln!(out, "ok {} -> {}", input.display(), target.display()); + // Each file's own export warnings (e.g. transparency composited over white) are + // reported for that file, even when an earlier file had the same warning. warn_all(err, &ws); + // A fallback diagnostic can also be in this file's export warnings; report it + // once, through the per-file warnings above, not twice. + let pending: Vec = diags.into_iter().filter(|d| !ws.contains(d)).collect(); + warn_dedup(err, &pending, &mut seen_warnings); } Err(e) => { failed += 1; let _ = writeln!(err, "FAIL {}: {e}", input.display()); + warn_dedup(err, &diags, &mut seen_warnings); failures.push((input.to_string_lossy().into_owned(), e.to_string())); if a.stop_on_error { break; @@ -267,3 +286,79 @@ mod batch_action_set_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::{Color, ColorMode, Document, Rect, SampleType, Size}; + use photocraft_io::{BandRenderer, IoError, RenderBand, RenderError}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RecordingRenderer { + calls: Arc, + } + + impl BandRenderer for RecordingRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &photocraft_raster::Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + self.calls.fetch_add(1, Ordering::SeqCst); + photocraft_io::CpuRenderer.render_bands(doc, rect, band_rows, ctl, sink) + } + } + + #[test] + fn batch_loop_recovers_renderer_after_error_on_first_item() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut renderer: Option> = Some(Box::new(RecordingRenderer { calls: calls.clone() })); + + let tmp = std::env::temp_dir().join(format!("pc-batch-regress-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let bad_file = tmp.join("01_bad.png"); + std::fs::write(&bad_file, b"not a valid png or document file").unwrap(); + + let good_file = tmp.join("02_good.pcraft"); + let mut good_doc = Document::with_background("test", Size::new(32, 32), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let extra_layer = good_doc.layers[0].clone(); + good_doc.layers.push(extra_layer); + photocraft_automation::files::save(&good_doc, &good_file, None, &Default::default(), None).unwrap(); + + let target_dir = tmp.join("out"); + std::fs::create_dir_all(&target_dir).unwrap(); + + let inputs = [bad_file, good_file]; + let opts = ExportOptions::default(); + + for (idx, input) in inputs.iter().enumerate() { + let mut h = Headless::trusted_local(); + h.set_renderer(renderer.take()); + let r = (|| -> Result> { + h.open(input)?; + let target = target_dir.join(format!("{idx}.png")); + let r = h.save(None, Some(&target), Some("png"), &opts)?; + Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default()) + })(); + if let Some(recovered) = h.take_renderer() { + renderer = Some(recovered); + } + if idx == 0 { + assert!(r.is_err(), "first corrupt item must fail"); + } else { + assert!(r.is_ok(), "second valid item must succeed: {:?}", r); + } + } + + assert!(renderer.is_some(), "renderer must be preserved across batch items"); + assert_eq!(calls.load(Ordering::SeqCst), 1, "renderer must have been invoked on the second item"); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 4c6678bfb1..95e8d4d8e2 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -10,7 +10,7 @@ use photocraft_io::ExportOptions; use crate::{ CliError, Result, args::{ConvertArgs, InfoArgs, RunArgs, Step}, - output::{JsonStyle, print_json, warn_all}, + output::{JsonStyle, print_json, warn_all, warn_dedup}, }; /// An output that is the input file itself, however it is spelt (relative, with `./`, through a @@ -34,10 +34,22 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write let mut o = files::open_with_options(input, &import_opts)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); - warn_all(err, &o.warnings); - warn_all(err, &missing_font_warnings(&o.document)); - let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?; - warn_all(err, &ws); + let mut seen_warnings = std::collections::HashSet::::new(); + warn_dedup(err, &o.warnings, &mut seen_warnings); + warn_dedup(err, &missing_font_warnings(&o.document), &mut seen_warnings); + let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(CliError::Other)?; + let mut renderer = policy.create_renderer(); + let ws = match renderer.as_mut() { + Some(renderer) => files::save_with_renderer(&o.document, output, a.export.format.as_deref(), &opts, None, Some(&mut **renderer)), + None => files::save(&o.document, output, a.export.format.as_deref(), &opts, None), + }; + // A failed GPU export can leave fallback reasons queued: report them before the error + // propagates, or the user would never learn why the GPU was not used. + if let Some(renderer) = renderer.as_mut() { + warn_dedup(err, &renderer.drain_diagnostics(), &mut seen_warnings); + } + let ws = ws?; + warn_dedup(err, &ws, &mut seen_warnings); Ok(()) } @@ -45,7 +57,10 @@ pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result { let file = &a.file; let mut h = Headless::trusted_local(); let opened = h.open_with_approval(file, a.approve_high_memory)?; - let mut doc = h.inspect(None)?; + let mut doc = match (a.detail, h.session.active()) { + (true, Some(st)) => photocraft_engine::inspect::document_detail(st), + _ => h.inspect(None)?, + }; let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); if let Some(st) = h.session.active() { warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String)); @@ -73,9 +88,14 @@ fn command_list(a: &RunArgs) -> Result> { Ok(cmds) } +/// Reported once by `run --gpu` when a step is `file.export.quickExport`. +const QUICK_EXPORT_CPU_NOTE: &str = "file.export.quickExport runs on the CPU compositor; --gpu applies to --out and the export commands that take a renderer"; + pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { let opts = ExportOptions::from(&a.export); let mut h = Headless::trusted_local(); + let policy = crate::gpu::GpuPolicy::from_args(&a.gpu).map_err(CliError::Other)?; + policy.apply_to_headless(&mut h); // Opening, running commands and exporting can all notice the same missing font: // one `run` invocation reports each warning line at most once. let mut reported_fonts = std::collections::HashSet::::new(); @@ -100,12 +120,25 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> continue; } let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?; - if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { + // A direct Batch reports per-file errors and continues, as in Scripts, unless it was + // cancelled, ran out of script steps or couldn't write its error log; failed action or + // script calls still stop the command list before export. + let failure = if id == "file.automate.batch" { + photocraft_engine::actions_cmds::batch_stop(&r) + } else { + photocraft_engine::actions_cmds::nested_failure(&id, &r) + }; + if let Some(message) = failure { return Err(CliError::NestedFailure { id, message }); } let new_warnings: Vec = h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &new_warnings); + // Quick Export composites and encodes inside the engine, which has no renderer: say so + // once, so `--gpu` never looks as if it applied to it (docs/development.md, GPU). + if policy.enabled && id == "file.export.quickExport" { + warn_dedup(err, &[QUICK_EXPORT_CPU_NOTE.to_string()], &mut reported_fonts); + } let mut result = json!({"command": id, "result": r}); if !new_warnings.is_empty() { result["warnings"] = json!(new_warnings); @@ -117,11 +150,19 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &remaining); if let Some(o) = a.out.as_deref() { - let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?; + let saved = h.save(None, Some(o), a.export.format.as_deref(), &opts); + // Report GPU diagnostics even when the export fails: the fallback reason is queued + // during the failed export and would otherwise be lost on the error path. + let failed_diags: Vec = h.drain_diagnostics().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); + warn_all(err, &failed_diags); + let r = saved?; let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); } + let diags = h.drain_diagnostics(); + let diags: Vec = diags.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); + warn_all(err, &diags); Ok(()) } diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs index 55e8df48e2..8222f0ba06 100644 --- a/apps/photocraft-cli/src/error.rs +++ b/apps/photocraft-cli/src/error.rs @@ -38,6 +38,8 @@ pub(crate) enum CliError { BatchOption(String), #[error("{0} file(s) failed")] FilesFailed(usize), + #[error("{0}")] + Other(String), } pub(crate) type Result = std::result::Result; diff --git a/apps/photocraft-cli/src/gpu.rs b/apps/photocraft-cli/src/gpu.rs new file mode 100644 index 0000000000..82171c64d3 --- /dev/null +++ b/apps/photocraft-cli/src/gpu.rs @@ -0,0 +1,112 @@ +//! GPU acceleration policy and options for photocraft-cli. + +use photocraft_automation::Headless; +use photocraft_io::BandRenderer; + +/// Optional GPU composition for supported flat exports and full-size previews. +#[derive(clap::Args)] +pub(crate) struct GpuArgs { + /// Prefer GPU rendering, falling back to CPU when unavailable or unsupported + #[arg(long, conflicts_with = "no_gpu")] + pub(crate) gpu: bool, + /// Use CPU rendering, overriding PHOTOCRAFT_GPU + #[arg(long, conflicts_with = "gpu")] + pub(crate) no_gpu: bool, +} + +/// Resolved GPU rendering policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GpuPolicy { + pub(crate) enabled: bool, +} + +impl GpuPolicy { + /// Resolve policy from arguments and environment. + /// + /// Precedence: + /// - Explicit `--gpu` enables GPU. + /// - Explicit `--no-gpu` disables GPU. + /// - Neither flag: `PHOTOCRAFT_GPU` env var (1 = enabled, 0 or empty/unset = disabled). + /// Any other nonempty value is rejected. + pub(crate) fn from_args(args: &GpuArgs) -> Result { + if args.gpu { + return Ok(Self { enabled: true }); + } + if args.no_gpu { + return Ok(Self { enabled: false }); + } + match std::env::var_os("PHOTOCRAFT_GPU") { + Some(val) => { + let val = val.to_string_lossy(); + let trimmed = val.trim(); + if trimmed.is_empty() || trimmed == "0" { + Ok(Self { enabled: false }) + } else if trimmed == "1" { + Ok(Self { enabled: true }) + } else { + Err(format!("invalid value '{trimmed}' for 'PHOTOCRAFT_GPU': expected '1' or '0'")) + } + } + None => Ok(Self { enabled: false }), + } + } + + /// Apply policy to a headless automation session. + pub(crate) fn apply_to_headless(&self, h: &mut Headless) { + #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] + if self.enabled { + h.enable_gpu(); + } + #[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))] + if self.enabled { + h.set_renderer(Some(Box::new(DisabledGpuRenderer::new()))); + } + } + + /// Create an optional boxed renderer under this policy. + #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] + pub(crate) fn create_renderer(&self) -> Option> { + if self.enabled { Some(Box::new(photocraft_automation::gpu::GpuRenderer::lazy())) } else { None } + } + + #[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))] + pub(crate) fn create_renderer(&self) -> Option> { + if self.enabled { Some(Box::new(DisabledGpuRenderer::new())) } else { None } + } +} + +/// Renderer stub emitted when GPU is requested in builds compiled without `--features gpu`. +#[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))] +struct DisabledGpuRenderer { + emitted: bool, +} + +#[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))] +impl DisabledGpuRenderer { + fn new() -> Self { + Self { emitted: false } + } +} + +#[cfg(any(not(feature = "gpu"), target_arch = "wasm32"))] +impl BandRenderer for DisabledGpuRenderer { + fn render_bands( + &mut self, + _doc: &photocraft_doc::Document, + _rect: photocraft_doc::Rect, + _band_rows: i32, + _ctl: &photocraft_raster::Interrupt<'_>, + _sink: &mut dyn FnMut(photocraft_io::RenderBand) -> Result<(), photocraft_io::IoError>, + ) -> Result<(), photocraft_io::RenderError> { + Err(photocraft_io::RenderError::Unavailable("GPU support is not compiled in this build (compile with --features gpu)".into())) + } + + fn drain_diagnostics(&mut self) -> Vec { + if self.emitted { + Vec::new() + } else { + self.emitted = true; + vec!["GPU acceleration requested but this build was compiled without GPU support (compile with --features gpu); falling back to CPU".into()] + } + } +} diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index c431b5529f..7e815db5d2 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -14,6 +14,7 @@ mod automation; mod batch; mod document; mod error; +mod gpu; mod output; pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions}; diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index ac37ec0537..05861d7477 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -33,6 +33,7 @@ impl From<&ExportArgs> for ExportOptions { o.encode.dds.compression = c; } o.encode.dds.mipmaps = a.dds_mipmaps; + o.psd_maximize_compatibility = !a.no_maximize_compatibility; o } } @@ -43,6 +44,14 @@ pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) { } } +pub(crate) fn warn_dedup(err: &mut dyn Write, ws: &[String], seen: &mut std::collections::HashSet) { + for w in ws { + if seen.insert(w.clone()) { + let _ = writeln!(err, "warning: {w}"); + } + } +} + /// How [`print_json`] lays the JSON out. pub(crate) enum JsonStyle { Pretty, diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 386eafe62c..a0c72c39b2 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -15,7 +15,7 @@ fn bin() -> Command { cmd } -const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"]; +const ENV_VARS: [&str; 4] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE", "PHOTOCRAFT_GPU"]; fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id())); @@ -363,6 +363,73 @@ fn run_stops_after_failed_action_without_exporting() { std::fs::remove_dir_all(d).unwrap(); } +/// A direct Batch keeps its partial result without preventing later commands or export. +#[test] +fn run_continues_after_batch_file_errors_and_keeps_partial_results() { + let d = tmp("run-partial-batch"); + let good = d.join("good.png"); + let bad = d.join("bad.png"); + let batch_out = d.join("batch"); + let caller_out = d.join("caller.pcraft"); + write_png(&good, 6, 4, 3); + std::fs::write(&bad, b"not a PNG").unwrap(); + let params = json!({"input":[good,bad],"output":batch_out,"format":"pcraft","steps":[ + ["layer.renameLayer",{"name":"Batch output"}] + ]}) + .to_string(); + let stdout = ok(bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "file.automate.batch", "--params", ¶ms]) + .args(["--cmd", "layer.new.layer", "--params", r#"{"name":"After batch"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&caller_out)) + .stdout; + let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0]["command"], "file.automate.batch"); + let result = &rows[0]["result"]; + let files = result["files"].as_array().unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(Path::new(files[0].as_str().unwrap()).canonicalize().unwrap(), batch_out.join("good.pcraft").canonicalize().unwrap()); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + assert_eq!(result["errors"][0]["file"], json!(bad)); + assert!(result["errors"][0]["error"].as_str().is_some_and(|message| !message.is_empty())); + assert!(batch_out.join("good.pcraft").is_file()); + assert!(!batch_out.join("bad.pcraft").exists()); + assert_eq!(rows[2]["command"], "document.inspect"); + assert!(rows[2]["result"]["layers"].as_array().unwrap().iter().any(|layer| layer["name"] == "After batch")); + assert!(caller_out.is_file()); + let saved: Value = serde_json::from_str(&ok(bin().arg("info").arg(&caller_out)).stdout).unwrap(); + assert!(saved["layers"].as_array().unwrap().iter().any(|layer| layer["name"] == "After batch")); + std::fs::remove_dir_all(d).unwrap(); +} + +/// A direct Batch whose error log can't be written stops the command list before export, also +/// when a file failed as well (per-file errors alone would let it go on). +#[test] +fn run_stops_when_a_direct_batch_cannot_write_its_error_log() { + let d = tmp("run-batch-log-fails"); + let bad = d.join("bad.png"); + let out = d.join("result.png"); + std::fs::write(&bad, b"not a PNG").unwrap(); + // The folder itself as the log path: the log write fails. + let params = json!({"input":[bad],"output":d.join("batch"),"format":"png","steps":[],"errorLog":d}).to_string(); + let result = bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "file.automate.batch", "--params", ¶ms]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out) + .output() + .unwrap(); + let error = String::from_utf8_lossy(&result.stderr); + assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout)); + assert!(error.contains("error log"), "{error}"); + let rows: Vec = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run"); + assert!(!out.exists(), "a stopped command list must not export"); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn run_continues_after_successful_action_and_exports() { let d = tmp("run-successful-action"); @@ -800,6 +867,24 @@ fn dds_converts_both_ways_with_the_compression_asked_for() { } } +/// #8064: `--no-maximize-compatibility` writes a layered PSD/PSB with Photoshop's placeholder +/// instead of the merged image, flagged in resource 1057; without it, the real image. +#[test] +fn no_maximize_compatibility_writes_the_psd_placeholder() { + let d = tmp("maximize-compat"); + let a = d.join("a.png"); + write_png(&a, 32, 16, 3); + for (args, ext, real) in + [(&[][..], "psd", true), (&["--no-maximize-compatibility"][..], "psd", false), (&["--no-maximize-compatibility"][..], "psb", false)] + { + let out = d.join(format!("a.{ext}")); + ok(bin().arg("run").arg(&a).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&out).args(args)); + let psd = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(psd.has_real_merged_data(), Some(real), "{args:?} {ext}"); + } + std::fs::remove_dir_all(d).unwrap(); +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a /// usage error and nothing is written. #[test] @@ -1721,3 +1806,30 @@ fn tiff_options_choose_compression_transparency_and_pyramid() { assert!(!out.exists(), "{flag} {bad} wrote a file"); } } + +#[test] +fn info_detail_adds_text_runs_and_antialiasing() { + fn type_layer(doc: &Value) -> Option<&Value> { + let mut stack: Vec<&Value> = doc["layers"].as_array()?.iter().collect(); + while let Some(l) = stack.pop() { + if l["kind"] == "Type" { + return Some(l); + } + stack.extend(l["children"].as_array().into_iter().flatten()); + } + None + } + let dir = tmp("info-detail"); + let file = dir.join("t.psd"); + ok(bin() + .args(["run", "--new", r#"{"width":64,"height":64}"#, "--cmd", "type.create", "--params", r#"{"text":"Hi","x":4,"y":30,"name":"T"}"#, "--out"]) + .arg(&file)); + let plain: Value = serde_json::from_str(&ok(bin().arg("info").arg(&file).arg("--compact")).stdout).unwrap(); + let text = &type_layer(&plain).expect("type layer")["text"]; + assert!(text.get("runs").is_none() && text.get("antialias").is_none(), "{text}"); + let detail: Value = serde_json::from_str(&ok(bin().arg("info").arg(&file).args(["--compact", "--detail"])).stdout).unwrap(); + let text = &type_layer(&detail).expect("type layer")["text"]; + assert!(text.get("antialias").is_some(), "{text}"); + assert!(text["runs"].as_array().is_some_and(|r| !r.is_empty()), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/photocraft-cli/tests/it/gpu.rs b/apps/photocraft-cli/tests/it/gpu.rs new file mode 100644 index 0000000000..eeb3faa8db --- /dev/null +++ b/apps/photocraft-cli/tests/it/gpu.rs @@ -0,0 +1,335 @@ +//! Tests for photocraft-cli GPU rendering flags, precedence, and fallback behavior. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +fn bin() -> Command { + let mut cmd = Command::new(crate::binary()); + cmd.env_remove("PHOTOCRAFT_CONTROL_PORT"); + cmd.env_remove("PHOTOCRAFT_CONTROL_TOKEN"); + cmd.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE"); + cmd.env_remove("PHOTOCRAFT_GPU"); + cmd +} + +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pc-cli-gpu-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn write_png(path: &Path, w: u32, h: u32) { + let data: Vec = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(7)).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); +} + +#[test] +fn gpu_and_no_gpu_conflict() { + let d = tmp("conflict"); + let in_png = d.join("in.png"); + let out_png = d.join("out.png"); + write_png(&in_png, 16, 16); + + let o = bin().args(["convert", "--gpu", "--no-gpu", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("cannot be used with") || stderr.contains("conflicts with"), "{stderr}"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn gpu_env_var_validation() { + let d = tmp("env_val"); + let in_png = d.join("in.png"); + let out_png = d.join("out.png"); + write_png(&in_png, 16, 16); + + // Invalid non-empty value rejected with usage error naming the variable + for bad in ["2", "true", "yes", "false", "on", "off"] { + let o = bin().env("PHOTOCRAFT_GPU", bad).args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap(); + assert_eq!(o.status.code(), Some(2), "expected exit 2 for PHOTOCRAFT_GPU={bad}"); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("PHOTOCRAFT_GPU"), "expected stderr to name variable: {stderr}"); + assert!(stderr.contains("expected '1' or '0'"), "expected usage note: {stderr}"); + } + + // Empty or blank value is accepted as unset (CPU default) + for empty in ["", " "] { + let out_sub = d.join(format!("out_{}.png", empty.trim())); + let o = bin().env("PHOTOCRAFT_GPU", empty).args(["convert", in_png.to_str().unwrap(), out_sub.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "expected success for PHOTOCRAFT_GPU={empty:?}"); + assert!(out_sub.is_file()); + } + + let _ = std::fs::remove_dir_all(&d); +} + +#[cfg(unix)] +#[test] +fn gpu_env_var_rejects_a_non_utf8_value() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let d = tmp("env_non_utf8"); + let in_png = d.join("in.png"); + let out_png = d.join("out.png"); + write_png(&in_png, 16, 16); + + let bad = OsString::from_vec(vec![0xff, 0xfe]); + let o = bin().env("PHOTOCRAFT_GPU", bad).args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap(); + assert_eq!(o.status.code(), Some(2), "a non-UTF-8 PHOTOCRAFT_GPU must be a usage error"); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("PHOTOCRAFT_GPU"), "the usage error must name the variable: {stderr}"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn explicit_flags_override_environment() { + let d = tmp("override"); + let in_png = d.join("in.png"); + let in_layers = d.join("in.pcraft"); + let out1 = d.join("out1.png"); + let out2 = d.join("out2.png"); + write_png(&in_png, 16, 16); + // A single-layer PNG takes the native export path and never calls the renderer; a + // multi-layer document composites, so it observes whether `--gpu` reached the backend. + let o = bin().args(["run", "--new", r#"{"width":16,"height":16}"#, "--cmd", "layer.new.layer", "--out", in_layers.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + + // --no-gpu overrides PHOTOCRAFT_GPU=1 + let o = bin().env("PHOTOCRAFT_GPU", "1").args(["convert", "--no-gpu", in_png.to_str().unwrap(), out1.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out1.is_file()); + + // --gpu overrides PHOTOCRAFT_GPU=0 + let o = bin().env("PHOTOCRAFT_GPU", "0").args(["convert", "--gpu", in_layers.to_str().unwrap(), out2.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out2.is_file()); + let stderr = String::from_utf8_lossy(&o.stderr); + // The renderer reports either its adapter, an unavailable adapter, or (in a build without + // GPU support) that GPU rendering was requested; the CPU default emits none of these. + let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU"]; + assert!(markers.iter().any(|marker| stderr.contains(marker)), "explicit --gpu must reach the renderer when the environment disables it: {stderr}"); + + // Explicit flag overrides even bad environment value + let out3 = d.join("out3.png"); + let o = bin().env("PHOTOCRAFT_GPU", "invalid").args(["convert", "--no-gpu", in_png.to_str().unwrap(), out3.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out3.is_file()); + + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn mcp_bridge_rejects_explicit_gpu_flags_and_ignores_env() { + // Explicit --gpu with --bridge rejected + let o = bin().args(["mcp", "--bridge", "127.0.0.1:50000", "--gpu"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("configure rendering on the desktop app"), "{stderr}"); + + // Explicit --no-gpu with --bridge rejected + let o = bin().args(["mcp", "--bridge", "127.0.0.1:50000", "--no-gpu"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("configure rendering on the desktop app"), "{stderr}"); + + // An invalid PHOTOCRAFT_GPU value is ignored in bridge mode: the desktop process controls + // rendering, so the bridge must not validate the variable or reject the request for it. + let o = bin().env("PHOTOCRAFT_GPU", "invalid").args(["mcp", "--bridge", "127.0.0.1:50000"]).stdin(std::process::Stdio::null()).output().unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!stderr.contains("PHOTOCRAFT_GPU"), "bridge mode must ignore the GPU environment: {stderr}"); +} + +#[test] +fn convert_and_run_honor_gpu_flag_and_preserve_stdout_protocol() { + let d = tmp("protocol"); + let out_png = d.join("out.png"); + + // run --gpu output on stdout is clean JSON + let o = + bin().args(["run", "--gpu", "--new", r#"{"width":32,"height":32}"#, "--cmd", "layer.new.layer", "--out", out_png.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out_png.is_file()); + let stdout = String::from_utf8_lossy(&o.stdout); + for line in stdout.lines() { + let v: Value = serde_json::from_str(line).expect("each stdout line must be valid JSON"); + assert!(v.get("command").is_some()); + } + + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn batch_recovers_renderer_after_failed_item_and_preserves_gpu_policy() { + let d = tmp("batch_regress"); + let in_dir = d.join("in"); + let out_dir = d.join("out"); + std::fs::create_dir_all(&in_dir).unwrap(); + std::fs::create_dir_all(&out_dir).unwrap(); + + // First item is corrupt and fails to open + let corrupt_png = in_dir.join("01_corrupt.png"); + std::fs::write(&corrupt_png, b"invalid png bytes").unwrap(); + + // Second item is valid + let valid_png = in_dir.join("02_valid.png"); + write_png(&valid_png, 32, 32); + + let actions = d.join("actions.json"); + std::fs::write(&actions, serde_json::json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap(); + let o = bin() + .args(["batch", "--gpu", "--actions"]) + .arg(&actions) + .args(["--in", in_dir.to_str().unwrap(), "--out", out_dir.to_str().unwrap()]) + .output() + .unwrap(); + + // Exit code 1 because 1 file failed + assert_eq!(o.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&o.stdout); + let stderr = String::from_utf8_lossy(&o.stderr); + + assert!(stdout.contains("1 succeeded, 1 failed"), "stdout: {stdout}"); + assert!(stderr.contains("FAIL"), "stderr must report failing item: {stderr}"); + let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU"]; + assert!(markers.iter().any(|marker| stderr.contains(marker)), "--gpu must reach the batch renderer: {stderr}"); + assert!(out_dir.join("02_valid.png").is_file(), "second item must have been saved successfully"); + + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn diagnostics_preserve_clean_stdout_and_emit_to_stderr() { + let d = tmp("diag_clean"); + let in_png = d.join("in.png"); + let out_png = d.join("out.png"); + write_png(&in_png, 32, 32); + + let o = bin().args(["convert", "--gpu", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap(); + + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out_png.is_file()); + // Convert emits no stdout; diagnostics must go to stderr + assert!(o.stdout.is_empty(), "convert stdout must remain completely empty, got: {:?}", String::from_utf8_lossy(&o.stdout)); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("falling back to CPU"), "single-layer convert must report its CPU fallback on stderr: {stderr}"); + + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn serve_gpu_preserves_clean_protocol_stdout_and_drains_diagnostics_to_stderr() { + use std::io::{BufRead, BufReader, Write}; + use std::process::Stdio; + + let mut child = bin().args(["serve", "--gpu"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + // 1. Initial valid command + writeln!(stdin, "{}", serde_json::json!({"id": 1, "method": "doc.new", "params": {"width": 16, "height": 16}})).unwrap(); + stdin.flush().unwrap(); + + let mut line1 = String::new(); + assert!(stdout.read_line(&mut line1).unwrap() > 0, "server must respond"); + let v1: Value = serde_json::from_str(&line1).expect("line 1 on stdout must be valid JSON"); + assert_eq!(v1["id"], 1); + assert_eq!(v1["ok"], true, "{v1}"); + + // 2. A second layer so the render below composites instead of taking the native path. + writeln!(stdin, "{}", serde_json::json!({"id": 2, "method": "engine.execute", "params": {"command": "layer.new.layer", "params": {"name": "Top"}}})) + .unwrap(); + stdin.flush().unwrap(); + + let mut line2 = String::new(); + assert!(stdout.read_line(&mut line2).unwrap() > 0, "server must respond to the layer command"); + let v2: Value = serde_json::from_str(&line2).expect("line 2 on stdout must be valid JSON"); + assert_eq!(v2["id"], 2); + assert_eq!(v2["ok"], true, "{v2}"); + + // 3. Failure recovery: invalid request receives error reply without crashing server + writeln!(stdin, "{}", serde_json::json!({"id": 3, "method": "invalid.nonexistent.method", "params": {}})).unwrap(); + stdin.flush().unwrap(); + + let mut line3 = String::new(); + assert!(stdout.read_line(&mut line3).unwrap() > 0, "server must respond to invalid request"); + let v3: Value = serde_json::from_str(&line3).expect("line 3 on stdout must be valid JSON"); + assert_eq!(v3["id"], 3); + assert_eq!(v3["ok"], false, "{v3}"); + + // 4. Subsequent valid command still succeeds (proves session & renderer recovery) + writeln!(stdin, "{}", serde_json::json!({"id": 4, "method": "doc.inspect"})).unwrap(); + stdin.flush().unwrap(); + + let mut line4 = String::new(); + assert!(stdout.read_line(&mut line4).unwrap() > 0, "server must respond to the inspect request"); + let v4: Value = serde_json::from_str(&line4).expect("line 4 on stdout must be valid JSON"); + assert_eq!(v4["id"], 4); + assert_eq!(v4["ok"], true, "{v4}"); + + // 5. Render the composite: one JSON line on stdout, diagnostics only on stderr. + writeln!(stdin, "{}", serde_json::json!({"id": 5, "method": "doc.render", "params": {"maxSide": 16}})).unwrap(); + stdin.flush().unwrap(); + + let mut line5 = String::new(); + assert!(stdout.read_line(&mut line5).unwrap() > 0, "server must answer the render request"); + let v5: Value = serde_json::from_str(&line5).expect("line 5 on stdout must be valid JSON"); + assert_eq!(v5["id"], 5); + assert_eq!(v5["ok"], true, "{v5}"); + assert!(v5["result"]["base64"].as_str().is_some_and(|b| !b.is_empty()), "render must return PNG bytes: {v5}"); + + // Close stdin and verify graceful exit + drop(stdin); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Every render that reaches the renderer reports its adapter or why it fell back, whichever + // build and adapter this test runs on. + // `doc.render` returns the diagnostics of its own render in the reply's `warnings`; any left + // in the queue go to stderr. Either way they stay off the protocol lines' framing. + let stderr = String::from_utf8_lossy(&output.stderr); + let reported = format!("{stderr}\n{}", v5["result"]["warnings"]); + let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU", "GPU acceleration requested"]; + assert!(markers.iter().any(|m| reported.contains(m)), "render must report its GPU backend or fallback in its reply or on stderr: {reported}"); +} + +#[test] +fn cpu_default_does_not_emit_gpu_warnings() { + let d = tmp("cpu_default"); + let in_png = d.join("in.png"); + let out_png = d.join("out.png"); + write_png(&in_png, 16, 16); + + let o = bin().args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out_png.is_file()); + + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!stderr.contains("GPU"), "default CPU convert should not emit GPU warnings, got: {stderr}"); + + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn run_gpu_quick_export_reports_that_it_runs_on_the_cpu() { + let d = tmp("quick_export"); + let out_png = d.join("quick.png"); + let params = format!(r#"{{"path":{}}}"#, serde_json::to_string(&out_png.to_string_lossy()).unwrap()); + let args = ["run", "--new", r#"{"width":16,"height":16,"background":"white"}"#, "--cmd", "file.export.quickExport", "--params", params.as_str()]; + let o = bin().args(args).arg("--gpu").output().unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out_png.is_file()); + let stderr = String::from_utf8_lossy(&o.stderr); + assert_eq!(stderr.matches("quickExport runs on the CPU compositor").count(), 1, "one CPU note expected: {stderr}"); + + let o = bin().args(args).output().unwrap(); + assert!(o.status.success()); + assert!(!String::from_utf8_lossy(&o.stderr).contains("runs on the CPU compositor"), "no note without --gpu"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs index 2729f74251..411791d94c 100644 --- a/apps/photocraft-cli/tests/it/main.rs +++ b/apps/photocraft-cli/tests/it/main.rs @@ -1,10 +1,13 @@ //! The crate's integration tests, linked into one test binary. mod cli; +mod gpu; #[cfg(all(target_os = "linux", target_env = "gnu"))] mod malloc_tuning; + mod man_and_completions; mod orientation; +mod psd_write_oracle; /// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. fn binary() -> std::ffi::OsString { diff --git a/apps/photocraft-cli/tests/it/psd_write_oracle.rs b/apps/photocraft-cli/tests/it/psd_write_oracle.rs new file mode 100644 index 0000000000..524f476fa1 --- /dev/null +++ b/apps/photocraft-cli/tests/it/psd_write_oracle.rs @@ -0,0 +1,45 @@ +//! Exercise the recipe runner with the real CLI and a synthetic editable PSD. + +use std::fs; +use std::path::Path; +use std::process::Command; + +use serde_json::{Value, json}; + +#[test] +fn edited_psd_roundtrip_is_checked_and_bad_expectations_fail() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let base = root.join("base.psd"); + let cli = crate::binary(); + let made = Command::new(&cli) + .args(["run", "--new", r#"{"width":64,"height":64}"#, "--cmd", "type.create", "--params", r#"{"text":"Oracle","x":4,"y":30,"name":"Oracle"}"#, "--out"]) + .arg(&base) + .output() + .unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + let hash = xtask::sha256::hex(&fs::read(&base).unwrap()); + let mut recipe = json!({"version":1,"corpus_revision":"synthetic","cases":[{ + "id":"style", "base":"base.psd", "base_sha256":hash, "target":{"kind":"Type","name":"Oracle"}, + "commands":[{"command":"type.antiAlias.crisp","params":{}}], + "checks":[{"op":"changed_to","path":"/text/antialias","value":"Crisp"},{"op":"unchanged","path":"/text/text"}] + }]}); + let recipe_path = root.join("recipes.json"); + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + fs::write(&recipe_path, serde_json::to_vec(&recipe).unwrap()).unwrap(); + let cli_path = Path::new(&cli).to_str().unwrap(); + let corpus = root.to_str().unwrap(); + let recipe_name = recipe_path.to_str().unwrap(); + let output = root.join("success"); + let args = [recipe_name, corpus, cli_path, output.to_str().unwrap()]; + xtask::psd_write_oracle::run(&src, &args).unwrap(); + let report: Value = serde_json::from_slice(&fs::read(output.join("report.json")).unwrap()).unwrap(); + assert_eq!(report["cases"][0]["status"], "pass"); + assert_eq!(report["cases"][0]["photoshop"]["opens"], "not_run"); + recipe["cases"][0]["checks"][0]["value"] = json!("Sharp"); + fs::write(&recipe_path, serde_json::to_vec(&recipe).unwrap()).unwrap(); + let bad_output = root.join("failure"); + assert!(xtask::psd_write_oracle::run(&src, &[recipe_name, corpus, cli_path, bad_output.to_str().unwrap()]).is_err()); + let bad: Value = serde_json::from_slice(&fs::read(bad_output.join("report.json")).unwrap()).unwrap(); + assert_eq!(bad["cases"][0]["status"], "fail"); +} diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html index 8ed9a6961e..7d15b18daa 100644 --- a/apps/photocraft-web-boot/index.html +++ b/apps/photocraft-web-boot/index.html @@ -16,6 +16,15 @@ this page, no Rust runs to say so, so the page itself shows what to do after a while. */ #photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; } @keyframes photocraft-stalled { to { visibility: visible; } } + /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic): + the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust + can no longer build any. */ + #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; } + #photocraft_stopped[hidden] { display: none; } + #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; } + #photocraft_stopped a { color: #ffffff; } + #photocraft_stopped_detail { color: #b8b8b8; } @@ -26,5 +35,10 @@ download or come from a different version of this page. Reload to try again; the browser console lists what failed.

+ diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 0176e0fe84..1811aea283 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -24,6 +24,7 @@ photocraft-io = { workspace = true } photocraft-codecs = { workspace = true } photocraft-doc = { workspace = true } photocraft-ui-egui = { workspace = true } +photocraft-raster = { workspace = true } egui = { workspace = true } log = { workspace = true } @@ -34,7 +35,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", "RequestMode", "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", "ErrorEvent", "PromiseRejectionEvent"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/index.html b/apps/photocraft-web/index.html index d090cf47da..30ea522b66 100644 --- a/apps/photocraft-web/index.html +++ b/apps/photocraft-web/index.html @@ -16,6 +16,15 @@ this page, no Rust runs to say so, so the page itself shows what to do after a while. */ #photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; } @keyframes photocraft-stalled { to { visibility: visible; } } + /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic): + the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust + can no longer build any. */ + #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; } + #photocraft_stopped[hidden] { display: none; } + #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; } + #photocraft_stopped a { color: #ffffff; } + #photocraft_stopped_detail { color: #b8b8b8; } @@ -26,5 +35,10 @@ download or come from a different version of this page. Reload to try again; the browser console lists what failed.

+ diff --git a/apps/photocraft-web/src/diagnostics.rs b/apps/photocraft-web/src/diagnostics.rs new file mode 100644 index 0000000000..134e0e4035 --- /dev/null +++ b/apps/photocraft-web/src/diagnostics.rs @@ -0,0 +1,148 @@ +//! What the page says when the module stops, and the `?free-mb` diagnostic flag (#10024). +//! +//! A failed allocation on `wasm32-unknown-unknown` aborts the module: the page keeps the last +//! frame and nothing says why. `web.rs` listens for the trap in the page and shows the static +//! `#photocraft_stopped` element; the decisions that need no browser live here, so native tests +//! cover them. + +use std::sync::{Mutex, PoisonError}; + +/// The message of the last panic, kept for the trap that follows it (a panic aborts the module). +static LAST_PANIC: Mutex = Mutex::new(String::new()); + +/// The most characters of a panic message the page shows. +const MAX_PANIC_CHARS: usize = 300; + +/// Truncates `message` to [`MAX_PANIC_CHARS`] characters, on a character boundary. +pub fn truncate(message: &str) -> String { + match message.char_indices().nth(MAX_PANIC_CHARS) { + Some((end, _)) => format!("{}\u{2026}", message.get(..end).unwrap_or_default()), + None => message.to_owned(), + } +} + +/// Remembers a panic's message (and where it happened) for [`take_panic`]. +pub fn record_panic(message: &str) { + *LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner) = truncate(message); +} + +/// The message [`record_panic`] kept, if any. +pub fn take_panic() -> Option { + let mut last = LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner); + (!last.is_empty()).then(|| std::mem::take(&mut *last)) +} + +/// `true` when an uncaught error, by its `name` and `message`, is the module trapping or the +/// browser refusing it memory, rather than some other script error or failed request. Browsers +/// word and capitalize these differently (Safari: `Unreachable code should not be executed`), so +/// the message is matched ignoring case. +pub fn is_trap(name: &str, message: &str) -> bool { + let message = message.to_ascii_lowercase(); + name == "RuntimeError" + || ["unreachable", "memory access out of bounds", "maximum call stack", "could not allocate memory", "out of memory"] + .iter() + .any(|m| message.contains(m)) +} + +/// Trap messages (lowercase, matched ignoring case) that mean an allocation failed: +/// `unreachable` with no panic before it is the allocation-failure abort (the only one that runs +/// no Rust), the others are the browser refusing to grow the memory. `memory access out of +/// bounds` is not among them: it is a bug. +const OUT_OF_MEMORY: [&str; 3] = ["unreachable", "could not allocate memory", "out of memory"]; + +/// The line under "PhotoCraft stopped": a captured panic means an internal error; a trap with no +/// panic before it is out of memory when its message says so (or says nothing), and otherwise +/// unexpected. +pub fn detail(panic: Option<&str>, trap_message: &str) -> String { + if let Some(p) = panic { + return format!("It hit an internal error: {p}"); + } + let lower = trap_message.to_ascii_lowercase(); + if lower.is_empty() || OUT_OF_MEMORY.iter().any(|m| lower.contains(m)) { + "It ran out of memory.".to_owned() + } else { + format!("It stopped unexpectedly: {}", truncate(trap_message)) + } +} + +/// `?free-mb=N` from a URL query (`?cpu&free-mb=300`): pretend only N MiB can be allocated, so +/// the memory gates and the low-memory warning can be seen without gigabytes of images. +pub fn free_mb(query: &str) -> Option { + query.trim_start_matches('?').split('&').find_map(|pair| pair.strip_prefix("free-mb=")?.parse().ok()) +} + +/// The bytes `free-mb` asks for, without overflowing. +pub fn free_bytes(mb: u64) -> u64 { + mb.saturating_mul(1 << 20) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panic_message_is_shown_as_an_internal_error() { + assert_eq!(detail(Some("index out of bounds"), "unreachable"), "It hit an internal error: index out of bounds"); + } + + #[test] + fn a_trap_with_no_panic_is_an_out_of_memory_abort() { + assert_eq!(detail(None, "unreachable"), "It ran out of memory."); + assert_eq!(detail(None, "RuntimeError: unreachable executed"), "It ran out of memory."); + assert_eq!(detail(None, ""), "It ran out of memory."); + assert_eq!(detail(None, "WebAssembly.Memory(): could not allocate memory"), "It ran out of memory."); + // Safari and Firefox word and capitalize theirs differently. + assert_eq!(detail(None, "Unreachable code should not be executed"), "It ran out of memory."); + assert_eq!(detail(None, "out of memory"), "It ran out of memory."); + } + + #[test] + fn another_kind_of_trap_is_not_blamed_on_memory() { + let text = detail(None, "divide by zero"); + assert!(text.starts_with("It stopped unexpectedly: divide by zero"), "{text}"); + // A bad memory access is a bug, not memory running out. + let text = detail(None, "memory access out of bounds"); + assert!(text.starts_with("It stopped unexpectedly: memory access out of bounds"), "{text}"); + } + + #[test] + fn only_traps_count() { + assert!(is_trap("RuntimeError", "unreachable")); + assert!(is_trap("RuntimeError", "")); + assert!(is_trap("RangeError", "Maximum call stack size exceeded")); + assert!(is_trap("RangeError", "WebAssembly.Memory(): could not allocate memory")); + // Matched ignoring case: Safari's wording, reported without the RuntimeError name. + assert!(is_trap("Error", "Unreachable code should not be executed")); + assert!(is_trap("Error", "Out of memory")); + assert!(!is_trap("TypeError", "Failed to fetch")); + assert!(!is_trap("Error", "ResizeObserver loop completed")); + } + + #[test] + fn long_messages_are_cut_on_a_character_boundary() { + let long = "\u{e9}".repeat(1000); + let cut = truncate(&long); + assert_eq!(cut.chars().count(), MAX_PANIC_CHARS + 1); + assert!(cut.ends_with('\u{2026}')); + assert_eq!(truncate("short"), "short"); + assert_eq!(truncate(&"x".repeat(MAX_PANIC_CHARS)).chars().count(), MAX_PANIC_CHARS); + } + + #[test] + fn the_recorded_panic_is_taken_once() { + record_panic("boom at a.rs:1"); + assert_eq!(take_panic().as_deref(), Some("boom at a.rs:1")); + assert_eq!(take_panic(), None); + } + + #[test] + fn free_mb_is_read_from_the_query() { + assert_eq!(free_mb(""), None); + assert_eq!(free_mb("?cpu"), None); + assert_eq!(free_mb("?free-mb=300"), Some(300)); + assert_eq!(free_mb("?cpu&free-mb=400&webgl"), Some(400)); + assert_eq!(free_mb("?free-mb=lots"), None); + assert_eq!(free_bytes(300), 300 << 20); + assert_eq!(free_bytes(u64::MAX), u64::MAX); + } +} diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 0802a02ebf..60846196f1 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -12,10 +12,15 @@ //! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`. //! //! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`); -//! `?webgl` forces the WebGL2 backend instead of WebGPU. +//! `?webgl` forces the WebGL2 backend instead of WebGPU; `?free-mb=N` pretends only N MiB can be +//! allocated, so the memory gates and the low-memory warning can be seen without gigabytes of +//! images (diagnostics only). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod diagnostics; + #[cfg(any(target_arch = "wasm32", test))] mod preset_bridge; @@ -48,4 +53,22 @@ mod tests { assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay"); assert!(!html.contains("").expect("the element is closed"); + assert!(element.trim_start().starts_with("hidden"), "hidden until a trap: {element}"); + assert!(element.contains("role=\"alert\""), "{element}"); + assert!(element.contains("Reload"), "{element}"); + assert!(element.contains("can't be recovered"), "says what is lost: {element}"); + assert!(element.contains("id=\"photocraft_stopped_detail\""), "a line for the cause: {element}"); + assert!(html.contains("#photocraft_stopped[hidden]"), "display: flex must not defeat the hidden attribute"); + assert!(html.contains("pointer-events: auto"), "the link works over the canvas"); + assert!(!html.contains(")>>>; +type PlaceInbox = photocraft_ui_egui::PlaceInbox; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries. @@ -25,6 +26,8 @@ const OPEN_EXTS: &[&str] = &[ "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; +/// Set once the module has trapped: the page is dead, so nothing is left to protect. +static STOPPED: AtomicBool = AtomicBool::new(false); const CANVAS_ID: &str = "photocraft_canvas"; /// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest @@ -57,6 +60,10 @@ pub fn start() { }; let q = query(); let force_cpu = q.contains("cpu"); + // `?free-mb=N`: the memory gates and the low-memory warning see only N MiB (diagnostics). + if let Some(mb) = crate::diagnostics::free_mb(&q) { + photocraft_raster::headroom::simulate_available(Some(crate::diagnostics::free_bytes(mb))); + } let mut options = eframe::WebOptions::default(); photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup); if q.contains("webgl") @@ -75,7 +82,13 @@ pub fn start() { } }; let pen_target = canvas.clone(); - let result = eframe::WebRunner::new() + let runner = eframe::WebRunner::new(); + // After the runner's own panic handler, so the message is kept and the runner's handler + // still runs (a panic aborts the module). + keep_panic_message(); + // Before the app is built: a trap while building it shows the stopped message too. + listen_for_trap(); + let result = runner .start( canvas, options, @@ -83,7 +96,7 @@ pub fn start() { PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); - let place_inbox: Inbox = Arc::default(); + let place_inbox: PlaceInbox = Arc::default(); let served: Served = Arc::default(); load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); @@ -180,11 +193,15 @@ fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocr /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is -/// refreshed every frame by [`WebShell`]. +/// refreshed every frame by [`WebShell`]. Once the module has stopped ([`STOPPED`]) the prompt is +/// skipped: the document can't be saved any more, and Reload is the only way on. fn guard_unload(unsaved: Arc) { use wasm_bindgen::closure::Closure; let Some(window) = web_sys::window() else { return }; let cb = Closure::::new(move |e: web_sys::BeforeUnloadEvent| { + if STOPPED.load(Ordering::Relaxed) { + return; + } if unsaved.load(Ordering::Relaxed) { e.prevent_default(); // Older browsers show the prompt only when a return value is set. @@ -196,6 +213,67 @@ fn guard_unload(unsaved: Arc) { } } +/// Keeps the message of a panic, then calls the handler that was installed before (the web +/// runner's, which logs it). A panic aborts the module, so the trap that follows has no message of +/// its own; this is what lets the stopped page tell an internal error from running out of memory. +fn keep_panic_message() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + crate::diagnostics::record_panic(&info.to_string()); + previous(info); + })); +} + +/// Shows the page's static `#photocraft_stopped` message when the module traps. The page learns of +/// a trap as an uncaught `WebAssembly.RuntimeError` (the `error` event) or, inside a spawned +/// future, an unhandled rejection. The module may be mid-call and its runner still borrowed, so +/// the handler touches no app state: it sets [`STOPPED`] and the element, nothing more. +fn listen_for_trap() { + use wasm_bindgen::closure::Closure; + let Some(window) = web_sys::window() else { return }; + let on_error = Closure::::new(move |e: web_sys::ErrorEvent| { + let error = e.error(); + let (name, message) = error_parts(&error, &e.message()); + if crate::diagnostics::is_trap(&name, &message) { + show_stopped(&message); + } + }); + if window.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref()).is_ok() { + on_error.forget(); + } + let on_rejection = Closure::::new(move |e: web_sys::PromiseRejectionEvent| { + let (name, message) = error_parts(&e.reason(), ""); + if crate::diagnostics::is_trap(&name, &message) { + show_stopped(&message); + } + }); + if window.add_event_listener_with_callback("unhandledrejection", on_rejection.as_ref().unchecked_ref()).is_ok() { + on_rejection.forget(); + } +} + +/// The name and message of a thrown value; `fallback` is the event's own message when the value +/// is not an `Error`. +fn error_parts(value: &wasm_bindgen::JsValue, fallback: &str) -> (String, String) { + match value.dyn_ref::() { + Some(error) => (String::from(error.name()), String::from(error.message())), + None => (String::new(), value.as_string().unwrap_or_else(|| fallback.to_owned())), + } +} + +/// Reveals `#photocraft_stopped` with the cause. Does as little as possible. +fn show_stopped(trap_message: &str) { + STOPPED.store(true, Ordering::Relaxed); + let detail = crate::diagnostics::detail(crate::diagnostics::take_panic().as_deref(), trap_message); + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + if let Some(el) = document.get_element_by_id("photocraft_stopped_detail") { + el.set_text_content(Some(&detail)); + } + if let Some(el) = document.get_element_by_id("photocraft_stopped") { + let _ = el.remove_attribute("hidden"); + } +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } @@ -267,7 +345,7 @@ struct WebShell { /// Dropped files to open as documents. inbox: Inbox, /// Dropped files to place on the active document's canvas. - place_inbox: Inbox, + place_inbox: PlaceInbox, /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, @@ -354,16 +432,30 @@ impl eframe::App for WebShell { // Decided now, where the drop landed: on the document's canvas the files are placed // as layers (as on the desktop), elsewhere opened. let places = self.app.dropped_on_canvas(ctx, &names); - let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect(); + let target_doc = self.app.session.active().map(|st| st.doc.id); + let files: Vec<_> = dropped.into_iter().zip(names).zip(places).map(|((f, name), place)| (f, name, place, target_doc)).collect(); let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone()); let ctx = ctx.clone(); // One after another, so they open or place in the order they were dropped. wasm_bindgen_futures::spawn_local(async move { - for ((f, name), place) in files { + for (f, name, place, target_doc) in files { match f.bytes_async().await { Ok(bytes) => { - let queue = if place { &place_inbox } else { &inbox }; - queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); + if place { + if let Some(doc) = target_doc { + let mut queued = place_inbox.lock().unwrap_or_else(PoisonError::into_inner); + let held = queued.iter().fold(0usize, |sum, (_, _, b)| sum.saturating_add(b.len())); + // Reads that finish before the next frame drains the inbox are + // bounded like the app's own drop queue. + if photocraft_ui_egui::file_open::place_inbox_takes(queued.len(), held, bytes.len()) { + queued.push((doc, name, bytes)); + } else { + log::warn!("dropped file {name} wasn't placed: too many dropped files are waiting"); + } + } + } else { + inbox.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); + } ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -407,7 +499,7 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { +fn services(inbox: Inbox, place_inbox: PlaceInbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { @@ -465,6 +557,9 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), + load_gpu_memo: None, + save_gpu_memo: None, + shader_compiler: None, ..Default::default() } } diff --git a/apps/photocraft/src/icon_theme.rs b/apps/photocraft/src/icon_theme.rs index cf2ede3b7b..562dcf76e3 100644 --- a/apps/photocraft/src/icon_theme.rs +++ b/apps/photocraft/src/icon_theme.rs @@ -202,17 +202,22 @@ pub fn rasterize_mask(svg: &[u8], px: u32) -> Option> { image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_data(svg, &opts).ok()?; - let size = tree.size(); - let side = size.width().max(size.height()); - if !side.is_finite() || side <= 0.0 { - return None; - } - let s = px as f32 / side; - let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); - let mut pixmap = tiny_skia::Pixmap::new(px, px)?; - resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); - Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) + // usvg and resvg panic on some malformed drawings: such an icon is unreadable, not a crash. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let tree = usvg::Tree::from_data(svg, &opts).ok()?; + let size = tree.size(); + let side = size.width().max(size.height()); + if !side.is_finite() || side <= 0.0 { + return None; + } + let s = px as f32 / side; + let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); + let mut pixmap = tiny_skia::Pixmap::new(px, px)?; + resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); + Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) + })) + .ok() + .flatten() } #[cfg(test)] @@ -303,6 +308,15 @@ mod tests { assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none()); } + /// resvg panics rendering an offset past the pixmap's size: the icon is unreadable instead. + #[test] + fn a_mask_the_renderer_panics_on_is_none() { + let svg = r##" + + "##; + assert!(rasterize_mask(svg.as_bytes(), 16).is_none()); + } + /// An icon's `` is never loaded: not a device file, not an embedded picture. #[test] fn masks_load_no_images() { diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 75eb0ea83a..5461c4cc19 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -588,6 +588,7 @@ fn main() -> eframe::Result { && app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu { app.set_wgpu(rs); + app.start_compositor(&cc.egui_ctx); } else { // The window still draws with wgpu: record its errors instead of panicking, // and notice when the device is lost (#3438). @@ -669,9 +670,18 @@ fn main() -> eframe::Result { if let Some(monitor) = tablet::install_windows() { let _ = tablet_monitor_win.set(monitor); } + // With Windows Ink disabled for the tablet, WinTab supplies the pen pressure, tilt, + // rotation and eraser end; the vendor driver still moves the cursor. + #[cfg(target_os = "windows")] + if let Some(poll) = + tablet::install_wintab(cc, &app.stylus.feed, app.session.prefs().tools.tablet_input == photocraft_engine::prefs::TabletInput::WinTab) + { + app.services.tablet_poll = Some(poll); + } #[cfg(target_os = "linux")] tablet::spawn_x11(&app.stylus.feed, display); app.services.right_button_down = tablet::right_button_service(); + app.services.pen_end = tablet::pen_end_service(); // Paths on the command line (Linux/Windows file associations, `photocraft a.psd`). app.open_paths(&files); if !unreadable_paths.is_empty() { @@ -1030,7 +1040,7 @@ mod tests { read }; - let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#); + let r = read(r#"{"interface":{"showTooltips":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#); assert!(!r.allow_agent_control, "a bad value in another section"); assert_eq!(r.control_port, 7878); let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 7741a8d8a3..545fc86eec 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -341,6 +341,41 @@ pub fn prefs_file() -> Option { config_dir().map(|d| d.join("preferences.json")) } +fn gpu_memo_file() -> Option { + config_dir().map(|d| d.join("gpu-compositor.json")) +} + +fn load_gpu_memo_text() -> Option { + use std::io::Read; + let file = std::fs::File::open(gpu_memo_file()?).ok()?; + let cap = photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES; + let mut bytes = Vec::new(); + file.take((cap + 1) as u64).read_to_end(&mut bytes).ok()?; + if bytes.len() > cap { + log::info!("ignoring oversized GPU compositor memo"); + return None; + } + String::from_utf8(bytes).ok() +} + +fn shader_compiler_identity() -> String { + #[cfg(target_os = "windows")] + { + let exe = std::env::current_exe().ok(); + let exe_dir = exe.as_deref().and_then(Path::parent); + match crate::gpu_startup::dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref(), exe_dir) { + eframe::wgpu::Dx12Compiler::Fxc => "fxc".into(), + eframe::wgpu::Dx12Compiler::DynamicDxc { dxc_path } => format!("dxc:{dxc_path}"), + eframe::wgpu::Dx12Compiler::StaticDxc => "static-dxc".into(), + eframe::wgpu::Dx12Compiler::Auto => "auto".into(), + } + } + #[cfg(not(target_os = "windows"))] + { + String::new() + } +} + /// The brush preset store (one file per preset group plus tip bitmaps; see /// `photocraft_engine::preset_store`). pub fn presets_dir() -> Option { @@ -791,6 +826,14 @@ pub fn native(automation: Option) -> load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), + load_gpu_memo: Some(Box::new(load_gpu_memo_text)), + save_gpu_memo: Some(Box::new(|text: &str| { + if text.len() > photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES { + return Err("GPU compositor memo exceeds 64 KiB".into()); + } + write_atomic(&gpu_memo_file().ok_or("no config directory")?, text.as_bytes()) + })), + shader_compiler: Some(shader_compiler_identity()), system_theme: crate::appearance::service(), append_text: Some(Box::new(|path: &str, text: &str| { use std::io::Write; diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index 19ba28996c..51f7b333ef 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -1,12 +1,14 @@ //! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and -//! barrel button on Windows (#5905). +//! barrel button (#5905) and eraser end (#2285) on Windows. //! //! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit //! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes //! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's //! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure -//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel -//! button through a thread-local message hook beside winit. +//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`) and the eraser end (`PEN_FLAG_INVERTED`); +//! `photocraft-tablet` recovers them through thread-local message hooks beside winit. With Windows +//! Ink disabled for the tablet, the optional WinTab mode reads pressure, tilt, rotation and the +//! eraser end from a WinTab 1.4 system context instead ([`install_wintab`]). //! //! Wayland compositors give a pen only to clients that bind the tablet protocol //! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs @@ -32,11 +34,50 @@ pub fn sink(feed: StylusFeed) -> impl Fn(Option) + Send + 'static { move |s| feed.set(s.map(pen_sample)) } -/// Install the Windows pointer message hook (main thread; keep the result until the event loop -/// returns). On failure this logs why: the pen barrel button is then missed. +/// Install the Windows pointer message hooks (main thread; keep the result until the event loop +/// returns). On failure this logs why: the pen barrel button and eraser end are then missed. #[cfg(target_os = "windows")] pub fn install_windows() -> Option { - photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok() + let monitor = photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()?; + if !monitor.tracks_leave() { + log::warn!("tablet input: the WH_CALLWNDPROC hook did not install, so the pen leaving the window may be missed"); + } + Some(monitor) +} + +/// Installs the WinTab backend on the real PhotoCraft window and returns its per-frame poll. +/// +/// `enabled` is Preferences > Tools > Pen Tablet > Tablet input set to WinTab. With Windows Ink disabled there, the vendor +/// driver still moves the cursor; this backend only supplies pressure/tilt/rotation/eraser +/// through the normal stylus feed. On failure it logs why and leaves Windows Ink untouched. +#[cfg(target_os = "windows")] +pub fn install_wintab(cc: &eframe::CreationContext<'_>, feed: &StylusFeed, enabled: bool) -> Option { + if !enabled { + return None; + } + use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + let hwnd = match cc.window_handle().ok()?.as_raw() { + RawWindowHandle::Win32(handle) => handle.hwnd.get(), + _ => return None, + }; + let ctx = cc.egui_ctx.clone(); + if let Err(error) = photocraft_tablet::wintab::install(hwnd, Box::new(move || ctx.request_repaint())) { + log::warn!("WinTab backend: {error}"); + return None; + } + let sink = sink(feed.clone()); + log::info!("WinTab backend active (system context)"); + let logged = std::cell::Cell::new(false); + Some(Box::new(move |focused| { + photocraft_tablet::wintab::poll(focused, &|sample| { + if let Some(sample) = sample + && !logged.replace(true) + { + log::info!("WinTab sample: pressure={:.3} tilt=({:.1},{:.1}) eraser={}", sample.pressure, sample.tilt_x, sample.tilt_y, sample.eraser); + } + sink(sample); + }); + })) } /// Stylus barrel button down check (Windows). The right mouse button is left to egui, which @@ -51,6 +92,18 @@ pub fn right_button_service() -> Option { None } +/// Which end of the pen is in use (Windows): flipping the pen to its eraser end selects the +/// Eraser (#2285). Elsewhere the end arrives with the samples in the stylus feed. +#[cfg(target_os = "windows")] +pub fn pen_end_service() -> Option { + Some(Box::new(photocraft_tablet::windows::take_pen_end)) +} + +#[cfg(not(target_os = "windows"))] +pub fn pen_end_service() -> Option { + None +} + /// Install the AppKit tablet monitor (main thread, once winit created the shared application: /// from eframe's app creation, not before the event loop). Keep the result until the event loop /// returns. On failure this logs why: pens then paint like a mouse. @@ -382,4 +435,40 @@ mod tests { assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app)); assert_eq!(app.ui.tool, Tool::Brush); } + + /// Windows: what the pointer hooks record reaches the Eraser switch through the service the + /// app installs (#2285). + #[cfg(target_os = "windows")] + #[test] + fn windows_eraser_end_switches_to_the_eraser_and_back() { + use photocraft_tablet::windows::{ + PEN_FLAG_ERASER, PEN_FLAG_INVERTED, WM_POINTERDOWN, WM_POINTERLEAVE, WM_POINTERUP, WM_POINTERUPDATE, handle_pointer_message, + }; + use photocraft_ui_egui::stylus::Stylus; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.services.pen_end = pen_end_service(); + app.ui.tool = Tool::Brush; + let hover = |pen_flags: u32| handle_pointer_message(WM_POINTERUPDATE, 1, move |_| Some(pen_flags)); + hover(0); + assert!(!Stylus::sync_eraser_tool(&mut app), "the tip changes nothing"); + hover(PEN_FLAG_ERASER); + assert!(!Stylus::sync_eraser_tool(&mut app), "an eraser button on the barrel is not the eraser end"); + handle_pointer_message(WM_POINTERLEAVE, 1, |_| None); + hover(PEN_FLAG_INVERTED); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Eraser); + handle_pointer_message(WM_POINTERLEAVE, 1, |_| None); + assert!(!Stylus::sync_eraser_tool(&mut app), "out of range"); + hover(0); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Brush, "the tip restores the Brush"); + // An eraser-end tap and the pen leaving, all before the next frame: the frame that + // handles the queued click still selects the Eraser. + handle_pointer_message(WM_POINTERDOWN, 1, |_| Some(PEN_FLAG_INVERTED | PEN_FLAG_ERASER)); + handle_pointer_message(WM_POINTERUP, 1, |_| None); + handle_pointer_message(WM_POINTERLEAVE, 1, |_| None); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Eraser); + assert!(!Stylus::sync_eraser_tool(&mut app), "out of range"); + } } diff --git a/assets/icons/crop-overlay-diagonal.svg b/assets/icons/crop-overlay-diagonal.svg new file mode 100644 index 0000000000..429dfe5d5d --- /dev/null +++ b/assets/icons/crop-overlay-diagonal.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-golden-ratio.svg b/assets/icons/crop-overlay-golden-ratio.svg new file mode 100644 index 0000000000..3ebe4c14b8 --- /dev/null +++ b/assets/icons/crop-overlay-golden-ratio.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-golden-spiral.svg b/assets/icons/crop-overlay-golden-spiral.svg new file mode 100644 index 0000000000..4e365613b7 --- /dev/null +++ b/assets/icons/crop-overlay-golden-spiral.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-grid.svg b/assets/icons/crop-overlay-grid.svg new file mode 100644 index 0000000000..aebe804016 --- /dev/null +++ b/assets/icons/crop-overlay-grid.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-thirds.svg b/assets/icons/crop-overlay-thirds.svg new file mode 100644 index 0000000000..3893cfd386 --- /dev/null +++ b/assets/icons/crop-overlay-thirds.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-triangle.svg b/assets/icons/crop-overlay-triangle.svg new file mode 100644 index 0000000000..70c915d38c --- /dev/null +++ b/assets/icons/crop-overlay-triangle.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/history-brush.svg b/assets/icons/history-brush.svg new file mode 100644 index 0000000000..53d4deea26 --- /dev/null +++ b/assets/icons/history-brush.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 0000000000..04bc7cb604 --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/rotate-ccw.svg b/assets/icons/rotate-ccw.svg new file mode 100644 index 0000000000..eb0fe3491a --- /dev/null +++ b/assets/icons/rotate-ccw.svg @@ -0,0 +1,14 @@ + + + + diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 11ce2f4549..511f5f8850 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -63,6 +63,8 @@ Filesystem capabilities are implemented. General tool and method capabilities re The five shared core tools are `command_list`, `command_run`, `command_batch`, `doc_inspect` and `render_preview`. Existing documented tools, including `doc_render_preview`, remain -listed. `photocraft://document` and `photocraft://commands` expose live JSON. Direct headless -Render Video supports progress and cancellation using the granted write root; see +listed. `photocraft://document` and `photocraft://commands` expose live JSON. Long commands +that are waited on report progress and stop when the request is cancelled, headless and in +bridge mode (where they may also outlast the bridge's reply timeout), and direct headless +Render Video does too, using the granted write root; see [`docs/mcp.md`](../../../docs/mcp.md) for arguments, cleanup and transport behavior. diff --git a/contributors/people.toml b/contributors/people.toml index f8dece1fdc..a2ead57598 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,7 +104,9 @@ display_name = "Ran Geerts" sync_github_name = false [people.vangel76] -sync_github_name = true +real_name = "Valentin Klein" +display_name = "vangel76" +sync_github_name = false [people.FinnNk] sync_github_name = true @@ -199,6 +201,9 @@ sync_github_name = true real_name = "Guillem Hermida" sync_github_name = true +[people.fokas-hoa] +display_name = "Tasos Fokas" + [people.fjamesprice] sync_github_name = true @@ -306,3 +311,7 @@ sync_github_name = true real_name = "Simone Recchia" display_name = "Simone Recchia" sync_github_name = false + +[people.davidvhk] +real_name = "David Vanhoucke" +sync_github_name = true diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 569ab65c03..11e862ffa6 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -296,7 +296,31 @@ impl<'a> Archive<'a> { /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. pub fn read(&mut self, name: &str) -> Result, Error> { - let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); + self.claim(name)?.decode() + } + + /// Extract several entries, decoding them on worker threads. The entries are charged one + /// after the other exactly as the same number of [`read`](Self::read) calls would charge + /// them, and the first error in that order is the one returned. + pub(crate) fn read_many(&mut self, names: &[&str]) -> Result>, Error> { + let mut claimed = Vec::with_capacity(names.len()); + let mut refused = None; + for name in names { + match self.claim(name) { + Ok(c) => claimed.push(c), + Err(e) => { + refused = Some(e); + break; + } + } + } + let decoded = decode_all(&claimed).into_iter().collect::, _>>()?; + refused.map_or(Ok(decoded), Err) + } + + /// Check an entry against the limits, count it as extracted and locate its stored bytes. + fn claim(&mut self, name: &str) -> Result, Error> { + let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?; let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; if size > self.limits.max_entry { return Err(Error::Limit("archive entry too large")); @@ -315,22 +339,79 @@ impl<'a> Archive<'a> { let method = e.compression & 3; let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; let data = c.take(offset(stored_len)?)?; - let mut out = match method { - 1 => inflate(data, size)?, - 2 => unzstd(data, size)?, - _ => data.to_vec(), + Ok(Claimed { data, size, compression: e.compression, crc: e.crc }) + } +} + +/// An entry that is charged and located but not yet decoded. +struct Claimed<'a> { + data: &'a [u8], + size: usize, + compression: u8, + crc: u32, +} + +impl Claimed<'_> { + fn decode(&self) -> Result, Error> { + let mut out = match self.compression & 3 { + 1 => inflate(self.data, self.size)?, + 2 => unzstd(self.data, self.size)?, + _ => self.data.to_vec(), }; - if out.len() != size { + if out.len() != self.size { return Err(Error::Malformed("archive entry size mismatch")); } - unpredict(&mut out, e.compression)?; - if crc32fast::hash(&out) != e.crc { + unpredict(&mut out, self.compression)?; + if crc32fast::hash(&out) != self.crc { return Err(Error::Malformed("archive entry checksum mismatch")); } Ok(out) } } +/// Most threads decoding entries at once; each zstd frame may reserve its declared window. +const MAX_DECODE_THREADS: usize = 8; +/// Fewer entries than this are decoded in place: starting a thread costs more than they do. +const MIN_PARALLEL_ENTRIES: usize = 4; + +/// Decode every entry, in parallel when there are enough of them. The results are in order. +fn decode_all(jobs: &[Claimed<'_>]) -> Vec, Error>> { + static THREADS: std::sync::OnceLock = std::sync::OnceLock::new(); + // The browser build has no threads to start. + let threads = if jobs.len() < MIN_PARALLEL_ENTRIES || cfg!(target_arch = "wasm32") { + 1 + } else { + (*THREADS.get_or_init(|| std::thread::available_parallelism().map_or(1, usize::from).min(MAX_DECODE_THREADS))).min(jobs.len()) + }; + if threads <= 1 { + return jobs.iter().map(Claimed::decode).collect(); + } + let next = std::sync::atomic::AtomicUsize::new(0); + // The workers and this thread take the next undecoded entry until none is left. + let work = || { + let mut done = Vec::new(); + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(job) = jobs.get(i) else { break }; + done.push((i, job.decode())); + } + done + }; + let mut slots: Vec, Error>>> = jobs.iter().map(|_| None).collect(); + std::thread::scope(|sc| { + // A thread that cannot be started just leaves its share to the others. + let workers: Vec<_> = (1..threads).filter_map(|_| std::thread::Builder::new().spawn_scoped(sc, work).ok()).collect(); + let mut finished = vec![work()]; + finished.extend(workers.into_iter().map(|w| w.join().unwrap_or_default())); + for (i, result) in finished.into_iter().flatten() { + if let Some(slot) = slots.get_mut(i) { + *slot = Some(result); + } + } + }); + slots.into_iter().map(|r| r.unwrap_or(Err(Error::Malformed("tile decoder stopped")))).collect() +} + /// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught /// without unbounded allocation. fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index b0a8c2daab..3749862891 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -2,7 +2,7 @@ //! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of //! 256 bytes × 256 rows per channel, each its own archive entry. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::Error; use crate::model::{Affine, Image, Pixels, Reader}; @@ -123,35 +123,78 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?; r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?; let mut planes = Vec::with_capacity(channels); - let mut cache = HashMap::new(); + let mut cache = Cache::new(); for c in 1..=channels { planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?); } - let n = (w as usize) * (h as usize); - let mut rgba = vec![0u8; n * 4]; - let sample = |p: &Vec, i: usize| -> f64 { - if bps == 2 { - f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0 - } else { - f64::from(p.get(i).copied().unwrap_or(0)) / 255.0 - } - }; - for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); - let (r2, g, b, a) = if channels == 1 { - (v[0], v[0], v[0], 1.0) - } else if channels == 5 { - let k = 1.0 - v[3]; - ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4]) - } else { - (v[0], v[1], v[2], v[3]) - }; - px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); - } + let rgba = interleave(&planes, channels, bps, pixels); let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world); Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) } +/// Quantize a unit-range sample to a byte: the one conversion every format goes through. +fn quant(v: f64) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Interleave the decoded planes (`n` samples of `bps` bytes each) into RGBA8. +/// +/// The samples used to be widened to `f64` one pixel at a time. The tables below hold the same +/// expressions evaluated once per input value, so the bytes are identical: an 8-bit sample +/// `x / 255 * 255` rounds back to `x`, a 16-bit one goes through a 64 Ki table, and CMYK's +/// `(1 - c)(1 - k)` through a table over the 256 x 256 `(c, k)` pairs. +pub(crate) fn interleave(planes: &[Vec], channels: usize, bps: usize, n: usize) -> Vec { + let mut rgba = vec![0u8; n * 4]; + let out = rgba.as_chunks_mut::<4>().0.iter_mut(); + let p = |c: usize| planes.get(c).map_or(&[][..], Vec::as_slice); + match (channels, bps) { + (1, 1) => { + for (o, &v) in out.zip(p(0)) { + *o = [v, v, v, 255]; + } + } + (4, 1) => { + let (r, g, b, a) = (p(0), p(1), p(2), p(3)); + for ((((o, &r), &g), &b), &a) in out.zip(r).zip(g).zip(b).zip(a) { + *o = [r, g, b, a]; + } + } + (5, 1) => { + let lut = cmyk_table(); + let (c, m, y, k, a) = (p(0), p(1), p(2), p(3), p(4)); + for (((((o, &c), &m), &y), &k), &a) in out.zip(c).zip(m).zip(y).zip(k).zip(a) { + let k = usize::from(k) << 8; + *o = [lut[k | usize::from(c)], lut[k | usize::from(m)], lut[k | usize::from(y)], a]; + } + } + (_, _) => { + let lut: Vec = (0..=u16::MAX).map(|v| quant(f64::from(v) / 65535.0)).collect(); + let s16 = |c: usize| p(c).as_chunks::<2>().0.iter().map(|b| lut[usize::from(u16::from_le_bytes(*b))]); + if channels == 1 { + for (o, g) in out.zip(s16(0)) { + *o = [g, g, g, 255]; + } + } else { + for ((((o, r), g), b), a) in out.zip(s16(0)).zip(s16(1)).zip(s16(2)).zip(s16(3)) { + *o = [r, g, b, a]; + } + } + } + } + rgba +} + +/// `(1 - c)(1 - k)` quantized, indexed by `k << 8 | c`. +fn cmyk_table() -> Vec { + let mut lut = Vec::with_capacity(256 * 256); + for k in 0..=255u8 { + for c in 0..=255u8 { + lut.push(quant((1.0 - f64::from(c) / 255.0) * (1.0 - f64::from(k) / 255.0))); + } + } + lut +} + /// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`. fn original(r: &mut Reader, name: &str) -> Result, Error> { let bytes = r.archive.read(name)?; @@ -167,14 +210,24 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] { [prefix[0], prefix[1], prefix[2], b'0' + c as u8] } +/// Decoded tiles by archive entry name: a tile shared by several grid cells or channels is +/// extracted (and charged to the archive's budget) once. +type Cache<'s> = HashMap<&'s str, Vec>; + +/// A tile to copy into a plane once its entry is decoded: pixel column and row of its corner. +struct Place<'s> { + name: &'s str, + at: (usize, usize), +} + /// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample. -fn plane( - r: &mut Reader, +fn plane<'s>( + r: &mut Reader<'s, '_, '_>, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32), - cache: &mut HashMap>, + cache: &mut Cache<'s>, ) -> Result, Error> { let s = r.s; let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); @@ -199,6 +252,15 @@ fn plane( 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 crop = (bx0, py0, bx1, py1); + // First walk the grid: flat cells are filled right away (cells never overlap, so the order + // of the copies does not matter) and the tiles that show are collected. They are extracted + // together below, on several threads, then copied. A walk that fails still extracts the + // tiles before the failing cell, as a tile-by-tile walk would have. + let mut places: Vec> = Vec::new(); + let mut wanted: Vec<&'s str> = Vec::new(); + let mut queued: HashSet<&'s str> = HashSet::new(); + let mut failure = None; 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)); @@ -208,30 +270,28 @@ fn plane( 4 => { let Some(at) = tiles.iter().position(|v| matches!(v, Value::Obj(Some(_)))) else { r.charge_slots(tiles.len())?; - return Err(Error::Malformed("missing pixel tile")); + failure = Some(Error::Malformed("missing pixel tile")); + break; }; r.charge_slots(at + 1)?; - let Some(Value::Obj(Some(blck))) = tiles.get(at) else { return Err(Error::Malformed("missing pixel tile")) }; + let Some(Value::Obj(Some(blck))) = tiles.get(at) else { + failure = Some(Error::Malformed("missing pixel tile")); + break; + }; let blck = *blck; tiles = tiles.get(at + 1..).unwrap_or_default(); let (ox, oy) = (tx * TILE, ty * TILE); if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { continue; } - let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); - if !cache.contains_key(&name) { - let mut data = r.archive.read(&name)?; - // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. - if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { - data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec(); - } - if data.len() != TILE_BYTES { - return Err(Error::Malformed("pixel tile size")); - } - cache.insert(name.clone(), data); + let Some(name) = s.entry(blck, b"Data") else { + failure = Some(Error::Malformed("pixel tile without data")); + break; + }; + if !cache.contains_key(name) && queued.insert(name) { + wanted.push(name); } - let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; - copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); + places.push(Place { name, at: (ox, oy) }); continue; } 3 => { @@ -242,22 +302,50 @@ fn plane( r.warn("pixel layers drawn from an embedded image (left empty)"); None } - _ => return Err(Error::Malformed("unknown pixel tile state")), + _ => { + failure = Some(Error::Malformed("unknown pixel tile state")); + break; + } }; if let Some(v) = fill { - copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v); + copy(&mut out, row, crop, (tx * TILE, ty * TILE), None, v); } } + let decoded = r.archive.read_many(&wanted); + // A tile that fails to extract is reported before the failure of the walk after it. + for (name, mut data) in wanted.into_iter().zip(decoded?) { + // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. + if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { + data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec(); + } + if data.len() != TILE_BYTES { + return Err(Error::Malformed("pixel tile size")); + } + cache.insert(name, data); + } + if let Some(e) = failure { + return Err(e); + } + for place in &places { + let tile = cache.get(place.name).ok_or(Error::Malformed("pixel tile"))?; + copy(&mut out, row, crop, place.at, Some(tile), 0); + } Ok(out) } -/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. -fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { +/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. `tile` is +/// the tile's bytes, or `None` to fill the overlap with `fill`. +pub(crate) fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), tile: Option<&[u8]>, fill: u8) { + let (xa, xb) = (ox.max(bx0), (ox + TILE).min(bx1)); + if xa >= xb { + return; + } for y in oy.max(py0)..(oy + TILE).min(py1) { - for x in ox.max(bx0)..(ox + TILE).min(bx1) { - if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) { - *b = at(x - ox, y - oy); - } + let start = (y - py0) * row + (xa - bx0); + let Some(dst) = out.get_mut(start..start + (xb - xa)) else { continue }; + match tile.and_then(|t| t.get((y - oy) * TILE + (xa - ox)..(y - oy) * TILE + (xb - ox))) { + Some(src) => dst.copy_from_slice(src), + None => dst.fill(if tile.is_some() { 0 } else { fill }), } } } diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index a8a1187da5..1afb5d29f8 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -808,6 +808,192 @@ mod spot_colours { } } +/// The raster fast paths against the code they replaced. +mod raster_identity { + use crate::raster::{copy, interleave}; + + /// Deterministic filler (xorshift). + fn bytes(n: usize, mut x: u64) -> Vec { + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }) + .collect() + } + + /// The per-pixel `f64` conversion `decode` used before the tables. + fn reference(planes: &[Vec], channels: usize, bps: usize, n: usize) -> Vec { + let sample = |p: &Vec, i: usize| -> f64 { + if bps == 2 { + f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0 + } else { + f64::from(p.get(i).copied().unwrap_or(0)) / 255.0 + } + }; + let mut rgba = vec![0u8; n * 4]; + for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); + let (r, g, b, a) = if channels == 1 { + (v[0], v[0], v[0], 1.0) + } else if channels == 5 { + let k = 1.0 - v[3]; + ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4]) + } else { + (v[0], v[1], v[2], v[3]) + }; + px.copy_from_slice(&[r, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + rgba + } + + #[test] + fn eight_bit_samples_round_trip() { + // Every byte through every position of the 4-channel and the grey path. + let ramp: Vec = (0..=255).collect(); + let planes = vec![ramp.clone(), ramp.iter().rev().copied().collect(), ramp.clone(), ramp.iter().rev().copied().collect()]; + assert_eq!(interleave(&planes, 4, 1, 256), reference(&planes, 4, 1, 256)); + assert_eq!(interleave(&planes[..1], 1, 1, 256), reference(&planes[..1], 1, 1, 256)); + } + + #[test] + fn sixteen_bit_samples_are_exhaustive() { + // All 65536 values in each channel position (rotated so the channels differ). + let all: Vec = (0..=u16::MAX).flat_map(u16::to_le_bytes).collect(); + let rot = |k: usize| -> Vec { (0..=u16::MAX).flat_map(|v| v.wrapping_add((k * 4099) as u16).to_le_bytes()).collect() }; + let planes = vec![all, rot(1), rot(2), rot(3)]; + assert_eq!(interleave(&planes, 4, 2, 65536), reference(&planes, 4, 2, 65536)); + assert_eq!(interleave(&planes[..1], 1, 2, 65536), reference(&planes[..1], 1, 2, 65536)); + } + + #[test] + fn cmyk_is_exhaustive_over_colour_and_black() { + // Every (c, k) pair in each of the c, m and y positions; alpha is arbitrary. + let n = 256 * 256; + let k: Vec = (0..n).map(|i| (i >> 8) as u8).collect(); + let c: Vec = (0..n).map(|i| i as u8).collect(); + let m: Vec = (0..n).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect(); + let y: Vec = (0..n).map(|i| (i as u8).wrapping_mul(13).wrapping_add(250)).collect(); + let planes = vec![c, m, y, k, bytes(n, 9)]; + assert_eq!(interleave(&planes, 5, 1, n), reference(&planes, 5, 1, n)); + } + + #[test] + fn random_planes_match_for_every_format() { + for (channels, bps) in [(1, 1), (1, 2), (4, 1), (4, 2), (5, 1)] { + let n = 5000; + let planes: Vec> = (0..channels).map(|c| bytes(n * bps, 1 + c as u64)).collect(); + assert_eq!(interleave(&planes, channels, bps, n), reference(&planes, channels, bps, n), "{channels}x{bps}"); + } + } + + /// The per-byte closure copy `plane` used before the row copies. + fn copy_reference( + out: &mut [u8], + row: usize, + (bx0, py0, bx1, py1): (usize, usize, usize, usize), + (ox, oy): (usize, usize), + at: impl Fn(usize, usize) -> u8, + ) { + for y in oy.max(py0)..(oy + 256).min(py1) { + for x in ox.max(bx0)..(ox + 256).min(bx1) { + if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) { + *b = at(x - ox, y - oy); + } + } + } + } + + #[test] + fn tile_copy_matches_the_byte_loop_for_every_crop() { + let tile = bytes(256 * 256, 77); + // Crops that cut the tile on each side, sit inside it, and miss it. + for &(bx0, bx1, py0, py1) in + &[(0, 600, 0, 600), (10, 300, 20, 200), (100, 150, 100, 130), (256, 512, 0, 256), (0, 256, 300, 400), (255, 257, 255, 257), (300, 301, 0, 1)] + { + for &(ox, oy) in &[(0, 0), (256, 0), (0, 256), (256, 256), (512, 256)] { + let row = bx1 - bx0; + let mut old = vec![0xAAu8; row * (py1 - py0)]; + let mut new = old.clone(); + copy_reference(&mut old, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile[y * 256 + x]); + copy(&mut new, row, (bx0, py0, bx1, py1), (ox, oy), Some(&tile), 0); + assert_eq!(old, new, "tile {ox},{oy} crop {bx0}..{bx1} x {py0}..{py1}"); + copy_reference(&mut old, row, (bx0, py0, bx1, py1), (ox, oy), |_, _| 0xFF); + copy(&mut new, row, (bx0, py0, bx1, py1), (ox, oy), None, 0xFF); + assert_eq!(old, new, "fill {ox},{oy} crop {bx0}..{bx1} x {py0}..{py1}"); + } + } + } +} + +/// `Archive::read_many` against the same reads made one at a time. +mod read_many { + use crate::synth::{self, Method}; + use crate::{Archive, Error, Limits}; + + fn entry(i: usize) -> Vec { + (0..4096 + i * 37).map(|b| (b * 31 + i * 7) as u8).collect() + } + + fn archive(count: usize, method: Method) -> Vec { + let datas: Vec> = (0..count).map(entry).collect(); + let names: Vec = (0..count).map(|i| format!("d/{i}")).collect(); + let entries: Vec<(&str, &[u8], Method)> = names.iter().zip(&datas).map(|(n, d)| (n.as_str(), d.as_slice(), method)).collect(); + synth::container(&entries, None) + } + + #[test] + fn matches_serial_reads_and_charges_the_same() { + for method in [Method::Stored, Method::Zlib, Method::Zstd] { + for count in [1, 3, 4, 5, 40] { + let file = archive(count, method); + let names: Vec = (0..count).map(|i| format!("d/{i}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut one = Archive::open(&file, Limits::default()).unwrap(); + let serial: Vec> = names.iter().map(|n| one.read(n).unwrap()).collect(); + let mut many = Archive::open(&file, Limits::default()).unwrap(); + assert_eq!(many.read_many(&names).unwrap(), serial); + assert_eq!(many.extracted(), one.extracted()); + assert_eq!(serial[count - 1], entry(count - 1)); + } + } + } + + #[test] + fn the_limit_stops_at_the_same_entry_as_serial_reads() { + let file = archive(12, Method::Zstd); + let names: Vec = (0..12).map(|i| format!("d/{i}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let limits = Limits { max_total: (0..7).map(|i| entry(i).len()).sum(), ..Limits::default() }; + let mut one = Archive::open(&file, limits).unwrap(); + let failed = names.iter().position(|n| one.read(n).is_err()).unwrap(); + assert_eq!(failed, 7); + let mut many = Archive::open(&file, limits).unwrap(); + assert!(matches!(many.read_many(&names), Err(Error::Limit(_)))); + assert_eq!(many.extracted(), one.extracted()); + } + + #[test] + fn the_first_failing_entry_in_order_is_reported() { + let mut file = archive(12, Method::Stored); + // Flip a data byte of entry 2 (checksum failure); entry 9 is missing from the list. + let at = file.windows(4).position(|w| w == b"#Fil").unwrap(); + let second = at + 4 + entry(0).len(); + let third = second + 4 + entry(1).len(); + file[third + 4 + 10] ^= 0xFF; + let names: Vec = (0..12).map(|i| if i == 9 { "gone".to_string() } else { format!("d/{i}") }).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut a = Archive::open(&file, Limits::default()).unwrap(); + assert_eq!(a.read_many(&names).unwrap_err(), Error::Malformed("archive entry checksum mismatch")); + // Without the corrupt entry the missing one is what fails. + let intact = archive(12, Method::Stored); + let mut a = Archive::open(&intact, Limits::default()).unwrap(); + assert_eq!(a.read_many(&names).unwrap_err(), Error::Malformed("missing archive entry")); + } +} + /// Object lookup and record arrays against the structures they replaced. mod stream_storage { use crate::stream::{self, Kind, Object, Tag, Value}; diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs index 2e18f9457e..9c2f0b55bf 100644 --- a/crates/algo/examples/bench_multiband.rs +++ b/crates/algo/examples/bench_multiband.rs @@ -77,7 +77,7 @@ fn run() -> Result<(), String> { let mut samples_ms = Vec::with_capacity(o.reps); for _ in 0..o.reps { let t0 = Instant::now(); - let output = panorama::multiband(cw, ch, &rois, &wts, levels); + let output = panorama::multiband(cw, ch, &rois, &wts, levels).map_err(|e| format!("multiband: {e}"))?; samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0); std::hint::black_box(&output); } diff --git a/crates/algo/examples/bench_remove_targets.rs b/crates/algo/examples/bench_remove_targets.rs new file mode 100644 index 0000000000..780875a1dc --- /dev/null +++ b/crates/algo/examples/bench_remove_targets.rs @@ -0,0 +1,162 @@ +//! Timing of the Remove Tool's target-mask construction (the dilation of the hole by the patch +//! radius, once per pyramid level) and of the whole fill, on a 24 or 36 MP RGBA f32 image. One +//! JSON line per repetition. Uses only APIs that exist before and after the change, so the same +//! file builds in a base checkout. +//! `cargo run --release -p photocraft-algo --example bench_remove_targets -- --mode phase --mp 24 --hole small --reps 20` +//! +//! * `--mode phase|e2e`: `phase` times the masks alone (`old_loop`, a verbatim copy of the loop +//! the completion used to run, against `remove_dilate`); `e2e` times `remove_with`. +//! * `--hole small|scattered|large|dense`: a 48 px disc; about 20 single cells spread over the +//! image; a centred ellipse (`--large 900x600` sets its extent); half the cells of a 2000x2000 +//! square (`phase` only). +use std::time::Instant; + +use photocraft_algo::remove::{dilate, hole_radius, remove_with}; +use photocraft_raster::Interrupt; + +/// The patch radius of the completion's default parameters. +const R: usize = 3; + +fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn hash(i: u64) -> u64 { + let mut x = i.wrapping_add(0x9e37_79b9_7f4a_7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +fn fnv(bytes: impl Iterator) -> u64 { + bytes.fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)) +} + +fn texture(w: usize, h: usize) -> Vec { + let mut img = Vec::with_capacity(w * h * 4); + for i in 0..w * h { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let n = (hash(i as u64) >> 40) as f32 / (1u64 << 24) as f32; + let v = 0.5 + 0.2 * (x * 0.05).sin() * (y * 0.07).cos() + 0.15 * n; + img.extend_from_slice(&[v, v * 0.8 + 0.05 * (x * 0.01).cos(), v * 0.6, 1.0]); + } + img +} + +fn hole_mask(kind: &str, w: usize, h: usize, large: (usize, usize)) -> Vec { + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let mut m = vec![false; w * h]; + match kind { + "small" => (0..w * h).for_each(|i| m[i] = ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < 24.0), + "scattered" => { + for k in 0..20u64 { + let (x, y) = (hash(k * 2) as usize % w, hash(k * 2 + 1) as usize % h); + m[y * w + x] = true; + } + } + "large" => { + let (a, b) = (large.0 as f32 / 2.0, large.1 as f32 / 2.0); + (0..w * h).for_each(|i| m[i] = (((i % w) as f32 - cx) / a).powi(2) + (((i / w) as f32 - cy) / b).powi(2) < 1.0); + } + "dense" => { + for y in h / 2 - 1000..h / 2 + 1000 { + for x in w / 2 - 1000..w / 2 + 1000 { + m[y * w + x] = hash((y * w + x) as u64) & 1 == 0; + } + } + } + _ => {} + } + m +} + +/// The pyramid levels the completion builds for this hole: the same count formula and the same +/// 2x2 OR of the hole. +fn hole_levels(w: usize, h: usize, hole: &[bool]) -> Vec<(usize, usize, Vec)> { + let n = 2 * R + 1; + let no = hole_radius(w, h, hole).min(1 << 20); + let want = (2.0 * f64::from(no) / n as f64).log2().ceil().max(1.0) as usize; + let (mut lw, mut lh, mut levels) = (w, h, 1); + while levels < want && lw / 2 >= 3 * n && lh / 2 >= 3 * n { + (lw, lh, levels) = (lw.div_ceil(2), lh.div_ceil(2), levels + 1); + } + let mut out = vec![(w, h, hole.to_vec())]; + while out.len() < levels { + let Some((pw, ph, prev)) = out.last() else { break }; + let (nw, nh) = (pw.div_ceil(2), ph.div_ceil(2)); + let next: Vec = (0..nw * nh) + .map(|i| { + let (fx, fy) = ((i % nw) * 2, (i / nw) * 2); + (0..2).any(|dy| (0..2).any(|dx| fx + dx < *pw && fy + dy < *ph && prev[(fy + dy) * pw + fx + dx])) + }) + .collect(); + out.push((nw, nh, next)); + } + out +} + +/// The loop the completion ran per level before the change, verbatim. +fn old_loop(lw: usize, lh: usize, hole: &[bool], r: usize) -> Vec { + let ri = r as i32; + let mut targets = vec![false; lw * lh]; + for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % lw) as i32, (i / lw) as i32); + for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) { + for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) { + targets[ty as usize * lw + tx as usize] = true; + } + } + } + targets +} + +fn main() { + let args: Vec = std::env::args().collect(); + let mode = arg(&args, "--mode").unwrap_or("phase"); + let mp: usize = arg(&args, "--mp").and_then(|v| v.parse().ok()).unwrap_or(24); + let kind = arg(&args, "--hole").unwrap_or("small"); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5); + let large = arg(&args, "--large").and_then(|v| v.split_once('x')).and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))).unwrap_or((900, 600)); + let (w, h) = if mp == 36 { (7360, 4912) } else { (6000, 4000) }; + let hole = hole_mask(kind, w, h, large); + let line = |rep: usize, variant: &str, ms: f64, hash: u64| { + println!("{{\"mode\":\"{mode}\",\"mp\":{mp},\"hole\":\"{kind}\",\"rep\":{rep},\"variant\":\"{variant}\",\"ms\":{ms:.3},\"hash\":\"{hash:016x}\"}}") + }; + match mode { + "phase" => { + let levels = hole_levels(w, h, &hole); + for rep in 0..reps { + let t = Instant::now(); + let old: Vec> = levels.iter().map(|(lw, lh, m)| old_loop(*lw, *lh, m, R)).collect(); + let old_ms = t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + let new: Vec> = levels.iter().map(|(lw, lh, m)| dilate(*lw, *lh, m, R)).collect(); + let new_ms = t.elapsed().as_secs_f64() * 1e3; + let (ho, hn) = (fnv(old.iter().flatten().map(|b| u8::from(*b))), fnv(new.iter().flatten().map(|b| u8::from(*b)))); + assert_eq!(ho, hn, "the masks differ"); + line(rep, "old_loop", old_ms, ho); + line(rep, "remove_dilate", new_ms, hn); + } + } + "e2e" => { + if kind == "dense" { + eprintln!("the dense hole is a phase-only scenario"); + std::process::exit(2); + } + let img = texture(w, h); + for rep in 0..reps { + let t = Instant::now(); + let Ok(out) = remove_with(w, h, 4, &img, &hole, 1, &Interrupt::NONE) else { + eprintln!("cancelled"); + std::process::exit(1); + }; + let ms = t.elapsed().as_secs_f64() * 1e3; + line(rep, "remove_with", ms, fnv(out.iter().flat_map(|v| v.to_bits().to_le_bytes()))); + } + } + _ => { + eprintln!("unknown --mode {mode}"); + std::process::exit(2); + } + } +} diff --git a/crates/algo/examples/bench_smartselect.rs b/crates/algo/examples/bench_smartselect.rs index 66146e50f4..ff070d0b97 100644 --- a/crates/algo/examples/bench_smartselect.rs +++ b/crates/algo/examples/bench_smartselect.rs @@ -81,16 +81,15 @@ fn main() { // Refine edge on a 20 px band (selection = the object result as a surface). let sel = combine_region(None, Some(&obj), SelectionMode::Replace).expect("selection"); - let content = sel.content_bounds(); let p = RefineParams { radius: 20.0, smooth: 10.0, feather: 1.0, contrast: 10.0, ..Default::default() }; let t = Instant::now(); - let refined = matting::refine_mask(&sampler, &matting::surface_reader(&sel), content, canvas, &p).expect("refined"); + let refined = matting::refine_surface(&sampler, &sel, canvas, &p, None).expect("allocation").expect("refined"); println!("refine edge (radius 20): {:.2?}", t.elapsed()); let t = Instant::now(); - let _ = matting::refine_mask(&sampler, &matting::surface_reader(&sel), content, canvas, &RefineParams { smart_radius: true, ..p }).expect("refined"); + let _ = matting::refine_surface(&sampler, &sel, canvas, &RefineParams { smart_radius: true, ..p }, None).expect("allocation").expect("refined"); println!("refine edge (radius 20, smart): {:.2?}", t.elapsed()); let t = Instant::now(); - let dec = matting::decontaminate(&s, &refined, 20.0, 100.0); + let dec = matting::decontaminate_surface(&s, &refined, 20.0, 100.0); println!("decontaminate: {:.2?} ({} tiles)", t.elapsed(), dec.tile_count()); // Focus area. diff --git a/crates/algo/src/artistic.rs b/crates/algo/src/artistic.rs index 4266091451..cb817f2935 100644 --- a/crates/algo/src/artistic.rs +++ b/crates/algo/src/artistic.rs @@ -236,6 +236,19 @@ impl GalleryEffect { } } + /// A copy whose stored numeric values are held to their declared ranges (a non-finite value + /// becomes the default). A project file stores `values` as plain numbers, so unlike + /// [`Self::set`] nothing has clamped them; geometry sized from them (reach) needs it. + pub(crate) fn clamped(&self) -> GalleryEffect { + let mut out = self.clone(); + for p in self.filter.params() { + if let (GalleryParamKind::Range { min, max, default }, Some(v)) = (&p.kind, out.values.get_mut(p.key)) { + *v = if v.is_finite() { v.clamp(*min, *max) } else { *default }; + } + } + out + } + /// A bool value. pub fn flag(&self, key: &str) -> bool { self.get(key) != 0.0 diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index 6ef9dad13a..a2c98e3c39 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -407,6 +407,8 @@ fn highlights(p: Px, l: f32, area: f32, intensity: f32) -> Px { /// Pixels an effect reads around an output pixel. pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 { + // Stored values come from a project file unclamped; keep the arithmetic below inside i32. + let e = &e.clamped(); let g = |k: &str| e.get(k); match e.filter { F::ColoredPencil => 2, @@ -528,7 +530,7 @@ fn glass_cell(e: &GalleryEffect) -> f32 { /// Total reach of a stack of effects. pub(crate) fn reach(effects: &[GalleryEffect]) -> i32 { - effects.iter().map(effect_reach).sum() + effects.iter().map(effect_reach).fold(0, i32::saturating_add) } /// Runs a stack of effects over `out` (the source covers `out` grown by [`reach`]). @@ -540,8 +542,9 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx) return src.crop(out); } let mut win = Win { r, w, h, c: src.data.chunks_exact(n).map(|px| rgb(rgba(ctx, px))).collect() }; - for e in effects { - apply(e, &mut win, ctx); + // The reach that sized `src` comes from the clamped values, so the pixels must use them too. + for e in effects.iter().map(GalleryEffect::clamped) { + apply(&e, &mut win, ctx); } finish(&win, src, out, ctx) } @@ -562,6 +565,8 @@ pub(crate) struct Prepared { impl Prepared { /// `None` for a stack that has no Cutout (it runs tile by tile as usual). pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option { + // Held to the declared ranges once here (see `run`), so the traced shapes and the tiles agree. + let effects = &effects.iter().map(GalleryEffect::clamped).collect::>(); let last = effects.iter().rposition(|e| e.filter == F::Cutout)?; let r = src.rect; let (w, h) = (r.width() as usize, r.height() as usize); diff --git a/crates/algo/src/blur2/shape_tests.rs b/crates/algo/src/blur2/shape_tests.rs index 02bdb0b3ca..213026493c 100644 --- a/crates/algo/src/blur2/shape_tests.rs +++ b/crates/algo/src/blur2/shape_tests.rs @@ -232,3 +232,178 @@ fn direct_36mp_image_with_small_output_is_not_rejected() { let result = shape_blur(&src, Rect::new(3000, 3000, 3001, 3001), &ctx, 5.0, BlurShape::Circle); assert_eq!(result, vec![0.25; 4]); } + +/// An asymmetric weighted kernel detects mirroring independently of span accumulation. +#[test] +fn custom_shape_coverage_matches_point_convolution_without_mirroring() { + let mask = Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.0, 0.25, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0] }; + let prepared = crate::shape_blur::Prepared::custom(1.0, &mask, &photocraft_raster::Interrupt::NONE).unwrap(); + for (channels, alpha) in [(1, false), (4, true)] { + let src = image(Rect::new(-2, -2, 5, 5), channels, alpha); + let ctx = Ctx { bounds: src.rect, mode: ColorMode::Rgb, alpha }; + let out = Rect::new(-3, -3, 6, 6); + let actual = prepared.run(&src, out, &ctx); + let mut expected = Vec::new(); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + let mut sums = vec![0.0f64; channels]; + for ky in 0..3 { + for kx in 0..3 { + let weight = f64::from(mask.data[ky * 3 + kx]); + let (sx, sy) = (x + 1 - kx as i32, y + 1 - ky as i32); + let a = if alpha { src.get(sx, sy, channels - 1) } else { 1.0 }; + for (c, sum) in sums.iter_mut().enumerate() { + let v = src.get(sx, sy, c); + *sum += f64::from(if alpha && c + 1 < channels { v * a } else { v }) * weight; + } + } + } + let mut px: Vec = sums.iter().map(|sum| (sum / 1.75) as f32).collect(); + unpremul_px(&mut px, alpha); + expected.extend(px); + } + } + assert_eq!(actual.len(), expected.len()); + for (a, e) in actual.iter().zip(expected) { + assert!((a - e).abs() < 1e-6, "{a} != {e}"); + } + } + // The top-left kernel corner, rather than its mirror, receives the impulse. + let mut src = Image { rect: Rect::new(0, 0, 7, 7), ch: 1, data: vec![0.0; 49] }; + src.data[3 * 7 + 3] = 1.0; + let ctx = Ctx { bounds: src.rect, mode: ColorMode::Grayscale, alpha: false }; + let out = prepared.run(&src, src.rect, &ctx); + assert!((out[2 * 7 + 2] - 1.0 / 1.75).abs() < 1e-6); + assert_eq!(out[4 * 7 + 4], 0.0); +} + +/// The custom path must agree with the existing square blur, including tiled boundaries. +#[test] +fn custom_square_preserves_builtin_result_and_tile_boundaries() { + let rect = Rect::new(-3, -2, 17, 14); + let src = image(rect, 4, true); + let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true }; + let mask = Image { rect: Rect::new(0, 0, 7, 7), ch: 1, data: vec![1.0; 49] }; + let p = FilterParams::CustomShapeBlur { radius: 2.5, kernel: Some(std::sync::Arc::new(mask)) }; + let actual = kernel(&p, &src, rect, &ctx); + let expected = shape_blur(&src, rect, &ctx, 2.5, BlurShape::Square); + exact(&actual, &expected); + let mut surface = Surface::new(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true }); + surface.write_region(rect, &src.data); + let tiled = apply_tiled(&surface, &p, rect, rect, None, 5, Some(rect)); + let ordinary = apply_tiled(&surface, &FilterParams::ShapeBlur { radius: 2.5, shape: BlurShape::Square }, rect, rect, None, 5, Some(rect)); + exact(&tiled.read_region(rect), &ordinary.read_region(rect)); +} + +/// Oversized and invalid kernel metadata must be rejected before proportional allocation. +#[test] +fn invalid_custom_kernels_are_refused_without_allocating_their_declared_size() { + for mask in [ + Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![0.0; 9] }, + Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![f32::NAN; 9] }, + Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.1; 9] }, + Image { rect: Rect::new(0, 0, 3, 3), ch: 4, data: vec![1.0; 36] }, + Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.0] }, + Image { rect: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), ch: 1, data: vec![] }, + ] { + assert!(crate::shape_blur::Prepared::custom(1.0, &mask, &photocraft_raster::Interrupt::NONE).is_none()); + } +} + +/// Point-by-point weighted convolution of `src` (zero outside it) by a square `mask` of side +/// `2 ri + 1`, normalized by the mask's total coverage: the reference for custom kernels. +fn weighted_reference(mask: &Image, ri: i32, src: &Image, out: Rect, alpha: bool) -> Vec { + let size = (2 * ri + 1) as usize; + let channels = src.ch; + let total: f64 = mask.data.iter().map(|&v| f64::from(v)).sum(); + let mut expected = Vec::new(); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + let mut sums = vec![0.0f64; channels]; + for ky in 0..size { + for kx in 0..size { + let weight = f64::from(mask.data[ky * size + kx]); + let (sx, sy) = (x + ri - kx as i32, y + ri - ky as i32); + let a = if alpha { src.get(sx, sy, channels - 1) } else { 1.0 }; + for (c, sum) in sums.iter_mut().enumerate() { + let v = src.get(sx, sy, c); + *sum += f64::from(if alpha && c + 1 < channels { v * a } else { v }) * weight; + } + } + } + let mut px: Vec = sums.iter().map(|sum| (sum / total) as f32).collect(); + unpremul_px(&mut px, alpha); + expected.extend(px); + } + } + expected +} + +/// Custom weights reach both dispatch paths: the direct kernel and the tiled filter. An +/// all-ones mask could not tell weights that were ignored or mirrored. +#[test] +fn nonuniform_custom_kernel_matches_weighted_reference_through_kernel_and_tiles() { + let rect = Rect::new(-3, -2, 13, 11); + let src = image(rect, 4, true); + let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true }; + // Asymmetric, with distinct weights in every row (5 x 5 for radius 2). + let data: Vec = (0..25).map(|i| [0.0, 0.125, 0.5, 1.0, 0.25, 0.75][(i * 7 + i / 5) % 6]).collect(); + let mask = Image { rect: Rect::new(0, 0, 5, 5), ch: 1, data }; + let expected = weighted_reference(&mask, 2, &src, rect, true); + let p = FilterParams::CustomShapeBlur { radius: 2.0, kernel: Some(std::sync::Arc::new(mask)) }; + let close = |actual: &[f32], what: &str| { + assert_eq!(actual.len(), expected.len(), "{what}"); + for (i, (a, e)) in actual.iter().zip(&expected).enumerate() { + assert!((a - e).abs() < 1e-5, "{what} sample {i}: {a} != {e}"); + } + }; + close(&kernel(&p, &src, rect, &ctx), "kernel"); + let mut surface = Surface::new(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true }); + surface.write_region(rect, &src.data); + // No extent: tiles read transparency beyond the layer, as the reference's zero extension. + close(&apply_tiled(&surface, &p, rect, rect, None, 4, None).read_region(rect), "apply_tiled"); +} + +/// A fragmented custom mask (alternating coverage, about size² / 2 runs) would make every +/// output row scan millions of windows; it is refused, while a smooth disc of the same radius +/// and a fully fragmented small kernel are accepted. +#[test] +fn fragmented_large_custom_kernels_are_refused_before_convolution() { + use crate::shape_blur::{Prepared, max_spans}; + let none = photocraft_raster::Interrupt::NONE; + let size = 2001usize; + let alternating = Image { rect: Rect::new(0, 0, size as i32, size as i32), ch: 1, data: (0..size * size).map(|i| (i % 2) as f32).collect() }; + assert!(size * size / 2 > max_spans(size)); + assert!(Prepared::custom(1000.0, &alternating, &none).is_none()); + // Through the filter: refused before any tile is convolved. + let mut surface = Surface::new(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true }); + let r = Rect::new(0, 0, 4, 4); + surface.fill_rect(r, &[0.25, 0.5, 0.75, 1.0]); + let p = FilterParams::CustomShapeBlur { radius: 1000.0, kernel: Some(std::sync::Arc::new(alternating)) }; + assert!(crate::apply_tiled_with(&surface, &p, r, r, None, 256, Some(r), &none).is_none()); + // A disc of radius 1000 has one run per row. + let c = 1000.0f32; + let disc: Vec = (0..size * size).map(|i| f32::from(((i % size) as f32 - c).hypot((i / size) as f32 - c) <= c)).collect(); + assert!(Prepared::custom(1000.0, &Image { rect: Rect::new(0, 0, size as i32, size as i32), ch: 1, data: disc }, &none).is_some()); + // Every kernel of up to 256 x 256 is accepted, however fragmented. + let small = 255usize; + let fragmented = + Image { rect: Rect::new(0, 0, small as i32, small as i32), ch: 1, data: (0..small * small).map(|i| ((i % 4) as f32 + 1.0) / 4.0).collect() }; + assert!(Prepared::custom(127.0, &fragmented, &none).is_some()); +} + +/// Kernel preparation and each tile's convolution poll cancellation, so a large custom kernel +/// does not run a whole tile after Cancel. +#[test] +fn custom_kernel_preparation_and_convolution_stop_when_cancelled() { + use crate::shape_blur::Prepared; + let none = photocraft_raster::Interrupt::NONE; + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + let mask = Image { rect: Rect::new(0, 0, 3, 3), ch: 1, data: vec![1.0, 0.25, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5] }; + assert!(Prepared::custom(1.0, &mask, &cancelled).is_none()); + let prepared = Prepared::custom(1.0, &mask, &none).unwrap(); + let src = image(Rect::new(0, 0, 6, 5), 4, true); + let ctx = Ctx { bounds: src.rect, mode: ColorMode::Rgb, alpha: true }; + assert_eq!(prepared.checked_run(&src, src.rect, &ctx, &none).map(|v| v.len()), Some(6 * 5 * 4)); + assert!(prepared.checked_run(&src, src.rect, &ctx, &cancelled).is_none()); +} diff --git a/crates/algo/src/cutout/curves.rs b/crates/algo/src/cutout/curves.rs index 76e65d1082..665de0c404 100644 --- a/crates/algo/src/cutout/curves.rs +++ b/crates/algo/src/cutout/curves.rs @@ -65,10 +65,6 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec { let mut keep: Vec = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() }; keep.sort_unstable(); keep.dedup(); - let mut is_corner = vec![false; n]; - for &k in &keep { - is_corner[k] = true; - } if closed && keep.len() < 2 { // A smooth loop: split it at two far-apart points (not corners) to simplify each half. let a = keep.first().copied().unwrap_or(0); @@ -92,8 +88,6 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec { verts.push(n - 1); } let v: Vec = verts.iter().map(|&i| p[i]).collect(); - let corner: Vec = verts.iter().map(|&i| is_corner[i]).collect(); - let _ = &corner; let mut out = v.clone(); if closed { out.extend(v.first().copied()); diff --git a/crates/algo/src/cutout/mod.rs b/crates/algo/src/cutout/mod.rs index b802b40767..00dfa4e291 100644 --- a/crates/algo/src/cutout/mod.rs +++ b/crates/algo/src/cutout/mod.rs @@ -303,7 +303,7 @@ mod tests { } #[test] - fn empty_images_stop_before_the_transpose_and_row_passes() { + fn empty_images_trace_to_nothing() { for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] { let t = trace(&[], r, &settings(4, 2.0, 1.0)); assert!(t.regions.is_empty()); diff --git a/crates/algo/src/cutout/regions.rs b/crates/algo/src/cutout/regions.rs index 803077c03a..7edde5b163 100644 --- a/crates/algo/src/cutout/regions.rs +++ b/crates/algo/src/cutout/regions.rs @@ -44,8 +44,8 @@ pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec, usize) (comp, n as usize) } -/// Merges small and sliver components into their neighbours. Returns each pixel's region and -/// each region's mean colour. +/// Merges components smaller than the Edge Simplicity area into the neighbour they share the most +/// border with. Returns each pixel's region and each region's mean colour. pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec, Vec) { let mut area = vec![0u32; n]; let mut sum = vec![[0.0f64; 3]; n]; diff --git a/crates/algo/src/lens_flare.rs b/crates/algo/src/lens_flare.rs new file mode 100644 index 0000000000..71c6975bbc --- /dev/null +++ b/crates/algo/src/lens_flare.rs @@ -0,0 +1,275 @@ +//! Filter › Render › Lens Flare. +//! +//! A flare is built from elements placed along its axis: the line from the flare centre (t = 0) +//! through the centre of the image (t = 1), as in a real lens, where the internal reflections line +//! up with the optical centre. Each lens has its own set, measured from Photoshop's output +//! (`lens_flare_data.rs`): +//! +//! - radial elements: a colour profile by distance around a point on the axis (the core, glows, +//! rings and the reflection discs, with their coloured rims); +//! - streaks: straight lines centred on the axis (Movie Prime's horizontal and diagonal streaks, the +//! 105 mm Prime's spike); +//! - rays: thin one-pixel rays out of the centre, drawn without antialiasing as Photoshop does. +//! +//! Sizes are in units of 1/800 of the image width: Photoshop sizes a flare by the width alone, the +//! same in landscape, square and portrait images. Brightness changes the shape of the elements, not +//! just their light (the core grows, the glows widen, at 300 % most lenses wash out to white), so +//! each lens is measured at several brightnesses; between two the flare blends them. The light is +//! screened onto the pixels. + +use photocraft_geom::Rect; + +use crate::LensType; +use crate::fxutil::{rgba, set_rgba, smoothstep}; +use crate::image::Image; +use crate::{Ctx, lens_flare_data as data}; + +/// A colour profile by distance (in flare units) around a point on the axis; zero outside the +/// first and last knot, linear between knots. +pub(crate) struct Radial { + pub t: f32, + /// Offset across the axis, in flare units. + pub perp: f32, + pub knots: &'static [(f32, [f32; 3])], +} + +/// A straight streak centred on the axis: full strength along `half · (1 − taper)`, fading to +/// nothing at `half`; a Gaussian `width` across. +pub(crate) struct Streak { + pub t: f32, + pub perp: f32, + /// Degrees, clockwise from the +x axis (image y points down). + pub angle: f32, + pub half: f32, + pub taper: f32, + pub width: f32, + pub rgb: [f32; 3], +} + +/// A one-pixel ray out of the flare centre from radius `r0` to `r1`, fading in and out over +/// `fade` of its length. +pub(crate) struct Ray { + pub angle: f32, + pub r0: f32, + pub r1: f32, + pub fade: f32, + pub rgb: [f32; 3], +} + +/// A lens's flare at one measured brightness (percent). +pub(crate) struct Level { + pub brightness: f32, + pub radial: &'static [Radial], + pub streaks: &'static [Streak], + pub rays: &'static [Ray], +} + +fn levels(lens: LensType) -> &'static [Level] { + match lens { + LensType::Zoom => data::ZOOM, + LensType::Prime35 => data::PRIME35, + LensType::Prime105 => data::PRIME105, + LensType::MoviePrime => data::MOVIE_PRIME, + } +} + +/// The two measured levels around `brightness` and the weight of the second (levels are sorted). +fn bracket(levels: &[Level], brightness: f32) -> Option<(&Level, &Level, f32)> { + let first = levels.first()?; + let last = levels.last()?; + if brightness <= first.brightness { + return Some((first, first, 0.0)); + } + let i = levels.partition_point(|l| l.brightness < brightness); + let Some(hi) = levels.get(i) else { return Some((last, last, 0.0)) }; + let Some(lo) = i.checked_sub(1).and_then(|j| levels.get(j)) else { return Some((hi, hi, 0.0)) }; + let span = hi.brightness - lo.brightness; + Some((lo, hi, if span > 0.0 { (brightness - lo.brightness) / span } else { 0.0 })) +} + +/// The profile at distance `d`; `None` outside it. +fn profile(knots: &[(f32, [f32; 3])], d: f32) -> Option<[f32; 3]> { + let (first, last) = (knots.first()?, knots.last()?); + if d < first.0 || d > last.0 { + return None; + } + // The first knot at or beyond `d` (binary search: profiles have up to a few hundred knots). + let i = knots.partition_point(|k| k.0 < d); + let Some(hi) = knots.get(i) else { return Some(last.1) }; + let Some(lo) = i.checked_sub(1).and_then(|j| knots.get(j)) else { return Some(hi.1) }; + let span = hi.0 - lo.0; + let f = if span > 0.0 { (d - lo.0) / span } else { 0.0 }; + Some(std::array::from_fn(|k| lo.1[k] + (hi.1[k] - lo.1[k]) * f)) +} + +/// Flare geometry in document pixels, and the output tile it is drawn into. +struct Geometry { + centre: (f32, f32), + /// Axis: image centre minus flare centre. + axis: (f32, f32), + /// Unit normal to the axis. + normal: (f32, f32), + /// Pixels per flare unit. + unit: f32, + /// Output tile: origin and size. + origin: (f32, f32), + size: (usize, usize), +} + +impl Geometry { + fn point(&self, t: f32, perp: f32) -> (f32, f32) { + (self.centre.0 + t * self.axis.0 + perp * self.unit * self.normal.0, self.centre.1 + t * self.axis.1 + perp * self.unit * self.normal.1) + } +} + +/// Adds `weight` times the light of one level into `light` (the output tile, row-major). +fn add_level(level: &Level, weight: f32, g: &Geometry, light: &mut [[f32; 3]]) { + if weight <= 0.0 { + return; + } + let (w, h) = g.size; + let (ox, oy) = g.origin; + // Radial elements, each only over the pixels it can reach. + for e in level.radial { + let Some(reach) = e.knots.last().map(|k| k.0 * g.unit) else { continue }; + let p = g.point(e.t, e.perp); + let x0 = ((p.0 - reach - ox).floor().max(0.0) as usize).min(w); + let x1 = ((p.0 + reach - ox).ceil().max(0.0) as usize).min(w); + let y0 = ((p.1 - reach - oy).floor().max(0.0) as usize).min(h); + let y1 = ((p.1 + reach - oy).ceil().max(0.0) as usize).min(h); + for y in y0..y1 { + let fy = oy + y as f32 + 0.5 - p.1; + for x in x0..x1 { + let fx = ox + x as f32 + 0.5 - p.0; + if let Some(v) = profile(e.knots, fx.hypot(fy) / g.unit) + && let Some(px) = light.get_mut(y * w + x) + { + for (p, v) in px.iter_mut().zip(v) { + *p += weight * v; + } + } + } + } + } + + for s in level.streaks { + let p = g.point(s.t, s.perp); + let (sin, cos) = s.angle.to_radians().sin_cos(); + let half = s.half.max(1e-3); + let taper = s.taper.clamp(0.01, 1.0); + let width = s.width.max(1e-3); + for (i, px) in light.iter_mut().enumerate() { + let fx = ox + (i % w) as f32 + 0.5 - p.0; + let fy = oy + (i / w) as f32 + 0.5 - p.1; + let across = (-fx * sin + fy * cos) / g.unit / width; + // NaN comes from overflowing geometry and must not reach the pixels. + if across.is_nan() || across.abs() > 4.0 { + continue; + } + let along = ((fx * cos + fy * sin) / g.unit).abs(); + let k = weight * (1.0 - smoothstep(half * (1.0 - taper), half, along)) * (-across * across).exp(); + for (p, c) in px.iter_mut().zip(s.rgb) { + *p += k * c; + } + } + } + + // Rays: walk each ray in short steps and light every pixel it crosses once. + for r in level.rays { + let (sin, cos) = r.angle.to_radians().sin_cos(); + let (r0, r1) = (r.r0 * g.unit, r.r1 * g.unit); + let length = (r1 - r0).max(1e-6); + let fade = r.fade.max(1e-3); + // Skip a ray whose segment misses this tile: none of its samples can land inside it. + let (sx, sy) = (g.centre.0 + r0 * cos, g.centre.1 + r0 * sin); + let (ex, ey) = (g.centre.0 + r1 * cos, g.centre.1 + r1 * sin); + if sx.max(ex) < ox - 1.0 || sx.min(ex) > ox + w as f32 + 1.0 || sy.max(ey) < oy - 1.0 || sy.min(ey) > oy + h as f32 + 1.0 { + continue; + } + let steps = ((r1 - r0) / 0.35).ceil().clamp(0.0, 1e6) as usize; + let mut last = None; + for i in 0..steps { + let rad = r0 + i as f32 * 0.35; + let (px, py) = ((g.centre.0 + rad * cos).floor(), (g.centre.1 + rad * sin).floor()); + if last == Some((px, py)) { + continue; + } + last = Some((px, py)); + let (lx, ly) = (px - ox, py - oy); + if lx < 0.0 || ly < 0.0 || lx >= w as f32 || ly >= h as f32 { + continue; + } + let pos = (rad - r0) / length; + let env = weight * ((pos.min(1.0 - pos)) / fade).clamp(0.0, 1.0); + if let Some(v) = light.get_mut(ly as usize * w + lx as usize) { + for (p, c) in v.iter_mut().zip(r.rgb) { + *p += env * c; + } + } + } + } +} + +pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec { + let n = src.ch; + let b = ctx.bounds; + let (bw, bh) = (b.width() as f32, b.height() as f32); + let c = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh); + let mid = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0); + let axis = (mid.0 - c.0, mid.1 - c.1); + let len = axis.0.hypot(axis.1); + // A centre so far off the canvas that the geometry overflows f32 (e.g. 1e300 from a command) + // lights nothing here; without this, NaN would reach every pixel through the streaks. + if ![c.0, c.1, len].iter().all(|v| v.is_finite()) { + return src.crop(out); + } + // At the image centre the axis has no direction; the offsets across it are tiny anyway. + let normal = if len > 1e-6 { (-axis.1 / len, axis.0 / len) } else { (0.0, 1.0) }; + let (w, h) = (out.width() as usize, out.height() as usize); + let g = Geometry { centre: c, axis, normal, unit: (bw / 800.0).max(1e-6), origin: (out.x0 as f32, out.y0 as f32), size: (w, h) }; + + let mut light = vec![[0.0f32; 3]; w * h]; + if let Some((lo, hi, f)) = bracket(levels(lens), brightness.clamp(10.0, 300.0)) { + add_level(lo, 1.0 - f, &g, &mut light); + if !std::ptr::eq(lo, hi) { + add_level(hi, f, &g, &mut light); + } + } + + let mut res = src.crop(out); + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let Some(f) = light.get(i) else { break }; + let mut c = rgba(ctx, px); + for k in 0..3 { + // `clamp` keeps NaN; a non-finite amount adds no light. + let a = if f[k].is_finite() { f[k].clamp(0.0, 1.0) } else { 0.0 }; + // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit value + // already above white is left as is rather than pulled down towards it. + c[k] += a * (1.0 - c[k]).max(0.0); + } + set_rgba(ctx, px, c); + } + res +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brightness_between_levels_blends_the_two_around_it() { + let lv = levels(LensType::Zoom); + let (a, b) = (&lv[0], &lv[1]); + let mid = (a.brightness + b.brightness) / 2.0; + let (lo, hi, f) = bracket(lv, mid).unwrap(); + assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, b) && (f - 0.5).abs() < 1e-6); + // Exactly on a level, and outside the measured range, one level is used as is. + let (_, hi, f) = bracket(lv, b.brightness).unwrap(); + assert!(std::ptr::eq(hi, b) && f == 1.0); + let (lo, hi, f) = bracket(lv, 1.0).unwrap(); + assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, a) && f == 0.0); + let last = lv.last().unwrap(); + let (lo, _, f) = bracket(lv, 1000.0).unwrap(); + assert!(std::ptr::eq(lo, last) && f == 0.0); + } +} diff --git a/crates/algo/src/lens_flare_data.rs b/crates/algo/src/lens_flare_data.rs new file mode 100644 index 0000000000..ce961f422f --- /dev/null +++ b/crates/algo/src/lens_flare_data.rs @@ -0,0 +1,14532 @@ +//! Lens Flare element tables, measured from Photoshop 27's Lens Flare output: one flare per lens +//! and brightness on black (1024 × 768). Distances are in flare units (1/800 of the image width); +//! see `lens_flare.rs`. Generated; regenerate rather than edit by hand. + +// Measured values, not constants: some happen to lie close to π/2, 1/π, log10(e) and the like. +#![allow(clippy::approx_constant)] + +use super::lens_flare::{Level, Radial, Ray, Streak}; + +const ZOOM_10_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [1.516, 1.414, 1.415]), + (0.8, [1.544, 1.444, 1.444]), + (2.4, [1.315, 1.105, 1.109]), + (4.0, [1.043, 0.7296, 0.7354]), + (4.8, [0.9226, 0.574, 0.5814]), + (5.6, [0.8159, 0.4383, 0.4452]), + (6.4, [0.7169, 0.3207, 0.3302]), + (7.2, [0.6416, 0.2356, 0.2432]), + (8.0, [0.5759, 0.1689, 0.1773]), + (8.8, [0.5388, 0.1407, 0.146]), + (9.6, [0.5073, 0.1246, 0.1308]), + (12.8, [0.4109, 0.09, 0.0964]), + (17.6, [0.2858, 0.0465, 0.0507]), + (19.2, [0.2513, 0.0337, 0.037]), + (23.2, [0.164, 0.0077, 0.011]), + (24.0, [0.149, 0.005, 0.0069]), + (26.4, [0.1056, -0.0084, -0.0062]), + (30.4, [0.0504, -0.0158, -0.016]), + (32.0, [0.0319, -0.0187, -0.017]), + (33.6, [0.0151, -0.0231, -0.0211]), + (34.4, [0.0073, -0.0233, -0.0237]), + (36.0, [-0.0083, -0.0231, -0.0243]), + (36.8, [-0.0153, -0.0253, -0.0242]), + (38.4, [-0.0274, -0.03, -0.0287]), + (40.0, [-0.0372, -0.0304, -0.0314]), + (41.6, [-0.051, -0.0303, -0.0313]), + (43.2, [-0.0596, -0.0302, -0.0312]), + (44.8, [-0.0645, -0.0301, -0.0311]), + (47.2, [-0.0664, -0.03, -0.031]), + (98.4, [-0.0529, -0.0264, -0.0272]), + (212.8, [-0.0329, -0.0179, -0.0181]), + (309.6, [-0.0133, -0.0068, -0.005]), + (326.4, [-0.0096, -0.0047, -0.0052]), + (384.8, [0.002, 0.0017, 0.0021]), + (432.0, [0.0089, 0.0052, 0.0059]), + (466.4, [0.0106, 0.005, 0.0057]), + (491.2, [-0.0013, -0.0017, -0.0042]), + (507.2, [-0.0053, -0.0029, -0.0046]), + (520.0, [-0.0042, -0.0007, -0.0025]), + (547.2, [0.0022, 0.0015, 0.0014]), + (570.4, [0.0003, -0.0012, 0.0013]), + (585.6, [0.0001, 0.0005, 0.0054]), + (596.8, [0.0009, 0.0003, 0.0]), + (600.8, [-0.0014, -0.0003, -0.0023]), + (604.8, [0.006, 0.0011, -0.0025]), + (606.4, [0.0071, 0.0005, -0.0028]), + (609.6, [-0.0005, 0.0001, -0.0024]), + (611.2, [-0.0006, 0.0003, -0.0018]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [-0.7096, -0.7016, -0.7011]), + (0.8, [-0.6774, -0.656, -0.6547]), + (1.6, [-0.6326, -0.5852, -0.5845]), + (2.4, [-0.5775, -0.5013, -0.5013]), + (3.2, [-0.5041, -0.4018, -0.4037]), + (4.0, [-0.4459, -0.3219, -0.323]), + (4.8, [-0.3859, -0.2444, -0.2467]), + (5.6, [-0.3358, -0.1822, -0.1842]), + (7.2, [-0.2519, -0.0844, -0.0871]), + (8.0, [-0.2283, -0.0601, -0.0628]), + (8.8, [-0.2087, -0.0458, -0.0469]), + (12.8, [-0.1518, -0.0243, -0.0268]), + (13.6, [-0.1412, -0.022, -0.0235]), + (17.6, [-0.0939, -0.0051, -0.0063]), + (19.2, [-0.0786, 0.0006, 0.0008]), + (23.2, [-0.0379, 0.0136, 0.0121]), + (24.0, [-0.0317, 0.013, 0.0139]), + (26.4, [-0.0116, 0.0204, 0.02]), + (30.4, [0.0133, 0.0234, 0.0245]), + (33.6, [0.029, 0.0271, 0.0268]), + (36.0, [0.0395, 0.0265, 0.0281]), + (38.4, [0.048, 0.0302, 0.0303]), + (40.0, [0.0517, 0.03, 0.0312]), + (42.4, [0.0616, 0.0299, 0.0311]), + (44.8, [0.0656, 0.0298, 0.0309]), + (104.0, [0.0512, 0.0257, 0.0266]), + (212.0, [0.0325, 0.0178, 0.0181]), + (309.6, [0.013, 0.0067, 0.005]), + (325.6, [0.0095, 0.0047, 0.0053]), + (382.4, [-0.0016, -0.0015, -0.0017]), + (429.6, [-0.0086, -0.005, -0.0056]), + (465.6, [-0.0105, -0.0049, -0.0055]), + (489.6, [0.0009, 0.0015, 0.0042]), + (506.4, [0.0053, 0.0029, 0.0047]), + (519.2, [0.0041, 0.0008, 0.0026]), + (546.4, [-0.0022, -0.0014, -0.0013]), + (569.6, [-0.0003, 0.0012, -0.0011]), + (584.8, [-0.0001, -0.0005, -0.0052]), + (596.0, [-0.0009, -0.0003, 0.0002]), + (599.2, [0.0017, 0.0006, 0.0017]), + (604.8, [-0.0072, -0.0008, 0.0027]), + (606.4, [-0.0055, -0.0002, 0.0029]), + (608.8, [0.0006, -0.0001, 0.0024]), + (610.4, [0.0006, -0.0003, 0.0017]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0043, 0.004, 0.004]), + (24.501, [0.0041, 0.004, 0.004]), + (26.082, [0.0084, 0.004, 0.004]), + (28.453, [0.0084, 0.004, 0.0041]), + (29.243, [0.0067, 0.0041, 0.0034]), + (30.034, [0.001, 0.0035, 0.0004]), + (30.824, [0.0001, 0.0006, 0.0]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.0119, 0.004, 0.0]), + (13.538, [0.0121, 0.004, 0.0]), + (14.334, [0.0107, 0.0037, 0.0]), + (15.131, [0.0027, 0.0004, 0.0]), + (15.927, [-0.0002, -0.0001, 0.0]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.0219, 0.0573, 0.0447]), + (0.76482, [0.0137, 0.0367, 0.0262]), + (1.5296, [0.013, 0.0299, 0.0218]), + (2.2945, [0.004, 0.0086, 0.0039]), + (3.0593, [-0.0002, 0.0007, -0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.0394, 0.0916, 0.0717]), + (0.79661, [0.0247, 0.0659, 0.0464]), + (1.5932, [0.0246, 0.0554, 0.0414]), + (2.3898, [0.0101, 0.0347, 0.024]), + (3.1864, [0.0082, 0.0201, 0.0175]), + (3.983, [0.0041, 0.0156, 0.0133]), + (5.5762, [0.0037, 0.0087, 0.0082]), + (6.3728, [-0.0005, 0.0042, 0.0048]), + (7.1694, [0.0001, 0.004, 0.004]), + (7.9661, [0.0, -0.0003, 0.0004]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.0032, 0.0039, 0.035]), + (6.3762, [0.003, 0.004, 0.0331]), + (12.752, [0.0032, 0.0044, 0.0273]), + (28.693, [0.003, 0.005, 0.0026]), + (33.475, [0.0035, 0.0049, -0.0025]), + (39.054, [0.0036, 0.0046, -0.0033]), + (53.401, [0.0039, 0.0044, 0.004]), + (54.198, [0.0041, 0.0005, 0.0003]), + (54.995, [0.0003, 0.0001, 0.0001]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0044, -0.0001, -0.0104]), + (12.734, [0.0045, -0.0003, -0.0077]), + (29.448, [0.0045, -0.0005, 0.0015]), + (31.04, [0.0002, -0.0005, 0.0021]), + (41.387, [0.0, -0.0003, 0.0018]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [0.0047, 0.0004, -0.0219]), + (11.937, [0.0042, 0.0, -0.018]), + (13.529, [0.0045, -0.0001, -0.0166]), + (15.916, [0.0004, -0.0002, -0.0146]), + (29.445, [0.0005, -0.0007, 0.0007]), + (35.016, [0.0003, -0.0005, 0.0049]), + (38.995, [0.0002, -0.0004, 0.0054]), + (47.749, [0.0001, -0.0002, 0.0017]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0001, 0.0078, 0.0382]), + (3.1792, [0.0, 0.0078, 0.035]), + (13.512, [0.0, 0.0078, 0.0109]), + (18.28, [0.0001, 0.0078, 0.0058]), + (21.46, [0.0, 0.008, 0.0048]), + (23.844, [-0.0001, 0.0, 0.0004]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0039, 0.0039, 0.0039]), + (74.31, [0.0037, 0.0039, 0.0037]), + (75.109, [0.0046, 0.0039, 0.0037]), + (75.908, [0.0086, 0.0039, 0.0038]), + (77.507, [0.0085, 0.0038, 0.0044]), + (78.306, [0.0045, 0.0046, 0.0007]), + (79.105, [0.0043, 0.0006, -0.0003]), + (79.904, [0.0008, -0.0002, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0, 0.0, -0.0001]), + (143.85, [0.0, 0.0, -0.0002]), + (145.45, [0.0, 0.0, 0.0042]), + (148.64, [0.0, 0.0, 0.0042]), + (150.24, [0.0, 0.0, -0.0002]), + (151.84, [-0.0003, 0.0, 0.0]), + (153.44, [0.0043, 0.0, 0.0]), + (156.64, [0.0038, 0.0, 0.0]), + (157.43, [0.0003, 0.0, 0.0]), + ], + }, +]; +const ZOOM_10_STREAKS: &[Streak] = &[]; +const ZOOM_10_RAYS: &[Ray] = &[]; +const ZOOM_50_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.764, 0.8405, 0.8238]), + (0.8, [0.7491, 0.8266, 0.8133]), + (1.6, [0.7522, 0.8271, 0.8122]), + (4.8, [0.7053, 0.7462, 0.7327]), + (7.2, [0.6707, 0.6725, 0.6597]), + (11.2, [0.5886, 0.5434, 0.5325]), + (14.4, [0.5151, 0.4343, 0.4226]), + (20.0, [0.3846, 0.2712, 0.255]), + (20.8, [0.3685, 0.2506, 0.24]), + (21.6, [0.3529, 0.229, 0.2159]), + (23.2, [0.3182, 0.1922, 0.186]), + (24.0, [0.3004, 0.1709, 0.1599]), + (24.8, [0.2898, 0.1597, 0.1501]), + (25.6, [0.2697, 0.1402, 0.1343]), + (27.2, [0.243, 0.1156, 0.1018]), + (28.8, [0.217, 0.091, 0.082]), + (30.4, [0.2024, 0.0752, 0.0638]), + (32.0, [0.189, 0.0583, 0.0509]), + (33.6, [0.1772, 0.0519, 0.0433]), + (39.2, [0.1453, 0.0388, 0.0282]), + (40.8, [0.1352, 0.0366, 0.0261]), + (42.4, [0.1216, 0.0317, 0.0185]), + (45.6, [0.104, 0.0259, 0.011]), + (48.8, [0.0868, 0.0199, 0.0063]), + (50.4, [0.0777, 0.0136, 0.004]), + (52.0, [0.0732, 0.0118, 0.0024]), + (53.6, [0.0646, 0.0101, -0.002]), + (55.2, [0.0608, 0.0054, -0.0031]), + (56.8, [0.0583, 0.0042, -0.004]), + (59.2, [0.05, 0.0024, -0.0113]), + (60.8, [0.0448, -0.0018, -0.0112]), + (62.4, [0.038, -0.0019, -0.0118]), + (63.2, [0.0397, -0.0026, -0.0123]), + (64.8, [0.0522, 0.0, -0.0106]), + (66.4, [0.0692, -0.0007, -0.0092]), + (67.2, [0.0727, -0.0012, -0.0086]), + (68.0, [0.0694, -0.0014, -0.0085]), + (68.8, [0.0574, -0.0031, -0.0105]), + (69.6, [0.0503, -0.0082, -0.0109]), + (72.0, [0.0129, -0.0124, -0.0101]), + (74.4, [0.007, -0.0139, -0.0097]), + (75.2, [0.0045, -0.0166, -0.0092]), + (76.8, [0.0007, -0.0171, -0.0123]), + (78.4, [-0.0072, -0.0176, -0.0153]), + (79.2, [-0.0069, -0.0178, -0.015]), + (82.4, [-0.0147, -0.0178, -0.0126]), + (85.6, [-0.0177, -0.0174, -0.0091]), + (87.2, [-0.0217, -0.0176, -0.0081]), + (92.0, [-0.0206, -0.0188, -0.0077]), + (93.6, [-0.0202, -0.0225, -0.0081]), + (99.2, [-0.0191, -0.0235, -0.0079]), + (100.8, [-0.0224, -0.0236, -0.0114]), + (108.8, [-0.0222, -0.0244, -0.0131]), + (120.0, [-0.0251, -0.0268, -0.0217]), + (124.0, [-0.0259, -0.027, -0.0231]), + (124.8, [-0.0261, -0.0303, -0.0233]), + (128.8, [-0.0264, -0.0296, -0.0227]), + (129.6, [-0.03, -0.0294, -0.026]), + (132.8, [-0.0303, -0.0285, -0.0245]), + (138.4, [-0.0305, -0.0275, -0.0228]), + (157.6, [-0.0293, -0.029, -0.0285]), + (198.4, [-0.0275, -0.0276, -0.0239]), + (242.4, [-0.0242, -0.023, -0.0209]), + (289.6, [-0.0179, -0.0157, -0.013]), + (308.8, [-0.0138, -0.012, -0.0059]), + (325.6, [-0.0111, -0.0095, -0.0077]), + (343.2, [-0.0081, -0.0067, -0.0029]), + (399.2, [0.0021, 0.0014, 0.0047]), + (432.8, [0.0051, 0.0047, 0.0078]), + (464.8, [0.0054, 0.0062, 0.0092]), + (472.8, [0.0052, 0.0053, 0.0065]), + (490.4, [-0.0031, -0.0019, -0.0023]), + (507.2, [-0.0098, -0.007, -0.0039]), + (522.4, [-0.0109, -0.0078, -0.0055]), + (552.8, [-0.0004, 0.0016, -0.002]), + (568.8, [0.0046, 0.0037, -0.0036]), + (573.6, [0.0073, 0.0054, -0.0014]), + (582.4, [0.0135, 0.01, 0.0083]), + (587.2, [0.0171, 0.0118, 0.0108]), + (593.6, [0.0163, 0.0093, 0.0067]), + (596.8, [0.0178, 0.0073, 0.009]), + (599.2, [0.0208, 0.0112, 0.0088]), + (600.8, [0.0239, 0.0166, 0.0069]), + (603.2, [0.0299, 0.0236, -0.0002]), + (605.6, [0.0333, 0.0213, -0.0074]), + (607.2, [0.0259, 0.0141, -0.0111]), + (611.2, [-0.0043, -0.0042, -0.0105]), + (612.8, [-0.0091, -0.0059, -0.0088]), + (614.4, [-0.0111, -0.0065, -0.0073]), + (617.6, [-0.0114, -0.0064, -0.005]), + (624.0, [-0.005, -0.0025, -0.0015]), + (635.2, [0.0011, 0.0, -0.0018]), + (640.0, [-0.0014, -0.0014, -0.0007]), + (644.0, [0.0006, 0.0007, 0.0033]), + (650.4, [0.0006, 0.001, 0.0037]), + (653.6, [-0.0005, 0.0003, 0.0018]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.2288, 0.1657, 0.1816]), + (1.6, [0.2265, 0.1649, 0.1839]), + (8.0, [0.205, 0.1203, 0.1415]), + (14.4, [0.1714, 0.0728, 0.0934]), + (20.0, [0.139, 0.0374, 0.0557]), + (23.2, [0.1236, 0.02, 0.0406]), + (24.0, [0.1187, 0.0153, 0.0339]), + (24.8, [0.1175, 0.0138, 0.0322]), + (25.6, [0.1125, 0.0095, 0.0293]), + (27.2, [0.1051, 0.0066, 0.023]), + (28.8, [0.0952, 0.0036, 0.0211]), + (29.6, [0.093, 0.0048, 0.0207]), + (32.0, [0.0847, 0.0015, 0.0189]), + (38.4, [0.0706, 0.0063, 0.0254]), + (44.0, [0.0645, 0.0125, 0.0302]), + (54.4, [0.0574, 0.0191, 0.0356]), + (59.2, [0.0436, 0.0224, 0.0364]), + (62.4, [0.0426, 0.0228, 0.0347]), + (64.0, [0.0479, 0.0239, 0.0329]), + (67.2, [0.0513, 0.0257, 0.03]), + (72.0, [0.0315, 0.0247, 0.0269]), + (80.8, [0.0308, 0.0262, 0.0232]), + (88.8, [0.0294, 0.0257, 0.0159]), + (100.8, [0.0258, 0.0273, 0.0153]), + (108.8, [0.0257, 0.0279, 0.017]), + (118.4, [0.0282, 0.0301, 0.0249]), + (126.4, [0.0299, 0.0296, 0.0276]), + (137.6, [0.0301, 0.027, 0.0228]), + (157.6, [0.0288, 0.0285, 0.0286]), + (200.8, [0.0269, 0.0269, 0.0238]), + (243.2, [0.0236, 0.0224, 0.021]), + (276.0, [0.0195, 0.0174, 0.0158]), + (293.6, [0.017, 0.0149, 0.0122]), + (308.8, [0.0135, 0.0117, 0.0062]), + (325.6, [0.0109, 0.0092, 0.008]), + (343.2, [0.0079, 0.0065, 0.0033]), + (399.2, [-0.0022, -0.0016, -0.0043]), + (432.8, [-0.0051, -0.0047, -0.0073]), + (464.0, [-0.0054, -0.0061, -0.0086]), + (472.0, [-0.0052, -0.0054, -0.006]), + (489.6, [0.0031, 0.0018, 0.0027]), + (506.4, [0.0097, 0.0069, 0.0043]), + (521.6, [0.0108, 0.0078, 0.0059]), + (552.0, [0.0004, -0.0015, 0.0024]), + (567.2, [-0.0044, -0.0035, 0.0043]), + (572.8, [-0.0072, -0.0053, 0.0019]), + (581.6, [-0.0134, -0.0099, -0.0078]), + (586.4, [-0.017, -0.0118, -0.0104]), + (592.8, [-0.0163, -0.0092, -0.0065]), + (596.0, [-0.0179, -0.0073, -0.0088]), + (598.4, [-0.0212, -0.0114, -0.0089]), + (600.0, [-0.0244, -0.0168, -0.0072]), + (602.4, [-0.0303, -0.0242, 0.0002]), + (604.8, [-0.0336, -0.0214, 0.0076]), + (605.6, [-0.0308, -0.0178, 0.0096]), + (607.2, [-0.0186, -0.0095, 0.0118]), + (610.4, [0.0045, 0.0043, 0.0103]), + (612.0, [0.0093, 0.0059, 0.0087]), + (613.6, [0.0111, 0.0065, 0.0072]), + (616.8, [0.0113, 0.0064, 0.0049]), + (623.2, [0.0049, 0.0024, 0.0016]), + (634.4, [-0.0011, -0.0001, 0.0018]), + (639.2, [0.0014, 0.0014, 0.0006]), + (643.2, [-0.0006, -0.0008, -0.0033]), + (649.6, [-0.0006, -0.001, -0.0038]), + (652.8, [0.0005, -0.0002, -0.0017]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0215, 0.0055, 0.0001]), + (0.79967, [0.0189, 0.0031, -0.0017]), + (2.399, [0.0218, 0.0059, 0.0011]), + (7.9967, [0.0202, 0.004, -0.0005]), + (117.55, [0.0202, 0.0045, -0.0001]), + (118.35, [0.0166, 0.0045, 0.0]), + (119.15, [0.0162, 0.0045, -0.0001]), + (271.89, [0.0165, 0.0043, -0.0001]), + (273.49, [0.0119, 0.0043, -0.0002]), + (274.29, [0.0118, 0.0045, -0.0002]), + (275.08, [0.0078, 0.0037, -0.0002]), + (275.88, [0.0085, -0.0001, -0.0002]), + (276.68, [0.0042, 0.0005, -0.0002]), + (277.48, [0.0044, 0.0004, -0.0002]), + (278.28, [0.0003, 0.0004, -0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0001, -0.0019, 0.0023]), + (0.79036, [0.0041, 0.0046, 0.004]), + (1.5807, [0.0031, 0.0044, 0.0053]), + (2.3711, [0.0044, 0.0039, 0.0047]), + (3.9518, [0.0028, 0.0037, 0.0043]), + (4.7422, [0.0041, 0.0029, 0.0045]), + (7.1133, [0.0025, 0.0026, 0.0034]), + (11.065, [0.0032, 0.003, 0.0033]), + (15.017, [0.0033, 0.0101, 0.0039]), + (17.388, [0.0031, 0.0138, 0.0078]), + (18.969, [0.0071, 0.0173, 0.009]), + (22.13, [0.0076, 0.0241, 0.0142]), + (22.921, [0.0105, 0.0264, 0.0158]), + (26.872, [0.0454, 0.0401, 0.0202]), + (27.663, [0.0475, 0.0426, 0.0226]), + (29.243, [0.037, 0.0458, 0.0242]), + (30.034, [0.0277, 0.035, 0.0202]), + (30.824, [0.0126, 0.0113, 0.007]), + (31.615, [0.0027, 0.0008, 0.0008]), + (32.405, [0.0003, 0.0002, 0.0006]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [-0.0012, 0.018, 0.0484]), + (0.79858, [0.0024, 0.022, 0.0555]), + (2.3957, [0.0034, 0.0229, 0.0578]), + (16.77, [0.0021, 0.0216, 0.0539]), + (22.36, [0.0018, 0.0126, 0.0352]), + (28.749, [0.0005, 0.0042, 0.0172]), + (43.123, [0.0, 0.0038, 0.016]), + (46.318, [0.0, 0.0016, 0.0074]), + (50.311, [-0.0001, 0.0001, 0.0011]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.0784, 0.0331, -0.0088]), + (1.5927, [0.0783, 0.0333, -0.0087]), + (3.9818, [0.0783, 0.0344, -0.0042]), + (7.9635, [0.078, 0.0351, -0.0017]), + (11.149, [0.0864, 0.0351, -0.0013]), + (13.538, [0.0843, 0.0359, -0.0011]), + (14.334, [0.0731, 0.0309, -0.0012]), + (15.131, [0.0234, 0.0096, -0.0011]), + (15.927, [-0.0005, -0.0001, -0.0012]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.1203, 0.3013, 0.2305]), + (0.76482, [0.0735, 0.1859, 0.1399]), + (1.5296, [0.0635, 0.1554, 0.1184]), + (2.2945, [0.0211, 0.0513, 0.0384]), + (3.0593, [0.0008, 0.0025, 0.0019]), + (3.8241, [0.0, -0.0007, 0.0]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.1746, 0.4112, 0.3272]), + (0.79661, [0.126, 0.3027, 0.2427]), + (1.5932, [0.1117, 0.266, 0.2161]), + (2.3898, [0.0648, 0.1607, 0.1396]), + (3.1864, [0.0436, 0.1066, 0.0988]), + (3.983, [0.0327, 0.0824, 0.0743]), + (4.7796, [0.0261, 0.068, 0.0636]), + (5.5762, [0.02, 0.0485, 0.0466]), + (7.1694, [0.0084, 0.0228, 0.0229]), + (7.9661, [0.0035, 0.0137, 0.0137]), + (8.7627, [0.0008, 0.0061, 0.0052]), + (9.5593, [0.0002, 0.0002, 0.0029]), + (12.746, [0.0002, 0.0002, 0.0019]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.0335, 0.0136, 0.1475]), + (6.3762, [0.0331, 0.0145, 0.1391]), + (9.5643, [0.0318, 0.0146, 0.1287]), + (11.158, [0.0351, 0.0177, 0.1221]), + (12.752, [0.0305, 0.0158, 0.1145]), + (19.129, [0.0216, 0.0135, 0.0756]), + (22.317, [0.0248, 0.014, 0.0507]), + (26.302, [0.0242, 0.0135, 0.0202]), + (27.099, [0.0278, 0.0134, 0.0149]), + (28.693, [0.0289, 0.0151, 0.0047]), + (30.287, [0.0293, 0.0169, -0.0041]), + (31.881, [0.034, 0.0165, -0.0115]), + (34.272, [0.0347, 0.0159, -0.0192]), + (35.069, [0.0383, 0.0165, -0.0209]), + (35.866, [0.0387, 0.0194, -0.022]), + (39.054, [0.0421, 0.0186, -0.0217]), + (39.851, [0.0419, 0.0183, -0.0207]), + (40.648, [0.0423, 0.0209, -0.0195]), + (41.445, [0.0459, 0.0226, -0.0179]), + (43.039, [0.0456, 0.0225, -0.0143]), + (44.633, [0.0499, 0.0223, -0.0105]), + (46.227, [0.0494, 0.0267, -0.0068]), + (47.025, [0.0528, 0.0263, -0.0051]), + (48.619, [0.053, 0.0265, -0.0017]), + (49.416, [0.0567, 0.0268, -0.0002]), + (50.213, [0.0568, 0.0306, 0.0011]), + (53.401, [0.0626, 0.0314, 0.0051]), + (54.198, [0.0567, 0.0309, 0.0018]), + (54.995, [0.0161, 0.0083, 0.0018]), + (55.792, [-0.0014, -0.0006, 0.0021]), + (56.589, [0.0003, 0.0006, 0.0022]), + (60.574, [0.0003, 0.0001, 0.0019]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0428, 0.0237, -0.0481]), + (1.5918, [0.0417, 0.0231, -0.0475]), + (3.1836, [0.0452, 0.0255, -0.0466]), + (4.7754, [0.0417, 0.0232, -0.0456]), + (7.1631, [0.042, 0.0229, -0.0435]), + (13.53, [0.0429, 0.0224, -0.0348]), + (19.897, [0.0454, 0.0229, -0.0196]), + (25.469, [0.0443, 0.0232, -0.0036]), + (29.448, [0.044, 0.0231, 0.0049]), + (30.244, [0.0319, 0.0175, 0.0062]), + (31.04, [0.0073, 0.0031, 0.0071]), + (31.836, [0.0, -0.0008, 0.008]), + (37.407, [-0.0004, -0.0001, 0.0098]), + (46.162, [0.0002, 0.0, 0.0022]), + (47.754, [-0.0024, -0.0017, 0.001]), + (48.55, [-0.0011, -0.0007, 0.0007]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [0.0291, 0.0217, -0.1021]), + (7.1624, [0.0298, 0.0215, -0.0963]), + (13.529, [0.0328, 0.0211, -0.0783]), + (14.325, [0.0283, 0.0181, -0.076]), + (15.916, [-0.0016, -0.0014, -0.0699]), + (16.712, [-0.003, -0.0021, -0.0668]), + (22.283, [0.0011, -0.0013, -0.0396]), + (29.445, [0.0013, -0.0011, -0.0016]), + (31.833, [0.0009, -0.0006, 0.0077]), + (35.016, [0.0007, 0.0, 0.0159]), + (37.403, [0.0009, 0.0008, 0.0187]), + (39.791, [0.0012, 0.0013, 0.0183]), + (49.341, [0.0017, 0.0011, 0.0032]), + (50.932, [0.0044, 0.0023, 0.0012]), + (53.32, [0.0024, 0.0008, -0.0014]), + (58.891, [-0.0015, -0.001, -0.0023]), + (62.074, [0.0, 0.0001, -0.0019]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0186, 0.0742, 0.1998]), + (0.7948, [0.0177, 0.0694, 0.1966]), + (1.5896, [0.0154, 0.0689, 0.1959]), + (3.1792, [0.0158, 0.0679, 0.1882]), + (7.948, [0.0152, 0.0604, 0.1315]), + (13.512, [0.0153, 0.0553, 0.0771]), + (15.896, [0.0173, 0.055, 0.0621]), + (16.691, [0.0154, 0.0539, 0.0568]), + (21.46, [0.0146, 0.0531, 0.0419]), + (22.254, [0.0105, 0.038, 0.0298]), + (23.049, [0.0028, 0.0112, 0.0111]), + (23.844, [0.0002, 0.0012, 0.0031]), + (24.639, [-0.0005, -0.0002, 0.0018]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0038, 0.0038, 0.0039]), + (32.76, [0.0038, 0.0034, 0.0039]), + (33.56, [0.0038, 0.0079, 0.0038]), + (39.952, [0.0038, 0.0075, 0.0038]), + (41.55, [0.0038, 0.0123, 0.0038]), + (46.344, [0.0038, 0.011, 0.0038]), + (47.942, [0.004, 0.0161, 0.0039]), + (48.741, [0.0034, 0.0155, 0.004]), + (49.54, [0.0078, 0.0157, 0.004]), + (51.937, [0.0077, 0.015, 0.0041]), + (53.535, [0.0078, 0.02, 0.0042]), + (56.732, [0.0078, 0.0193, 0.0041]), + (58.33, [0.0077, 0.0242, 0.0041]), + (59.928, [0.0072, 0.0235, 0.0041]), + (60.727, [0.0117, 0.0236, 0.0041]), + (61.526, [0.0117, 0.023, 0.0041]), + (63.124, [0.0117, 0.028, 0.0041]), + (66.32, [0.0117, 0.0273, 0.0038]), + (67.119, [0.0117, 0.0318, 0.0037]), + (69.516, [0.0118, 0.0314, 0.0034]), + (70.315, [0.0161, 0.0309, 0.0034]), + (71.114, [0.0155, 0.0353, 0.0033]), + (72.712, [0.0151, 0.0352, 0.0032]), + (73.511, [0.0192, 0.035, 0.0032]), + (74.31, [0.0283, 0.0358, 0.0032]), + (75.908, [0.0416, 0.0434, 0.0033]), + (76.707, [0.0501, 0.0429, 0.0032]), + (77.507, [0.054, 0.0431, 0.004]), + (78.306, [0.0511, 0.043, 0.0004]), + (79.904, [0.0171, -0.0004, -0.0002]), + (80.703, [0.0122, 0.0001, -0.0002]), + (81.502, [0.0029, 0.0, -0.0001]), + (82.301, [-0.0006, 0.0, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0004, -0.0007, -0.0002]), + (142.25, [-0.0001, 0.0001, -0.0004]), + (143.05, [0.0008, 0.0001, 0.0035]), + (146.25, [0.0143, -0.0001, 0.0308]), + (147.05, [0.0166, 0.0038, 0.0356]), + (147.85, [0.0196, 0.0094, 0.0352]), + (150.24, [0.0195, 0.0282, 0.0254]), + (151.04, [0.0222, 0.0316, 0.0199]), + (151.84, [0.0255, 0.0315, 0.0137]), + (154.24, [0.0355, 0.023, 0.0042]), + (155.04, [0.0347, 0.0184, 0.0006]), + (155.84, [0.0289, 0.0131, 0.0]), + (159.03, [0.0022, 0.0002, 0.0]), + (159.83, [-0.0002, 0.0, 0.0]), + ], + }, +]; +const ZOOM_50_STREAKS: &[Streak] = &[]; +const ZOOM_50_RAYS: &[Ray] = &[ + Ray { angle: 0.19, r0: 6.25, r1: 235.94, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 7.25, r0: 6.25, r1: 129.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 16.4, r0: 20.312, r1: 298.44, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 43.18, r0: 7.0312, r1: 288.28, fade: 0.05, rgb: [0.0176, 0.0157, 0.0157] }, + Ray { angle: 62.98, r0: 6.25, r1: 198.44, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 64.75, r0: 70.312, r1: 266.41, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 70.16, r0: 14.062, r1: 144.53, fade: 0.05, rgb: [0.0167, 0.0196, 0.0196] }, + Ray { angle: 71.94, r0: 77.344, r1: 210.94, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.1, r0: 57.031, r1: 259.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 81.01, r0: 28.125, r1: 279.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 86.35, r0: 6.25, r1: 158.59, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 91.69, r0: 6.25, r1: 128.91, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 93.37, r0: 65.625, r1: 303.91, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 104.33, r0: 28.906, r1: 262.5, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 109.69, r0: 29.688, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.83, r0: 71.875, r1: 172.66, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 118.75, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 129.16, r0: 65.625, r1: 159.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 131.19, r0: 28.906, r1: 222.66, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 138.3, r0: 56.25, r1: 175.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 145.39, r0: 69.531, r1: 207.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 147.3, r0: 73.438, r1: 221.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 148.82, r0: 69.531, r1: 192.19, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 152.63, r0: 62.5, r1: 181.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 155.88, r0: 19.531, r1: 256.25, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 161.64, r0: 14.062, r1: 235.16, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 163.13, r0: 38.281, r1: 244.53, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 168.91, r0: 35.938, r1: 238.28, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 172.13, r0: 35.156, r1: 143.75, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 180.0, r0: 25.0, r1: 88.281, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 186.52, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 193.85, r0: 28.906, r1: 200.0, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 208.12, r0: 60.938, r1: 253.12, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 213.33, r0: 29.688, r1: 278.12, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 217.21, r0: 38.281, r1: 246.88, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 220.48, r0: 52.344, r1: 221.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 224.42, r0: 93.75, r1: 177.34, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 225.87, r0: 70.312, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0098, 0.0118] }, + Ray { angle: 229.2, r0: 64.844, r1: 160.16, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 234.41, r0: 14.062, r1: 167.97, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 240.32, r0: 37.5, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 241.65, r0: 84.375, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 243.7, r0: 62.5, r1: 178.91, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 245.58, r0: 63.281, r1: 207.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 250.9, r0: 44.531, r1: 206.25, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 252.81, r0: 67.188, r1: 148.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 254.57, r0: 64.062, r1: 153.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 259.88, r0: 6.25, r1: 186.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 267.07, r0: 62.5, r1: 137.5, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 268.95, r0: 51.562, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 283.24, r0: 67.188, r1: 175.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 284.76, r0: 74.219, r1: 200.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 288.62, r0: 46.094, r1: 204.69, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 291.97, r0: 24.219, r1: 99.219, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 295.68, r0: 27.344, r1: 216.41, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 299.14, r0: 64.844, r1: 223.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 302.89, r0: 6.25, r1: 232.03, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 317.07, r0: 42.188, r1: 129.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 319.1, r0: 75.781, r1: 197.66, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 320.58, r0: 39.844, r1: 241.41, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 322.59, r0: 6.25, r1: 287.5, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 340.62, r0: 6.25, r1: 230.47, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 345.72, r0: 25.781, r1: 192.19, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 352.82, r0: 6.25, r1: 120.31, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, +]; +const ZOOM_75_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.6341, 0.8079, 0.8109]), + (2.4, [0.6422, 0.7918, 0.7924]), + (4.0, [0.6328, 0.776, 0.7774]), + (9.6, [0.5947, 0.6729, 0.6755]), + (17.6, [0.4894, 0.4854, 0.4893]), + (19.2, [0.4614, 0.4415, 0.4464]), + (24.0, [0.3872, 0.3338, 0.3344]), + (25.6, [0.3658, 0.2968, 0.3052]), + (26.4, [0.3531, 0.2849, 0.2871]), + (27.2, [0.3388, 0.2605, 0.2707]), + (28.0, [0.331, 0.2534, 0.2583]), + (30.4, [0.306, 0.2117, 0.2137]), + (32.0, [0.2911, 0.1892, 0.1959]), + (36.0, [0.2638, 0.1552, 0.1488]), + (37.6, [0.2561, 0.1462, 0.133]), + (40.0, [0.2352, 0.1234, 0.1087]), + (41.6, [0.2221, 0.1167, 0.0974]), + (44.0, [0.2109, 0.0977, 0.0794]), + (45.6, [0.1968, 0.0873, 0.0705]), + (48.8, [0.1823, 0.0699, 0.0514]), + (53.6, [0.1564, 0.0553, 0.0407]), + (56.0, [0.1425, 0.0524, 0.0319]), + (59.2, [0.1297, 0.0469, 0.0286]), + (60.8, [0.1264, 0.0409, 0.0286]), + (61.6, [0.1239, 0.0408, 0.0289]), + (62.4, [0.124, 0.0405, 0.026]), + (63.2, [0.1323, 0.0411, 0.025]), + (64.8, [0.1558, 0.0451, 0.0282]), + (66.4, [0.1897, 0.0512, 0.0327]), + (67.2, [0.1961, 0.0516, 0.031]), + (68.0, [0.1957, 0.049, 0.0301]), + (68.8, [0.1709, 0.0442, 0.0303]), + (69.6, [0.1535, 0.0398, 0.0261]), + (70.4, [0.1275, 0.0351, 0.0239]), + (72.0, [0.0918, 0.0273, 0.0209]), + (74.4, [0.0805, 0.0251, 0.0185]), + (76.0, [0.0738, 0.0181, 0.0174]), + (78.4, [0.0639, 0.0161, 0.0116]), + (83.2, [0.0461, 0.0117, 0.0157]), + (84.0, [0.0418, 0.0091, 0.0125]), + (84.8, [0.0366, 0.0082, 0.0124]), + (88.0, [0.0278, 0.0073, 0.0159]), + (89.6, [0.024, 0.0026, 0.0169]), + (91.2, [0.0208, 0.0022, 0.0138]), + (92.8, [0.0156, -0.0026, 0.0099]), + (95.2, [0.016, -0.0037, 0.0104]), + (97.6, [0.0196, -0.004, 0.0116]), + (99.2, [0.0195, -0.0076, 0.013]), + (100.8, [0.0195, -0.0076, 0.0063]), + (101.6, [0.0224, -0.0106, 0.0072]), + (106.4, [0.0252, -0.0111, 0.007]), + (107.2, [0.0192, -0.0114, 0.0065]), + (108.0, [0.0184, -0.0116, 0.0064]), + (108.8, [0.0189, -0.0119, 0.0029]), + (109.6, [0.0154, -0.0118, 0.0013]), + (111.2, [0.0145, -0.0163, -0.0011]), + (112.8, [0.014, -0.0169, -0.0034]), + (113.6, [0.0103, -0.0173, -0.0045]), + (114.4, [0.0103, -0.0176, -0.0056]), + (116.0, [0.0058, -0.0182, -0.0077]), + (116.8, [0.0059, -0.0186, -0.0081]), + (118.4, [0.0052, -0.0191, -0.0135]), + (119.2, [0.0049, -0.0188, -0.0138]), + (120.8, [0.0042, -0.0229, -0.0153]), + (123.2, [0.0038, -0.0228, -0.0167]), + (124.0, [0.0004, -0.0229, -0.0171]), + (126.4, [-0.0008, -0.0228, -0.0173]), + (127.2, [-0.0011, -0.0228, -0.0206]), + (129.6, [-0.0017, -0.0215, -0.0203]), + (131.2, [-0.0022, -0.0246, -0.0193]), + (132.0, [-0.0019, -0.0241, -0.0188]), + (133.6, [-0.0061, -0.0233, -0.0176]), + (136.0, [-0.006, -0.0222, -0.0153]), + (137.6, [-0.0061, -0.0218, -0.0185]), + (140.8, [-0.0064, -0.022, -0.0193]), + (141.6, [-0.0056, -0.0255, -0.0195]), + (143.2, [-0.0094, -0.0254, -0.0201]), + (146.4, [-0.009, -0.0257, -0.0208]), + (148.0, [-0.009, -0.0259, -0.0254]), + (152.0, [-0.0085, -0.0265, -0.0275]), + (152.8, [-0.0113, -0.027, -0.0281]), + (153.6, [-0.0125, -0.0304, -0.0289]), + (157.6, [-0.0122, -0.0305, -0.0302]), + (159.2, [-0.0121, -0.0305, -0.0339]), + (163.2, [-0.0117, -0.0301, -0.0326]), + (164.0, [-0.0152, -0.0303, -0.0324]), + (165.6, [-0.0149, -0.0308, -0.0321]), + (166.4, [-0.0148, -0.0335, -0.0319]), + (170.4, [-0.0146, -0.0328, -0.0306]), + (171.2, [-0.0146, -0.0327, -0.0339]), + (175.2, [-0.0143, -0.0323, -0.0329]), + (176.0, [-0.0176, -0.0321, -0.0327]), + (179.2, [-0.0171, -0.0314, -0.032]), + (180.8, [-0.017, -0.0353, -0.0316]), + (184.0, [-0.0169, -0.0344, -0.0304]), + (184.8, [-0.0169, -0.0343, -0.0334]), + (188.0, [-0.0162, -0.034, -0.0328]), + (189.6, [-0.0202, -0.0337, -0.0324]), + (195.2, [-0.0198, -0.0323, -0.031]), + (196.8, [-0.0198, -0.0361, -0.0306]), + (199.2, [-0.0198, -0.0354, -0.0295]), + (200.8, [-0.0198, -0.0352, -0.0333]), + (203.2, [-0.0197, -0.0348, -0.0322]), + (204.0, [-0.0233, -0.0346, -0.0321]), + (214.4, [-0.0223, -0.0324, -0.03]), + (216.0, [-0.0223, -0.0363, -0.0297]), + (217.6, [-0.0222, -0.0355, -0.0289]), + (219.2, [-0.0219, -0.0352, -0.0327]), + (220.8, [-0.0214, -0.0349, -0.032]), + (221.6, [-0.0248, -0.0347, -0.0318]), + (239.2, [-0.0221, -0.0303, -0.0286]), + (240.0, [-0.022, -0.0334, -0.0284]), + (241.6, [-0.0218, -0.0335, -0.0275]), + (243.2, [-0.0215, -0.0332, -0.0312]), + (244.0, [-0.0213, -0.0331, -0.0305]), + (244.8, [-0.0249, -0.0328, -0.0304]), + (289.6, [-0.0165, -0.0207, -0.0157]), + (309.6, [-0.0118, -0.0141, -0.0049]), + (324.8, [-0.0088, -0.0105, -0.0058]), + (343.2, [-0.0053, -0.006, 0.0005]), + (399.2, [0.0033, 0.0039, 0.0088]), + (432.8, [0.0037, 0.007, 0.0107]), + (467.2, [0.0009, 0.0087, 0.0104]), + (472.8, [0.0006, 0.0072, 0.0077]), + (491.2, [-0.0085, -0.0055, -0.0041]), + (503.2, [-0.0135, -0.0112, -0.0064]), + (512.8, [-0.013, -0.0106, -0.0055]), + (543.2, [-0.0076, -0.0019, -0.0047]), + (556.0, [-0.0016, -0.0003, -0.0042]), + (566.4, [0.0065, 0.0027, -0.0043]), + (571.2, [0.0115, 0.0052, -0.0023]), + (584.8, [0.0285, 0.0179, 0.013]), + (588.0, [0.0299, 0.0192, 0.0131]), + (593.6, [0.0285, 0.0172, 0.0083]), + (596.8, [0.0315, 0.0172, 0.012]), + (598.4, [0.0348, 0.0197, 0.0138]), + (599.2, [0.0369, 0.0241, 0.0134]), + (600.8, [0.0396, 0.0332, 0.0075]), + (603.2, [0.043, 0.037, -0.005]), + (604.0, [0.0417, 0.0355, -0.009]), + (605.6, [0.0395, 0.0276, -0.0148]), + (607.2, [0.0323, 0.0188, -0.0173]), + (610.4, [0.004, -0.0009, -0.015]), + (612.0, [-0.0083, -0.0072, -0.014]), + (613.6, [-0.0138, -0.0104, -0.013]), + (615.2, [-0.0159, -0.0112, -0.0112]), + (617.6, [-0.0156, -0.0095, -0.0076]), + (620.8, [-0.0116, -0.0052, -0.0039]), + (624.0, [-0.0083, -0.0034, -0.0019]), + (628.8, [-0.0049, -0.005, -0.0043]), + (632.0, [0.0007, 0.0009, -0.0027]), + (633.6, [0.002, 0.0015, -0.0029]), + (636.0, [0.0021, 0.0008, -0.0026]), + (640.8, [-0.002, -0.0024, -0.0001]), + (645.6, [-0.0005, 0.0003, 0.005]), + (648.0, [-0.0017, -0.0013, 0.0043]), + (652.0, [-0.0005, 0.001, 0.0044]), + (655.2, [0.0, 0.0004, 0.0007]), + (658.4, [0.0006, 0.001, -0.0002]), + (660.8, [-0.0006, -0.0011, -0.0028]), + (666.4, [-0.0002, -0.0005, -0.0018]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.3342, 0.1947, 0.2028]), + (0.8, [0.3282, 0.1888, 0.1988]), + (1.6, [0.3285, 0.1951, 0.2053]), + (3.2, [0.3243, 0.1912, 0.2029]), + (8.8, [0.3139, 0.171, 0.1825]), + (21.6, [0.2699, 0.1024, 0.1142]), + (24.0, [0.2564, 0.0924, 0.1015]), + (27.2, [0.2402, 0.0788, 0.09]), + (28.0, [0.2368, 0.0791, 0.0881]), + (30.4, [0.2206, 0.0719, 0.0794]), + (32.8, [0.2021, 0.0633, 0.0743]), + (36.0, [0.1764, 0.0467, 0.0661]), + (39.2, [0.1593, 0.0315, 0.061]), + (44.8, [0.1308, 0.0301, 0.0575]), + (48.8, [0.1168, 0.0319, 0.059]), + (58.4, [0.1091, 0.0426, 0.0673]), + (60.8, [0.1016, 0.0436, 0.0689]), + (62.4, [0.0972, 0.0441, 0.0658]), + (64.0, [0.1005, 0.0458, 0.0633]), + (66.4, [0.1161, 0.0486, 0.0603]), + (67.2, [0.116, 0.0492, 0.0591]), + (69.6, [0.0995, 0.0464, 0.0569]), + (72.0, [0.0764, 0.0446, 0.0567]), + (76.0, [0.0767, 0.0459, 0.0585]), + (80.8, [0.0768, 0.0486, 0.0563]), + (87.2, [0.0742, 0.048, 0.0453]), + (94.4, [0.0667, 0.0505, 0.0418]), + (104.0, [0.0358, 0.05, 0.0367]), + (107.2, [0.0324, 0.0503, 0.0371]), + (109.6, [0.0319, 0.051, 0.0378]), + (116.8, [0.0333, 0.0534, 0.0479]), + (124.8, [0.0354, 0.0539, 0.0531]), + (130.4, [0.037, 0.0523, 0.052]), + (137.6, [0.0373, 0.0488, 0.0461]), + (148.0, [0.0362, 0.0488, 0.0484]), + (157.6, [0.0354, 0.0496, 0.0546]), + (200.8, [0.0311, 0.0424, 0.0406]), + (241.6, [0.0252, 0.033, 0.0326]), + (290.4, [0.016, 0.0199, 0.0157]), + (309.6, [0.0115, 0.0136, 0.0054]), + (324.8, [0.0085, 0.0101, 0.0064]), + (344.0, [0.0049, 0.0055, -0.0001]), + (380.8, [-0.0015, -0.0014, -0.0057]), + (408.0, [-0.0038, -0.005, -0.0085]), + (466.4, [-0.001, -0.0087, -0.0097]), + (472.8, [-0.0004, -0.0068, -0.0064]), + (490.4, [0.0083, 0.0054, 0.0048]), + (503.2, [0.0133, 0.0113, 0.0069]), + (514.4, [0.0125, 0.0097, 0.0061]), + (542.4, [0.0075, 0.0019, 0.0053]), + (555.2, [0.0015, 0.0003, 0.0048]), + (568.0, [-0.0088, -0.0038, 0.0043]), + (572.8, [-0.0144, -0.0069, 0.0011]), + (583.2, [-0.0274, -0.0172, -0.012]), + (587.2, [-0.0297, -0.0191, -0.0126]), + (593.6, [-0.0286, -0.0172, -0.0079]), + (596.0, [-0.0316, -0.0173, -0.0118]), + (597.6, [-0.035, -0.02, -0.0138]), + (598.4, [-0.0376, -0.0247, -0.0136]), + (600.0, [-0.0402, -0.0335, -0.0074]), + (602.4, [-0.0431, -0.0373, 0.0051]), + (604.0, [-0.0414, -0.0315, 0.0124]), + (605.6, [-0.0372, -0.0234, 0.0165]), + (608.0, [-0.0188, -0.0076, 0.0163]), + (610.4, [0.0036, 0.005, 0.0143]), + (612.0, [0.0119, 0.0093, 0.0135]), + (614.4, [0.016, 0.0112, 0.011]), + (619.2, [0.0124, 0.0058, 0.0045]), + (623.2, [0.0081, 0.0033, 0.0019]), + (628.0, [0.0047, 0.0049, 0.0044]), + (631.2, [-0.0009, -0.001, 0.0027]), + (632.8, [-0.002, -0.0015, 0.003]), + (635.2, [-0.002, -0.0007, 0.0025]), + (639.2, [0.002, 0.0024, 0.0007]), + (644.8, [0.0006, -0.0002, -0.005]), + (647.2, [0.0017, 0.0013, -0.0043]), + (651.2, [0.0006, -0.001, -0.0044]), + (654.4, [-0.0001, -0.0004, -0.0006]), + (656.8, [-0.0009, -0.0012, -0.0001]), + (660.0, [0.0006, 0.0011, 0.0029]), + (665.6, [0.0002, 0.0004, 0.0017]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0334, 0.0136, 0.0008]), + (0.79967, [0.0306, 0.011, -0.002]), + (1.5993, [0.036, 0.0154, 0.003]), + (3.1987, [0.033, 0.014, 0.0008]), + (6.3973, [0.032, 0.013, -0.0001]), + (116.75, [0.0321, 0.0125, 0.0001]), + (117.55, [0.0293, 0.0128, 0.0001]), + (118.35, [0.0273, 0.0092, 0.0001]), + (119.15, [0.0241, 0.0087, 0.0001]), + (271.89, [0.0242, 0.0085, -0.0002]), + (273.49, [0.0186, 0.0087, -0.0002]), + (274.29, [0.0159, 0.0048, -0.0002]), + (276.68, [0.0083, 0.0038, -0.0002]), + (277.48, [0.0053, 0.0002, -0.0002]), + (279.08, [0.0001, 0.0006, -0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [-0.0024, -0.0025, -0.0085]), + (0.79036, [0.0068, 0.0042, 0.0053]), + (1.5807, [0.0044, 0.0055, 0.0075]), + (2.3711, [0.0045, 0.0034, 0.004]), + (3.1615, [0.0037, 0.0023, 0.003]), + (4.7422, [0.0038, 0.0031, 0.0035]), + (5.5325, [0.0037, 0.0042, 0.0058]), + (6.3229, [0.0049, 0.0038, 0.0052]), + (8.694, [0.0037, 0.0022, 0.003]), + (9.4844, [0.0044, 0.0048, 0.0043]), + (11.855, [0.0031, 0.0073, 0.0028]), + (12.646, [0.0047, 0.0105, 0.0047]), + (14.227, [0.0037, 0.0141, 0.0076]), + (16.598, [0.0046, 0.0165, 0.0102]), + (21.34, [0.0082, 0.0301, 0.0173]), + (22.921, [0.0134, 0.036, 0.0203]), + (26.872, [0.0535, 0.057, 0.0265]), + (27.663, [0.0562, 0.0606, 0.0287]), + (28.453, [0.0535, 0.063, 0.0303]), + (29.243, [0.0452, 0.0644, 0.031]), + (30.034, [0.034, 0.0511, 0.026]), + (30.824, [0.0166, 0.0181, 0.011]), + (31.615, [0.0042, 0.0018, 0.0018]), + (32.405, [0.0007, 0.0001, 0.0007]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [-0.0022, 0.0283, 0.0756]), + (0.79858, [0.0031, 0.0347, 0.0823]), + (2.3957, [0.0049, 0.0361, 0.0866]), + (16.77, [0.0023, 0.034, 0.08]), + (21.562, [0.0031, 0.0244, 0.0607]), + (25.555, [0.0011, 0.0156, 0.0445]), + (27.95, [0.0006, 0.0086, 0.0311]), + (31.943, [0.0003, 0.008, 0.0277]), + (43.123, [-0.0003, 0.0075, 0.027]), + (44.721, [-0.0003, 0.0047, 0.0171]), + (46.318, [-0.0003, 0.003, 0.0113]), + (50.311, [-0.0003, 0.0002, 0.0016]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1167, 0.051, -0.0139]), + (0.79635, [0.1128, 0.0517, -0.0125]), + (1.5927, [0.1139, 0.0508, -0.0138]), + (3.9818, [0.1134, 0.0532, -0.0066]), + (7.1672, [0.1126, 0.0542, -0.0034]), + (11.149, [0.1265, 0.0583, -0.0021]), + (13.538, [0.1239, 0.059, -0.0018]), + (14.334, [0.1061, 0.0495, -0.0019]), + (15.131, [0.037, 0.016, -0.0017]), + (15.927, [-0.0002, -0.0006, -0.0018]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.1797, 0.446, 0.3393]), + (0.76482, [0.11, 0.2762, 0.2075]), + (1.5296, [0.0929, 0.2308, 0.1773]), + (2.2945, [0.0296, 0.0774, 0.0606]), + (3.0593, [0.0012, 0.0038, 0.0035]), + (3.8241, [-0.0001, -0.0012, -0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.2472, 0.558, 0.4641]), + (0.79661, [0.1777, 0.4198, 0.3538]), + (1.5932, [0.1563, 0.3731, 0.3178]), + (2.3898, [0.0885, 0.236, 0.212]), + (3.1864, [0.0588, 0.1568, 0.1473]), + (3.983, [0.0467, 0.1223, 0.1149]), + (4.7796, [0.0388, 0.0977, 0.0937]), + (6.3728, [0.017, 0.05, 0.0504]), + (7.1694, [0.0126, 0.0332, 0.035]), + (7.9661, [0.0062, 0.02, 0.0228]), + (8.7627, [0.0024, 0.0082, 0.0107]), + (9.5593, [0.0007, 0.0002, 0.0045]), + (15.135, [0.0002, 0.0003, 0.0018]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.0783, 0.0383, 0.2114]), + (6.3762, [0.0756, 0.0384, 0.1997]), + (9.5643, [0.0717, 0.0378, 0.1845]), + (11.158, [0.0753, 0.0416, 0.175]), + (11.955, [0.0709, 0.0398, 0.1697]), + (16.738, [0.054, 0.0344, 0.1318]), + (22.317, [0.0539, 0.0304, 0.0715]), + (23.911, [0.0517, 0.0348, 0.0531]), + (24.708, [0.0514, 0.0344, 0.0444]), + (25.505, [0.0548, 0.0349, 0.036]), + (27.099, [0.056, 0.0342, 0.0202]), + (29.49, [0.0598, 0.0375, -0.001]), + (31.881, [0.0642, 0.0363, -0.0173]), + (32.678, [0.0642, 0.0389, -0.0217]), + (34.272, [0.0679, 0.0381, -0.0282]), + (35.866, [0.0691, 0.037, -0.0321]), + (36.663, [0.071, 0.0394, -0.0329]), + (39.054, [0.0723, 0.0371, -0.0317]), + (39.851, [0.0718, 0.0389, -0.0302]), + (40.648, [0.0752, 0.0402, -0.0283]), + (41.445, [0.0744, 0.0397, -0.0261]), + (42.242, [0.0777, 0.0391, -0.0238]), + (43.836, [0.0787, 0.0425, -0.0184]), + (45.43, [0.0807, 0.0419, -0.0131]), + (46.227, [0.083, 0.0449, -0.0105]), + (47.822, [0.0852, 0.0437, -0.0056]), + (48.619, [0.0848, 0.0458, -0.0032]), + (49.416, [0.0885, 0.0477, -0.0012]), + (51.01, [0.0882, 0.0474, 0.0023]), + (51.807, [0.0884, 0.051, 0.0037]), + (53.401, [0.0939, 0.0518, 0.006]), + (54.198, [0.0862, 0.0471, 0.003]), + (54.995, [0.0291, 0.014, 0.003]), + (55.792, [-0.0016, -0.0002, 0.0032]), + (56.589, [0.0009, 0.001, 0.0033]), + (65.356, [0.0003, 0.0003, 0.0019]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0531, 0.032, -0.0688]), + (1.5918, [0.0526, 0.0329, -0.0689]), + (3.1836, [0.0584, 0.0363, -0.0677]), + (4.7754, [0.0534, 0.0332, -0.0662]), + (6.3672, [0.0531, 0.0327, -0.0642]), + (12.734, [0.0568, 0.0329, -0.0527]), + (19.897, [0.062, 0.0348, -0.0285]), + (25.469, [0.0613, 0.0353, -0.0052]), + (29.448, [0.0603, 0.0343, 0.0066]), + (30.244, [0.0434, 0.0244, 0.0084]), + (31.04, [0.0094, 0.0031, 0.0094]), + (31.836, [-0.0025, -0.0039, 0.0111]), + (34.224, [-0.0024, -0.0031, 0.0134]), + (37.407, [-0.0027, -0.0024, 0.0134]), + (46.162, [-0.0003, -0.0006, 0.0028]), + (47.754, [-0.0043, -0.0028, 0.001]), + (50.141, [-0.0009, -0.0009, -0.0001]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [0.0252, 0.0216, -0.1506]), + (0.79582, [0.0232, 0.0183, -0.1476]), + (7.1624, [0.0253, 0.0183, -0.1391]), + (10.346, [0.0273, 0.0184, -0.1284]), + (12.733, [0.0314, 0.0206, -0.1167]), + (13.529, [0.0329, 0.0192, -0.1132]), + (14.325, [0.0262, 0.0158, -0.1099]), + (15.121, [0.0028, 0.0019, -0.1057]), + (15.916, [-0.0176, -0.0129, -0.1014]), + (16.712, [-0.0192, -0.0144, -0.0964]), + (19.1, [-0.0154, -0.0134, -0.0819]), + (22.283, [-0.0097, -0.0108, -0.057]), + (26.262, [-0.0078, -0.0105, -0.0246]), + (29.445, [-0.0078, -0.0107, -0.0025]), + (31.833, [-0.0076, -0.0094, 0.0108]), + (35.016, [-0.0066, -0.0076, 0.0222]), + (37.403, [-0.0052, -0.0051, 0.0261]), + (40.587, [-0.0031, -0.0029, 0.0245]), + (49.341, [0.0012, -0.0004, 0.0041]), + (50.932, [0.0056, 0.0021, 0.0012]), + (53.32, [0.0031, 0.0008, -0.0024]), + (58.891, [-0.0021, -0.0014, -0.0033]), + (60.482, [0.0005, 0.0, -0.003]), + (66.053, [-0.0003, -0.0003, -0.002]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0317, 0.099, 0.2988]), + (0.7948, [0.0265, 0.098, 0.2906]), + (3.1792, [0.0251, 0.0943, 0.2775]), + (9.5376, [0.0225, 0.0853, 0.1705]), + (11.922, [0.0225, 0.0832, 0.135]), + (13.512, [0.0227, 0.0816, 0.1157]), + (15.896, [0.0252, 0.0813, 0.0927]), + (17.486, [0.0217, 0.0782, 0.0803]), + (21.46, [0.0215, 0.0773, 0.0641]), + (22.254, [0.0154, 0.0548, 0.0467]), + (23.049, [0.0039, 0.015, 0.0167]), + (23.844, [0.0008, 0.0013, 0.0038]), + (24.639, [-0.0002, -0.0007, 0.0026]), + (27.818, [-0.0004, -0.0005, 0.0018]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0038, 0.0038, 0.0039]), + (27.167, [0.0038, 0.0035, 0.0039]), + (27.966, [0.0038, 0.0082, 0.0039]), + (33.56, [0.0038, 0.0081, 0.0038]), + (34.359, [0.0038, 0.0123, 0.0038]), + (38.354, [0.0038, 0.0111, 0.0038]), + (39.153, [0.0038, 0.0153, 0.0038]), + (41.55, [0.0039, 0.0156, 0.0038]), + (42.349, [0.0083, 0.0157, 0.0038]), + (43.148, [0.0076, 0.0153, 0.0038]), + (43.947, [0.0077, 0.0199, 0.0037]), + (47.143, [0.0078, 0.019, 0.0039]), + (47.942, [0.0077, 0.0233, 0.0039]), + (51.138, [0.0089, 0.0232, 0.0042]), + (51.937, [0.0123, 0.0276, 0.0042]), + (54.334, [0.0117, 0.0269, 0.0042]), + (55.933, [0.0116, 0.0318, 0.0042]), + (58.33, [0.0109, 0.0324, 0.0042]), + (59.129, [0.0145, 0.036, 0.0042]), + (59.928, [0.0158, 0.0351, 0.0042]), + (61.526, [0.0156, 0.0361, 0.0042]), + (62.325, [0.0156, 0.0399, 0.0042]), + (63.923, [0.0155, 0.0387, 0.004]), + (65.521, [0.015, 0.0435, 0.0039]), + (66.32, [0.0192, 0.0431, 0.0037]), + (67.119, [0.0196, 0.0425, 0.0037]), + (67.918, [0.0195, 0.0468, 0.0035]), + (69.516, [0.0196, 0.0464, 0.0033]), + (71.114, [0.0196, 0.0512, 0.0031]), + (71.913, [0.0196, 0.0507, 0.0031]), + (72.712, [0.0229, 0.0506, 0.003]), + (73.511, [0.0296, 0.0546, 0.003]), + (75.109, [0.0511, 0.0584, 0.003]), + (76.707, [0.0735, 0.067, 0.0029]), + (77.507, [0.0773, 0.066, 0.0038]), + (78.306, [0.0752, 0.0666, 0.0001]), + (79.105, [0.048, 0.0281, -0.0006]), + (79.904, [0.0265, 0.0028, -0.0002]), + (80.703, [0.0178, 0.0009, -0.0002]), + (81.502, [0.0057, -0.0002, -0.0001]), + (82.301, [-0.0007, 0.0001, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [-0.0004, 0.0008, -0.0002]), + (142.25, [-0.0002, 0.0001, -0.0003]), + (143.05, [0.0019, 0.0001, 0.0062]), + (146.25, [0.0235, 0.0001, 0.0472]), + (147.05, [0.0288, 0.0069, 0.0547]), + (148.64, [0.0305, 0.0232, 0.0486]), + (150.24, [0.0305, 0.041, 0.0379]), + (151.04, [0.0343, 0.0471, 0.0307]), + (151.84, [0.0385, 0.0466, 0.0202]), + (154.24, [0.0537, 0.0351, 0.0059]), + (155.04, [0.0532, 0.0283, 0.0014]), + (155.84, [0.044, 0.0191, -0.0001]), + (159.03, [0.0038, 0.0008, 0.0]), + (159.83, [-0.0003, -0.0001, 0.0]), + ], + }, +]; +const ZOOM_75_STREAKS: &[Streak] = &[]; +const ZOOM_75_RAYS: &[Ray] = &[ + Ray { angle: 0.19, r0: 6.25, r1: 236.72, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 7.39, r0: 6.25, r1: 116.41, fade: 0.05, rgb: [0.0118, 0.0167, 0.0157] }, + Ray { angle: 16.39, r0: 15.625, r1: 368.75, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 35.96, r0: 24.219, r1: 232.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 43.1, r0: 9.375, r1: 260.16, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 55.85, r0: 46.094, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 62.98, r0: 8.5938, r1: 220.31, fade: 0.05, rgb: [0.0235, 0.0294, 0.0275] }, + Ray { angle: 64.75, r0: 57.031, r1: 321.88, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 70.21, r0: 14.062, r1: 139.84, fade: 0.05, rgb: [0.0196, 0.0196, 0.0216] }, + Ray { angle: 71.94, r0: 67.188, r1: 241.41, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 79.1, r0: 41.406, r1: 310.94, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 81.03, r0: 56.25, r1: 341.41, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 86.31, r0: 6.25, r1: 159.38, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 91.51, r0: 6.25, r1: 115.62, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 93.43, r0: 6.25, r1: 377.34, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 104.33, r0: 26.562, r1: 317.19, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 109.8, r0: 13.281, r1: 177.34, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 116.78, r0: 61.719, r1: 282.81, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 118.75, r0: 18.75, r1: 214.06, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 129.2, r0: 64.844, r1: 370.31, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 131.18, r0: 28.906, r1: 254.69, fade: 0.05, rgb: [0.0235, 0.0275, 0.0255] }, + Ray { angle: 136.35, r0: 68.75, r1: 194.53, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 138.23, r0: 55.469, r1: 282.81, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 145.46, r0: 66.406, r1: 284.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 147.33, r0: 36.719, r1: 256.25, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 148.78, r0: 68.75, r1: 214.06, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 152.55, r0: 53.125, r1: 264.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 156.0, r0: 21.094, r1: 256.25, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 161.68, r0: 14.062, r1: 246.88, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 163.2, r0: 17.188, r1: 244.53, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 168.8, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 172.33, r0: 36.719, r1: 136.72, fade: 0.05, rgb: [0.0216, 0.0255, 0.0235] }, + Ray { angle: 186.58, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 193.77, r0: 24.219, r1: 221.88, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 200.86, r0: 72.656, r1: 184.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 202.85, r0: 57.031, r1: 250.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 207.96, r0: 26.562, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 213.23, r0: 17.188, r1: 279.69, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 217.09, r0: 37.5, r1: 293.75, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 220.48, r0: 42.969, r1: 286.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 224.36, r0: 16.406, r1: 213.28, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 225.93, r0: 75.781, r1: 178.91, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 227.35, r0: 91.406, r1: 164.84, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 229.4, r0: 35.938, r1: 161.72, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 234.32, r0: 10.938, r1: 123.44, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 234.61, r0: 19.531, r1: 171.88, fade: 0.05, rgb: [0.0235, 0.0294, 0.0294] }, + Ray { angle: 240.32, r0: 38.281, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 241.64, r0: 74.219, r1: 217.19, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 243.57, r0: 62.5, r1: 190.62, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 245.54, r0: 57.812, r1: 214.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 250.77, r0: 27.344, r1: 206.25, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 252.65, r0: 63.281, r1: 146.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 254.51, r0: 60.938, r1: 154.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 259.94, r0: 26.562, r1: 197.66, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 263.3, r0: 64.062, r1: 193.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 265.12, r0: 75.0, r1: 192.19, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 266.85, r0: 6.25, r1: 128.12, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 269.01, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 276.16, r0: 6.25, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 283.26, r0: 28.906, r1: 188.28, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 284.82, r0: 71.094, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 288.66, r0: 43.75, r1: 205.47, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 295.72, r0: 26.562, r1: 216.41, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 299.12, r0: 39.844, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 302.95, r0: 6.25, r1: 232.03, fade: 0.05, rgb: [0.0235, 0.0294, 0.0275] }, + Ray { angle: 306.27, r0: 67.188, r1: 229.69, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 310.14, r0: 60.156, r1: 251.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 317.34, r0: 34.375, r1: 115.62, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 319.22, r0: 69.531, r1: 274.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 320.71, r0: 6.25, r1: 282.03, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 322.47, r0: 6.25, r1: 320.31, fade: 0.05, rgb: [0.0196, 0.0216, 0.0235] }, + Ray { angle: 340.66, r0: 6.25, r1: 267.97, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 345.8, r0: 25.781, r1: 210.94, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 352.82, r0: 6.25, r1: 102.34, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, +]; +const ZOOM_100_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.3108, 0.6442, 0.7116]), + (0.8, [0.3108, 0.6592, 0.7231]), + (3.2, [0.3153, 0.6598, 0.7214]), + (5.6, [0.3139, 0.6507, 0.714]), + (8.8, [0.3033, 0.6181, 0.6903]), + (11.2, [0.2893, 0.5927, 0.6604]), + (12.0, [0.2903, 0.5773, 0.6488]), + (15.2, [0.2701, 0.5311, 0.6032]), + (22.4, [0.2142, 0.4049, 0.4756]), + (27.2, [0.1942, 0.3371, 0.3951]), + (28.8, [0.1886, 0.3131, 0.3724]), + (31.2, [0.184, 0.2885, 0.3438]), + (33.6, [0.1924, 0.2802, 0.3216]), + (38.4, [0.203, 0.2495, 0.276]), + (46.4, [0.1963, 0.2034, 0.2076]), + (52.0, [0.1878, 0.1735, 0.1683]), + (55.2, [0.1842, 0.1612, 0.1478]), + (56.8, [0.1822, 0.1496, 0.1377]), + (60.0, [0.176, 0.1313, 0.1213]), + (62.4, [0.1751, 0.12, 0.1156]), + (63.2, [0.1832, 0.1216, 0.1158]), + (64.8, [0.2365, 0.1309, 0.1187]), + (65.6, [0.256, 0.1338, 0.1208]), + (66.4, [0.2877, 0.1409, 0.1221]), + (67.2, [0.3035, 0.142, 0.1224]), + (68.0, [0.3, 0.1403, 0.1214]), + (69.6, [0.2549, 0.1208, 0.1139]), + (72.0, [0.1833, 0.0994, 0.1043]), + (72.8, [0.1807, 0.0962, 0.1041]), + (73.6, [0.1833, 0.0901, 0.103]), + (75.2, [0.1836, 0.0857, 0.0989]), + (76.8, [0.1798, 0.0825, 0.0971]), + (79.2, [0.1723, 0.0709, 0.0883]), + (81.6, [0.1687, 0.065, 0.0878]), + (83.2, [0.1627, 0.0603, 0.0825]), + (85.6, [0.1606, 0.0528, 0.0841]), + (87.2, [0.1537, 0.0468, 0.0809]), + (89.6, [0.1608, 0.0403, 0.0763]), + (90.4, [0.1602, 0.0344, 0.073]), + (94.4, [0.1613, 0.0241, 0.061]), + (95.2, [0.1611, 0.0209, 0.0597]), + (98.4, [0.1585, 0.0168, 0.0564]), + (100.0, [0.1605, 0.0115, 0.0549]), + (102.4, [0.1583, 0.0101, 0.0524]), + (104.0, [0.1585, 0.0013, 0.0519]), + (105.6, [0.1564, 0.0005, 0.0525]), + (107.2, [0.1577, -0.0006, 0.0456]), + (108.8, [0.1573, -0.0058, 0.0446]), + (110.4, [0.1522, -0.0081, 0.0416]), + (111.2, [0.1483, -0.009, 0.0369]), + (112.8, [0.1431, -0.0157, 0.0336]), + (114.4, [0.1355, -0.0146, 0.0296]), + (115.2, [0.1342, -0.0141, 0.0235]), + (116.8, [0.1324, -0.0129, 0.0208]), + (117.6, [0.1276, -0.0152, 0.0196]), + (118.4, [0.129, -0.0142, 0.0191]), + (120.0, [0.1275, -0.0124, 0.0138]), + (120.8, [0.1283, -0.0114, 0.0136]), + (121.6, [0.1237, -0.0103, 0.013]), + (122.4, [0.1235, -0.0123, 0.0125]), + (123.2, [0.1224, -0.0112, 0.0127]), + (124.0, [0.1245, -0.0131, 0.0096]), + (125.6, [0.1205, -0.0105, 0.0055]), + (127.2, [0.12, -0.0104, 0.0059]), + (128.8, [0.1225, -0.006, 0.0033]), + (131.2, [0.1229, 0.001, 0.006]), + (132.0, [0.1242, 0.0, 0.0075]), + (133.6, [0.1237, 0.0046, 0.006]), + (136.0, [0.1228, 0.0117, 0.0095]), + (136.8, [0.1227, 0.0111, 0.0109]), + (138.4, [0.1233, 0.0134, 0.0073]), + (140.0, [0.1255, 0.016, 0.0069]), + (141.6, [0.1241, 0.0182, 0.007]), + (142.4, [0.1228, 0.0168, 0.0036]), + (146.4, [0.1201, 0.024, 0.0023]), + (147.2, [0.1168, 0.0221, -0.0012]), + (148.0, [0.1159, 0.0226, -0.0021]), + (151.2, [0.1152, 0.0269, -0.0035]), + (152.0, [0.1116, 0.0276, -0.0068]), + (152.8, [0.1112, 0.0251, -0.0089]), + (156.0, [0.1093, 0.0288, -0.0114]), + (157.6, [0.1064, 0.0307, -0.0156]), + (158.4, [0.106, 0.0286, -0.0151]), + (161.6, [0.1014, 0.0282, -0.0137]), + (162.4, [0.1017, 0.0284, -0.017]), + (164.0, [0.101, 0.0245, -0.0163]), + (166.4, [0.097, 0.0245, -0.0148]), + (168.0, [0.097, 0.0242, -0.0183]), + (168.8, [0.0966, 0.0238, -0.0175]), + (169.6, [0.0966, 0.0203, -0.0174]), + (172.0, [0.0926, 0.02, -0.0158]), + (172.8, [0.0923, 0.0199, -0.0186]), + (174.4, [0.0919, 0.0202, -0.0183]), + (176.0, [0.0886, 0.0161, -0.0177]), + (177.6, [0.0882, 0.0157, -0.0166]), + (178.4, [0.0883, 0.0157, -0.0197]), + (180.0, [0.0889, 0.0157, -0.0191]), + (181.6, [0.0852, 0.0113, -0.0184]), + (183.2, [0.0861, 0.0114, -0.0173]), + (184.0, [0.0864, 0.0112, -0.0203]), + (185.6, [0.0875, 0.0108, -0.0197]), + (186.4, [0.0844, 0.0114, -0.0194]), + (188.0, [0.0853, 0.0072, -0.0188]), + (188.8, [0.0857, 0.0075, -0.0176]), + (190.4, [0.0869, 0.0073, -0.0207]), + (192.8, [0.0857, 0.0077, -0.0191]), + (193.6, [0.0863, 0.0042, -0.0189]), + (195.2, [0.0876, 0.0039, -0.0185]), + (196.0, [0.0884, 0.0039, -0.0213]), + (199.2, [0.0886, 0.004, -0.0193]), + (200.8, [0.0903, 0.0001, -0.0181]), + (202.4, [0.0926, 0.0006, -0.0214]), + (203.2, [0.0902, 0.0007, -0.0206]), + (206.4, [0.094, 0.0011, -0.0195]), + (207.2, [0.0949, -0.0026, -0.0185]), + (208.0, [0.0966, -0.0022, -0.0214]), + (208.8, [0.0946, -0.0021, -0.0216]), + (213.6, [0.1012, -0.0018, -0.0188]), + (214.4, [0.1024, -0.0051, -0.0214]), + (220.0, [0.0992, -0.0033, -0.0196]), + (220.8, [0.0985, -0.0034, -0.0196]), + (221.6, [0.0947, -0.0066, -0.0226]), + (227.2, [0.0918, -0.0046, -0.0191]), + (228.0, [0.088, -0.0034, -0.0215]), + (228.8, [0.0878, -0.0061, -0.0225]), + (233.6, [0.0846, -0.0054, -0.0204]), + (234.4, [0.0806, -0.0051, -0.0195]), + (236.0, [0.0794, -0.004, -0.0229]), + (237.6, [0.078, -0.0075, -0.0217]), + (240.8, [0.076, -0.0063, -0.0202]), + (242.4, [0.0708, -0.0058, -0.0188]), + (243.2, [0.0704, -0.0057, -0.0218]), + (244.0, [0.0696, -0.0051, -0.0217]), + (245.6, [0.0685, -0.0084, -0.0206]), + (248.0, [0.067, -0.007, -0.0188]), + (250.4, [0.0614, -0.0062, -0.0165]), + (252.0, [0.0598, -0.0057, -0.0193]), + (252.8, [0.059, -0.0051, -0.0181]), + (254.4, [0.0575, -0.0085, -0.0169]), + (256.0, [0.0563, -0.0073, -0.0155]), + (256.8, [0.0518, -0.007, -0.0148]), + (259.2, [0.0496, -0.0059, -0.0125]), + (260.0, [0.0488, -0.0055, -0.0155]), + (263.2, [0.0459, -0.0038, -0.0128]), + (264.0, [0.0459, -0.0071, -0.0122]), + (265.6, [0.0405, -0.0059, -0.0111]), + (268.0, [0.0386, -0.0048, -0.0091]), + (269.6, [0.0372, -0.0041, -0.0122]), + (272.8, [0.0345, -0.0023, -0.0098]), + (274.4, [0.029, -0.0056, -0.0089]), + (277.6, [0.0267, -0.0037, -0.0065]), + (279.2, [0.0253, -0.0029, -0.0095]), + (282.4, [0.0232, -0.0013, -0.007]), + (283.2, [0.0189, -0.0012, -0.0065]), + (284.0, [0.0185, -0.0002, -0.0059]), + (284.8, [0.0179, -0.003, -0.0054]), + (285.6, [0.0174, -0.0033, -0.0048]), + (288.8, [0.0154, -0.002, -0.0027]), + (289.6, [0.0149, -0.0017, -0.0057]), + (292.0, [0.0137, -0.0007, -0.0034]), + (293.6, [0.0093, -0.0001, -0.0022]), + (296.0, [0.0092, 0.0016, -0.0001]), + (297.6, [0.0089, -0.0014, 0.0015]), + (300.0, [0.0082, 0.0005, 0.0047]), + (301.6, [0.0074, 0.0013, 0.003]), + (303.2, [0.0068, 0.0019, 0.0052]), + (304.8, [0.0023, 0.0023, 0.007]), + (309.6, [0.0008, 0.0048, 0.0108]), + (311.2, [0.0005, 0.0019, 0.0116]), + (313.6, [-0.0001, 0.0037, 0.0126]), + (314.4, [-0.0004, 0.0041, 0.0091]), + (316.8, [-0.0009, 0.0049, 0.0096]), + (317.6, [-0.005, 0.0051, 0.0095]), + (326.4, [-0.0086, 0.0073, 0.009]), + (327.2, [-0.0089, 0.0039, 0.0094]), + (328.8, [-0.0095, 0.0044, 0.0106]), + (329.6, [-0.0097, 0.0046, 0.0077]), + (332.0, [-0.0105, 0.0051, 0.0087]), + (332.8, [-0.014, 0.0054, 0.0091]), + (347.2, [-0.0159, 0.0094, 0.0147]), + (348.8, [-0.016, 0.006, 0.0148]), + (349.6, [-0.0161, 0.0064, 0.0155]), + (351.2, [-0.0157, 0.0067, 0.0121]), + (352.8, [-0.0195, 0.007, 0.0125]), + (390.4, [-0.0211, 0.0089, 0.0167]), + (433.6, [-0.0211, 0.0029, 0.0149]), + (465.6, [-0.0208, -0.0041, 0.0106]), + (473.6, [-0.0166, -0.0057, 0.0061]), + (487.2, [-0.0142, -0.0106, -0.0028]), + (500.0, [-0.0157, -0.0142, -0.0042]), + (518.4, [-0.0166, -0.0122, -0.0029]), + (538.4, [-0.0158, -0.0054, -0.0066]), + (548.0, [-0.0102, -0.0011, -0.0071]), + (557.6, [-0.0004, 0.0042, -0.0067]), + (571.2, [0.0203, 0.0158, -0.0042]), + (584.0, [0.0454, 0.029, 0.0088]), + (588.0, [0.0515, 0.03, 0.0094]), + (593.6, [0.0528, 0.0266, 0.0047]), + (597.6, [0.059, 0.026, 0.0124]), + (598.4, [0.0608, 0.028, 0.0126]), + (600.0, [0.066, 0.0377, 0.0122]), + (601.6, [0.0646, 0.0431, 0.0049]), + (603.2, [0.0616, 0.0456, -0.0071]), + (605.6, [0.057, 0.0386, -0.0182]), + (607.2, [0.045, 0.0259, -0.0212]), + (608.8, [0.0247, 0.0107, -0.0222]), + (611.2, [-0.0044, -0.0058, -0.0206]), + (612.8, [-0.0147, -0.0094, -0.0183]), + (615.2, [-0.02, -0.0103, -0.015]), + (618.4, [-0.0211, -0.0118, -0.0106]), + (623.2, [-0.017, -0.0086, -0.0022]), + (628.8, [-0.0098, -0.005, -0.006]), + (632.0, [-0.0004, 0.0001, -0.0045]), + (633.6, [0.0024, 0.0014, -0.0027]), + (636.0, [0.0015, 0.0, -0.0015]), + (639.2, [-0.0036, -0.0013, -0.0018]), + (640.8, [-0.0049, -0.0019, -0.0002]), + (644.0, [-0.0014, 0.0005, 0.0059]), + (645.6, [-0.0005, 0.0014, 0.0067]), + (648.8, [-0.0015, 0.0003, 0.0049]), + (652.8, [0.0012, 0.0016, 0.0052]), + (656.0, [0.0007, -0.0006, 0.0]), + (658.4, [0.0021, 0.0001, 0.0]), + (661.6, [-0.0004, -0.002, -0.0036]), + (668.0, [0.0, 0.0003, -0.0017]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.6563, 0.3322, 0.2884]), + (0.8, [0.6349, 0.3164, 0.2761]), + (4.8, [0.633, 0.3106, 0.2708]), + (11.2, [0.6249, 0.2917, 0.2462]), + (22.4, [0.5906, 0.2397, 0.1951]), + (29.6, [0.5311, 0.2033, 0.1714]), + (36.8, [0.4362, 0.1448, 0.1429]), + (44.8, [0.3513, 0.107, 0.1218]), + (52.8, [0.2864, 0.079, 0.1111]), + (57.6, [0.2514, 0.0769, 0.1099]), + (59.2, [0.2404, 0.0793, 0.1105]), + (62.4, [0.2236, 0.0793, 0.1057]), + (67.2, [0.2328, 0.0905, 0.0988]), + (72.0, [0.1618, 0.0854, 0.0946]), + (72.8, [0.1566, 0.0863, 0.0952]), + (76.0, [0.144, 0.0914, 0.097]), + (81.6, [0.1301, 0.1006, 0.0999]), + (88.0, [0.1123, 0.1097, 0.0931]), + (92.8, [0.0879, 0.1177, 0.0976]), + (101.6, [0.0538, 0.128, 0.0997]), + (107.2, [0.0359, 0.1309, 0.0958]), + (109.6, [0.0294, 0.1327, 0.0979]), + (112.0, [0.0309, 0.136, 0.1018]), + (116.8, [0.0338, 0.1328, 0.1087]), + (120.8, [0.0282, 0.1281, 0.1123]), + (127.2, [0.0143, 0.1156, 0.1126]), + (137.6, [-0.0061, 0.0855, 0.0989]), + (140.8, [-0.0128, 0.08, 0.0993]), + (148.0, [-0.0127, 0.0664, 0.0999]), + (158.4, [-0.0105, 0.053, 0.1056]), + (180.0, [-0.0085, 0.0553, 0.0938]), + (190.4, [-0.0145, 0.0552, 0.0871]), + (196.8, [-0.0209, 0.0546, 0.0833]), + (215.2, [-0.0462, 0.0512, 0.0727]), + (228.0, [-0.0412, 0.0471, 0.0659]), + (242.4, [-0.0316, 0.0408, 0.059]), + (272.8, [-0.0062, 0.0262, 0.0337]), + (292.0, [0.0061, 0.0162, 0.0196]), + (300.0, [0.0071, 0.0111, 0.0122]), + (305.6, [0.0091, 0.0091, 0.0045]), + (313.6, [0.0114, 0.004, -0.0005]), + (325.6, [0.0157, 0.001, -0.0001]), + (340.8, [0.0184, -0.0039, -0.0083]), + (350.4, [0.0195, -0.0065, -0.0114]), + (386.4, [0.0204, -0.009, -0.0154]), + (432.8, [0.0204, -0.0029, -0.0139]), + (464.8, [0.0201, 0.004, -0.0096]), + (472.8, [0.016, 0.0056, -0.0052]), + (486.4, [0.0136, 0.0104, 0.0037]), + (499.2, [0.0151, 0.014, 0.005]), + (517.6, [0.0161, 0.012, 0.0036]), + (537.6, [0.0153, 0.0053, 0.0073]), + (547.2, [0.0098, 0.001, 0.0078]), + (556.8, [0.0001, -0.0042, 0.0074]), + (570.4, [-0.0204, -0.0157, 0.0049]), + (584.0, [-0.0468, -0.0294, -0.0086]), + (587.2, [-0.0515, -0.03, -0.0088]), + (592.8, [-0.0529, -0.0266, -0.0042]), + (596.0, [-0.0575, -0.0254, -0.0109]), + (597.6, [-0.0613, -0.0285, -0.0126]), + (599.2, [-0.0666, -0.0381, -0.0124]), + (600.0, [-0.0659, -0.0412, -0.0096]), + (602.4, [-0.0617, -0.0461, 0.0073]), + (604.8, [-0.057, -0.0385, 0.0183]), + (606.4, [-0.0446, -0.0254, 0.0212]), + (608.0, [-0.0242, -0.0102, 0.0222]), + (609.6, [-0.0036, 0.0018, 0.0214]), + (611.2, [0.0115, 0.0085, 0.0193]), + (612.8, [0.0174, 0.0098, 0.017]), + (614.4, [0.02, 0.0103, 0.0149]), + (617.6, [0.021, 0.0118, 0.0104]), + (622.4, [0.0167, 0.0085, 0.0022]), + (628.0, [0.0095, 0.0049, 0.0061]), + (631.2, [0.0, -0.0003, 0.0044]), + (632.8, [-0.0025, -0.0013, 0.0027]), + (635.2, [-0.0014, 0.0001, 0.0015]), + (639.2, [0.0047, 0.0018, 0.0012]), + (644.0, [0.0006, -0.0012, -0.0067]), + (648.0, [0.0015, -0.0003, -0.0049]), + (652.0, [-0.0012, -0.0016, -0.0051]), + (655.2, [-0.0008, 0.0006, 0.0001]), + (657.6, [-0.0021, 0.0, 0.0001]), + (660.8, [0.0003, 0.002, 0.0037]), + (667.2, [0.0, -0.0004, 0.0017]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0468, 0.0233, 0.0022]), + (0.79967, [0.0463, 0.0201, -0.0023]), + (1.5993, [0.0521, 0.0262, 0.0031]), + (2.399, [0.0522, 0.0274, 0.0041]), + (3.1987, [0.0501, 0.0238, 0.0014]), + (5.5977, [0.0485, 0.0221, -0.0002]), + (10.396, [0.0519, 0.0249, 0.0015]), + (12.795, [0.0505, 0.0232, -0.0001]), + (43.182, [0.0485, 0.0227, 0.0003]), + (59.175, [0.0488, 0.0218, -0.0004]), + (75.169, [0.0504, 0.0243, 0.0016]), + (115.95, [0.0497, 0.0236, 0.0005]), + (116.75, [0.0487, 0.0238, 0.0005]), + (119.15, [0.0367, 0.0166, 0.0006]), + (119.95, [0.035, 0.0167, 0.0005]), + (271.09, [0.0346, 0.0161, -0.0001]), + (271.89, [0.0335, 0.0166, -0.0001]), + (273.49, [0.0263, 0.012, -0.0001]), + (274.29, [0.0238, 0.012, -0.0002]), + (275.08, [0.0196, 0.008, -0.0001]), + (275.88, [0.0164, 0.0086, -0.0002]), + (276.68, [0.0127, 0.0045, -0.0002]), + (277.48, [0.0085, 0.0045, -0.0002]), + (278.28, [0.005, 0.0006, -0.0002]), + (279.08, [0.0013, 0.0005, -0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0036, -0.0107, -0.0054]), + (0.79036, [0.0068, 0.0049, 0.0059]), + (3.1615, [0.0047, 0.0015, 0.0017]), + (5.5325, [0.0048, 0.004, 0.0044]), + (7.9036, [0.0051, 0.0029, 0.0024]), + (11.855, [0.0041, 0.0081, 0.0049]), + (12.646, [0.0058, 0.0121, 0.0062]), + (15.017, [0.0064, 0.0172, 0.007]), + (16.598, [0.0073, 0.0205, 0.01]), + (22.13, [0.0128, 0.0395, 0.022]), + (22.921, [0.0165, 0.0426, 0.0235]), + (23.711, [0.0235, 0.0474, 0.0242]), + (26.082, [0.0493, 0.0646, 0.03]), + (26.872, [0.0554, 0.0697, 0.0319]), + (27.663, [0.0582, 0.074, 0.0344]), + (28.453, [0.0553, 0.0758, 0.0367]), + (29.243, [0.0481, 0.0772, 0.0383]), + (30.034, [0.0375, 0.0625, 0.0309]), + (30.824, [0.0191, 0.0239, 0.0117]), + (31.615, [0.0056, 0.0024, 0.0011]), + (32.405, [0.0017, -0.0002, -0.0007]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [0.002, 0.0462, 0.0896]), + (0.79858, [0.003, 0.0481, 0.1048]), + (2.3957, [0.0053, 0.05, 0.1097]), + (16.77, [0.0023, 0.0461, 0.1009]), + (17.569, [0.0021, 0.0456, 0.0989]), + (19.965, [0.0015, 0.0368, 0.0824]), + (20.763, [0.0027, 0.0359, 0.0791]), + (25.555, [0.0002, 0.0231, 0.0568]), + (27.95, [-0.0002, 0.0141, 0.0388]), + (31.943, [-0.0002, 0.0127, 0.0346]), + (43.123, [-0.0008, 0.0126, 0.034]), + (44.721, [-0.0008, 0.0075, 0.0219]), + (46.318, [-0.0009, 0.0043, 0.0146]), + (51.109, [-0.0011, -0.0008, 0.0005]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1416, 0.0584, -0.0167]), + (0.79635, [0.1414, 0.0615, -0.0108]), + (1.5927, [0.1418, 0.0596, -0.0145]), + (2.3891, [0.1415, 0.0612, -0.0096]), + (3.9818, [0.1418, 0.0632, -0.004]), + (7.1672, [0.1412, 0.0647, -0.0001]), + (8.7599, [0.1465, 0.0669, 0.0005]), + (10.353, [0.1519, 0.0717, 0.0058]), + (11.945, [0.1573, 0.0734, 0.0058]), + (13.538, [0.1537, 0.0713, 0.0058]), + (14.334, [0.1326, 0.0608, 0.0022]), + (15.131, [0.0507, 0.0204, -0.0014]), + (15.927, [0.0021, 0.0006, -0.0018]), + (16.723, [0.0005, 0.0003, -0.0017]), + (19.909, [0.0002, 0.0019, 0.0015]), + (23.094, [0.0002, 0.0018, 0.0014]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.23, 0.5705, 0.4333]), + (0.76482, [0.1405, 0.3521, 0.2642]), + (1.5296, [0.119, 0.298, 0.2248]), + (2.2945, [0.0389, 0.1004, 0.0772]), + (3.0593, [0.0034, 0.0052, 0.0048]), + (3.8241, [-0.0002, -0.0017, -0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.2965, 0.6513, 0.5792]), + (0.79661, [0.2181, 0.4985, 0.4419]), + (1.5932, [0.1881, 0.4529, 0.4008]), + (2.3898, [0.1131, 0.2913, 0.2716]), + (3.1864, [0.0754, 0.1964, 0.1898]), + (3.983, [0.0609, 0.155, 0.1498]), + (6.3728, [0.0242, 0.0638, 0.0649]), + (7.1694, [0.016, 0.0422, 0.0468]), + (7.9661, [0.0082, 0.0235, 0.0324]), + (8.7627, [0.0038, 0.0124, 0.0153]), + (9.5593, [0.0011, 0.0001, 0.0068]), + (17.525, [0.001, 0.0009, 0.0018]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.19, 0.1329, 0.3261]), + (6.3762, [0.1813, 0.1305, 0.3087]), + (9.5643, [0.1706, 0.1269, 0.2857]), + (11.158, [0.1713, 0.13, 0.2712]), + (11.955, [0.1631, 0.1259, 0.2633]), + (14.346, [0.1437, 0.1188, 0.2366]), + (18.332, [0.1201, 0.1066, 0.1824]), + (19.926, [0.1173, 0.1071, 0.1565]), + (23.911, [0.0996, 0.0995, 0.0885]), + (26.302, [0.0971, 0.0978, 0.051]), + (28.693, [0.0948, 0.096, 0.0179]), + (30.287, [0.0929, 0.0917, -0.0009]), + (31.881, [0.0919, 0.091, -0.0167]), + (33.475, [0.0926, 0.0864, -0.0289]), + (35.069, [0.0943, 0.0849, -0.0375]), + (37.46, [0.0954, 0.0809, -0.0427]), + (39.054, [0.0946, 0.0749, -0.0416]), + (39.851, [0.0948, 0.0754, -0.04]), + (41.445, [0.0968, 0.0715, -0.0351]), + (47.822, [0.1101, 0.0677, -0.0085]), + (50.213, [0.1168, 0.0701, -0.0002]), + (53.401, [0.1288, 0.0743, 0.0068]), + (54.198, [0.1155, 0.0699, 0.0046]), + (54.995, [0.0433, 0.0241, 0.0041]), + (55.792, [-0.0023, 0.0001, 0.0044]), + (56.589, [0.0004, 0.0018, 0.0046]), + (68.544, [0.0005, 0.0007, 0.0019]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0355, 0.0118, -0.1092]), + (0.7959, [0.0379, 0.0144, -0.1084]), + (1.5918, [0.0375, 0.0142, -0.1087]), + (3.1836, [0.0448, 0.0192, -0.1067]), + (4.7754, [0.0395, 0.0157, -0.1041]), + (6.3672, [0.0398, 0.0152, -0.1013]), + (10.347, [0.0457, 0.0166, -0.092]), + (13.53, [0.0511, 0.0185, -0.0806]), + (20.693, [0.0663, 0.0248, -0.0425]), + (25.469, [0.0709, 0.0285, -0.0125]), + (29.448, [0.0742, 0.0296, 0.0053]), + (30.244, [0.0508, 0.0184, 0.008]), + (31.04, [0.0062, -0.0104, 0.0102]), + (31.836, [-0.0087, -0.0179, 0.0123]), + (34.224, [-0.007, -0.0151, 0.0165]), + (37.407, [-0.0063, -0.012, 0.0176]), + (41.387, [-0.0048, -0.0084, 0.0128]), + (46.162, [-0.0017, -0.0034, 0.0038]), + (47.754, [-0.0059, -0.0049, 0.0015]), + (48.55, [-0.0035, -0.003, 0.001]), + (51.733, [-0.0017, -0.0011, -0.0005]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-0.0214, -0.0245, -0.2278]), + (3.1833, [-0.0213, -0.0238, -0.2263]), + (5.5707, [-0.0172, -0.0224, -0.2205]), + (7.1624, [-0.0168, -0.0235, -0.2149]), + (10.346, [-0.0093, -0.0202, -0.1985]), + (12.733, [0.0003, -0.0157, -0.1815]), + (13.529, [0.0034, -0.0164, -0.1759]), + (14.325, [-0.0042, -0.0227, -0.1708]), + (15.121, [-0.0323, -0.0431, -0.1645]), + (15.916, [-0.0572, -0.0609, -0.158]), + (16.712, [-0.0581, -0.0624, -0.1509]), + (19.895, [-0.0429, -0.0549, -0.1199]), + (22.283, [-0.0323, -0.0502, -0.0912]), + (26.262, [-0.0207, -0.0452, -0.0434]), + (28.649, [-0.0157, -0.0419, -0.0183]), + (31.833, [-0.0105, -0.0357, 0.0087]), + (35.016, [-0.0069, -0.0288, 0.0263]), + (37.403, [-0.005, -0.0226, 0.0327]), + (40.587, [-0.0013, -0.0149, 0.0316]), + (42.178, [0.0013, -0.0106, 0.0279]), + (46.157, [0.0023, -0.0039, 0.0146]), + (49.341, [0.0035, -0.0013, 0.0049]), + (50.932, [0.0089, 0.0027, 0.0012]), + (53.32, [0.0045, 0.0011, -0.0037]), + (58.891, [-0.0022, -0.0022, -0.0044]), + (60.482, [0.001, -0.0001, -0.0041]), + (70.032, [-0.0003, -0.0006, -0.0019]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0359, 0.1304, 0.3987]), + (0.7948, [0.034, 0.1334, 0.3815]), + (1.5896, [0.0331, 0.1332, 0.3843]), + (3.1792, [0.0317, 0.1304, 0.3675]), + (9.5376, [0.0294, 0.1132, 0.2281]), + (13.512, [0.0319, 0.1089, 0.1566]), + (15.896, [0.0347, 0.1086, 0.1262]), + (16.691, [0.0315, 0.1071, 0.1171]), + (19.075, [0.0316, 0.1053, 0.0989]), + (21.46, [0.0308, 0.1037, 0.0878]), + (22.254, [0.0209, 0.0723, 0.0629]), + (23.049, [0.0056, 0.0203, 0.0233]), + (23.844, [0.0005, 0.0019, 0.007]), + (25.434, [-0.0012, -0.0005, 0.0033]), + (30.202, [-0.0004, -0.0003, 0.0019]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0036, 0.0037, 0.0039]), + (23.172, [0.0036, 0.0032, 0.0039]), + (23.971, [0.0036, 0.0079, 0.0039]), + (27.966, [0.0036, 0.0074, 0.0039]), + (29.564, [0.0036, 0.0123, 0.0039]), + (32.76, [0.0036, 0.0112, 0.0038]), + (33.56, [0.0037, 0.0157, 0.0038]), + (34.359, [0.0033, 0.0156, 0.0038]), + (35.158, [0.0079, 0.0156, 0.0038]), + (36.756, [0.0076, 0.0152, 0.0038]), + (37.555, [0.0076, 0.0197, 0.0038]), + (39.952, [0.0076, 0.0192, 0.0038]), + (41.55, [0.0077, 0.024, 0.0037]), + (42.349, [0.0072, 0.0233, 0.0038]), + (43.148, [0.0117, 0.0233, 0.0037]), + (43.947, [0.0115, 0.024, 0.0038]), + (44.746, [0.0116, 0.028, 0.0038]), + (46.344, [0.0116, 0.0266, 0.0037]), + (47.942, [0.0117, 0.0317, 0.0039]), + (48.741, [0.0112, 0.0314, 0.004]), + (49.54, [0.0155, 0.0309, 0.0041]), + (50.339, [0.0155, 0.0352, 0.0042]), + (51.937, [0.0154, 0.0346, 0.0042]), + (53.535, [0.0157, 0.0398, 0.0043]), + (54.334, [0.015, 0.0386, 0.0043]), + (55.933, [0.0199, 0.0436, 0.0043]), + (56.732, [0.0192, 0.0427, 0.0043]), + (58.33, [0.0193, 0.0477, 0.0042]), + (59.129, [0.0195, 0.0464, 0.0042]), + (59.928, [0.0188, 0.0484, 0.0042]), + (60.727, [0.0234, 0.0517, 0.0043]), + (61.526, [0.0233, 0.0503, 0.0042]), + (63.124, [0.0233, 0.0556, 0.0042]), + (63.923, [0.0235, 0.0539, 0.0041]), + (64.722, [0.0227, 0.0582, 0.004]), + (65.521, [0.0268, 0.0588, 0.0039]), + (66.32, [0.0273, 0.0585, 0.0037]), + (67.119, [0.0272, 0.0632, 0.0036]), + (67.918, [0.0272, 0.062, 0.0034]), + (69.516, [0.0274, 0.067, 0.0031]), + (70.315, [0.0317, 0.0659, 0.0031]), + (71.114, [0.031, 0.0705, 0.003]), + (72.712, [0.0301, 0.0725, 0.0029]), + (73.511, [0.0401, 0.0751, 0.0029]), + (74.31, [0.0623, 0.0795, 0.0028]), + (75.109, [0.074, 0.0862, 0.0028]), + (75.908, [0.088, 0.0888, 0.0029]), + (76.707, [0.1016, 0.0967, 0.0028]), + (77.507, [0.109, 0.0978, 0.0036]), + (78.306, [0.1027, 0.0961, 0.0001]), + (79.105, [0.0683, 0.047, -0.0007]), + (79.904, [0.0375, 0.0061, -0.0002]), + (80.703, [0.0265, 0.0049, -0.0002]), + (81.502, [0.0086, 0.0004, -0.0001]), + (82.301, [-0.0012, 0.0, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0006, 0.0031, -0.0003]), + (0.79916, [0.0004, -0.0007, -0.0004]), + (19.18, [0.0007, 0.0007, 0.0002]), + (142.25, [-0.0002, 0.0001, -0.0005]), + (143.05, [0.0038, 0.0001, 0.0091]), + (146.25, [0.0344, 0.0001, 0.0632]), + (147.05, [0.0432, 0.0092, 0.0746]), + (147.85, [0.0436, 0.0216, 0.0731]), + (150.24, [0.044, 0.0576, 0.0546]), + (151.04, [0.0513, 0.067, 0.045]), + (151.84, [0.0553, 0.0665, 0.0296]), + (154.24, [0.0737, 0.0509, 0.01]), + (155.04, [0.0721, 0.0405, 0.0027]), + (155.84, [0.0591, 0.0283, -0.0001]), + (159.03, [0.0053, 0.0019, 0.0]), + (159.83, [-0.0004, -0.0002, 0.0]), + ], + }, +]; +const ZOOM_100_STREAKS: &[Streak] = &[]; +const ZOOM_100_RAYS: &[Ray] = &[ + Ray { angle: 0.23, r0: 17.188, r1: 391.41, fade: 0.05, rgb: [0.0216, 0.0235, 0.0216] }, + Ray { angle: 7.19, r0: 6.25, r1: 107.03, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 15.13, r0: 6.25, r1: 93.75, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 16.46, r0: 85.938, r1: 439.06, fade: 0.05, rgb: [0.0255, 0.0275, 0.0275] }, + Ray { angle: 17.45, r0: 14.844, r1: 130.47, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 24.29, r0: 21.094, r1: 119.53, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 35.97, r0: 71.875, r1: 333.59, fade: 0.05, rgb: [0.0098, 0.0098, 0.0118] }, + Ray { angle: 38.66, r0: 24.219, r1: 89.062, fade: 0.05, rgb: [0.0118, 0.0245, 0.0235] }, + Ray { angle: 43.16, r0: 12.5, r1: 393.75, fade: 0.05, rgb: [0.0314, 0.0314, 0.0353] }, + Ray { angle: 53.39, r0: 13.281, r1: 120.31, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 55.79, r0: 46.875, r1: 152.34, fade: 0.05, rgb: [0.0098, 0.0118, 0.0098] }, + Ray { angle: 62.91, r0: 17.188, r1: 240.62, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 64.75, r0: 61.719, r1: 375.78, fade: 0.05, rgb: [0.0216, 0.0235, 0.0216] }, + Ray { angle: 70.1, r0: 14.062, r1: 132.03, fade: 0.05, rgb: [0.0176, 0.0265, 0.0255] }, + Ray { angle: 71.94, r0: 66.406, r1: 268.75, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 73.88, r0: 77.344, r1: 228.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.16, r0: 42.969, r1: 362.5, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 81.03, r0: 55.469, r1: 402.34, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 86.33, r0: 16.406, r1: 159.38, fade: 0.05, rgb: [0.0235, 0.0353, 0.0353] }, + Ray { angle: 91.74, r0: 6.25, r1: 100.78, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 93.37, r0: 6.25, r1: 404.69, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 104.35, r0: 26.562, r1: 370.31, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 109.69, r0: 91.406, r1: 182.81, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 110.89, r0: 14.062, r1: 137.5, fade: 0.05, rgb: [0.0157, 0.0255, 0.0275] }, + Ray { angle: 112.95, r0: 66.406, r1: 310.16, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.83, r0: 59.375, r1: 324.22, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 118.75, r0: 17.969, r1: 232.03, fade: 0.05, rgb: [0.0216, 0.0235, 0.0275] }, + Ray { angle: 129.16, r0: 64.844, r1: 357.03, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 131.16, r0: 29.688, r1: 285.94, fade: 0.05, rgb: [0.0255, 0.0275, 0.0294] }, + Ray { angle: 135.32, r0: 14.062, r1: 119.53, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 136.28, r0: 110.94, r1: 324.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 138.19, r0: 46.094, r1: 314.06, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 145.43, r0: 46.094, r1: 284.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 147.42, r0: 14.844, r1: 278.12, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 148.7, r0: 81.25, r1: 232.81, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 152.63, r0: 39.062, r1: 263.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 155.92, r0: 18.75, r1: 256.25, fade: 0.05, rgb: [0.0353, 0.0392, 0.0392] }, + Ray { angle: 161.66, r0: 14.844, r1: 246.88, fade: 0.05, rgb: [0.0333, 0.0392, 0.0392] }, + Ray { angle: 163.17, r0: 18.75, r1: 244.53, fade: 0.05, rgb: [0.0304, 0.0353, 0.0353] }, + Ray { angle: 163.42, r0: 74.219, r1: 175.78, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 168.82, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 172.26, r0: 35.938, r1: 128.91, fade: 0.05, rgb: [0.0186, 0.0255, 0.0255] }, + Ray { angle: 183.2, r0: 69.531, r1: 206.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 186.51, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 193.74, r0: 6.25, r1: 240.62, fade: 0.05, rgb: [0.0216, 0.0235, 0.0275] }, + Ray { angle: 201.0, r0: 72.656, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 202.78, r0: 70.312, r1: 250.0, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 208.02, r0: 11.719, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 213.31, r0: 24.219, r1: 279.69, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 217.14, r0: 38.281, r1: 293.75, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 218.24, r0: 21.875, r1: 136.72, fade: 0.05, rgb: [0.0069, 0.0098, 0.0108] }, + Ray { angle: 220.48, r0: 47.656, r1: 300.78, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 224.33, r0: 31.25, r1: 249.22, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 226.01, r0: 60.156, r1: 227.34, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 227.42, r0: 78.125, r1: 263.28, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 229.38, r0: 49.219, r1: 162.5, fade: 0.05, rgb: [0.0157, 0.0167, 0.0196] }, + Ray { angle: 232.04, r0: 47.656, r1: 133.59, fade: 0.05, rgb: [0.0196, 0.0255, 0.0255] }, + Ray { angle: 234.58, r0: 23.438, r1: 175.78, fade: 0.05, rgb: [0.0235, 0.0314, 0.0353] }, + Ray { angle: 240.27, r0: 12.5, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0176, 0.0157] }, + Ray { angle: 241.7, r0: 75.781, r1: 221.09, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 243.57, r0: 62.5, r1: 200.78, fade: 0.05, rgb: [0.0235, 0.0235, 0.0275] }, + Ray { angle: 245.61, r0: 56.25, r1: 214.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 250.58, r0: 27.344, r1: 152.34, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 250.85, r0: 28.125, r1: 206.25, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 252.54, r0: 63.281, r1: 142.97, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 254.64, r0: 60.938, r1: 154.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 256.16, r0: 81.25, r1: 193.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 259.94, r0: 28.906, r1: 197.66, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 263.21, r0: 86.719, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 265.17, r0: 85.938, r1: 193.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 266.91, r0: 47.656, r1: 117.19, fade: 0.05, rgb: [0.0118, 0.0137, 0.0157] }, + Ray { angle: 269.02, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 276.19, r0: 14.844, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 283.24, r0: 57.812, r1: 198.44, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 284.74, r0: 98.438, r1: 201.56, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 288.62, r0: 81.25, r1: 205.47, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 295.78, r0: 27.344, r1: 216.41, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 297.53, r0: 92.969, r1: 107.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 299.12, r0: 83.594, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 301.07, r0: 87.5, r1: 220.31, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 302.89, r0: 35.938, r1: 232.03, fade: 0.05, rgb: [0.0255, 0.0304, 0.0314] }, + Ray { angle: 306.25, r0: 96.094, r1: 219.53, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 310.14, r0: 50.0, r1: 255.47, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 317.22, r0: 51.562, r1: 106.25, fade: 0.05, rgb: [0.0118, 0.0196, 0.0196] }, + Ray { angle: 319.11, r0: 67.969, r1: 297.66, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 320.67, r0: 37.5, r1: 307.81, fade: 0.05, rgb: [0.0235, 0.0235, 0.0275] }, + Ray { angle: 322.54, r0: 9.375, r1: 321.09, fade: 0.05, rgb: [0.0196, 0.0235, 0.0255] }, + Ray { angle: 324.44, r0: 75.0, r1: 157.03, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 340.57, r0: 12.5, r1: 304.69, fade: 0.05, rgb: [0.0314, 0.0314, 0.0353] }, + Ray { angle: 345.9, r0: 23.438, r1: 228.91, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, +]; +const ZOOM_125_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.2682, 0.5198, 0.6493]), + (0.8, [0.2901, 0.5275, 0.6554]), + (1.6, [0.2799, 0.5261, 0.6544]), + (5.6, [0.2707, 0.5232, 0.6565]), + (10.4, [0.2554, 0.5004, 0.64]), + (15.2, [0.2351, 0.4638, 0.6029]), + (22.4, [0.2074, 0.3841, 0.5262]), + (29.6, [0.209, 0.3165, 0.4518]), + (32.0, [0.2173, 0.3142, 0.4382]), + (36.8, [0.2534, 0.3174, 0.4106]), + (44.0, [0.2785, 0.3014, 0.3718]), + (52.8, [0.2977, 0.2708, 0.3168]), + (58.4, [0.2959, 0.2482, 0.2857]), + (62.4, [0.3022, 0.2333, 0.2663]), + (63.2, [0.3137, 0.2348, 0.2634]), + (65.6, [0.3826, 0.2514, 0.2619]), + (66.4, [0.4138, 0.2552, 0.26]), + (67.2, [0.4269, 0.2566, 0.2585]), + (68.0, [0.4285, 0.255, 0.2544]), + (72.0, [0.3137, 0.2073, 0.2302]), + (75.2, [0.3129, 0.1977, 0.2115]), + (80.0, [0.3086, 0.1787, 0.1933]), + (86.4, [0.2963, 0.1488, 0.1876]), + (88.8, [0.2896, 0.1414, 0.1796]), + (91.2, [0.2912, 0.1289, 0.1736]), + (92.8, [0.2957, 0.1208, 0.1639]), + (96.0, [0.2955, 0.1117, 0.1594]), + (97.6, [0.2964, 0.1018, 0.1551]), + (100.8, [0.2908, 0.0934, 0.1515]), + (104.8, [0.2843, 0.0836, 0.1407]), + (107.2, [0.2796, 0.0831, 0.1366]), + (109.6, [0.2755, 0.0786, 0.1297]), + (111.2, [0.2793, 0.0754, 0.1256]), + (113.6, [0.2746, 0.0745, 0.1171]), + (114.4, [0.2718, 0.0752, 0.1095]), + (115.2, [0.2687, 0.0768, 0.1066]), + (116.8, [0.2658, 0.0729, 0.102]), + (122.4, [0.2523, 0.0759, 0.0848]), + (124.0, [0.2509, 0.0794, 0.0848]), + (125.6, [0.2477, 0.076, 0.081]), + (128.0, [0.2449, 0.0783, 0.0797]), + (130.4, [0.2346, 0.0847, 0.0768]), + (134.4, [0.2194, 0.0923, 0.0808]), + (137.6, [0.2194, 0.0932, 0.0813]), + (141.6, [0.2123, 0.0955, 0.0734]), + (145.6, [0.2077, 0.0944, 0.0698]), + (147.2, [0.2045, 0.0974, 0.0654]), + (148.8, [0.2023, 0.0919, 0.0656]), + (150.4, [0.1985, 0.0943, 0.0571]), + (154.4, [0.1872, 0.0914, 0.0503]), + (155.2, [0.1822, 0.092, 0.0463]), + (157.6, [0.1773, 0.0921, 0.045]), + (158.4, [0.177, 0.0921, 0.0419]), + (160.0, [0.1732, 0.0916, 0.0427]), + (161.6, [0.1707, 0.0887, 0.0395]), + (163.2, [0.165, 0.0882, 0.0407]), + (164.0, [0.165, 0.0853, 0.0378]), + (166.4, [0.1576, 0.0859, 0.0391]), + (167.2, [0.158, 0.0826, 0.0362]), + (168.8, [0.1534, 0.0828, 0.0375]), + (170.4, [0.1529, 0.0794, 0.0345]), + (171.2, [0.1493, 0.0797, 0.0353]), + (172.0, [0.1493, 0.0799, 0.0363]), + (173.6, [0.1484, 0.0766, 0.0336]), + (175.2, [0.15, 0.0771, 0.0352]), + (176.0, [0.1508, 0.0771, 0.0323]), + (176.8, [0.1483, 0.0736, 0.0324]), + (178.4, [0.1509, 0.0736, 0.0337]), + (179.2, [0.1496, 0.0741, 0.0309]), + (180.0, [0.1486, 0.0708, 0.0312]), + (181.6, [0.1488, 0.071, 0.0317]), + (182.4, [0.1454, 0.0716, 0.0294]), + (184.8, [0.1447, 0.0687, 0.0314]), + (185.6, [0.1421, 0.0693, 0.0286]), + (187.2, [0.143, 0.066, 0.0302]), + (188.8, [0.1392, 0.0671, 0.0278]), + (191.2, [0.1367, 0.0653, 0.0294]), + (192.0, [0.1371, 0.0657, 0.0271]), + (194.4, [0.1344, 0.0641, 0.0291]), + (195.2, [0.1348, 0.0644, 0.0265]), + (197.6, [0.1325, 0.0628, 0.0282]), + (198.4, [0.1333, 0.0641, 0.0258]), + (200.0, [0.1313, 0.0658, 0.0275]), + (200.8, [0.1312, 0.0632, 0.0282]), + (201.6, [0.1319, 0.0635, 0.0253]), + (203.2, [0.1301, 0.0656, 0.0267]), + (204.0, [0.1304, 0.0636, 0.0281]), + (204.8, [0.131, 0.0632, 0.0258]), + (207.2, [0.1298, 0.0661, 0.0271]), + (208.0, [0.1304, 0.0634, 0.0256]), + (210.4, [0.1293, 0.0668, 0.0264]), + (212.0, [0.131, 0.0647, 0.0239]), + (214.4, [0.1307, 0.0687, 0.026]), + (215.2, [0.1317, 0.0667, 0.0233]), + (216.0, [0.1291, 0.0668, 0.0247]), + (217.6, [0.1314, 0.0692, 0.0263]), + (218.4, [0.133, 0.0709, 0.0246]), + (219.2, [0.1309, 0.0686, 0.0235]), + (221.6, [0.1346, 0.0723, 0.0256]), + (222.4, [0.1324, 0.0727, 0.0229]), + (223.2, [0.1338, 0.0709, 0.0242]), + (224.8, [0.1368, 0.0736, 0.0258]), + (225.6, [0.1359, 0.0754, 0.0232]), + (227.2, [0.1373, 0.0741, 0.0242]), + (228.8, [0.1383, 0.0767, 0.0251]), + (229.6, [0.137, 0.0785, 0.0224]), + (232.0, [0.1405, 0.079, 0.0251]), + (233.6, [0.1407, 0.0819, 0.0222]), + (236.0, [0.1413, 0.0822, 0.0244]), + (236.8, [0.1422, 0.0831, 0.0214]), + (240.0, [0.1432, 0.0852, 0.024]), + (240.8, [0.1449, 0.0861, 0.0214]), + (243.2, [0.1446, 0.0864, 0.0245]), + (244.8, [0.1472, 0.0895, 0.0223]), + (246.4, [0.1489, 0.0925, 0.0246]), + (247.2, [0.147, 0.0902, 0.0261]), + (248.8, [0.1454, 0.0927, 0.0242]), + (251.2, [0.1395, 0.0945, 0.0283]), + (252.8, [0.1378, 0.0954, 0.0266]), + (255.2, [0.1314, 0.0985, 0.031]), + (256.0, [0.13, 0.0962, 0.0308]), + (256.8, [0.1297, 0.0978, 0.0293]), + (259.2, [0.1229, 0.1018, 0.0336]), + (260.0, [0.1216, 0.0996, 0.0339]), + (260.8, [0.1211, 0.1004, 0.0319]), + (261.6, [0.1166, 0.1018, 0.0336]), + (264.0, [0.1133, 0.1057, 0.037]), + (264.8, [0.1129, 0.1036, 0.0343]), + (265.6, [0.1081, 0.1033, 0.0349]), + (268.0, [0.1051, 0.1018, 0.0381]), + (268.8, [0.1043, 0.0989, 0.0357]), + (269.6, [0.0996, 0.0963, 0.0356]), + (272.0, [0.0962, 0.0941, 0.0383]), + (273.6, [0.0905, 0.0907, 0.0357]), + (276.8, [0.087, 0.0869, 0.0396]), + (277.6, [0.0824, 0.0868, 0.037]), + (279.2, [0.0806, 0.0817, 0.0382]), + (280.8, [0.0792, 0.0808, 0.0398]), + (281.6, [0.0755, 0.0802, 0.0405]), + (282.4, [0.0736, 0.0801, 0.0378]), + (284.8, [0.0718, 0.0754, 0.0407]), + (286.4, [0.0665, 0.0743, 0.0408]), + (288.8, [0.0648, 0.0695, 0.042]), + (289.6, [0.0645, 0.0698, 0.0429]), + (290.4, [0.0606, 0.0694, 0.0443]), + (292.0, [0.0599, 0.069, 0.0424]), + (294.4, [0.059, 0.066, 0.0462]), + (295.2, [0.0559, 0.0662, 0.0479]), + (296.8, [0.0562, 0.0665, 0.0466]), + (298.4, [0.0562, 0.0667, 0.0499]), + (299.2, [0.0523, 0.0635, 0.0513]), + (303.2, [0.0497, 0.064, 0.0575]), + (304.0, [0.0454, 0.0615, 0.0591]), + (304.8, [0.0453, 0.0597, 0.0609]), + (308.0, [0.0427, 0.0595, 0.0614]), + (308.8, [0.0389, 0.0601, 0.0621]), + (310.4, [0.0388, 0.0569, 0.0633]), + (312.8, [0.0381, 0.0584, 0.0609]), + (313.6, [0.0341, 0.0584, 0.061]), + (314.4, [0.034, 0.059, 0.061]), + (316.0, [0.0326, 0.0548, 0.0606]), + (316.8, [0.0318, 0.0547, 0.0568]), + (320.8, [0.0255, 0.052, 0.0536]), + (321.6, [0.0245, 0.0487, 0.0521]), + (322.4, [0.0241, 0.0489, 0.0485]), + (324.0, [0.0191, 0.0479, 0.048]), + (326.4, [0.0176, 0.0471, 0.0478]), + (327.2, [0.0169, 0.0437, 0.0484]), + (328.0, [0.0165, 0.0425, 0.0453]), + (328.8, [0.0125, 0.0422, 0.046]), + (332.8, [0.0103, 0.0405, 0.0492]), + (333.6, [0.0096, 0.0368, 0.0464]), + (334.4, [0.0061, 0.0364, 0.0469]), + (339.2, [0.0052, 0.035, 0.0494]), + (340.0, [0.0013, 0.0323, 0.0475]), + (344.8, [0.0006, 0.029, 0.0501]), + (346.4, [-0.0034, 0.028, 0.0466]), + (347.2, [-0.0032, 0.0243, 0.0471]), + (351.2, [-0.0033, 0.0231, 0.0471]), + (352.0, [-0.0068, 0.0228, 0.0471]), + (352.8, [-0.0069, 0.0225, 0.0439]), + (353.6, [-0.0069, 0.0225, 0.0444]), + (354.4, [-0.007, 0.0189, 0.0443]), + (356.8, [-0.007, 0.0184, 0.0444]), + (358.4, [-0.0105, 0.018, 0.045]), + (360.0, [-0.0102, 0.0175, 0.0415]), + (364.0, [-0.01, 0.0138, 0.0421]), + (364.8, [-0.013, 0.0137, 0.0422]), + (368.0, [-0.0123, 0.0131, 0.0396]), + (369.6, [-0.012, 0.0097, 0.0397]), + (371.2, [-0.0122, 0.0099, 0.0399]), + (372.0, [-0.0147, 0.0098, 0.04]), + (376.8, [-0.0131, 0.0097, 0.0375]), + (378.4, [-0.0124, 0.0063, 0.0377]), + (379.2, [-0.0154, 0.0065, 0.0378]), + (381.6, [-0.0145, 0.0065, 0.0381]), + (382.4, [-0.0142, 0.0065, 0.035]), + (385.6, [-0.013, 0.0064, 0.0355]), + (387.2, [-0.0156, 0.0033, 0.0356]), + (390.4, [-0.0142, 0.0036, 0.0362]), + (392.0, [-0.0137, 0.0036, 0.0329]), + (394.4, [-0.0127, 0.0035, 0.0332]), + (396.0, [-0.0155, 0.0038, 0.0332]), + (396.8, [-0.0149, 0.0008, 0.0332]), + (397.6, [-0.0147, 0.0005, 0.0332]), + (400.0, [-0.0139, 0.0005, 0.0334]), + (400.8, [-0.0137, 0.0005, 0.0303]), + (403.2, [-0.0125, 0.0004, 0.0298]), + (404.0, [-0.015, 0.0003, 0.0296]), + (407.2, [-0.0146, 0.0001, 0.029]), + (408.0, [-0.0144, -0.0033, 0.0289]), + (410.4, [-0.0137, -0.0031, 0.0283]), + (411.2, [-0.0135, -0.0032, 0.025]), + (413.6, [-0.0126, -0.0034, 0.025]), + (414.4, [-0.0158, -0.0035, 0.0248]), + (419.2, [-0.0144, -0.0036, 0.024]), + (420.0, [-0.0142, -0.0066, 0.0239]), + (422.4, [-0.0136, -0.0075, 0.0239]), + (424.0, [-0.0133, -0.0077, 0.0198]), + (425.6, [-0.0136, -0.0079, 0.0197]), + (426.4, [-0.0164, -0.008, 0.0196]), + (433.6, [-0.0144, -0.0089, 0.0178]), + (434.4, [-0.0143, -0.0094, 0.0176]), + (435.2, [-0.0142, -0.0131, 0.0173]), + (436.0, [-0.014, -0.0126, 0.0171]), + (436.8, [-0.0139, -0.0129, 0.0167]), + (437.6, [-0.0138, -0.013, 0.0129]), + (438.4, [-0.0137, -0.0131, 0.013]), + (439.2, [-0.014, -0.0132, 0.0127]), + (440.0, [-0.0172, -0.0133, 0.0124]), + (452.8, [-0.0145, -0.0142, 0.0087]), + (453.6, [-0.0144, -0.0145, 0.0085]), + (454.4, [-0.0143, -0.0181, 0.0085]), + (456.0, [-0.014, -0.0177, 0.0041]), + (456.8, [-0.0139, -0.0178, 0.0044]), + (457.6, [-0.0171, -0.0179, 0.004]), + (465.6, [-0.0165, -0.0187, 0.0015]), + (472.0, [-0.0155, -0.0199, -0.0036]), + (478.4, [-0.0191, -0.0235, -0.0093]), + (487.2, [-0.0234, -0.0238, -0.0149]), + (494.4, [-0.028, -0.023, -0.0158]), + (503.2, [-0.0323, -0.0206, -0.0138]), + (519.2, [-0.0298, -0.0131, -0.0055]), + (532.0, [-0.0215, -0.0067, -0.0011]), + (541.6, [-0.013, -0.0024, -0.0008]), + (552.8, [-0.0015, 0.0041, -0.0049]), + (560.0, [0.006, 0.0099, -0.0103]), + (565.6, [0.0132, 0.015, -0.0142]), + (569.6, [0.0198, 0.0186, -0.0146]), + (574.4, [0.0295, 0.0229, -0.0118]), + (582.4, [0.0501, 0.0296, 0.0]), + (587.2, [0.061, 0.0312, 0.0045]), + (593.6, [0.0644, 0.0258, 0.0025]), + (596.8, [0.0674, 0.0235, 0.0098]), + (598.4, [0.0717, 0.0257, 0.0133]), + (600.0, [0.0776, 0.0364, 0.0134]), + (601.6, [0.0768, 0.0441, 0.0076]), + (603.2, [0.0755, 0.0489, -0.0019]), + (604.8, [0.0753, 0.0459, -0.0105]), + (606.4, [0.0688, 0.0367, -0.0168]), + (608.0, [0.0514, 0.0229, -0.0215]), + (610.4, [0.0187, 0.004, -0.0259]), + (611.2, [0.0089, -0.0008, -0.0265]), + (612.0, [0.0016, -0.0036, -0.0266]), + (613.6, [-0.0069, -0.0066, -0.0255]), + (615.2, [-0.0106, -0.0075, -0.0219]), + (618.4, [-0.0089, -0.0064, -0.0094]), + (620.8, [-0.0089, -0.0053, -0.0047]), + (624.0, [-0.012, -0.005, -0.0036]), + (628.0, [-0.0146, -0.0054, -0.0088]), + (630.4, [-0.0127, -0.0036, -0.0109]), + (636.8, [0.002, 0.0016, 0.0]), + (638.4, [0.0017, 0.0009, 0.0005]), + (642.4, [-0.0052, -0.0027, 0.002]), + (645.6, [-0.0038, -0.0022, 0.0077]), + (648.0, [0.0016, -0.0013, 0.0107]), + (649.6, [0.0022, -0.001, 0.0092]), + (652.8, [-0.0027, -0.0007, 0.0051]), + (656.0, [0.0035, 0.0017, 0.0042]), + (659.2, [-0.0016, 0.0006, -0.0031]), + (662.4, [0.0016, 0.0004, -0.0031]), + (664.8, [-0.0026, -0.0014, -0.0055]), + (667.2, [0.0007, -0.0007, -0.0029]), + (669.6, [-0.0023, -0.0009, -0.0028]), + (671.2, [-0.0014, -0.0004, -0.0017]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.6284, 0.4495, 0.3403]), + (0.8, [0.6645, 0.447, 0.3431]), + (9.6, [0.6821, 0.4406, 0.3293]), + (21.6, [0.6861, 0.4055, 0.2954]), + (30.4, [0.6289, 0.3672, 0.2709]), + (37.6, [0.539, 0.2937, 0.2389]), + (47.2, [0.4453, 0.2355, 0.2112]), + (53.6, [0.3872, 0.2122, 0.2006]), + (62.4, [0.3261, 0.1874, 0.1902]), + (67.2, [0.3239, 0.1828, 0.1811]), + (72.0, [0.2497, 0.1616, 0.1764]), + (76.8, [0.2283, 0.1603, 0.1794]), + (82.4, [0.2046, 0.172, 0.1818]), + (88.8, [0.1939, 0.1879, 0.1789]), + (96.0, [0.156, 0.202, 0.1829]), + (105.6, [0.1299, 0.2129, 0.1814]), + (108.8, [0.1229, 0.2103, 0.1817]), + (112.0, [0.1125, 0.2075, 0.1885]), + (120.8, [0.1001, 0.1919, 0.2022]), + (128.8, [0.088, 0.1722, 0.1979]), + (135.2, [0.0903, 0.1543, 0.1852]), + (138.4, [0.0853, 0.1464, 0.1809]), + (147.2, [0.0684, 0.1324, 0.1809]), + (157.6, [0.071, 0.1176, 0.1856]), + (160.0, [0.0719, 0.118, 0.185]), + (173.6, [0.0723, 0.1132, 0.1734]), + (179.2, [0.0641, 0.1124, 0.1687]), + (189.6, [0.0621, 0.1071, 0.159]), + (199.2, [0.0563, 0.0985, 0.1492]), + (215.2, [0.0388, 0.0777, 0.1325]), + (246.4, [-0.0148, 0.0243, 0.1006]), + (264.8, [0.0051, -0.0057, 0.0718]), + (280.8, [0.023, 0.0057, 0.0547]), + (287.2, [0.0274, 0.0089, 0.0465]), + (295.2, [0.0292, 0.0081, 0.0355]), + (299.2, [0.029, 0.0066, 0.0273]), + (305.6, [0.0331, 0.0068, 0.0122]), + (311.2, [0.0349, 0.0048, 0.007]), + (315.2, [0.0364, 0.0032, 0.0067]), + (326.4, [0.044, 0.0081, 0.0119]), + (341.6, [0.0488, 0.0123, -0.0011]), + (356.8, [0.0493, 0.0164, -0.0041]), + (368.0, [0.0462, 0.0182, -0.0071]), + (400.8, [0.0319, 0.0146, -0.0135]), + (414.4, [0.026, 0.0147, -0.0119]), + (437.6, [0.0205, 0.0163, -0.0082]), + (463.2, [0.0156, 0.0179, -0.0014]), + (469.6, [0.0144, 0.0189, 0.0028]), + (480.8, [0.0197, 0.0233, 0.0126]), + (492.0, [0.0261, 0.0227, 0.0166]), + (504.0, [0.0315, 0.0194, 0.0135]), + (518.4, [0.0289, 0.0126, 0.0062]), + (531.2, [0.0207, 0.0064, 0.0019]), + (540.8, [0.0123, 0.0021, 0.0015]), + (552.0, [0.0009, -0.0043, 0.0057]), + (559.2, [-0.0064, -0.0101, 0.011]), + (568.8, [-0.0201, -0.0186, 0.0153]), + (573.6, [-0.0297, -0.0229, 0.0125]), + (581.6, [-0.0501, -0.0296, 0.0007]), + (586.4, [-0.0611, -0.0312, -0.0039]), + (592.8, [-0.0644, -0.0258, -0.002]), + (596.0, [-0.0676, -0.0236, -0.0096]), + (597.6, [-0.0724, -0.0262, -0.0132]), + (599.2, [-0.0782, -0.0371, -0.0137]), + (600.0, [-0.0776, -0.0411, -0.0114]), + (602.4, [-0.0757, -0.0494, 0.0021]), + (604.0, [-0.0758, -0.046, 0.0107]), + (604.8, [-0.0733, -0.0414, 0.0142]), + (606.4, [-0.061, -0.0301, 0.0196]), + (609.6, [-0.0179, -0.0036, 0.0261]), + (611.2, [-0.0009, 0.0039, 0.0266]), + (612.8, [0.0072, 0.0066, 0.0253]), + (614.4, [0.0105, 0.0074, 0.0215]), + (617.6, [0.0086, 0.0062, 0.009]), + (620.0, [0.0088, 0.0051, 0.0046]), + (623.2, [0.012, 0.005, 0.0037]), + (626.4, [0.0143, 0.0054, 0.0077]), + (629.6, [0.0124, 0.0034, 0.0109]), + (633.6, [0.0025, -0.0008, 0.0027]), + (636.0, [-0.0022, -0.0017, 0.0]), + (637.6, [-0.0016, -0.0009, -0.0004]), + (641.6, [0.0052, 0.0027, -0.0022]), + (644.8, [0.0036, 0.0021, -0.008]), + (647.2, [-0.0019, 0.0012, -0.0107]), + (648.8, [-0.0021, 0.001, -0.009]), + (651.2, [0.0026, 0.001, -0.0052]), + (652.0, [0.0025, 0.0006, -0.0052]), + (653.6, [-0.0012, -0.0008, -0.0058]), + (655.2, [-0.0035, -0.0018, -0.0039]), + (658.4, [0.0015, -0.0006, 0.0032]), + (660.8, [-0.0016, -0.0006, 0.0029]), + (664.0, [0.0026, 0.0014, 0.0056]), + (666.4, [-0.0008, 0.0006, 0.0029]), + (668.8, [0.0024, 0.0009, 0.0029]), + (670.4, [0.0012, 0.0004, 0.0016]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0468, 0.0227, 0.0022]), + (0.79967, [0.0514, 0.0212, -0.0033]), + (1.5993, [0.0539, 0.0277, 0.0046]), + (4.798, [0.0521, 0.0244, 0.0003]), + (6.3973, [0.0525, 0.0255, 0.0015]), + (7.197, [0.0522, 0.0239, 0.0003]), + (10.396, [0.0557, 0.0275, 0.0029]), + (12.795, [0.0542, 0.0259, 0.0008]), + (27.988, [0.0522, 0.0246, 0.0005]), + (30.387, [0.0534, 0.0258, 0.0019]), + (59.175, [0.0518, 0.0234, -0.0001]), + (75.169, [0.0538, 0.0267, 0.0026]), + (114.35, [0.0533, 0.0257, 0.0015]), + (116.75, [0.0524, 0.0258, 0.0016]), + (119.15, [0.0414, 0.019, 0.0014]), + (119.95, [0.0394, 0.0191, 0.0015]), + (271.09, [0.0383, 0.0174, 0.0004]), + (271.89, [0.0361, 0.018, 0.0004]), + (276.68, [0.0136, 0.0082, 0.0002]), + (279.08, [0.0031, 0.0008, 0.0003]), + (279.88, [0.0012, 0.0013, 0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0033, -0.0132, -0.0096]), + (0.79036, [0.0027, 0.006, 0.0052]), + (2.3711, [0.0026, 0.0038, 0.0034]), + (5.5325, [0.0042, 0.0066, 0.0058]), + (7.9036, [0.0038, 0.005, 0.0037]), + (11.065, [0.0039, 0.0083, 0.0035]), + (11.855, [0.0036, 0.0098, 0.0043]), + (12.646, [0.0062, 0.0116, 0.0057]), + (15.807, [0.0056, 0.0172, 0.0079]), + (20.549, [0.0089, 0.032, 0.017]), + (22.921, [0.0131, 0.0402, 0.0213]), + (23.711, [0.018, 0.043, 0.0221]), + (26.082, [0.0362, 0.0586, 0.0266]), + (27.663, [0.0434, 0.066, 0.0301]), + (29.243, [0.0355, 0.0701, 0.0333]), + (30.034, [0.0277, 0.0575, 0.0269]), + (30.824, [0.0145, 0.0217, 0.0114]), + (31.615, [0.0049, 0.0025, 0.0018]), + (32.405, [0.0014, -0.0002, -0.0006]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [0.0006, 0.0416, 0.0902]), + (0.79858, [0.0028, 0.0479, 0.1026]), + (2.3957, [0.0036, 0.0481, 0.1051]), + (16.77, [0.0023, 0.0471, 0.098]), + (26.353, [0.0007, 0.0221, 0.0509]), + (27.95, [0.0005, 0.015, 0.0373]), + (31.145, [0.0009, 0.0146, 0.034]), + (35.936, [-0.0009, 0.0136, 0.0329]), + (43.123, [-0.0002, 0.0135, 0.033]), + (44.721, [-0.0003, 0.0087, 0.0215]), + (46.318, [-0.0002, 0.0053, 0.0146]), + (51.109, [-0.0003, -0.0002, 0.0007]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1487, 0.0636, -0.0151]), + (0.79635, [0.1384, 0.0644, -0.0109]), + (1.5927, [0.1415, 0.0646, -0.0141]), + (3.9818, [0.1422, 0.0685, -0.0035]), + (7.1672, [0.142, 0.0703, 0.0003]), + (8.7599, [0.1476, 0.0719, 0.0012]), + (10.353, [0.1539, 0.076, 0.0061]), + (11.945, [0.1581, 0.0769, 0.006]), + (13.538, [0.1536, 0.0761, 0.0054]), + (14.334, [0.1321, 0.0645, 0.0023]), + (15.131, [0.0522, 0.0248, -0.0006]), + (15.927, [0.0019, 0.0004, -0.0013]), + (17.52, [-0.0001, -0.0002, -0.0013]), + (19.112, [-0.0002, 0.0014, 0.0017]), + (22.298, [0.0003, 0.0023, 0.003]), + (31.058, [0.0008, 0.0016, 0.0005]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.2441, 0.6242, 0.4724]), + (0.76482, [0.1522, 0.3913, 0.2897]), + (1.5296, [0.127, 0.3249, 0.246]), + (2.2945, [0.0437, 0.1118, 0.0815]), + (3.0593, [0.0037, 0.0069, 0.0061]), + (3.8241, [-0.0007, -0.0023, -0.0011]), + (4.5889, [0.0002, -0.0004, 0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.2921, 0.671, 0.6166]), + (0.79661, [0.2265, 0.5314, 0.491]), + (1.5932, [0.1996, 0.4838, 0.4456]), + (2.3898, [0.1184, 0.3213, 0.307]), + (3.1864, [0.0804, 0.2213, 0.2194]), + (3.983, [0.0662, 0.1735, 0.17]), + (4.7796, [0.0513, 0.1407, 0.1418]), + (5.5762, [0.0413, 0.1066, 0.1116]), + (6.3728, [0.0249, 0.0711, 0.0776]), + (7.1694, [0.018, 0.048, 0.056]), + (7.9661, [0.0102, 0.0284, 0.0358]), + (8.7627, [0.0052, 0.0128, 0.0223]), + (9.5593, [0.002, 0.0014, 0.0105]), + (18.322, [0.0019, 0.001, 0.0023]), + (19.915, [-0.0013, -0.0005, 0.0016]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.4333, 0.3028, 0.5255]), + (3.1881, [0.4314, 0.3001, 0.5178]), + (6.3762, [0.4207, 0.2928, 0.4969]), + (11.158, [0.3968, 0.278, 0.4383]), + (11.955, [0.3846, 0.2711, 0.4258]), + (17.535, [0.3149, 0.228, 0.3184]), + (23.114, [0.2517, 0.193, 0.1768]), + (24.708, [0.2385, 0.1828, 0.137]), + (27.099, [0.2204, 0.1717, 0.0823]), + (28.693, [0.2076, 0.1639, 0.0495]), + (30.287, [0.1968, 0.1526, 0.0207]), + (31.881, [0.1857, 0.1447, -0.004]), + (33.475, [0.1775, 0.1358, -0.0234]), + (35.069, [0.1702, 0.1268, -0.0377]), + (37.46, [0.161, 0.1157, -0.0486]), + (39.851, [0.1513, 0.1054, -0.0481]), + (42.242, [0.1462, 0.1003, -0.0398]), + (47.025, [0.141, 0.0907, -0.0151]), + (50.213, [0.1454, 0.089, -0.0006]), + (51.807, [0.1453, 0.0897, 0.0045]), + (53.401, [0.1511, 0.0919, 0.0086]), + (54.198, [0.1422, 0.0859, 0.0073]), + (54.995, [0.0584, 0.0368, 0.0065]), + (55.792, [0.002, 0.0083, 0.0069]), + (56.589, [0.005, 0.0084, 0.0072]), + (62.168, [0.0033, 0.0045, 0.0048]), + (81.297, [0.0009, 0.0018, 0.0011]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [-0.0408, -0.0358, -0.1754]), + (0.7959, [-0.0348, -0.0337, -0.1719]), + (1.5918, [-0.0355, -0.0336, -0.1721]), + (3.1836, [-0.0263, -0.0276, -0.1694]), + (4.7754, [-0.0321, -0.0306, -0.1659]), + (6.3672, [-0.0298, -0.0304, -0.1611]), + (8.7549, [-0.0241, -0.0278, -0.153]), + (12.734, [-0.0093, -0.0197, -0.1333]), + (16.714, [0.0085, -0.0097, -0.1044]), + (21.489, [0.029, 0.0025, -0.0622]), + (25.469, [0.0409, 0.0109, -0.0253]), + (29.448, [0.0528, 0.017, 0.0018]), + (30.244, [0.029, 0.0037, 0.006]), + (31.04, [-0.0217, -0.0245, 0.0093]), + (31.836, [-0.0355, -0.0324, 0.0129]), + (35.019, [-0.0279, -0.0248, 0.0213]), + (37.407, [-0.0229, -0.0197, 0.0228]), + (39.795, [-0.0193, -0.0166, 0.0207]), + (45.366, [-0.0083, -0.008, 0.0077]), + (46.162, [-0.0074, -0.0072, 0.0061]), + (47.754, [-0.0101, -0.0079, 0.0029]), + (48.55, [-0.0062, -0.0058, 0.0019]), + (51.733, [-0.003, -0.0027, -0.0004]), + (52.529, [-0.0006, -0.0006, 0.0001]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-0.1721, -0.142, -0.3734]), + (0.79582, [-0.1751, -0.1397, -0.3732]), + (7.1624, [-0.1657, -0.1332, -0.3506]), + (10.346, [-0.154, -0.124, -0.3246]), + (13.529, [-0.132, -0.1112, -0.2895]), + (14.325, [-0.1379, -0.1167, -0.2814]), + (15.121, [-0.168, -0.1351, -0.2712]), + (15.916, [-0.1922, -0.1507, -0.2607]), + (16.712, [-0.1889, -0.1496, -0.2494]), + (19.895, [-0.1597, -0.1297, -0.2004]), + (23.875, [-0.1239, -0.1091, -0.1277]), + (27.854, [-0.0957, -0.0925, -0.0588]), + (29.445, [-0.0863, -0.0853, -0.0356]), + (31.833, [-0.0726, -0.0734, -0.0057]), + (33.424, [-0.0635, -0.0656, 0.0104]), + (35.016, [-0.0552, -0.058, 0.0226]), + (36.608, [-0.047, -0.0505, 0.0312]), + (38.995, [-0.0358, -0.0396, 0.0365]), + (41.383, [-0.0257, -0.0312, 0.0338]), + (42.178, [-0.0217, -0.0285, 0.0317]), + (45.362, [-0.0131, -0.0211, 0.0196]), + (49.341, [-0.0055, -0.0134, 0.0039]), + (50.932, [0.0025, -0.0071, -0.0009]), + (53.32, [-0.0019, -0.0077, -0.0065]), + (58.095, [-0.007, -0.0093, -0.0082]), + (58.891, [-0.0071, -0.0085, -0.0076]), + (60.482, [-0.0022, -0.0052, -0.0068]), + (78.786, [-0.0007, -0.0019, -0.0009]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0479, 0.1573, 0.4578]), + (0.7948, [0.0448, 0.1508, 0.4521]), + (3.1792, [0.0417, 0.1482, 0.4324]), + (9.5376, [0.0385, 0.1288, 0.2707]), + (13.512, [0.0419, 0.1258, 0.1876]), + (15.101, [0.044, 0.1257, 0.1612]), + (17.486, [0.0391, 0.1239, 0.1325]), + (18.28, [0.0398, 0.1238, 0.1243]), + (21.46, [0.0379, 0.1199, 0.1034]), + (22.254, [0.0274, 0.0862, 0.0768]), + (23.049, [0.0103, 0.0275, 0.0317]), + (23.844, [0.0041, 0.0042, 0.0102]), + (24.639, [0.0022, 0.0005, 0.0068]), + (25.434, [0.0013, 0.0, 0.0057]), + (36.561, [0.001, 0.0005, 0.0017]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0037, 0.0041, 0.0042]), + (20.775, [0.0037, 0.0035, 0.0041]), + (21.574, [0.0038, 0.0078, 0.004]), + (25.569, [0.0037, 0.0076, 0.0041]), + (26.368, [0.0037, 0.0115, 0.0041]), + (29.564, [0.0037, 0.0114, 0.004]), + (30.363, [0.0039, 0.0154, 0.004]), + (31.162, [0.0032, 0.0158, 0.004]), + (31.961, [0.0067, 0.0158, 0.004]), + (33.56, [0.0076, 0.0164, 0.004]), + (34.359, [0.0076, 0.0201, 0.004]), + (35.957, [0.0076, 0.0193, 0.0039]), + (37.555, [0.0077, 0.024, 0.0039]), + (38.354, [0.0073, 0.0237, 0.0039]), + (39.153, [0.011, 0.0233, 0.0038]), + (39.952, [0.0116, 0.0274, 0.0039]), + (42.349, [0.0116, 0.0279, 0.0038]), + (43.148, [0.0116, 0.0318, 0.0038]), + (44.746, [0.0115, 0.0316, 0.0039]), + (45.545, [0.0156, 0.0354, 0.0038]), + (47.143, [0.0156, 0.0351, 0.004]), + (47.942, [0.0156, 0.039, 0.004]), + (49.54, [0.0153, 0.0397, 0.0043]), + (50.339, [0.0168, 0.044, 0.0044]), + (51.138, [0.02, 0.043, 0.0045]), + (52.736, [0.0194, 0.0478, 0.0046]), + (53.535, [0.0197, 0.0466, 0.0046]), + (54.334, [0.0192, 0.0502, 0.0044]), + (55.933, [0.0238, 0.051, 0.0045]), + (56.732, [0.0234, 0.0553, 0.0045]), + (57.531, [0.0235, 0.0539, 0.0045]), + (59.129, [0.0231, 0.0585, 0.0044]), + (59.928, [0.0272, 0.0588, 0.0044]), + (60.727, [0.0275, 0.0629, 0.0044]), + (61.526, [0.0274, 0.0616, 0.0044]), + (62.325, [0.0275, 0.066, 0.0043]), + (63.124, [0.0269, 0.0655, 0.0044]), + (64.722, [0.0315, 0.0697, 0.0042]), + (67.119, [0.031, 0.075, 0.0036]), + (67.918, [0.0349, 0.0779, 0.0034]), + (68.717, [0.0353, 0.0777, 0.0033]), + (71.114, [0.0355, 0.0853, 0.0029]), + (71.913, [0.0395, 0.0857, 0.0029]), + (72.712, [0.0375, 0.0891, 0.0029]), + (73.511, [0.052, 0.09, 0.0029]), + (74.31, [0.0696, 0.0986, 0.003]), + (75.109, [0.0886, 0.1021, 0.003]), + (75.908, [0.1032, 0.1097, 0.0031]), + (76.707, [0.124, 0.1146, 0.0029]), + (77.507, [0.1298, 0.1155, 0.0036]), + (78.306, [0.124, 0.1156, 0.0005]), + (79.105, [0.0819, 0.0595, -0.0004]), + (79.904, [0.0492, 0.0079, -0.0001]), + (80.703, [0.0308, 0.0057, 0.0]), + (81.502, [0.0133, 0.0007, 0.0001]), + (82.301, [-0.0012, 0.0002, 0.0001]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0003, -0.0004, -0.0006]), + (1.5983, [0.0001, 0.001, -0.0007]), + (142.25, [0.0003, 0.0002, -0.0006]), + (143.05, [0.0068, 0.0003, 0.0141]), + (146.25, [0.0405, 0.0003, 0.079]), + (147.05, [0.0506, 0.0126, 0.0915]), + (147.85, [0.0516, 0.029, 0.0895]), + (148.64, [0.0512, 0.0411, 0.0821]), + (150.24, [0.0519, 0.0726, 0.0674]), + (151.04, [0.0651, 0.0823, 0.0535]), + (151.84, [0.0685, 0.0809, 0.0341]), + (153.44, [0.0845, 0.0663, 0.0189]), + (154.24, [0.0905, 0.0622, 0.0109]), + (155.04, [0.0878, 0.0475, 0.0024]), + (155.84, [0.0725, 0.0329, 0.0]), + (158.23, [0.0252, 0.0094, 0.0001]), + (159.03, [0.0056, 0.0015, 0.0]), + (159.83, [-0.0003, 0.0001, 0.0]), + ], + }, +]; +const ZOOM_125_STREAKS: &[Streak] = &[]; +const ZOOM_125_RAYS: &[Ray] = &[ + Ray { angle: 0.2, r0: 20.312, r1: 449.22, fade: 0.05, rgb: [0.0255, 0.0275, 0.0255] }, + Ray { angle: 8.06, r0: 14.062, r1: 128.91, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 15.36, r0: 17.188, r1: 158.59, fade: 0.05, rgb: [0.0147, 0.0235, 0.0235] }, + Ray { angle: 16.43, r0: 128.91, r1: 509.38, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 17.21, r0: 17.188, r1: 204.69, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 24.39, r0: 24.219, r1: 189.84, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 29.69, r0: 19.531, r1: 86.719, fade: 0.05, rgb: [0.0078, 0.0196, 0.0196] }, + Ray { angle: 34.95, r0: 32.812, r1: 141.41, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 35.96, r0: 110.94, r1: 333.59, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 38.74, r0: 32.812, r1: 150.78, fade: 0.05, rgb: [0.0176, 0.0275, 0.0275] }, + Ray { angle: 43.16, r0: 20.312, r1: 445.31, fade: 0.05, rgb: [0.0235, 0.0314, 0.0275] }, + Ray { angle: 53.33, r0: 17.188, r1: 189.84, fade: 0.05, rgb: [0.0157, 0.0255, 0.0245] }, + Ray { angle: 55.85, r0: 54.688, r1: 152.34, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 59.55, r0: 141.41, r1: 325.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 62.98, r0: 18.75, r1: 263.28, fade: 0.05, rgb: [0.0275, 0.0353, 0.0353] }, + Ray { angle: 64.81, r0: 63.281, r1: 431.25, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 70.17, r0: 18.75, r1: 125.78, fade: 0.05, rgb: [0.0118, 0.0206, 0.0206] }, + Ray { angle: 71.99, r0: 66.406, r1: 297.66, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 73.89, r0: 139.06, r1: 246.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.05, r0: 39.062, r1: 411.72, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 80.95, r0: 60.938, r1: 409.38, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 86.33, r0: 20.312, r1: 160.16, fade: 0.05, rgb: [0.0196, 0.0333, 0.0314] }, + Ray { angle: 93.42, r0: 10.938, r1: 404.69, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 104.36, r0: 28.125, r1: 417.19, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 109.71, r0: 119.53, r1: 189.84, fade: 0.05, rgb: [0.0137, 0.0176, 0.0157] }, + Ray { angle: 110.66, r0: 15.625, r1: 212.5, fade: 0.05, rgb: [0.0196, 0.0275, 0.0275] }, + Ray { angle: 110.92, r0: 15.625, r1: 178.12, fade: 0.05, rgb: [0.0059, 0.0078, 0.0098] }, + Ray { angle: 112.93, r0: 61.719, r1: 439.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.83, r0: 55.469, r1: 367.19, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 118.66, r0: 19.531, r1: 249.22, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 129.19, r0: 66.406, r1: 369.53, fade: 0.05, rgb: [0.0118, 0.0137, 0.0118] }, + Ray { angle: 131.14, r0: 28.906, r1: 317.97, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 135.54, r0: 25.0, r1: 144.53, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 136.26, r0: 160.94, r1: 324.22, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 138.23, r0: 55.469, r1: 314.06, fade: 0.05, rgb: [0.0088, 0.0118, 0.0118] }, + Ray { angle: 141.63, r0: 138.28, r1: 242.19, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 145.39, r0: 61.719, r1: 284.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 147.31, r0: 15.625, r1: 278.12, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 148.73, r0: 94.531, r1: 249.22, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 152.52, r0: 40.625, r1: 264.06, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 153.55, r0: 85.938, r1: 213.28, fade: 0.05, rgb: [0.0157, 0.0196, 0.0186] }, + Ray { angle: 155.92, r0: 19.531, r1: 256.25, fade: 0.05, rgb: [0.0275, 0.0353, 0.0373] }, + Ray { angle: 157.11, r0: 27.344, r1: 113.28, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 161.69, r0: 16.406, r1: 246.09, fade: 0.05, rgb: [0.0235, 0.0314, 0.0314] }, + Ray { angle: 163.07, r0: 18.75, r1: 244.53, fade: 0.05, rgb: [0.0196, 0.0265, 0.0255] }, + Ray { angle: 168.84, r0: 10.938, r1: 238.28, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 169.44, r0: 9.375, r1: 189.06, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 171.26, r0: 106.25, r1: 216.41, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 172.5, r0: 35.938, r1: 121.09, fade: 0.05, rgb: [0.0118, 0.0235, 0.0235] }, + Ray { angle: 176.63, r0: 32.031, r1: 225.0, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 183.13, r0: 146.09, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 186.48, r0: 35.938, r1: 235.16, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 193.74, r0: 18.75, r1: 240.62, fade: 0.05, rgb: [0.0176, 0.0235, 0.0255] }, + Ray { angle: 200.96, r0: 71.875, r1: 203.91, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 202.8, r0: 103.12, r1: 253.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 208.01, r0: 99.219, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 213.33, r0: 28.125, r1: 280.47, fade: 0.05, rgb: [0.0275, 0.0392, 0.0363] }, + Ray { angle: 217.04, r0: 42.188, r1: 292.97, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 218.19, r0: 25.0, r1: 212.5, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 220.48, r0: 49.219, r1: 300.78, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 224.23, r0: 39.062, r1: 279.69, fade: 0.05, rgb: [0.0137, 0.0157, 0.0196] }, + Ray { angle: 226.08, r0: 59.375, r1: 244.53, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 227.35, r0: 79.688, r1: 242.19, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 229.33, r0: 35.938, r1: 164.06, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 232.3, r0: 53.906, r1: 207.03, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 234.54, r0: 27.344, r1: 180.47, fade: 0.05, rgb: [0.0176, 0.0275, 0.0294] }, + Ray { angle: 240.39, r0: 71.875, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 241.67, r0: 72.656, r1: 221.88, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 243.75, r0: 61.719, r1: 210.94, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 245.57, r0: 63.281, r1: 214.06, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 250.54, r0: 39.844, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0137, 0.0118] }, + Ray { angle: 250.85, r0: 41.406, r1: 206.25, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 252.72, r0: 63.281, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 254.48, r0: 65.625, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 256.14, r0: 82.812, r1: 200.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 259.97, r0: 43.75, r1: 197.66, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 263.25, r0: 114.84, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 264.26, r0: 10.938, r1: 146.88, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 265.21, r0: 124.22, r1: 195.31, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 267.03, r0: 64.062, r1: 107.03, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 269.05, r0: 10.156, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0196, 0.0216] }, + Ray { angle: 276.22, r0: 36.719, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 283.35, r0: 66.406, r1: 200.0, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 284.81, r0: 115.62, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 285.74, r0: 40.625, r1: 165.62, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 287.59, r0: 50.0, r1: 146.09, fade: 0.05, rgb: [0.0176, 0.0196, 0.0216] }, + Ray { angle: 288.62, r0: 103.12, r1: 205.47, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 295.82, r0: 28.906, r1: 216.41, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 299.12, r0: 112.5, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 300.15, r0: 31.25, r1: 146.88, fade: 0.05, rgb: [0.0157, 0.0255, 0.0255] }, + Ray { angle: 302.91, r0: 36.719, r1: 232.03, fade: 0.05, rgb: [0.0216, 0.0275, 0.0255] }, + Ray { angle: 305.78, r0: 55.469, r1: 161.72, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 306.3, r0: 164.06, r1: 242.19, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 310.11, r0: 43.75, r1: 254.69, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 319.11, r0: 67.969, r1: 297.66, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 320.67, r0: 50.781, r1: 307.81, fade: 0.05, rgb: [0.0225, 0.0275, 0.0275] }, + Ray { angle: 322.54, r0: 45.312, r1: 321.09, fade: 0.05, rgb: [0.0225, 0.0275, 0.0275] }, + Ray { angle: 324.28, r0: 75.0, r1: 154.69, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 340.57, r0: 27.344, r1: 339.84, fade: 0.05, rgb: [0.0333, 0.0392, 0.0392] }, + Ray { angle: 345.77, r0: 23.438, r1: 246.09, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, +]; +const ZOOM_150_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.2242, 0.4596, 0.5721]), + (4.0, [0.225, 0.4574, 0.5696]), + (10.4, [0.2163, 0.4499, 0.5631]), + (20.0, [0.2003, 0.4111, 0.5416]), + (23.2, [0.2023, 0.3977, 0.5295]), + (28.0, [0.2138, 0.383, 0.5104]), + (30.4, [0.2229, 0.3789, 0.5019]), + (39.2, [0.2652, 0.4068, 0.4869]), + (42.4, [0.2838, 0.408, 0.48]), + (52.0, [0.3365, 0.3925, 0.4514]), + (60.0, [0.3557, 0.3879, 0.4246]), + (62.4, [0.3691, 0.3852, 0.4185]), + (64.0, [0.389, 0.3894, 0.4179]), + (67.2, [0.4477, 0.404, 0.4131]), + (68.0, [0.4503, 0.4016, 0.4097]), + (72.0, [0.4057, 0.3649, 0.3844]), + (84.0, [0.4189, 0.3189, 0.3324]), + (91.2, [0.4249, 0.2774, 0.3036]), + (96.0, [0.4258, 0.2609, 0.2921]), + (104.0, [0.4384, 0.2594, 0.2747]), + (109.6, [0.4308, 0.2588, 0.2637]), + (112.0, [0.427, 0.2539, 0.2514]), + (117.6, [0.4244, 0.2512, 0.2359]), + (129.6, [0.4052, 0.2523, 0.2219]), + (134.4, [0.3879, 0.2407, 0.2232]), + (140.8, [0.3808, 0.2274, 0.2194]), + (144.0, [0.3786, 0.2173, 0.2134]), + (153.6, [0.346, 0.1891, 0.195]), + (159.2, [0.3433, 0.1773, 0.1838]), + (164.0, [0.3285, 0.1674, 0.1764]), + (171.2, [0.315, 0.171, 0.1685]), + (174.4, [0.3049, 0.1677, 0.1671]), + (175.2, [0.305, 0.1699, 0.1612]), + (179.2, [0.2945, 0.169, 0.1577]), + (184.8, [0.2853, 0.1609, 0.1535]), + (192.0, [0.2848, 0.1572, 0.1511]), + (198.4, [0.2763, 0.1528, 0.1469]), + (211.2, [0.2783, 0.1488, 0.1392]), + (242.4, [0.2722, 0.1657, 0.1266]), + (248.0, [0.2719, 0.1715, 0.1303]), + (264.0, [0.2258, 0.1817, 0.1364]), + (270.4, [0.2085, 0.1695, 0.1381]), + (279.2, [0.1821, 0.1532, 0.1334]), + (290.4, [0.155, 0.139, 0.1346]), + (292.0, [0.1542, 0.1405, 0.1332]), + (296.8, [0.1473, 0.1377, 0.138]), + (300.8, [0.1353, 0.137, 0.1443]), + (304.8, [0.126, 0.1275, 0.1503]), + (305.6, [0.1211, 0.1264, 0.1487]), + (307.2, [0.1184, 0.1266, 0.1501]), + (308.0, [0.1146, 0.1234, 0.1467]), + (312.8, [0.1057, 0.1236, 0.1422]), + (316.0, [0.0946, 0.1193, 0.1349]), + (325.6, [0.0656, 0.0983, 0.1025]), + (330.4, [0.0514, 0.0856, 0.099]), + (332.0, [0.0493, 0.0841, 0.0957]), + (332.8, [0.045, 0.0828, 0.0959]), + (334.4, [0.0435, 0.0783, 0.0934]), + (336.0, [0.0377, 0.0765, 0.0939]), + (336.8, [0.0368, 0.0722, 0.0945]), + (337.6, [0.0365, 0.0717, 0.0914]), + (339.2, [0.031, 0.0706, 0.0923]), + (340.8, [0.0292, 0.0658, 0.0896]), + (341.6, [0.0251, 0.0655, 0.0903]), + (343.2, [0.0242, 0.0631, 0.0906]), + (344.8, [0.0196, 0.0601, 0.0869]), + (346.4, [0.019, 0.0579, 0.0861]), + (347.2, [0.0154, 0.0538, 0.0824]), + (349.6, [0.0141, 0.0526, 0.082]), + (350.4, [0.0103, 0.0494, 0.0783]), + (352.8, [0.0087, 0.0475, 0.0779]), + (353.6, [0.0055, 0.0465, 0.0747]), + (354.4, [0.0057, 0.0433, 0.0739]), + (356.0, [0.0046, 0.0428, 0.0736]), + (356.8, [0.0018, 0.0426, 0.0726]), + (358.4, [0.0023, 0.0385, 0.0701]), + (360.0, [-0.0011, 0.0378, 0.0696]), + (360.8, [-0.0007, 0.0377, 0.0664]), + (361.6, [-0.0005, 0.0343, 0.0667]), + (363.2, [-0.0034, 0.0338, 0.067]), + (364.0, [-0.0029, 0.0338, 0.0639]), + (365.6, [-0.0033, 0.0301, 0.0635]), + (366.4, [-0.0052, 0.0303, 0.0637]), + (368.8, [-0.0043, 0.0287, 0.0608]), + (369.6, [-0.0064, 0.0268, 0.0607]), + (372.0, [-0.0045, 0.0275, 0.0583]), + (372.8, [-0.007, 0.0245, 0.0583]), + (375.2, [-0.0046, 0.0243, 0.0556]), + (376.0, [-0.0071, 0.0247, 0.0561]), + (377.6, [-0.0059, 0.0216, 0.0567]), + (378.4, [-0.0047, 0.022, 0.0536]), + (380.0, [-0.0066, 0.0228, 0.054]), + (381.6, [-0.0045, 0.0197, 0.0537]), + (383.2, [-0.006, 0.0203, 0.052]), + (385.6, [-0.0028, 0.0181, 0.0515]), + (386.4, [-0.0046, 0.0188, 0.0498]), + (388.8, [-0.0026, 0.0191, 0.0512]), + (389.6, [-0.0028, 0.0171, 0.0484]), + (390.4, [-0.0044, 0.0178, 0.0482]), + (392.8, [-0.0013, 0.0193, 0.0494]), + (394.4, [-0.0031, 0.0167, 0.0465]), + (396.8, [-0.0005, 0.0182, 0.0476]), + (397.6, [-0.0025, 0.018, 0.0449]), + (398.4, [-0.0016, 0.016, 0.0445]), + (400.0, [0.0003, 0.0173, 0.0446]), + (401.6, [-0.0011, 0.0185, 0.043]), + (402.4, [0.0003, 0.0161, 0.0422]), + (404.0, [0.0026, 0.0174, 0.0425]), + (405.6, [0.0012, 0.0188, 0.0413]), + (407.2, [0.0033, 0.0166, 0.0404]), + (408.0, [0.0049, 0.0177, 0.0405]), + (409.6, [0.0033, 0.0189, 0.0407]), + (410.4, [0.0048, 0.0199, 0.0378]), + (412.0, [0.0072, 0.0176, 0.0385]), + (413.6, [0.0056, 0.0194, 0.0393]), + (415.2, [0.0083, 0.021, 0.0361]), + (416.8, [0.01, 0.019, 0.037]), + (417.6, [0.0087, 0.0204, 0.0374]), + (418.4, [0.0106, 0.0211, 0.0377]), + (419.2, [0.0118, 0.022, 0.0348]), + (420.8, [0.0141, 0.0234, 0.0358]), + (421.6, [0.0122, 0.0212, 0.0362]), + (424.8, [0.0182, 0.0257, 0.0352]), + (425.6, [0.0164, 0.0271, 0.0355]), + (426.4, [0.0172, 0.0248, 0.036]), + (429.6, [0.0224, 0.0287, 0.0349]), + (430.4, [0.0205, 0.03, 0.0352]), + (432.0, [0.0238, 0.028, 0.0362]), + (433.6, [0.027, 0.0304, 0.0334]), + (435.2, [0.026, 0.0322, 0.0348]), + (437.6, [0.0304, 0.0323, 0.0359]), + (438.4, [0.0322, 0.0334, 0.0329]), + (440.0, [0.0313, 0.0355, 0.0342]), + (442.4, [0.0355, 0.0368, 0.036]), + (443.2, [0.0371, 0.0361, 0.0337]), + (444.0, [0.0355, 0.0378, 0.0336]), + (448.0, [0.0424, 0.0417, 0.0367]), + (448.8, [0.0421, 0.0406, 0.0341]), + (449.6, [0.0413, 0.0424, 0.0347]), + (453.6, [0.0482, 0.0476, 0.038]), + (454.4, [0.0461, 0.0454, 0.0352]), + (459.2, [0.0513, 0.0522, 0.0399]), + (460.8, [0.0517, 0.0502, 0.0382]), + (464.0, [0.0549, 0.054, 0.0411]), + (465.6, [0.0525, 0.055, 0.0405]), + (466.4, [0.0541, 0.0548, 0.0388]), + (467.2, [0.0553, 0.0518, 0.0399]), + (470.4, [0.058, 0.0492, 0.0408]), + (473.6, [0.0534, 0.0393, 0.0386]), + (474.4, [0.0517, 0.034, 0.0387]), + (476.8, [0.0454, 0.0279, 0.0397]), + (479.2, [0.0428, 0.0225, 0.0379]), + (481.6, [0.0409, 0.0157, 0.0395]), + (483.2, [0.0374, 0.0141, 0.04]), + (488.0, [0.0345, 0.0086, 0.0394]), + (488.8, [0.0332, 0.0053, 0.0395]), + (490.4, [0.0295, 0.0042, 0.0399]), + (495.2, [0.0245, -0.0001, 0.0384]), + (497.6, [0.0197, -0.0046, 0.0385]), + (504.0, [0.0161, -0.0081, 0.0347]), + (504.8, [0.0129, -0.0092, 0.0344]), + (508.8, [0.0115, -0.0125, 0.0323]), + (509.6, [0.0109, -0.0129, 0.0291]), + (512.0, [0.0088, -0.0145, 0.0272]), + (512.8, [0.0059, -0.015, 0.0264]), + (518.4, [0.0007, -0.0196, 0.0208]), + (519.2, [-0.0005, -0.0199, 0.0164]), + (520.0, [-0.0015, -0.0202, 0.0162]), + (521.6, [-0.0039, -0.0209, 0.0137]), + (522.4, [-0.0077, -0.0212, 0.0125]), + (530.4, [-0.0215, -0.0262, -0.0021]), + (532.8, [-0.029, -0.0274, -0.0058]), + (540.0, [-0.0413, -0.0284, -0.0156]), + (540.8, [-0.0424, -0.0312, -0.0165]), + (544.0, [-0.045, -0.0301, -0.0206]), + (544.8, [-0.0481, -0.0298, -0.0215]), + (552.0, [-0.0481, -0.0241, -0.0273]), + (558.4, [-0.0437, -0.0165, -0.0298]), + (559.2, [-0.0438, -0.0156, -0.0332]), + (560.8, [-0.0427, -0.0135, -0.0334]), + (568.8, [-0.0289, -0.0008, -0.0349]), + (578.4, [0.0008, 0.0152, -0.0238]), + (585.6, [0.0225, 0.0229, -0.0139]), + (588.0, [0.0263, 0.0241, -0.0122]), + (593.6, [0.0259, 0.0208, -0.0121]), + (595.2, [0.0258, 0.0201, -0.0092]), + (597.6, [0.0287, 0.0198, -0.0004]), + (598.4, [0.031, 0.0223, 0.0029]), + (600.0, [0.0394, 0.0334, 0.0101]), + (601.6, [0.0494, 0.0433, 0.0125]), + (603.2, [0.0621, 0.054, 0.0119]), + (605.6, [0.0825, 0.0578, 0.0076]), + (606.4, [0.0842, 0.0568, 0.0056]), + (608.0, [0.0763, 0.0491, 0.001]), + (610.4, [0.0542, 0.0352, -0.003]), + (612.0, [0.0378, 0.0266, -0.0037]), + (613.6, [0.0269, 0.0209, -0.0034]), + (615.2, [0.0189, 0.0166, -0.002]), + (617.6, [0.0146, 0.0115, 0.0006]), + (620.8, [0.0165, 0.0059, 0.0016]), + (624.0, [0.0157, 0.0008, -0.0009]), + (630.4, [-0.0043, -0.0039, -0.01]), + (632.0, [-0.0087, -0.0036, -0.0105]), + (634.4, [-0.0127, -0.0022, -0.0079]), + (636.8, [-0.0114, -0.0004, -0.0031]), + (640.8, [0.003, 0.0001, 0.0053]), + (642.4, [0.0065, -0.0012, 0.0078]), + (644.0, [0.0038, -0.0029, 0.0093]), + (646.4, [-0.0067, -0.0052, 0.0089]), + (648.0, [-0.0101, -0.0054, 0.0087]), + (648.8, [-0.0092, -0.005, 0.009]), + (651.2, [-0.0002, -0.0026, 0.0096]), + (652.8, [0.0035, -0.0011, 0.0082]), + (654.4, [0.0022, -0.0002, 0.0049]), + (656.8, [-0.0043, 0.0004, -0.0009]), + (657.6, [-0.0043, 0.0006, -0.0018]), + (660.8, [0.0027, 0.0012, -0.0033]), + (664.0, [-0.0044, -0.0017, -0.0073]), + (664.8, [-0.0045, -0.0019, -0.0068]), + (667.2, [0.0014, -0.0006, -0.0034]), + (668.0, [0.0017, -0.0006, -0.0031]), + (670.4, [-0.0049, -0.0018, -0.0031]), + (671.2, [-0.0042, -0.0015, -0.0019]), + (672.8, [0.0012, -0.0003, 0.0005]), + (673.6, [0.0015, -0.0002, 0.0005]), + (675.2, [-0.0023, -0.0005, -0.0005]), + (676.0, [-0.0022, -0.0002, -0.0001]), + (677.6, [0.0025, 0.0008, 0.0014]), + (679.2, [0.0006, 0.0001, 0.0003]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.7093, 0.5088, 0.4152]), + (0.8, [0.7273, 0.5147, 0.4289]), + (18.4, [0.7396, 0.5086, 0.4163]), + (26.4, [0.7248, 0.4948, 0.4058]), + (29.6, [0.709, 0.4827, 0.4018]), + (38.4, [0.6551, 0.4105, 0.3776]), + (43.2, [0.6158, 0.3849, 0.3665]), + (52.0, [0.5447, 0.353, 0.3511]), + (63.2, [0.4739, 0.3113, 0.3333]), + (67.2, [0.4613, 0.3051, 0.3276]), + (71.2, [0.4136, 0.2895, 0.3247]), + (84.8, [0.3446, 0.2839, 0.3273]), + (93.6, [0.3063, 0.3038, 0.3308]), + (104.8, [0.2546, 0.285, 0.323]), + (116.8, [0.2275, 0.2624, 0.3274]), + (129.6, [0.2007, 0.2378, 0.3094]), + (132.0, [0.2002, 0.2353, 0.304]), + (135.2, [0.1999, 0.2344, 0.2958]), + (138.4, [0.1926, 0.2342, 0.2897]), + (144.8, [0.1795, 0.2383, 0.2858]), + (154.4, [0.18, 0.2432, 0.2809]), + (160.0, [0.1679, 0.2447, 0.2794]), + (165.6, [0.1642, 0.2433, 0.2742]), + (176.0, [0.16, 0.2195, 0.263]), + (182.4, [0.1576, 0.2127, 0.2559]), + (196.8, [0.1321, 0.1985, 0.2368]), + (207.2, [0.1118, 0.1831, 0.2225]), + (214.4, [0.1019, 0.1714, 0.2128]), + (246.4, [0.0432, 0.0996, 0.1659]), + (262.4, [0.0601, 0.0637, 0.1317]), + (279.2, [0.0797, 0.0683, 0.1081]), + (290.4, [0.0868, 0.0667, 0.0925]), + (296.0, [0.0867, 0.061, 0.0809]), + (300.0, [0.0892, 0.0575, 0.0688]), + (302.4, [0.0929, 0.0578, 0.0607]), + (305.6, [0.097, 0.0599, 0.0541]), + (313.6, [0.1044, 0.0539, 0.0534]), + (324.0, [0.1215, 0.0628, 0.0725]), + (327.2, [0.1265, 0.066, 0.0744]), + (339.2, [0.1393, 0.077, 0.068]), + (344.0, [0.1418, 0.0783, 0.0652]), + (355.2, [0.1453, 0.0839, 0.0669]), + (371.2, [0.1359, 0.0843, 0.0627]), + (384.0, [0.1197, 0.0792, 0.057]), + (396.8, [0.103, 0.0699, 0.0502]), + (420.0, [0.0672, 0.0456, 0.0386]), + (441.6, [0.0266, 0.0152, 0.0235]), + (449.6, [0.0112, 0.0027, 0.016]), + (460.8, [-0.0057, -0.0135, 0.0047]), + (465.6, [-0.0112, -0.018, 0.0007]), + (468.8, [-0.0147, -0.0163, -0.0007]), + (469.6, [-0.0172, -0.015, -0.0011]), + (470.4, [-0.017, -0.0133, -0.0002]), + (471.2, [-0.0169, -0.0113, -0.0027]), + (472.0, [-0.016, -0.0076, -0.0025]), + (475.2, [-0.0092, 0.0004, -0.0034]), + (476.8, [-0.0095, 0.0044, -0.0032]), + (477.6, [-0.0086, 0.0063, -0.0059]), + (479.2, [-0.0073, 0.0105, -0.0068]), + (480.0, [-0.0068, 0.0093, -0.0071]), + (484.0, [-0.0058, 0.0142, -0.0082]), + (485.6, [-0.0048, 0.0159, -0.0109]), + (488.0, [-0.0017, 0.0169, -0.0114]), + (488.8, [-0.0034, 0.0173, -0.0117]), + (491.2, [-0.001, 0.0198, -0.011]), + (492.0, [0.0002, 0.0205, -0.0134]), + (495.2, [0.0042, 0.0242, -0.0144]), + (496.0, [0.0027, 0.0226, -0.0144]), + (499.2, [0.0051, 0.0245, -0.0122]), + (500.0, [0.0056, 0.0249, -0.0146]), + (501.6, [0.0067, 0.0258, -0.0147]), + (502.4, [0.0078, 0.0262, -0.0144]), + (504.0, [0.006, 0.028, -0.0141]), + (504.8, [0.0059, 0.0257, -0.0139]), + (507.2, [0.0073, 0.0266, -0.0118]), + (509.6, [0.0088, 0.0281, -0.0125]), + (514.4, [0.0111, 0.0319, -0.0074]), + (515.2, [0.0119, 0.0293, -0.0066]), + (520.0, [0.019, 0.0313, -0.0024]), + (520.8, [0.0167, 0.0317, -0.0013]), + (525.6, [0.0248, 0.0348, 0.006]), + (526.4, [0.0265, 0.0319, 0.0075]), + (528.0, [0.0295, 0.032, 0.011]), + (528.8, [0.0312, 0.0327, 0.009]), + (530.4, [0.0355, 0.0334, 0.0115]), + (531.2, [0.0343, 0.0339, 0.0128]), + (538.4, [0.0474, 0.0367, 0.0229]), + (539.2, [0.0487, 0.0337, 0.0239]), + (540.8, [0.0507, 0.0338, 0.026]), + (542.4, [0.053, 0.0336, 0.0242]), + (544.8, [0.0509, 0.0325, 0.0267]), + (552.0, [0.0513, 0.0265, 0.0319]), + (555.2, [0.0495, 0.0227, 0.0335]), + (556.8, [0.0481, 0.0174, 0.0355]), + (557.6, [0.0472, 0.0159, 0.0324]), + (558.4, [0.0477, 0.015, 0.0333]), + (559.2, [0.0431, 0.0139, 0.0333]), + (565.6, [0.0339, 0.0045, 0.0358]), + (570.4, [0.0223, -0.0038, 0.0339]), + (578.4, [-0.0034, -0.0166, 0.0231]), + (584.8, [-0.0224, -0.0232, 0.0145]), + (588.8, [-0.0269, -0.0241, 0.0126]), + (593.6, [-0.0252, -0.0204, 0.0117]), + (596.8, [-0.0287, -0.02, 0.0006]), + (597.6, [-0.0312, -0.0228, -0.0031]), + (599.2, [-0.0401, -0.034, -0.0107]), + (600.8, [-0.0505, -0.0441, -0.0128]), + (602.4, [-0.0631, -0.0548, -0.0122]), + (604.0, [-0.078, -0.058, -0.0092]), + (604.8, [-0.0833, -0.0584, -0.0077]), + (605.6, [-0.0845, -0.0571, -0.0056]), + (607.2, [-0.0762, -0.049, -0.0008]), + (609.6, [-0.0536, -0.0351, 0.003]), + (611.2, [-0.0372, -0.0264, 0.0037]), + (612.8, [-0.0264, -0.0208, 0.0032]), + (614.4, [-0.0188, -0.0166, 0.0017]), + (616.8, [-0.0149, -0.0116, -0.0008]), + (619.2, [-0.0162, -0.0073, -0.0018]), + (621.6, [-0.0174, -0.0033, -0.0008]), + (623.2, [-0.0157, -0.0009, 0.001]), + (629.6, [0.0045, 0.0036, 0.0101]), + (632.8, [0.0117, 0.0024, 0.0089]), + (634.4, [0.0128, 0.0012, 0.0061]), + (636.0, [0.0109, 0.0001, 0.0027]), + (640.8, [-0.0058, 0.0004, -0.0069]), + (641.6, [-0.0067, 0.0011, -0.0081]), + (643.2, [-0.0035, 0.0029, -0.0095]), + (645.6, [0.0071, 0.0051, -0.009]), + (647.2, [0.0099, 0.0052, -0.0089]), + (648.0, [0.0088, 0.0047, -0.0092]), + (651.2, [-0.0027, 0.0015, -0.0092]), + (652.0, [-0.0037, 0.0009, -0.0082]), + (653.6, [-0.002, 0.0, -0.0047]), + (656.0, [0.0042, -0.0005, 0.001]), + (660.0, [-0.0027, -0.0013, 0.0035]), + (662.4, [0.003, 0.0011, 0.0071]), + (664.0, [0.0043, 0.0017, 0.0067]), + (666.4, [-0.0017, 0.0005, 0.0034]), + (667.2, [-0.0016, 0.0005, 0.0031]), + (669.6, [0.0049, 0.0017, 0.003]), + (672.0, [-0.0015, 0.0002, -0.0006]), + (674.4, [0.0024, 0.0004, 0.0005]), + (676.8, [-0.0028, -0.0009, -0.0014]), + (678.4, [-0.0003, -0.0001, -0.0001]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.048, 0.0259, 0.0035]), + (0.79967, [0.0474, 0.0251, 0.0005]), + (1.5993, [0.052, 0.0301, 0.0064]), + (2.399, [0.0511, 0.0311, 0.0052]), + (3.1987, [0.0497, 0.0271, 0.0038]), + (7.197, [0.0503, 0.028, 0.0038]), + (9.596, [0.0532, 0.0321, 0.0063]), + (18.392, [0.0507, 0.0285, 0.0035]), + (32.786, [0.0506, 0.0287, 0.0044]), + (58.376, [0.0499, 0.0263, 0.0026]), + (75.169, [0.0507, 0.0285, 0.0042]), + (109.55, [0.0515, 0.0281, 0.0035]), + (115.95, [0.051, 0.028, 0.0035]), + (119.95, [0.0406, 0.0215, 0.0036]), + (271.09, [0.0386, 0.0196, 0.0018]), + (271.89, [0.0364, 0.0198, 0.0019]), + (276.68, [0.0156, 0.0076, 0.0017]), + (278.28, [0.0078, 0.0062, 0.0018]), + (279.08, [0.0049, 0.0028, 0.0017]), + (279.88, [0.0028, 0.0033, 0.0018]), + (327.06, [0.0015, 0.0019, 0.0007]), + (392.64, [0.0036, 0.0028, 0.0008]), + (479.0, [0.0016, 0.0019, 0.0003]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0048, -0.0079, -0.0033]), + (0.79036, [0.0013, 0.0016, 0.0008]), + (4.7422, [0.0043, 0.0017, 0.0021]), + (8.694, [0.0033, 0.0041, 0.0006]), + (11.855, [0.004, 0.0056, 0.0036]), + (16.598, [0.004, 0.0116, 0.0057]), + (22.921, [0.0067, 0.0246, 0.0124]), + (27.663, [0.0198, 0.0402, 0.0174]), + (29.243, [0.0168, 0.0426, 0.0193]), + (30.034, [0.0133, 0.0353, 0.0156]), + (30.824, [0.0072, 0.0135, 0.0061]), + (31.615, [0.0025, 0.0016, 0.0007]), + (32.405, [0.0014, -0.0001, -0.0003]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [-0.0034, 0.0337, 0.0648]), + (0.79858, [0.0013, 0.0372, 0.0726]), + (2.3957, [0.0017, 0.0396, 0.075]), + (3.1943, [0.0014, 0.038, 0.0742]), + (6.3886, [0.003, 0.0397, 0.0755]), + (10.382, [0.0009, 0.0383, 0.0742]), + (16.77, [0.0012, 0.0384, 0.072]), + (17.569, [0.0008, 0.0374, 0.0707]), + (19.965, [0.0009, 0.0302, 0.0593]), + (24.756, [0.0004, 0.0221, 0.0442]), + (26.353, [0.0001, 0.018, 0.0375]), + (27.95, [-0.0002, 0.0122, 0.027]), + (34.339, [-0.0012, 0.011, 0.0241]), + (43.123, [-0.0005, 0.0108, 0.0245]), + (44.721, [-0.0002, 0.0075, 0.0153]), + (46.318, [-0.0002, 0.0052, 0.01]), + (50.311, [0.0, 0.0009, 0.0008]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1041, 0.0564, -0.0135]), + (0.79635, [0.1059, 0.0584, -0.0049]), + (1.5927, [0.1068, 0.0573, -0.0088]), + (2.3891, [0.1085, 0.0584, -0.004]), + (3.9818, [0.1077, 0.0604, 0.0005]), + (6.3708, [0.1076, 0.0625, 0.0036]), + (10.353, [0.1178, 0.0683, 0.009]), + (11.945, [0.1206, 0.0693, 0.0087]), + (13.538, [0.1182, 0.068, 0.0079]), + (14.334, [0.1013, 0.058, 0.0065]), + (15.131, [0.0428, 0.0237, 0.0032]), + (15.927, [0.0017, 0.0009, 0.0009]), + (21.501, [0.0017, 0.0028, 0.0046]), + (31.058, [0.0013, 0.0017, 0.0018]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.2107, 0.5799, 0.4155]), + (0.76482, [0.1357, 0.3628, 0.2637]), + (1.5296, [0.1085, 0.3035, 0.2176]), + (2.2945, [0.0387, 0.1042, 0.0735]), + (3.0593, [0.0031, 0.007, 0.0056]), + (3.8241, [-0.0005, -0.0026, -0.0027]), + (4.5889, [0.0001, -0.001, -0.001]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.268, 0.5905, 0.5674]), + (0.79661, [0.2003, 0.4823, 0.4597]), + (1.5932, [0.1779, 0.4442, 0.4219]), + (2.3898, [0.1086, 0.3072, 0.2988]), + (3.1864, [0.0724, 0.2131, 0.2159]), + (3.983, [0.0591, 0.1686, 0.171]), + (6.3728, [0.024, 0.0698, 0.0798]), + (7.1694, [0.0164, 0.0482, 0.0569]), + (7.9661, [0.0093, 0.029, 0.0409]), + (8.7627, [0.0045, 0.0137, 0.0233]), + (9.5593, [0.0021, 0.0022, 0.0129]), + (10.356, [0.0007, 0.0021, 0.0122]), + (17.525, [0.0018, 0.0027, 0.0035]), + (20.712, [-0.0019, -0.0001, 0.0015]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.7687, 0.5785, 0.764]), + (3.1881, [0.7621, 0.5742, 0.7545]), + (6.3762, [0.7402, 0.5567, 0.7245]), + (11.955, [0.6619, 0.5042, 0.6232]), + (17.535, [0.5353, 0.4164, 0.4711]), + (23.114, [0.4196, 0.3373, 0.2775]), + (26.302, [0.3654, 0.301, 0.1744]), + (28.693, [0.3276, 0.2718, 0.1071]), + (31.084, [0.2927, 0.2422, 0.0506]), + (32.678, [0.2727, 0.2239, 0.0204]), + (34.272, [0.2544, 0.2062, -0.0035]), + (35.866, [0.2375, 0.1891, -0.021]), + (37.46, [0.2236, 0.175, -0.0314]), + (39.851, [0.203, 0.1546, -0.0355]), + (42.242, [0.1867, 0.1382, -0.0301]), + (45.43, [0.1701, 0.1226, -0.0158]), + (47.822, [0.1641, 0.1117, -0.0048]), + (51.01, [0.1635, 0.1068, 0.0077]), + (53.401, [0.1691, 0.108, 0.0136]), + (54.198, [0.1569, 0.1006, 0.0121]), + (54.995, [0.0764, 0.0536, 0.0116]), + (55.792, [0.0203, 0.0205, 0.0118]), + (62.965, [0.0142, 0.0129, 0.0073]), + (71.732, [0.0087, 0.0084, 0.0031]), + (97.237, [0.0019, 0.0011, -0.0002]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [-0.1492, -0.1186, -0.2434]), + (1.5918, [-0.1477, -0.1212, -0.2439]), + (3.1836, [-0.1381, -0.115, -0.2398]), + (4.7754, [-0.1396, -0.118, -0.2348]), + (6.3672, [-0.1357, -0.1155, -0.2288]), + (9.5508, [-0.1201, -0.1043, -0.2123]), + (11.938, [-0.1038, -0.0925, -0.1954]), + (15.918, [-0.0726, -0.0708, -0.157]), + (20.693, [-0.0349, -0.0447, -0.1008]), + (25.469, [-0.0075, -0.0238, -0.0427]), + (27.06, [0.001, -0.0193, -0.0277]), + (27.856, [0.0075, -0.0151, -0.0202]), + (29.448, [0.0146, -0.0091, -0.0078]), + (30.244, [-0.0085, -0.0212, -0.0022]), + (31.04, [-0.0521, -0.0484, 0.0024]), + (31.836, [-0.0639, -0.055, 0.0071]), + (35.019, [-0.0488, -0.0408, 0.0194]), + (37.407, [-0.0398, -0.0317, 0.0227]), + (41.387, [-0.0275, -0.0211, 0.0183]), + (45.366, [-0.0158, -0.0113, 0.0081]), + (47.754, [-0.0139, -0.0099, 0.0034]), + (49.346, [-0.0082, -0.0058, 0.0014]), + (52.529, [-0.0021, -0.0014, 0.0007]), + (54.121, [-0.0019, -0.0013, -0.0006]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-0.4275, -0.3328, -0.5483]), + (0.79582, [-0.4164, -0.3294, -0.5526]), + (5.5707, [-0.403, -0.3186, -0.5324]), + (8.754, [-0.3849, -0.3047, -0.5026]), + (11.141, [-0.3599, -0.2881, -0.4712]), + (13.529, [-0.3266, -0.2669, -0.4322]), + (14.325, [-0.3275, -0.2687, -0.4202]), + (15.916, [-0.3639, -0.2938, -0.3906]), + (19.895, [-0.3016, -0.2477, -0.3048]), + (23.875, [-0.2413, -0.2083, -0.2052]), + (27.058, [-0.2017, -0.1834, -0.1305]), + (29.445, [-0.1737, -0.1622, -0.0819]), + (31.833, [-0.1463, -0.1396, -0.0416]), + (33.424, [-0.1298, -0.1255, -0.0194]), + (35.812, [-0.1063, -0.1047, 0.0051]), + (37.403, [-0.0922, -0.0916, 0.016]), + (38.995, [-0.0798, -0.08, 0.0217]), + (41.383, [-0.0622, -0.0642, 0.0217]), + (43.77, [-0.0482, -0.0508, 0.0155]), + (46.157, [-0.0369, -0.041, 0.0068]), + (49.341, [-0.0266, -0.0306, -0.0041]), + (50.932, [-0.0173, -0.023, -0.0083]), + (53.32, [-0.0213, -0.0219, -0.013]), + (58.095, [-0.0243, -0.0223, -0.014]), + (60.482, [-0.0167, -0.0166, -0.0116]), + (70.828, [-0.0104, -0.0104, -0.0044]), + (79.582, [-0.0066, -0.0059, -0.0016]), + (98.681, [-0.0019, -0.0014, -0.0001]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0588, 0.1636, 0.4885]), + (0.7948, [0.0604, 0.1646, 0.4725]), + (3.1792, [0.0582, 0.1607, 0.4559]), + (3.974, [0.0595, 0.1583, 0.4327]), + (4.7688, [0.0576, 0.155, 0.4179]), + (9.5376, [0.049, 0.1385, 0.2909]), + (12.717, [0.051, 0.1373, 0.2216]), + (15.101, [0.0536, 0.1363, 0.1771]), + (15.896, [0.0537, 0.1377, 0.1656]), + (17.486, [0.0486, 0.1356, 0.1435]), + (18.28, [0.0493, 0.1344, 0.1343]), + (21.46, [0.0472, 0.1305, 0.1122]), + (22.254, [0.0351, 0.0942, 0.0849]), + (23.049, [0.0164, 0.0344, 0.0381]), + (23.844, [0.0084, 0.0065, 0.0146]), + (24.639, [0.0053, 0.002, 0.0103]), + (25.434, [0.0039, 0.0014, 0.009]), + (27.818, [0.0023, 0.0017, 0.0075]), + (40.535, [0.0017, 0.0009, 0.0018]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0045, 0.0047, 0.0044]), + (18.378, [0.0046, 0.0047, 0.0045]), + (19.976, [0.0045, 0.0087, 0.0045]), + (23.172, [0.0045, 0.0083, 0.0044]), + (23.971, [0.0045, 0.0121, 0.0045]), + (26.368, [0.0044, 0.0122, 0.0045]), + (27.966, [0.0042, 0.0167, 0.0044]), + (29.564, [0.0084, 0.0162, 0.0043]), + (32.76, [0.0083, 0.0188, 0.0042]), + (33.56, [0.0082, 0.0217, 0.0043]), + (34.359, [0.0086, 0.0221, 0.0042]), + (35.158, [0.0119, 0.0219, 0.0041]), + (36.756, [0.0121, 0.0256, 0.0042]), + (39.952, [0.0125, 0.028, 0.004]), + (40.751, [0.0146, 0.0296, 0.004]), + (44.746, [0.0149, 0.0359, 0.0042]), + (45.545, [0.0179, 0.0388, 0.004]), + (48.741, [0.0181, 0.0434, 0.0045]), + (49.54, [0.0212, 0.0469, 0.0046]), + (52.736, [0.0214, 0.053, 0.005]), + (54.334, [0.0252, 0.056, 0.0049]), + (55.933, [0.0251, 0.0586, 0.0048]), + (57.531, [0.0282, 0.0628, 0.005]), + (59.928, [0.0285, 0.0687, 0.0048]), + (60.727, [0.0315, 0.0706, 0.0048]), + (66.32, [0.0348, 0.0836, 0.0039]), + (67.119, [0.0377, 0.0867, 0.0037]), + (69.516, [0.0393, 0.0929, 0.0031]), + (70.315, [0.0425, 0.0942, 0.003]), + (72.712, [0.0438, 0.1007, 0.0029]), + (75.109, [0.0982, 0.1194, 0.0034]), + (76.707, [0.1403, 0.1283, 0.0034]), + (77.507, [0.1461, 0.134, 0.0039]), + (78.306, [0.1414, 0.13, 0.001]), + (79.904, [0.0541, 0.0103, 0.0003]), + (81.502, [0.0157, 0.0025, 0.0006]), + (82.301, [-0.0001, 0.0005, 0.0006]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0031, -0.0012, -0.001]), + (0.79916, [0.0034, 0.0021, -0.0007]), + (7.9916, [0.0015, 0.0019, -0.0004]), + (19.18, [0.0033, 0.0032, 0.0001]), + (29.569, [0.0007, 0.0011, 0.0001]), + (141.45, [0.0005, 0.0005, -0.0002]), + (142.25, [0.0007, 0.0005, 0.0018]), + (143.05, [0.0089, 0.0006, 0.0181]), + (146.25, [0.0507, 0.0019, 0.0961]), + (147.05, [0.0646, 0.0177, 0.1092]), + (147.85, [0.0646, 0.0354, 0.106]), + (149.44, [0.0641, 0.0688, 0.0854]), + (150.24, [0.0671, 0.0888, 0.0803]), + (151.04, [0.0782, 0.0999, 0.0644]), + (151.84, [0.082, 0.0981, 0.0429]), + (154.24, [0.1079, 0.076, 0.0143]), + (155.04, [0.1048, 0.0597, 0.004]), + (155.84, [0.0847, 0.0409, 0.0002]), + (156.64, [0.0656, 0.0328, 0.0004]), + (159.03, [0.0057, 0.0022, 0.0001]), + (159.83, [0.0, 0.0003, 0.0001]), + ], + }, +]; +const ZOOM_150_STREAKS: &[Streak] = &[]; +const ZOOM_150_RAYS: &[Ray] = &[ + Ray { angle: 0.26, r0: 35.938, r1: 518.75, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 8.22, r0: 74.219, r1: 186.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 15.49, r0: 21.875, r1: 221.88, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 16.41, r0: 129.69, r1: 578.91, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 17.32, r0: 59.375, r1: 277.34, fade: 0.05, rgb: [0.0176, 0.0255, 0.0235] }, + Ray { angle: 24.5, r0: 40.625, r1: 258.59, fade: 0.05, rgb: [0.0127, 0.0196, 0.0176] }, + Ray { angle: 29.61, r0: 32.812, r1: 135.94, fade: 0.05, rgb: [0.0059, 0.0157, 0.0118] }, + Ray { angle: 34.95, r0: 100.78, r1: 202.34, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 35.98, r0: 118.75, r1: 527.34, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 38.93, r0: 39.062, r1: 212.5, fade: 0.05, rgb: [0.0078, 0.0216, 0.0176] }, + Ray { angle: 43.12, r0: 30.469, r1: 486.72, fade: 0.05, rgb: [0.0196, 0.0235, 0.0196] }, + Ray { angle: 53.28, r0: 44.531, r1: 260.16, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 62.91, r0: 35.156, r1: 283.59, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 64.81, r0: 66.406, r1: 446.88, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 70.06, r0: 37.5, r1: 117.97, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 71.94, r0: 78.125, r1: 325.0, fade: 0.05, rgb: [0.0118, 0.0137, 0.0118] }, + Ray { angle: 73.8, r0: 155.47, r1: 264.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.1, r0: 73.438, r1: 411.72, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 80.97, r0: 66.406, r1: 409.38, fade: 0.05, rgb: [0.0196, 0.0235, 0.0206] }, + Ray { angle: 86.35, r0: 28.125, r1: 159.38, fade: 0.05, rgb: [0.0098, 0.0235, 0.0196] }, + Ray { angle: 92.47, r0: 20.312, r1: 178.91, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 93.39, r0: 117.97, r1: 404.69, fade: 0.05, rgb: [0.0255, 0.0275, 0.0275] }, + Ray { angle: 104.37, r0: 50.0, r1: 417.19, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 109.6, r0: 119.53, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 110.66, r0: 27.344, r1: 285.94, fade: 0.05, rgb: [0.0186, 0.0294, 0.0255] }, + Ray { angle: 112.97, r0: 139.84, r1: 439.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.69, r0: 67.969, r1: 408.59, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 118.78, r0: 43.75, r1: 267.19, fade: 0.05, rgb: [0.0157, 0.0235, 0.0196] }, + Ray { angle: 129.12, r0: 77.344, r1: 371.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 131.03, r0: 47.656, r1: 353.12, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 135.41, r0: 47.656, r1: 196.88, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 136.28, r0: 141.41, r1: 324.22, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 138.17, r0: 74.219, r1: 314.06, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 145.43, r0: 72.656, r1: 284.38, fade: 0.05, rgb: [0.0098, 0.0127, 0.0118] }, + Ray { angle: 147.38, r0: 54.688, r1: 278.12, fade: 0.05, rgb: [0.0078, 0.0137, 0.0098] }, + Ray { angle: 148.84, r0: 135.94, r1: 269.53, fade: 0.05, rgb: [0.0118, 0.0147, 0.0137] }, + Ray { angle: 149.99, r0: 58.594, r1: 132.03, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 152.57, r0: 64.062, r1: 263.28, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 153.61, r0: 100.78, r1: 260.94, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 155.92, r0: 25.0, r1: 256.25, fade: 0.05, rgb: [0.0196, 0.0255, 0.0235] }, + Ray { angle: 156.99, r0: 29.688, r1: 167.19, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 161.68, r0: 21.875, r1: 246.88, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 163.07, r0: 73.438, r1: 244.53, fade: 0.05, rgb: [0.0235, 0.0294, 0.0294] }, + Ray { angle: 168.83, r0: 27.344, r1: 238.28, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 169.09, r0: 26.562, r1: 165.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 169.35, r0: 24.219, r1: 238.28, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 171.23, r0: 133.59, r1: 232.81, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 172.33, r0: 42.969, r1: 113.28, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 176.44, r0: 70.312, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 183.1, r0: 144.53, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 186.51, r0: 45.312, r1: 235.16, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 193.72, r0: 26.562, r1: 240.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 194.6, r0: 124.22, r1: 189.84, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 200.85, r0: 74.219, r1: 215.62, fade: 0.05, rgb: [0.0078, 0.0088, 0.0078] }, + Ray { angle: 202.7, r0: 119.53, r1: 253.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 206.99, r0: 39.062, r1: 121.09, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 208.03, r0: 114.06, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 209.13, r0: 60.156, r1: 155.47, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 213.3, r0: 33.594, r1: 279.69, fade: 0.05, rgb: [0.0216, 0.0294, 0.0275] }, + Ray { angle: 217.06, r0: 90.625, r1: 292.97, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 218.09, r0: 85.156, r1: 286.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 220.45, r0: 71.875, r1: 300.78, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 224.21, r0: 116.41, r1: 279.69, fade: 0.05, rgb: [0.0157, 0.0206, 0.0196] }, + Ray { angle: 225.0, r0: 25.0, r1: 153.91, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 225.99, r0: 99.219, r1: 262.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 227.35, r0: 92.188, r1: 264.84, fade: 0.05, rgb: [0.0078, 0.0088, 0.0078] }, + Ray { angle: 229.33, r0: 53.125, r1: 163.28, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 232.21, r0: 59.375, r1: 246.88, fade: 0.05, rgb: [0.0137, 0.0235, 0.0196] }, + Ray { angle: 234.55, r0: 37.5, r1: 182.03, fade: 0.05, rgb: [0.0118, 0.0235, 0.0216] }, + Ray { angle: 240.32, r0: 72.656, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 241.75, r0: 80.469, r1: 221.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 243.65, r0: 65.625, r1: 217.19, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 245.61, r0: 67.969, r1: 214.06, fade: 0.05, rgb: [0.0098, 0.0157, 0.0147] }, + Ray { angle: 250.9, r0: 56.25, r1: 206.25, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 254.7, r0: 88.281, r1: 149.22, fade: 0.05, rgb: [0.0078, 0.0118, 0.0078] }, + Ray { angle: 259.82, r0: 49.219, r1: 197.66, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 263.29, r0: 128.91, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 264.18, r0: 103.12, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 269.05, r0: 43.75, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 276.15, r0: 71.094, r1: 196.09, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 283.38, r0: 85.938, r1: 200.0, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 284.81, r0: 139.06, r1: 201.56, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 285.69, r0: 60.156, r1: 202.34, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 287.51, r0: 62.5, r1: 204.69, fade: 0.05, rgb: [0.0176, 0.0255, 0.0235] }, + Ray { angle: 288.62, r0: 103.91, r1: 205.47, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 295.82, r0: 42.188, r1: 216.41, fade: 0.05, rgb: [0.0157, 0.0225, 0.0196] }, + Ray { angle: 297.53, r0: 198.44, r1: 213.28, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 299.16, r0: 139.84, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0127, 0.0137] }, + Ray { angle: 299.88, r0: 35.156, r1: 208.59, fade: 0.05, rgb: [0.0118, 0.0216, 0.0196] }, + Ray { angle: 302.95, r0: 42.969, r1: 232.03, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 303.92, r0: 118.75, r1: 235.16, fade: 0.05, rgb: [0.0147, 0.0176, 0.0167] }, + Ray { angle: 305.74, r0: 76.562, r1: 227.34, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 306.33, r0: 189.06, r1: 242.19, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 310.14, r0: 73.438, r1: 255.47, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 319.2, r0: 78.125, r1: 298.44, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 320.63, r0: 59.375, r1: 307.81, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 322.58, r0: 60.938, r1: 321.09, fade: 0.05, rgb: [0.0196, 0.0255, 0.0235] }, + Ray { angle: 337.64, r0: 49.219, r1: 136.72, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 340.61, r0: 32.031, r1: 376.56, fade: 0.05, rgb: [0.0275, 0.0353, 0.0314] }, + Ray { angle: 344.88, r0: 25.781, r1: 169.53, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 345.83, r0: 75.781, r1: 263.28, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, +]; +const ZOOM_200_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.3625, 0.3726, 0.4423]), + (0.8, [0.3691, 0.3775, 0.4453]), + (39.2, [0.3683, 0.3739, 0.4442]), + (69.6, [0.3817, 0.3856, 0.4472]), + (110.4, [0.4177, 0.4213, 0.4617]), + (149.6, [0.4662, 0.4729, 0.4832]), + (182.4, [0.5306, 0.5391, 0.5134]), + (216.8, [0.6266, 0.651, 0.5531]), + (233.6, [0.6799, 0.6727, 0.5738]), + (239.2, [0.6988, 0.6662, 0.5814]), + (259.2, [0.7592, 0.6409, 0.5601]), + (266.4, [0.7581, 0.6298, 0.5527]), + (286.4, [0.7018, 0.5604, 0.5248]), + (298.4, [0.6811, 0.545, 0.5285]), + (304.8, [0.6329, 0.5083, 0.5229]), + (315.2, [0.5491, 0.464, 0.4571]), + (327.2, [0.4265, 0.3719, 0.3772]), + (341.6, [0.2907, 0.2651, 0.3093]), + (348.0, [0.2412, 0.227, 0.2806]), + (356.8, [0.1877, 0.1811, 0.243]), + (364.8, [0.153, 0.1509, 0.217]), + (372.8, [0.1328, 0.1304, 0.198]), + (384.0, [0.1226, 0.1175, 0.1771]), + (393.6, [0.1215, 0.1177, 0.1681]), + (403.2, [0.1301, 0.1295, 0.1601]), + (412.0, [0.1471, 0.1447, 0.1701]), + (417.6, [0.1632, 0.159, 0.1761]), + (427.2, [0.2038, 0.1946, 0.194]), + (441.6, [0.2811, 0.2572, 0.2256]), + (461.6, [0.4147, 0.3507, 0.2815]), + (467.2, [0.4568, 0.3592, 0.2962]), + (470.4, [0.4751, 0.3357, 0.3009]), + (474.4, [0.4432, 0.2695, 0.3114]), + (478.4, [0.4136, 0.2159, 0.329]), + (482.4, [0.4001, 0.1827, 0.3485]), + (484.8, [0.3998, 0.1706, 0.355]), + (491.2, [0.3992, 0.1517, 0.3688]), + (494.4, [0.3979, 0.1504, 0.3717]), + (497.6, [0.3973, 0.1479, 0.3682]), + (506.4, [0.3905, 0.155, 0.3519]), + (515.2, [0.3696, 0.1647, 0.3186]), + (521.6, [0.3476, 0.1706, 0.2849]), + (525.6, [0.3305, 0.1705, 0.2573]), + (531.2, [0.297, 0.1675, 0.2153]), + (538.4, [0.243, 0.1549, 0.1593]), + (547.2, [0.1635, 0.134, 0.0893]), + (552.8, [0.0994, 0.1086, 0.0377]), + (556.0, [0.0567, 0.0926, 0.0091]), + (556.8, [0.0439, 0.0886, 0.0016]), + (557.6, [0.0331, 0.0849, -0.0083]), + (558.4, [0.0217, 0.0779, -0.015]), + (559.2, [0.0065, 0.0732, -0.0243]), + (560.8, [-0.0185, 0.0616, -0.041]), + (563.2, [-0.0611, 0.0413, -0.0688]), + (566.4, [-0.1265, 0.0088, -0.1066]), + (567.2, [-0.1418, -0.0022, -0.1164]), + (568.0, [-0.1592, -0.0095, -0.1272]), + (571.2, [-0.2288, -0.0476, -0.1628]), + (573.6, [-0.2833, -0.0792, -0.1872]), + (579.2, [-0.4091, -0.1594, -0.2315]), + (587.2, [-0.5751, -0.2766, -0.2762]), + (592.0, [-0.684, -0.354, -0.3165]), + (594.4, [-0.7244, -0.3841, -0.3191]), + (596.0, [-0.7356, -0.3922, -0.3034]), + (596.8, [-0.7359, -0.3972, -0.2895]), + (597.6, [-0.7299, -0.3991, -0.2646]), + (598.4, [-0.7076, -0.3841, -0.2309]), + (599.2, [-0.6684, -0.356, -0.1805]), + (600.0, [-0.6161, -0.3178, -0.1257]), + (601.6, [-0.4868, -0.2199, -0.0243]), + (602.4, [-0.4085, -0.1604, 0.0155]), + (603.2, [-0.3233, -0.0985, 0.0528]), + (604.0, [-0.2429, -0.0358, 0.0829]), + (604.8, [-0.149, 0.0226, 0.1111]), + (605.6, [-0.056, 0.0758, 0.1241]), + (606.4, [0.029, 0.1183, 0.1378]), + (607.2, [0.1047, 0.1543, 0.1493]), + (608.0, [0.1721, 0.1749, 0.158]), + (608.8, [0.2285, 0.1958, 0.157]), + (610.4, [0.2956, 0.2205, 0.1619]), + (611.2, [0.316, 0.2231, 0.162]), + (612.0, [0.3299, 0.2289, 0.1558]), + (614.4, [0.3545, 0.243, 0.1515]), + (615.2, [0.3629, 0.2389, 0.1446]), + (617.6, [0.3535, 0.2354, 0.1306]), + (620.0, [0.3318, 0.2091, 0.1127]), + (621.6, [0.315, 0.1935, 0.1055]), + (622.4, [0.2954, 0.1754, 0.093]), + (625.6, [0.2333, 0.1166, 0.0585]), + (628.0, [0.19, 0.0897, 0.046]), + (628.8, [0.1703, 0.0793, 0.0326]), + (630.4, [0.134, 0.0595, 0.0199]), + (631.2, [0.1184, 0.0527, 0.0156]), + (632.0, [0.1027, 0.0471, 0.0157]), + (632.8, [0.0946, 0.0428, 0.008]), + (633.6, [0.0861, 0.0297, 0.0045]), + (634.4, [0.0761, 0.0243, 0.0032]), + (635.2, [0.0602, 0.0198, 0.0039]), + (636.8, [0.0437, 0.0128, 0.0139]), + (637.6, [0.0364, 0.0119, 0.0206]), + (640.0, [0.0232, 0.0266, 0.0405]), + (640.8, [0.0206, 0.0335, 0.0518]), + (641.6, [0.0193, 0.0313, 0.0617]), + (642.4, [0.0184, 0.0357, 0.0692]), + (643.2, [0.0266, 0.0402, 0.0737]), + (644.0, [0.0338, 0.0443, 0.074]), + (645.6, [0.0447, 0.0527, 0.0829]), + (647.2, [0.0372, 0.0515, 0.0774]), + (648.0, [0.0284, 0.0508, 0.07]), + (649.6, [0.0022, 0.0506, 0.0597]), + (650.4, [-0.0067, 0.0395, 0.0538]), + (651.2, [-0.0149, 0.0319, 0.0483]), + (652.0, [-0.0216, 0.0269, 0.0468]), + (652.8, [-0.0258, 0.024, 0.0453]), + (653.6, [-0.024, 0.0232, 0.0442]), + (654.4, [-0.0169, 0.028, 0.0433]), + (656.0, [0.004, 0.0271, 0.0441]), + (656.8, [0.0176, 0.0295, 0.0356]), + (657.6, [0.0237, 0.0325, 0.0309]), + (659.2, [0.0466, 0.0369, 0.02]), + (660.0, [0.0551, 0.0289, 0.0171]), + (660.8, [0.0635, 0.0226, 0.0109]), + (661.6, [0.0605, 0.0158, 0.0059]), + (662.4, [0.063, 0.0095, 0.0025]), + (663.2, [0.0677, 0.0046, 0.002]), + (664.0, [0.0748, 0.0027, 0.0048]), + (664.8, [0.0861, 0.005, 0.0028]), + (667.2, [0.0811, 0.0191, 0.0233]), + (668.8, [0.0692, 0.0253, 0.035]), + (669.6, [0.0651, 0.0166, 0.0282]), + (670.4, [0.0535, 0.014, 0.0267]), + (671.2, [0.0455, 0.0121, 0.0254]), + (673.6, [0.0313, 0.0136, 0.0249]), + (674.4, [0.0219, 0.0085, 0.0175]), + (676.0, [0.0158, 0.0125, 0.0158]), + (677.6, [0.003, 0.0103, 0.0076]), + (678.4, [0.0006, 0.0083, 0.0028]), + (679.2, [0.0002, 0.0085, 0.0044]), + (680.0, [0.0052, 0.0084, 0.0077]), + (681.6, [0.0169, 0.0228, 0.0187]), + (682.4, [0.0179, 0.0278, 0.0228]), + (683.2, [0.0147, 0.0306, 0.0254]), + (684.0, [0.0063, 0.0347, 0.0294]), + (684.8, [0.0091, 0.0435, 0.0273]), + (686.4, [0.0204, 0.0328, 0.0317]), + (687.2, [0.0222, 0.0281, 0.0356]), + (688.0, [0.0196, 0.0229, 0.0391]), + (688.8, [0.0093, 0.0181, 0.0432]), + (689.6, [0.0105, 0.0154, 0.0494]), + (690.4, [0.0113, 0.0142, 0.0367]), + (691.2, [0.0113, 0.0193, 0.0254]), + (692.8, [0.0108, 0.0054, 0.0031]), + (693.6, [0.0159, -0.0041, -0.0119]), + (694.4, [0.0607, -0.0115, -0.0256]), + (695.2, [0.0829, 0.0035, -0.0052]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.6333, 0.6162, 0.5536]), + (38.4, [0.6351, 0.6207, 0.5559]), + (69.6, [0.6218, 0.6092, 0.553]), + (114.4, [0.582, 0.5696, 0.5368]), + (153.6, [0.5323, 0.5166, 0.5148]), + (177.6, [0.4858, 0.4694, 0.4928]), + (194.4, [0.4443, 0.4233, 0.4744]), + (216.0, [0.3822, 0.3496, 0.4493]), + (232.8, [0.3295, 0.2717, 0.4287]), + (235.2, [0.3217, 0.2662, 0.4258]), + (266.4, [0.2249, 0.2072, 0.3548]), + (285.6, [0.2111, 0.2158, 0.3183]), + (288.8, [0.2079, 0.2131, 0.3104]), + (296.0, [0.1873, 0.1979, 0.2855]), + (300.8, [0.1984, 0.1973, 0.2636]), + (304.8, [0.2159, 0.2129, 0.2599]), + (314.4, [0.259, 0.2251, 0.2915]), + (327.2, [0.3476, 0.2852, 0.336]), + (340.8, [0.4316, 0.3482, 0.3603]), + (347.2, [0.4613, 0.3707, 0.3703]), + (360.0, [0.4963, 0.4002, 0.3852]), + (374.4, [0.494, 0.4009, 0.3832]), + (386.4, [0.4682, 0.3836, 0.3695]), + (396.0, [0.4403, 0.3582, 0.3557]), + (402.4, [0.417, 0.3355, 0.3441]), + (418.4, [0.3375, 0.264, 0.2862]), + (429.6, [0.2597, 0.1982, 0.2389]), + (440.8, [0.1718, 0.1253, 0.1886]), + (452.0, [0.0718, 0.0465, 0.1342]), + (457.6, [0.0204, 0.0099, 0.1061]), + (460.8, [-0.0073, -0.0083, 0.0908]), + (463.2, [-0.0299, -0.0194, 0.0785]), + (465.6, [-0.0515, -0.0262, 0.0686]), + (466.4, [-0.0626, -0.0288, 0.0638]), + (468.8, [-0.0845, -0.0204, 0.0539]), + (469.6, [-0.0879, -0.0132, 0.0524]), + (470.4, [-0.0881, -0.0037, 0.0472]), + (472.0, [-0.0798, 0.022, 0.0406]), + (473.6, [-0.0676, 0.0449, 0.0343]), + (474.4, [-0.0598, 0.0569, 0.0283]), + (475.2, [-0.0567, 0.0652, 0.0249]), + (476.0, [-0.0516, 0.0751, 0.0181]), + (477.6, [-0.0462, 0.0908, 0.0088]), + (478.4, [-0.044, 0.099, 0.0023]), + (479.2, [-0.0434, 0.1033, -0.0018]), + (480.0, [-0.0409, 0.1088, -0.0089]), + (480.8, [-0.0423, 0.113, -0.0135]), + (481.6, [-0.0399, 0.1164, -0.0189]), + (482.4, [-0.0429, 0.1205, -0.024]), + (483.2, [-0.0422, 0.1217, -0.0276]), + (485.6, [-0.0501, 0.1273, -0.0408]), + (488.8, [-0.0563, 0.1316, -0.0525]), + (489.6, [-0.0594, 0.1306, -0.0567]), + (491.2, [-0.0622, 0.1325, -0.0596]), + (492.8, [-0.062, 0.1305, -0.0631]), + (493.6, [-0.065, 0.1282, -0.0669]), + (494.4, [-0.0641, 0.1275, -0.0678]), + (497.6, [-0.0712, 0.1234, -0.0707]), + (501.6, [-0.078, 0.1116, -0.0715]), + (504.8, [-0.0827, 0.1039, -0.0697]), + (508.8, [-0.0827, 0.095, -0.0647]), + (514.4, [-0.0784, 0.0776, -0.0529]), + (519.2, [-0.0721, 0.0647, -0.0371]), + (520.0, [-0.0691, 0.063, -0.0329]), + (520.8, [-0.07, 0.0631, -0.0308]), + (521.6, [-0.0663, 0.0595, -0.0254]), + (522.4, [-0.0664, 0.0584, -0.0202]), + (525.6, [-0.0563, 0.0555, -0.0076]), + (527.2, [-0.0508, 0.0514, 0.0034]), + (528.0, [-0.0463, 0.0521, 0.0056]), + (528.8, [-0.0451, 0.0494, 0.0103]), + (530.4, [-0.0355, 0.0508, 0.0197]), + (531.2, [-0.0325, 0.0489, 0.0248]), + (532.8, [-0.021, 0.0515, 0.0334]), + (533.6, [-0.0187, 0.0488, 0.0395]), + (534.4, [-0.0127, 0.0496, 0.0439]), + (536.0, [-0.0052, 0.0496, 0.0558]), + (536.8, [0.0008, 0.0499, 0.0588]), + (538.4, [0.0098, 0.0502, 0.0705]), + (539.2, [0.0166, 0.0499, 0.073]), + (540.0, [0.0193, 0.0508, 0.0795]), + (540.8, [0.0259, 0.0531, 0.0862]), + (541.6, [0.0305, 0.0516, 0.0899]), + (548.8, [0.0881, 0.0641, 0.137]), + (551.2, [0.1133, 0.0746, 0.1558]), + (552.0, [0.1209, 0.0748, 0.1612]), + (557.6, [0.19, 0.0965, 0.2059]), + (563.2, [0.2771, 0.1343, 0.2608]), + (568.8, [0.3844, 0.1874, 0.3193]), + (572.8, [0.4663, 0.233, 0.3532]), + (576.8, [0.551, 0.2859, 0.3823]), + (586.4, [0.7353, 0.4119, 0.4223]), + (591.2, [0.8376, 0.4824, 0.4573]), + (594.4, [0.8812, 0.5129, 0.4523]), + (596.0, [0.8836, 0.522, 0.422]), + (596.8, [0.8734, 0.5188, 0.3962]), + (597.6, [0.8473, 0.5018, 0.36]), + (599.2, [0.7513, 0.4335, 0.2469]), + (600.0, [0.6856, 0.3825, 0.1952]), + (600.8, [0.6151, 0.3298, 0.1443]), + (601.6, [0.5357, 0.2694, 0.1018]), + (602.4, [0.4489, 0.2065, 0.0647]), + (603.2, [0.3649, 0.1402, 0.0347]), + (604.0, [0.2697, 0.0811, 0.0065]), + (604.8, [0.1772, 0.0287, -0.0089]), + (605.6, [0.0898, -0.0129, -0.0223]), + (606.4, [0.0133, -0.0493, -0.0336]), + (607.2, [-0.0537, -0.0719, -0.0425]), + (608.0, [-0.1102, -0.0927, -0.044]), + (608.8, [-0.1491, -0.1074, -0.047]), + (609.6, [-0.1778, -0.1165, -0.0485]), + (610.4, [-0.1972, -0.1229, -0.0483]), + (611.2, [-0.2107, -0.1282, -0.0461]), + (613.6, [-0.2379, -0.1438, -0.0413]), + (614.4, [-0.2482, -0.141, -0.0383]), + (616.0, [-0.2441, -0.1386, -0.0302]), + (616.8, [-0.2385, -0.1382, -0.0235]), + (620.0, [-0.2119, -0.1059, -0.0056]), + (620.8, [-0.2049, -0.0997, -0.0044]), + (621.6, [-0.1867, -0.0831, 0.0064]), + (623.2, [-0.1545, -0.0545, 0.0224]), + (624.0, [-0.145, -0.0401, 0.0307]), + (624.8, [-0.129, -0.0292, 0.0362]), + (626.4, [-0.0989, -0.0118, 0.0438]), + (627.2, [-0.0884, -0.0022, 0.049]), + (628.0, [-0.0692, 0.0085, 0.0588]), + (628.8, [-0.0507, 0.0157, 0.0655]), + (630.4, [-0.0181, 0.0303, 0.0751]), + (631.2, [-0.0072, 0.0357, 0.0725]), + (632.0, [0.0008, 0.0396, 0.0789]), + (632.8, [0.0095, 0.0493, 0.0821]), + (634.4, [0.0314, 0.0589, 0.0819]), + (636.0, [0.0475, 0.0656, 0.0673]), + (636.8, [0.0547, 0.0617, 0.0604]), + (637.6, [0.0546, 0.0576, 0.053]), + (638.4, [0.0597, 0.0524, 0.0462]), + (639.2, [0.0633, 0.0464, 0.0358]), + (640.0, [0.0657, 0.0392, 0.0245]), + (640.8, [0.0667, 0.0381, 0.0149]), + (641.6, [0.0624, 0.0337, 0.0078]), + (643.2, [0.0472, 0.0252, -0.0012]), + (644.0, [0.0426, 0.0224, -0.007]), + (644.8, [0.0342, 0.0142, -0.009]), + (645.6, [0.0368, 0.0147, -0.0075]), + (646.4, [0.042, 0.0147, -0.0023]), + (648.0, [0.0639, 0.0168, 0.0056]), + (648.8, [0.0735, 0.0141, 0.0114]), + (649.6, [0.0823, 0.0243, 0.0173]), + (650.4, [0.0903, 0.0313, 0.0226]), + (651.2, [0.0967, 0.0361, 0.0198]), + (652.0, [0.1003, 0.0386, 0.0211]), + (652.8, [0.0933, 0.039, 0.0222]), + (653.6, [0.0858, 0.031, 0.0231]), + (654.4, [0.0761, 0.0319, 0.0237]), + (655.2, [0.0641, 0.031, 0.021]), + (656.0, [0.0502, 0.0284, 0.0275]), + (658.4, [0.0182, 0.0198, 0.0433]), + (659.2, [0.0099, 0.0265, 0.0426]), + (660.0, [0.0007, 0.033, 0.0485]), + (660.8, [0.0015, 0.0397, 0.0533]), + (662.4, [-0.0063, 0.0505, 0.0564]), + (663.2, [-0.0139, 0.0479, 0.0532]), + (664.0, [-0.0271, 0.045, 0.0509]), + (665.6, [-0.0254, 0.0353, 0.0372]), + (666.4, [-0.022, 0.0309, 0.0304]), + (668.0, [-0.0097, 0.0249, 0.019]), + (668.8, [-0.0079, 0.0307, 0.0229]), + (669.6, [0.0025, 0.0332, 0.0244]), + (670.4, [0.0102, 0.0349, 0.0256]), + (671.2, [0.0163, 0.0353, 0.026]), + (672.8, [0.0237, 0.0329, 0.0259]), + (673.6, [0.029, 0.034, 0.0295]), + (675.2, [0.0356, 0.0302, 0.0314]), + (676.8, [0.0487, 0.0329, 0.0402]), + (677.6, [0.0469, 0.0348, 0.0447]), + (678.4, [0.046, 0.034, 0.0382]), + (679.2, [0.0405, 0.0296, 0.0346]), + (680.8, [0.0294, 0.0152, 0.0235]), + (681.6, [0.0291, 0.0108, 0.0197]), + (683.2, [0.0364, 0.0034, 0.0125]), + (684.0, [0.033, -0.0068, 0.0119]), + (685.6, [0.0219, 0.003, 0.0068]), + (686.4, [0.0208, 0.0079, 0.0029]), + (688.0, [0.0297, 0.0176, -0.0049]), + (688.8, [0.0282, 0.02, -0.0121]), + (689.6, [0.0275, 0.0207, 0.0003]), + (690.4, [0.0274, 0.0123, 0.0112]), + (692.0, [0.0278, 0.0269, 0.0338]), + (692.8, [0.016, 0.0363, 0.049]), + (693.6, [-0.039, 0.0428, 0.0618]), + (694.4, [0.0327, 0.0014, -0.002]), + (695.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.002, 0.0091, 0.0027]), + (0.79967, [-0.0043, 0.0051, -0.0002]), + (1.5993, [-0.0027, 0.006, 0.0003]), + (195.92, [-0.0045, 0.004, -0.0007]), + (271.89, [-0.0069, 0.0018, -0.0017]), + (278.28, [-0.0077, -0.0002, -0.0018]), + (328.66, [-0.0096, -0.0025, -0.0031]), + (372.64, [-0.0076, -0.001, -0.0028]), + (391.84, [-0.0028, 0.0021, -0.0007]), + (398.23, [-0.0046, 0.0012, 0.0007]), + (405.43, [-0.0048, 0.0014, -0.0019]), + (410.23, [-0.0038, 0.0024, -0.0014]), + (467.0, [-0.0071, -0.0006, -0.0027]), + (513.39, [-0.0132, -0.0055, -0.0061]), + (546.17, [-0.0156, -0.0062, -0.0079]), + (565.36, [-0.0139, -0.0042, -0.0087]), + (632.54, [-0.0132, -0.0065, -0.0071]), + (654.93, [-0.0095, -0.0047, -0.0049]), + (683.71, [-0.0015, -0.0007, -0.0016]), + ], + }, + Radial { t: 0.8008, perp: -0.19394, knots: &[(0.0, [0.0024, 0.0016, 0.0012]), (3.1854, [0.0019, 0.0013, 0.0009])] }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.0089, 0.0922, 0.0416]), + (0.79661, [0.0086, 0.0811, 0.0382]), + (1.5932, [0.0087, 0.0745, 0.0338]), + (3.1864, [0.0086, 0.0397, 0.024]), + (3.983, [0.0095, 0.0312, 0.0214]), + (5.5762, [0.0094, 0.0206, 0.0178]), + (6.3728, [0.0087, 0.013, 0.016]), + (8.7627, [0.0071, 0.0033, 0.011]), + (9.5593, [0.0066, 0.0018, 0.0107]), + (15.135, [0.0061, 0.0031, 0.0053]), + (19.119, [0.0013, 0.0, 0.0014]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [1.516, 1.313, 1.143]), + (5.5792, [1.463, 1.274, 1.101]), + (9.5643, [1.354, 1.196, 1.017]), + (15.143, [1.114, 1.022, 0.835]), + (23.114, [0.7349, 0.7341, 0.4867]), + (25.505, [0.6323, 0.6548, 0.3927]), + (28.693, [0.5081, 0.5531, 0.2848]), + (31.881, [0.3964, 0.4529, 0.197]), + (34.272, [0.3278, 0.385, 0.1459]), + (36.663, [0.272, 0.3236, 0.107]), + (38.257, [0.2409, 0.2883, 0.088]), + (39.851, [0.2128, 0.2551, 0.0735]), + (43.039, [0.1675, 0.2001, 0.0557]), + (45.43, [0.1429, 0.1679, 0.0491]), + (47.822, [0.126, 0.1434, 0.0454]), + (51.01, [0.1135, 0.122, 0.0424]), + (54.198, [0.105, 0.1076, 0.0399]), + (55.792, [0.0698, 0.071, 0.0382]), + (62.965, [0.0417, 0.0463, 0.0269]), + (70.138, [0.0267, 0.0322, 0.0178]), + (75.717, [0.0186, 0.0238, 0.0131]), + (96.44, [0.0033, 0.0049, 0.0026]), + (109.19, [-0.0027, -0.0019, -0.0016]), + (129.92, [-0.002, -0.0013, -0.001]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [-0.5004, -0.3986, -0.343]), + (0.7959, [-0.49, -0.3992, -0.3318]), + (5.5713, [-0.4694, -0.3848, -0.3193]), + (10.347, [-0.4158, -0.3466, -0.2849]), + (14.326, [-0.3525, -0.2988, -0.2377]), + (19.102, [-0.2684, -0.2363, -0.1671]), + (24.673, [-0.1868, -0.1744, -0.0871]), + (27.856, [-0.1438, -0.1435, -0.055]), + (29.448, [-0.1249, -0.1269, -0.0428]), + (31.04, [-0.1172, -0.1247, -0.0321]), + (32.632, [-0.1013, -0.1115, -0.0217]), + (35.019, [-0.0781, -0.0891, -0.0086]), + (36.611, [-0.066, -0.0743, -0.0027]), + (39.795, [-0.0496, -0.0548, 0.0031]), + (42.978, [-0.0349, -0.037, 0.0038]), + (46.958, [-0.0247, -0.0221, 0.0021]), + (49.346, [-0.0151, -0.0119, 0.001]), + (52.529, [-0.0067, -0.0043, 0.0002]), + (54.917, [-0.0015, -0.0007, -0.0002]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-1.06, -0.9091, -0.8607]), + (3.9791, [-1.045, -0.8977, -0.8451]), + (9.5498, [-0.9612, -0.8348, -0.7743]), + (15.916, [-0.7813, -0.7181, -0.6324]), + (23.875, [-0.5137, -0.5119, -0.3796]), + (26.262, [-0.4463, -0.4611, -0.3106]), + (29.445, [-0.3599, -0.3905, -0.2324]), + (32.629, [-0.2831, -0.3205, -0.1695]), + (35.812, [-0.2203, -0.2571, -0.1208]), + (38.995, [-0.1725, -0.2026, -0.0865]), + (42.178, [-0.1352, -0.158, -0.0677]), + (46.157, [-0.1055, -0.1181, -0.0567]), + (50.137, [-0.0859, -0.0906, -0.051]), + (51.728, [-0.0897, -0.087, -0.049]), + (54.116, [-0.0879, -0.0817, -0.0459]), + (58.095, [-0.0713, -0.0714, -0.0408]), + (60.482, [-0.0575, -0.0597, -0.0365]), + (68.44, [-0.0371, -0.0413, -0.0237]), + (77.194, [-0.0221, -0.0264, -0.0145]), + (89.132, [-0.0114, -0.0136, -0.0078]), + (104.25, [-0.0019, -0.0019, -0.0014]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.073, 0.1045, 0.2472]), + (3.1792, [0.0716, 0.1035, 0.2363]), + (5.5636, [0.0688, 0.0979, 0.2094]), + (8.7428, [0.0575, 0.0888, 0.1721]), + (10.332, [0.0548, 0.0853, 0.1547]), + (11.922, [0.057, 0.0893, 0.1385]), + (14.306, [0.0594, 0.0899, 0.1158]), + (17.486, [0.051, 0.0891, 0.0854]), + (19.075, [0.048, 0.0871, 0.0741]), + (21.46, [0.0444, 0.0803, 0.0624]), + (22.254, [0.0386, 0.0605, 0.0497]), + (23.049, [0.0305, 0.0303, 0.0286]), + (23.844, [0.0249, 0.0131, 0.0171]), + (25.434, [0.0189, 0.0081, 0.0137]), + (27.818, [0.0139, 0.0069, 0.0122]), + (34.971, [0.0107, 0.0056, 0.007]), + (44.509, [0.0031, 0.0015, 0.0013]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0077, 0.0082, 0.0022]), + (0.79904, [0.0044, 0.0068, 0.0031]), + (1.5981, [0.004, 0.0074, 0.0026]), + (15.981, [0.0039, 0.0049, 0.0036]), + (23.971, [0.0035, 0.0098, 0.0036]), + (25.569, [0.0071, 0.0108, 0.0036]), + (35.158, [0.0087, 0.0187, 0.0032]), + (46.344, [0.0135, 0.0315, 0.0031]), + (61.526, [0.0241, 0.0574, 0.0039]), + (71.913, [0.0337, 0.0795, 0.0028]), + (72.712, [0.0348, 0.0812, 0.0029]), + (73.511, [0.0446, 0.0855, 0.0032]), + (76.707, [0.0989, 0.1053, 0.0039]), + (77.507, [0.1042, 0.1074, 0.0044]), + (78.306, [0.0997, 0.1065, 0.0027]), + (79.105, [0.0703, 0.0572, 0.0022]), + (79.904, [0.0424, 0.0117, 0.0024]), + (81.502, [0.0149, 0.005, 0.0026]), + (82.301, [0.0046, 0.0026, 0.0026]), + (86.296, [0.0019, 0.0027, -0.0014]), + (89.492, [-0.0003, -0.0004, -0.0036]), + (93.487, [-0.004, -0.0034, -0.003]), + (95.884, [-0.0057, -0.0032, -0.0025]), + (102.28, [-0.003, -0.0017, -0.002]), + (135.84, [-0.0018, -0.001, -0.001]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0023, 0.0095, 0.002]), + (3.1966, [0.0028, 0.0077, 0.0006]), + (3.9958, [0.0031, 0.0093, 0.0006]), + (12.787, [0.0034, 0.0095, 0.001]), + (19.18, [0.0086, 0.0116, 0.0008]), + (23.176, [0.0042, 0.0064, 0.0003]), + (27.971, [0.0032, 0.0047, 0.0011]), + (77.519, [0.0029, 0.0024, 0.0017]), + (113.48, [0.0002, 0.0002, 0.001]), + (136.66, [-0.0003, 0.0004, 0.0005]), + (141.45, [0.0013, 0.0015, 0.0007]), + (142.25, [0.0024, 0.0015, 0.0027]), + (143.05, [0.0102, 0.0015, 0.0194]), + (143.85, [0.0222, 0.0015, 0.0388]), + (144.65, [0.0301, 0.0016, 0.0578]), + (145.45, [0.041, 0.0016, 0.0762]), + (146.25, [0.0529, 0.0044, 0.0963]), + (147.05, [0.0648, 0.0197, 0.1066]), + (147.85, [0.0638, 0.0373, 0.1031]), + (149.44, [0.0616, 0.0721, 0.0837]), + (150.24, [0.0674, 0.0906, 0.0765]), + (151.04, [0.0739, 0.0999, 0.0626]), + (151.84, [0.0782, 0.0968, 0.0413]), + (154.24, [0.1045, 0.0755, 0.014]), + (155.04, [0.1004, 0.0603, 0.004]), + (155.84, [0.081, 0.0422, 0.0005]), + (156.64, [0.0631, 0.034, 0.0006]), + (159.03, [0.0051, 0.003, 0.0003]), + (159.83, [-0.0005, 0.0004, 0.0001]), + ], + }, +]; +const ZOOM_200_STREAKS: &[Streak] = &[]; +const ZOOM_200_RAYS: &[Ray] = &[]; +const ZOOM_300_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.4969, 0.4969, 0.4969]), + (371.2, [0.4954, 0.4954, 0.4954]), + (487.2, [0.504, 0.504, 0.504]), + (539.2, [0.494, 0.494, 0.494]), + (568.0, [0.5075, 0.5075, 0.5075]), + (604.8, [0.5451, 0.5451, 0.5451]), + (624.0, [0.4924, 0.4924, 0.4924]), + (631.2, [0.5038, 0.5038, 0.5038]), + (680.0, [0.5024, 0.5024, 0.5024]), + (684.0, [0.4947, 0.4947, 0.4947]), + (689.6, [0.5146, 0.5146, 0.5146]), + (692.0, [0.491, 0.491, 0.491]), + (694.4, [0.421, 0.421, 0.421]), + (695.2, [0.5488, 0.5488, 0.5488]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.5024, 0.5024, 0.5024]), + (372.8, [0.504, 0.504, 0.504]), + (486.4, [0.4956, 0.4956, 0.4956]), + (538.4, [0.5056, 0.5056, 0.5056]), + (568.0, [0.4916, 0.4916, 0.4916]), + (604.0, [0.4548, 0.4548, 0.4548]), + (611.2, [0.469, 0.469, 0.469]), + (622.4, [0.5073, 0.5073, 0.5073]), + (629.6, [0.4962, 0.4962, 0.4962]), + (678.4, [0.4975, 0.4975, 0.4975]), + (683.2, [0.5052, 0.5052, 0.5052]), + (688.8, [0.4856, 0.4856, 0.4856]), + (691.2, [0.5117, 0.5117, 0.5117]), + (693.6, [0.5835, 0.5835, 0.5835]), + (694.4, [0.2167, 0.2167, 0.2167]), + (695.2, [0.0, 0.0, 0.0]), + ], + }, +]; +const ZOOM_300_STREAKS: &[Streak] = &[]; +const ZOOM_300_RAYS: &[Ray] = &[]; +pub(super) const ZOOM: &[Level] = &[ + Level { brightness: 10.0, radial: ZOOM_10_RADIAL, streaks: ZOOM_10_STREAKS, rays: ZOOM_10_RAYS }, + Level { brightness: 50.0, radial: ZOOM_50_RADIAL, streaks: ZOOM_50_STREAKS, rays: ZOOM_50_RAYS }, + Level { brightness: 75.0, radial: ZOOM_75_RADIAL, streaks: ZOOM_75_STREAKS, rays: ZOOM_75_RAYS }, + Level { brightness: 100.0, radial: ZOOM_100_RADIAL, streaks: ZOOM_100_STREAKS, rays: ZOOM_100_RAYS }, + Level { brightness: 125.0, radial: ZOOM_125_RADIAL, streaks: ZOOM_125_STREAKS, rays: ZOOM_125_RAYS }, + Level { brightness: 150.0, radial: ZOOM_150_RADIAL, streaks: ZOOM_150_STREAKS, rays: ZOOM_150_RAYS }, + Level { brightness: 200.0, radial: ZOOM_200_RADIAL, streaks: ZOOM_200_STREAKS, rays: ZOOM_200_RAYS }, + Level { brightness: 300.0, radial: ZOOM_300_RADIAL, streaks: ZOOM_300_STREAKS, rays: ZOOM_300_RAYS }, +]; + +const PRIME35_10_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.4306, 0.4221, 0.4191]), + (0.8, [0.3931, 0.3809, 0.3715]), + (1.6, [0.3745, 0.3477, 0.3356]), + (3.2, [0.2997, 0.2508, 0.2307]), + (4.8, [0.2312, 0.168, 0.1416]), + (6.4, [0.1839, 0.1101, 0.0847]), + (7.2, [0.1635, 0.0878, 0.0616]), + (8.0, [0.1476, 0.0712, 0.0456]), + (8.8, [0.1388, 0.0606, 0.0373]), + (9.6, [0.1349, 0.0595, 0.0376]), + (10.4, [0.1271, 0.0544, 0.0377]), + (11.2, [0.1222, 0.0506, 0.0338]), + (14.4, [0.101, 0.0395, 0.0282]), + (19.2, [0.0742, 0.0238, 0.0176]), + (20.0, [0.0711, 0.0209, 0.0187]), + (23.2, [0.0526, 0.0137, 0.0134]), + (26.4, [0.039, 0.0088, 0.0097]), + (28.8, [0.0318, 0.0056, 0.0098]), + (33.6, [0.0178, 0.0043, 0.0069]), + (41.6, [0.0028, 0.003, 0.0052]), + (45.6, [0.0013, 0.003, 0.0053]), + (132.0, [0.0015, 0.0047, 0.0061]), + (143.2, [0.0015, 0.0072, 0.0101]), + (157.6, [0.0015, 0.0116, 0.0185]), + (167.2, [0.0015, 0.0128, 0.0205]), + (235.2, [0.0014, 0.0079, 0.0126]), + (275.2, [0.0014, 0.0017, 0.0032]), + (301.6, [0.0013, 0.0004, 0.0015]), + (311.2, [0.0012, -0.0022, -0.0006]), + (331.2, [0.0029, 0.0003, 0.001]), + (343.2, [0.0005, -0.0008, -0.0009]), + (497.6, [0.001, -0.0067, -0.0109]), + (526.4, [0.0009, -0.0034, -0.0095]), + (596.0, [0.0002, -0.0088, -0.0194]), + (600.0, [0.0, -0.0072, -0.0171]), + (608.0, [-0.0003, -0.0001, -0.0027]), + (611.2, [0.0001, 0.001, 0.0004]), + (626.4, [0.0, -0.0001, -0.0031]), + (640.8, [0.0, 0.0, 0.0019]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.5493, 0.5456, 0.5422]), + (0.8, [0.4942, 0.4678, 0.4573]), + (1.6, [0.477, 0.44, 0.4255]), + (3.2, [0.3855, 0.3195, 0.2949]), + (4.8, [0.3029, 0.2169, 0.1839]), + (6.4, [0.245, 0.1448, 0.1129]), + (7.2, [0.2166, 0.114, 0.0817]), + (8.0, [0.1972, 0.0942, 0.0614]), + (8.8, [0.1853, 0.0809, 0.0517]), + (9.6, [0.1805, 0.079, 0.0498]), + (10.4, [0.1694, 0.072, 0.049]), + (12.8, [0.1485, 0.0569, 0.0379]), + (14.4, [0.1348, 0.0505, 0.0342]), + (15.2, [0.1298, 0.0468, 0.0291]), + (16.0, [0.1231, 0.0438, 0.0291]), + (16.8, [0.1164, 0.0401, 0.0248]), + (17.6, [0.1098, 0.0348, 0.023]), + (18.4, [0.1037, 0.0332, 0.0215]), + (19.2, [0.0993, 0.0291, 0.0162]), + (20.0, [0.0952, 0.0251, 0.0178]), + (23.2, [0.0713, 0.015, 0.0098]), + (25.6, [0.0587, 0.0084, 0.0055]), + (27.2, [0.0515, 0.0067, 0.0021]), + (28.8, [0.0446, 0.0023, 0.0028]), + (30.4, [0.0381, 0.0021, -0.0006]), + (32.0, [0.0322, 0.0022, -0.0004]), + (32.8, [0.029, -0.0005, 0.0004]), + (34.4, [0.0249, -0.0003, -0.003]), + (36.8, [0.0173, 0.0001, -0.0028]), + (37.6, [0.0144, -0.0027, -0.0023]), + (39.2, [0.0118, -0.0028, -0.0054]), + (42.4, [0.0023, -0.003, -0.0052]), + (45.6, [-0.0019, -0.003, -0.0053]), + (132.0, [-0.0015, -0.0047, -0.0061]), + (143.2, [-0.0015, -0.0071, -0.0099]), + (157.6, [-0.0015, -0.0115, -0.0184]), + (167.2, [-0.0015, -0.0128, -0.0205]), + (236.0, [-0.0014, -0.0078, -0.0126]), + (275.2, [-0.0014, -0.0017, -0.0033]), + (301.6, [-0.0013, -0.0005, -0.0015]), + (311.2, [-0.0011, 0.0022, 0.0006]), + (331.2, [-0.003, -0.0003, -0.001]), + (343.2, [-0.0005, 0.0007, 0.0009]), + (498.4, [-0.001, 0.0067, 0.0109]), + (527.2, [-0.0009, 0.0034, 0.0096]), + (596.8, [-0.0002, 0.0088, 0.0195]), + (600.8, [0.0, 0.0074, 0.0173]), + (608.8, [0.0003, 0.0002, 0.0029]), + (612.0, [-0.0001, -0.0009, -0.0004]), + (620.8, [-0.0001, -0.0004, 0.0004]), + (627.2, [0.0, 0.0001, 0.0031]), + (641.6, [0.0, 0.0001, -0.0019]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.004, 0.0039, 0.0039]), + (31.962, [0.0043, 0.0039, 0.004]), + (33.56, [0.0002, 0.0039, 0.004]), + (49.542, [-0.0003, 0.0036, 0.0036]), + (151.82, [0.0, 0.0039, 0.0039]), + (152.62, [0.0, 0.0038, 0.0038]), + (153.42, [0.0, 0.001, 0.0009]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[(0.0, [0.0, 0.0001, 0.008]), (15.867, [0.0, 0.0001, 0.0081]), (33.32, [0.0, 0.0001, 0.0041]), (35.7, [0.0, 0.0001, 0.0014])], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[(0.0, [0.0005, 0.0005, 0.0005]), (4.7082, [0.0012, 0.0005, 0.0011]), (11.771, [0.0034, 0.0039, 0.0037]), (16.479, [0.0017, 0.0018, 0.0018])], + }, +]; +const PRIME35_10_STREAKS: &[Streak] = &[]; +const PRIME35_10_RAYS: &[Ray] = &[]; +const PRIME35_50_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.45, 1.46, 1.46]), + (0.8, [1.405, 1.415, 1.396]), + (1.6, [1.41, 1.428, 1.402]), + (4.8, [1.331, 1.368, 1.26]), + (8.0, [1.228, 1.236, 1.083]), + (11.2, [1.106, 1.055, 0.8968]), + (16.0, [0.8984, 0.7326, 0.6134]), + (19.2, [0.7595, 0.5209, 0.442]), + (20.8, [0.6893, 0.4211, 0.3628]), + (22.4, [0.62, 0.3296, 0.2944]), + (24.0, [0.5594, 0.2464, 0.2283]), + (24.8, [0.5323, 0.2069, 0.1995]), + (26.4, [0.4768, 0.1425, 0.1461]), + (27.2, [0.4472, 0.1167, 0.1206]), + (28.0, [0.4167, 0.0898, 0.1001]), + (28.8, [0.3959, 0.0704, 0.0851]), + (30.4, [0.3633, 0.0403, 0.0585]), + (31.2, [0.3483, 0.0263, 0.0498]), + (32.0, [0.334, 0.0158, 0.0398]), + (32.8, [0.3205, 0.0113, 0.0333]), + (34.4, [0.3001, 0.0067, 0.0314]), + (35.2, [0.2915, 0.0053, 0.0288]), + (36.8, [0.2738, 0.0059, 0.0286]), + (38.4, [0.2556, 0.0017, 0.0249]), + (40.8, [0.23, 0.0017, 0.0228]), + (42.4, [0.2088, -0.0025, 0.0143]), + (44.8, [0.1842, -0.0027, 0.0104]), + (46.4, [0.1704, -0.0067, 0.009]), + (48.0, [0.1551, -0.0067, 0.0084]), + (49.6, [0.141, -0.0138, 0.0045]), + (50.4, [0.1347, -0.0162, 0.0049]), + (52.0, [0.1265, -0.0164, 0.0036]), + (52.8, [0.1193, -0.0161, 0.0006]), + (55.2, [0.1011, -0.0206, 0.0007]), + (56.8, [0.0899, -0.0201, -0.0007]), + (59.2, [0.066, -0.0206, -0.0101]), + (60.8, [0.0561, -0.0257, -0.0107]), + (62.4, [0.0482, -0.0257, -0.0148]), + (63.2, [0.0544, -0.0252, -0.0163]), + (64.8, [0.0817, -0.0193, -0.0153]), + (66.4, [0.1104, -0.0179, -0.0151]), + (67.2, [0.1173, -0.0178, -0.0151]), + (68.0, [0.1069, -0.0183, -0.0167]), + (68.8, [0.0863, -0.0223, -0.0196]), + (70.4, [0.0404, -0.0325, -0.0207]), + (71.2, [0.0174, -0.035, -0.0204]), + (72.0, [-0.0008, -0.0349, -0.0201]), + (72.8, [-0.0077, -0.0348, -0.0199]), + (73.6, [-0.0112, -0.0348, -0.0198]), + (75.2, [-0.018, -0.0396, -0.0199]), + (76.8, [-0.025, -0.0398, -0.0255]), + (78.4, [-0.0354, -0.0395, -0.0307]), + (87.2, [-0.0578, -0.0373, -0.0285]), + (92.0, [-0.0572, -0.0363, -0.0273]), + (94.4, [-0.0568, -0.0415, -0.0266]), + (99.2, [-0.0563, -0.0403, -0.025]), + (100.8, [-0.0616, -0.0399, -0.0294]), + (108.8, [-0.0603, -0.0374, -0.0248]), + (123.2, [-0.0597, -0.0352, -0.0312]), + (124.8, [-0.0596, -0.0402, -0.0313]), + (128.0, [-0.0593, -0.0402, -0.0312]), + (129.6, [-0.0642, -0.0398, -0.036]), + (131.2, [-0.0651, -0.0392, -0.0365]), + (141.6, [-0.064, -0.0289, -0.0241]), + (156.8, [-0.0617, -0.01, 0.0052]), + (161.6, [-0.0612, -0.0056, 0.0118]), + (165.6, [-0.0609, -0.0032, 0.0143]), + (175.2, [-0.0605, -0.0012, 0.0141]), + (185.6, [-0.0598, 0.0014, 0.0172]), + (193.6, [-0.0592, 0.0002, 0.0156]), + (203.2, [-0.0586, -0.004, 0.0102]), + (232.8, [-0.0572, -0.024, -0.0138]), + (251.2, [-0.0566, -0.0248, -0.014]), + (277.6, [-0.055, -0.0401, -0.0319]), + (300.8, [-0.054, -0.0429, -0.0349]), + (311.2, [-0.0542, -0.0484, -0.0393]), + (328.0, [-0.0469, -0.0418, -0.0334]), + (342.4, [-0.0532, -0.0461, -0.0388]), + (371.2, [-0.0474, -0.0468, -0.0406]), + (408.8, [-0.0454, -0.049, -0.0421]), + (430.4, [-0.0448, -0.0485, -0.044]), + (464.8, [-0.0377, -0.0445, -0.0426]), + (482.4, [-0.0276, -0.0357, -0.0381]), + (491.2, [-0.0254, -0.032, -0.0366]), + (501.6, [-0.0167, -0.0253, -0.0329]), + (518.4, [-0.0056, -0.006, -0.0208]), + (526.4, [0.0009, 0.0079, -0.0112]), + (531.2, [0.0034, 0.014, -0.0073]), + (580.0, [0.0134, 0.0112, -0.0159]), + (596.0, [0.0151, 0.0101, -0.0227]), + (598.4, [0.0167, 0.0122, -0.0191]), + (601.6, [0.0221, 0.018, -0.0021]), + (603.2, [0.0233, 0.0187, 0.004]), + (605.6, [0.02, 0.0148, 0.0045]), + (609.6, [0.0036, 0.0, -0.0045]), + (611.2, [-0.0027, -0.0023, -0.0048]), + (612.8, [-0.0057, -0.0018, -0.0032]), + (614.4, [-0.0048, -0.0017, -0.0029]), + (617.6, [-0.0015, -0.0047, -0.008]), + (619.2, [-0.002, -0.0037, -0.008]), + (622.4, [-0.0012, 0.0014, -0.004]), + (624.8, [0.0011, 0.0009, -0.0067]), + (628.0, [-0.0005, -0.0006, -0.0091]), + (632.8, [-0.0008, -0.0011, -0.0032]), + (636.0, [0.0006, 0.0011, 0.0059]), + (639.2, [0.0008, 0.0008, 0.0075]), + (648.0, [-0.0001, -0.0001, -0.0009]), + (652.8, [-0.0003, -0.0002, -0.0017]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.4095, -0.4275, -0.4124]), + (0.8, [-0.4189, -0.4246, -0.4124]), + (2.4, [-0.4081, -0.4236, -0.4019]), + (4.8, [-0.3877, -0.408, -0.3656]), + (8.0, [-0.3548, -0.3649, -0.3099]), + (11.2, [-0.313, -0.3021, -0.2479]), + (15.2, [-0.2546, -0.2106, -0.1686]), + (20.0, [-0.1837, -0.1018, -0.0831]), + (20.8, [-0.1735, -0.0884, -0.0722]), + (21.6, [-0.1606, -0.0708, -0.059]), + (23.2, [-0.1385, -0.0421, -0.039]), + (24.0, [-0.1282, -0.0269, -0.0264]), + (25.6, [-0.1099, -0.0017, -0.0097]), + (27.2, [-0.0908, 0.0162, 0.0084]), + (28.0, [-0.0798, 0.0239, 0.014]), + (28.8, [-0.074, 0.0307, 0.0187]), + (29.6, [-0.0691, 0.0343, 0.0231]), + (32.0, [-0.0538, 0.0469, 0.0314]), + (32.8, [-0.0495, 0.0472, 0.0337]), + (40.8, [-0.0224, 0.0451, 0.0311]), + (42.4, [-0.0151, 0.046, 0.0341]), + (48.0, [0.0011, 0.0446, 0.0332]), + (49.6, [0.0055, 0.0467, 0.034]), + (56.0, [0.0194, 0.0467, 0.0329]), + (57.6, [0.0228, 0.0456, 0.0338]), + (59.2, [0.0291, 0.0456, 0.0361]), + (62.4, [0.035, 0.0467, 0.0369]), + (63.2, [0.0321, 0.0464, 0.0372]), + (66.4, [0.0098, 0.0425, 0.0357]), + (67.2, [0.0063, 0.0423, 0.0352]), + (68.0, [0.0105, 0.042, 0.0362]), + (71.2, [0.0434, 0.0478, 0.037]), + (72.8, [0.0514, 0.0472, 0.0363]), + (75.2, [0.054, 0.0486, 0.0358]), + (78.4, [0.0595, 0.0477, 0.039]), + (87.2, [0.0662, 0.0453, 0.0365]), + (99.2, [0.0637, 0.0443, 0.0321]), + (100.8, [0.0659, 0.0438, 0.0334]), + (107.2, [0.0645, 0.0418, 0.0289]), + (120.0, [0.064, 0.0398, 0.0348]), + (132.0, [0.0651, 0.039, 0.0363]), + (142.4, [0.0639, 0.0284, 0.0233]), + (156.0, [0.0619, 0.0112, -0.0035]), + (161.6, [0.0612, 0.0059, -0.0114]), + (165.6, [0.061, 0.0034, -0.014]), + (174.4, [0.0606, 0.0015, -0.0138]), + (186.4, [0.0597, -0.0014, -0.0172]), + (194.4, [0.0592, -0.0001, -0.0155]), + (204.0, [0.0585, 0.0041, -0.01]), + (233.6, [0.0572, 0.0242, 0.014]), + (252.0, [0.0566, 0.025, 0.0142]), + (277.6, [0.055, 0.04, 0.0317]), + (300.8, [0.054, 0.0429, 0.0348]), + (311.2, [0.0543, 0.0484, 0.0393]), + (328.0, [0.0468, 0.0418, 0.0334]), + (343.2, [0.0532, 0.0462, 0.0389]), + (372.0, [0.0473, 0.0468, 0.0406]), + (408.8, [0.0454, 0.049, 0.0421]), + (431.2, [0.0448, 0.0485, 0.044]), + (464.0, [0.0381, 0.0449, 0.0427]), + (483.2, [0.0275, 0.0356, 0.0381]), + (492.0, [0.0254, 0.032, 0.0366]), + (499.2, [0.0185, 0.0281, 0.0345]), + (516.8, [0.0071, 0.0091, 0.0228]), + (528.0, [-0.0014, -0.0093, 0.0103]), + (532.0, [-0.0034, -0.014, 0.0073]), + (580.8, [-0.0134, -0.0112, 0.016]), + (596.8, [-0.0151, -0.0101, 0.0228]), + (599.2, [-0.0165, -0.0119, 0.0198]), + (602.4, [-0.0219, -0.0178, 0.0029]), + (604.0, [-0.0233, -0.0188, -0.0035]), + (605.6, [-0.0222, -0.0172, -0.0055]), + (607.2, [-0.0182, -0.0127, -0.0032]), + (609.6, [-0.0082, -0.003, 0.0029]), + (611.2, [-0.0009, 0.0013, 0.0049]), + (613.6, [0.0055, 0.0019, 0.0033]), + (615.2, [0.005, 0.0016, 0.0027]), + (619.2, [0.0016, 0.0046, 0.0083]), + (623.2, [0.0014, -0.0013, 0.0039]), + (626.4, [-0.0013, -0.0005, 0.0075]), + (628.8, [0.0003, 0.0006, 0.009]), + (633.6, [0.0007, 0.0011, 0.0034]), + (636.8, [-0.0006, -0.0011, -0.0055]), + (640.0, [-0.0008, -0.0008, -0.0074]), + (648.8, [0.0001, 0.0001, 0.0009]), + (653.6, [0.0003, 0.0003, 0.0018]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.0594, 0.0666, 0.0353]), + (4.7944, [0.0546, 0.0661, 0.035]), + (6.3925, [0.0507, 0.066, 0.035]), + (11.187, [0.0506, 0.0641, 0.035]), + (12.785, [0.0467, 0.0599, 0.035]), + (17.579, [0.0452, 0.06, 0.035]), + (19.177, [0.0408, 0.0599, 0.035]), + (23.972, [0.0401, 0.0595, 0.0351]), + (25.57, [0.0361, 0.0554, 0.0351]), + (31.962, [0.0356, 0.0556, 0.0359]), + (33.56, [0.0312, 0.0556, 0.0361]), + (39.953, [0.0309, 0.055, 0.0359]), + (41.551, [0.0262, 0.0507, 0.0356]), + (49.542, [0.0229, 0.0493, 0.0339]), + (51.14, [0.0184, 0.0494, 0.0338]), + (60.728, [0.0188, 0.051, 0.0348]), + (62.327, [0.0149, 0.047, 0.0352]), + (75.112, [0.0159, 0.0469, 0.0351]), + (76.71, [0.0116, 0.0469, 0.0351]), + (111.87, [0.0118, 0.047, 0.0352]), + (112.67, [0.0121, 0.047, 0.0352]), + (119.06, [0.0315, 0.047, 0.0353]), + (119.86, [0.0317, 0.047, 0.0353]), + (127.05, [0.0117, 0.047, 0.0352]), + (147.03, [0.0118, 0.0473, 0.0353]), + (147.83, [0.0119, 0.0458, 0.035]), + (149.42, [0.0119, 0.0391, 0.0296]), + (151.02, [0.0077, 0.0327, 0.0243]), + (152.62, [0.0078, 0.0263, 0.0196]), + (154.22, [0.0038, 0.0198, 0.0147]), + (155.82, [0.0037, 0.0133, 0.0095]), + (156.62, [0.0008, 0.0095, 0.0067]), + (159.01, [0.0001, 0.0001, -0.0002]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.0636, 0.0794]), + (7.9639, [-0.0001, 0.0635, 0.0793]), + (17.52, [-0.0001, 0.051, 0.0637]), + (23.095, [-0.0001, 0.0378, 0.0477]), + (31.059, [-0.0001, 0.0123, 0.0155]), + (35.041, [-0.0001, 0.0065, 0.0082]), + (44.598, [-0.0001, 0.0013, 0.0016]), + (63.711, [-0.0001, 0.0017, 0.002]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0076, 0.0082, 0.0701]), + (15.867, [0.0078, 0.0083, 0.0704]), + (17.454, [0.0065, 0.007, 0.0603]), + (19.04, [0.0061, 0.0066, 0.0557]), + (28.56, [0.0043, 0.0047, 0.0393]), + (30.94, [0.0039, 0.0043, 0.0327]), + (33.32, [0.004, 0.0043, 0.0242]), + (36.494, [0.0, 0.0004, 0.0119]), + (38.081, [0.0, 0.0003, 0.0056]), + (39.667, [0.0, 0.0003, 0.0016]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0004, 0.0044, 0.0047]), + (0.7847, [0.0049, 0.0032, 0.0027]), + (3.1388, [0.007, 0.0049, 0.0051]), + (4.7082, [0.0084, 0.0069, 0.0066]), + (7.847, [0.0147, 0.0141, 0.0144]), + (11.771, [0.017, 0.0189, 0.0175]), + (13.34, [0.016, 0.0177, 0.0163]), + (21.187, [0.0013, 0.0017, 0.0015]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0389, 0.0148, -0.0008]), + (3.9865, [0.0378, 0.0151, -0.0007]), + (7.1757, [0.0366, 0.0125, -0.0005]), + (9.5675, [0.0318, 0.0113, -0.0003]), + (20.73, [0.0191, 0.0043, 0.0004]), + (31.892, [0.006, 0.0005, 0.0002]), + (39.067, [0.0017, 0.0002, 0.0001]), + ], + }, +]; +const PRIME35_50_STREAKS: &[Streak] = &[]; +const PRIME35_50_RAYS: &[Ray] = &[ + Ray { angle: 71.57, r0: 6.25, r1: 82.812, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 104.33, r0: 48.438, r1: 264.06, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 118.75, r0: 15.625, r1: 196.09, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 147.3, r0: 13.281, r1: 223.44, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 161.64, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0353, 0.0353, 0.0353] }, + Ray { angle: 161.93, r0: 6.25, r1: 134.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 168.91, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 183.28, r0: 20.312, r1: 189.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 225.87, r0: 31.25, r1: 159.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 240.32, r0: 18.75, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 247.15, r0: 6.25, r1: 83.594, fade: 0.05, rgb: [0.0127, 0.0157, 0.0157] }, + Ray { angle: 251.07, r0: 6.25, r1: 84.375, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 254.58, r0: 25.0, r1: 157.03, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 268.96, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 283.24, r0: 38.281, r1: 178.91, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 297.53, r0: 12.5, r1: 109.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, +]; +const PRIME35_75_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.438, 1.379, 1.367]), + (0.8, [1.37, 1.369, 1.365]), + (1.6, [1.392, 1.373, 1.364]), + (3.2, [1.378, 1.374, 1.348]), + (6.4, [1.342, 1.372, 1.264]), + (10.4, [1.269, 1.266, 1.121]), + (15.2, [1.151, 1.04, 0.9127]), + (19.2, [1.03, 0.8126, 0.7253]), + (24.8, [0.8532, 0.5055, 0.4852]), + (28.0, [0.7566, 0.3624, 0.3754]), + (30.4, [0.6922, 0.2859, 0.3005]), + (33.6, [0.6149, 0.2093, 0.2311]), + (36.8, [0.5455, 0.1487, 0.1662]), + (38.4, [0.5141, 0.121, 0.1415]), + (39.2, [0.4955, 0.1021, 0.1263]), + (40.0, [0.4786, 0.0896, 0.1134]), + (41.6, [0.4461, 0.0787, 0.0952]), + (43.2, [0.422, 0.0585, 0.0782]), + (44.8, [0.3932, 0.0431, 0.0604]), + (46.4, [0.371, 0.0265, 0.0502]), + (48.0, [0.3515, 0.0156, 0.0363]), + (48.8, [0.3407, 0.0135, 0.0309]), + (49.6, [0.3326, 0.0136, 0.03]), + (51.2, [0.3178, 0.009, 0.0256]), + (53.6, [0.2948, 0.0039, 0.0252]), + (56.0, [0.2727, 0.0052, 0.0154]), + (58.4, [0.252, 0.006, 0.0166]), + (59.2, [0.2448, 0.0027, 0.0171]), + (60.8, [0.2325, -0.0033, 0.0179]), + (61.6, [0.2272, -0.0027, 0.0162]), + (62.4, [0.2277, -0.0019, 0.0111]), + (63.2, [0.2409, 0.0006, 0.0084]), + (64.0, [0.2645, 0.0052, 0.0088]), + (65.6, [0.3272, 0.0169, 0.0149]), + (66.4, [0.3561, 0.0235, 0.0156]), + (67.2, [0.3684, 0.026, 0.0135]), + (68.0, [0.3551, 0.0223, 0.0121]), + (68.8, [0.319, 0.0151, 0.0107]), + (70.4, [0.2275, -0.0009, 0.002]), + (71.2, [0.1855, -0.0079, 0.001]), + (72.0, [0.1522, -0.0123, -0.0018]), + (72.8, [0.1373, -0.0125, -0.0031]), + (73.6, [0.1313, -0.0125, -0.003]), + (74.4, [0.126, -0.0138, -0.0026]), + (76.0, [0.116, -0.0226, -0.0043]), + (77.6, [0.1054, -0.0236, -0.0126]), + (80.0, [0.0915, -0.0228, -0.0134]), + (82.4, [0.0781, -0.0279, -0.0127]), + (84.8, [0.0596, -0.0326, -0.0231]), + (88.0, [0.0441, -0.0312, -0.0214]), + (89.6, [0.037, -0.0358, -0.0208]), + (91.2, [0.0282, -0.0353, -0.026]), + (92.8, [0.0185, -0.0398, -0.0308]), + (94.4, [0.0177, -0.0391, -0.0296]), + (97.6, [0.0066, -0.038, -0.0279]), + (99.2, [-0.0019, -0.0426, -0.027]), + (100.8, [-0.0092, -0.0438, -0.0359]), + (102.4, [-0.0128, -0.0468, -0.0342]), + (104.8, [-0.0197, -0.0451, -0.0306]), + (106.4, [-0.0218, -0.0441, -0.0282]), + (107.2, [-0.0284, -0.0437, -0.0272]), + (109.6, [-0.0354, -0.0433, -0.0329]), + (111.2, [-0.0366, -0.0481, -0.0336]), + (112.8, [-0.038, -0.0477, -0.0346]), + (116.0, [-0.0484, -0.0468, -0.0371]), + (118.4, [-0.0485, -0.0461, -0.0446]), + (119.2, [-0.0485, -0.0462, -0.0452]), + (120.8, [-0.0484, -0.0513, -0.0462]), + (122.4, [-0.0482, -0.0509, -0.0467]), + (124.8, [-0.0539, -0.0499, -0.047]), + (126.4, [-0.0536, -0.0492, -0.048]), + (128.0, [-0.0534, -0.0485, -0.0529]), + (129.6, [-0.0531, -0.0485, -0.0525]), + (131.2, [-0.0528, -0.0527, -0.0516]), + (132.0, [-0.0529, -0.0519, -0.0508]), + (133.6, [-0.0582, -0.0497, -0.0485]), + (136.0, [-0.0579, -0.0452, -0.0443]), + (137.6, [-0.0575, -0.0417, -0.0458]), + (140.0, [-0.0569, -0.0358, -0.0389]), + (141.6, [-0.0571, -0.0368, -0.034]), + (142.4, [-0.0602, -0.0357, -0.0315]), + (143.2, [-0.0623, -0.0335, -0.0289]), + (146.4, [-0.0614, -0.0247, -0.0182]), + (148.0, [-0.061, -0.0203, -0.0181]), + (152.0, [-0.0602, -0.0094, -0.003]), + (153.6, [-0.065, -0.0105, 0.0039]), + (157.6, [-0.0635, -0.0017, 0.0183]), + (159.2, [-0.0631, 0.0016, 0.0173]), + (162.4, [-0.0624, 0.0073, 0.0242]), + (164.8, [-0.0681, 0.0103, 0.0275]), + (166.4, [-0.0679, 0.0063, 0.0288]), + (169.6, [-0.0676, 0.008, 0.0299]), + (172.0, [-0.0674, 0.009, 0.0242]), + (174.4, [-0.0672, 0.0102, 0.0251]), + (176.8, [-0.073, 0.0117, 0.0266]), + (179.2, [-0.0726, 0.0131, 0.0288]), + (180.8, [-0.0723, 0.0087, 0.0303]), + (183.2, [-0.0719, 0.0099, 0.0321]), + (185.6, [-0.0716, 0.0108, 0.0271]), + (188.0, [-0.0718, 0.0111, 0.0275]), + (189.6, [-0.0771, 0.0111, 0.0275]), + (194.4, [-0.0764, 0.0101, 0.026]), + (195.2, [-0.0763, 0.0093, 0.0257]), + (196.8, [-0.0761, 0.003, 0.0247]), + (199.2, [-0.0758, 0.0019, 0.0227]), + (200.8, [-0.0756, 0.001, 0.0159]), + (202.4, [-0.0754, 0.0, 0.0146]), + (204.8, [-0.081, -0.0014, 0.0127]), + (213.6, [-0.0798, -0.0079, 0.0039]), + (214.4, [-0.0797, -0.009, 0.003]), + (216.0, [-0.0795, -0.016, 0.0011]), + (217.6, [-0.0793, -0.0178, -0.0014]), + (219.2, [-0.0791, -0.0195, -0.009]), + (222.4, [-0.085, -0.023, -0.0132]), + (231.2, [-0.0843, -0.0306, -0.022]), + (239.2, [-0.0836, -0.0328, -0.0232]), + (240.8, [-0.0834, -0.0379, -0.0226]), + (241.6, [-0.0834, -0.0375, -0.0226]), + (243.2, [-0.0835, -0.0371, -0.0275]), + (245.6, [-0.0889, -0.0367, -0.0263]), + (252.8, [-0.0886, -0.0374, -0.0261]), + (278.4, [-0.0858, -0.0547, -0.0452]), + (300.8, [-0.0843, -0.0571, -0.0476]), + (311.2, [-0.0846, -0.0634, -0.0526]), + (328.0, [-0.0761, -0.0561, -0.0455]), + (344.0, [-0.0832, -0.0611, -0.0519]), + (368.8, [-0.0779, -0.0619, -0.0535]), + (423.2, [-0.0731, -0.0639, -0.0563]), + (463.2, [-0.0637, -0.0581, -0.0543]), + (483.2, [-0.0487, -0.045, -0.0457]), + (490.4, [-0.0458, -0.0405, -0.0431]), + (500.0, [-0.035, -0.0311, -0.0381]), + (516.8, [-0.0217, -0.0044, -0.0202]), + (526.4, [-0.0128, 0.0183, -0.0043]), + (531.2, [-0.0092, 0.0274, 0.0017]), + (576.8, [0.0045, 0.0292, 0.001]), + (596.8, [0.0071, 0.0298, -0.0035]), + (598.4, [0.0102, 0.0313, -0.0006]), + (601.6, [0.0268, 0.0339, 0.0119]), + (604.0, [0.0405, 0.031, 0.0189]), + (605.6, [0.0449, 0.0264, 0.0184]), + (607.2, [0.0421, 0.0193, 0.0132]), + (608.0, [0.0377, 0.015, 0.0093]), + (609.6, [0.0242, 0.0062, 0.0008]), + (611.2, [0.0078, -0.001, -0.0051]), + (612.8, [-0.0058, -0.0046, -0.0069]), + (613.6, [-0.0103, -0.0051, -0.0068]), + (615.2, [-0.0139, -0.0049, -0.0062]), + (620.0, [-0.003, -0.0041, -0.0102]), + (624.8, [-0.0018, 0.0018, -0.0033]), + (628.0, [0.003, 0.001, -0.005]), + (630.4, [0.0007, -0.0017, -0.0058]), + (632.8, [-0.0018, -0.0009, -0.001]), + (639.2, [0.0016, 0.0012, 0.0071]), + (644.8, [0.0001, -0.0001, 0.0016]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.3934, -0.3751, -0.3685]), + (3.2, [-0.3842, -0.3746, -0.3617]), + (6.4, [-0.3726, -0.3753, -0.3347]), + (9.6, [-0.3526, -0.3472, -0.2979]), + (14.4, [-0.3128, -0.2729, -0.2247]), + (24.0, [-0.2112, -0.0822, -0.0744]), + (27.2, [-0.1763, -0.0287, -0.0351]), + (29.6, [-0.1541, -0.0011, -0.0092]), + (31.2, [-0.1402, 0.0132, 0.0055]), + (32.8, [-0.127, 0.0263, 0.0156]), + (36.8, [-0.0975, 0.0513, 0.0425]), + (37.6, [-0.0934, 0.0542, 0.0457]), + (39.2, [-0.0809, 0.0663, 0.0543]), + (40.0, [-0.0753, 0.0703, 0.0589]), + (41.6, [-0.0643, 0.0721, 0.0641]), + (44.0, [-0.0529, 0.0808, 0.0719]), + (45.6, [-0.0423, 0.0855, 0.0754]), + (48.0, [-0.0337, 0.0905, 0.0806]), + (53.6, [-0.0157, 0.0919, 0.0811]), + (56.8, [-0.0068, 0.0891, 0.083]), + (60.8, [0.0028, 0.0899, 0.0796]), + (62.4, [0.0051, 0.0885, 0.0815]), + (63.2, [-0.0007, 0.0867, 0.0818]), + (64.0, [-0.0097, 0.0846, 0.0809]), + (66.4, [-0.0452, 0.0762, 0.0766]), + (67.2, [-0.0511, 0.0744, 0.0777]), + (68.0, [-0.0457, 0.0754, 0.0768]), + (68.8, [-0.0323, 0.0775, 0.0767]), + (71.2, [0.0174, 0.0852, 0.0794]), + (72.0, [0.0279, 0.086, 0.08]), + (72.8, [0.0331, 0.0858, 0.0805]), + (76.0, [0.0384, 0.088, 0.079]), + (78.4, [0.0432, 0.0872, 0.0819]), + (82.4, [0.0503, 0.0873, 0.0796]), + (84.8, [0.0565, 0.0886, 0.0832]), + (89.6, [0.0628, 0.0877, 0.0798]), + (92.8, [0.069, 0.0878, 0.0824]), + (99.2, [0.0742, 0.0863, 0.0768]), + (100.8, [0.0762, 0.0858, 0.0794]), + (105.6, [0.078, 0.0839, 0.0722]), + (107.2, [0.0817, 0.0829, 0.0701]), + (118.4, [0.0881, 0.0817, 0.0808]), + (124.8, [0.0896, 0.0814, 0.0828]), + (131.2, [0.0884, 0.0807, 0.0834]), + (137.6, [0.089, 0.0701, 0.0744]), + (142.4, [0.0894, 0.0603, 0.06]), + (148.0, [0.0885, 0.0449, 0.0428]), + (152.0, [0.0871, 0.0335, 0.027]), + (157.6, [0.0871, 0.022, 0.0054]), + (162.4, [0.0857, 0.0129, -0.0039]), + (164.8, [0.088, 0.0092, -0.0073]), + (169.6, [0.0873, 0.0079, -0.0101]), + (172.0, [0.0872, 0.0069, -0.0079]), + (174.4, [0.0866, 0.0057, -0.009]), + (179.2, [0.0883, 0.0024, -0.0128]), + (180.8, [0.0881, 0.0036, -0.0142]), + (183.2, [0.0877, 0.0021, -0.0164]), + (194.4, [0.0882, 0.0014, -0.0145]), + (199.2, [0.0876, 0.0057, -0.0118]), + (200.8, [0.0873, 0.0065, -0.0084]), + (214.4, [0.0876, 0.0159, 0.0043]), + (216.0, [0.0874, 0.0196, 0.0063]), + (224.0, [0.0889, 0.0283, 0.0187]), + (232.0, [0.0882, 0.0348, 0.0262]), + (240.8, [0.0874, 0.038, 0.0267]), + (252.8, [0.0887, 0.0372, 0.0259]), + (272.0, [0.0865, 0.0513, 0.0414]), + (281.6, [0.0856, 0.0556, 0.0462]), + (300.8, [0.0843, 0.0571, 0.0476]), + (312.0, [0.0844, 0.0635, 0.0526]), + (328.0, [0.076, 0.0561, 0.0455]), + (343.2, [0.0833, 0.0609, 0.0517]), + (369.6, [0.0779, 0.0619, 0.0535]), + (430.4, [0.0727, 0.0632, 0.0568]), + (465.6, [0.0629, 0.0575, 0.054]), + (484.0, [0.0486, 0.0449, 0.0456]), + (491.2, [0.0457, 0.0404, 0.0431]), + (495.2, [0.0409, 0.037, 0.0416]), + (505.6, [0.0315, 0.0242, 0.0339]), + (517.6, [0.0217, 0.0043, 0.0201]), + (527.2, [0.0127, -0.0184, 0.0043]), + (532.8, [0.0089, -0.0279, -0.0018]), + (577.6, [-0.0045, -0.0292, -0.001]), + (597.6, [-0.007, -0.0298, 0.0037]), + (600.0, [-0.0128, -0.0321, -0.0015]), + (602.4, [-0.0258, -0.0339, -0.0113]), + (604.8, [-0.0397, -0.0314, -0.0186]), + (606.4, [-0.0448, -0.027, -0.0187]), + (608.0, [-0.0428, -0.0201, -0.014]), + (608.8, [-0.0389, -0.016, -0.0102]), + (610.4, [-0.026, -0.0072, -0.0017]), + (612.0, [-0.0096, 0.0004, 0.0046]), + (613.6, [0.0045, 0.0044, 0.0068]), + (614.4, [0.0094, 0.005, 0.0068]), + (616.0, [0.0138, 0.0049, 0.0062]), + (617.6, [0.0117, 0.0044, 0.0064]), + (620.0, [0.0045, 0.0043, 0.0093]), + (621.6, [0.0025, 0.0038, 0.01]), + (624.8, [0.0028, -0.0005, 0.0047]), + (626.4, [0.0007, -0.0024, 0.0027]), + (628.8, [-0.003, -0.0012, 0.0046]), + (631.2, [-0.0011, 0.0016, 0.0059]), + (633.6, [0.0017, 0.001, 0.0013]), + (640.8, [-0.002, -0.0015, -0.0071]), + (645.6, [-0.0002, 0.0002, -0.0017]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.0834, 0.1014, 0.0566]), + (0.79906, [0.0852, 0.0995, 0.0541]), + (3.1962, [0.0853, 0.1016, 0.0546]), + (7.9906, [0.0779, 0.1008, 0.0545]), + (8.7896, [0.0771, 0.098, 0.0545]), + (15.182, [0.0719, 0.0975, 0.0545]), + (16.78, [0.0677, 0.0968, 0.0545]), + (18.378, [0.0677, 0.0928, 0.0546]), + (19.976, [0.0667, 0.0925, 0.0546]), + (20.776, [0.0633, 0.0924, 0.0546]), + (23.972, [0.0617, 0.0918, 0.0546]), + (25.57, [0.0574, 0.0914, 0.0547]), + (27.967, [0.0569, 0.0873, 0.0549]), + (31.163, [0.0535, 0.0874, 0.0557]), + (34.36, [0.0538, 0.0878, 0.0562]), + (35.958, [0.0497, 0.0878, 0.0564]), + (39.953, [0.0484, 0.0833, 0.0559]), + (41.551, [0.0435, 0.0827, 0.0554]), + (46.345, [0.0404, 0.0809, 0.0537]), + (47.944, [0.0351, 0.0805, 0.0533]), + (51.14, [0.034, 0.08, 0.0528]), + (52.738, [0.0336, 0.0756, 0.0528]), + (55.135, [0.0293, 0.076, 0.0527]), + (60.728, [0.0307, 0.078, 0.0543]), + (62.327, [0.0267, 0.0787, 0.0549]), + (65.523, [0.0272, 0.0797, 0.0557]), + (68.719, [0.0275, 0.0794, 0.0554]), + (70.317, [0.0273, 0.0747, 0.0552]), + (71.915, [0.0234, 0.0745, 0.0549]), + (81.504, [0.0237, 0.0743, 0.0547]), + (83.102, [0.0195, 0.0743, 0.0547]), + (111.87, [0.0195, 0.0745, 0.0549]), + (112.67, [0.021, 0.0745, 0.0549]), + (119.06, [0.0504, 0.0784, 0.0549]), + (119.86, [0.0515, 0.0784, 0.0549]), + (127.05, [0.0202, 0.0744, 0.0548]), + (147.03, [0.0197, 0.0744, 0.0551]), + (148.62, [0.0195, 0.0666, 0.0495]), + (149.42, [0.0164, 0.0612, 0.0454]), + (155.02, [0.0071, 0.0264, 0.0191]), + (155.82, [0.0041, 0.021, 0.0151]), + (159.01, [0.0001, 0.0011, 0.0004]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.096, 0.1202]), + (9.5566, [-0.0002, 0.0926, 0.1166]), + (18.317, [-0.0001, 0.0735, 0.0949]), + (23.095, [-0.0002, 0.0571, 0.0759]), + (28.67, [-0.0002, 0.0286, 0.0382]), + (31.059, [-0.0002, 0.0184, 0.0244]), + (32.652, [-0.0002, 0.014, 0.0185]), + (35.837, [-0.0002, 0.0085, 0.0112]), + (40.616, [-0.0002, 0.0038, 0.0048]), + (44.598, [-0.0002, 0.0016, 0.0019]), + (66.896, [-0.0002, 0.0017, 0.0019]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0148, 0.0168, 0.1051]), + (1.5867, [0.0156, 0.0169, 0.1076]), + (15.867, [0.0157, 0.0168, 0.1059]), + (17.454, [0.0135, 0.0144, 0.0916]), + (19.04, [0.0123, 0.0133, 0.0844]), + (26.974, [0.0092, 0.0102, 0.0646]), + (30.94, [0.0078, 0.0087, 0.0513]), + (38.081, [0.0, 0.0007, 0.0105]), + (40.461, [0.0002, 0.0006, 0.0015]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0056, 0.005, 0.002]), + (0.7847, [0.0088, 0.0078, 0.0064]), + (1.5694, [0.0082, 0.0058, 0.0061]), + (4.7082, [0.0132, 0.01, 0.0106]), + (7.847, [0.0214, 0.0207, 0.0204]), + (11.771, [0.0247, 0.0265, 0.0253]), + (12.555, [0.0232, 0.0242, 0.0232]), + (13.34, [0.0231, 0.0247, 0.0236]), + (18.048, [0.0092, 0.01, 0.0096]), + (21.972, [0.0018, 0.0012, 0.0014]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0555, 0.0267, -0.0013]), + (0.7973, [0.0562, 0.0239, -0.0012]), + (3.1892, [0.0561, 0.0238, -0.0012]), + (11.162, [0.0476, 0.0194, -0.0003]), + (23.919, [0.0242, 0.0093, 0.0008]), + (27.905, [0.0185, 0.0046, 0.0006]), + (31.892, [0.0123, 0.0029, 0.0003]), + (37.473, [0.005, 0.0005, 0.0002]), + (43.851, [0.0015, 0.0005, 0.0002]), + ], + }, +]; +const PRIME35_75_STREAKS: &[Streak] = &[]; +const PRIME35_75_RAYS: &[Ray] = &[ + Ray { angle: 6.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 8.37, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0186, 0.0186] }, + Ray { angle: 9.3, r0: 52.344, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0176, 0.0157] }, + Ray { angle: 10.73, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 11.64, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 37.2, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 38.24, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0137, 0.0176] }, + Ray { angle: 39.53, r0: 56.25, r1: 96.875, fade: 0.05, rgb: [0.0137, 0.0137, 0.0157] }, + Ray { angle: 41.61, r0: 26.562, r1: 85.938, fade: 0.05, rgb: [0.0137, 0.0176, 0.0157] }, + Ray { angle: 67.69, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 68.69, r0: 6.25, r1: 81.25, fade: 0.05, rgb: [0.0176, 0.0216, 0.0235] }, + Ray { angle: 69.77, r0: 6.25, r1: 36.719, fade: 0.05, rgb: [0.0216, 0.0304, 0.0324] }, + Ray { angle: 71.23, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 72.12, r0: 6.25, r1: 95.312, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 97.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 98.74, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 99.71, r0: 50.0, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 101.09, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0147, 0.0176] }, + Ray { angle: 102.15, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 104.33, r0: 50.0, r1: 317.19, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 118.75, r0: 17.188, r1: 214.06, fade: 0.05, rgb: [0.0314, 0.0314, 0.0314] }, + Ray { angle: 127.41, r0: 6.25, r1: 96.094, fade: 0.05, rgb: [0.0118, 0.0147, 0.0176] }, + Ray { angle: 127.67, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 128.66, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0137, 0.0176, 0.0206] }, + Ray { angle: 129.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0147, 0.0176] }, + Ray { angle: 131.8, r0: 6.25, r1: 95.312, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 147.33, r0: 13.281, r1: 257.81, fade: 0.05, rgb: [0.0275, 0.0314, 0.0294] }, + Ray { angle: 157.61, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 158.68, r0: 6.25, r1: 91.406, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 159.74, r0: 6.25, r1: 32.812, fade: 0.05, rgb: [0.0216, 0.0373, 0.0373] }, + Ray { angle: 161.68, r0: 6.25, r1: 246.88, fade: 0.05, rgb: [0.051, 0.051, 0.051] }, + Ray { angle: 168.81, r0: 11.719, r1: 238.28, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 183.23, r0: 21.094, r1: 234.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 187.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0157, 0.0176] }, + Ray { angle: 188.85, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 189.71, r0: 51.562, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0157, 0.0167] }, + Ray { angle: 191.14, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0186] }, + Ray { angle: 192.15, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0176, 0.0196, 0.0235] }, + Ray { angle: 217.67, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 218.73, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0118, 0.0157, 0.0176] }, + Ray { angle: 220.0, r0: 49.219, r1: 99.219, fade: 0.05, rgb: [0.0098, 0.0098, 0.0137] }, + Ray { angle: 221.96, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 225.93, r0: 30.469, r1: 210.94, fade: 0.05, rgb: [0.0235, 0.0275, 0.0235] }, + Ray { angle: 240.05, r0: 18.75, r1: 137.5, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 240.36, r0: 17.188, r1: 224.22, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 247.23, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, + Ray { angle: 249.3, r0: 6.25, r1: 33.594, fade: 0.05, rgb: [0.0176, 0.0284, 0.0275] }, + Ray { angle: 250.73, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 251.57, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0176, 0.0196, 0.0216] }, + Ray { angle: 254.52, r0: 41.406, r1: 157.03, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 269.0, r0: 8.5938, r1: 195.31, fade: 0.05, rgb: [0.0353, 0.0373, 0.0353] }, + Ray { angle: 276.16, r0: 97.656, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 276.97, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 278.33, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0167, 0.0176] }, + Ray { angle: 279.33, r0: 51.562, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0167, 0.0157] }, + Ray { angle: 280.73, r0: 44.531, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0157, 0.0137] }, + Ray { angle: 281.65, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0157, 0.0167] }, + Ray { angle: 283.26, r0: 50.0, r1: 189.06, fade: 0.05, rgb: [0.0294, 0.0314, 0.0314] }, + Ray { angle: 297.59, r0: 14.844, r1: 185.16, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 307.25, r0: 30.469, r1: 96.875, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 308.3, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0118, 0.0157, 0.0176] }, + Ray { angle: 309.62, r0: 52.344, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 311.76, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0157, 0.0176, 0.0196] }, + Ray { angle: 337.25, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 338.3, r0: 6.25, r1: 89.062, fade: 0.05, rgb: [0.0157, 0.0196, 0.0216] }, + Ray { angle: 339.3, r0: 60.938, r1: 99.219, fade: 0.05, rgb: [0.0167, 0.0176, 0.0176] }, + Ray { angle: 340.85, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0176, 0.0176, 0.0216] }, + Ray { angle: 341.68, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, +]; +const PRIME35_100_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.283, 1.225, 1.21]), + (0.8, [1.31, 1.302, 1.306]), + (1.6, [1.302, 1.281, 1.275]), + (5.6, [1.305, 1.288, 1.255]), + (10.4, [1.276, 1.276, 1.159]), + (16.0, [1.207, 1.1, 0.997]), + (24.8, [1.044, 0.7192, 0.7071]), + (28.8, [0.9663, 0.5735, 0.5935]), + (31.2, [0.9222, 0.5104, 0.5389]), + (36.0, [0.8373, 0.4128, 0.4378]), + (39.2, [0.7806, 0.3522, 0.38]), + (44.8, [0.6827, 0.2563, 0.2854]), + (48.0, [0.6315, 0.212, 0.2406]), + (52.0, [0.5698, 0.1612, 0.1896]), + (53.6, [0.5441, 0.1449, 0.1705]), + (55.2, [0.5204, 0.1322, 0.157]), + (57.6, [0.4894, 0.1055, 0.1349]), + (59.2, [0.4678, 0.0978, 0.1237]), + (60.8, [0.4505, 0.0845, 0.11]), + (62.4, [0.4413, 0.0758, 0.1006]), + (63.2, [0.4594, 0.0809, 0.0991]), + (66.4, [0.6287, 0.1213, 0.1015]), + (67.2, [0.6495, 0.1267, 0.1016]), + (68.0, [0.6286, 0.1217, 0.0988]), + (70.4, [0.4634, 0.0762, 0.0799]), + (71.2, [0.4065, 0.0639, 0.0732]), + (72.0, [0.3606, 0.0565, 0.0705]), + (72.8, [0.3404, 0.0511, 0.0704]), + (73.6, [0.3351, 0.0447, 0.0699]), + (74.4, [0.3298, 0.0424, 0.0686]), + (76.8, [0.3101, 0.0417, 0.064]), + (78.4, [0.2974, 0.0327, 0.0564]), + (79.2, [0.289, 0.0314, 0.0535]), + (80.8, [0.2794, 0.0314, 0.0537]), + (81.6, [0.2736, 0.0286, 0.0526]), + (83.2, [0.2581, 0.0248, 0.0433]), + (85.6, [0.2445, 0.0191, 0.0412]), + (86.4, [0.2363, 0.016, 0.0378]), + (88.8, [0.2215, 0.0159, 0.0319]), + (90.4, [0.2111, 0.0079, 0.0291]), + (91.2, [0.2044, 0.006, 0.0275]), + (92.8, [0.1937, 0.0068, 0.0264]), + (93.6, [0.1891, 0.007, 0.0228]), + (95.2, [0.1796, 0.0029, 0.0179]), + (97.6, [0.164, 0.0043, 0.0193]), + (98.4, [0.1573, 0.004, 0.0171]), + (100.0, [0.1531, 0.0008, 0.018]), + (101.6, [0.1437, 0.0028, 0.0208]), + (102.4, [0.1385, 0.0033, 0.0195]), + (104.0, [0.1313, -0.0056, 0.0205]), + (105.6, [0.1228, -0.0046, 0.0227]), + (107.2, [0.1187, -0.0033, 0.0165]), + (108.0, [0.1157, -0.0058, 0.0164]), + (109.6, [0.1032, -0.0063, 0.0163]), + (111.2, [0.0953, -0.0053, 0.0101]), + (112.8, [0.0873, -0.0144, 0.0083]), + (113.6, [0.0816, -0.0152, 0.0073]), + (115.2, [0.0769, -0.0143, -0.0043]), + (116.0, [0.077, -0.014, -0.0067]), + (116.8, [0.0742, -0.0155, -0.0077]), + (117.6, [0.0675, -0.0184, -0.0085]), + (118.4, [0.065, -0.0186, -0.0095]), + (120.0, [0.06, -0.0175, -0.0163]), + (121.6, [0.0497, -0.0179, -0.017]), + (122.4, [0.0456, -0.0208, -0.017]), + (124.0, [0.0427, -0.0255, -0.021]), + (125.6, [0.034, -0.0249, -0.0274]), + (127.2, [0.0287, -0.0286, -0.0275]), + (128.8, [0.0273, -0.0279, -0.0318]), + (130.4, [0.0229, -0.0264, -0.0312]), + (132.0, [0.0224, -0.029, -0.0294]), + (133.6, [0.017, -0.0266, -0.0315]), + (136.0, [0.0104, -0.0201, -0.0239]), + (137.6, [0.0082, -0.0198, -0.0214]), + (138.4, [0.0049, -0.0166, -0.0198]), + (140.8, [-0.0016, -0.0064, -0.008]), + (141.6, [-0.0024, -0.0052, -0.0048]), + (142.4, [-0.0047, -0.0047, -0.004]), + (143.2, [-0.0075, -0.0013, -0.0011]), + (146.4, [-0.0077, 0.0137, 0.0169]), + (148.0, [-0.0123, 0.0166, 0.0217]), + (151.2, [-0.0125, 0.033, 0.0423]), + (152.8, [-0.0165, 0.0359, 0.0482]), + (155.2, [-0.0151, 0.0468, 0.0651]), + (156.0, [-0.0168, 0.0505, 0.0698]), + (156.8, [-0.0197, 0.0538, 0.0717]), + (158.4, [-0.0194, 0.0546, 0.0774]), + (160.0, [-0.0189, 0.06, 0.0848]), + (160.8, [-0.0211, 0.0626, 0.088]), + (161.6, [-0.024, 0.0652, 0.0899]), + (162.4, [-0.024, 0.0673, 0.0891]), + (164.8, [-0.0236, 0.0671, 0.0939]), + (166.4, [-0.0288, 0.07, 0.096]), + (168.0, [-0.029, 0.0721, 0.092]), + (169.6, [-0.0288, 0.0685, 0.0927]), + (171.2, [-0.0339, 0.069, 0.0933]), + (173.6, [-0.0341, 0.071, 0.0885]), + (175.2, [-0.0347, 0.0684, 0.0897]), + (176.0, [-0.038, 0.0674, 0.0907]), + (180.0, [-0.0387, 0.0719, 0.0901]), + (181.6, [-0.0438, 0.0682, 0.0922]), + (183.2, [-0.0436, 0.0696, 0.0928]), + (184.8, [-0.0431, 0.0707, 0.0891]), + (186.4, [-0.0473, 0.0708, 0.09]), + (188.8, [-0.048, 0.0657, 0.0898]), + (190.4, [-0.0476, 0.0655, 0.0842]), + (192.0, [-0.0522, 0.0651, 0.0836]), + (194.4, [-0.0525, 0.0579, 0.0821]), + (196.0, [-0.0521, 0.057, 0.0756]), + (199.2, [-0.0573, 0.0544, 0.072]), + (200.8, [-0.0568, 0.0478, 0.0698]), + (202.4, [-0.0573, 0.0462, 0.0625]), + (204.0, [-0.0621, 0.045, 0.0607]), + (206.4, [-0.0613, 0.0419, 0.0578]), + (207.2, [-0.0612, 0.0376, 0.0562]), + (208.8, [-0.0647, 0.035, 0.0489]), + (209.6, [-0.0666, 0.0343, 0.048]), + (212.8, [-0.0656, 0.0309, 0.0438]), + (214.4, [-0.0664, 0.0241, 0.0381]), + (215.2, [-0.0698, 0.0224, 0.0345]), + (220.0, [-0.0705, 0.015, 0.0263]), + (222.4, [-0.076, 0.0057, 0.0165]), + (227.2, [-0.0772, -0.0008, 0.0091]), + (228.8, [-0.0812, -0.0057, 0.0019]), + (229.6, [-0.0812, -0.009, 0.0013]), + (232.8, [-0.0807, -0.0106, -0.0008]), + (234.4, [-0.0845, -0.0109, -0.0014]), + (236.0, [-0.0861, -0.0114, -0.0066]), + (237.6, [-0.0859, -0.0164, -0.006]), + (240.8, [-0.086, -0.0138, -0.0031]), + (242.4, [-0.0911, -0.0123, -0.0021]), + (244.0, [-0.0907, -0.011, -0.0056]), + (245.6, [-0.0905, -0.015, -0.0036]), + (248.0, [-0.0907, -0.0134, -0.0013]), + (250.4, [-0.0959, -0.0124, -0.0005]), + (252.0, [-0.0958, -0.0124, -0.0059]), + (253.6, [-0.0957, -0.0147, -0.0063]), + (254.4, [-0.0954, -0.0191, -0.0068]), + (257.6, [-0.1003, -0.0216, -0.0095]), + (259.2, [-0.0997, -0.0229, -0.0118]), + (260.0, [-0.0994, -0.0235, -0.0164]), + (260.8, [-0.0991, -0.0241, -0.0182]), + (262.4, [-0.0986, -0.0255, -0.0195]), + (264.8, [-0.101, -0.0326, -0.0214]), + (265.6, [-0.1039, -0.0331, -0.022]), + (268.0, [-0.1032, -0.0346, -0.024]), + (269.6, [-0.1029, -0.0355, -0.0304]), + (272.8, [-0.1027, -0.0373, -0.0337]), + (274.4, [-0.108, -0.0433, -0.0352]), + (277.6, [-0.1072, -0.0433, -0.038]), + (279.2, [-0.107, -0.0432, -0.0446]), + (282.4, [-0.1069, -0.0422, -0.0455]), + (284.0, [-0.1121, -0.0418, -0.0459]), + (285.6, [-0.1117, -0.0464, -0.0461]), + (288.8, [-0.1114, -0.0467, -0.0468]), + (289.6, [-0.1113, -0.0468, -0.0513]), + (290.4, [-0.1112, -0.0469, -0.052]), + (296.0, [-0.1163, -0.048, -0.0521]), + (297.6, [-0.1162, -0.0541, -0.0522]), + (300.0, [-0.1161, -0.0544, -0.0524]), + (301.6, [-0.1162, -0.0552, -0.0589]), + (304.0, [-0.1166, -0.0576, -0.0603]), + (304.8, [-0.1218, -0.0586, -0.061]), + (309.6, [-0.1226, -0.0647, -0.0655]), + (311.2, [-0.1221, -0.0713, -0.0662]), + (313.6, [-0.1205, -0.0714, -0.0654]), + (314.4, [-0.1198, -0.0711, -0.0697]), + (315.2, [-0.119, -0.0707, -0.0701]), + (316.8, [-0.1167, -0.0696, -0.0686]), + (318.4, [-0.1206, -0.0683, -0.0672]), + (326.4, [-0.1149, -0.0639, -0.0628]), + (328.0, [-0.115, -0.0695, -0.0629]), + (328.8, [-0.1152, -0.0695, -0.0625]), + (330.4, [-0.1159, -0.0699, -0.0688]), + (332.0, [-0.1167, -0.0706, -0.0694]), + (333.6, [-0.1233, -0.0716, -0.0704]), + (338.4, [-0.1269, -0.0746, -0.0738]), + (347.2, [-0.127, -0.0771, -0.0767]), + (348.8, [-0.1265, -0.0825, -0.0769]), + (349.6, [-0.1263, -0.0824, -0.0767]), + (351.2, [-0.1255, -0.0826, -0.0824]), + (352.8, [-0.1305, -0.0827, -0.0822]), + (432.0, [-0.1174, -0.0923, -0.0877]), + (460.8, [-0.1057, -0.0884, -0.0847]), + (472.8, [-0.093, -0.0827, -0.0779]), + (482.4, [-0.0803, -0.0742, -0.0704]), + (491.2, [-0.0743, -0.0671, -0.0657]), + (499.2, [-0.0622, -0.0572, -0.0593]), + (516.8, [-0.0419, -0.0239, -0.0357]), + (528.0, [-0.0265, 0.0054, -0.0132]), + (532.0, [-0.0223, 0.0133, -0.0073]), + (569.6, [-0.0032, 0.0277, 0.0061]), + (583.2, [0.0038, 0.0302, 0.0087]), + (596.0, [0.0125, 0.0306, 0.0084]), + (597.6, [0.0156, 0.0308, 0.0093]), + (598.4, [0.0195, 0.0314, 0.0106]), + (603.2, [0.0568, 0.0411, 0.0248]), + (604.8, [0.0654, 0.0383, 0.0254]), + (606.4, [0.0695, 0.0307, 0.0217]), + (608.0, [0.0665, 0.0205, 0.0144]), + (609.6, [0.0538, 0.0101, 0.006]), + (612.0, [0.0201, -0.0006, -0.0019]), + (613.6, [-0.0031, -0.0025, -0.0023]), + (614.4, [-0.0121, -0.0022, -0.0016]), + (615.2, [-0.0183, -0.0015, -0.0009]), + (616.0, [-0.0211, -0.0007, -0.0004]), + (617.6, [-0.0177, -0.0004, -0.0011]), + (620.0, [-0.0069, -0.0043, -0.0074]), + (622.4, [-0.0047, -0.007, -0.0118]), + (624.8, [-0.0062, -0.0023, -0.0058]), + (627.2, [0.004, 0.0041, 0.001]), + (628.8, [0.0071, 0.004, 0.0]), + (631.2, [0.0007, -0.0015, -0.0051]), + (632.8, [-0.0012, -0.0019, -0.0033]), + (635.2, [-0.0006, 0.0017, 0.004]), + (639.2, [0.0, -0.0013, 0.0016]), + (641.6, [0.0013, 0.0018, 0.0054]), + (643.2, [0.0023, 0.0016, 0.0031]), + (644.8, [0.0004, -0.0001, 0.0007]), + (650.4, [-0.0009, -0.0012, -0.0017]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.3178, -0.3098, -0.3125]), + (0.8, [-0.3018, -0.2804, -0.2781]), + (1.6, [-0.304, -0.2858, -0.2832]), + (4.8, [-0.3087, -0.2886, -0.2773]), + (10.4, [-0.3046, -0.2844, -0.2383]), + (16.0, [-0.2826, -0.2212, -0.182]), + (25.6, [-0.2221, -0.0755, -0.0742]), + (26.4, [-0.2171, -0.0677, -0.0661]), + (28.8, [-0.2012, -0.0349, -0.0437]), + (29.6, [-0.1962, -0.029, -0.036]), + (31.2, [-0.1866, -0.0137, -0.0251]), + (32.8, [-0.1778, -0.0048, -0.0134]), + (33.6, [-0.173, 0.0003, -0.0098]), + (36.0, [-0.1577, 0.0187, 0.009]), + (42.4, [-0.1182, 0.0578, 0.0476]), + (46.4, [-0.0941, 0.0777, 0.0692]), + (53.6, [-0.0555, 0.1085, 0.0996]), + (60.0, [-0.026, 0.1244, 0.117]), + (62.4, [-0.0192, 0.1301, 0.1217]), + (63.2, [-0.0256, 0.1277, 0.1219]), + (66.4, [-0.0902, 0.1115, 0.12]), + (67.2, [-0.0991, 0.1083, 0.1195]), + (68.0, [-0.0914, 0.1105, 0.1199]), + (71.2, [-0.0079, 0.1313, 0.129]), + (72.0, [0.0093, 0.1331, 0.1299]), + (72.8, [0.0148, 0.1349, 0.1293]), + (74.4, [0.0174, 0.1378, 0.1298]), + (78.4, [0.0287, 0.1403, 0.1334]), + (83.2, [0.0424, 0.1414, 0.1367]), + (85.6, [0.0466, 0.1428, 0.1368]), + (87.2, [0.0526, 0.1434, 0.1382]), + (89.6, [0.0547, 0.1432, 0.1382]), + (98.4, [0.0744, 0.141, 0.1374]), + (105.6, [0.0823, 0.1382, 0.1249]), + (111.2, [0.0899, 0.1342, 0.1287]), + (113.6, [0.0951, 0.1373, 0.1305]), + (115.2, [0.0965, 0.1365, 0.1363]), + (120.0, [0.1008, 0.1358, 0.1431]), + (127.2, [0.1098, 0.1351, 0.1449]), + (133.6, [0.1117, 0.1293, 0.1425]), + (138.4, [0.1145, 0.1162, 0.1276]), + (143.2, [0.118, 0.0973, 0.1052]), + (148.0, [0.1186, 0.0758, 0.0784]), + (151.2, [0.1177, 0.0586, 0.0568]), + (156.0, [0.1175, 0.0374, 0.0249]), + (161.6, [0.1186, 0.0185, 0.0007]), + (165.6, [0.1187, 0.0105, -0.0081]), + (168.0, [0.1193, 0.0064, -0.0085]), + (171.2, [0.121, 0.0057, -0.0106]), + (173.6, [0.1208, 0.0036, -0.0093]), + (176.0, [0.1216, 0.0037, -0.0116]), + (180.0, [0.1206, -0.0015, -0.0151]), + (182.4, [0.1224, -0.0019, -0.0184]), + (185.6, [0.1218, -0.0045, -0.019]), + (188.0, [0.1228, -0.0027, -0.02]), + (192.0, [0.1236, -0.0029, -0.0173]), + (194.4, [0.1232, 0.0007, -0.0163]), + (199.2, [0.1239, 0.0034, -0.0098]), + (200.8, [0.1237, 0.0068, -0.0081]), + (202.4, [0.1235, 0.008, -0.0042]), + (205.6, [0.1244, 0.0103, -0.0006]), + (208.0, [0.1241, 0.0149, 0.0036]), + (212.8, [0.1248, 0.0189, 0.01]), + (220.0, [0.125, 0.0308, 0.0233]), + (222.4, [0.1272, 0.037, 0.0299]), + (229.6, [0.1283, 0.0478, 0.0414]), + (236.0, [0.1294, 0.0502, 0.0458]), + (237.6, [0.1291, 0.0517, 0.0452]), + (250.4, [0.1315, 0.0437, 0.0354]), + (252.8, [0.1312, 0.0435, 0.037]), + (258.4, [0.1314, 0.049, 0.0406]), + (275.2, [0.1314, 0.0626, 0.059]), + (280.0, [0.1304, 0.0625, 0.0643]), + (296.0, [0.132, 0.0637, 0.0679]), + (300.0, [0.1318, 0.0661, 0.0682]), + (311.2, [0.134, 0.0779, 0.0779]), + (313.6, [0.1325, 0.0793, 0.0777]), + (324.0, [0.1237, 0.0729, 0.0714]), + (328.8, [0.123, 0.0734, 0.0706]), + (343.2, [0.132, 0.0806, 0.0799]), + (428.8, [0.1178, 0.0925, 0.0877]), + (464.0, [0.104, 0.0878, 0.0839]), + (482.4, [0.081, 0.0748, 0.0709]), + (492.0, [0.0743, 0.0671, 0.0656]), + (502.4, [0.0595, 0.0536, 0.0567]), + (512.0, [0.0483, 0.0354, 0.0437]), + (519.2, [0.0397, 0.0203, 0.0329]), + (529.6, [0.0255, -0.0075, 0.0115]), + (532.8, [0.0222, -0.0133, 0.0073]), + (570.4, [0.0031, -0.0277, -0.0061]), + (584.0, [-0.0038, -0.0303, -0.0087]), + (596.8, [-0.0124, -0.0307, -0.0084]), + (598.4, [-0.0152, -0.0308, -0.0092]), + (599.2, [-0.0186, -0.0313, -0.0103]), + (604.0, [-0.0558, -0.0411, -0.0245]), + (605.6, [-0.0648, -0.0389, -0.0256]), + (607.2, [-0.0694, -0.0318, -0.0223]), + (608.8, [-0.0673, -0.0217, -0.0154]), + (610.4, [-0.0557, -0.0112, -0.0069]), + (612.8, [-0.0229, 0.0001, 0.0015]), + (614.4, [0.0008, 0.0024, 0.0023]), + (615.2, [0.0103, 0.0022, 0.0017]), + (616.0, [0.0172, 0.0016, 0.0009]), + (616.8, [0.0207, 0.0008, 0.0004]), + (618.4, [0.0184, 0.0002, 0.0008]), + (621.6, [0.0052, 0.0056, 0.0092]), + (623.2, [0.0045, 0.007, 0.0117]), + (625.6, [0.0063, 0.0028, 0.0063]), + (626.4, [0.0044, 0.0003, 0.0034]), + (628.0, [-0.0033, -0.0039, -0.0009]), + (629.6, [-0.0073, -0.0044, -0.0005]), + (632.0, [-0.0012, 0.0012, 0.0049]), + (633.6, [0.0011, 0.002, 0.0037]), + (636.8, [0.0007, -0.0019, -0.0047]), + (640.0, [-0.0001, 0.0014, -0.0013]), + (642.4, [-0.0012, -0.0016, -0.0053]), + (644.0, [-0.0024, -0.0018, -0.0034]), + (645.6, [-0.0007, -0.0001, -0.0008]), + (648.0, [0.0002, -0.0001, -0.0009]), + (651.2, [0.0009, 0.0012, 0.0018]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.1191, 0.143, 0.0733]), + (3.1962, [0.1105, 0.1403, 0.0737]), + (5.5934, [0.1112, 0.1382, 0.0737]), + (9.5887, [0.1039, 0.1352, 0.0736]), + (14.383, [0.1024, 0.1305, 0.0736]), + (23.972, [0.0878, 0.1251, 0.0738]), + (25.57, [0.0841, 0.1211, 0.0739]), + (27.967, [0.0838, 0.1209, 0.0743]), + (29.565, [0.0795, 0.1205, 0.0748]), + (31.962, [0.0801, 0.1205, 0.0756]), + (33.56, [0.0759, 0.1201, 0.0758]), + (35.958, [0.0763, 0.12, 0.0761]), + (39.953, [0.0712, 0.119, 0.0755]), + (41.551, [0.0659, 0.1142, 0.0748]), + (44.747, [0.0622, 0.112, 0.0732]), + (46.345, [0.0568, 0.1114, 0.0726]), + (49.542, [0.0547, 0.1109, 0.0717]), + (51.14, [0.0497, 0.1066, 0.0715]), + (55.135, [0.0494, 0.1064, 0.0713]), + (56.733, [0.0451, 0.1066, 0.0716]), + (60.728, [0.0462, 0.1082, 0.0733]), + (62.327, [0.0425, 0.1048, 0.074]), + (67.92, [0.043, 0.1061, 0.0749]), + (69.518, [0.0391, 0.1057, 0.0746]), + (75.112, [0.0394, 0.1048, 0.0737]), + (76.71, [0.0352, 0.1006, 0.0738]), + (85.499, [0.0355, 0.101, 0.0739]), + (87.097, [0.0314, 0.101, 0.0739]), + (111.87, [0.0314, 0.1009, 0.074]), + (115.06, [0.0489, 0.1049, 0.0737]), + (116.66, [0.0589, 0.1055, 0.0781]), + (119.06, [0.0727, 0.1088, 0.0779]), + (119.86, [0.0739, 0.1088, 0.0779]), + (127.05, [0.0325, 0.1009, 0.0738]), + (147.03, [0.0317, 0.1014, 0.0737]), + (147.83, [0.0312, 0.0963, 0.0706]), + (158.21, [0.0011, 0.0086, 0.0057]), + (159.01, [0.0001, 0.002, 0.0007]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0007, 0.1338, 0.1558]), + (8.7602, [-0.0004, 0.1283, 0.1516]), + (18.317, [-0.0003, 0.1009, 0.1217]), + (23.095, [-0.0004, 0.0793, 0.0954]), + (29.466, [-0.0002, 0.0377, 0.0458]), + (31.059, [-0.0004, 0.0295, 0.0353]), + (32.652, [-0.0003, 0.0226, 0.0269]), + (34.245, [-0.0003, 0.0171, 0.0213]), + (38.227, [-0.0004, 0.009, 0.0109]), + (44.598, [-0.0002, 0.0024, 0.0029]), + (59.729, [-0.0003, 0.0036, 0.0041]), + (72.471, [-0.0004, 0.0015, 0.0019]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0232, 0.0313, 0.1277]), + (0.79334, [0.0246, 0.0331, 0.1315]), + (15.867, [0.0265, 0.0318, 0.1287]), + (17.454, [0.0225, 0.0276, 0.1121]), + (19.04, [0.0212, 0.0256, 0.1038]), + (28.56, [0.0153, 0.0182, 0.0739]), + (30.94, [0.0138, 0.0162, 0.0619]), + (33.32, [0.0108, 0.0122, 0.0481]), + (36.494, [0.0056, 0.0068, 0.0237]), + (39.667, [-0.0002, 0.0011, 0.0058]), + (41.254, [0.0, 0.0011, 0.001]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0079, 0.0109, 0.0109]), + (0.7847, [0.0094, 0.0073, 0.0081]), + (3.1388, [0.0132, 0.0087, 0.0101]), + (3.9235, [0.0163, 0.0109, 0.0121]), + (4.7082, [0.0165, 0.0127, 0.0136]), + (5.4929, [0.0215, 0.0161, 0.0174]), + (7.0623, [0.0245, 0.0214, 0.0223]), + (7.847, [0.028, 0.0267, 0.0272]), + (8.6317, [0.0283, 0.0273, 0.0277]), + (9.4165, [0.0315, 0.0312, 0.0312]), + (11.771, [0.0328, 0.0349, 0.0339]), + (12.555, [0.0309, 0.0321, 0.0314]), + (13.34, [0.0308, 0.0326, 0.0315]), + (19.618, [0.0075, 0.0067, 0.0073]), + (21.972, [0.0017, 0.0009, 0.0018]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0836, 0.0339, -0.0014]), + (1.5946, [0.0778, 0.0342, -0.0011]), + (5.5811, [0.0753, 0.0337, -0.001]), + (12.757, [0.0578, 0.0292, 0.0002]), + (18.338, [0.0449, 0.0229, 0.001]), + (20.73, [0.0433, 0.0205, 0.0011]), + (24.716, [0.0355, 0.0163, 0.0013]), + (30.297, [0.0224, 0.0095, 0.0009]), + (35.878, [0.0129, 0.0041, 0.0006]), + (43.054, [0.0036, 0.0017, 0.0007]), + (54.216, [0.0003, 0.0011, 0.0006]), + (71.757, [0.0016, 0.0019, 0.0017]), + (90.094, [-0.0011, 0.0, -0.0007]), + (102.05, [0.0004, 0.0019, 0.0013]), + ], + }, +]; +const PRIME35_100_STREAKS: &[Streak] = &[]; +const PRIME35_100_RAYS: &[Ray] = &[ + Ray { angle: 7.32, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0275] }, + Ray { angle: 8.05, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 9.3, r0: 6.25, r1: 37.5, fade: 0.05, rgb: [0.0118, 0.0216, 0.0255] }, + Ray { angle: 10.56, r0: 6.25, r1: 60.938, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 11.86, r0: 33.594, r1: 113.28, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 17.4, r0: 18.75, r1: 132.03, fade: 0.05, rgb: [0.0314, 0.0431, 0.0431] }, + Ray { angle: 17.65, r0: 18.75, r1: 128.12, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 24.03, r0: 16.406, r1: 120.31, fade: 0.05, rgb: [0.0098, 0.0137, 0.0157] }, + Ray { angle: 24.29, r0: 15.625, r1: 120.31, fade: 0.05, rgb: [0.0216, 0.0314, 0.0314] }, + Ray { angle: 37.26, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0167, 0.0196] }, + Ray { angle: 38.3, r0: 6.25, r1: 84.375, fade: 0.05, rgb: [0.0216, 0.0392, 0.0392] }, + Ray { angle: 38.56, r0: 6.25, r1: 86.719, fade: 0.05, rgb: [0.0225, 0.0373, 0.0392] }, + Ray { angle: 39.41, r0: 7.8125, r1: 40.625, fade: 0.05, rgb: [0.0137, 0.0275, 0.0294] }, + Ray { angle: 40.48, r0: 8.5938, r1: 35.938, fade: 0.05, rgb: [0.0078, 0.0127, 0.0147] }, + Ray { angle: 41.49, r0: 43.75, r1: 94.531, fade: 0.05, rgb: [0.0098, 0.0206, 0.0196] }, + Ray { angle: 41.76, r0: 35.938, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 53.14, r0: 14.844, r1: 120.31, fade: 0.05, rgb: [0.0078, 0.0127, 0.0127] }, + Ray { angle: 53.39, r0: 14.844, r1: 121.09, fade: 0.05, rgb: [0.0255, 0.0392, 0.0392] }, + Ray { angle: 67.69, r0: 8.5938, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 68.57, r0: 8.5938, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 69.89, r0: 76.562, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 71.17, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 71.87, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0275] }, + Ray { angle: 97.72, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0196, 0.0255, 0.0275] }, + Ray { angle: 98.42, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0235, 0.0255] }, + Ray { angle: 99.71, r0: 79.688, r1: 114.06, fade: 0.05, rgb: [0.0196, 0.0235, 0.0196] }, + Ray { angle: 100.98, r0: 79.688, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 102.24, r0: 33.594, r1: 113.28, fade: 0.05, rgb: [0.0176, 0.0216, 0.0196] }, + Ray { angle: 104.36, r0: 50.781, r1: 372.66, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 110.57, r0: 13.281, r1: 125.78, fade: 0.05, rgb: [0.0118, 0.0176, 0.0196] }, + Ray { angle: 110.89, r0: 14.062, r1: 138.28, fade: 0.05, rgb: [0.0324, 0.05, 0.051] }, + Ray { angle: 118.75, r0: 17.188, r1: 232.81, fade: 0.05, rgb: [0.0333, 0.0392, 0.0392] }, + Ray { angle: 127.69, r0: 8.5938, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0235, 0.0235] }, + Ray { angle: 128.59, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0118, 0.0157, 0.0196] }, + Ray { angle: 129.71, r0: 7.8125, r1: 37.5, fade: 0.05, rgb: [0.0118, 0.0196, 0.0255] }, + Ray { angle: 130.84, r0: 7.0312, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 132.02, r0: 38.281, r1: 110.94, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 147.42, r0: 14.844, r1: 278.12, fade: 0.05, rgb: [0.0333, 0.0353, 0.0353] }, + Ray { angle: 157.63, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 158.52, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 159.79, r0: 78.125, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0225, 0.0206] }, + Ray { angle: 161.67, r0: 6.25, r1: 246.88, fade: 0.05, rgb: [0.0608, 0.0647, 0.0627] }, + Ray { angle: 168.47, r0: 12.5, r1: 141.41, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 168.81, r0: 12.5, r1: 238.28, fade: 0.05, rgb: [0.0333, 0.0373, 0.0353] }, + Ray { angle: 183.22, r0: 21.875, r1: 234.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 187.67, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0255] }, + Ray { angle: 188.46, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0255] }, + Ray { angle: 189.71, r0: 68.75, r1: 114.06, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 190.96, r0: 6.25, r1: 59.375, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 192.24, r0: 28.125, r1: 113.28, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 217.72, r0: 13.281, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0216, 0.0216] }, + Ray { angle: 218.64, r0: 7.8125, r1: 78.125, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 219.8, r0: 6.25, r1: 39.062, fade: 0.05, rgb: [0.0118, 0.0216, 0.0255] }, + Ray { angle: 220.92, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0098, 0.0147, 0.0196] }, + Ray { angle: 222.04, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0176, 0.0196] }, + Ray { angle: 226.01, r0: 30.469, r1: 228.12, fade: 0.05, rgb: [0.0235, 0.0294, 0.0275] }, + Ray { angle: 240.27, r0: 17.969, r1: 224.22, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 247.12, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0245, 0.0245] }, + Ray { angle: 247.4, r0: 6.25, r1: 210.94, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 248.21, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0196, 0.0216] }, + Ray { angle: 249.48, r0: 83.594, r1: 114.06, fade: 0.05, rgb: [0.0363, 0.0392, 0.0392] }, + Ray { angle: 250.75, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 251.44, r0: 28.906, r1: 112.5, fade: 0.05, rgb: [0.0196, 0.0265, 0.0245] }, + Ray { angle: 254.64, r0: 38.281, r1: 155.47, fade: 0.05, rgb: [0.0235, 0.0314, 0.0275] }, + Ray { angle: 254.89, r0: 25.781, r1: 144.53, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 269.02, r0: 10.938, r1: 195.31, fade: 0.05, rgb: [0.0373, 0.0431, 0.0431] }, + Ray { angle: 276.16, r0: 113.28, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 277.32, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0176, 0.0275, 0.0275] }, + Ray { angle: 278.06, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 279.33, r0: 68.75, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 280.59, r0: 80.469, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 281.85, r0: 6.25, r1: 65.625, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 283.24, r0: 30.469, r1: 199.22, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 297.53, r0: 21.094, r1: 196.88, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 305.53, r0: 47.656, r1: 99.219, fade: 0.05, rgb: [0.0118, 0.0216, 0.0235] }, + Ray { angle: 307.46, r0: 9.375, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 308.33, r0: 7.8125, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 309.42, r0: 6.25, r1: 39.062, fade: 0.05, rgb: [0.0098, 0.0137, 0.0216] }, + Ray { angle: 310.6, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0137, 0.0157] }, + Ray { angle: 311.8, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0196, 0.0216] }, + Ray { angle: 337.28, r0: 8.5938, r1: 113.28, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 338.21, r0: 7.0312, r1: 114.06, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 339.49, r0: 78.125, r1: 114.06, fade: 0.05, rgb: [0.0353, 0.0373, 0.0392] }, + Ray { angle: 340.81, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 341.56, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0275, 0.0275] }, +]; +const PRIME35_125_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.07, 1.084, 1.069]), + (0.8, [1.135, 1.127, 1.117]), + (1.6, [1.113, 1.113, 1.101]), + (7.2, [1.131, 1.117, 1.088]), + (12.8, [1.12, 1.102, 1.016]), + (16.0, [1.101, 1.036, 0.9582]), + (27.2, [1.012, 0.7168, 0.7255]), + (30.4, [0.9818, 0.6473, 0.6681]), + (41.6, [0.8687, 0.4798, 0.5065]), + (51.2, [0.7621, 0.3521, 0.3864]), + (55.2, [0.7145, 0.3047, 0.3411]), + (61.6, [0.644, 0.2443, 0.278]), + (62.4, [0.643, 0.2379, 0.2696]), + (63.2, [0.6602, 0.2379, 0.2629]), + (66.4, [0.8143, 0.2649, 0.2486]), + (67.2, [0.8342, 0.2651, 0.2445]), + (68.0, [0.815, 0.2566, 0.2365]), + (68.8, [0.7675, 0.2406, 0.2283]), + (71.2, [0.5926, 0.1809, 0.2023]), + (72.0, [0.546, 0.1622, 0.1964]), + (72.8, [0.5273, 0.1539, 0.1892]), + (75.2, [0.5025, 0.1411, 0.1713]), + (76.8, [0.4903, 0.1298, 0.1663]), + (80.0, [0.4631, 0.1226, 0.1522]), + (81.6, [0.4498, 0.1142, 0.1497]), + (86.4, [0.4171, 0.1028, 0.1389]), + (88.8, [0.399, 0.1027, 0.1299]), + (91.2, [0.3829, 0.0937, 0.1243]), + (92.8, [0.3746, 0.0891, 0.116]), + (96.0, [0.3506, 0.0848, 0.1119]), + (97.6, [0.3426, 0.0763, 0.1088]), + (100.8, [0.3214, 0.0738, 0.1082]), + (104.0, [0.305, 0.0722, 0.1016]), + (107.2, [0.286, 0.0703, 0.1019]), + (111.2, [0.264, 0.0581, 0.0966]), + (113.6, [0.2488, 0.0553, 0.089]), + (115.2, [0.239, 0.055, 0.0784]), + (116.8, [0.2311, 0.0466, 0.074]), + (122.4, [0.1999, 0.0383, 0.055]), + (124.0, [0.1939, 0.0376, 0.0546]), + (125.6, [0.1849, 0.0299, 0.0506]), + (128.0, [0.1756, 0.0265, 0.0464]), + (130.4, [0.1618, 0.0298, 0.0393]), + (132.0, [0.1564, 0.0334, 0.041]), + (133.6, [0.1477, 0.0331, 0.0402]), + (136.0, [0.139, 0.0324, 0.0439]), + (136.8, [0.1368, 0.0333, 0.0477]), + (138.4, [0.1301, 0.0405, 0.0523]), + (140.0, [0.1216, 0.0443, 0.0601]), + (141.6, [0.1182, 0.0521, 0.0653]), + (142.4, [0.1168, 0.0536, 0.0704]), + (147.2, [0.0929, 0.0772, 0.0959]), + (148.8, [0.0879, 0.0783, 0.1078]), + (150.4, [0.0852, 0.0889, 0.1114]), + (151.2, [0.0842, 0.0913, 0.118]), + (152.8, [0.0752, 0.1012, 0.1277]), + (153.6, [0.0749, 0.1055, 0.135]), + (155.2, [0.0662, 0.1115, 0.1445]), + (157.6, [0.0614, 0.1207, 0.16]), + (159.2, [0.061, 0.1279, 0.1661]), + (160.8, [0.0565, 0.1301, 0.171]), + (162.4, [0.0482, 0.136, 0.1758]), + (164.8, [0.0449, 0.138, 0.1774]), + (166.4, [0.0368, 0.1403, 0.1789]), + (168.8, [0.0308, 0.1399, 0.1767]), + (173.6, [0.0237, 0.1342, 0.1683]), + (176.8, [0.0178, 0.1333, 0.1664]), + (178.4, [0.0173, 0.135, 0.1681]), + (180.0, [0.013, 0.1328, 0.1661]), + (181.6, [0.0125, 0.1341, 0.1665]), + (182.4, [0.0096, 0.1343, 0.1644]), + (184.0, [0.0093, 0.1312, 0.1658]), + (185.6, [0.0049, 0.1325, 0.1623]), + (187.2, [0.005, 0.1282, 0.1622]), + (188.8, [0.0007, 0.1284, 0.1572]), + (190.4, [0.0001, 0.1238, 0.1561]), + (191.2, [-0.0029, 0.1226, 0.1538]), + (192.8, [-0.003, 0.1212, 0.1492]), + (194.4, [-0.0072, 0.1154, 0.146]), + (196.0, [-0.0074, 0.1142, 0.1402]), + (197.6, [-0.0119, 0.1076, 0.1365]), + (199.2, [-0.0119, 0.1063, 0.1309]), + (200.0, [-0.0148, 0.1049, 0.1298]), + (204.8, [-0.0198, 0.0898, 0.115]), + (206.4, [-0.0235, 0.0883, 0.1108]), + (208.8, [-0.0242, 0.0806, 0.1018]), + (210.4, [-0.0284, 0.0789, 0.0993]), + (212.0, [-0.029, 0.072, 0.0917]), + (212.8, [-0.0323, 0.0712, 0.0906]), + (214.4, [-0.0321, 0.0689, 0.0866]), + (216.0, [-0.0361, 0.0617, 0.08]), + (218.4, [-0.0368, 0.0576, 0.0728]), + (219.2, [-0.0398, 0.0531, 0.0686]), + (221.6, [-0.0411, 0.0482, 0.0628]), + (223.2, [-0.0448, 0.041, 0.0561]), + (224.8, [-0.0444, 0.039, 0.0531]), + (226.4, [-0.0487, 0.0356, 0.046]), + (227.2, [-0.0478, 0.0319, 0.0456]), + (228.8, [-0.0489, 0.0314, 0.0429]), + (229.6, [-0.0516, 0.031, 0.039]), + (231.2, [-0.0503, 0.0259, 0.0382]), + (233.6, [-0.0532, 0.027, 0.0331]), + (235.2, [-0.0528, 0.0233, 0.0343]), + (236.8, [-0.0567, 0.0259, 0.0316]), + (238.4, [-0.0556, 0.0268, 0.0339]), + (239.2, [-0.0569, 0.025, 0.0352]), + (240.0, [-0.0598, 0.0268, 0.0357]), + (240.8, [-0.0588, 0.0286, 0.0338]), + (242.4, [-0.0588, 0.031, 0.0372]), + (243.2, [-0.0619, 0.0293, 0.0388]), + (244.8, [-0.0613, 0.0334, 0.0373]), + (246.4, [-0.062, 0.0365, 0.0407]), + (247.2, [-0.0651, 0.035, 0.0421]), + (248.8, [-0.0639, 0.037, 0.0399]), + (249.6, [-0.0637, 0.0383, 0.0412]), + (250.4, [-0.0662, 0.0393, 0.0422]), + (252.0, [-0.0667, 0.0361, 0.0422]), + (252.8, [-0.0664, 0.0373, 0.0388]), + (254.4, [-0.0699, 0.0385, 0.0392]), + (256.0, [-0.068, 0.0347, 0.0372]), + (256.8, [-0.0673, 0.0345, 0.0331]), + (258.4, [-0.0705, 0.0345, 0.0331]), + (259.2, [-0.0688, 0.0341, 0.0326]), + (260.8, [-0.0674, 0.0287, 0.0272]), + (262.4, [-0.0707, 0.029, 0.027]), + (264.0, [-0.0686, 0.0285, 0.0265]), + (264.8, [-0.068, 0.025, 0.0231]), + (266.4, [-0.0715, 0.0236, 0.0217]), + (268.0, [-0.0697, 0.0234, 0.0216]), + (269.6, [-0.072, 0.0182, 0.0167]), + (272.8, [-0.0706, 0.0194, 0.0147]), + (273.6, [-0.0741, 0.0175, 0.0105]), + (274.4, [-0.0738, 0.0148, 0.0106]), + (276.8, [-0.0719, 0.0171, 0.0104]), + (278.4, [-0.0756, 0.0166, 0.0052]), + (279.2, [-0.0745, 0.0138, 0.0059]), + (281.6, [-0.075, 0.0172, 0.0064]), + (282.4, [-0.0774, 0.0181, 0.0026]), + (283.2, [-0.0762, 0.018, 0.0026]), + (284.0, [-0.0758, 0.015, 0.0034]), + (285.6, [-0.0761, 0.0178, 0.0046]), + (286.4, [-0.0795, 0.0192, 0.0038]), + (287.2, [-0.0785, 0.0203, 0.0005]), + (288.0, [-0.0779, 0.0212, 0.0017]), + (288.8, [-0.0775, 0.0179, 0.0025]), + (290.4, [-0.0798, 0.0207, 0.004]), + (291.2, [-0.081, 0.0221, 0.0038]), + (292.0, [-0.0801, 0.0233, 0.0]), + (292.8, [-0.0795, 0.0247, 0.0011]), + (294.4, [-0.0789, 0.0214, 0.0025]), + (295.2, [-0.083, 0.0221, 0.0032]), + (296.0, [-0.0827, 0.0223, 0.0026]), + (296.8, [-0.082, 0.0227, -0.0009]), + (298.4, [-0.0807, 0.0233, 0.0012]), + (299.2, [-0.0836, 0.019, 0.0021]), + (300.8, [-0.0844, 0.0187, 0.0026]), + (301.6, [-0.0839, 0.0185, -0.0015]), + (303.2, [-0.0826, 0.0175, -0.0008]), + (304.0, [-0.0865, 0.0153, -0.0008]), + (304.8, [-0.0878, 0.0097, -0.0009]), + (305.6, [-0.0872, 0.0089, -0.0014]), + (306.4, [-0.087, 0.0074, -0.0068]), + (308.0, [-0.0862, 0.0046, -0.0076]), + (309.6, [-0.0906, 0.0022, -0.0076]), + (310.4, [-0.0895, -0.0031, -0.0069]), + (311.2, [-0.0881, -0.0029, -0.0069]), + (312.0, [-0.0867, -0.0025, -0.0103]), + (312.8, [-0.0845, -0.0017, -0.0085]), + (314.4, [-0.0862, 0.0013, -0.0044]), + (316.0, [-0.0816, -0.0006, 0.001]), + (316.8, [-0.0793, 0.0018, -0.0012]), + (317.6, [-0.0765, 0.0039, 0.0009]), + (319.2, [-0.0775, 0.0081, 0.0065]), + (320.8, [-0.0729, 0.0122, 0.0119]), + (321.6, [-0.0708, 0.0089, 0.0146]), + (322.4, [-0.0688, 0.0105, 0.012]), + (323.2, [-0.0671, 0.0123, 0.0129]), + (324.0, [-0.0703, 0.0139, 0.0135]), + (326.4, [-0.0666, 0.0185, 0.0145]), + (327.2, [-0.0658, 0.0174, 0.0151]), + (328.0, [-0.0646, 0.0148, 0.011]), + (329.6, [-0.0697, 0.0168, 0.0089]), + (332.8, [-0.0709, 0.0189, 0.0061]), + (333.6, [-0.0713, 0.0155, 0.0015]), + (334.4, [-0.0768, 0.0132, -0.0021]), + (339.2, [-0.0804, 0.0141, -0.0085]), + (340.0, [-0.0848, 0.0131, -0.0144]), + (340.8, [-0.0861, 0.0089, -0.0153]), + (345.6, [-0.0832, 0.0061, -0.0176]), + (346.4, [-0.0866, 0.0064, -0.0228]), + (347.2, [-0.0861, 0.0019, -0.0234]), + (348.0, [-0.0856, 0.0, -0.0233]), + (351.2, [-0.0834, -0.0025, -0.0236]), + (352.0, [-0.0865, -0.0031, -0.0232]), + (352.8, [-0.0883, -0.0037, -0.0281]), + (353.6, [-0.088, -0.0037, -0.0289]), + (354.4, [-0.0878, -0.0084, -0.0288]), + (355.2, [-0.0876, -0.0105, -0.0289]), + (357.6, [-0.0869, -0.0121, -0.0289]), + (358.4, [-0.0915, -0.0128, -0.0286]), + (360.0, [-0.0913, -0.0139, -0.0344]), + (360.8, [-0.091, -0.0141, -0.034]), + (362.4, [-0.0907, -0.021, -0.0339]), + (364.0, [-0.0899, -0.0219, -0.0337]), + (364.8, [-0.094, -0.0223, -0.0337]), + (366.4, [-0.0952, -0.0233, -0.0336]), + (367.2, [-0.0952, -0.0237, -0.0386]), + (368.8, [-0.0952, -0.0243, -0.0383]), + (369.6, [-0.095, -0.0295, -0.0381]), + (371.2, [-0.0948, -0.0308, -0.0378]), + (372.0, [-0.0996, -0.0312, -0.0376]), + (373.6, [-0.1003, -0.0319, -0.0368]), + (375.2, [-0.1003, -0.0325, -0.0424]), + (377.6, [-0.1005, -0.0336, -0.0414]), + (378.4, [-0.1, -0.0386, -0.0411]), + (380.0, [-0.1059, -0.039, -0.0405]), + (381.6, [-0.1061, -0.0394, -0.0394]), + (383.2, [-0.1063, -0.0396, -0.0445]), + (386.4, [-0.1064, -0.0396, -0.0427]), + (387.2, [-0.1113, -0.0445, -0.0423]), + (390.4, [-0.1128, -0.0454, -0.0403]), + (391.2, [-0.113, -0.0454, -0.041]), + (392.0, [-0.1131, -0.0454, -0.0453]), + (395.2, [-0.1135, -0.0451, -0.0486]), + (396.0, [-0.1185, -0.0446, -0.0494]), + (397.6, [-0.1187, -0.0502, -0.0511]), + (400.0, [-0.1189, -0.0492, -0.0531]), + (401.6, [-0.1189, -0.0487, -0.0606]), + (403.2, [-0.1186, -0.0481, -0.0618]), + (404.8, [-0.1242, -0.0476, -0.0631]), + (407.2, [-0.1238, -0.0463, -0.065]), + (408.8, [-0.1236, -0.0522, -0.0663]), + (410.4, [-0.1235, -0.0542, -0.0673]), + (411.2, [-0.1236, -0.0552, -0.0727]), + (412.0, [-0.1235, -0.0562, -0.0741]), + (413.6, [-0.1232, -0.0582, -0.0752]), + (415.2, [-0.1293, -0.0602, -0.0764]), + (419.2, [-0.1299, -0.0646, -0.0793]), + (420.8, [-0.1303, -0.0719, -0.0805]), + (422.4, [-0.1307, -0.0732, -0.0812]), + (424.0, [-0.1309, -0.0747, -0.0879]), + (425.6, [-0.1312, -0.0759, -0.0887]), + (426.4, [-0.1361, -0.0765, -0.0891]), + (434.4, [-0.1356, -0.0811, -0.0923]), + (436.0, [-0.135, -0.087, -0.0927]), + (438.4, [-0.1341, -0.0876, -0.0981]), + (440.8, [-0.1383, -0.0885, -0.0985]), + (453.6, [-0.1314, -0.0925, -0.1005]), + (454.4, [-0.1308, -0.0977, -0.1002]), + (456.0, [-0.1298, -0.0988, -0.1064]), + (456.8, [-0.1287, -0.0989, -0.106]), + (458.4, [-0.1338, -0.0993, -0.106]), + (464.8, [-0.128, -0.1, -0.105]), + (481.6, [-0.1024, -0.0953, -0.0937]), + (494.4, [-0.0879, -0.09, -0.0859]), + (503.2, [-0.0724, -0.0812, -0.0773]), + (518.4, [-0.0488, -0.0592, -0.0573]), + (532.0, [-0.0275, -0.0323, -0.0346]), + (565.6, [0.0086, -0.0151, -0.0162]), + (596.8, [0.0344, -0.0058, -0.005]), + (599.2, [0.0436, -0.0003, 0.0012]), + (602.4, [0.067, 0.0104, 0.012]), + (604.8, [0.0749, 0.011, 0.0118]), + (606.4, [0.073, 0.0087, 0.0087]), + (608.0, [0.0653, 0.0061, 0.0058]), + (609.6, [0.0508, 0.0036, 0.0039]), + (611.2, [0.0311, 0.0018, 0.0034]), + (613.6, [0.0021, 0.0023, 0.0037]), + (615.2, [-0.01, 0.0032, 0.0037]), + (616.0, [-0.013, 0.003, 0.0033]), + (617.6, [-0.0136, 0.0016, 0.0011]), + (621.6, [-0.0062, -0.0035, -0.006]), + (624.0, [-0.0023, -0.0041, -0.0071]), + (628.0, [-0.0075, -0.0024, -0.0053]), + (630.4, [-0.0009, -0.0022, -0.0043]), + (632.8, [0.007, 0.0022, 0.0011]), + (634.4, [0.0093, 0.005, 0.005]), + (635.2, [0.008, 0.005, 0.0055]), + (637.6, [-0.0023, -0.0006, 0.0014]), + (639.2, [-0.0054, -0.0024, 0.0]), + (641.6, [-0.0009, 0.0004, 0.0032]), + (643.2, [0.0027, 0.0009, 0.003]), + (645.6, [0.0014, 0.0012, 0.0017]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.1438, -0.1016, -0.088]), + (0.8, [-0.115, -0.1185, -0.1069]), + (6.4, [-0.1314, -0.1172, -0.1015]), + (8.0, [-0.1322, -0.1162, -0.0966]), + (12.8, [-0.1338, -0.1097, -0.0717]), + (16.0, [-0.129, -0.0855, -0.0504]), + (20.0, [-0.1225, -0.0453, -0.0198]), + (24.0, [-0.1129, -0.0017, 0.0105]), + (25.6, [-0.1095, 0.0138, 0.0234]), + (27.2, [-0.1047, 0.03, 0.0332]), + (29.6, [-0.0978, 0.0493, 0.0483]), + (36.8, [-0.077, 0.0885, 0.0861]), + (39.2, [-0.0698, 0.0982, 0.0963]), + (51.2, [-0.0243, 0.1557, 0.1492]), + (62.4, [0.0194, 0.1937, 0.1882]), + (63.2, [0.0119, 0.193, 0.1903]), + (64.8, [-0.0147, 0.1882, 0.1923]), + (66.4, [-0.0461, 0.1818, 0.1943]), + (67.2, [-0.054, 0.1817, 0.1954]), + (68.0, [-0.0476, 0.1842, 0.1985]), + (70.4, [0.0125, 0.2042, 0.207]), + (71.2, [0.0376, 0.2116, 0.2096]), + (72.0, [0.0536, 0.2179, 0.2113]), + (72.8, [0.0591, 0.2206, 0.214]), + (82.4, [0.0884, 0.2319, 0.226]), + (97.6, [0.121, 0.2361, 0.2301]), + (107.2, [0.1357, 0.2277, 0.216]), + (122.4, [0.1622, 0.2296, 0.233]), + (128.0, [0.1678, 0.2289, 0.2322]), + (133.6, [0.1754, 0.2196, 0.2281]), + (136.8, [0.1772, 0.2119, 0.217]), + (141.6, [0.1811, 0.1885, 0.1927]), + (150.4, [0.1895, 0.1367, 0.131]), + (156.8, [0.1936, 0.0972, 0.0767]), + (160.0, [0.1942, 0.0824, 0.0586]), + (162.4, [0.197, 0.0727, 0.0492]), + (165.6, [0.1986, 0.0645, 0.0415]), + (168.8, [0.2013, 0.0602, 0.0393]), + (173.6, [0.2019, 0.0588, 0.0406]), + (184.0, [0.2021, 0.0496, 0.03]), + (187.2, [0.2025, 0.0486, 0.0293]), + (188.8, [0.204, 0.0475, 0.0313]), + (193.6, [0.2034, 0.0519, 0.0348]), + (210.4, [0.2055, 0.0729, 0.0639]), + (227.2, [0.205, 0.1009, 0.0989]), + (233.6, [0.2027, 0.1014, 0.1037]), + (239.2, [0.2009, 0.0965, 0.0979]), + (251.2, [0.197, 0.0733, 0.0779]), + (254.4, [0.1952, 0.0698, 0.0774]), + (275.2, [0.179, 0.0741, 0.087]), + (283.2, [0.1747, 0.0673, 0.0877]), + (293.6, [0.1693, 0.0532, 0.0808]), + (298.4, [0.1673, 0.0514, 0.0774]), + (303.2, [0.1654, 0.0529, 0.0752]), + (311.2, [0.1631, 0.066, 0.0771]), + (314.4, [0.1573, 0.0623, 0.0719]), + (322.4, [0.1362, 0.0451, 0.0467]), + (328.0, [0.128, 0.0355, 0.0443]), + (332.8, [0.1295, 0.0325, 0.0487]), + (340.8, [0.1368, 0.033, 0.0623]), + (347.2, [0.1335, 0.0374, 0.0665]), + (370.4, [0.1303, 0.0577, 0.0694]), + (384.0, [0.1337, 0.0632, 0.0677]), + (392.0, [0.1366, 0.065, 0.0635]), + (408.0, [0.1394, 0.0624, 0.0811]), + (426.4, [0.143, 0.0841, 0.0968]), + (440.8, [0.1423, 0.0923, 0.1024]), + (456.8, [0.1334, 0.0989, 0.1063]), + (467.2, [0.126, 0.1001, 0.1045]), + (482.4, [0.1023, 0.0953, 0.0937]), + (495.2, [0.0878, 0.09, 0.0858]), + (504.0, [0.0722, 0.0811, 0.0772]), + (519.2, [0.0487, 0.0592, 0.0572]), + (532.8, [0.0274, 0.0323, 0.0346]), + (567.2, [-0.0095, 0.0147, 0.0158]), + (597.6, [-0.0344, 0.0059, 0.005]), + (600.0, [-0.0427, 0.0008, -0.0006]), + (603.2, [-0.0661, -0.0102, -0.0118]), + (605.6, [-0.0749, -0.0112, -0.0121]), + (608.0, [-0.0709, -0.0077, -0.0076]), + (609.6, [-0.0606, -0.0052, -0.005]), + (612.0, [-0.0335, -0.002, -0.0035]), + (614.4, [-0.0041, -0.0023, -0.0038]), + (616.0, [0.0089, -0.0032, -0.0038]), + (616.8, [0.0123, -0.0032, -0.0035]), + (619.2, [0.0126, -0.0008, 0.0001]), + (622.4, [0.0064, 0.0032, 0.0057]), + (624.8, [0.0023, 0.0041, 0.007]), + (626.4, [0.0032, 0.003, 0.0064]), + (628.0, [0.0068, 0.0023, 0.0054]), + (629.6, [0.0065, 0.0025, 0.0051]), + (632.0, [-0.0016, 0.0014, 0.0032]), + (635.2, [-0.0094, -0.0049, -0.0048]), + (636.8, [-0.0059, -0.0041, -0.0051]), + (638.4, [0.0015, 0.0001, -0.0018]), + (640.0, [0.0052, 0.0023, 0.0001]), + (641.6, [0.0037, 0.0009, -0.0018]), + (644.0, [-0.0027, -0.001, -0.0032]), + (646.4, [-0.0017, -0.0012, -0.0017]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.1346, 0.1521, 0.0797]), + (3.1962, [0.1329, 0.1555, 0.0808]), + (4.7944, [0.1271, 0.1528, 0.0807]), + (14.383, [0.1118, 0.1472, 0.0809]), + (20.776, [0.1003, 0.1389, 0.0815]), + (25.57, [0.0921, 0.1381, 0.0821]), + (27.168, [0.0915, 0.1341, 0.0825]), + (28.766, [0.0877, 0.1346, 0.0831]), + (32.761, [0.089, 0.1329, 0.0847]), + (36.757, [0.0857, 0.1309, 0.0853]), + (47.144, [0.0671, 0.1228, 0.082]), + (48.743, [0.0617, 0.1221, 0.0815]), + (51.14, [0.0605, 0.1216, 0.0814]), + (52.738, [0.0561, 0.1215, 0.0813]), + (55.934, [0.0554, 0.1196, 0.0815]), + (57.532, [0.0518, 0.1189, 0.0819]), + (60.728, [0.0529, 0.1204, 0.0837]), + (62.327, [0.0492, 0.1211, 0.0847]), + (64.724, [0.0503, 0.123, 0.0859]), + (66.322, [0.0502, 0.1195, 0.0862]), + (67.92, [0.0465, 0.1189, 0.086]), + (71.915, [0.0462, 0.1183, 0.0853]), + (73.513, [0.0423, 0.1181, 0.0851]), + (78.308, [0.0426, 0.1187, 0.0854]), + (79.906, [0.0406, 0.1149, 0.0858]), + (88.696, [0.0394, 0.1162, 0.0869]), + (90.294, [0.0359, 0.1163, 0.087]), + (111.07, [0.037, 0.1188, 0.0888]), + (111.87, [0.0375, 0.1189, 0.0889]), + (112.67, [0.0413, 0.119, 0.089]), + (118.26, [0.0816, 0.127, 0.0932]), + (119.86, [0.0841, 0.1272, 0.0934]), + (121.46, [0.0788, 0.1276, 0.0934]), + (127.05, [0.0389, 0.1199, 0.0899]), + (146.23, [0.0384, 0.1225, 0.0917]), + (147.03, [0.0387, 0.1213, 0.0915]), + (147.83, [0.0372, 0.1148, 0.0864]), + (156.62, [0.0081, 0.0268, 0.0187]), + (158.21, [0.0013, 0.0082, 0.0068]), + (159.01, [0.0003, 0.0011, 0.0011]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0011, 0.1392, 0.1715]), + (8.7602, [-0.0008, 0.1357, 0.1695]), + (16.724, [-0.0008, 0.1149, 0.1432]), + (22.299, [-0.0006, 0.0897, 0.1128]), + (24.688, [-0.0009, 0.0759, 0.0945]), + (29.466, [-0.0009, 0.0415, 0.0529]), + (31.059, [-0.0008, 0.0321, 0.041]), + (32.652, [-0.0009, 0.0257, 0.0315]), + (35.837, [-0.001, 0.0146, 0.0187]), + (39.819, [-0.0008, 0.0067, 0.0089]), + (45.394, [-0.0009, 0.0025, 0.0033]), + (60.525, [-0.001, 0.0047, 0.0055]), + (84.417, [-0.0009, -0.0018, -0.0017]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0224, 0.034, 0.1339]), + (1.5867, [0.0251, 0.0339, 0.1363]), + (2.38, [0.0233, 0.0338, 0.1352]), + (15.074, [0.0265, 0.0342, 0.1355]), + (15.867, [0.0263, 0.0341, 0.1327]), + (17.454, [0.0224, 0.0297, 0.117]), + (20.627, [0.02, 0.0262, 0.1029]), + (28.56, [0.0152, 0.0194, 0.0789]), + (31.734, [0.0128, 0.0157, 0.0616]), + (34.114, [0.0086, 0.0102, 0.0434]), + (34.907, [0.0068, 0.0087, 0.0397]), + (36.494, [0.0059, 0.0086, 0.0278]), + (38.081, [0.0032, 0.0046, 0.0154]), + (39.667, [0.0007, 0.0023, 0.0059]), + (41.254, [0.0006, 0.002, 0.0018]), + (49.981, [0.0008, 0.0017, 0.0019]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0133, 0.0084, 0.01]), + (0.7847, [0.0144, 0.0098, 0.0089]), + (1.5694, [0.0136, 0.0104, 0.0102]), + (3.1388, [0.0161, 0.0115, 0.0114]), + (4.7082, [0.021, 0.0153, 0.0158]), + (5.4929, [0.026, 0.0199, 0.0204]), + (7.0623, [0.0305, 0.0265, 0.0264]), + (7.847, [0.0352, 0.0324, 0.0323]), + (8.6317, [0.0349, 0.0336, 0.0337]), + (9.4165, [0.0399, 0.0383, 0.0379]), + (10.201, [0.0387, 0.0394, 0.0385]), + (11.771, [0.0423, 0.0441, 0.0425]), + (12.555, [0.0399, 0.0403, 0.0392]), + (13.34, [0.0398, 0.0411, 0.0399]), + (14.125, [0.0345, 0.036, 0.035]), + (14.909, [0.0327, 0.0338, 0.033]), + (15.694, [0.0265, 0.0276, 0.0272]), + (19.618, [0.0092, 0.0077, 0.0084]), + (22.756, [0.0016, 0.0, 0.0005]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0972, 0.0538, 0.0089]), + (0.7973, [0.0992, 0.0501, 0.0087]), + (5.5811, [0.0945, 0.0498, 0.0096]), + (8.7703, [0.0842, 0.0464, 0.0097]), + (14.351, [0.0729, 0.0394, 0.0106]), + (24.716, [0.0429, 0.0305, 0.0107]), + (27.108, [0.0413, 0.0273, 0.0104]), + (33.486, [0.0262, 0.0177, 0.0094]), + (39.865, [0.0145, 0.0135, 0.0091]), + (43.851, [0.0094, 0.0121, 0.0089]), + (47.838, [0.0063, 0.0114, 0.0086]), + (58.203, [0.0044, 0.01, 0.0078]), + (71.757, [0.0052, 0.0089, 0.0078]), + (92.486, [0.0013, 0.0053, 0.0041]), + (98.865, [0.0025, 0.0067, 0.0052]), + (112.42, [0.0017, 0.0042, 0.0032]), + ], + }, +]; +const PRIME35_125_STREAKS: &[Streak] = &[]; +const PRIME35_125_RAYS: &[Ray] = &[ + Ray { angle: 7.19, r0: 10.938, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0196] }, + Ray { angle: 8.29, r0: 10.156, r1: 67.188, fade: 0.05, rgb: [0.0078, 0.0157, 0.0167] }, + Ray { angle: 9.4, r0: 89.844, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 10.51, r0: 10.156, r1: 70.312, fade: 0.05, rgb: [0.0078, 0.0137, 0.0147] }, + Ray { angle: 11.64, r0: 31.25, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 17.21, r0: 19.531, r1: 205.47, fade: 0.05, rgb: [0.0353, 0.0471, 0.0431] }, + Ray { angle: 24.39, r0: 19.531, r1: 191.41, fade: 0.05, rgb: [0.0216, 0.0314, 0.0294] }, + Ray { angle: 37.13, r0: 10.938, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0137, 0.0157] }, + Ray { angle: 38.24, r0: 10.938, r1: 124.22, fade: 0.05, rgb: [0.0137, 0.0284, 0.0275] }, + Ray { angle: 38.74, r0: 10.938, r1: 152.34, fade: 0.05, rgb: [0.0157, 0.0314, 0.0304] }, + Ray { angle: 40.46, r0: 82.031, r1: 125.0, fade: 0.05, rgb: [0.0176, 0.0235, 0.0225] }, + Ray { angle: 41.8, r0: 38.281, r1: 134.38, fade: 0.05, rgb: [0.0108, 0.0196, 0.0216] }, + Ray { angle: 53.33, r0: 17.188, r1: 190.62, fade: 0.05, rgb: [0.0235, 0.0412, 0.0412] }, + Ray { angle: 67.39, r0: 14.844, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0176, 0.0176] }, + Ray { angle: 68.57, r0: 11.719, r1: 72.656, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 69.79, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0216, 0.0255, 0.0255] }, + Ray { angle: 70.95, r0: 12.5, r1: 65.625, fade: 0.05, rgb: [0.0078, 0.0176, 0.0176] }, + Ray { angle: 72.11, r0: 14.062, r1: 125.78, fade: 0.05, rgb: [0.0118, 0.0225, 0.0206] }, + Ray { angle: 72.36, r0: 32.031, r1: 121.88, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 81.91, r0: 73.438, r1: 161.72, fade: 0.05, rgb: [0.0118, 0.0157, 0.0127] }, + Ray { angle: 97.57, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0216] }, + Ray { angle: 98.67, r0: 10.156, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 99.76, r0: 93.75, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 100.84, r0: 10.156, r1: 71.094, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 102.0, r0: 34.375, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 104.36, r0: 50.0, r1: 417.19, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 110.66, r0: 15.625, r1: 212.5, fade: 0.05, rgb: [0.0373, 0.0529, 0.051] }, + Ray { angle: 118.66, r0: 17.969, r1: 250.78, fade: 0.05, rgb: [0.0294, 0.0392, 0.0392] }, + Ray { angle: 127.45, r0: 12.5, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0196, 0.0196] }, + Ray { angle: 128.46, r0: 10.938, r1: 75.0, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 129.76, r0: 51.562, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 130.78, r0: 13.281, r1: 65.625, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 132.04, r0: 39.062, r1: 125.78, fade: 0.05, rgb: [0.0137, 0.0216, 0.0196] }, + Ray { angle: 147.31, r0: 21.094, r1: 278.12, fade: 0.05, rgb: [0.0294, 0.0382, 0.0353] }, + Ray { angle: 157.35, r0: 31.25, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0235, 0.0216] }, + Ray { angle: 158.51, r0: 80.469, r1: 125.78, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 159.77, r0: 85.156, r1: 126.56, fade: 0.05, rgb: [0.0255, 0.0392, 0.0373] }, + Ray { angle: 161.69, r0: 13.281, r1: 246.09, fade: 0.05, rgb: [0.0412, 0.0608, 0.0588] }, + Ray { angle: 168.82, r0: 18.75, r1: 238.28, fade: 0.05, rgb: [0.0314, 0.0392, 0.0353] }, + Ray { angle: 183.11, r0: 25.0, r1: 234.38, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 187.57, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0216] }, + Ray { angle: 188.67, r0: 8.5938, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 189.78, r0: 90.625, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, + Ray { angle: 190.89, r0: 8.5938, r1: 66.406, fade: 0.05, rgb: [0.0078, 0.0157, 0.0176] }, + Ray { angle: 192.0, r0: 9.375, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0196] }, + Ray { angle: 217.5, r0: 26.562, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0196] }, + Ray { angle: 218.52, r0: 13.281, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 219.81, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 220.84, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0147, 0.0137] }, + Ray { angle: 222.14, r0: 11.719, r1: 127.34, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 226.08, r0: 31.25, r1: 244.53, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 240.39, r0: 20.312, r1: 224.22, fade: 0.05, rgb: [0.0196, 0.0275, 0.0235] }, + Ray { angle: 247.07, r0: 12.5, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0235, 0.0216] }, + Ray { angle: 248.21, r0: 77.344, r1: 125.78, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, + Ray { angle: 249.45, r0: 82.812, r1: 127.34, fade: 0.05, rgb: [0.0206, 0.0255, 0.0235] }, + Ray { angle: 250.53, r0: 12.5, r1: 73.438, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 251.72, r0: 45.312, r1: 127.34, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 254.49, r0: 42.188, r1: 153.91, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 269.03, r0: 13.281, r1: 195.31, fade: 0.05, rgb: [0.0333, 0.0412, 0.0412] }, + Ray { angle: 276.17, r0: 120.31, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 277.19, r0: 10.938, r1: 125.78, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 278.34, r0: 10.156, r1: 64.844, fade: 0.05, rgb: [0.0059, 0.0157, 0.0157] }, + Ray { angle: 279.45, r0: 89.844, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0225, 0.0216] }, + Ray { angle: 280.54, r0: 9.375, r1: 68.75, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 281.68, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 283.35, r0: 57.812, r1: 200.0, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 297.69, r0: 25.0, r1: 207.03, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 305.78, r0: 64.062, r1: 164.06, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 307.17, r0: 16.406, r1: 126.56, fade: 0.05, rgb: [0.0069, 0.0137, 0.0157] }, + Ray { angle: 308.24, r0: 11.719, r1: 125.78, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 309.5, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 310.59, r0: 11.719, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0127, 0.0137] }, + Ray { angle: 311.83, r0: 11.719, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0167, 0.0167] }, + Ray { angle: 337.07, r0: 24.219, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0235, 0.0216] }, + Ray { angle: 338.21, r0: 12.5, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0157, 0.0176] }, + Ray { angle: 339.49, r0: 78.906, r1: 127.34, fade: 0.05, rgb: [0.0216, 0.0255, 0.0255] }, + Ray { angle: 340.6, r0: 11.719, r1: 64.844, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 341.79, r0: 13.281, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0216] }, +]; +const PRIME35_150_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.8968, 0.6706, 0.7055]), + (0.8, [0.9194, 0.7841, 0.8374]), + (1.6, [0.9126, 0.756, 0.8061]), + (7.2, [0.9116, 0.7654, 0.8157]), + (14.4, [0.9049, 0.7674, 0.793]), + (30.4, [0.8732, 0.5997, 0.6894]), + (44.0, [0.828, 0.5033, 0.6074]), + (62.4, [0.7407, 0.37, 0.4829]), + (63.2, [0.7474, 0.3695, 0.4803]), + (67.2, [0.8341, 0.3885, 0.464]), + (68.0, [0.8246, 0.3803, 0.4579]), + (72.0, [0.6843, 0.3062, 0.4215]), + (72.8, [0.6721, 0.2995, 0.4174]), + (76.8, [0.6484, 0.2721, 0.3921]), + (87.2, [0.5851, 0.2208, 0.3384]), + (96.0, [0.5344, 0.19, 0.3074]), + (98.4, [0.5206, 0.1875, 0.2974]), + (109.6, [0.4667, 0.1694, 0.2857]), + (112.0, [0.4541, 0.1593, 0.2732]), + (115.2, [0.4362, 0.1536, 0.2703]), + (119.2, [0.4169, 0.1471, 0.2537]), + (127.2, [0.3803, 0.1397, 0.2411]), + (129.6, [0.3691, 0.138, 0.232]), + (132.0, [0.3569, 0.1314, 0.2302]), + (134.4, [0.3456, 0.1332, 0.2295]), + (140.0, [0.3198, 0.1463, 0.2401]), + (143.2, [0.3088, 0.1589, 0.2509]), + (147.2, [0.2877, 0.1777, 0.2676]), + (149.6, [0.2786, 0.1834, 0.2801]), + (155.2, [0.2524, 0.2117, 0.3044]), + (159.2, [0.2405, 0.2263, 0.3188]), + (164.8, [0.2156, 0.2363, 0.3244]), + (171.2, [0.1918, 0.2339, 0.3114]), + (173.6, [0.1798, 0.2252, 0.3064]), + (175.2, [0.1763, 0.2247, 0.2983]), + (178.4, [0.1618, 0.2215, 0.2951]), + (188.0, [0.1339, 0.2125, 0.2795]), + (192.0, [0.1244, 0.2031, 0.2672]), + (194.4, [0.1119, 0.197, 0.2583]), + (218.4, [0.0598, 0.1272, 0.1707]), + (224.0, [0.0497, 0.1168, 0.1544]), + (228.0, [0.045, 0.1126, 0.1481]), + (231.2, [0.0435, 0.1165, 0.1454]), + (234.4, [0.0394, 0.1211, 0.1484]), + (235.2, [0.0405, 0.1218, 0.149]), + (244.8, [0.036, 0.1566, 0.1734]), + (248.0, [0.0376, 0.17, 0.1848]), + (248.8, [0.036, 0.1746, 0.1871]), + (251.2, [0.0357, 0.1823, 0.192]), + (252.8, [0.0384, 0.1848, 0.1947]), + (253.6, [0.0369, 0.1881, 0.1959]), + (264.0, [0.0485, 0.2095, 0.1996]), + (264.8, [0.0465, 0.2112, 0.2016]), + (268.0, [0.0491, 0.2155, 0.2046]), + (269.6, [0.0474, 0.2196, 0.205]), + (270.4, [0.0496, 0.2205, 0.2074]), + (276.8, [0.0476, 0.2314, 0.2103]), + (280.0, [0.049, 0.2401, 0.2153]), + (280.8, [0.0466, 0.2436, 0.2171]), + (282.4, [0.0483, 0.2455, 0.218]), + (283.2, [0.0455, 0.2495, 0.2203]), + (284.8, [0.0473, 0.2525, 0.2209]), + (285.6, [0.0444, 0.2551, 0.2238]), + (286.4, [0.0454, 0.2581, 0.2227]), + (289.6, [0.0455, 0.2633, 0.2273]), + (290.4, [0.0424, 0.2616, 0.2305]), + (291.2, [0.0426, 0.2642, 0.2323]), + (294.4, [0.0422, 0.2688, 0.2317]), + (296.0, [0.0387, 0.2712, 0.2341]), + (297.6, [0.0401, 0.2743, 0.2322]), + (298.4, [0.0375, 0.2772, 0.2344]), + (300.0, [0.0405, 0.2775, 0.2336]), + (300.8, [0.0373, 0.2804, 0.2347]), + (301.6, [0.0377, 0.2819, 0.2369]), + (302.4, [0.0394, 0.2795, 0.2357]), + (304.0, [0.0359, 0.2816, 0.2347]), + (304.8, [0.0379, 0.2791, 0.2352]), + (306.4, [0.0341, 0.2764, 0.2306]), + (307.2, [0.0359, 0.2766, 0.2313]), + (308.8, [0.0328, 0.2706, 0.2262]), + (310.4, [0.0364, 0.2725, 0.2286]), + (311.2, [0.0347, 0.2694, 0.2263]), + (312.8, [0.0417, 0.274, 0.2334]), + (314.4, [0.0434, 0.2764, 0.2357]), + (315.2, [0.0474, 0.2802, 0.2408]), + (316.8, [0.0491, 0.2891, 0.2466]), + (318.4, [0.0562, 0.2934, 0.2534]), + (319.2, [0.055, 0.2981, 0.256]), + (320.8, [0.0623, 0.3016, 0.2576]), + (321.6, [0.0614, 0.3063, 0.2612]), + (323.2, [0.0663, 0.315, 0.2613]), + (324.8, [0.0657, 0.3176, 0.267]), + (326.4, [0.0697, 0.3231, 0.2655]), + (328.0, [0.0659, 0.3233, 0.2685]), + (329.6, [0.066, 0.3273, 0.2662]), + (330.4, [0.0612, 0.3239, 0.2672]), + (332.0, [0.0602, 0.3259, 0.2624]), + (332.8, [0.0545, 0.3272, 0.2629]), + (335.2, [0.0488, 0.3227, 0.2571]), + (336.0, [0.0426, 0.3234, 0.2572]), + (337.6, [0.0406, 0.3178, 0.2533]), + (339.2, [0.0334, 0.3185, 0.2522]), + (340.8, [0.0335, 0.3136, 0.2491]), + (341.6, [0.0292, 0.3143, 0.2486]), + (344.0, [0.0299, 0.3114, 0.2493]), + (344.8, [0.0264, 0.3123, 0.2479]), + (346.4, [0.029, 0.3149, 0.2483]), + (348.0, [0.0257, 0.3108, 0.2422]), + (349.6, [0.0289, 0.3116, 0.2429]), + (351.2, [0.0258, 0.3066, 0.237]), + (352.8, [0.0285, 0.3072, 0.2378]), + (353.6, [0.025, 0.3078, 0.2354]), + (356.0, [0.0277, 0.303, 0.233]), + (356.8, [0.0239, 0.3032, 0.2332]), + (359.2, [0.0261, 0.2986, 0.2288]), + (360.0, [0.0223, 0.2988, 0.2295]), + (362.4, [0.0249, 0.2942, 0.2252]), + (363.2, [0.0208, 0.2945, 0.2261]), + (365.6, [0.0228, 0.2902, 0.222]), + (366.4, [0.0188, 0.29, 0.2224]), + (368.8, [0.0211, 0.2908, 0.2187]), + (369.6, [0.0172, 0.2861, 0.2193]), + (372.0, [0.019, 0.2846, 0.2155]), + (372.8, [0.0156, 0.2816, 0.2164]), + (374.4, [0.0153, 0.2771, 0.2177]), + (375.2, [0.0165, 0.2762, 0.2134]), + (376.8, [0.0122, 0.2745, 0.2109]), + (379.2, [0.0137, 0.2665, 0.2019]), + (380.0, [0.0098, 0.2656, 0.2011]), + (381.6, [0.0098, 0.258, 0.199]), + (382.4, [0.0097, 0.2569, 0.1928]), + (383.2, [0.0048, 0.2558, 0.1913]), + (385.6, [0.0046, 0.2475, 0.1882]), + (387.2, [-0.0011, 0.2457, 0.1812]), + (389.6, [-0.0013, 0.2384, 0.1765]), + (390.4, [-0.0064, 0.237, 0.1725]), + (392.8, [-0.0071, 0.2348, 0.171]), + (394.4, [-0.0129, 0.2274, 0.1637]), + (396.8, [-0.0125, 0.2256, 0.1625]), + (397.6, [-0.0164, 0.2254, 0.1596]), + (398.4, [-0.0183, 0.2198, 0.1553]), + (400.8, [-0.0177, 0.2151, 0.1543]), + (401.6, [-0.0225, 0.2144, 0.1526]), + (402.4, [-0.0226, 0.2098, 0.1477]), + (404.8, [-0.0226, 0.2026, 0.147]), + (405.6, [-0.0271, 0.2011, 0.1459]), + (406.4, [-0.0269, 0.1996, 0.1408]), + (408.8, [-0.0277, 0.1891, 0.1403]), + (409.6, [-0.032, 0.1873, 0.1407]), + (412.8, [-0.0325, 0.1751, 0.1295]), + (413.6, [-0.0372, 0.1734, 0.1277]), + (416.0, [-0.0382, 0.1685, 0.1164]), + (416.8, [-0.0387, 0.162, 0.1143]), + (417.6, [-0.044, 0.1599, 0.1123]), + (420.8, [-0.0461, 0.1543, 0.0993]), + (421.6, [-0.0514, 0.148, 0.0974]), + (423.2, [-0.0531, 0.1444, 0.0939]), + (424.0, [-0.0538, 0.1431, 0.0871]), + (424.8, [-0.0538, 0.1418, 0.0848]), + (426.4, [-0.0606, 0.1372, 0.0816]), + (428.0, [-0.0614, 0.1314, 0.0781]), + (428.8, [-0.0619, 0.1297, 0.0717]), + (429.6, [-0.0619, 0.128, 0.0703]), + (431.2, [-0.0684, 0.1243, 0.0676]), + (432.8, [-0.0688, 0.1159, 0.0652]), + (433.6, [-0.0685, 0.1136, 0.0592]), + (435.2, [-0.0743, 0.1092, 0.0567]), + (436.0, [-0.074, 0.1077, 0.0558]), + (437.6, [-0.0737, 0.0975, 0.0542]), + (438.4, [-0.0729, 0.0959, 0.0487]), + (439.2, [-0.0746, 0.0938, 0.047]), + (440.0, [-0.0779, 0.0919, 0.0463]), + (442.4, [-0.0762, 0.0851, 0.044]), + (443.2, [-0.075, 0.0786, 0.0399]), + (444.0, [-0.0778, 0.077, 0.0359]), + (444.8, [-0.0804, 0.0751, 0.0354]), + (448.0, [-0.0775, 0.067, 0.0326]), + (448.8, [-0.0778, 0.0603, 0.0278]), + (449.6, [-0.082, 0.0585, 0.024]), + (453.6, [-0.0777, 0.0511, 0.012]), + (454.4, [-0.082, 0.0445, 0.0044]), + (455.2, [-0.0819, 0.0421, 0.0008]), + (459.2, [-0.0769, 0.0356, -0.0123]), + (460.0, [-0.0809, 0.0329, -0.0201]), + (460.8, [-0.0793, 0.0262, -0.0249]), + (464.8, [-0.0727, 0.0192, -0.0405]), + (465.6, [-0.0761, 0.0181, -0.0469]), + (466.4, [-0.0737, 0.0157, -0.0562]), + (467.2, [-0.0712, 0.0065, -0.0603]), + (470.4, [-0.0621, -0.0183, -0.0775]), + (471.2, [-0.0668, -0.0237, -0.0806]), + (472.8, [-0.0682, -0.0347, -0.0972]), + (473.6, [-0.0688, -0.0414, -0.1009]), + (474.4, [-0.0691, -0.0535, -0.1044]), + (476.0, [-0.0691, -0.0643, -0.1115]), + (477.6, [-0.079, -0.0738, -0.1182]), + (479.2, [-0.0795, -0.0833, -0.1341]), + (480.0, [-0.0796, -0.0872, -0.1375]), + (481.6, [-0.0802, -0.1056, -0.1445]), + (482.4, [-0.0804, -0.1103, -0.1479]), + (483.2, [-0.0885, -0.1151, -0.1515]), + (484.0, [-0.0909, -0.1194, -0.1547]), + (484.8, [-0.092, -0.124, -0.1577]), + (486.4, [-0.0939, -0.133, -0.1737]), + (488.0, [-0.0957, -0.1411, -0.1802]), + (488.8, [-0.0959, -0.1524, -0.1832]), + (489.6, [-0.0996, -0.1582, -0.1864]), + (490.4, [-0.1064, -0.1621, -0.1893]), + (492.0, [-0.107, -0.1693, -0.1945]), + (493.6, [-0.106, -0.1756, -0.2088]), + (496.0, [-0.1035, -0.1837, -0.2158]), + (497.6, [-0.1111, -0.1981, -0.2201]), + (500.0, [-0.1085, -0.2054, -0.2255]), + (501.6, [-0.1072, -0.2099, -0.2387]), + (504.0, [-0.1049, -0.2158, -0.2436]), + (504.8, [-0.1117, -0.217, -0.2452]), + (506.4, [-0.1104, -0.2301, -0.2477]), + (508.8, [-0.1069, -0.2351, -0.2516]), + (509.6, [-0.1057, -0.2368, -0.2604]), + (512.0, [-0.1016, -0.2414, -0.27]), + (513.6, [-0.1086, -0.2445, -0.2749]), + (515.2, [-0.106, -0.2471, -0.2795]), + (516.8, [-0.1035, -0.2602, -0.2842]), + (518.4, [-0.1013, -0.2655, -0.2886]), + (520.0, [-0.0996, -0.2709, -0.3022]), + (521.6, [-0.0991, -0.2746, -0.3054]), + (522.4, [-0.1044, -0.2761, -0.3067]), + (526.4, [-0.0966, -0.2793, -0.3103]), + (528.8, [-0.091, -0.2906, -0.3103]), + (531.2, [-0.085, -0.2897, -0.3206]), + (532.8, [-0.0907, -0.2903, -0.3214]), + (539.2, [-0.0783, -0.2944, -0.3258]), + (541.6, [-0.0737, -0.3059, -0.3269]), + (544.0, [-0.0698, -0.3075, -0.339]), + (544.8, [-0.0768, -0.3081, -0.3394]), + (545.6, [-0.0765, -0.3087, -0.3399]), + (556.8, [-0.0572, -0.3154, -0.346]), + (558.4, [-0.0547, -0.3264, -0.3483]), + (559.2, [-0.0529, -0.3268, -0.3566]), + (560.8, [-0.0617, -0.3276, -0.3587]), + (573.6, [-0.0433, -0.3331, -0.3645]), + (591.2, [-0.0222, -0.3397, -0.371]), + (598.4, [-0.0107, -0.3355, -0.3687]), + (600.0, [-0.0052, -0.3229, -0.356]), + (601.6, [0.0011, -0.3008, -0.3317]), + (604.0, [0.0098, -0.2485, -0.2719]), + (606.4, [0.0119, -0.1787, -0.1908]), + (609.6, [0.0063, -0.0851, -0.0794]), + (611.2, [0.0055, -0.0457, -0.0314]), + (612.0, [0.0062, -0.0279, -0.0102]), + (612.8, [0.0077, -0.0111, 0.0094]), + (614.4, [0.0121, 0.0194, 0.0441]), + (616.0, [0.0157, 0.0451, 0.0694]), + (616.8, [0.016, 0.0548, 0.0754]), + (617.6, [0.015, 0.0612, 0.0754]), + (618.4, [0.0126, 0.0628, 0.0692]), + (619.2, [0.0091, 0.0591, 0.0576]), + (620.0, [0.0049, 0.0503, 0.0424]), + (620.8, [0.0006, 0.0379, 0.0259]), + (622.4, [-0.0064, 0.0101, -0.0034]), + (623.2, [-0.0086, -0.0014, -0.0137]), + (624.0, [-0.0098, -0.0094, -0.0207]), + (625.6, [-0.01, -0.0164, -0.0272]), + (627.2, [-0.008, -0.0173, -0.0269]), + (628.8, [-0.0044, -0.0161, -0.0214]), + (630.4, [-0.0003, -0.0104, -0.0105]), + (632.0, [0.0029, -0.0008, 0.0017]), + (633.6, [0.0041, 0.0053, 0.0082]), + (634.4, [0.004, 0.0057, 0.0092]), + (637.6, [0.0025, 0.0037, 0.0103]), + (641.6, [0.0022, 0.0025, 0.0049]), + (644.8, [-0.0026, -0.0024, 0.0001]), + (646.4, [-0.0039, -0.0029, -0.001]), + (649.6, [0.0015, -0.0007, -0.0014]), + (651.2, [0.0022, 0.0016, -0.0004]), + (652.8, [-0.0008, 0.0011, -0.0023]), + (654.4, [-0.0035, -0.0021, -0.0042]), + (657.6, [-0.0014, -0.0011, -0.0033]), + (661.6, [0.0007, 0.0008, 0.002]), + (670.4, [0.0014, 0.0008, 0.0018]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.0775, 0.282, 0.2368]), + (0.8, [0.0881, 0.2347, 0.1824]), + (1.6, [0.085, 0.2383, 0.1892]), + (7.2, [0.0884, 0.2346, 0.1844]), + (14.4, [0.0925, 0.2294, 0.1893]), + (31.2, [0.1047, 0.2831, 0.2187]), + (44.8, [0.1195, 0.3069, 0.2392]), + (62.4, [0.1488, 0.3442, 0.2742]), + (64.0, [0.1402, 0.3428, 0.2754]), + (66.4, [0.1165, 0.3356, 0.2793]), + (67.2, [0.1129, 0.3348, 0.2789]), + (68.0, [0.1161, 0.3372, 0.2811]), + (72.0, [0.1684, 0.3629, 0.2925]), + (77.6, [0.1814, 0.3727, 0.3009]), + (97.6, [0.2185, 0.3841, 0.3181]), + (107.2, [0.2293, 0.3759, 0.3034]), + (120.0, [0.2469, 0.3709, 0.3083]), + (132.0, [0.2592, 0.3577, 0.2996]), + (136.0, [0.2626, 0.3456, 0.2898]), + (140.8, [0.2673, 0.3223, 0.2681]), + (147.2, [0.2744, 0.2823, 0.2299]), + (156.8, [0.2819, 0.2183, 0.1616]), + (160.8, [0.2857, 0.1981, 0.1417]), + (164.8, [0.2897, 0.1835, 0.1314]), + (171.2, [0.2942, 0.1733, 0.1297]), + (175.2, [0.2985, 0.1713, 0.1312]), + (188.0, [0.3053, 0.1597, 0.1239]), + (196.8, [0.3094, 0.1653, 0.1361]), + (220.8, [0.3127, 0.1921, 0.1772]), + (228.8, [0.3072, 0.186, 0.1788]), + (233.6, [0.3026, 0.1724, 0.1708]), + (237.6, [0.2968, 0.1575, 0.1583]), + (248.0, [0.2796, 0.0997, 0.1087]), + (252.8, [0.2704, 0.077, 0.0905]), + (264.0, [0.2409, 0.0361, 0.0657]), + (274.4, [0.2223, 0.0038, 0.0415]), + (279.2, [0.2152, -0.0133, 0.03]), + (284.8, [0.2074, -0.0363, 0.0147]), + (292.0, [0.1995, -0.0584, -0.0073]), + (301.6, [0.19, -0.0859, -0.0243]), + (304.8, [0.1865, -0.0893, -0.0273]), + (309.6, [0.1816, -0.0866, -0.0271]), + (312.0, [0.1752, -0.0899, -0.032]), + (315.2, [0.162, -0.1021, -0.0467]), + (316.8, [0.155, -0.1113, -0.0568]), + (319.2, [0.1451, -0.1237, -0.0671]), + (324.0, [0.1283, -0.149, -0.0826]), + (327.2, [0.1221, -0.1602, -0.0903]), + (330.4, [0.1218, -0.1673, -0.0941]), + (333.6, [0.1276, -0.1703, -0.0942]), + (340.0, [0.1394, -0.1694, -0.0917]), + (342.4, [0.1401, -0.1694, -0.0923]), + (344.8, [0.1375, -0.1704, -0.0953]), + (358.4, [0.1243, -0.1739, -0.0907]), + (369.6, [0.1159, -0.1735, -0.0934]), + (375.2, [0.1136, -0.1666, -0.0966]), + (380.0, [0.1106, -0.16, -0.0876]), + (395.2, [0.1188, -0.1369, -0.0654]), + (398.4, [0.1196, -0.1347, -0.0626]), + (407.2, [0.121, -0.1163, -0.0543]), + (410.4, [0.1221, -0.1078, -0.0539]), + (424.8, [0.1323, -0.0757, -0.015]), + (429.6, [0.1365, -0.066, -0.0041]), + (436.0, [0.1409, -0.0494, 0.0066]), + (449.6, [0.1357, -0.0123, 0.0251]), + (460.0, [0.1268, 0.0083, 0.0605]), + (461.6, [0.1261, 0.0134, 0.0687]), + (465.6, [0.1181, 0.0203, 0.0847]), + (467.2, [0.1167, 0.0231, 0.0958]), + (471.2, [0.1037, 0.0543, 0.1171]), + (474.4, [0.1081, 0.0766, 0.1368]), + (475.2, [0.1084, 0.0854, 0.1401]), + (480.8, [0.115, 0.1195, 0.1693]), + (483.2, [0.1158, 0.1384, 0.1797]), + (484.8, [0.1224, 0.1474, 0.1864]), + (489.6, [0.1277, 0.1769, 0.2111]), + (491.2, [0.1337, 0.1861, 0.2172]), + (494.4, [0.1334, 0.1995, 0.2324]), + (499.2, [0.1338, 0.2205, 0.2458]), + (503.2, [0.1298, 0.2317, 0.2603]), + (504.8, [0.1279, 0.2356, 0.2634]), + (506.4, [0.1311, 0.2415, 0.2663]), + (509.6, [0.1264, 0.251, 0.271]), + (512.8, [0.1213, 0.2573, 0.2859]), + (514.4, [0.124, 0.2603, 0.2908]), + (516.0, [0.1215, 0.2633, 0.2954]), + (521.6, [0.1138, 0.2848, 0.3158]), + (524.0, [0.1148, 0.2893, 0.3197]), + (532.0, [0.0968, 0.2976, 0.3286]), + (534.4, [0.0972, 0.2988, 0.3298]), + (540.8, [0.0845, 0.3034, 0.3342]), + (542.4, [0.0814, 0.3099, 0.335]), + (544.8, [0.077, 0.3115, 0.343]), + (545.6, [0.0806, 0.3121, 0.3434]), + (546.4, [0.0803, 0.3127, 0.3439]), + (557.6, [0.061, 0.3193, 0.35]), + (559.2, [0.0585, 0.3264, 0.3515]), + (560.0, [0.0569, 0.3269, 0.3565]), + (561.6, [0.0613, 0.3276, 0.3588]), + (562.4, [0.0606, 0.328, 0.3592]), + (597.6, [0.0143, 0.34, 0.3723]), + (600.8, [0.0055, 0.3248, 0.358]), + (603.2, [-0.004, 0.2894, 0.3189]), + (605.6, [-0.0115, 0.2316, 0.2522]), + (607.2, [-0.0123, 0.184, 0.1971]), + (609.6, [-0.0082, 0.1119, 0.1119]), + (612.0, [-0.0057, 0.0498, 0.0363]), + (613.6, [-0.0076, 0.0147, -0.0052]), + (615.2, [-0.0118, -0.0162, -0.0405]), + (616.8, [-0.0158, -0.0425, -0.0673]), + (617.6, [-0.0163, -0.0529, -0.0744]), + (618.4, [-0.0156, -0.06, -0.0758]), + (619.2, [-0.0135, -0.0628, -0.0709]), + (620.0, [-0.0102, -0.0602, -0.0603]), + (620.8, [-0.0061, -0.0525, -0.0458]), + (621.6, [-0.0018, -0.0407, -0.0293]), + (623.2, [0.0055, -0.0128, 0.0008]), + (624.0, [0.0079, -0.0007, 0.0118]), + (624.8, [0.0094, 0.0081, 0.0195]), + (625.6, [0.0099, 0.0135, 0.0243]), + (626.4, [0.0098, 0.0162, 0.0269]), + (628.8, [0.0065, 0.0172, 0.0255]), + (631.2, [0.0005, 0.0113, 0.012]), + (633.6, [-0.0038, -0.0023, -0.005]), + (634.4, [-0.0043, -0.0049, -0.0077]), + (636.0, [-0.0039, -0.005, -0.0095]), + (638.4, [-0.0027, -0.0036, -0.0102]), + (642.4, [-0.0025, -0.0025, -0.005]), + (647.2, [0.0038, 0.003, 0.001]), + (649.6, [0.0005, 0.0016, 0.0021]), + (652.0, [-0.0026, -0.0013, 0.0005]), + (653.6, [0.0002, -0.0013, 0.0021]), + (656.0, [0.0035, 0.0029, 0.0044]), + (659.2, [0.0006, 0.0006, 0.0028]), + (662.4, [-0.0008, -0.0008, -0.0019]), + (671.2, [-0.0014, -0.0008, -0.0019]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.1471, 0.1581, 0.074]), + (0.79906, [0.1409, 0.1551, 0.0745]), + (3.9953, [0.1325, 0.1545, 0.0776]), + (16.78, [0.111, 0.1397, 0.0777]), + (23.972, [0.0987, 0.1409, 0.0783]), + (25.57, [0.0952, 0.1377, 0.0789]), + (30.364, [0.0883, 0.1349, 0.0805]), + (37.556, [0.0835, 0.1317, 0.0822]), + (47.944, [0.0631, 0.1219, 0.0786]), + (58.331, [0.0524, 0.1231, 0.0803]), + (64.724, [0.0511, 0.1239, 0.0849]), + (66.322, [0.0475, 0.1244, 0.0853]), + (75.112, [0.044, 0.1208, 0.0848]), + (76.71, [0.0407, 0.1212, 0.0852]), + (81.504, [0.0417, 0.1236, 0.0871]), + (83.102, [0.038, 0.1205, 0.0877]), + (89.495, [0.039, 0.1224, 0.0894]), + (91.892, [0.0357, 0.123, 0.09]), + (111.87, [0.0391, 0.1293, 0.0946]), + (119.06, [0.092, 0.1419, 0.1032]), + (119.86, [0.093, 0.1419, 0.1033]), + (123.85, [0.0662, 0.1359, 0.1009]), + (127.05, [0.0414, 0.133, 0.0975]), + (146.23, [0.0433, 0.1404, 0.1037]), + (147.03, [0.0437, 0.1385, 0.1024]), + (147.83, [0.0414, 0.1309, 0.0977]), + (155.02, [0.0155, 0.048, 0.0358]), + (158.21, [0.0019, 0.0098, 0.0071]), + (159.01, [0.0004, 0.0011, 0.0004]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0038, 0.1134, 0.127]), + (0.79639, [0.0, 0.1391, 0.1646]), + (1.5928, [-0.0019, 0.1334, 0.1553]), + (7.1675, [-0.0014, 0.135, 0.1559]), + (12.742, [-0.0016, 0.1247, 0.1437]), + (18.317, [-0.0014, 0.1071, 0.1241]), + (22.299, [-0.0015, 0.0886, 0.1017]), + (25.484, [-0.0015, 0.0677, 0.0801]), + (29.466, [-0.0017, 0.0407, 0.0484]), + (32.652, [-0.0015, 0.0243, 0.029]), + (35.837, [-0.0019, 0.013, 0.0164]), + (38.227, [-0.0016, 0.0077, 0.0098]), + (43.005, [-0.0018, 0.0015, 0.0026]), + (46.19, [-0.0021, 0.0006, 0.0017]), + (58.933, [-0.0018, 0.0031, 0.0041]), + (83.621, [-0.0016, -0.0037, -0.0032]), + (103.53, [-0.0003, -0.0017, -0.0012]), + (122.64, [-0.0007, -0.0023, -0.002]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0169, 0.032, 0.1134]), + (0.79334, [0.0156, 0.0249, 0.1021]), + (1.5867, [0.0158, 0.0262, 0.1078]), + (2.38, [0.0166, 0.0269, 0.1045]), + (3.1734, [0.0153, 0.0262, 0.1064]), + (14.28, [0.0187, 0.027, 0.1062]), + (15.867, [0.0206, 0.0294, 0.1067]), + (17.454, [0.0169, 0.0251, 0.0936]), + (21.42, [0.0147, 0.0219, 0.0803]), + (28.56, [0.0113, 0.0169, 0.0633]), + (30.94, [0.0096, 0.0141, 0.0536]), + (38.874, [0.0018, 0.0052, 0.0095]), + (41.254, [0.0004, 0.0027, 0.0026]), + (63.468, [0.0003, 0.0018, 0.0016]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0117, 0.0111, 0.0102]), + (0.7847, [0.0112, 0.0079, 0.0087]), + (3.1388, [0.0154, 0.0109, 0.0139]), + (4.7082, [0.021, 0.0154, 0.0179]), + (5.4929, [0.0263, 0.0199, 0.0232]), + (6.2776, [0.0275, 0.0238, 0.0269]), + (9.4165, [0.0429, 0.0412, 0.0436]), + (10.201, [0.0426, 0.043, 0.0443]), + (11.771, [0.0473, 0.0496, 0.0506]), + (12.555, [0.045, 0.046, 0.0472]), + (13.34, [0.0451, 0.0471, 0.0477]), + (14.125, [0.0385, 0.0405, 0.0414]), + (14.909, [0.0363, 0.0376, 0.0386]), + (15.694, [0.0285, 0.0299, 0.0318]), + (18.048, [0.015, 0.0152, 0.0172]), + (19.618, [0.0075, 0.0074, 0.009]), + (21.187, [0.0022, 0.0018, 0.0038]), + (24.326, [-0.0025, -0.0026, -0.0013]), + (29.819, [-0.0019, -0.0008, -0.0004]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.1197, 0.0826, 0.0247]), + (0.7973, [0.1175, 0.073, 0.0192]), + (3.1892, [0.1165, 0.0728, 0.0205]), + (6.3784, [0.1093, 0.0701, 0.0213]), + (15.946, [0.0832, 0.0602, 0.0234]), + (19.932, [0.0689, 0.0542, 0.0238]), + (25.513, [0.058, 0.0454, 0.0238]), + (29.5, [0.045, 0.0438, 0.0231]), + (32.689, [0.037, 0.0387, 0.0223]), + (37.473, [0.0289, 0.032, 0.0214]), + (44.649, [0.0165, 0.0273, 0.0211]), + (49.432, [0.0122, 0.0258, 0.0205]), + (57.405, [0.0099, 0.0236, 0.0192]), + (71.757, [0.0105, 0.0211, 0.0177]), + (90.094, [0.0057, 0.0149, 0.0118]), + (99.662, [0.0068, 0.0151, 0.0117]), + (112.42, [0.0047, 0.0102, 0.0085]), + ], + }, +]; +const PRIME35_150_STREAKS: &[Streak] = &[]; +const PRIME35_150_RAYS: &[Ray] = &[ + Ray { angle: 7.1, r0: 50.0, r1: 139.84, fade: 0.05, rgb: [0.0088, 0.0157, 0.0157] }, + Ray { angle: 8.42, r0: 75.0, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 9.44, r0: 92.969, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 10.47, r0: 91.406, r1: 139.06, fade: 0.05, rgb: [0.0186, 0.0314, 0.0275] }, + Ray { angle: 11.74, r0: 46.094, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 17.32, r0: 28.125, r1: 277.34, fade: 0.05, rgb: [0.0284, 0.0412, 0.0353] }, + Ray { angle: 24.5, r0: 29.688, r1: 260.16, fade: 0.05, rgb: [0.0196, 0.0294, 0.0235] }, + Ray { angle: 37.17, r0: 71.094, r1: 138.28, fade: 0.05, rgb: [0.0118, 0.0255, 0.0176] }, + Ray { angle: 38.55, r0: 16.406, r1: 139.06, fade: 0.05, rgb: [0.0078, 0.0216, 0.0176] }, + Ray { angle: 38.93, r0: 17.188, r1: 213.28, fade: 0.05, rgb: [0.0137, 0.0275, 0.0235] }, + Ray { angle: 40.54, r0: 82.031, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0314, 0.0265] }, + Ray { angle: 41.83, r0: 48.438, r1: 137.5, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 53.28, r0: 28.125, r1: 260.94, fade: 0.05, rgb: [0.0216, 0.0353, 0.0353] }, + Ray { angle: 67.46, r0: 46.875, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 68.72, r0: 88.281, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0225, 0.0176] }, + Ray { angle: 69.77, r0: 93.75, r1: 139.84, fade: 0.05, rgb: [0.0157, 0.0235, 0.0225] }, + Ray { angle: 70.9, r0: 85.156, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 71.94, r0: 30.469, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 82.03, r0: 77.344, r1: 225.0, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 97.43, r0: 47.656, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 98.72, r0: 65.625, r1: 141.41, fade: 0.05, rgb: [0.0098, 0.0137, 0.0127] }, + Ray { angle: 99.75, r0: 100.0, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0186, 0.0157] }, + Ray { angle: 100.77, r0: 88.281, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 102.02, r0: 52.344, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0196, 0.0157] }, + Ray { angle: 104.36, r0: 52.344, r1: 417.19, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 110.66, r0: 25.0, r1: 285.94, fade: 0.05, rgb: [0.0314, 0.0451, 0.0412] }, + Ray { angle: 118.76, r0: 27.344, r1: 267.97, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 127.5, r0: 60.156, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0196, 0.0176] }, + Ray { angle: 128.7, r0: 97.656, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 129.58, r0: 95.312, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 130.73, r0: 81.25, r1: 139.06, fade: 0.05, rgb: [0.0196, 0.0294, 0.0275] }, + Ray { angle: 132.16, r0: 62.5, r1: 139.06, fade: 0.05, rgb: [0.0137, 0.0275, 0.0235] }, + Ray { angle: 147.38, r0: 34.375, r1: 278.12, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 157.39, r0: 44.531, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0147, 0.0137] }, + Ray { angle: 158.71, r0: 71.094, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 159.75, r0: 91.406, r1: 139.06, fade: 0.05, rgb: [0.0118, 0.0167, 0.0167] }, + Ray { angle: 161.69, r0: 17.969, r1: 246.09, fade: 0.05, rgb: [0.0275, 0.0471, 0.0431] }, + Ray { angle: 168.82, r0: 32.812, r1: 238.28, fade: 0.05, rgb: [0.0235, 0.0314, 0.0275] }, + Ray { angle: 183.11, r0: 45.312, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 187.45, r0: 47.656, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 188.74, r0: 85.938, r1: 141.41, fade: 0.05, rgb: [0.0127, 0.0206, 0.0176] }, + Ray { angle: 189.76, r0: 103.91, r1: 139.84, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 190.78, r0: 88.281, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0206, 0.0176] }, + Ray { angle: 192.07, r0: 39.844, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 217.53, r0: 48.438, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0147] }, + Ray { angle: 218.74, r0: 22.656, r1: 77.344, fade: 0.05, rgb: [0.002, 0.0078, 0.0078] }, + Ray { angle: 219.61, r0: 73.438, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 220.84, r0: 70.312, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 222.21, r0: 42.969, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 225.99, r0: 36.719, r1: 262.5, fade: 0.05, rgb: [0.0137, 0.0225, 0.0196] }, + Ray { angle: 240.32, r0: 35.156, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 247.11, r0: 33.594, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0176, 0.0147] }, + Ray { angle: 247.37, r0: 24.219, r1: 137.5, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 248.42, r0: 82.812, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0186, 0.0157] }, + Ray { angle: 249.45, r0: 96.875, r1: 139.84, fade: 0.05, rgb: [0.0216, 0.0324, 0.0275] }, + Ray { angle: 250.54, r0: 95.312, r1: 141.41, fade: 0.05, rgb: [0.0235, 0.0343, 0.0294] }, + Ray { angle: 251.59, r0: 42.969, r1: 141.41, fade: 0.05, rgb: [0.0078, 0.0176, 0.0157] }, + Ray { angle: 254.7, r0: 43.75, r1: 152.34, fade: 0.05, rgb: [0.0098, 0.0196, 0.0157] }, + Ray { angle: 269.04, r0: 21.094, r1: 195.31, fade: 0.05, rgb: [0.0235, 0.0314, 0.0284] }, + Ray { angle: 276.11, r0: 127.34, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 277.13, r0: 42.188, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 278.42, r0: 83.594, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 279.46, r0: 100.78, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0196, 0.0157] }, + Ray { angle: 280.47, r0: 90.625, r1: 139.06, fade: 0.05, rgb: [0.0176, 0.0235, 0.0196] }, + Ray { angle: 281.75, r0: 73.438, r1: 139.84, fade: 0.05, rgb: [0.0206, 0.0324, 0.0294] }, + Ray { angle: 283.38, r0: 60.938, r1: 200.0, fade: 0.05, rgb: [0.0196, 0.0294, 0.0275] }, + Ray { angle: 297.53, r0: 72.656, r1: 216.41, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 305.74, r0: 65.625, r1: 228.91, fade: 0.05, rgb: [0.0176, 0.0265, 0.0235] }, + Ray { angle: 307.34, r0: 71.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 308.48, r0: 94.531, r1: 140.62, fade: 0.05, rgb: [0.0127, 0.0176, 0.0167] }, + Ray { angle: 309.42, r0: 95.312, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 310.59, r0: 76.562, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 311.95, r0: 48.438, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0196, 0.0157] }, + Ray { angle: 337.12, r0: 47.656, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 338.44, r0: 85.156, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 339.49, r0: 96.875, r1: 139.84, fade: 0.05, rgb: [0.0216, 0.0333, 0.0284] }, + Ray { angle: 340.57, r0: 89.062, r1: 141.41, fade: 0.05, rgb: [0.0216, 0.0314, 0.0294] }, + Ray { angle: 341.68, r0: 45.312, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, +]; +const PRIME35_200_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.7154, 0.8569, 0.7967]), + (0.8, [0.7338, 0.8943, 0.8334]), + (1.6, [0.7292, 0.8846, 0.8226]), + (62.4, [0.7382, 0.9051, 0.8391]), + (100.8, [0.7537, 0.9389, 0.8685]), + (184.0, [0.8203, 1.086, 0.9897]), + (216.8, [0.8395, 1.112, 1.02]), + (223.2, [0.8488, 1.103, 1.04]), + (236.0, [0.8995, 1.202, 1.158]), + (249.6, [0.9794, 1.377, 1.293]), + (260.0, [1.073, 1.524, 1.395]), + (276.0, [1.114, 1.721, 1.534]), + (290.4, [1.119, 1.842, 1.613]), + (321.6, [1.108, 1.983, 1.706]), + (331.2, [1.095, 2.003, 1.708]), + (351.2, [1.022, 1.941, 1.633]), + (366.4, [0.9518, 1.845, 1.546]), + (388.0, [0.814, 1.621, 1.354]), + (413.6, [0.6226, 1.264, 1.057]), + (438.4, [0.407, 0.8514, 0.7121]), + (448.0, [0.3122, 0.6636, 0.5554]), + (457.6, [0.212, 0.4414, 0.3756]), + (460.0, [0.1878, 0.3733, 0.3243]), + (463.2, [0.1483, 0.2808, 0.2404]), + (465.6, [0.1187, 0.1927, 0.1711]), + (466.4, [0.1035, 0.1533, 0.1409]), + (467.2, [0.0885, 0.1188, 0.1159]), + (468.0, [0.0674, 0.076, 0.0831]), + (468.8, [0.052, 0.0383, 0.0557]), + (469.6, [0.0307, 0.0022, 0.0251]), + (470.4, [0.0146, -0.0416, -0.008]), + (471.2, [-0.0064, -0.0757, -0.0429]), + (472.0, [-0.0232, -0.1194, -0.0791]), + (472.8, [-0.0424, -0.1549, -0.1089]), + (473.6, [-0.0605, -0.1916, -0.1471]), + (474.4, [-0.0801, -0.233, -0.1765]), + (477.6, [-0.1524, -0.3986, -0.3124]), + (482.4, [-0.2722, -0.6528, -0.5018]), + (484.8, [-0.3368, -0.7803, -0.6111]), + (492.8, [-0.5721, -1.221, -0.9667]), + (496.8, [-0.6695, -1.437, -1.143]), + (520.8, [-1.138, -2.711, -2.172]), + (525.6, [-1.218, -2.905, -2.33]), + (538.4, [-1.343, -3.177, -2.56]), + (567.2, [-1.594, -3.676, -2.969]), + (596.8, [-1.773, -4.012, -3.261]), + (599.2, [-1.754, -3.951, -3.217]), + (600.8, [-1.698, -3.816, -3.118]), + (602.4, [-1.607, -3.604, -2.961]), + (604.0, [-1.482, -3.317, -2.735]), + (605.6, [-1.315, -2.961, -2.455]), + (608.0, [-1.007, -2.324, -1.937]), + (612.0, [-0.4248, -1.106, -0.9541]), + (613.6, [-0.2004, -0.6281, -0.5549]), + (614.4, [-0.0926, -0.3993, -0.3655]), + (615.2, [0.0061, -0.186, -0.1882]), + (616.0, [0.0895, 0.013, -0.0177]), + (616.8, [0.1631, 0.1932, 0.1339]), + (617.6, [0.2257, 0.3522, 0.2665]), + (618.4, [0.2749, 0.4814, 0.378]), + (619.2, [0.3157, 0.5899, 0.4733]), + (620.0, [0.3411, 0.6701, 0.5437]), + (620.8, [0.3535, 0.7192, 0.5886]), + (621.6, [0.3537, 0.7344, 0.6058]), + (622.4, [0.3391, 0.7145, 0.594]), + (623.2, [0.3171, 0.6691, 0.5626]), + (624.0, [0.2842, 0.5962, 0.509]), + (624.8, [0.2426, 0.4999, 0.4361]), + (628.0, [0.0402, 0.0324, 0.0784]), + (628.8, [-0.0043, -0.0727, -0.0089]), + (629.6, [-0.0406, -0.1614, -0.0717]), + (630.4, [-0.0667, -0.2229, -0.1167]), + (631.2, [-0.0818, -0.2621, -0.1426]), + (632.0, [-0.088, -0.2752, -0.1491]), + (632.8, [-0.075, -0.2637, -0.1424]), + (633.6, [-0.0573, -0.2379, -0.1199]), + (634.4, [-0.0376, -0.189, -0.0868]), + (635.2, [-0.0215, -0.1329, -0.0491]), + (636.0, [0.0033, -0.0768, -0.0132]), + (636.8, [0.0239, -0.0253, 0.0203]), + (637.6, [0.0391, 0.0169, 0.0432]), + (638.4, [0.0483, 0.0527, 0.056]), + (639.2, [0.0519, 0.0726, 0.0573]), + (640.0, [0.0504, 0.08, 0.0474]), + (640.8, [0.0464, 0.0781, 0.0408]), + (641.6, [0.0411, 0.0658, 0.0309]), + (642.4, [0.0344, 0.0625, 0.0214]), + (643.2, [0.0396, 0.0627, 0.0135]), + (644.0, [0.0434, 0.067, 0.0056]), + (645.6, [0.0487, 0.0807, 0.0144]), + (646.4, [0.0459, 0.0822, 0.016]), + (647.2, [0.0408, 0.0794, 0.0149]), + (648.0, [0.0347, 0.0733, 0.0095]), + (649.6, [0.0202, 0.0588, 0.0082]), + (650.4, [0.023, 0.049, 0.0092]), + (651.2, [0.0256, 0.0464, 0.015]), + (652.0, [0.0285, 0.0508, 0.0269]), + (652.8, [0.0308, 0.0603, 0.0451]), + (653.6, [0.0313, 0.0706, 0.0597]), + (654.4, [0.0355, 0.078, 0.0707]), + (655.2, [0.0356, 0.0767, 0.0776]), + (656.0, [0.0359, 0.0706, 0.0816]), + (656.8, [0.0375, 0.0647, 0.0789]), + (657.6, [0.035, 0.0611, 0.081]), + (659.2, [0.0489, 0.0642, 0.0894]), + (660.0, [0.0538, 0.0613, 0.0918]), + (660.8, [0.0558, 0.0563, 0.0888]), + (661.6, [0.0493, 0.0441, 0.0766]), + (663.2, [0.0486, 0.0082, 0.0391]), + (664.0, [0.0538, -0.0068, 0.0222]), + (664.8, [0.0628, -0.0088, 0.0077]), + (665.6, [0.068, -0.0014, 0.0127]), + (667.2, [0.0757, 0.0255, 0.0283]), + (668.0, [0.0752, 0.0298, 0.0306]), + (668.8, [0.0744, 0.0256, 0.0275]), + (669.6, [0.075, 0.0114, 0.0153]), + (670.4, [0.0703, 0.0056, 0.0105]), + (672.8, [0.0476, -0.0142, -0.0068]), + (674.4, [0.0356, -0.0242, -0.0133]), + (675.2, [0.0373, -0.0141, -0.0035]), + (676.0, [0.035, -0.0017, 0.0079]), + (676.8, [0.029, 0.0078, 0.0176]), + (677.6, [0.0214, 0.0107, 0.0223]), + (679.2, [0.0095, 0.0035, 0.023]), + (680.0, [0.0048, 0.0007, 0.027]), + (680.8, [-0.0012, 0.0091, 0.0279]), + (682.4, [-0.0179, 0.0253, 0.0293]), + (683.2, [-0.0254, 0.0413, 0.0349]), + (684.0, [-0.036, 0.0601, 0.0418]), + (684.8, [-0.0344, 0.0721, 0.0397]), + (687.2, [-0.0293, 0.0521, 0.0336]), + (688.8, [-0.028, 0.0469, 0.0352]), + (689.6, [-0.0112, 0.0488, 0.0397]), + (690.4, [0.0052, 0.0485, 0.0346]), + (691.2, [0.02, 0.0399, 0.0247]), + (692.0, [0.0347, 0.0224, 0.0153]), + (692.8, [0.0495, 0.0013, 0.0073]), + (693.6, [0.0663, -0.0223, -0.0014]), + (694.4, [0.0966, -0.0412, -0.0045]), + (695.2, [0.136, -0.0662, -0.0113]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.2737, 0.1155, 0.1799]), + (0.8, [0.2699, 0.1131, 0.1737]), + (11.2, [0.2675, 0.1035, 0.1681]), + (24.0, [0.2654, 0.0978, 0.1645]), + (63.2, [0.2615, 0.0943, 0.1603]), + (91.2, [0.2513, 0.0726, 0.1411]), + (108.0, [0.2422, 0.0517, 0.1236]), + (130.4, [0.2279, 0.019, 0.0962]), + (141.6, [0.2187, -0.002, 0.079]), + (169.6, [0.1921, -0.0601, 0.032]), + (180.0, [0.1832, -0.0785, 0.0166]), + (194.4, [0.1734, -0.0954, 0.0]), + (216.0, [0.1618, -0.1112, -0.0174]), + (218.4, [0.1597, -0.1122, -0.0214]), + (220.8, [0.1563, -0.1171, -0.0287]), + (223.2, [0.1512, -0.1279, -0.0401]), + (226.4, [0.1414, -0.1535, -0.0623]), + (231.2, [0.122, -0.2077, -0.1075]), + (236.0, [0.0995, -0.2714, -0.161]), + (245.6, [0.0457, -0.4226, -0.2798]), + (250.4, [0.0136, -0.5039, -0.3423]), + (252.8, [-0.0061, -0.5452, -0.3736]), + (260.0, [-0.0751, -0.6688, -0.4689]), + (267.2, [-0.1259, -0.7852, -0.5593]), + (278.4, [-0.1851, -0.9472, -0.6846]), + (284.8, [-0.2133, -1.024, -0.7428]), + (289.6, [-0.2268, -1.071, -0.7813]), + (303.2, [-0.2601, -1.177, -0.8641]), + (310.4, [-0.2792, -1.223, -0.9027]), + (321.6, [-0.3229, -1.309, -0.9761]), + (326.4, [-0.3369, -1.337, -0.995]), + (332.0, [-0.3422, -1.358, -1.009]), + (353.6, [-0.3313, -1.343, -0.9884]), + (372.0, [-0.2942, -1.26, -0.9208]), + (384.8, [-0.2462, -1.155, -0.8435]), + (396.0, [-0.2001, -1.043, -0.752]), + (411.2, [-0.1266, -0.8622, -0.6071]), + (420.0, [-0.0784, -0.744, -0.5174]), + (432.8, [0.0041, -0.5564, -0.3694]), + (436.8, [0.0266, -0.4979, -0.3235]), + (443.2, [0.0701, -0.3928, -0.2362]), + (448.0, [0.1071, -0.3071, -0.1697]), + (454.4, [0.1571, -0.1798, -0.0641]), + (458.4, [0.1909, -0.0895, 0.0018]), + (459.2, [0.1963, -0.0691, 0.0153]), + (460.8, [0.2105, -0.0283, 0.049]), + (462.4, [0.2258, 0.0126, 0.0857]), + (464.0, [0.2419, 0.0584, 0.1254]), + (464.8, [0.2505, 0.083, 0.1462]), + (466.4, [0.2666, 0.1421, 0.1896]), + (468.0, [0.2934, 0.212, 0.2409]), + (470.4, [0.3443, 0.3237, 0.325]), + (474.4, [0.4272, 0.5097, 0.4886]), + (483.2, [0.6195, 0.9528, 0.8273]), + (492.0, [0.8546, 1.42, 1.203]), + (497.6, [0.9847, 1.712, 1.438]), + (521.6, [1.406, 2.942, 2.422]), + (526.4, [1.477, 3.127, 2.571]), + (539.2, [1.579, 3.378, 2.776]), + (568.0, [1.779, 3.834, 3.142]), + (598.4, [1.915, 4.136, 3.391]), + (601.6, [1.844, 3.954, 3.259]), + (603.2, [1.752, 3.746, 3.106]), + (604.8, [1.629, 3.467, 2.885]), + (606.4, [1.465, 3.114, 2.608]), + (608.8, [1.157, 2.482, 2.097]), + (614.4, [0.3432, 0.7828, 0.7084]), + (615.2, [0.2337, 0.5523, 0.5174]), + (616.0, [0.1329, 0.3316, 0.3363]), + (616.8, [0.0424, 0.1291, 0.1607]), + (617.6, [-0.0334, -0.0553, 0.0052]), + (618.4, [-0.0985, -0.2192, -0.1316]), + (619.2, [-0.151, -0.3577, -0.248]), + (620.0, [-0.1975, -0.472, -0.3508]), + (620.8, [-0.226, -0.5586, -0.4266]), + (621.6, [-0.2411, -0.6147, -0.4772]), + (622.4, [-0.2438, -0.637, -0.5002]), + (623.2, [-0.2356, -0.6277, -0.4977]), + (624.0, [-0.216, -0.5886, -0.4714]), + (624.8, [-0.1853, -0.5215, -0.4224]), + (625.6, [-0.1454, -0.4298, -0.3533]), + (626.4, [-0.1015, -0.319, -0.2684]), + (627.2, [-0.0508, -0.2069, -0.1845]), + (628.8, [0.0523, 0.0314, -0.002]), + (629.6, [0.0941, 0.1398, 0.0855]), + (630.4, [0.1323, 0.2316, 0.1512]), + (631.2, [0.1607, 0.2955, 0.2003]), + (632.0, [0.1783, 0.3401, 0.2304]), + (632.8, [0.1856, 0.3588, 0.2411]), + (633.6, [0.1731, 0.3523, 0.2332]), + (634.4, [0.1563, 0.3271, 0.2136]), + (635.2, [0.1367, 0.2809, 0.1823]), + (636.0, [0.1188, 0.2256, 0.1451]), + (636.8, [0.0927, 0.1691, 0.1083]), + (637.6, [0.071, 0.1162, 0.0697]), + (638.4, [0.0546, 0.0714, 0.0448]), + (639.2, [0.0441, 0.0299, 0.0297]), + (640.0, [0.0353, 0.007, 0.0258]), + (640.8, [0.0359, -0.003, 0.0331]), + (641.6, [0.0395, -0.0028, 0.0371]), + (642.4, [0.0446, 0.0058, 0.0467]), + (643.2, [0.0507, 0.0093, 0.0565]), + (644.0, [0.0444, 0.01, 0.0647]), + (644.8, [0.0404, 0.0066, 0.072]), + (645.6, [0.0371, 0.0001, 0.066]), + (647.2, [0.0323, -0.014, 0.0599]), + (648.0, [0.0371, -0.012, 0.0604]), + (648.8, [0.043, -0.0066, 0.0645]), + (649.6, [0.0496, 0.0008, 0.0618]), + (650.4, [0.0568, 0.008, 0.0635]), + (651.2, [0.0526, 0.0139, 0.0632]), + (652.0, [0.0501, 0.0179, 0.0586]), + (652.8, [0.0472, 0.0149, 0.048]), + (653.6, [0.0447, 0.0062, 0.0263]), + (654.4, [0.0437, -0.0043, 0.011]), + (655.2, [0.036, -0.0125, -0.0009]), + (656.0, [0.0355, -0.0175, -0.0086]), + (656.8, [0.0354, -0.0118, -0.0131]), + (658.4, [0.0337, -0.0014, -0.0165]), + (659.2, [0.0264, -0.0008, -0.0203]), + (660.8, [0.0141, -0.0059, -0.0282]), + (661.6, [0.0113, -0.0024, -0.0311]), + (662.4, [0.013, 0.008, -0.0207]), + (663.2, [0.015, 0.0242, -0.0045]), + (664.0, [0.0147, 0.043, 0.0154]), + (664.8, [0.0105, 0.0586, 0.0334]), + (665.6, [0.0022, 0.06, 0.0472]), + (666.4, [-0.0077, 0.0548, 0.0438]), + (667.2, [-0.0133, 0.0415, 0.0356]), + (668.0, [-0.0166, 0.0278, 0.0279]), + (668.8, [-0.0166, 0.0213, 0.0243]), + (669.6, [-0.0157, 0.024, 0.0264]), + (670.4, [-0.016, 0.0341, 0.0345]), + (671.2, [-0.0167, 0.0403, 0.0395]), + (673.6, [0.0059, 0.0597, 0.0568]), + (675.2, [0.0163, 0.0692, 0.0624]), + (676.0, [0.0137, 0.0598, 0.0529]), + (676.8, [0.0151, 0.0475, 0.0416]), + (677.6, [0.0204, 0.037, 0.0312]), + (678.4, [0.0279, 0.0324, 0.0253]), + (679.2, [0.0352, 0.035, 0.0243]), + (680.0, [0.0364, 0.0392, 0.0243]), + (680.8, [0.0412, 0.041, 0.0164]), + (681.6, [0.0468, 0.0318, 0.0153]), + (683.2, [0.0631, 0.0165, 0.0145]), + (684.0, [0.0708, 0.0019, 0.0097]), + (684.8, [0.0796, -0.0171, 0.0027]), + (685.6, [0.0778, -0.0311, 0.0006]), + (686.4, [0.0768, -0.0325, 0.0007]), + (688.0, [0.073, -0.0179, 0.0054]), + (689.6, [0.0702, -0.0117, 0.0047]), + (690.4, [0.0543, -0.013, 0.0006]), + (691.2, [0.0374, -0.014, -0.0009]), + (692.0, [0.0224, -0.0069, 0.0083]), + (692.8, [0.0077, 0.0046, 0.0182]), + (693.6, [-0.007, 0.0251, 0.0261]), + (694.4, [-0.0229, 0.0489, 0.0353]), + (695.2, [-0.0528, 0.0682, 0.0392]), + (696.0, [-0.0921, 0.0915, 0.0446]), + (696.8, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.0447, 0.0359, -0.016]), + (0.79906, [0.0379, 0.0316, -0.0115]), + (3.9953, [0.0337, 0.0301, -0.0108]), + (19.177, [0.0225, 0.0238, -0.0091]), + (25.57, [0.0173, 0.0247, -0.0069]), + (37.556, [0.0156, 0.024, -0.0014]), + (47.944, [0.0073, 0.0237, -0.0006]), + (57.532, [0.0029, 0.0252, 0.0038]), + (59.929, [0.0017, 0.027, 0.0056]), + (65.523, [0.0032, 0.0315, 0.0101]), + (78.308, [0.003, 0.0376, 0.016]), + (85.499, [0.0064, 0.0491, 0.0251]), + (92.691, [0.0085, 0.055, 0.0314]), + (93.49, [0.0071, 0.0558, 0.0319]), + (111.07, [0.014, 0.0719, 0.0448]), + (112.67, [0.0188, 0.0741, 0.046]), + (118.26, [0.0488, 0.0854, 0.0551]), + (119.86, [0.0526, 0.088, 0.0564]), + (127.05, [0.0207, 0.0863, 0.0563]), + (140.63, [0.0266, 0.0996, 0.0671]), + (146.23, [0.0309, 0.1105, 0.0757]), + (147.03, [0.0308, 0.1085, 0.0743]), + (148.62, [0.0271, 0.0946, 0.0644]), + (159.01, [-0.0018, -0.0038, -0.0029]), + (159.81, [-0.0015, -0.0052, -0.0041]), + (259.69, [-0.0007, -0.0019, -0.0016]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0012, -0.0025, -0.0024]), + (20.706, [-0.0018, -0.0017, -0.0037]), + (44.598, [-0.0032, -0.0081, -0.0063]), + (55.747, [-0.0038, -0.0087, -0.007]), + (82.028, [-0.0028, -0.0065, -0.0051]), + (99.548, [-0.0012, -0.0028, -0.0023]), + (122.64, [-0.0009, -0.0023, -0.0017]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0016, 0.0043, 0.0036]), + (2.38, [0.0012, 0.0026, 0.0023]), + (3.1734, [0.0014, 0.0039, 0.0031]), + (77.748, [0.0009, 0.0023, 0.0019]), + (84.888, [-0.0005, -0.0025, -0.0015]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [-0.002, -0.0027, 0.0039]), + (1.5694, [-0.0013, 0.0003, 0.0026]), + (4.7082, [0.0025, 0.0035, 0.0053]), + (9.4165, [0.0126, 0.0191, 0.0191]), + (10.201, [0.0126, 0.0196, 0.0193]), + (11.771, [0.0157, 0.0259, 0.0235]), + (13.34, [0.0152, 0.0247, 0.0222]), + (15.694, [0.0079, 0.0131, 0.0123]), + (18.048, [0.0023, 0.0039, 0.0055]), + (20.402, [-0.0014, -0.0019, -0.0004]), + (21.972, [-0.0034, -0.005, -0.0021]), + (26.68, [-0.0052, -0.0062, -0.0046]), + (28.249, [-0.0036, -0.003, -0.0017]), + (32.173, [-0.0017, -0.0012, -0.0001]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.1135, 0.13, 0.0738]), + (0.7973, [0.1183, 0.1323, 0.077]), + (3.1892, [0.121, 0.1329, 0.0753]), + (7.1757, [0.1155, 0.1315, 0.0754]), + (25.513, [0.0773, 0.1109, 0.0745]), + (30.297, [0.0667, 0.1024, 0.0731]), + (39.067, [0.0516, 0.0924, 0.0705]), + (51.027, [0.0383, 0.0834, 0.0654]), + (78.135, [0.0266, 0.0619, 0.0496]), + (86.108, [0.028, 0.0564, 0.0447]), + (112.42, [0.0149, 0.0349, 0.0278]), + ], + }, +]; +const PRIME35_200_STREAKS: &[Streak] = &[]; +const PRIME35_200_RAYS: &[Ray] = &[]; +const PRIME35_300_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.5003, 0.5003, 0.5003]), + (688.8, [0.5036, 0.5036, 0.5036]), + (692.0, [0.4955, 0.4955, 0.4955]), + (695.2, [0.4741, 0.4741, 0.4741]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.4997, 0.4997, 0.4997]), + (688.8, [0.4963, 0.4963, 0.4963]), + (692.0, [0.5006, 0.5006, 0.5006]), + (696.0, [0.5245, 0.5245, 0.5245]), + (696.8, [0.0, 0.0, 0.0]), + ], + }, +]; +const PRIME35_300_STREAKS: &[Streak] = &[]; +const PRIME35_300_RAYS: &[Ray] = &[]; +pub(super) const PRIME35: &[Level] = &[ + Level { brightness: 10.0, radial: PRIME35_10_RADIAL, streaks: PRIME35_10_STREAKS, rays: PRIME35_10_RAYS }, + Level { brightness: 50.0, radial: PRIME35_50_RADIAL, streaks: PRIME35_50_STREAKS, rays: PRIME35_50_RAYS }, + Level { brightness: 75.0, radial: PRIME35_75_RADIAL, streaks: PRIME35_75_STREAKS, rays: PRIME35_75_RAYS }, + Level { brightness: 100.0, radial: PRIME35_100_RADIAL, streaks: PRIME35_100_STREAKS, rays: PRIME35_100_RAYS }, + Level { brightness: 125.0, radial: PRIME35_125_RADIAL, streaks: PRIME35_125_STREAKS, rays: PRIME35_125_RAYS }, + Level { brightness: 150.0, radial: PRIME35_150_RADIAL, streaks: PRIME35_150_STREAKS, rays: PRIME35_150_RAYS }, + Level { brightness: 200.0, radial: PRIME35_200_RADIAL, streaks: PRIME35_200_STREAKS, rays: PRIME35_200_RAYS }, + Level { brightness: 300.0, radial: PRIME35_300_RADIAL, streaks: PRIME35_300_STREAKS, rays: PRIME35_300_RAYS }, +]; + +const PRIME105_10_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.742, 0.7144, 0.7037]), + (0.8, [0.7034, 0.6788, 0.6594]), + (1.6, [0.6247, 0.587, 0.5597]), + (3.2, [0.4345, 0.3763, 0.3429]), + (4.0, [0.3632, 0.2983, 0.2645]), + (4.8, [0.3107, 0.2534, 0.2171]), + (5.6, [0.2863, 0.2331, 0.1986]), + (6.4, [0.2655, 0.2199, 0.1912]), + (8.0, [0.2349, 0.1946, 0.1647]), + (8.8, [0.2164, 0.1864, 0.1557]), + (9.6, [0.1996, 0.1718, 0.1457]), + (12.0, [0.1554, 0.1358, 0.1158]), + (14.4, [0.1134, 0.1079, 0.098]), + (15.2, [0.1078, 0.1007, 0.0926]), + (16.0, [0.0974, 0.0983, 0.0835]), + (17.6, [0.0831, 0.0817, 0.0702]), + (18.4, [0.0748, 0.0764, 0.0622]), + (19.2, [0.0682, 0.0673, 0.0558]), + (20.8, [0.0538, 0.0573, 0.0452]), + (24.0, [0.0328, 0.037, 0.0279]), + (25.6, [0.0271, 0.0309, 0.0221]), + (26.4, [0.0206, 0.0268, 0.0186]), + (28.0, [0.018, 0.0201, 0.0129]), + (28.8, [0.0134, 0.0188, 0.0119]), + (32.8, [0.0065, 0.0117, 0.0054]), + (34.4, [0.0049, 0.0082, 0.0049]), + (35.2, [0.0024, 0.0081, 0.0027]), + (36.8, [0.0018, 0.0043, 0.0013]), + (37.6, [-0.0007, 0.0046, 0.0011]), + (39.2, [-0.0011, 0.0007, -0.0024]), + (40.8, [-0.0049, 0.0004, -0.0023]), + (42.4, [-0.0045, -0.0028, -0.006]), + (44.0, [-0.0084, -0.0039, -0.0058]), + (44.8, [-0.0082, -0.0063, -0.0057]), + (46.4, [-0.0085, -0.0062, -0.0095]), + (47.2, [-0.0111, -0.0067, -0.0093]), + (48.8, [-0.0117, -0.01, -0.0094]), + (49.6, [-0.0117, -0.0099, -0.0091]), + (51.2, [-0.0123, -0.0098, -0.0129]), + (52.8, [-0.0153, -0.0134, -0.0128]), + (76.0, [-0.0158, -0.0143, -0.0128]), + (77.6, [-0.012, -0.0143, -0.0128]), + (80.0, [-0.0157, -0.0143, -0.0128]), + (99.2, [-0.0155, -0.0145, -0.0125]), + (209.6, [-0.0099, -0.0093, -0.0077]), + (368.8, [0.0014, 0.0015, 0.0013]), + (615.2, [-0.0022, -0.0024, -0.0021]), + (661.6, [-0.0005, -0.0002, -0.0005]), + (685.6, [-0.0025, -0.0018, -0.0011]), + (699.2, [0.0025, 0.0029, 0.0021]), + (702.4, [0.0012, 0.0015, 0.0008]), + (706.4, [-0.0047, -0.0043, -0.0037]), + (708.8, [-0.0062, -0.0059, -0.0048]), + (717.6, [-0.0011, -0.0014, -0.0012]), + (747.2, [-0.0017, -0.0016, -0.0014]), + (753.6, [-0.004, -0.0078, -0.0052]), + (756.8, [-0.0058, -0.0128, -0.0112]), + (760.0, [-0.0064, -0.015, -0.0156]), + (764.8, [-0.0049, -0.0101, -0.013]), + (772.0, [0.0045, 0.0136, 0.0033]), + (776.0, [0.0044, 0.018, 0.0085]), + (780.0, [0.0022, 0.011, 0.0101]), + (784.0, [-0.0013, -0.0023, 0.0026]), + (788.0, [-0.0028, -0.0085, -0.0031]), + (791.2, [-0.003, -0.0089, -0.0035]), + (808.8, [-0.0005, -0.0017, -0.0009]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.1004, 0.0924, 0.0911]), + (0.8, [0.0985, 0.0898, 0.0916]), + (1.6, [0.093, 0.0846, 0.0841]), + (2.4, [0.0756, 0.0643, 0.064]), + (4.0, [0.0589, 0.046, 0.0453]), + (4.8, [0.0499, 0.0398, 0.0389]), + (12.0, [0.0324, 0.0267, 0.028]), + (12.8, [0.0301, 0.0272, 0.0269]), + (13.6, [0.0299, 0.0247, 0.027]), + (14.4, [0.0273, 0.025, 0.0257]), + (19.2, [0.0226, 0.0194, 0.0202]), + (26.4, [0.0168, 0.0152, 0.015]), + (47.2, [0.0131, 0.0123, 0.0115]), + (128.8, [0.0121, 0.0115, 0.0097]), + (328.0, [-0.0022, -0.0019, -0.0019]), + (385.6, [-0.0046, -0.0043, -0.0037]), + (512.0, [-0.0011, -0.0011, -0.001]), + (625.6, [-0.0004, 0.0001, 0.0]), + (659.2, [-0.0022, -0.0022, -0.0017]), + (687.2, [-0.0003, -0.0006, -0.0011]), + (700.0, [-0.0052, -0.0053, -0.0042]), + (703.2, [-0.004, -0.0039, -0.0029]), + (707.2, [0.002, 0.002, 0.0016]), + (709.6, [0.0035, 0.0035, 0.0027]), + (718.4, [-0.0023, -0.0012, -0.001]), + (745.6, [-0.0021, -0.0019, -0.0015]), + (752.0, [-0.0007, 0.0016, -0.0001]), + (754.4, [-0.0016, -0.0021, -0.0031]), + (759.2, [-0.0057, -0.0154, -0.0118]), + (763.2, [-0.0076, -0.0205, -0.0184]), + (765.6, [-0.0061, -0.0167, -0.0169]), + (768.0, [-0.0038, -0.0068, -0.0114]), + (772.8, [0.0034, 0.0168, 0.0055]), + (774.4, [0.0051, 0.0212, 0.0089]), + (776.8, [0.0076, 0.0239, 0.0132]), + (778.4, [0.0073, 0.0218, 0.0132]), + (780.8, [0.0043, 0.0137, 0.0104]), + (784.0, [-0.0027, -0.0063, -0.0011]), + (785.6, [-0.0048, -0.0122, -0.0062]), + (792.0, [-0.002, -0.0054, -0.0007]), + (800.8, [-0.0022, -0.0057, -0.0018]), + (809.6, [-0.0002, -0.0016, -0.0008]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0022, 0.0021, 0.0013]), + (783.44, [0.0019, 0.0016, 0.0015]), + (818.62, [0.0032, 0.0019, 0.0018]), + (831.41, [0.0034, 0.0027, 0.003]), + (833.01, [0.0046, 0.0067, 0.0061]), + (834.61, [0.0067, 0.0131, 0.0105]), + (838.61, [0.0121, 0.0297, 0.0257]), + (841.8, [0.0135, 0.0346, 0.0328]), + (843.4, [0.0131, 0.0332, 0.033]), + (845.0, [0.0111, 0.0271, 0.0302]), + (848.2, [0.0044, 0.0044, 0.0155]), + (852.2, [-0.0078, -0.0297, -0.0082]), + (853.79, [-0.0103, -0.0373, -0.0146]), + (856.19, [-0.012, -0.0418, -0.0214]), + (857.79, [-0.0108, -0.0384, -0.0223]), + (860.19, [-0.0065, -0.0253, -0.0203]), + (862.59, [0.0006, -0.0032, -0.0095]), + (864.19, [0.005, 0.0114, 0.0008]), + (864.99, [0.0064, 0.0156, 0.0045]), + (865.79, [0.0064, 0.017, 0.0058]), + (875.38, [0.0042, 0.0126, 0.0031]), + (884.97, [0.0035, 0.0086, 0.0033]), + (890.57, [-0.0001, 0.0011, 0.0005]), + ], + }, + Radial { t: 1.4995, perp: 0.0, knots: &[(0.0, [0.0059, 0.0035, 0.0001]), (3.9983, [0.0044, 0.0035, 0.0002]), (9.5958, [0.0016, 0.0002, 0.0001])] }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0061, -0.0048, 0.0002]), + (0.79862, [-0.0042, -0.0034, 0.0002]), + (3.1945, [-0.0056, -0.0046, 0.0002]), + (9.5834, [-0.0019, -0.0003, 0.0001]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.045, 0.037, 0.0168]), + (8.7134, [0.044, 0.036, 0.0157]), + (9.5055, [0.0252, 0.021, 0.0098]), + (10.298, [0.0024, 0.0024, 0.0006]), + (11.09, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.0354, 0.0523, 0.0753]), + (1.5834, [0.0212, 0.0362, 0.0602]), + (2.375, [0.0109, 0.0251, 0.0499]), + (3.1667, [0.0071, 0.0213, 0.0457]), + (6.3335, [0.0064, 0.0184, 0.0446]), + (7.9168, [0.0021, 0.007, 0.0365]), + (8.7085, [0.0021, 0.0061, 0.0341]), + (14.25, [0.0002, 0.0007, 0.0002]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.0228, 0.015, 0.0]), + (0.78746, [0.0226, 0.0148, 0.0]), + (1.5749, [0.0176, 0.0085, 0.0]), + (2.3624, [0.0086, 0.0045, 0.0]), + (3.9373, [0.004, -0.0002, 0.0]), + (5.5122, [-0.0002, 0.0, 0.0]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[(0.0, [0.0, 0.004, 0.0079]), (3.9837, [0.0, 0.0044, 0.0078]), (4.7804, [0.0, 0.0007, 0.0037]), (5.5771, [0.0, -0.0001, -0.0001])], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0066, 0.0063, -0.0002]), + (0.78812, [0.0054, 0.0041, -0.0003]), + (5.5169, [0.0046, 0.0018, -0.0002]), + (8.6693, [0.0019, 0.0004, -0.0002]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.0655, 0.0664, 0.0674]), + (0.79199, [0.0544, 0.0561, 0.0594]), + (1.584, [0.0488, 0.052, 0.0531]), + (2.376, [0.0389, 0.0388, 0.0395]), + (3.96, [0.0161, 0.0167, 0.0177]), + (4.752, [0.0078, 0.0083, 0.0093]), + (6.336, [-0.001, -0.0007, 0.0]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[(0.0, [0.0037, 0.0038, 0.0039]), (29.204, [0.004, 0.004, 0.004]), (29.993, [0.0033, 0.0033, 0.0034]), (30.783, [-0.0002, -0.0002, -0.0002])], + }, + Radial { t: 0.94074, perp: 0.0, knots: &[(0.0, [0.0, 0.0141, 0.0177]), (0.7998, [0.0, 0.0085, 0.0088]), (2.3994, [0.0, -0.0002, -0.0001])] }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.0317, 0.0222, 0.0]), + (0.77016, [0.0268, 0.0197, -0.0001]), + (1.5403, [0.0214, 0.0114, -0.0001]), + (2.3105, [0.0106, 0.0072, -0.0001]), + (3.8508, [0.0063, 0.0023, -0.0001]), + (5.3911, [0.0023, 0.0018, 0.0]), + (7.7016, [0.0018, 0.0007, 0.0]), + ], + }, + Radial { t: 2.9016, perp: 0.0, knots: &[(0.0, [0.0044, 0.0011, 0.0005]), (46.298, [0.0042, 0.0004, 0.0002]), (47.895, [-0.0003, 0.0003, 0.0001])] }, +]; +const PRIME105_10_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_10_RAYS: &[Ray] = &[ + Ray { angle: 96.82, r0: 6.25, r1: 72.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 97.07, r0: 6.25, r1: 75.0, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 97.32, r0: 6.25, r1: 94.531, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 275.48, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 275.73, r0: 6.25, r1: 64.062, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 275.99, r0: 6.25, r1: 64.844, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, +]; +const PRIME105_50_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.7558, 0.858, 0.8891]), + (0.8, [0.9807, 0.9598, 0.8998]), + (1.6, [0.8986, 0.918, 0.8699]), + (2.4, [0.897, 0.9062, 0.8456]), + (4.8, [0.8267, 0.8432, 0.7661]), + (11.2, [0.7145, 0.731, 0.6539]), + (18.4, [0.5606, 0.5738, 0.5065]), + (23.2, [0.4652, 0.4709, 0.4194]), + (25.6, [0.4199, 0.4182, 0.3743]), + (28.8, [0.3696, 0.3598, 0.326]), + (40.8, [0.2569, 0.2164, 0.184]), + (44.8, [0.2227, 0.1771, 0.1437]), + (51.2, [0.1783, 0.1212, 0.0776]), + (55.2, [0.1578, 0.093, 0.0427]), + (58.4, [0.147, 0.0771, 0.0198]), + (60.8, [0.1453, 0.0707, 0.0092]), + (65.6, [0.1467, 0.0642, -0.0073]), + (70.4, [0.1508, 0.0587, -0.021]), + (75.2, [0.1574, 0.0564, -0.0289]), + (76.0, [0.1622, 0.056, -0.0297]), + (77.6, [0.1902, 0.0555, -0.0313]), + (78.4, [0.189, 0.0555, -0.032]), + (80.0, [0.1665, 0.0555, -0.0342]), + (80.8, [0.1661, 0.0555, -0.0353]), + (89.6, [0.1775, 0.0519, -0.0486]), + (100.0, [0.195, 0.0533, -0.059]), + (111.2, [0.2083, 0.0526, -0.068]), + (124.8, [0.2196, 0.0552, -0.0735]), + (139.2, [0.2053, 0.0467, -0.0764]), + (152.8, [0.1792, 0.0355, -0.0738]), + (160.8, [0.155, 0.024, -0.0736]), + (170.4, [0.1192, 0.0072, -0.0732]), + (179.2, [0.0817, -0.0094, -0.0709]), + (186.4, [0.0469, -0.0248, -0.0686]), + (195.2, [0.0001, -0.0456, -0.0658]), + (201.6, [-0.0359, -0.0616, -0.0638]), + (234.4, [-0.2321, -0.1483, -0.0515]), + (247.2, [-0.299, -0.1764, -0.0459]), + (260.0, [-0.347, -0.1943, -0.0396]), + (272.0, [-0.3683, -0.1983, -0.033]), + (288.0, [-0.3668, -0.1881, -0.0237]), + (320.8, [-0.3221, -0.1471, -0.0061]), + (346.4, [-0.2729, -0.11, 0.0055]), + (373.6, [-0.2078, -0.0697, 0.0143]), + (396.8, [-0.142, -0.0359, 0.0178]), + (421.6, [-0.0604, -0.0008, 0.0177]), + (438.4, [0.0027, 0.0232, 0.0155]), + (469.6, [0.1302, 0.066, 0.0076]), + (480.8, [0.1724, 0.0798, 0.0039]), + (490.4, [0.1985, 0.0842, 0.0003]), + (500.0, [0.2125, 0.0853, -0.0027]), + (512.0, [0.2109, 0.0808, -0.006]), + (524.8, [0.1886, 0.0684, -0.0079]), + (537.6, [0.1508, 0.0517, -0.0085]), + (557.6, [0.0794, 0.0249, -0.0064]), + (568.8, [0.0408, 0.0129, -0.0042]), + (576.8, [0.0161, 0.0062, -0.0024]), + (586.4, [-0.0088, -0.0001, -0.001]), + (591.2, [-0.0173, -0.0018, -0.0006]), + (595.2, [-0.0217, -0.0024, -0.0009]), + (600.8, [-0.0225, -0.0017, -0.0018]), + (608.0, [-0.0158, 0.0017, -0.0035]), + (620.0, [0.0066, 0.008, -0.0072]), + (626.4, [0.0201, 0.0099, -0.009]), + (630.4, [0.0268, 0.0088, -0.0099]), + (633.6, [0.033, 0.011, -0.0096]), + (636.8, [0.037, 0.0118, -0.0091]), + (641.6, [0.039, 0.0117, -0.0081]), + (648.8, [0.0359, 0.0093, -0.0058]), + (654.4, [0.0288, 0.006, -0.0039]), + (661.6, [0.0133, -0.0011, -0.0024]), + (670.4, [-0.0136, -0.0139, -0.0038]), + (675.2, [-0.0261, -0.0174, -0.0034]), + (680.0, [-0.0338, -0.0174, -0.0028]), + (685.6, [-0.0356, -0.0159, -0.0028]), + (690.4, [-0.0288, -0.0116, -0.0026]), + (700.0, [-0.004, 0.0031, 0.0005]), + (702.4, [-0.002, 0.0015, -0.0019]), + (706.4, [-0.0066, -0.0082, -0.0113]), + (708.0, [-0.0049, -0.0089, -0.0136]), + (709.6, [0.0032, -0.0058, -0.014]), + (710.4, [0.0104, -0.0027, -0.0135]), + (713.6, [0.0461, 0.0119, -0.0076]), + (714.4, [0.051, 0.0143, -0.006]), + (715.2, [0.0523, 0.0153, -0.0048]), + (716.0, [0.0496, 0.0145, -0.004]), + (716.8, [0.0427, 0.0115, -0.0037]), + (719.2, [0.0127, -0.0001, -0.0045]), + (720.0, [0.0075, -0.0008, -0.0046]), + (720.8, [0.0064, 0.0003, -0.0044]), + (722.4, [0.0118, 0.004, -0.0036]), + (723.2, [0.0146, 0.0046, -0.0035]), + (724.0, [0.0146, 0.0038, -0.0038]), + (724.8, [0.0109, 0.002, -0.0041]), + (727.2, [-0.0124, -0.0052, -0.0042]), + (728.0, [-0.0153, -0.0064, -0.0041]), + (730.4, [-0.0086, -0.0065, -0.0047]), + (733.6, [-0.0074, -0.0042, -0.0046]), + (736.8, [-0.0025, -0.0024, -0.0048]), + (740.0, [-0.0051, -0.0031, -0.0048]), + (743.2, [-0.0052, -0.004, -0.005]), + (746.4, [-0.0105, -0.0096, -0.0074]), + (749.6, [-0.0149, -0.0196, -0.0126]), + (752.0, [-0.0155, -0.0244, -0.0166]), + (755.2, [-0.019, -0.0337, -0.0234]), + (756.8, [-0.0208, -0.0396, -0.0295]), + (759.2, [-0.0212, -0.0417, -0.0377]), + (760.8, [-0.0188, -0.0384, -0.0403]), + (763.2, [-0.017, -0.0326, -0.04]), + (765.6, [-0.0139, -0.0215, -0.0341]), + (769.6, [-0.0053, 0.0026, -0.0159]), + (772.0, [0.0001, 0.0186, 0.0007]), + (773.6, [0.0032, 0.0268, 0.0098]), + (775.2, [0.0054, 0.033, 0.0164]), + (777.6, [0.0059, 0.0332, 0.022]), + (780.0, [0.0029, 0.0232, 0.0192]), + (780.8, [-0.0006, 0.0167, 0.0156]), + (784.0, [-0.0151, -0.0179, -0.0061]), + (787.2, [-0.0252, -0.0432, -0.017]), + (788.8, [-0.0285, -0.0512, -0.0203]), + (791.2, [-0.0309, -0.0563, -0.0219]), + (792.8, [-0.029, -0.054, -0.0207]), + (798.4, [-0.0186, -0.0353, -0.0118]), + (804.8, [-0.0126, -0.0277, -0.0059]), + (807.2, [-0.0092, -0.0227, -0.0036]), + (808.8, [-0.0118, -0.0221, -0.0059]), + (810.4, [-0.0058, -0.011, -0.0051]), + (812.0, [0.0042, 0.0056, 0.0006]), + (812.8, [0.0076, 0.0087, 0.0029]), + (813.6, [0.0062, 0.006, 0.003]), + (814.4, [0.0024, 0.0006, 0.001]), + (815.2, [0.0003, -0.0022, -0.0007]), + (816.0, [0.0004, -0.0017, -0.0009]), + (817.6, [0.003, 0.0031, 0.0012]), + (819.2, [-0.0001, -0.0008, -0.0002]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [-0.0451, -0.0148, 0.0454]), + (0.8, [0.0273, 0.0309, 0.0595]), + (1.6, [0.0224, 0.0242, 0.0562]), + (2.4, [0.0182, 0.0245, 0.0527]), + (4.0, [0.0126, 0.0197, 0.0477]), + (5.6, [0.0096, 0.0157, 0.0438]), + (6.4, [0.0105, 0.0163, 0.0429]), + (9.6, [-0.0014, 0.0088, 0.0377]), + (13.6, [-0.013, 0.0033, 0.0331]), + (24.0, [-0.0441, -0.0185, 0.0189]), + (28.8, [-0.0576, -0.0269, 0.0151]), + (38.4, [-0.0751, -0.0336, 0.0135]), + (45.6, [-0.0837, -0.0343, 0.018]), + (55.2, [-0.0909, -0.0319, 0.0286]), + (64.0, [-0.1048, -0.0308, 0.0381]), + (77.6, [-0.1309, -0.033, 0.0518]), + (80.8, [-0.1415, -0.0339, 0.0547]), + (91.2, [-0.1649, -0.038, 0.0633]), + (103.2, [-0.192, -0.0452, 0.0701]), + (116.0, [-0.2141, -0.0522, 0.0738]), + (128.8, [-0.2236, -0.0558, 0.074]), + (140.8, [-0.213, -0.0512, 0.0729]), + (151.2, [-0.1931, -0.0427, 0.0708]), + (161.6, [-0.1635, -0.0296, 0.0685]), + (171.2, [-0.1284, -0.0144, 0.0657]), + (180.8, [-0.0858, 0.0043, 0.0629]), + (190.4, [-0.0365, 0.0263, 0.0598]), + (199.2, [0.0131, 0.0484, 0.057]), + (236.8, [0.2424, 0.1497, 0.0427]), + (251.2, [0.3156, 0.18, 0.0362]), + (264.0, [0.358, 0.1947, 0.0295]), + (279.2, [0.3747, 0.1942, 0.0209]), + (294.4, [0.3638, 0.1798, 0.0119]), + (324.8, [0.3193, 0.14, -0.0043]), + (351.2, [0.2656, 0.1008, -0.016]), + (376.8, [0.2021, 0.0623, -0.0239]), + (401.6, [0.1289, 0.0256, -0.0272]), + (424.0, [0.0528, -0.0064, -0.0267]), + (440.0, [-0.0086, -0.0295, -0.0245]), + (470.4, [-0.1347, -0.0718, -0.0166]), + (481.6, [-0.1776, -0.0858, -0.0128]), + (492.0, [-0.2059, -0.0905, -0.0088]), + (501.6, [-0.2192, -0.0914, -0.0059]), + (512.8, [-0.2172, -0.087, -0.0027]), + (525.6, [-0.1948, -0.0745, -0.0007]), + (538.4, [-0.1566, -0.0576, -0.0001]), + (559.2, [-0.0811, -0.0294, -0.0023]), + (570.4, [-0.0421, -0.0173, -0.0046]), + (578.4, [-0.0172, -0.0105, -0.0064]), + (588.0, [0.0076, -0.0044, -0.0078]), + (592.0, [0.0147, -0.003, -0.0081]), + (596.8, [0.02, -0.0022, -0.0078]), + (603.2, [0.0198, -0.0035, -0.0067]), + (609.6, [0.013, -0.0067, -0.005]), + (620.8, [-0.0083, -0.0126, -0.0015]), + (628.0, [-0.0235, -0.0148, 0.0005]), + (631.2, [-0.0286, -0.0135, 0.0013]), + (635.2, [-0.0362, -0.0159, 0.0009]), + (642.4, [-0.0414, -0.0164, -0.0004]), + (649.6, [-0.0384, -0.014, -0.0027]), + (655.2, [-0.0313, -0.0107, -0.0047]), + (663.2, [-0.0137, -0.0026, -0.0063]), + (672.0, [0.0139, 0.0103, -0.0049]), + (676.8, [0.0262, 0.0135, -0.0054]), + (680.8, [0.0328, 0.0137, -0.0059]), + (687.2, [0.0342, 0.0116, -0.0059]), + (693.6, [0.0229, 0.0042, -0.0068]), + (700.8, [0.0038, -0.007, -0.0094]), + (703.2, [0.0015, -0.0056, -0.0071]), + (707.2, [0.0059, 0.0041, 0.0024]), + (708.8, [0.0042, 0.0049, 0.0047]), + (710.4, [-0.0029, 0.0021, 0.0052]), + (711.2, [-0.0096, -0.0009, 0.0046]), + (714.4, [-0.0476, -0.0165, -0.0008]), + (715.2, [-0.0548, -0.0198, -0.0027]), + (716.0, [-0.0588, -0.022, -0.0042]), + (716.8, [-0.0589, -0.0224, -0.0053]), + (717.6, [-0.0547, -0.0208, -0.0059]), + (718.4, [-0.0465, -0.0173, -0.006]), + (720.0, [-0.0251, -0.0086, -0.0053]), + (720.8, [-0.0174, -0.0064, -0.0049]), + (721.6, [-0.0138, -0.0065, -0.0049]), + (722.4, [-0.0141, -0.0082, -0.0053]), + (724.8, [-0.0228, -0.0119, -0.006]), + (725.6, [-0.0218, -0.0108, -0.0057]), + (726.4, [-0.0168, -0.0087, -0.0053]), + (728.0, [0.0002, -0.0036, -0.0052]), + (728.8, [0.006, -0.0018, -0.0054]), + (729.6, [0.0073, -0.001, -0.0054]), + (732.0, [-0.0002, -0.0011, -0.0046]), + (732.8, [-0.0008, -0.0014, -0.0043]), + (734.4, [-0.0013, -0.0031, -0.0045]), + (738.4, [-0.0085, -0.0068, -0.005]), + (741.6, [-0.0073, -0.0081, -0.0066]), + (744.8, [-0.0094, -0.0094, -0.0082]), + (747.2, [-0.0059, -0.0063, -0.0078]), + (748.8, [-0.0055, -0.0019, -0.0052]), + (751.2, [-0.0006, 0.0068, 0.0001]), + (752.0, [0.0001, 0.0075, 0.001]), + (752.8, [-0.0014, 0.0047, -0.0004]), + (754.4, [-0.0061, -0.0059, -0.007]), + (755.2, [-0.0082, -0.0127, -0.0115]), + (756.8, [-0.0153, -0.0309, -0.0245]), + (759.2, [-0.0253, -0.0517, -0.0386]), + (761.6, [-0.0332, -0.0664, -0.0472]), + (763.2, [-0.0358, -0.0691, -0.0505]), + (764.8, [-0.034, -0.0635, -0.0481]), + (766.4, [-0.0281, -0.0496, -0.0423]), + (768.8, [-0.0143, -0.0187, -0.0281]), + (772.0, [0.0096, 0.0331, -0.0016]), + (773.6, [0.0178, 0.0521, 0.0085]), + (775.2, [0.0225, 0.0645, 0.0186]), + (776.8, [0.0227, 0.0677, 0.0273]), + (778.4, [0.02, 0.0606, 0.0298]), + (780.0, [0.012, 0.0424, 0.0282]), + (780.8, [0.0062, 0.0298, 0.025]), + (783.2, [-0.0156, -0.0191, 0.0041]), + (784.8, [-0.0304, -0.0524, -0.0167]), + (785.6, [-0.0345, -0.062, -0.0234]), + (786.4, [-0.0354, -0.0647, -0.026]), + (789.6, [-0.0285, -0.0485, -0.019]), + (793.6, [-0.0216, -0.0372, -0.015]), + (796.0, [-0.0189, -0.0346, -0.0131]), + (800.8, [-0.019, -0.036, -0.0109]), + (804.0, [-0.0157, -0.0313, -0.0085]), + (806.4, [-0.0101, -0.0242, -0.0047]), + (808.8, [-0.0113, -0.024, -0.0063]), + (809.6, [-0.0091, -0.0201, -0.0059]), + (811.2, [-0.0006, -0.004, -0.0014]), + (812.0, [0.0027, 0.0008, 0.0006]), + (814.4, [-0.0007, -0.0027, -0.0011]), + (815.2, [0.0009, 0.0001, -0.0001]), + (816.8, [0.0018, 0.0022, 0.001]), + (819.2, [0.0006, -0.0008, -0.0008]), + (821.6, [0.0024, 0.0027, 0.0012]), + (822.4, [0.0004, -0.0004, -0.0003]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0231, 0.0139, 0.0076]), + (0.79943, [0.0243, 0.0172, 0.0098]), + (1.5989, [0.0248, 0.0166, 0.0077]), + (2.3983, [0.0249, 0.0167, 0.0094]), + (3.1977, [0.0244, 0.0153, 0.0066]), + (5.596, [0.0253, 0.0171, 0.0099]), + (7.9943, [0.0237, 0.0156, 0.0076]), + (28.78, [0.0257, 0.0171, 0.0099]), + (120.71, [0.027, 0.0163, 0.008]), + (155.89, [0.0253, 0.0153, 0.0079]), + (194.26, [0.0208, 0.0132, 0.008]), + (276.6, [0.0032, 0.0049, 0.0085]), + (312.58, [-0.0004, 0.0034, 0.0088]), + (405.31, [0.0015, 0.0052, 0.0096]), + (556.41, [0.0095, 0.008, 0.0092]), + (656.33, [0.0049, 0.0063, 0.0093]), + (713.09, [0.0055, 0.0065, 0.0092]), + (753.07, [0.0035, 0.0056, 0.0093]), + (773.05, [0.0047, 0.0063, 0.0094]), + (783.44, [0.0028, 0.0044, 0.0082]), + (793.84, [0.0095, 0.0087, 0.0102]), + (811.42, [0.0114, 0.0093, 0.0099]), + (817.82, [0.0139, 0.0111, 0.0105]), + (825.81, [0.0188, 0.0161, 0.0147]), + (830.61, [0.0187, 0.0149, 0.0132]), + (831.41, [0.0189, 0.0166, 0.0142]), + (833.01, [0.0231, 0.0289, 0.0222]), + (834.61, [0.0301, 0.048, 0.0348]), + (836.21, [0.0386, 0.0696, 0.0512]), + (838.61, [0.0502, 0.0961, 0.0736]), + (840.2, [0.0544, 0.1054, 0.0843]), + (841.8, [0.0567, 0.1085, 0.0906]), + (843.4, [0.0553, 0.1023, 0.0905]), + (845.0, [0.05, 0.0862, 0.0832]), + (847.4, [0.0323, 0.0437, 0.0623]), + (849.0, [0.018, 0.0106, 0.0433]), + (851.4, [-0.0049, -0.0441, 0.0089]), + (852.2, [-0.0108, -0.0591, -0.0019]), + (853.79, [-0.0207, -0.0835, -0.0214]), + (854.59, [-0.0235, -0.0917, -0.0301]), + (856.19, [-0.0253, -0.0995, -0.0448]), + (857.79, [-0.0223, -0.0912, -0.0505]), + (859.39, [-0.014, -0.0689, -0.0487]), + (860.19, [-0.0067, -0.0522, -0.044]), + (861.79, [0.0143, -0.0065, -0.0239]), + (863.39, [0.0376, 0.0451, 0.0062]), + (864.19, [0.0473, 0.0684, 0.0213]), + (864.99, [0.0542, 0.0858, 0.0324]), + (865.79, [0.0572, 0.0944, 0.0372]), + (869.78, [0.0594, 0.0997, 0.0392]), + (872.18, [0.0566, 0.0969, 0.0381]), + (876.98, [0.0443, 0.0784, 0.0278]), + (883.37, [0.0324, 0.0633, 0.0165]), + (886.57, [0.0236, 0.0506, 0.0096]), + (888.17, [0.0237, 0.0484, 0.0105]), + (888.97, [0.0235, 0.0448, 0.0113]), + (889.77, [0.0191, 0.0354, 0.0106]), + (891.37, [0.0052, 0.0095, 0.0044]), + (892.97, [-0.004, -0.0046, -0.0012]), + (894.57, [-0.0029, -0.0027, -0.0019]), + (895.37, [0.0003, 0.0013, -0.0001]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.0463, 0.1169, 0.01]), + (0.79965, [0.0545, 0.1287, 0.0135]), + (2.399, [0.0497, 0.1221, 0.0118]), + (6.3972, [0.0367, 0.1004, 0.0084]), + (12.794, [0.0205, 0.0603, 0.0031]), + (16.793, [0.0139, 0.0372, 0.0019]), + (20.791, [0.0116, 0.0212, 0.0008]), + (27.188, [0.0114, 0.009, -0.0005]), + (33.585, [0.0111, 0.0053, -0.0007]), + (78.366, [0.0114, 0.0061, -0.0001]), + (79.965, [0.0037, 0.002, -0.0001]), + (80.765, [0.003, 0.0019, -0.0001]), + (158.33, [0.0046, 0.0022, -0.0004]), + (159.93, [-0.0036, -0.0019, -0.0004]), + (472.6, [-0.0033, -0.0019, -0.0005]), + (579.75, [-0.0062, -0.0038, -0.001]), + (617.33, [-0.0019, -0.0016, -0.0011]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [0.0014, 0.0172, 0.0169]), + (2.3966, [-0.0024, 0.0133, 0.0137]), + (3.1955, [-0.0011, 0.0139, 0.014]), + (8.7876, [-0.0022, 0.0136, 0.0139]), + (60.714, [0.0002, 0.0153, 0.0152]), + (62.312, [0.0001, 0.0145, 0.0144]), + (63.91, [0.0001, 0.0013, 0.0011]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [0.001, 0.1936, 0.028]), + (0.79862, [0.0017, 0.1937, 0.0284]), + (2.3959, [-0.0035, 0.1821, 0.0277]), + (3.9931, [-0.0051, 0.1697, 0.0246]), + (10.382, [-0.0018, 0.0896, 0.009]), + (13.577, [-0.0003, 0.0603, 0.0048]), + (19.167, [0.0006, 0.0289, 0.0021]), + (24.757, [0.0003, 0.0078, -0.0004]), + (28.75, [0.0006, 0.0013, -0.0012]), + (32.743, [0.0008, -0.0003, -0.0012]), + (71.876, [0.0019, 0.0004, -0.0004]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0006, 0.0111, 0.0038]), + (23.962, [-0.0016, 0.0121, 0.0041]), + (25.559, [-0.0015, 0.0075, 0.0039]), + (27.955, [-0.0013, 0.005, 0.0035]), + (29.553, [-0.001, 0.0012, 0.0001]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [-0.002, 0.0136, 0.0089]), + (1.5824, [-0.001, 0.0148, 0.0083]), + (4.7472, [-0.0022, 0.0132, 0.0061]), + (14.242, [-0.0024, 0.0132, 0.0062]), + (17.406, [-0.0019, 0.0202, 0.0103]), + (18.989, [-0.0031, 0.0208, 0.0098]), + (19.78, [-0.0023, 0.0193, 0.0099]), + (20.571, [-0.002, 0.0135, 0.0066]), + (21.362, [-0.0017, 0.0036, 0.0013]), + (22.154, [-0.002, -0.0011, -0.0006]), + (23.736, [-0.0017, -0.0016, -0.001]), + ], + }, + Radial { t: 0.39897, perp: 0.0, knots: &[(0.0, [0.0035, 0.0017, 0.0004]), (4.6745, [0.0009, 0.0006, -0.0003]), (10.907, [0.0017, 0.0009, 0.0])] }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.2423, 0.1995, 0.1016]), + (0.79212, [0.2299, 0.1921, 0.0981]), + (7.9212, [0.2245, 0.1853, 0.0915]), + (8.7134, [0.2286, 0.1893, 0.0929]), + (9.5055, [0.1373, 0.1145, 0.0555]), + (10.298, [0.0134, 0.0112, 0.0054]), + (11.09, [-0.0006, -0.0007, -0.0006]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.0183, 0.0208, 0.0024]), + (0.79613, [0.0202, 0.0221, 0.0036]), + (42.195, [0.0183, 0.0235, 0.0047]), + (43.787, [0.0208, 0.025, 0.0062]), + (45.379, [0.0179, 0.0236, 0.0048]), + (50.952, [0.0145, 0.0222, 0.0033]), + (63.69, [0.0145, 0.0222, 0.004]), + (64.486, [0.0134, 0.0208, 0.0037]), + (66.078, [0.0044, 0.0063, 0.0004]), + (66.875, [0.0017, 0.0014, -0.0003]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.2163, 0.2893, 0.3745]), + (0.79168, [0.1675, 0.2473, 0.3422]), + (1.5834, [0.1323, 0.2065, 0.3079]), + (2.375, [0.0887, 0.1635, 0.2702]), + (3.1667, [0.0651, 0.1419, 0.2522]), + (3.9584, [0.0613, 0.1373, 0.2487]), + (4.7501, [0.0613, 0.1377, 0.249]), + (6.3335, [0.0552, 0.1244, 0.2422]), + (7.9168, [0.0188, 0.044, 0.198]), + (8.7085, [0.0159, 0.0368, 0.179]), + (11.875, [0.007, 0.0165, 0.0766]), + (12.667, [0.0042, 0.0097, 0.0513]), + (14.25, [0.0012, 0.0025, 0.0028]), + (15.834, [0.0007, 0.0015, 0.0011]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.1553, 0.1199, 0.0165]), + (0.78746, [0.1544, 0.1209, 0.015]), + (1.5749, [0.1149, 0.0925, 0.0088]), + (2.3624, [0.083, 0.0673, 0.0043]), + (3.1498, [0.0574, 0.0446, 0.0023]), + (3.9373, [0.0362, 0.0282, 0.0002]), + (5.5122, [0.0179, 0.0125, 0.0002]), + (7.0871, [0.0082, 0.0061, 0.0]), + (8.662, [0.0029, 0.0013, -0.0001]), + (11.812, [0.0019, 0.001, 0.0]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0041, 0.0417, 0.0555]), + (1.5935, [0.0041, 0.0441, 0.0562]), + (3.9837, [0.0045, 0.0429, 0.0555]), + (4.7804, [0.0007, 0.0183, 0.0246]), + (5.5771, [0.0, -0.0001, -0.0003]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0209, -0.1582, -0.0133]), + (0.78812, [0.023, -0.1589, -0.013]), + (1.5762, [0.0218, -0.1593, -0.0137]), + (4.7287, [0.0178, -0.1469, -0.0118]), + (8.6693, [0.0103, -0.1193, -0.0095]), + (14.186, [0.0035, -0.0739, -0.0057]), + (18.915, [0.0012, -0.0411, -0.003]), + (22.067, [0.0011, -0.0231, -0.001]), + (26.008, [0.0014, -0.0083, 0.0003]), + (29.949, [0.0013, -0.0015, 0.0011]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3088, 0.3142, 0.3163]), + (0.79199, [0.2437, 0.259, 0.2886]), + (1.584, [0.2259, 0.2395, 0.2578]), + (2.376, [0.1773, 0.1859, 0.1995]), + (3.168, [0.1325, 0.1405, 0.1452]), + (3.96, [0.0885, 0.0947, 0.0977]), + (4.752, [0.0529, 0.0571, 0.0569]), + (6.336, [0.0157, 0.0166, 0.0113]), + (7.1279, [0.0072, 0.008, 0.0021]), + (15.048, [0.0126, 0.0125, 0.0044]), + (30.096, [0.0125, 0.0128, 0.0046]), + (30.888, [0.0078, 0.0081, 0.0031]), + (31.68, [0.0, 0.0003, -0.0001]), + ], + }, + Radial { t: 2.5968, perp: 0.0, knots: &[(0.0, [-0.0023, -0.0007, 0.0003]), (0.79014, [0.0006, -0.0003, 0.0])] }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.0043, 0.0034, 0.0032]), + (16.575, [0.0039, 0.0029, 0.0032]), + (18.154, [0.0038, 0.0076, 0.0079]), + (20.522, [0.0038, 0.0068, 0.007]), + (22.1, [0.0039, 0.0115, 0.0118]), + (23.679, [0.004, 0.011, 0.0113]), + (25.257, [0.0041, 0.0156, 0.0158]), + (26.836, [0.0032, 0.0146, 0.0148]), + (28.415, [0.0033, 0.0191, 0.0193]), + (29.993, [0.0039, 0.0197, 0.0199]), + (30.783, [0.0033, 0.0155, 0.0157]), + (31.572, [-0.0003, 0.0027, 0.0028]), + (32.361, [-0.0001, -0.0009, -0.0007]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[(0.0, [0.0, 0.0, 0.0]), (2.3556, [0.0, 0.0, 0.0]), (3.1407, [-0.0028, -0.0015, 0.0]), (19.63, [-0.0022, -0.0012, 0.0])], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0085, 0.0843, 0.0959]), + (0.7998, [0.0025, 0.0488, 0.0552]), + (1.5996, [-0.0001, 0.0246, 0.0274]), + (2.3994, [-0.0001, -0.0017, -0.0018]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0077, -0.0271, -0.0028]), + (3.1726, [-0.0075, -0.0259, -0.0027]), + (11.104, [-0.0029, -0.0093, -0.0013]), + (12.691, [-0.0024, -0.0061, 0.0028]), + (15.07, [-0.0015, -0.0015, 0.0027]), + (15.863, [-0.0012, -0.0002, 0.0002]), + (17.45, [-0.0009, -0.0083, -0.0006]), + (24.588, [-0.0006, -0.0015, 0.0001]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.1823, 0.0785, 0.0089]), + (0.77016, [0.1487, 0.0606, 0.0018]), + (1.5403, [0.1057, 0.0279, -0.0088]), + (2.3105, [0.0788, 0.0061, -0.0129]), + (3.0807, [0.0551, -0.0113, -0.0142]), + (3.8508, [0.0344, -0.024, -0.0157]), + (5.3911, [0.0163, -0.0293, -0.0122]), + (6.1613, [0.01, -0.0266, -0.0103]), + (6.9315, [0.0068, -0.0235, -0.0084]), + (8.4718, [0.0016, -0.0183, -0.0052]), + (9.242, [0.0, -0.0132, -0.0038]), + (10.782, [0.0, -0.0056, -0.0019]), + (12.323, [0.0, -0.0015, -0.0007]), + ], + }, + Radial { t: 1.6547, perp: 0.0, knots: &[(0.0, [-0.0001, 0.0043, 0.0012]), (3.1458, [0.0, 0.0014, 0.0006])] }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0281, 0.0172, 0.0198]), + (11.175, [0.0279, 0.0182, 0.0206]), + (39.912, [0.0278, 0.0157, 0.0187]), + (46.298, [0.0295, 0.0163, 0.0194]), + (47.096, [0.0151, 0.0079, 0.0111]), + (47.895, [-0.0028, -0.0025, 0.0006]), + (48.693, [-0.0053, -0.0035, -0.0003]), + (59.868, [-0.0034, -0.0031, 0.0]), + (62.263, [-0.0043, -0.0048, -0.0004]), + (67.052, [-0.0018, -0.0011, 0.0002]), + ], + }, +]; +const PRIME105_50_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_50_RAYS: &[Ray] = &[ + Ray { angle: 0.01, r0: 6.25, r1: 100.78, fade: 0.05, rgb: [0.0196, 0.0196, 0.0176] }, + Ray { angle: 0.29, r0: 6.25, r1: 100.78, fade: 0.05, rgb: [0.0196, 0.0196, 0.0176] }, + Ray { angle: 3.08, r0: 6.25, r1: 25.781, fade: 0.05, rgb: [0.0157, 0.0118, 0.0108] }, + Ray { angle: 45.93, r0: 14.844, r1: 151.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0098] }, + Ray { angle: 60.36, r0: 6.25, r1: 225.0, fade: 0.05, rgb: [0.0118, 0.0098, 0.0118] }, + Ray { angle: 74.62, r0: 40.625, r1: 156.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 88.96, r0: 6.25, r1: 214.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 96.05, r0: 6.25, r1: 171.88, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 96.59, r0: 6.25, r1: 164.84, fade: 0.05, rgb: [0.0216, 0.0196, 0.0196] }, + Ray { angle: 97.28, r0: 6.25, r1: 110.16, fade: 0.05, rgb: [0.0176, 0.0186, 0.0196] }, + Ray { angle: 97.83, r0: 6.25, r1: 165.62, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 103.18, r0: 61.719, r1: 176.56, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 104.31, r0: 78.906, r1: 264.84, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 118.66, r0: 6.25, r1: 196.09, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 147.11, r0: 12.5, r1: 224.22, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 161.45, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 168.49, r0: 6.25, r1: 147.66, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 168.76, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 183.12, r0: 6.25, r1: 47.656, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 225.7, r0: 49.219, r1: 151.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0098] }, + Ray { angle: 240.25, r0: 6.25, r1: 224.22, fade: 0.05, rgb: [0.0098, 0.0098, 0.0108] }, + Ray { angle: 254.51, r0: 21.094, r1: 156.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 268.94, r0: 96.875, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 269.71, r0: 6.25, r1: 103.12, fade: 0.05, rgb: [0.0255, 0.0412, 0.0176] }, + Ray { angle: 269.99, r0: 6.25, r1: 103.12, fade: 0.05, rgb: [0.0255, 0.0412, 0.0176] }, + Ray { angle: 270.41, r0: 6.25, r1: 110.94, fade: 0.05, rgb: [0.0196, 0.0294, 0.0098] }, + Ray { angle: 275.17, r0: 6.25, r1: 164.06, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 275.45, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 275.93, r0: 6.25, r1: 167.19, fade: 0.05, rgb: [0.0216, 0.0216, 0.0196] }, + Ray { angle: 276.37, r0: 6.25, r1: 105.47, fade: 0.05, rgb: [0.0255, 0.0275, 0.0265] }, + Ray { angle: 283.25, r0: 56.25, r1: 177.34, fade: 0.05, rgb: [0.0078, 0.0059, 0.0059] }, + Ray { angle: 284.37, r0: 92.188, r1: 201.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 298.81, r0: 10.156, r1: 195.31, fade: 0.05, rgb: [0.0127, 0.0118, 0.0137] }, + Ray { angle: 327.28, r0: 11.719, r1: 223.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 341.56, r0: 6.25, r1: 235.94, fade: 0.05, rgb: [0.0235, 0.0235, 0.0216] }, + Ray { angle: 348.81, r0: 6.25, r1: 299.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, +]; +const PRIME105_75_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.4236, 0.6668, 0.9755]), + (0.8, [0.7098, 0.8339, 0.9616]), + (1.6, [0.6471, 0.7915, 0.9629]), + (3.2, [0.6572, 0.8017, 0.9414]), + (9.6, [0.6265, 0.7842, 0.8838]), + (15.2, [0.5821, 0.7373, 0.819]), + (21.6, [0.4875, 0.6473, 0.7279]), + (27.2, [0.4003, 0.5516, 0.641]), + (35.2, [0.3058, 0.4456, 0.5471]), + (40.8, [0.2566, 0.3827, 0.4845]), + (48.8, [0.2006, 0.2936, 0.3867]), + (55.2, [0.1618, 0.2338, 0.2961]), + (57.6, [0.1509, 0.2213, 0.2613]), + (61.6, [0.151, 0.2007, 0.2216]), + (67.2, [0.1602, 0.1723, 0.1552]), + (72.0, [0.1715, 0.1672, 0.1049]), + (75.2, [0.1816, 0.1623, 0.0769]), + (76.0, [0.1905, 0.1625, 0.07]), + (77.6, [0.2261, 0.1617, 0.0594]), + (78.4, [0.2274, 0.1599, 0.0545]), + (80.0, [0.2029, 0.1544, 0.0441]), + (84.8, [0.2204, 0.152, 0.0135]), + (88.8, [0.2372, 0.1523, -0.0155]), + (92.0, [0.2557, 0.156, -0.0305]), + (96.0, [0.2812, 0.1598, -0.0445]), + (104.0, [0.3285, 0.17, -0.0584]), + (108.8, [0.3564, 0.1764, -0.0739]), + (112.0, [0.3732, 0.1827, -0.08]), + (124.8, [0.4333, 0.1986, -0.0917]), + (130.4, [0.4466, 0.2037, -0.0923]), + (131.2, [0.4426, 0.1988, -0.0974]), + (136.8, [0.44, 0.193, -0.105]), + (148.8, [0.418, 0.1749, -0.1097]), + (161.6, [0.3671, 0.1422, -0.114]), + (176.8, [0.268, 0.0811, -0.1244]), + (184.8, [0.2042, 0.0442, -0.1228]), + (193.6, [0.1255, 0.0014, -0.1221]), + (198.4, [0.0778, -0.0227, -0.1227]), + (206.4, [-0.0024, -0.0687, -0.12]), + (229.6, [-0.2545, -0.1987, -0.108]), + (245.6, [-0.4094, -0.2791, -0.0998]), + (259.2, [-0.4997, -0.3252, -0.0908]), + (265.6, [-0.5209, -0.3359, -0.086]), + (272.8, [-0.5298, -0.3397, -0.079]), + (280.8, [-0.5259, -0.3371, -0.0726]), + (288.8, [-0.5065, -0.3262, -0.0658]), + (300.8, [-0.466, -0.3019, -0.0526]), + (317.6, [-0.4116, -0.2686, -0.039]), + (347.2, [-0.3065, -0.1963, -0.0122]), + (367.2, [-0.2364, -0.1469, 0.0016]), + (386.4, [-0.1683, -0.0999, 0.0109]), + (414.4, [-0.0638, -0.0307, 0.0175]), + (430.4, [0.0006, 0.0105, 0.0187]), + (449.6, [0.0821, 0.0617, 0.0184]), + (481.6, [0.2266, 0.1462, 0.0135]), + (490.4, [0.2535, 0.1572, 0.0104]), + (499.2, [0.268, 0.1624, 0.0075]), + (506.4, [0.2703, 0.1624, 0.0049]), + (514.4, [0.2633, 0.1591, 0.0031]), + (522.4, [0.2468, 0.1493, 0.002]), + (529.6, [0.2246, 0.1363, 0.0014]), + (546.4, [0.1508, 0.0942, 0.0017]), + (574.4, [0.0016, 0.0066, 0.0032]), + (580.8, [-0.0302, -0.0115, 0.0029]), + (584.8, [-0.0472, -0.0211, 0.0025]), + (588.8, [-0.0606, -0.0286, 0.0017]), + (595.2, [-0.0725, -0.036, -0.001]), + (599.2, [-0.0721, -0.0365, -0.0036]), + (604.0, [-0.064, -0.0327, -0.007]), + (609.6, [-0.0481, -0.024, -0.0105]), + (616.0, [-0.0257, -0.0112, -0.0138]), + (624.8, [0.0028, 0.0057, -0.0155]), + (632.8, [0.0181, 0.0128, -0.015]), + (636.8, [0.0223, 0.0147, -0.0137]), + (641.6, [0.0219, 0.0125, -0.0124]), + (649.6, [0.0135, 0.0023, -0.0102]), + (655.2, [0.0015, -0.0079, -0.0094]), + (665.6, [-0.029, -0.0309, -0.0117]), + (670.4, [-0.0404, -0.0396, -0.0138]), + (674.4, [-0.0445, -0.0417, -0.0136]), + (678.4, [-0.0423, -0.0389, -0.0128]), + (686.4, [-0.031, -0.0268, -0.0113]), + (689.6, [-0.0243, -0.0187, -0.01]), + (692.8, [-0.0136, -0.0076, -0.0075]), + (696.0, [-0.0003, 0.0031, -0.0044]), + (699.2, [0.0113, 0.0094, -0.0027]), + (700.8, [0.0131, 0.0088, -0.004]), + (702.4, [0.012, 0.0056, -0.0073]), + (706.4, [-0.0024, -0.0092, -0.0194]), + (708.0, [-0.0052, -0.011, -0.0211]), + (708.8, [-0.0044, -0.0099, -0.0208]), + (709.6, [-0.0008, -0.0064, -0.0194]), + (710.4, [0.0058, -0.0007, -0.0171]), + (713.6, [0.0448, 0.0306, -0.0053]), + (714.4, [0.051, 0.0336, -0.0038]), + (715.2, [0.0542, 0.0325, -0.0033]), + (716.0, [0.054, 0.0281, -0.0037]), + (716.8, [0.0494, 0.0211, -0.0049]), + (717.6, [0.0404, 0.0131, -0.0061]), + (719.2, [0.0137, 0.0001, -0.0071]), + (720.0, [0.002, -0.0033, -0.0068]), + (720.8, [-0.0047, -0.004, -0.0063]), + (721.6, [-0.0063, -0.0034, -0.006]), + (722.4, [-0.0035, -0.0019, -0.0061]), + (724.0, [0.0069, 0.001, -0.0081]), + (724.8, [0.0087, 0.0007, -0.0096]), + (725.6, [0.0054, -0.0016, -0.0108]), + (728.0, [-0.0185, -0.0139, -0.0122]), + (728.8, [-0.0221, -0.0165, -0.012]), + (730.4, [-0.0183, -0.0167, -0.0117]), + (732.0, [-0.0105, -0.0129, -0.0117]), + (732.8, [-0.0092, -0.0112, -0.0115]), + (736.0, [-0.0131, -0.0083, -0.0083]), + (738.4, [-0.0053, -0.0059, -0.0101]), + (739.2, [-0.0059, -0.0058, -0.0103]), + (741.6, [-0.0135, -0.0103, -0.0097]), + (744.0, [-0.0105, -0.0128, -0.0125]), + (746.4, [-0.0197, -0.0202, -0.0156]), + (748.8, [-0.0171, -0.0292, -0.0214]), + (751.2, [-0.0235, -0.0364, -0.0244]), + (755.2, [-0.0255, -0.0466, -0.0316]), + (756.8, [-0.0293, -0.0521, -0.0374]), + (758.4, [-0.029, -0.0545, -0.0437]), + (759.2, [-0.0287, -0.054, -0.047]), + (760.8, [-0.0261, -0.0476, -0.0508]), + (763.2, [-0.022, -0.0398, -0.0522]), + (765.6, [-0.0176, -0.0276, -0.0493]), + (769.6, [-0.0068, -0.0067, -0.0307]), + (771.2, [-0.0001, 0.0042, -0.0199]), + (772.8, [0.004, 0.0133, -0.0081]), + (774.4, [0.0082, 0.0223, 0.0008]), + (776.0, [0.0105, 0.0281, 0.0085]), + (777.6, [0.0091, 0.0288, 0.0147]), + (779.2, [0.0067, 0.0237, 0.0144]), + (780.0, [0.0048, 0.019, 0.0126]), + (780.8, [0.002, 0.0124, 0.008]), + (784.0, [-0.0161, -0.0286, -0.0184]), + (785.6, [-0.0212, -0.0434, -0.0271]), + (788.0, [-0.0279, -0.0616, -0.0355]), + (789.6, [-0.0304, -0.0702, -0.0388]), + (792.8, [-0.0342, -0.0795, -0.0396]), + (794.4, [-0.0318, -0.0758, -0.0378]), + (799.2, [-0.0183, -0.0499, -0.025]), + (800.8, [-0.0166, -0.0434, -0.0208]), + (803.2, [-0.0067, -0.0318, -0.0106]), + (804.0, [-0.0062, -0.0318, -0.0089]), + (805.6, [-0.009, -0.0347, -0.0073]), + (808.0, [-0.0066, -0.0307, -0.0072]), + (809.6, [-0.0088, -0.0322, -0.0127]), + (810.4, [-0.0065, -0.0265, -0.0135]), + (811.2, [-0.0043, -0.0167, -0.0097]), + (812.0, [0.0, -0.0019, -0.0026]), + (812.8, [0.0061, 0.011, 0.0019]), + (813.6, [0.0084, 0.0158, 0.0052]), + (814.4, [0.0072, 0.0094, 0.0045]), + (815.2, [0.0016, -0.002, -0.0003]), + (816.0, [0.0, -0.0051, -0.0027]), + (816.8, [0.002, -0.0005, -0.0015]), + (817.6, [0.0008, 0.0017, -0.0002]), + (818.4, [0.0029, 0.0041, 0.0008]), + (820.8, [0.0002, -0.0007, -0.0017]), + (821.6, [0.0024, 0.0029, 0.001]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.1961, 0.1121, 0.0052]), + (0.8, [0.3227, 0.1833, 0.0097]), + (1.6, [0.302, 0.1696, 0.0099]), + (2.4, [0.2898, 0.1649, 0.0098]), + (3.2, [0.288, 0.1612, 0.0101]), + (6.4, [0.2765, 0.1524, 0.0022]), + (10.4, [0.2399, 0.1215, -0.0085]), + (16.8, [0.1962, 0.0881, -0.0207]), + (21.6, [0.1736, 0.0706, -0.0273]), + (29.6, [0.1411, 0.0447, -0.0333]), + (36.0, [0.1152, 0.0272, -0.0357]), + (40.0, [0.1006, 0.018, -0.0328]), + (42.4, [0.0915, 0.0136, -0.0299]), + (47.2, [0.0744, 0.0076, -0.0195]), + (50.4, [0.0643, 0.0055, -0.0103]), + (55.2, [0.0502, 0.0031, 0.0058]), + (60.8, [0.0266, -0.0014, 0.0278]), + (67.2, [-0.0079, -0.0096, 0.0518]), + (74.4, [-0.0504, -0.0249, 0.0751]), + (77.6, [-0.0657, -0.0317, 0.0838]), + (80.0, [-0.085, -0.0359, 0.0903]), + (92.0, [-0.1603, -0.0626, 0.1225]), + (108.8, [-0.2795, -0.1043, 0.1473]), + (114.4, [-0.3155, -0.1178, 0.1518]), + (123.2, [-0.3637, -0.1349, 0.1521]), + (130.4, [-0.3924, -0.149, 0.1471]), + (132.0, [-0.39, -0.1451, 0.1518]), + (136.8, [-0.3904, -0.1426, 0.1551]), + (146.4, [-0.3826, -0.1357, 0.1531]), + (153.6, [-0.3672, -0.1266, 0.151]), + (161.6, [-0.3391, -0.1108, 0.1474]), + (176.0, [-0.2545, -0.0608, 0.1483]), + (188.8, [-0.1563, -0.0084, 0.1403]), + (200.8, [-0.0459, 0.0484, 0.1323]), + (207.2, [0.0183, 0.0823, 0.1274]), + (232.0, [0.2831, 0.2158, 0.1067]), + (245.6, [0.4138, 0.2818, 0.0957]), + (252.8, [0.4684, 0.3089, 0.0895]), + (259.2, [0.5053, 0.3271, 0.0835]), + (265.6, [0.5275, 0.3374, 0.0768]), + (272.0, [0.5366, 0.341, 0.0697]), + (280.0, [0.5336, 0.338, 0.0611]), + (288.8, [0.5131, 0.3256, 0.0519]), + (305.6, [0.454, 0.2894, 0.032]), + (338.4, [0.3381, 0.2123, 0.0004]), + (360.8, [0.2578, 0.156, -0.017]), + (381.6, [0.1834, 0.1041, -0.0287]), + (410.4, [0.0756, 0.0321, -0.037]), + (428.8, [0.0015, -0.0154, -0.0387]), + (450.4, [-0.0911, -0.0737, -0.0384]), + (479.2, [-0.2242, -0.1524, -0.0342]), + (489.6, [-0.2608, -0.1692, -0.0308]), + (500.0, [-0.2799, -0.1759, -0.0273]), + (507.2, [-0.2824, -0.1761, -0.0247]), + (515.2, [-0.2754, -0.1727, -0.0227]), + (523.2, [-0.2589, -0.1628, -0.0216]), + (530.4, [-0.2364, -0.1498, -0.0209]), + (547.2, [-0.1617, -0.1071, -0.0212]), + (576.0, [-0.0058, -0.0156, -0.0227]), + (581.6, [0.0222, 0.0004, -0.0224]), + (585.6, [0.0396, 0.0101, -0.022]), + (589.6, [0.0534, 0.0179, -0.0212]), + (596.0, [0.066, 0.0256, -0.0186]), + (600.0, [0.0661, 0.0265, -0.0159]), + (604.8, [0.0583, 0.0228, -0.0125]), + (610.4, [0.0426, 0.0142, -0.009]), + (616.8, [0.02, 0.0014, -0.0056]), + (621.6, [0.0031, -0.0091, -0.0045]), + (626.4, [-0.0112, -0.017, -0.0037]), + (633.6, [-0.0247, -0.023, -0.004]), + (637.6, [-0.0294, -0.0253, -0.0054]), + (642.4, [-0.0292, -0.0232, -0.0067]), + (650.4, [-0.021, -0.0131, -0.0089]), + (656.8, [-0.007, -0.0012, -0.0098]), + (663.2, [0.0109, 0.0128, -0.0091]), + (666.4, [0.0218, 0.0205, -0.0075]), + (671.2, [0.0335, 0.0295, -0.0055]), + (675.2, [0.0382, 0.0322, -0.0054]), + (679.2, [0.0366, 0.0297, -0.0062]), + (687.2, [0.0251, 0.0176, -0.0077]), + (690.4, [0.0184, 0.0097, -0.009]), + (693.6, [0.0081, -0.0013, -0.0114]), + (696.8, [-0.005, -0.0122, -0.0145]), + (700.0, [-0.0172, -0.019, -0.0164]), + (701.6, [-0.0194, -0.0186, -0.0153]), + (703.2, [-0.0185, -0.0156, -0.0122]), + (707.2, [-0.005, -0.0011, 0.0001]), + (708.8, [-0.0022, 0.001, 0.0021]), + (709.6, [-0.0026, 0.0001, 0.0018]), + (710.4, [-0.0053, -0.0027, 0.0005]), + (711.2, [-0.0109, -0.0078, -0.0017]), + (712.0, [-0.0193, -0.0149, -0.0045]), + (714.4, [-0.0516, -0.041, -0.0142]), + (715.2, [-0.0601, -0.0468, -0.0165]), + (716.0, [-0.0657, -0.0487, -0.0177]), + (716.8, [-0.0681, -0.0465, -0.0178]), + (717.6, [-0.0667, -0.0412, -0.0171]), + (718.4, [-0.0606, -0.0337, -0.0158]), + (719.2, [-0.0503, -0.0258, -0.0146]), + (720.8, [-0.0231, -0.0141, -0.014]), + (721.6, [-0.0131, -0.0118, -0.0144]), + (722.4, [-0.0083, -0.0119, -0.0149]), + (723.2, [-0.0086, -0.0131, -0.0151]), + (725.6, [-0.0237, -0.0182, -0.0124]), + (726.4, [-0.0242, -0.0175, -0.0109]), + (727.2, [-0.0193, -0.0147, -0.0098]), + (728.8, [-0.0026, -0.0067, -0.0086]), + (729.6, [0.0034, -0.0034, -0.0087]), + (730.4, [0.0053, -0.0016, -0.0089]), + (732.0, [-0.0012, -0.003, -0.0089]), + (733.6, [-0.0091, -0.0075, -0.0089]), + (735.2, [-0.0102, -0.0116, -0.0103]), + (736.8, [-0.0076, -0.013, -0.0124]), + (737.6, [-0.0087, -0.0138, -0.0125]), + (740.0, [-0.0179, -0.0176, -0.0114]), + (742.4, [-0.0124, -0.0167, -0.0133]), + (743.2, [-0.0125, -0.0159, -0.0139]), + (744.8, [-0.0167, -0.0158, -0.0136]), + (745.6, [-0.0164, -0.0157, -0.013]), + (747.2, [-0.0101, -0.0127, -0.0119]), + (750.4, [-0.01, 0.0002, -0.0048]), + (752.0, [-0.0046, 0.0047, -0.0024]), + (752.8, [-0.0049, 0.0013, -0.005]), + (754.4, [-0.0126, -0.0122, -0.014]), + (755.2, [-0.0168, -0.0208, -0.0195]), + (756.8, [-0.0244, -0.0437, -0.0358]), + (759.2, [-0.034, -0.0685, -0.0532]), + (761.6, [-0.0401, -0.0858, -0.0632]), + (763.2, [-0.0415, -0.0904, -0.0657]), + (764.8, [-0.0382, -0.0824, -0.0623]), + (766.4, [-0.031, -0.0658, -0.0549]), + (768.8, [-0.0161, -0.0289, -0.0399]), + (772.0, [0.0107, 0.0322, -0.013]), + (772.8, [0.016, 0.0451, -0.0066]), + (774.4, [0.0242, 0.0646, 0.0042]), + (776.0, [0.0264, 0.0721, 0.015]), + (776.8, [0.026, 0.0725, 0.0199]), + (777.6, [0.0249, 0.0698, 0.0217]), + (778.4, [0.0225, 0.0632, 0.0213]), + (780.0, [0.0141, 0.0419, 0.0196]), + (780.8, [0.0081, 0.0274, 0.0166]), + (782.4, [-0.0072, -0.0105, 0.0025]), + (783.2, [-0.0147, -0.029, -0.0071]), + (784.8, [-0.0307, -0.0688, -0.0325]), + (785.6, [-0.036, -0.0809, -0.0405]), + (786.4, [-0.0376, -0.0849, -0.0436]), + (788.8, [-0.0302, -0.0701, -0.0371]), + (792.0, [-0.0253, -0.0579, -0.0314]), + (800.0, [-0.0206, -0.0524, -0.0252]), + (801.6, [-0.0185, -0.0479, -0.0227]), + (804.0, [-0.0112, -0.0381, -0.015]), + (807.2, [-0.0057, -0.0285, -0.0058]), + (808.0, [-0.0059, -0.0297, -0.0064]), + (809.6, [-0.0084, -0.0353, -0.0123]), + (810.4, [-0.0063, -0.028, -0.0109]), + (812.0, [0.0019, -0.0016, -0.0007]), + (812.8, [0.0024, -0.0016, -0.0011]), + (813.6, [0.0041, 0.0028, 0.0005]), + (814.4, [0.0004, -0.005, -0.0022]), + (816.0, [0.0, -0.0021, -0.002]), + (816.8, [0.0029, 0.004, 0.0007]), + (820.0, [0.0011, -0.0002, -0.001]), + (821.6, [0.0024, 0.004, 0.0017]), + (822.4, [0.0017, 0.0009, -0.0008]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0374, 0.0293, 0.017]), + (0.79943, [0.0427, 0.0249, 0.0174]), + (1.5989, [0.0394, 0.0257, 0.0156]), + (3.1977, [0.0391, 0.0231, 0.0132]), + (4.7966, [0.0396, 0.0245, 0.0138]), + (6.3955, [0.0381, 0.023, 0.0132]), + (8.7938, [0.0393, 0.0246, 0.0149]), + (10.393, [0.0386, 0.0236, 0.0122]), + (12.791, [0.0389, 0.0249, 0.0142]), + (16.788, [0.0389, 0.0249, 0.0139]), + (18.387, [0.0401, 0.0265, 0.0162]), + (27.181, [0.0403, 0.0259, 0.0146]), + (40.771, [0.0418, 0.0279, 0.017]), + (72.748, [0.0433, 0.0288, 0.0163]), + (87.938, [0.0465, 0.0311, 0.017]), + (88.737, [0.041, 0.0258, 0.012]), + (89.537, [0.0432, 0.0279, 0.014]), + (92.734, [0.0447, 0.0294, 0.0148]), + (116.72, [0.0484, 0.0307, 0.0151]), + (127.11, [0.0495, 0.0313, 0.0153]), + (127.91, [0.0463, 0.0282, 0.0123]), + (128.71, [0.0481, 0.03, 0.014]), + (134.3, [0.0487, 0.0307, 0.0147]), + (155.89, [0.0481, 0.0302, 0.015]), + (193.46, [0.0411, 0.0266, 0.0155]), + (274.21, [0.0093, 0.0107, 0.0164]), + (308.58, [0.0027, 0.0074, 0.017]), + (555.61, [0.016, 0.0158, 0.0184]), + (656.33, [0.0095, 0.012, 0.0182]), + (702.7, [0.0111, 0.0128, 0.0179]), + (749.07, [0.0095, 0.0114, 0.0178]), + (773.05, [0.0113, 0.0129, 0.0181]), + (783.44, [0.0087, 0.0101, 0.0164]), + (793.84, [0.0164, 0.0167, 0.0198]), + (809.03, [0.0206, 0.0205, 0.0195]), + (817.82, [0.0254, 0.0243, 0.0199]), + (826.61, [0.0322, 0.0328, 0.0253]), + (830.61, [0.0303, 0.0313, 0.0245]), + (831.41, [0.0308, 0.0335, 0.0257]), + (833.01, [0.0366, 0.0481, 0.035]), + (834.61, [0.0453, 0.0702, 0.0499]), + (836.21, [0.0554, 0.0959, 0.069]), + (838.61, [0.0674, 0.126, 0.095]), + (840.2, [0.0708, 0.1358, 0.1077]), + (841.8, [0.0706, 0.1381, 0.1147]), + (843.4, [0.0667, 0.1301, 0.1152]), + (845.0, [0.0583, 0.1108, 0.1085]), + (847.4, [0.0381, 0.0627, 0.0875]), + (849.0, [0.0227, 0.0267, 0.0687]), + (851.4, [-0.0062, -0.0338, 0.0335]), + (853.0, [-0.0205, -0.0662, 0.0101]), + (854.59, [-0.0297, -0.0882, -0.0107]), + (856.19, [-0.0325, -0.0973, -0.0279]), + (856.99, [-0.0315, -0.0955, -0.0333]), + (857.79, [-0.0281, -0.0886, -0.0357]), + (859.39, [-0.0173, -0.064, -0.0355]), + (860.19, [-0.0095, -0.0451, -0.0311]), + (860.99, [0.0008, -0.02, -0.0213]), + (861.79, [0.0115, 0.0063, -0.0078]), + (862.59, [0.0237, 0.0353, 0.009]), + (863.39, [0.0373, 0.0671, 0.029]), + (864.19, [0.049, 0.095, 0.047]), + (864.99, [0.0575, 0.1162, 0.0605]), + (865.79, [0.0608, 0.1262, 0.0671]), + (869.78, [0.0612, 0.1335, 0.0713]), + (873.78, [0.0632, 0.1383, 0.0674]), + (876.98, [0.0531, 0.1226, 0.058]), + (880.18, [0.0415, 0.1014, 0.0477]), + (880.98, [0.04, 0.0962, 0.0441]), + (882.57, [0.0294, 0.0802, 0.0327]), + (884.17, [0.0198, 0.0694, 0.0212]), + (885.77, [0.0187, 0.0665, 0.0135]), + (886.57, [0.0186, 0.0655, 0.012]), + (887.37, [0.0181, 0.0656, 0.0125]), + (888.97, [0.0186, 0.0681, 0.0189]), + (889.77, [0.0181, 0.0624, 0.0206]), + (890.57, [0.0143, 0.0485, 0.019]), + (891.37, [0.0075, 0.0277, 0.0125]), + (892.17, [0.0047, 0.0161, 0.0073]), + (892.97, [-0.0025, -0.0035, -0.0009]), + (894.57, [-0.0053, -0.0099, -0.0048]), + (895.37, [-0.0021, -0.0015, -0.0023]), + (896.16, [0.0006, 0.0025, -0.0003]), + (896.96, [0.0008, 0.0031, 0.0008]), + (898.56, [0.0004, -0.0007, -0.0004]), + (900.96, [-0.0005, -0.0026, -0.0018]), + (901.76, [0.002, 0.0015, -0.0005]), + (902.56, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.0876, 0.2114, 0.0268]), + (0.79965, [0.0759, 0.2127, 0.0253]), + (1.5993, [0.0793, 0.2087, 0.0248]), + (4.7979, [0.0681, 0.1868, 0.0215]), + (11.195, [0.0443, 0.1225, 0.0114]), + (15.993, [0.0323, 0.0758, 0.0067]), + (18.392, [0.0284, 0.0563, 0.005]), + (20.791, [0.0267, 0.0405, 0.0036]), + (23.19, [0.0268, 0.0307, 0.0026]), + (26.389, [0.0268, 0.0213, 0.0017]), + (29.587, [0.0271, 0.016, 0.0014]), + (33.585, [0.0271, 0.0132, 0.0011]), + (78.366, [0.0281, 0.0142, 0.0023]), + (79.166, [0.0231, 0.0116, 0.0022]), + (79.965, [0.0136, 0.0068, 0.0023]), + (80.765, [0.0117, 0.0059, 0.0022]), + (158.33, [0.0123, 0.0062, 0.0017]), + (159.93, [-0.0031, -0.0017, 0.0017]), + (160.73, [-0.0034, -0.0018, 0.0017]), + (482.19, [-0.0037, -0.0022, 0.0012]), + (545.36, [-0.0065, -0.0035, 0.0011]), + (584.55, [-0.0122, -0.0067, -0.0003]), + (598.94, [-0.0109, -0.0073, -0.0016]), + (610.94, [-0.0052, -0.0031, -0.0014]), + (611.74, [-0.004, -0.0013, -0.0002]), + (613.33, [-0.0041, -0.0028, -0.0007]), + (614.93, [-0.0009, 0.0002, 0.0017]), + (616.53, [-0.0029, -0.0015, -0.0004]), + (617.33, [-0.0004, 0.0004, 0.0018]), + (618.13, [-0.0004, 0.0005, 0.0013]), + (618.93, [-0.0033, -0.002, -0.0012]), + (629.33, [-0.0026, -0.0018, -0.0018]), + (638.92, [0.0008, 0.0012, 0.0006]), + (639.72, [-0.0003, -0.0003, -0.0003]), + (640.52, [0.0034, 0.0023, 0.0026]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (7.7937, [0.0, 0.0, 0.0]), + (8.5731, [-0.0262, -0.0345, -0.0554]), + (9.3524, [0.0027, 0.0005, -0.0015]), + (10.132, [0.0014, -0.0011, -0.0021]), + (10.911, [0.0025, 0.0011, -0.0012]), + (18.705, [0.0016, 0.0009, 0.0001]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [-0.0055, 0.0167, 0.0146]), + (0.79887, [-0.0027, 0.0185, 0.0173]), + (1.5977, [-0.0033, 0.0168, 0.0166]), + (3.9944, [-0.0032, 0.0185, 0.0184]), + (4.7932, [-0.0008, 0.0192, 0.019]), + (7.9887, [-0.0023, 0.0178, 0.0178]), + (16.776, [-0.0005, 0.0199, 0.02]), + (19.972, [-0.0016, 0.0196, 0.0196]), + (27.96, [0.0005, 0.0208, 0.0204]), + (60.714, [0.0021, 0.0229, 0.022]), + (62.312, [0.0019, 0.0213, 0.0205]), + (63.111, [0.0016, 0.014, 0.0127]), + (63.91, [0.0018, 0.0039, 0.0026]), + (64.709, [0.0017, 0.001, 0.0]), + (92.669, [0.0016, 0.001, 0.0004]), + (93.468, [-0.0018, -0.0021, -0.0026]), + (94.267, [0.0002, -0.0002, -0.0007]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0168, 0.3203, 0.076]), + (0.79862, [-0.0193, 0.3224, 0.0719]), + (1.5972, [-0.0204, 0.3125, 0.0704]), + (3.1945, [-0.0267, 0.2929, 0.0633]), + (3.9931, [-0.0248, 0.282, 0.0608]), + (5.5903, [-0.0207, 0.2537, 0.0519]), + (9.5834, [-0.0118, 0.1688, 0.0263]), + (11.181, [-0.0087, 0.1379, 0.0183]), + (13.577, [-0.0059, 0.1031, 0.0113]), + (16.771, [-0.0042, 0.0706, 0.0076]), + (21.563, [-0.0032, 0.0325, 0.0031]), + (24.757, [-0.0033, 0.014, 0.0001]), + (28.75, [-0.0027, 0.0029, -0.0016]), + (34.341, [-0.0019, -0.0007, -0.0018]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [0.0028, 0.0219, 0.0083]), + (2.3962, [-0.0025, 0.018, 0.0076]), + (21.566, [-0.0029, 0.0182, 0.0078]), + (23.962, [-0.0031, 0.0207, 0.0102]), + (25.559, [-0.003, 0.0119, 0.0083]), + (27.157, [-0.0027, 0.0089, 0.0071]), + (29.553, [-0.0019, 0.0019, 0.0018]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [-0.0045, 0.0154, 0.0079]), + (0.7912, [-0.0024, 0.0155, 0.0078]), + (1.5824, [-0.0017, 0.0176, 0.0109]), + (9.4944, [-0.0038, 0.0147, 0.0062]), + (13.45, [-0.0024, 0.016, 0.0081]), + (15.033, [-0.0025, 0.0178, 0.008]), + (17.406, [-0.0019, 0.0247, 0.0129]), + (18.989, [-0.0034, 0.0257, 0.013]), + (19.78, [-0.0028, 0.0241, 0.0128]), + (20.571, [-0.0017, 0.0162, 0.0077]), + (21.362, [-0.0022, 0.0047, 0.0031]), + (22.154, [-0.0021, 0.0, 0.0003]), + (22.945, [-0.0018, -0.0013, -0.0005]), + ], + }, + Radial { + t: 0.39897, + perp: 0.0, + knots: &[ + (0.0, [-0.0029, 0.0019, -0.0011]), + (0.77909, [0.0038, 0.0032, 0.0024]), + (1.5582, [0.0024, 0.0018, -0.0009]), + (2.3373, [0.0018, 0.0022, -0.0012]), + (4.6745, [0.0028, 0.0022, 0.0004]), + (28.826, [0.0032, 0.002, -0.0001]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.3289, 0.2775, 0.1415]), + (0.79212, [0.3424, 0.2892, 0.1477]), + (1.5842, [0.3451, 0.2903, 0.1456]), + (7.9212, [0.3329, 0.2784, 0.1365]), + (8.7134, [0.34, 0.2838, 0.1394]), + (9.5055, [0.2019, 0.1687, 0.0826]), + (10.298, [0.0192, 0.0161, 0.0072]), + (11.09, [-0.0008, -0.0008, -0.0008]), + (11.882, [0.0028, 0.0023, 0.0011]), + (12.674, [0.0018, 0.0014, 0.0007]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.029, 0.0388, 0.0106]), + (1.5923, [0.0321, 0.0408, 0.0116]), + (42.195, [0.029, 0.0386, 0.0114]), + (43.787, [0.0332, 0.0408, 0.0137]), + (45.379, [0.0283, 0.0382, 0.0111]), + (50.952, [0.0232, 0.0359, 0.0086]), + (52.544, [0.025, 0.037, 0.0096]), + (63.69, [0.0229, 0.035, 0.0089]), + (64.486, [0.0215, 0.0327, 0.0086]), + (66.078, [0.0066, 0.0099, 0.0025]), + (66.875, [0.002, 0.0022, 0.0005]), + (67.671, [0.0015, 0.0011, 0.0004]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.285, 0.3834, 0.5035]), + (0.79168, [0.255, 0.3605, 0.4789]), + (2.375, [0.1305, 0.2419, 0.3886]), + (3.1667, [0.0984, 0.2125, 0.3659]), + (3.9584, [0.0913, 0.2049, 0.3619]), + (4.7501, [0.0927, 0.2068, 0.3614]), + (5.5418, [0.0892, 0.1997, 0.3586]), + (6.3335, [0.0842, 0.1861, 0.3518]), + (7.9168, [0.0287, 0.0658, 0.2917]), + (8.7085, [0.0242, 0.0547, 0.2632]), + (11.875, [0.0102, 0.0232, 0.1132]), + (12.667, [0.0059, 0.0132, 0.0751]), + (14.25, [0.0014, 0.003, 0.0025]), + (15.834, [0.0009, 0.0018, 0.0012]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[(0.0, [0.0, 0.0, 0.0]), (13.514, [0.0, 0.0, 0.0]), (14.309, [0.0206, 0.0213, 0.0173]), (15.104, [0.001, 0.0004, 0.0003])], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.1954, 0.1632, 0.0255]), + (0.78746, [0.2024, 0.1712, 0.0245]), + (1.5749, [0.1544, 0.1283, 0.0179]), + (2.3624, [0.1135, 0.0947, 0.0092]), + (3.1498, [0.0772, 0.0645, 0.0064]), + (3.9373, [0.0546, 0.0443, 0.0043]), + (4.7247, [0.0378, 0.0307, 0.0024]), + (5.5122, [0.0287, 0.021, 0.0001]), + (6.2997, [0.0201, 0.0153, 0.0001]), + (8.662, [0.0067, 0.0043, -0.0004]), + (10.237, [0.0046, 0.0025, 0.0002]), + (23.624, [0.0031, 0.0013, -0.0002]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0065, 0.0601, 0.0741]), + (0.79673, [0.0071, 0.0643, 0.0798]), + (2.3902, [0.0088, 0.0654, 0.0829]), + (3.9837, [0.0072, 0.0625, 0.0785]), + (4.7804, [0.0033, 0.0279, 0.0362]), + (5.5771, [0.001, 0.0012, 0.0015]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0444, -0.2736, -0.0393]), + (0.78812, [0.0415, -0.2769, -0.0371]), + (2.3644, [0.0408, -0.2724, -0.0349]), + (4.7287, [0.0345, -0.2539, -0.0315]), + (8.6693, [0.0224, -0.2055, -0.0234]), + (12.61, [0.0125, -0.1487, -0.0156]), + (16.551, [0.0071, -0.0983, -0.0105]), + (20.491, [0.0047, -0.0556, -0.0054]), + (23.644, [0.004, -0.0294, -0.0019]), + (26.008, [0.004, -0.016, -0.0004]), + (29.949, [0.0034, -0.0039, 0.0012]), + (33.889, [0.0026, -0.0001, 0.0012]), + (37.83, [0.0018, 0.0006, 0.0007]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3568, 0.3864, 0.4484]), + (0.79199, [0.3339, 0.3567, 0.419]), + (1.584, [0.2988, 0.324, 0.3702]), + (2.376, [0.2393, 0.2557, 0.2866]), + (3.168, [0.1819, 0.1924, 0.2114]), + (3.96, [0.122, 0.132, 0.1437]), + (4.752, [0.078, 0.083, 0.085]), + (5.544, [0.0522, 0.0547, 0.0495]), + (6.336, [0.0251, 0.0264, 0.0178]), + (7.1279, [0.0132, 0.0143, 0.0054]), + (9.5039, [0.0172, 0.0177, 0.007]), + (15.048, [0.0206, 0.0206, 0.0085]), + (20.592, [0.0191, 0.0192, 0.0077]), + (30.096, [0.0208, 0.0212, 0.0087]), + (30.888, [0.0139, 0.0143, 0.0064]), + (31.68, [0.0014, 0.0017, 0.0005]), + ], + }, + Radial { t: 2.5968, perp: 0.0, knots: &[(0.0, [-0.0007, -0.0007, -0.0001]), (4.7409, [0.0019, 0.0016, 0.0005]), (11.852, [0.0009, 0.0014, 0.0002])] }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.0048, 0.0042, 0.0044]), + (13.418, [0.0041, 0.0035, 0.0033]), + (14.997, [0.004, 0.0082, 0.0081]), + (16.575, [0.004, 0.0075, 0.0073]), + (18.154, [0.004, 0.0122, 0.012]), + (19.732, [0.004, 0.0119, 0.0117]), + (20.522, [0.0039, 0.0154, 0.0153]), + (22.1, [0.0041, 0.0153, 0.0152]), + (22.89, [0.0044, 0.0189, 0.0189]), + (23.679, [0.0041, 0.0197, 0.0196]), + (25.257, [0.0087, 0.0241, 0.024]), + (26.047, [0.0072, 0.0226, 0.0226]), + (26.836, [0.0068, 0.0259, 0.0259]), + (29.993, [0.0079, 0.0289, 0.0318]), + (30.783, [0.007, 0.0243, 0.0246]), + (31.572, [0.0008, 0.0065, 0.0066]), + (32.361, [-0.0001, -0.0009, -0.0008]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (2.3556, [0.0, 0.0, 0.0]), + (3.1407, [-0.005, -0.0011, 0.0019]), + (3.9259, [-0.0095, -0.0059, 0.0012]), + (4.7111, [-0.0074, -0.0033, 0.0013]), + (6.2815, [-0.0061, -0.002, 0.001]), + (10.993, [-0.0064, -0.0033, -0.0003]), + (19.63, [-0.0041, -0.0022, 0.0001]), + ], + }, + Radial { t: 0.94074, perp: 0.0, knots: &[(0.0, [0.0121, 0.1415, 0.1429]), (0.7998, [0.007, 0.0728, 0.0826]), (2.3994, [0.001, -0.0004, -0.0002])] }, + Radial { t: 0.87489, perp: 0.0, knots: &[(0.0, [-0.0041, -0.0027, -0.0033]), (0.78535, [0.0027, 0.0014, 0.0016]), (1.5707, [0.0005, -0.0001, -0.0003])] }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0115, -0.0418, -0.0037]), + (2.3795, [-0.0083, -0.0406, -0.0059]), + (4.759, [-0.0067, -0.0383, -0.0046]), + (9.5179, [-0.0036, -0.0218, -0.0028]), + (13.484, [-0.0017, -0.0082, 0.0061]), + (15.863, [-0.0008, -0.0016, 0.0021]), + (17.45, [-0.0001, -0.0128, -0.0007]), + (18.243, [-0.0001, -0.0136, -0.0005]), + (23.002, [0.0001, -0.0044, 0.0002]), + (26.174, [0.0, -0.0013, 0.0005]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.2676, 0.0886, -0.0116]), + (0.77016, [0.2119, 0.0752, -0.0138]), + (1.5403, [0.1631, 0.0277, -0.0249]), + (2.3105, [0.1179, -0.0033, -0.0315]), + (3.0807, [0.083, -0.0234, -0.0329]), + (3.8508, [0.0565, -0.0398, -0.0315]), + (4.621, [0.042, -0.0432, -0.0304]), + (5.3911, [0.0318, -0.0446, -0.0288]), + (6.1613, [0.0204, -0.0428, -0.0251]), + (6.9315, [0.0148, -0.0366, -0.0209]), + (9.242, [0.0038, -0.0212, -0.0099]), + (10.782, [0.0011, -0.0095, -0.0046]), + (12.323, [0.0002, -0.003, -0.0016]), + ], + }, + Radial { t: 1.6547, perp: 0.0, knots: &[(0.0, [-0.0001, 0.0063, 0.0027]), (3.9322, [0.0001, 0.0019, 0.0007])] }, + Radial { t: 2.6079, perp: 0.0, knots: &[(0.0, [-0.0014, 0.0, 0.0006]), (8.696, [-0.0024, -0.0025, -0.0007]), (11.858, [-0.0012, -0.0013, -0.0003])] }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0396, 0.0213, 0.027]), + (0.79824, [0.0424, 0.0219, 0.0279]), + (39.912, [0.0439, 0.0236, 0.0273]), + (46.298, [0.047, 0.0245, 0.0282]), + (47.096, [0.0254, 0.0125, 0.0161]), + (47.895, [-0.0039, -0.0033, 0.0002]), + (48.693, [-0.0073, -0.0062, -0.0026]), + (53.482, [-0.0048, -0.0052, -0.0014]), + (59.868, [-0.0043, -0.0048, -0.0014]), + (62.263, [-0.006, -0.0076, -0.0021]), + (63.859, [-0.0036, -0.004, -0.0011]), + (67.052, [-0.0017, -0.0013, -0.0005]), + ], + }, +]; +const PRIME105_75_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_75_RAYS: &[Ray] = &[ + Ray { angle: 0.22, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0255, 0.0216, 0.0196] }, + Ray { angle: 0.47, r0: 6.25, r1: 121.09, fade: 0.05, rgb: [0.0235, 0.0216, 0.0176] }, + Ray { angle: 7.59, r0: 6.25, r1: 121.88, fade: 0.05, rgb: [0.0157, 0.0157, 0.0137] }, + Ray { angle: 7.9, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0216, 0.0176, 0.0176] }, + Ray { angle: 8.21, r0: 6.25, r1: 125.78, fade: 0.05, rgb: [0.0176, 0.0137, 0.0137] }, + Ray { angle: 45.99, r0: 18.75, r1: 209.38, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 60.39, r0: 6.25, r1: 258.59, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 74.52, r0: 42.969, r1: 157.81, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 89.02, r0: 10.938, r1: 266.41, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 96.11, r0: 6.25, r1: 350.0, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 96.51, r0: 6.25, r1: 193.75, fade: 0.05, rgb: [0.0255, 0.0255, 0.0235] }, + Ray { angle: 97.18, r0: 6.25, r1: 114.84, fade: 0.05, rgb: [0.0216, 0.0206, 0.0216] }, + Ray { angle: 97.66, r0: 107.81, r1: 196.88, fade: 0.05, rgb: [0.0098, 0.0098, 0.0118] }, + Ray { angle: 103.23, r0: 86.719, r1: 192.97, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 104.32, r0: 24.219, r1: 320.31, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 118.66, r0: 16.406, r1: 214.06, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 147.22, r0: 12.5, r1: 257.81, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 161.5, r0: 14.062, r1: 246.88, fade: 0.05, rgb: [0.0255, 0.0255, 0.0255] }, + Ray { angle: 161.79, r0: 14.844, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 168.69, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 183.08, r0: 6.25, r1: 66.406, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 225.78, r0: 20.312, r1: 208.59, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 240.26, r0: 10.938, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 254.46, r0: 42.188, r1: 154.69, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 268.98, r0: 105.47, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 269.68, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0333, 0.0451, 0.0235] }, + Ray { angle: 269.99, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0333, 0.0451, 0.0235] }, + Ray { angle: 270.38, r0: 6.25, r1: 117.97, fade: 0.05, rgb: [0.0216, 0.0363, 0.0098] }, + Ray { angle: 275.22, r0: 6.25, r1: 194.53, fade: 0.05, rgb: [0.0147, 0.0157, 0.0167] }, + Ray { angle: 275.47, r0: 6.25, r1: 193.75, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 276.04, r0: 6.25, r1: 116.41, fade: 0.05, rgb: [0.0353, 0.0333, 0.0333] }, + Ray { angle: 276.48, r0: 6.25, r1: 170.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 276.93, r0: 6.25, r1: 194.53, fade: 0.05, rgb: [0.0127, 0.0118, 0.0137] }, + Ray { angle: 283.3, r0: 85.156, r1: 187.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 284.37, r0: 21.875, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0118, 0.0137] }, + Ray { angle: 291.05, r0: 12.5, r1: 61.719, fade: 0.05, rgb: [0.0118, 0.0157, 0.0137] }, + Ray { angle: 298.81, r0: 18.75, r1: 213.28, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 327.33, r0: 24.219, r1: 259.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 341.59, r0: 14.062, r1: 274.22, fade: 0.05, rgb: [0.0275, 0.0294, 0.0275] }, + Ray { angle: 348.73, r0: 6.25, r1: 369.53, fade: 0.05, rgb: [0.0216, 0.0216, 0.0216] }, +]; +const PRIME105_100_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.1723, 0.4255, 0.7155]), + (0.8, [0.2437, 0.438, 0.6829]), + (3.2, [0.2673, 0.4555, 0.6837]), + (10.4, [0.2552, 0.4548, 0.6555]), + (18.4, [0.2075, 0.4121, 0.5979]), + (23.2, [0.1619, 0.3731, 0.5534]), + (28.8, [0.1087, 0.3104, 0.5011]), + (32.0, [0.0869, 0.2824, 0.4753]), + (37.6, [0.0412, 0.2296, 0.4287]), + (42.4, [0.0116, 0.1917, 0.3933]), + (44.8, [0.0028, 0.1779, 0.3768]), + (48.0, [-0.0132, 0.1549, 0.3566]), + (49.6, [-0.0142, 0.1434, 0.3443]), + (53.6, [-0.0239, 0.1239, 0.319]), + (60.8, [-0.0171, 0.1007, 0.2728]), + (64.0, [-0.0065, 0.1001, 0.2542]), + (67.2, [0.0099, 0.0959, 0.2304]), + (70.4, [0.027, 0.0975, 0.2065]), + (74.4, [0.0479, 0.0917, 0.1813]), + (75.2, [0.0543, 0.0907, 0.176]), + (76.0, [0.0648, 0.0912, 0.171]), + (77.6, [0.1084, 0.0952, 0.1608]), + (78.4, [0.1114, 0.0957, 0.1545]), + (79.2, [0.0963, 0.0951, 0.148]), + (80.0, [0.0861, 0.095, 0.141]), + (80.8, [0.0876, 0.0963, 0.135]), + (88.8, [0.1367, 0.1066, 0.0824]), + (92.0, [0.1589, 0.1119, 0.0684]), + (96.0, [0.1897, 0.1229, 0.0491]), + (100.8, [0.2301, 0.1401, 0.0371]), + (107.2, [0.2827, 0.1631, 0.0148]), + (110.4, [0.3097, 0.1746, 0.008]), + (117.6, [0.3739, 0.2174, -0.0077]), + (120.0, [0.3944, 0.2316, -0.0073]), + (124.8, [0.4369, 0.2567, -0.0011]), + (128.8, [0.4689, 0.2761, 0.001]), + (136.0, [0.5221, 0.3087, 0.0104]), + (144.0, [0.5731, 0.3434, 0.0147]), + (154.4, [0.6301, 0.3771, 0.0296]), + (163.2, [0.6572, 0.3955, 0.0342]), + (166.4, [0.6648, 0.4021, 0.0388]), + (176.8, [0.6752, 0.4135, 0.05]), + (185.6, [0.6659, 0.4021, 0.0628]), + (194.4, [0.638, 0.3838, 0.0694]), + (208.8, [0.5531, 0.3328, 0.0837]), + (225.6, [0.4004, 0.2615, 0.1042]), + (236.0, [0.282, 0.2035, 0.1163]), + (237.6, [0.2574, 0.1891, 0.1122]), + (248.8, [0.1083, 0.1084, 0.1097]), + (250.4, [0.0829, 0.0925, 0.1049]), + (252.8, [0.0506, 0.0733, 0.1031]), + (260.0, [-0.038, 0.0177, 0.1011]), + (265.6, [-0.1019, -0.0238, 0.0999]), + (274.4, [-0.1949, -0.0817, 0.0952]), + (285.6, [-0.2953, -0.1488, 0.0906]), + (296.0, [-0.3716, -0.2002, 0.0878]), + (306.4, [-0.4245, -0.2361, 0.082]), + (319.2, [-0.4638, -0.2633, 0.07]), + (332.8, [-0.4818, -0.2755, 0.0538]), + (351.2, [-0.4704, -0.2741, 0.0259]), + (368.0, [-0.4325, -0.2583, -0.0061]), + (399.2, [-0.3295, -0.2156, -0.0696]), + (416.8, [-0.268, -0.1913, -0.0994]), + (438.4, [-0.1885, -0.1574, -0.1259]), + (452.0, [-0.1367, -0.132, -0.1365]), + (466.4, [-0.0772, -0.0985, -0.1372]), + (484.8, [-0.001, -0.0484, -0.1265]), + (494.4, [0.0336, -0.026, -0.118]), + (505.6, [0.0693, -0.0003, -0.1052]), + (515.2, [0.0957, 0.0215, -0.092]), + (523.2, [0.1133, 0.0359, -0.0806]), + (535.2, [0.1337, 0.0551, -0.062]), + (545.6, [0.1455, 0.0695, -0.0445]), + (562.4, [0.1525, 0.0842, -0.0163]), + (582.4, [0.1443, 0.0918, 0.0163]), + (592.8, [0.1326, 0.0906, 0.0312]), + (603.2, [0.1167, 0.0852, 0.043]), + (612.0, [0.1016, 0.0793, 0.0504]), + (620.0, [0.0857, 0.0716, 0.0536]), + (630.4, [0.0622, 0.0563, 0.051]), + (643.2, [0.0368, 0.0438, 0.0451]), + (655.2, [0.0202, 0.0337, 0.0332]), + (666.4, [0.01, 0.0242, 0.0183]), + (679.2, [0.0078, 0.0179, 0.0011]), + (683.2, [0.0075, 0.0164, -0.0034]), + (689.6, [0.0033, 0.0115, -0.0107]), + (701.6, [-0.0051, -0.0005, -0.0206]), + (709.6, [-0.0174, -0.0163, -0.0271]), + (727.2, [-0.0432, -0.0494, -0.0354]), + (734.4, [-0.0512, -0.0585, -0.0373]), + (740.8, [-0.0518, -0.0598, -0.0377]), + (745.6, [-0.048, -0.0561, -0.0365]), + (751.2, [-0.0371, -0.0452, -0.0317]), + (756.8, [-0.0262, -0.0419, -0.0327]), + (759.2, [-0.0197, -0.038, -0.0321]), + (762.4, [-0.0079, -0.0284, -0.0283]), + (764.8, [0.0032, -0.0172, -0.023]), + (768.0, [0.0199, 0.0017, -0.0139]), + (772.0, [0.0425, 0.0283, -0.0004]), + (774.4, [0.0531, 0.0409, 0.0073]), + (776.8, [0.0606, 0.0473, 0.0136]), + (779.2, [0.0646, 0.0468, 0.0167]), + (780.8, [0.0639, 0.042, 0.0162]), + (782.4, [0.0572, 0.0325, 0.0124]), + (784.8, [0.0365, 0.0183, 0.0081]), + (787.2, [0.0152, 0.018, 0.013]), + (788.8, [0.0085, 0.0214, 0.0154]), + (789.6, [0.0086, 0.0241, 0.0161]), + (791.2, [0.0129, 0.0292, 0.0158]), + (792.8, [0.0156, 0.0302, 0.0127]), + (794.4, [0.0115, 0.0239, 0.0069]), + (797.6, [-0.0054, 0.0028, -0.0068]), + (799.2, [-0.0092, -0.0046, -0.0121]), + (800.8, [-0.0099, -0.009, -0.0155]), + (805.6, [-0.0105, -0.0158, -0.025]), + (808.0, [-0.0159, -0.0191, -0.0299]), + (810.4, [-0.0158, -0.0133, -0.0254]), + (814.4, [-0.0054, 0.0071, -0.0032]), + (815.2, [-0.0051, 0.0097, -0.0004]), + (816.0, [-0.0055, 0.0096, -0.0001]), + (817.6, [-0.0074, 0.0015, -0.0061]), + (818.4, [-0.0097, -0.0063, -0.0111]), + (820.0, [-0.0179, -0.0278, -0.0232]), + (821.6, [-0.0244, -0.0468, -0.0314]), + (823.2, [-0.0282, -0.0586, -0.0352]), + (824.0, [-0.0273, -0.0598, -0.0346]), + (824.8, [-0.0246, -0.0566, -0.0328]), + (825.6, [-0.0209, -0.0497, -0.0303]), + (828.0, [-0.0084, -0.0202, -0.0232]), + (829.6, [-0.005, -0.0083, -0.0195]), + (831.2, [-0.0011, 0.006, -0.0123]), + (833.6, [0.0022, 0.0175, -0.0052]), + (834.4, [0.0029, 0.0176, -0.0045]), + (836.8, [-0.0047, 0.0027, -0.0086]), + (840.8, [-0.0143, -0.0175, -0.0177]), + (841.6, [-0.0154, -0.0194, -0.0187]), + (844.8, [-0.0131, -0.0152, -0.017]), + (849.6, [-0.0123, -0.014, -0.0154]), + (853.6, [-0.015, -0.0166, -0.0148]), + (860.0, [-0.0157, -0.0174, -0.0135]), + (862.4, [-0.0174, -0.0199, -0.0142]), + (864.8, [-0.016, -0.019, -0.0137]), + (869.6, [-0.0188, -0.0204, -0.0139]), + (871.2, [-0.019, -0.0213, -0.0145]), + (873.6, [-0.0156, -0.0188, -0.0125]), + (875.2, [-0.0163, -0.0195, -0.0149]), + (876.8, [-0.0148, -0.0164, -0.0147]), + (878.4, [-0.0131, -0.0163, -0.0151]), + (880.8, [-0.0092, -0.0113, -0.0101]), + (882.4, [-0.0086, -0.0069, -0.0093]), + (884.8, [-0.006, -0.0052, -0.0089]), + (887.2, [-0.0015, 0.003, 0.0011]), + (888.8, [0.0038, 0.0077, 0.0026]), + (889.6, [0.0026, 0.0055, 0.0006]), + (890.4, [-0.0018, 0.0018, -0.0011]), + (892.0, [-0.0013, 0.0038, 0.0003]), + (894.4, [-0.0004, 0.0025, -0.0003]), + (896.0, [-0.004, -0.0005, -0.001]), + (896.8, [-0.0035, 0.0002, -0.0003]), + (897.6, [-0.0002, 0.001, 0.0004]), + (898.4, [-0.0002, -0.0004, -0.0003]), + (899.2, [-0.0034, -0.0025, -0.0009]), + (900.0, [-0.0026, -0.0012, -0.0003]), + (900.8, [0.0011, 0.0009, 0.0005]), + (901.6, [0.0015, 0.0002, 0.0002]), + (902.4, [-0.0016, -0.0015, -0.0008]), + (904.0, [0.0025, 0.002, 0.0008]), + (904.8, [0.0014, 0.0007, 0.0003]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.7996, 0.5774, 0.2793]), + (0.8, [0.6929, 0.5025, 0.2761]), + (2.4, [0.6811, 0.4962, 0.279]), + (20.0, [0.6316, 0.4589, 0.2715]), + (38.4, [0.5463, 0.406, 0.2592]), + (54.4, [0.4521, 0.3494, 0.2551]), + (64.0, [0.3777, 0.3097, 0.2587]), + (76.8, [0.2577, 0.2532, 0.2698]), + (85.6, [0.167, 0.208, 0.2757]), + (93.6, [0.0836, 0.1606, 0.2761]), + (104.8, [-0.0369, 0.089, 0.2664]), + (110.4, [-0.0978, 0.0507, 0.2607]), + (117.6, [-0.1728, -0.0037, 0.2531]), + (126.4, [-0.2618, -0.0664, 0.2315]), + (135.2, [-0.3434, -0.1238, 0.2066]), + (144.0, [-0.4151, -0.1765, 0.1818]), + (152.8, [-0.4771, -0.2223, 0.1553]), + (160.0, [-0.5151, -0.2513, 0.1354]), + (168.8, [-0.5482, -0.2788, 0.1112]), + (177.6, [-0.5636, -0.2941, 0.0882]), + (187.2, [-0.5614, -0.2975, 0.0628]), + (197.6, [-0.5342, -0.285, 0.0378]), + (207.2, [-0.4859, -0.2585, 0.0145]), + (216.0, [-0.4227, -0.2298, -0.006]), + (226.4, [-0.327, -0.1907, -0.0288]), + (236.8, [-0.2145, -0.1411, -0.0519]), + (238.4, [-0.1905, -0.1271, -0.0492]), + (240.8, [-0.1591, -0.1108, -0.0505]), + (249.6, [-0.0479, -0.0543, -0.0602]), + (251.2, [-0.0237, -0.0394, -0.0572]), + (253.6, [0.0075, -0.0214, -0.0575]), + (257.6, [0.0557, 0.0073, -0.059]), + (265.6, [0.1441, 0.0615, -0.0628]), + (276.8, [0.2547, 0.129, -0.0668]), + (288.8, [0.3517, 0.1916, -0.0716]), + (297.6, [0.4078, 0.2275, -0.0741]), + (306.4, [0.4475, 0.2537, -0.0739]), + (315.2, [0.4745, 0.2712, -0.0706]), + (328.0, [0.494, 0.2817, -0.0629]), + (346.4, [0.4863, 0.2784, -0.0453]), + (367.2, [0.4375, 0.2542, -0.0149]), + (400.0, [0.3186, 0.1992, 0.0425]), + (417.6, [0.252, 0.1705, 0.0684]), + (439.2, [0.1673, 0.1321, 0.0913]), + (452.8, [0.1126, 0.1042, 0.0997]), + (467.2, [0.0521, 0.0699, 0.1001]), + (484.0, [-0.0191, 0.0233, 0.0907]), + (494.4, [-0.0576, -0.0015, 0.0818]), + (506.4, [-0.0967, -0.0296, 0.068]), + (516.0, [-0.1234, -0.0516, 0.0548]), + (524.0, [-0.1412, -0.0661, 0.0433]), + (534.4, [-0.1595, -0.0831, 0.0271]), + (545.6, [-0.1731, -0.0991, 0.0082]), + (562.4, [-0.1806, -0.1143, -0.0202]), + (582.4, [-0.1726, -0.1221, -0.0531]), + (603.2, [-0.1448, -0.1157, -0.0805]), + (620.8, [-0.1117, -0.1011, -0.0919]), + (631.2, [-0.0876, -0.0856, -0.0893]), + (644.0, [-0.0615, -0.0724, -0.0832]), + (656.0, [-0.0441, -0.0619, -0.0708]), + (667.2, [-0.0332, -0.052, -0.0555]), + (684.0, [-0.0301, -0.0436, -0.0331]), + (696.0, [-0.0215, -0.0333, -0.0196]), + (702.4, [-0.0168, -0.026, -0.015]), + (710.4, [-0.0044, -0.0098, -0.0081]), + (728.0, [0.0222, 0.0241, 0.0007]), + (735.2, [0.0307, 0.0337, 0.0029]), + (741.6, [0.0317, 0.0355, 0.0034]), + (746.4, [0.0281, 0.032, 0.0025]), + (752.0, [0.0174, 0.0212, -0.0022]), + (757.6, [0.0066, 0.0179, -0.0012]), + (760.0, [0.0001, 0.0141, -0.0017]), + (763.2, [-0.0116, 0.0045, -0.0055]), + (765.6, [-0.0226, -0.0065, -0.0106]), + (772.8, [-0.0623, -0.0526, -0.0335]), + (775.2, [-0.0732, -0.0652, -0.0412]), + (778.4, [-0.0829, -0.0728, -0.049]), + (780.8, [-0.0857, -0.0702, -0.0511]), + (783.2, [-0.0796, -0.0587, -0.0472]), + (785.6, [-0.0604, -0.0441, -0.0422]), + (788.0, [-0.0386, -0.043, -0.0468]), + (789.6, [-0.0304, -0.046, -0.0495]), + (790.4, [-0.0296, -0.0486, -0.0503]), + (792.8, [-0.0357, -0.0561, -0.0497]), + (794.4, [-0.0369, -0.0553, -0.0459]), + (796.0, [-0.0304, -0.0471, -0.0398]), + (799.2, [-0.0129, -0.026, -0.0266]), + (800.8, [-0.0099, -0.0192, -0.0217]), + (806.4, [-0.0115, -0.0109, -0.01]), + (808.8, [-0.0058, -0.0064, -0.0042]), + (810.4, [-0.0034, -0.0083, -0.0055]), + (812.0, [-0.0051, -0.0134, -0.0109]), + (814.4, [-0.0123, -0.0262, -0.0249]), + (816.0, [-0.0146, -0.0342, -0.0334]), + (817.6, [-0.0144, -0.0335, -0.033]), + (819.2, [-0.0124, -0.0227, -0.0249]), + (821.6, [-0.0007, 0.0091, -0.0072]), + (822.4, [0.003, 0.0187, -0.003]), + (824.0, [0.0077, 0.0315, 0.0008]), + (824.8, [0.0077, 0.0343, 0.0005]), + (825.6, [0.0055, 0.0325, -0.0014]), + (826.4, [0.0022, 0.0269, -0.004]), + (828.8, [-0.0108, -0.0035, -0.0122]), + (829.6, [-0.0132, -0.0103, -0.0136]), + (832.8, [-0.0198, -0.0333, -0.022]), + (834.4, [-0.0215, -0.0393, -0.0261]), + (835.2, [-0.0218, -0.0385, -0.0266]), + (838.4, [-0.0171, -0.0269, -0.0235]), + (841.6, [-0.0121, -0.0126, -0.0161]), + (843.2, [-0.01, -0.0091, -0.014]), + (846.4, [-0.0114, -0.013, -0.0152]), + (862.4, [-0.0154, -0.0174, -0.0128]), + (872.0, [-0.0188, -0.0205, -0.0142]), + (874.4, [-0.014, -0.0167, -0.0111]), + (876.0, [-0.0155, -0.0183, -0.0138]), + (878.4, [-0.0154, -0.017, -0.0154]), + (880.8, [-0.0093, -0.0118, -0.0098]), + (881.6, [-0.0084, -0.0109, -0.0094]), + (882.4, [-0.0106, -0.0115, -0.0105]), + (884.8, [-0.0078, -0.0074, -0.0099]), + (887.2, [-0.001, 0.0018, -0.0022]), + (888.8, [0.0023, 0.0058, 0.0007]), + (890.4, [0.0001, 0.0061, 0.0013]), + (892.0, [0.0011, 0.006, 0.0012]), + (895.2, [-0.0046, -0.0008, -0.0009]), + (897.6, [-0.0036, -0.0031, -0.0016]), + (899.2, [-0.0029, -0.0015, -0.0006]), + (900.8, [0.002, 0.0021, 0.001]), + (902.4, [0.0013, 0.0023, 0.0006]), + (903.2, [0.0034, 0.004, 0.0016]), + (904.0, [0.0034, 0.003, 0.0012]), + (904.8, [0.0002, 0.0, -0.0005]), + (906.4, [-0.0017, -0.002, -0.0009]), + (908.0, [-0.0024, -0.0021, -0.0005]), + (908.8, [-0.0004, -0.0001, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0482, 0.034, 0.024]), + (0.79943, [0.0512, 0.0428, 0.028]), + (2.3983, [0.05, 0.0401, 0.0268]), + (23.983, [0.0508, 0.0439, 0.0294]), + (50.364, [0.056, 0.0493, 0.034]), + (135.1, [0.0721, 0.0588, 0.0349]), + (181.47, [0.073, 0.0599, 0.0371]), + (195.86, [0.0709, 0.0592, 0.0386]), + (196.66, [0.0667, 0.0552, 0.0345]), + (197.46, [0.069, 0.0574, 0.0372]), + (211.05, [0.0655, 0.0558, 0.0385]), + (212.65, [0.0623, 0.0528, 0.0357]), + (213.45, [0.0637, 0.0544, 0.0376]), + (236.63, [0.0546, 0.0493, 0.0392]), + (300.59, [0.0253, 0.0302, 0.0406]), + (329.37, [0.0185, 0.0257, 0.0408]), + (362.94, [0.016, 0.0244, 0.0406]), + (493.25, [0.0224, 0.0268, 0.0366]), + (591.58, [0.0272, 0.0298, 0.0371]), + (689.11, [0.0241, 0.0284, 0.0379]), + (807.43, [0.0191, 0.0236, 0.0338]), + (863.39, [0.0196, 0.0255, 0.0351]), + (887.37, [0.0196, 0.0246, 0.033]), + (893.77, [0.0194, 0.0257, 0.0356]), + (899.36, [0.0192, 0.0222, 0.0318]), + (900.96, [0.0179, 0.0192, 0.0307]), + (904.96, [0.0243, 0.0278, 0.0331]), + (916.15, [0.0234, 0.0279, 0.0299]), + (920.95, [0.0281, 0.0315, 0.0284]), + (925.74, [0.0297, 0.0333, 0.0261]), + (929.74, [0.0328, 0.0379, 0.0271]), + (931.34, [0.0313, 0.0376, 0.0271]), + (935.34, [0.0345, 0.0379, 0.0266]), + (937.74, [0.037, 0.0411, 0.0286]), + (940.13, [0.029, 0.035, 0.0239]), + (941.73, [0.0315, 0.0377, 0.029]), + (944.13, [0.0297, 0.033, 0.0307]), + (946.53, [0.0193, 0.0254, 0.0222]), + (947.33, [0.018, 0.0229, 0.0207]), + (948.13, [0.0198, 0.0214, 0.021]), + (948.93, [0.0171, 0.0165, 0.0198]), + (950.53, [0.0147, 0.0133, 0.02]), + (951.33, [0.0125, 0.0105, 0.0174]), + (953.72, [0.0005, -0.0064, 0.0005]), + (954.52, [-0.0025, -0.0113, -0.0031]), + (955.32, [-0.004, -0.0132, -0.0034]), + (956.12, [-0.0039, -0.0126, -0.0023]), + (957.72, [0.0018, -0.0075, 0.0003]), + (959.32, [0.0029, -0.0059, 0.0006]), + (962.52, [0.0074, 0.0027, 0.0024]), + (963.32, [0.0074, 0.0033, 0.0024]), + (964.92, [0.0022, 0.001, 0.0008]), + (966.51, [0.0011, -0.0002, -0.0001]), + (968.11, [-0.0036, -0.0031, -0.0008]), + (971.31, [-0.0011, -0.0002, 0.0004]), + (972.11, [0.0019, 0.0022, 0.0015]), + (972.91, [0.0025, 0.0023, 0.0011]), + (976.11, [-0.0017, -0.0012, -0.0001]), + (978.51, [0.0012, 0.0008, 0.001]), + (980.9, [0.0002, 0.0012, 0.0007]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.1748, 0.357, 0.067]), + (0.79965, [0.1634, 0.3465, 0.0676]), + (1.5993, [0.1644, 0.3453, 0.0654]), + (2.399, [0.1597, 0.3394, 0.0642]), + (4.7979, [0.1442, 0.3123, 0.0579]), + (8.7962, [0.1147, 0.2509, 0.0421]), + (14.394, [0.0794, 0.1632, 0.0238]), + (17.592, [0.0638, 0.1165, 0.014]), + (19.991, [0.055, 0.0874, 0.0077]), + (21.591, [0.051, 0.0722, 0.0049]), + (23.99, [0.0466, 0.0548, 0.001]), + (26.389, [0.0434, 0.0425, -0.0014]), + (29.587, [0.0403, 0.032, -0.0033]), + (32.786, [0.038, 0.0268, -0.0036]), + (37.584, [0.0365, 0.0246, -0.0023]), + (78.366, [0.0402, 0.0271, -0.0006]), + (79.965, [0.0224, 0.0159, -0.0005]), + (80.765, [0.018, 0.0127, -0.0006]), + (158.33, [0.0229, 0.015, 0.0004]), + (159.93, [0.0028, 0.0022, 0.0004]), + (160.73, [-0.0001, 0.0, 0.0004]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0411, 0.3218, 0.1762]), + (0.77937, [0.045, 0.3135, 0.1746]), + (2.3381, [0.0425, 0.3074, 0.1698]), + (3.1175, [0.042, 0.2945, 0.1617]), + (3.8968, [0.0377, 0.2704, 0.1452]), + (4.6762, [0.0341, 0.2268, 0.1179]), + (5.4556, [0.0234, 0.1621, 0.0845]), + (6.235, [0.0156, 0.0874, 0.0412]), + (7.0143, [0.0085, 0.0415, 0.0188]), + (7.7937, [0.0056, 0.0199, 0.0069]), + (8.5731, [0.0052, 0.0016, -0.003]), + (9.3524, [0.0034, -0.0035, -0.0057]), + (14.808, [0.0024, 0.0029, 0.0006]), + (17.925, [0.0007, 0.0012, 0.0001]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [0.0002, 0.0251, 0.0218]), + (0.79887, [0.0019, 0.0242, 0.0206]), + (3.9944, [0.0027, 0.0258, 0.0229]), + (7.1898, [0.0012, 0.0245, 0.0218]), + (27.96, [0.0033, 0.0267, 0.0237]), + (60.714, [0.0024, 0.0267, 0.0244]), + (62.312, [0.0024, 0.0235, 0.0208]), + (63.91, [0.0023, 0.0051, 0.0035]), + (64.709, [0.0022, 0.0016, 0.0]), + (91.071, [0.0019, 0.0013, 0.0004]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [0.0969, 0.5109, 0.1902]), + (0.79862, [0.0915, 0.5172, 0.1886]), + (1.5972, [0.0866, 0.5078, 0.1859]), + (3.9931, [0.0756, 0.464, 0.1653]), + (6.3889, [0.0681, 0.3941, 0.1315]), + (9.5834, [0.0535, 0.2837, 0.0795]), + (11.181, [0.0465, 0.2336, 0.0594]), + (12.778, [0.0408, 0.1933, 0.0452]), + (14.375, [0.0363, 0.1612, 0.036]), + (17.57, [0.0291, 0.1106, 0.0244]), + (21.563, [0.0213, 0.0598, 0.0117]), + (24.757, [0.0158, 0.0293, 0.0033]), + (26.354, [0.0139, 0.0194, 0.0004]), + (27.952, [0.0124, 0.0128, -0.0012]), + (30.347, [0.0108, 0.0074, -0.0022]), + (35.139, [0.0097, 0.0049, -0.0014]), + (52.709, [0.0098, 0.0065, 0.0017]), + (85.452, [0.006, 0.0035, 0.0012]), + (151.74, [0.0019, 0.0015, 0.0005]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0013, 0.0232, 0.0084]), + (0.79873, [0.0019, 0.021, 0.0081]), + (1.5975, [0.0026, 0.023, 0.0089]), + (4.7924, [0.0031, 0.0223, 0.0093]), + (19.968, [0.0023, 0.0222, 0.0098]), + (23.962, [0.0017, 0.0249, 0.0125]), + (25.559, [-0.0005, 0.017, 0.0105]), + (27.955, [-0.0004, 0.011, 0.0078]), + (30.352, [-0.0005, 0.0018, 0.0013]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [0.0001, 0.0165, 0.0084]), + (0.7912, [0.0008, 0.0178, 0.0102]), + (2.3736, [-0.0005, 0.016, 0.0073]), + (4.7472, [-0.0014, 0.0151, 0.0063]), + (9.4944, [0.0003, 0.0161, 0.0079]), + (13.45, [-0.0008, 0.0159, 0.0077]), + (18.989, [-0.0004, 0.0261, 0.0131]), + (19.78, [-0.0006, 0.0229, 0.0114]), + (21.362, [-0.0028, 0.0043, 0.0023]), + (22.154, [-0.0032, -0.0002, -0.0007]), + (22.945, [-0.0029, -0.0023, -0.0015]), + (38.769, [-0.0017, -0.0019, -0.0008]), + ], + }, + Radial { + t: 0.39897, + perp: 0.0, + knots: &[ + (0.0, [-0.0023, -0.0023, -0.0048]), + (0.77909, [0.0015, -0.0008, -0.0014]), + (3.1164, [0.0023, 0.0024, 0.0013]), + (5.4536, [0.0016, 0.0009, 0.0006]), + (17.919, [0.0029, 0.0024, 0.0007]), + (27.268, [0.002, 0.0011, -0.0003]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.4465, 0.3627, 0.1844]), + (1.5842, [0.4356, 0.3631, 0.183]), + (7.1291, [0.4386, 0.3649, 0.1829]), + (8.7134, [0.429, 0.3577, 0.1801]), + (9.5055, [0.234, 0.1969, 0.0995]), + (10.298, [0.0214, 0.018, 0.0095]), + (11.09, [-0.0003, -0.0002, 0.0]), + (11.882, [0.0022, 0.0018, 0.0009]), + (12.674, [0.0013, 0.0011, 0.0005]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.0396, 0.0545, 0.025]), + (0.79613, [0.035, 0.0522, 0.0239]), + (42.195, [0.0343, 0.054, 0.0202]), + (43.787, [0.0364, 0.0552, 0.0212]), + (45.379, [0.0334, 0.0534, 0.0192]), + (50.952, [0.0304, 0.0516, 0.0176]), + (58.117, [0.0315, 0.0527, 0.0186]), + (63.69, [0.0301, 0.0503, 0.0175]), + (64.486, [0.0285, 0.0482, 0.017]), + (65.282, [0.0178, 0.0321, 0.0116]), + (66.078, [0.0052, 0.0101, 0.0037]), + (66.875, [0.0013, 0.0011, 0.0011]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.4091, 0.5462, 0.6575]), + (0.79168, [0.3216, 0.4623, 0.5971]), + (1.5834, [0.2646, 0.4064, 0.5594]), + (2.375, [0.1832, 0.3361, 0.5102]), + (3.1667, [0.1385, 0.2956, 0.482]), + (3.9584, [0.1299, 0.2844, 0.4752]), + (5.5418, [0.1228, 0.2706, 0.4685]), + (6.3335, [0.1116, 0.2491, 0.4571]), + (7.9168, [0.0408, 0.0943, 0.3805]), + (8.7085, [0.0322, 0.0746, 0.3399]), + (12.667, [0.0069, 0.0158, 0.0979]), + (13.459, [0.0041, 0.0089, 0.0498]), + (14.25, [0.0019, 0.0037, 0.0063]), + (15.042, [0.0011, 0.0022, 0.0009]), + (15.834, [0.0009, 0.0016, 0.0011]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[ + (0.0, [0.3209, 0.2539, 0.09]), + (0.79493, [0.2395, 0.2176, 0.0695]), + (1.5899, [0.2186, 0.1736, 0.0548]), + (2.3848, [0.1598, 0.1258, 0.0301]), + (3.1797, [0.1083, 0.0736, 0.016]), + (3.9746, [0.0738, 0.0424, 0.0067]), + (4.7696, [0.0462, 0.021, 0.0007]), + (5.5645, [0.0284, 0.01, 0.0]), + (6.3594, [0.0158, 0.0033, -0.0014]), + (7.1543, [0.0072, 0.0024, 0.0007]), + (7.9493, [0.0013, -0.001, 0.0008]), + (8.7442, [-0.0009, -0.0021, 0.0017]), + (9.5391, [-0.0034, -0.0072, -0.0005]), + (10.334, [-0.0043, -0.0091, -0.0007]), + (11.924, [-0.003, -0.002, 0.0024]), + (13.514, [-0.0004, 0.001, 0.0041]), + (14.309, [-0.0004, 0.007, 0.0064]), + (15.899, [0.0004, 0.0055, 0.0058]), + (16.693, [-0.0003, 0.0015, 0.0028]), + (17.488, [-0.0007, 0.0, 0.0013]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.2133, 0.1779, 0.0435]), + (0.78746, [0.2155, 0.1802, 0.0343]), + (1.5749, [0.1763, 0.1445, 0.0245]), + (2.3624, [0.1287, 0.1069, 0.0164]), + (3.1498, [0.0937, 0.0766, 0.0106]), + (3.9373, [0.064, 0.0516, 0.0066]), + (4.7247, [0.0427, 0.0352, 0.0039]), + (5.5122, [0.0321, 0.0244, 0.0021]), + (6.2997, [0.0211, 0.0168, 0.0011]), + (7.8746, [0.0104, 0.0083, -0.001]), + (10.237, [0.003, 0.0019, -0.0006]), + (23.624, [0.0035, 0.0025, -0.0003]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0091, 0.0723, 0.0975]), + (0.79673, [0.0111, 0.0803, 0.0996]), + (3.1869, [0.0093, 0.0766, 0.0971]), + (3.9837, [0.008, 0.0675, 0.0882]), + (4.7804, [0.0051, 0.0331, 0.0443]), + (5.5771, [0.0007, 0.0042, 0.0056]), + (6.3739, [-0.0006, -0.0008, 0.0001]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [-0.0497, -0.468, -0.1182]), + (0.78812, [-0.0471, -0.4632, -0.1162]), + (1.5762, [-0.0487, -0.4623, -0.1169]), + (3.1525, [-0.048, -0.4485, -0.111]), + (5.5169, [-0.0495, -0.415, -0.0997]), + (7.8812, [-0.0496, -0.3667, -0.0844]), + (12.61, [-0.0401, -0.252, -0.0525]), + (16.551, [-0.0305, -0.1665, -0.0333]), + (20.491, [-0.0203, -0.0942, -0.0161]), + (22.067, [-0.0159, -0.0698, -0.0097]), + (23.644, [-0.012, -0.0494, -0.0046]), + (25.22, [-0.0085, -0.0331, -0.001]), + (26.796, [-0.0054, -0.0209, 0.002]), + (29.949, [-0.0015, -0.0061, 0.0046]), + (31.525, [-0.0004, -0.0022, 0.0047]), + (34.677, [0.001, 0.0015, 0.0042]), + (40.982, [0.0015, 0.0019, 0.0019]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3805, 0.4182, 0.5362]), + (0.79199, [0.3595, 0.4081, 0.5204]), + (1.584, [0.3287, 0.3672, 0.4591]), + (2.376, [0.2577, 0.2877, 0.3567]), + (3.168, [0.2109, 0.2317, 0.268]), + (3.96, [0.1566, 0.1701, 0.1889]), + (4.752, [0.1134, 0.1188, 0.1219]), + (5.544, [0.0734, 0.0764, 0.0718]), + (6.336, [0.04, 0.0414, 0.0322]), + (7.1279, [0.0248, 0.0255, 0.0156]), + (14.256, [0.0282, 0.0282, 0.0162]), + (30.096, [0.027, 0.0273, 0.0158]), + (30.888, [0.0168, 0.017, 0.0111]), + (31.68, [0.0026, 0.0028, 0.0024]), + (32.472, [0.0001, 0.0004, 0.0003]), + ], + }, + Radial { t: 2.5968, perp: 0.0, knots: &[(0.0, [0.0006, 0.0013, 0.0]), (11.852, [0.0003, 0.0015, 0.0003])] }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.0064, 0.0033, 0.0064]), + (0.7893, [0.0032, 0.0036, 0.0045]), + (11.05, [0.004, 0.0033, 0.0045]), + (12.629, [0.004, 0.0078, 0.009]), + (14.207, [0.004, 0.0082, 0.0093]), + (14.997, [0.004, 0.0115, 0.0126]), + (18.154, [0.0038, 0.0153, 0.0164]), + (19.732, [0.0081, 0.0193, 0.0203]), + (21.311, [0.0075, 0.0231, 0.0243]), + (22.89, [0.0078, 0.0273, 0.0241]), + (24.468, [0.0122, 0.0315, 0.0285]), + (26.047, [0.0117, 0.0353, 0.0326]), + (27.625, [0.0155, 0.0391, 0.0375]), + (29.993, [0.0157, 0.0428, 0.0443]), + (30.783, [0.0121, 0.0341, 0.0338]), + (31.572, [0.0025, 0.0096, 0.0098]), + (32.361, [-0.0003, -0.0005, -0.0003]), + ], + }, + Radial { + t: -1.5241, + perp: 0.0, + knots: &[ + (0.0, [-0.0172, 0.1197, 0.1516]), + (0.75, [-0.0209, 0.108, 0.123]), + (1.5, [-0.0273, 0.0548, 0.0719]), + (2.25, [-0.0278, 0.0032, 0.0097]), + (3.0, [-0.0174, 0.0029, 0.0036]), + (3.75, [-0.0262, -0.0047, -0.0013]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0117, 0.0453, 0.0214]), + (0.78519, [0.0058, 0.0357, 0.0206]), + (1.5704, [0.0043, 0.0211, 0.0138]), + (2.3556, [0.0022, 0.0209, 0.0125]), + (3.1407, [0.0021, 0.0155, 0.0097]), + (5.4963, [0.0012, 0.0061, 0.0046]), + (7.0667, [0.0013, 0.0025, 0.0027]), + (10.993, [0.0038, -0.0006, -0.0002]), + (14.133, [0.0047, 0.0039, 0.0021]), + (19.63, [0.0027, 0.0023, 0.0013]), + ], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0092, 0.1292, 0.1522]), + (0.7998, [0.0101, 0.0897, 0.0987]), + (1.5996, [0.0039, 0.0445, 0.05]), + (2.3994, [0.0008, 0.0056, 0.0061]), + (3.1992, [0.0003, 0.0021, 0.0023]), + (3.999, [0.0004, 0.0012, 0.0012]), + ], + }, + Radial { + t: 0.87489, + perp: 0.0, + knots: &[ + (0.0, [0.0005, -0.0014, -0.0024]), + (0.78535, [-0.0004, -0.0016, 0.0]), + (3.1414, [0.0011, 0.0008, 0.0017]), + (7.0682, [-0.0009, -0.0024, -0.0017]), + (7.8535, [-0.0004, -0.001, -0.0008]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0396, -0.0876, -0.0174]), + (3.1726, [-0.0376, -0.0806, -0.0169]), + (6.3453, [-0.0311, -0.0691, -0.014]), + (10.311, [-0.023, -0.0473, -0.0079]), + (13.484, [-0.0178, -0.0264, 0.0082]), + (15.07, [-0.0151, -0.0189, 0.0055]), + (15.863, [-0.014, -0.0186, 0.0019]), + (17.45, [-0.0116, -0.0309, -0.0052]), + (18.243, [-0.0102, -0.0301, -0.005]), + (22.208, [-0.0064, -0.0155, -0.0018]), + (24.588, [-0.0047, -0.0094, -0.0003]), + (30.933, [-0.0017, -0.0016, 0.0011]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.2874, 0.085, -0.0345]), + (0.77016, [0.2325, 0.0609, -0.0424]), + (1.5403, [0.1602, 0.0051, -0.0653]), + (2.3105, [0.1219, -0.0227, -0.0678]), + (3.0807, [0.0787, -0.0543, -0.0732]), + (3.8508, [0.0502, -0.0711, -0.0711]), + (4.621, [0.0288, -0.0768, -0.0677]), + (5.3911, [0.0163, -0.0774, -0.0616]), + (6.1613, [0.0069, -0.0711, -0.054]), + (7.7016, [-0.0006, -0.052, -0.0376]), + (9.242, [-0.0042, -0.0324, -0.0213]), + (10.012, [-0.0038, -0.0232, -0.015]), + (11.552, [-0.0019, -0.009, -0.0057]), + (12.323, [-0.0008, -0.0047, -0.0034]), + ], + }, + Radial { t: 1.6547, perp: 0.0, knots: &[(0.0, [0.0013, 0.0078, 0.0045]), (5.5051, [0.0001, 0.0016, 0.0013])] }, + Radial { t: 2.6079, perp: 0.0, knots: &[(0.0, [-0.0014, -0.0023, -0.0008]), (7.1149, [-0.0005, -0.0017, -0.0004])] }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0625, 0.0414, 0.0381]), + (0.79824, [0.0622, 0.0376, 0.0383]), + (9.5789, [0.0624, 0.0372, 0.0376]), + (45.5, [0.0642, 0.0365, 0.0429]), + (46.298, [0.0633, 0.036, 0.0423]), + (47.096, [0.0356, 0.0204, 0.0238]), + (47.895, [-0.0002, -0.0027, 0.0002]), + (48.693, [-0.0058, -0.0063, -0.0033]), + (59.07, [-0.0038, -0.005, -0.0026]), + (62.263, [-0.005, -0.0071, -0.0033]), + (65.456, [-0.0018, -0.0017, -0.0014]), + ], + }, +]; +const PRIME105_100_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_100_RAYS: &[Ray] = &[ + Ray { angle: 0.29, r0: 9.375, r1: 139.84, fade: 0.05, rgb: [0.0235, 0.0216, 0.0176] }, + Ray { angle: 0.59, r0: 9.375, r1: 130.47, fade: 0.05, rgb: [0.0176, 0.0137, 0.0137] }, + Ray { angle: 3.06, r0: 249.22, r1: 299.22, fade: 0.05, rgb: [0.0039, 0.0059, 0.0059] }, + Ray { angle: 7.3, r0: 8.5938, r1: 89.844, fade: 0.05, rgb: [0.0176, 0.0157, 0.0157] }, + Ray { angle: 7.56, r0: 8.5938, r1: 139.06, fade: 0.05, rgb: [0.0167, 0.0137, 0.0137] }, + Ray { angle: 8.33, r0: 10.938, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 17.47, r0: 16.406, r1: 125.78, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 17.73, r0: 17.188, r1: 132.81, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 24.56, r0: 21.094, r1: 121.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 39.0, r0: 14.844, r1: 89.062, fade: 0.05, rgb: [0.0157, 0.0118, 0.0108] }, + Ray { angle: 46.07, r0: 23.438, r1: 225.78, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 53.69, r0: 17.969, r1: 125.78, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 53.95, r0: 17.969, r1: 92.188, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 60.35, r0: 21.875, r1: 296.09, fade: 0.05, rgb: [0.0127, 0.0137, 0.0118] }, + Ray { angle: 74.59, r0: 86.719, r1: 164.06, fade: 0.05, rgb: [0.0088, 0.0078, 0.0098] }, + Ray { angle: 89.03, r0: 12.5, r1: 316.41, fade: 0.05, rgb: [0.0196, 0.0216, 0.0196] }, + Ray { angle: 95.88, r0: 7.8125, r1: 221.09, fade: 0.05, rgb: [0.0196, 0.0196, 0.0176] }, + Ray { angle: 96.22, r0: 7.8125, r1: 404.69, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 96.9, r0: 7.8125, r1: 100.78, fade: 0.05, rgb: [0.0216, 0.0196, 0.0196] }, + Ray { angle: 97.31, r0: 8.5938, r1: 207.81, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 97.66, r0: 8.5938, r1: 222.66, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 103.24, r0: 95.312, r1: 199.22, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 104.34, r0: 24.219, r1: 373.44, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 110.72, r0: 21.094, r1: 138.28, fade: 0.05, rgb: [0.0216, 0.0216, 0.0196] }, + Ray { angle: 118.66, r0: 18.75, r1: 231.25, fade: 0.05, rgb: [0.0176, 0.0157, 0.0157] }, + Ray { angle: 125.37, r0: 39.844, r1: 106.25, fade: 0.05, rgb: [0.0059, 0.0069, 0.0078] }, + Ray { angle: 147.3, r0: 22.656, r1: 278.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 161.56, r0: 15.625, r1: 246.88, fade: 0.05, rgb: [0.0275, 0.0275, 0.0255] }, + Ray { angle: 168.42, r0: 30.469, r1: 168.75, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 168.69, r0: 30.469, r1: 238.28, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 183.13, r0: 71.094, r1: 231.25, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 197.88, r0: 16.406, r1: 131.25, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 198.16, r0: 16.406, r1: 100.78, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 204.8, r0: 19.531, r1: 121.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 219.44, r0: 17.969, r1: 89.062, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 225.6, r0: 21.875, r1: 128.12, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 225.87, r0: 21.875, r1: 227.34, fade: 0.05, rgb: [0.0118, 0.0098, 0.0098] }, + Ray { angle: 233.36, r0: 21.094, r1: 120.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 240.18, r0: 19.531, r1: 225.0, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 254.66, r0: 42.188, r1: 152.34, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 268.98, r0: 107.81, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0196] }, + Ray { angle: 269.73, r0: 11.719, r1: 128.12, fade: 0.05, rgb: [0.0314, 0.0412, 0.0196] }, + Ray { angle: 269.99, r0: 11.719, r1: 128.12, fade: 0.05, rgb: [0.0314, 0.0412, 0.0196] }, + Ray { angle: 270.24, r0: 10.156, r1: 142.97, fade: 0.05, rgb: [0.0216, 0.0333, 0.0118] }, + Ray { angle: 275.32, r0: 8.5938, r1: 196.09, fade: 0.05, rgb: [0.0137, 0.0157, 0.0137] }, + Ray { angle: 275.63, r0: 8.5938, r1: 181.25, fade: 0.05, rgb: [0.0196, 0.0176, 0.0176] }, + Ray { angle: 276.07, r0: 8.5938, r1: 196.09, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 276.37, r0: 8.5938, r1: 196.09, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 277.18, r0: 101.56, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 283.3, r0: 76.562, r1: 197.66, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 284.4, r0: 22.656, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 290.89, r0: 15.625, r1: 137.5, fade: 0.05, rgb: [0.0196, 0.0176, 0.0157] }, + Ray { angle: 297.64, r0: 160.94, r1: 207.03, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 298.8, r0: 21.875, r1: 222.66, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 327.45, r0: 27.344, r1: 288.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 341.59, r0: 16.406, r1: 311.72, fade: 0.05, rgb: [0.0275, 0.0294, 0.0275] }, + Ray { angle: 348.76, r0: 32.031, r1: 438.28, fade: 0.05, rgb: [0.0255, 0.0255, 0.0255] }, +]; +const PRIME105_125_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.4665, 0.4864, 0.5486]), + (0.8, [0.4652, 0.4948, 0.5513]), + (10.4, [0.474, 0.5028, 0.5494]), + (18.4, [0.4524, 0.4967, 0.5349]), + (43.2, [0.3331, 0.3987, 0.4624]), + (55.2, [0.2931, 0.3506, 0.4225]), + (60.0, [0.2886, 0.3369, 0.4085]), + (65.6, [0.2907, 0.3314, 0.3903]), + (74.4, [0.3071, 0.3299, 0.3622]), + (76.0, [0.3162, 0.3292, 0.3566]), + (77.6, [0.3387, 0.3318, 0.351]), + (78.4, [0.3395, 0.332, 0.3484]), + (80.0, [0.3203, 0.3297, 0.3409]), + (80.8, [0.3198, 0.3278, 0.3372]), + (92.8, [0.3548, 0.3242, 0.286]), + (108.8, [0.4019, 0.332, 0.2219]), + (114.4, [0.415, 0.338, 0.2046]), + (126.4, [0.4502, 0.3455, 0.1828]), + (134.4, [0.4591, 0.3516, 0.1712]), + (144.0, [0.4528, 0.3576, 0.1646]), + (153.6, [0.4355, 0.3511, 0.1679]), + (167.2, [0.3838, 0.3245, 0.1678]), + (182.4, [0.2948, 0.268, 0.1627]), + (192.8, [0.2245, 0.2106, 0.1578]), + (204.8, [0.1331, 0.133, 0.1556]), + (212.0, [0.0697, 0.0808, 0.1512]), + (218.4, [0.014, 0.0308, 0.1543]), + (228.0, [-0.0785, -0.0487, 0.1506]), + (235.2, [-0.1469, -0.1018, 0.1437]), + (238.4, [-0.1754, -0.1219, 0.1435]), + (245.6, [-0.2361, -0.1568, 0.1409]), + (252.8, [-0.2806, -0.1846, 0.138]), + (258.4, [-0.3055, -0.2008, 0.134]), + (264.8, [-0.3187, -0.2047, 0.1318]), + (272.8, [-0.3145, -0.1969, 0.1257]), + (279.2, [-0.2888, -0.179, 0.1182]), + (288.0, [-0.2321, -0.1407, 0.1117]), + (295.2, [-0.1771, -0.1002, 0.1067]), + (304.0, [-0.1141, -0.0594, 0.088]), + (311.2, [-0.0657, -0.0212, 0.0798]), + (314.4, [-0.0468, -0.0052, 0.0774]), + (321.6, [-0.0082, 0.0254, 0.068]), + (332.8, [0.0504, 0.0767, 0.0538]), + (340.8, [0.081, 0.1036, 0.0411]), + (353.6, [0.123, 0.1372, 0.0309]), + (361.6, [0.14, 0.1531, 0.0244]), + (368.8, [0.151, 0.1622, 0.0173]), + (380.8, [0.1541, 0.1631, 0.0008]), + (384.0, [0.1563, 0.1613, -0.0006]), + (396.8, [0.156, 0.1374, -0.0087]), + (406.4, [0.149, 0.115, -0.0152]), + (409.6, [0.1418, 0.1062, -0.0183]), + (420.0, [0.1131, 0.0798, -0.0193]), + (430.4, [0.0773, 0.0485, -0.0226]), + (444.8, [0.0305, 0.0094, -0.0221]), + (451.2, [0.0095, -0.0109, -0.0249]), + (461.6, [-0.0171, -0.0429, -0.0261]), + (468.0, [-0.0305, -0.0577, -0.0245]), + (480.0, [-0.0541, -0.0756, -0.0225]), + (484.8, [-0.0668, -0.0863, -0.0258]), + (492.0, [-0.0804, -0.0971, -0.0244]), + (500.8, [-0.0933, -0.1028, -0.0208]), + (505.6, [-0.1029, -0.1084, -0.0243]), + (516.8, [-0.1156, -0.1087, -0.0244]), + (527.2, [-0.1253, -0.1114, -0.0308]), + (543.2, [-0.1215, -0.0985, -0.0376]), + (551.2, [-0.1112, -0.0854, -0.0385]), + (559.2, [-0.0939, -0.0675, -0.0379]), + (569.6, [-0.0634, -0.0398, -0.0367]), + (576.8, [-0.0383, -0.0186, -0.0358]), + (582.4, [-0.0154, 0.0002, -0.0338]), + (595.2, [0.0449, 0.0504, -0.0215]), + (603.2, [0.0774, 0.0755, -0.0154]), + (612.0, [0.1019, 0.0928, -0.0118]), + (619.2, [0.11, 0.096, -0.0101]), + (626.4, [0.1069, 0.0874, -0.0106]), + (630.4, [0.0979, 0.0744, -0.0131]), + (633.6, [0.0928, 0.0688, -0.0127]), + (639.2, [0.0772, 0.0513, -0.0137]), + (644.8, [0.0573, 0.0305, -0.015]), + (652.0, [0.0254, -0.0006, -0.0177]), + (664.8, [-0.045, -0.0631, -0.0283]), + (671.2, [-0.0762, -0.0886, -0.0338]), + (676.8, [-0.095, -0.098, -0.0366]), + (687.2, [-0.1143, -0.096, -0.0442]), + (690.4, [-0.1125, -0.0904, -0.0457]), + (695.2, [-0.1002, -0.0741, -0.0447]), + (700.8, [-0.073, -0.0491, -0.0432]), + (705.6, [-0.0495, -0.037, -0.0524]), + (707.2, [-0.0336, -0.0289, -0.0541]), + (708.8, [-0.0102, -0.0157, -0.0531]), + (711.2, [0.037, 0.0118, -0.0469]), + (712.0, [0.0517, 0.0199, -0.0447]), + (712.8, [0.063, 0.0256, -0.0428]), + (713.6, [0.0705, 0.0291, -0.0411]), + (715.2, [0.0754, 0.0302, -0.0385]), + (716.8, [0.0685, 0.0257, -0.0372]), + (718.4, [0.0525, 0.0159, -0.0368]), + (723.2, [-0.0123, -0.0254, -0.0388]), + (725.6, [-0.0398, -0.0404, -0.0402]), + (727.2, [-0.0506, -0.0455, -0.0403]), + (729.6, [-0.0559, -0.0465, -0.0386]), + (734.4, [-0.0554, -0.0385, -0.0329]), + (736.8, [-0.0491, -0.0351, -0.033]), + (739.2, [-0.0459, -0.036, -0.035]), + (744.8, [-0.0504, -0.0445, -0.0434]), + (750.4, [-0.0628, -0.0632, -0.0521]), + (756.8, [-0.0668, -0.0789, -0.0598]), + (759.2, [-0.0659, -0.0791, -0.0699]), + (762.4, [-0.0626, -0.0732, -0.0787]), + (765.6, [-0.0547, -0.0542, -0.0805]), + (769.6, [-0.0391, -0.0249, -0.0674]), + (772.0, [-0.0268, -0.0032, -0.0499]), + (774.4, [-0.0175, 0.0135, -0.0359]), + (775.2, [-0.0152, 0.0174, -0.0313]), + (777.6, [-0.0099, 0.022, -0.0153]), + (778.4, [-0.0095, 0.0208, -0.0133]), + (780.0, [-0.0128, 0.013, -0.0144]), + (780.8, [-0.0159, 0.0057, -0.0185]), + (781.6, [-0.02, -0.005, -0.0254]), + (784.0, [-0.0294, -0.0305, -0.042]), + (785.6, [-0.0331, -0.0415, -0.0487]), + (788.8, [-0.0376, -0.0543, -0.0547]), + (792.0, [-0.0368, -0.0588, -0.0561]), + (795.2, [-0.0373, -0.0586, -0.0561]), + (799.2, [-0.0332, -0.0506, -0.0507]), + (801.6, [-0.0278, -0.0435, -0.0441]), + (803.2, [-0.0267, -0.042, -0.0392]), + (806.4, [-0.0212, -0.0351, -0.0248]), + (808.8, [-0.0074, -0.0226, -0.0094]), + (811.2, [0.0028, -0.0072, 0.0001]), + (812.0, [0.0086, -0.0028, 0.0025]), + (812.8, [0.0117, 0.0001, 0.0031]), + (813.6, [0.0106, 0.0005, 0.0016]), + (815.2, [-0.0014, -0.0059, -0.0036]), + (816.0, [-0.006, -0.0061, -0.0039]), + (816.8, [-0.0023, 0.001, -0.0014]), + (817.6, [0.0068, 0.0099, 0.0022]), + (818.4, [0.0085, 0.011, 0.0026]), + (819.2, [-0.002, 0.0032, -0.001]), + (820.0, [-0.0052, -0.0013, -0.0021]), + (820.8, [0.0032, 0.0026, 0.0012]), + (821.6, [-0.0025, 0.0009, 0.0013]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.4669, 0.4406, 0.3897]), + (0.8, [0.4529, 0.4326, 0.3854]), + (14.4, [0.4433, 0.4205, 0.381]), + (28.0, [0.4222, 0.3965, 0.3632]), + (45.6, [0.3763, 0.3496, 0.3384]), + (55.2, [0.3452, 0.3262, 0.3326]), + (68.8, [0.2846, 0.291, 0.3334]), + (78.4, [0.2358, 0.257, 0.3313]), + (93.6, [0.1416, 0.203, 0.3352]), + (104.8, [0.0713, 0.1568, 0.3282]), + (112.0, [0.0302, 0.1308, 0.3255]), + (124.0, [-0.0318, 0.0926, 0.3114]), + (130.4, [-0.0572, 0.0723, 0.3011]), + (135.2, [-0.0693, 0.0597, 0.293]), + (139.2, [-0.0763, 0.0491, 0.286]), + (148.0, [-0.0824, 0.0317, 0.2704]), + (153.6, [-0.0793, 0.0252, 0.2568]), + (158.4, [-0.0724, 0.0232, 0.2459]), + (167.2, [-0.0529, 0.0262, 0.2284]), + (174.4, [-0.0276, 0.0363, 0.2168]), + (183.2, [0.015, 0.0585, 0.2018]), + (194.4, [0.0752, 0.1013, 0.1832]), + (205.6, [0.1449, 0.1584, 0.1657]), + (219.2, [0.2467, 0.2406, 0.143]), + (232.0, [0.3513, 0.3254, 0.124]), + (237.6, [0.3962, 0.3554, 0.1164]), + (250.4, [0.4765, 0.3978, 0.0981]), + (256.8, [0.5029, 0.4084, 0.0904]), + (262.4, [0.5129, 0.4089, 0.0829]), + (271.2, [0.5024, 0.3932, 0.0741]), + (280.0, [0.4612, 0.3565, 0.0656]), + (288.0, [0.3983, 0.3086, 0.0619]), + (297.6, [0.3084, 0.2422, 0.0571]), + (304.8, [0.2468, 0.1962, 0.0577]), + (312.8, [0.1821, 0.1435, 0.0543]), + (328.0, [0.0827, 0.0564, 0.0539]), + (330.4, [0.0668, 0.0417, 0.0517]), + (336.0, [0.0339, 0.0117, 0.0501]), + (340.8, [0.01, -0.0098, 0.0507]), + (346.4, [-0.016, -0.0325, 0.0494]), + (356.0, [-0.0557, -0.0681, 0.046]), + (362.4, [-0.0761, -0.0877, 0.0441]), + (368.8, [-0.092, -0.1039, 0.0429]), + (373.6, [-0.0982, -0.1101, 0.0455]), + (377.6, [-0.1054, -0.1161, 0.0441]), + (381.6, [-0.1091, -0.1166, 0.046]), + (390.4, [-0.1207, -0.1128, 0.0414]), + (400.0, [-0.1243, -0.0987, 0.0407]), + (404.0, [-0.1257, -0.0933, 0.037]), + (412.0, [-0.1152, -0.0785, 0.0356]), + (422.4, [-0.0915, -0.0605, 0.029]), + (445.6, [-0.0282, -0.0101, 0.0147]), + (452.0, [-0.0106, 0.0067, 0.0127]), + (457.6, [0.0001, 0.0203, 0.0069]), + (463.2, [0.0115, 0.0347, 0.0038]), + (468.8, [0.0203, 0.0444, -0.001]), + (480.8, [0.0381, 0.0563, -0.0097]), + (486.4, [0.0498, 0.0657, -0.0091]), + (492.8, [0.0586, 0.072, -0.0139]), + (501.6, [0.0678, 0.0739, -0.0217]), + (506.4, [0.0751, 0.0772, -0.0204]), + (517.6, [0.0833, 0.073, -0.0253]), + (524.8, [0.0891, 0.0734, -0.0227]), + (533.6, [0.0882, 0.0666, -0.0223]), + (542.4, [0.0819, 0.0559, -0.0211]), + (551.2, [0.069, 0.0396, -0.0226]), + (557.6, [0.0544, 0.0243, -0.0245]), + (570.4, [0.0146, -0.0125, -0.0282]), + (576.8, [-0.0095, -0.0331, -0.0305]), + (581.6, [-0.0301, -0.05, -0.0322]), + (595.2, [-0.0961, -0.1055, -0.0468]), + (604.0, [-0.1335, -0.1346, -0.054]), + (613.6, [-0.1609, -0.1538, -0.058]), + (620.8, [-0.1686, -0.1565, -0.0596]), + (627.2, [-0.166, -0.1488, -0.0592]), + (631.2, [-0.1573, -0.1358, -0.0568]), + (634.4, [-0.1522, -0.1302, -0.0571]), + (645.6, [-0.1172, -0.092, -0.0549]), + (653.6, [-0.0814, -0.0571, -0.0519]), + (666.4, [-0.0103, 0.0059, -0.041]), + (672.0, [0.017, 0.0283, -0.0361]), + (675.2, [0.029, 0.0359, -0.0343]), + (677.6, [0.0363, 0.0386, -0.0332]), + (688.0, [0.0556, 0.0363, -0.0257]), + (691.2, [0.054, 0.0308, -0.0241]), + (696.0, [0.0415, 0.0144, -0.0251]), + (701.6, [0.0142, -0.0109, -0.0268]), + (704.8, [-0.0001, -0.0193, -0.0214]), + (706.4, [-0.0095, -0.0235, -0.0177]), + (708.0, [-0.0252, -0.0316, -0.0157]), + (709.6, [-0.0487, -0.0449, -0.0167]), + (713.6, [-0.1293, -0.0913, -0.0276]), + (715.2, [-0.1476, -0.1001, -0.0313]), + (716.8, [-0.1518, -0.1009, -0.0337]), + (719.2, [-0.137, -0.0915, -0.0352]), + (724.0, [-0.0766, -0.0517, -0.0337]), + (726.4, [-0.0491, -0.036, -0.0324]), + (728.0, [-0.0375, -0.0301, -0.0321]), + (729.6, [-0.0329, -0.0283, -0.0328]), + (732.0, [-0.0327, -0.0301, -0.035]), + (734.4, [-0.0353, -0.0352, -0.0384]), + (738.4, [-0.0463, -0.0423, -0.0395]), + (740.8, [-0.0507, -0.0424, -0.0379]), + (744.0, [-0.0508, -0.0416, -0.036]), + (747.2, [-0.0494, -0.0373, -0.0316]), + (751.2, [-0.0398, -0.0217, -0.0253]), + (752.0, [-0.0394, -0.0214, -0.0258]), + (752.8, [-0.0414, -0.0256, -0.029]), + (754.4, [-0.0487, -0.0403, -0.0396]), + (755.2, [-0.0536, -0.0502, -0.0466]), + (756.8, [-0.0647, -0.0747, -0.0656]), + (759.2, [-0.0766, -0.1026, -0.0842]), + (761.6, [-0.0823, -0.118, -0.0925]), + (763.2, [-0.082, -0.1175, -0.0944]), + (765.6, [-0.0733, -0.1007, -0.0877]), + (767.2, [-0.0614, -0.077, -0.078]), + (769.6, [-0.042, -0.0338, -0.063]), + (772.0, [-0.0156, 0.0166, -0.0455]), + (773.6, [-0.0043, 0.0417, -0.0366]), + (775.2, [0.0044, 0.0599, -0.0254]), + (776.8, [0.0091, 0.0675, -0.0135]), + (777.6, [0.0091, 0.0668, -0.0109]), + (778.4, [0.0066, 0.0605, -0.0109]), + (780.0, [-0.0022, 0.0392, -0.0088]), + (780.8, [-0.0078, 0.0253, -0.0099]), + (782.4, [-0.0222, -0.0105, -0.0195]), + (783.2, [-0.0294, -0.0282, -0.0274]), + (784.0, [-0.0388, -0.0505, -0.0408]), + (784.8, [-0.046, -0.0686, -0.0528]), + (785.6, [-0.0506, -0.0801, -0.0612]), + (786.4, [-0.0516, -0.0832, -0.0638]), + (788.8, [-0.0422, -0.0662, -0.0544]), + (791.2, [-0.0336, -0.0519, -0.0489]), + (794.4, [-0.028, -0.0428, -0.0465]), + (796.0, [-0.0277, -0.0418, -0.0467]), + (800.0, [-0.0321, -0.0457, -0.0468]), + (802.4, [-0.0315, -0.0457, -0.045]), + (804.8, [-0.0282, -0.043, -0.0394]), + (806.4, [-0.0259, -0.041, -0.0336]), + (807.2, [-0.0216, -0.0377, -0.0288]), + (808.0, [-0.0154, -0.0321, -0.0222]), + (809.6, [-0.0072, -0.0199, -0.0094]), + (812.0, [0.013, -0.0018, 0.0051]), + (812.8, [0.0105, -0.0022, 0.0053]), + (813.6, [0.0069, -0.0058, 0.0031]), + (814.4, [0.0011, -0.0079, 0.0003]), + (815.2, [0.001, -0.0057, -0.0002]), + (816.0, [0.0001, -0.0004, -0.0017]), + (816.8, [0.002, 0.0037, -0.0038]), + (818.4, [-0.0022, 0.0002, -0.0077]), + (819.2, [-0.0063, -0.0023, -0.0063]), + (820.0, [-0.0037, -0.0003, -0.0011]), + (820.8, [0.004, 0.0046, 0.0053]), + (821.6, [0.0048, 0.0094, 0.0075]), + (822.4, [0.0003, 0.0006, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0912, 0.0817, 0.0672]), + (0.79943, [0.0789, 0.0732, 0.0614]), + (1.5989, [0.084, 0.0758, 0.0643]), + (3.1977, [0.0817, 0.0734, 0.0619]), + (11.192, [0.0818, 0.0733, 0.0619]), + (28.78, [0.084, 0.078, 0.0659]), + (73.548, [0.0853, 0.0813, 0.0692]), + (127.91, [0.0891, 0.0829, 0.0682]), + (183.07, [0.0849, 0.0811, 0.0698]), + (203.06, [0.0795, 0.0769, 0.07]), + (267.01, [0.0523, 0.0578, 0.0713]), + (291.79, [0.0444, 0.0527, 0.0716]), + (306.18, [0.0427, 0.0515, 0.072]), + (408.51, [0.0525, 0.0598, 0.0726]), + (560.4, [0.0535, 0.0601, 0.0731]), + (625.96, [0.0571, 0.0635, 0.0732]), + (681.92, [0.0634, 0.0686, 0.0743]), + (753.07, [0.0627, 0.067, 0.0742]), + (782.65, [0.0664, 0.0693, 0.0734]), + (794.64, [0.0814, 0.0787, 0.0765]), + (814.62, [0.0985, 0.085, 0.0775]), + (826.61, [0.1069, 0.093, 0.082]), + (830.61, [0.1069, 0.0915, 0.0816]), + (831.41, [0.1081, 0.0942, 0.0834]), + (833.01, [0.1153, 0.1104, 0.0943]), + (836.21, [0.1365, 0.1603, 0.1287]), + (838.61, [0.1486, 0.1909, 0.1541]), + (841.0, [0.1524, 0.2035, 0.1721]), + (843.4, [0.1467, 0.1922, 0.1768]), + (845.0, [0.1355, 0.1689, 0.1722]), + (848.2, [0.1001, 0.094, 0.1472]), + (851.4, [0.0524, 0.0026, 0.1084]), + (852.2, [0.0423, -0.0177, 0.0976]), + (853.79, [0.0257, -0.0514, 0.0775]), + (854.59, [0.0192, -0.0638, 0.0674]), + (855.39, [0.0137, -0.073, 0.057]), + (856.19, [0.0101, -0.0784, 0.0463]), + (856.99, [0.0089, -0.0785, 0.0379]), + (857.79, [0.0098, -0.073, 0.033]), + (859.39, [0.0176, -0.0494, 0.0266]), + (860.19, [0.0248, -0.0314, 0.0283]), + (861.79, [0.0455, 0.0191, 0.0464]), + (862.59, [0.0567, 0.0473, 0.0614]), + (863.39, [0.0692, 0.0779, 0.0803]), + (864.19, [0.0791, 0.1037, 0.0974]), + (864.99, [0.0863, 0.1225, 0.1104]), + (865.79, [0.0885, 0.1297, 0.1156]), + (872.98, [0.071, 0.111, 0.1077]), + (877.78, [0.0708, 0.106, 0.1053]), + (880.18, [0.069, 0.1034, 0.101]), + (881.77, [0.0649, 0.0977, 0.0953]), + (884.17, [0.0591, 0.0921, 0.0823]), + (886.57, [0.0484, 0.0816, 0.0612]), + (888.17, [0.0326, 0.0647, 0.0378]), + (889.77, [0.0119, 0.0423, 0.0171]), + (890.57, [0.0032, 0.0313, 0.0091]), + (891.37, [-0.008, 0.02, 0.0014]), + (892.17, [-0.0108, 0.0152, -0.0017]), + (892.97, [-0.0127, 0.0148, -0.0017]), + (894.57, [-0.0059, 0.0132, 0.0039]), + (895.37, [0.001, 0.0112, 0.0083]), + (896.96, [0.0101, 0.0107, 0.0142]), + (897.76, [0.0061, 0.003, 0.0125]), + (898.56, [0.0021, -0.0017, 0.0082]), + (899.36, [0.0021, -0.0017, 0.004]), + (900.16, [0.0055, 0.0018, 0.0013]), + (900.96, [0.0022, -0.0019, -0.0019]), + (901.76, [0.0016, 0.003, 0.0025]), + (902.56, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.1151, 0.2719, 0.0433]), + (0.79965, [0.1303, 0.2853, 0.0521]), + (1.5993, [0.1226, 0.2764, 0.0464]), + (2.399, [0.1206, 0.2723, 0.0469]), + (4.7979, [0.1039, 0.2451, 0.0412]), + (7.9965, [0.0796, 0.1946, 0.029]), + (12.794, [0.0495, 0.1239, 0.0173]), + (17.592, [0.03, 0.0591, 0.0033]), + (19.991, [0.024, 0.0341, -0.0015]), + (23.19, [0.0202, 0.0118, -0.0066]), + (26.389, [0.0193, 0.0011, -0.0095]), + (29.587, [0.0193, -0.0029, -0.0106]), + (33.585, [0.0206, -0.0011, -0.0095]), + (40.782, [0.0241, 0.0093, -0.0055]), + (47.979, [0.0272, 0.014, -0.004]), + (77.566, [0.0341, 0.0196, -0.0043]), + (78.366, [0.0338, 0.0189, -0.0042]), + (79.166, [0.0265, 0.0142, -0.0043]), + (79.965, [0.0144, 0.0064, -0.0041]), + (80.765, [0.01, 0.0028, -0.0038]), + (81.565, [0.0098, 0.0024, -0.0041]), + (157.53, [0.0209, 0.011, -0.0043]), + (158.33, [0.02, 0.0102, -0.0043]), + (159.13, [0.0095, 0.0034, -0.0043]), + (159.93, [-0.0031, -0.0058, -0.0043]), + (160.73, [-0.0047, -0.0073, -0.0042]), + (474.99, [-0.004, -0.0068, -0.0043]), + (543.76, [-0.0071, -0.0095, -0.0055]), + (568.55, [-0.0075, -0.009, -0.0037]), + (582.15, [-0.0123, -0.0123, -0.0045]), + (585.35, [-0.0101, -0.0106, -0.0031]), + (589.34, [-0.0108, -0.0114, -0.0046]), + (590.94, [-0.0098, -0.0098, -0.0029]), + (595.74, [-0.0099, -0.011, -0.0054]), + (597.34, [-0.0091, -0.0101, -0.0039]), + (604.54, [-0.0073, -0.0092, -0.0041]), + (605.34, [-0.0055, -0.008, -0.0031]), + (610.14, [-0.005, -0.0079, -0.0051]), + (611.74, [-0.0047, -0.0087, -0.0044]), + (614.93, [-0.0032, -0.0063, -0.0028]), + (619.73, [-0.0041, -0.0058, -0.0052]), + (625.33, [-0.0005, -0.0028, -0.0029]), + (626.93, [-0.002, -0.0044, -0.0047]), + (629.33, [-0.0012, -0.0035, -0.0026]), + (630.93, [-0.0023, -0.0047, -0.0035]), + (632.53, [-0.0003, -0.0021, -0.0012]), + (637.32, [0.0011, -0.0005, 0.0001]), + (639.72, [-0.0009, -0.0022, -0.0014]), + (640.52, [0.0, -0.0022, -0.0008]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (7.7937, [0.0, 0.0, 0.0]), + (8.5731, [-0.0083, -0.0133, -0.0427]), + (9.3524, [0.0003, 0.002, 0.0084]), + (10.132, [-0.003, -0.0032, -0.0011]), + (10.911, [-0.0051, -0.0074, -0.0047]), + (11.691, [-0.0063, -0.009, -0.0052]), + (12.47, [-0.0053, -0.0064, -0.0037]), + (13.249, [-0.0013, -0.0031, 0.0002]), + (14.808, [-0.0001, -0.0009, -0.0029]), + (15.587, [0.0017, 0.0015, 0.0011]), + (17.146, [0.0015, 0.0016, 0.0011]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [-0.0044, 0.0165, 0.0121]), + (1.5977, [-0.0032, 0.0168, 0.0145]), + (3.1955, [-0.0016, 0.0204, 0.0185]), + (3.9944, [-0.0026, 0.0182, 0.0171]), + (6.391, [-0.0021, 0.017, 0.0157]), + (10.385, [-0.003, 0.0172, 0.0159]), + (13.581, [-0.0013, 0.0179, 0.0169]), + (18.374, [-0.0025, 0.0167, 0.0154]), + (61.513, [0.0004, 0.0194, 0.0174]), + (62.312, [0.0003, 0.0177, 0.0157]), + (63.91, [0.0003, 0.0034, 0.003]), + (64.709, [0.0004, 0.0006, 0.0003]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [0.045, 0.443, 0.1729]), + (0.79862, [0.0487, 0.4494, 0.1671]), + (1.5972, [0.046, 0.4409, 0.1663]), + (3.1945, [0.0353, 0.414, 0.1526]), + (3.9931, [0.0332, 0.3986, 0.1442]), + (6.3889, [0.0287, 0.332, 0.1107]), + (9.5834, [0.0207, 0.2326, 0.0663]), + (11.181, [0.0179, 0.1882, 0.0489]), + (12.778, [0.0156, 0.1523, 0.0366]), + (14.375, [0.0135, 0.1224, 0.0284]), + (17.57, [0.0115, 0.0767, 0.0182]), + (20.764, [0.009, 0.04, 0.0091]), + (23.16, [0.0079, 0.0184, 0.0028]), + (24.757, [0.0071, 0.0068, -0.0012]), + (27.952, [0.0075, -0.0065, -0.006]), + (31.146, [0.0085, -0.0085, -0.0071]), + (42.327, [0.0156, 0.0082, -0.0011]), + (50.313, [0.0166, 0.0126, -0.0001]), + (77.466, [0.01, 0.0077, -0.0003]), + (145.35, [0.0012, 0.0007, -0.0004]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0008, 0.0194, 0.0117]), + (0.79873, [0.0019, 0.0241, 0.013]), + (1.5975, [-0.004, 0.0169, 0.0073]), + (2.3962, [-0.0016, 0.0196, 0.0091]), + (3.1949, [-0.0034, 0.0183, 0.0078]), + (5.5911, [-0.0035, 0.0177, 0.0086]), + (11.182, [-0.0013, 0.0193, 0.0108]), + (20.767, [-0.0021, 0.0188, 0.0099]), + (23.962, [-0.0032, 0.0217, 0.0122]), + (25.559, [-0.0055, 0.0134, 0.0095]), + (27.157, [-0.0053, 0.009, 0.008]), + (31.15, [-0.004, -0.0024, -0.0004]), + (43.131, [-0.0017, -0.0012, 0.0004]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [-0.0051, 0.0094, 0.0007]), + (0.7912, [-0.0025, 0.0105, 0.0047]), + (1.5824, [-0.004, 0.011, 0.0028]), + (2.3736, [-0.002, 0.0118, 0.0049]), + (3.1648, [-0.0038, 0.0109, 0.0042]), + (7.1208, [-0.0017, 0.0114, 0.005]), + (13.45, [-0.0018, 0.0116, 0.0047]), + (18.989, [-0.0021, 0.0187, 0.0084]), + (19.78, [-0.0023, 0.0172, 0.0075]), + (21.362, [-0.0014, 0.0037, 0.0017]), + (22.154, [-0.0014, 0.0001, 0.0001]), + ], + }, + Radial { + t: 0.39897, + perp: 0.0, + knots: &[ + (0.0, [0.0026, -0.0014, -0.0018]), + (0.77909, [0.0035, 0.0022, 0.0005]), + (1.5582, [0.0035, 0.0025, 0.0009]), + (3.1164, [0.0014, 0.0012, -0.0018]), + (3.8954, [0.0021, 0.002, 0.0]), + (7.0118, [0.0013, 0.0017, 0.0002]), + (8.57, [0.0026, 0.0024, 0.0003]), + (13.245, [0.0014, 0.0011, -0.0012]), + (28.826, [0.0024, 0.0021, 0.0]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.5375, 0.4461, 0.2299]), + (7.9212, [0.5215, 0.4317, 0.2148]), + (8.7134, [0.5319, 0.4408, 0.2181]), + (9.5055, [0.3206, 0.2664, 0.1331]), + (10.298, [0.0344, 0.0281, 0.0151]), + (11.09, [-0.0011, -0.001, 0.0]), + (11.882, [0.0038, 0.0034, 0.0019]), + (12.674, [0.0018, 0.0015, 0.0012]), + (14.258, [0.0018, 0.0017, 0.0016]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.0357, 0.0709, 0.0276]), + (0.79613, [0.0392, 0.0688, 0.0269]), + (6.369, [0.0392, 0.0702, 0.0271]), + (8.7574, [0.0387, 0.0682, 0.0243]), + (42.195, [0.0418, 0.0683, 0.0247]), + (43.787, [0.0479, 0.0719, 0.029]), + (46.175, [0.0398, 0.067, 0.0237]), + (50.952, [0.0337, 0.0639, 0.0201]), + (52.544, [0.0368, 0.0658, 0.0218]), + (59.709, [0.0369, 0.0661, 0.0214]), + (63.69, [0.035, 0.0628, 0.0202]), + (64.486, [0.0321, 0.0575, 0.0186]), + (66.078, [0.0054, 0.0125, 0.0035]), + (66.875, [-0.0011, 0.0017, -0.0001]), + (67.671, [-0.0028, -0.0004, -0.0012]), + (71.651, [-0.0038, -0.0021, -0.0018]), + (92.351, [-0.0019, -0.0007, -0.0012]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.4758, 0.6007, 0.7234]), + (1.5834, [0.3106, 0.4603, 0.6349]), + (2.375, [0.2109, 0.3771, 0.5827]), + (3.1667, [0.1621, 0.3399, 0.5584]), + (3.9584, [0.1526, 0.3304, 0.5522]), + (4.7501, [0.153, 0.3317, 0.5531]), + (5.5418, [0.1479, 0.3223, 0.5487]), + (6.3335, [0.1375, 0.301, 0.5406]), + (7.9168, [0.0509, 0.113, 0.4694]), + (8.7085, [0.0415, 0.093, 0.423]), + (9.5002, [0.0343, 0.0778, 0.3642]), + (10.292, [0.0297, 0.0672, 0.3061]), + (11.084, [0.0231, 0.0519, 0.2484]), + (11.875, [0.0179, 0.0401, 0.1874]), + (12.667, [0.0111, 0.0242, 0.1261]), + (13.459, [0.007, 0.0146, 0.0647]), + (14.25, [0.0032, 0.0063, 0.009]), + (15.042, [0.0024, 0.0047, 0.0034]), + (16.625, [0.0011, 0.0018, 0.0013]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (13.514, [0.0, 0.0, 0.0]), + (14.309, [0.0049, -0.0115, -0.0106]), + (15.104, [0.0039, 0.0018, -0.0006]), + (15.899, [0.0047, 0.0021, 0.0015]), + (17.488, [0.0069, 0.0043, 0.0011]), + (19.078, [0.0037, 0.0023, 0.0008]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.2124, 0.1781, 0.0331]), + (0.78746, [0.2039, 0.1635, 0.0291]), + (1.5749, [0.1663, 0.1348, 0.0248]), + (3.1498, [0.0896, 0.0686, 0.0088]), + (3.9373, [0.0617, 0.0482, 0.0042]), + (4.7247, [0.0455, 0.0352, 0.0036]), + (6.2997, [0.0245, 0.0181, 0.0011]), + (7.8746, [0.0146, 0.009, -0.0007]), + (10.237, [0.0067, 0.004, -0.0003]), + (23.624, [0.0045, 0.0028, -0.0009]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0112, 0.0752, 0.0939]), + (0.79673, [0.0134, 0.082, 0.0976]), + (3.1869, [0.0104, 0.0769, 0.0923]), + (3.9837, [0.0079, 0.0722, 0.086]), + (4.7804, [0.0055, 0.0353, 0.0419]), + (5.5771, [0.0023, 0.0074, 0.0083]), + (6.3739, [0.0019, 0.0019, 0.0028]), + (7.1706, [0.0006, 0.0, 0.0006]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0288, -0.3538, -0.0912]), + (0.78812, [0.0274, -0.3496, -0.0935]), + (2.3644, [0.0262, -0.3473, -0.0915]), + (4.7287, [0.0247, -0.3205, -0.083]), + (6.305, [0.024, -0.2919, -0.074]), + (7.8812, [0.0204, -0.2639, -0.0654]), + (11.034, [0.0165, -0.1973, -0.0473]), + (14.186, [0.0163, -0.1311, -0.0316]), + (17.339, [0.0168, -0.0754, -0.02]), + (19.703, [0.0177, -0.039, -0.0117]), + (21.279, [0.0187, -0.0178, -0.0054]), + (22.856, [0.0196, -0.0001, -0.0001]), + (24.432, [0.0192, 0.0135, 0.0036]), + (26.008, [0.0191, 0.0233, 0.0065]), + (27.584, [0.0187, 0.0298, 0.0091]), + (30.737, [0.0169, 0.034, 0.0101]), + (32.313, [0.0156, 0.0324, 0.0097]), + (44.135, [0.0036, 0.0071, 0.0011]), + (49.652, [0.0009, 0.0015, 0.0]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3659, 0.4474, 0.6377]), + (0.79199, [0.387, 0.458, 0.6143]), + (1.584, [0.3559, 0.4128, 0.5396]), + (3.168, [0.2338, 0.2613, 0.3197]), + (3.96, [0.169, 0.1898, 0.2231]), + (4.752, [0.1182, 0.13, 0.1397]), + (5.544, [0.0838, 0.0882, 0.0861]), + (6.336, [0.0456, 0.049, 0.0407]), + (7.1279, [0.0268, 0.0294, 0.0174]), + (15.048, [0.0359, 0.037, 0.0209]), + (20.592, [0.0329, 0.034, 0.0193]), + (30.096, [0.0344, 0.0358, 0.0202]), + (30.888, [0.0246, 0.0257, 0.0143]), + (31.68, [0.0046, 0.0052, 0.0026]), + (32.472, [0.0001, 0.0006, 0.0003]), + ], + }, + Radial { + t: 2.5968, + perp: 0.0, + knots: &[ + (0.0, [0.0033, 0.0011, -0.0002]), + (0.79014, [0.0005, -0.0013, 0.0016]), + (4.7409, [0.0037, 0.0045, 0.0029]), + (6.3211, [0.0017, 0.0021, 0.0015]), + (10.272, [0.0018, 0.0038, 0.0019]), + (11.852, [0.0006, 0.0016, 0.0013]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.003, 0.0037, 0.004]), + (10.261, [0.0029, 0.0044, 0.0046]), + (11.05, [0.003, 0.0071, 0.0073]), + (12.629, [0.0028, 0.0076, 0.0078]), + (14.207, [0.0029, 0.0118, 0.0115]), + (15.786, [0.0031, 0.016, 0.0119]), + (16.575, [0.003, 0.0159, 0.0125]), + (18.154, [0.0072, 0.0199, 0.0162]), + (20.522, [0.0068, 0.0271, 0.0233]), + (22.1, [0.0114, 0.031, 0.0278]), + (23.679, [0.0116, 0.0328, 0.0332]), + (25.257, [0.0157, 0.0378, 0.0386]), + (26.836, [0.0134, 0.0388, 0.0429]), + (27.625, [0.0164, 0.0427, 0.0467]), + (29.993, [0.0192, 0.0517, 0.0563]), + (30.783, [0.0152, 0.0404, 0.0421]), + (31.572, [0.0027, 0.0102, 0.0092]), + (32.361, [-0.0008, -0.0013, -0.0008]), + (41.043, [0.0003, 0.0005, 0.0003]), + (42.622, [-0.0019, -0.0032, -0.001]), + (43.411, [-0.0006, -0.001, -0.0002]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (2.3556, [0.0, 0.0, 0.0]), + (3.1407, [-0.004, 0.0044, 0.0132]), + (3.9259, [-0.0087, -0.0048, -0.0009]), + (4.7111, [-0.0089, -0.0042, -0.0039]), + (5.4963, [-0.007, -0.0045, -0.0019]), + (6.2815, [-0.0088, -0.0067, -0.0068]), + (7.0667, [-0.0088, -0.0067, -0.0042]), + (7.8519, [-0.0081, -0.0044, 0.0014]), + (8.637, [-0.0077, -0.0042, 0.0017]), + (9.4222, [-0.01, -0.0079, -0.003]), + (10.207, [-0.0078, -0.0062, -0.0002]), + (10.993, [-0.0081, -0.0049, -0.0002]), + (11.778, [-0.008, -0.0047, 0.0002]), + (12.563, [-0.0098, -0.0071, -0.0017]), + (13.348, [-0.0076, -0.0045, 0.0012]), + (18.059, [-0.0068, -0.0045, -0.0006]), + (18.844, [-0.0085, -0.0064, -0.0025]), + (19.63, [-0.0065, -0.0047, 0.0]), + ], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0133, 0.1419, 0.1541]), + (0.7998, [0.0116, 0.0854, 0.089]), + (1.5996, [0.005, 0.0473, 0.0474]), + (2.3994, [0.0008, 0.004, 0.0036]), + (3.1992, [-0.0001, 0.0001, -0.0008]), + ], + }, + Radial { + t: 0.87489, + perp: 0.0, + knots: &[ + (0.0, [0.0046, 0.0041, 0.0011]), + (0.78535, [0.0029, 0.0035, 0.0019]), + (1.5707, [-0.0004, -0.0019, -0.0026]), + (3.1414, [-0.0019, -0.0017, -0.0019]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0325, -0.0619, -0.0111]), + (2.3795, [-0.0301, -0.0625, -0.0127]), + (4.759, [-0.0275, -0.0538, -0.0112]), + (7.9316, [-0.0204, -0.039, -0.0077]), + (10.311, [-0.0159, -0.0258, -0.0026]), + (13.484, [-0.0115, -0.0058, 0.0133]), + (14.277, [-0.0104, -0.002, 0.0127]), + (15.07, [-0.0099, -0.0001, 0.0092]), + (15.863, [-0.0086, -0.0002, 0.0059]), + (16.656, [-0.0071, -0.0055, 0.0025]), + (17.45, [-0.0067, -0.0136, -0.0015]), + (18.243, [-0.0059, -0.0157, -0.0031]), + (23.002, [-0.002, -0.0011, 0.0007]), + (25.381, [-0.0018, 0.002, 0.0012]), + (29.347, [-0.0007, 0.0048, 0.0025]), + (35.692, [0.0002, 0.0017, 0.001]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.3158, 0.1509, -0.0017]), + (0.77016, [0.2646, 0.0936, -0.0353]), + (1.5403, [0.1916, 0.0407, -0.0549]), + (2.3105, [0.1392, -0.001, -0.0619]), + (3.0807, [0.0963, -0.0313, -0.0652]), + (3.8508, [0.0602, -0.0489, -0.0647]), + (4.621, [0.0402, -0.0581, -0.0592]), + (6.1613, [0.013, -0.0563, -0.0462]), + (6.9315, [0.008, -0.0488, -0.039]), + (8.4718, [0.0016, -0.0333, -0.0253]), + (10.012, [-0.0021, -0.0195, -0.0129]), + (12.323, [-0.0002, -0.0035, -0.0029]), + ], + }, + Radial { + t: 1.6547, + perp: 0.0, + knots: &[ + (0.0, [0.0006, 0.0072, 0.0057]), + (1.5729, [0.0007, 0.007, 0.0035]), + (3.1458, [-0.0001, 0.0042, 0.0011]), + (3.9322, [0.001, 0.0036, 0.0012]), + (6.2916, [-0.0005, 0.0016, 0.0005]), + (9.4374, [0.0002, 0.0034, 0.002]), + (14.156, [-0.0004, 0.0014, 0.0001]), + ], + }, + Radial { + t: 2.6079, + perp: 0.0, + knots: &[ + (0.0, [-0.0011, -0.0009, 0.002]), + (0.79055, [-0.0029, -0.0018, -0.0024]), + (5.5338, [-0.0031, -0.0037, -0.0026]), + (7.1149, [-0.0012, -0.0018, -0.0015]), + (8.696, [-0.0024, -0.0038, -0.002]), + (11.858, [-0.0006, -0.0016, -0.0018]), + ], + }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0869, 0.0439, 0.0523]), + (10.377, [0.0884, 0.0463, 0.0541]), + (46.298, [0.0842, 0.0455, 0.0529]), + (47.096, [0.0497, 0.0231, 0.0295]), + (47.895, [0.0059, -0.0025, 0.0013]), + (48.693, [-0.0025, -0.0065, -0.0022]), + (53.482, [0.0005, -0.0055, -0.0009]), + (59.868, [0.0001, -0.0051, -0.001]), + (60.666, [-0.0002, -0.0058, -0.0011]), + (61.465, [-0.0033, -0.0108, -0.0028]), + (62.263, [-0.0035, -0.0107, -0.0029]), + (63.061, [-0.001, -0.0064, -0.0014]), + (64.658, [0.002, -0.0016, 0.0003]), + (69.447, [0.0052, 0.0039, 0.0023]), + (71.044, [0.004, 0.0018, 0.0017]), + (98.184, [0.002, 0.0008, 0.001]), + ], + }, +]; +const PRIME105_125_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_125_RAYS: &[Ray] = &[ + Ray { angle: 0.2, r0: 19.531, r1: 136.72, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 3.03, r0: 107.03, r1: 148.44, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, + Ray { angle: 7.91, r0: 17.188, r1: 148.44, fade: 0.05, rgb: [0.0118, 0.0098, 0.0078] }, + Ray { angle: 8.2, r0: 17.188, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0098, 0.0078] }, + Ray { angle: 10.36, r0: 141.41, r1: 157.81, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 17.42, r0: 32.031, r1: 204.69, fade: 0.05, rgb: [0.0196, 0.0196, 0.0157] }, + Ray { angle: 24.35, r0: 28.906, r1: 144.53, fade: 0.05, rgb: [0.0078, 0.0059, 0.0039] }, + Ray { angle: 24.6, r0: 29.688, r1: 190.62, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 38.95, r0: 26.562, r1: 161.72, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 46.13, r0: 28.125, r1: 242.97, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 53.53, r0: 27.344, r1: 191.41, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 60.44, r0: 40.625, r1: 324.22, fade: 0.05, rgb: [0.0137, 0.0118, 0.0118] }, + Ray { angle: 74.51, r0: 103.12, r1: 162.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 89.06, r0: 56.25, r1: 367.97, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 95.98, r0: 15.625, r1: 248.44, fade: 0.05, rgb: [0.0176, 0.0176, 0.0137] }, + Ray { angle: 96.25, r0: 15.625, r1: 372.66, fade: 0.05, rgb: [0.0176, 0.0157, 0.0137] }, + Ray { angle: 96.9, r0: 157.03, r1: 249.22, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 97.21, r0: 17.188, r1: 249.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 97.75, r0: 83.594, r1: 250.0, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 103.31, r0: 88.281, r1: 207.81, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 104.33, r0: 101.56, r1: 417.19, fade: 0.05, rgb: [0.0137, 0.0137, 0.0118] }, + Ray { angle: 110.61, r0: 25.0, r1: 213.28, fade: 0.05, rgb: [0.0196, 0.0196, 0.0167] }, + Ray { angle: 118.59, r0: 35.156, r1: 251.56, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 125.78, r0: 50.781, r1: 162.5, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 147.22, r0: 32.812, r1: 278.12, fade: 0.05, rgb: [0.0118, 0.0098, 0.0098] }, + Ray { angle: 161.56, r0: 28.125, r1: 246.88, fade: 0.05, rgb: [0.0294, 0.0275, 0.0255] }, + Ray { angle: 168.47, r0: 35.156, r1: 167.19, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 168.74, r0: 37.5, r1: 238.28, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 197.51, r0: 32.031, r1: 204.69, fade: 0.05, rgb: [0.0176, 0.0157, 0.0118] }, + Ray { angle: 197.79, r0: 30.469, r1: 153.12, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 204.7, r0: 26.562, r1: 196.88, fade: 0.05, rgb: [0.0118, 0.0098, 0.0078] }, + Ray { angle: 219.21, r0: 25.0, r1: 152.34, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 225.94, r0: 35.938, r1: 211.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 233.3, r0: 27.344, r1: 189.84, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 240.05, r0: 35.156, r1: 160.94, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 240.32, r0: 32.812, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0098, 0.0098] }, + Ray { angle: 254.4, r0: 42.188, r1: 153.91, fade: 0.05, rgb: [0.0078, 0.0059, 0.0049] }, + Ray { angle: 269.0, r0: 125.78, r1: 195.31, fade: 0.05, rgb: [0.0196, 0.0186, 0.0186] }, + Ray { angle: 269.71, r0: 17.969, r1: 147.66, fade: 0.05, rgb: [0.0235, 0.0294, 0.0118] }, + Ray { angle: 269.99, r0: 17.969, r1: 147.66, fade: 0.05, rgb: [0.0235, 0.0294, 0.0118] }, + Ray { angle: 270.29, r0: 22.656, r1: 149.22, fade: 0.05, rgb: [0.0196, 0.0255, 0.0078] }, + Ray { angle: 275.29, r0: 16.406, r1: 196.09, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 275.73, r0: 15.625, r1: 121.88, fade: 0.05, rgb: [0.0176, 0.0137, 0.0118] }, + Ray { angle: 276.24, r0: 15.625, r1: 196.09, fade: 0.05, rgb: [0.0176, 0.0157, 0.0137] }, + Ray { angle: 283.35, r0: 112.5, r1: 200.0, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 284.45, r0: 71.875, r1: 201.56, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 290.71, r0: 23.438, r1: 208.59, fade: 0.05, rgb: [0.0216, 0.0196, 0.0167] }, + Ray { angle: 298.75, r0: 25.781, r1: 222.66, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 305.87, r0: 48.438, r1: 161.72, fade: 0.05, rgb: [0.0059, 0.0069, 0.0039] }, + Ray { angle: 327.33, r0: 32.812, r1: 323.44, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 341.64, r0: 28.906, r1: 349.22, fade: 0.05, rgb: [0.0294, 0.0294, 0.0275] }, + Ray { angle: 348.49, r0: 37.5, r1: 173.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 348.77, r0: 38.281, r1: 507.03, fade: 0.05, rgb: [0.0255, 0.0255, 0.0235] }, +]; +const PRIME105_150_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.5797, 0.5585, 0.4441]), + (0.8, [0.5658, 0.547, 0.4338]), + (24.8, [0.573, 0.5581, 0.4481]), + (41.6, [0.563, 0.5603, 0.4649]), + (60.8, [0.5384, 0.5303, 0.4737]), + (71.2, [0.5357, 0.511, 0.4748]), + (77.6, [0.5433, 0.4991, 0.4677]), + (87.2, [0.5316, 0.481, 0.4541]), + (121.6, [0.5232, 0.4353, 0.3953]), + (140.8, [0.4862, 0.4067, 0.3741]), + (171.2, [0.3729, 0.3568, 0.3533]), + (184.0, [0.3071, 0.3163, 0.345]), + (193.6, [0.2453, 0.2794, 0.3406]), + (203.2, [0.1726, 0.2351, 0.3351]), + (209.6, [0.1206, 0.2004, 0.3326]), + (232.0, [-0.0451, 0.0667, 0.3162]), + (242.4, [-0.1182, 0.0221, 0.3086]), + (248.0, [-0.1495, 0.0036, 0.3014]), + (252.0, [-0.169, -0.0073, 0.2962]), + (260.8, [-0.1951, -0.0267, 0.284]), + (264.8, [-0.193, -0.0289, 0.2792]), + (268.8, [-0.1838, -0.0258, 0.2739]), + (272.0, [-0.1724, -0.023, 0.2675]), + (275.2, [-0.1578, -0.0172, 0.2626]), + (280.0, [-0.1308, -0.0021, 0.2562]), + (284.8, [-0.0969, 0.0182, 0.25]), + (288.8, [-0.0654, 0.0379, 0.2452]), + (293.6, [-0.0247, 0.0631, 0.2396]), + (304.0, [0.0605, 0.1114, 0.2161]), + (313.6, [0.1372, 0.1584, 0.2069]), + (325.6, [0.2162, 0.2087, 0.1916]), + (332.0, [0.2533, 0.23, 0.1777]), + (341.6, [0.2922, 0.2525, 0.1638]), + (355.2, [0.3123, 0.2531, 0.1373]), + (368.8, [0.3072, 0.2375, 0.1122]), + (377.6, [0.2843, 0.2128, 0.0849]), + (384.0, [0.2667, 0.1962, 0.072]), + (390.4, [0.2394, 0.1783, 0.0561]), + (394.4, [0.2222, 0.1685, 0.0503]), + (403.2, [0.1754, 0.1414, 0.0331]), + (408.8, [0.1484, 0.1276, 0.0272]), + (411.2, [0.1384, 0.1231, 0.027]), + (419.2, [0.0967, 0.0933, 0.0197]), + (423.2, [0.0741, 0.0734, 0.0139]), + (427.2, [0.0572, 0.0589, 0.0149]), + (432.0, [0.035, 0.0403, 0.0132]), + (434.4, [0.0273, 0.0342, 0.0155]), + (438.4, [0.0075, 0.0186, 0.0112]), + (443.2, [-0.0129, 0.002, 0.0091]), + (448.8, [-0.0318, -0.015, 0.0102]), + (452.8, [-0.0467, -0.0328, 0.008]), + (456.0, [-0.0557, -0.0425, 0.0084]), + (460.0, [-0.0652, -0.0514, 0.0105]), + (462.4, [-0.0689, -0.0537, 0.0148]), + (466.4, [-0.0846, -0.0648, 0.0098]), + (475.2, [-0.0978, -0.0668, 0.0184]), + (487.2, [-0.1045, -0.0609, 0.0337]), + (490.4, [-0.1091, -0.0619, 0.0346]), + (496.0, [-0.1145, -0.057, 0.0397]), + (500.0, [-0.1158, -0.0519, 0.0455]), + (505.6, [-0.1202, -0.0495, 0.0497]), + (519.2, [-0.1287, -0.0489, 0.0527]), + (528.0, [-0.129, -0.0586, 0.0442]), + (532.0, [-0.1296, -0.0627, 0.0367]), + (540.0, [-0.1205, -0.0638, 0.0274]), + (544.8, [-0.1067, -0.0546, 0.0279]), + (554.4, [-0.0779, -0.0406, 0.0227]), + (559.2, [-0.0577, -0.0328, 0.0212]), + (563.2, [-0.0338, -0.0229, 0.0235]), + (566.4, [-0.0084, -0.012, 0.0288]), + (571.2, [0.0217, -0.0014, 0.0278]), + (575.2, [0.0482, 0.012, 0.0304]), + (577.6, [0.0614, 0.0194, 0.029]), + (580.0, [0.0715, 0.0257, 0.0226]), + (584.0, [0.0904, 0.0445, 0.0241]), + (588.0, [0.1075, 0.0676, 0.0299]), + (593.6, [0.1213, 0.0953, 0.031]), + (596.0, [0.1224, 0.1027, 0.0275]), + (601.6, [0.1295, 0.1225, 0.0247]), + (605.6, [0.1262, 0.1218, 0.0126]), + (612.8, [0.1237, 0.1241, 0.0067]), + (616.8, [0.115, 0.1148, -0.0008]), + (620.0, [0.1051, 0.1041, -0.0093]), + (626.4, [0.0903, 0.0858, -0.0189]), + (628.8, [0.0851, 0.0779, -0.018]), + (630.4, [0.0789, 0.0692, -0.0191]), + (635.2, [0.0657, 0.0546, -0.0221]), + (638.4, [0.0549, 0.0412, -0.0251]), + (645.6, [0.0323, 0.0122, -0.0217]), + (650.4, [0.0116, -0.0131, -0.0183]), + (653.6, [-0.0071, -0.033, -0.0168]), + (659.2, [-0.0508, -0.0743, -0.0219]), + (661.6, [-0.0671, -0.0898, -0.0235]), + (665.6, [-0.1059, -0.1232, -0.0383]), + (673.6, [-0.1696, -0.1762, -0.0638]), + (677.6, [-0.1953, -0.1932, -0.076]), + (684.8, [-0.2446, -0.2266, -0.1101]), + (688.0, [-0.2595, -0.24, -0.1283]), + (691.2, [-0.2651, -0.2467, -0.1465]), + (694.4, [-0.2585, -0.2443, -0.1612]), + (697.6, [-0.2329, -0.2262, -0.1561]), + (700.8, [-0.1884, -0.1912, -0.1475]), + (703.2, [-0.1462, -0.1567, -0.1361]), + (704.8, [-0.112, -0.1288, -0.1247]), + (706.4, [-0.0669, -0.0901, -0.108]), + (707.2, [-0.0384, -0.0653, -0.0977]), + (708.0, [-0.0051, -0.0357, -0.0831]), + (708.8, [0.0323, -0.0025, -0.0666]), + (709.6, [0.0749, 0.0361, -0.0471]), + (711.2, [0.1664, 0.1209, -0.0027]), + (712.0, [0.207, 0.1581, 0.0161]), + (712.8, [0.2411, 0.1905, 0.032]), + (714.4, [0.288, 0.24, 0.058]), + (716.0, [0.3078, 0.265, 0.0714]), + (717.6, [0.3048, 0.2697, 0.0728]), + (719.2, [0.2795, 0.2528, 0.0662]), + (720.0, [0.2605, 0.2367, 0.0599]), + (722.4, [0.1847, 0.1682, 0.0353]), + (725.6, [0.0691, 0.0683, -0.002]), + (727.2, [0.0152, 0.0222, -0.0206]), + (728.8, [-0.0328, -0.0219, -0.0389]), + (730.4, [-0.072, -0.0597, -0.0543]), + (732.0, [-0.1023, -0.0911, -0.0677]), + (733.6, [-0.1241, -0.1148, -0.0783]), + (735.2, [-0.1391, -0.1302, -0.0859]), + (737.6, [-0.1504, -0.1407, -0.0919]), + (740.8, [-0.1536, -0.1435, -0.0954]), + (743.2, [-0.1497, -0.1412, -0.0961]), + (748.8, [-0.1227, -0.1292, -0.095]), + (751.2, [-0.1176, -0.1265, -0.0971]), + (755.2, [-0.1276, -0.1311, -0.1105]), + (759.2, [-0.1482, -0.1458, -0.1414]), + (761.6, [-0.1516, -0.1463, -0.1528]), + (765.6, [-0.138, -0.1312, -0.1588]), + (772.0, [-0.1251, -0.1025, -0.1486]), + (775.2, [-0.1192, -0.0886, -0.1498]), + (777.6, [-0.1171, -0.0866, -0.1556]), + (780.0, [-0.1188, -0.0936, -0.1778]), + (780.8, [-0.1212, -0.0995, -0.1892]), + (782.4, [-0.1286, -0.1178, -0.2118]), + (784.8, [-0.1382, -0.138, -0.2317]), + (790.4, [-0.1471, -0.1534, -0.2315]), + (796.0, [-0.1475, -0.165, -0.2275]), + (801.6, [-0.1313, -0.155, -0.2233]), + (805.6, [-0.1095, -0.1355, -0.2162]), + (808.0, [-0.1042, -0.1306, -0.2109]), + (809.6, [-0.099, -0.1265, -0.204]), + (811.2, [-0.0887, -0.1162, -0.1909]), + (813.6, [-0.078, -0.0966, -0.1603]), + (815.2, [-0.0705, -0.0808, -0.1272]), + (816.0, [-0.059, -0.0665, -0.1035]), + (816.8, [-0.0412, -0.0468, -0.0733]), + (818.4, [0.0101, 0.0052, -0.004]), + (819.2, [0.0338, 0.0277, 0.0263]), + (820.0, [0.0396, 0.0368, 0.0415]), + (820.8, [0.0155, 0.0224, 0.033]), + (821.6, [-0.044, -0.0166, -0.0026]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.332, 0.3562, 0.4701]), + (46.4, [0.3186, 0.3339, 0.4356]), + (63.2, [0.3068, 0.3353, 0.4296]), + (87.2, [0.2621, 0.3366, 0.4271]), + (125.6, [0.186, 0.2957, 0.4135]), + (132.8, [0.1803, 0.2869, 0.4038]), + (146.4, [0.1878, 0.2789, 0.381]), + (172.0, [0.2338, 0.2683, 0.3354]), + (185.6, [0.2781, 0.2811, 0.3141]), + (192.8, [0.3105, 0.2957, 0.3024]), + (205.6, [0.3827, 0.3314, 0.2804]), + (232.8, [0.5374, 0.4407, 0.2403]), + (240.0, [0.5742, 0.4598, 0.2312]), + (252.0, [0.6198, 0.4766, 0.2191]), + (260.8, [0.6323, 0.4783, 0.2132]), + (269.6, [0.6032, 0.4596, 0.2048]), + (280.8, [0.5276, 0.4115, 0.1973]), + (313.6, [0.1976, 0.1901, 0.1794]), + (326.4, [0.0869, 0.1134, 0.1706]), + (332.8, [0.0385, 0.0795, 0.1691]), + (337.6, [0.0081, 0.0578, 0.1679]), + (342.4, [-0.0173, 0.0394, 0.1666]), + (352.8, [-0.0555, 0.0175, 0.1662]), + (357.6, [-0.0667, 0.0127, 0.1676]), + (360.8, [-0.076, 0.0067, 0.1659]), + (367.2, [-0.0839, 0.004, 0.1674]), + (372.0, [-0.0828, 0.0074, 0.1716]), + (378.4, [-0.0764, 0.0153, 0.1792]), + (385.6, [-0.0666, 0.0216, 0.1806]), + (395.2, [-0.0377, 0.0332, 0.1831]), + (403.2, [-0.0078, 0.0442, 0.1841]), + (413.6, [0.0254, 0.0527, 0.1755]), + (418.4, [0.0427, 0.0627, 0.1712]), + (424.8, [0.0676, 0.0823, 0.1676]), + (436.0, [0.0989, 0.1062, 0.1487]), + (439.2, [0.1105, 0.1152, 0.1475]), + (456.8, [0.1501, 0.151, 0.1216]), + (463.2, [0.1541, 0.1531, 0.1043]), + (467.2, [0.164, 0.1586, 0.1029]), + (476.0, [0.1663, 0.1476, 0.0826]), + (484.0, [0.1598, 0.13, 0.0615]), + (492.0, [0.1594, 0.12, 0.0445]), + (498.4, [0.1583, 0.1072, 0.0302]), + (501.6, [0.1552, 0.0989, 0.0202]), + (512.8, [0.1517, 0.0837, 0.0014]), + (517.6, [0.1501, 0.0792, -0.0044]), + (520.0, [0.1476, 0.0771, -0.009]), + (524.8, [0.1432, 0.078, -0.0115]), + (529.6, [0.1377, 0.0771, -0.0128]), + (532.0, [0.1357, 0.0773, -0.011]), + (538.4, [0.1226, 0.0727, -0.0112]), + (545.6, [0.0964, 0.0544, -0.0176]), + (548.8, [0.0854, 0.0477, -0.0178]), + (555.2, [0.0581, 0.0313, -0.0197]), + (560.8, [0.0289, 0.0176, -0.0213]), + (564.0, [0.0061, 0.0066, -0.0258]), + (567.2, [-0.0225, -0.0072, -0.0337]), + (572.0, [-0.0571, -0.0222, -0.0372]), + (576.8, [-0.0923, -0.042, -0.0448]), + (578.4, [-0.1025, -0.0484, -0.0459]), + (581.6, [-0.1193, -0.0607, -0.0431]), + (584.8, [-0.1372, -0.0787, -0.0485]), + (588.8, [-0.1585, -0.1057, -0.0592]), + (593.6, [-0.175, -0.134, -0.0646]), + (602.4, [-0.1915, -0.1746, -0.0641]), + (606.4, [-0.1916, -0.1783, -0.0551]), + (610.4, [-0.1945, -0.1857, -0.0549]), + (614.4, [-0.1938, -0.1872, -0.0525]), + (617.6, [-0.1885, -0.1823, -0.0482]), + (621.6, [-0.1793, -0.1727, -0.0404]), + (627.2, [-0.1704, -0.161, -0.0363]), + (629.6, [-0.1666, -0.1549, -0.0384]), + (640.0, [-0.1393, -0.1197, -0.0394]), + (645.6, [-0.1248, -0.1002, -0.0476]), + (651.2, [-0.1044, -0.0738, -0.0552]), + (655.2, [-0.0822, -0.0503, -0.059]), + (660.8, [-0.0403, -0.0105, -0.056]), + (662.4, [-0.0309, -0.0014, -0.0563]), + (666.4, [0.0064, 0.0306, -0.0435]), + (672.8, [0.0561, 0.0738, -0.0248]), + (678.4, [0.0923, 0.0978, -0.0095]), + (686.4, [0.1438, 0.1324, 0.026]), + (689.6, [0.1561, 0.1439, 0.0432]), + (693.6, [0.1575, 0.1478, 0.0625]), + (695.2, [0.1517, 0.1448, 0.0695]), + (696.8, [0.1409, 0.1376, 0.0663]), + (698.4, [0.1254, 0.1261, 0.0625]), + (700.0, [0.1049, 0.1101, 0.0594]), + (702.4, [0.0669, 0.0796, 0.0491]), + (704.0, [0.0376, 0.0556, 0.0411]), + (705.6, [0.0035, 0.0277, 0.0304]), + (706.4, [-0.0178, 0.0096, 0.0221]), + (707.2, [-0.042, -0.0112, 0.0126]), + (708.0, [-0.0704, -0.0362, 0.0022]), + (708.8, [-0.1033, -0.0653, -0.0109]), + (709.6, [-0.1406, -0.0986, -0.0278]), + (710.4, [-0.1831, -0.137, -0.0469]), + (712.8, [-0.326, -0.2691, -0.1157]), + (714.4, [-0.4019, -0.3414, -0.1517]), + (716.0, [-0.4496, -0.3925, -0.179]), + (716.8, [-0.4629, -0.4082, -0.188]), + (718.4, [-0.472, -0.4246, -0.1955]), + (720.0, [-0.4579, -0.4208, -0.1942]), + (721.6, [-0.4245, -0.3945, -0.1847]), + (728.0, [-0.217, -0.2089, -0.1193]), + (729.6, [-0.1705, -0.1667, -0.1029]), + (731.2, [-0.1322, -0.1295, -0.0884]), + (732.8, [-0.1029, -0.0993, -0.0764]), + (734.4, [-0.083, -0.0764, -0.0669]), + (736.8, [-0.0653, -0.0569, -0.0578]), + (739.2, [-0.0604, -0.0521, -0.0553]), + (742.4, [-0.0667, -0.0588, -0.058]), + (745.6, [-0.0833, -0.0724, -0.0644]), + (748.8, [-0.1085, -0.0872, -0.0712]), + (752.0, [-0.125, -0.0958, -0.0735]), + (753.6, [-0.1339, -0.1121, -0.0818]), + (755.2, [-0.1435, -0.1363, -0.0947]), + (756.8, [-0.1554, -0.1676, -0.1139]), + (758.4, [-0.1603, -0.1867, -0.1248]), + (759.2, [-0.1617, -0.1952, -0.1292]), + (761.6, [-0.1598, -0.2057, -0.1401]), + (763.2, [-0.1588, -0.2035, -0.1482]), + (765.6, [-0.1548, -0.1845, -0.1581]), + (768.8, [-0.1302, -0.1265, -0.1685]), + (772.0, [-0.0955, -0.0548, -0.1775]), + (772.8, [-0.0887, -0.0392, -0.179]), + (774.4, [-0.0786, -0.0156, -0.179]), + (775.2, [-0.0766, -0.0097, -0.1779]), + (776.0, [-0.0758, -0.007, -0.1747]), + (776.8, [-0.076, -0.0064, -0.17]), + (777.6, [-0.0781, -0.0099, -0.1659]), + (778.4, [-0.0829, -0.019, -0.1626]), + (780.0, [-0.0957, -0.0443, -0.1502]), + (780.8, [-0.1031, -0.0604, -0.1467]), + (783.2, [-0.1319, -0.1227, -0.1589]), + (784.8, [-0.1551, -0.1722, -0.1824]), + (785.6, [-0.1625, -0.1885, -0.1929]), + (786.4, [-0.1645, -0.1944, -0.2021]), + (788.0, [-0.1577, -0.1852, -0.2092]), + (791.2, [-0.1434, -0.1607, -0.2075]), + (793.6, [-0.1389, -0.149, -0.2108]), + (795.2, [-0.1384, -0.1441, -0.216]), + (797.6, [-0.134, -0.1396, -0.2151]), + (801.6, [-0.1276, -0.1401, -0.216]), + (807.2, [-0.109, -0.1395, -0.209]), + (809.6, [-0.0937, -0.1292, -0.2018]), + (811.2, [-0.0819, -0.1177, -0.1965]), + (813.6, [-0.0878, -0.1101, -0.1867]), + (814.4, [-0.086, -0.1037, -0.1769]), + (815.2, [-0.0777, -0.0898, -0.1587]), + (816.0, [-0.0658, -0.0716, -0.1259]), + (816.8, [-0.0327, -0.0391, -0.0774]), + (817.6, [0.0149, 0.0038, -0.0273]), + (818.4, [0.0274, 0.0185, 0.014]), + (819.2, [0.0052, 0.0062, 0.0145]), + (820.0, [-0.0229, -0.0117, 0.0057]), + (820.8, [-0.0347, -0.0189, -0.0006]), + (821.6, [-0.0311, -0.0119, -0.0191]), + (822.4, [0.007, 0.0007, -0.0046]), + (823.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.1094, 0.1056, 0.1031]), + (0.79943, [0.1031, 0.0976, 0.0977]), + (63.955, [0.1023, 0.1031, 0.1015]), + (181.47, [0.1, 0.1015, 0.1026]), + (208.65, [0.0945, 0.0984, 0.1035]), + (275.01, [0.0728, 0.0845, 0.1042]), + (309.38, [0.0678, 0.082, 0.1045]), + (394.12, [0.0794, 0.09, 0.1051]), + (518.03, [0.083, 0.0925, 0.1051]), + (637.15, [0.1029, 0.1072, 0.1086]), + (680.32, [0.1117, 0.1156, 0.1099]), + (763.46, [0.1145, 0.1152, 0.1073]), + (782.65, [0.1266, 0.1247, 0.1115]), + (786.64, [0.1358, 0.133, 0.1159]), + (799.43, [0.175, 0.1729, 0.1364]), + (810.63, [0.1981, 0.1866, 0.1479]), + (818.62, [0.2167, 0.202, 0.1576]), + (825.81, [0.2365, 0.2222, 0.1718]), + (830.61, [0.2457, 0.2286, 0.1778]), + (831.41, [0.2481, 0.2319, 0.1802]), + (833.01, [0.2589, 0.2509, 0.1937]), + (836.21, [0.2901, 0.3107, 0.238]), + (838.61, [0.3081, 0.3458, 0.2709]), + (841.0, [0.3151, 0.3602, 0.299]), + (843.4, [0.3107, 0.3492, 0.3174]), + (845.0, [0.3001, 0.3265, 0.3258]), + (848.2, [0.2661, 0.2533, 0.3335]), + (852.2, [0.2174, 0.1489, 0.3362]), + (853.79, [0.2056, 0.1201, 0.3357]), + (854.59, [0.2021, 0.1108, 0.3339]), + (856.19, [0.1989, 0.1023, 0.3273]), + (856.99, [0.2009, 0.1058, 0.3239]), + (857.79, [0.2053, 0.1151, 0.3231]), + (859.39, [0.2181, 0.1435, 0.3214]), + (860.19, [0.2264, 0.1625, 0.3263]), + (861.79, [0.2501, 0.216, 0.3539]), + (864.19, [0.2934, 0.3103, 0.4134]), + (864.99, [0.3038, 0.3328, 0.4299]), + (865.79, [0.3076, 0.3419, 0.4408]), + (871.38, [0.2924, 0.318, 0.4441]), + (875.38, [0.2885, 0.3125, 0.4499]), + (880.18, [0.273, 0.3088, 0.4463]), + (886.57, [0.2211, 0.2815, 0.4304]), + (888.97, [0.2008, 0.2668, 0.4176]), + (890.57, [0.1828, 0.2505, 0.4047]), + (892.17, [0.1762, 0.2354, 0.3866]), + (893.77, [0.1688, 0.2121, 0.3518]), + (894.57, [0.1588, 0.1923, 0.3211]), + (895.37, [0.1445, 0.1672, 0.2731]), + (896.16, [0.1047, 0.1243, 0.2047]), + (896.96, [0.0435, 0.0653, 0.1286]), + (897.76, [0.0096, 0.0278, 0.0546]), + (898.56, [0.006, 0.0137, 0.0187]), + (899.36, [0.0072, 0.0052, -0.0072]), + (900.16, [-0.0006, -0.0072, -0.0282]), + (900.96, [-0.0014, -0.0167, -0.0183]), + (901.76, [0.0004, -0.004, -0.012]), + (902.56, [-0.0007, -0.0003, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [-0.1166, 0.2582, 0.015]), + (0.79965, [-0.105, 0.2627, 0.0163]), + (1.5993, [-0.1105, 0.2573, 0.0158]), + (2.399, [-0.1105, 0.2527, 0.0152]), + (3.9983, [-0.1171, 0.2348, 0.0118]), + (7.9965, [-0.137, 0.1712, -0.0033]), + (10.395, [-0.141, 0.1325, -0.0105]), + (12.794, [-0.1393, 0.0966, -0.0153]), + (16.793, [-0.1302, 0.0386, -0.0274]), + (19.192, [-0.1201, 0.0096, -0.0321]), + (21.591, [-0.1065, -0.0107, -0.0331]), + (23.19, [-0.0967, -0.0207, -0.0335]), + (25.589, [-0.08, -0.0299, -0.032]), + (27.988, [-0.0652, -0.0344, -0.03]), + (33.585, [-0.0342, -0.0309, -0.02]), + (39.183, [-0.0135, -0.0192, -0.0098]), + (45.58, [-0.0028, -0.0126, -0.0055]), + (51.178, [0.0013, -0.0088, -0.004]), + (55.976, [0.0023, -0.008, -0.0057]), + (77.566, [0.014, 0.0018, -0.0046]), + (78.366, [0.0132, 0.0011, -0.0044]), + (79.166, [0.0064, -0.0035, -0.0048]), + (79.965, [-0.0052, -0.0107, -0.0045]), + (80.765, [-0.0108, -0.0143, -0.0044]), + (83.964, [-0.0104, -0.0141, -0.005]), + (116.75, [0.0024, -0.0051, -0.0059]), + (157.53, [0.0228, 0.0101, -0.0056]), + (158.33, [0.0212, 0.0086, -0.0057]), + (159.13, [0.0123, 0.0033, -0.0056]), + (159.93, [-0.0013, -0.005, -0.0055]), + (160.73, [-0.005, -0.0083, -0.0059]), + (178.32, [-0.003, -0.0066, -0.0058]), + (236.7, [-0.0052, -0.0086, -0.0058]), + (474.19, [-0.0022, -0.0062, -0.0052]), + (514.18, [-0.0044, -0.0078, -0.0065]), + (546.16, [-0.0045, -0.0074, -0.0051]), + (548.56, [-0.006, -0.0087, -0.0064]), + (556.56, [-0.005, -0.0075, -0.005]), + (572.55, [-0.0081, -0.0102, -0.0061]), + (574.15, [-0.0068, -0.0088, -0.0044]), + (576.55, [-0.0081, -0.0104, -0.0054]), + (582.95, [-0.0082, -0.0095, -0.0042]), + (590.14, [-0.0085, -0.0107, -0.0062]), + (601.34, [-0.0054, -0.0085, -0.0047]), + (608.54, [-0.0048, -0.007, -0.0062]), + (610.14, [-0.0027, -0.005, -0.004]), + (614.13, [-0.0033, -0.0065, -0.0043]), + (618.13, [-0.0011, -0.0044, -0.0038]), + (619.73, [-0.0023, -0.0057, -0.0049]), + (623.73, [-0.0001, -0.0035, -0.0024]), + (630.13, [-0.0006, -0.0041, -0.0044]), + (631.73, [0.0006, -0.0029, -0.0027]), + (636.52, [-0.0005, -0.0046, -0.0025]), + (638.12, [-0.0003, -0.0021, -0.001]), + (640.52, [-0.0008, -0.0025, -0.0005]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (7.7937, [0.0, 0.0, 0.0]), + (8.5731, [0.0098, -0.0143, 0.0312]), + (9.3524, [0.0238, 0.019, 0.0268]), + (10.132, [0.0186, 0.0134, 0.0216]), + (10.911, [0.0124, 0.011, 0.016]), + (11.691, [0.0129, 0.0103, 0.0155]), + (12.47, [0.0085, 0.0076, 0.011]), + (14.808, [0.0025, 0.0019, 0.0031]), + (17.146, [0.0016, 0.0019, 0.0024]), + (18.705, [-0.001, -0.0028, -0.0019]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [-0.0035, 0.0064, 0.004]), + (16.776, [-0.0026, 0.0053, 0.0036]), + (61.513, [-0.0005, 0.0083, 0.0056]), + (62.312, [-0.0005, 0.0073, 0.005]), + (63.91, [-0.0006, 0.001, 0.0009]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0625, 0.5287, 0.1448]), + (0.79862, [-0.0609, 0.5162, 0.1425]), + (1.5972, [-0.0598, 0.5175, 0.1452]), + (2.3959, [-0.0654, 0.502, 0.1373]), + (3.9931, [-0.0703, 0.4675, 0.1232]), + (5.5903, [-0.0769, 0.4165, 0.098]), + (8.7848, [-0.0825, 0.3022, 0.05]), + (11.181, [-0.0816, 0.2254, 0.0214]), + (13.577, [-0.0763, 0.1679, 0.002]), + (14.375, [-0.0732, 0.1525, -0.0019]), + (17.57, [-0.0591, 0.1036, -0.0115]), + (19.965, [-0.0464, 0.0739, -0.0159]), + (22.361, [-0.0343, 0.0491, -0.0208]), + (24.757, [-0.0222, 0.0298, -0.0235]), + (27.952, [-0.0064, 0.0158, -0.0243]), + (29.549, [0.0017, 0.0145, -0.0226]), + (34.341, [0.0223, 0.0203, -0.0141]), + (38.334, [0.0342, 0.0268, -0.0082]), + (41.528, [0.0408, 0.0314, -0.0038]), + (48.716, [0.0457, 0.0348, -0.0004]), + (64.688, [0.0402, 0.029, -0.0008]), + (78.265, [0.0316, 0.0226, -0.001]), + (114.2, [0.0194, 0.0138, -0.0001]), + (144.55, [0.0057, 0.0039, -0.0002]), + (146.15, [0.003, 0.002, -0.0006]), + (150.14, [0.0004, 0.0004, 0.0003]), + (161.32, [-0.0027, -0.0021, -0.0002]), + (168.51, [-0.0018, -0.0012, 0.0005]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0011, 0.0104, 0.0069]), + (1.5975, [-0.001, 0.0124, 0.0075]), + (7.1885, [-0.0042, 0.0102, 0.006]), + (19.968, [-0.0036, 0.0104, 0.0052]), + (23.163, [-0.0028, 0.0141, 0.0071]), + (23.962, [-0.0034, 0.0139, 0.007]), + (25.559, [-0.0056, 0.0078, 0.0056]), + (31.15, [-0.0046, -0.0027, -0.0002]), + (49.521, [-0.0017, -0.0008, 0.0006]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[(0.0, [0.0014, -0.0006, -0.0006]), (0.7912, [-0.0007, 0.0014, -0.0004]), (18.989, [-0.0001, 0.0042, 0.0002]), (21.362, [0.0002, 0.0006, 0.0])], + }, + Radial { t: 0.39897, perp: 0.0, knots: &[(0.0, [0.002, 0.0008, 0.0]), (0.77909, [-0.0004, -0.0005, -0.0007])] }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.5559, 0.4551, 0.2261]), + (0.79212, [0.5696, 0.4697, 0.232]), + (5.5449, [0.5601, 0.4594, 0.2214]), + (7.9212, [0.5571, 0.4567, 0.2221]), + (8.7134, [0.5684, 0.465, 0.2235]), + (9.5055, [0.3522, 0.288, 0.1413]), + (10.298, [0.0468, 0.0385, 0.0211]), + (11.09, [0.0021, 0.0021, 0.0021]), + (11.882, [0.0068, 0.0051, 0.0028]), + (12.674, [0.0046, 0.0038, 0.0022]), + (13.466, [0.0053, 0.0044, 0.0032]), + (15.842, [0.0019, 0.0016, 0.0003]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [-0.023, 0.03, -0.0213]), + (0.79613, [-0.026, 0.0276, -0.0202]), + (2.3884, [-0.0254, 0.0258, -0.0216]), + (7.1651, [-0.0218, 0.0297, -0.0192]), + (8.7574, [-0.022, 0.0278, -0.0212]), + (14.33, [-0.0172, 0.0297, -0.0181]), + (16.719, [-0.0161, 0.0287, -0.0189]), + (23.088, [-0.0094, 0.0322, -0.0143]), + (34.233, [0.0058, 0.0395, -0.0058]), + (42.195, [0.0186, 0.0489, 0.0048]), + (43.787, [0.0267, 0.0529, 0.0092]), + (46.175, [0.0225, 0.052, 0.008]), + (48.564, [0.0226, 0.053, 0.0084]), + (50.952, [0.0189, 0.0522, 0.0072]), + (52.544, [0.0245, 0.0561, 0.0104]), + (55.729, [0.0258, 0.0572, 0.0104]), + (61.302, [0.0305, 0.0622, 0.0143]), + (63.69, [0.0299, 0.0611, 0.0135]), + (64.486, [0.0267, 0.0562, 0.0115]), + (65.282, [0.0137, 0.0356, 0.0054]), + (66.078, [-0.0006, 0.0111, -0.0024]), + (66.875, [-0.0087, -0.0028, -0.0066]), + (67.671, [-0.0108, -0.006, -0.0084]), + (72.447, [-0.0112, -0.0076, -0.0083]), + (91.554, [-0.0076, -0.0049, -0.0085]), + (108.27, [-0.0018, 0.0, -0.0015]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.5001, 0.6146, 0.7136]), + (0.79168, [0.4273, 0.5549, 0.6866]), + (1.5834, [0.341, 0.4859, 0.6518]), + (2.375, [0.235, 0.4035, 0.6083]), + (3.1667, [0.1797, 0.3637, 0.5867]), + (3.9584, [0.1689, 0.3545, 0.581]), + (4.7501, [0.1696, 0.3573, 0.5818]), + (5.5418, [0.1643, 0.3476, 0.5773]), + (6.3335, [0.1524, 0.3261, 0.5699]), + (7.9168, [0.0602, 0.1301, 0.5106]), + (8.7085, [0.0485, 0.1065, 0.4625]), + (9.5002, [0.041, 0.0911, 0.4]), + (10.292, [0.0359, 0.0809, 0.3396]), + (11.875, [0.0238, 0.053, 0.2137]), + (12.667, [0.0161, 0.035, 0.146]), + (13.459, [0.01, 0.0217, 0.0751]), + (14.25, [0.0048, 0.0103, 0.0114]), + (15.042, [0.0034, 0.0073, 0.0041]), + (17.417, [0.0004, 0.0005, -0.0001]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (13.514, [0.0, 0.0, 0.0]), + (14.309, [-0.0547, -0.0385, -0.0569]), + (15.104, [-0.0126, -0.0124, -0.0179]), + (15.899, [-0.0129, -0.0115, -0.0171]), + (16.693, [-0.008, -0.0088, -0.0105]), + (19.078, [0.0001, -0.0006, -0.0003]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.1307, 0.1025, 0.0141]), + (0.78746, [0.1209, 0.0927, 0.012]), + (1.5749, [0.0954, 0.0721, 0.0083]), + (3.1498, [0.055, 0.0389, 0.0043]), + (4.7247, [0.0306, 0.0208, 0.0007]), + (6.2997, [0.0181, 0.0104, -0.0007]), + (7.8746, [0.0101, 0.0055, -0.0018]), + (9.4495, [0.0061, 0.002, -0.0012]), + (23.624, [0.004, 0.0015, -0.0013]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0149, 0.0624, 0.0664]), + (0.79673, [0.0121, 0.0582, 0.0636]), + (1.5935, [0.0093, 0.0576, 0.0611]), + (3.9837, [0.0063, 0.0507, 0.055]), + (4.7804, [0.0036, 0.0251, 0.0285]), + (5.5771, [0.0011, 0.0073, 0.0086]), + (6.3739, [-0.0007, 0.0002, 0.0006]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.2896, -0.3971, -0.0464]), + (0.78812, [0.2996, -0.3847, -0.0454]), + (1.5762, [0.2931, -0.3876, -0.0499]), + (3.9406, [0.2903, -0.3596, -0.0382]), + (6.305, [0.2846, -0.3153, -0.0265]), + (8.6693, [0.2723, -0.2607, -0.0091]), + (12.61, [0.2427, -0.1671, 0.014]), + (15.762, [0.2141, -0.1023, 0.0258]), + (18.127, [0.1911, -0.0602, 0.0315]), + (20.491, [0.1693, -0.0232, 0.0384]), + (22.067, [0.1544, -0.0026, 0.0413]), + (23.644, [0.1401, 0.0149, 0.0441]), + (25.22, [0.1241, 0.0261, 0.0433]), + (26.796, [0.1096, 0.0343, 0.0426]), + (28.372, [0.0946, 0.0375, 0.0397]), + (30.737, [0.075, 0.0383, 0.0338]), + (35.465, [0.0402, 0.0255, 0.0192]), + (38.618, [0.0251, 0.0181, 0.0127]), + (42.559, [0.0116, 0.0092, 0.0054]), + (46.499, [0.0042, 0.0033, 0.0014]), + (49.652, [0.0019, 0.0018, 0.0008]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.2846, 0.3732, 0.586]), + (0.79199, [0.3393, 0.4185, 0.6065]), + (1.584, [0.3142, 0.3773, 0.5339]), + (3.168, [0.2212, 0.2537, 0.3256]), + (3.96, [0.1677, 0.1892, 0.2321]), + (4.752, [0.1241, 0.1357, 0.1494]), + (5.544, [0.0927, 0.0985, 0.096]), + (6.336, [0.0549, 0.0578, 0.0498]), + (7.1279, [0.0323, 0.0345, 0.0225]), + (7.9199, [0.0293, 0.0315, 0.0176]), + (9.5039, [0.0341, 0.0355, 0.0202]), + (15.048, [0.0413, 0.0413, 0.0228]), + (20.592, [0.0379, 0.0385, 0.0213]), + (30.096, [0.0395, 0.04, 0.0214]), + (30.888, [0.0282, 0.029, 0.0163]), + (31.68, [0.0052, 0.0063, 0.0034]), + (32.472, [-0.0004, 0.0007, -0.0002]), + ], + }, + Radial { + t: 2.5968, + perp: 0.0, + knots: &[ + (0.0, [-0.0073, -0.0099, -0.0048]), + (0.79014, [-0.0045, -0.0063, 0.0003]), + (1.5803, [-0.0015, -0.0037, 0.0028]), + (5.531, [-0.0004, -0.001, 0.0024]), + (7.1113, [0.0015, 0.0012, 0.0045]), + (10.272, [0.0016, 0.0025, 0.0031]), + (16.593, [-0.0018, -0.0013, -0.0001]), + (22.914, [0.0008, 0.0008, 0.0004]), + (30.025, [-0.001, -0.0006, -0.0018]), + (34.766, [0.0005, 0.0021, -0.0006]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [-0.0008, 0.0078, 0.0064]), + (0.7893, [-0.0014, 0.0043, 0.0029]), + (1.5786, [-0.0006, 0.0065, 0.0046]), + (2.3679, [-0.0019, 0.0042, 0.002]), + (3.9465, [-0.0024, 0.0037, 0.0011]), + (7.1037, [-0.0008, 0.0045, 0.0034]), + (7.893, [-0.002, 0.0038, 0.0026]), + (11.839, [-0.0008, 0.0087, 0.0082]), + (14.997, [-0.0006, 0.0144, 0.0145]), + (17.365, [0.0041, 0.018, 0.0191]), + (19.732, [0.0052, 0.0237, 0.0234]), + (24.468, [0.0125, 0.0392, 0.0389]), + (26.836, [0.0127, 0.0454, 0.0462]), + (29.204, [0.0168, 0.0545, 0.0568]), + (29.993, [0.0173, 0.0564, 0.0578]), + (30.783, [0.0137, 0.0445, 0.0459]), + (31.572, [0.0032, 0.0124, 0.0127]), + (32.361, [-0.0011, 0.0012, 0.0008]), + (33.15, [-0.0021, -0.0014, -0.0011]), + (41.043, [-0.0001, -0.0005, 0.0]), + (42.622, [-0.0022, -0.0034, -0.0013]), + (44.201, [-0.0007, -0.0014, -0.0008]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (2.3556, [0.0, 0.0, 0.0]), + (3.1407, [-0.017, -0.0079, -0.0104]), + (3.9259, [-0.0188, -0.0132, -0.0129]), + (4.7111, [-0.0131, -0.0058, -0.0061]), + (5.4963, [-0.0111, -0.0076, -0.0071]), + (6.2815, [-0.0092, -0.0043, -0.0031]), + (7.0667, [-0.0132, -0.0095, -0.0078]), + (7.8519, [-0.0119, -0.0104, -0.0072]), + (9.4222, [-0.0082, -0.0064, -0.0022]), + (10.993, [-0.007, -0.0049, -0.0015]), + (11.778, [-0.0079, -0.0061, -0.0033]), + (13.348, [-0.0072, -0.0064, -0.0028]), + (14.133, [-0.0053, -0.0034, 0.0]), + (14.919, [-0.0055, -0.0037, 0.0013]), + (15.704, [-0.0027, -0.001, 0.0034]), + (16.489, [-0.0063, -0.004, -0.0004]), + (18.059, [-0.0053, -0.0034, 0.0004]), + (19.63, [-0.0069, -0.0055, -0.0016]), + ], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0091, 0.0966, 0.0887]), + (0.7998, [0.0085, 0.0612, 0.0564]), + (1.5996, [0.0032, 0.0308, 0.0279]), + (2.3994, [-0.0006, 0.0025, 0.0026]), + (3.1992, [-0.0004, 0.0002, -0.0012]), + ], + }, + Radial { + t: 0.87489, + perp: 0.0, + knots: &[ + (0.0, [0.0031, 0.0, -0.0019]), + (0.78535, [0.0002, 0.0008, -0.0007]), + (3.9268, [0.0036, 0.0022, 0.0011]), + (5.4975, [0.0027, 0.0011, 0.0002]), + (6.2828, [0.0005, -0.0004, -0.0022]), + (11.78, [0.0006, -0.0003, -0.0014]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [0.0407, -0.0825, -0.0081]), + (0.79316, [0.0417, -0.0777, -0.0064]), + (1.5863, [0.044, -0.071, -0.0009]), + (4.759, [0.0449, -0.066, -0.0007]), + (5.5521, [0.0474, -0.0602, 0.0007]), + (7.9316, [0.0483, -0.0477, 0.0022]), + (9.5179, [0.05, -0.0374, 0.0055]), + (10.311, [0.0481, -0.0336, 0.0063]), + (12.691, [0.0485, -0.0143, 0.02]), + (14.277, [0.0447, -0.0069, 0.0214]), + (15.07, [0.0434, -0.004, 0.0185]), + (15.863, [0.0427, -0.0028, 0.0167]), + (16.656, [0.0418, -0.0066, 0.0125]), + (17.45, [0.0402, -0.0154, 0.0085]), + (18.243, [0.039, -0.016, 0.0069]), + (21.415, [0.0331, -0.0049, 0.0089]), + (25.381, [0.0243, 0.0042, 0.0098]), + (30.14, [0.0136, 0.0065, 0.0072]), + (34.106, [0.0064, 0.0033, 0.0036]), + (38.865, [0.0013, -0.0004, 0.0]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.2696, 0.0714, -0.0124]), + (0.77016, [0.2295, 0.0442, -0.0455]), + (1.5403, [0.1607, -0.0073, -0.0591]), + (3.0807, [0.0761, -0.0646, -0.0699]), + (3.8508, [0.045, -0.0799, -0.0667]), + (4.621, [0.0267, -0.0799, -0.0616]), + (5.3911, [0.0136, -0.0807, -0.055]), + (6.9315, [0.0013, -0.0613, -0.039]), + (7.7016, [-0.0021, -0.0507, -0.0321]), + (9.242, [-0.005, -0.0319, -0.0193]), + (10.012, [-0.0044, -0.0235, -0.0145]), + (12.323, [-0.0007, -0.0048, -0.0034]), + ], + }, + Radial { + t: 1.6547, + perp: 0.0, + knots: &[ + (0.0, [0.0108, 0.0094, 0.0067]), + (0.78645, [0.0037, 0.0057, 0.0016]), + (1.5729, [0.0053, 0.0072, 0.0043]), + (3.1458, [0.0019, 0.0015, -0.0005]), + (9.4374, [0.0065, 0.0049, 0.0048]), + (11.797, [0.0036, 0.0023, 0.0009]), + (18.088, [0.0018, -0.0008, -0.0001]), + ], + }, + Radial { + t: 2.6079, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, -0.0002, -0.0101]), + (0.79055, [-0.0007, 0.0015, -0.0073]), + (1.5811, [-0.0002, 0.0007, -0.0059]), + (2.3716, [0.0016, 0.0024, -0.0024]), + (4.7433, [-0.0006, -0.0003, -0.0049]), + (7.1149, [-0.0005, -0.0006, -0.0043]), + (8.696, [-0.0022, -0.0033, -0.0054]), + (11.068, [0.0003, -0.0008, -0.0022]), + (13.439, [0.0004, -0.0003, -0.0028]), + (15.811, [0.0027, 0.0014, -0.0005]), + (16.602, [0.0015, 0.0009, -0.001]), + ], + }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.1735, 0.0997, 0.1048]), + (0.79824, [0.1751, 0.1045, 0.1072]), + (3.193, [0.1724, 0.1007, 0.1057]), + (11.175, [0.1685, 0.1013, 0.1076]), + (20.754, [0.1603, 0.0983, 0.1031]), + (31.93, [0.1446, 0.092, 0.0945]), + (40.71, [0.1288, 0.0825, 0.0837]), + (45.5, [0.1241, 0.0781, 0.0792]), + (46.298, [0.1204, 0.0739, 0.0747]), + (47.096, [0.0801, 0.0512, 0.0514]), + (47.895, [0.032, 0.0245, 0.0249]), + (48.693, [0.022, 0.0167, 0.0162]), + (54.281, [0.0185, 0.0115, 0.0126]), + (56.675, [0.0148, 0.008, 0.0099]), + (59.868, [0.0142, 0.0077, 0.01]), + (60.666, [0.0126, 0.006, 0.0088]), + (61.465, [0.0092, 0.001, 0.0069]), + (62.263, [0.0083, -0.0003, 0.0064]), + (64.658, [0.0124, 0.0067, 0.0082]), + (69.447, [0.0142, 0.0104, 0.0092]), + (74.237, [0.0119, 0.0075, 0.0081]), + (83.816, [0.0109, 0.0068, 0.0093]), + (94.193, [0.0081, 0.0051, 0.0076]), + (113.35, [0.0018, -0.0004, -0.0005]), + ], + }, +]; +const PRIME105_150_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_150_RAYS: &[Ray] = &[ + Ray { angle: 0.28, r0: 67.969, r1: 148.44, fade: 0.05, rgb: [0.0078, 0.0059, 0.002] }, + Ray { angle: 2.93, r0: 117.19, r1: 164.84, fade: 0.05, rgb: [0.0078, 0.0069, 0.0059] }, + Ray { angle: 17.46, r0: 102.34, r1: 277.34, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 24.65, r0: 70.312, r1: 262.5, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 39.06, r0: 99.219, r1: 211.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 46.03, r0: 164.06, r1: 275.0, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 53.43, r0: 83.594, r1: 262.5, fade: 0.05, rgb: [0.0108, 0.0098, 0.0078] }, + Ray { angle: 60.39, r0: 131.25, r1: 360.94, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 89.04, r0: 76.562, r1: 404.69, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 94.19, r0: 189.06, r1: 246.88, fade: 0.05, rgb: [0.0059, 0.0039, 0.0039] }, + Ray { angle: 96.31, r0: 189.84, r1: 277.34, fade: 0.05, rgb: [0.0137, 0.0098, 0.0098] }, + Ray { angle: 96.82, r0: 219.53, r1: 278.12, fade: 0.05, rgb: [0.0157, 0.0157, 0.0137] }, + Ray { angle: 97.32, r0: 96.875, r1: 272.66, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 97.6, r0: 94.531, r1: 247.66, fade: 0.05, rgb: [0.0098, 0.0098, 0.0059] }, + Ray { angle: 103.35, r0: 124.22, r1: 160.16, fade: 0.05, rgb: [0.0078, 0.0059, 0.0059] }, + Ray { angle: 104.37, r0: 163.28, r1: 417.19, fade: 0.05, rgb: [0.0137, 0.0118, 0.0118] }, + Ray { angle: 110.59, r0: 66.406, r1: 285.94, fade: 0.05, rgb: [0.0157, 0.0137, 0.0108] }, + Ray { angle: 118.67, r0: 86.719, r1: 265.62, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 125.73, r0: 158.59, r1: 217.97, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 147.27, r0: 100.0, r1: 278.12, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 161.57, r0: 67.969, r1: 246.88, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 168.73, r0: 82.812, r1: 238.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 183.03, r0: 146.09, r1: 190.62, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, + Ray { angle: 197.54, r0: 99.219, r1: 245.31, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 204.76, r0: 78.125, r1: 257.81, fade: 0.05, rgb: [0.0098, 0.0078, 0.0059] }, + Ray { angle: 219.23, r0: 85.938, r1: 212.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0059] }, + Ray { angle: 225.87, r0: 118.75, r1: 226.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 233.25, r0: 123.44, r1: 243.75, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 240.27, r0: 153.12, r1: 224.22, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 269.03, r0: 142.19, r1: 195.31, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 269.75, r0: 54.688, r1: 155.47, fade: 0.05, rgb: [0.0078, 0.0167, 0.002] }, + Ray { angle: 270.22, r0: 74.219, r1: 163.28, fade: 0.05, rgb: [0.0118, 0.0196, 0.002] }, + Ray { angle: 275.2, r0: 135.16, r1: 196.09, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 276.15, r0: 67.969, r1: 86.719, fade: 0.05, rgb: [0.0059, 0.0059, 0.0] }, + Ray { angle: 283.43, r0: 141.41, r1: 200.78, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 284.45, r0: 108.59, r1: 201.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 290.67, r0: 71.094, r1: 208.59, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 298.8, r0: 111.72, r1: 222.66, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 305.84, r0: 171.09, r1: 226.56, fade: 0.05, rgb: [0.0069, 0.0069, 0.0049] }, + Ray { angle: 327.39, r0: 90.625, r1: 350.0, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 341.63, r0: 72.656, r1: 396.88, fade: 0.05, rgb: [0.0216, 0.0216, 0.0176] }, + Ray { angle: 348.78, r0: 79.688, r1: 575.78, fade: 0.05, rgb: [0.0216, 0.0216, 0.0176] }, +]; +const PRIME105_200_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.7926, 0.6984, 0.5194]), + (44.0, [0.7514, 0.6746, 0.5372]), + (114.4, [0.6147, 0.5914, 0.5933]), + (155.2, [0.5354, 0.5446, 0.6049]), + (202.4, [0.4227, 0.4741, 0.5927]), + (271.2, [0.2119, 0.3247, 0.548]), + (290.4, [0.1717, 0.2882, 0.5295]), + (304.8, [0.1529, 0.2666, 0.513]), + (314.4, [0.1517, 0.2613, 0.5045]), + (324.8, [0.1602, 0.2642, 0.4987]), + (340.8, [0.1996, 0.288, 0.4936]), + (356.8, [0.2496, 0.3215, 0.4911]), + (375.2, [0.3393, 0.3793, 0.4914]), + (382.4, [0.3665, 0.4037, 0.4887]), + (405.6, [0.4256, 0.4357, 0.4579]), + (424.8, [0.4806, 0.4422, 0.4369]), + (432.0, [0.4996, 0.4509, 0.4177]), + (449.6, [0.5132, 0.4375, 0.3357]), + (460.0, [0.5221, 0.4321, 0.3133]), + (470.4, [0.5162, 0.4174, 0.3104]), + (473.6, [0.5125, 0.4146, 0.3151]), + (484.0, [0.4673, 0.3883, 0.3355]), + (489.6, [0.4199, 0.3551, 0.3445]), + (494.4, [0.368, 0.3195, 0.3531]), + (500.8, [0.2832, 0.2611, 0.3675]), + (506.4, [0.2013, 0.2103, 0.394]), + (512.0, [0.1047, 0.1571, 0.421]), + (515.2, [0.051, 0.1315, 0.4397]), + (520.0, [-0.0226, 0.1019, 0.4738]), + (525.6, [-0.1035, 0.0742, 0.5104]), + (530.4, [-0.1756, 0.044, 0.5231]), + (536.0, [-0.2353, 0.0243, 0.5392]), + (537.6, [-0.2489, 0.0214, 0.5434]), + (540.8, [-0.2668, 0.0235, 0.5551]), + (544.8, [-0.2894, 0.0228, 0.5577]), + (548.8, [-0.3007, 0.0282, 0.5571]), + (550.4, [-0.3017, 0.0325, 0.5562]), + (553.6, [-0.2957, 0.0449, 0.5555]), + (558.4, [-0.2698, 0.0708, 0.5497]), + (560.8, [-0.2447, 0.0934, 0.5498]), + (563.2, [-0.2113, 0.1217, 0.5507]), + (569.6, [-0.1018, 0.2157, 0.5516]), + (575.2, [0.0025, 0.3021, 0.5465]), + (580.0, [0.1043, 0.3839, 0.5462]), + (583.2, [0.1794, 0.4421, 0.5503]), + (592.0, [0.3415, 0.5341, 0.5345]), + (596.0, [0.3972, 0.55, 0.5233]), + (601.6, [0.4448, 0.539, 0.4988]), + (610.4, [0.4715, 0.4913, 0.4555]), + (616.8, [0.4916, 0.4758, 0.4461]), + (623.2, [0.5347, 0.4869, 0.4575]), + (632.8, [0.6596, 0.5474, 0.5149]), + (644.8, [0.793, 0.609, 0.5778]), + (648.8, [0.7964, 0.5977, 0.5804]), + (654.4, [0.7243, 0.5296, 0.558]), + (657.6, [0.6397, 0.4558, 0.5166]), + (660.0, [0.5641, 0.3892, 0.4791]), + (664.0, [0.3975, 0.2489, 0.3915]), + (666.4, [0.2736, 0.1493, 0.3247]), + (669.6, [0.0912, 0.0033, 0.2248]), + (670.4, [0.0429, -0.0354, 0.1988]), + (672.8, [-0.1122, -0.1585, 0.1152]), + (674.4, [-0.2206, -0.2408, 0.0616]), + (676.0, [-0.344, -0.3361, -0.004]), + (676.8, [-0.4091, -0.3872, -0.0392]), + (680.0, [-0.6813, -0.5988, -0.174]), + (684.0, [-1.089, -0.9121, -0.3792]), + (686.4, [-1.373, -1.133, -0.5266]), + (691.2, [-1.879, -1.533, -0.8161]), + (693.6, [-2.04, -1.657, -0.9227]), + (696.0, [-2.121, -1.722, -1.011]), + (697.6, [-2.088, -1.694, -1.026]), + (700.0, [-1.913, -1.556, -1.001]), + (701.6, [-1.69, -1.38, -0.9269]), + (703.2, [-1.379, -1.127, -0.7994]), + (704.0, [-1.197, -0.9793, -0.7229]), + (704.8, [-0.9914, -0.8092, -0.6314]), + (705.6, [-0.7533, -0.6126, -0.5205]), + (706.4, [-0.4845, -0.3899, -0.394]), + (707.2, [-0.1806, -0.1369, -0.2471]), + (708.0, [0.1601, 0.1487, -0.0784]), + (708.8, [0.5371, 0.4662, 0.1109]), + (709.6, [0.9496, 0.8168, 0.3231]), + (711.2, [1.804, 1.545, 0.7671]), + (712.0, [2.175, 1.861, 0.958]), + (712.8, [2.461, 2.105, 1.103]), + (713.6, [2.661, 2.274, 1.203]), + (714.4, [2.784, 2.377, 1.266]), + (716.0, [2.837, 2.421, 1.304]), + (717.6, [2.691, 2.303, 1.269]), + (718.4, [2.555, 2.194, 1.23]), + (720.0, [2.202, 1.907, 1.129]), + (721.6, [1.768, 1.552, 1.002]), + (723.2, [1.292, 1.163, 0.8543]), + (725.6, [0.5835, 0.5797, 0.5727]), + (726.4, [0.3716, 0.4066, 0.4724]), + (727.2, [0.1759, 0.2476, 0.3713]), + (728.0, [-0.0025, 0.1015, 0.2716]), + (728.8, [-0.1551, -0.0234, 0.1829]), + (729.6, [-0.2829, -0.1292, 0.1053]), + (730.4, [-0.3879, -0.2169, 0.041]), + (731.2, [-0.4798, -0.2961, -0.0228]), + (732.8, [-0.6246, -0.4335, -0.1454]), + (733.6, [-0.6786, -0.4914, -0.2025]), + (734.4, [-0.7209, -0.5392, -0.2512]), + (735.2, [-0.7415, -0.5672, -0.2794]), + (736.0, [-0.7424, -0.5768, -0.2881]), + (736.8, [-0.7206, -0.5653, -0.2772]), + (737.6, [-0.6801, -0.5368, -0.2517]), + (738.4, [-0.6261, -0.4956, -0.2156]), + (739.2, [-0.5616, -0.4441, -0.1707]), + (740.0, [-0.4916, -0.387, -0.123]), + (741.6, [-0.3557, -0.2737, -0.0411]), + (742.4, [-0.2966, -0.2247, -0.0076]), + (743.2, [-0.2462, -0.1835, 0.0208]), + (744.0, [-0.2103, -0.1541, 0.0389]), + (744.8, [-0.1819, -0.1305, 0.0547]), + (745.6, [-0.1667, -0.1196, 0.0634]), + (746.4, [-0.1675, -0.1249, 0.0625]), + (747.2, [-0.1863, -0.1479, 0.0485]), + (748.0, [-0.2249, -0.1902, 0.0164]), + (748.8, [-0.2785, -0.2468, -0.0305]), + (750.4, [-0.3909, -0.3638, -0.1312]), + (751.2, [-0.434, -0.4093, -0.1725]), + (752.0, [-0.4694, -0.4484, -0.2115]), + (753.6, [-0.5216, -0.5215, -0.2933]), + (755.2, [-0.5372, -0.5547, -0.3775]), + (756.8, [-0.5336, -0.5704, -0.476]), + (759.2, [-0.5007, -0.5443, -0.5899]), + (760.8, [-0.498, -0.5423, -0.6974]), + (761.6, [-0.5041, -0.5543, -0.7565]), + (763.2, [-0.5331, -0.5989, -0.8815]), + (765.6, [-0.5933, -0.6805, -1.088]), + (768.0, [-0.6515, -0.7583, -1.351]), + (768.8, [-0.6615, -0.7765, -1.435]), + (771.2, [-0.6598, -0.8116, -1.676]), + (775.2, [-0.6658, -0.8927, -2.025]), + (777.6, [-0.7237, -1.007, -2.252]), + (780.8, [-0.7565, -1.09, -2.454]), + (784.0, [-0.865, -1.267, -2.657]), + (785.6, [-0.9204, -1.349, -2.747]), + (787.2, [-0.9547, -1.414, -2.806]), + (794.4, [-1.052, -1.606, -2.91]), + (800.0, [-1.026, -1.591, -2.936]), + (803.2, [-0.9692, -1.535, -2.901]), + (806.4, [-0.8705, -1.437, -2.726]), + (808.0, [-0.7915, -1.357, -2.543]), + (809.6, [-0.6785, -1.234, -2.323]), + (811.2, [-0.5473, -1.078, -2.069]), + (812.0, [-0.4849, -0.9807, -1.946]), + (812.8, [-0.4152, -0.8772, -1.797]), + (813.6, [-0.3212, -0.7425, -1.606]), + (814.4, [-0.2127, -0.5852, -1.364]), + (815.2, [-0.0923, -0.4106, -1.099]), + (816.0, [0.0095, -0.2407, -0.826]), + (816.8, [0.0931, -0.0749, -0.4975]), + (817.6, [0.1349, 0.0608, -0.1509]), + (818.4, [0.1089, 0.1362, 0.158]), + (819.2, [0.0556, 0.1498, 0.4088]), + (820.0, [0.0703, 0.1822, 0.6078]), + (820.8, [0.0379, 0.076, 0.4067]), + (821.6, [0.0639, -0.0137, -0.146]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.1317, 0.2654, 0.5565]), + (0.8, [0.1353, 0.2684, 0.5632]), + (8.0, [0.1359, 0.2678, 0.5604]), + (21.6, [0.1455, 0.2736, 0.5582]), + (43.2, [0.174, 0.2904, 0.5453]), + (115.2, [0.3155, 0.3765, 0.4874]), + (157.6, [0.3998, 0.4263, 0.4762]), + (206.4, [0.52, 0.5018, 0.4901]), + (272.8, [0.7264, 0.6492, 0.535]), + (296.0, [0.7728, 0.6928, 0.559]), + (311.2, [0.7857, 0.7103, 0.5753]), + (328.0, [0.7716, 0.7058, 0.5851]), + (356.8, [0.6887, 0.6527, 0.5918]), + (369.6, [0.6283, 0.6149, 0.5914]), + (383.2, [0.5441, 0.5635, 0.5944]), + (406.4, [0.4186, 0.4719, 0.6255]), + (420.0, [0.3426, 0.4359, 0.6403]), + (432.8, [0.2715, 0.3877, 0.6523]), + (451.2, [0.2039, 0.3514, 0.6873]), + (455.2, [0.1851, 0.3393, 0.6828]), + (468.0, [0.1478, 0.3199, 0.6619]), + (474.4, [0.1401, 0.314, 0.6434]), + (479.2, [0.1428, 0.3089, 0.6239]), + (485.6, [0.1607, 0.3141, 0.5929]), + (490.4, [0.1903, 0.3313, 0.5734]), + (495.2, [0.2294, 0.3548, 0.5507]), + (500.8, [0.2879, 0.3903, 0.5222]), + (506.4, [0.3546, 0.4286, 0.4836]), + (514.4, [0.4705, 0.4837, 0.4241]), + (526.4, [0.6214, 0.5248, 0.3139]), + (531.2, [0.681, 0.5432, 0.2883]), + (538.4, [0.7348, 0.5471, 0.248]), + (541.6, [0.7439, 0.5366, 0.2274]), + (549.6, [0.7569, 0.512, 0.2055]), + (553.6, [0.7435, 0.4889, 0.1973]), + (559.2, [0.7004, 0.4442, 0.1895]), + (564.0, [0.6291, 0.3811, 0.1768]), + (566.4, [0.5808, 0.3398, 0.1673]), + (576.8, [0.3649, 0.1571, 0.1524]), + (578.4, [0.3243, 0.1228, 0.1457]), + (581.6, [0.2472, 0.0613, 0.1407]), + (583.2, [0.2028, 0.0256, 0.1329]), + (584.0, [0.1824, 0.0099, 0.1307]), + (585.6, [0.1477, -0.0142, 0.1315]), + (588.0, [0.0992, -0.0446, 0.1348]), + (591.2, [0.0277, -0.088, 0.1277]), + (592.8, [-0.0052, -0.1053, 0.126]), + (596.8, [-0.0729, -0.131, 0.1277]), + (602.4, [-0.1369, -0.1336, 0.1392]), + (606.4, [-0.1632, -0.1204, 0.1542]), + (608.8, [-0.179, -0.1157, 0.1569]), + (611.2, [-0.1859, -0.1057, 0.1648]), + (613.6, [-0.2044, -0.1091, 0.1589]), + (616.8, [-0.2171, -0.1033, 0.1638]), + (617.6, [-0.2225, -0.1046, 0.1617]), + (622.4, [-0.2644, -0.1219, 0.1456]), + (624.8, [-0.2927, -0.1374, 0.1325]), + (628.8, [-0.3559, -0.1745, 0.0985]), + (631.2, [-0.3884, -0.1893, 0.0812]), + (633.6, [-0.4302, -0.2121, 0.0595]), + (636.0, [-0.4602, -0.2273, 0.0482]), + (637.6, [-0.4851, -0.2421, 0.0347]), + (639.2, [-0.504, -0.2515, 0.0252]), + (640.8, [-0.5275, -0.2639, 0.0106]), + (642.4, [-0.548, -0.2731, 0.0001]), + (645.6, [-0.5938, -0.2977, -0.027]), + (647.2, [-0.6063, -0.3011, -0.0329]), + (648.8, [-0.6078, -0.2967, -0.0345]), + (650.4, [-0.6074, -0.2933, -0.041]), + (652.0, [-0.5953, -0.2805, -0.0393]), + (652.8, [-0.5842, -0.2699, -0.0349]), + (655.2, [-0.5509, -0.2397, -0.0287]), + (656.0, [-0.536, -0.2264, -0.0238]), + (658.4, [-0.4751, -0.1732, 0.0038]), + (661.6, [-0.3764, -0.0877, 0.0493]), + (664.0, [-0.2797, -0.0051, 0.0994]), + (664.8, [-0.2455, 0.0222, 0.1149]), + (665.6, [-0.2069, 0.0525, 0.1344]), + (668.0, [-0.0821, 0.1518, 0.1992]), + (671.2, [0.0998, 0.2953, 0.2941]), + (675.2, [0.3575, 0.4956, 0.4246]), + (677.6, [0.5423, 0.638, 0.5204]), + (680.8, [0.8104, 0.8469, 0.6515]), + (684.8, [1.213, 1.155, 0.8504]), + (688.0, [1.588, 1.447, 1.044]), + (692.0, [2.001, 1.771, 1.279]), + (694.4, [2.161, 1.893, 1.383]), + (697.6, [2.239, 1.954, 1.482]), + (700.0, [2.113, 1.85, 1.468]), + (701.6, [1.942, 1.715, 1.424]), + (703.2, [1.681, 1.507, 1.327]), + (704.8, [1.337, 1.226, 1.179]), + (705.6, [1.136, 1.06, 1.091]), + (706.4, [0.9006, 0.8647, 0.9808]), + (707.2, [0.6322, 0.6417, 0.8511]), + (708.0, [0.3302, 0.3894, 0.7029]), + (708.8, [-0.0061, 0.1075, 0.5361]), + (709.6, [-0.3793, -0.2066, 0.3483]), + (710.4, [-0.7883, -0.553, 0.1393]), + (711.2, [-1.231, -0.9306, -0.0914]), + (712.0, [-1.685, -1.318, -0.3301]), + (712.8, [-2.112, -1.684, -0.5529]), + (713.6, [-2.476, -1.994, -0.7392]), + (714.4, [-2.747, -2.225, -0.875]), + (715.2, [-2.935, -2.385, -0.9705]), + (716.0, [-3.05, -2.482, -1.032]), + (716.8, [-3.099, -2.523, -1.063]), + (718.4, [-3.036, -2.475, -1.062]), + (719.2, [-2.937, -2.396, -1.04]), + (720.8, [-2.639, -2.156, -0.9624]), + (722.4, [-2.246, -1.838, -0.8572]), + (724.8, [-1.573, -1.29, -0.6635]), + (726.4, [-1.122, -0.9215, -0.4972]), + (727.2, [-0.9093, -0.7476, -0.403]), + (728.0, [-0.7144, -0.5898, -0.3099]), + (728.8, [-0.5401, -0.4496, -0.2208]), + (729.6, [-0.3841, -0.3228, -0.134]), + (730.4, [-0.2525, -0.2157, -0.0566]), + (731.2, [-0.1453, -0.1276, 0.009]), + (732.0, [-0.0601, -0.0565, 0.0609]), + (732.8, [0.0118, 0.0062, 0.1111]), + (733.6, [0.0733, 0.0639, 0.162]), + (734.4, [0.1184, 0.1108, 0.2061]), + (735.2, [0.1532, 0.1516, 0.2468]), + (736.0, [0.18, 0.185, 0.2804]), + (736.8, [0.1873, 0.2008, 0.2945]), + (737.6, [0.1741, 0.1981, 0.2883]), + (738.4, [0.1367, 0.1723, 0.261]), + (739.2, [0.0793, 0.1272, 0.2176]), + (740.0, [0.0097, 0.0709, 0.1639]), + (741.6, [-0.1545, -0.0672, 0.0384]), + (742.4, [-0.23, -0.1312, -0.0131]), + (744.0, [-0.3684, -0.2528, -0.1093]), + (744.8, [-0.4239, -0.3021, -0.1513]), + (746.4, [-0.4977, -0.3689, -0.2153]), + (747.2, [-0.5193, -0.3881, -0.2409]), + (748.0, [-0.5254, -0.3918, -0.2578]), + (748.8, [-0.5071, -0.3708, -0.2551]), + (749.6, [-0.473, -0.3329, -0.2373]), + (751.2, [-0.3734, -0.2307, -0.1693]), + (752.0, [-0.3264, -0.1872, -0.1387]), + (752.8, [-0.3048, -0.1789, -0.1306]), + (753.6, [-0.2973, -0.1926, -0.1336]), + (754.4, [-0.3059, -0.2248, -0.1484]), + (755.2, [-0.321, -0.2658, -0.1624]), + (756.0, [-0.3535, -0.3302, -0.1878]), + (756.8, [-0.3984, -0.4052, -0.223]), + (758.4, [-0.4829, -0.5495, -0.2792]), + (760.0, [-0.562, -0.6941, -0.3687]), + (761.6, [-0.6308, -0.8209, -0.5008]), + (763.2, [-0.6601, -0.8745, -0.6294]), + (764.0, [-0.6599, -0.8752, -0.7051]), + (765.6, [-0.6337, -0.84, -0.8642]), + (767.2, [-0.5641, -0.7513, -1.016]), + (768.0, [-0.5236, -0.6963, -1.071]), + (768.8, [-0.4973, -0.6551, -1.142]), + (770.4, [-0.4741, -0.5986, -1.314]), + (772.0, [-0.4329, -0.5179, -1.466]), + (772.8, [-0.4269, -0.4922, -1.56]), + (774.4, [-0.4417, -0.4753, -1.749]), + (776.0, [-0.4743, -0.4974, -1.93]), + (776.8, [-0.4813, -0.5062, -1.994]), + (777.6, [-0.4914, -0.5279, -2.057]), + (778.4, [-0.513, -0.5716, -2.109]), + (780.0, [-0.5806, -0.7069, -2.211]), + (780.8, [-0.6425, -0.8172, -2.269]), + (782.4, [-0.8115, -1.13, -2.423]), + (783.2, [-0.882, -1.266, -2.495]), + (784.8, [-1.015, -1.524, -2.642]), + (785.6, [-1.06, -1.614, -2.699]), + (786.4, [-1.067, -1.639, -2.713]), + (788.0, [-1.041, -1.602, -2.693]), + (792.8, [-1.007, -1.524, -2.751]), + (795.2, [-1.014, -1.515, -2.796]), + (799.2, [-0.9618, -1.47, -2.767]), + (803.2, [-0.8822, -1.416, -2.649]), + (804.8, [-0.8168, -1.353, -2.554]), + (805.6, [-0.765, -1.3, -2.485]), + (808.0, [-0.7531, -1.26, -2.55]), + (809.6, [-0.7873, -1.285, -2.622]), + (810.4, [-0.7672, -1.274, -2.584]), + (811.2, [-0.7315, -1.245, -2.494]), + (812.0, [-0.6608, -1.164, -2.345]), + (812.8, [-0.6121, -1.108, -2.203]), + (813.6, [-0.4339, -0.898, -1.933]), + (814.4, [-0.2664, -0.7207, -1.66]), + (815.2, [-0.0797, -0.5244, -1.314]), + (816.0, [0.021, -0.3624, -0.7921]), + (816.8, [0.1033, -0.1711, -0.1526]), + (817.6, [0.1727, 0.0322, 0.3113]), + (818.4, [0.2038, 0.1774, 0.5556]), + (819.2, [0.2379, 0.1945, 0.5059]), + (820.0, [-0.0764, -0.0926, -0.0887]), + (820.8, [-0.5861, -0.6086, -1.046]), + (821.6, [0.2565, 0.3822, 0.1155]), + (822.4, [0.0056, -0.0036, -0.0877]), + (823.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0772, 0.0371, -0.0814]), + (353.35, [0.0625, 0.0264, -0.0824]), + (417.3, [0.0645, 0.0279, -0.0823]), + (466.07, [0.0699, 0.0308, -0.0838]), + (498.05, [0.0782, 0.0362, -0.083]), + (518.03, [0.0806, 0.0369, -0.0853]), + (522.83, [0.0841, 0.0399, -0.0832]), + (543.61, [0.0891, 0.0439, -0.0798]), + (562.0, [0.0895, 0.0433, -0.0811]), + (575.59, [0.0916, 0.0467, -0.076]), + (605.17, [0.0957, 0.0514, -0.0745]), + (628.35, [0.1071, 0.0606, -0.0733]), + (658.73, [0.1312, 0.0772, -0.0707]), + (676.32, [0.1377, 0.0793, -0.0731]), + (704.3, [0.1528, 0.0873, -0.0695]), + (719.49, [0.1555, 0.0876, -0.0692]), + (729.08, [0.1527, 0.0847, -0.0695]), + (745.87, [0.1389, 0.0725, -0.0773]), + (753.07, [0.1303, 0.0657, -0.082]), + (764.26, [0.1053, 0.0449, -0.0977]), + (766.66, [0.1057, 0.0449, -0.0992]), + (769.85, [0.1143, 0.0521, -0.0967]), + (774.65, [0.1448, 0.0766, -0.0836]), + (776.25, [0.1584, 0.0863, -0.0786]), + (777.85, [0.1754, 0.1004, -0.0708]), + (780.25, [0.2057, 0.1247, -0.0545]), + (783.44, [0.2387, 0.1533, -0.0353]), + (785.04, [0.2673, 0.1781, -0.0187]), + (787.44, [0.3227, 0.2275, 0.0173]), + (791.44, [0.4126, 0.3074, 0.0682]), + (796.24, [0.5036, 0.3873, 0.1153]), + (801.83, [0.5665, 0.4432, 0.1531]), + (808.23, [0.6579, 0.5261, 0.2193]), + (810.63, [0.6891, 0.5537, 0.2365]), + (814.62, [0.7571, 0.616, 0.2963]), + (817.82, [0.7808, 0.6368, 0.3258]), + (821.02, [0.8264, 0.6822, 0.3821]), + (823.42, [0.8469, 0.7061, 0.4154]), + (830.61, [0.9185, 0.7909, 0.5657]), + (831.41, [0.9252, 0.8055, 0.5847]), + (833.01, [0.9698, 0.8973, 0.6599]), + (835.41, [1.08, 1.12, 0.8373]), + (837.01, [1.166, 1.284, 0.9948]), + (838.61, [1.228, 1.42, 1.145]), + (839.41, [1.251, 1.475, 1.23]), + (841.8, [1.324, 1.623, 1.576]), + (843.4, [1.356, 1.676, 1.844]), + (845.0, [1.363, 1.689, 2.134]), + (847.4, [1.31, 1.621, 2.569]), + (849.8, [1.3, 1.584, 3.068]), + (852.2, [1.249, 1.514, 3.549]), + (853.79, [1.258, 1.524, 3.871]), + (856.19, [1.312, 1.617, 4.318]), + (857.79, [1.386, 1.766, 4.584]), + (859.39, [1.482, 1.963, 4.807]), + (860.19, [1.549, 2.094, 4.913]), + (864.19, [2.015, 2.961, 5.52]), + (864.99, [2.086, 3.092, 5.62]), + (865.79, [2.118, 3.151, 5.676]), + (875.38, [2.196, 3.289, 5.935]), + (879.38, [2.121, 3.238, 5.893]), + (882.57, [2.012, 3.146, 5.797]), + (884.97, [1.829, 2.967, 5.569]), + (888.17, [1.712, 2.809, 5.416]), + (888.97, [1.681, 2.781, 5.338]), + (889.77, [1.607, 2.71, 5.196]), + (890.57, [1.512, 2.618, 4.992]), + (891.37, [1.366, 2.456, 4.7]), + (892.17, [1.256, 2.315, 4.44]), + (893.77, [0.773, 1.718, 3.608]), + (894.57, [0.4839, 1.376, 3.056]), + (895.37, [0.2653, 1.047, 2.284]), + (896.16, [0.0671, 0.6838, 1.377]), + (896.96, [-0.0986, 0.3119, 0.622]), + (897.76, [-0.2027, 0.0037, 0.0263]), + (898.56, [-0.2614, -0.1368, -0.2718]), + (899.36, [0.0868, 0.0875, 0.0211]), + (900.16, [0.6392, 0.5913, 0.7396]), + (900.96, [-0.1996, -0.3848, -0.5084]), + (901.76, [0.0656, 0.1096, 0.0597]), + (902.56, [0.0011, -0.0002, -0.0025]), + (903.36, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [-0.2041, -0.1018, 0.0017]), + (0.79965, [-0.194, -0.0946, 0.0078]), + (1.5993, [-0.1987, -0.0995, 0.0035]), + (3.9983, [-0.1966, -0.1016, 0.0059]), + (9.5958, [-0.1918, -0.1156, -0.0004]), + (15.193, [-0.1663, -0.1187, -0.0073]), + (19.991, [-0.1416, -0.1134, -0.0111]), + (24.789, [-0.1182, -0.1027, -0.0134]), + (31.986, [-0.0875, -0.0747, -0.0121]), + (35.185, [-0.0761, -0.0615, -0.0096]), + (38.383, [-0.0669, -0.0502, -0.0071]), + (42.382, [-0.0585, -0.041, -0.0048]), + (50.378, [-0.0498, -0.0321, -0.0032]), + (77.566, [-0.0346, -0.0221, -0.0036]), + (80.765, [-0.0388, -0.0249, -0.0037]), + (84.763, [-0.0375, -0.024, -0.0038]), + (112.75, [-0.0213, -0.0136, -0.0023]), + (131.94, [-0.0141, -0.0106, -0.006]), + (157.53, [0.007, 0.0029, -0.0048]), + (158.33, [0.0065, 0.0025, -0.005]), + (160.73, [-0.0023, -0.0029, -0.0052]), + (167.93, [-0.0001, -0.0017, -0.0055]), + (183.12, [-0.0012, -0.0024, -0.0056]), + (205.51, [0.0012, 0.0009, -0.0009]), + (219.11, [-0.0006, 0.0001, 0.0003]), + (231.9, [-0.0033, -0.0022, -0.001]), + (278.28, [-0.0019, -0.0011, -0.0005]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0509, 0.0376, 0.0331]), + (0.79862, [-0.0606, 0.0308, 0.0344]), + (1.5972, [-0.0568, 0.0329, 0.0321]), + (2.3959, [-0.0595, 0.0295, 0.0326]), + (3.1945, [-0.0614, 0.0238, 0.028]), + (3.9931, [-0.0647, 0.0191, 0.0284]), + (5.5903, [-0.0759, 0.0013, 0.0198]), + (6.3889, [-0.0832, -0.01, 0.0154]), + (7.9862, [-0.0926, -0.0293, 0.0084]), + (9.5834, [-0.0987, -0.045, 0.0027]), + (10.382, [-0.1002, -0.0518, -0.0003]), + (11.979, [-0.0968, -0.0595, -0.0033]), + (13.577, [-0.0885, -0.0619, -0.004]), + (14.375, [-0.0819, -0.0604, -0.0029]), + (16.771, [-0.0675, -0.0597, -0.0066]), + (18.368, [-0.0554, -0.0561, -0.0063]), + (23.959, [-0.0215, -0.0431, -0.0096]), + (27.952, [-0.0013, -0.0301, -0.0106]), + (33.542, [0.0243, -0.0031, -0.0068]), + (35.938, [0.0319, 0.0064, -0.0062]), + (37.535, [0.0379, 0.0136, -0.0037]), + (41.528, [0.0489, 0.0263, 0.0006]), + (43.125, [0.0501, 0.0281, -0.0003]), + (45.521, [0.0541, 0.0327, 0.002]), + (50.313, [0.0572, 0.0367, 0.0033]), + (83.855, [0.0394, 0.0248, 0.0026]), + (93.438, [0.0367, 0.0234, 0.0033]), + (111.01, [0.026, 0.0165, 0.0018]), + (138.96, [0.0135, 0.01, 0.005]), + (144.55, [0.0092, 0.0073, 0.0049]), + (149.34, [0.0029, 0.0033, 0.0053]), + (158.13, [-0.0025, -0.0001, 0.0043]), + (164.52, [-0.0034, -0.0007, 0.0046]), + (190.07, [-0.0009, 0.0011, 0.005]), + (203.65, [-0.0034, -0.0017, 0.0017]), + (220.42, [-0.0019, -0.0012, 0.0002]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.3136, 0.229, 0.0623]), + (0.79212, [0.3456, 0.2574, 0.0827]), + (2.3764, [0.333, 0.2461, 0.0763]), + (3.1685, [0.3422, 0.2533, 0.0778]), + (3.9606, [0.3252, 0.2388, 0.0688]), + (4.7527, [0.3406, 0.2526, 0.078]), + (5.5449, [0.3268, 0.2409, 0.0742]), + (6.337, [0.3326, 0.2472, 0.081]), + (7.9212, [0.3267, 0.2446, 0.087]), + (8.7134, [0.336, 0.2506, 0.0902]), + (9.5055, [0.231, 0.1747, 0.069]), + (10.298, [0.0668, 0.0523, 0.0273]), + (11.09, [0.0202, 0.016, 0.0103]), + (11.882, [0.022, 0.017, 0.0104]), + (12.674, [0.0167, 0.0134, 0.0086]), + (13.466, [0.0146, 0.0111, 0.0073]), + (14.258, [0.0094, 0.0064, 0.0033]), + (15.842, [0.0042, 0.0024, -0.0006]), + (18.219, [0.0016, 0.0006, -0.0007]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [-0.1805, -0.1582, -0.147]), + (0.79613, [-0.1872, -0.1708, -0.1562]), + (1.5923, [-0.1833, -0.1648, -0.153]), + (2.3884, [-0.1847, -0.167, -0.1557]), + (3.9806, [-0.1783, -0.1607, -0.1497]), + (9.5535, [-0.1706, -0.1528, -0.143]), + (13.534, [-0.1678, -0.1497, -0.1423]), + (25.476, [-0.1455, -0.1304, -0.1376]), + (35.03, [-0.1142, -0.1037, -0.1234]), + (42.195, [-0.0798, -0.0722, -0.1009]), + (43.787, [-0.0697, -0.0639, -0.0946]), + (46.175, [-0.0607, -0.0541, -0.0873]), + (49.36, [-0.0541, -0.0436, -0.0814]), + (50.952, [-0.0554, -0.0408, -0.0809]), + (54.137, [-0.044, -0.0269, -0.0726]), + (57.321, [-0.0382, -0.0178, -0.0696]), + (59.709, [-0.0309, -0.0085, -0.0643]), + (63.69, [-0.0238, 0.0012, -0.0594]), + (64.486, [-0.0272, -0.0034, -0.0613]), + (65.282, [-0.035, -0.015, -0.0651]), + (66.078, [-0.0445, -0.0303, -0.0687]), + (66.875, [-0.0491, -0.0391, -0.0701]), + (68.467, [-0.0507, -0.0423, -0.0709]), + (82.001, [-0.044, -0.0397, -0.0684]), + (89.166, [-0.0417, -0.0397, -0.0701]), + (95.535, [-0.0367, -0.0361, -0.0627]), + (101.9, [-0.027, -0.0261, -0.0438]), + (109.07, [-0.0161, -0.0127, -0.0172]), + (110.66, [-0.012, -0.0084, -0.0105]), + (114.64, [-0.0066, -0.0027, -0.0017]), + (117.83, [-0.0035, -0.0011, 0.0023]), + (122.6, [0.0003, 0.0003, 0.0025]), + (126.58, [0.0001, -0.0001, 0.0008]), + (128.18, [-0.0012, -0.0015, -0.0009]), + (133.75, [-0.0019, -0.0018, -0.0008]), + (134.55, [-0.004, -0.0037, -0.0024]), + (137.73, [-0.0045, -0.0038, -0.0024]), + (138.53, [-0.0062, -0.0055, -0.004]), + (140.91, [-0.0047, -0.0037, -0.0015]), + (145.69, [-0.005, -0.0035, -0.0015]), + (151.26, [-0.0072, -0.0065, -0.0024]), + (155.24, [-0.0047, -0.0048, -0.0002]), + (160.82, [-0.0047, -0.0041, -0.0011]), + (162.41, [-0.0028, -0.002, -0.0004]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.3461, 0.3718, 0.335]), + (0.79168, [0.2913, 0.3338, 0.332]), + (1.5834, [0.2429, 0.308, 0.3313]), + (2.375, [0.1641, 0.2597, 0.322]), + (3.1667, [0.1333, 0.2442, 0.3204]), + (3.9584, [0.1252, 0.2397, 0.3179]), + (4.7501, [0.1286, 0.247, 0.3203]), + (5.5418, [0.1247, 0.2403, 0.3166]), + (6.3335, [0.1146, 0.2274, 0.3158]), + (7.9168, [0.0551, 0.1063, 0.3101]), + (8.7085, [0.0428, 0.0864, 0.2839]), + (9.5002, [0.038, 0.0777, 0.2493]), + (10.292, [0.0361, 0.0739, 0.2155]), + (11.084, [0.0309, 0.0635, 0.1792]), + (12.667, [0.0201, 0.0427, 0.0975]), + (13.459, [0.0159, 0.0341, 0.0502]), + (14.25, [0.0125, 0.0254, 0.0145]), + (15.042, [0.0083, 0.0169, 0.0014]), + (15.834, [0.0058, 0.012, -0.0023]), + (16.625, [0.0019, 0.0026, -0.0073]), + (17.417, [-0.0017, -0.0037, -0.0106]), + (18.209, [-0.0027, -0.0069, -0.0119]), + (21.375, [-0.0014, -0.004, -0.0086]), + (24.542, [-0.003, -0.005, -0.0094]), + (29.292, [-0.0023, -0.0036, -0.0069]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.2844, 0.136, -0.0135]), + (0.78812, [0.2889, 0.1438, -0.0105]), + (3.9406, [0.2884, 0.1478, -0.0065]), + (6.305, [0.2942, 0.1653, 0.0019]), + (8.6693, [0.2861, 0.1751, 0.0056]), + (11.822, [0.2645, 0.1813, 0.0116]), + (17.339, [0.2063, 0.1706, 0.0168]), + (22.856, [0.149, 0.1471, 0.0209]), + (27.584, [0.1062, 0.1182, 0.0213]), + (30.737, [0.0825, 0.0953, 0.0201]), + (36.254, [0.0452, 0.0526, 0.0123]), + (40.194, [0.0266, 0.0305, 0.0078]), + (43.347, [0.0154, 0.0177, 0.0042]), + (47.287, [0.0063, 0.0072, 0.0012]), + (51.228, [0.0013, 0.0017, -0.0001]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.0124, 0.0678, 0.1364]), + (0.79199, [0.0781, 0.1244, 0.1818]), + (1.584, [0.0744, 0.1173, 0.1659]), + (2.376, [0.0755, 0.1102, 0.1482]), + (3.168, [0.0736, 0.0995, 0.125]), + (3.96, [0.0487, 0.0684, 0.0862]), + (4.752, [0.0437, 0.0564, 0.0648]), + (5.544, [0.023, 0.0328, 0.0358]), + (7.1279, [0.0008, 0.0073, 0.0091]), + (7.9199, [-0.0025, 0.0047, 0.0051]), + (8.7119, [0.0032, 0.0089, 0.0075]), + (10.296, [0.0076, 0.0117, 0.0066]), + (12.672, [0.0167, 0.0179, 0.01]), + (13.464, [0.0169, 0.0187, 0.009]), + (15.84, [0.0312, 0.0293, 0.0142]), + (18.216, [0.0291, 0.0273, 0.0131]), + (19.8, [0.0185, 0.0187, 0.0081]), + (22.176, [0.0223, 0.0217, 0.0115]), + (27.72, [0.0252, 0.0238, 0.0108]), + (29.304, [0.0248, 0.0232, 0.0089]), + (30.096, [0.0221, 0.0209, 0.0072]), + (30.888, [0.0146, 0.0142, 0.0036]), + (31.68, [0.0019, 0.002, -0.0032]), + (32.472, [-0.0045, -0.0041, -0.0072]), + (33.264, [-0.0053, -0.0049, -0.008]), + (39.6, [-0.0062, -0.0071, -0.0087]), + (45.936, [-0.0046, -0.0049, -0.0065]), + (49.896, [-0.0012, 0.0007, -0.001]), + (56.232, [-0.0026, -0.0012, 0.0023]), + ], + }, + Radial { + t: 2.5968, + perp: 0.0, + knots: &[ + (0.0, [0.0006, -0.0111, 0.0097]), + (0.79014, [-0.0015, -0.0089, 0.0099]), + (2.3704, [0.002, -0.005, 0.0108]), + (3.1606, [0.0003, -0.0072, 0.0094]), + (3.9507, [0.0026, -0.0038, 0.011]), + (6.3211, [-0.0022, -0.0072, 0.0057]), + (7.9014, [0.0002, -0.0041, 0.0068]), + (8.6916, [-0.0007, -0.0045, 0.0058]), + (9.4817, [0.0025, -0.0009, 0.0085]), + (12.642, [0.0035, 0.0021, 0.0077]), + (14.223, [-0.0022, -0.0019, 0.0026]), + (15.013, [-0.0007, -0.0004, 0.004]), + (15.803, [-0.0024, -0.0017, 0.0021]), + (16.593, [-0.0017, -0.0009, 0.0029]), + (17.383, [0.0009, 0.0015, 0.0048]), + (18.173, [-0.0009, -0.0002, 0.002]), + (19.754, [-0.0015, -0.0013, 0.0004]), + (20.544, [-0.0002, 0.0004, 0.0016]), + (22.914, [-0.0018, -0.0013, -0.0009]), + (28.445, [-0.0015, -0.0013, -0.0003]), + (34.766, [-0.0011, -0.0009, -0.006]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.014, 0.0155, 0.0193]), + (0.7893, [0.0067, 0.0059, 0.0116]), + (1.5786, [0.0049, 0.0033, 0.0074]), + (2.3679, [-0.0003, -0.0023, 0.0039]), + (3.1572, [0.0, -0.0015, 0.0039]), + (3.9465, [-0.0036, -0.005, -0.0002]), + (5.5251, [-0.002, -0.0044, 0.0]), + (7.1037, [0.0014, -0.0006, 0.0046]), + (11.05, [-0.0001, 0.0024, 0.0061]), + (13.418, [0.0011, 0.0061, 0.0097]), + (14.997, [-0.0001, 0.0062, 0.009]), + (18.154, [0.0043, 0.0129, 0.0152]), + (22.1, [0.0052, 0.0193, 0.0196]), + (24.468, [0.0111, 0.0269, 0.0268]), + (26.836, [0.0098, 0.0305, 0.0298]), + (29.204, [0.0131, 0.0373, 0.0354]), + (29.993, [0.0127, 0.0369, 0.0344]), + (30.783, [0.0087, 0.0281, 0.0267]), + (31.572, [0.0014, 0.0084, 0.0093]), + (32.361, [-0.0025, -0.0013, -0.0002]), + (33.15, [-0.0033, -0.0045, -0.0028]), + (37.097, [-0.0044, -0.0055, -0.0046]), + (40.254, [-0.003, -0.0038, -0.0028]), + (42.622, [-0.0046, -0.0062, -0.0042]), + (45.779, [-0.0025, -0.0035, -0.0025]), + (47.358, [-0.0033, -0.0045, -0.0033]), + (56.829, [-0.0006, -0.0008, -0.0003]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [0.028, 0.0035, -0.0118]), + (0.79316, [0.024, -0.0015, -0.0133]), + (3.1726, [0.0277, 0.0033, -0.0079]), + (3.9658, [0.0276, 0.0033, -0.0096]), + (4.759, [0.0284, 0.005, -0.0076]), + (7.9316, [0.029, 0.009, -0.0076]), + (11.104, [0.0279, 0.0131, -0.0037]), + (15.07, [0.0218, 0.0147, -0.0014]), + (19.036, [0.0169, 0.0119, -0.0011]), + (23.795, [0.0138, 0.0137, 0.0016]), + (28.554, [0.0072, 0.0089, 0.0011]), + (32.52, [0.0051, 0.006, 0.0021]), + (36.485, [0.0018, 0.0018, 0.0006]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.0042, -0.022, -0.0081]), + (0.77016, [-0.0007, -0.0227, -0.0161]), + (1.5403, [-0.0149, -0.0354, -0.016]), + (2.3105, [-0.0243, -0.0408, -0.0186]), + (3.8508, [-0.034, -0.0454, -0.0192]), + (5.3911, [-0.0309, -0.0394, -0.013]), + (9.242, [-0.0104, -0.0127, -0.0021]), + (12.323, [0.001, 0.0004, 0.0012]), + ], + }, + Radial { + t: 1.6547, + perp: 0.0, + knots: &[ + (0.0, [0.0091, 0.0048, 0.0025]), + (0.78645, [0.004, 0.0008, -0.0006]), + (1.5729, [0.0055, 0.0016, 0.0]), + (4.7187, [0.0035, -0.0004, -0.0018]), + (6.2916, [0.0049, 0.0013, -0.0001]), + (7.8645, [0.0036, 0.0006, -0.0016]), + (11.797, [0.0051, 0.0027, 0.0008]), + (12.583, [0.0034, 0.0014, -0.0008]), + (18.875, [0.0023, 0.0005, 0.0003]), + (19.661, [0.0007, -0.0008, -0.0004]), + ], + }, + Radial { + t: 2.6079, + perp: 0.0, + knots: &[ + (0.0, [-0.0007, 0.0074, -0.0096]), + (1.5811, [-0.0032, 0.0045, -0.0117]), + (2.3716, [0.0003, 0.008, -0.0078]), + (4.7433, [-0.0021, 0.0038, -0.0087]), + (5.5338, [-0.0009, 0.0041, -0.0071]), + (7.1149, [-0.0032, 0.0016, -0.0093]), + (9.4866, [-0.0022, 0.0009, -0.0067]), + (11.068, [-0.0042, -0.0021, -0.0084]), + (13.439, [-0.0032, -0.0024, -0.0079]), + (14.23, [-0.0013, -0.0009, -0.0055]), + (15.02, [-0.0019, -0.0017, -0.0062]), + (17.392, [0.0003, -0.0003, -0.0029]), + (19.764, [-0.0007, -0.0012, -0.0031]), + (20.554, [0.0011, 0.0007, -0.0002]), + ], + }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.2962, 0.2613, 0.2034]), + (1.5965, [0.2984, 0.2647, 0.206]), + (6.3859, [0.2879, 0.2517, 0.1962]), + (12.772, [0.2819, 0.2457, 0.1935]), + (19.956, [0.2732, 0.2385, 0.1955]), + (34.324, [0.2317, 0.2032, 0.1802]), + (45.5, [0.1809, 0.1556, 0.1474]), + (46.298, [0.1705, 0.1478, 0.1407]), + (47.096, [0.1418, 0.1303, 0.126]), + (47.895, [0.1077, 0.1112, 0.1106]), + (48.693, [0.0953, 0.1009, 0.102]), + (51.886, [0.0846, 0.0867, 0.093]), + (54.281, [0.0818, 0.0807, 0.0914]), + (56.675, [0.0733, 0.0699, 0.0849]), + (60.666, [0.0646, 0.0575, 0.0794]), + (62.263, [0.0589, 0.0487, 0.0761]), + (67.052, [0.0537, 0.0434, 0.0715]), + (69.447, [0.054, 0.0452, 0.0717]), + (76.631, [0.049, 0.0418, 0.0695]), + (89.403, [0.0443, 0.0408, 0.0698]), + (96.587, [0.0374, 0.0347, 0.0584]), + (101.38, [0.031, 0.0279, 0.0445]), + (106.96, [0.0204, 0.0157, 0.0224]), + (109.36, [0.0191, 0.014, 0.0167]), + (111.75, [0.0141, 0.0088, 0.0089]), + (118.14, [0.006, 0.0021, -0.0017]), + (121.33, [0.0026, 0.001, -0.0031]), + (122.93, [0.0019, 0.0011, -0.0016]), + ], + }, +]; +const PRIME105_200_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_200_RAYS: &[Ray] = &[]; +const PRIME105_300_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.3434, 0.3434, 0.3434]), + (718.4, [0.346, 0.346, 0.346]), + (810.4, [0.3302, 0.3302, 0.3302]), + (816.0, [0.3443, 0.3443, 0.3443]), + (817.6, [0.338, 0.338, 0.338]), + (819.2, [0.3168, 0.3168, 0.3168]), + (821.6, [0.2463, 0.2463, 0.2463]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.3449, 0.3449, 0.3449]), + (730.4, [0.3443, 0.3443, 0.3443]), + (812.8, [0.3356, 0.3356, 0.3356]), + (817.6, [0.3253, 0.3253, 0.3253]), + (819.2, [0.3291, 0.3291, 0.3291]), + (820.0, [0.3369, 0.3369, 0.3369]), + (820.8, [0.3553, 0.3553, 0.3553]), + (821.6, [0.3414, 0.3414, 0.3414]), + (822.4, [0.3729, 0.3729, 0.3729]), + (823.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.3117, 0.3117, 0.3117]), + (822.62, [0.3125, 0.3125, 0.3125]), + (853.79, [0.3184, 0.3184, 0.3184]), + (879.38, [0.3338, 0.3338, 0.3338]), + (897.76, [0.3283, 0.3283, 0.3283]), + (899.36, [0.3376, 0.3376, 0.3376]), + (900.16, [0.3335, 0.3335, 0.3335]), + (900.96, [0.369, 0.369, 0.369]), + (901.76, [0.3656, 0.3656, 0.3656]), + (902.56, [0.0042, 0.0042, 0.0042]), + (903.36, [0.0, 0.0, 0.0]), + ], + }, +]; +const PRIME105_300_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_300_RAYS: &[Ray] = &[]; +pub(super) const PRIME105: &[Level] = &[ + Level { brightness: 10.0, radial: PRIME105_10_RADIAL, streaks: PRIME105_10_STREAKS, rays: PRIME105_10_RAYS }, + Level { brightness: 50.0, radial: PRIME105_50_RADIAL, streaks: PRIME105_50_STREAKS, rays: PRIME105_50_RAYS }, + Level { brightness: 75.0, radial: PRIME105_75_RADIAL, streaks: PRIME105_75_STREAKS, rays: PRIME105_75_RAYS }, + Level { brightness: 100.0, radial: PRIME105_100_RADIAL, streaks: PRIME105_100_STREAKS, rays: PRIME105_100_RAYS }, + Level { brightness: 125.0, radial: PRIME105_125_RADIAL, streaks: PRIME105_125_STREAKS, rays: PRIME105_125_RAYS }, + Level { brightness: 150.0, radial: PRIME105_150_RADIAL, streaks: PRIME105_150_STREAKS, rays: PRIME105_150_RAYS }, + Level { brightness: 200.0, radial: PRIME105_200_RADIAL, streaks: PRIME105_200_STREAKS, rays: PRIME105_200_RAYS }, + Level { brightness: 300.0, radial: PRIME105_300_RADIAL, streaks: PRIME105_300_STREAKS, rays: PRIME105_300_RAYS }, +]; + +const MOVIE_PRIME_10_RADIAL: &[Radial] = &[Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [0.797, 0.6681, 0.6641]), + (0.8, [0.7816, 0.6455, 0.6416]), + (1.6, [0.7035, 0.5304, 0.5264]), + (2.4, [0.5987, 0.3796, 0.3742]), + (3.2, [0.5134, 0.263, 0.2546]), + (4.0, [0.442, 0.1709, 0.1616]), + (4.8, [0.3982, 0.1233, 0.1145]), + (5.6, [0.3764, 0.1061, 0.0979]), + (7.2, [0.3466, 0.0972, 0.0886]), + (11.2, [0.2805, 0.0834, 0.0762]), + (16.8, [0.1985, 0.0616, 0.0546]), + (21.6, [0.1389, 0.045, 0.0392]), + (24.0, [0.115, 0.0391, 0.0331]), + (24.8, [0.0894, 0.031, 0.0265]), + (25.6, [0.0559, 0.0198, 0.017]), + (26.4, [0.0325, 0.0118, 0.0098]), + (27.2, [0.0218, 0.0079, 0.0065]), + (28.0, [0.0156, 0.0058, 0.0048]), + (29.6, [0.0089, 0.0033, 0.0028]), + (33.6, [0.0017, 0.0008, 0.0007]), + ], +}]; +const MOVIE_PRIME_10_STREAKS: &[Streak] = &[ + Streak { t: -0.0013744, perp: 0.0, angle: 0.017496, half: 6000.0, taper: 0.12679, width: 1.1111, rgb: [0.0039, 0.0116, 0.0216] }, + Streak { t: 0.12399, perp: 0.0, angle: 45.165, half: 325.79, taper: 0.39567, width: 0.81918, rgb: [0.0176, 0.0394, 0.0677] }, + Streak { t: 1.7983, perp: 0.0, angle: 45.122, half: 511.94, taper: 0.75998, width: 1.4135, rgb: [0.0034, 0.0097, 0.018] }, + Streak { t: 1.9986, perp: 0.0, angle: 0.0018621, half: 157.85, taper: 1.0, width: 1.116, rgb: [0.0085, 0.0237, 0.0437] }, +]; +const MOVIE_PRIME_10_RAYS: &[Ray] = &[ + Ray { angle: 43.29, r0: 139.06, r1: 195.31, fade: 0.05, rgb: [0.0137, 0.0314, 0.0549] }, + Ray { angle: 179.62, r0: 60.938, r1: 234.38, fade: 0.05, rgb: [0.002, 0.0059, 0.0118] }, + Ray { angle: 180.0, r0: 60.938, r1: 234.38, fade: 0.05, rgb: [0.0039, 0.0118, 0.0176] }, +]; +const MOVIE_PRIME_50_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [0.9958, 0.9842, 0.9842]), + (1.6, [0.9814, 0.9552, 0.9513]), + (5.6, [0.8918, 0.7676, 0.7461]), + (8.8, [0.8017, 0.6156, 0.5844]), + (12.8, [0.6887, 0.4547, 0.4172]), + (15.2, [0.624, 0.3713, 0.3362]), + (17.6, [0.5637, 0.3068, 0.2703]), + (19.2, [0.5298, 0.2757, 0.2426]), + (20.8, [0.4995, 0.253, 0.2172]), + (23.2, [0.4624, 0.2259, 0.2003]), + (31.2, [0.3453, 0.1601, 0.1395]), + (33.6, [0.3126, 0.1369, 0.125]), + (34.4, [0.3016, 0.1326, 0.1225]), + (36.0, [0.2809, 0.1241, 0.1103]), + (39.2, [0.2384, 0.1051, 0.0989]), + (41.6, [0.2124, 0.0924, 0.0832]), + (44.8, [0.1749, 0.072, 0.0696]), + (48.8, [0.1319, 0.0539, 0.0507]), + (52.0, [0.1106, 0.0378, 0.0419]), + (53.6, [0.0978, 0.0339, 0.0327]), + (56.0, [0.0771, 0.0292, 0.0285]), + (58.4, [0.0623, 0.019, 0.0238]), + (60.8, [0.0516, 0.0178, 0.0184]), + (62.4, [0.0431, 0.0176, 0.018]), + (67.2, [0.028, 0.0095, 0.0128]), + (68.8, [0.0234, 0.0091, 0.0097]), + (70.4, [0.017, 0.0089, 0.0092]), + (76.0, [0.0085, 0.0069, 0.009]), + (78.4, [0.0069, 0.0046, 0.005]), + (80.0, [0.0044, 0.0046, 0.0048]), + (88.0, [0.004, 0.0041, 0.0044]), + (88.8, [0.0041, 0.0024, 0.0043]), + (91.2, [0.0005, 0.0003, 0.0003]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.0458, 0.0601]), + (0.79957, [-0.0001, 0.0485, 0.0594]), + (11.194, [0.001, 0.0416, 0.0529]), + (23.987, [0.0002, 0.0093, 0.0117]), + (35.181, [0.0001, 0.0014, 0.0019]), + ], + }, + Radial { + t: 1.8215, + perp: 0.0, + knots: &[ + (0.0, [0.0031, 0.0069, 0.0116]), + (0.79072, [0.0032, 0.0069, 0.0114]), + (2.3722, [0.0012, 0.0026, 0.0042]), + (5.535, [0.0003, 0.0007, 0.0011]), + (7.9072, [0.0007, 0.0014, 0.0024]), + (16.605, [0.0001, 0.0004, 0.0006]), + (19.768, [0.0006, 0.0013, 0.0022]), + (32.42, [0.0005, 0.001, 0.0017]), + ], + }, + Radial { + t: 0.84541, + perp: 0.0, + knots: &[ + (0.0, [-0.0003, 0.0119, 0.0136]), + (4.6304, [-0.0003, 0.0095, 0.0115]), + (11.576, [-0.0004, 0.0043, 0.0049]), + (18.522, [0.0003, 0.0019, 0.0027]), + ], + }, +]; +const MOVIE_PRIME_50_STREAKS: &[Streak] = &[ + Streak { t: -0.0010401, perp: 0.0, angle: 0.018284, half: 5943.2, taper: 0.17314, width: 1.0624, rgb: [0.0429, 0.0849, 0.1394] }, + Streak { t: 0.1239, perp: 0.0, angle: 45.148, half: 379.48, taper: 0.99231, width: 5.3801, rgb: [0.0209, 0.0345, 0.0495] }, + Streak { t: 1.7985, perp: 0.0, angle: 45.116, half: 371.18, taper: 0.010862, width: 1.9725, rgb: [0.0178, 0.0332, 0.0508] }, + Streak { t: 1.9989, perp: 0.0, angle: 0.00086328, half: 177.25, taper: 1.0, width: 1.0363, rgb: [0.0711, 0.1475, 0.2468] }, +]; +const MOVIE_PRIME_50_RAYS: &[Ray] = &[ + Ray { angle: 0.38, r0: 17.969, r1: 233.59, fade: 0.05, rgb: [0.0098, 0.0176, 0.0294] }, + Ray { angle: 0.63, r0: 17.969, r1: 139.84, fade: 0.05, rgb: [0.0059, 0.0118, 0.0176] }, + Ray { angle: 1.01, r0: 17.969, r1: 86.719, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 37.2, r0: 60.938, r1: 83.594, fade: 0.05, rgb: [0.0127, 0.0137, 0.0118] }, + Ray { angle: 43.02, r0: 123.44, r1: 171.09, fade: 0.05, rgb: [0.0902, 0.1804, 0.2941] }, + Ray { angle: 43.29, r0: 137.5, r1: 196.09, fade: 0.05, rgb: [0.0843, 0.1627, 0.2647] }, + Ray { angle: 43.55, r0: 160.94, r1: 227.34, fade: 0.05, rgb: [0.0804, 0.1647, 0.2755] }, + Ray { angle: 43.81, r0: 196.09, r1: 272.66, fade: 0.05, rgb: [0.0941, 0.1922, 0.3098] }, + Ray { angle: 45.59, r0: 36.719, r1: 75.781, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 135.46, r0: 47.656, r1: 80.469, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 179.62, r0: 8.5938, r1: 234.38, fade: 0.05, rgb: [0.0137, 0.0294, 0.0471] }, + Ray { angle: 180.0, r0: 16.406, r1: 234.38, fade: 0.05, rgb: [0.0294, 0.0569, 0.0922] }, + Ray { angle: 181.01, r0: 16.406, r1: 42.969, fade: 0.05, rgb: [0.0059, 0.0039, 0.0078] }, + Ray { angle: 181.28, r0: 16.406, r1: 32.812, fade: 0.05, rgb: [0.0039, 0.0039, 0.0059] }, + Ray { angle: 181.55, r0: 16.406, r1: 26.562, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, + Ray { angle: 225.01, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0098, 0.0127, 0.0137] }, + Ray { angle: 226.81, r0: 160.94, r1: 232.81, fade: 0.05, rgb: [0.0706, 0.1412, 0.2294] }, + Ray { angle: 227.11, r0: 136.72, r1: 191.41, fade: 0.05, rgb: [0.0863, 0.1725, 0.2922] }, + Ray { angle: 227.38, r0: 117.97, r1: 166.41, fade: 0.05, rgb: [0.0961, 0.1922, 0.3157] }, + Ray { angle: 227.72, r0: 102.34, r1: 144.53, fade: 0.05, rgb: [0.1078, 0.2118, 0.3431] }, + Ray { angle: 228.0, r0: 92.188, r1: 131.25, fade: 0.05, rgb: [0.1098, 0.2118, 0.3353] }, + Ray { angle: 247.08, r0: 28.906, r1: 82.812, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 358.49, r0: 6.25, r1: 26.562, fade: 0.05, rgb: [0.0059, 0.0039, 0.0078] }, + Ray { angle: 358.75, r0: 6.25, r1: 33.594, fade: 0.05, rgb: [0.0059, 0.0039, 0.0039] }, + Ray { angle: 359.01, r0: 6.25, r1: 42.188, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, +]; +const MOVIE_PRIME_75_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 0.9991, 0.9989]), + (2.4, [0.9921, 0.9757, 0.9696]), + (9.6, [0.9179, 0.7845, 0.7519]), + (18.4, [0.7627, 0.5299, 0.491]), + (24.0, [0.6486, 0.3888, 0.3549]), + (27.2, [0.5875, 0.324, 0.2963]), + (30.4, [0.5337, 0.2789, 0.2542]), + (37.6, [0.4309, 0.2055, 0.1974]), + (39.2, [0.4093, 0.1971, 0.1882]), + (40.8, [0.3901, 0.1791, 0.175]), + (44.8, [0.3364, 0.1533, 0.1531]), + (51.2, [0.2642, 0.1091, 0.1156]), + (53.6, [0.2419, 0.1002, 0.1031]), + (56.8, [0.2111, 0.0876, 0.0962]), + (58.4, [0.1998, 0.0816, 0.0885]), + (60.0, [0.1874, 0.0756, 0.0854]), + (62.4, [0.1697, 0.0734, 0.0797]), + (68.0, [0.1405, 0.062, 0.0671]), + (70.4, [0.1264, 0.0573, 0.0657]), + (72.0, [0.117, 0.0536, 0.0598]), + (75.2, [0.1017, 0.0527, 0.0572]), + (76.8, [0.0924, 0.0455, 0.0531]), + (77.6, [0.0891, 0.0449, 0.0509]), + (79.2, [0.0874, 0.0448, 0.0492]), + (81.6, [0.0774, 0.0407, 0.0449]), + (84.8, [0.065, 0.0384, 0.0438]), + (86.4, [0.06, 0.0332, 0.0371]), + (89.6, [0.0488, 0.0325, 0.0354]), + (92.0, [0.0439, 0.0284, 0.0326]), + (92.8, [0.0432, 0.0283, 0.0325]), + (94.4, [0.0361, 0.0284, 0.0323]), + (96.0, [0.0348, 0.0283, 0.0285]), + (97.6, [0.0313, 0.0242, 0.0282]), + (99.2, [0.0276, 0.024, 0.0283]), + (100.8, [0.0277, 0.024, 0.0247]), + (102.4, [0.0278, 0.0237, 0.0244]), + (104.0, [0.0241, 0.02, 0.0243]), + (108.8, [0.0231, 0.0201, 0.0204]), + (110.4, [0.0197, 0.0162, 0.0204]), + (112.0, [0.0198, 0.0162, 0.0203]), + (113.6, [0.0198, 0.0162, 0.0165]), + (114.4, [0.0196, 0.0163, 0.0166]), + (116.0, [0.0158, 0.0163, 0.0166]), + (117.6, [0.0159, 0.0127, 0.0165]), + (119.2, [0.0159, 0.0124, 0.0163]), + (120.8, [0.0159, 0.0124, 0.0126]), + (123.2, [0.0118, 0.0123, 0.0127]), + (124.8, [0.0119, 0.0122, 0.0126]), + (126.4, [0.0119, 0.0083, 0.0126]), + (127.2, [0.0119, 0.0084, 0.0123]), + (128.8, [0.012, 0.0083, 0.0085]), + (131.2, [0.0079, 0.0083, 0.0085]), + (134.4, [0.008, 0.0084, 0.0085]), + (136.0, [0.008, 0.0046, 0.0085]), + (137.6, [0.0081, 0.0043, 0.0051]), + (140.0, [0.004, 0.0042, 0.0043]), + (147.2, [0.004, 0.0041, 0.0041]), + (148.8, [0.004, 0.001, 0.0042]), + (150.4, [0.0033, 0.0001, 0.0003]), + (151.2, [0.0009, 0.0, 0.0001]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.0691, 0.0923]), + (0.79957, [-0.0001, 0.0712, 0.0921]), + (3.1983, [-0.0001, 0.0698, 0.0905]), + (9.5948, [-0.0004, 0.0618, 0.0813]), + (11.194, [0.0013, 0.0612, 0.0817]), + (19.989, [0.0008, 0.0282, 0.0385]), + (22.388, [0.0002, 0.0167, 0.0229]), + (24.787, [0.0, 0.0103, 0.0141]), + (27.185, [0.0007, 0.0087, 0.0122]), + (30.384, [0.0002, 0.0048, 0.0066]), + (35.981, [0.0, 0.0013, 0.0018]), + ], + }, + Radial { + t: 1.8191, + perp: 0.0, + knots: &[ + (0.0, [0.0039, 0.0078, 0.0134]), + (0.79604, [0.0039, 0.0079, 0.0132]), + (2.3881, [0.0012, 0.0025, 0.0042]), + (3.9802, [0.0006, 0.0011, 0.002]), + (5.5723, [0.0005, 0.0009, 0.0016]), + (6.3684, [0.0009, 0.0019, 0.0032]), + (14.329, [0.0002, 0.0005, 0.0009]), + (32.638, [0.0005, 0.0011, 0.0018]), + ], + }, + Radial { + t: 0.84673, + perp: 0.0, + knots: &[ + (0.0, [-0.0002, 0.0183, 0.0236]), + (3.9523, [-0.0003, 0.0157, 0.0206]), + (11.857, [-0.0002, 0.0066, 0.0085]), + (21.343, [0.0002, 0.0012, 0.0016]), + ], + }, +]; +const MOVIE_PRIME_75_STREAKS: &[Streak] = &[ + Streak { t: -0.00095449, perp: 0.0, angle: 0.01348, half: 5779.5, taper: 0.40206, width: 1.1047, rgb: [0.0659, 0.1249, 0.2017] }, + Streak { t: 0.12405, perp: 0.0, angle: 45.164, half: 290.83, taper: 0.1344, width: 0.79032, rgb: [0.1723, 0.3301, 0.5247] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.122, half: 285.95, taper: 0.034813, width: 0.83456, rgb: [0.072, 0.1438, 0.2356] }, + Streak { t: 1.999, perp: 0.0, angle: 0.00023081, half: 179.3, taper: 1.0, width: 1.0836, rgb: [0.0999, 0.2026, 0.3352] }, +]; +const MOVIE_PRIME_75_RAYS: &[Ray] = &[ + Ray { angle: 0.38, r0: 17.188, r1: 234.38, fade: 0.05, rgb: [0.0137, 0.0275, 0.0451] }, + Ray { angle: 0.63, r0: 17.188, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0275] }, + Ray { angle: 1.01, r0: 17.188, r1: 86.719, fade: 0.05, rgb: [0.0078, 0.0118, 0.0176] }, + Ray { angle: 1.4, r0: 17.188, r1: 61.719, fade: 0.05, rgb: [0.0078, 0.0098, 0.0127] }, + Ray { angle: 1.69, r0: 17.188, r1: 51.562, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 6.97, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 8.31, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 9.3, r0: 53.906, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 10.69, r0: 50.781, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0118] }, + Ray { angle: 12.68, r0: 6.25, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 14.03, r0: 21.094, r1: 97.656, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 14.95, r0: 22.656, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 37.2, r0: 60.938, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 38.24, r0: 74.219, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 39.52, r0: 6.25, r1: 27.344, fade: 0.05, rgb: [0.0098, 0.0206, 0.0235] }, + Ray { angle: 40.37, r0: 6.25, r1: 30.469, fade: 0.05, rgb: [0.0078, 0.0147, 0.0186] }, + Ray { angle: 42.82, r0: 112.5, r1: 156.25, fade: 0.05, rgb: [0.1216, 0.2431, 0.3882] }, + Ray { angle: 43.12, r0: 129.69, r1: 179.69, fade: 0.05, rgb: [0.1412, 0.2706, 0.4353] }, + Ray { angle: 43.38, r0: 145.31, r1: 206.25, fade: 0.05, rgb: [0.1176, 0.2333, 0.3804] }, + Ray { angle: 43.7, r0: 178.91, r1: 251.56, fade: 0.05, rgb: [0.152, 0.302, 0.4716] }, + Ray { angle: 67.71, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 68.69, r0: 6.25, r1: 81.25, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 69.77, r0: 59.375, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 71.23, r0: 60.938, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 73.13, r0: 6.25, r1: 44.531, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 75.52, r0: 23.438, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0137, 0.0157] }, + Ray { angle: 97.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 98.74, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 99.73, r0: 51.562, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 101.16, r0: 39.844, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 103.07, r0: 6.25, r1: 42.188, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 104.37, r0: 6.25, r1: 76.562, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 105.45, r0: 21.094, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 127.41, r0: 6.25, r1: 96.094, fade: 0.05, rgb: [0.0078, 0.0118, 0.0108] }, + Ray { angle: 127.66, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0127, 0.0118] }, + Ray { angle: 128.66, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 129.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0059, 0.0098, 0.0078] }, + Ray { angle: 133.19, r0: 6.25, r1: 39.844, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 157.61, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 158.68, r0: 6.25, r1: 93.75, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 159.74, r0: 67.188, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 161.17, r0: 60.938, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 163.12, r0: 6.25, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 165.52, r0: 25.0, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 179.62, r0: 9.375, r1: 234.38, fade: 0.05, rgb: [0.0196, 0.0412, 0.0706] }, + Ray { angle: 180.0, r0: 16.406, r1: 234.38, fade: 0.05, rgb: [0.0431, 0.0843, 0.1373] }, + Ray { angle: 181.03, r0: 16.406, r1: 42.188, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 181.28, r0: 16.406, r1: 34.375, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 181.55, r0: 16.406, r1: 28.906, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 181.8, r0: 16.406, r1: 25.781, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 187.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 188.85, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 189.73, r0: 53.906, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 191.14, r0: 43.75, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 193.11, r0: 6.25, r1: 42.188, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 194.37, r0: 6.25, r1: 67.188, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 195.45, r0: 18.75, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 217.67, r0: 16.406, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 218.73, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 220.01, r0: 48.438, r1: 99.219, fade: 0.05, rgb: [0.0078, 0.0088, 0.0088] }, + Ray { angle: 221.96, r0: 6.25, r1: 25.0, fade: 0.05, rgb: [0.0098, 0.0235, 0.0275] }, + Ray { angle: 223.25, r0: 6.25, r1: 42.969, fade: 0.05, rgb: [0.0078, 0.0127, 0.0127] }, + Ray { angle: 224.26, r0: 6.25, r1: 95.312, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 225.57, r0: 6.25, r1: 30.469, fade: 0.05, rgb: [0.0039, 0.0088, 0.0098] }, + Ray { angle: 225.92, r0: 6.25, r1: 17.188, fade: 0.05, rgb: [0.0039, 0.0098, 0.0157] }, + Ray { angle: 227.06, r0: 139.06, r1: 196.09, fade: 0.05, rgb: [0.1255, 0.2539, 0.4118] }, + Ray { angle: 227.31, r0: 123.44, r1: 171.09, fade: 0.05, rgb: [0.1431, 0.2784, 0.4431] }, + Ray { angle: 227.56, r0: 109.38, r1: 153.91, fade: 0.05, rgb: [0.151, 0.2961, 0.4637] }, + Ray { angle: 227.84, r0: 97.656, r1: 138.28, fade: 0.05, rgb: [0.1588, 0.3059, 0.4647] }, + Ray { angle: 228.1, r0: 88.281, r1: 126.56, fade: 0.05, rgb: [0.1451, 0.2824, 0.4245] }, + Ray { angle: 247.09, r0: 22.656, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 248.26, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 249.3, r0: 70.312, r1: 99.219, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 250.73, r0: 60.938, r1: 96.875, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 251.57, r0: 7.8125, r1: 84.375, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 252.65, r0: 53.906, r1: 94.531, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 254.97, r0: 23.438, r1: 98.438, fade: 0.05, rgb: [0.0118, 0.0157, 0.0137] }, + Ray { angle: 276.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 278.35, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 279.33, r0: 53.125, r1: 97.656, fade: 0.05, rgb: [0.0108, 0.0118, 0.0118] }, + Ray { angle: 280.73, r0: 48.438, r1: 97.656, fade: 0.05, rgb: [0.0088, 0.0078, 0.0078] }, + Ray { angle: 281.54, r0: 39.062, r1: 95.312, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 282.68, r0: 6.25, r1: 42.969, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 284.03, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 285.07, r0: 20.312, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 307.25, r0: 26.562, r1: 96.875, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 308.4, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 309.64, r0: 55.469, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0108] }, + Ray { angle: 311.61, r0: 56.25, r1: 85.938, fade: 0.05, rgb: [0.0059, 0.0078, 0.0059] }, + Ray { angle: 312.9, r0: 6.25, r1: 32.812, fade: 0.05, rgb: [0.0078, 0.0176, 0.0176] }, + Ray { angle: 313.92, r0: 6.25, r1: 68.75, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 315.0, r0: 6.25, r1: 85.938, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 337.25, r0: 8.5938, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 338.3, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 339.3, r0: 60.938, r1: 99.219, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 340.76, r0: 57.812, r1: 96.875, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 342.67, r0: 6.25, r1: 40.625, fade: 0.05, rgb: [0.0098, 0.0157, 0.0196] }, + Ray { angle: 345.08, r0: 21.875, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0137, 0.0157] }, + Ray { angle: 357.95, r0: 10.156, r1: 28.125, fade: 0.05, rgb: [0.0059, 0.0078, 0.0059] }, + Ray { angle: 358.2, r0: 10.156, r1: 28.125, fade: 0.05, rgb: [0.0059, 0.0088, 0.0078] }, + Ray { angle: 358.49, r0: 10.156, r1: 29.688, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 358.75, r0: 10.156, r1: 33.594, fade: 0.05, rgb: [0.0059, 0.0078, 0.0098] }, + Ray { angle: 359.01, r0: 10.156, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, +]; +const MOVIE_PRIME_100_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [0.9999, 1.0, 1.0]), + (2.4, [1.0, 0.9929, 0.9926]), + (7.2, [0.9871, 0.9371, 0.9152]), + (16.8, [0.9129, 0.7518, 0.7172]), + (28.0, [0.7523, 0.4998, 0.4769]), + (32.8, [0.6739, 0.4055, 0.3923]), + (36.0, [0.6215, 0.3543, 0.3422]), + (38.4, [0.586, 0.3197, 0.3114]), + (39.2, [0.5732, 0.308, 0.3071]), + (44.0, [0.5127, 0.2629, 0.2655]), + (47.2, [0.4754, 0.236, 0.2424]), + (48.8, [0.456, 0.2281, 0.2384]), + (49.6, [0.4474, 0.2202, 0.2287]), + (55.2, [0.3871, 0.1845, 0.2005]), + (56.8, [0.3748, 0.1776, 0.1899]), + (60.0, [0.3459, 0.164, 0.1806]), + (72.8, [0.2614, 0.1328, 0.1467]), + (77.6, [0.234, 0.1257, 0.1344]), + (84.8, [0.1949, 0.1118, 0.1235]), + (85.6, [0.1913, 0.1086, 0.1175]), + (87.2, [0.1851, 0.1082, 0.1167]), + (88.8, [0.1746, 0.1007, 0.1125]), + (90.4, [0.1685, 0.1, 0.1123]), + (92.0, [0.1582, 0.0959, 0.1043]), + (93.6, [0.1532, 0.096, 0.1042]), + (96.8, [0.1392, 0.0877, 0.0998]), + (98.4, [0.1345, 0.0843, 0.093]), + (100.8, [0.1225, 0.0837, 0.0885]), + (101.6, [0.122, 0.0808, 0.0883]), + (102.4, [0.1163, 0.0798, 0.0882]), + (104.0, [0.1111, 0.0798, 0.0844]), + (106.4, [0.1068, 0.0718, 0.0839]), + (107.2, [0.1034, 0.0718, 0.0775]), + (108.0, [0.1025, 0.0715, 0.0763]), + (108.8, [0.0969, 0.0684, 0.0763]), + (110.4, [0.094, 0.0677, 0.0733]), + (112.0, [0.0866, 0.0667, 0.072]), + (112.8, [0.0869, 0.0635, 0.0723]), + (113.6, [0.0843, 0.0638, 0.0689]), + (115.2, [0.0792, 0.064, 0.0682]), + (116.8, [0.0745, 0.0596, 0.0676]), + (117.6, [0.0752, 0.0599, 0.0642]), + (119.2, [0.07, 0.0601, 0.0642]), + (120.0, [0.0667, 0.058, 0.0646]), + (120.8, [0.0673, 0.0556, 0.0622]), + (123.2, [0.0631, 0.0563, 0.0603]), + (124.0, [0.0631, 0.0537, 0.0605]), + (124.8, [0.0624, 0.0518, 0.057]), + (125.6, [0.059, 0.052, 0.0562]), + (128.0, [0.0594, 0.0502, 0.0562]), + (128.8, [0.0561, 0.0477, 0.0525]), + (132.0, [0.0553, 0.048, 0.0524]), + (132.8, [0.0518, 0.0446, 0.0489]), + (136.0, [0.0506, 0.0439, 0.0484]), + (136.8, [0.0472, 0.0431, 0.0459]), + (137.6, [0.0474, 0.0398, 0.0439]), + (140.0, [0.0469, 0.0399, 0.0441]), + (140.8, [0.0433, 0.0398, 0.0436]), + (141.6, [0.0435, 0.0394, 0.04]), + (142.4, [0.0434, 0.0358, 0.0401]), + (144.0, [0.0436, 0.0358, 0.04]), + (144.8, [0.0412, 0.0358, 0.0401]), + (146.4, [0.0395, 0.0354, 0.0357]), + (147.2, [0.0394, 0.032, 0.036]), + (148.8, [0.0389, 0.0317, 0.0358]), + (149.6, [0.0354, 0.0317, 0.0361]), + (151.2, [0.0355, 0.0317, 0.0316]), + (152.8, [0.0356, 0.0275, 0.0318]), + (154.4, [0.0312, 0.0276, 0.0319]), + (156.0, [0.0315, 0.0276, 0.0277]), + (157.6, [0.0317, 0.0265, 0.0279]), + (158.4, [0.0301, 0.0235, 0.0279]), + (159.2, [0.0273, 0.0238, 0.0278]), + (160.0, [0.0277, 0.0237, 0.028]), + (161.6, [0.0277, 0.0238, 0.0236]), + (163.2, [0.0278, 0.024, 0.024]), + (164.8, [0.0234, 0.0195, 0.0239]), + (166.4, [0.0237, 0.0199, 0.0235]), + (167.2, [0.0237, 0.0199, 0.0202]), + (168.8, [0.024, 0.0198, 0.0201]), + (169.6, [0.0209, 0.0201, 0.0201]), + (171.2, [0.0198, 0.0157, 0.02]), + (172.8, [0.0198, 0.0159, 0.0197]), + (173.6, [0.0198, 0.016, 0.016]), + (175.2, [0.0198, 0.0159, 0.0161]), + (176.0, [0.0161, 0.0159, 0.0161]), + (176.8, [0.0158, 0.016, 0.0161]), + (178.4, [0.0158, 0.0119, 0.016]), + (179.2, [0.0158, 0.012, 0.0161]), + (180.8, [0.0158, 0.012, 0.012]), + (181.6, [0.016, 0.012, 0.0122]), + (183.2, [0.0118, 0.012, 0.0121]), + (185.6, [0.0119, 0.0119, 0.0122]), + (186.4, [0.0119, 0.0083, 0.0121]), + (187.2, [0.0119, 0.008, 0.0123]), + (188.8, [0.0119, 0.0081, 0.0078]), + (189.6, [0.0115, 0.0081, 0.0083]), + (190.4, [0.008, 0.0081, 0.0082]), + (195.2, [0.008, 0.0083, 0.0083]), + (196.8, [0.0079, 0.0039, 0.0084]), + (198.4, [0.0077, 0.0042, 0.004]), + (199.2, [0.0042, 0.0042, 0.0044]), + (208.0, [0.004, 0.0042, 0.0043]), + (209.6, [0.0039, 0.0001, 0.0041]), + (210.4, [0.0038, 0.0003, 0.0004]), + (211.2, [0.0001, 0.0003, 0.0004]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [0.0115, 0.118, 0.1485]), + (0.79957, [0.0089, 0.1182, 0.1461]), + (3.1983, [0.0022, 0.1055, 0.1289]), + (9.5948, [0.0005, 0.0923, 0.1121]), + (11.994, [0.0017, 0.0864, 0.106]), + (15.192, [0.0006, 0.0702, 0.0853]), + (21.588, [0.0005, 0.0314, 0.0388]), + (24.787, [0.0003, 0.017, 0.0213]), + (30.384, [0.0002, 0.0071, 0.0088]), + (36.78, [0.0, 0.0012, 0.0016]), + ], + }, + Radial { + t: 1.83, + perp: 0.0, + knots: &[ + (0.0, [0.0018, 0.0058, 0.0098]), + (0.79309, [0.0025, 0.0054, 0.0094]), + (3.1724, [0.0008, 0.0016, 0.0029]), + (7.1378, [0.0002, 0.0006, 0.0011]), + (30.931, [0.0005, 0.0011, 0.0019]), + ], + }, + Radial { + t: 0.84646, + perp: 0.0, + knots: &[ + (0.0, [0.0047, 0.0355, 0.0455]), + (0.79449, [0.0053, 0.0369, 0.0474]), + (1.589, [0.0042, 0.0345, 0.0444]), + (2.3835, [0.0017, 0.0296, 0.0369]), + (11.917, [-0.0001, 0.011, 0.0133]), + (15.89, [0.0001, 0.0052, 0.0063]), + (21.451, [0.0, 0.0013, 0.0014]), + ], + }, +]; +const MOVIE_PRIME_100_STREAKS: &[Streak] = &[ + Streak { t: 0.0000864, perp: 0.0, angle: 0.02032, half: 4760.3, taper: 0.01495, width: 1.1183, rgb: [0.0977, 0.1807, 0.2877] }, + Streak { t: 0.12391, perp: 0.0, angle: 45.163, half: 329.95, taper: 0.42439, width: 1.1752, rgb: [0.1469, 0.2728, 0.4212] }, + Streak { t: 1.7989, perp: 0.0, angle: 45.143, half: 288.76, taper: 0.039305, width: 2.0621, rgb: [0.0337, 0.0602, 0.0905] }, + Streak { t: 2.0001, perp: 0.0, angle: -0.0000721, half: 184.38, taper: 0.99976, width: 1.0663, rgb: [0.1469, 0.2947, 0.483] }, +]; +const MOVIE_PRIME_100_RAYS: &[Ray] = &[ + Ray { angle: 0.47, r0: 17.969, r1: 189.06, fade: 0.05, rgb: [0.0137, 0.0275, 0.049] }, + Ray { angle: 0.8, r0: 17.969, r1: 114.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0255] }, + Ray { angle: 1.05, r0: 17.969, r1: 83.594, fade: 0.05, rgb: [0.0078, 0.0137, 0.0196] }, + Ray { angle: 1.31, r0: 17.969, r1: 66.406, fade: 0.05, rgb: [0.0059, 0.0118, 0.0157] }, + Ray { angle: 1.57, r0: 17.969, r1: 54.688, fade: 0.05, rgb: [0.0059, 0.0098, 0.0137] }, + Ray { angle: 1.85, r0: 17.969, r1: 46.875, fade: 0.05, rgb: [0.0059, 0.0098, 0.0127] }, + Ray { angle: 2.33, r0: 24.219, r1: 38.281, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 7.32, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 8.01, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0167, 0.0167] }, + Ray { angle: 9.32, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0147, 0.0137, 0.0137] }, + Ray { angle: 10.53, r0: 78.906, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 11.76, r0: 35.938, r1: 107.03, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 12.6, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 13.85, r0: 18.75, r1: 67.188, fade: 0.05, rgb: [0.0039, 0.0098, 0.0078] }, + Ray { angle: 15.14, r0: 40.625, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 37.26, r0: 60.938, r1: 110.94, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 38.27, r0: 73.438, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0108, 0.0118] }, + Ray { angle: 39.41, r0: 85.938, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0127, 0.0137] }, + Ray { angle: 40.49, r0: 56.25, r1: 78.125, fade: 0.05, rgb: [0.1196, 0.2765, 0.4431] }, + Ray { angle: 41.61, r0: 6.25, r1: 40.625, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 43.02, r0: 122.66, r1: 171.09, fade: 0.05, rgb: [0.1725, 0.3373, 0.5255] }, + Ray { angle: 43.29, r0: 137.5, r1: 196.09, fade: 0.05, rgb: [0.1608, 0.3118, 0.4922] }, + Ray { angle: 43.55, r0: 160.16, r1: 227.34, fade: 0.05, rgb: [0.1608, 0.3235, 0.5412] }, + Ray { angle: 43.81, r0: 195.31, r1: 272.66, fade: 0.05, rgb: [0.1912, 0.3627, 0.5706] }, + Ray { angle: 67.69, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 68.57, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0127, 0.0137] }, + Ray { angle: 69.89, r0: 76.562, r1: 113.28, fade: 0.05, rgb: [0.0127, 0.0137, 0.0137] }, + Ray { angle: 71.13, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 71.87, r0: 32.812, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0098] }, + Ray { angle: 73.17, r0: 77.344, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 74.48, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 75.38, r0: 22.656, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 97.67, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 98.46, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 99.71, r0: 81.25, r1: 114.06, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 100.95, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0167, 0.0147, 0.0157] }, + Ray { angle: 102.24, r0: 32.812, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 102.97, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 104.25, r0: 6.25, r1: 39.844, fade: 0.05, rgb: [0.0039, 0.0127, 0.0176] }, + Ray { angle: 105.43, r0: 20.312, r1: 114.06, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 127.69, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 128.59, r0: 6.25, r1: 81.25, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 129.71, r0: 6.25, r1: 32.812, fade: 0.05, rgb: [0.0059, 0.0118, 0.0157] }, + Ray { angle: 130.84, r0: 85.938, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 132.0, r0: 89.844, r1: 110.94, fade: 0.05, rgb: [0.0157, 0.0167, 0.0157] }, + Ray { angle: 133.05, r0: 6.25, r1: 35.156, fade: 0.05, rgb: [0.0059, 0.0137, 0.0147] }, + Ray { angle: 134.26, r0: 6.25, r1: 57.031, fade: 0.05, rgb: [0.0059, 0.0127, 0.0147] }, + Ray { angle: 135.0, r0: 19.531, r1: 101.56, fade: 0.05, rgb: [0.0137, 0.0216, 0.0196] }, + Ray { angle: 157.63, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 158.56, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 159.79, r0: 75.781, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 161.13, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 161.87, r0: 45.312, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 163.13, r0: 73.438, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0137] }, + Ray { angle: 164.47, r0: 20.312, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 165.35, r0: 22.656, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 178.15, r0: 11.719, r1: 46.875, fade: 0.05, rgb: [0.0059, 0.0127, 0.0176] }, + Ray { angle: 178.45, r0: 11.719, r1: 55.469, fade: 0.05, rgb: [0.0078, 0.0137, 0.0196] }, + Ray { angle: 179.3, r0: 11.719, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0284, 0.0461] }, + Ray { angle: 179.62, r0: 11.719, r1: 234.38, fade: 0.05, rgb: [0.0255, 0.0569, 0.0922] }, + Ray { angle: 180.0, r0: 16.406, r1: 234.38, fade: 0.05, rgb: [0.0529, 0.1059, 0.1725] }, + Ray { angle: 181.01, r0: 17.969, r1: 42.969, fade: 0.05, rgb: [0.0059, 0.0098, 0.0137] }, + Ray { angle: 181.28, r0: 17.969, r1: 34.375, fade: 0.05, rgb: [0.0049, 0.0098, 0.0118] }, + Ray { angle: 181.55, r0: 17.969, r1: 29.688, fade: 0.05, rgb: [0.0059, 0.0088, 0.0108] }, + Ray { angle: 181.8, r0: 17.969, r1: 26.562, fade: 0.05, rgb: [0.0039, 0.0078, 0.0069] }, + Ray { angle: 187.67, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0157, 0.0176] }, + Ray { angle: 188.46, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 189.71, r0: 79.688, r1: 114.06, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 190.96, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0147, 0.0176, 0.0176] }, + Ray { angle: 192.24, r0: 34.375, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 192.98, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 194.31, r0: 76.562, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 195.5, r0: 18.75, r1: 114.06, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 217.72, r0: 19.531, r1: 112.5, fade: 0.05, rgb: [0.0088, 0.0137, 0.0137] }, + Ray { angle: 218.63, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 219.77, r0: 6.25, r1: 39.062, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 220.93, r0: 87.5, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 222.02, r0: 6.25, r1: 31.25, fade: 0.05, rgb: [0.0039, 0.0137, 0.0176] }, + Ray { angle: 223.24, r0: 6.25, r1: 75.0, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 224.27, r0: 6.25, r1: 84.375, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 225.57, r0: 6.25, r1: 14.844, fade: 0.05, rgb: [0.0039, 0.0049, 0.0108] }, + Ray { angle: 226.7, r0: 173.44, r1: 247.66, fade: 0.05, rgb: [0.1206, 0.2324, 0.3745] }, + Ray { angle: 226.95, r0: 150.0, r1: 214.06, fade: 0.05, rgb: [0.1353, 0.2706, 0.4314] }, + Ray { angle: 227.2, r0: 128.91, r1: 182.81, fade: 0.05, rgb: [0.1706, 0.3353, 0.5412] }, + Ray { angle: 227.54, r0: 110.16, r1: 155.47, fade: 0.05, rgb: [0.1863, 0.3608, 0.5569] }, + Ray { angle: 227.84, r0: 97.656, r1: 138.28, fade: 0.05, rgb: [0.1961, 0.3745, 0.549] }, + Ray { angle: 228.1, r0: 88.281, r1: 126.56, fade: 0.05, rgb: [0.1824, 0.3392, 0.4873] }, + Ray { angle: 247.23, r0: 22.656, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 248.21, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0088, 0.0137, 0.0137] }, + Ray { angle: 249.45, r0: 83.594, r1: 114.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0147] }, + Ray { angle: 250.74, r0: 39.062, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 251.44, r0: 42.969, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 252.75, r0: 70.312, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0118] }, + Ray { angle: 254.06, r0: 7.0312, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 254.97, r0: 21.875, r1: 111.72, fade: 0.05, rgb: [0.0127, 0.0137, 0.0137] }, + Ray { angle: 277.32, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 278.05, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 279.3, r0: 61.719, r1: 112.5, fade: 0.05, rgb: [0.0088, 0.0098, 0.0098] }, + Ray { angle: 280.59, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0147, 0.0137, 0.0137] }, + Ray { angle: 281.86, r0: 34.375, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0118] }, + Ray { angle: 282.6, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 283.9, r0: 6.25, r1: 44.531, fade: 0.05, rgb: [0.0039, 0.0127, 0.0127] }, + Ray { angle: 285.09, r0: 22.656, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 307.46, r0: 21.094, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 308.33, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0127, 0.0137] }, + Ray { angle: 309.42, r0: 6.25, r1: 39.844, fade: 0.05, rgb: [0.0059, 0.0118, 0.0127] }, + Ray { angle: 310.6, r0: 80.469, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 311.76, r0: 83.594, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 312.9, r0: 70.312, r1: 111.72, fade: 0.05, rgb: [0.0069, 0.0069, 0.0078] }, + Ray { angle: 315.0, r0: 6.25, r1: 100.0, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 337.28, r0: 21.875, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 338.21, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 339.49, r0: 78.125, r1: 114.06, fade: 0.05, rgb: [0.0235, 0.0255, 0.0235] }, + Ray { angle: 340.81, r0: 6.25, r1: 110.94, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 341.56, r0: 31.25, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 342.81, r0: 78.906, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 344.06, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 344.97, r0: 21.094, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 357.95, r0: 10.156, r1: 32.031, fade: 0.05, rgb: [0.0039, 0.0078, 0.0059] }, + Ray { angle: 358.2, r0: 10.156, r1: 32.031, fade: 0.05, rgb: [0.0039, 0.0078, 0.0059] }, + Ray { angle: 358.49, r0: 10.156, r1: 32.031, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 358.75, r0: 10.156, r1: 35.938, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 359.01, r0: 10.156, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0078, 0.0098] }, +]; +const MOVIE_PRIME_125_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 0.9999, 1.0]), + (4.8, [1.0, 0.9906, 0.9819]), + (9.6, [0.9951, 0.9549, 0.9326]), + (14.4, [0.9807, 0.8977, 0.8691]), + (22.4, [0.9289, 0.7669, 0.7422]), + (33.6, [0.8067, 0.5605, 0.5522]), + (40.8, [0.7142, 0.4408, 0.449]), + (44.0, [0.6726, 0.3962, 0.4099]), + (48.0, [0.6252, 0.3514, 0.368]), + (50.4, [0.6003, 0.3289, 0.3511]), + (57.6, [0.5318, 0.2866, 0.3107]), + (61.6, [0.5023, 0.2683, 0.2963]), + (83.2, [0.3653, 0.2134, 0.2335]), + (96.0, [0.2956, 0.1811, 0.2006]), + (105.6, [0.2458, 0.1644, 0.1785]), + (119.2, [0.1878, 0.1356, 0.1483]), + (125.6, [0.1653, 0.1266, 0.1381]), + (130.4, [0.1479, 0.1155, 0.127]), + (143.2, [0.115, 0.0988, 0.1075]), + (165.6, [0.083, 0.0712, 0.0786]), + (180.8, [0.066, 0.0555, 0.06]), + (187.2, [0.0592, 0.0507, 0.0557]), + (192.8, [0.0515, 0.0439, 0.0483]), + (195.2, [0.0509, 0.044, 0.0481]), + (196.8, [0.0475, 0.0414, 0.0447]), + (200.0, [0.0458, 0.0402, 0.0438]), + (202.4, [0.0434, 0.0362, 0.0405]), + (204.8, [0.0399, 0.0362, 0.0394]), + (207.2, [0.0396, 0.0323, 0.0365]), + (210.4, [0.0356, 0.0322, 0.0342]), + (212.8, [0.0352, 0.0283, 0.0324]), + (214.4, [0.0318, 0.0281, 0.0322]), + (216.0, [0.0316, 0.0282, 0.0287]), + (218.4, [0.0298, 0.0241, 0.0282]), + (219.2, [0.0278, 0.024, 0.0283]), + (222.4, [0.0277, 0.0236, 0.0241]), + (224.0, [0.0249, 0.0201, 0.0239]), + (228.8, [0.0227, 0.0197, 0.0197]), + (231.2, [0.0197, 0.0157, 0.0198]), + (233.6, [0.0198, 0.0156, 0.0158]), + (236.0, [0.016, 0.0156, 0.0156]), + (238.4, [0.0158, 0.0117, 0.0157]), + (240.8, [0.0159, 0.0117, 0.0118]), + (244.0, [0.0119, 0.0118, 0.0117]), + (246.4, [0.0119, 0.0079, 0.0119]), + (248.8, [0.0116, 0.0079, 0.008]), + (250.4, [0.008, 0.0079, 0.008]), + (253.6, [0.0079, 0.0082, 0.0081]), + (256.0, [0.0079, 0.0041, 0.0082]), + (258.4, [0.0058, 0.0041, 0.0042]), + (259.2, [0.004, 0.0041, 0.0042]), + (267.2, [0.004, 0.0041, 0.0042]), + (268.0, [0.0039, 0.0024, 0.0043]), + (270.4, [0.001, 0.0001, 0.0001]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0031, 0.1096, 0.1372]), + (0.79957, [-0.0016, 0.1105, 0.1404]), + (7.1961, [-0.0011, 0.1052, 0.1313]), + (9.5948, [-0.0012, 0.0991, 0.1243]), + (11.194, [0.0022, 0.099, 0.1268]), + (15.192, [0.0, 0.0774, 0.0972]), + (18.39, [-0.0003, 0.0562, 0.0707]), + (19.989, [0.0011, 0.0486, 0.0625]), + (22.388, [0.0, 0.0316, 0.0403]), + (24.787, [-0.0003, 0.0204, 0.0262]), + (27.185, [0.0008, 0.0171, 0.0228]), + (27.985, [0.0003, 0.0138, 0.0182]), + (31.983, [-0.0001, 0.0075, 0.0098]), + (39.179, [-0.0004, 0.0009, 0.0014]), + ], + }, + Radial { t: 1.372, perp: 0.0, knots: &[(0.0, [0.0, 0.0007, -0.0002]), (9.5534, [0.0005, 0.0009, 0.0015])] }, + Radial { + t: 0.84359, + perp: 0.0, + knots: &[ + (0.0, [0.0001, 0.0322, 0.0426]), + (4.7869, [-0.0004, 0.0261, 0.0326]), + (11.17, [-0.0003, 0.0122, 0.0147]), + (13.563, [0.0011, 0.0109, 0.0143]), + (15.956, [0.0005, 0.0069, 0.0087]), + (18.35, [0.0013, 0.0064, 0.0088]), + (19.148, [0.0008, 0.0048, 0.0064]), + (23.137, [0.0004, 0.0013, 0.0018]), + ], + }, +]; +const MOVIE_PRIME_125_STREAKS: &[Streak] = &[ + Streak { t: -0.00094542, perp: 0.0, angle: 0.013245, half: 5315.3, taper: 0.72268, width: 1.1147, rgb: [0.1205, 0.219, 0.3446] }, + Streak { t: 0.12404, perp: 0.0, angle: 45.159, half: 289.29, taper: 0.11141, width: 0.79857, rgb: [0.2496, 0.485, 0.7848] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.11, half: 285.89, taper: 0.032659, width: 0.84042, rgb: [0.1175, 0.2334, 0.3793] }, + Streak { t: 1.999, perp: 0.0, angle: -0.0000396, half: 181.22, taper: 1.0, width: 1.0809, rgb: [0.1685, 0.3348, 0.544] }, +]; +const MOVIE_PRIME_125_RAYS: &[Ray] = &[ + Ray { angle: 0.38, r0: 18.75, r1: 234.38, fade: 0.05, rgb: [0.0196, 0.0412, 0.0686] }, + Ray { angle: 0.7, r0: 18.75, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0196, 0.0333] }, + Ray { angle: 1.01, r0: 18.75, r1: 87.5, fade: 0.05, rgb: [0.0059, 0.0137, 0.0216] }, + Ray { angle: 1.28, r0: 18.75, r1: 67.969, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 1.55, r0: 18.75, r1: 55.469, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 1.8, r0: 18.75, r1: 48.438, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 2.05, r0: 25.781, r1: 42.969, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 2.3, r0: 25.781, r1: 38.281, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 7.19, r0: 6.25, r1: 130.47, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 8.29, r0: 6.25, r1: 71.875, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 9.4, r0: 89.062, r1: 137.5, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 10.51, r0: 89.844, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 11.64, r0: 78.125, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0196, 0.0157] }, + Ray { angle: 12.73, r0: 6.25, r1: 49.219, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 13.83, r0: 18.75, r1: 67.969, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 14.93, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 37.13, r0: 60.156, r1: 126.56, fade: 0.05, rgb: [0.0196, 0.0216, 0.0206] }, + Ray { angle: 38.24, r0: 81.25, r1: 125.78, fade: 0.05, rgb: [0.0137, 0.0157, 0.0176] }, + Ray { angle: 39.41, r0: 88.281, r1: 125.0, fade: 0.05, rgb: [0.0186, 0.0196, 0.0196] }, + Ray { angle: 40.44, r0: 55.469, r1: 77.344, fade: 0.05, rgb: [0.1118, 0.2941, 0.4529] }, + Ray { angle: 41.73, r0: 6.25, r1: 40.625, fade: 0.05, rgb: [0.002, 0.0098, 0.0118] }, + Ray { angle: 42.91, r0: 6.25, r1: 63.281, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 43.16, r0: 129.69, r1: 184.38, fade: 0.05, rgb: [0.1961, 0.3882, 0.6039] }, + Ray { angle: 43.45, r0: 150.78, r1: 214.06, fade: 0.05, rgb: [0.1853, 0.3716, 0.6088] }, + Ray { angle: 43.7, r0: 178.91, r1: 251.56, fade: 0.05, rgb: [0.2363, 0.4618, 0.6814] }, + Ray { angle: 67.39, r0: 21.094, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 68.57, r0: 6.25, r1: 72.656, fade: 0.05, rgb: [0.0059, 0.0098, 0.0108] }, + Ray { angle: 69.79, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 70.95, r0: 82.812, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 72.11, r0: 93.75, r1: 126.56, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 73.26, r0: 90.625, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 74.27, r0: 6.25, r1: 76.562, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 75.52, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 97.57, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 98.68, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 99.76, r0: 94.531, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 100.84, r0: 89.062, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 101.99, r0: 85.938, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 103.11, r0: 6.25, r1: 42.969, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 104.14, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 105.3, r0: 21.875, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 127.45, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0157, 0.0176] }, + Ray { angle: 128.41, r0: 6.25, r1: 124.22, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 129.76, r0: 50.781, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 130.78, r0: 87.5, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 132.02, r0: 82.031, r1: 125.78, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 133.05, r0: 6.25, r1: 68.75, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 134.11, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0039, 0.0098, 0.0118] }, + Ray { angle: 135.0, r0: 21.875, r1: 110.94, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 157.1, r0: 6.25, r1: 119.53, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 157.35, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 158.51, r0: 6.25, r1: 65.625, fade: 0.05, rgb: [0.0039, 0.0118, 0.0137] }, + Ray { angle: 159.77, r0: 67.188, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 160.9, r0: 82.812, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 162.07, r0: 82.031, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 163.23, r0: 6.25, r1: 46.875, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 164.25, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 165.41, r0: 24.219, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 177.79, r0: 12.5, r1: 39.062, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 178.08, r0: 12.5, r1: 45.312, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 178.36, r0: 12.5, r1: 53.125, fade: 0.05, rgb: [0.0059, 0.0137, 0.0176] }, + Ray { angle: 179.62, r0: 12.5, r1: 234.38, fade: 0.05, rgb: [0.0294, 0.0647, 0.1059] }, + Ray { angle: 180.0, r0: 17.969, r1: 234.38, fade: 0.05, rgb: [0.0608, 0.1216, 0.1961] }, + Ray { angle: 181.01, r0: 18.75, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0098, 0.0137] }, + Ray { angle: 181.28, r0: 18.75, r1: 35.156, fade: 0.05, rgb: [0.0039, 0.0088, 0.0108] }, + Ray { angle: 181.55, r0: 18.75, r1: 28.906, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, + Ray { angle: 187.57, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 188.68, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0157, 0.0157] }, + Ray { angle: 189.79, r0: 91.406, r1: 126.56, fade: 0.05, rgb: [0.0167, 0.0196, 0.0196] }, + Ray { angle: 190.84, r0: 86.719, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 192.0, r0: 82.812, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 193.11, r0: 6.25, r1: 43.75, fade: 0.05, rgb: [0.0039, 0.0098, 0.0118] }, + Ray { angle: 194.19, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0059, 0.0118, 0.0147] }, + Ray { angle: 195.35, r0: 20.312, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 217.5, r0: 26.562, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 218.52, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 219.81, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0176] }, + Ray { angle: 220.84, r0: 42.969, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0127, 0.0137] }, + Ray { angle: 222.12, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 223.15, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 224.23, r0: 6.25, r1: 125.0, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 227.06, r0: 139.06, r1: 196.09, fade: 0.05, rgb: [0.1892, 0.3784, 0.5961] }, + Ray { angle: 227.31, r0: 123.44, r1: 171.09, fade: 0.05, rgb: [0.198, 0.3941, 0.6127] }, + Ray { angle: 227.56, r0: 109.38, r1: 153.91, fade: 0.05, rgb: [0.2049, 0.4078, 0.6078] }, + Ray { angle: 227.88, r0: 96.094, r1: 136.72, fade: 0.05, rgb: [0.202, 0.402, 0.5588] }, + Ray { angle: 247.06, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 248.21, r0: 73.438, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 249.45, r0: 82.812, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 250.53, r0: 81.25, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0186] }, + Ray { angle: 251.72, r0: 80.469, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 252.85, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 253.9, r0: 18.75, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 255.08, r0: 24.219, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 277.19, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 278.33, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 279.44, r0: 86.719, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0167, 0.0176] }, + Ray { angle: 280.53, r0: 89.062, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 281.63, r0: 42.188, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 282.76, r0: 6.25, r1: 46.875, fade: 0.05, rgb: [0.0039, 0.0118, 0.0098] }, + Ray { angle: 283.83, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0059, 0.0127, 0.0147] }, + Ray { angle: 284.93, r0: 20.312, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 285.25, r0: 21.875, r1: 121.88, fade: 0.05, rgb: [0.0039, 0.0039, 0.0078] }, + Ray { angle: 307.17, r0: 21.094, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 308.24, r0: 6.25, r1: 125.78, fade: 0.05, rgb: [0.0059, 0.0127, 0.0137] }, + Ray { angle: 309.52, r0: 80.469, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 310.59, r0: 46.094, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0098, 0.0108] }, + Ray { angle: 311.83, r0: 80.469, r1: 126.56, fade: 0.05, rgb: [0.0167, 0.0186, 0.0196] }, + Ray { angle: 312.91, r0: 6.25, r1: 37.5, fade: 0.05, rgb: [0.002, 0.0098, 0.0137] }, + Ray { angle: 313.92, r0: 6.25, r1: 70.312, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 315.0, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 337.07, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 338.21, r0: 7.0312, r1: 126.56, fade: 0.05, rgb: [0.0069, 0.0137, 0.0137] }, + Ray { angle: 339.49, r0: 78.906, r1: 127.34, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 340.53, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0196, 0.0216, 0.0196] }, + Ray { angle: 341.74, r0: 50.0, r1: 127.34, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 342.9, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 343.94, r0: 6.25, r1: 71.875, fade: 0.05, rgb: [0.0039, 0.0118, 0.0137] }, + Ray { angle: 345.08, r0: 21.875, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 357.45, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0039] }, + Ray { angle: 357.7, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0039] }, + Ray { angle: 357.95, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 358.2, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0078] }, + Ray { angle: 358.49, r0: 14.062, r1: 31.25, fade: 0.05, rgb: [0.0039, 0.0059, 0.0078] }, + Ray { angle: 358.75, r0: 14.062, r1: 35.938, fade: 0.05, rgb: [0.0039, 0.0059, 0.0078] }, + Ray { angle: 359.01, r0: 14.062, r1: 43.75, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, +]; +const MOVIE_PRIME_150_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 1.0, 1.0]), + (8.0, [1.0, 0.9947, 0.9807]), + (17.6, [0.9904, 0.9239, 0.9043]), + (26.4, [0.9573, 0.8173, 0.8054]), + (33.6, [0.9094, 0.7129, 0.7119]), + (44.8, [0.8091, 0.5546, 0.5754]), + (54.4, [0.7201, 0.4495, 0.4808]), + (59.2, [0.6826, 0.4167, 0.4508]), + (77.6, [0.5729, 0.3575, 0.388]), + (105.6, [0.4168, 0.2807, 0.3037]), + (128.8, [0.3039, 0.2258, 0.245]), + (145.6, [0.2366, 0.1898, 0.2058]), + (150.4, [0.2233, 0.1777, 0.1938]), + (155.2, [0.2056, 0.1694, 0.1843]), + (164.8, [0.1836, 0.1537, 0.1667]), + (167.2, [0.1742, 0.1498, 0.1622]), + (205.6, [0.1105, 0.0955, 0.1042]), + (230.4, [0.0786, 0.0669, 0.0713]), + (235.2, [0.0708, 0.0621, 0.0668]), + (243.2, [0.0629, 0.0542, 0.0591]), + (244.8, [0.061, 0.051, 0.0555]), + (247.2, [0.0591, 0.051, 0.0553]), + (248.8, [0.0552, 0.0473, 0.0515]), + (251.2, [0.0551, 0.0476, 0.0515]), + (252.8, [0.0512, 0.0442, 0.0476]), + (255.2, [0.051, 0.0436, 0.0479]), + (256.8, [0.0472, 0.0412, 0.0442]), + (259.2, [0.0474, 0.0396, 0.0438]), + (260.8, [0.0435, 0.0397, 0.0416]), + (263.2, [0.0434, 0.0357, 0.0397]), + (264.8, [0.0395, 0.0356, 0.0391]), + (266.4, [0.0393, 0.0344, 0.0357]), + (268.0, [0.0391, 0.0317, 0.0358]), + (269.6, [0.0354, 0.0317, 0.0355]), + (271.2, [0.0354, 0.0312, 0.0317]), + (272.8, [0.0353, 0.0276, 0.0318]), + (274.4, [0.0314, 0.0276, 0.0319]), + (276.8, [0.0315, 0.0277, 0.0277]), + (278.4, [0.0292, 0.0235, 0.0276]), + (279.2, [0.0274, 0.0236, 0.0277]), + (282.4, [0.0277, 0.0236, 0.0237]), + (284.0, [0.0235, 0.0208, 0.0237]), + (285.6, [0.0236, 0.0198, 0.024]), + (287.2, [0.0236, 0.0198, 0.0199]), + (288.0, [0.0239, 0.0197, 0.0198]), + (289.6, [0.0199, 0.0192, 0.0199]), + (290.4, [0.0197, 0.0164, 0.0198]), + (292.0, [0.0197, 0.0159, 0.0196]), + (293.6, [0.0196, 0.0158, 0.0157]), + (294.4, [0.02, 0.0159, 0.016]), + (296.0, [0.0158, 0.0159, 0.016]), + (299.2, [0.0158, 0.0119, 0.0158]), + (300.8, [0.0158, 0.0119, 0.0118]), + (303.2, [0.0117, 0.0119, 0.012]), + (304.8, [0.0118, 0.012, 0.012]), + (307.2, [0.0118, 0.0078, 0.0121]), + (308.8, [0.0116, 0.0079, 0.0079]), + (310.4, [0.0077, 0.0079, 0.008]), + (314.4, [0.0079, 0.0081, 0.008]), + (316.0, [0.0079, 0.0039, 0.0083]), + (317.6, [0.0082, 0.004, 0.0041]), + (319.2, [0.004, 0.004, 0.004]), + (327.2, [0.004, 0.0043, 0.0041]), + (328.8, [0.0039, 0.0004, 0.0041]), + (329.6, [0.0042, 0.0, 0.0014]), + (331.2, [0.0002, 0.0001, 0.0002]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.1279, 0.1503]), + (0.79957, [-0.0019, 0.1277, 0.1521]), + (4.7974, [-0.0017, 0.1242, 0.1482]), + (9.5948, [-0.002, 0.1138, 0.1354]), + (11.194, [0.0025, 0.1155, 0.1397]), + (18.39, [-0.0006, 0.0665, 0.0796]), + (19.989, [0.0013, 0.0595, 0.0722]), + (21.588, [0.0003, 0.0454, 0.0552]), + (24.787, [-0.0005, 0.027, 0.033]), + (27.185, [0.0015, 0.023, 0.0299]), + (27.985, [0.0004, 0.0184, 0.0237]), + (28.784, [0.0004, 0.0167, 0.0209]), + (33.582, [-0.0004, 0.0076, 0.0094]), + (39.978, [-0.0006, 0.001, 0.0017]), + ], + }, + Radial { + t: 1.4706, + perp: 0.0, + knots: &[ + (0.0, [0.0, -0.0001, -0.0005]), + (4.7819, [-0.0001, -0.0001, -0.0004]), + (7.1728, [0.001, 0.002, 0.0034]), + (9.5638, [0.0004, 0.0007, 0.0011]), + (15.94, [0.0005, 0.0008, 0.0015]), + ], + }, + Radial { + t: 0.8409, + perp: 0.0, + knots: &[ + (0.0, [-0.0009, 0.0356, 0.0385]), + (0.79741, [-0.0002, 0.0382, 0.0422]), + (3.1896, [-0.0008, 0.0345, 0.0395]), + (11.164, [-0.0007, 0.0154, 0.0176]), + (13.556, [0.0018, 0.0157, 0.0194]), + (15.948, [0.0005, 0.0091, 0.0106]), + (18.34, [0.002, 0.009, 0.0119]), + (19.138, [0.0008, 0.0063, 0.0078]), + (23.125, [0.0004, 0.0013, 0.0013]), + ], + }, +]; +const MOVIE_PRIME_150_STREAKS: &[Streak] = &[ + Streak { t: -0.00097202, perp: 0.0, angle: 0.01593, half: 5063.9, taper: 0.24264, width: 1.0908, rgb: [0.1543, 0.2762, 0.431] }, + Streak { t: 0.12417, perp: 0.0, angle: 45.155, half: 354.78, taper: 0.5404, width: 3.0732, rgb: [0.0673, 0.1218, 0.1752] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.09, half: 475.21, taper: 0.80649, width: 2.0575, rgb: [0.0492, 0.0882, 0.1334] }, + Streak { t: 1.999, perp: 0.0, angle: 0.0044542, half: 180.45, taper: 1.0, width: 1.0392, rgb: [0.2189, 0.4317, 0.6954] }, +]; +const MOVIE_PRIME_150_RAYS: &[Ray] = &[ + Ray { angle: 0.47, r0: 20.312, r1: 189.06, fade: 0.05, rgb: [0.0157, 0.0353, 0.0569] }, + Ray { angle: 0.8, r0: 20.312, r1: 114.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0265] }, + Ray { angle: 1.05, r0: 20.312, r1: 84.375, fade: 0.05, rgb: [0.0059, 0.0137, 0.0196] }, + Ray { angle: 1.31, r0: 20.312, r1: 67.188, fade: 0.05, rgb: [0.0039, 0.0118, 0.0157] }, + Ray { angle: 1.57, r0: 20.312, r1: 55.469, fade: 0.05, rgb: [0.0039, 0.0098, 0.0127] }, + Ray { angle: 1.85, r0: 20.312, r1: 46.875, fade: 0.05, rgb: [0.002, 0.0078, 0.0098] }, + Ray { angle: 2.13, r0: 27.344, r1: 40.625, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, + Ray { angle: 7.09, r0: 6.25, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 8.42, r0: 72.656, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0167, 0.0137] }, + Ray { angle: 9.42, r0: 99.219, r1: 138.28, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 10.47, r0: 91.406, r1: 140.62, fade: 0.05, rgb: [0.0275, 0.0284, 0.0284] }, + Ray { angle: 11.74, r0: 39.062, r1: 145.31, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 12.73, r0: 64.062, r1: 149.22, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 14.03, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 15.14, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 37.2, r0: 60.938, r1: 139.06, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 38.45, r0: 73.438, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 39.41, r0: 96.875, r1: 140.62, fade: 0.05, rgb: [0.0216, 0.0216, 0.0216] }, + Ray { angle: 40.43, r0: 102.34, r1: 138.28, fade: 0.05, rgb: [0.0314, 0.0353, 0.0314] }, + Ray { angle: 42.02, r0: 83.594, r1: 118.75, fade: 0.05, rgb: [0.1412, 0.3549, 0.5078] }, + Ray { angle: 43.04, r0: 123.44, r1: 173.44, fade: 0.05, rgb: [0.2039, 0.4118, 0.5863] }, + Ray { angle: 43.29, r0: 137.5, r1: 196.09, fade: 0.05, rgb: [0.2, 0.3941, 0.598] }, + Ray { angle: 43.55, r0: 160.94, r1: 230.47, fade: 0.05, rgb: [0.1824, 0.3725, 0.6127] }, + Ray { angle: 43.81, r0: 196.09, r1: 275.78, fade: 0.05, rgb: [0.2451, 0.4843, 0.7176] }, + Ray { angle: 45.28, r0: 42.969, r1: 127.34, fade: 0.05, rgb: [0.0196, 0.0275, 0.0275] }, + Ray { angle: 67.47, r0: 31.25, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 68.72, r0: 84.375, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 69.77, r0: 67.969, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 70.9, r0: 85.156, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0186, 0.0176] }, + Ray { angle: 71.94, r0: 59.375, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 73.26, r0: 75.0, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 74.28, r0: 6.25, r1: 80.469, fade: 0.05, rgb: [0.0039, 0.0118, 0.0118] }, + Ray { angle: 75.31, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 97.43, r0: 6.25, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 98.72, r0: 6.25, r1: 141.41, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 99.76, r0: 100.0, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0216, 0.0196] }, + Ray { angle: 100.77, r0: 87.5, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 102.02, r0: 38.281, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0098, 0.0108] }, + Ray { angle: 103.05, r0: 83.594, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 104.37, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 105.42, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 127.5, r0: 8.5938, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0127, 0.0137] }, + Ray { angle: 128.7, r0: 9.375, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 129.58, r0: 94.531, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0186] }, + Ray { angle: 130.73, r0: 78.125, r1: 139.84, fade: 0.05, rgb: [0.0255, 0.0324, 0.0314] }, + Ray { angle: 132.12, r0: 60.938, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0176, 0.0137] }, + Ray { angle: 133.05, r0: 90.625, r1: 138.28, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 134.21, r0: 97.656, r1: 136.72, fade: 0.05, rgb: [0.0314, 0.0392, 0.0392] }, + Ray { angle: 135.27, r0: 74.219, r1: 133.59, fade: 0.05, rgb: [0.0294, 0.0353, 0.0353] }, + Ray { angle: 157.36, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 158.71, r0: 10.156, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 159.75, r0: 91.406, r1: 139.84, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 160.9, r0: 96.094, r1: 140.62, fade: 0.05, rgb: [0.0167, 0.0216, 0.0196] }, + Ray { angle: 161.9, r0: 78.906, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 163.23, r0: 74.219, r1: 140.62, fade: 0.05, rgb: [0.0167, 0.0216, 0.0176] }, + Ray { angle: 164.27, r0: 18.75, r1: 76.562, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 165.31, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 177.98, r0: 14.844, r1: 42.969, fade: 0.05, rgb: [0.002, 0.0098, 0.0118] }, + Ray { angle: 178.26, r0: 14.844, r1: 50.0, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 178.53, r0: 14.844, r1: 58.594, fade: 0.05, rgb: [0.0039, 0.0137, 0.0157] }, + Ray { angle: 178.78, r0: 14.844, r1: 71.875, fade: 0.05, rgb: [0.0059, 0.0157, 0.0235] }, + Ray { angle: 179.3, r0: 14.844, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0314, 0.049] }, + Ray { angle: 179.62, r0: 14.844, r1: 234.38, fade: 0.05, rgb: [0.0294, 0.0686, 0.1127] }, + Ray { angle: 180.0, r0: 20.312, r1: 234.38, fade: 0.05, rgb: [0.0569, 0.1294, 0.2059] }, + Ray { angle: 181.01, r0: 20.312, r1: 43.75, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, + Ray { angle: 181.28, r0: 20.312, r1: 34.375, fade: 0.05, rgb: [0.002, 0.0078, 0.0078] }, + Ray { angle: 187.45, r0: 6.25, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 188.74, r0: 6.25, r1: 141.41, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 189.76, r0: 100.0, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 190.78, r0: 83.594, r1: 140.62, fade: 0.05, rgb: [0.0186, 0.0206, 0.0196] }, + Ray { angle: 192.03, r0: 85.156, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 193.1, r0: 57.812, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 194.39, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.002, 0.0078, 0.0098] }, + Ray { angle: 195.43, r0: 20.312, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 217.53, r0: 42.969, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 218.74, r0: 20.312, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 219.66, r0: 7.8125, r1: 84.375, fade: 0.05, rgb: [0.002, 0.0078, 0.0098] }, + Ray { angle: 220.84, r0: 67.969, r1: 141.41, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 222.16, r0: 78.906, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0176, 0.0157] }, + Ray { angle: 223.05, r0: 78.906, r1: 138.28, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 224.25, r0: 6.25, r1: 99.219, fade: 0.05, rgb: [0.0059, 0.0147, 0.0157] }, + Ray { angle: 225.0, r0: 8.5938, r1: 124.22, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 225.27, r0: 8.5938, r1: 73.438, fade: 0.05, rgb: [0.002, 0.0088, 0.0118] }, + Ray { angle: 226.81, r0: 160.94, r1: 233.59, fade: 0.05, rgb: [0.1657, 0.3284, 0.4941] }, + Ray { angle: 227.06, r0: 139.06, r1: 200.0, fade: 0.05, rgb: [0.1667, 0.349, 0.5529] }, + Ray { angle: 227.31, r0: 123.44, r1: 171.88, fade: 0.05, rgb: [0.1725, 0.3608, 0.5392] }, + Ray { angle: 228.0, r0: 92.188, r1: 131.25, fade: 0.05, rgb: [0.1686, 0.3745, 0.5157] }, + Ray { angle: 247.11, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 248.42, r0: 69.531, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0186, 0.0176] }, + Ray { angle: 249.45, r0: 90.625, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 250.54, r0: 92.969, r1: 141.41, fade: 0.05, rgb: [0.0314, 0.0373, 0.0373] }, + Ray { angle: 251.59, r0: 57.812, r1: 141.41, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 252.9, r0: 72.656, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 253.94, r0: 19.531, r1: 74.219, fade: 0.05, rgb: [0.0059, 0.0157, 0.0157] }, + Ray { angle: 254.96, r0: 22.656, r1: 141.41, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 277.13, r0: 10.156, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 278.42, r0: 64.062, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0137] }, + Ray { angle: 279.45, r0: 97.656, r1: 140.62, fade: 0.05, rgb: [0.0147, 0.0196, 0.0186] }, + Ray { angle: 280.47, r0: 85.938, r1: 139.84, fade: 0.05, rgb: [0.0176, 0.0206, 0.0206] }, + Ray { angle: 281.73, r0: 39.844, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 282.75, r0: 62.5, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 284.03, r0: 87.5, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 285.13, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 307.32, r0: 30.469, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 308.48, r0: 16.406, r1: 85.156, fade: 0.05, rgb: [0.0039, 0.0098, 0.0118] }, + Ray { angle: 309.42, r0: 10.938, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 310.59, r0: 73.438, r1: 140.62, fade: 0.05, rgb: [0.0216, 0.0294, 0.0294] }, + Ray { angle: 311.95, r0: 49.219, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 312.84, r0: 71.875, r1: 139.06, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 314.01, r0: 85.156, r1: 138.28, fade: 0.05, rgb: [0.0275, 0.0353, 0.0333] }, + Ray { angle: 337.12, r0: 34.375, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 338.44, r0: 81.25, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 339.49, r0: 93.75, r1: 140.62, fade: 0.05, rgb: [0.0216, 0.0333, 0.0314] }, + Ray { angle: 340.57, r0: 86.719, r1: 141.41, fade: 0.05, rgb: [0.0275, 0.0353, 0.0314] }, + Ray { angle: 341.68, r0: 80.469, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0186, 0.0176] }, + Ray { angle: 342.96, r0: 67.969, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0167, 0.0157] }, + Ray { angle: 343.98, r0: 17.969, r1: 72.656, fade: 0.05, rgb: [0.0059, 0.0157, 0.0137] }, + Ray { angle: 344.99, r0: 21.875, r1: 141.41, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 358.49, r0: 19.531, r1: 30.469, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 358.75, r0: 19.531, r1: 35.156, fade: 0.05, rgb: [0.002, 0.0059, 0.0078] }, + Ray { angle: 359.01, r0: 19.531, r1: 44.531, fade: 0.05, rgb: [0.002, 0.0078, 0.0078] }, +]; +const MOVIE_PRIME_200_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 1.0, 1.0]), + (13.6, [1.0, 1.0, 0.9967]), + (28.0, [1.0, 0.9627, 0.9735]), + (43.2, [0.9874, 0.8778, 0.9117]), + (60.8, [0.9438, 0.7641, 0.8185]), + (83.2, [0.8674, 0.6687, 0.7223]), + (151.2, [0.5757, 0.448, 0.489]), + (180.8, [0.4478, 0.3681, 0.3995]), + (196.8, [0.388, 0.3268, 0.3551]), + (224.8, [0.3084, 0.264, 0.2875]), + (254.4, [0.2397, 0.2055, 0.2245]), + (280.0, [0.1883, 0.1612, 0.1741]), + (310.4, [0.1335, 0.1141, 0.1261]), + (333.6, [0.0983, 0.0858, 0.0931]), + (344.8, [0.084, 0.0713, 0.079]), + (347.2, [0.0824, 0.0708, 0.0751]), + (348.8, [0.0784, 0.0669, 0.0752]), + (350.4, [0.0786, 0.0672, 0.071]), + (352.0, [0.0746, 0.0635, 0.0713]), + (353.6, [0.0748, 0.0631, 0.0672]), + (356.0, [0.0707, 0.0599, 0.0673]), + (358.4, [0.0669, 0.0593, 0.0632]), + (360.0, [0.067, 0.0553, 0.0615]), + (361.6, [0.0629, 0.0551, 0.0593]), + (364.0, [0.0626, 0.0514, 0.058]), + (366.4, [0.0589, 0.0514, 0.0554]), + (368.8, [0.0554, 0.0472, 0.0513]), + (371.2, [0.0552, 0.0475, 0.0516]), + (372.8, [0.0513, 0.0435, 0.0476]), + (375.2, [0.0508, 0.0434, 0.0477]), + (376.8, [0.0471, 0.041, 0.0436]), + (379.2, [0.0471, 0.0394, 0.0434]), + (380.0, [0.0446, 0.0396, 0.0436]), + (381.6, [0.0433, 0.0361, 0.0394]), + (384.0, [0.0416, 0.0355, 0.0398]), + (385.6, [0.0393, 0.0351, 0.036]), + (387.2, [0.0393, 0.0315, 0.0357]), + (388.8, [0.0373, 0.0316, 0.0359]), + (390.4, [0.0354, 0.0316, 0.0319]), + (392.8, [0.0354, 0.0275, 0.0317]), + (394.4, [0.0314, 0.0276, 0.0316]), + (396.8, [0.0316, 0.0274, 0.0278]), + (398.4, [0.0281, 0.0236, 0.0277]), + (402.4, [0.0277, 0.0239, 0.0238]), + (404.0, [0.0236, 0.0198, 0.0238]), + (405.6, [0.0236, 0.0198, 0.024]), + (407.2, [0.0236, 0.0198, 0.0198]), + (408.8, [0.0226, 0.02, 0.0199]), + (410.4, [0.0196, 0.0159, 0.0198]), + (414.4, [0.0199, 0.0159, 0.016]), + (416.0, [0.0159, 0.0158, 0.016]), + (416.8, [0.0157, 0.0159, 0.016]), + (418.4, [0.0158, 0.0119, 0.016]), + (420.8, [0.0156, 0.0119, 0.0119]), + (422.4, [0.0117, 0.0119, 0.012]), + (424.8, [0.0118, 0.0122, 0.012]), + (426.4, [0.0119, 0.0081, 0.0122]), + (428.0, [0.012, 0.008, 0.0083]), + (430.4, [0.0079, 0.008, 0.0081]), + (434.4, [0.0079, 0.008, 0.0081]), + (436.0, [0.0079, 0.004, 0.0082]), + (437.6, [0.0081, 0.0041, 0.0047]), + (439.2, [0.0039, 0.0041, 0.0042]), + (446.4, [0.004, 0.0043, 0.0042]), + (448.0, [0.004, 0.0002, 0.0044]), + (449.6, [0.0042, 0.0001, 0.0002]), + (451.2, [0.0001, 0.0001, 0.0002]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0012, 0.1206, 0.1371]), + (0.79957, [-0.0031, 0.1209, 0.1376]), + (1.5991, [-0.0017, 0.122, 0.1377]), + (5.597, [-0.0021, 0.1181, 0.1332]), + (9.5948, [-0.0028, 0.1091, 0.1229]), + (11.194, [0.0024, 0.1128, 0.1293]), + (12.793, [0.0002, 0.1014, 0.1166]), + (18.39, [-0.0012, 0.0661, 0.0753]), + (19.989, [0.0011, 0.0604, 0.0708]), + (21.588, [-0.0002, 0.047, 0.0548]), + (24.787, [-0.0009, 0.029, 0.0337]), + (27.185, [0.0013, 0.0259, 0.0323]), + (27.985, [0.0002, 0.0207, 0.0251]), + (29.584, [-0.0001, 0.0161, 0.0195]), + (33.582, [-0.0009, 0.0084, 0.0103]), + (38.379, [-0.0012, 0.0027, 0.0034]), + (44.776, [-0.0013, -0.001, -0.0009]), + (46.375, [-0.0004, 0.0008, 0.0019]), + (47.974, [-0.0005, 0.0007, 0.0017]), + ], + }, + Radial { + t: 1.6911, + perp: 0.0, + knots: &[ + (0.0, [0.0006, 0.0007, 0.0009]), + (0.79924, [-0.0003, -0.0005, -0.0009]), + (2.3977, [0.0019, 0.0036, 0.006]), + (4.7954, [0.0019, 0.0038, 0.0062]), + (7.9924, [0.0008, 0.0015, 0.0024]), + (18.382, [0.0002, 0.0004, 0.0006]), + (30.371, [0.0001, 0.0001, 0.0001]), + (33.568, [0.0014, 0.0029, 0.0047]), + (35.166, [0.0008, 0.0015, 0.0025]), + (40.761, [0.0004, 0.0008, 0.0013]), + (43.159, [0.001, 0.002, 0.0031]), + (57.545, [0.0006, 0.0011, 0.0018]), + ], + }, + Radial { + t: 0.83516, + perp: 0.0, + knots: &[ + (0.0, [-0.0036, 0.0307, 0.0338]), + (0.77929, [-0.0013, 0.0348, 0.0382]), + (3.1172, [-0.0009, 0.0335, 0.0379]), + (8.5722, [-0.0011, 0.0253, 0.0286]), + (11.689, [-0.0009, 0.0179, 0.0206]), + (12.469, [0.0018, 0.0217, 0.0263]), + (13.248, [0.0036, 0.0228, 0.0276]), + (14.806, [0.0014, 0.015, 0.0179]), + (16.365, [0.0007, 0.0107, 0.0123]), + (17.144, [0.0013, 0.0098, 0.0118]), + (17.924, [0.003, 0.0124, 0.0158]), + (18.703, [0.0026, 0.0108, 0.0139]), + (19.482, [0.0015, 0.0068, 0.0085]), + (23.379, [0.0006, 0.0004, 0.0005]), + ], + }, +]; +const MOVIE_PRIME_200_STREAKS: &[Streak] = &[ + Streak { t: -0.00099547, perp: 0.0, angle: 0.015305, half: 4745.1, taper: 0.01495, width: 1.082, rgb: [0.2123, 0.3739, 0.6285] }, + Streak { t: 0.12445, perp: 0.0, angle: 45.155, half: 302.34, taper: 0.011295, width: 0.76047, rgb: [0.2109, 0.4419, 0.7519] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.03, half: 237.44, taper: 0.70807, width: 4.587, rgb: [0.0284, 0.0457, 0.0645] }, + Streak { t: 1.9989, perp: 0.0, angle: 0.022816, half: 180.17, taper: 1.0, width: 1.0643, rgb: [0.275, 0.5361, 0.8465] }, +]; +const MOVIE_PRIME_200_RAYS: &[Ray] = &[ + Ray { angle: 0.7, r0: 32.031, r1: 126.56, fade: 0.05, rgb: [0.0039, 0.0137, 0.0176] }, + Ray { angle: 1.47, r0: 43.75, r1: 57.812, fade: 0.05, rgb: [0.0, 0.0059, 0.0059] }, + Ray { angle: 7.27, r0: 48.438, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0098] }, + Ray { angle: 8.36, r0: 90.625, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 9.42, r0: 88.281, r1: 167.97, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 10.53, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 11.63, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 12.73, r0: 94.531, r1: 168.75, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 13.86, r0: 92.188, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 15.0, r0: 49.219, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 37.28, r0: 62.5, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 38.4, r0: 89.062, r1: 167.97, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 39.35, r0: 101.56, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0255, 0.0235] }, + Ray { angle: 40.49, r0: 104.69, r1: 167.97, fade: 0.05, rgb: [0.0196, 0.0294, 0.0255] }, + Ray { angle: 41.85, r0: 82.031, r1: 114.06, fade: 0.05, rgb: [0.0471, 0.1961, 0.2451] }, + Ray { angle: 42.98, r0: 121.09, r1: 171.09, fade: 0.05, rgb: [0.1314, 0.2941, 0.3373] }, + Ray { angle: 43.26, r0: 139.06, r1: 196.09, fade: 0.05, rgb: [0.1471, 0.3294, 0.4078] }, + Ray { angle: 43.52, r0: 161.72, r1: 227.34, fade: 0.05, rgb: [0.1706, 0.3392, 0.4804] }, + Ray { angle: 43.78, r0: 191.41, r1: 271.09, fade: 0.05, rgb: [0.1667, 0.3686, 0.5824] }, + Ray { angle: 45.26, r0: 63.281, r1: 152.34, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 67.59, r0: 45.312, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 68.54, r0: 99.219, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0176] }, + Ray { angle: 69.67, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 70.85, r0: 94.531, r1: 168.75, fade: 0.05, rgb: [0.0196, 0.0294, 0.0255] }, + Ray { angle: 71.94, r0: 96.094, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 73.06, r0: 90.625, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 74.19, r0: 87.5, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 75.3, r0: 48.438, r1: 167.97, fade: 0.05, rgb: [0.0069, 0.0137, 0.0137] }, + Ray { angle: 97.51, r0: 50.781, r1: 167.97, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 98.61, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 99.71, r0: 100.78, r1: 169.53, fade: 0.05, rgb: [0.0216, 0.0314, 0.0294] }, + Ray { angle: 100.79, r0: 91.406, r1: 167.97, fade: 0.05, rgb: [0.0176, 0.0275, 0.0235] }, + Ray { angle: 101.89, r0: 98.438, r1: 167.97, fade: 0.05, rgb: [0.0186, 0.0314, 0.0275] }, + Ray { angle: 103.03, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 104.12, r0: 95.312, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 105.23, r0: 46.875, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0157, 0.0137] }, + Ray { angle: 127.43, r0: 49.219, r1: 167.97, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 128.6, r0: 82.812, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 129.56, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0118, 0.0216, 0.0176] }, + Ray { angle: 130.78, r0: 87.5, r1: 167.97, fade: 0.05, rgb: [0.0118, 0.0196, 0.0157] }, + Ray { angle: 132.04, r0: 82.031, r1: 167.97, fade: 0.05, rgb: [0.0098, 0.0216, 0.0196] }, + Ray { angle: 133.2, r0: 87.5, r1: 167.97, fade: 0.05, rgb: [0.0137, 0.0216, 0.0206] }, + Ray { angle: 134.31, r0: 115.62, r1: 163.28, fade: 0.05, rgb: [0.0235, 0.0353, 0.0314] }, + Ray { angle: 135.22, r0: 90.625, r1: 160.94, fade: 0.05, rgb: [0.0176, 0.0294, 0.0275] }, + Ray { angle: 157.56, r0: 34.375, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 158.54, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0147, 0.0225, 0.0216] }, + Ray { angle: 159.63, r0: 86.719, r1: 168.75, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 160.83, r0: 87.5, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 161.94, r0: 96.094, r1: 168.75, fade: 0.05, rgb: [0.0167, 0.0294, 0.0265] }, + Ray { angle: 163.04, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0265, 0.0235] }, + Ray { angle: 164.17, r0: 85.156, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 165.27, r0: 48.438, r1: 167.97, fade: 0.05, rgb: [0.0059, 0.0137, 0.0127] }, + Ray { angle: 165.52, r0: 71.875, r1: 159.38, fade: 0.05, rgb: [0.0039, 0.0059, 0.0039] }, + Ray { angle: 178.33, r0: 39.844, r1: 50.781, fade: 0.05, rgb: [0.0, 0.0059, 0.0078] }, + Ray { angle: 178.6, r0: 39.844, r1: 61.719, fade: 0.05, rgb: [0.0, 0.0078, 0.0098] }, + Ray { angle: 179.37, r0: 28.906, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0255, 0.0343] }, + Ray { angle: 179.62, r0: 28.906, r1: 234.38, fade: 0.05, rgb: [0.0196, 0.0598, 0.0912] }, + Ray { angle: 187.52, r0: 51.562, r1: 167.97, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 188.63, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 189.73, r0: 100.0, r1: 169.53, fade: 0.05, rgb: [0.0206, 0.0314, 0.0275] }, + Ray { angle: 190.82, r0: 95.312, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0255, 0.0255] }, + Ray { angle: 191.92, r0: 84.375, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0235] }, + Ray { angle: 193.03, r0: 93.75, r1: 167.97, fade: 0.05, rgb: [0.0167, 0.0275, 0.0235] }, + Ray { angle: 194.17, r0: 80.469, r1: 168.75, fade: 0.05, rgb: [0.0118, 0.0216, 0.0176] }, + Ray { angle: 195.25, r0: 44.531, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 217.53, r0: 52.344, r1: 169.53, fade: 0.05, rgb: [0.0059, 0.0157, 0.0118] }, + Ray { angle: 218.63, r0: 96.875, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 219.61, r0: 67.188, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 220.84, r0: 91.406, r1: 167.97, fade: 0.05, rgb: [0.0118, 0.0196, 0.0157] }, + Ray { angle: 222.12, r0: 89.062, r1: 169.53, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 223.24, r0: 101.56, r1: 167.97, fade: 0.05, rgb: [0.0176, 0.0294, 0.0265] }, + Ray { angle: 224.33, r0: 30.469, r1: 117.97, fade: 0.05, rgb: [0.002, 0.0098, 0.0078] }, + Ray { angle: 225.0, r0: 35.156, r1: 150.78, fade: 0.05, rgb: [0.0098, 0.0216, 0.0157] }, + Ray { angle: 226.89, r0: 153.91, r1: 223.44, fade: 0.05, rgb: [0.1059, 0.2275, 0.348] }, + Ray { angle: 227.23, r0: 131.25, r1: 182.81, fade: 0.05, rgb: [0.1275, 0.298, 0.4137] }, + Ray { angle: 227.52, r0: 112.5, r1: 159.38, fade: 0.05, rgb: [0.1039, 0.2608, 0.351] }, + Ray { angle: 247.29, r0: 40.625, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 248.3, r0: 97.656, r1: 169.53, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 249.39, r0: 85.938, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 250.59, r0: 95.312, r1: 169.53, fade: 0.05, rgb: [0.0186, 0.0294, 0.0275] }, + Ray { angle: 251.68, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 252.8, r0: 90.625, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 253.9, r0: 96.875, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0314, 0.0275] }, + Ray { angle: 254.99, r0: 47.656, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 277.26, r0: 48.438, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 278.37, r0: 87.5, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 279.46, r0: 103.12, r1: 167.97, fade: 0.05, rgb: [0.0225, 0.0333, 0.0304] }, + Ray { angle: 280.54, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0167, 0.0294, 0.0255] }, + Ray { angle: 281.65, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0235] }, + Ray { angle: 282.76, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0294, 0.0255] }, + Ray { angle: 283.88, r0: 82.812, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 285.01, r0: 46.875, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 307.32, r0: 52.344, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0147, 0.0137] }, + Ray { angle: 308.45, r0: 94.531, r1: 167.97, fade: 0.05, rgb: [0.0137, 0.0216, 0.0196] }, + Ray { angle: 309.42, r0: 97.656, r1: 168.75, fade: 0.05, rgb: [0.0118, 0.0186, 0.0176] }, + Ray { angle: 310.54, r0: 73.438, r1: 167.97, fade: 0.05, rgb: [0.0108, 0.0196, 0.0167] }, + Ray { angle: 311.83, r0: 81.25, r1: 168.75, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 313.05, r0: 93.75, r1: 167.19, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 314.13, r0: 90.625, r1: 165.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 337.32, r0: 46.875, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 338.33, r0: 105.47, r1: 169.53, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 339.42, r0: 94.531, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 340.6, r0: 97.656, r1: 169.53, fade: 0.05, rgb: [0.0196, 0.0314, 0.0294] }, + Ray { angle: 341.72, r0: 81.25, r1: 168.75, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 342.81, r0: 91.406, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0245] }, + Ray { angle: 343.9, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 345.06, r0: 47.656, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, +]; +const MOVIE_PRIME_300_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 1.0, 1.0]), + (224.0, [1.0, 0.9997, 1.0]), + (244.0, [1.0, 0.9227, 0.9989]), + (260.8, [0.9997, 0.8587, 0.9298]), + (335.2, [0.7014, 0.6018, 0.6524]), + (396.8, [0.4939, 0.4238, 0.4598]), + (432.0, [0.3919, 0.3355, 0.3653]), + (474.4, [0.2836, 0.2443, 0.2649]), + (512.0, [0.2034, 0.1735, 0.1896]), + (538.4, [0.1534, 0.1326, 0.1432]), + (561.6, [0.1175, 0.0989, 0.1089]), + (564.0, [0.1165, 0.1028, 0.1131]), + (565.6, [0.1114, 0.0969, 0.1064]), + (573.6, [0.0987, 0.0857, 0.0928]), + (584.8, [0.0848, 0.0718, 0.0801]), + (590.4, [0.0781, 0.0679, 0.0722]), + (592.0, [0.0749, 0.0638, 0.0723]), + (594.4, [0.0716, 0.0639, 0.0681]), + (600.0, [0.0674, 0.0559, 0.0634]), + (601.6, [0.0633, 0.0558, 0.0604]), + (604.0, [0.0632, 0.052, 0.0581]), + (604.8, [0.0602, 0.0519, 0.0564]), + (607.2, [0.0595, 0.0517, 0.0561]), + (608.0, [0.0583, 0.0486, 0.0531]), + (608.8, [0.0556, 0.048, 0.0524]), + (611.2, [0.0556, 0.0483, 0.0527]), + (612.8, [0.0516, 0.0442, 0.0486]), + (615.2, [0.0506, 0.0443, 0.0487]), + (616.8, [0.0475, 0.0402, 0.0446]), + (619.2, [0.0472, 0.0399, 0.0445]), + (620.0, [0.044, 0.0403, 0.0441]), + (620.8, [0.0435, 0.0386, 0.0408]), + (621.6, [0.0435, 0.0362, 0.0405]), + (623.2, [0.0438, 0.0361, 0.0404]), + (624.0, [0.0409, 0.036, 0.0407]), + (625.6, [0.0396, 0.0359, 0.0366]), + (626.4, [0.0395, 0.0325, 0.0364]), + (628.0, [0.0392, 0.0321, 0.0364]), + (628.8, [0.036, 0.0321, 0.0366]), + (631.2, [0.0356, 0.0313, 0.0324]), + (632.0, [0.0356, 0.0283, 0.0324]), + (632.8, [0.0355, 0.0281, 0.0323]), + (633.6, [0.0322, 0.028, 0.0324]), + (634.4, [0.0317, 0.0281, 0.0322]), + (635.2, [0.0316, 0.028, 0.0288]), + (636.8, [0.0316, 0.0282, 0.0284]), + (637.6, [0.0319, 0.0249, 0.0284]), + (639.2, [0.0278, 0.0241, 0.0288]), + (640.8, [0.0277, 0.0241, 0.0244]), + (643.2, [0.0276, 0.0239, 0.0245]), + (644.0, [0.0241, 0.0204, 0.0244]), + (645.6, [0.0238, 0.0201, 0.0247]), + (647.2, [0.0239, 0.0201, 0.0204]), + (648.0, [0.0233, 0.0201, 0.0205]), + (648.8, [0.0199, 0.0201, 0.0204]), + (649.6, [0.0199, 0.0165, 0.0203]), + (652.0, [0.0198, 0.0161, 0.0201]), + (652.8, [0.0197, 0.0161, 0.0165]), + (653.6, [0.0201, 0.0161, 0.0164]), + (655.2, [0.0158, 0.016, 0.0163]), + (656.0, [0.0159, 0.0165, 0.0163]), + (656.8, [0.0159, 0.0134, 0.0164]), + (658.4, [0.0159, 0.0122, 0.0167]), + (660.0, [0.0158, 0.0122, 0.0124]), + (660.8, [0.0162, 0.0122, 0.0124]), + (661.6, [0.0127, 0.0121, 0.0124]), + (662.4, [0.012, 0.0121, 0.0124]), + (664.8, [0.012, 0.0117, 0.0124]), + (665.6, [0.012, 0.0081, 0.0124]), + (667.2, [0.012, 0.0082, 0.0128]), + (668.0, [0.012, 0.0082, 0.0093]), + (668.8, [0.0118, 0.0082, 0.0084]), + (669.6, [0.0081, 0.0082, 0.0084]), + (674.4, [0.008, 0.0085, 0.0083]), + (676.0, [0.008, 0.0041, 0.0087]), + (677.6, [0.0084, 0.0042, 0.0042]), + (679.2, [0.0038, 0.0042, 0.0044]), + (687.2, [0.004, 0.0044, 0.0045]), + (688.8, [0.0039, -0.0002, -0.0001]), + (689.6, [0.0043, 0.0003, 0.0004]), + (691.2, [-0.0004, 0.0002, 0.0003]), + (780.0, [-0.0013, -0.0024, -0.0035]), + (816.8, [-0.0009, -0.0017, -0.0026]), + (820.8, [-0.0008, -0.0014, -0.0021]), + (821.6, [0.0, 0.0, 0.0]), + ], + }, + Radial { t: 0.73278, perp: 0.0, knots: &[(0.0, [0.0, 0.0018, 0.0001]), (20.789, [0.0, 0.0018, 0.0008])] }, + Radial { + t: 2.4547, + perp: 0.0, + knots: &[ + (0.0, [0.0011, 0.0006, 0.0009]), + (9.5151, [0.0006, 0.0011, 0.0014]), + (11.894, [0.0028, 0.0054, 0.0077]), + (14.273, [0.0017, 0.0031, 0.0045]), + (21.409, [0.0011, 0.002, 0.0028]), + (26.959, [0.001, 0.0017, 0.0024]), + (29.338, [0.0019, 0.0036, 0.0053]), + (42.818, [0.0008, 0.0013, 0.002]), + (48.368, [0.0012, 0.0023, 0.0035]), + (59.469, [0.0006, 0.0011, 0.0016]), + (63.434, [0.0011, 0.0023, 0.0034]), + (72.156, [0.0005, 0.0009, 0.0014]), + (76.121, [0.0007, 0.0014, 0.0022]), + ], + }, +]; +const MOVIE_PRIME_300_STREAKS: &[Streak] = &[ + Streak { t: -0.00095944, perp: 0.0, angle: 0.012041, half: 5435.5, taper: 0.011317, width: 1.064, rgb: [0.2758, 0.554, 1.142] }, + Streak { t: 0.12868, perp: 0.0, angle: 45.14, half: 283.55, taper: 0.01, width: 0.63873, rgb: [0.031, 0.1591, 0.3557] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.074, half: 273.03, taper: 0.033361, width: 0.8378, rgb: [0.2456, 0.4704, 0.7752] }, + Streak { t: 1.9987, perp: 0.0, angle: 0.0065955, half: 182.6, taper: 1.0, width: 1.0982, rgb: [0.3026, 0.5787, 0.9971] }, +]; +const MOVIE_PRIME_300_RAYS: &[Ray] = &[]; +pub(super) const MOVIE_PRIME: &[Level] = &[ + Level { brightness: 10.0, radial: MOVIE_PRIME_10_RADIAL, streaks: MOVIE_PRIME_10_STREAKS, rays: MOVIE_PRIME_10_RAYS }, + Level { brightness: 50.0, radial: MOVIE_PRIME_50_RADIAL, streaks: MOVIE_PRIME_50_STREAKS, rays: MOVIE_PRIME_50_RAYS }, + Level { brightness: 75.0, radial: MOVIE_PRIME_75_RADIAL, streaks: MOVIE_PRIME_75_STREAKS, rays: MOVIE_PRIME_75_RAYS }, + Level { brightness: 100.0, radial: MOVIE_PRIME_100_RADIAL, streaks: MOVIE_PRIME_100_STREAKS, rays: MOVIE_PRIME_100_RAYS }, + Level { brightness: 125.0, radial: MOVIE_PRIME_125_RADIAL, streaks: MOVIE_PRIME_125_STREAKS, rays: MOVIE_PRIME_125_RAYS }, + Level { brightness: 150.0, radial: MOVIE_PRIME_150_RADIAL, streaks: MOVIE_PRIME_150_STREAKS, rays: MOVIE_PRIME_150_RAYS }, + Level { brightness: 200.0, radial: MOVIE_PRIME_200_RADIAL, streaks: MOVIE_PRIME_200_STREAKS, rays: MOVIE_PRIME_200_RAYS }, + Level { brightness: 300.0, radial: MOVIE_PRIME_300_RADIAL, streaks: MOVIE_PRIME_300_STREAKS, rays: MOVIE_PRIME_300_RAYS }, +]; diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 60a3b488e4..ad72954a22 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -43,6 +43,8 @@ pub mod histogram; mod image; pub mod inpaint; pub mod lens; +mod lens_flare; +mod lens_flare_data; pub mod liquify; pub mod magnetic; pub mod matting; @@ -76,6 +78,7 @@ mod selection_blur; pub mod separable; mod shape_blur; mod sharpen; +mod square_dilate; pub mod stack; mod stylize; mod stylize2; @@ -504,6 +507,12 @@ pub enum FilterParams { radius: f32, shape: BlurShape, }, + /// A custom shape's coverage kernel, resolved by the caller before rendering. + CustomShapeBlur { + radius: f32, + #[serde(skip)] + kernel: Option>, + }, // ---- Blur Gallery ---- /// Centre fractions, angle degrees, focus/transition as fractions of the bounds' shorter side. @@ -683,7 +692,7 @@ impl FilterParams { FilterParams::ReduceNoise { .. } => "Reduce Noise", FilterParams::SmartBlur { .. } => "Smart Blur", FilterParams::LensBlur { .. } => "Lens Blur", - FilterParams::ShapeBlur { .. } => "Shape Blur", + FilterParams::ShapeBlur { .. } | FilterParams::CustomShapeBlur { .. } => "Shape Blur", FilterParams::TiltShift { .. } => "Tilt-Shift", FilterParams::IrisBlur { .. } => "Iris Blur", FilterParams::FieldBlur { .. } => "Field Blur", @@ -728,7 +737,7 @@ fn halo_ext(p: &FilterParams) -> Halo { // 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::ShapeBlur { radius, .. } | FilterParams::CustomShapeBlur { 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))), FilterParams::FieldBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))), @@ -846,7 +855,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec { render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)) } - FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens), + FilterParams::LensFlare { brightness, center_x, center_y, lens } => lens_flare::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens), FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting( src, out, @@ -918,6 +927,10 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec blur2::shape_blur(src, out, ctx, *radius, *shape), + FilterParams::CustomShapeBlur { radius, kernel } => kernel + .as_ref() + .and_then(|mask| shape_blur::Prepared::custom(*radius, mask, &photocraft_raster::Interrupt::NONE)) + .map_or_else(Vec::new, |prepared| prepared.run(src, out, ctx)), FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => { gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition) } @@ -1034,10 +1047,14 @@ pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: #[cfg(target_arch = "wasm32")] let threads = 1u64; let tile = auto_tile(params, bounds).max(1) as u64; + // Each worker holds one tile's windows at a time, so no more workers than tiles hold them + // at once (a 24 MP image at a 1024 px tile has 24 tiles, fewer than 32 threads). + let tiles = u64::from(area.width()).div_ceil(tile).saturating_mul(u64::from(area.height()).div_ceil(tile)).max(1); + let threads = threads.min(tiles); let floats = (channels as u64).saturating_mul(4); // Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area. let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3); - match params.halo_for(bounds) { + let windows = match params.halo_for(bounds) { Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)), Halo::Radius(r) => { let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2)); @@ -1045,7 +1062,22 @@ pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads), ) } - } + }; + windows.saturating_add(shape_blur_bytes(params, bounds, tile, channels as u64, threads)) +} + +/// What Shape Blur holds beyond the float windows: each worker's f64 prefix sums over its +/// halo-expanded tile, and for a custom shape the kernel mask kept for the whole run plus its +/// prepared coverage runs (at most [`shape_blur::max_spans`], 20 bytes each). +fn shape_blur_bytes(params: &FilterParams, bounds: Rect, tile: u64, channels: u64, threads: u64) -> u64 { + let (FilterParams::ShapeBlur { .. } | FilterParams::CustomShapeBlur { .. }) = params else { return 0 }; + let Halo::Radius(r) = params.halo_for(bounds) else { return 0 }; + let side = tile.min(2048).saturating_add((r.max(0) as u64).saturating_mul(2)).saturating_add(1); + let prefix = side.saturating_mul(side).saturating_mul(channels).saturating_mul(8).saturating_mul(threads); + let FilterParams::CustomShapeBlur { kernel: Some(mask), .. } = params else { return prefix }; + let size = (mask.rect.width() as usize).max(mask.rect.height() as usize); + let spans = (shape_blur::max_spans(size) as u64).saturating_mul(20); + prefix.saturating_add((mask.data.len() as u64).saturating_mul(4)).saturating_add(spans) } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). @@ -1121,7 +1153,11 @@ fn apply_tiled_with_impl( } // The public tile override must not create a zero-step loop or an // unbounded scratch buffer for these checked filters. Automatic tiles are unchanged. - let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile }; + let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::CustomShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { + tile.clamp(1, 2048) + } else { + tile + }; let fmt = surface.format(); let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha }; // Prepare the immutable sample transforms once for all output tiles. If the @@ -1161,6 +1197,13 @@ fn apply_tiled_with_impl( } // Shape rasterization depends only on params, not on a tile's pixels. let shape = match params { + FilterParams::CustomShapeBlur { radius, kernel } => { + if ctl.cancelled() { + return None; + } + let prepared = shape_blur::Prepared::custom(*radius, kernel.as_ref()?, ctl)?; + Some(prepared) + } FilterParams::ShapeBlur { radius, shape } => { if ctl.cancelled() { return None; @@ -1237,7 +1280,7 @@ fn apply_tiled_with_impl( }; let Some(mut data) = (match (&shape, &radial) { _ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)), - (Some(shape), _) => shape.checked_run(src, *t, &ctx), + (Some(shape), _) => shape.checked_run(src, *t, &ctx, ctl), (None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) { Some(data) => Some(data), None if ctl.cancelled() => None, diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index 6cc03e2094..952e9ce346 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -391,14 +391,7 @@ fn shift_coverage(v: f32, k: f32) -> f32 { if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v } } -/// 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. +/// Bounds reachable by refinement and brush additions, clipped to 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() { @@ -410,6 +403,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option< /// 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. +#[cfg(test)] pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option { refine_mask_with(sampler, mask, content, canvas, p, None) } @@ -417,6 +411,7 @@ pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Syn /// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image /// (the area can reach beyond the edge-detection band); where it restores, the original edge /// stays. The sampler is read wherever the brush adds, whatever the radius. +#[cfg(test)] pub fn refine_mask_brushed( sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Sync), @@ -428,6 +423,8 @@ pub fn refine_mask_brushed( refine_mask_with(sampler, mask, content, canvas, p, brush) } +/// Internal helper for dense refinement test oracles. +#[cfg(test)] fn refine_mask_with( sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Sync), @@ -497,7 +494,245 @@ fn refine_mask_with( crate::segment::trim_region(Region { bbox, mask: out }) } +/// Refines sparse coverage without materializing its bounding rectangle. +/// Missing input tiles use the mask's default; output is zero outside the canvas. +pub fn refine_surface( + sampler: &dyn Sampler, + mask: &Surface, + canvas: Rect, + p: &RefineParams, + brush: Option<&EdgeBrush>, +) -> Result, photocraft_raster::AllocationError> { + refine_surface_with_interrupt(sampler, mask, canvas, p, brush, &photocraft_raster::Interrupt::NONE) +} + +/// Interruptible refinement. Cancellation discards the partial result and returns `Ok(None)`. +/// Callers distinguish cancellation from an empty mask through `interrupt.cancelled()`. +pub fn refine_surface_with_interrupt( + sampler: &dyn Sampler, + mask: &Surface, + canvas: Rect, + p: &RefineParams, + brush: Option<&EdgeBrush>, + interrupt: &photocraft_raster::Interrupt<'_>, +) -> Result, photocraft_raster::AllocationError> { + if interrupt.cancelled() { + return Ok(None); + } + use std::collections::{BTreeSet, HashMap}; + use std::sync::Arc; + let default = mask.default_pixel().first().copied().unwrap_or(0.0); + let content = if default > 0.0 { canvas } else { mask.content_bounds().intersect(&canvas) }; + let bbox = refine_area(content, canvas, p, brush); + if bbox.is_empty() { + return Ok(None); + } + let halo = p.halo().saturating_add(brush.map_or(0, |b| (b.window() as i32).saturating_mul(4))); + // Preserve the dense implementation's block origin, including non-aligned selections. + let mut blocks = BTreeSet::new(); + let mut include = |area: Rect| -> Result<(), photocraft_raster::AllocationError> { + let area = area.intersect(&bbox); + if area.is_empty() { + return Ok(()); + } + let x0 = (i64::from(area.x0) - i64::from(bbox.x0)) / i64::from(TILE); + let y0 = (i64::from(area.y0) - i64::from(bbox.y0)) / i64::from(TILE); + let x1 = (i64::from(area.x1) - 1 - i64::from(bbox.x0)) / i64::from(TILE); + let y1 = (i64::from(area.y1) - 1 - i64::from(bbox.y0)) / i64::from(TILE); + for y in y0..=y1 { + for x in x0..=x1 { + if interrupt.cancelled() { + return Ok(()); + } + blocks.insert((y, x)); + if blocks.len() > 1_048_576 { + return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: blocks.len().saturating_mul(64) }); + } + } + } + Ok(()) + }; + if default > 0.0 { + include(bbox)?; + } else { + for (tc, _) in mask.tiles() { + if interrupt.cancelled() { + return Ok(None); + } + include(tc.rect().inflate(p.spread()))?; + } + } + if let Some(b) = brush { + for tc in b.tile_coords() { + if interrupt.cancelled() { + return Ok(None); + } + include(tc.rect().inflate(p.spread()))?; + } + } + if interrupt.cancelled() { + return Ok(None); + } + let mut uniform: HashMap> = HashMap::new(); + let mut output = Surface::new(PixelFormat::GRAY8); + let solid = output.solid_tile(&[1.0]); + // Only mixed blocks need expensive filtering. Keep their concurrency and aggregate + // scratch estimate bounded; uniform interiors still take the cheap serial path. + struct Work { + core: Rect, + input: Rect, + painted: Option>, + } + let run = |work: &Work| { + let (t, r) = (work.core, work.input); + if interrupt.cancelled() { + return (t, Vec::new()); + } + let m = crate::selection::mask_from_surface(Some(mask), r); + let (rw, rh) = (r.width() as usize, r.height() as usize); + let adds = work.painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0)); + // An allocated tile can be mixed while this block's clipped halo is constant. + // Preserve the dense path's local check before sampling the guide image. + let first = m.first().copied().unwrap_or(0.0); + if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) { + return (t, vec![(first * 255.0).round() as u8; t.width() as usize * t.height() as usize]); + } + let img = (p.uses_image() || adds).then(|| sampler.rgb(r)); + let bb = match (&work.painted, brush) { + (Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }), + _ => None, + }; + let refined = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb); + let mut data = Vec::with_capacity(t.width() as usize * t.height() as usize); + for yy in t.y0..t.y1 { + let start = (yy - r.y0) as usize * rw + (t.x0 - r.x0) as usize; + let end = start + t.width() as usize; + if let Some(row) = refined.get(start..end) { + data.extend(row.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + } + (t, data) + }; + let flush = |pending: &mut Vec, output: &mut Surface| -> Result<(), photocraft_raster::AllocationError> { + if interrupt.cancelled() { + pending.clear(); + return Ok(()); + } + #[cfg(not(target_arch = "wasm32"))] + let results: Vec<_> = { + use rayon::prelude::*; + pending.par_iter().map(&run).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec<_> = pending.iter().map(&run).collect(); + if interrupt.cancelled() { + pending.clear(); + return Ok(()); + } + for (t, data) in results { + if data.iter().any(|v| *v != 0) { + output.try_write_interleaved(t, &data)?; + for tc in t.tiles() { + if output.tile(tc).is_some_and(|tile| tile.bytes().iter().all(|v| *v == 255)) { + output.put_tiles([(tc, solid.clone())]); + } + } + } + } + pending.clear(); + Ok(()) + }; + let mut pending = Vec::new(); + let mut pending_bytes = 0usize; + const MAX_SCRATCH_BYTES: usize = 512 * 1024 * 1024; + // A larger queue lets Rayon balance cheap and expensive diagonal blocks. + // The scratch budget still limits how many blocks can enter a batch. + const MAX_FILTER_BATCH: usize = 24; + for (by, bx) in blocks { + if interrupt.cancelled() { + return Ok(None); + } + let x = (i64::from(bbox.x0) + bx * i64::from(TILE)) as i32; + let y = (i64::from(bbox.y0) + by * i64::from(TILE)) as i32; + let t = Rect::new(x, y, x.saturating_add(TILE).min(bbox.x1), y.saturating_add(TILE).min(bbox.y1)); + let r = t.inflate(halo).intersect(&canvas); + let scratch_pixels = (r.width() as usize).checked_mul(r.height() as usize).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; + let scratch_bytes = scratch_pixels.checked_mul(256).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; + let painted = brush + .filter(|b| b.tile_coords().any(|tc| !tc.rect().intersect(&r).is_empty())) + .map(|b| { + if scratch_bytes > MAX_SCRATCH_BYTES { + return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: scratch_bytes }); + } + Ok(b.read(r)) + }) + .transpose()?; + let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0)); + let mut constant = None; + let mut all_uniform = true; + for tc in r.tiles() { + let value = if let Some(tile) = mask.tile(tc) { + let key = Arc::as_ptr(tile) as usize; + *uniform.entry(key).or_insert_with(|| { + let first = mask.sample_channel(tc.rect().x0, tc.rect().y0, 0); + let fmt = mask.format(); + let bytes = fmt.bytes_per_pixel(); + let binding = tile.bytes(); + let first_pixel = binding.get(..bytes)?; + binding.chunks_exact(bytes).all(|p| p == first_pixel).then_some(first) + }) + } else { + Some(default) + }; + let Some(value) = value else { + all_uniform = false; + break; + }; + if constant.is_some_and(|v| v != value) { + all_uniform = false; + break; + } + constant = Some(value); + } + if !adds && all_uniform && matches!(constant, Some(0.0)) { + continue; + } + if !adds && all_uniform && matches!(constant, Some(1.0)) { + let tc = photocraft_geom::TileCoord::containing(t.x0, t.y0); + if tc.rect() == t { + output.put_tiles([(tc, solid.clone())]); + continue; + } + output.fill_rect(t, &[1.0]); + } else { + if scratch_bytes > MAX_SCRATCH_BYTES { + return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: scratch_bytes }); + } + if !pending.is_empty() && (pending.len() >= MAX_FILTER_BATCH || pending_bytes.saturating_add(scratch_bytes) > MAX_SCRATCH_BYTES) { + flush(&mut pending, &mut output)?; + pending_bytes = 0; + } + pending_bytes = pending_bytes.saturating_add(scratch_bytes); + pending.push(Work { core: t, input: r, painted }); + continue; + } + // Canonical tiles can straddle two legacy blocks. Share them as soon as complete, + // keeping solid interiors compact even when the processing origin is unaligned. + for tc in t.tiles() { + if output.tile(tc).is_some_and(|tile| tile.bytes().iter().all(|v| *v == 255)) { + output.put_tiles([(tc, solid.clone())]); + } + } + } + flush(&mut pending, &mut output)?; + if interrupt.cancelled() { + return Ok(None); + } + Ok((output.tile_count() > 0).then_some(output)) +} + /// A mask reader over a [`Region`] (zero outside its box). +#[cfg(test)] pub fn region_reader(reg: &Region) -> impl Fn(Rect) -> Vec + Sync + '_ { move |r: Rect| { let mut v = Vec::with_capacity(r.width() as usize * r.height() as usize); @@ -511,6 +746,7 @@ pub fn region_reader(reg: &Region) -> impl Fn(Rect) -> Vec + Sync + '_ { } /// A mask reader over a selection surface (channel 0). +#[cfg(test)] pub fn surface_reader(s: &Surface) -> impl Fn(Rect) -> Vec + Sync + '_ { move |r: Rect| crate::selection::mask_from_surface(Some(s), r) } @@ -522,6 +758,8 @@ pub fn region_surface(reg: &Region) -> Surface { s } +/// Partitions an area into fixed-size tile rectangles. +#[cfg(test)] fn tiles_of(area: Rect) -> Vec { let mut tiles = Vec::new(); let mut y = area.y0; @@ -536,6 +774,8 @@ fn tiles_of(area: Rect) -> Vec { tiles } +/// Maps a function over tile rectangles in parallel on native targets. +#[cfg(test)] fn par_tiles(tiles: &[Rect], f: impl Fn(&Rect) -> Option + Sync + Send) -> Vec { #[cfg(not(target_arch = "wasm32"))] { @@ -552,6 +792,7 @@ fn par_tiles(tiles: &[Rect], f: impl Fn(&Rect) -> Option + Sync + Se /// average colour of nearby fully selected pixels (normalised convolution with a box of radius /// `max(radius, 3)`, widened ×4 where no such pixel is near), by `amount`% scaled by its /// transparency. Works in the surface's own colour channels (any model / depth). +#[cfg(test)] pub fn decontaminate(src: &Surface, alpha: &Region, radius: f32, amount: f32) -> Surface { let fmt = src.format(); let n = fmt.channels(); @@ -610,8 +851,76 @@ pub fn decontaminate(src: &Surface, alpha: &Region, radius: f32, amount: f32) -> out } +/// Decontaminates sparse GRAY8 alpha, processing one fringe tile at a time. +pub fn decontaminate_surface(src: &Surface, alpha: &Surface, radius: f32, amount: f32) -> Surface { + let fmt = src.format(); + let n = fmt.channels(); + let nc = n - fmt.alpha as usize; + let rad = radius.max(3.0).ceil() as usize; + let halo = (rad * 4) as i32; + let amount = amount.clamp(0.0, 100.0) / 100.0; + let soft = |a: f32| a > 0.02 && a < 0.98; + let run = |t: &Rect| { + if !(t.y0..t.y1).any(|y| (t.x0..t.x1).any(|x| soft(alpha.sample_channel(x, y, 0)))) { + return None; + } + let r = t.inflate(halo); + let (rw, rh) = (r.width() as usize, r.height() as usize); + let mut px = Vec::new(); + src.read_region_into(r, &mut px); + let al: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| alpha.sample_channel(x, y, 0))).collect(); + let wts: Vec = (0..rw * rh).map(|i| if al[i] >= 0.98 { if fmt.alpha { px[i * n + n - 1] } else { 1.0 } } else { 0.0 }).collect(); + let den = box_mean(&wts, rw, rh, rad); + let den2 = box_mean(&wts, rw, rh, rad * 4); + let mut nums = Vec::with_capacity(nc); + for c in 0..nc { + let v: Vec = (0..rw * rh).map(|i| wts[i] * px[i * n + c]).collect(); + nums.push((box_mean(&v, rw, rh, rad), box_mean(&v, rw, rh, rad * 4))); + } + let (cx, cy, cw, ch) = ((t.x0 - r.x0) as usize, (t.y0 - r.y0) as usize, t.width() as usize, t.height() as usize); + let mut out = Vec::with_capacity(cw * ch * n); + for yy in 0..ch { + for xx in 0..cw { + let i = (cy + yy) * rw + cx + xx; + let a = al[i]; + let k = if soft(a) { amount * ((1.0 - a) * 4.0).clamp(0.0, 1.0) } else { 0.0 }; + for c in 0..n { + let v = px[i * n + c]; + if c >= nc || k == 0.0 { + out.push(v); + continue; + } + let f = if den[i] > 1e-4 { + nums[c].0[i] / den[i] + } else if den2[i] > 1e-4 { + nums[c].1[i] / den2[i] + } else { + v + }; + out.push(v + (f - v) * k); + } + } + } + Some((*t, out)) + }; + let mut out = src.clone(); + let mut hard = std::collections::HashMap::new(); + for (tc, tile) in alpha.tiles() { + let key = std::sync::Arc::as_ptr(tile) as usize; + let uniform_hard = *hard.entry(key).or_insert_with(|| tile.bytes().iter().all(|v| *v == 0 || *v == 255)); + if uniform_hard { + continue; + } + if let Some((t, data)) = run(&tc.rect()) { + out.write_region(t, &data); + } + } + out +} + /// The pixels of `src` with their alpha multiplied by `alpha` (zero outside its box); the result /// has an alpha channel even if `src` does not. +#[cfg(test)] pub fn masked_copy(src: &Surface, alpha: &Region) -> Surface { let fmt = src.format(); let n = fmt.channels(); @@ -644,12 +953,182 @@ pub fn masked_copy(src: &Surface, alpha: &Region) -> Surface { out } +/// Copies selected pixels without collecting buffers across the alpha bounds. +/// `alpha` is a zero-default GRAY8 surface, as returned by [`refine_surface`]. +/// Explicit `bounds` preserve source colour under zero alpha inside the finite mask extent. +pub fn masked_copy_surface(src: &Surface, alpha: &Surface, bounds: Rect) -> Surface { + let fmt = src.format(); + let n = fmt.channels(); + let output_fmt = PixelFormat { alpha: true, ..fmt }; + let mut out = Surface::new(output_fmt); + let mut default = src.default_pixel(); + if !fmt.alpha { + default.push(1.0); + } + let default_tile = out.solid_tile(&default); + let mut full = std::collections::HashMap::new(); + for (tc, tile) in alpha.tiles() { + let key = std::sync::Arc::as_ptr(tile) as usize; + let opaque = bounds.contains_rect(&tc.rect()) && *full.entry(key).or_insert_with(|| tile.bytes().iter().all(|v| *v == 255)); + if opaque && fmt.alpha { + if let Some(t) = src.tile(*tc) { + out.put_tiles([(*tc, t.clone())]); + } else if default.last().is_some_and(|a| *a > 0.0) { + out.put_tiles([(*tc, default_tile.clone())]); + } + continue; + } + if opaque && src.tile(*tc).is_none() { + out.put_tiles([(*tc, default_tile.clone())]); + continue; + } + let r = tc.rect().intersect(&bounds); + let px = src.read_region(r); + let al = alpha.read_region(r); + let mut data = Vec::with_capacity(al.len().saturating_mul(output_fmt.channels())); + let mut any = false; + for (p, a) in px.chunks_exact(n).zip(al) { + let colors = n - usize::from(fmt.alpha); + data.extend_from_slice(p.get(..colors).unwrap_or(&[])); + let sa = if fmt.alpha { p.last().copied().unwrap_or(0.0) } else { 1.0 }; + data.push(sa * a); + any |= sa * a > 0.0; + } + if any { + out.write_region(r, &data); + } + } + out +} + #[cfg(test)] mod tests { use super::*; use crate::segment::ImageSampler; use photocraft_color::SampleType; + #[test] + fn sparse_refine_matches_dense_at_unaligned_origins() { + let canvas = Rect::new(-15, -9, 540, 280); + let source = Surface::new(PixelFormat::RGBA8); + let sampler = crate::segment::SurfaceSampler(&source); + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-3, 4, 310, 210), &[1.0]); + mask.fill_rect(Rect::new(64, 50, 130, 90), &[0.25]); + for params in [RefineParams::default(), RefineParams { radius: 4.0, smart_radius: true, smooth: 10.0, feather: 2.0, contrast: 20.0, shift_edge: -15.0 }] + { + let dense = refine_mask(&sampler, &surface_reader(&mask), mask.content_bounds(), canvas, ¶ms).expect("dense"); + let sparse = refine_surface(&sampler, &mask, canvas, ¶ms, None).expect("allocation").expect("sparse"); + assert_eq!(sparse.to_interleaved(canvas), region_surface(&dense).to_interleaved(canvas)); + } + } + + #[test] + fn refinement_discards_partial_output_after_cancellation() { + use std::sync::atomic::{AtomicBool, Ordering}; + struct CancelGuide<'a>(&'a AtomicBool); + impl Sampler for CancelGuide<'_> { + fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { + self.0.store(true, Ordering::Relaxed); + vec![[0.2, 0.4, 0.7, 1.0]; r.width() as usize * r.height() as usize] + } + } + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::Relaxed); + let interrupt = photocraft_raster::Interrupt::cancel_only(&cancel); + let canvas = Rect::new(0, 0, 1024, 1024); + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(100, 100, 800, 800), &[1.0]); + let before = mask.clone(); + let params = RefineParams { radius: 4.0, ..Default::default() }; + let result = refine_surface_with_interrupt(&CancelGuide(&cancelled), &mask, canvas, ¶ms, None, &interrupt).unwrap(); + assert!(cancelled.load(Ordering::Relaxed), "guide must trigger cancellation"); + assert!(result.is_none(), "partial refinement must be discarded"); + assert_eq!(mask, before); + + // Multi-batch cancellation: an earlier batch flushes and writes output, and a later + // guide sample triggers cancellation. The partial output must be discarded. + let multi_cancelled = AtomicBool::new(false); + let multi_cancel = || multi_cancelled.load(Ordering::Relaxed); + let multi_interrupt = photocraft_raster::Interrupt::cancel_only(&multi_cancel); + struct MultiBatchGuide<'a> { + cancelled: &'a AtomicBool, + samples: std::sync::atomic::AtomicUsize, + cancel_after: usize, + } + impl Sampler for MultiBatchGuide<'_> { + fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { + let prev = self.samples.fetch_add(1, Ordering::SeqCst); + if prev >= self.cancel_after { + self.cancelled.store(true, Ordering::Relaxed); + } + vec![[0.2, 0.4, 0.7, 1.0]; r.width() as usize * r.height() as usize] + } + } + // 32 mixed blocks along x exceed MAX_FILTER_BATCH (24), forcing at least one completed flush. + let multi_canvas = Rect::new(0, 0, 32 * 256, 256); + let mut multi_mask = Surface::new(PixelFormat::GRAY8); + multi_mask.fill_rect(Rect::new(0, 0, 32 * 256, 128), &[1.0]); + let multi_before = multi_mask.clone(); + let multi_guide = MultiBatchGuide { cancelled: &multi_cancelled, samples: std::sync::atomic::AtomicUsize::new(0), cancel_after: 24 }; + let multi_result = refine_surface_with_interrupt(&multi_guide, &multi_mask, multi_canvas, ¶ms, None, &multi_interrupt).unwrap(); + assert!(multi_cancelled.load(Ordering::Relaxed), "later guide call must trigger cancellation"); + assert!(multi_guide.samples.load(Ordering::SeqCst) > 24, "cancellation must occur after the initial batch"); + assert!(multi_result.is_none(), "partial output from completed flush must be discarded upon cancellation"); + assert_eq!(multi_mask, multi_before); + } + + #[test] + fn constant_clipped_halo_does_not_sample_a_mixed_tile_guide() { + struct NoGuide; + impl Sampler for NoGuide { + fn rgba(&self, _: Rect) -> Vec<[f32; 4]> { + panic!("constant halo must not read the guide"); + } + } + let canvas = Rect::new(10, 10, 30, 30); + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(canvas, &[1.0]); + let params = RefineParams { radius: 4.0, feather: 1.0, ..Default::default() }; + let output = refine_surface(&NoGuide, &mask, canvas, ¶ms, None).unwrap().unwrap(); + assert!(output.to_interleaved(canvas).iter().all(|v| *v == 255)); + } + + #[test] + fn sparse_brushed_refine_matches_dense_for_add_and_restore() { + let canvas = Rect::new(-10, -5, 530, 270); + let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.7, 1.0]); + let sampler = crate::segment::SurfaceSampler(&source); + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(12, 17, 300, 230), &[1.0]); + mask.fill_rect(Rect::new(80, 70, 140, 110), &[0.0]); + let strokes = [ + EdgeStroke { points: vec![(295.0, 40.0), (320.0, 200.0)], size: 20.0, add: true }, + EdgeStroke { points: vec![(310.0, 100.0), (300.0, 160.0)], size: 12.0, add: false }, + ]; + let brush = EdgeBrush::new(&strokes, canvas).unwrap(); + let params = RefineParams { radius: 4.0, smart_radius: true, feather: 2.0, smooth: 5.0, ..Default::default() }; + let dense = refine_mask_brushed(&sampler, &surface_reader(&mask), mask.content_bounds(), canvas, ¶ms, Some(&brush)).unwrap(); + let sparse = refine_surface(&sampler, &mask, canvas, ¶ms, Some(&brush)).unwrap().unwrap(); + assert_eq!(sparse.to_interleaved(canvas), region_surface(&dense).to_interleaved(canvas)); + } + + #[test] + fn sparse_refine_handles_reveal_all_without_input_tiles() { + let canvas = Rect::new(0, 0, 100_000, 100_000); + let source = Surface::new(PixelFormat::RGBA8); + let sampler = crate::segment::SurfaceSampler(&source); + let mask = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + let output = refine_surface(&sampler, &mask, canvas, &RefineParams::default(), None).expect("allocation").expect("selected"); + assert_eq!(output.sample_channel(0, 0, 0), 1.0); + assert_eq!(output.sample_channel(99_999, 99_999, 0), 1.0); + assert_eq!(output.sample_channel(100_000, 100_000, 0), 0.0); + assert!(std::sync::Arc::ptr_eq( + output.tile(photocraft_geom::TileCoord::new(10, 10)).unwrap(), + output.tile(photocraft_geom::TileCoord::new(20, 20)).unwrap() + )); + } + /// Horizontal blend from `a` (left) to `b` (right) across a Gaussian-blurred edge at x = 40. fn blurred_edge(w: usize, h: usize, sigma: f32) -> (RgbImage, Vec) { let (a, b) = ([0.9f32, 0.8, 0.2], [0.1f32, 0.2, 0.6]); @@ -813,6 +1292,13 @@ mod tests { .collect(); let a = Region { bbox: Rect::new(0, 0, 20, 8), mask }; let d = decontaminate(&s, &a, 3.0, 100.0); + let sparse_alpha = region_surface(&a); + let sparse_d = decontaminate_surface(&s, &sparse_alpha, 3.0, 100.0); + for (expected, actual) in d.read_region(a.bbox).iter().zip(sparse_d.read_region(a.bbox)) { + assert!((expected - actual).abs() < 1e-5, "{sample:?}: decontamination changed"); + } + let sparse_c = masked_copy_surface(&d, &sparse_alpha, a.bbox); + assert_eq!(sparse_c.to_interleaved(a.bbox), masked_copy(&d, &a).to_interleaved(a.bbox)); let p = d.pixel(10, 4); assert!(p[0] > 0.9 && p[2] < 0.1, "{sample:?}: {p:?}"); assert_eq!(d.pixel(15, 4), s.pixel(15, 4)); diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs index 24e4c82ad1..d9fca63935 100644 --- a/crates/algo/src/matting/brush.rs +++ b/crates/algo/src/matting/brush.rs @@ -216,6 +216,12 @@ fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator impl Iterator + '_ { + self.tiles.keys().copied() + } + /// 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 { diff --git a/crates/algo/src/nonlocal.rs b/crates/algo/src/nonlocal.rs index 148a1bd32d..3bc34ceec0 100644 --- a/crates/algo/src/nonlocal.rs +++ b/crates/algo/src/nonlocal.rs @@ -614,7 +614,7 @@ impl Solver<'_> { // `FUSE_BAND` of a seam are solved for. const FUSE_BAND: usize = 16; let seams: Vec = (0..w * h).map(|i| div[i * ch..(i + 1) * ch].iter().any(|v| *v != 0.0)).collect(); - let band = crate::remove::dilate(w, h, &seams, FUSE_BAND); + let band = crate::square_dilate::dilate_square(w, h, &seams, FUSE_BAND); let halves: [Vec; 2] = [0, 1].map(|colour| copied.iter().map(|(p, _, _)| *p).filter(|&p| band[p] && (p % w + p / w) % 2 == colour).collect()); let mut delta = vec![0.0f32; w * h * ch]; let mut next: Vec = Vec::new(); @@ -846,15 +846,7 @@ pub fn complete_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], return Ok(None); } let ri = r as i32; - let mut targets = vec![false; lw * lh]; - for (i, _) in level.hole.iter().enumerate().filter(|(_, m)| **m) { - let (x, y) = ((i % lw) as i32, (i / lw) as i32); - for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) { - for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) { - targets[ty as usize * lw + tx as usize] = true; - } - } - } + let targets = crate::square_dilate::dilate_square(lw, lh, &level.hole, r); let mut s = Solver { w: lw, h: lh, cha, ch, r: ri, img: level.img.clone(), hole: &level.hole, valid, valid_list, targets, ctl }; let mut nnf = vec![(0i32, 0i32); lw * lh]; let mut cost = vec![f32::INFINITY; lw * lh]; @@ -1012,4 +1004,20 @@ mod tests { let progress = |_: f32| {}; assert_eq!(complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)), Err(Cancelled)); } + + /// Every cancellation check point stops the completion: moving or dropping one is a change. + #[test] + fn cancelling_at_each_early_check_is_an_error() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (w, h) = (64, 64); + let img: Vec = (0..w * h).map(noise).collect(); + let hole: Vec = (0..w * h).map(|i| (20..40).contains(&(i % w)) && (20..40).contains(&(i / w))).collect(); + for k in 1..=6 { + let calls = AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::Relaxed) + 1 >= k; + let progress = |_: f32| {}; + let got = complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)); + assert_eq!(got, Err(Cancelled), "cancelling at check {k}"); + } + } } diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index dc44f06c1f..86e3faea08 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -40,9 +40,9 @@ use crate::transform::Homography; mod blend; -pub use blend::{Geometry, Photometric, RoiImage, blend_levels, multiband, photometric, seam_labels}; +pub use blend::{BAND_BUDGET, Geometry, Photometric, RoiImage, band_bytes_bound, blend_levels, multiband, photometric, seam_labels, try_multiband_batched}; #[cfg(test)] -use blend::{expand, multiband_batched, push_pull, reduce}; +use blend::{band_cost, expand, push_pull, reduce}; /// Photomerge layouts. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -929,7 +929,7 @@ mod tests { .collect() }) .collect(); - let (out, cov) = multiband(w, h, &imgs, &wts, 4); + let (out, cov) = multiband(w, h, &imgs, &wts, 4).unwrap(); let row: Vec = (0..w).map(|x| out[20 * w + x]).collect(); assert!((row[2] - 0.3).abs() < 0.02 && (row[w - 3] - 0.7).abs() < 0.02, "{row:?}"); let max_jump = row.windows(2).map(|p| (p[1] - p[0]).abs()).fold(0.0f32, f32::max); diff --git a/crates/algo/src/panorama/blend.rs b/crates/algo/src/panorama/blend.rs index 034e5ed96d..9b4be0262c 100644 --- a/crates/algo/src/panorama/blend.rs +++ b/crates/algo/src/panorama/blend.rs @@ -1,4 +1,17 @@ use super::{Normal, Placement, par_map, par_rows}; +use photocraft_raster::AllocationError; + +fn alloc_plane(len: usize) -> Result, AllocationError> { + let bytes = len.checked_mul(std::mem::size_of::()).ok_or(AllocationError::SizeOverflow)?; + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; + v.resize(len, 0.0); + Ok(v) +} + +fn plane_len(w: usize, h: usize, ch: usize) -> Result { + w.checked_mul(h).and_then(|n| n.checked_mul(ch)).ok_or(AllocationError::SizeOverflow) +} // ---------- photometric ---------- @@ -311,29 +324,22 @@ pub fn blend_levels(w: usize, h: usize) -> usize { n } -/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native -/// targets. Purely elementwise, so the result is the serial result bit for bit. -fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec { +/// `a[i] = f(a[i], b[i])` for every `i` (the shorter length wins), in place and in parallel on +/// native targets. Purely elementwise, so the result is the serial result bit for bit. +fn par_zip_assign(a: &mut [f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) { const CHUNK: usize = 1 << 14; - let mut out = vec![0.0f32; a.len().min(b.len())]; - let fill = |o: &mut [f32], a: &[f32], b: &[f32]| { - for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { - *o = f(a, b); + let fill = |a: &mut [f32], b: &[f32]| { + for (a, &b) in a.iter_mut().zip(b) { + *a = f(*a, b); } }; #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; - out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b)); + a.par_chunks_mut(CHUNK).zip(b.par_chunks(CHUNK)).for_each(|(a, b)| fill(a, b)); } #[cfg(target_arch = "wasm32")] - out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b)); - out -} - -/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial). -fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec { - par_zip(a, a, |x, _| f(x)) + a.chunks_mut(CHUNK).zip(b.chunks(CHUNK)).for_each(|(a, b)| fill(a, b)); } // `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the @@ -342,11 +348,11 @@ fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec { // the one-thread loops they replace (rows never read each other's output), so the result is // bit for bit unchanged. -pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec) { +pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> Result<(usize, usize, Vec), AllocationError> { let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); let k = [1.0f32, 4.0, 6.0, 4.0, 1.0]; // Separable 5-tap binomial, then decimate. - let mut tmp = vec![0.0f32; nw * h * ch]; + let mut tmp = alloc_plane(plane_len(nw, h, ch)?)?; par_rows(&mut tmp, nw, ch, |y, row| { let line = &src[y * w * ch..(y + 1) * w * ch]; for x in 0..nw { @@ -361,7 +367,7 @@ pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usiz } } }); - let mut out = vec![0.0f32; nw * nh * ch]; + let mut out = alloc_plane(plane_len(nw, nh, ch)?)?; par_rows(&mut out, nw, ch, |y, row| { // Each output row is five source rows added in tap order: the same sum per element as // before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises. @@ -375,21 +381,21 @@ pub(super) fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usiz *o /= 16.0; } }); - (nw, nh, out) + Ok((nw, nh, out)) } -pub(super) fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec { +pub(super) fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Result, AllocationError> { // Bilinear upsampling at half-pixel-aligned positions. - let mut out = vec![0.0f32; w * h * ch]; + let mut out = alloc_plane(plane_len(w, h, ch)?)?; // Source columns and weights depend on x only: worked out once instead of for every row. // The clamped position is never negative, so truncating is `floor` (and spares the libm call). - let cols: Vec<(usize, usize, f32)> = (0..w) - .map(|x| { - let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32); - let x0 = fx as usize; - (x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32) - }) - .collect(); + let mut cols = Vec::new(); + cols.try_reserve_exact(w).map_err(|_| AllocationError::NotEnoughMemory { bytes: w.saturating_mul(std::mem::size_of::<(usize, usize, f32)>()) })?; + cols.extend((0..w).map(|x| { + let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32); + let x0 = fx as usize; + (x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32) + })); par_rows(&mut out, w, ch, |y, row| { let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); let y0 = fy as usize; @@ -404,20 +410,20 @@ pub(super) fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: } } }); - out + Ok(out) } /// Fills pixels with zero `wt` from their surroundings (push-pull), so pyramid levels don't /// pull in black from outside an image. -pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) { +pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) -> Result<(), AllocationError> { if w <= 1 && h <= 1 { - return; + return Ok(()); } let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); // Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each // coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero. - let mut cpx = vec![0.0f32; nw * nh * ch]; - let mut cw = vec![0.0f32; nw * nh]; + let mut cpx = alloc_plane(plane_len(nw, nh, ch)?)?; + let mut cw = alloc_plane(plane_len(nw, nh, 1)?)?; par_rows(&mut cw, nw, 1, |cy, row| { for y in 2 * cy..(2 * cy + 2).min(h) { for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() { @@ -454,7 +460,7 @@ pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32 } } if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) { - push_pull(nw, nh, ch, &mut cpx, &cw); + push_pull(nw, nh, ch, &mut cpx, &cw)?; } par_rows(px, w, ch, |y, row| { for x in 0..w { @@ -468,6 +474,7 @@ pub(super) fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32 } } }); + Ok(()) } /// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian @@ -491,50 +498,89 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize /// uses a many-core machine better than one band's row-parallel work, and one at a time made the /// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop. #[cfg(not(target_arch = "wasm32"))] -const BAND_BUDGET: usize = 5 << 30; +pub const BAND_BUDGET: usize = 5 << 30; /// Without threads the bands of a batch are built one after another anyway, so each is added as /// soon as it is built (the browser's address space is 4 GB). #[cfg(target_arch = "wasm32")] -const BAND_BUDGET: usize = 0; +pub const BAND_BUDGET: usize = 0; /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image /// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour -/// channels (`ch` per pixel) and the total coverage. -pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { - multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET) +/// channels (`ch` per pixel) and the total coverage, or an error when a buffer can't be allocated. +pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> Result<(Vec, Vec), AllocationError> { + try_multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET, None) +} + +/// The grid an image's band is built on: its ROI grown by a margin and aligned down to the +/// coarsest level's grid, clipped to the `w × h` canvas. Returns `(x0, y0, width, height)`. +fn band_grid(im: &RoiImage, w: usize, h: usize, levels: usize) -> (i32, i32, usize, usize) { + let align = 1i32 << (levels.max(1) - 1); + let margin = align * 4; + let gx0 = ((im.x0 - margin).max(0) / align) * align; + let gy0 = ((im.y0 - margin).max(0) / align) * align; + let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); + let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); + (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize) +} + +/// Estimated bytes of building one band: its grid's colour and weight planes about three times +/// over (the pyramids are 4/3 of a plane, and the grid copy and `expand`/`reduce` scratch come +/// on top). A safe upper bound: the build itself peaks lower. +pub(super) fn band_cost(im: &RoiImage, w: usize, h: usize, ch: usize, levels: usize) -> usize { + let (_, _, gw, gh) = band_grid(im, w, h, levels); + gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3) +} + +/// An upper bound on `band_cost` (the estimate batching uses) for any ROI of `w × h` pixels on a `canvas_w × canvas_h` +/// canvas: the grid grows the ROI by the margin on both sides and aligns its start down by up to +/// `align - 1`, and never exceeds the canvas. +pub fn band_bytes_bound(w: usize, h: usize, ch: usize, levels: usize, canvas_w: usize, canvas_h: usize) -> usize { + let align = 1usize << (levels.max(1) - 1).min(30); + let margin = align.saturating_mul(4); + let extent = |n: usize, canvas: usize| n.saturating_add(margin.saturating_mul(2)).saturating_add(align).min(canvas).max(1); + extent(w, canvas_w).saturating_mul(extent(h, canvas_h)).saturating_mul(ch.saturating_add(1)).saturating_mul(4 * 3) } /// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one /// image). Each pixel still adds the bands in image order, so the result doesn't depend on it. -pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize, budget: usize) -> (Vec, Vec) { +/// With `max_band_budget`, a single band estimated above it is an error instead. +pub fn try_multiband_batched( + w: usize, + h: usize, + imgs: &[RoiImage], + weights: &[Vec], + levels: usize, + budget: usize, + max_band_budget: Option, +) -> Result<(Vec, Vec), AllocationError> { let ch = imgs.first().map_or(1, |i| i.ch); let levels = levels.max(1); - let align = 1i32 << (levels - 1); + let cost = |im: &RoiImage| band_cost(im, w, h, ch, levels); + if let Some(limit) = max_band_budget { + for im in imgs { + let bytes = cost(im); + if bytes > limit { + return Err(AllocationError::NotEnoughMemory { bytes }); + } + } + } // Level sizes of the panorama. let mut sizes = vec![(w, h)]; for _ in 1..levels { let Some(&(pw, ph)) = sizes.last() else { break }; sizes.push((pw.div_ceil(2), ph.div_ceil(2))); } - let mut acc: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect(); - let mut wsum: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect(); - let margin = align * 4; - // Each image on its ROI grown by a margin and aligned to the coarsest level's grid. - let grid = |im: &RoiImage| { - let gx0 = ((im.x0 - margin).max(0) / align) * align; - let gy0 = ((im.y0 - margin).max(0) / align) * align; - let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); - let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); - (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize) - }; - // Building one band holds its grid's colour and weight planes about three times over: the - // pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top. - let cost = |im: &RoiImage| { - let (_, _, gw, gh) = grid(im); - gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3) - }; - let band = |k: usize| -> PyramidBand { + let mut acc: Vec> = Vec::with_capacity(sizes.len()); + let mut wsum: Vec> = Vec::with_capacity(sizes.len()); + for &(lw, lh) in &sizes { + let acc_len = lw.checked_mul(lh).and_then(|n| n.checked_mul(ch)).ok_or(AllocationError::SizeOverflow)?; + acc.push(alloc_plane(acc_len)?); + let wsum_len = lw.checked_mul(lh).ok_or(AllocationError::SizeOverflow)?; + wsum.push(alloc_plane(wsum_len)?); + } + let grid = |im: &RoiImage| band_grid(im, w, h, levels); + let band = |k: usize| -> Result { let im = &imgs[k]; let (gx0, gy0, gw, gh) = grid(im); // Each grid pixel takes the image pixel that lands on it (every image pixel lands on at @@ -543,7 +589,7 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64); (x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize) }; - let mut px = vec![0.0f32; gw * gh * ch]; + let mut px = alloc_plane(plane_len(gw, gh, ch)?)?; par_rows(&mut px, gw, ch, |gy, row| { for gx in 0..gw { if let Some(i) = src_of(gx, gy) { @@ -551,15 +597,7 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: } } }); - let mut cov = vec![0.0f32; gw * gh]; - par_rows(&mut cov, gw, 1, |gy, row| { - for (gx, o) in row.iter_mut().enumerate() { - if let Some(i) = src_of(gx, gy) { - *o = im.alpha[i]; - } - } - }); - let mut wt = vec![0.0f32; gw * gh]; + let mut wt = alloc_plane(plane_len(gw, gh, 1)?)?; par_rows(&mut wt, gw, 1, |gy, row| { for (gx, o) in row.iter_mut().enumerate() { if let Some(i) = src_of(gx, gy) { @@ -567,9 +605,16 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: } } }); - let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 }); - drop(cov); - push_pull(gw, gh, ch, &mut px, &bin); + // Binary coverage straight from the alpha (0 outside the image), without a coverage plane. + let mut bin = alloc_plane(plane_len(gw, gh, 1)?)?; + par_rows(&mut bin, gw, 1, |gy, row| { + for (gx, o) in row.iter_mut().enumerate() { + if let Some(i) = src_of(gx, gy) { + *o = if im.alpha[i] > 0.5 { 1.0 } else { 0.0 }; + } + } + }); + push_pull(gw, gh, ch, &mut px, &bin)?; drop(bin); // Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather // than being copied: with one image built at a time, a copy is a serial pass over it. @@ -582,17 +627,20 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: gau.push(cwt); break; } - let (nw, nh, down) = reduce(cw, chh, ch, &cur); - let up = expand(nw, nh, ch, &down, cw, chh); - lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); - let (_, _, wdown) = reduce(cw, chh, 1, &cwt); + let (nw, nh, down) = reduce(cw, chh, ch, &cur)?; + let up = expand(nw, nh, ch, &down, cw, chh)?; + // The Laplacian replaces the level's colours in place (`cur - up`, elementwise). + par_zip_assign(&mut cur, &up, |a, b| a - b); + drop(up); + lap.push((cw, chh, cur)); + let (_, _, wdown) = reduce(cw, chh, 1, &cwt)?; gau.push(cwt); cw = nw; chh = nh; cur = down; cwt = wdown; } - (gx0, gy0, lap, gau) + Ok((gx0, gy0, lap, gau)) }; // Build the bands a batch at a time and add each batch before building the next. Each pixel // adds the bands in the same order as the serial loop (per level, bands one after another; @@ -601,14 +649,15 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: let mut start = 0; while start < imgs.len() { let mut end = start + 1; - let mut bytes = cost(&imgs[start]); + let c = cost(&imgs[start]); + let mut bytes = c; while let Some(im) = imgs.get(end) && bytes.saturating_add(cost(im)) <= budget { bytes = bytes.saturating_add(cost(im)); end += 1; } - let bands = par_map(end - start, |j| band(start + j)); + let bands = par_map(end - start, |j| band(start + j)).into_iter().collect::, _>>()?; for l in 0..levels { let pw = sizes[l].0; par_rows(&mut wsum[l], pw, 1, |py, row| { @@ -660,18 +709,28 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: } }); } + drop(wsum); // Collapse. // `levels ≥ 1`, so there is always a coarsest level. - let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) }; + let Some(mut cur) = acc.pop() else { + let total = w.checked_mul(h).and_then(|n| n.checked_mul(ch)).ok_or(AllocationError::SizeOverflow)?; + let cov_total = w.checked_mul(h).ok_or(AllocationError::SizeOverflow)?; + return Ok((alloc_plane(total)?, alloc_plane(cov_total)?)); + }; for l in (0..levels - 1).rev() { let (pw, ph) = sizes[l]; let (sw, sh) = sizes[l + 1]; - let up = expand(sw, sh, ch, &cur, pw, ph); - cur = par_zip(&acc[l], &up, |a, b| a + b); + let up = expand(sw, sh, ch, &cur, pw, ph)?; + // Add the upsampled coarser levels into this level's accumulator (`acc + up`, in place). + let mut level = std::mem::take(&mut acc[l]); + par_zip_assign(&mut level, &up, |a, b| a + b); + drop(up); + cur = level; } // Coverage: union of the images. Rows are independent; each walks the images in order, so a // pixel sees the same sequence of `max` calls as in the serial loop. - let mut cov = vec![0.0f32; w * h]; + let cov_len = w.checked_mul(h).ok_or(AllocationError::SizeOverflow)?; + let mut cov = alloc_plane(cov_len)?; par_rows(&mut cov, w, 1, |py, row| { for im in imgs { let y = py as i64 - im.y0 as i64; @@ -688,5 +747,5 @@ pub(super) fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: } } }); - (cur, cov) + Ok((cur, cov)) } diff --git a/crates/algo/src/panorama/parallel_tests.rs b/crates/algo/src/panorama/parallel_tests.rs index 77c25b0f98..a8972f32cb 100644 --- a/crates/algo/src/panorama/parallel_tests.rs +++ b/crates/algo/src/panorama/parallel_tests.rs @@ -284,13 +284,13 @@ fn reduce_and_expand_match_the_serial_code() { for (n, &(w, h)) in SIZES.iter().enumerate() { for ch in [1usize, 3, 4] { let src = noise(w * h * ch, n as u64 * 7 + ch as u64); - let (nw, nh, got) = reduce(w, h, ch, &src); + let (nw, nh, got) = reduce(w, h, ch, &src).unwrap(); let (rw, rh, want) = ref_reduce(w, h, ch, &src); assert_eq!((nw, nh), (rw, rh)); assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}"); // Expand back to the original size and to sizes that are not twice the source's. for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] { - let got = expand(nw, nh, ch, &want, ew, eh); + let got = expand(nw, nh, ch, &want, ew, eh).unwrap(); let want_up = ref_expand(nw, nh, ch, &want, ew, eh); assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}"); } @@ -311,7 +311,7 @@ fn push_pull_matches_the_serial_code() { }; let px = noise(w * h * ch, 100 + n as u64); let (mut got, mut want) = (px.clone(), px); - push_pull(w, h, ch, &mut got, &wt); + push_pull(w, h, ch, &mut got, &wt).unwrap(); ref_push_pull(w, h, ch, &mut want, &wt); assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}"); } @@ -350,7 +350,7 @@ fn multiband_matches_the_serial_code() { for ch in [1usize, 3] { let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64); for levels in [1usize, 2, 3, 5, 7] { - let got = multiband(w, h, &imgs, &wts, levels); + let got = multiband(w, h, &imgs, &wts, levels).unwrap(); let want = ref_multiband(w, h, &imgs, &wts, levels); assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}"); assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}"); @@ -358,7 +358,7 @@ fn multiband_matches_the_serial_code() { } } // No images at all, and zero levels. - assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); + assert_eq!(multiband(9, 7, &[], &[], 3).unwrap().0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); } /// Batching the bands (#7758) changes when they are built, not the result. Budgets from none @@ -374,7 +374,7 @@ fn multiband_batches_match_the_serial_code() { for levels in [1usize, 3, 5] { let want = ref_multiband(w, h, &imgs, &wts, levels); for &budget in &budgets { - let got = multiband_batched(w, h, &imgs, &wts, levels, budget); + let got = try_multiband_batched(w, h, &imgs, &wts, levels, budget, None).unwrap(); assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}"); assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}"); } @@ -388,15 +388,60 @@ fn multiband_batches_match_the_serial_code() { #[test] fn multiband_hashes_recorded_from_the_serial_code() { let (imgs, wts) = rois(97, 61, 3, 5); - let (px, cov) = multiband(97, 61, &imgs, &wts, 5); + let (px, cov) = multiband(97, 61, &imgs, &wts, 5).unwrap(); assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels"); let (imgs, wts) = rois(130, 70, 1, 9); - let (px, cov) = multiband(130, 70, &imgs, &wts, 7); + let (px, cov) = multiband(130, 70, &imgs, &wts, 7).unwrap(); assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels"); let wt = weights(65, 41, 3); let mut px = noise(65 * 41 * 3, 4); - push_pull(65, 41, 3, &mut px, &wt); + push_pull(65, 41, 3, &mut px, &wt).unwrap(); assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb"); - let (nw, nh, r) = reduce(65, 41, 3, &px); - assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb"); + let (nw, nh, r) = reduce(65, 41, 3, &px).unwrap(); + assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41).unwrap()]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb"); +} + +#[test] +fn multiband_rejects_single_band_larger_than_budget() { + let (w, h, ch) = (97, 61, 3); + let (imgs, wts) = rois(w, h, ch, 42); + let err = try_multiband_batched(w, h, &imgs, &wts, 3, 100, Some(100)).unwrap_err(); + assert!(matches!(err, photocraft_raster::AllocationError::NotEnoughMemory { bytes } if bytes > 100)); +} + +/// [`band_bytes_bound`] covers the cost the batching estimates for any ROI, offsets and levels. +#[test] +fn band_bytes_bound_covers_the_band_cost() { + for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (201, 129)].iter().enumerate() { + for ch in [1usize, 3, 4] { + let (imgs, _) = rois(w, h, ch, n as u64 + 3); + for levels in [0usize, 1, 2, 3, 5, 7] { + for im in &imgs { + let cost = band_cost(im, w, h, ch, levels); + let bound = band_bytes_bound(im.w, im.h, ch, levels, w, h); + assert!(bound >= cost, "{w}x{h}x{ch} levels {levels} roi {}x{} at ({}, {}): bound {bound} < cost {cost}", im.w, im.h, im.x0, im.y0); + } + } + } + } +} + +#[test] +fn multiband_checks_later_images_and_zero_limit() { + let small = RoiImage { x0: 0, y0: 0, w: 1, h: 1, ch: 1, px: vec![1.0], alpha: vec![1.0] }; + let large = RoiImage { x0: 0, y0: 0, w: 20, h: 20, ch: 1, px: vec![1.0; 400], alpha: vec![1.0; 400] }; + let limit = band_cost(&small, 40, 40, 1, 1); + let expected = band_cost(&large, 40, 40, 1, 1); + assert!(expected > limit); + let err = try_multiband_batched(40, 40, &[small.clone(), large], &[vec![1.0], vec![1.0; 400]], 1, usize::MAX, Some(limit)).unwrap_err(); + assert!(matches!(err, photocraft_raster::AllocationError::NotEnoughMemory { bytes } if bytes == expected)); + let err = try_multiband_batched(40, 40, &[small], &[vec![1.0]], 1, usize::MAX, Some(0)).unwrap_err(); + assert!(matches!(err, photocraft_raster::AllocationError::NotEnoughMemory { .. })); +} + +#[test] +fn pyramid_planes_reject_size_overflow() { + assert!(matches!(reduce(usize::MAX, 3, 3, &[]), Err(photocraft_raster::AllocationError::SizeOverflow))); + assert!(matches!(expand(1, 1, 3, &[0.0; 3], usize::MAX, 2), Err(photocraft_raster::AllocationError::SizeOverflow))); + assert!(matches!(push_pull(usize::MAX, 3, 3, &mut [], &[]), Err(photocraft_raster::AllocationError::SizeOverflow))); } diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index a1b8cbd91d..ba5e2631f8 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -1117,6 +1117,50 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out) } +/// How many times over a stored mesh's triangles may cover its deformed box, counting each +/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a +/// crafted one stacks large triangles to multiply the drawing work. +const MAX_MESH_COVERAGE: f64 = 16.0; + +/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file +/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored +/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex +/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose +/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times. +pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option { + let n = mesh.verts.len(); + if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in mesh.verts.iter().chain(deformed) { + if !(p[0].is_finite() && p[1].is_finite()) { + return None; + } + } + for p in deformed { + (x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1])); + } + let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0); + if area > MAX_WARP_PIXELS { + return None; + } + let covered: f64 = mesh + .tris + .iter() + .map(|t| { + let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1])); + let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0; + span(xs) * span(ys) + }) + .sum(); + if covered > MAX_MESH_COVERAGE * area { + return None; + } + let order: Vec = (0..mesh.tris.len()).collect(); + Some(render(src, bounds, mesh, deformed, &order, interp)) +} + /// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same /// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the /// result when none was given. @@ -1267,6 +1311,50 @@ mod tests { assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0])); } + #[test] + fn a_stored_mesh_is_drawn_as_it_is() { + // A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box, + // sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the + // mesh are dropped. + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + for y in 0..48 { + for x in 0..48 { + let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0; + s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 }; + let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0]; + let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect(); + let b = s.content_bounds(); + let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws"); + let ob = out.content_bounds(); + let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0); + for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() { + let w = f64::from(px[0] * px[3]); + let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5); + (sw, sx, sy) = (sw + w, sx + w * x, sy + w * y); + } + let want = map([12.0, 20.0]); + assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]); + // The source column x = 44 is outside the mesh: nothing lands right of the sheared box. + assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}"); + // Malformed meshes are refused rather than drawn. + assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none()); + let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none()); + let mut far = deformed.clone(); + far[2] = [f64::NAN, 0.0]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + far[2] = [1.0e6, 1.0e6]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + // The same two triangles stacked 40 times: far more drawing than the box needs. + let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none()); + let none = PuppetMesh { tris: Vec::new(), ..mesh }; + assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none()); + } + #[test] fn no_pins_or_unmoved_pins_are_identity() { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/algo/src/remove.rs b/crates/algo/src/remove.rs index 550bda5809..16a9ae3825 100644 --- a/crates/algo/src/remove.rs +++ b/crates/algo/src/remove.rs @@ -54,34 +54,9 @@ pub fn close_loops(w: usize, h: usize, mask: &[bool]) -> Vec { outside.iter().map(|o| !*o).collect() } -/// `mask` grown by `r` cells (a square structuring element), in two separable passes. +/// `mask` grown by `r` cells (a square structuring element, clipped to the grid). pub fn dilate(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { - if r == 0 || mask.len() != w.saturating_mul(h) { - return mask.to_vec(); - } - // A pass sets each cell when any cell within `r` along one axis is set (a running count). - let pass = |src: &[bool], horizontal: bool| -> Vec { - let (lines, len) = if horizontal { (h, w) } else { (w, h) }; - let at = |line: usize, k: usize| if horizontal { line * w + k } else { k * w + line }; - let mut out = vec![false; src.len()]; - for line in 0..lines { - let mut count = 0usize; - for k in 0..len.min(r) { - count += usize::from(src[at(line, k)]); - } - for k in 0..len { - if k + r < len { - count += usize::from(src[at(line, k + r)]); - } - if k > r { - count -= usize::from(src[at(line, k - r - 1)]); - } - out[at(line, k)] = count > 0; - } - } - out - }; - pass(&pass(mask, true), false) + crate::square_dilate::dilate_square(w, h, mask, r) } /// The largest chessboard distance from a hole cell to the nearest cell outside the hole (cells diff --git a/crates/algo/src/render.rs b/crates/algo/src/render.rs index 2a1867dac8..9a0a4189ab 100644 --- a/crates/algo/src/render.rs +++ b/crates/algo/src/render.rs @@ -1,11 +1,11 @@ -//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects. +//! Render: Clouds, Difference Clouds, Fibers, Lighting Effects (Lens Flare: `lens_flare.rs`). use photocraft_geom::Rect; use crate::fxutil::{MAXC, luma, rgba, set_rgba, smoothstep, xy}; use crate::image::Image; use crate::noise::hash01; -use crate::{Ctx, LensType, Light, LightKind, TextureChannel}; +use crate::{Ctx, Light, LightKind, TextureChannel}; /// Smooth value noise in [0, 1). fn value_noise(x: f32, y: f32, seed: u32) -> f32 { @@ -106,107 +106,6 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength: res } -/// One lens-flare ghost: position along the flare→centre axis, radius and tint. -struct Ghost { - t: f32, - r: f32, - tint: [f32; 3], - k: f32, -} - -/// Lens Flare: an additive (screen) glow, rays, halo ring and ghost discs -/// along the line through the image centre. Lens types vary the elements. -pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec { - let n = src.ch; - let b = ctx.bounds; - let (bw, bh) = (b.width() as f32, b.height() as f32); - let diag = bw.hypot(bh).max(1.0); - let (cx, cy) = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh); - let (mx, my) = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0); - let bright = brightness.clamp(10.0, 300.0) / 100.0; - let s = diag / 1000.0; - let (core, glow, ring_r, ring_w, rays, streak) = match lens { - LensType::Zoom => (14.0, 90.0, 0.0, 0.0, 0.5, 0.0), - LensType::Prime35 => (12.0, 70.0, 90.0, 6.0, 0.3, 0.0), - LensType::Prime105 => (22.0, 130.0, 0.0, 0.0, 0.8, 0.0), - LensType::MoviePrime => (10.0, 60.0, 0.0, 0.0, 0.2, 1.0), - }; - let ghosts: Vec = match lens { - LensType::Zoom => vec![ - Ghost { t: 0.35, r: 10.0, tint: [0.6, 0.8, 1.0], k: 0.18 }, - Ghost { t: 0.6, r: 26.0, tint: [0.5, 1.0, 0.6], k: 0.10 }, - Ghost { t: 0.85, r: 6.0, tint: [1.0, 0.7, 0.4], k: 0.25 }, - Ghost { t: 1.25, r: 40.0, tint: [0.7, 0.5, 1.0], k: 0.08 }, - Ghost { t: 1.5, r: 16.0, tint: [1.0, 0.6, 0.5], k: 0.15 }, - Ghost { t: 1.9, r: 70.0, tint: [0.5, 0.7, 1.0], k: 0.06 }, - ], - LensType::Prime35 => vec![Ghost { t: 0.7, r: 18.0, tint: [0.6, 1.0, 0.7], k: 0.12 }, Ghost { t: 1.4, r: 30.0, tint: [1.0, 0.7, 0.5], k: 0.09 }], - LensType::Prime105 => vec![Ghost { t: 0.5, r: 14.0, tint: [1.0, 0.8, 0.5], k: 0.12 }, Ghost { t: 1.6, r: 50.0, tint: [0.6, 0.7, 1.0], k: 0.07 }], - LensType::MoviePrime => vec![ - Ghost { t: 0.4, r: 8.0, tint: [0.5, 0.7, 1.0], k: 0.2 }, - Ghost { t: 1.0, r: 20.0, tint: [0.4, 0.6, 1.0], k: 0.12 }, - Ghost { t: 1.6, r: 12.0, tint: [0.5, 0.8, 1.0], k: 0.16 }, - ], - }; - let mut res = src.crop(out); - for (i, px) in res.chunks_exact_mut(n).enumerate() { - let (x, y) = xy(out, i); - let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); - let (dx, dy) = (fx - cx, fy - cy); - let d = (dx * dx + dy * dy).sqrt(); - let mut f = [0.0f32; 3]; - // Hot core and wide glow (warm white). - let g = (-(d / (core * s)).powi(2)).exp() + 0.35 * (-d / (glow * s)).exp(); - for (k, w) in [1.0, 0.95, 0.85].iter().enumerate() { - f[k] += g * w; - } - // Rays: angular modulation decaying with distance. - if rays > 0.0 { - let th = dy.atan2(dx); - let m = ((th * 12.0).sin() * 0.5 + 0.5).powi(8) + ((th * 7.0 + 1.3).sin() * 0.5 + 0.5).powi(12) * 0.6; - let rr = rays * m * (-d / (glow * 1.6 * s)).exp(); - for v in f.iter_mut() { - *v += rr * 0.6; - } - } - // Anamorphic horizontal streak (movie prime). - if streak > 0.0 { - let st = (-(dy.abs() / (2.5 * s))).exp() * (-(dx.abs() / (450.0 * s))).exp(); - f[0] += st * 0.5; - f[1] += st * 0.7; - f[2] += st; - } - // Halo ring with a slight chromatic spread. - if ring_r > 0.0 { - for (k, off) in [1.04f32, 1.0, 0.96].iter().enumerate() { - let rd = (d - ring_r * s * off) / (ring_w * s); - f[k] += 0.18 * (-rd * rd).exp(); - } - } - // Ghosts along the axis through the centre. - for gh in &ghosts { - let (gx, gy) = (cx + (mx - cx) * gh.t * 2.0, cy + (my - cy) * gh.t * 2.0); - let gd = ((fx - gx).powi(2) + (fy - gy).powi(2)).sqrt() / (gh.r * s); - if gd < 1.2 { - // Soft-edged disc, slightly brighter at the rim. - let e = smoothstep(1.0, 0.85, gd) * (0.6 + 0.4 * gd); - for (fk, t) in f.iter_mut().zip(gh.tint) { - *fk += e * gh.k * t; - } - } - } - let mut c = rgba(ctx, px); - for k in 0..3 { - let a = (f[k] * bright).max(0.0); - // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit - // value already above white is left as is rather than pulled down towards it. - c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0); - } - set_rgba(ctx, px, c); - } - res -} - pub(crate) struct LightingSpec<'a> { pub lights: &'a [Light], pub gloss: f32, diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 4dcf0b6944..635a5a3079 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -288,6 +288,21 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect { Rect::new((r.x0 as f64 * sx).floor() as i32, (r.y0 as f64 * sy).floor() as i32, (r.x1 as f64 * sx).ceil() as i32, (r.y1 as f64 * sy).ceil() as i32) } +/// The largest rectangle a resize of content at `r` by `(sx, sy)` with `filter` stores: the +/// result, or the image between the passes of a two-pass bicubic. That one keeps an axis at its +/// old size, so when both axes shrink it is larger than the result (#9212). +pub fn largest_resize_rect(r: Rect, sx: f64, sy: f64, filter: Resample) -> Rect { + let area = |r: Rect| u64::from(r.width()) * u64::from(r.height()); + let (mut cur, mut largest) = (r, Rect::EMPTY); + for p in plan(filter, sx, sy) { + cur = scaled_rect(cur, p.sx, p.sy); + if area(cur) > area(largest) { + largest = cur; + } + } + largest +} + /// Resizes a surface by `(sx, sy)` about the document origin. The surface's /// default pixel is kept (masks keep their "reveal all" background). pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface { @@ -346,71 +361,137 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option) /// Runs `filter`'s passes. Between two passes the image is stored in its own format, which /// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so -/// the intermediate image is never larger than the result. +/// the intermediate image is never larger than the result when an axis grows, but is when both +/// shrink ([`largest_resize_rect`]). fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize) -> Surface { + resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE) +} + +/// Resize with cancellation between passes and bounded groups of output bands. +/// A cancelled operation returns no partially resized surface. +pub fn resize_surface_with_interrupt( + s: &Surface, + sx: f64, + sy: f64, + filter: Resample, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check()?; + let out = resize_controlled(s, sx, sy, filter, None, READ_BUDGET, ctl); + ctl.check()?; + Ok(out) +} + +fn resize_controlled(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface { let mut cur: Option = None; let mut canvas = canvas; for p in plan(filter, sx, sy) { - let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget); + if ctl.cancelled() { + return Surface::new(s.format()); + } + let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget, ctl); canvas = canvas.map(|c| scaled_rect(c, p.sx, p.sy)); cur = Some(next); } - let mut out = cur.unwrap_or_else(|| s.clone()); + let mut out = preserve_details(cur.unwrap_or_else(|| s.clone()), filter, sx, sy); + out.prune(); + out +} + +/// Preserve Details' light sharpening of an enlargement (Lanczos's output, for the other filters +/// nothing). +fn preserve_details(mut out: Surface, filter: Resample, sx: f64, sy: f64) -> Surface { if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) { let dst = out.content_bounds(); let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 }; let area = crate::output_area(&p, dst, dst, None); out = crate::apply(&out, &p, area, dst, None); } - out.prune(); out } -/// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see -/// [`resize_surface_in_canvas`]). -fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> Surface { - let Pass { sx, sy, kx, ky } = pass; - let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty()); - let fmt = s.format(); - let mut out = Surface::with_default(fmt, &s.default_pixel()); - let src = s.content_bounds(); - let dst = scaled_rect(src, sx, sy); - if dst.is_empty() { - return out; +/// Output rows computed together by [`PassState::band`]. +const BAND: usize = 64; + +/// One pass prepared: its taps and the constants every band needs. [`PassState::band`] computes +/// one band of output rows from the source, so the parallel loop of [`resize_pass`] and +/// [`ResizeSteps`] run the same code and give the same pixels. +struct PassState { + n: usize, + alpha: bool, + /// The output extent. + dst: Rect, + /// The area every tap is clamped into (the source's content, or with a canvas that and the canvas). + area: Rect, + hx: Vec, + vy: Vec, + x_lo: i32, + x_hi: i32, + def: Vec, + /// A source row of nothing but the default pixel, after the horizontal pass. + def_row: Vec, + rows_per_read: i32, +} + +impl PassState { + /// Prepares `pass` over `s`; `None` when the output is empty. With `canvas`, the content and + /// the canvas repeat their edge outwards (see [`resize_surface_in_canvas`]). + fn new(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> Option { + let Pass { sx, sy, kx, ky } = pass; + let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty()); + let fmt = s.format(); + let src = s.content_bounds(); + let dst = scaled_rect(src, sx, sy); + if dst.is_empty() { + return None; + } + let n = fmt.channels(); + let alpha = fmt.alpha; + // With `edge`, every tap is clamped into the edge area, which holds the content. + let area = edge.unwrap_or(src); + let hx = taps(dst.x0, dst.x1, sx, kx, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1)); + let vy = taps(dst.y0, dst.y1, sy, ky, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1)); + let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0); + let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo); + let mut def = s.default_pixel(); + premultiply(&mut def, n, alpha); + let dw = dst.width() as usize; + let mut st = Self { n, alpha, dst, area, hx, vy, x_lo, x_hi, def, def_row: Vec::new(), rows_per_read: 1 }; + st.def_row = { + let row: Vec = st.def.iter().copied().cycle().take((x_hi - x_lo) as usize * n).collect(); + let mut out = vec![0.0f32; dw * n]; + st.horizontal(&row, &mut out); + out + }; + // At most one tile row per read anyway, so this always fits an `i32`. + st.rows_per_read = (budget / ((x_hi - x_lo).max(1) as usize * n).max(dw * n)).clamp(1, TILE_SIZE as usize) as i32; + Some(st) } - let n = fmt.channels(); - let alpha = fmt.alpha; - // With `edge`, every tap is clamped into the edge area, which holds the content. - let area = edge.unwrap_or(src); - let hx = taps(dst.x0, dst.x1, sx, kx, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1)); - let vy = taps(dst.y0, dst.y1, sy, ky, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1)); - let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0); - let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo); - let mut def = s.default_pixel(); - premultiply(&mut def, n, alpha); - let dw = dst.width() as usize; - const BAND: usize = 64; - let bands: Vec<(usize, usize)> = (0..vy.len()).step_by(BAND).map(|b| (b, (b + BAND).min(vy.len()))).collect(); - // Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples. - let horizontal = |row: &[f32], out: &mut [f32]| match n { - 1 => horizontal_fixed::<1>(row, out, &hx, x_lo, &def), - 2 => horizontal_fixed::<2>(row, out, &hx, x_lo, &def), - 3 => horizontal_fixed::<3>(row, out, &hx, x_lo, &def), - 4 => horizontal_fixed::<4>(row, out, &hx, x_lo, &def), - 5 => horizontal_fixed::<5>(row, out, &hx, x_lo, &def), - _ => horizontal_dynamic(row, out, &hx, x_lo, &def, n), - }; - // A source row of nothing but the default pixel, after the horizontal pass. - let def_row = { - let row: Vec = def.iter().copied().cycle().take((x_hi - x_lo) as usize * n).collect(); - let mut out = vec![0.0f32; dw * n]; - horizontal(&row, &mut out); - out - }; - // At most one tile row per read anyway, so this always fits an `i32`. - let rows_per_read = (budget / ((x_hi - x_lo).max(1) as usize * n).max(dw * n)).clamp(1, TILE_SIZE as usize) as i32; - let run = |&(b0, b1): &(usize, usize)| -> (Rect, Vec) { - let band = vy.get(b0..b1).unwrap_or(&[]); + + /// How many bands of [`BAND`] output rows the pass has. + fn bands(&self) -> usize { + self.vy.len().div_ceil(BAND) + } + + /// Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples. + fn horizontal(&self, row: &[f32], out: &mut [f32]) { + let (hx, x_lo, def) = (&self.hx, self.x_lo, &self.def); + match self.n { + 1 => horizontal_fixed::<1>(row, out, hx, x_lo, def), + 2 => horizontal_fixed::<2>(row, out, hx, x_lo, def), + 3 => horizontal_fixed::<3>(row, out, hx, x_lo, def), + 4 => horizontal_fixed::<4>(row, out, hx, x_lo, def), + 5 => horizontal_fixed::<5>(row, out, hx, x_lo, def), + _ => horizontal_dynamic(row, out, hx, x_lo, def, self.n), + } + } + + /// Band `b` of the output: its rectangle and samples, from the pass's source `s`. + fn band(&self, s: &Surface, b: usize) -> (Rect, Vec) { + let (n, alpha, area, dst, x_lo, x_hi) = (self.n, self.alpha, self.area, self.dst, self.x_lo, self.x_hi); + let dw = dst.width() as usize; + let (b0, b1) = (b * BAND, ((b + 1) * BAND).min(self.vy.len())); + let band = self.vy.get(b0..b1).unwrap_or(&[]); let y_lo = band.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.y0); let y_hi = band.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.y0).max(y_lo); let mut res = vec![0.0f32; band.len() * dw * n]; @@ -434,7 +515,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> if s.has_tiles_in(Rect::new(x_lo, y, x_hi, y1)) { let mut c0 = y; while c0 < y1 { - let c1 = (c0 + rows_per_read).min(y1); + let c1 = (c0 + self.rows_per_read).min(y1); let read = Rect::new(x_lo, c0, x_hi, c1); s.read_region_into(read, &mut rows); premultiply(&mut rows, n, alpha); @@ -442,7 +523,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> hrows.resize((c1 - c0) as usize * dw * n, 0.0); let rw = read.width() as usize * n; for (row, out) in rows.chunks_exact(rw.max(1)).zip(hrows.chunks_exact_mut(dw * n)) { - horizontal(row, out); + self.horizontal(row, out); } for (r, h) in (c0..c1).zip(hrows.chunks_exact(dw * n)) { add(&mut res, r, h); @@ -456,7 +537,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> let (a, b) = ((y - t.start).max(0) as usize, (y1 - t.start).max(0) as usize); let w: f32 = t.weights.get(a.min(t.weights.len())..b.min(t.weights.len())).unwrap_or(&[]).iter().sum(); if w != 0.0 { - for (dv, sv) in d.iter_mut().zip(&def_row) { + for (dv, sv) in d.iter_mut().zip(&self.def_row) { *dv += sv * w; } } @@ -465,7 +546,7 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> y = y1; } for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) { - for (dv, sv) in d.iter_mut().zip(&def_row) { + for (dv, sv) in d.iter_mut().zip(&self.def_row) { *dv += sv * t.outside; } } @@ -476,7 +557,15 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> } unpremultiply(&mut res, n, alpha); (Rect::new(dst.x0, dst.y0 + b0 as i32, dst.x1, dst.y0 + b1 as i32), res) - }; + } +} + +/// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see +/// [`resize_surface_in_canvas`]). +fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface { + let mut out = Surface::with_default(s.format(), &s.default_pixel()); + let Some(st) = PassState::new(s, pass, canvas, budget) else { return out }; + let bands: Vec = (0..st.bands()).collect(); // Bands go into the output a group at a time, so the `f32` rows held at once are bounded by // the group, not by the whole destination (#1544). #[cfg(not(target_arch = "wasm32"))] @@ -484,13 +573,16 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> #[cfg(target_arch = "wasm32")] let group = 1; for chunk in bands.chunks(group) { + if ctl.cancelled() { + return Surface::new(s.format()); + } #[cfg(not(target_arch = "wasm32"))] let parts: Vec<(Rect, Vec)> = { use rayon::prelude::*; - chunk.par_iter().map(run).collect() + chunk.par_iter().map(|&b| st.band(s, b)).collect() }; #[cfg(target_arch = "wasm32")] - let parts: Vec<(Rect, Vec)> = chunk.iter().map(run).collect(); + let parts: Vec<(Rect, Vec)> = chunk.iter().map(|&b| st.band(s, b)).collect(); for (r, d) in parts { out.write_region(r, &d); } @@ -498,6 +590,101 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> out } +/// [`resize_surface`] (or, with a canvas, [`resize_surface_in_canvas`]) in resumable steps, for a +/// caller that cannot block, such as the web build with no threads: [`ResizeSteps::step`] computes +/// bands of output rows until the caller says to stop, and returns the surface once they are all +/// done. It runs [`resize_with_budget`]'s passes and [`resize_pass`]'s bands through the same +/// code, so its result equals [`resize_surface`]'s. +pub struct ResizeSteps { + /// The current pass's source: the input, then each pass's output. + src: Surface, + passes: std::vec::IntoIter, + total: usize, + canvas: Option, + filter: Resample, + scale: (f64, f64), + run: Option, + /// Final pruning must yield too: sparse output can contain many default-filled tiles. + prune: Option, +} + +/// The pass being computed by [`ResizeSteps`]. +struct PassRun { + pass: Pass, + state: PassState, + out: Surface, + next: usize, +} + +impl ResizeSteps { + /// Resizes `src` by `(sx, sy)` about the document origin, as [`resize_surface`] (no `canvas`) + /// or [`resize_surface_in_canvas`] does. + pub fn new(src: Surface, sx: f64, sy: f64, filter: Resample, canvas: Option) -> Self { + let passes = plan(filter, sx, sy); + Self { src, total: passes.len(), passes: passes.into_iter(), canvas, filter, scale: (sx, sy), run: None, prune: None } + } + + /// How far along it is, `0.0..=1.0`: whole passes plus the bands done of the current one. + pub fn progress(&self) -> f32 { + let done = self.total.saturating_sub(self.passes.len() + usize::from(self.run.is_some())); + let part = self.run.as_ref().map_or(0.0, |r| r.next as f32 / r.state.bands().max(1) as f32); + ((done as f32 + part) / self.total.max(1) as f32).clamp(0.0, 1.0) + } + + /// Does at least one unit of work (planning a pass, one band of 64 output rows, or the + /// closing prune batch) and goes on while `more()` returns true. `Some` once the resize is done. + pub fn step(&mut self, more: &mut dyn FnMut() -> bool) -> Option { + loop { + match self.run.take() { + None => { + if let Some(pass) = self.passes.next() { + let out = Surface::with_default(self.src.format(), &self.src.default_pixel()); + match PassState::new(&self.src, pass, self.canvas, READ_BUDGET) { + Some(state) => self.run = Some(PassRun { pass, state, out, next: 0 }), + None => self.advance(out, pass), + } + } else if let Some(out) = self.finish_step() { + return Some(out); + } + } + Some(mut run) if run.next < run.state.bands() => { + let (r, data) = run.state.band(&self.src, run.next); + run.out.write_region(r, &data); + run.next += 1; + self.run = Some(run); + } + Some(run) => self.advance(run.out, run.pass), + } + if !more() { + return None; + } + } + } + + /// The next pass reads this one's output. + fn advance(&mut self, out: Surface, pass: Pass) { + self.canvas = self.canvas.map(|c| scaled_rect(c, pass.sx, pass.sy)); + self.src = out; + } + + /// The last pass's output, with the same final pruning as [`resize_with_budget`], but at + /// most four tiles per unit so a broad transparent area cannot monopolize the closing poll. + fn finish_step(&mut self) -> Option { + if self.prune.is_none() { + let placeholder = Surface::new(self.src.format()); + let out = std::mem::replace(&mut self.src, placeholder); + self.src = preserve_details(out, self.filter, self.scale.0, self.scale.1); + self.prune = Some(self.src.prune_cursor()); + } + let cursor = self.prune.as_mut()?; + if !self.src.prune_batch(cursor, 4) { + return None; + } + let placeholder = Surface::new(self.src.format()); + Some(std::mem::replace(&mut self.src, placeholder)) + } +} + /// Moves a surface by whole pixels (default pixel kept). pub fn translate_surface(s: &Surface, dx: i32, dy: i32) -> Surface { s.translated(dx, dy, s.content_bounds()) diff --git a/crates/algo/src/resample/tests.rs b/crates/algo/src/resample/tests.rs index 0ee08847e1..6e410f1c5d 100644 --- a/crates/algo/src/resample/tests.rs +++ b/crates/algo/src/resample/tests.rs @@ -400,3 +400,196 @@ fn nearest_neighbor_reduction_does_not_average() { } } } + +/// #9212: a bicubic shrink of both axes stores the image between its passes with one axis at the +/// old size, larger than the result; single-pass filters and growing axes store nothing larger. +#[test] +fn largest_resize_rect_counts_the_image_between_the_passes() { + let r = Rect::new(0, 0, 100, 80); + for f in [Resample::Bicubic, Resample::BicubicSharper, Resample::BicubicAutomatic, Resample::PreserveDetails] { + assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 80), "{f:?}"); + } + // The axis with the smaller scale goes first. + assert_eq!(largest_resize_rect(r, 0.5, 0.25, Resample::Bicubic), Rect::new(0, 0, 100, 20)); + for f in [Resample::Bilinear, Resample::Nearest, Resample::Lanczos] { + assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 40), "{f:?}"); + } + for f in [Resample::Bicubic, Resample::BicubicAutomatic] { + assert_eq!(largest_resize_rect(r, 2.0, 3.0, f), Rect::new(0, 0, 200, 240), "{f:?} grows"); + assert_eq!(largest_resize_rect(r, 0.5, 2.0, f), Rect::new(0, 0, 50, 160), "{f:?} shrinks x, grows y"); + } +} + +#[test] +fn controlled_resize_matches_pixels_and_discards_cancelled_work() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let src = ramp(depth); + let expected = resize_surface(&src, 1.7, 0.6, Resample::Bicubic); + let actual = resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &photocraft_raster::Interrupt::NONE).unwrap(); + let bounds = expected.content_bounds(); + assert_eq!(expected.read_region(bounds), actual.read_region(bounds)); + let cancelled = || true; + let progress = |_| {}; + let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress); + assert!(resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &ctl).is_err()); + } +} + +/// Every band group of a pass asks `ctl` again, not only the start of each pass: counting the +/// calls catches a missing per-group check, which a cancel (that fails at the first call or at the +/// final one alike) cannot. +#[test] +fn controlled_resize_checks_cancellation_per_band_group() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let src = ramp(depth); + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancelled = || { + calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + false + }; + let progress = |_| {}; + let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress); + assert!(resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &ctl).is_ok()); + let passes = plan(Resample::Bicubic, 1.7, 0.6).len(); + let checks = calls.load(std::sync::atomic::Ordering::Relaxed); + assert!(checks > passes + 2, "{checks} cancellation checks for {passes} passes; per-group checks are missing"); + } +} + +/// A pseudo-random surface of `w`×`h` in a format of `mode`, `sample` and `alpha`. +fn noisy(w: i32, h: i32, mode: ColorMode, sample: SampleType, alpha: bool) -> Surface { + let fmt = PixelFormat::new(mode, sample, alpha); + let n = fmt.channels(); + let mut seed = 0x2545_f491u32; + let v: Vec = (0..w as usize * h as usize * n) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as f32 / (1u32 << 24) as f32 + }) + .collect(); + let mut s = Surface::new(fmt); + s.write_region(Rect::new(0, 0, w, h), &v); + s +} + +/// Drives `steps` to the end, `per_call` units of work at a time (0: unlimited); returns the +/// surface and how many calls it took. +fn drive(mut steps: ResizeSteps, per_call: usize) -> (Surface, usize) { + let mut calls = 0; + loop { + calls += 1; + let mut left = per_call; + let mut more = || { + left = left.wrapping_sub(1); + per_call == 0 || left > 0 + }; + if let Some(s) = steps.step(&mut more) { + return (s, calls); + } + assert!(calls < 10_000, "the resize never finished"); + } +} + +#[test] +fn steps_equal_resize_surface() { + let cases = [ + (SampleType::U8, ColorMode::Rgb, true), + (SampleType::U16, ColorMode::Rgb, false), + (SampleType::F32, ColorMode::Rgb, true), + (SampleType::U8, ColorMode::Grayscale, true), + ]; + for (sample, mode, alpha) in cases { + let src = noisy(150, 130, mode, sample, alpha); + // Up, down, one axis each way, and enough output rows for several bands. + for (sx, sy) in [(0.6, 0.6), (1.7, 1.7), (0.4, 2.2), (1.0, 0.5)] { + for filter in [Resample::Bicubic, Resample::Lanczos, Resample::BicubicAutomatic, Resample::Nearest] { + let expected = resize_surface(&src, sx, sy, filter); + for per_call in [0, 1, 7] { + let (got, _) = drive(ResizeSteps::new(src.clone(), sx, sy, filter, None), per_call); + assert!(got == expected, "{sample:?} {mode:?} alpha={alpha} scale=({sx}, {sy}) {filter:?} per_call={per_call}"); + } + } + } + } +} + +#[test] +fn steps_equal_resize_surface_in_canvas() { + let src = noisy(90, 70, ColorMode::Rgb, SampleType::U8, true); + let canvas = Rect::new(0, 0, 90, 70); + for (sx, sy) in [(0.7, 0.7), (1.9, 1.3)] { + let expected = resize_surface_in_canvas(&src, sx, sy, Resample::Bicubic, canvas); + let (got, _) = drive(ResizeSteps::new(src.clone(), sx, sy, Resample::Bicubic, Some(canvas)), 1); + assert!(got == expected, "scale=({sx}, {sy})"); + } +} + +#[test] +fn steps_take_several_calls_and_report_progress() { + let src = noisy(120, 300, ColorMode::Rgb, SampleType::U8, true); + let mut steps = ResizeSteps::new(src, 0.9, 0.9, Resample::Bicubic, None); + assert_eq!(steps.progress(), 0.0); + let mut seen = Vec::new(); + let mut calls = 0; + let out = loop { + calls += 1; + if let Some(s) = steps.step(&mut || false) { + break s; + } + seen.push(steps.progress()); + }; + // Planning, one call per 64-row band (270 rows: five), and the closing call. + assert!(calls >= 6, "{calls}"); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "progress goes back: {seen:?}"); + assert!(seen.iter().all(|p| (0.0..=1.0).contains(p))); + assert_eq!(out.content_bounds(), Rect::new(0, 0, 108, 270)); +} + +#[test] +fn steps_of_an_empty_surface_are_empty() { + let src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + let expected = resize_surface(&src, 0.5, 0.5, Resample::Bicubic); + let (got, _) = drive(ResizeSteps::new(src, 0.5, 0.5, Resample::Bicubic, None), 1); + assert!(got == expected); +} + +/// A transparent image with distant opaque corners keeps broad resize bounds. Its empty +/// output tiles must be pruned across polls rather than scanning the whole surface at once. +#[test] +fn sparse_resize_final_pruning_yields_between_tile_batches() { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + sparse_resize_final_pruning(sample); + } +} + +fn sparse_resize_final_pruning(sample: SampleType) { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + for (x0, y0) in [(0, 0), (4600, 504)] { + for y in y0..y0 + 8 { + for x in x0..x0 + 8 { + src.write_pixel(x, y, &[1.0, 0.0, 0.0, 1.0]); + } + } + } + let expected = resize_surface(&src, 0.5, 0.5, Resample::Nearest); + let mut steps = ResizeSteps::new(src, 0.5, 0.5, Resample::Nearest, None); + let mut polls = 0; + while !steps.passes.as_slice().is_empty() || steps.run.is_some() { + assert!(steps.step(&mut || false).is_none()); + polls += 1; + assert!(polls < 100, "the resize passes did not finish"); + } + let unpruned = steps.src.tile_count(); + assert!(unpruned >= 9, "the fixture must leave a broad output to scan: {unpruned}"); + let mut closing_polls = 0; + let actual = loop { + closing_polls += 1; + if let Some(out) = steps.step(&mut || false) { + break out; + } + assert!(closing_polls < 100, "the final prune did not finish"); + }; + assert!(closing_polls >= unpruned.div_ceil(4), "{unpruned} tiles were scanned in {closing_polls} closing polls"); + assert!(actual == expected, "stepped pruning must preserve pixels and remove the same empty tiles"); + assert!(actual.tile_count() < unpruned, "the fixture must include default-filled output tiles"); +} diff --git a/crates/algo/src/segment/sky.rs b/crates/algo/src/segment/sky.rs index a54bcf6772..45240fade7 100644 --- a/crates/algo/src/segment/sky.rs +++ b/crates/algo/src/segment/sky.rs @@ -3,8 +3,9 @@ //! 1. At a reduced working resolution every pixel gets a *sky likelihood*: a colour prior //! (blue and bright, or bright and unsaturated for overcast / cloud, or a bright warm glow //! for sunsets) times a smoothness term (sky has little gradient energy). -//! 2. Seeds are likely-sky pixels in the top rows; the sky region grows from them through -//! 4-connected neighbours that stay likely-sky and change colour gradually. +//! 2. Seeds are likely-sky pixels in the top rows, falling back to substantial high-confidence +//! components in the upper half when the top is obstructed. The sky region grows from them +//! through 4-connected neighbours that stay likely-sky and change colour gradually. //! 3. Sky seen through gaps (between branches) is not connected to the top: likely-sky pixels //! that fit a colour model (GMM) of the grown sky are added too. Small holes are filled. //! 4. The binary working-resolution mask is upsampled and refined at full resolution with a @@ -30,22 +31,31 @@ pub struct SkyParams { } impl Default for SkyParams { + /// Default parameters (threshold 50, softness 50). fn default() -> Self { Self { threshold: 50.0, softness: 50.0 } } } +/// Hermite interpolation between `e0` and `e1` with cubic smoothing. #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } +/// Rec. 601 perceptual luminance of an RGB triple. #[inline] fn luma(p: [f32; 3]) -> f32 { 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2] } +/// Euclidean distance between two RGB colours. +#[inline] +fn rgb_dist(a: [f32; 3], b: [f32; 3]) -> f32 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + /// Colour prior: how sky-like a colour is on its own (0–1). pub fn colour_prior(p: [f32; 3]) -> f32 { let [r, g, b] = p; @@ -79,7 +89,7 @@ pub fn likelihood(img: &RgbImage) -> Vec { img.px.iter().zip(&tex).map(|(p, t)| colour_prior(*p) * (1.0 - smooth(0.015, 0.07, *t))).collect() } -/// Binary sky mask at working resolution (`None` when no sky touches the top of the image). +/// Binary sky mask at working resolution (`None` when no plausible sky region is found). pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option> { let (w, h) = (img.w, img.h); if w < 2 || h < 2 { @@ -97,11 +107,22 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option> { stack.push(i); } } + let max_step = 0.12f32; if stack.len() * 20 < top * w { - return None; // less than 5 % of the top edge looks like sky + // An overhang can hide the entire top band. Discard the sparse top seeds before + // looking for more certain, connected sky patches below it. + mask.fill(false); + stack = obstructed_sky_seeds(img, &like, t + 0.1, max_step); + if stack.is_empty() { + return None; + } + for &i in &stack { + if let Some(m) = mask.get_mut(i) { + *m = true; + } + } } // Region growing with a gradual-change constraint. - let max_step = 0.12f32; while let Some(i) = stack.pop() { let (x, y) = (i % w, i / w); let n = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]; @@ -110,8 +131,7 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option> { continue; } let (a, b) = (img.px[i], img.px[j]); - let d = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); - if d < max_step { + if rgb_dist(a, b) < max_step { mask[j] = true; stack.push(j); } @@ -138,6 +158,56 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option> { mask.iter().any(|v| *v).then_some(mask) } +/// Without border evidence, require a connected patch of at least 0.5% of the image (and +/// 16 pixels). Searching only the upper half avoids using water or blue ground as seeds; +/// counting each component separately keeps scattered bright specks from passing the gate. +/// Adjacent pixels must satisfy the same gradual colour constraint (`max_step`) as region growing. +fn obstructed_sky_seeds(img: &RgbImage, like: &[f32], threshold: f32, max_step: f32) -> Vec { + let (w, h) = (img.w, img.h); + let Some(area) = w.checked_mul(h) else { return Vec::new() }; + let Some(upper) = w.checked_mul(h.div_ceil(2)) else { return Vec::new() }; + if img.px.len() < upper { + return Vec::new(); + } + let Some(like) = like.get(..upper) else { return Vec::new() }; + let minimum = area.div_ceil(200).max(16); + let mut seen = vec![false; upper]; + let mut seeds = Vec::new(); + let mut component = Vec::new(); + for (start, &score) in like.iter().enumerate() { + if score <= threshold || seen.get(start).copied().unwrap_or(true) { + continue; + } + component.clear(); + component.push(start); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + let mut cursor = 0; + while let Some(&i) = component.get(cursor) { + cursor += 1; + let x = i % w; + let neighbours = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), i.checked_sub(w), i.checked_add(w).filter(|j| *j < upper)]; + for j in neighbours.into_iter().flatten() { + if like.get(j).is_some_and(|v| *v > threshold) + && let Some(s) = seen.get_mut(j) + && !*s + { + let (a, b) = (img.px[i], img.px[j]); + if rgb_dist(a, b) < max_step { + *s = true; + component.push(j); + } + } + } + } + if component.len() >= minimum { + seeds.extend_from_slice(&component); + } + } + seeds +} + /// A neutral region that also covers most of the bottom rows is a studio or product backdrop, /// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one. fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool { @@ -291,6 +361,89 @@ mod tests { assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1); } + #[test] + fn finds_obstructed_blue_and_overcast_sky_in_disconnected_openings() { + for sky in [[0.35, 0.6, 0.95], [0.85, 0.86, 0.88]] { + // A dark arch with two openings: neither sky component touches an image edge. + let img = + RgbImage::from_fn( + 160, + 100, + |x, y| { + if (15..65).contains(&y) && ((15..65).contains(&x) || (95..145).contains(&x)) { sky } else { [0.12, 0.2, 0.1] } + }, + ); + for threshold in [0.0, 50.0, 100.0] { + let p = SkyParams { threshold, ..SkyParams::default() }; + let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 160, 100), p).expect("sky beneath arch"); + assert!(r.at(40, 30) > 0.9 && r.at(120, 30) > 0.9, "both disconnected openings: {sky:?}, {threshold}"); + assert!(r.at(40, 55) > 0.9, "growth beyond the upper-half seed search"); + assert!(r.at(40, 5) < 0.1, "overhead obstruction"); + assert!(r.at(80, 30) < 0.1, "arch pillar"); + assert!(r.at(40, 85) < 0.1, "ground"); + } + } + } + + #[test] + fn obstructed_fallback_rejects_small_patches_and_lower_half_objects() { + for colour in [[0.35, 0.6, 0.95], [0.9, 0.9, 0.92]] { + let img = RgbImage::from_fn(160, 100, |x, y| { + if ((20..26).contains(&x) && (20..26).contains(&y)) || ((20..140).contains(&x) && (65..95).contains(&y)) { colour } else { [0.12, 0.2, 0.1] } + }); + assert!(sky_mask_low(&img, 50.0).is_none(), "small upper patch and large lower object: {colour:?}"); + } + } + + #[test] + fn obstructed_neutral_backdrop_is_still_rejected() { + let img = RgbImage::from_fn(160, 100, |_, y| if y < 12 { [0.12, 0.2, 0.1] } else { [0.95, 0.95, 0.94] }); + assert!(sky_mask_low(&img, 50.0).is_none()); + } + + #[test] + fn fallback_requires_individually_large_four_connected_components() { + let (w, h) = (80, 80); + let img = RgbImage { w, h, px: vec![[0.35, 0.6, 0.95]; w * h] }; + // Enough total likely pixels to pass the area gate, but no large component. + let mut like: Vec = (0..w * h).map(|i| if i % w % 4 == 0 && i / w % 4 == 0 { 1.0 } else { 0.0 }).collect(); + assert!(obstructed_sky_seeds(&img, &like, 0.5, 0.12).is_empty()); + // Adjacent row ends must not join across the horizontal image boundary. + like.fill(0.0); + for y in 10..20 { + for x in 0..2 { + like[y * w + x] = 1.0; + like[(y - 1) * w + w - 1 - x] = 1.0; + } + } + assert!(obstructed_sky_seeds(&img, &like, 0.5, 0.12).is_empty()); + } + + #[test] + fn obstructed_fallback_excludes_attached_differently_coloured_patch() { + let (w, h) = (160, 100); + // Minimum qualifying component size: area.div_ceil(200).max(16) = 16000 / 200 = 80. + // Sky component is 40x40 = 1600 pixels in rows 15..55, cols 20..60 (qualifies). + // Attached differently coloured patch is 2x5 = 10 pixels in rows 20..25, cols 60..62 (< 80 min). + // Both patches have high likelihood, but their colour distance exceeds max_step (0.12). + let img = RgbImage::from_fn(w, h, |x, y| { + if (15..55).contains(&y) && (20..60).contains(&x) { + [0.35, 0.6, 0.95] + } else if (20..25).contains(&y) && (60..62).contains(&x) { + [0.85, 0.86, 0.88] + } else { + [0.12, 0.2, 0.1] + } + }); + let mask = sky_mask_low(&img, 50.0).expect("sky mask"); + assert!(mask[30 * w + 40], "qualifying blue sky must be in mask"); + for y in 20..25 { + for x in 60..62 { + assert!(!mask[y * w + x], "attached patch at ({x}, {y}) must be excluded from mask"); + } + } + } + /// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky /// reaching the bottom (a photo taken upward) still is. #[test] diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index 75a1a47a56..8db2f1517d 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -13,20 +13,25 @@ //! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the //! lightly blurred CIELAB image from its mean colour. //! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels -//! are definite background); the result is cleaned (small islands dropped). +//! are definite background); the result is cleaned: small islands are dropped unless they lie +//! next to the main part (a head cut off by a neck too thin for the working resolution). //! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted //! to the image's frame marks every pixel it explains as probable background, and the rest //! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground. //! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white //! wall) have low saliency and in step 4 land in the background model, which then explains -//! them away; here they start as foreground and the cut keeps them (#2788). Only what -//! touches the first pass's result survives, so the pass cannot pull in unrelated things. +//! them away; here they start as foreground and the cut keeps them (#2788). Only components +//! touching the first pass's result survive, but attached clutter can survive with them. +//! Where the subject runs out of the picture (a torso cut off by the bottom edge), the frame +//! pixels the rest of the frame cannot explain are left out of the backdrop model and left to +//! the cut; otherwise they would teach it the shirt's colour and be fixed as background. //! 6. Holes are filled only when their colour fits the foreground model better than the //! background's, so a patch of backdrop enclosed by the subject (between an arm and the //! torso) stays open; specks still fill. Then the mask is re-cut at full resolution. //! //! Works well for a distinct object on a comparatively plain background; it is not a semantic -//! segmenter. +//! segmenter. Nearby clutter and detached subject parts with the same geometry cannot be +//! distinguished by the component filter. use std::cmp::Ordering; use std::collections::BinaryHeap; @@ -82,7 +87,7 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) } let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) }; let low: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); - let low = super::clean_mask(&low, w, h, 0.2, 0.0); + let low = keep_main_and_nearby(&low, w, h); if !low.iter().any(|v| *v) { return Ok(None); } @@ -94,39 +99,27 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) /// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model /// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the -/// subject runs out of the picture); the frame is definite background, every other pixel it +/// subject runs out of the picture, and minus what [`subject_at_edge`] finds of the subject +/// there); the rest of the frame is definite background, every other pixel it /// explains well is probable background, and the rest (the blob, and anything the backdrop /// cannot account for, such as a cream shirt on a near-white wall) starts as probable /// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that /// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass. fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result, Gmm, Gmm)>, Cancelled> { let (w, h) = (img.w, img.h); - let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); - for (i, on) in blob.iter().enumerate() { - if *on { - let (x, y) = (i % w, i / w); - (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); - } - } - if x1 < x0 || y1 < y0 { - return Ok(None); - } - let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); - let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin)); - let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1)); + let Some(near) = margin_box(blob, w, h) else { return Ok(None) }; + let (bx0, by0, bx1, by1) = near; let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2); let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h; let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y); - let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect(); - let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) }; - // What the frame itself costs under its own model bounds "explained well". - let mut frame_costs: Vec = samples.iter().map(|p| backdrop.neg_log(*p)).collect(); - frame_costs.sort_by(|a, b| a.total_cmp(b)); - let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) }; + let edge = |i: usize| in_frame(i % w, i / w) && !near_blob(i % w, i / w); + // The subject running out of the picture is neither backdrop to learn nor fixed background. + let released = subject_at_edge(img, blob, frame, near, &edge); + let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !released[*i]).map(|i| img.px[i]).collect(); + let Some((backdrop, limit)) = fit_backdrop(&samples) else { return Ok(None) }; let mut trimap: Vec = (0..w * h) .map(|i| { - let (x, y) = (i % w, i / w); - if in_frame(x, y) && !near_blob(x, y) { + if edge(i) && !released[i] { BG } else if blob[i] || backdrop.neg_log(img.px[i]) > limit { PR_FG @@ -141,6 +134,359 @@ fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Resul Ok(low.iter().any(|v| *v).then_some((low, fg, bg))) } +/// The box around the pixels `blob` marks in a `w` × `h` image, grown on every side by 15% of +/// its longer side (at least four pixels) and clipped to the image, as (x0, y0, x1, y1) +/// inclusive. `None` when `blob` marks nothing. +fn margin_box(blob: &[bool], w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, on) in blob.iter().enumerate() { + if *on { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + if x1 < x0 || y1 < y0 { + return None; + } + let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); + Some((x0.saturating_sub(margin), y0.saturating_sub(margin), (x1 + margin).min(w - 1), (y1 + margin).min(h - 1))) +} + +/// The connected parts of `mask` that make up the subject: those at least a fifth the size of +/// the largest, and smaller ones (not specks) with pixels close to it. A part of the subject the +/// cut separated from the rest, such as a head whose thin neck the working resolution lost, a +/// tail or something held, would otherwise go missing; clutter further away is still dropped. +fn keep_main_and_nearby(mask: &[bool], w: usize, h: usize) -> Vec { + let (lab, sizes) = super::components(mask, w, h); + let Some((main, largest)) = sizes.iter().copied().enumerate().skip(1).max_by_key(|(_, n)| *n) else { return vec![false; w * h] }; + // Bounding boxes per component: (x0, y0, x1, y1). + let mut bbox = vec![(usize::MAX, usize::MAX, 0usize, 0usize); sizes.len()]; + for (i, l) in lab.iter().enumerate() { + if let Some(b) = bbox.get_mut(*l as usize) { + let (x, y) = (i % w, i / w); + *b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + let Some(&(mx0, my0, mx1, my1)) = bbox.get(main) else { return vec![false; w * h] }; + let margin = ((mx1 - mx0 + 1).max(my1 - my0 + 1) as f32 * 0.15).ceil() as usize; + let (nx0, ny0, nx1, ny1) = (mx0.saturating_sub(margin), my0.saturating_sub(margin), mx1 + margin, my1 + margin); + // A part is near when some of its pixels lie within the margin, not merely its box: a thin + // diagonal streak from a corner (a cable, a rail) can have a box that reaches the margin. + let mut near = vec![false; sizes.len()]; + for y in ny0..=ny1.min(h - 1) { + for x in nx0..=nx1.min(w - 1) { + let label = lab.get(y * w + x).copied().unwrap_or(0) as usize; + if let Some(n) = near.get_mut(label) { + *n = true; + } + } + } + let (big, speck) = ((largest as f32 * 0.2).ceil() as usize, ((largest as f32 * 0.005).ceil() as usize).max(4)); + // Only small, nearby components can be vetoed as edge clutter. A strip must be + // narrow relative to the canvas and at least twice as long as it is wide. + let keep: Vec = sizes + .iter() + .zip(&bbox) + .enumerate() + .map(|(label, (&size, &(x0, y0, x1, y1)))| { + if label == 0 { + return false; + } + if label == main || size >= big { + return true; + } + let (width, height) = (x1 - x0 + 1, y1 - y0 + 1); + let nearby = near.get(label).copied().unwrap_or(false); + let horizontal_strip = (x0 == 0 || x1 + 1 == w) && height < h * 12 / 100 && width >= height * 2; + let vertical_strip = (y0 == 0 || y1 + 1 == h) && width < w * 12 / 100 && height >= width * 2; + let thin_edge = horizontal_strip || vertical_strip; + size >= speck && nearby && !thin_edge + }) + .collect(); + lab.iter().map(|l| keep.get(*l as usize).copied().unwrap_or(false)).collect() +} + +#[cfg(test)] +mod component_tests { + use super::{keep_main_and_nearby, margin_box, subject_at_edge}; + use crate::segment::RgbImage; + + #[test] + fn nearby_component_margin_and_size_boundaries() { + // The main 20x20 component has margin ceil(20 * .15) = 3 and a four-pixel floor. + // A 30x30 component raises the floor to ceil(900 * .005) = 5. + for (side, x, count, retained) in [(20, 42, 3, false), (20, 42, 4, true), (20, 43, 4, false), (30, 54, 4, false), (30, 54, 5, true), (30, 55, 5, false)] + { + let (w, h) = (80, 80); + let mut mask = vec![false; w * h]; + for y in 20..20 + side { + for x in 20..20 + side { + mask[y * w + x] = true; + } + } + for y in 25..25 + count { + mask[y * w + x] = true; + } + let result = keep_main_and_nearby(&mask, w, h); + assert!(result[20 * w + 20]); + assert_eq!(result[25 * w + x], retained, "side {side}, x {x}, size {count}"); + } + // A qualifying nearby island is kept whether it is a head or unrelated clutter. + } + + fn rotate(x: usize, y: usize, side: usize, turns: usize) -> (usize, usize) { + let (mut x, mut y) = (x, y); + for _ in 0..turns { + (x, y) = (side - 1 - y, x); + } + (x, y) + } + + #[test] + fn cropped_heads_and_large_limbs_survive_on_every_edge() { + let side = 240; + for turns in 0..4 { + for (main, part) in [((100, 20, 140, 70), (111, 0, 129, 18)), ((80, 70, 120, 120), (122, 90, 240, 100))] { + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [main, part] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + let (x, y) = rotate(part.0, part.1, side, turns); + assert!(result[y * side + x], "edge {turns}, part {part:?}"); + } + } + } + + #[test] + fn small_nearby_elongated_edge_strips_are_excluded() { + let side = 240; + for turns in 0..4 { + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [(70, 80, 170, 180), (112, 0, 120, 70)] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + let (x, y) = rotate(112, 0, side, turns); + assert!(!result[y * side + x], "edge {turns}"); + } + } + + #[test] + fn narrow_edge_parts_matching_subject_are_released_on_every_edge() { + let side = 240; + let frame = 12; + for turns in 0..4 { + for (colour, expected) in [([0.8, 0.2, 0.1], true), ([0.1, 0.2, 0.8], false)] { + let mut img = RgbImage::new(side, side); + img.px.fill([0.5, 0.5, 0.5]); + let mut blob = vec![false; side * side]; + for y in 60..100 { + for x in 100..140 { + let (x, y) = rotate(x, y, side, turns); + blob[y * side + x] = true; + img.px[y * side + x] = [0.8, 0.2, 0.1]; + } + } + for y in 0..frame { + for x in 110..120 { + let (x, y) = rotate(x, y, side, turns); + img.px[y * side + x] = colour; + } + } + let edge = |i: usize| { + let (x, y) = (i % side, i / side); + x < frame || y < frame || x + frame >= side || y + frame >= side + }; + let near = margin_box(&blob, side, side).unwrap(); + let released = subject_at_edge(&img, &blob, frame, near, &edge); + let (x, y) = rotate(110, 0, side, turns); + assert_eq!(released[y * side + x], expected, "edge {turns}, colour {colour:?}"); + } + } + } + + #[test] + fn wide_edge_parts_are_released_only_alongside_the_subject() { + // A dark pole along the left edge, next to the shoulder of a figure that runs off the + // bottom edge, stays backdrop although it runs a long way along that edge. A cream torso + // cut off by the bottom edge below a head (the first pass lost it, so the subject's + // colours do not explain it either) is still released. + let side = 240; + let frame = 12; + // (subject, part, part colour, released), boxes as (x0, y0, x1, y1) exclusive. + let cases = [((10, 100, 110, 240), (0, 0, 8, 240), [0.1, 0.1, 0.1], false), ((100, 40, 140, 80), (60, 228, 180, 240), [0.9, 0.85, 0.7], true)]; + for turns in 0..4 { + for (subject, part, colour, expected) in cases { + let mut img = RgbImage::new(side, side); + img.px.fill([0.5, 0.5, 0.5]); + let mut blob = vec![false; side * side]; + for (x0, y0, x1, y1) in [subject, part] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + let on = (x0, y0) == (subject.0, subject.1); + blob[y * side + x] = on; + img.px[y * side + x] = if on { [0.8, 0.2, 0.1] } else { colour }; + } + } + } + // As in `backdrop_pass`: the frame, minus the blob's box. + let (bx0, by0, bx1, by1) = margin_box(&blob, side, side).unwrap(); + let edge = |i: usize| { + let (x, y) = (i % side, i / side); + let in_frame = x < frame || y < frame || x + frame >= side || y + frame >= side; + in_frame && !((bx0..=bx1).contains(&x) && (by0..=by1).contains(&y)) + }; + let released = subject_at_edge(&img, &blob, frame, (bx0, by0, bx1, by1), &edge); + let (x, y) = rotate(part.0, part.1, side, turns); + assert_eq!(released[y * side + x], expected, "edge {turns}, part {part:?}"); + } + } + } + + #[test] + fn nearby_means_pixels_within_the_margin_not_just_the_box() { + // The main part covers 100..140 × 20..60, so its margin box is 94..=145 × 14..=65. A + // one-pixel diagonal streak from the corner (a cable, a rail) to (100, 100) has a box + // that overlaps the margin box, but none of its pixels lie in it: it is not kept. A + // compact part whose pixels lie inside the margin is. + let side = 240; + let mut mask = vec![false; side * side]; + for y in 20..60 { + for x in 100..140 { + mask[y * side + x] = true; + } + } + for k in 0..=100 { + mask[k * side + k] = true; + } + for y in 30..35 { + for x in 142..146 { + mask[y * side + x] = true; + } + } + let result = keep_main_and_nearby(&mask, side, side); + assert!(result[20 * side + 100]); + assert!(!result[0], "the corner of the streak is kept"); + assert!(!result[100 * side + 100], "the end of the streak is kept"); + assert!(result[30 * side + 142], "the compact part inside the margin is dropped"); + } + + #[test] + fn clutter_away_from_the_subject_or_too_small_is_dropped() { + // The main 40x40 part has margin ceil(40 * .15) = 6 and a speck floor of + // ceil(1600 * .005) = 8 pixels. A 5x5 object just past the margin and a 2x3 one inside + // it are clutter; a 3x3 one inside it is kept. + let side = 240; + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [(100, 100, 140, 140), (147, 110, 152, 115), (110, 143, 112, 146), (120, 143, 123, 146)] { + for y in y0..y1 { + for x in x0..x1 { + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + assert!(result[100 * side + 100]); + assert!(!result[110 * side + 147], "clutter past the margin is kept"); + assert!(!result[143 * side + 110], "a speck inside the margin is kept"); + assert!(result[143 * side + 120], "a part inside the margin is dropped"); + } +} + +/// A colour model of `samples` and the cost above which a pixel is not explained by it: what +/// the samples themselves cost under it (99th percentile), plus a margin. `None` without samples. +fn fit_backdrop(samples: &[[f32; 3]]) -> Option<(Gmm, f32)> { + let model = Gmm::fit(&super::subsample(samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG)?; + let mut costs: Vec = samples.iter().map(|p| model.neg_log(*p)).collect(); + costs.sort_by(|a, b| a.total_cmp(b)); + let limit = costs.get(costs.len() * 99 / 100)? + 4.0; + Some((model, limit)) +} + +/// The pixels of the frame (those `edge` accepts, `frame` wide) that belong to the subject +/// running out of the picture, such as a torso cut off by the bottom edge: pixels of a side +/// (its corners included) that the colours of the frame off that side cannot explain. Without +/// this, such a part trains the backdrop model and is fixed as background, so a cream shirt on a +/// near-white wall is lost. A floor or a table runs up the sides too, so the frame off the +/// bottom knows its colour and it stays backdrop; only a band confined to one side's strip is +/// released (and is then left to the cut). Small patches stay backdrop. Narrow patches are +/// released only if their colour is also explained by the retained subject; wide ones whatever +/// their colour, but only where they run alongside `near` (the blob's box with its margin, as +/// [`margin_box`] gives it): a pole or a door frame along a side next to the subject's shoulder +/// stays backdrop, while a torso the first pass lost still lies below the head. +fn subject_at_edge(img: &super::RgbImage, blob: &[bool], frame: usize, near: (usize, usize, usize, usize), edge: &dyn Fn(usize) -> bool) -> Vec { + let (w, h) = (img.w, img.h); + // Top, bottom, left, right. + let on = |i: usize, s: usize| { + let (x, y) = (i % w, i / w); + [y < frame, y + frame >= h, x < frame, x + frame >= w].get(s).copied().unwrap_or(false) + }; + let subject_samples: Vec<[f32; 3]> = img.px.iter().zip(blob).filter_map(|(pixel, on)| on.then_some(*pixel)).collect(); + let subject_model = fit_backdrop(&subject_samples); + let mut odd = vec![false; w * h]; + for s in 0..4 { + let others: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !on(*i, s)).map(|i| img.px[i]).collect(); + let Some((model, limit)) = fit_backdrop(&others) else { continue }; + for (i, o) in odd.iter_mut().enumerate() { + if !*o && edge(i) && on(i, s) && model.neg_log(img.px[i]) > limit { + *o = true; + } + } + } + // A stray pixel (noise, a dead pixel) is not a subject: only patches of some size count. + let (lab, sizes) = super::components(&odd, w, h); + let min = (frame * frame / 2).max(4); + let mut bounds = vec![(w, h, 0usize, 0usize); sizes.len()]; + for (i, &label) in lab.iter().enumerate() { + if label != 0 + && let Some(b) = bounds.get_mut(label as usize) + { + let (x, y) = (i % w, i / w); + *b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + // Count matching pixels per component so a single coincidental pixel cannot release it. + let mut matching = vec![0usize; sizes.len()]; + if let Some((model, limit)) = subject_model { + for (i, &label) in lab.iter().enumerate() { + if label != 0 + && model.neg_log(img.px[i]) <= limit + && let Some(count) = matching.get_mut(label as usize) + { + *count += 1; + } + } + } + lab.iter() + .map(|l| { + let i = *l as usize; + let Some((&size, &(x0, y0, x1, y1))) = sizes.get(i).zip(bounds.get(i)) else { return false }; + if *l == 0 || size < min { + return false; + } + // Along the edge, a wide patch must overlap the blob's box: beside the subject, `edge` + // leaves out that box, so a pole along a side shows up only above or below it. + let (nx0, ny0, nx1, ny1) = near; + let wide_on_horizontal_edge = (y0 == 0 || y1 + 1 == h) && x1 - x0 + 1 >= 2 * frame && x0 <= nx1 && x1 >= nx0; + let wide_on_vertical_edge = (x0 == 0 || x1 + 1 == w) && y1 - y0 + 1 >= 2 * frame && y0 <= ny1 && y1 >= ny0; + let matches_subject = matching.get(i).is_some_and(|count| *count >= size.div_ceil(2)); + wide_on_horizontal_edge || wide_on_vertical_edge || matches_subject + }) + .collect() +} + /// Per-pixel saliency in `0..=1` (see the module docs). pub fn saliency(img: &super::RgbImage) -> Vec { let (w, h) = (img.w, img.h); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 07f52b815b..6a36bb53cd 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -962,6 +962,148 @@ fn select_subject_leaves_a_background_coloured_gap_open() { assert!(v > 0.9, "IoU {v}"); } +/// A portrait on a light wall shading down the picture: dark hair, a face, a neck and a +/// torso of colour `shirt` running off the bottom edge (as most portraits are cropped). With +/// `floor`, the torso ends on a dark table that fills the bottom quarter, the full width (so it +/// runs up the picture's sides too). Returns (image, truth, the torso). +fn portrait_scene(seed: u64, shirt: [f32; 3], floor: bool) -> (RgbImage, Vec, Vec) { + let (w, h) = (240usize, 200usize); + let mut rng = Rng::new(seed); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut torso = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32, y as f32); + let hair = ((fx - 120.0) / 30.0).powi(2) + ((fy - 52.0) / 27.0).powi(2) <= 1.0; + let face = ((fx - 120.0) / 26.0).powi(2) + ((fy - 66.0) / 32.0).powi(2) <= 1.0; + let neck = (109..131).contains(&x) && (90..112).contains(&y); + // Shoulders widening towards the bottom, wider than the head's box. + let half = 40.0 + (fy - 110.0) * 0.8; + let body = y >= 110 && (fx - 120.0).abs() <= half && (!floor || y < 150); + let table = floor && y >= 150; + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = hair || face || neck || body; + torso[i] = body && !neck; + let wall = 0.95 - 0.07 * fy / h as f32; + img.px[i] = if table { + [0.35, 0.22, 0.12] + } else if face { + [0.88, 0.71, 0.59] + } else if hair { + [0.24, 0.16, 0.12] + } else if neck { + [0.84, 0.67, 0.55] + } else if body { + shirt + } else { + [wall, wall - 0.01, wall - 0.04] + } + .map(|v| (v + n).clamp(0.0, 1.0)); + } + } + (img, truth, torso) +} + +#[test] +fn select_subject_keeps_a_low_contrast_part_that_runs_off_the_edge() { + // A cream shirt on a near-white wall, cut off by the bottom edge: it used to train the + // backdrop model and be fixed as background there, so only the head was selected. + let (img, truth, torso) = portrait_scene(3, [0.92, 0.85, 0.72], false); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let c = coverage(®, &torso, 240, 200); + assert!(c > 0.9, "the torso is part of the subject: covered {c}"); + let v = iou(®, &truth, 240, 200); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_table_along_the_bottom_edge_out() { + // The same figure behind a table that fills the bottom of the picture: the table also runs + // off the edge, but it is not the subject. + let (img, truth, _) = portrait_scene(3, [0.12, 0.2, 0.45], true); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let table: Vec = (0..240 * 200).map(|i| i / 240 >= 150).collect(); + let t = coverage(®, &table, 240, 200); + assert!(t < 0.1, "the table stays unselected: covered {t}"); + let v = iou(®, &truth, 240, 200); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_nearby_single_edge_object_out() { + // A horizontal door-frame piece enters only from the left and stops just short of the + // shoulder. It is large enough to be considered by the edge release heuristic. + let (mut img, truth, _) = low_contrast_figure(7); + let (w, h) = (240, 200); + let mut frame = vec![false; w * h]; + for y in 48..56 { + for x in 0..77 { + frame[y * w + x] = true; + img.px[y * w + x] = [0.32, 0.24, 0.16]; + } + } + let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let subject_coverage = coverage(®, &truth, w, h); + let frame_coverage = coverage(®, &frame, w, h); + assert!(subject_coverage > 0.85, "subject coverage {subject_coverage}"); + assert!(frame_coverage < 0.1, "door-frame coverage {frame_coverage}"); +} + +#[test] +fn select_subject_ignores_a_stray_pixel_at_the_edge() { + // One white pixel in the corner (a dead pixel, a speck of dust) is not the subject running + // out of the picture: it stays unselected and the subject is found as without it. + let canvas = Rect::new(0, 0, 200, 160); + for seed in 1..=8 { + let (mut img, truth) = disc_scene(200, 160, 100.0, 80.0, 45.0, seed); + img.px[0] = [1.0, 1.0, 1.0]; + let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, canvas).unwrap(); + assert!(reg.at(0, 0) < 0.5, "seed {seed}: the stray pixel is selected"); + let v = iou(®, &truth, 200, 160); + assert!(v > 0.9, "seed {seed}: IoU {v}"); + } +} + +#[test] +fn select_subject_keeps_a_small_detached_part_next_to_the_subject() { + // A standing figure whose head is apart from its body (the neck too thin to survive the + // working resolution): the head is under a fifth of the body's size but part of the subject. + let (w, h) = (300usize, 200usize); + let mut rng = Rng::new(9); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut head = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32, y as f32); + let is_head = ((fx - 150.0) / 9.0).powi(2) + ((fy - 40.0) / 11.0).powi(2) <= 1.0; + let body = (128..172).contains(&x) && (55..170).contains(&y); + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = is_head || body; + head[i] = is_head; + img.px[i] = if is_head { + [0.88, 0.7, 0.58] + } else if body { + [0.75, 0.15, 0.15] + } else { + [0.55, 0.72, 0.9] + } + .map(|v| (v + n).clamp(0.0, 1.0)); + } + } + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let c = coverage(®, &head, w, h); + assert!(c > 0.9, "the head is part of the subject: covered {c}"); + let v = iou(®, &truth, w, h); + assert!(v > 0.9, "IoU {v}"); +} + #[test] fn antialias_blends_edge_pixels_with_their_neighbourhood() { // A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth; diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 1e0c1dd149..daf7a2526a 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -778,6 +778,8 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { } mod distance; +#[cfg(test)] +mod ellipse_aa_edge_tests; mod inverse; mod lasso; mod marquee; @@ -925,5 +927,185 @@ pub fn polygon_window(points: &[(f32, f32)], canvas: Rect, window: Rect, anti_al m } +/// Matches candidate colors against a set of selected colors within an $L_\\infty$ +/// tolerance per channel. +/// +/// Rather than checking against a single 4D axis-aligned bounding box (which would +/// falsely include intermediate colors when disparate hues like red and blue are selected), +/// this indexes selected colors in a bounding-volume hierarchy (BVH). Candidate colors +/// outside the expanded bounds of subtrees are pruned in $O(1)$ time. Fully transparent +/// pixels (`a == 0`) match each other whatever their hidden RGB, as in [`magic_wand`]. +#[derive(Clone, Debug)] +pub struct ColorMatcher { + has_transparent: bool, + tree: ColorBvh, + tol: f32, +} + +#[derive(Clone, Debug)] +struct ColorBvh { + points: Vec<[f32; 4]>, + nodes: Vec, +} + +#[derive(Clone, Debug)] +enum ColorNode { + Leaf { start: usize, end: usize }, + Branch { min: [f32; 4], max: [f32; 4], left: usize, right: usize }, +} + +impl ColorBvh { + fn build(mut points: Vec<[f32; 4]>) -> Result { + let capacity = Self::node_count(points.len()); + let mut nodes = Self::reserve_nodes(capacity)?; + if !points.is_empty() { + Self::build_nodes(&mut points, 0, &mut nodes); + } + Ok(Self { points, nodes }) + } + + fn node_count(len: usize) -> usize { + if len == 0 { + 0 + } else if len <= 16 { + 1 + } else { + 1 + Self::node_count(len / 2) + Self::node_count(len - len / 2) + } + } + + fn reserve_nodes(capacity: usize) -> Result, &'static str> { + let mut nodes = Vec::new(); + nodes.try_reserve_exact(capacity).map_err(|_| "not enough memory to index selection colors")?; + Ok(nodes) + } + + // Reserve the exact node count before recursion so pushes cannot allocate. + fn build_nodes(points: &mut [[f32; 4]], start: usize, nodes: &mut Vec) -> usize { + if points.len() <= 16 { + let index = nodes.len(); + nodes.push(ColorNode::Leaf { start, end: start + points.len() }); + return index; + } + let (mut min, mut max) = ([f32::MAX; 4], [f32::MIN; 4]); + for p in points.iter() { + for c in 0..4 { + min[c] = min[c].min(p[c]); + max[c] = max[c].max(p[c]); + } + } + let mut axis = 0; + for c in 1..4 { + if max[c] - min[c] > max[axis] - min[axis] { + axis = c; + } + } + let mid = points.len() / 2; + points.select_nth_unstable_by(mid, |a, b| a[axis].total_cmp(&b[axis])); + let (left_points, right_points) = points.split_at_mut(mid); + let left = Self::build_nodes(left_points, start, nodes); + let right = Self::build_nodes(right_points, start + mid, nodes); + let index = nodes.len(); + nodes.push(ColorNode::Branch { min, max, left, right }); + index + } + + fn matches_node(&self, index: usize, q: &[f32; 4], tol: f32) -> bool { + match self.nodes.get(index) { + Some(ColorNode::Leaf { start, end }) => { + self.points.get(*start..*end).is_some_and(|pts| pts.iter().any(|p| (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6))) + } + Some(ColorNode::Branch { min, max, left, right }) => { + for c in 0..4 { + let d = if q[c] < min[c] { + min[c] - q[c] + } else if q[c] > max[c] { + q[c] - max[c] + } else { + 0.0 + }; + if d > tol + 1e-6 { + return false; + } + } + self.matches_node(*left, q, tol) || self.matches_node(*right, q, tol) + } + None => false, + } + } + + fn matches(&self, q: &[f32; 4], tol: f32) -> bool { + self.nodes.len().checked_sub(1).is_some_and(|root| self.matches_node(root, q, tol)) + } +} + +/// Bytes of memory the index is charged per distinct colour: the dedupe set entry and its +/// table slack, the colour in the point list, and its share of the tree nodes. It is an +/// approximate average, not a worst case: while the set rehashes or the point list doubles, +/// the peak runs to roughly 70–90 bytes per colour. +const COLOR_INDEX_BYTES: usize = 64; + +/// The most memory the index may be charged. It scales with the distinct colours rather than the +/// canvas, so a noisy 24 MP photo, or a float document where every pixel is its own colour, +/// still builds; it only keeps the index within the sampled pixels' own budget. On 32-bit +/// (the web build) 1 GiB still admits every distinct colour of a 24 MP photo. +const COLOR_INDEX_BUDGET: usize = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + +impl ColorMatcher { + /// Creates a color matcher from selected RGBA colors and an $L_\\infty$ tolerance in $[0, 1]$. + pub fn new(selected_colors: impl IntoIterator, tol: f32) -> Result { + Self::with_budget(selected_colors, tol, COLOR_INDEX_BUDGET) + } + + // Bound the index by `budget` bytes, charged at `COLOR_INDEX_BYTES` per distinct colour, and + // fail without altering the selection. + fn with_budget(selected_colors: impl IntoIterator, tol: f32, budget: usize) -> Result { + let limit = budget / COLOR_INDEX_BYTES; + let mut has_transparent = false; + let mut seen = std::collections::HashSet::new(); + let mut opaque_or_semi = Vec::new(); + for c in selected_colors { + // A transparent color also stays in the tree: a nearly transparent query is compared + // by its RGB and alpha like any other. + has_transparent |= c[3] == 0.0; + let norm = |x: f32| if x == 0.0 { 0.0f32.to_bits() } else { x.to_bits() }; + let key = [norm(c[0]), norm(c[1]), norm(c[2]), norm(c[3])]; + if !seen.contains(&key) { + if seen.len() >= limit { + return Err("the selection contains too many distinct colors to match; select a smaller area"); + } + seen.try_reserve(1).map_err(|_| "not enough memory to index selection colors")?; + opaque_or_semi.try_reserve(1).map_err(|_| "not enough memory to index selection colors")?; + seen.insert(key); + opaque_or_semi.push(c); + } + } + drop(seen); + opaque_or_semi.sort_unstable_by(|a, b| { + a[0].total_cmp(&b[0]).then_with(|| a[1].total_cmp(&b[1])).then_with(|| a[2].total_cmp(&b[2])).then_with(|| a[3].total_cmp(&b[3])) + }); + opaque_or_semi.dedup_by(|a, b| a == b); + // The tree keeps this list; give back the slack its doubling left. + opaque_or_semi.shrink_to_fit(); + + Ok(Self { has_transparent, tree: ColorBvh::build(opaque_or_semi)?, tol: tol.max(0.0) }) + } + + /// Returns `true` if no colors (including transparent pixels) were provided. + #[inline] + pub fn is_empty(&self) -> bool { + !self.has_transparent && self.tree.nodes.is_empty() + } + + /// Returns `true` if `q` falls within `tol` of any selected color. + #[inline] + pub fn matches(&self, q: [f32; 4]) -> bool { + if q[3] == 0.0 && self.has_transparent { + return true; + } + self.tree.matches(&q, self.tol) + } +} + #[cfg(test)] mod tests; diff --git a/crates/algo/src/selection/ellipse_aa_edge_tests.rs b/crates/algo/src/selection/ellipse_aa_edge_tests.rs new file mode 100644 index 0000000000..7b3e08cfa0 --- /dev/null +++ b/crates/algo/src/selection/ellipse_aa_edge_tests.rs @@ -0,0 +1,100 @@ +//! The Elliptical Marquee's anti-aliased edge against the reference measurement (#11391): +//! document 400 × 350, ellipse 100,100–300,250, the patch x 127–132, y 119–124 on the 45° part +//! of its top-left edge. Grids hold what a black fill of the selection reads there (255 outside, +//! 0 fully selected), as the issue's tables do. + +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +use super::ellipse_surface; + +const AREA: Rect = Rect { x0: 0, y0: 0, x1: 400, y1: 350 }; +const ELLIPSE: Rect = Rect { x0: 100, y0: 100, x1: 300, y1: 250 }; +const PATCH: (i32, i32) = (127, 119); + +/// The reference with Anti-alias on. +const SOFT: [[u8; 6]; 6] = [ + [255, 255, 255, 255, 255, 152], + [255, 255, 255, 212, 106, 0], + [255, 255, 172, 64, 0, 0], + [247, 137, 26, 0, 0, 0], + [105, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], +]; + +/// Anti-alias off: the reference and PhotoCraft before #11391 agree. +const HARD: [[u8; 6]; 6] = + [[255, 255, 255, 255, 255, 255], [255, 255, 255, 255, 0, 0], [255, 255, 255, 0, 0, 0], [255, 255, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]; + +/// The grey a black fill of selection `s` gives at `(x, y)` on white. +fn grey(s: &Surface, x: i32, y: i32) -> u8 { + let v = s.pixel(x, y).first().copied().unwrap_or(0.0); + (255.0 - v * 255.0).round() as u8 +} + +fn patch(s: &Surface) -> [[u8; 6]; 6] { + std::array::from_fn(|j| std::array::from_fn(|i| grey(s, PATCH.0 + i as i32, PATCH.1 + j as i32))) +} + +#[test] +fn the_anti_aliased_patch_matches_the_reference_within_three_levels() { + let got = patch(&ellipse_surface(ELLIPSE, AREA, true)); + for (j, (row, want)) in got.iter().zip(&SOFT).enumerate() { + for (i, (g, w)) in row.iter().zip(want).enumerate() { + assert!(g.abs_diff(*w) <= 3, "{},{}: {g} vs reference {w}\n{got:?}", PATCH.0 + i as i32, PATCH.1 + j as i32); + } + } +} + +#[test] +fn the_other_three_quadrants_mirror_the_measured_one() { + let s = ellipse_surface(ELLIPSE, AREA, true); + // Symmetric about x = 200 and y = 175: pixel x mirrors to 399 - x, y to 349 - y. + for y in PATCH.1..PATCH.1 + 6 { + for x in PATCH.0..PATCH.0 + 6 { + let here = s.pixel(x, y); + assert_eq!(s.pixel(399 - x, y), here, "{x},{y} mirrored across x"); + assert_eq!(s.pixel(x, 349 - y), here, "{x},{y} mirrored across y"); + assert_eq!(s.pixel(399 - x, 349 - y), here, "{x},{y} mirrored through the centre"); + } + } + // A few partial pixels, so the mirror is not of 0s and 1s alone. + assert_eq!(grey(&s, 399 - 129, 122), grey(&s, 129, 122)); + assert!((1..255).contains(&grey(&s, 399 - 129, 349 - 121))); +} + +#[test] +fn the_hard_edge_is_unchanged() { + assert_eq!(patch(&ellipse_surface(ELLIPSE, AREA, false)), HARD); +} + +/// Fully inside reads exactly 1 and outside exactly 0, so Add / Subtract / Intersect combine as +/// before; the edge is soft only in a band at most √2 px wide. +#[test] +fn inside_is_exactly_selected_and_outside_exactly_empty() { + let s = ellipse_surface(ELLIPSE, AREA, true); + // Every pixel whose square lies inside, here all of the axes but the end pixels (whose + // corners poke out by a sliver). + for x in 101..299 { + assert_eq!(s.pixel(x, 175), vec![1.0], "{x},175 on the horizontal axis"); + } + for y in 101..249 { + assert_eq!(s.pixel(200, y), vec![1.0], "200,{y} on the vertical axis"); + } + for (x, y) in [(100, 175), (299, 175), (200, 100), (200, 249)] { + assert!(s.pixel(x, y)[0] > 0.99, "{x},{y}: the axis ends are all but full"); + } + for (x, y) in [(99, 175), (300, 175), (200, 99), (200, 250), (100, 100), (299, 249), (0, 0)] { + assert_eq!(s.pixel(x, y), vec![0.0], "{x},{y} outside"); + } + let partial = (0..350).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| (1..255).contains(&grey(&s, x, y))).count(); + let (hard_in, soft_in) = { + let h = ellipse_surface(ELLIPSE, AREA, false); + let sum = |s: &Surface| s.read_region(AREA).iter().map(|v| f64::from(*v)).sum::(); + (sum(&h), sum(&s)) + }; + // The perimeter (≈ 556 px) times a band 1 to √2 px across, and the hard ellipse's area. + assert!((556..787).contains(&partial), "{partial} partial pixels"); + let exact = std::f64::consts::PI * 100.0 * 75.0; + assert!((soft_in - exact).abs() < 0.002 * exact && (hard_in - exact).abs() < 0.002 * exact, "{soft_in} {hard_in} vs {exact}"); +} diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index 7afd788fdc..6afff4ab50 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -30,14 +30,22 @@ fn decode8(b: u8) -> f32 { } /// An ellipse inscribed in a rectangle, tested in `f32` exactly as the Elliptical Marquee -/// always has (so its anti-aliased edge keeps the same bytes). +/// always has (so its hard edge keeps the same bytes). struct Ellipse { cx: f32, cy: f32, rx: f32, ry: f32, + /// At most 3 px across: the anti-aliased edge keeps its 4×4 sub-samples (#11391 measured + /// only larger ellipses). + small: bool, + /// `1 / rx`, `1 / ry`, `1 / rx²` and `1 / ry²` for [`Ellipse::ramp`]. + inv: [f64; 4], } +/// The widest an ellipse is across and still [`Ellipse::small`]. +const SMALL: u32 = 3; + impl Ellipse { fn new(r: Rect) -> Self { Self { @@ -45,42 +53,82 @@ impl Ellipse { cy: (i64::from(r.y0) + i64::from(r.y1)) as f32 / 2.0, rx: r.width() as f32 / 2.0, ry: r.height() as f32 / 2.0, + small: r.width().min(r.height()) <= SMALL, + inv: { + let (rx, ry) = (f64::from(r.width()) / 2.0, f64::from(r.height()) / 2.0); + [1.0 / rx, 1.0 / ry, 1.0 / (rx * rx), 1.0 / (ry * ry)] + }, } } + /// The anti-aliased edge's coverage of pixel `(x, y)`, as Photoshop draws it (#11391): with + /// `F` the normalised radius at the pixel's centre, `(F - 1) / (|∂F/∂x| + |∂F/∂y|)` is the + /// centre's distance to the edge measured in the pixel's own width across the edge, and the + /// coverage ramps linearly over that width. An axis-aligned edge gets the pixel's exact + /// area; a 45° edge ramps over √2 px. 1 exactly at and around the centre. + #[inline] + fn ramp(&self, x: i32, y: i32) -> f64 { + let [irx, iry, irx2, iry2] = self.inv; + let (dx, dy) = (f64::from(x) + 0.5 - f64::from(self.cx), f64::from(y) + 0.5 - f64::from(self.cy)); + let (u, v) = (dx * irx, dy * iry); + let f = (u * u + v * v).sqrt(); + // `f · (|∂F/∂x| + |∂F/∂y|)`; 0 only at the centre itself. + let across = dx.abs() * irx2 + dy.abs() * iry2; + if across <= 0.0 { + return 1.0; + } + (0.5 - (f - 1.0) * f / across).clamp(0.0, 1.0) + } + #[inline] fn inside(&self, x: f32, y: f32) -> bool { let (dx, dy) = ((x - self.cx) / self.rx, (y - self.cy) / self.ry); dx * dx + dy * dy <= 1.0 } - /// Whether every point of pixel `(x, y)` that the coverage samples is inside: its four - /// corners (anti-aliased) or its centre. Along a row the pixels where this holds form one - /// run: each test is monotonic in the distance from the centre (IEEE rounding is monotonic). + /// Whether pixel `(x, y)` is fully selected: its centre is inside (hard edge), its four + /// corners are (a small anti-aliased ellipse), or its [`Ellipse::ramp`] is 1. Along a row + /// the pixels where this holds form one run: each test grows with the distance from the + /// centre; [`row_span`] checks both ends of the run. #[inline] fn full(&self, x: i32, y: i32, aa: bool) -> bool { let (fx, fy) = (x as f32, y as f32); if !aa { return self.inside(fx + 0.5, fy + 0.5); } - self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0) + if self.small { + return self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0); + } + self.ramp(x, y) >= 1.0 } - /// Coverage of pixel `(x, y)` in sixteenths: 16 when [`Ellipse::full`], else (anti-aliased) - /// the number of its 4×4 sub-samples inside. + /// The byte pixel `(x, y)` stores: `one` when [`Ellipse::full`], 0 outside a hard edge, and + /// otherwise the fraction of its 4×4 sub-samples inside (small) or its [`Ellipse::ramp`]. #[inline] - fn coverage(&self, x: i32, y: i32, aa: bool) -> u8 { + fn coverage(&self, x: i32, y: i32, aa: bool, enc: &[u8; 17]) -> u8 { + let one = enc[16]; + if aa && !self.small { + // One ramp: a full pixel's is 1, which encodes as `one`. + // `encode8`'s rounding, inline: this runs for every edge pixel. + return (self.ramp(x, y) as f32 * 255.0 + 0.5) as u8; + } if self.full(x, y, aa) { - return 16; + return one; } if !aa { return 0; } let (fx, fy) = (x as f32, y as f32); - (0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count() as u8 + let n = (0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count(); + enc.get(n).copied().unwrap_or(one) } } +/// The bytes for 0/16 … 16/16 coverage. +fn sixteenths() -> [u8; 17] { + std::array::from_fn(|n| encode8(n as f32 / 16.0)) +} + /// One row of the ellipse: nothing outside `lo..hi`, fully selected in `full_lo..full_hi`, and /// the edge pixels in between (`lo..full_lo`, `full_hi..hi`) need the exact coverage test. #[derive(Clone, Copy)] @@ -94,7 +142,7 @@ struct RowSpan { /// Bounds row `y` of `e` within `cut.x0..cut.x1` from the exact (f64) ellipse, widened by a /// margin that covers the f32 tests' rounding. The bounds are then checked with the exact tests /// (the fully selected run at both ends, nothing just outside); any doubt evaluates the whole row. -fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan { +fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool, enc: &[u8; 17]) -> RowSpan { let exact = RowSpan { lo: cut.x0, full_lo: cut.x1, full_hi: cut.x1, hi: cut.x1 }; let (cx, cy, rx, ry) = (f64::from(e.cx), f64::from(e.cy), f64::from(e.rx), f64::from(e.ry)); // Every point the tests sample in this row lies in `y..y + 1`. @@ -118,14 +166,15 @@ fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan { if inner < 0.0 || full_lo >= full_hi { (full_lo, full_hi) = (hi, hi); } - let empty = |x: i32| e.coverage(x, y, aa) == 0; + let empty = |x: i32| e.coverage(x, y, aa, enc) == 0; let ok = (lo <= cut.x0 || empty(lo - 1)) && (hi >= cut.x1 || empty(hi)) && (full_lo >= full_hi || (e.full(full_lo, y, aa) && e.full(full_hi - 1, y, aa))); if ok { RowSpan { lo, full_lo, full_hi, hi } } else { exact } } /// The Elliptical Marquee's shape: the ellipse inscribed in `r`, cut to `clip`, as a GRAY8 -/// selection. Anti-aliased edge pixels hold the fraction of their 4×4 sub-samples inside; -/// without anti-aliasing a pixel is selected when its centre is inside. +/// selection. Anti-aliased edge pixels ramp across the edge as in Photoshop ([`Ellipse::ramp`]; +/// an ellipse at most 3 px across keeps the fraction of 4×4 sub-samples inside); without +/// anti-aliasing a pixel is selected when its centre is inside. pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface { let mut s = Surface::new(PixelFormat::GRAY8); let cut = r.intersect(&clip); @@ -133,9 +182,9 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface { return s; } let e = Ellipse::new(r); - let enc: [u8; 17] = std::array::from_fn(|n| encode8(n as f32 / 16.0)); + let enc = sixteenths(); let one = encode8(1.0); - let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias)); + let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias, &enc)); let span = |y: i32| rows.get(usize::try_from(y - cut.y0).ok()?); let full = s.solid_tile(&[1.0]); let blank = s.solid_tile(&[0.0]); @@ -159,11 +208,11 @@ pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface { d.fill(one); } for x in (sp.lo.max(tr.x0)..sp.full_lo.min(tr.x1)).chain(sp.full_hi.max(tr.x0)..sp.hi.min(tr.x1)) { - let n = e.coverage(x, y, anti_alias); + let n = e.coverage(x, y, anti_alias, &enc); if n > 0 && let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(x)) { - *d = enc.get(usize::from(n)).copied().unwrap_or(one); + *d = n; } } } @@ -302,7 +351,8 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) mod tests { use super::*; - /// The Elliptical Marquee as it was: every pixel tested and written one at a time. + /// The Elliptical Marquee one pixel at a time: every pixel tested and written on its own (the + /// anti-aliased ramp through [`Ellipse::ramp`]). fn old_ellipse(r: Rect, area: Rect, aa: bool) -> Surface { let cut = r.intersect(&area); let mut shape = Surface::new(PixelFormat::GRAY8); @@ -318,6 +368,8 @@ mod tests { let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count(); let cov = if !aa { if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 } + } else if r.width().min(r.height()) > SMALL { + Ellipse::new(r).ramp(x, y) as f32 } else if corners == 4 { 1.0 } else { diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 226a4c5a68..4e7bdcbe06 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -1,3 +1,95 @@ +#[test] +fn color_matcher_bvh_and_transparency() { + let red = [1.0, 0.0, 0.0, 1.0]; + let blue = [0.0, 0.0, 1.0, 1.0]; + let matcher = ColorMatcher::new([red, blue], 10.0 / 255.0).unwrap(); + assert!(!matcher.is_empty()); + + assert!(matcher.matches([0.98, 0.02, 0.0, 1.0])); + assert!(matcher.matches([0.0, 0.02, 0.98, 1.0])); + + assert!(!matcher.matches([0.0, 0.0, 0.0, 1.0])); + assert!(!matcher.matches([0.0, 1.0, 0.0, 1.0])); + assert!(!matcher.matches([1.0, 1.0, 0.0, 1.0])); + assert!(!matcher.matches([0.5, 0.0, 0.5, 1.0])); + assert!(!matcher.matches([0.0, 0.0, 0.0, 0.0])); + + let matcher_trans = ColorMatcher::new([red, [0.0, 0.0, 0.0, 0.0]], 10.0 / 255.0).unwrap(); + assert!(matcher_trans.matches([0.0, 0.0, 0.0, 0.0])); + assert!(matcher_trans.matches([0.5, 0.2, 0.1, 0.0])); + assert!(matcher_trans.matches([0.98, 0.0, 0.0, 1.0])); + assert!(!matcher_trans.matches([0.0, 1.0, 0.0, 1.0])); + + let empty = ColorMatcher::new([], 10.0 / 255.0).unwrap(); + assert!(empty.is_empty()); + assert!(!empty.matches(red)); + + // Only an exactly transparent pair ignores RGB: a faint pixel is compared like any other. + let faint = [0.0, 0.0, 0.0, 1e-6]; + assert!(!matcher_trans.matches([0.5, 0.2, 0.1, 1e-6])); + assert!(matcher_trans.matches(faint)); + let matcher_faint = ColorMatcher::new([faint], 10.0 / 255.0).unwrap(); + assert!(matcher_faint.matches([0.0, 0.0, 0.0, 0.0])); + assert!(!matcher_faint.matches([0.5, 0.0, 0.0, 0.0])); +} + +#[test] +fn color_matcher_preserves_distinct_faint_32f_colors_at_zero_tolerance() { + let zero = [0.0, 0.0, 0.0, 0.0]; + let faint = [0.0, 0.0, 0.0, 8e-6]; + let matcher = ColorMatcher::new([zero, faint], 0.0).unwrap(); + assert!(matcher.matches(zero)); + assert!(matcher.matches(faint)); + assert!(!matcher.matches([0.0, 0.0, 0.0, 2e-5])); +} + +#[test] +fn color_matcher_coalesces_exact_repeated_colors() { + let red = [1.0, 0.0, 0.0, 1.0]; + let transparent = [0.0, 0.0, 0.0, 0.0]; + let colors = std::iter::repeat_n(red, 10_000).chain(std::iter::repeat_n(transparent, 10_000)); + let matcher = ColorMatcher::new(colors, 0.0).unwrap(); + assert!(matcher.matches(red)); + assert!(matcher.matches(transparent)); + assert!(!matcher.matches([0.0, 1.0, 0.0, 1.0])); +} + +#[test] +fn color_matcher_splits_colors_that_are_all_nearly_equal() { + // Distinct colours a few ulps apart: no spread to split on usefully, but still no leaf past 16. + let mut c = 0.5f32; + let selected: Vec<[f32; 4]> = (0..200) + .map(|_| { + c = c.next_up(); + [c, 0.25, 0.75, 1.0] + }) + .collect(); + let matcher = ColorMatcher::new(selected.iter().copied(), 0.0).unwrap(); + assert!(matcher.tree.nodes.iter().any(|node| matches!(node, ColorNode::Branch { .. })), "more than 16 colours must not share one leaf"); + for p in &selected { + assert!(matcher.matches(*p)); + } + assert!(!matcher.matches([0.6, 0.25, 0.75, 1.0])); +} + +#[test] +fn color_matcher_tree_matches_brute_force() { + // More than 16 distinct colors, so the tree has nodes and not just one leaf. + let selected: Vec<[f32; 4]> = (0..200) + .map(|i| { + let t = i as f32 / 199.0; + [t, (t * 7.0).fract(), (t * 13.0).fract(), if i % 25 == 0 { 0.0 } else { 1.0 }] + }) + .collect(); + let tol = 0.05; + let matcher = ColorMatcher::new(selected.iter().copied(), tol).unwrap(); + for i in 0..2000 { + let t = i as f32 / 1999.0; + let q = [(t * 3.0).fract(), (t * 5.0).fract(), (t * 11.0).fract(), if i % 7 == 0 { 0.0 } else { 1.0 }]; + let want = selected.iter().any(|p| (q[3] == 0.0 && p[3] == 0.0) || (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6)); + assert_eq!(matcher.matches(q), want, "{q:?}"); + } +} use super::*; fn img(w: usize, h: usize, f: impl Fn(usize, usize) -> [f32; 4]) -> Vec<[f32; 4]> { @@ -655,6 +747,45 @@ fn combine_region_matches_combine() { assert!(combine_region(None, None, SelectionMode::Subtract).is_none()); } +#[test] +fn color_matcher_bounds_tightly_clustered_leaves() { + let colors: Vec<_> = (0..1000).map(|i| [i as f32 * 1e-10, 0.0, 0.0, 1.0]).collect(); + let matcher = ColorMatcher::new(colors.iter().copied(), 0.0).unwrap(); + for node in &matcher.tree.nodes { + if let ColorNode::Leaf { start, end } = node { + assert!(end - start <= 16); + } + } + assert!(matcher.matches(colors[999])); + assert!(!matcher.matches([0.1, 0.0, 0.0, 1.0])); +} + +#[test] +fn color_matcher_budget_fires_at_the_distinct_color_it_is_sized_for() { + let colors: Vec<_> = (0..5).map(|i| [i as f32 / 8.0, 0.0, 0.0, 1.0]).collect(); + // Room for exactly `n` distinct colours: the n-th still fits, the (n + 1)-th does not. + for n in 1..=5 { + let budget = n * COLOR_INDEX_BYTES; + assert!(ColorMatcher::with_budget(colors[..n].iter().copied(), 0.0, budget).is_ok(), "{n} colours in {budget} bytes"); + if n < 5 { + assert!(ColorMatcher::with_budget(colors[..=n].iter().copied(), 0.0, budget).is_err(), "{} colours in {budget} bytes", n + 1); + } + } + // A budget short of one colour's charge admits none, and duplicates are never charged. + assert!(ColorMatcher::with_budget([colors[0]], 0.0, COLOR_INDEX_BYTES - 1).is_err()); + let repeated = std::iter::repeat_n(colors[0], 1000); + assert!(ColorMatcher::with_budget(repeated, 0.0, COLOR_INDEX_BYTES).is_ok()); +} + +#[test] +fn color_matcher_default_budget_admits_a_noisy_float_canvas() { + // Every pixel of a 2000x1000 float canvas is its own colour: past the old 1M-colour cap. + let colors = (0..2_000_000u32).map(|i| [i as f32 * 1e-7, (i % 1013) as f32 * 1e-3, 0.5, 1.0]); + let matcher = ColorMatcher::new(colors, 0.0).unwrap(); + assert!(matcher.matches([1_999_999.0 * 1e-7, (1_999_999 % 1013) as f32 * 1e-3, 0.5, 1.0])); + assert!(!matcher.matches([0.9, 0.9, 0.9, 1.0])); +} + #[test] fn polygon_window_keeps_full_canvas_fractional_coverage() { for canvas in [Rect::new(0, 0, 110_000, 12), Rect::new(-123, -7, 109_877, 5)] { @@ -687,3 +818,12 @@ fn feather_leaves_everything_beyond_its_reach_at_zero() { assert!(out[reach + 1..].iter().all(|v| *v == 0.0), "radius {radius}: nonzero past reach {reach}"); } } + +#[test] +fn color_matcher_tree_reservation_failure_is_reported() { + assert!(ColorBvh::reserve_nodes(usize::MAX).is_err()); + for n in 0..1024 { + let tree = ColorBvh::build(vec![[0.0; 4]; n]).unwrap(); + assert_eq!(tree.nodes.len(), ColorBvh::node_count(n)); + } +} diff --git a/crates/algo/src/shape_blur.rs b/crates/algo/src/shape_blur.rs index 5b262afdcb..d13d7b5dfe 100644 --- a/crates/algo/src/shape_blur.rs +++ b/crates/algo/src/shape_blur.rs @@ -2,6 +2,7 @@ //! in the same span order as the scalar implementation, but contiguous slices //! let the compiler vectorize without architecture-specific code or unsafe. use photocraft_geom::Rect; +use photocraft_raster::Interrupt; use crate::{ BlurShape, Ctx, @@ -16,20 +17,78 @@ pub(crate) fn radius(r: f32) -> f32 { if r.is_finite() { r.clamp(0.0, 1000.0) } else { 0.0 } } +/// The most coverage runs a custom kernel of side `size` may hold. Every output row adds one +/// window per run, so a fragmented mask (alternating or ever-changing coverage, up to `size²` +/// runs) would cost as much as a direct convolution. Smooth anti-aliased shapes need about one +/// run per edge pixel plus one per interior stretch (a radius-1000 disc about 8k, 4 per row); +/// this allows 64 per row on average and never limits kernels of up to 256 × 256. +pub(crate) fn max_spans(size: usize) -> usize { + size.saturating_mul(64).max(1 << 16) +} + pub(crate) struct Prepared { spans: Spans, + weights: Option>, } impl Prepared { pub(crate) fn new(r: f32, shape: BlurShape) -> Self { - Self { spans: Spans::new(radius(r), shape_inside(shape)) } + Self { spans: Spans::new(radius(r), shape_inside(shape)), weights: None } + } + + /// Coverage of a custom shape, centred in a (2 ceil(radius) + 1) square. `None` for a + /// malformed or empty mask, a cancelled `ctl`, or one too fragmented to convolve (see + /// [`max_spans`]). + pub(crate) fn custom(r: f32, mask: &Image, ctl: &Interrupt) -> Option { + let ri = radius(r).ceil() as i32; + let size = usize::try_from(ri.checked_mul(2)?.checked_add(1)?).ok()?; + if mask.ch != 1 + || mask.rect.width() as usize != size + || mask.rect.height() as usize != size + || mask.data.len() != size.checked_mul(size)? + || mask.data.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) + { + return None; + } + let mut rows = Vec::new(); + let mut weights = Vec::new(); + let mut count = 0.0; + let limit = max_spans(size); + for (y, row) in mask.data.chunks_exact(size).enumerate() { + if ctl.cancelled() || rows.len() > limit { + return None; + } + rows.try_reserve(size).ok()?; + weights.try_reserve(size).ok()?; + let mut x = 0; + while let Some(&coverage) = row.get(x) { + let start = x; + x += 1; + while row.get(x) == Some(&coverage) { + x += 1; + } + if coverage > 0.0 { + // Convolution samples source(x-offset), so an impulse draws the selected + // kernel in its original orientation rather than a mirrored shape. + rows.push((ri - y as i32, ri - x as i32 + 1, ri - start as i32)); + weights.push(f64::from(coverage)); + count += (x - start) as f64 * f64::from(coverage); + } + } + } + if rows.len() > limit { + return None; + } + (count > 0.0).then_some(Self { spans: Spans { rows, count }, weights: Some(weights) }) } pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec { - self.checked_run(src, out, ctx).unwrap_or_default() + self.checked_run(src, out, ctx, &Interrupt::NONE).unwrap_or_default() } - pub(crate) fn checked_run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Option> { + /// `None` for malformed input, an allocation that does not fit, or a cancelled `ctl` + /// (polled per input and output row). + pub(crate) fn checked_run(&self, src: &Image, out: Rect, ctx: &Ctx, ctl: &Interrupt) -> Option> { let n = src.ch; if n == 0 || n > crate::fxutil::MAXC || out.is_empty() { return Some(Vec::new()); @@ -51,13 +110,16 @@ impl Prepared { if prefix_len > MAX_SAMPLES || output_len > MAX_SAMPLES { return None; } - let prefix = Prefix::build(src, ctx, n, stride, prefix_len)?; + let prefix = Prefix::build(src, ctx, n, stride, prefix_len, ctl)?; let mut result = Vec::new(); result.try_reserve_exact(output_len).ok()?; let mut acc = Vec::new(); acc.try_reserve_exact(row_len).ok()?; acc.resize(row_len, 0.0f64); for y in out.y0..out.y1 { + if ctl.cancelled() { + return None; + } self.accumulate_row(&prefix, src, out, y, &mut acc)?; for px in acc.chunks_exact(n) { let mut sample = [0.0f32; crate::fxutil::MAXC]; @@ -76,7 +138,7 @@ impl Prepared { fn accumulate_row(&self, prefix: &Prefix, src: &Image, out: Rect, y: i32, acc: &mut [f64]) -> Option<()> { let x = i64::from(out.x0) - i64::from(src.rect.x0); acc.fill(0.0); - for &(dy, a, b) in &self.spans.rows { + for (index, &(dy, a, b)) in self.spans.rows.iter().enumerate() { let yy = i64::from(y) - i64::from(src.rect.y0) + i64::from(dy); let Ok(yy) = usize::try_from(yy) else { continue; @@ -85,7 +147,8 @@ impl Prepared { continue; } let row = prefix.row(yy)?; - prefix.add_window(row, acc, x + i64::from(a), x + i64::from(b) + 1, out.width() as i64)?; + let weight = self.weights.as_ref().and_then(|w| w.get(index)).copied().unwrap_or(1.0); + prefix.add_window(row, acc, x + i64::from(a), x + i64::from(b) + 1, out.width() as i64, weight)?; } Some(()) } @@ -102,7 +165,7 @@ struct Prefix { } impl Prefix { - fn build(src: &Image, ctx: &Ctx, n: usize, stride: usize, len: usize) -> Option { + fn build(src: &Image, ctx: &Ctx, n: usize, stride: usize, len: usize, ctl: &Interrupt) -> Option { let w = src.rect.width() as usize; let h = src.rect.height() as usize; let input_row = w.checked_mul(n)?; @@ -111,6 +174,9 @@ impl Prefix { data.resize(len, 0.0f64); if input_row != 0 { for (s, d) in src.data.chunks_exact(input_row).zip(data.chunks_exact_mut(stride)) { + if ctl.cancelled() { + return None; + } let mut sum = [0.0f64; crate::fxutil::MAXC]; for (px, dst) in s.chunks_exact(n).zip(d.get_mut(n..)?.chunks_exact_mut(n)) { let alpha = if ctx.alpha { *px.last()? } else { 1.0 }; @@ -131,7 +197,7 @@ impl Prefix { } /// Add the `[lo, hi)` window difference of `row` for `acc.len() / n` output pixels. - fn add_window(&self, row: &[f64], acc: &mut [f64], lo: i64, hi: i64, end: i64) -> Option<()> { + fn add_window(&self, row: &[f64], acc: &mut [f64], lo: i64, hi: i64, end: i64, weight: f64) -> Option<()> { let (n, w) = (self.n, self.w); let row_len = acc.len(); if lo >= 0 && hi + end <= w as i64 { @@ -140,7 +206,7 @@ impl Prefix { let low = row.get(start_lo..start_lo.checked_add(row_len)?)?; let high = row.get(start_hi..start_hi.checked_add(row_len)?)?; for ((acc, hi), lo) in acc.iter_mut().zip(high).zip(low) { - *acc += hi - lo; + *acc += if weight == 1.0 { hi - lo } else { (hi - lo) * weight }; } } else { // Direct kernel callers may omit the halo or request pixels outside src. @@ -149,7 +215,7 @@ impl Prefix { let low = (lo + i as i64).clamp(0, w as i64) as usize * n; let high = (hi + i as i64).clamp(0, w as i64) as usize * n; for ((acc, hi), lo) in px.iter_mut().zip(row.get(high..high + n)?).zip(row.get(low..low + n)?) { - *acc += hi - lo; + *acc += if weight == 1.0 { hi - lo } else { (hi - lo) * weight }; } } } diff --git a/crates/algo/src/square_dilate.rs b/crates/algo/src/square_dilate.rs new file mode 100644 index 0000000000..3781d71d99 --- /dev/null +++ b/crates/algo/src/square_dilate.rs @@ -0,0 +1,337 @@ +//! Grow a boolean mask by a clipped square: shared by the Remove Tool and the non-local completion. + +/// `mask` grown by `r` cells with a square structuring element, clipped to the `w × h` grid: a +/// cell is set when any mask cell lies within chessboard distance `r`. A mask of the wrong size +/// comes back unchanged. +/// +/// Work stays proportional to the mask's extent, not the grid's: a mask cell costs `(2r+1)²` +/// stores directly, which is the cheapest for a small or sparse mask; a bulky one is grown by two +/// running-count passes over its own expanded bounding box instead. +pub(crate) fn dilate_square(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { + if r == 0 || mask.len() != w.saturating_mul(h) { + return mask.to_vec(); + } + let Some(b) = bounds(w, mask) else { + return vec![false; mask.len()]; + }; + let mut out = vec![false; mask.len()]; + match choose(w, h, &b, r) { + Path::Direct => direct(w, h, mask, r, &b, &mut out), + Path::Separable => { + separable(w, h, mask, r, &b, &mut out); + } + } + out +} + +/// The tight box around the set cells (inclusive corners) and how many there are. A mask of at +/// most `KEEP` set cells also keeps their indices (`cells.len() == count`), so the direct path +/// needn't scan the grid a second time. +struct Bounds { + x0: usize, + y0: usize, + x1: usize, + y1: usize, + count: usize, + cells: Vec, +} + +/// Most set cells whose indices `bounds` keeps. +const KEEP: usize = 1 << 12; + +#[derive(Debug, PartialEq, Eq)] +enum Path { + Direct, + Separable, +} + +/// `None` when no cell is set. `mask` has `w` columns. +fn bounds(w: usize, mask: &[bool]) -> Option { + let mut b = Bounds { x0: usize::MAX, y0: usize::MAX, x1: 0, y1: 0, count: 0, cells: Vec::new() }; + // Most cells are unset: skip 64 at a time with a vectorised test before looking at any. + for (c, chunk) in mask.chunks(64).enumerate().filter(|(_, chunk)| chunk.iter().any(|m| *m)) { + for (j, _) in chunk.iter().enumerate().filter(|(_, m)| **m) { + let i = c * 64 + j; + let (x, y) = (i % w, i / w); + // Row-major order: `y` never decreases. + (b.x0, b.y0, b.x1, b.y1, b.count) = (b.x0.min(x), b.y0.min(y), b.x1.max(x), y, b.count + 1); + if b.count <= KEEP { + b.cells.push(i); + } else if !b.cells.is_empty() { + b.cells = Vec::new(); + } + } + } + (b.count > 0).then_some(b) +} + +/// The box around the set cells grown by `r` and clipped to the grid: `(x0, x1, y0, y1)`, inclusive. +fn expanded(w: usize, h: usize, b: &Bounds, r: usize) -> (usize, usize, usize, usize) { + (b.x0.saturating_sub(r), b.x1.saturating_add(r).min(w - 1), b.y0.saturating_sub(r), b.y1.saturating_add(r).min(h - 1)) +} + +/// Cells of scratch the separable path allocates: the rows that can hold set cells, across the +/// expanded box. Never more than the expanded box's area. +#[cfg(test)] +fn scratch_cells(w: usize, h: usize, b: &Bounds, r: usize) -> usize { + let (ex0, ex1, _, _) = expanded(w, h, b, r); + (b.y1 - b.y0 + 1).saturating_mul(ex1 - ex0 + 1) +} + +/// The cheaper way to grow the mask: the direct squares when they cost no more than two passes +/// over the expanded box. +fn choose(w: usize, h: usize, b: &Bounds, r: usize) -> Path { + let side = r.saturating_mul(2).saturating_add(1); + let direct = b.count.saturating_mul(side.saturating_mul(side)); + let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r); + let separable = (ex1 - ex0 + 1).saturating_mul(ey1 - ey0 + 1).saturating_mul(2); + if direct <= separable { Path::Direct } else { Path::Separable } +} + +/// Set the clipped `(2r+1)²` square around every set cell. +fn direct(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) { + let mut stamp = |x: usize, y: usize| { + let (sx, ex) = (x.saturating_sub(r), x.saturating_add(r).min(w - 1)); + for ty in y.saturating_sub(r)..=y.saturating_add(r).min(h - 1) { + out[ty * w + sx..=ty * w + ex].fill(true); + } + }; + if b.cells.len() == b.count { + b.cells.iter().for_each(|i| stamp(i % w, i / w)); + return; + } + for y in b.y0..=b.y1 { + for (dx, _) in mask[y * w + b.x0..=y * w + b.x1].iter().enumerate().filter(|(_, m)| **m) { + stamp(b.x0 + dx, y); + } + } +} + +/// Two running-count passes (a cell is set when any cell within `r` along one axis is set), +/// confined to the expanded box. Returns the scratch cells it allocated. +fn separable(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) -> usize { + let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r); + let (bw, eh, rows) = (ex1 - ex0 + 1, ey1 - ey0 + 1, b.y1 - b.y0 + 1); + // Horizontal: every set cell lies in `x0..=x1`, so clipping a row's window at the box instead + // of the grid only drops cells that are unset anyway. Only the rows that hold set cells. + let mut horiz = vec![false; rows * bw]; + for ry in 0..rows { + let src = &mask[(b.y0 + ry) * w + ex0..=(b.y0 + ry) * w + ex1]; + let dst = &mut horiz[ry * bw..(ry + 1) * bw]; + let mut count = 0usize; + for &s in &src[..bw.min(r)] { + count += usize::from(s); + } + for (k, d) in dst.iter_mut().enumerate() { + count += src.get(k.saturating_add(r)).map_or(0, |s| usize::from(*s)); + count -= k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(|j| src.get(j)).map_or(0, |s| usize::from(*s)); + *d = count > 0; + } + } + // Vertical, row-major: the expanded box's rows form a sequence of length `eh` whose element + // `k` is the scratch row of output row `ey0 + k`, or all unset above and below the set rows. + let row = |k: usize| if k < eh { (ey0 + k).checked_sub(b.y0).filter(|j| *j < rows).map(|j| &horiz[j * bw..(j + 1) * bw]) } else { None }; + let mut counts = vec![0usize; bw]; + for k in 0..eh.min(r) { + if let Some(src) = row(k) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s)); + } + } + for k in 0..eh { + if let Some(src) = row(k.saturating_add(r)) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s)); + } + if let Some(src) = k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(row) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c -= usize::from(*s)); + } + let dst = &mut out[(ey0 + k) * w + ex0..(ey0 + k) * w + ex1 + 1]; + dst.iter_mut().zip(&counts).for_each(|(d, c)| *d = *c > 0); + } + horiz.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The loop the non-local completion used before this module: the oracle. + fn reference(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { + let ri = r as i32; + let mut targets = vec![false; w * h]; + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % w) as i32, (i / w) as i32); + for ty in (y - ri).max(0)..=(y + ri).min(h as i32 - 1) { + for tx in (x - ri).max(0)..=(x + ri).min(w as i32 - 1) { + targets[ty as usize * w + tx as usize] = true; + } + } + } + targets + } + + fn forced(w: usize, h: usize, mask: &[bool], r: usize, path: &Path) -> Vec { + let mut out = vec![false; w * h]; + if let Some(b) = bounds(w, mask) { + match path { + Path::Direct => direct(w, h, mask, r, &b, &mut out), + Path::Separable => { + separable(w, h, mask, r, &b, &mut out); + } + } + } + out + } + + /// `dilate_square` and both paths, forced, against the oracle. + fn check(w: usize, h: usize, mask: &[bool], r: usize, what: &str) { + let want = reference(w, h, mask, r); + assert!(dilate_square(w, h, mask, r) == want, "{what}: dilate_square {w}x{h} r={r}"); + assert!(forced(w, h, mask, r, &Path::Direct) == want, "{what}: direct {w}x{h} r={r}"); + assert!(forced(w, h, mask, r, &Path::Separable) == want, "{what}: separable {w}x{h} r={r}"); + } + + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn mask(&mut self, n: usize, per_mille: u64) -> Vec { + (0..n).map(|_| self.next() % 1000 < per_mille).collect() + } + } + + #[test] + fn every_mask_on_small_grids_matches_the_oracle() { + for w in 1..=3usize { + for h in 1..=3usize { + for bits in 0..1u32 << (w * h) { + let mask: Vec = (0..w * h).map(|i| bits >> i & 1 == 1).collect(); + for r in [1, 2, 3, 5] { + check(w, h, &mask, r, "exhaustive"); + } + } + } + } + } + + fn cases(w: usize, h: usize) -> Vec<(&'static str, Vec)> { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ (w * 31 + h) as u64); + let at = |x: usize, y: usize| y * w + x; + let single = |x: usize, y: usize| { + let mut m = vec![false; w * h]; + m[at(x, y)] = true; + m + }; + let (cx, cy, mx, my) = (w / 2, h / 2, w - 1, h - 1); + let mut two = single(0, 0); + two[at(mx, my)] = true; + let blob = |x0: usize, y0: usize| -> Vec { + (0..w * h).map(|i| (x0..x0 + 3.min(w - x0)).contains(&(i % w)) && (y0..y0 + 3.min(h - y0)).contains(&(i / w))).collect() + }; + let mut corners = blob(0, 0); + corners.iter_mut().zip(blob(w.saturating_sub(3), h.saturating_sub(3))).for_each(|(a, b)| *a |= b); + vec![ + ("empty", vec![false; w * h]), + ("centre", single(cx, cy)), + ("top-left", single(0, 0)), + ("top-right", single(mx, 0)), + ("bottom-left", single(0, my)), + ("bottom-right", single(mx, my)), + ("top edge", single(cx, 0)), + ("bottom edge", single(cx, my)), + ("left edge", single(0, cy)), + ("right edge", single(mx, cy)), + ("sparse", rng.mask(w * h, 5)), + ("dense", rng.mask(w * h, 700)), + ("full", vec![true; w * h]), + ("two corners", two), + ("two blobs", corners), + ("edge blob", blob(0, cy.saturating_sub(1))), + ("horizontal line", (0..w * h).map(|i| i / w == cy).collect()), + ("vertical line", (0..w * h).map(|i| i % w == cx).collect()), + ] + } + + #[test] + fn named_cases_match_the_oracle() { + for (w, h) in [(64, 48), (1, 97), (97, 1), (7, 3)] { + for (what, mask) in cases(w, h) { + for r in [1, 2, 3, 5, 16, 200] { + check(w, h, &mask, r, what); + } + } + } + } + + /// More set cells than `bounds` keeps (`KEEP`): it drops its cell list, and the direct path + /// rescans the box instead. + #[test] + fn a_mask_with_more_cells_than_are_kept_matches_the_oracle() { + let (w, h) = (200, 200); + let mask = Rng(0x2545_f491_4f6c_dd1d).mask(w * h, 130); + let Some(b) = bounds(w, &mask) else { panic!("cells are set") }; + assert!(b.count > KEEP && b.cells.is_empty(), "{} cells, {} kept", b.count, b.cells.len()); + check(w, h, &mask, 1, "more than KEEP"); + } + + #[test] + fn the_remove_tool_dilate_is_unchanged() { + for (w, h) in [(64, 48), (1, 97), (7, 3)] { + for (what, mask) in cases(w, h) { + for r in [1, 3, 16] { + assert!(crate::remove::dilate(w, h, &mask, r) == reference(w, h, &mask, r), "{what} {w}x{h} r={r}"); + } + assert!(crate::remove::dilate(w, h, &mask, 0) == mask, "{what}: r = 0"); + } + } + assert_eq!(dilate_square(4, 4, &[true; 5], 2), vec![true; 5], "a mask of the wrong size"); + assert_eq!(dilate_square(0, 0, &[], 2), Vec::::new()); + } + + #[test] + fn a_small_or_sparse_mask_takes_the_direct_path_however_big_the_grid() { + let (w, h) = (600, 400); + let mut one = vec![false; w * h]; + one[200 * w + 300] = true; + let Some(b) = bounds(w, &one) else { panic!("one cell is set") }; + assert_eq!(choose(w, h, &b, 3), Path::Direct); + let mut two = vec![false; w * h]; + two[0] = true; + two[w * h - 1] = true; + let Some(b) = bounds(w, &two) else { panic!("two cells are set") }; + assert_eq!((b.x0, b.y0, b.x1, b.y1), (0, 0, w - 1, h - 1), "the box is the whole grid"); + assert_eq!(choose(w, h, &b, 3), Path::Direct); + } + + #[test] + fn a_bulky_mask_takes_the_separable_path_with_bounded_scratch() { + let (w, h) = (240, 240); + let mut rng = Rng(7); + let mut mask = vec![false; w * h]; + for y in 20..220 { + for x in 20..220 { + mask[y * w + x] = rng.next().is_multiple_of(2); + } + } + let Some(b) = bounds(w, &mask) else { panic!("the patch has set cells") }; + assert_eq!(choose(w, h, &b, 3), Path::Separable); + let (ex0, ex1, ey0, ey1) = expanded(w, h, &b, 3); + let scratch = scratch_cells(w, h, &b, 3); + assert!(scratch <= (ex1 - ex0 + 1) * (ey1 - ey0 + 1), "scratch stays inside the expanded box"); + assert!(scratch < w * h, "and is smaller than the grid when the mask is inside it"); + let mut out = vec![false; w * h]; + assert_eq!(separable(w, h, &mask, 3, &b, &mut out), scratch); + } + + #[test] + fn huge_radii_do_not_overflow() { + let mask = [false, true, false, false]; + assert_eq!(dilate_square(2, 2, &mask, usize::MAX), vec![true; 4]); + assert_eq!(forced(2, 2, &mask, usize::MAX, &Path::Separable), vec![true; 4]); + } +} diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 8a2e6a272d..a1400c57ba 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -16,6 +16,55 @@ fn relight_admission_tracks_the_document_dependent_working_set() { assert!(working_bytes(¶ms, tiny, tiny, 4) < 64 << 20, "{}", working_bytes(¶ms, tiny, tiny, 4)); } +/// Shape Blur admits each worker's f64 prefix sums over its halo-expanded tile, and a custom +/// shape also the kernel mask it keeps for the whole run and its worst-case coverage runs. +#[test] +fn shape_blur_admission_counts_prefix_sums_and_the_retained_kernel() { + let area = Rect::new(0, 0, 4096, 4096); + let radius = 100.0f32; + let plain = FilterParams::ShapeBlur { radius, shape: BlurShape::Circle }; + let Halo::Radius(reach) = plain.halo_for(area) else { panic!("Shape Blur has a radius halo") }; + let threads = rayon::current_num_threads().max(1) as u64; + let tile = auto_tile(&plain, area) as u64; + let window = tile + 2 * reach as u64; + // The float windows every radius filter admits (4 channels of f32), then the f64 prefix sums. + let windows = (area.size().area() * 16).min(RESULT_BUDGET as u64) * 3 + (window * window + tile * tile * 3) * 16 * threads; + let prefix = (window + 1) * (window + 1) * 4 * 8 * threads; + assert_eq!(working_bytes(&plain, area, area, 4), windows + prefix); + + let size = (2 * shape_blur::radius(radius).ceil() as usize + 1) as i32; + let mask = Image { rect: Rect::new(0, 0, size, size), ch: 1, data: vec![1.0; (size * size) as usize] }; + let custom = FilterParams::CustomShapeBlur { radius, kernel: Some(std::sync::Arc::new(mask)) }; + let kernel = (size * size) as u64 * 4 + shape_blur::max_spans(size as usize) as u64 * 20; + assert_eq!(working_bytes(&custom, area, area, 4), working_bytes(&plain, area, area, 4) + kernel); + // Unresolved, it holds no kernel yet. + assert_eq!(working_bytes(&FilterParams::CustomShapeBlur { radius, kernel: None }, area, area, 4), working_bytes(&plain, area, area, 4)); +} + +/// Only as many workers as there are tiles hold a tile's windows at once: a 24 MP Shape Blur +/// at radius 500 (24 tiles of 1024 px) counts 24 workers' windows, not one per thread. +#[test] +fn radius_filter_admission_counts_no_more_workers_than_tiles() { + let area = Rect::new(0, 0, 6000, 4000); + let plain = FilterParams::ShapeBlur { radius: 500.0, shape: BlurShape::Heart }; + let Halo::Radius(reach) = plain.halo_for(area) else { panic!("Shape Blur has a radius halo") }; + let tile = auto_tile(&plain, area) as u64; + let tiles = 6000u64.div_ceil(tile) * 4000u64.div_ceil(tile); + let workers = (rayon::current_num_threads().max(1) as u64).min(tiles); + let window = tile + 2 * reach as u64; + let windows = (area.size().area() * 16).min(RESULT_BUDGET as u64) * 3 + (window * window + tile * tile * 3) * 16 * workers; + let prefix = (window + 1) * (window + 1) * 4 * 8 * workers; + assert_eq!(working_bytes(&plain, area, area, 4), windows + prefix); + // One tile: one worker, whatever the thread count. + let small = Rect::new(0, 0, 200, 200); + let one = FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Circle }; + let Halo::Radius(r) = one.halo_for(small) else { panic!("Shape Blur has a radius halo") }; + let t = auto_tile(&one, small) as u64; + let w = t + 2 * r as u64; + let single = (small.size().area() * 16).min(RESULT_BUDGET as u64) * 3 + (w * w + t * t * 3) * 16 + (w + 1) * (w + 1) * 4 * 8; + assert_eq!(working_bytes(&one, small, small, 4), single); +} + fn fmt(s: SampleType) -> PixelFormat { PixelFormat::new(ColorMode::Rgb, s, true) } diff --git a/crates/algo/src/tests/ext.rs b/crates/algo/src/tests/ext.rs index 7c28485f1f..88b5138ded 100644 --- a/crates/algo/src/tests/ext.rs +++ b/crates/algo/src/tests/ext.rs @@ -435,6 +435,82 @@ fn lens_flare_is_brightest_at_its_centre() { assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens"); } +/// A Lens Flare on black over `r` (its reference bounds), as straight RGB. +fn flare_on_black(r: Rect, lens: LensType) -> Vec { + let s = flat(SampleType::F32, r, BLACK); + let p = FilterParams::LensFlare { brightness: 100.0, center_x: 0.3, center_y: 0.3, lens }; + apply(&s, &p, output_area(&p, s.content_bounds(), r, None), r, None).read_region(r) +} + +#[test] +fn lens_flare_scales_with_the_image() { + // Sizes are fractions of the image diagonal: the same flare on a twice-as-large image, halved, + // is the small one (up to the one-pixel rays, which stay one pixel wide). + for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] { + let (small, big) = (Rect::new(0, 0, 200, 150), Rect::new(0, 0, 400, 300)); + let a = flare_on_black(small, lens); + let b = flare_on_black(big, lens); + let mut diff = 0.0f64; + for y in 0..150usize { + for x in 0..200usize { + for k in 0..3 { + let at = |xx: usize, yy: usize| f64::from(b[(yy * 400 + xx) * 4 + k]); + let halved = (at(2 * x, 2 * y) + at(2 * x + 1, 2 * y) + at(2 * x, 2 * y + 1) + at(2 * x + 1, 2 * y + 1)) / 4.0; + diff += (halved - f64::from(a[(y * 200 + x) * 4 + k])).abs(); + } + } + } + let mean = diff / (200.0 * 150.0 * 3.0); + assert!(mean < 0.01, "{lens:?}: mean difference {mean}"); + } +} + +#[test] +fn every_lens_flare_lens_looks_different() { + let r = Rect::new(0, 0, 160, 120); + let lenses = [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime]; + let flares: Vec> = lenses.iter().map(|l| flare_on_black(r, *l)).collect(); + for i in 0..flares.len() { + for j in i + 1..flares.len() { + let d: f32 = flares[i].iter().zip(&flares[j]).map(|(a, b)| (a - b).abs()).sum::() / flares[i].len() as f32; + assert!(d > 0.01, "{:?} and {:?} are nearly the same ({d})", lenses[i], lenses[j]); + } + } +} + +#[test] +fn lens_flare_with_an_overflowing_centre_leaves_finite_pixels() { + // A centre of 1e300 from a command becomes infinite in f32; NaN must not reach the pixels. + let src = flat(SampleType::F32, R, [0.2, 0.3, 0.4, 1.0]); + for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] { + for (cx, cy) in [(f32::INFINITY, f32::NEG_INFINITY), (1e38, -1e38), (f32::NAN, 0.5), (5.0, -5.0)] { + let out = run(&src, &FilterParams::LensFlare { brightness: 100.0, center_x: cx, center_y: cy, lens }); + let px = out.read_region(R); + assert!(px.iter().all(|v| v.is_finite()), "{lens:?} at ({cx}, {cy})"); + assert!(px.iter().zip(src.read_region(R)).all(|(a, b)| *a >= b - 1e-6), "{lens:?}: screen never darkens"); + } + } +} + +#[test] +fn lens_flare_between_two_measured_brightnesses_blends_their_light() { + // 75 % and 100 % are measured levels; 87.5 % is halfway, so its light is their mean. On black + // 32-bit the result is the light itself; at 0 and 1 the clamp makes the blend nonlinear. + let black = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]); + for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] { + let at = |brightness: f32| run(&black, &FilterParams::LensFlare { brightness, center_x: 0.25, center_y: 0.5, lens }).read_region(R); + let (lo, mid, hi) = (at(75.0), at(87.5), at(100.0)); + let mut checked = 0; + for ((l, m), h) in lo.iter().zip(&mid).zip(&hi) { + if [*l, *m, *h].iter().all(|v| *v > 0.0 && *v < 1.0) { + assert!((m - 0.5 * (l + h)).abs() < 1e-4, "{lens:?}: {m} is not the mean of {l} and {h}"); + checked += 1; + } + } + assert!(checked >= 100, "{lens:?}: too few unclipped values ({checked})"); + } +} + #[test] fn lens_flare_never_darkens_32_bit_values_above_one() { // #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core). diff --git a/crates/algo/tests/it/gallery_reach_extreme.rs b/crates/algo/tests/it/gallery_reach_extreme.rs new file mode 100644 index 0000000000..32d4356267 --- /dev/null +++ b/crates/algo/tests/it/gallery_reach_extreme.rs @@ -0,0 +1,76 @@ +//! A project file stores a Filter Gallery effect's values as plain numbers, bypassing the clamp that +//! `GalleryEffect::set` applies, so the reach arithmetic must survive any `f32` in them. + +use photocraft_algo::{FilterParams, GalleryEffect, GalleryFilter, GalleryParamKind, Halo, apply}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +const EXTREME: [f32; 6] = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, 3.0e9]; + +/// The reach in pixels; a whole-image effect (`Halo::Bounds`) has no radius to check. +fn radius(effects: Vec) -> i32 { + match (FilterParams::FilterGallery { effects }).halo() { + Halo::Radius(r) => r, + _ => 0, + } +} + +fn with_all_ranges(filter: GalleryFilter, v: f32) -> GalleryEffect { + let mut e = GalleryEffect::new(filter); + for p in filter.params() { + if matches!(p.kind, GalleryParamKind::Range { .. }) { + e.values.insert(p.key.to_string(), v); + } + } + e +} + +#[test] +fn gallery_reach_survives_extreme_stored_values() { + for &filter in GalleryFilter::ALL { + for v in EXTREME { + let r = radius(vec![with_all_ranges(filter, v)]); + assert!(r >= 0, "{filter:?} with every range at {v}: reach {r}"); + } + } +} + +#[test] +fn gallery_reach_of_a_stack_survives_extreme_stored_values() { + for &filter in GalleryFilter::ALL { + let stack = vec![with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX)]; + let r = radius(stack); + assert!(r >= 0, "{filter:?} stacked three times: reach {r}"); + } +} + +/// Every range value at `scale` times its declared maximum (past the range when `scale` > 1). +fn scaled_to_max(filter: GalleryFilter, scale: f32) -> GalleryEffect { + let mut e = GalleryEffect::new(filter); + for p in filter.params() { + if let GalleryParamKind::Range { max, .. } = p.kind { + e.values.insert(p.key.to_string(), max * scale); + } + } + e +} + +/// The tile halo comes from the clamped values, so the pixels must be drawn from them too: a stored +/// value past its range renders exactly as the clamped one, with no seam where tiles meet. +#[test] +fn gallery_pixels_use_the_same_clamped_values_as_the_reach() { + let mut src = Surface::new(PixelFormat::RGBA8); + src.fill_rect(Rect::new(0, 0, 160, 160), &[0.2, 0.5, 0.8, 1.0]); + src.fill_rect(Rect::new(30, 40, 120, 100), &[0.9, 0.3, 0.1, 1.0]); + src.fill_rect(Rect::new(70, 60, 90, 130), &[0.1, 0.9, 0.4, 1.0]); + let area = Rect::new(0, 0, 160, 160); + for &filter in GalleryFilter::ALL { + let run = |e: GalleryEffect| apply(&src, &FilterParams::FilterGallery { effects: vec![e] }, area, area, None).read_region(area); + assert_eq!( + run(scaled_to_max(filter, 3.0)), + run(scaled_to_max(filter, 1.0)), + "{filter:?}: a stored value past its range must render as the clamped one" + ); + } +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index 1f26e46b8e..342eeb2501 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod gallery_reach_extreme; mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 320a1f50f4..bee8984e1c 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -6,6 +6,15 @@ edition.workspace = true license.workspace = true rust-version.workspace = true +[features] +default = [] +gpu = [ + "dep:photocraft-gpu", + "dep:wgpu", + "dep:pollster", + "photocraft-gpu/native", +] + [lints] workspace = true @@ -28,7 +37,13 @@ getrandom = { workspace = true } cap-std = "4" clap = { workspace = true } +# Optional native GPU rendering backend +photocraft-gpu = { workspace = true, optional = true } +wgpu = { version = "30", default-features = false, features = ["std", "wgsl"], optional = true } +pollster = { version = "1", optional = true } + [dev-dependencies] +photocraft-color = { workspace = true } photocraft-geom = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index adb6d8eea6..1f1a5e0509 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -1,8 +1,8 @@ //! Client for the desktop app's JSON-lines control protocol //! (`docs/control-protocol.md`): one JSON request per line, replies matched -//! by `id`. Keeps one connection, reconnecting after a failure — or before -//! a call, once the connection has been idle long enough that the app -//! (which closes idle control connections after +//! by `id`. Keeps one connection, reconnecting after a failure or a call +//! dropped before its reply — or before a call, once the connection has been +//! idle long enough that the app (which closes idle control connections after //! [`IO_TIMEOUT`](crate::security::IO_TIMEOUT)) may already have closed it. use std::sync::atomic::{AtomicU64, Ordering}; @@ -148,41 +148,31 @@ impl BridgeClient { *guard = Some((conn, Instant::now())); } let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let Some((conn, _)) = guard.as_mut() else { + // Out of the cache until answered: a call dropped mid-exchange (a cancelled wait) closes + // its connection, so no later call shares a socket with a half-sent request or a reply + // the app is still working on. + let Some((mut conn, _)) = guard.take() else { return Err(Failure::NotSent(format!("not connected to {}", self.addr))); }; - let outcome = tokio::time::timeout(self.timeout, exchange(conn, id, method, params)).await; + let outcome = tokio::time::timeout(self.timeout, exchange(&mut conn, id, method, params)).await; match outcome { - Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => { - // An oversized frame leaves unread bytes. Drop this connection and - // report the budget failure without retrying a possibly completed edit. - *guard = None; - Err(Failure::Definite(error)) - } + // An oversized frame leaves unread bytes. Drop this connection and + // report the budget failure without retrying a possibly completed edit. + Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => Err(Failure::Definite(error)), Ok(Ok(Ok(v))) => { - if let Some((_, last_used)) = guard.as_mut() { - *last_used = Instant::now(); - } + *guard = Some((conn, Instant::now())); Ok(v) } Ok(Ok(Err(e))) => { // The app answered: the operation did not apply — except the app's own // deadline reply, which fires while the command may still be running. - if let Some((_, last_used)) = guard.as_mut() { - *last_used = Instant::now(); - } + *guard = Some((conn, Instant::now())); Err(Failure::Definite(e)) } // A failed write or lost reply does not prove the edit was not applied. // Reconnect on the next call; only a vouched read may resend (#1007). - Ok(Err(e)) => { - *guard = None; - Err(Failure::Transport(e.to_string())) - } - Err(_) => { - *guard = None; - Err(Failure::TimedOut(())) - } + Ok(Err(e)) => Err(Failure::Transport(e.to_string())), + Err(_) => Err(Failure::TimedOut(())), } } } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 0ecc057c59..a9ef67274d 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -5,7 +5,7 @@ use std::path::Path; use photocraft_doc::Document; use photocraft_format::{PcraftWriter, SaveOptions}; -use photocraft_io::ExportOptions; +use photocraft_io::{BandRenderer, ExportOptions}; use crate::AutomationError; @@ -103,6 +103,43 @@ pub fn save( format_override: Option<&str>, opts: &ExportOptions, writer: Option<&mut PcraftWriter>, +) -> Result, AutomationError> { + save_with_renderer(doc, path, format_override, opts, writer, None) +} + +/// [`save`] with an optional custom [`BandRenderer`]. +pub fn save_with_renderer( + doc: &Document, + path: &Path, + format_override: Option<&str>, + opts: &ExportOptions, + writer: Option<&mut PcraftWriter>, + mut renderer: Option<&mut dyn BandRenderer>, +) -> Result, AutomationError> { + let mut kept = Vec::new(); + let res = save_with_renderer_keeping(doc, path, format_override, opts, writer, renderer.as_mut().map(|r| &mut **r as &mut dyn BandRenderer), &mut kept); + if res.is_err() + && let Some(r) = renderer + { + // Callers without their own store still see the reason through the renderer. + for warning in kept { + r.push_diagnostic(warning); + } + } + res +} + +/// [`save_with_renderer`] that hands the export warnings of an encode whose write then failed +/// to the caller in `kept`, independent of what the renderer does with +/// [`BandRenderer::push_diagnostic`] (the default is a no-op). +pub fn save_with_renderer_keeping( + doc: &Document, + path: &Path, + format_override: Option<&str>, + opts: &ExportOptions, + writer: Option<&mut PcraftWriter>, + renderer: Option<&mut dyn BandRenderer>, + kept: &mut Vec, ) -> Result, AutomationError> { let ext = format_override .map(|f| f.trim_start_matches('.').to_ascii_lowercase()) @@ -113,34 +150,106 @@ pub fn save( let mut local = PcraftWriter::new(); let w = writer.unwrap_or(&mut local); w.save_path(&named, path, &native_options(&named, opts)?)?; - return Ok(Vec::new()); + let mut warnings = Vec::new(); + if let Some(r) = renderer { + warnings.push("native .pcraft format uses CPU compositor for previews".into()); + warnings.extend(r.drain_diagnostics()); + } + return Ok(warnings); + } + let Some(renderer) = renderer else { + let r = photocraft_io::export_with_renderer(doc, &ext, opts, &photocraft_raster::Interrupt::NONE, None)?; + // Crash-safe: a failed write never destroys the previous file. + photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?; + return Ok(r.warnings); + }; + let r = photocraft_io::export_with_renderer(doc, &ext, opts, &photocraft_raster::Interrupt::NONE, Some(&mut *renderer))?; + if let Err(e) = photocraft_format::atomic_write(path, &r.bytes) { + // Encoding drained the renderer's diagnostics into `r.warnings`; hand them to the + // caller so it can still report the adapter or fallback reason alongside the write error. + kept.extend(r.warnings); + return Err(AutomationError::Io(e.to_string())); } - let r = photocraft_io::export(doc, &ext, opts)?; - // Crash-safe: a failed write never destroys the previous file. - photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?; Ok(r.warnings) } /// Encode a document for a capability-scoped write. Unlike [`save`], this /// never obtains ambient filesystem authority and emits `.pcraft` as a ZIP. pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opts: &ExportOptions) -> Result<(Vec, Vec), AutomationError> { + save_bytes_with_renderer(doc, name, format_override, opts, None) +} + +/// [`save_bytes`] with an optional custom [`BandRenderer`]. +pub fn save_bytes_with_renderer( + doc: &Document, + name: &str, + format_override: Option<&str>, + opts: &ExportOptions, + renderer: Option<&mut dyn BandRenderer>, +) -> Result<(Vec, Vec), AutomationError> { let ext = format_override .map(|format| format.trim_start_matches('.').to_ascii_lowercase()) .or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase())) .ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?; if ext == photocraft_format::EXTENSION { let named = named_for_output(doc, name); - return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?, Vec::new())); + // Encode before draining: if the options check or the encode fails, the queued + // diagnostics stay with the renderer for the caller to report alongside the error. + let bytes = photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?; + let mut warnings = Vec::new(); + if let Some(r) = renderer { + warnings.push("native .pcraft format uses CPU compositor for previews".into()); + warnings.extend(r.drain_diagnostics()); + } + return Ok((bytes, warnings)); } - let result = photocraft_io::export(doc, &ext, opts)?; + let result = photocraft_io::export_with_renderer(doc, &ext, opts, &photocraft_raster::Interrupt::NONE, renderer)?; Ok((result.bytes, result.warnings)) } /// Flattened document as PNG, scaled to fit `max_side` (0 = full size). pub fn render_png(doc: &Document, max_side: u32) -> Result, AutomationError> { + render_png_with_renderer(doc, max_side, None) +} + +/// [`render_png`] with an optional custom [`BandRenderer`]. Preview fallback warnings are +/// queued on the renderer; use [`render_png_with_warnings`] to receive them directly. +pub fn render_png_with_renderer(doc: &Document, max_side: u32, mut renderer: Option<&mut dyn BandRenderer>) -> Result, AutomationError> { + let (png, warnings) = render_png_with_warnings(doc, max_side, renderer.as_mut().map(|r| &mut **r as &mut dyn BandRenderer))?; + if let Some(r) = renderer { + for w in warnings { + r.push_diagnostic(w); + } + } + Ok(png) +} + +/// [`render_png_with_renderer`] that returns the preview fallback warnings with the bytes. +pub fn render_png_with_warnings(doc: &Document, max_side: u32, mut renderer: Option<&mut dyn BandRenderer>) -> Result<(Vec, Vec), AutomationError> { let side = if max_side == 0 { doc.size.width.max(doc.size.height) } else { max_side }; + let (target_w, target_h) = photocraft_compose::thumbnail_size(doc, side.max(1)); + let mut warnings = Vec::new(); + + if let Some(r) = renderer.as_mut() + && (target_w, target_h) == (doc.size.width, doc.size.height) + { + // 8-bit RGBA whatever the document's depth, like the CPU preview path: a 16-bit + // document's preview must not be twice as large as the one it previews. + let res = photocraft_io::export_preview_png_with_renderer(doc, Some(&mut **r)); + match res { + Ok(res) => return Ok((res.bytes, res.warnings)), + Err(photocraft_io::IoError::Cancelled) => return Err(AutomationError::Other("rendering was cancelled".into())), + Err(e) => return Err(e.into()), + } + } + + if renderer.is_some() { + warnings.push("scaled previews and thumbnails use CPU compositor; GPU rendering applies to full-size previews".into()); + } + let img = photocraft_compose::thumbnail(doc, side.max(1)); let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels) .map_err(|e| AutomationError::Other(e.to_string()))?; - photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| AutomationError::Other(e.to_string())) + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| AutomationError::Other(e.to_string()))?; + Ok((png, warnings)) } diff --git a/crates/automation/src/gpu.rs b/crates/automation/src/gpu.rs new file mode 100644 index 0000000000..d05f3c45ab --- /dev/null +++ b/crates/automation/src/gpu.rs @@ -0,0 +1,211 @@ +//! Native GPU renderer adapter and session integration for headless automation. + +use std::sync::Arc; + +use photocraft_doc::{DocId, Document, Rect}; +use photocraft_gpu::context::{NativeContext, NativeContextOptions}; +use photocraft_gpu::{Compositor, readback}; +use photocraft_io::{BandRenderer, IoError, RenderBand, RenderError}; + +use crate::AutomationError; + +/// Headless GPU renderer implementing [`BandRenderer`] with device and compositor reuse. +pub struct GpuRenderer { + context: Option>, + compositor: Option, + last_doc_id: Option, + options: NativeContextOptions, + max_staging_bytes: usize, + init_error: Option, + diagnostics: Vec, + adapter_reported: bool, +} + +impl GpuRenderer { + /// Create and immediately initialize a GPU renderer. + pub fn new() -> Result { + let mut r = Self::lazy(); + r.ensure_init().map_err(|e| AutomationError::Other(format!("failed to initialize GPU renderer: {e}")))?; + Ok(r) + } + + /// Create a GPU renderer that lazily requests an adapter on first render. + pub fn lazy() -> Self { + Self { + context: None, + compositor: None, + last_doc_id: None, + options: NativeContextOptions::default(), + max_staging_bytes: readback::DEFAULT_MAX_STAGING_BYTES, + init_error: None, + diagnostics: Vec::new(), + adapter_reported: false, + } + } + + /// Create with custom [`NativeContextOptions`]. + pub fn with_options(options: NativeContextOptions) -> Self { + Self { + context: None, + compositor: None, + last_doc_id: None, + options, + max_staging_bytes: readback::DEFAULT_MAX_STAGING_BYTES, + init_error: None, + diagnostics: Vec::new(), + adapter_reported: false, + } + } + + /// Set maximum in-flight staging bytes for bounded readback. + pub fn set_max_staging_bytes(&mut self, bytes: usize) { + self.max_staging_bytes = bytes; + } + + fn push_diag(&mut self, msg: String) { + const MAX_DIAGNOSTICS: usize = 16; + if self.diagnostics.len() < MAX_DIAGNOSTICS && !self.diagnostics.contains(&msg) { + self.diagnostics.push(msg); + } + } + + fn ensure_init(&mut self) -> Result<(), RenderError> { + if let Some(err) = &self.init_error { + return Err(RenderError::Unavailable(err.clone())); + } + + if self.context.is_none() { + match NativeContext::request(&self.options) { + Ok(ctx) => match ctx.create_compositor() { + Ok(comp) => { + if !self.adapter_reported { + self.adapter_reported = true; + let is_software = ctx.is_software(); + let adapter_type = if is_software { "software rasterizer" } else { "hardware" }; + let msg = format!("GPU adapter: {} ({:?}, {adapter_type})", ctx.adapter_name(), ctx.backend()); + self.push_diag(msg); + } + self.context = Some(Arc::new(ctx)); + self.compositor = Some(comp); + } + Err(e) => { + let msg = format!("could not build GPU compositor: {e}"); + self.push_diag(msg.clone()); + self.init_error = Some(msg.clone()); + return Err(RenderError::Unavailable(msg)); + } + }, + Err(e) => { + let msg = format!("no GPU adapter available: {e}"); + self.push_diag(msg.clone()); + self.init_error = Some(msg.clone()); + return Err(RenderError::Unavailable(msg)); + } + } + } + Ok(()) + } + + /// The underlying GPU context if initialized. + pub fn context(&self) -> Option<&Arc> { + self.context.as_ref() + } + + /// Whether this renderer is healthy. + pub fn is_healthy(&self) -> bool { + self.context.as_ref().is_some_and(|c| c.is_healthy()) + } + + /// Whether the active adapter is a software rasterizer. + pub fn is_software(&self) -> bool { + self.context.as_ref().is_some_and(|c| c.is_software()) + } + + /// Adapter name reported by the driver. + pub fn adapter_name(&self) -> Option<&str> { + self.context.as_ref().map(|c| c.adapter_name()) + } + + /// Invalidate compositor caches for a closed or modified document. + pub fn forget_doc(&mut self, doc: DocId) { + if let Some(comp) = &mut self.compositor { + comp.forget_doc(doc); + } + if self.last_doc_id == Some(doc) { + self.last_doc_id = None; + } + } + + /// Invalidate all compositor caches (resident pages, effect maps, pattern textures). + pub fn reset_cache(&mut self) { + if let Some(comp) = &mut self.compositor { + comp.reset_cache(); + } + self.last_doc_id = None; + } +} + +impl BandRenderer for GpuRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &photocraft_raster::Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + let max_staging = self.max_staging_bytes; + self.ensure_init()?; + + let ctx = self.context.clone().ok_or_else(|| RenderError::Unavailable("GPU context missing".into()))?; + if !ctx.is_healthy() { + let fault = ctx.health().fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()); + return Err(RenderError::Device(fault)); + } + + let comp = self.compositor.as_mut().ok_or_else(|| RenderError::Unavailable("GPU compositor missing".into()))?; + if let Err(unsupported) = comp.supports(doc) { + self.push_diag(format!("GPU rendering unsupported for document: {}", unsupported.0)); + return Err(RenderError::Unsupported(unsupported.0)); + } + + if self.last_doc_id != Some(doc.id) { + if let Some(prev) = self.last_doc_id { + comp.forget_doc(prev); + } + self.last_doc_id = Some(doc.id); + } + + let device = ctx.device(); + let queue = ctx.queue(); + + readback::render_bands(comp, device, queue, doc, rect, band_rows, max_staging, ctl, |band_rect, pixels| sink(RenderBand { rect: band_rect, pixels })) + .map_err(|e| { + let render_err = match e { + readback::ReadbackError::Unsupported(s) => RenderError::Unsupported(s), + readback::ReadbackError::DeviceLost(s) => RenderError::Device(s), + readback::ReadbackError::AllocationFailed(s) => RenderError::Unavailable(s), + readback::ReadbackError::MappingFailed(s) => RenderError::Device(s), + readback::ReadbackError::Cancelled => RenderError::Cancelled, + readback::ReadbackError::Execution(s) => RenderError::Execution(s), + readback::ReadbackError::Sink(io) => RenderError::sink(io), + }; + if render_err.is_retryable() { + self.push_diag(format!("GPU readback error: {render_err}")); + } + render_err + }) + } + + fn drain_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.diagnostics) + } + + fn push_diagnostic(&mut self, diagnostic: String) { + self.push_diag(diagnostic); + } + + fn forget_document(&mut self, doc: DocId) { + self.forget_doc(doc); + } +} diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index e623e69f2c..db662ada64 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started}; use photocraft_engine::{Session, command_specs, file_cmds}; use photocraft_format::PcraftWriter; use photocraft_io::ExportOptions; @@ -15,7 +16,7 @@ use crate::{AuthorizedWorkspace, AutomationError, files}; fn untrusted(filesystem: Filesystem) -> Headless { let mut session = Session::new(); session.authorize = Some(authorize_engine_step); - Headless { session, writers: HashMap::new(), filesystem } + Headless { session, writers: HashMap::new(), filesystem, renderer: None, pending_diagnostics: Vec::new(), waited: HashMap::new() } } enum Filesystem { @@ -30,6 +31,29 @@ pub struct Headless { /// Incremental `.pcraft` writers per document id. writers: HashMap, filesystem: Filesystem, + /// Jobs a caller waits on ([`Headless::command_begin`]), with their outcome once they end. + /// Kept here because `jobs.list` holds only the last few jobs that ended. + waited: HashMap>, + renderer: Option>, + /// Export warnings kept when a write fails, and preview fallback warnings: the next + /// [`Headless::drain_diagnostics`] still reports them, whatever the renderer does with + /// [`photocraft_io::BandRenderer::push_diagnostic`]. + pending_diagnostics: Vec, +} + +/// A waited job's state, from [`Headless::job_poll`]. +#[derive(Debug)] +pub enum JobPoll { + /// Still running: progress 0..1 and the job's message. + Running(f32, String), + /// Ended: the command's result, or why it failed or was cancelled. + Ended(Result), +} + +/// What admission decided: run the command, or Render Video already ran. +enum Admitted { + Run(Value), + Done(Value), } impl Default for Headless { @@ -48,7 +72,14 @@ impl Headless { /// not a remote automation client, supplies these host paths. Nested /// `actions.play` steps are not re-checked: this caller is already trusted. pub fn trusted_local() -> Self { - Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::TrustedLocal } + Self { + session: Session::new(), + writers: HashMap::new(), + filesystem: Filesystem::TrustedLocal, + renderer: None, + pending_diagnostics: Vec::new(), + waited: HashMap::new(), + } } /// Create an automation session with capability-scoped file access. @@ -57,11 +88,59 @@ impl Headless { untrusted(Filesystem::Workspace(workspace)) } + /// Configure a custom [`photocraft_io::BandRenderer`] for flat exports and previews. + pub fn with_renderer(mut self, renderer: Box) -> Self { + self.renderer = Some(renderer); + self + } + + /// Set or clear the active [`photocraft_io::BandRenderer`]. + pub fn set_renderer(&mut self, renderer: Option>) { + self.renderer = renderer; + } + + /// Take the active renderer out of this session. + pub fn take_renderer(&mut self) -> Option> { + self.renderer.take() + } + + /// Mutable reference to the active renderer if any. + pub fn renderer_mut(&mut self) -> Option<&mut dyn photocraft_io::BandRenderer> { + self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer) + } + + /// Drain diagnostics from the active renderer if any, and the export warnings a failed + /// capability-scoped write kept. Each is returned once. + pub fn drain_diagnostics(&mut self) -> Vec { + let mut drained = std::mem::take(&mut self.pending_diagnostics); + if let Some(renderer) = self.renderer.as_mut() { + drained.extend(renderer.drain_diagnostics()); + } + drained + } + + #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] + /// Enable the native GPU renderer backend with lazy device initialization. + pub fn enable_gpu(&mut self) { + self.renderer = Some(Box::new(crate::gpu::GpuRenderer::lazy())); + } + + #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] + /// Builder method to enable the native GPU renderer backend. + pub fn with_gpu(mut self) -> Self { + self.enable_gpu(); + self + } + /// Apply background jobs that finished since the last request, so every request (save, /// export, inspect, preview, `session.list`, commands) sees their result. Cheap when no job /// runs. The JSON-lines server and the MCP server call it before each request. pub fn sync_jobs(&mut self) { - self.session.poll_jobs(); + for event in self.session.poll_jobs() { + if let Some(slot) = self.waited.get_mut(&event.id) { + *slot = Some(event); + } + } } fn doc_index(&self, index: Option) -> Result { @@ -298,14 +377,36 @@ impl Headless { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), Filesystem::TrustedLocal => { let writer = self.writers.entry(doc.id.0).or_default(); - files::save(&doc, &target, format, opts, Some(writer))? + // Export warnings of a failed write stay in `pending_diagnostics`, whatever the + // renderer does with `push_diagnostic`. + files::save_with_renderer_keeping( + &doc, + &target, + format, + opts, + Some(writer), + self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer), + &mut self.pending_diagnostics, + )? } Filesystem::Workspace(workspace) => { // Only the target is granted: a linked smart object's stored path is document // data, so PSD export writes that layer as pixels instead of embedding the file. let opts = ExportOptions { read_linked_files: false, ..opts.clone() }; - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, &opts)?; - workspace.write(target_text, &bytes)?; + let (bytes, warnings) = files::save_bytes_with_renderer( + &doc, + target_text, + format, + &opts, + self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer), + )?; + if let Err(error) = workspace.write(target_text, &bytes) { + // Keep the export diagnostics for the caller, as the local save path does: + // they are reported with the write error and by the next drain, even when the + // renderer's `push_diagnostic` is the default no-op. + self.pending_diagnostics.extend(warnings); + return Err(error); + } warnings } }; @@ -323,6 +424,8 @@ impl Headless { if let Some(st) = this.session.active_mut() { st.saved_to(target.to_string_lossy().into_owned()); } + // History Options › Automatically Create New Snapshot When Saving (#2966). + photocraft_engine::history_options_cmds::snapshot_on_save(&mut this.session); Ok(()) })?; } @@ -344,13 +447,16 @@ impl Headless { Ok(photocraft_engine::inspect::document(&self.session.documents()[i])) } - pub fn render_png(&self, index: Option, max_side: u32) -> Result, AutomationError> { + pub fn render_png(&mut self, index: Option, max_side: u32) -> Result, AutomationError> { let i = self.doc_index(index)?; let doc = &self.session.documents()[i].doc; if !matches!(&self.filesystem, Filesystem::TrustedLocal) { crate::budgets::check_preview(doc.size.width, doc.size.height, max_side)?; } - let png = files::render_png(doc, max_side)?; + let (png, warnings) = files::render_png_with_warnings(doc, max_side, self.renderer.as_mut().map(|b| &mut **b as &mut dyn photocraft_io::BandRenderer))?; + // Kept here so `drain_diagnostics` returns them whatever the renderer does with + // `push_diagnostic`. + self.pending_diagnostics.extend(warnings); if !matches!(&self.filesystem, Filesystem::TrustedLocal) { crate::budgets::check_png(png.len())?; } @@ -368,7 +474,13 @@ impl Headless { pub fn close(&mut self, index: Option) -> Result { let i = self.doc_index(index)?; if let Some(d) = self.session.close(i) { - self.writers.remove(&d.doc.id.0); + let id = d.doc.id; + self.writers.remove(&id.0); + // Release this document's GPU caches now instead of keeping them until another + // document renders or the server exits. + if let Some(r) = self.renderer.as_mut() { + r.forget_document(id); + } } // The closed document's buffers went to the scratch pool: hand them back to the OS. photocraft_raster::scratch::release(); @@ -387,22 +499,111 @@ impl Headless { /// Photomerge, …) runs in the background and this returns `{"job": id}` at once (poll with /// `jobs.list`, stop with `jobs.cancel`). Other commands finish before returning either way. pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result { + let params = match self.admit(id, params)? { + Admitted::Run(params) => params, + Admitted::Done(value) => return Ok(value), + }; + // Commands such as `file.close`, `file.closeAll` and `file.closeOthers` remove + // documents directly through the session, so release their GPU caches here too. + let before = self.document_ids(); + let result = if wait { + self.session.execute(id, params).map_err(AutomationError::from) + } else { + self.session.start(id, params).map_err(AutomationError::from).map(|started| match started { + Started::Done(v) => v, + Started::Job(job) => json!({"job": job.0, "pending": true}), + }) + }; + self.forget_removed_documents(&before); + result + } + + /// Start a command for a caller that waits without holding the session: a job-capable + /// command runs in the background and returns its job, to follow with [`Self::job_poll`] + /// and stop with [`Self::job_abandon`]. Other commands finish here. + pub fn command_begin(&mut self, id: &str, params: Value) -> Result { + let params = match self.admit(id, params)? { + Admitted::Run(params) => params, + Admitted::Done(value) => return Ok(Started::Done(value)), + }; + let before = self.document_ids(); + let started = self.session.start(id, params); + self.forget_removed_documents(&before); + let started = started?; + if let Started::Job(job) = started { + self.waited.insert(job, None); + } + Ok(started) + } + + /// Where a job from [`Self::command_begin`] is. Once it has ended this returns its result + /// and forgets the job. Never blocks. + pub fn job_poll(&mut self, job: JobId) -> JobPoll { + // A finished job stays in the running list until it is applied. + self.sync_jobs(); + if let Some(info) = self.session.job(job) { + return JobPoll::Running(info.progress, info.message); + } + let outcome = self.waited.remove(&job).flatten(); + JobPoll::Ended(match outcome { + Some(event) => match event.outcome { + JobOutcome::Done(value) => Ok(value), + JobOutcome::Failed(error) => Err(event.error.map(AutomationError::from).unwrap_or(AutomationError::Other(error))), + JobOutcome::Cancelled => Err(photocraft_engine::EngineError::Cancelled.into()), + }, + // Ended without this session seeing it end (not expected): `wait_job` reads the + // recent-jobs list and does not block for a job that is no longer running. + None => self.session.wait_job(job).map_err(Into::into), + }) + } + + /// Stop waiting on `job`: cancel it if it is still running (the document stays unchanged) + /// and forget it. Returns how the job really ended: `Cancelled` only when this call cancelled + /// it. A job that ended first (another request applied it) keeps its result, and one that + /// cannot be cancelled (printing) is reported as still running. + pub fn job_abandon(&mut self, job: JobId) -> Result { + if self.session.cancel_job(job) { + self.waited.remove(&job); + return Err(photocraft_engine::EngineError::Cancelled.into()); + } + match self.job_poll(job) { + JobPoll::Ended(result) => result, + JobPoll::Running(..) => { + self.waited.remove(&job); + Err(AutomationError::Other(format!("job {} cannot be cancelled and is still running; check jobs.list for its outcome", job.0))) + } + } + } + + /// Default the params, authorize an untrusted command and apply finished jobs. Render Video + /// runs here for an untrusted session, with its own write capability. + fn admit(&mut self, id: &str, params: Value) -> Result { let params = if params.is_null() { json!({}) } else { params }; if !matches!(&self.filesystem, Filesystem::TrustedLocal) { if id == "file.export.renderVideo" { - return self.render_video(params, |_, _| true); + return self.render_video(params, |_, _| true).map(Admitted::Done); } authorize_engine_command(id, ¶ms)?; } // Background jobs that finished since the last request (or batch step) are applied first. self.sync_jobs(); - if wait { - return Ok(self.session.execute(id, params)?); + Ok(Admitted::Run(params)) + } + + fn document_ids(&self) -> Vec { + self.session.documents().iter().map(|st| st.doc.id).collect() + } + + /// Release the writer and GPU caches of every document a command removed. + fn forget_removed_documents(&mut self, before: &[photocraft_doc::DocId]) { + let after: std::collections::HashSet<_> = self.document_ids().into_iter().collect(); + let removed: Vec<_> = before.iter().copied().filter(|id| !after.contains(id)).collect(); + for id in removed { + self.writers.remove(&id.0); + if let Some(r) = self.renderer.as_mut() { + r.forget_document(id); + } } - Ok(match self.session.start(id, params)? { - photocraft_engine::jobs::Started::Done(v) => v, - photocraft_engine::jobs::Started::Job(job) => json!({"job": job.0, "pending": true}), - }) } } @@ -451,3 +652,30 @@ fn exported(dir: &str, written: photocraft_engine::Result>, outputs: } } } + +#[cfg(test)] +mod waited_error_tests { + use super::*; + + #[test] + fn waited_job_keeps_typed_failure() { + let mut headless = Headless::trusted_local(); + let id = JobId(123); + let error = photocraft_engine::EngineError::PsdHardBudget { name: "large.psd".into(), required: 9000, limit: 4000, free_memory: None }; + headless.waited.insert( + id, + Some(JobEvent { + id, + command: "file.open".into(), + label: "Open".into(), + document: None, + outcome: JobOutcome::Failed(error.to_string()), + error: Some(error), + }), + ); + assert!(matches!( + headless.job_poll(id), + JobPoll::Ended(Err(AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required: 9000, limit: 4000, .. }))) + )); + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b4691723b9..e6d99e07c8 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -31,6 +31,11 @@ pub use headless::Headless; pub use server::{Backend, PhotocraftMcp}; pub use workspace::AuthorizedWorkspace; +#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] +pub mod gpu; +#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] +pub use gpu::GpuRenderer; + #[derive(Debug, thiserror::Error)] pub enum AutomationError { #[error("{0}")] diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 6ac8d96247..2f6b7ea818 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -17,7 +17,7 @@ //! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export //! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`, //! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else -//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}` +//! returns it base64-encoded; adds `warnings` when a renderer fell back), `doc.select {index}`, `doc.close {index?}` //! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}` //! - `methods`: this list. @@ -79,12 +79,16 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { } } } - AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) - | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => { + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => { if let Some(m) = obj.as_object_mut() { m.insert("required".into(), json!(required)); m.insert("hard".into(), json!(limit)); m.insert("hardLimit".into(), json!(limit)); + if let Some(free) = free_memory { + m.insert("available".into(), json!(free)); + m.insert("availableRam".into(), json!(free)); + } } } _ => {} @@ -180,6 +184,9 @@ impl Headless { let tiff_bool = |key| photocraft_engine::file_cmds::tiff_bool_param(&p, key, "doc.save").map_err(|e| bad(e.to_string())); opts.tiff_transparency = tiff_bool("tiffTransparency")?; opts.encode.tiff_pyramid = tiff_bool("tiffPyramid")?.unwrap_or(false); + if let Some(maximize) = photocraft_engine::file_cmds::maximize_compatibility_param(&p, "doc.save").map_err(|e| bad(e.to_string()))? { + opts.psd_maximize_compatibility = maximize; + } match p.get("quality") { None | Some(Value::Null) => {} Some(v) => { @@ -221,16 +228,24 @@ impl Headless { } }; let png = self.render_png(index_of(&p)?, max)?; - match str_of(&p, "path") { + // A GPU to CPU fallback is reported with this reply, not on the next save. + let warnings = self.drain_diagnostics(); + let mut reply = match str_of(&p, "path") { Some(path) => { self.write_render(&PathBuf::from(path), &png)?; - Ok(json!({"path": path, "bytes": png.len()})) + json!({"path": path, "bytes": png.len()}) } - None => Ok(json!({ + None => json!({ "mime": "image/png", "base64": base64::engine::general_purpose::STANDARD.encode(&png), - })), + }), + }; + if !warnings.is_empty() + && let Some(object) = reply.as_object_mut() + { + object.insert("warnings".into(), json!(warnings)); } + Ok(reply) } "doc.select" => { let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?; @@ -314,6 +329,13 @@ pub fn respond(h: &Mutex, line: &str) -> Value { // poisoned lock is safe to keep using, the same as the MCP server does. let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.lock().unwrap_or_else(PoisonError::into_inner).handle(method, params))) .unwrap_or_else(|_| Err(AutomationError::Other(format!("internal error: `{method}` panicked")))); + // A renderer whose diagnostics panic must not lose the reply either. + let diagnostics = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.lock().unwrap_or_else(PoisonError::into_inner).drain_diagnostics())).unwrap_or_default(); + for diagnostic in diagnostics { + // A broken stderr pipe must not panic the server before it writes the reply. + let _ = writeln!(std::io::stderr(), "warning: {diagnostic}"); + } match r { Ok(v) => json!({"id": id, "ok": true, "result": v}), Err(e) => { diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index 0a20075267..2e9348ae38 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -116,6 +116,36 @@ fn poisoned_session_keeps_serving() { assert_eq!(r["ok"], true, "{r}"); } +/// A renderer whose diagnostics panic must not cost the request its reply. +#[test] +fn a_panicking_renderer_diagnostic_drain_still_writes_the_reply() { + struct PanickyDiagnostics; + impl photocraft_io::BandRenderer for PanickyDiagnostics { + fn render_bands( + &mut self, + _doc: &photocraft_doc::Document, + _rect: photocraft_doc::Rect, + _band_rows: i32, + _ctl: &photocraft_raster::Interrupt<'_>, + _sink: &mut dyn FnMut(photocraft_io::RenderBand) -> Result<(), photocraft_io::IoError>, + ) -> Result<(), photocraft_io::RenderError> { + Ok(()) + } + + fn drain_diagnostics(&mut self) -> Vec { + panic!("diagnostics blew up"); + } + } + let h = session(); + h.lock().unwrap().set_renderer(Some(Box::new(PanickyDiagnostics))); + let r = respond(&h, r#"{"id":7,"method":"doc.new","params":{"width":8,"height":8}}"#); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["id"], 7); + // The session keeps serving afterwards. + let r = respond(&h, r#"{"id":8,"method":"doc.new","params":{"width":8,"height":8}}"#); + assert_eq!(r["ok"], true, "{r}"); +} + #[test] fn batch_stops_on_error_unless_told_not_to() { let h = session(); @@ -273,6 +303,28 @@ fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() { std::fs::remove_dir_all(dir).unwrap(); } +/// #8064: `doc.save` takes `maximizeCompatibility` for a layered PSD's merged image (false: the +/// placeholder, flagged in resource 1057); anything but a boolean writes nothing. +#[test] +fn doc_save_takes_maximize_compatibility() { + let dir = std::env::temp_dir().join(format!("pc-rpc-save-maximize-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 64, "height": 32})).unwrap(); + h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap(); + let real_merged = |p: &std::path::Path| photocraft_psd::PsdFile::from_bytes(&std::fs::read(p).unwrap()).unwrap().has_real_merged_data(); + for (name, maximize, real) in [("default.psd", json!(null), true), ("off.psd", json!(false), false), ("on.psd", json!(true), true)] { + let path = dir.join(name); + h.handle("doc.save", json!({"path": path.to_string_lossy(), "maximizeCompatibility": maximize})).unwrap(); + assert_eq!(real_merged(&path), Some(real), "{name}"); + } + let bad = dir.join("bad.psd"); + let err = h.handle("doc.save", json!({"path": bad.to_string_lossy(), "maximizeCompatibility": 0})).unwrap_err().to_string(); + assert!(err.contains("maximizeCompatibility"), "{err}"); + assert!(!bad.exists(), "a bad value writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() { let mut h = Headless::new(); @@ -1062,7 +1114,12 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() { // fields, and only the fields an approval request has: no soft limit and no capacity, since // no approval can raise the ceiling. let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"}); - let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 }); + let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { + name: "big.psd".into(), + required: 9000, + limit: 8000, + free_memory: None, + }); super::attach_memory_error_details(&mut hard_obj, &hard); assert_eq!(hard_obj["required"], 9000); assert_eq!(hard_obj["hard"], 8000); @@ -1070,6 +1127,20 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() { for absent in ["soft", "softLimit", "available", "availableRam"] { assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}"); } + + // When measured free memory set the limit, the refusal also reports it. + let mut low_obj = json!({"id": 3, "ok": false, "error": "test"}); + let low = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { + name: "big.psd".into(), + required: 9000, + limit: 8000, + free_memory: Some(10_000), + }); + super::attach_memory_error_details(&mut low_obj, &low); + assert_eq!(low_obj["hardLimit"], 8000); + assert_eq!(low_obj["available"], 10_000); + assert_eq!(low_obj["availableRam"], 10_000); + assert!(low_obj.get("soft").is_none(), "a hard refusal has no soft limit: {low_obj}"); } /// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 5c74340355..2e34cf6cbe 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -1,6 +1,7 @@ //! The MCP server (rmcp). Tool names use `snake_case` with underscores (dots //! are not valid in every client's tool-name grammar). +use std::io::Write as _; use std::path::PathBuf; use std::sync::{Arc, Mutex, PoisonError}; @@ -8,7 +9,8 @@ use base64::Engine as _; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{CallToolResult, ContentBlock as Content}; -use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_router}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, tool, tool_router}; use schemars::JsonSchema; use serde::Deserialize; use serde_json::{Value, json}; @@ -111,6 +113,11 @@ pub struct ExportLayersParams { /// Encoder quality, 0..12, for the formats that take one. #[serde(default)] pub quality: Option, + /// PSD/PSB: write the real merged image (Photoshop's Maximize Compatibility); false writes + /// Photoshop's white placeholder (Bitmap, Indexed Color and Duotone documents still write the + /// real image). Unset: the engine's Preferences › File Handling decides. + #[serde(default, rename = "maximizeCompatibility")] + pub maximize_compatibility: Option, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -212,6 +219,13 @@ pub struct SaveParams { /// The save may omit it; check the manifest's `full_composite` member. Off by default. #[serde(default, rename = "pcraftFullComposite")] pub pcraft_full_composite: Option, + /// PSD/PSB: write the real merged image (Photoshop's Maximize Compatibility). false writes + /// Photoshop's white placeholder instead: a smaller file and a faster save, but readers that + /// show only the merged image see white. Bitmap, Indexed Color and Duotone documents still + /// write the real image. Unset: the real image (in bridge mode, the app's + /// Preferences › File Handling decides). + #[serde(default, rename = "maximizeCompatibility")] + pub maximize_compatibility: Option, #[serde(default)] pub index: Option, } @@ -348,7 +362,8 @@ pub struct MenuParams { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UiSetParams { - /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel + /// Fields for the control method `ui.set`: tool, moveShowTransform (bool, the Move tool's + /// Show Transform Controls option), panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, /// vectorMaskTarget, filterMaskTarget (paint the active smart object's filter mask), selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, @@ -367,15 +382,22 @@ pub struct UiSetParams { /// combine, subtract, intersect or exclude: how the next Pen path joins the selected shape /// layer or path), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use - /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, + /// `shape.edit` names and units; colour comes from `tools.setColors`. frameShape (rectangle, + /// ellipse, triangle, polygon or custom: the Frame tool's shape), frameSides (3..100, the + /// polygon's sides), frameTarget (frame, content or both: what the selected frame's handles + /// edit). camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "mode", - /// "color", "smartObject" or "artboard", which lists the artboards that hold the selected - /// layers), kinds (a list of layer kinds), name (matches part of the layer name), mode (a blend - /// mode name), color (a layer colour label, or "none") and smart (a list of "linked", "missing" - /// and "embedded") for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": - /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not - /// over this channel). updatesOpen (bool, the Help › Check for Updates window). + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "effect", + /// "mode", "attribute", "color", "smartObject" or "artboard", which lists the artboards that + /// hold the selected layers), kinds (a list of layer kinds), name (matches part of the layer + /// name), effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay", + /// "outerGlow" or "dropShadow"), mode (a blend mode name), attribute ("visible", "locked", + /// "empty", "linked", "clipped", "layerMask", "vectorMask", "layerEffects", + /// "advancedBlending", or "notVisible" … "noAdvancedBlending"), color (a layer colour label, + /// or "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel + /// filter). strokeTrace (true, false, "stderr" or "off": the stroke latency trace at runtime; a + /// CSV file is set at launch with --stroke-trace, not over this channel). updatesOpen (bool, + /// the Help › Check for Updates window). /// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel /// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b", /// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage", @@ -504,8 +526,9 @@ fn png_result(png: &[u8], note: String) -> CallToolResult { } /// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps -/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required -/// and hard amounts, because no approval can raise that ceiling. +/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps its required and +/// hard amounts, because no approval can raise that ceiling, and adds the free memory when +/// measured memory set the limit. fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { let Some(obj) = obj.as_object_mut() else { return }; match err { @@ -521,11 +544,15 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { obj.insert("availableRam".into(), json!(avail)); } } - AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) - | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => { + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => { obj.insert("required".into(), json!(required)); obj.insert("hard".into(), json!(limit)); obj.insert("hardLimit".into(), json!(limit)); + if let Some(free) = free_memory { + obj.insert("available".into(), json!(free)); + obj.insert("availableRam".into(), json!(free)); + } } _ => {} } @@ -574,8 +601,12 @@ impl PhotocraftMcp { Self::with_backend(Backend::Headless(Arc::new(Mutex::new(Headless::new())))) } + pub fn with_headless(headless: Headless) -> Self { + Self::with_backend(Backend::Headless(Arc::new(Mutex::new(headless)))) + } + pub fn headless_with_workspace(workspace: AuthorizedWorkspace) -> Self { - Self::with_backend(Backend::Headless(Arc::new(Mutex::new(Headless::with_workspace(workspace))))) + Self::with_headless(Headless::with_workspace(workspace)) } pub fn bridge(addr: &str, token: &str) -> Result { @@ -779,6 +810,9 @@ impl PhotocraftMcp { if let Some(v) = p.quality { opts.insert("quality".into(), json!(v)); } + if let Some(v) = p.maximize_compatibility { + opts.insert("maximizeCompatibility".into(), json!(v)); + } let opts = Value::Object(opts); // Bridge mode runs the app on this machine, which writes its own paths like any command. let mut bridge_params = opts.clone(); @@ -861,7 +895,7 @@ impl PhotocraftMcp { } #[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \ - (psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")] + (psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")] async fn doc_save(&self, Parameters(p): Parameters) -> Result { self.save_impl(p).await } @@ -898,7 +932,17 @@ impl PhotocraftMcp { #[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")] async fn doc_render_preview(&self, Parameters(p): Parameters) -> Result { let max = p.max_side.unwrap_or(1024); - if let Some(r) = self.headless_op(move |h| h.render_png(p.index, max)).await { + if let Some(r) = self + .headless_op(move |h| { + let res = h.render_png(p.index, max); + for d in h.drain_diagnostics() { + // Ignore a failed stderr write: the completed render must still be returned. + let _ = writeln!(std::io::stderr(), "warning: {d}"); + } + res + }) + .await + { return Ok(match r { Ok(png) => png_result(&png, format!("flattened preview (max side {max})")), Err(e) => fail(e), @@ -965,9 +1009,11 @@ impl PhotocraftMcp { #[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result. \ Long commands (filters, Content-Aware Fill/Scale, Photomerge, brush import) wait to finish unless `wait` is false: \ - then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel).")] - async fn command_run(&self, Parameters(p): Parameters) -> Result { - self.run_command(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true)).await + then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel). A waited long \ + command reports progress, cancelling the request cancels it, and other calls are served meanwhile.")] + async fn command_run(&self, Parameters(p): Parameters, context: RequestContext) -> Result { + let mut waiter = Waiter::new(Some(context)); + to_result(self.step(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true), &mut waiter, (0, 1)).await) } #[tool(description = "List background jobs: running ones with progress (0-1), message and elapsed time, then the last few \ @@ -985,49 +1031,8 @@ impl PhotocraftMcp { #[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \ results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \ Not an atomic transaction: earlier successful steps stay applied (no rollback).")] - async fn command_batch(&self, Parameters(p): Parameters) -> Result { - if p.steps.len() > MAX_BATCH_STEPS { - return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len()))); - } - let stop = p.stop_on_error.unwrap_or(true); - let steps: Vec = - p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({})), "wait": s.wait.unwrap_or(true)})).collect(); - let args = json!({"steps": steps, "stopOnError": stop}); - // The reply travels as escaped JSON text, so steps are charged their escaped size. - if let Some(r) = self.headless_op(move |h| h.batch_with_budget(&args, BatchReplyBudget::escaped())).await { - return to_result(r); - } - let Some(b) = self.bridge_client() else { - return Ok(no_backend()); - }; - let mut results = Vec::new(); - let mut failed = 0; - let mut reply_budget = BatchReplyBudget::escaped(); - for s in &steps { - let response = b.call("engine.execute", s.clone()).await; - let was_error = response.is_err(); - let result = match response { - Ok(value) => json!({"ok": true, "result": value}), - Err(error) => { - let mut obj = json!({"ok": false, "error": error.to_string()}); - attach_memory_error_details(&mut obj, &error); - obj - } - }; - if let Err(error) = reply_budget.charge(&result) { - failed += 1; - results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")})); - break; - } - results.push(result); - if was_error { - failed += 1; - if stop { - break; - } - } - } - Ok(ok_json(&json!({"completed": results.len() - failed, "failed": failed, "results": results}))) + async fn command_batch(&self, Parameters(p): Parameters, context: RequestContext) -> Result { + self.batch(p, Waiter::new(Some(context))).await } // ----- live-GUI tools (bridge mode) ----- @@ -1102,14 +1107,60 @@ fn bridge_only_headless(name: &str) -> Result { impl PhotocraftMcp { async fn run_command(&self, id: String, params: Value, wait: bool) -> Result { - let (id2, params2) = (id.clone(), params.clone()); - if let Some(r) = self.headless_op(move |h| h.command_start(&id2, params2, wait)).await { - return to_result(r); + to_result(self.step(id, params, wait, &mut Waiter::new(None), (0, 1)).await) + } + + /// Run one command in either backend; `at` is its place in a batch, for progress. + async fn step(&self, id: String, params: Value, wait: bool, waiter: &mut Waiter, at: (usize, usize)) -> Result { + let none = || Err(AutomationError::Other("internal error: no headless session or app bridge".into())); + match &*self.backend { + Backend::Headless(_) if wait => self.headless_wait(id, params, waiter, at).await.unwrap_or_else(none), + Backend::Headless(_) => self.headless_op(move |h| h.command_start(&id, params, false)).await.unwrap_or_else(none), + Backend::Bridge(b) if wait => self.bridge_wait(b, id, params, waiter, at).await, + Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params, "wait": false})).await, } - let Some(b) = self.bridge_client() else { - return Ok(no_backend()); - }; - to_result(b.call("engine.execute", json!({"command": id, "params": params, "wait": wait})).await) + } + + async fn batch(&self, p: BatchParams, mut waiter: Waiter) -> Result { + if p.steps.len() > MAX_BATCH_STEPS { + return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len()))); + } + let stop = p.stop_on_error.unwrap_or(true); + let total = p.steps.len(); + let mut results = Vec::new(); + let mut failed = 0; + // The reply travels as escaped JSON text, so steps are charged their escaped size. + let mut reply_budget = BatchReplyBudget::escaped(); + for (i, s) in p.steps.into_iter().enumerate() { + // A cancelled request runs no further steps; the client no longer reads the reply. + if waiter.is_cancelled() { + break; + } + let response = self.step(s.id, s.params.unwrap_or_else(|| json!({})), s.wait.unwrap_or(true), &mut waiter, (i, total)).await; + let was_error = response.is_err(); + let result = match response { + Ok(value) => json!({"ok": true, "result": value}), + Err(error) => { + let mut obj = json!({"ok": false, "error": error.to_string()}); + attach_memory_error_details(&mut obj, &error); + obj + } + }; + if let Err(error) = reply_budget.charge(&result) { + failed += 1; + results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")})); + break; + } + results.push(result); + if was_error { + failed += 1; + if stop { + break; + } + } + waiter.report((i + 1) as f64, total as f64, "").await; + } + Ok(ok_json(&json!({"completed": results.len() - failed, "failed": failed, "results": results}))) } /// `jobs.list` / `jobs.cancel` in either backend; `read` picks the bridge's retry rule. @@ -1150,9 +1201,10 @@ impl PhotocraftMcp { if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless - // does), the Targa bits, the DDS options and the TIFF compression, transparency and - // pyramid, so the other options are headless-only. Saying so beats saving with - // defaults while the caller believes their quality or format was applied. + // does), the Targa bits, the DDS options, the TIFF compression, transparency and + // pyramid, and Maximize Compatibility, so the other options are headless-only. Saying + // so beats saving with defaults while the caller believes their quality or format was + // applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), @@ -1190,6 +1242,9 @@ impl PhotocraftMcp { if p.tiff_pyramid { params["tiffPyramid"] = json!(true); } + if let Some(maximize) = p.maximize_compatibility { + params["maximizeCompatibility"] = json!(maximize); + } return to_result(b.call("app.save", params).await); } let Some(r) = self @@ -1207,13 +1262,21 @@ impl PhotocraftMcp { } opts.encode.tiff_pyramid = p.tiff_pyramid; opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; + if let Some(maximize) = p.maximize_compatibility { + opts.psd_maximize_compatibility = maximize; + } if let Some(q) = p.quality { opts.encode.jpeg_quality = q; opts.encode.webp_quality = q; opts.encode.webp_lossless = false; } let path = p.path.map(PathBuf::from); - h.save(p.index, path.as_deref(), p.format.as_deref(), &opts) + let res = h.save(p.index, path.as_deref(), p.format.as_deref(), &opts); + for d in h.drain_diagnostics() { + // Ignore a failed stderr write: the completed save must still be returned. + let _ = writeln!(std::io::stderr(), "warning: {d}"); + } + res }) .await else { @@ -1227,6 +1290,8 @@ mod conventions; mod progress; mod transport; +use progress::Waiter; + /// Used by the render helper in tests and the CLI. pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Result, AutomationError> { files::render_png(doc, max_side) @@ -1312,10 +1377,7 @@ mod tests { [(json!({"radius":"big"}), "radius"), (json!({"radus":3}), "radus"), (json!({"radius":5000}), "radius"), (json!({"radius":-4}), "radius")] { for wait in [true, false] { - let result = mcp - .command_run(Parameters(RunParams { id: "filter.blur.gaussianBlur".into(), params: Some(params.clone()), wait: Some(wait) })) - .await - .unwrap(); + let result = mcp.run_command("filter.blur.gaussianBlur".into(), params.clone(), wait).await.unwrap(); assert_eq!(result.is_error, Some(true)); let error = &result.content.first().and_then(|c| c.as_text()).unwrap().text; assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}"); @@ -1342,7 +1404,7 @@ mod tests { let step = |id: &str, params: Value| RunParams { id: id.into(), params: Some(params), wait: None }; let mut steps: Vec = (0..MAX_BATCH_STEPS - 1).map(|_| step("command.list", json!({}))).collect(); steps.push(step("file.new", json!({"width": 8, "height": 8}))); - let result = mcp.command_batch(Parameters(BatchParams { steps, stop_on_error: Some(false) })).await.unwrap(); + let result = mcp.batch(BatchParams { steps, stop_on_error: Some(false) }, Waiter::new(None)).await.unwrap(); assert_ne!(result.is_error, Some(true), "{:?}", result.content.first().and_then(|c| c.as_text()).map(|t| &t.text[..200.min(t.text.len())])); let text = &result.content.first().and_then(|c| c.as_text()).unwrap().text; let reply: Value = serde_json::from_str(text).unwrap(); @@ -1354,6 +1416,184 @@ mod tests { assert_eq!(docs, 0, "the step after the budget ran out did not run"); } + /// rmcp may drop a cancelled request's future without polling it again; its job must still + /// stop and leave the document unchanged (#8129). + #[tokio::test(flavor = "multi_thread")] + async fn dropping_a_waited_command_cancels_its_job() { + let mcp = PhotocraftMcp::headless(); + mcp.headless_op(|h| { + h.command_run("file.new", json!({"width": 6000, "height": 6000}))?; + h.command_run("filter.noise.addNoise", json!({"amount": 50})) + }) + .await + .unwrap() + .unwrap(); + let before = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(); + let mut waiter = Waiter::new(None); + let waited = mcp.headless_wait("filter.blur.gaussianBlur".into(), json!({"radius": 1000}), &mut waiter, (0, 1)); + assert!(tokio::time::timeout(std::time::Duration::from_millis(100), waited).await.is_err(), "the blur ended before it was dropped"); + let t = std::time::Instant::now(); + loop { + let (running, cancelled) = + mcp.headless_op(|h| Ok((h.session.has_jobs(), h.session.jobs_with_recent().iter().any(|j| j.state == "cancelled")))).await.unwrap().unwrap(); + if !running { + assert!(cancelled, "the job ended without being cancelled"); + break; + } + assert!(t.elapsed().as_secs() < 30, "the dropped job kept running"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!(mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(), before); + } + + /// Dropped while its start still waits for the session lock (held here as a long synchronous + /// command would): the job that starts afterwards is cancelled too, not left running. + #[tokio::test(flavor = "multi_thread")] + async fn a_wait_dropped_before_its_job_starts_cancels_the_job() { + let mcp = PhotocraftMcp::headless(); + mcp.headless_op(|h| { + h.command_run("file.new", json!({"width": 6000, "height": 6000}))?; + h.command_run("filter.noise.addNoise", json!({"amount": 50})) + }) + .await + .unwrap() + .unwrap(); + let before = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(); + let Backend::Headless(session) = &*mcp.backend else { panic!("headless") }; + let (session, (locked, is_locked)) = (session.clone(), std::sync::mpsc::channel()); + let holder = std::thread::spawn(move || { + let _held = session.lock().unwrap(); + locked.send(()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(300)); + }); + is_locked.recv().unwrap(); + let mut waiter = Waiter::new(None); + let waited = mcp.headless_wait("filter.blur.gaussianBlur".into(), json!({"radius": 1000}), &mut waiter, (0, 1)); + assert!(tokio::time::timeout(std::time::Duration::from_millis(50), waited).await.is_err(), "the start waits for the lock"); + holder.join().unwrap(); + let t = std::time::Instant::now(); + loop { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let (started, running) = mcp.headless_op(|h| Ok((!h.session.jobs_with_recent().is_empty(), h.session.has_jobs()))).await.unwrap().unwrap(); + if started && !running { + break; + } + assert!(t.elapsed().as_secs() < 30, "the job kept running"); + } + let cancelled = mcp.headless_op(|h| Ok(h.session.jobs_with_recent().iter().any(|j| j.state == "cancelled"))).await.unwrap().unwrap(); + assert!(cancelled, "the job ended without being cancelled"); + assert_eq!(mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(), before); + } + + /// Cancellation notifications only cancel the token: the handler remains alive while + /// its blocking start waits for the session lock. + #[tokio::test(flavor = "multi_thread")] + async fn cancelled_queued_commands_never_start() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let mcp = PhotocraftMcp::headless(); + mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap().unwrap(); + let before = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap(); + let Backend::Headless(session) = &*mcp.backend else { panic!("headless") }; + let session = session.clone(); + let (locked, acquired) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _held = session.lock().unwrap(); + locked.send(()).unwrap(); + released.recv().unwrap(); + }); + acquired.recv().unwrap(); + + let (mut input, server_in) = tokio::io::duplex(65536); + let (server_out, output) = tokio::io::duplex(65536); + let server = tokio::spawn(mcp.clone().serve_io(server_in, server_out)); + let mut lines = BufReader::new(output).lines(); + async fn send(input: &mut tokio::io::DuplexStream, value: Value) { + input.write_all(format!("{value}\n").as_bytes()).await.unwrap(); + } + send(&mut input, json!({"jsonrpc":"2.0", "id":1, "method":"initialize", "params":{"protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"test", "version":"1"}}})).await; + lines.next_line().await.unwrap().unwrap(); + send(&mut input, json!({"jsonrpc":"2.0", "method":"notifications/initialized"})).await; + for (id, command, params) in [(2, "layer.new.layer", json!({})), (3, "filter.blur.gaussianBlur", json!({"radius": 10}))] { + send( + &mut input, + json!({"jsonrpc":"2.0", "id":id, "method":"tools/call", "params":{"name":"command_run", "arguments":{"id":command, "params":params}}}), + ) + .await; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + for id in [2, 3] { + send(&mut input, json!({"jsonrpc":"2.0", "method":"notifications/cancelled", "params":{"requestId":id}})).await; + } + send(&mut input, json!({"jsonrpc":"2.0", "id":4, "method":"ping"})).await; + loop { + let reply: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + if reply["id"] == 4 { + break; + } + } + release.send(()).unwrap(); + holder.join().unwrap(); + // Let both blocked starts acquire the mutex before inspecting their effects. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let (after, jobs) = mcp.headless_op(|h| Ok((h.inspect(None)?, h.session.jobs_with_recent()))).await.unwrap().unwrap(); + assert_eq!(after, before); + assert!(jobs.is_empty(), "cancelled queued job started: {jobs:?}"); + drop(input); + server.await.unwrap().unwrap(); + } + + /// The same over the bridge (#8130): a dropped wait sends `jobs.cancel` for the app's job. + #[tokio::test(flavor = "multi_thread")] + async fn dropping_a_bridged_wait_cancels_the_apps_job() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let (cancels, mut cancelled) = tokio::sync::mpsc::unbounded_channel(); + let polled = Arc::new(tokio::sync::Notify::new()); + let polling = polled.clone(); + // A fake app whose job 3 never ends and whose poll never replies. Dropping the wait + // discards that connection, so the cancellation must arrive on a fresh one (#8130). + let app = tokio::spawn(async move { + loop { + let (socket, _) = listener.accept().await.unwrap(); + let (read, mut write) = socket.into_split(); + let mut lines = tokio::io::BufReader::new(read).lines(); + let (auth, _) = crate::security::authentication_reply(&lines.next_line().await.unwrap().unwrap(), TOKEN); + write.write_all(format!("{auth}\n").as_bytes()).await.unwrap(); + while let Ok(Some(line)) = lines.next_line().await { + let request: Value = serde_json::from_str(&line).unwrap(); + let result = match request["method"].as_str().unwrap() { + "engine.execute" => json!({"job": 3, "pending": true}), + "jobs.list" => { + polling.notify_one(); + continue; + } + "jobs.cancel" => json!({"cancelled": 1}), + method => panic!("unexpected method {method}"), + }; + write.write_all(format!("{}\n", json!({"id": request["id"], "ok": true, "result": result})).as_bytes()).await.unwrap(); + if request["method"] == "jobs.cancel" { + cancels.send(request).unwrap(); + return; + } + } + } + }); + let mcp = PhotocraftMcp::with_backend(Backend::Bridge(Arc::new(BridgeClient::new(addr, TOKEN).unwrap()))); + let mut waited = Box::pin(mcp.run_command("filter.blur.gaussianBlur".into(), json!({"radius": 4}), true)); + tokio::select! { + result = &mut waited => panic!("the running job answered before its wait was dropped: {result:?}"), + ready = tokio::time::timeout(std::time::Duration::from_secs(10), polled.notified()) => ready.expect("the app received the poll"), + } + drop(waited); + let cancel = tokio::time::timeout(std::time::Duration::from_secs(10), cancelled.recv()).await.unwrap().unwrap(); + assert_eq!((&cancel["method"], &cancel["params"]), (&json!("jobs.cancel"), &json!({"job": 3}))); + tokio::time::timeout(std::time::Duration::from_secs(10), app).await.unwrap().unwrap(); + } + #[tokio::test(flavor = "multi_thread")] async fn mcp_doc_open_schema_and_structured_error() { let schema = &PhotocraftMcp::tool_router().map["doc_open"].attr.input_schema; @@ -1401,7 +1641,12 @@ mod tests { assert!(serde_json::from_value::(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}"); } - let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 }); + let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { + name: "huge.psd".into(), + required: 9_000, + limit: 8_000, + free_memory: None, + }); let res = to_result(Err(err)).unwrap(); assert_eq!(res.is_error, Some(true)); let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text; @@ -1412,6 +1657,21 @@ mod tests { for absent in ["soft", "softLimit", "available", "availableRam"] { assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}"); } + + // With measured free memory behind the limit, the refusal reports it too. + let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { + name: "huge.psd".into(), + required: 9_000, + limit: 8_000, + free_memory: Some(10_000), + }); + let res = to_result(Err(err)).unwrap(); + let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text; + let v: Value = serde_json::from_str(text).unwrap(); + assert_eq!(v["hardLimit"], 8_000); + assert_eq!(v["available"], 10_000); + assert_eq!(v["availableRam"], 10_000); + assert!(v.get("soft").is_none(), "a hard refusal has no soft limit: {v}"); } /// The MCP server applies finished jobs before every tool call too (#503). diff --git a/crates/automation/src/server/conventions.rs b/crates/automation/src/server/conventions.rs index b48c41db87..c6792a1e6b 100644 --- a/crates/automation/src/server/conventions.rs +++ b/crates/automation/src/server/conventions.rs @@ -17,11 +17,14 @@ fn annotated(mut tool: Tool) -> Tool { let title = chars.next().map(|c| c.to_uppercase().collect::() + chars.as_str()).unwrap_or_default(); let read = matches!( tool.name.as_ref(), - "command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot" + "command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot" | "jobs_list" ); - let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place"); + // Changes state without discarding anything: the scoped exports refuse to overwrite a file, + // and switching the active document edits nothing. + let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place" | "doc_select" | "doc_export_layers" | "doc_export_data_sets"); + let idempotent = read || tool.name == "doc_select"; tool.title = Some(title.clone()); - tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false))); + tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(idempotent), Some(false))); Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false)); tool } diff --git a/crates/automation/src/server/progress.rs b/crates/automation/src/server/progress.rs index 1844203f8e..8b3697f65d 100644 --- a/crates/automation/src/server/progress.rs +++ b/crates/automation/src/server/progress.rs @@ -1,5 +1,9 @@ -//! Render Video runs on the existing blocking worker; rmcp remains available for ping/cancel. -use super::{CallToolResult, PhotocraftMcp, Value, fail, no_backend, ok_json}; +//! Progress and cancellation for calls that wait. Render Video runs on the existing blocking +//! worker; other long commands run as engine jobs that the server polls ([`Waiter`]): the headless +//! session's own, or the app's over the bridge. +use super::{Arc, AutomationError, Backend, BridgeClient, CallToolResult, PhotocraftMcp, PoisonError, Value, fail, json, no_backend, ok_json}; +use crate::headless::JobPoll; +use photocraft_engine::jobs::{JobId, Started}; use rmcp::RoleServer; use rmcp::model::ProgressNotificationParam; use rmcp::service::RequestContext; @@ -41,3 +45,267 @@ impl PhotocraftMcp { } } } + +/// Where a waited call reports progress and learns that it was cancelled. Without a request +/// (a direct call in a test) nothing is reported and nothing cancels. +pub(super) struct Waiter { + context: Option>, + last: Option, + sent_at: Option, +} + +impl Waiter { + pub(super) fn new(context: Option>) -> Self { + Waiter { context, last: None, sent_at: None } + } + + pub(super) fn is_cancelled(&self) -> bool { + self.context.as_ref().is_some_and(|c| c.ct.is_cancelled()) + } + + async fn cancelled(&self) { + match &self.context { + Some(c) => c.ct.cancelled().await, + None => std::future::pending().await, + } + } + + /// Send `progress` of `total` when the request has a progress token. At most every 100ms, + /// and only when it grew (MCP requires progress to increase). + pub(super) async fn report(&mut self, progress: f64, total: f64, message: &str) { + let Some(context) = &self.context else { return }; + let Some(token) = context.meta.get_progress_token() else { return }; + let due = self.sent_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(100)); + if !due || !progress.is_finite() || self.last.is_some_and(|n| progress <= n) || context.ct.is_cancelled() { + return; + } + self.last = Some(progress); + self.sent_at = Some(Instant::now()); + let mut note = ProgressNotificationParam::new(token, progress).with_total(total); + if !message.is_empty() { + note = note.with_message(message); + } + let _ = context.peer.notify_progress(note).await; + } +} + +/// Cancels a waited job when its waiter goes away without collecting it: rmcp may drop the +/// request future on cancellation, and nothing else would stop the job. +struct Abandon { + backend: Arc, + job: Option, +} + +impl Drop for Abandon { + fn drop(&mut self) { + let Some(job) = self.job.take() else { return }; + let runtime = tokio::runtime::Handle::try_current(); + match &*self.backend { + Backend::Headless(session) => { + let session = session.clone(); + let abandon = move || drop(session.lock().unwrap_or_else(PoisonError::into_inner).job_abandon(job)); + match runtime { + // A synchronous command can hold the lock for a while: don't block a runtime thread. + Ok(runtime) => drop(runtime.spawn_blocking(abandon)), + Err(_) => abandon(), + } + } + // Without a runtime there is no way to reach the app; the job then runs to the end. + Backend::Bridge(client) => { + if let Ok(runtime) = runtime { + let client = client.clone(); + drop(runtime.spawn(async move { client.call("jobs.cancel", json!({"job": job.0})).await })); + } + } + } + } +} + +impl PhotocraftMcp { + /// Headless: run command `id` for a caller that waits (#8129). A job-capable command runs as + /// a background job, so the session serves other calls meanwhile; its progress goes to + /// `waiter` as `step.0 + fraction` of `step.1`, and cancelling the request cancels the job, + /// leaving the document unchanged. `None` in bridge mode. + pub(super) async fn headless_wait(&self, id: String, params: Value, waiter: &mut Waiter, step: (usize, usize)) -> Option> { + if !matches!(&*self.backend, Backend::Headless(_)) { + return None; + } + let backend = self.backend.clone(); + let cancel = waiter.context.as_ref().map(|context| context.ct.clone()); + // The guard is armed under the lock that starts the job: when the request is dropped while + // the start still waits for the lock, the closure's output is dropped unread and cancels it. + let mut guard = match self + .headless_op(move |h| { + if cancel.as_ref().is_some_and(|token| token.is_cancelled()) { + return Err(photocraft_engine::EngineError::Cancelled.into()); + } + Ok(match h.command_begin(&id, params)? { + Started::Job(job) if cancel.as_ref().is_some_and(|token| token.is_cancelled()) => { + return h.job_abandon(job).map(Err); + } + Started::Job(job) => Ok(Abandon { backend, job: Some(job) }), + Started::Done(value) => Err(value), + }) + }) + .await? + { + Ok(Ok(guard)) => guard, + Ok(Err(value)) => return Some(Ok(value)), + Err(error) => return Some(Err(error)), + }; + let Some(job) = guard.job else { return Some(Err(AutomationError::Other("internal error: a started job has no id".into()))) }; + let mut delay = Duration::from_millis(1); + loop { + match self.headless_op(move |h| Ok(h.job_poll(job))).await? { + Ok(JobPoll::Running(fraction, message)) => { + let fraction = if fraction.is_finite() { f64::from(fraction.clamp(0.0, 1.0)) } else { 0.0 }; + waiter.report(step.0 as f64 + fraction, step.1 as f64, &message).await; + } + Ok(JobPoll::Ended(result)) => { + guard.job = None; + return Some(result); + } + Err(error) => return Some(Err(error)), + } + tokio::select! { + biased; + () = waiter.cancelled() => { + // Cancel before answering, so the next call already sees the document unchanged. + // A job that ended first is answered with its result, not as cancelled. + guard.job = None; + return Some(self.headless_op(move |h| Ok(h.job_abandon(job))).await?.and_then(|result| result)); + } + () = tokio::time::sleep(delay) => {} + } + delay = (delay * 2).min(Duration::from_millis(50)); + } + } + /// Bridge: run command `id` for a caller that waits (#8130). An engine command starts with + /// `wait: false`; when the app runs it as a background job, `jobs.list` is polled until the job + /// ends, so no single reply has to arrive within the client's call timeout. Progress and + /// cancellation as in [`Self::headless_wait`]. Only the start is an edit, and it is sent once + /// (#1007); a failed poll reports the job instead of guessing its outcome. + pub(super) async fn bridge_wait( + &self, + client: &BridgeClient, + id: String, + params: Value, + waiter: &mut Waiter, + step: (usize, usize), + ) -> Result { + // A UI command can start a job without naming it in its reply: the app waits on those. + if photocraft_engine::commands::find(&id).is_none() { + return client.call("engine.execute", json!({"command": id, "params": params, "wait": true})).await; + } + let started = client.call("engine.execute", json!({"command": id, "params": params, "wait": false})).await?; + let pending = started.get("pending").and_then(Value::as_bool) == Some(true); + let Some(job) = started.get("job").and_then(Value::as_u64).filter(|_| pending) else { + return Ok(started); + }; + let mut guard = Abandon { backend: self.backend.clone(), job: Some(JobId(job)) }; + let mut delay = Duration::from_millis(10); + loop { + // Race the poll against a cancel: an app slow to answer must not hold it until the call times out. + let polled = tokio::select! { + biased; + listed = client.call_read("jobs.list", json!({})) => Some(listed), + () = waiter.cancelled() => None, + }; + let listed = match polled { + Some(Ok(listed)) => listed, + Some(Err(error)) if !waiter.is_cancelled() => { + // The app may be fine and the job still running: leave it, and say how to find it. + guard.job = None; + return Err(AutomationError::Bridge(format!( + "lost track of job {job} ({id}): {error}; it may still be running and apply its result: check jobs_list, or stop it with jobs_cancel" + ))); + } + // Cancelled during the poll, which may also have failed: the app may answer a + // fresh connection. + _ => return Err(bridge_cancel(client, &mut guard, job, &id).await), + }; + let entry = listed.get("jobs").and_then(Value::as_array).and_then(|jobs| jobs.iter().find(|j| j.get("id").and_then(Value::as_u64) == Some(job))); + let Some(entry) = entry else { + guard.job = None; + return Err(AutomationError::Bridge(format!( + "job {job} ({id}) ended but jobs.list no longer shows it (it keeps the last few); inspect the document for its result" + ))); + }; + let text = |key: &str| entry.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + match entry.get("state").and_then(Value::as_str) { + Some("running") => { + let fraction = entry.get("progress").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |f| f.clamp(0.0, 1.0)); + waiter.report(step.0 as f64 + fraction, step.1 as f64, &text("message")).await; + } + state => { + guard.job = None; + // The same results the app's own waited reply gives. + return match state { + Some("done") => Ok(entry.get("result").cloned().unwrap_or(Value::Null)), + Some("failed") => Err(AutomationError::App(Some(text("error")).filter(|e| !e.is_empty()).unwrap_or_else(|| "unknown error".into()))), + Some("cancelled") => Err(AutomationError::App("cancelled".into())), + _ => Err(AutomationError::Bridge(format!("job {job} ({id}) has an unknown state: {entry}"))), + }; + } + } + tokio::select! { + biased; + () = waiter.cancelled() => return Err(bridge_cancel(client, &mut guard, job, &id).await), + () = tokio::time::sleep(delay) => {} + } + delay = (delay * 2).min(Duration::from_millis(200)); + } + } +} + +/// Cancel bridged `job` (`id`) before answering a cancelled wait. Failed calls leave the guard +/// armed for one best-effort cleanup attempt on drop. +async fn bridge_cancel(client: &BridgeClient, guard: &mut Abandon, job: u64, id: &str) -> AutomationError { + let cancel = client.call("jobs.cancel", json!({"job": job})).await; + // A failed or abandoned call keeps the guard armed for one cleanup attempt on drop. + // Only an acknowledged cancellation proves that cleanup is no longer needed. + // A definite refusal is not improved by retrying; malformed replies and transport + // failures leave the guard armed because the outcome is uncertain. + if cancel.as_ref().is_ok_and(|value| cancellation_acknowledged(value) || refusal(value, job).is_some()) { + guard.job = None; + } + cancel_answer(job, id, cancel) +} + +/// The answer to a bridged wait that was cancelled, from the app's reply to `jobs.cancel`. The app +/// refuses to cancel a job that already ended, whose result may then be applied: say so rather +/// than report a cancellation. +fn cancel_answer(job: u64, id: &str, reply: Result) -> AutomationError { + match reply { + Ok(value) if refusal(&value, job).is_some() => { + let reason = refusal(&value, job).unwrap_or("unknown reason"); + AutomationError::Bridge(format!("job {job} ({id}) was not cancelled: {reason}; check jobs_list for its outcome")) + } + Ok(value) if cancellation_acknowledged(&value) => photocraft_engine::EngineError::Cancelled.into(), + Ok(value) => AutomationError::Bridge(format!("cancellation of job {job} ({id}) was not confirmed: {value}; check jobs_list for its outcome")), + Err(error) => AutomationError::Bridge(format!("job {job} ({id}) cancellation was not confirmed: {error}; check jobs_list for its outcome")), + } +} + +fn cancellation_acknowledged(value: &Value) -> bool { + value.get("cancelled").and_then(Value::as_u64) == Some(1) +} + +fn refusal(value: &Value, job: u64) -> Option<&str> { + value.get("notCancelled")?.as_array()?.iter().find(|entry| entry.get("job").and_then(Value::as_u64) == Some(job))?.get("reason")?.as_str() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_refused_cancel_is_not_reported_as_cancelled() { + let cancelled = cancel_answer(7, "filter.blur.gaussianBlur", Ok(json!({"cancelled": 1}))); + assert!(matches!(cancelled, AutomationError::Engine(photocraft_engine::EngineError::Cancelled)), "{cancelled}"); + let refused = cancel_answer(7, "filter.blur.gaussianBlur", Err(AutomationError::App("job 7 is not running".into()))); + assert!(matches!(&refused, AutomationError::Bridge(m) if m.contains("not confirmed") && m.contains("job 7 is not running")), "{refused}"); + let refused = cancel_answer(7, "filter.blur.gaussianBlur", Ok(json!({"cancelled": 0, "notCancelled": [{"job": 7, "reason": "committing"}]}))); + assert!(matches!(&refused, AutomationError::Bridge(m) if m.contains("job 7") && m.contains("committing") && m.contains("jobs_list")), "{refused}"); + } +} diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 3e068258fe..ade7ff9707 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -494,6 +494,7 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool { | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" + | "shape.presets.importCsh" | "plugin.install" | "swatches.import" | "style.presets.importAsl" diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 5ea4a5b3fe..1759016726 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -572,6 +572,18 @@ fn registry_filesystem_path_params_are_classified() { assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}"); } +/// Shape Blur uses inline vector geometry without granting a headless caller filesystem access. +#[test] +fn shape_blur_accepts_inline_geometry_and_rejects_filesystem_strings() { + let mut headless = crate::headless::Headless::new(); + headless.command_run("file.new", serde_json::json!({"width":8,"height":8})).unwrap(); + headless + .command_run("filter.blur.shapeBlur", serde_json::json!({"radius":5,"shapePath":{"subpaths":[{"knots":[[0,0],[1,0],[1,1],[0,1]],"closed":true}]}})) + .unwrap(); + let error = headless.command_run("filter.blur.shapeBlur", serde_json::json!({"radius":5,"shapePath":"/outside/shape.svg"})).unwrap_err(); + assert!(error.to_string().contains("nonempty vector path"), "{error}"); +} + #[test] fn native_vector_preview_updates_are_not_an_untrusted_automation_capability() { let params = serde_json::json!({"document":1,"layer":1,"contentsId":1,"fingerprint":"00".repeat(32),"extension":"ai","sourceBase64":"JVBERi0=","previewSvg":""}); @@ -698,6 +710,7 @@ fn svg_update_keeps_inline_and_builtin_smart_filters_working() { } } +/// Distinguish filesystem parameters from descriptions of inline vector paths. fn documents_filesystem_path_params(id: &str, params: &str) -> bool { // These commands return settings or installed-profile paths; they do not accept a host // filesystem path as an automation input. @@ -707,7 +720,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { // These registry descriptions refer to document vector paths, not host files. if matches!( id, - "filter.blurGallery.pathBlur" + "filter.blur.shapeBlur" + | "filter.blurGallery.pathBlur" | "filter.render.flame" | "shape.create" | "shape.edit" @@ -718,7 +732,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.info" | "path.addToSelection" | "path.subtractFromSelection" + | "path.intersectWithSelection" | "path.set" + | "actions.insertPath" | "path.transform" | "path.moveAnchors" | "path.moveHandle" @@ -731,6 +747,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.clippingPath.set" | "path.rename" | "path.stroke" + | "path.toShape" | "type.attachToPath" | "type.fillPath" | "type.detachFromPath" @@ -747,6 +764,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "layer.combineShapes.intersectShapeAreas" | "layer.combineShapes.excludeOverlappingShapes" | "layer.combineShapes.mergeShapeComponents" + | "layer.new.frame" + // `"stepName":"Transform Path"` names the History step, not a host file. + | "edit.transform" ) || id.starts_with("layer.newFillLayer.") || id.starts_with("layer.newAdjustmentLayer.") { @@ -1054,3 +1074,64 @@ fn absolute_paths_are_checked_against_the_authority_s_own_root() { let none = AuthorizedWorkspace::default(); assert!(none.read(&target.to_string_lossy()).is_err()); } + +#[test] +fn csh_import_requires_inline_data_in_automation() { + let id = "shape.presets.importCsh"; + for path in ["/outside/set.csh", "set.csh", "../set.csh", ""] { + let params = serde_json::json!({"path": path}); + assert!(authorize_engine_command(id, ¶ms).is_err(), "{path}"); + assert!(authorize_desktop_engine_command(id, ¶ms).is_err(), "{path}"); + } + // The importer gives inline data precedence, even when a path is also supplied. + for params in [serde_json::json!({"data": "QUJD"}), serde_json::json!({"data": "QUJD", "path": "/outside/set.csh"})] { + assert!(authorize_engine_command(id, ¶ms).is_ok()); + assert!(authorize_desktop_engine_command(id, ¶ms).is_ok()); + } + let mut headless = crate::headless::Headless::new(); + let error = headless.command_run(id, serde_json::json!({"path": "/outside/set.csh"})).unwrap_err(); + assert!(error.to_string().contains("uses ambient filesystem paths and is disabled"), "{error}"); +} + +/// A one-shape CSH set (version 2) named `name`, with a single open one-knot subpath. +fn one_shape_csh(name: &str) -> Vec { + use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath}; + let mut bytes = b"cush".to_vec(); + bytes.extend_from_slice(&2u32.to_be_bytes()); + bytes.extend_from_slice(&1u32.to_be_bytes()); + let units: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + bytes.extend_from_slice(&(units.len() as u32).to_be_bytes()); + for unit in units { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + while !bytes.len().is_multiple_of(4) { + bytes.push(0); + } + bytes.extend_from_slice(&1u32.to_be_bytes()); + let mut body = vec![2, b'i', b'd']; + for bound in [0i32, 0, 100, 100] { + body.extend_from_slice(&bound.to_be_bytes()); + } + let knot = PsdKnot { linked: true, pre: (0.25, 0.25), anchor: (0.5, 0.5), post: (0.75, 0.75) }; + body.extend_from_slice(&PathData::from_subpaths(&[PsdSubpath { closed: false, operation: 0, knots: vec![knot] }], false).to_bytes()); + while !body.len().is_multiple_of(4) { + body.push(0); + } + bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&body); + bytes +} + +/// The headless import itself reads inline `data` before `path`: a valid set imports even when +/// the path beside it cannot be read (and would be refused as an ambient path on its own). +#[test] +fn csh_import_with_inline_data_ignores_an_unreadable_path() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(one_shape_csh("Inline")); + let mut headless = crate::headless::Headless::new(); + let params = serde_json::json!({"data": data, "path": "/outside/missing-set.csh", "group": "Inline Set"}); + let result = headless.command_run("shape.presets.importCsh", params).unwrap(); + assert_eq!(result["count"], 1, "{result}"); + assert_eq!(result["names"], serde_json::json!(["Inline"]), "{result}"); + assert_eq!(result["group"], "Inline Set", "{result}"); +} diff --git a/crates/automation/tests/it/bridge.rs b/crates/automation/tests/it/bridge.rs index 258e08e4fd..460a33e264 100644 --- a/crates/automation/tests/it/bridge.rs +++ b/crates/automation/tests/it/bridge.rs @@ -164,3 +164,39 @@ async fn a_timed_out_call_is_not_retried() { assert!(!error.contains("may have completed"), "a read timeout never claims an applied edit: {error}"); assert_eq!(requests.lock().unwrap().len(), 1, "a timed-out call is not resent"); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_dropped_before_its_reply_does_not_reuse_its_connection() { + // Connection 1 never answers. A call dropped while waiting on it (a cancelled MCP wait) must + // close it: on the same socket the next request would queue behind the unanswered one. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = std::sync::Arc::clone(&accepted); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let silent = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0; + tokio::spawn(async move { + authenticate(&mut socket).await; + let (read, mut write) = socket.into_split(); + let mut lines = BufReader::new(read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(request) = serde_json::from_str::(&line) else { continue }; + if !silent { + let reply = json!({"id": request["id"], "ok": true, "result": {"served": true}}); + if write.write_all(format!("{reply}\n").as_bytes()).await.is_err() { + return; + } + } + } + }); + } + }); + + let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap().with_timeout(Duration::from_secs(30)); + let dropped = tokio::time::timeout(Duration::from_millis(300), bridge.call_read("jobs.list", json!({}))).await; + assert!(dropped.is_err(), "the silent connection answered: {dropped:?}"); + let next = tokio::time::timeout(Duration::from_secs(10), bridge.call("jobs.cancel", json!({"job": 7}))).await; + assert_eq!(next.expect("the next call waited on the dropped call's connection").unwrap()["served"], true); + assert_eq!(accepted.load(std::sync::atomic::Ordering::SeqCst), 2, "the next call opened a fresh connection"); +} diff --git a/crates/automation/tests/it/gpu_backend.rs b/crates/automation/tests/it/gpu_backend.rs new file mode 100644 index 0000000000..e5b3c599a8 --- /dev/null +++ b/crates/automation/tests/it/gpu_backend.rs @@ -0,0 +1,284 @@ +//! Tests for the headless GPU backend, capability isolation, cache mutation, and CPU/GPU parity. + +#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] +mod tests { + use std::fs; + + use photocraft_codecs::decode; + use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent}; + use photocraft_geom::{Rect, Size}; + use photocraft_io::BandRenderer; + use photocraft_raster::Surface; + + use photocraft_automation::workspace::AuthorizedWorkspace; + use photocraft_automation::{GpuRenderer, Headless}; + + fn make_test_doc(w: u32, h: u32, depth: SampleType) -> Document { + let mut doc = Document::with_background("test_doc", Size::new(w, h), ColorMode::Rgb, depth, Color::WHITE); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(Rect::new(10, 10, w as i32 - 10, h as i32 - 10), &[0.2, 0.5, 0.8, 0.8]); + let mut top = Layer::new("top", LayerContent::Raster(s)); + top.opacity = 0.75; + top.blend = BlendMode::Multiply; + doc.layers.push(top); + doc + } + + #[test] + fn headless_with_gpu_renders_png_full_and_scaled() { + let mut h = Headless::trusted_local().with_gpu(); + let doc = make_test_doc(120, 80, SampleType::U8); + h.session.open_document(doc, None); + + // Full size render (max_side = 0) + let full_png = h.render_png(None, 0).expect("full render_png"); + let full_img = decode(&full_png).expect("decode full"); + assert_eq!((full_img.width(), full_img.height()), (120, 80)); + let diagnostics = h.drain_diagnostics(); + + // Scaled preview (max_side = 40) + let thumb_png = h.render_png(None, 40).expect("thumbnail render_png"); + let thumb_img = decode(&thumb_png).expect("decode thumbnail"); + assert_eq!((thumb_img.width(), thumb_img.height()), (40, 27)); + + // Skip when this host has no usable GPU: then the full-size render legitimately used + // the CPU path instead, and there is no GPU result to check. + if diagnostics.iter().any(|d| d.contains("no GPU adapter available") || d.contains("could not build GPU compositor")) { + return; + } + assert!(diagnostics.iter().any(|d| d.contains("GPU adapter:")), "GPU was not initialized: {diagnostics:?}"); + assert!(!diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "full-size render fell back to CPU: {diagnostics:?}"); + } + + #[test] + fn capability_scoped_writes_preserved_with_gpu() { + let dir = std::env::temp_dir().join(format!("photocraft-cap-test-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + let workspace = AuthorizedWorkspace::new(Some(&dir), Some(&dir)).expect("authorized workspace"); + + let mut h = Headless::with_workspace(workspace).with_gpu(); + let doc = make_test_doc(64, 64, SampleType::U8); + h.session.open_document(doc, None); + + let valid_target = dir.join("out.png"); + let res = h.save(None, Some(&valid_target), Some("png"), &Default::default()); + assert!(res.is_ok(), "authorized write must succeed: {:?}", res); + assert!(valid_target.exists()); + + // Attempt write outside authorized workspace + let invalid_target = std::env::temp_dir().join("outside_unauthorized.png"); + let denied = h.save(None, Some(&invalid_target), Some("png"), &Default::default()); + assert!(denied.is_err(), "write outside authorized workspace must be denied"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn cache_invalidation_across_mutations_and_distinct_docs() { + let mut h = Headless::trusted_local().with_gpu(); + + // 1. Render Doc 1 + let doc1 = make_test_doc(64, 64, SampleType::U8); + h.session.open_document(doc1, None); + let png1_initial = h.render_png(Some(0), 0).expect("initial render"); + let diagnostics = h.drain_diagnostics(); + if diagnostics.iter().any(|d| d.contains("no GPU adapter available") || d.contains("could not build GPU compositor")) { + return; + } + assert!(diagnostics.iter().any(|d| d.contains("GPU adapter:")), "GPU was not initialized: {diagnostics:?}"); + assert!(!diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "initial render fell back to CPU: {diagnostics:?}"); + let img1_initial = decode(&png1_initial).expect("decode initial"); + + // 2. Mutate Doc 1 in place (change top layer opacity to 0.1) + h.session + .edit("Change layer opacity", |doc, _| { + doc.layers[1].opacity = 0.1; + Ok(()) + }) + .expect("edit opacity"); + let png1_mutated = h.render_png(Some(0), 0).expect("mutated render"); + let mutated_diagnostics = h.drain_diagnostics(); + assert!(!mutated_diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "mutated render fell back to CPU: {mutated_diagnostics:?}"); + let img1_mutated = decode(&png1_mutated).expect("decode mutated"); + + assert_ne!(img1_initial.data(), img1_mutated.data(), "mutating document must invalidate cache and produce fresh pixels"); + + // 3. Open Doc 2 with distinct contents + let mut doc2 = Document::with_background("doc2", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::BLACK); + let mut s2 = Surface::new(doc2.pixel_format()); + s2.fill_rect(Rect::new(0, 0, 64, 64), &[1.0, 0.0, 0.0, 1.0]); + doc2.layers.push(Layer::new("red", LayerContent::Raster(s2))); + h.session.open_document(doc2, None); + + let png2 = h.render_png(Some(1), 0).expect("doc2 render"); + let doc2_diagnostics = h.drain_diagnostics(); + assert!(!doc2_diagnostics.iter().any(|d| d.contains("custom renderer unavailable")), "second-document render fell back to CPU: {doc2_diagnostics:?}"); + let img2 = decode(&png2).expect("decode doc2"); + + assert_ne!(img2.data(), img1_mutated.data(), "distinct document must not return stale pixels from previous doc"); + } + + #[test] + fn cpu_gpu_direct_parity_and_matrix_verification() { + let mut gpu_renderer = match GpuRenderer::new() { + Ok(r) => r, + Err(e) => { + eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present"); + return; + } + }; + + let adapter = gpu_renderer.adapter_name().unwrap_or("unknown"); + let is_software = gpu_renderer.is_software(); + println!("GPU parity verified using adapter '{adapter}' (software: {is_software})"); + + // Matrix: 8-bit output x Alpha modes (opaque background, translucent document). + // U16 and F32 are deliberately excluded: band readback uploads half-precision textures, + // so both must reject and let the export fall back to the CPU (checked below). + for with_alpha in [false, true] { + let doc = if with_alpha { + let mut d = Document::new("translucent", Size::new(96, 96), ColorMode::Rgb, SampleType::U8); + let mut s = Surface::new(d.pixel_format()); + s.fill_rect(Rect::new(10, 10, 86, 86), &[0.3, 0.6, 0.9, 0.5]); + d.layers.push(Layer::new("trans", LayerContent::Raster(s))); + d + } else { + make_test_doc(96, 96, SampleType::U8) + }; + + // Independent reference CPU compositor oracle + let mut cpu_pixels = Vec::new(); + photocraft_compose::render_bands(&doc, doc.bounds(), 16, |buf| { + cpu_pixels.extend_from_slice(&buf.px); + Ok::<(), std::convert::Infallible>(()) + }) + .expect("CPU reference compose failed"); + + // Direct GPU renderer invocation (MUST NOT fall back to CPU) + let mut gpu_pixels = Vec::new(); + gpu_renderer + .render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |band| { + gpu_pixels.extend_from_slice(&band.pixels); + Ok::<(), photocraft_io::IoError>(()) + }) + .expect("direct GPU render_bands failed"); + + assert_eq!(cpu_pixels.len(), gpu_pixels.len()); + let tol = 1.0 / 255.0; + for (i, (c, g)) in cpu_pixels.iter().zip(&gpu_pixels).enumerate() { + for ch in 0..4 { + assert!((c[ch] - g[ch]).abs() <= tol, "alpha {} pixel {} ch {} differs: cpu={}, gpu={}", with_alpha, i, ch, c[ch], g[ch]); + } + } + } + + // U16 and F32 need the CPU compositor: band readback would quantise them to half. + for depth in [SampleType::U16, SampleType::F32] { + let doc = make_test_doc(64, 64, depth); + let res = gpu_renderer.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |_| Ok::<(), photocraft_io::IoError>(())); + assert!( + matches!(res, Err(photocraft_io::RenderError::Unsupported(_))), + "{depth:?} documents must be rejected so the export falls back to CPU: {res:?}" + ); + } + } + + #[test] + fn high_depth_exports_fall_back_to_the_cpu_compositor() { + let mut gpu_renderer = match GpuRenderer::new() { + Ok(r) => r, + Err(e) => { + eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present"); + return; + } + }; + + let opts = photocraft_io::ExportOptions::default(); + for depth in [SampleType::U16, SampleType::F32] { + let doc = make_test_doc(64, 64, depth); + let expected = photocraft_io::export_flat(&doc, photocraft_codecs::Format::Png, &opts).expect("CPU export"); + let res = photocraft_io::export_flat_with_renderer( + &doc, + photocraft_codecs::Format::Png, + &opts, + &photocraft_raster::Interrupt::NONE, + Some(&mut gpu_renderer), + ) + .expect("the export must retry on the CPU"); + assert_eq!(expected.bytes, res.bytes, "{depth:?} exports must keep the CPU compositor's precision"); + assert!(res.warnings.iter().any(|w| w.contains("falling back to CPU")), "{depth:?} export must report its CPU fallback: {:?}", res.warnings); + } + } + + #[test] + fn gpu_renderer_diagnostics_and_bounded_storage() { + let mut r = match GpuRenderer::new() { + Ok(r) => r, + Err(e) => { + eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present"); + return; + } + }; + + let diags = r.drain_diagnostics(); + assert!(!diags.is_empty(), "adapter status must be reported once in diagnostics"); + assert!(diags[0].contains("GPU adapter:"), "unexpected diagnostic: {:?}", diags); + + // After draining, second drain should be empty + assert!(r.drain_diagnostics().is_empty()); + + // Push diagnostics up to limit and verify bounded dedup storage + for i in 0..30 { + r.push_diagnostic(format!("test diagnostic {i}")); + } + let drained = r.drain_diagnostics(); + assert!(drained.len() <= 16, "diagnostics must be capped to bounded limit, got {}", drained.len()); + } + + #[test] + fn gpu_renderer_preserves_sink_errors_and_cancellation() { + let mut gpu_renderer = match GpuRenderer::new() { + Ok(r) => r, + Err(e) => { + eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present"); + return; + } + }; + + let doc = make_test_doc(64, 64, SampleType::U8); + + // 1. Cancellation preservation + let cancel_res = + gpu_renderer.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |_| Err(photocraft_io::IoError::Cancelled)); + assert!(matches!(cancel_res, Err(photocraft_io::RenderError::Cancelled)), "cancellation must be preserved: {cancel_res:?}"); + + // 2. Sink I/O error preservation + let sink_res = gpu_renderer.render_bands(&doc, doc.bounds(), 16, &photocraft_raster::Interrupt::NONE, &mut |_| { + Err(photocraft_io::IoError::Read("simulated sink error".into())) + }); + assert!(matches!(sink_res, Err(photocraft_io::RenderError::Sink(_))), "sink error must be preserved as RenderError::Sink: {sink_res:?}"); + } + + #[test] + fn gpu_renderer_bounded_readback_staging() { + let mut gpu_renderer = match GpuRenderer::new() { + Ok(r) => r, + Err(e) => { + eprintln!("SKIPPED: GpuRenderer unavailable ({e}); no GPU adapter present"); + return; + } + }; + + let doc = make_test_doc(128, 128, SampleType::U8); + gpu_renderer.set_max_staging_bytes(32 * 1024); + + let mut received_rows = 0; + let res = gpu_renderer.render_bands(&doc, doc.bounds(), 0, &photocraft_raster::Interrupt::NONE, &mut |band| { + received_rows += band.rect.height(); + Ok(()) + }); + assert!(res.is_ok(), "bounded staging readback must succeed: {res:?}"); + assert_eq!(received_rows, 128); + } +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 0fdd94a4a8..e14cbcab39 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -4,6 +4,7 @@ mod agent_tasks; mod agent_tasks_extended; mod agent_tasks_more; mod bridge; +mod gpu_backend; mod inactive_save; mod krita; mod layered_save_dirty; @@ -14,4 +15,5 @@ mod native_full_composite; mod native_save_name; mod openraster; mod raw_defaults; +mod renderer_save; mod smart_filter_authorization; diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index 2320bf7adc..7b4ce8aae3 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -513,6 +513,70 @@ async fn headless_doc_save_writes_tiff_options() { cleanup(&dir); } +/// #8064: headless `doc_save`/`doc_export` take `maximizeCompatibility` for a layered PSD/PSB's +/// merged image: false writes Photoshop's placeholder and says so in resource 1057; unset, the +/// real image. A value that isn't a boolean is refused before anything is written. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_save_takes_maximize_compatibility() { + let dir = tmp("maximize-compat"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 64, "height": 32, "background": "white"})).await; + let r = call(&client, "command_run", json!({"id": "layer.new.layer", "params": {}})).await; + assert_ne!(r.is_error, Some(true), "{}", text(&r)); + for (tool, params, real) in [ + ("doc_export", json!({"path": "default.psd"}), true), + ("doc_export", json!({"path": "off.psd", "maximizeCompatibility": false}), false), + ("doc_save", json!({"path": "off.psb", "maximizeCompatibility": false}), false), + ("doc_save", json!({"path": "on.psd", "maximizeCompatibility": true}), true), + ] { + let r = call(&client, tool, params.clone()).await; + assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); + let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); + assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); + } + let r = call(&client, "doc_export", json!({"path": "bad.psd", "maximizeCompatibility": "no"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(!dir.join("bad.psd").exists()); + client.cancel().await.unwrap(); + cleanup(&dir); +} + +/// #8064: `doc_export_layers` takes `maximizeCompatibility` too, for PSD/PSB layer files. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_export_layers_takes_maximize_compatibility() { + let dir = tmp("export-layers-maximize-compat"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 16, "height": 16, "background": "white", "name": "card"})).await; + for (sub, params, real) in + [("on", json!({"dir": "on", "format": "psd"}), true), ("off", json!({"dir": "off", "format": "psd", "maximizeCompatibility": false}), false)] + { + let result = json_of(&call(&client, "doc_export_layers", params.clone()).await); + assert_eq!(result["count"], 1, "{params}: {result}"); + let file = result["files"][0].as_str().unwrap(); + assert!(file.starts_with(sub), "{file}"); + let bytes = std::fs::read(dir.join(file)).unwrap(); + assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); + } + let r = call(&client, "doc_export_layers", json!({"dir": "bad", "format": "psd", "maximizeCompatibility": "no"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + client.cancel().await.unwrap(); + cleanup(&dir); +} + +/// #8064: `maximizeCompatibility` reaches the app's `app.save` through the bridge; unset, nothing +/// is sent and the app's preference decides. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_forwards_maximize_compatibility() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd", "maximizeCompatibility": false})).await); + assert_eq!(saved, json!({"saved": {"path": "out.psd", "maximizeCompatibility": false}})); + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd"})).await); + assert_eq!(saved, json!({"saved": {"path": "out.psd"}})); + client.cancel().await.unwrap(); + app.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; @@ -1305,10 +1369,14 @@ async fn command_batch_step_without_waiting_starts_a_background_job() { async fn bridge_command_batch_forwards_each_steps_wait() { let (addr, app) = fake_app().await; let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; - let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}]})).await); - assert_eq!(r["completed"], 2, "{r}"); + let steps = json!([{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}, {"id": "view.zoomIn"}]); + let r = json_of(&call(&client, "command_batch", json!({"steps": steps})).await); + assert_eq!(r["completed"], 3, "{r}"); assert_eq!(r["results"][0]["result"]["wait"], false, "{r}"); - assert_eq!(r["results"][1]["result"]["wait"], true, "{r}"); + // A waited engine command starts without waiting too: the bridge polls a job it starts (#8130). + assert_eq!(r["results"][1]["result"]["wait"], false, "{r}"); + // A UI command can't be polled, so the app waits on it. + assert_eq!(r["results"][2]["result"]["wait"], true, "{r}"); client.cancel().await.unwrap(); app.abort(); } @@ -1390,6 +1458,58 @@ async fn convention_tools_have_titles_hints_and_strict_schemas() { client.cancel().await.unwrap(); } +/// The hints say what each tool does, not just that hints exist (#8129): `jobs_list` was +/// advertised as destructive. A new tool fails here until it is classified. +#[tokio::test(flavor = "multi_thread")] +async fn convention_tool_hints_match_what_each_tool_does() { + // (read-only, destructive, idempotent) + const READ: (bool, bool, bool) = (true, false, true); + const ADDS: (bool, bool, bool) = (false, false, false); + const EDITS: (bool, bool, bool) = (false, true, false); + let expected = [ + ("command_list", READ), + ("doc_inspect", READ), + ("session_list", READ), + ("render_preview", READ), + ("doc_render_preview", READ), + ("ui_inspect", READ), + ("ui_screenshot", READ), + ("jobs_list", READ), + ("doc_new", ADDS), + ("doc_open", ADDS), + ("doc_place", ADDS), + ("doc_export_layers", ADDS), + ("doc_export_data_sets", ADDS), + ("doc_select", (false, false, true)), + ("doc_save", EDITS), + ("doc_replace_smart_object", EDITS), + ("doc_export", EDITS), + ("doc_close", EDITS), + ("doc_import_data_sets", EDITS), + ("command_run", EDITS), + ("command_batch", EDITS), + ("jobs_cancel", EDITS), + ("ui_pointer", EDITS), + ("ui_menu_invoke", EDITS), + ("ui_set", EDITS), + ("control_call", EDITS), + ]; + let client = connect(PhotocraftMcp::headless()).await; + let tools = client.list_all_tools().await.unwrap(); + assert_eq!(tools.len(), expected.len(), "classify every tool here"); + for tool in tools { + let value = serde_json::to_value(&tool).unwrap(); + let hints = &value["annotations"]; + let actual = (hints["readOnlyHint"] == true, hints["destructiveHint"] == true, hints["idempotentHint"] == true); + let want = expected.iter().find(|(name, _)| *name == tool.name).map(|(_, hints)| *hints); + assert_eq!(Some(actual), want, "{}: {hints}", tool.name); + assert_eq!(hints["openWorldHint"], false, "{}", tool.name); + let description = tool.description.as_deref().unwrap_or_default(); + assert!(!description.contains(" "), "{}: a run of spaces in {description:?}", tool.name); + } + client.cancel().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn convention_unknown_arguments_name_the_key_without_side_effects() { let client = connect(PhotocraftMcp::headless()).await; diff --git a/crates/automation/tests/it/renderer_save.rs b/crates/automation/tests/it/renderer_save.rs new file mode 100644 index 0000000000..7fef7793fa --- /dev/null +++ b/crates/automation/tests/it/renderer_save.rs @@ -0,0 +1,283 @@ +//! A failed renderer-aware save must never replace the previous file, and closing a document +//! must release its renderer caches. + +use std::fs; +use std::sync::{Arc, Mutex}; + +use photocraft_automation::workspace::AuthorizedWorkspace; +use photocraft_automation::{Headless, files}; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{DocId, Document, Rect, Size}; +use photocraft_io::{BandRenderer, CpuRenderer, ExportOptions, IoError, RenderBand, RenderError}; + +struct FailingRenderer; + +impl BandRenderer for FailingRenderer { + fn render_bands( + &mut self, + _doc: &Document, + _rect: Rect, + _band_rows: i32, + _ctl: &photocraft_raster::Interrupt<'_>, + _sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + Err(RenderError::Execution("forced renderer failure".into())) + } +} + +#[test] +fn failed_renderer_save_preserves_prior_file() { + let dir = std::env::temp_dir().join(format!("photocraft-renderer-save-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let target = dir.join("existing.png"); + let original = b"prior file contents that must survive a failed save"; + fs::write(&target, original).unwrap(); + + let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut top = doc.layers[0].clone(); + top.name = "top".into(); + doc.layers.push(top); + + let mut renderer = FailingRenderer; + let res = files::save_with_renderer(&doc, &target, Some("png"), &ExportOptions::default(), None, Some(&mut renderer)); + assert!(res.is_err(), "the renderer failure must fail the save: {res:?}"); + assert_eq!(fs::read(&target).unwrap(), original, "a failed save must leave the previous file intact"); + let _ = fs::remove_dir_all(&dir); +} + +struct ForgetRecordingRenderer { + forgotten: Arc>>, +} + +impl BandRenderer for ForgetRecordingRenderer { + fn render_bands( + &mut self, + _doc: &Document, + _rect: Rect, + _band_rows: i32, + _ctl: &photocraft_raster::Interrupt<'_>, + _sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + Ok(()) + } + + fn forget_document(&mut self, doc: DocId) { + self.forgotten.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(doc); + } +} + +#[test] +fn closing_a_document_forgets_its_renderer_caches() { + let forgotten = Arc::new(Mutex::new(Vec::new())); + let mut h = Headless::trusted_local(); + h.session.open_document(Document::with_background("close", Size::new(16, 16), ColorMode::Rgb, SampleType::U8, Color::WHITE), None); + let id = h.session.documents()[0].doc.id; + h.set_renderer(Some(Box::new(ForgetRecordingRenderer { forgotten: forgotten.clone() }))); + + h.close(Some(0)).expect("close document"); + + let forgotten = forgotten.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); + assert_eq!(forgotten, vec![id], "closing a document must release its renderer caches"); +} + +#[test] +fn closing_a_document_through_a_command_forgets_its_renderer_caches() { + let forgotten = Arc::new(Mutex::new(Vec::new())); + let mut h = Headless::trusted_local(); + for name in ["first", "second"] { + h.session.open_document(Document::with_background(name, Size::new(16, 16), ColorMode::Rgb, SampleType::U8, Color::WHITE), None); + } + let closed = h.session.documents()[0].doc.id; + h.set_renderer(Some(Box::new(ForgetRecordingRenderer { forgotten: forgotten.clone() }))); + + // `file.close` removes the document through the session, not `Headless::close`. + h.command_run("file.close", serde_json::json!({"document": 0})).expect("close command"); + + let forgotten = forgotten.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); + assert_eq!(forgotten, vec![closed], "a command that closes a document must release its renderer caches"); +} + +struct DiagnosingRenderer { + diagnostics: Vec, +} + +impl BandRenderer for DiagnosingRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &photocraft_raster::Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + self.push_diagnostic("renderer diagnostic survives a failed write".into()); + let mut cpu = CpuRenderer; + cpu.render_bands(doc, rect, band_rows, ctl, sink) + } + + fn drain_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.diagnostics) + } + + fn push_diagnostic(&mut self, diagnostic: String) { + if !self.diagnostics.contains(&diagnostic) { + self.diagnostics.push(diagnostic); + } + } +} + +#[test] +fn failed_atomic_write_keeps_renderer_diagnostics_for_the_caller() { + let dir = std::env::temp_dir().join(format!("photocraft-renderer-write-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + // A directory at the destination makes the final atomic rename fail after encoding. + let target = dir.join("target.png"); + fs::create_dir(&target).unwrap(); + + let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut top = doc.layers[0].clone(); + top.name = "top".into(); + doc.layers.push(top); + + let mut renderer = DiagnosingRenderer { diagnostics: Vec::new() }; + let res = files::save_with_renderer(&doc, &target, Some("png"), &ExportOptions::default(), None, Some(&mut renderer)); + assert!(res.is_err(), "a write over a directory must fail: {res:?}"); + let diagnostics = renderer.drain_diagnostics(); + assert!(diagnostics.iter().any(|d| d.contains("failed write")), "diagnostics drained before the failed write must be queued again: {diagnostics:?}"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn failed_workspace_write_keeps_export_diagnostics_for_the_caller() { + let dir = std::env::temp_dir().join(format!("photocraft-workspace-save-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let root = dir.join("root"); + fs::create_dir_all(&root).unwrap(); + + let workspace = AuthorizedWorkspace::new(Some(&root), Some(&root)).expect("authorized workspace"); + let mut h = Headless::with_workspace(workspace); + let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut top = doc.layers[0].clone(); + top.name = "top".into(); + doc.layers.push(top); + h.session.open_document(doc, None); + h.set_renderer(Some(Box::new(DiagnosingRenderer { diagnostics: Vec::new() }))); + + // Outside the authorized root: the export succeeds, then its write is refused. + let outside = dir.join("outside.png"); + let res = h.save(None, Some(&outside), Some("png"), &Default::default()); + assert!(res.is_err(), "a write outside the workspace must fail: {res:?}"); + let diagnostics = h.drain_diagnostics(); + assert!( + diagnostics.iter().any(|d| d.contains("failed write")), + "export diagnostics drained before the failed workspace write must be queued again: {diagnostics:?}" + ); + let _ = fs::remove_dir_all(&dir); +} + +/// Overrides only `drain_diagnostics`: [`BandRenderer::push_diagnostic`] keeps its default no-op, +/// as most custom renderers leave it. +struct DrainOnlyRenderer; + +impl BandRenderer for DrainOnlyRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &photocraft_raster::Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + let mut cpu = CpuRenderer; + cpu.render_bands(doc, rect, band_rows, ctl, sink) + } + + fn drain_diagnostics(&mut self) -> Vec { + Vec::new() + } +} + +#[test] +fn failed_workspace_write_keeps_export_warnings_with_a_drain_only_renderer() { + let dir = std::env::temp_dir().join(format!("photocraft-workspace-warnings-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let root = dir.join("root"); + fs::create_dir_all(&root).unwrap(); + + let workspace = AuthorizedWorkspace::new(Some(&root), Some(&root)).expect("authorized workspace"); + let mut h = Headless::with_workspace(workspace); + let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut top = doc.layers[0].clone(); + top.name = "top".into(); + doc.layers.push(top); + h.session.open_document(doc, None); + h.set_renderer(Some(Box::new(DrainOnlyRenderer))); + + // Outside the authorized root: the export succeeds, then its write is refused. + let outside = dir.join("outside.png"); + let res = h.save(None, Some(&outside), Some("png"), &Default::default()); + assert!(res.is_err(), "a write outside the workspace must fail: {res:?}"); + let diagnostics = h.drain_diagnostics(); + assert!( + diagnostics.iter().any(|d| d.contains("flattened")), + "the export warnings must survive a failed workspace write when the renderer ignores pushed diagnostics: {diagnostics:?}" + ); + let again = h.drain_diagnostics(); + assert!(again.is_empty(), "the export warnings must drain exactly once: {again:?}"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn failed_local_write_keeps_export_warnings_with_a_drain_only_renderer() { + let dir = std::env::temp_dir().join(format!("photocraft-local-warnings-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + // A directory at the destination makes the final atomic rename fail after encoding. + let target = dir.join("target.png"); + fs::create_dir(&target).unwrap(); + + let mut h = Headless::trusted_local(); + let mut doc = Document::with_background("test", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut top = doc.layers[0].clone(); + top.name = "top".into(); + doc.layers.push(top); + h.session.open_document(doc, None); + h.set_renderer(Some(Box::new(DrainOnlyRenderer))); + + let res = h.save(None, Some(&target), Some("png"), &Default::default()); + assert!(res.is_err(), "a write over a directory must fail: {res:?}"); + let diagnostics = h.drain_diagnostics(); + assert!( + diagnostics.iter().any(|d| d.contains("flattened")), + "the export warnings must survive a failed local write when the renderer ignores pushed diagnostics: {diagnostics:?}" + ); + assert!(h.drain_diagnostics().is_empty(), "the export warnings must drain exactly once"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn scaled_preview_fallback_warning_is_drained_and_the_rpc_reply_carries_it() { + let mut h = Headless::trusted_local(); + h.session.open_document(Document::with_background("preview", Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE), None); + h.set_renderer(Some(Box::new(DrainOnlyRenderer))); + + // A scaled preview composes on the CPU; the warning must not depend on `push_diagnostic`. + let png = h.render_png(None, 16).expect("scaled preview"); + assert!(png.starts_with(b"\x89PNG")); + let diagnostics = h.drain_diagnostics(); + assert!(diagnostics.iter().any(|d| d.contains("scaled previews")), "preview fallback warning must be drained: {diagnostics:?}"); + + let reply = h.handle("doc.render", serde_json::json!({"maxSide": 16})).expect("doc.render"); + assert!(reply.get("base64").is_some(), "existing reply fields stay: {reply}"); + let warnings = reply.get("warnings").and_then(|w| w.as_array()).expect("warnings array"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scaled previews"))), "{reply}"); + assert!(h.drain_diagnostics().is_empty(), "the reply consumed the warnings"); + + // No renderer, no fallback, no `warnings` key. + let mut plain = Headless::trusted_local(); + plain.session.open_document(Document::with_background("plain", Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE), None); + let reply = plain.handle("doc.render", serde_json::json!({"maxSide": 16})).expect("doc.render"); + assert!(reply.get("warnings").is_none(), "{reply}"); +} diff --git a/crates/automation/tests/mcp_wait.rs b/crates/automation/tests/mcp_wait.rs new file mode 100644 index 0000000000..797b1d141a --- /dev/null +++ b/crates/automation/tests/mcp_wait.rs @@ -0,0 +1,424 @@ +//! Calls that wait on a long command: headless (#8129), with progress, other calls served +//! meanwhile and cancellation that leaves the document unchanged; and over the bridge (#8130), +//! polling the app's job past the reply timeout. Driven over the real line transport. +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use photocraft_automation::security::authentication_reply; +use photocraft_automation::{Backend, BridgeClient, PhotocraftMcp}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines}; + +struct Wire { + input: DuplexStream, + lines: Lines>, + server: tokio::task::JoinHandle>, +} + +impl Wire { + async fn start(mcp: PhotocraftMcp) -> Wire { + let (input, server_in) = tokio::io::duplex(1 << 20); + let (server_out, output) = tokio::io::duplex(1 << 20); + let server = tokio::spawn(mcp.serve_io(server_in, server_out)); + let mut wire = Wire { input, lines: BufReader::new(output).lines(), server }; + wire.send(json!({"jsonrpc":"2.0", "id":1, "method":"initialize", "params":{"protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"test", "version":"1"}}})) + .await; + wire.next().await; + wire.send(json!({"jsonrpc":"2.0", "method":"notifications/initialized"})).await; + wire + } + + async fn send(&mut self, value: Value) { + self.input.write_all(format!("{value}\n").as_bytes()).await.unwrap(); + } + + async fn next(&mut self) -> Value { + let line = tokio::time::timeout(Duration::from_secs(60), self.lines.next_line()).await.unwrap().unwrap().unwrap(); + serde_json::from_str(&line).unwrap() + } + + async fn call(&mut self, id: u64, name: &str, args: Value, token: Option<&str>) { + let mut request = json!({"jsonrpc":"2.0", "id":id, "method":"tools/call", "params":{"name":name, "arguments":args}}); + if let Some(token) = token { + request["params"]["_meta"] = json!({"progressToken": token}); + } + self.send(request).await; + } + + /// The reply to `id`, skipping progress notes. `pending` must not answer meanwhile. + async fn reply(&mut self, id: u64, pending: Option) -> Value { + let (text, error) = self.outcome(id, pending).await; + assert!(!error, "{text}"); + serde_json::from_str(&text).unwrap() + } + + /// The text of the tool result for `id` and whether it is an error. + async fn outcome(&mut self, id: u64, pending: Option) -> (String, bool) { + loop { + let message = self.next().await; + if pending.is_some_and(|p| message["id"] == p) { + panic!("request {} answered while it should still run: {message}", pending.unwrap_or(0)); + } + if message["id"] == id { + let text = message["result"]["content"][0]["text"].as_str().unwrap_or_else(|| panic!("{message}")).to_owned(); + return (text, message["result"]["isError"] == true); + } + } + } + + async fn cancel(&mut self, id: u64) { + self.send(json!({"jsonrpc":"2.0", "method":"notifications/cancelled", "params":{"requestId":id}})).await; + } + + /// Poll `jobs_list` until no job runs; `cancelled` must never be answered. + async fn settle(&mut self, mut id: u64, cancelled: u64) -> Value { + loop { + self.call(id, "jobs_list", json!({}), None).await; + let jobs = self.reply(id, Some(cancelled)).await; + if !jobs["jobs"].as_array().unwrap().iter().any(|j| j["state"] == "running") { + return jobs; + } + id += 1; + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + async fn history(&mut self, id: u64) -> Vec { + self.call(id, "doc_inspect", json!({}), None).await; + self.reply(id, None).await["history"].as_array().unwrap().clone() + } + + /// A document whose blur takes seconds even in an optimized build (about 2s here). + async fn big_document(&mut self) { + self.call(2, "doc_new", json!({"width": 6000, "height": 6000}), None).await; + self.reply(2, None).await; + self.call(3, "command_run", json!({"id": "filter.noise.addNoise", "params": {"amount": 50}}), None).await; + self.reply(3, None).await; + } + + /// Wait for the first progress note of request `id` (so its job is running). + async fn first_progress(&mut self, id: u64, token: &str, total: f64) -> f64 { + loop { + let note = self.next().await; + assert_ne!(note["id"], id, "request {id} finished before it could be cancelled: {note}"); + if note["method"] == "notifications/progress" { + assert_eq!(note["params"]["progressToken"], token, "{note}"); + assert_eq!(note["params"]["total"].as_f64(), Some(total), "{note}"); + return note["params"]["progress"].as_f64().unwrap(); + } + } + } + + async fn finish(self) { + drop(self.input); + tokio::time::timeout(Duration::from_secs(10), self.server).await.unwrap().unwrap().unwrap(); + } +} + +const BLUR: fn() -> Value = || json!({"id": "filter.blur.gaussianBlur", "params": {"radius": 1000}}); + +#[tokio::test(flavor = "multi_thread")] +async fn a_waited_command_reports_progress_serves_other_calls_and_cancels_cleanly() { + let mut wire = Wire::start(PhotocraftMcp::headless()).await; + wire.big_document().await; + let before = wire.history(4).await; + + wire.call(5, "command_run", BLUR(), Some("blur")).await; + let progress = wire.first_progress(5, "blur", 1.0).await; + assert!((0.0..1.0).contains(&progress), "{progress}"); + // Served while the blur runs: before #8129 both waited for it behind the session lock. + wire.call(6, "jobs_list", json!({}), None).await; + let jobs = wire.reply(6, Some(5)).await; + assert!(jobs["jobs"].as_array().unwrap().iter().any(|j| j["state"] == "running" && j["command"] == "filter.blur.gaussianBlur"), "{jobs}"); + assert_eq!(wire.history(7).await, before); + + wire.cancel(5).await; + let jobs = wire.settle(8, 5).await; + assert!(jobs["jobs"].as_array().unwrap().iter().any(|j| j["state"] == "cancelled"), "{jobs}"); + // Before #8129 the cancelled blur still ran to the end and added its history step. + assert_eq!(wire.history(1000).await, before, "a cancelled command leaves the document unchanged"); + + // The session keeps working, and a waited command that finishes returns its result. + wire.call(1001, "doc_new", json!({"width": 64, "height": 64}), None).await; + wire.reply(1001, None).await; + wire.call(1002, "command_run", json!({"id": "filter.blur.gaussianBlur", "params": {"radius": 2}}), Some("short")).await; + wire.reply(1002, None).await; + assert_eq!(wire.history(1003).await.last(), Some(&json!("Gaussian Blur"))); + wire.finish().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_a_batch_cancels_its_waited_step_and_runs_no_later_step() { + let mut wire = Wire::start(PhotocraftMcp::headless()).await; + wire.big_document().await; + let before = wire.history(4).await; + + let steps = json!([BLUR(), {"id": "layer.new.layer", "params": {"name": "after"}}]); + wire.call(5, "command_batch", json!({"steps": steps, "stop_on_error": false}), Some("batch")).await; + assert!(wire.first_progress(5, "batch", 2.0).await < 1.0, "still in the first step"); + wire.cancel(5).await; + wire.settle(6, 5).await; + // Give a (wrongly) continuing batch time to start its next step. + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(wire.history(1000).await, before, "neither the blur nor the later step ran"); + wire.finish().await; +} + +const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +/// A scripted desktop app for bridge mode. A blur runs as job 7 for `runs`: `engine.execute` +/// with `wait: false` answers `{job, pending}` at once, without it only when the job ends, like +/// the app. `jobs.list` reports the job and `jobs.cancel` stops it. Other commands answer at +/// once. Every request is logged. +struct FakeApp { + runs: Duration, + /// Close the connection instead of answering the start (its reply is lost). + lose_start: bool, + /// Lose the first cancellation request before applying it; a later request can succeed. + lose_cancel: bool, + /// How long to hold each `jobs.list` reply (an app slow to answer). + hold_polls: Duration, + started: Option, + cancelled: bool, + log: Vec, +} + +impl FakeApp { + async fn serve(runs: Duration, lose_start: bool) -> (String, Arc>) { + let app = Arc::new(Mutex::new(FakeApp { + runs, + lose_start, + lose_cancel: false, + hold_polls: Duration::ZERO, + started: None, + cancelled: false, + log: Vec::new(), + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let shared = app.clone(); + tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + let app = shared.clone(); + tokio::spawn(async move { + let (read, mut write) = socket.into_split(); + let mut lines = BufReader::new(read).lines(); + let (auth, ok) = authentication_reply(&lines.next_line().await.unwrap().unwrap(), TOKEN); + assert!(ok, "each connection must authenticate"); + write.write_all(format!("{auth}\n").as_bytes()).await.unwrap(); + while let Ok(Some(line)) = lines.next_line().await { + let request: Value = serde_json::from_str(&line).unwrap(); + let Some((result, after)) = app.lock().unwrap().answer(&request) else { return }; + tokio::time::sleep(after).await; + let reply = json!({"id": request["id"], "ok": true, "result": result}); + if write.write_all(format!("{reply}\n").as_bytes()).await.is_err() { + return; + } + } + }); + } + }); + (addr, app) + } + + /// The result for `request` and how long to hold it; `None` closes the connection. + fn answer(&mut self, request: &Value) -> Option<(Value, Duration)> { + self.log.push(request.clone()); + let p = &request["params"]; + let result = match request["method"].as_str().unwrap() { + "engine.execute" if p["wait"] == false && p["command"] == "filter.blur.gaussianBlur" => { + if self.lose_start { + return None; + } + self.started = Some(Instant::now()); + json!({"job": 7, "pending": true}) + } + "engine.execute" if p["command"] == "filter.blur.gaussianBlur" => { + self.started = Some(Instant::now()); + return Some((json!({"blurred": true}), self.runs)); + } + "engine.execute" => json!({"ran": p["command"]}), + "jobs.list" => { + let Some(started) = self.started else { return Some((json!({"jobs": []}), Duration::ZERO)) }; + let progress = started.elapsed().as_secs_f64() / self.runs.as_secs_f64(); + let job = match () { + () if self.cancelled => json!({"id": 7, "state": "cancelled", "progress": progress.min(1.0), "message": ""}), + () if progress >= 1.0 => json!({"id": 7, "state": "done", "progress": 1.0, "message": "", "result": {"blurred": true}}), + () => json!({"id": 7, "state": "running", "progress": progress, "message": "Blurring"}), + }; + return Some((json!({"jobs": [job]}), self.hold_polls)); + } + "jobs.cancel" => { + if self.lose_cancel { + self.lose_cancel = false; + return None; + } + self.cancelled = true; + json!({"cancelled": 1}) + } + method => panic!("unexpected `{method}`"), + }; + Some((result, Duration::ZERO)) + } + + /// The logged requests for `method`. + fn sent(app: &Mutex, method: &str) -> Vec { + app.lock().unwrap().log.iter().filter(|r| r["method"] == method).map(|r| r["params"].clone()).collect() + } +} + +/// An MCP server bridged to `addr` whose every call times out after 300ms. +fn bridged(addr: &str) -> PhotocraftMcp { + PhotocraftMcp::with_backend(Backend::Bridge(Arc::new(BridgeClient::new(addr, TOKEN).unwrap().with_timeout(Duration::from_millis(300))))) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bridged_job_that_outlives_the_reply_timeout_returns_its_result_with_progress() { + // The job runs five times as long as any one reply may take. + let (addr, app) = FakeApp::serve(Duration::from_millis(1500), false).await; + let mut wire = Wire::start(bridged(&addr)).await; + wire.call(5, "command_run", json!({"id": "filter.blur.gaussianBlur", "params": {"radius": 9}}), Some("blur")).await; + let progress = wire.first_progress(5, "blur", 1.0).await; + assert!((0.0..1.0).contains(&progress), "{progress}"); + assert_eq!(wire.reply(5, None).await, json!({"blurred": true})); + // Before #8130 the edit was sent waiting, and the call failed after the timeout. + assert_eq!(FakeApp::sent(&app, "engine.execute"), [json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 9}, "wait": false})]); + assert!(FakeApp::sent(&app, "jobs.list").len() > 1); + assert!(FakeApp::sent(&app, "jobs.cancel").is_empty()); + + // A command the engine doesn't know (a UI command) still lets the app wait. + assert!(photocraft_engine::commands::find("view.zoomIn").is_none()); + wire.call(6, "command_run", json!({"id": "view.zoomIn"}), None).await; + wire.reply(6, None).await; + assert_eq!(FakeApp::sent(&app, "engine.execute").last(), Some(&json!({"command": "view.zoomIn", "params": {}, "wait": true}))); + + // So does every waited step of a batch, which reports the steps around the job's progress. + let steps = json!([{"id": "filter.blur.gaussianBlur", "params": {"radius": 9}}, {"id": "layer.new.layer"}]); + app.lock().unwrap().started = None; + wire.call(7, "command_batch", json!({"steps": steps}), Some("batch")).await; + assert!(wire.first_progress(7, "batch", 2.0).await < 1.0, "still in the first step"); + let batch = wire.reply(7, None).await; + assert_eq!(batch["completed"], 2, "{batch}"); + assert_eq!(batch["results"][0]["result"], json!({"blurred": true}), "{batch}"); + wire.finish().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_a_bridged_wait_cancels_the_apps_job() { + let (addr, app) = FakeApp::serve(Duration::from_secs(600), false).await; + let mut wire = Wire::start(bridged(&addr)).await; + wire.call(5, "command_run", BLUR(), Some("blur")).await; + wire.first_progress(5, "blur", 1.0).await; + wire.cancel(5).await; + let t = Instant::now(); + while FakeApp::sent(&app, "jobs.cancel").is_empty() { + assert!(t.elapsed() < Duration::from_secs(10), "the job was never cancelled"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(FakeApp::sent(&app, "jobs.cancel"), [json!({"job": 7})]); + assert_eq!(FakeApp::sent(&app, "engine.execute").len(), 1, "the edit is sent once"); + wire.finish().await; +} + +/// A failed cancellation keeps the drop guard armed, so its cleanup can reach the app. +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_bridge_cancel_keeps_cleanup_armed() { + let (addr, app) = FakeApp::serve(Duration::from_secs(600), false).await; + app.lock().unwrap().lose_cancel = true; + let mut wire = Wire::start(bridged(&addr)).await; + wire.call(5, "command_run", BLUR(), Some("blur")).await; + wire.first_progress(5, "blur", 1.0).await; + wire.cancel(5).await; + + let deadline = Instant::now() + Duration::from_secs(10); + while !app.lock().unwrap().cancelled { + assert!(Instant::now() < deadline, "the failed jobs.cancel disabled cleanup and left the job running"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(FakeApp::sent(&app, "jobs.cancel"), [json!({"job": 7}), json!({"job": 7})]); + assert_eq!(FakeApp::sent(&app, "engine.execute").len(), 1, "the edit is never resent"); + wire.finish().await; +} + +/// A cancel during a poll the app is slow to answer, or that then fails (review of #8134), reaches +/// the app at once over a fresh connection. +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_during_a_slow_poll_cancels_without_waiting_for_it() { + let (addr, app) = FakeApp::serve(Duration::from_secs(600), false).await; + app.lock().unwrap().hold_polls = Duration::from_secs(600); + // The poll would hold the cancel for this long if it waited for it. + let bridge = BridgeClient::new(&addr, TOKEN).unwrap().with_timeout(Duration::from_secs(30)); + let mut wire = Wire::start(PhotocraftMcp::with_backend(Backend::Bridge(Arc::new(bridge)))).await; + wire.call(5, "command_run", BLUR(), Some("blur")).await; + let t = Instant::now(); + while FakeApp::sent(&app, "jobs.list").is_empty() { + assert!(t.elapsed() < Duration::from_secs(10), "the job was never polled"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + wire.cancel(5).await; + let t = Instant::now(); + while FakeApp::sent(&app, "jobs.cancel").is_empty() { + assert!(t.elapsed() < Duration::from_secs(10), "the cancel waited for the poll"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(FakeApp::sent(&app, "jobs.cancel"), [json!({"job": 7})]); + assert_eq!(FakeApp::sent(&app, "jobs.list").len(), 1, "the slow poll was not retried"); + wire.finish().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_lost_start_reply_is_reported_and_never_resent() { + let (addr, app) = FakeApp::serve(Duration::from_millis(100), true).await; + let mut wire = Wire::start(bridged(&addr)).await; + wire.call(5, "command_run", BLUR(), None).await; + let (error, is_error) = wire.outcome(5, None).await; + assert!(is_error && error.contains("may have completed"), "{error}"); + // No resend of the edit, and no polling for a job whose id never arrived. + assert_eq!(FakeApp::sent(&app, "engine.execute").len(), 1); + assert!(FakeApp::sent(&app, "jobs.list").is_empty()); + wire.finish().await; +} + +#[test] +fn polling_a_job_directly_returns_its_result_once_it_ends() { + use photocraft_automation::headless::JobPoll; + use photocraft_engine::jobs::Started; + + let mut h = photocraft_automation::Headless::trusted_local(); + h.command_run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let Started::Job(job) = h.command_begin("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap() else { + panic!("Gaussian Blur runs as a job"); + }; + // No `sync_jobs` between polls: `job_poll` must apply the finished job itself. + let deadline = std::time::Instant::now() + Duration::from_secs(60); + loop { + match h.job_poll(job) { + JobPoll::Running(..) => assert!(std::time::Instant::now() < deadline, "job_poll never saw the job end"), + JobPoll::Ended(result) => { + result.unwrap(); + break; + } + } + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn abandoning_a_job_that_already_ended_returns_its_result_not_cancelled() { + use photocraft_engine::jobs::Started; + + let mut h = photocraft_automation::Headless::trusted_local(); + h.command_run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let Started::Job(job) = h.command_begin("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap() else { + panic!("Gaussian Blur runs as a job"); + }; + // Another request applies the finished job before the waiter's cancel arrives. + let deadline = std::time::Instant::now() + Duration::from_secs(60); + while h.session.job(job).is_some() { + assert!(std::time::Instant::now() < deadline, "the job never ended"); + std::thread::sleep(Duration::from_millis(1)); + h.sync_jobs(); + } + // The blur is in the document, so the waiter must not hear "cancelled". + h.job_abandon(job).unwrap(); +} diff --git a/crates/automation/tests/save_snapshot.rs b/crates/automation/tests/save_snapshot.rs new file mode 100644 index 0000000000..95c922c4a3 --- /dev/null +++ b/crates/automation/tests/save_snapshot.rs @@ -0,0 +1,47 @@ +//! History Options › Automatically Create New Snapshot When Saving (#2966) through the headless +//! server: a save of the document to its own layered file adds a snapshot when the option is on; +//! a flat export is a copy and adds none, and with the option off no save does. + +use photocraft_automation::Headless; +use serde_json::json; + +fn tmp() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "pc-save-snapshot-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn snapshots(h: &Headless) -> usize { + h.session.active().unwrap().snapshots.len() +} + +#[test] +fn a_document_save_adds_a_snapshot_only_with_the_option() { + let dir = tmp(); + let psd = dir.join("a.psd"); + let png = dir.join("a.png"); + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + assert_eq!(snapshots(&h), 1, "the first snapshot"); + + // Off (the default): saving adds nothing. + h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap(); + assert_eq!(snapshots(&h), 1); + + h.handle("engine.execute", json!({"command": "history.options", "params": {"automaticallyCreateNewSnapshotWhenSaving": true}})).unwrap(); + h.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap(); + h.handle("doc.save", json!({})).unwrap(); + assert_eq!(snapshots(&h), 2, "the save added one"); + let st = h.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.snapshots[1].doc, &st.doc), "of the document as saved"); + assert!(!st.is_dirty(), "and the document stays saved"); + + // A flat export is a copy, not a save of the document. + h.handle("doc.save", json!({"path": png.to_str().unwrap()})).unwrap(); + assert_eq!(snapshots(&h), 2); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/scratch_save.rs b/crates/automation/tests/scratch_save.rs index e61c607e8f..586be3a50d 100644 --- a/crates/automation/tests/scratch_save.rs +++ b/crates/automation/tests/scratch_save.rs @@ -3,11 +3,35 @@ #![cfg(not(target_arch = "wasm32"))] use photocraft_automation::{AutomationError, files}; -use photocraft_doc::{ColorMode, Document, Layer, PixelFormat, SampleType, Size}; +use photocraft_doc::{ColorMode, Document, Layer, PixelFormat, Rect, SampleType, Size}; use photocraft_geom::TileCoord; -use photocraft_raster::spill; +use photocraft_io::{BandRenderer, IoError, RenderBand, RenderError}; +use photocraft_raster::{Interrupt, spill}; use std::io::{Read, Seek, SeekFrom, Write}; +struct QueuedDiagnosticsRenderer(Vec); + +impl BandRenderer for QueuedDiagnosticsRenderer { + fn render_bands( + &mut self, + _doc: &Document, + _rect: Rect, + _band_rows: i32, + _ctl: &Interrupt<'_>, + _sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + Ok(()) + } + + fn drain_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.0) + } + + fn push_diagnostic(&mut self, diagnostic: String) { + self.0.push(diagnostic); + } +} + #[test] fn native_save_refuses_previews_rendered_from_unreadable_scratch() { let dir = std::env::temp_dir().join(format!("photocraft-automation-scratch-save-{}", std::process::id())); @@ -32,9 +56,20 @@ fn native_save_refuses_previews_rendered_from_unreadable_scratch() { file.sync_all().unwrap(); let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() }; + // A flat export refused after its provisional result (the unreadable tile is only noticed + // while reading) keeps that result's renderer notes queued for the caller. + let mut renderer = QueuedDiagnosticsRenderer(Vec::new()); + let err = photocraft_io::export_with_renderer(&doc, "png", &on, &Interrupt::NONE, Some(&mut renderer)).unwrap_err(); + assert!(matches!(err, IoError::Unsupported(_)), "{err:?}"); + assert!(renderer.0.iter().any(|d| d.contains("single-layer export copies")), "refused export must re-queue its notes: {:?}", renderer.0); // Refused while rendering the previews, before the bundle is assembled. let err = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap_err(); assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + // A failed native encode must leave the renderer's queued diagnostics for the caller. + let mut renderer = QueuedDiagnosticsRenderer(vec!["queued native save diagnostic".into()]); + let err = files::save_bytes_with_renderer(&doc, "Scratch.pcraft", None, &on, Some(&mut renderer)).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + assert_eq!(renderer.0, ["queued native save diagnostic"], "a failed native save must leave queued diagnostics on the renderer"); let path = dir.join("Scratch.pcraft"); let err = files::save(&doc, &path, None, &on, None).unwrap_err(); assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index d547f064ed..007edc57c5 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -229,7 +229,8 @@ pub fn parse_cube(text: &str) -> Result { /// shaper line of input values, then integer RGB rows with **blue** varying fastest. The /// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to /// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube -/// root of the row count and the range is inferred from the largest value (#9121). +/// root of the row count and the range is inferred from the largest value (#9121). A shaper that +/// isn't the even ramp is applied before the table, as Photoshop does (#8982): see [`apply_shaper`]. pub fn parse_3dl(text: &str) -> Result { let mut mesh: Option<(u32, u32)> = None; let mut rows: Vec<[f64; 3]> = Vec::new(); @@ -239,12 +240,13 @@ pub fn parse_3dl(text: &str) -> Result { // Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is // on the 12-bit scale, as Photoshop 27.11 reads it (#9121). let mut header = false; + let mut shaper: Option> = None; for line in text.lines() { let line = line.split('#').next().unwrap_or("").trim(); - if line.is_empty() { - continue; - } - if line.starts_with(|c: char| c.is_ascii_alphabetic()) { + // A header is `3DMESH` or starts with a word (`Mesh 4 12`): a letter, and not a number. + // `NaN` and `inf` are numbers, and `0oops` starts like one: those rows are read strictly. + let Some(first) = line.split_whitespace().next() else { continue }; + if first.eq_ignore_ascii_case("3DMESH") || first.starts_with(char::is_alphabetic) && first.parse::().is_err() { header = true; // The only header keyword that carries data. A malformed or out-of-range `Mesh` // line is ignored, as every text line always was. @@ -265,18 +267,20 @@ pub fn parse_3dl(text: &str) -> Result { } continue; } - let v: Vec = line.split_whitespace().filter_map(|s| s.parse().ok()).collect(); - if v.is_empty() { - // The `3DMESH` magic carries no numbers, but it is a header all the same: a table - // behind it reads a 1023 peak on the 10-bit scale (#9121). - header = true; - continue; - } + // Every token is a number: a malformed one is refused rather than dropped, which could + // turn a shaper into an even ramp and lose it silently. + let v: Vec = line.split_whitespace().map(str::parse).collect::>().map_err(|_| LutError(format!("bad line `{line}`")))?; first_len.get_or_insert(v.len()); if v.len() == 3 { rows.push([v[0], v[1], v[2]]); + } else if v.len() >= 2 { + // Two values are a 2-point shaper (a 2³ table's), not something to drop. + // The input shaper: one row of values ahead of the table, and only one. + if shaper.is_some() || !rows.is_empty() { + return err(format!("a second shaper row `{line}`")); + } + shaper = Some(v); } - // Lines with more than three numbers are the input shaper; it is assumed linear. } let (n, nodes, scale) = match mesh { Some((in_bits, out_bits)) => { @@ -284,7 +288,12 @@ pub fn parse_3dl(text: &str) -> Result { let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?; // One row more than the declared cube, and the first row has exactly `n` values: // that row is the shaper, not a 28th node of a 3³ cube (#9078). - let nodes = if rows.len() == expected + 1 && first_len == Some(n) { &rows[1..] } else { &rows[..] }; + let nodes = if shaper.is_none() && rows.len() == expected + 1 && first_len == Some(n) { + shaper = rows.first().map(|row| row.to_vec()); + &rows[1..] + } else { + &rows[..] + }; if nodes.len() != expected { return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len())); } @@ -314,7 +323,102 @@ pub fn parse_3dl(text: &str) -> Result { data[at + k] = (row[k] / scale) as f32; } } - LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check() + let lut = LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()?; + Ok(match shaper { + Some(shaper) => apply_shaper(lut, &shaper)?, + None => lut, + }) +} + +/// Bakes a `.3dl` shaper into the table. Photoshop applies the shaper row as a 1-D curve on each +/// input channel ahead of the 3-D table (input `x` reads the shaper, sampled evenly over 0..1, +/// at `x`, interpolated linearly), so each node of the table becomes the table's own trilinear +/// value at the shaped input. An even ramp (the usual shaper) changes nothing and is skipped. +/// The table is baked on a grid holding both its own nodes and the shaper's knots: exact for an +/// identity table, an approximation at that resolution otherwise. A shaper that would need more +/// than [`MAX_SIZE`] nodes for that, or that holds a value that isn't finite, is refused. +fn apply_shaper(lut: LutFile, shaper: &[f64]) -> Result { + let m = shaper.len(); + if m < 2 { + return Ok(lut); + } + // Apply or refuse: a shaper that can't be applied never leaves the table to render alone. + if shaper.iter().any(|v| !v.is_finite()) { + return err("the .3dl shaper holds a value that isn't finite"); + } + // The shaper's own depth, from 10 bits up: a 0..1023 ramp is the even ramp with or without a header. + let scale = inferred_3dl_scale(shaper.iter().fold(0.0f64, |a, &b| a.max(b)), true); + let even = |k: usize| scale * k as f64 / (m - 1) as f64; + // Shapers round their ramp: within one code is still the even ramp. An integer shaper's code + // is 1; a float shaper (0..1) is held to one 10-bit step. + let code = if scale > 1.0 { 1.0 } else { 1.0 / 1023.0 }; + if shaper.iter().enumerate().all(|(k, &v)| (v - even(k)).abs() <= code) { + return Ok(lut); + } + // Scaled to 0..1 first, so interpolating between two extreme values can't overflow. + let shaped: Vec = shaper.iter().map(|v| v / scale).collect(); + let curve = |x: f64| -> f32 { + let p = x.clamp(0.0, 1.0) * (m - 1) as f64; + let i = (p.floor() as usize).min(m - 2); + let f = p - i as f64; + let (a, b) = (shaped.get(i).copied().unwrap_or(0.0), shaped.get(i + 1).copied().unwrap_or(0.0)); + (a + (b - a) * f).clamp(0.0, 1.0) as f32 + }; + // The bake grid holds both the table's nodes and the shaper's knots (lcm of their intervals), + // so the curve keeps its breakpoints whatever the table's size: exact on an identity table. A + // grid that would pass MAX_SIZE is refused: sampled more coarsely, a bent shaper could read as + // even and be lost without a word. + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + let (a, k) = (lut.size - 1, m - 1); + let aligned = (a / gcd(a, k)).checked_mul(k).map(|l| l + 1).filter(|&l| l <= MAX_SIZE); + let Some(n) = aligned else { + return err(format!("the .3dl shaper's {m} points can't be kept on a table of at most {MAX_SIZE} nodes")); + }; + let step = (n - 1) as f64; + let mut out = LutFile { size: n, data: vec![0.0; n * n * n * 3], ..lut.clone() }; + for b in 0..n { + for g in 0..n { + for r in 0..n { + let at = ((b * n + g) * n + r) * 3; + let v = sample(&lut, [curve(r as f64 / step), curve(g as f64 / step), curve(b as f64 / step)]); + if let Some(dst) = out.data.get_mut(at..at + 3) { + dst.copy_from_slice(&v); + } + } + } + } + out.check() +} + +/// Trilinear lookup of `rgb` (0..1) in a [`LutFile`] (red fastest). +fn sample(l: &LutFile, rgb: [f32; 3]) -> [f32; 3] { + let n = l.size; + let loc = |v: f32| { + let p = v.clamp(0.0, 1.0) * (n - 1) as f32; + let i = (p as usize).min(n.saturating_sub(2)); + (i, p - i as f32) + }; + let (r0, fr) = loc(rgb[0]); + let (g0, fg) = loc(rgb[1]); + let (b0, fb) = loc(rgb[2]); + let at = |r: usize, g: usize, b: usize, k: usize| l.data.get(((b * n + g) * n + r) * 3 + k).copied().unwrap_or(0.0); + let mut o = [0.0f32; 3]; + for (k, ov) in o.iter_mut().enumerate() { + let c = |r, g, b| at(r, g, b, k); + let x00 = c(r0, g0, b0) + (c(r0 + 1, g0, b0) - c(r0, g0, b0)) * fr; + let x10 = c(r0, g0 + 1, b0) + (c(r0 + 1, g0 + 1, b0) - c(r0, g0 + 1, b0)) * fr; + let x01 = c(r0, g0, b0 + 1) + (c(r0 + 1, g0, b0 + 1) - c(r0, g0, b0 + 1)) * fr; + let x11 = c(r0, g0 + 1, b0 + 1) + (c(r0 + 1, g0 + 1, b0 + 1) - c(r0, g0 + 1, b0 + 1)) * fr; + let y0 = x00 + (x10 - x00) * fg; + let y1 = x01 + (x11 - x01) * fg; + *ov = y0 + (y1 - y0) * fb; + } + o } /// The code range a `.3dl` node table without a declared output depth was written with: the @@ -447,6 +551,197 @@ pub fn builtin(id: &str) -> Option { mod tests { use super::*; + /// An identity `.3dl` of edge `n` (12-bit nodes, blue fastest) under `shaper`. + fn identity_3dl(shaper: &[u32], n: usize) -> String { + let mut t = shaper.iter().map(u32::to_string).collect::>().join(" ") + "\n"; + let node = |i: usize| (i * 4095 / (n - 1)).to_string(); + for r in 0..n { + for g in 0..n { + for b in 0..n { + t += &format!("{} {} {}\n", node(r), node(g), node(b)); + } + } + } + t + } + + #[test] + fn three_dl_shaper_applies_before_the_table() { + // #8982: Photoshop applies a .3dl's shaper row as a curve ahead of the 3-D table. On grey + // 128 an identity table with the shaper 0 114 455 1023 gives 71 there, and a 17-point + // squared shaper 64 (reported measurements); even ramps leave grey 128 as it is. + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let bent = parse_3dl(&identity_3dl(&[0, 114, 455, 1023], 4)).unwrap(); + assert_eq!(grey(&bent), 71.0); + let squared: Vec = (0..17).map(|i| (1023.0 * (i as f64 / 16.0).powi(2)).round() as u32).collect(); + assert_eq!(grey(&parse_3dl(&identity_3dl(&squared, 17)).unwrap()), 64.0); + let even4 = parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4)).unwrap(); + assert_eq!(grey(&even4), 128.0); + assert_eq!(even4, parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).lines().skip(1).collect::>().join("\n")).unwrap()); + let even17: Vec = (0..17).map(|i| (1023.0 * i as f64 / 16.0).round() as u32).collect(); + assert_eq!(grey(&parse_3dl(&identity_3dl(&even17, 17)).unwrap()), 128.0); + // The ends stay put, and the shaper bends each channel on its own. + assert_eq!(sample(&bent, [0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]); + let c = sample(&bent, [1.0 / 3.0, 0.0, 2.0 / 3.0]); + assert!((c[0] - 114.0 / 1023.0).abs() < 1e-4 && (c[2] - 455.0 / 1023.0).abs() < 1e-4, "{c:?}"); + // A shaper that isn't finite is refused rather than dropped (see + // `three_dl_non_finite_shaper_is_refused`). + assert!(parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).replacen("341", "NaN", 1)).is_err()); + } + + /// #9170 review: a float shaper (values in 0..1) is held to a float ramp, not to one integer + /// code, so a bent float shaper is applied. + #[test] + fn three_dl_float_shaper_is_applied() { + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let with = |shaper: &str| { + let table = identity_3dl(&[0, 1, 2, 3], 4); + parse_3dl(&format!("{shaper}\n{}", table.split_once('\n').unwrap().1)).unwrap() + }; + // 0.502 lands between 0.1 and 0.3 at 0.506 of the way: 0.2012, so grey 51. + assert_eq!(grey(&with("0 0.1 0.3 1.0")), 51.0); + // An even float ramp, written to three decimals, still changes nothing. + assert_eq!(grey(&with("0 0.333 0.667 1.0")), 128.0); + } + + /// #9170 review: a shaper with more points than the table keeps its curve: the table is + /// resampled at the shaper's resolution rather than reading the shaper at its own two nodes. + #[test] + fn three_dl_shaper_finer_than_the_table_keeps_its_curve() { + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let small = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], 2)).unwrap(); + // 0.502 lands between 0 and 512 at 0.506 of the way: 259 / 1023, so grey 65. + assert_eq!(grey(&small), 65.0); + assert_eq!(sample(&small, [1.0, 0.0, 1.0]), [1.0, 0.0, 1.0], "the ends stay put"); + } + + /// #9170 review: a finite but extreme shaper can't overflow into NaN when it is baked. + #[test] + fn three_dl_extreme_shaper_bakes_only_finite_values() { + let table = identity_3dl(&[0, 1, 2, 3], 2); + let text = format!("0 1e308 -1e308 0\n{}", table.split_once('\n').unwrap().1); + // Refused or baked, but never a table holding NaN. + if let Ok(l) = parse_3dl(&text) { + assert!(l.data.iter().all(|v| v.is_finite()), "baked a non-finite value"); + } + } + + /// #9170 review (bommerts): the shaper's breakpoints survive a table whose grid doesn't line + /// up with them. The bake grid holds both the table's nodes and the shaper's knots, so on an + /// identity table the curve is exact: `0 0 512 1023` is 0 at ⅓ over 5³ and 17³ tables. + #[test] + fn three_dl_shaper_knots_survive_a_nonaligned_table() { + for n in [5, 17] { + let l = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], n)).unwrap(); + let at = |x: f32| sample(&l, [x, 0.0, 0.0])[0]; + assert!(at(1.0 / 3.0).abs() < 1e-6, "{n}³: {}", at(1.0 / 3.0)); + // The helper's 12-bit nodes are truncated, so the table itself is within a 12-bit step. + assert!((at(2.0 / 3.0) - 512.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: {}", at(2.0 / 3.0)); + assert!((at(0.5) - 256.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: halfway between the knots"); + } + } + + /// A two-value shaper row (a 2³ table's) is applied like a longer one, not dropped; the even + /// two-point ramp changes nothing. + #[test] + fn three_dl_two_point_shaper_is_applied() { + let bent = parse_3dl(&identity_3dl(&[0, 512], 2)).unwrap(); + let top = sample(&bent, [1.0, 1.0, 1.0]); + assert!(top.iter().all(|v| (v - 512.0 / 1023.0).abs() < 1e-4), "{top:?}"); + assert!((sample(&bent, [0.5, 0.0, 0.0])[0] - 256.0 / 1023.0).abs() < 1e-4); + let even = parse_3dl(&identity_3dl(&[0, 1023], 2)).unwrap(); + assert_eq!( + even, + parse_3dl(&identity_3dl(&[0, 1023], 2).lines().skip(1).collect::>().join( + " +" + )) + .unwrap() + ); + // A second two-value row is a second shaper, refused like a longer one. + let body = identity_3dl(&[0, 512], 2); + assert!( + parse_3dl(&format!( + "{body}0 512 +" + )) + .is_err() + ); + } + + /// #9170 review (bommerts): a shaper row with a token that isn't a number, or a second + /// shaper row, is refused rather than silently dropped. + #[test] + fn three_dl_malformed_shaper_rows_are_refused() { + let table = identity_3dl(&[0, 1, 2, 3], 4); + let body = table.split_once('\n').unwrap().1; + assert!(parse_3dl(&format!("0 341 junk 682 1023\n{body}")).is_err(), "a junk token"); + assert!(parse_3dl(&format!("0 114 455 1023\n0 114 455 1023\n{body}")).is_err(), "two shaper rows"); + assert!(parse_3dl(&format!("0 114 455 1023\n{body}")).is_ok()); + } + + /// #9170 review (bommerts): the standard `3DMESH` / `Title lut 12` preamble still reads; only + /// the rows after it are parsed strictly. + #[test] + fn three_dl_mesh_preamble_is_accepted() { + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let plain = identity_3dl(&[0, 114, 455, 1023], 4); + let headed = parse_3dl(&format!("3DMESH\nTitle lut 12\n{plain}")).unwrap(); + assert_eq!(headed, parse_3dl(&plain).unwrap(), "the preamble changes nothing"); + assert_eq!(grey(&headed), 71.0, "the bent shaper still applies"); + let even = identity_3dl(&[0, 341, 682, 1023], 4); + assert_eq!(grey(&parse_3dl(&format!("3dmesh\nTitle lut 12\n{even}")).unwrap()), 128.0); + assert!(parse_3dl(&format!("3DMESH\nTitle lut 12\n0 341 junk 682 1023\n{}", even.split_once('\n').unwrap().1)).is_err(), "still strict after it"); + } + + /// #9170 review: a shaper that isn't finite is refused, not dropped, so a Color Lookup never + /// renders the table alone for a file whose shaper it can't apply. + #[test] + fn three_dl_non_finite_shaper_is_refused() { + let body = identity_3dl(&[0, 1, 2, 3], 4); + let body = body.split_once('\n').unwrap().1; + for shaper in ["0 NaN 682 1023", "0 341 inf 1023", "-inf 341 682 1023"] { + assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}"); + } + } + + /// #9170 review: a shaper row that starts with `NaN` or `inf` is a shaper, not a header, so + /// it reaches the non-finite refusal instead of being skipped. + #[test] + fn three_dl_shaper_starting_non_finite_is_refused() { + let body = identity_3dl(&[0, 1, 2, 3], 4); + let body = body.split_once('\n').unwrap().1; + for shaper in ["NaN 341 682 1023", "inf 341 682 1023", "-inf 341 682 1023"] { + assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}"); + } + } + + /// #9170 review: only a line starting with a word is a header. One whose first token looks + /// like a number but isn't (`0oops`) is read strictly and refused, not skipped with its shaper. + #[test] + fn three_dl_malformed_first_token_is_refused() { + let body = identity_3dl(&[0, 1, 2, 3], 4); + let body = body.split_once('\n').unwrap().1; + for shaper in ["0oops 114 455 1023", "-x 114 455 1023", ".5x 114 455 1023", "+ 114 455 1023"] { + assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}"); + } + let (first, rest) = body.split_once('\n').unwrap(); + assert!(parse_3dl(&format!("0 114 455 1023\n{first}\n1x 0 0\n{rest}")).is_err(), "a table row too"); + assert!(parse_3dl(&format!("3DMESH\nTitle lut 12\n0 114 455 1023\n{body}")).is_ok(), "headers still skip"); + } + + /// #9170 review: a bent shaper whose knots can't all be kept within MAX_SIZE is refused rather + /// than sampled where it may look even (here a 257-point shaper bent only at odd knots, whose + /// 129-node fallback would read the identity). + #[test] + fn three_dl_shaper_too_fine_to_keep_is_refused() { + let shaper: Vec = (0..257u32).map(|i| (i * 1023 / 256 + if i % 2 == 1 { 40 } else { 0 }).min(1023)).collect(); + assert!(parse_3dl(&identity_3dl(&shaper, 2)).is_err()); + // As many knots as nodes stays within reach. + let same: Vec = (0..17u32).map(|i| (1023.0 * (f64::from(i) / 16.0).powi(2)).round() as u32).collect(); + assert!(parse_3dl(&identity_3dl(&same, 17)).is_ok()); + } + #[test] fn cube_title_with_hash_round_trips() { let l = LutFile::from_fn("Look #3", 2, |c| c); @@ -687,6 +982,19 @@ DOMAIN_MAX 0.5 1 1 assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}"); } + #[test] + fn three_dl_mesh_extra_node_cannot_replace_a_captured_shaper() { + for (bits, size) in [(2, 5), (3, 9)] { + let mut text = mesh_3dl(bits, 12, size, 4095.0, true); + let valid = parse_3dl(&text).unwrap(); + assert_eq!(valid.size, size); + assert_eq!(valid.data, parse_3dl(&mesh_3dl(bits, 12, size, 4095.0, false)).unwrap().data); + text.push_str("4095 4095 4095\n"); + let error = parse_3dl(&text).unwrap_err().to_string(); + assert!(error.contains(&format!("expected {} rows", size * size * size)), "{error}"); + } + } + #[test] fn three_dl_malformed_mesh_header_falls_back_to_inference() { // A `Mesh` line that is not exactly two integer fields is not a declaration: it is diff --git a/crates/cms/src/pipeline.rs b/crates/cms/src/pipeline.rs index 1c095756ea..7fad1a107b 100644 --- a/crates/cms/src/pipeline.rs +++ b/crates/cms/src/pipeline.rs @@ -183,6 +183,17 @@ impl Pipeline { out[..self.outputs].copy_from_slice(&v[..self.outputs]); } + /// Evaluates the stages after the first one on values already passed through it. + #[inline] + pub(crate) fn eval_after_first(&self, v: &mut [f32; MAX], out: &mut [f32]) { + let mut n = self.inputs; + for s in self.stages.iter().skip(1) { + n = s.run(v, n); + } + let _ = n; + out[..self.outputs].copy_from_slice(&v[..self.outputs]); + } + /// Removes no-op stages and cancels adjacent inverse pairs (Lab↔XYZ, encode/decode). pub fn optimize(&mut self) { let mut out: Vec = Vec::with_capacity(self.stages.len()); diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index 5cd6c65c57..0c8b5fa4a9 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -42,6 +42,8 @@ pub const GRID_4D: usize = 17; const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS; /// Resolution of output shaper tables (indexed by `x^(1/4)`). const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES; +// The blocked matrix/TRC path wraps table indices with a mask instead of a bounds check. +const _: () = assert!(OUT_TABLE.is_power_of_two()); /// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ. const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287]; @@ -91,6 +93,8 @@ pub struct Transform { post: Option, /// Pre-located grid coordinates per 8-bit code: (offset contribution, fraction) per input. grid8: Vec<[(u32, f32); 256]>, + /// For an exact core: the first stage's curves at every 8-bit code. + exact8: Vec<[f32; 256]>, } impl Transform { @@ -178,7 +182,13 @@ impl Transform { .collect(), _ => Vec::new(), }; - Transform { inputs, outputs, opts, pipeline, core, post, grid8 } + let exact8 = match (&core, pipeline.stages.first()) { + (Core::Exact, Some(Stage::Curves(cs))) if cs.len() == inputs && inputs <= 4 => { + cs.iter().map(|c| std::array::from_fn(|i| c.eval(i as f32 / 255.0))).collect() + } + _ => Vec::new(), + }; + Transform { inputs, outputs, opts, pipeline, core, post, grid8, exact8 } } /// White preservation: the device-link node at the source's device white gets exactly the @@ -386,6 +396,10 @@ impl Transform { } } } + Core::Shaper { curves8, matrix: Some((3, 3, m, off)), .. } if self.shaper3_ok(curves8.len(), m.len(), off.len()) => { + let load = |sp: &[u8]| [curves8[0][sp[0] as usize], curves8[1][sp[1] as usize], curves8[2][sp[2] as usize]]; + self.shaper3_blocks(s, ss, d, ds, extra, m, off, load, q); + } Core::Shaper { curves8, matrix, .. } => { let mut v = [0.0f32; 16]; for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) { @@ -402,6 +416,21 @@ impl Transform { } } } + Core::Exact if !self.exact8.is_empty() => { + for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) { + let mut v = [0.0f32; 16]; + for k in 0..self.inputs { + v[k] = self.exact8[k][sp[k] as usize]; + } + self.pipeline.eval_after_first(&mut v, &mut out); + for k in 0..self.outputs { + dp[k] = q(out[k]); + } + for e in 0..extra { + dp[self.outputs + e] = sp[self.inputs + e]; + } + } + } _ => { let mut inp = [0.0f32; 16]; for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) { @@ -420,6 +449,74 @@ impl Transform { } } + fn shaper3_ok(&self, curves: usize, m: usize, off: usize) -> bool { + self.inputs == 3 + && self.outputs == 3 + && curves == 3 + && m == 9 + && off == 3 + && self.post.as_ref().is_some_and(|p| p.tables.len() == 3 && p.tables.iter().all(|t| t.len() == OUT_TABLE)) + } + + /// Matrix/TRC transform of whole pixels in blocks: the matrix and the output-table index + /// maths run over a block of pixels at a time (same operations, same order, per pixel). + #[allow(clippy::too_many_arguments)] + fn shaper3_blocks( + &self, + s: &[S], + ss: usize, + d: &mut [S], + ds: usize, + extra: usize, + m: &[f32], + off: &[f32], + load: impl Fn(&[S]) -> [f32; 3], + store: impl Fn(f32) -> S, + ) { + const B: usize = 64; + let Some(post) = self.post.as_ref() else { return }; + let tabs = [&post.tables[0][..OUT_TABLE], &post.tables[1][..OUT_TABLE], &post.tables[2][..OUT_TABLE]]; + let m: [f32; 9] = std::array::from_fn(|i| m[i]); + let off: [f32; 3] = std::array::from_fn(|i| off[i]); + let n_px = (s.len() / ss).min(d.len() / ds); + let mut x = [[0.0f32; B]; 3]; + let mut start = 0; + while start < n_px { + let n = B.min(n_px - start); + let sb = &s[start * ss..(start + n) * ss]; + let db = &mut d[start * ds..(start + n) * ds]; + for (j, sp) in sb.chunks_exact(ss).enumerate() { + let [a, b, c] = load(sp); + for r in 0..3 { + let mut acc = off[r]; + acc += m[r * 3] * a; + acc += m[r * 3 + 1] * b; + acc += m[r * 3 + 2] * c; + x[r][j] = acc; + } + } + for (ch, t) in tabs.iter().enumerate() { + let mut u = [0.0f32; B]; + let mut ix = [0i32; B]; + for j in 0..B { + let v = x[ch][j].clamp(0.0, 1.0).sqrt().sqrt() * (OUT_TABLE - 1) as f32; + let i = (v as i32).min(OUT_TABLE as i32 - 2); + ix[j] = i; + u[j] = v - i as f32; + } + for (j, dp) in db.chunks_exact_mut(ds).enumerate() { + let i = ix[j] as usize & (OUT_TABLE - 1); + let (v0, v1) = (t[i], t[(i + 1) & (OUT_TABLE - 1)]); + dp[ch] = store(v0 + (v1 - v0) * u[j]); + } + } + for (sp, dp) in sb.chunks_exact(ss).zip(db.chunks_exact_mut(ds)) { + dp[3..3 + extra].copy_from_slice(&sp[3..3 + extra]); + } + start += n; + } + } + /// Converts 16-bit pixels (see [`Transform::convert_f32`] for the layout). pub fn convert_u16(&self, src: &[u16], src_stride: usize, dst: &mut [u16], dst_stride: usize, copy_extra: bool) { self.check(src_stride, dst_stride); @@ -428,6 +525,13 @@ impl Transform { } fn run_u16(&self, s: &[u16], ss: usize, d: &mut [u16], ds: usize, extra: usize) { + if let Core::Shaper { curves, matrix: Some((3, 3, m, off)), .. } = &self.core + && self.shaper3_ok(curves.len(), m.len(), off.len()) + { + let load = + |sp: &[u16]| [table(&curves[0], sp[0] as f32 / 65535.0), table(&curves[1], sp[1] as f32 / 65535.0), table(&curves[2], sp[2] as f32 / 65535.0)]; + return self.shaper3_blocks(s, ss, d, ds, extra, m, off, load, |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16); + } let mut inp = [0.0f32; 16]; let mut out = [0.0f32; 16]; for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) { diff --git a/crates/cms/tests/it/cms.rs b/crates/cms/tests/it/cms.rs index f390a390da..12a451bcad 100644 --- a/crates/cms/tests/it/cms.rs +++ b/crates/cms/tests/it/cms.rs @@ -819,3 +819,93 @@ fn device_links_parse_and_are_reported_unsupported() { // The output space still bounds the tag: a CMYK→CMYK link with a 4→3 table is malformed. assert!(matches!(Profile::parse(&device_link(b"CMYK", b"CMYK", 4, 3)), Err(photocraft_cms::CmsError::Invalid(_)))); } + +/// The blocked matrix/TRC path (64 pixels at a time) must match the per-pixel evaluation for +/// every pixel count around the block size, with and without extra channels. +#[test] +fn matrix_trc_blocks_match_per_pixel_eval() { + let pairs = [(Builtin::Srgb, Builtin::DisplayP3), (Builtin::ProPhotoCompat, Builtin::Srgb), (Builtin::AdobeRgbCompat, Builtin::Rec2020)]; + for (a, b) in pairs { + let t = Transform::new(a.profile(), b.profile(), Intent::RelativeColorimetric, true).unwrap(); + for &(ss, ds, copy) in &[(3usize, 3usize, false), (4, 4, true), (5, 4, true)] { + let max = 20011; + let seed = |i: usize| (i.wrapping_mul(2654435761) >> 7) as u32; + let src16: Vec = (0..max * ss).map(|i| (seed(i) >> 3) as u16).collect(); + let src8: Vec = (0..max * ss).map(|i| (seed(i) >> 11) as u8).collect(); + let mut full8 = vec![0u8; max * ds]; + t.convert_u8(&src8, ss, &mut full8, ds, copy); + for px in [1usize, 2, 63, 64, 65, 127, 128, 129, 1000] { + let mut o16 = vec![0u16; px * ds]; + t.convert_u16(&src16[..px * ss], ss, &mut o16, ds, copy); + let mut o8 = vec![0u8; px * ds]; + t.convert_u8(&src8[..px * ss], ss, &mut o8, ds, copy); + assert_eq!(o8[..], full8[..px * ds], "u8 prefix of a longer buffer, {px} px"); + for p in 0..px { + let inp: Vec = (0..3).map(|k| src16[p * ss + k] as f32 / 65535.0).collect(); + let mut want = [0.0f32; 3]; + t.eval_fast(&inp, &mut want); + for k in 0..3 { + assert_eq!(o16[p * ds + k], (want[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16, "u16 px {p} ch {k} of {px}"); + } + if copy && ds > 3 { + assert_eq!(o16[p * ds + 3], src16[p * ss + 3]); + assert_eq!(o8[p * ds + 3], src8[p * ss + 3]); + } + } + } + } + } +} + +/// 8-bit and 16-bit conversion through an analytic pipeline (no lookup table, here RGB to Lab) +/// must equal the exact per-pixel evaluation. The 8-bit path tabulates the first stage's +/// curves per code; `curves_are_tables` selects whether those curves are parametric (their +/// `eval` is analytic) or `curv` tables (their `eval` interpolates inside the same loop). +fn check_exact_core(src_profile: &Profile, curves_are_tables: bool) { + let trc = src_profile.trc.as_ref().expect("matrix/TRC source"); + assert!(trc.iter().all(|c| matches!(c, photocraft_cms::Curve::Table(_)) == curves_are_tables), "TRC kind"); + let t = Transform::new(src_profile, lab(), Intent::RelativeColorimetric, true).unwrap(); + let px = 256usize * 256; + let src: Vec = (0..px as u32).flat_map(|i| [(i & 255) as u8, (i >> 8) as u8, (i.wrapping_mul(2654435761) >> 13) as u8, 200]).collect(); + let mut out = vec![0u8; px * 4]; + t.convert_u8(&src, 4, &mut out, 4, true); + for (sp, dp) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) { + let inp = [sp[0] as f32 / 255.0, sp[1] as f32 / 255.0, sp[2] as f32 / 255.0]; + let mut want = [0.0f32; 3]; + t.eval(&inp, &mut want); + for k in 0..3 { + assert_eq!(dp[k], (want[k].clamp(0.0, 1.0) * 255.0 + 0.5) as u8, "u8 {sp:?} ch {k}"); + } + assert_eq!(dp[3], 200); + } + // 16-bit input stays off the 8-bit table and must agree with the untabulated evaluation. + let src16: Vec = (0..px * 4).map(|i| if i % 4 == 3 { 51400 } else { (i.wrapping_mul(2654435761) >> 9) as u16 }).collect(); + let mut out16 = vec![0u16; px * 4]; + t.convert_u16(&src16, 4, &mut out16, 4, true); + for (sp, dp) in src16.as_chunks::<4>().0.iter().zip(out16.as_chunks::<4>().0) { + let inp = [sp[0] as f32 / 65535.0, sp[1] as f32 / 65535.0, sp[2] as f32 / 65535.0]; + let mut want = [0.0f32; 3]; + t.eval(&inp, &mut want); + for k in 0..3 { + assert_eq!(dp[k], (want[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16, "u16 {sp:?} ch {k}"); + } + assert_eq!(dp[3], 51400); + } +} + +#[test] +fn exact_core_u8_matches_exact_eval() { + check_exact_core(srgb(), false); +} + +/// Same, with `curv` table TRCs: the first-stage table is built from `Curve::eval`'s table +/// branch and must still be applied to its own channel. +#[test] +fn exact_core_u8_matches_exact_eval_with_table_trc() { + // Distinct tables per channel, so a first stage applied to the wrong channel shows up. + let mut p = srgb_as_v2_tables(); + let table = |g: f64| photocraft_cms::Curve::Table((0..1024).map(|i| ((i as f64 / 1023.0).powf(g) * 65535.0).round() as f32 / 65535.0).collect()); + p.trc = Some([table(1.8), table(2.2), table(2.6)]); + let table_trc = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap(); + check_exact_core(&table_trc, true); +} diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index cadc2b4bc8..1d220e6ac7 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -31,6 +31,7 @@ const DDPF_FOURCC: u32 = 0x4; const DDPF_RGB: u32 = 0x40; const DDPF_LUMINANCE: u32 = 0x2_0000; const DDSCAPS2_CUBEMAP: u32 = 0x200; +const DDSCAPS2_CUBEMAP_FACES: u32 = 0xfc00; const DDSCAPS2_VOLUME: u32 = 0x20_0000; const DIMENSION_TEXTURE2D: u32 = 3; const MISC_TEXTURECUBE: u32 = 0x4; @@ -46,6 +47,16 @@ fn le32(b: &[u8], at: usize) -> Result { Ok(u32::from_le_bytes(bytes)) } +/// Header-declared extra subresources cannot survive a single-image rewrite, even without presence flags. +pub(crate) fn has_additional_images(bytes: &[u8]) -> Result { + let depth = le32(bytes, 24)?; + let mips = le32(bytes, 28)?; + let faces = (le32(bytes, 112)? & DDSCAPS2_CUBEMAP_FACES).count_ones(); + let dx10 = le32(bytes, 80)? & DDPF_FOURCC != 0 && bytes.get(84..88) == Some(b"DX10"); + let (cube, array) = if dx10 { (le32(bytes, 136)? & MISC_TEXTURECUBE != 0, le32(bytes, 140)?) } else { (false, 0) }; + Ok(depth > 1 || mips > 1 || faces > 1 || cube || array > 1) +} + /// A FourCC as text when it is printable, else as the number legacy writers store there. fn fourcc_name(fourcc: u32) -> String { let bytes = fourcc.to_le_bytes(); diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index aa71f94651..86e4938b0d 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -146,6 +146,11 @@ struct Header { element: Element, } +/// The decoder opens only element zero; protect the other declared elements from flat saves. +pub(crate) fn has_additional_images(bytes: &[u8]) -> Result { + Ok(parse(bytes)?.elements > 1) +} + fn parse(b: &[u8]) -> Result { let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?; let missing = |what: &str| err(format!("the header ends before {what}")); diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index ec0956a4ec..0d68d20ca9 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -128,12 +128,20 @@ fn part_info(index: usize, header: &exr::meta::header::Header) -> ExrPartInfo { // U32 channels (Arnold ID buffers) are listed as F32: exact up to 2^24. exr::meta::attribute::SampleType::F32 | exr::meta::attribute::SampleType::U32 => SampleType::F32, }; + let display = &header.shared_attributes.display_window; ExrPartInfo { index, name: header.own_attributes.layer_name.as_ref().map(|n| n.to_string()), view: header.own_attributes.view_name.as_ref().map(|v| v.to_string()), width: w, height: h, + data_window: (header.own_attributes.layer_position.0, header.own_attributes.layer_position.1, w, h), + display_window: ( + display.position.0, + display.position.1, + u32::try_from(display.size.0).unwrap_or(u32::MAX), + u32::try_from(display.size.1).unwrap_or(u32::MAX), + ), deep: header.deep, tiled: matches!(header.blocks, exr::meta::BlockDescription::Tiles(_)), channels: header.channels.list.iter().map(|c| ExrChannelInfo { name: c.name.to_string(), sample: sample(c.sample_type) }).collect(), diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 214d968944..ae6e945054 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -211,14 +211,65 @@ fn data_len(h: &Header, bitpix: i64) -> Result { .ok_or_else(|| err("data size overflows")) } -/// Finds the image HDU: the primary one when it has at least two axes, else the first `IMAGE` -/// extension. Returns its header and the primary header (for its descriptive cards). +/// Inspect every HDU without decoding pixels: one gray plane or three RGB planes are retained, +/// while other cubes, image extensions, tables and other data-bearing HDUs cannot survive a flat save. +/// Incomplete or over-limit walks fail closed rather than treating an unchecked suffix as safe. +pub(crate) fn has_additional_images(bytes: &[u8]) -> Result { + let mut start = 0; + let mut image_seen = false; + for index in 0..MAX_HDUS { + let h = parse_header(bytes, start)?; + if index == 0 && h.get("SIMPLE").is_none() { + return Err(err("missing SIMPLE")); + } + let ax = axes(&h)?; + let size = data_len(&h, h.int("BITPIX").unwrap_or(8))?; + let image_hdu = index == 0 || h.string("XTENSION") == Some("IMAGE"); + if h.logical("ZIMAGE") { + return Ok(true); + } + if !image_hdu && size > 0 { + return Ok(true); + } + // An image HDU with a zero axis has no data (FITS 4.0, NAXISn) and holds no image, like + // the empty primary: `find_image` skips it too. + if image_hdu && size > 0 && ax.len() >= 2 && ax[..2].iter().all(|n| *n > 0) { + let planes = ax.iter().skip(2).try_fold(1u64, |n, axis| n.checked_mul(*axis)).ok_or_else(|| err("plane count overflows"))?; + let retained = planes == 1 || (ax.get(2) == Some(&3) && planes == 3); + if image_seen || !retained || h.int("GCOUNT").unwrap_or(1) > 1 || h.int("PCOUNT").unwrap_or(0) > 0 { + return Ok(true); + } + image_seen = true; + } else if size > 0 { + return Ok(true); + } + let padded = size.div_ceil(BLOCK as u64).checked_mul(BLOCK as u64).ok_or_else(|| err("data size overflows"))?; + let next = usize::try_from(padded).ok().and_then(|size| h.data_start.checked_add(size)).ok_or_else(|| err("data size overflows"))?; + if next > bytes.len() { + return Err(err("truncated HDU data")); + } + if next == bytes.len() { + return Ok(false); + } + start = next; + } + Err(err("too many HDUs to inspect every source image")) +} + +/// Whether an HDU with these axes stores pixels: at least two axes and none of them zero (FITS 4.0, +/// NAXISn: a zero axis means no data follow the header). +fn holds_pixels(ax: &[u64]) -> bool { + ax.len() >= 2 && ax.iter().all(|n| *n > 0) +} + +/// Finds the image HDU: the primary one when it holds pixels, else the first `IMAGE` extension +/// that does. Returns its header and the primary header (for its descriptive cards). fn find_image(b: &[u8]) -> Result<(Header, Option
), CodecError> { let primary = parse_header(b, 0)?; if primary.get("SIMPLE").is_none() { return Err(err("missing SIMPLE")); } - if axes(&primary)?.len() >= 2 { + if holds_pixels(&axes(&primary)?) { return Ok((primary, None)); } let mut compressed = false; @@ -234,7 +285,7 @@ fn find_image(b: &[u8]) -> Result<(Header, Option
), CodecError> { } let h = parse_header(b, next)?; let kind = h.string("XTENSION").unwrap_or("").to_string(); - if kind == "IMAGE" && axes(&h)?.len() >= 2 { + if kind == "IMAGE" && holds_pixels(&axes(&h)?) { return Ok((h, Some(primary))); } if kind == "BINTABLE" && h.logical("ZIMAGE") { @@ -683,6 +734,41 @@ mod tests { assert!(img.meta.text.contains(&("TELESCOP".into(), "Seestar S50".into()))); } + /// A zero axis (`NAXIS3 = 0`) means the HDU has no data: it is neither the opened image nor + /// an additional one that Batch Save and Close would discard. + #[test] + fn zero_axis_image_hdus_are_empty() { + let empty = |primary: bool| { + let first = if primary { "SIMPLE = T" } else { "XTENSION= 'IMAGE '" }; + header(&[first, "BITPIX = 8", "NAXIS = 3", "NAXIS1 = 1", "NAXIS2 = 1", "NAXIS3 = 0", "PCOUNT = 0", "GCOUNT = 1"]) + }; + let image = || { + let mut b = header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1", "PCOUNT = 0", "GCOUNT = 1"]); + b.push(42); + b.resize(b.len().div_ceil(BLOCK) * BLOCK, 0); + b + }; + let mut after_empty_primary = empty(true); + after_empty_primary.extend(image()); + let mut after_empty_extension = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0", "EXTEND = T"]); + after_empty_extension.extend(empty(false)); + after_empty_extension.extend(image()); + let mut before_empty_extension = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0", "EXTEND = T"]); + before_empty_extension.extend(image()); + before_empty_extension.extend(empty(false)); + for b in [&after_empty_primary, &after_empty_extension, &before_empty_extension] { + assert!(!crate::has_unretained_source_data(b).unwrap()); + assert_eq!(decode(b, &Limits::default()).unwrap().data(), &[42]); + } + // Only empty HDUs: nothing to open, nothing to lose. + assert!(!crate::has_unretained_source_data(&empty(true)).unwrap()); + assert!(decode(&empty(true), &Limits::default()).is_err()); + // A second non-empty image is still additional data. + let mut two = after_empty_primary.clone(); + two.extend(image()); + assert!(crate::has_unretained_source_data(&two).unwrap()); + } + #[test] fn fpack_is_refused_as_unsupported() { let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]); diff --git a/crates/codecs/src/codecs/gif_frames.rs b/crates/codecs/src/codecs/gif_frames.rs index 6a99b54f7b..b5439f6eff 100644 --- a/crates/codecs/src/codecs/gif_frames.rs +++ b/crates/codecs/src/codecs/gif_frames.rs @@ -48,6 +48,9 @@ pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result, decoder.set_limits(il).map_err(map)?; let (w, h) = decoder.dimensions(); limits.check_bytes(w, h, 4)?; + // The backend initializes the whole logical screen before it reads the first frame, so the + // same file-size plausibility as the first-frame decode applies before iterating (#8597). + super::via_image::gif_plausible(bytes, w, h, 4)?; let frame_bytes = u64::from(w) * u64::from(h) * 4; let mut out: Vec = Vec::new(); diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index f2c4d4b9ee..e2e854622f 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -196,6 +196,54 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } +const EXTENDED_XMP_HEADER: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + +/// The file without its Extended XMP APP1 segments (`None` when it has none), for `zune-jpeg`. +/// +/// zune-jpeg keeps every Extended XMP fragment until a GUID's set is complete and re-sorts and +/// re-checks all pending fragments after every marker it parses, so many small, consistent but +/// incomplete fragments (or a few followed by many other markers) cost quadratic time before a +/// pixel is decoded. Nothing here reads its reassembled Extended XMP ([`scan_metadata`] works on +/// the original bytes), so the fragments are left out of what it parses. The walk stops at the +/// first scan, as the header parse does, and skips stray bytes to the next `FF` as it does. +fn without_extended_xmp(b: &[u8]) -> Option> { + let mut drop: Vec> = Vec::new(); + let mut i = 2; // past SOI + while let Some(&byte) = b.get(i) { + if byte != 0xFF { + i += b.get(i..)?.iter().position(|&v| v == 0xFF)?; + continue; + } + let Some(&code) = b.get(i + 1) else { break }; + match code { + 0xFF => i += 1, + 0x01 | 0xD0..=0xD8 => i += 2, + 0xD9 | 0xDA => break, + _ => { + let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else { break }; + let end = i.saturating_add(2 + usize::from(len)); + if code == 0xE1 && b.get(i + 4..).is_some_and(|p| p.starts_with(EXTENDED_XMP_HEADER)) { + drop.push(i..end.min(b.len())); + } + i = end; + } + } + } + if drop.is_empty() { + return None; + } + // Reserve what is kept, not the input: the dropped fragments may be most of it. + let kept = b.len().saturating_sub(drop.iter().map(|r| r.end - r.start).sum::()); + let mut out = Vec::with_capacity(kept); + let mut at = 0; + for r in drop { + out.extend_from_slice(b.get(at..r.start)?); + at = r.end; + } + out.extend_from_slice(b.get(at..)?); + Some(out) +} + /// Whether `craft-jpeg` (rather than `zune-jpeg`) decodes the file: arithmetic coding /// (SOF9–SOF11) or a DCT frame whose precision isn't 8 bits (#4934, #5003). Lossless (SOF3) and /// 12-bit progressive Huffman (SOF2) go there too: craft-jpeg reports them unsupported, where @@ -273,7 +321,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result return Ok(img); } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535); - let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options); + let stripped = without_extended_xmp(bytes); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(stripped.as_deref().unwrap_or(bytes)), options); dec.decode_headers().map_err(err)?; let (w, h) = dec.dimensions().ok_or_else(|| err("no dimensions"))?; let (w, h) = (w as u32, h as u32); @@ -525,6 +574,36 @@ mod tests { bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") } + /// An APP1 Extended XMP fragment (GUID, total size, offset, data). + fn extended_xmp(offset: u32) -> Vec { + let mut seg = EXTENDED_XMP_HEADER.to_vec(); + seg.extend_from_slice(&[b'A'; 32]); + seg.extend_from_slice(&1_000_000u32.to_be_bytes()); + seg.extend_from_slice(&offset.to_be_bytes()); + seg.push(b'x'); + let mut out = vec![0xFF, 0xE1]; + out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + out.extend_from_slice(&seg); + out + } + + #[test] + fn extended_xmp_fragments_are_left_out_of_the_backend_parse() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![120; 16 * 16 * 3]).unwrap(); + let plain = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + assert!(without_extended_xmp(&plain).is_none()); + // 20000 consistent but incomplete fragments right after SOI: zune-jpeg would re-sort and + // re-check the growing pending list after each one. + let mut hostile = plain[..2].to_vec(); + for k in 0..20_000u32 { + hostile.extend_from_slice(&extended_xmp(k * 2)); + } + hostile.extend_from_slice(&plain[2..]); + assert_eq!(without_extended_xmp(&hostile).as_deref(), Some(&plain[..])); + let back = decode(&hostile, &Limits::default()).unwrap(); + assert_eq!(back.dimensions(), (16, 16)); + } + /// 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] diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 1d91604abf..d1ff5140cb 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed}; +use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -26,74 +26,653 @@ pub(crate) fn dpi_of_ppm(ppm: u32) -> f32 { if (whole / f64::from(METERS_PER_INCH)).round() == f64::from(ppm) { whole as f32 } else { dpi as f32 } } +/// Decodes a whole PNG, by driving a [`PngDecodeRows`] to its end. pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { - let mut input = Cursor::new(bytes); - let mut decoder = png::Decoder::new_with_limits(&mut input, png::Limits { bytes: limits.alloc_usize() }); - decoder.set_transformations(png::Transformations::EXPAND); - // png's lazy text get_text() inflates without consulting its allocation limit. - // Read text separately below, without retaining compressed copies in the decoder. - decoder.set_ignore_text_chunk(true); - { - let info = decoder.read_header_info().map_err(map_png_err)?; - let (w, h) = info.size(); - // Worst case output: 4 channels x 16 bit. - limits.check_bytes(w, h, 8)?; - // zlib never beats 1032:1; refuse a file too small for the declared image before the - // output buffer exists. Measured on the stored samples (before palette/bit expansion). - let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8); - Limits::check_plausible(stored, bytes.len(), 1100)?; + PngDecodeRows::new(bytes, limits)?.step(&mut || true)?.ok_or_else(|| err("the decoder stopped before the image ended")) +} + +/// PNG bytes shared between the decoder and whoever else keeps them (a placed file's embedded +/// source): cloning shares the buffer. +#[derive(Debug, Clone)] +pub struct SharedBytes(pub std::sync::Arc>); + +impl AsRef<[u8]> for SharedBytes { + fn as_ref(&self) -> &[u8] { + self.0.as_slice() } - let mut reader = decoder.read_info().map_err(map_png_err)?; - // APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it. - let images = reader.info().animation_control.map_or(1, |a| u64::from(a.num_frames) + u64::from(reader.info().frame_control.is_none())); - let (color, depth) = reader.output_color_type(); - let layout = match color { - png::ColorType::Grayscale => ChannelLayout::Gray, - png::ColorType::GrayscaleAlpha => ChannelLayout::GrayA, - png::ColorType::Rgb => ChannelLayout::Rgb, - png::ColorType::Rgba => ChannelLayout::Rgba, - png::ColorType::Indexed => return Err(err("palette was not expanded")), - }; - let sample = match depth { - png::BitDepth::Eight => SampleType::U8, - png::BitDepth::Sixteen => SampleType::U16, - d => return Err(err(format!("unexpected output bit depth {d:?}"))), - }; - let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?; - let mut buf = alloc_zeroed::(size)?; - let out = reader.next_frame(&mut buf).map_err(map_png_err)?; - buf.truncate(out.buffer_size()); - // Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels - // are complete, but never swallow an allocation-limit failure. - if let Err(e @ png::DecodingError::LimitsExceeded) = reader.finish() { - return Err(map_png_err(e)); +} + +/// The input of a [`PngDecodeRows`] decoder: a cursor that also tells where the reader stopped, which +/// the text chunks after the image are read up to. +struct Tracked { + cursor: Cursor, + at: std::sync::Arc, +} + +impl> Tracked { + fn sync(&self) { + self.at.store(self.cursor.position(), std::sync::atomic::Ordering::Relaxed); } - if sample == SampleType::U16 { - for c in buf.as_chunks_mut::<2>().0 { - let v = u16::from_be_bytes([c[0], c[1]]); - c.copy_from_slice(&v.to_ne_bytes()); +} + +impl> std::io::Read for Tracked { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.cursor.read(buf)?; + self.sync(); + Ok(n) + } +} + +impl> std::io::BufRead for Tracked { + fn fill_buf(&mut self) -> std::io::Result<&[u8]> { + self.cursor.fill_buf() + } + + fn consume(&mut self, amt: usize) { + self.cursor.consume(amt); + self.sync(); + } +} + +impl> std::io::Seek for Tracked { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + let at = self.cursor.seek(pos)?; + self.sync(); + Ok(at) + } +} + +/// The decoder's input, shared between the reader and the metadata pass: moving an owned buffer in +/// doesn't keep a second copy of it. +struct SharedInput(std::sync::Arc); + +impl Clone for SharedInput { + fn clone(&self) -> Self { + Self(std::sync::Arc::clone(&self.0)) + } +} + +impl> AsRef<[u8]> for SharedInput { + fn as_ref(&self) -> &[u8] { + (*self.0).as_ref() + } +} + +/// A PNG decoded in resumable steps, for a caller that must not block for the whole image (the +/// web build, which has no threads): [`PngDecodeRows::step`] decodes rows until the caller says to +/// stop, and returns the [`Image`] once the last row and the chunks after it are read. [`decode`] +/// drives one to the end, so the two give the same image. +/// +/// An interlaced (Adam7) PNG decodes in a single step: its passes land at stride positions all +/// over the image, so no row is final before the last pass. +pub struct PngDecodeRows> { + reader: png::Reader>>, + at: std::sync::Arc, + bytes: SharedInput, + limits: Limits, + buf: Vec, + layout: ChannelLayout, + sample: SampleType, + /// The image's size (the first frame's, for an animation) and bytes per output row. + width: u32, + height: usize, + line: usize, + /// The decoder's whole output size, for an interlaced image's single frame decode. + size: usize, + rows: usize, + /// For [`PngDecodeRows::step_stream`]: the first row of the group held in `buf`. + group_start: usize, + interlaced: bool, + images: u64, + /// The chunks after the pixels: scanned from `text_at` up to `text_end`, a batch at a time, + /// with `text` keeping what was accepted and the decompressor's state. + text_at: usize, + text_end: usize, + text: TextScan, + /// True once the pixel reader has been finished and the tail is known. + tail: bool, + done: bool, + /// Test-only count of the pixel finalization: metadata yields must not repeat it. + #[cfg(test)] + pixel_finalizations: u32, +} + +impl> PngDecodeRows { + /// Reads the header and refuses what [`decode`] refuses before any pixel is decoded: sizes + /// past `limits`, and a file too small for the image it declares. + pub fn new(bytes: B, limits: &Limits) -> Result { + Self::open(bytes, limits, true) + } + + /// [`PngDecodeRows::new`], optionally without reserving the whole image: a caller that hands + /// the rows on as they come ([`decode_rows`]) never holds them all. + fn open(bytes: B, limits: &Limits, reserve: bool) -> Result { + let at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let bytes = SharedInput(std::sync::Arc::new(bytes)); + let input = Tracked { cursor: Cursor::new(bytes.clone()), at: at.clone() }; + let mut decoder = png::Decoder::new_with_limits(input, png::Limits { bytes: limits.alloc_usize() }); + decoder.set_transformations(png::Transformations::EXPAND); + // png's lazy text get_text() inflates without consulting its allocation limit. + // Read text separately below, without retaining compressed copies in the decoder. + decoder.set_ignore_text_chunk(true); + { + let info = decoder.read_header_info().map_err(map_png_err)?; + let (w, h) = info.size(); + // Size checks now; the allocation budget once the output layout (channels and sample + // size) is known below, so a Gray8 image isn't charged for RGBA16. + limits.check_size(w, h)?; + // zlib never beats 1032:1; refuse a file too small for the declared image before the + // output buffer exists. Measured on the stored samples (before palette/bit expansion). + let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8); + Limits::check_plausible(stored, bytes.as_ref().len(), 1100)?; } + let reader = decoder.read_info().map_err(map_png_err)?; + // APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it. + let images = reader.info().animation_control.map_or(1, |a| u64::from(a.num_frames) + u64::from(reader.info().frame_control.is_none())); + let (color, depth) = reader.output_color_type(); + let layout = match color { + png::ColorType::Grayscale => ChannelLayout::Gray, + png::ColorType::GrayscaleAlpha => ChannelLayout::GrayA, + png::ColorType::Rgb => ChannelLayout::Rgb, + png::ColorType::Rgba => ChannelLayout::Rgba, + png::ColorType::Indexed => return Err(err("palette was not expanded")), + }; + let sample = match depth { + png::BitDepth::Eight => SampleType::U8, + png::BitDepth::Sixteen => SampleType::U16, + d => return Err(err(format!("unexpected output bit depth {d:?}"))), + }; + // The frame the pixels are of: an fcTL before the IDAT overrides the header's size. + let info = reader.info(); + let (width, height) = info.frame_control.map_or_else(|| info.size(), |f| (f.width, f.height)); + let line = reader.output_line_size(width).ok_or_else(|| err("image too large"))?; + // The output layout is known: charge its real bytes per pixel, not RGBA16's worst case. + limits.check(width, height, layout, sample)?; + let height = usize::try_from(height).map_err(|_| err("image too large"))?; + if width == 0 || height == 0 { + return Err(err("the image is empty")); + } + let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?; + if line.checked_mul(height).is_none_or(|needed| needed > size) { + return Err(err("image too large")); + } + // Reserve without touching: each row is zero-filled as it is decoded, so the first slice + // doesn't zero-fill the whole image (a stall on the web build). + let mut buf = Vec::new(); + if reserve { + buf.try_reserve_exact(size).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {size} bytes for the image")))?; + } + let interlaced = info.interlaced; + Ok(Self { + reader, + at, + bytes, + limits: *limits, + buf, + layout, + sample, + width, + height, + line, + size, + rows: 0, + group_start: 0, + interlaced, + images, + text_at: 8, + text_end: 0, + text: TextScan::default(), + tail: false, + done: false, + #[cfg(test)] + pixel_finalizations: 0, + }) } + + /// [`PngDecodeRows::new`] without reserving the whole image, for a caller that streams the rows + /// ([`PngDecodeRows::step_stream`]) and so never holds them all. A caller that finds it must + /// decode the whole image after all (an interlaced file) calls + /// [`PngDecodeRows::reserve_output`] before [`PngDecodeRows::step`]. + pub fn new_streaming(bytes: B, limits: &Limits) -> Result { + Self::open(bytes, limits, false) + } + + /// Reserves the buffer [`PngDecodeRows::step`] decodes the whole image into. + pub fn reserve_output(&mut self) -> Result<(), CodecError> { + let size = self.size; + self.buf.try_reserve_exact(size).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {size} bytes for the image"))) + } + + /// Whether the rows come in final order, so [`PngDecodeRows::step_stream`] can hand them on: + /// not an interlaced file, not an APNG (its first frame may be a sub-rectangle), and not one + /// whose `eXIf` before the image data asks for another orientation. (An `eXIf` after the image + /// data shows only in [`PngExtras::orientation`] at the end.) + pub fn streamable(&self) -> bool { + let info = self.reader.info(); + let upright = info.exif_metadata.as_deref().is_none_or(|e| crate::orientation::exif_orientation(e) == 1); + !self.interlaced && info.animation_control.is_none() && upright + } + + /// The size and pixel format the rows of [`PngDecodeRows::step_stream`] come in. + pub fn header(&self) -> PngHeader { + PngHeader { width: self.width, height: u32::try_from(self.height).unwrap_or(u32::MAX), layout: self.layout, sample: self.sample } + } + + /// The image's width and height in pixels. + pub fn size(&self) -> (u32, u32) { + (self.width, u32::try_from(self.height).unwrap_or(u32::MAX)) + } + + /// How much of the image is decoded, `0.0..=1.0`. + pub fn progress(&self) -> f32 { + if self.done { 1.0 } else { self.rows as f32 / self.height.max(1) as f32 } + } + + /// Decodes at least one row (the whole image if it is interlaced), then goes on while `more()` + /// returns true. `Some` once the image is complete, after which the decoder is spent. + pub fn step(&mut self, more: &mut dyn FnMut() -> bool) -> Result, CodecError> { + if self.done { + return Err(err("the image was already decoded")); + } + // Once the pixels are complete and the tail is known, a step only reads metadata: + // running the pixel reader again after `begin_tail` would decode an Adam7 frame twice + // or finish the image data again on every metadata yield. + if !self.tail { + if self.interlaced { + // An Adam7 frame is decoded in one go, over the whole output buffer. + self.buf.resize(self.size, 0); + let out = self.reader.next_frame(&mut self.buf).map_err(map_png_err)?; + self.buf.truncate(out.buffer_size()); + self.rows = self.height; + if self.sample == SampleType::U16 { + to_native_endian(&mut self.buf); + } + } + while self.rows < self.height { + let at = self.rows.checked_mul(self.line).ok_or_else(|| err("image too large"))?; + let end = at.checked_add(self.line).ok_or_else(|| err("image too large"))?; + // Grow by one row at a time: the zero-fill of the output is spread over the decodes + // instead of stalling the first slice. + if self.buf.len() < end { + self.buf.resize(end, 0); + } + let row = self.buf.get_mut(at..end).ok_or_else(|| err("the image data is longer than the image"))?; + self.reader.read_row(row).map_err(map_png_err)?.ok_or_else(|| err("the image data ended early"))?; + if self.sample == SampleType::U16 { + to_native_endian(row); + } + self.rows += 1; + if self.rows < self.height && !more() { + return Ok(None); + } + } + if !self.interlaced { + #[cfg(test)] + { + self.pixel_finalizations += 1; + } + // No row is left: this reads on to the end of the image data. + if self.reader.read_row(&mut []).map_err(map_png_err)?.is_some() { + return Err(err("the image has more rows than its header declares")); + } + self.buf.truncate(self.line.saturating_mul(self.height)); + } + // The chunks after the pixels, one bounded batch at a time: a large text chunk never + // makes one step block, and what was accepted stays accepted when a step stops. + self.begin_tail()?; + } + while !self.text.done { + self.text_batch()?; + if !self.text.done && !more() { + return Ok(None); + } + } + self.done = true; + self.finish().map(Some) + } + + /// Decodes the rows of a [`streamable`](PngDecodeRows::streamable) file a group at a time: + /// `sink(y0, data)` gets whole rows `y0..` in final order, `group` of them at a time (the last + /// group may be shorter; 0 counts as 1), native-endian, exactly the samples [`decode`] would + /// return, and the decoder keeps only that group. Decodes at least one row, then goes on while + /// `more()` returns true; `Some` with the extras once the file is read to its end, with the + /// resolution a format that lacks one takes from its EXIF or XMP. A damaged file fails with + /// the error [`decode`] gives, after however many rows were delivered. An orientation (an + /// `eXIf` after the image data may set one) is for the caller to apply. + pub fn step_stream>( + &mut self, + group: u32, + more: &mut dyn FnMut() -> bool, + sink: &mut PngRowSink<'_, E>, + ) -> Result, E> { + if self.done { + return Err(err("the image was already decoded").into()); + } + let group = usize::try_from(group.max(1)).unwrap_or(usize::MAX); + if !self.tail { + while self.rows < self.height { + let held = self.rows - self.group_start; + let at = held.checked_mul(self.line).ok_or_else(|| err("image too large"))?; + let end = at.checked_add(self.line).ok_or_else(|| err("image too large"))?; + if self.buf.len() < end { + self.buf.try_reserve_exact(end - self.buf.len()).map_err(|_| CodecError::LimitExceeded("cannot allocate a group of rows".into()))?; + self.buf.resize(end, 0); + } + let row = self.buf.get_mut(at..end).ok_or_else(|| err("invalid PNG row group"))?; + if self.reader.read_row(row).map_err(map_png_err)?.is_none() { + return Err(err("the image data ends before the last row").into()); + } + if self.sample == SampleType::U16 { + to_native_endian(row); + } + self.rows += 1; + if self.rows - self.group_start == group || self.rows == self.height { + let data = self.buf.get(..end).ok_or_else(|| err("invalid PNG row group"))?; + sink(u32::try_from(self.group_start).map_err(|_| err("image too large"))?, data)?; + self.group_start = self.rows; + } + if self.rows < self.height && !more() { + return Ok(None); + } + } + // Past the last row: trailing damage fails the same way it does in `decode`. + if self.reader.read_row(&mut []).map_err(map_png_err)?.is_some() { + return Err(err("the image has more rows than its header declares").into()); + } + self.begin_tail()?; + } + while !self.text.done { + self.text_batch()?; + if !self.text.done && !more() { + return Ok(None); + } + } + self.done = true; + let mut extras = self.extras(); + // As `decode_as_with` does for every format: a resolution the format lacks comes from EXIF or XMP. + if extras.meta.dpi.is_none() { + extras.meta.dpi = crate::resolution::from_metadata(extras.meta.exif.as_deref(), extras.meta.xmp.as_deref()); + } + Ok(Some(extras)) + } + + /// Finishes the pixel reader and fixes where the metadata scan ends. Tolerate malformed + /// tails once pixels are complete, but never swallow an allocation-limit failure. + fn begin_tail(&mut self) -> Result<(), CodecError> { + if let Err(e @ png::DecodingError::LimitsExceeded) = self.reader.finish() { + return Err(map_png_err(e)); + } + self.text_end = usize::try_from(self.at.load(std::sync::atomic::Ordering::Relaxed)).map_err(|_| err("invalid PNG reader position"))?; + self.text.remaining = self.limits.alloc_usize(); + self.tail = true; + Ok(()) + } + + /// Reads one bounded batch of the chunks after the pixels: up to [`TEXT_CHUNK_BATCH`] chunks, + /// or one batch of one text chunk's CRC or value while either is mid-stream. + fn text_batch(&mut self) -> Result<(), CodecError> { + let bytes = self.bytes.clone(); + let bytes = bytes.as_ref(); + if let Some(pending) = self.text.pending.take() { + match pending { + TextPending::Crc(p) => self.crc_batch(p, bytes)?, + TextPending::Value(p) => match inflate_text(p, bytes)? { + TextBatch::More(p) => self.text.pending = Some(TextPending::Value(p)), + TextBatch::Finished(p) => { + let (keyword, keyword_len, kind, text) = (p.keyword, p.keyword_len, p.kind, p.out); + self.text.accept(keyword, keyword_len, kind, text)?; + } + TextBatch::Invalid(produced) => self.text.charge(produced)?, + }, + } + return Ok(()); + } + let mut chunks = 0; + while chunks < TEXT_CHUNK_BATCH && !self.text.done { + if self.scan_text_chunk(bytes)? { + break; + } + chunks += 1; + } + Ok(()) + } + + /// Hashes one bounded batch of a text chunk's data. Only a fully CRC-valid chunk is parsed + /// into a value, so a broken chunk is still dropped before its keyword or flags are used. + fn crc_batch(&mut self, mut p: PendingCrc, bytes: &[u8]) -> Result<(), CodecError> { + let Some(left) = p.data.end.checked_sub(p.at) else { return Ok(()) }; + let take = left.min(TEXT_BATCH); + let Some(end) = p.at.checked_add(take) else { return Ok(()) }; + let Some(slice) = bytes.get(p.at..end) else { return Ok(()) }; + p.crc.update(slice); + p.at = end; + if p.at < p.data.end { + self.text.pending = Some(TextPending::Crc(p)); + return Ok(()); + } + if p.crc.sum() != p.checksum { + return Ok(()); + } + let Some(data) = bytes.get(p.data.clone()) else { return Ok(()) }; + if let Some(value) = self.pending_text(p.kind, data, p.data.start)? { + self.text.pending = Some(TextPending::Value(value)); + } + Ok(()) + } + + /// The value of a CRC-valid text chunk, with its decoded keyword and where its bytes start. + fn pending_text(&self, kind: [u8; 4], data: &[u8], data_at: usize) -> Result, CodecError> { + // A keyword is 1..=79 Latin-1 bytes, so only the start of the chunk can hold it. + let Some(nul) = data.iter().take(80).position(|&b| b == 0) else { return Ok(None) }; + let Some(keyword) = data.get(..nul) else { return Ok(None) }; + let Some(value_start) = nul.checked_add(1) else { return Ok(None) }; + let Some(value) = data.get(value_start..) else { return Ok(None) }; + if keyword.is_empty() { + return Ok(None); + } + let (value, compressed, latin1, kind) = match &kind { + b"tEXt" => (value, false, true, TextKind::Plain), + b"zTXt" => { + let Some((&0, value)) = value.split_first() else { return Ok(None) }; + (value, true, true, TextKind::Compressed) + } + _ => { + let Some(([flag, method], value)) = value.split_first_chunk::<2>() else { return Ok(None) }; + if *flag > 1 || (*flag == 1 && *method != 0) { + return Ok(None); + } + let Some((language, value)) = split_nul(value) else { return Ok(None) }; + let Some((translated, value)) = split_nul(value) else { return Ok(None) }; + if !language.is_ascii() || std::str::from_utf8(translated).is_err() { + return Ok(None); + } + (value, *flag == 1, false, TextKind::International) + } + }; + let keyword_len = latin1_len(keyword).ok_or_else(text_limit)?; + let keyword: String = keyword.iter().map(|&b| char::from(b)).collect(); + let Some(value_at) = data_at.checked_add(data.len().saturating_sub(value.len())) else { return Ok(None) }; + let Some(value_end) = value_at.checked_add(value.len()) else { return Ok(None) }; + Ok(Some(PendingText { + kind, + keyword, + keyword_len, + limit: self.text.remaining.saturating_sub(keyword_len), + input: value_at..value_end, + at: value_at, + latin1, + decoder: compressed.then(|| flate2::Decompress::new(true)), + // An uncompressed iTXt value's UTF-8 is checked batch by batch, before its budget. + utf8: (!compressed && !latin1).then(Utf8Check::default), + over: false, + out: Vec::new(), + })) + } + + /// Reads the header of the next chunk, starting a text chunk's CRC (which then reads its + /// bytes in batches) or stepping over any other. False when the scan can go on in this + /// batch; true when a text chunk was started and its bytes need their own batches. Sets + /// [`TextScan::done`] at the end of the tail, at `IEND`, or at a malformed one, as the + /// previous one-pass reader did. + fn scan_text_chunk(&mut self, bytes: &[u8]) -> Result { + let Some(rest) = bytes.get(self.text_at..self.text_end) else { + self.text.done = true; + return Ok(false); + }; + let Some((length, rest)) = rest.split_first_chunk::<4>() else { + self.text.done = true; + return Ok(false); + }; + let length = u32::from_be_bytes(*length); + // PNG chunk lengths are at most 2^31-1. Broken tails remain tolerated. + if length > i32::MAX as u32 { + self.text.done = true; + return Ok(false); + } + let Ok(length) = usize::try_from(length) else { + self.text.done = true; + return Ok(false); + }; + let Some((kind, rest)) = rest.split_first_chunk::<4>() else { + self.text.done = true; + return Ok(false); + }; + let Some((_data, rest)) = rest.split_at_checked(length) else { + self.text.done = true; + return Ok(false); + }; + let Some((checksum, _)) = rest.split_first_chunk::<4>() else { + self.text.done = true; + return Ok(false); + }; + let Some(next) = self.text_at.checked_add(12).and_then(|at| at.checked_add(length)).filter(|&at| at <= self.text_end) else { + self.text.done = true; + return Ok(false); + }; + let Some(data_at) = self.text_at.checked_add(8) else { + self.text.done = true; + return Ok(false); + }; + self.text_at = next; + if kind == b"IEND" { + self.text.done = true; + return Ok(false); + } + if !matches!(kind, b"tEXt" | b"zTXt" | b"iTXt") { + return Ok(false); + } + let mut crc = flate2::Crc::new(); + crc.update(kind); + let Some(data_end) = data_at.checked_add(length) else { + self.text.done = true; + return Ok(false); + }; + // The CRC of a large chunk is hashed a bounded batch at a time too, so one metadata + // step never scans the whole chunk. + self.text.pending = + Some(TextPending::Crc(PendingCrc { kind: *kind, data: data_at..data_end, at: data_at, crc, checksum: u32::from_be_bytes(*checksum) })); + Ok(true) + } + + /// What the file holds besides its pixels, with what the metadata scan accepted. + fn extras(&mut self) -> PngExtras { + let info = self.reader.info(); + let icc = info.icc_profile.as_ref().map(|c| c.to_vec()); + let mut meta = Metadata { exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), ..Default::default() }; + if let Some(d) = info.pixel_dims + && d.unit == png::Unit::Meter + && d.xppu > 0 + && d.yppu > 0 + { + meta.dpi = Some((dpi_of_ppm(d.xppu), dpi_of_ppm(d.yppu))); + } + meta.text = std::mem::take(&mut self.text.plain); + meta.text.extend(std::mem::take(&mut self.text.compressed)); + meta.text.extend(std::mem::take(&mut self.text.international)); + meta.xmp = self.text.xmp.take(); + let mut warnings = Vec::new(); + if self.images > 1 { + warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(self.images).ok() }); + } + let orientation = meta.exif.as_deref().map_or(1, crate::orientation::exif_orientation); + PngExtras { icc, meta, warnings, orientation } + } + + /// The image itself, with what the metadata scan accepted. + fn finish(&mut self) -> Result { + let height = u32::try_from(self.height).map_err(|_| err("image too large"))?; + let mut img = Image::from_raw(self.width, height, self.layout, self.sample, std::mem::take(&mut self.buf))?; + let extras = self.extras(); + img.icc = extras.icc; + img.meta = extras.meta; + img.warnings = extras.warnings; + Ok(img) + } +} + +/// 16-bit samples from the file's big-endian bytes to the machine's. +fn to_native_endian(samples: &mut [u8]) { + for c in samples.as_chunks_mut::<2>().0 { + let v = u16::from_be_bytes([c[0], c[1]]); + c.copy_from_slice(&v.to_ne_bytes()); + } +} + +/// What a PNG holds besides its pixels. These can sit after the image data (`eXIf`, text), so +/// they are complete only once the pixels have been read. +pub struct PngExtras { + pub icc: Option>, + pub meta: Metadata, + pub warnings: Vec, + /// The EXIF orientation (1-8) the pixels are stored in; 1 when there is none. + pub orientation: u16, +} + +/// Inspect APNG's declared images, including a separate default image, without decoding pixels. +pub(crate) fn has_additional_images(bytes: &[u8]) -> Result { + let limits = Limits::default(); + let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits { bytes: limits.alloc_usize() }); + decoder.set_ignore_text_chunk(true); + let reader = decoder.read_info().map_err(map_png_err)?; let info = reader.info(); - let mut img = Image::from_raw(out.width, out.height, layout, sample, buf)?; - img.icc = info.icc_profile.as_ref().map(|c| c.to_vec()); - let mut meta = Metadata { exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), ..Default::default() }; - if let Some(d) = info.pixel_dims - && d.unit == png::Unit::Meter - && d.xppu > 0 - && d.yppu > 0 - { - meta.dpi = Some((dpi_of_ppm(d.xppu), dpi_of_ppm(d.yppu))); + Ok(info.animation_control.is_some_and(|a| u64::from(a.num_frames) + u64::from(info.frame_control.is_none()) > 1)) +} + +/// The size and pixel format the rows of [`decode_rows`] come in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PngHeader { + pub width: u32, + pub height: u32, + pub layout: ChannelLayout, + pub sample: SampleType, +} + +/// Where [`decode_rows`] sends rows: the index of the first, then whole rows. +pub type PngRowSink<'a, E> = dyn FnMut(u32, &[u8]) -> Result<(), E> + 'a; + +/// Decodes a PNG a group of rows at a time, so the caller never needs the whole decoded image: +/// `begin` gets the header once and says whether to go on (after the same size and plausibility checks [`decode`] makes), +/// then `rows(y0, data)` gets whole rows `y0..` in final order, `group` of them at a time (the +/// last group may be shorter; a `group` of 0 counts as 1), native-endian, exactly the samples [`decode`] would return. Returns the extras once the file +/// has been read to its end, or `None` (having called neither closure) when the rows can't be +/// had in final order: an interlaced (Adam7) file, and an APNG, whose first frame may be a +/// sub-rectangle. A damaged file fails with the error [`decode`] gives, after however many rows +/// were delivered. The limits are checked before the first row; an orientation, which an `eXIf` +/// after the image data may set, is for the caller to apply. +pub(crate) fn decode_rows>( + bytes: &[u8], + limits: &Limits, + group: u32, + begin: &mut dyn FnMut(&PngHeader) -> Result, + rows: &mut PngRowSink<'_, E>, +) -> Result, E> { + // The limits and plausibility checks are made here, before any row; the output is not reserved. + let mut dec = PngDecodeRows::new_streaming(bytes, limits)?; + if !dec.streamable() || !begin(&dec.header())? { + return Ok(None); } - drop(reader); - let end = usize::try_from(input.position()).map_err(|_| err("invalid PNG reader position"))?; - let consumed = bytes.get(..end).ok_or_else(|| err("invalid PNG reader position"))?; - read_text_metadata(consumed, limits.alloc_usize(), &mut meta)?; - img.meta = meta; - if images > 1 { - img.warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(images).ok() }); - } - Ok(img) + // With a `more` that always says go on, the first call decodes the whole file. + dec.step_stream(group, &mut || true, rows) } /// The colour table of a palette PNG (`PLTE`) and the one entry its `tRNS` makes transparent. @@ -179,124 +758,265 @@ fn latin1_len(bytes: &[u8]) -> Option { bytes.len().checked_add(bytes.iter().filter(|&&b| b >= 128).count()) } -/// Read only CRC-valid text chunks, including those after IDAT, with one shared -/// budget for decoded UTF-8 keywords and values (including overwritten XMP). -/// Invalid ancillary text stays optional, as it was with png's text decoder. -fn read_text_metadata(bytes: &[u8], mut remaining: usize, meta: &mut Metadata) -> Result<(), CodecError> { - let Some(mut rest) = bytes.get(8..) else { return Ok(()) }; - let (mut plain, mut compressed, mut international) = (Vec::new(), Vec::new(), Vec::new()); - while let Some((length, tail)) = rest.split_first_chunk::<4>() { - let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break }; - let length = u32::from_be_bytes(*length); - // PNG chunk lengths are at most 2^31-1. Broken tails remain tolerated. - if length > i32::MAX as u32 { - break; - } - let Ok(length) = usize::try_from(length) else { break }; - let Some(data) = tail.get(..length) else { break }; - let Some((checksum, next)) = tail.get(length..).and_then(<[u8]>::split_first_chunk::<4>) else { break }; - rest = next; - if kind == b"IEND" { - break; - } - if !matches!(kind, b"tEXt" | b"zTXt" | b"iTXt") { - continue; - } - let mut crc = flate2::Crc::new(); - crc.update(kind); - crc.update(data); - if crc.sum() != u32::from_be_bytes(*checksum) { - continue; - } - let Some((keyword, value)) = split_nul(data) else { continue }; - if keyword.is_empty() || keyword.len() > 79 { - continue; - } - let (value, deflated, latin1) = match kind { - b"tEXt" => (value, false, true), - b"zTXt" => { - let Some((&0, value)) = value.split_first() else { continue }; - (value, true, true) - } - _ => { - let Some(([flag, method], value)) = value.split_first_chunk::<2>() else { continue }; - if *flag > 1 || (*flag == 1 && *method != 0) { - continue; - } - let Some((language, value)) = split_nul(value) else { continue }; - let Some((translated, value)) = split_nul(value) else { continue }; - if !language.is_ascii() || std::str::from_utf8(translated).is_err() { - continue; - } - (value, *flag == 1, false) - } - }; - let keyword_len = latin1_len(keyword).ok_or_else(text_limit)?; - let limit = remaining.saturating_sub(keyword_len); - let Some(text) = bounded_text(value, deflated, latin1, limit)? else { continue }; - remaining = remaining.checked_sub(keyword_len).and_then(|left| left.checked_sub(text.len())).ok_or_else(text_limit)?; - let keyword: String = keyword.iter().map(|&b| char::from(b)).collect(); +/// The text chunks after the pixels, accepted one bounded batch at a time: one shared budget for +/// decoded UTF-8 keywords and values (including overwritten XMP), the CRC and decompressor of a +/// chunk kept between batches. Invalid ancillary text stays optional, as it was with png's text +/// decoder. +#[derive(Default)] +struct TextScan { + /// UTF-8 bytes left of [`Limits::max_alloc`] for the text. + remaining: usize, + plain: Vec<(String, String)>, + compressed: Vec<(String, String)>, + international: Vec<(String, String)>, + xmp: Option, + pending: Option, + done: bool, +} + +impl TextScan { + /// Charges `produced` bytes to the shared budget: what a chunk inflated counts even when its + /// text is then dropped (bad UTF-8, a corrupt or truncated stream), or every rejected chunk + /// could inflate up to the whole remaining budget again. + fn charge(&mut self, produced: usize) -> Result<(), CodecError> { + self.remaining = self.remaining.checked_sub(produced).ok_or_else(text_limit)?; + Ok(()) + } + + /// Keeps a decoded value, charging the keyword and text to the shared budget; invalid UTF-8 + /// text is dropped, but what it inflated is still charged. + fn accept(&mut self, keyword: String, keyword_len: usize, kind: TextKind, out: Vec) -> Result<(), CodecError> { + let produced = out.len(); + let Ok(text) = String::from_utf8(out) else { return self.charge(produced) }; + self.remaining = self.remaining.checked_sub(keyword_len).and_then(|left| left.checked_sub(text.len())).ok_or_else(text_limit)?; match kind { // Some writers put XMP in Latin-1 text chunks instead of the usual iTXt. // Treat the reserved keyword consistently, after the same budget checks. - _ if keyword == XMP_KEYWORD => meta.xmp = Some(text), - b"tEXt" => plain.push((keyword, text)), - b"zTXt" => compressed.push((keyword, text)), - _ => international.push((keyword, text)), - } - } - // Preserve the previous decoder's grouping of text entries by chunk type. - meta.text.extend(plain); - meta.text.extend(compressed); - meta.text.extend(international); - Ok(()) -} - -/// No input-sized allocation until its decoded size fits. Inflation uses a -/// fixed scratch buffer and checks each batch before growing the output. -fn bounded_text(input: &[u8], compressed: bool, latin1: bool, limit: usize) -> Result, CodecError> { - let mut output = Vec::new(); - let mut append = |bytes: &[u8]| -> Result<(), CodecError> { - let added = if latin1 { latin1_len(bytes).ok_or_else(text_limit)? } else { bytes.len() }; - if output.len().checked_add(added).is_none_or(|len| len > limit) { - return Err(text_limit()); - } - output.try_reserve(added).map_err(|_| text_limit())?; - if latin1 { - // Every Latin-1 code point fits in at most two UTF-8 bytes. - let mut utf8 = [0; 2]; - for &b in bytes { - output.extend_from_slice(char::from(b).encode_utf8(&mut utf8).as_bytes()); - } - } else { - output.extend_from_slice(bytes); + _ if keyword == XMP_KEYWORD => self.xmp = Some(text), + TextKind::Plain => self.plain.push((keyword, text)), + TextKind::Compressed => self.compressed.push((keyword, text)), + TextKind::International => self.international.push((keyword, text)), } Ok(()) - }; - if compressed { - let mut decoder = flate2::Decompress::new(true); - let mut scratch = [0; 8192]; - loop { - let start_in = decoder.total_in(); - let start_out = decoder.total_out(); - let Some(tail) = usize::try_from(start_in).ok().and_then(|at| input.get(at..)) else { return Ok(None) }; - let Ok(status) = decoder.decompress(tail, &mut scratch, flate2::FlushDecompress::None) else { return Ok(None) }; - let Some(batch) = usize::try_from(decoder.total_out() - start_out).ok().and_then(|len| scratch.get(..len)) else { return Ok(None) }; - append(batch)?; - if status == flate2::Status::StreamEnd { - break; - } - if start_in == decoder.total_in() && start_out == decoder.total_out() { - return Ok(None); // Truncated stream: never keep partial text. + } +} + +/// Which list a text chunk is kept in, as the previous decoder grouped them by chunk type. +#[derive(Clone, Copy, PartialEq, Eq)] +enum TextKind { + Plain, + Compressed, + International, +} + +/// A text chunk being read: its CRC first, then (when valid) its value. +enum TextPending { + Crc(PendingCrc), + Value(PendingText), +} + +/// The CRC of a text chunk's data, hashed a bounded batch at a time before the chunk is parsed. +struct PendingCrc { + kind: [u8; 4], + /// The chunk's data in the file, and the next byte a batch hashes. + data: std::ops::Range, + at: usize, + crc: flate2::Crc, + checksum: u32, +} + +/// A text chunk's value being read, with its position in the file and (when compressed) the +/// inflator kept between batches. +struct PendingText { + kind: TextKind, + keyword: String, + keyword_len: usize, + /// The most decoded bytes this value may take: the budget left minus its keyword. + limit: usize, + /// The value's bytes in the file (the compressed ones for zTXt and compressed iTXt). + input: std::ops::Range, + /// The next byte of `input` a batch reads or inflates. + at: usize, + latin1: bool, + /// Some for a compressed value. + decoder: Option, + /// Some for an uncompressed iTXt value: its UTF-8 is checked before its bytes are charged. + utf8: Option, + /// The value has outgrown the budget but its UTF-8 validity is still being checked, so an + /// invalid value is still dropped instead of failing the decode. + over: bool, + out: Vec, +} + +/// Incremental UTF-8 validation across batches, so a value that spans batches is checked as a +/// whole before it can fail the budget. A sequence straddling two batches keeps its expected +/// continuation range here. +#[derive(Default)] +struct Utf8Check { + /// Continuation bytes still needed, and the range the next one may take. + need: u8, + min: u8, + max: u8, +} + +impl Utf8Check { + /// Feeds the next bytes of the value; false at the first one that isn't valid UTF-8. + fn feed(&mut self, bytes: &[u8]) -> bool { + for &b in bytes { + if self.need > 0 { + if b < self.min || b > self.max { + return false; + } + self.need -= 1; + (self.min, self.max) = (0x80, 0xBF); + } else { + match b { + 0x00..=0x7f => {} + 0xc2..=0xdf => { + self.need = 1; + (self.min, self.max) = (0x80, 0xBF); + } + 0xe0 => { + self.need = 2; + (self.min, self.max) = (0xa0, 0xBF); + } + 0xe1..=0xec | 0xee..=0xef => { + self.need = 2; + (self.min, self.max) = (0x80, 0xBF); + } + 0xed => { + self.need = 2; + (self.min, self.max) = (0x80, 0x9f); + } + 0xf0 => { + self.need = 3; + (self.min, self.max) = (0x90, 0xBF); + } + 0xf1..=0xf3 => { + self.need = 3; + (self.min, self.max) = (0x80, 0xBF); + } + 0xf4 => { + self.need = 3; + (self.min, self.max) = (0x80, 0x8f); + } + _ => return false, + } } } + true + } + + /// Whether the bytes so far end on a whole character. + fn complete(&self) -> bool { + self.need == 0 + } +} + +/// What one batch of a text value produced. +enum TextBatch { + /// The value needs another batch. + More(PendingText), + /// Every byte of the value was read; `out` is its UTF-8 text. + Finished(PendingText), + /// A broken chunk: drop it, as the previous reader did. + /// The bytes it inflated, which are charged to the budget even though the text is dropped. + Invalid(usize), +} + +/// How much of one text value one batch reads or inflates. +const TEXT_BATCH: usize = 8192; +/// How many chunks one batch scans when it isn't continuing a text value. +const TEXT_CHUNK_BATCH: usize = 64; + +/// Reads one batch of a text value. Inflation uses a fixed scratch buffer and checks each batch +/// against the budget before growing the output, so no input-sized allocation is made first. +fn inflate_text(mut p: PendingText, bytes: &[u8]) -> Result { + let Some(input) = bytes.get(p.input.clone()) else { return Ok(TextBatch::Invalid(p.out.len())) }; + let mut scratch = [0; TEXT_BATCH]; + if let Some(decoder) = p.decoder.as_mut() { + let Some(tail) = input.get(p.at.saturating_sub(p.input.start)..) else { return Ok(TextBatch::Invalid(p.out.len())) }; + let start_in = decoder.total_in(); + let start_out = decoder.total_out(); + let status = decoder.decompress(tail, &mut scratch, flate2::FlushDecompress::None); + let used = usize::try_from(decoder.total_in().saturating_sub(start_in)).unwrap_or(usize::MAX); + let made = usize::try_from(decoder.total_out().saturating_sub(start_out)).unwrap_or(usize::MAX); + let Ok(status) = status else { + // The backend may write a batch before it reports corruption: charge it too, Latin-1 + // text at its UTF-8 length as on the success path. + let charged = match scratch.get(..made) { + Some(batch) if p.latin1 => latin1_len(batch).unwrap_or(usize::MAX), + _ => made, + }; + return Ok(TextBatch::Invalid(p.out.len().saturating_add(charged))); + }; + let (Some(used), Some(batch)) = (p.at.checked_add(used), scratch.get(..made)) else { return Ok(TextBatch::Invalid(p.out.len())) }; + p.at = used; + if !append_checked(&mut p.out, &mut p.utf8, &mut p.over, batch, p.latin1, p.limit)? { + return Ok(TextBatch::Invalid(p.out.len())); + } + if status == flate2::Status::StreamEnd { + return Ok(TextBatch::Finished(p)); + } + if start_in == decoder.total_in() && start_out == decoder.total_out() { + return Ok(TextBatch::Invalid(p.out.len())); // Truncated stream: never keep partial text. + } } else { - if !latin1 && std::str::from_utf8(input).is_err() { - return Ok(None); + let remaining = input.len().saturating_sub(p.at.saturating_sub(p.input.start)); + let take = remaining.min(TEXT_BATCH); + let start = p.at.saturating_sub(p.input.start); + let Some(batch) = input.get(start..start.saturating_add(take)) else { return Ok(TextBatch::Invalid(0)) }; + p.at = p.at.saturating_add(take); + if !append_checked(&mut p.out, &mut p.utf8, &mut p.over, batch, p.latin1, p.limit)? { + // Stored text isn't inflated: nothing is charged for it. + return Ok(TextBatch::Invalid(0)); + } + if take == remaining { + if p.utf8.as_ref().is_some_and(|check| !check.complete()) { + return Ok(TextBatch::Invalid(0)); + } + if p.over { + return Err(text_limit()); + } + return Ok(TextBatch::Finished(p)); } - append(input)?; } - Ok(String::from_utf8(output).ok()) + Ok(TextBatch::More(p)) +} + +/// Appends one batch of decoded bytes, refusing to grow past the budget. Every Latin-1 code +/// point fits in at most two UTF-8 bytes. +/// +/// An uncompressed iTXt value is UTF-8 checked batch by batch before it is charged: an invalid +/// value is dropped even when it is over the budget, as the previous whole-value check did. +/// Its budget error is deferred until every byte has been seen and found valid. +fn append_checked(out: &mut Vec, utf8: &mut Option, over: &mut bool, bytes: &[u8], latin1: bool, limit: usize) -> Result { + if let Some(check) = utf8.as_mut() + && !check.feed(bytes) + { + return Ok(false); + } + if *over { + return Ok(true); + } + let added = if latin1 { latin1_len(bytes).ok_or_else(text_limit)? } else { bytes.len() }; + if out.len().checked_add(added).is_none_or(|len| len > limit) { + if utf8.is_some() { + *over = true; + return Ok(true); + } + return Err(text_limit()); + } + out.try_reserve(added).map_err(|_| text_limit())?; + if latin1 { + let mut buf = [0; 2]; + for &b in bytes { + out.extend_from_slice(char::from(b).encode_utf8(&mut buf).as_bytes()); + } + } else { + out.extend_from_slice(bytes); + } + Ok(true) } fn is_latin1_keyword(k: &str) -> bool { @@ -1305,3 +2025,115 @@ mod streamed_tests { assert!(rows.finish().is_err()); } } + +#[cfg(test)] +mod rows_tests { + use super::*; + + /// The pixels `decode` read before it was built on [`PngDecodeRows`]: the whole image in one + /// `next_frame` call, then the byte order of 16-bit samples. + fn one_call(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { + let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits { bytes: Limits::default().alloc_usize() }); + decoder.set_transformations(png::Transformations::EXPAND); + decoder.set_ignore_text_chunk(true); + let mut reader = decoder.read_info().map_err(map_png_err)?; + let mut buf = vec![0u8; reader.output_buffer_size().ok_or_else(|| err("image too large"))?]; + let out = reader.next_frame(&mut buf).map_err(map_png_err)?; + buf.truncate(out.buffer_size()); + if reader.output_color_type().1 == png::BitDepth::Sixteen { + to_native_endian(&mut buf); + } + Ok((out.width, out.height, buf)) + } + + fn image(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) -> Image { + let len = w as usize * h as usize * layout.channels() * sample.bytes(); + let data: Vec = (0..len).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); + Image::from_raw(w, h, layout, sample, data).unwrap() + } + + #[test] + fn rows_give_the_pixels_of_a_single_call() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::U8, SampleType::U16] { + for interlaced in [false, true] { + let img = image(33, 21, layout, sample); + let opts = EncodeOptions { png_interlaced: interlaced, ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + let (w, h, pixels) = one_call(&bytes).unwrap(); + for per_call in [1usize, 7, usize::MAX] { + let mut rows = PngDecodeRows::new(bytes.as_slice(), &Limits::default()).unwrap(); + let mut left = per_call; + let got = loop { + left -= 1; + let mut more = || { + left = left.saturating_sub(1); + left > 0 + }; + if let Some(img) = rows.step(&mut more).unwrap() { + break img; + } + left = per_call; + }; + assert_eq!((got.width(), got.height()), (w, h)); + assert_eq!(got.data(), pixels.as_slice(), "{layout:?} {sample:?} interlaced={interlaced} per_call={per_call}"); + assert_eq!(got.data(), img.data()); + } + } + } + } + } + + /// A metadata yield after the last row must not finish the pixel reader again on every call. + #[test] + fn metadata_yields_do_not_finalize_the_pixels_again() { + let text = "A".repeat(20_000); + let img = image(8, 6, ChannelLayout::Rgb, SampleType::U8).with_meta(Metadata { text: vec![("Comment".into(), text.clone())], ..Default::default() }); + let bytes = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let mut rows = PngDecodeRows::new(bytes.as_slice(), &Limits::default()).unwrap(); + let mut calls = 0; + let got = loop { + calls += 1; + if let Some(img) = rows.step(&mut || false).unwrap() { + break img; + } + assert!(calls < 10_000, "the decoder never finished"); + }; + assert!(calls > 1, "the metadata took more than the pixel step"); + assert_eq!(rows.pixel_finalizations, 1, "a metadata yield must not finalize the pixels again"); + assert_eq!(got.meta.text, vec![("Comment".into(), text)]); + } + /// A compressed iTXt chunk whose text inflates to `len` bytes of invalid UTF-8. + fn bad_itxt(len: usize) -> Vec { + use std::io::Write; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&vec![0xff; len]).unwrap(); + let mut data = b"Comment\0\x01\x00\0\0".to_vec(); + data.extend_from_slice(&z.finish().unwrap()); + let mut out = (data.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(b"iTXt"); + out.extend_from_slice(&data); + let mut crc = flate2::Crc::new(); + crc.update(b"iTXt"); + crc.update(&data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out + } + + /// A small PNG with `chunks` placed before its `IEND`. + fn png_with(chunks: &[u8]) -> Vec { + let png = crate::encode(&image(4, 4, ChannelLayout::Rgb, SampleType::U8), Format::Png, &EncodeOptions::default()).unwrap(); + let (head, iend) = png.split_at(png.len() - 12); + [head, chunks, iend].concat() + } + + #[test] + fn dropped_text_still_counts_against_the_budget() { + let limits = Limits { max_alloc: 1 << 20, ..Limits::default() }; + let decode = |bytes: &[u8]| PngDecodeRows::new(bytes, &limits)?.step(&mut || true)?.ok_or_else(|| err("unfinished")); + let one = decode(&png_with(&bad_itxt(100_000))).unwrap(); + assert!(one.meta.text.is_empty(), "invalid UTF-8 is dropped"); + // Twenty chunks inflate 2 MB between them: each is dropped, but the work is charged. + assert!(matches!(decode(&png_with(&bad_itxt(100_000).repeat(20))), Err(CodecError::LimitExceeded(_)))); + } +} diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 8b2d5d4e8f..c74ab4436a 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -171,7 +171,7 @@ mod tags; mod ycbcr; pub use decode::TiffBands; -pub(crate) use decode::{decode, decode_page, orientation}; +pub(crate) use decode::{decode, decode_page, has_unretained_samples, orientation}; pub(crate) use encode::encode; #[cfg(test)] use encode::{Extras, try_with_capacity}; diff --git a/crates/codecs/src/codecs/tiff/decode.rs b/crates/codecs/src/codecs/tiff/decode.rs index e2359e6828..d8e11c4474 100644 --- a/crates/codecs/src/codecs/tiff/decode.rs +++ b/crates/codecs/src/codecs/tiff/decode.rs @@ -26,6 +26,22 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> Ok(img) } +/// Inspect the selected directory's sample classification without decoding any pixels. +/// Mirror document import: inspect bands first, then the default whole-image route when +/// auxiliary channels are present. Preparation reads metadata, not strips or tiles. +pub(crate) fn has_unretained_samples(bytes: &[u8], offset: u64) -> Result { + let file = File::parse(bytes).ok_or_else(|| err("not a TIFF or BigTIFF header"))?; + let dir = file.ifd(offset).ok_or_else(|| err("image directory cut off"))?; + let bands = prepare(&file, &dir, &Limits::banded(), cfg!(target_pointer_width = "32"))?; + if bands.extras_over_limit > 0 || bands.extras_dropped > 0 { + return Ok(true); + } + if bands.lay.extra_at.is_empty() { + return Ok(false); + } + Ok(prepare(&file, &dir, &Limits::default(), true)?.extras_dropped > 0) +} + /// The file, page `page`'s directory (`None`: the default page) and the /// [`DecodeWarning::MorePages`] the page opens with when the file holds more. fn open_page(bytes: &[u8], page: Option) -> Result<(File<'_>, Ifd, Option), CodecError> { diff --git a/crates/codecs/src/codecs/tiff_ifd.rs b/crates/codecs/src/codecs/tiff_ifd.rs index cdaa890ec4..8fac8c6b42 100644 --- a/crates/codecs/src/codecs/tiff_ifd.rs +++ b/crates/codecs/src/codecs/tiff_ifd.rs @@ -188,7 +188,13 @@ impl<'a> File<'a> { let count = if self.big { self.u64_at(e.checked_add(4)?)? } else { u64::from(self.u32_at(e.checked_add(4)?)?) }; let field = e.checked_add(if self.big { 12 } else { 8 })?; // Unknown types are skipped, as the specification says. - let Some(size) = type_size(ty) else { continue }; + let Some(size) = type_size(ty) else { + // Keep an unreadable child declaration visible to the completeness check. + if tag == TAG_SUB_IFDS { + entries.push(Entry { tag, ty, count, value_at: field }); + } + continue; + }; let fits = count.checked_mul(size).is_some_and(|len| len <= inline); let value_at = if fits { field @@ -273,12 +279,27 @@ impl<'a> File<'a> { } /// SubIFD offsets of a directory (tag 330 as LONG, IFD, LONG8 or IFD8). - fn sub_ifds(&self, ifd: &Ifd) -> Vec { - let Some(e) = ifd.get(TAG_SUB_IFDS) else { return Vec::new() }; - if !matches!(e.ty, 4 | 13 | 16 | 18) { - return Vec::new(); + fn sub_ifds(&self, ifd: &Ifd) -> (Vec, bool) { + let mut entries = ifd.entries.iter().filter(|e| e.tag == TAG_SUB_IFDS); + let Some(e) = entries.next() else { return (Vec::new(), true) }; + let mut complete = entries.next().is_none() && e.count <= MAX_SUBIFDS; + if e.count == 0 { + return (Vec::new(), complete); } - (0..e.count.min(MAX_SUBIFDS)).map_while(|i| self.uint(e, i)).filter(|&o| o != 0).collect() + if !matches!(e.ty, 4 | 13 | 16 | 18) { + return (Vec::new(), false); + } + let mut children = Vec::new(); + for i in 0..e.count.min(MAX_SUBIFDS) { + let Some(at) = self.uint(e, i) else { + complete = false; + break; + }; + if at != 0 { + children.push(at); + } + } + (children, complete) } } @@ -338,8 +359,8 @@ pub struct TiffInfo { pub little_endian: bool, /// Main-chain directories in file order, each followed by its SubIFDs (depth first). pub pages: Vec, - /// `false` when the walk stopped early: an unreadable directory, a cycle, or more than - /// 10 000 directories. The pages listed up to that point are still valid. + /// `false` for unreadable directories or child lists, cycles, or directory/depth/count limits. + /// The pages listed up to that point are still valid. pub complete: bool, } @@ -422,10 +443,14 @@ impl Walk<'_, '_> { }; let index = self.pages.len(); self.pages.push(describe(self.f, &ifd, parent)); + let (children, complete) = self.f.sub_ifds(&ifd); + self.complete &= complete; if depth < MAX_SUB_DEPTH { - for sub in self.f.sub_ifds(&ifd) { + for sub in children { self.chain(sub, Some(index), depth + 1); } + } else if !children.is_empty() { + self.complete = false; } at = ifd.next; } diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 79b39fd2f6..7c02c614e9 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -2,7 +2,7 @@ //! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by //! [`super::tga`] and only read here. -use std::io::Cursor; +use std::io::{Cursor, Read}; use image::{DynamicImage, ImageDecoder, ImageFormat}; @@ -91,6 +91,11 @@ fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result(f: Format, bytes: &'a [u8], limits: &Limits) -> Result u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(1 << 28)), - _ => declared, - }; - if !(matches!(f, Format::Avif | Format::Hdr) || f == Format::Bmp && bmp_is_rle(bytes)) { - Limits::check_plausible(declared, bytes.len(), 1 << 14)?; + match f { + Format::Avif | Format::Hdr => {} + Format::Gif => gif_plausible(bytes, w, h, bpp)?, + // A BMP's RLE escapes (end of line, end of bitmap, delta) leave pixels unwritten, so no + // ratio bounds a valid file either. The decoder still allocates and clears the whole + // canvas, so like a GIF's screen only `UNBACKED_CANVAS` of it is free of data. + Format::Bmp if bmp_is_rle(bytes) => Limits::check_plausible(declared.saturating_sub(UNBACKED_CANVAS), bytes.len(), 1 << 14)?, + _ => Limits::check_plausible(declared, bytes.len(), 1 << 14)?, } Ok(dec) } +/// Canvas bytes a GIF's logical screen or a sparse RLE BMP may declare without data behind +/// them (256 MiB: an 8192x8192 RGBA screen). Past this, the file must back the rest. +const UNBACKED_CANVAS: u64 = 1 << 28; + +/// Whether a GIF whose `w`x`h` logical screen decodes to `bpp` bytes a pixel is plausible for +/// its file size. The first frame is what the file must hold: the logical screen around it needs +/// no data, so up to [`UNBACKED_CANVAS`] of screen is free and only the rest must be backed. +/// Shared by the first-frame decode and the animation decode ([`super::gif_frames`]), whose +/// backend also initializes the whole screen before reading the first frame. +pub(crate) fn gif_plausible(bytes: &[u8], w: u32, h: u32, bpp: u64) -> Result<(), CodecError> { + let declared = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); + let declared = match gif_first_frame(bytes) { + Some((fw, fh)) => u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(UNBACKED_CANVAS)), + None => declared, + }; + Limits::check_plausible(declared, bytes.len(), 1 << 14) +} + +/// Most bytes one embedded iCCP profile of an icon may inflate to: 255 APP2 segments of a JPEG, +/// the largest profile any of our formats carries, and far past any real one. +const MAX_ICO_ICC_BYTES: u64 = 255 * 65_519; +/// Most bytes all of an icon's embedded iCCP profiles may inflate to together while checked. +/// Entries may point into overlapping chunk chains, so this bounds the check's own work. +const MAX_ICO_ICC_TOTAL: u64 = 64 << 20; + +/// Refuses an icon whose embedded PNG images carry an iCCP profile larger than the profile cap +/// (or `max_alloc`). The `image` crate's ICO decoder opens the PNG with no limits, and the `png` +/// crate inflates the profile while the decoder is built: a few kilobytes of zlib would ask for +/// gigabytes. Each profile is inflated here into a sink, so the check itself allocates nothing. +fn ico_png_profiles_fit(b: &[u8], limits: &Limits) -> Result<(), CodecError> { + const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let u16_at = |at: usize| b.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let u32_at = |at: usize| b.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let be32 = |at: usize| b.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + let cap = MAX_ICO_ICC_BYTES.min(limits.max_alloc); + let Some(count) = u16_at(4) else { return Ok(()) }; + let mut offsets: Vec = (0..usize::from(count)).filter_map(|i| u32_at(6 + 16 * i + 12)).filter_map(|o| usize::try_from(o).ok()).collect(); + offsets.sort_unstable(); + offsets.dedup(); + let mut total = 0u64; + // Chunk offsets already walked by any start: a walk from here repeats the earlier one, so stop. + let mut visited = std::collections::HashSet::new(); + for start in offsets { + if b.get(start..start.saturating_add(8)) != Some(&PNG_SIGNATURE[..]) { + continue; + } + // Chunks up to the image data: the png crate parses iCCP only before IDAT. + let mut at = start + 8; + while let (Some(len), Some(kind)) = (be32(at), b.get(at.saturating_add(4)..at.saturating_add(8))) { + if !visited.insert(at) { + break; + } + let data_at = at + 8; + let Some(data) = usize::try_from(len).ok().and_then(|n| b.get(data_at..data_at.checked_add(n)?)) else { break }; + match kind { + b"IDAT" | b"IEND" => break, + b"iCCP" => { + // Profile name, NUL, compression method, then the zlib stream. + let Some(z) = data.iter().position(|&c| c == 0).and_then(|n| data.get(n + 2..)) else { break }; + // Count what inflates before a corrupt stream fails too: the png crate + // allocates that much before it finds the error. + let mut inflated = flate2::read::ZlibDecoder::new(z).take(cap.saturating_add(1)); + let (mut n, mut buf) = (0u64, [0u8; 8192]); + while let Ok(got @ 1..) = inflated.read(&mut buf) { + n += got as u64; + } + total = total.saturating_add(n); + if n > cap || total > MAX_ICO_ICC_TOTAL { + return Err(CodecError::LimitExceeded(format!("an icon's embedded colour profile inflates past {cap} bytes"))); + } + // The png crate refuses a second iCCP chunk without inflating it. + break; + } + _ => {} + } + at = data_at + data.len() + 4; + } + } + Ok(()) +} + pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { let mut dec = open(f, bytes, limits)?; let icc = dec.icc_profile().ok().flatten(); @@ -162,11 +246,18 @@ fn gif_first_frame(b: &[u8]) -> Option<(u32, u32)> { /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. -fn gif_more_frames(b: &[u8]) -> Option { +pub(crate) fn gif_more_frames(b: &[u8]) -> Option { let (frames, total) = gif_walk(b, |_| true); (frames > 1).then_some(DecodeWarning::MoreFrames { total }) } +/// Whether a GIF holds more than one frame, or None when the walk stops before the trailer +/// after at most one frame (so frames past the break could not be counted). +pub(crate) fn gif_has_more_frames(b: &[u8]) -> Option { + let (frames, total) = gif_walk(b, |_| true); + if frames > 1 { Some(true) } else { total.map(|_| false) } +} + /// Walks a GIF's blocks without decompressing any, handing `frame` the offset of each image /// descriptor until it returns false. Gives the descriptors seen, and the frame count if the /// blocks reached the trailer. @@ -310,6 +401,48 @@ mod indexed_tests { assert_eq!(bmp_palette_len(&bytes), None); } + /// A 64x48 icon holding one PNG with an iCCP chunk of `profile` bytes (zeros). + fn icon_with_profile(profile: usize) -> Vec { + use std::io::Write; + let img = Image::from_u8(64, 48, ChannelLayout::Rgba, vec![128; 64 * 48 * 4]).unwrap(); + let png = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&vec![0u8; profile]).unwrap(); + let mut data = b"icc\0\0".to_vec(); + data.extend_from_slice(&z.finish().unwrap()); + let mut crc = flate2::Crc::new(); + crc.update(b"iCCP"); + crc.update(&data); + let mut chunk = (data.len() as u32).to_be_bytes().to_vec(); + chunk.extend_from_slice(b"iCCP"); + chunk.extend_from_slice(&data); + chunk.extend_from_slice(&crc.sum().to_be_bytes()); + // Signature (8) and IHDR (25), then the profile. + let mut png_icc = png[..33].to_vec(); + png_icc.extend_from_slice(&chunk); + png_icc.extend_from_slice(&png[33..]); + let mut ico = vec![0, 0, 1, 0, 1, 0, 64, 48, 0, 0, 1, 0, 32, 0]; + ico.extend_from_slice(&(png_icc.len() as u32).to_le_bytes()); + ico.extend_from_slice(&22u32.to_le_bytes()); + ico.extend_from_slice(&png_icc); + ico + } + + #[test] + fn an_icon_profile_inflating_past_the_cap_is_refused_before_the_png_opens() { + let small = icon_with_profile(4096); + let img = decode(Format::Ico, &small, &Limits::default()).unwrap(); + assert_eq!(img.dimensions(), (64, 48)); + let huge = icon_with_profile(MAX_ICO_ICC_BYTES as usize + 1); + assert!(huge.len() < 64 << 10, "{} bytes", huge.len()); + for r in [decode(Format::Ico, &huge, &Limits::default()).map(|_| ()), probe(Format::Ico, &huge, &Limits::default()).map(|_| ())] { + assert!(matches!(&r, Err(CodecError::LimitExceeded(m)) if m.contains("colour profile")), "{r:?}"); + } + // max_alloc lowers the cap. + let tight = Limits { max_alloc: 2048, ..Limits::default() }; + assert!(matches!(decode(Format::Ico, &small, &tight), Err(CodecError::LimitExceeded(_)))); + } + #[test] fn palette_bmp_rejects_bad_input() { let pal = [[0, 0, 0], [255, 255, 255]]; diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index aba9e3b2f0..fb32949487 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -349,6 +349,10 @@ pub struct ExrPartInfo { pub view: Option, pub width: u32, pub height: u32, + /// Absolute data-window bounds: x, y, width and height. + pub data_window: (i32, i32, u32, u32), + /// Absolute display-window bounds: x, y, width and height. + pub display_window: (i32, i32, u32, u32), /// True for `deepscanline`/`deeptile` parts; those are composited by the deep /// decoder ([`crate::decode_deep_exr`]), not decoded part by part. pub deep: bool, @@ -656,31 +660,7 @@ impl Image { if layout == self.layout && sample == self.sample { return self.clone(); } - let data = if sample == self.sample && !self.layout.has_alpha() && layout == self.layout.with_alpha() { - // Fast path (opening any opaque file): copy the samples, append an opaque alpha. - let bps = sample.bytes(); - let src = self.layout.channels() * bps; - let one: Vec = match sample { - SampleType::U8 => vec![u8::MAX], - SampleType::U16 => u16::MAX.to_ne_bytes().to_vec(), - SampleType::F16 => f16::ONE.to_bits().to_ne_bytes().to_vec(), - SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(), - }; - let mut out = Vec::with_capacity(self.data.len() / src * (src + bps)); - for px in self.data.chunks_exact(src) { - out.extend_from_slice(px); - out.extend_from_slice(&one); - } - out - } else if let Some(fast) = self.convert_int_direct(layout, sample) { - fast - } else if layout == self.layout { - quantize(&self.to_normalized(), sample) - } else { - let src = self.to_normalized(); - let values = convert_layout(&src, self.layout, layout); - quantize(&values, sample) - }; + let data = convert_data(&self.data, self.layout, self.sample, layout, sample); let mut meta = self.meta.clone(); if sample != self.sample { // Extra samples are stored in the image's sample type, so they convert with it. A @@ -705,55 +685,6 @@ impl Image { } } - /// Integer fast path of [`Image::convert`] for U8/U16 sources and targets whose result is - /// a pure channel copy: same colour model (alpha added as opaque, or dropped) or gray to - /// RGB. Bit-identical to the generic f32 route (see the equivalence tests) but without - /// the two intermediate f32 buffers. Returns `None` for everything else. - fn convert_int_direct(&self, layout: ChannelLayout, sample: SampleType) -> Option> { - let int = |s: SampleType| matches!(s, SampleType::U8 | SampleType::U16); - if !int(self.sample) || !int(sample) { - return None; - } - let gray_to_rgb = self.layout.is_gray() && layout.is_rgb(); - if !gray_to_rgb && !layout.same_model(self.layout) { - return None; - } - let one: &[u8] = match sample { - SampleType::U8 => &[u8::MAX], - _ => &u16::MAX.to_ne_bytes(), - }; - let (fc, tc) = (self.layout.channels(), layout.channels()); - let (sb, db) = (self.sample.bytes(), sample.bytes()); - let (src_color, dst_color) = (self.layout.color_channels(), layout.color_channels()); - let n = self.data.len() / (fc * sb); - let mut out = Vec::with_capacity(n.checked_mul(tc)?.checked_mul(db)?); - macro_rules! run { - ($conv:expr) => {{ - let conv = $conv; - for px in self.data.chunks_exact(fc * sb) { - for j in 0..dst_color { - let c = if src_color == 1 { 0 } else { j }; - conv(&px[c * sb..(c + 1) * sb], &mut out); - } - if layout.has_alpha() { - if self.layout.has_alpha() { - conv(&px[src_color * sb..(src_color + 1) * sb], &mut out); - } else { - out.extend_from_slice(one); - } - } - } - }}; - } - match (self.sample, sample) { - (SampleType::U8, SampleType::U8) => run!(|s: &[u8], o: &mut Vec| o.push(s[0])), - (SampleType::U16, SampleType::U16) => run!(|s: &[u8], o: &mut Vec| o.extend_from_slice(s)), - (SampleType::U8, SampleType::U16) => run!(|s: &[u8], o: &mut Vec| o.extend_from_slice(&(u16::from(s[0]) * 257).to_ne_bytes())), - _ => run!(|s: &[u8], o: &mut Vec| o.push(((u32::from(u16::from_ne_bytes([s[0], s[1]])) + 128) / 257) as u8)), - } - Some(out) - } - /// [`Image::convert`], borrowing `self` when it already has that layout and sample type /// (encoders use it so a full-size image isn't copied for nothing). pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> { @@ -776,6 +707,98 @@ impl Image { } } +/// The samples of `data` (`from` layout and sample type) in the `layout` and `sample` type: the +/// arithmetic of [`Image::convert`], on borrowed bytes. +fn convert_data(data: &[u8], from: ChannelLayout, from_sample: SampleType, layout: ChannelLayout, sample: SampleType) -> Vec { + if layout == from && sample == from_sample { + data.to_vec() + } else if sample == from_sample && !from.has_alpha() && layout == from.with_alpha() { + // Fast path (opening any opaque file): copy the samples, append an opaque alpha. + let bps = sample.bytes(); + let src = from.channels() * bps; + let one: Vec = match sample { + SampleType::U8 => vec![u8::MAX], + SampleType::U16 => u16::MAX.to_ne_bytes().to_vec(), + SampleType::F16 => f16::ONE.to_bits().to_ne_bytes().to_vec(), + SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(), + }; + let mut out = Vec::with_capacity(data.len() / src * (src + bps)); + for px in data.chunks_exact(src) { + out.extend_from_slice(px); + out.extend_from_slice(&one); + } + out + } else if let Some(fast) = convert_int_direct(data, from, from_sample, layout, sample) { + fast + } else if layout == from { + quantize(&to_normalized(data, from_sample), sample) + } else { + let src = to_normalized(data, from_sample); + let values = convert_layout(&src, from, layout); + quantize(&values, sample) + } +} + +/// [`Image::convert`] on borrowed pixels: `rows` rows of `width` pixels in `from` (layout, sample +/// type) become the same pixels in `to`, bit for bit what the image conversion gives. Lets a +/// caller convert a band of a decoded image without copying it into an [`Image`] first. +pub fn convert_samples(src: &[u8], width: u32, rows: u32, from: (ChannelLayout, SampleType), to: (ChannelLayout, SampleType)) -> Result, CodecError> { + let expected = byte_len(width, rows, from.0, from.1).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?; + if src.len() != expected { + return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{rows} {:?} {:?}", src.len(), from.0, from.1))); + } + Ok(convert_data(src, from.0, from.1, to.0, to.1)) +} + +/// Integer fast path of [`Image::convert`] for U8/U16 sources and targets whose result is +/// a pure channel copy: same colour model (alpha added as opaque, or dropped) or gray to +/// RGB. Bit-identical to the generic f32 route (see the equivalence tests) but without +/// the two intermediate f32 buffers. Returns `None` for everything else. +fn convert_int_direct(data: &[u8], from: ChannelLayout, from_sample: SampleType, layout: ChannelLayout, sample: SampleType) -> Option> { + let int = |s: SampleType| matches!(s, SampleType::U8 | SampleType::U16); + if !int(from_sample) || !int(sample) { + return None; + } + let gray_to_rgb = from.is_gray() && layout.is_rgb(); + if !gray_to_rgb && !layout.same_model(from) { + return None; + } + let one: &[u8] = match sample { + SampleType::U8 => &[u8::MAX], + _ => &u16::MAX.to_ne_bytes(), + }; + let (fc, tc) = (from.channels(), layout.channels()); + let (sb, db) = (from_sample.bytes(), sample.bytes()); + let (src_color, dst_color) = (from.color_channels(), layout.color_channels()); + let n = data.len() / (fc * sb); + let mut out = Vec::with_capacity(n.checked_mul(tc)?.checked_mul(db)?); + macro_rules! run { + ($conv:expr) => {{ + let conv = $conv; + for px in data.chunks_exact(fc * sb) { + for j in 0..dst_color { + let c = if src_color == 1 { 0 } else { j }; + conv(&px[c * sb..(c + 1) * sb], &mut out); + } + if layout.has_alpha() { + if from.has_alpha() { + conv(&px[src_color * sb..(src_color + 1) * sb], &mut out); + } else { + out.extend_from_slice(one); + } + } + } + }}; + } + match (from_sample, sample) { + (SampleType::U8, SampleType::U8) => run!(|s: &[u8], o: &mut Vec| o.push(s[0])), + (SampleType::U16, SampleType::U16) => run!(|s: &[u8], o: &mut Vec| o.extend_from_slice(s)), + (SampleType::U8, SampleType::U16) => run!(|s: &[u8], o: &mut Vec| o.extend_from_slice(&(u16::from(s[0]) * 257).to_ne_bytes())), + _ => run!(|s: &[u8], o: &mut Vec| o.push(((u32::from(u16::from_ne_bytes([s[0], s[1]])) + 128) / 257) as u8)), + } + Some(out) +} + // --------------------------------------------------------------------------- // Sample conversion helpers // --------------------------------------------------------------------------- @@ -886,6 +909,20 @@ mod tests { use super::*; + #[test] + fn convert_samples_matches_image_convert_and_rejects_a_wrong_length() { + for sample in SampleType::ALL { + let vals: Vec = (0..5 * 2 * 3).map(|i| (i as f32 * 0.31) % 1.0).collect(); + let img = Image::from_normalized(5, 2, ChannelLayout::Rgb, sample, &vals).unwrap(); + for (layout, to) in [(ChannelLayout::Rgba, sample), (ChannelLayout::Gray, SampleType::F32), (ChannelLayout::Rgb, SampleType::U8)] { + let want = img.convert(layout, to); + let got = convert_samples(img.data(), 5, 2, (ChannelLayout::Rgb, sample), (layout, to)).unwrap(); + assert_eq!(got, want.data(), "{sample:?} to {layout:?} {to:?}"); + } + assert!(convert_samples(img.data().get(1..).unwrap_or_default(), 5, 2, (ChannelLayout::Rgb, sample), (ChannelLayout::Rgba, sample)).is_err()); + } + } + /// The generic f32 route `convert` used before the integer fast paths (the reference). fn convert_via_f32(img: &Image, layout: ChannelLayout, sample: SampleType) -> Vec { quantize(&convert_layout(&img.to_normalized(), img.layout, layout), sample) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index ec9f0995de..0664a11e0d 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -31,7 +31,10 @@ pub mod web; pub use crate::cicp::Cicp; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; -pub use crate::codecs::png::{PngPalette, PngRows, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; +pub use crate::codecs::png::{ + PngDecodeRows, PngExtras, PngHeader, PngPalette, PngRowSink, PngRows, SharedBytes, encode_indexed as encode_png_indexed, palette as png_palette, + stereo as png_stereo, +}; pub use crate::codecs::tiff::{ PhotoshopTags as TiffPhotoshopTags, TiffBands, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian, }; @@ -42,13 +45,13 @@ pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, ExtraSample, Image, - MAX_EXTRA_SAMPLES, Metadata, SampleType, + MAX_EXTRA_SAMPLES, Metadata, SampleType, convert_samples, }; pub use crate::options::{ DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TIFF_PYRAMID_MIN, TiffCompression, }; -pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; +pub use crate::orientation::{OrientSteps, exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; @@ -97,10 +100,56 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), }?; + finish_format(format, bytes, img, opts) +} + +/// What [`decode_as_with`] does with a PNG decoded in steps by [`PngDecodeRows`] (`img` is its +/// output): the resolution from the metadata, the upright orientation and the final limit check, +/// so the result equals [`decode_as_with`]'s on the same file. +pub fn finish_png_decode(img: Image, opts: &DecodeOptions) -> Result { + match begin_png_finish(img, opts)? { + PngFinish::Done(img) => Ok(*img), + PngFinish::Orient(mut steps) => loop { + if let Some(img) = steps.step(&mut || true)? { + return Ok(img); + } + }, + } +} + +/// Where [`begin_png_finish`] leaves a decoded PNG. +pub enum PngFinish { + /// Finished: it was already upright (or the options keep the orientation). + Done(Box), + /// To be turned upright, a band of rows at a time, by driving these steps. + Orient(Box), +} + +/// [`finish_png_decode`] with the turning upright (orientations 2-8) left to the caller, in +/// bands it can pace: the resolution from the metadata and the limit checks are made here, and the +/// result of the steps equals what [`finish_png_decode`] returns. +pub fn begin_png_finish(mut img: Image, opts: &DecodeOptions) -> Result { + if img.meta.dpi.is_none() { + img.meta.dpi = resolution::from_metadata(img.meta.exif.as_deref(), img.meta.xmp.as_deref()); + } + let o = img.meta.exif.as_deref().map_or(1, exif_orientation); + let l = &opts.limits; + // As `finish_decode` does: the final guard, then (turning 5-8 swaps the sides) the upright size. + l.check(img.width(), img.height(), img.layout(), img.sample_type())?; + if opts.keep_orientation || !(2..=8).contains(&o) { + return Ok(PngFinish::Done(Box::new(img))); + } + if (5..=8).contains(&o) { + l.check(img.height(), img.width(), img.layout(), img.sample_type())?; + } + Ok(PngFinish::Orient(Box::new(OrientSteps::new(img, o)?))) +} + +/// The steps after a decoder of `format` has produced `img` from `bytes`. +fn finish_format(format: Format, bytes: &[u8], mut img: Image, opts: &DecodeOptions) -> Result { // Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the // XMP or EXIF one counts, like Photoshop: a WebP, HEIF or PNG from a camera or phone // opens at its recorded resolution, not at the 72 ppi default. - let mut img = img; if img.meta.dpi.is_none() && format != Format::Tiff { img.meta.dpi = resolution::from_metadata(img.meta.exif.as_deref(), img.meta.xmp.as_deref()); } @@ -117,6 +166,26 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res finish_decode(img, o, opts) } +/// Decodes a PNG a group of rows at a time, for a caller that builds its own pixel storage and +/// never needs the whole decoded image. `begin` gets the [`PngHeader`] once and returns whether to go on, then `rows(y0, data)` +/// gets whole rows `y0..` (`group` of them, fewer in the last call; native-endian) in final order, holding exactly the +/// samples [`decode`] would return. The result is the metadata, complete once the file is read. +/// +/// `Ok(None)`, with no row delivered, means the rows can't be had in final order (an +/// interlaced or animated PNG) or `begin` returned false: use [`decode_as_with`]. Damaged files fail as [`decode_as_with`] +/// fails them, possibly after some rows. The limits are checked before the first row; the +/// orientation is not applied, so [`PngExtras::orientation`] other than 1 is for the caller to +/// handle (or to fall back to [`decode_as_with`], which turns the image upright). +pub fn decode_png_rows>( + bytes: &[u8], + opts: &DecodeOptions, + group: u32, + begin: &mut dyn FnMut(&PngHeader) -> Result, + rows: &mut PngRowSink<'_, E>, +) -> Result, E> { + png::decode_rows(bytes, &opts.limits, group, begin, rows) +} + /// The upright pixel size [`decode`] would return, read from the file's header without decoding /// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same /// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an @@ -193,6 +262,38 @@ pub fn webp_is_animated(bytes: &[u8]) -> bool { crate::codecs::webp::is_animated(bytes) } +/// Inspect declared source data which a flat decode and save cannot retain: additional frames, +/// TIFF pages, masks or unretained channels, ICO entries, DDS mip levels, slices, faces or arrays, +/// DPX elements and FITS HDU payloads. +/// Pixels are not decoded; derived TIFF thumbnails do not require preservation here. +/// Other formats return false; this does not establish preservation of other format-specific data. +pub fn has_unretained_source_data(bytes: &[u8]) -> Result { + match detect(bytes) { + Some(Format::Gif) => via_image::gif_has_more_frames(bytes).ok_or_else(|| CodecError::malformed(Format::Gif, "cannot inspect every frame")), + Some(Format::Png) => png::has_additional_images(bytes), + Some(Format::WebP) => Ok(webp_is_animated(bytes)), + Some(Format::Tiff) => { + let info = tiff_info(bytes)?; + if !info.complete { + return Err(CodecError::malformed(Format::Tiff, "cannot inspect every source image directory")); + } + let retained = info.default_page(); + if info.pages.iter().enumerate().any(|(index, page)| Some(index) != retained && page.kind != TiffPageKind::ReducedResolution) { + return Ok(true); + } + retained.and_then(|index| info.pages.get(index)).map_or(Ok(false), |page| tiff::has_unretained_samples(bytes, page.ifd_offset)) + } + Some(Format::Ico) => { + let count = bytes.get(4..6).and_then(|b| <[u8; 2]>::try_from(b).ok()).ok_or_else(|| CodecError::malformed(Format::Ico, "truncated image count"))?; + Ok(u16::from_le_bytes(count) > 1) + } + Some(Format::Dds) => dds::has_additional_images(bytes), + Some(Format::Dpx) => dpx::has_additional_images(bytes), + Some(Format::Fits) => fits::has_additional_images(bytes), + _ => Ok(false), + } +} + /// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is /// absent, malformed or out of range. pub fn tiff_orientation(bytes: &[u8]) -> u16 { diff --git a/crates/codecs/src/orientation.rs b/crates/codecs/src/orientation.rs index 7f31a6b271..5ad8be2529 100644 --- a/crates/codecs/src/orientation.rs +++ b/crates/codecs/src/orientation.rs @@ -311,6 +311,104 @@ fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usiz } } +/// Writes the oriented output rows `y0..` that fit in `dst` (whole rows of `ow` pixels), `bpp` +/// bytes per pixel: one band of [`orient_bytes`], the same pixels it writes. +fn orient_band(src: &[u8], dst: &mut [u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize, y0: usize) { + macro_rules! go { + ($n:literal) => {{ + let (s, _) = src.as_chunks::<$n>(); + let (d, _) = dst.as_chunks_mut::<$n>(); + band(s, d, o, (w, h), ow, y0) + }}; + } + match bpp { + 1 => go!(1), + 2 => go!(2), + 3 => go!(3), + 4 => go!(4), + 5 => go!(5), + 6 => go!(6), + 8 => go!(8), + 10 => go!(10), + 12 => go!(12), + 16 => go!(16), + _ => go!(20), + } +} + +/// An image turned upright a band of rows at a time, for a caller that must not block for the +/// whole image (the web build has no threads): [`OrientSteps::step`] gives the [`Image`] +/// [`Image::oriented`] gives, once every band is written. +pub struct OrientSteps { + src: Image, + o: u16, + bpp: usize, + /// The oriented size. + ow: usize, + oh: usize, + out: Vec, + /// Output rows written so far. + done: usize, +} + +impl OrientSteps { + /// Sets up turning `img` upright for orientation `o` (2–8; anything else leaves it as it is). + /// Allocates the output, which the image is turned into beside the original. + pub fn new(img: Image, o: u16) -> Result { + let (w, h) = (img.width() as usize, img.height() as usize); + let (ow, oh) = if o >= 5 { (h, w) } else { (w, h) }; + let (layout, sample) = (img.layout(), img.sample_type()); + let bpp = layout.channels() * sample.bytes(); + let turns = (2..=8).contains(&o); + if turns && (!matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0) { + return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}"))); + } + // Reserve without touching: `step` grows the buffer a band at a time, so no slice + // zero-fills the whole output. + let mut out = Vec::new(); + if turns { + let n = img.data().len(); + out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?; + } + Ok(Self { src: img, o, bpp, ow, oh, out, done: 0 }) + } + + /// How much of the image is turned, `0.0..=1.0`. + pub fn progress(&self) -> f32 { + if self.oh == 0 { 1.0 } else { self.done as f32 / self.oh as f32 } + } + + /// Writes bands of [`BAND`] output rows, at least one, then goes on while `more()` returns + /// true; `Some` with the upright image once the last band is written. + pub fn step(&mut self, more: &mut dyn FnMut() -> bool) -> Result, CodecError> { + if !(2..=8).contains(&self.o) { + let placeholder = Image::from_raw(0, 0, self.src.layout(), self.src.sample_type(), Vec::new())?; + return Ok(Some(std::mem::replace(&mut self.src, placeholder))); + } + let (w, h) = (self.src.width() as usize, self.src.height() as usize); + while self.done < self.oh { + let rows = BAND.min(self.oh - self.done); + let row_bytes = self.ow.checked_mul(self.bpp).ok_or_else(|| CodecError::InvalidImage("image too large".into()))?; + let at = self.done.saturating_mul(row_bytes); + let end = at.saturating_add(rows.saturating_mul(row_bytes)); + if self.out.len() < end { + self.out.resize(end, 0); + } + let dst = self.out.get_mut(at..end).ok_or_else(|| CodecError::InvalidImage("the oriented rows are out of range".into()))?; + orient_band(self.src.data(), dst, self.bpp, self.o, (w, h), self.ow, self.done); + self.done += rows; + if self.done < self.oh && !more() { + return Ok(None); + } + } + let (layout, sample) = (self.src.layout(), self.src.sample_type()); + let placeholder = Image::from_raw(0, 0, layout, sample, Vec::new())?; + let src = std::mem::replace(&mut self.src, placeholder); + let data = std::mem::take(&mut self.out); + src.oriented_with(self.o, data).map(Some) + } +} + impl Image { /// The image turned upright for orientation `o` (1–8; anything else is /// treated as 1 and returns the image unchanged). Width and height swap @@ -321,8 +419,7 @@ impl Image { return Ok(self); } let (w, h) = (self.width() as usize, self.height() as usize); - let swap = o >= 5; - let (ow, oh) = if swap { (h, w) } else { (w, h) }; + let ow = if o >= 5 { h } else { w }; let (layout, sample) = (self.layout(), self.sample_type()); let bpp = layout.channels() * sample.bytes(); // Every layout/sample pair is 1–20 bytes per pixel and has a fixed-size path. @@ -330,6 +427,16 @@ impl Image { return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}"))); } let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?; + self.oriented_with(o, data) + } + + /// The upright image of orientation `o` (2–8) whose turned pixels are `data`: the size, + /// metadata and extra samples of [`Image::oriented`]. + fn oriented_with(self, o: u16, data: Vec) -> Result { + let (w, h) = (self.width() as usize, self.height() as usize); + let swap = o >= 5; + let (ow, oh) = if swap { (h, w) } else { (w, h) }; + let (layout, sample) = (self.layout(), self.sample_type()); let mut meta = self.meta.clone(); // Extra samples (a TIFF's alpha and spot planes, one sample a pixel) turn with the image. // A plane of the wrong size is left as it is: importers drop it with a warning. diff --git a/crates/codecs/tests/it/dds.rs b/crates/codecs/tests/it/dds.rs index 1f4091d430..7612e9b87c 100644 --- a/crates/codecs/tests/it/dds.rs +++ b/crates/codecs/tests/it/dds.rs @@ -322,6 +322,46 @@ fn refuses_cubemaps_arrays_and_volumes() { unsupported(&compressed(4, 4, b"DXT2"), "premultiplied"); } +/// Face declarations matter even when the general cube flag is absent; padding does not. +#[test] +fn source_probe_distinguishes_extra_faces_from_single_images_and_padding() { + for caps in [0u32, 0x400, 0xc00, 0xfc00] { + let mut b = compressed(4, 4, b"DXT1"); + b[112..116].copy_from_slice(&caps.to_le_bytes()); + b.extend(bc1_block(RED, RED, [0; 4])); + for _ in 1..caps.count_ones() { + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + } + assert!(decode(&b).unwrap().data().chunks(4).all(|p| p == [255, 0, 0, 255])); + assert_eq!(photocraft_codecs::has_unretained_source_data(&b).unwrap(), caps.count_ones() > 1); + } + // A FourCC-looking unused union and trailing bytes are not a DX10 declaration. + let mut single = header(1, 1, DDPF_RGB, b"DX10", 24, [0xff, 0xff00, 0xff_0000, 0]); + single.extend([1, 2, 3]); + single.extend([0xff; 64]); + assert_eq!(decode(&single).unwrap().data(), &[1, 2, 3]); + for mips in [0u32, 1] { + single[28..32].copy_from_slice(&mips.to_le_bytes()); + assert!(!photocraft_codecs::has_unretained_source_data(&single).unwrap()); + } +} + +/// The metadata probe follows the same DX10 discriminator as preview decoding. +#[test] +fn source_probe_reads_dx10_array_and_cube_declarations() { + for (array, misc) in [(0, 0), (1, 0), (2, 0), (1, 4)] { + let mut b = dx10(1, 1, 28, 3, misc, array); + b.extend([1, 2, 3, 4]); + let multiple = array > 1 || misc == 4; + assert_eq!(photocraft_codecs::has_unretained_source_data(&b).unwrap(), multiple); + if multiple { + assert!(matches!(decode(&b), Err(CodecError::Unsupported { .. }))); + } else { + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + } + } +} + #[test] fn rejects_truncated_files_at_every_length_without_panicking() { let mut b = dx10(5, 3, 77, 3, 0, 1); // BC3_UNORM: two 16-byte blocks diff --git a/crates/codecs/tests/it/decode_warnings.rs b/crates/codecs/tests/it/decode_warnings.rs index 82bf75b57d..57af48c838 100644 --- a/crates/codecs/tests/it/decode_warnings.rs +++ b/crates/codecs/tests/it/decode_warnings.rs @@ -307,3 +307,205 @@ fn warnings_survive_orientation() { } assert_eq!(img.convert(ChannelLayout::Rgba, SampleType::U16).warnings, img.warnings); } + +/// A flagless depth still declares slices the flat reader and writer cannot retain. +#[test] +fn dds_depth_probe_refuses_unretained_slices_without_volume_flags() { + let single = encode(&solid(COLOURS[0]), Format::Dds, &Default::default()).unwrap(); + let expected = decode(&single).unwrap(); + let mut second = single[128..].to_vec(); + second[0] ^= 0xff; + for depth in [0u32, 1, 2, 3] { + let mut bytes = single.clone(); + bytes[24..28].copy_from_slice(&depth.to_le_bytes()); + for _ in 1..depth { + bytes.extend_from_slice(&second); + } + let flags = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let caps = u32::from_le_bytes(bytes[112..116].try_into().unwrap()); + assert_eq!(flags & 0x0080_0000, 0); + assert_eq!(caps & 0x0020_0000, 0); + // Decoding remains permissive for preview; only source replacement is refused. + assert_eq!(decode(&bytes).unwrap().data(), expected.data()); + assert_eq!(has_unretained_source_data(&bytes).unwrap(), depth > 1); + } +} + +/// Header-only inspection protects unretained source payloads while allowing stills and thumbnails. +#[test] +fn additional_images_probe_uses_container_structure_without_decoding_pixels() { + use photocraft_codecs::has_unretained_source_data; + let frames: Vec<_> = COLOURS.iter().take(2).map(|&c| solid(c)).collect(); + for (bytes, multiple) in [ + (gif(1), false), + (gif(2), true), + (apng(0, false, false), false), + (apng(1, false, false), false), + (apng(1, false, true), true), + (apng(2, true, false), true), + (animated_webp(&frames[..1]), false), + (animated_webp(&frames), true), + (tiff(&[(0, SampleType::U8)]), false), + (tiff(&[(0, SampleType::U8), (2, SampleType::U8)]), true), + (tiff(&[(0, SampleType::U8), (1, SampleType::U8)]), false), + (tiff(&[(4, SampleType::U8)]), false), + (tiff(&[(0, SampleType::U8), (4, SampleType::U8)]), true), + (tiff(&[(4, SampleType::U8), (0, SampleType::U8)]), true), + (tiff(&[(0, SampleType::U8), (1, SampleType::U8), (4, SampleType::U8)]), true), + ] { + assert_eq!(has_unretained_source_data(&bytes).unwrap(), multiple); + } + // A GIF whose blocks break off before the trailer after one frame may hide later frames: + // the probe refuses it instead of reporting a still. + let one = gif(1); + assert!(has_unretained_source_data(&one[..one.len() - 1]).is_err(), "trailer cut off"); + let two = gif(2); + let mut broken = gif(1); + broken.pop(); + broken.push(0x99); + broken.extend_from_slice(&two[two.len() - 10..]); + assert!(has_unretained_source_data(&broken).is_err(), "junk block after the first frame"); + assert!(has_unretained_source_data(&two[..two.len() - 1]).unwrap(), "two frames seen, trailer cut off"); + let image = solid(COLOURS[0]); + for mipmaps in [false, true] { + let mut options = EncodeOptions::default(); + options.dds.mipmaps = mipmaps; + let bytes = encode(&image, Format::Dds, &options).unwrap(); + assert_eq!(has_unretained_source_data(&bytes).unwrap(), mipmaps); + if mipmaps { + let mut flagless = bytes; + let flags = u32::from_le_bytes(flagless[8..12].try_into().unwrap()); + flagless[8..12].copy_from_slice(&(flags & !0x0002_0000).to_le_bytes()); + decode(&flagless).unwrap(); + // Microsoft DDS_HEADER: readers cannot rely on DDSD_MIPMAPCOUNT being set. + assert!(has_unretained_source_data(&flagless).unwrap()); + } + } + let dpx = encode(&image, Format::Dpx, &Default::default()).unwrap(); + for little in [false, true] { + let mut bytes = dpx.clone(); + if little { + bytes[..4].copy_from_slice(b"XPDS"); + for at in [4, 772, 776, 780, 808, 812] { + bytes[at..at + 4].reverse(); + } + for at in [768, 770, 804, 806] { + bytes[at..at + 2].reverse(); + } + } + assert!(!has_unretained_source_data(&bytes).unwrap()); + for count in [2u16, 8] { + bytes[770..772].copy_from_slice(&if little { count.to_le_bytes() } else { count.to_be_bytes() }); + assert!(has_unretained_source_data(&bytes).unwrap()); + } + bytes[770..772].fill(0); + assert!(has_unretained_source_data(&bytes).is_err()); + assert!(has_unretained_source_data(&bytes[..771]).is_err()); + } + let one = encode(&image, Format::Ico, &Default::default()).unwrap(); + assert!(!has_unretained_source_data(&one).unwrap()); + let mut multiple = one[..6].to_vec(); + multiple[4..6].copy_from_slice(&2u16.to_le_bytes()); + let mut entry = one[6..22].to_vec(); + let offset = u32::from_le_bytes(entry[12..16].try_into().unwrap()) + 16; + entry[12..16].copy_from_slice(&offset.to_le_bytes()); + multiple.extend_from_slice(&entry); + multiple.extend_from_slice(&entry); + multiple.extend_from_slice(&one[22..]); + decode(&multiple).unwrap(); + assert!(has_unretained_source_data(&multiple).unwrap()); + assert!(has_unretained_source_data(b"\x89PNG\r\n\x1a\n").is_err()); +} + +/// FITS RGB planes form one image; other cubes and later image HDUs would be discarded. +#[test] +fn fits_additional_images_probe_distinguishes_color_planes_and_hdus() { + let hdu = |primary: bool, axes: &[usize]| { + let mut cards = vec![ + if primary { "SIMPLE = T".to_string() } else { "XTENSION= 'IMAGE'".to_string() }, + "BITPIX = 8".into(), + format!("NAXIS = {}", axes.len() + 2), + "NAXIS1 = 1".into(), + "NAXIS2 = 1".into(), + ]; + cards.extend(axes.iter().enumerate().map(|(index, n)| format!("NAXIS{} = {n}", index + 3))); + cards.push("END".into()); + let mut bytes = Vec::new(); + for card in cards.drain(..) { + bytes.extend_from_slice(format!("{card:<80}").as_bytes()); + } + bytes.resize(bytes.len().div_ceil(2880) * 2880, b' '); + bytes.extend((0..axes.iter().product::()).map(|i| i as u8 + 1)); + bytes.resize(bytes.len().div_ceil(2880) * 2880, 0); + bytes + }; + for (axes, multiple) in [(vec![1], false), (vec![2], true), (vec![3], false), (vec![4], true), (vec![1, 3], true), (vec![3, 1], false)] { + let bytes = hdu(true, &axes); + let image = decode(&bytes).unwrap(); + assert_eq!(image.warnings.iter().any(|w| matches!(w, DecodeWarning::MorePages { .. })), multiple); + assert_eq!(has_unretained_source_data(&bytes).unwrap(), multiple); + } + let mut bytes = hdu(true, &[1]); + bytes.extend(hdu(false, &[1])); + decode(&bytes).unwrap(); + assert!(has_unretained_source_data(&bytes).unwrap()); + let mut incomplete = hdu(true, &[1]); + incomplete.extend_from_slice(b"XTENSION= 'IMAGE'"); + assert!(has_unretained_source_data(&incomplete).is_err()); +} + +/// Tables and extension heaps are source data even though the flat image decoder skips them. +#[test] +fn fits_source_probe_preserves_nonimage_payload_and_allows_empty_extensions() { + let primary = encode(&solid(COLOURS[0]), Format::Fits, &Default::default()).unwrap(); + for (kind, rows, heap, unretained) in + [("BINTABLE", 1, 0, true), ("TABLE", 1, 0, true), ("OTHER", 1, 0, true), ("BINTABLE", 0, 4, true), ("BINTABLE", 0, 0, false)] + { + let mut bytes = primary.clone(); + for card in [ + format!("XTENSION= '{kind}'"), + "BITPIX = 8".into(), + "NAXIS = 2".into(), + "NAXIS1 = 4".into(), + format!("NAXIS2 = {rows}"), + format!("PCOUNT = {heap}"), + "GCOUNT = 1".into(), + "TFIELDS = 1".into(), + "TTYPE1 = 'DATA'".into(), + "TFORM1 = '4B'".into(), + "END".into(), + ] { + bytes.extend_from_slice(format!("{card:<80}").as_bytes()); + } + bytes.resize(bytes.len().div_ceil(2880) * 2880, b' '); + bytes.extend(std::iter::repeat_n(0xa5, rows * 4 + heap)); + bytes.resize(bytes.len().div_ceil(2880) * 2880, 0); + assert_eq!(decode(&bytes).unwrap().data(), decode(&primary).unwrap().data()); + assert_eq!(has_unretained_source_data(&bytes).unwrap(), unretained, "{kind} rows={rows} heap={heap}"); + } + let mut heap = primary.clone(); + let end = heap.as_chunks::<80>().0.iter().position(|c| c.starts_with(b"END ")).unwrap() * 80; + heap[end..end + 160].fill(b' '); + heap[end..end + 11].copy_from_slice(b"PCOUNT = 1"); + heap[end + 80..end + 83].copy_from_slice(b"END"); + heap[2880 + (W * H * 3) as usize] = 0xa5; + decode(&heap).unwrap(); + assert!(has_unretained_source_data(&heap).unwrap()); + for data in [false, true] { + let mut bytes = Vec::new(); + for card in ["SIMPLE = T", "BITPIX = 8", if data { "NAXIS = 1" } else { "NAXIS = 0" }, "NAXIS1 = 2", "END"] { + bytes.extend_from_slice(format!("{card:<80}").as_bytes()); + } + bytes.resize(2880, b' '); + if data { + bytes.extend_from_slice(&[0x11, 0x22]); + bytes.resize(5760, 0); + } + let mut image = primary.clone(); + image[..80].fill(b' '); + image[..17].copy_from_slice(b"XTENSION= 'IMAGE'"); + bytes.extend(image); + decode(&bytes).unwrap(); + assert_eq!(has_unretained_source_data(&bytes).unwrap(), data, "empty primary versus skipped one-dimensional payload"); + } +} diff --git a/crates/codecs/tests/it/limits.rs b/crates/codecs/tests/it/limits.rs index 536e303039..b6cfea5e69 100644 --- a/crates/codecs/tests/it/limits.rs +++ b/crates/codecs/tests/it/limits.rs @@ -564,6 +564,36 @@ fn an_rle_bmp_ending_early_still_decodes() { assert_eq!((img.width(), img.height()), (4000, 4000)); } +#[test] +fn an_rle_bmp_canvas_past_the_unbacked_allowance_needs_data() { + // 30000x30000 RLE8 with only the end-of-bitmap escape: 2.7 GB of RGB from about 1 KB. + let data = 14 + 40 + 1024; + let mut b = b"BM".to_vec(); + for v in [data + 2, 0, data, 40, 30000, 30000] { + b.extend_from_slice(&(v as u32).to_le_bytes()); + } + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&8u16.to_le_bytes()); + for v in [1, 2, 0, 0, 0, 0] { + b.extend_from_slice(&(v as u32).to_le_bytes()); + } + b.resize(data, 0); + b.extend_from_slice(&[0, 1]); + refused_as_implausible(Format::Bmp, &b); +} + +#[test] +fn animated_gif_frames_need_a_plausible_file_size_too() { + let frames = |b: &[u8]| decode_gif_frames(b, &DecodeOptions::default()); + assert_eq!(frames(&tiny_gif((4000, 4000), (1, 1))).unwrap().len(), 1); + for gif in [tiny_gif((30000, 30000), (1, 1)), tiny_gif((100, 100), (40000, 40000))] { + match frames(&gif) { + Err(CodecError::LimitExceeded(m)) => assert!(m.contains("cannot hold"), "{m}"), + other => panic!("expected an implausible-size refusal, got {:?}", other.map(|f| f.len())), + } + } +} + #[test] fn pcx_header_declaring_gigabytes_needs_a_plausible_file_size() { // 24-bit (three 8-bit planes) 65535 x 16384, with one row of data. diff --git a/crates/codecs/tests/it/orientation.rs b/crates/codecs/tests/it/orientation.rs index 4bb7ca7662..08150ebeaa 100644 --- a/crates/codecs/tests/it/orientation.rs +++ b/crates/codecs/tests/it/orientation.rs @@ -204,6 +204,9 @@ fn png_opens_upright_for_every_orientation() { assert_eq!(img.dimensions(), (w as u32, h as u32), "o={o}"); assert_eq!(img.data(), &up[..], "o={o}"); assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1, "o={o}: metadata says upright"); + // Decoded in steps and finished by `finish_png_decode`, it is the same image. + let stepped = PngDecodeRows::new(file.as_slice(), &Limits::default()).unwrap().step(&mut || true).unwrap().unwrap(); + assert_eq!(finish_png_decode(stepped, &DecodeOptions::default()).unwrap(), img, "o={o}: stepped"); // Opting out keeps the stored pixels and the tag. let raw = decode_with(&file, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap(); assert_eq!((raw.dimensions(), raw.data()), ((sw as u32, sh as u32), &s[..]), "o={o}"); @@ -211,6 +214,76 @@ fn png_opens_upright_for_every_orientation() { } } +/// An image turned upright a band of rows at a time is the image `oriented` gives, for every +/// orientation, pixel size and shape, and takes a call per band. +#[test] +fn orient_steps_equal_oriented() { + for o in 1..=8u16 { + for (layout, sample) in [ + (ChannelLayout::Gray, SampleType::U8), + (ChannelLayout::GrayA, SampleType::U16), + (ChannelLayout::Rgb, SampleType::U8), + (ChannelLayout::Rgba, SampleType::U16), + (ChannelLayout::Rgba, SampleType::F32), + ] { + for (w, h) in [(1u32, 1u32), (7, 5), (130, 66), (65, 200)] { + let len = w as usize * h as usize * layout.channels() * sample.bytes(); + let data: Vec = (0..len).map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8).collect(); + let mut img = Image::from_raw(w, h, layout, sample, data).unwrap(); + img.meta.dpi = Some((100.0, 200.0)); + img.meta.exif = Some(exif(u32::from(o), false, false)); + let want = img.clone().oriented(o).unwrap(); + let mut steps = OrientSteps::new(img, o).unwrap(); + let mut calls = 0; + let got = loop { + calls += 1; + assert!(steps.progress() <= 1.0); + if let Some(img) = steps.step(&mut || false).unwrap() { + break img; + } + }; + assert_eq!(got, want, "o={o} {layout:?} {sample:?} {w}x{h}"); + let rows = if o >= 5 { w } else { h } as usize; + let expected = if (2..=8).contains(&o) { rows.div_ceil(64) } else { 1 }; + assert_eq!(calls, expected, "o={o} {w}x{h}: one call per band of 64 rows"); + } + } + } +} + +/// A PNG turned upright in bands by `begin_png_finish` is the image `decode` gives, for every +/// orientation, and a band of a tall image is not the whole of it. +#[test] +fn png_finishes_upright_in_bands_for_every_orientation() { + let (w, h) = (70, 150); + let up = upright(w, h, |x, y| (x * 7 + y * 13) as u8); + for o in 1..=8u16 { + let (s, sw, sh) = stored(&up, w, h, o); + let png = encode(&gray(sw, sh, s), Format::Png, &EncodeOptions::default()).unwrap(); + let file = png_with_exif(&png, &exif(u32::from(o), o % 2 == 0, false)); + let want = decode(&file).unwrap(); + let img = PngDecodeRows::new(file.as_slice(), &Limits::default()).unwrap().step(&mut || true).unwrap().unwrap(); + let got = match begin_png_finish(img, &DecodeOptions::default()).unwrap() { + PngFinish::Done(img) => { + assert_eq!(o, 1); + *img + } + PngFinish::Orient(mut steps) => { + assert!(o > 1); + let mut calls = 0; + loop { + calls += 1; + if let Some(img) = steps.step(&mut || false).unwrap() { + assert!(calls > 1, "o={o}: more than one band"); + break img; + } + } + } + }; + assert_eq!(got, want, "o={o}"); + } +} + /// Extra samples (a TIFF's alpha and spot planes) turn with the image, so a channel stays /// aligned with the pixels for every orientation. #[test] diff --git a/crates/codecs/tests/it/png_jpeg.rs b/crates/codecs/tests/it/png_jpeg.rs index 0b8b42a7b7..aedf949605 100644 --- a/crates/codecs/tests/it/png_jpeg.rs +++ b/crates/codecs/tests/it/png_jpeg.rs @@ -195,6 +195,279 @@ fn tiff_f32_output_matches_oracle() { assert_eq!(o, img.to_f32_samples().unwrap()); } +// --------------------------------------------------------------------------- +// PNG decoded in steps (PngDecodeRows) +// --------------------------------------------------------------------------- + +/// Decodes `bytes` with [`PngDecodeRows`], going on for `per_call` rows per call (0: unlimited); +/// returns the image and how many calls it took. +fn decode_in_steps(bytes: &[u8], per_call: usize) -> Result<(Image, usize), CodecError> { + let mut rows = PngDecodeRows::new(bytes, &Limits::default())?; + let mut calls = 0; + loop { + calls += 1; + let mut left = per_call; + let mut more = || { + left = left.wrapping_sub(1); + per_call == 0 || left > 0 + }; + if let Some(img) = rows.step(&mut more)? { + return Ok((img, calls)); + } + assert!(calls < 100_000, "the decoder never finished"); + } +} + +fn assert_steps_equal_decode(bytes: &[u8], what: &str) { + let whole = decode(bytes); + for per_call in [1, 7, 0] { + // `decode` also takes the resolution from EXIF or XMP and turns the image upright; + // `finish_png_decode` does that for a stepped decode, as the stepped import does. + let stepped = decode_in_steps(bytes, per_call).and_then(|(img, calls)| Ok((finish_png_decode(img, &DecodeOptions::default())?, calls))); + match (&whole, stepped) { + (Ok(a), Ok((b, _))) => assert_eq!(a, &b, "{what}, {per_call} rows per call"), + (Err(_), Err(_)) => {} + (a, b) => panic!("{what}, {per_call} rows per call: decode {:?}, steps {:?}", a.as_ref().map(|_| ()), b.map(|_| ())), + } + } +} + +#[test] +fn png_rows_equal_decode_in_every_layout_and_depth() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::U8, SampleType::U16] { + let mut img = synth(41, 29, layout, sample, 77, 0.3).with_icc(Some(sample_icc(400))); + img.meta.text.push(("Comment".into(), "stepped".into())); + img.meta.dpi = Some((300.0, 300.0)); + for interlaced in [false, true] { + let bytes = encode(&img, Format::Png, &EncodeOptions { png_interlaced: interlaced, ..Default::default() }).unwrap(); + assert_steps_equal_decode(&bytes, &format!("{layout:?} {sample:?} interlaced={interlaced}")); + } + } + } +} + +#[test] +fn png_rows_equal_decode_for_palette_and_transparency() { + let pal = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [9, 9, 9]]; + let idx: Vec = (0..50 * 20).map(|i| (i % 4) as u8).collect(); + let bytes = encode_png_indexed(50, 20, &idx, &pal, Some(3), None, None).unwrap(); + assert_steps_equal_decode(&bytes, "palette with a transparent entry"); +} + +#[test] +fn png_rows_take_one_call_per_row_and_report_progress() { + let bytes = encode(&synth(16, 40, ChannelLayout::Rgb, SampleType::U8, 5, 0.2), Format::Png, &EncodeOptions::default()).unwrap(); + let mut rows = PngDecodeRows::new(bytes.as_slice(), &Limits::default()).unwrap(); + assert_eq!(rows.size(), (16, 40)); + assert_eq!(rows.progress(), 0.0); + let mut seen = Vec::new(); + let img = loop { + if let Some(img) = rows.step(&mut || false).unwrap() { + break img; + } + seen.push(rows.progress()); + }; + assert_eq!(seen.len(), 39, "one call per row but the last, which also finishes the file"); + assert!(seen.windows(2).all(|w| w[0] < w[1])); + assert_eq!(rows.progress(), 1.0); + assert_eq!(img, decode(&bytes).unwrap()); + // A decoder that has finished says so instead of decoding again. + assert!(rows.step(&mut || true).is_err()); +} + +#[test] +fn png_rows_share_their_bytes_without_copying_them() { + let bytes = std::sync::Arc::new(encode(&synth(20, 20, ChannelLayout::Rgba, SampleType::U8, 3, 0.2), Format::Png, &EncodeOptions::default()).unwrap()); + let mut rows = PngDecodeRows::new(SharedBytes(bytes.clone()), &Limits::default()).unwrap(); + let img = rows.step(&mut || true).unwrap().unwrap(); + assert_eq!(img, decode(&bytes).unwrap()); + drop(rows); + assert_eq!(std::sync::Arc::strong_count(&bytes), 1, "the decoder let go of the bytes"); +} + +/// A CRC-valid PNG chunk. +fn png_chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + let mut out = u32::try_from(data.len()).unwrap().to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out +} + +fn zlib(text: &[u8]) -> Vec { + use std::io::Write; + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(text).unwrap(); + encoder.finish().unwrap() +} + +/// A `zTXt` chunk (`deflated`) or a compressed `iTXt` one (`!deflated`) with `text` as its value. +fn png_text_chunk(kind: &[u8; 4], keyword: &[u8], text: &[u8]) -> Vec { + let mut data = keyword.to_vec(); + data.push(0); + if kind == b"zTXt" { + data.push(0); + } else { + data.extend_from_slice(&[1, 0, 0, 0]); + } + data.extend_from_slice(&zlib(text)); + png_chunk(kind, &data) +} + +/// `png` with `chunks` between the image data and `IEND` (the text-after-IDAT case). +fn with_chunks_before_iend(png: &[u8], chunks: &[Vec]) -> Vec { + let mut at = 8; + loop { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + if &png[at + 4..at + 8] == b"IEND" { + let mut out = png[..at].to_vec(); + for c in chunks { + out.extend_from_slice(c); + } + out.extend_from_slice(&png[at..]); + return out; + } + at += len + 12; + } +} + +#[test] +fn png_rows_read_compressed_text_in_bounded_batches() { + let img = synth(19, 23, ChannelLayout::Rgba, SampleType::U8, 9, 0.4); + let base = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let text = "A".repeat(150_000); + for kind in [b"zTXt", b"iTXt"] { + let bytes = with_chunks_before_iend(&base, &[png_text_chunk(kind, b"Comment", text.as_bytes())]); + let mut rows = PngDecodeRows::new(bytes.as_slice(), &Limits::default()).unwrap(); + let mut calls = 0; + // An empty budget: every step reads one bounded batch and stops. + let stepped = loop { + calls += 1; + if let Some(img) = rows.step(&mut || false).unwrap() { + break img; + } + assert!(calls < 100_000, "the decoder never finished"); + }; + assert_eq!(stepped, decode(&bytes).unwrap(), "{kind:?}"); + assert_eq!(stepped.meta.text, vec![("Comment".into(), text.clone())], "{kind:?}: the whole value is kept"); + assert!(calls > 20, "{kind:?}: {calls} bounded calls, not one blocking pass"); + assert_steps_equal_decode(&bytes, &format!("compressed {kind:?} text")); + } +} + +/// An Adam7 image whose compressed text needs many metadata yields: the pixels are decoded +/// once, before the first yield, and later steps only read text. +#[test] +fn png_rows_finish_adam7_text_in_bounded_batches() { + let img = synth(19, 23, ChannelLayout::Rgba, SampleType::U8, 9, 0.4); + let base = encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap(); + let text = "A".repeat(150_000); + for kind in [b"zTXt", b"iTXt"] { + let bytes = with_chunks_before_iend(&base, &[png_text_chunk(kind, b"Comment", text.as_bytes())]); + let mut rows = PngDecodeRows::new(bytes.as_slice(), &Limits::default()).unwrap(); + let mut calls = 0; + let stepped = loop { + calls += 1; + if let Some(img) = rows.step(&mut || false).unwrap() { + break img; + } + assert!(calls < 100_000, "the decoder never finished"); + }; + assert_eq!(stepped, decode(&bytes).unwrap(), "{kind:?}"); + assert_eq!(stepped.meta.text, vec![("Comment".into(), text.clone())], "{kind:?}: the whole value is kept"); + assert!(calls > 20, "{kind:?}: {calls} bounded calls after the one Adam7 pass"); + } +} + +/// An uncompressed `iTXt` chunk (`value` is its text, so its CRC covers the whole value). +fn png_itxt_uncompressed(keyword: &[u8], value: &[u8]) -> Vec { + let mut data = keyword.to_vec(); + data.push(0); + data.extend_from_slice(&[0, 0, 0, 0]); + data.extend_from_slice(value); + png_chunk(b"iTXt", &data) +} + +/// The CRC of a large text chunk takes bounded batches too, before any of its value is read. +#[test] +fn png_rows_read_a_large_text_crc_in_bounded_batches() { + let img = synth(5, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.2); + let base = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let text: String = (0..300_000u32).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let bytes = with_chunks_before_iend(&base, &[png_itxt_uncompressed(b"Comment", text.as_bytes())]); + let mut rows = PngDecodeRows::new(bytes.as_slice(), &Limits::default()).unwrap(); + let mut calls = 0; + let stepped = loop { + calls += 1; + if let Some(img) = rows.step(&mut || false).unwrap() { + break img; + } + assert!(calls < 100_000, "the decoder never finished"); + }; + assert_eq!(stepped.meta.text, vec![("Comment".into(), text)]); + assert!(calls > 60, "{calls} calls: the CRC and the value each take bounded batches"); +} + +/// A text chunk whose CRC does not match is still dropped after its bounded CRC batches. +#[test] +fn png_rows_drop_a_corrupt_text_crc() { + let img = synth(5, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.2); + let base = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let mut chunk = png_itxt_uncompressed(b"Comment", b"not accepted"); + let at = chunk.len() - 5; + chunk[at] ^= 0xFF; + let bytes = with_chunks_before_iend(&base, &[chunk]); + let stepped = decode_in_steps(&bytes, 0).unwrap().0; + assert!(stepped.meta.text.is_empty(), "a broken CRC drops the text"); + assert_eq!(stepped, decode(&bytes).unwrap(), "the one-pass decode drops it too"); +} + +/// An uncompressed iTXt value that is not UTF-8 is dropped without charging its bytes against +/// the budget, even when it is larger than all of it: the previous reader checked UTF-8 first. +#[test] +fn png_rows_drop_invalid_uncompressed_text_before_charging_the_budget() { + let img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 3, 0.2); + let base = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let mut value = vec![b'A'; 12_000]; + value[11_999] = 0xFF; + let bytes = with_chunks_before_iend(&base, &[png_itxt_uncompressed(b"Comment", &value)]); + let opts = DecodeOptions { limits: Limits { max_alloc: 200, ..Default::default() }, ..Default::default() }; + let mut rows = PngDecodeRows::new(bytes.as_slice(), &opts.limits).unwrap(); + let stepped = rows.step(&mut || true).unwrap().unwrap(); + let stepped = finish_png_decode(stepped, &opts).unwrap(); + assert!(stepped.meta.text.is_empty(), "invalid UTF-8 is skipped, not a budget failure"); + let whole = decode_with(&bytes, &opts).unwrap(); + assert!(whole.meta.text.is_empty(), "the one-pass decode skips it too"); +} + +#[test] +fn png_rows_fail_like_decode_on_damaged_files() { + let img = synth(64, 64, ChannelLayout::Rgb, SampleType::U8, 11, 1.0); + for interlaced in [false, true] { + let bytes = encode(&img, Format::Png, &EncodeOptions { png_interlaced: interlaced, ..Default::default() }).unwrap(); + // Cuts inside the signature, the IHDR chunk or the next chunk header leave no complete image. + for cut in [8, 20, 40] { + let truncated = bytes.get(..cut).unwrap(); + assert!(decode(truncated).is_err(), "cut at {cut} interlaced={interlaced}"); + assert!(decode_in_steps(truncated, 1).is_err(), "cut at {cut} interlaced={interlaced}"); + } + for cut in [bytes.len() / 3, bytes.len() / 2, bytes.len() - 20] { + assert_steps_equal_decode(bytes.get(..cut).unwrap(), &format!("cut at {cut} interlaced={interlaced}")); + } + // A flipped byte in the middle of the compressed data. + let mut bad = bytes.clone(); + let mid = bad.len() / 2; + bad[mid] ^= 0xFF; + assert_steps_equal_decode(&bad, "a damaged byte"); + } + assert!(decode_in_steps(&[], 1).is_err()); + assert!(decode_in_steps(b"not a png at all, just some text bytes", 1).is_err()); +} + // --------------------------------------------------------------------------- // PngSuite corpus (repo-root corpus/pngsuite/*.png; feature `corpus`, fetched by // `cargo xtask corpus --all`; missing = failure) @@ -228,3 +501,146 @@ fn pngsuite_corpus_matches_oracle() { eprintln!("pngsuite: {checked} files compared"); assert!(checked > 100, "{}: only {checked} PNGs compared: run `cargo xtask corpus --all`", dir.display()); } + +/// The preflight charges the decoded layout's bytes per pixel: a 1000×1000 Gray8 PNG (1 MB) fits a +/// 1.5 MB budget, which the old worst-case RGBA16 preflight (8 MB) refused. +#[test] +fn png_preflight_uses_the_output_layout() { + let img = synth(1000, 1000, ChannelLayout::Gray, SampleType::U8, 7, 0.5); + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let opts = DecodeOptions { limits: Limits { max_alloc: 1_500_000, ..Limits::none() }, ..Default::default() }; + let got = decode_with(&bytes, &opts).expect("a Gray8 image is charged 1 byte per pixel"); + assert_eq!(got.data(), img.data()); + let tight = DecodeOptions { limits: Limits { max_alloc: 900_000, ..Limits::none() }, ..Default::default() }; + assert!(decode_with(&bytes, &tight).is_err(), "the actual pixel budget still applies"); +} + +#[cfg(feature = "corpus")] +#[test] +fn png_rows_equal_decode_on_the_pngsuite_corpus() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pngsuite"); + let Ok(entries) = std::fs::read_dir(&dir) else { + panic!("{} is missing: run `cargo xtask corpus --all`", dir.display()); + }; + let mut checked = 0; + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("png") { + continue; + } + let bytes = std::fs::read(&p).unwrap(); + assert_steps_equal_decode(&bytes, &p.display().to_string()); + checked += 1; + } + assert!(checked > 100, "{}: only {checked} PNGs compared: run `cargo xtask corpus --all`", dir.display()); +} + +/// Row-by-row decoding gives `decode`'s pixels and metadata for every PngSuite file it takes (all +/// but the interlaced ones), and fails where `decode` fails. +#[cfg(feature = "corpus")] +#[test] +fn pngsuite_corpus_rows_match_the_whole_decode() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pngsuite"); + let Ok(entries) = std::fs::read_dir(&dir) else { + panic!("{} is missing: run `cargo xtask corpus --all`", dir.display()); + }; + let opts = DecodeOptions { keep_orientation: true, ..Default::default() }; + let (mut streamed, mut refused, mut skipped) = (0, 0, 0); + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("png") { + continue; + } + let bytes = std::fs::read(&p).unwrap(); + // The importer only streams files `detect` takes for PNG. + if detect(&bytes) != Some(Format::Png) { + skipped += 1; + continue; + } + let whole = decode_with(&bytes, &opts); + let (mut data, mut header) = (Vec::new(), None); + let rows = decode_png_rows::( + &bytes, + &opts, + 256, + &mut |h| { + header = Some(*h); + Ok(true) + }, + &mut |_, r| { + data.extend_from_slice(r); + Ok(()) + }, + ); + match (whole, rows) { + (Ok(img), Ok(Some(x))) => { + let h = header.unwrap(); + assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (h.width, h.height, h.layout, h.sample), "{}", p.display()); + assert!(img.data() == data, "{}: pixels", p.display()); + assert_eq!( + (&img.icc, &img.meta.exif, &img.meta.text, &img.meta.xmp, &img.warnings), + (&x.icc, &x.meta.exif, &x.meta.text, &x.meta.xmp, &x.warnings), + "{}", + p.display() + ); + assert_eq!(img.meta.dpi, x.meta.dpi, "{}", p.display()); + streamed += 1; + } + (Ok(_), Ok(None)) => refused += 1, + (Err(a), Err(b)) => assert_eq!(a.to_string(), b.to_string(), "{}", p.display()), + (a, b) => panic!("{}: whole {:?}, rows {:?}", p.display(), a.map(|_| ()), b.map(|r| r.map(|_| ()))), + } + } + eprintln!("pngsuite: {streamed} streamed, {refused} refused (interlaced), {skipped} skipped (not detected as PNG)"); + assert!(streamed > 100, "only {streamed} PNGs streamed: run `cargo xtask corpus --all`"); +} + +/// Streaming a PNG's rows a group at a time gives the pixels and metadata `decode` gives, holds +/// only a group at a time, and stops between rows when asked. +#[test] +fn step_stream_gives_the_pixels_and_extras_of_decode() { + for (layout, sample) in [(ChannelLayout::Gray, SampleType::U8), (ChannelLayout::Rgb, SampleType::U16), (ChannelLayout::Rgba, SampleType::U8)] { + let (w, h) = (23u32, 19u32); + let len = w as usize * h as usize * layout.channels() * sample.bytes(); + let mut img = Image::from_raw(w, h, layout, sample, (0..len).map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8).collect()).unwrap(); + img.meta.dpi = Some((144.0, 144.0)); + img.meta.text.push(("Comment".into(), "streamed".into())); + img.icc = Some(vec![7; 40]); + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let want = decode(&bytes).unwrap(); + for group in [0u32, 1, 4, 19, 64] { + let mut dec = PngDecodeRows::new_streaming(bytes.as_slice(), &Limits::default()).unwrap(); + assert!(dec.streamable()); + assert_eq!(dec.header(), PngHeader { width: w, height: h, layout, sample }); + let (mut data, mut next, mut calls) = (Vec::new(), 0u32, 0); + let extras = loop { + calls += 1; + let mut sink = |y0: u32, rows: &[u8]| -> Result<(), CodecError> { + assert_eq!(y0, next, "{layout:?} group {group}"); + assert!(rows.len() <= group.max(1) as usize * dec_line(w, layout, sample), "a group at most"); + next += (rows.len() / dec_line(w, layout, sample)) as u32; + data.extend_from_slice(rows); + Ok(()) + }; + if let Some(extras) = dec.step_stream(group, &mut || false, &mut sink).unwrap() { + break extras; + } + assert!(calls < 10_000); + }; + assert!(calls > 1, "{layout:?}: stopped between rows"); + assert_eq!(data, want.data(), "{layout:?} group {group}: pixels"); + assert_eq!((extras.icc.as_deref(), extras.meta.dpi, &extras.meta.text), (want.icc.as_deref(), want.meta.dpi, &want.meta.text)); + assert_eq!(extras.orientation, 1); + assert!(dec.step_stream::(group, &mut || true, &mut |_, _| Ok(())).is_err(), "spent"); + } + } + // An interlaced file doesn't stream. + let img = Image::from_raw(9, 9, ChannelLayout::Gray, SampleType::U8, vec![1; 81]).unwrap(); + let bytes = encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap(); + assert!(!PngDecodeRows::new_streaming(bytes.as_slice(), &Limits::default()).unwrap().streamable()); +} + +/// Bytes per row of `w` pixels. +fn dec_line(w: u32, layout: ChannelLayout, sample: SampleType) -> usize { + w as usize * layout.channels() * sample.bytes() +} diff --git a/crates/codecs/tests/it/tiff_pages.rs b/crates/codecs/tests/it/tiff_pages.rs index 6996f83ae8..6255f9ac23 100644 --- a/crates/codecs/tests/it/tiff_pages.rs +++ b/crates/codecs/tests/it/tiff_pages.rs @@ -186,20 +186,143 @@ fn extra_samples_past_the_cap_are_reported() { let spp = 3 + EXTRAS; let px: Vec = (0..w * h * spp).map(|i| (i % spp) as u8).collect(); let d = strips(w as u32, h as u32, 8, spp as u16, 2, h as u32, &px).tag(338, SHORT, &[0; EXTRAS]); - let img = decode_ok(&build(true, false, &[d], &[0]).bytes); + let bytes = build(true, false, &[d], &[0]).bytes; + let img = decode_ok(&bytes); assert_eq!(img.layout(), ChannelLayout::Rgb); assert_eq!(img.meta.extra_samples.len(), MAX_EXTRA_SAMPLES); assert_eq!(img.meta.extra_samples.last().map(|e| e.data[0]), Some((3 + MAX_EXTRA_SAMPLES - 1) as u8)); assert_eq!(img.warnings, vec![DecodeWarning::ExtraSamplesOverLimit { format: Format::Tiff, count: 4 }]); + assert!(has_unretained_source_data(&bytes).unwrap()); let text = img.warnings[0].to_string(); assert!(text.contains("4 extra TIFF file channels") && text.contains("past the first 16"), "{text}"); // At the cap exactly, nothing is left out. let spp = 3 + MAX_EXTRA_SAMPLES; let px: Vec = (0..w * h * spp).map(|i| (i % spp) as u8).collect(); let d = strips(w as u32, h as u32, 8, spp as u16, 2, h as u32, &px).tag(338, SHORT, &[0; MAX_EXTRA_SAMPLES]); - let img = decode_ok(&build(true, false, &[d], &[0]).bytes); + let bytes = build(true, false, &[d], &[0]).bytes; + let img = decode_ok(&bytes); assert_eq!(img.meta.extra_samples.len(), MAX_EXTRA_SAMPLES); assert!(img.warnings.is_empty(), "{:?}", img.warnings); + assert!(!has_unretained_source_data(&bytes).unwrap()); +} + +/// The guard follows decoder classification, including non-first, repeated or missing alpha tags. +#[test] +fn source_sample_cap_matches_the_decoder_at_layout_and_alpha_boundaries() { + for (photo, color) in [(1u16, 1usize), (2, 3), (5, 4), (8, 3), (9, 3)] { + for extras in [MAX_EXTRA_SAMPLES - 1, MAX_EXTRA_SAMPLES, MAX_EXTRA_SAMPLES + 1] { + for kind in 0..7 { + let alpha = matches!(kind, 1..=3); + let spp = color + extras + usize::from(alpha); + let px: Vec<_> = (0..spp).map(|i| i as u8 + 1).collect(); + let mut kinds = vec![0; spp - color]; + if alpha { + kinds[if kind == 2 { 1 } else { 0 }] = 2; + } + if kind == 3 { + kinds[1] = 1; + } + if kind == 4 { + kinds.push(2); + } + let mut dir = strips(1, 1, 8, spp as u16, photo, 1, &px); + if kind != 5 { + dir = dir.tag(338, SHORT, &kinds); + } + if kind == 6 { + dir = dir.tag(338, SHORT, &vec![2; spp - color]); + } + let bytes = build(true, false, &[dir], &[0]).bytes; + let image = decode(&bytes); + let guarded = has_unretained_source_data(&bytes); + match image { + Ok(image) => { + let dropped = image.warnings.iter().any(|w| matches!(w, DecodeWarning::ExtraSamplesOverLimit { .. })); + assert_eq!(guarded.unwrap(), dropped, "photo={photo} extras={extras} alpha tags={kind}"); + if kind <= 3 { + assert_eq!(dropped, extras > MAX_EXTRA_SAMPLES); + } + } + Err(_) if kind >= 4 => assert!(guarded.is_err(), "unsupported tags remain refused"), + Err(error) => panic!("supported photo={photo} extras={extras} tags={kind}: {error}"), + } + } + } + } +} + +/// Independent compressed zero rows satisfy the decoder's file-size safety bound. Inspection +/// follows import's default budget without allocating or decoding the declared whole image. +#[cfg(target_pointer_width = "64")] +#[test] +fn source_sample_guard_follows_whole_aux_and_banded_plain_budget_routes() { + for (size, bits, extras, guarded) in [(16384u32, 8u16, MAX_EXTRA_SAMPLES, true), (32768, 32, 0, false)] { + let samples = 3 + extras; + let entries = if extras == 0 { 11u16 } else { 12 }; + let bits_at = 8 + 2 + usize::from(entries) * 12 + 4; + let kinds_at = bits_at + samples * 2; + let offsets_at = kinds_at + extras * 2; + let counts_at = offsets_at + size as usize * 4; + let data_at = counts_at + size as usize * 4; + let sample = if bits == 32 { SampleType::F32 } else { SampleType::U8 }; + let mut image = Image::from_raw(size, 1, ChannelLayout::Rgb, sample, vec![0; size as usize * 3 * sample.bytes()]).unwrap(); + image.meta.extra_samples = (0..extras).map(|_| ExtraSample { kind: 0, data: vec![0; size as usize * sample.bytes()] }).collect(); + let encoded = encode(&image, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::Deflate, ..Default::default() }).unwrap(); + decode_ok(&encoded); + assert_eq!(&encoded[..2], b"II"); + let first = u32::from_le_bytes(encoded[4..8].try_into().unwrap()) as usize; + let n = u16::from_le_bytes(encoded[first..first + 2].try_into().unwrap()) as usize; + let field = |tag: u16| { + let at = (0..n).map(|i| first + 2 + i * 12).find(|&at| u16::from_le_bytes(encoded[at..at + 2].try_into().unwrap()) == tag).unwrap(); + assert_eq!(u32::from_le_bytes(encoded[at + 4..at + 8].try_into().unwrap()), 1); + u32::from_le_bytes(encoded[at + 8..at + 12].try_into().unwrap()) as usize + }; + let row = &encoded[field(273)..field(273) + field(279)]; + let mut tags = vec![ + (256u16, 4u16, 1u32, size), + (257, 4, 1, size), + (258, 3, samples as u32, bits_at as u32), + (259, 3, 1, field(259) as u32), + (262, 3, 1, 2), + (273, 4, size, offsets_at as u32), + (277, 3, 1, samples as u32), + (278, 4, 1, 1), + (279, 4, size, counts_at as u32), + (284, 3, 1, 1), + (339, 3, 1, if bits == 32 { 3 } else { 1 }), + ]; + if extras > 0 { + tags.push((338, 3, extras as u32, kinds_at as u32)); + } + tags.sort_unstable_by_key(|t| t.0); + let mut bytes = b"II\x2a\0\x08\0\0\0".to_vec(); + bytes.extend_from_slice(&entries.to_le_bytes()); + for (tag, ty, count, value) in tags { + bytes.extend_from_slice(&tag.to_le_bytes()); + bytes.extend_from_slice(&ty.to_le_bytes()); + bytes.extend_from_slice(&count.to_le_bytes()); + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.extend_from_slice(&0u32.to_le_bytes()); + for _ in 0..samples { + bytes.extend_from_slice(&bits.to_le_bytes()); + } + for _ in 0..extras { + bytes.extend_from_slice(&0u16.to_le_bytes()); + } + for index in 0..size { + bytes.extend_from_slice(&((data_at + index as usize * row.len()) as u32).to_le_bytes()); + } + for _ in 0..size { + bytes.extend_from_slice(&(row.len() as u32).to_le_bytes()); + } + assert_eq!(bytes.len(), data_at); + for _ in 0..size { + bytes.extend_from_slice(row); + } + assert!(bytes.len() < 32 << 20); + assert_eq!(has_unretained_source_data(&bytes).unwrap(), guarded); + } } #[test] @@ -455,6 +578,63 @@ fn the_chain_walk_is_capped() { assert_eq!(decode_ok(&b.bytes).warnings, [DecodeWarning::MorePages { total: None }]); } +/// A partial child walk remains usable for decoding, but cannot authorize destructive saves. +#[test] +fn incomplete_subifd_lists_and_depth_are_reported() { + for (little, big) in ORDERS { + let ty = if big { IFD8 } else { IFD }; + for depth in [4usize, 5] { + let dirs: Vec<_> = (0..=depth) + .map(|i| { + let dir = gray_page( + 2, + 2, + i as u8, + Some(if i == 0 { + 0 + } else if i == depth { + 4 + } else { + 1 + }), + ); + if i < depth { dir.sub_ifds(ty, &[i + 1]) } else { dir } + }) + .collect(); + let b = build(little, big, &dirs, &[0]); + assert_eq!(tiff_info(&b.bytes).unwrap().complete, depth == 4); + decode_ok(&b.bytes); + if depth == 4 { + assert!(has_unretained_source_data(&b.bytes).unwrap()); + } else { + assert!(has_unretained_source_data(&b.bytes).is_err()); + } + } + for count in [0usize, 1, 256, 257] { + let b = build(little, big, &[gray_page(2, 2, 9, None).tag(330, ty, &vec![0; count])], &[0]); + assert_eq!(tiff_info(&b.bytes).unwrap().complete, count <= 256, "null child slots {count}"); + decode_ok(&b.bytes); + } + for invalid in [SHORT, u16::MAX] { + let b = build(little, big, &[gray_page(2, 2, 9, None).tag(330, invalid, &[0])], &[0]); + assert!(!tiff_info(&b.bytes).unwrap().complete); + assert!(has_unretained_source_data(&b.bytes).is_err()); + decode_ok(&b.bytes); + } + let b = build(little, big, &[gray_page(2, 2, 9, None).tag(330, ty, &[0]).tag(330, ty, &[0])], &[0]); + assert!(!tiff_info(&b.bytes).unwrap().complete, "duplicate child declarations"); + let mut b = build(little, big, &[gray_page(2, 2, 9, None).tag(330, ty, &[0, 0])], &[0]); + let (entry, ..) = find_entry(&b, little, big, 0, 330).unwrap(); + let offset = b.bytes.len() as u64 - 1; + let value = if little { offset.to_le_bytes() } else { offset.to_be_bytes() }; + let (field, width) = if big { (entry + 12, 8) } else { (entry + 8, 4) }; + b.bytes[field..field + width].copy_from_slice(if little { &value[..width] } else { &value[8 - width..] }); + assert!(!tiff_info(&b.bytes).unwrap().complete, "truncated child list"); + assert!(has_unretained_source_data(&b.bytes).is_err()); + decode_ok(&b.bytes); + } +} + #[test] fn image_data_cut_off_decodes_with_a_warning() { let (w, h) = (4u32, 8u32); diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 50e8e220ac..17d8f9faf7 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -13,6 +13,7 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-vector = { workspace = true } +photocraft-cms = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } @@ -21,7 +22,6 @@ rayon = { workspace = true } proptest = { workspace = true } criterion = { workspace = true } photocraft-paint = { workspace = true } -photocraft-cms = { workspace = true } [[bench]] name = "compose" diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index 2f8eaa8861..acf41639bd 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -190,10 +190,16 @@ pub fn separable(mode: BlendMode) -> bool { /// channel's brightness (1 − ink), as CMYK channels are blended, so Multiply adds ink and Screen /// takes it away. pub fn blend_ink(mode: BlendMode, cb: [f32; 4], cs: [f32; 4]) -> [f32; 4] { + blend_ink_fill(mode, cb, cs, 1.0) +} + +/// [`blend_ink`] at `fill`: below 100% the special eight change the blend itself +/// (`psblend::blend_rgb_fill`, on each ink's brightness); other modes ignore `fill`. +fn blend_ink_fill(mode: BlendMode, cb: [f32; 4], cs: [f32; 4], fill: f32) -> [f32; 4] { let b = |i: usize| 1.0 - cb[i]; let s = |i: usize| 1.0 - cs[i]; - let cmy = super::psblend::blend_rgb(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)]); - let k = super::psblend::blend_rgb(mode, [b(3); 3], [s(3); 3]); + let cmy = super::psblend::blend_rgb_fill(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)], fill); + let k = super::psblend::blend_rgb_fill(mode, [b(3); 3], [s(3); 3], fill); // A result that is one of the inputs (Normal, Darken, Lighten, ...) is that ink exactly. let ink = |r: f32, i: usize| { if r == s(i) { @@ -383,7 +389,7 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { let luts = matches!(adj, Adjustment::Levels { .. } | Adjustment::Curves { .. }).then(|| super::adjust::tone_luts_depth(adj, Some(depth))); let tone = |ch: usize, v: f32| match adj { Adjustment::Invert => 1.0 - v, - Adjustment::Posterize { levels } => super::adjust::posterize(v, *levels), + Adjustment::Posterize { levels } => super::adjust::posterize_in(v, *levels, Some(depth)), _ => luts.as_ref().and_then(|t| t.get(ch)).map_or(v, |t| lut_at(t, v)), }; for p in &mut buf.px { @@ -401,14 +407,26 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { } /// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and -/// mask; coverage is unchanged (the compositor's adjustment mix). -fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { +/// mask; coverage is unchanged (the compositor's adjustment mix). `fill_special` is set for an +/// unclipped adjustment layer, where Fill below 100% in one of Photoshop's special eight +/// ([`super::psblend::fill_is_special`]) is part of the blend and only Opacity covers the +/// backdrop: the compositor's adjustment mix does the same (#9501). A clipped adjustment is +/// mixed atop its base as if the base were opaque, where the compositor keeps Fill as coverage +/// (`composite_atop_any`), so it passes `false`. +fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx, fill_special: bool) { let opacity = l.opacity * l.fill_opacity; + let special = fill_special && l.fill_opacity < 1.0 && super::psblend::fill_is_special(l.blend); + let fill = l.fill_opacity.clamp(0.0, 1.0); + if special && fill <= 0.0 { + // Zero Fill is the backdrop: keep its inks without a brightness round trip. + return; + } + let coverage = if special { l.opacity } else { opacity }; let rect = base.rect; let w = (rect.width() as usize).max(1); let mv = mask_vals(l, rect, cx); for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() { - let k = opacity * mask_at(&mv, i); + let k = coverage * mask_at(&mv, i); if k <= 0.0 { continue; } @@ -422,7 +440,9 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct } continue; } - let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); + let cb = [p[0], p[1], p[2], p[3]]; + let cs = [a[0], a[1], a[2], a[3]]; + let bl = if special { blend_ink_fill(l.blend, cb, cs, fill) } else { blend_ink(l.blend, cb, cs) }; for (v, b) in p.iter_mut().zip(bl) { *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; } @@ -450,7 +470,8 @@ fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) { if let LayerContent::Adjustment(adj) = &c.content { let mut adjusted = base.clone(); adjust_inks(adj, &mut adjusted, cx.depth); - mix_adjusted(c, base, &adjusted, cx); + // Clipped onto its base as if opaque: the compositor keeps Fill as coverage there. + mix_adjusted(c, base, &adjusted, cx, false); continue; } let content = layer_content(c, base.rect, cx); @@ -554,7 +575,7 @@ fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx: let mut adjusted = backdrop.clone(); adjust_inks(adj, &mut adjusted, cx.depth); composite_clipped(clipped, &mut adjusted, cx); - mix_adjusted(l, backdrop, &adjusted, cx); + mix_adjusted(l, backdrop, &adjusted, cx, true); return; } let mut content = layer_content(l, rect, cx); diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 44b265c8dd..8deaa68de3 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -119,6 +119,11 @@ fn ref_blend(mode: BlendMode, b: f32, s: f32) -> f32 { 1.0 - crate::psblend::blend_rgb(mode, [1.0 - b; 3], [1.0 - s; 3])[0] } +/// [`ref_blend`] for the special eight at `fill` below 100%: Fill changes the blend itself. +fn ref_blend_fill(mode: BlendMode, b: f32, s: f32, fill: f32) -> f32 { + 1.0 - crate::psblend::blend_rgb_fill(mode, [1.0 - b; 3], [1.0 - s; 3], fill)[0] +} + /// The general compositing formula (W3C / PDF), written out on each ink. fn over_mode(mode: BlendMode, b: Cmyka, s: Cmyka, opacity: f32, text: bool) -> Cmyka { if mode == BlendMode::Normal { @@ -214,20 +219,26 @@ fn ref_adjust(adj: &Adjustment, p: Cmyka) -> Cmyka { } /// An adjustment layer `l` (adjusting `p`, then `clipped` onto that) mixed into `p` in its blend -/// mode by its opacity and mask; coverage stays. -fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32, y: i32) -> Cmyka { +/// mode by its opacity and mask; coverage stays. For an unclipped adjustment (`fill_special`) +/// the special eight below 100% Fill are part of the blend and only Opacity covers; clipped onto +/// a base, Fill stays coverage as on the compositor. +fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], fill_special: bool, p: Cmyka, x: i32, y: i32) -> Cmyka { let mut a = ref_adjust(adj, p); if p[4] > 0.0 { a = ref_clip(clipped, a, x, y); } - let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); + let opacity = l.opacity * l.fill_opacity; if l.blend == BlendMode::Dissolve { // Opacity and mask are the chance the pixel takes the adjustment fully. + let k = opacity * ref_mask(l, x, y); return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p }; } + let special = fill_special && l.fill_opacity < 1.0 && crate::psblend::fill_is_special(l.blend); + let k = if special { l.opacity } else { opacity } * ref_mask(l, x, y); let mut o = p; for c in 0..4 { - o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; + let m = if special { ref_blend_fill(l.blend, p[c], a[c], l.fill_opacity) } else { ref_blend(l.blend, p[c], a[c]) }; + o[c] = p[c] + (m - p[c]) * k; } o } @@ -236,7 +247,7 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32 fn ref_clip(clipped: &[Layer], mut content: Cmyka, x: i32, y: i32) -> Cmyka { for c in clipped.iter().filter(|c| c.visible) { if let LayerContent::Adjustment(adj) = &c.content { - content = ref_adjusted(c, adj, &[], content, x, y); + content = ref_adjusted(c, adj, &[], false, content, x, y); } else if content[4] > 0.0 { let a = content[4]; let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, Some((x, y))); @@ -292,7 +303,7 @@ fn ref_layer(l: &Layer, clipped: &[Layer], x: i32, y: i32, b: Cmyka) -> Cmyka { o[4] = a; return o; } - LayerContent::Adjustment(adj) => return ref_adjusted(l, adj, clipped, b, x, y), + LayerContent::Adjustment(adj) => return ref_adjusted(l, adj, clipped, true, b, x, y), LayerContent::Group(g) => { let mut c = ref_stack(&g.children, x, y, [0.0; 5]); c[4] *= ref_mask(l, x, y); @@ -1186,6 +1197,100 @@ fn adjustment_stacks_match_the_reference() { assert_routes_agree(&d); } +/// A CMYK document at the requested sample depth. +fn doc_at(depth: SampleType, w: u32, h: u32) -> Document { + Document::new("c", Size::new(w, h), ColorMode::Cmyk, depth) +} + +/// A special blend mode's Fill below 100% on an adjustment layer changes the blend itself and +/// leaves coverage to Opacity, as the RGB compositor mixes it (#9501); the split route runs that +/// compositor on stand-ins, so both ink routes agree, at 8 and 16 bits. Clipped onto a base, +/// Fill stays coverage, exactly as the compositor's `composite_atop_any` does. Each structure is +/// also checked alone over its own paper, and every prefix of the combined stack, so a +/// disagreement names the layer it starts at. +#[test] +fn special_fill_adjustments_agree_with_the_split_route() { + use photocraft_doc::adjust::LevelsChannel; + + /// The split route gives the exact path's inks on `d`, saying where they first differ. + fn check_routes(d: &Document, ctx: &str) { + eprintln!("{ctx}: checking split against exact"); + assert!(supports(d) && split::supports(d), "{ctx}: unsupported document"); + let via = split_render(d); + let exact = render_cmyk(d, via.rect).unwrap_or_else(|| panic!("{ctx}: supported")); + let width = via.rect.width() as usize; + for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() { + let differs = (g[4] - w[4]).abs() > 1e-5 || (w[4] > 1e-4 && (0..4).any(|c| (g[c] - w[c]).abs() > 1e-5)); + if differs { + let (x, y) = (via.rect.x0 + (i % width) as i32, via.rect.y0 + (i / width) as i32); + panic!("{ctx}: ({x},{y}) split {g:?} vs exact {w:?}"); + } + } + eprintln!("{ctx}: split and exact agree"); + } + + let d0 = LevelsChannel::default; + for depth in [SampleType::U8, SampleType::U16] { + let cmyka = PixelFormat { mode: ColorMode::Cmyk, sample: depth, alpha: true }; + let cmyk = PixelFormat { mode: ColorMode::Cmyk, sample: depth, alpha: false }; + let pix = |rects: &[(Rect, Cmyka)]| { + let mut l = Layer::raster("l", cmyka); + if let Some(s) = l.surface_mut() { + for (r, c) in rects { + s.fill_rect(*r, c); + } + } + l + }; + let mut paper_l = Layer::raster("bg", cmyk); + if let Some(s) = paper_l.surface_mut() { + s.fill_rect(Rect::new(0, 0, 6, 4), &[0.15, 0.35, 0.05, 0.1]); + } + // LinearDodge Levels and Posterize, each with a low Fill. + for adj in [cmyk_levels(LevelsChannel { in_white: 0.6, gamma: 1.7, ..d0() }, d0()), Adjustment::Posterize { levels: 3 }] { + let mut a = adjustment(adj.clone()); + a.blend = BlendMode::LinearDodge; + a.fill_opacity = 0.2; + // Through a pass-through group: the adjustment acts on the backdrop below it. + let mut pass = Layer::group("pass", vec![a.clone()]); + pass.blend = BlendMode::PassThrough; + // Inside an isolated group: the adjustment acts on the group's own rendering. + let mut inner = a.clone(); + inner.opacity = 0.75; + let mut iso = Layer::group("iso", vec![pix(&[(Rect::new(0, 0, 4, 4), [0.0, 0.6, 0.2, 0.4, 0.8])]), inner]); + iso.blend = BlendMode::Normal; + // Clipped onto a pixel base: Fill is coverage there. + let mut clipped = a.clone(); + clipped.clipped = true; + let base = pix(&[(Rect::new(0, 0, 6, 4), [0.3, 0.1, 0.5, 0.2, 0.9])]); + + // Each structure on its own, over its own paper. + let mut pass_doc = doc_at(depth, 6, 4); + pass_doc.layers.extend([paper_l.clone(), pass.clone()]); + check_routes(&pass_doc, &format!("depth {depth:?}, {adj:?}, pass-through group alone")); + let mut iso_doc = doc_at(depth, 6, 4); + iso_doc.layers.extend([paper_l.clone(), iso.clone()]); + check_routes(&iso_doc, &format!("depth {depth:?}, {adj:?}, isolated group alone")); + let mut clipped_doc = doc_at(depth, 6, 4); + clipped_doc.layers.extend([paper_l.clone(), base.clone(), clipped.clone()]); + check_routes(&clipped_doc, &format!("depth {depth:?}, {adj:?}, clipped onto a base alone")); + + // The combined stack, one layer at a time. + let mut d = doc_at(depth, 6, 4); + let steps = + [("paper", paper_l.clone()), ("pass-through adjustment", pass), ("isolated group", iso), ("alpha base", base), ("clipped adjustment", clipped)]; + let total = steps.len(); + for (n, (name, l)) in steps.into_iter().enumerate() { + d.layers.push(l); + check_routes(&d, &format!("depth {depth:?}, {adj:?}, combined prefix {}/{total} ({name})", n + 1)); + } + if depth == SampleType::U8 { + assert_matches_reference(&d); + } + } + } +} + /// In a 32-bit document (no rounding to levels), inks an adjustment leaves alone come out /// bit for bit, at full strength and through blend modes that pick an input. #[test] @@ -1858,3 +1963,94 @@ fn every_cmyk_route_rounds_after_an_adjustment_layer() { assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}"); } } + +/// An adjustment layer in one of the special eight below 100% Fill: Fill is part of the blend and +/// Opacity alone mixes it in, on the ink path as on the RGB compositor (#9512; Linear Burn at +/// Fill 71% put Y 0.957 where the split route gives 0.988). +#[test] +fn adjustment_layers_keep_the_special_fill_rule() { + let levels = AdjSpec::Levels { channel: 3, levels: [0.125, 0.8, 1.1, 0.14, 0.78] }; + let s = Spec { + w: 3, + h: 1, + layers: vec![ + raster_spec(BlendMode::Normal, (Rect::new(0, 0, 3, 1), [0.0, 0.0, 0.88, 1.0, 1.0]), 1.0, false), + LayerSpec::Pixels { + kind: Kind::Adjust(levels.clone()), + blend: BlendMode::LinearBurn, + fills: vec![], + opacity: 0.9, + fill: 0.71, + clipped: false, + visible: true, + mask: None, + }, + ], + }; + assert_split_matches_exact(&s); + for mode in SEPARABLE.iter().copied().filter(|m| crate::psblend::fill_is_special(*m)) { + let mut d = doc(4, 2); + d.layers.push(paper(4, 2, [0.2, 0.5, 0.1, 0.1])); + d.layers.push(layer(&[(Rect::new(0, 0, 3, 2), [0.6, 0.1, 0.4, 0.2, 0.8])])); + let mut a = build(&LayerSpec::Pixels { + kind: Kind::Adjust(levels.clone()), + blend: mode, + fills: vec![], + opacity: 0.9, + fill: 0.4, + clipped: false, + visible: true, + mask: None, + }); + a.mask = Some(mask(&[(Rect::new(2, 0, 4, 2), 0.5)], 1.0, 1.0, true)); + d.layers.push(a); + assert_matches_reference(&d); + assert_routes_agree(&d); + } +} + +/// A one-pixel 32-bit CMYK document: a raster `base` backdrop and an Invert adjustment layer in +/// `blend` at `fill`; `clipped`, when given, is a fully covering clipped Normal raster that +/// replaces the adjusted inks exactly. +fn special_fill_doc(base: Cmyka, blend: BlendMode, fill: f32, clipped: Option) -> Document { + let format = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::F32, alpha: true }; + let pixel = Rect::new(0, 0, 1, 1); + let mut d = Document::new("fill", Size::new(1, 1), ColorMode::Cmyk, SampleType::F32); + let mut backdrop = Layer::raster("base", format); + backdrop.surface_mut().unwrap().fill_rect(pixel, &base); + d.layers.push(backdrop); + let mut adjust = adjustment(Adjustment::Invert); + adjust.blend = blend; + adjust.fill_opacity = fill; + d.layers.push(adjust); + if let Some(ink) = clipped { + let mut clip = Layer::raster("clip", format); + clip.surface_mut().unwrap().fill_rect(pixel, &ink); + clip.clipped = true; + d.layers.push(clip); + } + d +} + +/// Zero Fill leaves the backdrop bit-exact in all of the special eight. Linear Burn and Linear +/// Light used to round trip through brightness (0.7 came back as 0.70000005). +#[test] +fn adjustment_zero_fill_keeps_backdrop_inks_exact() { + let base = [0.1, 0.4, 0.7, 0.3, 1.0]; + for mode in SEPARABLE.iter().copied().filter(|m| crate::psblend::fill_is_special(*m)) { + let d = special_fill_doc(base, mode, 0.0, None); + assert!(supports(&d)); + assert_eq!(render1(&d, 0, 0), base, "{mode:?}"); + } +} + +/// Two F32 inks can share one brightness; zero Fill keeps the backdrop's, not the source's. +#[test] +fn adjustment_zero_fill_preserves_colliding_brightness() { + let base = [f32::from_bits(0x3dcccccc), 0.4, 0.7, 0.3, 1.0]; + let source = [f32::from_bits(0x3dcccccd), 0.4, 0.7, 0.3, 1.0]; + assert_ne!(base[0], source[0]); + assert_eq!(1.0 - base[0], 1.0 - source[0]); + let d = special_fill_doc(base, BlendMode::Difference, 0.0, Some(source)); + assert_eq!(render1(&d, 0, 0), base); +} diff --git a/crates/compose/src/composite.rs b/crates/compose/src/composite.rs index 4ef497672f..679a2298c0 100644 --- a/crates/compose/src/composite.rs +++ b/crates/compose/src/composite.rs @@ -363,6 +363,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: let clip_base_adjusts = clipped.iter().any(|c| c.visible) && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content + && layer.frame.is_none() && layer.blend == BlendMode::PassThrough && layer.fill_opacity >= 1.0 && !effects::has_effects(layer) diff --git a/crates/compose/src/extents.rs b/crates/compose/src/extents.rs index 7e4f8d234e..c0ae28d340 100644 --- a/crates/compose/src/extents.rs +++ b/crates/compose/src/extents.rs @@ -5,6 +5,16 @@ use super::{BlendMode, Document, Layer, LayerContent, Rect, advanced, bounds, ef /// Bounds of a layer's own pixels (union over group children; the canvas /// for fill layers; an artboard's board). pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { + if let Some(f) = &layer.frame { + // Empty placeholders are shell overlays, so they must not schedule a full frame-sized + // mask/composite on either renderer (particularly on 24+ MP documents). + if layer.children().is_some_and(|c| c.is_empty()) && f.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { + return Rect::EMPTY; + } + let b = f.bounds(); + let n = f.stroke.as_ref().map_or(0, |s| s.width.ceil() as i32); + return Rect::new(b.x0.saturating_sub(n), b.y0.saturating_sub(n), b.x1.saturating_add(n), b.y1.saturating_add(n)); + } // Transparency Shapes Layer off: effects and knockout cover the whole layer (the canvas). if advanced::shapeless(layer) { return canvas; @@ -61,6 +71,9 @@ fn frame_stroke_bounds(layer: &Layer, canvas: Rect) -> Rect { /// children count with their own effects (a child's outside stroke shapes the group's drop /// shadow and must not be cut off at the child's pixels, #3274). pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect { + if layer.frame.is_some() { + return layer_bounds(layer, canvas); + } match &layer.content { // A Frame's stroke is part of the group's content its effects are built from. LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => { @@ -359,6 +372,10 @@ pub(crate) fn shapeless_stroke_bounds(layer: &Layer) -> Option { /// Adjustment layers count as empty: they only change pixels already there (which is what an /// isolated group needs; see [`change_bounds`] for the document-level question). pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option { + if layer.frame.is_some() { + let bounds = layer_bounds(layer, canvas); + return Some(if bounds.is_empty() { bounds } else { bounds.inflate(effects::margin(layer)).intersect(&canvas) }); + } if !transparent_outside(layer) { return None; } diff --git a/crates/compose/src/fx_cache.rs b/crates/compose/src/fx_cache.rs index b24aabc29b..e1cd209002 100644 --- a/crates/compose/src/fx_cache.rs +++ b/crates/compose/src/fx_cache.rs @@ -45,6 +45,7 @@ pub(crate) fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map:: layer_identity(c, h, pin); } format!("{:?}{:?}", g.artboard, g.frame_stroke).hash(h); + format!("{:?}", layer.frame).hash(h); } LayerContent::Fill(f) => { format!("{f:?}").hash(h); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 2af1c2e8fb..e93e0818f3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod effects; pub mod fill_layout; pub mod filter_mask; pub mod gradient_fill; +pub mod lut_rows; pub mod masks; pub mod multichannel; pub mod native; @@ -82,8 +83,8 @@ use render::in_blend_space; use render::render_tiled_with; pub(crate) use render::visible_layers; pub use render::{ - BAND_PIXELS, MAX_RENDER_PIXELS, band_rows_for, cmyk_space, composite_to_surface, flatten, flatten_to_surface, render, render_bands, render_layer, - render_layer_in, render_tiled, + BAND_PIXELS, MAX_RENDER_PIXELS, band_rows_for, cmyk_space, composite_to_surface, composite_to_surface_with_interrupt, flatten, flatten_to_surface, render, + render_bands, render_layer, render_layer_in, render_tiled, }; /// Straight-alpha RGBA float buffer covering a rectangle. diff --git a/crates/compose/src/lut_rows.rs b/crates/compose/src/lut_rows.rs new file mode 100644 index 0000000000..b0e525e786 --- /dev/null +++ b/crates/compose/src/lut_rows.rs @@ -0,0 +1,197 @@ +//! Adjustment LUT rows (4096 entries each) kept between renders, keyed by a hash of what they're +//! built from. The GPU planner rebuilt the Levels tables on every plan, 1–2 ms per layer (#11498); +//! callers hash exactly the builder's inputs, so settings that don't change the rows share an +//! entry. Most recently used first, at most [`CAPACITY`] entries +//! (32 × 3 rows × 16 KiB = 1.5 MiB); [`purge_cache`] empties it (Edit › Purge › All). + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Entries in one LUT row. +pub const ROW_LEN: usize = 4096; + +/// The LUT rows of one adjustment state. +pub type Rows = Vec<[f32; ROW_LEN]>; + +/// Adjustment states the shared cache remembers. +pub const CAPACITY: usize = 32; + +/// A most-recently-used list of row sets by key. +#[derive(Debug)] +pub struct RowCache { + entries: VecDeque<(u64, Arc)>, + capacity: usize, + generation: u64, +} + +impl RowCache { + /// An empty cache holding at most `capacity` row sets. + pub fn new(capacity: usize) -> Self { + Self { entries: VecDeque::new(), capacity, generation: 0 } + } + + /// The rows stored for `key`, now the most recently used. + fn get(&mut self, key: u64) -> Option> { + let entry = self.entries.iter().position(|(k, _)| *k == key).and_then(|i| self.entries.remove(i))?; + let rows = Arc::clone(&entry.1); + self.entries.push_front(entry); + Some(rows) + } + + /// Store `rows` for `key` (unless another builder got there first), dropping the oldest entry + /// when full. + fn insert(&mut self, key: u64, rows: Arc) { + if !self.entries.iter().any(|(k, _)| *k == key) { + self.entries.push_front((key, rows)); + self.entries.truncate(self.capacity); + } + } + + /// Row sets held. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether nothing is held. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Bytes of rows held. + pub fn bytes(&self) -> usize { + self.entries.iter().map(|(_, r)| r.len().saturating_mul(std::mem::size_of::<[f32; ROW_LEN]>())).sum() + } + + /// Drop every entry, returning the bytes they held. + pub fn clear(&mut self) -> usize { + let freed = self.bytes(); + self.entries.clear(); + self.generation = self.generation.wrapping_add(1); + freed + } +} + +/// The rows for `key` in `cache`, built by `build` only when it doesn't hold them. `build` runs +/// outside the lock, so other renders never wait on it. +pub fn cached_in(cache: &Mutex, key: u64, build: impl FnOnce() -> Rows) -> Rows { + let (hit, generation) = { + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + (cache.get(key), cache.generation) + }; + if let Some(rows) = hit { + return rows.to_vec(); + } + let rows = build(); + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + // A purge must not be undone by a build that was already in flight. + if cache.generation == generation { + cache.insert(key, Arc::new(rows.clone())); + } + rows +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(RowCache::new(CAPACITY))) +} + +/// [`cached_in`] the shared cache. +pub fn cached(key: u64, build: impl FnOnce() -> Rows) -> Rows { + cached_in(cache(), key, build) +} + +/// Bytes of rows the shared cache holds. +pub fn cache_bytes() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).bytes() +} + +/// Drop every cached row set (Edit › Purge › All), returning the bytes they held. +pub fn purge_cache() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).clear() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + fn rows(v: f32) -> Rows { + vec![[v; ROW_LEN]; 3] + } + + #[test] + fn a_known_key_is_served_without_building() { + let cache = Mutex::new(RowCache::new(4)); + let builds = Cell::new(0); + let get = |key: u64, v: f32| { + cached_in(&cache, key, || { + builds.set(builds.get() + 1); + rows(v) + }) + }; + assert_eq!(get(1, 0.25)[2][7], 0.25); + assert_eq!(get(1, 0.75)[2][7], 0.25, "the stored rows, not a rebuild"); + assert_eq!(builds.get(), 1); + get(2, 0.5); + assert_eq!(builds.get(), 2); + } + + #[test] + fn the_least_recently_used_entry_is_dropped_first() { + let cache = Mutex::new(RowCache::new(CAPACITY)); + let builds = Cell::new(0); + let get = |key: u64| { + cached_in(&cache, key, || { + builds.set(builds.get() + 1); + rows(0.0) + }) + }; + // A slider drag through many states keeps only the most recent ones. + for key in 0..200 { + get(key); + } + assert_eq!(cache.lock().map(|c| c.len()).unwrap_or(0), CAPACITY); + assert_eq!(builds.get(), 200); + // Key 168 is the oldest kept: reading it makes 169 the oldest, which the next new key drops. + get(168); + get(1000); + assert_eq!(builds.get(), 201); + get(168); + assert_eq!(builds.get(), 201, "kept: it was used recently"); + get(169); + assert_eq!(builds.get(), 202, "dropped: the oldest when the cache was full"); + } + + #[test] + fn purge_discards_in_flight_builds() { + use std::sync::mpsc; + let cache = Mutex::new(RowCache::new(CAPACITY)); + let (started_tx, started_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let cache_ref = &cache; + scope.spawn(move || { + cached_in(cache_ref, 123, || { + started_tx.send(()).unwrap(); + resume_rx.recv().unwrap(); + rows(0.5) + }) + }); + started_rx.recv().unwrap(); + cache.lock().unwrap().clear(); + resume_tx.send(()).unwrap(); + }); + assert!(cache.lock().unwrap().is_empty(), "a build started before purge must not refill the cache"); + } + + #[test] + fn clearing_reports_the_bytes_held() { + let mut cache = RowCache::new(CAPACITY); + cache.insert(1, Arc::new(rows(0.0))); + cache.insert(2, Arc::new(vec![[0.0; ROW_LEN]])); + assert_eq!(cache.bytes(), 4 * ROW_LEN * 4); + assert_eq!(cache.clear(), 4 * ROW_LEN * 4); + assert!(cache.is_empty()); + assert_eq!(cache.bytes(), 0); + } +} diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 4eedc88619..ea6c11b5b0 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -1,49 +1,215 @@ -//! Display of Multichannel documents: every spot (ink) channel printed over white paper. +//! Spot inks printed over a picture: every spot (ink) channel of a Multichannel document over +//! white paper, and the visible spot channels of an RGB or CMYK document over its composite +//! (the canvas preview and the PSD thumbnail; never `flatten`, export or the merged image). //! //! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity //! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is -//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document -//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. -//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. +//! how the reference editor previews spot channels. Inks print in Channels panel order. An +//! alpha-type channel of a Multichannel document (selected / masked areas) still prints, as a +//! transparent black ink, like the reference editor shows it. Its value is stored as-is +//! (1 = selected, white in the Channels panel), so its ink is `1 − value`. -use photocraft_doc::Document; +//! This is a screen approximation, not a press proof: stored RGB alternate colours are taken as +//! sRGB and, like Lab/gray alternates, converted into an RGB document's own profile; CMYK +//! alternates use the document CMYK profile. No spectral ink or paper interaction is modelled. +//! At full coverage, solidity 0 multiplies the underlying colour and solidity 1 replaces it. +//! Previewing borrows the document and only changes the supplied display buffer. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, ColorSpace, Profile, Transform, TransformOptions}; +use photocraft_color::convert::CmykSpace; +use photocraft_color::{Color, ColorMode}; +use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; use crate::Buffer; -/// The printed inks of `doc` over `rect` (opaque). -pub fn inks(doc: &Document, rect: Rect) -> Buffer { - let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); - let mut vals = Vec::new(); - for ch in &doc.channels { - let alpha = ch.spot.is_none(); - let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - // An empty alpha channel (all black) is solid black ink, so it can't be skipped. - if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { - continue; - } - let c = ink.to_rgb(); - let s = solidity.clamp(0.0, 1.0); - let n = ch.surface.channels(); - ch.surface.read_region_into(rect, &mut vals); - for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); - if v <= 0.0 { +/// One channel printed as an ink: its display colour in the composite's colour space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ink { + pub rgb: [f32; 3], + pub solidity: f32, + /// An alpha-type channel: black ink where the channel is unselected (`1 − value`). + pub alpha: bool, +} + +impl Ink { + /// The ink of channel `ch` of `doc`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. Printing several inks, build one [`InkSpace`] and use [`InkSpace::ink`]. + pub fn of(doc: &Document, ch: &AlphaChannel) -> Ink { + InkSpace::of(doc).ink(ch) + } + + /// Print this ink with channel values `values` (one per pixel) over the opaque `px`. + pub fn apply(&self, px: &mut [[f32; 4]], values: impl IntoIterator) { + let (c, s) = (self.rgb, self.solidity); + for (p, v) in px.iter_mut().zip(values) { + let v = if self.alpha { 1.0 - v } else { v }; + if v.is_nan() || v <= 0.0 { continue; } + let v = v.min(1.0); let keep = 1.0 - s * v; for j in 0..3 { p[j] = keep * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]; } } } +} + +/// How spot colours of one document map into its composite space. Build it once per render: +/// the document's profiles are parsed here, not per ink or per damage rect. +pub struct InkSpace { + cmyk: Option>, + /// sRGB → an RGB document's own profile, when that differs from sRGB. + to_own: Option>, +} + +impl InkSpace { + pub fn of(doc: &Document) -> InkSpace { + let to_own = if doc.mode == ColorMode::Rgb { + doc.icc_profile + .as_ref() + .and_then(|b| Profile::parse(b).ok()) + .filter(|p| p.color_space == ColorSpace::Rgb && !p.same_colors(Builtin::Srgb.profile())) + .and_then(|own| photocraft_cms::transform::cached(Builtin::Srgb.profile(), &own, TransformOptions::default()).ok()) + } else { + None + }; + InkSpace { cmyk: crate::cmyk_space(doc), to_own } + } + + /// The ink of channel `ch`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. + pub fn ink(&self, ch: &AlphaChannel) -> Ink { + match ch.spot { + Some((color, solidity)) => Ink { rgb: self.rgb(color), solidity: solidity.clamp(0.0, 1.0), alpha: false }, + None => Ink { rgb: [0.0; 3], solidity: 0.0, alpha: true }, + } + } + + /// The display colour of spot colour `color` in the composite space: CMYK colours through the + /// document's CMYK profile (the built-in coated CMYK otherwise), and RGB (taken as sRGB), CMYK, + /// Lab and gray colours of an RGB document converted from sRGB into its own profile, so an ink + /// keeps its colour in a linear (32-bit), Adobe RGB or ProPhoto document. + pub fn rgb(&self, color: Color) -> [f32; 3] { + if matches!(color.mode, ColorMode::Indexed | ColorMode::Multichannel) { + return color.to_rgb(); + } + let mut rgb = photocraft_color::convert::with_cmyk_space(self.cmyk.as_ref(), || color.to_rgb()); + if let Some(t) = &self.to_own { + t.apply(&mut rgb, 3); + } + rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }) + } +} + +/// Print the channels `visible(i)` picks from `doc.channels` (in panel order) as inks over the +/// opaque `buf`, reading them over `buf.rect`. +pub fn overprint(buf: &mut Buffer, doc: &Document, visible: impl Fn(usize) -> bool) { + let rect = buf.rect; + let space = InkSpace::of(doc); + let mut vals = Vec::new(); + for (i, ch) in doc.channels.iter().enumerate() { + if !visible(i) { + continue; + } + let ink = space.ink(ch); + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !ink.alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { + continue; + } + let n = ch.surface.channels().max(1); + ch.surface.read_region_into(rect, &mut vals); + ink.apply(&mut buf.px, vals.chunks_exact(n).map(|v| v[0])); + } +} + +/// Whether channel `i` of `doc` is a spot ink that prints over an RGB or CMYK composite. +pub fn is_overprint_spot(doc: &Document, i: usize) -> bool { + matches!(doc.mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Grayscale | ColorMode::Lab) && doc.channels.get(i).is_some_and(|c| c.spot.is_some()) +} + +/// The printed inks of `doc` over `rect` (opaque). +pub fn inks(doc: &Document, rect: Rect) -> Buffer { + let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); + overprint(&mut buf, doc, |_| true); buf } /// The starting backdrop of a document render: the inks for Multichannel, else transparent. pub(crate) fn backdrop(doc: &Document, rect: Rect) -> Buffer { - if doc.mode == photocraft_color::ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } + if doc.mode == ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } +} + +/// [`crate::thumbnail_buffer`] of an RGB or CMYK document with its spot channels printed over it +/// (matted over white, so opaque, where there is ink): the document's preview, as the reference +/// editor saves it. +/// Unchanged when no spot channel has ink. +pub fn thumbnail_with_inks(doc: &Document, max_side: u32) -> Buffer { + let mut buf = crate::thumbnail_buffer(doc, max_side); + let spots: Vec = (0..doc.channels.len()) + .filter(|&i| is_overprint_spot(doc, i) && doc.channels.get(i).is_some_and(|c| !c.surface.content_bounds().intersect(&doc.bounds()).is_empty())) + .collect(); + let (w, h) = (buf.rect.width(), buf.rect.height()); + if spots.is_empty() || w == 0 || h == 0 || buf.px.len() != w as usize * h as usize { + return buf; + } + let space = InkSpace::of(doc); + let inks: Vec<(Ink, Vec)> = + spots.into_iter().filter_map(|i| doc.channels.get(i)).map(|ch| (space.ink(ch), reduce(&ch.surface, doc.bounds(), w, h))).collect(); + // Only pixels with ink are matted over white (the inks print on paper); the rest keep their + // transparency. + for (k, p) in buf.px.iter_mut().enumerate() { + if inks.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0)) { + let a = if p[3].is_nan() { 0.0 } else { p[3].clamp(0.0, 1.0) }; + *p = [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0]; + } + } + for (ink, vals) in inks { + ink.apply(&mut buf.px, vals); + } + buf +} + +/// Samples per side of a thumbnail pixel that [`reduce`] averages at most. +const REDUCE_SAMPLES: i32 = 4; + +/// The first channel of `s` over `area`, averaged down to `w`×`h`, one output row at a time. +/// A box up to [`REDUCE_SAMPLES`] pixels a side is averaged whole; a bigger one at that many +/// evenly spaced rows and columns, so a huge document costs about `h × 4` row reads, not a scan. +fn reduce(s: &photocraft_raster::Surface, area: Rect, w: u32, h: u32) -> Vec { + let (sw, sh) = (u64::from(area.width()), u64::from(area.height())); + let n = s.channels().max(1); + let edge = |i: u32, out: u32, len: u64| (u64::from(i) * len / u64::from(out.max(1))) as i32; + // The offsets from a box's start that are sampled. + let picks = |len: i32| -> Vec { + if len <= REDUCE_SAMPLES { (0..len).collect() } else { (0..REDUCE_SAMPLES).map(|j| ((2 * j + 1) * len) / (2 * REDUCE_SAMPLES)).collect() } + }; + let mut out = Vec::with_capacity(w as usize * h as usize); + let mut row = Vec::new(); + let mut sums = vec![(0.0f32, 0u32); w as usize]; + for y in 0..h { + let (y0, y1) = (edge(y, h, sh), edge(y + 1, h, sh)); + sums.iter_mut().for_each(|t| *t = (0.0, 0)); + for dy in picks((y1 - y0).max(1)) { + let sy = area.y0 + y0 + dy; + s.read_region_into(Rect::new(area.x0, sy, area.x1, sy + 1), &mut row); + for (x, t) in sums.iter_mut().enumerate() { + let (x0, x1) = (edge(x as u32, w, sw), edge(x as u32 + 1, w, sw)); + for dx in picks((x1 - x0).max(1)) { + if let Some(v) = row.get((x0 + dx) as usize * n) { + t.0 += v; + t.1 += 1; + } + } + } + } + out.extend(sums.iter().map(|&(sum, count)| if count > 0 { sum / count as f32 } else { 0.0 })); + } + out } #[cfg(test)] @@ -70,7 +236,7 @@ mod tests { assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is - // unselected, as Photoshop shows it. + // unselected, as the reference editor shows it. let mut k = Surface::new(fmt); k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); @@ -83,4 +249,169 @@ mod tests { doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); } + + /// One spot channel of `depth` holding `vals` (a row), ink `rgb` at `solidity`. + fn spot(depth: SampleType, name: &str, rgb: [f32; 3], solidity: f32, vals: &[f32]) -> AlphaChannel { + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_region(Rect::new(0, 0, vals.len() as i32, 1), vals); + AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), solidity)), ..AlphaChannel::new(name, s) } + } + + /// The ink formula written out for one ink over `p`. + fn print(p: [f32; 3], c: [f32; 3], s: f32, v: f32) -> [f32; 3] { + std::array::from_fn(|j| (1.0 - s * v) * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]) + } + + fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool { + a[3] == 1.0 && a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + /// Spot channels never reach the composite of an RGB or CMYK document: flatten, the merged + /// image and export are the same with and without them. + #[test] + fn spots_stay_out_of_rgb_and_cmyk_flatten() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("d", Size::new(4, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let plain = crate::flatten(&doc); + doc.channels.push(spot(depth, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 0.5, 0.0, 1.0])); + doc.channels.push(spot(depth, "pink", [1.0, 0.3, 0.6], 1.0, &[1.0, 1.0, 1.0, 1.0])); + assert_eq!(crate::flatten(&doc).px, plain.px, "{mode:?} {depth:?}"); + let mut bare = doc.clone(); + bare.channels.clear(); + assert_eq!(crate::render(&doc, doc.bounds()).px, crate::render(&bare, doc.bounds()).px, "{mode:?} {depth:?}"); + } + } + } + + /// Two overlapping inks over a coloured picture, at every solidity and coverage, in both + /// orders and at 8 and 16 bits: the second ink prints over the first. + #[test] + fn overprint_follows_the_ink_formula_in_panel_order() { + let (a, b) = ([0.0, 0.6, 0.65], [0.9, 0.2, 0.5]); + let under = [0.8, 0.7, 0.3]; + for depth in [SampleType::U8, SampleType::U16] { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for sa in [0.0, 0.5, 1.0] { + for sb in [0.0, 0.5, 1.0] { + let cover = [0.0, 0.5, 1.0]; + let mut doc = Document::new("o", Size::new(3, 1), mode, depth); + doc.channels.push(spot(depth, "a", a, sa, &cover)); + doc.channels.push(spot(depth, "b", b, sb, &[1.0, 0.5, 0.0])); + for swapped in [false, true] { + if swapped { + doc.channels.reverse(); + } + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |_| true); + let vals: Vec<(f32, f32)> = vec![(0.0, 1.0), (0.5, 0.5), (1.0, 0.0)]; + for (x, &(va, vb)) in vals.iter().enumerate() { + let want = if swapped { print(print(under, b, sb, vb), a, sa, va) } else { print(print(under, a, sa, va), b, sb, vb) }; + // 0.5 is stored as 128/255 at 8 bits. + assert!(close(buf.px[x], want, 0.006), "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}: {:?} vs {want:?}", buf.px[x]); + } + if swapped { + doc.channels.reverse(); + } + } + // Only the channels `visible` picks print. + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |i| i == 0); + assert!(close(buf.px[2], print(under, a, sa, 1.0), 1e-5)); + } + } + } + } + } + + /// A solid transparent ink over white shows its own colour; a solid opaque one covers any colour. + #[test] + fn solid_inks_show_their_colour() { + let teal = [0.0, 157.0 / 255.0, 165.0 / 255.0]; + let mut doc = Document::new("s", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + doc.channels.push(spot(SampleType::U8, "teal", teal, 0.0, &[1.0])); + let mut buf = Buffer::filled(doc.bounds(), [1.0; 4]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + doc.channels[0].spot = Some((Color::rgb(teal[0], teal[1], teal[2]), 1.0)); + let mut buf = Buffer::filled(doc.bounds(), [0.2, 0.9, 0.1, 1.0]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + } + + #[test] + fn hidden_spot_leaves_the_process_composite_unchanged() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("hidden", Size::new(1, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + doc.channels.push(spot(depth, "opaque", [0.0, 0.6, 0.65], 1.0, &[1.0])); + let before = crate::flatten(&doc); + let mut preview = before.clone(); + overprint(&mut preview, &doc, |_| false); + assert_eq!(preview.px, before.px); + overprint(&mut preview, &doc, |_| true); + assert!(close(preview.px[0], [0.0, 0.6, 0.65], 1e-6)); + assert_eq!(crate::flatten(&doc).px, before.px); + assert_eq!(doc.channels[0].spot, Some((Color::rgb(0.0, 0.6, 0.65), 1.0))); + } + } + } + + /// A CMYK spot colour displays through the document's CMYK profile, as the CMYK composite does. + #[test] + fn cmyk_ink_colours_go_through_the_document_profile() { + let doc = Document::new("c", Size::new(1, 1), ColorMode::Cmyk, SampleType::U8); + let cyan = Color { mode: ColorMode::Cmyk, c: [1.0, 0.0, 0.0, 0.0], alpha: 1.0 }; + let space = crate::cmyk_space(&doc); + let want = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cyan.to_rgb()); + assert_eq!(InkSpace::of(&doc).rgb(cyan), want.map(|v| v.clamp(0.0, 1.0))); + // RGB inks are taken as stored. + assert_eq!(InkSpace::of(&doc).rgb(Color::rgb(0.1, 0.2, 0.3)), [0.1, 0.2, 0.3]); + } + + /// An RGB spot colour is sRGB, like the other alternates: an sRGB document takes it as stored, + /// a linear (32-bit) one gets its linear values, which the canvas encodes back to the colour. + #[test] + fn rgb_ink_colours_convert_into_the_document_profile() { + let teal = Color::rgb(0.0, 0.6, 0.65); + let mut doc = Document::new("l", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + assert_eq!(InkSpace::of(&doc).rgb(teal), [0.0, 0.6, 0.65]); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let got = InkSpace::of(&doc).rgb(teal); + let want = [0.0, 0.6, 0.65].map(photocraft_color::convert::srgb_to_linear); + assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() <= 0.005), "{got:?} vs {want:?}"); + } + + /// The thumbnail prints the spot inks over the composite (matted over white). + #[test] + fn thumbnail_prints_spot_inks() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut doc = Document::new("t", Size::new(4, 1), mode, SampleType::U8); + let plain = thumbnail_with_inks(&doc, 160); + assert_eq!(plain.px, crate::thumbnail_buffer(&doc, 160).px, "no ink: unchanged"); + doc.channels.push(spot(SampleType::U8, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 1.0, 0.0, 0.0])); + let t = thumbnail_with_inks(&doc, 160); + assert!(close(t.px[0], [0.0, 0.6, 0.65], 0.003), "{mode:?} {:?}", t.px[0]); + // No ink: the transparent pixel stays transparent (the writer mattes it later). + assert_eq!(t.px[3], plain.px[3], "{mode:?}"); + // Halved: each thumbnail pixel averages two document pixels. + let t = thumbnail_with_inks(&doc, 2); + assert!(close(t.px[0], print([1.0; 3], [0.0, 0.6, 0.65], 0.0, 1.0), 0.003), "{:?}", t.px); + assert_eq!(t.px[1], crate::thumbnail_buffer(&doc, 2).px[1], "no ink: unchanged"); + } + } + + /// A box bigger than the sample grid averages evenly spaced samples: ink on the left half of + /// a 64-pixel box reads half covered. + #[test] + fn reduce_samples_big_boxes() { + let mut sf = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let vals: Vec = (0..64).map(|x| if x < 32 { 1.0 } else { 0.0 }).collect(); + sf.write_region(Rect::new(0, 0, 64, 1), &vals); + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 1, 1); + assert_eq!(r, [0.5]); + // Small boxes stay exact. + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 16, 1); + assert_eq!(r, (0..16).map(|x| if x < 8 { 1.0 } else { 0.0 }).collect::>()); + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index dba123e21c..aa56917f40 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -21,21 +21,47 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); let w = x1.saturating_sub(x0).max(0) as usize; - let mut row = vec![0u8; w * bpp]; - for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); - for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. - if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { - px.copy_from_slice(from); + // Rows are read in parallel, a chunk at a time, so the scratch stays small. + const CHUNK_ROWS: i32 = 64; + let mut chunk = Vec::new(); + let mut y = y0; + while y < y1 { + let end = y.saturating_add(CHUNK_ROWS).min(y1); + chunk.clear(); + chunk.resize(w * (end - y) as usize * bpp, 0u8); + let fill = |(row, oy): (&mut [u8], i32)| { + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); + for (i, px) in row.chunks_exact_mut(bpp).enumerate() { + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } + }; + let rows = (w * bpp).max(1); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + chunk.par_chunks_mut(rows).zip(y..end).for_each(fill); } - out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); + #[cfg(target_arch = "wasm32")] + chunk.chunks_mut(rows).zip(y..end).for_each(fill); + out.write_interleaved(Rect::new(x0, y, x1, end), &chunk); + y = end; } out } fn shrink_layer(l: &mut Layer, k: u32) { + if let Some(f) = &mut l.frame { + f.transform = Affine::scale(1.0 / f64::from(k)).mul(&f.transform); + if let Some(s) = &mut f.stroke { + s.width /= k as f32; + } + if let Some(mask) = &mut l.vector_mask { + mask.path = f.outline(); + } + } if let Some(m) = &mut l.mask { m.surface = downsample(&m.surface, k); } diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 250a3ebce1..4f5d629f3a 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -357,3 +357,30 @@ pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { buf }) } + +/// Composite in bounded bands, retaining native colour-model paths, with cancellation. +pub fn composite_to_surface_with_interrupt( + doc: &Document, + rect: Rect, + fmt: photocraft_color::PixelFormat, + background: Option<[f32; 3]>, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check()?; + let mut out = Surface::new(fmt); + let rows = band_rows_for(rect.width(), 64); + let mut y = rect.y0; + while y < rect.y1 { + ctl.check()?; + let end = y.saturating_add(rows).min(rect.y1); + let band = Rect::new(rect.x0, y, rect.x1, end); + let part = composite_to_surface(doc, band, fmt, background); + ctl.check()?; + let mut pixels = Vec::new(); + part.read_region_into(band, &mut pixels); + out.write_region(band, &pixels); + y = end; + } + out.prune(); + Ok(out) +} diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 53875d5922..5a44e739bb 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -106,11 +106,15 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac Some(parts) } -/// The stroke-only shape a Photoshop Frame's stroke draws: `Group::frame_stroke` along the -/// group's enabled vector mask (the frame outline). `None` for other layers and for a frame -/// without a visible stroke or a vector mask. +/// The stroke-only shape a Frame's stroke draws: the Frame tool's own border (`Layer::frame`), +/// else a Photoshop Frame's `Group::frame_stroke` along the group's enabled vector mask (the +/// frame outline). `None` for other layers and for a frame without a visible stroke (or, for a +/// Photoshop Frame, a vector mask). pub fn frame_shape(layer: &Layer) -> Option { let LayerContent::Group(g) = &layer.content else { return None }; + if let Some(shape) = layer.frame.as_ref().and_then(|f| f.stroke_shape()) { + return Some(shape); + } let stroke = g.frame_stroke.as_ref().filter(|s| s.width.is_finite() && s.width > 0.0 && s.opacity > 0.0)?; let vm = layer.vector_mask.as_ref().filter(|m| m.enabled && !m.path.is_empty())?; Some(ShapeLayer { path: vm.path.clone(), fill: None, stroke: Some(stroke.clone()), ..ShapeLayer::default() }) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7ea8655099..153b038adf 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2143,6 +2143,21 @@ fn shapeless_effects_render_the_same_in_bands_and_parts() { } } +/// The interruptible composite renders band by band: with shapeless effects (whose maps are built +/// per band) it still gives the whole-document composite. +#[test] +fn interruptible_composite_with_shapeless_effects_matches_the_whole_composite() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = shapeless_fx_doc(depth); + let fmt = PixelFormat::new(ColorMode::Rgb, depth, true); + purge_effect_cache(); + let expected = composite_to_surface(&d, d.bounds(), fmt, None); + purge_effect_cache(); + let actual = composite_to_surface_with_interrupt(&d, d.bounds(), fmt, None, &photocraft_raster::Interrupt::NONE).unwrap(); + assert!(actual.read_region(d.bounds()) == expected.read_region(d.bounds()), "{depth:?}: the interruptible composite differs"); + } +} + #[test] fn shapeless_effect_maps_cover_only_the_band_being_rendered() { // Transparency Shapes Layer off makes an effect region the whole canvas: on a 1920 × 103675 diff --git a/crates/compose/tests/interrupt_surface.rs b/crates/compose/tests/interrupt_surface.rs new file mode 100644 index 0000000000..2131c52b79 --- /dev/null +++ b/crates/compose/tests/interrupt_surface.rs @@ -0,0 +1,36 @@ +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_compose::{composite_to_surface, composite_to_surface_with_interrupt}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Interrupt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[test] +fn controlled_composite_keeps_native_pixels_and_cancels_between_bands() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("bands", Size::new(16, 600), mode, depth); + let fmt = PixelFormat::new(mode, depth, true); + let mut layer = Layer::raster("pixels", fmt); + let (channels, colors) = (fmt.channels(), mode.color_channels()); + for y in 0..600 { + // Every row and channel differs, and every pixel is opaque: a band at the wrong + // offset or with a swapped channel changes the result. + let pixel: Vec = + (0..channels).map(|c| if c == colors { 1.0 } else { (1 + (y as usize % 29) * channels + c * 7) as f32 / 255.0 }).collect(); + layer.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, y, 16, 1), &pixel); + } + // What the layer holds: an opaque single raster layer composites to its own pixels. + let stored = layer.surface().unwrap().read_region(doc.bounds()); + doc.layers.push(layer); + let expected = composite_to_surface(&doc, doc.bounds(), fmt, None); + let actual = composite_to_surface_with_interrupt(&doc, doc.bounds(), fmt, None, &Interrupt::NONE).unwrap(); + assert_eq!(actual.read_region(doc.bounds()), expected.read_region(doc.bounds()), "{mode:?} {depth:?}"); + assert_eq!(actual.read_region(doc.bounds()), stored, "the composite keeps the layer's pixels: {mode:?} {depth:?}"); + let calls = AtomicUsize::new(0); + let cancelled = || calls.fetch_add(1, Ordering::Relaxed) >= 3; + let progress = |_| {}; + assert!(composite_to_surface_with_interrupt(&doc, doc.bounds(), fmt, None, &Interrupt::new(&cancelled, &progress)).is_err()); + } + } +} diff --git a/crates/doc/src/frame.rs b/crates/doc/src/frame.rs new file mode 100644 index 0000000000..251671111b --- /dev/null +++ b/crates/doc/src/frame.rs @@ -0,0 +1,55 @@ +//! Image-placeholder geometry. The outline is normalized to the unit square; its affine +//! placement is independent of the image's placement, so resizing a frame never resamples it. +use crate::{Path, ShapeLayer, ShapeStroke}; +use photocraft_geom::{Affine, Point, Rect}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Frame { + pub path: Path, + pub transform: Affine, + #[serde(default)] + pub stroke: Option, +} + +impl Frame { + pub fn outline(&self) -> Path { + self.path.transform(&self.transform) + } + pub fn corners(&self) -> [Point; 4] { + [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)].map(|p| self.transform.apply(p)) + } + pub fn bounds(&self) -> Rect { + let [a, b, c, d] = self.corners(); + let x0 = a.x.min(b.x).min(c.x).min(d.x); + let y0 = a.y.min(b.y).min(c.y).min(d.y); + let x1 = a.x.max(b.x).max(c.x).max(d.x); + let y1 = a.y.max(b.y).max(c.y).max(d.y); + Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32) + } + /// Reject invalid metadata before it can enter geometry or rasterization. + pub fn valid(&self) -> bool { + self.transform.m.iter().all(|n| n.is_finite() && n.abs() <= 600_000.0) + && self.transform.inverse().is_some() + && { + let [a, b, c, d, _, _] = self.transform.m; + (0.01..=300_000.0).contains(&a.hypot(b)) && (0.01..=300_000.0).contains(&c.hypot(d)) + } + && !self.path.is_empty() + && self.path.subpaths.len() <= 4096 + && self.path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(s.knots.len())) <= 100_000 + && self + .path + .subpaths + .iter() + .flat_map(|s| &s.knots) + .flat_map(|k| [k.anchor, k.in_ctrl, k.out_ctrl]) + .all(|p| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 10.0 && p.y.abs() <= 10.0) + && self.corners().iter().all(|p| p.x.abs() <= 600_000.0 && p.y.abs() <= 600_000.0) + && self.stroke.as_ref().is_none_or(|s| s.validate().is_ok() && (0.0..=1000.0).contains(&s.width) && s.opacity.is_finite()) + } + pub fn stroke_shape(&self) -> Option { + let stroke = self.stroke.clone()?; + Some(ShapeLayer { path: self.outline(), fill: None, stroke: Some(stroke), cache: None, psd_raw: None, live: None, density: 1.0, feather: 0.0 }) + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 915ddca7da..9cc2dd7b48 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,7 @@ pub mod analysis; pub mod blend_if; pub mod comps; pub mod effects; +pub mod frame; pub mod mode; pub mod pattern; pub mod slices; @@ -35,6 +36,7 @@ pub use effects::{ Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition, }; +pub use frame::Frame; pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode}; pub use pattern::Pattern; pub use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; @@ -562,6 +564,12 @@ impl SmartObject { perspective: None, } } + + /// Where the placed source lands (transform, perspective and warp), as JSON: the placement a + /// smart filter stored in document pixels (an imported Puppet Warp mesh) was made for. + pub fn placement_json(&self) -> serde_json::Value { + serde_json::json!({"transform": self.transform.m, "perspective": self.perspective, "warp": self.warp}) + } } #[derive(Clone, Debug, PartialEq)] @@ -646,6 +654,9 @@ pub struct Layer { pub effects: Effects, pub label: LabelColor, pub content: LayerContent, + /// Frame Tool container; its clipping outline and image have independent placements. Boxed: + /// inline it grows every layer by 224 bytes, and deep documents recurse on small stacks. + pub frame: Option>, /// Preserved PSD additional-layer-info blocks (key, data) for lossless /// round-trip of anything not modelled above: vector masks, blending /// options, layer version, and the original adjustment/fill blocks (the @@ -703,6 +714,7 @@ impl Layer { effects: Effects { enabled: true, ..Default::default() }, label: LabelColor::None, content, + frame: None, psd_blocks: Vec::new(), psd_id: None, fill_cache: None, @@ -1249,20 +1261,29 @@ impl Document { Some((p.children_mut()?, last)) } - /// Insert `layer` directly above the layer `above` (or at the top of the root if None). + /// Frames expose their image as an editable target, rather than as a layer-stack container. + /// New layers beside that image belong beside the frame, keeping the image slot intact. + pub fn insertion_path(&self, relative: LayerId) -> Option { + let mut path = self.path_of(relative)?; + let end = (1..path.len()).find(|n| path.get(..*n).and_then(|p| self.layer_at(p)).is_some_and(|l| l.frame.is_some())).unwrap_or(path.len()); + path.truncate(end); + Some(path) + } + + /// Insert `layer` above `above` (beside its frame for frame content), or at the root's top. pub fn insert_above(&mut self, above: Option, layer: Layer) -> LayerId { let id = layer.id; - match above.and_then(|a| self.path_of(a)).and_then(|path| self.siblings_mut(&path)) { + match above.and_then(|a| self.insertion_path(a)).and_then(|path| self.siblings_mut(&path)) { Some((sib, idx)) => sib.insert(idx + 1, layer), None => self.layers.push(layer), } id } - /// Insert `layer` directly below the layer `below` (or at the bottom of the root if None). + /// Insert `layer` below `below` (beside its frame for frame content), or at the root's bottom. pub fn insert_below(&mut self, below: Option, layer: Layer) -> LayerId { let id = layer.id; - match below.and_then(|b| self.path_of(b)).and_then(|path| self.siblings_mut(&path)) { + match below.and_then(|b| self.insertion_path(b)).and_then(|path| self.siblings_mut(&path)) { Some((sib, idx)) => sib.insert(idx, layer), None => self.layers.insert(0, layer), } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4c48527a77..b5b814b1a2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } rayon = { workspace = true } +chrono = { workspace = true } +web-time = { workspace = true } photocraft-paint = { workspace = true } photocraft-algo = { workspace = true } photocraft-text = { workspace = true } @@ -45,7 +47,9 @@ semver = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" -chrono = { workspace = true } +# `clock`: the local time that names History Options' snapshot on save (#2966); already in the +# tree through rmcp. The web build has no local clock here and names it "Snapshot N". +chrono = { workspace = true, features = ["clock"] } # `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a # file is that file (std's `MetadataExt::file_index` is unstable). diff --git a/crates/engine/examples/bench_brush_mips.rs b/crates/engine/examples/bench_brush_mips.rs new file mode 100644 index 0000000000..33ed7417eb --- /dev/null +++ b/crates/engine/examples/bench_brush_mips.rs @@ -0,0 +1,117 @@ +//! Sampled-tip preparation and live strokes on a materialized 24 MP layer. This harness also +//! builds against the pre-cache revision for alternating before/after runs on the same runner. +//! `cargo run --release -p photocraft-engine --example bench_brush_mips` +use std::hint::black_box; +use std::time::Instant; + +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_engine::{EngineError, Session, brush_cmds::LiveStroke}; +use photocraft_geom::Rect; +use photocraft_paint::{BrushContext, GrayTile, StrokePoint, TipShape}; +use serde_json::json; + +fn ms(start: Instant) -> f64 { + start.elapsed().as_secs_f64() * 1e3 +} + +fn median(v: &mut [f64]) -> f64 { + v.sort_by(f64::total_cmp); + v.get(v.len() / 2).copied().unwrap_or(0.0) +} + +fn main() -> Result<(), Box> { + for depth in [8, 16, 32] { + let sample = match depth { + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => SampleType::U8, + }; + let fmt = PixelFormat::RGBA8.with_sample(sample); + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth}))?; + s.execute("layer.new.layer", json!({}))?; + s.edit("materialize", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no pixel layer".into()))?; + surf.fill_rect(Rect::new(0, 0, 6000, 4000), &photocraft_raster::from_rgba(&fmt, [0.3, 0.6, 0.8, 0.7])); + Ok(()) + })?; + for side in [0, 1024, 2048, 5000] { + let original = (side > 0).then(|| GrayTile::from_fn(side, side, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0)); + for symmetry in ["off", "dual"] { + if symmetry == "off" { + if s.is_enabled("paint.symmetryDisable") { + s.execute("paint.symmetryDisable", json!({}))?; + } + } else { + s.execute("paint.setSymmetry", json!({"mode":symmetry}))?; + } + let (mut cold, mut warm, mut begin, mut draw, mut release) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut fingerprint = None; + // A fresh allocation makes every cold preparation a genuine miss. Copying + // samples and materializing the document are outside all measured stages. + for trial in 0..8 { + s.tools.brush.tip = original + .as_ref() + .map_or(TipShape::Round, |t| TipShape::Sampled(GrayTile { width: t.width, height: t.height, data: t.data.as_ref().to_vec().into() })); + let t = Instant::now(); + let ctx = BrushContext::new(&s.tools.brush); + let cold_ms = ms(t); + black_box(&ctx); + drop(ctx); + let t = Instant::now(); + let ctx = BrushContext::new(&s.tools.brush); + let warm_ms = ms(t); + black_box(&ctx); + drop(ctx); + let mut points: Vec<[f64; 3]> = (0..65).map(|i| [2600.0 + f64::from(i) * 5.0, 1800.0 + (f64::from(i) / 8.0).sin() * 30.0, 0.8]).collect(); + let mut params = json!({"points":[points[0]],"size":60,"flow":0.5,"smoothing":0.0,"seed":42}); + let t = Instant::now(); + let mut live = LiveStroke::begin(&s, ¶ms)?; + let begin_ms = ms(t); + let t = Instant::now(); + for batch in points.get(1..).unwrap_or_default().chunks(4) { + let batch: Vec<_> = batch.iter().map(|p| StrokePoint::new(p[0], p[1], p[2] as f32)).collect(); + live.push(&batch)?; + } + let draw_ms = ms(t); + let surface = live.doc.layers.iter().rev().find_map(|l| l.surface()).ok_or("no painted surface")?; + let hash = surface + .read_region(Rect::new(2500, 1700, 3500, 2300)) + .iter() + .fold(0xcbf2_9ce4_8422_2325u64, |hash, v| (hash ^ u64::from(v.to_bits())).wrapping_mul(0x100_0000_01b3)); + if fingerprint.is_some_and(|previous| previous != hash) { + return Err("stroke fingerprint changed across repeated trials".into()); + } + fingerprint = Some(hash); + params["points"] = json!(std::mem::take(&mut points)); + let t = Instant::now(); + s.offer_live_stroke(live)?; + let result = s.execute("paint.stroke", params)?; + let release_ms = ms(t); + if result.get("fromLive").is_none() { + return Err("commit did not take the live pixels".into()); + } + s.execute("edit.undo", json!({}))?; + if trial > 0 { + cold.push(cold_ms); + warm.push(warm_ms); + begin.push(begin_ms); + draw.push(draw_ms); + release.push(release_ms); + } + } + println!( + "tip={side} depth={depth} symmetry={symmetry} cold_ms={:.6} warm_ms={:.6} begin_ms={:.6} draw_ms={:.6} release_ms={:.6} fingerprint={:016x}", + median(&mut cold), + median(&mut warm), + median(&mut begin), + median(&mut draw), + median(&mut release), + fingerprint.unwrap_or(0) + ); + } + } + } + Ok(()) +} diff --git a/crates/engine/examples/bench_frame.rs b/crates/engine/examples/bench_frame.rs new file mode 100644 index 0000000000..55e85f80f8 --- /dev/null +++ b/crates/engine/examples/bench_frame.rs @@ -0,0 +1,58 @@ +//! 24 MP frame creation: vector commands versus the previous raster-mask construction. +//! `cargo run --release -p photocraft-engine --example bench_frame` +//! The baseline times only the former mask work; command times include history/validation. +use std::hint::black_box; +use std::time::Instant; + +use photocraft_doc::LayerMask; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::json; + +fn raster_mask(rect: Rect, ellipse: bool) -> LayerMask { + let mut mask = LayerMask::hide_all(); + if !ellipse { + mask.surface.fill_rect(rect, &[1.0]); + return mask; + } + let (rx, ry) = (f64::from(rect.width()) / 2., f64::from(rect.height()) / 2.); + let (cx, cy) = (f64::from(rect.x0) + rx, f64::from(rect.y0) + ry); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let covered = (0..16) + .filter(|i| { + let dx = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4. - cx) / rx; + let dy = (f64::from(y) + (f64::from(i / 4) + 0.5) / 4. - cy) / ry; + dx * dx + dy * dy <= 1. + }) + .count(); + if covered > 0 { + mask.surface.write_pixel(x, y, &[covered as f32 / 16.]); + } + } + } + mask.surface.prune(); + mask +} + +fn main() -> Result<(), Box> { + let mut session = Session::new(); + session.execute("file.new", json!({"width":6000,"height":4000}))?; + let rect = Rect::new(100, 100, 5900, 3900); + for (shape, ellipse) in [("rectangle", false), ("ellipse", true)] { + let mut before = f64::INFINITY; + let mut after = f64::INFINITY; + for _ in 0..3 { + let start = Instant::now(); + let mask = black_box(raster_mask(rect, ellipse)); + before = before.min(start.elapsed().as_secs_f64() * 1000.); + drop(mask); + let start = Instant::now(); + session.execute("layer.new.frame", json!({"x":100,"y":100,"width":5800,"height":3800,"shape":shape}))?; + after = after.min(start.elapsed().as_secs_f64() * 1000.); + session.undo(); + } + println!("{shape}, 6000x4000 canvas, best of 3: previous mask {before:.3} ms; vector command {after:.3} ms"); + } + Ok(()) +} diff --git a/crates/engine/examples/bench_grow_similar.rs b/crates/engine/examples/bench_grow_similar.rs new file mode 100644 index 0000000000..9368ebf02f --- /dev/null +++ b/crates/engine/examples/bench_grow_similar.rs @@ -0,0 +1,50 @@ +//! End-to-end timing of `select.grow` and `select.similar` on large documents. +//! `cargo run --release -p photocraft-engine --example bench_grow_similar [reps]` +use std::time::{Duration, Instant}; + +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::json; + +fn main() { + let reps: usize = std::env::args().nth(1).and_then(|v| v.parse().ok()).unwrap_or(3); + let mut s = Session::new(); + let (w, h) = (6000, 4000); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(200, 200, 1800, 1400), &[1.0, 0.0, 0.0, 1.0]); + bg.fill_rect(Rect::new(2200, 200, 3800, 1400), &[0.0, 0.0, 1.0, 1.0]); + bg.fill_rect(Rect::new(200, 1600, 1800, 2800), &[0.0, 1.0, 0.0, 1.0]); + bg.fill_rect(Rect::new(2200, 1600, 3800, 2800), &[0.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + + let cases = [ + ("grow contiguous 1000x800", "select.grow", json!({"tolerance": 10}), vec![json!({"x": 500, "y": 500, "width": 500, "height": 400})]), + ("grow large selection 1600x1200", "select.grow", json!({"tolerance": 10}), vec![json!({"x": 200, "y": 200, "width": 1600, "height": 1200})]), + ("similar single color", "select.similar", json!({"tolerance": 10}), vec![json!({"x": 500, "y": 500, "width": 100, "height": 100})]), + ( + "similar dual color (red + blue)", + "select.similar", + json!({"tolerance": 10}), + vec![json!({"x": 500, "y": 500, "width": 100, "height": 100}), json!({"x": 2500, "y": 500, "width": 100, "height": 100, "mode": "add"})], + ), + ]; + + for (label, cmd, params, initial_selections) in cases { + let mut best = Duration::MAX; + for _ in 0..reps { + s.execute("select.deselect", json!({})).ok(); + for init in &initial_selections { + s.execute("select.rect", init.clone()).unwrap(); + } + let t = Instant::now(); + s.execute(cmd, params.clone()).unwrap(); + best = best.min(t.elapsed()); + } + println!("{label:36} best of {reps}: {best:.2?}"); + } +} diff --git a/crates/engine/src/actions_record.rs b/crates/engine/src/actions_record.rs index 4bfb54befa..1076950e28 100644 --- a/crates/engine/src/actions_record.rs +++ b/crates/engine/src/actions_record.rs @@ -9,6 +9,8 @@ //! - any other `layer` id becomes the layer's name when the name is unique in the document, and //! `layer.select` always does (selecting is choosing a layer by name, as Photoshop records //! it); an ambiguous name keeps the id, which still replays on the recording document; +//! - a mask drop's destination (`layer.layerMask.moveTo`'s `to`) becomes the layer's name, with +//! `toOccurrence` when several layers share it; //! - the Type tool's keystrokes fold into the `type.create` that made the layer. On an existing //! layer, each replacement stays separate so replay keeps style runs between edited spans. //! @@ -69,6 +71,9 @@ pub(crate) fn portable(s: &Session, id: &str, mut params: Value, before: &Before object.insert("yPercent".into(), serde_json::json!(y_percent)); } } + if id == "layer.layerMask.moveTo" { + portable_mask_destination(doc, &mut params); + } let Some(target) = params.get("layer").and_then(Value::as_u64).map(LayerId) else { return params }; let Some(obj) = params.as_object_mut() else { return params }; let only_selected = before.selected.len() <= 1; @@ -85,6 +90,22 @@ pub(crate) fn portable(s: &Session, id: &str, mut params: Value, before: &Before params } +/// A mask drop's `to` id becomes the layer's name, and `toOccurrence` which of several layers of +/// that name it is, in document order. Moving a mask leaves the stack as it is, so the count means +/// the same layer when the step replays. It is taken from the document before the step: dropping +/// on the Background renames it "Layer 0". +fn portable_mask_destination(doc: &Document, params: &mut Value) { + let Some(to) = params.get("to").and_then(Value::as_u64).map(LayerId) else { return }; + let Some(layer) = doc.layer(to) else { return }; + let matches: Vec<_> = doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).map(|(_, _, l)| l.id).collect(); + let Some(occurrence) = matches.iter().position(|id| *id == to) else { return }; + let Some(fields) = params.as_object_mut() else { return }; + fields.insert("to".into(), Value::String(layer.name.clone())); + if matches.len() > 1 { + fields.insert("toOccurrence".into(), occurrence.into()); + } +} + /// The text layer a journaled `type.create` made or `type.edit` changed, so the keystrokes that /// follow fold into that entry only when they type into the same layer (the Type tool selects /// a clicked layer without a command, so the journal alone cannot tell). diff --git a/crates/engine/src/actions_record/tests.rs b/crates/engine/src/actions_record/tests.rs index dfffc2c08f..60c93f370a 100644 --- a/crates/engine/src/actions_record/tests.rs +++ b/crates/engine/src/actions_record/tests.rs @@ -100,6 +100,7 @@ fn a_background_job_journals_the_same_way() { fn frame_action_keeps_the_layer_it_wraps() { let mut s = session(); let picture = new_layer(&mut s, "Picture"); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); s.execute("actions.record", json!({"name": "Frame picture"})).unwrap(); s.execute("layer.new.frame", json!({"x": 1, "y": 1, "width": 20, "height": 20, "layer": picture})).unwrap(); s.execute("actions.stop", json!({})).unwrap(); @@ -108,6 +109,7 @@ fn frame_action_keeps_the_layer_it_wraps() { s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); new_layer(&mut s, "Spare"); let target = new_layer(&mut s, "Picture"); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); assert_ne!(target, picture, "the replay target has a different document-local id"); let played = s.execute("actions.play", json!({"action": "Frame picture"})).unwrap(); assert!(played.get("failed").is_none(), "{played}"); diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 5d58cfae88..bf5dc177f6 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -27,6 +27,18 @@ pub(crate) fn capacity(len: usize, purpose: &str) -> Result> { Ok(out) } +/// `Vec::push` that reports an allocation failure instead of aborting; each call is one +/// attempt for the test failpoints. +pub(crate) fn try_push(out: &mut Vec, value: T, purpose: &str) -> Result<()> { + #[cfg(test)] + if failpoint_hits() { + return Err(error(purpose, "not enough memory")); + } + out.try_reserve(1).map_err(|_| error(purpose, "the buffer could not grow"))?; + out.push(value); + Ok(()) +} + /// Check an operation boundary where downstream encoders/decoders allocate internally. /// The test-only failpoint lets command tests prove their transaction boundary. pub(crate) fn checkpoint(purpose: &str) -> Result<()> { diff --git a/crates/engine/src/brush_pressure_size_default_tests.rs b/crates/engine/src/brush_pressure_size_default_tests.rs new file mode 100644 index 0000000000..c09fe26313 --- /dev/null +++ b/crates/engine/src/brush_pressure_size_default_tests.rs @@ -0,0 +1,87 @@ +//! Pressure for Size starts off (#11809). The reference application leaves both pressure toggles +//! in the options bar unhighlighted after Reset Tool, so a fresh brush has neither on — and the +//! brush Reset Tool hands back is `BrushSettings::default()` plus smoothing +//! (`crates/ui-egui/src/canvas/paint_mouse.rs::fresh_brush`), so the default is what the tool +//! reset reports through `brush.get`. The options bar's two `circle-dot` buttons are drawn from +//! these same two fields (`crates/ui-egui/src/panels/options_bar.rs`), so off here means drawn +//! off there. + +use crate::Session; +use photocraft_paint::brush::BrushSettings; +use serde_json::{Value, json}; + +/// The brush a fresh session starts with: neither pressure toggle on. +#[test] +fn the_default_brush_has_neither_pressure_toggle_on() { + let b = BrushSettings::default(); + assert!(!b.pressure_size, "Pressure for Size is off by default, as the reference leaves it after Reset Tool (#11809)"); + assert!(!b.pressure_opacity, "Pressure for Opacity stays off"); +} + +/// What `brush.get` answers on a fresh session, which is what the options bar reads to draw the +/// two toggles. +#[test] +fn brush_get_reports_both_pressure_toggles_off() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let got = s.execute("brush.get", json!({})).unwrap(); + assert_eq!(got["pressureSize"], json!(false), "brush.get reports Pressure for Size off (#11809)"); + assert_eq!(got["pressureOpacity"], json!(false), "brush.get reports Pressure for Opacity off"); + // The session's own starting brush is the default brush at Photoshop's 10 % smoothing — the + // same brush Reset Tool hands back — so only the smoothing differs. + assert_eq!( + s.tools.brush, + BrushSettings { smoothing: photocraft_paint::brush::Smoothing { amount: 0.1, ..Default::default() }, ..BrushSettings::default() } + ); +} + +/// A brush that does not name the field — a preset saved before #11809, an older recorded +/// command — takes the off default rather than the old on one. +#[test] +fn a_brush_that_omits_the_field_takes_the_off_default() { + let b: BrushSettings = serde_json::from_value(json!({"size": 30.0, "hardness": 1.0})).unwrap(); + assert!(!b.pressure_size, "an omitted pressureSize reads as off (#11809)"); + assert!(!b.pressure_opacity, "an omitted pressureOpacity reads as off"); + assert_eq!(b.size, 30.0, "the fields that are there still apply"); +} + +/// Switching it on still works: `tools.setBrush` writes the field `brush.get` reads back, and the +/// same field the options-bar button is drawn from. +#[test] +fn pressure_for_size_still_switches_on_and_off() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"pressureSize": true}})).unwrap(); + assert!(s.tools.brush.pressure_size, "the toggle still turns on"); + assert_eq!(s.execute("brush.get", json!({})).unwrap()["pressureSize"], json!(true)); + s.execute("tools.setBrush", json!({"brush": {"pressureSize": false}})).unwrap(); + assert!(!s.tools.brush.pressure_size, "and off again"); + assert!(!s.tools.brush.pressure_opacity, "it never touches Pressure for Opacity"); +} + +/// One stroke of a pressure ramp (0.3 → 0.65 → 1.0) across a new empty layer with `brush`: the +/// layer's pixels and what the stroke covers. +fn ramp(brush: Value) -> (Vec, photocraft_geom::Rect) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[20.0, 20.0, 0.3], [40.0, 20.0, 0.65], [60.0, 20.0, 1.0]], "size": 24.0, "color": "#ff0000", "brush": brush})) + .unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (surf.read_region(st.doc.bounds()), surf.content_bounds()) +} + +/// Done when 2: a pen stroke with varying pressure and the default brush keeps a constant size, +/// so it paints exactly what the same stroke with Pressure for Size turned off does — and +/// something else with it turned on, which is what makes this say anything. +#[test] +fn a_default_brush_stroke_keeps_its_size_under_a_pressure_ramp() { + let (off, off_box) = ramp(json!({"spacing": 0.25, "pressureSize": false})); + let (on, on_box) = ramp(json!({"spacing": 0.25, "pressureSize": true})); + let (dflt, dflt_box) = ramp(json!({"spacing": 0.25})); + assert_ne!(on, off, "the ramp alone does not change the stroke: Pressure for Size has to"); + assert_eq!(dflt, off, "the default brush paints like Pressure for Size off (#11809)"); + assert_eq!(dflt_box, off_box, "and reaches as far under the whole ramp"); + assert_ne!(dflt_box, on_box, "the turned-on brush still tapers the stroke's ends"); +} diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 6276e432a3..93bc4654b9 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use photocraft_doc::{Document, Fill, Layer, LayerContent, TextLayer}; +use photocraft_doc::{Document, Fill, GradientStyle, Layer, LayerContent, TextLayer}; use photocraft_geom::{Affine, Point, Rect}; use photocraft_raster::Surface; @@ -52,8 +52,12 @@ fn map_angle(a: &Affine, deg: f32) -> f32 { } fn map_fill(a: &Affine, f: &mut Fill) { - if let Fill::Gradient { angle, offset, .. } = f { + if let Fill::Gradient { angle, offset, style, reverse, .. } = f { *angle = map_angle(a, *angle); + // An Angle gradient sweeps clockwise from its angle; a mirror turns the sweep around. + if *style == GradientStyle::Angle && a.m[0] * a.m[3] - a.m[1] * a.m[2] < 0.0 { + *reverse = !*reverse; + } // The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns // and flips, where the frame's sides swap with the axes. Scale (Image Size) resizes the // frame too, so only each axis's direction applies. @@ -138,6 +142,26 @@ pub(crate) fn retarget_type_resolution(doc: &mut Document, dpi: f32) { } retag_text(dpi, t); }); + // The styles travelling with the copies scale the same way: a style a copy brings to a + // document that lacks it defines the rewritten size, so the copy has no local override. + let st = &mut doc.text_styles; + let sets = st + .character + .iter_mut() + .map(|c| &mut c.attrs) + .chain(st.paragraph.iter_mut().chain([&mut st.basic]).flat_map(|p| [&mut p.para_attrs, &mut p.char_attrs])); + for attrs in sets { + for (key, v) in attrs.iter_mut() { + let Some(n) = v.as_f64().filter(|n| n.is_finite()) else { continue }; + let n = n as f32; + let scaled = match key.as_str() { + "size_pt" | "leading_pt" => point_size(n, k), + "baseline_shift_pt" | "first_line_indent_pt" | "start_indent_pt" | "end_indent_pt" | "space_before_pt" | "space_after_pt" => grown(n, k), + _ => continue, + }; + *v = serde_json::json!(scaled); + } + } } /// A positive size in points times `k`, kept finite and above zero (a tiny ratio must not make @@ -156,6 +180,14 @@ fn grown(v: f32, k: f64) -> f32 { /// the parts Free Transform (`transform_layer`) doesn't move are touched: unlinked vector masks, /// gradient fill angles, effect reference points and artboards. pub(crate) fn transform_layer_geometry(l: &mut Layer, a: &Affine, content: bool, dpi: f32) { + if content && let Some(f) = &mut l.frame { + f.transform = a.mul(&f.transform); + // Past `Frame::valid`'s bounds it would no longer load; the vector mask, moved below, + // keeps the clip as a plain masked group. + if !f.valid() { + l.frame = None; + } + } if let Some(vm) = l.vector_mask.as_mut() && (content || !vm.linked) { diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 78a7a141c3..beb04de97d 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -221,6 +221,32 @@ fn gradient_fill_offset_stays_a_fraction_of_the_frame() { assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}"); } +#[test] +fn turns_carry_an_angle_gradient_fill_with_its_pixels() { + // An Angle gradient sweeps clockwise: a mirror must reverse it, a turn must not. + for t in TURNS { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + let p = json!({"from": [20, 14], "to": [50, 4], "style": "angle", "dither": false}); + s.execute("gradient.fill.create", p).unwrap(); + let before = photocraft_compose::flatten(doc(&s)); + s.execute(command(t), json!({})).unwrap(); + let want = expected(&before, t, doc(&s).size.width); + let (mean, big) = diff(&photocraft_compose::flatten(doc(&s)).px, &want); + assert!(mean < 2.0 / 255.0 && big < 0.02, "{t:?}: mean {mean} big {big}"); + let flip = matches!(t, Turn::FlipHorizontal | Turn::FlipVertical); + assert_eq!(s.execute("gradient.fill.get", json!({})).unwrap()["reverse"], json!(flip), "{t:?}"); + // Turning back restores the fill model (Reverse toggled twice for flips). + s.execute(command(t), json!({})).unwrap(); + if matches!(t, Turn::Cw90 | Turn::Ccw90) { + s.execute(command(t), json!({})).unwrap(); + s.execute(command(t), json!({})).unwrap(); + } + let g = s.execute("gradient.fill.get", json!({})).unwrap(); + assert_eq!(g["reverse"], json!(false), "{t:?}: {g}"); + } +} + /// The shape's (fill, stroke) gradient angles after `cmd` on a shape whose fill and stroke both /// start as a 0° (left to right) gradient. fn shape_gradient_angles_after(cmd: &str, p: serde_json::Value) -> (f32, f32) { diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index 3b64b10ca2..8441a84fe7 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -85,7 +85,8 @@ pub(crate) fn copy(s: &mut Session, p: &Value) -> Result> { if bounds.is_empty() { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } - s.clipboard = Some(Clip { svg: None, components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) }); + s.clipboard = + Some(Clip { svg: None, components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id), board: None }); Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k}))) } @@ -197,6 +198,7 @@ pub(crate) fn copy_colors(s: &mut Session, p: &Value, colors: crate::color_chann resolution: Some(st.doc.resolution_dpi), source: Some(st.doc.id), components: (colors.len() > 1).then(|| colors.indices().collect()), + board: None, }); Ok(json!({"bounds":[area.x0,area.y0,area.width(),area.height()],"colors":colors.indices().collect::>()})) } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index df40333b90..c4addf4c22 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -67,6 +67,8 @@ pub mod color_cmds; pub mod color_to_alpha_cmds; pub mod comps_cmds; pub mod contour_lib_cmds; +#[cfg(test)] +mod count_delete_group_tests; pub mod cutout_cmds; pub mod distort_cmds; pub mod document_preset_cmds; @@ -83,7 +85,7 @@ pub mod fill_cmds; pub mod fill_key_cmds; pub mod float_cmds; pub mod font_library_cmds; -pub(super) mod frame_cmds; +pub mod frame_cmds; pub mod fx_view_cmds; pub mod fx_visibility_cmds; pub mod gallery_cmds; @@ -169,7 +171,7 @@ use enabled::{can_redo, can_undo, has_doc, has_layer, has_pixel_layer, has_pixel pub use layer_params::blend_from_str; pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, layer_panel_option, layer_param, mask_at_depth, mask_new_layer, selection_mask}; pub(crate) use layer_references::{recorded_drop, recorded_layer}; -use ops::{arrange, merge_group, set_mask, set_mask_and_deselect}; +use ops::{arrange, merge_group, move_mask, set_mask, set_mask_and_deselect}; pub(crate) use ops::{layer_copy, translate_layer}; use params::{bad, set_clipped}; pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; @@ -569,18 +571,26 @@ fn build() -> Vec { } let id = layer_param(s, p)?; let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); + // The dialog offers Duplicate Layer's own name: only a different one was typed (#9023). + let mut typed = None; let nid = s.edit("Duplicate Layer", |doc, active| { let mut dup = layer_copy(doc, id, add_copy)?; - if let Some(name) = &name { + if let Some(name) = name.as_ref().filter(|n| **n != dup.name) { dup.name.clone_from(name); + typed = Some(name.clone()); } let nid = doc.insert_above(Some(id), dup); + // A lone copy joins its original's link group, as in the reference application (#11217). + crate::layer_multi_cmds::relink_copies(doc, &[nid])?; if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; } *active = Some(nid); Ok(nid) })?; + if let Some(name) = typed { + crate::type_cmds::note_typed_name(s, nid, &name); + } Ok(json!({ "layer": nid.0 })) } ), @@ -722,6 +732,10 @@ fn build() -> Vec { } Ok(()) })?; + // The Layers panel's in-place rename: a name the user typed (#9023). + if let (Some(name), [id]) = (p.get("name").and_then(Value::as_str), ids.as_slice()) { + crate::type_cmds::note_typed_name(s, *id, name); + } Ok(Value::Null) } ), @@ -795,6 +809,15 @@ fn build() -> Vec { "Delete Layer Mask", None )), + cmd!( + "layer.layerMask.moveTo", + "Move Layer Mask", + [], + None, + r##"{"layer":id? (the mask's layer, default active),"to":id|name (the layer or group it goes to; a recorded step names it),"toOccurrence":n? (which of several layers named `to`, in document order),"copy":bool=false (⌥-drag: a copy goes and the original stays),"replace":bool=false (replace a mask already there; otherwise refused)} → {layer,to,copy}"##, + has_layer, + move_mask + ), cmd!("layer.mergeDown", "Merge Down", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; s.edit("Merge Down", |doc, active| { @@ -870,7 +893,7 @@ fn build() -> Vec { "Gradient…", ["Layer", "New Fill Layer"], None, - r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, + r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask),"stops"?,"transparency"?,"midpoints"?,"scale"?,"offset"?,"dither"?,"align"? (as gradient.fill.set; stops replace from/to)}"##, has_doc, |s, p| { let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; @@ -879,15 +902,26 @@ fn build() -> Vec { let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); + // The rest of a recorded Gradient Fill (a loaded action's Make): applied as + // `gradient.fill.set` would, in the same step. + let rest: serde_json::Map = ["stops", "transparency", "midpoints", "scale", "offset", "dither", "align"] + .iter() + .filter_map(|k| p.get(*k).map(|v| ((*k).to_string(), v.clone()))) + .collect(); + let (fg, bg) = (s.tools.foreground, s.tools.background); let id = s.edit("New Gradient Fill Layer", |doc, active| { - let fill = Fill::gradient( + let mut fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], angle, 1.0, style, reverse, - ) - .in_mode(doc.mode); + ); + if !rest.is_empty() { + let l = Layer::new(String::new(), LayerContent::Fill(fill.clone())); + fill = crate::gradient_fill_cmds::apply_set(&l, &fill, doc.bounds(), &Value::Object(rest.clone()), fg, bg)?; + } + let fill = fill.in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); @@ -942,7 +976,7 @@ fn build() -> Vec { "Move Layer", [], None, - r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels","selection":bool=false} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer; selection: the selection moves too, in the same step)"##, + r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels","selection":bool=false,"nest":bool=false} → null | {nested:[id]} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer; selection: the selection moves too, in the same step; nest: auto-nesting as the Move tool does it, a layer whose bounds centre lands on another artboard joins it and one whose centre is off every artboard leaves for the top level; a clipping base takes the layers clipped to it along and a clipped layer goes only where its base goes; artboards, the Background, empty layers, layers whose board doesn't change, groups that would nest too deep and layers locked with Prevent Auto-Nesting (on the layer, a group around it or the destination board) stay put, with their whole clipping group; nested lists the layers that changed board)"##, has_layer, crate::layer_multi_cmds::translate ), @@ -1205,7 +1239,7 @@ fn build() -> Vec { ( "colorLookup", "Color Lookup…", - r##"{"lut":"none|warm|cool|tealOrange|bleachBypass|fadedFilm|dayForNight|monoContrast|crossProcess"="none","file":text,"interpolation":"trilinear|tetrahedral"="trilinear","dither":bool=false,"data":json} (file: .cube/.3dl/.look path; data: file text + "fileName")"##, + r##"{"lut":"none|warm|cool|tealOrange|bleachBypass|fadedFilm|dayForNight|monoContrast|crossProcess"="none","file":text,"interpolation":"trilinear|tetrahedral"="trilinear","dither":bool=false,"data":text,"dataEncoding":"text|base64"="text","fileName":text?,"lutFormat":"cube|3dl|look"?} (file: LUT path; data: file text or encoded bytes; lutFormat overrides format detection)"##, ), ]; for &(kind, label, params) in ADJ { @@ -1216,7 +1250,7 @@ fn build() -> Vec { label, menu: &["Layer", "New Adjustment Layer"], shortcut: None, - params: Box::leak(format!(r#"{params} + {{{NEW_LAYER_PATH},"opacity":0..1=1}}"#).into_boxed_str()), + params: Box::leak(format!(r#"{params} + {{{NEW_LAYER_PATH},"opacity":0..1=1,"name":str?=the adjustment's name + number,"mode":blend mode name="normal" (not Pass Through),"clipToPrevious":bool=false}}"#).into_boxed_str()), enabled: has_doc, run: |s, p| { let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index ddeeb8c13f..3392c5361a 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -7,14 +7,15 @@ //! `edit.transform.againCopy`) into an action. A replayable command's journal entry replaces the //! entries of the commands it ran on its own behalf, so they are recorded once (#3067). Playback //! runs those steps with [`Session::execute`] and stops at the first error, leaving one history -//! step per step that ran. Steps switched off with `actions.toggle` (the Actions panel check -//! marks) are skipped by playback, Batch and droplets. +//! entry for the whole play by default (`actions.setHistory` opts into per-step history). +//! Steps switched off with `actions.toggle` (the Actions panel check marks) are skipped by +//! playback, Batch and droplets. //! //! An untrusted session installs [`Session::authorize`]. `actions.play` calls it for every nested //! step, because the outer `actions.play` id would otherwise hide a recorded `file.*` command from //! the top-level check. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, VecDeque}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -22,6 +23,11 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod history; +pub(crate) use history::ActionHistory; +pub use history::{ActionHistoryContinuation, finish_pending_history, suspend_history}; +pub(crate) use history::{before_edit as before_action_edit, begin_document_history, finish_document_history, history_rows, reconcile_history_removal}; + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -88,6 +94,9 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000; #[serde(default)] pub struct ActionState { pub list: Vec, + /// Painting-tool strokes are recorded only when the user enables them for this session. + #[serde(skip)] + pub record_tools: bool, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -138,7 +147,7 @@ pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value { } /// Resolve the recorded visibility target identically in engine and shell playback, and drop -/// the `.atn` event a Filter Gallery step remembers for Save Actions: the command, its journal +/// the `.atn` event or Custom descriptor remembered for Save Actions: the command, its journal /// entry (Last Filter), a smart filter and the step's dialog never see it. pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { let mut p = p.clone(); @@ -159,11 +168,16 @@ pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { } } } - if id.starts_with("filter.gallery.") + if (id.starts_with("filter.gallery.") || id == crate::atn_cmds::MENU_ITEM) && let Some(object) = p.as_object_mut() { object.remove(crate::atn_cmds::RECORDED_EVENT); } + if id == "filter.other.custom" + && let Some(object) = p.as_object_mut() + { + object.remove("__atnCustom"); + } if id == "layer.setProps" && let Some(target) = p.get("_actionLayer") { @@ -265,48 +279,177 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { Ok((action, from)) } -/// The actions that `steps` call through Play Action steps and the branches of Conditional steps, -/// followed transitively, in list order, so a droplet can run without the Actions panel that made -/// it (#2794, #6804). Index references in `steps` and in the copies become names, which resolve -/// without the rest of the list. A target that doesn't resolve is left alone: it fails at -/// playback, as it would have before. -pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec { - fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut Vec<(usize, usize)>) { - for (id, params) in steps.iter_mut() { - match id.as_str() { - "actions.play" => { - let Ok(i) = resolve(list, params, "actions.play") else { continue }; - if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { - obj.insert("action".into(), json!(action.name)); - } - queue.push((i, depth)); +/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched. +fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) { + let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect(); + while let Some((id, params)) = pending.pop() { + let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null()); + if id == "file.automate.batch" { + let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") }; + if let Some(key) = inline { + if scoped { + continue; } - // Conditional branches only take names, so there is nothing to rewrite. - "actions.conditional" => { - for key in ["then", "else"] { - let Some(name) = params.get(key).and_then(Value::as_str) else { continue }; - if let Some(i) = list.iter().position(|a| a.name == name) { - queue.push((i, depth)); + // A present invalid `steps` still wins over `action`, just as at runtime. + let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue }; + for step in steps.iter_mut().rev() { + let call = match step { + Value::Array(a) => { + let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue }; + a.get_mut(1).map(|params| (id, params)) } + Value::Object(o) => { + let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue }; + o.get_mut("params").map(|params| (id, params)) + } + // String steps and omitted params use `{}`, with no action target. + _ => None, + }; + if let Some(call) = call { + pending.push(call); } } - _ => {} + continue; } } + f(&id, params, scoped); } - let mut queue = Vec::new(); - targets(list, steps, 1, &mut queue); +} + +/// Shift numeric selectors in the session list, including calls in unscoped inline Batch steps. +pub(crate) fn shift_action_calls(list: &mut [Action], at: usize, count: usize) { + for action in list { + visit_calls(&mut action.steps, &mut |id, params, _| { + if !matches!(id, "actions.play" | "file.automate.batch") { + return; + } + let Some(value) = params.get("action") else { return }; + let Some(index) = num(value).and_then(|i| usize::try_from(i).ok()) else { return }; + if index >= at { + params["action"] = if let Some(fractional) = value.as_f64().filter(|_| value.is_f64()) { + json!(fractional + count as f64) + } else { + json!(index.saturating_add(count)) + }; + } + }); + } +} + +/// Capture transitive Play Action, Conditional and Batch dependencies for a standalone droplet +/// (#2794, #6804). Ordinary references become names; normalized copies keep source-list order. +/// A named Batch with its own action list selects an untouched source copy at an exported index, +/// after the ordinary copies. Inline embedded bodies retain their own references. Missing +/// targets remain deferred failures, as before. +pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec { + /// Resolve in the current namespace before capture rewrites the reference. + fn target(list: &[Action], params: &Value, id: &str) -> Result { + if id == "file.automate.batch" { crate::file_cmds::batch_action(list, params) } else { resolve(list, params, id) } + } + /// Capture ordinary calls transitively, but retain an untouched source for scoped named Batch. + fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut VecDeque<(usize, usize)>, scoped: &mut BTreeSet) { + if depth > 16 { + return; + } + visit_calls(steps, &mut |id, params, own_actions| match id { + "actions.play" | "file.automate.batch" => { + let Ok(i) = target(list, params, id) else { return }; + // The reference is rewritten by `remap` once the exported list is known. + if own_actions { + scoped.insert(i); + } else { + queue.push_back((i, depth)); + } + } + "actions.conditional" => { + for key in ["then", "else"] { + let Some(name) = params.get(key).and_then(Value::as_str) else { continue }; + if let Some(i) = list.iter().position(|a| a.name == name) { + queue.push_back((i, depth)); + } + } + } + _ => {} + }); + } + let mut queue = VecDeque::new(); + let mut scoped = BTreeSet::new(); + targets(list, steps, 1, &mut queue, &mut scoped); let mut found = std::collections::BTreeMap::new(); - while let Some((i, depth)) = queue.pop() { - // Playback stops at 16 levels, so deeper calls never run. + // Visit shallow calls first, so a deeper path cannot hide a reachable dependency. + while let Some((i, depth)) = queue.pop_front() { if depth > 16 || found.contains_key(&i) { continue; } let Some(mut action) = list.get(i).cloned() else { continue }; - targets(list, &mut action.steps, depth + 1, &mut queue); + // Disabled steps never play, so the actions they call are not dependencies. + for (step, call) in action.steps.iter_mut().enumerate() { + if !action.disabled.contains(&step) { + targets(list, std::slice::from_mut(call), depth + 1, &mut queue, &mut scoped); + } + } found.insert(i, action); } - found.into_values().collect() + // Where each captured source action lands in the exported list (source order). + let exported: std::collections::BTreeMap = found.keys().enumerate().map(|(at, &i)| (i, at)).collect(); + let mut found: Vec<_> = found.into_values().collect(); + let names: Vec = found.iter().map(|a| a.name.clone()).collect(); + let mut originals = Vec::new(); + let mut slots = std::collections::BTreeMap::new(); + for i in scoped { + if let Some(action) = list.get(i) { + slots.insert(i, found.len() + originals.len()); + originals.push(action.clone()); + } + } + // A named Batch selects from the caller before switching to its embedded list. Its source + // must keep raw references, even when normal Play needs a normalized copy of the same action. + let final_len = found.len() + originals.len(); + let mut remap = |id: &str, params: &mut Value, scoped: bool| { + if !matches!(id, "actions.play" | "file.automate.batch") { + return; + } + if scoped + && let Ok(i) = target(list, params, id) + && let Some(slot) = slots.get(&i) + { + params["action"] = json!(slot); + // The pinned source slot identifies its set; a numeric Batch choice cannot carry a named set. + if let Some(object) = params.as_object_mut() { + object.remove("set"); + } + return; + } + // An ordinary call becomes the captured action's name, which reads well and resolves to + // it while the name is unique in the exported list. Two captured actions of one name (in + // different sets) would both resolve to the first, so such a call keeps its exported index. + if !scoped && let Ok(i) = target(list, params, id) { + let Some(name) = list.get(i).map(|a| a.name.clone()) else { return }; + match exported.get(&i) { + Some(&at) if names.iter().filter(|n| **n == name).count() > 1 => { + params["action"] = json!(at); + if let Some(object) = params.as_object_mut() { + object.remove("set"); + } + } + _ => params["action"] = json!(name), + } + return; + } + // Appended source copies must not make an originally missing numeric target valid. + if let Some(i) = params.get("action").and_then(num).and_then(|i| usize::try_from(i).ok()) + && i >= list.len() + && i < final_len + { + params["action"] = json!(final_len); + } + }; + visit_calls(steps, &mut remap); + for action in &mut found { + visit_calls(&mut action.steps, &mut remap); + } + found.extend(originals); + found } fn always(_: &Session) -> std::result::Result<(), String> { @@ -363,7 +506,7 @@ fn from_step(p: &Value, len: usize) -> Result { fn list(s: &mut Session, _p: &Value) -> Result { let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); - Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording})) + Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording, "toolRecording": s.actions.record_tools})) } fn get(s: &mut Session, p: &Value) -> Result { @@ -426,7 +569,32 @@ const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"]; /// Replayable journal entries not yet committed to this action while recording. pub fn pending_steps(s: &Session, action: usize) -> impl Iterator { let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from); - s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p)) + s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p) && (s.actions.record_tools || !tool_stroke(id))) +} + +/// The point-based painting tools committed by the canvas; fills and gradients remain commands. +pub(crate) fn tool_stroke(id: &str) -> bool { + matches!( + id, + "paint.stroke" + | "paint.pencil" + | "paint.mixerBrush" + | "paint.colorReplacement" + | "paint.blur" + | "paint.sharpen" + | "paint.smudge" + | "paint.dodge" + | "paint.burn" + | "paint.sponge" + | "paint.cloneStamp" + | "paint.healingBrush" + | "paint.historyBrush" + | "paint.artHistoryBrush" + | "paint.patternStamp" + | "paint.backgroundEraser" + | "paint.spotHealing" + | "paint.remove" + ) } pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { @@ -525,6 +693,7 @@ pub fn saves_smart_contents(s: &Session, id: &str) -> bool { id == "file.save" && s.is_enabled(SAVE_CONTENTS) } +/// Run enabled steps from `from`, retaining child results when nested work fails. fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { let mut ran = 0u64; let mut failed = None; @@ -533,6 +702,8 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option (u64, Option { if let Some(error) = nested_failure(id, &value) { - failed = Some(json!({"step": i, "id": id, "error": error})); + // Like Scripts, retain the child result so partial Batch outputs remain visible. + let mut failure = json!({"step": i, "id": id, "error": error}); + failure["result"] = value; + failed = Some(failure); break; } ran += 1; @@ -565,6 +739,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option Result> { flush_recording(s)?; if s.actions.playing == 0 { + history::begin(s, &action.name); s.actions.playback_steps = 0; } let recording = s.actions.recording.take(); @@ -573,9 +748,13 @@ pub fn begin_playback(s: &mut Session, action: &Action) -> Result, action: &Action, from: usize) { s.actions.playback_stack.pop(); s.actions.playing = s.actions.playing.saturating_sub(1); + if s.actions.playing == 0 { + history::finish_playback(s); + } if let Some((_, idx)) = recording { restore_recording(s, idx, &action.name, from); } @@ -591,9 +770,43 @@ pub fn restore_recording(s: &mut Session, idx: usize, action: &str, from: usize) s.journal.push(("actions.play".into(), params)); } -/// Nested playback returns a structured failure even though the command itself succeeded. +/// What stops the caller of a Batch even where per-file errors don't (a direct Batch in a script +/// or a CLI command list): the script ran out of steps inside it, it was cancelled, or its +/// requested error log could not be written. +pub fn batch_stop(value: &Value) -> Option { + if let Some(error) = value.get("scriptLimitError").and_then(Value::as_str) { + return Some(error.to_string()); + } + if value.get("cancelled") == Some(&Value::Bool(true)) { + return Some("Batch was cancelled".into()); + } + value.get("errorLogError").and_then(Value::as_str).map(|error| format!("Batch error log failed: {error}")) +} + +/// Nested playback and Batch return structured failures even though the command succeeded. /// Promote it to the caller's failed step so no later parent steps run after a child fails. +/// Batch text summarizes counts and the first failure; its full result retains every file outcome. pub fn nested_failure(id: &str, value: &Value) -> Option { + if id == "file.scripts.browse" && value.get("ok") == Some(&Value::Bool(false)) { + let error = value + .get("results") + .and_then(Value::as_array) + .and_then(|results| results.iter().find_map(|result| result.get("error").and_then(Value::as_str))) + .unwrap_or("unknown error"); + return Some(format!("script failed: {error}")); + } + if id == "file.automate.batch" { + let errors = value.get("errors").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + let Some(first) = errors.first() else { + // Every input succeeded, yet the run was stopped or its requested log is missing: + // later parent steps must not run as if the Batch had finished. + return batch_stop(value); + }; + let error = first.get("error")?.as_str()?; + let file = first.get("file").or_else(|| first.get("document")).and_then(Value::as_str).unwrap_or("unknown input"); + let saved = value.get("files").and_then(Value::as_array).map_or(0, Vec::len); + return Some(format!("Batch: {} file(s) failed, {saved} saved; first failure `{file}`: {error}", errors.len())); + } (id == "actions.play" || id == "actions.conditional").then(|| value.get("failed")).flatten().map(|failure| { format!( "action `{}` step {} failed: {}", @@ -760,7 +973,7 @@ fn toggle(s: &mut Session, p: &Value) -> Result { /// Prefix of the persisted shortcut override that plays a named action (`actions.play:`). pub const PLAY_SHORTCUT_PREFIX: &str = "actions.play:"; -/// Rename an action in place. Nested `actions.play` steps and `actions.conditional` branches +/// Rename an action in place. Nested `actions.play` steps, named Batch steps and `actions.conditional` branches /// that call it by name, and its function-key binding, follow the new name so nothing silently /// stops working. fn rename(s: &mut Session, p: &Value) -> Result { @@ -780,11 +993,18 @@ fn rename(s: &mut Session, p: &Value) -> Result { return Err(bad("actions.rename", "an action with this name already exists")); } let pending = s.actions.recording.map_or(s.journal.len(), |(start, _)| start); + // A loaded library can hold two actions of one name (in different sets): a call follows the + // rename only if it resolved to this action before it, so one reaching the other stays put. + let before = s.actions.list.clone(); + let by_name = resolve(&before, &json!({"action": old}), "actions.rename").ok() == Some(idx); let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); for (id, params) in calls { let keys: &[&str] = match id.as_str() { - "actions.play" => &["action"], - "actions.conditional" => &["then", "else"], + "actions.play" if by_name => &["action"], + "actions.conditional" if by_name => &["then", "else"], + // A named Batch selects from the caller's list, by name alone or within its set; + // explicit `steps` run instead of any named action, so those are left alone. + "file.automate.batch" if params.get("steps").is_none() && crate::file_cmds::batch_action(&before, params).ok() == Some(idx) => &["action"], _ => &[], }; for key in keys { @@ -827,8 +1047,87 @@ fn set_color(s: &mut Session, p: &Value) -> Result { Ok(json!({"action": name, "color": color})) } +/// Active playback and retained dialog continuations keep their chosen history policy. +pub(crate) fn history_policy_in_use(s: &Session) -> bool { + s.actions.playing != 0 || s.action_history.is_some() || s.paused_action_history.iter().any(|lease| lease.strong_count() != 0) +} + +/// Create a single history state alone; `actions.setPlayback` sets it with the performance mode. +fn set_history(s: &mut Session, p: &Value) -> Result { + not_playing(s, "actions.setHistory")?; + if history_policy_in_use(s) { + return Err(bad("actions.setHistory", "cannot change action history settings during playback")); + } + let enabled = p.get("singleState").and_then(Value::as_bool).ok_or_else(|| bad("actions.setHistory", "singleState must be a boolean"))?; + s.edit_prefs(|prefs| prefs.actions.create_single_history_state = enabled); + Ok(json!({"singleState":enabled})) +} + +/// Playback Options: `performance`, Pause For's whole `seconds` (1 to 60) and `singleState`, each +/// optional, all validated before any is stored, and saved in preferences. `{}` reads them. +fn set_playback(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "actions.setPlayback"; + not_playing(s, CMD)?; + if !p.is_object() { + return Err(bad(CMD, "params must be an object")); + } + let performance = match p.get("performance") { + None => None, + Some(v) => { + Some(v.as_str().and_then(crate::prefs::PlaybackPerformance::parse).ok_or_else(|| bad(CMD, "performance must be accelerated, step or pause"))?) + } + }; + let range = crate::prefs::Actions::PAUSE_SECONDS; + let seconds = match p.get("seconds") { + None => None, + Some(v) => Some( + v.as_u64() + .and_then(|n| u8::try_from(n).ok()) + .filter(|n| range.contains(n)) + .ok_or_else(|| bad(CMD, format!("seconds must be a whole number from {} to {}", range.start(), range.end())))?, + ), + }; + let single = match p.get("singleState") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "singleState must be a boolean"))?), + }; + if single.is_some_and(|single| single != s.prefs().actions.create_single_history_state) && history_policy_in_use(s) { + return Err(bad(CMD, "cannot change action history settings during playback")); + } + if performance.is_some() || seconds.is_some() || single.is_some() { + s.edit_prefs(|prefs| { + let a = &mut prefs.actions; + a.performance = performance.unwrap_or(a.performance); + a.pause_seconds = seconds.unwrap_or(a.pause_seconds); + a.create_single_history_state = single.unwrap_or(a.create_single_history_state); + }); + } + let a = &s.prefs().actions; + Ok(json!({"performance": a.performance.name(), "seconds": a.pause_seconds(), "singleState": a.create_single_history_state})) +} + pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.setHistory", + label: "Set Actions History", + menu: &[], + shortcut: None, + params: r##"{"singleState":bool} → {singleState}. Persist Create a single history state (default true). Playback Options can use this setting without changing its performance mode."##, + enabled: always, + run: set_history, + journal: false, + }, + CommandSpec { + id: "actions.setPlayback", + label: "Playback Options", + menu: &[], + shortcut: None, + params: r##"{"performance":"accelerated"|"step"|"pause"?, "seconds":1..60?, "singleState":bool?} → {performance, seconds, singleState}. The Actions panel menu's Playback Options, saved in preferences (`actions.performance`, `actions.pauseSeconds`, `actions.createSingleHistoryState`). Step by Step and Pause For pace a person's Play in the app, about one second between steps plus Pause For's whole seconds; actions.play from agents, the CLI and Batch always runs at once. Omitted keys keep their value; {} reads them. Every value is checked before any is stored."##, + enabled: always, + run: set_playback, + journal: false, + }, CommandSpec { id: "actions.move", label: "Move Action or Step", @@ -844,7 +1143,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null, toolRecording:bool}", enabled: always, run: list, journal: false, @@ -884,7 +1183,7 @@ pub fn specs() -> Vec { label: "Play Action", menu: &[], shortcut: None, - params: r##"{"action":name|index, "from":step?} → {action, ran, failed?:{step, id, error}}. step and from are 0-based; steps switched off with actions.toggle are skipped. Stops at the first error (the command still returns ok, with failed set) and leaves one history step per step that ran. Nested calls are supported up to 16 levels; cycles are rejected. Recording stores a named call rather than its expanded commands. Each step is checked with Session::authorize when one is installed."##, + params: r##"{"action":name|index, "from":step?} → {action, ran, failed?:{step, id, error}}. step and from are 0-based; steps switched off with actions.toggle are skipped. Stops at the first error (the command still returns ok, with failed set) and groups document edits in one Action: history entry by default; actions.setHistory can retain per-step history. Nested calls are supported up to 16 levels; cycles are rejected. Recording stores a named call rather than its expanded commands. Each step is checked with Session::authorize when one is installed."##, enabled: always, run: play, journal: false, @@ -935,6 +1234,9 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; +#[cfg(test)] +mod playback_tests; + #[cfg(test)] mod security_tests { use super::*; diff --git a/crates/engine/src/cmd/actions_cmds/history.rs b/crates/engine/src/cmd/actions_cmds/history.rs new file mode 100644 index 0000000000..c6f5d52197 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/history.rs @@ -0,0 +1,465 @@ +//! Consolidate document history after commands finish their own atomic transactions. +use super::{EngineError, Result, Session}; +use photocraft_doc::{DocId, Document}; +use photocraft_ops::{History, LayerTarget}; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Clone, Debug)] +struct BeforeDocument { + id: DocId, + doc: Arc, + history: History, + layers: LayerTarget, + coalesce: Option, + saved_revision: u64, + clean: bool, +} + +impl BeforeDocument { + /// Capture the complete document boundary, including clean state and targeted layers. + fn capture(st: &crate::DocState) -> Self { + Self { + id: st.doc.id, + doc: st.doc.clone(), + history: st.history.clone(), + layers: st.layer_target(), + coalesce: st.coalesce.clone(), + saved_revision: st.saved_revision, + clean: !st.is_dirty(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ActionHistory { + pub name: String, + external: bool, + before: Vec, +} + +/// The outer play owns one lazy scope; nested plays share its document snapshots. +pub(super) fn begin(s: &mut Session, name: &str) { + if s.action_history.is_none() && s.prefs().actions.create_single_history_state { + s.action_history = Some(ActionHistory { name: name.into(), external: false, before: Vec::new() }); + } +} + +/// Capture each document lazily, before its first ordinary or compound edit. Commands retain +/// their own records, purge and rollback semantics until the outer action finishes. +pub(crate) fn before_edit(s: &mut Session) { + let Some(group) = &s.action_history else { return }; + let Some(st) = s.active() else { return }; + let previous = group.before.iter().position(|before| before.id == st.doc.id); + if previous.is_some_and(|i| !st.history.is_cleared() && !st.history.was_purged_since(&group.before[i].history)) { + return; + } + let before = BeforeDocument::capture(st); + if let Some(group) = &mut s.action_history { + match previous { + Some(i) => group.before[i] = before, + None => group.before.push(before), + } + } + if let Some(st) = s.active_mut() { + st.coalesce = None; + st.history.begin_visibility_capture(&[]); + } +} + +/// Rows visible just before the user deletes history, distinct from internal compound purges. +pub(crate) fn history_rows(s: &Session) -> Vec { + if s.action_history.is_none() { + return Vec::new(); + } + let Some(st) = s.active() else { return Vec::new() }; + st.history.row_ids().collect() +} + +/// Apply only the user's actual row removals to the existing action boundary immediately. +pub(crate) fn reconcile_history_removal(s: &mut Session, rows: &[u64]) { + let Some(st) = s.active() else { return }; + let Some(group) = &s.action_history else { return }; + let Some(index) = group.before.iter().position(|before| before.id == st.doc.id) else { return }; + let prior = &group.before[index].history; + let after: HashSet<_> = st.history.row_ids().collect(); + let baseline: HashSet<_> = prior.row_ids().collect(); + let removed: Vec<_> = rows.iter().copied().filter(|id| !after.contains(id) && baseline.contains(id)).collect(); + if removed.is_empty() { + return; + } + if prior.row_id(prior.past_len()).is_some_and(|id| removed.contains(&id)) { + // The original pixels themselves were deleted. Only the post-delete document may + // start a later grouped suffix; its accumulator excludes edits before that boundary. + let mut before = BeforeDocument::capture(st); + before.history.end_visibility_capture(); + if let Some(group) = &mut s.action_history { + group.before[index] = before; + } + if let Some(st) = s.active_mut() { + st.coalesce = None; + st.history.begin_visibility_capture(&[]); + } + } else if let Some(group) = &mut s.action_history { + let history = &mut group.before[index].history; + for id in removed { + let Some(row) = history.row_of(id) else { continue }; + let current = history.past_len(); + if row < current { + history.remove(row); + } else if row > current { + history.remove_later(row - current - 1); + } + } + } +} + +/// Fold ordinary edits while retaining real Clear History and irreversible purge boundaries. +fn consolidate(s: &mut Session, group: &ActionHistory) { + for before in &group.before { + let Some(st) = s.docs.iter_mut().find(|st| st.doc.id == before.id) else { continue }; + if st.history.was_purged_since(&before.history) { + st.history.end_visibility_capture(); + continue; + } + let mut history = before.history.clone(); + // Discarded command-local rows may have been visible while a dialog was open. Their + // identifiers must not be allocated to a different state after consolidation. + history.reserve_ids_from(&st.history); + history.max_states = st.history.max_states; + history.max_bytes = st.history.max_bytes; + history.non_linear = st.history.non_linear; + if st.doc.as_ref() == before.doc.as_ref() { + st.doc = before.doc.clone(); + st.coalesce = before.coalesce.clone(); + if before.clean && st.saved_revision == before.saved_revision { + st.saved_revision = st.revision; + } + } else { + let visibility = st.history.captured_visibility().unwrap_or(st.history.current_visibility()).to_vec(); + history.set_current_layers(before.layers.clone()); + history.record(format!("Action: {}", group.name), before.doc.clone(), st.layer_target()); + history.add_current_visibility(&visibility); + st.coalesce = None; + } + history.preserve_action_clear(&st.history); + if let Some(source) = history.preserve_action_brush_source(&st.history) { + let (source, restore_on_clear_undo) = match source { + photocraft_ops::ActionBrushSource::Current(source) => (source, false), + photocraft_ops::ActionBrushSource::Cleared(source) => (source, true), + }; + // The selected row will no longer exist, but the History Brush must still paint its + // exact pixels. Snapshots already retain source pixels without adding an Undo row. + let id = st.next_snapshot; + st.next_snapshot = id.saturating_add(1); + st.snapshots.push(crate::history_cmds::Snapshot { id, name: format!("{}: {}", group.name, source.label), doc: source.doc, layers: source.layers }); + if restore_on_clear_undo { + history.set_cleared_brush_snapshot(id); + } else { + history.set_action_brush_snapshot(id); + } + } + st.history = history; + st.trim_history(); + } +} + +/// Publish all edited documents once the outer play, including its failure prefix, ends. +pub(super) fn finish(s: &mut Session) { + if let Some(group) = s.action_history.take() { + consolidate(s, &group); + } +} + +/// Batch owns its scope across nested plays without adding an artificial playback depth. +pub(crate) fn begin_document_history(s: &mut Session, name: &str) { + begin(s, name); + if let Some(group) = &mut s.action_history { + group.external = true; + } +} + +/// Finalize the per-document scope explicitly owned by Batch, including a completed prefix on failure. +pub(crate) fn finish_document_history(s: &mut Session) { + finish(s); +} + +/// Finish an ordinary outer playback without closing a scope owned by the surrounding Batch. +pub(super) fn finish_playback(s: &mut Session) { + if s.action_history.as_ref().is_none_or(|group| !group.external) { + finish(s); + } +} + +#[derive(Clone, Debug, PartialEq)] +struct HistoryBinding { + past: usize, + rows: Vec<(Option, String)>, + source: Option, + source_snapshot: Option, + cleared: bool, +} + +impl HistoryBinding { + /// Rows and source identity detect history changes that leave document pixels untouched. + fn of(history: &History) -> Self { + let labels = history.entries().into_iter().chain(history.redo_labels().map(str::to_string)); + Self { + past: history.past_len(), + rows: labels.enumerate().map(|(i, label)| (history.row_id(i), label)).collect(), + source: history.brush_source(), + source_snapshot: history.brush_source_snapshot(), + cleared: history.is_cleared(), + } + } +} + +#[derive(Clone, Debug)] +struct PausedDocument { + id: DocId, + doc: Arc, + history: HistoryBinding, + source_snapshot: Option, +} + +/// Exact document/history binding of a dialog continuation. Never stored in dialog JSON. +#[derive(Clone, Debug)] +pub struct ActionHistoryContinuation { + _policy_lease: Arc<()>, + /// Set by the first successful `resume`, shared by every clone: a kept clone can't reopen a + /// scope that already ran (and may have finished). + resumed: Arc, + group: Option, + active: Option, + documents: Vec, +} + +impl ActionHistoryContinuation { + /// Resume before the dialog's edit; intervening edits must never join the original play. + pub fn resume(&self, s: &mut Session) -> Result<()> { + if s.action_history.is_some() || s.active().map(|st| st.doc.id) != self.active { + return Err(EngineError::Other("the paused action's document is no longer current".into())); + } + for before in &self.documents { + let Some(st) = s.docs.iter().find(|st| st.doc.id == before.id) else { + return Err(EngineError::Other("a paused action's document was closed".into())); + }; + if !Arc::ptr_eq(&st.doc, &before.doc) || HistoryBinding::of(&st.history) != before.history || st.source_snapshot != before.source_snapshot { + return Err(EngineError::Other("the paused action's document or history changed".into())); + } + } + if self.resumed.swap(true, Ordering::SeqCst) { + return Err(EngineError::Other("the paused action was already resumed".into())); + } + if let Some(group) = &self.group { + for before in &group.before { + if let Some(st) = s.docs.iter_mut().find(|st| st.doc.id == before.id) { + let prefix = st.history.current_visibility().to_vec(); + st.history.begin_visibility_capture(&prefix); + } + } + } + s.action_history = self.group.clone(); + Ok(()) + } + + /// Rebind a completed nonmodal dialog edit, leaving open-dialog time outside its scope. + /// Only the scope resumed from this continuation may be taken; a parent remains untouched. + pub fn suspend_after_edit(&self, s: &mut Session) -> Result { + if s.actions.playing != 0 || s.active().map(|st| st.doc.id) != self.active { + return Err(EngineError::Other("the dialog's action scope is no longer current".into())); + } + let active = self.documents.iter().find(|doc| Some(doc.id) == self.active); + let at_dialog_boundary = |before: &BeforeDocument| active.is_some_and(|doc| before.id == doc.id && Arc::ptr_eq(&before.doc, &doc.doc)); + let owned = match (&self.group, &s.action_history) { + (None, None) => true, + (Some(expected), Some(current)) => { + expected.name == current.name + && expected.external == current.external + && current.before.len() >= expected.before.len() + && current.before.len() <= expected.before.len().saturating_add(1) + && expected + .before + .iter() + .zip(¤t.before) + .all(|(before, now)| before.id == now.id && (Arc::ptr_eq(&before.doc, &now.doc) || at_dialog_boundary(now))) + && current.before.iter().skip(expected.before.len()).all(at_dialog_boundary) + } + _ => false, + }; + if !owned { + return Err(EngineError::Other("the dialog no longer owns the active action scope".into())); + } + Ok(Self::capture(s)) + } + + /// Publish the prefix and capture the same document/history/source binding for both callers. + fn capture(s: &mut Session) -> Self { + s.paused_action_history.retain(|lease| lease.strong_count() != 0); + let policy_lease = Arc::new(()); + s.paused_action_history.push(Arc::downgrade(&policy_lease)); + let group = s.action_history.take(); + if let Some(group) = &group { + consolidate(s, group); + } + let active = s.active().map(|st| st.doc.id); + let documents = s + .docs + .iter() + .filter(|st| Some(st.doc.id) == active || group.as_ref().is_some_and(|g| g.before.iter().any(|b| b.id == st.doc.id))) + .map(|st| PausedDocument { id: st.doc.id, doc: st.doc.clone(), history: HistoryBinding::of(&st.history), source_snapshot: st.source_snapshot }) + .collect(); + Self { _policy_lease: policy_lease, resumed: Arc::new(AtomicBool::new(false)), group, active, documents } + } +} + +/// Publish the completed prefix, then release the active scope while a dialog is open. +pub fn suspend_history(s: &mut Session) -> Option { + if s.actions.playing != 1 { + return None; + } + Some(ActionHistoryContinuation::capture(s)) +} + +/// End a dialog scope if its command or continuation fails before playback starts. +pub fn finish_pending_history(s: &mut Session) { + if s.actions.playing == 0 { + finish_playback(s); + } +} + +#[cfg(test)] +mod tests { + /// Batch 16 review: a kept clone can't reopen a scope its continuation already resumed and + /// finished, even with the document and history unchanged. + #[test] + fn a_continuation_resumes_once() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":true})).unwrap(); + begin_document_history(&mut s, "Spell"); + let continuation = ActionHistoryContinuation::capture(&mut s); + let kept = continuation.clone(); + continuation.resume(&mut s).unwrap(); + finish_document_history(&mut s); + assert!(kept.resume(&mut s).is_err(), "the clone shares the resumed mark"); + assert!(s.action_history.is_none()); + } + + use super::*; + use serde_json::json; + + #[test] + /// Keep the policy fixed until every clone of a paused continuation releases its lease, in either history mode. + fn history_policy_stays_fixed_until_all_paused_continuations_are_released() { + for single in [false, true] { + let mut s = Session::new(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Paused"); + let continuation = ActionHistoryContinuation::capture(&mut s); + let copy = continuation.clone(); + assert!(s.execute("actions.setHistory", json!({"singleState":!single})).is_err()); + assert_eq!(s.prefs().actions.create_single_history_state, single); + continuation.resume(&mut s).unwrap(); + finish_document_history(&mut s); + drop(continuation); + assert!(s.execute("actions.setHistory", json!({"singleState":!single})).is_err()); + drop(copy); + s.execute("actions.setHistory", json!({"singleState":!single})).unwrap(); + assert_eq!(s.prefs().actions.create_single_history_state, !single); + } + } + + #[test] + /// Generic preference set/reset routes must preserve the same paused-policy guard as actions.setHistory. + fn generic_preference_updates_cannot_change_a_paused_history_policy() { + for single in [false, true] { + let mut s = Session::new(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Paused"); + let continuation = ActionHistoryContinuation::capture(&mut s); + for params in [ + json!({"path":"actions.createSingleHistoryState","value":!single}), + json!({"path":"actions","value":{"createSingleHistoryState":!single}}), + json!({"path":"","value":{"actions":{"createSingleHistoryState":!single}}}), + json!({"path":"colorSettings.intent","value":"perceptual","values":{"actions.createSingleHistoryState":!single}}), + ] { + let before = s.prefs().clone(); + let color = s.color.settings.clone(); + assert!(s.execute("prefs.set", params).is_err()); + assert_eq!(s.prefs(), &before); + assert_eq!(s.color.settings, color); + } + for params in [json!({"path":"actions.createSingleHistoryState"}), json!({"path":"actions"}), json!({})] { + assert_eq!(s.execute("prefs.reset", params).is_err(), !single); + assert_eq!(s.prefs().actions.create_single_history_state, single); + } + s.execute("prefs.set", json!({"path":"actions.createSingleHistoryState","value":single})).unwrap(); + s.execute("prefs.set", json!({"path":"performance.historyStates","value":17})).unwrap(); + s.execute("prefs.reset", json!({"path":"cursors"})).unwrap(); + drop(continuation); + s.execute("prefs.set", json!({"path":"actions.createSingleHistoryState","value":!single})).unwrap(); + } + } + + #[test] + /// Rebinding a dialog retains its first edit while respecting an accepted Clear History boundary. + fn idle_dialog_rebind_keeps_first_edit_and_clear_boundaries() { + for single in [false, true] { + for clear in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Spell"); + if clear { + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + s.execute("history.clear", json!({})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + let mut continuation = ActionHistoryContinuation::capture(&mut s); + for name in ["First correction", "Second correction"] { + continuation.resume(&mut s).unwrap(); + s.execute("layer.renameLayer", json!({"name":name})).unwrap(); + continuation = continuation.suspend_after_edit(&mut s).unwrap(); + assert!(s.action_history.is_none(), "idle time must not own a history scope"); + assert_eq!(s.actions.playing, 0); + } + continuation.resume(&mut s).unwrap(); + finish_document_history(&mut s); + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + if single { + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + } else { + assert_ne!(s.active().unwrap().doc.as_ref(), before.as_ref()); + } + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } + } + + #[test] + /// A dialog continuation cannot adopt another caller's active grouping scope. + fn idle_dialog_rebind_never_takes_an_unrelated_parent_scope() { + for single in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Spell"); + let continuation = ActionHistoryContinuation::capture(&mut s); + // An independently owned parent scope must never be taken, even for a paused + // per-step action whose policy is now protected from changes. + s.action_history = Some(ActionHistory { name: "Parent".into(), external: true, before: Vec::new() }); + let before = s.active().unwrap().doc.clone(); + let rows = s.active().unwrap().history.entries(); + assert!(continuation.suspend_after_edit(&mut s).is_err()); + assert!(suspend_history(&mut s).is_none(), "ordinary suspension still requires playing == 1"); + assert_eq!(s.action_history.as_ref().unwrap().name, "Parent"); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), rows); + finish_document_history(&mut s); + } + } +} diff --git a/crates/engine/src/cmd/actions_cmds/playback_tests.rs b/crates/engine/src/cmd/actions_cmds/playback_tests.rs new file mode 100644 index 0000000000..7ba2bba195 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/playback_tests.rs @@ -0,0 +1,107 @@ +//! Playback Options (`actions.setPlayback`): validation, preferences and headless playback. +use super::*; +use serde_json::json; + +fn options(s: &Session) -> (crate::prefs::PlaybackPerformance, u8, bool) { + let a = &s.prefs().actions; + (a.performance, a.pause_seconds, a.create_single_history_state) +} + +#[test] +fn set_playback_refuses_bad_values_and_changes_nothing() { + let mut s = Session::new(); + let before = options(&s); + for bad in [ + json!({"performance": "fast"}), + json!({"performance": 1}), + json!({"performance": null}), + json!({"performance": "pause", "seconds": 0}), + json!({"performance": "pause", "seconds": 61}), + json!({"performance": "pause", "seconds": 1.5}), + json!({"performance": "pause", "seconds": -1}), + json!({"performance": "pause", "seconds": "5"}), + json!({"performance": "pause", "seconds": 256}), + json!({"performance": "pause", "seconds": 1e300}), + json!({"performance": "step", "singleState": "yes"}), + json!({"singleState": null}), + json!([]), + json!("pause"), + json!(null), + ] { + assert!(s.execute("actions.setPlayback", bad.clone()).is_err(), "{bad} is refused"); + assert_eq!(options(&s), before, "{bad} changes nothing, also not its valid keys"); + } +} + +#[test] +fn set_playback_stores_each_value_in_preferences() { + use crate::prefs::PlaybackPerformance::*; + let mut s = Session::new(); + assert_eq!(options(&s), (Accelerated, 1, true), "the defaults the reference application ships"); + let r = s.execute("actions.setPlayback", json!({"performance": "pause", "seconds": 60})).unwrap(); + assert_eq!(r, json!({"performance": "pause", "seconds": 60, "singleState": true})); + s.execute("actions.setPlayback", json!({"seconds": 1})).unwrap(); + assert_eq!(options(&s), (Pause, 1, true), "the range's ends are accepted"); + s.execute("actions.setPlayback", json!({"performance": "step", "singleState": false})).unwrap(); + assert_eq!(options(&s), (StepByStep, 1, false), "the seconds stay while another mode is chosen"); + assert_eq!(s.execute("actions.setPlayback", json!({})).unwrap(), json!({"performance": "step", "seconds": 1, "singleState": false})); + // A preferences file round trip keeps all three. + s.execute("actions.setPlayback", json!({"performance": "pause", "seconds": 7})).unwrap(); + let saved = s.prefs_to_json(); + let mut t = Session::new(); + assert!(t.load_prefs_json(&saved).unwrap().is_empty()); + assert_eq!(options(&t), (Pause, 7, false)); + // prefs.set validates the same range and names. + assert!(t.execute("prefs.set", json!({"path": "actions.pauseSeconds", "value": 0})).is_err()); + assert!(t.execute("prefs.set", json!({"path": "actions.pauseSeconds", "value": 61})).is_err()); + assert!(t.execute("prefs.set", json!({"path": "actions.pauseSeconds", "value": 2.5})).is_err()); + assert!(t.execute("prefs.set", json!({"path": "actions.performance", "value": "slow"})).is_err()); + t.execute("prefs.set", json!({"path": "actions.performance", "value": "accelerated"})).unwrap(); + assert_eq!(options(&t).0, Accelerated); +} + +#[test] +fn older_or_hand_edited_preferences_load_within_range() { + let mut s = Session::new(); + // Before Playback Options: only the history flag. + assert!(s.load_prefs_json(r#"{"actions":{"createSingleHistoryState":false}}"#).unwrap().is_empty()); + assert_eq!(options(&s), (crate::prefs::PlaybackPerformance::Accelerated, 1, false)); + s.load_prefs_json(r#"{"actions":{"pauseSeconds":200}}"#).unwrap(); + assert_eq!(s.prefs().actions.pause_seconds(), 60, "a number out of range plays at the nearest end"); + s.load_prefs_json(r#"{"actions":{"pauseSeconds":0}}"#).unwrap(); + assert_eq!(s.prefs().actions.pause_seconds(), 1); +} + +#[test] +fn set_playback_keeps_the_history_policy_of_a_running_play() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.actions.list.push(Action::new("Change", vec![("actions.setPlayback".to_string(), json!({"singleState": false}))])); + let played = s.execute("actions.play", json!({"action": "Change"})).unwrap(); + assert!(played.get("failed").is_some(), "an action can't change Playback Options while it plays: {played}"); + assert!(s.prefs().actions.create_single_history_state); +} + +/// Headless playback ignores the performance mode: one call runs every step at once, in one +/// history state with Create a single history state on and one per step with it off. +#[test] +fn headless_play_runs_the_whole_action_in_every_mode() { + for performance in ["accelerated", "step", "pause"] { + for single in [true, false] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.setPlayback", json!({"performance": performance, "seconds": 60, "singleState": single})).unwrap(); + let steps = ["A", "B", "C"].map(|n| ("layer.new.layer".to_string(), json!({"name": n}))).to_vec(); + s.actions.list.push(Action::new("Three", steps)); + let layers = s.active().unwrap().doc.layers.len(); + let past = s.active().unwrap().history.past_len(); + let t0 = std::time::Instant::now(); + let played = s.execute("actions.play", json!({"action": "Three"})).unwrap(); + assert!(t0.elapsed() < std::time::Duration::from_secs(30), "{performance}: no wait headless"); + assert_eq!(played["ran"], 3, "{performance}/{single}: {played}"); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3); + let added = if single { 1 } else { 3 }; + assert_eq!(s.active().unwrap().history.past_len(), past + added, "{performance}/{single}"); + } + } +} diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 8f38b142d5..6a18a871f1 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -544,9 +544,12 @@ fn record_keeps_only_replayable_steps() { } #[test] +/// Playback on a different document must match native edits at every depth, independent of history grouping. fn play_on_another_document_matches_pixels_at_each_depth() { for depth in [8, 16, 32] { let mut s = Session::new(); + // This control checks the optional, per-step history behavior. + s.execute("actions.setHistory", json!({"singleState": false})).unwrap(); record_red(&mut s, depth); s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap(); let before = s.active().unwrap().history.past_len(); @@ -597,6 +600,84 @@ fn recorded_layer_selection_replays_by_name_on_another_document() { /// #7845: dragging a layer in the Layers panel while recording keeps the drop target by name, /// so the action plays on another document (whose layer ids differ) instead of failing with /// "no such layer". The reporter's steps, replayed on a new document. +/// #10485: a mask thumbnail dropped on another layer while recording keeps its destination by +/// name, so the action moves the mask to the same layer in another document (whose ids differ) +/// rather than to whichever layer has the recorded id there. +#[test] +fn recorded_mask_drop_replays_on_another_document() { + let masked = |s: &mut Session, name: &str| { + let id = s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + id + }; + let has_mask = |s: &Session, name: &str| s.active().unwrap().doc.walk().iter().any(|(_, _, l)| l.name == name && l.mask.is_some()); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let photo = masked(&mut s, "Photo"); + let shadow = s.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("actions.record", json!({"name": "Mask to shadow"})).unwrap(); + s.execute("layer.layerMask.moveTo", json!({"layer": photo, "to": shadow, "copy": false})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"layer": "Photo", "to": "Shadow", "copy": false})); + // Another document: an extra layer between them, so the recorded id is a different layer. + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + masked(&mut replay, "Photo"); + replay.execute("layer.new.layer", json!({"name": "Extra"})).unwrap(); + replay.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(has_mask(&replay, "Shadow"), "the mask went to Shadow"); + assert!(!has_mask(&replay, "Photo") && !has_mask(&replay, "Extra"), "and nowhere else"); +} + +/// A mask dropped on one of several same-named layers records which of them it is, so replay +/// picks the same one (not the topmost of that name), and an explicit name needs the occurrence. +#[test] +fn recorded_mask_drop_onto_a_duplicate_name_replays_on_the_same_layer() { + let build = |s: &mut Session, extra: bool| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + if extra { + s.execute("layer.new.layer", json!({"name": "Extra"})).unwrap(); + } + let photo = s.execute("layer.new.layer", json!({"name": "Photo"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + let lower = s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap(); + let upper = s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap(); + (photo, lower, upper) + }; + let mask_on = |s: &Session, id: u64| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().mask.is_some(); + let mut s = Session::new(); + let (photo, lower, upper) = build(&mut s, false); + s.execute("actions.record", json!({"name": "Copy mask"})).unwrap(); + s.execute("layer.layerMask.moveTo", json!({"layer": photo, "to": lower, "copy": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"layer": "Photo", "to": "Twin", "toOccurrence": 0, "copy": true})); + assert!(mask_on(&s, lower) && !mask_on(&s, upper)); + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let (photo2, lower2, upper2) = build(&mut replay, true); + assert_ne!((photo, lower, upper), (photo2, lower2, upper2), "the ids differ"); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(mask_on(&replay, photo2), "a copy: the original stays"); + assert!(mask_on(&replay, lower2), "the copy went to the lower Twin"); + assert!(!mask_on(&replay, upper2), "not to the upper one"); + // Explicit calls: a name shared by several layers needs `toOccurrence`; ids still work. + let err = replay.execute("layer.layerMask.moveTo", json!({"layer": photo2, "to": "Twin", "copy": true})).unwrap_err().to_string(); + assert!(err.contains("toOccurrence"), "{err}"); + replay.execute("layer.layerMask.moveTo", json!({"layer": photo2, "to": "Twin", "toOccurrence": 1, "copy": true})).unwrap(); + assert!(mask_on(&replay, upper2), "occurrence 1 is the upper Twin"); + replay.execute("layer.layerMask.delete", json!({"layer": upper2})).unwrap(); + replay.execute("layer.layerMask.moveTo", json!({"layer": photo2, "to": upper2})).unwrap(); + assert!(mask_on(&replay, upper2) && !mask_on(&replay, photo2), "a numeric id still names the layer"); +} + #[test] fn recorded_layer_drag_replays_on_another_document() { for depth in [8, 16, 32] { @@ -1238,9 +1319,12 @@ fn button_color_is_one_of_the_photoshop_swatches() { } #[test] +/// An arrangement that cannot move the layer must not create a grouped history entry. fn arrange_that_cannot_move_is_a_noop_in_playback() { // #8453: imported actions make an adjustment layer, then Bring it to Front, where it already is. let mut s = Session::new(); + // Keep individual rows visible so grouping cannot hide an extra no-op record. + s.execute("actions.setHistory", json!({"singleState": false})).unwrap(); s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); let steps = vec![ ("layer.newAdjustmentLayer.curves".to_string(), json!({})), @@ -1291,6 +1375,78 @@ fn arrange_that_cannot_move_is_a_noop_in_playback() { assert!(played["failed"].is_object(), "{played}"); } +/// A deep route cannot consume the only visit to a dependency reachable near the root. +#[test] +fn called_actions_keeps_dependencies_reachable_by_a_shallow_route() { + let mut list = vec![Action::new("Leaf", Vec::new()), Action::new("Shared", vec![("actions.play".into(), json!({"action": "Leaf"}))])]; + for i in 0..15 { + let next = if i == 14 { "Shared".to_string() } else { format!("D{}", i + 1) }; + list.push(Action::new(format!("D{i}"), vec![("actions.play".into(), json!({"action": next}))])); + } + let mut steps = vec![("file.automate.batch".into(), json!({"action": "Shared"})), ("actions.play".into(), json!({"action": "D0"}))]; + let called = called_actions(&list, &mut steps); + assert!(called.iter().any(|a| a.name == "Leaf")); + assert_eq!(called.len(), list.len()); +} + +/// Extra source copies cannot turn deferred missing indices into valid action calls. +#[test] +fn scoped_source_slots_do_not_bind_previously_missing_references() { + let list = vec![ + Action::new("Leaf", Vec::new()), + Action::new("Shared", vec![("actions.play".into(), json!({"action": 0}))]), + Action::new("Bad", vec![("actions.play".into(), json!({"action": 3}))]), + ]; + let mut steps = vec![ + ("actions.play".into(), json!({"action": "Shared"})), + ("file.automate.batch".into(), json!({"action": "Shared", "calledActions": []})), + ("actions.play".into(), json!({"action": "Bad"})), + ]; + for id in ["actions.play", "file.automate.batch"] { + for action in [json!(3), json!(3.25), json!(-1), json!("Missing"), json!(null)] { + steps.push((id.into(), json!({"action": action}))); + if id == "file.automate.batch" { + steps.push((id.into(), json!({"action": action, "calledActions": []}))); + } + } + } + let called = called_actions(&list, &mut steps); + assert_eq!(called.len(), 4); + assert_eq!(called.last(), list.get(1), "the raw source keeps its own indices"); + for (id, params) in &steps[3..] { + assert!(resolve(&called, params, id).is_err(), "{id}: {params}"); + } + let bad = called.iter().find(|a| a.name == "Bad").unwrap(); + assert!(resolve(&called, &bad.steps[0].1, "actions.play").is_err()); + // A scoped source runs its raw references in the embedded namespace. An index beyond + // the caller's library can therefore be valid there, even after capture adds a source copy. + let list = + vec![Action::new("Source", vec![("actions.play".into(), json!({"action": 3}))]), Action::new("Other", Vec::new()), Action::new("Last", Vec::new())]; + let embedded = vec![ + Action::new("Gap0", Vec::new()), + Action::new("Gap1", Vec::new()), + Action::new("Gap2", Vec::new()), + Action::new("Child", vec![("layer.new.layer".into(), json!({"name": "embedded child"}))]), + ]; + let mut steps = vec![ + ("actions.play".into(), json!({"action": 0})), + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": 2})), + ("file.automate.batch".into(), json!({"source": "open", "action": "Source", "calledActions": embedded})), + ]; + let called = called_actions(&list, &mut steps); + assert_eq!(called.len(), 4); + assert_eq!(called.last(), list.first(), "the scoped source keeps its embedded index"); + assert!(resolve(&called, &called[0].steps[0].1, "actions.play").is_err(), "the ordinary source still has a missing target"); + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + session.actions.list = called; + let (id, params) = steps.last().unwrap(); + let result = session.execute(id, params.clone()).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert!(session.active().unwrap().doc.layers.iter().any(|layer| layer.name == "embedded child")); +} + /// #10459 review: recording into an existing set keeps that set's exact name, even one with /// surrounding spaces (from an older actions.json); a new set name is trimmed. #[test] @@ -1322,3 +1478,124 @@ fn record_keeps_an_empty_loaded_sets_exact_name() { assert_eq!(crate::actions_edit_cmds::set_names(&s), [" Web "], "one folder, not a trimmed second one"); assert_eq!(s.execute("actions.save", json!({"set": " Web "})).unwrap()["actions"], json!(["First"])); } + +/// A droplet capturing two actions of one name (sets A and B) keeps each call on the action it +/// reached: an index or set-qualified call to B's action still reaches B's copy, a name-only call +/// still reaches A's, and a call to a uniquely named action stays a name. +#[test] +fn captured_calls_keep_their_target_among_same_named_actions() { + let mut list = Vec::new(); + for (set, step) in [("A", "image.adjustments.invert"), ("B", "image.imageRotation.180")] { + let mut a = Action::new("Retouch", vec![(step.into(), json!({}))]); + a.set = Some(set.into()); + list.push(a); + } + list.push(Action::new("Other", vec![("image.imageRotation.90cw".into(), json!({}))])); + let mut steps: Vec<(String, Value)> = vec![ + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": "Retouch"})), + ("file.automate.batch".into(), json!({"action": "Retouch", "set": "B", "input": [], "output": "/out"})), + ("actions.play".into(), json!({"action": 2})), + ]; + let called = crate::actions_cmds::called_actions(&list, &mut steps); + assert_eq!(called.len(), 3, "{called:?}"); + let set_of = |i: usize| called[i].set.clone(); + assert_eq!(set_of(resolve(&called, &steps[0].1, "t").unwrap()), Some("B".into()), "{}", steps[0].1); + assert_eq!(set_of(resolve(&called, &steps[1].1, "t").unwrap()), Some("A".into()), "{}", steps[1].1); + assert_eq!(set_of(crate::file_cmds::batch_action(&called, &steps[2].1).unwrap()), Some("B".into()), "{}", steps[2].1); + assert_eq!(steps[3].1["action"], "Other", "a unique name stays a name"); +} + +/// Two actions of one name in different sets (a loaded library): renaming the second by index +/// leaves the unqualified calls, which reach the first, and a Batch with explicit steps alone; +/// only the Batch qualified with the second's set follows. Renaming the first moves them all. +#[test] +fn rename_leaves_calls_that_reach_a_same_named_action_in_another_set() { + let calls = || { + vec![ + ("file.automate.batch".into(), json!({"action": "Target", "input": [], "output": "/out"})), + ("file.automate.batch".into(), json!({"action": "Target", "set": "B", "input": [], "output": "/out"})), + ("file.automate.batch".into(), json!({"action": "Target", "set": "B", "steps": [["image.adjustments.invert", {}]], "input": [], "output": "/out"})), + ("actions.play".into(), json!({"action": "Target"})), + ("actions.conditional".into(), json!({"if": "documentIsOpen", "then": "Target", "else": "Target"})), + ] + }; + let session = || { + let mut s = Session::new(); + for set in ["A", "B"] { + let mut target = Action::new("Target", vec![("image.adjustments.invert".into(), json!({}))]); + target.set = Some(set.into()); + s.actions.list.push(target); + } + s.actions.list.push(Action::new("Caller", calls())); + s + }; + let mut s = session(); + s.execute("actions.rename", json!({"action": 1, "name": "Renamed"})).unwrap(); + let caller = &s.actions.list[2].steps; + assert_eq!(caller[0].1["action"], "Target", "an unqualified Batch reaches set A's action"); + assert_eq!(caller[1].1["action"], "Renamed", "the Batch qualified with set B follows"); + assert_eq!(caller[2].1["action"], "Target", "explicit steps are left alone"); + assert_eq!(caller[3].1["action"], "Target", "a nested Play reaches set A's action"); + assert_eq!((&caller[4].1["then"], &caller[4].1["else"]), (&json!("Target"), &json!("Target"))); + assert_eq!(crate::file_cmds::batch_action(&s.actions.list, &caller[0].1).unwrap(), 0); + assert_eq!(crate::file_cmds::batch_action(&s.actions.list, &caller[1].1).unwrap(), 1); + + let mut s = session(); + s.execute("actions.rename", json!({"action": 0, "name": "Renamed"})).unwrap(); + let caller = &s.actions.list[2].steps; + assert_eq!(caller[0].1["action"], "Renamed"); + assert_eq!(caller[1].1["action"], "Target", "the Batch qualified with set B still names B's action"); + assert_eq!(caller[3].1["action"], "Renamed"); + assert_eq!((&caller[4].1["then"], &caller[4].1["else"]), (&json!("Renamed"), &json!("Renamed"))); +} + +/// A saved named Batch step follows a renamed action, by name alone or within its own set; +/// a Batch naming the same action in another set and an inline Batch stay as they were. +#[test] +fn rename_follows_named_batch_steps_in_their_set() { + let mut s = Session::new(); + let mut target = Action::new("Target", vec![("image.adjustments.invert".into(), json!({}))]); + target.set = Some("S".into()); + s.actions.list.push(target); + let steps = vec![ + ("file.automate.batch".into(), json!({"action": "Target", "input": [], "output": "/out"})), + ("file.automate.batch".into(), json!({"action": "Target", "set": "S", "input": [], "output": "/out"})), + ("file.automate.batch".into(), json!({"action": "Target", "set": null, "input": [], "output": "/out"})), + ("file.automate.batch".into(), json!({"action": "Target", "set": "Other", "input": [], "output": "/out"})), + ("file.automate.batch".into(), json!({"action": [["actions.play", {"action": "Target"}]], "input": [], "output": "/out"})), + ]; + s.actions.list.push(Action::new("Caller", steps)); + s.execute("actions.rename", json!({"action": "Target", "name": "Renamed"})).unwrap(); + let caller = &s.actions.list[1].steps; + for step in &caller[..3] { + assert_eq!(step.1["action"], "Renamed", "{}", step.1); + assert_eq!(crate::file_cmds::batch_action(&s.actions.list, &step.1).unwrap(), 0, "{}", step.1); + } + assert_eq!(caller[1].1["set"], "S"); + assert_eq!(caller[3].1["action"], "Target", "a Batch in another set names a different action"); + assert!(caller[4].1["action"].is_array()); +} + +/// All Batch files saved, but the requested error log could not be written: the calling action +/// stops at that step instead of going on as if the Batch had finished. +#[test] +fn playback_stops_when_a_nested_batch_cannot_write_its_error_log() { + let dir = std::env::temp_dir().join(format!("photocraft-actions-batch-log-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dir = dir.to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + // A folder as the log path: the log write fails while every file succeeds. + let batch = json!({"source": "open", "steps": [], "output": format!("{dir}/out"), "format": "png", "errorLog": dir}); + s.actions.list.push(Action::new("Run", vec![("file.automate.batch".into(), batch), ("layer.new.layer".into(), json!({}))])); + let layers = s.active().unwrap().doc.layers.len(); + let played = s.execute("actions.play", json!({"action": "Run"})).unwrap(); + let failed = &played["failed"]; + assert_eq!(failed["step"], 0, "{played}"); + assert!(failed["error"].as_str().unwrap().contains("error log"), "{played}"); + assert!(failed["result"]["errorLogError"].is_string(), "{played}"); + assert_eq!(s.active().unwrap().doc.layers.len(), layers, "the step after the Batch must not run"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index f8e4da4f54..d527b6d773 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -13,6 +13,11 @@ use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod insert_path; +mod record_again; +pub use insert_path::selected_path as selected_recording_path; +pub use record_again::{RecordAgainTarget, target as record_again_target}; + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -21,6 +26,35 @@ fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } +/// Flush under the old mode so changing the toggle never retroactively records or loses a stroke. +fn allow_tool_recording(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.allowToolRecording"; + not_playing(s, cmd)?; + let enabled = match p.get("enabled") { + None | Some(Value::Null) => !s.actions.record_tools, + Some(Value::Bool(enabled)) => *enabled, + Some(_) => return Err(bad(cmd, "`enabled` must be true or false")), + }; + if enabled != s.actions.record_tools { + if s.actions.recording.is_some() && s.tool_stroke_job_running() { + return Err(bad(cmd, "wait for the current tool stroke before changing recording")); + } + flush_recording(s)?; + s.actions.record_tools = enabled; + } + Ok(json!({"enabled": enabled})) +} + +/// Insert a menu event with absent settings, reusing the ordinary validated step insertion. +fn insert_menu_item(s: &mut Session, p: &Value) -> Result { + if p.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "action" | "at" | "id" | "eventKind"))) { + return Err(bad("actions.insertMenuItem", "menu items accept no recorded settings")); + } + let params = json!({"id":p.get("id"),"eventKind":p.get("eventKind").cloned().unwrap_or(json!("name"))}); + crate::atn_cmds::menu_item_command(¶ms)?; + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":crate::atn_cmds::MENU_ITEM,"params":params})) +} + fn index(p: &Value, key: &str, cmd: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -66,14 +100,113 @@ fn insert(s: &mut Session, p: &Value) -> Result { if s.actions.recording.is_some_and(|(_, i)| i == idx) { flush_recording(s)?; } + let is_stop = id == crate::atn_cmds::STOP_MESSAGE; + let result = insert_step(s, idx, at, (id, params)); + // A newly inserted Stop shows its message in the app. Duplicates keep their own flags. + if is_stop { + s.actions.list[idx].dialogs.insert(at); + } + Ok(result) +} + +/// Insert an already validated step without normalizing a copied step's payload. +fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value { let action = &mut s.actions.list[idx]; - action.steps.insert(at, (id.clone(), params)); + let id = step.0.clone(); + action.steps.insert(at, step); action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()}); s.actions.touch(); + result +} + +/// Duplicate a step or action below its source, preserving settings and per-step flags. +fn duplicate(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + not_playing(s, cmd)?; + if let Some(set) = p.get("set") { + if p.get("action").is_some() || p.get("step").is_some() { + return Err(bad(cmd, "select a set or an action step, not both")); + } + return duplicate_set(s, set.as_str().ok_or_else(|| bad(cmd, "set must be a name"))?); + } + let idx = resolve(&s.actions.list, p, cmd)?; + if let Some(step) = index(p, "step", cmd)? { + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + if step >= len { + return Err(bad(cmd, "step is out of range")); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &s.actions.list[idx]; + let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?; + let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step)); + let at = step + 1; + let mut result = insert_step(s, idx, at, copied); + let action = &mut s.actions.list[idx]; + if disabled { + action.disabled.insert(at); + } + if dialog { + action.dialogs.insert(at); + } + result["index"] = json!(idx); + return Ok(result); + } + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating an action")); + } + let mut copied = s.actions.list[idx].clone(); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + copied.name = names.claim(&format!("{} copy", copied.name)); + let at = idx + 1; + let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); + s.actions.list.insert(at, copied); + crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, 1); + s.actions.touch(); Ok(result) } +/// Copy the named group represented by main, including a session-only empty New Set. +fn duplicate_set(s: &mut Session, source: &str) -> Result { + let cmd = "actions.duplicate"; + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating a set")); + } + let sets = set_names(s); + if !sets.iter().any(|name| name == source) { + return Err(bad(cmd, "the selected set no longer exists")); + } + let name = crate::atn_cmds::UniqueNames::new(sets).claim(&format!("{source} copy")); + if let Some(first) = s.actions.list.iter().position(|action| action.set.as_deref() == Some(source)) { + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|action| action.name.clone())); + let copies: Vec<_> = s + .actions + .list + .iter() + .filter(|action| action.set.as_deref() == Some(source)) + .cloned() + .map(|mut action| { + action.name = names.claim(&format!("{} copy", action.name)); + action.set = Some(name.clone()); + action + }) + .collect(); + // Classic groups rows by name even when a set's actions are interleaved. + let at = first + s.actions.list[first..].iter().take_while(|action| action.set.as_deref() == Some(source)).count(); + let count = copies.len(); + s.actions.list.splice(at..at, copies); + crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, count); + s.actions.touch(); + return Ok(json!({"set": name, "index": at, "actions": count})); + } + let at = s.actions.sets.iter().position(|set| set == source).ok_or_else(|| bad(cmd, "the selected set no longer exists"))? + 1; + s.actions.sets.insert(at, name.clone()); + s.actions.touch(); + Ok(json!({"set": name, "actions": 0})) +} + /// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without /// it, every step (all on unless all already are, like clicking the action's own toggle). fn dialog(s: &mut Session, p: &Value) -> Result { @@ -273,12 +406,62 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.insertMenuItem", + label: "Insert Menu Item…", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"id":menu command,"at":index?,"eventKind":"name"|"code"="name"} → {action,step,id,steps}. Records an unambiguous supported menu event without settings. Image Size and mapped filter menu events are supported. Desktop playback opens the existing editor when settings are needed; no-settings items run immediately."##, + enabled: always, + run: insert_menu_item, + journal: false, + }, + CommandSpec { + id: "actions.insertPath", + label: "Insert Path", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"name":"work"|"layer"|saved path name,"at":index?} → {action,step,id,steps}. Snapshot exactly the selected path as a path.set work-path step, preserving cubic handles and path options. No fallback from a missing Work Path to a layer path. The measured Work Path ATN descriptor supports absolute coordinates, cubic handles, even-odd subpaths and Add/Subtract/XOR. Paths outside that measured form still play natively and are reported as unsupported by ATN export."##, + enabled: always, + run: insert_path::run, + journal: false, + }, + CommandSpec { + id: "actions.allowToolRecording", + label: "Allow Tool Recording", + menu: &[], + shortcut: None, + params: r##"{"enabled":bool?=toggle} → {enabled:bool}. Opt in to recording painting-tool strokes (off initially). Existing pending steps are committed under the previous mode. Painting, history and the journal are unchanged. actions.list reports toolRecording."##, + enabled: always, + run: allow_tool_recording, + journal: false, + }, + CommandSpec { + id: "actions.recordAgain", + label: "Record Again", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index,"params":{new settings},"revision":u64?} → {action,step,id,result}. Replays the selected command and stores its new settings only after success, preserving its enabled/dialog flags. The panel opens the command's editor at the recorded settings. Stop recording or playback first; revision optionally rejects stale library state."##, + enabled: always, + run: record_again::run, + journal: false, + }, + CommandSpec { + id: "actions.duplicate", + label: "Duplicate Action", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index?} or {"set":name} → {action?,index?,step?,steps?,set?,actions?}. Copy an action, step or named set below the source. Copies retain settings and flags, and leave shortcuts with the originals."##, + enabled: always, + run: duplicate, + journal: false, + }, CommandSpec { id: "actions.insert", label: "Insert Step", menu: &[], shortcut: None, - params: r##"{"action":name|index, "id":command id, "params":{}?, "at":index?=end} → {action, step, id, steps}. Inserts a step at the 0-based position (Photoshop's Insert Stop… with id actions.stopMessage, Insert Menu Item… with any command). Pending recorded steps count; flags of the steps after it follow them."##, + params: r##"{"action":name|index, "id":command id, "params":{}?, "at":index?=end} → {action, step, id, steps}. Inserts a step at the 0-based position (the reference application's Insert Stop… with id actions.stopMessage, its dialog toggle on; Insert Menu Item… with any command). Pending recorded steps count; flags of the steps after it follow them."##, enabled: always, run: insert, journal: false, @@ -350,7 +533,7 @@ mod tests { let a = &s.actions.list[0]; assert_eq!(a.steps[1], ("actions.stopMessage".to_string(), json!({"message": "Check", "allowContinue": true}))); assert_eq!(a.disabled.iter().copied().collect::>(), [2], "the switched-off step moved down one"); - assert_eq!(a.dialogs.iter().copied().collect::>(), [3]); + assert_eq!(a.dialogs.iter().copied().collect::>(), [1, 3], "the inserted Stop's dialog is on; the old toggle moved down one"); // Default position: the end. A bare id works for a parameterless command. let r = s.execute("actions.insert", json!({"action": 0, "id": "layer.new.layer"})).unwrap(); assert_eq!(r["step"], 4); diff --git a/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs new file mode 100644 index 0000000000..9be10d94ad --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs @@ -0,0 +1,26 @@ +//! Capture full native geometry without substituting another path for a stale selection. +use super::{Result, Session, Value, bad, json}; +use photocraft_doc::{LayerContent, Path}; + +/// Borrow exactly the path the panel selected, without Work Path's ordinary layer fallback. +pub fn selected_path<'a>(s: &'a Session, name: &str) -> Result<&'a Path> { + let st = s.active().ok_or(crate::EngineError::NoDocument)?; + let path = match name { + "work" => st.doc.work_path.as_ref(), + "layer" => st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| match &l.content { + LayerContent::Shape(shape) => Some(&shape.path), + _ => l.vector_mask.as_ref().map(|v| &v.path), + }), + name => st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path), + }; + path.ok_or_else(|| bad("actions.insertPath", "the selected path no longer exists")) +} + +/// The native path step is also the source for a future independently verified ATN wire mapping. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("actions.insertPath", "select a path"))?; + let path = selected_path(s, name)?; + photocraft_vector::flatten::validate_path(path).map_err(|e| bad("actions.insertPath", e))?; + let path = crate::vector_cmds::path_json(path); + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":"path.set","params":{"name":"work","path":path}})) +} diff --git a/crates/engine/src/cmd/actions_edit_cmds/record_again.rs b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs new file mode 100644 index 0000000000..b6049967bb --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs @@ -0,0 +1,55 @@ +//! Record Again executes the selected command before replacing its stored settings. +use super::{Result, Session, Value, bad, index, json, not_playing, resolve}; + +const COMMAND: &str = "actions.recordAgain"; + +/// A validated selection shared by the engine and the panel's seeded editor. +pub struct RecordAgainTarget { + pub action: usize, + pub step: usize, + pub command: String, + pub params: Value, +} + +/// Resolve only executable steps, without flushing recording or editing the library. +pub fn target(s: &Session, p: &Value) -> Result { + not_playing(s, COMMAND)?; + if s.actions.recording.is_some() { + return Err(bad(COMMAND, "stop recording before recording a step again")); + } + if let Some(revision) = p.get("revision") { + let revision = revision.as_u64().ok_or_else(|| bad(COMMAND, "`revision` must be an unsigned integer"))?; + if revision != s.actions.rev { + return Err(bad(COMMAND, "the actions changed; select the step again")); + } + } + let action = resolve(&s.actions.list, p, COMMAND)?; + let step = index(p, "step", COMMAND)?.ok_or_else(|| bad(COMMAND, "select a step"))?; + let (id, params) = s.actions.list.get(action).and_then(|a| a.steps.get(step)).ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let (command, params) = + if id == crate::atn_cmds::MENU_ITEM { (crate::atn_cmds::menu_item_command(params)?.to_string(), json!({})) } else { (id.clone(), params.clone()) }; + if command.starts_with("actions.") || !crate::commands::find(&command).is_some_and(|c| c.journal) { + return Err(bad(COMMAND, "the selected step has no editable engine command")); + } + Ok(RecordAgainTarget { action, step, command, params }) +} + +/// Keep the original definition on invalid settings, denied execution or changed ownership. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let selected = target(s, p)?; + let params = p.get("params").filter(|p| p.is_object()).cloned().ok_or_else(|| bad(COMMAND, "supply the step's new settings in `params`"))?; + let original = s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)).cloned().ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let revision = s.actions.rev; + // Resolve portable recorded targets for execution; keep their portable form in the step. + let resolved = crate::actions_cmds::playback_params(s, &selected.command, ¶ms)?; + let result = s.execute(&selected.command, resolved)?; + if s.actions.rev != revision || s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)) != Some(&original) { + return Err(bad(COMMAND, "the action changed during execution; its step was not overwritten")); + } + let action = s.actions.list.get_mut(selected.action).ok_or_else(|| bad(COMMAND, "the action no longer exists"))?; + let step = action.steps.get_mut(selected.step).ok_or_else(|| bad(COMMAND, "the step no longer exists"))?; + *step = (selected.command.clone(), params); + let name = action.name.clone(); + s.actions.touch(); + Ok(json!({"action": name, "step": selected.step, "id": selected.command, "result": result})) +} diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index d974576a53..ea3e327832 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -101,7 +101,8 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> Result (String::new(), None, 0, false, false, None), }; + let encoding = opt_str(CMD, p, "dataEncoding")?.unwrap_or("text"); + if !matches!(encoding, "text" | "base64") { + return Err(bad(CMD, "`dataEncoding` must be \"text\" or \"base64\"")); + } + let format = match opt_str(CMD, p, "lutFormat")? { + None => None, + Some("cube") => Some(photocraft_cms::lutfile::Format::Cube), + Some("3dl") => Some(photocraft_cms::lutfile::Format::ThreeDl), + Some("look") => Some(photocraft_cms::lutfile::Format::Look), + Some(_) => return Err(bad(CMD, "`lutFormat` must be \"cube\", \"3dl\" or \"look\"")), + }; // The parsed table, its label and (for a file) the file as loaded, which a PSD save embeds. let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String, Option)> = None; if let Some(id) = opt_str(CMD, p, "lut")? { @@ -130,13 +142,20 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> Result) -> LutSource { - let format = match photocraft_cms::lutfile::Format::detect(file_name, &bytes) { +fn lut_source(format: photocraft_cms::lutfile::Format, bytes: Vec) -> LutSource { + let format = match format { photocraft_cms::lutfile::Format::Cube => LutFormat::Cube, photocraft_cms::lutfile::Format::ThreeDl => LutFormat::ThreeDl, photocraft_cms::lutfile::Format::Look => LutFormat::Look, @@ -415,6 +434,33 @@ fn replace_color(s: &mut Session, p: &Value) -> Result { // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range // (a percentage of the canvas's longer side) of where it was picked. let points = sample_points(p).map_err(|m| bad(CMD, m))?; + // Several samples (Add to Sample / Subtract from Sample, #6280): the colours match as + // Select › Color Range's do for the same clicks, through its own matcher. + // A malformed `subtractPoints` is refused here, as malformed `points` are, rather than read as + // no subtraction by the single-colour path. + let subtracted = match p.get("subtractPoints") { + None => false, + Some(v) => !v.as_array().ok_or_else(|| bad(CMD, "`subtractPoints` must be [[x, y], …]"))?.is_empty(), + }; + if subtracted || points.len() > 1 { + let mut q = json!({"points": p.get("points").cloned().unwrap_or(Value::Array(Vec::new())), "fuzziness": fuzz}); + // With no Add samples, the subtraction starts from the dialog's From colour, not the + // foreground; with them, the samples alone span the range, as in Color Range. + // Forwarded as resolved (a `null` or missing colour is the foreground), as RGB 0..1. + if points.is_empty() { + q["color"] = json!([c[0], c[1], c[2]]); + } + for key in ["subtractPoints", "order", "localized", "range"] { + if let Some(v) = p.get(key) { + q[key] = v.clone(); + } + } + let samples = crate::selection_cmds::ColorRangeSamples::new(s, &q).map_err(|e| bad(CMD, e.to_string()))?; + return rgba_edit(s, "Replace Color", p, |px, r| { + let mask = samples.coverage(px, r.width() as usize, 1.0, [r.x0 as f32, r.y0 as f32]); + tone::replace_color_masked(px, &mask, h, sat, l); + }); + } let localized = if p.get("localized").and_then(Value::as_bool).unwrap_or(false) { if points.is_empty() { return Err(bad(CMD, "localized clusters need eyedropper `points` (where the colour was picked)")); @@ -568,7 +614,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?,"labMinimum":[L,a,b]?,"labMaximum":[L,a,b]?} (Lab bounds: a finite ordered D50 box, mutually exclusive with color/points/localized/range; composite pixel-layer target only. Otherwise color defaults to the foreground; localized points use range % of the longer side.)"##, + r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?,"subtractPoints":[[x,y],…]?,"order":["+"|"-",…]?,"labMinimum":[L,a,b]?,"labMaximum":[L,a,b]?} (Lab bounds: a finite ordered D50 box, mutually exclusive with color/points/subtractPoints/order/localized/range; composite pixel-layer target only. Otherwise color ("#rrggbb") defaults to the foreground; localized points use range % of the longer side; several "points" (Add to Sample) or any "subtractPoints" (Subtract from Sample) match as select.colorRange does for the same samples, "order" the click order.)"##, has_pixels, replace_color ), diff --git a/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs b/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs index 574610523c..9ee5371ac0 100644 --- a/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs +++ b/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs @@ -242,4 +242,20 @@ mod tests { assert_eq!(s.active().unwrap().doc, before); assert_eq!(s.active().unwrap().history.past_len(), history); } + + /// Add to Sample / Subtract from Sample (#6280) are RGB samples: with Lab bounds they are + /// refused, as `points` are, and the document is left as it was. + #[test] + fn lab_bounds_refuse_subtracted_samples_and_click_order() { + for extra in [serde_json::json!({"subtractPoints": [[1, 1]]}), serde_json::json!({"order": ["+", "-"]}), serde_json::json!({"subtractPoints": []})] { + let mut s = Session::new(); + s.execute("file.new", serde_json::json!({"width": 4, "height": 2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let mut p = serde_json::json!({"labMinimum": [0, -10, -10], "labMaximum": [100, 10, 10], "lightness": 50}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let err = s.execute(CMD, p).unwrap_err().to_string(); + assert!(err.contains("Lab bounds cannot be combined"), "{extra}: {err}"); + assert_eq!(s.active().unwrap().doc, before, "{extra}"); + } + } } diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index 51843ae022..162524367d 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -36,6 +36,12 @@ fn changed(a: [f32; 4], b: [f32; 4]) -> f32 { (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max) } +/// Every pixel of the active layer, row by row. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + let b = doc(s).bounds(); + (b.y0..b.y1).flat_map(|y| (b.x0..b.x1).map(move |x| (x, y))).map(|(x, y)| rgba(s, x, y)).collect() +} + fn doc(s: &Session) -> &Document { &s.active().unwrap().doc } @@ -754,6 +760,239 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { assert!(err.to_string().contains("bad point"), "{err}"); } +/// #6280: Add to Sample and Subtract from Sample, as in Photoshop: several picked colours are +/// all replaced, and a subtracted one is left alone, matching as Select › Color Range does for +/// the same samples. +#[test] +fn replace_color_adds_and_subtracts_samples_as_color_range_does() { + // Red on the left (x < 16), blue in the middle, green on the right (32 ≤ x < 44), and a + // lighter red at the edge that the samples only partly cover. + let (red, blue, green, pink) = ([0.85, 0.1, 0.1, 1.0], [0.1, 0.15, 0.85, 1.0], [0.1, 0.8, 0.15, 1.0], [0.95, 0.25, 0.25, 1.0]); + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| { + if x < 16 { + red + } else if x < 32 { + blue + } else if x < 44 { + green + } else { + pink + } + }); + let before = (rgba(&s, 4, 16), rgba(&s, 24, 16), rgba(&s, 40, 16), rgba(&s, 46, 16)); + let original = pixels(&s); + let shift = |extra: Value| { + let mut p = json!({"fuzziness": 40, "hue": 120, "saturation": 20}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + // Red and blue sampled (one added): both change, green doesn't. + s.execute("image.adjustments.replaceColor", shift(json!({"color": "#d91a1a", "points": [[4, 16], [24, 16]]}))).unwrap(); + assert!(changed(rgba(&s, 4, 16), before.0) > 0.1, "red is replaced"); + assert!(changed(rgba(&s, 24, 16), before.1) > 0.1, "the added blue is replaced"); + assert_eq!(rgba(&s, 40, 16), before.2, "green wasn't sampled"); + let partial = rgba(&s, 46, 16); + // One undo step brings every pixel back exactly. + s.execute("edit.undo", json!({})).unwrap(); + assert!(pixels(&s) == original, "undo restores the pixels"); + // The same coverage as Select › Color Range for the same clicks. + s.execute("select.colorRange", json!({"points": [[4, 16], [24, 16]], "fuzziness": 40})).unwrap(); + let sel = doc(&s).selection.clone().unwrap(); + for x in [4, 24, 40] { + let want = sel.read_region(Rect::from_xywh(x, 16, 1, 1))[0]; + assert!((want > 0.5) == (x < 32), "Color Range at {x}: {want}"); + } + // The lighter red is partly covered, and replaced in proportion: the blend of its colour and + // its fully replaced colour at that coverage, not either one (as a 0.5 threshold would give). + let k = sel.read_region(Rect::from_xywh(46, 16, 1, 1))[0]; + assert!(k > 0.1 && k < 0.9, "partial coverage: {k}"); + let mut full = [before.3]; + tone::replace_color_masked(&mut full, &[1.0], 120.0, 20.0, 0.0); + let blend: [f32; 4] = std::array::from_fn(|c| before.3[c] + (full[0][c] - before.3[c]) * k); + assert!(changed(partial, blend) < 1.5 / 255.0, "{partial:?} vs the blend {blend:?} at coverage {k}"); + assert!(changed(blend, before.3).min(changed(blend, full[0])) > 0.1, "the blend is far from both ends a threshold gives"); + s.execute("select.deselect", json!({})).unwrap(); + // A subtraction undoes exactly too. (Blue is far from the red sample here, so subtracting + // it changes nothing; `replace_color_subtracting_inside_the_added_range_takes_a_colour_out` + // has a subtraction that does.) + let selected = pixels(&s); + s.execute("image.adjustments.replaceColor", shift(json!({"points": [[4, 16]], "subtractPoints": [[24, 16]], "order": ["+", "-"]}))).unwrap(); + assert!(changed(rgba(&s, 4, 16), before.0) > 0.1); + assert_eq!(rgba(&s, 40, 16), before.2); + s.execute("edit.undo", json!({})).unwrap(); + assert!(pixels(&s) == selected, "undo restores the pixels"); + // Bad samples are refused, naming this command. + let err = s.execute("image.adjustments.replaceColor", shift(json!({"points": [[4, 16]], "subtractPoints": [[999, 16]]}))).unwrap_err(); + assert!(err.to_string().contains("replaceColor") && err.to_string().contains("outside"), "{err}"); +} + +/// FredApps review on #10443: a subtracted sample inside the added range. Photoshop's Subtract +/// from Sample reshapes the samples' Lab box (`LabRange::subtract`): a subtracted colour inside +/// it pulls the nearer edge onto itself, so it stays matched and the added colour beyond that +/// edge drops out. Two added greys span dark to light; subtracting a grey near the light end +/// leaves the light one unreplaced. +#[test] +fn replace_color_subtracting_inside_the_added_range_takes_a_colour_out() { + let grey = |v: f32| [v, v, v, 1.0]; + // Dark grey (x < 12), mid grey (subtracted), light grey, black (never sampled). + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| match x { + ..12 => grey(0.25), + 12..24 => grey(0.65), + 24..36 => grey(0.75), + _ => grey(0.0), + }); + let at = [(4, 16), (16, 16), (28, 16), (40, 16)]; + let before: Vec<_> = at.iter().map(|&(x, y)| rgba(&s, x, y)).collect(); + let added = json!({"fuzziness": 20, "lightness": -50, "points": [[4, 16], [28, 16]]}); + // Added only: the box spans dark to light, so all three greys are replaced. + s.execute("image.adjustments.replaceColor", added.clone()).unwrap(); + for (i, &(x, y)) in at[..3].iter().enumerate() { + assert!(changed(rgba(&s, x, y), before[i]) > 0.1, "grey {i} is in the added range"); + } + assert_eq!(rgba(&s, 40, 16), before[3]); + s.execute("edit.undo", json!({})).unwrap(); + // Mid grey subtracted: the light end moves onto it, so the light grey is left alone. + let mut subtracted = added.clone(); + subtracted["subtractPoints"] = json!([[16, 16]]); + subtracted["order"] = json!(["+", "+", "-"]); + s.execute("image.adjustments.replaceColor", subtracted.clone()).unwrap(); + assert!(changed(rgba(&s, 4, 16), before[0]) > 0.1, "the dark grey is still replaced"); + assert!(changed(rgba(&s, 16, 16), before[1]) > 0.1, "the subtracted grey stays matched, at the new edge"); + assert_eq!(rgba(&s, 28, 16), before[2], "the light grey beyond it is taken out"); + assert_eq!(rgba(&s, 40, 16), before[3]); + // Select › Color Range agrees for the same clicks. + s.execute("edit.undo", json!({})).unwrap(); + let mut range = subtracted; + range.as_object_mut().unwrap().remove("lightness"); + s.execute("select.colorRange", range).unwrap(); + let sel = doc(&s).selection.clone().unwrap(); + let covered: Vec = at.iter().map(|&(x, y)| sel.read_region(Rect::from_xywh(x, y, 1, 1))[0] > 0.5).collect(); + assert_eq!(covered, [true, true, false, false]); +} + +/// CodeRabbit review on #10443: the click order (`"order"`) reaches the matcher, as in Select › +/// Color Range. Added dark grey, subtracted mid grey, added light grey (`+ - +`): the subtraction +/// comes while the box is the dark grey alone, far from the mid grey, so it changes nothing and the +/// light grey is replaced; all additions first (`+ + -`) would leave it out. +#[test] +fn replace_color_follows_the_click_order_as_color_range_does() { + let grey = |v: f32| [v, v, v, 1.0]; + // Dark grey (x < 12), mid grey (subtracted), light grey, black (never sampled). + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| match x { + ..12 => grey(0.25), + 12..24 => grey(0.65), + 24..36 => grey(0.75), + _ => grey(0.0), + }); + let at = [(4, 16), (16, 16), (28, 16), (40, 16)]; + let before: Vec<_> = at.iter().map(|&(x, y)| rgba(&s, x, y)).collect(); + let clicks = json!({"fuzziness": 20, "points": [[4, 16], [28, 16]], "subtractPoints": [[16, 16]], "order": ["+", "-", "+"]}); + let mut p = clicks.clone(); + p["lightness"] = json!(-50); + s.execute("image.adjustments.replaceColor", p).unwrap(); + let replaced: Vec = at.iter().enumerate().map(|(i, &(x, y))| changed(rgba(&s, x, y), before[i]) > 0.1).collect(); + s.execute("edit.undo", json!({})).unwrap(); + // Darkening leaves black black whether or not it's matched: lighten to see whether it is. + p = clicks.clone(); + p["lightness"] = json!(50); + s.execute("image.adjustments.replaceColor", p).unwrap(); + let lifted = changed(rgba(&s, 40, 16), before[3]) > 0.1; + s.execute("edit.undo", json!({})).unwrap(); + s.execute("select.colorRange", clicks).unwrap(); + let sel = doc(&s).selection.clone().unwrap(); + let covered: Vec = at.iter().map(|&(x, y)| sel.read_region(Rect::from_xywh(x, y, 1, 1))[0] > 0.5).collect(); + assert_eq!(covered, [true, true, true, false], "Color Range: the light grey is added back after the subtraction"); + assert_eq!(replaced, covered, "Replace Color matches what Color Range covers for the same clicks"); + assert_eq!(lifted, covered[3], "Replace Color matches black as Color Range does"); +} + +/// FredApps review on #10443: Localized Color Clusters with several samples. A matching colour +/// is replaced only near one of the samples, as in Select › Color Range. +#[test] +fn replace_color_localized_clusters_with_several_samples() { + // Two creams, sampled at x = 2 and x = 24; the first cream again far right (x ≥ 40). + let (cream, cream2, grey) = ([0.9, 0.88, 0.79, 1.0], [0.88, 0.86, 0.76, 1.0], [0.5, 0.5, 0.5, 1.0]); + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| match x { + ..8 => cream, + 20..28 => cream2, + 40.. => cream, + _ => grey, + }); + let at = [(3, 16), (24, 16), (44, 16), (14, 16)]; + let before: Vec<_> = at.iter().map(|&(x, y)| rgba(&s, x, y)).collect(); + let mut p = json!({"fuzziness": 20, "hue": 180, "saturation": 60, "points": [[2, 16], [24, 16]]}); + // Without clusters both creams change everywhere, the far one too. + s.execute("image.adjustments.replaceColor", p.clone()).unwrap(); + assert!(changed(rgba(&s, 44, 16), before[2]) > 0.05, "far cream without clusters"); + s.execute("edit.undo", json!({})).unwrap(); + // Range 20 % of 48 px: within about 10 px of either sample. + p["localized"] = json!(true); + p["range"] = json!(20); + s.execute("image.adjustments.replaceColor", p.clone()).unwrap(); + assert!(changed(rgba(&s, 3, 16), before[0]) > 0.05, "near the first sample"); + assert!(changed(rgba(&s, 24, 16), before[1]) > 0.05, "near the second sample"); + assert_eq!(rgba(&s, 44, 16), before[2], "the same cream far from both samples"); + assert_eq!(rgba(&s, 14, 16), before[3], "grey isn't sampled"); + // Select › Color Range agrees for the same clicks. + s.execute("edit.undo", json!({})).unwrap(); + for k in ["hue", "saturation"] { + p.as_object_mut().unwrap().remove(k); + } + s.execute("select.colorRange", p).unwrap(); + let sel = doc(&s).selection.clone().unwrap(); + let covered: Vec = at.iter().map(|&(x, y)| sel.read_region(Rect::from_xywh(x, y, 1, 1))[0] > 0.5).collect(); + assert_eq!(covered, [true, true, false, false]); +} + +/// #10443 review: with only Subtract from Sample clicks, the dialog's From colour is what the +/// subtraction starts from, not the foreground (black here), so the red is still replaced. +#[test] +fn replace_color_subtract_only_starts_from_the_from_colour() { + let (red, blue) = ([0.85, 0.1, 0.1, 1.0], [0.1, 0.15, 0.85, 1.0]); + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| if x < 16 { red } else { blue }); + let (r0, b0) = (rgba(&s, 4, 16), rgba(&s, 24, 16)); + let p = json!({"color": "#d91a1a", "hue": 120, "fuzziness": 40, "points": [], "subtractPoints": [[24, 16]]}); + s.execute("image.adjustments.replaceColor", p).unwrap(); + assert_ne!(rgba(&s, 4, 16), r0, "the From colour (red) is replaced"); + // Blue is outside the From colour's range anyway: subtracting from a single colour doesn't + // reshape its range, as in Photoshop (see + // `replace_color_subtracting_inside_the_added_range_takes_a_colour_out`). + assert_eq!(rgba(&s, 24, 16), b0, "blue isn't matched"); +} + +/// #10443 review: a `null` From colour means the foreground, for the subtract-only matcher too. +#[test] +fn replace_color_subtract_only_reads_a_null_colour_as_the_foreground() { + let (red, blue) = ([0.85, 0.1, 0.1, 1.0], [0.1, 0.15, 0.85, 1.0]); + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| if x < 16 { red } else { blue }); + s.tools.foreground = red; + let r0 = rgba(&s, 4, 16); + let p = json!({"color": null, "hue": 120, "fuzziness": 40, "points": [], "subtractPoints": [[24, 16]]}); + s.execute("image.adjustments.replaceColor", p).unwrap(); + assert_ne!(rgba(&s, 4, 16), r0, "the foreground (red) is replaced"); +} + +/// A malformed `subtractPoints` is refused even with one sample, where the single-colour path +/// would otherwise ignore it (CodeRabbit review on #10443). +#[test] +fn replace_color_refuses_malformed_subtract_points() { + let mut s = session(8, "rgb"); + paint(&mut s, |_, _| [0.85, 0.1, 0.1, 1.0]); + for points in [json!([]), json!([[4, 16]])] { + let p = json!({"color": "#d91a1a", "hue": 120, "points": points, "subtractPoints": "invalid"}); + let err = s.execute("image.adjustments.replaceColor", p).unwrap_err(); + assert!(err.to_string().contains("replaceColor") && err.to_string().contains("subtractPoints"), "{err}"); + } + // An empty list is no subtraction: the single-colour path. + s.execute("image.adjustments.replaceColor", json!({"color": "#d91a1a", "hue": 120, "points": [[4, 16]], "subtractPoints": []})).unwrap(); +} + /// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick /// Mask selects everything but the original rectangle, whichever way the colour reads. With /// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha diff --git a/crates/engine/src/cmd/adjust_params.rs b/crates/engine/src/cmd/adjust_params.rs index 6e89defef2..47ae39d2f9 100644 --- a/crates/engine/src/cmd/adjust_params.rs +++ b/crates/engine/src/cmd/adjust_params.rs @@ -1,3 +1,5 @@ +use photocraft_doc::BlendMode; + use super::enabled::{check_all_unlocked, is_mask_target, paint_surface}; use super::layer_params::{layer_param, mask_new_layer}; use super::{Adjustment, ColorMode, EngineError, Layer, LayerContent, Map, Result, Session, Value, json, pixels}; @@ -12,13 +14,32 @@ pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: & .filter(|o| o.is_finite() && (0.0..=1.0).contains(o)) .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "opacity must be a number in 0..1".into() })? as f32, }; + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + // Name, blend mode and clipping read like New Layer's (#8384). + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string".into()))?.to_string()), + }; + let mode = match p.get("mode") { + None | Some(Value::Null) => BlendMode::Normal, + Some(v) => { + let m = v.as_str().ok_or_else(|| bad("`mode` must be a blend mode name".into()))?; + crate::commands::blend_from_str(m).filter(|b| *b != BlendMode::PassThrough).ok_or_else(|| bad(format!("unknown layer blend mode `{m}`")))? + } + }; + let clip = match p.get("clipToPrevious") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("`clipToPrevious` must be true or false".into()))?, + }; let label = format!("New {} Layer", adj.label()); - let name = adj.label().to_string(); + let default_name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { - let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); + let mut l = Layer::new(name.clone().unwrap_or_else(|| doc.next_layer_name(&default_name)), LayerContent::Adjustment(adj)); l.opacity = opacity; + l.blend = mode; mask_new_layer(doc, &mut l, p, cmd, true)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); + crate::clip_group::settle(doc, &[id], &Default::default(), clip); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/cmd/align_cmds.rs b/crates/engine/src/cmd/align_cmds.rs index 1cddbb51cd..485c2085f6 100644 --- a/crates/engine/src/cmd/align_cmds.rs +++ b/crates/engine/src/cmd/align_cmds.rs @@ -31,7 +31,7 @@ use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; use crate::commands::CommandSpec; -use crate::photo_cmds::{NO_DETAIL, plane_matrix, register, register_direct, seam_blend, seam_order, warp_placed}; +use crate::photo_cmds::{BlendOptions, NO_DETAIL, plane_matrix, register, register_direct, seam_blend, seam_order, warp_placed}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -264,7 +264,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let order = seam_order(&surfs, 0); // Seamless Tones and Colors also evens out exposure and colour between the layers (gain // compensation over their overlaps), as in Photomerge. - let b = seam_blend(&surfs, area, &order, seamless, seamless, None); + let opt = BlendOptions { seamless, gains: seamless, geometry: None, budget: None }; + let b = seam_blend(surfs, area, &order, opt, None)?; let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index defaa834d4..d9d7d1510c 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -175,6 +175,7 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { /// as it was and the journal forgets the commands `f` ran, so an action being recorded does not /// replay an edit that was taken back. pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + crate::actions_cmds::before_action_edit(s); let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); // An inner command may select a layer that disappears when the compound rolls back. @@ -187,6 +188,8 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. let samplers = st.color_samplers.clone(); + // So is the painting symmetry, which a rotation moves with the pixels. + let symmetry = st.symmetry.clone(); let r = f(s); if r.is_err() { s.journal.truncate(journal); @@ -211,6 +214,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess st.channel_view = channel_view; st.coalesce = coalesce; st.color_samplers = samplers; + st.symmetry = symmetry; st.revision += 1; // A failed compound does not make an otherwise untouched document dirty. if was_clean { @@ -393,6 +397,10 @@ fn gray_range(depth: SampleType) -> f64 { /// gray image `gray` (0–1). Returns features in scan order of their first pixel; `origin` offsets /// the reported bounds. pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin: (i32, i32), range: f64) -> Vec { + measure_features_impl(mask, Some(gray), w, h, origin, range) +} + +fn measure_features_impl(mask: &[bool], gray: Option<&[f32]>, w: usize, h: usize, origin: (i32, i32), range: f64) -> Vec { // Two-pass connected components. let mut label = vec![u32::MAX; w * h]; let mut parent: Vec = Vec::new(); @@ -490,8 +498,15 @@ pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin: }, ); // Area, bounds and gray values. - let mut feats: Vec = - (0..n).map(|_| Feature { bounds: Rect::EMPTY, gray_min: f64::MAX, gray_max: f64::MIN, histogram: vec![0; 256], ..Default::default() }).collect(); + let mut feats: Vec = (0..n) + .map(|_| Feature { + bounds: Rect::EMPTY, + gray_min: f64::MAX, + gray_max: f64::MIN, + histogram: if gray.is_some() { vec![0; 256] } else { Vec::new() }, + ..Default::default() + }) + .collect(); let mut sums = vec![0.0f64; n]; let mut values: Vec> = vec![Vec::new(); n]; for y in 0..h { @@ -505,12 +520,14 @@ pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin: let (px, py) = (x as i32 + origin.0, y as i32 + origin.1); let r = Rect::new(px, py, px + 1, py + 1); f.bounds = if f.bounds.is_empty() { r } else { f.bounds.union(&r) }; - let g = f64::from(gray[y * w + x]); - f.gray_min = f.gray_min.min(g); - f.gray_max = f.gray_max.max(g); - sums[l as usize] += g; - f.histogram[(g.clamp(0.0, 1.0) * 255.0).round() as usize] += 1; - values[l as usize].push((g.clamp(0.0, 1.0) * 65535.0).round() as u16); + if let Some(gray) = gray { + let g = f64::from(gray[y * w + x]); + f.gray_min = f.gray_min.min(g); + f.gray_max = f.gray_max.max(g); + sums[l as usize] += g; + f.histogram[(g.clamp(0.0, 1.0) * 255.0).round() as usize] += 1; + values[l as usize].push((g.clamp(0.0, 1.0) * 65535.0).round() as u16); + } } } for (i, f) in feats.iter_mut().enumerate() { @@ -576,7 +593,7 @@ fn gray_image(d: &Document, r: Rect) -> Vec { buf.px.par_iter().map(|p| 0.30 * p[0] + 0.59 * p[1] + 0.11 * p[2]).collect() } -fn selection_rows(d: &Document) -> Result<(Feature, Vec)> { +fn selection_rows(d: &Document, needs_gray: bool) -> Result<(Feature, Vec)> { let r = match &d.selection { Some(sel) => sel.content_bounds().intersect(&d.bounds()), None => d.bounds(), @@ -597,13 +614,16 @@ fn selection_rows(d: &Document) -> Result<(Feature, Vec)> { }; let (w, h) = (r.width() as usize, r.height() as usize); let mask = selection_mask(d, r, n); - let gray = gray_image(d, r); + let gray = needs_gray.then(|| gray_image(d, r)); let range = gray_range(d.depth); - let feats = measure_features(&mask, &gray, w, h, (r.x0, r.y0), range); + let feats = measure_features_impl(&mask, gray.as_deref(), w, h, (r.x0, r.y0), range); if feats.is_empty() { return Err(EngineError::Other("the selection is empty".into())); } - let mut all: Vec = mask.iter().zip(&gray).filter(|(m, _)| **m).map(|(_, g)| (g.clamp(0.0, 1.0) * 65535.0).round() as u16).collect(); + let mut all: Vec = gray + .as_deref() + .map(|gray| mask.iter().zip(gray).filter(|(m, _)| **m).map(|(_, g)| (g.clamp(0.0, 1.0) * 65535.0).round() as u16).collect()) + .unwrap_or_default(); let med = median(&mut all) * range; Ok((summary(&feats, med), feats)) } @@ -876,9 +896,15 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { s.edit("Delete Count Group", |d, _| { d.measurement.count_groups.remove(g); let n = d.measurement.count_groups.len(); - // Removing a group before the active one shifts it down a slot. + // Deleting the current group makes the first one current (#11771); removing a group + // before the current one shifts it down a slot (#5654). let a = d.measurement.active_count_group; - d.measurement.active_count_group = (if g < a { a - 1 } else { a }).min(n.saturating_sub(1)); + let next = match g.cmp(&a) { + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Less => a - 1, + std::cmp::Ordering::Greater => a, + }; + d.measurement.active_count_group = next.min(n.saturating_sub(1)); Ok(()) })?; Ok(count_json(doc(s)?.as_ref())) @@ -998,7 +1024,13 @@ fn record(s: &mut Session, p: &Value) -> Result { let mut rows: Vec> = Vec::new(); match source { "selection" => { - let (sum, feats) = selection_rows(&d)?; + let needs_gray = s + .analysis + .data_points + .selection + .iter() + .any(|key| matches!(key.as_str(), "grayMin" | "grayMax" | "grayMean" | "grayMedian" | "integratedDensity" | "histogram")); + let (sum, feats) = selection_rows(&d, needs_gray)?; let mut m = base_row(s, &d, source, &label, &now); feature_values(&mut m, &sum, feats.len(), factor); rows.push(m); diff --git a/crates/engine/src/cmd/analysis_cmds/tests.rs b/crates/engine/src/cmd/analysis_cmds/tests.rs index c1184f2a4c..dc76470bc9 100644 --- a/crates/engine/src/cmd/analysis_cmds/tests.rs +++ b/crates/engine/src/cmd/analysis_cmds/tests.rs @@ -552,3 +552,18 @@ fn iso_from_unix_matches_the_old_cycle_conversion_including_beyond_chrono() { assert_eq!(iso_from_unix(i64::MAX), "292277026596-12-04T15:30:07Z"); assert_eq!(iso_from_unix(i64::MIN), "-292277022657-01-27T08:29:52Z"); } + +#[test] +fn geometric_features_match_full_measurements_without_gray() { + let mask = [true, true, false, false, false, false, false, true, true]; + let gray = [0.2; 9]; + let full = measure_features(&mask, &gray, 3, 3, (4, 7), 255.0); + let geometric = measure_features_impl(&mask, None, 3, 3, (4, 7), 255.0); + assert_eq!(full.len(), 2); + for (a, b) in full.iter().zip(&geometric) { + assert_eq!(a.area, b.area); + assert_eq!(a.perimeter, b.perimeter); + assert_eq!(a.bounds, b.bounds); + assert!(b.histogram.is_empty()); + } +} diff --git a/crates/engine/src/cmd/artboard_cmds.rs b/crates/engine/src/cmd/artboard_cmds.rs index f9e31e4cd0..91391edc25 100644 --- a/crates/engine/src/cmd/artboard_cmds.rs +++ b/crates/engine/src/cmd/artboard_cmds.rs @@ -19,6 +19,7 @@ use crate::{EngineError, Result, Session}; /// Artboard size presets (name, width, height) in pixels, Photoshop's common devices and pages. pub const PRESETS: &[(&str, u32, u32)] = &[ + ("iPhone 8/7/6", 750, 1334), ("iPhone 14", 390, 844), ("iPhone 14 Pro Max", 430, 932), ("Android 1080p", 360, 640), @@ -289,10 +290,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result { }; let background = background_param(p, cmd)?.unwrap_or_default(); let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string); + let take_background = match p.get("takeBackground") { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "`takeBackground` must be true or false"))?, + }; let id = s.edit("New Artboard", |doc, active| { let mut g = Layer::group(name.unwrap_or_else(|| next_artboard_name(doc)), vec![]); set_artboard(&mut g, Artboard { rect, background, preset }); + // Only the first board takes the Background in (decided before the board exists). + let take = take_background && doc.artboards().is_empty() && doc.background().is_some(); let id = doc.insert_above(None, g); + if take { + move_background_into(doc, id); + } fit_canvas(doc); *active = Some(id); Ok(id) @@ -301,6 +311,19 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) } +/// Turns the Background into a normal layer ("Layer 0") and makes it the child of board `board`, +/// as the reference does when the first artboard is drawn. Does nothing without a Background. +fn move_background_into(doc: &mut Document, board: LayerId) { + let Some(bg) = doc.background().map(|l| l.id) else { return }; + let Some(mut layer) = doc.remove(bg) else { return }; + crate::extra_cmds::unlock_background(&mut layer); + match doc.layer_mut(board).and_then(Layer::children_mut) { + Some(children) => children.push(layer), + // Unreachable for a group; put the layer back rather than lose it. + None => doc.layers.insert(0, layer), + } +} + /// Convert a plain group at its content bounds; recorded guide IDs are ignored session metadata. fn artboard_from_group(s: &mut Session, p: &Value) -> Result { let cmd = "layer.new.artboardFromGroup"; @@ -392,6 +415,9 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result { /// Changing X/Y moves the board with its contents; changing W/H resizes it from the top left. fn set_props(s: &mut Session, p: &Value) -> Result { let cmd = "layer.artboard.set"; + if p.get("layers").is_some() { + return set_backgrounds(s, p); + } let d = s.active().ok_or(EngineError::NoDocument)?; let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?; let old = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?; @@ -447,6 +473,34 @@ fn set_props(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) } +/// `layer.artboard.set` with a `layers` array: the background of every listed board in one undo +/// step, "Edit Artboard" for one board and "Edit Artboards" for several (the options bar's +/// background control with boards selected). Only `background`/`color` apply to several boards. +fn set_backgrounds(s: &mut Session, p: &Value) -> Result { + let cmd = "layer.artboard.set"; + let d = s.active().ok_or(EngineError::NoDocument)?; + let mut ids: Vec = Vec::new(); + for v in p.get("layers").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`layers` must be an array of layer ids"))? { + let id = LayerId(v.as_u64().ok_or_else(|| bad(cmd, "`layers` must be an array of layer ids"))?); + d.doc.layer(id).and_then(Layer::artboard).ok_or_else(|| bad(cmd, format!("layer {} is not an artboard", id.0)))?; + if !ids.contains(&id) { + ids.push(id); + } + } + if ids.is_empty() { + return Err(bad(cmd, "`layers` is empty")); + } + let background = background_param(p, cmd)?.ok_or_else(|| bad(cmd, "`layers` needs a `background`"))?; + let label = if ids.len() == 1 { "Edit Artboard" } else { "Edit Artboards" }; + s.edit(label, |doc, _| { + for id in &ids { + doc.layer_mut(*id).and_then(Layer::artboard_mut).ok_or(EngineError::NoLayer(*id))?.background = background; + } + Ok(()) + })?; + Ok(json!({"layers": ids.iter().map(|i| i.0).collect::>()})) +} + /// The Artboard tool's "+" buttons: a board the size of board `layer` on one of its sides, a /// [`GAP`] away (further out past any board in the way), empty or (`duplicate`, the ⌥-click) a /// copy with its contents. A board left of or above the canvas origin shifts the whole document @@ -705,7 +759,7 @@ pub fn specs() -> Vec { "layer.new.artboard", "Artboard…", &["Layer", "New"], - r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom)} → {layer, rect}"##, + r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom),"takeBackground":bool=false (the first board in the document also takes in the Background as a normal layer "Layer 0"; the Artboard tool's drag sets it)} → {layer, rect}"##, has_doc, new_artboard ), @@ -768,6 +822,9 @@ pub fn specs() -> Vec { ] } +#[cfg(test)] +#[path = "artboard_cmds/first_board_background_tests.rs"] +mod first_board_background_tests; #[cfg(test)] #[path = "artboard_cmds/tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/artboard_cmds/first_board_background_tests.rs b/crates/engine/src/cmd/artboard_cmds/first_board_background_tests.rs new file mode 100644 index 0000000000..5d1f50206f --- /dev/null +++ b/crates/engine/src/cmd/artboard_cmds/first_board_background_tests.rs @@ -0,0 +1,127 @@ +//! The first drawn artboard takes the Background in as "Layer 0" (#11387): `takeBackground` on +//! `layer.new.artboard`, at 8, 16 and 32 bit, with the cases that keep today's result. + +use super::*; +use photocraft_geom::Rect; + +/// 800 x 600 with the white Background and a pixel layer "Red" at 100,100 to 300,250. +fn session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "depth": depth, "background": "white"})).unwrap(); + s.edit("Red", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("Red", fmt); + l.surface_mut().unwrap().fill_rect(Rect { x0: 100, y0: 100, x1: 300, y1: 250 }, &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + s +} + +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; + +fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn names(layers: &[Layer]) -> Vec<&str> { + layers.iter().map(|l| l.name.as_str()).collect() +} + +fn steps(s: &Session) -> Vec { + s.active().unwrap().history.entries() +} + +fn draw(s: &mut Session, take: serde_json::Value) -> Result { + s.execute("layer.new.artboard", json!({"rect": [400, 300, 200, 200], "takeBackground": take})) +} + +#[test] +fn the_first_board_takes_the_background_in_as_layer_0() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let before = steps(&s).len(); + let r = draw(&mut s, json!(true)).unwrap(); + let board = LayerId(r["layer"].as_u64().unwrap()); + let d = doc(&s); + assert_eq!(names(&d.layers), ["Red", "Artboard 1"], "{depth} bit"); + assert!(d.background().is_none(), "{depth} bit"); + let b = d.layer(board).unwrap(); + assert_eq!(r["rect"], json!([400, 300, 200, 200])); + let kids = b.children().unwrap(); + assert_eq!(names(kids), ["Layer 0"], "{depth} bit"); + let l0 = kids.first().unwrap(); + assert!(!l0.locks.transparency && !l0.locks.position, "{depth} bit"); + // The Background's own pixels travel with it: white at both samples, and Red still red. + assert_eq!(l0.surface().unwrap().rgba(150, 150), WHITE, "{depth} bit"); + assert_eq!(l0.surface().unwrap().rgba(700, 500), WHITE, "{depth} bit"); + let red = d.layers.first().unwrap(); + assert_eq!(red.surface().unwrap().rgba(150, 150), [1.0, 0.0, 0.0, 1.0], "{depth} bit"); + assert_eq!((d.size.width, d.size.height), (800, 600), "{depth} bit"); + assert_eq!(s.active().unwrap().active_layer, Some(board)); + let after = steps(&s); + assert_eq!(after.len(), before + 1, "one history step"); + assert_eq!(after.last().map(String::as_str), Some("New Artboard")); + // One undo brings the Background back at the bottom with its name and locks. + s.undo(); + let d = doc(&s); + assert_eq!(names(&d.layers), ["Background", "Red"], "{depth} bit"); + let bg = d.background().unwrap(); + assert!(bg.locks.transparency && bg.locks.position, "{depth} bit"); + assert_eq!(bg.surface().unwrap().rgba(150, 150), WHITE, "{depth} bit"); + assert_eq!(bg.surface().unwrap().rgba(700, 500), WHITE, "{depth} bit"); + assert!(d.artboards().is_empty()); + assert_eq!((d.size.width, d.size.height), (800, 600)); + } +} + +#[test] +fn without_the_param_or_with_false_the_background_stays_outside() { + for p in [json!({"rect": [400, 300, 200, 200]}), json!({"rect": [400, 300, 200, 200], "takeBackground": false})] { + let mut s = session(8); + s.execute("layer.new.artboard", p).unwrap(); + assert_eq!(names(&doc(&s).layers), ["Background", "Red", "Artboard 1"]); + assert!(doc(&s).background().is_some()); + } +} + +#[test] +fn with_a_board_already_present_the_result_is_as_today() { + let mut s = session(8); + s.execute("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap(); + draw(&mut s, json!(true)).unwrap(); + let d = doc(&s); + assert_eq!(names(&d.layers), ["Background", "Red", "Artboard 1", "Artboard 2"]); + assert!(d.background().is_some()); + assert!(d.artboards().iter().all(|b| b.1 != "Layer 0")); +} + +#[test] +fn without_a_background_the_result_is_as_today() { + let mut s = session(8); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + assert!(doc(&s).background().is_none()); + draw(&mut s, json!(true)).unwrap(); + assert_eq!(names(&doc(&s).layers), ["Layer 0", "Red", "Artboard 1"]); + assert!(doc(&s).layers.last().unwrap().children().unwrap().is_empty()); +} + +#[test] +fn a_non_boolean_take_background_is_an_error_and_changes_nothing() { + for bad in [json!("true"), json!(1), json!([true])] { + let mut s = session(8); + let (n, count) = (steps(&s).len(), doc(&s).layers.len()); + assert!(draw(&mut s, bad.clone()).is_err(), "{bad}"); + assert_eq!(steps(&s).len(), n, "{bad}"); + assert_eq!(doc(&s).layers.len(), count, "{bad}"); + assert!(doc(&s).background().is_some(), "{bad}"); + } +} + +#[test] +fn the_param_is_listed_in_the_command_params_doc() { + let spec = specs().into_iter().find(|c| c.id == "layer.new.artboard").unwrap(); + assert!(spec.params.contains("takeBackground")); +} diff --git a/crates/engine/src/cmd/artboard_cmds/tests.rs b/crates/engine/src/cmd/artboard_cmds/tests.rs index 39b663efbe..e079c5b54c 100644 --- a/crates/engine/src/cmd/artboard_cmds/tests.rs +++ b/crates/engine/src/cmd/artboard_cmds/tests.rs @@ -840,3 +840,268 @@ fn from_layers_defaults_are_the_selected_layers_size() { assert_eq!(from_layers_defaults(&s), Some(("Artboard 1".to_string(), [100, 80])), "an empty layer: the canvas"); assert_eq!(from_layers_defaults(&Session::new()), None); } + +/// Board "A" at (10, 10, 50, 50) holding a red square at (10..30)², and an empty board "B" at +/// (200, 20, 240, 60). +fn nest_boards(s: &mut Session) -> (LayerId, LayerId, LayerId) { + let red = add_square(s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + let a = LayerId(s.execute("layer.new.artboardFromLayers", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.artboard.set", json!({"width": 40, "height": 40})).unwrap(); + let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [200, 20, 40, 40], "name": "B"})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(s).artboard_of(red), Some(a)); + (red, a, b) +} + +/// The ids of a board's children, bottom to top. +fn children(s: &Session, board: LayerId) -> Vec { + doc(s).layer(board).unwrap().children().unwrap().iter().map(|l| l.id).collect() +} + +/// Auto-nesting (Photoshop's Move tool): a layer dragged onto another artboard joins it, on top of +/// its stack, instead of staying in its old board and being clipped away; dragged onto the +/// pasteboard, it leaves for the top level, above the board it left. One step for Undo. +#[test] +fn a_layer_moved_onto_another_artboard_joins_it() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let (red, a, b) = nest_boards(&mut s); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("layer.translate", json!({"layer": red.0, "dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(r, json!({"nested": [red.0]}), "{depth}"); + assert_eq!(doc(&s).artboard_of(red), Some(b), "{depth}"); + assert_eq!(children(&s, b).last(), Some(&red), "{depth}: on top of the board's stack"); + assert!(red_at(&s, 205, 25), "{depth}: shows on board B"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}: one step"); + + // Off every board: to the top level, directly above the board it left. + s.execute("layer.translate", json!({"layer": red.0, "dx": -100, "dy": 0, "nest": true})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), None, "{depth}"); + let top: Vec = doc(&s).layers.iter().map(|l| l.id).collect(); + let at = top.iter().position(|id| *id == red).unwrap(); + assert_eq!(top.get(at - 1), Some(&b), "{depth}: above the board it left"); + assert!(red_at(&s, 105, 25), "{depth}: shows over the pasteboard"); + + // Undo takes the move and the change of board back together. + s.undo(); + assert_eq!(doc(&s).artboard_of(red), Some(b), "{depth}"); + s.undo(); + assert_eq!(doc(&s).artboard_of(red), Some(a), "{depth}"); + assert!(red_at(&s, 15, 15), "{depth}"); + } +} + +/// Only the Move tool nests (it passes `nest`): a nudge or a script's `layer.translate` moves the +/// layer and leaves it where it is in the stack, as before. +#[test] +fn translate_without_nest_leaves_the_layer_in_its_board() { + let mut s = session(8); + let (red, a, _b) = nest_boards(&mut s); + let r = s.execute("layer.translate", json!({"layer": red.0, "dx": 190, "dy": 10})).unwrap(); + assert_eq!(r, Value::Null); + assert_eq!(doc(&s).artboard_of(red), Some(a)); + assert!(s.execute("layer.translate", json!({"layer": red.0, "dx": 1, "nest": "yes"})).is_err(), "nest must be a boolean"); +} + +/// A layer that stays on its board, or moves between spots off every board, doesn't change parent; +/// a loose layer dragged onto a board joins it. +#[test] +fn nesting_changes_parent_only_when_the_board_changes() { + let mut s = session(8); + let (red, a, b) = nest_boards(&mut s); + let r = s.execute("layer.translate", json!({"layer": red.0, "dx": 5, "dy": 5, "nest": true})).unwrap(); + assert_eq!(r, Value::Null, "still on board A"); + assert_eq!(doc(&s).artboard_of(red), Some(a)); + // Out to the pasteboard, then along it: no board either side. + s.execute("layer.translate", json!({"layer": red.0, "dx": 80, "dy": 0, "nest": true})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), None); + assert_eq!(s.execute("layer.translate", json!({"layer": red.0, "dx": 10, "dy": 0, "nest": true})).unwrap(), Value::Null); + // And onto board B from outside any board. + s.execute("layer.translate", json!({"layer": red.0, "dx": 95, "dy": 10, "nest": true})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(b)); + // Moving a board itself never nests it. + assert_eq!(s.execute("layer.translate", json!({"layer": a.0, "dx": 190, "dy": 60, "nest": true})).unwrap(), Value::Null); + assert!(doc(&s).layers.iter().any(|l| l.id == a), "board A stays at the top level"); +} + +/// Lock › Prevent Auto-Nesting, on the layer or on the board it would join, keeps it where it is. +#[test] +fn prevent_auto_nesting_keeps_the_layer_in_its_board() { + let mut s = session(8); + let (red, a, b) = nest_boards(&mut s); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("layer.lockLayers", json!({"artboard": true})).unwrap(); + s.execute("layer.translate", json!({"layer": red.0, "dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(a), "locked layer"); + s.execute("layer.lockLayers", json!({"artboard": false})).unwrap(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("layer.lockLayers", json!({"artboard": true})).unwrap(); + s.execute("layer.translate", json!({"layer": red.0, "dx": 0, "dy": 1, "nest": true})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(a), "locked board takes nothing in"); +} + +/// Several layers dragged onto a board together keep their order there. +#[test] +fn layers_moved_together_keep_their_order_on_the_new_board() { + let mut s = session(8); + let (red, a, b) = nest_boards(&mut s); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + let blue = add_square(&mut s, Rect::new(12, 12, 28, 28), [0.0, 0.0, 1.0, 1.0]); + s.execute("layer.moveTo", json!({"layer": blue.0, "target": a.0, "position": "into"})).unwrap(); + assert_eq!(children(&s, a), vec![red, blue]); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("layer.select", json!({"layer": blue.0, "mode": "add"})).unwrap(); + let r = s.execute("layer.translate", json!({"dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(r["nested"].as_array().unwrap().len(), 2); + assert_eq!(children(&s, b), vec![red, blue], "same order, blue still above red"); + assert!(children(&s, a).is_empty()); +} + +/// A square `r` of `rgba` on top of `board`'s stack, clipped to the layer below it. +fn clipped_square(s: &mut Session, board: LayerId, r: Rect, rgba: [f32; 4]) -> LayerId { + let id = add_square(s, r, rgba); + s.execute("layer.moveTo", json!({"layer": id.0, "target": board.0, "position": "into"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id.0, "clipped": true})).unwrap(); + id +} + +fn clipped(s: &Session, id: LayerId) -> bool { + doc(s).layer(id).unwrap().clipped +} + +fn green_at(s: &Session, x: i32, y: i32) -> bool { + let px = photocraft_compose::render(doc(s), Rect::new(x, y, x + 1, y + 1)).px[0]; + px[0] < 0.01 && px[1] > 0.99 && px[3] > 0.99 +} + +/// A clipping group moves between boards as one (#10512 follow-up): the base dragged onto board B +/// takes the layers clipped to it along, in order, directly above it and still clipped. Left on +/// board A, they were released and covered the whole board. One step for Undo. +#[test] +fn a_clipping_base_moved_onto_another_artboard_takes_its_clipped_layers() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let (red, a, b) = nest_boards(&mut s); + // Two green layers covering board A, clipped to the red square. + let c1 = clipped_square(&mut s, a, Rect::new(10, 10, 50, 50), [0.0, 1.0, 0.0, 1.0]); + let c2 = clipped_square(&mut s, a, Rect::new(10, 10, 50, 50), [0.0, 1.0, 0.0, 1.0]); + assert_eq!(children(&s, a), vec![red, c1, c2], "{depth}"); + assert!(green_at(&s, 15, 15) && !green_at(&s, 40, 40), "{depth}: green only over red"); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("layer.translate", json!({"layer": red.0, "dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(r, json!({"nested": [red.0, c1.0, c2.0]}), "{depth}"); + assert_eq!(children(&s, b), vec![red, c1, c2], "{depth}: directly above their base, in order"); + assert!(children(&s, a).is_empty(), "{depth}"); + assert!(!clipped(&s, red) && clipped(&s, c1) && clipped(&s, c2), "{depth}: still clipped"); + assert!(!green_at(&s, 40, 40), "{depth}: board A isn't covered"); + assert!(red_at(&s, 205, 25), "{depth}: the green, left at A's spot, isn't over red on B"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}: one step"); + + s.undo(); + assert_eq!(children(&s, a), vec![red, c1, c2], "{depth}"); + assert!(clipped(&s, c1) && clipped(&s, c2), "{depth}"); + assert!(green_at(&s, 15, 15), "{depth}"); + } +} + +/// A clipped layer goes where its base goes (#10512 follow-up). Dragged off its board on its own, +/// it stays on it, clipped: it shows only over its base, which hasn't moved, as Photoshop's +/// clipping masks keep a clipped layer with its base. Dragged together with its base, it follows +/// the base, as when the base is dragged alone. +#[test] +fn a_clipped_layer_goes_where_its_base_goes() { + let mut s = session(8); + let (red, a, b) = nest_boards(&mut s); + let c = clipped_square(&mut s, a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0]); + let r = s.execute("layer.translate", json!({"layer": c.0, "dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(r, Value::Null, "nothing changes board"); + assert_eq!(children(&s, a), vec![red, c]); + assert!(clipped(&s, c)); + // Moved back over its base; then both are dragged onto board B together. + s.execute("layer.translate", json!({"layer": c.0, "dx": -190, "dy": -10})).unwrap(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("layer.select", json!({"layer": c.0, "mode": "add"})).unwrap(); + let r = s.execute("layer.translate", json!({"dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(r, json!({"nested": [red.0, c.0]})); + assert_eq!(children(&s, b), vec![red, c]); + assert!(clipped(&s, c)); + assert!(green_at(&s, 205, 25), "the clipped layer still shows over its base"); +} + +/// When any layer of a clipping group can't change board (Prevent Auto-Nesting on a clipped +/// layer here), the whole group stays where it is, clipped, rather than splitting. +#[test] +fn a_clipping_group_with_a_locked_layer_stays_whole() { + let mut s = session(8); + let (red, a, _b) = nest_boards(&mut s); + let c = clipped_square(&mut s, a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0]); + s.execute("layer.select", json!({"layer": c.0})).unwrap(); + s.execute("layer.lockLayers", json!({"artboard": true})).unwrap(); + let r = s.execute("layer.translate", json!({"layer": red.0, "dx": 190, "dy": 10, "nest": true})).unwrap(); + assert_eq!(r, Value::Null); + assert_eq!(children(&s, a), vec![red, c]); + assert!(clipped(&s, c)); +} + +/// Where `id`'s pixels are. Its own name: main's tests (#10489) already have a `content`. +fn pixel_bounds(s: &Session, id: LayerId) -> Rect { + doc(s).layer(id).unwrap().surface().unwrap().content_bounds() +} + +/// The board a dragged layer joins is the one under the true middle of its bounds (#10512 +/// review): boards L (20..30) and R (30..40) meet at x = 30, and a layer dropped at 29..31 is +/// centred at 30, on R. Halving each edge first put it at 29, on L, where it was clipped. +#[test] +fn a_layer_straddling_two_boards_joins_the_one_under_its_middle() { + let mut s = session(8); + let l = LayerId(s.execute("layer.new.artboard", json!({"rect": [20, 0, 10, 20], "name": "L"})).unwrap()["layer"].as_u64().unwrap()); + let r = LayerId(s.execute("layer.new.artboard", json!({"rect": [30, 0, 10, 20], "name": "R"})).unwrap()["layer"].as_u64().unwrap()); + // A loose 2-pixel-wide layer at 49..51, off both boards. + let id = add_square(&mut s, Rect::new(49, 5, 51, 10), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(doc(&s).artboard_of(id), None); + s.execute("layer.translate", json!({"layer": id.0, "dx": -20, "dy": 0, "nest": true})).unwrap(); + assert_eq!(pixel_bounds(&s, id), Rect::new(29, 5, 31, 10)); + assert_eq!(doc(&s).artboard_of(id), Some(r), "its middle, x = 30, is on R"); + assert_ne!(doc(&s).artboard_of(id), Some(l)); +} + +/// Negative coordinates round down too (#10512 review): a layer dropped at -4..3 is centred at +/// -0.5, left of a board starting at x = 0, so it stays off the board. Truncating toward zero put +/// its middle at 0, inside the board. +#[test] +fn a_layer_centred_just_left_of_a_board_at_negative_coordinates_stays_off_it() { + let mut s = session(8); + let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [0, 0, 20, 20], "name": "B"})).unwrap()["layer"].as_u64().unwrap()); + let id = add_square(&mut s, Rect::new(-24, 5, -17, 10), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(doc(&s).artboard_of(id), None); + s.execute("layer.translate", json!({"layer": id.0, "dx": 20, "dy": 0, "nest": true})).unwrap(); + assert_eq!(pixel_bounds(&s, id), Rect::new(-4, 5, 3, 10)); + assert_eq!(doc(&s).artboard_of(id), None, "centred at -0.5, left of B"); + // One pixel further right (-3..4, middle 0.5) is on the board. + s.execute("layer.translate", json!({"layer": id.0, "dx": 1, "dy": 0, "nest": true})).unwrap(); + assert_eq!(doc(&s).artboard_of(id), Some(b)); +} + +#[test] +fn a_layers_array_sets_every_listed_boards_background_in_one_step() { + let mut s = session(8); + let a = LayerId(s.execute("layer.new.artboard", json!({"rect": [0, 0, 50, 50]})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [200, 0, 50, 50]})).unwrap()["layer"].as_u64().unwrap()); + let steps = s.active().unwrap().history.entries().len(); + s.execute("layer.artboard.set", json!({"layers": [a.0, b.0], "background": "black"})).unwrap(); + let entries = s.active().unwrap().history.entries(); + assert_eq!(entries.len(), steps + 1); + assert_eq!(entries.last().map(String::as_str), Some("Edit Artboards")); + for id in [a, b] { + assert_eq!(doc(&s).layer(id).and_then(Layer::artboard).unwrap().background, ArtboardBackground::Black); + } + s.execute("layer.artboard.set", json!({"layers": [a.0], "background": "custom", "color": "#ff0000"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Edit Artboard")); + // Undo is one step; errors change nothing. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s).layer(a).and_then(Layer::artboard).unwrap().background, ArtboardBackground::Black); + let n = s.active().unwrap().history.entries().len(); + assert!(s.execute("layer.artboard.set", json!({"layers": [a.0, 9999], "background": "white"})).is_err()); + assert!(s.execute("layer.artboard.set", json!({"layers": [], "background": "white"})).is_err()); + assert!(s.execute("layer.artboard.set", json!({"layers": [a.0]})).is_err()); + assert_eq!(s.active().unwrap().history.entries().len(), n); +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 41078987cd..0e90e5d3dc 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,26 +32,40 @@ mod descriptors; mod events; mod fields; mod files; +mod menu_item; mod steps_artboard; +mod steps_batch; +mod steps_calculations; mod steps_channels; +mod steps_content_aware; mod steps_direct_adjustments; mod steps_distort; mod steps_doc; +mod steps_droplet; mod steps_gallery; +mod steps_gradient; mod steps_image; mod steps_layer; +mod steps_layer_style; +mod steps_lookup; mod steps_make; - -mod steps_stylize; +mod steps_path; +mod steps_perspective_warp; +mod steps_type_path; mod steps_other; mod steps_render; mod steps_selection; +mod steps_shape_blur; +mod steps_stack; +mod steps_stylize; +mod steps_video; mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; +pub(crate) use convert::UniqueNames; pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; @@ -66,6 +80,7 @@ use events::{EVENTS, event_and_descriptor, event_for}; #[cfg(test)] use fields::Mapped; use files::{load, save}; +pub use menu_item::{MENU_ITEM, menu_item_command, menu_item_commands, menu_item_needs_settings}; #[cfg(test)] use steps_doc::crop_from; pub(crate) use steps_gallery::RECORDED_EVENT; @@ -140,6 +155,16 @@ pub fn specs() -> Vec { run: stop_message, journal: true, }, + CommandSpec { + id: MENU_ITEM, + label: "Menu Item", + menu: &[], + shortcut: None, + params: r##"{"id":menu command,"eventKind":"name"|"code"="name"} (a descriptor-free Image Size or unambiguous mapped filter menu event; desktop playback asks for settings when needed, headless playback only runs stateless menu items)"##, + enabled: always, + run: menu_item::run, + journal: true, + }, CommandSpec { id: PHOTOSHOP_STEP, label: "Photoshop Step", @@ -167,3 +192,6 @@ mod selection_tests; #[cfg(test)] mod render_tests; + +#[cfg(test)] +mod recorded_tools_tests; diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 4b22515c09..71b64db4cb 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -69,14 +69,18 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); for (i, item) in a.items.iter().enumerate() { - let mapped = match (event_for(&item.event), &item.descriptor) { - (Some(e), Some(d)) => (e.from)(d), - (Some(e), None) => (e.from)(&Descriptor::default()), - (None, _) => Mapped::Unsupported { harmless: false }, + let menu = item.descriptor.is_none().then(|| super::menu_item::from_event(&item.event)).flatten(); + let mapped = match (menu, event_for(&item.event), &item.descriptor) { + (Some(params), _, _) => Mapped::Steps(vec![(super::MENU_ITEM.into(), params)]), + (_, Some(e), Some(d)) => (e.from)(d), + (_, Some(e), None) => (e.from)(&Descriptor::default()), + _ => Mapped::Unsupported { harmless: false }, }; let (new_steps, enabled) = match mapped { Mapped::Steps(mut s) => { super::steps_gallery::remember_event(item, &mut s); + super::steps_stack::remember_item(item, &mut s); + super::steps_video::remember(item, &mut s); (s, item.enabled) } Mapped::Unsupported { harmless } => { @@ -134,6 +138,9 @@ pub struct Skipped { } pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option { + if id == super::MENU_ITEM { + return super::menu_item::export(params); + } if id == PHOTOSHOP_STEP { let event = jstr(params, "event")?; let event = match params.get("eventKind") { @@ -154,7 +161,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option Option, next_suffix: std::collections::HashMap, } impl UniqueNames { - pub(super) fn new>(existing: I) -> Self { + pub(crate) fn new>(existing: I) -> Self { UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() } } /// Reserves and returns a unique name derived from `name`, recording it as taken. - pub(super) fn claim(&mut self, name: &str) -> String { + pub(crate) fn claim(&mut self, name: &str) -> String { if self.taken.insert(name.to_string()) { return name.to_string(); } diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 87d327c2e5..162bf22f8a 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -6,8 +6,9 @@ use super::descriptors::{ use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from, artboard_to}; use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to}; +use super::steps_content_aware::{caf_from, caf_to}; use super::steps_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_to}; -use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to}; +use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, shear_from, shear_to, wave_from, wave_to}; use super::steps_doc::{ align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to, crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to, @@ -23,6 +24,7 @@ use super::steps_layer::{ move_from, move_to, paste_effects_from, paste_effects_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to, }; +use super::steps_layer_style::{scale_from, style_to}; use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to}; use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; @@ -86,18 +88,21 @@ fn applies_at_canvas_bounds(p: &Value) -> bool { } /// Settings a mapped event has no field for, by command: `(param, the value the event plays as)`. -/// Photoshop's event has nowhere to put these, so a step that sets one cannot be written: it +/// The exported event has nowhere to put these, so a step that sets one cannot be written: it /// would import as a different edit, silently. Such a step is refused, the way [`pixel_target`] /// refuses a filter aimed at a mask -- and no key Photoshop does not define is invented for it, /// which the clean-room rule forbids and which would make the file wrong in another reader. /// /// A step that leaves the setting at the value the event plays as exports as before. /// How a setting the event cannot carry behaves once the step is replayed without it. Which of -/// the two a setting takes follows the command's own reading of it, not the parameter spec. +/// these a setting takes follows the command's own reading of it, not the parameter spec. enum Lost { - /// Replay uses this value, so only a step already at it survives. Numbers and strings are - /// both compared as written, since the commands below read these settings as written. + /// Replay uses this value, so only a step already at it survives. Numbers, strings and + /// booleans are compared as written, since the commands below read these settings as written. ReplaysAs(&'static str), + /// Replay uses this boolean, and the command refuses any other type, so only a step at this + /// boolean survives: a string such as `"true"` is refused. + ReplaysAsBool(bool), /// The command reads only this one spelling as anything other than what replay does, so every /// other value survives -- a differently cased one included, because it too plays as replay's. DiffersAt(&'static str), @@ -112,6 +117,8 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ // the exact string "roundness" -- see `preserve` in filters.rs. ("filter.other.minimum", &[("preserve", Lost::DiffersAt("roundness"))]), ("filter.other.maximum", &[("preserve", Lost::DiffersAt("roundness"))]), + // Eqlz is exported with no fields; replay equalizes the selected area only. + ("image.adjustments.equalize", &[("selectedAreaOnly", Lost::ReplaysAsBool(true))]), ]; /// Whether every setting of `p` that its event cannot hold would play the same without it @@ -119,8 +126,15 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ fn holds_its_settings(id: &str, p: &Value) -> bool { let Some((_, settings)) = UNHOLDABLE.iter().find(|(cmd, _)| *cmd == id) else { return true }; settings.iter().all(|(param, lost)| match (p.get(param), lost) { - (None | Some(Value::Null), _) => true, - (Some(v), Lost::ReplaysAs(played)) => v.as_str() == Some(played) || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)), + (None, _) => true, + // The command refuses null here too, so a null step is refused like any other non-boolean. + (Some(v), Lost::ReplaysAsBool(played)) => v.as_bool() == Some(*played), + (Some(Value::Null), _) => true, + (Some(v), Lost::ReplaysAs(played)) => { + v.as_str() == Some(played) + || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)) + || v.as_bool().is_some_and(|b| played.parse::() == Ok(b)) + } (Some(v), Lost::DiffersAt(spelling)) => v.as_str() != Some(spelling), }) } @@ -273,16 +287,22 @@ fn target_only_to(_: &str, p: &Value) -> Option { const SHAPE_BLUR: &[F] = &[F("Rds ", "radius", Kind::Px)]; -/// Map the radius-only Shape Blur form; keep custom assets or extra keys unsupported. +/// Dispatch the radius-only and installed custom-shape forms through one event. fn shape_blur_from(d: &Descriptor) -> Mapped { + if d.get("customShape").is_some() { + return super::steps_shape_blur::from(d); + } let unsupported = Mapped::Unsupported { harmless: false }; let Some(p) = fields_from(d, SHAPE_BLUR) else { return unsupported }; let p = Value::Object(p); if shape_blur_to("filter.blur.shapeBlur", &p).is_some_and(|expected| d.items == expected.items) { step("filter.blur.shapeBlur", p) } else { unsupported } } -/// Write an optional finite 5–1000 pixel radius, refusing explicit shapes and other options. -fn shape_blur_to(_: &str, p: &Value) -> Option { +/// Keep both represented forms; arbitrary native geometry and other options still refuse export. +fn shape_blur_to(id: &str, p: &Value) -> Option { + if ["shapeName", "shapeId", "shapeOriginType"].iter().any(|key| p.get(*key).is_some()) { + return super::steps_shape_blur::to(id, p); + } if !params(p, &["radius"]) { return None; } @@ -548,6 +568,7 @@ pub(super) const EVENTS: &[Event] = &[ &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), // Distort (#10605): every key in recorded order, Photoshop's limits and defaults. + Event { code: "Shr ", name: "shear", label: "Shear", ids: &["filter.distort.shear"], from: shear_from, to: shear_to }, Event { code: "Pnch", name: "pinch", @@ -653,6 +674,14 @@ pub(super) const EVENTS: &[Event] = &[ to: empty_to, }, // Adjustments (Image › Adjustments). + Event { + code: "", + name: "colorLookup", + label: "Color Lookup", + ids: &["image.adjustments.colorLookup"], + from: super::steps_lookup::from, + to: super::steps_lookup::to, + }, Event { code: "", name: "adaptCorrect", @@ -763,11 +792,36 @@ pub(super) const EVENTS: &[Event] = &[ }, Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to }, // Layers. + Event { code: "", name: "scaleEffectsEvent", label: "Scale Effects", ids: &["layer.layerStyle.scaleEffects"], from: scale_from, to: style_to }, target_event!("reverse", "Reverse", "layer.arrange.reverse"), target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"), target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"), target_event!("rasterizeAll", "Rasterize All Layers", "layer.rasterize.allLayers"), target_event!("addBlankVideoLayer", "New Blank Video Layer", "layer.videoLayers.newBlankVideoLayer"), + Event { + code: "", + name: "insertBlankFrame", + label: "Insert Blank Frame", + ids: &["layer.videoLayers.insertBlankFrame"], + from: |d| super::steps_video::from(d, "layer.videoLayers.insertBlankFrame"), + to: |_, p| super::steps_video::to(p), + }, + Event { + code: "", + name: "duplicateFrame", + label: "Duplicate Frame", + ids: &["layer.videoLayers.duplicateFrame"], + from: |d| super::steps_video::from(d, "layer.videoLayers.duplicateFrame"), + to: |_, p| super::steps_video::to(p), + }, + Event { + code: "", + name: "deleteFrame", + label: "Delete Frame", + ids: &["layer.videoLayers.deleteFrame"], + from: |d| super::steps_video::from(d, "layer.videoLayers.deleteFrame"), + to: |_, p| super::steps_video::to(p), + }, target_event!("isolateLayers", "Isolate Layers", "select.isolateLayers"), target_event!("textLoadDefaultTypeStyles", "Load Default Type Styles", "type.loadDefaultTypeStyles"), target_event!("textSaveDefaultTypeStyles", "Save Default Type Styles", "type.saveDefaultTypeStyles"), @@ -795,8 +849,11 @@ pub(super) const EVENTS: &[Event] = &[ name: "make", label: "Make", ids: &[ + "image.calculations", "type.convertToShape", + "type.createWorkPath", "layer.new.layer", + "layer.layerStyle.createLayer", "layer.new.group", "layer.groupLayers", "layer.duplicate", @@ -804,6 +861,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.new.layerViaCut", "layer.newFillLayer.solidColor", "layer.newFillLayer.pattern", + "layer.newFillLayer.gradient", "layer.newAdjustmentLayer.levels", "layer.newAdjustmentLayer.curves", "layer.newAdjustmentLayer.hueSaturation", @@ -848,9 +906,11 @@ pub(super) const EVENTS: &[Event] = &[ "layer.setLabelColor", "layer.setAdjustment", "layer.fill.pattern.set", + "gradient.fill.set", "layer.new.layerFromBackground", "layer.layerStyle.blendingOptions", "layer.smartFilter.blendingOptions", + "path.set", ], from: set_from, to: set_to, @@ -910,6 +970,22 @@ pub(super) const EVENTS: &[Event] = &[ ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]), ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]), ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]), + Event { + code: "", + name: "applyImageStackPluginRenderer", + label: "Stack Mode", + ids: super::steps_stack::MODE_IDS, + from: super::steps_stack::mode_from, + to: super::steps_stack::mode_to, + }, + Event { + code: "", + name: "imageStackConvertSmartObject", + label: "Stack Mode: None", + ids: &[super::steps_stack::NONE], + from: super::steps_stack::none_from, + to: super::steps_stack::none_to, + }, target_event!("placedLayerMakeCopy", "New Smart Object via Copy", "layer.smartObjects.newSmartObjectViaCopy"), target_event!("placedLayerConvertToLayers", "Convert to Layers", "layer.smartObjects.convertToLayers"), target_event!("placedLayerConvertToEmbedded", "Convert to Embedded", "layer.smartObjects.convertToEmbedded"), @@ -1047,6 +1123,22 @@ pub(super) const EVENTS: &[Event] = &[ ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]), Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to }, // Edit. + Event { + code: "", + name: "perspectiveWarpTransform", + label: "Perspective Warp", + ids: &[crate::distort_cmds::PERSPECTIVE], + from: super::steps_perspective_warp::from, + to: super::steps_perspective_warp::to, + }, + Event { + code: "Grdn", + name: "gradientClassEvent", + label: "Gradient", + ids: &["paint.gradient"], + from: super::steps_gradient::from, + to: super::steps_gradient::to, + }, Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, Event { code: "Strk", @@ -1090,6 +1182,7 @@ pub(super) const EVENTS: &[Event] = &[ from: |d| profile_from(d, "edit.convertToProfile"), to: profile_to, }, + Event { code: "", name: "cafWorkspace", label: "Content-Aware Fill", ids: &["edit.contentAwareFill"], from: caf_from, to: caf_to }, // File and actions. Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, @@ -1099,6 +1192,23 @@ pub(super) const EVENTS: &[Event] = &[ Event { code: "save", name: "save", label: "Save", ids: &["file.save", "file.saveAs", "file.saveACopy"], from: save_from, to: save_to }, ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, + Event { + code: "Btch", + name: "batch", + label: "Batch", + ids: &["file.automate.batch"], + from: super::steps_batch::batch_from, + to: super::steps_batch::batch_to, + }, + // Create Droplet (#10710): Photoshop has no string ID for it. + Event { + code: "CrtD", + name: "CrtD", + label: "Create Droplet", + ids: &["file.automate.createDroplet"], + from: super::steps_droplet::droplet_from, + to: super::steps_droplet::droplet_to, + }, Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to }, Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; @@ -1122,6 +1232,7 @@ const LEGACY_CODES: &[(&str, &str)] = &[ /// reference application reports `IntW` and `Dstr` as `interfaceWhite` and `distort` at run time. const NAME_ALIASES: &[(&str, &str)] = &[("interfaceWhite", "intersectWith"), ("distort", "distribute")]; +/// Match populated event identifiers, retaining legacy code aliases on import. pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); let by_name = |name: &str| EVENTS.iter().find(|e| !e.name.is_empty() && e.name == name); diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 307043b592..14be047374 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -1,6 +1,8 @@ use super::convert::{UniqueNames, export, import_with_encoding}; use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json}; +/// Load a set and retarget its own action references once, preserving foreign qualifications +/// even when the caller changes the imported set's name or action names collide. pub(super) fn load(s: &mut Session, p: &Value) -> Result { let cmd = "actions.load"; if s.actions.recording.is_some() || s.actions.playing > 0 { @@ -14,6 +16,7 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { }; let (bytes, _) = crate::preset_import_cmds::file_bytes(p, cmd, ".atn file")?; let mut imp = import_with_encoding(&bytes, encoding).map_err(|e| bad(cmd, e))?; + let original_set = imp.set.clone(); match p.get("set") { None | Some(Value::Null) => {} Some(Value::String(n)) if !n.trim().is_empty() => { @@ -48,13 +51,22 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { names.push(name); added.push(a); } - // Play and Conditional steps call actions by name: follow the renames in the actions just - // loaded, each target looked up once by the name the file gave it. + // Play, Conditional, Batch and Create Droplet steps call actions by name: follow the renames in + // the actions just loaded, each target looked up once by the name the file gave it. for a in &mut added { for (id, params) in &mut a.steps { + // A qualified Batch or Create Droplet reference belongs to the file's original set, not + // to a foreign set which happens to have the destination override's name. + let by_set = matches!(id.as_str(), "file.automate.batch" | "file.automate.createDroplet"); + if by_set && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) { + params["set"] = json!(imp.set); + } else if by_set && params.get("set").is_some_and(|set| !set.is_null()) { + continue; + } let keys: &[&str] = match id.as_str() { "actions.play" => &["action"], "actions.conditional" => &["then", "else"], + "file.automate.batch" | "file.automate.createDroplet" => &["action"], _ => &[], }; for key in keys { diff --git a/crates/engine/src/cmd/atn_cmds/menu_item.rs b/crates/engine/src/cmd/atn_cmds/menu_item.rs new file mode 100644 index 0000000000..69ed161cd6 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/menu_item.rs @@ -0,0 +1,90 @@ +//! Descriptor-free menu intent is distinct from a command with recorded settings. +//! Image Size and unambiguous mapped filter events keep absent settings distinct from empty ones. + +use super::events::{EVENTS, Event, event_for}; +use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json}; + +/// Find a supported, unambiguous event for a menu step with no settings. +fn menu_event(id: &str) -> Option<&'static Event> { + // Camera Raw's ATN mapping requires a verified settings descriptor (#8714). + if id == crate::lens_cmds::RAW || (id != "image.imageSize" && !id.starts_with("filter.")) { + return None; + } + EVENTS.iter().find(|e| { + e.ids == [id] + && !e.name.is_empty() + && event_for(&AtnEvent::name(e.name)).is_some_and(|mapped| mapped.code == e.code && mapped.ids == e.ids) + && crate::commands::find(id).is_some() + }) +} + +/// Menu choices use existing event mappings rather than inventing event names from command ids. +pub fn menu_item_commands() -> Vec<&'static str> { + let mut seen = std::collections::HashSet::new(); + EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| menu_event(id).is_some() && seen.insert(*id)).collect() +} + +/// Stateless filter menu items execute immediately; settings-bearing ones require the editor. +pub fn menu_item_needs_settings(id: &str) -> bool { + crate::commands::find(id).is_none_or(|c| !c.params.trim_start().starts_with("{}")) +} + +/// An Insert Menu Item step with no recorded command descriptor. +pub const MENU_ITEM: &str = "actions.menuItem"; + +/// Validate menu identity before the shell gates and opens its actual command. +pub fn menu_item_command(p: &Value) -> Result<&str> { + let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?; + if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind" | super::RECORDED_EVENT)) { + return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings")); + } + if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) { + return Err(bad(MENU_ITEM, "`eventKind` must be name or code")); + } + let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(MENU_ITEM, "select a menu command"))?; + let event = menu_event(id).ok_or_else(|| bad(MENU_ITEM, "the command has no unambiguous supported menu event"))?; + if p.get("eventKind").and_then(Value::as_str) == Some("code") { + let code = event.code.as_bytes().try_into().ok().map(AtnEvent::Code); + if !code.as_ref().and_then(event_for).is_some_and(|mapped| mapped.name == event.name && mapped.ids == event.ids) { + return Err(bad(MENU_ITEM, "this menu event has no unambiguous four-character code")); + } + } + Ok(id) +} + +/// Preserve the exact code/name kind only for the supported descriptor-free event. +pub(super) fn from_event(event: &AtnEvent) -> Option { + let mapped = event_for(event)?; + let id = *mapped.ids.first()?; + let canonical = menu_event(id)?; + if canonical.name != mapped.name || canonical.code != mapped.code { + return None; + } + let kind = match event { + AtnEvent::Code(code) if mapped.code.as_bytes() == code => "code", + AtnEvent::Name(name) if mapped.name.as_bytes() == name => "name", + _ => return None, + }; + Some(json!({"id":id,"eventKind":kind})) +} + +/// Write absent settings as `None`, never as an empty or current-target descriptor. +pub(super) fn export(p: &Value) -> Option { + let mapped = menu_event(menu_item_command(p).ok()?)?; + let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") { + AtnEvent::Code(mapped.code.as_bytes().try_into().ok()?) + } else { + AtnEvent::name(mapped.name) + }; + let (event, dictionary) = super::steps_gallery::recorded_event(super::MENU_ITEM, p).unwrap_or_else(|| (event, mapped.label.as_bytes().to_vec())); + Some(AtnItem { event, dictionary, descriptor: None, ..Default::default() }) +} + +/// Headless execution runs stateless items and refuses settings deliberately left for a dialog. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let id = menu_item_command(p)?; + if !menu_item_needs_settings(id) { + return s.execute(id, json!({})); + } + Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied"))) +} diff --git a/crates/engine/src/cmd/atn_cmds/path_tests.rs b/crates/engine/src/cmd/atn_cmds/path_tests.rs new file mode 100644 index 0000000000..25efc33c0d --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/path_tests.rs @@ -0,0 +1,227 @@ +use super::super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, UnicodeString, atn}; +use super::*; + +/// Construct a pixel point directly from the measured #10706 wire format. +fn wire_point(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y })) +} + +/// Encode a fixture corner without cubic handles. +fn corner(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pthp").with("Anch", wire_point(x, y))) +} + +/// Build the measured outer and inner contours independently of the exporter. +fn fixture(operation: &str, joined: bool, reversed: bool) -> Descriptor { + let mut outer = vec![corner(50.0, 250.0), corner(50.0, 100.0), corner(350.0, 100.0), corner(350.0, 250.0)]; + if !joined { + outer[1] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(50.0, 100.0)) + .with("Fwd ", wire_point(50.0, 100.0)) + .with("Bwd ", wire_point(100.0, 20.0)) + .with("Smoo", D::Boolean(false)), + ); + outer[2] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(350.0, 100.0)) + .with("Fwd ", wire_point(300.0, 20.0)) + .with("Bwd ", wire_point(350.0, 100.0)) + .with("Smoo", D::Boolean(false)), + ); + } + let mut inner = vec![corner(150.0, 150.0), corner(250.0, 150.0), corner(250.0, 220.0), corner(150.0, 220.0)]; + if reversed { + inner.reverse(); + } + let sub = |points| D::Descriptor(Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(points))); + let component = |op, subs| { + D::Descriptor( + Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new(op) }) + .with("SbpL", D::List(subs)), + ) + }; + let components = if joined { + vec![component("Add ", vec![sub(outer), sub(inner)])] + } else { + vec![component("Add ", vec![sub(outer)]), component(operation, vec![sub(inner)])] + }; + Descriptor::new(if joined { "null" } else { "setd" }) + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(components)) +} + +/// Extract the single executable Work Path assignment from a measured descriptor. +fn params(d: &Descriptor) -> Value { + let Mapped::Steps(steps) = from(d) else { panic!("measured path must map") }; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "path.set"); + steps[0].1.clone() +} + +/// Sample the filled contour, its hole and the untouched exterior independently. +fn check_area(path: &Path) { + let pixels = photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::new(0, 0, 400, 300)); + for (x, y, expected) in [(100, 185, 1.0), (200, 185, 0.0), (10, 20, 0.0)] { + assert_eq!(pixels[y * 400 + x], expected, "sample {x},{y}"); + } +} + +#[test] +fn measured_curves_and_component_operations_round_trip_and_cut_holes() { + for (operation, expected) in [("Sbtr", PathOp::Subtract), ("xor", PathOp::Exclude)] { + let d = fixture(operation, false, false); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.fill_rule, FillRule::EvenOdd); + assert_eq!(path.subpaths[1].op, expected); + let knots = &path.subpaths[0].knots; + assert_eq!(knots[1].out_ctrl, Point::new(50.0, 100.0)); + assert_eq!(knots[1].in_ctrl, Point::new(100.0, 20.0)); + assert_eq!(knots[2].out_ctrl, Point::new(300.0, 20.0)); + assert_eq!(knots[2].in_ctrl, Point::new(350.0, 100.0)); + assert_eq!(to(&p), Some(d)); + check_area(&path); + } +} + +#[test] +fn one_component_has_even_odd_holes_with_either_inner_winding() { + for reversed in [false, true] { + let d = fixture("Add ", true, reversed); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.components(), vec![0..2]); + assert_eq!(path.subpaths[1].op, PathOp::Join); + check_area(&path); + assert_eq!(to(&p), Some(d)); + } +} + +#[test] +fn whole_atn_and_native_insert_path_replay_keep_geometry() { + let original = fixture("Sbtr", false, false); + let file = AtnFile { + actions: vec![AtnAction { + items: vec![AtnItem { event: AtnEvent::name("set"), descriptor: Some(original.clone()), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let imported = super::super::import(&atn::write(&file).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let (bytes, skipped) = super::super::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(original.clone())); + let expected = path_from(&original).unwrap(); + let mut s = crate::Session::new(); + s.execute("file.new", json!({"width":400,"height":300})).unwrap(); + s.execute("path.set", json!({"name":"Source","path":crate::vector_cmds::path_json(&expected)})).unwrap(); + s.actions.list.push(Action::new("Captured", Vec::new())); + let revision = s.active().unwrap().revision; + s.execute("actions.insertPath", json!({"action":0,"name":"Source"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + let (bytes, skipped) = super::super::export("S", &[&s.actions.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = super::super::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + s.actions.list = imported.actions; + s.execute("path.set", json!({"name":"Source","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&expected)); + check_area(s.active().unwrap().doc.work_path.as_ref().unwrap()); +} + +#[test] +fn source_wire_identity_is_retained_only_for_unchanged_geometry() { + let mut d = fixture("xor", false, false); + d.name = UnicodeString::new_nul("Set"); + d.items.reverse(); + let mut p = params(&d); + assert_eq!(to(&p).unwrap().to_bytes(), d.to_bytes()); + p["path"]["subpaths"][0]["knots"][0]["anchor"][0] = json!(51.0); + assert!(to(&p).is_none()); +} + +#[test] +fn unknown_and_unrepresented_settings_are_not_silently_lost() { + let base = fixture("Sbtr", false, false); + let mut empty_components = base.clone(); + let (_, components) = empty_components.items.iter_mut().find(|(key, _)| key.is("T ")).unwrap(); + *components = D::List(Vec::new()); + for d in [base.clone().with("extra", D::Boolean(true)), empty_components, fixture("Intr", false, false)] { + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let p = params(&base); + let mut empty = p.clone(); + empty["path"]["subpaths"] = json!([]); + empty.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&empty).is_none()); + for key in ["inverted", "fillRule"] { + let mut changed = p.clone(); + changed["path"][key] = if key == "inverted" { json!(true) } else { json!("nonzero") }; + changed.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&changed).is_none()); + } + let mut changed = p.clone(); + changed["path"]["unrepresented"] = json!(1); + assert!(to(&changed).is_none()); + let mut changed = p; + changed[SOURCE] = json!("not base64"); + assert!(to(&changed).is_none()); +} + +#[test] +fn malformed_coordinates_and_oversized_paths_stay_unsupported() { + fn list(value: &mut D) -> &mut Vec { + match value { + D::List(v) => v, + _ => panic!("fixture list"), + } + } + fn object(value: &mut D) -> &mut Descriptor { + match value { + D::Descriptor(v) => v, + _ => panic!("fixture object"), + } + } + for bad in [D::UnitFloat { unit: *b"#Prc", value: 50.0 }, D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }, D::Double(50.0)] { + let mut d = fixture("Sbtr", false, false); + let components = list(&mut d.items[1].1); + let subs = list(&mut object(&mut components[0]).items[1].1); + let points = list(&mut object(&mut subs[0]).items[1].1); + let anchor = object(&mut points[0]); + let coordinates = object(&mut anchor.items[0].1); + coordinates.items[0].1 = bad; + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let mut p = params(&fixture("Sbtr", false, false)); + p.as_object_mut().unwrap().remove(SOURCE); + let knot = p["path"]["subpaths"][0]["knots"][0].clone(); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot.clone(); MAX_PARTS - 4]); + assert!(to(&p).is_some(), "the existing 4096-knot ceiling remains representable"); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot; MAX_PARTS + 1]); + assert!(to(&p).is_none()); +} + +#[test] +fn oversized_descriptor_names_are_rejected_before_retained_wire_serialization() { + // Each name meets the local limit, but their aggregate exceeds the carrier. + // Keep this typed input independent of the serializer under test. + let mut point = + Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }); + point.name = UnicodeString(vec![b'x' as u16; 4096]); + let mut knot = Descriptor::new("Pthp").with("Anch", D::Descriptor(point)); + knot.name = UnicodeString(vec![b'x' as u16; 4096]); + let sub = Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(vec![D::Descriptor(knot); 300])); + let component = Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new("Add ") }) + .with("SbpL", D::List(vec![D::Descriptor(sub)])); + let d = Descriptor::new("setd") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(vec![D::Descriptor(component)])); + assert!(path_from(&d).is_none(), "recognition must reject before from() serializes the retained descriptor"); + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); +} diff --git a/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs b/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs new file mode 100644 index 0000000000..c63c863b32 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs @@ -0,0 +1,214 @@ +//! #10710: Actions steps recorded with the Gradient tool and Create Droplet, as the issue's table +//! gives them (measured in `docs/atn.md` › "Measured through dialogs and recordings"; once mapped, +//! the events are listed with the supported events instead). + +use super::*; + +fn action(items: Vec) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Recorded"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + }) + .unwrap() +} + +fn step(event: &[u8; 4], label: &str, d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(*event), dictionary: label.as_bytes().to_vec(), descriptor: Some(d), ..Default::default() } +} + +fn saved(s: &mut Session) -> Vec { + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items +} + +/// The gradient object the Gradient tool records: dither, classic interpolation, angle, type, +/// scale and offset in percent, then the two-stop gradient. +fn tool_gradient(angle: f64, scale: f64, offset: (f64, f64)) -> Descriptor { + let stop = |hex: &str, at: i32| { + D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(hex).unwrap())) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(at)) + .with("Mdpn", D::Integer(50)), + ) + }; + let trn = |at: i32| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![stop("#102030", 0), stop("#f0e0d0", 4096)])) + .with("Trns", D::List(vec![trn(0), trn(4096)])); + desc("gradientLayer") + .with("Dthr", D::Boolean(true)) + .with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls")) + .with("Angl", ang(angle)) + .with("Type", enm("GrdT", "Lnr ")) + .with("Scl ", pct(scale)) + .with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(offset.0)).with("Vrtc", pct(offset.1)))) + .with("Grad", D::Descriptor(grad)) +} + +/// The Gradient tool (default mode) records a Make of a gradient fill layer at the first click and +/// a Set with the finished drag (`Algn` false). Both load as their commands, play, and save back +/// as recorded. +#[test] +fn gradient_tool_steps_load_play_and_save_back() { + let make = desc("null") + .with("null", class_ref("contentLayer")) + .with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(tool_gradient(90.0, 100.0, (0.0, 0.0)))))); + let set = desc("null") + .with("null", target_ref("contentLayer")) + .with("T ", D::Descriptor(tool_gradient(-35.0, 140.0, (12.5, -8.0)).with("Algn", D::Boolean(false)))); + let items = vec![step(b"Mk ", "Make", make.clone()), step(b"setd", "Set", set.clone())]; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(items))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["layer.newFillLayer.gradient", "gradient.fill.set"]); + let set_params = &s.actions.list[0].steps[1].1; + assert_eq!( + (set_params["angle"].as_f64(), set_params["scale"].as_f64(), set_params["align"].as_bool()), + (Some(-35.0), Some(140.0), Some(false)), + "{set_params}" + ); + + s.execute("file.new", json!({"width": 20, "height": 12})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(played["ran"], 2, "{played}"); + + let back = saved(&mut s); + assert_eq!(back.len(), 2); + assert_eq!((back[0].event.id(), back[0].descriptor.as_ref()), ("Mk ".to_string(), Some(&make))); + assert_eq!((back[1].event.id(), back[1].descriptor.as_ref()), ("setd".to_string(), Some(&set))); +} + +/// A Create Droplet step as the reference application records it with a Folder destination and +/// Override Action "Save As" on. +fn droplet(file: &str, output: &str, extra: &[(&str, D)]) -> Descriptor { + let reference = D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Tools") }, + ]); + let mut d = desc("null").with("In ", path_value(file)).with("Fldr", D::Boolean(false)).with("Usng", reference); + for (k, v) in extra.iter().take_while(|(k, _)| *k == "OvrO") { + d = d.with(k, v.clone()); + } + d = d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true)); + for (k, v) in extra.iter().filter(|(k, _)| *k != "OvrO") { + d = d.with(k, v.clone()); + } + d +} + +/// Create Droplet loads as `file.automate.createDroplet`, plays (writes the droplet with the +/// action's steps) and saves back as recorded. +#[test] +fn create_droplet_step_loads_plays_and_saves_back() { + let dir = std::env::temp_dir().join(format!("pc-10710-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Sharpen.pcdroplet").to_string_lossy().into_owned(); + let out = dir.join("out").to_string_lossy().into_owned(); + let recorded = droplet(&file, &out, &[("OvrO", D::Boolean(true))]); + let mut s = Session::new(); + // The action the droplet runs. + s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))])); + s.actions.list[0].set = Some("Tools".into()); + let loaded = s + .execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(vec![step(b"CrtD", "Create Droplet", recorded.clone())]))})) + .unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let a = s.actions.list.iter().find(|a| a.name == "A").unwrap(); + assert_eq!( + a.steps, + [("file.automate.createDroplet".to_string(), json!({"path": file, "action": "Sharpen", "set": "Tools", "output": out, "overrideOpen": true}))] + ); + let r = s.execute("file.automate.createDroplet", a.steps[0].1.clone()).unwrap(); + assert_eq!(r["path"], json!(file)); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(written["action"]["steps"], json!([["filter.sharpen.sharpen", {}]])); + assert_eq!((written["options"]["output"].as_str(), written["options"]["overrideOpen"].as_bool()), (Some(out.as_str()), Some(true))); + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + let back = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let item = back.actions.iter().find(|a| a.name.to_string_lossy() == "A").unwrap().items[0].clone(); + assert_eq!((item.event.id(), item.descriptor), ("CrtD".to_string(), Some(recorded))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What the droplet cannot do keeps the Photoshop step: Include All Subfolders, Save and Close, +/// no destination, Save As steps left to save where recorded, suppressed dialogs, custom naming +/// and an error log. +#[test] +fn create_droplet_steps_the_droplet_cannot_play_are_kept() { + let kept = |d: Descriptor| matches!(steps_droplet::droplet_from(&d), Mapped::Unsupported { harmless: false }); + let base = || droplet("/d/S.pcdroplet", "/d/out", &[]); + let replaced = |key: &str, v: D| { + let mut d = base(); + d.items.retain(|(k, _)| !k.is(key)); + d.with(key, v) + }; + assert!(!kept(base()), "the plain form maps"); + assert!(kept(replaced("Fldr", D::Boolean(true)))); + assert!(kept(replaced("Ovrd", D::Boolean(false)))); + let mut no_destination = base(); + no_destination.items.retain(|(k, _)| !k.is("T ")); + assert!(kept(no_destination.clone())); + assert!(kept(no_destination.with("SvAn", D::Boolean(true)))); + assert!(kept(base().with("suppressOpenOptions", D::Boolean(true)))); + assert!(kept(base().with("fileNamingRules", D::Descriptor(desc("fileNamingRules"))))); + assert!(kept(base().with("Log ", path_value("/d/logs")))); +} + +/// Settings the event has no key for refuse the step on save rather than replay differently. +#[test] +fn create_droplet_steps_that_cannot_be_written_are_refused() { + let p = json!({"path": "/d/S.pcdroplet", "action": "Sharpen", "output": "/d/out"}); + assert!(export_item("file.automate.createDroplet", &p, "S").is_some()); + for extra in [json!({"format": "png"}), json!({"quality": 8}), json!({"steps": [["filter.sharpen.sharpen", {}]]}), json!({"name": "Other"})] { + let mut q = p.clone(); + q.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + assert!(export_item("file.automate.createDroplet", &q, "S").is_none(), "{q}"); + } + let mut no_output = p.clone(); + no_output.as_object_mut().unwrap().remove("output"); + assert!(export_item("file.automate.createDroplet", &no_output, "S").is_none()); +} + +/// Loading a set whose action clashes with one already in the panel renames the loaded copy; its +/// Create Droplet steps follow the rename, as Batch steps do, whether the reference names the +/// action alone or the action in the file's own set (CodeRabbit review on steps_droplet.rs:59). +#[test] +fn create_droplet_steps_follow_a_renamed_loaded_action() { + let with_ref = |items: Vec| { + let mut d = droplet("/d/S.pcdroplet", "/d/out", &[]); + d.items.retain(|(k, _)| !k.is("Usng")); + d.with("Usng", D::Reference(items)) + }; + let actn = || ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") }; + let own_set = ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Recorded") }; + let file = atn::write(&AtnFile { + name: UnicodeString::new_nul("Recorded"), + actions: vec![ + AtnAction { name: UnicodeString::new_nul("Sharpen"), ..Default::default() }, + AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![step(b"CrtD", "Create Droplet", with_ref(vec![actn()])), step(b"CrtD", "Create Droplet", with_ref(vec![actn(), own_set]))], + ..Default::default() + }, + ], + ..Default::default() + }) + .unwrap(); + let mut s = Session::new(); + s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![])); + s.actions.list[0].set = Some("Tools".into()); + let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file), "set": "Loaded"})).unwrap(); + assert_eq!(r["actions"], json!(["Sharpen 2", "A"]), "{r}"); + let a = s.actions.list.iter().find(|a| a.name == "A").unwrap(); + assert_eq!(a.steps[0].1["action"], "Sharpen 2", "{:?}", a.steps[0]); + assert_eq!((&a.steps[1].1["action"], &a.steps[1].1["set"]), (&json!("Sharpen 2"), &json!("Loaded")), "{:?}", a.steps[1]); +} diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs index 21f87c286e..7d1d4fd2a5 100644 --- a/crates/engine/src/cmd/atn_cmds/render_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -128,7 +128,7 @@ fn render_descriptors_load_and_save_without_losing_values() { let center = [0.40000000596046; 2]; let mut cases = vec![("Fbrs", None, fibers(16, 4, 123)), ("LnsF", Some("lensFlare"), flare(100, center, "Zm"))]; for seed in [i32::MIN, -1, 0, i32::MAX] { - cases.push(("Fbrs", None, fibers(-23, i32::MAX, seed))); + cases.push(("Fbrs", None, fibers(64, 1, seed))); } for lens in ["Zm", "Nkn", "Nkn1", "PnVs"] { cases.push(("LnsF", Some("lensFlare"), flare(280, [5.0, -5.0], lens))); @@ -181,15 +181,19 @@ fn render_defaults_seeds_and_brightness_bounds_are_explicit() { /// Unknown enums, malformed types and unmeasured extra fields stay visibly unsupported. #[test] fn unsupported_render_descriptors_keep_their_bytes_and_flags() { - for descriptor in [ + let cases = [ desc("null").with("Lns ", enm("Lns ", "Bad!")), desc("null").with("Lns ", enm("Other", "Zm")), desc("null").with("Brgh", D::Double(100.0)), desc("null").with("FlrC", D::Descriptor(desc("Other"))), desc("null").with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.4)))), desc("null").with("futureSetting", D::Boolean(true)), - ] { - let mut original = atn::parse(&file(AtnEvent::Code(*b"LnsF"), descriptor.clone())).unwrap(); + ] + .into_iter() + .map(|d| (*b"LnsF", d)) + .chain([i32::MIN, -1, 0, i32::MAX].map(|seed| (*b"Fbrs", fibers(-23, i32::MAX, seed)))); + for (event, descriptor) in cases { + let mut original = atn::parse(&file(AtnEvent::Code(event), descriptor.clone())).unwrap(); original.actions[0].items[0].enabled = false; original.actions[0].items[0].with_dialog = true; original.actions[0].items[0].dialog_options = 2; @@ -261,30 +265,51 @@ fn render_actions_match_direct_filters_and_undo() { // Just right of the canvas: the dialog's 0..1 range does not clamp a recorded centre. (*b"LnsF", flare(100, [1.4, 0.25], "Zm"), "filter.render.lensFlare", json!({"brightness": 100, "centerX": 1.4, "centerY": 0.25, "lens": "zoom"})), ]; - for mode in ["rgb", "gray"] { - for depth in [8, 16, 32] { - for (event, descriptor, id, params) in &cases { - let setup = || { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); - s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap(); - s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap(); - s - }; - let mut action = setup(); - let mut direct = setup(); - let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(); - action.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})).unwrap(); - let played = action.execute("actions.play", json!({"action": "Render"})).unwrap(); - assert!(played.get("failed").is_none(), "{played}"); - direct.execute(id, params.clone()).unwrap(); - let surface = |s: &Session| { - let st = s.active().unwrap(); - st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) - }; - assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}"); - action.execute("edit.undo", json!({})).unwrap(); - assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before); + for single_state in [true, false] { + for mode in ["rgb", "gray"] { + for depth in [8, 16, 32] { + for (event, descriptor, id, params) in &cases { + let setup = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap(); + s + }; + let mut action = setup(); + let mut direct = setup(); + action.execute("actions.setHistory", json!({"singleState": single_state})).unwrap(); + let before_doc = action.active().unwrap().doc.clone(); + let history = action.active().unwrap().history.past_len(); + let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(); + action + .execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})) + .unwrap(); + let played = action.execute("actions.play", json!({"action": "Render"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + direct.execute(id, params.clone()).unwrap(); + let surface = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) + }; + assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}"); + let after_doc = action.active().unwrap().doc.clone(); + let changed = after_doc.as_ref() != before_doc.as_ref(); + let added = usize::from(!single_state || changed); + assert_eq!(action.active().unwrap().history.past_len(), history + added, "{mode}/{depth}/{id}, singleState={single_state}"); + // An unchanged grouped action preserves the setup row instead of adding one. + if added > 0 { + action.execute("edit.undo", json!({})).unwrap(); + } else { + assert!(std::sync::Arc::ptr_eq(&action.active().unwrap().doc, &before_doc)); + } + assert_eq!(action.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{mode}/{depth}/{id} undo"); + assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before); + if added > 0 { + action.execute("edit.redo", json!({})).unwrap(); + assert_eq!(action.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{mode}/{depth}/{id} redo"); + } + } } } } diff --git a/crates/engine/src/cmd/atn_cmds/steps_batch.rs b/crates/engine/src/cmd/atn_cmds/steps_batch.rs new file mode 100644 index 0000000000..639d2a9402 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_batch.rs @@ -0,0 +1,283 @@ +//! Batch mapping based on #7170 by @HirthikBalaji, extended with the measured naming rules. + +use super::descriptors::{class, desc, enm, jstr, path_text, path_value, text}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, ReferenceItem, UnicodeString, Value, json}; + +const ORIGINAL: &str = "__atnBatch"; + +/// Reject unknown or duplicate wire keys before conversion could discard them. +fn keys(d: &Descriptor, allowed: &[&str]) -> bool { + d.items.iter().all(|(k, _)| allowed.iter().any(|s| k.is(s))) && allowed.iter().all(|s| d.items.iter().filter(|(k, _)| k.is(s)).count() <= 1) +} + +/// Read the measured list and nested serial/compatibility fields without collapsing unknown components. +fn naming_from(rules: &Descriptor) -> Option { + if !rules.class_id.is("fileNamingRules") || !keys(rules, &["fileNamingComponents", "Strt", "Mcnt", "Win ", "unix"]) { + return None; + } + let D::List(items) = rules.get("fileNamingComponents")? else { return None }; + if !(1..=6).contains(&items.len()) { + return None; + } + let mut components = Vec::new(); + for item in items { + let D::Descriptor(c) = item else { return None }; + if !c.class_id.is("fileNamingComponent") || c.items.len() != 1 { + return None; + } + if let Some(D::Text(t)) = c.get("Txt ") { + let text = t.to_string_lossy(); + if text.len() > 255 { + return None; + } + components.push(json!({"text":text})); + } else { + let D::Enumerated { type_id, value } = c.get("component")? else { return None }; + if !type_id.is("fileNamingComponent") { + return None; + } + let (_, native) = crate::file_cmds::BATCH_NAMING_COMPONENTS.iter().find(|(wire, _)| value.is(wire))?; + components.push(json!({"component":native})); + } + } + let mut p = json!({"namingComponents":components}); + if let Some(start) = rules.get("Strt") { + let D::Integer(start) = start else { return None }; + if *start < 1 { + return None; + } + p["startingSerial"] = json!(start); + } + let mut flags = json!({}); + for (wire, native) in [("Mcnt", "macintosh"), ("Win ", "windows"), ("unix", "unix")] { + if let Some(v) = rules.get(wire) { + let D::Boolean(v) = v else { return None }; + flags[native] = json!(v); + } + } + if !flags.as_object()?.is_empty() { + p["compatibility"] = flags; + } + Some(p) +} + +/// Write each literal or known component explicitly; counters must fit the signed wire field. +fn naming_to(p: &Value) -> Option { + let components = p.get("namingComponents")?.as_array()?; + if !(1..=6).contains(&components.len()) { + return None; + } + let mut items = Vec::new(); + for c in components { + if c.as_object()?.len() != 1 { + return None; + } + let d = if let Some(t) = c.get("text") { + let t = t.as_str()?; + if t.len() > 255 { + return None; + } + desc("fileNamingComponent").with("Txt ", text(t)) + } else { + let kind = c.get("component")?.as_str()?; + let (wire, _) = crate::file_cmds::BATCH_NAMING_COMPONENTS.iter().find(|(_, native)| kind == *native)?; + desc("fileNamingComponent").with("component", enm("fileNamingComponent", wire)) + }; + items.push(D::Descriptor(d)); + } + let mut d = desc("fileNamingRules").with("fileNamingComponents", D::List(items)); + if let Some(start) = p.get("startingSerial") { + let start = i32::try_from(start.as_u64()?).ok()?; + if start < 1 { + return None; + } + d = d.with("Strt", D::Integer(start)); + } + if let Some(flags) = p.get("compatibility") { + if flags.as_object()?.keys().any(|k| !["macintosh", "windows", "unix"].contains(&k.as_str())) { + return None; + } + for (wire, native) in [("Mcnt", "macintosh"), ("Win ", "windows"), ("unix", "unix")] { + if let Some(v) = flags.get(native) { + d = d.with(wire, D::Boolean(v.as_bool()?)); + } + } + } + Some(d) +} + +/// Admit supported folder settings while leaving unrepresented input and dialog modes as whole steps. +fn read(d: &Descriptor) -> Option { + if !keys(d, &["null", "OvrO", "Fldr", "suppressWarnings", "suppressOpenOptions", "Usng", "T ", "SvAn", "Ovrd", "fileNamingRules"]) { + return None; + } + let quiet_raw = match d.get("suppressOpenOptions") { + None => None, + Some(D::Boolean(value)) => Some(*value), + _ => return None, + }; + let quiet_profiles = match d.get("suppressWarnings") { + None => None, + Some(D::Boolean(value)) => Some(*value), + _ => return None, + }; + let override_open = match d.get("OvrO") { + None => None, + Some(D::Boolean(value)) => Some(*value), + _ => return None, + }; + let save_and_close = match d.get("SvAn") { + None | Some(D::Boolean(false)) => false, + Some(D::Boolean(true)) => true, + _ => return None, + }; + if save_and_close && d.get("T ").is_some() { + return None; + } + let mut p = json!({"input":path_text(d.get("null")?)?,"stopOnError":true,"includeNonImages":true}); + if let Some(value) = quiet_profiles { + p["suppressWarnings"] = json!(value); + } + if let Some(value) = quiet_raw { + p["suppressOpenOptions"] = json!(value); + } + if let Some(value) = override_open { + p["overrideOpen"] = json!(value); + } + if save_and_close { + p["destination"] = json!("saveAndClose"); + } else { + p["output"] = json!(path_text(d.get("T ")?)?); + p["flatOutput"] = json!(true); + } + let D::Reference(items) = d.get("Usng")? else { return None }; + match items.as_slice() { + [ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }] + if action.class_id.is("Actn") && set.class_id.is("ASet") => + { + p["action"] = json!(name.to_string_lossy()); + p["set"] = json!(set_name.to_string_lossy()); + } + [ReferenceItem::Name { class: action, name }] if action.class_id.is("Actn") => p["action"] = json!(name.to_string_lossy()), + _ => return None, + } + for (wire, native) in [("Fldr", "includeSubfolders"), ("Ovrd", "overrideSaveAs")] { + let value = match d.get(wire) { + None => false, + Some(D::Boolean(v)) => *v, + _ => return None, + }; + p[native] = json!(value); + } + if save_and_close && p["overrideSaveAs"] == Value::Bool(true) { + return None; + } + match d.get("fileNamingRules") { + Some(D::Descriptor(rules)) => p.as_object_mut()?.extend(naming_from(rules)?.as_object()?.clone()), + None if !save_and_close => p["namingComponents"] = json!([{"component":"name"},{"component":"extension"}]), + None => {} + _ => return None, + } + Some(p) +} + +/// Retain original metadata beside native params so an unedited save need not canonicalize the descriptor. +pub(super) fn batch_from(d: &Descriptor) -> Mapped { + let p = read(d).map(|mut p| { + p[ORIGINAL] = json!(photocraft_paint::tile::b64_encode(&d.to_bytes())); + p + }); + p.map_or(Mapped::Unsupported { harmless: false }, |p| step("file.automate.batch", p)) +} + +/// Reuse recorded metadata only while params match; represented edits get a fresh descriptor. +pub(super) fn batch_to(_: &str, p: &Value) -> Option { + if p.as_object()?.keys().any(|k| { + ![ + "input", + "output", + "destination", + "action", + "set", + "flatOutput", + "includeSubfolders", + "overrideSaveAs", + "overrideOpen", + "suppressWarnings", + "suppressOpenOptions", + "stopOnError", + "includeNonImages", + "namingComponents", + "startingSerial", + "compatibility", + ORIGINAL, + ] + .contains(&k.as_str()) + }) { + return None; + } + // Recorded folders include unsupported files and stop at the first error without Log. + if p.get("stopOnError") != Some(&Value::Bool(true)) || p.get("includeNonImages") != Some(&Value::Bool(true)) { + return None; + } + if let Some(d) = original_batch(p) { + return Some(d); + } + let save_and_close = match p.get("destination") { + None | Some(Value::Null) => false, + Some(Value::String(value)) if value == "folder" => false, + Some(Value::String(value)) if value == "saveAndClose" => true, + _ => return None, + }; + let subfolders = p.get("includeSubfolders").map(Value::as_bool).unwrap_or(Some(false))?; + if save_and_close { + if p.get("output").is_some_and(|v| !v.is_null()) + || p.get("flatOutput").is_some_and(|v| !v.is_null()) + || p.get("overrideSaveAs") == Some(&Value::Bool(true)) + { + return None; + } + } else { + if subfolders && p.get("flatOutput") != Some(&Value::Bool(true)) { + return None; + } + if p.get("flatOutput").is_some_and(|v| v != &Value::Bool(true)) { + return None; + } + } + let input = jstr(p, "input").filter(|s| !s.is_empty())?; + let action = jstr(p, "action").filter(|s| !s.is_empty())?; + let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }]; + if let Some(set) = p.get("set").filter(|set| !set.is_null()) { + reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) }); + } + let d = desc("Btch").with("null", path_value(input)).with("Fldr", D::Boolean(subfolders)).with("Usng", D::Reference(reference)); + let d = if save_and_close { d.with("SvAn", D::Boolean(true)) } else { d.with("T ", path_value(jstr(p, "output").filter(|s| !s.is_empty())?)) }; + let mut d = d.with("Ovrd", D::Boolean(p.get("overrideSaveAs").map(Value::as_bool).unwrap_or(Some(false))?)); + if let Some(value) = p.get("suppressWarnings") { + d = d.with("suppressWarnings", D::Boolean(value.as_bool()?)); + } + if let Some(value) = p.get("suppressOpenOptions") { + d = d.with("suppressOpenOptions", D::Boolean(value.as_bool()?)); + } + if let Some(value) = p.get("overrideOpen") { + d = d.with("OvrO", D::Boolean(value.as_bool()?)); + } + if p.get("namingComponents").is_some() { + d = d.with("fileNamingRules", D::Descriptor(naming_to(p)?)); + } else if !save_and_close || ["startingSerial", "compatibility"].iter().any(|key| p.get(key).is_some()) { + return None; + } + Some(d) +} + +/// The original descriptor is reusable only while all represented settings remain unchanged. +pub(super) fn original_batch(p: &Value) -> Option { + let original = p.get(ORIGINAL)?.as_str()?; + let d = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(original)?).ok()?; + let expected = read(&d)?; + let mut core = p.clone(); + core.as_object_mut()?.remove(ORIGINAL); + (core == expected).then_some(d) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_calculations.rs b/crates/engine/src/cmd/atn_cmds/steps_calculations.rs new file mode 100644 index 0000000000..6c6e047c42 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_calculations.rs @@ -0,0 +1,124 @@ +//! Calculations records Make Channel with a `Clcl` object, not a Calculations event. + +use super::descriptors::{class, code, desc, enm, get_obj, get_ref, trimmed}; +use super::fields::{Mapped, step}; +use super::steps_image::CALC_BLEND; +use super::tables::{CHANNELS, channel_of, lookup, lookup_back}; +use super::{D, Descriptor, ReferenceItem, UnicodeString, Value, json}; + +fn only(p: &Value, keys: &[&str]) -> bool { + p.as_object().is_some_and(|m| m.keys().all(|k| keys.contains(&k.as_str()))) +} + +fn source_from(items: &[ReferenceItem]) -> Option { + let (channel, rest) = items.split_first()?; + let channel = match channel { + ReferenceItem::Enumerated { class, type_id, value } if class.class_id.is("Chnl") => { + if type_id.is("Ordn") && value.is("Trgt") { + json!({"targeted": true}) + } else if type_id.is("Chnl") && value.is("Trsp") { + json!("transparency") + } else if type_id.is("Chnl") { + json!({"color": channel_of(&trimmed(value))?}) + } else { + return None; + } + } + ReferenceItem::Name { class, name } if class.class_id.is("Chnl") => json!({"name": name.to_string_lossy()}), + _ => return None, + }; + let mut source = json!({"channel": channel}); + match rest { + [] => {} + [ReferenceItem::Enumerated { class, type_id, value }] if class.class_id.is("Lyr ") && type_id.is("Ordn") && value.is("Mrgd") => { + source["layer"] = json!("merged"); + } + [ReferenceItem::Name { class, name }] if class.class_id.is("Lyr ") => source["layer"] = json!({"name": name.to_string_lossy()}), + _ => return None, + } + Some(source) +} + +fn source_to(source: &Value) -> Option { + if !only(source, &["channel", "layer"]) { + return None; + } + let channel = source.get("channel")?; + let item = if channel == &json!({"targeted": true}) { + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Trgt") } + } else if channel == "transparency" && source.get("layer").is_some_and(|v| v == "merged") { + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Trsp") } + } else if only(channel, &["color"]) { + let value = lookup_back(CHANNELS, channel.get("color")?.as_str()?)?; + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(value) } + } else if only(channel, &["name"]) { + ReferenceItem::Name { class: class("Chnl"), name: UnicodeString::new_nul(channel.get("name")?.as_str()?) } + } else { + return None; + }; + let mut items = vec![item]; + match source.get("layer") { + None => {} + Some(v) if v == "merged" => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), + Some(v) if only(v, &["name"]) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(v.get("name")?.as_str()?) }), + _ => return None, + } + Some(D::Reference(items)) +} + +/// Only the measured Make form is claimed here; other Make Channel forms reach the mask mapper. +pub(super) fn from_descriptor(d: &Descriptor) -> Option { + let using = get_obj(d, "Usng")?; + if !using.class_id.is("Clcl") { + return None; + } + let mapped = || -> Option { + let D::Class(new) = d.get("Nw ")? else { return None }; + if !new.class_id.is("Chnl") { + return None; + } + let D::Enumerated { type_id, value } = using.get("Clcl")? else { return None }; + if !type_id.is("Clcn") { + return None; + } + let mut p = json!({ + "source1": source_from(get_ref(using, "T ")?)?, + "source2": source_from(get_ref(using, "Src2")?)?, + "blending": lookup(CALC_BLEND, &trimmed(value))?, + }); + if let Some(v) = using.get("Scl ") { + let D::Double(n) = v else { return None }; + p["scale"] = json!(n); + } + if let Some(v) = using.get("Ofst") { + let D::Integer(n) = v else { return None }; + p["offset"] = json!(n); + } + // This also rejects duplicate/unknown keys, changed order and unsupported reference + // metadata rather than losing any of them when the step is saved again. + (to_descriptor(&p)? == *d).then_some(p) + }; + Some(mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.calculations", p))) +} + +pub(super) fn to_descriptor(p: &Value) -> Option { + if !only(p, &["source1", "source2", "blending", "scale", "offset", "result"]) || p.get("result").is_some_and(|r| r != "newChannel") { + return None; + } + // Calculations plays an omitted blending as Multiply (`Blending::parse`), as Apply Image saves it. + let blend = match p.get("blending") { + None | Some(Value::Null) => "Mltp", + Some(v) => lookup_back(CALC_BLEND, v.as_str()?)?, + }; + let mut using = desc("Clcl").with("T ", source_to(p.get("source1")?)?).with("Clcl", enm("Clcn", blend)); + if let Some(v) = p.get("scale") { + let n = v.as_f64().filter(|n| n.is_finite() && (1.0..=2.0).contains(n))?; + using = using.with("Scl ", D::Double(n)); + } + if let Some(v) = p.get("offset") { + let n = i32::try_from(v.as_i64()?).ok().filter(|n| (-255..=255).contains(n))?; + using = using.with("Ofst", D::Integer(n)); + } + using = using.with("Src2", source_to(p.get("source2")?)?); + Some(desc("null").with("Nw ", D::Class(class("Chnl"))).with("Usng", D::Descriptor(using))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs b/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs new file mode 100644 index 0000000000..3e30609ea2 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs @@ -0,0 +1,91 @@ +//! Recorded Content-Aware Fill settings; selection and job/history behavior belong to the command. +use super::descriptors::{desc, enm, jstr}; +use super::fields::{Mapped, step}; +use super::tables::lookup_back; +use super::{D, Descriptor, Map, Value}; + +const CMD: &str = "edit.contentAwareFill"; +const SOURCE: &str = "_atnCafDescriptor"; +// The measured row bounds these values; its ellipses are not additional enum bindings. +const SAMPLING: &[(&str, &str)] = &[("cafSamplingRegionRectangular", "rectangular")]; +const COLOR: &[(&str, &str)] = &[("cafColorAdaptationDefault", "default")]; +const ROTATION: &[(&str, &str)] = &[("cafRotationAmountNone", "none")]; +// Current Layer is also explicit in the primary Action Manager example cited in docs/atn.md. +const OUTPUT: &[(&str, &str)] = &[("cafOutputToNewLayer", "new"), ("cafOutputToCurrentLayer", "current")]; +const KEYS: &[&str] = &["cafSamplingRegion", "cafSampleAllLayers", "cafColorAdaptationLevel", "cafRotationAmount", "cafScale", "cafMirror", "cafOutput"]; +const PARAMS: &[&str] = &["sampling", "sampleAllLayers", "colorAdaptation", "rotationAdaptation", "scale", "mirror", "output"]; + +/// All seven settings are recorded. Missing keys have no measured Photoshop default here. +fn recorded(d: &Descriptor) -> Option { + if d.items.len() != KEYS.len() || KEYS.iter().any(|name| d.items.iter().filter(|(key, _)| key.is(name)).count() != 1) { + return None; + } + let mut p = Map::new(); + for (key, param, table) in [(KEYS[0], PARAMS[0], SAMPLING), (KEYS[2], PARAMS[2], COLOR), (KEYS[3], PARAMS[3], ROTATION), (KEYS[6], PARAMS[6], OUTPUT)] { + let D::Enumerated { type_id, value } = d.get(key)? else { return None }; + if !type_id.is(key) { + return None; + } + p.insert(param.into(), table.iter().find(|(wire, _)| value.is(wire))?.1.into()); + } + for (key, param) in [(KEYS[1], PARAMS[1]), (KEYS[4], PARAMS[4]), (KEYS[5], PARAMS[5])] { + let D::Boolean(value) = d.get(key)? else { return None }; + p.insert(param.into(), (*value).into()); + } + Some(Value::Object(p)) +} + +/// Write only represented native options, making their defaults explicit in all seven fields. +fn descriptor(p: &Value) -> Option { + let object = p.as_object()?; + if !super::events::pixel_target(p) + || object.keys().any(|key| !PARAMS.contains(&key.as_str()) && !matches!(key.as_str(), "target" | "seed" | SOURCE)) + || p.get("seed").is_some_and(|seed| seed.as_u64() != Some(1)) + { + return None; + } + let enumeration = |key: &str, param: &str, table: &[(&str, &str)], default: &str| { + let value = match p.get(param) { + None => default, + Some(value) => value.as_str()?, + }; + Some(enm(key, lookup_back(table, value)?)) + }; + let boolean = |param: &str| match p.get(param) { + None => Some(D::Boolean(false)), + Some(value) => value.as_bool().map(D::Boolean), + }; + Some( + desc("null") + .with(KEYS[0], enumeration(KEYS[0], PARAMS[0], SAMPLING, "auto")?) + .with(KEYS[1], boolean(PARAMS[1])?) + .with(KEYS[2], enumeration(KEYS[2], PARAMS[2], COLOR, "default")?) + .with(KEYS[3], enumeration(KEYS[3], PARAMS[3], ROTATION, "none")?) + .with(KEYS[4], boolean(PARAMS[4])?) + .with(KEYS[5], boolean(PARAMS[5])?) + .with(KEYS[6], enumeration(KEYS[6], PARAMS[6], OUTPUT, "current")?), + ) +} + +/// Import the verified seven-field form, retaining noncanonical wire data for an unchanged save. +pub(super) fn caf_from(d: &Descriptor) -> Mapped { + let Some(mut p) = recorded(d) else { return Mapped::Unsupported { harmless: false } }; + if descriptor(&p).as_ref() != Some(d) { + // Key order, descriptor identity and ID encoding are wire data, not native options. + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(CMD, p) +} + +/// Export supported settings; preserve the recorded wire form only while its settings agree. +pub(super) fn caf_to(_: &str, p: &Value) -> Option { + let canonical = descriptor(p)?; + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + // Validate preserved data even after an edit; it cannot hide unsupported source options. + let original = descriptor(&recorded(&source)?)?; + // A supported edit has a verified canonical form; stale wire data cannot override it. + Some(if original == canonical { source } else { canonical }) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index 86ffa239da..703b2327dd 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -1,4 +1,4 @@ -//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Spherize, Twirl, Wave and +//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave and //! ZigZag, each key in recorded order with the limits and defaults Photoshop measures. use super::descriptors::{desc, enm, num, trimmed}; @@ -219,3 +219,125 @@ pub(super) fn wave_to(_: &str, p: &Value) -> Option { }; Some(d.with("SclH", D::Integer(100)).with("SclV", D::Integer(100)).with("UndA", enm("UndA", undefined)).with("RndS", D::Integer(seed))) } + +// Adapter safety budget, not a Photoshop format maximum: at most 64 KiB per f64 point +// vector and 32 KiB in the native renderer, whose per-row curve search is linear. +const MAX_SHEAR_POINTS: usize = 4096; + +/// A curve must fit the playback budget and retain its order through native deduplication. +fn shear_curve(points: &[[f64; 2]]) -> bool { + (2..=MAX_SHEAR_POINTS).contains(&points.len()) + && points.iter().all(|p| p[0].is_finite() && (0.0..=1.0).contains(&p[0]) && (-1.0..=63.0 / 64.0).contains(&p[1])) + && points.windows(2).all(|p| p[1][0] as f32 - p[0][0] as f32 >= 1e-6) +} + +/// Recorded grid coordinates become native top-to-bottom positions and half-width offsets. +fn shear_normalized(points: &[[f64; 2]]) -> Vec<[f64; 2]> { + points.iter().map(|p| [(p[1] - 1.0) / 127.0, p[0] / 64.0]).collect() +} + +/// Read every measured point; an invalid entry must not disappear in the native list parser. +fn shear_points(value: &D) -> Option> { + let D::List(values) = value else { return None }; + if !(2..=MAX_SHEAR_POINTS).contains(&values.len()) { + return None; + } + values + .iter() + .map(|v| { + let D::Descriptor(point) = v else { return None }; + if trimmed(&point.class_id) != "Pnt" { + return None; + } + let [(h, D::Double(x)), (v, D::Double(y))] = point.items.as_slice() else { return None }; + (trimmed(h) == "Hrzn" && trimmed(v) == "Vrtc" && x.is_finite() && y.is_finite()).then(|| [x.clamp(-64.0, 63.0), y.clamp(1.0, 128.0)]) + }) + .collect() +} + +/// Bound native/source JSON lists before conversion, without cloning the input array. +fn shear_json_points(value: &Value) -> Option> { + let points = value.as_array()?; + if !(2..=MAX_SHEAR_POINTS).contains(&points.len()) { + return None; + } + points + .iter() + .map(|point| { + let [x, y] = point.as_array()?.as_slice() else { return None }; + Some([x.as_f64()?, y.as_f64()?]) + }) + .collect() +} + +/// Import only the complete measured curve range, with dialog clamps and exact fractional save data. +pub(super) fn shear_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !only_keys(d, &["ShrP", "UndA", "ShrS", "ShrE"]) { + return None; + } + let grid = setting(d, "ShrP", shear_points, vec![[0.0, 1.0], [0.0, 128.0]])?; + let index = |v: &D| if let D::Integer(n) = v { Some(*n) } else { None }; + if setting(d, "ShrS", index, 0)? != 0 || setting(d, "ShrE", index, 1)? != i32::try_from(grid.len().checked_sub(1)?).ok()? { + return None; + } + let points = shear_normalized(&grid); + if !shear_curve(&points) { + return None; + } + let undefined = setting(d, "UndA", enum_of("UndA"), "WrpA".into())?; + let mut p = json!({"points": points, "undefinedAreas": lookup(WAVE_UNDEFINED, &undefined)?}); + // Dividing by 127 can lose a wire bit (for example Vrtc = 4.975609756097561). + // Preserve only that case, and reject stale metadata on export after a point edit. + if points.iter().zip(&grid).any(|(p, g)| [p[1] * 64.0, p[0] * 127.0 + 1.0] != *g) { + p["_atnShearPoints"] = json!(grid); + } + Some(step("filter.distort.shear", p)) + }; + mapped().unwrap_or(Mapped::Unsupported { harmless: false }) +} + +/// Export the effective native curve; settings without a faithful measured wire form are refused. +pub(super) fn shear_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["points", "amount", "undefinedAreas", "target", "_atnShearPoints"]) { + return None; + } + let points: Vec<[f64; 2]> = match p.get("points") { + None | Some(Value::Null) => { + let amount = match p.get("amount") { + None | Some(Value::Null) => 0.0, + Some(value) => value.as_f64().filter(|n| n.is_finite())?, + }; + // Match the native command's f32 conversion and amount clamp. + let amount = (amount as f32).clamp(-100.0, 100.0) / 100.0; + vec![[0.0, 0.0], [0.5, f64::from(amount)], [1.0, 0.0]] + } + Some(value) => shear_json_points(value)?, + }; + if !shear_curve(&points) { + return None; + } + let stored = p.get("_atnShearPoints"); + let grid: Vec<[f64; 2]> = match stored { + None => points.iter().map(|p| [p[1] * 64.0, p[0] * 127.0 + 1.0]).collect(), + Some(value) => shear_json_points(value)?, + }; + // Derived grid arithmetic may change an f64 wire bit without changing the f32 curve + // the native command consumes. Stored source coordinates still require exact agreement: + // even an edit below f32 precision must not revive stale recorded metadata. + let matches = grid.len() == points.len() + && grid.iter().zip(&points).all(|(grid, point)| { + let normalized = [(grid[1] - 1.0) / 127.0, grid[0] / 64.0]; + if stored.is_some() { normalized == *point } else { normalized.map(|v| v as f32) == point.map(|v| v as f32) } + }); + if grid.iter().any(|p| !(-64.0..=63.0).contains(&p[0]) || !(1.0..=128.0).contains(&p[1])) || !matches { + return None; + } + let end = i32::try_from(grid.len().checked_sub(1)?).ok()?; + let undefined = match p.get("undefinedAreas") { + None | Some(Value::Null) => "WrpA", + Some(value) => lookup_back(WAVE_UNDEFINED, value.as_str()?)?, + }; + let grid = grid.into_iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + Some(desc("null").with("ShrP", D::List(grid)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(end))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index c0c442f443..f4f8bf883c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -27,14 +27,21 @@ pub(super) fn place_to(id: &str, p: &Value) -> Option { /// `open`: File › Open of a recorded path. pub(super) fn open_from(d: &Descriptor) -> Mapped { + if d.items.len() != 1 || !d.class_id.is("null") || !d.name.to_string_lossy().is_empty() { + return Mapped::Unsupported { harmless: false }; + } match d.get("null").and_then(path_text) { Some(path) => step("file.openAs", json!({"path": path})), None => Mapped::Unsupported { harmless: false }, } } +/// Export the represented Open path; refuse settings without a supported wire mapping. pub(super) fn open_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", path_value(jstr(p, "path")?))) + if p.as_object()?.len() != 1 { + return None; + } + Some(desc("null").with("null", path_value(jstr(p, "path").filter(|path| !path.is_empty())?))) } fn path_extension(path: &str) -> Option { diff --git a/crates/engine/src/cmd/atn_cmds/steps_droplet.rs b/crates/engine/src/cmd/atn_cmds/steps_droplet.rs new file mode 100644 index 0000000000..beee14d2f9 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_droplet.rs @@ -0,0 +1,85 @@ +//! File › Automate › Create Droplet (`CrtD`, #10710): the droplet file (`In `), the action +//! (`Usng`) and the Batch keys, as #10710 lists them from the reference application. Only the form +//! `file.automate.createDroplet` plays the same way maps; any other stays a Photoshop step. + +use super::descriptors::{class, desc, jstr, path_text, path_value}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; + +/// The keys a recorded Create Droplet step can carry (the Batch keys, with `In ` for the source). +const KEYS: &[&str] = &["In ", "Fldr", "Usng", "OvrO", "suppressWarnings", "suppressOpenOptions", "T ", "SvAn", "Ovrd", "fileNamingRules", "Log ", "Nm "]; + +fn flag(d: &Descriptor, key: &str) -> Option { + match d.get(key) { + None => Some(false), + Some(D::Boolean(b)) => Some(*b), + Some(_) => None, + } +} + +/// A droplet that saves each file into a folder and overrides the action's Save As steps maps: +/// that is what `file.automate.createDroplet` writes (its output folder replaces recorded saves). +/// Include All Subfolders (the droplet lists a folder's own images only), Save and Close or no +/// destination, Save As steps left to save where they were recorded, suppressed warnings or open +/// options, custom file naming and an error log have no field in the droplet, so they keep the +/// Photoshop step. +pub(super) fn droplet_from(d: &Descriptor) -> Mapped { + read(d).map_or(Mapped::Unsupported { harmless: false }, |p| step("file.automate.createDroplet", Value::Object(p))) +} + +fn read(d: &Descriptor) -> Option> { + if !d.items.iter().all(|(k, _)| KEYS.iter().any(|s| k.is(s))) { + return None; + } + let off = |k: &str| flag(d, k) == Some(false); + if !off("Fldr") || !off("SvAn") || !off("suppressWarnings") || !off("suppressOpenOptions") || flag(d, "Ovrd") != Some(true) { + return None; + } + if ["fileNamingRules", "Log ", "Nm "].iter().any(|k| d.get(k).is_some()) { + return None; + } + let mut p = Map::new(); + p.insert("path".into(), json!(path_text(d.get("In ")?)?)); + let D::Reference(items) = d.get("Usng")? else { return None }; + match items.as_slice() { + [ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }] + if action.class_id.is("Actn") && set.class_id.is("ASet") => + { + p.insert("action".into(), json!(name.to_string_lossy())); + p.insert("set".into(), json!(set_name.to_string_lossy())); + } + [ReferenceItem::Name { class: action, name }] if action.class_id.is("Actn") => { + p.insert("action".into(), json!(name.to_string_lossy())); + } + _ => return None, + } + p.insert("output".into(), json!(path_text(d.get("T ")?)?)); + if flag(d, "OvrO")? { + p.insert("overrideOpen".into(), json!(true)); + } + Some(p) +} + +/// Written as `read` reads it: the droplet file, Include All Subfolders off, the action by name +/// (in its set when the step names one), Override Action "Open" only when on, the destination +/// folder and Override Action "Save As" on. Any other setting refuses the step. +pub(super) fn droplet_to(_: &str, p: &Value) -> Option { + let allowed = ["path", "action", "set", "output", "overrideOpen"]; + if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !allowed.contains(&k.as_str())) { + return None; + } + let path = jstr(p, "path").filter(|s| !s.is_empty())?; + let action = jstr(p, "action").filter(|s| !s.is_empty())?; + let output = jstr(p, "output").filter(|s| !s.is_empty())?; + let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }]; + if let Some(set) = p.get("set").filter(|v| !v.is_null()) { + reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) }); + } + let mut d = desc("null").with("In ", path_value(path)).with("Fldr", D::Boolean(false)).with("Usng", D::Reference(reference)); + match p.get("overrideOpen") { + None | Some(Value::Null) | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) => d = d.with("OvrO", D::Boolean(true)), + Some(_) => return None, + } + Some(d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index 215fab4a67..e844ce7daf 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -600,11 +600,12 @@ pub(crate) const RECORDED_EVENT: &str = "_atnEvent"; /// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`). /// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft. pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { - if !is_effect(&item.event) { - return; - } let [(id, Value::Object(p))] = steps else { return }; - if effect_command(&item.event) != Some(id.as_str()) { + let menu = id == super::MENU_ITEM + && item.descriptor.is_none() + && super::menu_item::from_event(&item.event) + .is_some_and(|expected| p.get("id") == expected.get("id") && p.get("eventKind") == expected.get("eventKind")); + if !menu && effect_command(&item.event) != Some(id.as_str()) { return; } let kind = match item.event { @@ -617,8 +618,8 @@ pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary})); } -/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves -/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +/// Return remembered event/name bytes only while they still match the current command and kind. +/// Otherwise the exporter uses that command's canonical event and name. pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec)> { let r = params.get(RECORDED_EVENT)?; let event = jstr(r, "event")?; @@ -631,7 +632,13 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec< Some(bytes) => bytes, None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(), }; - (effect_command(&event) == Some(id)).then_some((event, name)) + let matches = if id == super::MENU_ITEM { + super::menu_item::from_event(&event) + .is_some_and(|expected| params.get("id") == expected.get("id") && params.get("eventKind") == expected.get("eventKind")) + } else { + effect_command(&event) == Some(id) + }; + matches.then_some((event, name)) } /// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's diff --git a/crates/engine/src/cmd/atn_cmds/steps_gradient.rs b/crates/engine/src/cmd/atn_cmds/steps_gradient.rs new file mode 100644 index 0000000000..8f58d03fed --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_gradient.rs @@ -0,0 +1,297 @@ +//! Classic Gradient recordings (#10710), using the existing pixel-paint command. +//! +//! Presets have centred midpoints and RGB byte colours. Other gradient forms stay +//! kept steps rather than losing settings on playback. Source descriptors are +//! retained only after decoding to the same executable parameters. + +use super::convert::normalise; +use super::descriptors::{desc, enm, pct, px, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{blend_label, blend_ps}; +use super::{D, Descriptor, Value, json}; + +const ORIGIN: &str = "__atnClassicGradient"; +const MAX_STOPS: usize = 64; +const MAX_DESCRIPTOR: usize = 64 * 1024; +const STYLES: &[(&str, &str)] = &[("Lnr ", "linear"), ("Rdl ", "radial"), ("Angl", "angle"), ("Rflc", "reflected"), ("Dmnd", "diamond")]; +const METHODS: &[(&str, &str)] = &[("Gcls", "classic"), ("Perc", "perceptual"), ("Lnr ", "linear"), ("Smoo", "smooth")]; + +/// Extra or repeated fields must not disappear from the operation being played. +fn shape(d: &Descriptor, class: &str, keys: &[&str]) -> Option<()> { + if !d.class_id.is(class) || d.name.0.len() > 1024 || d.items.len() > keys.len() { + return None; + } + for (i, (key, _)) in d.items.iter().enumerate() { + if !keys.iter().any(|k| key.is(k)) || d.items.iter().take(i).any(|(other, _)| other.as_bytes() == key.as_bytes()) { + return None; + } + } + Some(()) +} + +fn object<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> { + match d.get(key)? { + D::Descriptor(d) => Some(d), + _ => None, + } +} + +fn number(v: &D, unit: Option<&[u8; 4]>) -> Option { + let n = match (v, unit) { + (D::Integer(n), None) => f64::from(*n), + (D::Double(n), None) => *n, + (D::UnitFloat { unit: actual, value }, Some(expected)) if actual == expected => *value, + _ => return None, + }; + (n.is_finite() && (n as f32).is_finite()).then_some(n) +} + +fn enumeration<'a>(v: &'a D, ty: &str) -> Option<&'a super::Id> { + match v { + D::Enumerated { type_id, value } if type_id.is(ty) => Some(value), + _ => None, + } +} + +fn flag(d: &Descriptor, key: &str, default: bool) -> Option { + match d.get(key) { + None => Some(default), + Some(D::Boolean(v)) => Some(*v), + _ => None, + } +} + +fn point(d: &Descriptor, key: &str) -> Option { + let p = object(d, key)?; + shape(p, "Pnt ", &["Hrzn", "Vrtc"])?; + Some(json!([number(p.get("Hrzn")?, Some(b"#Pxl"))?, number(p.get("Vrtc")?, Some(b"#Pxl"))?])) +} + +fn list<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [D]> { + match d.get(key)? { + D::List(v) if (2..=MAX_STOPS).contains(&v.len()) => Some(v), + _ => None, + } +} + +fn location(d: &Descriptor, previous: &mut f64) -> Option { + let D::Integer(position) = d.get("Lctn")? else { return None }; + let n = f64::from(*position) / 4096.0; + if !(0.0..=1.0).contains(&n) || n < *previous || !matches!(d.get("Mdpn"), Some(D::Integer(50))) { + return None; + } + *previous = n; + Some(n) +} + +/// Decode every accepted setting; no source metadata is ever executed. +fn params(d: &Descriptor) -> Option { + // Recordings use the event class; PhotoCraft's canonical exports use null. + let class = if d.class_id.is("Grdn") { "Grdn" } else { "null" }; + shape(d, class, &["From", "T ", "Opct", "Md ", "Type", "Rvrs", "Dthr", "UsMs", "gradientsInterpolationMethod", "Grad"])?; + let style = enumeration(d.get("Type")?, "GrdT")?; + let style = STYLES.iter().find(|(ps, _)| style.is(ps))?.1; + // No omitted-method default was established by the recorded-tools reference. + let method = enumeration(d.get("gradientsInterpolationMethod")?, "gradientInterpolationMethodType")?; + let method = METHODS.iter().find(|(ps, _)| method.is(ps))?.1; + let opacity = match d.get("Opct") { + None => 100.0, + Some(v) => number(v, Some(b"#Prc"))?, + }; + if !(1.0..=100.0).contains(&opacity) { + return None; + } + let mode = match d.get("Md ") { + None => "Normal", + Some(v) => blend_label(&trimmed(enumeration(v, "BlnM")?))?, + }; + if mode == "Pass Through" { + return None; + } + let g = object(d, "Grad")?; + shape(g, "Grdn", &["Nm ", "GrdF", "Intr", "Clrs", "Trns"])?; + let D::Text(name) = g.get("Nm ")? else { return None }; + if name.0.len() > 1024 || !enumeration(g.get("GrdF")?, "GrdF")?.is("CstS") { + return None; + } + let smoothness = number(g.get("Intr")?, None)? * 100.0 / 4096.0; + if !(0.0..=100.0).contains(&smoothness) { + return None; + } + let mut stops = Vec::new(); + let mut previous = -1.0; + for s in list(g, "Clrs")? { + let D::Descriptor(s) = s else { return None }; + shape(s, "Clrt", &["Clr ", "Type", "Lctn", "Mdpn"])?; + if !enumeration(s.get("Type")?, "Clry")?.is("UsrS") { + return None; + } + let c = object(s, "Clr ")?; + shape(c, "RGBC", &["Rd ", "Grn ", "Bl "])?; + let rgb = [number(c.get("Rd ")?, None)?, number(c.get("Grn ")?, None)?, number(c.get("Bl ")?, None)?]; + if !rgb.iter().all(|v| (0.0..=255.0).contains(v) && v.fract() == 0.0) { + return None; + } + stops.push(json!([location(s, &mut previous)?, format!("#{:02x}{:02x}{:02x}", rgb[0] as u8, rgb[1] as u8, rgb[2] as u8)])); + } + let use_transparency = flag(d, "UsMs", false)?; + let mut transparency = Vec::new(); + previous = -1.0; + for s in list(g, "Trns")? { + let D::Descriptor(s) = s else { return None }; + shape(s, "TrnS", &["Opct", "Lctn", "Mdpn"])?; + let opacity = number(s.get("Opct")?, Some(b"#Prc"))?; + if !(0.0..=100.0).contains(&opacity) { + return None; + } + let t = location(s, &mut previous)?; + if use_transparency { + transparency.push(json!([t, opacity])); + } + } + Some(json!({"from":point(d,"From")?,"to":point(d,"T ")?,"style":style,"opacity":opacity,"mode":mode, + "reverse":flag(d,"Rvrs",false)?,"dither":flag(d,"Dthr",false)?,"stops":stops, + "transparency":transparency,"method":method,"smoothness":smoothness})) +} + +pub(super) fn from(d: &Descriptor) -> Mapped { + let mapped = || { + let mut p = params(d)?; + let bytes = d.to_bytes(); + if bytes.len() > MAX_DESCRIPTOR { + return None; + } + p[ORIGIN] = json!(crate::paint::tile::b64_encode(&bytes)); + Some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("paint.gradient", p)) +} + +fn finite(v: &Value, lo: f64, hi: f64) -> Option { + v.as_f64().filter(|v| v.is_finite() && (lo..=hi).contains(v)) +} + +fn native_point(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array().filter(|a| a.len() == 2)?; + let coordinate = |i: usize| a.get(i)?.as_f64().filter(|v| v.is_finite() && (*v as f32).is_finite()); + Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(coordinate(0)?)).with("Vrtc", px(coordinate(1)?)))) +} + +fn native_location(a: &[Value], previous: &mut f64) -> Option { + let t = finite(a.first()?, 0.0, 1.0)? * 4096.0; + if t.fract() != 0.0 || t < *previous { + return None; + } + *previous = t; + Some(t as i32) +} + +/// Canonical edited/native form. Never substitute current presets or drop a target. +fn canonical(p: &Value) -> Option { + let style = STYLES.iter().find(|(_, native)| Some(*native) == p.get("style").map_or(Some("linear"), Value::as_str))?.0; + let method = METHODS.iter().find(|(_, native)| Some(*native) == p.get("method").and_then(Value::as_str))?.0; + let smoothness = finite(p.get("smoothness").unwrap_or(&json!(100)), 0.0, 100.0)?; + let mut colors = Vec::new(); + let mut previous = -1.0; + for a in p.get("stops")?.as_array().filter(|a| (2..=MAX_STOPS).contains(&a.len()))? { + let a = a.as_array().filter(|a| a.len() == 2)?; + let text = a.get(1)?.as_str()?.strip_prefix('#')?; + if text.len() != 6 || !text.is_ascii() { + return None; + } + let rgb = u32::from_str_radix(text, 16).ok()?; + let c = desc("RGBC") + .with("Rd ", D::Double(f64::from((rgb >> 16) & 255))) + .with("Grn ", D::Double(f64::from((rgb >> 8) & 255))) + .with("Bl ", D::Double(f64::from(rgb & 255))); + colors.push(D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(c)) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(native_location(a, &mut previous)?)) + .with("Mdpn", D::Integer(50)), + )); + } + let mut transparency = Vec::new(); + previous = -1.0; + let opacity_stops = p.get("transparency")?.as_array().filter(|a| a.len() <= MAX_STOPS)?; + if opacity_stops.len() == 1 { + return None; + } + for a in opacity_stops { + let a = a.as_array().filter(|a| a.len() == 2)?; + transparency.push(D::Descriptor( + desc("TrnS") + .with("Opct", pct(finite(a.get(1)?, 0.0, 100.0)?)) + .with("Lctn", D::Integer(native_location(a, &mut previous)?)) + .with("Mdpn", D::Integer(50)), + )); + } + let use_transparency = !transparency.is_empty(); + if !use_transparency { + transparency = [0, 4096] + .into_iter() + .map(|t| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50)))) + .collect(); + } + let gradient = desc("Grdn") + .with("Nm ", super::descriptors::text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(smoothness * 4096.0 / 100.0)) + .with("Clrs", D::List(colors)) + .with("Trns", D::List(transparency)); + let mut d = desc("null").with("From", native_point(p, "from")?).with("T ", native_point(p, "to")?); + let opacity = finite(p.get("opacity").unwrap_or(&json!(100)), 1.0, 100.0)?; + if opacity != 100.0 { + d = d.with("Opct", pct(opacity)); + } + let mode = blend_ps(p.get("mode").map_or(Some("Normal"), Value::as_str)?)?; + if mode == "passThrough" || mode == "PsTh" { + return None; + } + if mode != "Nrml" { + d = d.with("Md ", enm("BlnM", mode)); + } + d = d.with("Type", enm("GrdT", style)); + for (param, key, default) in [("reverse", "Rvrs", false), ("dither", "Dthr", true)] { + let value = p.get(param).map_or(Some(default), Value::as_bool)?; + if value { + d = d.with(key, D::Boolean(true)); + } + } + if use_transparency { + d = d.with("UsMs", D::Boolean(true)); + } + Some(d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", method)).with("Grad", D::Descriptor(gradient))) +} + +pub(super) fn to(_: &str, p: &Value) -> Option { + let values = p.as_object()?; + if !pixel_target(p) + || !values.keys().all(|k| { + ["from", "to", "style", "opacity", "mode", "reverse", "dither", "stops", "transparency", "method", "smoothness", "target", ORIGIN] + .contains(&k.as_str()) + }) + { + return None; + } + if let Some(origin) = p.get(ORIGIN) { + let text = origin.as_str().filter(|v| v.len() <= MAX_DESCRIPTOR * 2)?; + let bytes = crate::paint::tile::b64_decode(text)?; + if bytes.len() > MAX_DESCRIPTOR { + return None; + } + let d = Descriptor::from_bytes(&bytes).ok()?; + let mut expected = params(&d)?; + let mut actual = p.clone(); + actual.as_object_mut()?.remove(ORIGIN); + actual.as_object_mut()?.remove("target"); + normalise(&mut expected); + normalise(&mut actual); + if expected == actual { + return Some(d); + } + } + canonical(p) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 149f1939fb..760b0d1a0d 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -136,7 +136,7 @@ pub(super) fn transform_to(id: &str, p: &Value) -> Option { } /// `Clcn` (Apply Image / Calculations blending) ↔ `image.applyImage`'s `blending`. -const CALC_BLEND: &[(&str, &str)] = &[ +pub(super) const CALC_BLEND: &[(&str, &str)] = &[ ("Nrml", "normal"), ("Mltp", "multiply"), ("Scrn", "screen"), @@ -246,7 +246,13 @@ pub(super) fn apply_image_to(_: &str, p: &Value) -> Option { if !source.is_object() || not_name("layer") || not_name("channel") { return None; } - let mut items = vec![apply_image_channel(jstr(&source, "channel").unwrap_or("composite"))?]; + let channel = jstr(&source, "channel").unwrap_or("composite"); + // Without a layer, transparency is the active layer's (`channel_cmds::refs::source`); `Mrgd` + // would replay the merged transparency instead, and that layer has no name to record here. + if channel.eq_ignore_ascii_case("transparency") && jstr(&source, "layer").is_none() { + return None; + } + let mut items = vec![apply_image_channel(channel)?]; match jstr(&source, "layer") { Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }), diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs new file mode 100644 index 0000000000..4b85127cf4 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -0,0 +1,130 @@ +//! The measured Scale Effects event and Make-from-layer-effects form. +use super::descriptors::{PCT, class, class_ref, desc, get_ref, jstr, pct, ref_class}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; + +const SCALE: &str = "layer.layerStyle.scaleEffects"; +pub(super) const CREATE: &str = "layer.layerStyle.createLayer"; +const SOURCE: &str = "_atnLayerStyleDescriptor"; + +/// Only documented keys, once each: unknown or repeated settings cannot be discarded. +fn keys(d: &Descriptor, expected: &[&str]) -> bool { + d.items.len() == expected.len() && expected.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) +} + +/// The dialog records whole percentages; native fractional/clamped values are not guessed. +fn whole_percent(value: f64) -> bool { + value.is_finite() && (1.0..=1000.0).contains(&value) && value.fract() == 0.0 +} + +/// The native Create Layers operation has only the measured implicit active-layer target. +fn create_descriptor() -> Descriptor { + desc("null").with("null", class_ref("Lyr ")).with( + "Usng", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + ) +} + +/// Parse the exact recorded values and reference chain, keeping unsupported forms complete. +fn parameters(id: &str, d: &Descriptor) -> Option { + if id == SCALE { + if !keys(d, &["Scl "]) { + return None; + } + let D::UnitFloat { unit, value } = d.get("Scl ")? else { return None }; + return (*unit == PCT && whole_percent(*value)).then(|| json!({"scale":value})); + } + if id != CREATE || !keys(d, &["null", "Usng"]) { + return None; + } + let [ReferenceItem::Class(layer)] = get_ref(d, "null")? else { return None }; + let [ReferenceItem::Property { class: property, key }, ReferenceItem::Enumerated { class: target, type_id, value }] = get_ref(d, "Usng")? else { + return None; + }; + (layer.class_id.is("Lyr ") && property.class_id.is("Prpr") && key.is("Lefx") && target.class_id.is("Lyr ") && type_id.is("Ordn") && value.is("Trgt")) + .then(|| json!({})) +} + +/// Build supported native values explicitly; an absent scale uses the native default of 100. +fn descriptor(id: &str, p: &Value) -> Option { + let object = p.as_object()?; + if object.keys().any(|key| key != SOURCE && !(id == SCALE && key == "scale")) { + return None; + } + match id { + SCALE => { + let value = match p.get("scale") { + None => 100.0, + Some(v) => v.as_f64()?, + }; + whole_percent(value).then(|| desc("null").with("Scl ", pct(value))) + } + CREATE => Some(create_descriptor()), + _ => None, + } +} + +/// Preserve wire identity/order while unchanged, without making it a native option. +fn from(id: &str, d: &Descriptor) -> Mapped { + let Some(mut p) = parameters(id, d) else { return Mapped::Unsupported { harmless: false } }; + if descriptor(id, &p).as_ref() != Some(d) { + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(id, p) +} + +/// Import Scale Effects only when the whole recorded percentage is represented. +pub(super) fn scale_from(d: &Descriptor) -> Mapped { + from(SCALE, d) +} + +/// Find typed effects references in parsed values; opaque data and class names are not references. +fn has_style_reference(value: &D) -> bool { + let mut pending = vec![value]; + while let Some(value) = pending.pop() { + match value { + D::Reference(parts) => { + if parts.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx"))) { + return true; + } + } + D::Descriptor(d) | D::GlobalObject(d) => pending.extend(d.items.iter().map(|(_, value)| value)), + D::List(values) => pending.extend(values), + D::ObjectArray(array) => pending.extend(array.body.items.iter().map(|(_, value)| value)), + _ => {} + } + } + false +} + +/// Intercept layer-style Make intent before generic Make can turn it into a blank layer. +pub(super) fn create_from_descriptor(d: &Descriptor) -> Option { + let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr"); + let style_intent = d.items.iter().any(|(item_key, value)| { + // Deep inspection belongs to Layer Make; other Make classes retain direct-reference handling. + let style_reference = (layer_make || matches!(value, D::Reference(_))) && has_style_reference(value); + if item_key.is("Usng") { + // A layer Make may omit Usng, or use the ordinary layer-options object. Other + // shapes must not be discarded as if they were an empty layer-options object. + style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr "))) + } else { + // A miskeyed effects reference is still style intent, not an ordinary blank layer. + layer_make && style_reference + } + }); + style_intent.then(|| from(CREATE, d)) +} + +/// Validate preserved data before fallback; supported edits export their current canonical values. +pub(super) fn style_to(id: &str, p: &Value) -> Option { + let canonical = descriptor(id, p)?; + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + let original = descriptor(id, ¶meters(id, &source)?)?; + Some(if original == canonical { source } else { canonical }) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_lookup.rs b/crates/engine/src/cmd/atn_cmds/steps_lookup.rs new file mode 100644 index 0000000000..a5f599da8c --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_lookup.rs @@ -0,0 +1,111 @@ +//! Embedded 3D LUT steps retain their recorded metadata and the original LUT bytes. + +use super::descriptors::{enm, get_bool, get_text, jstr, text}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Value, json}; + +const ORIGINAL: &str = "__atnColorLookup"; +const KEYS: &[&str] = &["lookupType", "Nm ", "Dthr", "profile", "LUTFormat", "LUT3DFileData", "LUT3DFileName"]; + +/// Admit only measured 3D-LUT fields and parser-supported data; other profiles and settings stay whole. +fn read(d: &Descriptor) -> Option<(&[u8], &'static str, String, bool)> { + if d.items.iter().any(|(k, _)| !KEYS.iter().any(|key| k.is(key))) || KEYS.iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() > 1) { + return None; + } + let D::Enumerated { type_id, value } = d.get("lookupType")? else { return None }; + if !type_id.is("colorLookupType") || !value.is("3DLUT") { + return None; + } + // A nonempty profile may affect processing; the native LUT command has no ICC stage. + if d.get("profile").is_some_and(|v| !matches!(v, D::RawData(bytes) if bytes.is_empty())) || d.get("Nm ").is_some_and(|v| !matches!(v, D::Text(_))) { + return None; + } + let D::Enumerated { type_id, value } = d.get("LUTFormat")? else { return None }; + if !type_id.is("LUTFormatType") { + return None; + } + let format = if value.is("LUTFormatCUBE") { + "cube" + } else if value.is("LUTFormat3DL") { + "3dl" + } else if value.is("LUTFormatLOOK") { + "look" + } else { + return None; + }; + let D::RawData(bytes) = d.get("LUT3DFileData")? else { return None }; + if bytes.is_empty() { + return None; + } + let parser = match format { + "3dl" => photocraft_cms::lutfile::Format::ThreeDl, + "look" => photocraft_cms::lutfile::Format::Look, + _ => photocraft_cms::lutfile::Format::Cube, + }; + let lut = photocraft_cms::lutfile::parse_as(parser, bytes).ok()?; + if !lut.domain_is_default() { + return None; + } + Some((bytes, format, get_text(d, "LUT3DFileName")?, get_bool(d, "Dthr")?)) +} + +/// Carry the original bytes into native playback without following the recorded source path. +fn params(d: &Descriptor) -> Option { + let (bytes, format, file_name, dither) = read(d)?; + // Keep the potentially large file only in `data`; the template retains key order and + // recording metadata without another copy of the embedded LUT. + let template = Descriptor { + name: d.name.clone(), + class_id: d.class_id.clone(), + items: d.items.iter().map(|(key, value)| (key.clone(), if key.is("LUT3DFileData") { D::RawData(Vec::new()) } else { value.clone() })).collect(), + }; + Some(json!({ + "data": photocraft_paint::tile::b64_encode(bytes), + "dataEncoding": "base64", + "lutFormat": format, + "fileName": file_name, + "dither": dither, + "__atnColorLookup": photocraft_paint::tile::b64_encode(&template.to_bytes()), + })) +} + +/// Keep unsupported lookup forms intact instead of replaying only a subset of their processing. +pub(super) fn from(d: &Descriptor) -> Mapped { + params(d).map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.colorLookup", p)) +} + +/// Restore the embedded bytes into their recorded metadata; unrepresented edits refuse export. +pub(super) fn to(_: &str, p: &Value) -> Option { + if p.as_object()?.keys().any(|key| !["data", "dataEncoding", "lutFormat", "fileName", "dither", ORIGINAL].contains(&key.as_str())) + || jstr(p, "dataEncoding")? != "base64" + { + return None; + } + let mut d = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, ORIGINAL)?)?).ok()?; + let bytes = photocraft_paint::tile::b64_decode(jstr(p, "data")?)?; + if d.items.iter().filter(|(key, _)| key.is("LUT3DFileData")).count() != 1 { + return None; + } + let (_, value) = d.items.iter_mut().find(|(key, _)| key.is("LUT3DFileData"))?; + *value = D::RawData(bytes); + let format = match jstr(p, "lutFormat")? { + "cube" => "LUTFormatCUBE", + "3dl" => "LUTFormat3DL", + "look" => "LUTFormatLOOK", + _ => return None, + }; + let file_name = jstr(p, "fileName")?; + for (key, value) in &mut d.items { + if key.is("LUTFormat") { + *value = enm("LUTFormatType", format); + } else if key.is("LUT3DFileName") { + if !matches!(value, D::Text(recorded) if recorded.to_string_lossy() == file_name) { + *value = text(file_name); + } + } else if key.is("Dthr") { + *value = D::Boolean(p.get("dither")?.as_bool()?); + } + } + read(&d)?; + Some(d) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 9ce3bbb8f7..c331d1c585 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1,12 +1,28 @@ use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps, levels_complete}; use super::descriptors::{ - PCT, PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, - jnum, jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, + PCT, PX, Target, ang, boolean, class, class_ref, code, color_desc, color_hex, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj, + get_ref, get_text, jbool, jnum, jstr, layer_ref, layer_target, long, name_ref, num, pct, px, ref_class, target, target_ref, text, trimmed, }; use super::fields::{Mapped, none, step, steps}; use super::steps_layer::quick_mask_ref; -use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_back}; -use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json}; +use super::tables::{CHANNELS, LABEL_COLORS, MODES, blend_label, blend_ps, channel_of, channel_ref, lookup, lookup_back}; +use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; + +/// A `Md ` value as a blend-mode label, only when it is a `BlnM` enumeration: another +/// enumeration type sharing a value id (`Mltp`) is not a blend mode. +fn blend_mode(d: &Descriptor) -> Option<&'static str> { + match d.get("Md ")? { + D::Enumerated { type_id, value } if type_id.is("BlnM") => blend_label(&trimmed(value)), + _ => None, + } +} + +/// A `Nm ` text that saves back unchanged: no unpaired surrogate replaced, no extra NULs lost. +fn exact_name(d: &Descriptor) -> Option { + let D::Text(original) = d.get("Nm ")? else { return None }; + let name = original.to_string_lossy(); + (*original == UnicodeString::new_nul(&name) || *original == UnicodeString::new(&name)).then_some(name) +} /// A `patternLayer` descriptor's pattern, scale, angle and link as the params of /// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. A pattern recorded with both an id @@ -62,6 +78,354 @@ fn pattern_desc(p: &Value) -> Option { Some(t) } +/// Gradient Fill layers: `GrdT` ↔ `style`. +const GRADIENT_STYLES: &[(&str, &str)] = &[("Lnr", "linear"), ("Rdl", "radial"), ("Angl", "angle"), ("Rflt", "reflected"), ("Dmnd", "diamond")]; + +/// The `gradientLayer` keys [`gradient_params`] reads; a descriptor with any other key stays a +/// kept Photoshop step. +const GRADIENT_LAYER_KEYS: &[&str] = &["Grad", "Type", "Angl", "Rvrs", "Dthr", "Algn", "Scl ", "Ofst", "gradientsInterpolationMethod"]; + +/// The hidden param an imported Gradient Fill step keeps its recorded `gradientLayer` descriptor +/// in, so Save Actions writes it back unchanged while its params are the ones it loaded with. +const GRADIENT_DESCRIPTOR: &str = "__atnDescriptor"; + +/// A `gradientLayer` descriptor as the params of `layer.newFillLayer.gradient` / +/// `gradient.fill.set`. Midpoint k belongs to the segment that ends at stop k + 1, as the PSD +/// reader reads it; all-default midpoints give `[]`, which clears custom ones on a Set. None for +/// what the fill does not play: a Noise gradient, a smoothness below 100 %, a non-classic +/// interpolation, a transparency midpoint, stops out of order or values out of range. +fn gradient_params(t: &Descriptor) -> Option> { + if t.items.iter().any(|(k, _)| !GRADIENT_LAYER_KEYS.iter().any(|g| k.is(g))) { + return None; + } + let mut p = Map::new(); + if let Some(s) = t.get("Type") { + p.insert("style".into(), lookup(GRADIENT_STYLES, &enumerated(s)?)?.into()); + } + if let Some(a) = t.get("Angl") { + p.insert("angle".into(), num(a)?.into()); + } + for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither"), ("Algn", "align")] { + if let Some(v) = t.get(key) { + p.insert(param.into(), boolean(v)?.into()); + } + } + if let Some(s) = t.get("Scl ") { + p.insert("scale".into(), num(s)?.into()); + } + if let Some(o) = t.get("Ofst") { + let D::Descriptor(o) = o else { return None }; + p.insert("offset".into(), json!([get_num(o, "Hrzn")?, get_num(o, "Vrtc")?])); + } + if t.get("gradientsInterpolationMethod").is_some_and(|m| enumerated(m).as_deref() != Some("Gcls")) { + return None; + } + let g = get_obj(t, "Grad")?; + if get_enum(g, "GrdF").as_deref() != Some("CstS") || g.get("Intr").is_some_and(|i| num(i) != Some(4096.0)) { + return None; + } + let (mut stops, mut mids, mut previous) = (Vec::new(), Vec::new(), 0.0); + for (i, s) in get_list(g, "Clrs").filter(|c| (2..=1024).contains(&c.len()))?.iter().enumerate() { + let D::Descriptor(s) = s else { return None }; + let color = match get_enum(s, "Type").as_deref() { + None | Some("UsrS") => json!(color_hex(get_obj(s, "Clr ")?)?), + Some("FrgC") => json!("foreground"), + Some("BckC") => json!("background"), + Some(_) => return None, + }; + let at = get_num(s, "Lctn")?; + if !(previous..=4096.0).contains(&at) { + return None; + } + previous = at; + stops.push(json!([at / 4096.0, color])); + if i > 0 { + // The dialog's midpoints are 5..95 %; playback would clamp anything else. + let mid = get_num(s, "Mdpn").unwrap_or(50.0); + if !(5.0..=95.0).contains(&mid) { + return None; + } + mids.push(mid / 100.0); + } + } + p.insert("stops".into(), Value::Array(stops)); + if let Some(trns) = get_list(g, "Trns") { + let (mut out, mut previous) = (Vec::new(), 0.0); + for s in trns { + let D::Descriptor(s) = s else { return None }; + if get_num(s, "Mdpn").is_some_and(|m| m != 50.0) { + return None; + } + // Playback would clamp and sort what is out of range or out of order. + let (at, opacity) = (get_num(s, "Lctn")?, get_num(s, "Opct")?); + if !(previous..=4096.0).contains(&at) || !(0.0..=100.0).contains(&opacity) { + return None; + } + previous = at; + out.push(json!([at / 4096.0, opacity])); + } + p.insert("transparency".into(), Value::Array(out)); + } + if mids.iter().all(|m| *m == 0.5) { + mids.clear(); + } + p.insert("midpoints".into(), json!(mids)); + Some(p) +} + +/// [`gradient_params`] plus the recorded descriptor, for Save Actions. +fn gradient_step(t: &Descriptor) -> Option { + let mut p = gradient_params(t)?; + p.insert(GRADIENT_DESCRIPTOR.into(), photocraft_paint::tile::b64_encode(&t.to_bytes()).into()); + Some(Value::Object(p)) +} + +/// The `gradientLayer` descriptor for a Gradient Fill step: the recorded one while the params are +/// still what it imports as, else one written from the params. That needs every stop as +/// `#rrggbb` on a 1/4096 location, whole-percent midpoints and, for a Set (`set`), the +/// transparency stops: Photoshop's Set replaces the whole gradient, where the command keeps the +/// layer's own transparency. +fn gradient_desc(p: &Value, set: bool) -> Option { + if let Some(t) = jstr(p, GRADIENT_DESCRIPTOR).and_then(photocraft_paint::tile::b64_decode).and_then(|b| Descriptor::from_bytes(&b).ok()) { + let (mut a, mut b) = (Value::Object(gradient_params(&t)?), p.clone()); + b.as_object_mut()?.remove(GRADIENT_DESCRIPTOR); + super::convert::normalise(&mut a); + super::convert::normalise(&mut b); + if a == b { + return Some(t); + } + } + const KEYS: &[&str] = &["style", "angle", "reverse", "dither", "align", "scale", "offset", "stops", "transparency", "midpoints", GRADIENT_DESCRIPTOR]; + if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !KEYS.contains(&k.as_str())) || (set && p.get("transparency").is_none()) { + return None; + } + let location = |v: &Value| { + let t = v.as_f64()? * 4096.0; + ((0.0..=4096.0).contains(&t) && t.fract() == 0.0).then(|| long(t)) + }; + let stops = p.get("stops")?.as_array().filter(|s| (2..=1024).contains(&s.len()))?; + let mids: Vec = match p.get("midpoints") { + None | Some(Value::Null) => Vec::new(), + Some(m) => m.as_array()?.iter().map(Value::as_f64).collect::>()?, + }; + if mids.len() >= stops.len() { + return None; + } + let (mut clrs, mut previous) = (Vec::new(), 0.0); + for (i, s) in stops.iter().enumerate() { + let [at, color] = s.as_array()?.as_slice() else { return None }; + if at.as_f64()? < previous { + return None; + } + previous = at.as_f64()?; + // A foreground or background stop is left out: Photoshop records the colour it had too. + let color = color_desc(color.as_str()?)?; + let mid = if i == 0 { 50.0 } else { mids.get(i - 1).copied().unwrap_or(0.5) * 100.0 }; + if (mid - mid.round()).abs() > 1e-9 || !(5.0..=95.0).contains(&mid.round()) { + return None; + } + clrs.push(D::Descriptor( + desc("Clrt").with("Clr ", D::Descriptor(color)).with("Type", enm("Clry", "UsrS")).with("Lctn", location(at)?).with("Mdpn", long(mid)), + )); + } + let default_trns = vec![json!([0, 100]), json!([1, 100])]; + let trns: Option> = p + .get("transparency") + .and_then(Value::as_array) + .unwrap_or(&default_trns) + .iter() + .map(|s| { + let [at, o] = s.as_array()?.as_slice() else { return None }; + Some(D::Descriptor(desc("TrnS").with("Opct", pct(o.as_f64()?)).with("Lctn", location(at)?).with("Mdpn", long(50.0)))) + }) + .collect(); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(clrs)) + .with("Trns", D::List(trns?)); + let mut t = desc("gradientLayer"); + if let Some(a) = jnum(p, "angle") { + t = t.with("Angl", ang(a)); + } + if let Some(s) = jstr(p, "style") { + t = t.with("Type", enm("GrdT", lookup_back(GRADIENT_STYLES, s)?)); + } + for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr"), ("align", "Algn")] { + if let Some(v) = p.get(param).filter(|v| !v.is_null()) { + t = t.with(key, D::Boolean(v.as_bool()?)); + } + } + if let Some(s) = jnum(p, "scale") { + t = t.with("Scl ", pct(s)); + } + if let Some(o) = p.get("offset").filter(|v| !v.is_null()) { + let [x, y] = o.as_array()?.as_slice() else { return None }; + t = t.with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(x.as_f64()?)).with("Vrtc", pct(y.as_f64()?)))); + } + Some(t.with("Grad", D::Descriptor(grad))) +} + +/// `layer.newFillLayer.gradient` as a Make of a `contentLayer`. The two-colour form (`from`, +/// `to`) becomes the stops it plays as; without them it plays the toolbox colours, which +/// Photoshop records with the colour they had. +fn gradient_make_to(p: &Value) -> Option { + let mut p = p.clone(); + let o = p.as_object_mut()?; + if o.remove("path").is_some_and(|v| !v.is_null()) { + return None; + } + // Stops replace `from` and `to` when the step plays, so those two are left out beside them. + match (o.remove("from").filter(|v| !v.is_null()), o.remove("to").filter(|v| !v.is_null())) { + _ if o.contains_key("stops") => {} + (None, None) => {} + (Some(a), Some(b)) => { + o.insert("stops".into(), json!([[0, a], [1, b]])); + } + _ => return None, + } + let t = gradient_desc(&p, false)?; + Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))) +} + +/// `layer.layerStyle.blendingOptions` as a Set of the target layer. +fn blending_options_to(p: &Value) -> Option { + let o = p.as_object()?; + let blend_if = p.get("blendIf"); + let mut t = desc("Lyr "); + if blend_if.is_some() { + // With Blend If, the dialog's opacities and mode are in the same `set`; without it, + // Photoshop's `set` of them loads as `layer.setProps`. + for (param, key) in [("opacity", "Opct"), ("fillOpacity", "fillOpacity")] { + if let Some(v) = jnum(p, param) { + t = t.with(key, pct(v)); + } + } + if let Some(m) = jstr(p, "blend") { + t = t.with("Md ", enm("BlnM", blend_ps(m)?)); + } + } + for (key, param) in ADVANCED_BLENDING { + if let Some(b) = jbool(p, param) { + t = t.with(key, D::Boolean(b)); + } + } + if let Some(b) = blend_if { + t = t.with("Blnd", blend_if_to(b)?); + if let Some(fx) = jstr(p, EFFECTS_SCALE) { + let fx = photocraft_paint::tile::b64_decode(fx).and_then(|b| Descriptor::from_bytes(&b).ok())?; + if !scale_only(&fx) { + return None; + } + t = t.with("Lefx", D::Descriptor(fx)); + } + } + // Only these export: Photoshop records the dialog's other options differently. + if t.items.is_empty() || t.items.len() != o.len() { + return None; + } + Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))) +} + +/// Blend If: a `Blnd` list's `Chnl` ↔ `blendIf` channel (`Gry` is the composite). +fn blend_if_channel(r: &[ReferenceItem]) -> Option<&'static str> { + match target(r) { + Target::Ordinal(o) if o == "Cmps" => Some("gray"), + Target::Ordinal(o) => channel_of(&o), + _ => None, + } +} + +/// The slider keys of one `Blnd` entry: This Layer, then Underlying Layer, each black low and +/// high, white low and high. +const BLEND_IF_KEYS: [[&str; 4]; 2] = [["SrcB", "Srcl", "SrcW", "Srcm"], ["DstB", "Dstl", "DstW", "Dstt"]]; + +/// A `Blnd` list as `layer.layerStyle.blendingOptions`' `blendIf`. Each entry's sliders are +/// whole levels 0..255 (also in a 16-bit document); Underlying Layer may be missing. None for +/// anything else, so the step stays a kept Photoshop step. +fn blend_if_from(list: &[D]) -> Option { + let mut out = Vec::with_capacity(list.len()); + for e in list { + let D::Descriptor(e) = e else { return None }; + if e.items.iter().any(|(k, _)| !k.is("Chnl") && !BLEND_IF_KEYS.iter().flatten().any(|b| k.is(b))) { + return None; + } + let channel = blend_if_channel(get_ref(e, "Chnl")?)?; + let range = |keys: &[&str; 4]| -> Option> { + let v: Vec> = keys + .iter() + .map(|k| match e.get(k) { + Some(D::Integer(i)) if (0..=255).contains(i) => Some(*i), + _ => None, + }) + .collect(); + match v.iter().flatten().count() { + 0 if keys.iter().all(|k| e.get(k).is_none()) => Some(None), + // In order, as the command takes them; an inverted range stays a Photoshop step. + 4 => { + let q: Vec = v.into_iter().flatten().collect(); + q.windows(2).all(|w| w[0] <= w[1]).then(|| Some(json!(q))) + } + _ => None, + } + }; + let mut m = json!({"channel": channel, "thisLayer": range(&BLEND_IF_KEYS[0])??}); + if let Some(u) = range(&BLEND_IF_KEYS[1])? { + m["underlying"] = u; + } + out.push(m); + } + (!out.is_empty()).then_some(Value::Array(out)) +} + +/// `blendIf` back as a `Blnd` list: named channels and whole levels only (an index or `null` +/// reset has no Photoshop form). +fn blend_if_to(v: &Value) -> Option { + let entries = match v { + Value::Array(a) => a.iter().collect::>(), + Value::Object(_) => vec![v], + _ => return None, + }; + let mut list = Vec::with_capacity(entries.len()); + for e in entries { + let o = e.as_object()?; + if o.keys().any(|k| !matches!(k.as_str(), "channel" | "thisLayer" | "underlying")) { + return None; + } + let channel = match e.get("channel") { + None | Some(Value::Null) => "gray", + Some(Value::String(c)) if c == "grey" => "gray", + Some(Value::String(c)) => c.as_str(), + _ => return None, + }; + lookup_back(CHANNELS, channel)?; + let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel))); + for (param, keys) in [("thisLayer", &BLEND_IF_KEYS[0]), ("underlying", &BLEND_IF_KEYS[1])] { + let Some(r) = e.get(param) else { + if param == "thisLayer" { + return None; + } + continue; + }; + let r: Vec = r.as_array()?.iter().map(Value::as_f64).collect::>()?; + let q = match r.as_slice() { + [b, w] => [*b, *b, *w, *w], + [a, b, c, w] => [*a, *b, *c, *w], + _ => return None, + }; + if !q.iter().all(|x| (0.0..=255.0).contains(x) && x.fract() == 0.0) || !q.windows(2).all(|w| w[0] <= w[1]) { + return None; + } + for (k, x) in keys.iter().zip(q) { + d = d.with(k, long(x)); + } + } + list.push(D::Descriptor(d)); + } + Some(D::List(list)) +} + /// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command. const MASK_USING: &[(&str, &str)] = &[ ("RvlA", "layer.layerMask.revealAll"), @@ -179,6 +543,15 @@ fn type_shape_descriptor() -> Descriptor { /// Import Make events only when their geometry and options can be represented faithfully, /// including the exact target-text-to-content-layer references. pub(super) fn make_from(d: &Descriptor) -> Mapped { + if let Some(mapped) = super::steps_calculations::from_descriptor(d) { + return mapped; + } + if get_ref(d, "null").and_then(ref_class).as_deref() == Some("Path") { + return super::steps_type_path::from(d); + } + if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) { + return mapped; + } if d.items == type_shape_descriptor().items { return none("type.convertToShape"); } @@ -204,7 +577,7 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { if let Some(o) = get_num(u, "Opct") { p.insert("opacity".into(), (o / 100.0).into()); } - if let Some(m) = get_enum(u, "Md ").and_then(|m| blend_label(&m)) { + if let Some(m) = blend_mode(u) { p.insert("mode".into(), m.into()); } match u.get("Grup") { @@ -235,6 +608,28 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { // Recorded layer opacity uses 0..255 even in a 16-bit document. params.insert("opacity".into(), ((opacity * 255.0 / 100.0).round() / 255.0).into()); } + // Name, blend mode and clipping travel on the same command; an unreadable value + // keeps the whole step rather than replaying a different layer (#8384). + if let Some(u) = using { + let Some((_, Value::Object(params))) = mapped.first_mut() else { return Mapped::Unsupported { harmless: false } }; + if u.get("Nm ").is_some() { + let Some(name) = exact_name(u) else { return Mapped::Unsupported { harmless: false } }; + params.insert("name".into(), name.into()); + } + if u.get("Md ").is_some() { + let Some(mode) = blend_mode(u).filter(|m| *m != "Pass Through") else { + return Mapped::Unsupported { harmless: false }; + }; + params.insert("mode".into(), mode.into()); + } + match u.get("Grup") { + None => {} + Some(D::Boolean(clip)) => { + params.insert("clipToPrevious".into(), (*clip).into()); + } + Some(_) => return Mapped::Unsupported { harmless: false }, + } + } if let Some(c) = using.and_then(|u| get_enum(u, "Clr ")) { let Some(color) = lookup(LABEL_COLORS, &c) else { return Mapped::Unsupported { harmless: false } }; mapped.push(("layer.setLabelColor".into(), json!({"color": color}))); @@ -256,6 +651,11 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + // A name, opacity, mode or clipping beside `Type` would neither play nor save back. + Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) { + Some(p) if using.is_some_and(|u| u.items.iter().all(|(k, _)| k.is("Type"))) => step("layer.newFillLayer.gradient", p), + _ => Mapped::Unsupported { harmless: false }, + }, _ => Mapped::Unsupported { harmless: false }, }, Some("layerSection") => { @@ -311,6 +711,15 @@ fn recorded_percent(opacity: f64) -> f64 { /// Write a creation step, refusing native options the target descriptor cannot carry; Type to /// Shape exports only without native options. pub(super) fn make_to(id: &str, p: &Value) -> Option { + if id == "image.calculations" { + return super::steps_calculations::to_descriptor(p); + } + if id == super::steps_type_path::COMMAND { + return super::steps_type_path::to(p); + } + if id == super::steps_layer_style::CREATE { + return super::steps_layer_style::style_to(id, p); + } if id == "type.convertToShape" { return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor()); } @@ -376,6 +785,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.newFillLayer.gradient" => gradient_make_to(p)?, "layer.duplicate" => { // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). // Anything else falls through to the `Dplc` event below. @@ -439,32 +849,67 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { n = n.with("Fl ", enm("Fl ", fill)).with("Dpth", long(jnum(p, "depth").unwrap_or(8.0))); desc("null").with("Nw ", D::Descriptor(n)) } - _ => { - let suffix = id.strip_prefix("layer.newAdjustmentLayer.")?; - let a = adjustment_by_suffix(suffix)?; - let opacity = match p.get("opacity") { - None => None, - Some(value) => Some(value.as_f64().filter(|o| o.is_finite() && (0.0..=1.0).contains(o))?), - }; - let mut params = p.clone(); - if let Some(params) = params.as_object_mut() { - params.remove("opacity"); - } - // Layer opacity must not turn default adjustment settings into a custom preset. - let t = if params.as_object().is_some_and(|o| o.is_empty()) { - desc(a.class).with("presetKind", enm("presetKindType", "presetKindDefault")) - } else { - (a.to)(¶ms)? - }; - let mut using = desc("AdjL").with("Type", D::Descriptor(t)); - if let Some(opacity) = opacity { - using = using.with("Opct", pct(recorded_percent(opacity))); - } - desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)) - } + _ => return adjustment_layer_to(id, p), }) } +/// `layer.newAdjustmentLayer.*` as a Make step: the adjustment's settings under `Type`, the new +/// layer's opacity, name, blend mode and clipping beside it. +fn adjustment_layer_to(id: &str, p: &Value) -> Option { + let suffix = id.strip_prefix("layer.newAdjustmentLayer.")?; + let a = adjustment_by_suffix(suffix)?; + let opacity = match p.get("opacity") { + None => None, + Some(value) => Some(value.as_f64().filter(|o| o.is_finite() && (0.0..=1.0).contains(o))?), + }; + // Layer properties go on the `Usng` layer, not into the adjustment's own settings; a + // value the reference application cannot record makes the step unexportable (#8384). + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str()?), + }; + let mode = match p.get("mode") { + None | Some(Value::Null) => None, + Some(v) => { + let label = v.as_str()?; + if crate::commands::blend_from_str(label)? == photocraft_doc::BlendMode::PassThrough { + return None; + } + Some(blend_ps(label)?) + } + }; + let clip = match p.get("clipToPrevious") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool()?), + }; + let mut params = p.clone(); + if let Some(params) = params.as_object_mut() { + for key in ["opacity", "name", "mode", "clipToPrevious"] { + params.remove(key); + } + } + // Layer opacity must not turn default adjustment settings into a custom preset. + let t = if params.as_object().is_some_and(|o| o.is_empty()) { + desc(a.class).with("presetKind", enm("presetKindType", "presetKindDefault")) + } else { + (a.to)(¶ms)? + }; + let mut using = desc("AdjL").with("Type", D::Descriptor(t)); + if let Some(opacity) = opacity { + using = using.with("Opct", pct(recorded_percent(opacity))); + } + if let Some(name) = name { + using = using.with("Nm ", text(name)); + } + if let Some(mode) = mode { + using = using.with("Md ", enm("BlnM", mode)); + } + if let Some(clip) = clip { + using = using.with("Grup", D::Boolean(clip)); + } + Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using))) +} + /// Import selection geometry or a channel source, including its optional source layer. fn selection_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; @@ -567,6 +1012,25 @@ const LOCKS: &[(&str, &str)] = &[ ("protectAll", "all"), ]; +/// The Blending Options keys besides the Advanced checkboxes that `layer.layerStyle.blendingOptions` +/// takes from a `set` of the layer: opacity, fill opacity, mode and Blend If. +const BLENDING_DIALOG: &[&str] = &["Opct", "fillOpacity", "Md ", "Blnd"]; + +/// The hidden `layer.layerStyle.blendingOptions` param that keeps the `Lefx` = {Scl} the Layer +/// Style dialog's OK records beside Blend If, so Save Actions writes the Set back unchanged. +const EFFECTS_SCALE: &str = "__atnEffectsScale"; + +/// A `Lefx` holding only the effects' scale: what the dialog's OK records on a layer without +/// effects. It changes nothing. +fn scale_only(fx: &Descriptor) -> bool { + fx.items.iter().all(|(k, _)| k.is("Scl ")) +} + +/// A `T` key that is such a `Lefx`. +fn is_scale_only(k: &Id, v: &D) -> bool { + matches!(v, D::Descriptor(fx) if k.is("Lefx") && scale_only(fx)) +} + /// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params. const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")]; @@ -648,6 +1112,32 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { advanced.insert((*param).into(), b.into()); } } + if let Some(list) = get_list(t, "Blnd") { + // Effects beside Blend If have no step here: keep the recorded step rather than play + // and save it back without them. + if t.items.iter().any(|(k, v)| k.is("Lefx") && !is_scale_only(k, v)) { + return unsupported; + } + let Some(b) = blend_if_from(list) else { return unsupported }; + advanced.insert("blendIf".into(), b); + // The Blending Options dialog's OK: with nothing but its own options, the opacities and + // the mode go with Blend If into one step, saved back as the one `set`. + if t.items.iter().all(|(k, v)| BLENDING_DIALOG.iter().any(|b| k.is(b)) || ADVANCED_BLENDING.iter().any(|(a, _)| k.is(a)) || is_scale_only(k, v)) { + if let Some(D::Descriptor(fx)) = t.get("Lefx") { + advanced.insert(EFFECTS_SCALE.into(), photocraft_paint::tile::b64_encode(&fx.to_bytes()).into()); + } + for (key, param) in [("Opct", "opacity"), ("fillOpacity", "fillOpacity")] { + if let Some(v) = get_num(t, key) { + advanced.insert(param.into(), v.into()); + } + } + p.remove("opacity"); + p.remove("fill"); + if let Some(b) = p.remove("blend") { + advanced.insert("blend".into(), b); + } + } + } if !advanced.is_empty() { if !targeted { return unsupported; @@ -660,10 +1150,7 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { // The Layer Style dialog's OK on a layer without effects records only the effects' // scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes // the document. - let no_effect = t.items.iter().all(|(k, val)| match val { - D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")), - _ => false, - }); + let no_effect = t.items.iter().all(|(k, val)| is_scale_only(k, val)); if !v.is_empty() { steps(v) } else if no_effect { @@ -677,6 +1164,7 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; match (ref_class(r).as_deref(), target(r)) { + (Some("Path"), Target::Property(p)) if p == "WrPt" => super::steps_path::from(d), (Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d), // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), @@ -717,6 +1205,12 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)), _ => unsupported, }, + // Photoshop's Set replaces the whole gradient; one without transparency stops would + // keep the layer's own on play, and could not be written back once edited. + Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t).filter(|p| p.get("transparency").is_some()) { + Some(p) => step("gradient.fill.set", p), + None => unsupported, + }, _ => unsupported, }, (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { @@ -777,6 +1271,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); Some(match id { + "path.set" => return super::steps_path::to(p), "select.all" => sel(enm("Ordn", "Al ")), "select.deselect" => sel(enm("Ordn", "None")), "select.reselect" => sel(enm("Ordn", "Prvs")), @@ -863,21 +1358,14 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?)) } - "layer.layerStyle.blendingOptions" => { - let o = p.as_object()?; - let mut t = desc("Lyr "); - for (key, param) in ADVANCED_BLENDING { - if let Some(b) = jbool(p, param) { - t = t.with(key, D::Boolean(b)); - } - } - // Only the Advanced checkboxes export: Photoshop records the dialog's other options - // differently. - if t.items.is_empty() || t.items.len() != o.len() { + "gradient.fill.set" => { + // A named layer or dragged handles have no place in the event. + if ["layer", "from", "to"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return None; } - desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)) + desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(gradient_desc(p, true)?)) } + "layer.layerStyle.blendingOptions" => blending_options_to(p)?, "layer.smartFilter.blendingOptions" => { if p.get("layer").is_some() { return None; diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs index 599ed7cab7..7d8992e314 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_other.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -104,30 +104,54 @@ fn within(range: &RangeInclusive) -> impl Fn(&D) -> Option + '_ { /// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl `, /// `Ofst` or cell that is not a number, one that is not a whole number (export writes integers, /// so Save Actions would refuse it), or one outside the command's range (it would play clamped). -pub(super) fn custom_from(d: &Descriptor) -> Mapped { - let unsupported = Mapped::Unsupported { harmless: false }; - if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) { - return unsupported; +/// Retain the measured mapping adapted from #10631 by @hectorviov. +fn custom_params(d: &Descriptor) -> Option { + if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) || d.items.iter().filter(|(key, _)| trimmed(key) == "Mtrx").count() != 1 { + return None; } - let Some(cells) = get_list(d, "Mtrx").filter(|c| c.len() == 25) else { return unsupported }; - let Some(kernel) = cells.iter().map(within(&CUSTOM_CELL)).collect::>>() else { return unsupported }; - let (Some(scale), Some(offset)) = (setting(d, "Scl ", within(&CUSTOM_SCALE), 1.0), setting(d, "Ofst", within(&CUSTOM_OFFSET), 0.0)) else { - return unsupported; - }; + let cells = get_list(d, "Mtrx").filter(|c| c.len() == 25)?; + let kernel = cells.iter().map(within(&CUSTOM_CELL)).collect::>>()?; + let scale = setting(d, "Scl ", within(&CUSTOM_SCALE), 1.0)?; + let offset = setting(d, "Ofst", within(&CUSTOM_OFFSET), 0.0)?; let mut p = Map::new(); p.insert("kernel".into(), json!(kernel)); p.insert("scale".into(), scale.into()); p.insert("offset".into(), offset.into()); - step("filter.other.custom", Value::Object(p)) + Some(Value::Object(p)) } -/// `Scl `, `Ofst` and the 25 cells as integers; a kernel left out is the command's identity +/// Map supported settings while retaining noncanonical key padding, defaults and numeric kinds +/// for an unchanged Save Actions, as Batch retains its original descriptor. +pub(super) fn custom_from(d: &Descriptor) -> Mapped { + let Some(mut p) = custom_params(d) else { return Mapped::Unsupported { harmless: false } }; + if custom_to("filter.other.custom", &p).as_ref() != Some(d) { + p["__atnCustom"] = json!(photocraft_paint::tile::b64_encode(&d.to_bytes())); + } + step("filter.other.custom", p) +} + +/// New or edited settings write `Scl `, `Ofst` and the 25 cells as integers; unchanged imports +/// retain their descriptor representation. A kernel left out is the command's identity /// (1 in the middle). A fraction anywhere refuses the step: it would replay rounded; so does a /// value outside the command's range, which it doesn't play. pub(super) fn custom_to(_: &str, p: &Value) -> Option { - if !pixel_target(p) || !sets_only(p, &["target", "kernel", "scale", "offset"]) { + if !p.is_object() || !pixel_target(p) || !sets_only(p, &["target", "kernel", "scale", "offset", "__atnCustom"]) { return None; } + if let Some(original) = + p.get("__atnCustom").and_then(Value::as_str).and_then(photocraft_paint::tile::b64_decode).and_then(|bytes| Descriptor::from_bytes(&bytes).ok()) + { + let mut core = p.clone(); + core.as_object_mut()?.remove("__atnCustom"); + core.as_object_mut()?.remove("target"); + if let Some(mut represented) = custom_params(&original) { + super::convert::normalise(&mut represented); + super::convert::normalise(&mut core); + if represented == core { + return Some(original); + } + } + } let int = |range: &RangeInclusive, v: &Value| { let n = v.as_f64()?; (n.fract() == 0.0 && range.contains(&n)).then_some(n as i32) diff --git a/crates/engine/src/cmd/atn_cmds/steps_path.rs b/crates/engine/src/cmd/atn_cmds/steps_path.rs new file mode 100644 index 0000000000..584f5b6c9d --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_path.rs @@ -0,0 +1,212 @@ +//! Measured Insert Path: per-component operations, with even-odd subpaths in each component. +use super::descriptors::{PX, class, desc, enm, get_list, get_ref, jstr, px}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; +use photocraft_doc::{FillRule, Knot, Path, PathOp, Subpath}; +use photocraft_geom::Point; + +pub(super) const SOURCE: &str = "_atnInsertPathDescriptor"; +// The native path validator admits at most 4096 knots/subpaths. Larger wire +// records remain complete kept steps rather than secondary geometry allocations. +const MAX_PARTS: usize = 4096; +const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; + +#[cfg(test)] +#[path = "path_tests.rs"] +mod tests; + +/// Bound the retained wire form before serializing it into a second allocation. +/// Names use UTF-16; 64 bytes per measured field conservatively covers its fixed +/// key/type/scalar or list header. Nested descriptors charge their own fields. +fn keys(d: &Descriptor, required: &[&str], optional: &[&str], budget: &mut usize) -> bool { + let size = d.name.0.len().checked_mul(2).and_then(|names| d.items.len().checked_mul(64)?.checked_add(names)?.checked_add(32)); + let Some(remaining) = size.and_then(|size| budget.checked_sub(size)) else { return false }; + *budget = remaining; + d.name.0.len() <= 4096 + && d.items.iter().all(|(k, _)| required.iter().chain(optional).any(|key| k.is(key))) + && required.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) + && optional.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() <= 1) +} + +/// Require the recorded object class before interpreting its fields as geometry. +fn object<'a>(value: &'a D, class_id: &str) -> Option<&'a Descriptor> { + match value { + D::Descriptor(d) if d.class_id.is(class_id) => Some(d), + _ => None, + } +} + +/// Read a pixel point within the native coordinate range and charge its fields to the wire budget. +fn point(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pnt ")?; + if !keys(d, &["Hrzn", "Vrtc"], &[], budget) { + return None; + } + let coordinate = |key| match d.get(key)? { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && value.abs() <= f64::from(f32::MAX) => Some(*value), + _ => None, + }; + Some(Point::new(coordinate("Hrzn")?, coordinate("Vrtc")?)) +} + +/// A corner stores only its anchor. A cubic knot must supply both handles and its smooth flag. +fn knot(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pthp")?; + if !keys(d, &["Anch"], &["Fwd ", "Bwd ", "Smoo"], budget) { + return None; + } + let anchor = point(d.get("Anch")?, budget)?; + match (d.get("Fwd "), d.get("Bwd "), d.get("Smoo")) { + (None, None, None) => Some(Knot::corner(anchor.x, anchor.y)), + (Some(out), Some(input), Some(D::Boolean(smooth))) => { + Some(Knot { anchor, out_ctrl: point(out, budget)?, in_ctrl: point(input, budget)?, smooth: *smooth }) + } + _ => None, + } +} + +/// Decode a Work Path property assignment, preserving the grouping and operation of each component. +fn path_from(d: &Descriptor) -> Option { + let mut budget = MAX_SOURCE_BYTES; + if !(d.class_id.is("null") || d.class_id.is("setd")) || !keys(d, &["null", "T "], &[], &mut budget) { + return None; + } + let Some([ReferenceItem::Property { class, key }]) = get_ref(d, "null") else { return None }; + if !class.class_id.is("Path") || class.name.0.len() > 4096 || !key.is("WrPt") { + return None; + } + budget = budget.checked_sub(class.name.0.len().checked_mul(2)?.checked_add(64)?)?; + let components = get_list(d, "T ")?; + if components.is_empty() || components.len() > MAX_PARTS { + return None; + } + let mut path = Path { fill_rule: FillRule::EvenOdd, ..Default::default() }; + let mut count = 0usize; + for value in components { + let component = object(value, "PaCm")?; + if !keys(component, &["shapeOperation", "SbpL"], &[], &mut budget) { + return None; + } + let op = match component.get("shapeOperation")? { + D::Enumerated { type_id, value } if type_id.is("shapeOperation") => { + if value.is("Add ") { + PathOp::Combine + } else if value.is("Sbtr") { + PathOp::Subtract + } else if value.is("xor") { + PathOp::Exclude + } else { + return None; + } + } + _ => return None, + }; + if path.subpaths.is_empty() && op != PathOp::Combine { + return None; + } + let subpaths = get_list(component, "SbpL")?; + if subpaths.is_empty() || path.subpaths.len().checked_add(subpaths.len())? > MAX_PARTS { + return None; + } + for (i, value) in subpaths.iter().enumerate() { + let sub = object(value, "Sbpl")?; + if !keys(sub, &["Clsp", "Pts "], &[], &mut budget) { + return None; + } + let D::Boolean(closed) = sub.get("Clsp")? else { return None }; + let points = get_list(sub, "Pts ")?; + count = count.checked_add(points.len())?; + if count > MAX_PARTS { + return None; + } + let knots = points.iter().map(|value| knot(value, &mut budget)).collect::>>()?; + path.subpaths.push(Subpath { closed: *closed, knots, op: if i == 0 { op } else { PathOp::Join } }); + } + } + photocraft_vector::flatten::validate_path(&path).ok()?; + Some(path) +} + +/// Decode only the measured Work Path form; unsupported data stays a complete kept step. +pub(super) fn from(d: &Descriptor) -> Mapped { + let Some(path) = path_from(d) else { return Mapped::Unsupported { harmless: false } }; + let raw = d.to_bytes(); + if raw.len() > MAX_SOURCE_BYTES { + return Mapped::Unsupported { harmless: false }; + } + step("path.set", json!({"name":"work", "path":crate::vector_cmds::path_json(&path), SOURCE:photocraft_paint::tile::b64_encode(&raw)})) +} + +/// Write a point using pixel units for both coordinates. +fn point_to(p: Point) -> D { + D::Descriptor(desc("Pnt ").with("Hrzn", px(p.x)).with("Vrtc", px(p.y))) +} + +/// Emit the compact corner form only when the handles coincide and the knot has no smooth flag. +fn knot_to(k: &Knot) -> D { + let mut d = desc("Pthp").with("Anch", point_to(k.anchor)); + if k.in_ctrl != k.anchor || k.out_ctrl != k.anchor || k.smooth { + d = d.with("Fwd ", point_to(k.out_ctrl)).with("Bwd ", point_to(k.in_ctrl)).with("Smoo", D::Boolean(k.smooth)); + } + D::Descriptor(d) +} + +/// Export representable native geometry or an unchanged validated source descriptor. +/// Nonzero winding, inversion and unmeasured operations cannot silently become even-odd. +pub(super) fn to(p: &Value) -> Option { + if jstr(p, "name") != Some("work") || p.as_object()?.keys().any(|k| !matches!(k.as_str(), "name" | "path" | SOURCE)) { + return None; + } + let value = p.get("path")?; + let subpaths = value.get("subpaths")?.as_array()?; + if subpaths.is_empty() + || subpaths.len() > MAX_PARTS + || subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.get("knots")?.as_array()?.len()))? > MAX_PARTS + { + return None; + } + let path = crate::vector_cmds::parse_path(value).ok()?; + photocraft_vector::flatten::validate_path(&path).ok()?; + let mut canonical = crate::vector_cmds::path_json(&path); + let mut supplied = value.clone(); + super::convert::normalise(&mut canonical); + super::convert::normalise(&mut supplied); + if path.inverted || path.fill_rule != FillRule::EvenOdd || canonical != supplied { + return None; + } + if let Some(source) = p.get(SOURCE) { + let text = source.as_str()?; + if text.len() > MAX_SOURCE_BYTES.div_ceil(3) * 4 { + return None; + } + let raw = photocraft_paint::tile::b64_decode(text)?; + if raw.len() > MAX_SOURCE_BYTES { + return None; + } + let original = Descriptor::from_bytes(&raw).ok()?; + return (path_from(&original)? == path).then_some(original); + } + let mut components = Vec::new(); + for range in path.components() { + let first = path.subpaths.get(range.start)?; + let op = match first.op { + PathOp::Combine => "Add ", + PathOp::Subtract => "Sbtr", + PathOp::Exclude => "xor", + _ => return None, + }; + if components.is_empty() && first.op != PathOp::Combine { + return None; + } + let subs = path + .subpaths + .get(range)? + .iter() + .map(|s| D::Descriptor(desc("Sbpl").with("Clsp", D::Boolean(s.closed)).with("Pts ", D::List(s.knots.iter().map(knot_to).collect())))) + .collect(); + components.push(D::Descriptor(desc("PaCm").with("shapeOperation", enm("shapeOperation", op)).with("SbpL", D::List(subs)))); + } + Some( + desc("setd").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])).with("T ", D::List(components)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_perspective_warp.rs b/crates/engine/src/cmd/atn_cmds/steps_perspective_warp.rs new file mode 100644 index 0000000000..87f789ef91 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_perspective_warp.rs @@ -0,0 +1,171 @@ +//! Recorded Perspective Warp layout and warp planes (#10710). + +use std::collections::BTreeSet; + +use photocraft_algo::perspective::{Plane, linked_corners}; +use photocraft_algo::transform::{WARP_REACH, convex_quad}; +use photocraft_io::smart_map::{MAX_PERSPECTIVE_WARP_PLANES as MAX_PLANES, perspective_warp_geometry, perspective_warp_indexed_geometry}; + +use super::convert::normalise; +use super::descriptors::{desc, enm, get_obj, get_ref, layer_ref, layer_target, px, ref_class}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, ReferenceItem, Value, json}; +use crate::distort_cmds::PERSPECTIVE; + +const EVENT: &str = "perspectiveWarpTransform"; +const SOURCE: &str = "__atnPerspectiveWarp"; +const RECT: &str = "__atnPerspectiveWarpRect"; +const MAX_DESCRIPTOR_BYTES: usize = 256 * 1024; + +/// Allows exactly the measured keys, once each. The preserved descriptor retains their order. +fn exact_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.name.0.len() <= 1024 && d.items.len() == keys.len() && keys.iter().all(|key| d.items.iter().filter(|(id, _)| id.is(key)).count() == 1) +} + +fn coordinate(value: f64) -> bool { + value.abs() <= f64::from(WARP_REACH) +} + +fn rect_valid([left, top, right, bottom]: [f64; 4]) -> bool { + [left, top, right, bottom].into_iter().all(coordinate) && left < right && top < bottom +} + +fn reference_rect(d: &Descriptor) -> Option<[f64; 4]> { + let r = get_obj(d, "referenceRect")?; + if !r.class_id.is("Rctn") || !exact_keys(r, &["Top ", "Left", "Btom", "Rght"]) { + return None; + } + let number = |key| match r.get(key)? { + D::UnitFloat { unit: [b'#', b'P', b'x', b'l'], value } => Some(*value), + _ => None, + }; + let r = [number("Left")?, number("Top ")?, number("Rght")?, number("Btom")?]; + rect_valid(r).then_some(r) +} + +fn objects<'a>(d: &'a Descriptor, key: &str, class: &str, keys: &[&str], max: usize) -> Option<&'a [D]> { + let D::List(items) = d.get(key)? else { return None }; + (!items.is_empty() && items.len() <= max && items.iter().all(|item| matches!(item, D::Descriptor(d) if d.class_id.is(class) && exact_keys(d, keys)))) + .then_some(items) +} + +/// Each recorded index must link exactly the corners the native command would link. Edges +/// shared by two faces must have opposite winding, since the native map has one neighbour per edge. +fn same_topology(planes: &[Plane], indices: &[[usize; 4]], vertices: usize) -> Option<()> { + let at = |plane: usize, corner: usize| indices.get(plane)?.get(corner); + let mut linked = BTreeSet::new(); + for group in linked_corners(planes) { + let &(plane, corner) = group.first()?; + let index = at(plane, corner)?; + if !linked.insert(*index) || !group.iter().all(|&(p, c)| at(p, c) == Some(index)) { + return None; + } + } + let mut edges = BTreeSet::new(); + let distinct = indices.iter().all(|&[a, b, c, d]| [(a, b), (b, c), (c, d), (d, a)].into_iter().all(|edge| edges.insert(edge))); + (linked.len() == vertices && distinct).then_some(()) +} + +/// Decodes the recorded target and its document-space planes, retaining the reference rectangle +/// for export. Unmodelled references and geometry stay as kept steps. +fn parameters(d: &Descriptor) -> Option { + if !(d.class_id.is(EVENT) || d.class_id.is("null")) || !exact_keys(d, &["null", "Md ", "referenceRect", "vertices", "warpedVertices", "quads"]) { + return None; + } + if !matches!(d.get("Md ")?, D::Enumerated { type_id, value } if type_id.is("perspectiveWarpMode") && value.is("warp")) { + return None; + } + let target = get_ref(d, "null")?; + if target.len() != 1 || ref_class(target).as_deref() != Some("Lyr") { + return None; + } + match target.first()? { + ReferenceItem::Enumerated { type_id, value, .. } if type_id.is("Ordn") && value.is("Trgt") => {} + ReferenceItem::Name { name, .. } if name.0.len() <= 1024 && String::from_utf16(&name.0).is_ok() && !name.to_string_lossy().is_empty() => {} + ReferenceItem::Property { key, .. } if key.is("Bckg") => {} + _ => return None, + } + let mut p = layer_target(target)?; + let rect = reference_rect(d)?; + let src = objects(d, "vertices", "Pnt ", &["Hrzn", "Vrtc"], 4 * MAX_PLANES)?; + objects(d, "warpedVertices", "Pnt ", &["Hrzn", "Vrtc"], 4 * MAX_PLANES)?; + objects(d, "quads", "perspectiveWarpQuad", &["indices"], MAX_PLANES)?; + let (planes, indices) = perspective_warp_indexed_geometry(d)?; + if !planes.iter().all(|p| p.src.iter().chain(&p.dst).flatten().copied().all(coordinate) && convex_quad(&p.src) && convex_quad(&p.dst)) { + return None; + } + same_topology(&planes, &indices, src.len())?; + p.insert("planes".into(), json!(planes)); + p.insert("target".into(), json!("pixels")); + p.insert(RECT.into(), json!(rect)); + Some(Value::Object(p)) +} + +/// Keep the exact recorded descriptor after decoding its executable settings. +pub(super) fn from(d: &Descriptor) -> Mapped { + let mapped = || { + let mut p = parameters(d)?; + let bytes = d.to_bytes(); + if bytes.len() > MAX_DESCRIPTOR_BYTES { + return None; + } + p[SOURCE] = json!(crate::paint::tile::b64_encode(&bytes)); + Some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step(PERSPECTIVE, p)) +} + +/// Emits the measured field order when the caller supplies the original reference rectangle. +fn canonical(p: &Value) -> Option { + let [left, top, right, bottom] = serde_json::from_value(p.get(RECT)?.clone()).ok()?; + if !rect_valid([left, top, right, bottom]) { + return None; + } + let planes = p.get("planes")?.as_array().filter(|planes| (1..=MAX_PLANES).contains(&planes.len()))?; + let planes: Vec = serde_json::from_value(Value::Array(planes.clone())).ok()?; + let rect = desc("Rctn").with("Top ", px(top)).with("Left", px(left)).with("Btom", px(bottom)).with("Rght", px(right)); + let (src, dst, quads) = perspective_warp_geometry(&planes); + Some( + desc(EVENT) + .with("null", layer_ref(p)?) + .with("Md ", enm("perspectiveWarpMode", "warp")) + .with("referenceRect", D::Descriptor(rect)) + .with("vertices", src) + .with("warpedVertices", dst) + .with("quads", quads), + ) +} + +/// Reuses source bytes only while they still describe the same target, rectangle and planes. +/// A changed recorded mesh cannot silently replace its indexed topology. +pub(super) fn to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !p.as_object()?.keys().all(|k| ["layer", "planes", "target", "interpolation", RECT, SOURCE].contains(&k.as_str())) { + return None; + } + if p.get("interpolation").is_some_and(|v| v.as_str() != Some("bicubic")) { + return None; + } + let descriptor = if let Some(source) = p.get(SOURCE) { + let text = source.as_str().filter(|v| v.len() <= MAX_DESCRIPTOR_BYTES * 2)?; + let bytes = crate::paint::tile::b64_decode(text)?; + if bytes.len() > MAX_DESCRIPTOR_BYTES { + return None; + } + Descriptor::from_bytes(&bytes).ok()? + } else { + canonical(p)? + }; + let mut expected = parameters(&descriptor)?; + let mut actual = p.clone(); + let m = actual.as_object_mut()?; + m.remove(SOURCE); + m.remove("interpolation"); + if m.get("layer").is_some_and(Value::is_null) { + m.remove("layer"); + } + m.insert("target".into(), json!("pixels")); + normalise(&mut actual); + normalise(&mut expected); + (actual == expected).then_some(descriptor) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs index b4911b6207..7c7a1a1850 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_render.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -55,14 +55,19 @@ fn new_seed() -> i32 { std::collections::hash_map::RandomState::new().hash_one(0u8) as i32 } -/// Fibers keeps raw variance/strength until their dialog limits are measured. +/// Apply the measured Fibers limits from #10620 while retaining main's one-time missing seed. pub(super) fn fibers_from(d: &Descriptor) -> Mapped { let mapped = || { if !keys(d, &["Vrnc", "Strg", "RndS"]) { return None; } let seed = if d.get("RndS").is_none() { new_seed() } else { integer(d, "RndS", 0)? }; - Some(json!({"variance": integer(d, "Vrnc", 16)?, "strength": integer(d, "Strg", 4)?, "seed": seed})) + let p = json!({"variance": integer(d, "Vrnc", 16)?, "strength": integer(d, "Strg", 4)?, "seed": seed}); + let mut represented = fibers_to("filter.render.fibers", &p)?; + // Missing fields legitimately acquire defaults (and one stable seed). Present + // fields and outer metadata must still have an exact native representation. + represented.items.retain(|(key, _)| d.items.iter().any(|(recorded, _)| recorded == key)); + (represented == *d).then_some(p) }; mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("filter.render.fibers", p)) } @@ -76,12 +81,12 @@ pub(super) fn fibers_to(_: &str, p: &Value) -> Option { Some(n) => u32::try_from(n).ok()? as i32, None => param_integer(p, "seed", 0)?, }; - Some( - desc("null") - .with("Vrnc", D::Integer(param_integer(p, "variance", 16)?)) - .with("Strg", D::Integer(param_integer(p, "strength", 4)?)) - .with("RndS", D::Integer(seed)), - ) + let variance = param_integer(p, "variance", 16)?; + let strength = param_integer(p, "strength", 4)?; + if !(1..=64).contains(&variance) || !(1..=64).contains(&strength) { + return None; + } + Some(desc("null").with("Vrnc", D::Integer(variance)).with("Strg", D::Integer(strength)).with("RndS", D::Integer(seed))) } /// Read the point's doubles without changing the recorded descriptor precision. diff --git a/crates/engine/src/cmd/atn_cmds/steps_shape_blur.rs b/crates/engine/src/cmd/atn_cmds/steps_shape_blur.rs new file mode 100644 index 0000000000..29f0e548d9 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_shape_blur.rs @@ -0,0 +1,47 @@ +//! Recorded custom shapes select an installed asset by its opaque ID, never by a guessed name. + +use super::descriptors::{PX, desc, get_text, px, text}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Value, json}; + +/// Admit the measured custom-shape form; the native command resolves its installed ID at playback. +pub(super) fn from(d: &Descriptor) -> Mapped { + let mapped = || -> Option { + let D::UnitFloat { unit, value } = d.get("Rds ")? else { return None }; + if *unit != PX { + return None; + } + let D::Descriptor(shape) = d.get("customShape")? else { return None }; + if !shape.class_id.is("customShape") { + return None; + } + let mut p = json!({"radius": value, "shapeName": get_text(shape, "Nm ")?, "shapeId": get_text(shape, "Idnt")?}); + if let Some(origin) = d.get("keyOriginType") { + let D::Integer(9) = origin else { return None }; + p["shapeOriginType"] = json!(9); + } + // Reconstructing also rejects duplicate fields, unknown settings and nested metadata + // that this command could not write back without loss, including the outer name and class. + (to("filter.blur.shapeBlur", &p)? == *d).then_some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("filter.blur.shapeBlur", p)) +} + +/// Refuse native geometry or target options that the recorded installed-shape reference cannot carry. +pub(super) fn to(_: &str, p: &Value) -> Option { + if p.as_object()?.keys().any(|key| !["radius", "shapeName", "shapeId", "shapeOriginType"].contains(&key.as_str())) { + return None; + } + let radius = p.get("radius")?.as_f64().filter(|r| r.is_finite() && (5.0..=1000.0).contains(r))?; + let name = p.get("shapeName")?.as_str().filter(|s| !s.is_empty())?; + let id = p.get("shapeId")?.as_str().filter(|s| !s.is_empty())?; + let shape = desc("customShape").with("Nm ", text(name)).with("Idnt", text(id)); + let mut d = desc("null").with("Rds ", px(radius)).with("customShape", D::Descriptor(shape)); + if let Some(origin) = p.get("shapeOriginType") { + if origin.as_i64() != Some(9) { + return None; + } + d = d.with("keyOriginType", D::Integer(9)); + } + Some(d) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_stack.rs b/crates/engine/src/cmd/atn_cmds/steps_stack.rs new file mode 100644 index 0000000000..9fcb9a6f9b --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_stack.rs @@ -0,0 +1,128 @@ +//! Recorded Smart Object Stack Modes use the existing native mode commands. + +use photocraft_doc::StackMode; + +use super::descriptors::{class, desc, jstr, text}; +use super::fields::{Mapped, step}; +use super::{AtnItem, D, Descriptor, Value, json}; + +const PREFIX: &str = "layer.smartObjects.stackMode."; +const SOURCE: &str = "_atnStackDescriptor"; +pub(super) const NONE: &str = "layer.smartObjects.stackMode.none"; + +pub(super) const MODE_IDS: &[&str] = &[ + "layer.smartObjects.stackMode.range", + "layer.smartObjects.stackMode.entropy", + "layer.smartObjects.stackMode.kurtosis", + "layer.smartObjects.stackMode.maximum", + "layer.smartObjects.stackMode.median", + "layer.smartObjects.stackMode.minimum", + "layer.smartObjects.stackMode.mean", + "layer.smartObjects.stackMode.skewness", + "layer.smartObjects.stackMode.standardDeviation", + "layer.smartObjects.stackMode.summation", + "layer.smartObjects.stackMode.variance", +]; + +const MODES: &[(&str, StackMode)] = &[ + ("rang", StackMode::Range), + ("entr", StackMode::Entropy), + ("kurt", StackMode::Kurtosis), + ("maxx", StackMode::Maximum), + ("medn", StackMode::Median), + ("minn", StackMode::Minimum), + ("avrg", StackMode::Mean), + ("skew", StackMode::Skewness), + ("stdv", StackMode::StandardDeviation), + ("summ", StackMode::Summation), + ("vari", StackMode::Variance), +]; + +/// The plug-in class selects the mode; its localized display name does not. +fn recorded_mode(d: &Descriptor) -> Option<(&'static str, StackMode)> { + if d.items.len() != 2 { + return None; + } + let (Some(D::Class(plugin)), Some(D::Text(_))) = (d.get("imageStackPlugin"), d.get("Nm ")) else { return None }; + MODES.iter().copied().find(|(code, _)| plugin.class_id.is(code)) +} + +fn descriptor(code: &str, mode: StackMode) -> Descriptor { + desc("null").with("imageStackPlugin", D::Class(class(code))).with("Nm ", text(mode.label())) +} + +/// Keep descriptor names, key order, class encodings and original UTF-16 text on unchanged saves. +fn params(d: &Descriptor, canonical: &Descriptor) -> Value { + let mut p = json!({}); + if d != canonical { + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + p +} + +fn original(p: &Value) -> Option { + Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok() +} + +/// The descriptorless form reaches the mapper as a default empty `null` descriptor. +fn is_none_descriptor(d: &Descriptor) -> bool { + d.items.is_empty() && d.class_id.is("null") +} + +/// Unknown mode classes, types, duplicate keys and extra settings stay complete kept steps. +pub(super) fn mode_from(d: &Descriptor) -> Mapped { + let Some((code, mode)) = recorded_mode(d) else { return Mapped::Unsupported { harmless: false } }; + step(&format!("{PREFIX}{}", mode.id()), params(d, &descriptor(code, mode))) +} + +/// The measured form targets the active Smart Object; explicit native targets refuse export. +pub(super) fn mode_to(id: &str, p: &Value) -> Option { + if p.as_object()?.keys().any(|key| key != SOURCE) { + return None; + } + let suffix = id.strip_prefix(PREFIX)?; + let (code, mode) = MODES.iter().copied().find(|(_, mode)| suffix == mode.id())?; + let canonical = descriptor(code, mode); + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = original(p)?; + if is_none_descriptor(&source) { + return Some(canonical); + } + let (_, recorded) = recorded_mode(&source)?; + Some(if recorded == mode { source } else { canonical }) +} + +/// None has no recorded settings and removes the native Smart Object stack mode. +pub(super) fn none_from(d: &Descriptor) -> Mapped { + if is_none_descriptor(d) { step(NONE, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +pub(super) fn none_to(_: &str, p: &Value) -> Option { + if p.as_object()?.keys().any(|key| key != SOURCE) { + return None; + } + if p.get(SOURCE).is_none() { + return Some(desc("null")); + } + let source = original(p)?; + if is_none_descriptor(&source) { + return Some(source); + } + recorded_mode(&source).map(|_| desc("null")) +} + +/// Import otherwise presents an absent descriptor as an empty one. Retain the distinction for None. +pub(super) fn remember_item(item: &AtnItem, steps: &mut [(String, Value)]) { + if let [(id, Value::Object(p))] = steps + && id.as_str() == NONE + && let Some(d) = &item.descriptor + { + p.insert(SOURCE.into(), photocraft_paint::tile::b64_encode(&d.to_bytes()).into()); + } +} + +pub(super) fn without_descriptor(id: &str, p: &Value) -> bool { + id == NONE && p.get(SOURCE).is_none() +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_type_path.rs b/crates/engine/src/cmd/atn_cmds/steps_type_path.rs new file mode 100644 index 0000000000..ec99bfd2e9 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_type_path.rs @@ -0,0 +1,48 @@ +//! The measured Make Path from the target text layer, without guessing named sources. +use super::descriptors::{class_ref, desc, get_ref, jstr, target_ref}; +use super::fields::{Mapped, step}; +use super::{Descriptor, ReferenceItem, Value, json}; + +pub(super) const COMMAND: &str = "type.createWorkPath"; +const SOURCE: &str = "_atnTypePathDescriptor"; + +/// Canonical Make Path descriptor for the active text layer; it carries no path geometry. +fn descriptor() -> Descriptor { + desc("null").with("null", class_ref("Path")).with("From", target_ref("TxLr")) +} + +/// Unknown keys, duplicate keys and different reference chains stay complete kept steps. +fn supported(d: &Descriptor) -> bool { + if d.items.len() != 2 || ["null", "From"].iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() != 1) { + return false; + } + matches!(get_ref(d, "null"), Some([ReferenceItem::Class(path)]) if path.class_id.is("Path")) + && matches!(get_ref(d, "From"), Some([ReferenceItem::Enumerated { class, type_id, value }]) + if class.class_id.is("TxLr") && type_id.is("Ordn") && value.is("Trgt")) +} + +/// Map only the measured active-text reference, retaining noncanonical wire identity for export. +pub(super) fn from(d: &Descriptor) -> Mapped { + if !supported(d) { + return Mapped::Unsupported { harmless: false }; + } + let mut params = json!({}); + // Descriptor/class names, key order and ID encodings are wire identity, not native options. + if *d != descriptor() { + params[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(COMMAND, params) +} + +/// Export implicit native playback or a validated original descriptor; explicit targets cannot +/// be represented by this measured form and must use the caller's unsupported-step handling. +pub(super) fn to(p: &Value) -> Option { + if p.as_object()?.keys().any(|key| key != SOURCE) { + return None; + } + if p.get(SOURCE).is_none() { + return Some(descriptor()); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + supported(&source).then_some(source) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_video.rs b/crates/engine/src/cmd/atn_cmds/steps_video.rs new file mode 100644 index 0000000000..af3158a978 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_video.rs @@ -0,0 +1,41 @@ +//! The three measured, option-free edits of the active video layer's current frame. +use super::{ + AtnItem, Descriptor, Value, + fields::{Mapped, step}, + json, +}; + +const NO_DESCRIPTOR: &str = "_atnVideoFrameNoDescriptor"; + +/// These events edit video footage, separately from document frame animation. +fn frame_command(id: &str) -> bool { + matches!(id, "layer.videoLayers.insertBlankFrame" | "layer.videoLayers.duplicateFrame" | "layer.videoLayers.deleteFrame") +} + +/// Only the measured empty descriptor is executable; extra settings remain kept. +pub(super) fn from(d: &Descriptor, id: &str) -> Mapped { + if *d == Descriptor::default() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Import's absent-descriptor placeholder must not become a descriptor on save. +pub(super) fn remember(item: &AtnItem, steps: &mut [(String, Value)]) { + if item.descriptor.is_none() { + for (id, params) in steps { + if frame_command(id) + && let Some(params) = params.as_object_mut() + { + params.insert(NO_DESCRIPTOR.into(), Value::Bool(true)); + } + } + } +} + +/// Refuse native options and malformed transport data that have no wire field. +pub(super) fn to(p: &Value) -> Option { + p.as_object()?.iter().all(|(key, value)| key == NO_DESCRIPTOR && value == &Value::Bool(true)).then(Descriptor::default) +} + +/// Called only after `to` has validated the complete native parameter object. +pub(super) fn omitted(id: &str, p: &Value) -> bool { + frame_command(id) && p.get(NO_DESCRIPTOR) == Some(&Value::Bool(true)) +} diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 976940ef43..0e8ca94d79 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -360,7 +360,7 @@ fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { } } -/// Imported actions produce the same pixels and Undo history as the specified native commands. +/// Imported actions match native pixels with either grouped or per-step Undo and Redo. #[test] fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() { let items: Vec<_> = fixtures() @@ -368,36 +368,50 @@ fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() { .map(|f| code_item(f.code, f.changed)) .chain(["FndE", "Slrz", "Fct ", "Frgm"].map(|code| code_item(code, desc("null")))) .collect(); - for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] { - let mut imported = load_items(items.clone()); - imported.actions.list[0].disabled.insert(10); - imported.actions.list[0].dialogs.insert(0); - let steps = imported.actions.list[0].steps.clone(); - let mut direct = Session::new(); - for s in [&mut imported, &mut direct] { - prepare_pattern(s, depth, mode); - } - let before = pixels(&imported); - let history = imported.active().unwrap().history.past_len(); - let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); - assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}"); - assert_eq!(result["ran"], 11); - for (index, (id, params)) in steps.into_iter().enumerate() { - if index != 10 { - direct.execute(&id, params).unwrap(); + for single_state in [true, false] { + for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] { + let mut imported = load_items(items.clone()); + imported.execute("actions.setHistory", json!({"singleState": single_state})).unwrap(); + imported.actions.list[0].disabled.insert(10); + imported.actions.list[0].dialogs.insert(0); + let steps = imported.actions.list[0].steps.clone(); + let mut direct = Session::new(); + for s in [&mut imported, &mut direct] { + prepare_pattern(s, depth, mode); } + let before = pixels(&imported); + let before_doc = imported.active().unwrap().doc.clone(); + let history = imported.active().unwrap().history.past_len(); + let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}"); + assert_eq!(result["ran"], 11); + for (index, (id, params)) in steps.into_iter().enumerate() { + if index != 10 { + direct.execute(&id, params).unwrap(); + } + } + assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}"); + let after_doc = imported.active().unwrap().doc.clone(); + if single_state { + assert_eq!(imported.active().unwrap().history.past_len(), history + 1); + } else { + assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len()); + } + let added = imported.active().unwrap().history.past_len() - history; + assert!(added > 0); + for _ in 0..added { + imported.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(pixels(&imported), before, "{depth}/{mode} undo"); + assert_eq!(imported.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{depth}/{mode} undo"); + for _ in 0..added { + imported.execute("edit.redo", json!({})).unwrap(); + } + assert_eq!(imported.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{depth}/{mode} redo"); + let saved = saved_items(&mut imported); + assert!(!saved[10].enabled); + assert!(saved[0].with_dialog); } - assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}"); - assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len()); - let added = imported.active().unwrap().history.past_len() - history; - assert!(added > 0); - for _ in 0..added { - imported.execute("edit.undo", json!({})).unwrap(); - } - assert_eq!(pixels(&imported), before, "{depth}/{mode} undo"); - let saved = saved_items(&mut imported); - assert!(!saved[10].enabled); - assert!(saved[0].with_dialog); } } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f468f72432..27b6f6500b 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3,6 +3,17 @@ use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::json; +/// A wire key has one mapper; distinct wire aliases may still name the same command. +#[test] +fn event_wire_keys_are_unique() { + let mut codes = std::collections::BTreeSet::new(); + let mut names = std::collections::BTreeSet::new(); + for event in EVENTS { + assert!(event.code.is_empty() || codes.insert(event.code), "duplicate ATN code {}", event.code); + assert!(event.name.is_empty() || names.insert(event.name), "duplicate ATN name {}", event.name); + } +} + #[test] fn blend_modes_accept_photoshop_string_ids_and_export_canonical_ids() { for (string_id, char_id, label) in [ @@ -69,6 +80,168 @@ fn b64(bytes: &[u8]) -> String { photocraft_paint::tile::b64_encode(bytes) } +fn color_lookup_case() -> Value { + let bytes = b"LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + let d = desc("null") + .with("lookupType", enm("colorLookupType", "3DLUT")) + .with("Nm ", text("identity.cube")) + .with("Dthr", D::Boolean(false)) + .with("profile", D::RawData(Vec::new())) + .with("LUTFormat", enm("LUTFormatType", "LUTFormatCUBE")) + .with("LUT3DFileData", D::RawData(Vec::new())) + .with("LUT3DFileName", text("identity.cube")); + json!({"data":b64(bytes),"dataEncoding":"base64","lutFormat":"cube","fileName":"identity.cube","dither":false,"__atnColorLookup":b64(&d.to_bytes())}) +} + +/// Keep the starting counter nested in naming rules, as recorded by the Batch dialog. +fn batch_descriptor(component: &str) -> Descriptor { + let rules = desc("fileNamingRules") + .with("fileNamingComponents", D::List(vec![D::Descriptor(desc("fileNamingComponent").with("component", enm("fileNamingComponent", component)))])) + .with("Strt", D::Integer(7)) + .with("Mcnt", D::Boolean(true)) + .with("Win ", D::Boolean(false)) + .with("unix", D::Boolean(true)); + desc("Btch") + .with("null", path_value("/input")) + .with("Fldr", D::Boolean(false)) + .with( + "Usng", + D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Child") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("S") }, + ]), + ) + .with("T ", path_value("/output")) + .with("Ovrd", D::Boolean(false)) + .with("fileNamingRules", D::Descriptor(rules)) +} + +/// Preserve the recorded template in the generic registry round-trip witness. +fn batch_case() -> Value { + json!({"input":"/input","output":"/output","action":"Child","set":"S","flatOutput":true,"includeSubfolders":false,"overrideSaveAs":false,"stopOnError":true,"includeNonImages":true, + "namingComponents":[{"component":"serial3"}],"startingSerial":7,"compatibility":{"macintosh":true,"windows":false,"unix":true}, + "__atnBatch":b64(&batch_descriptor("threeDigit").to_bytes())}) +} + +/// Save and Close replaces the folder path while retaining its recorded naming metadata. +fn batch_save_and_close_case() -> Value { + let mut d = batch_descriptor("threeDigit"); + d.items.retain(|(key, _)| !key.is("T ")); + let d = d.with("SvAn", D::Boolean(true)); + let mut p = batch_case(); + p.as_object_mut().unwrap().remove("output"); + p.as_object_mut().unwrap().remove("flatOutput"); + p["destination"] = json!("saveAndClose"); + p["__atnBatch"] = json!(b64(&d.to_bytes())); + p +} + +/// Destination changes retain exact descriptors and reject ambiguous output/override settings. +#[test] +fn batch_save_and_close_round_trips_without_folder_defaults() { + let p = batch_save_and_close_case(); + let d = super::steps_batch::batch_to("file.automate.batch", &p).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(d.clone()))])])).unwrap(); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].1, p); + let (out, skipped) = export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d.clone())); + for bad in [d.clone().with("T ", path_value("/other")), { + let mut bad = d.clone(); + bad.items.retain(|(key, _)| !key.is("Ovrd")); + bad.with("Ovrd", D::Boolean(true)) + }] { + let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(bad.clone()))])])).unwrap(); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, PHOTOSHOP_STEP); + let (out, _) = export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(bad)); + } +} + +/// Each documented component and portability flag must survive native conversion and save. +#[test] +fn batch_all_documented_components_and_compatibility_flags_round_trip() { + for &(wire, native) in crate::file_cmds::BATCH_NAMING_COMPONENTS { + let d = batch_descriptor(wire); + let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(d.clone()))])])).unwrap(); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty(), "{wire}"); + let p = &imported.actions[0].steps[0].1; + assert_eq!(p["namingComponents"], json!([{"component":native}])); + assert_eq!(p["startingSerial"], 7); + assert_eq!(p["stopOnError"], true); + assert_eq!(p["includeNonImages"], true); + assert_eq!(p["compatibility"], json!({"macintosh":true,"windows":false,"unix":true})); + let (out, skipped) = export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + } +} + +/// Batch's Open binding round-trips, but richer Open descriptors must remain whole. +#[test] +fn batch_override_open_and_unrepresented_open_settings_round_trip() { + for enabled in [false, true] { + let mut p = batch_case(); + p.as_object_mut().unwrap().remove("__atnBatch"); + p["overrideOpen"] = json!(enabled); + p["suppressWarnings"] = json!(enabled); + p["suppressOpenOptions"] = json!(enabled); + let a = Action::new("Driver", vec![("file.automate.batch".into(), p)]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let loaded = import(&bytes).unwrap(); + assert_eq!(loaded.actions[0].steps[0].1["overrideOpen"], enabled); + assert_eq!(loaded.actions[0].steps[0].1["suppressWarnings"], enabled); + assert_eq!(loaded.actions[0].steps[0].1["suppressOpenOptions"], enabled); + let (again, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&again).unwrap().actions[0].items[0].descriptor, atn::parse(&bytes).unwrap().actions[0].items[0].descriptor); + } + let rich = desc("null").with("null", path_value("/recorded.png")).with("unrepresentedOpenSetting", D::Boolean(true)); + let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Opn ", "Open", Some(rich.clone()))])])).unwrap(); + let loaded = import(&bytes).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let (again, _) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&again).unwrap().actions[0].items[0].descriptor, Some(rich)); + assert!(super::steps_doc::open_to("file.openAs", &json!({"path":"/recorded.png","as":"png"})).is_none()); +} + +/// Unsupported modes and misplaced fields must not lose data through a partial conversion. +#[test] +fn batch_unknown_components_wrong_start_placement_and_unsupported_modes_stay_whole() { + let mut cases = vec![batch_descriptor("unknownComponent")]; + for (key, value) in [("Strt", D::Integer(3)), ("OvrO", D::Integer(1)), ("SvAn", D::Boolean(true)), ("Log ", path_value("/log"))] { + cases.push(batch_descriptor("Nm ").with(key, value)); + } + let mut odd = batch_descriptor("Nm "); + odd.items.push((Id::new("Fldr"), D::Boolean(true))); // Duplicate keys cannot disappear on save. + cases.push(odd); + for d in cases { + let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(d.clone()))])])).unwrap(); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, PHOTOSHOP_STEP); + let (out, _) = export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + } + let mut p = batch_case(); + p["target"] = json!("mask"); + assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); + for key in ["stopOnError", "includeNonImages"] { + for value in [Value::Null, json!(false), json!("yes")] { + let mut p = batch_case(); + p[key] = value; + assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); + } + let mut p = batch_case(); + p.as_object_mut().unwrap().remove(key); + assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); + } +} + /// A set with a mapped filter, an adjustment layer, selection steps, a layer by name, a Stop, /// a Play, a tool selection (no document effect) and an event PhotoCraft has no command for. fn sample() -> AtnFile { @@ -427,10 +600,11 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}"); } -/// A setting Photoshop's event has no field for is dropped by export, and the step then plays back +/// A setting the exported event has no field for is dropped by export, and the step then plays back /// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and -/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. So is -/// an adjustment edit that names its layer: the event edits the active one (#8532). +/// Maximum's `preserve`, Equalize's `selectedAreaOnly`. Such a step is refused, as one with no +/// equivalent event already is. So is an adjustment edit that names its layer: the event edits the +/// active one (#8532). #[test] fn a_setting_the_event_cannot_hold_is_refused() { for (id, params) in [ @@ -438,6 +612,10 @@ fn a_setting_the_event_cannot_hold_is_refused() { ("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})), ("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": false})), + // Equalize reads `selectedAreaOnly` as a boolean only and refuses the string and null. + ("image.adjustments.equalize", json!({"selectedAreaOnly": "true"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": null})), ("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})), ("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})), ] { @@ -472,6 +650,8 @@ fn settings_at_the_value_the_event_plays_still_export() { // review). ("filter.other.minimum", json!({"radius": 4, "preserve": "Squareness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "Roundness"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": true})), + ("image.adjustments.equalize", json!({})), ] { let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())])); @@ -485,6 +665,16 @@ fn settings_at_the_value_the_event_plays_still_export() { /// Each mapped command retains its parameters through its corresponding ATN descriptor. fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ + ( + "edit.perspectiveWarp", + json!({"planes":[{"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5,5],[17,4],[18,20],[4,19]]}], + "target":"pixels","__atnPerspectiveWarpRect":[0,0,32,24]}), + ), + ( + "paint.gradient", + json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100, + "stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":false}), + ), ("filter.render.clouds", json!({})), ("filter.render.differenceClouds", json!({})), ("filter.render.fibers", json!({"variance": 20, "strength": 7, "seed": -123})), @@ -495,8 +685,25 @@ fn every_mapped_command_exports_and_imports_back() { ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"intersect"})), ("edit.pasteSpecial.pasteInto", json!({})), ("edit.pasteSpecial.pasteOutside", json!({})), + ( + "edit.contentAwareFill", + json!({"sampling":"rectangular","sampleAllLayers":true,"colorAdaptation":"default", + "rotationAdaptation":"none","scale":true,"mirror":false,"output":"new"}), + ), ("edit.fade", json!({"opacity":50,"mode":"Multiply"})), ("layer.linkLayers", json!({})), + ("layer.smartObjects.stackMode.range", json!({})), + ("layer.smartObjects.stackMode.entropy", json!({})), + ("layer.smartObjects.stackMode.kurtosis", json!({})), + ("layer.smartObjects.stackMode.maximum", json!({})), + ("layer.smartObjects.stackMode.median", json!({})), + ("layer.smartObjects.stackMode.minimum", json!({})), + ("layer.smartObjects.stackMode.mean", json!({})), + ("layer.smartObjects.stackMode.skewness", json!({})), + ("layer.smartObjects.stackMode.standardDeviation", json!({})), + ("layer.smartObjects.stackMode.summation", json!({})), + ("layer.smartObjects.stackMode.variance", json!({})), + ("layer.smartObjects.stackMode.none", json!({})), ("layer.distribute.leftEdges", json!({})), ("layer.distribute.horizontalCenters", json!({})), ("layer.distribute.rightEdges", json!({})), @@ -523,10 +730,19 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), ("filter.noise.median", json!({"radius": 2})), ("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), + ("image.calculations", json!({"source1":{"channel":{"color":"red"}},"source2":{"channel":{"color":"green"}},"blending":"add","scale":1,"offset":0})), + ("image.adjustments.colorLookup", color_lookup_case()), + ("file.automate.batch", batch_case()), + ("file.automate.batch", batch_save_and_close_case()), + ("filter.other.custom", json!({"kernel":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"scale":1,"offset":0})), + ("filter.render.fibers", json!({"variance":64,"strength":1,"seed":-1})), + ("filter.blur.shapeBlur", json!({"radius":5,"shapeName":"Recorded Square","shapeId":"original-fixture-square","shapeOriginType":9})), ("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})), ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("file.automate.createDroplet", json!({"path": "/d/Sharpen.pcdroplet", "action": "Sharpen", "set": "Tools", "output": "/d/out", "overrideOpen": true})), + ("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})), ("filter.distort.pinch", json!({"amount": -30})), ("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})), ("filter.distort.spherize", json!({"amount": -50, "mode": "verticalOnly"})), @@ -618,6 +834,14 @@ fn every_mapped_command_exports_and_imports_back() { ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("type.convertToShape", json!({})), + ("type.createWorkPath", json!({})), + ( + "path.set", + json!({"name":"work","path":{"fillRule":"evenodd","inverted":false,"subpaths":[{"closed":false,"op":"combine","knots":[ + {"anchor":[1,2],"in":[1,2],"out":[3,4],"smooth":false}, + {"anchor":[8,9],"in":[6,7],"out":[8,9],"smooth":false} + ]}]}}), + ), ("filter.blur.shapeBlur", json!({})), ("filter.blur.shapeBlur", json!({"radius": 10})), ("layer.new.group", json!({"name": "G"})), @@ -634,6 +858,9 @@ fn every_mapped_command_exports_and_imports_back() { ("type.loadDefaultTypeStyles", json!({})), ("type.saveDefaultTypeStyles", json!({})), ("image.analysis.recordMeasurements", json!({})), + ("layer.videoLayers.insertBlankFrame", json!({})), + ("layer.videoLayers.duplicateFrame", json!({})), + ("layer.videoLayers.deleteFrame", json!({})), ("file.automate.cropAndStraightenPhotos", json!({})), ("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})), ("layer.new.artboardFromGroup", json!({})), @@ -754,6 +981,8 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.createClippingMask", json!({})), ("layer.ungroupLayers", json!({})), ("layer.layerStyle.copyLayerStyle", json!({})), + ("layer.layerStyle.scaleEffects", json!({"scale": 200})), + ("layer.layerStyle.createLayer", json!({})), ("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": true})), ("layer.setExpanded", json!({"all": true, "expanded": false})), ("layerComp.apply", json!({"comp": "Original"})), @@ -842,6 +1071,11 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), ("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})), ("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})), + ("layer.layerStyle.blendingOptions", json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [0, 0, 255, 255]}]})), + ( + "layer.layerStyle.blendingOptions", + json!({"opacity": 40, "fillOpacity": 70, "blend": "Multiply", "blendIf": [{"channel": "blue", "thisLayer": [5, 5, 250, 250]}]}), + ), ("layer.createClippingMask", json!({})), ("layer.lockLayers", json!({"transparency": true})), ("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})), @@ -860,7 +1094,9 @@ fn every_mapped_command_exports_and_imports_back() { continue; } let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id())); - match (e.from)(item.descriptor.as_ref().unwrap()) { + assert_eq!(item.descriptor.is_none(), *id == super::steps_stack::NONE, "{id}: unexpected descriptor presence"); + let descriptor = item.descriptor.unwrap_or_default(); + match (e.from)(&descriptor) { Mapped::Steps(steps) => { assert_eq!(steps.len(), 1, "{id}: {steps:?}"); let navigation = match *id { @@ -882,6 +1118,17 @@ fn every_mapped_command_exports_and_imports_back() { } assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); + if *id == "paint.gradient" { + // Executable values match; independent public tests verify the source carrier. + back.as_object_mut().unwrap().remove("__atnClassicGradient"); + } + if *id == "path.set" { + // The imported step retains the original descriptor alongside its playable path. + back.as_object_mut().unwrap().remove(super::steps_path::SOURCE).unwrap(); + } + if *id == "edit.perspectiveWarp" { + back.as_object_mut().unwrap().remove("__atnPerspectiveWarp"); + } normalise(&mut back); if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); @@ -977,6 +1224,9 @@ fn every_mapped_command_exports_and_imports_back() { "filter.sharpen.sharpenMore", // Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged). "filter.cameraRaw", + // Load with the recorded descriptor riding along (gradient_fill_steps_export_and_import_back). + "gradient.fill.set", + "layer.newFillLayer.gradient", ]; let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect(); assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}"); @@ -1169,6 +1419,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + item("GsnB", "Gaussian Blur", Some(desc("null"))), // Duplicate into another open document: the `T` reference means nothing at // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. item( @@ -1190,8 +1441,11 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); - assert_eq!(imp.unsupported.len(), 5); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.actions[0].steps[4].1["id"], "filter.blur.gaussianBlur"); + assert_eq!(imp.actions[0].steps[4].1["eventKind"], "code"); + assert_eq!(imp.actions[0].steps[5].1, json!({})); + assert_eq!(imp.unsupported.iter().map(|step| step.step).collect::>(), [0, 1, 2, 3, 6]); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } @@ -6044,3 +6298,483 @@ fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() { assert_eq!(channel.unsupported.len(), 1, "{o} on a channel was mapped: {:?}", channel.actions[0].steps); } } + +/// Nonfinite recorded doubles remain unsupported before native parameters can be constructed. +#[test] +fn shear_nonfinite_coordinates_stay_kept() { + let point = |x, y| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(x)).with("Vrtc", D::Double(y))); + let finite = desc("null").with("ShrP", D::List(vec![point(-10000.0, -1000.0), point(10000.0, 1000.0)])); + assert!(matches!(steps_distort::shear_from(&finite), Mapped::Steps(_)), "finite coordinates still clamp and map"); + for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for d in [ + desc("null").with("ShrP", D::List(vec![point(n, 1.0), point(0.0, 128.0)])), + desc("null").with("ShrP", D::List(vec![point(0.0, 1.0), point(0.0, n)])), + ] { + assert!(matches!(steps_distort::shear_from(&d), Mapped::Unsupported { .. }), "{d:?}"); + } + } +} + +/// #10771: a Gradient Fill as Photoshop records it: three stops, a transparency stop at 40 % and +/// one midpoint per stop (`Mdpn` of stop k is the segment that ends at it). +fn recorded_gradient(mids: [i32; 3]) -> Descriptor { + let stop = |hex: &str, at: i32, mid: i32| { + D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(hex).unwrap())) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(at)) + .with("Mdpn", D::Integer(mid)), + ) + }; + let trn = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![stop("#ff0000", 0, mids[0]), stop("#00ff00", 2048, mids[1]), stop("#0000ff", 4096, mids[2])])) + .with("Trns", D::List(vec![trn(0, 100.0), trn(4096, 40.0)])); + desc("gradientLayer").with("Angl", ang(30.0)).with("Type", enm("GrdT", "Rdl ")).with("Grad", D::Descriptor(grad)) +} + +fn gradient_set(t: Descriptor) -> AtnItem { + item("setd", "Set", Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(t)))) +} + +fn gradient_make(t: Descriptor) -> AtnItem { + let d = desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))); + item("Mk ", "Make", Some(d)) +} + +/// The first saved step of `action` (strict save). +fn saved_first(s: &mut Session) -> (AtnEvent, Option) { + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let i = &saved.actions[0].items[0]; + (i.event.clone(), i.descriptor.clone()) +} + +fn without_hidden(p: &Value) -> Value { + let mut p = p.clone(); + p.as_object_mut().unwrap().remove("__atnDescriptor"); + normalise(&mut p); + p +} + +fn active_fill(s: &Session) -> photocraft_doc::Fill { + let st = s.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + photocraft_doc::LayerContent::Fill(f) => f.clone(), + c => panic!("not a fill layer: {}", c.kind_name()), + } +} + +/// #10771: a recorded Set of a Gradient Fill loads as `gradient.fill.set` with its midpoints on +/// the segment they belong to, plays them, and saves back as the recorded descriptor. +#[test] +fn gradient_fill_set_imports_midpoints_plays_and_saves_back_unchanged() { + let recorded = recorded_gradient([50, 25, 70]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded.clone())])).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let (id, p) = s.actions.list[0].steps[0].clone(); + assert_eq!(id, "gradient.fill.set"); + let mut expected = json!({ + "style": "radial", "angle": 30, + "stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]], + "transparency": [[0, 100], [1, 40]], + "midpoints": [0.25, 0.7] + }); + normalise(&mut expected); + assert_eq!(without_hidden(&p), expected); + assert_eq!( + saved_first(&mut s), + (AtnEvent::Code(*b"setd"), Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(recorded)))) + ); + // It plays on a Gradient Fill layer. + s.execute("file.new", json!({"width": 16, "height": 12})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; + assert_eq!(stops.len(), 3); + assert_eq!(midpoints, vec![0.25, 0.7]); + assert_eq!(opacity_stops.len(), 2); + // Default midpoints load as `[]`, which clears the custom ones when played. + let mut s2 = Session::new(); + s2.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 50, 50]))])).unwrap())})).unwrap(); + assert_eq!(s2.actions.list[0].steps[0].1["midpoints"], json!([])); + s.actions = s2.actions; + s.execute("actions.play", json!({"action": "A"})).unwrap(); + let photocraft_doc::Fill::Gradient { midpoints, .. } = active_fill(&s) else { panic!("not a gradient") }; + assert!(midpoints.is_empty() || midpoints.iter().all(|m| *m == 0.5), "{midpoints:?}"); +} + +/// #10771: an edited loaded step writes a fresh descriptor from its params, midpoints included. +#[test] +fn edited_gradient_fill_set_writes_a_fresh_descriptor() { + let mut s = Session::new(); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 25, 70]))])).unwrap())})).unwrap(); + s.actions.list[0].steps[0].1["midpoints"] = json!([0.4, 0.6]); + let (_, d) = saved_first(&mut s); + let t = get_obj(d.as_ref().unwrap(), "T ").unwrap(); + let clrs = get_list(get_obj(t, "Grad").unwrap(), "Clrs").unwrap(); + let mids: Vec = clrs.iter().map(|c| if let D::Descriptor(c) = c { get_num(c, "Mdpn").unwrap() } else { panic!() }).collect(); + assert_eq!(mids, vec![50.0, 40.0, 60.0]); + // Its own import is the edited params. + let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"setd")).unwrap().from)(d.as_ref().unwrap()) else { panic!("did not import") }; + let mut edited = without_hidden(&s.actions.list[0].steps[0].1); + edited["midpoints"] = json!([0.4, 0.6]); + normalise(&mut edited); + assert_eq!(without_hidden(&steps[0].1), edited); +} + +/// #10771: a recorded Make of a Gradient Fill layer plays its stops, transparency and midpoints +/// and saves back unchanged. +#[test] +fn gradient_fill_make_imports_plays_and_saves_back_unchanged() { + let recorded = recorded_gradient([50, 25, 70]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_make(recorded.clone())])).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + assert_eq!(s.actions.list[0].steps[0].0, "layer.newFillLayer.gradient"); + let (event, d) = saved_first(&mut s); + assert_eq!(event, AtnEvent::Code(*b"Mk ")); + assert_eq!(get_obj(get_obj(d.as_ref().unwrap(), "Usng").unwrap(), "Type"), Some(&recorded)); + for depth in [8, 16] { + s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; + assert_eq!((stops.len(), midpoints, opacity_stops.len()), (3, vec![0.25, 0.7], 2)); + // One undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert!(!matches!(s.active().unwrap().doc.layers.last().unwrap().content, photocraft_doc::LayerContent::Fill(_))); + } +} + +/// #10771: PhotoCraft's own Gradient Fill steps export and load back as the same fill. +#[test] +fn gradient_fill_steps_export_and_import_back() { + let set = json!({"style": "linear", "angle": 45, "stops": [[0, "#ff0000"], [0.25, "#00ff00"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 50]], "midpoints": [0.3, 0.6]}); + let item = export_item("gradient.fill.set", &set, "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") }; + let mut expected = set.clone(); + normalise(&mut expected); + assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("gradient.fill.set", expected)); + // The two-colour Make plays as the same two stops at full opacity. + let item = export_item("layer.newFillLayer.gradient", &json!({"from": "#ff0000", "to": "#0000ff", "angle": 45}), "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") }; + let mut expected = json!({"angle": 45, "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": []}); + normalise(&mut expected); + assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("layer.newFillLayer.gradient", expected)); + // Stops replace `from` and `to` when it plays, so a Make with all three exports its stops. + let p = json!({"from": "#ff0000", "to": "#0000ff", "stops": [[0, "#00ff00"], [1, "#ffffff"]], "transparency": [[0, 100], [1, 100]]}); + let item = export_item("layer.newFillLayer.gradient", &p, "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") }; + assert_eq!(steps[0].1["stops"], json!([[0.0, "#00ff00"], [1.0, "#ffffff"]])); + // What the event cannot hold is refused: a Set without its transparency, toolbox colours, + // dragged handles, a path, a midpoint off a whole percent. + for (id, p) in [ + ("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]]})), + ("gradient.fill.set", json!({"stops": [[0, "foreground"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})), + ("gradient.fill.set", json!({"from": [0, 0], "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})), + ("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": [0.333]})), + ("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "path": "work"})), + ("layer.newFillLayer.gradient", json!({"from": "#ff0000"})), + ] { + assert!(export_item(id, &p, "S").is_none(), "{id}: {p}"); + } +} + +/// #10771: a Noise gradient, smoothness below 100 % or a transparency midpoint stay Photoshop +/// steps rather than playing as a different fill. +#[test] +fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() { + // `with` appends; these replace the recorded value. + fn replaced(mut d: Descriptor, key: &str, v: D) -> Descriptor { + d.items.retain(|(k, _)| !k.is(key)); + d.with(key, v) + } + let base = recorded_gradient([50, 50, 50]); + let with_grad = |f: &dyn Fn(Descriptor) -> Descriptor| { + let g = get_obj(&base, "Grad").unwrap().clone(); + replaced(base.clone(), "Grad", D::Descriptor(f(g))) + }; + let trns_mid = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(0)).with("Mdpn", D::Integer(30)); + let trns_end = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(4096)).with("Mdpn", D::Integer(50)); + let trns = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); + for t in [ + // A midpoint outside the dialog's 5..95 % would play clamped. + recorded_gradient([50, 2, 50]), + recorded_gradient([50, 50, 97]), + // Transparency stops out of range or out of order would play clamped and sorted. + with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 100.0), trns(5000, 40.0)]))), + with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 150.0), trns(4096, 40.0)]))), + with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(4096, 100.0), trns(0, 40.0)]))), + // A Set without transparency stops would keep the layer's own. + with_grad(&|mut g| { + g.items.retain(|(k, _)| !k.is("Trns")); + g + }), + with_grad(&|g| replaced(g, "GrdF", enm("GrdF", "ClNs"))), + with_grad(&|g| replaced(g, "Intr", D::Double(2048.0))), + with_grad(&|g| replaced(g, "Trns", D::List(vec![D::Descriptor(trns_mid.clone()), D::Descriptor(trns_end.clone())]))), + base.clone().with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Perc")), + ] { + let imp = import(&atn::write(&one_action(vec![gradient_set(t)])).unwrap()).unwrap(); + assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP); + } + // A Make whose new layer also gets a name, opacity, mode or clipping: the command plays none + // of them, so the step stays as recorded. + for (key, v) in [("Nm ", text("Sky")), ("Opct", pct(50.0)), ("Md ", enm("BlnM", "Mltp")), ("Grup", D::Boolean(true))] { + let using = desc("contentLayer").with("Type", D::Descriptor(base.clone())).with(key, v); + let make = item("Mk ", "Make", Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(using)))); + let imp = import(&atn::write(&one_action(vec![make])).unwrap()).unwrap(); + assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP, "{key}"); + } +} + +fn blend_if_entry(channel: &str, this: [i32; 4], under: Option<[i32; 4]>) -> D { + let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel))); + for (k, v) in ["SrcB", "Srcl", "SrcW", "Srcm"].into_iter().zip(this) { + d = d.with(k, D::Integer(v)); + } + if let Some(u) = under { + for (k, v) in ["DstB", "Dstl", "DstW", "Dstt"].into_iter().zip(u) { + d = d.with(k, D::Integer(v)); + } + } + D::Descriptor(d) +} + +fn layer_set(t: Descriptor) -> AtnItem { + item("setd", "Set", Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)))) +} + +fn blend_layers(depth: u8) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s +} + +fn active_info(s: &Session) -> Value { + let st = s.active().unwrap(); + crate::inspect::layer(st.doc.layer(st.active_layer.unwrap()).unwrap()) +} + +/// #10771: Blend If loads as `layer.layerStyle.blendingOptions` and plays (it used to be a +/// silent no-op), on 8- and 16-bit documents, Gray and a colour channel, split sliders. +#[test] +fn blend_if_imports_and_plays() { + let blnd = D::List(vec![blend_if_entry("gray", [0, 30, 200, 255], Some([10, 10, 240, 250])), blend_if_entry("red", [20, 40, 255, 255], None)]); + for depth in [8, 16] { + let mut s = blend_layers(depth); + let loaded = s + .execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", blnd.clone()))])).unwrap())})) + .unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let mut expected = json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}, {"channel": "red", "thisLayer": [20, 40, 255, 255]}]}); + normalise(&mut expected); + let (id, p) = &s.actions.list[0].steps[0]; + assert_eq!((id.as_str(), p), ("layer.layerStyle.blendingOptions", &expected)); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let b = active_info(&s)["blendIf"].clone(); + assert_eq!(b[0], json!({"channel": 0, "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}), "{b}"); + assert_eq!(b[1], json!({"channel": 1, "thisLayer": [20, 40, 255, 255], "underlying": [0, 0, 255, 255]}), "{b}"); + // It saves back as the same `Blnd`. + let (_, d) = saved_first(&mut s); + assert_eq!(get_obj(d.as_ref().unwrap(), "T ").unwrap().get("Blnd"), Some(&blnd)); + } +} + +/// #10771: the Blending Options dialog's OK (opacity, fill, mode and Blend If in one Set) is one +/// step that applies them all; a Set that also renames splits into setProps and Blend If. +#[test] +fn blend_if_with_the_dialog_options_is_one_step_and_a_rename_splits() { + let blnd = D::List(vec![blend_if_entry("gray", [0, 0, 128, 255], None)]); + let dialog = desc("Lyr ").with("Opct", pct(40.0)).with("fillOpacity", pct(70.0)).with("Md ", enm("BlnM", "Mltp")).with("Blnd", blnd.clone()); + let mut s = blend_layers(8); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap(); + let steps = &s.actions.list[0].steps; + assert_eq!(steps.len(), 1, "{steps:?}"); + assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions"); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = active_info(&s); + assert!((info["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-3, "{info}"); + assert_eq!(info["blend"], json!("Multiply"), "{info}"); + assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}"); + let (_, d) = saved_first(&mut s); + assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog)); + // With a rename the Set is not the dialog's: the name and opacity go through setProps. + let mut s = blend_layers(8); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Nm ", text("Renamed")).with("Opct", pct(40.0)).with("Blnd", blnd))])).unwrap())})) + .unwrap(); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert!(ids.contains(&"layer.setProps") && ids.contains(&"layer.layerStyle.blendingOptions"), "{ids:?}"); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = active_info(&s); + assert_eq!(info["name"], json!("Renamed")); + assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}"); +} + +/// #10771: the Layer Style dialog's OK on a layer without effects also records the effects' +/// scale (`Lefx` = {Scl}). With Blend If it is still the dialog's one step and saves back with it. +#[test] +fn blend_if_with_the_recorded_effects_scale_saves_back_unchanged() { + let blnd = D::List(vec![blend_if_entry("gray", [10, 20, 200, 230], Some([0, 0, 255, 255]))]); + let dialog = desc("Lyr ").with("Opct", pct(80.0)).with("Blnd", blnd.clone()).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)))); + let mut s = blend_layers(8); + s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap(); + let steps = &s.actions.list[0].steps; + assert_eq!(steps.len(), 1, "{steps:?}"); + assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions"); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = active_info(&s); + assert!((info["opacity"].as_f64().unwrap() - 0.8).abs() < 1e-3, "{info}"); + assert_eq!(info["blendIf"][0]["thisLayer"], json!([10, 20, 200, 230]), "{info}"); + let (_, d) = saved_first(&mut s); + assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog)); + // A `Lefx` with an effect in it is not the scale alone: the step is not the dialog's. + let with_fx = desc("Lyr ").with("Blnd", blnd).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)).with("DrSh", D::Descriptor(desc("DrSh"))))); + let imp = import(&atn::write(&one_action(vec![layer_set(with_fx)])).unwrap()).unwrap(); + assert!(imp.actions[0].steps.iter().all(|(_, p)| p.get("__atnEffectsScale").is_none()), "{:?}", imp.actions[0].steps); + // Nor can it play without the shadow: it stays the recorded step. + assert_eq!(imp.actions[0].steps.iter().map(|(c, _)| c.as_str()).collect::>(), [PHOTOSHOP_STEP], "{:?}", imp.actions[0].steps); +} + +/// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying +/// sliders or an unknown key stay Photoshop steps. +#[test] +fn blend_if_the_command_cannot_play_stays_a_photoshop_step() { + let with = |key: &str| match blend_if_entry("gray", [0, 0, 255, 255], None) { + D::Descriptor(d) => D::Descriptor(d.with(key, D::Integer(3))), + e => e, + }; + let (half, unknown) = (with("DstB"), with("Zzzz")); + let inverted = blend_if_entry("gray", [0, 200, 100, 255], None); + for e in [blend_if_entry("gray", [0, 0, 300, 255], None), inverted, half, unknown] { + let imp = import(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", D::List(vec![e])))])).unwrap()).unwrap(); + assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP); + } + for p in [ + json!({"blendIf": null}), + json!({"blendIf": {"channel": 2, "thisLayer": [0, 255]}}), + json!({"blendIf": {"channel": "gray", "thisLayer": [200, 100]}}), + json!({"blendIf": {"channel": "gray", "underlying": [0, 255]}}), + ] { + assert!(export_item("layer.layerStyle.blendingOptions", &p, "S").is_none(), "{p}"); + } +} + +/// Save one action of steps through `actions.save`, returning the skipped list and the steps +/// `actions.load` reads back. +fn save_and_reload(steps: Vec<(String, Value)>) -> (Value, Vec<(String, Value)>) { + let mut s = Session::new(); + let mut action = Action::new("A", steps); + action.set = Some("Review".into()); + s.actions.list.push(action); + let saved = s.execute("actions.save", json!({"set": "Review", "skipUnsupported": true})).unwrap(); + let mut fresh = Session::new(); + fresh.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + (saved["skipped"].clone(), fresh.actions.list[0].steps.clone()) +} + +/// Calculations plays an omitted blending as Multiply, so Save Actions writes it as Multiply. +#[test] +fn calculations_without_blending_saves_as_multiply() { + let sources = json!({"source1": {"channel": {"color": "red"}}, "source2": {"channel": {"color": "green"}}}); + for blending in [None, Some(Value::Null)] { + let mut params = sources.clone(); + if let Some(b) = blending.clone() { + params["blending"] = b; + } + let (skipped, steps) = save_and_reload(vec![("image.calculations".into(), params)]); + assert_eq!(skipped, json!([]), "{blending:?}"); + let mut expected = sources.clone(); + expected["blending"] = json!("multiply"); + assert_eq!(steps, vec![("image.calculations".to_string(), expected)], "{blending:?}"); + } + let mut unknown = sources.clone(); + unknown["blending"] = json!("bogus"); + assert_eq!(save_and_reload(vec![("image.calculations".into(), unknown)]).0.as_array().map(Vec::len), Some(1)); +} + +/// A transparency source with no layer reads the active layer's transparency; Save Actions +/// refuses it rather than write the merged transparency. Merged and named layers still save. +#[test] +fn apply_image_active_layer_transparency_is_not_saved_as_merged() { + for source in [json!({"channel": "transparency"}), json!({"channel": "Transparency", "layer": null})] { + let (skipped, _) = save_and_reload(vec![("image.applyImage".into(), json!({"source": source, "blending": "normal"}))]); + assert_eq!(skipped.as_array().map(Vec::len), Some(1), "{source}: {skipped}"); + } + for layer in ["merged", "Pixels"] { + let params = json!({"source": {"channel": "transparency", "layer": layer}, "blending": "normal"}); + let (skipped, steps) = save_and_reload(vec![("image.applyImage".into(), params)]); + assert_eq!(skipped, json!([]), "{layer}"); + assert_eq!(steps[0].1["source"]["layer"], layer, "{layer}: {steps:?}"); + } + // Other channels keep the merged default for an omitted layer. + let (skipped, steps) = save_and_reload(vec![("image.applyImage".into(), json!({"source": {"channel": "red"}, "blending": "normal"}))]); + assert_eq!(skipped, json!([])); + assert_eq!(steps[0].1["source"]["layer"], "merged"); +} + +fn make_levels_layer(using: Descriptor) -> Imported { + let levels = desc("Lvls").with("Adjs", D::List(vec![D::Descriptor(desc("LvlA").with("Chnl", channel_ref(None)).with("Gmm ", D::Double(1.2)))])); + let make = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using.with("Type", D::Descriptor(levels)))); + let file = AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("Mk ", "Make", Some(make))], ..Default::default() }], + ..Default::default() + }; + import(&atn::write(&file).unwrap()).unwrap() +} + +/// A Make adjustment layer takes `Md ` as its blend mode only when it is a `BlnM` enumeration. +#[test] +fn adjustment_layer_blend_mode_needs_a_blend_mode_enumeration() { + let imported = make_levels_layer(desc("AdjL").with("Md ", enm("BlnM", "Mltp"))); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].1["mode"], "Multiply"); + let imported = make_levels_layer(desc("AdjL").with("Md ", enm("Ordn", "Mltp"))); + assert_eq!(imported.unsupported.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, PHOTOSHOP_STEP); + // A plain Make layer drops it rather than read another enumeration as a mode. + let make = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(desc("Lyr ").with("Md ", enm("Ordn", "Mltp")))); + let file = AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("Mk ", "Make", Some(make))], ..Default::default() }], + ..Default::default() + }; + let imported = import(&atn::write(&file).unwrap()).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, "layer.new.layer"); + assert!(imported.actions[0].steps[0].1.get("mode").is_none(), "{:?}", imported.actions[0].steps); +} + +/// An adjustment layer name that would not save back unchanged keeps the whole step. +#[test] +fn adjustment_layer_name_must_round_trip() { + for (name, kept) in [ + (UnicodeString::new_nul("Grade"), true), + (UnicodeString::new("Grade"), true), + (UnicodeString(vec![u16::from(b'G'), 0xD800, 0]), false), + (UnicodeString(vec![u16::from(b'G'), 0, 0]), false), + ] { + let imported = make_levels_layer(desc("AdjL").with("Nm ", D::Text(name.clone()))); + let step = &imported.actions[0].steps[0]; + if kept { + assert!(imported.unsupported.is_empty(), "{name:?}"); + assert_eq!(step.1["name"], "Grade", "{name:?}"); + } else { + assert_eq!(step.0, PHOTOSHOP_STEP, "{name:?}"); + } + } +} diff --git a/crates/engine/src/cmd/automate_cmds.rs b/crates/engine/src/cmd/automate_cmds.rs index ee253bea4a..9a5f94f68c 100644 --- a/crates/engine/src/cmd/automate_cmds.rs +++ b/crates/engine/src/cmd/automate_cmds.rs @@ -124,11 +124,7 @@ fn check_known(steps: &[(String, Value)], cmd: &str) -> Result<()> { const MAX_SCRIPT_DEPTH: u8 = 16; pub(crate) const MAX_SCRIPT_STEPS: u64 = crate::actions_cmds::MAX_PLAYBACK_STEPS; -/// A script run from a script reports its own failure inside an `Ok`; it stops the caller too. -fn nested_script_failure(id: &str, r: &Value) -> Option { - (id == "file.scripts.browse" && r.get("ok") == Some(&Value::Bool(false))).then(|| "a script it runs failed".to_string()) -} - +/// Direct Batch file errors remain script results; called action failures stop later script steps. fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { let mut results = Vec::new(); for (id, params) in steps { @@ -138,14 +134,18 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { break; } match s.execute(id, params.clone()) { - // A called action reports a failed step inside an `Ok`; it stops the script too. - Ok(r) => match crate::actions_cmds::nested_failure(id, &r).or_else(|| nested_script_failure(id, &r)) { - Some(error) => { - results.push(json!({"command": id, "result": r, "error": error})); - break; + Ok(r) => { + // Direct Batch calls report file errors successfully to scripts, but running out of + // script steps, a cancel or a failed error log stops it. Called actions still fail it. + let failure = if id == "file.automate.batch" { crate::actions_cmds::batch_stop(&r) } else { crate::actions_cmds::nested_failure(id, &r) }; + match failure { + Some(error) => { + results.push(json!({"command": id, "result": r, "error": error})); + break; + } + None => results.push(json!({"command": id, "result": r})), } - None => results.push(json!({"command": id, "result": r})), - }, + } Err(e) => { results.push(json!({"command": id, "error": e.to_string()})); break; @@ -324,19 +324,45 @@ pub fn document_opened(s: &mut Session) { fire_event(s, "openDocument"); } -/// After the shell saved document `index`: Save Document scripts and Image Assets. -pub fn document_saved(s: &mut Session, index: usize) -> Option { +/// After the shell saved document `doc`: Save Document scripts and Image Assets. +/// The script runs first and may close or reorder tabs, so asset generation resolves the saved +/// document's identity again: a script that closed a different tab must not skip its assets, and +/// one that closed the saved document must not generate a replacement tab's (#11121). +pub fn document_saved(s: &mut Session, doc: photocraft_doc::DocId) -> Option { fire_event(s, "saveDocument"); + let index = s.documents().iter().position(|st| st.doc.id == doc)?; crate::web_cmds::on_saved(s, index) } // ---------- Create Droplet ---------- +/// Resolve the enabled steps of the saved action chosen for a droplet: by name within `set` when +/// one is given (a loaded library can hold one name in two sets), else by name or index. +pub(crate) fn named_action_steps(s: &Session, p: &Value, cmd: &str) -> Result> { + let index = match p.get("set").filter(|v| !v.is_null()) { + Some(set) => { + let set = set.as_str().ok_or_else(|| bad(cmd, "\"set\" must be a string"))?; + let name = p.get("action").and_then(Value::as_str).ok_or_else(|| bad(cmd, "a set needs a named action"))?; + s.actions + .list + .iter() + .position(|a| a.name == name && a.set.as_deref() == Some(set)) + .ok_or_else(|| bad(cmd, format!("no action named `{name}` in set `{set}`")))? + } + None => crate::actions_cmds::resolve(&s.actions.list, p, cmd)?, + }; + let action = s.actions.list.get(index).ok_or_else(|| bad(cmd, "the selected action no longer exists"))?; + Ok(action.enabled_steps().map(|(_, step)| step.clone()).collect()) +} + +/// Save the chosen action's enabled steps and dependencies as a runnable droplet. fn create_droplet(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.createDroplet"; let path = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"path\" (where to save the droplet)"))?; - let steps_v = p.get("steps").or_else(|| p.get("action")).ok_or_else(|| bad(cmd, "missing \"steps\" (the action to run)"))?; - let mut steps = parse_steps(steps_v, cmd)?; + let mut steps = match p.get("steps").or_else(|| p.get("action").filter(|v| v.is_array())) { + Some(steps) => parse_steps(steps, cmd)?, + None => named_action_steps(s, p, cmd)?, + }; // As in batch, the droplet's output folder replaces recorded saves, which are kept to mark the // result, and view steps don't apply. steps.retain(|(id, _)| crate::file_cmds::batch_keeps_step(id)); @@ -431,8 +457,14 @@ fn run_droplet(s: &mut Session, p: &Value) -> Result { join(&first.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(), "droplet-output") }); let mut bp = json!({"steps": steps, "input": inputs, "output": output, "format": opts.get("format").cloned().unwrap_or(json!("same"))}); - if let Some(q) = opts.get("quality") { - bp["quality"] = q.clone(); + if let Some(name) = v.get("action").and_then(|action| action.get("name")).or_else(|| v.get("name")) { + bp["action"] = name.clone(); + } + // Override Open is saved with the droplet: a recorded Open then reads each dropped input. + for key in ["quality", "overrideOpen"] { + if let Some(value) = opts.get(key) { + bp[key] = value.clone(); + } } if let Some(called) = v.get("calledActions") { bp["calledActions"] = called.clone(); @@ -597,7 +629,7 @@ pub fn specs() -> Vec { "file.automate.createDroplet", "Create Droplet…", &["File", "Automate"], - r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action and Conditional steps call are saved in the droplet)"##, + r##"{"path":str (.pcdroplet),"steps":[[id,params]…]?,"action":name|index|[steps]? (used only when steps is absent; named actions exclude disabled steps),"set":str? (with a named action: that set's action of the name),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action and Conditional steps call are saved in the droplet)"##, native, create_droplet ), diff --git a/crates/engine/src/cmd/automate_cmds/tests.rs b/crates/engine/src/cmd/automate_cmds/tests.rs index c6206cc041..2899a2067a 100644 --- a/crates/engine/src/cmd/automate_cmds/tests.rs +++ b/crates/engine/src/cmd/automate_cmds/tests.rs @@ -168,6 +168,83 @@ fn droplets_are_written_and_run() { assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err()); } +/// Names and indices choose the same action and omit its disabled steps. +#[test] +fn batch_and_droplets_use_the_named_actions_enabled_steps() { + let dir = tmp("named-actions"); + let inputs = images(&dir, 1, 2, 1); + let mut s = Session::new(); + let invert = crate::actions_cmds::Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))]); + let mut rotate = crate::actions_cmds::Action::new("Rotate", vec![("image.imageRotation.90cw".into(), json!({})), ("file.close".into(), json!({}))]); + rotate.disabled.insert(1); + s.actions.list = vec![invert, rotate]; + for command in ["file.automate.batch", "file.automate.createDroplet"] { + for (i, action) in [json!("Rotate"), json!(1)].into_iter().enumerate() { + let output = format!("{dir}/{command}-{i}"); + let p = json!({"action": action, "input": inputs, "output": output, "path": format!("{output}.pcdroplet"), "format": "png", "shim": false}); + let mut result = s.execute(command, p).unwrap(); + if command == "file.automate.createDroplet" { + let droplet: Value = serde_json::from_slice(&std::fs::read(result["path"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(droplet["action"]["steps"], json!([["image.imageRotation.90cw", {}]])); + // A droplet carries its chosen steps into a session with no saved actions. + result = Session::new().execute("file.automate.runDroplet", json!({"droplet": result["path"], "input": inputs})).unwrap(); + } + assert_eq!(result["errors"], json!([]), "{command}: {result}"); + let image = photocraft_codecs::decode(&std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(image.dimensions(), (1, 2), "{command}: Rotate runs and the disabled Close does not"); + } + } +} + +/// Explicit steps retain precedence, and invalid choices fail before any output is written. +#[test] +fn batch_and_droplets_keep_explicit_steps_and_reject_invalid_sources_before_writing() { + let dir = tmp("action-sources"); + let inputs = images(&dir, 1, 2, 1); + let mut s = Session::new(); + s.actions.list.push(crate::actions_cmds::Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))])); + for command in ["file.automate.batch", "file.automate.createDroplet"] { + for (i, source) in [ + json!({"steps": [["image.imageRotation.90cw", {}]], "action": "Missing"}), + json!({"action": [["image.imageRotation.90cw", {}]]}), + json!({"steps": [], "action": "Invert"}), + ] + .into_iter() + .enumerate() + { + let output = format!("{dir}/{command}-inline-{i}"); + let mut p = json!({"input": inputs, "output": output, "path": format!("{output}.pcdroplet"), "format": "png", "shim": false}); + p.as_object_mut().unwrap().extend(source.as_object().unwrap().clone()); + let mut result = s.execute(command, p).unwrap(); + if command == "file.automate.createDroplet" { + result = Session::new().execute("file.automate.runDroplet", json!({"droplet": result["path"], "input": inputs})).unwrap(); + } + assert_eq!(result["errors"], json!([]), "{command}: {result}"); + let image = photocraft_codecs::decode(&std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(image.dimensions(), if i == 2 { (2, 1) } else { (1, 2) }); + } + for (i, source) in [ + json!({"action": "Deleted"}), + json!({"action": 9}), + json!({"action": -1}), + json!({"steps": null, "action": "Invert"}), + json!({"steps": {}, "action": "Invert"}), + json!({"steps": [["bogus", {}]], "action": "Invert"}), + ] + .into_iter() + .enumerate() + { + let output = format!("{dir}/{command}-rejected-{i}"); + let path = format!("{output}.pcdroplet"); + let mut p = json!({"input": inputs, "output": output, "path": path, "format": "png", "shim": false}); + p.as_object_mut().unwrap().extend(source.as_object().unwrap().clone()); + assert!(s.execute(command, p).is_err(), "{command}: {source}"); + assert!(!std::path::Path::new(&output).exists(), "{command}: no output directory on rejection"); + assert!(!std::path::Path::new(&path).exists(), "{command}: no droplet on rejection"); + } + } +} + /// A droplet keeps the action's recorded saves, which mark the result as in Batch, so one made from /// an action ending in Save As and Close writes its files instead of failing on every one. #[test] @@ -223,6 +300,7 @@ fn droplet_shim_passes_shell_metacharacters_as_a_literal_path() { assert_eq!(std::fs::read(&capture).unwrap(), expected.to_string_lossy().as_bytes()); } +/// Droplets retain transitive calls and effective Batch references across fresh or different sessions. #[test] fn droplets_carry_the_actions_their_play_steps_call() { use crate::actions_cmds::Action; @@ -261,6 +339,80 @@ fn droplets_carry_the_actions_their_play_steps_call() { let r = fresh.execute("file.automate.runDroplet", json!({"droplet": looping, "input": [dir.clone()]})).unwrap(); assert_eq!(r["errors"].as_array().unwrap().len(), inputs.len(), "a self-call fails instead of looping: {r}"); + // Retain the copied action's flags as well as its steps. + s.actions.list[1].steps.push(("file.close".into(), json!({}))); + s.actions.list[1].disabled.insert(1); + s.actions.list[1].dialogs.insert(0); + s.actions.list[1].set = Some("Captured".into()); + s.actions.list.push(act( + "BatchLoop", + json!([["file.automate.batch", {"action": "BatchLoop", "input": [inputs[0]], "output": format!("{dir}/loop"), "format": "png"}]]), + )); + for (name, source, expected_names, error) in [ + ("batch-name", json!({"action": "Rotate"}), &["Rotate"][..], None), + ("batch-index", json!({"action": 1}), &["Rotate"][..], None), + ("batch-transitive", json!({"action": "Outer"}), &["Rotate", "Outer"][..], None), + ("batch-explicit", json!({"steps": [["actions.play", {"action": 1, "note": "keep"}, "tail"]], "action": 3}), &["Rotate"][..], None), + ("batch-legacy", json!({"action": [{"id": "actions.play", "params": {"action": 1}, "tag": "keep"}]}), &["Rotate"][..], None), + ( + "batch-objects", + json!({"steps": [ + {"command": "actions.play", "id": "ignored", "params": {"action": 1}, "tag": "keep"}, + "image.imageRotation.180", ["image.imageRotation.180"], {"id": "image.imageRotation.180"}, + {"command": "image.imageRotation.180", "id": "actions.play", "params": {"action": 3}} + ], "action": 3}), + &["Rotate"][..], + None, + ), + ("batch-empty", json!({"steps": [], "action": 1}), &[][..], None), + ("batch-null", json!({"steps": null, "action": 1}), &[][..], Some("steps")), + ("batch-invalid", json!({"steps": {}, "action": 1}), &[][..], Some("steps")), + ("batch-invalid-command", json!({"steps": [{"command": null, "id": "actions.play", "params": {"action": 1}}]}), &[][..], Some("command")), + ("batch-missing", json!({"action": "Missing"}), &[][..], Some("no action named")), + ("batch-cycle", json!({"action": "BatchLoop"}), &["BatchLoop"][..], Some("recursive action call")), + ] { + let inner = format!("{dir}/inner-{name}"); + let mut params = json!({"input": [inputs[0]], "output": inner, "format": "png", "note": {"action": 3}}); + params.as_object_mut().unwrap().extend(source.as_object().unwrap().clone()); + let mut expected = params.clone(); + match name { + "batch-index" => expected["action"] = json!("Rotate"), + "batch-explicit" => expected["steps"][0][1]["action"] = json!("Rotate"), + "batch-legacy" => expected["action"][0]["params"]["action"] = json!("Rotate"), + "batch-objects" => expected["steps"][0]["params"]["action"] = json!("Rotate"), + _ => {} + } + let dp = make(&mut s, name, json!([["file.automate.batch", params]])); + let saved: Value = serde_json::from_slice(&std::fs::read(&dp).unwrap()).unwrap(); + assert_eq!(saved["action"]["steps"][0][1], expected, "{name}: keep the effective source's shape and unrelated fields"); + let names: Vec<&str> = + saved["calledActions"].as_array().map(|actions| actions.iter().map(|a| a["name"].as_str().unwrap()).collect()).unwrap_or_default(); + assert_eq!(names, expected_names, "{name}: capture only effective dependencies in original list order"); + if expected_names.is_empty() { + assert!(saved.get("calledActions").is_none(), "{name}: preserve deferred missing-target and legacy policy"); + } + if let Some(captured) = saved["calledActions"].as_array().and_then(|actions| actions.iter().find(|a| a["name"] == "Rotate")) { + assert_eq!(*captured, json!(s.actions.list[1]), "{name}: preserve disabled/dialog/set flags"); + } + for different in [false, true] { + let mut replay = Session::new(); + if different { + replay.actions.list = vec![Action::new("Unrelated", Vec::new()), Action::new("Rotate", Vec::new())]; + } + let r = replay + .execute("file.automate.runDroplet", json!({"droplet": dp, "input": [inputs[0]], "output": format!("{dir}/outer-{name}-{different}")})) + .unwrap(); + if let Some(error) = error { + assert_eq!(r["files"], json!([]), "{name}: {r}"); + assert!(r["errors"][0]["error"].as_str().unwrap().contains(error), "{name}: {r}"); + } else { + assert_eq!(r["errors"], json!([]), "{name}/{different}: {r}"); + let out = photocraft_codecs::decode(&std::fs::read(format!("{inner}/img0.png")).unwrap()).unwrap(); + assert_eq!(out.dimensions(), if name == "batch-empty" { (40, 20) } else { (20, 40) }, "{name}/{different}"); + } + } + } + // Droplets written before `calledActions` still run. let old = format!("{dir}/old.pcdroplet"); std::fs::write( @@ -272,6 +424,105 @@ fn droplets_carry_the_actions_their_play_steps_call() { assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); } +/// Embedded Batch lists own their references; a named source is still selected from the caller. +#[test] +fn droplets_preserve_batch_action_namespaces() { + use crate::actions_cmds::Action; + let dir = tmp("droplet-scopes"); + let input = images(&dir, 1, 40, 20).remove(0); + for kind in ["inline", "named", "named-set", "mixed", "mixed-two", "caller-null"] { + let mut maker = Session::new(); + let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("file.close".into(), json!({}))]); + source.disabled.insert(1); + source.dialogs.insert(0); + source.set = Some("Captured".into()); + maker.actions.list = vec![Action::new("Gap", Vec::new()), Action::new("Outside", Vec::new()), source.clone()]; + let embedded = vec![ + Action::new("LocalGap", Vec::new()), + Action::new("Inside", vec![("image.imageRotation.90cw".into(), json!({}))]), + Action::new("Outside", Vec::new()), + ]; + let inner = format!("{dir}/{kind}-inner"); + let mut batch = json!({"input": [input], "output": inner, "format": "png", "calledActions": embedded}); + if kind == "inline" || kind == "caller-null" { + batch["steps"] = json!([["actions.play", {"action": 1}]]); + } else { + batch["action"] = json!("Source"); + if kind == "named-set" { + batch["set"] = json!("Captured"); + } + } + if kind == "caller-null" { + maker.actions.list[1].steps.push(("image.imageRotation.90cw".into(), json!({}))); + batch["calledActions"] = Value::Null; + } + let mut steps = Vec::new(); + if kind.starts_with("mixed") { + steps.push(json!(["actions.play", {"action": "Source"}])); + } + steps.push(json!(["file.automate.batch", batch])); + let other = format!("{dir}/{kind}-other"); + if kind == "mixed-two" { + let mut second = batch.clone(); + second["output"] = json!(other); + second["calledActions"][1]["steps"] = json!([]); + steps.push(json!(["file.automate.batch", second])); + } + let path = maker + .execute( + "file.automate.createDroplet", + json!({"path": format!("{dir}/{kind}"), "steps": steps, "format": "png", "output": format!("{dir}/{kind}-out"), "shim": false}), + ) + .unwrap()["path"] + .clone(); + let saved: Value = serde_json::from_slice(&std::fs::read(path.as_str().unwrap()).unwrap()).unwrap(); + if kind == "inline" { + assert_eq!(saved["action"]["steps"][0][1], batch, "embedded inline steps are unchanged"); + assert!(saved.get("calledActions").is_none(), "embedded references do not capture enclosing actions"); + } else if kind != "caller-null" { + let captured: Vec = serde_json::from_value(saved["calledActions"].clone()).unwrap(); + assert_eq!(captured.last(), Some(&source), "one untouched source retains its references and flags"); + assert_eq!(captured.len(), if kind.starts_with("mixed") { 3 } else { 1 }); + } + let r = Session::new().execute("file.automate.runDroplet", json!({"droplet": path, "input": [input]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{kind}: {r}"); + let out = photocraft_codecs::decode(&std::fs::read(format!("{inner}/img0.png")).unwrap()).unwrap(); + assert_eq!(out.dimensions(), (20, 40), "{kind}"); + if kind == "mixed-two" { + assert_eq!(photocraft_codecs::decode(&std::fs::read(format!("{other}/img0.png")).unwrap()).unwrap().dimensions(), (40, 20)); + } + } +} + +/// Untouched scoped variants count toward the existing 4096-record playback limit. +#[test] +fn droplet_scoped_variants_keep_the_existing_action_limit() { + use crate::actions_cmds::Action; + let dir = tmp("droplet-scope-limit"); + let input = images(&dir, 1, 2, 1).remove(0); + for count in [4095, 4096] { + let mut maker = Session::new(); + maker.actions.list = (0..count).map(|i| Action::new(format!("A{i}"), Vec::new())).collect(); + let mut steps: Vec<_> = (0..count).map(|i| json!(["actions.play", {"action": i}])).collect(); + steps.push( + json!(["file.automate.batch", {"action": 0, "calledActions": [], "input": [input], "output": format!("{dir}/{count}-inner"), "format": "png"}]), + ); + let output = format!("{dir}/{count}-out"); + let path = maker + .execute("file.automate.createDroplet", json!({"path": format!("{dir}/{count}"), "steps": steps, "output": output, "format": "png", "shim": false})) + .unwrap()["path"] + .clone(); + let result = Session::new().execute("file.automate.runDroplet", json!({"droplet": path, "input": [input]})); + if count == 4095 { + assert_eq!(result.unwrap()["errors"], json!([])); + assert!(std::path::Path::new(&output).exists()); + } else { + assert!(result.unwrap_err().to_string().contains("4096")); + assert!(!std::path::Path::new(&output).exists()); + } + } +} + #[test] fn droplets_carry_the_actions_their_conditional_steps_call() { use crate::actions_cmds::Action; @@ -529,3 +780,60 @@ fn scripts_nested_deeper_than_the_limit_fail() { assert!(r.to_string().contains("nested more than"), "{r}"); assert_eq!(s.actions.script_depth, 0, "the depth is released"); } + +/// A droplet captures only the actions its enabled steps can call: a disabled Play step in a +/// called action does not add its target (nor count against the 4096-action limit). +#[test] +fn droplets_skip_actions_called_only_from_disabled_steps() { + let dir = tmp("droplet-disabled-calls"); + let mut s = Session::new(); + let mut top = crate::actions_cmds::Action::new( + "Top", + vec![("actions.play".into(), json!({"action": "Skipped"})), ("actions.play".into(), json!({"action": "Used"}))], + ); + top.disabled.insert(0); + let used = crate::actions_cmds::Action::new("Used", vec![("image.adjustments.invert".into(), json!({}))]); + let skipped = crate::actions_cmds::Action::new("Skipped", vec![("image.imageRotation.90cw".into(), json!({}))]); + s.actions.list = vec![skipped, top, used]; + let r = + s.execute("file.automate.createDroplet", json!({"path": format!("{dir}/D"), "steps": [["actions.play", {"action": "Top"}]], "shim": false})).unwrap(); + let droplet: Value = serde_json::from_slice(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap(); + let names: Vec<&str> = droplet["calledActions"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Top", "Used"], "{droplet}"); + // The droplet still runs on its own: the enabled call resolves, the disabled one never plays. + let inputs = images(&dir, 1, 3, 2); + let run = Session::new().execute("file.automate.runDroplet", json!({"droplet": r["path"], "input": inputs, "output": format!("{dir}/out")})).unwrap(); + assert_eq!(run["errors"], json!([]), "{run}"); + let out = photocraft_codecs::decode(&std::fs::read(format!("{dir}/out/img0.png")).unwrap()).unwrap(); + assert_eq!(out.dimensions(), (3, 2), "the disabled Rotate must not run"); +} + +/// A droplet saved with Override Open reads each dropped input through its recorded Open, +/// not the file the Open recorded. +#[test] +fn droplets_forward_override_open_to_batch() { + let dir = tmp("droplet-override-open"); + let inputs = images(&dir, 1, 6, 4); + let recorded = { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 3, "background": "#00ff00"})).unwrap(); + let path = format!("{dir}/recorded.png"); + crate::file_cmds::save_doc(&s.active().unwrap().doc, &path, None).unwrap(); + path + }; + for (override_open, size) in [(true, (6, 4)), (false, (2, 3))] { + let mut s = Session::new(); + let out = format!("{dir}/out-{override_open}"); + let r = s + .execute( + "file.automate.createDroplet", + json!({"path": format!("{dir}/D-{override_open}"), "steps": [["file.openAs", {"path": recorded, "as": "png"}]], + "format": "png", "output": out, "overrideOpen": override_open, "shim": false}), + ) + .unwrap(); + let run = Session::new().execute("file.automate.runDroplet", json!({"droplet": r["path"], "input": [inputs[0]]})).unwrap(); + assert_eq!(run["errors"], json!([]), "{run}"); + let image = photocraft_codecs::decode(&std::fs::read(format!("{out}/img0.png")).unwrap()).unwrap(); + assert_eq!(image.dimensions(), size, "overrideOpen {override_open}: {run}"); + } +} diff --git a/crates/engine/src/cmd/brush_cmds/tests.rs b/crates/engine/src/cmd/brush_cmds/tests.rs index c29d4c3146..f72a0e207b 100644 --- a/crates/engine/src/cmd/brush_cmds/tests.rs +++ b/crates/engine/src/cmd/brush_cmds/tests.rs @@ -26,8 +26,9 @@ fn stroke_backwards_compatible() { assert_eq!(r["damage"].as_array().unwrap().len(), 4); assert_eq!(rgba(&s, 30, 20), [0.0, 0.0, 1.0, 1.0]); assert_eq!(rgba(&s, 30, 30)[3], 0.0); - // [x, y, pressure] with the default pressure-size brush: light pressure → thinner line. - s.execute("paint.stroke", json!({"points": [[4, 5, 0.2], [60, 5, 0.2]], "size": 10})).unwrap(); + // [x, y, pressure] with Pressure for Size on: light pressure → a thinner line. The default + // brush has it off since #11809, so this stroke asks for it. + s.execute("paint.stroke", json!({"points": [[4, 5, 0.2], [60, 5, 0.2]], "size": 10, "brush": {"pressureSize": true}})).unwrap(); assert!(rgba(&s, 30, 5)[3] > 0.9 && rgba(&s, 30, 8)[3] < 0.01); } diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index e890a492f2..a860b50db5 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -54,9 +54,10 @@ fn can_split(s: &Session) -> std::result::Result<(), String> { Ok(()) } } +/// Merge Channels needs a document per colour channel: three at least (RGB and Lab; CMYK four). fn can_merge(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if mergeable(s).len() >= 2 { Ok(()) } else { Err("Merge Channels needs at least two open grayscale documents of the same size".into()) } + if mergeable(s).len() >= 3 { Ok(()) } else { Err("Merge Channels needs at least three open grayscale documents of the same size".into()) } } // --------------------------------------------------------------------------------------------- @@ -173,7 +174,7 @@ pub fn specs() -> Vec { "Calculations…", ["Image"], None, - r##"{"source1":{"document","layer","channel","invert"},"source2":{…},"blending":"multiply|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"mask":{…}?,"result":"newChannel|newDocument|selection"="newChannel","name":str?}"##, + r##"{"source1":{"document","layer","channel","invert"},"source2":{…},"blending":"multiply|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"mask":{…}?,"result":"newChannel|newDocument|selection"="newChannel","name":str?} (source channel: {"targeted":true} for one targeted colour/alpha, {"name":text} for a literal alpha name, {"color":text} for a colour component; source layer: {"name":text} for a literal layer name)"##, has_doc, calculations ), diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 5cedd21e31..a9552e6d62 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -26,6 +26,11 @@ pub fn parse_ref(v: &Value, doc: &Document) -> Option { match v { Value::Number(n) => n.as_u64().map(|i| ChanRef::Alpha(i as usize)), Value::Object(o) => { + if o.len() == 1 + && let Some(name) = o.get("name").and_then(Value::as_str) + { + return doc.channels.iter().position(|c| c.name == name).map(ChanRef::Alpha); + } if let Some(name) = o.get("color").and_then(Value::as_str) { let names = color_names(mode); let k = names.iter().position(|n| n.eq_ignore_ascii_case(name))?; @@ -91,7 +96,15 @@ pub(super) fn native_pixels(doc: &Document, layer: Option) -> Result<(V match layer { Some(id) => { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - let s = l.surface().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no pixels", l.name)))?; + let s = match l.surface() { + Some(s) => s, + None => { + if let LayerContent::Shape(shape) = &l.content { + return Ok((photocraft_vector::render_shape(shape, fmt, r).read_region(r), n)); + } + return Err(EngineError::Other(format!("layer \"{}\" has no pixels", l.name))); + } + }; if s.format() == fmt { return Ok((s.read_region(r), n)); } @@ -176,7 +189,7 @@ fn ref_planes(doc: &Document, layer: Option, active: Option, r .map(|p| p[3]) .collect(), ], - // Adjustment and fill layers cover the whole canvas. + // Groups, adjustment and fill layers cover the whole canvas. _ => vec![vec![1.0; area.width() as usize * area.height() as usize]], }, } @@ -231,9 +244,28 @@ pub(super) fn source(s: &Session, p: &Value, cmd: &str, gray: bool) -> Result ChanRef::Color(k), + super::view::ChannelTarget::Alpha(i) => ChanRef::Alpha(i), + super::view::ChannelTarget::Composite if color_count(&st.doc) == 1 => ChanRef::Composite, + _ => return Err(bad(cmd, "the source must target one colour or alpha channel")), + } + } else { + p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, &st.doc)).ok_or_else(|| bad(cmd, "unknown `channel`"))? + }; let layer = source_layer(&st.doc, p, cmd)?; - let mut planes = if gray { vec![gray_plane(&st.doc, layer, st.active_layer, r)?] } else { ref_planes(&st.doc, layer, st.active_layer, r)? }; + let mut planes = if r == ChanRef::Transparency && p.get("layer").is_some_and(|v| v == "merged") { + // An explicit merged transparency source reads all visible layers. An omitted layer + // retains the existing active-layer transparency behavior. + vec![photocraft_compose::render(&st.doc, st.doc.bounds()).px.iter().map(|p| p[3]).collect()] + } else if gray { + vec![gray_plane(&st.doc, layer, st.active_layer, r)?] + } else { + ref_planes(&st.doc, layer, st.active_layer, r)? + }; // Guard every caller (`.remove(0)` / `src[0]`) against an empty source. if planes.is_empty() { return Err(bad(cmd, "the source has no channels")); @@ -296,7 +328,11 @@ pub(crate) fn source_spec(p: &Value, key: &str, prefix: &str) -> Option { for (k, field) in [("document", "Document"), ("layer", "Layer"), ("channel", "Channel"), ("invert", "Invert")] { match p.get(format!("{prefix}{field}")) { None | Some(Value::Null) => {} - Some(Value::String(s)) if s.is_empty() || (k == "layer" && s == "merged") => {} + Some(Value::String(s)) + if s.is_empty() + || (k == "layer" + && s == "merged" + && !p.get(format!("{prefix}Channel")).and_then(Value::as_str).is_some_and(|channel| channel.eq_ignore_ascii_case("transparency"))) => {} Some(v) => { m.insert(k.into(), v.clone()); } diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index a640a7b45b..3a56aefc3b 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1186,6 +1186,25 @@ fn layers_panel_mask_target_isolates_the_curve_channel() { assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); } +/// An opened RGB or CMYK document shows its spot channels (they print over the composite) and +/// hides its alpha channels; a Multichannel document's inks are its composite, so their eyes stay off. +#[test] +fn opened_documents_show_their_spot_channels() { + use photocraft_color::{Color, ColorMode, PixelFormat}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Multichannel] { + let mut d = Document::new("spots", photocraft_doc::Size::new(4, 4), mode, SampleType::U8); + let fmt = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + d.channels.push(AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(fmt))); + d.channels.push(AlphaChannel { spot: Some((Color::rgb(0.0, 0.6, 0.65), 0.0)), ..AlphaChannel::new("teal", photocraft_raster::Surface::new(fmt)) }); + let mut s = Session::new(); + s.open_document(d, None); + let v = &s.active().unwrap().channel_view; + let spot_on = mode != ColorMode::Multichannel; + assert_eq!((v.alpha_shown(0), v.alpha_shown(1)), (false, spot_on), "{mode:?}"); + assert_eq!(v.visible_colors(color_count(&s.active().unwrap().doc)), color_count(&s.active().unwrap().doc), "{mode:?}: the composite stays on"); + } +} + /// Split Channels closes the original, so as in the reference application it splits only an /// image with a single layer: two layers (even with one hidden) or a lone group are refused and /// the document stays open with its layers; a flattened image or a lone normal layer still @@ -1219,3 +1238,21 @@ fn split_refuses_an_image_with_more_than_one_layer() { assert!(s.execute("channel.split", json!({})).is_err()); assert_eq!(s.documents().len(), 1); } + +/// Merge Channels is offered from three grayscale documents of one size, the fewest a mode +/// (RGB or Lab) merges; two never merge (#11451). +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut s = Session::new(); + for _ in 0..2 { + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + } + s.execute("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap(); + s.set_active(0); + let why = s.disabled_reason("channel.merge").expect("two of one size are not enough"); + assert!(why.contains("three"), "{why}"); + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + assert!(s.is_enabled("channel.merge")); + s.execute("channel.merge", json!({"mode": "lab"})).unwrap(); + assert_eq!(s.documents().len(), 2, "the three of one size merged; the other is left"); +} diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index 5fb2d7d0f2..c7c7942d0b 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, Mutex, Weak}; use photocraft_cms::{Builtin, ColorSpace, GamutCheck, Intent, Lut3d, Profile, SampleKind, Transform}; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{DocId, Document, Duotone, Effect, Fill, FxPaint, Layer, LayerContent}; +use photocraft_doc::{DocId, Document, Duotone, Effect, Fill, FxPaint, Layer, LayerContent, TextStyles}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -397,16 +397,16 @@ impl ColorState { pub fn display_transform_for(&self, doc: &Document, display: Option) -> Result> { let src = self.canvas_display_for(doc, display)?.source.clone(); let dst = self.monitor_for(display); - let pv = self.proof(doc.id); - let proof = pv.enabled.then(|| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); + // Borrowed: the canvas asks per frame, and the default proof state allocates a profile. + let pv = self.proof_ref(doc.id).filter(|pv| pv.enabled); + let proof = pv.map(|pv| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); let key = (src.content_hash(), dst.content_hash(), proof); if let Some(t) = self.display_cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return Ok(t.clone()); } - let t = if pv.enabled { - Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper) - } else { - Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC) + let t = match pv { + Some(pv) => Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper), + None => Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC), } .map_err(cms_err)?; let t = Arc::new(t); @@ -763,6 +763,26 @@ fn convert_layer_colors(l: &mut Layer, cc: &mut dyn FnMut(&mut Color)) { } } +/// Applies `cc` to every colour a type style sets (character styles, paragraph styles and Basic +/// Paragraph), so type converted with its document still matches its styles instead of showing +/// a local override. +fn convert_style_colors(st: &mut TextStyles, cc: &mut dyn FnMut(&mut Color)) { + let sets = st + .character + .iter_mut() + .map(|c| &mut c.attrs) + .chain(st.paragraph.iter_mut().chain([&mut st.basic]).flat_map(|p| [&mut p.para_attrs, &mut p.char_attrs])); + for attrs in sets { + for v in attrs.values_mut().filter(|v| v.is_object()) { + let Ok(mut c) = serde_json::from_value::(v.clone()) else { continue }; + cc(&mut c); + if let Ok(n) = serde_json::to_value(c) { + *v = n; + } + } + } +} + fn paint_colors(p: &mut FxPaint, cc: &mut dyn FnMut(&mut Color)) { match p { FxPaint::Color(c) => cc(c), @@ -793,6 +813,7 @@ pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc: convert_layer_colors(l, &mut |c| convert_color(c, from_mode, to_mode, &t)); // Preserved PSD blocks describing colours in the old mode no longer apply. } + convert_style_colors(&mut doc.text_styles, &mut |c| convert_color(c, from_mode, to_mode, &t)); doc.mode = to_mode; doc.icc_profile = Some(dst.to_bytes()); // Leaving Indexed / Duotone: the palette and inks no longer apply. @@ -819,6 +840,12 @@ fn ink_duotone(doc: &mut Document) { } }); } + convert_style_colors(&mut doc.text_styles, &mut |c| { + if matches!(c.mode, ColorMode::Grayscale | ColorMode::Duotone) { + let [r, g, b] = d.render(c.c[0]); + *c = Color::rgba(r, g, b, c.alpha); + } + }); doc.mode = ColorMode::Rgb; doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); } diff --git a/crates/engine/src/cmd/color_cmds/profile_dither.rs b/crates/engine/src/cmd/color_cmds/profile_dither.rs index 4bc5854ac0..9f368484a0 100644 --- a/crates/engine/src/cmd/color_cmds/profile_dither.rs +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -8,7 +8,7 @@ use rayon::prelude::*; use super::{ ColorMode, Document, EngineError, Intent, PixelFormat, Profile, Result, SampleType, Surface, Transform, cms_err, convert_color, convert_document, - convert_layer_colors, convert_surface, document_profile, space_mode, + convert_layer_colors, convert_style_colors, convert_surface, document_profile, space_mode, }; const TILE_PIXELS: u128 = (TILE_SIZE * TILE_SIZE) as u128; @@ -197,6 +197,7 @@ pub(super) fn convert(doc: &mut Document, dst: &Profile, intent: Intent, bpc: bo for layer in &mut doc.layers { convert_layer_colors(layer, &mut |c| convert_color(c, from, to_mode, &t)); } + convert_style_colors(&mut doc.text_styles, &mut |c| convert_color(c, from, to_mode, &t)); doc.mode = to_mode; doc.icc_profile = Some(dst.to_bytes()); doc.color_table = None; diff --git a/crates/engine/src/cmd/count_delete_group_tests.rs b/crates/engine/src/cmd/count_delete_group_tests.rs new file mode 100644 index 0000000000..bdabca1c73 --- /dev/null +++ b/crates/engine/src/cmd/count_delete_group_tests.rs @@ -0,0 +1,61 @@ +//! #11771: deleting the current count group makes the first remaining group current. Deleting +//! another group by index keeps the current one (#5654), and the last group goes without error. + +use serde_json::{Value, json}; + +use crate::Session; + +/// Three groups, each with one marker, and the second one current. +fn three_groups() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + for (i, name) in ["One", "Two", "Three"].iter().enumerate() { + s.execute("count.newGroup", json!({"name": name})).unwrap(); + s.execute("count.add", json!({"x": 10 + 10 * i, "y": 10})).unwrap(); + } + s.execute("count.setGroup", json!({"group": 1})).unwrap(); + assert_eq!(current(&mut s), (1, "Two".to_string())); + s +} + +/// The current group's index and name. +fn current(s: &mut Session) -> (u64, String) { + let c = s.execute("image.analysis.countTool", json!({})).unwrap(); + let i = c["activeGroup"].as_u64().unwrap(); + let name = c["groups"].get(i as usize).and_then(|g| g["name"].as_str()).unwrap_or_default().to_string(); + (i, name) +} + +fn groups(s: &mut Session) -> usize { + s.execute("image.analysis.countTool", json!({})).unwrap()["groups"].as_array().map_or(0, Vec::len) +} + +#[test] +fn deleting_the_current_group_makes_the_first_one_current() { + for params in [json!({}), json!({"group": 1})] { + let mut s = three_groups(); + s.execute("count.deleteGroup", params.clone()).unwrap(); + assert_eq!(groups(&mut s), 2, "{params}"); + assert_eq!(current(&mut s), (0, "One".to_string()), "{params}"); + } +} + +#[test] +fn deleting_another_group_keeps_the_current_one() { + let mut s = three_groups(); + s.execute("count.deleteGroup", json!({"group": 2})).unwrap(); + assert_eq!(current(&mut s), (1, "Two".to_string())); + let mut s = three_groups(); + s.execute("count.deleteGroup", json!({"group": 0})).unwrap(); + assert_eq!(current(&mut s), (0, "Two".to_string())); +} + +#[test] +fn deleting_the_last_group_leaves_none() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("count.newGroup", json!({"name": "Only"})).unwrap(); + s.execute("count.deleteGroup", json!({})).unwrap(); + let c: Value = s.execute("image.analysis.countTool", json!({})).unwrap(); + assert_eq!(c["groups"].as_array().map(Vec::len), Some(0)); +} diff --git a/crates/engine/src/cmd/cutout_cmds.rs b/crates/engine/src/cmd/cutout_cmds.rs index 3984bc27a8..971519254a 100644 --- a/crates/engine/src/cmd/cutout_cmds.rs +++ b/crates/engine/src/cmd/cutout_cmds.rs @@ -63,25 +63,25 @@ fn run(s: &mut Session, p: &Value) -> Result { let found = ctx .stage(0.1, 0.6, "Finding subject", |ctl| with_doc_sampler(doc, Some(id), sample_all, |smp, _| subject::select_subject_with(smp, area, ctl))) .map_err(|_| EngineError::Cancelled)?; - let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; + let region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; + let mut surface = matting::region_surface(®ion); if ctx.cancelled() { return Err(EngineError::Cancelled); } if refine { - ctx.progress(0.6, "Refining edge"); - let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| { - matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, area, &REFINE) + let refined = ctx.stage(0.6, 0.95, "Refining edge", |interrupt| { + with_doc_sampler(doc, Some(id), sample_all, |smp, _| matting::refine_surface_with_interrupt(smp, &surface, area, &REFINE, None, interrupt)) }); - region = refined.unwrap_or(region); + surface = refined.map_err(|e| EngineError::Other(e.to_string()))?.unwrap_or(surface); if ctx.cancelled() { return Err(EngineError::Cancelled); } } crate::extra_cmds::background_to_layer_for_mask(doc, id); - let mask = crate::commands::mask_at_depth(LayerMask { surface: matting::region_surface(®ion), ..LayerMask::reveal_all() }, doc.depth)?; + let mask = crate::commands::mask_at_depth(LayerMask { surface: surface.clone(), ..LayerMask::reveal_all() }, doc.depth)?; doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); doc.selection = None; - let b = region.bbox; + let b = surface.content_bounds(); Ok([b.x0, b.y0, b.width() as i32, b.height() as i32]) }, move |bounds| json!({ "layer": id.0, "bounds": bounds }), diff --git a/crates/engine/src/cmd/distort_cmds.rs b/crates/engine/src/cmd/distort_cmds.rs index 156095288f..6eb7949c57 100644 --- a/crates/engine/src/cmd/distort_cmds.rs +++ b/crates/engine/src/cmd/distort_cmds.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell}; use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; -use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask}; +use photocraft_algo::puppet::{PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask, warp_with_mesh}; use photocraft_algo::transform::Interp; use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; use photocraft_color::PixelFormat; @@ -86,6 +86,51 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result { Ok(PuppetWarp { pins, mode, density, expansion }) } +/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds); the +/// PSD importer reads no more. +const MAX_STORED_VERTS: usize = 1 << 16; + +/// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts", +/// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of +/// solving again. `None` when there is none, it is malformed, or the filter was edited since: its +/// pins are no longer the ones it was solved for, or a solver setting is set (imported params +/// set none). +pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> { + let m = p.get("mesh")?; + if m.get("pins") != p.get("pins") || ["mode", "density", "expansion"].iter().any(|k| p.get(*k).is_some()) { + return None; + } + let points = |k: &str| -> Option> { + let v = m.get(k)?.as_array()?; + if v.len() > MAX_STORED_VERTS { + return None; + } + v.iter().map(|q| serde_json::from_value::<[f64; 2]>(q.clone()).ok()).collect() + }; + let (verts, deformed) = (points("verts")?, points("deformed")?); + let tris = m.get("tris")?.as_array()?; + if tris.len() > 2 * MAX_STORED_VERTS { + return None; + } + let tris: Vec<[usize; 3]> = tris.iter().map(|t| serde_json::from_value(t.clone()).ok()).collect::>()?; + Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed)) +} + +/// `params` of smart filter `command` on `sm`, without a stored Puppet Warp mesh made for another +/// placement than `sm`'s (`mesh.placement`, recorded on import): a mesh in document pixels doesn't +/// fit a smart object moved, transformed or warped since, so its pins are solved instead. +pub fn placed_puppet_params<'a>(command: &str, params: &'a Value, sm: &photocraft_doc::SmartObject) -> std::borrow::Cow<'a, Value> { + let stale = command == PUPPET_SMART && params.get("mesh").is_some_and(|m| m.get("placement") != Some(&sm.placement_json())); + if !stale { + return std::borrow::Cow::Borrowed(params); + } + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("mesh"); + } + std::borrow::Cow::Owned(p) +} + /// Perspective Warp params (planes, after the optional straighten). pub fn perspective_params(cmd: &str, p: &Value) -> Result> { let mut planes: Vec = match p.get("planes") { @@ -182,7 +227,17 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect) Some(liquify_surface(surf, &strokes, cell, canvas)) } // A stored warp too far to render leaves the smart object as it is (#1019). - PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())), + PUPPET | PUPPET_SMART => { + // A mesh stored with the filter (from a Photoshop file) is drawn as it is; one that + // can't be drawn leaves the pins to solve. + let b = surf.content_bounds(); + if let Some((mesh, deformed)) = stored_puppet_mesh(params).filter(|_| !b.is_empty()) + && let Some(out) = warp_with_mesh(surf, b, &mesh, &deformed, interp(params)) + { + return Some(out); + } + Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())) + } PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))), _ => None, } @@ -407,7 +462,7 @@ pub fn specs() -> Vec { CommandSpec { id: PUPPET_SMART, label: "Puppet Warp", - menu: &["Layer", "Smart Objects"], + menu: &[], shortcut: None, params: P, enabled: smart_layer, @@ -427,7 +482,7 @@ pub fn specs() -> Vec { CommandSpec { id: PERSPECTIVE_SMART, label: "Perspective Warp", - menu: &["Layer", "Smart Objects"], + menu: &[], shortcut: None, params: Q, enabled: smart_layer, diff --git a/crates/engine/src/cmd/distort_cmds/tests.rs b/crates/engine/src/cmd/distort_cmds/tests.rs index 81d17c28c2..851b785814 100644 --- a/crates/engine/src/cmd/distort_cmds/tests.rs +++ b/crates/engine/src/cmd/distort_cmds/tests.rs @@ -473,3 +473,71 @@ fn unlocked_or_position_locked_smart_objects_take_distortions() { } } } + +#[test] +fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { + // A Photoshop file's Puppet Warp carries its solved mesh: it is drawn as it is (here a whole + // move by (2, 1), although one pin alone would move the block by (6, 4)), until the pins + // change; then the pins are solved. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]}); + let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy); + let p = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41)); + assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0); + // Pins edited after import: the stored mesh no longer applies. + let mut edited = p.clone(); + edited["mesh"]["pins"][0]["dst"] = json!([40, 29]); + let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + // So does a solver setting set after import (imported params set none). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut q = p.clone(); + q[k] = v; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44), "{k}"); + } + // A malformed mesh is ignored the same way. + for bad in [ + json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}), + json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 9]]}), + ] { + let mut q = p.clone(); + q["mesh"] = bad; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + } +} + +#[test] +fn a_stored_puppet_mesh_is_drawn_only_on_the_placement_it_was_made_for() { + // The mesh sits in document pixels where the layer was on import (`mesh.placement`); once the + // smart object is moved, transformed or warped, its pins are solved instead. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let src = photocraft_doc::SmartSource::Linked { path: "x.png".into() }; + let mut sm = photocraft_doc::SmartObject::new(src, photocraft_geom::Affine::IDENTITY, None); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]], "placement": sm.placement_json()}); + let params = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + sm.smart_filters.push(SmartFilter { command: PUPPET_SMART.into(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }); + let render = |sm: &photocraft_doc::SmartObject| crate::smart_cmds::apply_smart_filters(&surf, sm, canvas).unwrap().content_bounds(); + assert_eq!(render(&sm), Rect::new(12, 11, 62, 41), "the stored mesh"); + let mut placed = sm.clone(); + placed.transform = photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] }; + assert_eq!(render(&placed), Rect::new(16, 14, 66, 44), "moved: solved"); + let mut warped = sm.clone(); + warped.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 50.0, 30.0])); + assert_eq!(render(&warped), Rect::new(16, 14, 66, 44), "warped: solved"); + // A mesh with no placement recorded is treated the same way. + let mut bare = sm.clone(); + bare.smart_filters[0].params["mesh"].as_object_mut().unwrap().remove("placement"); + assert_eq!(render(&bare), Rect::new(16, 14, 66, 44), "no placement: solved"); +} diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index fb3cc944f6..dbf1a9a6f1 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -38,6 +38,10 @@ pub struct Clip { /// The document the pixels were copied from: Paste keeps them in place only there, and /// centres them in any other (#8945). None for an image from another app. pub source: Option, + /// The artboard the pixels were copied from (its id and rect at the time), when they came + /// from one: Paste into another board of the same document keeps their place on the board + /// ([`board_shift`], #8788). None outside boards, across several, or for a copied board. + pub board: Option<(LayerId, Rect)>, } impl Clip { @@ -65,6 +69,7 @@ fn scratch_doc(doc: &Document) -> Document { copies.icc_profile = doc.icc_profile.clone(); copies.resolution_dpi = doc.resolution_dpi; copies.patterns = doc.patterns.clone(); + copies.text_styles = doc.text_styles.clone(); copies } @@ -209,7 +214,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Ok(Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }); + return Ok(Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None, board: None }); } // Only the selected area is read: converting the whole layer (a 24 MP Background gaining // alpha) cost about a second per Layer via Copy (#668). @@ -231,7 +236,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { out.put_tiles(shared); out.prune(); let bounds = out.content_bounds(); - Ok(Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None }) + Ok(Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None, board: None }) } /// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection @@ -313,11 +318,97 @@ fn copy(s: &mut Session, merged: bool) -> Result { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } let b = clip.bounds; - let clip = Clip { resolution: Some(d.doc.resolution_dpi), source: Some(d.doc.id), ..clip }; + let board = source_board(d, merged, b); + let clip = Clip { resolution: Some(d.doc.resolution_dpi), source: Some(d.doc.id), board, ..clip }; s.clipboard = Some(clip); Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]})) } +/// The artboard a Copy took its pixels from (#8788): the board the copied layers sit in (the +/// layers Copy actually takes: every selected layer only when it copies them whole, +/// [`multi_copy_ids`], else the active one), or for Copy Merged the board under the middle of the +/// copied area. None outside boards, when the copied layers span several, or when a board itself +/// was copied (a pasted board is placed as a board, not on another one). +fn source_board(d: &crate::DocState, merged: bool, bounds: Rect) -> Option<(LayerId, Rect)> { + let board = if merged { + // The same middle a pasted layer is placed by, so a clip leaves and joins boards alike. + let (cx, cy) = crate::layer_place::centre(bounds); + d.doc.artboards().into_iter().rev().find(|(_, _, b)| b.rect.contains(cx, cy)).map(|(id, _, _)| id)? + } else { + let several = multi_copy_ids(d); + let ids = if several.is_empty() { vec![d.active_layer?] } else { several }; + let mut boards = ids.iter().map(|id| d.doc.layer(*id).filter(|l| l.artboard().is_none()).and_then(|_| d.doc.artboard_of(*id))); + let first = boards.next()??; + if !boards.all(|b| b == Some(first)) { + return None; + } + first + }; + Some((board, d.doc.layer(board)?.artboard()?.rect)) +} + +/// Paste into another artboard than the one the pixels were copied from (#8788): they keep +/// their place on the board, moved by the offset between the two boards' origins, as a layer +/// dragged between boards does. Copying a design from one board and pasting it with another +/// board's layer selected puts it at the same spot there, instead of where it was on the first +/// board (outside the second, which hid it) or in the middle of the view. Only within one +/// document, and not for Paste Into / Outside (`"clampToCanvas": false`), which centre on the +/// selection. `active` is the layer the paste lands beside; the board is its board. Returns the +/// shift and that board, which the pasted layer then joins ([`join_board`]). +pub(crate) fn board_shift(clip: &Clip, doc: &Document, active: Option, p: &Value, new_layer: bool) -> Option<(i32, i32, LayerId)> { + if p.get("clampToCanvas").and_then(Value::as_bool) == Some(false) || clip.source != Some(doc.id) { + return None; + } + let (from, src) = clip.board?; + let active = active?; + let to = doc.artboard_of(active)?; + if to == from { + return None; + } + // Pasted with a locked board itself selected, the layer goes above the board, not into it + // (`layer_place`'s rule for layers edited right after they are made), so it keeps its usual + // place instead of one on a board it isn't on. A layer inside a locked board is placed by its + // own rules and still lands on the board. A paste into the board's own mask (`new_layer` + // false) adds no layer and mask edits don't check position locks, so it always takes the + // board's shift. + let board = doc.layer(to)?; + if new_layer && active == to && !takes_pastes(board) { + return None; + } + let dst = board.artboard()?.rect; + // Boards more than the 32-bit coordinate range apart (#10538 review): the pixels can't be + // moved that far, so the paste keeps its usual placement (in place for Paste in Place) + // rather than wrapping around to an unrelated spot. + Some((dst.x0.checked_sub(src.x0)?, dst.y0.checked_sub(src.y0)?, to)) +} + +/// Does `board` take a pasted layer into it? Not with a lock that refuses editing it +/// (`layer_place`'s rule for layers edited right after they are made). +fn takes_pastes(board: &Layer) -> bool { + let k = board.locks; + !(k.all || k.pixels || k.transparency || k.position) +} + +/// Puts pasted layer `id` on `board`, on top of its stack, when placement left it outside. A paste +/// moved onto a board belongs to it even when its middle falls past a smaller board's edge; the +/// board clips what lies beyond, as it clips anything on it (#10538 review). `clipped` is the +/// copy's own clipping flag (the one it had before placement, which set it for the slot it left): +/// on top of the board's stack it keeps it while a layer below can be its base, as a new layer +/// does ([`crate::layer_place::insert_new`]), and drops it on an empty board. +fn join_board(doc: &mut Document, id: LayerId, board: LayerId, clipped: bool) { + if doc.artboard_of(id) == Some(board) || !doc.layer(board).is_some_and(takes_pastes) { + return; + } + let Some(mut layer) = doc.remove(id) else { return }; + match doc.layer_mut(board).and_then(Layer::children_mut) { + Some(children) => { + layer.clipped = clipped && !children.is_empty(); + children.push(layer); + } + None => doc.layers.push(layer), + } +} + /// Copy the targeted layer mask (`"target":"mask"`, #2928) or smart-filter mask /// (`"target":"filterMask"`): the mask's values as a /// grayscale clip, inside the selection or the whole canvas — an untouched mask copies at its @@ -360,7 +451,7 @@ fn copy_mask(s: &mut Session, filter: bool) -> Result { } let b = bounds; let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id)); - s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: b, layers: None, resolution, source }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: b, layers: None, resolution, source, board: None }); Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }})) } @@ -428,11 +519,13 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { } let canvas = d.doc.bounds(); let fmt = d.doc.pixel_format(); - let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place); + let shift = board_shift(&clip, &d.doc, d.active_layer, p, true); + let board = shift.map(|(_, _, b)| b); + let (dx, dy) = shift.map(|(dx, dy, _)| (dx, dy)).unwrap_or_else(|| paste_offset(&clip, d.doc.id, canvas, p, in_place)); if mode.is_none() && let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) { - return paste_layers(s, layers, dx, dy); + return paste_layers(s, layers, dx, dy, board); } let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); @@ -441,6 +534,10 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::AutoEditable); + if let Some(b) = board { + // Pixels pasted as a new layer start unclipped. + join_board(doc, id, b, false); + } *active = Some(id); doc.selection = None; Ok(id) @@ -451,8 +548,9 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { /// Paste of a whole copied layer ([`whole_layer`]): fresh copies (a group's children too), /// converted to the document's colour, moved as the pixels would be, above the active layer. A pasted smart object gets its -/// own contents, independent of the copied one (as across documents). -fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result { +/// own contents, independent of the copied one (as across documents). Moved onto `board` (see +/// [`board_shift`]), they join it. +fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32, board: Option) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let mut copies = layers.clone(); for l in &mut copies.layers { @@ -479,15 +577,27 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< linked.push((*signature, *key, missing)); } } + let styles = std::mem::take(&mut copies.text_styles); let id = s.edit("Paste", |doc, active| { doc.patterns.extend(patterns); + crate::layer_copy_cmds::land_text_styles(doc, styles); doc.metadata.psd_global_blocks.extend(linked); // The first pasted layer goes where a new layer would; the rest stack above it. let mut place = crate::layer_place::Place::AutoEditable; + let mut pasted = Vec::with_capacity(copies.layers.len()); for l in copies.layers { - *active = Some(crate::layer_place::insert_new(doc, *active, l, place)); + let clipped = l.clipped; + let id = crate::layer_place::insert_new(doc, *active, l, place); + pasted.push((id, clipped)); + *active = Some(id); place = crate::layer_place::Place::Above; } + // Bottom to top, so they keep their order on the board. + if let Some(b) = board { + for (id, clipped) in pasted { + join_board(doc, id, b, clipped); + } + } crate::cmd::layer_multi_cmds::check_group_depth(doc, "Paste")?; doc.selection = None; active.ok_or(EngineError::Other("nothing to paste".into())) @@ -534,8 +644,8 @@ pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { } /// Paste into the targeted layer mask, alpha channel or Quick Mask (`"target"`) instead of as a -/// new layer (Photoshop, #1035): the pasted pixels' luminosity, placed as a paste places them, -/// over what was there. Their transparency, and `limit` (Paste Into / Outside), let it show +/// new layer (Photoshop, #1035): the pasted pixels' luminosity, placed as a paste places them +/// (into a mask on another artboard too), over what was there. Their transparency, and `limit` (Paste Into / Outside), let it show /// through. One history step. pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: Option<&Surface>, label: &str) -> Result { crate::allocation::checkpoint("pasting pixels")?; @@ -543,7 +653,13 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); let layer = d.active_layer; - let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place); + // Into a layer's mask on another board of this document, the pixels keep their place on the + // board, as a layer paste beside the same layer does ([`board_shift`]). An alpha channel or + // the Quick Mask covers the whole document, on no board, so the active layer's board doesn't + // move what goes into it. + let shift = if crate::channel_cmds::is_channel_target(p) { None } else { board_shift(&clip, &d.doc, layer, p, false) }; + let shift = shift.map(|(dx, dy, _)| (dx, dy)); + let (dx, dy) = shift.unwrap_or_else(|| paste_offset(&clip, d.doc.id, canvas, p, in_place)); let src = shifted(&clip.surface, dx, dy); let area = src.content_bounds().intersect(&canvas); s.edit(label, |doc, _| { @@ -610,6 +726,8 @@ fn layer_via(s: &mut Session, cut: bool) -> Result { dup.name = doc.copy_name(&src.name); dup.locks = Default::default(); let nid = doc.insert_above(Some(id), dup); + // A lone copy joins its original's link group, as in the reference application (#11217). + crate::layer_multi_cmds::relink_copies(doc, &[nid])?; *active = Some(nid); Ok(nid) })?; @@ -871,12 +989,16 @@ fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result> let px = match (v.as_f64(), v.as_str()) { (Some(n), _) => n, (None, Some(text)) => { - let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| { - EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %")) - })?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let extent = if vertical { doc.size.width } else { doc.size.height }; - unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch()) + let per_inch = s.prefs().units_and_rulers.point_size.per_inch(); + // A length, or arithmetic on lengths as Photoshop's field takes ("100% / 3", #10440). + let to_px = |n: f64, unit: crate::prefs::Unit| unit.to_px(n, doc.resolution_dpi as f64, extent as f64, per_inch); + crate::prefs::Unit::eval_length(text, crate::prefs::Unit::Pixels, to_px).ok_or_else(|| { + EngineError::Other(format!( + "`position` {text:?} is not a length: give a number of pixels, a number with px, in, cm, mm, pt, pc or %, or arithmetic on them (+ - * /)" + )) + })? } _ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())), }; diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs index 58b92ae779..089a9873ba 100644 --- a/crates/engine/src/cmd/edit_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_cmds/tests.rs @@ -17,7 +17,16 @@ fn new_clipboard_document_rejects_an_unrepresentable_origin() { let mut s = Session::new(); let mut surface = Surface::new(PixelFormat::RGBA8); surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]); - s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None }); + s.clipboard = Some(Clip { + svg: None, + components: None, + surface, + bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), + layers: None, + resolution: None, + source: None, + board: None, + }); let result = new_from_clipboard(&mut s); assert!(result.unwrap_err().to_string().contains("clipboard origin")); assert!(s.execute("file.newFromClipboard", json!({})).is_err()); @@ -58,6 +67,7 @@ fn paste_centers_clipboard_bounds_near_coordinate_limits() { layers: None, resolution: None, source: None, + board: None, }; let (dx, dy) = paste_offset(&clip, DocId(1), Rect::new(0, 0, 20, 20), &json!({}), false); assert_eq!(i64::from(origin) + i64::from(dx), 9); @@ -240,7 +250,8 @@ fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session { s.execute("file.new", json!({"width": w, "height": h})).unwrap(); let mut surface = Surface::new(PixelFormat::RGBA8); surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]); - s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None }); + s.clipboard = + Some(Clip { svg: None, components: None, surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None, board: None }); s } @@ -304,6 +315,7 @@ fn paste_offset_clamp_survives_extreme_coordinates() { layers: None, resolution: None, source: None, + board: None, }; let (dx, dy) = paste_offset(&clip, DocId(1), Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false); assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping"); @@ -1280,6 +1292,56 @@ fn guide_positions_take_photoshop_units() { assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0); } +/// #10440: the position takes arithmetic as Photoshop's New Guide field does. Measured in +/// Photoshop 2025 on a 600 × 400, 72 ppi document: "100% / 3" → 200, "100% - 50px" → 550, +/// "300 / 2" → 150 (pixels), "1in + 10" → 792 (the bare 10 is in the first operand's unit). +#[test] +fn guide_positions_take_arithmetic() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + for (text, px) in [ + ("100% / 3", 200.0), + ("100% - 50px", 550.0), + ("300 / 2", 150.0), + ("1in + 10", 792.0), + ("2 * 50 px", 100.0), + ("10 + 2 * 3", 16.0), + ("-50px + 100", 50.0), + ] { + assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}"); + } + // Horizontal guides take percent of the height. + assert!((new_guide(&mut s, "horizontal", json!("50% + 10px")) - 210.0).abs() < 1e-6); + for bad in ["100% /", "1in * 2in", "10 / 0", "10 / 2px", "5 ^ 2", "+", "50% 3"] { + let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{bad}: {err}"); + } +} + +/// The Unicode minus (U+2212, what some keyboards and pasted text give) is a minus, as in the +/// numeric fields' own evaluator. +#[test] +fn guide_position_arithmetic_takes_the_unicode_minus() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + for (text, px) in [("100% − 50px", 550.0), ("−50px + 100", 50.0), ("1in − −10", 792.0), ("−100", -100.0)] { + assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}"); + } +} + +/// Text longer than the numeric fields' 1024-byte cap is refused before it is tokenised. +#[test] +fn guide_position_arithmetic_is_capped_at_1024_bytes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + let fits = format!("{}100", "0 + ".repeat(255)); + assert_eq!(fits.len(), 1023); + assert_eq!(new_guide(&mut s, "vertical", json!(fits)), 100.0); + let long = format!("{}100", "0 + ".repeat(256)); + let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": long})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{err}"); +} + #[test] fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() { let mut s = Session::new(); @@ -1369,6 +1431,26 @@ fn editing_a_guide_changes_what_is_given_in_one_undoable_step() { assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff])); } +/// #10440: Edit Selected Guides reads its position as New Guide does, arithmetic included; % is of +/// the new orientation's extent. +#[test] +fn editing_a_guide_takes_arithmetic_in_its_position() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "100% / 4"})).unwrap(); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "1in + 1"})).unwrap(); + assert_eq!(s.active().unwrap().doc.guides.vertical, vec![100.0, 200.0]); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "newOrientation": "horizontal", "position": "50% − 10px"})).unwrap(); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![200.0], vec![90.0])); + let before = g.clone(); + let err = s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "1in * 2in"})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{err}"); + assert_eq!(s.active().unwrap().doc.guides, before); +} + #[test] fn bad_guide_edits_are_refused_without_a_history_step() { let mut s = Session::new(); @@ -1453,13 +1535,13 @@ fn lift_reference(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }; + return Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None, board: None }; } let px = lift_region(src, sel, area, with_alpha.channels()).unwrap(); out.try_write_region(area, &px).unwrap(); out.prune(); let bounds = out.content_bounds(); - Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None } + Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None, board: None } } fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface { @@ -1526,3 +1608,372 @@ fn copy_shares_whole_tiles_with_the_layer() { assert_eq!(shared, src.tile_count(), "every tile is shared"); } } + +/// Board "A" at (10, 10, 50, 50) holding a red square at (10..30)², and an empty board "B" at +/// (200, 20, 240, 60), B active. +fn two_boards() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 80})).unwrap(); + let red = s.execute("layer.new.layer", json!({"name": "Red"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(red).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let a = s.execute("layer.new.artboardFromLayers", json!({"name": "A"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.artboard.set", json!({"width": 40, "height": 40})).unwrap(); + let b = s.execute("layer.new.artboard", json!({"rect": [200, 20, 40, 40], "name": "B"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + (s, red, a, b) +} + +fn board_of_active(s: &Session) -> Option { + let st = s.active().unwrap(); + st.doc.artboard_of(st.active_layer.unwrap()) +} + +/// #8788: pixels copied on one artboard and pasted with another board (or a layer on it) selected +/// land at the same spot on that board, inside it, instead of at their old spot on the first board +/// (where the second board hid them) or in the middle of the view. Paste and Paste in Place alike. +#[test] +fn paste_into_another_artboard_keeps_the_place_on_the_board() { + for (cmd, params) in [("edit.paste", json!({})), ("edit.paste", json!({"center": [500.0, 500.0]})), ("edit.pasteSpecial.pasteInPlace", json!({}))] { + let (mut s, red, a, b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + let r = s.execute(cmd, params.clone()).unwrap(); + assert_eq!(r["offset"], json!([190, 10]), "{cmd} {params}: moved by B's origin minus A's"); + assert_eq!(board_of_active(&s), Some(b), "{cmd} {params}: the pasted layer is on board B"); + assert_eq!(active_bounds(&s), Rect::new(200, 20, 220, 40), "{cmd} {params}: the same spot on B"); + // The original stays on A. + assert_eq!(s.active().unwrap().doc.artboard_of(red), Some(a)); + } +} + +/// Paste within the same board, or with no board on the paste side, behaves as before; so does +/// Paste Into, which centres on the selection. +#[test] +fn paste_on_the_same_artboard_or_off_the_boards_is_unchanged() { + let (mut s, red, a, _b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + // Same board: in place. + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["offset"], json!([0, 0])); + assert_eq!(board_of_active(&s), Some(a)); + // The Background is on no board: the usual rules (in place, here). + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + assert_eq!(s.execute("edit.paste", json!({})).unwrap()["offset"], json!([0, 0])); + // Paste Into's `clampToCanvas: false` keeps its own centring. + let clip = s.clipboard.clone().unwrap(); + let doc = &s.active().unwrap().doc; + let on_b = doc.artboards().iter().find(|x| x.1 == "B").map(|x| x.0); + let inside = doc.layer(on_b.unwrap()).unwrap().id; + assert!(board_shift(&clip, doc, Some(inside), &json!({"clampToCanvas": false}), true).is_none()); + assert!(board_shift(&clip, doc, Some(inside), &json!({}), true).is_some()); +} + +/// A copied layer taken whole (a group here) is moved onto the other board the same way. +#[test] +fn a_whole_layer_pasted_into_another_artboard_keeps_its_place() { + let (mut s, red, _a, b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + let g = s.execute("layer.groupLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.select", json!({"layer": g.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.clipboard.as_ref().unwrap().layers.is_some(), "a group is copied whole"); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let st = s.active().unwrap(); + let pasted = st.active_layer.unwrap(); + assert_eq!(st.doc.artboard_of(pasted), Some(b)); + let inner = st.doc.layer(pasted).unwrap().children().unwrap()[0].surface().unwrap().content_bounds(); + assert_eq!(inner, Rect::new(200, 20, 220, 40)); +} + +/// Copy Merged takes the board under the middle of the copied area; a copied board itself, or a +/// selection of layers on two boards, has no source board. +#[test] +fn the_source_board_of_merged_board_and_mixed_copies() { + let (mut s, red, a, b) = two_boards(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.copyMerged", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board.map(|x| x.0), Some(a)); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board, None, "a copied board"); + let on_b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": on_b.0, "target": b.0, "position": "into"})).unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(on_b).unwrap().surface_mut().unwrap().fill_rect(Rect::new(205, 25, 210, 30), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("layer.select", json!({"layer": on_b.0, "mode": "add"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board, None, "layers on two boards"); +} + +/// With a pixel selection, Copy takes the active layer only, even with several layers selected, +/// so its source board is the active layer's (#10538 review): other selected layers on another +/// board don't cancel it. +#[test] +fn a_selection_copy_takes_the_active_layers_board_whatever_else_is_selected() { + let (mut s, red, a, b) = two_boards(); + let on_b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": on_b.0, "target": b.0, "position": "into"})).unwrap(); + s.execute("layer.select", json!({"layer": on_b.0})).unwrap(); + s.execute("layer.select", json!({"layer": red.0, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(red)); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board.map(|x| x.0), Some(a), "the active layer's board"); +} + +/// Copy Merged finds the board under the true middle of the copied area: two boards meeting at +/// x = 10, and an area 9..11 wide whose middle (10) is on the right one (#10538 review). +#[test] +fn copy_merged_uses_the_true_middle_of_the_area() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + let left = s.execute("layer.new.artboard", json!({"rect": [0, 0, 10, 20], "name": "L"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + let right = s.execute("layer.new.artboard", json!({"rect": [10, 0, 10, 20], "name": "R"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + assert_ne!(left, right); + s.execute("select.rect", json!({"x": 9, "y": 5, "width": 2, "height": 2})).unwrap(); + s.execute("edit.copyMerged", json!({})).unwrap(); + let clip = s.clipboard.as_ref().unwrap(); + assert_eq!((clip.bounds.x0, clip.bounds.x1), (9, 11)); + assert_eq!(clip.board.map(|x| x.0), Some(right), "the middle, x = 10, is on R"); +} + +/// With a locked board itself selected, a paste goes above the board rather than into it, so it +/// isn't moved to the board's spot either (#10538 review): it keeps the usual paste placement. +#[test] +fn paste_with_a_locked_artboard_selected_is_not_moved_onto_it() { + let (mut s, red, _a, b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["offset"], json!([0, 0]), "not moved by the boards' offset"); + assert_eq!(board_of_active(&s), None, "above the locked board, not in it"); +} + +/// A merged clip whose middle is on its source board lands inside the destination board after +/// the shift too: both use the true middle of the area (#10538 review). Boards R (10..20) and D +/// (30..40); the clip spans 9..11 (middle 10, on R) and moves by 20 to 29..31 (middle 30, on D). +#[test] +fn a_shifted_merged_clip_is_placed_inside_the_destination_board() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 50, "height": 20})).unwrap(); + s.execute("layer.new.artboard", json!({"rect": [0, 0, 10, 20], "name": "L"})).unwrap(); + let r = s.execute("layer.new.artboard", json!({"rect": [10, 0, 10, 20], "name": "R"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + let d = s.execute("layer.new.artboard", json!({"rect": [30, 0, 10, 20], "name": "D"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("select.rect", json!({"x": 9, "y": 5, "width": 2, "height": 2})).unwrap(); + s.execute("edit.copyMerged", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board.map(|x| x.0), Some(r)); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": d.0})).unwrap(); + let out = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(out["offset"], json!([20, 0])); + assert_eq!(board_of_active(&s), Some(d), "inside board D"); +} + +/// A pasted layer centred just left of a board stays off it (#10538 review): the placement +/// middle rounds down, so content spanning -4..3 (middle -0.5) is left of a board starting at +/// x = 0. Truncating toward zero put it at 0, and the board took it in and clipped it. +#[test] +fn content_centred_just_left_of_a_board_is_not_placed_in_it() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + let b = s.execute("layer.new.artboard", json!({"rect": [0, 0, 20, 20], "name": "B"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + let place = |s: &mut Session, r: Rect| { + s.edit("place", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("L", fmt); + l.surface_mut().unwrap().fill_rect(r, &[1.0, 0.0, 0.0, 1.0]); + let id = crate::layer_place::insert_new(doc, Some(b), l, crate::layer_place::Place::Auto); + *active = Some(id); + Ok(doc.artboard_of(id)) + }) + .unwrap() + }; + assert_eq!(place(&mut s, Rect::new(-4, 5, 3, 10)), None, "middle -0.5: left of B"); + assert_eq!(place(&mut s, Rect::new(-3, 5, 4, 10)), Some(b), "middle 0.5: on B"); + assert_eq!(crate::layer_place::centre(Rect::new(-4, -4, 3, 3)), (-1, -1)); + assert_eq!(crate::layer_place::centre(Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX)), (-1, -1)); +} + +/// In a document without artboards nothing changes (#10538 review): Paste in the same document +/// keeps the canvas position, and the clip has no source board. +#[test] +fn paste_in_a_document_without_artboards_keeps_the_canvas_position() { + let mut s = session(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board, None); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["offset"], json!([0, 0])); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30)); +} + +/// Pasting onto a smaller board keeps the same offset from the board's origin; what falls past +/// the smaller board's edge is clipped by it, as anything on a board is (#10538 review). +#[test] +fn paste_onto_a_smaller_artboard_keeps_the_offset_and_the_board_clips_the_rest() { + let (mut s, red, _a, _b) = two_boards(); + // Board S at (300, 20), 10×10: smaller than the 20×20 red square. + let small = s.execute("layer.new.artboard", json!({"rect": [300, 20, 10, 10], "name": "S"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": small.0})).unwrap(); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["offset"], json!([290, 10]), "S's origin minus A's"); + assert_eq!(board_of_active(&s), Some(small)); + assert_eq!(active_bounds(&s), Rect::new(300, 20, 320, 40), "the layer keeps all its pixels"); + let px = |x: i32, y: i32| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(x, y, x + 1, y + 1)).px[0]; + assert!(px(305, 25)[0] > 0.99 && px(305, 25)[1] < 0.01, "shown inside S"); + assert!(!(px(315, 35)[0] > 0.99 && px(315, 35)[1] < 0.01), "clipped past S's edge"); +} + +/// A pasted layer that joins a board keeps its own clipping while the board already holds a layer +/// it can clip to, and drops it on an empty board (#10538 review): a clipped group copied whole +/// from board A and pasted onto smaller boards S (holding a layer) and E (empty). +#[test] +fn a_clipped_paste_joining_a_board_stays_clipped_only_over_a_layer() { + let (mut s, red, a, _b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + let g = s.execute("layer.groupLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + let base = s.execute("layer.new.layer", json!({"name": "Base"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": base.0, "target": g.0, "position": "below"})).unwrap(); + s.edit("clip", |doc, _| { + doc.layer_mut(g).unwrap().clipped = true; + Ok(()) + }) + .unwrap(); + assert_eq!(s.active().unwrap().doc.artboard_of(g), Some(a)); + let small = |s: &mut Session, x: i32, name: &str| { + s.execute("layer.new.artboard", json!({"rect": [x, 20, 10, 10], "name": name})).unwrap()["layer"].as_u64().map(LayerId).unwrap() + }; + let (sb, eb) = (small(&mut s, 300, "S"), small(&mut s, 400, "E")); + let held = s.execute("layer.new.layer", json!({"name": "Held"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": held.0, "target": sb.0, "position": "into"})).unwrap(); + s.execute("layer.select", json!({"layer": g.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.clipboard.as_ref().unwrap().layers.is_some(), "the group is copied whole"); + let paste_on = |s: &mut Session, board: LayerId| { + s.execute("layer.select", json!({"layer": board.0})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + (st.doc.artboard_of(id), st.doc.layer(id).unwrap().clipped) + }; + assert_eq!(paste_on(&mut s, sb), (Some(sb), true), "S holds a layer to clip to"); + assert_eq!(paste_on(&mut s, eb), (Some(eb), false), "E is empty"); +} + +/// A layer copied off every board and pasted with a layer on board B selected goes above B, at +/// the top level, where it shows (#10538 follow-up): the clip has no source board, so it isn't +/// moved onto B, and inside B, beside the selected layer, B hid it. Content centred on B (pasted +/// at the view centre there) still goes inside B, by the rule a paste with B itself selected +/// follows. +#[test] +fn a_paste_from_off_the_boards_beside_a_layer_on_a_board_stays_visible() { + let (mut s, _red, _a, b) = two_boards(); + s.execute("select.deselectLayers", json!({})).unwrap(); + let loose = s.execute("layer.new.layer", json!({"name": "Loose"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(loose).unwrap().surface_mut().unwrap().fill_rect(Rect::new(60, 60, 70, 70), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + assert_eq!(s.active().unwrap().doc.artboard_of(loose), None); + let on_b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": on_b.0, "target": b.0, "position": "into"})).unwrap(); + s.execute("layer.select", json!({"layer": loose.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().board, None, "copied off every board"); + // Hidden, so only the paste can show at its spot. + s.execute("layer.setProps", json!({"layer": loose.0, "visible": false})).unwrap(); + s.execute("layer.select", json!({"layer": on_b.0})).unwrap(); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["offset"], json!([0, 0])); + assert_eq!(board_of_active(&s), None, "not inside B, which would hide it"); + let st = s.active().unwrap(); + let top: Vec = st.doc.layers.iter().map(|l| l.id).collect(); + let at = |id: LayerId| top.iter().position(|x| *x == id); + assert_eq!(at(st.active_layer.unwrap()), at(b).map(|i| i + 1), "directly above B"); + let px = photocraft_compose::render(&st.doc, Rect::new(65, 65, 66, 66)).px[0]; + assert!(px[0] > 0.99 && px[1] < 0.01, "the paste shows"); + // Centred on B, it lands inside B, beside the selected layer. + s.execute("layer.select", json!({"layer": on_b.0})).unwrap(); + s.execute("edit.paste", json!({"center": [220.0, 40.0]})).unwrap(); + assert_eq!(board_of_active(&s), Some(b)); + assert_eq!(active_bounds(&s), Rect::new(215, 35, 225, 45)); +} + +/// Paste into a mask on another artboard lands where a layer paste does (#10538 follow-up): at +/// the same spot on the board, moved by the offset between the boards' origins, for Paste and +/// Paste in Place alike. `paste_offset` alone left it at the old spot on board A (in place) or in +/// the middle of the view. +#[test] +fn paste_into_a_mask_on_another_artboard_keeps_the_place_on_the_board() { + for (cmd, params) in [ + ("edit.paste", json!({"target": "mask"})), + ("edit.paste", json!({"target": "mask", "center": [500.0, 500.0]})), + ("edit.pasteSpecial.pasteInPlace", json!({"target": "mask"})), + ] { + let (mut s, red, _a, b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let lum = luminosity(&s, red, 15, 15); + let m = s.execute("layer.new.layer", json!({"name": "m"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": m.0, "target": b.0, "position": "into"})).unwrap(); + s.execute("layer.select", json!({"layer": m.0})).unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + let r = s.execute(cmd, params.clone()).unwrap(); + assert_eq!(r["offset"], json!([190, 10]), "{cmd} {params}: moved by B's origin minus A's"); + assert!((mask_at(&s, m, 205, 25) - lum).abs() < 2.0 / 255.0, "{cmd} {params}: the same spot on B"); + assert_eq!(mask_at(&s, m, 15, 15), 0.0, "{cmd} {params}: not at the old spot on A"); + } +} + +/// Paste in Place into an alpha channel with a layer on another artboard selected keeps the +/// document position (#11441 review): the channel covers the whole document and belongs to no +/// board, so the selected layer's board doesn't shift it. +#[test] +fn paste_in_place_into_an_alpha_channel_ignores_the_selected_layers_board() { + let (mut s, red, _a, b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let lum = luminosity(&s, red, 15, 15); + let m = s.execute("layer.new.layer", json!({"name": "m"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + s.execute("layer.moveTo", json!({"layer": m.0, "target": b.0, "position": "into"})).unwrap(); + s.execute("layer.select", json!({"layer": m.0})).unwrap(); + s.execute("channel.new", json!({})).unwrap(); + let r = s.execute("edit.pasteSpecial.pasteInPlace", json!({"target": {"channel": 0}})).unwrap(); + assert_eq!(r["offset"], json!([0, 0]), "not moved to B"); + let c = &s.active().unwrap().doc.channels[0].surface; + assert!((c.sample_channel(15, 15, 0) - lum).abs() < 2.0 / 255.0, "at the copied spot"); +} + +/// Paste into the mask of a position-locked artboard itself takes the board's shift too +/// (#11441 review): the lock keeps a new layer off the board, but a mask paste adds no layer and +/// mask edits don't check position locks, so it lands at the same spot on the board, not at the +/// old spot on board A. +#[test] +fn paste_into_a_position_locked_artboards_own_mask_keeps_the_place_on_the_board() { + let (mut s, red, _a, b) = two_boards(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let lum = luminosity(&s, red, 15, 15); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + s.execute("layer.lockLayers", json!({"position": true})).unwrap(); + let r = s.execute("edit.paste", json!({"target": "mask"})).unwrap(); + assert_eq!(r["offset"], json!([190, 10]), "moved by B's origin minus A's"); + assert!((mask_at(&s, b, 205, 25) - lum).abs() < 2.0 / 255.0, "the same spot on B"); +} diff --git a/crates/engine/src/cmd/edit_menu_cmds.rs b/crates/engine/src/cmd/edit_menu_cmds.rs index b4287e649e..98ea8b4619 100644 --- a/crates/engine/src/cmd/edit_menu_cmds.rs +++ b/crates/engine/src/cmd/edit_menu_cmds.rs @@ -313,7 +313,7 @@ pub use content_aware::{CafPlan, FillEdit, MAX_CAF_EDITS, MAX_CAF_POINTS, SelOp, use content_aware::{can_caf, content_aware_fill, delete_and_fill}; pub use content_aware_scale::ContentAwareScalePreview; use content_aware_scale::content_aware_scale; -pub use fade::fade_surface; +pub use fade::{FadePreview, fade_preview, fade_surface}; use fade::{can_fade, fade}; pub use find_replace::find_matches; use find_replace::{find_replace, has_type_layer}; diff --git a/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs b/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs index e0d05742df..49c0ea2bdf 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs @@ -112,6 +112,7 @@ pub(super) fn content_aware_scale(s: &mut Session, p: &Value) -> Result { let (w, h) = (region.width() as usize, region.height() as usize); let (nw, nh) = (dst.width() as usize, dst.height() as usize); let label = "Content-Aware Scale"; + let background = s.tools.background; // A background job when started with `Session::start` (#210): seam carving runs on a worker // against the document snapshot and checks for cancellation before every seam. crate::jobs::run( @@ -128,8 +129,11 @@ pub(super) fn content_aware_scale(s: &mut Session, p: &Value) -> Result { }, move |s, out| { s.edit(label, |doc, _| { + // The scaled area is vacated as Delete leaves it: transparent, or on the Background, + // which can't hold transparency, the background colour (Photoshop). + writable_surface(doc, id)?; + crate::edit_cmds::clear_area(doc, id, region, None, background)?; let surf = writable_surface(doc, id)?; - crate::pixels::try_clear_surface(surf, region, None)?; surf.write_region(dst, &out); surf.prune(); Ok(()) diff --git a/crates/engine/src/cmd/edit_menu_cmds/fade.rs b/crates/engine/src/cmd/edit_menu_cmds/fade.rs index 14f9b8365d..ad767c584c 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/fade.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/fade.rs @@ -1,5 +1,5 @@ use super::{ - BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, + BlendMode, Document, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, surface_of_mut, to_rgba, }; @@ -67,7 +67,18 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble Ok(()) } -pub(super) fn fade(s: &mut Session, p: &Value) -> Result { +/// What Edit › Fade with `p` blends: the layer, mask or channel of the last step, its pixels +/// before it, the area to blend, the mode and the opacity. +struct FadeJob { + target: FadeTarget, + label: String, + before: Surface, + area: Rect, + mode: BlendMode, + opacity: f32, +} + +fn fade_job(s: &Session, p: &Value) -> Result { let cmd = "edit.fade"; let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?; let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0; @@ -82,12 +93,45 @@ pub(super) fn fade(s: &mut Session, p: &Value) -> Result { // A uniform mask can change every pixel without storing a single tile. In that case // the content bounds are empty, but Fade must still blend across the canvas. let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; - let label = format!("Fade {}", src.label); - s.edit(&label, |doc, _| { - let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; - fade_surface(&before, surf, area, mode, opacity)?; - surf.prune(); - Ok(()) - })?; - Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()})) + Ok(FadeJob { target: src.target, label: src.label, before, area, mode, opacity }) +} + +fn apply_fade(doc: &mut Document, j: &FadeJob) -> Result<()> { + let surf = surface_of_mut(doc, j.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; + fade_surface(&j.before, surf, j.area, j.mode, j.opacity)?; + surf.prune(); + Ok(()) +} + +pub(super) fn fade(s: &mut Session, p: &Value) -> Result { + let j = fade_job(s, p)?; + let label = format!("Fade {}", j.label); + s.edit(&label, |doc, _| apply_fade(doc, &j))?; + Ok(json!({"faded": j.label, "opacity": j.opacity * 100.0, "mode": j.mode.label()})) +} + +/// Edit › Fade with given params, captured from the session without a history step: the Fade +/// dialog's live preview (#8230). [`FadePreview::render`] does the blend and can run off the +/// session (the preview worker). The document copy shares its tiles with the session's, and +/// only the tiles in the faded area are blended and rewritten. +pub struct FadePreview { + doc: Document, + job: FadeJob, +} + +impl FadePreview { + /// The active document as Edit › Fade would leave it. + pub fn render(mut self) -> Result { + apply_fade(&mut self.doc, &self.job)?; + Ok(self.doc) + } +} + +/// What Edit › Fade with `p` would do to the active document. `Err` when Fade isn't available +/// or `p` is invalid. +pub fn fade_preview(s: &Session, p: &Value) -> Result { + can_fade(s).map_err(|e| bad("edit.fade", e))?; + let job = fade_job(s, p)?; + let doc = Document::clone(&s.active().ok_or(EngineError::NoDocument)?.doc); + Ok(FadePreview { doc, job }) } diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index a74edb818b..5a9ce8c2c5 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -17,13 +17,14 @@ fn clip_bytes(s: &Session) -> usize { s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.len()).sum()) } +/// Apply the requested irreversible purge and record its boundary so action finalization cannot resurrect undo states. pub(super) fn purge(s: &mut Session, what: &str) -> Result { let mut freed = 0usize; let mut items: Vec<&str> = Vec::new(); if matches!(what, "undo" | "all") && s.active().is_some_and(|d| d.history.can_undo()) { let before = history_bytes(s, false); if let Some(st) = s.active_mut() { - st.history.purge_last(); + st.history.purge_undo(); } freed += before.saturating_sub(history_bytes(s, false)); items.push("undo"); @@ -66,11 +67,21 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result { freed = freed.saturating_add(sources); items.push("smart object source cache"); } + let mips = photocraft_paint::purge_mip_cache(); + if mips > 0 { + freed = freed.saturating_add(mips); + items.push("brush mip cache"); + } let strokes = photocraft_compose::shape_split::purge_cache(); if strokes > 0 { freed = freed.saturating_add(strokes); items.push("shape stroke cache"); } + let luts = photocraft_compose::lut_rows::purge_cache(); + if luts > 0 { + freed = freed.saturating_add(luts); + items.push("adjustment table cache"); + } } s.edit_state.fade = None; let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) }; @@ -95,6 +106,8 @@ pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> { || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 || crate::smart_cmds::source_cache_bytes() > 0 + || photocraft_paint::mip_cache_bytes() > 0 + || photocraft_compose::lut_rows::cache_bytes() > 0 { Ok(()) } else { diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs index fb8dcacdcf..069ff2573c 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs @@ -1,6 +1,8 @@ use super::*; use photocraft_doc::vector::Subpath; +static PURGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn session(depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "white"})).unwrap(); @@ -12,11 +14,42 @@ fn px(s: &Session, x: i32, y: i32) -> Vec { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y) } +fn px_rgba(s: &Session, x: i32, y: i32) -> [f32; 4] { + active(s).surface().unwrap().rgba(x, y) +} + fn active(s: &Session) -> &Layer { let st = s.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap() } +/// #8230: the Fade dialog's preview is the document Fade would leave, at every depth and in a +/// blend mode, with no history step and nothing changed until OK; with nothing to fade or a bad +/// mode it is an error, not a panic. +#[test] +fn fade_preview_matches_fade_without_a_step() { + for depth in [8, 16, 32] { + assert!(fade_preview(&session(depth), &json!({"opacity": 50})).is_err(), "nothing to fade yet"); + for p in [json!({"opacity": 25}), json!({"opacity": 60, "mode": "difference"}), json!({})] { + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + let (steps, revision) = (s.active().unwrap().history.past_len(), s.active().unwrap().revision); + let preview = fade_preview(&s, &p).and_then(FadePreview::render).unwrap(); + assert_eq!((s.active().unwrap().history.past_len(), s.active().unwrap().revision), (steps, revision), "depth {depth} {p}: no step"); + assert_eq!(px(&s, 5, 5)[0], 0.0, "depth {depth} {p}: the layer is untouched"); + let id = s.active().unwrap().active_layer.unwrap(); + let shown = preview.layer(id).unwrap().surface().unwrap().pixel(5, 5); + s.execute("edit.fade", p.clone()).unwrap(); + assert_eq!(shown, px(&s, 5, 5), "depth {depth} {p}: the preview is what OK applies"); + } + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + assert!(fade_preview(&s, &json!({"mode": "nonsense"})).is_err()); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(fade_preview(&s, &json!({})).is_err(), "not right after the step"); + } +} + #[test] fn fade_blends_last_step_at_all_depths() { for depth in [8, 16, 32] { @@ -113,7 +146,11 @@ fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() { assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted"); let expected = was + (1.0 - 2.0 * was) * k; let steps = s.active().unwrap().history.past_len(); + // The Fade dialog's preview (#8230) shows the channel as OK leaves it. + let preview = fade_preview(&s, &json!({"opacity": opacity})).and_then(FadePreview::render).unwrap(); + let shown = if quick_mask { preview.quick_mask.as_ref() } else { preview.channels.first() }.unwrap().surface.pixel(5, 5)[0]; s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + assert_eq!(shown, channel(&s, 5, 5), "depth {depth}, quick mask {quick_mask}, {opacity} %: the preview is what OK applies"); for (x, y) in [(0, 0), (5, 5), (63, 47)] { let v = channel(&s, x, y); assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}"); @@ -184,6 +221,7 @@ fn fade_ignores_a_target_the_command_doesnt_take() { #[test] fn purge_commands() { + let _purge_test_guard = PURGE_TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut s = session(8); assert!(!s.is_enabled("edit.purge.undo")); assert!(s.is_enabled("edit.purge.videoCache")); @@ -210,6 +248,30 @@ fn purge_commands() { let _ = s.execute("edit.purge.all", json!({})); } +#[test] +fn purge_all_releases_prepared_brush_tips_even_without_a_document() { + let _purge_test_guard = PURGE_TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + use photocraft_paint::{BrushContext, BrushSettings, Dab, GrayTile, TipShape}; + let mut s = Session::new(); + let brush = BrushSettings { tip: TipShape::Sampled(GrayTile::from_fn(31, 17, |x, y| ((x + y) % 5) as f32 / 4.0)), ..Default::default() }; + let ctx = BrushContext::new(&brush); + let dab = Dab::round(photocraft_geom::Point::new(12.5, 14.5), 10.0, 1.0); + let rect = ctx.dab_rect(&dab, false); + let mut before = Vec::new(); + ctx.rasterize(&dab, false, rect, &mut before); + assert!(s.is_enabled("edit.purge.all"), "prepared tips alone enable All"); + let result = s.execute("edit.purge.all", json!({})).unwrap(); + assert!(result["purged"].as_array().unwrap().contains(&json!("brush mip cache"))); + assert!(result["bytes"].as_u64().unwrap() > 0); + // An already prepared brush remains valid after the cache releases its Arc. + let mut after = Vec::new(); + ctx.rasterize(&dab, false, rect, &mut after); + assert_eq!(before, after); + let next = BrushContext::new(&brush); + next.rasterize(&dab, false, rect, &mut after); + assert_eq!(before, after); +} + /// White canvas with vertical black stripes, a red blob to remove, and a selection around it. fn blob_session(depth: u32) -> Session { let mut s = session(depth); @@ -518,6 +580,38 @@ fn content_aware_scale_keeps_subject() { } } +/// On the Background, which can't hold transparency, the area a selection's Content-Aware Scale +/// vacates takes the background colour, as Delete leaves it (Photoshop); the layer stays the +/// Background. On a normal layer it is left transparent. +#[test] +fn content_aware_scale_on_the_background_vacates_to_the_background_colour() { + for depth in [8, 16] { + let mut s = session(depth); + s.tools.background = [0.0, 0.0, 1.0, 1.0]; + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap(); + let r = s.execute("edit.contentAwareScale", json!({"width": 10})).unwrap(); + assert_eq!(r["bounds"], json!([10, 10, 10, 10]), "{depth}-bit"); + let st = s.active().unwrap(); + assert!(st.doc.is_background(id), "{depth}-bit: still the Background"); + let px = |x, y| st.doc.layer(id).unwrap().surface().unwrap().rgba(x, y); + for (x, y) in [(20, 10), (25, 15), (29, 19)] { + assert_eq!(px(x, y), [0.0, 0.0, 1.0, 1.0], "{depth}-bit: ({x}, {y}) is vacated"); + } + assert_eq!(px(12, 15), [1.0, 1.0, 1.0, 1.0], "{depth}-bit: the scaled pixels"); + assert_eq!(px(5, 5), [1.0, 1.0, 1.0, 1.0], "{depth}-bit: outside the selection"); + // A normal layer's vacated area is transparent. + let mut s = session(depth); + s.tools.background = [0.0, 0.0, 1.0, 1.0]; + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.contentAwareScale", json!({"width": 10})).unwrap(); + assert_eq!(px_rgba(&s, 25, 15)[3], 0.0, "{depth}-bit: a layer's hole is transparent"); + assert_eq!(px_rgba(&s, 12, 15), [1.0, 0.0, 0.0, 1.0], "{depth}-bit"); + } +} + #[test] fn content_aware_scale_params() { let mut s = session(8); diff --git a/crates/engine/src/cmd/exr_cmds.rs b/crates/engine/src/cmd/exr_cmds.rs index 5fbc355314..f8ef76f654 100644 --- a/crates/engine/src/cmd/exr_cmds.rs +++ b/crates/engine/src/cmd/exr_cmds.rs @@ -39,6 +39,7 @@ fn selected(p: &Value, prefix: &str) -> Vec { .unwrap_or_default() } +/// Open the selected EXR parts and view, retaining their Batch source identity. fn open_exr_parts(s: &mut Session, p: &Value) -> Result { let path = crate::file_cmds::str_param(p, "path", "file.openExrParts")?.to_string(); let view = p.get("view").and_then(Value::as_str).unwrap_or("both"); @@ -55,6 +56,8 @@ fn open_exr_parts(s: &mut Session, p: &Value) -> Result { let mut r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; crate::file_cmds::tag_exr_colorspace(s, &path, &mut r.document); let (i, color) = s.open_document(r.document, Some(path.clone())); + crate::file_cmds::remember_batch_open(s); + super::file_cmds::batch_open_notice(s, &path, &r.warnings, &color); Ok(json!({"document": i, "color": color, "warnings": r.warnings})) } diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index 2faefdf9e8..da5bc746b4 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -76,12 +76,18 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String { /// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that /// grayscale surface is rewritten instead, as the core filters do (#935). fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { + map_pixels_in(s, label, p, false, f) +} + +/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and +/// every pixel takes its result in full. +fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { let id = layer_param(s, p)?; let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels; s.edit(label, |doc, _| { let canvas = doc.bounds(); let selection = doc.selection.clone(); - let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + let area = if whole { canvas } else { selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)) }; let (surf, lock_alpha) = if channel { let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(id), p)? else { return Ok(()) }; (surf, false) @@ -109,7 +115,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut f(area, &mut px, &cover); let mut enc = [0.0f32; 8]; for (i, out) in raw.chunks_exact_mut(n).enumerate() { - let k = cover[i]; + let k = if whole { 1.0 } else { cover[i] }; if k <= 0.0 { continue; } @@ -230,9 +236,16 @@ fn stroke(s: &mut Session, p: &Value) -> Result { } /// Histogram equalization, like Photoshop's: one CDF over the R, G and B values of -/// the selected pixels, applied to every channel. +/// the selected pixels, applied to every channel. With a selection, `selectedAreaOnly: false` +/// applies the selection's mapping to the layer within the canvas (Photoshop's Equalize Entire +/// Image Based On Selected Area). fn equalize(s: &mut Session, p: &Value) -> Result { - map_pixels(s, "Equalize", p, |_, px, cover| { + let whole = match p.get("selectedAreaOnly") { + None => false, + Some(Value::Bool(only)) => !only, + Some(_) => return Err(EngineError::BadParams { cmd: "image.adjustments.equalize".into(), msg: "selectedAreaOnly must be true or false".into() }), + }; + map_pixels_in(s, "Equalize", p, whole, |_, px, cover| { let mut hist = [0f64; 256]; for (p, &k) in px.iter().zip(cover) { if k > 0.0 && p[3] > 0.0 { @@ -556,6 +569,10 @@ fn is_empty_layer(l: &Layer) -> bool { } } +/// Layer › Ungroup Layers: replace the selected group with its children in place, the topmost +/// child becoming active. Ungrouping an empty group next to other layers simply removes it; +/// when it is the only root layer the edit is refused, as `layer.delete` refuses to drop the +/// last layer (#11118). fn ungroup(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; s.edit("Ungroup Layers", |doc, active| { @@ -566,6 +583,12 @@ fn ungroup(s: &mut Session, p: &Value) -> Result { }; let children = group.children.clone(); let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(id))?; + // Ungrouping the only root layer when it is an empty group would leave the document + // with no layers at all; `layer.delete` refuses to drop the last layer, so refuse here + // too instead (#11118). + if parent.is_empty() && children.is_empty() && doc.layers.len() == 1 { + return Err(EngineError::Other("a document must keep at least one layer".into())); + } let sib = if parent.is_empty() { &mut doc.layers } else { doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(id))? }; sib.remove(idx); let top = children.last().map(|l| l.id); @@ -752,6 +775,8 @@ fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str let px = content_pixels(doc, &snap); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); + l.frame = None; + l.psd_blocks.retain(|(k, _)| k != b"pcFr"); l.fill_cache = None; // The PSD fill or placed-layer blocks no longer describe the layer: kept, they // would reopen it as a fill layer or smart object and drop these pixels. @@ -870,7 +895,15 @@ pub fn specs() -> Vec { })?; Ok(json!({"selected": true})) }), - spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, equalize), + spec!( + "image.adjustments.equalize", + "Equalize", + &["Image", "Adjustments"], + None, + r#"{"selectedAreaOnly":bool=true (with a selection, false equalizes the layer within the canvas by the selection's histogram)}"#, + has_pixels, + equalize + ), spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| { let d = s.active().ok_or(EngineError::NoDocument)?; let all = reveal_all_bounds(&d.doc); diff --git a/crates/engine/src/cmd/extra_cmds/tests.rs b/crates/engine/src/cmd/extra_cmds/tests.rs index 97243efd58..3543a012b4 100644 --- a/crates/engine/src/cmd/extra_cmds/tests.rs +++ b/crates/engine/src/cmd/extra_cmds/tests.rs @@ -473,6 +473,64 @@ fn equalize_spreads_values() { } } +/// Equalize with a selection: by default only the selected pixels change, as Photoshop's +/// Equalize Selected Area Only; `selectedAreaOnly: false` maps the layer within the canvas by the +/// selected pixels' histogram, as its Equalize Entire Image Based On Selected Area. +#[test] +fn equalize_entire_image_based_on_the_selected_area() { + let setup = |depth: u32| { + let mut s = session(depth); + paint_square(&mut s, Rect::new(0, 0, 30, 15), [0.4, 0.4, 0.4, 1.0]); + paint_square(&mut s, Rect::new(0, 15, 30, 30), [0.5, 0.5, 0.5, 1.0]); + // Darker than anything selected: the selection's mapping sends it to black. + paint_square(&mut s, Rect::new(30, 0, 40, 30), [0.3, 0.3, 0.3, 1.0]); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + s + }; + for depth in [8, 16, 32] { + for params in [json!({}), json!({"selectedAreaOnly": true})] { + let mut s = setup(depth); + s.execute("image.adjustments.equalize", params.clone()).unwrap(); + let inside = (pixel(&s, 1, 1)[0], pixel(&s, 1, 20)[0]); + assert!(inside.0 < 0.05 && inside.1 > 0.95, "{depth} {params}: inside {inside:?}"); + let outside = [pixel(&s, 25, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]]; + assert!(outside.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth} {params}: outside {outside:?}"); + } + let mut s = setup(depth); + s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": false})).unwrap(); + for (x, y, want_light) in [(1, 1, false), (1, 20, true), (25, 1, false), (25, 20, true), (35, 1, false), (35, 20, false)] { + let v = pixel(&s, x, y)[0]; + assert!(if want_light { v > 0.95 } else { v < 0.05 }, "{depth}: ({x}, {y}) = {v}"); + } + assert!(doc(&s).selection.is_some(), "{depth}: the selection stays"); + s.undo(); + let undone = [pixel(&s, 1, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]]; + assert!(undone.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth}: one undo step {undone:?}"); + } +} + +/// Without a selection, `selectedAreaOnly: false` gives the same pixels as plain Equalize. +#[test] +fn equalize_entire_image_without_a_selection_is_plain_equalize() { + let run = |params: Value| { + let mut s = session(8); + paint_square(&mut s, Rect::new(0, 0, 40, 15), [0.4, 0.4, 0.4, 1.0]); + paint_square(&mut s, Rect::new(0, 15, 40, 30), [0.5, 0.5, 0.5, 1.0]); + s.execute("image.adjustments.equalize", params).unwrap(); + active(&s).surface().unwrap().read_region(Rect::new(0, 0, 40, 30)) + }; + assert_eq!(run(json!({"selectedAreaOnly": false})), run(json!({}))); +} + +#[test] +fn equalize_refuses_a_selected_area_only_that_is_not_a_boolean() { + for bad in [json!(null), json!("false"), json!(0), json!([true]), json!({})] { + let mut s = session(8); + let e = s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": bad})).unwrap_err().to_string(); + assert!(e.contains("selectedAreaOnly"), "{bad}: {e}"); + } +} + /// A selection masks Clouds but doesn't change their size: inside it they are the canvas's /// clouds, as when Clouds sized itself by the document (before it became a filter). #[test] @@ -713,6 +771,41 @@ fn ungroup_restores_children_in_order() { assert_eq!(doc(&s).layers.len(), 2); } +#[test] +fn ungroup_the_sole_empty_group_is_refused() { + // #11118: ungrouping the only root layer when it was an empty group emptied the document + // and cleared the active layer, while deleting a layer refuses to drop the last one. + let mut s = session(8); + let child = active(&s).id; + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = active(&s).id; + s.execute("layer.hideLayers", json!({ "layer": child.0 })).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); // the group is now empty + let bg = doc(&s).layers.iter().find(|l| !l.is_group()).unwrap().id; + s.execute("layer.hideLayers", json!({ "layer": bg.0 })).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); // the empty group is the sole layer + assert_eq!(doc(&s).layers.len(), 1); + assert!(doc(&s).layers[0].is_group()); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute("layer.ungroupLayers", json!({ "layer": group.0 })).is_err(), "ungrouping the sole empty group must be refused, not empty the document"); + assert_eq!(doc(&s).layers.len(), 1, "the group is left in place"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "a refused edit records no history step"); + assert_eq!(s.active().unwrap().active_layer, Some(group), "the group stays active"); +} + +#[test] +fn ungroup_an_empty_group_next_to_other_layers_removes_it() { + // The control for the refusal above: an empty group that is not the only root layer still + // ungroups, and the group simply disappears. + let mut s = session(8); + let group = LayerId(s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert!(doc(&s).layers.iter().any(|l| l.id == group)); + s.execute("layer.ungroupLayers", json!({ "layer": group.0 })).unwrap(); + assert!(doc(&s).layers.iter().all(|l| l.id != group), "the empty group is gone"); + assert_eq!(doc(&s).layers.len(), 2, "nothing arrived in its place"); + assert_eq!(s.active().unwrap().active_layer, doc(&s).top_layer(), "an active layer survives"); +} + #[test] fn reveal_all_grows_the_canvas() { let mut s = session(8); diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 4f555c2a61..495f9d1892 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -25,28 +25,31 @@ mod export; mod info; mod open; mod paths; +mod place_steps; mod processing; +mod raw_open; mod save; pub use batch_naming::plan as plan_batch_names; -#[cfg(test)] -pub(crate) use batch_run::MAX_SUBFOLDER_DEPTH; use batch_run::batch; pub use batch_run::same_format as batch_same_format; use batch_run::same_format; #[cfg(not(target_arch = "wasm32"))] pub use batch_run::validate_flat_outputs; pub use batch_run::{BatchInput, OutputClaims, encode_batch_error_log, list_images_tree}; -pub(crate) use batch_run::{batch_inputs, batch_keeps_step, batch_save_point, process_files}; +#[cfg(test)] +pub(crate) use batch_run::{MAX_SUBFOLDER_DEPTH, list_files_tree}; +pub(crate) use batch_run::{batch_action, batch_inputs, batch_keeps_step, batch_save_point, process_files}; pub use export::{LayerExportReport, SkippedExportLayer, bake_cube, export_layers_report_with, export_layers_with}; use export::{color_lookup_tables, layers_to_files, push_unique}; use info::file_info; pub use info::{CAMERA_FIELDS, camera_data, read_file_info, write_file_info}; #[cfg(test)] use info::{exposure_text, find_element}; +pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_batch_open}; pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; +pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; -pub(crate) use open::{import, import_with_options, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -54,16 +57,18 @@ pub use paths::{ extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, portable_device_name, same_destination, same_file, saves_in_place, template_name, untitled_name, }; -pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, read_file, sanitize, stem, write_file}; +pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, read_file, read_file_limited, sanitize, stem, write_file}; +pub(crate) use place_steps::PlaceSteps; use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatten_all_masks, image_processor, load_files_into_stack}; use save::save_a_copy; pub(crate) use save::{SaveOpts, encode, save_doc}; pub use save::{ - dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, pcraft_full_composite_param, tga_bits_param, tiff_bool_param, tiff_compression_param, - tiff_would_write_layers, + dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, maximize_compatibility_param, pcraft_full_composite_param, tga_bits_param, tiff_bool_param, + tiff_compression_param, tiff_would_write_layers, }; mod batch_naming; +pub(crate) use batch_naming::COMPONENTS as BATCH_NAMING_COMPONENTS; // ---------- predicates ---------- @@ -168,11 +173,18 @@ pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface { /// a sparse bundle declares its canvas, and one the size of the format allows would be rendered /// in full, however little it holds. Refused past the compositor's own per-render limit. pub(crate) fn flattened_checked(doc: &Document, fmt: PixelFormat, cmd: &str) -> Result { + check_flattenable(doc, cmd)?; + Ok(flattened(doc, fmt)) +} + +/// The refusal [`flattened_checked`] gives a canvas past the compositor's per-render limit, for a +/// caller that flattens `doc` some other way (a place job, which can be cancelled while it does). +pub(crate) fn check_flattenable(doc: &Document, cmd: &str) -> Result<()> { let b = doc.bounds(); if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("the document's {}×{} canvas is too large to flatten", b.width(), b.height()) }); } - Ok(flattened(doc, fmt)) + Ok(()) } fn new_guide_layout(s: &mut Session, p: &Value) -> Result { @@ -260,7 +272,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tiffTransparency":bool? (TIFF: Save Transparency; off composites over white; unset, kept unless the document has alpha or spot channels),"tiffPyramid":bool=false (TIFF: Save Image Pyramid, half-size copies after the image),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tiffTransparency":bool? (TIFF: Save Transparency; off composites over white; unset, kept unless the document has alpha or spot channels),"tiffPyramid":bool=false (TIFF: Save Image Pyramid, half-size copies after the image),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, native_doc, save_a_copy ), @@ -292,7 +304,7 @@ pub fn specs() -> Vec { "Place Embedded…", &["File"], None, - r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, + r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage),"frame":id|false? (default: selected frame)}"##, native_doc, |s, p| place(s, p, false) ), @@ -301,7 +313,7 @@ pub fn specs() -> Vec { "Place Linked…", &["File"], None, - r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, + r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage),"frame":id|false? (default: selected frame)}"##, native_doc, |s, p| place(s, p, true) ), @@ -337,7 +349,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; Save/Save As marks the result),"source":"folder|open"="folder" (open: run on live open documents),"input":folder|[paths] (folder source),"includeSubfolders":bool=false (up to 32 levels and 100000 files, outputs are flat under output),"output":folder (optional for open source),"format":"same|png|jpg|psd|tiff|…"="same","naming":string? (tokens: {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; the naming template determines the whole filename),"startingSerial":1..4294967295=1,"stopOnError":bool=false,"errorLog":path? (blank disables it),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]?} → {files, errors, stopped?, skipped?, errorLog?, documents?} (folder batches report per-file job progress and support cancellation)"##, + r##"{"action":name|index|[steps]? (used only when steps is absent; named actions exclude disabled steps),"set":str? (for a named action),"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"source":"folder|open"="folder" (open runs on live documents),"input":folder|[paths] (folder source),"errorLog":path? (blank disables it),"suppressOpenOptions":bool=false (use caller RAW defaults and fully supported prior XMP without a dialog; unsupported prior settings or unanswerable profile questions fail the input),"suppressWarnings":bool=false (apply caller opening policy without profile questions; import diagnostics remain in notices),"overrideOpen":bool=false (execute action Open steps with the current input path, retaining other Open options; without an executed Open the input fails),"includeNonImages":bool=false (folder inputs report unsupported files as per-file errors instead of skipping them; arrays are unchanged),"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; ordinary outputs are flat; namingComponents can retain subfolders when flatOutput is false),"destination":"folder|saveAndClose"="folder" (Save and Close writes the same source path/format; rejects already-open sources and changed result document identity),"output":folder? (required only for folder; omit for Save and Close),"format":"same|png|jpg|psd|tiff|…"="same" (Save and Close requires same),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"namingComponents":[{"component":choice}|{"text":str}]? (up to six; the exact filename, with no automatic extension),"compatibility":{"macintosh":bool,"windows":bool,"unix":bool}?,"flatOutput":bool=false (structured naming only; put every result directly under output, dropping its subfolder path; duplicate output names make later results error entries),"overrideSaveAs":bool? (recorded routing; requires a directly represented Save configuration),"stopOnError":bool=false (report the first failed input and stop; earlier outputs remain saved),"naming":string? (blank keeps source names; {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; template determines the whole filename),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool? (Folder default false; Save and Close default true, with false refused when TIFF layer data would be lost),"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility),"calledActions":[{"name","steps"}…]? (internal action references; default: the session's; a named source is selected from the caller)} → {files, errors, stopped?, skipped?, errorLog?, documents?} (structured naming, includeNonImages and Save and Close refuse duplicate results; ordinary native templates keep the later result)"##, native, batch ), @@ -346,7 +358,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -374,7 +386,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility)} → {files}"##, native_doc, layers_to_files ), @@ -405,6 +417,10 @@ pub fn specs() -> Vec { #[path = "file_cmds/tests.rs"] mod tests; +#[cfg(test)] +#[path = "file_cmds/place_job_tests.rs"] +mod place_job_tests; + #[cfg(test)] mod aces_exr_tests { use super::*; diff --git a/crates/engine/src/cmd/file_cmds/batch_naming.rs b/crates/engine/src/cmd/file_cmds/batch_naming.rs index 89b5203409..8f7c579a69 100644 --- a/crates/engine/src/cmd/file_cmds/batch_naming.rs +++ b/crates/engine/src/cmd/file_cmds/batch_naming.rs @@ -60,7 +60,8 @@ impl Date { } } -fn letters(mut serial: u32, upper: bool) -> String { +/// Serial letters use the one-based count; recorded components pass the input index (#4579 by @FabioLissi). +fn letters(mut serial: u64, upper: bool) -> String { let mut bytes = Vec::new(); while serial > 0 { serial -= 1; @@ -72,6 +73,99 @@ fn letters(mut serial: u32, upper: bool) -> String { String::from_utf8(bytes).unwrap_or_default() } +/// Photoshop's component identifiers and the structured native naming choices. +pub(crate) const COMPONENTS: &[(&str, &str)] = &[ + ("Nm ", "name"), + ("lowerCase", "nameLower"), + ("upperCase", "nameUpper"), + ("oneDigit", "serial1"), + ("twoDigit", "serial2"), + ("threeDigit", "serial3"), + ("fourDigit", "serial4"), + ("lowerCaseSerial", "letter"), + ("upperCaseSerial", "LETTER"), + ("mmddyy", "mmddyy"), + ("mmdd", "mmdd"), + ("yyyymmdd", "yyyymmdd"), + ("yymmdd", "yymmdd"), + ("yyddmm", "yyddmm"), + ("ddmmyy", "ddmmyy"), + ("ddmm", "ddmm"), + ("lowerCaseExtension", "extension"), + ("upperCaseExtension", "EXTENSION"), +]; + +/// Bound structured fields before file processing; literals never pass through the legacy template parser. +fn components(params: &Value) -> Result> { + let Some(value) = params.get("namingComponents").filter(|v| !v.is_null()) else { return Ok(None) }; + let list = value.as_array().filter(|a| (1..=6).contains(&a.len())).ok_or_else(|| bad("namingComponents needs one to six components"))?; + for item in list { + let valid = item.as_object().is_some_and(|o| { + o.len() == 1 + && (o.get("text").and_then(Value::as_str).is_some_and(|t| t.len() <= 255) + || o.get("component").and_then(Value::as_str).is_some_and(|kind| COMPONENTS.iter().any(|(_, native)| kind == *native))) + }); + if !valid { + return Err(bad("each naming component needs one known component or literal text of at most 255 bytes")); + } + } + Ok(Some(list)) +} + +/// Read explicit portability flags; path-containment and host-filesystem checks remain mandatory. +fn compatibility(params: &Value) -> Result<(bool, bool, bool)> { + let Some(value) = params.get("compatibility").filter(|v| !v.is_null()) else { return Ok((false, false, false)) }; + let flags = value.as_object().ok_or_else(|| bad("compatibility must be an object"))?; + if flags.iter().any(|(key, value)| !["macintosh", "windows", "unix"].contains(&key.as_str()) || !value.is_boolean()) { + return Err(bad("compatibility accepts only macintosh, windows and unix booleans")); + } + Ok(( + flags.get("macintosh").and_then(Value::as_bool).unwrap_or(false), + flags.get("windows").and_then(Value::as_bool).unwrap_or(false), + flags.get("unix").and_then(Value::as_bool).unwrap_or(false), + )) +} + +/// Construct the entire filename in recorded order, adding a dot only for the final extension component. +fn expand_components(parts: &[Value], name: &str, serial: u32, index: usize, ext: &str, date: chrono::NaiveDate) -> Result { + let mut out = String::new(); + for (i, part) in parts.iter().enumerate() { + if let Some(text) = part.get("text").and_then(Value::as_str) { + out.push_str(text); + } else { + let kind = part.get("component").and_then(Value::as_str).ok_or_else(|| bad("missing naming component"))?; + let value = match kind { + "name" => name.to_string(), + "nameLower" => name.to_lowercase(), + "nameUpper" => name.to_uppercase(), + "serial1" => format!("{serial}"), + "serial2" => format!("{serial:02}"), + "serial3" => format!("{serial:03}"), + "serial4" => format!("{serial:04}"), + "letter" | "LETTER" => { + letters(u64::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| bad("too many serial letters"))?, kind == "LETTER") + } + "extension" | "EXTENSION" => { + format!("{}{}", if i + 1 == parts.len() { "." } else { "" }, if kind == "EXTENSION" { ext.to_uppercase() } else { ext.to_lowercase() }) + } + "mmddyy" => date.format("%m%d%y").to_string(), + "mmdd" => date.format("%m%d").to_string(), + "yyyymmdd" => date.format("%Y%m%d").to_string(), + "yymmdd" => date.format("%y%m%d").to_string(), + "yyddmm" => date.format("%y%d%m").to_string(), + "ddmmyy" => date.format("%d%m%y").to_string(), + "ddmm" => date.format("%d%m").to_string(), + _ => return Err(bad("unknown naming component")), + }; + out.push_str(&value); + } + if out.len() > 255 { + return Err(bad("naming must produce at most 255 bytes")); + } + } + Ok(out) +} + fn parse(template: &str) -> Result>> { let mut parts = Vec::new(); let mut rest = template; @@ -124,7 +218,7 @@ fn expand(parts: &[Part<'_>], name: &str, ext: &str, serial: u32, date: Date) -> } } Part::Serial(width) => out.push_str(&format!("{serial:0width$}")), - Part::Letter(upper) => out.push_str(&letters(serial, *upper)), + Part::Letter(upper) => out.push_str(&letters(u64::from(serial), *upper)), Part::Date(kind) => out.push_str(&date.format(*kind)), Part::Ext(upper) => { // The reference app adds the separator only for the final extension component. @@ -144,8 +238,18 @@ fn expand(parts: &[Part<'_>], name: &str, ext: &str, serial: u32, date: Date) -> } /// An absent or empty template preserves the old filename path exactly. An explicit template -/// generates one component per input; the export format supplies the extension separately. +/// determines the whole filename. Recorded components retain their separate format choice. pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result>> { + let structured = components(params)?; + let compat = compatibility(params)?; + let flat = match params.get("flatOutput") { + None | Some(Value::Null) => false, + Some(Value::Bool(v)) => *v, + _ => return Err(bad("flatOutput must be a boolean")), + }; + if flat && structured.is_none() { + return Err(bad("flatOutput needs namingComponents")); + } let start = match params.get("startingSerial") { None => 1, Some(n) => { @@ -157,30 +261,58 @@ pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) - Some(Value::String(s)) => s, Some(_) => return Err(bad("naming must be a string")), }; - if template.is_empty() { + if structured.is_some() && !template.is_empty() { + return Err(bad("use naming or namingComponents, not both")); + } + if params.get("compatibility").is_some_and(|v| !v.is_null()) && structured.is_none() { + return Err(bad("compatibility needs namingComponents")); + } + if template.is_empty() && structured.is_none() { return Ok(None); } if template.len() > 255 { return Err(bad("naming must be at most 255 bytes")); } let parts = parse(template)?; - let has_serial = parts.iter().any(|p| matches!(p, Part::Serial(_) | Part::Letter(_))); + let has_serial = parts.iter().any(|p| matches!(p, Part::Serial(_) | Part::Letter(_))) + || structured.is_some_and(|list| { + list.iter().any(|p| p.get("component").and_then(Value::as_str).is_some_and(|kind| matches!(kind, "serial1" | "serial2" | "serial3" | "serial4"))) + }); let timestamp = crate::analysis_cmds::now_iso(); let date = Date::from_iso(×tamp).ok_or_else(|| bad("naming: the current UTC date is unavailable"))?; + let calendar = if structured.is_some() { + Some(chrono::NaiveDate::parse_from_str(timestamp.get(..10).unwrap_or(""), "%Y-%m-%d").map_err(|_| bad("naming: current UTC date unavailable"))?) + } else { + None + }; let mut outputs = Vec::with_capacity(inputs.len()); - for (index, BatchInput { path, .. }) in inputs.iter().enumerate() { + for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() { let serial = if has_serial { u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))? } else { start }; let ext = if format == "same" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; - let name = expand(&parts, &stem(path), &ext, serial, date); - if name.is_empty() || name.ends_with(['.', ' ']) || name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { - return Err(bad("naming must produce a nonempty filename of at most 255 bytes without trailing spaces, path separators or reserved characters")); + let name = if let (Some(components), Some(calendar)) = (structured, calendar) { + expand_components(components, &stem(path), serial, index, &ext, calendar)? + } else { + expand(&parts, &stem(path), &ext, serial, date) + }; + if name.is_empty() || name.ends_with(['.', ' ']) { + return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space")); } - // Photoshop Batch writes all subfolder results into the destination itself. - outputs.push(join(output, &name)); + if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\".contains(c)) { + return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); + } + let windows = structured.is_none() || cfg!(windows) || compat.1; + if windows && (name.contains(['<', '>', ':', '"', '|', '?', '*']) || structured.is_some() && super::is_windows_device_name(&name)) + || compat.0 && name.contains(':') + { + return Err(bad("naming is incompatible with the requested platform")); + } + // Preserve the recorded structured routing; ordinary native templates use main's flat output. + let subfolder = if structured.is_some() && !flat { rel_dir.as_str() } else { "" }; + outputs.push(join(&join(output, subfolder), &name)); } // Renaming within the source folder could replace a later input before it is read. Resolve // existing paths so aliases such as `source/./001.png` cannot bypass the check. The default @@ -202,6 +334,86 @@ pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) - #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + /// Fix the date and input index so naming checks are independent of the clock and serial start. + #[test] + fn batch_components_cover_names_serials_letters_and_every_measured_date() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 11, 1).unwrap(); + let expected = [ + ("name", "Photo{serial}"), + ("nameLower", "photo{serial}"), + ("nameUpper", "PHOTO{SERIAL}"), + ("serial1", "7"), + ("serial2", "07"), + ("serial3", "007"), + ("serial4", "0007"), + ("letter", "a"), + ("LETTER", "A"), + ("mmddyy", "110126"), + ("mmdd", "1101"), + ("yyyymmdd", "20261101"), + ("yymmdd", "261101"), + ("yyddmm", "260111"), + ("ddmmyy", "011126"), + ("ddmm", "0111"), + ("extension", ".png"), + ("EXTENSION", ".PNG"), + ]; + for (component, want) in expected { + assert_eq!(expand_components(&[json!({"component":component})], "Photo{serial}", 7, 0, "png", date).unwrap(), want, "{component}"); + } + assert_eq!(expected.len(), COMPONENTS.len()); + assert_eq!(letters(26, false), "z"); + assert_eq!(letters(27, true), "AA"); + assert_eq!(expand_components(&[json!({"component":"letter"})], "x", 9999, 27, "png", date).unwrap(), "ab"); + } + + /// Literal braces stay literal and an extension adds its dot only in the final position. + #[test] + fn batch_extension_position_and_literal_text_determine_the_entire_name() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 11, 1).unwrap(); + for (parts, want) in [ + (json!([{"component":"name"},{"component":"extension"}]), "a.png"), + (json!([{"component":"name"},{"component":"EXTENSION"}]), "a.PNG"), + (json!([{"component":"name"},{"component":"extension"},{"component":"serial3"}]), "apng001"), + (json!([{"component":"name"},{"component":"serial3"}]), "a001"), + (json!([{"component":"name"},{"component":"extension"},{"text":"x"}]), "apngx"), + (json!([{"component":"name"},{"component":"extension"},{"component":"extension"}]), "apng.png"), + (json!([{"component":"name"},{"text":".jpg"}]), "a.jpg"), + ] { + assert_eq!(expand_components(parts.as_array().unwrap(), "a", 1, 0, "png", date).unwrap(), want); + } + } + + /// Check filename admission before processing, including counter overflow and portability limits. + #[test] + fn batch_component_preflight_retains_bounds_platform_checks_and_serial_overflow() { + let inputs = + vec![BatchInput { path: "input/Photo.png".into(), rel_dir: "sub".into() }, BatchInput { path: "input/Other.png".into(), rel_dir: String::new() }]; + let mut p = json!({"namingComponents":[{"component":"letter"}],"startingSerial":u32::MAX,"flatOutput":true, + "compatibility":{"macintosh":true,"windows":true,"unix":true}}); + let outputs = plan(&inputs, "output", "same", &p).unwrap().unwrap(); + assert_eq!(crate::file_cmds::file_name(&outputs[0]), "a"); + assert_eq!(crate::file_cmds::file_name(&outputs[1]), "b"); + assert!(!outputs[0].contains("sub")); + p["namingComponents"] = json!([{"component":"serial1"}]); + assert!(plan(&inputs, "output", "same", &p).is_err()); + for components in [ + json!([{"component":"unknown"}]), + json!([{"component":"name","text":"x"}]), + json!(vec![json!({"text":"x"}); 7]), + json!([{"text":"../escape"}]), + json!([{"text":"CON"}]), + ] { + p["startingSerial"] = json!(1); + p["namingComponents"] = components; + assert!(plan(&inputs, "output", "same", &p).is_err(), "{p}"); + } + p["namingComponents"] = json!([{"text":"safe"}]); + p["compatibility"]["windows"] = json!("yes"); + assert!(plan(&inputs, "output", "same", &p).is_err()); + } #[test] fn substituted_names_are_literal_and_serials_do_not_wrap_or_truncate() { diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 1a981298c0..6af9a34e21 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -1,5 +1,5 @@ use super::open::import_at_raw_ppi; -use super::paths::{extension, file_name, join, list_images, read_file, stem, write_file}; +use super::paths::{extension, file_name, join, list_files, list_images, read_file, stem, write_file}; use super::processing::parse_steps; use super::save::{SaveOpts, encode, save_doc, save_doc_as}; use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param}; @@ -35,28 +35,44 @@ pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000; /// subfolders, each tagged with its relative folder. `output` (when inside the input folder, as /// the dialog's default `/batch` is) is not walked, so a rerun doesn't process its results. pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result> { + collect_inputs(p, cmd, output, false) +} + +/// Collect folder inputs with Batch's explicit unsupported-file policy; arrays keep their order. +fn collect_inputs(p: &Value, cmd: &str, output: &str, include_non_images: bool) -> Result> { let recurse = match p.get("includeSubfolders") { None | Some(Value::Null) => false, Some(Value::Bool(b)) => *b, Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"includeSubfolders\" must be true or false".into() }), }; match p.get("input").or_else(|| p.get("files")) { - Some(Value::String(dir)) if recurse => list_images_tree(dir, output, cmd), + Some(Value::String(dir)) if recurse => list_files_tree(dir, output, cmd, include_non_images), + Some(Value::String(dir)) if include_non_images => { + Ok(list_files(dir, true)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()) + } _ => Ok(batch_inputs(p, cmd)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()), } } -/// The images under `dir` and its subfolders: each folder's files sorted by name, then its +/// The selected files under `dir` and its subfolders: each folder's files sorted by name, then its /// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and /// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip` -/// (the output folder) is left out. An unreadable reached folder is an error. +/// (the output folder) is left out. #[cfg(not(target_arch = "wasm32"))] -pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { +pub(crate) fn list_files_tree(dir: &str, skip: &str, cmd: &str, include_non_images: bool) -> Result> { use std::path::{Path, PathBuf}; - let canon = |p: &Path| std::fs::canonicalize(p).ok(); + let canon = |p: &Path| std::fs::canonicalize(p).map_err(|error| EngineError::Other(format!("{}: {error}", p.display()))); let root = Path::new(dir); - let root_canon = canon(root).ok_or_else(|| EngineError::Other(format!("{dir}: cannot open the folder")))?; - let skip = if skip.is_empty() { None } else { canon(Path::new(skip)) }; + let root_canon = canon(root)?; + let skip = if skip.is_empty() { + None + } else { + match std::fs::canonicalize(skip) { + Ok(path) => Some(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(EngineError::Other(format!("{skip}: {error}"))), + } + }; let mut seen = std::collections::HashSet::new(); seen.insert(root_canon); let mut out = Vec::new(); @@ -64,29 +80,34 @@ pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result = vec![(root.to_path_buf(), String::new(), 0)]; while let Some((folder, rel, depth)) = stack.pop() { let shown = folder.to_string_lossy().into_owned(); - // Fail before writing any output if a reached input folder cannot be read. - let files = list_images(&shown)?; + // A reached unreadable folder fails before any output is written. + let files = list_files(&shown, include_non_images)?; if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") }); + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} files with its subfolders") }); } out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() })); if depth >= MAX_SUBFOLDER_DEPTH { continue; } - let rd = std::fs::read_dir(&folder).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + let rd = std::fs::read_dir(&folder).map_err(|error| EngineError::Other(format!("{shown}: {error}")))?; let mut subs: Vec<(String, PathBuf)> = Vec::new(); for entry in rd { - let path = entry.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?.path(); - if path.is_dir() + let path = entry.map_err(|error| EngineError::Other(format!("{shown}: {error}")))?.path(); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, // Broken links are not inputs. + Err(error) => return Err(EngineError::Other(format!("{}: {error}", path.display()))), + }; + if metadata.is_dir() && let Some(name) = path.file_name() { - subs.push((name.to_string_lossy().into_owned(), path.clone())); + subs.push((name.to_string_lossy().into_owned(), path)); } } subs.sort(); // Pushed in reverse so the stack pops them in name order. for (name, sub) in subs.into_iter().rev() { - let c = canon(&sub).ok_or_else(|| EngineError::Other(format!("{}: cannot resolve the subfolder", sub.display())))?; + let c = canon(&sub)?; if skip.as_ref() == Some(&c) || !seen.insert(c) { continue; } @@ -98,10 +119,15 @@ pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result Result> { +pub(crate) fn list_files_tree(dir: &str, _skip: &str, _cmd: &str, _include_non_images: bool) -> Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } +/// Image-only tree traversal shared with native Batch consumers. +pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { + list_files_tree(dir, skip, cmd, false) +} + /// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format` /// (`"same"` keeps the input's extension). Errors per file are collected, not fatal. pub(crate) fn process_files( @@ -179,6 +205,31 @@ fn same_format_takes_the_file_names_extension() { assert_eq!(same_format("/in/.hidden"), "png"); } +/// A per-input failure retaining the complete response from nested work. +struct FileFailure { + error: EngineError, + result: Option, +} + +impl From for FileFailure { + fn from(error: EngineError) -> Self { + Self { error, result: None } + } +} + +impl FileFailure { + /// Ordinary failures keep their shape; nested failures also keep their results. + fn value(&self, key: &str, input: &str) -> Value { + let mut failure = json!({"error": self.error.to_string()}); + failure[key] = json!(input); + if let Some(result) = &self.result { + failure["result"] = result.clone(); + } + failure + } +} + +/// Run one source through its action and output pipeline while finalizing only that document's owned history scope. fn process_outputs<'a>( s: &Session, outputs: impl IntoIterator, @@ -187,72 +238,108 @@ fn process_outputs<'a>( ) -> Value { // The scratch sessions have default preferences; a raw opens at the user's Raw Defaults. let raw_ppi = s.prefs().raw_defaults.resolution; + let action_prefs = s.prefs().actions.clone(); let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = OutputClaims::default(); + let run = |scratch: &mut Session, out: &str| { + scratch.prefs.edit(|prefs| prefs.actions = action_prefs.clone()); + f(scratch, out).map_err(FileFailure::from) + }; for (path, out) in outputs { - let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None }, f); + let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None, input: None }, &run); match r { Ok(()) => { written.record(&out, path); files.push(out); } - Err(e) => errors.push(json!({"file": path, "error": e.to_string()})), + Err(error) => errors.push(error.value("file", path)), } } json!({"files": files, "errors": errors}) } +type PrepareInput<'a> = &'a dyn Fn(&mut Session, &str) -> Result<()>; + +struct InputPolicy<'a> { + prepare: PrepareInput<'a>, + notices: &'a std::cell::RefCell>, +} + struct ProcessOptions<'a> { format: Option<&'a str>, written: Option<&'a OutputClaims>, ctx: Option<&'a crate::jobs::JobCtx>, + input: Option<&'a InputPolicy<'a>>, } -fn process_one(raw_ppi: f64, path: &str, out: &str, save: SaveOpts, options: ProcessOptions<'_>, f: &dyn Fn(&mut Session, &str) -> Result) -> Result<()> { +/// Open and process one scratch input, collecting opening diagnostics even on failure. +/// All destination writes retain the worker's cancellation checkpoint. +fn process_one( + raw_ppi: f64, + path: &str, + out: &str, + save: SaveOpts, + options: ProcessOptions<'_>, + f: &dyn Fn(&mut Session, &str) -> std::result::Result, +) -> std::result::Result<(), FileFailure> { if let Some(written) = options.written { written.check(out).map_err(EngineError::Other)?; } - let bytes = read_file(path)?; let mut scratch = Session::new(); - let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; - scratch.add_document(doc, Some(path.to_string())); - if let Some(ctx) = options.ctx { - ctx.publish_batch_preview(path, scratch.active().map(|state| state.doc.clone())); - } - let saved_by_action = f(&mut scratch, out)?; - if saved_by_action { - // The action's saved snapshot is already written: a cancel or Esc arriving now stops the - // inputs after this one, not this result. - return Ok(()); - } - if let Some(ctx) = options.ctx { - ctx.check()?; - if ctx.batch_interrupt_requested() { - return Err(EngineError::Cancelled); - } - } - let d = scratch.active().ok_or(EngineError::NoDocument)?; - if let Some(format) = options.format { - let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; - if let Some(ctx) = options.ctx { - save_batch_doc(&d.doc, out, &ext, save, ctx)?; + let result = (|| -> std::result::Result<(), FileFailure> { + if let Some(input) = options.input { + (input.prepare)(&mut scratch, path)?; } else { - save_doc_as(&d.doc, out, &ext, save)?; + let bytes = read_file(path)?; + let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; + scratch.add_document(doc, Some(path.to_string())); } - } else { - save_doc(&d.doc, out, save)?; + if let Some(ctx) = options.ctx { + ctx.publish_batch_preview(path, scratch.active().map(|state| state.doc.clone())); + } + let saved_by_action = f(&mut scratch, out)?; + if saved_by_action { + // The action's saved snapshot is already written: a cancel or Esc arriving now stops the + // inputs after this one, not this result. + return Ok(()); + } + if let Some(ctx) = options.ctx { + ctx.check()?; + if ctx.batch_interrupt_requested() { + return Err(EngineError::Cancelled.into()); + } + } + let d = scratch.active().ok_or(EngineError::NoDocument)?; + let warnings = if let Some(format) = options.format { + let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + if let Some(ctx) = options.ctx { save_batch_doc(&d.doc, out, &ext, save, ctx)? } else { save_doc_as(&d.doc, out, &ext, save)? } + } else { + save_doc(&d.doc, out, save)? + }; + record_export_warnings(&mut scratch, out, warnings); + Ok(()) + })(); + if let Some(input) = options.input { + input.notices.borrow_mut().extend(scratch.batch_open_notices.take().unwrap_or_default()); } - Ok(()) + result } /// Encode before the Batch write checkpoint. Esc accepted during an action or encode therefore /// leaves the current destination untouched; the worker owns the write once the checkpoint passes. -fn save_batch_doc(doc: &Document, out: &str, format: &str, save: SaveOpts, ctx: &crate::jobs::JobCtx) -> Result<()> { - let (bytes, _) = encode(doc, format, save)?; +fn save_batch_doc(doc: &Document, out: &str, format: &str, save: SaveOpts, ctx: &crate::jobs::JobCtx) -> Result> { + let (bytes, warnings) = encode(doc, format, save)?; ctx.batch_before_write()?; write_file(out, &bytes)?; - Ok(()) + Ok(warnings) +} + +/// Keep encoder diagnostics beside the existing per-file opening notices, including snapshots. +fn record_export_warnings(scratch: &mut Session, out: &str, warnings: Vec) { + if !warnings.is_empty() { + scratch.batch_open_notices.get_or_insert_with(Vec::new).push(json!({"file": out, "warnings": warnings})); + } } #[derive(Clone, Debug)] @@ -343,6 +430,8 @@ pub fn validate_flat_outputs(outputs: &[(String, String)]) -> Result<()> { Ok(()) } +/// Exercise the native runner without recorded opening or collision-policy overrides. +#[cfg(test)] pub(super) fn process_batch_outputs( raw_ppi: f64, outputs: Vec<(String, String)>, @@ -352,6 +441,30 @@ pub(super) fn process_batch_outputs( ctx: &crate::jobs::JobCtx, f: &dyn Fn(&mut Session, &str) -> Result, ) -> Result { + process_batch_outputs_with(BatchRun { raw_ppi, format, save, policy, input: None, check_duplicates: false }, outputs, ctx, &|scratch, out| { + f(scratch, out).map_err(FileFailure::from) + }) +} + +struct BatchRun<'a> { + raw_ppi: f64, + format: &'a str, + save: SaveOpts, + policy: &'a BatchPolicy, + input: Option<&'a InputPolicy<'a>>, + check_duplicates: bool, +} + +/// Share background progress, interruption and write ordering across native and recorded inputs. +/// Recorded opening and destination policy vary without replacing those lifecycle checks. +fn process_batch_outputs_with( + config: BatchRun<'_>, + outputs: Vec<(String, String)>, + ctx: &crate::jobs::JobCtx, + f: &dyn Fn(&mut Session, &str) -> std::result::Result, +) -> Result { + let BatchRun { raw_ppi, format, save, policy, input, check_duplicates } = config; + let mut written = OutputClaims::default(); let total = outputs.len(); let mut files = Vec::new(); let mut errors = Vec::new(); @@ -368,11 +481,20 @@ pub(super) fn process_batch_outputs( break; } ctx.progress(index as f32 / total.max(1) as f32, &format!("{} of {total}: {}", index + 1, file_name(path))); - let result = process_one(raw_ppi, path, out, save, ProcessOptions { format: Some(format), written: None, ctx: Some(ctx) }, f); + let result = process_one( + raw_ppi, + path, + out, + save, + ProcessOptions { format: Some(format), written: check_duplicates.then_some(&written), ctx: Some(ctx), input }, + f, + ); + if result.is_ok() { + written.record(out, path); + } // An input that failed on its own (not because it was stopped) stays a failure with its // reason, even when a cancel or Esc lands while it runs. - let failure = - result.as_ref().err().filter(|error| !matches!(error, EngineError::Cancelled)).map(|error| json!({"file": path, "error": error.to_string()})); + let failure = result.as_ref().err().filter(|error| !matches!(&error.error, EngineError::Cancelled)).map(|error| error.value("file", path)); if ctx.cancelled() { ctx.publish_batch_preview(path, None); // A cancel that lands while the result is written still leaves that file written. @@ -415,7 +537,7 @@ pub(super) fn process_batch_outputs( files.push(out.clone()); } Err(error) => { - errors.push(json!({"file": path, "error": error.to_string()})); + errors.push(error.value("file", path)); if policy.stop_on_error { stopped = Some(path.clone()); break; @@ -448,21 +570,29 @@ pub(super) fn process_batch_outputs( /// The output paths a batch run has written, so a later input whose output name matches an /// earlier one's (`a.png` and `a.jpg` saved as JPEG, or the same name in two input folders) is /// reported instead of silently replacing that result (#420, #422). Names are compared ignoring -/// case, because macOS and Windows file systems do. +/// case, because macOS and Windows file systems do, and through symlinked folders, so two +/// spellings of one destination are one claim. #[derive(Default)] pub struct OutputClaims(pub(super) std::collections::HashMap); impl OutputClaims { + fn key(out: &str) -> String { + #[cfg(not(target_arch = "wasm32"))] + if let Some(identity) = super::paths::destination_identity(std::path::Path::new(out)) { + return identity.to_string_lossy().to_lowercase(); + } + out.to_lowercase() + } /// `Err` (naming the earlier input) when `out` was already written in this run. pub fn check(&self, out: &str) -> std::result::Result<(), String> { - match self.0.get(&out.to_lowercase()) { + match self.0.get(&Self::key(out)) { Some(first) => Err(format!("not written: {out} already holds the result of {first} from this run (same output name)")), None => Ok(()), } } /// Remember that `input`'s result was written to `out`. pub fn record(&mut self, out: &str, input: &str) { - self.0.insert(out.to_lowercase(), input.to_string()); + self.0.insert(Self::key(out), input.to_string()); } } @@ -478,33 +608,78 @@ pub(crate) fn batch_keeps_step(id: &str) -> bool { (batch_save_point(id) || !crate::actions_cmds::shell_save_command(id)) && !crate::actions_cmds::shell_view_command(id) } +pub(crate) fn batch_action(list: &[crate::actions_cmds::Action], p: &Value) -> Result { + const CMD: &str = "file.automate.batch"; + if let Some(set) = p.get("set").filter(|v| !v.is_null()) { + let set = set.as_str().ok_or_else(|| EngineError::BadParams { cmd: CMD.into(), msg: "set must be a string".into() })?; + let name = + p.get("action").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: CMD.into(), msg: "a set needs a named action".into() })?; + return list + .iter() + .position(|a| a.name == name && a.set.as_deref() == Some(set)) + .ok_or_else(|| EngineError::BadParams { cmd: CMD.into(), msg: format!("no action named `{name}` in set `{set}`") }); + } + crate::actions_cmds::resolve(list, p, CMD) +} + +/// Apply the selected file-error policy while retaining per-file results for direct callers. +/// Resolve explicit or named steps, then run folder work on scratch documents or edit live tabs. +/// Named actions carry their recursion context and both routes normalize recorded parameters. pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; - let mut steps = - parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; - // Each result goes to the destination folder, which overrides the action's recorded Save / - // Save As steps: they only mark the result. - steps.retain(|(id, _)| batch_keeps_step(id) && id != "file.export.quickExport"); - if let Some((id, _)) = steps.iter().find(|(id, _)| !batch_save_point(id) && crate::commands::find(id).is_none()) { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); + let quiet_raw = match p.get("suppressOpenOptions") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "suppressOpenOptions must be a boolean".into() }), + }; + let quiet_profiles = match p.get("suppressWarnings") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "suppressWarnings must be a boolean".into() }), + }; + let override_open = match p.get("overrideOpen") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "overrideOpen must be a boolean".into() }), + }; + // Save and Close destination adapted from #10026 (@edaldridgePersonal, b96be5300cfa). + let save_and_close = match p.get("destination") { + None | Some(Value::Null) => false, + Some(Value::String(value)) if value == "folder" => false, + Some(Value::String(value)) if value == "saveAndClose" => true, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "destination must be folder or saveAndClose".into() }), + }; + let save_close_tiff_layers = if save_and_close { + match p.get("tiffLayers") { + None | Some(Value::Null) => true, + Some(Value::Bool(value)) => *value, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "tiffLayers must be a boolean".into() }), + } + } else { + false + }; + if save_and_close { + if p.get("output").is_some_and(|v| !v.is_null()) { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "Save and Close writes each source; omit output".into() }); + } + if p.get("format").is_some_and(|v| !v.is_null() && v.as_str() != Some("same")) { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "Save and Close requires the source format (same)".into() }); + } } + let include_non_images = match p.get("includeNonImages") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "includeNonImages must be a boolean".into() }), + }; let policy = BatchPolicy::parse(p, cmd)?; let source = match p.get("source") { None | Some(Value::Null) => "folder", - Some(Value::String(source)) if matches!(source.as_str(), "folder" | "open") => source.as_str(), - _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"source\" must be \"folder\" or \"open\"".into() }), + Some(Value::String(value)) if matches!(value.as_str(), "folder" | "open") => value.as_str(), + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "source must be folder or open".into() }), }; - let output = if source == "folder" { - str_param(p, "output", cmd)?.to_string() - } else { - match p.get("output") { - None | Some(Value::Null) => String::new(), - Some(Value::String(path)) => path.clone(), - Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"output\" must be a folder path".into() }), - } - }; - let inputs = if source == "folder" { batch_inputs_tree(p, cmd, &output)? } else { Vec::new() }; - let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); + if source == "open" && (save_and_close || override_open || quiet_raw || quiet_profiles || include_non_images) { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "recorded folder-opening and Save and Close options require a folder source".into() }); + } // Steps that call another action play it from the caller's actions; the playback stack comes // along so an action that batches itself is caught as a recursive call. let list = match p.get("calledActions") { @@ -519,118 +694,367 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { list } }; + let mut called_name = None; + let mut steps = if let Some(st) = p.get("steps") { + parse_steps(st)? + } else if let Some(act) = p.get("action").filter(|v| v.is_array()) { + parse_steps(act)? + } else { + // The caller selects the source; calledActions supplies only its internal namespace. + let index = batch_action(&s.actions.list, p)?; + let (action, _) = crate::actions_cmds::playback_plan(s, &json!({"action": index}))?; + called_name = Some(action.name.clone()); + action.enabled_steps().map(|(_, (id, params))| (id.clone(), params.clone())).collect() + }; + let action_name = s + .action_history + .as_ref() + .map(|group| group.name.clone()) + .or_else(|| s.actions.playback_stack.first().cloned()) + .or_else(|| called_name.clone()) + .or_else(|| p.get("action").and_then(Value::as_str).map(str::to_string)) + .or_else(|| p.get("action").and_then(|action| action.get("name")).and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| "Batch".into()); + // Preserve the current Save snapshots and view-step policy while adding named actions. + steps.retain(|(id, _)| batch_keeps_step(id) && id != "file.export.quickExport"); + if let Some(value) = p.get("overrideSaveAs").filter(|v| !v.is_null()) { + let override_save = value.as_bool().ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "overrideSaveAs must be a boolean".into() })?; + let has_save = steps.iter().any(|(id, _)| batch_save_point(id)); + if save_and_close && override_save { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "Save and Close does not represent Override Save As".into() }); + } + if !save_and_close && override_save != has_save { + return Err(EngineError::BadParams { + cmd: cmd.into(), + msg: if override_save { + "this recorded Batch requires a Save step in its action".into() + } else { + "Batch cannot follow action Save destinations without Override Save As".into() + }, + }); + } + } + if let Some((id, _)) = steps.iter().find(|(id, _)| !batch_save_point(id) && crate::commands::find(id).is_none()) { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); + } + let output = if save_and_close || source == "open" { + match p.get("output") { + None | Some(Value::Null) => String::new(), + Some(Value::String(path)) => path.clone(), + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "output must be a folder path".into() }), + } + } else { + str_param(p, "output", cmd)?.to_string() + }; + let inputs = if source == "folder" { collect_inputs(p, cmd, &output, include_non_images)? } else { Vec::new() }; + if save_and_close + && inputs.iter().any(|input| s.documents().iter().any(|doc| doc.path.as_deref().is_some_and(|path| super::paths::same_file(&input.path, path)))) + { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "Save and Close cannot overwrite a source already open in this session".into() }); + } + let check_folder_format = include_non_images && p.get("input").or_else(|| p.get("files")).is_some_and(Value::is_string); + let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); // A batch inside a played action charges its inputs × steps (at least one each: an input is read, // decoded and saved even with no steps) to the playback tree it is in and - // hands the count on, so a batch of batches can't multiply past the limit. A top-level batch - // is not limited by it. - let input_count = if source == "open" { s.documents().len() } else { inputs.len() }; + // hands the count on, so nested batches cannot multiply past the limit. A top-level named + // action starts fresh; standalone explicit steps retain their existing behavior. + let named = called_name.is_some(); let mut playback_steps = 0; - if s.actions.playing > 0 { - playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(input_count as u64)); + if s.actions.playing > 0 || named { + let spent = if s.actions.playing > 0 { s.actions.playback_steps } else { 0 }; + playback_steps = + spent.saturating_add((steps.len().max(1) as u64).saturating_mul(if source == "open" { s.documents().len() } else { inputs.len() } as u64)); if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() }); + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the action playback step limit".into() }); + } + if s.actions.playing > 0 { + s.actions.playback_steps = playback_steps; } - s.actions.playback_steps = playback_steps; } + let mut playback_stack = s.actions.playback_stack.clone(); + playback_stack.extend(called_name); let actions = crate::actions_cmds::ActionState { list, - playing: s.actions.playing, - playback_stack: s.actions.playback_stack.clone(), + playing: if named { s.actions.playing.saturating_add(1) } else { s.actions.playing }, + playback_stack, playback_steps, + script_depth: s.actions.script_depth, + script_steps: s.actions.script_steps, ..Default::default() }; if source == "open" { let save = SaveOpts::from_params(s, p, cmd)?; - if p.get("calledActions").is_some_and(|value| !value.is_null()) { - let previous = std::mem::replace(&mut s.actions.list, actions.list.clone()); - let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save); - s.actions.list = previous; - return result; + // A caller's recording index belongs to its original library, not an embedded namespace. + // As in ordinary named playback, suspend it while executing and restore the Batch owner. + let previous_recording = if named { + crate::actions_cmds::flush_recording(s)?; + s.actions.recording.take() + } else { + None + }; + // Swap only execution context, retaining the recorder, revision and script counters. + let previous_playing = std::mem::replace(&mut s.actions.playing, actions.playing); + let previous_stack = std::mem::replace(&mut s.actions.playback_stack, actions.playback_stack); + let previous_steps = std::mem::replace(&mut s.actions.playback_steps, actions.playback_steps); + let previous_list = p.get("calledActions").is_some_and(|value| !value.is_null()).then(|| std::mem::replace(&mut s.actions.list, actions.list)); + let owns_history = s.action_history.is_none() && s.prefs().actions.create_single_history_state; + if owns_history { + crate::actions_cmds::begin_document_history(s, &action_name); } - return batch_open_documents(s, &steps, p, &output, &format, &policy, save); + let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save); + if owns_history { + crate::actions_cmds::finish_document_history(s); + } + s.actions.playing = previous_playing; + s.actions.playback_stack = previous_stack; + if let Some(previous) = previous_list { + s.actions.list = previous; + } + if previous_playing == 0 { + s.actions.playback_steps = previous_steps; + } + if named { + s.actions.recording = previous_recording; + } + return result; + } + let check_duplicates = recorded_output_guard(p); + let names = if save_and_close { None } else { batch_naming::plan(&inputs, &output, &format, p)? }; + let mut save = SaveOpts::from_params(s, p, cmd)?; + if save_and_close { + save.tiff_layers = save_close_tiff_layers; + save.preserve_alpha = true; } - let names = batch_naming::plan(&inputs, &output, &format, p)?; - let save = SaveOpts::from_params(s, p, cmd)?; let authorize = s.authorize; - let raw_ppi = s.prefs().raw_defaults.resolution; - let outputs: Vec<(String, String)> = match names { - Some(names) => inputs.iter().map(|input| input.path.clone()).zip(names).collect(), - None => inputs - .iter() - .map(|input| { - let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; - let out = join(&output, &format!("{}.{ext}", stem(&input.path))); - (input.path.clone(), out) - }) - .collect(), + let raw_ppi = super::open::raw_open_resolution(s); + let action_prefs = s.prefs().actions.clone(); + let prefs = s.prefs.clone(); + let color_settings = s.color.settings.clone(); + let raw_settings = s.batch_raw_open.as_ref().unwrap_or(&s.prefs().raw_defaults).clone(); + let script_count = s.actions.script_steps; + let outputs: Vec<(String, String)> = if save_and_close { + inputs.iter().map(|input| (input.path.clone(), input.path.clone())).collect() + } else { + match names { + Some(names) => inputs.iter().map(|input| input.path.clone()).zip(names).collect(), + None => inputs + .iter() + .map(|input| { + let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + (input.path.clone(), join(&output, &format!("{}.{ext}", stem(&input.path)))) + }) + .collect(), + } }; #[cfg(not(target_arch = "wasm32"))] validate_flat_outputs(&outputs)?; - validate_error_log(&policy, outputs.iter().map(|(input, _)| input.clone()), outputs.iter().map(|(_, output)| output.clone()))?; - // Each input runs in its own scratch session, so what the played steps spend is carried from - // one input to the next (failed or not) and back to this session, not restarted per input. + validate_error_log(&policy, outputs.iter().map(|(path, _)| path.clone()), outputs.iter().map(|(_, path)| path.clone()))?; let was_playing = s.actions.playing > 0; + #[cfg(test)] + let history_observer = s.batch_history_observer; crate::jobs::run( s, "Batch", false, move |ctx| { let carried = std::cell::Cell::new(playback_steps); - let run = |scratch: &mut Session, out: &str| { + let script_steps = std::cell::Cell::new(script_count); + let notices = std::cell::RefCell::new(Vec::new()); + // Whether this input's source opened read-only; Save and Close must not replace it. + let read_only_source = std::cell::Cell::new(false); + let prepare = |scratch: &mut Session, path: &str| -> Result<()> { + read_only_source.set(false); + #[cfg(not(target_arch = "wasm32"))] + if check_folder_format && !super::paths::openable_name(path) { + return Err(EngineError::Other(format!("unsupported Batch folder input: {path}"))); + } + #[cfg(target_arch = "wasm32")] + let _ = check_folder_format; + if quiet_profiles || quiet_raw { + scratch.prefs = prefs.clone(); + scratch.color.settings = color_settings.clone(); + if quiet_profiles { + scratch.color.settings.ask_on_mismatch = false; + scratch.color.settings.ask_on_missing = false; + } + scratch.authorize = authorize; + if quiet_raw { + scratch.batch_raw_open = Some(raw_settings.clone()); + } + scratch.batch_open_notices = Some(Vec::new()); + } + if override_open { + scratch.batch_open = Some(super::open::BatchOpen { path: path.to_string(), sources: Vec::new(), raw_ppi, read_only: false }); + } else { + let bytes = read_file(path)?; + if quiet_profiles || quiet_raw { + super::open::open_bytes_as(scratch, &file_name(path), &bytes, None, Some(path.to_string()))?; + read_only_source.set(scratch.active().is_some_and(|state| state.source_read_only)); + } else { + let (doc, read_only) = super::open::import_source_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; + read_only_source.set(read_only); + scratch.add_document(doc, Some(path.to_string())); + } + } + Ok(()) + }; + let input = InputPolicy { prepare: &prepare, notices: ¬ices }; + let run = |scratch: &mut Session, out: &str| -> std::result::Result { + let source_id = scratch.active().map(|state| state.doc.id); + let source_path = scratch + .batch_open + .as_ref() + .map(|binding| binding.path.clone()) + .or_else(|| scratch.active().and_then(|state| state.path.clone())) + .unwrap_or_default(); + let encode_format = if format == "same" { same_format(&source_path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + // Save and Close writes over the source, so its bytes must be the source's own + // format: a source PhotoCraft reads but saves as PSD (Cineon, whose extension has + // a writer elsewhere) would otherwise get PSD bytes under its own name. + if save_and_close + && !extension(out).is_some_and(|ext| { + ext == encode_format + && (matches!(ext.as_str(), "psd" | "psb" | "pcraft" | "ora") + || photocraft_codecs::from_extension(&ext).is_some_and(|format| format.caps().write)) + }) + { + return Err(EngineError::Other(format!("Save and Close needs Save As for this source format: {out}")).into()); + } + // Check the actual result, including a saved snapshot after Close, before either write path. + let validate_result = |scratch: &Session, doc: &Document| -> Result<()> { + if scratch.batch_open.as_ref().is_some_and(|binding| binding.sources.is_empty()) { + return Err(EngineError::Other("Override Open requires an executed Open step for this input".into())); + } + if !save_and_close { + return Ok(()); + } + let from_source = scratch.batch_open.as_ref().map_or(source_id == Some(doc.id), |binding| binding.sources.contains(&doc.id)); + if !from_source { + return Err(EngineError::Other("Save and Close needs the original source document or its recorded Save snapshot".into())); + } + // As File > Save does, never replace a source opened read-only (an Affinity, + // Krita, XCF or PDN file, even one named `.png`) with a flat encoding. + if read_only_source.get() || scratch.batch_open.as_ref().is_some_and(|binding| binding.read_only) { + return Err(EngineError::Other(format!("Save and Close cannot replace this source; save it to a separate output folder: {out}"))); + } + // All recorded Opens passed their gates before inspecting the original source, + // including when this result is a saved snapshot whose document has been closed. + preserve_source(out)?; + let tiff = extension(out).and_then(|ext| photocraft_codecs::from_extension(&ext)) == Some(photocraft_codecs::Format::Tiff); + if tiff { + if !matches!( + doc.pixel_format().mode, + photocraft_color::ColorMode::Rgb | photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Cmyk + ) || doc.mode == photocraft_color::ColorMode::Multichannel + { + return Err(EngineError::Other("Save and Close cannot preserve this TIFF color mode".into())); + } + if !save.tiff_layers && super::save::tiff_would_write_layers(doc) { + return Err(EngineError::Other("Save and Close would flatten TIFF layer data; keep tiffLayers enabled".into())); + } + } + Ok(()) + }; scratch.authorize = authorize; scratch.actions = actions.clone(); scratch.actions.playback_steps = carried.get(); - let result = (|| { - // A recorded save marks a result. If the action later closes or switches - // documents, write the saved snapshot instead of the new active document. - let source = scratch.active().and_then(|state| state.path.as_deref()).unwrap_or("").to_string(); - let encode_format = if format == "same" { same_format(&source) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + scratch.actions.script_steps = script_steps.get(); + scratch.prefs.edit(|prefs| prefs.actions = action_prefs.clone()); + let owns_history = scratch.action_history.is_none() && scratch.prefs().actions.create_single_history_state; + if owns_history { + crate::actions_cmds::begin_document_history(scratch, &action_name); + } + let result = (|| -> std::result::Result { let mut saved: Option> = None; for (id, params) in &steps { ctx.check()?; if ctx.batch_interrupt_requested() { - return Err(EngineError::Cancelled); + return Err(EngineError::Cancelled.into()); } + // A recorded Save is a step too: Batch writes the output for it. + charge_script_step(scratch)?; if batch_save_point(id) { - let state = scratch.active_mut().ok_or(EngineError::NoDocument)?; - saved = Some(state.doc.clone()); - // The destination save is performed by Batch; subsequent recorded - // Close steps see Save / Save As as saved, but Save a Copy as changed. + let st = scratch.active_mut().ok_or(EngineError::NoDocument)?; + saved = Some(st.doc.clone()); + // Batch performs the action's Save / Save As (to its destination), so the + // document is saved there as far as the steps after it see: a Close recorded + // without a save policy (`"save":"ask"`) then closes it. A Save a Copy leaves + // the document as changed as it was. if id != "file.saveACopy" { - state.saved_revision = state.revision; + st.saved_revision = st.revision; } continue; } - let result = scratch.execute(id, params.clone())?; - ctx.publish_batch_preview(&source, scratch.active().map(|state| state.doc.clone())); - if let Some(error) = crate::actions_cmds::nested_failure(id, &result) { - return Err(EngineError::Other(error)); + let params = crate::actions_cmds::playback_params(scratch, id, params)?; + let r = scratch.execute(id, params)?; + ctx.publish_batch_preview(&source_path, scratch.active().map(|state| state.doc.clone())); + // A failed step inside a called action makes this file an error, not a saved result. + if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { + return Err(FileFailure { error: EngineError::Other(error), result: Some(r) }); } } + // While the saved document is still open the steps after the save reach the result too + // (#3058), so it is saved after the action as usual. Closed after its save, or with another + // document left active, it is written as the save found it. match saved { - Some(doc) if scratch.active().is_none_or(|active| active.doc.id != doc.id) => { - ctx.check()?; - save_batch_doc(&doc, out, &encode_format, save, ctx)?; + Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { + validate_result(scratch, &doc)?; + let warnings = save_batch_doc(&doc, out, &encode_format, save, ctx)?; + record_export_warnings(scratch, out, warnings); Ok(true) } - _ => Ok(false), + _ => { + validate_result(scratch, &scratch.active().ok_or(EngineError::NoDocument)?.doc)?; + Ok(false) + } } })(); + if owns_history { + crate::actions_cmds::finish_document_history(scratch); + } + #[cfg(test)] + if let Some(observe) = history_observer { + observe(scratch); + } carried.set(scratch.actions.playback_steps); + script_steps.set(scratch.actions.script_steps); result }; - let result = process_batch_outputs(raw_ppi, outputs, &format, save, &policy, ctx, &run); - Ok((result, carried.get())) + let mut result = process_batch_outputs_with( + BatchRun { raw_ppi, format: &format, save, policy: &policy, input: Some(&input), check_duplicates }, + outputs, + ctx, + &run, + )?; + let notices = notices.into_inner(); + if !notices.is_empty() { + result["notices"] = json!(notices); + } + Ok((result, carried.get(), script_steps.get())) }, - move |s, (result, carried)| { + move |s, (result, carried, script_steps)| { if was_playing { s.actions.playback_steps = carried; } - result + s.actions.script_steps = script_steps; + let mut result = result; + mark_script_limit(s, &mut result); + if let (Some(notices), Some(imports)) = (&mut s.batch_open_notices, result.get("notices").and_then(Value::as_array)) { + notices.extend(imports.iter().cloned()); + } + Ok(result) }, ) } +/// Preserve the recorded/structured collision policy while ordinary native Batch keeps +/// current main's later-file-wins behavior. The validated request uses this in both source modes. +fn recorded_output_guard(p: &Value) -> bool { + p.get("destination").and_then(Value::as_str) == Some("saveAndClose") + || p.get("includeNonImages").and_then(Value::as_bool) == Some(true) + || p.get("namingComponents").is_some_and(|value| !value.is_null()) +} + /// Source: Opened Files runs on the live documents, so edits remain in their histories. /// Documents are identified before playback; a step that closes or reorders tabs cannot make /// the next input silently become a different document. @@ -682,15 +1106,24 @@ fn batch_open_documents( let mut errors = Vec::new(); let mut stopped = None; let mut done = 0; + let mut written = OutputClaims::default(); + let check_duplicates = recorded_output_guard(p); for (index, (id, name, path)) in originals.iter().enumerate() { // The document a recorded Save / Save As marked as saved, with its earlier saved // revision: when Batch then writes nothing, it reads as changed again. let mut marked: Option<(u64, u64)> = None; - let result = (|| -> Result<()> { + let result = (|| -> std::result::Result<(), FileFailure> { + // A rejected destination cannot edit the next document either. + if check_duplicates && let Some(paths) = &planned { + let out = paths.get(index).ok_or_else(|| EngineError::Other("batch output plan is incomplete".into()))?; + written.check(out).map_err(EngineError::Other)?; + } let position = s.documents().iter().position(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?; s.set_active(position); let mut saved: Option> = None; for (command, params) in steps { + // A recorded Save is a step too: Batch writes the output for it. + charge_script_step(s)?; if batch_save_point(command) { let state = s.active_mut().ok_or(EngineError::NoDocument)?; saved = Some(state.doc.clone()); @@ -703,9 +1136,10 @@ fn batch_open_documents( } continue; } - let response = s.execute(command, params.clone())?; + let params = crate::actions_cmds::playback_params(s, command, params)?; + let response = s.execute(command, params)?; if let Some(error) = crate::actions_cmds::nested_failure(command, &response) { - return Err(EngineError::Other(error)); + return Err(FileFailure { error: EngineError::Other(error), result: Some(response) }); } } let Some(paths) = &planned else { return Ok(()) }; @@ -717,6 +1151,7 @@ fn batch_open_documents( }; let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; save_doc_as(&doc, out, &ext, save)?; + written.record(out, path); files.push(out.clone()); Ok(()) })(); @@ -731,7 +1166,7 @@ fn batch_open_documents( } } if let Err(error) = result { - errors.push(json!({"document": name, "error": error.to_string()})); + errors.push(error.value("document", name)); if policy.stop_on_error { stopped = Some(name.clone()); break; @@ -750,5 +1185,317 @@ fn batch_open_documents( result["errorLogError"] = json!(error); } result["documents"] = json!(originals.len()); + mark_script_limit(s, &mut result); Ok(result) } + +/// A Batch run from a script charges each step it executes to the script's total, as nested +/// playback does (`consume_playback_step`); the Batch itself is only one script step. +fn charge_script_step(s: &mut Session) -> Result<()> { + if s.actions.script_depth == 0 { + return Ok(()); + } + let max = crate::automate_cmds::MAX_SCRIPT_STEPS; + if s.actions.script_steps >= max { + return Err(EngineError::Other(format!("the script ran more than {max} steps in all (scripts calling scripts)"))); + } + s.actions.script_steps += 1; + Ok(()) +} + +/// A file that failed once the script's step budget is spent failed because of it, and every +/// later script step would fail the same way: the result says so, so a script running this +/// Batch directly stops with that error (`batch_stop`) instead of reporting success. +fn mark_script_limit(s: &Session, result: &mut Value) { + let max = crate::automate_cmds::MAX_SCRIPT_STEPS; + let failed = result.get("errors").and_then(Value::as_array).is_some_and(|errors| !errors.is_empty()); + if s.actions.script_depth > 0 && s.actions.script_steps >= max && failed { + result["scriptLimitError"] = json!(format!("the script ran more than {max} steps in all (scripts calling scripts)")); + } +} + +/// Check source structure once at the common pre-write seam, after Open authorization and ownership. +fn preserve_source(path: &str) -> Result<()> { + let bytes = read_file(path)?; + if photocraft_codecs::has_unretained_source_data(&bytes).map_err(|e| EngineError::Other(format!("cannot inspect source data: {e}")))? { + return Err(EngineError::Other("Save and Close cannot preserve additional source data; save to a separate output folder".into())); + } + if photocraft_codecs::detect(&bytes) == Some(photocraft_codecs::Format::OpenExr) { + preserve_exr(&bytes)?; + } + Ok(()) +} + +/// Only a single flat, unprefixed RGB(A) source can be preserved by the EXR writer. +fn preserve_exr(bytes: &[u8]) -> Result<()> { + let parts = photocraft_codecs::exr_info(bytes, &Default::default()).map_err(|e| EngineError::Other(format!("cannot inspect EXR source: {e}")))?; + if parts.len() != 1 + || parts.first().is_none_or(|p| { + p.data_window != (0, 0, p.width, p.height) + || p.display_window != p.data_window + || p.deep + || p.view.is_some() + || !(3..=4).contains(&p.channels.len()) + || p.channels.iter().any(|c| !matches!(c.name.as_str(), "R" | "G" | "B" | "A")) + || ["R", "G", "B"].iter().any(|name| !p.channels.iter().any(|c| c.name == *name)) + }) + { + return Err(EngineError::Other( + "Save and Close cannot preserve this EXR's parts or channels, or its data/display windows; save to a separate output folder".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod action_history_tests { + use super::*; + use crate::actions_cmds::Action; + use std::cell::RefCell; + + #[derive(Debug)] + struct Observed { + id: photocraft_doc::DocId, + past: usize, + label: String, + name: String, + undo_name: String, + } + + thread_local! { static OBSERVED: RefCell> = const { RefCell::new(Vec::new()) }; } + + /// Inspect the real scratch boundary without retaining or changing the scratch session. + fn observe(s: &Session) { + assert!(s.action_history.is_none(), "Batch must release the scratch scope on every exit"); + let st = s.active().unwrap(); + let mut history = st.history.clone(); + let (before, _) = history.undo(st.doc.clone()).unwrap(); + let undo_name = before.layer(st.active_layer.unwrap()).unwrap().name.clone(); + let (after, _) = history.redo(before).unwrap(); + assert_eq!(after.as_ref(), st.doc.as_ref(), "redo restores exact document and layer identities"); + let name = st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone(); + OBSERVED.with(|seen| { + seen.borrow_mut().push(Observed { id: st.doc.id, past: st.history.past_len(), label: st.history.undo_label().unwrap().into(), name, undo_name }) + }); + } + + /// Batch and droplets keep one independent named entry per input, including nested and stopped runs. + #[test] + fn batch_and_droplet_document_history_boundaries() { + let dir = tempfile::tempdir().unwrap(); + let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![255; 48]).unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let paths: Vec<_> = ["a.png", "b.png"] + .iter() + .map(|name| { + let path = dir.path().join(name); + std::fs::write(&path, &bytes).unwrap(); + path + }) + .collect(); + for (case, single, nested, stopped, droplet, named) in [ + ("batch", true, false, false, false, false), + ("off", false, false, false, false, false), + ("nested", true, true, false, false, false), + ("stopped", true, false, true, false, false), + ("droplet", true, false, false, true, false), + ("named", true, false, false, false, true), + ("named-off", false, false, false, false, true), + ("named-nested", true, true, false, false, true), + ("named-stopped", true, false, true, false, true), + ] { + OBSERVED.with(|seen| seen.borrow_mut().clear()); + let mut s = Session::new(); + s.batch_history_observer = Some(observe); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + let mut steps = vec![("layer.renameLayer".into(), json!({"name":"Prefix"}))]; + if stopped { + steps.push(("actions.stopMessage".into(), json!({"message":"Stop here"}))); + } + steps.push(("layer.renameLayer".into(), json!({"name":"Final"}))); + let action_steps = if nested { + s.actions.list.push(Action::new("Child", steps)); + vec![("actions.play".to_string(), json!({"action":"Child"}))] + } else { + steps + }; + let output = dir.path().join(case); + let result = if droplet { + let path = dir.path().join("named.pcdroplet"); + s.execute("file.automate.createDroplet", json!({"path":path,"name":"Named","steps":action_steps,"output":output,"shim":false})).unwrap(); + s.execute("file.automate.runDroplet", json!({"droplet":path,"input":paths})).unwrap() + } else if named { + let index = s.actions.list.len(); + s.actions.list.push(Action::new("Named", action_steps)); + s.execute("file.automate.batch", json!({"action":index,"input":paths,"output":output})).unwrap() + } else { + s.execute("file.automate.batch", json!({"action":"Named","steps":action_steps,"input":paths,"output":output})).unwrap() + }; + assert_eq!(result["errors"].as_array().unwrap().len(), if stopped { 2 } else { 0 }, "{case}: {result}"); + OBSERVED.with(|seen| { + let seen = seen.borrow(); + assert_eq!(seen.len(), 2, "{case}"); + assert_ne!(seen[0].id, seen[1].id, "inputs have separate editing identities"); + for entry in seen.iter() { + assert_eq!(entry.past, if single { 1 } else { 2 }, "{case}: {entry:?}"); + if single { + assert_eq!(entry.label, "Action: Named", "{case}"); + } + assert_eq!(entry.name, if stopped { "Prefix" } else { "Final" }, "{case}"); + if !single { + assert_eq!(entry.undo_name, "Prefix", "OFF undoes only the last step"); + } else { + assert_ne!(entry.undo_name, "Prefix", "ON undoes the whole play"); + } + } + }); + } + } + + #[test] + /// Batch over open documents must preserve an enclosing action scope and finish its own document scopes. + fn opened_document_batch_keeps_top_level_and_parent_history_boundaries() { + for (single, parent, named) in [false, true] + .into_iter() + .flat_map(|single| [false, true].into_iter().flat_map(move |parent| [false, true].into_iter().map(move |named| (single, parent, named)))) + { + let mut s = Session::new(); + for _ in 0..2 { + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + } + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + let before: Vec<_> = s.documents().iter().map(|st| (st.doc.clone(), st.history.past_len())).collect(); + let steps = vec![("layer.renameLayer".into(), json!({"name":"Prefix"})), ("layer.renameLayer".into(), json!({"name":"Final"}))]; + let params = if named { + s.actions.list.push(Action::new("Opened batch", steps)); + json!({"source":"open","action":0}) + } else { + json!({"source":"open","action":"Opened batch","steps":steps}) + }; + if parent { + s.actions.list.push(Action::new( + "Parent", + vec![ + ("layer.renameLayer".into(), json!({"name":"Before batch"})), + ("file.automate.batch".into(), params), + ("layer.renameLayer".into(), json!({"name":"After batch"})), + ], + )); + let result = s.execute("actions.play", json!({"action":"Parent"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + } else { + let result = s.execute("file.automate.batch", params).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + } + assert!(s.action_history.is_none()); + for (index, (original, past)) in before.iter().enumerate() { + s.set_active(index); + let st = s.active().unwrap(); + let after = st.doc.clone(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if parent && index == 1 { "After batch" } else { "Final" }); + let steps = if parent && index == 1 { 4 } else { 2 }; + assert_eq!(st.history.past_len(), past + if single { 1 } else { steps }); + if single { + assert_eq!(st.history.undo_label(), Some(if parent { "Action: Parent" } else { "Action: Opened batch" })); + } + assert!(s.undo()); + if single { + assert_eq!(s.active().unwrap().doc.as_ref(), original.as_ref()); + } else { + assert_ne!(s.active().unwrap().doc.as_ref(), original.as_ref()); + } + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } + } + + #[test] + /// A Batch failure restores the library and leaves the caller's active history scope owned by the caller. + fn opened_document_batch_error_restores_library_without_finishing_parent() { + for parent in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":true})).unwrap(); + s.actions.list.push(Action::new("Original", vec![])); + let library = serde_json::to_value(&s.actions.list).unwrap(); + let before = s.active().unwrap().doc.clone(); + if parent { + crate::actions_cmds::begin_document_history(&mut s, "Parent"); + } + let result = s + .execute( + "file.automate.batch", + json!({ + "source":"open","action":"Broken","calledActions":[Action::new("Foreign", vec![])], + "steps":[["layer.renameLayer",{"name":"Prefix"}],["layer.renameLayer",{"name":false}]] + }), + ) + .unwrap(); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(serde_json::to_value(&s.actions.list).unwrap(), library); + if parent { + assert_eq!(s.action_history.as_ref().unwrap().name, "Parent"); + s.execute("layer.renameLayer", json!({"name":"Parent tail"})).unwrap(); + crate::actions_cmds::finish_document_history(&mut s); + } else { + assert!(s.action_history.is_none()); + } + let st = s.active().unwrap(); + let after = st.doc.clone(); + assert_eq!(st.history.undo_label(), Some(if parent { "Action: Parent" } else { "Action: Broken" })); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } + + thread_local! { static CANCEL_CONTEXT: RefCell> = const { RefCell::new(None) }; } + + fn cancel_after_prefix(id: &str, params: &Value) -> Result<()> { + if id == "layer.renameLayer" && params["name"] == "Cancel here" { + CANCEL_CONTEXT.with(|ctx| ctx.borrow().as_ref().unwrap().cancel()); + return Err(EngineError::Cancelled); + } + Ok(()) + } + + #[test] + /// Cancellation after a completed edit retains an undoable prefix without leaving a Batch-owned scope open. + fn cancelled_folder_batch_finalizes_its_completed_prefix() { + let dir = tempfile::tempdir().unwrap(); + let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![255; 48]).unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let input = dir.path().join("input.png"); + std::fs::write(&input, bytes).unwrap(); + let mut s = Session::new(); + s.execute("actions.setHistory", json!({"singleState":true})).unwrap(); + let ctx = crate::jobs::JobCtx::new(); + s.set_inline_job_ctx(Some(ctx.clone())); + CANCEL_CONTEXT.with(|slot| *slot.borrow_mut() = Some(ctx.clone())); + OBSERVED.with(|seen| seen.borrow_mut().clear()); + s.authorize = Some(cancel_after_prefix); + s.batch_history_observer = Some(observe); + let result = s + .execute( + "file.automate.batch", + json!({"action":"Cancelled batch","input":[input],"output":dir.path().join("out"), + "steps":[["layer.renameLayer",{"name":"Prefix"}],["layer.renameLayer",{"name":"Cancel here"}]] + }), + ) + .unwrap(); + CANCEL_CONTEXT.with(|slot| *slot.borrow_mut() = None); + assert!(ctx.cancelled()); + assert_eq!(result["cancelled"], true, "{result}"); + assert!(result["files"].as_array().unwrap().is_empty(), "{result}"); + OBSERVED.with(|seen| { + let seen = seen.borrow(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].past, 1); + assert_eq!(seen[0].label, "Action: Cancelled batch"); + assert_eq!(seen[0].name, "Prefix"); + assert_ne!(seen[0].undo_name, "Prefix"); + }); + } +} diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 1397702318..6121180e22 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -1,8 +1,41 @@ use super::paths::{file_name, read_file, stem, template_name}; -use super::{ - Affine, Arc, Document, EngineError, Layer, LayerContent, Resample, Result, Session, SmartObject, SmartSource, Value, f64_param, flattened_checked, json, - resize_surface, str_param, translate_surface, -}; +use super::{Arc, Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, PlaceSteps, Result, Session, Value, json, str_param}; + +/// Input and successfully opened document identities of one Override Open scratch session. +pub(crate) struct BatchOpen { + pub path: String, + pub sources: Vec, + pub raw_ppi: f64, + /// A recorded Open read a source that must not be replaced (Affinity, Krita, XCF, PDN). + pub read_only: bool, +} + +/// Replace only the file choice before either authorization gate; malformed params stay invalid. +pub(crate) fn batch_open_params(s: &Session, id: &str, params: &Value) -> Option { + let binding = s.batch_open.as_ref()?; + if !matches!(id, "file.openAs" | "file.openExrParts") || !(params.is_object() || params.is_null()) { + return None; + } + let mut effective = if params.is_null() { super::json!({}) } else { params.clone() }; + effective.as_object_mut()?.insert("path".into(), Value::String(binding.path.clone())); + Some(effective) +} + +/// Keep import warnings and the applied color policy when only profile questions are suppressed. +pub(crate) fn batch_open_notice(s: &mut Session, name: &str, warnings: &[String], color: &Value) { + if let Some(notices) = &mut s.batch_open_notices { + notices.push(json!({"file":name,"warnings":warnings,"color":color})); + } +} + +/// Record provenance after a redirected native Open succeeds, including repeated Opens. +pub(crate) fn remember_batch_open(s: &mut Session) { + let opened = s.active().map(|st| (st.doc.id, st.source_read_only)); + if let (Some(binding), Some((id, read_only))) = (&mut s.batch_open, opened) { + binding.sources.push(id); + binding.read_only |= read_only; + } +} pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { import_with_options(name, bytes, &photocraft_io::ImportOptions::default()) @@ -22,10 +55,16 @@ pub(crate) fn import_opened(s: &Session, name: &str, bytes: &[u8]) -> Result Result { + import_source_at_raw_ppi(raw_ppi, name, bytes).map(|(doc, _)| doc) +} + +/// [`import_at_raw_ppi`], also telling whether the source must not be replaced by a save +/// (`ImportResult::source_read_only`: Affinity, Krita, XCF, PDN, whatever its extension says). +pub(super) fn import_source_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Result<(Document, bool)> { let r = import_result(name, bytes)?; let mut doc = r.document; raw_resolution(raw_ppi, &r.warnings, &mut doc); - Ok(doc) + Ok((doc, r.source_read_only)) } fn import_result(name: &str, bytes: &[u8]) -> Result { @@ -112,7 +151,12 @@ pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { /// Camera Raw's workflow options set it (the raw itself states none). `warnings` are the /// import's notes, which say whether the raw was developed or only its preview opened. pub fn raw_defaults(s: &Session, warnings: &[String], doc: &mut Document) { - raw_resolution(s.prefs().raw_defaults.resolution, warnings, doc); + raw_resolution(raw_open_resolution(s), warnings, doc); +} + +/// RAW resolution inherited by nested Batch inputs, or the session preference outside a binding. +pub(crate) fn raw_open_resolution(s: &Session) -> f64 { + s.batch_open.as_ref().map_or_else(|| s.prefs().raw_defaults.resolution, |binding| binding.raw_ppi) } fn raw_resolution(ppi: f64, warnings: &[String], doc: &mut Document) { @@ -121,6 +165,7 @@ fn raw_resolution(ppi: f64, warnings: &[String], doc: &mut Document) { } } +/// Import bytes with an optional format override, retaining source identity and opening diagnostics. pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { open_bytes_as_with_options(s, name, bytes, as_ext, path, false) } @@ -145,7 +190,16 @@ pub fn open_bytes_as_with_options( let mut opts = crate::jobs::import_options(s.prefs()); opts.psd_high_memory_approved = approve_high_memory; opts.psd_approved_estimate = None; - let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| crate::error::io_open_error(&decode_name, e))?; + let raw = super::raw_open::prepare(s, &decode_name, path.as_deref(), bytes)?; + let (r, raw_settings) = if let Some((imported, settings)) = raw { + (imported, Some(settings)) + } else { + let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| crate::error::io_open_error(&decode_name, e))?; + (r, None) + }; + if (s.batch_open.is_some() || s.batch_open_notices.is_some()) && r.preview_only { + return Err(EngineError::Other(format!("{name}: only an embedded preview could be read; Batch cannot process it as the source"))); + } let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); raw_defaults(s, &r.warnings, &mut doc); @@ -159,15 +213,23 @@ pub fn open_bytes_as_with_options( path } }; + if raw_settings.is_some() && s.color.open_question(&doc).is_some() { + return Err(EngineError::Other(format!("{name}: a color profile question requires interactive opening or suppressWarnings"))); + } // Color Settings policies (preserve / convert / discard the embedded profile). let (i, color) = s.open_document(doc, path.filter(|_| !r.source_read_only)); if let Some(st) = s.active_mut() { st.source_read_only = r.source_read_only; } + if let Some(settings) = raw_settings { + super::raw_open::finish(s, settings)?; + } + batch_open_notice(s, name, &r.warnings, &color); // Import notes (e.g. how a camera raw was developed, or that only its preview opened). Ok(json!({"document": i, "color": color, "warnings": r.warnings})) } +/// Open the selected file with its decoder options and record its Batch source identity. pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.openAs")?.to_string(); let as_ext = p.get("as").or_else(|| p.get("format")).and_then(Value::as_str); @@ -179,7 +241,9 @@ pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { } }; let bytes = read_file(&path)?; - open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory) + let result = open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory)?; + remember_batch_open(s); + Ok(result) } /// Answers a pending high-memory preflight: `approve` false cancels it; true rechecks the hard @@ -219,7 +283,8 @@ pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { // does not keep the tile share), and the import re-checks the exact split before decoding. let budget = photocraft_io::memory::resolve_budget_with_scratch(photocraft_io::memory::MemorySnapshot::current(), None, u64::MAX); if preflight.required > budget.hard_limit { - return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit }); + let free_memory = budget.refusal_free_memory(preflight.required); + return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit, free_memory }); } let started = s.start_open_approved_with_background( &preflight.name, @@ -245,71 +310,153 @@ pub const PLACE_EMBEDDED: &str = "Place Embedded"; /// `linked` makes it a linked smart object that refers to that path instead of embedding the bytes. /// With Preferences ▸ General ▸ Always Create Smart Objects When Placing off, an embedded place /// lands as a plain pixel layer instead (an explicit `"smartObject": false` asks for one). +/// +/// The decoding and resampling are a background job when the command was started with +/// [`Session::start`] (see [`crate::jobs`]); inline otherwise. The layer is inserted by the apply +/// step on the calling thread, as one undo step. pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let (cw, ch) = (d.doc.size.width as f64, d.doc.size.height as f64); - let fmt = d.doc.pixel_format(); - let vector = photocraft_io::svg::is_svg(&bytes); - let approve = match p.get("approveHighMemory") { - None | Some(Value::Null) => false, - Some(Value::Bool(b)) => *b, - Some(_) => { - return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); - } - }; - let mut opts = crate::jobs::import_options(s.prefs()); - opts.psd_high_memory_approved = approve; - opts.psd_approved_estimate = None; - let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| crate::error::io_open_error(name, e))?; - if imported.preview_only { - return Err(EngineError::Other(format!( - "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" - ))); + let input = PlaceInput::new(s, name, bytes, linked, p)?; + place_input(s, input) +} + +/// Run a place: in the background when the command was started with [`Session::start`]. +fn place_input(s: &mut Session, input: PlaceInput) -> Result { + let label = input.job_label(); + crate::jobs::run_stepped(s, &label, true, PlaceSteps::new(input, s.cooperative_jobs()), apply_place) +} + +/// The file a place reads. +pub(super) enum PlaceFile { + /// Its contents, already read (a dropped or picked file with no path, a script's bytes). + Bytes(Arc>), + /// Its path, read by [`PlaceSteps`]: on the job's worker when the place runs in the + /// background, so a large file doesn't hold up the window while it is read. + Path(String), +} + +/// Everything a place reads from the session, taken up front so [`PlaceSteps`] can run on a +/// worker that never sees the [`Session`]. +pub(crate) struct PlaceInput { + pub(super) name: String, + pub(super) file: PlaceFile, + pub(super) linked: Option, + pub(super) params: Value, + pub(super) canvas: (f64, f64), + pub(super) fmt: PixelFormat, + pub(super) opts: photocraft_io::ImportOptions, + /// Preferences ▸ General ▸ Resize Image During Place, unless the params say otherwise. + pub(super) fit: bool, + /// Preferences ▸ General ▸ Always Create Smart Objects When Placing, unless the params say + /// otherwise. A linked place is always a smart object. + pub(super) smart: bool, + /// The document as the job sees it, for rendering a vector source at its placement scale. A + /// path isn't read yet, so whether it is a vector source is decided once it is. + pub(super) doc: Option>, + /// The frame the image goes into: the active layer's, `"frame"` in the params, or none + /// (`"frame": false`). Its image renders through a smart object. + pub(super) frame: Option<(LayerId, photocraft_doc::Frame)>, +} + +impl PlaceInput { + /// The input of a place of `bytes` already read (named `name`; `linked`: Place Linked to that path). + pub(crate) fn new(s: &Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { + Self::with_file(s, name, PlaceFile::Bytes(Arc::new(bytes)), linked, p) } - let src = imported.document; - let (w, h) = (src.size.width as f64, src.size.height as f64); - // The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default). - let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(s.prefs().general.resize_image_during_place); - let scale = match f64_param(p, "scale") { - Some(k) => (k / 100.0).max(1e-4), - None if fit && (w > cw || h > ch) => (cw / w).min(ch / h), - None => 1.0, - }; - let center = match p.get("center").and_then(Value::as_array) { - Some(c) if c.len() == 2 => (c[0].as_f64().unwrap_or(cw / 2.0), c[1].as_f64().unwrap_or(ch / 2.0)), - _ => (cw / 2.0, ch / 2.0), - }; - let (dx, dy) = ((center.0 - w * scale / 2.0).round(), (center.1 - h * scale / 2.0).round()); - let mut px = flattened_checked(&src, fmt, "file.place")?; - if (scale - 1.0).abs() > 1e-9 { - px = resize_surface(&px, scale, scale, Resample::Bicubic); - } - let px = translate_surface(&px, dx as i32, dy as i32); - // Preferences ▸ General ▸ Always Create Smart Objects When Placing (on by default): off, an - // embedded place lands as a plain pixel layer. A linked place is always a smart object. - let smart = linked.is_some() || p.get("smartObject").and_then(Value::as_bool).unwrap_or(s.prefs().general.always_create_smart_objects_when_placing); - let source = match linked { - Some(path) => SmartSource::Linked { path }, - None => SmartSource::Embedded { file_name: file_name(name), bytes: Arc::new(bytes) }, - }; - let layer_name = stem(name); - let label = if matches!(&source, SmartSource::Linked { .. }) { "Place Linked" } else { PLACE_EMBEDDED }; - let id = s.edit(label, |doc, active| { - let layer = if smart { - let mut so = SmartObject::new(source, Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, Some(px)); - // A vector source (SVG) renders at its placement scale instead of as a resampled raster. - if vector && let Some(sharp) = crate::smart_cmds::render(doc, &so)? { - so.cache = Some(sharp); - } - Layer::new(layer_name, LayerContent::Smart(so)) - } else { - Layer::new(layer_name, LayerContent::Raster(px)) + + /// The input of a place of `file`, with the destination (canvas or frame), preferences and + /// import settings taken from the session up front. + fn with_file(s: &Session, name: &str, file: PlaceFile, linked: Option, p: &Value) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let prefs = s.prefs(); + let frame_id = match p.get("frame") { + Some(Value::Bool(false)) => None, + Some(v) if !v.is_null() => Some(LayerId(v.as_u64().ok_or_else(|| EngineError::Other("frame must be a frame id or false".into()))?)), + _ => d.active_layer.and_then(|id| crate::frame_cmds::frame_of(&d.doc, id)), }; - let id = crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); - *active = Some(id); - Ok(id) + let frame = frame_id + .map(|id| { + d.doc + .layer(id) + .and_then(|l| l.frame.as_deref().cloned()) + .map(|f| (id, f)) + .ok_or_else(|| EngineError::Other("the destination frame is unavailable".into())) + }) + .transpose()?; + let canvas = frame.as_ref().map_or((d.doc.size.width as f64, d.doc.size.height as f64), |(_, f)| { + let [a, b, c, d, _, _] = f.transform.m; + (a.hypot(b), c.hypot(d)) + }); + let approve = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; + let mut opts = crate::jobs::import_options(prefs); + opts.psd_high_memory_approved = approve; + opts.psd_approved_estimate = None; + Ok(Self { + name: name.to_string(), + canvas, + fmt: d.doc.pixel_format(), + opts, + // The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default). + fit: p.get("fit").and_then(Value::as_bool).unwrap_or(prefs.general.resize_image_during_place), + // Preferences ▸ General ▸ Always Create Smart Objects When Placing (on by default): off, + // an embedded place lands as a plain pixel layer. + smart: linked.is_some() || p.get("smartObject").and_then(Value::as_bool).unwrap_or(prefs.general.always_create_smart_objects_when_placing), + doc: match &file { + PlaceFile::Bytes(bytes) => (frame.is_some() || photocraft_io::is_svg_input(name, bytes)).then(|| Arc::clone(&d.doc)), + PlaceFile::Path(_) => Some(Arc::clone(&d.doc)), + }, + frame, + params: p.clone(), + file, + linked, + }) + } + + /// The job's name in the progress UI. + pub(crate) fn job_label(&self) -> String { + format!("Placing {}", file_name(&self.name)) + } + + /// The command this place stands for. + pub(crate) fn command(&self) -> &'static str { + if self.linked.is_some() { "file.placeLinked" } else { "file.placeEmbedded" } + } +} + +/// A place decoded, flattened and fitted, ready to be inserted by [`apply_place`]. +pub(crate) struct Prepared { + pub(super) content: LayerContent, + pub(super) layer_name: String, + /// The history step's name. + pub(super) label: &'static str, + pub(super) scale: f64, + pub(super) bounds: [f64; 4], + pub(super) smart: bool, + /// The frame the layer fills, if any (it becomes the frame's content instead of a new layer). + pub(super) frame: Option, +} + +/// Insert the prepared layer as one undo step and report the place. Runs on the session's thread. +pub(crate) fn apply_place(s: &mut Session, p: Prepared) -> Result { + let Prepared { content, layer_name, label, scale, bounds, smart, frame } = p; + let layer = Layer::new(layer_name, content); + let id = layer.id; + s.edit(label, move |doc, active| { + if let Some(frame_id) = frame { + crate::frame_cmds::put_content(doc, frame_id, layer)?; + *active = Some(frame_id); + } else { + crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); + *active = Some(id); + } + Ok(()) })?; - Ok(json!({"layer": id.0, "scale": scale * 100.0, "bounds": [dx, dy, dx + w * scale, dy + h * scale], "smartObject": smart})) + Ok(json!({"layer": id.0, "frame": frame.map(|f| f.0), "scale": scale * 100.0, "bounds": bounds, "smartObject": smart})) } pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { @@ -318,7 +465,6 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected boolean for approveHighMemory".into() }); } let path = str_param(p, "path", cmd)?.to_string(); - let bytes = read_file(&path)?; // The link is stored absolute: a relative one would later be taken from the document's // folder (`doc_path`), not the working directory the path was given in. let link = linked.then(|| { @@ -328,5 +474,7 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { path.clone() } }); - place_bytes(s, &path, bytes, link, p) + // The file is read by the place itself, on the job's worker when it runs in the background. + let input = PlaceInput::with_file(s, &path, PlaceFile::Path(path.clone()), link, p)?; + place_input(s, input) } diff --git a/crates/engine/src/cmd/file_cmds/paths.rs b/crates/engine/src/cmd/file_cmds/paths.rs index 928325e92f..2cee4bb299 100644 --- a/crates/engine/src/cmd/file_cmds/paths.rs +++ b/crates/engine/src/cmd/file_cmds/paths.rs @@ -24,6 +24,20 @@ pub(crate) fn read_file(path: &str) -> Result> { Err(EngineError::Other(format!("cannot read {path}: no file system on the web"))) } +#[cfg(not(target_arch = "wasm32"))] +/// [`read_file`] with the caller's own byte limit: a larger file is refused without being read, +/// for a caller that has to fit it into what is left of a memory budget. The `io::Error` is +/// returned as is, so the caller can tell a file over its limit (`FileTooLarge`, `OutOfMemory`) +/// from one it could not open or read. +pub(crate) fn read_file_limited(path: &str, max: u64) -> std::io::Result> { + photocraft_format::read_file_limited(std::path::Path::new(path), max) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn read_file_limited(path: &str, _max: u64) -> std::io::Result> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, format!("cannot read {path}: no file system on the web"))) +} + #[cfg(not(target_arch = "wasm32"))] /// Every file the engine writes goes through here: crash-safe (temp file + fsync + rename, see /// [`photocraft_format::atomic`]), so a failed save never destroys the previous file. @@ -37,13 +51,18 @@ pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { } /// Files of a folder that look like images we can open, sorted by name. -#[cfg(not(target_arch = "wasm32"))] pub fn list_images(dir: &str) -> Result> { + list_files(dir, false) +} + +/// Sorted regular files, optionally retaining unsupported names for per-file Batch errors. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn list_files(dir: &str, include_non_images: bool) -> Result> { let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; let mut out = Vec::new(); for entry in rd { let path = entry.map_err(|e| EngineError::Other(format!("{dir}: {e}")))?.path(); - if !openable_name(&path.to_string_lossy()) { + if !include_non_images && !openable_name(&path.to_string_lossy()) { continue; } match std::fs::metadata(&path) { @@ -58,7 +77,7 @@ pub fn list_images(dir: &str) -> Result> { } #[cfg(target_arch = "wasm32")] -pub fn list_images(dir: &str) -> Result> { +pub(super) fn list_files(dir: &str, _include_non_images: bool) -> Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } diff --git a/crates/engine/src/cmd/file_cmds/place_job_tests.rs b/crates/engine/src/cmd/file_cmds/place_job_tests.rs new file mode 100644 index 0000000000..8e96d9cb69 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/place_job_tests.rs @@ -0,0 +1,585 @@ +//! Place as a background job (#10031): the decode, flatten and resize run on a worker and the +//! layer is inserted when the job is applied. Inline callers see no difference. + +use std::time::{Duration, Instant}; + +use super::*; +use crate::jobs::{JobEvent, JobId, JobOutcome, Started}; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("photocraft-place-job-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.to_string_lossy().into_owned() +} + +fn session(w: u32, h: u32, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); + s +} + +/// A small RGB gradient PNG, so a wrong pixel anywhere shows. +fn gradient_png(w: u32, h: u32) -> Vec { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +fn job(r: Started) -> JobId { + match r { + Started::Job(id) => id, + Started::Done(v) => panic!("expected a background job, ran inline: {v}"), + } +} + +/// Poll until job `id` ends (or 60 s pass); returns its event. +fn wait_event(s: &mut Session, id: JobId) -> JobEvent { + let t = Instant::now(); + loop { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return e; + } + assert!(t.elapsed() < Duration::from_secs(60), "job {id:?} did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// The active layer's pixels over the canvas and its content. +fn layer_pixels(s: &Session) -> Vec { + let d = s.active().unwrap(); + let surface = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap(); + surface.read_region(d.doc.bounds().union(&surface.content_bounds())) +} + +fn canvas_pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px.clone() +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// The place a job does is the place the command does inline: the same result, layer, pixels and +/// one history step, at every depth, for a smart object and for a pixel layer. +#[test] +fn a_place_job_equals_the_inline_place() { + let dir = tmp("equal"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, gradient_png(160, 80)).unwrap(); + for depth in [8, 16, 32] { + for smart in [true, false] { + let params = json!({"path": path, "smartObject": smart}); + let mut inline = session(100, 100, depth); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = session(100, 100, depth); + let before = steps(&s); + let id = job(s.start("file.placeEmbedded", params).unwrap()); + assert_eq!(steps(&s), before, "nothing is inserted before the job is applied"); + let e = wait_event(&mut s, id); + let JobOutcome::Done(got) = &e.outcome else { panic!("{depth} {smart}: {e:?}") }; + assert_eq!(e.command, "file.placeEmbedded"); + assert_eq!(e.label, "Placing grad.png"); + same_but_layer(got, &want); + assert_eq!(steps(&s), before + 1, "one undo step"); + assert_eq!(s.active().unwrap().history.undo_label(), Some(PLACE_EMBEDDED)); + assert_eq!(layer_pixels(&s), layer_pixels(&inline), "layer pixels at {depth}-bit, smart {smart}"); + assert_eq!(canvas_pixels(&s), canvas_pixels(&inline), "composite at {depth}-bit, smart {smart}"); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + } + } +} + +/// Place Linked as a job keeps the link and its history label. +#[test] +fn a_linked_place_job_keeps_the_link() { + let dir = tmp("linked"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, gradient_png(40, 40)).unwrap(); + let mut s = session(100, 100, 8); + let id = s.start("file.placeLinked", json!({"path": path})).map(job).unwrap(); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + assert!(matches!(&l.content, LayerContent::Smart(so) if matches!(&so.source, SmartSource::Linked { path: p } if *p == path))); + assert_eq!(d.history.undo_label(), Some("Place Linked")); +} + +/// Bytes with no path (a web or dropped file) start the same job through `start_place_bytes`. +#[test] +fn start_place_bytes_matches_place_bytes() { + let bytes = gradient_png(160, 80); + let mut inline = session(100, 100, 16); + let want = place_bytes(&mut inline, "drop.png", bytes.clone(), None, &json!({})).unwrap(); + + let mut s = session(100, 100, 16); + let id = job(s.start_place_bytes("drop.png", bytes.clone(), None, json!({})).unwrap()); + // The document is locked meanwhile: another place (or edit) is refused with the job's name. + let refused = s.start_place_bytes("again.png", bytes, None, json!({})).unwrap_err().to_string(); + assert!(refused.contains("Placing drop.png"), "{refused}"); + assert!(s.execute("layer.new.layer", json!({})).is_err()); + assert!(s.execute("jobs.list", json!({})).is_ok(), "queries still run"); + let e = wait_event(&mut s, id); + assert_eq!(e.command, "file.placeEmbedded"); + let JobOutcome::Done(got) = &e.outcome else { panic!("{e:?}") }; + same_but_layer(got, &want); + assert_eq!(got["layer"], json!(s.active().unwrap().active_layer.unwrap().0), "the result names the placed layer"); + assert_eq!(layer_pixels(&s), layer_pixels(&inline)); + assert_eq!(steps(&s), steps(&inline)); +} + +/// Assert a place result equals `want` except for its `layer`: layer ids come from a counter +/// shared by every session, so two sessions never hand out the same one. +fn same_but_layer(got: &Value, want: &Value) { + let strip = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("layer")); + v + }; + assert!(got["layer"].is_u64(), "{got}"); + assert_eq!(strip(got), strip(want)); +} + +/// Cancelling stops the place: nothing is inserted and the document is the very same one. +#[test] +fn cancelling_a_place_job_leaves_the_document_unchanged() { + let mut s = session(100, 100, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start_place_bytes("drop.png", gradient_png(160, 80), None, json!({})).unwrap()); + assert!(s.cancel_job(id)); + s.join_cancelled_jobs(); + assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); + assert!(!s.active().unwrap().history.can_redo()); + assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); + assert!(s.active_job().is_none(), "the document is unlocked"); +} + +/// An edit made while the job runs wins: applying fails and no layer is placed. +#[test] +fn an_edit_during_a_place_job_discards_the_place() { + let mut s = session(100, 100, 8); + let id = job(s.start_place_bytes("drop.png", gradient_png(160, 80), None, json!({})).unwrap()); + // Commands are refused while the job runs; a direct edit (the shell's, say) is not. + s.edit("Poke", |doc, _| { + doc.name = "poked".into(); + Ok(()) + }) + .unwrap(); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("Placing drop.png") && err.contains("document changed"), "{err}"); + let d = s.active().unwrap(); + assert_eq!(d.doc.name, "poked", "the edit is kept"); + assert_eq!(d.doc.layers.len(), 1, "nothing was placed"); + assert_eq!(d.history.undo_label(), Some("Poke")); +} + +/// Hostile input as a job is an error, never a panic, and the document is left alone. +#[test] +fn bad_input_as_a_place_job_fails_without_panicking() { + let dir = tmp("bad"); + let good = gradient_png(32, 32); + let empty = join(&dir, "empty.png"); + std::fs::write(&empty, b"").unwrap(); + let truncated = join(&dir, "truncated.png"); + std::fs::write(&truncated, good.get(..good.len() / 2).unwrap()).unwrap(); + let garbage = join(&dir, "garbage.png"); + std::fs::write(&garbage, [0x89u8, b'P', b'N', b'G', 0xff, 0x00, 0x13, 0x37, 0x42]).unwrap(); + + let mut s = session(64, 64, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + for path in [&empty, &truncated, &garbage] { + let id = job(s.start("file.placeEmbedded", json!({"path": path})).unwrap()); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{path}: {e:?}") }; + assert!(err.contains("png"), "the file is named: {err}"); + } + for bytes in [Vec::new(), good.get(..10).unwrap().to_vec(), vec![0u8; 3]] { + let id = job(s.start_place_bytes("bad.png", bytes, None, json!({})).unwrap()); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); + } + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc), "a failed place changes nothing"); + assert_eq!(steps(&s), before); + // Params of the wrong type fall back to the defaults or are refused, but never panic. + let params = json!({"scale": "big", "center": "middle", "fit": "yes", "smartObject": 3}); + if let Ok(Started::Job(id)) = s.start_place_bytes("ok.png", good, None, params) { + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); + assert!(s.undo()); + } + assert_eq!(steps(&s), before); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + // Inline, a missing document is the same error as before. + let mut none = Session::new(); + assert!(matches!(place_bytes(&mut none, "a.png", Vec::new(), None, &json!({})), Err(EngineError::NoDocument))); + assert!(matches!(none.start_place_bytes("a.png", Vec::new(), None, json!({})), Err(EngineError::NoDocument))); +} + +/// A place from a path reads the file in the job, not in the command that starts it: a large file +/// doesn't hold up the window while it is read. A file that can't be read fails the job with the +/// error the inline place gives, and changes nothing. +#[test] +fn a_place_job_reads_its_file_on_the_worker() { + let dir = tmp("read"); + let missing = join(&dir, "missing.png"); + let mut inline = session(64, 64, 8); + let want = inline.execute("file.placeEmbedded", json!({"path": missing})).unwrap_err().to_string(); + assert!(want.contains(&missing), "{want}"); + + let mut s = session(64, 64, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start("file.placeEmbedded", json!({"path": missing})).expect("the job starts before the file is read")); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert_eq!(err, &want); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); +} + +/// A placed file whose canvas is past the compositor's limit is refused with the same error the +/// place gave before it ran as a job: bad params naming the canvas. +#[test] +fn a_canvas_too_large_to_flatten_is_refused_as_before() { + let huge = Document::new("huge", photocraft_doc::Size::new(40_000, 30_000), ColorMode::Rgb, photocraft_color::SampleType::U8); + let bytes = photocraft_format::save_to_bytes(&huge, &Default::default()).unwrap(); + let mut s = session(100, 100, 8); + let before = steps(&s); + match place_bytes(&mut s, "huge.pcraft", bytes.clone(), None, &json!({})) { + Err(EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, "file.placeEmbedded"); + assert_eq!(msg, "the document's 40000×30000 canvas is too large to flatten"); + } + other => panic!("{other:?}"), + } + let id = job(s.start_place_bytes("huge.pcraft", bytes, None, json!({})).unwrap()); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("40000×30000 canvas is too large to flatten"), "{err}"); + assert_eq!(steps(&s), before, "nothing was placed"); +} + +// ---------- place in steps, for a session with no worker threads (the web build) ---------- + +/// A pseudo-random PNG of `layout` and `sample`, so a wrong pixel anywhere shows and the file +/// is big enough to take many rows. +fn noise_png(w: u32, h: u32, layout: photocraft_codecs::ChannelLayout, sample: photocraft_codecs::SampleType, interlaced: bool) -> Vec { + let len = w as usize * h as usize * layout.channels() * sample.bytes(); + let data: Vec = (0..len).map(|i| ((i.wrapping_mul(2_654_435_761) >> 13) ^ (i / 97)) as u8).collect(); + let img = photocraft_codecs::Image::from_raw(w, h, layout, sample, data).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions { png_interlaced: interlaced, ..Default::default() }) + .unwrap() +} + +/// A cooperative session whose polls each do one unit of work, so the polls a place needs do not +/// depend on the machine's speed. +fn stepping(w: u32, h: u32, depth: u32) -> Session { + let mut s = session(w, h, depth); + s.set_cooperative_jobs(true); + s.set_job_step_budget(Duration::ZERO); + s +} + +/// Polls until job `id` ends; returns its event and how many polls that took. +fn poll_to_end(s: &mut Session, id: JobId) -> (JobEvent, usize) { + for polls in 1..=1_000_000 { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return (e, polls); + } + } + panic!("job {id:?} never ended"); +} + +/// A place a slice per poll equals the inline place: the same result, layer pixels, composite and +/// one history step, for every kind of PNG, at every depth, for a smart object and a pixel layer, +/// reduced to fit, left alone and enlarged. +#[test] +fn a_cooperative_place_equals_the_inline_place() { + use photocraft_codecs::{ChannelLayout as L, SampleType as T}; + let dir = tmp("cooperative"); + let sources = [ + ("rgb8.png", noise_png(320, 300, L::Rgb, T::U8, false)), + ("rgba16.png", noise_png(310, 301, L::Rgba, T::U16, false)), + ("gray8-interlaced.png", noise_png(300, 305, L::Gray, T::U8, true)), + ("graya8.png", noise_png(301, 300, L::GrayA, T::U8, false)), + ]; + for (file, bytes) in &sources { + let path = join(&dir, file); + std::fs::write(&path, bytes).unwrap(); + for depth in [8, 16, 32] { + for smart in [true, false] { + for scale in [None, Some(100), Some(180)] { + let mut params = json!({"path": path, "smartObject": smart}); + if let Some(k) = scale { + params["scale"] = json!(k); + } + let what = format!("{file} at {depth}-bit, smart {smart}, scale {scale:?}"); + let mut inline = session(150, 150, depth); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = stepping(150, 150, depth); + let before = steps(&s); + let id = job(s.start("file.placeEmbedded", params).unwrap()); + assert!(s.has_jobs(), "{what}"); + assert_eq!(steps(&s), before, "nothing is inserted before the job is applied"); + let (e, polls) = poll_to_end(&mut s, id); + let JobOutcome::Done(got) = &e.outcome else { panic!("{what}: {e:?}") }; + assert!(polls > 3, "{what}: took {polls} polls"); + assert_eq!((e.command.as_str(), e.label.as_str()), ("file.placeEmbedded", &*format!("Placing {file}"))); + same_but_layer(got, &want); + assert_eq!(steps(&s), before + 1, "{what}: one undo step"); + assert_eq!(s.active().unwrap().history.undo_label(), Some(PLACE_EMBEDDED)); + assert_eq!(layer_pixels(&s), layer_pixels(&inline), "{what}: layer pixels"); + assert_eq!(canvas_pixels(&s), canvas_pixels(&inline), "{what}: composite"); + if smart { + // The embedded source is the file's bytes, shared with the decoder. + let d = s.active().unwrap(); + let LayerContent::Smart(so) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("{what}") }; + assert!(matches!(&so.source, SmartSource::Embedded { bytes: b, .. } if **b == *bytes), "{what}"); + } + assert!(s.undo(), "{what}"); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + } + } + } + } +} + +/// With the real budget (8 ms a poll) the place ends the same, and Place Linked and bytes with no +/// path take the same road. +#[test] +fn a_cooperative_place_with_the_default_budget_and_from_bytes() { + let bytes = gradient_png(300, 320); + let mut inline = session(120, 120, 16); + let want = place_bytes(&mut inline, "drop.png", bytes.clone(), None, &json!({})).unwrap(); + + let mut s = session(120, 120, 16); + s.set_cooperative_jobs(true); + let id = job(s.start_place_bytes("drop.png", bytes.clone(), None, json!({})).unwrap()); + // The document is locked meanwhile, as for a worker. + let refused = s.start_place_bytes("again.png", bytes.clone(), None, json!({})).unwrap_err().to_string(); + assert!(refused.contains("Placing drop.png"), "{refused}"); + let (e, _) = poll_to_end(&mut s, id); + let JobOutcome::Done(got) = &e.outcome else { panic!("{e:?}") }; + same_but_layer(got, &want); + assert_eq!(layer_pixels(&s), layer_pixels(&inline)); + assert_eq!(steps(&s), steps(&inline)); + + let dir = tmp("cooperative-linked"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, bytes).unwrap(); + let id = job(s.start("file.placeLinked", json!({"path": path})).unwrap()); + assert!(matches!(poll_to_end(&mut s, id).0.outcome, JobOutcome::Done(_))); + let d = s.active().unwrap(); + assert!( + matches!(&d.doc.layer(d.active_layer.unwrap()).unwrap().content, LayerContent::Smart(so) if matches!(&so.source, SmartSource::Linked { path: p } if *p == path)) + ); + assert_eq!(d.history.undo_label(), Some("Place Linked")); +} + +/// An offset off the tile grid rebuilds the tiles the image touches, and a cooperative place does +/// that a few tiles per poll rather than all of them in one: it takes at least a poll per source +/// tile more than the same place on the grid, and ends with the same pixels as the inline place. +#[test] +fn a_cooperative_place_moves_its_pixels_a_few_tiles_per_poll() { + let bytes = gradient_png(600, 520); + let tiles = 3 * 3; + let polls_at = |center: [i32; 2]| { + let params = json!({"center": center, "smartObject": false}); + let mut inline = session(2000, 2000, 8); + place_bytes(&mut inline, "drop.png", bytes.clone(), None, ¶ms).unwrap(); + let mut s = stepping(2000, 2000, 8); + let id = job(s.start_place_bytes("drop.png", bytes.clone(), None, params).unwrap()); + let (e, polls) = poll_to_end(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(layer_pixels(&s), layer_pixels(&inline), "at {center:?}"); + polls + }; + // The image lands at (256, 256), a whole number of tiles, or at (257, 3). + let on_grid = polls_at([300 + 256, 260 + 256]); + let off_grid = polls_at([300 + 257, 260 + 3]); + assert!(off_grid >= on_grid + tiles, "{off_grid} polls off the grid, {on_grid} on it"); +} + +/// `wait_job` runs a cooperative place to the end. +#[test] +fn wait_job_finishes_a_cooperative_place() { + let bytes = gradient_png(300, 300); + let mut inline = session(100, 100, 8); + let want = place_bytes(&mut inline, "drop.png", bytes.clone(), None, &json!({})).unwrap(); + let mut s = stepping(100, 100, 8); + let id = job(s.start_place_bytes("drop.png", bytes, None, json!({})).unwrap()); + s.poll_jobs(); + let got = s.wait_job(id).unwrap(); + same_but_layer(&got, &want); + assert_eq!(layer_pixels(&s), layer_pixels(&inline)); + assert!(!s.has_jobs()); +} + +/// Progress goes up through the stages and the status names them. +#[test] +fn a_cooperative_place_reports_its_progress() { + let mut s = stepping(100, 100, 8); + let id = job(s.start_place_bytes("drop.png", gradient_png(300, 300), None, json!({})).unwrap()); + let (mut last, mut messages) = (0.0f32, Vec::new()); + while s.job(id).is_some() { + s.poll_jobs(); + if let Some(info) = s.job(id) { + assert!(info.progress >= last && info.progress < 1.0, "{last} -> {}", info.progress); + last = info.progress; + if !info.message.is_empty() && messages.last() != Some(&info.message) { + messages.push(info.message); + } + } + } + assert!(last > 0.5, "{last}"); + assert_eq!(messages, ["Decoding", "Flattening", "Resizing", "Placing"]); +} + +/// Cancelling between polls leaves the document the very same one and unlocks it. +#[test] +fn cancelling_a_cooperative_place_between_polls_leaves_the_document_unchanged() { + let mut s = stepping(100, 100, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start_place_bytes("drop.png", gradient_png(300, 300), None, json!({})).unwrap()); + for _ in 0..5 { + assert!(s.poll_jobs().is_empty()); + } + assert!(s.cancel_job(id)); + assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); + assert!(!s.active().unwrap().history.can_redo()); + assert!(s.active_job().is_none(), "the document is unlocked"); + assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); +} + +/// A PNG that is damaged part-way through its image data fails the job, without a panic and with +/// the document untouched; the rows before the damage are decoded first. +#[test] +fn a_cooperative_place_of_a_damaged_png_fails_cleanly() { + use photocraft_codecs::{ChannelLayout as L, SampleType as T}; + let good = noise_png(320, 300, L::Rgb, T::U8, false); + let mut flipped = good.clone(); + let mid = flipped.len() / 2; + flipped[mid] ^= 0xFF; + let cases = [ + ("cut in half", good.get(..good.len() / 2).unwrap().to_vec()), + ("cut just after the header", good.get(..60).unwrap().to_vec()), + ("a damaged byte", flipped), + ("not a PNG", b"\x89PNG\r\n\x1a\nthen text".to_vec()), + ("empty", Vec::new()), + ]; + let mut s = stepping(100, 100, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + for (what, bytes) in cases { + let Ok(Started::Job(id)) = s.start_place_bytes("bad.png", bytes, None, json!({})) else { + // Refused before it started: also an error, not a panic. + continue; + }; + let (e, polls) = poll_to_end(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{what}: {e:?}") }; + assert!(err.contains("bad.png"), "{what}: the file is named: {err}"); + if what == "cut in half" { + assert!(polls > 20, "{what}: the rows before the cut are decoded first ({polls} polls)"); + } + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc), "{what}"); + assert_eq!(steps(&s), before, "{what}"); + assert!(s.active_job().is_none(), "{what}: unlocked"); + } +} + +/// The flatten in bands is the flatten in one go, for every colour model and depth. +#[test] +fn a_banded_flatten_equals_flattened_checked() { + use super::place_steps::Flatten; + use photocraft_codecs::{ChannelLayout as L, SampleType as T}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + // Wide enough for two bands across, and several tile rows. + let sources = [ + noise_png(2100, 600, L::Gray, T::U8, false), + noise_png(700, 530, L::Rgb, T::U8, false), + noise_png(300, 520, L::Rgba, T::U16, false), + noise_png(260, 520, L::GrayA, T::U16, false), + ]; + let formats = [ + PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), + PixelFormat::new(ColorMode::Rgb, SampleType::U16, true), + PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), + PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false), + PixelFormat::new(ColorMode::Grayscale, SampleType::U16, true), + ]; + for bytes in &sources { + let doc = photocraft_io::import("src.png", bytes).unwrap().document; + for fmt in formats { + let expected = flattened_checked(&doc, fmt, "file.placeEmbedded").unwrap(); + let mut flatten = Flatten::new(doc.clone(), true, fmt, "file.placeEmbedded").unwrap(); + let mut bands = 0; + let got = loop { + bands += 1; + if let Some(px) = flatten.advance().unwrap() { + break px; + } + }; + assert!(bands > 1, "{}x{}: {bands} bands", doc.size.width, doc.size.height); + assert!(got == expected, "{}x{} {:?} {:?}: banded != whole", doc.size.width, doc.size.height, doc.mode, fmt); + // Not banded is the plain flatten. + let whole = Flatten::new(doc.clone(), false, fmt, "file.placeEmbedded").unwrap().advance().unwrap().unwrap(); + assert!(whole == expected); + } + } +} + +/// An Indexed Color document (a palette PNG) flattens in bands to the same pixels. +#[test] +fn a_banded_flatten_of_a_palette_image_equals_flattened_checked() { + use super::place_steps::Flatten; + let pal = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [9, 9, 9]]; + let idx: Vec = (0..300 * 600).map(|i| ((i / 7 + i / 300) % 4) as u8).collect(); + let png = photocraft_codecs::encode_png_indexed(300, 600, &idx, &pal, Some(3), None, None).unwrap(); + let doc = photocraft_io::import("p.png", &png).unwrap().document; + assert!(doc.color_table.is_some()); + let fmt = doc.pixel_format(); + let expected = flattened_checked(&doc, fmt, "file.placeEmbedded").unwrap(); + let mut flatten = Flatten::new(doc, true, fmt, "file.placeEmbedded").unwrap(); + let got = loop { + if let Some(px) = flatten.advance().unwrap() { + break px; + } + }; + assert!(got == expected); +} + +/// A canvas too large to flatten is refused before any band is rendered. +#[test] +fn a_banded_flatten_keeps_the_size_limit() { + use super::place_steps::Flatten; + let doc = Document::new("huge", photocraft_geom::Size::new(40_000, 40_000), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + assert!(Flatten::new(doc, true, fmt, "file.placeEmbedded").is_err()); +} + +/// The size refusal names the command that was run, embedded or linked, not a made-up id. +#[test] +fn the_flatten_size_error_names_the_place_command() { + use super::place_steps::Flatten; + let huge = || Document::new("huge", photocraft_geom::Size::new(40_000, 40_000), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = huge().pixel_format(); + for command in ["file.placeEmbedded", "file.placeLinked"] { + match Flatten::new(huge(), true, fmt, command) { + Err(EngineError::BadParams { cmd, .. }) => assert_eq!(cmd, command), + other => panic!("{:?}", other.map(|_| ())), + } + } + // Through a whole place: a linked one reports file.placeLinked. + let bytes = photocraft_format::save_to_bytes(&huge(), &Default::default()).unwrap(); + let mut s = session(100, 100, 8); + s.set_cooperative_jobs(true); + s.set_job_step_budget(Duration::ZERO); + let id = job(s.start_place_bytes("huge.pcraft", bytes, Some("huge.pcraft".into()), json!({})).unwrap()); + let (e, _) = poll_to_end(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("file.placeLinked"), "{err}"); +} diff --git a/crates/engine/src/cmd/file_cmds/place_steps.rs b/crates/engine/src/cmd/file_cmds/place_steps.rs new file mode 100644 index 0000000000..8d35d54704 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/place_steps.rs @@ -0,0 +1,325 @@ +//! Place in resumable steps (#10031): decode, flatten, resize and translate, each in pieces small +//! enough to run between two frames where there are no worker threads (the web build). +//! +//! Every piece computes what the one-shot code computes, so the placed layer is the same whether +//! a worker thread, a [`PlaceSteps`] driven to the end, or a frame-by-frame [`Session::poll_jobs`] +//! did the work. `fine` (a cooperative session) cuts the work into pieces; without it each stage +//! is one unit and native code keeps its parallel resize and flatten. + +use photocraft_algo::resample::ResizeSteps; +use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_io::PngImport; +use photocraft_raster::{Surface, TranslateSteps}; +use web_time::Instant; + +use super::open::{PLACE_EMBEDDED, PlaceFile, PlaceInput, Prepared}; +use super::paths::{file_name, read_file, stem}; +use super::{ + Affine, Arc, Document, EngineError, LayerContent, Resample, Result, SmartObject, SmartSource, Value, check_flattenable, f64_param, flattened_checked, + resize_surface, translate_surface, +}; +use crate::jobs::{JobCtx, StepWork}; + +/// Pixels a flatten band covers at most: four tiles of a tile row (about 5 ms on one core). +const BAND_PIXELS: i64 = 4 * (TILE_SIZE as i64) * (TILE_SIZE as i64); + +/// A place as a [`StepWork`]; its result is what [`super::open::apply_place`] inserts. +pub(crate) struct PlaceSteps { + input: PlaceInput, + /// The file's contents, once read (a path is read by the first step). + bytes: Arc>, + /// Cut the work into small pieces (for a session without threads). + fine: bool, + stage: Stage, + /// Where the placed image lands, known once it is decoded. + plan: Plan, +} + +#[derive(Clone, Copy, Default)] +struct Plan { + w: f64, + h: f64, + scale: f64, + dx: f64, + dy: f64, +} + +enum Stage { + /// Nothing done yet. + Decode, + /// A plain PNG, a few rows at a time. + DecodePng(Box), + Flatten(Box), + Resize(Box), + /// The fitted pixels being moved to where they land, a few tiles at a time. + Translate(Box), + /// The pixels in place (none for a frame's image, which renders from its source), to wrap in + /// a layer. + Finish(Option), + Spent, +} + +impl PlaceSteps { + /// `fine` cuts the work into pieces of a few milliseconds, as a session without worker + /// threads needs; without it each stage is one unit of work. + pub(crate) fn new(input: PlaceInput, fine: bool) -> Self { + Self { input, bytes: Arc::new(Vec::new()), fine, stage: Stage::Decode, plan: Plan::default() } + } + + /// An import error as the engine reports it: a cancel stays one and a refused memory budget + /// keeps its typed fields. + fn io_error(&self, e: photocraft_io::IoError) -> EngineError { + match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + // Typed, as the one-shot place reports it: a refused memory budget keeps its fields. + e => crate::error::io_open_error(&self.input.name, e), + } + } + + /// One unit of work; `Some` when the place is prepared. + fn advance(&mut self, ctx: &JobCtx, deadline: Instant) -> Result> { + match std::mem::replace(&mut self.stage, Stage::Spent) { + Stage::Decode => { + self.read(ctx)?; + let input = &self.input; + let bytes = &self.bytes; + let png = if self.fine { PngImport::new(&input.name, bytes).map_err(|e| self.io_error(e))? } else { None }; + match png { + Some(png) => self.stage = Stage::DecodePng(Box::new(png)), + None => { + let imported = ctx + .stage(0.0, 0.6, "Decoding", |ctl| photocraft_io::import_with_options_and_interrupt(&input.name, bytes, &input.opts, ctl)) + .map_err(|e| self.io_error(e))?; + self.decoded(imported, false, ctx)?; + } + } + } + Stage::DecodePng(mut png) => { + ctx.progress(0.55 * png.progress(), "Decoding"); + match png.step(&mut || Instant::now() < deadline).map_err(|e| self.io_error(e))? { + Some(imported) => self.decoded(imported, true, ctx)?, + None => self.stage = Stage::DecodePng(png), + } + } + Stage::Flatten(mut flatten) => { + ctx.progress(0.6 + 0.1 * flatten.progress(), "Flattening"); + match flatten.advance()? { + Some(px) => self.flattened(px, ctx), + None => self.stage = Stage::Flatten(flatten), + } + } + Stage::Resize(mut resize) => { + ctx.progress(0.7 + 0.2 * resize.progress(), "Resizing"); + match resize.step(&mut || Instant::now() < deadline) { + Some(px) => self.fitted(px), + None => self.stage = Stage::Resize(resize), + } + } + Stage::Translate(mut shift) => { + ctx.progress(0.9 + 0.05 * shift.progress(), "Placing"); + self.stage = match shift.step(&mut || Instant::now() < deadline) { + Some(px) => Stage::Finish(Some(px)), + None => Stage::Translate(shift), + }; + } + Stage::Finish(px) => return self.finish(px, ctx).map(Some), + Stage::Spent => return Err(EngineError::Other("internal error: the place was already prepared".into())), + } + Ok(None) + } + + /// Gets the file's bytes (reading a path) and refuses a place that cannot fit in memory, + /// before anything is decoded. + fn read(&mut self, ctx: &JobCtx) -> Result<()> { + self.bytes = match &self.input.file { + PlaceFile::Bytes(bytes) => Arc::clone(bytes), + PlaceFile::Path(path) => { + ctx.progress(0.0, "Reading"); + let bytes = read_file(path)?; + ctx.check()?; + Arc::new(bytes) + } + }; + let (name, fmt) = (&self.input.name, self.input.fmt); + crate::memory_gate::ensure(&format!("place \u{201c}{}\u{201d}", file_name(name)), crate::memory_gate::place_need(&self.bytes, fmt.bytes_per_pixel()))?; + // A path isn't read when the input is made, so whether it is a vector source is decided now. + let vector = self.input.frame.is_some() || photocraft_io::is_svg_input(name, &self.bytes); + self.input.doc = self.input.doc.take().filter(|_| vector); + Ok(()) + } + + /// The file is decoded: where it lands, then on to flattening. `banded`: the document is a + /// plain image, which flattens band by band. + fn decoded(&mut self, imported: photocraft_io::ImportResult, banded: bool, ctx: &JobCtx) -> Result<()> { + if imported.preview_only { + return Err(EngineError::Other(format!( + "{}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here", + self.input.name + ))); + } + let src = imported.document; + let (w, h) = (src.size.width as f64, src.size.height as f64); + let (cw, ch) = self.input.canvas; + let p = &self.input.params; + let scale = match f64_param(p, "scale") { + Some(k) => (k / 100.0).max(1e-4), + None if self.input.frame.is_some() && self.input.fit => (cw / w).max(ch / h), + None if self.input.fit && (w > cw || h > ch) => (cw / w).min(ch / h), + None => 1.0, + }; + let center = match p.get("center").and_then(Value::as_array) { + Some(c) if c.len() == 2 => (c[0].as_f64().unwrap_or(cw / 2.0), c[1].as_f64().unwrap_or(ch / 2.0)), + _ => (cw / 2.0, ch / 2.0), + }; + let (dx, dy) = ((center.0 - w * scale / 2.0).round(), (center.1 - h * scale / 2.0).round()); + self.plan = Plan { w, h, scale, dx, dy }; + // A frame's image renders through its smart object, which refuses a placement past the + // memory budget; resampling it to the frame's size first would allocate that surface twice. + if self.input.frame.is_some() { + self.stage = Stage::Finish(None); + return Ok(()); + } + ctx.progress(0.6, "Flattening"); + self.stage = Stage::Flatten(Box::new(Flatten::new(src, banded && self.fine, self.input.fmt, self.input.command())?)); + Ok(()) + } + + /// The image is flattened: on to resizing it to fit, if it needs that. + fn flattened(&mut self, px: Surface, ctx: &JobCtx) { + let scale = self.plan.scale; + if (scale - 1.0).abs() <= 1e-9 { + self.fitted(px); + } else if self.fine { + self.stage = Stage::Resize(Box::new(ResizeSteps::new(px, scale, scale, Resample::Bicubic, None))); + } else { + ctx.progress(0.7, "Resizing"); + self.fitted(resize_surface(&px, scale, scale, Resample::Bicubic)); + } + } + + /// The pixels are fitted: on to moving them to where they land. A cooperative session does + /// that a few target tiles at a time (an offset off the tile grid rebuilds nearly every tile + /// the image touches); otherwise it is one [`translate_surface`]. + fn fitted(&mut self, px: Surface) { + let (dx, dy) = (self.plan.dx as i32, self.plan.dy as i32); + self.stage = if self.fine { + let content = px.content_bounds(); + Stage::Translate(Box::new(TranslateSteps::new(px, dx, dy, content))) + } else { + Stage::Finish(Some(translate_surface(&px, dx, dy))) + }; + } + + /// Wraps the pixels, already in place, in the layer's content. + fn finish(&mut self, px: Option, ctx: &JobCtx) -> Result { + let Plan { w, h, scale, dx, dy } = self.plan; + let input = &self.input; + let (cw, ch) = input.canvas; + let placement = input.frame.as_ref().map_or(Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, |(_, f)| { + f.transform.mul(&Affine { m: [scale / cw, 0.0, 0.0, scale / ch, dx / cw, dy / ch] }) + }); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].map(|(x, y)| placement.apply(photocraft_geom::Point::new(x, y))); + let bounds = [ + corners.iter().map(|q| q.x).fold(f64::INFINITY, f64::min), + corners.iter().map(|q| q.y).fold(f64::INFINITY, f64::min), + corners.iter().map(|q| q.x).fold(f64::NEG_INFINITY, f64::max), + corners.iter().map(|q| q.y).fold(f64::NEG_INFINITY, f64::max), + ]; + let label = if input.linked.is_some() { "Place Linked" } else { PLACE_EMBEDDED }; + // A frame's image is always a smart object. + let smart = input.smart || input.frame.is_some(); + let content = if smart { + let source = match &input.linked { + Some(path) => SmartSource::Linked { path: path.clone() }, + None => SmartSource::Embedded { file_name: file_name(&input.name), bytes: Arc::clone(&self.bytes) }, + }; + let mut so = SmartObject::new(source, placement, px); + // A vector source (SVG) or a frame's image renders at its placement scale instead of + // as a resampled raster. + if let Some(doc) = &input.doc { + ctx.progress(0.9, "Rendering"); + ctx.check()?; + if let Some(sharp) = crate::smart_cmds::render(doc, &so)? { + so.cache = Some(sharp); + } + } + if so.cache.is_none() { + return Err(EngineError::Other(format!("{}: the placed image could not be rendered", input.name))); + } + LayerContent::Smart(so) + } else { + let px = px.ok_or_else(|| EngineError::Other(format!("{}: a frame's image must be a smart object", input.name)))?; + LayerContent::Raster(px) + }; + Ok(Prepared { content, layer_name: stem(&input.name), label, scale, bounds, smart, frame: input.frame.as_ref().map(|(id, _)| *id) }) + } +} + +impl StepWork for PlaceSteps { + /// Runs units of work until `deadline` (at least one), checking for a cancel between them. + fn step(&mut self, ctx: &JobCtx, deadline: Instant) -> Result> { + loop { + ctx.check()?; + if let Some(prepared) = self.advance(ctx, deadline)? { + return Ok(Some(prepared)); + } + if Instant::now() >= deadline { + return Ok(None); + } + } + } +} + +/// The decoded document being flattened to the surface it is resized from. +pub(super) struct Flatten { + doc: Document, + fmt: photocraft_color::PixelFormat, + /// The place command, for the size error. + command: &'static str, + /// Flatten band by band; otherwise in one go. + banded: bool, + bounds: Rect, + /// The next band's top left corner. + at: (i32, i32), + /// Width of a band, a whole number of tiles. + cols: i32, + out: Option, +} + +impl Flatten { + /// `command` is the place command (`file.placeEmbedded` or `file.placeLinked`), named in the + /// error a canvas past the compositor's limit gives. + pub(super) fn new(doc: Document, banded: bool, fmt: photocraft_color::PixelFormat, command: &'static str) -> Result { + check_flattenable(&doc, command)?; + let bounds = doc.bounds(); + // Bands hold whole tiles, so each tile is written by one band and can move as it is. A + // document that does not start on the tile grid (none does) flattens in one go. + let banded = banded && bounds.x0.rem_euclid(TILE_SIZE) == 0 && bounds.y0.rem_euclid(TILE_SIZE) == 0; + let cols = i32::try_from(BAND_PIXELS / i64::from(TILE_SIZE) / i64::from(TILE_SIZE)).unwrap_or(1).max(1) * TILE_SIZE; + Ok(Self { doc, fmt, command, banded, bounds, at: (bounds.x0, bounds.y0), cols, out: None }) + } + + /// How much of the flatten is done, `0.0..=1.0`. + fn progress(&self) -> f32 { + let rows = i64::from(self.bounds.height()).max(1); + (i64::from(self.at.1.saturating_sub(self.bounds.y0)) as f32 / rows as f32).clamp(0.0, 1.0) + } + + /// Flattens the next band (the whole document if not banded); `Some` with the surface once + /// the last one is in. + pub(super) fn advance(&mut self) -> Result> { + if !self.banded { + return flattened_checked(&self.doc, self.fmt, self.command).map(Some); + } + let (x, y) = self.at; + // A band is part of one tile row, at most `cols` wide. + let y1 = (y.div_euclid(TILE_SIZE).saturating_add(1).saturating_mul(TILE_SIZE)).min(self.bounds.y1); + let x1 = x.saturating_add(self.cols).min(self.bounds.x1); + let band = photocraft_compose::composite_to_surface(&self.doc, Rect::new(x, y, x1, y1), self.fmt, None); + // The band's tiles move into the result as they are, with no copy of their pixels. + let out = self.out.get_or_insert_with(|| Surface::with_default(self.fmt, &band.default_pixel())); + out.put_tiles(band.tiles().map(|(c, t)| (*c, Arc::clone(t)))); + self.at = if x1 >= self.bounds.x1 { (self.bounds.x0, y1) } else { (x1, y) }; + Ok(if self.at.1 >= self.bounds.y1 { Some(self.out.take().unwrap_or_else(|| Surface::new(self.fmt))) } else { None }) + } +} diff --git a/crates/engine/src/cmd/file_cmds/raw_open.rs b/crates/engine/src/cmd/file_cmds/raw_open.rs new file mode 100644 index 0000000000..905f359aaa --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/raw_open.rs @@ -0,0 +1,142 @@ +//! Noninteractive RAW opening for Batch, using the same developer and workflow as Camera Raw. + +use photocraft_algo::camera_raw::CameraRaw; +use photocraft_io::{ImportResult, raw::RawTuning}; + +use super::{EngineError, Result, Session, Value, json}; + +/// Read only a bounded regular XMP file. No ambient sidecar reads are granted to gated sessions. +#[cfg(not(target_arch = "wasm32"))] +fn prior_settings(path: Option<&str>) -> Result> { + use std::io::Read; + let Some(path) = path else { return Ok(None) }; + let sidecar = match path.rsplit_once('.') { + Some((stem, ext)) if !ext.contains(['/', '\\']) => format!("{stem}.xmp"), + _ => format!("{path}.xmp"), + }; + let meta = match std::fs::metadata(&sidecar) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(EngineError::Other(format!("{sidecar}: {error}"))), + }; + let cap = photocraft_io::camera_raw_xmp::MAX_XMP_BYTES; + if !meta.is_file() || meta.len() > cap as u64 { + return Err(EngineError::Other(format!("{sidecar}: RAW settings must be a regular file of at most {cap} bytes"))); + } + let file = std::fs::File::open(&sidecar).map_err(|error| EngineError::Other(format!("{sidecar}: {error}")))?; + let mut bytes = Vec::new(); + file.take(cap as u64 + 1).read_to_end(&mut bytes).map_err(|error| EngineError::Other(format!("{sidecar}: {error}")))?; + let mut settings = CameraRaw::default(); + let report = photocraft_io::camera_raw_xmp::load(&mut settings, &bytes).map_err(|error| EngineError::Other(format!("{sidecar}: {error}")))?; + if !report.ignored.is_empty() { + return Err(EngineError::Other(format!("{sidecar}: unsupported prior RAW settings: {}", report.ignored.join(", ")))); + } + Ok(Some(settings)) +} + +#[cfg(target_arch = "wasm32")] +fn prior_settings(path: Option<&str>) -> Result> { + if path.is_some() { Err(EngineError::Other("cannot read RAW sidecars on the web".into())) } else { Ok(None) } +} + +/// Sample the existing content at the Camera Raw histogram spacing, without a full proxy image. +fn auto_sample(surface: &photocraft_raster::Surface) -> Vec<[f32; 4]> { + let area = surface.content_bounds(); + let (w, h) = (area.x1.saturating_sub(area.x0).max(0) as usize, area.y1.saturating_sub(area.y0).max(0) as usize); + if w == 0 || h == 0 { + return Vec::new(); + } + let k = w.max(h).div_ceil(512).max(1); + let mut out = Vec::with_capacity(w.div_ceil(k) * h.div_ceil(k)); + let mut row = vec![[0.0; 4]; w]; + for y in (0..h).step_by(k) { + let yy = area.y0.saturating_add(y as i32); + surface.read_rgba_into(photocraft_geom::Rect::new(area.x0, yy, area.x1, yy.saturating_add(1)), &mut row); + out.extend(row.iter().step_by(k).copied()); + } + out +} + +/// Develop representable prior settings at the sensor stage, retaining only the remaining RGB work. +pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) -> Result> { + let Some(defaults) = &s.batch_raw_open else { return Ok(None) }; + if !photocraft_io::raw::is_raw(bytes) { + return Ok(None); + } + if s.authorize.is_some() || crate::jobs::authorization_installed() { + return Err(EngineError::Other("RAW sidecar opening requires a trusted session; no implicit read capability is available".into())); + } + let prior = prior_settings(path)?; + let mut settings = prior.clone().unwrap_or_default(); + let opts = crate::jobs::import_options(s.prefs()); + let mut imported = photocraft_io::import_with_options(name, bytes, &opts).map_err(|error| EngineError::Other(format!("{name}: {error}")))?; + if imported.preview_only || !imported.warnings.iter().any(|warning| warning.contains(photocraft_io::raw::DEVELOPED_NOTE)) { + return Err(EngineError::Other(format!("{name}: complete RAW development is required"))); + } + if prior.is_none() && defaults.apply_auto_tone { + let surface = imported.document.layers.iter().find_map(|layer| layer.surface()).ok_or(EngineError::NoDocument)?; + photocraft_algo::camera_raw::auto_tone(&auto_sample(surface), &mut settings); + } + let tuning = RawTuning { + temperature: settings.temperature, + tint: settings.tint, + exposure: settings.exposure, + demosaic: defaults.demosaic.method(), + shadows: settings.shadows, + highlights: settings.highlights, + clarity: settings.clarity, + texture: settings.texture, + dehaze: settings.dehaze, + }; + if tuning != (RawTuning { demosaic: opts.demosaic, ..Default::default() }) { + super::open::raw_defaults(s, &imported.warnings, &mut imported.document); + let (mut developed, wb) = photocraft_io::raw::import_raw_tuned(name, bytes, &tuning).map_err(|error| EngineError::Other(format!("{name}: {error}")))?; + developed.document.resolution_dpi = imported.document.resolution_dpi; + imported = developed; + settings.exposure = 0.0; + settings.shadows = 0.0; + settings.highlights = 0.0; + settings.clarity = 0.0; + settings.texture = 0.0; + settings.dehaze = 0.0; + if wb { + settings.temperature = 0.0; + settings.tint = 0.0; + } + } + if let Some((amount, radius)) = defaults.sharpen_for.preset() + && settings.sharpen_amount == 0.0 + { + settings.sharpen_amount = amount; + settings.sharpen_radius = radius; + } + Ok(Some((imported, settings))) +} + +/// Finish the same RGB filter and workflow conversions used by interactive Camera Raw opening. +pub(super) fn finish(s: &mut Session, settings: CameraRaw) -> Result<()> { + let defaults = s.batch_raw_open.clone().ok_or_else(|| EngineError::Other("RAW opening settings are missing".into()))?; + let full = serde_json::to_value(&settings).map_err(|error| EngineError::Other(error.to_string()))?; + let empty = serde_json::to_value(CameraRaw::default()).map_err(|error| EngineError::Other(error.to_string()))?; + let mut params = serde_json::Map::new(); + if let (Some(full), Some(empty)) = (full.as_object(), empty.as_object()) { + for (key, value) in full { + if key != "pixelScale" && empty.get(key) != Some(value) { + params.insert(key.clone(), value.clone()); + } + } + } + if !params.is_empty() { + s.execute("filter.cameraRaw", Value::Object(params))?; + } + if defaults.color_space != crate::prefs::RawColorSpace::ProPhoto { + s.execute("edit.convertToProfile", json!({"profile":defaults.color_space.profile_id(),"intent":"relative"}))?; + } + if defaults.bit_depth == crate::prefs::RawDepth::Eight { + s.execute("image.mode.bits8", json!({}))?; + } + if defaults.open_as_smart_object { + s.execute("layer.smartObjects.convertToSmartObject", json!({}))?; + } + Ok(()) +} diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 681a466366..a262a83a1c 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -29,6 +29,8 @@ pub(crate) struct SaveOpts { /// TIFF: Save Transparency (`"tiffTransparency": bool`); unset, the transparency is kept /// unless the document has alpha or spot channels ([`photocraft_io::ExportOptions::tiff_transparency`]). pub tiff_transparency: Option, + /// Protect RGB Targa24 alpha and flat-TIFF transparency for Save and Close; not a user option. + pub preserve_alpha: bool, /// TIFF: Save Image Pyramid (`"tiffPyramid": bool`); unset, off. pub tiff_pyramid: bool, /// DDS: the compression and whether to write the mip chain (`"ddsCompression"`, @@ -55,6 +57,7 @@ impl Default for SaveOpts { dpx_bits: None, tiff_compression: None, tiff_transparency: None, + preserve_alpha: false, tiff_pyramid: false, dds: photocraft_io::DdsOptions::default(), image_previews: true, @@ -69,7 +72,8 @@ impl SaveOpts { /// `jpegFormat` it doesn't name or a `tgaBits` other than 24 or 32 is an error); the image /// previews and the PSD merged image from the session's File Handling preferences (Ask When /// Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox and its - /// Maximize Compatibility checkbox both start on). The `.pcraft` full-size composite comes from + /// Maximize Compatibility checkbox both start on); `maximizeCompatibility` decides the merged + /// image for this save instead. The `.pcraft` full-size composite comes from /// `pcraftFullComposite`, or without it from the File Handling preference. pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { let file_handling = &s.prefs().file_handling; @@ -81,10 +85,11 @@ impl SaveOpts { dpx_bits: dpx_bits_param(p, cmd)?, tiff_compression: tiff_compression_param(p, cmd)?, tiff_transparency: tiff_bool_param(p, "tiffTransparency", cmd)?, + preserve_alpha: false, tiff_pyramid: tiff_bool_param(p, "tiffPyramid", cmd)?.unwrap_or(false), dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, - psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, + psd_maximize_compatibility: maximize_compatibility_param(p, cmd)?.unwrap_or(file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never), pcraft_full_composite: pcraft_full_composite_param(p, cmd)?.unwrap_or(file_handling.pcraft_full_composite), }) } @@ -127,6 +132,19 @@ pub fn pcraft_full_composite_param(p: &Value, cmd: &str) -> Result> } } +/// One save's Maximize Compatibility in `p` (`"maximizeCompatibility": bool`, Photoshop's checkbox +/// when saving a PSD/PSB; absent or null: the File Handling preference decides). Anything else is +/// a bad parameter of command `cmd` (#8064). +pub fn maximize_compatibility_param(p: &Value, cmd: &str) -> Result> { + match p.get("maximizeCompatibility") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_bool() + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`maximizeCompatibility` must be true or false, got {v}") }), + } +} + /// The TIFF compression in `p` (`"tiffCompression": "none" | "lzw" | "zip"`, Photoshop's TIFF /// Options; absent or null: none). Anything else is a bad parameter of command `cmd` (#5301). pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result> { @@ -216,6 +234,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tiff_transparency: save.tiff_transparency, + preserve_alpha: save.preserve_alpha, tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 804e6a01ca..d42d333128 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1,12 +1,760 @@ use super::*; +// Named Batch action regression adapted from #7170 by @HirthikBalaji. +/// Named action lookup retains the original #7170 behavior while rejecting recursive Batch calls. +#[test] +fn batch_resolves_action_by_name_and_detects_recursion() { + let dir = tmp("batch-action-name"); + let input = png(&dir, "in.png", 8, 8, "#ff0000"); + let out = join(&dir, "out"); + std::fs::create_dir_all(&out).unwrap(); + let mut s = Session::new(); + let mut action = crate::actions_cmds::Action::new("Scale", vec![("image.imageSize".into(), json!({"width":16,"height":16}))]); + action.set = Some("Ops".into()); + s.actions.list.push(action); + let result = s.execute("file.automate.batch", json!({"action":"Scale","set":"Ops","input":[input.clone()],"output":out})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + for p in [json!({"action":"Nonexistent"}), json!({"action":"Scale","set":"Wrong set"})] { + let mut p = p; + p["input"] = json!([input.clone()]); + p["output"] = json!(out); + assert!(s.execute("file.automate.batch", p).is_err()); + } + s.actions.playback_stack.push("Scale".into()); + let err = s.execute("file.automate.batch", json!({"action":"Scale","input":[input],"output":out})).unwrap_err(); + assert!(err.to_string().contains("recursive action call"), "{err}"); +} + +/// Exercise saved-action replay through Batch; filename suffixes cannot change encoded image content. +#[test] +fn batch_structured_names_keep_image_format_and_refuse_unsupported_save_routing() { + let dir = tmp("batch-component-format"); + let input = png(&dir, "a.png", 3, 2, "#ff0000"); + let output = join(&dir, "out"); + std::fs::create_dir_all(&output).unwrap(); + let mut s = Session::new(); + let components = json!([{"component":"name"},{"text":".jpg"}]); + let result = s + .execute( + "file.automate.batch", + json!({"steps":[],"input":[input.clone()],"output":output, + "namingComponents":components,"overrideSaveAs":false}), + ) + .unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let path = result["files"][0].as_str().unwrap(); + assert!(path.ends_with("a.jpg")); + assert!(std::fs::read(path).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"), "typed extension must not change the encoded image format"); + for (steps, override_save) in [(json!([]), true), (json!([["file.saveAs", {}]]), false)] { + assert!( + s.execute( + "file.automate.batch", + json!({"steps":steps,"input":[input.clone()],"output":output, + "namingComponents":[{"component":"name"}],"overrideSaveAs":override_save}) + ) + .is_err() + ); + } + let saved = s + .execute( + "file.automate.batch", + json!({"steps":[["file.saveAs",{}],["file.close",{}]],"input":[input],"output":output, + "namingComponents":[{"text":"no-extension"}],"overrideSaveAs":true}), + ) + .unwrap(); + assert!(saved["errors"].as_array().unwrap().is_empty(), "{saved}"); + assert!(std::fs::read(saved["files"][0].as_str().unwrap()).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + // One composition witness: Save Actions -> Load Actions -> named Batch -> actual PNG bytes. + let mut child = crate::actions_cmds::Action::new("Child", vec![]); + child.set = Some("S".into()); + s.actions.list.push(child); + let params = json!({"input":dir,"output":output,"action":"Child","set":"S","includeSubfolders":false,"flatOutput":true, + "overrideSaveAs":false,"stopOnError":true,"includeNonImages":true,"namingComponents":[{"text":"from-atn"}]}); + let driver = crate::actions_cmds::Action::new("Driver", vec![("file.automate.batch".into(), params)]); + let (bytes, skipped) = crate::atn_cmds::export("S", &[&driver], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = crate::atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + s.actions.list.extend(imported.actions); + s.execute("actions.play", json!({"action":"Driver"})).unwrap(); + assert!(std::fs::read(join(&output, "from-atn")).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); +} + +/// Recorded Stop For Errors keeps prior writes and leaves later inputs untouched; native defaults continue. +#[test] +fn batch_recorded_stop_for_errors_retains_earlier_outputs_and_stops_before_later_inputs() { + let dir = tmp("batch-stop-for-errors"); + let input = join(&dir, "in"); + std::fs::create_dir_all(&input).unwrap(); + let first = png(&input, "a.png", 3, 2, "#ff0000"); + let bad = join(&input, "b.txt"); + std::fs::write(&bad, b"not an image").unwrap(); + let last = png(&input, "c.png", 3, 2, "#0000ff"); + let mut s = Session::new(); + for (mode, stop) in [("default", None), ("continue", Some(false)), ("stop", Some(true))] { + let output = join(&dir, mode); + let mut p = json!({"input":[first,bad,last],"output":output,"steps":[]}); + if let Some(stop) = stop { + p["stopOnError"] = json!(stop); + } + let result = s.execute("file.automate.batch", p).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), if stop == Some(true) { 1 } else { 2 }, "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result["errors"][0]["file"], bad); + assert!(std::path::Path::new(&join(&output, "a.png")).is_file()); + assert_eq!(std::path::Path::new(&join(&output, "c.png")).exists(), stop != Some(true)); + } + // Native folder defaults still select images only. + let native = s.execute("file.automate.batch", json!({"input":input,"output":join(&dir,"folder-default"),"steps":[]})).unwrap(); + assert_eq!(native["files"].as_array().unwrap().len(), 2, "{native}"); + assert!(native["errors"].as_array().unwrap().is_empty(), "{native}"); + let mut child = crate::actions_cmds::Action::new("Child", vec![]); + child.set = Some("S".into()); + s.actions.list.push(child); + let output = join(&dir, "recorded"); + let p = json!({"input":input,"output":output,"action":"Child","set":"S","stopOnError":true,"includeNonImages":true, + "includeSubfolders":false,"overrideSaveAs":false,"flatOutput":true, + "namingComponents":[{"component":"name"},{"component":"extension"}]}); + let sentinel = json!({"width":7,"height":9}); + let driver = crate::actions_cmds::Action::new("Driver", vec![("file.automate.batch".into(), p.clone()), ("file.new".into(), sentinel.clone())]); + let (bytes, skipped) = crate::atn_cmds::export("S", &[&driver], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = crate::atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[1].0, "file.new", "the after-Batch sentinel must be replayable"); + s.actions.list.extend(imported.actions); + let result = s.execute("actions.play", json!({"action":"Driver"})).unwrap(); + assert_eq!(result["ran"], 0, "{result}"); + assert_eq!(result["failed"]["id"], "file.automate.batch"); + assert!(result["failed"]["error"].as_str().unwrap().contains("b.txt"), "{result}"); + assert!(s.documents().is_empty(), "the command after the failed Batch must not run"); + assert!(std::path::Path::new(&join(&output, "a.png")).is_file()); + assert!(!std::path::Path::new(&join(&output, "c.png")).exists()); + // The measured scripting API reports success for the same stopped Batch and continues the script. + let result = s.execute("file.scripts.browse", json!({"steps":[["file.automate.batch",p],["file.new",sentinel]]})).unwrap(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["results"][0]["result"]["errors"].as_array().unwrap().len(), 1); + assert_eq!(s.documents().len(), 1, "the direct script caller still runs its next command"); + batch_folder_input_controls(&mut s, &dir, &input, &first, &bad); +} + +/// Check folder selection and explicit-array behavior against the same three Batch inputs. +fn batch_folder_input_controls(s: &mut Session, dir: &str, input: &str, first: &str, bad: &str) { + // The recursive mode keeps output exclusion and reports excluded RAW names before import. + let output = join(input, "out"); + std::fs::create_dir_all(&output).unwrap(); + png(&output, "ignored.png", 3, 2, "#ffffff"); + let sub = join(input, "sub"); + std::fs::create_dir_all(&sub).unwrap(); + let raw = join(&sub, "d.cr3"); + std::fs::copy(first, &raw).unwrap(); + let result = s + .execute( + "file.automate.batch", + json!({"input":input,"output":output,"steps":[],"format":"png", + "includeSubfolders":true,"includeNonImages":true}), + ) + .unwrap(); + assert_eq!(result["files"], json!([join(&output, "a.png"), join(&output, "c.png")]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 2, "{result}"); + assert_eq!(std::path::Path::new(result["errors"][0]["file"].as_str().unwrap()), std::path::Path::new(bad)); + assert_eq!(std::path::Path::new(result["errors"][1]["file"].as_str().unwrap()), std::path::Path::new(&raw)); + assert!(result["errors"][1]["error"].as_str().unwrap().contains("unsupported Batch folder input"), "{result}"); + assert!(!std::path::Path::new(&join(&output, "sub/d.png")).exists()); + // Explicit arrays still sniff the image content, including with the folder option enabled. + let output = join(dir, "explicit-array"); + let result = s.execute("file.automate.batch", json!({"input":[raw],"output":output,"steps":[],"format":"png","includeNonImages":true})).unwrap(); + assert_eq!(result["files"], json!([join(&output, "d.png")]), "{result}"); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + for key in ["stopOnError", "includeNonImages"] { + let invalid_output = join(dir, key); + let mut p = json!({"input":[first],"output":invalid_output,"steps":[]}); + p[key] = json!("true"); + let err = s.execute("file.automate.batch", p).unwrap_err(); + assert!(err.to_string().contains(key), "{err}"); + assert!(!std::path::Path::new(&invalid_output).exists()); + } +} + +/// In-place Batch retains caller state and commits only successful sources, including a saved Close snapshot. +#[test] +fn batch_save_and_close_preserves_caller_and_failed_sources() { + let dir = tmp("batch-save-close"); + let inputs: Vec = ["a.png", "b.png", "c.png"].iter().map(|name| png(&dir, name, 3, 2, "#ff0000")).collect(); + let originals: Vec> = inputs.iter().map(|path| std::fs::read(path).unwrap()).collect(); + let caller_dir = join(&dir, "caller"); + std::fs::create_dir_all(&caller_dir).unwrap(); + let caller_path = png(&caller_dir, "caller.png", 7, 9, "#4488aa"); + let mut s = Session::new(); + open_bytes_as(&mut s, "caller.png", &std::fs::read(&caller_path).unwrap(), None, Some(caller_path)).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let caller_state = |s: &Session| { + let docs: Vec = s + .documents() + .iter() + .map(|st| { + json!({ + "id":st.doc.id,"path":st.path,"revision":st.revision,"saved":st.saved_revision, + "activeLayer":st.active_layer,"selected":st.selected_layers,"channels":format!("{:?}",st.channel_view), + "history":st.history.past_len(),"undo":st.history.undo_label(),"redo":st.history.can_redo(), + "bytes":photocraft_format::save_to_bytes(&st.doc,&Default::default()).unwrap() + }) + }) + .collect(); + json!({"active":s.active_index(),"generation":s.active_generation(),"docs":docs}) + }; + let before = caller_state(&s); + let mut child = crate::actions_cmds::Action::new( + "Child", + vec![("image.adjustments.invert".into(), json!({})), ("file.saveAs".into(), json!({})), ("file.close".into(), json!({}))], + ); + child.set = Some("S".into()); + s.actions.list.push(child); + let params = json!({"input":dir,"destination":"saveAndClose","action":"Child","set":"S", + "includeSubfolders":false,"overrideSaveAs":false,"stopOnError":true,"includeNonImages":true}); + let driver = crate::actions_cmds::Action::new("Driver", vec![("file.automate.batch".into(), params)]); + let (bytes, skipped) = crate::atn_cmds::export("S", &[&driver], &[], false).unwrap(); + assert!(skipped.is_empty()); + s.actions.list.extend(crate::atn_cmds::import(&bytes).unwrap().actions); + let result = s.execute("actions.play", json!({"action":"Driver"})).unwrap(); + assert_eq!(result["ran"], 1, "{result}"); + assert!(result.get("failed").is_none(), "{result}"); + for (path, original) in inputs.iter().zip(&originals) { + assert_ne!(std::fs::read(path).unwrap(), *original); + let loaded = photocraft_io::import("saved.png", &std::fs::read(path).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&loaded, Rect::new(1, 0, 2, 1)).px[0], [0.0, 1.0, 1.0, 1.0]); + } + assert_eq!(caller_state(&s), before); + + /// Arm the existing save failpoint at the second file's last nonallocating action step. + fn fail_second_save(id: &str, _: &Value) -> Result<()> { + static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + if id == "prefs.get" && CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 1 { + crate::allocation::fail_next_for_test(); + } + Ok(()) + } + let original: Vec> = inputs.iter().map(|path| std::fs::read(path).unwrap()).collect(); + s.authorize = Some(fail_second_save); + let result = s + .execute( + "file.automate.batch", + json!({"input":inputs,"destination":"saveAndClose","stopOnError":true, + "steps":[["image.adjustments.invert",{}],["prefs.get",{"path":"interface.uiScale"}]]}), + ) + .unwrap(); + s.authorize = None; + assert_eq!(result["files"], json!([inputs[0]]), "{result}"); + assert_eq!(result["errors"][0]["file"], inputs[1]); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("not enough memory for saving document"), "{result}"); + assert_ne!(std::fs::read(&inputs[0]).unwrap(), original[0]); + assert_eq!(std::fs::read(&inputs[1]).unwrap(), original[1]); + assert_eq!(std::fs::read(&inputs[2]).unwrap(), original[2]); + assert_eq!(caller_state(&s), before); + + let source = std::fs::read(&inputs[0]).unwrap(); + open_bytes_as(&mut s, "a.png", &source, None, Some(inputs[0].clone())).unwrap(); + let open_before = caller_state(&s); + assert!(s.execute("file.automate.batch", json!({"input":[inputs[0]],"destination":"saveAndClose","steps":[]})).is_err()); + assert_eq!(caller_state(&s), open_before); + assert_eq!(std::fs::read(&inputs[0]).unwrap(), source); + s.close(s.documents().len() - 1); + let before = caller_state(&s); + let result = s + .execute( + "file.automate.batch", + json!({"input":[inputs[0]],"destination":"saveAndClose","stopOnError":true, + "steps":[["file.new",{"width":2,"height":2}]]}), + ) + .unwrap(); + assert!(result["files"].as_array().unwrap().is_empty(), "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("original source document"), "{result}"); + assert_eq!(std::fs::read(&inputs[0]).unwrap(), source); + assert_eq!(caller_state(&s), before); + assert!(s.execute("file.automate.batch", json!({"input":[inputs[0]],"destination":"saveAndClose","format":"jpg","steps":[]})).is_err()); + assert_eq!(std::fs::read(&inputs[0]).unwrap(), source); + assert_eq!(caller_state(&s), before); +} + +/// In-place TIFF keeps layers by default; explicit flattening is allowed only when it loses no layer data. +#[test] +fn batch_save_and_close_keeps_tiff_layers() { + let dir = tmp("batch-save-close-tiff"); + let path = join(&dir, "layered.tif"); + let mut source = session(3, 2, 8); + source.execute("layer.new.layer", json!({"name":"Overlay"})).unwrap(); + let names: Vec<_> = doc(&source).layers.iter().map(|layer| layer.name.clone()).collect(); + save_doc(doc(&source), &path, super::save::SaveOpts { tiff_layers: true, ..Default::default() }).unwrap(); + let mut s = Session::new(); + let result = s.execute("file.automate.batch", json!({"input":[path],"destination":"saveAndClose","steps":[]})).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let bytes = std::fs::read(&path).unwrap(); + assert!(photocraft_codecs::tiff_photoshop_tags(&bytes).1.is_some()); + let back = photocraft_io::import("layered.tif", &bytes).unwrap().document; + assert_eq!(back.layers.iter().map(|layer| layer.name.clone()).collect::>(), names); + let result = s.execute("file.automate.batch", json!({"input":[path],"destination":"saveAndClose","tiffLayers":false,"steps":[]})).unwrap(); + assert!(result["files"].as_array().unwrap().is_empty(), "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("TIFF layer data"), "{result}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + // Existing Folder defaults still write a flat copy; that flat source may explicitly keep layers off. + let copies = join(&dir, "copies"); + let result = s.execute("file.automate.batch", json!({"input":[path],"output":copies,"steps":[]})).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let flat = result["files"][0].as_str().unwrap(); + let flat_bytes = std::fs::read(flat).unwrap(); + assert!(photocraft_codecs::tiff_photoshop_tags(&flat_bytes).1.is_none()); + let result = s.execute("file.automate.batch", json!({"input":[flat],"destination":"saveAndClose","tiffLayers":false,"steps":[]})).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert_eq!(result["files"], json!([flat])); +} + +/// Read a small saved Batch result with the same import/compositor oracle as the existing fixtures. +fn batch_open_pixel(path: &str) -> [f32; 4] { + let loaded = photocraft_io::import("result.png", &std::fs::read(path).unwrap()).unwrap().document; + photocraft_compose::render(&loaded, Rect::new(0, 0, 1, 1)).px[0] +} + +/// Called and conditional actions and scripts retain Open settings and use each selected input. +#[test] +fn batch_override_open_follows_called_conditional_and_script_steps() { + let dir = tmp("batch-override-open"); + let first = png(&dir, "a.png", 3, 2, "#ff0000"); + let second = png(&dir, "b.png", 3, 2, "#0000ff"); + let recorded = png(&dir, "recorded.png", 3, 2, "#00ff00"); + let mut s = session(7, 9, 8); + let caller = photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(); + let prefs = serde_json::to_value(s.prefs()).unwrap(); + s.actions.list.push(crate::actions_cmds::Action::new("Open", vec![("file.openAs".into(), json!({"path":recorded,"as":"png"}))])); + let actions = serde_json::to_value(&s.actions.list).unwrap(); + let steps = json!([["actions.conditional",{"if":"always","then":"Open"}], + ["file.scripts.browse",{"steps":[["file.closeAll",{}],["file.openAs",{"path":recorded,"as":"png"}]]}], + ["image.adjustments.invert",{}]]); + for enabled in [false, true] { + let output = join(&dir, if enabled { "redirected" } else { "legacy" }); + let result = s + .execute( + "file.automate.batch", + json!({"input":[first,second],"output":output,"steps":steps, + "calledActions":s.actions.list,"overrideOpen":enabled}), + ) + .unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert_eq!(batch_open_pixel(&join(&output, "a.png")), if enabled { [0.0, 1.0, 1.0, 1.0] } else { [1.0, 0.0, 1.0, 1.0] }); + assert_eq!(batch_open_pixel(&join(&output, "b.png")), if enabled { [1.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 1.0, 1.0] }); + } + let failed = s + .execute( + "file.automate.batch", + json!({"input":[first],"output":join(&dir,"forced-format"),"overrideOpen":true, + "steps":[["file.openAs",{"path":recorded,"as":"pdn"}]]}), + ) + .unwrap(); + assert_eq!(failed["errors"].as_array().unwrap().len(), 1, "nondefault Open settings must still affect decoding: {failed}"); + assert!(!std::path::Path::new(&join(&dir, "forced-format/a.png")).exists()); + let invalid = join(&dir, "invalid-override"); + assert!(s.execute("file.automate.batch", json!({"input":[first],"output":invalid,"steps":[],"overrideOpen":"true"})).is_err()); + assert!(!std::path::Path::new(&invalid).exists()); + assert_eq!(serde_json::to_value(&s.actions.list).unwrap(), actions); + assert_eq!(serde_json::to_value(s.prefs()).unwrap(), prefs); + assert_eq!(photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(), caller); + assert!(s.batch_open.is_none()); +} + +/// Both authorization gates see the actual input; a denied file keeps its source and output intact. +#[test] +fn batch_override_open_authorizes_effective_path_and_carries_failure_budget() { + static OPENS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + fn gate(id: &str, p: &Value) -> Result<()> { + if id == "file.openAs" { + OPENS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if p["as"] != "png" || p["path"].as_str().is_none_or(|path| file_name(path) != "a.png") { + return Err(EngineError::Other("effective Open denied".into())); + } + } + Ok(()) + } + let dir = tmp("batch-open-authorization"); + let first = png(&dir, "a.png", 3, 2, "#ff0000"); + let second = png(&dir, "b.png", 3, 2, "#0000ff"); + let original = std::fs::read(&second).unwrap(); + let output = join(&dir, "out"); + std::fs::create_dir_all(&output).unwrap(); + let existing = png(&output, "b.png", 3, 2, "#4488aa"); + let intact = std::fs::read(&existing).unwrap(); + let mut s = Session::new(); + s.actions.list.push(crate::actions_cmds::Action::new("Open", vec![("file.openAs".into(), json!({"path":"/recorded.png","as":"png"}))])); + s.batch_open = Some(BatchOpen { path: first.clone(), sources: Vec::new(), raw_ppi: 300.0, read_only: false }); + assert!(matches!(s.execute("file.openAs", json!([1])), Err(EngineError::BadParams { .. }))); + assert!(s.documents().is_empty()); + s.batch_open = None; + s.authorize = Some(gate); + let driver = crate::actions_cmds::Action::new("Driver", vec![]); + let recording = crate::actions_cmds::begin_playback(&mut s, &driver).unwrap(); + OPENS.store(0, std::sync::atomic::Ordering::SeqCst); + let result = s + .execute( + "file.automate.batch", + json!({"input":[first,second],"output":output,"overrideOpen":true,"stopOnError":true, + "steps":[["actions.play",{"action":"Open"}]]}), + ) + .unwrap(); + assert_eq!(result["files"], json!([join(&output, "a.png")]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("effective Open denied")); + assert_eq!(OPENS.load(std::sync::atomic::Ordering::SeqCst), 3, "two gates for the first Open, preauthorization denies the second"); + assert_eq!(s.actions.playback_steps, 4); + assert_eq!(std::fs::read(&second).unwrap(), original); + assert_eq!(std::fs::read(&existing).unwrap(), intact); + assert!(s.documents().is_empty()); + assert!(s.batch_open.is_none()); + crate::actions_cmds::finish_playback(&mut s, recording, &driver, 0); +} + +/// Inner Batch bindings do not replace outer input provenance or the shared logical-step budget. +#[test] +fn batch_override_open_nested_binding_and_save_close_preserve_sources() { + let dir = tmp("batch-open-nested"); + let first = png(&dir, "a.png", 3, 2, "#ff0000"); + let second = png(&dir, "b.png", 3, 2, "#0000ff"); + let output = join(&dir, "outer"); + let inner = join(&dir, "inner"); + let open = json!({"path":"/recorded.png","as":"png"}); + let mut s = Session::new(); + s.actions.list.push(crate::actions_cmds::Action::new("Open", vec![("file.openAs".into(), open.clone())])); + let steps = json!([["actions.play",{"action":"Open"}], + ["file.automate.batch",{"input":[second],"output":inner,"overrideOpen":true,"steps":[["file.openAs",open],["image.adjustments.invert",{}]]}], + ["file.closeAll",{}],["file.openAs",open],["image.adjustments.invert",{}]]); + let driver = crate::actions_cmds::Action::new("Driver", vec![]); + let recording = crate::actions_cmds::begin_playback(&mut s, &driver).unwrap(); + s.actions.playback_steps = crate::actions_cmds::MAX_PLAYBACK_STEPS - 8; + let result = s.execute("file.automate.batch", json!({"input":[first],"output":output,"overrideOpen":true,"steps":steps})).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert_eq!(batch_open_pixel(&join(&output, "a.png")), [0.0, 1.0, 1.0, 1.0]); + assert_eq!(batch_open_pixel(&join(&inner, "b.png")), [1.0, 1.0, 0.0, 1.0]); + assert_eq!(s.actions.playback_steps, crate::actions_cmds::MAX_PLAYBACK_STEPS); + crate::actions_cmds::finish_playback(&mut s, recording, &driver, 0); + let original = std::fs::read(&first).unwrap(); + for steps in [ + json!([["image.adjustments.invert", {}]]), + json!([["file.new",{"width":2,"height":2}],["file.saveAs",{}]]), + json!([["file.openAs",open],["file.new",{"width":2,"height":2}],["file.saveAs",{}]]), + ] { + let result = s.execute("file.automate.batch", json!({"input":[first],"destination":"saveAndClose","overrideOpen":true,"steps":steps})).unwrap(); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(std::fs::read(&first).unwrap(), original); + } + let result = s + .execute( + "file.automate.batch", + json!({"input":[first],"destination":"saveAndClose","overrideOpen":true, + "steps":[["file.openAs",open],["image.adjustments.invert",{}],["file.saveAs",{}],["file.close",{}]]}), + ) + .unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert_eq!(batch_open_pixel(&first), [0.0, 1.0, 1.0, 1.0]); + assert!(s.documents().is_empty()); + assert!(s.batch_open.is_none()); +} + +/// Profile suppression uses caller settings without silencing import errors or changing the caller. +#[test] +fn batch_quiet_profile_open_uses_caller_policy_and_retains_diagnostics() { + let dir = tmp("batch-quiet-profile"); + let source = png(&dir, "a.png", 3, 2, "#4488aa"); + let mut fixture = Session::new(); + fixture.execute("file.openAs", json!({"path":source})).unwrap(); + fixture.execute("edit.assignProfile", json!({"profile":"display-p3"})).unwrap(); + fixture.execute("file.saveACopy", json!({"path":source,"overwrite":true})).unwrap(); + let original = std::fs::read(&source).unwrap(); + let bad = join(&dir, "b.png"); + std::fs::write(&bad, b"not an image").unwrap(); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb":"srgb","policyRgb":"convert","askOnMismatch":true,"askOnMissing":true})).unwrap(); + let prefs = serde_json::to_value(s.prefs()).unwrap(); + let settings = s.color.settings.clone(); + let policy = s.color.open_question(doc(&fixture)).unwrap(); + assert_eq!(policy["ask"], true); + let output = join(&dir, "out"); + let result = s.execute("file.automate.batch", json!({"input":[source,bad],"output":output,"suppressWarnings":true,"stopOnError":true,"steps":[]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result["notices"][0]["color"]["action"], "converted"); + assert_eq!(result["notices"][0]["color"]["ask"], false); + let saved = photocraft_io::import("a.png", &std::fs::read(join(&output, "a.png")).unwrap()).unwrap().document; + let profile = photocraft_cms::Profile::parse(saved.icc_profile.as_ref().unwrap()).unwrap(); + assert!(profile.same_colors(photocraft_cms::Builtin::Srgb.profile())); + // A script calling an action with a failing inner Batch still retains both import reports. + let inner = join(&dir, "inner"); + s.actions.list.push(crate::actions_cmds::Action::new( + "Quiet inner", + vec![("file.automate.batch".into(), json!({"input":[source,bad],"output":inner,"suppressWarnings":true,"stopOnError":true,"steps":[]}))], + )); + let nested = s + .execute( + "file.automate.batch", + json!({"input":[source],"output":join(&dir,"nested-root"),"suppressWarnings":true, + "steps":[["file.scripts.browse",{"steps":[["actions.play",{"action":"Quiet inner"}]]}]]}), + ) + .unwrap(); + assert_eq!(nested["errors"].as_array().unwrap().len(), 1, "{nested}"); + let notices = nested["notices"].as_array().unwrap(); + assert_eq!(notices.len(), 2, "one outer and one inner import, no duplicate: {nested}"); + assert!(notices.iter().all(|notice| notice["color"]["action"] == "converted" && notice["color"]["ask"] == false)); + assert_eq!(std::fs::read(&source).unwrap(), original); + assert_eq!(s.color.settings, settings); + assert_eq!(serde_json::to_value(s.prefs()).unwrap(), prefs); + assert!(s.batch_open_notices.is_none()); + let invalid = join(&dir, "invalid"); + assert!(s.execute("file.automate.batch", json!({"input":[source],"output":invalid,"steps":[],"suppressWarnings":"true"})).is_err()); + assert!(!std::path::Path::new(&invalid).exists()); +} + +/// The sentinel bounds even the broken version: named live-document cycles must reject first, +/// while an acyclic action preserves the caller's recorder and surrounding playback stack. +#[test] +fn named_open_document_batch_keeps_cycle_context_and_live_recorder() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn gate(id: &str, _: &Value) -> Result<()> { + if id == "file.automate.batch" && CALLS.fetch_add(1, Ordering::SeqCst) >= 4 { + return Err(EngineError::Other("bounded Batch recursion sentinel".into())); + } + Ok(()) + } + for mutual in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let nested = |name| ("file.automate.batch".into(), json!({"source":"open","action":name})); + s.actions.list = vec![ + crate::actions_cmds::Action::new("Recorder", vec![]), + crate::actions_cmds::Action::new("Loop A", vec![nested(if mutual { "Loop B" } else { "Loop A" })]), + crate::actions_cmds::Action::new("Loop B", vec![nested("Loop A")]), + crate::actions_cmds::Action::new("Leaf", vec![("actions.play".into(), json!({"action":0}))]), + ]; + let definitions = s.actions.list[1..].to_vec(); + s.actions.playback_stack = vec!["Enclosing".into()]; + s.actions.recording = Some((s.journal.len(), 0)); + let take = s.actions.take; + s.authorize = Some(gate); + CALLS.store(0, Ordering::SeqCst); + let result = batch(&mut s, &json!({"source":"open","action":"Loop A"})).unwrap(); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("recursive action"), "{result}"); + assert!(CALLS.load(Ordering::SeqCst) <= 2, "the cycle check must beat the finite sentinel"); + assert_eq!(s.actions.playback_stack, ["Enclosing"]); + assert_eq!(s.actions.recording.map(|(_, owner)| owner), Some(0)); + assert_eq!(s.actions.take, take); + assert_eq!(&s.actions.list[1..], definitions.as_slice()); + let before = doc(&s).walk().len(); + let embedded = vec![crate::actions_cmds::Action::new("Embedded", vec![("layer.new.layer".into(), json!({"name":"visited"}))])]; + let result = batch(&mut s, &json!({"source":"open","action":"Leaf","calledActions":embedded})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(doc(&s).walk().len(), before + 1); + assert_eq!(s.actions.playback_stack, ["Enclosing"]); + assert_eq!(s.actions.recording.map(|(_, owner)| owner), Some(0)); + assert_eq!(s.actions.take, take); + assert_eq!(&s.actions.list[1..], definitions.as_slice()); + } +} + +/// Named live-document playback must normalize recorded targets and percentage coordinates, +/// just as ordinary action playback does, before authorization sees execution params. +#[test] +fn named_open_document_batch_uses_effective_recorded_parameters() { + fn gate(id: &str, p: &Value) -> Result<()> { + if id == "type.create" && (p.get("xPercent").is_some() || p["x"] != json!(50.0) || p["y"] != json!(40.0)) { + return Err(EngineError::Other("type position was not normalized before authorization".into())); + } + if id == "layer.setProps" && p.get("_actionLayer").is_some() { + return Err(EngineError::Other("recorded visibility target leaked to execution".into())); + } + Ok(()) + } + for through_batch in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":200,"height":100})).unwrap(); + let a = s.execute("layer.new.layer", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap(); + s.actions.list.push(crate::actions_cmds::Action::new( + "Recorded", + vec![ + ("layer.setProps".into(), json!({"visible":false,"_actionLayer":{"name":"A","ordinal":0}})), + ("type.create".into(), json!({"text":"x","size":12,"xPercent":25.0,"yPercent":40.0})), + ], + )); + if through_batch { + s.authorize = Some(gate); + let result = s.execute("file.automate.batch", json!({"source":"open","action":"Recorded"})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + } else { + let result = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert_eq!(result["ran"], 2, "{result}"); + } + assert!(!doc(&s).layer(photocraft_doc::LayerId(a)).unwrap().visible); + assert!(doc(&s).layer(photocraft_doc::LayerId(b)).unwrap().visible); + assert_eq!(doc(&s).walk().iter().filter(|(_, _, layer)| matches!(layer.content, photocraft_doc::LayerContent::Text(_))).count(), 1); + } +} + +/// Structured outputs reject a repeated live-document destination before editing that document; +/// the ordinary native template control retains main's intentional later-file-wins behavior. +#[test] +fn structured_open_document_batch_guards_destinations_before_editing() { + let dir = tmp("batch-open-collision-policy"); + for structured in [false, true] { + let mut s = Session::new(); + for color in ["#ff0000", "#0000ff"] { + s.execute("file.new", json!({"width":2,"height":2,"background":color})).unwrap(); + } + let before = s.documents()[1].history.past_len(); + let output = join(&dir, if structured { "structured" } else { "ordinary" }); + let mut p = json!({"source":"open","output":output,"format":"png","stopOnError":true, + "steps":[["image.adjustments.invert",{}]]}); + if structured { + p["namingComponents"] = json!([{"text":"same.png"}]); + } else { + p["naming"] = json!("same.png"); + } + let result = s.execute("file.automate.batch", p).unwrap(); + let saved = photocraft_io::import("same.png", &std::fs::read(join(&output, "same.png")).unwrap()).unwrap().document; + let pixel = photocraft_compose::render(&saved, Rect::new(0, 0, 1, 1)).px[0]; + if structured { + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written")); + assert_eq!(pixel, [0.0, 1.0, 1.0, 1.0], "the first result remains intact"); + assert_eq!(s.documents()[1].history.past_len(), before); + assert_eq!(photocraft_compose::render(&s.documents()[1].doc, Rect::new(0, 0, 1, 1)).px[0], [0.0, 0.0, 1.0, 1.0]); + } else { + assert_eq!(result["files"].as_array().unwrap().len(), 2, "{result}"); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(pixel, [1.0, 1.0, 0.0, 1.0], "native naming keeps the later result"); + assert_eq!(s.documents()[1].history.past_len(), before + 1); + } + } +} + +/// A background recorded Batch keeps quiet opening, structured content and collision refusal +/// through the shared cancellation/write runner; the duplicate never replaces the first result. +#[test] +fn background_recorded_batch_keeps_opening_and_destination_policies() { + let dir = tmp("batch-recorded-background"); + let source = png(&dir, "source.png", 3, 2, "#4488aa"); + let other = png(&dir, "other.png", 3, 2, "#ff0000"); + let mut fixture = Session::new(); + fixture.execute("file.openAs", json!({"path":source})).unwrap(); + fixture.execute("edit.assignProfile", json!({"profile":"display-p3"})).unwrap(); + fixture.execute("file.saveACopy", json!({"path":source,"overwrite":true})).unwrap(); + let original = std::fs::read(&source).unwrap(); + let missing = join(&dir, "missing.png"); + let output = join(&dir, "out"); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb":"srgb","policyRgb":"convert","askOnMismatch":true,"askOnMissing":true})).unwrap(); + let prefs = serde_json::to_value(s.prefs()).unwrap(); + let settings = s.color.settings.clone(); + let job = match s + .start( + "file.automate.batch", + json!({ + "input":[source,other,missing],"output":output,"format":"png", + "namingComponents":[{"text":"result.jpg"}],"flatOutput":true, + "suppressWarnings":true,"includeNonImages":true,"stopOnError":true, + "overrideSaveAs":true,"steps":[["file.saveAs",{}],["file.close",{}]] + }), + ) + .unwrap() + { + crate::jobs::Started::Job(id) => id, + crate::jobs::Started::Done(_) => panic!("a started folder Batch must run in the background"), + }; + let result = s.wait_job(job).unwrap(); + let saved_path = join(&output, "result.jpg"); + assert_eq!(result["files"], json!([saved_path]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written"), "{result}"); + assert_eq!(result["skipped"], json!([missing]), "Stop For Errors leaves the third input unprocessed"); + assert_eq!(result["notices"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result["notices"][0]["color"]["action"], "converted"); + assert_eq!(result["notices"][0]["color"]["ask"], false); + let bytes = std::fs::read(saved_path).unwrap(); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "the structured suffix does not choose the encoder"); + let saved = photocraft_io::import("result.png", &bytes).unwrap().document; + let profile = photocraft_cms::Profile::parse(saved.icc_profile.as_ref().unwrap()).unwrap(); + assert!(profile.same_colors(photocraft_cms::Builtin::Srgb.profile())); + assert_eq!(std::fs::read(source).unwrap(), original); + assert_eq!(serde_json::to_value(s.prefs()).unwrap(), prefs); + assert_eq!(s.color.settings, settings); + assert!(s.documents().is_empty()); +} + +/// Quiet RAW opening applies supported prior XMP and refuses settings or reads it cannot honor. +#[test] +fn batch_quiet_raw_applies_prior_settings_and_workflow_without_touching_inputs() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-quiet-raw"); + let path = join(&dir, "raw.dng"); + let (w, h) = (24, 16); + let bytes = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build(); + std::fs::write(&path, &bytes).unwrap(); + let sidecar = join(&dir, "raw.xmp"); + let settings = photocraft_algo::camera_raw::CameraRaw { exposure: 1.0, ..Default::default() }; + let xmp = photocraft_io::camera_raw_xmp::save(&settings); + std::fs::write(&sidecar, &xmp).unwrap(); + let mut s = Session::new(); + s.execute("prefs.set", json!({"path":"rawDefaults.resolution","value":240})).unwrap(); + let prefs = serde_json::to_value(s.prefs()).unwrap(); + let output = join(&dir, "out"); + let p = json!({"input":[path],"output":output,"format":"psd","suppressOpenOptions":true,"suppressWarnings":true,"steps":[]}); + let result = s.execute("file.automate.batch", p.clone()).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let actual = photocraft_io::import("raw.psd", &std::fs::read(join(&output, "raw.psd")).unwrap()).unwrap().document; + assert_eq!(actual.resolution_dpi, 240.0); + let (expected, _) = + photocraft_io::raw::import_raw_tuned("raw.dng", &bytes, &photocraft_io::raw::RawTuning { exposure: 1.0, ..Default::default() }).unwrap(); + let mut oracle = Session::new(); + oracle.add_document(expected.document, None); + oracle.execute("edit.convertToProfile", json!({"profile":s.prefs().raw_defaults.color_space.profile_id(),"intent":"relative"})).unwrap(); + let area = Rect::new(0, 0, w as i32, h as i32); + let got = photocraft_compose::render(&actual, area); + let want = photocraft_compose::render(doc(&oracle), area); + assert!(got.px.iter().zip(&want.px).all(|(got, want)| got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1.0 / 65535.0))); + let saved = std::fs::read(join(&output, "raw.psd")).unwrap(); + std::fs::write(&sidecar, xmp.replace("crs:Exposure2012=", "crs:Temperature=")).unwrap(); + let failed = s.execute("file.automate.batch", p.clone()).unwrap(); + assert!(failed["errors"][0]["error"].as_str().unwrap().contains("Temperature"), "{failed}"); + assert_eq!(std::fs::read(join(&output, "raw.psd")).unwrap(), saved); + fn allow(_: &str, _: &Value) -> Result<()> { + Ok(()) + } + s.authorize = Some(allow); + let failed = s.execute("file.automate.batch", p).unwrap(); + assert!(failed["errors"][0]["error"].as_str().unwrap().contains("no implicit read capability"), "{failed}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(serde_json::to_value(s.prefs()).unwrap(), prefs); + assert!(s.batch_raw_open.is_none()); + assert!(s.batch_open_notices.is_none()); +} + fn tmp(name: &str) -> String { - let d = std::env::temp_dir().join(format!("photocraft-file-cmds-{}-{name}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("photocraft-file-cmds-{}-{name}-{id}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d.to_string_lossy().into_owned() } +/// Concurrent tests may reuse a descriptive label without deleting another fixture's files. +#[test] +fn temporary_directories_with_the_same_label_are_isolated() { + let first = tmp("same-label"); + let marker = join(&first, "source.png"); + std::fs::write(&marker, b"original source").unwrap(); + let second = tmp("same-label"); + assert_ne!(first, second); + assert_eq!(std::fs::read(marker).unwrap(), b"original source"); + assert!(std::path::Path::new(&second).is_dir()); +} + fn session(w: u32, h: u32, depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); @@ -760,6 +1508,29 @@ fn revert_batch_and_stack_open_a_raw_at_the_raw_defaults_resolution() { assert_eq!(files.len(), 1, "{r}"); let psd = photocraft_io::import("IMG_0003.psd", &std::fs::read(files[0].as_str().unwrap()).unwrap()).unwrap().document; assert_eq!(psd.resolution_dpi, 240.0, "Batch"); + // A nested Override Open inherits the outer input's resolution, not scratch defaults. + let nested = join(&dir, "nested"); + let r = s + .execute( + "file.automate.batch", + json!({"input":[path],"output":join(&dir,"outer"),"format":"psd","overrideOpen":true, + "steps":[["file.openAs",{"path":"/recorded.dng"}], + ["file.automate.batch",{"input":[path],"output":nested,"format":"psd","overrideOpen":true, + "steps":[["file.openAs",{"path":"/recorded.dng"}]]}]]}), + ) + .unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let nested = photocraft_io::import("IMG_0003.psd", &std::fs::read(join(&nested, "IMG_0003.psd")).unwrap()).unwrap().document; + assert_eq!(nested.resolution_dpi, 240.0, "nested Override Open keeps the caller's RAW resolution"); + let quiet = join(&dir, "quiet-raw"); + let r = s.execute("file.automate.batch", json!({"input":[path],"output":quiet,"format":"psd","suppressWarnings":true,"steps":[]})).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + assert!( + r["notices"][0]["warnings"].as_array().unwrap().iter().any(|warning| warning.as_str().unwrap().contains(photocraft_io::raw::DEVELOPED_NOTE)), + "{r}" + ); + let quiet = photocraft_io::import("IMG_0003.psd", &std::fs::read(join(&quiet, "IMG_0003.psd")).unwrap()).unwrap().document; + assert_eq!(quiet.resolution_dpi, 240.0); s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [path]})).unwrap(); assert_eq!(doc(&s).resolution_dpi, 240.0, "Load Files into Stack"); } @@ -1104,6 +1875,41 @@ fn full_composite_preference_controls_the_pcraft_full_size_composite() { } } +/// #8064: `maximizeCompatibility` decides one save's PSD/PSB merged image over the preference +/// (Photoshop's Maximize Compatibility checkbox); unset, the preference decides. Anything but a +/// boolean is refused before anything is written. +#[test] +fn maximize_compatibility_param_overrides_the_preference_for_one_save() { + use crate::prefs::Ask; + let dir = tmp("maximize-param"); + let mut s = session(300, 200, 16); + s.execute("layer.new.layer", json!({})).unwrap(); + let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + for (ask, param, expect) in [ + (Ask::Always, json!(false), false), + (Ask::Never, json!(true), true), + (Ask::Ask, json!(false), false), + (Ask::Never, json!(null), false), + (Ask::Always, json!(null), true), + ] { + s.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = ask); + for ext in ["psd", "psb"] { + let out = join(&dir, &format!("{ask:?}-{param}.{ext}")); + s.execute("file.saveACopy", json!({"path": out, "maximizeCompatibility": param})).unwrap(); + assert_eq!(real_merged(&out), Some(expect), "{ask:?} {param} {ext}"); + } + } + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); + for bad in [json!("no"), json!(0), json!(1), json!([true])] { + let out = join(&dir, "bad.psd"); + let r = s.execute("file.saveACopy", json!({"path": out, "maximizeCompatibility": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is as it was"); + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); @@ -1300,6 +2106,12 @@ fn camera_data_formats_like_photoshop_and_falls_back_to_xmp() { assert_eq!(info(Some(0.5), Some(5.6), None), json!({"exposure": "1/2 s", "fNumber": "f/5.6"})); assert_eq!(info(Some(0.4), None, None), json!({"exposure": "0.4 s"})); assert_eq!(info(Some(1.0 / 2000.0), None, None), json!({"exposure": "1/2000 s"})); + // Non-standard fractions round to the nearest 1/n instead of losing digits (#6958). + assert_eq!(info(Some(0.031714), None, None), json!({"exposure": "1/32 s"})); + assert_eq!(info(Some(0.0215), None, None), json!({"exposure": "1/47 s"})); + assert_eq!(info(Some(1.0 / 3.0), None, None), json!({"exposure": "1/3 s"})); + // Too far from any whole denominator: three significant digits, not two decimals. + assert_eq!(info(Some(0.2731), None, None), json!({"exposure": "0.273 s"})); assert_eq!(info(Some(0.0), Some(0.0), Some(0.0)), json!({})); // No EXIF block (a raw import, XMP-only files): Photoshop's exif:/tiff:/aux: properties. let xmp = r#" @@ -1905,6 +2717,314 @@ fn load_files_into_stack_and_image_processor_and_batch() { assert!(s.execute("file.automate.batch", json!({"steps": [["no.such.command", {}]], "input": input, "output": out})).is_err()); } +/// Named Batch and Play cycles hit production guards before the test-only authorization ceiling. +#[test] +fn named_batch_cycles_stop_before_the_test_ceiling() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn bounded(id: &str, _: &Value) -> Result<()> { + if id == "file.automate.batch" && CALLS.fetch_add(1, Ordering::SeqCst) >= 4 { + return Err(EngineError::Other("test recursion ceiling".into())); + } + Ok(()) + } + // Play checks authorization both before dispatch and at command execution. + for (route, calls) in [("self", 2), ("mutual", 3), ("play-child", 3), ("play-parent", 2)] { + for source in ["folder", "open"] { + let dir = tmp(&format!("named-batch-recursion-{route}-{source}")); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let output = join(&dir, "out"); + let params = json!({"action": "A", "source": source, "input": [input], "output": output, "format": "png"}); + let mut child = params.clone(); + child["action"] = json!(if route == "self" || route == "play-parent" { "A" } else { "B" }); + let first = if route == "play-child" { ("actions.play".into(), json!({"action": "B"})) } else { ("file.automate.batch".into(), child) }; + let mut s = Session::new(); + if source == "open" { + s.execute("file.openAs", json!({"path": input})).unwrap(); + } + s.actions.list = vec![ + crate::actions_cmds::Action::new("A", vec![first]), + crate::actions_cmds::Action::new("B", vec![("file.automate.batch".into(), params.clone())]), + ]; + s.authorize = Some(bounded); + CALLS.store(0, Ordering::SeqCst); + let result = + if route == "play-parent" { s.execute("actions.play", json!({"action": "A"})) } else { s.execute("file.automate.batch", params) }.unwrap(); + assert_eq!(CALLS.load(Ordering::SeqCst), calls, "{route}: the test ceiling must not be needed: {result}"); + let error = if route == "play-parent" { &result["failed"]["error"] } else { &result["errors"][0]["error"] }; + assert!(error.as_str().unwrap().contains("recursive action call"), "{route}: {result}"); + if route != "play-parent" { + assert_eq!(result["files"], json!([]), "{route}: {result}"); + } + assert!(!std::path::Path::new(&output).exists()); + assert_eq!(s.actions.playing, 0); + assert!(s.actions.playback_stack.is_empty()); + } + } +} + +/// Rejected and successful named batches preserve the caller's playback and recording state. +#[test] +fn named_batch_limits_nesting_and_keeps_the_callers_state() { + use crate::actions_cmds::Action; + for source in ["folder", "open"] { + let dir = tmp(&format!("named-batch-state-{source}")); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let output = join(&dir, "out"); + let params = json!({"action": "Invert", "source": source, "input": [input], "output": output, "format": "png"}); + let mut s = Session::new(); + if source == "open" { + s.execute("file.openAs", json!({"path": input})).unwrap(); + } + s.actions.list.push(Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))])); + s.actions.playing = 16; + s.actions.playback_stack = (0..16).map(|i| format!("Parent{i}")).collect(); + let before = s.actions.clone(); + let error = s.execute("file.automate.batch", params.clone()).unwrap_err(); + assert!(error.to_string().contains("nesting exceeds 16"), "{error}"); + assert_eq!(s.actions, before); + assert!(!std::path::Path::new(&output).exists()); + + s.actions.playing = 0; + s.actions.playback_stack.clear(); + s.execute("actions.record", json!({"name": "Recorder"})).unwrap(); + let recording = s.actions.recording; + let mut missing_output = params.clone(); + missing_output.as_object_mut().unwrap().remove("output"); + if source == "open" { + missing_output["source"] = json!("invalid"); + } + assert!(s.execute("file.automate.batch", missing_output).is_err()); + assert_eq!(s.actions.recording, recording); + assert!(s.actions.playback_stack.is_empty()); + let result = s.execute("file.automate.batch", params.clone()).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(s.actions.recording, recording); + assert_eq!(s.actions.playing, 0); + assert!(s.actions.playback_stack.is_empty()); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[1].steps, vec![("file.automate.batch".into(), params)]); + } +} + +/// Inputs and children share playback work, while a new top-level named batch starts fresh. +#[test] +fn named_batch_charges_the_existing_budget_across_inputs_and_children() { + use crate::actions_cmds::{Action, MAX_PLAYBACK_STEPS}; + let dir = tmp("named-batch-budget"); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let output = join(&dir, "out"); + let mut s = Session::new(); + s.actions.list.push(Action::new("Large", vec![("image.adjustments.invert".into(), json!({})); 100])); + let result = s.execute("file.automate.batch", json!({"action": "Large", "input": vec![input.clone(); 1001], "output": output})).unwrap_err(); + assert!(result.to_string().contains("step limit"), "{result}"); + assert!(!std::path::Path::new(&output).exists()); + assert_eq!(s.actions.playing, 0); + assert!(s.actions.playback_stack.is_empty()); + + // A finished playback leaves its count behind; a new named Batch starts fresh. + s.actions.list = vec![Action::new("Empty", Vec::new())]; + s.actions.playback_steps = MAX_PLAYBACK_STEPS; + let result = s.execute("file.automate.batch", json!({"action": "Empty", "input": [input], "output": join(&dir, "fresh")})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(result["files"].as_array().unwrap().len(), 1); + + s.actions.list = vec![ + Action::new("Outer", vec![("actions.play".into(), json!({"action": "Leaf"}))]), + Action::new("Leaf", vec![("select.deselect".into(), json!({})); 2]), + ]; + s.actions.playing = 1; + s.actions.playback_stack = vec!["Parent".into()]; + s.actions.playback_steps = MAX_PLAYBACK_STEPS - 4; + let result = s.execute("file.automate.batch", json!({"action": "Outer", "input": [input, input], "output": output, "naming": "{serial}"})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("step limit"), "{result}"); + assert_eq!(s.actions.playback_steps, MAX_PLAYBACK_STEPS); + assert_eq!(s.actions.playing, 1); + assert_eq!(s.actions.playback_stack, ["Parent"]); +} + +/// Nested named batches honor the caller's authorization and preserve its action state. +#[test] +fn named_batch_allows_nested_work_and_preserves_authorization() { + use crate::actions_cmds::Action; + fn deny_invert(id: &str, _: &Value) -> Result<()> { + if id == "image.adjustments.invert" { Err(EngineError::Other("invert denied".into())) } else { Ok(()) } + } + let dir = tmp("named-batch-nesting"); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + for denied in [false, true] { + let output = join(&dir, &format!("out-{denied}")); + let inner = join(&dir, &format!("inner-{denied}")); + let child = json!({"action": "Leaf", "input": [input], "output": inner, "format": "png"}); + let mut s = Session::new(); + s.actions.list = + vec![Action::new("Outer", vec![("file.automate.batch".into(), child)]), Action::new("Leaf", vec![("image.adjustments.invert".into(), json!({}))])]; + if denied { + s.authorize = Some(deny_invert); + } + let before = s.actions.clone(); + let result = s.execute("file.automate.batch", json!({"action": "Outer", "input": [input], "output": output, "format": "png"})).unwrap(); + if denied { + assert_eq!(result["files"], json!([]), "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("invert denied"), "{result}"); + assert!(!std::path::Path::new(&output).exists()); + assert!(!std::path::Path::new(&inner).exists()); + } else { + assert_eq!(result["errors"], json!([]), "{result}"); + let saved = photocraft_io::import("red.png", &std::fs::read(join(&inner, "red.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0]); + } + assert_eq!(s.actions, before); + } +} + +/// Action parents stop on child failure; direct scripts retain per-file results and continue. +#[test] +fn nested_batch_errors_stop_actions_but_direct_scripts_keep_results_and_continue() { + use crate::actions_cmds::Action; + let dir = tmp("nested-batch-failure"); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let output = join(&dir, "out"); + let batch = json!({"input": [input], "output": output, "steps": [["file.close", {}]]}); + let mut s = Session::new(); + let result = s.execute("file.automate.batch", batch.clone()).unwrap(); + assert_eq!(result["files"], json!([])); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + assert_eq!(result["errors"][0].as_object().unwrap().len(), 2, "ordinary errors still contain only file/error"); + let steps = vec![("file.automate.batch".into(), batch), ("file.new".into(), json!({"width": 2, "height": 2}))]; + s.actions.list.push(Action::new("Caller", steps.clone())); + let result = s.execute("actions.play", json!({"action": "Caller"})).unwrap(); + assert_eq!(result["ran"], 0, "{result}"); + assert!(result["failed"]["error"].as_str().unwrap().contains("no active document"), "{result}"); + let result = s.execute("file.scripts.browse", json!({"steps": steps})).unwrap(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["results"].as_array().unwrap().len(), 2); + assert_eq!(result["results"][0]["result"]["errors"].as_array().unwrap().len(), 1); + assert_eq!(s.documents().len(), 1, "the direct script reaches file.new"); + assert_eq!(s.active().unwrap().doc.size, photocraft_doc::Size::new(2, 2)); + assert!(!std::path::Path::new(&output).exists()); + assert_eq!(crate::actions_cmds::nested_failure("file.automate.batch", &json!({"files": ["ok.png"], "errors": []})), None); + assert_eq!(crate::actions_cmds::nested_failure("file.automate.batch", &json!({"errors": "invalid"})), None); +} + +/// Partial child outputs survive file, action and direct-script failure handling. +#[test] +fn nested_batch_partial_failures_keep_outputs_and_stop_only_action_parents() { + use crate::actions_cmds::Action; + let dir = tmp("nested-batch-partial-failure"); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let corrupt = join(&dir, "corrupt.png"); + std::fs::write(&corrupt, b"not a PNG").unwrap(); + for caller in ["batch", "action", "script"] { + let mut s = Session::new(); + let output = join(&dir, caller); + let batch = json!({"input": [input, corrupt], "output": output, "steps": [], "format": "png"}); + let steps = vec![("file.automate.batch".into(), batch.clone()), ("file.new".into(), json!({"width": 2, "height": 2}))]; + let result = match caller { + "batch" => s.execute("file.automate.batch", batch).unwrap(), + "action" => { + s.actions.list.push(Action::new("Mixed", steps)); + let result = s.execute("actions.play", json!({"action": "Mixed"})).unwrap(); + assert_eq!(result["ran"], 0, "{result}"); + let error = result["failed"]["error"].as_str().unwrap(); + assert!(error.contains("1 file(s) failed") && error.contains("1 saved") && error.contains(&corrupt), "{error}"); + result["failed"]["result"].clone() + } + _ => { + let result = s.execute("file.scripts.browse", json!({"steps": steps})).unwrap(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["results"].as_array().unwrap().len(), 2); + assert!(result["results"][0].get("error").is_none(), "{result}"); + result["results"][0]["result"].clone() + } + }; + let saved = join(&output, "red.png"); + assert_eq!(result["files"], json!([saved]), "{caller}: {result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + assert_eq!(result["errors"][0]["file"], corrupt); + assert!(std::path::Path::new(&saved).exists(), "{caller}: partial success remains saved"); + if caller == "script" { + assert_eq!(s.documents().len(), 1, "the direct script reaches file.new"); + assert_eq!(s.active().unwrap().doc.size, photocraft_doc::Size::new(2, 2)); + } else { + assert!(s.documents().is_empty(), "{caller}: failed action parents stop"); + } + } +} + +/// A successful nested Batch lets the parent action reach its following step. +#[test] +fn successful_nested_batch_allows_later_action_steps() { + use crate::actions_cmds::Action; + let dir = tmp("nested-batch-success"); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let mut s = Session::new(); + let batch = json!({"input": [input], "output": join(&dir, "success"), "steps": [], "format": "png"}); + s.actions.list.push(Action::new("Success", vec![("file.automate.batch".into(), batch), ("file.new".into(), json!({"width": 2, "height": 2}))])); + let result = s.execute("actions.play", json!({"action": "Success"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(result["ran"], 2); + assert_eq!(s.documents().len(), 1, "a successful Batch reaches file.new"); +} + +/// Folder and live-document parents retain child outputs and failures, stopping later writes. +#[test] +fn nested_batches_retain_partial_results() { + let dir = tmp("nested-batch-results"); + let input = png(&dir, "red.png", 8, 8, "#ff0000"); + let corrupt = join(&dir, "corrupt.png"); + std::fs::write(&corrupt, b"not a PNG").unwrap(); + let mut s = Session::new(); + for (source, naming) in [("folder", None), ("folder", Some("{serial}")), ("open", None)] { + let output = join( + &dir, + if source == "open" { + "open" + } else if naming.is_some() { + "named" + } else { + "default" + }, + ); + let outer = join(&output, "outer"); + let later = join(&output, "later.png"); + let mut params = json!({ + "input": [input], "output": outer, "format": "png", + "steps": [ + ["file.automate.batch", {"input": [input, corrupt], "output": output, "steps": [], "format": "png"}], + ["file.export.saveForWebLegacy", {"path": later, "format": "png"}] + ] + }); + if let Some(naming) = naming { + params["naming"] = json!(naming); + } + if source == "open" { + s.execute("file.openAs", json!({"path": input})).unwrap(); + params["source"] = json!("open"); + } + let result = s.execute("file.automate.batch", params).unwrap(); + assert_eq!(result["files"], json!([]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + if source == "open" { + assert!(result["errors"][0]["document"].is_string(), "{result}"); + } else { + assert_eq!(result["errors"][0]["file"], input); + } + let error = result["errors"][0]["error"].as_str().unwrap(); + assert!(error.contains("1 file(s) failed") && error.contains("1 saved"), "{error}"); + let child = &result["errors"][0]["result"]; + let saved = join(&output, "red.png"); + assert_eq!(child["files"], json!([saved]), "{result}"); + assert_eq!(child["errors"].as_array().unwrap().len(), 1); + assert_eq!(child["errors"][0]["file"], corrupt); + assert!(std::path::Path::new(&saved).exists(), "the successful child output remains saved"); + assert!(!std::path::Path::new(&outer).exists(), "the failed outer input is not written"); + assert!(!std::path::Path::new(&later).exists(), "later steps do not execute"); + assert_eq!(s.documents().len(), usize::from(source == "open")); + } +} + #[test] fn color_lookup_table_bakes_the_adjustment_stack() { let mut s = session(8, 8, 8); @@ -2238,10 +3358,26 @@ fn reveal_in_finder_on_windows_passes_the_clicked_tabs_saved_path() { s.execute("file.new", json!({"width": 8, "height": 8, "name": "other"})).unwrap(); let r = s.execute("file.revealInFinder", json!({"document": 0, "dryRun": true})).unwrap(); - assert_eq!(r["program"], "explorer"); + // The Windows Explorer by full path: a bare name would first find an explorer.exe in the app's folder. + let explorer = crate::layer_menu_cmds::windows_root(std::env::var_os("SystemRoot")).join("explorer.exe"); + assert_eq!(r["program"], json!(explorer.to_string_lossy())); assert_eq!(r["args"], json!(["/select,", r"C:\项目 图片\海报 🖼.png"])); } +/// Windows tools run from the Windows folder `SystemRoot` names, and from `C:\Windows` when it is +/// unset or relative (which would resolve against the working directory). +#[cfg(windows)] +#[test] +fn the_windows_folder_falls_back_when_system_root_is_unset_or_relative() { + use crate::layer_menu_cmds::windows_root; + use std::path::PathBuf; + assert_eq!(windows_root(Some(r"D:\WinNT".into())), PathBuf::from(r"D:\WinNT")); + assert_eq!(windows_root(None), PathBuf::from(r"C:\Windows")); + assert_eq!(windows_root(Some(r"Windows".into())), PathBuf::from(r"C:\Windows")); + assert_eq!(windows_root(Some(r"\Windows".into())), PathBuf::from(r"C:\Windows")); + assert_eq!(windows_root(Some("".into())), PathBuf::from(r"C:\Windows")); +} + /// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in /// subfolders too, each result keeping its subfolder under the output folder (so same-named files /// don't collide), and skip the output folder when it sits inside the input folder. @@ -2314,7 +3450,7 @@ fn batch_subfolder_walk_is_depth_limited() { std::fs::write(join(&deep, &format!("l{level}.png")), b"x").unwrap(); } } - let found = list_images_tree(&dir, "", "test").unwrap(); + let found = list_files_tree(&dir, "", "test", false).unwrap(); let names: Vec<_> = found.iter().map(|i| file_name(&i.path)).collect(); assert_eq!(names, vec![format!("l{MAX_SUBFOLDER_DEPTH}.png")], "{found:?}"); assert_eq!(found[0].rel_dir.split('/').count(), MAX_SUBFOLDER_DEPTH); @@ -2340,7 +3476,7 @@ fn batch_subfolder_walk_survives_symlink_loops() { eprintln!("skipped: cannot create a directory symlink here"); return; } - let found = list_images_tree(&dir, "", "test").unwrap(); + let found = list_files_tree(&dir, "", "test", false).unwrap(); let names: Vec<_> = found.iter().map(|i| (i.rel_dir.as_str(), file_name(&i.path))).collect(); assert_eq!(names, vec![("", "a.png".to_string()), ("sub", "b.png".to_string())]); } @@ -2587,6 +3723,28 @@ fn folder_listing_takes_every_format_photocraft_opens() { assert!(!super::openable_name("a.avif")); } +/// Save and Close never writes a source in a format other than its own: a Cineon source, which +/// Batch saves as PSD, is refused and left as it was, while a PNG next to it is saved in place. +#[test] +fn batch_save_and_close_refuses_a_source_it_would_save_in_another_format() { + let dir = tmp("batch-save-close-cin"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ff0000"})).unwrap(); + let cin = join(&dir, "a.cin"); + save_doc(doc(&s), &cin, None).unwrap(); + let png_path = png(&dir, "b.png", 8, 8, "#ff0000"); + let (cin_before, png_before) = (std::fs::read(&cin).unwrap(), std::fs::read(&png_path).unwrap()); + let r = Session::new() + .execute("file.automate.batch", json!({"input": dir, "destination": "saveAndClose", "steps": [["image.adjustments.invert", {}]]})) + .unwrap(); + let errors = r["errors"].as_array().unwrap(); + assert_eq!(errors.len(), 1, "{r}"); + assert!(errors[0]["file"].as_str().unwrap().ends_with("a.cin"), "{r}"); + assert!(errors[0]["error"].as_str().unwrap().contains("needs Save As"), "{r}"); + assert_eq!(std::fs::read(&cin).unwrap(), cin_before, "the Cineon source keeps its bytes"); + assert_ne!(std::fs::read(&png_path).unwrap(), png_before, "the PNG is saved in place"); +} + /// A batch over a folder processes PAM, PFM and DDS files instead of skipping them. #[test] fn batch_folder_processes_netpbm_and_dds_files() { @@ -2968,7 +4126,7 @@ fn approve_memory_releases_the_preflight_when_the_hard_limit_refuses_it() { photocraft_io::memory::with_mock_memory(snap_low, || { let err = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})).unwrap_err(); match &err { - EngineError::PsdHardBudget { name, required, limit } => { + EngineError::PsdHardBudget { name, required, limit, .. } => { assert_eq!(name, "ram_drop.psd"); assert_eq!((*required, *limit), (8 * gb, expected_limit)); } @@ -2997,7 +4155,7 @@ fn open_as_and_place_keep_the_typed_hard_budget_fields() { let mut s = Session::new(); let err = s.execute("file.openAs", json!({"path": path.clone(), "approveHighMemory": true})).unwrap_err(); match &err { - EngineError::PsdHardBudget { name, required, limit } => { + EngineError::PsdHardBudget { name, required, limit, .. } => { assert_eq!(name, &path); assert!(*required > hard && *limit == hard); } @@ -3012,3 +4170,193 @@ fn open_as_and_place_keep_the_typed_hard_budget_fields() { }); }); } + +thread_local! { static REVIEW_CANCEL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } + +fn cancel_at_marker(id: &str, params: &Value) -> Result<()> { + if id == "layer.renameLayer" && params["name"] == "Cancel here" { + REVIEW_CANCEL.with(|ctx| ctx.borrow().as_ref().unwrap().cancel()); + return Err(EngineError::Cancelled); + } + Ok(()) +} + +/// A Batch cancelled with no file errors stops the calling action at that step. +#[test] +fn playback_stops_after_a_cancelled_nested_batch() { + let dir = tmp("batch-cancel-parent"); + let input = png(&dir, "in.png", 4, 4, "#ff0000"); + let mut s = session(4, 4, 8); + let ctx = crate::jobs::JobCtx::new(); + s.set_inline_job_ctx(Some(ctx.clone())); + REVIEW_CANCEL.with(|slot| *slot.borrow_mut() = Some(ctx.clone())); + s.authorize = Some(cancel_at_marker); + let batch = json!({"input": [input], "output": join(&dir, "out"), "steps": [["layer.renameLayer", {"name": "Cancel here"}]]}); + s.actions.list.push(crate::actions_cmds::Action::new("Run", vec![("file.automate.batch".into(), batch), ("layer.new.layer".into(), json!({}))])); + let layers = doc(&s).layers.len(); + let played = s.execute("actions.play", json!({"action": "Run"})).unwrap(); + REVIEW_CANCEL.with(|slot| *slot.borrow_mut() = None); + assert!(ctx.cancelled()); + assert_eq!(played["failed"]["step"], 0, "{played}"); + assert_eq!(played["failed"]["result"]["cancelled"], true, "{played}"); + assert_eq!(played["failed"]["result"]["errors"], json!([]), "{played}"); + assert_eq!(doc(&s).layers.len(), layers, "the step after the cancelled Batch must not run"); +} + +/// Steps a Batch runs from inside a script count against the script's total, in both sources. +#[test] +fn batch_steps_inside_a_script_count_against_its_step_limit() { + let dir = tmp("batch-script-steps"); + let input = png(&dir, "in.png", 4, 4, "#ff0000"); + for source in ["open", "folder"] { + let mut s = session(4, 4, 8); + s.actions.script_depth = 1; + s.actions.script_steps = crate::automate_cmds::MAX_SCRIPT_STEPS - 1; + let mut p = json!({"source": source, "output": join(&dir, source), "format": "png", + "steps": [["image.adjustments.invert", {}], ["image.adjustments.invert", {}]]}); + if source == "folder" { + p["input"] = json!([input]); + } + let result = s.execute("file.automate.batch", p).unwrap(); + let error = result["errors"][0]["error"].as_str().unwrap_or_default(); + assert!(error.contains("steps in all"), "{source}: {result}"); + assert_eq!(s.actions.script_steps, crate::automate_cmds::MAX_SCRIPT_STEPS, "{source}"); + } +} + +/// A recorded Save is a step too: with the script's budget spent, a Save-only Batch writes +/// nothing, in both sources, and its result says the script ran out of steps. +#[test] +fn a_save_only_batch_writes_nothing_once_the_script_steps_are_spent() { + let dir = tmp("batch-script-save-only"); + let input = png(&dir, "in.png", 4, 4, "#ff0000"); + for source in ["open", "folder"] { + let mut s = session(4, 4, 8); + s.actions.script_depth = 1; + s.actions.script_steps = crate::automate_cmds::MAX_SCRIPT_STEPS; + let out = join(&dir, &format!("out-{source}")); + let mut p = json!({"source": source, "output": out, "format": "png", "steps": [["file.save", {}]]}); + if source == "folder" { + p["input"] = json!([input]); + } + let result = s.execute("file.automate.batch", p).unwrap(); + assert_eq!(result["files"], json!([]), "{source}: {result}"); + assert!(result["errors"][0]["error"].as_str().unwrap_or_default().contains("steps in all"), "{source}: {result}"); + assert!(result["scriptLimitError"].is_string(), "{source}: {result}"); + let written = std::fs::read_dir(&out).map(|d| d.count()).unwrap_or(0); + assert_eq!(written, 0, "{source}: nothing may be written past the budget"); + } +} + +/// A direct Batch in a script that spends the script's last step makes the script fail there +/// instead of reporting `ok: true` with the exhaustion hidden as a per-file error. +#[test] +fn a_direct_batch_that_spends_the_script_budget_fails_the_script() { + let dir = tmp("batch-script-budget-browse"); + let input = png(&dir, "in.png", 4, 4, "#ff0000"); + for source in ["open", "folder"] { + let mut s = session(4, 4, 8); + // A nested script: the outer one has already spent all but two steps. + s.actions.script_depth = 1; + s.actions.script_steps = crate::automate_cmds::MAX_SCRIPT_STEPS - 2; + let mut batch = json!({"source": source, "output": join(&dir, &format!("out-{source}")), "format": "png", + "steps": [["image.adjustments.invert", {}], ["image.adjustments.invert", {}]]}); + if source == "folder" { + batch["input"] = json!([input]); + } + let r = s.execute("file.scripts.browse", json!({"steps": [["file.automate.batch", batch]]})).unwrap(); + assert_eq!(r["ok"], false, "{source}: {r}"); + assert!(r["results"][0]["error"].as_str().unwrap_or_default().contains("steps in all"), "{source}: {r}"); + assert!(r["results"][0]["result"]["errors"].as_array().is_some_and(|e| !e.is_empty()), "{source}: {r}"); + } +} + +/// Per-file errors from a direct Batch stay script results, but a cancel or an error log that +/// could not be written stops the script, also when files failed as well. +#[test] +fn a_direct_batch_in_a_script_stops_it_when_cancelled_or_its_log_fails() { + let dir = tmp("batch-script-stop"); + let input = png(&dir, "in.png", 4, 4, "#ff0000"); + let missing = join(&dir, "missing.png"); + let next = json!(["layer.new.layer", {}]); + + // Ordinary per-file errors: the script goes on. + let mut s = session(4, 4, 8); + let layers = doc(&s).layers.len(); + let batch = json!({"input": [missing.clone()], "output": join(&dir, "out-plain"), "steps": []}); + let r = s.execute("file.scripts.browse", json!({"steps": [["file.automate.batch", batch], next.clone()]})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(doc(&s).layers.len(), layers + 1, "{r}"); + + // A failed error log next to a failed file: the script stops at the Batch. + let mut s = session(4, 4, 8); + let layers = doc(&s).layers.len(); + let batch = json!({"input": [missing], "output": join(&dir, "out-log"), "steps": [], "errorLog": dir.clone()}); + let r = s.execute("file.scripts.browse", json!({"steps": [["file.automate.batch", batch], next.clone()]})).unwrap(); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["results"][0]["error"].as_str().unwrap_or_default().contains("error log"), "{r}"); + assert_eq!(doc(&s).layers.len(), layers, "the step after the Batch must not run: {r}"); + + // A cancel: the script stops at the Batch. + let mut s = session(4, 4, 8); + let ctx = crate::jobs::JobCtx::new(); + s.set_inline_job_ctx(Some(ctx.clone())); + REVIEW_CANCEL.with(|slot| *slot.borrow_mut() = Some(ctx.clone())); + s.authorize = Some(cancel_at_marker); + let layers = doc(&s).layers.len(); + let batch = json!({"input": [input], "output": join(&dir, "out-cancel"), "steps": [["layer.renameLayer", {"name": "Cancel here"}]]}); + let r = s.execute("file.scripts.browse", json!({"steps": [["file.automate.batch", batch], next]})).unwrap(); + REVIEW_CANCEL.with(|slot| *slot.borrow_mut() = None); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["results"][0]["error"], "Batch was cancelled", "{r}"); + assert_eq!(doc(&s).layers.len(), layers, "the step after the Batch must not run: {r}"); +} + +/// Two spellings of one destination through a symlinked folder are one output claim. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn output_claims_see_one_destination_through_a_symlinked_folder() { + let dir = tmp("batch-claims-symlink"); + let real = join(&dir, "real"); + std::fs::create_dir_all(&real).unwrap(); + let link = join(&dir, "link"); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&real, &link); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_dir(&real, &link); + if let Err(error) = linked { + eprintln!("skipped: cannot create a directory symlink here: {error}"); + return; + } + let mut claims = OutputClaims::default(); + claims.record(&join(&real, "a.png"), "first.png"); + assert!(claims.check(&join(&link, "A.png")).is_err(), "the link reaches the earlier result"); + assert!(claims.check(&join(&real, "b.png")).is_ok()); +} + +/// Save and Close never replaces a source that opened read-only, such as a Krita document +/// named `.png`, whether Batch opens it or a recorded Open with Override Open does. +#[test] +fn save_and_close_refuses_a_read_only_source() { + let dir = tmp("batch-save-close-read-only"); + let maindoc = "\n\ + "; + let mut zip = photocraft_format::zip::ZipWriter::new(); + zip.add("mimetype", b"application/x-krita").unwrap(); + zip.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + let bytes = zip.finish().unwrap(); + let source = join(&dir, "source.png"); + std::fs::write(&source, &bytes).unwrap(); + assert!(photocraft_io::import("source.png", &bytes).unwrap().source_read_only, "the fixture opens read-only"); + for override_open in [false, true] { + let mut p = json!({"input": [source], "destination": "saveAndClose", "steps": []}); + if override_open { + p["overrideOpen"] = json!(true); + p["steps"] = json!([["file.openAs", {"path": join(&dir, "elsewhere.png")}]]); + } + let result = Session::new().execute("file.automate.batch", p).unwrap(); + let error = result["errors"][0]["error"].as_str().unwrap_or_default(); + assert!(error.contains("cannot replace this source"), "overrideOpen {override_open}: {result}"); + assert_eq!(std::fs::read(&source).unwrap(), bytes, "overrideOpen {override_open}: the source is unchanged"); + } +} diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 88bbafe878..e6f7e77b9d 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -279,7 +279,7 @@ fn content_aware_native_with_options( let window = sampling_window(hole_bounds, canvas); let n = surf.format().channels(); let (w, h) = window_size(window)?; - let img = surf.read_region(window); + let img = crate::allocation::read_region(surf, window, "Content-Aware fill samples")?; let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?; let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?; let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?; diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index bb93f233cb..0700fb30c8 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -243,6 +243,8 @@ pub struct Floating { /// The piece has been moved at least once, even if it is back where it was cut (nudged away /// and back): its selection is the moved pixels' then, as each of Photoshop's nudges leaves it. pub moved: bool, + /// History step name when dropped; `None` is "Move". The Move tool names a ⌥-drag copy "Duplicate". + pub step_name: Option, } /// The floating piece of `st`, if it is still valid (the document hasn't changed under it). @@ -289,12 +291,14 @@ pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool { fn float(s: &mut Session, p: &Value) -> Result { let (dx, dy) = (int_param(p, "dx"), int_param(p, "dy")); let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + // A name for the History step that drops the piece: short and not blank, else the default. + let step_name = p.get("stepName").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty() && s.chars().count() <= 64).map(str::to_string); let background = s.tools.background; let st = s.active_mut().ok_or(EngineError::NoDocument)?; if floating(st).is_none() { let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; let parts = CutParts::new(&st.doc, layer, copy, background, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Mask)?; - st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false }); + st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false, step_name }); } let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?; f.offset = (f.offset.0.saturating_add(dx), f.offset.1.saturating_add(dy)); @@ -311,20 +315,21 @@ pub fn drop_floating(s: &mut Session) -> Result { return Ok(json!({"dropped": false})); }; st.floating = None; + let name = f.step_name.clone().unwrap_or_else(|| "Move".to_string()); let (dx, dy) = f.offset; if (dx, dy) == (0, 0) { // Back where it was cut: the pixels are as they were. A piece that never moved (a click) // changes nothing; one nudged away and back keeps the moved pixels' selection (#7538). if let Some(outline) = f.parts.outline().filter(|_| f.moved) { let outline = outline.clone(); - s.edit("Move", |doc, _| { + s.edit(&name, |doc, _| { doc.selection = Some(outline); Ok(()) })?; } return Ok(json!({"dropped": true, "offset": [0, 0]})); } - s.edit("Move", |doc, _| { + s.edit(&name, |doc, _| { f.parts.place(doc, dx, dy)?; let sel = f.parts.outline().or(doc.selection.as_ref()); doc.selection = sel.map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy)); @@ -374,7 +379,7 @@ pub fn specs() -> Vec { label: "Float Selection", menu: &[], shortcut: None, - params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##, + params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"stepName":string="Move" (History name of the step that drops the piece; blank or over 64 characters falls back to Move),"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##, enabled: can_float, journal: true, run: float, diff --git a/crates/engine/src/cmd/float_cmds/tests.rs b/crates/engine/src/cmd/float_cmds/tests.rs index 44c887643f..1fec9630b7 100644 --- a/crates/engine/src/cmd/float_cmds/tests.rs +++ b/crates/engine/src/cmd/float_cmds/tests.rs @@ -501,6 +501,32 @@ fn a_soft_edge_is_as_selected_as_it_is_opaque_and_feathered_moves_leave_the_rest assert!((alpha(doc, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(doc, id, 45, 20) - 0.5).abs() < 0.01, "half left, half moved"); } +/// #10461: after a move the selection is the moved pixels' alpha, and Free Transform then takes +/// each soft edge pixel whole, as moving does. Multiplying the selection in lifted a² and left +/// a × (1 − a) of every edge behind, a grey outline where the shape had been. +#[test] +fn transforming_after_a_move_takes_soft_edges_whole() { + let (mut s, id) = session(); + s.edit("soft edge", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + for (x, a) in [(30, 0.75), (31, 0.5), (32, 0.25)] { + surf.fill_rect(Rect::new(x, 10, x + 1, 30), &[1.0, 0.0, 0.0, a]); + } + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 40, "height": 30})).unwrap(); + s.execute("select.float", json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert!((selected(&s.active().unwrap().doc, 41, 20) - 0.5).abs() < 0.01, "the selection is the moved pixels' alpha"); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 20], "interpolation": "nearest"})).unwrap(); + let doc = &s.active().unwrap().doc; + for (x, a) in [(39, 1.0), (40, 0.75), (41, 0.5), (42, 0.25)] { + assert_eq!(alpha(doc, id, x, 15), 0.0, "x {x}: nothing left where it was"); + assert!((alpha(doc, id, x, 45) - a).abs() < 0.01, "x {x}: alpha {} where {a} moved", alpha(doc, id, x, 45)); + } +} + /// #7538: arrow nudges add up to one floating piece, so up then down is back at no offset. The /// selection is the moved pixels' all the same, as after each of Photoshop's nudges, in one step; /// a piece that never moved (a click) changes nothing. diff --git a/crates/engine/src/cmd/frame_cmds.rs b/crates/engine/src/cmd/frame_cmds.rs index 9bfcb257de..8e2ea786b5 100644 --- a/crates/engine/src/cmd/frame_cmds.rs +++ b/crates/engine/src/cmd/frame_cmds.rs @@ -1,55 +1,172 @@ -//! Layer › New › Frame from Layers. A frame clips its contents to a rectangle, so a template can -//! hold an image (or placeholder) that is cropped to the frame. We build it from existing -//! primitives: group the selected layers, then clip the group with a rectangular layer mask the -//! size of their bounds — the same visual result Photoshop's Frame tool produces for this command. -//! `layer.new.frame` is the Frame tool's (K) command: a rectangular or elliptical frame drawn on -//! the canvas, empty or holding the layer it was drawn over. Headless and scriptable like -//! every other command. - -use photocraft_doc::{Layer, LayerContent, LayerId, LayerMask, PixelFormat}; -use photocraft_geom::Rect; -use serde_json::{Value, json}; - +//! Photoshop-style image frames: independently editable vector containers with smart content. use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Document, Frame, Layer, LayerContent, LayerId, Path, VectorMask}; +use photocraft_geom::Affine; +use serde_json::{Value, json}; +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: "layer.frame".into(), msg: msg.into() } +} +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or("no document open".into()) +} fn has_layer(s: &Session) -> std::result::Result<(), String> { - match s.active() { - Some(d) if !d.selected_layers.is_empty() || d.active_layer.is_some() => Ok(()), - Some(_) => Err("select a layer".into()), - None => Err("no document".into()), + s.active().and_then(|d| d.active_layer).map(|_| ()).ok_or("select a layer".into()) +} +/// The enclosing frame, including a frame's selected content. +pub fn frame_of(doc: &Document, id: LayerId) -> Option { + let path = doc.path_of(id)?; + (1..=path.len()).rev().filter_map(|n| doc.layer_at(path.get(..n)?)).find(|l| l.frame.is_some()).map(|l| l.id) +} +fn frame_id(s: &Session, p: &Value) -> Result { + let id = crate::vector_cmds::layer_id(s, p)?; + frame_of(&s.active().ok_or(EngineError::NoDocument)?.doc, id).ok_or_else(|| bad("select a frame")) +} +fn check_lock(doc: &Document, id: LayerId) -> Result<()> { + let locks = doc.effective_locks(id); + if locks.all || locks.position { Err(bad("the frame or its containing group is locked")) } else { Ok(()) } +} +fn geometry(p: &Value) -> Result { + let get = |key: &str| crate::commands::int_i32("layer.new.frame", p, key)?.ok_or_else(|| bad(format!("missing `{key}`"))); + let (x, y, w, h) = (get("x")?, get("y")?, get("width")?, get("height")?); + if !(1..=300_000).contains(&w) || !(1..=300_000).contains(&h) || x.checked_add(w).is_none() || y.checked_add(h).is_none() { + return Err(bad("frame sides must be 1..=300000 pixels, within the coordinate range")); + } + let shape = p.get("shape").map_or(Some("rectangle"), Value::as_str).ok_or_else(|| bad("shape must be a string"))?; + let path = match shape { + "rectangle" | "ellipse" | "triangle" | "polygon" => { + let sides = p.get("sides").map_or(Some(5), Value::as_u64).ok_or_else(|| bad("sides must be an integer"))?; + if !(3..=100).contains(&sides) { + return Err(bad("sides must be 3..=100")); + } + crate::vector_cmds::shape_path(&json!({"kind": match shape { "rectangle" => "rect", "ellipse" => "ellipse", _ => "polygon" }, + "rect": [0, 0, 1, 1], "sides": if shape == "triangle" { 3 } else { sides }}))? + } + "custom" => { + let path = crate::vector_cmds::parse_path(p.get("path").ok_or_else(|| bad("a custom frame needs path"))?).map_err(bad)?; + normalized(&path)?.0 + } + _ => return Err(bad("shape must be rectangle, ellipse, triangle, polygon or custom")), + }; + let stroke = p.get("stroke").map(|v| crate::vector_cmds::parse_stroke(v, None, Color::BLACK).map_err(bad)).transpose()?.flatten(); + let f = Frame { path, transform: Affine { m: [f64::from(w), 0.0, 0.0, f64::from(h), f64::from(x), f64::from(y)] }, stroke }; + if !f.valid() { + return Err(bad("invalid or excessively complex frame geometry")); + } + Ok(f) +} +fn normalized(path: &Path) -> Result<(Path, Affine)> { + let (x0, y0, x1, y1) = path.control_bounds().ok_or_else(|| bad("the outline is empty"))?; + let (w, h) = (x1 - x0, y1 - y0); + if ![x0, y0, x1, y1, w, h].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 { + return Err(bad("the outline must have finite nonzero dimensions")); + } + let transform = Affine { m: [w, 0.0, 0.0, h, x0, y0] }; + let inverse = transform.inverse().ok_or_else(|| bad("the outline is too small"))?; + Ok((path.transform(&inverse), transform)) +} +/// Keep the clipping path in the ordinary document model, so every compositor and exporter masks it. +pub fn refresh(layer: &mut Layer) { + if let Some(f) = &layer.frame { + layer.vector_mask = Some(VectorMask { path: f.outline(), enabled: true, linked: true, density: 1.0, feather: 0.0 }); } } - -/// A reveal mask that is on inside `rect` and off (hidden) everywhere else. -fn clip_mask(rect: Rect) -> LayerMask { - let mut mask = LayerMask::hide_all(); - let (w, h) = (rect.width() as usize, rect.height() as usize); - if w > 0 && h > 0 { - mask.surface.write_region(rect, &vec![1.0f32; w * h]); - mask.surface.prune(); - } - // Default to GRAY8 like the other mask constructors. - debug_assert_eq!(mask.surface.format(), PixelFormat::GRAY8); - mask +fn info(layer: &Layer) -> Value { + layer.frame.as_ref().map_or( + Value::Null, + |f| json!({"layer":layer.id.0,"name":layer.name,"transform":f.transform.m,"frame":[f.bounds().x0,f.bounds().y0,f.bounds().width(),f.bounds().height()],"stroke":f.stroke,"content":layer.children().and_then(|c| c.first()).map(|l|l.id.0)}), + ) +} +fn query(s: &mut Session, p: &Value) -> Result { + let id = frame_id(s, p)?; + Ok(info(s.active().and_then(|st| st.doc.layer(id)).ok_or(EngineError::NoLayer(id))?)) +} +fn new_frame(s: &mut Session, p: &Value) -> Result { + let f = geometry(p)?; + let content = match p.get("layer") { + None | Some(Value::Null) => None, + Some(_) => Some(crate::commands::layer_param(s, p)?), + }; + let name = p.get("name").map(|v| v.as_str().map(str::to_owned).ok_or_else(|| bad("name must be a string"))).transpose()?; + let gid = s.edit("New Frame", |doc, active| { + let child = match content { + None => None, + Some(id) => { + check_lock(doc, id)?; + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if !matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)) + || doc.is_background(id) + { + return Err(bad("a frame takes a pixel, type, shape or smart object layer other than the Background")); + } + if frame_of(doc, id).is_some() { + return Err(bad("that layer is already a frame's image")); + } + let mut child = if matches!(l.content, LayerContent::Smart(_)) { l.clone() } else { crate::smart_cmds::layer_to_smart(doc, l)? }; + child.id = id; + Some(child) + } + }; + let mut g = Layer::group(name.unwrap_or_else(|| doc.next_layer_name("Frame")), vec![]); + g.blend = BlendMode::Normal; + g.frame = Some(Box::new(f)); + refresh(&mut g); + // A frame is one row, as in Photoshop: closed, so the next new layer goes above it rather + // than into it. + if let LayerContent::Group(group) = &mut g.content { + group.expanded = false; + } + let gid = crate::layer_place::insert_new(doc, content.or(*active), g, crate::layer_place::Place::AutoEditable); + if let Some(child) = child { + doc.remove(child.id).ok_or(EngineError::NoLayer(child.id))?; + doc.layer_mut(gid).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(gid))?.push(child); + } + crate::layer_multi_cmds::check_group_depth(doc, "New Frame")?; + *active = Some(gid); + Ok(gid) + })?; + crate::layer_multi_cmds::reselect(s, vec![gid], Some(gid)); + let mut result = info(s.active().and_then(|st| st.doc.layer(gid)).ok_or(EngineError::NoLayer(gid))?); + result["shape"] = p.get("shape").cloned().unwrap_or(json!("rectangle")); + let (x, y, w, h) = (p["x"].clone(), p["y"].clone(), p["width"].clone(), p["height"].clone()); + result["frame"] = json!([x, y, w, h]); + Ok(result) } - -/// Groups the selected layers, then turns the group into a frame, as one history step: one undo -/// takes the whole command back (#496). fn frame_from_layers(s: &mut Session, p: &Value) -> Result { - let mut gp = serde_json::Map::new(); - if let Some(name) = p.get("name") { - gp.insert("name".into(), name.clone()); - } + let st = s.active().ok_or(EngineError::NoDocument)?; + let ids = st.selected_layers(); + let bounds = ids + .iter() + .filter_map(|id| st.doc.layer(*id)) + .map(|l| crate::transform_cmds::transform_frame(&st.doc, l)) + .reduce(|a, b| a.union(&b)) + .filter(|b| !b.is_empty()) + .unwrap_or(st.doc.bounds()); + let f = geometry(&json!({"x":bounds.x0,"y":bounds.y0,"width":bounds.width(),"height":bounds.height()}))?; let journal = s.journal.len(); - let grouped = s.execute("layer.new.groupFromLayers", Value::Object(gp))?; - match frame_group(s, &grouped) { - Ok(r) => { - // Fold the grouping step into the frame step. + let grouped = s.execute("layer.new.groupFromLayers", p.clone())?; + let id = LayerId(grouped.get("layer").and_then(Value::as_u64).ok_or_else(|| bad("grouping returned no layer"))?); + let r = s.edit("Frame from Layers", |doc, _| { + let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let children = group.children().ok_or_else(|| bad("not a group"))?.to_vec(); + let image = crate::smart_cmds::layers_to_smart(doc, &group.name, children)?; + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if let Some(children) = l.children_mut() { + *children = vec![image]; + } + l.frame = Some(Box::new(f)); + l.blend = BlendMode::Normal; + refresh(l); + Ok(()) + }); + match r { + Ok(()) => { if let Some(st) = s.active_mut() { st.history.purge_last(); } - Ok(r) + Ok(json!({"layer":id.0,"frame":[bounds.x0,bounds.y0,bounds.width(),bounds.height()]})) } Err(e) => { // The group couldn't be framed: take the grouping back, leaving nothing to redo and @@ -60,247 +177,201 @@ fn frame_from_layers(s: &mut Session, p: &Value) -> Result { } } } - -/// Clips the group `layer.new.groupFromLayers` returned (`grouped`) to its contents' bounds. -fn frame_group(s: &mut Session, grouped: &Value) -> Result { - let gid = LayerId(grouped.get("layer").and_then(Value::as_u64).ok_or_else(|| EngineError::Other("grouping did not return a layer".into()))?); - let d = s.active().ok_or(EngineError::NoDocument)?; - let rect = - crate::layer_multi_cmds::layer_bounds(d.doc.layer(gid).ok_or(EngineError::NoLayer(gid))?).filter(|r| !r.is_empty()).unwrap_or_else(|| d.doc.bounds()); - - s.edit("Frame from Layers", |doc, _| { - let mask = crate::commands::mask_at_depth(clip_mask(rect), doc.depth)?; - doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?.mask = Some(mask); +fn set(s: &mut Session, p: &Value) -> Result { + if !["transform", "stroke", "name"].iter().any(|key| p.get(key).is_some()) { + return Err(bad("supply transform, stroke or name")); + } + let id = frame_id(s, p)?; + s.edit("Frame Properties", |doc, _| { + check_lock(doc, id)?; + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let f = l.frame.as_mut().ok_or_else(|| bad("not a frame"))?; + if let Some(v) = p.get("transform") { + let m: [f64; 6] = serde_json::from_value(v.clone()).map_err(|_| bad("transform must be six finite numbers"))?; + f.transform = Affine { m }; + } + if let Some(v) = p.get("stroke") { + f.stroke = crate::vector_cmds::parse_stroke(v, f.stroke.clone(), Color::BLACK).map_err(bad)?; + } + if let Some(v) = p.get("name") { + l.name = v.as_str().ok_or_else(|| bad("name must be a string"))?.into(); + } + if !f.valid() { + return Err(bad("invalid frame geometry or stroke")); + } + refresh(l); Ok(()) })?; - Ok(json!({"layer": gid.0, "frame": [rect.x0, rect.y0, rect.width(), rect.height()]})) + Ok(info(s.active().and_then(|st| st.doc.layer(id)).ok_or(EngineError::NoLayer(id))?)) } - -/// The largest frame side (px) `layer.new.frame` accepts, as Photoshop's canvas limit. -const MAX_FRAME_SIDE: i32 = 300_000; - -/// A reveal mask that is on inside the ellipse inscribed in `rect` and off everywhere else, with -/// 4x4-supersampled edges (as the Elliptical Marquee). Only pixels inside `area` are written. -fn ellipse_mask(rect: Rect, area: Rect) -> LayerMask { - let mut mask = LayerMask::hide_all(); - let (cx, cy) = ((f64::from(rect.x0) + f64::from(rect.x1)) / 2.0, (f64::from(rect.y0) + f64::from(rect.y1)) / 2.0); - let (rx, ry) = (f64::from(rect.width().max(1)) / 2.0, f64::from(rect.height().max(1)) / 2.0); - let inside = |x: f64, y: f64| { - let (dx, dy) = ((x - cx) / rx, (y - cy) / ry); - dx * dx + dy * dy <= 1.0 - }; - let cut = rect.intersect(&area); - for y in cut.y0..cut.y1 { - for x in cut.x0..cut.x1 { - let (fx, fy) = (f64::from(x), f64::from(y)); - let n = (0..16).filter(|i| inside(fx + (f64::from(i % 4) + 0.5) / 4.0, fy + (f64::from(i / 4) + 0.5) / 4.0)).count(); - if n > 0 { - mask.surface.write_pixel(x, y, &[n as f32 / 16.0]); - } - } - } - mask.surface.prune(); - mask -} - -/// Frame tool (K): a new frame — a group clipped to the drawn rectangle or ellipse — as one -/// history step. With `layer` (a pixel, type, shape or smart object layer, not the Background), -/// that layer becomes the frame's content, as when a frame is drawn over a selected layer in -/// Photoshop; else it is empty. -fn new_frame(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "layer.new.frame"; - let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; - let get = |k: &str| crate::commands::int_i32(CMD, p, k)?.ok_or_else(|| bad(format!("missing `{k}`"))); - let (x, y, w, h) = (get("x")?, get("y")?, get("width")?, get("height")?); - if !(1..=MAX_FRAME_SIDE).contains(&w) || !(1..=MAX_FRAME_SIDE).contains(&h) { - return Err(bad(format!("`width` and `height` must be 1..={MAX_FRAME_SIDE}"))); - } - let (Some(x1), Some(y1)) = (x.checked_add(w), y.checked_add(h)) else { - return Err(bad("the frame is outside the 32-bit coordinate range".into())); - }; - let rect = Rect::new(x, y, x1, y1); - let ellipse = match p.get("shape").map(|v| v.as_str()) { - None | Some(Some("rectangle")) => false, - Some(Some("ellipse")) => true, - Some(_) => return Err(bad("`shape` must be \"rectangle\" or \"ellipse\"".into())), - }; - let content = crate::commands::explicit_layer(s, p)?; - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let gid = s.edit("New Frame", |doc, active| { - let mask = - crate::commands::mask_at_depth(if ellipse { ellipse_mask(rect, doc.bounds()) } else { clip_mask(rect.intersect(&doc.bounds())) }, doc.depth)?; - let name = name.unwrap_or_else(|| doc.next_layer_name("Frame")); - let gid = match content { - Some(id) => { - let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); - if !framable || doc.is_background(id) { - return Err(bad("only a pixel, type, shape or smart object layer (not the Background) can go in a frame".into())); - } - let gid = doc.insert_above(Some(id), Layer::group(name, vec![])); - let child = doc.remove(id).ok_or(EngineError::NoLayer(id))?; - doc.layer_mut(gid).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(gid))?.push(child); - crate::layer_multi_cmds::check_group_depth(doc, "New Frame")?; - gid - } - None => crate::layer_place::insert_new(doc, *active, Layer::group(name, vec![]), crate::layer_place::Place::AutoEditable), +fn convert(s: &mut Session, p: &Value) -> Result { + let id = crate::vector_cmds::layer_id(s, p)?; + s.edit("Convert to Frame", |doc, active| { + check_lock(doc, id)?; + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let outline = match &l.content { + LayerContent::Shape(sh) => sh.path.clone(), + LayerContent::Text(t) => crate::type_extra_cmds::text_path(doc, t), + _ => return Err(bad("select a shape or type layer")), }; - let frame = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?; - frame.mask = Some(mask); - // A frame is one row, as in Photoshop: closed, so the next new layer goes above it rather - // than into it. - if let LayerContent::Group(g) = &mut frame.content { - g.expanded = false; + let (path, transform) = normalized(&outline)?; + let mut f = Frame { path, transform, stroke: None }; + if p.get("width").is_some() || p.get("height").is_some() { + let [a, b, c, d, x, y] = f.transform.m; + let n = |k: &str, old: f64| -> Result { + match p.get(k) { + None => Ok(old), + Some(v) => v.as_f64().filter(|v| v.is_finite() && (1.0..=300_000.0).contains(v)).ok_or_else(|| bad(format!("{k} must be 1..=300000"))), + } + }; + f.transform.m = [n("width", a)?, b, c, n("height", d)?, x, y]; } - *active = Some(gid); - Ok(gid) + if !f.valid() { + return Err(bad("invalid converted frame")); + } + let name = p.get("name").map(|v| v.as_str().ok_or_else(|| bad("name must be a string"))).transpose()?.unwrap_or(&l.name).to_string(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.content = LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }); + l.name = name; + l.frame = Some(Box::new(f)); + l.mask = None; + l.effects = Default::default(); + // The group would otherwise still carry the type or shape layer's PSD content blocks. + l.fill_cache = None; + l.psd_blocks.retain(|(k, _)| !crate::vector_cmds::SHAPE_BLOCKS.contains(&k) && !matches!(k, b"TySh" | b"tySh" | b"lmfx")); + l.blend = BlendMode::Normal; + refresh(l); + *active = Some(id); + Ok(()) })?; - crate::layer_multi_cmds::reselect(s, vec![gid], Some(gid)); - Ok(json!({"layer": gid.0, "frame": [x, y, w, h], "shape": if ellipse { "ellipse" } else { "rectangle" }})) + Ok(info(s.active().and_then(|st| st.doc.layer(id)).ok_or(EngineError::NoLayer(id))?)) +} +/// Insert/replace the image, preserving the container's identity and placement. +pub fn put_content(doc: &mut Document, id: LayerId, layer: Layer) -> Result<()> { + check_lock(doc, id)?; + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if l.frame.is_none() { + return Err(bad("not a frame")); + } + let children = l.children_mut().ok_or_else(|| bad("frame is not a container"))?; + *children = vec![layer]; + Ok(()) +} +fn set_content(s: &mut Session, p: &Value) -> Result { + let id = frame_id(s, p)?; + let source = LayerId(p.get("content").and_then(Value::as_u64).ok_or_else(|| bad("content must name an image layer"))?); + let copy = p.get("copy").map_or(Some(false), Value::as_bool).ok_or_else(|| bad("copy must be a boolean"))?; + let child_id = s.edit("Replace Frame Content", |doc, active| { + check_lock(doc, source)?; + let src = doc.layer(source).ok_or(EngineError::NoLayer(source))?; + if !matches!(src.content, LayerContent::Raster(_) | LayerContent::Smart(_)) || frame_of(doc, source) == Some(source) { + return Err(bad("content must be a pixel or smart object layer")); + } + let mut child = if matches!(src.content, LayerContent::Smart(_)) { src.clone() } else { crate::smart_cmds::layer_to_smart(doc, src)? }; + if copy { + child = child.duplicate(); + } else { + child.id = source; + doc.remove(source).ok_or(EngineError::NoLayer(source))?; + } + let child_id = child.id; + put_content(doc, id, child)?; + *active = Some(id); + Ok(child_id) + })?; + Ok(json!({"layer":id.0,"content":child_id.0})) +} +fn fit(s: &mut Session, p: &Value) -> Result { + let id = frame_id(s, p)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let f = l.frame.as_ref().ok_or_else(|| bad("not a frame"))?.clone(); + let child = l.children().and_then(|c| c.first()).ok_or_else(|| bad("the frame has no image"))?; + check_lock(&st.doc, id)?; + check_lock(&st.doc, child.id)?; + // Transparent margins are part of a placed image. Fitting its painted-pixel bounds would + // progressively zoom a PNG on every click; use the immutable source canvas instead. + if let LayerContent::Smart(smart) = &child.content + && smart.warp.is_none() + && smart.perspective.is_none() + && let Some([x0, y0, x1, y1]) = crate::smart_cmds::source_box(&st.doc, smart) + { + let (w, h) = (x1 - x0, y1 - y0); + if w <= 0.0 || h <= 0.0 { + return Err(bad("the frame image has no canvas")); + } + match p.get("mode").map_or(Some("contentToFrame"), Value::as_str) { + Some("frameToContent") => { + let transform = smart.transform.mul(&Affine { m: [w, 0.0, 0.0, h, x0, y0] }); + return set(s, &json!({"layer":id.0,"transform":transform.m})); + } + Some("contentToFrame") => { + let [a, b, c, d, _, _] = f.transform.m; + let (fw, fh) = (a.hypot(b), c.hypot(d)); + let k = (fw / w).max(fh / h); + let transform = + f.transform.mul(&Affine { m: [k / fw, 0.0, 0.0, k / fh, (1.0 - w * k / fw) / 2.0 - x0 * k / fw, (1.0 - h * k / fh) / 2.0 - y0 * k / fh] }); + let child_id = child.id; + let relative = transform.mul(&smart.transform.inverse().ok_or_else(|| bad("the frame image has a degenerate transform"))?); + // The same path as `edit.transform`, so the image's linked masks and Smart Filter + // mask move with it. + return s.edit("Fit Content to Frame", |doc, _| { + crate::transform_cmds::transform_by(doc, child_id, relative, photocraft_algo::transform::Interp::Bicubic)?; + Ok(json!({"layer":id.0,"content":child_id.0})) + }); + } + _ => return Err(bad("mode must be contentToFrame or frameToContent")), + } + } + let b = crate::transform_cmds::transform_frame(&st.doc, child); + if b.is_empty() { + return Err(bad("the frame content is empty")); + } + match p.get("mode").map_or(Some("contentToFrame"), Value::as_str) { + Some("frameToContent") => set(s, &json!({"layer":id.0,"transform":[b.width(),0,0,b.height(),b.x0,b.y0]})), + Some("contentToFrame") => { + let [a, bv, c, d, _, _] = f.transform.m; + let (w, h) = (a.hypot(bv), c.hypot(d)); + let k = (w / f64::from(b.width())).max(h / f64::from(b.height())); + let (nw, nh) = (f64::from(b.width()) * k / w, f64::from(b.height()) * k / h); + let quad = [ + (-0.5 * (nw - 1.0), -0.5 * (nh - 1.0)), + (0.5 * (nw + 1.0), -0.5 * (nh - 1.0)), + (0.5 * (nw + 1.0), 0.5 * (nh + 1.0)), + (-0.5 * (nw - 1.0), 0.5 * (nh + 1.0)), + ] + .map(|(x, y)| { + let q = f.transform.apply(photocraft_geom::Point::new(x, y)); + [q.x, q.y] + }); + s.execute("edit.transform", json!({"layer":child.id.0,"rect":[b.x0,b.y0,b.x1,b.y1],"quad":quad})) + } + _ => Err(bad("mode must be contentToFrame or frameToContent")), + } } - pub fn specs() -> Vec { + let spec = |id, label, params, enabled, run| CommandSpec { id, label, params, menu: &[], shortcut: None, journal: true, enabled, run }; vec![ - CommandSpec { - id: "layer.new.frame", - label: "New Frame", - menu: &[], - shortcut: None, - params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel, type, shape or smart object layer `layer` if given"#, - enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), - journal: true, - run: new_frame, - }, - CommandSpec { - id: "layer.new.frameFromLayers", - label: "Frame from Layers", - menu: &["Layer", "New"], - shortcut: None, - params: r#"{"name":str?} → {layer, frame:[x,y,w,h]}: groups the selected layers and clips them to a frame rect"#, - enabled: has_layer, - journal: true, - run: |s, p| frame_from_layers(s, p), - }, + spec( + "layer.new.frame", + "New Frame", + r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse|triangle|polygon|custom"="rectangle","sides":3..100=5,"path":object?,"stroke":object|null?,"layer":id?,"name":str?} → {layer,frame:[x,y,w,h],shape}"#, + has_doc, + new_frame, + ), + CommandSpec { menu: &["Layer", "New"], ..spec("layer.new.frameFromLayers", "Frame from Layers", r#"{name?:str}"#, has_layer, frame_from_layers) }, + spec("layer.frame.set", "Frame Properties", r#"{"layer":id?,"transform":[a,b,c,d,x,y]?,"stroke":object|null?,"name":str?}"#, has_doc, set), + CommandSpec { journal: false, ..spec("layer.frame.info", "Frame Info", r#"{"layer":id?}"#, has_doc, query) }, + spec("layer.frame.convert", "Convert to Frame", r#"{"layer":id?,"name":str?,"width":px?,"height":px?}"#, has_doc, convert), + spec( + "layer.frame.setContent", + "Replace Frame Content", + r#"{"layer":id?,"content":id,"copy":bool=false}: a layer dragged into a frame keeps its original size"#, + has_doc, + set_content, + ), + spec("layer.frame.fit", "Fit Frame / Content", r#"{"layer":id?,"mode":"contentToFrame|frameToContent"="contentToFrame"}"#, has_doc, fit), ] } - #[cfg(test)] -mod tests { - use super::*; - use photocraft_doc::{Layer, LayerContent}; - use photocraft_geom::Rect; - - fn session() -> (Session, LayerId, LayerId) { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 80, "height": 60})).unwrap(); - let (a, b) = s - .edit("setup", |doc, _| { - let fmt = doc.pixel_format(); - let mut a = Layer::raster("A", fmt); - a.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 24, 24), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); - let mut b = Layer::raster("B", fmt); - b.surface_mut().unwrap().fill_rect(Rect::new(30, 20, 50, 40), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); - let (ia, ib) = (a.id, b.id); - doc.layers.push(a); - doc.layers.push(b); - Ok((ia, ib)) - }) - .unwrap(); - (s, a, b) - } - - #[test] - fn frames_selected_layers_into_a_masked_group() { - let (mut s, a, b) = session(); - s.execute("layer.select", json!({"layer": a.0, "mode": "replace"})).unwrap(); - s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); - let r = s.execute("layer.new.frameFromLayers", json!({"name": "Frame 1"})).unwrap(); - let gid = LayerId(r["layer"].as_u64().unwrap()); - // frame rect = union of the two layers' bounds (4,4)..(50,40). - let frame = r["frame"].as_array().unwrap(); - assert_eq!(frame[2].as_i64().unwrap(), 50 - 4); // width - assert_eq!(frame[3].as_i64().unwrap(), 40 - 4); // height - let st = s.active().unwrap(); - let g = st.doc.layer(gid).unwrap(); - assert!(matches!(g.content, LayerContent::Group(_)), "it's a group"); - assert!(g.mask.is_some(), "the group has a clip mask"); - // Undoable (last step is the frame). - assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Frame from Layers")); - } - - #[test] - fn frame_from_layers_is_one_history_step() { - // #496: grouping and framing undo together. - let (mut s, a, b) = session(); - let entries = s.active().unwrap().history.entries(); - let layers = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); - let before = layers(&s); - s.execute("layer.select", json!({"layer": a.0, "mode": "replace"})).unwrap(); - s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); - let gid = LayerId(s.execute("layer.new.frameFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); - let mut framed = entries.clone(); - framed.push("Frame from Layers".into()); - assert_eq!(s.active().unwrap().history.entries(), framed); - assert!(s.undo()); - assert_eq!(s.active().unwrap().history.entries(), entries); - assert_eq!(layers(&s), before, "one undo leaves no group behind"); - assert!(s.redo()); - assert!(s.active().unwrap().doc.layer(gid).is_some_and(|g| g.mask.is_some()), "redo brings back the framed group"); - } - - /// Frame tool: a rectangular frame is an empty group clipped to the drawn rect, undone in one - /// step; an elliptical one hides the corners; drawn over a pixel layer, it holds that layer. - #[test] - fn new_frame_clips_a_group_to_the_drawn_shape() { - let (mut s, a, _) = session(); - let count = |s: &Session| s.active().unwrap().doc.layers.len(); - let before = count(&s); - let r = s.execute("layer.new.frame", json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); - let gid = LayerId(r["layer"].as_u64().unwrap()); - let st = s.active().unwrap(); - let g = st.doc.layer(gid).unwrap(); - assert!(matches!(&g.content, LayerContent::Group(c) if c.children.is_empty()), "an empty frame"); - let m = g.mask.as_ref().unwrap(); - assert_eq!((m.value(10, 10), m.value(49, 39), m.value(9, 10), m.value(50, 39)), (1.0, 1.0, 0.0, 0.0)); - assert_eq!((g.name.as_str(), st.active_layer), ("Frame 1", Some(gid))); - assert!(s.undo()); - assert_eq!(count(&s), before, "one undo removes the frame"); - - let r = s.execute("layer.new.frame", json!({"x": 10, "y": 10, "width": 40, "height": 30, "shape": "ellipse", "layer": a.0})).unwrap(); - let gid = LayerId(r["layer"].as_u64().unwrap()); - let g = s.active().unwrap().doc.layer(gid).unwrap(); - assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([a])), "the frame holds layer A"); - let m = g.mask.as_ref().unwrap(); - assert_eq!((m.value(30, 25), m.value(10, 10)), (1.0, 0.0), "centre shown, corner hidden"); - for bad in [json!({"x": 0, "y": 0, "width": 0, "height": 5}), json!({"x": 0, "y": 0, "width": 5, "height": 5, "shape": "star"}), json!({"x": 0})] { - assert!(s.execute("layer.new.frame", bad).is_err()); - } - } - - /// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer. - #[test] - fn new_frame_takes_a_type_layer() { - let (mut s, a, _) = session(); - let tid = s - .edit("setup", |doc, _| { - let cache = doc.layer(a).and_then(|l| l.surface()).cloned(); - let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })); - let id = t.id; - doc.layers.push(t); - Ok(id) - }) - .unwrap(); - let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap(); - let gid = LayerId(r["layer"].as_u64().unwrap()); - let g = s.active().unwrap().doc.layer(gid).unwrap(); - assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer"); - assert!(s.undo()); - assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back"); - } - - #[test] - fn needs_a_selected_layer() { - let mut s = Session::new(); - assert!(s.execute("layer.new.frameFromLayers", json!({})).is_err()); - } -} +mod tests; diff --git a/crates/engine/src/cmd/frame_cmds/tests.rs b/crates/engine/src/cmd/frame_cmds/tests.rs new file mode 100644 index 0000000000..d399af33cf --- /dev/null +++ b/crates/engine/src/cmd/frame_cmds/tests.rs @@ -0,0 +1,553 @@ +use super::*; +use photocraft_doc::{Layer, LayerContent}; +use photocraft_geom::Rect; + +fn session() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 60})).unwrap(); + let (a, b) = s + .edit("setup", |doc, _| { + let fmt = doc.pixel_format(); + let mut a = Layer::raster("A", fmt); + a.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 24, 24), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let mut b = Layer::raster("B", fmt); + b.surface_mut().unwrap().fill_rect(Rect::new(30, 20, 50, 40), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); + let (ia, ib) = (a.id, b.id); + doc.layers.push(a); + doc.layers.push(b); + Ok((ia, ib)) + }) + .unwrap(); + (s, a, b) +} + +#[test] +fn frames_selected_layers_into_a_masked_group() { + let (mut s, a, b) = session(); + s.execute("layer.select", json!({"layer": a.0, "mode": "replace"})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let r = s.execute("layer.new.frameFromLayers", json!({"name": "Frame 1"})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + // frame rect = union of the two layers' bounds (4,4)..(50,40). + let frame = r["frame"].as_array().unwrap(); + assert_eq!(frame[2].as_i64().unwrap(), 50 - 4); // width + assert_eq!(frame[3].as_i64().unwrap(), 40 - 4); // height + let st = s.active().unwrap(); + let g = st.doc.layer(gid).unwrap(); + assert!(matches!(g.content, LayerContent::Group(_)), "it's a group"); + assert!(g.frame.is_some() && g.vector_mask.is_some(), "the group has a clip mask"); + // Undoable (last step is the frame). + assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Frame from Layers")); +} + +#[test] +fn frame_from_layers_is_one_history_step() { + // #496: grouping and framing undo together. + let (mut s, a, b) = session(); + let entries = s.active().unwrap().history.entries(); + let layers = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); + let before = layers(&s); + s.execute("layer.select", json!({"layer": a.0, "mode": "replace"})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let gid = LayerId(s.execute("layer.new.frameFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let mut framed = entries.clone(); + framed.push("Frame from Layers".into()); + assert_eq!(s.active().unwrap().history.entries(), framed); + assert!(s.undo()); + assert_eq!(s.active().unwrap().history.entries(), entries); + assert_eq!(layers(&s), before, "one undo leaves no group behind"); + assert!(s.redo()); + assert!(s.active().unwrap().doc.layer(gid).is_some_and(|g| g.frame.is_some() && g.vector_mask.is_some()), "redo brings back the framed group"); +} + +/// Frame tool: a rectangular frame is an empty group clipped to the drawn rect, undone in one +/// step; an elliptical one hides the corners; drawn over a pixel layer, it holds that layer. +#[test] +fn new_frame_clips_a_group_to_the_drawn_shape() { + let (mut s, a, _) = session(); + let count = |s: &Session| s.active().unwrap().doc.layers.len(); + let before = count(&s); + let r = s.execute("layer.new.frame", json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let st = s.active().unwrap(); + let g = st.doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.is_empty()), "an empty frame"); + let m = TestMask(g.vector_mask.as_ref().unwrap().path.clone()); + assert_eq!((m.value(10, 10), m.value(49, 39), m.value(9, 10), m.value(50, 39)), (1.0, 1.0, 0.0, 0.0)); + assert_eq!((g.name.as_str(), st.active_layer), ("Frame 1", Some(gid))); + assert!(s.undo()); + assert_eq!(count(&s), before, "one undo removes the frame"); + + let r = s.execute("layer.new.frame", json!({"x": 10, "y": 10, "width": 40, "height": 30, "shape": "ellipse", "layer": a.0})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let g = s.active().unwrap().doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([a])), "the frame holds layer A"); + let m = TestMask(g.vector_mask.as_ref().unwrap().path.clone()); + assert_eq!((m.value(30, 25), m.value(10, 10)), (1.0, 0.0), "centre shown, corner hidden"); + for bad in [json!({"x": 0, "y": 0, "width": 0, "height": 5}), json!({"x": 0, "y": 0, "width": 5, "height": 5, "shape": "star"}), json!({"x": 0})] { + assert!(s.execute("layer.new.frame", bad).is_err()); + } +} + +/// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer. +#[test] +fn new_frame_takes_a_type_layer() { + let (mut s, a, _) = session(); + let tid = s + .edit("setup", |doc, _| { + let cache = doc.layer(a).and_then(|l| l.surface()).cloned(); + let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })); + let id = t.id; + doc.layers.push(t); + Ok(id) + }) + .unwrap(); + let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let g = s.active().unwrap().doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back"); +} + +#[test] +fn needs_a_selected_layer() { + let mut s = Session::new(); + assert!(s.execute("layer.new.frameFromLayers", json!({})).is_err()); +} + +struct TestMask(photocraft_doc::Path); +impl TestMask { + fn value(&self, x: i32, y: i32) -> f32 { + photocraft_vector::path_coverage(&self.0, Rect::new(x, y, x + 1, y + 1))[0] + } +} + +#[test] +fn frame_and_smart_image_transform_independently_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap(); + let source = s + .edit("Image", |doc, _| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 80, 60), &photocraft_raster::from_rgba(&doc.pixel_format(), [1., 0., 0., 1.])); + let id = l.id; + doc.layers.push(l); + Ok(id) + }) + .unwrap(); + let id = LayerId(s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30,"layer":source.0})).unwrap()["layer"].as_u64().unwrap()); + let original = s.active().unwrap().doc.layer(source).unwrap().clone(); + assert!(matches!(original.content, LayerContent::Smart(_))); + s.execute("layer.frame.set", json!({"layer":id.0,"transform":[20,0,0,25,30,12]})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(source).unwrap(), &original, "frame-only editing preserves the complete image"); + let frame = s.active().unwrap().doc.layer(id).unwrap().frame.clone(); + s.execute("layer.translate", json!({"layer":source.0,"dx":4,"dy":6})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().frame, frame, "content-only movement leaves the frame"); + s.execute("layer.translate", json!({"layer":id.0,"dx":5,"dy":7})).unwrap(); + let f = s.active().unwrap().doc.layer(id).unwrap().frame.as_ref().unwrap(); + assert_eq!(f.transform.m, [20., 0., 0., 25., 35., 19.]); + let before = s.active().unwrap().doc.clone(); + s.execute("edit.transform", json!({"layer":id.0,"rect":[35,19,55,44],"quad":[[35,19],[75,19],[75,69],[35,69]]})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().frame.as_ref().unwrap().transform.m, [40., 0., 0., 50., 35., 19.]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().frame, before.layer(id).unwrap().frame); + } +} + +#[test] +fn frame_placement_covers_and_replaces_with_one_undo() { + let (mut s, _, _) = session(); + let id = LayerId(s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap()["layer"].as_u64().unwrap()); + let mut image = Document::new("Image", photocraft_geom::Size::new(20, 10), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut l = Layer::raster("Photo", image.pixel_format()); + l.surface_mut().unwrap().fill_rect(image.bounds(), &[0., 0., 1., 1.]); + image.layers.push(l); + let png = photocraft_io::export(&image, "png", &Default::default()).unwrap().bytes; + let result = crate::file_cmds::place_bytes(&mut s, "blue.png", png.clone(), None, &json!({"smartObject":false})).unwrap(); + assert_eq!(result["frame"], id.0); + assert_eq!(result["scale"], 300.0); + let frame = s.active().unwrap().doc.layer(id).unwrap(); + let child = frame.children().unwrap().first().unwrap(); + let first = child.id; + let LayerContent::Smart(sm) = &child.content else { panic!("a placed frame image must remain smart") }; + assert_eq!(sm.transform.m, [3., 0., 0., 3., 0., 10.]); + crate::file_cmds::place_bytes(&mut s, "again.png", png, None, &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().children().unwrap().len(), 1); + assert_ne!(s.active().unwrap().doc.layer(id).unwrap().children().unwrap()[0].id, first); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().children().unwrap()[0].id, first); +} + +#[test] +fn frame_shapes_strokes_and_editability_survive_native_and_psd() { + for depth in [8, 16, 32] { + for shape in ["rectangle", "ellipse", "triangle", "polygon", "custom"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap(); + let p = json!({"x":12,"y":10,"width":32,"height":24,"shape":shape,"sides":6, + "path":{"subpaths":[{"closed":true,"knots":[[0,0],[100,0],[50,100]]}]},"stroke":{"color":"#ff0000","width":3,"align":"outside"}}); + let id = LayerId(s.execute("layer.new.frame", p).unwrap()["layer"].as_u64().unwrap()); + let original = &s.active().unwrap().doc; + let rendered = photocraft_compose::render(original, original.bounds()); + assert!(rendered.px.iter().any(|p| p[0] > 0.9 && p[3] > 0.5), "empty {shape} stroke at {depth}"); + for name in ["pcraft", "psd"] { + let bytes = photocraft_io::export(original, name, &Default::default()).unwrap().bytes; + let restored = photocraft_io::import(name, &bytes).unwrap().document; + let l = restored + .walk() + .into_iter() + .find(|(_, _, l)| l.frame.is_some()) + .unwrap_or_else(|| { + panic!( + "no frame in {name} {shape} {depth}; {:?}", + restored.walk().iter().map(|(_, _, l)| (&l.name, l.psd_blocks.iter().map(|(k, _)| k).collect::>())).collect::>() + ) + }) + .2; + assert_eq!(l.frame, original.layer(id).unwrap().frame, "{name} {shape} {depth}"); + assert!(l.children().unwrap().is_empty(), "export-only vector border is not frame content"); + let after = photocraft_compose::render(&restored, restored.bounds()); + let error = rendered.px.iter().zip(after.px.iter()).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + assert!(error < 0.025, "{name} {shape} {depth}: {error}"); + } + } + } +} + +#[test] +fn frame_commands_reject_invalid_geometry_and_locks_without_history_changes() { + let (mut s, _, _) = session(); + let id = s.execute("layer.new.frame", json!({"x":10,"y":10,"width":20,"height":20})).unwrap()["layer"].as_u64().unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + for p in [ + json!({"layer":id,"transform":[0,0,0,0,1,1]}), + json!({"layer":id,"transform":[1e200,0,0,1,0,0]}), + json!({"layer":id,"stroke":{"width":1e8}}), + json!({"layer":id,"transform":"bad"}), + ] { + assert!(s.execute("layer.frame.set", p).is_err()); + assert_eq!(s.active().unwrap().doc.layer(LayerId(id)).unwrap(), before.layer(LayerId(id)).unwrap()); + assert_eq!(s.active().unwrap().history.entries(), history); + } + s.execute("layer.setProps", json!({"layer":id,"locks":{"position":true}})).unwrap(); + assert!(s.execute("layer.frame.set", json!({"layer":id,"transform":[30,0,0,30,10,10]})).is_err()); + assert!(s.execute("layer.frame.fit", json!({"layer":id})).is_err()); + assert!(s.execute("layer.frame.setContent", json!({"layer":id,"content":id})).is_err()); +} + +#[test] +fn converting_a_shape_uses_its_outline_and_leaves_an_empty_frame() { + let (mut s, _, _) = session(); + let r = s.execute("shape.create", json!({"kind":"ellipse","rect":[10,15,40,30],"fill":"#123456"})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + s.execute("layer.frame.convert", json!({"layer":id,"name":"Portrait","width":20,"height":25})).unwrap(); + let l = s.active().unwrap().doc.layer(LayerId(id)).unwrap(); + assert_eq!(l.name, "Portrait"); + assert!(l.children().unwrap().is_empty()); + assert_eq!(l.frame.as_ref().unwrap().transform.m, [20., 0., 0., 25., 10., 15.]); + assert_eq!(TestMask(l.vector_mask.as_ref().unwrap().path.clone()).value(10, 15), 0.0); + assert!(s.undo()); + assert!(matches!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Shape(_))); +} + +#[test] +fn fitting_transparent_images_uses_their_canvas_and_is_idempotent() { + let (mut s, _, _) = session(); + let frame = s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap()["layer"].as_u64().unwrap(); + let mut image = Document::new("Transparent", photocraft_geom::Size::new(20, 10), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut l = Layer::raster("Mark", image.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0., 0., 1., 1.]); + image.layers.push(l); + let bytes = photocraft_io::export(&image, "png", &Default::default()).unwrap().bytes; + crate::file_cmds::place_bytes(&mut s, "transparent.png", bytes, None, &json!({})).unwrap(); + let child = s.active().unwrap().doc.layer(LayerId(frame)).unwrap().children().unwrap()[0].id; + let pose = |s: &Session| { + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(child).unwrap().content else { panic!("smart") }; + sm.transform + }; + let before = pose(&s); + assert_eq!(before.m, [3., 0., 0., 3., 0., 10.]); + for _ in 0..3 { + s.execute("layer.frame.fit", json!({"layer":frame})).unwrap(); + assert_eq!(pose(&s), before); + } + s.execute("layer.frame.fit", json!({"layer":frame,"mode":"frameToContent"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(LayerId(frame)).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(0, 10, 60, 40)); + assert_eq!(pose(&s), before); +} + +#[test] +fn explicit_frames_accept_layer_drops_when_the_dragged_image_is_active() { + let (mut s, a, _) = session(); + let frame = s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer":a.0})).unwrap(); + let before = crate::transform_cmds::transform_frame(&s.active().unwrap().doc, s.active().unwrap().doc.layer(a).unwrap()); + s.execute("layer.frame.setContent", json!({"layer":frame,"content":a.0})).unwrap(); + let child = s.active().unwrap().doc.layer(LayerId(frame)).unwrap().children().unwrap().first().unwrap(); + assert_eq!(child.id, a); + assert!(matches!(child.content, LayerContent::Smart(_))); + assert_eq!(crate::transform_cmds::transform_frame(&s.active().unwrap().doc, child), before, "a layer drop does not resize or recenter the image"); + s.execute("layer.select", json!({"layer":s.active().unwrap().doc.layers.first().unwrap().id.0})).unwrap(); + assert!(s.execute("layer.frame.set", json!({"layer":frame,"name":"Portrait"})).is_ok()); + let (history, journal) = (s.active().unwrap().history.entries(), s.journal.len()); + let info = s.execute("layer.frame.info", json!({"layer":frame})).unwrap(); + assert_eq!(info["name"], "Portrait"); + assert_eq!(info["frame"], json!([10, 10, 40, 30])); + assert_eq!(info["content"], a.0); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal.len(), journal); +} + +#[test] +fn copying_an_image_into_a_frame_preserves_the_source_and_undoes_together() { + let (mut s, a, _) = session(); + let frame = s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap()["layer"].as_u64().unwrap(); + let source = s.active().unwrap().doc.layer(a).unwrap().clone(); + let entries = s.active().unwrap().history.entries(); + let placed = s.execute("layer.frame.setContent", json!({"layer":frame,"content":a.0,"copy":true})).unwrap(); + assert_ne!(placed["content"], a.0); + assert_eq!(s.active().unwrap().doc.layer(a).unwrap(), &source); + let child = s.active().unwrap().doc.layer(LayerId(frame)).unwrap().children().unwrap().first().unwrap(); + assert_eq!( + crate::transform_cmds::transform_frame(&s.active().unwrap().doc, child), + crate::transform_cmds::transform_frame(&s.active().unwrap().doc, &source) + ); + s.undo(); + assert_eq!(s.active().unwrap().history.entries(), entries); + assert!(s.active().unwrap().doc.layer(LayerId(frame)).unwrap().children().unwrap().is_empty()); + assert_eq!(s.active().unwrap().doc.layer(a).unwrap(), &source); +} + +#[test] +fn empty_frame_placeholders_do_not_schedule_canvas_sized_composites() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000})).unwrap(); + let frame = LayerId(s.execute("layer.new.frame", json!({"x":100,"y":100,"width":5800,"height":3800})).unwrap()["layer"].as_u64().unwrap()); + let st = s.active().unwrap(); + let layer = st.doc.layer(frame).unwrap(); + assert_eq!(crate::transform_cmds::transform_frame(&st.doc, layer), Rect::new(100, 100, 5900, 3900), "the editable container still has its full geometry"); + assert_eq!(photocraft_compose::layer_bounds(layer, st.doc.bounds()), Rect::EMPTY); + assert_eq!(photocraft_compose::composite_bounds(layer, st.doc.bounds()), Some(Rect::EMPTY)); + s.execute("layer.frame.set", json!({"layer":frame.0,"stroke":{"color":"#ff0000","width":2}})).unwrap(); + let st = s.active().unwrap(); + assert!(!photocraft_compose::layer_bounds(st.doc.layer(frame).unwrap(), st.doc.bounds()).is_empty()); +} + +#[test] +fn new_layers_and_outside_placement_do_not_enter_a_frames_image_slot() { + let (mut s, image, _) = session(); + let frame = LayerId(s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30,"layer":image.0})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer":image.0})).unwrap(); + let new = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active().unwrap().doc.path_of(LayerId(new)).unwrap().len(), 1); + s.execute("layer.select", json!({"layer":image.0})).unwrap(); + let bytes = photocraft_io::export(&s.active().unwrap().doc, "png", &Default::default()).unwrap().bytes; + let placed = crate::file_cmds::place_bytes(&mut s, "outside.png", bytes, None, &json!({"frame":false})).unwrap(); + assert_eq!(placed["frame"], Value::Null); + let st = s.active().unwrap(); + assert_eq!(st.doc.path_of(LayerId(placed["layer"].as_u64().unwrap())).unwrap().len(), 1); + assert_eq!(st.doc.layer(frame).unwrap().children().unwrap().len(), 1); + assert_eq!(st.doc.layer(frame).unwrap().children().unwrap()[0].id, image); + let mut doc = (*st.doc).clone(); + let below = Layer::raster("Below", doc.pixel_format()); + let id = doc.insert_below(Some(image), below); + assert_eq!(doc.path_of(id).unwrap().len(), 1); + assert_eq!(doc.layer(frame).unwrap().children().unwrap().len(), 1); +} + +fn blue_png(w: u32, h: u32) -> Vec { + let mut image = Document::new("Image", photocraft_geom::Size::new(w, h), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut l = Layer::raster("Photo", image.pixel_format()); + l.surface_mut().unwrap().fill_rect(image.bounds(), &[0., 0., 1., 1.]); + image.layers.push(l); + photocraft_io::export(&image, "png", &Default::default()).unwrap().bytes +} + +#[test] +fn frame_placement_reports_document_bounds() { + let (mut s, _, _) = session(); + s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap(); + // 20×10 covers 40×30 at 3×: 60×30, centred on the frame, so 10 px overhangs each side. + let r = crate::file_cmds::place_bytes(&mut s, "blue.png", blue_png(20, 10), None, &json!({})).unwrap(); + assert_eq!(r["bounds"], json!([0.0, 10.0, 60.0, 40.0]), "{r}"); +} + +#[test] +fn placing_into_a_huge_frame_does_not_resample_to_the_frame_size() { + // Filling this frame scales the image to 300000×150000 px (45 Gpx). Placement used to + // bicubic-resize it to that size, and the smart object render warped it unclipped: either + // filled memory until the OS killed the process. The render now refuses it up front. + let (mut s, _, _) = session(); + let id = LayerId(s.execute("layer.new.frame", json!({"x":0,"y":0,"width":200_000,"height":150_000})).unwrap()["layer"].as_u64().unwrap()); + let before = s.active().unwrap().history.entries(); + let err = crate::file_cmds::place_bytes(&mut s, "blue.png", blue_png(20, 10), None, &json!({})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); + let st = s.active().unwrap(); + assert_eq!(st.history.entries(), before, "a failed place leaves no history"); + assert!(st.doc.layer(id).unwrap().children().unwrap().is_empty()); +} + +#[test] +fn fitting_content_to_a_huge_frame_is_refused_before_rendering() { + let (mut s, image, _) = session(); + let id = s.execute("layer.new.frame", json!({"x":0,"y":0,"width":40,"height":30,"layer":image.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.frame.set", json!({"layer":id,"transform":[200_000,0,0,150_000,0,0]})).unwrap(); + let before = s.active().unwrap().history.entries(); + let err = s.execute("layer.frame.fit", json!({"layer":id,"mode":"contentToFrame"})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); + assert_eq!(s.active().unwrap().history.entries(), before); +} + +#[test] +fn converting_drops_the_layers_own_psd_content_blocks() { + let (mut s, _, _) = session(); + let id = s.execute("shape.create", json!({"kind":"rectangle","rect":[10,15,40,30],"fill":"#123456"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("blocks", |doc, _| { + let l = doc.layer_mut(LayerId(id)).unwrap(); + for k in [b"TySh", b"tySh", b"SoCo", b"vscg", b"vstk", b"lmfx", b"luni"] { + l.psd_blocks.push((*k, std::sync::Arc::new(vec![0; 4]))); + } + Ok(()) + }) + .unwrap(); + s.execute("layer.frame.convert", json!({"layer":id})).unwrap(); + let l = s.active().unwrap().doc.layer(LayerId(id)).unwrap(); + let keys: Vec<_> = l.psd_blocks.iter().map(|(k, _)| k).collect(); + assert_eq!(keys, [b"luni"], "only blocks unrelated to the shape survive"); + assert!(l.fill_cache.is_none()); +} + +#[test] +fn a_frames_image_cannot_be_framed_again() { + let (mut s, image, _) = session(); + let first = LayerId(s.execute("layer.new.frame", json!({"x":0,"y":0,"width":40,"height":30,"layer":image.0})).unwrap()["layer"].as_u64().unwrap()); + let before = s.active().unwrap().history.entries(); + assert!(s.execute("layer.new.frame", json!({"x":20,"y":20,"width":40,"height":30,"layer":image.0})).is_err()); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(first).unwrap().children().unwrap()[0].id, image, "the image stays in its frame"); + assert_eq!(st.history.entries(), before); +} + +#[test] +fn moving_a_frame_past_its_limits_keeps_a_plain_masked_group() { + // An invalid frame would make the saved file fail to load; the clip survives as the mask. + let (mut s, _, _) = session(); + let id = LayerId(s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap()["layer"].as_u64().unwrap()); + let doc = (*s.active().unwrap().doc).clone(); + let mut l = doc.layer(id).unwrap().clone(); + crate::vector_cmds::translate_vectors(&doc, &mut l, 700_000.0, 0.0); + assert!(l.frame.is_none()); + assert!(l.vector_mask.is_some()); + let mut l = doc.layer(id).unwrap().clone(); + crate::canvas_geom::transform_layer_geometry(&mut l, &photocraft_geom::Affine::scale(10_000.0), true, 72.0); + assert!(l.frame.is_none()); + assert!(l.vector_mask.is_some()); +} + +#[test] +fn linked_layers_keep_their_links_beside_a_frame_in_psd() { + // The frame's extra PSD records ("Frame Image" group, divider, stroke) used to shift every + // later Link Layers entry onto the wrong record. + let (mut s, a, b) = session(); + s.execute("layer.new.frame", json!({"x":0,"y":0,"width":20,"height":20,"layer":a.0,"stroke":{"color":"#ff0000","width":2}})).unwrap(); + let doc = &mut (*s.active().unwrap().doc).clone(); + let top = Layer::raster("C", doc.pixel_format()); + let c = top.id; + doc.layers.push(top); + for id in [b, c] { + doc.layer_mut(id).unwrap().link_group = Some(5); + } + let bytes = photocraft_io::export(doc, "psd", &Default::default()).unwrap().bytes; + let restored = photocraft_io::import("psd", &bytes).unwrap().document; + let links: Vec<_> = restored.walk().iter().map(|(_, _, l)| (l.name.clone(), l.link_group)).filter(|(_, g)| g.is_some()).collect(); + let names: Vec<_> = links.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["B", "C"], "{links:?}"); + assert_eq!(links[0].1, links[1].1); +} + +#[test] +fn a_pcraft_with_unusable_frame_metadata_still_opens() { + let (mut s, _, _) = session(); + let id = LayerId(s.execute("layer.new.frame", json!({"x":10,"y":10,"width":40,"height":30})).unwrap()["layer"].as_u64().unwrap()); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.layer_mut(id).unwrap().frame.as_mut().unwrap().transform.m[4] = 900_000.0; + let bytes = photocraft_io::export(&doc, "pcraft", &Default::default()).unwrap().bytes; + let restored = photocraft_io::import("pcraft", &bytes).unwrap().document; + let l = restored.layer(id).unwrap(); + assert!(l.frame.is_none()); + assert!(l.vector_mask.is_some(), "the clip survives as the group's vector mask"); +} + +/// A frame at (40,28) 32×32 holding session layer `A` (a 20×20 patch at (4,4)), with `setup` run on +/// the image before it is fitted. Returns the session, the frame and the image ids. +fn fitted(setup: impl FnOnce(&mut Layer)) -> (Session, LayerId, LayerId) { + let (mut s, a, _) = session(); + let frame = LayerId(s.execute("layer.new.frame", json!({"x":40,"y":28,"width":32,"height":32,"layer":a.0})).unwrap()["layer"].as_u64().unwrap()); + let child = s.active().unwrap().doc.layer(frame).unwrap().children().unwrap()[0].id; + s.edit("setup", |doc, _| { + setup(doc.layer_mut(child).unwrap()); + Ok(()) + }) + .unwrap(); + s.execute("layer.frame.fit", json!({"layer":frame.0,"mode":"contentToFrame"})).unwrap(); + (s, frame, child) +} + +fn near(a: Rect, b: Rect) -> bool { + (a.x0 - b.x0).abs() <= 1 && (a.y0 - b.y0).abs() <= 1 && (a.x1 - b.x1).abs() <= 1 && (a.y1 - b.y1).abs() <= 1 +} + +fn image_bounds(s: &Session, child: LayerId) -> Rect { + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(child).unwrap().content else { panic!("smart") }; + sm.cache.as_ref().unwrap().content_bounds() +} + +fn patch_mask(bounds: Rect) -> photocraft_doc::LayerMask { + let mut m = photocraft_doc::LayerMask::hide_all(); + m.surface.fill_rect(bounds, &[1.0]); + m +} + +#[test] +fn fitting_content_moves_a_linked_raster_mask_with_the_image() { + let (mut s, frame, child) = fitted(|l| l.mask = Some(patch_mask(Rect::new(4, 4, 24, 24)))); + let image = image_bounds(&s, child); + assert!(image.x0 >= 30, "the image moved into the frame: {image:?}"); + let mask = s.active().unwrap().doc.layer(child).unwrap().mask.as_ref().unwrap().surface.content_bounds(); + assert!(near(mask, image), "the mask {mask:?} follows the image {image:?}"); + assert!(s.undo()); + let l = s.active().unwrap().doc.layer(child).unwrap(); + assert_eq!(l.mask.as_ref().unwrap().surface.content_bounds(), Rect::new(4, 4, 24, 24)); + assert!(s.active().unwrap().doc.layer(frame).is_some()); +} + +#[test] +fn fitting_content_moves_a_linked_vector_mask_with_the_image() { + let outline = photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(4., 4.), (24., 4.), (24., 24.), (4., 24.)])]); + let (mut s, _, child) = fitted(|l| l.vector_mask = Some(photocraft_doc::VectorMask::new(outline.clone()))); + let image = image_bounds(&s, child); + let (x0, y0, x1, y1) = s.active().unwrap().doc.layer(child).unwrap().vector_mask.as_ref().unwrap().path.control_bounds().unwrap(); + assert!(near(Rect::new(x0.round() as i32, y0.round() as i32, x1.round() as i32, y1.round() as i32), image), "{x0} {y0} {x1} {y1} vs {image:?}"); + assert!(s.undo()); + let vm = s.active().unwrap().doc.layer(child).unwrap().vector_mask.clone().unwrap(); + assert_eq!(vm.path, outline); +} + +#[test] +fn fitting_content_moves_the_smart_filter_mask_with_the_image() { + let (mut s, _, child) = fitted(|l| { + if let LayerContent::Smart(sm) = &mut l.content { + sm.filter_mask = Some(patch_mask(Rect::new(4, 4, 24, 24))); + } + }); + let image = image_bounds(&s, child); + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(child).unwrap().content else { panic!("smart") }; + let mask = sm.filter_mask.as_ref().unwrap().surface.content_bounds(); + assert!(near(mask, image), "the filter mask {mask:?} follows the image {image:?}"); + assert!(s.undo()); + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(child).unwrap().content else { panic!("smart") }; + assert_eq!(sm.filter_mask.as_ref().unwrap().surface.content_bounds(), Rect::new(4, 4, 24, 24)); +} diff --git a/crates/engine/src/cmd/history_cmds.rs b/crates/engine/src/cmd/history_cmds.rs index d7b3fc8207..cdd1018ff0 100644 --- a/crates/engine/src/cmd/history_cmds.rs +++ b/crates/engine/src/cmd/history_cmds.rs @@ -69,20 +69,20 @@ fn delete_state(s: &mut Session, p: &Value) -> Result { if i == 0 { return Err(bad(CMD, "the oldest state can't be deleted")); } + let rows = crate::actions_cmds::history_rows(s); if non_linear { crate::history_options_cmds::delete_state(s, i, current)?; - let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries(); - return Ok(json!({ "states": entries.len(), "current": entries.last() })); - } - // Step back to the state before `i`; the steps from `i` on are then redo states. - for _ in i..=current { - if !s.undo() { - return Err(EngineError::Other("the history can't step back right now".into())); + } else { + // Step back to the state before `i`; the steps from `i` on are then redo states. + for _ in i..=current { + if !s.undo() { + return Err(EngineError::Other("the history can't step back right now".into())); + } } + s.active_mut().ok_or(EngineError::NoDocument)?.history.clear_redo(); } - let st = s.active_mut().ok_or(EngineError::NoDocument)?; - st.history.clear_redo(); - let entries = st.history.entries(); + crate::actions_cmds::reconcile_history_removal(s, &rows); + let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries(); Ok(json!({ "states": entries.len(), "current": entries.last() })) } diff --git a/crates/engine/src/cmd/history_options_cmds.rs b/crates/engine/src/cmd/history_options_cmds.rs index 17341b5ae1..c2f814d008 100644 --- a/crates/engine/src/cmd/history_options_cmds.rs +++ b/crates/engine/src/cmd/history_options_cmds.rs @@ -1,35 +1,113 @@ -//! History panel › History Options… (#2966): Allow Non-Linear History and Make Layer Visibility -//! Changes Undoable. The values are preferences (`historyOptions.*`, also `prefs.set`), applied -//! by [`Session::apply_prefs`] and [`Session::edit_visibility`]. Without params the command -//! reports them; the GUI opens its dialog instead and runs it with the dialog's values on OK. -//! -//! Photoshop's three snapshot options (Automatically Create First Snapshot, Automatically -//! Create New Snapshot When Saving, Show New Snapshot Dialog by Default) come with #3634, which -//! makes the snapshots act on them. +//! History panel › History Options… (#2966), Photoshop's five checkboxes: Automatically Create +//! First Snapshot, Automatically Create New Snapshot When Saving, Allow Non-Linear History, Show +//! New Snapshot Dialog by Default and Make Layer Visibility Changes Undoable. The values are +//! preferences (`historyOptions.*`, also `prefs.set`), applied by [`Session::add_document`], +//! [`snapshot_on_save`], [`Session::apply_prefs`], [`Session::edit_visibility`] and the GUI's +//! camera button. Without params the command reports them; the GUI opens its dialog instead and +//! runs it with the dialog's values on OK. use serde_json::{Map, Value}; use crate::commands::CommandSpec; +use crate::history_cmds::Snapshot; +use crate::prefs::HistoryOptions; use crate::{EngineError, Result, Session}; const CMD: &str = "history.options"; -/// The option keys, as in `prefs.get historyOptions` and the command's params. -pub const OPTIONS: [&str; 2] = ["allowNonLinearHistory", "makeLayerVisibilityChangesUndoable"]; +/// The option keys, as in `prefs.get historyOptions` and the command's params, in the order of +/// Photoshop's dialog. +pub const OPTIONS: [&str; 5] = [ + "automaticallyCreateFirstSnapshot", + "automaticallyCreateNewSnapshotWhenSaving", + "allowNonLinearHistory", + "showNewSnapshotDialogByDefault", + "makeLayerVisibilityChangesUndoable", +]; + +/// The option named `key` (one of [`OPTIONS`]). +fn option_mut<'a>(o: &'a mut HistoryOptions, key: &str) -> Option<&'a mut bool> { + Some(match key { + "automaticallyCreateFirstSnapshot" => &mut o.automatically_create_first_snapshot, + "automaticallyCreateNewSnapshotWhenSaving" => &mut o.automatically_create_new_snapshot_when_saving, + "allowNonLinearHistory" => &mut o.allow_non_linear_history, + "showNewSnapshotDialogByDefault" => &mut o.show_new_snapshot_dialog_by_default, + "makeLayerVisibilityChangesUndoable" => &mut o.make_layer_visibility_changes_undoable, + _ => return None, + }) +} fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } } -/// The current History Options as `{allowNonLinearHistory, makeLayerVisibilityChangesUndoable}`. +/// The current History Options, keyed by [`OPTIONS`]. pub fn current(s: &Session) -> Value { let o = &s.prefs().history_options; serde_json::json!({ + "automaticallyCreateFirstSnapshot": o.automatically_create_first_snapshot, + "automaticallyCreateNewSnapshotWhenSaving": o.automatically_create_new_snapshot_when_saving, "allowNonLinearHistory": o.allow_non_linear_history, + "showNewSnapshotDialogByDefault": o.show_new_snapshot_dialog_by_default, "makeLayerVisibilityChangesUndoable": o.make_layer_visibility_changes_undoable, }) } +/// History Options › Show New Snapshot Dialog by Default: whether a plain click on the History +/// panel's camera button opens New Snapshot. +pub fn show_new_snapshot_dialog(s: &Session) -> bool { + s.prefs().history_options.show_new_snapshot_dialog_by_default +} + +/// History Options › Automatically Create New Snapshot When Saving: after the active document +/// was saved to its own file (not a copy or an export), add a snapshot of it named with the local +/// time of the save, as Photoshop does. Not a history state. Returns the new snapshot's id, or +/// `None` when the option is off, no document is active, or the document is unchanged since the +/// snapshot the previous save added (and that snapshot is still there), so saving a clean +/// document again doesn't add a duplicate row. Shells call it where they record a document save. +/// +/// Memory: like every History panel snapshot, each one pins a copy of the document for the +/// session (only the tiles that differ from the document and the other snapshots cost memory). +/// [`crate::DocState::trim_history`] counts those tiles against the History & Cache budget by +/// dropping the oldest history states, but never drops a snapshot itself: many saves of a +/// changing document can hold more than the budget until snapshots are deleted or the document +/// is closed. +pub fn snapshot_on_save(s: &mut Session) -> Option { + if !s.prefs().history_options.automatically_create_new_snapshot_when_saving { + return None; + } + let st = s.active_mut()?; + // Saved again without a change: the previous save's snapshot already shows this document. + if let Some(prev) = st.save_snapshot + && st.snapshots.iter().any(|snap| snap.id == prev && std::sync::Arc::ptr_eq(&snap.doc, &st.doc)) + { + return None; + } + let name = save_snapshot_name().unwrap_or_else(|| crate::history_cmds::next_snapshot_name(st)); + let id = st.next_snapshot; + st.next_snapshot = id.saturating_add(1); + let (doc, layers) = (st.doc.clone(), st.layer_target()); + // Like the camera button: the document and its saved state are unchanged. + st.snapshots.push(Snapshot { id, name, doc, layers }); + st.save_snapshot = Some(id); + // The snapshot's tiles count against the history budget. + st.trim_history(); + Some(id) +} + +/// The local time as `HH:MM:SS`, the name of a snapshot made on save; `None` without a clock +/// (the web build), where the snapshot gets the camera button's "Snapshot N" instead. +fn save_snapshot_name() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + Some(chrono::Local::now().format("%H:%M:%S").to_string()) + } + #[cfg(target_arch = "wasm32")] + { + None + } +} + fn options(s: &mut Session, p: &Value) -> Result { let given: Map = match p { Value::Null => Map::new(), @@ -39,7 +117,7 @@ fn options(s: &mut Session, p: &Value) -> Result { // Validate everything before changing anything. let mut set = Vec::new(); for (k, v) in &given { - if !OPTIONS.contains(&k.as_str()) { + if option_mut(&mut HistoryOptions::default(), k).is_none() { return Err(bad(format!("unknown option `{k}` (expected {})", OPTIONS.join(", ")))); } let b = v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false")))?; @@ -48,9 +126,8 @@ fn options(s: &mut Session, p: &Value) -> Result { if !set.is_empty() { s.edit_prefs(|prefs| { for (k, b) in &set { - match k.as_str() { - "allowNonLinearHistory" => prefs.history_options.allow_non_linear_history = *b, - _ => prefs.history_options.make_layer_visibility_changes_undoable = *b, + if let Some(o) = option_mut(&mut prefs.history_options, k) { + *o = *b; } } }); @@ -120,7 +197,7 @@ pub fn specs() -> Vec { label: "History Options…", menu: &[], shortcut: None, - params: r##"{"allowNonLinearHistory":bool?,"makeLayerVisibilityChangesUndoable":bool?} → both values (no params: report them). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##, + params: r##"{"automaticallyCreateFirstSnapshot":bool?,"automaticallyCreateNewSnapshotWhenSaving":bool?,"allowNonLinearHistory":bool?,"showNewSnapshotDialogByDefault":bool?,"makeLayerVisibilityChangesUndoable":bool?} → all five values (no params: report them). Automatically Create First Snapshot: a new or opened document starts with a snapshot of itself, named after it (default on). Automatically Create New Snapshot When Saving: each save of the document (not a copy) adds a snapshot named with the time of the save (default off). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Show New Snapshot Dialog by Default: a click on the History panel's camera button opens New Snapshot, not only ⌥-click (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##, enabled: always, run: options, journal: false, @@ -148,10 +225,21 @@ mod tests { #[test] fn defaults_match_photoshop_and_round_trip_through_prefs() { let mut s = Session::new(); - assert_eq!(s.execute(CMD, json!({})).unwrap(), json!({"allowNonLinearHistory": false, "makeLayerVisibilityChangesUndoable": true})); + let defaults = json!({ + "automaticallyCreateFirstSnapshot": true, + "automaticallyCreateNewSnapshotWhenSaving": false, + "allowNonLinearHistory": false, + "showNewSnapshotDialogByDefault": false, + "makeLayerVisibilityChangesUndoable": true, + }); + assert_eq!(s.execute(CMD, json!({})).unwrap(), defaults); assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions"})).unwrap(), current(&s)); - let r = s.execute(CMD, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})).unwrap(); - assert_eq!(r, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})); + let flipped: Map = defaults.as_object().unwrap().iter().map(|(k, v)| (k.clone(), json!(!v.as_bool().unwrap()))).collect(); + let r = s.execute(CMD, Value::Object(flipped.clone())).unwrap(); + assert_eq!(r, Value::Object(flipped)); + for key in OPTIONS { + assert_eq!(s.execute("prefs.get", json!({"path": format!("historyOptions.{key}")})).unwrap(), r[key], "{key}"); + } assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions.allowNonLinearHistory"})).unwrap(), json!(true)); // Saved and restored with the other preferences. let saved = s.prefs_to_json(); @@ -177,6 +265,11 @@ mod tests { json!({"allowNonLinearHistory": 1}), json!({"makeLayerVisibilityChangesUndoable": null}), json!({"allowNonLinearHistory": true, "snapshots": true}), + json!({"automaticallyCreateFirstSnapshot": "true"}), + json!({"automaticallyCreateNewSnapshotWhenSaving": 0}), + json!({"showNewSnapshotDialogByDefault": [true]}), + json!({"showNewSnapshotDialogByDefault": true, "showNewSnapshotDialog": true}), + json!({"AutomaticallyCreateFirstSnapshot": false}), json!([true]), json!("x"), json!(3), @@ -185,6 +278,8 @@ mod tests { assert_eq!(current(&s), before, "{p} changed nothing"); } assert!(s.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": "on"})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "historyOptions.automaticallyCreateFirstSnapshot", "value": 1})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "historyOptions.showNewSnapshotDialogByDefault", "value": null})).is_err()); assert_eq!(current(&s), before); } @@ -358,6 +453,139 @@ mod tests { assert!(s.active().unwrap().history.non_linear); } + fn snapshot_names(s: &Session) -> Vec { + s.active().unwrap().snapshots.iter().map(|snap| snap.name.clone()).collect() + } + + /// PNG bytes of a small image, to open. + fn png() -> Vec { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + crate::file_cmds::encode(&s.active().unwrap().doc, "a.png", None::).unwrap().0 + } + + /// Automatically Create First Snapshot (checked against Photoshop): on (the default), a new or + /// opened document starts with a snapshot named after it; off, it starts with none and the + /// camera's first snapshot is still "Snapshot 1". + #[test] + fn first_snapshot_follows_the_option_on_new_and_open() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let name = s.active().unwrap().doc.name.clone(); + assert_eq!(snapshot_names(&s), [name]); + crate::file_cmds::open_bytes_as(&mut s, "photo.png", &png(), None, None).unwrap(); + assert_eq!(snapshot_names(&s), ["photo.png"], "named after the file, as Photoshop does"); + + s.execute(CMD, json!({"automaticallyCreateFirstSnapshot": false})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(snapshot_names(&s).is_empty()); + assert_eq!(entries(&s).len(), 1, "the opening state is still there"); + crate::file_cmds::open_bytes_as(&mut s, "photo.png", &png(), None, None).unwrap(); + assert!(snapshot_names(&s).is_empty()); + assert_eq!(s.execute("history.newSnapshot", json!({})).unwrap(), json!({"snapshot": 1, "name": "Snapshot 1"})); + assert_eq!(crate::inspect::document(s.active().unwrap())["snapshots"], json!([{"id": 1, "name": "Snapshot 1"}])); + // Documents opened earlier keep theirs: the option applies when a document opens. + s.set_active(0); + assert_eq!(snapshot_names(&s).len(), 1); + } + + /// Automatically Create New Snapshot When Saving: off (the default), a save adds nothing; on, + /// each save adds a snapshot of the document as saved, named with the time, and not a + /// history state. + #[test] + fn a_save_adds_a_snapshot_only_with_the_option() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert_eq!(snapshot_on_save(&mut s), None); + assert_eq!(snapshot_names(&s).len(), 1, "only the first snapshot"); + + s.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.active_mut().unwrap().saved_to("saved.psd".into()); + let states = entries(&s); + let id = snapshot_on_save(&mut s).unwrap(); + let st = s.active().unwrap(); + let snap = st.snapshots.last().unwrap(); + assert_eq!(snap.id, id); + assert!(std::sync::Arc::ptr_eq(&snap.doc, &st.doc), "the document as saved"); + // HH:MM:SS, the local time of the save. + let parts: Vec<&str> = snap.name.split(':').collect(); + assert!(parts.len() == 3 && parts.iter().all(|p| p.len() == 2 && p.bytes().all(|b| b.is_ascii_digit())), "{}", snap.name); + assert_eq!(entries(&s), states, "not a history state"); + assert!(!s.active().unwrap().is_dirty(), "the saved document stays clean"); + // Each save adds one; selecting it brings that save back. + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let second = snapshot_on_save(&mut s).unwrap(); + assert_eq!(snapshot_names(&s).len(), 3); + s.execute("history.selectSnapshot", json!({"snapshot": id})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().rgba(1, 1), [1.0, 0.0, 0.0, 1.0]); + assert!(second > id); + // No document: nothing to do, no panic. + let mut empty = Session::new(); + empty.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + assert_eq!(snapshot_on_save(&mut empty), None); + } + + /// Saving an unchanged document again (⌘S on a clean document) adds no duplicate row; a + /// change, or deleting the previous save's snapshot, makes the next save add one again. + #[test] + fn saving_an_unchanged_document_adds_no_duplicate_snapshot() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute(CMD, json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + let first = snapshot_on_save(&mut s).unwrap(); + assert_eq!(snapshot_on_save(&mut s), None, "unchanged since the last save snapshot"); + assert_eq!(snapshot_names(&s).len(), 2, "the first snapshot and one save snapshot"); + // A view-only change (no document change) doesn't count either. + s.active_mut().unwrap().revision += 1; + assert_eq!(snapshot_on_save(&mut s), None); + // An edit does. + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let second = snapshot_on_save(&mut s).unwrap(); + assert!(second > first); + assert_eq!(snapshot_on_save(&mut s), None); + // The previous save's snapshot deleted: the next save shows the document again. + s.execute("history.deleteSnapshot", json!({"snapshot": second})).unwrap(); + assert!(snapshot_on_save(&mut s).is_some()); + assert_eq!(snapshot_names(&s).len(), 3); + } + + #[test] + fn show_new_snapshot_dialog_reads_the_option() { + let mut s = Session::new(); + assert!(!show_new_snapshot_dialog(&s)); + s.execute("prefs.set", json!({"path": "historyOptions.showNewSnapshotDialogByDefault", "value": true})).unwrap(); + assert!(show_new_snapshot_dialog(&s)); + assert_eq!(crate::inspect::session(&s)["historyOptions"]["showNewSnapshotDialogByDefault"], json!(true)); + } + + /// The first snapshot and a save snapshot are recorded steps like any other: with Make Layer + /// Visibility Changes Undoable off, selecting one that shows a layer the eye hid, then Undo, + /// hides the layer again, and Redo shows it (the step changed it, #3535's rule). + #[test] + fn selecting_a_snapshot_undoes_its_own_visibility_change() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + // Red, not the white document as opened (`red` only reads the red channel). + let red = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().surface().unwrap().pixel(1, 1)[1] < 0.5; + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false, "automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.active_mut().unwrap().saved_to("saved.psd".into()); + let saved = snapshot_on_save(&mut s).unwrap(); + // The eye hides the layer: no state. + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + for (snap, is_red) in [(0, false), (saved, true)] { + s.execute("history.selectSnapshot", json!({"snapshot": snap})).unwrap(); + assert!(visible(&s, id) && red(&s) == is_red, "snapshot {snap} shows the layer as it was"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!visible(&s, id) && red(&s), "undoing snapshot {snap} hides the layer again"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(visible(&s, id) && red(&s) == is_red, "redoing snapshot {snap} shows it"); + s.execute("edit.undo", json!({})).unwrap(); + } + } + /// With the oldest state current, non-linear Delete is still enabled for a later (dimmed) /// state (the History panel's trash and its drop target ask for it); linear history has /// nothing to delete there. diff --git a/crates/engine/src/cmd/history_source_cmds.rs b/crates/engine/src/cmd/history_source_cmds.rs index 908e6d11d3..c42b69b3d8 100644 --- a/crates/engine/src/cmd/history_source_cmds.rs +++ b/crates/engine/src/cmd/history_source_cmds.rs @@ -43,7 +43,7 @@ pub enum HistorySource { /// The document's History Brush source, `None` once the source state or snapshot is gone. pub fn source(st: &DocState) -> Option { - match st.source_snapshot { + match st.source_snapshot.or_else(|| st.history.brush_source_snapshot()) { Some(id) => st.snapshots.iter().any(|s| s.id == id).then_some(HistorySource::Snapshot(id)), None => st.history.brush_source().map(HistorySource::State), } @@ -366,6 +366,7 @@ mod tests { json!({"state": 1.5}), json!({"state": "1"}), json!({"state": [1]}), + json!({"snapshot": 99}), json!({"snapshot": 1}), json!({"snapshot": -1}), json!({"snapshot": "Open"}), @@ -375,7 +376,7 @@ mod tests { assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); } assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed"); - // Snapshot 0 is the document as opened, so it is a valid source. + // Snapshot 0 is the document as opened (Automatically Create First Snapshot), so it is a valid source. assert_eq!(s.execute(CMD, json!({"snapshot": 0})).unwrap()["snapshot"], 0); } } diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index cd5af85152..fa6a50e0a1 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -1,9 +1,10 @@ //! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and //! bit depth) conversions, Duplicate. +use std::collections::HashSet; use std::convert::Infallible; -use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface}; +use photocraft_algo::resample::{Resample, crop_surface, largest_resize_rect, resize_surface_in_canvas, scaled_rect, translate_surface}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size}; use photocraft_geom::Rect; @@ -11,6 +12,7 @@ use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::commands::CommandSpec; +use crate::document_preset_cmds::MAX_RESOLUTION; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -235,15 +237,27 @@ fn image_size(s: &mut Session, p: &Value) -> Result { pw = pw.or(Some(ow as f64 * pc / 100.0)); ph = ph.or(Some(oh as f64 * pc / 100.0)); } + // The document range File › New and the dialog's field allow; outside it, refuse rather than + // clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). The document's own + // resolution always passes: an imported file may have one outside the range, and the dialog + // sends it back unchanged (exactly: a value that only rounds to it as f32 is refused). + let own_dpi = f64::from(d.doc.resolution_dpi); + let dpi = match p.get("resolution") { + None | Some(Value::Null) => None, + Some(v) => { + let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || r == own_dpi)); + Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?) + } + }; // `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size - // with Resample on, as an action records it). + // with Resample on, as an action records it). An unchanged resolution keeps the pixels. if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true) && pw.is_none() && ph.is_none() - && let Some(r) = p.get("resolution").and_then(Value::as_f64) + && let Some(r) = dpi + && r != own_dpi { - let r = r.clamp(1.0, 10_000.0); - let old = f64::from(d.doc.resolution_dpi).max(1.0); + let old = own_dpi.max(1.0); pw = Some(ow as f64 * r / old); ph = Some(oh as f64 * r / old); } @@ -260,9 +274,10 @@ fn image_size(s: &mut Session, p: &Value) -> Result { if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } - let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { + // Before the sampler map or the document change: a resize that doesn't fit would trap on the web (#10024). + crate::memory_gate::ensure(&format!("resize the image to {nw} x {nh} px"), crate::memory_gate::image_size_need(&d.doc, nw, nh))?; // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); @@ -270,7 +285,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { s.edit("Image Size", |doc, _| { if let Some(r) = dpi { // Type keeps its pixel size; resampling below then scales it with the pixels. - crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0)); + crate::canvas_geom::set_resolution(doc, r as f32); } let filter = match resample { Method::None => return Ok(()), @@ -278,8 +293,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc_with_styles(doc, nw, nh, filter, scale_styles); - Ok(()) + resample_doc_with_styles(doc, "image.imageSize", nw, nh, filter, scale_styles) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; @@ -314,18 +328,20 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution -/// mode. Callers check [`check_resample_budget`] first. -pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { - resample_doc_with_styles(doc, nw, nh, filter, true); +/// mode. Callers check [`check_resample_budget`] for the new canvas first; the pixels outside the +/// canvas are scaled too, so a surface they would grow past the budget is refused here (#9212). +pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample) -> Result<()> { + resample_doc_with_styles(doc, cmd, nw, nh, filter, true) } /// Resize document content while optionally retaining layer-effect sizes in pixels. -fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) { +fn resample_doc_with_styles(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample, scale_styles: bool) -> Result<()> { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { - return; + return Ok(()); } + check_hidden_resample_budget(cmd, doc, sx, sy, filter)?; // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. let canvas = Rect::from_xywh(0, 0, ow, oh); let taken = crate::smart_cmds::take_caches(&mut doc.layers); @@ -356,6 +372,76 @@ fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resamp doc.size = Size::new(nw, nh); crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas))); + Ok(()) +} + +/// [`check_resample_budget`] for the largest surface [`resample_doc`] stores while scaling by +/// `sx` x `sy` with `filter` (masks, channels and the selection with Bilinear): each keeps its +/// pixels outside the canvas (a crop that didn't delete them, a layer moved off the canvas), and +/// those grow with the scale like the rest. A bicubic shrink also stores the image between its +/// two passes, one axis still at its old size ([`largest_resize_rect`]). The refusal suggests +/// Delete Cropped Pixels only when the surfaces over the budget are all ones it clips +/// ([`delete_cropped_pixels`]). +fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, filter: Resample) -> Result<()> { + let new_canvas = scaled_rect(doc.bounds(), sx, sy); + // The surfaces Delete Cropped Pixels clips; masks, channels, the selection, type, shapes and + // smart objects keep their pixels outside the crop. + let mut clipped: HashSet<*const Surface> = HashSet::new(); + for_each_layer(&mut doc.layers, &mut |l| { + clipped.extend(l.fill_cache.as_ref().map(|fc| std::ptr::from_ref(&fc.surface))); + if let Some(v) = &l.video { + clipped.extend(v.frames.iter().chain(v.original_frames.iter().flatten()).map(std::ptr::from_ref)); + } + if let LayerContent::Raster(s) = &l.content { + clipped.insert(std::ptr::from_ref(s)); + } + }); + let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); + // The largest surface by bytes, and the largest one Delete Cropped Pixels doesn't clip: its + // width, height and bytes per pixel (a mask has one channel, the layers the document's). + let (mut largest, mut largest_kept) = ((0u32, 0u32, 1u64), (0u32, 0u32, 1u64)); + let mut visit = |surf: &Surface, filter: Resample| { + let r = largest_resize_rect(surf.content_bounds(), sx, sy, filter); + // What stays within the new canvas is already budgeted. + if r.is_empty() || new_canvas.union(&r) == new_canvas { + return; + } + let this = (r.width(), r.height(), surf.format().bytes_per_pixel().max(1) as u64); + if bytes(this) > bytes(largest) { + largest = this; + } + if !clipped.contains(&std::ptr::from_ref(surf)) && bytes(this) > bytes(largest_kept) { + largest_kept = this; + } + }; + // The surfaces `resample_doc` scales, smart object caches included. + for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| visit(surf, if is_mask { Resample::Bilinear } else { filter })); + for ch in doc.channels.iter().chain(doc.quick_mask.as_ref()) { + visit(&ch.surface, Resample::Bilinear); + } + if let Some(sel) = &doc.selection { + visit(sel, Resample::Bilinear); + } + let (kept, (w, h, bpp)) = if bytes(largest_kept) > MAX_RESAMPLE_BYTES { + (true, largest_kept) + } else if bytes(largest) > MAX_RESAMPLE_BYTES { + (false, largest) + } else { + return Ok(()); + }; + let hint = if kept { + "choose a smaller size (layer masks, channels, the quick mask, the selection, type, shapes and smart objects keep theirs through Crop with Delete Cropped Pixels)" + } else { + "choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)" + }; + let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000; + Err(bad( + cmd, + format!( + "{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: {hint}", + u64::from(w).saturating_mul(u64::from(h)) / 1_000_000 + ), + )) } fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -439,6 +525,30 @@ fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) { }); } +/// The document's only artboard, when it covers exactly the canvas. The Crop tool frames that +/// board, so its straight crop fits the board to the new canvas, as the reference application's +/// Crop tool resizes the artboard to the frame. Several boards, or one that covers only part of +/// the canvas, keep their own layout. +fn canvas_artboard(doc: &Document) -> Option { + match doc.artboards().as_slice() { + [(id, _, board)] if board.rect == doc.bounds() => Some(*id), + _ => None, + } +} + +/// Fits the board [`canvas_artboard`] found before the crop to the new canvas. A new size drops +/// the size preset it was made from, which no longer describes it; a frame of the board's own size +/// keeps it, as Artboard Options does. +fn fit_canvas_artboard(doc: &mut Document, id: Option) { + let bounds = doc.bounds(); + if let Some(board) = id.and_then(|id| doc.layer_mut(id)).and_then(|l| l.artboard_mut()) { + if board.rect.width() != bounds.width() || board.rect.height() != bounds.height() { + board.preset.clear(); + } + board.rect = bounds; + } +} + /// Crops the document to `r` (in current document coordinates). /// A frame that extends past the canvas fills the Background's new area with `extension` (the /// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there. @@ -685,21 +795,29 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result } s.set_sampler_map(target.sampler_map(r)); let extension = s.tools.background; + // Only the Crop tool's frame (an explicit rectangle) resizes a canvas-sized artboard; a + // selection crop leaves boards as they are. + let tool_frame = explicit.is_some(); + let mut filled = false; s.edit("Crop", |doc, active| { let original = doc.size; + let board = if tool_frame { canvas_artboard(doc) } else { None }; if unlock { unlock_background_for_crop(doc); } crop_doc(doc, "image.crop", r, delete, extension)?; + fit_canvas_artboard(doc, board); // A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped // Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060). if content_aware { - crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?; + filled = crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height), delete)?; } doc.selection = None; - target.apply(doc); - Ok(()) + target.apply(doc) })?; + if filled && let Some(st) = s.active_mut() { + st.history.set_current_label("Content-aware Crop"); + } Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }))) } @@ -713,15 +831,16 @@ struct CropTarget { impl CropTarget { /// Applies the target to a document that has just been cropped (inside the crop's history step). - fn apply(&self, doc: &mut Document) { + fn apply(&self, doc: &mut Document) -> Result<()> { if let Some((w, h)) = self.size { // As for Image Size: an Indexed Color document holds only its colour table's colours (#6019). let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter }; - resample_doc(doc, w, h, filter); + resample_doc(doc, "image.crop", w, h, filter)?; } if let Some(r) = self.resolution { doc.resolution_dpi = r; } + Ok(()) } /// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels @@ -864,6 +983,9 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar } else { coverage }; + // The canvas board, found before the turn, fits the frame as in a straight crop. + let board = s.active().and_then(|d| canvas_artboard(&d.doc)); + let mut filled = false; crate::analysis_cmds::compound(s, "Crop", |s| { // Pixels the crop deletes needn't be turned first: keeping only what lands in the frame // makes the rotation cost the frame's size, not the canvas's (a small turned crop of a @@ -899,14 +1021,17 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar let extension = s.tools.background; s.edit("Crop", |doc, active| { crop_doc(doc, CMD, target, delete, extension)?; + fit_canvas_artboard(doc, board); if content_aware { - crate::crop_fill::fill_uncovered(doc, *active, &coverage)?; + filled = crate::crop_fill::fill_uncovered(doc, *active, &coverage, delete)?; } doc.selection = None; - crop_target.apply(doc); - Ok(()) + crop_target.apply(doc) }) })?; + if filled && let Some(st) = s.active_mut() { + st.history.set_current_label("Content-aware Crop"); + } Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) } @@ -942,8 +1067,14 @@ fn trim(s: &mut Session, p: &Value) -> Result { "bottomRight" => buf.px.last().copied(), _ => None, }; + // Transparency is judged on the image as stored at its depth: alpha under half a step rounds to 0. + let half_step = match d.doc.depth { + SampleType::U8 => Some(0.5 / 255.0), + SampleType::U16 => Some(0.5 / 65535.0), + SampleType::F32 => None, + }; let differs = |q: [f32; 4]| match reference { - None => q[3] > 0.0, + None => half_step.map_or(q[3] > 0.0, |h| q[3] >= h), Some(r) => (0..4).any(|c| (q[c] * q[3] - r[c] * r[3]).abs() > 0.5 / 255.0 || (c == 3 && (q[3] - r[3]).abs() > 0.5 / 255.0)), }; let mut b = Rect::EMPTY; @@ -1075,7 +1206,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##, has_doc, crop ), diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index fc4ce0bcf9..1e0fe5f223 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -234,6 +234,48 @@ fn resolution_only_change() { assert_eq!(r, json!({"width": 40, "height": 20})); } +/// #9260: a resolution outside the document range (1..=30000 ppi, as File › New and the +/// dialog's field) is refused and leaves the document as it was, instead of being clamped; +/// one inside it, also above 10000, is set as given. +#[test] +fn image_size_refuses_a_resolution_out_of_range() { + for extra in [json!({"resample": "none"}), json!({"resample": "bicubic"}), json!({"resample": "bicubic", "keepPrintSize": true})] { + for r in [json!(1e39), json!(-5), json!(0), json!(0.5), json!(30_001), json!("300")] { + let mut s = session(); + let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()); + let mut p = extra.clone(); + p["resolution"] = r; + assert!(s.execute("image.imageSize", p.clone()).is_err(), "{p}"); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()), (size, dpi, past), "{p}: nothing changed"); + } + } + let mut s = session(); + s.execute("image.imageSize", json!({"resolution": 20_000, "resample": "none"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 20_000.0)); + s.execute("image.imageSize", json!({"resolution": null, "resample": "none"})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 20_000.0, "null leaves the resolution as it is"); + // An imported document's own resolution outside the range (a PNG pHYs of 1 px/m is + // 0.0254 ppi) still resizes: the Image Size dialog sends it back unchanged on OK. + for own in [0.0254_f32, 40_000.0] { + let mut s = session(); + s.edit("ppi", |doc, _| { + doc.resolution_dpi = own; + Ok(()) + }) + .unwrap(); + s.execute("image.imageSize", json!({"width": 80, "height": 40, "resolution": f64::from(own), "resample": "bicubic"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}"); + assert!(s.execute("image.imageSize", json!({"resolution": 0.03, "resample": "none"})).is_err(), "{own}: another value is still checked"); + // Only the exact value: one that merely rounds to it as f32 is refused. + let near = if own < 1.0 { f64::from(own) * (1.0 + 1e-9) } else { f64::from(own) + 0.001 }; + assert_eq!(near as f32, own); + assert!(s.execute("image.imageSize", json!({"resolution": near, "resample": "none"})).is_err(), "{own}: {near}"); + // keepPrintSize with the unchanged resolution keeps the pixels. + s.execute("image.imageSize", json!({"resolution": f64::from(own), "resample": "bicubic", "keepPrintSize": true})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}: keepPrintSize"); + } +} + /// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is /// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump. #[test] @@ -825,6 +867,79 @@ fn crop_target_accepts_whole_number_floats() { assert_eq!(doc(&s).size, Size::new(40, 20)); } +/// #9212: a non-deleting crop keeps the pixels outside the frame, and W x H resamples them +/// too. A 20 px frame of a 2000 px Background resampled to 2000 px scales its hidden pixels +/// x100, to 200000 px a side (160 GB at 8 bits, past the 8 GiB budget): refused before +/// allocating, like a target size past the budget. +#[test] +fn crop_target_budgets_the_hidden_pixels_it_resamples() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + s.execute("paint.setSymmetry", json!({"mode": "vertical", "center": [500, 700]})).unwrap(); + let symmetry = s.active().unwrap().symmetry.clone(); + let past = s.active().unwrap().history.past_len(); + let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "deleteCroppedPixels": false, "targetWidth": 2000, "targetHeight": 2000}); + for angle in [None, Some(90)] { + let mut p = p.clone(); + if let Some(a) = angle { + p["angle"] = json!(a); + } + let err = s.execute("image.crop", p).unwrap_err().to_string(); + // Since the crop turns the Background into Layer 0 (#10867), the turned layer's + // bounds are 2001 px a side, resampled x100 to 200100. + let want = if angle.is_some() { "200100 x 200100 px is 40040 megapixels" } else { "200000 x 200000 px is 40000 megapixels" }; + assert!(err.contains(want) && err.contains("Delete Cropped Pixels"), "{angle:?}: {err}"); + assert_eq!(doc(&s).size, Size::new(2000, 2000), "{angle:?}: the document is unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{angle:?}: no history step"); + // The turned crop rotates first: the painting symmetry turns back with the document. + assert_eq!(s.active().unwrap().symmetry, symmetry, "{angle:?}: the painting symmetry stays"); + } + // Deleting the cropped pixels leaves only the frame to resample. + p["deleteCroppedPixels"] = json!(true); + s.execute("image.crop", p).unwrap(); + assert_eq!(doc(&s).size, Size::new(2000, 2000)); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); +} + +/// #9212: Image Size resamples the pixels outside the canvas as well. After a non-deleting +/// crop to 10 px, 20000 px (400 MP, within the budget) scales the hidden 100 px Background x2000. +#[test] +fn image_size_budgets_the_hidden_pixels_it_resamples() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + // The hidden pixels are a pixel layer's: Delete Cropped Pixels would clear them. + assert!(err.contains("or delete them first (Crop with Delete Cropped Pixels)"), "{err}"); + assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (Size::new(10, 10), past)); + // Without resampling only the resolution changes. + s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(10, 10), 300.0)); +} + +/// Delete Cropped Pixels clips pixel layers only: an alpha channel keeps its pixels outside the +/// crop, so the refusal doesn't send the user to an option that can't clear it. +#[test] +fn hidden_resample_budget_names_what_delete_cropped_pixels_keeps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.edit("channel", |doc, _| { + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(Rect::new(0, 0, 100, 100), &[1.0; 100 * 100]); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": true})).unwrap(); + let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + assert!(err.contains("channels") && !err.contains("delete them first"), "{err}"); + assert_eq!(doc(&s).size, Size::new(10, 10)); +} + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -873,6 +988,28 @@ fn trim_based_on_top_left_colour() { assert_eq!(doc(&s).size, Size::new(10, 10)); } +#[test] +fn trim_transparent_drops_alpha_that_rounds_to_zero_at_the_depth() { + // A faint pixel at (0, 0) on a 40 % layer: alpha 1 step × 0.4 rounds to 0 at 8 and 16 bits + // (the stored image is transparent there), so Trim keeps only the opaque square. A 1/255 + // pixel is 0.4 × 257 ≈ 103 steps at 16 bits and any alpha is content at 32 bits: kept. + for (depth, faint, keeps) in [(8, 1.0 / 255.0, false), (16, 1.0 / 65535.0, false), (16, 1.0 / 255.0, true), (32, 1.0 / 65535.0, true)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth, "background": "transparent"})).unwrap(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + surf.fill_rect(Rect::new(40, 40, 50, 50), &[1.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, faint]); + Ok(()) + }) + .unwrap(); + s.execute("layer.setProps", json!({"layer": doc(&s).layers[0].id.0, "opacity": 0.4})).unwrap(); + let r = s.execute("image.trim", json!({"basedOn": "transparent"})).unwrap(); + let want = if keeps { json!({"x": 0, "y": 0, "width": 50, "height": 50}) } else { json!({"x": 40, "y": 40, "width": 10, "height": 10}) }; + assert_eq!(r, want, "{depth}-bit, faint alpha {faint}"); + } +} + #[test] fn thirty_two_bit_holds_linear_light() { // Photoshop 27.11: an 8-bit sRGB grey 128 becomes 0.2159 in 32 bits (and 32 → 0.0144), @@ -973,6 +1110,24 @@ fn duplicate_document() { assert!(doc(&s).name.ends_with("copy")); } +/// #9212: a bicubic shrink stores the image between its two passes with one axis at its old +/// size. A Background whose kept pixels reach (69999, 69999), halved from 200 to 100 px, ends at +/// about 35000 x 35000 px (4.9 GB, within the 8 GiB budget) but passes through about +/// 35000 x 70000 px (9.8 GB): refused before allocating. Bilinear has one pass and fits. The +/// check alone runs, so nothing that large is allocated either way. +#[cfg(target_pointer_width = "64")] +#[test] +fn hidden_resample_budget_counts_the_image_between_bicubic_passes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let mut d = doc(&s).clone(); + let bg = d.background_mut().unwrap().surface_mut().unwrap(); + bg.fill_rect(Rect::new(69_999, 69_999, 70_000, 70_000), &[1.0, 0.0, 0.0, 1.0]); + let err = check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bicubic).unwrap_err().to_string(); + assert!(err.contains(" x 70000 px is "), "{err}"); + check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bilinear).unwrap(); +} + #[test] fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/layer_copy_cmds.rs b/crates/engine/src/cmd/layer_copy_cmds.rs index 2a4ce26493..0c12d5a21f 100644 --- a/crates/engine/src/cmd/layer_copy_cmds.rs +++ b/crates/engine/src/cmd/layer_copy_cmds.rs @@ -8,10 +8,11 @@ //! document. Pixels are converted to the destination's colour profile (Color Settings' intent and //! black point compensation) and bit depth, as Photoshop converts dragged layers. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use photocraft_color::ColorMode; -use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId}; +use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId, TextStyles}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -122,11 +123,76 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document) // application (24 pt at 72 ppi becomes 5.76 pt at 300 ppi, with no transform). Left at the // source's, a copy re-laid out at the destination's resolution on its next edit (#9560). crate::canvas_geom::retarget_type_resolution(copies, dest.resolution_dpi); + adopt_text_styles(copies, &dest.text_styles)?; let new_canvas = copies.bounds() != dest.bounds(); prepare_shapes(&mut copies.layers, dest, new_canvas); Ok(()) } +/// Points the copies' type at `dest`'s character and paragraph styles, as the reference +/// application does: a style is matched by name and the destination's definition wins (a copy +/// whose rewritten size differs from it shows a local override), and a style `dest` lacks comes +/// along (already scaled by [`crate::canvas_geom::retarget_type_resolution`]) under a fresh id. +/// Afterwards `copies.text_styles` holds just the styles to add; [`land_text_styles`] adds them. +fn adopt_text_styles(copies: &mut Document, dest: &TextStyles) -> Result<()> { + let src = std::mem::take(&mut copies.text_styles); + let mut used = (BTreeSet::new(), BTreeSet::new()); + crate::image_cmds::for_each_layer(&mut copies.layers, &mut |l| { + if let LayerContent::Text(t) = &l.content { + used.0.extend(t.runs.iter().filter_map(|r| r.style.style_sheet)); + used.1.extend(t.paragraphs.iter().filter_map(|p| p.style.style_sheet)); + } + }); + let full = || EngineError::Other("no free text style id".into()); + let (mut chars, mut paras) = (HashMap::new(), HashMap::new()); + let mut next = dest.next_char_id(); + for id in used.0 { + let Some(def) = src.char_style(id) else { continue }; + let to = match dest.character.iter().find(|d| d.name == def.name) { + Some(d) => d.id, + None => { + let fresh = next.ok_or_else(full)?; + next = fresh.checked_add(1); + copies.text_styles.character.push(CharacterStyleDef { id: fresh, ..def.clone() }); + fresh + } + }; + chars.insert(id, to); + } + let mut next = dest.next_para_id(); + for id in used.1 { + // Basic Paragraph (id 0) is implicit; every document has its own. + let Some(def) = src.para_style(id).filter(|_| id != 0) else { continue }; + let to = match dest.paragraph.iter().find(|d| d.name == def.name) { + Some(d) => d.id, + None => { + let fresh = next.ok_or_else(full)?; + next = fresh.checked_add(1); + copies.text_styles.paragraph.push(ParagraphStyleDef { id: fresh, ..def.clone() }); + fresh + } + }; + paras.insert(id, to); + } + crate::image_cmds::for_each_layer(&mut copies.layers, &mut |l| { + if let LayerContent::Text(t) = &mut l.content { + for r in &mut t.runs { + r.style.style_sheet = r.style.style_sheet.and_then(|id| chars.get(&id).copied()); + } + for p in &mut t.paragraphs { + p.style.style_sheet = p.style.style_sheet.and_then(|id| paras.get(&id).copied()); + } + } + }); + Ok(()) +} + +/// Adds the styles [`adopt_text_styles`] left in `styles` to `doc`. +pub(crate) fn land_text_styles(doc: &mut Document, styles: TextStyles) { + doc.text_styles.character.extend(styles.character); + doc.text_styles.paragraph.extend(styles.paragraph); +} + /// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement). pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx: i32, dy: i32) { if dx != 0 || dy != 0 { @@ -181,6 +247,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { copies.icc_profile = sdoc.icc_profile.clone(); // Type re-lays out at the source's resolution, so it keeps its size in pixels. copies.resolution_dpi = sdoc.resolution_dpi; + copies.text_styles.clone_from(&sdoc.text_styles); for id in &ids { let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?; let mut l = src_layer.duplicate(); @@ -191,7 +258,12 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { } copies.layers.push(l); } + // A name other than the copy's own was typed (#9023). + let mut typed = None; if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) { + if l.name != name { + typed = Some(name.to_string()); + } l.name = name.to_string(); } // Measure in source space: destination refresh may clip a shape before it is moved. @@ -214,10 +286,12 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { let (dx, dy) = (dx.round() as i32, dy.round() as i32); translate_copies(&mut copies, &ddoc, dx, dy); let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect(); + let styles = std::mem::take(&mut copies.text_styles); s.set_active(dest); let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" }; let edited = s.edit(label, |doc, active| { doc.patterns.extend(patterns); + land_text_styles(doc, styles); let mut above = *active; let mut new = Vec::with_capacity(copies.layers.len()); // The first copy goes where a new layer would; the rest stack above it. @@ -238,6 +312,9 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { s.set_active(src); } let new = edited?; + if let (Some(name), [id]) = (typed, new.as_slice()) { + crate::type_cmds::note_typed_name(s, *id, &name); + } let last = new.last().copied(); crate::layer_multi_cmds::reselect(s, new.clone(), last); Ok(json!({"document": dest, "layers": new.iter().map(|l| l.0).collect::>(), "offset": [dx, dy]})) @@ -271,11 +348,17 @@ pub(crate) fn duplicate_to_new_document(s: &mut Session, p: &Value) -> Result Result = nd.layers.iter().map(|l| l.id).collect(); let index = s.add_document(nd, None); + if let (Some(name), [id]) = (typed, new_ids.as_slice()) { + crate::type_cmds::note_typed_name(s, *id, &name); + } crate::layer_multi_cmds::reselect(s, new_ids.clone(), new_ids.last().copied()); let mut out = json!({"document": index, "layers": new_ids.iter().map(|l| l.0).collect::>()}); if let [one] = new_ids.as_slice() diff --git a/crates/engine/src/cmd/layer_copy_cmds/tests.rs b/crates/engine/src/cmd/layer_copy_cmds/tests.rs index f3176af0af..5f82c1c4bd 100644 --- a/crates/engine/src/cmd/layer_copy_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_copy_cmds/tests.rs @@ -500,3 +500,166 @@ fn retargeting_type_survives_bad_resolutions() { crate::canvas_geom::retarget_type_resolution(&mut d, f32::MAX); assert!(size(&d).is_finite() && size(&d) > 0.0, "{}", size(&d)); } + +/// Document 0 at 72 ppi with "Hello" in Paragraph Style 1 (24 pt) and "He" also in Character +/// Style 1 (24 pt, underlined); document 1 at 300 ppi, its own styles made by `dest`. +fn styled_type_docs(dest: impl FnOnce(&mut Session)) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 72})).unwrap(); + let t = s.execute("type.create", json!({"text": "Hello", "size": 12, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.paragraphStyle.new", json!({"attrs": {"size": 24}, "fromSelection": false, "apply": true, "layer": t})).unwrap(); + s.execute("type.characterStyle.new", json!({"attrs": {"size": 24, "underline": true}, "fromSelection": false, "apply": true, "layer": t, "range": [0, 2]})) + .unwrap(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 300})).unwrap(); + dest(&mut s); + s.set_active(0); + s.execute("layer.select", json!({"layer": t})).unwrap(); + (s, LayerId(t)) +} + +/// The styles panel state of `range` in layer `id` of document 1. +fn styles_of(s: &mut Session, id: LayerId, range: [usize; 2]) -> (Value, Value) { + s.set_active(1); + let c = s.execute("type.characterStyle.list", json!({"layer": id.0, "range": range})).unwrap(); + let p = s.execute("type.paragraphStyle.list", json!({"layer": id.0, "range": range})).unwrap(); + (c, p) +} + +/// #10500: type copied to a document of another resolution brings the styles it uses, as in +/// the reference application (measured with Duplicate Layer from 72 to 300 ppi): a style the +/// destination lacks arrives under its name, scaled like the text (24 pt → 5.76 pt), so the +/// copy has no override; it never takes over an unrelated destination style with the same id. +#[test] +fn copied_type_brings_its_styles_scaled() { + for paste in [false, true] { + let (mut s, _) = styled_type_docs(|s| { + s.execute("type.characterStyle.new", json!({"name": "Unrelated", "attrs": {"size": 50}, "fromSelection": false})).unwrap(); + s.execute("type.paragraphStyle.new", json!({"name": "Unrelated", "attrs": {"size": 50}, "fromSelection": false})).unwrap(); + }); + let id = if paste { + s.execute("edit.copy", json!({})).unwrap(); + s.set_active(1); + LayerId(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap()["layer"].as_u64().unwrap()) + } else { + copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap()) + }; + assert!(close(text(&s, 1, id).runs[0].style.size_pt, 5.76), "paste {paste}"); + let (c, p) = styles_of(&mut s, id, [0, 2]); + let names = |v: &Value| v["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect::>(); + assert_eq!(names(&c), ["None", "Unrelated", "Character Style 1"], "paste {paste}"); + assert_eq!(names(&p), ["Basic Paragraph", "Unrelated", "Paragraph Style 1"], "paste {paste}"); + let (cs, ps) = (&c["styles"][2], &p["styles"][2]); + assert!(close(cs["attrs"]["size_pt"].as_f64().unwrap() as f32, 5.76), "{cs}"); + assert!(close(ps["characterAttrs"]["size_pt"].as_f64().unwrap() as f32, 5.76), "{ps}"); + assert_eq!((&c["current"]["character"], &c["current"]["characterOverride"]), (&cs["id"], &json!(false)), "paste {paste}"); + assert_eq!((&p["current"]["paragraph"], &p["current"]["paragraphOverride"]), (&ps["id"], &json!(false)), "paste {paste}"); + // The source keeps its 24 pt styles. + assert_eq!(doc(&s, 0).text_styles.character[0].attrs["size_pt"], json!(24.0)); + } +} + +/// A destination that already has a style of the same name keeps its definition (24 pt), and +/// the copy, at 5.76 pt, shows a local override ("+"), as in the reference application. +#[test] +fn copied_type_uses_the_destinations_same_named_style() { + let (mut s, _) = styled_type_docs(|s| { + s.execute("type.characterStyle.new", json!({"name": "Filler", "fromSelection": false})).unwrap(); + s.execute("type.characterStyle.new", json!({"name": "Character Style 1", "attrs": {"size": 24, "underline": true}, "fromSelection": false})).unwrap(); + }); + let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap()); + let (c, _) = styles_of(&mut s, id, [0, 2]); + assert_eq!(c["styles"].as_array().unwrap().len(), 3, "no second Character Style 1: {c}"); + let existing = &c["styles"][2]; + assert_eq!(existing["attrs"]["size_pt"], json!(24.0)); + assert_eq!((&c["current"]["character"], &c["current"]["characterOverride"]), (&existing["id"], &json!(true))); + assert!(close(text(&s, 1, id).runs[0].style.size_pt, 5.76)); +} + +/// Duplicate Layer › New Document brings the styles the type uses (at the same size). +#[test] +fn type_duplicated_to_a_new_document_keeps_its_styles() { + let (mut s, t) = styled_type_docs(|_| {}); + let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap(); + let (i, id) = (r["document"].as_u64().unwrap() as usize, LayerId(r["layer"].as_u64().unwrap())); + s.set_active(i); + let c = s.execute("type.characterStyle.list", json!({"layer": id.0, "range": [0, 2]})).unwrap(); + let p = s.execute("type.paragraphStyle.list", json!({"layer": id.0, "range": [0, 2]})).unwrap(); + assert_eq!(c["styles"][1]["name"], "Character Style 1"); + assert_eq!(c["styles"][1]["attrs"]["size_pt"], json!(24.0)); + assert_eq!((&c["current"]["character"], &c["current"]["characterOverride"]), (&c["styles"][1]["id"], &json!(false))); + assert_eq!((&p["current"]["paragraph"], &p["current"]["paragraphOverride"]), (&p["styles"][1]["id"], &json!(false))); + assert_eq!(text(&s, i, id).runs, text(&s, 0, t).runs); +} + +/// Document 0 (RGB, 72 ppi) with "Hello" whose "He" is in Character Style 1 (red); document 1 +/// (CMYK, 72 ppi). The type layer is selected in document 0. +fn red_styled_type_docs() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap(); + let t = s.execute("type.create", json!({"text": "Hello", "size": 12, "x": 20, "y": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.characterStyle.new", json!({"attrs": {"color": "#ff0000"}, "fromSelection": false, "apply": true, "layer": t, "range": [0, 2]})).unwrap(); + s.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72, "mode": "cmyk"})).unwrap(); + s.set_active(0); + s.execute("layer.select", json!({"layer": t})).unwrap(); + (s, LayerId(t)) +} + +/// The colour Character Style 1 of document `i` sets, and whether "He" of `id` overrides it. +fn style_color(s: &mut Session, i: usize, id: LayerId) -> (photocraft_color::Color, bool) { + s.set_active(i); + let c = s.execute("type.characterStyle.list", json!({"layer": id.0, "range": [0, 2]})).unwrap(); + let style = c["styles"].as_array().unwrap().iter().find(|x| x["name"] == "Character Style 1").unwrap(); + (serde_json::from_value(style["attrs"]["color"].clone()).unwrap(), c["current"]["characterOverride"].as_bool().unwrap()) +} + +/// Type copied to a document of another colour mode converts its run colours, and the style it +/// brings converts with the same transform: the run matches its style (no override), so a later +/// change to the style's colour still reaches it. Converting the document itself (Image › Mode, +/// Convert to Profile with and without dither) keeps its styles in step the same way. +#[test] +fn copied_type_styles_convert_their_colours_with_the_type() { + let (mut s, _) = red_styled_type_docs(); + let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap()); + let run = text(&s, 1, id).runs[0].style.color; + assert_eq!(run.mode, ColorMode::Cmyk, "the run converted"); + let (style, over) = style_color(&mut s, 1, id); + assert_eq!(style, run, "the copied style converted with the run"); + assert!(!over, "no false override"); + for (cmd, p) in [ + ("image.mode.cmyk", json!({})), + ("edit.convertToProfile", json!({"profile": "coated-cmyk", "dither": false})), + ("edit.convertToProfile", json!({"profile": "coated-cmyk", "dither": true})), + ("edit.convertToProfile", json!({"profile": "lab-d50"})), + ] { + let (mut s, t) = red_styled_type_docs(); + s.execute(cmd, p.clone()).unwrap(); + let run = text(&s, 0, t).runs[0].style.color; + assert_ne!(run.mode, ColorMode::Rgb, "{cmd} {p}: the run converted"); + let (style, over) = style_color(&mut s, 0, t); + assert_eq!(style, run, "{cmd} {p}: the style converted with the run"); + assert!(!over, "{cmd} {p}: no false override"); + } +} + +/// Duplicate Layer › New Document keeps the source's Basic Paragraph: type that inherits its +/// size from it has no override there, and Clear Override leaves it as it was. +#[test] +fn type_duplicated_to_a_new_document_keeps_the_sources_basic_paragraph() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let t = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 12, "x": 20, "y": 50})).unwrap()["layer"].as_u64().unwrap()); + s.execute("type.paragraphStyle.set", json!({"id": 0, "attrs": {"size": 30}})).unwrap(); + assert!(close(text(&s, 0, t).runs[0].style.size_pt, 30.0), "the type inherits Basic Paragraph"); + let basic = doc(&s, 0).text_styles.basic.clone(); + s.execute("layer.select", json!({"layer": t.0})).unwrap(); + let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap(); + let (i, id) = (r["document"].as_u64().unwrap() as usize, LayerId(r["layer"].as_u64().unwrap())); + assert_eq!(doc(&s, i).text_styles.basic, basic); + s.set_active(i); + let c = s.execute("type.characterStyle.list", json!({"layer": id.0})).unwrap(); + let p = s.execute("type.paragraphStyle.list", json!({"layer": id.0})).unwrap(); + assert_eq!((&c["current"]["characterOverride"], &p["current"]["paragraphOverride"]), (&json!(false), &json!(false)), "{c} {p}"); + s.execute("type.paragraphStyle.clearOverride", json!({"layer": id.0})).unwrap(); + s.execute("type.characterStyle.clearOverride", json!({"layer": id.0})).unwrap(); + assert!(close(text(&s, i, id).runs[0].style.size_pt, 30.0), "{}", text(&s, i, id).runs[0].style.size_pt); +} diff --git a/crates/engine/src/cmd/layer_menu_cmds.rs b/crates/engine/src/cmd/layer_menu_cmds.rs index 75701d9ddd..46aae07a71 100644 --- a/crates/engine/src/cmd/layer_menu_cmds.rs +++ b/crates/engine/src/cmd/layer_menu_cmds.rs @@ -316,6 +316,49 @@ fn remove_matte(s: &mut Session, white: bool) -> Result { }) } +/// Extracts the alpha channel of `surf` within bounds `r` into a GRAY8 surface, +/// preserving default alpha for unallocated tiles intersecting `r`. +fn decontamination_alpha(surf: &Surface, r: Rect) -> Surface { + let mut alpha = Surface::new(photocraft_color::PixelFormat::GRAY8); + let default_alpha = to_rgba(&surf.format(), &surf.default_pixel())[3].clamp(0.0, 1.0); + if default_alpha > 0.0 { + let shared = alpha.solid_tile(&[default_alpha]); + for tc in r.tiles() { + if surf.tile(tc).is_some() { + continue; + } + let area = tc.rect().intersect(&r); + if area == tc.rect() { + alpha.put_tiles([(tc, shared.clone())]); + } else { + alpha.fill_rect(area, &[default_alpha]); + } + } + } + let mut converted = std::collections::HashMap::new(); + for (tc, tile) in surf.tiles() { + let area = tc.rect().intersect(&r); + if area.is_empty() { + continue; + } + let key = std::sync::Arc::as_ptr(tile) as usize; + if area == tc.rect() + && let Some(shared) = converted.get(&key) + { + alpha.put_tiles([(*tc, std::sync::Arc::clone(shared))]); + continue; + } + let values: Vec = read_rgba(surf, area).iter().map(|q| (q[3].clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + alpha.write_interleaved(area, &values); + if area == tc.rect() + && let Some(shared) = alpha.tile(*tc) + { + converted.insert(key, shared.clone()); + } + } + alpha +} + /// Layer › Matting › Color Decontaminate: fringe colours move toward nearby opaque colours /// (the Select and Mask decontamination, driven by the layer's own alpha). fn color_decontaminate(s: &mut Session, p: &Value) -> Result { @@ -328,9 +371,8 @@ fn color_decontaminate(s: &mut Session, p: &Value) -> Result { if r.is_empty() || !surf.format().alpha { return Ok(()); } - let alpha: Vec = read_rgba(surf, r).iter().map(|q| (q[3].clamp(0.0, 1.0) * 255.0).round() as u8).collect(); - let region = Region { bbox: r, mask: alpha }; - *surf = photocraft_algo::matting::decontaminate(surf, ®ion, radius, amount); + let alpha = decontamination_alpha(surf, r); + *surf = photocraft_algo::matting::decontaminate_surface(surf, &alpha, radius, amount); Ok(()) })?; Ok(Value::Null) @@ -392,8 +434,8 @@ pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { if dry_run { return Ok(json!({"program": program, "args": args})); } - ensure_folder(program, &args)?; - spawn(program, &args)?; + ensure_folder(&program, &args)?; + spawn(&program, &args)?; Ok(json!({"path": path})) } @@ -411,8 +453,28 @@ fn ensure_folder(program: &str, args: &[String]) -> Result<()> { } /// The file-manager invocation that selects `path` on this platform. -pub fn reveal_command(path: &str) -> Result<(&'static str, Vec)> { - reveal_command_on(std::env::consts::OS, path) +pub fn reveal_command(path: &str) -> Result<(String, Vec)> { + let (program, args) = reveal_command_on(std::env::consts::OS, path)?; + Ok((system_program(program), args)) +} + +/// Windows tools run by full path: by bare name, Windows first looks in the app's own folder, so +/// an `explorer.exe` lying next to a portable PhotoCraft would start instead (#9666). +#[cfg(windows)] +fn system_program(program: &str) -> String { + windows_root(std::env::var_os("SystemRoot")).join(format!("{program}.exe")).to_string_lossy().into_owned() +} + +/// The Windows folder for the `SystemRoot` value `system_root`: `C:\Windows` when it is unset or +/// not an absolute path (a relative one would resolve against the working directory). +#[cfg(windows)] +pub(crate) fn windows_root(system_root: Option) -> std::path::PathBuf { + system_root.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")) +} + +#[cfg(not(windows))] +fn system_program(program: &str) -> String { + program.to_string() } /// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value). diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index 62522ccc39..c967f86964 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -821,3 +821,29 @@ fn applying_a_feathered_mask_keeps_off_canvas_pixels_it_reveals() { s.execute("layer.layerMask.apply", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().rgba(52, 15)[3], 1.0, "off-canvas pixel stays visible"); } + +#[test] +fn decontamination_alpha_shares_solid_source_tiles() { + let mut source = Surface::new(photocraft_color::PixelFormat::RGBA8); + let bounds = Rect::new(0, 0, 2048, 1024); + source.fill_rect(bounds, &[0.4, 0.2, 0.7, 0.5]); + let alpha = decontamination_alpha(&source, bounds); + let a = photocraft_geom::TileCoord::new(0, 0); + let b = photocraft_geom::TileCoord::new(3, 2); + assert!(std::sync::Arc::ptr_eq(alpha.tile(a).unwrap(), alpha.tile(b).unwrap())); + assert_eq!(alpha.sample_channel(20, 20, 0), source.sample_channel(20, 20, 3)); +} + +#[test] +fn decontamination_alpha_keeps_missing_source_alpha_only_inside_bounds() { + let mut source = Surface::with_default(photocraft_color::PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]); + source.write_pixel(255, 128, &[0.5, 0.0, 0.5, 0.5]); + let bounds = Rect::new(10, 10, 700, 240); + let alpha = decontamination_alpha(&source, bounds); + assert_eq!(alpha.sample_channel(300, 128, 0), 1.0); + assert_eq!(alpha.sample_channel(699, 128, 0), 1.0); + assert_eq!(alpha.sample_channel(255, 128, 0), source.sample_channel(255, 128, 3)); + for (x, y) in [(9, 128), (700, 128), (300, 9), (300, 240)] { + assert_eq!(alpha.sample_channel(x, y, 0), 0.0); + } +} diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index ada040e8b7..5076d665ff 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -32,7 +32,7 @@ pub use moves::{move_targets, move_to, moved, moved_target, moves_mask_alone, pr use selection::{doc_state, find_layers, select_all_layers, select_linked}; pub(crate) use selection::{mask_targets, recorded_step, reselect, targets, top_level}; pub use selection::{select, selected, set_selection}; -pub(crate) use structure::{check_group_depth, deletion_neighbours}; +pub(crate) use structure::{check_group_depth, deletion_neighbours, relink_copies}; pub use structure::{delete_selected, duplicate_selected, group_layers, set_visible_selected}; use structure::{link_layers, lock_layers, merge_layers, rename_layer, reverse}; @@ -101,7 +101,15 @@ pub fn specs() -> Vec { } Ok(Value::Null) }), - spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers), + spec!( + "select.findLayers", + "Find Layers", + &["Select"], + Some("Cmd+Alt+Shift+F"), + r##"{"name":str?} (filters the Layers panel by Name, a case-insensitive substring, selecting none of the matches; without `name` the panel's Name text is kept; replies with the matching layers)"##, + has_doc, + find_layers + ), spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)), spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")), spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")), @@ -158,7 +166,7 @@ pub fn specs() -> Vec { "layer.lockLayers", "Lock Layers…", &["Layer"], - None, + Some("Cmd+/"), r##"{"transparency":bool?,"pixels":bool?,"position":bool?,"artboard":bool?,"all":bool?} (none given: toggle lock all)"##, has_layer, lock_layers diff --git a/crates/engine/src/cmd/layer_multi_cmds/damage.rs b/crates/engine/src/cmd/layer_multi_cmds/damage.rs index 8440fab75b..a3fb64f032 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/damage.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/damage.rs @@ -156,6 +156,7 @@ fn layer_pixels_cmp(a: &Layer, b: &Layer, surf: SurfaceCmp) -> bool { clipped, mask, vector_mask, + frame, effects, label: _, content, @@ -176,6 +177,7 @@ fn layer_pixels_cmp(a: &Layer, b: &Layer, surf: SurfaceCmp) -> bool { && *fill_opacity == b.fill_opacity && *clipped == b.clipped && *vector_mask == b.vector_mask + && *frame == b.frame && *effects == b.effects && *excluded_channels == b.excluded_channels && *blend_if == b.blend_if diff --git a/crates/engine/src/cmd/layer_multi_cmds/moves.rs b/crates/engine/src/cmd/layer_multi_cmds/moves.rs index ed26ba1030..078dde4d57 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/moves.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/moves.rs @@ -1,5 +1,5 @@ use super::selection::{reselect, same_named_targets, selected, top_level}; -use super::structure::check_group_depth; +use super::structure::{check_group_depth, relink_copies}; use super::{Document, EngineError, Layer, LayerContent, LayerId, Result, Session, Value, bad, int_i32, json}; /// A Layers-panel drag of the selected layer records its drop target by name (and which of @@ -134,10 +134,12 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } Some(other) => return Err(bad("layer.translate", format!("unknown units `{other}` (px|pt)"))), }; - let carry = match p.get("selection") { - None | Some(Value::Null) => false, - Some(v) => v.as_bool().ok_or_else(|| bad("layer.translate", "`selection` must be a boolean"))?, + let flag = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(false), + Some(v) => v.as_bool().ok_or_else(|| bad("layer.translate", format!("`{key}` must be a boolean"))), }; + let carry = flag("selection")?; + let nest = flag("nest")?; if dx == 0 && dy == 0 { return Ok(Value::Null); } @@ -150,7 +152,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } let mask_target = p.get("target").and_then(Value::as_str) == Some("mask"); let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - s.edit("Move", |doc, _| { + let nested = s.edit("Move", |doc, _| { let (alone, rest): (Vec, Vec) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone)); let mut ids = move_targets(doc, &rest); if ids.is_empty() && alone.is_empty() { @@ -158,6 +160,8 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect(); move_layers(doc, &before, &moves)?; + // In the same step, so Undo takes the move and the change of board back together. + let nested = if nest { auto_nest(doc, &ids) } else { Vec::new() }; for id in alone { let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -178,9 +182,113 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { if carry { doc.selection = doc.selection.as_ref().map(|sel| shift_surface(sel, dx, dy)); } - Ok(ids) + Ok(nested) })?; - Ok(Value::Null) + if nested.is_empty() { + return Ok(Value::Null); + } + Ok(json!({"nested": nested.iter().map(|id| id.0).collect::>()})) +} + +/// The artboard a layer at `bounds` is on: the topmost board holding the centre of its bounds +/// ([`crate::layer_place::centre`]), the rule a new layer is placed by. `boards` are top to bottom. +fn board_under(boards: &[(LayerId, photocraft_geom::Rect)], bounds: photocraft_geom::Rect) -> Option { + let (cx, cy) = crate::layer_place::centre(bounds); + boards.iter().find(|(_, r)| r.contains(cx, cy)).map(|(id, _)| *id) +} + +/// One planned auto-nest move: its order (walk order, then its place above a clip base), the +/// layer, and the boards it leaves and joins. +type Nest = ((usize, usize), LayerId, Option, Option); + +/// Move-tool auto-nesting, as in Photoshop: after a Move drag, a layer now on another artboard +/// joins that board, on top of its stack, and a layer moved off every board leaves its board for +/// the top level, directly above the board it left. Artboards share one canvas, so without this a +/// layer dragged onto another board stayed in its old board and was clipped away by it. +/// +/// A clipping group moves as one: a base that changes board takes the layers clipped to it along, +/// directly above it and still clipped. Left behind, they were released (their base gone) and +/// covered their old board. A clipped layer never changes board by itself, moved or not: it shows +/// only over its base, so it goes where its base goes (with it, or nowhere). The group stays +/// whole where it is when any of it can't go. +/// +/// `ids` are the moved layers ([`move_targets`]), already at their new positions. Left where they +/// are: artboards themselves, the Background, empty layers, layers whose board doesn't change, a +/// group that would nest past [`photocraft_doc::MAX_GROUP_DEPTH`], and layers locked against it +/// with Lock › Prevent Auto-Nesting (on the layer, a group around it, or the board it would join). +/// Returns the layers that changed board, bottom to top. +fn auto_nest(doc: &mut Document, ids: &[LayerId]) -> Vec { + let boards: Vec<(LayerId, photocraft_geom::Rect)> = doc.artboards().into_iter().rev().map(|(id, _, board)| (id, board.rect)).collect(); + if boards.is_empty() { + return Vec::new(); + } + let locked_board = |doc: &Document, id: LayerId| doc.layer(id).is_some_and(|l| l.locks.artboard || l.locks.all); + // Bottom to top, so layers moving together keep their order in their new parent: by walk + // order, and a carried clipped layer by its base's and then its place above the base. + let mut plan: Vec = Vec::new(); + // The paths of the layers planned so far: a layer inside one of them goes with it. + let mut carried: Vec> = Vec::new(); + for (order, (path, _, l)) in doc.walk().into_iter().enumerate() { + if !ids.contains(&l.id) || l.artboard().is_some() || doc.is_background(l.id) || carried.iter().any(|c| path.starts_with(c)) { + continue; + } + if l.clipped && crate::clip_group::base_of(doc, l.id).is_some() { + continue; + } + let bounds = crate::layer_multi_cmds::layer_bounds(l); + let Some(bounds) = bounds.filter(|b| !b.is_empty()) else { continue }; + let (from, to) = (doc.artboard_of(l.id), board_under(&boards, bounds)); + let clipped = if l.clipped { Vec::new() } else { crate::clip_group::clipped_above(doc, l.id) }; + let group: Vec<&Layer> = std::iter::once(l).chain(clipped.iter().filter_map(|id| doc.layer(*id))).collect(); + if from == to || to.is_some_and(|b| locked_board(doc, b)) || group.iter().any(|m| doc.effective_locks(m.id).artboard) { + continue; + } + if to.is_some() && group.iter().any(|m| 1 + crate::layer_place::subtree_depth(m) > photocraft_doc::MAX_GROUP_DEPTH) { + continue; + } + for (k, m) in group.into_iter().enumerate() { + plan.push(((order, k), m.id, from, to)); + carried.extend(doc.path_of(m.id)); + } + } + if plan.is_empty() { + return Vec::new(); + } + plan.sort_by_key(|p| p.0); + let moving: Vec = plan.iter().map(|p| p.1).collect(); + let before = crate::clip_group::Before::read(doc, &moving); + let mut nested = Vec::with_capacity(plan.len()); + // How many layers have left each board so far: each goes above the last one. + let mut left: Vec<(LayerId, usize)> = Vec::new(); + for (_, id, from, to) in plan { + let Some(layer) = doc.remove(id) else { continue }; + match to { + Some(board) => match doc.layer_mut(board).and_then(Layer::children_mut) { + Some(children) => children.push(layer), + None => doc.layers.push(layer), + }, + None => { + let above = from.and_then(|b| doc.layers.iter().position(|t| t.id == b)).map_or(doc.layers.len(), |i| i + 1); + let count = left.iter_mut().find(|(b, _)| Some(*b) == from); + let n = match count { + Some((_, n)) => { + *n += 1; + *n - 1 + } + None => { + if let Some(b) = from { + left.push((b, 1)); + } + 0 + } + }; + doc.layers.insert((above + n).min(doc.layers.len()), layer); + } + } + nested.push(id); + } + crate::clip_group::settle(doc, &nested, &before, false); + nested } /// The layers a move of `roots` takes along: their linked layers, without layers whose group @@ -379,6 +487,11 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: & siblings.splice(at..at, moved); } check_group_depth(doc, "Reorder Layer")?; + // A copy is a new layer: it starts unlinked from its original, as a Duplicate does + // (#11217); copies of a whole linked set stay linked to each other under a fresh group. + if copy { + relink_copies(doc, &copies)?; + } // The layers now sit in their new parents; carry each across to its new board. Copies keep the // order of `moved_ids`, so `from` lines up with either list. let landed: &[LayerId] = if copy { &copies } else { moved_ids }; diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index e5e361fb1c..378b356190 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -49,6 +49,7 @@ pub(crate) fn top_level(doc: &Document, ids: &[LayerId]) -> Vec { /// Replace the layer selection without a history step (like [`Session::select_layer`]). pub fn set_selection(s: &mut Session, ids: Vec, active: Option, anchor: Option) -> Result<()> { + let before = s.active_layer_stamp(); let st = s.active_mut().ok_or(EngineError::NoDocument)?; if let Some(bad) = ids.iter().chain(active.iter()).find(|id| st.doc.layer(**id).is_none()) { return Err(EngineError::NoLayer(*bad)); @@ -70,6 +71,7 @@ pub fn set_selection(s: &mut Session, ids: Vec, active: Option st.saved_revision = st.revision; } st.last_damage = Some(Rect::EMPTY); + s.note_layer_change(before); Ok(()) } @@ -334,16 +336,27 @@ pub(super) fn select_all_layers(s: &mut Session) -> Result { Ok(json!({"selected": n})) } +/// Select › Find Layers (#10884): as in Photoshop it filters the Layers panel by Name and leaves the +/// layer selection alone. The reply's `layerFilter` is the Name filter the app applies (`name` only +/// when one is given) and `selected` lists the layers it matches: part of the name, trimmed and +/// ignoring case, as the panel matches: every layer for a blank name, which the panel doesn't filter +/// by (none without a name). pub(super) fn find_layers(s: &mut Session, p: &Value) -> Result { - let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase(); + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad("select.findLayers", "`name` must be a string"))?), + }; let d = s.active().ok_or(EngineError::NoDocument)?; - let ids: Vec = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect(); - if ids.is_empty() { - return Err(EngineError::Other(format!("no layer name contains \"{needle}\""))); + let needle = name.map(|n| n.trim().to_lowercase()); + let ids: Vec = match &needle { + Some(n) => d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(n.as_str())).map(|(_, _, l)| l.id.0).collect(), + None => Vec::new(), + }; + let mut filter = json!({"by": "name"}); + if let Some(n) = name { + filter["name"] = json!(n); } - let out = ids.iter().map(|l| l.0).collect::>(); - set_selection(s, ids, None, None)?; - Ok(json!({"selected": out})) + Ok(json!({"selected": ids, "layerFilter": filter})) } pub(super) fn select_linked(s: &mut Session) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/structure.rs b/crates/engine/src/cmd/layer_multi_cmds/structure.rs index 228372e293..66974e1dd2 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/structure.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/structure.rs @@ -74,9 +74,10 @@ pub(super) fn rename_layer(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("layer.renameLayer", "missing `name`"))?.to_string(); let id = crate::commands::layer_param(s, p)?; s.edit("Rename Layer", |doc, _| { - doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.name = name; + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.name.clone_from(&name); Ok(()) })?; + crate::type_cmds::note_typed_name(s, id, &name); Ok(Value::Null) } @@ -287,6 +288,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul } copies.push(nid); } + relink_copies(doc, &copies)?; let a = new_active.or(copies.last().copied()); *active = a; Ok((copies, a)) @@ -296,6 +298,49 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) } +/// Link groups of fresh copies, as the reference application does (measured, #11217): a copy of +/// one layer alone (Duplicate, Layer via Copy) joins its original's link group. With several +/// layers copied, the copies of several layers of one link group are linked to each other under a +/// group of their own, as `layer_copy_cmds::adapt_copies` remaps copies landing in another +/// document, and a copy that is the only one from its link group is unlinked. `copies` are the +/// copies just inserted in `doc` (the layers in copied groups are remapped with them); the +/// originals keep their groups. +pub(crate) fn relink_copies(doc: &mut Document, copies: &[LayerId]) -> Result<()> { + // One layer copied on its own keeps the link group it was copied with. + if let [only] = copies + && doc.layer(*only).is_some_and(|l| !matches!(l.content, LayerContent::Group(_))) + { + return Ok(()); + } + // Every copied layer with the link group it was copied with. An explicit stack rather than + // recursion: documents can nest deeply. + let mut linked: Vec<(LayerId, u64)> = Vec::new(); + let mut stack: Vec<&Layer> = copies.iter().filter_map(|id| doc.layer(*id)).collect(); + while let Some(l) = stack.pop() { + if let Some(group) = l.link_group { + linked.push((l.id, group)); + } + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter()); + } + } + let mut groups: Vec = linked.iter().map(|(_, group)| *group).collect(); + groups.sort_unstable(); + groups.dedup(); + for group in groups { + let members: Vec = linked.iter().filter(|(_, g)| *g == group).map(|(id, _)| *id).collect(); + // The id is picked after the previous group's copies took theirs, so no two share one. + let fresh = + if members.len() < 2 { None } else { Some(doc.unused_link_group().ok_or_else(|| EngineError::Other("no free link group id is left".into()))?) }; + for id in members { + if let Some(l) = doc.layer_mut(id) { + l.link_group = fresh; + } + } + } + Ok(()) +} + /// Show/hide every selected layer in one step. pub fn set_visible_selected(s: &mut Session, visible: bool) -> Result { let sel = selected(s); diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index a3c055cf7b..fc39d25f5d 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -586,9 +586,23 @@ fn find_layers_by_name() { s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap(); s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap(); assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo"); - let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap(); - assert_eq!(r["selected"], json!([a.0, b.0])); - assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err()); + // #10884: Find Layers filters the Layers panel by Name and leaves the selection alone. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let r = s.execute("select.findLayers", json!({"name": " PHOTO "})).unwrap(); + assert_eq!(r["selected"], json!([a.0, b.0]), "the matching layers, trimmed and ignoring case"); + assert_eq!(r["layerFilter"], json!({"by": "name", "name": " PHOTO "}), "the app sets the Name text as given"); + assert_eq!(s.active().unwrap().selected_layers(), [a], "the selection is unchanged"); + let r = s.execute("select.findLayers", json!({"name": "nothing"})).unwrap(); + assert_eq!(r["selected"], json!([]), "no match lists nothing and is no error"); + let all: Vec = doc(&s).walk().into_iter().map(|(_, _, l)| l.id.0).collect(); + for blank in ["", " "] { + let r = s.execute("select.findLayers", json!({"name": blank})).unwrap(); + assert_eq!(r["selected"], json!(all), "a blank name filters nothing, as in the panel: {blank:?}"); + } + let r = s.execute("select.findLayers", json!({})).unwrap(); + assert_eq!((&r["selected"], &r["layerFilter"]), (&json!([]), &json!({"by": "name"})), "without a name it only switches to Name"); + assert_eq!(s.active().unwrap().selected_layers(), [a]); + assert!(s.execute("select.findLayers", json!({"name": 3})).is_err()); assert!(s.execute("layer.renameLayer", json!({})).is_err()); } @@ -1456,6 +1470,93 @@ fn inspect_reports_selection() { assert_eq!(v["layers"][0]["selected"], true); } +fn link_group(s: &Session, id: LayerId) -> Option { + doc(s).layer(id).unwrap().link_group +} + +/// A and B linked (red blocks), C unlinked: (session, A, B, C, A and B's link group). +fn linked_pair() -> (Session, LayerId, LayerId, LayerId, Option) { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5)); + let b = rect_layer(&mut s, Rect::new(10, 0, 15, 5)); + let c = rect_layer(&mut s, Rect::new(20, 0, 25, 5)); + select_all(&mut s, &[a, b]); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + let group = link_group(&s, a); + assert!(group.is_some() && link_group(&s, b) == group); + (s, a, b, c, group) +} + +/// #11217: a copy of one linked layer alone joins its original's link group; copied together with +/// an unlinked layer it is unlinked (both measured in the reference application). The originals +/// stay linked. +#[test] +fn duplicating_one_linked_layer_keeps_the_copy_in_its_link_group() { + let (mut s, a, b, c, group) = linked_pair(); + // One layer, and with an unlinked layer beside it (several selected). + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let copy = LayerId(s.execute("layer.duplicate", json!({"inPlace": true})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(link_group(&s, copy), group, "the lone copy joins the originals' link group"); + select_all(&mut s, &[a, c]); + let r = s.execute("layer.duplicate", json!({})).unwrap(); + let copies: Vec = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2); + assert!(copies.iter().all(|id| link_group(&s, *id).is_none()), "a lone copy of a group has nothing to stay linked to"); + assert_eq!((link_group(&s, a), link_group(&s, b), link_group(&s, c)), (group, group, None), "the originals keep their links"); +} + +/// #11217 review: copy-dragging layers in the panel goes through `apply_move`, which must +/// relink its copies the way Duplicate does: a lone copy joins its original's link group, a +/// copied pair is linked to each other under a fresh group, not to the originals. +#[test] +fn copy_dragging_linked_layers_relinks_the_copies() { + let (mut s, a, b, c, group) = linked_pair(); + let r = s.execute("layer.moveTo", json!({"layer": a.0, "target": c.0, "position": "above", "copy": true})).unwrap(); + let copy = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(link_group(&s, copy), group, "the lone copy-dragged copy joins the originals' link group"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links"); + + let r = s.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": c.0, "position": "above", "copy": true})).unwrap(); + let copies: Vec = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2); + let fresh = link_group(&s, copies[0]); + assert!(fresh.is_some() && fresh != group && link_group(&s, copies[1]) == fresh, "the copied pair is linked to each other under a fresh group"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links"); +} + +/// #11217 review: copies of a linked pair stay linked to each other under a new group, not the +/// originals', which keep theirs. +#[test] +fn duplicating_a_linked_pair_links_the_copies_to_each_other() { + let (mut s, a, b, _, group) = linked_pair(); + select_all(&mut s, &[a, b]); + let r = s.execute("layer.duplicate", json!({})).unwrap(); + let copies: Vec = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2); + let fresh = link_group(&s, copies[0]); + assert!(fresh.is_some() && fresh != group, "a group of their own: {fresh:?} vs {group:?}"); + assert_eq!(link_group(&s, copies[1]), fresh, "the copies are linked to each other"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep theirs"); + // Moving a copy moves its partner copy only. + let (a0, b0) = (bounds(&s, a), bounds(&s, b)); + s.execute("layer.translate", json!({"layer": copies[0].0, "dx": 3})).unwrap(); + assert_eq!(bounds(&s, copies[1]), b0.translate(3, 0)); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a0, b0)); +} + +/// #11217: Layer via Copy of one linked layer (no selection) joins its original's link group too +/// (measured): the same rule as `layer.duplicate`, on its own code path in `layer_via`. +#[test] +fn layer_via_copy_of_one_linked_layer_keeps_the_copy_in_its_link_group() { + let (mut s, a, b, _, group) = linked_pair(); + let layers = doc(&s).layers.len(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let copy = LayerId(s.execute("layer.new.layerViaCopy", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(&s).layers.len(), layers + 1, "only the active layer is copied"); + assert_eq!(link_group(&s, copy), group, "the lone copy joins the originals' link group"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links"); +} + /// #7538: `selection: true` moves the selection with the layers in the same step, as the Move /// tool does with several layers selected; without it the selection stays. #[test] diff --git a/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs index c7acbff4a0..af70e8c9fe 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs @@ -243,7 +243,7 @@ fn refreshing_type_layers_damages_only_those_layers() { let one = LayerId(s.execute("type.create", json!({"x": 100, "y": 100, "text": "One", "size": 20})).unwrap()["layer"].as_u64().unwrap()); let two = LayerId(s.execute("type.create", json!({"x": 1000, "y": 800, "text": "Two", "size": 20})).unwrap()["layer"].as_u64().unwrap()); let before = doc(&s); - let busy = s.refresh_type_layers(&[(before.id, one)]); + let busy = s.refresh_type_layers(&[crate::TypeLayerRef { doc: before.id, layer: one, first_snapshot: false }]); assert!(busy.is_empty()); let after = doc(&s); let d = damage(&s).expect("a bounded refresh"); diff --git a/crates/engine/src/cmd/magnetic_cmds.rs b/crates/engine/src/cmd/magnetic_cmds.rs index f13d96fa5c..50bd40fdd0 100644 --- a/crates/engine/src/cmd/magnetic_cmds.rs +++ b/crates/engine/src/cmd/magnetic_cmds.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use photocraft_algo::magnetic::{self, Settings, Tracer}; use photocraft_algo::selection as sel; -use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_doc::{ColorMode, DocId, Document, LayerId}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -23,9 +23,12 @@ const MAX_POINTS: usize = 100_000; /// Longest border one command traces (px, measured along the fastening points). const MAX_LENGTH: f64 = 200_000.0; -/// The pixels the Magnetic Lasso follows: the active layer's when it is a pixel layer (Photoshop's -/// tool sees only the current layer), else the composite. A snapshot of one document revision, -/// read lazily and cached while a border is drawn. +/// The pixels the Magnetic Lasso follows: the visible composite of an RGB document, whichever layer +/// is active (hidden layers add no edges). Documents in other modes (CMYK, Grayscale, Indexed, ...) +/// keep the older rule: the active layer's own pixels when it is a pixel layer, else the composite. +/// A snapshot of one document revision, read lazily (only the tiles near the border) and cached by +/// the tracer while a border is drawn; [`EdgeSource::is_current`] drops it when the document changes, +/// so the composite is rendered once per revision and tile, not once per pointer event. pub struct EdgeSource { doc: Arc, layer: Option, @@ -33,8 +36,12 @@ pub struct EdgeSource { tracer: Tracer, } -/// The active layer when it has pixels of its own. +/// The one layer whose pixels the border follows, or `None` for the composite: always the composite +/// in an RGB document; in other modes the active layer when it has pixels of its own. fn pixel_layer(d: &DocState) -> Option { + if d.doc.mode == ColorMode::Rgb { + return None; + } d.active_layer.filter(|id| d.doc.layer(*id).is_some_and(|l| l.surface().is_some())) } @@ -45,7 +52,13 @@ impl EdgeSource { Some(EdgeSource { doc: d.doc.clone(), layer: pixel_layer(d), revision: d.revision, tracer: Tracer::new(d.doc.bounds()) }) } - /// Whether this still shows the session's active document, layer and revision. + /// Whether this still shows the session's active document and revision (and, outside RGB + /// documents, active layer). + /// + /// When this returns `false` the caller must drop the `EdgeSource` and build a new one with + /// [`EdgeSource::new`]: [`EdgeSource::snap`] and [`EdgeSource::trace`] keep reading the old + /// snapshot and its cached pixels otherwise. The Magnetic Lasso tool does so in `source()` + /// (`crates/ui-egui/src/tools/magnetic_lasso_ui.rs`) before every snap and trace. pub fn is_current(&self, s: &Session) -> bool { s.active().is_some_and(|d| d.doc.id == self.doc.id && d.revision == self.revision && pixel_layer(d) == self.layer) } @@ -70,7 +83,7 @@ impl EdgeSource { } } -/// Straight RGBA8 pixels of `r` from the layer, or the composite without one. +/// Straight RGBA8 pixels of `r` from the layer, or the visible composite without one. fn read(doc: &Document, layer: Option, r: Rect) -> Vec<[u8; 4]> { match layer.and_then(|id| doc.layer(id)).and_then(|l| l.surface()) { Some(surface) => sel::rgba8_image(surface, r), diff --git a/crates/engine/src/cmd/magnetic_cmds/tests.rs b/crates/engine/src/cmd/magnetic_cmds/tests.rs index 39f7bef2bb..dd1c163346 100644 --- a/crates/engine/src/cmd/magnetic_cmds/tests.rs +++ b/crates/engine/src/cmd/magnetic_cmds/tests.rs @@ -65,16 +65,16 @@ fn modes_and_a_given_outline() { } #[test] -fn edges_come_from_the_active_pixel_layer() { +fn edges_come_from_the_visible_composite() { let mut s = session(); s.execute("layer.new.layer", json!({})).unwrap(); - // An empty layer has no edges: the point stays where it is. + // An empty layer on top adds no pixels: the edge of the Background's square still counts. let settings = Settings::default(); let mut src = EdgeSource::new(&s).unwrap(); - assert_eq!(src.snap([44.0, 50.0], settings), Some([44.0, 50.0])); + let p = src.snap([44.0, 50.0], settings).unwrap(); + assert!((p[0] - 40.0).abs() < 0.01, "{p:?}"); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.select", json!({"layer": bg.0})).unwrap(); - assert!(!src.is_current(&s), "another layer"); let mut src = EdgeSource::new(&s).unwrap(); assert!(src.is_current(&s)); let p = src.snap([44.0, 50.0], settings).unwrap(); diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 3043f080e6..ea6504f7a8 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -566,7 +566,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { } s.edit("Bitmap", |doc, active| { if (nw, nh) != (doc.size.width, doc.size.height) { - crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic); + crate::image_cmds::resample_doc(doc, CMD, nw, nh, photocraft_algo::resample::Resample::Bicubic)?; } doc.resolution_dpi = dpi; let w = doc.size.width as usize; diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 4d3c97b02b..8f190f445c 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -452,6 +452,18 @@ fn bitmap_output_resolution_resamples_and_sets_the_resolution() { assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128)); } +/// #9212: the Output resolution resamples the pixels outside the canvas too. A 10 px crop that +/// kept a 1000 px document's pixels, converted from 1 to 100 ppi, would grow them to 100000 px a +/// side (20 GB of gray and alpha at 8 bits, past the 8 GiB budget): refused before allocating. +#[test] +fn bitmap_output_resolution_budgets_the_hidden_pixels() { + let mut s = ramp(1000, 1000, 8, 1.0); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap(); + let err = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 100})).unwrap_err().to_string(); + assert!(err.contains("100000 x 100000 px is 10000 megapixels"), "{err}"); + assert_eq!((doc(&s).size.width, doc(&s).mode), (10, ColorMode::Grayscale)); +} + #[test] fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() { // The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the diff --git a/crates/engine/src/cmd/ops.rs b/crates/engine/src/cmd/ops.rs index b34aab7623..0d1b91aa1f 100644 --- a/crates/engine/src/cmd/ops.rs +++ b/crates/engine/src/cmd/ops.rs @@ -97,6 +97,63 @@ fn set_mask_with(s: &mut Session, p: &Value, label: &str, mask: Option Result { + let bad = |m: &str| EngineError::BadParams { cmd: "layer.layerMask.moveTo".into(), msg: m.into() }; + let from = layer_param(s, p)?; + let to = mask_destination(s, p)?; + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); + if from == to { + return Err(bad("the mask is already on that layer")); + } + let label = if copy { "Copy Layer Mask" } else { "Move Layer Mask" }; + s.edit(label, |doc, _| { + let mask = doc.layer(from).ok_or(EngineError::NoLayer(from))?.mask.clone().ok_or_else(|| bad("the layer has no layer mask"))?; + if doc.layer(to).ok_or(EngineError::NoLayer(to))?.mask.is_some() && !replace { + return Err(bad("the layer already has a layer mask (pass `replace`)")); + } + check_all_unlocked(doc, to)?; + if !copy { + check_all_unlocked(doc, from)?; + } + // The Background can't hold a mask, so it becomes a normal layer first, as for Add Mask. + crate::extra_cmds::background_to_layer_for_mask(doc, to); + doc.layer_mut(to).ok_or(EngineError::NoLayer(to))?.mask = Some(mask); + if !copy { + doc.layer_mut(from).ok_or(EngineError::NoLayer(from))?.mask = None; + } + Ok(()) + })?; + Ok(json!({"layer": from.0, "to": to.0, "copy": copy})) +} + +/// `layer.layerMask.moveTo`'s `to`: an id, or a name (as a recorded step keeps it) with +/// `toOccurrence` choosing among the layers that share it, in document order (required when +/// several do). +fn mask_destination(s: &Session, p: &Value) -> Result { + let bad = |m: String| EngineError::BadParams { cmd: "layer.layerMask.moveTo".into(), msg: m }; + match p.get("to") { + Some(Value::String(name)) => { + let occurrence = p + .get("toOccurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("`toOccurrence` must be a non-negative integer".into()))) + .transpose()?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let matches: Vec<_> = doc.walk().into_iter().filter(|(_, _, l)| &l.name == name).map(|(_, _, l)| l.id).collect(); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad(format!("multiple layers named `{name}`; specify `toOccurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad(format!("no layer named `{name}` at occurrence {index}"))) + } + Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad("`to` must be a layer id or name".into())), + None => Err(bad("missing `to`".into())), + } +} + /// Move a layer's pixels, linked mask and type by whole pixels (vectors move via /// `vector_cmds::translate_vectors`). pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { @@ -188,3 +245,110 @@ pub(super) fn merge_group(s: &mut Session, id: LayerId) -> Result { })?; Ok(json!({ "layer": id.0 })) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + /// #10485: dragging a layer's mask onto a group moves it there, ⌥ copies it (Photoshop). A + /// layer that has a mask already keeps it unless asked to replace it; one undo puts it back. + #[test] + fn a_layer_mask_moves_or_copies_onto_another_layer_or_group() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let group = s.execute("layer.groupLayers", json!({"layer": b})).unwrap()["layer"].as_u64().unwrap(); + let mask_of = |s: &Session, id: u64| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().mask.clone(); + let original = mask_of(&s, a).expect("a has a mask"); + // Move onto the group. + s.execute("layer.layerMask.moveTo", json!({"layer": a, "to": group})).unwrap(); + assert!(mask_of(&s, a).is_none(), "moved away"); + assert_eq!(mask_of(&s, group).unwrap().surface, original.surface, "the same mask on the group"); + // One undo puts it back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(mask_of(&s, a).is_some() && mask_of(&s, group).is_none()); + // ⌥: a copy. + s.execute("layer.layerMask.moveTo", json!({"layer": a, "to": group, "copy": true})).unwrap(); + assert!(mask_of(&s, a).is_some() && mask_of(&s, group).is_some()); + assert_eq!(mask_of(&s, group).unwrap().surface, original.surface, "the copy on the group"); + // The group gets a mask of its own: refused unless replacing, which leaves a's on it. + s.execute("layer.layerMask.delete", json!({"layer": group})).unwrap(); + s.execute("layer.layerMask.hideAll", json!({"layer": group})).unwrap(); + assert_ne!(mask_of(&s, group).unwrap().surface, original.surface, "the group's own mask differs"); + assert!(s.execute("layer.layerMask.moveTo", json!({"layer": a, "to": group})).is_err()); + s.execute("layer.layerMask.moveTo", json!({"layer": a, "to": group, "replace": true})).unwrap(); + assert!(mask_of(&s, a).is_none()); + assert_eq!(mask_of(&s, group).unwrap().surface, original.surface, "a's mask replaced the group's"); + // Bad input. + assert!(s.execute("layer.layerMask.moveTo", json!({"layer": a, "to": group})).is_err(), "no mask to move"); + assert!(s.execute("layer.layerMask.moveTo", json!({"layer": group, "to": group})).is_err(), "onto itself"); + assert!(s.execute("layer.layerMask.moveTo", json!({"layer": group})).is_err(), "no target"); + } + + /// #10485: dropping a mask back on its own layer's row is a no-op: the mask keeps its pixels, + /// enabled state and density, nothing else in the document changes, and no history step is added. + #[test] + fn dropping_a_layer_mask_on_its_own_layer_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + s.execute("layer.layerMask.edit", json!({"layer": a, "density": 40})).unwrap(); + s.execute("layer.layerMask.enabled", json!({"layer": a, "enabled": false})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mask = doc.layer(photocraft_doc::LayerId(a)).unwrap().mask.clone().expect("a has a mask"); + assert!(!mask.enabled && (mask.density - 0.4).abs() < 1e-6, "set up: disabled, 40% density"); + let history = s.active().unwrap().history.entries(); + for (copy, replace) in [(false, false), (true, false), (false, true), (true, true)] { + let r = s.execute("layer.layerMask.moveTo", json!({"layer": a, "to": a, "copy": copy, "replace": replace})); + assert!(r.is_err(), "copy: {copy}, replace: {replace}: refused"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(photocraft_doc::LayerId(a)).unwrap().mask.as_ref(), Some(&mask), "copy: {copy}, replace: {replace}: the mask is untouched"); + assert!(st.doc == doc, "copy: {copy}, replace: {replace}: the document is unchanged"); + assert_eq!(st.history.entries(), history, "copy: {copy}, replace: {replace}: no history step"); + } + } + + /// #10485: one undo after an ⌥-copy, or after a move or copy that replaces the target's own + /// mask, puts both layers' masks back exactly (pixels, enabled, density, feather). + #[test] + fn undo_restores_both_masks_after_a_copy_or_a_replace() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + s.execute("layer.layerMask.edit", json!({"layer": a, "density": 60, "feather": 3})).unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 18, "y": 2, "width": 6, "height": 20})).unwrap(); + s.execute("layer.layerMask.hideSelection", json!({"layer": b})).unwrap(); + s.execute("layer.layerMask.enabled", json!({"layer": b, "enabled": false})).unwrap(); + let c = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let mask_of = |s: &Session, id: u64| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().mask.clone(); + let (mask_a, mask_b) = (mask_of(&s, a).expect("a has a mask"), mask_of(&s, b).expect("b has a mask")); + assert_ne!(mask_a, mask_b, "set up: two different masks"); + let doc = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries().len(); + let cases = [ + ("⌥-copy onto a layer without a mask", json!({"layer": a, "to": c, "copy": true}), c), + ("⌥-copy replacing a mask", json!({"layer": a, "to": b, "copy": true, "replace": true}), b), + ("move replacing a mask", json!({"layer": a, "to": b, "replace": true}), b), + ]; + for (case, params, to) in cases { + s.execute("layer.layerMask.moveTo", params).unwrap(); + assert_eq!(mask_of(&s, to).as_ref(), Some(&mask_a), "{case}: the target has a's mask"); + assert_eq!(s.active().unwrap().history.entries().len(), history + 1, "{case}: one history step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_of(&s, a).as_ref(), Some(&mask_a), "{case}: a's mask is back"); + assert_eq!(mask_of(&s, b).as_ref(), Some(&mask_b), "{case}: b's mask is back"); + assert!(mask_of(&s, c).is_none(), "{case}: c has no mask again"); + assert!(s.active().unwrap().doc == doc, "{case}: the document is as before"); + } + } +} diff --git a/crates/engine/src/cmd/photo_cmds.rs b/crates/engine/src/cmd/photo_cmds.rs index 9dd7da094b..e9a98c6184 100644 --- a/crates/engine/src/cmd/photo_cmds.rs +++ b/crates/engine/src/cmd/photo_cmds.rs @@ -16,12 +16,12 @@ use photocraft_algo::transform::{Homography, Interp, warp_surface}; use photocraft_algo::warp::warp_mesh_surface; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, LayerMask, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; use crate::commands::CommandSpec; -use crate::file_cmds::{file_name, flattened, import, list_images, read_file}; +use crate::file_cmds::{file_name, flattened, flattened_checked, import, import_with_options, list_images, read_file, read_file_limited}; use crate::{EngineError, Result, Session}; /// Wall-clock timer for the `ms` fields of results (always 0 on the web, where @@ -64,11 +64,248 @@ pub(crate) struct Source { /// Loaded sources, the common pixel format and the first source's ICC profile. type Loaded = (Vec, PixelFormat, Option>>); -fn load_sources(s: &Session, p: &Value, cmd: &str) -> Result { - let mut docs: Vec<(String, Document)> = Vec::new(); +/// Pixel bytes every surface `doc` holds, counting a tile shared between surfaces once. +fn document_pixels(doc: &Document) -> usize { + let mut seen = std::collections::HashSet::new(); + let mut total = 0usize; + doc.for_each_surface(|surface| { + for (_, tile) in surface.tiles() { + if seen.insert(std::sync::Arc::as_ptr(tile) as usize) { + total = total.saturating_add(tile.len()); + } + } + }); + total +} + +/// Bytes a `w` × `h` surface allocates in `bpp`: whole 256 px tiles, as [`Surface`] stores them. +fn surface_bytes(w: usize, h: usize, bpp: usize) -> usize { + if w == 0 || h == 0 { + return 0; + } + let side = TILE_SIZE as usize; + w.div_ceil(side).saturating_mul(h.div_ceil(side)).saturating_mul(side.saturating_mul(side)).saturating_mul(bpp) +} + +/// `bytes` for people ("12 MiB", "900 KiB", "17 B"). +fn bytes_text(bytes: usize) -> String { + if bytes >= 1 << 20 { + format!("{} MiB", bytes >> 20) + } else if bytes >= 1 << 10 { + format!("{} KiB", bytes >> 10) + } else { + format!("{bytes} B") + } +} + +/// Keeps a Photomerge's sources inside the command's memory budget while they are loaded. +/// +/// `None` (Merge to HDR Pro) has no budget and loads as before. With a budget, a file is read +/// only up to what is left, its import is lowered to that remainder through the importer's own +/// preflight, and the decoded document plus the surfaces flattening allocates are charged +/// before the source is flattened. +struct SourceLoad { + budget: Option, + /// Pixels of the flattened sources kept so far. + retained: usize, + /// Sources admitted so far (for the error message). + count: usize, +} + +impl SourceLoad { + fn new(budget: Option) -> Self { + Self { budget, retained: 0, count: 0 } + } + + /// What one more source needs while it is flattened: the document's own pixels, the source + /// surface, the flatten's working and output surfaces, and a file's encoded bytes. + fn needed(&self, doc: &Document, bpp: usize, encoded: usize) -> usize { + let b = doc.bounds(); + let output = surface_bytes(b.width() as usize, b.height() as usize, bpp); + self.retained.saturating_add(encoded).saturating_add(document_pixels(doc)).saturating_add(output.saturating_mul(2)) + } + + fn exceeds(&self, cmd: &str, doc: &Document, bpp: usize, encoded: usize, budget: usize) -> EngineError { + let b = doc.bounds(); + crate::allocation::error( + cmd, + &format!( + "loading {} source image(s) plus another {}×{} one needs about {}, which exceeds the {} budget; use fewer or smaller images, or raise \"memoryBudget\"", + self.count, + b.width(), + b.height(), + bytes_text(self.needed(doc, bpp, encoded)), + bytes_text(budget) + ), + ) + } + + /// Admits and charges one `doc` flattened in `bpp`, or fails before the flatten allocates. + fn admit(&mut self, cmd: &str, doc: &Document, bpp: usize, encoded: usize) -> Result<()> { + let Some(budget) = self.budget else { + self.count += 1; + return Ok(()); + }; + if self.needed(doc, bpp, encoded) > budget { + return Err(self.exceeds(cmd, doc, bpp, encoded, budget)); + } + let b = doc.bounds(); + self.retained = self.retained.saturating_add(surface_bytes(b.width() as usize, b.height() as usize, bpp)); + self.count += 1; + Ok(()) + } + + /// What is left of the budget for the next source. + fn remaining(&self, cmd: &str) -> Result { + let Some(budget) = self.budget else { return Ok(usize::MAX) }; + budget + .checked_sub(self.retained) + .filter(|left| *left > 0) + .ok_or_else(|| crate::allocation::error(cmd, &format!("the sources loaded so far use the whole {} budget", bytes_text(budget)))) + } + + /// Reads and imports one file, bounding the encoded read and the decode by what is left. + /// `None` (Merge to HDR Pro) reads and imports as before. + fn load_file(&mut self, s: &Session, path: &str, cmd: &str) -> Result<(String, Document, usize)> { + let name = file_name(path); + if self.budget.is_none() { + let bytes = read_file(path)?; + return Ok((name.clone(), import(&name, &bytes)?, bytes.len())); + } + let left = self.remaining(cmd)?; + let bytes = read_file_limited(path, u64::try_from(left).unwrap_or(u64::MAX)).map_err(|e| match e.kind() { + // Only a file over what is left, or one memory cannot hold, is a memory error; a + // missing or unreadable file is a file error, as without a budget. + std::io::ErrorKind::FileTooLarge | std::io::ErrorKind::OutOfMemory => { + crate::allocation::error(cmd, &format!("{e} (only {} is left of the budget for a source file's bytes)", bytes_text(left))) + } + _ => EngineError::Other(format!("{path}: {e}")), + })?; + let available = left.saturating_sub(bytes.len()); + let opts = self.import_options(s, &name, &bytes, available, cmd)?; + let doc = import_with_options(&name, &bytes, &opts).map_err(|e| match e { + // The caller limit is this command's remainder, not the machine's hard limit. + EngineError::PsdHardBudget { required, limit, .. } if limit == u64::try_from(available).unwrap_or(u64::MAX) => crate::allocation::error( + cmd, + &format!( + "decoding {name} needs about {}, which exceeds the {} left; use fewer or smaller images, or raise \"memoryBudget\"", + bytes_text(usize::try_from(required).unwrap_or(usize::MAX)), + bytes_text(available) + ), + ), + e => e, + })?; + Ok((name, doc, bytes.len())) + } + + /// The importer options that lower this import to what is left after the retained sources + /// and the encoded buffer: the importer's own PSD/TIFF preflight, or a flat decode limit. + /// The decoded document and the flatten are still charged by [`Self::admit`]. + fn import_options(&self, s: &Session, name: &str, bytes: &[u8], available: usize, cmd: &str) -> Result { + use photocraft_io::ImportPreflight; + let mut opts = crate::jobs::import_options(s.prefs()); + if available == 0 { + return Err(crate::allocation::error( + cmd, + &format!("the sources already loaded and {name}'s {} encoded bytes use the whole budget", bytes_text(bytes.len())), + )); + } + let limit = u64::try_from(available).unwrap_or(u64::MAX); + match photocraft_io::import_preflight(name, bytes) { + ImportPreflight::Psd => opts.psd_max_pixel_bytes = limit, + ImportPreflight::Tiff => { + self.admit_tiff_header(name, bytes, available, cmd)?; + opts.psd_max_pixel_bytes = limit; + } + ImportPreflight::Flat => { + // The decoded buffer, the document built from it and the flatten's output are + // alive together, so keep a third of the remainder for each. + let decode = available / 3; + if decode == 0 { + return Err(crate::allocation::error( + cmd, + &format!("no room is left to decode {name} after the {} of sources already loaded", bytes_text(self.retained)), + )); + } + opts.flat_max_pixel_bytes = u64::try_from(decode).unwrap_or(u64::MAX); + } + ImportPreflight::Unbounded => { + return Err(bad( + cmd, + format!( + "{name}: this format has no memory estimate before decoding, so Photomerge cannot fit it into its budget; use a PSD, TIFF or flat raster file, or open it first and add the open document" + ), + )); + } + } + Ok(opts) + } + + /// Refuses a TIFF whose default page alone would not fit in `available`; a layered TIFF is + /// also bounded by the importer's own PSD-style preflight. + fn admit_tiff_header(&self, name: &str, bytes: &[u8], available: usize, cmd: &str) -> Result<()> { + let info = photocraft_codecs::tiff_info(bytes).map_err(|e| bad(cmd, format!("{name}: {e}")))?; + let page = info.default_page().and_then(|i| info.pages.get(i)).ok_or_else(|| bad(cmd, format!("{name}: the TIFF has no readable page")))?; + let bpp = u64::from(page.samples_per_pixel).saturating_mul(u64::from(page.bits_per_sample).div_ceil(8)); + let needed = u64::from(page.width).saturating_mul(u64::from(page.height)).saturating_mul(bpp); + let available = u64::try_from(available).unwrap_or(u64::MAX); + if needed > available { + let needed = usize::try_from(needed).unwrap_or(usize::MAX); + let available = usize::try_from(available).unwrap_or(usize::MAX); + return Err(crate::allocation::error( + cmd, + &format!( + "{name} needs about {} for its {}×{} page, which exceeds the {} left", + bytes_text(needed), + page.width, + page.height, + bytes_text(available) + ), + )); + } + Ok(()) + } +} + +/// Sources collected so far, with the common format and the first source's ICC profile. +#[derive(Default)] +struct Sources { + out: Vec, + fmt: Option, + icc: Option>>, +} + +impl Sources { + /// Flattens `d` into the common format, admitted by `load` first. + fn add(&mut self, load: &mut SourceLoad, name: String, d: &Document, encoded: usize, cmd: &str) -> Result<()> { + let source_fmt = d.pixel_format(); + // Registration and blending work in RGB, so the result is RGB. + let output_fmt = *self.fmt.get_or_insert_with(|| { + if matches!(source_fmt.mode, ColorMode::Cmyk | ColorMode::Lab | ColorMode::Multichannel) { + PixelFormat::new(ColorMode::Rgb, source_fmt.sample, true) + } else { + source_fmt + } + }); + if self.out.is_empty() && source_fmt.mode == output_fmt.mode { + self.icc = d.icc_profile.clone(); + } + let info = d.metadata.exif.as_ref().map(|e| exif::read(e)).unwrap_or_default(); + load.admit(cmd, d, output_fmt.bytes_per_pixel(), encoded)?; + self.out.push(Source { name, surf: flattened_checked(d, output_fmt, cmd)?, w: d.size.width as usize, h: d.size.height as usize, exif: info }); + Ok(()) + } +} + +/// Flattens the open documents and files named by `p` into common-format sources. With a +/// `budget`, each source is read, imported and admitted by [`SourceLoad`]; `None` (Merge to HDR +/// Pro) only flattens, as before. `cmd` names the command in errors. +fn load_sources(s: &Session, p: &Value, cmd: &str, budget: Option) -> Result { + let mut src = Sources::default(); + let mut load = SourceLoad::new(budget); if p.get("useOpenDocuments").and_then(Value::as_bool).unwrap_or(false) { for d in s.documents() { - docs.push((d.doc.name.clone(), (*d.doc).clone())); + src.add(&mut load, d.doc.name.clone(), &d.doc, 0, cmd)?; } } let paths: Vec = match p.get("paths").or_else(|| p.get("input")) { @@ -77,27 +314,14 @@ fn load_sources(s: &Session, p: &Value, cmd: &str) -> Result { _ => Vec::new(), }; for path in &paths { - let bytes = read_file(path)?; - docs.push((file_name(path), import(&file_name(path), &bytes)?)); + let (name, doc, encoded) = load.load_file(s, path, cmd)?; + src.add(&mut load, name, &doc, encoded, cmd)?; } - if docs.is_empty() { + if src.out.is_empty() { return Err(bad(cmd, "pass \"paths\" (files or a folder) or \"useOpenDocuments\": true")); } - let first = &docs[0].1; - let mut fmt = first.pixel_format(); - if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab | ColorMode::Multichannel) { - // Registration and blending work in RGB; like Photoshop, the result is RGB. - fmt = PixelFormat::new(ColorMode::Rgb, fmt.sample, true); - } - let icc = (first.pixel_format().mode == fmt.mode).then(|| first.icc_profile.clone()).flatten(); - let out = docs - .into_iter() - .map(|(name, d)| { - let info = d.metadata.exif.as_ref().map(|e| exif::read(e)).unwrap_or_default(); - Source { name, surf: flattened(&d, fmt), w: d.size.width as usize, h: d.size.height as usize, exif: info } - }) - .collect(); - Ok((out, fmt, icc)) + let Some(fmt) = src.fmt else { return Err(bad(cmd, "no readable image sources")) }; + Ok((src.out, fmt, src.icc)) } /// Luminance and coverage of `surf` over `area`, box-downsampled by `k`. @@ -259,20 +483,103 @@ pub(crate) fn plane_matrix(pl: &Placement, origin: (f64, f64)) -> Option usize { + let env_mb = std::env::var("PHOTOCRAFT_PHOTOMERGE_BUDGET_MB").ok(); + budget_from(p.get("memoryBudget").and_then(Value::as_u64), env_mb.as_deref(), crate::memory::physical_memory()) +} + +/// The budget: `param` (bytes) wins, then `env_mb` (a budget in MiB), then half of `ram` bytes +/// (4 GiB assumed when unknown) but at least 1 GiB. +fn budget_from(param: Option, env_mb: Option<&str>, ram: Option) -> usize { + if let Some(b) = param { + return usize::try_from(b).unwrap_or(usize::MAX); + } + if let Some(mb) = env_mb.and_then(|v| v.trim().parse::().ok()) { + return usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX); + } + let ram = ram.unwrap_or(4 << 30); + usize::try_from((ram / 2).max(1 << 30)).unwrap_or(usize::MAX) +} + +/// Estimated peak working memory of a Photomerge and the part of it that stays allocated while +/// bands are built (the ROIs, weights and accumulators; what is left of the budget goes to the +/// bands, see [`panorama::band_bytes_bound`]). `source_sizes` and `placed_sizes` are the source +/// and warped sizes of the placed images, `cw` × `ch` the canvas, `cc` its colour channels and +/// `bpp` its bytes per pixel. +/// +/// The peak is the largest of the phases, since each frees what the next no longer needs: warping +/// (sources and warped images), ROI conversion (ROIs, weights, warped images), the bands (ROIs, +/// accumulators and the largest band), the collapse (ROIs, accumulators and the running result), +/// the output (ROIs, blended colour, composite and output layers) and the fill (layers, composite +/// and the fill's own planes). +#[allow(clippy::too_many_arguments)] +fn estimate_photomerge_memory( + source_sizes: &[(usize, usize)], + placed_sizes: &[(usize, usize)], + cw: usize, + ch: usize, + cc: usize, + bpp: usize, + blend: bool, + fill: bool, +) -> (usize, usize) { + let px_sum = |sizes: &[(usize, usize)]| sizes.iter().map(|&(w, h)| w.saturating_mul(h)).fold(0usize, usize::saturating_add); + let sources = px_sum(source_sizes).saturating_mul(bpp); + let roi_px = px_sum(placed_sizes); + let warped = roi_px.saturating_mul(bpp); + if !blend { + return (sources.saturating_add(warped).max(warped), 0); + } + // ROI colours, alpha and the seam weights (f32 each). + let rois_weights = roi_px.saturating_mul((cc + 2) * 4); + let canvas_px = cw.saturating_mul(ch); + // The accumulator and weight-sum pyramids (4/3 of a plane each). + let accumulators = canvas_px.saturating_mul((cc + 1) * 4 * 4 / 3); + let blend_out = canvas_px.saturating_mul((cc + 1) * 4); + let output_layers = roi_px.saturating_mul(bpp + 1); + let composite = canvas_px.saturating_mul(bpp); + let fill_bytes = if fill { composite.saturating_add(24 << 20) } else { 0 }; + + // The levels `seam_blend` blends with. + let levels = panorama::blend_levels(cw, ch).min(7); + let max_band_cost = placed_sizes.iter().map(|&(w, h)| panorama::band_bytes_bound(w, h, cc, levels, cw, ch)).max().unwrap_or(0); + + let fixed_during_bands = rois_weights.saturating_add(accumulators); + let phases = [ + sources.saturating_add(warped), + rois_weights.saturating_add(warped), + fixed_during_bands.saturating_add(max_band_cost), + fixed_during_bands.saturating_add(canvas_px.saturating_mul(cc * 4)), + rois_weights.saturating_add(blend_out).saturating_add(composite).saturating_add(output_layers), + output_layers.saturating_add(composite).saturating_add(fill_bytes), + ]; + (phases.into_iter().max().unwrap_or(0), fixed_during_bands) +} + /// Splits a surface region into colour channels and coverage. -fn roi_of(surf: &Surface, area: Rect) -> RoiImage { +fn roi_of(surf: &Surface, area: Rect) -> Result { let fmt = surf.format(); let n = fmt.channels(); let cc = fmt.mode.color_channels(); - let raw = surf.read_region(area); let (w, h) = (area.width() as usize, area.height() as usize); - let mut px = Vec::with_capacity(w * h * cc); - let mut alpha = Vec::with_capacity(w * h); - for q in raw.chunks_exact(n) { - px.extend_from_slice(&q[..cc]); - alpha.push(if fmt.alpha { q[n - 1] } else { 1.0 }); + let total_px = w.checked_mul(h).and_then(|v| v.checked_mul(cc)).ok_or_else(|| crate::allocation::error("Photomerge", "ROI size overflowed"))?; + let total_alpha = w.checked_mul(h).ok_or_else(|| crate::allocation::error("Photomerge", "ROI size overflowed"))?; + let mut px = crate::allocation::capacity(total_px, "Photomerge ROI")?; + let mut alpha = crate::allocation::capacity(total_alpha, "Photomerge alpha")?; + if w > 0 && h > 0 { + let mut row = vec![0.0f32; w * n]; + for y in 0..h { + let row_rect = Rect::new(area.x0, area.y0 + y as i32, area.x1, area.y0 + y as i32 + 1); + surf.read_region_into(row_rect, &mut row); + for q in row.chunks_exact(n) { + px.extend_from_slice(&q[..cc]); + alpha.push(if fmt.alpha { q[n - 1] } else { 1.0 }); + } + } } - RoiImage { x0: area.x0, y0: area.y0, w, h, ch: cc, px, alpha } + Ok(RoiImage { x0: area.x0, y0: area.y0, w, h, ch: cc, px, alpha }) } /// Placements, source sizes and the panorama offset (for vignette removal). @@ -287,25 +594,26 @@ pub(crate) struct Blended { pub vignette: [f64; 2], } -/// Gain compensation (+ optional vignetting), seams in `order`, multi-band blending. Inputs are -/// surfaces already in panorama coordinates with alpha. `geometry` enables vignette removal. -pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seamless: bool, gains: bool, geometry: Option) -> Blended { - let n = warped.len(); - let fmt = warped.first().map_or(PixelFormat::RGBA8, Surface::format); - let mut rois: Vec = warped - .iter() - .map(|s| { - let b = s.content_bounds().intersect(&canvas); - if b.is_empty() { RoiImage { x0: 0, y0: 0, w: 0, h: 0, ch: fmt.mode.color_channels(), px: Vec::new(), alpha: Vec::new() } } else { roi_of(s, b) } - }) - .collect(); +/// How [`seam_blend`] blends: `seamless` blends the bands (else hard seams), `gains` compensates +/// exposure and colour, `geometry` enables vignette removal. `budget` is the bytes of bands built +/// at a time and the cap on any single band (an error beyond it); `None` is 5 GiB batches with +/// no cap. +pub(crate) struct BlendOptions<'a> { + pub seamless: bool, + pub gains: bool, + pub geometry: Option>, + pub budget: Option, +} + +/// Applies the photometric model (gains, vignetting) to `rois`; returns the gains and the vignette. +fn apply_photometric(rois: &mut [RoiImage], fmt: PixelFormat, geometry: Option>, seamless: bool, gains: bool) -> (Vec>, [f64; 2]) { // Photometric model in panorama coordinates relative to the canvas origin used by placements. let shifted: Option> = geometry.map(|(pl, _, off)| pl.iter().map(|p| p.translated(off.0, off.1)).collect()); let geo = match (&shifted, geometry) { (Some(pl), Some((_, sizes, _))) => Some((pl.as_slice(), sizes)), _ => None, }; - let photo = panorama::photometric(&rois, geo, seamless && gains, geo.is_some()); + let photo = panorama::photometric(rois, geo, seamless && gains, geo.is_some()); let vig = photo.vignette != [0.0, 0.0]; for (i, roi) in rois.iter_mut().enumerate() { let ch = roi.ch; @@ -335,6 +643,12 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam } } } + (photo.gains, photo.vignette) +} + +/// Per-image seam weights (1 inside an image's seam region, else 0) over each ROI. +fn compute_seam_weights(rois: &[RoiImage], canvas: Rect, order: &[usize]) -> Vec> { + let n = rois.len(); // Seams on a ≤ 1000 px grid. let (cw, chh) = (canvas.width().max(1) as usize, canvas.height().max(1) as usize); let s = cw.max(chh).div_ceil(1000).max(1); @@ -377,8 +691,7 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam let (lx, ly) = (x - r.x0, y - r.y0); if lx < 0 || ly < 0 || lx as usize >= r.w || ly as usize >= r.h { 0.0 } else { r.alpha[ly as usize * r.w + lx as usize] } }; - let weights: Vec> = rois - .iter() + rois.iter() .enumerate() .map(|(i, roi)| { (0..roi.w * roi.h) @@ -401,65 +714,131 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam }) .collect() }) - .collect(); + .collect() +} + +/// The blended composite over `canvas`, its alpha the coverage. +fn build_composite(canvas: Rect, fmt: PixelFormat, blend: &[f32], cov: &[f32]) -> Surface { + let (cw, chh) = (canvas.width().max(1) as usize, canvas.height().max(1) as usize); + let cc = fmt.mode.color_channels(); + let nch = fmt.channels(); + let mut composite = Surface::new(fmt); + if cw > 0 && chh > 0 { + let mut row_comp = vec![0.0f32; cw * nch]; + for y in 0..chh { + for x in 0..cw { + let i = y * cw + x; + for c in 0..cc { + let v = blend[i * cc + c]; + row_comp[x * nch + c] = if fmt.sample == SampleType::F32 { v } else { v.clamp(0.0, 1.0) }; + } + row_comp[x * nch + nch - 1] = cov[i]; + } + let row_area = Rect::new(canvas.x0, canvas.y0 + y as i32, canvas.x1, canvas.y0 + y as i32 + 1); + composite.write_region(row_area, &row_comp); + } + composite.prune(); + } + composite +} + +/// Per input, its pixels (blended inside its seam region when `seamless`) and its seam mask. +fn build_layers( + rois: &[RoiImage], + weights: &[Vec], + canvas: Rect, + fmt: PixelFormat, + blend: &[f32], + seamless: bool, + check_cancel: Option<&dyn Fn() -> Result<()>>, +) -> Result> { + let cw = canvas.width().max(1) as usize; + let cc = fmt.mode.color_channels(); + let nch = fmt.channels(); + let mut layers = Vec::with_capacity(rois.len()); + for (roi, wt) in rois.iter().zip(weights) { + if let Some(check) = check_cancel { + check()?; + } + let area = Rect::new(roi.x0, roi.y0, roi.x0 + roi.w as i32, roi.y0 + roi.h as i32); + let mut ls = Surface::new(fmt); + let mut ms = Surface::with_default(PixelFormat::GRAY8, &[0.0]); + if !area.is_empty() && roi.w > 0 && roi.h > 0 { + let mut row_px = vec![0.0f32; roi.w * nch]; + let mut row_mask = vec![0.0f32; roi.w]; + for y in 0..roi.h { + let v = (roi.y0 - canvas.y0) as usize + y; + for x in 0..roi.w { + let u = (roi.x0 - canvas.x0) as usize + x; + let k = y * roi.w + x; + let g = v * cw + u; + let inside = wt[k] > 0.5; + for c in 0..cc { + row_px[x * nch + c] = if inside && seamless { data_clamp(blend[g * cc + c], fmt) } else { roi.px[k * cc + c] }; + } + row_px[x * nch + nch - 1] = roi.alpha[k]; + row_mask[x] = wt[k]; + } + let row_area = Rect::new(roi.x0, roi.y0 + y as i32, roi.x0 + roi.w as i32, roi.y0 + y as i32 + 1); + ls.write_region(row_area, &row_px); + ms.write_region(row_area, &row_mask); + } + } + ls.prune(); + ms.prune(); + layers.push((ls, ms)); + } + Ok(layers) +} + +/// Gain compensation (+ optional vignetting), seams in `order`, multi-band blending. Inputs are +/// surfaces already in panorama coordinates with alpha. `geometry` enables vignette removal. +pub(crate) fn seam_blend( + warped: Vec, + canvas: Rect, + order: &[usize], + opt: BlendOptions<'_>, + check_cancel: Option<&dyn Fn() -> Result<()>>, +) -> Result { + let n = warped.len(); + let fmt = warped.first().map_or(PixelFormat::RGBA8, Surface::format); + let mut rois: Vec = Vec::with_capacity(n); + for s in warped { + if let Some(check) = check_cancel { + check()?; + } + let b = s.content_bounds().intersect(&canvas); + if b.is_empty() { + rois.push(RoiImage { x0: 0, y0: 0, w: 0, h: 0, ch: fmt.mode.color_channels(), px: Vec::new(), alpha: Vec::new() }); + } else { + rois.push(roi_of(&s, b)?); + } + } + let (gains, vignette) = apply_photometric(&mut rois, fmt, opt.geometry, opt.seamless, opt.gains); + let weights = compute_seam_weights(&rois, canvas, order); // Shift ROIs to canvas-relative coordinates for the blend, and back after: a shifted copy // would hold every source's pixels twice (#7758). for r in &mut rois { r.x0 -= canvas.x0; r.y0 -= canvas.y0; } - let levels = if seamless { panorama::blend_levels(cw, chh).min(7) } else { 1 }; - let (blend, cov) = panorama::multiband(cw, chh, &rois, &weights, levels); + if let Some(check) = check_cancel { + check()?; + } + let (cw, chh) = (canvas.width().max(1) as usize, canvas.height().max(1) as usize); + let levels = if opt.seamless { panorama::blend_levels(cw, chh).min(7) } else { 1 }; + let band_budget = opt.budget.unwrap_or(panorama::BAND_BUDGET); + let (blend, cov) = panorama::try_multiband_batched(cw, chh, &rois, &weights, levels, band_budget, opt.budget)?; for r in &mut rois { r.x0 += canvas.x0; r.y0 += canvas.y0; } - let cc = fmt.mode.color_channels(); - let nch = fmt.channels(); - let mut composite = Surface::new(fmt); - { - let mut data = vec![0.0f32; cw * chh * nch]; - for i in 0..cw * chh { - for c in 0..cc { - let v = blend[i * cc + c]; - data[i * nch + c] = if fmt.sample == SampleType::F32 { v } else { v.clamp(0.0, 1.0) }; - } - data[i * nch + nch - 1] = cov[i]; - } - composite.write_region(canvas, &data); - composite.prune(); + if let Some(check) = check_cancel { + check()?; } - let layers = rois - .iter() - .zip(&weights) - .map(|(roi, wt)| { - let area = Rect::new(roi.x0, roi.y0, roi.x0 + roi.w as i32, roi.y0 + roi.h as i32); - let mut px = vec![0.0f32; roi.w * roi.h * nch]; - let mut mask = vec![0.0f32; roi.w * roi.h]; - for k in 0..roi.w * roi.h { - let (u, v) = ((roi.x0 - canvas.x0) as usize + k % roi.w, (roi.y0 - canvas.y0) as usize + k / roi.w); - let g = v * cw + u; - let inside = wt[k] > 0.5; - for c in 0..cc { - px[k * nch + c] = if inside && seamless { data_clamp(blend[g * cc + c], fmt) } else { roi.px[k * cc + c] }; - } - px[k * nch + nch - 1] = roi.alpha[k]; - mask[k] = wt[k]; - } - let mut ls = Surface::new(fmt); - if !area.is_empty() { - ls.write_region(area, &px); - } - ls.prune(); - let mut ms = Surface::with_default(PixelFormat::GRAY8, &[0.0]); - if !area.is_empty() { - ms.write_region(area, &mask); - } - ms.prune(); - (ls, ms) - }) - .collect(); - Blended { layers, composite, gains: photo.gains, vignette: photo.vignette } + let composite = build_composite(canvas, fmt, &blend, &cov); + let layers = build_layers(&rois, &weights, canvas, fmt, &blend, opt.seamless, check_cancel)?; + Ok(Blended { layers, composite, gains, vignette }) } fn data_clamp(v: f32, fmt: PixelFormat) -> f32 { @@ -494,7 +873,10 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { let vignette = p.get("vignetteRemoval").and_then(Value::as_bool).unwrap_or(false); let geometric = p.get("geometricCorrection").and_then(Value::as_bool).unwrap_or(false); let fill = p.get("contentAwareFill").and_then(Value::as_bool).unwrap_or(false); - let (srcs, fmt, icc) = load_sources(s, p, cmd)?; + // Resolved before loading: the sources are flattened and retained one at a time, so the + // budget has to bound them before the first surface is allocated, not after they all exist. + let budget = photomerge_memory_budget(p); + let (mut srcs, fmt, icc) = load_sources(s, p, cmd, Some(budget))?; if srcs.len() < 2 { return Err(bad(cmd, "Photomerge needs two or more images")); } @@ -515,37 +897,57 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { let t_reg = t0.ms() / 1000.0; ctx.check()?; ctx.progress(0.35, "Photomerge: warping images"); - let placed: Vec = (0..srcs.len()).filter(|&i| al.placements[i].is_some()).collect(); + let placed: Vec = (0..srcs.len()).filter(|&i| al.placements.get(i).is_some_and(Option::is_some)).collect(); // Parallel to `placed`. - let placements: Vec<&Placement> = placed.iter().filter_map(|&i| al.placements[i].as_ref()).collect(); - let failed: Vec = (0..srcs.len()).filter(|&i| al.placements[i].is_none()).map(|i| srcs[i].name.clone()).collect(); + let placements: Vec<&Placement> = placed.iter().filter_map(|&i| al.placements.get(i).and_then(Option::as_ref)).collect(); + let failed: Vec = (0..srcs.len()).filter(|&i| al.placements.get(i).is_none_or(Option::is_none)).map(|i| srcs[i].name.clone()).collect(); // Canvas: union of the placed images. let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + // Each placed image's warped extent (the bounds of its placement). + let mut extents: Vec<(f64, f64)> = Vec::with_capacity(placed.len()); for (&i, pl) in placed.iter().zip(&placements) { let b = pl.bounds(srcs[i].w as f64, srcs[i].h as f64); + extents.push((b[2] - b[0], b[3] - b[1])); x0 = x0.min(b[0]); y0 = y0.min(b[1]); x1 = x1.max(b[2]); y1 = y1.max(b[3]); } let (cw, ch) = ((x1 - x0).ceil(), (y1 - y0).ceil()); - if !(cw.is_finite() && ch.is_finite()) || cw > 30_000.0 || ch > 30_000.0 || cw * ch > 6.0e8 { + if !(cw.is_finite() && ch.is_finite()) || cw <= 0.0 || ch <= 0.0 || cw > 30_000.0 || ch > 30_000.0 || cw * ch > 6.0e8 { return Err(EngineError::Other(format!("the panorama would be {cw}×{ch} px; try another layout"))); } let canvas = Rect::new(0, 0, cw as i32, ch as i32); let offset = (-x0.floor(), -y0.floor()); - let warped: Vec = { - placed - .iter() - .zip(&placements) - .enumerate() - .map(|(k, (&i, pl))| { - ctx.check()?; - ctx.progress(0.35 + 0.25 * k as f32 / placed.len().max(1) as f32, ""); - warp_placed(&srcs[i].surf, Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32), (0.0, 0.0), pl, offset, Interp::Bicubic) - }) - .collect::>>()? - }; + let sizes: Vec<(usize, usize)> = placed.iter().map(|&i| (srcs[i].w, srcs[i].h)).collect(); + let cc = fmt.mode.color_channels(); + let bpp = fmt.bytes_per_pixel(); + // The estimate follows the warped images (one pixel of slack for sub-pixel bounds), clipped to the canvas. + let warped_sizes: Vec<(usize, usize)> = + extents.iter().map(|&(w, h)| ((w.ceil() + 1.0).max(0.0).min(cw) as usize, (h.ceil() + 1.0).max(0.0).min(ch) as usize)).collect(); + let (est_needed, fixed_during_bands) = estimate_photomerge_memory(&sizes, &warped_sizes, cw as usize, ch as usize, cc, bpp, blend, fill); + if est_needed > budget { + let mb_est = est_needed / (1 << 20); + let mb_budget = budget / (1 << 20); + return Err(crate::allocation::error( + "Photomerge", + &format!("estimated memory of {mb_est} MiB exceeds budget of {mb_budget} MiB; use fewer or smaller images, or raise \"memoryBudget\""), + )); + } + for (i, s) in srcs.iter_mut().enumerate() { + if al.placements.get(i).is_none_or(Option::is_none) { + s.surf = Surface::new(fmt); + } + } + let mut warped: Vec = Vec::with_capacity(placed.len()); + for (k, (&i, pl)) in placed.iter().zip(&placements).enumerate() { + ctx.check()?; + ctx.progress(0.35 + 0.25 * k as f32 / placed.len().max(1) as f32, ""); + let src_rect = Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32); + let w = warp_placed(&srcs[i].surf, src_rect, (0.0, 0.0), pl, offset, Interp::Bicubic)?; + srcs[i].surf = Surface::new(fmt); + warped.push(w); + } let t_warp = t0.ms() / 1000.0; ctx.check()?; ctx.progress(0.6, "Photomerge: blending images"); @@ -555,10 +957,12 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { let ref_pos = placed.iter().position(|&i| i == al.reference).unwrap_or(0); if blend { let pls: Vec = placements.iter().map(|p| (*p).clone()).collect(); - let sizes: Vec<(usize, usize)> = placed.iter().map(|&i| (srcs[i].w, srcs[i].h)).collect(); let order = seam_order(&warped, ref_pos); let geo = vignette.then_some((pls.as_slice(), sizes.as_slice(), offset)); - let b = seam_blend(&warped, canvas, &order, true, true, geo); + let remaining_band_budget = budget.saturating_sub(fixed_during_bands); + let check_cancel = || ctx.check(); + let opt = BlendOptions { seamless: true, gains: true, geometry: geo, budget: Some(remaining_band_budget) }; + let b = seam_blend(warped, canvas, &order, opt, Some(&check_cancel))?; for (k, (px, mask)) in b.layers.into_iter().enumerate() { let mut l = Layer::new(srcs[placed[k]].name.clone(), LayerContent::Raster(px)); let mut m = LayerMask::hide_all(); @@ -617,16 +1021,16 @@ fn content_aware_fill(comp: &Surface, canvas: Rect) -> Surface { let (w, h) = (canvas.width() as usize, canvas.height() as usize); let k = ((w * h) as f64 / 1.5e6).sqrt().ceil().max(1.0) as usize; let (sw, sh) = (w.div_ceil(k), h.div_ceil(k)); - let full = comp.read_region(canvas); let cc = n - 1; let mut small = vec![0.0f32; sw * sh * cc]; let mut hole = vec![false; sw * sh]; let mut acc_a = vec![0.0f32; sw * sh]; let mut cnt = vec![0.0f32; sw * sh]; for y in 0..h { + let row = comp.read_region(Rect::new(canvas.x0, canvas.y0 + y as i32, canvas.x1, canvas.y0 + y as i32 + 1)); for x in 0..w { let i = (y / k) * sw + x / k; - let px = &full[(y * w + x) * n..(y * w + x + 1) * n]; + let px = &row[x * n..(x + 1) * n]; let a = px[cc]; for c in 0..cc { small[i * cc + c] += px[c] * a; @@ -644,10 +1048,13 @@ fn content_aware_fill(comp: &Surface, canvas: Rect) -> Surface { } let source: Vec = hole.iter().map(|h| !h).collect(); let filled = fill(sw, sh, cc, &small, &hole, &source, &FillOptions::default()); - let mut out = vec![0.0f32; w * h * n]; + let mut s = Surface::new(fmt); for y in 0..h { + let area = Rect::new(canvas.x0, canvas.y0 + y as i32, canvas.x1, canvas.y0 + y as i32 + 1); + let full = comp.read_region(area); + let mut out = vec![0.0f32; w * n]; for x in 0..w { - let o = (y * w + x) * n; + let o = x * n; let a = full[o + cc]; let i = (y / k) * sw + x / k; for c in 0..cc { @@ -664,9 +1071,8 @@ fn content_aware_fill(comp: &Surface, canvas: Rect) -> Surface { } out[o + cc] = 1.0; } + s.write_region(area, &out); } - let mut s = Surface::new(fmt); - s.write_region(canvas, &out); s.prune(); s } @@ -685,7 +1091,7 @@ fn shift_rgba(px: &[[f32; 4]], w: usize, h: usize, dx: i32, dy: i32) -> Vec<[f32 fn merge_to_hdr(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.mergeToHdrPro"; let t0 = Stopwatch::start(); - let (srcs, _fmt, icc) = load_sources(s, p, cmd)?; + let (srcs, _fmt, icc) = load_sources(s, p, cmd, None)?; if srcs.len() < 2 { return Err(bad(cmd, "Merge to HDR Pro needs two or more exposures")); } @@ -969,7 +1375,7 @@ pub fn specs() -> Vec { label: "Photomerge…", menu: &["File", "Automate"], shortcut: None, - params: r##"{"paths":[str]|folder,"useOpenDocuments":bool=false,"layout":"auto|perspective|cylindrical|spherical|collage|reposition","blend":bool=true,"vignetteRemoval":bool=false,"geometricCorrection":bool=false,"contentAwareFill":bool=false,"focalLength":mm=0 (35 mm equivalent; 0 = EXIF or estimated)} → new document, one masked layer per image"##, + params: r##"{"paths":[str]|folder,"useOpenDocuments":bool=false,"layout":"auto|perspective|cylindrical|spherical|collage|reposition","blend":bool=true,"vignetteRemoval":bool=false,"geometricCorrection":bool=false,"contentAwareFill":bool=false,"focalLength":mm=0 (35 mm equivalent; 0 = EXIF or estimated),"memoryBudget":bytes?=half of RAM} → new document, one masked layer per image"##, enabled: always, run: photomerge, journal: true, diff --git a/crates/engine/src/cmd/photo_cmds/tests.rs b/crates/engine/src/cmd/photo_cmds/tests.rs index 8d0152a986..a161099d46 100644 --- a/crates/engine/src/cmd/photo_cmds/tests.rs +++ b/crates/engine/src/cmd/photo_cmds/tests.rs @@ -50,6 +50,15 @@ fn crop(px: &[[f32; 4]], sw: usize, x0: usize, y0: usize, w: usize, h: usize, ga .collect() } +/// A fresh temporary folder for a test's files. +#[cfg(not(target_arch = "wasm32"))] +fn temp_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-photomerge-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + #[test] fn photomerge_open_documents_reposition_and_perspective() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { @@ -350,3 +359,272 @@ fn crop_and_straighten_makes_documents() { let (c, e) = (l.rgba(d.size.width as i32 / 2, 6)[0], l.rgba(d.size.width as i32 / 2, d.size.height as i32 - 6)[0]); assert!((c - e).abs() < 0.05); } + +#[test] +fn photomerge_rejects_insufficient_memory_budget() { + let (sw, sh) = (520, 220); + let sc = scene(sw, sh, 5); + let mut s = Session::new(); + for (k, x0) in [0usize, 180].into_iter().enumerate() { + s.add_document(doc_from(&format!("img{k}"), &crop(&sc, sw, x0, 10, 260, 200, 1.0), 260, 200, SampleType::U8), None); + } + assert_eq!(s.documents().len(), 2); + // Budget of 1024 bytes is insufficient for the panorama. + let err = s.execute("file.automate.photomerge", json!({"useOpenDocuments": true, "layout": "reposition", "memoryBudget": 1024})).unwrap_err(); + assert!(err.to_string().contains("not enough memory for file.automate.photomerge"), "{err}"); + assert!(err.to_string().contains("exceeds the"), "{err}"); + // Session state and documents must remain intact. + assert_eq!(s.documents().len(), 2); + assert_eq!(s.active().unwrap().doc.name, "img1"); +} + +#[test] +fn photomerge_estimate_is_the_largest_phase_not_the_sum() { + // Two 100×100 RGB8 images (4 bytes per pixel in the canvas) on a 150×100 canvas. + let sizes = [(100, 100), (100, 100)]; + let (cw, ch, cc, bpp) = (150, 100, 3, 4); + let band = panorama::band_bytes_bound(100, 100, cc, panorama::blend_levels(cw, ch).min(7), cw, ch); + assert_eq!(band, 150 * 100 * 4 * 12); + let (rois, accumulators) = (20_000 * 20, 15_000 * 21); + let (peak, fixed) = estimate_photomerge_memory(&sizes, &sizes, cw, ch, cc, bpp, true, false); + assert_eq!(fixed, rois + accumulators); + // The bands phase (ROIs, accumulators and one band) is the largest here. + let bands = rois + accumulators + band; + let collapse = rois + accumulators + 15_000 * 12; + let output = rois + 15_000 * 16 + 15_000 * 4 + 20_000 * 5; + assert!(bands > collapse && bands > output); + assert_eq!(peak, bands); + // A sum of every buffer would be far more. + assert!(peak < rois + accumulators + 15_000 * 16 + 20_000 * 5 + band); + // Filling adds its own phase, which does not exceed the bands here. + let (with_fill, _) = estimate_photomerge_memory(&sizes, &sizes, cw, ch, cc, bpp, true, true); + assert_eq!(with_fill, (20_000 * 5 + 15_000 * 4 + 15_000 * 4 + (24 << 20)).max(peak)); + // Without blending only the sources and warped layers are held. + assert_eq!(estimate_photomerge_memory(&sizes, &sizes, cw, ch, cc, bpp, false, false), (80_000 + 80_000, 0)); +} + +#[test] +fn photomerge_estimate_fits_six_large_photos_in_eight_gib() { + let photos = [(6000, 4000); 6]; + let (peak, fixed) = estimate_photomerge_memory(&photos, &photos, 27_000, 4_112, 3, 4, true, false); + assert!(peak < 8 << 30, "{} MiB", peak >> 20); + assert!(fixed < peak); +} + +/// A flat opaque RGBA8 surface covering `area`. +fn flat_surface(area: Rect, v: f32) -> Surface { + let fmt = PixelFormat::RGBA8; + let n = (area.width() * area.height()) as usize; + let data: Vec = (0..n).flat_map(|_| [v, v, v, 1.0]).collect(); + let mut s = Surface::new(fmt); + s.write_region(area, &data); + s +} + +#[test] +fn seam_blend_rejects_a_band_larger_than_its_budget() { + let (a, b) = (Rect::new(0, 0, 64, 64), Rect::new(32, 0, 96, 64)); + let warped = vec![flat_surface(a, 0.3), flat_surface(b, 0.6)]; + let opt = BlendOptions { seamless: true, gains: true, geometry: None, budget: Some(1000) }; + let err = seam_blend(warped, Rect::new(0, 0, 96, 64), &[0, 1], opt, None).err().expect("a 1000 byte budget can't hold a band"); + assert!(err.to_string().contains("not enough memory to allocate"), "{err}"); + // Without a budget the same blend succeeds. + let warped = vec![flat_surface(a, 0.3), flat_surface(b, 0.6)]; + let opt = BlendOptions { seamless: true, gains: true, geometry: None, budget: None }; + assert!(seam_blend(warped, Rect::new(0, 0, 96, 64), &[0, 1], opt, None).is_ok()); +} + +#[test] +fn photomerge_succeeds_with_a_large_enough_memory_budget() { + let (sw, sh) = (520, 220); + let sc = scene(sw, sh, 5); + let mut s = Session::new(); + for (k, x0) in [0usize, 180].into_iter().enumerate() { + s.add_document(doc_from(&format!("img{k}"), &crop(&sc, sw, x0, 10, 260, 200, 1.0), 260, 200, SampleType::U8), None); + } + let r = s.execute("file.automate.photomerge", json!({"useOpenDocuments": true, "layout": "reposition", "memoryBudget": 1u64 << 30})).unwrap(); + assert_eq!(r["layers"], 2, "{r}"); + assert_eq!(s.documents().len(), 3); +} + +#[test] +fn photomerge_memory_budget_precedence_and_defaults() { + // The param wins over the environment and the machine. + assert_eq!(budget_from(Some(123), Some("64"), Some(64 << 30)), 123); + // Then the environment variable (MiB). + assert_eq!(budget_from(None, Some(" 64 "), Some(64 << 30)), 64 << 20); + // An unparsable variable falls through to the machine. + assert_eq!(budget_from(None, Some("lots"), Some(8 << 30)), 4 << 30); + // Half of RAM with no upper ceiling. + assert_eq!(budget_from(None, None, Some(64 << 30)), 32 << 30); + // At least 1 GiB, and 4 GiB of RAM assumed when unknown. + assert_eq!(budget_from(None, None, Some(1 << 30)), 1 << 30); + assert_eq!(budget_from(None, None, None), 2 << 30); + // Huge values saturate instead of panicking or truncating. + assert_eq!(budget_from(Some(u64::MAX), None, None), usize::try_from(u64::MAX).unwrap_or(usize::MAX)); + let _ = budget_from(None, Some("18446744073709551615"), None); + let _ = budget_from(None, None, Some(u64::MAX)); +} + +#[test] +fn photomerge_rejects_oversized_sparse_source_before_flattening() { + let mut s = Session::new(); + s.add_document(Document::new("sparse", Size::new(100_000, 100_000), ColorMode::Rgb, SampleType::U8), None); + let err = load_sources(&s, &json!({"useOpenDocuments": true}), "file.automate.photomerge", None).err().expect("oversized canvas must be refused"); + assert!(err.to_string().contains("too large to flatten"), "{err}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.active().unwrap().doc.name, "sparse"); +} + +#[test] +fn photomerge_source_load_charges_the_document_and_both_flatten_surfaces() { + let cmd = "file.automate.photomerge"; + let px = scene(260, 200, 3); + let one = doc_from("a", &px, 260, 200, SampleType::U8); + let bpp = one.pixel_format().bytes_per_pixel(); + let output = surface_bytes(260, 200, bpp); + let cost = SourceLoad::new(None).needed(&one, bpp, 0); + assert_eq!(document_pixels(&one), output, "a covered 260×200 surface is two 256 px tiles"); + assert_eq!(cost, 3 * output, "the document, the kept surface and the flatten's working surface"); + + let mut load = SourceLoad::new(Some(cost)); + assert!(load.admit(cmd, &one, bpp, 0).is_ok(), "the first source fits exactly"); + assert_eq!(load.retained, output); + let err = load.admit(cmd, &one, bpp, 0).expect_err("the second source exceeds the budget"); + assert!(err.to_string().contains("not enough memory for file.automate.photomerge"), "{err}"); + assert!(err.to_string().contains("260×200"), "{err}"); + assert!(err.to_string().contains("exceeds the"), "{err}"); + assert_eq!(load.retained, output, "a refused source is not counted as retained"); + + // An encoded file's bytes are held during the import as well. + assert!(SourceLoad::new(Some(cost + 99)).admit(cmd, &one, bpp, 100).is_err()); + assert!(SourceLoad::new(Some(cost + 100)).admit(cmd, &one, bpp, 100).is_ok()); + + // Every layer of the decoded document is charged, not just the two output surfaces. + let mut layered = one.clone(); + layered.layers.extend(doc_from("b", &px, 260, 200, SampleType::U8).layers); + assert_eq!(document_pixels(&layered), 2 * output); + assert!(SourceLoad::new(Some(cost + output - 1)).admit(cmd, &layered, bpp, 0).is_err()); + assert!(SourceLoad::new(Some(cost + output)).admit(cmd, &layered, bpp, 0).is_ok()); + + // Hostile sizes saturate and fail a finite budget instead of wrapping under it. + assert!(SourceLoad::new(Some(1 << 40)).admit(cmd, &one, usize::MAX, usize::MAX).is_err()); + // Without a budget (Merge to HDR Pro) nothing is assessed. + assert!(SourceLoad::new(None).admit(cmd, &one, usize::MAX, usize::MAX).is_ok()); +} + +#[test] +fn photomerge_rejects_over_budget_sources_before_flattening() { + let (sw, sh) = (520, 220); + let sc = scene(sw, sh, 9); + let mut s = Session::new(); + for (k, x0) in [0usize, 180].into_iter().enumerate() { + s.add_document(doc_from(&format!("img{k}"), &crop(&sc, sw, x0, 10, 260, 200, 1.0), 260, 200, SampleType::U8), None); + } + // Enough for the first source, but not for a second one after its own surface is kept. + let one = &s.documents()[0].doc; + let bpp = one.pixel_format().bytes_per_pixel(); + let cost = SourceLoad::new(None).needed(one, bpp, 0); + let budget = cost + surface_bytes(260, 200, bpp) / 2; + let err = s.execute("file.automate.photomerge", json!({"useOpenDocuments": true, "layout": "reposition", "memoryBudget": budget as u64})).unwrap_err(); + assert!(err.to_string().contains("not enough memory for file.automate.photomerge"), "{err}"); + assert!(err.to_string().contains("exceeds the"), "{err}"); + // Refused during loading, before alignment, warping or a new document. + assert_eq!(s.documents().len(), 2); + assert_eq!(s.active().unwrap().doc.name, "img1"); +} + +#[test] +fn photomerge_rejects_a_panorama_over_budget_after_loading() { + let (sw, sh) = (520, 220); + let sc = scene(sw, sh, 5); + let mut s = Session::new(); + for (k, x0) in [0usize, 180].into_iter().enumerate() { + s.add_document(doc_from(&format!("img{k}"), &crop(&sc, sw, x0, 10, 260, 200, 1.0), 260, 200, SampleType::U8), None); + } + // Enough to load both sources, but below the warp/blend estimate, so the post-alignment + // check refuses the job rather than the loader. + let one = &s.documents()[0].doc; + let bpp = one.pixel_format().bytes_per_pixel(); + let budget = SourceLoad::new(None).needed(one, bpp, 0) + surface_bytes(260, 200, bpp); + let err = s.execute("file.automate.photomerge", json!({"useOpenDocuments": true, "layout": "reposition", "memoryBudget": budget as u64})).unwrap_err(); + assert!(err.to_string().contains("estimated memory"), "{err}"); + assert!(err.to_string().contains("exceeds budget"), "{err}"); + assert_eq!(s.documents().len(), 2); + assert_eq!(s.active().unwrap().doc.name, "img1"); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn photomerge_refuses_an_oversized_file_before_reading_or_decoding_it() { + let cmd = "file.automate.photomerge"; + let dir = temp_dir("oversized-file"); + let path = dir.join("big.png"); + // Not a valid image: reaching the decoder would report a format error instead. + std::fs::write(&path, vec![0xAB; 4096]).unwrap(); + let err = load_sources(&Session::new(), &json!({"paths": [path.to_string_lossy()]}), cmd, Some(1024)) + .err() + .expect("a file larger than the budget must be refused"); + assert!(err.to_string().contains("was not read"), "{err}"); + assert!(err.to_string().contains("only 1 KiB is left"), "{err}"); + assert!(!err.to_string().contains("unrecognized"), "{err}"); + assert!(!err.to_string().contains("image format"), "{err}"); +} + +/// A source that cannot be opened is a file error under a budget too, not a memory error. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn photomerge_reports_a_missing_file_as_a_file_error_under_a_budget() { + let cmd = "file.automate.photomerge"; + let missing = temp_dir("missing-file").join("does-not-exist.png"); + let err = load_sources(&Session::new(), &json!({"paths": [missing.to_string_lossy()]}), cmd, Some(1 << 30)).err().expect("a missing file must fail"); + let message = err.to_string(); + assert!(!message.contains("not enough memory"), "{message}"); + assert!(message.contains("does-not-exist.png"), "{message}"); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn photomerge_file_source_is_charged_beside_the_sources_already_loaded() { + let cmd = "file.automate.photomerge"; + let px = scene(260, 200, 4); + let open = doc_from("open", &px, 260, 200, SampleType::U8); + let bpp = open.pixel_format().bytes_per_pixel(); + let cost = SourceLoad::new(None).needed(&open, bpp, 0); + let png = photocraft_io::export(&open, "source.png", &Default::default()).unwrap().bytes; + let dir = temp_dir("file-budget"); + let path = dir.join("source.png"); + std::fs::write(&path, &png).unwrap(); + // Exactly enough for the file's encoded bytes, its document and its flatten surfaces, plus + // two bytes: alone it loads, but not beside an open source that keeps its surface. + let budget = cost + png.len() + 2; + + let alone = Session::new(); + let (loaded, _, _) = load_sources(&alone, &json!({"paths": [path.to_string_lossy()]}), cmd, Some(budget)).expect("the file alone fits"); + assert_eq!(loaded.len(), 1); + + let mut s = Session::new(); + s.add_document(open, None); + let err = load_sources(&s, &json!({"useOpenDocuments": true, "paths": [path.to_string_lossy()]}), cmd, Some(budget)) + .err() + .expect("the retained open source leaves no room for the file's own pixels"); + let message = err.to_string(); + assert!(message.contains("exceeds the") || message.contains("limit exceeded"), "{message}"); + assert_eq!(s.documents().len(), 1); +} + +#[test] +fn merge_to_hdr_pro_accepts_sources_without_the_photomerge_budget() { + let (sw, sh) = (120, 90); + let sc = scene(sw, sh, 11); + let mut s = Session::new(); + for k in 0..2 { + s.add_document(doc_from(&format!("exp{k}"), &crop(&sc, sw, 0, 0, sw, sh, 1.0 + k as f32), sw, sh, SampleType::U8), None); + } + // A 1-byte Photomerge budget refuses the sources; Merge to HDR Pro passes no budget and + // still loads them, so its behaviour is unchanged. + assert!(load_sources(&s, &json!({"useOpenDocuments": true}), "file.automate.photomerge", Some(1)).is_err()); + let (srcs, fmt, _) = load_sources(&s, &json!({"useOpenDocuments": true}), "file.automate.mergeToHdrPro", None).unwrap(); + assert_eq!(srcs.len(), 2); + assert_eq!(fmt, PixelFormat::RGBA8); +} diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index 07f7f50eb0..3adbee6f06 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -34,46 +34,71 @@ pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64; /// File bytes from `data` (base64) or `path`, plus a default group name. pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, String)> { - file_bytes_within(p, cmd, what, MAX_FILE_BYTES) + file_bytes_limited(p, cmd, what, MAX_FILE_BYTES) } -/// [`file_bytes`] with a size cap of `max` bytes. -fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec, String)> { +/// Load preset bytes with a format-specific limit; `data` takes precedence over `path`. +/// Inline base64 returns an empty group name; a file returns its filename stem. +/// The encoded-length check bounds base64 allocation, then decoded length is checked exactly. +/// File reads are capped at `max_bytes + 1` so growth after the metadata check is still rejected. +/// This helper does not authorize paths; automation callers must enforce their workspace policy. +pub(crate) fn file_bytes_limited(p: &Value, cmd: &str, what: &str, max_bytes: u64) -> Result<(Vec, String)> { if let Some(d) = p.get("data") { let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?; - if s.len() as u64 > max / 3 * 4 + 4 { + if s.len() as u64 > max_bytes / 3 * 4 + 4 { return Err(bad(cmd, "`data` is too large")); } let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?; + if bytes.len() as u64 > max_bytes { + return Err(bad(cmd, "`data` is too large")); + } return Ok((bytes, String::new())); } let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?; let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); - if len > max { + if len > max_bytes { return Err(bad(cmd, format!("{path}: file too large ({len} bytes)"))); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let file = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = read_preset_bytes(file, cmd, path, max_bytes)?; let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); Ok((bytes, stem)) } +/// Bound the actual stream independently of metadata, which can become stale before reading. +/// One extra byte distinguishes an exact-limit file from an oversized or growing input. +fn read_preset_bytes(reader: impl std::io::Read, cmd: &str, path: &str, max_bytes: u64) -> Result> { + use std::io::Read; + let mut bytes = Vec::new(); + reader.take(max_bytes.saturating_add(1)).read_to_end(&mut bytes).map_err(|error| EngineError::Other(format!("{path}: {error}")))?; + if bytes.len() as u64 > max_bytes { + return Err(bad(cmd, format!("{path}: file too large"))); + } + Ok(bytes) +} + /// The preset names in use, for naming new presets. -struct UniqueNames { - /// Every name in use, ASCII-lowercased. +pub(crate) struct UniqueNames { + /// Every name in use, normalized according to the comparison mode. taken: std::collections::HashSet, - /// The next number to try for each repeated name (lowercased). + /// The next number to try for each normalized repeated name. next: std::collections::HashMap, + exact: bool, } impl UniqueNames { - fn new<'a>(names: impl IntoIterator) -> Self { - UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new() } + pub(crate) fn new<'a>(names: impl IntoIterator) -> Self { + UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new(), exact: false } } - /// A name not in use (case-insensitive): `name`, `name 2`, `name 3`… It is in use from then on. - fn unique(&mut self, name: &str) -> String { - let key = name.to_ascii_lowercase(); + pub(crate) fn new_exact<'a>(names: impl IntoIterator) -> Self { + UniqueNames { taken: names.into_iter().map(str::to_string).collect(), next: std::collections::HashMap::new(), exact: true } + } + + /// A name not in use under the selected comparison mode: `name`, `name 2`, `name 3`… It is in use from then on. + pub(crate) fn unique(&mut self, name: &str) -> String { + let key = if self.exact { name.to_string() } else { name.to_ascii_lowercase() }; if self.taken.insert(key.clone()) { return name.to_string(); } @@ -101,7 +126,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { move |ctx| { ctx.progress(0.0, "Importing brushes"); let p = ¶ms; - let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; + let (bytes, stem) = file_bytes_limited(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; let group = match p.get("group") { Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(), None if !stem.is_empty() => stem, @@ -348,6 +373,14 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { None => "Imported Gradients".to_string(), }; let grads = photocraft_psd::grd::parse(&bytes).map_err(|e| bad(cmd, format!("not a readable gradient file (.grd): {e}")))?; + // Same default as the group insertion below and the documented contract (#10640). + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); + let mut taken = + UniqueNames::new_exact(s.presets.gradients.iter().filter(|g| !replace || g.name != group).flat_map(|g| g.items.iter().map(|i| i.name.as_str()))); + let original_names: Vec = + grads.iter().enumerate().map(|(i, g)| if g.name.trim().is_empty() { format!("Gradient {}", i + 1) } else { g.name.trim().to_string() }).collect(); + let mut reserved = taken.taken.clone(); + reserved.extend(original_names.iter().cloned()); let mut warnings = Vec::new(); let mut items = Vec::new(); let (mut noise, mut midpoints) = (0, false); @@ -365,6 +398,15 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { opacity.clear(); } let name = if g.name.trim().is_empty() { format!("Gradient {}", i + 1) } else { g.name.trim().to_string() }; + let name = if taken.taken.insert(name.clone()) { + name + } else { + std::mem::swap(&mut taken.taken, &mut reserved); + let unique = taken.unique(&name); + std::mem::swap(&mut taken.taken, &mut reserved); + taken.taken.insert(unique.clone()); + unique + }; items.push(GradientPreset { name, stops, opacity }); } if noise > 0 { @@ -379,7 +421,7 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { let names: Vec = items.iter().map(|g| g.name.clone()).collect(); let st = &mut s.presets; // As with brushes: a new group beside one of the same name unless `replace` (#10640). - let group = if p.get("replace").and_then(Value::as_bool).unwrap_or(false) { + let group = if replace { st.gradients.retain(|g| g.name != group); group } else { @@ -392,6 +434,27 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + /// A growing stream must fail the actual-read check after at most cap + 1 bytes; + /// the exact-limit control stays valid, including a zero-byte budget. + #[test] + fn preset_stream_limit_does_not_depend_on_metadata() { + for cap in [0_u64, 2] { + let mut exact = std::io::Cursor::new(vec![7; cap as usize]); + assert_eq!(super::read_preset_bytes(&mut exact, "test", "fixture", cap).unwrap(), vec![7; cap as usize]); + let mut growing = std::io::Cursor::new(vec![7; 32]); + let error = super::read_preset_bytes(&mut growing, "test", "fixture", cap).unwrap_err(); + assert!(error.to_string().contains("file too large"), "{error}"); + assert_eq!(growing.position(), cap + 1, "the reader must not consume the rest of a growing source"); + } + } + #[test] + fn decoded_preset_data_cannot_exceed_its_format_budget() { + // Both payloads have four encoded characters: checking base64 length alone is insufficient. + let at_limit = serde_json::json!({"data": "AQI="}); + let over_limit = serde_json::json!({"data": "AQID"}); + assert_eq!(super::file_bytes_limited(&at_limit, "test", "preset", 2).unwrap().0, [1, 2]); + assert!(super::file_bytes_limited(&over_limit, "test", "preset", 2).is_err()); + } use super::*; use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12}; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV}; @@ -502,6 +565,104 @@ mod tests { assert_eq!(s.tools.presets.len(), n); } + #[test] + fn duplicate_gradient_names_remain_independently_selectable() { + use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + let gradients: Vec<_> = [0.2, 0.8] + .into_iter() + .map(|red| GrdGradient { + name: "Duplicate".into(), + stops: vec![ + GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([red, 0.0, 0.0]) }, + GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 1.0, 1.0]) }, + ], + ..Default::default() + }) + .collect(); + let data = photocraft_paint::tile::b64_encode(&write(&gradients)); + let mut s = Session::new(); + // Re-importing over the group replaces it (`replace`, #10640), so it keeps the same names. + for _ in 0..2 { + let result = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Duplicates", "replace": true})).unwrap(); + assert_eq!(result["imported"], json!(["Duplicate", "Duplicate 2"])); + } + s.execute("gradient.presets.select", json!({"preset": "Duplicate"})).unwrap(); + let first = s.presets.gradient.clone(); + s.execute("gradient.presets.select", json!({"preset": "Duplicate 2"})).unwrap(); + assert_ne!(first.stops, s.presets.gradient.stops); + let mut saved = s.presets.to_json(&s); + let groups = saved["gradients"].as_array_mut().unwrap(); + let group = groups.iter_mut().find(|g| g["name"] == "Duplicates").unwrap(); + group["items"][1]["name"] = json!("Duplicate"); + s.load_presets_json(saved); + let restored = s.presets.gradients.iter().find(|g| g.name == "Duplicates").unwrap(); + assert_eq!(restored.items[1].name, "Duplicate 2"); + assert_eq!(s.presets.gradient.name, "Duplicate 2"); + let result = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Other", "replace": false})).unwrap(); + assert_eq!(result["imported"], json!(["Duplicate 3", "Duplicate 4"])); + } + + /// Re-importing without `replace` (the default) adds a sibling group, so its presets must get + /// names distinct from the existing group's, keeping name-only selection unambiguous. + #[test] + fn default_gradient_reimport_keeps_names_unique_across_sibling_groups() { + use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + let gradients = vec![GrdGradient { + name: "Sunset".into(), + stops: vec![ + GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) }, + GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 1.0, 1.0]) }, + ], + ..Default::default() + }]; + let data = photocraft_paint::tile::b64_encode(&write(&gradients)); + let mut s = Session::new(); + let first = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Sky"})).unwrap(); + assert_eq!(first["imported"], json!(["Sunset"])); + let second = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Sky"})).unwrap(); + assert_eq!(second["group"], json!("Sky (2)")); + assert_eq!(second["imported"], json!(["Sunset 2"])); + // Explicit `replace` drops the same-named group, so the original name is free again. + let third = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Sky", "replace": true})).unwrap(); + assert_eq!(third["group"], json!("Sky")); + assert_eq!(third["imported"], json!(["Sunset"])); + } + + #[test] + fn gradient_import_preserves_original_and_case_distinct_names() { + use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + let gradients: Vec<_> = ["Foo", "Foo", "Foo 2", "FOO"] + .into_iter() + .enumerate() + .map(|(i, name)| GrdGradient { + name: name.into(), + stops: vec![ + GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([i as f32 / 4.0, 0.0, 0.0]) }, + GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 1.0, 1.0]) }, + ], + ..Default::default() + }) + .collect(); + let mut s = Session::new(); + let result = s + .execute( + "gradient.presets.importGrd", + json!({ + "data": photocraft_paint::tile::b64_encode(&write(&gradients)), "group": "Names" + }), + ) + .unwrap(); + assert_eq!(result["imported"], json!(["Foo", "Foo 3", "Foo 2", "FOO"])); + for (name, red) in [("Foo", 0.0), ("Foo 3", 0.25), ("Foo 2", 0.5), ("FOO", 0.75)] { + s.execute("gradient.presets.select", json!({"preset": name})).unwrap(); + let current = s.presets.gradient.clone(); + assert_eq!(current.name, name); + assert_eq!(current.stops[0].1, crate::presets::gradients::StopColor::Rgb([red, 0.0, 0.0])); + } + let mut tools = UniqueNames::new(["Foo"]); + assert_eq!(tools.unique("FOO"), "FOO 2"); + } + #[test] fn grd_import_adds_a_gradient_group() { use crate::presets::gradients::StopColor; @@ -675,16 +836,16 @@ mod tests { let path = dir.join("cap.abr"); std::fs::write(&path, [0u8; 10]).unwrap(); let p = json!({"path": path.to_string_lossy()}); - assert!(file_bytes_within(&p, "t", "f", 10).is_ok()); - assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); + assert!(file_bytes_limited(&p, "t", "f", 10).is_ok()); + assert!(file_bytes_limited(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); // A sparse file between the two caps: refused as a .grd, which is checked before reading. let big = dir.join("big.grd"); std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap(); let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err(); assert!(err.to_string().contains("too large"), "{err}"); let data = json!({"data": "A".repeat(16)}); - assert!(file_bytes_within(&data, "t", "f", 12).is_ok()); - assert!(file_bytes_within(&data, "t", "f", 8).is_err()); + assert!(file_bytes_limited(&data, "t", "f", 12).is_ok()); + assert!(file_bytes_limited(&data, "t", "f", 8).is_err()); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/engine/src/cmd/print_cmds.rs b/crates/engine/src/cmd/print_cmds.rs index e8b0064e2b..ba09fdea23 100644 --- a/crates/engine/src/cmd/print_cmds.rs +++ b/crates/engine/src/cmd/print_cmds.rs @@ -476,8 +476,9 @@ fn system_printers(s: &mut Session, p: &Value) -> Result { // ---------- PDF ---------- -/// Paper sizes in points (portrait). -pub const PAPERS: [(&str, f64, f64); 8] = [ +/// Paper sizes in points (portrait). The original list keeps its order and sizes; the Japanese +/// photo, postcard and JIS sizes follow it (millimetres × 72/25.4). +pub const PAPERS: &[(&str, f64, f64)] = &[ ("letter", 612.0, 792.0), ("legal", 612.0, 1008.0), ("tabloid", 792.0, 1224.0), @@ -486,8 +487,76 @@ pub const PAPERS: [(&str, f64, f64); 8] = [ ("a5", 419.53, 595.28), ("4x6", 288.0, 432.0), ("5x7", 360.0, 504.0), + // Japanese postcards (はがき and 往復はがき), the JIS B series that Japanese printing and + // Excel use (B5 182×257, B4 257×364 mm), A6 and the photo sizes L判, 2L判, KG判, 六切, 四切. + ("postcard", 283.46, 419.53), + ("double-postcard", 419.53, 566.93), + ("b5", 515.91, 728.50), + ("b4", 728.50, 1031.81), + ("a6", 297.64, 419.53), + ("l", 252.28, 360.00), + ("2l", 360.00, 504.57), + ("kg", 289.13, 430.87), + ("6cut", 575.43, 720.00), + ("4cut", 720.00, 864.57), ]; +/// Japanese customary names and other spellings for the papers in [`PAPERS`], matched after +/// [`normalize_paper`]: `はがき` is `postcard`, `L判` is `l`, `JIS B5` is `b5`, and so on. +const PAPER_ALIASES: &[(&str, &str)] = &[ + ("はがき", "postcard"), + ("ハガキ", "postcard"), + ("葉書", "postcard"), + ("ポストカード", "postcard"), + ("往復はがき", "double-postcard"), + ("往復ハガキ", "double-postcard"), + ("JIS B5", "b5"), + ("B5 (JIS)", "b5"), + ("JIS B4", "b4"), + ("B4 (JIS)", "b4"), + ("L判", "l"), + ("Lサイズ", "l"), + ("L-size", "l"), + ("2L判", "2l"), + ("2Lサイズ", "2l"), + ("KG判", "kg"), + ("KGサイズ", "kg"), + ("六切", "6cut"), + ("6切", "6cut"), + ("四切", "4cut"), + ("4切", "4cut"), +]; + +/// The form [`paper_size`] matches names in: lower case, with whitespace and punctuation ignored +/// and `×`/`*` read as `x`, so `L判`, `l-size` and `l` are one name, `4 × 6` is `4x6`, and +/// `B5(JIS)` and `JIS B5.` are `b5`. +fn normalize_paper(name: &str) -> String { + name.chars() + .map(|c| match c { + '×' | '*' => 'x', + c => c, + }) + .filter(|c| !c.is_whitespace() && !c.is_ascii_punctuation() && !matches!(c, '(' | ')' | '、' | '。' | ',' | '.')) + .collect::() + .to_lowercase() +} + +/// The portrait size in points of a named paper, `None` for unknown names. Case, spaces and +/// punctuation don't matter, and the Japanese customary names (`はがき`, `L判`, `六切`, …) and the +/// JIS spelling (`JIS B5`) are aliases of the papers above. +pub fn paper_size(name: &str) -> Option<(f64, f64)> { + let n = normalize_paper(name); + if n.is_empty() { + return None; + } + let canonical = PAPERS + .iter() + .find(|p| normalize_paper(p.0) == n) + .map(|p| p.0) + .or_else(|| PAPER_ALIASES.iter().find(|(alias, _)| normalize_paper(alias) == n).map(|(_, name)| *name))?; + PAPERS.iter().find(|p| p.0 == canonical).map(|p| (p.1, p.2)) +} + /// The image placed on a print page. pub struct PrintImage { pub width: u32, @@ -770,11 +839,9 @@ fn print_image(doc: &Document, p: &Value, cmd: &str) -> Result<(PrintImage, Valu fn paper(p: &Value, cmd: &str) -> Result<(f64, f64)> { let (w, h) = match p.get("paper") { None => (612.0, 792.0), - Some(Value::String(name)) => PAPERS - .iter() - .find(|(n, _, _)| n.eq_ignore_ascii_case(name)) - .map(|(_, w, h)| (*w, *h)) - .ok_or_else(|| bad(cmd, format!("unknown paper `{name}` ({})", PAPERS.map(|p| p.0).join("|"))))?, + Some(Value::String(name)) => { + paper_size(name).ok_or_else(|| bad(cmd, format!("unknown paper `{name}` ({})", PAPERS.iter().map(|p| p.0).collect::>().join("|"))))? + } Some(Value::Array(a)) if a.len() == 2 => (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)), _ => return Err(bad(cmd, "paper is a name or [width, height] in points")), }; diff --git a/crates/engine/src/cmd/print_cmds/tests.rs b/crates/engine/src/cmd/print_cmds/tests.rs index 177d1305ba..c3d6e536df 100644 --- a/crates/engine/src/cmd/print_cmds/tests.rs +++ b/crates/engine/src/cmd/print_cmds/tests.rs @@ -144,6 +144,80 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { assert!(s.execute("file.print", json!({"colorHandling": "photocraftManages", "dryRun": true})).is_err(), "needs a printer profile"); } +/// The Japanese papers are in the table at their metric sizes, their customary names resolve (case, +/// spaces and punctuation ignored), and the sizes that were there before are unchanged. +#[test] +fn japanese_paper_sizes_and_aliases() { + // mm -> pt, within 0.1 pt. + for (name, w_mm, h_mm) in [ + ("postcard", 100.0, 148.0), + ("double-postcard", 148.0, 200.0), + ("b5", 182.0, 257.0), + ("b4", 257.0, 364.0), + ("a6", 105.0, 148.0), + ("l", 89.0, 127.0), + ("2l", 127.0, 178.0), + ("kg", 102.0, 152.0), + ("6cut", 203.0, 254.0), + ("4cut", 254.0, 305.0), + ] { + let (w, h) = paper_size(name).unwrap_or_else(|| panic!("{name}")); + let want = (w_mm * 72.0 / 25.4, h_mm * 72.0 / 25.4); + assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h}, wanted {want:?}"); + } + // The customary and English names resolve to the same paper. + for (a, b) in [ + ("はがき", "postcard"), + ("ハガキ", "postcard"), + ("葉書", "postcard"), + ("往復はがき", "double-postcard"), + ("JIS B5", "b5"), + ("B5 (JIS)", "b5"), + ("JIS B4", "b4"), + ("L判", "l"), + ("Lサイズ", "l"), + ("l-size", "l"), + ("2L判", "2l"), + ("KG判", "kg"), + ("六切", "6cut"), + ("6切", "6cut"), + ("四切", "4cut"), + ("4切", "4cut"), + ("A4", "a 4"), + ("4 × 6", "4x6"), + ("B5(JIS)", "b5"), + ("JIS B5.", "b5"), + ("JIS B5。", "b5"), + ] { + assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); + } + assert!(paper_size("napkin").is_none()); + // The papers that were already there keep their sizes. + assert_eq!(paper_size("letter"), Some((612.0, 792.0))); + assert_eq!(paper_size("a4"), Some((595.28, 841.89))); + assert_eq!(paper_size("4x6"), Some((288.0, 432.0))); + assert_eq!(paper_size("5x7"), Some((360.0, 504.0))); +} + +/// A print on はがき or L判 writes a PDF page of that paper's size: 100 × 148 mm is +/// 283.46 × 419.53 pt and 89 × 127 mm is 252.28 × 360.00 pt. The customary names and the JIS +/// spelling work too, and an unknown paper is still refused. +#[test] +fn japanese_paper_sizes_reach_the_pdf_page() { + let dir = tmp("jp-paper"); + for (paper, w, h) in [("postcard", 283.46, 419.53), ("l", 252.28, 360.00), ("はがき", 283.46, 419.53), ("JIS B5", 515.91, 728.50)] { + let mut s = session("rgb", 8); + let out = format!("{dir}/{}.pdf", paper.replace(' ', "_")); + let r = s.execute("file.print", json!({"output": out, "paper": paper, "scaleToFit": true})).unwrap(); + assert_eq!(r["pdf"], out); + let pdf = std::fs::read(&out).unwrap(); + let text = String::from_utf8_lossy(&pdf); + let want = format!("/MediaBox [0 0 {w:.2} {h:.2}]"); + assert!(text.contains(&want), "{paper}: wanted {want}"); + } + assert!(session("rgb", 8).execute("file.print", json!({"paper": "napkin", "dryRun": true})).is_err()); +} + #[test] fn photocraft_manages_colors_converts_to_the_printer_profile() { let mut s = session("rgb", 8); diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index 2f94d273d5..0746ca7440 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -386,7 +386,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0"# + r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0,"opacity":0..100=100 (overrides the common 1..100: how strongly the history state is painted; 0 changes nothing; no flow, mode is Normal)"# ), enabled: has_pixel_layer, run: art_history::run, diff --git a/crates/engine/src/cmd/retouch_cmds/art_history.rs b/crates/engine/src/cmd/retouch_cmds/art_history.rs index 0339c88263..b66c7b0b35 100644 --- a/crates/engine/src/cmd/retouch_cmds/art_history.rs +++ b/crates/engine/src/cmd/retouch_cmds/art_history.rs @@ -430,6 +430,47 @@ mod tests { assert!(rgba(&s, 24, 16)[0] > 0.9, "{:?}", rgba(&s, 24, 16)); } + /// Total absolute difference of the layer from `before` (a row of pixels across the stroke). + fn row_diff(s: &Session, before: &[[f32; 4]]) -> f32 { + before.iter().enumerate().map(|(i, b)| (0..4).map(|c| (rgba(s, 8 + i as i32, 16)[c] - b[c]).abs()).sum::()).sum() + } + + /// The stroke's Opacity scales how far it restores the source, at every depth; omitted = 100. + #[test] + fn opacity_scales_the_stroke_at_every_depth() { + for depth in [8u64, 16, 32] { + let setup = || { + let mut s = session(48, 32, depth, "rgb"); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s + }; + let row = |s: &Session| (8..41).map(|x| rgba(s, x, 16)).collect::>(); + let diff = |extra: Value| { + let mut s = setup(); + let before = row(&s); + stroke(&mut s, extra).unwrap(); + row_diff(&s, &before) + }; + let none = diff(json!({"state": 1, "opacity": 0})); + let half = diff(json!({"state": 1, "opacity": 50})); + let full = diff(json!({"state": 1, "opacity": 100})); + let omitted = diff(json!({"state": 1})); + assert_eq!(none, 0.0, "opacity 0 leaves the pixels alone at depth {depth}"); + assert!(half > 0.0 && half < full, "depth {depth}: half {half} full {full}"); + assert_eq!(omitted, full, "omitted opacity is 100 at depth {depth}"); + } + } + + #[test] + fn opacity_out_of_range_is_an_error() { + let mut s = session(32, 24, 8, "rgb"); + paint_layer(&mut s, |_, _| [0.2, 0.3, 0.4, 1.0]); + for bad in [json!(150), json!(-1)] { + assert!(stroke(&mut s, json!({"state": 1, "opacity": bad})).is_err()); + } + } + #[test] fn bad_params_are_errors() { let mut s = session(32, 24, 8, "rgb"); diff --git a/crates/engine/src/cmd/select_extra_cmds.rs b/crates/engine/src/cmd/select_extra_cmds.rs index 55e2ec10d9..9fd0d65356 100644 --- a/crates/engine/src/cmd/select_extra_cmds.rs +++ b/crates/engine/src/cmd/select_extra_cmds.rs @@ -163,6 +163,11 @@ fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result { /// scale/rotate/move, refused when the result would not fit the transform budget. fn transform_selection(s: &mut Session, p: &Value) -> Result { const CMD: &str = "select.transformSelection"; + let step_name = match p.get("stepName") { + None | Some(Value::Null) => "Transform Selection", + Some(Value::String(n)) if n == "Move Selection" => "Move Selection", + Some(_) => return Err(bad(CMD, "`stepName` must be \"Move Selection\"")), + }; let d = s.active().ok_or(EngineError::NoDocument)?; let sel = d.doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?; let b = sel.content_bounds(); @@ -206,7 +211,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { if h.0.iter().zip(Homography::IDENTITY.0).all(|(a, b)| (a - b).abs() < 1e-12) { return Ok(json!({"changed": false, "selected": true})); } - let selected = s.edit("Transform Selection", |doc, _| { + let selected = s.edit(step_name, |doc, _| { if let Some(sel) = &doc.selection { crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection", sel.format())?; doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty()); @@ -250,7 +255,7 @@ pub fn specs() -> Vec { label: "Transform Selection", menu: &["Select"], shortcut: None, - params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##, + params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"stepName":"Move Selection"? (History step name for a plain move; the UI sends it for a drag of the outline; default "Transform Selection"; any other value is refused),"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##, enabled: has_selection, run: transform_selection, journal: true, @@ -422,6 +427,23 @@ mod tests { assert!(s.execute("select.transformSelection", json!({"scaleX": 0})).is_err()); } + #[test] + fn transform_selection_step_name_is_restricted() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let last = |s: &Session| s.active().unwrap().history.entries().last().cloned().unwrap(); + s.execute("select.transformSelection", json!({"dx": 5, "dy": 0})).unwrap(); + assert_eq!(last(&s), "Transform Selection"); + s.execute("select.transformSelection", json!({"dx": 5, "dy": 0, "stepName": "Move Selection"})).unwrap(); + assert_eq!(last(&s), "Move Selection"); + let n = s.active().unwrap().history.past_len(); + for bad_name in [json!("Crop"), json!(""), json!(7)] { + assert!(s.execute("select.transformSelection", json!({"dx": 5, "dy": 0, "stepName": bad_name})).is_err()); + } + assert_eq!(s.active().unwrap().history.past_len(), n); + } + #[test] fn transform_selection_needs_a_selection() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 024f6b088b..13737e70bd 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -70,7 +70,11 @@ fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result< surf.read_rgba_into(area, &mut px); Ok((area, px)) } - _ => Ok((area, photocraft_compose::render(&d.doc, area).into_px())), + _ => { + // The composite allocates inside compose; report a failure at its boundary. + crate::allocation::checkpoint("sampling pixels for a selection")?; + Ok((area, photocraft_compose::render(&d.doc, area).into_px())) + } } } @@ -540,42 +544,67 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec { out } +/// Pushes a pixel onto the grow frontier, reporting an allocation failure instead of aborting. +fn try_push(stack: &mut Vec, i: usize) -> Result<()> { + crate::allocation::try_push(stack, i, "growing a selection") +} + /// Grow (contiguous) / Similar (anywhere): pixels whose colour lies within -/// the selected pixels' colour range widened by the tolerance. +/// tolerance of any selected pixel (indexed via ColorMatcher BVH). fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let tol = f(p, "tolerance", 32.0) / 255.0; let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?; let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); - let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); - for (q, v) in px.iter().zip(&m) { - if *v >= 0.5 { - for c in 0..4 { - lo[c] = lo[c].min(q[c]); - hi[c] = hi[c].max(q[c]); - } - } - } - if lo[0] > hi[0] { + let matcher = + sel::ColorMatcher::new(px.iter().zip(&m).filter_map(|(q, v)| (*v >= 0.5).then_some(*q)), tol).map_err(|message| EngineError::Other(message.into()))?; + if matcher.is_empty() { return Err(EngineError::Other("selection is empty".into())); } - let inside = |q: &[f32; 4]| (0..4).all(|c| q[c] >= lo[c] - tol - 1e-6 && q[c] <= hi[c] + tol + 1e-6); let (w, h) = (area.width() as usize, area.height() as usize); - let mut out: Vec = m.iter().map(|v| if *v >= 0.5 { 1.0 } else { 0.0 }).collect(); + let mut out = crate::allocation::filled(m.len(), 0.0f32, "growing a selection")?; + for (o, v) in out.iter_mut().zip(&m) { + *o = if *v >= 0.5 { 1.0 } else { 0.0 }; + } if contiguous { - let mut stack: Vec = (0..out.len()).filter(|i| out[*i] > 0.0).collect(); + // Frontier stack: only push selected pixels adjacent to at least one unselected pixel. + // Every push goes through `try_push` so a canvas-sized stack fails instead of aborting. + let mut stack: Vec = Vec::new(); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if out[i] == 0.0 { + continue; + } + let has_unselected_neighbor = + (x > 0 && out[i - 1] == 0.0) || (x + 1 < w && out[i + 1] == 0.0) || (y > 0 && out[i - w] == 0.0) || (y + 1 < h && out[i + w] == 0.0); + if has_unselected_neighbor { + try_push(&mut stack, i)?; + } + } + } while let Some(i) = stack.pop() { let (x, y) = (i % w, i / w); let n = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]; for j in n.into_iter().flatten() { - if out[j] == 0.0 && inside(&px[j]) { + if out[j] == 0.0 && matcher.matches(px[j]) { out[j] = 1.0; - stack.push(j); + try_push(&mut stack, j)?; } } } } else { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_iter_mut().zip(px.par_iter()).for_each(|(o, q)| { + if *o == 0.0 && matcher.matches(*q) { + *o = 1.0; + } + }); + } + #[cfg(target_arch = "wasm32")] for (o, q) in out.iter_mut().zip(&px) { - if inside(q) { + if *o == 0.0 && matcher.matches(*q) { *o = 1.0; } } @@ -592,7 +621,9 @@ fn lasso(s: &mut Session, p: &Value) -> Result { if pts.len() < 3 { return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "needs at least 3 points".into() }); } - polygon_selection(s, "select.lasso", "Lasso", &pts, p) + // The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop). + let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { "Lasso" }; + polygon_selection(s, "select.lasso", step, &pts, p) } /// Selects the polygon `pts` with the lasso tools' `mode`, `antiAlias` and `feather` params, as @@ -725,7 +756,7 @@ pub fn specs() -> Vec { "select.lasso", "Lasso", [], - r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0}"##, + r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false}"##, has_doc, lasso ), diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs index 4cb1b62cbe..b4e3e2999d 100644 --- a/crates/engine/src/cmd/selection_cmds/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/tests.rs @@ -79,6 +79,20 @@ fn selection_sampling_allocation_failure_keeps_the_selection() { assert_eq!(s.active().unwrap().history.entries(), history); } +#[test] +fn selection_sampling_all_layers_reports_a_failed_composite_and_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + // Sample All Layers renders the composite: the failure is reported there, before the mask. + crate::allocation::fail_next_for_test(); + let err = s.execute("select.similar", json!({"sampleAllLayers": true})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for sampling pixels for a selection"), "{err}"); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); +} + #[test] fn modify_rejects_oversized_canvases_before_reading_the_mask() { let mut s = Session::new(); @@ -641,6 +655,69 @@ fn grow_and_similar() { assert_eq!(coverage(&s, 20, 20), 0.0); } +#[test] +fn grow_and_similar_multi_color_does_not_select_intermediate_hull() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(5, 5, 15, 15), &[1.0, 0.0, 0.0, 1.0]); + bg.fill_rect(Rect::new(25, 5, 35, 15), &[0.0, 0.0, 1.0, 1.0]); + bg.fill_rect(Rect::new(5, 18, 15, 28), &[0.0, 0.0, 0.0, 1.0]); + bg.fill_rect(Rect::new(25, 18, 35, 28), &[0.5, 0.0, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + + // Select a patch of Red and a patch of Blue + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 2, "height": 2})).unwrap(); + s.execute("select.rect", json!({"x": 26, "y": 6, "width": 2, "height": 2, "mode": "add"})).unwrap(); + + // Similar should select the remaining Red and Blue, but NOT Black or Purple + s.execute("select.similar", json!({"tolerance": 5})).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "red rect should be selected"); + assert_eq!(coverage(&s, 30, 10), 1.0, "blue rect should be selected"); + assert_eq!(coverage(&s, 10, 22), 0.0, "black rect must not be selected"); + assert_eq!(coverage(&s, 30, 22), 0.0, "purple rect must not be selected"); + + // Grow from just the Red patch should not reach disconnected Blue + s.execute("select.deselect", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 2, "height": 2})).unwrap(); + s.execute("select.grow", json!({"tolerance": 5})).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "contiguous red rect should be grown"); + assert_eq!(coverage(&s, 30, 10), 0.0, "disconnected blue rect must not be grown"); +} + +#[test] +fn grow_and_similar_report_a_failed_mask_allocation_and_keep_the_selection() { + for cmd in ["select.grow", "select.similar"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 4, "height": 4})).unwrap(); + // The first fallible allocation samples the layer; the second is the canvas-sized result. + crate::allocation::fail_after_for_test(1); + let err = s.execute(cmd, json!({"tolerance": 5})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for growing a selection"), "{cmd}: {err}"); + assert_eq!(coverage(&s, 7, 7), 1.0, "{cmd}: the selection must be untouched"); + assert_eq!(coverage(&s, 20, 20), 0.0, "{cmd}: the selection must be untouched"); + } +} + +#[test] +fn grow_reports_a_failed_frontier_push_mid_traversal_and_keeps_the_selection() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 4, "height": 4})).unwrap(); + // Two buffer allocations (sample, result), then the 12 ring pixels of the 4x4 selection seed the + // frontier; three more clean pushes happen while flood-filling outwards before the next one fails. + crate::allocation::fail_after_for_test(2 + 12 + 3); + let err = s.execute("select.grow", json!({"tolerance": 5})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for growing a selection"), "{err}"); + assert_eq!(coverage(&s, 7, 7), 1.0, "the selection must be untouched"); + assert_eq!(coverage(&s, 20, 20), 0.0, "the flood fill must not have been committed"); + assert_eq!(coverage(&s, 11, 7), 0.0, "the flood fill must not have been committed"); +} + #[test] fn lasso_polygon() { let mut s = session(); diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index 6cb8fccaed..5832fb5df9 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -101,6 +101,48 @@ fn has_linked(s: &Session) -> std::result::Result<(), String> { _ => Err("the smart object is embedded".into()), } } +fn linked_layers(doc: &Document) -> Vec { + doc.walk() + .into_iter() + .filter(|(_, _, l)| matches!(&l.content, LayerContent::Smart(sm) if matches!(sm.source, SmartSource::Linked { .. }))) + .map(|(_, _, l)| l.id) + .collect() +} +fn has_any_linked(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + if linked_layers(&d.doc).is_empty() { Err("the document has no linked smart object".into()) } else { Ok(()) } +} +/// Embed All Linked: reads every linked file first, so a missing one refuses the whole step. +fn embed_all_linked(s: &mut Session, _p: &Value) -> Result { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let ids = linked_layers(doc); + if ids.is_empty() { + return Err(other("the document has no linked smart object")); + } + let mut sources = Vec::with_capacity(ids.len()); + for id in &ids { + let src = &smart(doc, *id)?.source; + let (file_name, bytes) = source_bytes(&doc.metadata, src).ok_or_else(|| { + let path = match src { + SmartSource::Linked { path } => path.as_str(), + SmartSource::Embedded { .. } => "", + }; + other(format!("the linked file can't be read: {path}")) + })?; + sources.push(SmartSource::Embedded { file_name, bytes }); + } + let parent = doc.id; + let contents: Vec = ids.iter().map(|id| smart(doc, *id).map(|sm| sm.contents_id)).collect::>()?; + s.edit("Embed All Linked", |doc, _| { + for (id, src) in ids.iter().zip(sources) { + smart_mut(doc, *id)?.source = src; + refresh_or_fail(doc, *id)?; + } + Ok(()) + })?; + s.smart_links.retain(|l| l.parent != parent || !contents.contains(&l.contents_id)); + Ok(json!({"embedded": ids.iter().map(|l| l.0).collect::>()})) +} fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } @@ -146,14 +188,13 @@ mod source; pub(crate) use contents::on_close; pub use contents::{commit_child, contents_of, replace_contents_bytes, replace_contents_from_bytes}; use contents::{edit_contents, export_contents, is_smart_child, path_param, replace_contents, set_source, update_all}; -#[cfg(test)] use filters::smart_mut; -pub(crate) use filters::{add_smart_filter, filter_mask_paintable, filter_mask_surface, refresh_after_filter_mask_edit}; +pub(crate) use filters::{add_smart_filter, add_smart_filter_in, filter_mask_paintable, filter_mask_surface, refresh_after_filter_mask_edit}; use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters}; pub(crate) use placing::detach_psd; -use placing::{convert, shift_layer, via_copy}; +pub(crate) use placing::{affine_homography, set_placement, shift_smart, transform_placement}; +use placing::{convert, reset_transform, shift_layer, via_copy}; pub use placing::{layer_to_smart, layers_to_smart, placement}; -pub(crate) use placing::{set_placement, shift_smart, transform_placement}; #[cfg(test)] pub(crate) use rendering::DISPLAY_NATIVE_HASH_BYTES; use rendering::refresh_or_fail; @@ -198,6 +239,14 @@ pub fn specs() -> Vec { has_smart, edit_contents ), + spec!( + "layer.smartObjects.resetTransform", + "Reset Transform", + SO, + r##"{"layer":id?} (the placement returns to 100 % of the current contents' own size with rotation, skew, Distort, Perspective and the Warp cleared, centred on the average of the placement's four corners; the smart filters stay; refused with nothing changed when the object can't be re-rendered (a visible filter PhotoCraft doesn't have, or contents that can't be read); one undo step)"##, + has_smart, + reset_transform + ), spec!("layer.smartObjects.saveContents", "Save Contents", &[], "{} (in an Edit Contents document)", is_smart_child, |s, _| { let i = s.active_index().ok_or(EngineError::NoDocument)?; Ok(json!({"updated": commit_child(s, i)?})) @@ -244,12 +293,20 @@ pub fn specs() -> Vec { has_smart, unpack::convert_to_layers ), - spec!("layer.smartObjects.convertToEmbedded", "Convert to Embedded", SO, r##"{"layer":id?}"##, has_linked, |s, p| { + spec!("layer.smartObjects.convertToEmbedded", "Embed Linked", SO, r##"{"layer":id?}"##, has_linked, |s, p| { set_source(s, p, "Convert to Embedded", true, |meta, src| { let (file_name, bytes) = source_bytes(meta, src).ok_or_else(|| other("the linked file can't be read"))?; Ok(SmartSource::Embedded { file_name, bytes }) }) }), + spec!( + "layer.smartObjects.embedAllLinked", + "Embed All Linked", + SO, + "{} (every linked smart object of the active document becomes embedded in one history step; a linked file that can't be read refuses the whole step and the error names it) -> {embedded: [layer ids]}", + has_any_linked, + embed_all_linked + ), spec!( "layer.smartObjects.convertToLinked", "Convert to Linked…", @@ -340,7 +397,7 @@ pub fn specs() -> Vec { "layer.smartFilter.setParams", "Edit Smart Filter", &[], - r##"{"layer":id?,"index":u32?,"params":{…} (merged)}"##, + r##"{"layer":id?,"index":u32?,"params":{…} (merged),"replace":bool=false (params replace the stored ones instead)}"##, has_smart_filters, set_filter_params ), diff --git a/crates/engine/src/cmd/smart_cmds/filters.rs b/crates/engine/src/cmd/smart_cmds/filters.rs index 3009ecfbc0..c37c17a6f2 100644 --- a/crates/engine/src/cmd/smart_cmds/filters.rs +++ b/crates/engine/src/cmd/smart_cmds/filters.rs @@ -61,6 +61,18 @@ fn mask_from_selection(sel: &Surface) -> LayerMask { /// filter mask, as in Photoshop. Without a renderable source the filter is applied to the cached /// pixels (and still recorded). pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_doc::SmartFilter, selection: Option<&Surface>) -> Result<()> { + add_smart_filter_in(doc, id, sf, selection, &crate::jobs::JobCtx::new()) +} + +/// Append a filter while retaining the job's cancellation context through its re-render. +pub(crate) fn add_smart_filter_in( + doc: &mut Document, + id: LayerId, + sf: photocraft_doc::SmartFilter, + selection: Option<&Surface>, + ctx: &crate::jobs::JobCtx, +) -> Result<()> { + ctx.check()?; let canvas = doc.bounds(); let renderable = source_bytes(&doc.metadata, &smart(doc, id)?.source).is_some(); let sm = smart_mut(doc, id)?; @@ -71,11 +83,11 @@ pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_d sm.filter_mask = Some(mask_from_selection(sel)); } sm.smart_filters.push(sf); - return refresh_or_fail(doc, id); + return refresh_or_fail_in(doc, id, ctx); } let bounds = selection.map(Surface::content_bounds).filter(|b| !b.is_empty()).unwrap_or(canvas); let cache = sm.cache.as_ref().ok_or_else(|| other("smart object has no pixels"))?; - let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)? + let out = crate::filters::apply_filter_to_surface_in(&sf.command, &sf.params, cache, bounds, selection, canvas, ctx)? .ok_or_else(|| other(format!("unknown filter {}", sf.command)))?; sm.cache = Some(out); sm.smart_filters.push(sf); @@ -127,9 +139,40 @@ pub(super) fn set_filter_visible(s: &mut Session, p: &Value) -> Result { /// The Displace filter, whose map a smart filter keeps embedded in its params. const DISPLACE: &str = "filter.distort.displace"; +/// Merge a Shape Blur edit and resolve only geometry, before the cancellable re-render. +fn edited_shape_params(s: &Session, previous: &Value, patch: &Value) -> Result { + let mut params = previous.clone(); + match (&mut params, patch) { + (Value::Object(old), Value::Object(new)) => old.extend(new.clone()), + (slot, new) => *slot = new.clone(), + } + // A changed selector replaces incompatible stored selectors and geometry. Explicit fields + // in the patch still win; radius-only edits and echoed selectors keep portable geometry. + let clear: &[&str] = if let Some(id) = patch.get("shapeId") { + if Some(id) != previous.get("shapeId") { &["shapeName", "shapePath"] } else { &[] } + } else if let Some(name) = patch.get("shapeName") { + if Some(name) != previous.get("shapeName") { &["shapeId", "shapePath"] } else { &[] } + } else if patch.get("shape").is_some() && patch.get("shapePath").is_none() { + &["shapeId", "shapeName", "shapePath"] + } else { + &[] + }; + if let Value::Object(map) = &mut params { + for key in clear { + if patch.get(*key).is_none() { + map.remove(*key); + } + } + } + crate::filters_ext::resolve_shape_path(s, "filter.blur.shapeBlur", &mut params)?; + Ok(params) +} + pub(super) fn set_filter_params(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.smartFilter.setParams"; - let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?; + let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters, or replacing them with `replace`)"))?; + // An editor holding all of a filter's settings replaces them, so a key it removed stops applying. + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); let authorize = s.authorize; // A Displace given another open document as its map embeds that one in place of the old // (#7423). It is read here, before the edit, from the session; the request is authorized @@ -138,10 +181,14 @@ pub(super) fn set_filter_params(s: &mut Session, p: &Value) -> Result { // negative `mapDocument` (the dialog's "None"), names no map. let new_map_asked = crate::filters_ext::map_document(&new).is_some(); let new_path = crate::filters_ext::map_path(&new).is_some(); - let displace = { + let (displace, shape_params) = { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let sm = smart(doc, layer_param(s, p)?)?; - sm.smart_filters.get(filter_index(CMD, p, sm)?).is_some_and(|f| f.command == DISPLACE) + let f = sm.smart_filters.get(filter_index(CMD, p, sm)?).ok_or_else(|| bad(CMD, "no smart filter at this index"))?; + let nothing = json!({}); + let previous = if replace { ¬hing } else { &f.params }; + let shape_params = if f.command == "filter.blur.shapeBlur" { Some(edited_shape_params(s, previous, &new)?) } else { None }; + (f.command == DISPLACE, shape_params) }; let new_map = if displace && new_map_asked { // The old embedded map, if the edit passed it back, gives way to the one named. @@ -164,9 +211,15 @@ pub(super) fn set_filter_params(s: &mut Session, p: &Value) -> Result { if f.command == photocraft_io::smart_map::UNSUPPORTED_FILTER { return Err(bad(CMD, "this smart filter isn't implemented in PhotoCraft: it is kept as is (it can be hidden, moved or deleted)")); } - match (&mut f.params, new) { - (Value::Object(old), Value::Object(n)) => old.extend(n), - (slot, n) => *slot = n, + if let Some(params) = shape_params { + f.params = params; + } else if replace { + f.params = new; + } else { + match (&mut f.params, new) { + (Value::Object(old), Value::Object(n)) => old.extend(n), + (slot, n) => *slot = n, + } } if f.command == DISPLACE { if let Some(map) = &new_map { diff --git a/crates/engine/src/cmd/smart_cmds/placing.rs b/crates/engine/src/cmd/smart_cmds/placing.rs index 847ad700db..48cede87e4 100644 --- a/crates/engine/src/cmd/smart_cmds/placing.rs +++ b/crates/engine/src/cmd/smart_cmds/placing.rs @@ -1,8 +1,10 @@ +use super::rendering::{refresh_or_fail, unapplied_filters}; use super::source::{SourceImage, buffer_to_surface, cache_key, cache_put, source_bytes}; use super::{ Affine, Arc, BlendMode, Document, EngineError, Homography, Layer, LayerContent, LayerId, PLACED_KEYS, Rect, Result, Session, Size, SmartContentsId, - SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, translate_surface, + SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, source_box, translate_surface, }; +use photocraft_algo::transform::Interp; /// Rounds float noise off a composed transform (scale 0.1 then 10 should be exactly 1), so /// repeated transforms land back on exact, lossless placements. @@ -57,6 +59,73 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) { } } +/// Layer ▸ Smart Objects ▸ Reset Transform, and the same item of the Layers panel menu (#11662): +/// the placement goes back to 100 % of the current contents' own size with rotation, skew, Distort, +/// Perspective **and Warp** cleared, centred where the object sits. Measured in the reference +/// application 2026-11-06 (see the #11675 discussion): a smart object warped with Arc 50, rotated +/// and scaled came back with no warp at all, at its contents' own size, unrotated, and centred on +/// the average of its four corners. The smart filters stay. +/// +/// The step needs the contents re-rendered from their source: a stack whose filters can't all be +/// applied, or a source that can't be read, refuses it whole rather than recording the old (still +/// warped) pixels under a placement that no longer describes them. +pub(super) fn reset_transform(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let LayerContent::Smart(sm) = &st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.content else { + return Err(other("the layer is not a smart object")); + }; + // Checked before anything changes, so a filter PhotoCraft doesn't have (kept verbatim from a + // PSD) leaves the document as it was, as `layer.smartObjects.updateModifiedContent` insists on + // a re-render too. + let stuck = unapplied_filters(sm); + if let Some(name) = stuck.first() { + return Err(other(format!("the {name} smart filter is not supported, so the smart object's transform can't be reset"))); + } + // The contents' own box in source pixels: 100 % maps it one-to-one onto the document. + let [x0, y0, x1, y1] = source_box(&st.doc, sm).ok_or_else(|| other("the smart object's contents are unavailable"))?; + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let old = placement(sm); + // The object stays where it is: the average of the placement's four corners, which is its + // centre for an affine placement and the quad's centre once Distort or Perspective bent it. + let (mut sx, mut sy) = (0.0, 0.0); + for (u, v) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] { + let (px, py) = old.apply(u, v); + sx += px; + sy += py; + } + let (dx, dy) = (sx / 4.0 - cx, sy / 4.0 - cy); + let to = Affine { m: [1.0, 0.0, 0.0, 1.0, dx, dy] }; + let new = affine_homography(&to); + // Everything the accumulated transform moved the pixels by, so the linked masks and the Smart + // Filter mask follow the placement back as they do under Free Transform. + let rel = new.mul(&old.inverse().ok_or_else(|| other("the smart object has a degenerate transform"))?); + if ![dx, dy, cx, cy].iter().all(|v| v.is_finite()) || !rel.0.iter().all(|v| v.is_finite()) { + return Err(other("the smart object's placement is not a usable transform")); + } + s.edit("Reset Transform", |doc, _| { + // The Warp is part of what the reference takes back, so the re-render below starts from the + // unwarped contents. How far a painted Smart Filter mask should follow a cleared warp is + // unmeasured: here it follows the placement, as it does under Free Transform. + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.warp = None; + } + crate::transform_cmds::transform_by_map(doc, id, &rel, None, Interp::Bicubic)?; + // Required, not best-effort: `transform_by_map` re-renders too, but falls back to the old + // pixels when that isn't possible. `Session::edit` runs this on a copy, so an `Err` here + // records no step and leaves the document exactly as it was. The state is unchanged since + // that render, so the guarded refresh answers from the stack cache rather than placing the + // contents a second time. + refresh_or_fail(doc, id)?; + // `new ∘ old⁻¹ ∘ old` only lands on the reset map to within float noise, and could leave a + // Distort placement behind as a nearly affine one: store the exact map. + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + set_placement(sm, new); + } + Ok(json!({"layer": id.0, "transform": to.m})) + }) +} + /// Forget PSD placed-layer data that no longer describes the smart object (its source changed): /// PSD export then writes fresh placed-layer blocks and embeds the new source. pub(crate) fn detach_psd(l: &mut Layer) { diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index ab862d5545..e922e125ef 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -4,7 +4,7 @@ use super::source::{ }; use super::{ Affine, Arc, BlendMode, Document, EngineError, Homography, JobCtx, Layer, LayerContent, LayerId, PixelFormat, Rect, Result, SmartFilter, SmartObject, - SmartSource, Stage, StageKey, Surface, Value, decode_source, other, stack_cache, + SmartSource, Stage, StageKey, Surface, Value, affine_homography, decode_source, other, placement, stack_cache, }; use std::sync::Mutex; @@ -129,7 +129,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent)?; + cur = apply_smart_filter(f, sm, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?; } Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), @@ -155,8 +155,9 @@ fn renders_in_object(f: &SmartFilter) -> bool { /// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the /// requesting session's authority denies the filter's params: callers surface the error instead of /// rendering (and caching) unfiltered pixels. -fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result { - let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? { +fn apply_smart_filter(f: &SmartFilter, sm: &SmartObject, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result { + let params = crate::distort_cmds::placed_puppet_params(&f.command, &f.params, sm); + let out = match crate::filters::apply_filter_to_surface_in(&f.command, ¶ms, cur, canvas, None, extent, ctx)? { // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. Some(out) => out, None => cur.clone(), @@ -176,11 +177,13 @@ pub(super) fn place(name: &str, bytes: &[u8], doc: &Document, sm: &SmartObject) Some(p) => { let [h0, h1, h2, h3, h4, h5, h6, h7, h8] = *p; let h = Homography([h0 / k, h1 / k, h2, h3 / k, h4 / k, h5, h6 / k, h7 / k, h8]); + crate::transform_cmds::check_budget(img.bounds, &h, "smart object", fmt)?; photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &h, None)? } None => { let [a, b, c, d, e, f] = sm.transform.m; let t = Affine { m: [a / k, b / k, c / k, d / k, e, f] }; + crate::transform_cmds::check_budget(img.bounds, &affine_homography(&t), "smart object", fmt)?; photocraft_algo::warp::place_source(&img.surface, img.bounds, &t, None)? } }); @@ -191,9 +194,13 @@ pub(super) fn place(name: &str, bytes: &[u8], doc: &Document, sm: &SmartObject) Some(mode) => stack_image(name, bytes, fmt, mode)?, None => source_image(name, bytes, fmt)?, }; + // The placed stage is unclipped: a large enough placement (an image scaled to fill a huge + // frame) would otherwise fill memory before any allocation could fail. + let h = placement(sm); + crate::transform_cmds::check_budget(img.bounds, &h, "smart object", fmt)?; // Through the warp (source space) and the transform in one pass; whole-pixel moves are exact. Ok(match &sm.perspective { - Some(p) => photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &Homography(*p), sm.warp.as_ref())?, + Some(_) => photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &h, sm.warp.as_ref())?, None => photocraft_algo::warp::place_source(&img.surface, img.bounds, &sm.transform, sm.warp.as_ref())?, }) } @@ -361,7 +368,7 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul Some(b) => b, None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)), }; - cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent)?, extent: cur.extent }; + cur = Stage { surface: apply_smart_filter(f, sm, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent }; stack_cache::put(*key, &cur); } ctx.progress(1.0, ""); diff --git a/crates/engine/src/cmd/smart_cmds/source.rs b/crates/engine/src/cmd/smart_cmds/source.rs index 7385c21da9..56e936004c 100644 --- a/crates/engine/src/cmd/smart_cmds/source.rs +++ b/crates/engine/src/cmd/smart_cmds/source.rs @@ -357,6 +357,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result (f64, f64) { + let b = crate::smart_cmds::source_box(&s.active().unwrap().doc, sm).unwrap(); + placement(sm).apply((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0) +} + +/// The average of the four corners of `sm`'s placement in document pixels: the point Reset +/// Transform keeps (measured in the reference application 2026-11-06, see the #11675 discussion). +/// It is the box centre under an affine placement, and the quad's centre under a projective one. +fn quad_centre(s: &Session, sm: &SmartObject) -> (f64, f64) { + let b = crate::smart_cmds::source_box(&s.active().unwrap().doc, sm).unwrap(); + let h = placement(sm); + let (mut x, mut y) = (0.0, 0.0); + for (u, v) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] { + let (px, py) = h.apply(u, v); + x += px; + y += py; + } + (x / 4.0, y / 4.0) +} + +/// Layer ▸ Smart Objects ▸ Reset Transform (#11662): 100 % of the contents' own size, no rotation, +/// skew, Distort or Perspective, and the object stays centred where it is. +#[test] +fn reset_transform_restores_100_percent_in_place() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let fresh = active_smart(&s).cache.unwrap().content_bounds(); + s.execute("edit.transform", json!({"layer": id, "scaleX": 200, "scaleY": 200, "rotate": 30})).unwrap(); + s.execute("layer.translate", json!({"dx": 7, "dy": 4})).unwrap(); + let sm = active_smart(&s); + assert!(sm.transform.m[0].hypot(sm.transform.m[1]) > 1.5, "the object really is scaled up"); + assert_ne!(sm.transform.m[1], 0.0, "…and rotated"); + let centre = placed_centre(&s, &sm); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let after = active_smart(&s); + assert!(after.perspective.is_none(), "no projective placement left"); + assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "100 %, square, neither rotated nor skewed"); + let now = placed_centre(&s, &after); + assert!((now.0 - centre.0).abs() < 1e-6 && (now.1 - centre.1).abs() < 1e-6, "centred where it was: {now:?} vs {centre:?}"); + let r = after.cache.unwrap().content_bounds(); + assert_eq!((r.width(), r.height()), (fresh.width(), fresh.height()), "the contents at their own size"); + // Re-rendered from the source rather than resampled from the rotated pixels: the reset object + // is exactly the converted layer moved by the same whole-pixel distance. + let mut control = session(8); + paint(&mut control); + convert(&mut control); + control.execute("layer.translate", json!({"dx": 7, "dy": 4})).unwrap(); + let (got, want) = (flat(&s), flat(&control)); + assert!(max_diff(&got, &want) < 1e-6, "lossless reset (mean {})", mean_diff(&got, &want)); + // Already reset: the second run changes nothing. + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + assert_eq!(active_smart(&s).transform, after.transform, "idempotent"); +} + +/// Reset Transform takes the Warp back with the transform and keeps the smart filter stack: +/// measured in the reference application 2026-11-06 (an Arc 50 warp came back as no warp at all), +/// see the #11675 discussion. +#[test] +fn reset_transform_clears_the_warp_and_keeps_the_smart_filters() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let before = active_smart(&s); + assert!(before.warp.is_some(), "the fixture is warped"); + let r = before.cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": [[x0, y0], [x1, y0], [x1 - 12.0, y1 - 8.0], [x0, y1]]})).unwrap(); + assert!(active_smart(&s).perspective.is_some(), "Distort keeps a projective placement"); + let centre = quad_centre(&s, &active_smart(&s)); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let after = active_smart(&s); + assert!(after.warp.is_none(), "the Warp goes with the transform, as the reference does"); + assert_eq!(after.smart_filters.len(), 1, "the smart filter stack stays"); + assert!(after.perspective.is_none(), "Distort is cleared"); + assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0]); + let now = placed_centre(&s, &after); + assert!((now.0 - centre.0).abs() < 1e-6 && (now.1 - centre.1).abs() < 1e-6, "centred where it was: {now:?} vs {centre:?}"); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let again = active_smart(&s); + assert_eq!(again.transform, after.transform, "idempotent"); + assert!(again.warp.is_none(), "and still unwarpd"); +} + +/// The centre Reset Transform keeps is the average of the placement's four corners, not the point +/// its box centre maps to: they differ once Distort or Perspective bends the quad, and the +/// reference application keeps the corners' average (measured 2026-11-06, #11675 discussion). +#[test] +fn reset_transform_centres_a_distorted_object_on_its_corner_average() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let r = active_smart(&s).cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + let quad = [[x0 - 10.0, y0 - 6.0], [x1 + 26.0, y0 - 2.0], [x1 + 4.0, y1 + 18.0], [x0 - 16.0, y1 + 8.0]]; + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": quad})).unwrap(); + let sm = active_smart(&s); + assert!(sm.perspective.is_some(), "a projective placement"); + let corners = quad_centre(&s, &sm); + let middle = placed_centre(&s, &sm); + assert!((corners.0 - middle.0).abs() + (corners.1 - middle.1).abs() > 1.0, "the fixture splits the two centres: {corners:?} vs {middle:?}"); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let after = active_smart(&s); + assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "100 %, square"); + assert!(after.perspective.is_none() && after.warp.is_none(), "no projective placement or warp left"); + let now = placed_centre(&s, &after); + assert!((now.0 - corners.0).abs() < 1e-6 && (now.1 - corners.1).abs() < 1e-6, "centred on the corners: {now:?} vs {corners:?}"); + assert!((now.0 - middle.0).abs() + (now.1 - middle.1).abs() > 1.0, "not on the point the box centre mapped to"); +} + +/// A stack holding a filter this build can't apply can't be re-rendered without the warp, so Reset +/// Transform refuses the whole step instead of recording the old warped pixels at the new +/// placement: no history state, and placement, warp and stack left exactly as they were. +#[test] +fn reset_transform_refuses_a_stack_it_cannot_re_render() { + let mut s = session(8); + paint(&mut s); + convert(&mut s); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap(); + s.execute("edit.transform", json!({"scaleX": 160, "scaleY": 160})).unwrap(); + // A smart filter whose command this build doesn't have, as an imported PSD leaves it. + s.edit("unsupported filter", |doc, active| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("a smart object") }; + sm.smart_filters.push(SmartFilter { + command: UNSUPPORTED_FILTER.into(), + params: json!({"name": "Camera Raw Filter"}), + blend: BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + Ok(()) + }) + .unwrap(); + let before = active_smart(&s); + assert!(!super::rendering::unapplied_filters(&before).is_empty(), "the fixture's filter can't be applied"); + let states = s.active().unwrap().history.entries().len(); + let err = s.execute("layer.smartObjects.resetTransform", json!({})).unwrap_err().to_string(); + assert!(err.contains("Camera Raw Filter"), "the error names the filter that blocks it: {err}"); + let after = active_smart(&s); + assert_eq!(after.transform, before.transform, "the placement is left alone"); + assert_eq!(after.warp, before.warp, "the warp is still there"); + assert_eq!(after.smart_filters, before.smart_filters, "so is the stack"); + assert_eq!(s.active().unwrap().history.entries().len(), states, "a refused step records no history state"); + // Only this step is refused: with the stack gone the same object resets normally. + s.execute("layer.smartFilter.clearSmartFilters", json!({})).unwrap(); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let cleared = active_smart(&s); + assert_eq!(&cleared.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "then the reset goes through"); + assert!(cleared.warp.is_none(), "with the warp"); +} + +/// Enabled for a smart object only, and one undo step back (#11662). +#[test] +fn reset_transform_needs_a_smart_object_and_undoes_in_one_step() { + let mut empty = Session::new(); + assert!(!empty.is_enabled("layer.smartObjects.resetTransform"), "no document"); + assert!(empty.execute("layer.smartObjects.resetTransform", json!({})).is_err()); + let mut s = session(8); + paint(&mut s); + let plain = s.active().unwrap().active_layer.unwrap().0; + assert!(!s.is_enabled("layer.smartObjects.resetTransform"), "a pixel layer has no placement to reset"); + assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": plain})).is_err()); + let id = convert(&mut s); + assert!(s.is_enabled("layer.smartObjects.resetTransform")); + s.execute("edit.transform", json!({"layer": id, "matrix": [2, 0, 0, 2, 0, 0]})).unwrap(); + let scaled = active_smart(&s); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + assert_eq!(&active_smart(&s).transform.m[..4], &[1.0, 0.0, 0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + let back = active_smart(&s); + assert_eq!(back.transform, scaled.transform, "one history step back to the scaled placement"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(&active_smart(&s).transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "…and forward again"); + // Hostile targets are refused, never acted on. + assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": u64::MAX})).is_err()); + assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": "nope"})).is_err()); + // A locked layer can't be moved. + s.execute("layer.lockLayers", json!({"layer": id, "position": true})).unwrap(); + let locked = active_smart(&s).transform; + assert!(s.execute("layer.smartObjects.resetTransform", json!({})).is_err()); + assert_eq!(active_smart(&s).transform, locked, "the placement is left alone"); +} + #[test] fn filters_follow_transforms() { let mut s = session(16); @@ -712,6 +899,7 @@ fn commands_are_registered_with_catalog_ids() { "layer.smartObjects.newSmartObjectViaCopy", "layer.smartObjects.rasterize", "layer.smartObjects.editContents", + "layer.smartObjects.resetTransform", "layer.smartObjects.replaceContents", "layer.smartObjects.exportContents", "layer.smartObjects.updateModifiedContent", @@ -1397,3 +1585,40 @@ fn native_digest_cache_misses_after_copy_on_write_and_keeps_recently_used_source drop(newest); assert!(weak.upgrade().is_none()); } + +/// `replace` makes `params` the filter's whole settings, so a key the edit left out stops +/// applying; without it the params merge, as partial edits rely on. Either way it is one undo. +#[test] +fn set_params_replace_drops_the_keys_it_leaves_out() { + let mut s = session(8); + paint(&mut s); + convert(&mut s); + s.execute("image.adjustments.channelMixer", json!({"monochrome": true, "gray": [30, 30, 40, 0]})).unwrap(); + let params = |s: &Session| active_smart(s).smart_filters[0].params.clone(); + + s.execute("layer.smartFilter.setParams", json!({"params": {"monochrome": false}})).unwrap(); + assert_eq!(params(&s), json!({"monochrome": false, "gray": [30, 30, 40, 0]}), "merged by default"); + + let edit = json!({"monochrome": false, "red": [70, 30, 0, 0]}); + s.execute("layer.smartFilter.setParams", json!({"params": edit, "replace": true})).unwrap(); + assert_eq!(params(&s), edit, "replaced"); + + s.undo(); + assert_eq!(params(&s), json!({"monochrome": false, "gray": [30, 30, 40, 0]})); +} + +/// `replace` also holds for Shape Blur, whose settings otherwise merge before its shape resolves. +#[test] +fn set_params_replace_drops_a_shape_blur_radius_it_leaves_out() { + let mut s = session(8); + paint(&mut s); + convert(&mut s); + s.execute("filter.blur.shapeBlur", json!({"radius": 5, "shape": "circle"})).unwrap(); + let params = |s: &Session| active_smart(s).smart_filters[0].params.clone(); + + s.execute("layer.smartFilter.setParams", json!({"params": {"shape": "circle"}})).unwrap(); + assert_eq!(params(&s)["radius"], json!(5), "merged by default"); + + s.execute("layer.smartFilter.setParams", json!({"params": {"shape": "circle"}, "replace": true})).unwrap(); + assert!(params(&s).get("radius").is_none(), "replaced: {}", params(&s)); +} diff --git a/crates/engine/src/cmd/smartselect_cmds.rs b/crates/engine/src/cmd/smartselect_cmds.rs index adbf9b5f46..a8a3e64d36 100644 --- a/crates/engine/src/cmd/smartselect_cmds.rs +++ b/crates/engine/src/cmd/smartselect_cmds.rs @@ -99,6 +99,7 @@ fn points(p: &Value) -> Vec<(f32, f32)> { /// edges and removes blockiness. const ENHANCE: RefineParams = RefineParams { radius: 4.0, smart_radius: true, smooth: 15.0, feather: 0.0, contrast: 25.0, shift_edge: 0.0 }; +/// Expands a selection around sampled points using image gradients, optionally enhancing edges. fn quick_selection(s: &mut Session, p: &Value) -> Result { let pts = points(p); if pts.is_empty() { @@ -113,10 +114,26 @@ fn quick_selection(s: &mut Session, p: &Value) -> Result { let enhance = b(p, "enhanceEdge", false); let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| { let canvas = doc.bounds(); - let r = quick::quick_select(smp, canvas, &pts, size)?; - if enhance { matting::refine_mask(smp, &matting::region_reader(&r), r.bbox, canvas, &ENHANCE) } else { Some(r) } + let Some(r) = quick::quick_select(smp, canvas, &pts, size) else { return Ok(None) }; + let surface = matting::region_surface(&r); + if enhance { matting::refine_surface(smp, &surface, canvas, &ENHANCE, None).map_err(|e| EngineError::Other(e.to_string())) } else { Ok(Some(surface)) } + })??; + let Some(region) = region else { return apply(s, "Quick Selection", None, m) }; + let bounds = region.content_bounds(); + let selected = s.edit("Quick Selection", |doc, _| { + let empty = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + let old = doc.selection.as_ref().unwrap_or(&empty); + let area = old.content_bounds().union(&bounds).intersect(&doc.bounds()); + let out = match m { + SelectionMode::Replace => region.clone(), + SelectionMode::Add => photocraft_algo::selection::combine_sparse(old, ®ion, area, f32::max), + SelectionMode::Subtract => photocraft_algo::selection::combine_sparse(old, ®ion, area, |a, b| (a - b).max(0.0)), + SelectionMode::Intersect => photocraft_algo::selection::combine_surfaces(old, ®ion, area, f32::min), + }; + doc.selection = (!out.content_bounds().is_empty()).then_some(out); + Ok(doc.selection.is_some()) })?; - apply(s, "Quick Selection", region.as_ref(), m) + Ok(json!({"selected": selected, "changed": true, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()]})) } fn rect_param(p: &Value) -> Option { @@ -434,24 +451,14 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { 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()); - // The refined result is dense over the selection's bounds: refuse what would not fit - // rather than allocate it (a thin edit across a huge canvas; the #8783 review). - let content = sel.content_bounds(); - let area = matting::refine_area(content, doc.bounds(), ¶ms, brush.as_ref()); - if u64::from(area.width()) * u64::from(area.height()) > matting::MAX_REFINE_AREA { - return Err(bad( - "select.refineEdge", - &format!("the selection is too large to refine ({} × {} px; at most {} px in all)", area.width(), area.height(), matting::MAX_REFINE_AREA), - )); - } - Ok(matting::refine_mask_brushed(smp, &matting::surface_reader(sel), content, doc.bounds(), ¶ms, brush.as_ref())) + matting::refine_surface(smp, sel, doc.bounds(), ¶ms, brush.as_ref()).map_err(|e| EngineError::Other(e.to_string())) })??; 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() { "selection" => { let selected = s.edit("Select and Mask", |doc, _| { - doc.selection = region.as_ref().map(matting::region_surface); + doc.selection = region.clone(); Ok(doc.selection.is_some()) })?; Ok(json!({ "output": output, "selected": selected })) @@ -459,8 +466,7 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { "layerMask" => { let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?; s.edit("Select and Mask", |doc, _| { - let surface = - region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); + let surface = region.clone().unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); crate::extra_cmds::background_to_layer_for_mask(doc, id); let existing = doc.layer(id).and_then(|l| l.mask.as_ref()).cloned().unwrap_or_else(LayerMask::reveal_all); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..existing }); @@ -476,14 +482,14 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { EngineError::Other(format!("the active layer is {} {} layer without pixels", src_layer.content.article(), src_layer.content.kind_name())) })?; let name = d.doc.copy_name(&src_layer.name); - let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() }; + let empty = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); let reg = region.as_ref().unwrap_or(&empty); - let pixels = if decontaminate { matting::decontaminate(src, reg, params.radius, amount) } else { src.clone() }; + let pixels = if decontaminate { matting::decontaminate_surface(src, reg, params.radius, amount) } else { src.clone() }; let with_mask = matches!(output.as_str(), "newLayerWithMask" | "newDocumentWithMask"); let (content, mask) = if with_mask { - (pixels, Some(LayerMask { surface: matting::region_surface(reg), ..LayerMask::reveal_all() })) + (pixels, Some(LayerMask { surface: reg.clone(), ..LayerMask::reveal_all() })) } else { - (matting::masked_copy(&pixels, reg), None) + (matting::masked_copy_surface(&pixels, reg, reg.content_bounds()), None) }; if output.starts_with("newDocument") { // Same canvas, colour mode, depth and profile, so the pixels need no conversion. diff --git a/crates/engine/src/cmd/smartselect_cmds/tests.rs b/crates/engine/src/cmd/smartselect_cmds/tests.rs index 5fecac7969..823e0883b5 100644 --- a/crates/engine/src/cmd/smartselect_cmds/tests.rs +++ b/crates/engine/src/cmd/smartselect_cmds/tests.rs @@ -272,7 +272,7 @@ fn select_subject_samples_the_selected_layer_else_the_composite() { /// after Deselect); sampling something else or a pixel edit computes it again. #[test] fn select_subject_repeat_reuses_the_mask_until_pixels_change() { - let (mut s, _) = disc(8); + let (mut s, inside) = disc(8); let sel = |s: &Session| { let mut v = Vec::new(); s.active().unwrap().doc.selection.as_ref().unwrap().read_region_into(Rect::new(0, 0, 200, 160), &mut v); @@ -298,7 +298,10 @@ fn select_subject_repeat_reuses_the_mask_until_pixels_change() { .unwrap(); assert!(s.subject_cache.region.is_none()); s.execute("select.subject", json!({})).unwrap(); - assert_eq!(sel(&s), first); + // Recomputed on the edited pixels (one changed pixel may move the edge a little). + assert!(!std::sync::Arc::ptr_eq(&mask, &cached(&s))); + let v = sel_iou(&s, 200, 160, inside); + assert!(v > 0.9, "IoU {v}"); } /// A vertical Gaussian-blurred edge at x = 40 (warm left, cool right) with the left half selected. @@ -543,29 +546,26 @@ fn sel_bytes(s: &Session, w: i32, h: i32) -> Vec { (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. +/// Huge sparse selections refine without a dense bounding-box result. #[test] -fn refine_edge_refuses_a_selection_too_large_to_refine() { +fn refine_edge_accepts_huge_sparse_and_solid_selections() { 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.refineEdge", json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]})).expect("sparse refine"); + let diagonal = s.active().and_then(|d| d.doc.selection.as_ref()).expect("selected"); + assert!(diagonal.tile_count() < 4000); + for coordinate in [100, 50_000, 99_990] { + assert!(diagonal.sample_channel(coordinate, coordinate, 0) > 0.5, "diagonal stroke missing at {coordinate}"); + } + assert!(diagonal.sample_channel(100, 150, 0) < 0.01, "off-stroke pixel inside a touched tile was filled"); 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()); + s.execute("select.refineEdge", json!({"radius": 4})).expect("solid refine"); + let all = s.active().and_then(|d| d.doc.selection.as_ref()).expect("selected"); + assert_eq!(all.sample_channel(50_000, 50_000, 0), 1.0); + let a = all.tile(photocraft_geom::TileCoord::new(10, 10)).expect("interior"); + let b = all.tile(photocraft_geom::TileCoord::new(20, 20)).expect("interior"); + assert!(std::sync::Arc::ptr_eq(a, b), "solid output buffers are shared"); } /// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint @@ -700,3 +700,14 @@ fn refine_edge_rejects_bad_edits_and_keeps_the_selection() { assert_eq!(sel_bytes(&s, 80, 40), before, "{bad}"); } } + +#[test] +fn refine_edge_rejects_excessive_scratch_before_changing_selection() { + let mut s = Session::new(); + s.add_document(Document::new("scratch", photocraft_doc::Size::new(10_000, 10_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), None); + s.execute("select.rect", json!({"x": 5000, "y": 5000, "width": 1, "height": 1})).unwrap(); + let before = s.active().unwrap().doc.selection.clone(); + let error = s.execute("select.refineEdge", json!({"feather": 1000})).expect_err("scratch limit"); + assert!(error.to_string().contains("not enough memory"), "{error}"); + assert_eq!(s.active().unwrap().doc.selection, before); +} diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index c322da8e26..4243569f68 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -17,6 +17,9 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> { s.active().and_then(|d| d.active_layer).map(|_| ()).ok_or_else(|| "no active layer".into()) } +/// The History step name of a shape-tool bounding box scale (`edit.transform` `stepName`). +pub const TRANSFORM_PATH: &str = "Transform Path"; + fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: "edit.transform".into(), msg: msg.into() } } @@ -58,6 +61,9 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { /// A group moves all its descendants, so its frame must not clip raster children to the selection. pub(crate) fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect { + if let Some(f) = &layer.frame { + return f.bounds(); + } let content = match &layer.content { LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).fold(Rect::EMPTY, |a, b| a.union(&b)), // A soft shape (Density below 100 % or a Feather) renders past its path, over the whole @@ -266,9 +272,9 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { } /// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several -/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but -/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or -/// only the Background besides it), that layer; the active layer when nothing else is. +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501). The +/// Background stays among them, so such a box refuses as locked (#11101, [`background_among`]). +/// With one layer selected, that layer; the active layer when nothing else is. pub fn selection_targets(st: &crate::DocState) -> Vec { targets_of(&st.doc, &st.selected_layers(), st.active_layer) } @@ -279,10 +285,29 @@ pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + let ids = crate::layer_multi_cmds::top_level(doc, selected); if ids.is_empty() { active.into_iter().collect() } else { ids } } +/// The Background when it is among several layers one box would move: Photoshop refuses that Free +/// Transform with its locked-layer alert rather than leaving the Background out (#11101). +pub fn background_among(doc: &Document, ids: &[LayerId]) -> Option { + if ids.len() < 2 { + return None; + } + ids.iter().copied().find(|id| doc.is_background(*id)) +} + +/// The layers one box over `targets` moves: `targets` with the layers linked to them, as the Move +/// tool takes them along. In Photoshop transforming one linked layer transforms the whole link +/// group (#11217). A linked Background joins only when it is a target itself: a link doesn't +/// bring it into a box over other layers. +pub fn box_targets(doc: &Document, targets: &[LayerId]) -> Vec { + let mut ids = crate::layer_multi_cmds::move_targets(doc, targets); + ids.retain(|id| targets.contains(id) || !doc.is_background(*id)); + ids +} + /// The `layers` param: the layers one box transforms together (at most 10000, each in the /// document), without those a listed group carries. `None` when it isn't given. fn layers_param(doc: &Document, p: &Value) -> Result>> { @@ -475,6 +500,13 @@ fn apply_transform( // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: // drop it, the fill re-renders from its model (as `transform_layer_geometry` does). l.fill_cache = None; + if let Some(f) = &mut l.frame { + let a = affine.ok_or_else(|| bad("a frame supports affine transforms; select its image for perspective transforms"))?; + f.transform = a.mul(&f.transform); + if !f.valid() { + return Err(bad("invalid frame transform")); + } + } match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -574,8 +606,15 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface for (i, (l, r)) in lp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { let (x, y) = (src.x0 + (i % w) as i32, src.y0 + (i / w) as i32); let k = sel.sample_channel(x, y, 0); - l[n - 1] *= k; - r[n - 1] *= 1.0 - k; + // The selection lifts its share of a pixel's coverage, up to all of it, as Move does + // (`float_cmds::cut_piece`, #7655): a layer's own transparency loaded as the selection + // lifts it whole and leaves no ghost behind (#5595), and so does the selection a move + // leaves, the moved pixels' alpha, with no a × (1 − a) of a soft edge behind (#10461). + // An opaque pixel splits as k and 1 − k. + let a = l[n - 1]; + let lifted = a.min(k.max(0.0)); + l[n - 1] = lifted; + r[n - 1] = (a - lifted).max(0.0); // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the // selected pixels' (what a stretch repeats at the edge, #3773). if l[n - 1] <= 0.0 { @@ -591,6 +630,30 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface Ok((lifted, rest)) } +/// Moves layer `id` by `affine` (document → document) as `edit.transform` does: its content, linked +/// masks and Smart Filter mask follow, and type, shapes and smart objects re-render. The document +/// selection is left alone. +pub(crate) fn transform_by(doc: &mut Document, id: LayerId, affine: Affine, interp: Interp) -> Result<()> { + transform_by_map(doc, id, &crate::smart_cmds::affine_homography(&affine), Some(affine), interp) +} + +/// [`transform_by`] with the map already built, and `affine` only when it is affine (Distort and +/// Perspective results aren't: their smart object keeps the full projective placement). +pub(crate) fn transform_by_map(doc: &mut Document, id: LayerId, h: &Homography, affine: Option, interp: Interp) -> Result<()> { + let group = group_locks(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // Price the smart object caches before they are taken: the fallback warp below isn't budgeted. + check_layer_budget(None, l, h)?; + let taken = crate::smart_cmds::take_caches(std::slice::from_mut(l)); + apply_transform(None, group, l, h, affine, interp, &Interrupt::NONE)?; + let snapshot = doc.clone(); + if let Some(l) = doc.layer_mut(id) { + refresh_text(&snapshot, l); + } + crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?; + Ok(()) +} + pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) { match &mut l.content { LayerContent::Text(t) => crate::type_cmds::refresh(doc, t), @@ -795,6 +858,13 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { } fn transform(s: &mut Session, p: &Value) -> Result { + // The shape tools' bounding box scales live shapes with this command and names the step as + // the reference does. Passed in, not renamed after: the edit may land later as a job. + let step_name = match p.get("stepName") { + None | Some(Value::Null) => "Free Transform", + Some(Value::String(n)) if n == TRANSFORM_PATH => TRANSFORM_PATH, + Some(_) => return Err(bad("`stepName` must be \"Transform Path\"")), + }; let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; let many = layers_param(&st.doc, p)?; @@ -812,16 +882,31 @@ fn transform(s: &mut Session, p: &Value) -> Result { (None, false) if many.is_some() => None, (None, false) => return Err(EngineError::Other("no active layer".into())), }; + let listed = many.is_some(); // The layers the box moves: those listed, the one named, or else the layer selection (#6501). - let ids: Vec = match (many, explicit) { + let mut ids: Vec = match (many, explicit) { (Some(ids), _) => ids, _ if lone.is_some() => Vec::new(), (None, Some(id)) => vec![id], - (None, None) => selection_targets(st), + (None, None) => { + let ids = selection_targets(st); + if let Some(bg) = background_among(&st.doc, &ids) { + let name = st.doc.layer(bg).map_or("Background", |l| l.name.as_str()); + return Err(EngineError::Other(format!("layer \"{name}\" is locked"))); + } + ids + } }; if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { return Err(EngineError::NoLayer(*missing)); } + // The named layer or the selection takes its linked layers along, as the Move tool does: in + // Photoshop transforming one linked layer transforms the whole link group (#11217), but not a + // linked Background it didn't target ([`box_targets`]). A caller listing `layers` gets + // exactly those. + if !listed { + ids = box_targets(&st.doc, &ids); + } let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { let v: Vec = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); @@ -866,7 +951,7 @@ fn transform(s: &mut Session, p: &Value) -> Result { // on a worker with progress, and can be cancelled. crate::jobs::edit_job( s, - "Free Transform", + step_name, move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)), move |()| json!({"rect": rect, "layers": layers}), ) @@ -952,7 +1037,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers, as Photoshop's Free Transform with several selected; refused as locked when the Background is among several),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index e26572d6e7..a50e4239fd 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -91,13 +91,17 @@ fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { } #[test] -fn the_background_stays_out_of_a_selection_of_several() { +fn the_background_among_a_selection_of_several_refuses_as_locked() { + // Photoshop shows its locked-layer alert rather than leaving the Background out (#11101). let (mut s, paint, text) = two_layers(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); - let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); - assert_eq!(r["layers"], json!([paint.0, text.0])); let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap_err().to_string(); + assert!(err.contains("\"Background\" is locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even {paint:?} or {text:?}"); assert!(st.doc.is_background(bg), "still the Background"); assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); } @@ -273,6 +277,157 @@ fn a_recorded_transform_of_the_selected_layers_replays_on_the_selection_in_anoth assert_eq!(bounds(&s, spare), spare0, "the unselected layer stayed"); } +/// A 200 x 200 document with three 20 x 20 blocks, "A" (x 10..30), "B" (x 60..80) and "C" +/// (x 120..140), all at y 10..30. A and B are linked; C isn't. Only A is selected, and active. +fn linked_layers() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let mut ids = Vec::new(); + for (name, x) in [("A", 10), ("B", 60), ("C", 120)] { + let id = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + ids.push(id); + } + let (a, b, c) = (ids[0], ids[1], ids[2]); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + (s, a, b, c) +} + +fn moved_layers(r: &Value) -> Vec { + let mut ids: Vec = serde_json::from_value(r["layers"].clone()).unwrap(); + ids.sort_unstable(); + ids +} + +/// `got` is `want` give or take a pixel of resampling on each edge. +fn assert_near(got: Rect, want: Rect) { + let close = [(got.x0, want.x0), (got.y0, want.y0), (got.x1, want.x1), (got.y1, want.y1)].iter().all(|(g, w)| (g - w).abs() <= 1); + assert!(close, "{got:?} is not {want:?}"); +} + +/// A 200 % scale of A and B about the centre (45, 20) of their joined frame. +fn scaled_pair(s: &Session, a: LayerId, b: LayerId) { + assert_near(bounds(s, a), Rect::new(-25, 0, 15, 40)); + assert_near(bounds(s, b), Rect::new(75, 0, 115, 40)); +} + +#[test] +fn transforming_one_linked_layer_transforms_its_link_group() { + // Before #11217 only A scaled; Photoshop scales every layer linked to it, by the same box. + let (mut s, a, b, c) = linked_layers(); + let c0 = bounds(&s, c); + let r = s.execute("edit.transform", json!({"layer": a.0, "scaleX": 200, "scaleY": 200, "interpolation": "nearest"})).unwrap(); + assert_eq!(moved_layers(&r), [a.0, b.0]); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [10.0, 10.0, 80.0, 30.0], "the frame covers the link group"); + scaled_pair(&s, a, b); + assert_eq!(bounds(&s, c), c0, "an unlinked layer stays put"); + s.undo(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(10, 10, 30, 30), Rect::new(60, 10, 80, 30)), "one Undo takes both back"); +} + +#[test] +fn transforming_the_selected_linked_layer_transforms_its_link_group() { + let (mut s, a, b, c) = linked_layers(); + let c0 = bounds(&s, c); + // No `layer`: the layer selection (A alone) is the target. + let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200, "interpolation": "nearest"})).unwrap(); + assert_eq!(moved_layers(&r), [a.0, b.0]); + scaled_pair(&s, a, b); + assert_eq!(bounds(&s, c), c0); +} + +#[test] +fn listed_layers_do_not_pull_in_their_linked_layers() { + let (mut s, a, b, _) = linked_layers(); + let r = s.execute("edit.transform", json!({"layers": [a.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(moved_layers(&r), [a.0]); + assert_eq!(bounds(&s, a), Rect::new(15, 10, 35, 30)); + assert_eq!(bounds(&s, b), Rect::new(60, 10, 80, 30), "listed layers are exactly the ones moved"); +} + +#[test] +fn moving_a_linked_layer_by_free_transform_moves_each_linked_layer_once() { + let (mut s, a, b, c) = linked_layers(); + let c0 = bounds(&s, c); + // A and B both selected, and linked: each moves by the offset once, not once per link. + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + assert_eq!(moved_layers(&r), [a.0, b.0]); + assert_eq!(bounds(&s, a), Rect::new(40, 50, 60, 70)); + assert_eq!(bounds(&s, b), Rect::new(90, 50, 110, 70)); + assert_eq!(bounds(&s, c), c0); + // And from A alone, through the active layer. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("edit.transform", json!({"dx": -30, "dy": -40})).unwrap(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(10, 10, 30, 30), Rect::new(60, 10, 80, 30))); +} + +#[test] +fn a_locked_linked_layer_refuses_the_transform_of_its_link_group() { + // As with several selected layers: one that can't move leaves every layer as it was. + let (mut s, a, b, _) = linked_layers(); + s.execute("layer.setProps", json!({"layer": b.0, "locks": {"position": true}})).unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision); +} + +/// The Background (white, 200 x 200) linked to "A", a red block at x 10..30, y 10..30; A is +/// selected, and active. +fn background_linked_to_a_layer() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(a).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + s.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + (s, bg, a) +} + +#[test] +fn a_linked_background_stays_out_of_a_transform_of_its_partner() { + // #11217 review: the link group widening must not pull in a Background the transform didn't + // target, any more than a selection of several does. + let (mut s, bg, a) = background_linked_to_a_layer(); + for p in [json!({"matrix": [1, 0, 0, 1, 5, 0]}), json!({"layer": a.0, "matrix": [1, 0, 0, 1, 5, 0]})] { + let r = s.execute("edit.transform", p.clone()).unwrap(); + assert_eq!(moved_layers(&r), [a.0], "{p}"); + let st = s.active().unwrap(); + assert!(st.doc.is_background(bg), "{p}: still the Background"); + assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200), "{p}: and where it was"); + } + assert_eq!(bounds(&s, a), Rect::new(20, 10, 40, 30), "A moved both times"); +} + +#[test] +fn a_transform_of_a_linked_background_still_transforms_it() { + let (mut s, bg, a) = background_linked_to_a_layer(); + let r = s.execute("edit.transform", json!({"layer": bg.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert!(moved_layers(&r).contains(&bg.0), "the targeted Background moves: {r}"); + let st = s.active().unwrap(); + assert!(!st.doc.is_background(bg), "turned into a normal layer first, as in Photoshop"); + assert_eq!(bounds(&s, a), Rect::new(15, 10, 35, 30), "with the layer linked to it"); +} + #[test] fn a_recorded_transform_of_other_layers_than_the_selection_keeps_their_ids() { let (mut s, paint, _) = two_layers(); diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index 7bf247466b..124e69198d 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -907,3 +907,87 @@ fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() { assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame"); } } + +/// Free Transform of a layer's own transparency (⌘-click its thumbnail) takes all of it and leaves +/// no ghost where it was, as Photoshop does and as Move already did (#5595). Photoshop 2025, an +/// anti-aliased 60 px ellipse moved 150 px with Free Transform: no pixel left behind. +#[test] +fn transforming_a_layers_own_transparency_leaves_no_ghost() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 120, "background": "transparent"})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 60, "height": 60, "ellipse": true, "antiAlias": true})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#cc2222"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + let alpha_sum = |s: &Session, area: Rect| -> f32 { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(area).as_chunks::<4>().0.iter().map(|p| p[3]).sum() + }; + let (old, new) = (Rect::new(0, 0, 110, 120), Rect::new(110, 0, 300, 120)); + let before = alpha_sum(&s, old); + assert!(before > 2000.0, "the ellipse: {before}"); + s.execute("edit.transform", json!({"dx": 150, "dy": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let left = surf.read_region(old).as_chunks::<4>().0.iter().filter(|p| p[3] > 0.0).count(); + assert_eq!(left, 0, "pixels left behind at the old spot"); + let moved = alpha_sum(&s, new); + assert!((moved - before).abs() < 1.0, "all the coverage moved: {before} → {moved}"); + // An opaque pixel half selected still splits half and half. + let mut s = session(); + s.edit("half", |doc, _| { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(10, 10, 30, 20), &[0.5]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"dx": 40, "dy": 0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let rest = surf.read_region(Rect::new(15, 15, 16, 16)); + assert!((rest[3] - 0.5).abs() < 0.01, "half the opaque pixel stays: {rest:?}"); +} + +#[test] +fn split_selected_lifts_up_to_the_selection_and_leaves_the_rest() { + // One half-covered pixel under a partial selection: the lift takes min(a, k), the rest keeps + // max(a − k, 0), and the two add back up to the pixel's coverage. + let gray_f32 = PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false); + let a = 0.5_f32; + // A negative coverage lifts nothing and leaves the full alpha behind. + for k in [-0.25_f32, 0.25, 0.5, 0.75] { + let kk = k.max(0.0); + let mut surf = Surface::new(PixelFormat::RGBA32F); + surf.fill_rect(Rect::new(3, 4, 4, 5), &[1.0, 0.0, 0.0, a]); + let mut sel = Surface::new(gray_f32); + sel.fill_rect(Rect::new(3, 4, 4, 5), &[k]); + let (lifted, rest) = split_selected(&surf, &sel).unwrap(); + let (l, r) = (lifted.sample_channel(3, 4, 3), rest.sample_channel(3, 4, 3)); + assert!((l - a.min(kk)).abs() < 1e-6, "k = {k}: lifted {l}, want {}", a.min(kk)); + assert!((r - (a - kk).max(0.0)).abs() < 1e-6, "k = {k}: left {r}, want {}", (a - kk).max(0.0)); + assert!((l + r - a).abs() < 1e-6, "k = {k}: lifted {l} + left {r} != {a}"); + } +} + +/// `stepName` names the step "Transform Path" (the shape tools' bounding box); without it the +/// step stays "Free Transform"; any other name is refused and changes nothing. +#[test] +fn step_name_transform_path_is_the_only_name_taken() { + let mut s = session(); + let p = json!({"scaleX": 50, "scaleY": 50}); + s.execute("edit.transform", p.clone()).unwrap(); + assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Free Transform")); + let mut named = p.clone(); + named["stepName"] = json!("Transform Path"); + s.execute("edit.transform", named).unwrap(); + assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Transform Path")); + let steps = s.active().unwrap().history.past_len(); + for bad_name in [json!("Free Transform"), json!("Move"), json!(""), json!(3), json!(["Transform Path"])] { + let mut wrong = p.clone(); + wrong["stepName"] = bad_name.clone(); + assert!(s.execute("edit.transform", wrong).is_err(), "{bad_name}"); + } + assert_eq!(s.active().unwrap().history.past_len(), steps, "a refused name changes nothing"); +} diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index 6206a17fc9..d05c05f46a 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -671,26 +671,34 @@ pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; + let typed = typed_name(s, id); + let mut named = None; let (r, damage) = s.edit(label, |doc, _| { let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let auto_named = is_auto_named(l); + let auto_named = is_auto_named(l, typed.as_deref()); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name()))); }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); + // Only a text change renames: a style or layout edit keeps a copy's "Title copy" (#9023). + let old_text = auto_named.then(|| t.text.clone()); let mut rename = None; let r = f(t, dpi, &mut rename)?; refresh_at(dpi, format, t); // Only this layer's pixels changed: the canvas recomposites their old and new area // instead of the whole document (#124). Unknown old pixels mean a full refresh. let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b)); - let name = rename.or_else(|| auto_named.then(|| layer_name(&t.text))); + named.clone_from(&rename); + let name = rename.or_else(|| old_text.is_some_and(|old| old != t.text).then(|| layer_name(&t.text))); if let Some(n) = name { l.name = n; } Ok((r, damage)) })?; + if let Some(n) = named { + note_typed_name(s, id, &n); + } if let Some(d) = damage { s.hint_damage(d); } @@ -789,9 +797,33 @@ fn shown_style_params<'a>(t: &TextLayer, p: &'a Value) -> Result bool { - matches!(&l.content, LayerContent::Text(t) if l.name == layer_name(&t.text)) +/// the text. A layer renamed to anything else keeps its name (#483). A duplicate's name ("Title +/// copy", "Title copy 2") still comes from its text, so the copy is renamed after its new text +/// as Photoshop does, unless the user typed that name (`typed`, #9023). +pub(crate) fn is_auto_named(l: &Layer, typed: Option<&str>) -> bool { + let LayerContent::Text(t) = &l.content else { return false }; + let auto = layer_name(&t.text); + // Duplicate Layer names a copy of "Title copy" "Title copy 2", not "Title copy copy". + l.name == auto || (typed != Some(l.name.as_str()) && copy_root(&l.name).is_some_and(|root| root == copy_root(&auto).unwrap_or(&auto))) +} + +/// The name the user last gave layer `id` in the active document, if any (see +/// [`crate::DocState::typed_names`]). +pub(crate) fn typed_name(s: &Session, id: LayerId) -> Option { + s.active().and_then(|d| d.typed_names.get(&id)).cloned() +} + +/// Records that the user named layer `id` of the active document `name` (#9023). +pub(crate) fn note_typed_name(s: &mut Session, id: LayerId, name: &str) { + if let Some(d) = s.active_mut() { + d.typed_names.insert(id, name.to_string()); + } +} + +/// The name a Duplicate Layer copy was made from: "Title" for "Title copy" or "Title copy 2". +fn copy_root(name: &str) -> Option<&str> { + let (root, rest) = name.rsplit_once(" copy")?; + (rest.is_empty() || rest.strip_prefix(' ').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))).then_some(root) } /// The name a type layer gets from its text: its first non-empty line, at most 40 characters. @@ -919,13 +951,17 @@ pub fn specs() -> Vec { journal: true, run: |s, p| { let mut t = new_text_layer(s, p, "type.create")?; - let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| layer_name(&t.text)); + let given = p.get("name").and_then(Value::as_str).map(str::to_string); + let name = given.clone().unwrap_or_else(|| layer_name(&t.text)); let id = s.edit("New Type Layer", |doc, active| { refresh(doc, &mut t); let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable); *active = Some(id); Ok(id) })?; + if let Some(given) = given { + note_typed_name(s, id, &given); + } let bounds = s.active().and_then(|d| d.doc.layer(id)).and_then(|l| l.surface()).map(|c| c.content_bounds()); let mut out = json!({ "layer": id.0, "bounds": bounds.map(|r| [r.x0, r.y0, r.x1, r.y1]) }); if let Some(LayerContent::Text(t)) = s.active().and_then(|d| d.doc.layer(id)).map(|l| &l.content) { diff --git a/crates/engine/src/cmd/type_cmds/tests.rs b/crates/engine/src/cmd/type_cmds/tests.rs index b9851d27e1..066e0f5a8a 100644 --- a/crates/engine/src/cmd/type_cmds/tests.rs +++ b/crates/engine/src/cmd/type_cmds/tests.rs @@ -104,8 +104,9 @@ fn refreshing_type_layers_takes_no_history_step() { st.saved_revision = st.revision; } let rev = s.active().unwrap().revision; - let unknown = [(doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; - assert!(s.refresh_type_layers(&[&[(doc_id, id)][..], &unknown].concat()).is_empty()); + let layers = [(doc_id, id), (doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; + let layers: Vec<_> = layers.iter().map(|&(doc, layer)| crate::TypeLayerRef { doc, layer, first_snapshot: false }).collect(); + assert!(s.refresh_type_layers(&layers).is_empty()); assert!(text_layer(&s, id.0).cache.is_some()); let st = s.active().unwrap(); assert!(st.revision > rev); @@ -115,6 +116,94 @@ fn refreshing_type_layers_takes_no_history_step() { assert!(s.active().unwrap().doc.layer(id).is_none()); } +/// The first History snapshot is the document as opened, taken before a served font arrived: +/// the refresh re-renders its type layers too, so returning to it keeps the real font (#6087). +#[test] +fn refreshing_type_layers_refreshes_the_first_snapshot() { + use crate::TypeLayerRef; + let mut s = session(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + let mut stale = (*s.active().unwrap().doc).clone(); + let Some(Layer { content: LayerContent::Text(t), .. }) = stale.layer_mut(id) else { panic!("not a type layer") }; + t.cache = None; + let snap_cache = |s: &Session| match &s.active().unwrap().snapshots[0].doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.cache.is_some(), + other => panic!("{}", other.kind_name()), + }; + let open = |doc, layer| TypeLayerRef { doc, layer, first_snapshot: false }; + let first = |doc, layer| TypeLayerRef { doc, layer, first_snapshot: true }; + // Opened with the fallback font: the first snapshot is the open document itself. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + assert!(!snap_cache(&s)); + assert!(s.refresh_type_layers(&[open(doc_id, id)]).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some()); + assert!(snap_cache(&s), "the first snapshot is refreshed with the document"); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it"); + // Edited since it opened: the snapshot is refreshed on its own, the edit stays. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + let layers = s.active().unwrap().doc.layers.len(); + assert!(s.refresh_type_layers(&[open(doc_id, id), first(doc_id, id), first(doc_id, LayerId(9999))]).is_empty()); + assert!(snap_cache(&s)); + assert!(text_layer(&s, id.0).cache.is_some()); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers); + assert_eq!(st.snapshots[0].doc.layers.len(), layers - 1, "the snapshot keeps the document as opened"); + // Each version is refreshed only for the layers picked in it: one picked in the open + // document (e.g. a PSD layer edited since, while the snapshot keeps the file's pixels) + // leaves the snapshot alone, and the other way round. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.refresh_type_layers(&[open(doc_id, id)]).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some()); + assert!(!snap_cache(&s), "the snapshot was not picked"); + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + let rev = s.active().unwrap().revision; + assert!(s.refresh_type_layers(&[first(doc_id, id)]).is_empty()); + assert!(snap_cache(&s)); + assert!(text_layer(&s, id.0).cache.is_none(), "the open layer was not picked"); + assert_eq!(s.active().unwrap().revision, rev, "and the open document did not change"); + // The layer is gone from the open document: the snapshot still gets the real font. + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + assert!(s.active().unwrap().doc.layer(id).is_none()); + assert!(s.refresh_type_layers(&[first(doc_id, id)]).is_empty()); + assert!(snap_cache(&s), "a layer deleted since it opened is refreshed in the snapshot"); +} + +/// A layer picked in the first snapshot while the document had moved on is refreshed in the open +/// document too when, by the time of the refresh (a busy document's retry), Undo has taken it back +/// to the snapshot: the two share one document, which must not keep the fallback font. +#[test] +fn a_layer_picked_in_the_first_snapshot_refreshes_the_open_document_it_is_again() { + let mut s = session(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + let mut stale = (*s.active().unwrap().doc).clone(); + let Some(Layer { content: LayerContent::Text(t), .. }) = stale.layer_mut(id) else { panic!("not a type layer") }; + t.cache = None; + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + // Picked while the layer was deleted from the open document: only in the snapshot. + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + let picked = [crate::TypeLayerRef { doc: doc_id, layer: id, first_snapshot: true }]; + assert!(s.undo()); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "Undo is back at the snapshot itself"); + let rev = st.revision; + assert!(s.refresh_type_layers(&picked).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some(), "the open document has the real font"); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it with the snapshot"); + assert!(st.revision > rev); +} + #[test] fn create_renders_and_is_undoable() { let mut s = session(); @@ -169,6 +258,136 @@ fn auto_name_follows_the_text() { assert_eq!(layer_name_of(&s, id), "Type Layer"); } +/// #9023: a duplicated type layer ("Title copy") is named after its new text once that is +/// edited, as the original would be; a renamed copy keeps its name. +#[test] +fn a_duplicated_type_layer_follows_its_new_text() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Title"})).unwrap()["layer"].as_u64().unwrap(); + let mut copies = Vec::new(); + for expected in ["Title copy", "Title copy 2"] { + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + let copy = s.active().unwrap().active_layer.unwrap().0; + assert_ne!(copy, id); + assert_eq!(layer_name_of(&s, copy), expected); + copies.push(copy); + } + s.execute("type.edit", json!({"layer": copies[0], "text": "Subtitle"})).unwrap(); + assert_eq!(layer_name_of(&s, copies[0]), "Subtitle", "the copy follows its new text"); + s.execute("type.edit", json!({"layer": copies[1], "text": "Caption"})).unwrap(); + assert_eq!(layer_name_of(&s, copies[1]), "Caption"); + assert_eq!(layer_name_of(&s, id), "Title", "the original keeps its own"); + // A layer whose text ends in " copy": its duplicate is "Draft copy 2", not "Draft copy copy". + let draft = s.execute("type.create", json!({"x": 0, "y": 80, "text": "Draft copy"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": draft})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + let draft_copy = s.active().unwrap().active_layer.unwrap().0; + assert_eq!(layer_name_of(&s, draft_copy), "Draft copy 2"); + s.execute("type.edit", json!({"layer": draft_copy, "text": "Final"})).unwrap(); + assert_eq!(layer_name_of(&s, draft_copy), "Final"); + // Names that only look like copies, or a copy renamed by hand, are kept. + for name in ["Title copy two", "Title copy 2b", "Title copyedit", "Title copy", "My Title copy", "Title copy -1", "Title copy ", "Tit"] { + s.execute("type.edit", json!({"layer": id, "name": name})).unwrap(); + s.execute("type.edit", json!({"layer": id, "text": "Title"})).unwrap(); + assert_eq!(layer_name_of(&s, id), name, "{name}"); + } +} + +/// #9023: a copy-shaped name given in the Layers panel (`layer.setProps`) or by `type.create`'s +/// `name` is the user's, so editing the text keeps it. +#[test] +fn a_copy_shaped_name_given_in_the_layers_panel_or_at_creation_is_kept() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Title"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "name": "Title copy 2"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "text": "Other"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Title copy 2", "renamed in the Layers panel"); + let named = s.execute("type.create", json!({"x": 0, "y": 80, "text": "Head", "name": "Head copy"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": named, "text": "Body"})).unwrap(); + assert_eq!(layer_name_of(&s, named), "Head copy", "named at creation"); +} + +/// #9023: a copy-shaped name typed for a duplicate into a new document is the user's too. +#[test] +fn a_copy_shaped_name_given_when_duplicating_into_a_new_document_is_kept() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Title"})).unwrap()["layer"].as_u64().unwrap(); + let copy = s.execute("layer.duplicate", json!({"layer": id, "document": "new", "name": "Title copy 2"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": copy, "text": "Other"})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy 2"); +} + +/// #9023: only a change of its text renames a copy. Size, style and layout edits keep "Title +/// copy" (they used to rename it "Title"); editing its text still renames it after the text. +#[test] +fn a_duplicated_type_layer_keeps_its_name_until_its_text_changes() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Title"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + let copy = s.active().unwrap().active_layer.unwrap().0; + assert_eq!(layer_name_of(&s, copy), "Title copy"); + s.execute("type.increaseSize", json!({"layer": copy})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy", "Change Type Size"); + s.execute("type.edit", json!({"layer": copy, "color": "#ff0000", "tracking": 50})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy", "a style-only type.edit"); + s.execute("type.edit", json!({"layer": copy, "kerning": "optical"})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy", "kerning"); + s.execute("type.edit", json!({"layer": copy, "text": "Title"})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy", "the same text again"); + s.execute("type.convertToParagraphText", json!({"layer": copy})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy", "Convert to Paragraph Text"); + s.execute("type.convertToPointText", json!({"layer": copy})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy", "Convert to Point Text"); + s.execute("type.edit", json!({"layer": copy, "text": "Subtitle"})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Subtitle", "a text change still renames it"); + // A layer named exactly after its text behaves as before. + s.execute("type.increaseSize", json!({"layer": id})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Title"); + s.execute("type.edit", json!({"layer": id, "text": "Heading"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Heading"); +} + +/// #9023 review: a copy-shaped name the user typed ("Title copy 2" on a layer whose text is +/// "Title") is kept when the text changes, however it was typed; one Duplicate Layer made, also +/// through its dialog's offered name, still follows the text, and so does one restored by undo. +#[test] +fn a_typed_copy_shaped_name_is_kept() { + let mut s = session(); + let typed = |s: &mut Session, how: &str, id: u64, name: &str| match how { + "rename" => s.execute("layer.renameLayer", json!({"layer": id, "name": name})).map(|_| id), + "type.edit" => s.execute("type.edit", json!({"layer": id, "name": name})).map(|_| id), + _ => { + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("layer.duplicate", json!({"name": name})).map(|v| v["layer"].as_u64().unwrap()) + } + }; + for how in ["rename", "type.edit", "dialog"] { + // The dialog offers "Title copy" itself (checked below). + for name in ["Title copy", "Title copy 2", "Title copy 17"].into_iter().filter(|n| how != "dialog" || *n != "Title copy") { + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Title"})).unwrap()["layer"].as_u64().unwrap(); + let named = typed(&mut s, how, id, name).unwrap(); + assert_eq!(layer_name_of(&s, named), name, "{how} {name}"); + s.execute("type.edit", json!({"layer": named, "text": "Subtitle"})).unwrap(); + assert_eq!(layer_name_of(&s, named), name, "{how}: a typed {name:?} is kept"); + s.execute("layer.delete", json!({"layer": named})).unwrap(); + } + } + // The dialog's own offer ("Title copy") is Duplicate Layer's name, not a typed one. + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Title"})).unwrap()["layer"].as_u64().unwrap(); + let copy = typed(&mut s, "dialog", id, "Title copy").unwrap(); + s.execute("type.edit", json!({"layer": copy, "text": "Caption"})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Caption"); + // Undoing a rename brings back the generated name, which follows the text again. + let copy = typed(&mut s, "dialog", id, "Title copy").unwrap(); + s.execute("layer.renameLayer", json!({"layer": copy, "name": "Title copy 9"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Title copy"); + s.execute("type.edit", json!({"layer": copy, "text": "Footer"})).unwrap(); + assert_eq!(layer_name_of(&s, copy), "Footer"); +} + #[test] fn size_keys_step_each_run_and_respect_layer_scale() { let mut s = session(); diff --git a/crates/engine/src/cmd/type_extra_cmds.rs b/crates/engine/src/cmd/type_extra_cmds.rs index 566912b21c..6a1a5070d3 100644 --- a/crates/engine/src/cmd/type_extra_cmds.rs +++ b/crates/engine/src/cmd/type_extra_cmds.rs @@ -1,7 +1,7 @@ //! The Type menu: anti-aliasing, orientation, point ⇄ paragraph conversion, Create Work Path, -//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles (with the Numerals submenu), -//! Paste Lorem Ipsum, Update All Text Layers, missing-font replacement and the default type -//! styles. +//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles (with the Numerals submenu and the 20 +//! Stylistic Sets), Paste Lorem Ipsum, Update All Text Layers, missing-font replacement and the +//! default type styles. //! //! Everything edits the typed [`TextLayer`] model and re-renders through `type_cmds::refresh`, //! so the PSD `TySh` data (and with it warp, anti-aliasing and the OpenType flags) is regenerated @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use crate::commands::{CommandSpec, explicit_layer, layer_param}; use crate::type_cmds::{ - check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range, + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range, typed_name, with_text_targets, }; use crate::{EngineError, Result, Session}; @@ -76,16 +76,19 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { pub(crate) fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result) -> Result { let id = layer_param(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; + let typed = typed_name(s, id); s.edit(label, |doc, _| { let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let auto_named = is_auto_named(l); + let auto_named = is_auto_named(l, typed.as_deref()); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name()))); }; + // Only a text change renames: a style or layout edit keeps a copy's "Title copy" (#9023). + let old_text = auto_named.then(|| t.text.clone()); let r = f(t, dpi)?; refresh_at(dpi, format, t); - let name = auto_named.then(|| layer_name(&t.text)); + let name = old_text.is_some_and(|old| old != t.text).then(|| layer_name(&t.text)); if let Some(n) = name { l.name = n; } @@ -503,6 +506,12 @@ fn warp_text(s: &mut Session, p: &Value) -> Result { // ---------- OpenType toggles ---------- +/// The OpenType tags of Type › OpenType › Stylistic Sets: `STYLISTIC_SET_TAGS[n - 1]` is Set n. +pub const STYLISTIC_SET_TAGS: [&str; 20] = [ + "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", "ss19", + "ss20", +]; + /// OpenType figure tags Type › OpenType › Numerals clears or sets (`lnum`/`onum`/`pnum`/`tnum`). pub const NUMERAL_FEATURE_TAGS: [&str; 4] = ["lnum", "onum", "pnum", "tnum"]; @@ -879,7 +888,14 @@ pub fn specs() -> Vec { const LP: &str = r##"{"layer":id?}"##; const FP: &str = r##"{"layer":id? or "layers":[id,…]? (default selected Type layers)}"##; const OT: &str = r##"{"layer":id? or "layers":[id,…]? (default selected Type layers),"on":bool? (default: toggle representative),"range":[startChar,endChar]? (single layer; default all)}"##; - vec![ + // One toggle per stylistic set; a run fn can't capture, so each indexes the tag table itself. + macro_rules! stylistic_sets { + ($($n:literal),* $(,)?) => { + [$(spec!(concat!("type.openType.stylisticSets.set", $n), concat!("Set ", $n), &["Type", "OpenType", "Stylistic Sets"], OT, has_format_target, |s, p| toggle_opentype(s, p, STYLISTIC_SET_TAGS[$n - 1]))),*] + }; + } + let sets = stylistic_sets![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + let mut all = vec![ spec!("type.antiAlias.none", "None", &["Type", "Anti-Alias"], FP, has_format_target, |s, p| set_aa(s, p, AntiAlias::None)), spec!("type.antiAlias.sharp", "Sharp", &["Type", "Anti-Alias"], FP, has_format_target, |s, p| set_aa(s, p, AntiAlias::Sharp)), spec!("type.antiAlias.crisp", "Crisp", &["Type", "Anti-Alias"], FP, has_format_target, |s, p| set_aa(s, p, AntiAlias::Crisp)), @@ -995,7 +1011,9 @@ pub fn specs() -> Vec { |s, _| save_defaults(s) ), spec!("type.loadDefaultTypeStyles", "Load Default Type Styles", &["Type"], FP, has_defaults, load_defaults), - ] + ]; + all.extend(sets); + all } #[cfg(test)] diff --git a/crates/engine/src/cmd/type_extra_cmds/tests.rs b/crates/engine/src/cmd/type_extra_cmds/tests.rs index 4008f82001..04bd2cbf16 100644 --- a/crates/engine/src/cmd/type_extra_cmds/tests.rs +++ b/crates/engine/src/cmd/type_extra_cmds/tests.rs @@ -375,6 +375,152 @@ fn numerals_tabular_changes_inter_digit_widths() { assert!((width(&s) - plain).abs() < 0.5, "default restores the advances"); } +const SET_IDS: [&str; 3] = ["type.openType.stylisticSets.set1", "type.openType.stylisticSets.set3", "type.openType.stylisticSets.set20"]; + +#[test] +fn stylistic_sets_are_registered_with_their_menu_path() { + for n in 1..=20 { + let spec = crate::commands::find(&format!("type.openType.stylisticSets.set{n}")).unwrap(); + assert_eq!(spec.label, format!("Set {n}")); + assert_eq!(spec.menu, ["Type", "OpenType", "Stylistic Sets"]); + } + assert!(crate::commands::find("type.openType.stylisticSets.set0").is_none()); + assert!(crate::commands::find("type.openType.stylisticSets.set21").is_none()); +} + +#[test] +fn stylistic_sets_toggle_independently_and_keep_other_features() { + let mut s = session(8); + let id = text_layer(&mut s, "office 1/2"); + let before = text(&s, id).char_runs()[0].style.clone(); + s.execute("type.openType.stylisticAlternates", json!({})).unwrap(); + s.execute(SET_IDS[0], json!({})).unwrap(); + s.execute(SET_IDS[1], json!({})).unwrap(); + s.execute(SET_IDS[2], json!({"on": true})).unwrap(); + for tag in ["ss01", "ss03", "ss20", "salt"] { + assert!(on_at(&s, id, 0, tag), "{tag}"); + } + assert!(!on_at(&s, id, 0, "ss02")); + s.execute(SET_IDS[0], json!({})).unwrap(); + assert!(!on_at(&s, id, 0, "ss01"), "toggled off"); + for tag in ["ss03", "ss20", "salt"] { + assert!(on_at(&s, id, 0, tag), "{tag} stays"); + } + let st = text(&s, id).char_runs()[0].style.clone(); + assert!(st.ligatures, "liga untouched"); + assert_eq!((st.size_pt, st.font_family.clone(), st.color), (before.size_pt, before.font_family, before.color)); +} + +#[test] +fn stylistic_set_range_edits_only_those_characters_and_direction_follows_the_range_start() { + let mut s = session(8); + let id = text_layer(&mut s, "abcdef"); + s.execute("type.openType.stylisticSets.set2", json!({"range": [3, 6]})).unwrap(); + let on: Vec = (0..6).map(|i| on_at(&s, id, i, "ss02")).collect(); + assert_eq!(on, [false, false, false, true, true, true]); + // The range starts on the set, so the default direction is off. + let r = s.execute("type.openType.stylisticSets.set2", json!({"range": [3, 6]})).unwrap(); + assert_eq!(r["on"], false); + assert!((0..6).all(|i| !on_at(&s, id, i, "ss02"))); + s.execute("type.openType.stylisticSets.set2", json!({"on": true, "range": [3, 6]})).unwrap(); + // A range starting before the styled characters turns it on (start is off), for all of them. + let r = s.execute("type.openType.stylisticSets.set2", json!({"range": [1, 6]})).unwrap(); + assert_eq!(r["on"], true); + let on: Vec = (0..6).map(|i| on_at(&s, id, i, "ss02")).collect(); + assert_eq!(on, [false, true, true, true, true, true]); + // And a range starting on a set character toggles off even though it begins mid-text. + let r = s.execute("type.openType.stylisticSets.set2", json!({"range": [4, 5]})).unwrap(); + assert_eq!(r["on"], false); + let on: Vec = (0..6).map(|i| on_at(&s, id, i, "ss02")).collect(); + assert_eq!(on, [false, true, true, true, false, true]); +} + +#[test] +fn stylistic_sets_apply_to_every_listed_layer() { + let mut s = session(8); + let a = text_layer(&mut s, "one"); + let b = text_layer(&mut s, "two"); + s.execute("type.openType.stylisticSets.set7", json!({"layers": [a.0, b.0]})).unwrap(); + assert!(on_at(&s, a, 0, "ss07") && on_at(&s, b, 2, "ss07")); + s.execute("type.openType.stylisticSets.set7", json!({"layers": [a.0, b.0]})).unwrap(); + assert!(!on_at(&s, a, 0, "ss07") && !on_at(&s, b, 0, "ss07")); +} + +#[test] +fn stylistic_set_toggles_are_one_undo_step_each() { + let mut s = session(8); + let id = text_layer(&mut s, "abc"); + s.execute(SET_IDS[0], json!({})).unwrap(); + s.execute(SET_IDS[1], json!({})).unwrap(); + assert!(s.undo()); + assert!(on_at(&s, id, 0, "ss01") && !on_at(&s, id, 0, "ss03"), "one step undone"); + assert!(s.undo()); + assert!(!on_at(&s, id, 0, "ss01")); + assert!(s.redo()); + assert!(on_at(&s, id, 0, "ss01") && !on_at(&s, id, 0, "ss03"), "one step redone"); + assert!(s.redo()); + assert!(on_at(&s, id, 0, "ss01") && on_at(&s, id, 0, "ss03")); +} + +/// The sets survive a `.pcraft` save. In PSD they live only in the file's `Txt2` block, which a +/// document made here doesn't get yet; the PSD round trip of an opened file is tested in +/// `photocraft-io` (`psd_kerning.rs`, `edited_text_layer_keeps_its_stylistic_sets`). +#[test] +fn stylistic_sets_survive_a_pcraft_round_trip() { + let mut s = session(8); + let id = text_layer(&mut s, "Il36"); + s.execute("type.openType.stylisticSets.set1", json!({})).unwrap(); + s.execute("type.openType.stylisticSets.set5", json!({})).unwrap(); + let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + let doc = photocraft_format::load_from_bytes(&bytes).unwrap(); + let Some(LayerContent::Text(t)) = doc.layer(id).map(|l| &l.content) else { panic!("text layer lost") }; + let st = &t.char_runs()[0].style; + assert!(feature_on(st, "ss01") && feature_on(st, "ss05") && !feature_on(st, "ss02")); +} + +#[test] +fn stylistic_sets_fail_gracefully_on_bad_params() { + let mut s = session(8); + let bg = s.active().unwrap().active_layer.unwrap(); + let id = text_layer(&mut s, "abc"); + let cmd = "type.openType.stylisticSets.set9"; + assert!(s.execute(cmd, json!({"layer": 987_654})).is_err(), "unknown layer"); + assert!(s.execute(cmd, json!({"layer": bg.0})).is_err(), "not a type layer"); + assert!(s.execute(cmd, json!({"layers": ["x"]})).is_err()); + // Same as the other toggles: an unusable `on` falls back to toggling, a reversed range is + // read in order, and a range past the end clamps (nothing to style). + s.execute(cmd, json!({"on": "x"})).unwrap(); + assert!(on_at(&s, id, 0, "ss09")); + s.execute(cmd, json!({"range": [5, 2]})).unwrap(); + s.execute(cmd, json!({"range": [50, 90]})).unwrap(); + s.execute(cmd, json!({"range": [u64::MAX, 0]})).unwrap(); + s.execute(cmd, json!({"range": "all"})).unwrap(); + assert!(s.execute(cmd, json!({"layer": id.0, "range": [0, 1], "on": null})).is_ok()); +} + +#[test] +fn stylistic_sets_change_inter_glyphs() { + let mut s = session(8); + let r = s.execute("type.create", json!({"x": 20, "y": 70, "text": "Il36", "font": "Inter", "size": 36, "color": "#000000"})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + let glyphs = |s: &Session| { + let t = text(s, id).clone(); + let mut e = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + e.layout(&t, 72.0).glyphs.iter().map(|g| g.id).collect::>() + }; + let plain = glyphs(&s); + s.execute("type.openType.stylisticSets.set1", json!({"range": [2, 4]})).unwrap(); + let one = glyphs(&s); + assert_ne!(one, plain, "ss01 redraws the digits"); + s.execute("type.openType.stylisticSets.set2", json!({"range": [0, 2]})).unwrap(); + let two = glyphs(&s); + assert_ne!(two, one, "ss02 redraws I and l"); + assert_eq!(two.len(), plain.len()); + s.execute("type.openType.stylisticSets.set1", json!({"range": [2, 4]})).unwrap(); + s.execute("type.openType.stylisticSets.set2", json!({"range": [0, 2]})).unwrap(); + assert_eq!(glyphs(&s), plain, "turning both off restores the glyphs"); +} + #[test] fn lorem_update_fonts_and_default_styles() { let mut s = session(16); diff --git a/crates/engine/src/cmd/variables_cmds.rs b/crates/engine/src/cmd/variables_cmds.rs index 9c89c4756e..c56ee0f6c0 100644 --- a/crates/engine/src/cmd/variables_cmds.rs +++ b/crates/engine/src/cmd/variables_cmds.rs @@ -19,6 +19,14 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document".into()) } +fn has_data_sets(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or_else(|| "no document".to_string())?; + if st.doc.variables.data_sets.is_empty() { + return Err("no data sets in document".into()); + } + Ok(()) +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -488,6 +496,9 @@ macro_rules! spec { ($id:expr, $label:expr, $menu:expr, $params:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: has_doc, journal: true, run: $run } }; + ($id:expr, $label:expr, $menu:expr, $params:expr, $enabled:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: $enabled, journal: true, run: $run } + }; } pub fn specs() -> Vec { @@ -511,6 +522,7 @@ pub fn specs() -> Vec { "Apply Data Set…", &["Image"], "{name|index} → {applied, index}: sets layer visibility/text/pixels from the data set (one history step)", + has_data_sets, |s, p| apply_data_set(s, p) ), spec!( @@ -525,6 +537,7 @@ pub fn specs() -> Vec { "Data Sets as Files…", &["File", "Export"], "{dir, format?:png, dataSets?[names], naming?:\"{name}|{index}|{document}\"} → {files,count}: apply each data set and export the flattened document; colliding filenames are rejected before writing (include {index} to disambiguate)", + has_data_sets, |s, p| export_as_files(s, p) ), CommandSpec { diff --git a/crates/engine/src/cmd/variables_cmds/tests.rs b/crates/engine/src/cmd/variables_cmds/tests.rs index ec5b728e71..1f7fa5cb12 100644 --- a/crates/engine/src/cmd/variables_cmds/tests.rs +++ b/crates/engine/src/cmd/variables_cmds/tests.rs @@ -652,3 +652,20 @@ fn exported_data_sets_keep_the_character_style() { assert_eq!((got.weight, got.italic, got.underline), (style.weight, style.italic, style.underline)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn data_set_commands_require_data_sets_to_be_enabled() { + let mut s = Session::new(); + assert!(!s.is_enabled("file.export.dataSetsAsFiles")); + assert!(!s.is_enabled("image.applyDataSet")); + + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + // Document exists but has no data sets: commands remain disabled + assert!(!s.is_enabled("file.export.dataSetsAsFiles")); + assert!(!s.is_enabled("image.applyDataSet")); + + // Once data sets are defined, commands become enabled + s.execute("image.variables.dataSets", json!({"dataSets": [{"name": "Set 1", "values": []}]})).unwrap(); + assert!(s.is_enabled("file.export.dataSetsAsFiles")); + assert!(s.is_enabled("image.applyDataSet")); +} diff --git a/crates/engine/src/cmd/vector_clipboard.rs b/crates/engine/src/cmd/vector_clipboard.rs index 2a61f8b5e3..8cf52e8156 100644 --- a/crates/engine/src/cmd/vector_clipboard.rs +++ b/crates/engine/src/cmd/vector_clipboard.rs @@ -66,6 +66,8 @@ pub(super) fn from_svg(bytes: &[u8]) -> Result { layers: None, resolution: None, source: None, + // An image from another app came from no artboard. + board: None, }) } diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index f331a30335..1f1cfb8622 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -30,7 +30,7 @@ use crate::commands::{CommandSpec, blend_from_str}; use crate::{EngineError, Result, Session}; /// Keys PSD uses for a shape layer's vector data; dropped when a shape is rasterized. -const SHAPE_BLOCKS: [&[u8; 4]; 8] = [b"vmsk", b"vsms", b"vogk", b"vstk", b"vscg", b"SoCo", b"GdFl", b"PtFl"]; +pub(crate) const SHAPE_BLOCKS: [&[u8; 4]; 8] = [b"vmsk", b"vsms", b"vogk", b"vstk", b"vscg", b"SoCo", b"GdFl", b"PtFl"]; pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -80,7 +80,7 @@ pub(crate) use paths::{is_work, path_mut}; pub(crate) use shape::{bake_path, map_path, map_shape, shift_shape, with_shape}; use shape::{ can_paste_shape_fill, can_paste_shape_stroke, combine_shapes, copy_shape_fill, copy_shape_stroke, has_shape_fill, has_shape_stroke, merge_components, - paste_shape_fill, paste_shape_stroke, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize, + paste_shape_fill, paste_shape_stroke, path_to_shape, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize, }; pub use shape::{preview_shape_stroke, refresh_shape, shape_path, transform_shape, translate_vectors}; use vector_mask::{toggle_vector_mask, vector_mask_add, vector_mask_delete, vector_mask_edit, vector_mask_info, vector_mask_rasterize, with}; @@ -230,6 +230,27 @@ pub fn specs() -> Vec { run: |s, p| path_to_selection_with_mode(s, p, "subtract"), journal: true, }, + // ⇧⌥⌘↩ / Ctrl+Alt+Shift+Enter intersects the path with the selection (#7726). + CommandSpec { + id: "path.intersectWithSelection", + label: "Intersect Path with Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "intersect")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "intersect"), + journal: true, + }, + spec!( + "path.toShape", + "Make Shape from Path", + [], + leak(format!( + r##"{{"name":str|"work"|"layer"="work" (the Work Path becomes the shape's path; a saved path stays) | "path":{{subpaths}} (inline; not with name),"consumeWorkPath":bool=false (with path: it was drawn into the Work Path, which goes),{fill}=foreground,{stroke}=none,"layerName":str?}} → shape.info"## + )), + has_doc, + path_to_shape + ), spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), spec!( "path.fill", @@ -315,13 +336,13 @@ pub fn specs() -> Vec { "subtract", "Subtract Front Shape" )), - spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes( + spec!("layer.combineShapes.intersectShapeAreas", "Unite Shapes at Overlap", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes( s, p, "intersect", "Intersect Shape Areas" )), - spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], COMBINE_PARAMS, has_layer, |s, p| { + spec!("layer.combineShapes.excludeOverlappingShapes", "Subtract Shapes at Overlap", [], COMBINE_PARAMS, has_layer, |s, p| { combine_shapes(s, p, "exclude", "Exclude Overlapping Shapes") }), spec!( diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index 7c7f550a29..e54c38fd19 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -371,7 +371,8 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": selected })) } -/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts, +/// ⇧⌥⌘↩ intersects); /// a `mode` in the params is overridden, the rest is forwarded and validated as usual. pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { let mut q = match p { diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 33e1bfe3cf..000a8d0a13 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -107,6 +107,14 @@ pub fn preview_shape_stroke(doc: &Document, id: LayerId, params: &Value) -> Resu /// masks, recursing into groups. For the Move tool / `layer.move`. pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { let a = Affine::translate(dx, dy); + if let Some(f) = &mut l.frame { + f.transform = a.mul(&f.transform); + // Past `Frame::valid`'s bounds it would no longer load; the vector mask, moved below, + // keeps the clip as a plain masked group. + if !f.valid() { + l.frame = None; + } + } if let Some(vm) = l.vector_mask.as_mut() && vm.linked { @@ -424,16 +432,7 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { let l = live_from_params(CMD, kind, p, None)?; (vector::shapes::live_path(&l), Some(l)) }; - let fg = s.tools.foreground; - let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); - let fill = match p.get("fill") { - Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?, - None => Some(Fill::Solid(fgc)), - }; - let stroke = match p.get("stroke") { - Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?, - None => None, - }; + let (fill, stroke) = new_fill_stroke(s, CMD, p)?; vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; // Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode). if let Some(target) = p.get("addTo").and_then(Value::as_u64) { @@ -463,6 +462,72 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { shape_info(s, id) } +/// A new shape layer's `fill` (default the foreground colour) and `stroke` (default none). +fn new_fill_stroke(s: &Session, cmd: &str, p: &Value) -> Result<(Option, Option)> { + let fg = s.tools.foreground; + let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); + let fill = match p.get("fill") { + Some(v) => parse_fill(v).map_err(|e| bad(cmd, e))?, + None => Some(Fill::Solid(fgc)), + }; + let stroke = match p.get("stroke") { + Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(cmd, e))?, + None => None, + }; + Ok((fill, stroke)) +} + +/// The Pen options bar's Make: Shape: a new shape layer, as one History step, from the work path +/// or a saved path (`name`), or from an inline `path` (a Pen path still being drawn). The Work +/// Path becomes the shape's path, so it is gone afterwards; a saved path stays. An inline `path` +/// leaves the document's paths alone unless `consumeWorkPath` says it was drawn into the Work +/// Path. Unlike the other path commands, `"work"` never falls back to the active layer's path: +/// that would duplicate the selected shape or vector mask. +pub(super) fn path_to_shape(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.toShape"; + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) => Some(n.as_str()), + Some(_) => return Err(bad(CMD, "`name` must be a path name: \"work\", \"layer\" or a saved path's name")), + }; + let consume_flag = match p.get("consumeWorkPath") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(CMD, "`consumeWorkPath` must be true or false")), + }; + let (path, consume_work) = match p.get("path").filter(|v| !v.is_null()) { + Some(_) if name.is_some() => return Err(bad(CMD, "give `name` or `path`, not both")), + Some(v) => (parse_path(v).map_err(|e| bad(CMD, e))?, consume_flag), + None if super::is_work(name) => { + let d = s.active().ok_or(EngineError::NoDocument)?; + (d.doc.work_path.clone().ok_or_else(|| bad(CMD, "there is no Work Path"))?, true) + } + None => (super::resolve_path(s, name)?, false), + }; + if !path.subpaths.iter().any(|sp| sp.knots.len() >= 2) { + return Err(bad(CMD, "the path has no segment to make a shape from")); + } + let (fill, stroke) = new_fill_stroke(s, CMD, p)?; + vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; + let layer_name = match p.get("layerName") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if !n.trim().is_empty() => Some(n.clone()), + Some(_) => return Err(bad(CMD, "`layerName` must be a name that isn't empty")), + }; + let mut sh = ShapeLayer { path, fill, stroke, ..Default::default() }; + let id = s.edit("New Shape Layer", |doc, active| { + refresh_shape(doc, &mut sh); + let name = layer_name.unwrap_or_else(|| doc.next_layer_name(default_name("path"))); + let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable); + if consume_work { + doc.work_path = None; + } + *active = Some(id); + Ok(id) + })?; + shape_info(s, id) +} + pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result { const CMD: &str = "shape.edit"; let id = layer_id(s, p)?; diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index b659cbd656..93d220a1de 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -403,6 +403,114 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } +#[test] +fn path_to_shape_makes_a_shape_layer_from_the_work_path_in_one_step() { + let mut s = session(80, 80, 8); + let square = json!({"subpaths": [{"knots": [[10, 10], [50, 10], [50, 50], [10, 50]]}]}); + s.execute("path.set", json!({"path": square})).unwrap(); + let history = s.active().unwrap().history.past_len(); + let info = s.execute("path.toShape", json!({"fill": "#ff0000"})).unwrap(); + let id = LayerId(info["layer"].as_u64().unwrap()); + let d = doc(&s); + let LayerContent::Shape(sh) = &d.layer(id).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.subpaths[0].knots.len(), 4); + assert_eq!(sh.fill, Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)))); + // The Work Path became the shape's path. + assert!(d.work_path.is_none()); + assert_eq!(s.active().unwrap().active_layer, Some(id)); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + s.undo(); + assert!(doc(&s).layer(id).is_none()); + assert!(doc(&s).work_path.is_some()); +} + +#[test] +fn path_to_shape_keeps_a_saved_path() { + let mut s = session(80, 80, 8); + let tri = json!({"subpaths": [{"knots": [[10, 10], [50, 10], [30, 50]]}]}); + s.execute("path.set", json!({"name": "Tri", "path": tri})).unwrap(); + let info = s.execute("path.toShape", json!({"name": "Tri", "layerName": "Roof"})).unwrap(); + assert_eq!(info["name"], "Roof"); + assert_eq!(doc(&s).paths[0].name, "Tri"); +} + +#[test] +fn path_to_shape_rejects_bad_input_without_changing_history() { + let mut s = session(80, 80, 8); + assert!(s.execute("path.toShape", json!({})).is_err(), "no path"); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap(); + let before = doc(&s).clone(); + let history = s.active().unwrap().history.past_len(); + for p in [ + json!({"name": "missing"}), + json!({"name": 42}), + json!({"name": ["work"]}), + json!({"fill": "not a colour"}), + json!({"fill": 42}), + json!({"stroke": {"width": -1}}), + json!({"stroke": {"width": 1e100}}), + json!({"stroke": true}), + ] { + assert!(s.execute("path.toShape", p.clone()).is_err(), "accepted {p}"); + assert_eq!(*doc(&s), before, "{p} modified the document"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{p} changed history"); + } +} + +/// With no Work Path, `path.toShape` doesn't take the active shape layer's path: that would only +/// duplicate the shape. +#[test] +fn path_to_shape_without_a_work_path_does_not_copy_the_active_shape() { + let mut s = session(80, 80, 8); + s.execute("shape.create", json!({"rect": [10, 10, 30, 30]})).unwrap(); + let layers = doc(&s).layers.len(); + for p in [json!({}), json!({"name": "work"})] { + assert!(s.execute("path.toShape", p.clone()).is_err(), "{p} made a shape"); + } + assert_eq!(doc(&s).layers.len(), layers); +} + +/// An inline `path` (a Pen path still being drawn) makes the shape and leaves the Work Path alone, +/// unless it was drawn into the Work Path (`consumeWorkPath`). +#[test] +fn path_to_shape_takes_an_inline_path() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap(); + let drawn = json!({"subpaths": [{"knots": [[5, 5], [30, 5], [30, 30]]}]}); + let history = s.active().unwrap().history.past_len(); + let info = s.execute("path.toShape", json!({"path": drawn})).unwrap(); + let id = LayerId(info["layer"].as_u64().unwrap()); + let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.subpaths[0].knots[0].anchor, Point::new(5.0, 5.0)); + assert!(doc(&s).work_path.is_some(), "the Work Path stays"); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + s.execute("path.toShape", json!({"path": drawn, "consumeWorkPath": true})).unwrap(); + assert!(doc(&s).work_path.is_none()); +} + +#[test] +fn path_to_shape_rejects_paths_and_names_it_cannot_use() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap(); + let before = doc(&s).clone(); + let drawn = json!({"subpaths": [{"knots": [[5, 5], [30, 5], [30, 30]]}]}); + for p in [ + json!({"path": {"subpaths": [{"knots": [[5, 5]]}]}}), + json!({"path": {"subpaths": []}}), + json!({"path": 42}), + json!({"path": drawn, "name": "work"}), + json!({"path": drawn, "consumeWorkPath": "yes"}), + json!({"layerName": 5}), + json!({"layerName": ""}), + json!({"layerName": " "}), + ] { + assert!(s.execute("path.toShape", p.clone()).is_err(), "accepted {p}"); + assert_eq!(*doc(&s), before, "{p} modified the document"); + } + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12]]}]}})).unwrap(); + assert!(s.execute("path.toShape", json!({})).is_err(), "a one-anchor Work Path"); +} + #[test] fn path_make_selection_rejects_invalid_options_without_changing_history() { let mut s = session(80, 80, 8); @@ -460,6 +568,26 @@ fn open_pen_paths_make_the_same_selection_as_closed_paths() { } } +/// ⇧⌘↩, ⌥⌘↩ and ⇧⌥⌘↩'s commands are Make Selection from Path with their mode (#7726), whatever +/// `mode` the params ask for. +#[test] +fn the_fixed_mode_path_commands_combine_as_their_mode() { + let mut s = session(80, 80, 8); + let square = |x: f64| path_json(&vector::shapes::rect(x, 20.0, 30.0, 30.0)); + s.execute("path.set", json!({"name": "Sq", "path": square(30.0)})).unwrap(); + for (id, mode) in [("path.addToSelection", "add"), ("path.subtractFromSelection", "subtract"), ("path.intersectWithSelection", "intersect")] { + s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap(); + s.execute("path.toSelection", json!({"name": "Sq", "mode": mode, "antiAlias": false})).unwrap(); + let want = selection_mask(&s); + s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap(); + s.execute(id, json!({"name": "Sq", "mode": "replace", "antiAlias": false})).unwrap(); + assert_eq!(selection_mask(&s), want, "{id} is mode {mode}"); + } + // Intersecting keeps only the 10 px both squares cover. + let mask = selection_mask(&s); + assert_eq!(mask.iter().filter(|v| **v > 0.5).count(), 10 * 30); +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); @@ -545,6 +673,9 @@ fn stroke_path_simulate_pressure_tapers_both_ends() { let coverage = |simulate: bool, x: i32| -> usize { let mut s = session(200, 60, 8); s.execute("layer.new.layer", json!({})).unwrap(); + // Simulate Pressure tapers a brush that follows pen pressure; the default brush stopped + // doing that on its own (#11809). + s.execute("tools.setBrush", json!({"brush": {"pressureSize": true}})).unwrap(); s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": [[20, 30], [180, 30]]}]}})).unwrap(); let p = json!({"tool": "brush", "size": 20, "hardness": 1.0, "color": "#000000", "opacity": 100, "simulatePressure": simulate}); s.execute("path.stroke", p).unwrap(); diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index d5540c1f79..dde11a8f2a 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -14,6 +14,36 @@ use crate::{EngineError, Result, Session}; /// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the /// quantised frame and palette while rendering, so the document canvas limit is not enough. const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; +/// Largest total an animated GIF render keeps staged before encoding: one index byte per pixel plus +/// a 768-byte palette for every frame, the same 2 GiB bound as imported footage ([`MAX_FOOTAGE_BYTES`]). +/// The per-frame cap alone lets `frames × pixels` reach terabytes (a 4000 × 4000 canvas over a +/// 1,000,000-frame timeline). 1920 × 1080 holds about 2,145 frames and 4096 × 2160 about 242. +/// This bounds the staged frames only (indices and palettes). It does not bound the encoder's output +/// buffer (LZW data, roughly 1.3-1.5 times the raw size, growing by doubling) or the per-frame +/// transients (RGBA f32, 16 bytes per pixel), so it is not a bound on peak memory. +const MAX_GIF_STAGED_BYTES: u64 = MAX_FOOTAGE_BYTES; +/// The GIF format stores each side as 16 bits. +const MAX_GIF_SIDE: u64 = 65_535; + +/// Reject a GIF render whose canvas or frames-times-canvas staging would be unreasonable, before any +/// frame is rendered. All sums are checked in `u64`. +fn check_gif_budget(w: u64, h: u64, frames: u64) -> std::result::Result<(), String> { + if w > MAX_GIF_SIDE || h > MAX_GIF_SIDE { + return Err(format!("{w} × {h} is too large to render as an animated GIF (the GIF format allows {MAX_GIF_SIDE} pixels per side)")); + } + let pixels = w.checked_mul(h).filter(|p| *p <= MAX_GIF_FRAME_PIXELS); + let Some(pixels) = pixels else { + return Err(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)")); + }; + let per_frame = pixels + 768; + if frames.checked_mul(per_frame).is_none_or(|total| total > MAX_GIF_STAGED_BYTES) { + return Err(format!( + "{frames} frames of {w} × {h} are too large to render as an animated GIF (limit {} MiB staged in total); shorten the timeline or reduce the canvas", + MAX_GIF_STAGED_BYTES >> 20 + )); + } + Ok(()) +} /// Set every video layer's displayed content to its frame at the playhead. pub fn sync(doc: &mut Document) { @@ -110,6 +140,15 @@ fn blank_of(fmt: photocraft_color::PixelFormat) -> Surface { Surface::new(fmt) } +/// Both the altered and original footage stacks must have room for an added frame. +fn check_frame_growth(frames: usize, original_frames: Option) -> Result<()> { + let limit = crate::timeline_cmds::MAX_DURATION; + if frames.max(original_frames.unwrap_or(0)) >= limit { + return Err(EngineError::Other(format!("a video layer is limited to {limit} frames"))); + } + Ok(()) +} + fn new_blank(s: &mut Session, _p: &Value) -> Result { let id = s.edit("New Blank Video Layer", |doc, active| { store(doc); @@ -138,7 +177,8 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result { fn insert_blank(s: &mut Session, _p: &Value) -> Result { let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); edit_video(s, "Insert Blank Frame", move |v, cur| { - let at = (cur + 1).min(v.frames.len()); + check_frame_growth(v.frames.len(), v.original_frames.as_ref().map(Vec::len))?; + let at = cur.saturating_add(1).min(v.frames.len()); v.frames.insert(at, blank_of(fmt)); if let Some(original) = &mut v.original_frames { // The stacks stay aligned, but never index past a shorter one. @@ -150,7 +190,9 @@ fn insert_blank(s: &mut Session, _p: &Value) -> Result { fn duplicate_frame(s: &mut Session, _p: &Value) -> Result { edit_video(s, "Duplicate Frame", |v, cur| { - if let Some(f) = v.frames.get(cur).cloned() { + if let Some(f) = v.frames.get(cur) { + check_frame_growth(v.frames.len(), v.original_frames.as_ref().map(Vec::len))?; + let f = f.clone(); if let Some(original) = &mut v.original_frames { let orig = original.get(cur).cloned().unwrap_or_else(|| blank_of(f.format())); original.insert((cur + 1).min(original.len()), orig); @@ -626,11 +668,9 @@ pub fn render_video_with( // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { - // The whole canvas is staged as f32 RGBA for each frame; reject over-budget - // dimensions before an infallible allocation can abort the process. - if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { - return Err(EngineError::Other(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"))); - } + // Each frame is staged as f32 RGBA and every encoded frame is kept until the file is written; + // reject over-budget sizes before an infallible allocation can abort the process. + check_gif_budget(w as u64, h as u64, frames as u64).map_err(EngineError::Other)?; let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); for f in 0..frames { diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index f2acb0bd0f..2038f07ee2 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -29,6 +29,17 @@ fn new_blank_insert_duplicate_delete_frames() { assert!(s.active().unwrap().doc.timeline.as_ref().unwrap().duration >= frames(&s, id)); } +/// Check the shared growth limit without constructing a million-frame document. +#[test] +fn frame_growth_stops_at_the_limit_of_either_stack() { + let limit = crate::timeline_cmds::MAX_DURATION; + assert!(check_frame_growth(limit - 1, None).is_ok()); + assert!(check_frame_growth(limit - 1, Some(limit - 1)).is_ok()); + assert!(check_frame_growth(limit, None).is_err()); + assert!(check_frame_growth(limit - 1, Some(limit)).is_err()); + assert!(check_frame_growth(limit + 1, None).is_err()); +} + #[test] fn rasterize_drops_the_stack() { let mut s = session(); @@ -202,9 +213,9 @@ fn tiff_page_errors_keep_memory_refusals_typed_and_other_context() { other => panic!("page 2 approval refusal lost its fields: {other:?}"), } - let hard = photocraft_io::IoError::PsdImportBudget { required: 9_000, limit: 8_000 }; + let hard = photocraft_io::IoError::PsdImportBudget { required: 9_000, limit: 8_000, free_memory: None }; match tiff_page_error(display, name, 2, hard) { - EngineError::PsdHardBudget { name: named, required, limit } => { + EngineError::PsdHardBudget { name: named, required, limit, .. } => { assert_eq!(named, name); assert_eq!((required, limit), (9_000, 8_000)); } @@ -233,7 +244,7 @@ fn video_frames_to_layers_keeps_a_layered_tiff_page_refusal_typed() { let _limit = super::limits::Scoped::psd_max_pixel_bytes(1); let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err(); match err { - EngineError::PsdHardBudget { name, required, limit } => { + EngineError::PsdHardBudget { name, required, limit, .. } => { assert_eq!(name, path); assert_eq!(limit, 1); assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}"); @@ -284,7 +295,7 @@ fn video_frames_to_layers_uses_the_footage_limit_for_a_single_page_layered_tiff( let _limit = super::limits::Scoped::psd_max_pixel_bytes(1); let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err(); match err { - EngineError::PsdHardBudget { name, required, limit } => { + EngineError::PsdHardBudget { name, required, limit, .. } => { assert_eq!(name, path); assert_eq!(limit, 1); assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}"); @@ -905,6 +916,33 @@ fn render_video_rejects_gif_frames_too_large_to_stage() { assert!(err.to_string().contains("too large"), "{err}"); } +#[test] +fn render_video_rejects_a_gif_whose_frames_together_are_too_large() { + // Each 4000 x 4000 frame is under the per-frame cap, but 100,000 of them keep ~1.6 TB staged. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4000, "height": 4000})).unwrap(); + s.execute("timeline.create", json!({"duration": 100_000, "fps": 30})).unwrap(); + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("many-gif"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("staged in total"), "{err}"); +} + +#[test] +fn gif_budget_boundaries() { + // Sane work is untouched: a minute of 1080p at 30 fps, and the existing small cases. + assert!(check_gif_budget(1920, 1080, 1000).is_ok()); + assert!(check_gif_budget(16, 12, 1).is_ok()); + // Exactly at the limit passes, one frame more fails. + let per_frame = 1000 * 1000 + 768; + let n = MAX_GIF_STAGED_BYTES / per_frame; + assert!(check_gif_budget(1000, 1000, n).is_ok()); + assert!(check_gif_budget(1000, 1000, n + 1).is_err()); + // The per-frame cap and the 16-bit GIF side still apply, and nothing overflows. + assert!(check_gif_budget(9000, 8000, 1).is_err()); + assert!(check_gif_budget(65_536, 1, 1).is_err()); + assert!(check_gif_budget(65_535, 65_535, u64::MAX).is_err()); + assert!(check_gif_budget(1, 1, u64::MAX).is_err()); +} + #[test] fn fps_from_delays_picks_most_common_and_breaks_ties_by_first_seen() { // Most common delay wins: 100 ms appears twice, 40 ms once -> 10 fps. diff --git a/crates/engine/src/cmd/warp_cmds.rs b/crates/engine/src/cmd/warp_cmds.rs index c2f434fb1e..4b94af82a9 100644 --- a/crates/engine/src/cmd/warp_cmds.rs +++ b/crates/engine/src/cmd/warp_cmds.rs @@ -664,7 +664,7 @@ pub fn specs() -> Vec { CommandSpec { id: "layer.smartObjects.warp", label: "Warp", - menu: &["Layer", "Smart Objects"], + menu: &[], shortcut: None, params: P, enabled: active_smart, diff --git a/crates/engine/src/cmd/web_cmds.rs b/crates/engine/src/cmd/web_cmds.rs index 3bd4f5a6c3..624250e509 100644 --- a/crates/engine/src/cmd/web_cmds.rs +++ b/crates/engine/src/cmd/web_cmds.rs @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"folder":folder? (whole image, named after the document; ignored with "path" or "dir"),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"folder":folder? (whole image, named after the document; ignored with "path" or "dir"),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user|selected"="all" (selected requires numbers when the document has slices),"numbers":[n]? (slice numbers, each an existing slice; chooses the slices whatever the mode; with `path` or `folder` alone on a document with slices, refused: use `dir`)} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/cmd/web_cmds/save_web.rs b/crates/engine/src/cmd/web_cmds/save_web.rs index cac6a15e5c..af655df8d7 100644 --- a/crates/engine/src/cmd/web_cmds/save_web.rs +++ b/crates/engine/src/cmd/web_cmds/save_web.rs @@ -167,6 +167,57 @@ fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) { }); } +/// Which slices an export writes. +enum SlicePick { + All, + User, + Numbers(Vec), +} + +/// The slices chosen by `slices` (`all|user|selected`) and `numbers`. Photoshop's dialog can +/// only select slices it shows, so a script naming one that doesn't exist, an unknown mode, or +/// Selected Slices with nothing selected is refused rather than exporting too few or too many +/// files. A document without slices is one image whatever the mode, as in the dialog. +fn slice_pick(p: &Value, cmd: &str, doc: &photocraft_doc::Document) -> Result { + let mode = match p.get("slices") { + None | Some(Value::Null) => "all", + Some(Value::String(m)) if matches!(m.as_str(), "all" | "user" | "selected") => m.as_str(), + Some(v) => return Err(bad(cmd, format!("unknown slices `{}` (all|user|selected)", v.as_str().map_or_else(|| v.to_string(), str::to_string)))), + }; + let Some(numbers) = p.get("numbers").filter(|v| !v.is_null()) else { + return match mode { + "selected" if !doc.slices.is_empty() => Err(bad(cmd, "no slices are selected: Selected Slices needs `numbers`")), + "user" => Ok(SlicePick::User), + _ => Ok(SlicePick::All), + }; + }; + let numbers: Vec = numbers + .as_array() + .filter(|a| !a.is_empty()) + .and_then(|a| a.iter().map(Value::as_u64).collect()) + .ok_or_else(|| bad(cmd, "numbers must be a list of slice numbers"))?; + let count = slices::resolve(doc).len(); + let mut missing: Vec = numbers.iter().copied().filter(|n| !(1..=count as u64).contains(n)).collect(); + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + let list = missing.iter().map(u64::to_string).collect::>().join(", "); + let what = if missing.len() == 1 { "slice" } else { "slices" }; + return Err(bad(cmd, format!("no {what} numbered {list} (the document's slices are 1–{count})"))); + } + // `path` alone is the whole image in one file (with `slices` too, the slices go next to it): + // the chosen slices would be ignored, so a document with slices refuses it. + let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + if given("path") && !given("dir") && matches!(p.get("slices"), None | Some(Value::Null)) && count > 1 { + return Err(bad(cmd, "`path` writes the whole image as one file: to export the slices in `numbers`, use `dir`")); + } + // `folder` alone (#5305) is the whole image too, under the document's name. + if given("folder") && !given("path") && !given("dir") && count > 1 { + return Err(bad(cmd, "`folder` writes the whole image as one file: to export the slices in `numbers`, use `dir`")); + } + Ok(SlicePick::Numbers(numbers)) +} + fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>, native_paths: bool) -> Result { let cmd = "file.export.saveForWebLegacy"; if let Some(authorize) = s.authorize { @@ -174,6 +225,8 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } // Settings are checked first, so a refused export leaves a floating selection where it is. let st = WebSettings::from_params(p, cmd)?; + // So are the slices: committing a floating move doesn't change them. + let pick = slice_pick(p, cmd, &s.active().ok_or(EngineError::NoDocument)?.doc)?; // Direct platform calls must export the moved selection as it appears on the canvas, // just as Session::execute does before dispatching the native command. crate::float_cmds::before_command(s, cmd)?; @@ -191,16 +244,12 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u let dpi = wdoc.resolution_dpi; let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); - let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); - let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); - let chosen: Vec<&ResolvedSlice> = all - .iter() - .filter(|r| match (&numbers, pick) { - (Some(n), _) => n.contains(&(r.number as u64)), - (None, "user") => r.origin != photocraft_doc::SliceOrigin::Auto, - _ => true, - }) - .collect(); + let picked = |r: &ResolvedSlice| match &pick { + SlicePick::Numbers(n) => n.contains(&(r.number as u64)), + SlicePick::User => r.origin != photocraft_doc::SliceOrigin::Auto, + SlicePick::All => true, + }; + let chosen: Vec<&ResolvedSlice> = all.iter().filter(|r| picked(r)).collect(); let single = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()); let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()); // `folder` (with no `path` or `dir`): the whole image into that folder under the document's @@ -265,13 +314,20 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u }; // Slices never overlap here: one covered by later slices comes as its visible pieces (see // `slices::resolve`), each its own image and cell, as Photoshop's subslices. A no-image cell's - // text goes into its first piece only. + // text goes into its first piece only. In the HTML, a slice that isn't exported stays as an + // empty cell, so the exported ones keep their place in the table. let mut texted = std::collections::HashSet::new(); - for r in &chosen { + for r in &all { let piece = scale_rect(r.rect, sx, sy).intersect(&wdoc.bounds()); if piece.is_empty() { continue; } + if !picked(r) { + if html { + cells.push((piece, String::new(), String::new())); + } + continue; + } let stored = r.id.and_then(|id| doc.slices.get(id)); if r.kind == SliceKind::NoImage { let text = stored diff --git a/crates/engine/src/cmd/web_cmds/tests.rs b/crates/engine/src/cmd/web_cmds/tests.rs index 851171e4ce..82c2a6c5a9 100644 --- a/crates/engine/src/cmd/web_cmds/tests.rs +++ b/crates/engine/src/cmd/web_cmds/tests.rs @@ -715,6 +715,7 @@ fn generator_naming_grammar() { fn image_assets_needs_the_generator_preference() { let dir = tmp("generator_pref"); let mut s = session(8); + let doc = s.active().unwrap().doc.id; let id = s.active().unwrap().active_layer.unwrap(); s.execute("layer.renameLayer", json!({"layer": id.0, "name": "logo.png"})).unwrap(); assert!(!s.prefs().plug_ins.generator_enabled, "off by default, as in Photoshop"); @@ -725,19 +726,20 @@ fn image_assets_needs_the_generator_preference() { assert!(s.is_enabled("file.generate.imageAssets")); assert_eq!(s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap()["enabled"], true); s.active_mut().unwrap().path = Some(format!("{dir}/doc.psd")); - assert!(crate::automate_cmds::document_saved(&mut s, 0).is_some(), "assets are written on save"); + assert!(crate::automate_cmds::document_saved(&mut s, doc).is_some(), "assets are written on save"); // Turning it off again takes the menu item away and stops the export, even for a document // left switched on. s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": false})).unwrap(); assert!(!s.is_enabled("file.generate.imageAssets")); - assert!(crate::automate_cmds::document_saved(&mut s, 0).is_none(), "no assets while Generator is off"); + assert!(crate::automate_cmds::document_saved(&mut s, doc).is_none(), "no assets while Generator is off"); } #[test] fn image_assets_are_generated_now_and_on_save() { let dir = tmp("assets"); let mut s = session(8); + let doc = s.active().unwrap().doc.id; // Preferences ▸ Plug-ins ▸ Enable Generator: the feature is opt-in. s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); let id = s.active().unwrap().active_layer.unwrap(); @@ -752,12 +754,12 @@ fn image_assets_are_generated_now_and_on_save() { // On save: written to -assets next to the file. let saved = format!("{dir}/doc.psd"); s.active_mut().unwrap().path = Some(saved.clone()); - let r = crate::automate_cmds::document_saved(&mut s, 0).unwrap(); + let r = crate::automate_cmds::document_saved(&mut s, doc).unwrap(); assert_eq!(r["dir"], json!(format!("{dir}/doc-assets"))); assert!(std::path::Path::new(&format!("{dir}/doc-assets/logo.png")).is_file()); // Off: nothing on save. assert_eq!(s.execute("file.generate.imageAssets", json!({})).unwrap()["enabled"], false); - assert!(crate::automate_cmds::document_saved(&mut s, 0).is_none()); + assert!(crate::automate_cmds::document_saved(&mut s, doc).is_none()); // A default layer adds variants. let s2 = session(8); let doc = s2.active().unwrap().doc.clone(); @@ -774,6 +776,82 @@ fn image_assets_are_generated_now_and_on_save() { assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +/// A Save Document script that closes an earlier clean tab (#11121): the asset generation that +/// follows re-resolves the saved document's identity, so the shifted list neither skips A's +/// outputs nor exports some other tab's. +#[test] +fn a_save_script_closing_another_tab_still_generates_the_saved_documents_assets() { + let dir = tmp("script_close_tab"); + let mut s = Session::new(); + // Document B first (tab 0), then document A with an eligible layer (tab 1, active). + s.execute("file.new", json!({"width": 8, "height": 8, "name": "b"})).unwrap(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "a"})).unwrap(); + let a = s.active().unwrap().doc.id; + s.edit("logo", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("logo.png", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 12, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/a.psd")); + s.execute("file.scripts.scriptEventsManager", json!({"enabled": true, "add": {"event": "saveDocument", "steps": [["file.close", {"document": 0}]]}})) + .unwrap(); + + let r = crate::automate_cmds::document_saved(&mut s, a).expect("A survived the script, so its assets generate"); + assert_eq!(r["files"].as_array().unwrap().len(), 1, "{r}"); + assert!(std::path::Path::new(&format!("{dir}/a-assets/logo.png")).is_file(), "A's asset is written although the script closed B"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.documents()[0].doc.id, a, "the survivor is A, and A's own assets were written"); +} + +/// The sibling condition (#11121): when the script closes the saved document itself, the +/// continuation must generate nothing — least of all the replacement tab's assets. +#[test] +fn a_save_script_closing_the_saved_document_generates_no_replacement_assets() { + let dir = tmp("script_close_saved"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "a"})).unwrap(); + let a = s.active().unwrap().doc.id; + s.edit("logo", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("logo.png", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 12, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/a.psd")); + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "b"})).unwrap(); + s.edit("logo2", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("logo2.png", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 12, 12), &photocraft_raster::from_rgba(&fmt, [0.0, 1.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/b.psd")); + s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); + // Both tabs have a path and an eligible layer: whichever tab the continuation mistakes the + // saved one for would produce a file. + // The script closes the saved document (A, tab 0); B shifts up into its place. + s.execute("file.scripts.scriptEventsManager", json!({"enabled": true, "add": {"event": "saveDocument", "steps": [["file.close", {"document": 0}]]}})) + .unwrap(); + + assert!(crate::automate_cmds::document_saved(&mut s, a).is_none(), "the saved document is gone, so nothing generates"); + assert!(!std::path::Path::new(&format!("{dir}/a-assets")).exists()); + assert!(!std::path::Path::new(&format!("{dir}/b-assets/logo2.png")).exists(), "the replacement tab's assets are not exported"); +} + /// Photoshop's Generator exports an artboard named like an asset at the board's size, transparent /// edges included (generator-assets renderer: the canvas bounds are the artboard's), with no /// preference. Layers inside the board are still trimmed to their pixels. @@ -1339,3 +1417,169 @@ fn folder_saves_under_the_documents_own_name() { assert_eq!(r["files"], json!([format!("{dir}/named.png")])); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn selected_slices_keep_their_place_in_the_html_table() { + // CodeRabbit review on PR #10557: exporting only the right of two slices put its image in + // the table's first column, on the left. Unexported slices stay as empty cells. + let dir = tmp("selected-html"); + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 32, 48], "name": "left"})).unwrap(); + s.execute("slice.new", json!({"rect": [32, 0, 32, 48], "name": "right"})).unwrap(); + let right = photocraft_doc::slices::resolve(&s.active().unwrap().doc).iter().find(|r| r.name == "right").unwrap().number; + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "html": true, "numbers": [right]})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().rsplit('/').next().unwrap()).collect(); + assert_eq!(files, ["right.png"]); + let page = std::fs::read_to_string(r["html"].as_str().unwrap()).unwrap(); + let row = &page[page.find("").unwrap()..page.find("").unwrap()]; + let cells: Vec<&str> = row.split("= 2, "{row}"); + assert!(!cells[0].contains("images/right.png"), "the right slice is in column 0:\n{row}"); + assert!(cells[1].contains("images/right.png"), "column 1:\n{row}"); +} + +/// Two side-by-side user slices, `left` and `right`, and their numbers. +fn two_slices() -> (Session, u64, u64) { + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 32, 48], "name": "left"})).unwrap(); + s.execute("slice.new", json!({"rect": [32, 0, 32, 48], "name": "right"})).unwrap(); + let all = photocraft_doc::slices::resolve(&s.active().unwrap().doc); + let number = |name: &str| all.iter().find(|r| r.name == name).unwrap().number as u64; + (s, number("left"), number("right")) +} + +/// A refused slice export writes and remembers nothing, and (called directly, as the platform +/// writer is) leaves a floating move in place. +fn assert_refused(s: &mut Session, dir: &str, p: Value, expect: &str) { + s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap(); + s.execute("select.float", json!({"dx": 32})).unwrap(); + let mut written = Vec::new(); + let err = save_for_web_with_writer(s, &p, &mut |path, _| { + written.push(path.to_string()); + Ok(()) + }) + .unwrap_err() + .to_string(); + assert!(err.contains(expect), "{p}: {err}"); + assert_eq!(written, Vec::::new(), "{p}: nothing written"); + assert!(s.file_menu.last_web.is_none(), "{p}: nothing remembered"); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some(), "{p}: the floating move is not committed"); + let err = s.execute("file.export.saveForWebLegacy", p.clone()).unwrap_err().to_string(); + assert!(err.contains(expect), "{p}: {err}"); + assert_eq!(std::fs::read_dir(dir).unwrap().count(), 0, "{p}: nothing written"); +} + +#[test] +fn slice_numbers_that_do_not_exist_are_refused() { + // FredApps review on PR #10557: a number naming no slice was dropped, so an export of only + // missing numbers "succeeded" with no files and a page of empty cells. Photoshop's dialog + // can only select existing slices; a script naming others is refused. + let dir = tmp("missing-numbers"); + let (mut s, left, right) = two_slices(); + let count = photocraft_doc::slices::resolve(&s.active().unwrap().doc).len() as u64; + let (gone, also_gone) = (count + 1, count + 7); + assert_refused(&mut s, &dir, json!({"format": "png24", "dir": dir, "html": true, "numbers": [gone]}), &format!("no slice numbered {gone}")); + // Partly missing: the dialog builds `numbers` from slices it shows, so every number it sends + // exists; one that doesn't is a stale or mistyped script and the export is refused whole. + let err = s + .execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "html": true, "numbers": [left, also_gone, right, gone]})) + .unwrap_err() + .to_string(); + assert!(err.contains(&format!("no slices numbered {gone}, {also_gone}")), "{err}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written"); + // Slice numbers start at 1. + let err = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "numbers": [0]})).unwrap_err().to_string(); + assert!(err.contains("no slice numbered 0"), "{err}"); + // Existing numbers still export exactly those slices. + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "numbers": [right, left]})).unwrap(); + let mut files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().rsplit('/').next().unwrap()).collect(); + files.sort_unstable(); + assert_eq!(files, ["left.png", "right.png"]); +} + +#[test] +fn slice_modes_are_validated() { + // FredApps review on PR #10557: any `slices` value but "user" exported every slice, + // including the dialog's "selected" without `numbers`. + let dir = tmp("slice-modes"); + let (mut s, left, right) = two_slices(); + for (p, expect) in [ + (json!({"slices": "selcted"}), "unknown slices `selcted` (all|user|selected)"), + (json!({"slices": 2}), "unknown slices `2` (all|user|selected)"), + (json!({"slices": "selected"}), "no slices are selected"), + (json!({"slices": "selected", "numbers": []}), "numbers must be a list of slice numbers"), + (json!({"numbers": ["1"]}), "numbers must be a list of slice numbers"), + (json!({"numbers": 1}), "numbers must be a list of slice numbers"), + ] { + let mut p = p; + p["format"] = json!("png24"); + p["dir"] = json!(dir); + assert_refused(&mut s, &dir, p, expect); + } + // Every valid call still works. + let count = photocraft_doc::slices::resolve(&s.active().unwrap().doc).len(); + let names = |r: &Value| r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().rsplit('/').next().unwrap().to_string()).collect::>(); + for (mode, expect) in [(json!(null), count), (json!("all"), count), (json!("user"), 2)] { + let out = tmp("slice-modes-ok"); + let mut p = json!({"format": "png24", "dir": out}); + if !mode.is_null() { + p["slices"] = mode.clone(); + } + assert_eq!(names(&s.execute("file.export.saveForWebLegacy", p).unwrap()).len(), expect, "{mode}"); + } + let out = tmp("slice-modes-selected"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": out, "slices": "selected", "numbers": [right]})).unwrap(); + assert_eq!(names(&r), ["right.png"]); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": out, "slices": "user", "numbers": [left]})).unwrap(); + assert_eq!(names(&r), ["left.png"], "numbers choose the slices whatever the mode"); + // A document without slices exports the whole image as one, whatever the dialog's Slices + // choice: the dialog sends "selected" without numbers then (`web_params`). + let mut s = session(8); + for mode in ["all", "user", "selected"] { + let out = tmp("slice-modes-none"); + if mode != "user" { + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": out, "slices": mode})).unwrap(); + assert_eq!(names(&r), ["web.png"], "{mode}"); + assert_eq!(decode(r["files"][0].as_str().unwrap()).dimensions(), (64, 48), "{mode}"); + } + let path = format!("{out}/whole.png"); + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": path, "slices": mode})).unwrap(); + assert_eq!(decode(&path).dimensions(), (64, 48), "{mode}"); + } +} + +#[test] +fn a_whole_image_path_with_slice_numbers_is_refused() { + // CodeRabbit review on PR #10557: `path` alone writes the whole image as one file, so slice + // `numbers` with it were ignored and every slice came out in that file. It is refused; `dir` + // exports the chosen slices. + let dir = tmp("path-numbers"); + let (mut s, left, right) = two_slices(); + let path = format!("{dir}/web.png"); + assert_refused(&mut s, &dir, json!({"format": "png24", "path": path, "numbers": [right]}), "use `dir`"); + assert_refused(&mut s, &dir, json!({"format": "png24", "path": path, "dir": "", "numbers": [left, right]}), "use `dir`"); + // `folder` alone is the whole image under the document's name (#5305): refused the same way. + assert_refused(&mut s, &dir, json!({"format": "png24", "folder": dir, "numbers": [right]}), "use `dir`"); + // An explicit null `slices` is the default mode, as if absent. + assert_refused(&mut s, &dir, json!({"format": "png24", "path": path, "slices": null, "numbers": [right]}), "use `dir`"); + // Valid calls still work: `path` alone is the whole image; with `slices`, the chosen slices + // go next to it; `dir` with `numbers` writes those slices. + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": path})).unwrap(); + assert_eq!(r["files"], json!([path])); + assert_eq!(decode(&path).dimensions(), (64, 48)); + let names = |r: &Value| r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().rsplit('/').next().unwrap().to_string()).collect::>(); + let out = tmp("path-numbers-selected"); + let r = s + .execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": format!("{out}/web.png"), "slices": "selected", "numbers": [right]})) + .unwrap(); + assert_eq!(names(&r), ["right.png"]); + let out = tmp("path-numbers-dir"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": format!("{out}/web.png"), "dir": out, "numbers": [left]})).unwrap(); + assert_eq!(names(&r), ["left.png"]); + // A document without slices is one image: its only slice number is the whole image. + let mut s = session(8); + let out = tmp("path-numbers-none"); + let whole = format!("{out}/whole.png"); + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": whole, "numbers": [1]})).unwrap(); + assert_eq!(decode(&whole).dimensions(), (64, 48)); +} diff --git a/crates/engine/src/crop_fill.rs b/crates/engine/src/crop_fill.rs index 339da83ce9..f5da0de07b 100644 --- a/crates/engine/src/crop_fill.rs +++ b/crates/engine/src/crop_fill.rs @@ -3,9 +3,10 @@ //! strips an extension adds) is filled from its surroundings instead of holding the background //! colour or transparency. //! -//! Which layer: the Background when the document has one (the layer the crop filled with the -//! background colour), otherwise the active pixel layer (where the crop left transparency). Other -//! layers keep their transparency there, as after a plain crop. Each connected piece of the +//! Which layers: the Background and the active pixel layer, each sampled independently. Unmeasured +//! cases (Delete Cropped Pixels off, or a hidden, locked or nonpixel active layer) retain the +//! Background-else-active target. Other layers keep their transparency, as after a plain crop. +//! Each connected piece of the //! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling //! window around that piece, and only pixels the image covers are sampled. //! @@ -27,6 +28,9 @@ const SEED: u64 = 1; /// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap. const MAX_PIXELS: u64 = 400_000_000; +/// Limit for the dense native-float input read when reaching beyond the normal sampling window. +const MAX_WIDENED_SAMPLE_BYTES: usize = 512 << 20; + /// Where the cropped canvas came from in the original document. #[derive(Clone, Debug)] pub(crate) struct Coverage { @@ -38,10 +42,10 @@ pub(crate) struct Coverage { pub height: f64, /// Whether the map rotates (its resampled edge is antialiased against the background colour). pub rotated: bool, - /// The filled layer's pixels before a turned crop (original document px), set by + /// Each candidate target's pixels before a turned crop (original document px), set by /// [`Coverage::holding`]: where they lie past the old canvas they are hidden image, not /// uncovered area. A straight crop has none: Photoshop fills over hidden pixels there. - pub held: Option, + pub held: Option>, } impl Coverage { @@ -53,7 +57,14 @@ impl Coverage { /// Takes the pixels of the layer the fill will go to from `doc` before a turned crop (the /// surface is shared, not copied), so hidden pixels past the old canvas count as covered. pub fn holding(mut self, doc: &Document, active: Option) -> Self { - self.held = target_layer(doc, active).ok().and_then(|id| doc.layer(id)).and_then(|l| l.surface()).cloned(); + self.held = Some( + target_layer(doc, active) + .ok() + .into_iter() + .chain(additional_target(doc, active)) + .filter_map(|id| doc.layer(id).and_then(|l| l.surface()).map(|surface| (id, surface.clone()))) + .collect(), + ); self } @@ -61,19 +72,19 @@ impl Coverage { /// the original canvas by at least half the pixel's projected size (exact for an unrotated /// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling /// blended with the background colour. - fn covered(&self, u: i32, v: i32, margin: f64) -> bool { + fn covered(&self, u: i32, v: i32, margin: f64, held: Option<&Surface>) -> bool { let [a, b, c, d, e, f] = self.map; let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5); let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f); - (x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || self.holds(x, y) + (x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || Self::holds(held, x, y) } /// Whether the layer held an image pixel at original document point (x, y) past the canvas /// (#8060): mostly opaque there and one pixel around it too (the turned edge is blended with /// the background colour, as at the canvas edge). Without an alpha channel /// hidden pixels can't be told from empty ones: nothing is held. - fn holds(&self, x: f64, y: f64) -> bool { - let Some(surf) = &self.held else { return false }; + fn holds(held: Option<&Surface>, x: f64, y: f64) -> bool { + let Some(surf) = held else { return false }; let format = surf.format(); if !(format.alpha && x.is_finite() && y.is_finite()) { return false; @@ -100,8 +111,20 @@ const UNLABELLED: u32 = u32::MAX; /// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the /// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the -/// caller's history step. -pub(crate) fn fill_uncovered(doc: &mut Document, active: Option, cov: &Coverage) -> Result<()> { +/// caller's history step. Returns whether any uncovered pixels were filled, for the history name. +pub(crate) fn fill_uncovered(doc: &mut Document, active: Option, cov: &Coverage, delete: bool) -> Result { + let first = target_layer(doc, active); + let additional = if delete { additional_target(doc, active) } else { None }; + let mut filled = fill_layer(doc, first, cov, false)?; + if let Some(id) = additional { + filled |= fill_layer(doc, Ok(id), cov, true)?; + } + Ok(filled) +} + +/// One target at a time keeps the label/window allocations bounded to one canvas. The caller's +/// existing edit/compound transaction rolls every target back if a later fill fails. +fn fill_layer(doc: &mut Document, id: Result, cov: &Coverage, own_pixels: bool) -> Result { let canvas = doc.bounds(); let (w, h) = (canvas.width(), canvas.height()); if u64::from(w) * u64::from(h) > MAX_PIXELS { @@ -110,62 +133,131 @@ pub(crate) fn fill_uncovered(doc: &mut Document, active: Option, cov: & let (w, h) = (w as usize, h as usize); let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?; if len == 0 { - return Ok(()); + return Ok(false); } let margin = cov.margin(); + let held = id.as_ref().ok().and_then(|id| cov.held.as_ref()?.iter().find(|(held_id, _)| held_id == id).map(|(_, surface)| surface)); let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?; labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| { for (x, l) in row.iter_mut().enumerate() { - if !cov.covered(x as i32, y as i32, margin) { + if !cov.covered(x as i32, y as i32, margin, held) { *l = UNLABELLED; } } }); if !labels.contains(&UNLABELLED) { - return Ok(()); + return Ok(false); } if !labels.contains(&0) { - return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into())); + return if own_pixels { + Ok(false) + } else { + Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into())) + }; } - let id = target_layer(doc, active)?; - let pieces = label_components(&mut labels, w, h)?; + // Resolve an absent target only after the no-fill/sample checks, preserving the legacy path. + let id = id?; let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; + let n = surf.format().channels().max(1); + let has_alpha = surf.format().alpha; + let has_pixel = |x, y| !has_alpha || surf.sample_channel(x, y, n - 1) > 0.0; + let donor_bounds = own_pixels.then(|| if has_alpha { surf.content_bounds().intersect(&canvas) } else { canvas }); + // The added target is optional when it has no covered pixels of its own to sample. + // This check does not absorb errors from synthesis or allocation. + if donor_bounds.is_some_and(|bounds| { + !contains_donor(bounds, |x, y| { + let at = usize::try_from(y).ok().and_then(|y| y.checked_mul(w)).zip(usize::try_from(x).ok()).and_then(|(r, x)| r.checked_add(x)); + at.and_then(|i| labels.get(i)) == Some(&0) && has_pixel(x, y) + }) + }) { + return Ok(false); + } + let pieces = label_components(&mut labels, w, h)?; let label = |x: i32, y: i32| -> u32 { if x < 0 || y < 0 { return 0; } (y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0) }; - let n = surf.format().channels().max(1); - let filled: Vec<(Rect, Vec)> = pieces - .par_iter() - .enumerate() - .map(|(k, bounds)| -> Result<(Rect, Vec)> { - let k = k as u32 + 1; - let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?; - // The piece's bounds, its pixels from the fill and the rest as they were. - let mut out = surf.read_region(*bounds); - let ww = window.width() as usize; - for y in bounds.y0..bounds.y1 { - for x in bounds.x0..bounds.x1 { - if label(x, y) != k || !window.contains(x, y) { - continue; - } - let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n; - let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n; - if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) { - d.copy_from_slice(s); - } + let fill_piece = |(k, bounds): (usize, &Rect)| -> Result<(Rect, Vec)> { + let k = k as u32 + 1; + // A partial active layer can have no donor near a distant turned corner. Only that + // added target may widen its sampling envelope; its hole and output stay unchanged. + let usable = |x, y| label(x, y) == 0 && (!own_pixels || has_pixel(x, y)); + let sampling_bounds = if let Some(donor_bounds) = donor_bounds { + let window = crate::fill_cmds::sampling_window(*bounds, canvas).intersect(&donor_bounds); + if contains_donor(window, usable) { *bounds } else { widened_sampling_bounds(*bounds, donor_bounds, canvas, n)? } + } else { + *bounds + }; + let (window, px) = crate::fill_cmds::content_aware_native( + surf, + sampling_bounds, + canvas, + true, + SEED, + |x, y| label(x, y) == k, + // A partial active layer (EdgeHalf in #10868) must extend its own image, not + // sample its empty canvas rows into the new strip. Keep legacy sampling intact. + usable, + )?; + // The piece's bounds, its pixels from the fill and the rest as they were. + let mut out = crate::allocation::read_region(surf, *bounds, "Content-Aware crop output")?; + let ww = window.width() as usize; + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + if label(x, y) != k || !window.contains(x, y) { + continue; + } + let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n; + let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n; + if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) { + d.copy_from_slice(s); } } - Ok((*bounds, out)) - }) - .collect::>()?; + } + Ok((*bounds, out)) + }; + let filled: Vec<(Rect, Vec)> = if own_pixels { + // A fallback window can span the canvas: keep only one added-target window live. + pieces.iter().enumerate().map(fill_piece).collect::>()? + } else { + pieces.par_iter().enumerate().map(fill_piece).collect::>()? + }; let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; for (r, px) in filled { surf.write_region(r, &px); } - Ok(()) + Ok(true) +} + +/// Checks the candidate rectangle for a covered pixel that passes the target's donor policy. +fn contains_donor(bounds: Rect, usable: impl Fn(i32, i32) -> bool) -> bool { + (bounds.y0..bounds.y1).any(|y| (bounds.x0..bounds.x1).any(|x| usable(x, y))) +} + +/// Widens the sampling envelope to include distant donors, after pricing the actual float read. +fn widened_sampling_bounds(piece: Rect, donors: Rect, canvas: Rect, channels: usize) -> Result { + let expanded = piece.union(&donors); + let window = crate::fill_cmds::sampling_window(expanded, canvas); + let bytes = (window.width() as usize) + .checked_mul(window.height() as usize) + .and_then(|pixels| pixels.checked_mul(channels)) + .and_then(|samples| samples.checked_mul(std::mem::size_of::())); + if bytes.is_none_or(|bytes| bytes > MAX_WIDENED_SAMPLE_BYTES) { + return Err(EngineError::Other("the expanded Content-Aware crop sampling window is too large".into())); + } + Ok(expanded) +} + +/// Only the measured, editable active-raster case adds a target to the Background. Ancestor +/// visibility and locks count too; all unmeasured cases keep the original target selection. +fn additional_target(doc: &Document, active: Option) -> Option { + let background = doc.background()?.id; + let id = active.filter(|id| *id != background)?; + let path = doc.path_of(id)?; + let layer = doc.layer_at(&path)?; + (matches!(layer.content, photocraft_doc::LayerContent::Raster(_)) && doc.shown_at(&path) && !doc.locks_at(&path).any()).then_some(id) } /// The Background, else the active pixel layer. @@ -242,9 +334,9 @@ mod tests { fn unrotated_coverage_is_exact() { let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4); let m = c.margin(); - assert!(!c.covered(1, 0, m), "x = -1"); - assert!(c.covered(2, 0, m), "x = 0"); - assert!(c.covered(6, 0, m), "x = 4"); - assert!(!c.covered(7, 0, m), "x = 5"); + assert!(!c.covered(1, 0, m, None), "x = -1"); + assert!(c.covered(2, 0, m, None), "x = 0"); + assert!(c.covered(6, 0, m, None), "x = 4"); + assert!(!c.covered(7, 0, m, None), "x = 5"); } } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index afdf86f4fa..cb9bc5fe8a 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -48,14 +48,22 @@ pub struct DocState { /// to `.pcraft` or PSD, and not a history step. pub color_samplers: Vec<[f64; 2]>, /// History panel snapshots, oldest first (`history.newSnapshot`, #2966): the first (id 0) is - /// the document as opened. Kept for the session only, as in Photoshop: not history states, + /// the document as opened (unless History Options › Automatically Create First Snapshot is + /// off). Kept for the session only, as in Photoshop: not history states, /// and not saved with the document. pub snapshots: Vec, /// The id the next snapshot gets; ids are never reused, so deleting one keeps the others. pub(crate) next_snapshot: u64, + /// The snapshot the latest save added (History Options › Automatically Create New Snapshot + /// When Saving), so saving an unchanged document again doesn't add a duplicate row. + pub(crate) save_snapshot: Option, /// The snapshot set as the History Brush's source (`history.setSource {snapshot}`); `None`: /// the source is a state, kept by the history ([`photocraft_ops::History::brush_source`]). pub(crate) source_snapshot: Option, + /// Names the user gave layers (Rename Layer, Duplicate Layer's Name, `type.edit`'s name): a + /// type layer keeps such a name although it looks like a copy's ("Title copy 2"), while a + /// name Duplicate Layer made still follows the text (#9023). Kept for the session only. + pub(crate) typed_names: std::collections::HashMap, } impl DocState { @@ -78,6 +86,11 @@ impl DocState { let mut history = History::default(); let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() }; history.set_current_layers(target.clone()); + // Spot channels open visible, printing over the composite; alpha channels open hidden. + let channel_view = channel_cmds::ChannelView { + alpha_visible: (0..doc.channels.len()).map(|i| photocraft_compose::multichannel::is_overprint_spot(&doc, i)).collect(), + ..Default::default() + }; let doc = Arc::new(doc); // Photoshop's first snapshot: the document as opened, named after it, so the History // panel's top row can always bring it back (#2966). @@ -96,7 +109,7 @@ impl DocState { view_revision: 0, last_damage: None, coalesce: None, - channel_view: Default::default(), + channel_view, isolated_layers: Vec::new(), symmetry: None, fx_collapsed: Vec::new(), @@ -105,7 +118,9 @@ impl DocState { color_samplers: Vec::new(), snapshots: vec![first], next_snapshot: 1, + save_snapshot: None, source_snapshot: None, + typed_names: Default::default(), } } /// The selected layers in bottom-to-top document order, always including the active layer. @@ -185,7 +200,7 @@ pub struct ToolState { impl ToolState { /// How many CMYK picks are remembered. - const CMYK_PICKS: usize = 8; + pub const CMYK_PICKS: usize = 8; /// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1). pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) { diff --git a/crates/engine/src/embed_all_linked_tests.rs b/crates/engine/src/embed_all_linked_tests.rs new file mode 100644 index 0000000000..70bed5c0c1 --- /dev/null +++ b/crates/engine/src/embed_all_linked_tests.rs @@ -0,0 +1,144 @@ +//! Layer > Smart Objects > Embed Linked and Embed All Linked (#11401). + +use photocraft_doc::{LayerContent, SmartSource}; +use serde_json::json; + +use crate::Session; +use crate::cmd::command_specs; + +fn label(id: &str) -> &'static str { + command_specs().iter().find(|c| c.id == id).map(|c| c.label).unwrap_or_else(|| panic!("{id} is not registered")) +} + +/// Adds a layer with a few painted pixels (an empty layer can't become a smart object). +fn painted_layer(s: &mut Session, red: f32) { + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("setup", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(photocraft_geom::Rect::new(2, 2, 10, 10), &[red, 1.0 - red, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); +} + +/// Three smart objects: the first two linked to files in `dir`, the third embedded. +fn three(dir: &std::path::Path) -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut ids = [0u64; 3]; + for (i, slot) in ids.iter_mut().enumerate() { + painted_layer(&mut s, 0.25 * (i + 1) as f32); + *slot = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + if i < 2 { + let path = dir.join(format!("linked{i}.pcraft")); + s.execute("layer.smartObjects.convertToLinked", json!({"path": path.to_str().unwrap()})).unwrap(); + } + } + (s, ids) +} + +fn is_linked(s: &Session, id: u64) -> bool { + let doc = &s.active().unwrap().doc; + let layer = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.id.0 == id).unwrap(); + match &layer.content { + LayerContent::Smart(sm) => matches!(sm.source, SmartSource::Linked { .. }), + other => panic!("not smart: {}", other.kind_name()), + } +} + +/// The file name and bytes an embedded smart object carries. +fn embedded(s: &Session, id: u64) -> (String, Vec) { + let doc = &s.active().unwrap().doc; + let layer = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.id.0 == id).unwrap(); + match &layer.content { + LayerContent::Smart(sm) => match &sm.source { + SmartSource::Embedded { file_name, bytes } => (file_name.clone(), bytes.to_vec()), + SmartSource::Linked { .. } => panic!("still linked"), + }, + other => panic!("not smart: {}", other.kind_name()), + } +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("pc-embed-all-{name}-{}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn embed_linked_and_embed_all_linked_are_named_in_the_engine() { + assert_eq!(label("layer.smartObjects.convertToEmbedded"), "Embed Linked"); + assert_eq!(label("layer.smartObjects.embedAllLinked"), "Embed All Linked"); + // The warp commands stay registered for scripts and the Edit menu routes. + for id in ["layer.smartObjects.warp", "layer.smartObjects.perspectiveWarp", "layer.smartObjects.puppetWarp"] { + label(id); + } +} + +#[test] +fn embed_all_linked_embeds_both_linked_objects_in_one_undoable_step() { + let dir = temp_dir("both"); + let (mut s, ids) = three(&dir); + assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]) && !is_linked(&s, ids[2])); + assert!(s.is_enabled("layer.smartObjects.embedAllLinked")); + let before = s.active().unwrap().history.entries().len(); + let files: Vec> = (0..2).map(|i| photocraft_format::read_file(&dir.join(format!("linked{i}.pcraft"))).unwrap()).collect(); + assert_ne!(files[0], files[1], "the fixtures differ"); + let third = embedded(&s, ids[2]); + + let r = s.execute("layer.smartObjects.embedAllLinked", json!({})).unwrap(); + assert_eq!(r["embedded"], json!([ids[0], ids[1]])); + assert!(ids.iter().all(|&id| !is_linked(&s, id))); + for (i, file) in files.iter().enumerate() { + let (name, bytes) = embedded(&s, ids[i]); + assert_eq!(name, format!("linked{i}.pcraft"), "object {i} keeps its own file name"); + assert_eq!(&bytes, file, "object {i} carries its own linked file's bytes"); + } + assert_eq!(embedded(&s, ids[2]), third, "the already embedded object is untouched"); + let entries = s.active().unwrap().history.entries(); + assert_eq!(entries.len(), before + 1, "one history step"); + assert_eq!(entries.last().map(String::as_str), Some("Embed All Linked")); + assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "nothing left to embed"); + + s.execute("edit.undo", json!({})).unwrap(); + assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]) && !is_linked(&s, ids[2])); + assert_eq!(s.active().unwrap().history.entries().len(), before); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn embed_all_linked_with_no_linked_object_is_an_error_not_a_panic() { + let mut s = Session::new(); + assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "no document"); + assert!(s.execute("layer.smartObjects.embedAllLinked", json!({})).is_err()); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + painted_layer(&mut s, 1.0); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + assert!(!s.is_enabled("layer.smartObjects.embedAllLinked"), "only embedded objects"); + let before = s.active().unwrap().history.entries().len(); + assert!(s.execute("layer.smartObjects.embedAllLinked", json!({})).is_err()); + assert_eq!(s.active().unwrap().history.entries().len(), before); +} + +#[test] +fn embed_all_linked_refuses_the_whole_step_when_a_linked_file_is_missing() { + let dir = temp_dir("missing"); + let (mut s, ids) = three(&dir); + std::fs::remove_file(dir.join("linked1.pcraft")).unwrap(); + let before = s.active().unwrap().history.entries().len(); + let err = s.execute("layer.smartObjects.embedAllLinked", json!({})).unwrap_err().to_string(); + assert!(err.contains("linked1.pcraft"), "the error names the missing file: {err}"); + assert!(is_linked(&s, ids[0]) && is_linked(&s, ids[1]), "nothing was embedded"); + assert_eq!(s.active().unwrap().history.entries().len(), before); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn embed_linked_still_embeds_one_object_under_its_old_id() { + let dir = temp_dir("one"); + let (mut s, ids) = three(&dir); + s.execute("layer.smartObjects.convertToEmbedded", json!({"layer": ids[0]})).unwrap(); + assert!(!is_linked(&s, ids[0]) && is_linked(&s, ids[1])); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 0abdb6302a..4f464b79d7 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -23,8 +23,14 @@ pub enum EngineError { PsdApprovalRequired { name: String, required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, /// The file's estimated memory cannot be admitted within the hard ceiling, which no approval /// can raise. The requirement may equal the ceiling when the estimate saturated. - #[error("cannot open “{name}”: its estimated memory requirement of {required} bytes cannot be admitted within the {limit} byte hard limit on this machine")] - PsdHardBudget { name: String, required: u64, limit: u64 }, + /// + /// `free_memory` is the free memory measured when it set the limit, if it did. + #[error( + "cannot open “{name}”: its estimated memory requirement of {required_text} ({required} bytes) cannot be admitted within the {limit_text} ({limit} byte) hard limit{scope}{scratch}{advice}", + required_text = bytes_text(.required), limit_text = bytes_text(.limit), scope = build_limit_scope(build_memory_limit()), + scratch = unmeasured_scratch_hint(scratch_room_unmeasured()), advice = photocraft_io::memory::low_memory_advice(.free_memory) + )] + PsdHardBudget { name: String, required: u64, limit: u64, free_memory: Option }, /// The file behind an opened handle is not the one whose memory estimate was approved. /// The document is discarded and is not opened. #[error("“{name}” changed since it was checked; open it again to approve the new file")] @@ -36,6 +42,75 @@ pub enum EngineError { Cancelled, } +/// Where this build's hard memory limit comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BuildMemoryLimit { + /// Measured from the computer's memory (64-bit desktop). + Measured, + /// The 32-bit desktop build: its cap, or the computer's memory if lower. + Desktop32Bit, + /// Fixed by the browser build. + FixedWeb, +} + +/// This build's memory-limit source, from its target. +fn build_memory_limit() -> BuildMemoryLimit { + memory_limit_for(cfg!(target_arch = "wasm32"), cfg!(target_pointer_width = "32")) +} + +/// The limit source for a target. The wasm32 check comes first: wasm32 is also 32-bit. +fn memory_limit_for(is_wasm32: bool, is_32_bit: bool) -> BuildMemoryLimit { + if is_wasm32 { + BuildMemoryLimit::FixedWeb + } else if is_32_bit { + BuildMemoryLimit::Desktop32Bit + } else { + BuildMemoryLimit::Measured + } +} + +/// The text after "hard limit": where the limit comes from and, where another build could open +/// the file, where to get it. Its sentences stand apart so the action isn't lost in the cause. +fn build_limit_scope(limit: BuildMemoryLimit) -> String { + let releases = crate::updates_cmds::RELEASES; + match limit { + BuildMemoryLimit::Measured => " on this machine".to_owned(), + BuildMemoryLimit::Desktop32Bit => format!( + " on this machine. This 32-bit build of PhotoCraft can use at most 2 GiB of memory for a file, or less if the computer has less memory free. The 64-bit build may be able to open it: download it from {releases}" + ), + BuildMemoryLimit::FixedWeb => format!(" of the browser version of PhotoCraft. To open it, download the desktop app from {releases}"), + } +} + +/// The current scratch configuration, not the admission-time cause of a refusal. +fn scratch_room_unmeasured() -> bool { + let stats = photocraft_raster::spill::stats(); + stats.free_space_unmeasured && stats.size_cap_bytes.is_none() +} + +/// Configuration guidance only: the refusal can instead be bound by a caller or build limit, +/// or by memory that cannot spill. Current scratch stats cannot identify that cause. +fn unmeasured_scratch_hint(unmeasured: bool) -> &'static str { + if unmeasured { + ". Current scratch configuration: free space can't be measured and no Scratch Size Limit is set. To configure scratch capacity, set a Scratch Size Limit in Preferences > Scratch Disks, or use a disk whose free space can be measured. Scratch capacity cannot override caller or build limits, or replace memory that cannot spill to disk" + } else { + "" + } +} + +/// A size for people, next to the exact byte count: "21.5 GiB", "900 MiB", "17 B". +fn bytes_text(bytes: &u64) -> String { + const GIB: u64 = 1 << 30; + const MIB: u64 = 1 << 20; + if *bytes >= GIB { + format!("{:.1} GiB", *bytes as f64 / GIB as f64) + } else if *bytes >= MIB { + format!("{} MiB", bytes / MIB) + } else { + format!("{bytes} B") + } +} + pub type Result = std::result::Result; impl EngineError { @@ -59,7 +134,7 @@ pub(crate) fn io_open_error(name: &str, error: photocraft_io::IoError) -> Engine IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => { EngineError::PsdApprovalRequired { name: name.to_string(), required, soft_limit, hard_limit, available_ram } } - IoError::PsdImportBudget { required, limit } => EngineError::PsdHardBudget { name: name.to_string(), required, limit }, + IoError::PsdImportBudget { required, limit, free_memory } => EngineError::PsdHardBudget { name: name.to_string(), required, limit, free_memory }, IoError::SourceChanged => EngineError::SourceChanged { name: name.to_string() }, error => EngineError::Other(format!("{name}: {error}")), } @@ -70,3 +145,102 @@ impl From for EngineError { Self::Other(format!("not enough memory for this operation ({error}); the document was not changed")) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn hard(free_memory: Option) -> EngineError { + EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 4000, free_memory } + } + + #[test] + fn hard_budget_message_tells_the_user_to_free_memory_when_it_was_measured() { + let message = hard(Some(900 << 20)).to_string(); + assert!(message.ends_with("Close other apps and try again"), "{message}"); + assert!(message.contains("900 MiB"), "{message}"); + + let message = hard(Some(0)).to_string(); + assert!(message.contains("measured no free memory"), "{message}"); + + let message = hard(None).to_string(); + assert!(message.contains(&build_limit_scope(build_memory_limit())), "{message}"); + assert!(!message.contains("Close other apps"), "{message}"); + } + + #[test] + fn io_open_error_carries_the_free_memory_through() { + for free_memory in [None, Some(0), Some(3 << 30)] { + let error = photocraft_io::IoError::PsdImportBudget { required: 9000, limit: 4000, free_memory }; + match io_open_error("big.psd", error) { + EngineError::PsdHardBudget { required: 9000, limit: 4000, free_memory: carried, .. } => assert_eq!(carried, free_memory), + other => panic!("expected the typed hard refusal, got {other:?}"), + } + } + } + + #[test] + fn hard_budget_scope_text_per_build() { + assert_eq!(build_limit_scope(BuildMemoryLimit::Measured), " on this machine"); + let releases = crate::updates_cmds::RELEASES; + assert_eq!( + build_limit_scope(BuildMemoryLimit::Desktop32Bit), + format!( + " on this machine. This 32-bit build of PhotoCraft can use at most 2 GiB of memory for a file, or less if the computer has less memory free. The 64-bit build may be able to open it: download it from {releases}" + ) + ); + assert_eq!( + build_limit_scope(BuildMemoryLimit::FixedWeb), + format!(" of the browser version of PhotoCraft. To open it, download the desktop app from {releases}") + ); + } + + #[test] + fn unmeasured_scratch_hint_only_when_unmeasured() { + assert_eq!(unmeasured_scratch_hint(false), ""); + let hint = unmeasured_scratch_hint(true); + assert!(hint.starts_with(". Current scratch configuration:"), "{hint}"); + assert!(hint.contains("Scratch Size Limit in Preferences > Scratch Disks") && hint.contains("disk whose free space can be measured"), "{hint}"); + } + + #[test] + fn scratch_configuration_hint_does_not_promise_to_raise_a_binding_limit() { + let hint = unmeasured_scratch_hint(true); + // A caller-bound refusal can carry the same live scratch state as a capacity refusal. + // The note must describe that state without attributing this file's refusal to it. + assert!(hint.contains("cannot override caller or build limits"), "{hint}"); + assert!(hint.contains("memory that cannot spill to disk"), "{hint}"); + assert!(!hint.contains("for this file") && !hint.contains("so it adds no room"), "{hint}"); + } + + #[test] + fn hard_budget_message_names_file_numbers_and_scope() { + let text = EngineError::PsdHardBudget { name: "big.psd".into(), required: 23_049_071_616, limit: 2_147_483_648, free_memory: None }.to_string(); + for part in ["big.psd", "21.5 GiB", "23049071616 bytes", "2.0 GiB", "2147483648 byte", "hard limit"] { + assert!(text.contains(part), "{text}"); + } + assert!(text.contains(&build_limit_scope(build_memory_limit())), "{text}"); + } + + #[test] + fn bytes_text_picks_a_readable_unit() { + assert_eq!(bytes_text(&17), "17 B"); + assert_eq!(bytes_text(&(900 << 20)), "900 MiB"); + assert_eq!(bytes_text(&(2 << 30)), "2.0 GiB"); + assert_eq!(bytes_text(&23_049_071_616), "21.5 GiB"); + assert!(bytes_text(&u64::MAX).ends_with(" GiB")); + } + + #[test] + fn memory_limit_follows_the_target() { + assert_eq!(memory_limit_for(true, true), BuildMemoryLimit::FixedWeb); + assert_eq!(memory_limit_for(true, false), BuildMemoryLimit::FixedWeb); + assert_eq!(memory_limit_for(false, true), BuildMemoryLimit::Desktop32Bit); + assert_eq!(memory_limit_for(false, false), BuildMemoryLimit::Measured); + } + + #[test] + fn two_gib_in_the_text_matches_the_cap() { + assert_eq!(photocraft_io::memory::NATIVE_32BIT_PEAK_CAP, 2 << 30); + } +} diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 9e73d10811..d2fcd10064 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -185,6 +185,20 @@ pub fn apply_filter_to_surface( selection: Option<&photocraft_raster::Surface>, canvas: photocraft_geom::Rect, ) -> Result> { + apply_filter_to_surface_in(id, params, surf, bounds, selection, canvas, &crate::jobs::JobCtx::new()) +} + +/// Render a stored filter with the caller's cancellation context, including custom kernel preparation. +pub(crate) fn apply_filter_to_surface_in( + id: &str, + params: &Value, + surf: &photocraft_raster::Surface, + bounds: photocraft_geom::Rect, + selection: Option<&photocraft_raster::Surface>, + canvas: photocraft_geom::Rect, + ctx: &crate::jobs::JobCtx, +) -> Result> { + ctx.check()?; // Replays bypass command dispatch; nested paths must still obey the requesting session's // authority, including adjustment LUTs and plug-ins, before any resolver can read them. crate::jobs::authorize_render_command(id, params)?; @@ -207,13 +221,31 @@ pub fn apply_filter_to_surface( let Some(mut fp) = params_for(id, params) else { return Ok(None) }; // A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an // unknown filter; the command itself already refused an unreadable map when it was added. - if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() { - return Ok(None); + // The stack owns progress across filters; this operation only inherits cancellation. + let cancelled = || ctx.cancelled(); + let ctl = photocraft_raster::Interrupt::cancel_only(&cancelled); + match crate::filters_ext::resolve_map_path(&mut fp, params, &ctl) { + Err(EngineError::Cancelled) => return Err(EngineError::Cancelled), + Err(e) if matches!(fp, FilterParams::CustomShapeBlur { .. }) => return Err(e), + Err(_) => return Ok(None), + Ok(()) => {} } let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds); let content = surf.content_bounds(); let area = algo::output_area(&fp, content, bounds, sel_bounds); - Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))) + let out = algo::apply_in_with(surf, &fp, area, bounds, selection, canvas.union(&content), &ctl); + filter_output(out, ctx.cancelled()).map(Some) +} + +/// A filter's result. The filter also yields nothing when it cannot prepare its working data (a +/// custom Shape Blur kernel it cannot allocate or does not accept): that is a failure to report, +/// not a cancellation, unless the job was cancelled. +fn filter_output(out: Option, cancelled: bool) -> Result { + match out { + Some(surface) => Ok(surface), + None if cancelled => Err(EngineError::Cancelled), + None => Err(EngineError::Other("the filter could not prepare its working data (not enough memory, or an unusable kernel)".into())), + } } pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> { @@ -238,6 +270,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result if id == "filter.distort.displace" { prepared = crate::filters_ext::displace_request(&prepared); } + crate::filters_ext::resolve_shape_path(s, id, &mut prepared)?; let p = &prepared; let mut fp = params_for(id, p).ok_or_else(|| EngineError::Other(format!("unknown filter {id}")))?; crate::filters_ext::resolve(s, &mut fp, p)?; @@ -258,10 +291,13 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result s, &label, move |doc, _, ctx| { + ctx.check()?; + let prepare_kernel = |fp: &mut FilterParams| ctx.stage(0.0, 0.1, "Preparing filter", |ctl| crate::filters_ext::resolve_shape_kernel(fp, &p, ctl)); let filter = |surf: &photocraft_raster::Surface, fp: &FilterParams, area, bounds, sel: Option<&photocraft_raster::Surface>, extent| { let _working = photocraft_raster::memory::try_reserve_operation(algo::working_bytes(fp, area, bounds, surf.format().channels())) .map_err(EngineError::Other)?; - ctx.stage(0.0, 1.0, &msg, |ctl| algo::apply_in_with(surf, fp, area, bounds, sel, extent, ctl)).ok_or(EngineError::Cancelled) + let out = ctx.stage(0.1, 1.0, &msg, |ctl| algo::apply_in_with(surf, fp, area, bounds, sel, extent, ctl)); + filter_output(out, ctx.cancelled()) }; let selection = doc.selection.clone(); let sel_bounds = selection.as_ref().map(photocraft_raster::Surface::content_bounds); @@ -272,6 +308,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result // Neighbourhood filters repeat edge pixels at the canvas edge (or past it, where the // layer has off-canvas pixels) instead of fading in transparency, as Photoshop does. if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(layer), &p)? { + prepare_kernel(&mut fp)?; let content = doc_bounds.union(&surf.content_bounds()); let area = algo::output_area(&fp, content, bounds, sel_bounds); *surf = filter(surf, &fp, area, bounds, selection.as_ref(), algo::separable::edge_extent(doc_bounds, content))?; @@ -319,10 +356,11 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result }; // Non-destructive: record the filter and re-render the smart object from its source. let sf = SmartFilter { command: id.clone(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; - return crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref()).map(|()| fp); + return crate::smart_cmds::add_smart_filter_in(doc, layer, sf, selection.as_ref(), ctx).map(|()| fp); } _ => return Err(EngineError::Other("not a pixel layer".into())), }; + prepare_kernel(&mut fp)?; if let Some(colors) = colors { if fp.makes_transparency() { return Err(EngineError::Other("this filter requires composite channels".into())); diff --git a/crates/engine/src/filters/tests.rs b/crates/engine/src/filters/tests.rs index 4d21d2dfdf..24df853b3b 100644 --- a/crates/engine/src/filters/tests.rs +++ b/crates/engine/src/filters/tests.rs @@ -536,3 +536,15 @@ fn displace_on_a_smart_object_embeds_its_map() { let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; assert_eq!(first_value(&sm.smart_filters[1].params), 0.0, "the black map, not the passed-back white one"); } + +/// A filter that yields nothing without a cancellation (a custom Shape Blur kernel it could not +/// allocate or accept) reports a failure; only a cancelled job reports `Cancelled`. +#[test] +fn filter_without_output_is_cancelled_only_when_the_job_was() { + assert!(matches!(filter_output(None, true), Err(EngineError::Cancelled))); + let failure = filter_output(None, false).unwrap_err(); + assert!(!matches!(failure, EngineError::Cancelled), "{failure:?}"); + assert!(failure.to_string().contains("could not prepare"), "{failure}"); + let surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + assert!(filter_output(Some(surface), true).is_ok()); +} diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 6cedd4a022..da816353d5 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -158,6 +158,12 @@ pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> { (rule.accepts(value), rule.expected()) } else { match key.as_str() { + "shapeId" | "shapeName" if id == "filter.blur.shapeBlur" => (value.as_str().is_some_and(|s| !s.is_empty()), "a nonempty string".into()), + "shapePath" if id == "filter.blur.shapeBlur" => ( + crate::vector_cmds::parse_path(value).is_ok_and(|path| !path.is_empty() && path.control_bounds().is_some()), + "a nonempty vector path".into(), + ), + "shapeOriginType" if id == "filter.blur.shapeBlur" => (value.as_i64() == Some(9), "the recorded custom-shape origin 9".into()), "layer" => (value.as_u64().is_some() || value.is_string(), "an unsigned layer id or layer name".into()), "coalesce" => (value.is_string(), "a string".into()), "target" => ( diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 963e04e729..467b3a8dc1 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -202,9 +202,13 @@ const PARAMS: &[(&str, Build)] = &[ // Blur ("filter.blur.smartBlur", smart_blur), ("filter.blur.lensBlur", lens_blur), - ("filter.blur.shapeBlur", |p| FilterParams::ShapeBlur { - radius: f(p, "radius", 10.0).clamp(5.0, 1000.0), - shape: choice(p, "shape", "circle", BLUR_SHAPES, BlurShape::Circle), + ("filter.blur.shapeBlur", |p| { + let radius = f(p, "radius", 10.0).clamp(5.0, 1000.0); + if ["shapeId", "shapeName", "shapePath"].iter().any(|key| p.get(key).is_some_and(|v| !v.is_null())) { + FilterParams::CustomShapeBlur { radius, kernel: None } + } else { + FilterParams::ShapeBlur { radius, shape: choice(p, "shape", "circle", BLUR_SHAPES, BlurShape::Circle) } + } }), // Blur Gallery ("filter.blurGallery.tiltShift", tilt_shift), @@ -480,6 +484,7 @@ fn uses_colours(id: &str) -> bool { /// recorded params (journal, smart filters) reproduce the result. pub(crate) fn prepare(s: &Session, id: &str, p: &Value) -> Value { let mut out = if p.is_object() { p.clone() } else { json!({}) }; + let _ = resolve_shape_path(s, id, &mut out); if uses_colours(id) && let Value::Object(m) = &mut out { @@ -489,6 +494,14 @@ pub(crate) fn prepare(s: &Session, id: &str, p: &Value) -> Value { out } +/// Keep portable custom-shape geometry in recorded params. Kernel preparation stays in the job. +pub(crate) fn resolve_shape_path(s: &Session, id: &str, p: &mut Value) -> Result<()> { + if id == "filter.blur.shapeBlur" && p.get("shapePath").is_none() && ["shapeId", "shapeName"].iter().any(|key| p.get(key).is_some_and(|v| !v.is_null())) { + p["shapePath"] = crate::vector_cmds::path_json(&crate::presets::shapes::filter_path(s, p)?); + } + Ok(()) +} + /// A flattened document (or one of its layers) as an RGBA map image. fn map_from_document(doc: &Document, layer: Option) -> Option { let buf = match layer.and_then(|l| doc.layer(photocraft_doc::LayerId(l))) { @@ -565,7 +578,8 @@ fn map_from_path(path: &str, layer: Option) -> Result> /// the map, so their re-render reads the map again here; without it the filter passed the pixels /// through. The path is stored in the document, so it is followed only as /// [`crate::doc_path::resolve`] allows. `mapDocument` needs the session and is left unresolved. -pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> { +pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value, ctl: &photocraft_raster::Interrupt) -> Result<()> { + resolve_shape_kernel(fp, p, ctl)?; if let FilterParams::Displace { map, .. } = fp && map.is_none() && let Some(path) = map_path(p) @@ -576,6 +590,43 @@ pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> { Ok(()) } +/// Rebuild a custom kernel from portable vector data for direct and Smart Filter rendering. +pub(crate) fn resolve_shape_kernel(fp: &mut FilterParams, p: &Value, ctl: &photocraft_raster::Interrupt) -> Result<()> { + let FilterParams::CustomShapeBlur { radius, kernel } = fp else { return Ok(()) }; + ctl.check().map_err(|_| EngineError::Cancelled)?; + // Preserve Shape Blur's existing radius ceiling before rasterizing an O(r²) kernel. + if !radius.is_finite() || !(0.0..=1000.0).contains(radius) { + return Err(EngineError::Other("Shape Blur radius must be finite and at most 1000".into())); + } + let value = p.get("shapePath").ok_or_else(|| EngineError::Other("Shape Blur needs an installed shape or shapePath".into()))?; + let path = crate::vector_cmds::parse_path(value).map_err(|why| EngineError::Other(format!("Shape Blur: {why}")))?; + if path.is_empty() || path.control_bounds().is_none() { + return Err(EngineError::Other("Shape Blur needs a nonempty shape".into())); + } + let ri = radius.ceil() as i32; + let size = ri.saturating_mul(2).saturating_add(1); + let fitted = crate::presets::shapes::fit(&path, [0.0, 0.0, f64::from(size), f64::from(size)], true); + if fitted + .subpaths + .iter() + .flat_map(|sub| &sub.knots) + .flat_map(|knot| [knot.anchor, knot.in_ctrl, knot.out_ctrl]) + .any(|p| !p.x.is_finite() || !p.y.is_finite()) + { + return Err(EngineError::Other("Shape Blur's selected shape has nonfinite geometry".into())); + } + let rect = Rect::new(0, 0, size, size); + // The coverage buffer is size² floats (16 MB at radius 1000): admit it before allocating. + let side = u64::try_from(size).unwrap_or(0); + let _working = photocraft_raster::memory::try_reserve_operation(side.saturating_mul(side).saturating_mul(4)).map_err(EngineError::Other)?; + let data = photocraft_vector::path_coverage_with(&fitted, rect, ctl).ok_or(EngineError::Cancelled)?; + if data.len() != (size as usize).saturating_mul(size as usize) || !data.iter().any(|v| *v > 0.0) { + return Err(EngineError::Other("Shape Blur's selected shape has no coverage".into())); + } + *kernel = Some(Arc::new(algo::Image { rect, ch: 1, data })); + Ok(()) +} + /// Resolves inputs read from the target layer itself: the layer mask as Lens Blur depth. pub(crate) fn resolve_in_layer(fp: &mut FilterParams, layer: &Layer, bounds: Rect) { if let FilterParams::LensBlur { depth: DepthSource::LayerMask, depth_map, .. } = fp @@ -767,7 +818,7 @@ pub fn specs() -> Vec { "filter.blur.shapeBlur", "Shape Blur…", ["Filter", "Blur"], - r##"{"radius":5..1000=10,"shape":"circle|ring|square|diamond|triangle|hexagon|star|heart|cross"}"## + r##"{"radius":5..1000=10,"shape":"circle|ring|square|diamond|triangle|hexagon|star|heart|cross","shapeId":str?,"shapeName":str?,"shapePath":json?,"shapeOriginType":9? (recorded ATN metadata)} (custom shape: exact installed id, name, or portable path; ids never fall back to names)"## ), // Blur Gallery cmd!( diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index c5a30dc155..60db1cbb45 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -33,11 +33,18 @@ fn lens_flare_keeps_off_canvas_centers_and_native_defaults() { /// Extreme centers and malformed inputs remain bounded command executions with finite pixels. #[test] fn lens_flare_extreme_centers_do_not_panic_or_corrupt_pixels() { - for p in [json!({"centerX": 1e300, "centerY": -1e300}), json!({"centerX": null, "centerY": []}), json!({"centerX": 5, "centerY": -5, "brightness": -100})] { - let mut s = session(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("filter.render.lensFlare", p))); - assert!(result.is_ok()); - assert!(pixels(&s).iter().all(|v| v.is_finite())); + for lens in ["zoom", "prime35", "prime105", "moviePrime"] { + for p in [ + json!({"centerX": 1e300, "centerY": -1e300, "lens": lens}), + json!({"centerX": 1e38, "centerY": -1e38, "lens": lens}), + json!({"centerX": null, "centerY": [], "lens": lens}), + json!({"centerX": 5, "centerY": -5, "brightness": -100, "lens": lens}), + ] { + let mut s = session(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("filter.render.lensFlare", p.clone()))); + assert!(result.is_ok(), "{p}"); + assert!(pixels(&s).iter().all(|v| v.is_finite()), "non-finite pixels for {p}"); + } } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index dc681b7d8e..722e8b5c6d 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -27,6 +27,16 @@ pub fn session(s: &Session) -> Value { } pub fn document(d: &DocState) -> Value { + document_with(d, false) +} + +/// [`document`] plus every text run and paragraph run, anti-aliasing and the full serialized +/// layer-effect settings, for format round-trip checks (`photocraft-cli info --detail`). +pub fn document_detail(d: &DocState) -> Value { + document_with(d, true) +} + +fn document_with(d: &DocState, detail: bool) -> Value { let doc = &d.doc; let selected = d.selected_layers(); let instances = smart_instances(&doc.layers); @@ -43,7 +53,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "hasSelection": doc.selection.is_some(), "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), - "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata), detail)).collect::>(), "history": d.history.entries(), "historySource": crate::history_source_cmds::source_json(d), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), @@ -69,7 +79,13 @@ pub fn document(d: &DocState) -> Value { /// Layer tree top-to-bottom (display order). Smart object instances are counted within `l`. pub fn layer(l: &Layer) -> Value { - layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None) + layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None, false) +} + +/// [`layer`] plus every text run and paragraph run, anti-aliasing and the full serialized +/// layer-effect settings, for format round-trip checks (`photocraft-cli info --detail`). +pub fn layer_detail(l: &Layer) -> Value { + layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None, true) } /// How many smart objects in `layers`, groups included, share each contents identity. @@ -87,7 +103,13 @@ fn smart_instances(layers: &[Layer]) -> HashMap { } /// [`layer`] with a `selected` flag on every node (Layers panel multi-selection). -fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMap, meta: Option<&photocraft_doc::Metadata>) -> Value { +fn layer_sel( + l: &Layer, + selected: &[photocraft_doc::LayerId], + instances: &HashMap, + meta: Option<&photocraft_doc::Metadata>, + detail: bool, +) -> Value { let bounds = l.surface().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] @@ -112,7 +134,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMa }); match &l.content { LayerContent::Group(g) => { - v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta)).collect()); + v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta, detail)).collect()); v["expanded"] = json!(g.expanded); if let Some(a) = &g.artboard { v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset}); @@ -153,6 +175,11 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMa } LayerContent::Text(t) => { v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt}); + if detail { + v["text"]["antialias"] = serde_json::to_value(t.antialias).unwrap_or(Value::Null); + v["text"]["runs"] = serde_json::to_value(t.char_runs()).unwrap_or(Value::Null); + v["text"]["paragraphs"] = serde_json::to_value(t.paragraph_runs()).unwrap_or(Value::Null); + } } _ => {} } @@ -182,6 +209,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMa "enabled": l.effects.enabled, "items": l.effects.items.iter().map(|e| json!({"kind": e.label(), "enabled": e.enabled()})).collect::>(), }); + if detail { + v["effects"]["data"] = serde_json::to_value(&l.effects.items).unwrap_or(Value::Null); + } } // Video layer (Layer › Video Layers): the frame stack behind the displayed content. if let Some(vid) = &l.video { @@ -200,6 +230,55 @@ mod tests { use photocraft_color::PixelFormat; use photocraft_doc::{LayerMask, Path, VectorMask}; + #[test] + fn layer_inspect_reports_text_runs_and_full_effect_data() { + let text = photocraft_doc::TextLayer { + text: "Hi".into(), + font_family: "Fixture Sans".into(), + size_pt: 18.0, + antialias: photocraft_doc::text::AntiAlias::Crisp, + ..Default::default() + }; + let mut lyr = Layer::new("Styled text", LayerContent::Text(text)); + lyr.effects.enabled = true; + lyr.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + + let plain = layer(&lyr); + for key in ["antialias", "runs", "paragraphs"] { + assert!(plain["text"].get(key).is_none(), "{key}"); + } + assert!(plain["effects"].get("data").is_none()); + + let inspected = layer_detail(&lyr); + let text_json = &inspected["text"]; + assert_eq!(text_json["text"], "Hi"); + assert_eq!(text_json["font"], "Fixture Sans"); + assert_eq!(text_json["sizePt"], 18.0); + assert_eq!(text_json["antialias"], serde_json::to_value(photocraft_doc::text::AntiAlias::Crisp).unwrap()); + assert_eq!( + text_json["runs"], + serde_json::to_value(match &lyr.content { + LayerContent::Text(t) => t.char_runs(), + _ => Vec::new(), + }) + .unwrap() + ); + assert_eq!( + text_json["paragraphs"], + serde_json::to_value(match &lyr.content { + LayerContent::Text(t) => t.paragraph_runs(), + _ => Vec::new(), + }) + .unwrap() + ); + + let effects = &inspected["effects"]; + assert_eq!(effects["enabled"], true); + assert_eq!(effects["items"][0]["kind"], "Drop Shadow"); + assert_eq!(effects["data"], serde_json::to_value(&lyr.effects.items).unwrap()); + assert!(effects["data"][0]["DropShadow"]["distance"].is_number(), "{effects}"); + } + #[test] fn an_adjustment_layer_is_reported_with_the_params_its_command_takes() { let mut s = Session::new(); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b6bb17bcd8..1390e89902 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -12,6 +12,10 @@ //! - [`Session::start`] runs a job-capable command on a worker thread and returns its //! [`JobId`]; [`Session::poll_jobs`] (called every frame by the UI) applies finished jobs. //! On wasm `start` runs inline (same API, written once). +//! - Work that is written as [`StepWork`] (Place) also runs where there are no threads: with +//! [`Session::set_cooperative_jobs`] (the web build's default) [`run_stepped`] registers the job +//! without a worker, and [`Session::poll_jobs`] runs one slice of it, about 8 ms, per call. The +//! UI stays responsive between slices, and the result is the same as on a worker. //! - [`Session::cancel_job`] sets the flag; the algorithms check it per tile, row band or //! iteration, and the result is discarded. The document is never touched by a worker. //! - A job with an effect outside the session (a save replacing its file) calls @@ -34,6 +38,8 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +const MAX_BACKGROUND_JOBS: usize = 16; + type Authorization = fn(&str, &Value) -> Result<()>; thread_local! { @@ -457,29 +463,53 @@ pub struct JobInfo { type Payload = Box; type Apply = Box Result + Send>; -/// Wall-clock milliseconds since some fixed point (0 on wasm, where `Instant` is unavailable and -/// jobs run inline anyway). +/// Milliseconds on the monotonic clock since some fixed point (`web_time` reads the browser's on wasm). fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 - } - #[cfg(target_arch = "wasm32")] - { - 0.0 + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +/// How much of a frame [`Session::poll_jobs`] gives a [`StepWork`] job, unless +/// [`Session::set_job_step_budget`] says otherwise. +const STEP_BUDGET: std::time::Duration = std::time::Duration::from_millis(8); + +/// A deadline no job reaches, for running [`StepWork`] to the end in one go. +fn no_deadline() -> web_time::Instant { + let now = web_time::Instant::now(); + now.checked_add(std::time::Duration::from_secs(1 << 30)).unwrap_or(now) +} + +/// Work a job does a slice at a time (see [`run_stepped`]). +pub trait StepWork: Send + 'static { + /// Does some of the work and returns `Ok(None)` for "not finished, call again", or the result. + /// It should return soon after `deadline` passes (checking it between units of work), but + /// always does at least one unit, so that calling it again makes progress. `ctx` reports + /// progress and tells of cancellation, as for a worker. + fn step(&mut self, ctx: &JobCtx, deadline: web_time::Instant) -> Result>; +} + +/// Runs `work` to the end, as a worker does. +fn drive(mut work: impl StepWork, ctx: &JobCtx) -> Result { + loop { + ctx.check()?; + if let Some(t) = work.step(ctx, no_deadline())? { + return Ok(t); + } } } +/// Runs one slice of a cooperative job: `Ok(None)` when it has more to do. +type Stepper = Box Result> + Send>; + struct Running { id: JobId, command: String, label: String, params: Value, journal: bool, - /// The recording (`ActionState::take`) running when the job started, if any (set by - /// `dispatch`, once recording resumes). Only that recording gets the job's step: one started - /// later must not pick up an edit made before it. + /// The take active at dispatch, including strokes started with tool recording off. + started_in_take: Option, + /// The take eligible to receive this job's step under the mode at dispatch. recorded_by: Option, pre_selected: Option>, before: crate::actions_record::Before, @@ -494,7 +524,10 @@ struct Running { /// The document's layer selection when the job started: applying targets it, not whatever /// got selected meanwhile (a Type click does that without editing the document, #7366). layers: Option, - rx: std::sync::mpsc::Receiver>, + /// Where the worker thread sends its result. A cooperative job has none: [`Session::poll_jobs`] + /// runs its `stepper` instead. + rx: Option>>, + stepper: Option, apply: Option, #[cfg(not(target_arch = "wasm32"))] handle: Option>, @@ -522,6 +555,16 @@ impl LayerSelection { } impl Running { + /// Runs a slice of a cooperative job until `deadline`: `Ok(None)` when it has more to do. + /// A cancelled job ends with `Err(Cancelled)` before doing more. + fn step(&mut self, deadline: web_time::Instant) -> Result> { + self.ctx.check()?; + match self.stepper.as_mut() { + Some(stepper) => stepper(&self.ctx, deadline), + None => Err(EngineError::Other(format!("`{}` has nothing to run", self.label))), + } + } + fn info(&self) -> JobInfo { JobInfo { id: self.id, @@ -549,8 +592,18 @@ struct Draining { release: bool, } +#[cfg(not(target_arch = "wasm32"))] +fn reap_draining(draining: &mut Vec) -> bool { + let mut release = false; + draining.retain(|d| { + let done = d.handle.is_finished(); + release |= done && d.release; + !done + }); + release +} + /// The session's job table (see the module docs). -#[derive(Default)] pub struct Jobs { next: u64, /// Set while [`Session::start`] runs a command: [`run`] spawns instead of running inline. @@ -567,6 +620,39 @@ pub struct Jobs { draining: Vec, /// The context inline job work runs under (see [`Session::set_inline_job_ctx`]). inline: Option, + /// No worker threads: [`StepWork`] jobs are stepped by [`Session::poll_jobs`], everything else + /// runs inline. On for wasm; see [`Session::set_cooperative_jobs`]. + cooperative: bool, + /// How long [`Session::poll_jobs`] runs a cooperative job per call. + step_budget: std::time::Duration, +} + +// Derivable on a desktop, where `cooperative` starts off; wasm starts it on. +#[allow(clippy::derivable_impls)] +impl Default for Jobs { + /// No jobs, spawning off; a session steps its jobs itself (cooperative) on wasm only. + fn default() -> Self { + Self { + next: 0, + spawn: false, + pending: None, + running: Vec::new(), + events: Vec::new(), + recent: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + draining: Vec::new(), + inline: None, + cooperative: cfg!(target_arch = "wasm32"), + step_budget: STEP_BUDGET, + } + } +} + +impl Jobs { + /// Does a job started now run on a worker thread? (Not inline, and not stepped on this thread.) + fn threaded(&self) -> bool { + self.spawn && !self.cooperative && !cfg!(target_arch = "wasm32") + } } const RECENT: usize = 16; @@ -671,36 +757,19 @@ pub fn run( // The job's document: stored paths it follows are resolved against it on any thread. let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from); let _document = crate::doc_path::scope(document.as_deref()); - if !s.jobs.spawn || cfg!(target_arch = "wasm32") { + if !s.jobs.threaded() { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); } - if s.jobs.pending.is_some() { - return Err(EngineError::Other(format!("`{label}` started a second background job in one command"))); - } - let (doc, snapshot, layers) = match (lock_document, s.active()) { - (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc)), Some(LayerSelection::of(st))), - (true, None) => return Err(EngineError::NoDocument), - (false, _) => (None, None, None), - }; - let ctx = JobCtx::new(); + let mut job = new_job(s, label, lock_document, authorization, document.clone(), apply)?; let (tx, rx) = std::sync::mpsc::channel::>(); - let apply_document = document.clone(); - let erased: Apply = Box::new(move |s, any| { - let _authorization = AuthorizationScope::enter(authorization); - let _document = crate::doc_path::scope(apply_document.as_deref()); - let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; - apply(s, *t) - }); - s.jobs.next += 1; - let id = JobId(s.jobs.next); #[cfg(not(target_arch = "wasm32"))] - let handle = { - let wctx = ctx.clone(); + { + let wctx = job.ctx.clone(); let label_w = label.to_string(); // The work closure is moved into the thread. If spawning fails (thread limits), there is // no way to get it back, so the error is reported instead. - let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || { + let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", job.id.0)).spawn(move || { let _authorization = AuthorizationScope::enter(authorization); let _document = crate::doc_path::scope(document.as_deref()); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload))) @@ -709,36 +778,109 @@ pub fn run( // The receiver is gone when the job was cancelled: nothing to report. let _ = tx.send(r); }); - Some(spawned.map_err(|e| EngineError::Other(format!("could not start a background job: {e}")))?) - }; + job.handle = Some(spawned.map_err(|e| EngineError::Other(format!("could not start a background job: {e}")))?); + } #[cfg(target_arch = "wasm32")] { // Unreachable (wasm runs inline above); keep the types used. drop((work, tx)); } - s.jobs.pending = Some(Running { - id, + job.rx = Some(rx); + let id = job.id; + s.jobs.pending = Some(job); + Ok(json!({"job": id.0})) +} + +/// [`run`] for work done a slice at a time ([`StepWork`]): where the session has no worker threads +/// ([`Session::set_cooperative_jobs`], the web build) the job runs on the UI thread, a slice of +/// about 8 ms per [`Session::poll_jobs`], so the window keeps repainting meanwhile. Elsewhere it +/// is [`run`] with `work` driven to the end: on a worker thread for [`Session::start`], inline for +/// [`Session::execute`]. The result is the same either way. +pub fn run_stepped( + s: &mut Session, + label: &str, + lock_document: bool, + work: impl StepWork, + apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, +) -> Result { + if !(s.jobs.spawn && s.jobs.cooperative) { + return run(s, label, lock_document, move |ctx| drive(work, ctx), apply); + } + let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); + let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from); + let mut job = new_job(s, label, lock_document, authorization, document.clone(), apply)?; + let (label_w, mut work) = (label.to_string(), work); + // The slices run under the requesting session's authority and document, as a worker would. + job.stepper = Some(Box::new(move |ctx, deadline| { + let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(document.as_deref()); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work.step(ctx, deadline))) + .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged")))) + .map(|done| done.map(|t| Box::new(t) as Payload)) + })); + let id = job.id; + s.jobs.pending = Some(job); + Ok(json!({"job": id.0})) +} + +/// A job for [`run`] and [`run_stepped`] to start, without its worker. +fn new_job( + s: &mut Session, + label: &str, + lock_document: bool, + authorization: Option, + document: Option, + apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, +) -> Result { + if s.jobs.pending.is_some() { + return Err(EngineError::Other(format!("`{label}` started a second background job in one command"))); + } + #[cfg(not(target_arch = "wasm32"))] + s.reap_draining_jobs(); + let admitted = s.jobs.running.len(); + #[cfg(not(target_arch = "wasm32"))] + let admitted = admitted.saturating_add(s.jobs.draining.len()); + if admitted >= MAX_BACKGROUND_JOBS { + return Err(EngineError::Other(format!( + "too many background jobs ({MAX_BACKGROUND_JOBS}); wait for a running or cancelling job to finish before starting another" + ))); + } + let (doc, snapshot, layers) = match (lock_document, s.active()) { + (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc)), Some(LayerSelection::of(st))), + (true, None) => return Err(EngineError::NoDocument), + (false, _) => (None, None, None), + }; + let erased: Apply = Box::new(move |s, any| { + let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(document.as_deref()); + let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; + apply(s, *t) + }); + s.jobs.next = s.jobs.next.checked_add(1).ok_or_else(|| EngineError::Other("background job identifier space exhausted".into()))?; + Ok(Running { + id: JobId(s.jobs.next), command: String::new(), label: label.to_string(), params: Value::Null, journal: true, + started_in_take: None, recorded_by: None, pre_selected: None, before: crate::actions_record::Before::capture(s), doc, snapshot, - ctx, + ctx: JobCtx::new(), started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, filter_mask_layer: s.filter_mask_layer, layers, - rx, + rx: None, + stepper: None, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] - handle, - }); - Ok(json!({"job": id.0})) + handle: None, + }) } /// A document edit whose body is the job: like [`Session::edit`] (one undo step named `label`), @@ -750,7 +892,7 @@ pub fn edit_job( f: impl FnOnce(&mut Document, &mut Option, &JobCtx) -> Result + Send + 'static, finish: impl FnOnce(R) -> Value + Send + 'static, ) -> Result { - if !s.jobs.spawn || cfg!(target_arch = "wasm32") { + if !s.jobs.threaded() { // Inline: exactly `Session::edit`, as before jobs existed. let ctx = s.jobs.inline.clone().unwrap_or_default(); let r = s.edit(label, |doc, active| f(doc, active, &ctx))?; @@ -890,6 +1032,11 @@ pub struct PendingPsdPreflight { } impl Session { + /// A recording mode change must not reclassify an in-flight stroke from this take. + pub(crate) fn tool_stroke_job_running(&self) -> bool { + self.jobs.running.iter().any(|job| job.started_in_take == Some(self.actions.take) && crate::actions_cmds::tool_stroke(&job.command)) + } + /// Run the heavy part of inline (not background) job commands under `ctx`, so another thread /// can cancel them: a live preview whose parameters changed stops its stale computation /// (`Err(Cancelled)`, the document unchanged) instead of finishing it. `None` restores the @@ -901,7 +1048,10 @@ impl Session { /// Run a command, in the background when it supports it (see [`jobs`](crate::jobs)). Errors /// like [`Session::execute`] for unknown, disabled or failing commands. pub fn start(&mut self, id: &str, params: Value) -> Result { - self.dispatch(id, params, true) + let before = self.active_layer_stamp(); + let started = self.dispatch(id, params, true); + self.note_layer_change(before); + started } pub const MAX_PENDING_PREFLIGHTS: usize = 32; @@ -1061,6 +1211,24 @@ impl Session { self.start_open_inner(name, source, true, false, None, None, background) } + /// Place a file's bytes in the background, like `file.placeEmbedded` (`linked`: `file.placeLinked` + /// to that path) for bytes that have no path to read: decode, flatten and fit on a worker, then + /// insert the layer as one undo step when [`Session::poll_jobs`] applies it. `name` names the + /// layer and the file; `params` are the command's (`scale`, `center`, `fit`, `smartObject`). + /// The job's result is the command's: `{layer, frame, scale, bounds, smartObject}`. The active + /// document is locked meanwhile, so this is refused while another job runs on it. On wasm (and + /// in a [cooperative](Session::set_cooperative_jobs) session) it is not a worker's job but + /// one [`Session::poll_jobs`] runs a slice of per call. + pub fn start_place_bytes(&mut self, name: &str, bytes: Vec, linked: Option, params: Value) -> Result { + let input = crate::file_cmds::PlaceInput::new(self, name, bytes, linked, ¶ms)?; + let (command, label) = (input.command(), input.job_label()); + if let Some(conflict) = self.job_conflict(command, true) { + return Err(EngineError::Other(conflict)); + } + let work = crate::file_cmds::PlaceSteps::new(input, self.cooperative_jobs()); + self.start_job_stepped(command, json!({"name": name}), &label, true, work, crate::file_cmds::apply_place) + } + /// Decodes a file in a job and optionally holds damaged PSD content for /// interactive confirmation while leaving headless opens non-interactive. // Keep the worker and apply closures together with their execution options. @@ -1249,9 +1417,30 @@ impl Session { work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + self.start_registered(command, params, spawn, |s| run(s, label, lock_document, work, apply)) + } + + /// [`Session::start_job`] for [`StepWork`]: on a worker, or where the session is cooperative + /// (see [`Session::set_cooperative_jobs`]) a slice per [`Session::poll_jobs`] on this thread. + pub fn start_job_stepped( + &mut self, + command: &str, + params: Value, + label: &str, + lock_document: bool, + work: impl StepWork, + apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, + ) -> Result { + self.start_registered(command, params, true, |s| run_stepped(s, label, lock_document, work, apply)) + } + + /// Runs `register` (which calls [`run`] or [`run_stepped`]) with background jobs allowed when + /// `spawn` (the jobs then run on a worker, or on wasm inline or in slices, see + /// `Jobs::threaded`), and files the job it started under `command`. + fn start_registered(&mut self, command: &str, params: Value, spawn: bool, register: impl FnOnce(&mut Session) -> Result) -> Result { let prev_spawn = self.jobs.spawn; - self.jobs.spawn = spawn && !cfg!(target_arch = "wasm32"); - let r = run(self, label, lock_document, work, apply); + self.jobs.spawn = spawn; + let r = register(self); self.jobs.spawn = prev_spawn; let pending = self.jobs.pending.take(); match (r, pending) { @@ -1272,46 +1461,86 @@ impl Session { } } + /// Whether this session has no worker threads: [`StepWork`] jobs are run a slice at a time by + /// [`Session::poll_jobs`] on the UI thread, and every other command runs inline. The web + /// build (wasm) is always like this; tests turn it on to run that path on a desktop. + pub fn cooperative_jobs(&self) -> bool { + self.jobs.cooperative + } + + /// How long one [`Session::poll_jobs`] runs a cooperative job (8 ms by default, a share of a + /// frame). A slice always does one unit of work, so zero makes every poll do just that: tests + /// use it to count polls without depending on the machine's speed. + pub fn set_job_step_budget(&mut self, budget: std::time::Duration) { + self.jobs.step_budget = budget; + } + + /// Turn [`Session::cooperative_jobs`] on or off. Jobs already running keep running as they + /// started. + pub fn set_cooperative_jobs(&mut self, on: bool) { + self.jobs.cooperative = on; + } + /// Apply finished background jobs (on this thread) and return every job that ended since - /// the last call. Cheap when nothing runs; the UI calls it every frame. + /// the last call. Also runs one slice (about 8 ms) of a cooperative job, if there is one. Cheap + /// when nothing runs; the UI calls it every frame. pub fn poll_jobs(&mut self) -> Vec { let mut i = 0; + let mut stepped = false; + let budget = self.jobs.step_budget; while i < self.jobs.running.len() { - match self.jobs.running.get(i).map(|r| r.rx.try_recv()) { - Some(Ok(r)) => { + let Some(job) = self.jobs.running.get_mut(i) else { break }; + let finished = match &job.rx { + Some(rx) => match rx.try_recv() { + Ok(r) => Some(r), + Err(std::sync::mpsc::TryRecvError::Empty) => None, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + Some(Err(EngineError::Other(format!("`{}` stopped unexpectedly; the document is unchanged", job.label)))) + } + }, + // One cooperative job gets the frame's budget; any others wait for the next poll. + None if !stepped => { + stepped = true; + // An absurd budget (`Duration::MAX` through `set_job_step_budget`) must not + // overflow the deadline: fall back to one no job reaches. + job.step(web_time::Instant::now().checked_add(budget).unwrap_or_else(no_deadline)).transpose() + } + None => None, + }; + match finished { + Some(r) => { let job = self.jobs.running.remove(i); self.finish_job(job, r); } - Some(Err(std::sync::mpsc::TryRecvError::Empty)) => i += 1, - Some(Err(std::sync::mpsc::TryRecvError::Disconnected)) => { - let job = self.jobs.running.remove(i); - let label = job.label.clone(); - self.finish_job(job, Err(EngineError::Other(format!("`{label}` stopped unexpectedly; the document is unchanged")))); - } - None => break, + None => i += 1, } } #[cfg(not(target_arch = "wasm32"))] - { - let mut release = false; - self.jobs.draining.retain(|d| { - let done = d.handle.is_finished(); - release |= done && d.release; - !done - }); - if release { - photocraft_raster::scratch::release(); - } - } + self.reap_draining_jobs(); std::mem::take(&mut self.jobs.events) } + #[cfg(not(target_arch = "wasm32"))] + fn reap_draining_jobs(&mut self) { + if reap_draining(&mut self.jobs.draining) { + photocraft_raster::scratch::release(); + } + } + /// Block until job `id` ends, apply it, and return its result (the control channel's and - /// MCP's default `wait`, and the CLI). `Err(Cancelled)` if it was cancelled. + /// MCP's default `wait`, and the CLI). `Err(Cancelled)` if it was cancelled. A cooperative job + /// is run to the end here. pub fn wait_job(&mut self, id: JobId) -> Result { if let Some(i) = self.jobs.running.iter().position(|r| r.id == id) { - let job = self.jobs.running.remove(i); - let r = job.rx.recv().unwrap_or_else(|_| Err(EngineError::Other(format!("`{}` stopped unexpectedly", job.label)))); + let mut job = self.jobs.running.remove(i); + let r = match &job.rx { + Some(rx) => rx.recv().unwrap_or_else(|_| Err(EngineError::Other(format!("`{}` stopped unexpectedly", job.label)))), + None => loop { + if let Some(r) = job.step(no_deadline()).transpose() { + break r; + } + }, + }; self.finish_job(job, r); } match self.jobs.recent.iter().rev().find(|j| j.id == id) { @@ -1482,6 +1711,7 @@ impl Session { if !(params.is_object() || params.is_null()) { return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() }); } + let params = crate::file_cmds::batch_open_params(self, id, ¶ms).unwrap_or(params); // Untrusted sessions gate every command, including the ones a command runs on its own // behalf (`file.automate.conditionalModeChange` runs `image.mode.*`), before any side // effect. @@ -1573,6 +1803,13 @@ impl Session { self.hint_before = None; } let pending = self.jobs.pending.take(); + // Shape Blur by an installed shape's id or name records the geometry it ran with, as the + // filter's own prepared params carry it: the journal (Last Filter, recorded actions) + // replays where that shape is not installed. The run resolved it already, so it can't fail. + let mut params = params; + if r.is_ok() { + let _ = crate::filters_ext::resolve_shape_path(self, id, &mut params); + } let out = match (r, pending) { (Err(e), Some(job)) => { job.ctx.cancel(); @@ -1583,7 +1820,8 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; - job.recorded_by = self.actions.recording.map(|_| self.actions.take); + job.started_in_take = self.actions.recording.map(|_| self.actions.take); + job.recorded_by = job.started_in_take.filter(|_| self.actions.record_tools || !crate::actions_cmds::tool_stroke(id)); job.pre_selected = pre_selected.clone(); job.before = std::mem::take(&mut record_before); let jid = job.id; @@ -1696,7 +1934,13 @@ impl Session { } /// Apply (or report the failure of) a job whose worker has delivered `r`. - fn finish_job(&mut self, mut job: Running, r: Result) { + fn finish_job(&mut self, job: Running, r: Result) { + let before = self.active_layer_stamp(); + self.finish_job_step(job, r); + self.note_layer_change(before); + } + + fn finish_job_step(&mut self, mut job: Running, r: Result) { let apply = job.apply.take(); // Keep the typed error beside its display text, so `wait_job` and `JobEvent` can still // report a hard budget's required/limit or an approval request's limits. diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 109a008826..f6d4cf6b85 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::time::{Duration, Instant}; use serde_json::json; @@ -1161,6 +1162,296 @@ fn every_job_terminal_path_drops_a_live_commit_stroke() { } } +// ---------- cooperative jobs (no worker threads, as on the web) ---------- + +/// A job that needs `needed` calls, and records what each one saw. +struct Counting { + needed: usize, + done: usize, + /// How far ahead the deadline of each call was. + ahead: Arc>>, + calls: Arc, + thread: Arc>>, + dropped: Arc, +} + +impl Drop for Counting { + fn drop(&mut self) { + self.dropped.store(true, Ordering::Relaxed); + } +} + +impl StepWork for Counting { + fn step(&mut self, ctx: &JobCtx, deadline: web_time::Instant) -> Result> { + self.ahead.lock().unwrap().push(deadline.saturating_duration_since(web_time::Instant::now())); + *self.thread.lock().unwrap() = Some(std::thread::current().id()); + self.calls.fetch_add(1, Ordering::Relaxed); + self.done += 1; + ctx.progress(self.done as f32 / self.needed as f32, "Counting"); + Ok((self.done >= self.needed).then_some(self.done)) + } +} + +struct Probe { + ahead: Arc>>, + calls: Arc, + thread: Arc>>, + dropped: Arc, +} + +fn counting(needed: usize) -> (Counting, Probe) { + let probe = Probe { ahead: Arc::default(), calls: Arc::default(), thread: Arc::default(), dropped: Arc::default() }; + let work = + Counting { needed, done: 0, ahead: probe.ahead.clone(), calls: probe.calls.clone(), thread: probe.thread.clone(), dropped: probe.dropped.clone() }; + (work, probe) +} + +fn start_counting(s: &mut Session, needed: usize, lock: bool) -> (JobId, Probe) { + let (work, probe) = counting(needed); + let id = job(s.start_job_stepped("test.stepped", json!({}), "Stepping", lock, work, |_, n| Ok(json!({"steps": n}))).unwrap()); + (id, probe) +} + +#[test] +fn a_cooperative_job_gets_one_slice_per_poll_on_the_calling_thread() { + let mut s = session(40, 30); + s.set_cooperative_jobs(true); + let (id, probe) = start_counting(&mut s, 3, true); + assert!(s.has_jobs()); + assert_eq!(probe.calls.load(Ordering::Relaxed), 0, "starting does no work"); + // The document is locked as for a worker. + let refused = s.execute("layer.new.layer", json!({})).unwrap_err().to_string(); + assert!(refused.contains("Stepping"), "{refused}"); + for polls in 1..=2 { + assert!(s.poll_jobs().is_empty(), "poll {polls}"); + assert_eq!(probe.calls.load(Ordering::Relaxed), polls); + assert!(s.has_jobs()); + } + let info = s.job(id).unwrap(); + assert_eq!(info.message, "Counting"); + assert!((info.progress - 2.0 / 3.0).abs() < 1e-6, "{}", info.progress); + let events = s.poll_jobs(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].outcome, JobOutcome::Done(json!({"steps": 3}))); + assert_eq!((events[0].command.as_str(), events[0].label.as_str()), ("test.stepped", "Stepping")); + assert!(!s.has_jobs()); + assert_eq!(*probe.thread.lock().unwrap(), Some(std::thread::current().id()), "no worker thread"); + // Each slice was given about a frame's budget: no more than 8 ms from the poll. + let ahead = probe.ahead.lock().unwrap().clone(); + assert!(ahead.iter().all(|d| *d <= STEP_BUDGET), "{ahead:?}"); + assert!(ahead.iter().any(|d| *d > Duration::ZERO), "{ahead:?}"); + assert!(probe.dropped.load(Ordering::Relaxed), "the finished work is let go"); + s.execute("layer.new.layer", json!({})).unwrap(); +} + +#[test] +fn a_cooperative_job_reports_real_elapsed_time() { + let mut s = session(8, 8); + s.set_cooperative_jobs(true); + let (id, _) = start_counting(&mut s, 5, false); + std::thread::sleep(Duration::from_millis(20)); + assert!(s.job(id).unwrap().elapsed_ms >= 15.0, "{}", s.job(id).unwrap().elapsed_ms); + assert_eq!(s.wait_job(id).unwrap(), json!({"steps": 5})); +} + +/// A budget too large to add to an `Instant` runs the slice to the end instead of overflowing. +#[test] +fn a_huge_step_budget_does_not_panic() { + let mut s = session(8, 8); + s.set_cooperative_jobs(true); + s.set_job_step_budget(Duration::MAX); + let (id, probe) = start_counting(&mut s, 2, false); + s.poll_jobs(); + assert_eq!(probe.calls.load(Ordering::Relaxed), 1, "one slice still runs"); + assert_eq!(s.wait_job(id).unwrap(), json!({"steps": 2})); +} + +#[test] +fn only_one_cooperative_job_is_stepped_per_poll() { + let mut s = session(8, 8); + s.set_cooperative_jobs(true); + let (a, pa) = start_counting(&mut s, 5, false); + let (b, pb) = start_counting(&mut s, 5, false); + s.poll_jobs(); + assert_eq!((pa.calls.load(Ordering::Relaxed), pb.calls.load(Ordering::Relaxed)), (1, 0)); + s.poll_jobs(); + assert_eq!((pa.calls.load(Ordering::Relaxed), pb.calls.load(Ordering::Relaxed)), (2, 0)); + // Both finish all the same. + assert_eq!(s.wait_job(a).unwrap(), json!({"steps": 5})); + assert_eq!(s.wait_job(b).unwrap(), json!({"steps": 5})); +} + +#[test] +fn wait_job_runs_a_cooperative_job_to_the_end() { + let mut s = session(8, 8); + s.set_cooperative_jobs(true); + let (id, probe) = start_counting(&mut s, 50, true); + assert_eq!(s.wait_job(id).unwrap(), json!({"steps": 50})); + assert_eq!(probe.calls.load(Ordering::Relaxed), 50); + assert!(!s.has_jobs()); +} + +#[test] +fn cancelling_a_cooperative_job_between_polls_discards_it() { + let mut s = session(8, 8); + s.set_cooperative_jobs(true); + let (id, probe) = start_counting(&mut s, 10, true); + s.poll_jobs(); + assert!(s.cancel_job(id)); + assert!(probe.dropped.load(Ordering::Relaxed), "the work is let go at once"); + assert!(!s.has_jobs()); + let events = s.poll_jobs(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].outcome, JobOutcome::Cancelled); + assert_eq!(probe.calls.load(Ordering::Relaxed), 1, "no slice after the cancel"); + assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); + s.execute("layer.new.layer", json!({})).unwrap(); +} + +#[test] +fn a_failing_or_panicking_slice_fails_the_job() { + struct Fails(bool); + impl StepWork<()> for Fails { + fn step(&mut self, _: &JobCtx, _: web_time::Instant) -> Result> { + if self.0 { Err(EngineError::Other("the slice failed".into())) } else { panic!("a bug in the slice") } + } + } + for panics in [false, true] { + let mut s = session(8, 8); + s.set_cooperative_jobs(true); + let id = job(s.start_job_stepped("test.stepped", json!({}), "Failing", true, Fails(!panics), |_, ()| Ok(json!({}))).unwrap()); + let events = s.poll_jobs(); + assert_eq!(events.len(), 1, "panics={panics}"); + let JobOutcome::Failed(msg) = &events[0].outcome else { panic!("{:?}", events[0]) }; + assert_eq!(events[0].id, id); + if panics { + assert!(msg.contains("internal error"), "{msg}"); + } else { + assert_eq!(msg, "the slice failed"); + } + assert!(!s.has_jobs()); + s.execute("layer.new.layer", json!({})).unwrap(); + } +} + +#[test] +fn a_cooperative_session_runs_other_commands_inline() { + let mut s = session(60, 40); + s.set_cooperative_jobs(true); + assert!(matches!(s.start("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(), Started::Done(_))); + assert!(!s.has_jobs()); + let opened = s.start_open("a.png", OpenSource::Bytes(Arc::new(Vec::new()))); + assert!(opened.is_err() || matches!(opened, Ok(Started::Done(_))), "opening runs inline too"); +} + +#[test] +fn stepped_work_without_cooperation_runs_on_a_worker_or_inline() { + // A worker thread, when started in the background. + let mut s = session(8, 8); + assert!(!s.cooperative_jobs() || cfg!(target_arch = "wasm32")); + let (id, probe) = start_counting(&mut s, 4, true); + let e = wait_event(&mut s, id); + assert_eq!(e.outcome, JobOutcome::Done(json!({"steps": 4}))); + assert_ne!(*probe.thread.lock().unwrap(), Some(std::thread::current().id()), "ran on a worker"); + assert_eq!(probe.calls.load(Ordering::Relaxed), 4); + + // Inline, when run as part of a command. + let (work, probe) = counting(6); + let v = run_stepped(&mut s, "Inline", true, work, |_, n| Ok(json!({"steps": n}))).unwrap(); + assert_eq!(v, json!({"steps": 6})); + assert_eq!(*probe.thread.lock().unwrap(), Some(std::thread::current().id())); + assert!(!s.has_jobs()); + + // Cooperative, but not started in the background: inline as well. + s.set_cooperative_jobs(true); + let (work, _) = counting(3); + assert_eq!(run_stepped(&mut s, "Inline", true, work, |_, n| Ok(json!({"steps": n}))).unwrap(), json!({"steps": 3})); +} + +#[test] +fn an_inline_stepped_job_stops_when_cancelled() { + let mut s = session(8, 8); + let ctx = JobCtx::new(); + s.set_inline_job_ctx(Some(ctx.clone())); + let (work, probe) = counting(1_000_000); + ctx.cancel(); + let r = run_stepped(&mut s, "Inline", true, work, |_, n| Ok(json!({"steps": n}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert!(probe.calls.load(Ordering::Relaxed) <= 1); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn background_admission_counts_cancelled_workers_until_they_finish() { + // Dropping the senders releases every worker even if an assertion unwinds. + let mut gates = Vec::new(); + let mut s = Session::new(); + let mut ids = Vec::new(); + for _ in 0..MAX_BACKGROUND_JOBS { + let (tx, rx) = std::sync::mpsc::channel::<()>(); + gates.push(tx); + ids.push(job(s + .start_job( + "test.gated", + json!({}), + "gated", + false, + move |_| { + let _ = rx.recv(); + Ok(()) + }, + |_, ()| Ok(Value::Null), + ) + .unwrap())); + } + let rejected = s.start_job("test.extra", json!({}), "extra", false, |_| -> Result<()> { panic!("over-limit worker ran") }, |_, ()| Ok(Value::Null)); + assert!(rejected.unwrap_err().to_string().contains("too many background jobs")); + for id in ids { + assert!(s.cancel_job(id)); + } + assert!(s.jobs.running.is_empty()); + assert_eq!(s.jobs.draining.len(), MAX_BACKGROUND_JOBS); + let rejected = + s.start_job("test.extra", json!({}), "extra", false, |_| -> Result<()> { panic!("draining workers bypassed the cap") }, |_, ()| Ok(Value::Null)); + assert!(rejected.unwrap_err().to_string().contains("too many background jobs")); + drop(gates); + s.join_cancelled_jobs(); + let id = job(s.start_job("test.resumed", json!({}), "resumed", false, |_| Ok(()), |_, ()| Ok(json!("resumed"))).unwrap()); + assert_eq!(s.wait_job(id).unwrap(), json!("resumed")); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn reaping_finished_workers_preserves_the_release_request() { + let (tx, rx) = std::sync::mpsc::channel::<()>(); + let mut draining = vec![ + Draining { handle: std::thread::spawn(|| {}), doc: None, release: true }, + Draining { + handle: std::thread::spawn(move || { + let _ = rx.recv(); + }), + doc: None, + release: true, + }, + ]; + let start = Instant::now(); + while !draining[0].handle.is_finished() { + assert!(start.elapsed() < Duration::from_secs(30)); + std::thread::yield_now(); + } + assert!(reap_draining(&mut draining)); + assert_eq!(draining.len(), 1); + assert!(!reap_draining(&mut draining), "a running worker must retain its release request"); + drop(tx); + let start = Instant::now(); + while !draining[0].handle.is_finished() { + assert!(start.elapsed() < Duration::from_secs(30)); + std::thread::yield_now(); + } + assert!(reap_draining(&mut draining)); + assert!(draining.is_empty()); +} + #[cfg(unix)] fn tmp_dir(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("photocraft-engine-jobs-{}-{name}", std::process::id())); @@ -1278,14 +1569,14 @@ fn a_failed_job_keeps_its_typed_error_for_wait_and_events() { json!({}), "Test hard budget", false, - |_ctx| -> Result<()> { Err(EngineError::PsdHardBudget { name: "too-big.psd".into(), required: 9000, limit: 4000 }) }, + |_ctx| -> Result<()> { Err(EngineError::PsdHardBudget { name: "too-big.psd".into(), required: 9000, limit: 4000, free_memory: None }) }, |_, _| Ok(Value::Null), ) .unwrap(); let id = job(started); let err = s.wait_job(id).expect_err("the job fails"); assert!( - matches!(&err, EngineError::PsdHardBudget { name, required, limit } if name == "too-big.psd" && *required == 9000 && *limit == 4000), + matches!(&err, EngineError::PsdHardBudget { name, required, limit, .. } if name == "too-big.psd" && *required == 9000 && *limit == 4000), "wait_job must not flatten the typed refusal: {err:?}" ); let event = s.poll_jobs().into_iter().find(|e| e.id == id).expect("the job event"); diff --git a/crates/engine/src/layer_place.rs b/crates/engine/src/layer_place.rs index 3cb9c1e854..53d8f836b8 100644 --- a/crates/engine/src/layer_place.rs +++ b/crates/engine/src/layer_place.rs @@ -17,6 +17,8 @@ pub enum Place { /// transform box, Paste, the Gradient tool's live gradient, video layers, Render and Vanishing /// Point drawing, a new frame): never inside a group with a lock of its own (all, pixels, /// transparency or position) that would refuse that edit; the layer goes above the group. + /// Never on an artboard its content is centred off, even beside a layer on that board: it goes + /// above the board. AutoEditable, /// Directly above the anchor, never inside it: a layer made from another layer, or the second /// and later of several new layers. @@ -30,10 +32,20 @@ pub enum Place { /// parent's children (bottom to top). A missing or stale `anchor` means the top of the stack. pub fn insertion_point(doc: &Document, anchor: Option, layer: &Layer, place: Place) -> (LayerPath, usize) { let root_top = (Vec::new(), doc.layers.len()); - let Some(path) = anchor.and_then(|a| doc.path_of(a)) else { return root_top }; + // Beside a frame's image means beside the frame: the image slot stays whole. + let Some(path) = anchor.and_then(|a| doc.insertion_path(a)) else { return root_top }; // An artboard stays at the top level: beside the anchor's top-level layer. let path = if layer.artboard().is_some() { path.get(..1).map(<[usize]>::to_vec).unwrap_or_default() } else { path }; let (Some((&k, parent)), Some(target)) = (path.split_last(), doc.layer_at(&path)) else { return root_top }; + // Beside a layer on an artboard, content placed off that board (Paste, Place, a Type or Shape + // click: centred off it) goes above the board at the top level, as it does with the board + // itself as the anchor ([`takes_new_layers`]): inside, the board would clip it away (a layer + // copied off every board, pasted with a layer on a board selected). The other new layers + // ([`Place::Auto`]) are empty or cover the canvas (a neutral fill), and stay on the board. + let editable_new = place == Place::AutoEditable; + if editable_new && let Some(board) = off_board(doc, &path, layer) { + return (Vec::new(), board + 1); + } let locks = &target.locks; let editable = !(locks.all || locks.pixels || locks.transparency || locks.position); match place { @@ -61,6 +73,16 @@ pub fn insert_new(doc: &mut Document, anchor: Option, mut layer: Layer, index += 1; } layer.clipped = false; + } else if place == Place::AutoEditable && index > 0 && sib.get(index - 1).is_some_and(|below| below.artboard().is_some()) { + // New content placed directly above an artboard (pasted off its board, or beside a + // board that doesn't take it): never clipped to it, where the board would clip it + // away, and past the clipped layers above the slot so their clipping mask stays + // whole, as a new artboard goes. A layer put [`Place::Above`] a board keeps its flag: + // the layers after a pasted board, a duplicate. + while sib.get(index).is_some_and(|above| above.clipped) { + index += 1; + } + layer.clipped = false; } else if sib.get(index).is_some_and(|above| above.clipped) { layer.clipped = true; } else if index == 0 { @@ -74,6 +96,16 @@ pub fn insert_new(doc: &mut Document, anchor: Option, mut layer: Layer, id } +/// The pixel holding the middle of `bounds`, which decides the board content is on: a new or +/// pasted layer, or one the Move tool drops (auto-nesting). The edges are added before halving +/// (halving each first put 9..11 at 9) and the sum is floored, not truncated toward zero (which +/// put -4..3, whose middle is -0.5, at 0, inside a board starting there); in i64, so it can't +/// overflow. The middle of two i32 edges is itself an i32. +pub(crate) fn centre(bounds: photocraft_geom::Rect) -> (i32, i32) { + let mid = |a: i32, b: i32| i32::try_from((i64::from(a) + i64::from(b)).div_euclid(2)).unwrap_or(a); + (mid(bounds.x0, bounds.x1), mid(bounds.y0, bounds.y1)) +} + /// Does the group `target` at depth `depth` (its path length) take a new `layer` as its top /// child? An open group or an artboard does, locked or not (Photoshop puts a new layer inside a /// group locked with Lock All), unless nesting would pass [`MAX_GROUP_DEPTH`]. An artboard @@ -81,22 +113,34 @@ pub fn insert_new(doc: &mut Document, anchor: Option, mut layer: Layer, /// it for the canvas (Place, Paste, a Type or Shape click): elsewhere the board would clip it. fn takes_new_layers(target: &Layer, depth: usize, layer: &Layer) -> bool { let LayerContent::Group(g) = &target.content else { return false }; - let on_board = |rect: photocraft_geom::Rect| { - crate::layer_multi_cmds::layer_bounds(layer).is_none_or(|b| { - let (cx, cy) = (b.x0 / 2 + b.x1 / 2, b.y0 / 2 + b.y1 / 2); - rect.contains(cx, cy) - }) - }; let open = match &g.artboard { - Some(board) => on_board(board.rect), + Some(board) => on_board(layer, board.rect), None => g.expanded, }; - open && layer.artboard().is_none() && depth + subtree_depth(layer) <= MAX_GROUP_DEPTH + // A frame holds its image only (`layer.frame.set`/`setContent`), never new layers. + open && target.frame.is_none() && layer.artboard().is_none() && depth + subtree_depth(layer) <= MAX_GROUP_DEPTH +} + +/// Is `layer`'s content centred on the board at `rect`? A layer with no content is (it can go +/// anywhere). +fn on_board(layer: &Layer, rect: photocraft_geom::Rect) -> bool { + crate::layer_multi_cmds::layer_bounds(layer).is_none_or(|b| { + let (cx, cy) = centre(b); + rect.contains(cx, cy) + }) +} + +/// The top-level index of the artboard holding the layer at `path` (inside it, not the board +/// itself), when `layer`'s content is centred off that board. +fn off_board(doc: &Document, path: &[usize], layer: &Layer) -> Option { + let (&top, inside) = path.split_first()?; + let board = doc.layers.get(top)?.artboard()?; + (!inside.is_empty() && !on_board(layer, board.rect)).then_some(top) } /// Group nesting inside `layer` itself: 0 for a layer or an empty group, as /// [`Document::max_group_depth`] counts it. Explicit stack: pasted groups can be deep. -fn subtree_depth(layer: &Layer) -> usize { +pub(crate) fn subtree_depth(layer: &Layer) -> usize { let mut max = 0; let mut stack: Vec<(&Layer, usize)> = vec![(layer, 0)]; while let Some((l, depth)) = stack.pop() { diff --git a/crates/engine/src/layer_place_tests.rs b/crates/engine/src/layer_place_tests.rs index 5a84035426..aa818ca47f 100644 --- a/crates/engine/src/layer_place_tests.rs +++ b/crates/engine/src/layer_place_tests.rs @@ -355,3 +355,38 @@ fn a_clipped_layer_with_nothing_below_is_released() { assert!(d.layer(g).unwrap().children().unwrap().iter().any(|c| c.id == l)); assert!(!d.layer(l).unwrap().clipped); } + +/// A copied clipped layer pasted off the board, beside a layer on it, goes above the board +/// unclipped (#11441 review): clipped to the board, the board's composite would hide it. +#[test] +fn a_clipped_paste_off_the_board_lands_above_it_unclipped() { + let (mut s, _, _) = session(); + let board = id(s.execute("layer.new.artboard", json!({"rect": [0, 0, 8, 8]})).unwrap()); + let mut d = doc(&s).clone(); + let mut on = Layer::raster("on", d.pixel_format()); + on.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(1, 1, 5, 5), &[1.0, 0.0, 0.0, 1.0]); + let on = insert_new(&mut d, Some(board), on, Place::Auto); + let mut off = Layer::raster("was clipped", d.pixel_format()); + off.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(10, 10, 16, 16), &[1.0, 0.0, 0.0, 1.0]); + off.clipped = true; + let off = insert_new(&mut d, Some(on), off, Place::AutoEditable); + assert_eq!(d.artboard_of(off), None); + let root: Vec = d.layers.iter().map(|l| l.id).collect(); + assert_eq!(root.iter().position(|x| *x == off), root.iter().position(|x| *x == board).map(|i| i + 1)); + assert!(!d.layer(off).unwrap().clipped, "not clipped to the board"); +} + +/// A clipped layer put directly above an artboard with [`Place::Above`] keeps its flag +/// (#11441 review): it is the layer after a pasted board, stacked as it was copied. +#[test] +fn a_clipped_layer_placed_above_an_artboard_stays_clipped() { + let (mut s, _, _) = session(); + let board = id(s.execute("layer.new.artboard", json!({"rect": [0, 0, 8, 8]})).unwrap()); + let mut d = doc(&s).clone(); + let mut l = Layer::raster("clipped", d.pixel_format()); + l.clipped = true; + let l = insert_new(&mut d, Some(board), l, Place::Above); + let root: Vec = d.layers.iter().map(|x| x.id).collect(); + assert_eq!(root.iter().position(|x| *x == l), root.iter().position(|x| *x == board).map(|i| i + 1)); + assert!(d.layer(l).unwrap().clipped); +} diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 176f08b264..9bb6ab7693 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -676,6 +676,15 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { if let Some(x) = b_of(p, "reverse") { g.reverse = x; } + // The gradient's centre as a fraction of the layer's bounds, `[x, y]` (0: centred); a drag on + // the image with the Layer Style dialog on the page moves it (#10763). + if let Some(a) = p.get("offset").and_then(Value::as_array) + && let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) + && x.is_finite() + && y.is_finite() + { + g.offset = (x.clamp(-4.0, 4.0) as f32, y.clamp(-4.0, 4.0) as f32); + } } /// Applies the params present in `p` onto an existing effect, in the command's diff --git a/crates/engine/src/layer_style/gradient_overlay.rs b/crates/engine/src/layer_style/gradient_overlay.rs index ee88cb447d..217f6fd70c 100644 --- a/crates/engine/src/layer_style/gradient_overlay.rs +++ b/crates/engine/src/layer_style/gradient_overlay.rs @@ -200,6 +200,16 @@ mod tests { assert_eq!(gradient.angle, 90.0); assert!(gradient.reverse); assert_eq!(gradient.scale, 0.75); + // The offset (#10763) is geometry too: a fraction of the layer's bounds, left alone by + // junk and clamped. + overlay(&mut gradient, &mut source, &json!({"offset": [0.25, -0.1]})); + assert_eq!(gradient.offset, (0.25, -0.1)); + assert_eq!(gradient.stops, expected); + overlay(&mut gradient, &mut source, &json!({"offset": "far"})); + overlay(&mut gradient, &mut source, &json!({"offset": [f64::NAN, 0.0]})); + assert_eq!(gradient.offset, (0.25, -0.1)); + overlay(&mut gradient, &mut source, &json!({"offset": [100.0, -100.0]})); + assert_eq!(gradient.offset, (4.0, -4.0)); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f975ad40a0..8f28147217 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,6 +31,8 @@ pub mod layer_place; pub mod layer_style; pub mod link_watch; pub mod malloc_tuning; +pub mod memory; +pub mod memory_gate; pub mod new_layer; mod param_check; mod pixels; @@ -95,7 +97,7 @@ pub use cmd::fill_cmds; pub use cmd::fill_key_cmds; pub use cmd::float_cmds; pub use cmd::font_library_cmds; -use cmd::frame_cmds; +pub use cmd::frame_cmds; pub use cmd::fx_view_cmds; pub use cmd::fx_visibility_cmds; pub use cmd::gallery_cmds; @@ -173,6 +175,16 @@ pub use session::move_item; use session::restore_target; pub(crate) use session::{carry_visibility, fix_selection, visibility_changes}; +/// A type layer for [`Session::refresh_type_layers`]: in the open document, or in its first +/// History snapshot (the document as opened) when `first_snapshot`. The caller picks each against +/// that version of the layer, which may differ from the open one or be gone from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TypeLayerRef { + pub doc: photocraft_doc::DocId, + pub layer: LayerId, + pub first_snapshot: bool, +} + /// The pixels of a raster layer (typically one a command just created), as an error instead /// of a panic if the layer has none. pub(crate) fn pixels_mut(l: &mut photocraft_doc::Layer) -> Result<&mut photocraft_raster::Surface> { @@ -189,6 +201,12 @@ pub(crate) fn active_layer_of(s: &Session) -> std::result::Result<&photocraft_do #[derive(Default)] pub struct Session { docs: Vec, + /// Override Open belongs only to one Batch scratch session, never to caller preferences. + pub(crate) batch_open: Option, + /// Import diagnostics retained by a Batch scratch that suppresses only profile prompts. + pub(crate) batch_open_notices: Option>, + /// Frozen RAW workflow settings for one noninteractive Batch input. + pub(crate) batch_raw_open: Option, /// Admitted document identities in opening order, independent of visual-tab reordering. open_document_order: Vec, active: Option, @@ -198,6 +216,13 @@ pub struct Session { active_generation: u64, /// The document id [`Self::active_generation`] was last bumped for. active_doc_for_generation: Option, + /// Change marker: advances at least once whenever the active document or layer changes through + /// the `Session` API (commands via `execute`, `start` and finished jobs, `select_layer`, + /// `restore_layer_selection`, `layer_multi_cmds::set_selection`, `set_active_layer`, undo / redo / + /// rollback, document switches). Compare two readings for equality to know whether anything + /// changed in between; it is not an exact count of operations (one operation may advance it + /// more than once). Writing `DocState::active_layer` directly bypasses it. + active_layer_changes: u64, pub tools: ToolState, /// Log of executed commands (for action recording and debugging). pub journal: Vec<(String, Value)>, @@ -205,6 +230,13 @@ pub struct Session { typed_layer: Option<(photocraft_doc::DocId, LayerId)>, /// Coalescing key of the command being executed. coalesce_request: Option, + /// The outer action's original document/history states, independent of command transactions. + action_history: Option, + /// Weak leases expire when every copy of a suspended continuation is abandoned. + paused_action_history: Vec>, + /// Tests observe the actual scratch-session boundary without retaining it or changing output. + #[cfg(test)] + batch_history_observer: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, /// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options @@ -307,11 +339,17 @@ pub struct Session { pub damage_misses: usize, } +#[cfg(test)] +mod brush_pressure_size_default_tests; +#[cfg(test)] +mod embed_all_linked_tests; #[cfg(test)] mod fill_layer_mode_tests; #[cfg(test)] mod pattern_mode_tests; #[cfg(test)] +mod second_keys_zoom_actions_tests; +#[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs new file mode 100644 index 0000000000..7b61e1b063 --- /dev/null +++ b/crates/engine/src/memory.rs @@ -0,0 +1,43 @@ +//! Physical memory of the machine, shared by the engine's memory budgets (Photomerge) and the +//! shell's GPU budgets. + +/// Physical memory of this machine in bytes, if known. `PHOTOCRAFT_RAM_MB` overrides it (to +/// simulate a smaller machine). +pub fn physical_memory() -> Option { + static RAM: std::sync::OnceLock> = std::sync::OnceLock::new(); + *RAM.get_or_init(|| { + if let Some(mb) = std::env::var("PHOTOCRAFT_RAM_MB").ok().and_then(|v| v.trim().parse::().ok()) { + return mb.checked_mul(1 << 20); + } + detect_physical_memory() + }) +} + +/// Physical memory as the operating system reports it, ignoring `PHOTOCRAFT_RAM_MB` and not +/// cached. `None` where nothing reports it (the web build, other platforms). +pub fn detect_physical_memory() -> Option { + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?; + String::from_utf8(out.stdout).ok()?.trim().parse().ok() + } + #[cfg(target_os = "linux")] + { + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb: u64 = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; + kb.checked_mul(1024) + } + #[cfg(target_os = "freebsd")] + { + let out = std::process::Command::new("/sbin/sysctl").args(["-n", "hw.physmem"]).output().ok()?; + String::from_utf8(out.stdout).ok()?.trim().parse().ok() + } + #[cfg(windows)] + { + winsafe::GlobalMemoryStatusEx().map(|m| m.ullTotalPhys).ok().filter(|b| *b > 0) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "freebsd", windows)))] + { + None + } +} diff --git a/crates/engine/src/memory_gate.rs b/crates/engine/src/memory_gate.rs new file mode 100644 index 0000000000..67fc6816f3 --- /dev/null +++ b/crates/engine/src/memory_gate.rs @@ -0,0 +1,346 @@ +//! Refuses a big operation that cannot fit in memory, with an error, before it starts (#10024). +//! +//! On the web build a failed allocation aborts the whole module and the page freezes without a +//! message. [`photocraft_raster::headroom`] tells whether a given number of bytes can still be +//! allocated; an operation that is about to allocate a lot asks here first, with a rough upper +//! bound of what it will need. A refusal changes nothing (the document, its history and the +//! selection stay as they were) and says what the user can do. Off the web every check passes. +//! +//! The estimates are deliberately rough and err high: they count the big buffers an operation +//! holds at once, not every temporary. A layered PSD/PSB adds a canvas-sized surface per layer +//! to the flat-image estimate; one whose header or sections can't be read, or that isn't 8-bit +//! RGB or grayscale, has no safe bound and is refused by the web gate before decoding. + +use photocraft_doc::{Document, LayerContent, SmartSource}; +use photocraft_geom::TILE_SIZE; + +use crate::{EngineError, Result}; + +/// Peak bytes per source pixel while a smart object is rendered from its source: the decoded +/// source, its composite, the converted surface and the transformed result, measured on a large +/// embedded file. Lower it when the decoded originals stop being kept alongside (#10030). +pub const SMART_RENDER_BYTES_PER_PX: u64 = 41; + +/// Bytes per pixel of the compositor's working buffer (`RGBA`, `f32`) that flattening a placed +/// file fills. +const COMPOSITE_BYTES_PER_PX: u64 = 16; + +/// Pixels assumed per byte of a file whose size can't be read from its header (a rough bound: a +/// compressed image holds a few pixels per byte). +const FALLBACK_PX_PER_FILE_BYTE: u64 = 4; + +/// Bytes per canvas pixel each layer of a layered PSD/PSB keeps once decoded, on top of the flat +/// estimate: an 8-bit RGBA surface and its mask, assumed to cover the whole canvas. +const PSD_LAYER_BYTES_PER_PX: u64 = 5; + +/// `Ok` when `need` bytes can be allocated; otherwise an error naming `what` (for example +/// `place “x.png”`), the need, and what the user can do. The document is not touched. +pub fn ensure(what: &str, need: u64) -> Result<()> { + check(what, need, "Save your work, close other documents or use Edit › Purge › All, then try again.") +} + +/// [`ensure`] for saving and exporting: the user must be able to keep their work, so the advice +/// leads with freeing memory. The document stays open and unsaved. +pub fn ensure_for_save(what: &str, need: u64) -> Result<()> { + check(what, need, "Use Edit › Purge › All or close other documents to free memory, then save again. The document is still open and unchanged.") +} + +fn check(what: &str, need: u64, advice: &str) -> Result<()> { + photocraft_raster::headroom::ensure(need).map_err(|_| { + if need == u64::MAX { + // Freeing memory cannot satisfy an unbounded need, so the retry advice would mislead. + EngineError::Other(format!( + "Not enough memory to safely {what} in the browser: the file has a format whose memory use cannot be bounded in advance. Open it in the desktop app, or flatten it to a plain 8-bit RGB or grayscale image first." + )) + } else { + EngineError::Other(format!("Not enough memory to {what} (it needs about {} MB). {advice}", mb(need))) + } + }) +} + +/// `bytes` in whole megabytes, rounded up, with thousands separators. +fn mb(bytes: u64) -> String { + let n = bytes.div_ceil(1 << 20).to_string(); + let mut out = String::with_capacity(n.len() + n.len() / 3); + for (i, c) in n.chars().enumerate() { + if i > 0 && (n.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// Pixel count of an image file read from its header, without decoding it. Formats without a +/// header reader here fall back to a size-based guess, so the answer is always an upper-bound +/// style estimate rather than an error. +pub(crate) fn source_pixels(bytes: &[u8]) -> u64 { + header_pixels(bytes).unwrap_or_else(|| (bytes.len() as u64).saturating_mul(FALLBACK_PX_PER_FILE_BYTE)) +} + +fn header_pixels(bytes: &[u8]) -> Option { + if let Ok((w, h)) = photocraft_codecs::probe_size(bytes) { + return Some(u64::from(w) * u64::from(h)); + } + let be32 = |at: usize| -> Option { Some(u64::from(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?))) }; + let be16 = |at: usize| -> Option { Some(u64::from(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?))) }; + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + // IHDR: width, height. + return Some(be32(16)?.saturating_mul(be32(20)?)); + } + if bytes.starts_with(b"8BPS") { + // PSD / PSB header: height then width. + return Some(be32(14)?.saturating_mul(be32(18)?)); + } + if bytes.starts_with(&[0xff, 0xd8]) { + // JPEG: the first start-of-frame marker holds height then width. + let mut at = 2usize; + while at.checked_add(4)? <= bytes.len() { + if *bytes.get(at)? != 0xff { + at += 1; + continue; + } + let marker = *bytes.get(at + 1)?; + match marker { + 0xff => at += 1, + 0xc0..=0xcf if !matches!(marker, 0xc4 | 0xc8 | 0xcc) => return Some(be16(at + 5)?.saturating_mul(be16(at + 7)?)), + 0xd0..=0xd9 | 0x01 | 0x00 => at += 2, + _ => at = at.checked_add(2)?.checked_add(usize::try_from(be16(at + 2)?).ok()?)?, + } + } + } + None +} + +/// The number of layers a PSD/PSB file decodes to, read from its sections without decoding: 0 +/// for a flat file. The color-mode data and image resources (resolution, ICC profile, thumbnail +/// and the like) hold no layer pixels and are only skipped. `None` when the header or a section +/// can't be read, or the file isn't 8-bit RGB or grayscale with at most 4 channels: those have +/// no safe bound. +fn psd_layer_count(bytes: &[u8]) -> Option { + let header = bytes.get(..26)?; + let version = u16::from_be_bytes([header[4], header[5]]); + let channels = u16::from_be_bytes([header[12], header[13]]); + let depth = u16::from_be_bytes([header[22], header[23]]); + let mode = u16::from_be_bytes([header[24], header[25]]); + if !matches!(version, 1 | 2) || !(1..=4).contains(&channels) || depth != 8 || !matches!(mode, 1 | 3) { + return None; + } + // A big-endian length field of `width` bytes at `at`. + let length = |at: usize, width: usize| -> Option { + let field = bytes.get(at..at.checked_add(width)?)?; + usize::try_from(field.iter().fold(0u64, |n, &b| (n << 8) | u64::from(b))).ok() + }; + let mut at = 26usize; + for _ in 0..2 { + // Color-mode data, then image resources. + at = at.checked_add(4)?.checked_add(length(at, 4)?)?; + } + let width = if version == 1 { 4 } else { 8 }; + let layers_and_masks = length(at, width)?; + let start = at.checked_add(width)?; + // The merged image's compression field follows the section. + if bytes.len().saturating_sub(start.checked_add(layers_and_masks)?) < 2 { + return None; + } + if layers_and_masks == 0 { + return Some(0); + } + // The layer info opens with its own length, then the layer count (negative when the first + // alpha channel holds the merged image's transparency). + let layer_info = length(start, width)?; + if layer_info == 0 { + // Some writers keep the layers in a `Layr` tagged block instead, which isn't counted here. + let section = bytes.get(start..start.checked_add(layers_and_masks)?)?; + return if section.windows(8).any(|w| w == b"8BIMLayr" || w == b"8B64Layr") { None } else { Some(0) }; + } + if layer_info.checked_add(width)? > layers_and_masks { + return None; + } + let count_at = start.checked_add(width)?; + let count = <[u8; 2]>::try_from(bytes.get(count_at..count_at.checked_add(2)?)?).ok()?; + Some(u64::from(i16::from_be_bytes(count).unsigned_abs())) +} + +/// Bytes per source pixel a file keeps beyond the flat estimate: a canvas-sized surface per layer +/// of a layered PSD/PSB, nothing for other files. `None` for a PSD/PSB with no safe bound, which +/// the web gate then refuses before decoding; native behavior is unchanged. +fn layer_bytes_per_px(bytes: &[u8]) -> Option { + if !bytes.starts_with(b"8BPS") { + return Some(0); + } + Some(psd_layer_count(bytes)?.saturating_mul(PSD_LAYER_BYTES_PER_PX)) +} + +/// Place: the file's bytes, plus the decoded file, its composite and the placed pixels, which +/// are alive together (`bytes_per_px` is the document's pixel size, at least 4), plus the layers +/// of a layered PSD/PSB. +pub(crate) fn place_need(bytes: &[u8], bytes_per_px: usize) -> u64 { + let Some(layers) = layer_bytes_per_px(bytes) else { return u64::MAX }; + let px = source_pixels(bytes); + let per_px = (bytes_per_px.max(4) as u64).saturating_mul(3).saturating_add(COMPOSITE_BYTES_PER_PX).saturating_add(layers); + (bytes.len() as u64).saturating_add(px.saturating_mul(per_px)) +} + +/// A smart object's re-render from `bytes`: its source pixels at the measured peak, plus the +/// layers of a layered PSD/PSB. +pub(crate) fn smart_render_need(bytes: &[u8]) -> u64 { + let Some(layers) = layer_bytes_per_px(bytes) else { return u64::MAX }; + source_pixels(bytes).saturating_mul(SMART_RENDER_BYTES_PER_PX.saturating_add(layers)) +} + +/// Image Size to `nw` x `nh`: the resampled pixel surfaces and masks (each layer's tiles scaled +/// by the change of area, capped at a full canvas) in the document's pixel size, plus every +/// distinct embedded smart object re-rendered from its source at once. +pub(crate) fn image_size_need(doc: &Document, nw: u32, nh: u32) -> u64 { + let bpp = doc.pixel_format().bytes_per_pixel().max(1) as u64; + let old_px = (u64::from(doc.size.width) * u64::from(doc.size.height)).max(1); + let new_px = u64::from(nw) * u64::from(nh); + let canvas_bytes = new_px.saturating_mul(bpp); + let tile_bytes = (TILE_SIZE as u64) * (TILE_SIZE as u64) * bpp; + let resized = |tiles: usize| -> u64 { + let old = (tiles as u64).saturating_mul(tile_bytes); + let scaled = (old as u128 * u128::from(new_px) / u128::from(old_px)).min(u128::from(u64::MAX)) as u64; + scaled.max(tile_bytes.min(canvas_bytes)).min(canvas_bytes) + }; + let mut need = 0u64; + let mut seen = std::collections::HashSet::new(); + for (_, _, layer) in doc.walk() { + if let Some(s) = layer.surface() { + need = need.saturating_add(resized(s.tile_count())); + } + if let Some(m) = &layer.mask { + need = need.saturating_add(resized(m.surface.tile_count())); + } + if let LayerContent::Smart(so) = &layer.content { + let embedded = match &so.source { + SmartSource::Embedded { bytes, .. } => Some(bytes.as_slice()), + SmartSource::Linked { .. } => None, + }; + if let Some(bytes) = embedded + && seen.insert(so.contents_id) + { + need = need.saturating_add(smart_render_need(bytes)); + } + } + } + need +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn megabytes_are_rounded_up_and_grouped() { + assert_eq!(mb(0), "0"); + assert_eq!(mb(1), "1"); + assert_eq!(mb(1240 << 20), "1,240"); + assert_eq!(mb(1_000 << 20), "1,000"); + assert_eq!(mb(12_345_678 << 20), "12,345,678"); + } + + #[test] + fn headers_give_pixel_counts() { + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend_from_slice(&300u32.to_be_bytes()); + png.extend_from_slice(&200u32.to_be_bytes()); + assert_eq!(source_pixels(&png), 60_000); + let mut psd = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); + psd.extend_from_slice(&50u32.to_be_bytes()); + psd.extend_from_slice(&40u32.to_be_bytes()); + assert_eq!(source_pixels(&psd), 2_000); + // SOI, an APP0 segment of length 4, then SOF0: precision, height 20, width 30. + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 30, 1]; + assert_eq!(source_pixels(&jpeg), 600); + } + + #[test] + fn malformed_psd_sections_are_refused_without_panicking() { + let mut flat = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + flat.extend_from_slice(&[0; 14]); + assert_eq!(psd_layer_count(&flat), Some(0)); + for end in 4..flat.len() { + assert_eq!(place_need(&flat[..end], 4), u64::MAX); + assert_eq!(smart_render_need(&flat[..end]), u64::MAX); + } + flat[26..30].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(place_need(&flat, 4), u64::MAX); + } + + /// A 2 x 2 RGB PSD with a resolution resource (as every Photoshop file has) and, when + /// `layers` is set, a layer info section holding only that layer count. + fn psd_with_resources(layers: Option) -> Vec { + let mut psd = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + psd.extend_from_slice(&[0; 4]); + // ResolutionInfo (0x03ED): signature, id, empty name padded to even, 16 bytes of data. + let mut resource = b"8BIM\x03\xed\0\0".to_vec(); + resource.extend_from_slice(&16u32.to_be_bytes()); + resource.extend_from_slice(&[0, 0x48, 0, 0, 0, 1, 0, 1, 0, 0x48, 0, 0, 0, 1, 0, 1]); + psd.extend_from_slice(&(resource.len() as u32).to_be_bytes()); + psd.extend_from_slice(&resource); + match layers { + None => psd.extend_from_slice(&[0; 4]), + Some(n) => { + psd.extend_from_slice(&6u32.to_be_bytes()); + psd.extend_from_slice(&2u32.to_be_bytes()); + psd.extend_from_slice(&n.to_be_bytes()); + } + } + psd.extend_from_slice(&[0; 2]); + psd.extend_from_slice(&[128; 12]); + psd + } + + #[test] + fn image_resources_do_not_make_a_psd_unbounded() { + let psd = psd_with_resources(None); + assert_eq!(psd_layer_count(&psd), Some(0)); + assert_eq!(place_need(&psd, 4), psd.len() as u64 + 4 * (4 * 3 + COMPOSITE_BYTES_PER_PX)); + assert_eq!(smart_render_need(&psd), 4 * SMART_RENDER_BYTES_PER_PX); + } + + #[test] + fn a_layered_psd_counts_a_canvas_sized_surface_per_layer() { + // A negative count (merged transparency in the first alpha channel) is still 3 layers. + for n in [3i16, -3] { + let psd = psd_with_resources(Some(n)); + assert_eq!(psd_layer_count(&psd), Some(3), "{n}"); + assert_eq!(place_need(&psd, 4), psd.len() as u64 + 4 * (4 * 3 + COMPOSITE_BYTES_PER_PX + 3 * PSD_LAYER_BYTES_PER_PX)); + assert_eq!(smart_render_need(&psd), 4 * (SMART_RENDER_BYTES_PER_PX + 3 * PSD_LAYER_BYTES_PER_PX)); + } + // A layer info length that runs past its section can't be trusted. + let mut psd = psd_with_resources(Some(3)); + let info_at = psd.len() - 14 - 6; + psd[info_at..info_at + 4].copy_from_slice(&100u32.to_be_bytes()); + assert_eq!(place_need(&psd, 4), u64::MAX); + // Layers kept in a tagged block instead of the layer info aren't counted, so refused. + let mut tagged = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + tagged.extend_from_slice(&[0; 8]); + tagged.extend_from_slice(&20u32.to_be_bytes()); + tagged.extend_from_slice(&[0; 8]); + tagged.extend_from_slice(b"8BIMLayr"); + tagged.extend_from_slice(&[0; 4 + 2 + 12]); + assert_eq!(place_need(&tagged, 4), u64::MAX); + // Cut anywhere before the merged image's compression field, it's refused. + let psd = psd_with_resources(Some(3)); + for end in 4..psd.len() - 12 { + assert_eq!(place_need(&psd[..end], 4), u64::MAX, "{end}"); + } + } + + #[test] + fn unreadable_headers_fall_back_to_a_size_guess_without_panicking() { + assert_eq!(source_pixels(b""), 0); + assert_eq!(source_pixels(&[0u8; 100]), 400); + for cut in 0..40 { + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x10\0\0\0\x10".to_vec(); + png.truncate(cut); + let _ = source_pixels(&png); + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 30, 1]; + let _ = source_pixels(&jpeg[..cut.min(jpeg.len())]); + } + // A segment length that runs off the end of the file ends the scan. + let _ = source_pixels(&[0xff, 0xd8, 0xff, 0xe0, 0xff, 0xff]); + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a463418ef6..65f5f735cb 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -100,6 +100,11 @@ choice!( /// pointer (Photoshop), or erase with the current brush while dragging (Krita, Paint). RightClickPaint { BrushPicker = "brushPicker", Erase = "erase" } default BrushPicker ); +choice!( + /// Windows: which pen input supplies pressure. Windows Ink is the default; WinTab needs + /// Windows Ink turned off in the tablet driver's utility and a restart. + TabletInput { WindowsInk = "windowsInk", WinTab = "winTab" } default WindowsInk +); choice!(OtherCursor { Standard = "standard", Precise = "precise" } default Standard); choice!(CheckerSize { None = "none", Small = "small", Medium = "medium", Large = "large" } default Medium); choice!(CheckerColors { Light = "light", Medium = "medium", Dark = "dark", Red = "red", Orange = "orange", Green = "green", Blue = "blue", Purple = "purple", Custom = "custom" } default Light); @@ -183,6 +188,78 @@ impl Unit { let unit = if suffix.is_empty() { None } else { Some(Unit::from_suffix(suffix)?) }; Some((v, unit)) } + /// A length typed as Photoshop's numeric fields take arithmetic (#10440): lengths and numbers + /// joined by `+`, `-`, `*` and `/`, e.g. "100% / 3", "100% - 50px", "1in + 10", "2 * 50 px". + /// `*` and `/` bind tighter, and `−` (U+2212) is a minus. A number without a unit added or + /// subtracted takes the first operand's unit (Photoshop: "1in + 10" is 11 in), or `default` + /// when the first has none; one multiplied or divided is a plain number. `to_px` converts a + /// value in a unit. `None` when the text is over 1024 bytes (the numeric fields' cap) or isn't + /// such an expression, multiplies two lengths, divides by a length or by zero, or the result + /// isn't finite. + pub fn eval_length(text: &str, default: Unit, to_px: impl Fn(f64, Unit) -> f64) -> Option { + if text.len() > 1024 { + return None; + } + let text = text.replace('−', "-"); + // Tokens: operators, and operands (a number with an optional unit suffix). + let mut ops = Vec::new(); + let mut operands: Vec<(f64, Option)> = Vec::new(); + let mut rest = text.trim(); + let mut want_operand = true; + while !rest.is_empty() { + if want_operand { + let num_end = rest + .char_indices() + .find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+')))) + .map_or(rest.len(), |(i, _)| i); + let after = &rest[num_end..]; + let unit_end = after.find(['+', '-', '*', '/']).unwrap_or(after.len()); + let (v, unit) = Unit::parse_length(&rest[..num_end + unit_end])?; + operands.push((v, unit)); + rest = after[unit_end..].trim_start(); + want_operand = false; + } else { + let op = rest.chars().next()?; + if !matches!(op, '+' | '-' | '*' | '/') { + return None; + } + ops.push(op); + rest = rest[1..].trim_start(); + want_operand = true; + } + } + if want_operand || operands.is_empty() { + return None; + } + let first_unit = operands[0].1.unwrap_or(default); + // Fold the products and quotients into terms: (sum of px for unit factors, scalar, has a unit). + let mut terms: Vec<(char, f64, Option)> = Vec::new(); + let (mut sign, mut value, mut unit) = ('+', operands[0].0, operands[0].1); + for (op, (v, u)) in ops.iter().zip(operands.iter().skip(1)) { + match op { + '*' => { + if unit.is_some() && u.is_some() { + return None; + } + value *= v; + unit = unit.or(*u); + } + '/' => { + if u.is_some() || *v == 0.0 { + return None; + } + value /= v; + } + _ => { + terms.push((sign, value, unit)); + (sign, value, unit) = (*op, *v, *u); + } + } + } + terms.push((sign, value, unit)); + let px: f64 = terms.iter().map(|&(s, v, u)| if s == '-' { -1.0 } else { 1.0 } * to_px(v, u.unwrap_or(first_unit))).sum(); + px.is_finite().then_some(px) + } /// The unit a typed suffix names, in any case: `px`, `in` or `"`, `cm`, `mm`, `pt`, `pc` or /// `pica`, `%`, and their long forms (`pixels`, `inches`, `centimetres`…). The one table every /// length field reads. @@ -222,6 +299,10 @@ pub struct General { pub export_clipboard: bool, pub resize_image_during_place: bool, pub always_create_smart_objects_when_placing: bool, + /// Place files without opening Free Transform on them (#8492). Only the interactive place + /// (File ▸ Place, canvas drops) opens Free Transform; `file.placeEmbedded`/`file.placeLinked` + /// run with a `path` (actions, scripts, automation) never do, so this doesn't affect them. + pub skip_transform_when_placing: bool, /// Scroll wheel zooms instead of panning. pub zoom_with_scroll_wheel: bool, /// Wheel zoom eases over the frames of a notch; off, a notch is one instant ×1.1 step. @@ -242,6 +323,7 @@ impl Default for General { export_clipboard: true, resize_image_during_place: true, always_create_smart_objects_when_placing: true, + skip_transform_when_placing: false, zoom_with_scroll_wheel: false, animated_zoom: true, zoom_resizes_windows: false, @@ -287,9 +369,6 @@ pub struct Interface { /// objects in a large document stay visible (Photoshop's Clip Thumbnails to Layer Bounds; #4281). pub clip_thumbnails_to_layer_bounds: bool, pub show_tooltips: bool, - /// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new - /// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). - pub enable_animations: bool, /// Move tool drags show only the layer's outline and an arrow, leaving its pixels in place /// until release. Off (the default), the pixels follow the pointer live inside the outline. pub show_bounding_box_when_dragging_layer: bool, @@ -330,7 +409,6 @@ impl Default for Interface { show_menu_colors: true, clip_thumbnails_to_layer_bounds: false, show_tooltips: true, - enable_animations: true, show_bounding_box_when_dragging_layer: false, system_title_bar: false, show_file_name_in_title: true, @@ -396,6 +474,9 @@ pub struct Tools { pub right_click_with_painting_tools: RightClickPaint, /// Pen tablets: pressure, tilt and rotation reach the brush (off: a pen paints like a mouse). pub use_tablet_pressure: bool, + /// Windows: which pen input supplies pressure. Windows Ink is the default; WinTab needs + /// Windows Ink turned off in the tablet driver's utility and a restart. + pub tablet_input: TabletInput, /// Pen pressure response: `[input, output]` control points in 0..1 that the pen's pressure /// passes through before it reaches any brush ([`PressureCurve`]). Linear by default. pub pressure_curve: Vec<[f32; 2]>, @@ -423,6 +504,7 @@ impl Default for Tools { double_click_layer_mask_launches_select_and_mask: true, right_click_with_painting_tools: RightClickPaint::BrushPicker, use_tablet_pressure: true, + tablet_input: TabletInput::WindowsInk, pressure_curve: vec![[0.0, 0.0], [1.0, 1.0]], bundle_nudges: true, nudge_bundle_pause_ms: 1000, @@ -568,15 +650,43 @@ impl Default for Export { pub const DEFAULT_MEMORY_USAGE_MB: u32 = 8192; /// The most Memory Usage the web build honours, in MB: a wasm module's linear memory tops out at -/// 4 GiB, so a larger allowance (the desktop default is 8 GiB) would never trim anything. +/// 4 GiB, so a larger allowance (the desktop default is 8 GiB) would never trim anything. The +/// effect cache is capped at it on every 32-bit build ([`SMALL_ADDRESS_SPACE`]). const WASM_MEMORY_USAGE_CAP_MB: u32 = 1536; +/// A 32-bit address space (wasm32, 32-bit Windows): an effect cache budget beyond +/// [`WASM_MEMORY_USAGE_CAP_MB`] would never evict before allocation fails. +const SMALL_ADDRESS_SPACE: bool = cfg!(target_pointer_width = "32"); + /// The Memory Usage a build honours for a `stored` preference: capped on the web build (`web`). /// Separate from the target check so native tests exercise both. fn honoured_memory_usage_mb(stored: u32, web: bool) -> u32 { if web { stored.min(WASM_MEMORY_USAGE_CAP_MB) } else { stored } } +/// The smallest Effect Cache the Preferences dialog and `prefs.set` accept, in MB. +const EFFECT_CACHE_MIN_MB: u32 = 16; + +/// The largest Effect Cache the Preferences dialog and `prefs.set` accept, in MB: on a build with +/// a 32-bit address space (`small`) the cap there, so the field shows the budget the cache gets. +fn effect_cache_max_mb(small: bool) -> u32 { + if small { WASM_MEMORY_USAGE_CAP_MB } else { 65536 } +} + +/// The nearest Effect Cache the field accepts to a `stored` preference, in MB, on a build with a +/// 32-bit address space (`small`) or not. Separate from the target check so native tests exercise +/// both. +fn accepted_effect_cache_mb(stored: u32, small: bool) -> u32 { + stored.clamp(EFFECT_CACHE_MIN_MB, effect_cache_max_mb(small)) +} + +/// The layer-effect cache budget a build honours for a `stored` preference, in bytes: within the +/// range the field accepts, so a hand-edited 0 reports 16 MB, not 0. Separate from the target +/// check so native tests exercise both. +fn effect_cache_budget_bytes_for(stored: u32, small: bool) -> u64 { + u64::from(accepted_effect_cache_mb(stored, small)) << 20 +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Performance { @@ -665,6 +775,12 @@ impl Performance { .unwrap_or_else(|| u64::from(self.effective_memory_usage_mb()).saturating_mul(1 << 20) / 2); usize::try_from(bytes).unwrap_or(usize::MAX) } + + /// The layer-effect cache budget the build honours, in bytes: [`Performance::effect_cache_mb`], + /// capped at 1536 MB on 32-bit builds (the web and 32-bit Windows). + pub fn effect_cache_budget_bytes(&self) -> usize { + usize::try_from(effect_cache_budget_bytes_for(self.effect_cache_mb, SMALL_ADDRESS_SPACE)).unwrap_or(usize::MAX) + } } impl Default for Performance { @@ -1051,21 +1167,70 @@ impl Default for Integrations { } } -/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either). -/// Photoshop's snapshot options come with #3634. +choice!( + /// Playback Options › Performance: how fast a person's Play runs an action's steps. + PlaybackPerformance { Accelerated = "accelerated", StepByStep = "step", Pause = "pause" } default Accelerated +); + +/// Actions panel Playback Options. These are saved independently of an action library. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Actions { + pub create_single_history_state: bool, + /// Accelerated, Step by Step or Pause For. Agents and headless playback always run at once. + pub performance: PlaybackPerformance, + /// Pause For's whole seconds between steps, 1 to 60 (the dialog keeps it while another mode + /// is chosen, as the reference application does). + pub pause_seconds: u8, +} + +impl Default for Actions { + fn default() -> Self { + Self { create_single_history_state: true, performance: PlaybackPerformance::Accelerated, pause_seconds: 1 } + } +} + +impl Actions { + /// Shortest and longest Pause For, in seconds. + pub const PAUSE_SECONDS: std::ops::RangeInclusive = 1..=60; + + /// Pause For's seconds, within [`Self::PAUSE_SECONDS`] also when a hand-edited file holds + /// another number. + pub fn pause_seconds(&self) -> u8 { + self.pause_seconds.clamp(*Self::PAUSE_SECONDS.start(), *Self::PAUSE_SECONDS.end()) + } +} + +/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either), +/// in the dialog's order. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct HistoryOptions { + /// Automatically Create First Snapshot: a new or opened document starts with a snapshot of + /// itself, named after it (the History panel's top row). + pub automatically_create_first_snapshot: bool, + /// Automatically Create New Snapshot When Saving: every save of the document (not a copy) + /// adds a snapshot named with the time of the save. + pub automatically_create_new_snapshot_when_saving: bool, /// Allow Non-Linear History: editing after selecting an earlier state keeps the later /// states (the new step is appended at the end), and deleting a state deletes only it. pub allow_non_linear_history: bool, + /// Show New Snapshot Dialog by Default: a plain click on the History panel's camera button + /// opens New Snapshot (otherwise only ⌥-click does). + pub show_new_snapshot_dialog_by_default: bool, /// Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state. pub make_layer_visibility_changes_undoable: bool, } impl Default for HistoryOptions { fn default() -> Self { - Self { allow_non_linear_history: false, make_layer_visibility_changes_undoable: true } + Self { + automatically_create_first_snapshot: true, + automatically_create_new_snapshot_when_saving: false, + allow_non_linear_history: false, + show_new_snapshot_dialog_by_default: false, + make_layer_visibility_changes_undoable: true, + } } } @@ -1135,6 +1300,7 @@ impl LayerMaskOverlay { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Preferences { + pub actions: Actions, pub general: General, pub interface: Interface, pub workspace: Workspace, @@ -1207,6 +1373,13 @@ pub struct Preferences { /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, + /// Foreground/background swatches for pixels and masks and exact CMYK picks, restored by the shell at launch. + /// Null in older files, which retain the default black/white pixel colours. + pub tool_colors: Value, + /// The tools used last, most recent first, restored at launch so each toolbar slot shows the + /// tool it was left on and each tool key brings it back, as in Photoshop (#3569). Tool-name + /// strings owned and validated by the shell; null in older files. + pub group_tools: Value, /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To /// targets that differ from their defaults, saved as the user toggles them and restored at /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. @@ -1362,7 +1535,7 @@ impl Session { self.scratch_epoch = photocraft_raster::spill::epoch(); let n = self.prefs().performance.history_states.max(1) as usize; let bytes = self.prefs().performance.history_cap_bytes(); - let budget = self.prefs().performance.effect_cache_mb as usize; + let budget = self.prefs().performance.effect_cache_budget_bytes(); let non_linear = self.prefs().history_options.allow_non_linear_history; for st in &mut self.docs { st.history.max_states = n; @@ -1370,7 +1543,7 @@ impl Session { st.history.non_linear = non_linear; st.trim_history(); } - photocraft_compose::set_effect_cache_budget(budget << 20); + photocraft_compose::set_effect_cache_budget(budget); crate::transform_cmds::set_budget(u64::from(self.prefs().performance.effective_memory_usage_mb()) << 20); // Without waiting for the system font scan: this runs on the UI thread at launch. photocraft_text::set_shared_missing_glyph_protection(self.prefs().type_.missing_glyph_protection()); @@ -1453,7 +1626,11 @@ impl Session { let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); let mut invalid = Vec::new(); - let prefs: Preferences = read_lenient(v, &[], &mut invalid); + let mut prefs: Preferences = read_lenient(v, &[], &mut invalid); + // An Effect Cache the field no longer accepts (above the 32-bit cap, saved before the cap + // or by a 64-bit build; a hand-edited 0) loads as the nearest value it does. Otherwise + // Apply from another Preferences tab, which validates every value, would reject it. + prefs.performance.effect_cache_mb = accepted_effect_cache_mb(prefs.performance.effect_cache_mb, SMALL_ADDRESS_SPACE); if let Some(c) = color { self.color.settings = read_lenient(c, &["colorSettings"], &mut invalid); photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma); @@ -1526,6 +1703,9 @@ impl Session { } else { next.set(path, value)?; } + if next.actions.create_single_history_state != self.prefs().actions.create_single_history_state && crate::actions_cmds::history_policy_in_use(self) { + return Err("cannot change action history settings during playback".into()); + } // Old `prefs.set interface.theme` clients (by path or inside a section object) still // select a visible theme. if path == "interface.theme" || next.interface.theme != self.prefs().interface.theme { diff --git a/crates/engine/src/prefs/cmds.rs b/crates/engine/src/prefs/cmds.rs index 982bf209ab..fa695e1e92 100644 --- a/crates/engine/src/prefs/cmds.rs +++ b/crates/engine/src/prefs/cmds.rs @@ -56,8 +56,16 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { Ok(Value::Object(out)) } +/// Validate policy-changing resets before mutating either preferences or color settings. fn prefs_reset(s: &mut Session, p: &Value) -> Result { let path = p.get("path").or_else(|| p.get("section")).and_then(Value::as_str); + if !path.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.")) { + let mut candidate = s.prefs().clone(); + candidate.reset(path).map_err(|e| bad("prefs.reset", e))?; + if candidate.actions.create_single_history_state != s.prefs().actions.create_single_history_state && crate::actions_cmds::history_policy_in_use(s) { + return Err(bad("prefs.reset", "cannot change action history settings during playback")); + } + } match path { Some("colorSettings") => s.color.settings = Default::default(), // One colour setting: `Preferences` has no `colorSettings`, so take the default @@ -169,7 +177,7 @@ pub fn specs() -> Vec { section!("edit.preferences.transparencyAndGamut", "Transparency & Gamut…"), section!("edit.preferences.unitsAndRulers", "Units & Rulers…"), section!("edit.preferences.guidesGridAndSlices", "Guides, Grid & Slices…"), - section!("edit.preferences.plugIns", "Plug-ins…"), + section!("edit.preferences.plugIns", "Plugins…"), section!("edit.preferences.type", "Type…"), section!("edit.preferences.enhancedControls", "Enhanced Controls…"), section!("edit.preferences.rawDefaults", "Camera Raw…"), diff --git a/crates/engine/src/prefs/shortcuts.rs b/crates/engine/src/prefs/shortcuts.rs index 337c8e3754..f2a69c30ce 100644 --- a/crates/engine/src/prefs/shortcuts.rs +++ b/crates/engine/src/prefs/shortcuts.rs @@ -124,6 +124,8 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ /// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace /// (Shift+Delete on a Mac) for Fill; ⌘+ beside ⌘= for Zoom In; and off the Mac, Ctrl+F4 beside /// Ctrl+W for Close (Windows' close-document key; macOS keeps Ctrl+F4 for window focus). +/// Also Alt+Ctrl+0 beside Ctrl+1 for 100% and F9 beside Alt+F9 for Window > Actions (#11396; +/// measured on Windows, so the Mac gets the usual Cmd mapping of the first). pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ ("edit.cut", "F2"), ("edit.copy", "F3"), @@ -131,6 +133,8 @@ pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ ("edit.fill", "Shift+Backspace"), ("select.inverse", "Shift+F7"), ("view.zoomIn", "Cmd++"), + ("view.actualPixels", "Cmd+Alt+0"), + ("window.panel.actions", "F9"), #[cfg(not(target_os = "macos"))] ("file.close", "Cmd+F4"), ]; diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 2e4e7ee098..351ce2883c 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -512,6 +512,20 @@ fn svg_group_rasterization_threshold_is_bounded_by_document_depth() { assert_eq!(s.prefs().file_handling.rasterize_svg_groups_deeper_than, 0, "out-of-range preferences leave the valid value unchanged"); } +#[test] +fn tablet_input_pref_defaults_to_windows_ink() { + let mut s = session(); + assert_eq!(s.prefs().tools.tablet_input, TabletInput::WindowsInk); + assert_eq!(s.execute("prefs.get", json!({"path": "tools.tabletInput"})).unwrap(), json!("windowsInk")); + assert_eq!(choices("tools.tabletInput"), Some(TabletInput::NAMES)); + s.execute("prefs.set", json!({"path": "tools.tabletInput", "value": "winTab"})).unwrap(); + assert_eq!(s.prefs().tools.tablet_input, TabletInput::WinTab); + for bad in [json!("ink"), json!(1), json!(true), json!(null)] { + assert!(s.execute("prefs.set", json!({"path": "tools.tabletInput", "value": bad})).is_err()); + } + assert_eq!(s.prefs().tools.tablet_input, TabletInput::WinTab, "rejected values change nothing"); +} + #[test] fn right_click_with_painting_tools_pref() { let mut s = session(); @@ -1025,6 +1039,25 @@ fn a_wrongly_typed_value_resets_only_that_field() { assert_eq!(invalid[1].keys, ["shortcuts", "file.open"]); } +#[test] +fn the_removed_enable_animations_preference_still_loads_and_is_unknown() { + // #9479: Interface › Enable animations is gone (Photoshop has no such setting). Files saved + // with it still load whole, without a reported bad value, and the key isn't written back. + let mut s = Session::new(); + let invalid = + s.load_prefs_json(r#"{"version": 2, "interface": {"enableAnimations": false, "showTooltips": false}, "performance": {"historyStates": 20}}"#).unwrap(); + assert!(invalid.is_empty(), "{invalid:?}"); + assert!(!s.prefs().interface.show_tooltips, "its neighbours load"); + assert_eq!(s.prefs().performance.history_states, 20); + assert!(!s.prefs_to_json().contains("enableAnimations")); + for value in [json!(true), json!(false), json!("nope")] { + let e = s.execute("prefs.set", json!({"values": {"interface.enableAnimations": value}})).unwrap_err(); + assert!(e.to_string().contains("unknown preference"), "{e}"); + assert!(s.execute("prefs.set", json!({"path": "interface.enableAnimations", "value": value})).is_err()); + } + assert!(s.execute("prefs.get", json!({"path": "interface.enableAnimations"})).is_err()); +} + #[test] fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { let mut s = Session::new(); @@ -1081,3 +1114,74 @@ fn the_web_build_caps_memory_usage() { assert_eq!(honoured_memory_usage_mb(stored, false), stored, "desktop, {stored} MB"); } } + +/// The effect cache budget is the stored MB in bytes, at least 16 MB (a hand-edited 0 included), +/// at most 1536 MB on a 32-bit address space (the web, 32-bit Windows) and 65536 MB elsewhere, +/// and never wraps: on wasm32 an uncapped 4096 MB shifted into `usize` was 0. Both kinds of +/// build are checked on every target, in `u64` so the expectations themselves can't overflow a +/// 32-bit `usize`. +#[test] +fn the_effect_cache_budget_is_capped_on_32_bit_builds_and_never_wraps() { + const MB: u64 = 1 << 20; + let cap = WASM_MEMORY_USAGE_CAP_MB; + for (stored, small_mb, large_mb) in [ + (0, 16, 16), + (15, 16, 16), + (16, 16, 16), + (768, 768, 768), + (cap, cap, cap), + (cap + 1, cap, cap + 1), + (4096, cap, 4096), + (65536, cap, 65536), + (u32::MAX, cap, 65536), + ] { + assert_eq!(effect_cache_budget_bytes_for(stored, true), u64::from(small_mb) * MB, "32-bit, {stored} MB"); + assert_eq!(effect_cache_budget_bytes_for(stored, false), u64::from(large_mb) * MB, "64-bit, {stored} MB"); + } + let p = Performance { effect_cache_mb: 4096, ..Performance::default() }; + let expected = effect_cache_budget_bytes_for(4096, cfg!(target_pointer_width = "32")); + assert_eq!(u64::try_from(p.effect_cache_budget_bytes()).ok(), Some(expected), "fits this target's usize"); +} + +/// The Effect Cache field accepts only what the build honours, so the dialog never shows a value +/// the cache doesn't get. +#[test] +fn the_effect_cache_range_matches_the_honoured_budget() { + assert_eq!(effect_cache_max_mb(true), WASM_MEMORY_USAGE_CAP_MB); + assert_eq!(effect_cache_max_mb(false), 65536); + for small in [true, false] { + let max = effect_cache_max_mb(small); + assert_eq!(effect_cache_budget_bytes_for(max, small), u64::from(max) << 20, "small = {small}: the largest accepted value is honoured"); + } + assert_eq!(effect_cache_budget_bytes_for(EFFECT_CACHE_MIN_MB, false), u64::from(EFFECT_CACHE_MIN_MB) << 20, "the smallest accepted value is honoured"); + let small = cfg!(target_pointer_width = "32"); + assert_eq!(range("performance.effectCacheMb"), Some((f64::from(EFFECT_CACHE_MIN_MB), f64::from(effect_cache_max_mb(small))))); + assert!(check_value("performance.effectCacheMb", &json!(effect_cache_max_mb(small))).is_ok()); + assert!(check_value("performance.effectCacheMb", &json!(effect_cache_max_mb(small) + 1)).is_err()); + assert!(Performance::default().effect_cache_mb <= effect_cache_max_mb(true), "the default fits the 32-bit cap"); +} + +/// An Effect Cache the field no longer accepts loads as the nearest value it does: a 32-bit build +/// took up to 65536 MB before its 1536 MB cap, and Apply from another Preferences tab sends every +/// value, so the old one rejected the whole change. On every target: the helper for both kinds of +/// build, and the load and Apply paths for this one. +#[test] +fn an_effect_cache_above_the_cap_loads_clamped_and_does_not_block_apply() { + let cap = WASM_MEMORY_USAGE_CAP_MB; + for (stored, small_mb, large_mb) in [(0, 16, 16), (768, 768, 768), (cap + 1, cap, cap + 1), (4096, cap, 4096), (65536, cap, 65536), (u32::MAX, cap, 65536)] + { + assert_eq!(accepted_effect_cache_mb(stored, true), small_mb, "32-bit, {stored} MB"); + assert_eq!(accepted_effect_cache_mb(stored, false), large_mb, "64-bit, {stored} MB"); + } + let max = effect_cache_max_mb(SMALL_ADDRESS_SPACE); + for (saved, loaded) in [(u64::from(max) + 1, max), (u64::from(u32::MAX), max), (0, EFFECT_CACHE_MIN_MB), (768, 768)] { + let mut s = session(); + let invalid = s.load_prefs_json(&json!({"performance": {"effectCacheMb": saved}}).to_string()).unwrap(); + assert!(invalid.is_empty(), "{saved} MB reads, so it isn't reported: {invalid:?}"); + assert_eq!(s.execute("prefs.get", json!({"path": "performance.effectCacheMb"})).unwrap(), json!(loaded), "{saved} MB"); + let mut values = s.execute("prefs.get", json!({"path": ""})).unwrap(); + values["fileHandling"]["recentFileCount"] = json!(7); + s.execute("prefs.set", json!({"path": "", "value": values})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"path": "fileHandling.recentFileCount"})).unwrap(), json!(7), "{saved} MB"); + } +} diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index e474f30dbe..f9c0b9f805 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -1,9 +1,9 @@ use super::shortcuts::normalize_shortcut; use super::{ AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview, - GpuBackend, Interpolation, LightTheme, LineStyle, LinuxDisplayServer, LogDestination, LogDetail, OtherCursor, PaintingCursor, PointSize, PressureCurve, - QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TextEngine, Theme, TypeUnit, UiFontSize, UiScale, - Unit, Value, + GpuBackend, Interpolation, LightTheme, LineStyle, LinuxDisplayServer, LogDestination, LogDetail, OtherCursor, PaintingCursor, PlaybackPerformance, + PointSize, PressureCurve, QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TabletInput, TextEngine, + Theme, TypeUnit, UiFontSize, UiScale, Unit, Value, }; /// Preferences dialog sections in Photoshop's order: (id, title). @@ -21,7 +21,7 @@ pub const SECTIONS: [(&str, &str); 18] = [ ("transparencyAndGamut", "Transparency & Gamut"), ("unitsAndRulers", "Units & Rulers"), ("guidesGridAndSlices", "Guides, Grid & Slices"), - ("plugIns", "Plug-ins"), + ("plugIns", "Plugins"), ("type", "Type"), ("enhancedControls", "Enhanced Controls"), ("rawDefaults", "Camera Raw Defaults"), @@ -33,12 +33,12 @@ pub const SECTIONS: [(&str, &str); 18] = [ /// They still load, save and round-trip through `prefs.get` / `prefs.set` unchanged. /// /// When you implement one, remove it here; the `prefs_usage` test fails while a listed -/// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere. +/// preference is read anywhere outside the prefs model (`prefs.rs` except its `impl Session` +/// blocks), and while an unlisted one is read nowhere. pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", - "performance.effectCacheMb", "performance.legacyCompositing", "unitsAndRulers.columnWidth", "unitsAndRulers.gutter", @@ -88,6 +88,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "cursors.painting" => PaintingCursor::NAMES, "cursors.other" => OtherCursor::NAMES, "tools.rightClickWithPaintingTools" => RightClickPaint::NAMES, + "tools.tabletInput" => TabletInput::NAMES, "transparencyAndGamut.gridSize" => CheckerSize::NAMES, "transparencyAndGamut.gridColors" => CheckerColors::NAMES, "unitsAndRulers.rulers" | "guidesGridAndSlices.gridUnit" => Unit::NAMES, @@ -103,6 +104,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "performance.gpuBackend" => GpuBackend::NAMES, "performance.linuxDisplayServer" => LinuxDisplayServer::NAMES, "performance.renderingMode" => RenderingMode::NAMES, + "actions.performance" => PlaybackPerformance::NAMES, _ => return None, }) } @@ -111,6 +113,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { pub fn range(path: &str) -> Option<(f64, f64)> { Some(match path { "fileHandling.autosaveMinutes" => (1.0, 240.0), + "actions.pauseSeconds" => (1.0, 60.0), "fileHandling.recentFileCount" => (0.0, 100.0), "tools.nudgeBundlePauseMs" => (100.0, 10_000.0), "fileHandling.rasterizeSvgGroupsDeeperThan" => (0.0, photocraft_doc::MAX_GROUP_DEPTH as f64), @@ -122,7 +125,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), - "performance.effectCacheMb" => (16.0, 65536.0), + "performance.effectCacheMb" => (f64::from(super::EFFECT_CACHE_MIN_MB), f64::from(super::effect_cache_max_mb(super::SMALL_ADDRESS_SPACE))), "transparencyAndGamut.gamutWarningOpacity" => (1.0, 100.0), "unitsAndRulers.columnWidth" | "unitsAndRulers.gutter" => (0.0, 10_000.0), "unitsAndRulers.printResolution" | "unitsAndRulers.screenResolution" | "rawDefaults.resolution" => (1.0, 10_000.0), diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index efdb16cfc8..1845daa6d0 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -200,12 +200,39 @@ impl Session { contours: take(&mut m, "contours"), }; let st = &mut self.presets; - if let Some(g) = p.gradients { - st.gradients = g; - } if let Some(g) = p.gradient { st.gradient = g; } + let saved_current = st.gradient.clone(); + if let Some(g) = p.gradients { + st.gradients = g; + // Older imports could contain duplicate names, which alias panel selections. + // Reserve all original names so a duplicate cannot steal a later preset's name. + // Lookup is case-sensitive: Foo and FOO are already independently addressable. + let mut reserved: std::collections::HashSet = + st.gradients.iter().flat_map(|group| group.items.iter().map(|item| item.name.clone())).collect(); + let mut seen = std::collections::HashSet::new(); + let mut next = std::collections::HashMap::::new(); + for group in &mut st.gradients { + for item in &mut group.items { + if seen.insert(item.name.clone()) { + continue; + } + let suffix = next.entry(item.name.clone()).or_insert(2); + loop { + let candidate = format!("{} {}", item.name, suffix); + *suffix = suffix.saturating_add(1); + if reserved.insert(candidate.clone()) { + if *item == saved_current { + st.gradient.name = candidate.clone(); + } + item.name = candidate; + break; + } + } + } + } + } if let Some(g) = p.styles { st.styles = g; } diff --git a/crates/engine/src/presets/shapes.rs b/crates/engine/src/presets/shapes.rs index e8a966e40d..821a69a083 100644 --- a/crates/engine/src/presets/shapes.rs +++ b/crates/engine/src/presets/shapes.rs @@ -16,6 +16,8 @@ use crate::commands::CommandSpec; use crate::vector_cmds::path_json; use crate::{EngineError, Result, Session}; +mod csh; + /// Group listing Edit › Define Custom Shape results (`EditState::custom_shapes`). pub const CUSTOM_GROUP: &str = "Custom Shapes"; @@ -23,6 +25,9 @@ pub const CUSTOM_GROUP: &str = "Custom Shapes"; pub struct ShapePreset { pub name: String, pub path: Path, + /// An imported library's opaque identity; old and locally drawn presets need none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, } impl Named for ShapePreset { @@ -185,7 +190,7 @@ fn shout() -> String { /// Built-in groups (our own drawings). pub fn builtin() -> Vec> { let mk = |list: Vec<(&str, String)>| -> Vec { - list.into_iter().filter_map(|(n, src)| parse(&src).ok().map(|path| ShapePreset { name: n.into(), path })).collect() + list.into_iter().filter_map(|(n, src)| parse(&src).ok().map(|path| ShapePreset { name: n.into(), path, id: None })).collect() }; let s = |x: &str| x.to_string(); vec![ @@ -277,7 +282,7 @@ pub fn builtin() -> Vec> { /// Every shape the panel shows: the preset groups plus Define Custom Shape results. pub fn all_groups(s: &Session) -> Vec> { let mut g = s.presets.shapes.clone(); - let custom: Vec = s.edit_state.custom_shapes.iter().map(|c| ShapePreset { name: c.name.clone(), path: c.path.clone() }).collect(); + let custom: Vec = s.edit_state.custom_shapes.iter().map(|c| ShapePreset { name: c.name.clone(), path: c.path.clone(), id: None }).collect(); if !custom.is_empty() { g.push(Group::new(CUSTOM_GROUP, custom)); } @@ -290,6 +295,24 @@ fn lookup(s: &Session, name: &str, group: Option<&str>, cmd: &str) -> Result Result { + const CMD: &str = "filter.blur.shapeBlur"; + if let Some(value) = p.get("shapeId") { + let id = value.as_str().filter(|id| !id.is_empty()).ok_or_else(|| bad(CMD, "shapeId must be a nonempty string"))?; + let mut matches = s.presets.shapes.iter().flat_map(|g| &g.items).filter(|shape| shape.id.as_deref() == Some(id)); + let found = matches.next().ok_or_else(|| bad(CMD, format!("no installed shape with id \"{id}\"")))?; + // Reimporting an unchanged library adds another display name for the same asset. + // Different geometry under one ID is ambiguous and must not pick the first match. + if matches.any(|shape| shape.path != found.path) { + return Err(bad(CMD, format!("different installed shapes have id \"{id}\""))); + } + return Ok(found.path.clone()); + } + let name = p.get("shapeName").and_then(Value::as_str).ok_or_else(|| bad(CMD, "a custom shape needs shapeId or shapeName"))?; + Ok(lookup(s, name, None, CMD)?.path) +} + /// Coverage (0..1) of `path` fitted into a `size`² swatch with a small margin. pub fn thumbnail(path: &Path, size: u32) -> Vec { let size = size.clamp(4, 512); @@ -318,7 +341,17 @@ fn list(s: &mut Session, _: &Value) -> Result { let groups: Vec = all_groups(s) .iter() .map(|g| { - let items: Vec = g.items.iter().map(|i| json!({"name": i.name, "subpaths": i.path.subpaths.len()})).collect(); + let items: Vec = g + .items + .iter() + .map(|i| { + let mut item = json!({"name": i.name, "subpaths": i.path.subpaths.len()}); + if let Some(id) = &i.id { + item["id"] = json!(id); + } + item + }) + .collect(); json!({"name": g.name, "shapes": items}) }) .collect(); @@ -362,6 +395,14 @@ fn place(s: &mut Session, p: &Value) -> Result { fn new_preset(s: &mut Session, p: &Value) -> Result { const CMD: &str = "shape.presets.new"; + let id = match p.get("id") { + None | Some(Value::Null) => None, + Some(Value::String(id)) if !id.is_empty() => Some(id.clone()), + _ => return Err(bad(CMD, "id must be a nonempty string")), + }; + if id.as_deref().is_some_and(|id| s.presets.shapes.iter().flat_map(|g| &g.items).any(|shape| shape.id.as_deref() == Some(id))) { + return Err(bad(CMD, "a shape with this id is already installed")); + } let path = match p.get("path") { Some(v @ Value::Object(_)) => crate::vector_cmds::parse_path(v).map_err(|e| bad(CMD, e))?, Some(Value::String(src)) if src.trim_start().starts_with(['M', 'O', '!']) => parse(src).map_err(|e| bad(CMD, e))?, @@ -379,7 +420,7 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { let name = unique_name(&s.presets.shapes, str_param(p, "name").unwrap_or("Shape")); let gi = group_index(&mut s.presets.shapes, str_param(p, "group")); let group = s.presets.shapes[gi].name.clone(); - s.presets.shapes[gi].items.push(ShapePreset { name: name.clone(), path }); + s.presets.shapes[gi].items.push(ShapePreset { name: name.clone(), path, id }); s.presets_changed(); Ok(json!({"name": name, "group": group})) } @@ -428,12 +469,13 @@ fn reset(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + csh::spec(), CommandSpec { id: "shape.presets.list", label: "Shape Presets", menu: &[], shortcut: None, - params: "{} → {groups:[{name,shapes:[{name,subpaths}]}]}", + params: "{} → {groups:[{name,shapes:[{name,id?,subpaths}]}]}", enabled: always, run: list, journal: false, @@ -453,7 +495,7 @@ pub fn specs() -> Vec { label: "New Shape Preset", menu: &[], shortcut: None, - params: r##"{"name":str="Shape","group":name?,"path":{…path}|"M x y L …" (preset path language)|"work"|saved path name? (default: work path, active shape layer or vector mask)}"##, + params: r##"{"name":str="Shape","id":str? (an imported library's opaque identity),"group":name?,"path":{…path}|"M x y L …" (preset path language)|"work"|saved path name? (default: work path, active shape layer or vector mask)}"##, enabled: always, run: new_preset, journal: true, diff --git a/crates/engine/src/presets/shapes/csh.rs b/crates/engine/src/presets/shapes/csh.rs new file mode 100644 index 0000000000..d9c83bca5a --- /dev/null +++ b/crates/engine/src/presets/shapes/csh.rs @@ -0,0 +1,90 @@ +//! Import a complete custom-shape set before changing the persistent library. + +use std::collections::{HashMap, HashSet}; + +use serde_json::{Value, json}; + +use super::{CommandSpec, Group, Session, ShapePreset, always, bad, fit}; +use crate::Result; + +const CMD: &str = "shape.presets.importCsh"; + +pub(super) fn spec() -> CommandSpec { + CommandSpec { + id: CMD, + label: "Import Shapes", + menu: &[], + shortcut: None, + params: r#"{"path":str (.csh) | "data":str (base64),"group":str?} → {group,count,names}; appends a uniquely named group, never replaces existing presets"#, + enabled: always, + run: import, + journal: true, + } +} + +/// Parse and convert the complete set before applying any library changes. +/// Group and shape names are resolved against the live library on apply, not the worker's +/// snapshot, so edits made while the job runs do not introduce duplicate addressable names. +/// Conversion fits each path into a 100-by-100 reference box while preserving its aspect ratio. +fn import(s: &mut Session, p: &Value) -> Result { + let group = p + .get("group") + .map(|v| v.as_str().map(str::trim).filter(|n| !n.is_empty()).map(str::to_owned).ok_or_else(|| bad(CMD, "`group` must be a non-empty string"))) + .transpose()?; + let params = p.clone(); + crate::jobs::run( + s, + "Import Shapes", + false, + move |ctx| { + ctx.check()?; + let (bytes, stem) = crate::preset_import_cmds::file_bytes_limited(¶ms, CMD, ".csh file", photocraft_psd::csh::MAX_FILE_BYTES as u64)?; + let shapes = photocraft_psd::csh::parse(&bytes).map_err(|e| bad(CMD, e.to_string()))?; + if shapes.is_empty() { + return Err(bad(CMD, "the CSH set contains no shapes")); + } + let mut items = Vec::with_capacity(shapes.len()); + for shape in shapes { + ctx.check()?; + let [top, left, bottom, right] = shape.bounds; + // The parser validates ordering; widen before subtraction for signed bounds. + let width = u32::try_from(i64::from(right) - i64::from(left)).map_err(|_| bad(CMD, "shape width exceeds supported bounds"))?; + let height = u32::try_from(i64::from(bottom) - i64::from(top)).map_err(|_| bad(CMD, "shape height exceeds supported bounds"))?; + let path = photocraft_io::vector_map::path_from_records(&shape.path, width, height); + if path.is_empty() { + return Err(bad(CMD, format!("shape {:?} has no path", shape.name))); + } + let path = fit(&path, [0.0, 0.0, 100.0, 100.0], true); + // Photoshop stores some preset names as `$$$/…/Key=Shown`. The Shapes panel + // renders the stored name, so keep the text after `=`. + let shown = photocraft_doc::pattern::display_name(&shape.name); + let name = if shown.trim().is_empty() { "Shape".to_owned() } else { shown.to_owned() }; + // Keep opaque IDs unchanged. An anonymous preset remains usable by name; + // repeated IDs are checked against geometry when a filter resolves them. + let id = (!shape.id.is_empty()).then_some(shape.id); + items.push(ShapePreset { name, path, id }); + } + let group = group.unwrap_or_else(|| if stem.is_empty() { "Imported Shapes".into() } else { stem }); + Ok((group, items)) + }, + |s, (group, mut items)| { + let group = super::super::gradients::unique_group(&s.presets.shapes, &group); + let mut taken: HashSet = s.presets.shapes.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect(); + taken.extend(s.edit_state.custom_shapes.iter().map(|c| c.name.clone())); + let mut suffixes: HashMap = HashMap::new(); + for item in &mut items { + let base = item.name.clone(); + let suffix = suffixes.entry(base.clone()).or_insert(2); + while !taken.insert(item.name.clone()) { + item.name = format!("{base} {suffix}"); + *suffix += 1; + } + } + let names: Vec = items.iter().map(|i| i.name.clone()).collect(); + let result = json!({"group": group, "count": items.len(), "names": names}); + s.presets.shapes.push(Group::new(&group, items)); + s.presets_changed(); + Ok(result) + }, + ) +} diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index b196117839..0bb3a13857 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -612,6 +612,73 @@ fn tool_presets_save_filter_select() { assert!(s.execute("tool.presets.new", json!({"name": "x"})).is_err()); } +#[test] +fn tool_presets_reject_invalid_colors_before_applying_the_brush() { + let mut invalid = vec![ + json!(null), + json!(true), + json!(7), + json!("red"), + json!({}), + json!([]), + json!([1]), + json!([1, 0]), + json!([1, 0, 0, 1, 0]), + json!([null, 0.4, 0.6]), + ]; + for channel in 0..4 { + for bad_channel in [json!(1e100), json!(-1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let mut values = json!([0.2, 0.4, 0.6, 0.8]); + values[channel] = bad_channel; + invalid.push(values); + } + } + for values in invalid { + let mut s = Session::new(); + s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap(); + let color = s.tools.foreground; + let brush = s.tools.brush.clone(); + let brush_rev = s.tools.brush_rev; + s.execute( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values} + }), + ) + .unwrap(); + assert!(s.execute("tool.presets.select", json!({"preset": "Invalid"})).is_err(), "{values}"); + assert_eq!(s.tools.foreground, color, "{values}"); + assert_eq!(s.tools.brush, brush, "{values}"); + assert_eq!(s.tools.brush_rev, brush_rev, "{values}"); + } +} + +#[test] +fn tool_presets_keep_finite_hdr_colors_and_leave_absent_colors_unchanged() { + let mut s = Session::new(); + for (options, expected) in [ + (json!({"foreground": [1.5, -0.1, 0.6, 0.8]}), [1.5, -0.1, 0.6, 0.8]), + (json!({"foreground": [0.2, 0.4, 0.6]}), [0.2, 0.4, 0.6, 1.0]), + (json!({}), [0.2, 0.4, 0.6, 1.0]), + ] { + let preset = s.execute("tool.presets.new", json!({"tool": "move", "options": options})).unwrap(); + s.execute("tool.presets.select", json!({"preset": preset["name"]})).unwrap(); + assert_eq!(s.tools.foreground, expected); + } +} + +/// An Art History Brush preset captures the brush's opacity and selecting it restores it. +#[test] +fn art_history_brush_preset_carries_the_opacity() { + let mut s = Session::new(); + s.execute("tools.setBrush", json!({"brush": {"opacity": 0.4}})).unwrap(); + s.execute("tool.presets.new", json!({"name": "Faint History", "tool": "artHistoryBrush"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"opacity": 1.0}})).unwrap(); + let r = s.execute("tool.presets.select", json!({"preset": "Faint History"})).unwrap(); + assert_eq!(r["tool"], "artHistoryBrush"); + assert!((s.tools.brush.opacity - 0.4).abs() < 1e-6, "{}", s.tools.brush.opacity); +} + /// Crop presets are `crop` tool presets: saved, listed, persisted and deleted like the others. #[test] fn crop_presets_are_tool_presets_that_persist() { @@ -879,6 +946,37 @@ fn a_damaged_preset_set_keeps_the_other_sets() { assert_eq!(next.presets.export_presets, s.presets.export_presets); } +#[test] +fn restoring_duplicate_gradient_names_preserves_addressable_presets() { + use crate::presets::Group; + use crate::presets::gradients::GradientPreset; + + let mut s = Session::new(); + let presets = vec![ + Group::new("First", vec![GradientPreset::new("Foo", &["#ff0000", "#000000"]), GradientPreset::new("Foo", &["#00ff00", "#000000"])]), + Group::new("Second", vec![GradientPreset::new("Foo 2", &["#0000ff", "#000000"]), GradientPreset::new("FOO", &["#ffff00", "#000000"])]), + ]; + s.presets.gradient = presets[0].items[1].clone(); + s.presets.gradients = presets; + let saved = s.presets.to_json(&s); + let mut restored = Session::new(); + restored.load_presets_json(saved.clone()); + let names: Vec = restored.presets.gradients.iter().flat_map(|g| g.items.iter().map(|p| p.name.clone())).collect(); + assert_eq!(names, ["Foo", "Foo 3", "Foo 2", "FOO"]); + assert_eq!(restored.presets.gradient.name, "Foo 3"); + assert_eq!(restored.presets.gradient.stops, GradientPreset::new("", &["#00ff00", "#000000"]).stops); + for (name, color) in [("Foo 2", "#0000ff"), ("FOO", "#ffff00")] { + restored.execute("gradient.presets.select", json!({"preset": name})).unwrap(); + assert_eq!(restored.presets.gradient.stops, GradientPreset::new("", &[color, "#000000"]).stops); + } + let current = restored.presets.gradient.clone(); + let once = restored.presets.to_json(&restored); + restored.load_presets_json(once); + assert_eq!(restored.presets.gradient, current); + let names: Vec = restored.presets.gradients.iter().flat_map(|g| g.items.iter().map(|p| p.name.clone())).collect(); + assert_eq!(names, ["Foo", "Foo 3", "Foo 2", "FOO"]); +} + // ------------------------------------------------------------------ export presets #[test] diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 2746c1576e..25b70eb185 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -115,6 +115,7 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { "healing", "spothealing", "historybrush", + "arthistorybrush", "blur", "sharpen", "smudge", @@ -157,13 +158,21 @@ fn select(s: &mut Session, p: &Value) -> Result { .find(|t| t.name == name) .cloned() .ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; + // Validate before applying the brush too: an invalid colour must leave the preset unapplied. + let foreground = match t.options.get("foreground") { + None => s.tools.foreground, + Some(value) => { + let valid = value.as_array().is_some_and(|channels| matches!(channels.len(), 3 | 4) && channels.iter().all(Value::is_number)); + if !valid { + return Err(bad(CMD, "`foreground` must be [r, g, b] or [r, g, b, a] numbers")); + } + crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)? + } + }; if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) { super::call(s, "tools.setBrush", json!({"brush": b}))?; } - if let Some(c) = t.options.get("foreground").and_then(Value::as_array).filter(|a| a.len() >= 3) { - let v: Vec = c.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - s.tools.foreground = [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]; - } + s.tools.foreground = foreground; Ok(json!({"name": t.name, "tool": t.tool, "options": t.options})) } diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index 6c7afc15d9..f87c77f5fd 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -305,10 +305,10 @@ pub fn specs() -> Vec { kind_spec!("view.proofSetup.workingMagentaPlate", "Working Magenta Plate"), kind_spec!("view.proofSetup.workingYellowPlate", "Working Yellow Plate"), kind_spec!("view.proofSetup.workingBlackPlate", "Working Black Plate"), - kind_spec!("view.proofSetup.workingCmyPlate", "Working CMY Plate"), - kind_spec!("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB"), - kind_spec!("view.proofSetup.colorBlindnessProtanopia", "Color Blindness — Protanopia-type"), - kind_spec!("view.proofSetup.colorBlindnessDeuteranopia", "Color Blindness — Deuteranopia-type"), + kind_spec!("view.proofSetup.workingCmyPlate", "Working CMY Plates"), + kind_spec!("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB (Gamma 1.8)"), + kind_spec!("view.proofSetup.colorBlindnessProtanopia", "Color Blindness - Protanopia-type"), + kind_spec!("view.proofSetup.colorBlindnessDeuteranopia", "Color Blindness - Deuteranopia-type"), CommandSpec { id: "view.thirtyTwoBitPreviewOptions", label: "32-bit Preview Options…", diff --git a/crates/engine/src/second_keys_zoom_actions_tests.rs b/crates/engine/src/second_keys_zoom_actions_tests.rs new file mode 100644 index 0000000000..92d3b820cf --- /dev/null +++ b/crates/engine/src/second_keys_zoom_actions_tests.rs @@ -0,0 +1,95 @@ +//! The reference application's second default keys for View > 100% and Window > Actions (#11396): +//! Alt+Ctrl+0 beside Ctrl+1, and F9 beside Alt+F9. +//! +//! The engine owns the key tables and the Keyboard Shortcuts command. The zoom itself and the +//! Actions panel are UI state (`window.panel.actions` is a menu item the engine does not run), so +//! these tests check that each key resolves to its command exactly as the main key does and +//! that the user can remove it. They do not exercise key dispatch; `alternate_keys_run_their_command` +//! in the UI crate's `shortcut_dispatch` tests does, and lists both keys. + +use crate::Session; +use crate::prefs::{ALTERNATE_SHORTCUTS, default_alternates, normalize_shortcut}; +use serde_json::json; + +/// The menus' main keys (`menu_catalog.rs`): what View > 100% and Window > Actions show. +const ACTUAL_PIXELS_MAIN: &str = "Cmd+1"; +const ACTIONS_MAIN: &str = "Alt+F9"; + +/// Our notation: `Cmd` is Ctrl off the Mac, so this is Ctrl+Alt+0 on Windows and Linux. +const ACTUAL_PIXELS_ALT: &str = "Cmd+Alt+0"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s +} + +/// The command a key press runs among the engine's bindable commands and the two this issue +/// adds keys to: a command's main key (given for the UI-only one), else its alternates in effect. +fn resolve(s: &Session, key: &str) -> Vec<&'static str> { + let want = normalize_shortcut(key).unwrap(); + let mains = [("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)), ("window.panel.actions", Some(ACTIONS_MAIN))]; + let mut ids: Vec<&'static str> = Vec::new(); + for (id, main) in mains { + let main_in_effect = s.prefs().shortcut(id, main); + let is_main = main_in_effect.and_then(normalize_shortcut).as_ref() == Some(&want); + if is_main || s.prefs().alternates(id, main_in_effect).contains(&want) { + ids.push(id); + } + } + ids +} + +#[test] +fn both_keys_are_default_alternates() { + assert!(ALTERNATE_SHORTCUTS.contains(&("view.actualPixels", "Cmd+Alt+0"))); + assert!(ALTERNATE_SHORTCUTS.contains(&("window.panel.actions", "F9"))); + assert_eq!(default_alternates("view.actualPixels").collect::>(), ["Cmd+Alt+0"]); + assert_eq!(default_alternates("window.panel.actions").collect::>(), ["F9"]); + let s = session(); + let zoom = s.prefs().alternates("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)); + assert_eq!(zoom, [normalize_shortcut(ACTUAL_PIXELS_ALT).unwrap()]); + assert_eq!(s.prefs().alternates("window.panel.actions", Some(ACTIONS_MAIN)), ["F9"]); +} + +#[test] +fn each_key_resolves_to_its_command_like_the_main_key() { + let s = session(); + assert_eq!(resolve(&s, ACTUAL_PIXELS_ALT), ["view.actualPixels"]); + assert_eq!(resolve(&s, ACTUAL_PIXELS_MAIN), ["view.actualPixels"]); + assert_eq!(resolve(&s, "F9"), ["window.panel.actions"]); + assert_eq!(resolve(&s, ACTIONS_MAIN), ["window.panel.actions"]); +} + +#[test] +fn the_menu_shows_only_the_main_key() { + // The menus print the catalogue's key, and an alternate is never the main key in effect. + let s = session(); + assert_eq!(s.prefs().shortcut("view.actualPixels", Some(ACTUAL_PIXELS_MAIN)), Some(ACTUAL_PIXELS_MAIN)); + assert_eq!(s.prefs().shortcut("window.panel.actions", Some(ACTIONS_MAIN)), Some(ACTIONS_MAIN)); + assert_eq!(crate::shell_commands::SHELL_COMMANDS.iter().find(|c| c.0 == "view.actualPixels").and_then(|c| c.3), Some(ACTUAL_PIXELS_MAIN)); +} + +#[test] +fn keyboard_shortcuts_lists_the_second_key_and_removing_it_stops_it() { + let mut s = session(); + let r = s.execute("edit.keyboardShortcuts", json!({"filter": "view.actualPixels"})).unwrap(); + let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "view.actualPixels").unwrap().clone(); + assert_eq!(row["shortcut"], ACTUAL_PIXELS_MAIN); + assert_eq!(row["alternates"], json!([normalize_shortcut(ACTUAL_PIXELS_ALT).unwrap()])); + assert_eq!(row["defaultAlternates"], json!(["Cmd+Alt+0"])); + + s.execute("edit.keyboardShortcuts", json!({"alternates": {"view.actualPixels": []}})).unwrap(); + assert!(resolve(&s, ACTUAL_PIXELS_ALT).is_empty()); + assert_eq!(resolve(&s, ACTUAL_PIXELS_MAIN), ["view.actualPixels"], "the main key still works"); + + // Window > Actions is a menu item, so it is not in the engine's list, but its second key can + // still be set and removed, as the Keyboard Shortcuts dialog does. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"window.panel.actions": []}})).unwrap(); + assert!(resolve(&s, "F9").is_empty()); + assert_eq!(resolve(&s, ACTIONS_MAIN), ["window.panel.actions"]); + + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + assert_eq!(resolve(&s, "F9"), ["window.panel.actions"]); + assert_eq!(resolve(&s, ACTUAL_PIXELS_ALT), ["view.actualPixels"]); +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 3e6bc4abc7..49f8205079 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,7 +1,7 @@ use crate::{EngineError, Result}; use super::history::fix_selection; -use super::{DocState, Document, Session, channel_cmds, smart_cmds}; +use super::{DocState, Document, LayerId, Session, channel_cmds, smart_cmds}; /// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when /// `i` is out of range. @@ -43,16 +43,48 @@ impl Session { self.active_generation } + /// Change marker: advances at least once whenever the active document or layer changes through + /// the `Session` API (see the field). Compare two readings for equality to know whether anything + /// changed in between; it is not an exact count of operations. A direct write to + /// `DocState::active_layer` through `active_mut()` bypasses it. + pub fn active_layer_changes(&self) -> u64 { + self.active_layer_changes + } + /// Bump [`Self::active_generation`] when the active document id changed. Call after every /// assignment to `self.active`. pub(crate) fn note_active_change(&mut self) { let id = self.active().map(|st| st.doc.id); if id != self.active_doc_for_generation { + self.active_layer_changes = self.active_layer_changes.wrapping_add(1); self.active_doc_for_generation = id; self.active_generation = self.active_generation.wrapping_add(1); } } + /// The active document and its active layer, for [`Self::note_layer_change`]. + pub(crate) fn active_layer_stamp(&self) -> Option<(photocraft_doc::DocId, Option)> { + self.active().map(|st| (st.doc.id, st.active_layer)) + } + + /// Count a change of the active document or layer since `before` (from + /// [`Self::active_layer_stamp`]). Every path that can change either calls this after it. + pub(crate) fn note_layer_change(&mut self, before: Option<(photocraft_doc::DocId, Option)>) { + if self.active_layer_stamp() != before { + self.active_layer_changes = self.active_layer_changes.wrapping_add(1); + } + } + + /// Make `layer` the active document's active layer and nothing else (the selection list and + /// anchor are left as they are), counted in [`Self::active_layer_changes`]. + pub fn set_active_layer(&mut self, layer: Option) { + let before = self.active_layer_stamp(); + if let Some(st) = self.active_mut() { + st.active_layer = layer; + } + self.note_layer_change(before); + } + pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { self.active = Some(index); @@ -142,6 +174,11 @@ impl Session { st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize; st.history.max_bytes = self.prefs.get().performance.history_cap_bytes(); st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history; + // History Options › Automatically Create First Snapshot off: no snapshot of the document + // as opened, so the History panel starts at the opening state (#2966). + if !self.prefs.get().history_options.automatically_create_first_snapshot { + st.snapshots.clear(); + } self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index e0fc23447b..5522f5fc5c 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -15,7 +15,10 @@ impl Session { /// Runs synchronously, including job-capable commands (see [`jobs`]; [`Session::start`] runs /// those in the background). pub fn execute(&mut self, id: &str, params: Value) -> Result { - match self.dispatch(id, params, false)? { + let before = self.active_layer_stamp(); + let result = self.dispatch(id, params, false); + self.note_layer_change(before); + match result? { jobs::Started::Done(v) => Ok(v), // Not reached: inline dispatch never starts a job. Waiting is still correct. jobs::Started::Job(j) => self.wait_job(j), @@ -243,6 +246,9 @@ impl Session { /// [`Self::edit`], recording a history state only when `record` is set. An unrecorded change /// still marks the document modified and leaves the redo states alone. pub(crate) fn edit_with(&mut self, label: &str, record: bool, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + if record { + crate::actions_cmds::before_action_edit(self); + } // Every mutation path (linked-file refresh, transform, place) re-renders smart objects // through this entry; the requesting session's authority must reach those renders. let _authorization = crate::jobs::render_authorization_scope(self.authorize); diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index b6e81e64bc..01a90a169f 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -2,11 +2,24 @@ use super::documents::fix_active; use super::{Arc, DocState, Document, LayerId, LayerTarget, Session, channel_cmds, layer_multi_cmds}; impl Session { + /// Undo a document step or restore Clear History's rows and source without editing pixels. pub fn undo(&mut self) -> bool { + let before = self.active_layer_stamp(); + let taken = self.undo_step(); + self.note_layer_change(before); + taken + } + + fn undo_step(&mut self) -> bool { // A background job is computing from the current state: it must not move under it. if self.active_job().is_some() { return false; } + // Undo Clear History only restores metadata. It must not establish the new document + // baseline that an actual edit after Clear History would create. + if !self.active().is_some_and(|st| st.history.is_cleared()) { + crate::actions_cmds::before_action_edit(self); + } let keep_visibility = !self.visibility_undoable(); let Some(st) = self.active_mut() else { return false }; st.coalesce = None; @@ -47,10 +60,19 @@ impl Session { } } + /// Restore the next document state and its selection/visibility bookkeeping through the ordinary history path. pub fn redo(&mut self) -> bool { + let before = self.active_layer_stamp(); + let taken = self.redo_step(); + self.note_layer_change(before); + taken + } + + fn redo_step(&mut self) -> bool { if self.active_job().is_some() { return false; } + crate::actions_cmds::before_action_edit(self); let keep_visibility = !self.visibility_undoable(); let Some(st) = self.active_mut() else { return false }; st.coalesce = None; @@ -84,6 +106,13 @@ impl Session { /// last of them). Falls back to Undo plus forgetting the redo states when the history /// changed since that step. Returns whether a step was taken back. pub fn rollback(&mut self) -> bool { + let before = self.active_layer_stamp(); + let taken = self.rollback_step(); + self.note_layer_change(before); + taken + } + + fn rollback_step(&mut self) -> bool { if self.active_job().is_some() { return false; } diff --git a/crates/engine/src/session/layers.rs b/crates/engine/src/session/layers.rs index 2e6009d16f..d7f8ed9997 100644 --- a/crates/engine/src/session/layers.rs +++ b/crates/engine/src/session/layers.rs @@ -1,9 +1,11 @@ use super::history::fix_selection; use super::{Arc, EngineError, LayerId, Result, Session, channel_cmds, layer_multi_cmds, type_cmds}; +use crate::TypeLayerRef; impl Session { /// Replace the active document's active layer without creating a history step. pub fn select_layer(&mut self, id: LayerId) -> Result<()> { + let before = self.active_layer_stamp(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; if st.doc.layer(id).is_none() { return Err(EngineError::NoLayer(id)); @@ -19,6 +21,7 @@ impl Session { st.saved_revision = st.revision; } st.last_damage = Some(photocraft_geom::Rect::EMPTY); + self.note_layer_change(before); Ok(()) } @@ -27,6 +30,7 @@ impl Session { /// is no layer selected. Selected layers that are gone are dropped; an active layer that is /// gone is an error and changes nothing. Like [`Self::select_layer`], not an edit. pub fn restore_layer_selection(&mut self, active: Option, selected: &[LayerId], anchor: Option) -> Result<()> { + let before = self.active_layer_stamp(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; if let Some(id) = active && st.doc.layer(id).is_none() @@ -44,6 +48,7 @@ impl Session { st.saved_revision = st.revision; } st.last_damage = Some(photocraft_geom::Rect::EMPTY); + self.note_layer_change(before); Ok(()) } @@ -52,41 +57,70 @@ impl Session { /// unchanged, so this is no history step and leaves a clean document clean. Unknown documents /// and layers are skipped. A document a background job is computing from must not move under /// it: its layers are returned, to try again later. - pub fn refresh_type_layers(&mut self, layers: &[(photocraft_doc::DocId, LayerId)]) -> Vec<(photocraft_doc::DocId, LayerId)> { + pub fn refresh_type_layers(&mut self, layers: &[TypeLayerRef]) -> Vec { let mut busy = Vec::new(); let mut docs: Vec = Vec::new(); - for (d, _) in layers { - if !docs.contains(d) { - docs.push(*d); + for l in layers { + if !docs.contains(&l.doc) { + docs.push(l.doc); } } for doc_id in docs { - let mine = layers.iter().filter(|(d, _)| *d == doc_id); if self.job_on(doc_id).is_some() { - busy.extend(mine); + busy.extend(layers.iter().filter(|l| l.doc == doc_id)); continue; } + let ids = |first: bool| -> Vec { layers.iter().filter(|l| l.doc == doc_id && l.first_snapshot == first).map(|l| l.layer).collect() }; let Some(st) = self.docs.iter_mut().find(|st| st.doc.id == doc_id) else { continue }; - let snapshot = st.doc.clone(); - let mut doc = (*snapshot).clone(); - let mut changed = false; - for (_, id) in mine { - if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = doc.layer_mut(*id) { - type_cmds::refresh(&snapshot, t); - changed = true; + // The first snapshot is the document as opened (#2966, #6087): it was taken before the + // font arrived, so its type layers get the same refresh, or returning to it brings the + // fallback-font pixels back. While it still is the open document, it shares the + // refreshed one; once edited, it is refreshed on its own, layers the edit removed too. + let first_is_doc = st.snapshots.iter().any(|s| s.id == 0 && Arc::ptr_eq(&s.doc, &st.doc)); + if !first_is_doc + && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) + && let Some(doc) = refresh_text_layers(&first.doc, &ids(true)) + { + first.doc = Arc::new(doc); + } + // While shared, a layer picked in the snapshot is a layer of the open document too + // (a document busy when its font arrived can be back at the snapshot by the retry). + let mut open = ids(false); + if first_is_doc { + for id in ids(true) { + if !open.contains(&id) { + open.push(id); + } } } - if changed { - let clean = st.saved_revision == st.revision; - st.doc = Arc::new(doc); - st.revision += 1; - if clean { - st.saved_revision = st.revision; - } - // Only the refreshed layers' caches changed: their changed tiles. - st.last_damage = layer_multi_cmds::step_damage(&snapshot, &st.doc); + let Some(doc) = refresh_text_layers(&st.doc, &open) else { continue }; + let doc = Arc::new(doc); + if first_is_doc && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) { + first.doc = doc.clone(); } + let clean = st.saved_revision == st.revision; + let before = std::mem::replace(&mut st.doc, doc); + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + // Only the refreshed layers' caches changed: their changed tiles. + st.last_damage = layer_multi_cmds::step_damage(&before, &st.doc); } busy } } + +/// `doc` with its type layers among `ids` re-rendered (`Session::refresh_type_layers`), or `None` +/// when none of them is a type layer of `doc`. +fn refresh_text_layers(doc: &Arc, ids: &[LayerId]) -> Option { + let mut out = (**doc).clone(); + let mut changed = false; + for id in ids { + if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = out.layer_mut(*id) { + type_cmds::refresh(doc, t); + changed = true; + } + } + changed.then_some(out) +} diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 3b52c03a03..49db9509e5 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -42,7 +42,9 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), - ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), + ("view.lockGuides", "Lock Guides", &["View", "Guides"], Some("Cmd+Alt+;")), + ("view.editSelectedGuides", "Edit Selected Guides", &["View", "Guides"], None), + ("view.clearSelectedGuides", "Clear Selected Guides", &["View", "Guides"], None), ("view.extras", "Extras", &["View"], Some("Cmd+H")), ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), @@ -57,7 +59,7 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), ("view.rotateView", "Rotate View", &[], None), ("view.resetView", "Reset View", &["View"], None), - ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), + ("window.newWindowForDocument", "New Window", &["Window", "Arrange"], None), ("window.toggle.layers", "Layers", &["Window"], Some("F7")), ("window.toggle.history", "History", &["Window"], None), ("window.toggle.properties", "Properties", &["Window"], None), @@ -70,6 +72,7 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), ("window.toggle.options", "Options", &["Window"], None), + ("window.toggle.contextualTaskBar", "Contextual Task Bar", &["Window"], None), // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), @@ -122,8 +125,8 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ ("tools.select.count", "Count Tool", Some("I")), ("tools.select.brush", "Brush Tool", Some("B")), ("tools.select.pencil", "Pencil Tool", Some("B")), - ("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")), ("tools.select.colorReplacement", "Color Replacement Tool", Some("B")), + ("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")), ("tools.select.eraser", "Eraser Tool", Some("E")), ("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")), ("tools.select.magicEraser", "Magic Eraser Tool", Some("E")), @@ -136,8 +139,8 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ ("tools.select.hand", "Hand Tool", Some("H")), ("tools.select.rotateView", "Rotate View Tool", Some("R")), ("tools.select.zoom", "Zoom Tool", Some("Z")), - ("tools.select.remove", "Remove Tool", Some("J")), ("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")), + ("tools.select.remove", "Remove Tool", Some("J")), ("tools.select.healing", "Healing Brush Tool", Some("J")), ("tools.select.patch", "Patch Tool", Some("J")), ("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")), @@ -157,9 +160,9 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ ("tools.select.pen", "Pen Tool", Some("P")), ("tools.select.freeformPen", "Freeform Pen Tool", Some("P")), ("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")), - ("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")), - ("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")), - ("tools.select.convertPoint", "Convert Point Tool", Some("P")), + ("tools.select.addAnchorPoint", "Add Anchor Point Tool", None), + ("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", None), + ("tools.select.convertPoint", "Convert Point Tool", None), ("tools.select.pathSelection", "Path Selection Tool", Some("A")), ("tools.select.directSelection", "Direct Selection Tool", Some("A")), ("tools.select.rectangle", "Rectangle Tool", Some("U")), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 599d0ce2d6..8f82376a3a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1986,3 +1986,59 @@ fn the_document_activation_generation_bumps_on_switch_and_back() { assert_eq!(s.active_generation(), g3); assert_eq!(s.active().unwrap().doc.id, first); } + +#[test] +fn the_active_layer_change_count_moves_only_when_a_command_changes_the_active_layer() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let b = s.active().unwrap().active_layer.unwrap(); + let n = s.active_layer_changes(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + assert_eq!(s.active_layer_changes(), n, "selecting the active layer again changes nothing"); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + assert!(s.active_layer_changes() > n); + let m = s.active_layer_changes(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + assert!(s.active_layer_changes() >= m + 2, "a round trip back to the same layer still counts"); +} + +#[test] +fn direct_selection_calls_count_active_layer_changes_only_when_the_layer_changes() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let b = s.active().unwrap().active_layer.unwrap(); + let count = |s: &Session| s.active_layer_changes(); + let n = count(&s); + s.select_layer(b).unwrap(); + assert_eq!(count(&s), n, "select_layer of the active layer"); + s.select_layer(a).unwrap(); + assert_eq!(count(&s), n + 1, "select_layer of another layer"); + s.restore_layer_selection(Some(a), &[a], Some(a)).unwrap(); + assert_eq!(count(&s), n + 1, "restore_layer_selection of the same active layer"); + s.restore_layer_selection(Some(b), &[b], Some(b)).unwrap(); + assert_eq!(count(&s), n + 2, "restore_layer_selection of another layer"); + layer_multi_cmds::set_selection(&mut s, vec![b], Some(b), Some(b)).unwrap(); + assert_eq!(count(&s), n + 2, "set_selection of the same active layer"); + layer_multi_cmds::set_selection(&mut s, vec![a], Some(a), Some(a)).unwrap(); + assert_eq!(count(&s), n + 3, "set_selection of another layer"); + s.set_active_layer(Some(a)); + assert_eq!(count(&s), n + 3, "set_active_layer of the same layer"); + s.set_active_layer(Some(b)); + assert_eq!(count(&s), n + 4, "set_active_layer of another layer"); +} + +#[test] +fn the_active_layer_marker_advances_for_a_new_document_and_not_for_a_same_layer_select() { + let mut s = session_with_doc(); + let before = s.active_layer_changes(); + let id = s.active().unwrap().active_layer.unwrap(); + s.select_layer(id).unwrap(); + assert_eq!(s.active_layer_changes(), before, "re-selecting the active layer leaves the marker alone"); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(s.active_layer_changes() > before, "a new active document advances the marker"); +} diff --git a/crates/engine/tests/csh_import.rs b/crates/engine/tests/csh_import.rs new file mode 100644 index 0000000000..3c6e8779dd --- /dev/null +++ b/crates/engine/tests/csh_import.rs @@ -0,0 +1,133 @@ +//! Original geometric CSH fixture, assembled from the public container/path layout. +//! No Adobe or third-party shape assets are embedded. + +use photocraft_engine::Session; +use serde_json::json; + +#[path = "../../psd/tests/common/csh_fixture.rs"] +mod fixture; +use fixture::{polygon_csh, triangle_csh}; + +#[test] +fn csh_import_stores_the_display_form_of_a_localization_key() { + let bytes = fixture::named_polygon_csh("$$$/Presets/Shapes/Tiger=Tiger", &[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]); + let blank = fixture::named_polygon_csh("$$$/Presets/Shapes/Blank= ", &[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]); + let mut session = Session::new(); + let result = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Localized CSH"})).unwrap(); + assert_eq!(result["group"], "Localized CSH"); + assert_eq!(result["names"], json!(["Tiger"])); + assert_eq!(session.presets.shapes.iter().find(|g| g.name == "Localized CSH").unwrap().items[0].name, "Tiger"); + let again = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Localized CSH"})).unwrap(); + assert_eq!(again["names"], json!(["Tiger 2"])); + let blanked = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&blank)})).unwrap(); + assert_eq!(blanked["names"], json!(["Shape"])); +} + +#[test] +fn csh_import_makes_an_original_shape_available_without_a_document() { + let bytes = triangle_csh(); + if let Some(path) = std::env::var_os("PHOTOCRAFT_CSH_FIXTURE") { + std::fs::write(path, &bytes).unwrap(); + } + let mut session = Session::new(); + session + .execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Original CSH"})) + .expect("CSH import must be available without an open document"); + let groups = photocraft_engine::presets::shapes::all_groups(&session); + let imported = groups.iter().find(|g| g.name == "Original CSH").expect("imported group"); + assert_eq!(imported.items.len(), 1); + assert_eq!(imported.items[0].name, "Triangle"); + let path = &imported.items[0].path; + assert_eq!(path.subpaths.len(), 1); + assert!(path.subpaths[0].closed); + assert_eq!(path.subpaths[0].knots.len(), 3); + // The reference bounds are twice as wide as tall; normalization must preserve that. + assert_eq!(path.control_bounds().unwrap(), (0.0, 25.0, 100.0, 75.0)); +} + +#[test] +fn csh_failed_import_preserves_existing_presets_and_revision() { + let mut session = Session::new(); + let before = session.presets.to_json(&session); + let revision = session.presets.rev; + let bytes = triangle_csh(); + for end in [0, 11, bytes.len() - 1] { + assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes[..end])})).is_err()); + assert_eq!(session.presets.to_json(&session), before); + assert_eq!(session.presets.rev, revision); + } + assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": 12})).is_err()); + assert_eq!(session.presets.to_json(&session), before); +} + +#[test] +fn csh_invalid_second_shape_does_not_partially_import_a_set() { + let triangle = triangle_csh(); + let mut set = triangle.clone(); + set[8..12].copy_from_slice(&2u32.to_be_bytes()); + set.extend_from_slice(&triangle[12..]); + // The first shape is complete; truncate the second shape's path/padding. + set.pop(); + let mut session = Session::new(); + session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&triangle)})).unwrap(); + let before = session.presets.to_json(&session); + let revision = session.presets.rev; + assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&set)})).is_err()); + assert_eq!(session.presets.to_json(&session), before); + assert_eq!(session.presets.rev, revision); +} + +#[test] +fn csh_multiple_shapes_with_matching_names_remain_individually_available() { + let triangle = triangle_csh(); + let mut set = triangle.clone(); + set[8..12].copy_from_slice(&2u32.to_be_bytes()); + set.extend_from_slice(&triangle[12..]); + let mut s = Session::new(); + let result = s.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&set), "group": "Pair"})).unwrap(); + assert_eq!(result["count"], 2); + assert_eq!(result["names"], json!(["Triangle", "Triangle 2"])); + let group = s.presets.shapes.iter().find(|g| g.name == "Pair").unwrap(); + assert_eq!(group.items.len(), 2); + assert_eq!(group.items[0].path, group.items[1].path); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + for name in ["Triangle", "Triangle 2"] { + s.execute("shape.presets.place", json!({"preset": name, "group": "Pair"})).unwrap(); + } +} + +#[test] +fn csh_repeat_import_names_are_unique_and_survive_preferences_reload() { + let mut session = Session::new(); + let params = json!({"data": photocraft_paint::tile::b64_encode(&triangle_csh()), "group": "Original CSH"}); + let first = session.execute("shape.presets.importCsh", params.clone()).unwrap(); + let second = session.execute("shape.presets.importCsh", params).unwrap(); + assert_eq!(first["count"], 1); + assert_eq!(second["count"], 1); + assert_ne!(first["group"], second["group"]); + assert_ne!(first["names"], second["names"]); + let persisted = session.presets.to_json(&session); + let mut restored = Session::new(); + restored.load_presets_json(persisted); + assert_eq!(restored.presets.shapes, session.presets.shapes); +} + +#[test] +fn csh_same_winding_inner_subpath_stays_a_hole_after_place_and_reload() { + // Same winding intentionally: a nonzero union would incorrectly fill the hole. + let bytes = + polygon_csh(&[&[(0.125, 0.125), (0.875, 0.125), (0.875, 0.875), (0.125, 0.875)], &[(0.375, 0.375), (0.625, 0.375), (0.625, 0.625), (0.375, 0.625)]]); + let mut imported = Session::new(); + let result = imported.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + let mut s = Session::new(); + s.load_presets_json(imported.presets.to_json(&imported)); + s.execute("file.new", json!({"width": 96, "height": 96, "background": "transparent"})).unwrap(); + s.execute("shape.presets.place", json!({"preset": result["names"][0], "rect": [8, 8, 80, 80], "keepAspect": false, "fill": "#ff0000"})).unwrap(); + let image = photocraft_compose::flatten(&s.active().unwrap().doc); + assert!(image.px[48 * 96 + 16][0] > 0.99, "ring must remain red"); + assert!(image.px[48 * 96 + 16][3] > 0.99, "ring must remain opaque"); + assert!(image.px[48 * 96 + 48][3] < 0.01, "inner subpath must remain transparent"); + assert!(image.px[4 * 96 + 4][3] < 0.01, "outside shape must remain transparent"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|pixel| pixel[3] < 0.01)); +} diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index 4f65bcc34e..72b91241a4 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -125,7 +125,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() { } } -/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes. +/// Only an absent descriptor is menu intent. Empty or invalid descriptors remain kept steps. #[test] fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { for event in ["ImgS", "imageSize"] { @@ -143,12 +143,20 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { let original = atn::parse(&source).unwrap(); let mut s = Session::new(); let loaded = load(&mut s, &source); - assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (step, params) = &s.actions.list[0].steps[0]; + if original.actions[0].items[0].descriptor.is_none() { + assert_eq!(loaded["unsupported"], json!([])); + assert_eq!(step, atn_cmds::MENU_ITEM); + assert_eq!(params["id"], "image.imageSize"); + assert_eq!(params["eventKind"], if event == "ImgS" { "code" } else { "name" }); + } else { + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(step, atn_cmds::PHOTOSHOP_STEP); + } let saved = atn::parse(&save(&mut s)).unwrap(); assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + assert_eq!(saved.actions[0].items[0].dictionary, original.actions[0].items[0].dictionary); } } } diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs new file mode 100644 index 0000000000..e5c97d294d --- /dev/null +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -0,0 +1,122 @@ +//! Duplicate keeps action identities, complete settings and per-step flags stable. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::{Value, json}; + +/// A tiny document to distinguish definition edits from document mutations and playback. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s +} + +#[test] +fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { + let mut s = session(); + let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("layer.select".into(), json!({"layer": "Photo"}))]); + source.disabled.insert(1); + source.dialogs.insert(0); + source.set = Some("Retouch".into()); + source.color = "red".into(); + let foreign = json!({"actions": [{"name": "Foreign", "steps": [["actions.play", {"action": 1}]]}]}); + s.actions.list = vec![ + source.clone(), + Action::new("Target", vec![("select.all".into(), json!({}))]), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": 3})), // Unresolved before and after insertion. + ("actions.play".into(), json!({"action": 1.0})), + ("file.automate.batch".into(), foreign.clone()), + ("actions.conditional".into(), json!({"if": "always", "then": "Target", "else": "Source"})), + ], + ), + ]; + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:Source".into(), "F6".into()); + }); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": "Source"})).unwrap(); + assert_eq!(result["index"], 1); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Source", "Source copy", "Target", "Caller"]); + let mut expected = source; + expected.name = "Source copy".into(); + expected.steps[0].1["action"] = json!(2); + assert_eq!(s.actions.list[1], expected); + assert_eq!(s.actions.list[0].steps[0].1["action"], 2); + let caller = &s.actions.list[3]; + assert_eq!(caller.steps[0].1["action"], 2); + assert_eq!(caller.steps[1].1["action"], 4); + assert_eq!(caller.steps[2].1["action"], 2.0); + assert_eq!(caller.steps[3].1, foreign, "embedded libraries keep their own indices"); + assert_eq!(caller.steps[4].1, json!({"if": "always", "then": "Target", "else": "Source"})); + assert_eq!(s.prefs().shortcuts.get("actions.play:Source").map(String::as_str), Some("F6")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Source copy")); + assert_eq!(s.active().unwrap().revision, revision); + let played = s.execute("actions.play", json!({"action": "Source copy"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.is_some(), "the copied call still plays Target rather than itself"); + s.execute("actions.duplicate", json!({"action": 0})).unwrap(); + assert_eq!(s.actions.list[1].name, "Source copy 2"); + assert_eq!(s.actions.list[2].name, "Source copy"); + assert_eq!(s.actions.list[1].steps[0].1["action"], 3); +} + +#[test] +fn step_copies_preserve_payloads_and_shift_both_flag_columns() { + let mut s = session(); + let payload = json!({"layer": "Photo", "_actionLayer": {"name": "Photo", "ordinal": 2}}); + let mut a = Action::new("Steps", vec![("layer.select".into(), payload.clone()), ("select.all".into(), Value::Null), ("select.deselect".into(), json!({}))]); + a.disabled.extend([0, 2]); + a.dialogs.extend([0, 1]); + s.actions.list.push(a); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(result["step"], 1); + assert_eq!(result["index"], 0); + let a = &s.actions.list[0]; + assert_eq!(a.name, "Steps"); + assert_eq!(a.steps[0], a.steps[1]); + assert_eq!(a.disabled.iter().copied().collect::>(), [0, 1, 3]); + assert_eq!(a.dialogs.iter().copied().collect::>(), [0, 1, 2]); + s.execute("actions.duplicate", json!({"action": 0, "step": 2})).unwrap(); + assert!(s.actions.list[0].steps[2].1.is_null()); + assert!(s.actions.list[0].steps[3].1.is_null(), "copying does not normalize null settings to an empty object"); + s.actions.list[0].steps[1].1["layer"] = json!(99); + assert_eq!(s.actions.list[0].steps[0].1, payload, "the copy owns its settings"); + assert_eq!(s.active().unwrap().revision, revision); +} + +#[test] +fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() { + let mut s = session(); + s.execute("actions.record", json!({"name": "Recording"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let before = s.actions.list.clone(); + let recording = s.actions.recording; + let revision = s.active().unwrap().revision; + for params in [ + json!({}), + json!({"action": 9}), + json!({"action": 0}), + json!({"action": 0, "step": -1}), + json!({"action": 0, "step": 5}), + json!({"action": 0, "step": "x"}), + ] { + assert!(s.execute("actions.duplicate", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, before); + assert_eq!(s.actions.recording, recording); + assert_eq!(s.active().unwrap().revision, revision); + } + s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("select.all".into(), json!({})); 2]); + assert!(s.actions.recording.is_some()); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.len(), 3); + s.actions.list.push(Action::new("Edit during playback", vec![("actions.duplicate".into(), json!({"action": 0}))])); + let before = s.actions.list.clone(); + let played = s.execute("actions.play", json!({"action": "Edit during playback"})).unwrap(); + assert!(played["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{played}"); + assert_eq!(s.actions.list, before); +} diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs new file mode 100644 index 0000000000..6b65f2f08d --- /dev/null +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -0,0 +1,200 @@ +//! The remaining panel commands compose with the same definitions, journal and ATN boundary. +use photocraft_engine::{Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, jobs::Started}; +use serde_json::json; + +/// Tiny real document with one empty action keeps recording and background jobs observable. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":40,"height":30})).unwrap(); + s.actions.list.push(Action::new("A", vec![])); + s +} + +/// A painted stripe gives both the remove tool and blur a real input before recording starts. +fn stroke(s: &mut Session, color: &str) { + s.execute("paint.stroke", json!({"points":[[8,15],[32,15]],"size":3,"color":color})).unwrap(); +} + +/// Mode changes flush under the previous mode without altering painting or ordinary recording. +#[test] +fn tool_recording_is_opt_in_and_toggles_do_not_reclassify_pending_steps() { + let mut s = session(); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + stroke(&mut s, "#ff0000"); + s.execute("select.all", json!({})).unwrap(); + let original = s.actions.clone(); + assert!(s.execute("actions.allowToolRecording", json!({"enabled":"yes"})).is_err()); + assert_eq!(s.actions, original, "invalid mode must not flush pending work"); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + stroke(&mut s, "#00ff00"); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + stroke(&mut s, "#0000ff"); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(), ["select.all", "paint.stroke", "select.deselect"]); + assert_eq!(s.actions.list[0].steps[1].1["color"], "#00ff00"); + assert_eq!(s.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 3, "all actual painting remains journaled"); + assert_eq!(s.execute("actions.list", json!({})).unwrap()["toolRecording"], false); +} + +/// A stroke started while off never joins a later enabled recording; ordinary jobs keep their owner. +#[test] +fn background_stroke_ownership_does_not_suppress_ordinary_filter_jobs() { + for command in ["paint.remove", "filter.blur.gaussianBlur"] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + let params = if command == "paint.remove" { json!({"points":[[20,15],[21,15]],"size":3}) } else { json!({"radius":1}) }; + let Started::Job(job) = s.start(command, params).unwrap() else { panic!("expected the real background job: {command}") }; + let changed = s.execute("actions.allowToolRecording", json!({"enabled":true})); + if command == "paint.remove" { + assert!(changed.is_err(), "the in-flight stroke retains the mode it started under"); + assert!(!s.actions.record_tools); + } else { + changed.unwrap(); + } + s.wait_job(job).unwrap(); + if command == "paint.remove" { + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let ids = s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(); + if command == "paint.remove" { + assert!(ids.is_empty()); + } else { + assert_eq!(ids, [command]); + } + } +} + +/// A Remove job started outside the current take neither blocks its toggle nor joins its steps. +#[test] +fn background_strokes_from_outside_the_current_take_do_not_block_the_toggle() { + for previous_take in [false, true] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + if previous_take { + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + } + let Started::Job(job) = s.start("paint.remove", json!({"points":[[20,15],[21,15]],"size":3})).unwrap() else { + panic!("expected the real Remove job"); + }; + if previous_take { + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + } + s.execute("actions.record", json!({"action":"A"})).unwrap(); + assert_eq!(s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap()["enabled"], true); + s.wait_job(job).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert!(s.actions.list[0].steps.is_empty()); + assert_eq!(s.journal.iter().filter(|step| step.0 == "paint.remove").count(), 1); + } +} + +/// Interleaved membership and numeric calls expose incorrect folder placement or target rewriting. +#[test] +fn set_duplication_preserves_metadata_call_targets_and_empty_folder_order() { + let mut s = session(); + s.actions.list.clear(); + for (name, set) in [("A", "S"), ("Other", "T"), ("B", "S")] { + let mut a = Action::new(name, vec![("actions.play".into(), json!({"action":2}))]); + a.set = Some(set.into()); + a.color = "red".into(); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + } + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + let result = s.execute("actions.duplicate", json!({"set":"S"})).unwrap(); + assert_eq!(result["set"], "S copy"); + assert_eq!(actions_edit_cmds::set_names(&s), ["S", "S copy", "T"]); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["A", "A copy", "B copy", "Other", "B"]); + for a in &s.actions.list { + assert_eq!(a.steps[0].1["action"], 4); + assert_eq!(a.color, "red"); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + } + assert!(!s.prefs().shortcuts.contains_key("actions.play:A copy")); + let mut empty = Session::new(); + for name in ["E", "F"] { + empty.execute("actions.newSet", json!({"name":name})).unwrap(); + } + empty.execute("actions.duplicate", json!({"set":"E"})).unwrap(); + assert_eq!(actions_edit_cmds::set_names(&empty), ["E", "E copy", "F"]); + let original = empty.actions.clone(); + for params in [json!({"set":"Missing"}), json!({"set":"E","action":0}), json!({"set":false})] { + assert!(empty.execute("actions.duplicate", params).is_err()); + assert_eq!(empty.actions, original); + } +} + +/// Menu insertion keeps descriptor absence and kind; empty descriptors and ambiguous commands differ. +#[test] +fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() { + assert!(!atn_cmds::menu_item_needs_settings("filter.noise.despeckle")); + assert!(atn_cmds::menu_item_needs_settings("filter.blur.gaussianBlur")); + for id in ["image.imageSize", "filter.blur.gaussianBlur", "filter.noise.despeckle"] { + for kind in ["name", "code"] { + let mut s = session(); + s.execute("actions.insertMenuItem", json!({"action":"A","id":id,"eventKind":kind})).unwrap(); + let bytes = atn_cmds::export("S", &[&s.actions.list[0]], &[], false).unwrap().0; + let raw = photocraft_psd::atn::parse(&bytes).unwrap(); + assert!(raw.actions[0].items[0].descriptor.is_none()); + let imported = atn_cmds::import(&bytes).unwrap(); + let (step, params) = &imported.actions[0].steps[0]; + assert_eq!(step, atn_cmds::MENU_ITEM); + assert_eq!(params["id"], id); + assert_eq!(params["eventKind"], kind); + let (saved, skipped) = atn_cmds::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = photocraft_psd::atn::parse(&saved).unwrap(); + assert_eq!(saved.actions[0].items[0], raw.actions[0].items[0]); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}"); + } + } + let mut s = session(); + let original = s.actions.clone(); + for params in + [json!({"action":"A","id":"layer.setProps"}), json!({"action":"A","id":"file.open"}), json!({"action":"A","id":"image.imageSize","params":{"width":4}})] + { + assert!(s.execute("actions.insertMenuItem", params).is_err()); + assert_eq!(s.actions, original); + } + let mut raw = photocraft_psd::atn::AtnFile::default(); + raw.actions.push(photocraft_psd::atn::AtnAction { + items: vec![photocraft_psd::atn::AtnItem { + event: photocraft_psd::atn::AtnEvent::name("gaussianBlur"), + descriptor: Some(photocraft_psd::descriptor::Descriptor::new("null")), + ..Default::default() + }], + ..Default::default() + }); + let loaded = atn_cmds::import(&photocraft_psd::atn::write(&raw).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, "filter.blur.gaussianBlur"); +} + +/// A full curve is snapshotted independently of later source edits and replays as the work path. +#[test] +fn insert_path_captures_cubic_geometry_without_editing_the_document() { + let mut s = session(); + let path = json!({"fillRule":"evenodd","inverted":true,"subpaths":[{"closed":false,"op":"combine","knots":[{"anchor":[1.5,2.25],"in":[-1.0,3.0],"out":[5.0,8.0],"smooth":true},{"anchor":[20.0,18.0],"in":[12.0,5.0],"out":[21.0,19.0]}]}]}); + s.execute("path.set", json!({"name":"Curve","path":path})).unwrap(); + let expected = s.execute("path.info", json!({"name":"Curve"})).unwrap()["path"].clone(); + let revision = s.active().unwrap().revision; + assert!(s.execute("actions.insertPath", json!({"action":"A","name":"work"})).is_err()); + assert!(s.actions.list[0].steps.is_empty()); + s.execute("actions.insertPath", json!({"action":"A","name":"Curve"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.actions.list[0].steps[0].1["path"], expected); + s.execute("path.set", json!({"name":"Curve","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.execute("path.info", json!({"name":"work"})).unwrap()["path"], expected); + assert!(atn_cmds::export("S", &[&s.actions.list[0]], &[], false).is_err(), "the measured ATN descriptor cannot represent inversion"); +} diff --git a/crates/engine/tests/it/actions_record_again.rs b/crates/engine/tests/it/actions_record_again.rs new file mode 100644 index 0000000000..9e3b02367b --- /dev/null +++ b/crates/engine/tests/it/actions_record_again.rs @@ -0,0 +1,77 @@ +//! Record Again edits one definition only after its concrete command succeeds. +use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; +use serde_json::{Value, json}; + +/// A disabled, dialog-enabled resize followed by another step exposes flag and ordering loss. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let mut a = Action::new("A", vec![("image.imageSize".into(), json!({"width": 4, "height": 3, "resample": "nearest"})), ("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + s +} + +/// Only the selected step runs, retains its flags, and exports its new settings to ATN. +#[test] +fn successful_rerecording_updates_one_step_and_roundtrips_new_settings() { + for menu in [false, true] { + let mut s = session(); + if menu { + s.actions.list[0].steps[0] = (atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"})); + } + let revision = s.actions.rev; + s.execute("actions.recordAgain", json!({"action": "A", "step": 0, "revision": revision, "params": {"width": 6, "height": 5, "resample": "nearest"}})) + .unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(s.active().unwrap().doc.selection.is_none(), "the following step did not play"); + let a = &s.actions.list[0]; + assert_eq!(a.steps.len(), 2); + assert_eq!(a.steps[0].0, "image.imageSize"); + assert_eq!(a.steps[0].1["width"], 6); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + assert_eq!(s.actions.rev, revision + 1); + assert_eq!(s.journal.last().unwrap().0, "image.imageSize"); + let bytes = atn_cmds::export("Set", &[a], &[], false).unwrap().0; + let loaded = atn_cmds::import(&bytes).unwrap(); + assert_eq!(loaded.actions[0].steps[0].1["width"].as_f64(), Some(6.0)); + assert!(loaded.actions[0].disabled.contains(&0)); + assert!(loaded.actions[0].dialogs.contains(&0)); + } +} + +/// Permit the wrapper while denying its concrete edit, proving nested authorization remains active. +fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) } +} + +/// Invalid settings, stale selections, denied execution and recording preserve the original step. +#[test] +fn failed_or_stale_rerecording_never_replaces_the_original_step() { + let mut s = session(); + let original = s.actions.list.clone(); + let revision = s.active().unwrap().revision; + for request in [ + json!({"action":"A","step":0}), + json!({"action":"A","step":0,"params":[]}), + json!({"action":"A","step":0,"params":{"percent":-1}}), + json!({"action":"A","step":99,"params":{}}), + json!({"action":"Missing","step":0,"params":{}}), + json!({"action":"A","step":0,"revision":1,"params":{}}), + ] { + assert!(s.execute("actions.recordAgain", request.clone()).is_err(), "{request}"); + assert_eq!(s.actions.list, original); + assert_eq!(s.actions.rev, 0); + assert_eq!(s.active().unwrap().revision, revision); + } + s.authorize = Some(deny_resize); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{"width":6,"height":5}})).is_err()); + assert_eq!(s.actions.list, original); + s.authorize = None; + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{}})).is_err()); + assert_eq!(s.actions.list, original, "recording was not flushed by a refused edit"); +} diff --git a/crates/engine/tests/it/actions_single_history.rs b/crates/engine/tests/it/actions_single_history.rs new file mode 100644 index 0000000000..95c19b016a --- /dev/null +++ b/crates/engine/tests/it/actions_single_history.rs @@ -0,0 +1,904 @@ +//! Public playback history semantics, independent of the Actions panel implementation. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap(); + s.actions.list.push(Action::new("Two layers", ["First", "Second"].into_iter().map(|name| ("layer.new.layer".into(), json!({"name":name}))).collect())); + s +} + +/// Start with distinct older source pixels and a nondefault History Brush source. +fn history_control_session(single_state: bool) -> Session { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color":"#00ff00"})).unwrap(); + let current = s.active().unwrap().history.past_len(); + s.execute("history.setSource", json!({"state":current})).unwrap(); + s +} + +/// An explicit clear stays undoable; later edits forget it and group from its boundary. +#[test] +fn recorded_clear_preserves_its_boundary_and_grouped_suffix() { + for single_state in [false, true] { + for prefix in [false, true] { + for suffix in [false, true] { + let mut s = history_control_session(single_state); + let past = s.active().unwrap().history.past_len(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + steps.push(("history.clear".into(), json!({}))); + if suffix { + steps.extend([("layer.renameLayer".into(), json!({"name":"Suffix one"})), ("layer.renameLayer".into(), json!({"name":"Suffix two"}))]); + } + s.actions.list = vec![Action::new("Clear", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + if suffix { + assert!(!s.active().unwrap().history.is_cleared()); + assert_eq!(s.active().unwrap().history.past_len(), if single_state { 1 } else { 2 }); + assert!(s.undo()); + if !single_state { + assert!(s.undo()); + } + assert!(!s.undo(), "the next edit really forgot the cleared older states"); + } else { + assert!(s.active().unwrap().history.is_cleared()); + assert_eq!(s.active().unwrap().history.past_len(), 0); + assert!(s.undo(), "Undo Clear History restores rows, not older pixels"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after)); + assert_eq!(s.active().unwrap().history.past_len(), past + usize::from(prefix)); + } + } + } + } +} + +/// Both a surviving old row and an intermediate action row remain usable brush sources. +#[test] +fn recorded_source_change_preserves_selected_pixels_and_grouping() { + for single_state in [false, true] { + for prefix in [false, true] { + for current in [false, true] { + let mut s = history_control_session(single_state); + let past = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.clone(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + let selected = if current { past + usize::from(prefix) } else { 1 }; + steps.extend([ + ("history.setSource".into(), json!({"state":selected})), + ("edit.fill".into(), json!({"color":"#00ffff"})), + ("layer.renameLayer".into(), json!({"name":"After source"})), + ]); + s.actions.list = vec![Action::new("Source", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let added = if single_state { 1 } else { 2 + usize::from(prefix) }; + assert_eq!(s.active().unwrap().history.past_len(), past + added); + let after = s.active().unwrap().doc.clone(); + for _ in 0..added { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + for _ in 0..added { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + s.execute("edit.fill", json!({"contents":"history"})).unwrap(); + let pixel = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + let expected = if !current { + [1.0, 0.0, 0.0, 1.0] + } else if prefix { + [0.0, 0.0, 1.0, 1.0] + } else { + [0.0, 1.0, 0.0, 1.0] + }; + assert_eq!(pixel, expected, "singleState={single_state}, prefix={prefix}, current={current}"); + } + } + } +} + +/// Purge Histories is irreversible; suffix edits keep their grouped Undo/Redo boundary. +#[test] +fn recorded_purge_histories_does_not_restore_discarded_states() { + for single_state in [false, true] { + for prefix in [false, true] { + let mut s = history_control_session(single_state); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + steps.extend([ + ("edit.purge.histories".into(), json!({})), + ("layer.renameLayer".into(), json!({"name":"Suffix one"})), + ("layer.renameLayer".into(), json!({"name":"Suffix two"})), + ]); + s.actions.list = vec![Action::new("Purge", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), if single_state { 1 } else { 2 }); + assert!(s.undo()); + if !single_state { + assert!(s.undo()); + } + assert!(!s.undo(), "Purge Histories cannot be undone"); + assert!(s.redo()); + if !single_state { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +/// Dialog identity protection is independent of the optional history grouping. +#[test] +fn per_step_playback_also_binds_its_dialog_continuation() { + use photocraft_engine::actions_cmds; + let mut s = history_control_session(false); + let action = s.actions.list[0].clone(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).expect("per-step playback still needs a document/history binding"); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + s.execute("layer.renameLayer", json!({"name":"Unrelated"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + assert!(continuation.resume(&mut s).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); +} + +/// Selecting another existing source snapshot invalidates a pause even when its pixels match. +#[test] +fn snapshot_source_identity_is_bound_to_the_dialog_continuation() { + use photocraft_engine::actions_cmds; + for single_state in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let a = s.execute("history.newSnapshot", json!({"name":"Source A"})).unwrap()["snapshot"].clone(); + let b = s.execute("history.newSnapshot", json!({"name":"Source B"})).unwrap()["snapshot"].clone(); + s.execute("history.setSource", json!({"snapshot":a})).unwrap(); + let action = s.actions.list[0].clone(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + s.execute("history.setSource", json!({"snapshot":b})).unwrap(); + assert!(continuation.resume(&mut s).is_err(), "singleState={single_state}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), history); + } +} + +/// Undo Clear History inside playback restores rows, rather than recording another clear baseline. +#[test] +fn in_action_undo_clear_restores_history_and_keeps_grouped_image_undo() { + for single_state in [false, true] { + for prefix in [false, true] { + let mut s = history_control_session(single_state); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let rows = s.active().unwrap().history.entries(); + let mut steps = Vec::new(); + if prefix { + steps.push(("layer.renameLayer".into(), json!({"name":"Prefix"}))); + } + steps.extend([ + ("history.clear".into(), json!({})), + ("edit.undo".into(), json!({})), + ("edit.undo".into(), json!({})), + ("edit.redo".into(), json!({})), + ]); + s.actions.list = vec![Action::new("Undo Clear", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(!s.active().unwrap().history.is_cleared(), "singleState={single_state}, prefix={prefix}"); + assert_eq!(s.active().unwrap().history.past_len(), past + usize::from(prefix)); + if prefix { + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } else { + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), rows); + } + } + } +} + +/// A source held by Clear History becomes active on Undo Clear, with no additional image Undo row. +#[test] +fn cleared_intermediate_source_restores_exact_pixels_after_undo_redo() { + for single_state in [false, true] { + for undo_clear in [false, true] { + let mut s = history_control_session(single_state); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new( + "Cleared Source", + vec![ + ("edit.fill".into(), json!({"color":"#0000ff"})), + ("history.setSource".into(), json!({"state":past+1})), + ("edit.fill".into(), json!({"color":"#00ffff"})), + ("history.clear".into(), json!({})), + ], + )]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().history.is_cleared()); + assert_eq!(s.active().unwrap().history.past_len(), 0); + if undo_clear { + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert!(!s.active().unwrap().history.is_cleared()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after)); + let added = if single_state { 1 } else { 2 }; + assert_eq!(s.active().unwrap().history.past_len(), past + added); + for _ in 0..added { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + for _ in 0..added { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + // Before Undo Clear the source is the current cyan document. Afterwards it is the + // explicitly selected intermediate blue document, including after image Undo/Redo. + s.execute("edit.fill", json!({"contents":"history"})).unwrap(); + let pixel = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + let expected = if undo_clear { [0.0, 0.0, 1.0, 1.0] } else { [0.0, 1.0, 1.0, 1.0] }; + assert_eq!(pixel, expected, "singleState={single_state}, undoClear={undo_clear}"); + } + } +} + +/// Default playback is one named edit, including the complete Undo and Redo states. +#[test] +fn default_playback_is_one_named_history_state() { + let mut s = session(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + let result = s.execute("actions.play", json!({"action":"Two layers"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Two layers")); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 2); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().history.redo_label(), Some("Action: Two layers")); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + assert_eq!(s.active().unwrap().doc.layers.iter().map(|l| &l.name).collect::>(), after.layers.iter().map(|l| &l.name).collect::>()); +} + +/// Disabling the persisted option retains the existing one-entry-per-step behavior. +#[test] +fn disabled_single_history_keeps_each_step() { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState":false})).unwrap(); + let count = s.active().unwrap().history.past_len(); + let layers = s.active().unwrap().doc.layers.len(); + s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), count + 2); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); +} + +/// Failure commits the completed prefix; an inner play uses its outer action's name and key. +#[test] +fn failures_and_nested_calls_keep_the_outer_history_entry() { + let mut s = session(); + let layers = s.active().unwrap().doc.layers.len(); + s.actions.list.push(Action::new( + "Outer", + vec![ + ("layer.new.layer".into(), json!({"name":"Outer layer"})), + ("actions.play".into(), json!({"action":0})), + ("not.a.command".into(), json!({})), + ("layer.new.layer".into(), json!({"name":"Must not run"})), + ], + )); + let before = s.active().unwrap().history.past_len(); + let result = s.execute("actions.play", json!({"action":"Outer"})).unwrap(); + assert_eq!(result["failed"]["step"], 2); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3); + assert_eq!(s.active().unwrap().history.past_len(), before + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Outer")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3); +} + +/// No-op playback preserves redo, and separate plays never coalesce into one another. +#[test] +fn no_op_and_repeated_plays_preserve_history_boundaries() { + let mut s = session(); + s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(s.undo()); + let entries = s.active().unwrap().history.entries(); + let redo = s.active().unwrap().history.redo_label().map(str::to_owned); + s.actions.list.push(Action::new("Empty", vec![])); + s.execute("actions.play", json!({"action":"Empty"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries(), entries); + assert_eq!(s.active().unwrap().history.redo_label(), redo.as_deref()); + assert!(s.redo()); + let count = s.active().unwrap().history.past_len(); + let layers = s.active().unwrap().doc.layers.len(); + s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); +} + +/// Older preference files default on; an explicit off survives the public save/load path. +#[test] +fn action_history_setting_is_persisted_and_rejects_wrong_types() { + let mut s = Session::new(); + assert!(s.prefs().actions.create_single_history_state); + assert!(s.execute("actions.setHistory", json!({"singleState":"false"})).is_err()); + assert!(s.prefs().actions.create_single_history_state); + s.execute("actions.setHistory", json!({"singleState":false})).unwrap(); + let mut restored = Session::new(); + assert!(restored.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty()); + assert!(!restored.prefs().actions.create_single_history_state); + restored.load_prefs_json("{}").unwrap(); + assert!(restored.prefs().actions.create_single_history_state); +} + +#[test] +fn an_open_document_batch_cannot_change_an_active_history_scope_setting() { + for single in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState": single})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let result = s + .execute( + "file.automate.batch", + json!({"source": "open", "action": "Setting boundary", "steps": [ + ["layer.renameLayer", {"name": "Prefix"}], ["actions.setHistory", {"singleState": false}], ["layer.renameLayer", {"name": "Suffix"}] + ]}), + ) + .unwrap(); + if single { + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result.to_string().contains("cannot change action history settings"), "{result}"); + assert!(s.prefs().actions.create_single_history_state); + assert_eq!(s.active().unwrap().doc.layers[0].name, "Prefix"); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Setting boundary")); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + } else { + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert!(!s.prefs().actions.create_single_history_state); + assert_eq!(s.active().unwrap().doc.layers[0].name, "Suffix"); + assert_eq!(s.active().unwrap().history.past_len(), past + 2); + } + s.execute("actions.setHistory", json!({"singleState": !single})).unwrap(); + assert_eq!(s.prefs().actions.create_single_history_state, !single, "the batch scope ended"); + } +} + +#[test] +fn a_real_action_still_refuses_history_setting_changes_with_grouping_on_or_off() { + for single in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState": single})).unwrap(); + s.actions.list = vec![Action::new("Setting", vec![("actions.setHistory".into(), json!({"singleState": !single}))])]; + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(result["failed"]["id"], "actions.setHistory"); + assert_eq!(s.prefs().actions.create_single_history_state, single); + } +} + +#[test] +fn an_accepted_clear_survives_an_action_that_returns_to_empty_captured_history() { + for single in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState": single})).unwrap(); + let original = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), 0); + assert!(s.execute("history.clear", json!({})).is_err(), "ordinary empty Clear still refuses"); + s.actions.list = vec![Action::new( + "Empty capture Clear", + vec![ + ("edit.fill".into(), json!({"color": "#000000"})), + ("history.setSource".into(), json!({"state": 1})), + ("edit.undo".into(), json!({})), + ("history.clear".into(), json!({})), + ], + )]; + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().history.is_cleared()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &original)); + assert!(s.undo(), "Undo Clear remains available with an empty captured baseline"); + assert!(!s.active().unwrap().history.is_cleared()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &original)); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 0.0, 1.0], "the exact black source is restored"); + } +} + +/// A command's internal purge must not remove the outer action's only Undo snapshot. +#[test] +fn frame_compound_retains_undo_redo_content_and_layer_targets() { + for single in [true, false] { + let mut s = session(); + s.execute("layer.new.layer", json!({"name":"Subject"})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let target = s.active().unwrap().active_layer; + let selected = s.active().unwrap().selected_layers.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new("Frame", vec![("layer.new.frameFromLayers".into(), json!({}))])]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_ne!(after.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + if single { + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Frame")); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().active_layer, target); + assert_eq!(s.active().unwrap().selected_layers, selected); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// A real tilted crop's compound transaction remains inside the outer prefix and suffix. +#[test] +fn tilted_crop_between_edits_is_one_action() { + let mut s = session(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new( + "Crop", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("image.crop".into(), json!({"x":0,"y":0,"width":3,"height":3,"angle":10})), + ("layer.new.layer".into(), json!({"name":"Suffix"})), + ], + )]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Crop")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); +} + +/// Command-local rollback removes a refused copy but keeps the action's completed rename. +#[test] +fn again_copy_failure_preserves_the_completed_prefix() { + let mut s = session(); + s.execute("layer.new.layerFromBackground", json!({"ifPresent":true})).unwrap(); + s.execute("edit.transform", json!({"matrix":[1,0,0,1,1,0]})).unwrap(); + s.execute("layer.setProps", json!({"locks":{"position":true}})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = + vec![Action::new("Prefix", vec![("layer.renameLayer".into(), json!({"name":"Completed prefix"})), ("edit.transform.againCopy".into(), json!({}))])]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["failed"]["step"], 1, "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len()); + assert_ne!(after.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Prefix")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); +} + +/// A nonempty semantic no-op preserves the original document identity, rows, redo and clean state. +#[test] +fn same_name_rename_does_not_record_or_clear_redo() { + let mut s = session(); + s.execute("layer.new.layer", json!({"name":"Later"})).unwrap(); + assert!(s.undo()); + let st = s.active().unwrap(); + let before = st.doc.clone(); + let entries = st.history.entries(); + let redo: Vec<_> = st.history.redo_labels().map(str::to_string).collect(); + let ids: Vec<_> = (0..entries.len() + redo.len()).map(|i| st.history.row_id(i)).collect(); + let dirty = st.is_dirty(); + let name = st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone(); + s.actions.list = vec![Action::new("No change", vec![("layer.renameLayer".into(), json!({"name":name}))])]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &before)); + assert_eq!(st.history.entries(), entries); + assert_eq!(st.history.redo_labels().collect::>(), redo.iter().map(String::as_str).collect::>()); + assert_eq!((0..ids.len()).map(|i| st.history.row_id(i)).collect::>(), ids); + assert_eq!(st.is_dirty(), dirty); + assert!(s.redo()); +} + +/// Stop commits its prefix, while Allow Continue includes the following step in that same entry. +#[test] +fn stop_message_retains_the_completed_prefix() { + for allow in [false, true] { + let mut s = session(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new( + "Stop", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("actions.stopMessage".into(), json!({"message":"Check","allowContinue":allow})), + ("layer.renameLayer".into(), json!({"name":"Suffix"})), + ], + )]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result.get("failed").is_none(), allow, "{result}"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if allow { "Suffix" } else { "Prefix" }); + assert_eq!(st.history.past_len(), count + 1); + assert_eq!(st.history.undo_label(), Some("Action: Stop")); + let after = st.doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// Resumed dialog edits and subsequent steps join the published prefix, without an active scope while paused. +#[test] +fn suspended_history_resumes_the_same_action() { + use photocraft_engine::actions_cmds; + let mut s = session(); + let action = s.actions.list[0].clone(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + continuation.resume(&mut s).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Confirmed dialog"})).unwrap(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.new.layer", json!({"name":"Suffix"})).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 1); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Two layers")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + assert!(continuation.resume(&mut s).is_err(), "the old continuation cannot join the finalized history"); +} + +/// An unrelated edit, history purge or document switch during a pause cannot be merged into the action. +#[test] +fn suspended_history_refuses_document_or_history_drift() { + use photocraft_engine::actions_cmds; + for drift in 0..3 { + let mut s = session(); + let action = s.actions.list[0].clone(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + match drift { + 0 => { + s.execute("layer.new.layer", json!({"name":"Unrelated"})).unwrap(); + } + 1 => { + s.active_mut().unwrap().history.purge_last(); + } + _ => { + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + } + } + let before = s.active().unwrap().doc.clone(); + let entries = s.active().unwrap().history.entries(); + assert!(continuation.resume(&mut s).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), entries); + } +} + +/// Visibility excluded from history remains excluded when it occurs between grouped edits. +#[test] +fn grouping_preserves_non_undoable_visibility() { + let mut s = session(); + s.execute("history.options", json!({"makeLayerVisibilityChangesUndoable":false})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.actions.list = vec![Action::new( + "Mixed", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("layer.setProps".into(), json!({"visible":false})), + ("layer.renameLayer".into(), json!({"name":"Final"})), + ], + )]; + let count = s.active().unwrap().history.past_len(); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert!(s.undo()); + let actual = s.active().unwrap().doc.layer(layer).unwrap(); + assert!(!actual.visible); + assert_eq!(actual.name, before.layer(layer).unwrap().name); + assert!(s.redo()); + assert!(!s.active().unwrap().doc.layer(layer).unwrap().visible); + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().name, "Final"); +} + +/// Nonlinear redo states remain ordered, and trimming cannot evict the before-action boundary. +#[test] +fn grouping_preserves_nonlinear_history_and_a_one_state_limit() { + for nonlinear in [false, true] { + let mut s = session(); + s.execute("history.options", json!({"allowNonLinearHistory":nonlinear})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Future"})).unwrap(); + let future = s.active().unwrap().history.row_id(s.active().unwrap().history.past_len()).unwrap(); + assert!(s.undo()); + if !nonlinear { + s.active_mut().unwrap().history.max_states = 1; + } + let before = s.active().unwrap().doc.clone(); + s.actions.list = vec![Action::new("Many", (0..5).map(|i| ("layer.renameLayer".into(), json!({"name":format!("Name{i}")}))).collect())]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Many")); + if !nonlinear { + assert_eq!(s.active().unwrap().history.past_len(), 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + } else { + assert!(s.active().unwrap().history.row_of(future).is_some(), "the existing future row keeps its identity"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layers.iter().any(|layer| layer.name == "Future")); + } + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// Check both removed ids and the labels attached to every surviving older row. +fn assert_delete_rows(s: &Session, originals: &[(u64, String, std::sync::Arc)], removed: &[usize], case: &str, single_state: bool) { + for (i, (id, label, _)) in originals.iter().enumerate() { + let row = s.active().unwrap().history.row_of(*id); + if removed.contains(&i) { + assert!(row.is_none(), "{case}, singleState={single_state}: deleted row {id} returned"); + } else { + let row = row.unwrap_or_else(|| panic!("{case}: surviving row {id} disappeared")); + let st = s.active().unwrap(); + let labels: Vec<_> = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_owned)).collect(); + assert_eq!(&labels[row], label); + } + } +} + +/// Compare Undo with the unchanged oracle for this deletion and history mode. +fn assert_delete_boundary( + s: &Session, + case: &str, + single_state: bool, + prefix: bool, + before: &std::sync::Arc, + originals: &[(u64, String, std::sync::Arc)], +) { + let boundary = if case == "linear-past" { + &originals[0].2 + } else if case == "nonlinear-redo" { + // Non-linear history appends the retained future after the original current. + &originals[3].2 + } else if single_state { + before + } else if case == "nonlinear-past" && prefix { + &originals[3].2 + } else { + &originals[4].2 + }; + assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref(), "{case}, singleState={single_state}, prefix={prefix}"); +} + +/// Play the real Delete State command, retaining identities of the older rows it did not delete. +fn recorded_delete_case(case: &str) { + for single_state in [false, true] { + for prefix in [false, true] { + if !prefix && case != "nonlinear-past" { + continue; + } + let nonlinear = case.starts_with("nonlinear"); + let mut s = history_control_session(single_state); + s.execute("history.options", json!({"allowNonLinearHistory":nonlinear})).unwrap(); + s.execute("edit.fill", json!({"color":"#0000ff"})).unwrap(); + s.execute("edit.fill", json!({"color":"#00ffff"})).unwrap(); + let originals: Vec<_> = (0..=s.active().unwrap().history.past_len()) + .map(|i| { + let st = s.active().unwrap(); + (st.history.row_id(i).unwrap(), st.history.entries()[i].clone(), st.history.row_doc(i, &st.doc).unwrap()) + }) + .collect(); + let source = if case.ends_with("past") { 1 } else { 4 }; + s.execute("history.setSource", json!({"state":source})).unwrap(); + if case == "nonlinear-redo" { + assert!(s.undo()); + assert!(s.undo()); + } + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + if case.ends_with("current") { + steps.push(("history.setSource".into(), json!({"state":past + 1}))); + } + if case == "nonlinear-redo" { + steps.push(("history.selectState".into(), json!({"state":3}))); + } + let deleted = match case { + "linear-past" | "nonlinear-past" => 1, + "nonlinear-redo" => 4, + _ => past + 1, + }; + steps.extend([ + ("history.deleteState".into(), json!({"state":deleted})), + ("edit.fill".into(), json!({"color":"#ffff00"})), + ("edit.fill".into(), json!({"color":"#ff00ff"})), + ]); + s.actions.list = vec![Action::new("Delete state", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{case}, singleState={single_state}: {result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers[0].surface().unwrap().rgba(0, 0), [1.0, 0.0, 1.0, 1.0]); + let removed: Vec<_> = match case { + "linear-past" => (1..5).collect(), + "nonlinear-past" => vec![1], + "nonlinear-redo" => vec![4], + _ => Vec::new(), + }; + assert_delete_rows(&s, &originals, &removed, case, single_state); + let count = if single_state { 1 } else { 2 }; + if single_state { + assert_eq!(s.active().unwrap().history.past_len(), originals.len() - removed.len()); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Delete state")); + } + for _ in 0..count { + assert!(s.undo()); + } + assert_delete_boundary(&s, case, single_state, prefix, &before, &originals); + for _ in 0..count { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + for i in removed { + assert!(s.active().unwrap().history.row_of(originals[i].0).is_none()); + } + assert!(s.execute("edit.fill", json!({"contents":"history"})).is_err(), "the selected deleted source must stay unresolved"); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +#[test] +fn recorded_linear_past_deletion_keeps_its_boundary_and_suffix() { + recorded_delete_case("linear-past"); +} + +#[test] +fn recorded_linear_current_deletion_keeps_surviving_rows() { + recorded_delete_case("linear-current"); +} + +#[test] +fn recorded_nonlinear_past_deletion_keeps_only_surviving_rows() { + recorded_delete_case("nonlinear-past"); +} + +#[test] +fn recorded_nonlinear_current_deletion_keeps_surviving_rows() { + recorded_delete_case("nonlinear-current"); +} + +#[test] +fn recorded_nonlinear_redo_deletion_keeps_only_surviving_rows() { + recorded_delete_case("nonlinear-redo"); +} + +/// Purge Undo removes one predecessor, while native snapshots and legitimate older rows remain. +fn recorded_user_purge_undo_case(suffix: bool) { + for single_state in [false, true] { + for prefix in [false, true] { + let mut s = history_control_session(single_state); + let old: Vec<_> = (0..=s.active().unwrap().history.past_len()) + .map(|i| { + let st = s.active().unwrap(); + (st.history.row_id(i).unwrap(), st.history.row_doc(i, &st.doc).unwrap()) + }) + .collect(); + let removed = if prefix { 2 } else { 1 }; + s.execute("history.setSource", json!({"state":removed})).unwrap(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + steps.extend([("edit.purge.undo".into(), json!({})), ("history.newSnapshot".into(), json!({"name":"Purge boundary"}))]); + if suffix { + steps.extend([("edit.fill".into(), json!({"color":"#ffff00"})), ("edit.fill".into(), json!({"color":"#ff00ff"}))]); + } + s.actions.list = vec![Action::new("Purge Undo", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "singleState={single_state}, prefix={prefix}, suffix={suffix}: {result}"); + let st = s.active().unwrap(); + let after = st.doc.clone(); + let boundary = st.snapshots.last().unwrap().doc.clone(); + let snapshot = st.snapshots.last().unwrap().id; + assert_eq!(boundary.layers[0].surface().unwrap().rgba(0, 0), if prefix { [0.0, 0.0, 1.0, 1.0] } else { [0.0, 1.0, 0.0, 1.0] }); + assert!(st.history.row_of(old[removed].0).is_none(), "Purge Undo's removed predecessor returned"); + assert!(st.history.row_of(old[0].0).is_some()); + let count = if single_state { 1 } else { 2 }; + if suffix { + for _ in 0..count { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref()); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), old[if prefix { 1 } else { 0 }].1.as_ref(), "the purged pixels cannot be undone back into existence"); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref()); + if suffix { + for _ in 0..count { + assert!(s.redo()); + } + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + assert!(s.active().unwrap().history.row_of(old[removed].0).is_none()); + assert!(s.execute("edit.fill", json!({"contents":"history"})).is_err()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + s.execute("edit.fill", json!({"contents":"history", "snapshot":snapshot})).unwrap(); + assert_eq!( + s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0), + boundary.layers[0].surface().unwrap().rgba(0, 0), + "native snapshots outlive Purge Undo" + ); + } + } +} + +#[test] +fn recorded_user_purge_undo_does_not_restore_its_removed_predecessor() { + recorded_user_purge_undo_case(false); +} + +#[test] +fn recorded_user_purge_undo_groups_only_the_later_suffix() { + recorded_user_purge_undo_case(true); +} diff --git a/crates/engine/tests/it/atn_adjustment_make_props.rs b/crates/engine/tests/it/atn_adjustment_make_props.rs new file mode 100644 index 0000000000..3cd7475428 --- /dev/null +++ b/crates/engine/tests/it/atn_adjustment_make_props.rs @@ -0,0 +1,128 @@ +//! #8384: an adjustment layer's Make step keeps the new layer's name (`Nm `), blend mode +//! (`Md `) and clipping (`Grup`) on load, playback and save. +use photocraft_doc::{BlendMode, LayerContent}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +const NAME: &str = "Independent named adjustment"; + +fn text(s: &str) -> D { + D::Text(UnicodeString::new_nul(s)) +} + +fn blend(code: &str) -> D { + D::Enumerated { type_id: Id::new("BlnM"), value: Id::new(code) } +} + +fn levels() -> Descriptor { + Descriptor::new("Lvls").with("presetKind", D::Enumerated { type_id: Id::new("presetKindType"), value: Id::new("presetKindDefault") }) +} + +/// `Make adjustment layer` with `extra` keys after `Type`, in the order Save Actions writes them. +fn make(extra: &[(&str, D)]) -> Descriptor { + let mut using = Descriptor::new("AdjL").with("Type", D::Descriptor(levels())); + for (k, v) in extra { + using = using.with(k, v.clone()); + } + let class_ref = ReferenceItem::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new("AdjL") }); + Descriptor::new("null").with("null", D::Reference(vec![class_ref])).with("Usng", D::Descriptor(using)) +} + +fn bytes(descriptor: Descriptor) -> Vec { + let file = AtnFile { + name: UnicodeString::new_nul("Props"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Make"), + items: vec![AtnItem { event: AtnEvent::Code(*b"Mk "), dictionary: b"Make".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + atn::write(&file).unwrap() +} + +fn doc(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 3, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Pixels"})).unwrap(); + s +} + +#[test] +fn make_keeps_name_mode_and_clipping_through_play_and_save_at_both_depths() { + let cases: [(&[(&str, D)], Value); 5] = [ + (&[("Nm ", text(NAME))], json!({"name": NAME})), + (&[("Md ", blend("Mltp"))], json!({"mode": "Multiply"})), + (&[("Grup", D::Boolean(true))], json!({"clipToPrevious": true})), + (&[("Grup", D::Boolean(false))], json!({"clipToPrevious": false})), + (&[("Nm ", text(NAME)), ("Md ", blend("Scrn")), ("Grup", D::Boolean(true))], json!({"name": NAME, "mode": "Screen", "clipToPrevious": true})), + ]; + for depth in [8, 16] { + for (extra, params) in &cases { + let descriptor = make(extra); + let mut s = doc(depth); + let base = s.active().unwrap().active_layer.unwrap(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes(descriptor.clone()))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{params}"); + let steps = &s.actions.list[0].steps; + assert_eq!(steps.len(), 1, "{params}"); + assert_eq!(steps[0].0, "layer.newAdjustmentLayer.levels"); + for (k, v) in params.as_object().unwrap() { + assert_eq!(steps[0].1.get(k), Some(v), "{params}: {}", steps[0].1); + } + + let played = s.execute("actions.play", json!({"action": "Make"})).unwrap(); + assert!(played.get("failed").is_none(), "{params}: {played}"); + let st = s.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert!(matches!(layer.content, LayerContent::Adjustment(_)), "{params}"); + assert_eq!(layer.name, if params.get("name").is_some() { NAME } else { "Levels 1" }, "{params}"); + let mode = match params.get("mode").and_then(Value::as_str) { + Some("Multiply") => BlendMode::Multiply, + Some("Screen") => BlendMode::Screen, + _ => BlendMode::Normal, + }; + assert_eq!(layer.blend, mode, "{params}"); + assert_eq!(layer.clipped, params.get("clipToPrevious") == Some(&json!(true)), "{params}"); + let base = st.doc.layer(base).unwrap(); + assert_eq!((base.name.as_str(), base.blend, base.clipped), ("Pixels", BlendMode::Normal, false), "{params}"); + + // Save Actions writes the descriptor the step was loaded from. + let saved = s.execute("actions.save", json!({"set": "Props"})).unwrap(); + let data = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&data).unwrap(); + assert_eq!(file.actions[0].items[0].descriptor.as_ref(), Some(&descriptor), "{params}"); + } + } +} + +#[test] +fn unreadable_layer_properties_keep_the_step() { + for extra in [("Nm ", D::Integer(2)), ("Md ", blend("unknownBlend")), ("Md ", blend("PsTh")), ("Md ", text("Multiply")), ("Grup", text("true"))] { + let imported = atn_cmds::import(&bytes(make(std::slice::from_ref(&extra)))).unwrap(); + assert_eq!(imported.unsupported.len(), 1, "{:?}", extra.0); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP, "{:?}", extra.0); + } +} + +#[test] +fn bad_layer_properties_fail_without_a_layer_or_an_export() { + let mut s = doc(8); + for params in [ + json!({"name": false}), + json!({"name": 3}), + json!({"mode": false}), + json!({"mode": "unknown"}), + json!({"mode": "Pass Through"}), + json!({"clipToPrevious": 1}), + json!({"clipToPrevious": "true"}), + ] { + let layers = s.active().unwrap().doc.layers.len(); + assert!(s.execute("layer.newAdjustmentLayer.levels", params.clone()).is_err(), "{params}"); + assert_eq!(s.active().unwrap().doc.layers.len(), layers, "{params} must not add a layer"); + let action = Action::new("Make", vec![("layer.newAdjustmentLayer.levels".into(), params.clone())]); + assert!(atn_cmds::export("Props", &[&action], &[], false).is_err(), "{params} must not save as a default layer"); + } +} diff --git a/crates/engine/tests/it/atn_calculations.rs b/crates/engine/tests/it/atn_calculations.rs new file mode 100644 index 0000000000..7b9f16984e --- /dev/null +++ b/crates/engine/tests/it/atn_calculations.rs @@ -0,0 +1,302 @@ +//! Recorded Calculations Make forms, using the measured reference and ScriptListener types. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } +} + +fn channel(ty: &str, value: &str) -> Vec { + vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new(ty), value: Id::new(value) }] +} + +fn named(cls: &str, name: &str) -> ReferenceItem { + ReferenceItem::Name { class: class(cls), name: UnicodeString::new_nul(name) } +} + +fn make(a: Vec, b: Vec) -> Descriptor { + let using = Descriptor::new("Clcl") + .with("T ", D::Reference(a)) + .with("Clcl", D::Enumerated { type_id: Id::new("Clcn"), value: Id::new("Add ") }) + .with("Scl ", D::Double(1.0)) + .with("Ofst", D::Integer(0)) + .with("Src2", D::Reference(b)); + Descriptor::new("null").with("Nw ", D::Class(class("Chnl"))).with("Usng", D::Descriptor(using)) +} + +fn bytes(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Calculations"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Calculate"), + items: vec![AtnItem { event: AtnEvent::Code(*b"Mk "), descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +fn roundtrip(d: Descriptor) -> Action { + let imported = atn_cmds::import(&bytes(d.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + let action = imported.actions[0].clone(); + assert_eq!(action.steps[0].0, "image.calculations"); + let (saved, skipped) = atn_cmds::export("Calculations", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor, Some(d)); + action +} + +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 3, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.edit("pattern", |doc, active| { + let area = doc.bounds(); + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let pixels: Vec = [[0.125, 0.25, 0.5, 1.0], [0.25, 0.5, 0.125, 1.0], [0.5, 0.125, 0.25, 1.0]] + .into_iter() + .flat_map(|v| photocraft_raster::from_rgba(&format, v)) + .collect(); + surface.write_region(area, &pixels); + Ok(()) + }) + .unwrap(); + s +} + +fn alpha(s: &Session, index: usize) -> Vec { + let doc = &s.active().unwrap().doc; + doc.channels[index].surface.read_region(doc.bounds()) +} + +fn play(s: &mut Session, action: Action) -> Value { + s.actions.list = vec![action]; + s.execute("actions.play", json!({"action": "Calculate"})).unwrap() +} + +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +#[test] +fn atn_calculations_add_creates_alpha_with_expected_pixels_and_undo() { + let action = roundtrip(make(channel("Chnl", "Rd "), channel("Chnl", "Grn "))); + for depth in [8, 16, 32] { + let mut s = fixture(depth); + let before = saved(&s); + let original = photocraft_compose::flatten(&s.active().unwrap().doc).into_px(); + assert_eq!(play(&mut s, action.clone())["ran"], 1); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.channels.len(), 1); + assert_eq!(doc.channels[0].name, "Alpha 1"); + assert_eq!(doc.channels[0].surface.format().sample, doc.pixel_format().sample); + for (got, rgba) in alpha(&s, 0).iter().zip(&original) { + assert!((got - (rgba[0] + rgba[1]).min(1.0)).abs() < 2.0 / 255.0, "{depth}: {got} {rgba:?}"); + } + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px, original); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + } + let mut d = make(channel("Chnl", "Rd "), channel("Chnl", "Grn ")); + let D::Descriptor(using) = &mut d.items[1].1 else { panic!() }; + using.items[1].1 = D::Enumerated { type_id: Id::new("Clcn"), value: Id::new("Sbtr") }; + let mut s = fixture(32); + assert_eq!(play(&mut s, roundtrip(d))["ran"], 1); + assert_eq!(alpha(&s, 0), vec![0.125, 0.25, 0.0], "source1 is subtracted from source2"); +} + +#[test] +fn atn_calculations_target_is_resolved_on_replay_and_literal_alpha_is_distinct() { + let action = roundtrip(make(channel("Ordn", "Trgt"), channel("Chnl", "Grn "))); + let mut results = Vec::new(); + for component in ["red", "blue"] { + let mut s = fixture(32); + s.execute("channel.target", json!({"channel": component})).unwrap(); + assert_eq!(play(&mut s, action.clone())["ran"], 1); + results.push(alpha(&s, 0)); + } + assert_eq!(results[0], vec![0.375, 0.75, 0.625]); + assert_eq!(results[1], vec![0.75, 0.625, 0.375]); + + let mut s = fixture(32); + s.execute("channel.new", json!({"name": "red", "fill": "white"})).unwrap(); + assert_eq!(play(&mut s, action)["ran"], 1); + assert_eq!(alpha(&s, 1), vec![1.0; 3]); + let literal = roundtrip(make(vec![named("Chnl", "red")], channel("Chnl", "Grn "))); + s.execute("channel.target", json!({"channel": "blue"})).unwrap(); + assert_eq!(play(&mut s, literal)["ran"], 1); + assert_eq!(alpha(&s, 2), vec![1.0; 3], "named red must read the alpha, not the red component"); + // The old string remains a literal alpha name; only the typed selector has target semantics. + s.execute("channel.rename", json!({"channel": 0, "name": "targeted"})).unwrap(); + s.execute("image.calculations", json!({"source1":{"channel":"targeted"},"source2":{"channel":{"color":"green"}},"blending":"add"})).unwrap(); + assert_eq!(alpha(&s, 3), vec![1.0; 3]); +} + +#[test] +fn atn_calculations_named_layer_is_not_the_merged_sentinel() { + let mut a = channel("Chnl", "Rd "); + a.push(named("Lyr ", "merged")); + let action = roundtrip(make(a, channel("Chnl", "Grn "))); + let mut s = fixture(32); + s.execute("layer.setProps", json!({"name": "merged"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "top"})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + assert_eq!(play(&mut s, action)["ran"], 1); + assert_eq!(alpha(&s, 0), vec![0.125, 0.25, 0.5], "red from named layer plus merged green"); +} + +#[test] +fn atn_calculations_explicit_merged_transparency_reads_composite_alpha() { + let mut source = channel("Chnl", "Trsp"); + source.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Mrgd") }); + let mut d = make(source, channel("Chnl", "Rd ")); + let D::Descriptor(using) = &mut d.items[1].1 else { panic!() }; + using.items[1].1 = D::Enumerated { type_id: Id::new("Clcn"), value: Id::new("Nrml") }; + let action = roundtrip(d); + let mut s = fixture(32); + for (index, alphas) in [[0.5, 0.0, 0.25], [0.25, 0.5, 0.0]].into_iter().enumerate() { + if index == 1 { + s.execute("layer.new.layer", json!({"name":"top"})).unwrap(); + } + s.edit("alpha pattern", |doc, active| { + let area = doc.bounds(); + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let pixels: Vec<_> = alphas.into_iter().flat_map(|a| photocraft_raster::from_rgba(&format, [0.5, 0.25, 0.125, a])).collect(); + surface.write_region(area, &pixels); + Ok(()) + }) + .unwrap(); + } + let before = saved(&s); + assert_eq!(play(&mut s, action)["ran"], 1); + assert_eq!(alpha(&s, 0), vec![0.625, 0.5, 0.25], "top alpha plus base alpha times one minus top alpha"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + for channel in ["transparency", "Transparency", "TRANSPARENCY"] { + s.execute("image.calculations",json!({"source1Layer":"merged","source1Channel":channel,"source2Layer":"merged","source2Channel":channel,"blending":"multiply","maskLayer":"merged"})).unwrap(); + assert_eq!(alpha(&s, 0), vec![0.390625, 0.25, 0.0625], "both flat sources retain merged alpha; a channel-less merged mask remains absent"); + s.execute("edit.undo", json!({})).unwrap(); + } + s.execute("image.calculations", json!({"source1":{"channel":"transparency"},"source2":{"channel":{"color":"red"}},"blending":"normal"})).unwrap(); + assert_eq!(alpha(&s, 0), vec![0.25, 0.5, 0.0], "omitting the layer still reads the active layer's alpha"); +} + +#[test] +fn atn_calculations_unsupported_targets_and_missing_sources_leave_history_unchanged() { + let p = json!({"source1":{"channel":{"targeted":true}},"source2":{"channel":{"color":"green"}},"blending":"add"}); + let mut s = fixture(32); + let before = saved(&s); + let label = s.active().unwrap().history.undo_label().map(str::to_owned); + assert!(s.execute("image.calculations", p.clone()).is_err(), "composite is not one channel"); + assert_eq!(saved(&s), before); + assert_eq!(s.active().unwrap().history.undo_label(), label.as_deref()); + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + s.execute("channel.target", json!({"channel": "blue", "extend": true})).unwrap(); + assert!(s.execute("image.calculations", p).is_err(), "multiple colours are not one channel"); + for channel in [json!({"name":"missing"}), json!({"color":"cyan"})] { + assert!(s.execute("image.calculations", json!({"source1":{"channel":channel},"source2":{"channel":{"color":"green"}},"blending":"add"})).is_err()); + } + assert_eq!(saved(&s), before); + assert_eq!(s.active().unwrap().history.undo_label(), label.as_deref()); +} + +#[test] +fn atn_calculations_group_transparency_uses_the_canvas_and_undoes() { + let mut group = fixture(32); + group.execute("layer.new.group", json!({"name":"source"})).unwrap(); + let before = saved(&group); + let label = group.active().unwrap().history.undo_label().map(str::to_owned); + group + .execute( + "image.calculations", + json!({"source1":{"layer":{"name":"source"},"channel":"transparency"},"source2":{"channel":{"color":"green"}},"blending":"add"}), + ) + .unwrap(); + assert_eq!(alpha(&group, 0), vec![1.0; 3]); + group.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&group), before); + assert_eq!(group.active().unwrap().history.undo_label(), label.as_deref()); +} + +#[test] +fn atn_calculations_unknown_wire_forms_are_kept_and_unrepresentable_native_options_refuse() { + let original = make(channel("Chnl", "Rd "), channel("Chnl", "Grn ")); + let mut variants = vec![original.clone().with("extra", D::Boolean(true)), original.clone().with("Nw ", D::Class(class("Chnl")))]; + let D::Descriptor(using) = original.get("Usng").unwrap() else { panic!() }; + for (key, value) in [("Scl ", D::Integer(1)), ("Ofst", D::Double(0.0)), ("extra", D::Boolean(true))] { + let mut d = using.clone(); + d.items.retain(|(id, _)| !id.is(key)); + d = d.with(key, value); + variants.push(Descriptor::new("null").with("Nw ", D::Class(class("Chnl"))).with("Usng", D::Descriptor(d))); + } + let mut omitted_blend = using.clone(); + omitted_blend.items.retain(|(id, _)| !id.is("Clcl")); + variants.push(Descriptor::new("null").with("Nw ", D::Class(class("Chnl"))).with("Usng", D::Descriptor(omitted_blend))); + let mut bad_ref = channel("Chnl", "Rd "); + bad_ref.push(ReferenceItem::Index { class: class("Dcmn"), index: 1 }); + variants.push(make(bad_ref, channel("Chnl", "Grn "))); + for r in [channel("Ordn", "Rd "), channel("Chnl", "nope"), channel("Chnl", "Trsp")] { + variants.push(make(r, channel("Chnl", "Grn "))); + } + for d in variants { + let imported = atn_cmds::import(&bytes(d.clone())).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (saved, _) = atn_cmds::export("Calculations", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor, Some(d)); + } + let valid = roundtrip(original).steps[0].1.clone(); + for (key, value) in [ + ("result", json!("selection")), + ("mask", json!({"channel":"red"})), + ("opacity", json!(50)), + ("offset", json!(0.5)), + ("scale", json!(3)), + ("name", json!("custom")), + ] { + let mut p = valid.clone(); + p[key] = value; + let a = Action::new("Bad", vec![("image.calculations".into(), p)]); + assert!(atn_cmds::export("Calculations", &[&a], &[], false).is_err(), "{key}"); + } +} + +#[test] +fn atn_calculations_make_routing_retains_mask_creation() { + let d = Descriptor::new("null") + .with("Nw ", D::Class(class("Chnl"))) + .with("At ", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Msk ") }])) + .with("Usng", D::Enumerated { type_id: Id::new("UsrM"), value: Id::new("RvlA") }); + let imported = atn_cmds::import(&bytes(d)).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].0, "layer.layerMask.revealAll"); + let mut s = fixture(32); + assert_eq!(play(&mut s, imported.actions[0].clone())["ran"], 1); + let doc = &s.active().unwrap().doc; + assert!(doc.channels.is_empty()); + assert!(doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().mask.is_some()); +} + +/// Non-default valid scale and offset retain their wire types and affect the calculated pixels. +#[test] +fn atn_calculations_valid_scale_and_offset_roundtrip_and_pixels() { + for (scale, offset) in [(2.0, 0), (1.0, 32), (2.0, -32)] { + let mut d = make(channel("Chnl", "Rd "), channel("Chnl", "Grn ")); + let D::Descriptor(using) = &mut d.items[1].1 else { panic!("Calculations settings") }; + using.items.iter_mut().find(|(k, _)| k.is("Scl ")).unwrap().1 = D::Double(scale); + using.items.iter_mut().find(|(k, _)| k.is("Ofst")).unwrap().1 = D::Integer(offset); + let action = roundtrip(d); + let mut s = fixture(32); + let input = photocraft_compose::flatten(&s.active().unwrap().doc).into_px(); + assert_eq!(play(&mut s, action)["ran"], 1); + for (got, rgba) in alpha(&s, 0).iter().zip(input) { + let expected = ((rgba[0] + rgba[1]) / scale as f32 + offset as f32 / 255.0).clamp(0.0, 1.0); + assert!((got - expected).abs() < 1e-6, "scale={scale} offset={offset}: {got} vs {expected}"); + } + } +} diff --git a/crates/engine/tests/it/atn_classic_gradient.rs b/crates/engine/tests/it/atn_classic_gradient.rs new file mode 100644 index 0000000000..709b7a7d90 --- /dev/null +++ b/crates/engine/tests/it/atn_classic_gradient.rs @@ -0,0 +1,317 @@ +//! Synthetic measured-layout recordings through public ATN load/save and playback. +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::json; + +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} +fn pct(value: f64) -> D { + D::UnitFloat { unit: *b"#Prc", value } +} +fn point(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y })) +} +fn color(t: i32, rgb: [f64; 3]) -> D { + let c = Descriptor::new("RGBC").with("Rd ", D::Double(rgb[0])).with("Grn ", D::Double(rgb[1])).with("Bl ", D::Double(rgb[2])); + D::Descriptor( + Descriptor::new("Clrt").with("Clr ", D::Descriptor(c)).with("Type", enm("Clry", "UsrS")).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50)), + ) +} +fn opacity(t: i32, value: f64) -> D { + D::Descriptor(Descriptor::new("TrnS").with("Opct", pct(value)).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50))) +} +fn fixture(style: &str, method: &str, changed: bool) -> Descriptor { + let gradient = Descriptor::new("Grdn") + .with("Nm ", D::Text(UnicodeString::new_nul("Recorded gradient"))) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![color(0, [0.0, 32.0, 255.0]), color(4096, [255.0, 224.0, 0.0])])) + .with("Trns", D::List(vec![opacity(0, 20.0), opacity(4096, 80.0)])); + let mut d = Descriptor::new("null").with("From", point(1.0, 1.0)).with("T ", point(6.0, 2.0)); + if changed { + d = d.with("Opct", pct(54.5)).with("Md ", enm("BlnM", "Mltp")); + } + d = d.with("Type", enm("GrdT", style)); + if changed { + d = d.with("Rvrs", D::Boolean(true)).with("UsMs", D::Boolean(true)); + } + d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", method)).with("Grad", D::Descriptor(gradient)) +} +fn recording(event: AtnEvent, d: Descriptor) -> Vec { + let file = AtnFile { + name: UnicodeString::new_nul("Set"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Gradient"), + items: vec![AtnItem { event, descriptor: Some(d), dictionary: b"Gradient".to_vec(), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + atn::write(&file).unwrap() +} +fn imported(d: Descriptor) -> atn_cmds::Imported { + atn_cmds::import(&recording(AtnEvent::Code(*b"Grdn"), d)).unwrap() +} +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Set", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions[0].items[0].clone() +} +fn replace(d: &mut Descriptor, key: &str, v: D) { + d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 = v; +} +fn obj<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Descriptor { + let D::Descriptor(value) = &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 else { panic!("not an object") }; + value +} + +#[test] +fn both_event_ids_and_top_level_classes_keep_complete_unchanged_descriptors() { + for class in ["null", "Grdn"] { + for style in ["Lnr ", "Rdl ", "Angl", "Rflc", "Dmnd"] { + for method in ["Gcls", "Perc", "Lnr ", "Smoo"] { + for changed in [false, true] { + for event in [AtnEvent::Code(*b"Grdn"), AtnEvent::name("gradientClassEvent")] { + let mut d = fixture(style, method, changed); + d.class_id = Id::new(class); + let loaded = atn_cmds::import(&recording(event, d.clone())).unwrap(); + assert!(loaded.unsupported.is_empty(), "{style} {method}"); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, "paint.gradient"); + assert_eq!(saved(action).descriptor.unwrap().to_bytes(), d.to_bytes()); + assert_eq!(action.steps[0].1["dither"], false, "omitted Dthr is off, unlike native default"); + assert_eq!(action.steps[0].1["opacity"], if changed { json!(54.5) } else { json!(100) }); + assert_eq!(action.steps[0].1["transparency"], if changed { json!([[0, 20], [1, 80]]) } else { json!([]) }); + } + } + } + } + } +} + +#[test] +fn explicit_defaults_key_order_id_encoding_and_flags_are_preserved() { + let mut d = fixture("Lnr ", "Gcls", false); + d.items.push((Id::Str(b"Opct".to_vec()), pct(100.0))); + d.items.push((Id::new("Dthr"), D::Boolean(false))); + d.items.push((Id::new("Rvrs"), D::Boolean(false))); + d.items.reverse(); + let mut loaded = imported(d.clone()); + assert!(loaded.unsupported.is_empty()); + loaded.actions[0].disabled.insert(0); + loaded.actions[0].dialogs.insert(0); + let item = saved(&loaded.actions[0]); + assert_eq!(item.descriptor.unwrap().to_bytes(), d.to_bytes()); + assert!(!item.enabled); + assert!(item.with_dialog); +} + +#[test] +fn supported_edits_replace_stale_source_and_export_recorded_default_omissions() { + let original = fixture("Rdl ", "Perc", true); + let mut loaded = imported(original.clone()); + let p = &mut loaded.actions[0].steps[0].1; + p["opacity"] = json!(100); + p["mode"] = json!("Normal"); + p["reverse"] = json!(false); + p["dither"] = json!(true); + p["transparency"] = json!([]); + p["from"] = json!([2, 3]); + let item = saved(&loaded.actions[0]); + let d = item.descriptor.unwrap(); + assert_ne!(d.to_bytes(), original.to_bytes()); + let keys: Vec<&[u8]> = d.items.iter().map(|(k, _)| k.as_bytes()).collect(); + assert_eq!(keys, vec![b"From".as_slice(), b"T ", b"Type", b"Dthr", b"gradientsInterpolationMethod", b"Grad"]); + let back = imported(d); + assert!(back.unsupported.is_empty()); + assert_eq!(back.actions[0].steps[0].1["from"], json!([2, 3])); + assert_eq!(back.actions[0].steps[0].1["dither"], true); + assert_eq!(back.actions[0].steps[0].1["transparency"], json!([])); +} + +#[test] +fn malformed_unknown_and_unrepresentable_descriptors_stay_whole() { + let base = fixture("Lnr ", "Gcls", true); + let mut bad = Vec::new(); + let mut d = base.clone(); + d.class_id = Id::new("UnknownGradient"); + bad.push(d); + let mut d = base.clone(); + d.items.retain(|(k, _)| !k.is("Grad")); + bad.push(d); + let mut d = base.clone(); + d.items.retain(|(k, _)| !k.is("gradientsInterpolationMethod")); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "Type", enm("GrdT", "Unknown")); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "Type", enm("wrong", "Lnr ")); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "Opct", D::Double(54.5)); + bad.push(d); + let mut d = base.clone(); + d.items.push((Id::new("Dthr"), D::Integer(1))); + bad.push(d); + let mut d = base.clone(); + d.items.push((Id::Str(b"Opct".to_vec()), pct(100.0))); + bad.push(d); + let mut d = base.clone(); + d.items.push((Id::new("unknownSetting"), D::Boolean(true))); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "unknown")); + bad.push(d); + let mut d = base.clone(); + replace(obj(&mut d, "From"), "Hrzn", D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }); + bad.push(d); + let mut d = base.clone(); + replace(obj(&mut d, "From"), "Hrzn", pct(1.0)); + bad.push(d); + for (key, value) in [("Mdpn", D::Integer(25)), ("Type", enm("Clry", "FrgC")), ("Lctn", D::Integer(-1))] { + let mut d = base.clone(); + let D::List(stops) = &mut obj(&mut d, "Grad").items.iter_mut().find(|(k, _)| k.is("Clrs")).unwrap().1 else { panic!() }; + let D::Descriptor(stop) = &mut stops[0] else { panic!() }; + replace(stop, key, value); + bad.push(d); + } + let mut d = base.clone(); + let D::List(stops) = &mut obj(&mut d, "Grad").items.iter_mut().find(|(k, _)| k.is("Clrs")).unwrap().1 else { panic!() }; + let D::Descriptor(stop) = &mut stops[0] else { panic!() }; + replace(obj(stop, "Clr "), "Rd ", D::Double(0.5)); + bad.push(d); + for d in bad { + let bytes = d.to_bytes(); + let loaded = imported(d); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), bytes); + } +} + +#[test] +fn unsupported_native_targets_and_damaged_origins_are_refused() { + let loaded = imported(fixture("Lnr ", "Gcls", false)); + for (key, value) in [ + ("target", json!("mask")), + ("gradient", json!("current preset")), + ("method", json!("unknown")), + ("style", json!("unknown")), + ("style", json!(42)), + ("__atnClassicGradient", json!("not base64")), + ("__atnClassicGradient", json!("A".repeat(128 * 1024 + 1))), + ("stops", json!([[0, "foreground"], [1, "background"]])), + ("stops", json!([[0, "#000000"]])), + ("stops", json!([[0.1, "#000000"], [1, "#ffffff"]])), + ] { + let mut action = loaded.actions[0].clone(); + action.steps[0].1[key] = value; + assert!(atn_cmds::export("S", &[&action], &[], false).is_err(), "{key}"); + let (_, skipped) = atn_cmds::export("S", &[&action], &[], true).unwrap(); + assert_eq!(skipped.len(), 1, "{key}"); + } +} + +#[test] +fn colour_and_transparency_stop_counts_have_supported_and_refused_boundaries() { + for key in ["Clrs", "Trns"] { + for count in [1, 2, 64, 65] { + let mut d = fixture("Lnr ", "Gcls", true); + let stops = (0..count) + .map(|i| { + let t = i * 4096 / (count - 1).max(1); + if key == "Clrs" { color(t, [32.0, 96.0, 224.0]) } else { opacity(t, 50.0) } + }) + .collect(); + replace(obj(&mut d, "Grad"), key, D::List(stops)); + let bytes = d.to_bytes(); + let loaded = imported(d); + assert_eq!(loaded.unsupported.is_empty(), (2..=64).contains(&count), "{key} {count}"); + assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), bytes); + } + } +} + +#[test] +fn omitted_transparency_and_dither_use_recorded_defaults_during_playback() { + for depth in [8, 16, 32] { + for dither in [false, true] { + let mut d = fixture("Lnr ", "Gcls", false); + d.class_id = Id::new("Grdn"); + if dither { + d.items.push((Id::new("Dthr"), D::Boolean(true))); + } + let loaded = imported(d); + assert!(loaded.unsupported.is_empty()); + let mut actual = Session::new(); + let mut control = Session::new(); + for s in [&mut actual, &mut control] { + s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"background":"#204060"})).unwrap(); + } + actual.actions.list = loaded.actions; + actual.execute("actions.play", json!({"action":0})).unwrap(); + // The descriptor contains non-opaque Trns, but omitted UsMs disables them. + // Dthr=false is explicit, overriding the native command's true default. + control + .execute( + "paint.gradient", + json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100, + "stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":dither}), + ) + .unwrap(); + let a = actual.active().unwrap().doc.layers[0].surface().unwrap(); + let b = control.active().unwrap().doc.layers[0].surface().unwrap(); + for y in 0..4 { + for x in 0..8 { + assert_eq!(a.pixel(x, y), b.pixel(x, y), "{depth} dither={dither}"); + } + } + } + } +} + +#[test] +fn recorded_gradients_match_native_pixels_and_undo_at_each_depth() { + for class in ["null", "Grdn"] { + for depth in [8, 16, 32] { + for (style, native_style) in [("Lnr ", "linear"), ("Rdl ", "radial"), ("Angl", "angle"), ("Rflc", "reflected"), ("Dmnd", "diamond")] { + for (method, native_method) in [("Gcls", "classic"), ("Perc", "perceptual"), ("Lnr ", "linear"), ("Smoo", "smooth")] { + let mut descriptor = fixture(style, method, true); + descriptor.class_id = Id::new(class); + let loaded = imported(descriptor); + assert!(loaded.unsupported.is_empty()); + let mut actual = Session::new(); + let mut control = Session::new(); + for s in [&mut actual, &mut control] { + s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"background":"#204060"})).unwrap(); + } + let before = actual.active().unwrap().doc.clone(); + actual.actions.list = loaded.actions; + actual.execute("actions.play", json!({"action":0})).unwrap(); + control + .execute( + "paint.gradient", + json!({"from":[1,1],"to":[6,2],"style":native_style,"method":native_method,"smoothness":100, + "stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[[0,20],[1,80]],"opacity":54.5,"mode":"Multiply","reverse":true,"dither":false}), + ) + .unwrap(); + let pixels = |s: &Session| s.active().unwrap().doc.layers[0].surface().unwrap().clone(); + let (a, b) = (pixels(&actual), pixels(&control)); + for y in 0..4 { + for x in 0..8 { + assert_eq!(a.pixel(x, y), b.pixel(x, y), "{depth} {style} {method} ({x},{y})"); + } + } + assert_ne!(a.pixel(3, 1), before.layers[0].surface().unwrap().pixel(3, 1), "non-vacuous paint"); + assert!(actual.undo()); + assert_eq!(actual.active().unwrap().doc, before); + } + } + } + } +} diff --git a/crates/engine/tests/it/atn_color_lookup.rs b/crates/engine/tests/it/atn_color_lookup.rs new file mode 100644 index 0000000000..edc58a2122 --- /dev/null +++ b/crates/engine/tests/it/atn_color_lookup.rs @@ -0,0 +1,261 @@ +use photocraft_doc::{Adjustment, LayerContent}; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::json; + +const CUBE: &[u8] = b"LUT_3D_SIZE 2\n1 1 1\n0 1 1\n1 0 1\n0 0 1\n1 1 0\n0 1 0\n1 0 0\n0 0 0\n"; + +/// An unreadable recorded filename ensures playback depends on embedded data alone. +fn descriptor(format: &str, bytes: &[u8]) -> Descriptor { + let text = |s: &str| D::Text(UnicodeString::new_nul(s)); + let en = |ty: &str, value: &str| D::Enumerated { type_id: Id::new(ty), value: Id::new(value) }; + Descriptor::new("null") + .with("lookupType", en("colorLookupType", "3DLUT")) + .with("Nm ", text("/missing/original/source")) + .with("Dthr", D::Boolean(false)) + .with("profile", D::RawData(Vec::new())) + .with("LUTFormat", en("LUTFormatType", format)) + .with("LUT3DFileData", D::RawData(bytes.to_vec())) + .with("LUT3DFileName", text("embedded.bin")) +} + +fn encoded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + actions: vec![AtnAction { + name: UnicodeString::new_nul("Lookup"), + items: vec![AtnItem { event: AtnEvent::name("colorLookup"), descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":3,"height":2,"depth":depth,"background":"#4080c0"})).unwrap(); + s +} + +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() +} + +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +/// Original invert tables exercise text CUBE/3DL and binary UTF-16 LOOK decoding. +fn samples() -> Vec<(&'static str, Vec)> { + let mut three_dl = String::from("0 1023\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + three_dl.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095)); + } + } + } + let hex: String = + photocraft_cms::lutfile::LutFile::identity(2).data.into_iter().flat_map(|v| (1.0 - v).to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let look = format!("2{hex}"); + let le = [vec![0xff, 0xfe], look.encode_utf16().flat_map(u16::to_le_bytes).collect()].concat(); + let be = [vec![0xfe, 0xff], look.encode_utf16().flat_map(u16::to_be_bytes).collect()].concat(); + vec![("LUTFormatCUBE", CUBE.to_vec()), ("LUTFormat3DL", three_dl.into_bytes()), ("LUTFormatLOOK", le), ("LUTFormatLOOK", be)] +} + +/// Compare every supported encoding and document depth with native adjustment playback and Undo. +#[test] +fn embedded_lookup_bytes_replay_invert_and_save_the_exact_descriptor() { + for (format, bytes) in samples() { + let d = descriptor(format, &bytes); + let imported = atn_cmds::import(&encoded(d.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + let a = &imported.actions[0]; + assert_eq!(a.steps[0].0, "image.adjustments.colorLookup"); + let (out, skipped) = atn_cmds::export("Lookup", &[a], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + for depth in [8, 16, 32] { + let mut s = fixture(depth); + let before = saved(&s); + let original = pixels(&s); + s.actions.list = imported.actions.clone(); + assert_eq!(s.execute("actions.play", json!({"action":"Lookup"})).unwrap()["ran"], 1); + for (got, old) in pixels(&s).iter().zip(&original) { + for c in 0..3 { + assert!((got[c] - (1.0 - old[c])).abs() < 2.0 / 255.0, "{format} {depth}: {got:?} {old:?}"); + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + } + } +} + +/// Unchanged text keeps its UTF-16 representation; editing it still writes the requested name. +#[test] +fn lookup_filename_and_outer_metadata_survive_unrelated_edits() { + for name in [UnicodeString::new("embedded.bin"), UnicodeString(vec![65, 0, 0]), UnicodeString(vec![0xd800, 0])] { + let mut d = descriptor("LUTFormatCUBE", CUBE); + d.name = UnicodeString(vec![0xd800, 0]); + d.class_id = Id::Str(b"null".to_vec()); + d.items.iter_mut().find(|(key, _)| key.is("LUT3DFileName")).unwrap().1 = D::Text(name); + let imported = atn_cmds::import(&encoded(d.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + let mut action = imported.actions[0].clone(); + assert_eq!(action.steps[0].0, "image.adjustments.colorLookup"); + for edited in [false, true] { + action.steps[0].1["dither"] = json!(edited); + let (out, skipped) = atn_cmds::export("Lookup", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let mut expected = d.clone(); + expected.items.iter_mut().find(|(key, _)| key.is("Dthr")).unwrap().1 = D::Boolean(edited); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(expected)); + } + action.steps[0].1["fileName"] = json!("renamed.cube"); + let (out, _) = atn_cmds::export("Lookup", &[&action], &[], false).unwrap(); + let result = atn::parse(&out).unwrap().actions[0].items[0].descriptor.clone().unwrap(); + assert_eq!(result.name, d.name); + assert_eq!(result.class_id, d.class_id); + assert_eq!(result.get("LUT3DFileName"), Some(&D::Text(UnicodeString::new_nul("renamed.cube")))); + } +} + +/// Saving an adjustment must preserve the original payload rather than a decoded substitute. +#[test] +fn encoded_lookup_keeps_its_bytes_in_a_saved_adjustment() { + for (format, bytes) in samples() { + let imported = atn_cmds::import(&encoded(descriptor(format, &bytes))).unwrap(); + let mut p = imported.actions[0].steps[0].1.clone(); + p.as_object_mut().unwrap().remove("__atnColorLookup"); + let mut s = fixture(8); + s.execute("layer.newAdjustmentLayer.colorLookup", p).unwrap(); + let psd = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes; + let doc = photocraft_io::import("lookup.psd", &psd).unwrap().document; + let source = doc + .walk() + .into_iter() + .find_map(|(_, _, layer)| match &layer.content { + LayerContent::Adjustment(Adjustment::ColorLookup { source, .. }) => source.as_ref(), + _ => None, + }) + .unwrap(); + assert_eq!(source.bytes.as_slice(), bytes); + } +} + +/// An identity table isolates the nonlinear shaper through import, replay and saved adjustment bytes. +#[test] +fn embedded_three_dl_retains_its_nonlinear_shaper_through_action_playback() { + // A two-node identity table with a four-knot shaper. Ignoring the first row + // would leave the original pixels untouched; #9170 retains these midtones. + let mut table = String::from("0 0 512 1023\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + table.push_str(&format!("{} {} {}\n", r * 4095, g * 4095, b * 4095)); + } + } + } + let d = descriptor("LUTFormat3DL", table.as_bytes()); + let imported = atn_cmds::import(&encoded(d.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].0, "image.adjustments.colorLookup"); + let (out, skipped) = atn_cmds::export("Lookup", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + for depth in [8, 16, 32] { + let mut s = fixture(depth); + let original = pixels(&s); + s.actions.list = imported.actions.clone(); + assert_eq!(s.execute("actions.play", json!({"action":"Lookup"})).unwrap()["ran"], 1); + for (got, old) in pixels(&s).iter().zip(&original) { + for c in 0..3 { + let x = old[c].clamp(0.0, 1.0) * 3.0; + let knot = (x.floor() as usize).min(2); + let knots = [0.0, 0.0, 512.0 / 1023.0, 1.0]; + let expected = knots[knot] + (knots[knot + 1] - knots[knot]) * (x - knot as f32); + assert!((got[c] - expected).abs() < 2.0 / 255.0, "{depth}-bit channel {c}: {got:?} from {old:?}"); + } + } + } + // A shaper the parser refuses remains a complete step, rather than applying + // just its identity table and reporting a successful Color Lookup. + let bad = descriptor("LUTFormat3DL", table.replacen("0 0 512 1023", "0 NaN 512 1023", 1).as_bytes()); + let imported = atn_cmds::import(&encoded(bad.clone())).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (out, _) = atn_cmds::export("Lookup", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(bad)); +} + +/// Unsupported recording fields and native edits must not silently become partial lookup steps. +#[test] +fn unknown_lookup_forms_stay_whole_and_changed_native_steps_refuse_export() { + let d = descriptor("LUTFormatCUBE", CUBE); + let imported = atn_cmds::import(&encoded(d.clone())).unwrap(); + let mut edited = imported.actions[0].clone(); + edited.steps[0].1["dither"] = json!(true); + let (out, _) = atn_cmds::export("Lookup", &[&edited], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("Dthr"), Some(&D::Boolean(true))); + for (key, value) in [("target", json!("mask")), ("lutFormat", json!("look")), ("interpolation", json!("tetrahedral"))] { + let mut a = imported.actions[0].clone(); + a.steps[0].1[key] = value; + assert!(atn_cmds::export("Lookup", &[&a], &[], false).is_err()); + } + for (key, value) in [ + ("profile", D::RawData(vec![1, 2, 3])), + ("lookupType", D::Enumerated { type_id: Id::new("colorLookupType"), value: Id::new("abstractProfile") }), + ("LUTFormat", D::Enumerated { type_id: Id::new("OtherType"), value: Id::new("LUTFormatCUBE") }), + ("Dthr", D::Integer(0)), + ("extra", D::Boolean(true)), + ] { + let mut odd = d.clone(); + odd.items.retain(|(k, _)| !k.is(key)); + odd = odd.with(key, value); + let a = atn_cmds::import(&encoded(odd.clone())).unwrap().actions.remove(0); + assert_eq!(a.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (out, _) = atn_cmds::export("Lookup", &[&a], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(odd)); + } + let mut s = fixture(8); + let before = saved(&s); + let history = s.active().unwrap().history.past_len(); + for p in [json!({"data":"!","dataEncoding":"base64"}), json!({"data":"x","dataEncoding":"unknown"}), json!({"data":"x","lutFormat":"unknown"})] { + assert!(s.execute("image.adjustments.colorLookup", p).is_err()); + assert_eq!(saved(&s), before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + s.execute("image.adjustments.colorLookup", json!({"data":std::str::from_utf8(CUBE).unwrap(),"fileName":"legacy.cube"})).unwrap(); + assert_ne!(saved(&s), before, "the existing text input remains supported"); +} + +/// Float playback must apply the recorded dither without hiding it in integer quantization. +#[test] +fn recorded_dither_changes_float_pixels_within_its_documented_bound() { + let render = |dither| { + let mut d = descriptor("LUTFormatCUBE", CUBE); + d.items.iter_mut().find(|(key, _)| key.is("Dthr")).unwrap().1 = D::Boolean(dither); + let imported = atn_cmds::import(&encoded(d)).unwrap(); + assert!(imported.unsupported.is_empty()); + let mut s = fixture(32); + s.actions.list = imported.actions; + assert_eq!(s.execute("actions.play", json!({"action":"Lookup"})).unwrap()["ran"], 1); + pixels(&s) + }; + let plain = render(false); + // Opaque, unsaturated fixture pixels include nonzero ordered offsets. + assert!(plain.iter().all(|p| p[3] == 1.0 && p[..3].iter().all(|v| (0.01..0.99).contains(v)))); + assert!((0..2).any(|y| (0..3).any(|x| photocraft_compose::adjust::bayer4(x, y) != 0.0))); + let dithered = render(true); + let mut changed = false; + for (a, b) in dithered.iter().zip(&plain) { + assert_eq!(a[3], b[3]); + for c in 0..3 { + let difference = (a[c] - b[c]).abs(); + assert!(difference < 0.5 / 255.0, "channel {c}: {difference}"); + changed |= difference > 0.0; + } + } + assert!(changed, "Dthr=true must change at least one float channel"); +} diff --git a/crates/engine/tests/it/atn_content_aware_fill.rs b/crates/engine/tests/it/atn_content_aware_fill.rs new file mode 100644 index 0000000000..bb175549b4 --- /dev/null +++ b/crates/engine/tests/it/atn_content_aware_fill.rs @@ -0,0 +1,423 @@ +//! Recorded CAF settings use the native operation; unsupported descriptors stay whole. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const CMD: &str = "edit.contentAwareFill"; + +fn enm(key: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(key), value: Id::new(value) } +} + +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("cafSamplingRegion", enm("cafSamplingRegion", "cafSamplingRegionRectangular")) + .with("cafSampleAllLayers", D::Boolean(false)) + .with("cafColorAdaptationLevel", enm("cafColorAdaptationLevel", "cafColorAdaptationDefault")) + .with("cafRotationAmount", enm("cafRotationAmount", "cafRotationAmountNone")) + .with("cafScale", D::Boolean(false)) + .with("cafMirror", D::Boolean(false)) + .with("cafOutput", enm("cafOutput", "cafOutputToNewLayer")) +} + +fn params() -> Value { + json!({"sampling":"rectangular","sampleAllLayers":false,"colorAdaptation":"default", + "rotationAdaptation":"none","scale":false,"mirror":false,"output":"new"}) +} + +fn item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::name("cafWorkspace"), dictionary: b"Content-Aware Fill".to_vec(), descriptor: Some(d), ..Default::default() } +} + +fn file(item: AtnItem) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item], ..Default::default() }], + ..Default::default() + } +} + +fn load(d: Descriptor) -> Action { + let loaded = import(&atn::write(&file(item(d))).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + loaded.actions.into_iter().next().unwrap() +} + +fn exported(p: Value) -> Option { + let a = Action::new("A", vec![(CMD.into(), p)]); + let bytes = export("S", &[&a], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() +} + +/// Each field retains its native meaning, recorded type and position, including false booleans. +#[test] +fn caf_settings_roundtrip_seven_fields_and_boolean_choices() { + let base = descriptor(); + let mut cases = Vec::new(); + for bits in 0..8 { + let mut d = base.clone(); + let mut p = params(); + for (bit, index, param) in [(0, 1, "sampleAllLayers"), (1, 4, "scale"), (2, 5, "mirror")] { + let on = bits & (1 << bit) != 0; + d.items[index].1 = D::Boolean(on); + p[param] = json!(on); + } + cases.push((d, p)); + } + let mut current = base.clone(); + current.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer"); + let mut p = params(); + p["output"] = json!("current"); + cases.push((current, p)); + for (d, p) in cases { + let action = load(d.clone()); + assert_eq!(action.steps, vec![(CMD.into(), p.clone())]); + assert_eq!(exported(p).unwrap(), item(d.clone())); + let bytes = export("S", &[&action], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], item(d)); + } +} + +/// Unedited source identity, order and flags survive; damaged preservation metadata is refused. +#[test] +fn caf_source_order_and_flags_survive_without_reviving_stale_settings() { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded CAF"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let a = &imported.actions[0]; + assert!(a.steps[0].1.get("_atnCafDescriptor").is_some()); + let bytes = export("S", &[a], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], source); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnCafDescriptor"] = json!("not base64"); + assert!(exported(damaged).is_none()); + let invalid = descriptor().with("extra", D::Boolean(true)); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnCafDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes())); + assert!(exported(damaged).is_none(), "valid base64 cannot hide an unrepresented source field"); +} + +/// Representable edits export their new values canonically, retaining step flags without skips. +#[test] +fn caf_edited_settings_export_without_stale_wire_metadata() { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded CAF"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let a = &imported.actions[0]; + assert!(a.steps[0].1.get("_atnCafDescriptor").is_some()); + for (param, index, value, wire) in [ + ("sampleAllLayers", 1, json!(true), D::Boolean(true)), + ("scale", 4, json!(true), D::Boolean(true)), + ("mirror", 5, json!(true), D::Boolean(true)), + ("output", 6, json!("current"), enm("cafOutput", "cafOutputToCurrentLayer")), + ] { + let mut edited = a.clone(); + edited.steps[0].1[param] = value.clone(); + let mut expected_params = params(); + expected_params[param] = value; + let mut canonical = descriptor(); + canonical.items[index].1 = wire; + let mut expected = source.clone(); + expected.descriptor = Some(canonical); + for skip in [false, true] { + let (bytes, skipped) = export("S", &[&edited], &[], skip).unwrap(); + assert!(skipped.is_empty(), "a supported {param} edit must not be skipped"); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![expected.clone()]); + let reloaded = import(&bytes).unwrap(); + assert!(reloaded.unsupported.is_empty()); + assert_eq!(reloaded.actions[0].steps[0].1, expected_params); + } + } +} + +/// Missing defaults, malformed/repeated fields and unrepresented workspace state stay complete. +#[test] +fn caf_unrepresented_descriptors_are_kept_and_never_partially_play() { + let base = descriptor(); + let mut cases = vec![Descriptor::new("null"), base.clone().with("extra", D::Boolean(true))]; + for i in 0..base.items.len() { + let mut missing = base.clone(); + missing.items.remove(i); + cases.push(missing); + let mut repeated = base.clone(); + repeated.items.push(repeated.items[i].clone()); + cases.push(repeated); + let mut wrong = base.clone(); + wrong.items[i].1 = D::Integer(0); + cases.push(wrong); + } + for (i, key, value) in [ + (0, "cafSamplingRegion", "cafSamplingRegionAuto"), + (0, "cafSamplingRegion", "cafSamplingRegionCustom"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationNone"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationHigh"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationVeryHigh"), + (2, "cafColorAdaptationLevel", "unknown"), + (3, "cafRotationAmount", "cafRotationAmountLow"), + (3, "cafRotationAmount", "cafRotationAmountMedium"), + (3, "cafRotationAmount", "cafRotationAmountHigh"), + (3, "cafRotationAmount", "cafRotationAmountFull"), + (3, "cafRotationAmount", "unknown"), + (6, "cafOutput", "cafOutputToDuplicateLayer"), + ] { + let mut d = base.clone(); + d.items[i].1 = enm(key, value); + cases.push(d); + } + let mut wrong_class = base.clone(); + wrong_class.items[0].1 = enm("unrelatedEnum", "cafSamplingRegionRectangular"); + cases.push(wrong_class); + for d in cases { + let source = item(d); + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert_eq!(imported.unsupported.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep"); + let saved = export("S", &[&imported.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0], source); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(imported.actions); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// Known native defaults become explicit values; unsupported defaults/options cannot disappear. +#[test] +fn caf_native_exports_preserve_effective_defaults_and_refuse_unrepresented_options() { + for p in [json!({"sampling":"rectangular","output":"new"}), json!({"sampling":"rectangular","output":"new","seed":1,"target":"pixels"}), params()] { + let a = load(exported(p).unwrap().descriptor.unwrap()); + assert_eq!(a.steps, vec![(CMD.into(), params())]); + } + let mut current = params(); + current["output"] = json!("current"); + for p in [json!({"sampling":"rectangular"}), current.clone()] { + let a = load(exported(p).unwrap().descriptor.unwrap()); + assert_eq!(a.steps, vec![(CMD.into(), current.clone())]); + } + for (key, value) in [ + ("sampling", json!("auto")), + ("sampling", json!("custom")), + ("sampling", json!("unknown")), + ("output", json!("duplicate")), + ("colorAdaptation", json!("none")), + ("colorAdaptation", json!("high")), + ("colorAdaptation", json!("veryHigh")), + ("rotationAdaptation", json!("low")), + ("rotationAdaptation", json!("medium")), + ("rotationAdaptation", json!("high")), + ("rotationAdaptation", json!("full")), + ("sampleAllLayers", json!(0)), + ("scale", Value::Null), + ("mirror", json!("true")), + ("seed", json!(2)), + ("layer", json!(1)), + ("margin", json!(10)), + ("area", json!([0, 0, 4, 4])), + ("channel", json!(0)), + ("samplingStrokes", json!([])), + ("fillEdits", json!([])), + ("target", json!("mask")), + ("_atnCafDescriptor", Value::Null), + ] { + let mut p = params(); + p[key] = value; + assert!(exported(p.clone()).is_none(), "{p}"); + } + for p in [json!({}), json!({"output":"new"}), json!([])] { + assert!(exported(p.clone()).is_none(), "unrepresented native default: {p}"); + } +} + +fn session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap(); + s.execute("edit.fill", json!({"contents":"foreground"})).unwrap(); + s +} + +/// Compare the complete small fixture, including pixels outside the selected fill area. +fn assert_same_pixels(actual: &Surface, expected: &Surface, depth: u32, output: &str) { + for y in 0..8 { + for x in 0..8 { + assert_eq!(actual.rgba(x, y), expected.rgba(x, y), "{depth}/{output}: pixel{x},{y}"); + } + } +} + +/// Native and imported fills produce the same real pixels, source/selection state and Undo/Redo. +#[test] +fn caf_action_matches_native_pixels_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let before = s.active().unwrap().doc.clone(); + let source = s.active().unwrap().active_layer.unwrap(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.push(load(descriptor())); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + assert_eq!(result["ran"], 1); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.selection, before.selection); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + assert_eq!(after.layer(source), before.layer(source), "new output keeps its source unchanged"); + let id = s.active().unwrap().active_layer.unwrap(); + let pixels = after.layer(id).unwrap().surface().unwrap(); + let center = pixels.rgba(3, 3); + assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: hole was not filled: {center:?}"); + assert_eq!(pixels.rgba(0, 0)[3], 0.0, "new output is transparent outside the selection"); + let mut native = session(depth); + native.execute(CMD, params()).unwrap(); + let expected = &native.active().unwrap().doc; + assert_eq!(after.layers.len(), expected.layers.len()); + for (a, b) in after.layers.iter().zip(&expected.layers) { + let (a, b) = (a.surface().unwrap(), b.surface().unwrap()); + assert_same_pixels(a, b, depth, "new"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + + // Current Layer keeps one layer and matches the ordinary native operation as well. + let mut current = session(depth); + let mut p = params(); + p["output"] = json!("current"); + current.execute(CMD, p).unwrap(); + assert_eq!(current.active().unwrap().doc.layers.len(), 1); + let center = current.active().unwrap().doc.layers[0].surface().unwrap().rgba(3, 3); + assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: native Current Layer control failed: {center:?}"); + let mut recorded = session(depth); + let before = recorded.active().unwrap().doc.clone(); + let source = recorded.active().unwrap().active_layer.unwrap(); + let past = recorded.active().unwrap().history.past_len(); + let mut d = descriptor(); + d.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer"); + recorded.actions.list.push(load(d)); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len()); + assert_eq!(after.selection, before.selection); + assert_eq!(recorded.active().unwrap().active_layer, Some(source)); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + let expected = current.active().unwrap().doc.layers[0].surface().unwrap(); + let actual = after.layer(source).unwrap().surface().unwrap(); + assert_same_pixels(actual, expected, depth, "current"); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc, before); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc, after); + } +} + +/// A translucent active layer over white makes layer-only and composite samples differ. +fn multilayer_session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Translucent sample"})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("Translucent sample pixels", |doc, _| { + let bounds = doc.bounds(); + doc.layer_mut(layer).unwrap().surface_mut().unwrap().write_region(bounds, &[0.0, 0.0, 1.0, 0.5].repeat(64)); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap(); + s +} + +/// Current Layer preserves both sampling choices; distinct native controls detect a forced false. +#[test] +fn caf_current_layer_sampling_matches_distinct_multilayer_controls() { + for depth in [8, 16, 32] { + let mut native_centers = Vec::new(); + for all_layers in [false, true] { + let mut native = multilayer_session(depth); + let mut p = params(); + p["output"] = json!("current"); + p["sampleAllLayers"] = json!(all_layers); + native.execute(CMD, p).unwrap(); + let native_id = native.active().unwrap().active_layer.unwrap(); + let expected = native.active().unwrap().doc.layer(native_id).unwrap().surface().unwrap(); + native_centers.push(expected.rgba(3, 3)); + + let mut recorded = multilayer_session(depth); + let before = recorded.active().unwrap().doc.clone(); + let source = recorded.active().unwrap().active_layer.unwrap(); + let past = recorded.active().unwrap().history.past_len(); + let mut d = descriptor(); + d.items[1].1 = D::Boolean(all_layers); + d.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer"); + recorded.actions.list.push(load(d)); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}/{all_layers}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), 2); + assert_eq!(after.selection, before.selection); + assert_eq!(recorded.active().unwrap().active_layer, Some(source)); + // One `Action:` entry when the play changed the document, none when it didn't: a fill + // from the uniform layer alone can rewrite it with the same pixels (#10944). + let changed = usize::from(after != before); + assert_eq!(recorded.active().unwrap().history.past_len(), past + changed); + let actual = after.layer(source).unwrap().surface().unwrap(); + assert_same_pixels(actual, expected, depth, "current multilayer"); + assert_eq!(actual.rgba(0, 0), before.layer(source).unwrap().surface().unwrap().rgba(0, 0)); + for layer in before.layers.iter().filter(|layer| layer.id != source) { + assert_eq!(after.layer(layer.id), Some(layer)); + } + if changed == 1 { + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc, before); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc, after); + } + } + assert_ne!(native_centers[0], native_centers[1], "{depth}: fixture must distinguish layer-only from composite sampling"); + } +} + +/// The adapter cannot manufacture a selection or turn a refused native operation into an edit. +#[test] +fn caf_action_without_selection_fails_without_an_edit() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.push(load(descriptor())); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); +} diff --git a/crates/engine/tests/it/atn_dialog_filters.rs b/crates/engine/tests/it/atn_dialog_filters.rs new file mode 100644 index 0000000000..0e1b925820 --- /dev/null +++ b/crates/engine/tests/it/atn_dialog_filters.rs @@ -0,0 +1,173 @@ +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn encoded(code: &[u8; 4], d: Descriptor) -> Vec { + atn::write(&AtnFile { + actions: vec![AtnAction { + name: UnicodeString::new_nul("Dialog"), + items: vec![AtnItem { event: AtnEvent::Code(*code), descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// Pair measured descriptors with native params, including signed Fibers seed bits. +fn cases() -> Vec<([u8; 4], Descriptor, &'static str, Value)> { + let kernel: Vec = (0..25).map(|i| if i == 12 { 1 } else { 0 }).collect(); + let emboss: Vec = (0..25) + .map(|i| match i { + 11 => -1, + 13 => 1, + _ => 0, + }) + .collect(); + vec![ + ( + *b"Cstm", + Descriptor::new("null") + .with("Scl ", D::Integer(1)) + .with("Ofst", D::Integer(17)) + .with("Mtrx", D::List(kernel.iter().copied().map(D::Integer).collect())), + "filter.other.custom", + json!({"kernel":kernel,"scale":1,"offset":17}), + ), + ( + *b"Cstm", + Descriptor::new("null") + .with("Scl ", D::Integer(1)) + .with("Ofst", D::Integer(17)) + .with("Mtrx", D::List(emboss.iter().copied().map(D::Integer).collect())), + "filter.other.custom", + json!({"kernel":emboss,"scale":1,"offset":17}), + ), + ( + *b"Fbrs", + Descriptor::new("null").with("Vrnc", D::Integer(64)).with("Strg", D::Integer(1)).with("RndS", D::Integer(-1)), + "filter.render.fibers", + json!({"variance":64,"strength":1,"seed":u32::MAX}), + ), + ] +} + +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":7,"height":5,"depth":depth,"background":"#4080c0"})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#cc2233","background":"#112288"})).unwrap(); + s +} + +/// Compare imported actions with native execution across depths, then verify descriptor save and Undo. +#[test] +fn custom_and_fibers_recordings_round_trip_replay_native_settings_and_undo() { + for (code, d, id, p) in cases() { + let imported = atn_cmds::import(&encoded(&code, d.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].0, id); + let (out, skipped) = atn_cmds::export("Dialog", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + for depth in [8, 16, 32] { + let mut native = fixture(depth); + native.execute(id, p.clone()).unwrap(); + let mut s = fixture(depth); + let before = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + s.actions.list = imported.actions.clone(); + assert_eq!(s.execute("actions.play", json!({"action":"Dialog"})).unwrap()["ran"], 1); + assert_eq!( + photocraft_compose::flatten(&s.active().unwrap().doc).px, + photocraft_compose::flatten(&native.active().unwrap().doc).px, + "{id} {depth}-bit" + ); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(), before); + } + } +} + +/// A missing Fibers seed is captured once, then replay and Save Actions reuse the same bits. +#[test] +fn fibers_missing_seed_is_stable_across_playback_and_save() { + let d = Descriptor::new("null").with("Vrnc", D::Integer(16)).with("Strg", D::Integer(4)); + let imported = atn_cmds::import(&encoded(b"Fbrs", d)).unwrap(); + assert!(imported.unsupported.is_empty()); + let p = &imported.actions[0].steps[0].1; + let seed = i32::try_from(p["seed"].as_i64().unwrap()).unwrap(); + let (out, skipped) = atn_cmds::export("Dialog", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("RndS"), Some(&D::Integer(seed))); + let saved = atn_cmds::import(&out).unwrap(); + assert_eq!(saved.actions[0].steps[0].1, *p); + let render = |actions| { + let mut s = fixture(8); + s.actions.list = actions; + s.execute("actions.play", json!({"action":"Dialog"})).unwrap(); + photocraft_compose::flatten(&s.active().unwrap().doc).px.clone() + }; + assert_eq!(render(imported.actions.clone()), render(saved.actions)); +} + +/// Default materialization remains native; unrepresented metadata stays on the preserved path. +#[test] +fn fibers_preserves_noncanonical_descriptors_without_rejecting_missing_defaults() { + for d in [Descriptor::new("null"), Descriptor::new("null").with("RndS", D::Integer(-1))] { + let imported = atn_cmds::import(&encoded(b"Fbrs", d)).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].0, "filter.render.fibers"); + let (out, skipped) = atn_cmds::export("Dialog", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = atn_cmds::import(&out).unwrap(); + assert_eq!(saved.actions[0].steps, imported.actions[0].steps); + } + let canonical = Descriptor::new("null").with("Vrnc", D::Integer(16)).with("Strg", D::Integer(4)).with("RndS", D::Integer(7)); + let mut named = canonical.clone(); + named.name = UnicodeString(vec![0xd800, 0]); + let mut class = canonical.clone(); + class.class_id = Id::Str(b"null".to_vec()); + let mut ordered = canonical.clone(); + ordered.items.swap(0, 1); + let mut key = canonical; + key.items[0].0 = Id::Str(b"Vrnc".to_vec()); + for d in [named, class, ordered, key] { + let imported = atn_cmds::import(&encoded(b"Fbrs", d.clone())).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (out, _) = atn_cmds::export("Dialog", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + } +} + +/// Malformed and unsupported forms remain complete steps instead of being partially interpreted. +#[test] +fn dialog_filters_keep_unrepresented_types_ranges_and_fields_whole() { + for (code, d, _, _) in cases() { + let changes = if code == *b"Cstm" { + vec![("Scl ", D::Integer(0)), ("Ofst", D::Integer(10000)), ("Mtrx", D::List(vec![D::Integer(0); 24])), ("Scl ", D::Double(1.5))] + } else { + vec![("Vrnc", D::Integer(0)), ("Strg", D::Integer(65)), ("RndS", D::Double(1.0))] + }; + for (key, value) in changes.into_iter().chain([("unknown", D::Boolean(true))]) { + let mut odd = d.clone(); + if let Some((_, slot)) = odd.items.iter_mut().find(|(k, _)| k.is(key)) { + *slot = value; + } else { + odd.items.push((Id::new(key), value)); + } + let a = atn_cmds::import(&encoded(&code, odd.clone())).unwrap(); + assert_eq!(a.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (out, _) = atn_cmds::export("Dialog", &[&a.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(odd)); + } + } + for (code, d, _, _) in cases() { + let a = atn_cmds::import(&encoded(&code, d)).unwrap(); + let key = if code == *b"Cstm" { "scale" } else { "variance" }; + for value in [json!(0), json!(10000), json!(1.5)] { + let mut edited = a.actions[0].clone(); + edited.steps[0].1[key] = value; + assert!(atn_cmds::export("Dialog", &[&edited], &[], false).is_err()); + } + } +} diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs new file mode 100644 index 0000000000..59b484022a --- /dev/null +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -0,0 +1,522 @@ +//! Measured layer-style action steps retain their settings and use the existing native commands. +use photocraft_doc::{Document, Effect, LayerId}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const SCALE: &str = "layer.layerStyle.scaleEffects"; +const CREATE: &str = "layer.layerStyle.createLayer"; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::default(), class_id: Id::new(id) } +} + +fn scale(value: f64) -> Descriptor { + Descriptor::new("null").with("Scl ", D::UnitFloat { unit: *b"#Prc", value }) +} + +fn create() -> Descriptor { + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with( + "Usng", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + ) +} + +fn item(id: &str, descriptor: Descriptor) -> AtnItem { + let (event, name) = if id == SCALE { (AtnEvent::name("scaleEffectsEvent"), "Scale Effects") } else { (AtnEvent::Code(*b"Mk "), "Make") }; + AtnItem { event, dictionary: name.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() } +} + +fn file(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + } +} + +fn action(items: Vec) -> Action { + let loaded = import(&atn::write(&file(items)).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + loaded.actions.into_iter().next().unwrap() +} + +fn exported(id: &str, params: Value) -> Option { + let a = Action::new("A", vec![(id.into(), params)]); + let bytes = export("S", &[&a], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() +} + +/// Wire bytes also compare preserved NaN payloads, whose floating values are not reflexively equal. +fn same_item_wire(mut actual: AtnItem, mut expected: AtnItem) { + assert_eq!(actual.descriptor.take().map(|d| d.to_bytes()), expected.descriptor.take().map(|d| d.to_bytes())); + assert_eq!(actual, expected); +} + +/// The direct control must actually scale the known one-pixel shadow, not merely accept the call. +fn scaled_shadow(s: &Session, source: LayerId, percent: f64) { + let shadow = s + .active() + .unwrap() + .doc + .layer(source) + .unwrap() + .effects + .items + .iter() + .find_map(|effect| match effect { + Effect::DropShadow(shadow) => Some(shadow), + _ => None, + }) + .unwrap(); + let factor = percent as f32 / 100.0; + assert_eq!((shadow.distance, shadow.size), (factor, factor)); +} + +/// A small painted source with two effects makes a real conversion differ from a blank layer. +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Source"})).unwrap(); + let source = s.active().unwrap().active_layer.unwrap(); + s.edit("Synthetic pixels", |doc, _| { + let pixels: Vec = + (0..8).flat_map(|y| (0..8).flat_map(move |x| if (2..6).contains(&x) && (2..6).contains(&y) { [0.2, 0.5, 0.9, 1.0] } else { [0.0; 4] })).collect(); + let bounds = doc.bounds(); + doc.layer_mut(source).unwrap().surface_mut().unwrap().write_region(bounds, &pixels); + Ok(()) + }) + .unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"blend":"normal","distance":1,"size":1,"opacity":60})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color":"#ff8000","opacity":50,"add":true})).unwrap(); + (s, source) +} + +/// Compare each complete raster and its style/stack state without assuming cross-session IDs. +fn equivalent(actual: &Session, expected: &Session) { + let a = &actual.active().unwrap().doc; + let b = &expected.active().unwrap().doc; + assert_eq!(a.layers.len(), b.layers.len()); + assert_eq!(a.selection, b.selection); + for (a, b) in a.layers.iter().zip(&b.layers) { + assert_eq!((&a.name, a.clipped, a.blend, a.opacity), (&b.name, b.clipped, b.blend, b.opacity)); + assert_eq!(a.effects, b.effects); + let (a, b) = (a.surface().unwrap(), b.surface().unwrap()); + assert_eq!(a.content_bounds(), b.content_bounds()); + let bounds = a.content_bounds().union(&b.content_bounds()); + assert_eq!(a.read_region(bounds), b.read_region(bounds)); + } +} + +/// Undo the expected playback history edits, then redo the complete document state. +fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) { + for _ in 0..edits { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), before); + for _ in 0..edits { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after); +} + +/// Playback must produce actual effect layers, not the blank layer the old Make branch creates. +#[test] +fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let (mut native, native_source) = fixture(depth); + let native_before = native.active().unwrap().doc.clone(); + native.execute(CREATE, json!({})).unwrap(); + let native_after = native.active().unwrap().doc.clone(); + assert_eq!(native_after.layers.len(), native_before.layers.len() + 2); + assert!(native_after.layer(native_source).unwrap().effects.items.is_empty()); + undo_redo(&mut native, &native_before, &native_after, 1); + + let (mut recorded, source) = fixture(depth); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(CREATE, create())])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 2, "{depth}: Create Layers must split two effects, not create one blank layer"); + assert!(after.layer(source).unwrap().effects.items.is_empty()); + let at = after.layers.iter().position(|l| l.id == source).unwrap(); + assert!(after.layers[at - 1].name.ends_with("Drop Shadow") && !after.layers[at - 1].clipped); + assert!(after.layers[at + 1].name.ends_with("Color Overlay") && after.layers[at + 1].clipped); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); + } +} + +/// The recorded whole-percent limits and interior values match native effect scaling at all depths. +#[test] +fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() { + for single_state in [false, true] { + for depth in [8, 16, 32] { + for percent in [1.0, 100.0, 200.0, 1000.0] { + let (mut native, native_source) = fixture(depth); + let native_past = native.active().unwrap().history.past_len(); + native.execute(SCALE, json!({"scale":percent})).unwrap(); + scaled_shadow(&native, native_source, percent); + let edits = native.active().unwrap().history.past_len() - native_past; + let (mut recorded, _) = fixture(depth); + recorded.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(SCALE, scale(percent))])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + // Grouped playback omits the unchanged 100% scale; per-step mode keeps native rows. + let playback_edits = if single_state { usize::from(percent != 100.0) } else { edits }; + assert_eq!(recorded.active().unwrap().history.past_len(), past + playback_edits, "singleState={single_state}, depth={depth}, scale={percent}"); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, playback_edits); + } + } + } +} + +/// Scaling then splitting keeps native pixels with one grouped or two separately reversible edits. +#[test] +fn scale_then_create_layers_matches_native_sequence() { + for single_state in [false, true] { + let (mut native, _) = fixture(16); + native.execute(SCALE, json!({"scale":200})).unwrap(); + native.execute(CREATE, json!({})).unwrap(); + let (mut recorded, _) = fixture(16); + recorded.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 2); + assert!(result.get("failed").is_none()); + let edits = if single_state { 1 } else { 2 }; + assert_eq!(recorded.active().unwrap().history.past_len(), past + edits); + let after = recorded.active().unwrap().doc.clone(); + equivalent(&recorded, &native); + if !single_state { + // Per-step playback must also preserve the native intermediate scaled-effects state. + assert!(native.undo()); + assert!(recorded.undo()); + equivalent(&recorded, &native); + assert!(native.redo()); + assert!(recorded.redo()); + } + undo_redo(&mut recorded, &before, &after, edits); + } +} + +/// Unedited wire identity/order/flags survive; a supported scale edit exports its new value. +#[test] +fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() { + for (id, mut d) in [(SCALE, scale(100.0)), (CREATE, create())] { + d.name = UnicodeString::new_nul("Recorded style step"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(id, d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let a = action(vec![source.clone()]); + assert_eq!(a.steps[0].0, id); + let bytes = export("S", &[&a], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnLayerStyleDescriptor"] = json!("not base64"); + assert!(exported(id, damaged).is_none()); + } + let mut d = scale(100.0); + d.name = UnicodeString::new_nul("Original"); + let mut a = action(vec![item(SCALE, d)]); + a.steps[0].1["scale"] = json!(200); + for skip in [false, true] { + let (bytes, skipped) = export("S", &[&a], &[], skip).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(scale(200.0))); + } +} + +/// Native defaults are explicit; unrepresented targets, options and damaged metadata cannot vanish. +#[test] +fn layer_style_native_exports_are_strict_and_make_defaults_explicit() { + assert_eq!(exported(SCALE, json!({})).unwrap().descriptor, Some(scale(100.0))); + let mut canonical_create = create(); + // The shared canonical class constructor writes nul-terminated display names; + // imported empty names remain separate wire data in the round-trip fixtures. + for (_, value) in &mut canonical_create.items { + let D::Reference(parts) = value else { panic!("fixture must contain references") }; + for part in parts { + let class = match part { + ReferenceItem::Class(class) | ReferenceItem::Property { class, .. } | ReferenceItem::Enumerated { class, .. } => class, + _ => panic!("fixture must contain class/property/target references"), + }; + class.name = UnicodeString::new_nul(""); + } + } + assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(canonical_create)); + for percent in [1, 100, 200, 1000] { + assert_eq!(exported(SCALE, json!({"scale":percent})).unwrap().descriptor, Some(scale(f64::from(percent)))); + } + for value in [json!(0), json!(1001), json!(1.5), Value::Null, json!("100"), json!(true)] { + assert!(exported(SCALE, json!({"scale":value})).is_none()); + } + for id in [SCALE, CREATE] { + for key in ["layer", "layers", "target", "unknown"] { + let mut p = json!({}); + p[key] = json!(1); + assert!(exported(id, p).is_none(), "{id}/{key}"); + } + let invalid = if id == SCALE { scale(100.0) } else { create() }.with("extra", D::Boolean(true)); + let mut p = if id == SCALE { json!({"scale":200}) } else { json!({}) }; + p["_atnLayerStyleDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes())); + assert!(exported(id, p).is_none()); + } + assert!(exported(CREATE, json!({"scale":100})).is_none()); +} + +/// Malformed style references remain whole and cannot fall through to ordinary blank-layer creation. +#[test] +fn layer_style_malformed_records_stay_kept_without_an_edit() { + let mut cases = vec![(SCALE, Descriptor::new("null")), (CREATE, Descriptor::new("null").with("Usng", create().items[1].1.clone()))]; + for value in [D::Integer(100), D::Double(100.0), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 100.0 }] { + cases.push((SCALE, Descriptor::new("null").with("Scl ", value))); + } + for value in [0.0, 1001.0, 1.5, f64::NAN, f64::INFINITY] { + cases.push((SCALE, scale(value))); + } + for (id, d) in [(SCALE, scale(100.0)), (CREATE, create())] { + cases.push((id, d.clone().with("extra", D::Boolean(true)))); + let mut duplicate = d.clone(); + duplicate.items.push(duplicate.items[0].clone()); + cases.push((id, duplicate)); + } + for null in [ + D::Integer(1), + D::Reference(vec![ReferenceItem::Class(class("Chnl"))]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]), + ] { + let mut d = create(); + d.items[0].1 = null; + cases.push((CREATE, d)); + } + for using in [ + D::Integer(1), + D::Boolean(false), + D::Descriptor(Descriptor::new("unknown")), + D::Reference(vec![]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("wrong") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("wrong"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Al ") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ReferenceItem::Class(class("Lyr ")), + ]), + ] { + let mut d = create(); + d.items[1].1 = using; + cases.push((CREATE, d)); + } + for (id, d) in cases { + let source = item(id, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{id}: {source:?}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// A miskeyed effects reference must be kept, rather than playing as an ordinary blank layer. +#[test] +fn miskeyed_effects_reference_stays_whole_without_a_blank_layer_edit() { + let mut d = create(); + d.items[1].0 = Id::new("Usin"); + let source = item(CREATE, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions.clone()); + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + // Play before checking import classification: the old path actually creates a blank layer. + assert!( + s.active().unwrap().doc == before, + "miskeyed Lefx reference changed the document: layers {} -> {}, history {} -> {}", + before.layers.len(), + s.active().unwrap().doc.layers.len(), + past, + s.active().unwrap().history.past_len() + ); + assert_eq!(s.active().unwrap().history.past_len(), past); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); +} + +/// A normal layer Make, including class-only Make, still creates a blank layer without splitting fx. +#[test] +fn ordinary_layer_make_remains_a_legitimate_control() { + for using in [None, Some(D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary")))))] { + let mut d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])); + if let Some(u) = using { + d = d.with("Usng", u); + } + let a = action(vec![item(CREATE, d)]); + assert_eq!(a.steps[0].0, "layer.new.layer"); + let (mut s, source) = fixture(8); + let before = s.active().unwrap().doc.clone(); + s.actions.list.push(a); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + undo_redo(&mut s, &before, &after, 1); + } +} + +/// Every typed container must retain nested effects intent without changing the document or history. +#[test] +fn nested_effects_references_stay_whole_without_a_blank_layer_edit() { + let reference = create().items.pop().unwrap().1; + let nested = Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Before"))).with("data", reference.clone()); + let mut deepest = reference.clone(); + // Root descriptor depth0, Usng value depth1, options descriptor depth2, data value depth3. + for _ in 3..MAX_DEPTH { + deepest = D::List(vec![D::Boolean(false), deepest]); + } + let cases = [ + ("reference", reference.clone()), + ("descriptor", D::Descriptor(nested.clone())), + ("global object", D::GlobalObject(nested.clone())), + ("list", D::List(vec![D::Boolean(false), reference])), + ("object array", D::ObjectArray(ObjectArray { prefix: 16, body: nested.clone() })), + ( + "mixed containers", + D::List(vec![D::Boolean(false), D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested })))]), + ), + ("parser depth limit", deepest), + ]; + for (label, value) in cases { + for key in ["Usng", "Usin"] { + let d = Descriptor::new("null") + .with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])) + .with(key, D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone()))); + let source = item(CREATE, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions.clone()); + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + // Playback precedes classification so the baseline exposes the actual blank-layer edit. + assert!( + s.active().unwrap().doc == before, + "{label}/{key}: nested Lefx changed the document: layers {} -> {}, history {} -> {}", + before.layers.len(), + s.active().unwrap().doc.layers.len(), + past, + s.active().unwrap().history.past_len() + ); + assert_eq!(s.active().unwrap().history.past_len(), past, "{label}/{key}"); + assert_eq!(result["ran"], 0, "{label}/{key}"); + assert!(result.get("failed").is_some(), "{label}/{key}"); + assert_eq!(loaded.unsupported.len(), 1, "{label}/{key}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep", "{label}/{key}"); + let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty(), "{label}/{key}"); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1, "{label}/{key}"); + same_item_wire(saved.into_iter().next().unwrap(), source); + } + } +} + +/// Native layer options still play with Lefx as a name or in synthetic opaque metadata. +#[test] +fn ordinary_layer_make_native_export_matches_native_playback() { + let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true}); + let opaque = D::List(vec![D::RawData(create().to_bytes()), D::Alias(b"Lefx".to_vec()), D::Path(b"Lefx".to_vec()), D::Text(UnicodeString::new_nul("Lefx"))]); + for metadata in [None, Some(opaque)] { + let mut source = exported("layer.new.layer", params.clone()).unwrap(); + if let Some(value) = metadata { + let D::Descriptor(options) = &mut source.descriptor.as_mut().unwrap().items[1].1 else { panic!("native layer options") }; + options.items.push((Id::new("data"), value)); + } + let a = action(vec![source]); + assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone())); + let (mut native, _) = fixture(8); + native.execute("layer.new.layer", params.clone()).unwrap(); + let (mut recorded, source) = fixture(8); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(a); + let result = recorded.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); + } +} diff --git a/crates/engine/tests/it/atn_newer_events.rs b/crates/engine/tests/it/atn_newer_events.rs index acecf5ec7e..302a0545c1 100644 --- a/crates/engine/tests/it/atn_newer_events.rs +++ b/crates/engine/tests/it/atn_newer_events.rs @@ -680,10 +680,15 @@ fn artboard_edit_descriptor(background: i32, change_sizes: i32) -> Descriptor { fn artboard_background_edit_roundtrips_without_replaying_recorded_geometry() { for (kind, expected) in [(1, [1.0; 4]), (2, [0.0, 0.0, 0.0, 1.0]), (3, [0.0; 4]), (4, [0.25, 0.5, 0.75, 1.0])] { let mut s = artboard_fixture(); - s.execute("layer.new.artboardFromGroup", json!({})).unwrap(); + let initial_background = if kind == 1 { "black" } else { "white" }; + s.execute("layer.new.artboardFromGroup", json!({"background":initial_background})).unwrap(); Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.horizontal.push(3.0); let before = s.active().unwrap().doc.clone(); let id = s.active().unwrap().active_layer.unwrap(); + assert_eq!( + before.layer(id).unwrap().artboard().unwrap().background, + if kind == 1 { photocraft_doc::ArtboardBackground::Black } else { photocraft_doc::ArtboardBackground::White } + ); let history = s.active().unwrap().history.past_len(); let d = artboard_edit_descriptor(kind, 8); assert_eq!(load(&mut s, &file("editArtboardEvent", d.clone()))["unsupported"], json!([])); @@ -1010,7 +1015,7 @@ fn shape_blur_radius_boundaries_keep_their_value_without_running_large_kernels() } #[test] -fn shape_blur_keeps_custom_assets_and_invalid_radius_forms_whole() { +fn shape_blur_keeps_unrepresented_assets_and_invalid_radius_forms_whole() { let custom = Descriptor::new("customShape").with("Nm ", D::Text(UnicodeString::new_nul("Star"))).with("Idnt", D::Text(UnicodeString::new_nul("asset-id"))); for descriptor in [ radius_descriptor(4.0), @@ -1018,7 +1023,8 @@ fn shape_blur_keeps_custom_assets_and_invalid_radius_forms_whole() { radius_descriptor(f64::NAN), radius_descriptor(f64::INFINITY), Descriptor::new("shapeBlur").with("Rds ", D::UnitFloat { unit: *b"#Prc", value: 10.0 }), - radius_descriptor(10.0).with("customShape", D::Descriptor(custom)).with("keyOriginType", D::Integer(9)), + // Origin 9 is supported by ID; other origin forms must remain whole. + radius_descriptor(10.0).with("customShape", D::Descriptor(custom)).with("keyOriginType", D::Integer(8)), radius_descriptor(10.0).with("extra", D::Boolean(true)), ] { let mut s = Session::new(); diff --git a/crates/engine/tests/it/atn_perspective_warp.rs b/crates/engine/tests/it/atn_perspective_warp.rs new file mode 100644 index 0000000000..2967285842 --- /dev/null +++ b/crates/engine/tests/it/atn_perspective_warp.rs @@ -0,0 +1,202 @@ +//! The measured Perspective Warp descriptor shape through ATN conversion and action playback. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_geom::Rect; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const EVENT: &str = "perspectiveWarpTransform"; +const COMMAND: &str = "edit.perspectiveWarp"; + +fn px(value: f64) -> D { + D::UnitFloat { unit: *b"#Pxl", value } +} + +fn points(points: &[[f64; 2]]) -> D { + D::List(points.iter().map(|&[x, y]| D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", px(x)).with("Vrtc", px(y)))).collect()) +} + +fn quad(indices: [i32; 4]) -> D { + D::Descriptor(Descriptor::new("perspectiveWarpQuad").with("indices", D::List(indices.into_iter().map(D::Integer).collect()))) +} + +/// Two planes sharing a seam. Vertices appear out of corner order to distinguish exact export. +fn fixture(name: Option<&str>) -> Descriptor { + let class = Class { class_id: Id::new("Lyr "), name: UnicodeString::new_nul("") }; + let target = match name { + Some(name) => ReferenceItem::Name { class, name: UnicodeString::new_nul(name) }, + None => ReferenceItem::Enumerated { class, type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + }; + let rect = Descriptor::new("Rctn").with("Top ", px(0.0)).with("Left", px(0.0)).with("Btom", px(24.0)).with("Rght", px(32.0)); + Descriptor::new(EVENT) + .with("null", D::Reference(vec![target])) + .with("Md ", D::Enumerated { type_id: Id::new("perspectiveWarpMode"), value: Id::new("warp") }) + .with("referenceRect", D::Descriptor(rect)) + .with("vertices", points(&[[16.0, 20.0], [4.0, 4.0], [28.0, 4.0], [4.0, 20.0], [16.0, 4.0], [28.0, 20.0]])) + .with("warpedVertices", points(&[[17.0, 20.0], [5.5, 5.0], [27.0, 6.0], [4.0, 19.0], [18.0, 4.0], [26.0, 17.0]])) + .with("quads", D::List(vec![quad([1, 4, 0, 3]), quad([4, 2, 5, 0])])) +} + +fn imported(d: Descriptor, with_dialog: bool, enabled: bool) -> atn_cmds::Imported { + let item = + AtnItem { event: AtnEvent::name(EVENT), dictionary: b"Perspective Warp".to_vec(), descriptor: Some(d), with_dialog, enabled, ..Default::default() }; + let file = AtnFile { + name: UnicodeString::new_nul("Set"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Warp"), items: vec![item], ..Default::default() }], + ..Default::default() + }; + atn_cmds::import(&atn::write(&file).unwrap()).unwrap() +} + +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Set", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions[0].items[0].clone() +} + +fn planes() -> Value { + json!([ + {"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5.5,5],[18,4],[17,20],[4,19]]}, + {"src":[[16,4],[28,4],[28,20],[16,20]],"dst":[[18,4],[27,6],[26,17],[17,20]]} + ]) +} + +fn value_mut<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut D { + &mut d.items.iter_mut().find(|(id, _)| id.is(key)).unwrap().1 +} + +/// Exact export preserves the original indexed mesh, target, rectangle and step flags. +#[test] +fn descriptor_round_trip_preserves_shared_indices_and_flags() { + for name in [None, Some("Façade")] { + let d = fixture(name); + let loaded = imported(d.clone(), true, false); + assert!(loaded.unsupported.is_empty()); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, COMMAND); + let params = &action.steps[0].1; + assert_eq!(params["planes"], planes()); + assert_eq!(params["layer"].as_str(), name); + assert_eq!(params["target"], "pixels"); + assert_eq!(params["__atnPerspectiveWarpRect"], json!([0, 0, 32, 24])); + let item = saved(action); + assert_eq!(item.event.id(), EVENT); + assert_eq!(item.descriptor.unwrap().to_bytes(), d.to_bytes()); + assert!(item.with_dialog); + assert!(!item.enabled); + } +} + +/// Unsupported targets, units, modes and geometry retain the original bytes as kept steps. +#[test] +fn unsupported_recordings_are_kept_without_partial_mapping() { + let d = fixture(None); + let mut layout = d.clone(); + *value_mut(&mut layout, "Md ") = D::Enumerated { type_id: Id::new("perspectiveWarpMode"), value: Id::new("layout") }; + let mut wrong_target = d.clone(); + *value_mut(&mut wrong_target, "null") = D::Reference(vec![ReferenceItem::Class(Class { class_id: Id::new("Chnl"), name: UnicodeString::new_nul("") })]); + let mut bad_index = d.clone(); + *value_mut(&mut bad_index, "quads") = D::List(vec![quad([1, 4, 99, 3])]); + let mut wrong_units = d.clone(); + let D::Descriptor(rect) = value_mut(&mut wrong_units, "referenceRect") else { panic!("reference rectangle") }; + *value_mut(rect, "Rght") = D::UnitFloat { unit: *b"#Prc", value: 32.0 }; + let mut incompatible_topology = d.clone(); + for key in ["vertices", "warpedVertices"] { + let D::List(points) = value_mut(&mut incompatible_topology, key) else { panic!("vertex list") }; + points.push(points[4].clone()); + } + *value_mut(&mut incompatible_topology, "quads") = D::List(vec![quad([1, 4, 0, 3]), quad([6, 2, 5, 0])]); + let mut duplicate = d.clone(); + duplicate.items.push((Id::new("quads"), d.get("quads").unwrap().clone())); + for descriptor in [layout, wrong_target, bad_index, wrong_units, incompatible_topology, duplicate] { + let loaded = imported(descriptor.clone(), false, true); + assert_eq!(loaded.unsupported.len(), 1, "{descriptor:?}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), descriptor.to_bytes()); + } +} + +/// Native export needs a reference rectangle. A stale recorded topology must not be substituted. +#[test] +fn native_export_requires_known_rectangle_and_rejects_stale_source() { + let mut action = Action::new("Warp", vec![(COMMAND.into(), json!({"planes": planes()}))]); + assert!(atn_cmds::export("Set", &[&action], &[], false).is_err()); + action.steps[0].1["__atnPerspectiveWarpRect"] = json!([0, 0, 32, 24]); + let d = saved(&action).descriptor.unwrap(); + let keys: Vec<_> = d.items.iter().map(|(id, _)| id.as_bytes()).collect(); + assert_eq!(keys, [b"null".as_slice(), b"Md ", b"referenceRect", b"vertices", b"warpedVertices", b"quads"]); + let mut recorded = imported(d, false, true).actions.remove(0); + recorded.steps[0].1["planes"][0]["dst"][0][0] = json!(6); + assert!(atn_cmds::export("Set", &[&recorded], &[], false).is_err()); + action.steps[0].1["interpolation"] = json!("nearest"); + assert!(atn_cmds::export("Set", &[&action], &[], false).is_err()); +} + +fn session(depth: u8) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("Fixture", |doc, _| { + let layer = doc.layer_mut(id).unwrap(); + layer.name = "Facade".into(); + let surf = layer.surface_mut().unwrap(); + let fmt = surf.format(); + surf.fill_rect(Rect::new(4, 4, 16, 20), &photocraft_raster::from_rgba(&fmt, [0.9, 0.2, 0.1, 1.0])); + surf.fill_rect(Rect::new(16, 4, 28, 20), &photocraft_raster::from_rgba(&fmt, [0.1, 0.2, 0.9, 1.0])); + Ok(()) + }) + .unwrap(); + (s, id) +} + +/// A named raster is warped, while the active decoy stays unchanged. +#[test] +fn action_playback_matches_the_native_raster_warp() { + for depth in [8, 16, 32] { + let (mut actual, id) = session(depth); + let (mut direct, did) = session(depth); + let before = actual.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone(); + for s in [&mut actual, &mut direct] { + s.execute("layer.new.layer", json!({})).unwrap(); + } + let decoy = actual.active().unwrap().active_layer.unwrap(); + let decoy_before = actual.active().unwrap().doc.layer(decoy).unwrap().clone(); + actual.actions.list.extend(imported(fixture(Some("Facade")), false, true).actions); + actual.execute("actions.play", json!({"action":0})).unwrap(); + direct.execute(COMMAND, json!({"planes":planes(),"layer":"Facade","target":"pixels"})).unwrap(); + let result = actual.active().unwrap().doc.layer(id).unwrap().surface().unwrap(); + assert_ne!(*result, before); + assert_eq!(result, direct.active().unwrap().doc.layer(did).unwrap().surface().unwrap()); + assert_eq!(actual.active().unwrap().doc.layer(decoy), Some(&decoy_before)); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(actual.active().unwrap().doc.layer(id).unwrap().surface(), Some(&before)); + } +} + +/// The same active-layer recording adds a native Smart Filter and keeps PSD export available. +#[test] +fn active_smart_object_playback_matches_the_native_filter() { + let (mut actual, _) = session(8); + let (mut direct, _) = session(8); + for s in [&mut actual, &mut direct] { + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + } + actual.actions.list.extend(imported(fixture(None), false, true).actions); + actual.execute("actions.play", json!({"action":0})).unwrap(); + direct.execute("layer.smartObjects.perspectiveWarp", json!({"planes":planes()})).unwrap(); + let [actual, direct] = [&actual, &direct].map(|s| { + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart object") }; + sm + }); + assert_eq!(actual.cache, direct.cache); + assert_eq!(actual.smart_filters.len(), 1); + assert_eq!(actual.smart_filters[0].command, COMMAND); + assert_eq!(actual.smart_filters[0].params["planes"], planes()); + assert!(photocraft_io::smart_map::item_for_filter(&actual.smart_filters[0]).is_ok()); +} diff --git a/crates/engine/tests/it/atn_review_regressions.rs b/crates/engine/tests/it/atn_review_regressions.rs new file mode 100644 index 0000000000..aabe913b22 --- /dev/null +++ b/crates/engine/tests/it/atn_review_regressions.rs @@ -0,0 +1,1078 @@ +//! Public-API regressions for the dialog-event and Batch review findings. +use photocraft_doc::LayerContent; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +/// A bounded synthetic document for command and channel comparisons. +fn session(mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":5,"height":5,"mode":mode,"background":"#804020"})).unwrap(); + s +} + +/// Encode one real ATN event, preserving its enabled state. +fn encoded(code: [u8; 4], d: Descriptor, enabled: bool) -> Vec { + atn::write(&AtnFile { + actions: vec![AtnAction { + name: UnicodeString::new_nul("Review"), + items: vec![AtnItem { event: AtnEvent::Code(code), descriptor: Some(d), enabled, ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// The supported UTF-16 path representation, without platform-specific Alias bytes. +fn path(value: &str) -> D { + let units: Vec<_> = value.encode_utf16().chain(Some(0)).collect(); + let mut bytes = b"txtu".to_vec(); + bytes.extend_from_slice(&((12 + 2 * units.len()) as u32).to_le_bytes()); + bytes.extend_from_slice(&(units.len() as u32).to_le_bytes()); + for unit in units { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + D::Path(bytes) +} + +/// A named descriptor reference with an empty display-class name. +fn named(class: &str, name: &str) -> ReferenceItem { + ReferenceItem::Name { class: Class { name: UnicodeString::new_nul(""), class_id: Id::new(class) }, name: UnicodeString::new_nul(name) } +} + +/// Recover the first saved descriptor using the public exporter and parser. +fn exported(action: &Action) -> Descriptor { + let (bytes, skipped) = atn_cmds::export("Parent", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0).descriptor.unwrap() +} + +/// A three-entry Mesh shaper must affect the input, while an even ramp preserves identity. +#[test] +fn mesh_three_value_shaper_is_applied() { + for (shaper, expected) in [("0 0 1023", 0.0), ("0 511.5 1023", 0.5)] { + let mut text = format!("3DMESH\nMesh 1 12\n{shaper}\n"); + for r in [0, 2048, 4095] { + for g in [0, 2048, 4095] { + for b in [0, 2048, 4095] { + text.push_str(&format!("{r} {g} {b}\n")); + } + } + } + let lut = photocraft_cms::lutfile::parse_3dl(&text).unwrap(); + let mid = ((lut.size / 2 * lut.size + lut.size / 2) * lut.size + lut.size / 2) * 3; + assert!((lut.data[mid] - expected).abs() < 0.002, "{shaper}: {}", lut.data[mid]); + } +} + +/// An untouched one-name Batch keeps all original bytes; an edit keeps its unqualified lookup. +#[test] +fn batch_untouched_reference_and_edited_set_are_distinct() { + let d = Descriptor::new("Btch").with("null", path("input")).with("T ", path("output")).with("Usng", D::Reference(vec![named("Actn", "Child")])); + let imported = atn_cmds::import(&encoded(*b"Btch", d.clone(), true)).unwrap(); + assert!(imported.unsupported.is_empty()); + let mut action = imported.actions[0].clone(); + assert_eq!(exported(&action), d); + action.steps[0].1["action"] = json!("Edited"); + let edited = exported(&action); + let D::Reference(names) = edited.get("Usng").unwrap() else { panic!("action reference") }; + assert_eq!(*names, vec![named("Actn", "Edited")]); +} + +/// Native Batch's optional false flag must export, but malformed values must not. +#[test] +fn batch_missing_override_save_as_exports_false() { + let p = json!({"input":"in","output":"out","action":"Child","stopOnError":true,"includeNonImages":true,"namingComponents":[{"component":"name"},{"component":"extension"}]}); + let action = Action::new("Review", vec![("file.automate.batch".into(), p.clone())]); + assert_eq!(exported(&action).get("Ovrd"), Some(&D::Boolean(false))); + let mut invalid = p; + invalid["overrideSaveAs"] = json!(17); + assert!(atn_cmds::export("Parent", &[&Action::new("Bad", vec![("file.automate.batch".into(), invalid)])], &[], false).is_err()); +} + +/// Unknown outer Calculations metadata stays a whole disabled step instead of changing on save. +#[test] +fn calculations_outer_descriptor_metadata_is_preserved() { + let action = Action::new( + "Calculate", + vec![("image.calculations".into(), json!({"source1":{"channel":{"color":"red"}},"source2":{"channel":{"color":"red"}},"blending":"add"}))], + ); + let supported = exported(&action); + for change_name in [false, true] { + let mut d = supported.clone(); + if change_name { + d.name = UnicodeString::new_nul("Outer name"); + } else { + d.class_id = Id::new("Unrepresented"); + } + let imported = atn_cmds::import(&encoded(*b"Mk ", d.clone(), false)).unwrap(); + assert!(!imported.unsupported.is_empty(), "outer metadata was silently admitted"); + let (bytes, _) = atn_cmds::export("Parent", &[&imported.actions[0]], &[], false).unwrap(); + let item = &atn::parse(&bytes).unwrap().actions[0].items[0]; + assert_eq!(item.descriptor, Some(d)); + assert!(!item.enabled); + } +} + +/// Custom export refuses non-object values instead of inventing an identity filter. +#[test] +fn custom_non_object_params_refuse_export() { + for params in [Value::Null, json!(3), json!(true), json!([])] { + assert!(atn_cmds::export("Parent", &[&Action::new("Bad", vec![("filter.other.custom".into(), params)])], &[], false).is_err()); + } + exported(&Action::new("Identity", vec![("filter.other.custom".into(), json!({}))])); +} + +/// Accepted main encodings still map and play, while an unchanged save retains their exact descriptor. +#[test] +fn custom_supported_representations_map_and_save_losslessly() { + let kernel = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + let mut metadata = Descriptor::new("null").with("Mtrx", kernel.clone()).with("Scl ", D::Integer(2)); + metadata.name = UnicodeString(vec![0xd800, 0]); + metadata.class_id = Id::Str(b"null".to_vec()); + for d in [ + Descriptor::new("null").with("Scl", D::Integer(3)).with("Mtrx", kernel.clone()), + Descriptor::new("null").with("Mtrx", kernel.clone()).with("Scl ", D::Double(2.0)), + Descriptor::new("Cstm").with("Ofst", D::Double(0.0)).with("Mtrx", kernel).with("Scl ", D::Integer(1)), + metadata, + ] { + let imported = atn_cmds::import(&encoded(*b"Cstm", d.clone(), true)).unwrap(); + assert!(imported.unsupported.is_empty()); + let mut action = imported.actions[0].clone(); + assert_eq!(action.steps[0].0, "filter.other.custom"); + assert_eq!(exported(&action), d); + let mut native = session("rgb"); + native.execute("filter.other.custom", action.steps[0].1.clone()).unwrap(); + let mut played = session("rgb"); + played.actions.list.push(action.clone()); + played.execute("actions.play", json!({"action":action.name})).unwrap(); + let commands: Vec<_> = played.journal.iter().filter(|(id, _)| id == "filter.other.custom").collect(); + assert!(!commands.is_empty()); + assert!(commands.iter().all(|(_, params)| params.get("__atnCustom").is_none())); + let (id, last) = photocraft_engine::filters::last_filter(&played).unwrap(); + assert_eq!(id, "filter.other.custom"); + assert!(last.get("__atnCustom").is_none()); + assert!(played.actions.list[0].steps[0].1.get("__atnCustom").is_some()); + assert_eq!(exported(&played.actions.list[0]), d, "Save Actions still owns the original descriptor"); + assert_eq!(photocraft_compose::flatten(&played.active().unwrap().doc).px, photocraft_compose::flatten(&native.active().unwrap().doc).px); + action.steps[0].1["scale"] = json!(4); + assert_eq!(exported(&action).get("Scl "), Some(&D::Integer(4)), "editing must not reuse stale original metadata"); + } +} + +/// Duplicate kernels are ambiguous even when both lists are valid or use different wire ID kinds. +#[test] +fn custom_duplicate_kernel_fields_are_kept_with_their_enabled_state() { + let kernel = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + for key in [Id::new("Mtrx"), Id::Str(b"Mtrx".to_vec())] { + for enabled in [false, true] { + for second in [kernel.clone(), D::List(vec![D::Integer(0); 25])] { + let mut d = Descriptor::new("null").with("Mtrx", kernel.clone()); + d.items.push((key.clone(), second)); + let imported = atn_cmds::import(&encoded(*b"Cstm", d.clone(), enabled)).unwrap(); + assert_eq!(imported.unsupported.len(), 1); + let action = &imported.actions[0]; + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(action.disabled.contains(&0), !enabled); + assert_eq!(exported(action), d); + let (bytes, _) = atn_cmds::export("Parent", &[action], &[], false).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].enabled, enabled); + } + } + } +} + +/// Cached and cache-less shapes expose identical component and composite Calculations planes. +#[test] +fn calculations_uncached_shape_matches_cached_pixels() { + for channel in [json!({"color":0}), json!("composite")] { + let result = |drop_cache| { + let mut s = session("rgb"); + let id = s.execute("shape.create", json!({"kind":"rect","rect":[1,1,3,3],"fill":"#cc3333"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("Source shape", |doc, _| { + let fmt = doc.pixel_format(); + let area = doc.bounds(); + let layer = doc.layer_mut(photocraft_doc::LayerId(id)).unwrap(); + layer.name.clear(); + if let LayerContent::Shape(shape) = &mut layer.content { + shape.cache = if drop_cache { None } else { Some(photocraft_vector::render_shape(shape, fmt, area)) }; + } + Ok(()) + }) + .unwrap(); + let source = json!({"layer":{"name":""},"channel":channel}); + s.execute("image.calculations", json!({"source1":source,"source2":source,"blending":"multiply"})).unwrap(); + let doc = &s.active().unwrap().doc; + doc.channels[0].surface.read_region(doc.bounds()) + }; + assert_eq!(result(false), result(true)); + } +} + +/// Targeted Gray and an explicit Gray selector resolve the same single source plane. +#[test] +fn calculations_targeted_gray_matches_explicit_gray() { + let result = |channel| { + let mut s = session("gray"); + s.execute("image.calculations", json!({"source1":{"channel":channel},"source2":{"channel":"gray"},"blending":"multiply"})).unwrap(); + let doc = &s.active().unwrap().doc; + doc.channels[0].surface.read_region(doc.bounds()) + }; + assert_eq!(result(json!("gray")), result(json!({"targeted":true}))); +} + +/// A recorded visibility target resolves in each input, not against its active layer. +#[test] +fn named_batch_resolves_recorded_layer_targets() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("layers.pcraft"); + let mut source = session("rgb"); + source.execute("layer.new.layer", json!({"name":"Target"})).unwrap(); + source.execute("layer.new.layer", json!({"name":"Active"})).unwrap(); + std::fs::write(&input, photocraft_format::save_to_bytes(&source.active().unwrap().doc, &Default::default()).unwrap()).unwrap(); + let mut s = Session::new(); + s.actions.list.push(Action::new("Hide", vec![("layer.setProps".into(), json!({"visible":false,"_actionLayer":{"name":"Target","ordinal":0}}))])); + let result = s.execute("file.automate.batch", json!({"input":[input],"output":dir.path().join("out"),"format":"pcraft","action":"Hide"})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let saved = photocraft_io::import("saved.pcraft", &std::fs::read(dir.path().join("out/layers.pcraft")).unwrap()).unwrap().document; + assert!(!saved.walk().iter().find(|(_, _, l)| l.name == "Target").unwrap().2.visible); + assert!(saved.walk().iter().find(|(_, _, l)| l.name == "Active").unwrap().2.visible); +} + +/// A selected multipart EXR import may save elsewhere but must not destroy its original parts. +#[test] +fn batch_save_close_refuses_lossy_exr_parts() { + use exr::prelude::*; + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("parts.exr"); + let mk = |name: &str, channels: &[(&str, f32)]| { + Layer::new( + (2, 2), + LayerAttributes::named(name), + Encoding::FAST_LOSSLESS, + AnyChannels::sort(channels.iter().map(|(name, v)| AnyChannel::new(*name, FlatSamples::F32(vec![*v; 4]))).collect()), + ) + }; + Image::from_layers( + ImageAttributes::new(IntegerBounds::new((0, 0), (2, 2))), + vec![mk("Z", &[("Z", 2.0)]), mk("beauty", &[("R", 0.8), ("G", 0.4), ("B", 0.2), ("A", 1.0)])], + ) + .write() + .to_file(&input) + .unwrap(); + let before = std::fs::read(&input).unwrap(); + let steps = json!([{"command":"file.openExrParts","params":{"path":"recorded.exr","part/beauty":true}}, {"command":"file.saveAs","params":{}}]); + let mut s = Session::new(); + for override_open in [false, true] { + let steps = if override_open { steps.clone() } else { json!([]) }; + let result = + s.execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","overrideOpen":override_open,"steps":steps})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 0, "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("cannot preserve this EXR"), "{result}"); + assert_eq!(std::fs::read(&input).unwrap(), before, "all original parts must remain byte-identical"); + } + let result = + s.execute("file.automate.batch", json!({"input":[input],"output":dir.path().join("out"),"format":"exr","overrideOpen":true,"steps":steps})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "Save As still permits the selected part: {result}"); + let flat = dir.path().join("flat.exr"); + std::fs::copy(result["files"][0].as_str().unwrap(), &flat).unwrap(); + let result = s.execute("file.automate.batch", json!({"input":[flat],"destination":"saveAndClose","steps":[]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "ordinary flat EXR still saves: {result}"); +} + +/// In-place EXR writes retain canonical windows or refuse both active and closed results. +#[test] +fn batch_save_close_refuses_changed_exr_windows() { + use exr::prelude::*; + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("window.exr"); + for (origin, display, accepted) in [ + ((-3, 5), IntegerBounds::new((-3, 5), (2, 2)), false), + ((0, 0), IntegerBounds::new((-1, -1), (4, 4)), false), + ((0, 0), IntegerBounds::new((0, 0), (2, 2)), true), + ] { + let layer = Layer::new( + (2, 2), + LayerAttributes { layer_position: Vec2(origin.0, origin.1), ..Default::default() }, + Encoding::FAST_LOSSLESS, + AnyChannels::sort( + [("R", 0.25), ("G", 0.5), ("B", 2.0)].into_iter().map(|(name, value)| AnyChannel::new(name, FlatSamples::F32(vec![value; 4]))).collect(), + ), + ); + Image::from_layers(ImageAttributes::new(display), vec![layer]).write().to_file(&input).unwrap(); + let before = std::fs::read(&input).unwrap(); + for snapshot in [false, true] { + std::fs::write(&input, &before).unwrap(); + let steps = if snapshot { json!([["file.openAs", {"path":"recorded.exr"}], ["file.saveAs", {}], ["file.close", {}]]) } else { json!([]) }; + let result = Session::new() + .execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","overrideOpen":snapshot,"steps":steps})) + .unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), usize::from(accepted), "{result}"); + if accepted { + let after = std::fs::read(&input).unwrap(); + let info = photocraft_codecs::exr_info(&after, &Default::default()).unwrap(); + assert_eq!(info[0].data_window, (0, 0, 2, 2)); + assert_eq!(info[0].display_window, info[0].data_window); + let rgba = |bytes: &[u8]| { + photocraft_codecs::decode(bytes) + .unwrap() + .convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::F32) + .to_f32_samples() + .unwrap() + }; + assert_eq!(rgba(&after), rgba(&before)); + } else { + assert!(result["errors"][0]["error"].as_str().unwrap().contains("data/display windows"), "{result}"); + assert_eq!(std::fs::read(&input).unwrap(), before); + } + } + std::fs::write(&input, &before).unwrap(); + let separate = + Session::new().execute("file.automate.batch", json!({"input":[input],"output":dir.path().join("copies"),"format":"exr","steps":[]})).unwrap(); + assert_eq!(separate["files"].as_array().unwrap().len(), 1, "{separate}"); + assert_eq!(std::fs::read(&input).unwrap(), before); + } +} + +/// Targa alpha remains source data even when its descriptor omits the alpha flag. +#[test] +fn batch_save_close_refuses_targa_24_alpha_loss() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("alpha.tga"); + for (alpha, descriptor_alpha) in [([0, 173], true), ([0, 0], true), ([0, 173], false)] { + let image = Image::from_u8(2, 1, ChannelLayout::Rgba, vec![11, 22, 33, alpha[0], 77, 88, 99, alpha[1]]).unwrap(); + let mut before = photocraft_codecs::encode(&image, Format::Tga, &EncodeOptions::default()).unwrap(); + if !descriptor_alpha { + before[17] &= 0xf0; + } + let opened = photocraft_io::import("alpha.tga", &before).unwrap().document; + assert_eq!(opened.channels.len(), 1, "the fixture must retain its imported alpha plane"); + for snapshot in [false, true] { + std::fs::write(&input, &before).unwrap(); + let steps = if snapshot { json!([["file.openAs", {"path":"recorded.tga"}], ["file.saveAs", {}], ["file.close", {}]]) } else { json!([]) }; + let result = Session::new() + .execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","tgaBits":24,"overrideOpen":snapshot,"steps":steps})) + .unwrap(); + assert_eq!(result["files"], json!([]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("24-bit Targa"), "{result}"); + assert_eq!(std::fs::read(&input).unwrap(), before); + } + } +} + +/// Automatic/32-bit Targa keeps hidden RGB and alpha; separate 24-bit output reports the loss. +#[test] +fn batch_targa_preservation_and_export_warnings() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("alpha.tga"); + let pixels = vec![11, 22, 33, 0, 77, 88, 99, 173]; + let image = Image::from_u8(2, 1, ChannelLayout::Rgba, pixels.clone()).unwrap(); + let before = photocraft_codecs::encode(&image, Format::Tga, &EncodeOptions::default()).unwrap(); + for bits in [Value::Null, json!(32)] { + std::fs::write(&input, &before).unwrap(); + let result = Session::new().execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","tgaBits":bits,"steps":[]})).unwrap(); + assert_eq!(result["files"], json!([input]), "{result}"); + assert_eq!(photocraft_codecs::decode(&std::fs::read(&input).unwrap()).unwrap().data(), pixels); + } + for snapshot in [false, true] { + std::fs::write(&input, &before).unwrap(); + let steps = if snapshot { json!([["file.saveAs", {}], ["file.close", {}]]) } else { json!([]) }; + let result = Session::new() + .execute("file.automate.batch", json!({"input":[input],"output":dir.path().join("copies"),"format":"tga","tgaBits":24,"steps":steps})) + .unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let output = result["files"][0].as_str().unwrap(); + assert_eq!(photocraft_codecs::decode(&std::fs::read(output).unwrap()).unwrap().data(), &[11, 22, 33, 77, 88, 99]); + assert!( + result["notices"] + .as_array() + .unwrap() + .iter() + .any(|n| n["warnings"].as_array().is_some_and(|w| w.iter().any(|v| v.as_str().is_some_and(|s| s.contains("24-bit") && s.contains("Alpha 1"))))), + "{result}" + ); + assert_eq!(std::fs::read(&input).unwrap(), before); + } +} + +/// Explicit 24-bit options do not reject opaque RGB or the grayscale Targa option bypass. +#[test] +fn batch_targa_24_keeps_opaque_and_gray_alpha_results() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("allowed.tga"); + for (layout, pixels) in [(ChannelLayout::Rgb, vec![11, 22, 33, 77, 88, 99]), (ChannelLayout::GrayA, vec![0, 0, 96, 255])] { + let image = Image::from_u8(2, 1, layout, pixels.clone()).unwrap(); + std::fs::write(&input, photocraft_codecs::encode(&image, Format::Tga, &EncodeOptions::default()).unwrap()).unwrap(); + let result = Session::new().execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","tgaBits":24,"steps":[]})).unwrap(); + assert_eq!(result["files"], json!([input]), "{result}"); + assert_eq!(photocraft_codecs::decode(&std::fs::read(&input).unwrap()).unwrap().data(), pixels); + } +} + +/// A tiny flat TIFF with real composite transparency and an optional retained alpha plane. +fn batch_tiff_alpha_fixture(extra: bool, opaque: bool) -> Vec { + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_doc::{AlphaChannel, Document, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + let mut doc = Document::new("alpha", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + let mut pixels = Surface::new(doc.pixel_format()); + pixels.fill_rect(if opaque { Rect::new(0, 0, 2, 2) } else { Rect::new(1, 1, 2, 2) }, &[1.0, 0.0, 0.0, 1.0]); + doc.layers.push(Layer::new("Art", LayerContent::Raster(pixels))); + if extra { + let mut mask = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + mask.fill_rect(Rect::new(0, 0, 1, 2), &[1.0]); + doc.channels.push(AlphaChannel::new("Mask", mask)); + } + photocraft_io::export(&doc, "alpha.tif", &photocraft_io::ExportOptions { tiff_transparency: Some(true), ..Default::default() }).unwrap().bytes +} + +/// The actual flat path refuses explicit and auxiliary-plane matting before replacing the source. +#[test] +fn batch_save_close_refuses_flat_tiff_transparency_loss() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("alpha.tif"); + for (extra, transparency) in [(false, json!(false)), (true, Value::Null)] { + let before = batch_tiff_alpha_fixture(extra, false); + for snapshot in [false, true] { + std::fs::write(&input, &before).unwrap(); + let steps = if snapshot { + json!([["file.openAs", {"path":"recorded.tif"}], ["layer.toggleLockTransparency", {}], ["file.saveAs", {}], ["file.close", {}]]) + } else { + json!([["layer.toggleLockTransparency", {}]]) + }; + let result = Session::new() + .execute( + "file.automate.batch", + json!({"input":[input],"destination":"saveAndClose","overrideOpen":snapshot,"tiffTransparency":transparency,"steps":steps}), + ) + .unwrap(); + assert_eq!(result["files"], json!([]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("TIFF composite transparency"), "{result}"); + assert_eq!(std::fs::read(&input).unwrap(), before); + } + } +} + +/// Keeping flat alpha, preserving actual layers and writing an opaque composite remain allowed. +#[test] +fn batch_tiff_alpha_guard_keeps_legitimate_results() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("allowed.tif"); + for (layered, opaque, transparency) in [(false, false, json!(true)), (true, false, json!(false)), (false, true, Value::Null)] { + let before = batch_tiff_alpha_fixture(true, opaque); + std::fs::write(&input, &before).unwrap(); + let steps = if !layered && !opaque { json!([["layer.toggleLockTransparency", {}]]) } else { json!([]) }; + let result = Session::new() + .execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","tiffTransparency":transparency,"steps":steps})) + .unwrap(); + assert_eq!(result["files"], json!([input]), "{result}"); + let bytes = std::fs::read(&input).unwrap(); + assert_eq!(photocraft_codecs::tiff_photoshop_tags(&bytes).1.is_some(), layered); + let back = photocraft_io::import("allowed.tif", &bytes).unwrap().document; + assert_eq!(back.channels.len(), 1); + assert_eq!(back.channels[0].surface.pixel(0, 0)[0], 1.0); + assert_eq!(back.channels[0].surface.pixel(1, 1)[0], 0.0); + if layered { + let LayerContent::Raster(surface) = &back.layers[0].content else { panic!("the saved layer must remain raster") }; + assert_eq!(surface.pixel(0, 0).last().copied(), Some(0.0)); + } else { + let actual = photocraft_codecs::decode(&bytes).unwrap(); + let original = photocraft_codecs::decode(&before).unwrap(); + assert_eq!(actual.data(), original.data()); + } + } +} + +/// Starts the largest supported custom kernel as a background job, cancels it at once and checks +/// it ends cancelled without an edit. Returns how long admission and the whole run took. +fn cancel_largest_custom_shape(smart: bool) -> (std::time::Duration, std::time::Duration) { + let mut s = session("rgb"); + if smart { + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + } + let before = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + assert!(s.execute("filter.blur.shapeBlur", json!({"radius":5,"shapePath":{"subpaths":[]}})).is_err()); + assert_eq!(photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(), before); + let knots: Vec<_> = (0..2048).map(|i| json!([if i % 2 == 0 { 0 } else { 1000 }, (i * 617) % 1001])).collect(); + let params = json!({"radius":1000,"shapePath":{"subpaths":[{"closed":true,"knots":knots}]}}); + let start = std::time::Instant::now(); + let photocraft_engine::jobs::Started::Job(job) = s.start("filter.blur.shapeBlur", params).unwrap() else { panic!("background filter job") }; + let admitted = start.elapsed(); + assert!(s.cancel_job(job)); + s.join_cancelled_jobs(); + let drained = start.elapsed(); + let outcome = loop { + if let Some(event) = s.poll_jobs().into_iter().find(|e| e.id == job) { + break event.outcome; + } + assert!(start.elapsed() < std::time::Duration::from_secs(60), "the cancelled job never reported"); + std::thread::sleep(std::time::Duration::from_millis(2)); + }; + assert_eq!(outcome, photocraft_engine::jobs::JobOutcome::Cancelled, "smart={smart}"); + assert_eq!(photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(), before); + (admitted, drained) +} + +/// The largest supported custom kernel is prepared on the worker and can be cancelled without an edit. +#[test] +fn custom_shape_preparation_yields_a_cancellable_job() { + for smart in [false, true] { + cancel_largest_custom_shape(smart); + } +} + +/// How fast that job is admitted and drained after a cancel. +#[test] +// Wall-clock timing depends on machine load (the release corpus job runs every test), so it is +// opt-in, like `cancel_takes_effect_within_200_ms_on_24_mp`. +#[ignore = "timing: cargo test --release -p photocraft-engine --test it custom_shape_preparation_cancels_quickly -- --ignored"] +fn custom_shape_preparation_cancels_quickly() { + for smart in [false, true] { + let (admitted, drained) = cancel_largest_custom_shape(smart); + eprintln!("custom shape smart={smart}: admission={admitted:?}, cancelled worker drained in {drained:?}"); + assert!(admitted < std::time::Duration::from_secs(1), "command thread prepared the kernel for {admitted:?}"); + assert!(drained < std::time::Duration::from_secs(3), "kernel preparation did not observe cancellation"); + } +} + +/// Source-preservation inspection must follow the recorded Open's authorization gate. +#[test] +fn batch_exr_preservation_does_not_preempt_open_authorization() { + fn deny_open(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "file.openExrParts" { Err(photocraft_engine::EngineError::Other("recorded Open denied".into())) } else { Ok(()) } + } + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("unreadable.exr"); + std::fs::write(&input, b"not an EXR header").unwrap(); + let mut s = Session::new(); + s.authorize = Some(deny_open); + let result = s + .execute( + "file.automate.batch", + json!({ + "input":[input],"destination":"saveAndClose","overrideOpen":true, + "steps":[{"command":"file.openExrParts","params":{"path":"recorded.exr"}}] + }), + ) + .unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 0); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("recorded Open denied"), "{result}"); + assert_eq!(std::fs::read(input).unwrap(), b"not an EXR header"); +} + +/// Removing the original descriptor must exercise every native naming-component wire mapping. +#[test] +fn batch_naming_components_rebuild_without_original_descriptor() { + for (wire, native) in [ + ("Nm ", "name"), + ("lowerCase", "nameLower"), + ("upperCase", "nameUpper"), + ("oneDigit", "serial1"), + ("twoDigit", "serial2"), + ("threeDigit", "serial3"), + ("fourDigit", "serial4"), + ("lowerCaseSerial", "letter"), + ("upperCaseSerial", "LETTER"), + ("mmddyy", "mmddyy"), + ("mmdd", "mmdd"), + ("yyyymmdd", "yyyymmdd"), + ("yymmdd", "yymmdd"), + ("yyddmm", "yyddmm"), + ("ddmmyy", "ddmmyy"), + ("ddmm", "ddmm"), + ("lowerCaseExtension", "extension"), + ("upperCaseExtension", "EXTENSION"), + ] { + let component = + Descriptor::new("fileNamingComponent").with("component", D::Enumerated { type_id: Id::new("fileNamingComponent"), value: Id::new(wire) }); + let rules = Descriptor::new("fileNamingRules").with("fileNamingComponents", D::List(vec![D::Descriptor(component)])); + let d = Descriptor::new("Btch") + .with("null", path("input")) + .with("T ", path("output")) + .with("Usng", D::Reference(vec![named("Actn", "Child"), named("ASet", "Parent")])) + .with("fileNamingRules", D::Descriptor(rules.clone())); + let imported = atn_cmds::import(&encoded(*b"Btch", d, true)).unwrap(); + assert!(imported.unsupported.is_empty(), "{wire}"); + let mut action = imported.actions[0].clone(); + let params = &mut action.steps[0].1; + assert_eq!(params["namingComponents"], json!([{"component":native}])); + assert!(params.as_object_mut().unwrap().remove("__atnBatch").is_some()); + assert_eq!(exported(&action).get("fileNamingRules"), Some(&D::Descriptor(rules)), "{wire}"); + } +} + +/// Tiny encoded sources keep Batch tests independent of external image assets. +fn small_image(format: photocraft_codecs::Format) -> Vec { + let image = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgba, [32, 64, 128, 255].repeat(4)).unwrap(); + photocraft_codecs::encode(&image, format, &Default::default()).unwrap() +} + +/// A real one-name Batch import follows its own renamed action, never a same-name existing action. +#[test] +fn imported_batch_references_follow_collision_renames_once_and_keep_other_sets() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("input.png"); + let output = dir.path().join("out"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let d = Descriptor::new("Btch") + .with("null", path(dir.path().to_str().unwrap())) + .with("T ", path(output.to_str().unwrap())) + .with("Usng", D::Reference(vec![named("Actn", "Child")])); + let mut file = atn::parse(&encoded(*b"Btch", d, true)).unwrap(); + file.name = UnicodeString::new_nul("Imported"); + for name in ["Child", "Child 2"] { + let child = Action::new(name, vec![("image.imageSize".into(), json!({"width":3,"height":1,"resample":"nearest"}))]); + file.actions.push(atn::parse(&atn_cmds::export("Imported", &[&child], &[], false).unwrap().0).unwrap().actions.remove(0)); + } + let mut external = file.actions[0].items[0].clone(); + external.descriptor.as_mut().unwrap().items.iter_mut().find(|(k, _)| k.is("Usng")).unwrap().1 = + D::Reference(vec![named("Actn", "Child"), named("ASet", "Other")]); + file.actions[0].items.push(external); + let mut s = Session::new(); + let mut old = Action::new("Child", vec![("select.deselect".into(), json!({}))]); + old.set = Some("Other".into()); + s.actions.list.push(old); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&atn::write(&file).unwrap()),"replace":false})).unwrap(); + let own_name = loaded["actions"][1].as_str().unwrap(); + let imported = &s.actions.list[1]; + assert_eq!(imported.steps[0].1["action"], own_name); + assert_eq!(imported.steps[1].1["action"], "Child"); + assert_eq!(imported.steps[1].1["set"], "Other"); + let batch = imported.steps[0].1.clone(); + let result = s.execute("file.automate.batch", batch).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let doc = photocraft_io::import("output.png", &std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(doc.size, photocraft_doc::Size::new(3, 1), "the renamed imported child ran"); +} + +/// Null is the optional default set, while invalid typed and explicit set values remain distinct. +#[test] +fn batch_null_set_exports_like_absent_and_preserves_explicit_sets() { + let p = json!({"input":"in","output":"out","action":"Child","stopOnError":true,"includeNonImages":true,"namingComponents":[{"component":"name"},{"component":"extension"}]}); + let absent = exported(&Action::new("Review", vec![("file.automate.batch".into(), p.clone())])); + for (set, allowed) in [(Value::Null, true), (json!("Other"), true), (json!(4), false)] { + let mut p = p.clone(); + p["set"] = set.clone(); + let action = Action::new("Review", vec![("file.automate.batch".into(), p)]); + let result = atn_cmds::export("Parent", &[&action], &[], false); + assert_eq!(result.is_ok(), allowed); + if allowed { + let bytes = result.unwrap().0; + let imported = atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0].1.get("set"), if set.is_null() { None } else { Some(&set) }); + if set.is_null() { + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(absent.clone())); + } + } + } +} + +/// Both native and edited imported Batch steps keep global lookup when their reference is unqualified. +#[test] +fn batch_unqualified_export_keeps_cross_set_resolution() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("input.png"), small_image(photocraft_codecs::Format::Png)).unwrap(); + for edited in [false, true] { + let p = json!({"input":dir.path(),"output":dir.path().join(format!("out-{edited}")),"action":"Child","stopOnError":true,"includeNonImages":true,"namingComponents":[{"component":"name"},{"component":"extension"}]}); + let mut parent = Action::new("Parent", vec![("file.automate.batch".into(), p)]); + if edited { + parent = atn_cmds::import(&atn_cmds::export("Parent set", &[&parent], &[], false).unwrap().0).unwrap().actions.remove(0); + parent.steps[0].1["suppressWarnings"] = json!(true); + } + let imported = atn_cmds::import(&atn_cmds::export("Parent set", &[&parent], &[], false).unwrap().0).unwrap(); + let batch = imported.actions[0].steps[0].1.clone(); + assert!(batch.get("set").is_none()); + let mut child = Action::new("Child", vec![("image.imageSize".into(), json!({"width":3,"height":1,"resample":"nearest"}))]); + child.set = Some("Other set".into()); + let mut s = Session::new(); + s.actions.list.push(child); + let result = s.execute("file.automate.batch", batch).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let doc = photocraft_io::import("out.png", &std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(doc.size, photocraft_doc::Size::new(3, 1)); + } +} + +/// Rebinding the imported set follows local children, but a foreign destination-set reference stays foreign. +#[test] +fn batch_load_override_preserves_original_set_identity_and_foreign_references() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("input.png"), small_image(photocraft_codecs::Format::Png)).unwrap(); + for (foreign, collision) in [(false, false), (false, true), (true, true)] { + let params = json!({"input":dir.path(),"output":dir.path().join(format!("out-{foreign}-{collision}")),"action":"Child","set":if foreign {"Destination"} else {"Original"},"stopOnError":true,"includeNonImages":true,"namingComponents":[{"component":"name"},{"component":"extension"}]}); + let parent = Action::new("Parent", vec![("file.automate.batch".into(), params)]); + let child = Action::new("Child", vec![("image.imageSize".into(), json!({"width":3,"height":1,"resample":"nearest"}))]); + let bytes = atn_cmds::export("Original", &[&parent, &child], &[], false).unwrap().0; + let mut s = Session::new(); + if collision { + let mut old = Action::new("Child", vec![("image.imageSize".into(), json!({"width":5,"height":1,"resample":"nearest"}))]); + old.set = Some("Destination".into()); + s.actions.list.push(old); + } + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&bytes),"set":"Destination","replace":false})).unwrap(); + let parent = s.actions.list.iter().find(|a| a.name == loaded["actions"][0].as_str().unwrap()).unwrap(); + let batch = parent.steps[0].1.clone(); + assert_eq!(batch["set"], "Destination"); + assert_eq!(batch["action"], if foreign { json!("Child") } else { loaded["actions"][1].clone() }); + let saved = atn_cmds::import(&atn_cmds::export("Destination", &[parent], &[], false).unwrap().0).unwrap(); + assert_eq!(saved.actions[0].steps[0].1["set"], batch["set"]); + assert_eq!(saved.actions[0].steps[0].1["action"], batch["action"]); + let result = s.execute("file.automate.batch", batch).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let doc = photocraft_io::import("out.png", &std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(doc.size, photocraft_doc::Size::new(if foreign { 5 } else { 3 }, 1)); + } +} + +/// A real one-bit transparency-mask IFD, skipped by default TIFF decoding, retains separate data. +fn tiff_with_separate_mask() -> Vec { + let mut bytes = small_image(photocraft_codecs::Format::Tiff); + assert_eq!(&bytes[..2], b"II"); + let first = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(bytes[first..first + 2].try_into().unwrap()) as usize; + if !bytes.len().is_multiple_of(2) { + bytes.push(0); + } + let mask = bytes.len() as u32; + bytes[first + 2 + count * 12..first + 6 + count * 12].copy_from_slice(&mask.to_le_bytes()); + let data = mask + 2 + 11 * 12 + 4; + bytes.extend_from_slice(&11u16.to_le_bytes()); + for (tag, ty, value) in [ + (254u16, 4u16, 4u32), + (256, 4, 2), + (257, 4, 2), + (258, 3, 1), + (259, 3, 1), + (262, 3, 4), + (273, 4, data), + (277, 3, 1), + (278, 4, 2), + (279, 4, 2), + (284, 3, 1), + ] { + bytes.extend_from_slice(&tag.to_le_bytes()); + bytes.extend_from_slice(&ty.to_le_bytes()); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&[0x80, 0x40]); + assert!(photocraft_codecs::tiff_info(&bytes).unwrap().pages.iter().any(|p| p.kind == photocraft_codecs::TiffPageKind::Mask)); + bytes +} + +/// A flat RGB(A) TIFF with individually identifiable auxiliary channels. +fn tiff_with_aux_channels(extras: usize, alpha: bool) -> Vec { + let samples = 3 + usize::from(alpha) + extras; + let kinds = extras + usize::from(alpha); + let bits = 8 + 2 + 12 * 12 + 4; + let kind_at = bits + samples * 2; + let data = kind_at + kinds * 2; + let mut bytes = b"II\x2a\0\x08\0\0\0".to_vec(); + bytes.extend_from_slice(&12u16.to_le_bytes()); + for (tag, ty, count, value) in [ + (256u16, 4u16, 1u32, 2u32), + (257, 4, 1, 2), + (258, 3, samples as u32, bits as u32), + (259, 3, 1, 1), + (262, 3, 1, 2), + (273, 4, 1, data as u32), + (277, 3, 1, samples as u32), + (278, 4, 1, 2), + (279, 4, 1, (samples * 4) as u32), + (284, 3, 1, 1), + (338, 3, kinds as u32, kind_at as u32), + (339, 3, 1, 1), + ] { + bytes.extend_from_slice(&tag.to_le_bytes()); + bytes.extend_from_slice(&ty.to_le_bytes()); + bytes.extend_from_slice(&count.to_le_bytes()); + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.extend_from_slice(&0u32.to_le_bytes()); + for _ in 0..samples { + bytes.extend_from_slice(&8u16.to_le_bytes()); + } + for i in 0..kinds { + bytes.extend_from_slice(&(if alpha && i == 0 { 2u16 } else { 0 }).to_le_bytes()); + } + for _ in 0..4 { + bytes.extend_from_slice(&[32, 64, 128]); + if alpha { + bytes.push(200); + } + bytes.extend((0..extras).map(|i| i as u8 + 1)); + } + assert_eq!(bytes.len(), data + samples * 4); + bytes +} + +/// Extra source payloads must survive both ordinary and saved/closed Batch refusal paths. +#[test] +fn batch_save_close_preserves_additional_images_and_allows_single_images() { + let dir = tempfile::tempdir().unwrap(); + let still = small_image(photocraft_codecs::Format::Gif); + let flags = still[10]; + let blocks = 13 + if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 }; + let mut animated = still[..still.len() - 1].to_vec(); + animated.extend_from_slice(&still[blocks..]); + assert_eq!(photocraft_codecs::decode_gif_frames(&animated, &Default::default()).unwrap().len(), 2); + let dpx = small_image(photocraft_codecs::Format::Dpx); + let mut elements = dpx.clone(); + elements[770..772].copy_from_slice(&2u16.to_be_bytes()); + elements.extend_from_slice(b"distinct second DPX element payload"); + let dds = small_image(photocraft_codecs::Format::Dds); + let mut slices = dds.clone(); + slices[24..28].copy_from_slice(&2u32.to_le_bytes()); + let mut second = dds[128..].to_vec(); + second[0] ^= 0xff; + slices.extend(second); + let mut faces = slices.clone(); + faces[24..28].copy_from_slice(&0u32.to_le_bytes()); + faces[112..116].copy_from_slice(&0xc00u32.to_le_bytes()); + let rgb = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, [32, 64, 128].repeat(4)).unwrap(); + let mut options = photocraft_codecs::EncodeOptions::default(); + options.dds.mipmaps = true; + let mut mipmaps = photocraft_codecs::encode(&rgb, photocraft_codecs::Format::Dds, &options).unwrap(); + let flags = u32::from_le_bytes(mipmaps[8..12].try_into().unwrap()); + mipmaps[8..12].copy_from_slice(&(flags & !0x0002_0000).to_le_bytes()); + let fits = photocraft_codecs::encode(&rgb, photocraft_codecs::Format::Fits, &Default::default()).unwrap(); + let mut hdu = fits.clone(); + hdu[..80].fill(b' '); + hdu[..17].copy_from_slice(b"XTENSION= 'IMAGE'"); + hdu[2880] ^= 0xff; + let mut hdus = fits.clone(); + hdus.extend(hdu); + let mut cubes = fits.clone(); + let axis = cubes.as_chunks_mut::<80>().0.iter_mut().find(|card| card.starts_with(b"NAXIS3 =")).unwrap(); + axis.fill(b' '); + axis[..11].copy_from_slice(b"NAXIS3 = 4"); + let mut table = fits.clone(); + for card in [ + "XTENSION= 'BINTABLE'", + "BITPIX = 8", + "NAXIS = 2", + "NAXIS1 = 4", + "NAXIS2 = 1", + "PCOUNT = 0", + "GCOUNT = 1", + "TFIELDS = 1", + "TTYPE1 = 'DATA'", + "TFORM1 = '4B'", + "END", + ] { + table.extend_from_slice(format!("{card:<80}").as_bytes()); + } + table.resize(table.len().div_ceil(2880) * 2880, b' '); + table.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + table.resize(table.len().div_ceil(2880) * 2880, 0); + let mut cases = vec![ + ("gif", animated), + ("dpx", elements), + ("dds", slices), + ("dds", faces), + ("dds", mipmaps), + ("fits", hdus), + ("fits", cubes), + ("fits", table), + ("tiff", tiff_with_separate_mask()), + ]; + for alpha in [false, true] { + cases.push(("tiff", tiff_with_aux_channels(photocraft_codecs::MAX_EXTRA_SAMPLES + 1, alpha))); + } + for (index, (extension, multiple)) in cases.into_iter().enumerate() { + photocraft_codecs::decode(&multiple).unwrap(); + let input = dir.path().join(format!("source.{extension}")); + for steps in [ + json!([]), + json!([{"command":"file.openAs","params":{"path":"recorded.png"}},{"command":"file.saveAs","params":{}},{"command":"file.close","params":{"force":true}}]), + ] { + std::fs::write(&input, &multiple).unwrap(); + let override_open = !steps.as_array().unwrap().is_empty(); + let mut s = Session::new(); + let result = + s.execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","overrideOpen":override_open,"steps":steps})).unwrap(); + assert!(result["files"].as_array().unwrap().is_empty(), "{extension}: {result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("additional source data"), "{extension}: {result}"); + assert_eq!(std::fs::read(&input).unwrap(), multiple); + } + let result = + Session::new().execute("file.automate.batch", json!({"input":[input],"output":dir.path().join(format!("separate-{index}")),"steps":[]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{extension}: {result}"); + assert!(std::path::Path::new(result["files"][0].as_str().unwrap()).is_file()); + assert_eq!(std::fs::read(&input).unwrap(), multiple); + } + for (extension, bytes) in [("gif", still), ("dpx", dpx), ("dds", dds), ("fits", fits), ("tiff", small_image(photocraft_codecs::Format::Tiff))] { + let input = dir.path().join(format!("still.{extension}")); + std::fs::write(&input, bytes).unwrap(); + let result = Session::new().execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","steps":[]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{extension}: {result}"); + assert!(!photocraft_codecs::has_unretained_source_data(&std::fs::read(input).unwrap()).unwrap()); + } +} + +/// Auxiliary channels below and at the cap remain writable, including beside a retained alpha. +#[test] +fn batch_save_close_preserves_aux_channels_at_the_supported_cap() { + let dir = tempfile::tempdir().unwrap(); + for alpha in [false, true] { + for extras in [photocraft_codecs::MAX_EXTRA_SAMPLES - 1, photocraft_codecs::MAX_EXTRA_SAMPLES] { + let bytes = tiff_with_aux_channels(extras, alpha); + let expected = photocraft_codecs::decode(&bytes).unwrap().meta.extra_samples; + assert_eq!(expected.len(), extras); + let input = dir.path().join(format!("{alpha}-{extras}.tiff")); + std::fs::write(&input, bytes).unwrap(); + let result = Session::new().execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","steps":[]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let saved = photocraft_codecs::decode(&std::fs::read(input).unwrap()).unwrap(); + assert_eq!(saved.meta.extra_samples, expected, "alpha={alpha} extras={extras}"); + } + } +} + +/// Droplet dependencies retain explicit Batch calls and rewrite their indices before packaging. +#[test] +fn called_actions_packages_explicit_batch_children_and_replays_them() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("input.png"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let list = vec![Action::new("Decoy", vec![]), Action::new("Child", vec![("image.imageSize".into(), json!({"width":3,"height":1,"resample":"nearest"}))])]; + for field in ["steps", "action"] { + for shape in 0..3 { + let step = match shape { + 0 => json!(["actions.play", {"action":1}]), + 1 => json!({"command":"actions.play","params":{"action":1},"kept":17}), + _ => json!({"id":"actions.play","params":{"action":1},"kept":17}), + }; + let mut params = json!({"input":[input],"output":dir.path().join(format!("{field}-{shape}"))}); + params[field] = json!([step]); + if field == "steps" { + params["action"] = json!(0); + } + let mut steps = vec![("file.automate.batch".into(), params)]; + let captured = photocraft_engine::actions_cmds::called_actions(&list, &mut steps); + assert_eq!(captured.iter().map(|a| a.name.as_str()).collect::>(), ["Child"]); + let rewritten = &steps[0].1[field][0]; + let params = if shape == 0 { &rewritten[1] } else { &rewritten["params"] }; + assert_eq!(params["action"], "Child"); + if shape != 0 { + assert_eq!(rewritten["kept"], 17); + } + let mut s = Session::new(); + s.actions.list = captured; + let result = s.execute(&steps[0].0, steps[0].1.clone()).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let doc = photocraft_io::import("saved.png", &std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(doc.size, photocraft_doc::Size::new(3, 1)); + } + } +} + +/// A deep route to a shared action must not hide children reached by a shallower playable branch. +#[test] +fn called_actions_packages_shared_descendants_at_the_shallowest_depth() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("input.png"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let mut list = vec![ + Action::new("Shared", vec![("actions.play".into(), json!({"action":1}))]), + Action::new("Child", vec![("image.imageSize".into(), json!({"width":3,"height":1,"resample":"nearest"}))]), + ]; + // The long branch reaches Shared at depth16 and its child beyond the playback limit. + for i in 0..15 { + list.push(Action::new(format!("A{i}"), vec![("actions.play".into(), json!({"action":if i == 14 { 0 } else { i + 3 }}))])); + } + for (condition, then, otherwise) in [("always", "Shared", "A0"), ("never", "A0", "Shared")] { + let mut steps = vec![( + "file.automate.batch".into(), + json!({"input":[input],"output":dir.path().join(condition),"steps":[["actions.conditional",{"if":condition,"then":then,"else":otherwise}]]}), + )]; + let captured = photocraft_engine::actions_cmds::called_actions(&list, &mut steps); + assert_eq!(captured.iter().map(|a| &a.name).collect::>(), list.iter().map(|a| &a.name).collect::>()); + assert_eq!(captured[0].steps[0].1["action"], "Child"); + let mut s = Session::new(); + s.actions.list = captured; + let result = s.execute(&steps[0].0, steps[0].1.clone()).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let saved = std::fs::read(result["files"][0].as_str().unwrap()).unwrap(); + let doc = photocraft_io::import("saved.png", &saved).unwrap().document; + assert_eq!(doc.size, photocraft_doc::Size::new(3, 1)); + } +} + +/// JSON containers do not consume action-call depth; the existing cycle and sixteen-call limits remain. +#[test] +fn called_actions_nested_batch_shapes_keep_cycle_and_action_depth_bounds() { + let cycle = vec![Action::new("Child", vec![("actions.play".into(), json!({"action":0}))])]; + let mut step = json!(["actions.play", {"action":0}]); + for level in 0..64 { + let key = if level % 2 == 0 { "steps" } else { "action" }; + let mut params = json!({"kept":level}); + params[key] = json!([step]); + step = json!({"id":"file.automate.batch","params":params}); + } + let mut steps = vec![("file.automate.batch".into(), json!({"steps":[step]}))]; + let captured = photocraft_engine::actions_cmds::called_actions(&cycle, &mut steps); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].steps[0].1["action"], "Child"); + let mut node = &steps[0].1["steps"][0]; + for level in (0..64).rev() { + let params = &node["params"]; + assert_eq!(params["kept"], level); + node = ¶ms[if level % 2 == 0 { "steps" } else { "action" }][0]; + } + assert_eq!(node[1]["action"], "Child"); + let chain: Vec<_> = + (0..17).map(|i| Action::new(format!("A{i}"), if i == 16 { vec![] } else { vec![("actions.play".into(), json!({"action":i+1}))] })).collect(); + let mut steps = vec![("file.automate.batch".into(), json!({"steps":[["actions.play",{"action":0}]]}))]; + assert_eq!(photocraft_engine::actions_cmds::called_actions(&chain, &mut steps).len(), 16); + let mut invalid = vec![("file.automate.batch".into(), json!({"steps":null,"action":0}))]; + let before = invalid.clone(); + assert!(photocraft_engine::actions_cmds::called_actions(&chain, &mut invalid).is_empty()); + assert_eq!(invalid, before, "malformed explicit steps must not fall back to a named action"); +} + +/// Scripts nested through Batch retain the caller's depth and cumulative spending across failed inputs. +#[test] +fn batch_carries_script_limits_and_restores_balanced_depth() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("source.png"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let step = json!({"command":"file.scripts.browse","params":{"steps":[{"command":"select.all"}]}}); + for (depth, spending, allowed) in [(16, 0, false), (1, photocraft_engine::actions_cmds::MAX_PLAYBACK_STEPS - 1, false), (1, 2, true)] { + let mut s = Session::new(); + s.actions.script_depth = depth; + s.actions.script_steps = spending; + let result = s.execute("file.automate.batch",json!({"input":[input,input],"output":dir.path().join("out"),"steps":[step],"namingComponents":[{"component":"serial2"},{"component":"extension"}]})).unwrap(); + assert_eq!(result["errors"].as_array().unwrap().is_empty(), allowed, "{result}"); + assert_eq!(s.actions.script_depth, depth); + if depth == 1 { + // Per input: the Batch's direct step (the nested script), then that script's one step + // unless the playback limit refuses it. + assert_eq!(s.actions.script_steps, spending + if allowed { 4 } else { 2 }); + } + } +} + +/// Structured naming with Override Open keeps the actual input encoding when format defaults to same. +#[test] +fn structured_batch_override_open_saves_default_same_signature() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("source.png"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let result=Session::new().execute("file.automate.batch",json!({"input":[input],"output":dir.path().join("out"),"overrideOpen":true,"namingComponents":[{"component":"name"},{"component":"extension"}],"steps":[{"command":"file.openAs","params":{"path":"recorded.jpg"}}]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let output = result["files"][0].as_str().unwrap(); + assert_eq!(std::path::Path::new(output).extension().unwrap(), "png"); + assert!(std::fs::read(output).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); +} diff --git a/crates/engine/tests/it/atn_shape_blur.rs b/crates/engine/tests/it/atn_shape_blur.rs new file mode 100644 index 0000000000..df9a5b43eb --- /dev/null +++ b/crates/engine/tests/it/atn_shape_blur.rs @@ -0,0 +1,418 @@ +use photocraft_doc::LayerContent; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::json; + +#[path = "../../../psd/tests/common/csh_fixture.rs"] +mod csh_fixture; + +const SHAPE_ID: &str = "original-fixture-square"; + +/// The recorded display name differs from the installed ID to expose accidental name lookup. +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("Rds ", D::UnitFloat { unit: *b"#Pxl", value: 5.0 }) + .with( + "customShape", + D::Descriptor( + Descriptor::new("customShape") + .with("Nm ", D::Text(UnicodeString::new_nul("Triangle"))) + .with("Idnt", D::Text(UnicodeString::new_nul(SHAPE_ID))), + ), + ) + .with("keyOriginType", D::Integer(9)) +} + +fn encoded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + actions: vec![AtnAction { + name: UnicodeString::new_nul("Shape"), + items: vec![AtnItem { event: AtnEvent::name("shapeBlur"), descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":25,"height":25,"depth":depth,"background":"#000000"})).unwrap(); + s.execute("paint.stroke", json!({"points":[[8,9],[16,13]],"size":3,"hardness":1,"color":"#ffffff"})).unwrap(); + s +} + +fn install_square(s: &mut Session) { + s.execute("shape.presets.new", json!({"name":"Original Square","id":SHAPE_ID,"path":"M 0 0 L 10 0 L 10 10 L 0 10 Z"})).unwrap(); + s.execute("shape.presets.edit", json!({"action":"rename","preset":"Original Square","name":"Renamed Square"})).unwrap(); +} + +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() +} +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +fn smart_params(s: &Session) -> serde_json::Value { + s.active() + .unwrap() + .doc + .walk() + .into_iter() + .find_map(|(_, _, l)| match &l.content { + LayerContent::Smart(sm) => sm.smart_filters.first().map(|filter| filter.params.clone()), + _ => None, + }) + .unwrap() +} + +fn smart_builtin(shape: &str, radius: u32) -> Session { + let mut s = fixture(16); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("filter.blur.shapeBlur", json!({"radius":radius,"shape":shape})).unwrap(); + s +} + +#[test] +fn smart_filter_params_resolve_custom_shapes_and_keep_portable_geometry() { + for selector in [json!({"shapeId":SHAPE_ID}), json!({"shapeName":"Renamed Square"})] { + let mut s = fixture(16); + install_square(&mut s); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("filter.blur.shapeBlur", json!({"radius":5,"shape":"circle"})).unwrap(); + let before = saved(&s); + s.execute("layer.smartFilter.setParams", json!({"index":0,"params":selector.clone()})).unwrap(); + let path = smart_params(&s)["shapePath"].clone(); + assert!(path.is_object()); + assert_eq!(pixels(&s), pixels(&smart_builtin("square", 5))); + let after = saved(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(saved(&s), after); + + let mut reopened = Session::new(); + assert!(!reopened.presets.shapes.iter().flat_map(|g| &g.items).any(|shape| shape.id.as_deref() == Some(SHAPE_ID))); + reopened.add_document(photocraft_format::load_from_bytes(&after).unwrap(), None); + reopened.execute("layer.smartFilter.setParams", json!({"index":0,"params":{"radius":6}})).unwrap(); + assert_eq!(smart_params(&reopened)["shapePath"], path); + assert_eq!(pixels(&reopened), pixels(&smart_builtin("square", 6))); + let mut echoed = selector; + echoed["radius"] = json!(7); + reopened.execute("layer.smartFilter.setParams", json!({"index":0,"params":echoed})).unwrap(); + assert_eq!(smart_params(&reopened)["shapePath"], path); + assert_eq!(pixels(&reopened), pixels(&smart_builtin("square", 7))); + } +} + +#[test] +fn smart_filter_selector_changes_replace_the_old_custom_geometry() { + for (initial, replacement) in [ + (json!({"radius":5,"shapeId":SHAPE_ID}), json!({"shapeId":"replacement-triangle"})), + (json!({"radius":5,"shapeName":"Renamed Square"}), json!({"shapeName":"Replacement Triangle"})), + (json!({"radius":5,"shapeId":SHAPE_ID}), json!({"shapeName":"Replacement Triangle"})), + (json!({"radius":5,"shapeName":"Renamed Square"}), json!({"shapeId":"replacement-triangle"})), + ] { + let mut s = fixture(16); + install_square(&mut s); + s.execute("shape.presets.new", json!({"name":"Replacement Triangle","id":"replacement-triangle","path":"M 0 0 L 10 0 L 5 10 Z"})).unwrap(); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("filter.blur.shapeBlur", initial).unwrap(); + let old_path = smart_params(&s)["shapePath"].clone(); + let old_pixels = pixels(&s); + s.execute("layer.smartFilter.setParams", json!({"index":0,"params":replacement.clone()})).unwrap(); + assert_ne!(smart_params(&s)["shapePath"], old_path); + let stale = if replacement.get("shapeId").is_some() { "shapeName" } else { "shapeId" }; + assert!(smart_params(&s).get(stale).is_none()); + let mut expected = fixture(16); + expected.load_presets_json(s.presets.to_json(&s)); + expected.execute("filter.convertForSmartFilters", json!({})).unwrap(); + expected.execute("filter.blur.shapeBlur", json!({"radius":5,"shapeId":"replacement-triangle"})).unwrap(); + assert_ne!(pixels(&s), old_pixels); + assert_eq!(pixels(&s), pixels(&expected)); + } +} + +#[test] +fn smart_filter_builtin_choice_replaces_custom_selectors_and_geometry() { + let mut source = fixture(16); + install_square(&mut source); + source.execute("filter.convertForSmartFilters", json!({})).unwrap(); + source.execute("filter.blur.shapeBlur", json!({"radius":5,"shapeId":SHAPE_ID})).unwrap(); + let path = smart_params(&source)["shapePath"].clone(); + let expected = pixels(&smart_builtin("circle", 5)); + for selector in [json!({"shapeId":SHAPE_ID}), json!({"shapeName":"Renamed Square"}), json!({})] { + let mut s = fixture(16); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + // The retained built-in field already says circle; an explicit circle choice must still + // replace the custom geometry rather than being mistaken for an unchanged selection. + let mut params = selector; + params["radius"] = json!(5); + params["shape"] = json!("circle"); + params["shapePath"] = path.clone(); + s.execute("filter.blur.shapeBlur", params).unwrap(); + assert_ne!(pixels(&s), expected); + let before = saved(&s); + s.execute("layer.smartFilter.setParams", json!({"index":0,"params":{"shape":"circle"}})).unwrap(); + let actual = smart_params(&s); + for key in ["shapeId", "shapeName", "shapePath"] { + assert!(actual.get(key).is_none(), "{actual}"); + } + assert_eq!(pixels(&s), expected); + let after = saved(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(saved(&s), after); + } +} + +#[test] +fn smart_filter_invalid_or_denied_shape_edits_are_atomic() { + let mut s = smart_builtin("square", 5); + install_square(&mut s); + for params in [json!({"shapeId":"missing"}), json!({"shapeName":"missing"}), json!({"shapeName":false})] { + rejected_smart_params(&mut s, params); + } + s.execute("layer.smartFilter.setParams", json!({"index":0,"params":{"shapeId":SHAPE_ID}})).unwrap(); + rejected_smart_params(&mut s, json!({"shapeId":"missing"})); + rejected_smart_params(&mut s, json!({"shapeName":"missing"})); + s.authorize = Some(|id, _| if id == "filter.blur.shapeBlur" { Err(photocraft_engine::EngineError::Other("shape edit denied".into())) } else { Ok(()) }); + for params in [json!({"radius":6}), json!({"shape":"circle"})] { + let error = rejected_smart_params(&mut s, params); + assert!(error.to_string().contains("shape edit denied"), "{error}"); + } +} + +fn rejected_smart_params(s: &mut Session, params: serde_json::Value) -> photocraft_engine::EngineError { + let before = saved(s); + let history = s.active().unwrap().history.past_len(); + let revision = s.active().unwrap().revision; + let error = s.execute("layer.smartFilter.setParams", json!({"index":0,"params":params})).unwrap_err(); + assert_eq!(saved(s), before); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.active().unwrap().revision, revision); + error +} + +/// A conflicting display name must not select a different kernel; playback remains undoable. +#[test] +fn custom_shape_uses_installed_id_despite_name_collision_and_undoes() { + let original = descriptor(); + let imported = atn_cmds::import(&encoded(original.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + let (out, skipped) = atn_cmds::export("Shapes", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(original)); + for depth in [8, 16, 32] { + let mut builtin = fixture(depth); + builtin.execute("filter.blur.shapeBlur", json!({"radius":5,"shape":"square"})).unwrap(); + let expected = pixels(&builtin); + let mut s = fixture(depth); + install_square(&mut s); + let before = saved(&s); + s.actions.list = imported.actions.clone(); + assert_eq!(s.execute("actions.play", json!({"action":"Shape"})).unwrap()["ran"], 1); + assert_eq!(pixels(&s), expected, "{depth}-bit square custom kernel"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + } +} + +/// A saved Smart Filter carries geometry so reopening does not depend on ambient presets. +#[test] +fn custom_shape_smart_filter_keeps_geometry_across_save_without_preset_library() { + let mut s = fixture(16); + install_square(&mut s); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("filter.blur.shapeBlur", json!({"radius":5,"shapeId":SHAPE_ID})).unwrap(); + let expected = pixels(&s); + let doc = photocraft_format::load_from_bytes(&saved(&s)).unwrap(); + let filter = doc + .walk() + .into_iter() + .find_map(|(_, _, l)| match &l.content { + LayerContent::Smart(sm) => sm.smart_filters.first(), + _ => None, + }) + .unwrap(); + assert!(filter.params["shapePath"].is_object()); + let mut reopened = Session::new(); + assert!(!reopened.presets.shapes.iter().flat_map(|g| &g.items).any(|shape| shape.id.as_deref() == Some(SHAPE_ID))); + reopened.add_document(doc, None); + reopened.execute("layer.smartFilter.setVisible", json!({"index":0,"visible":false})).unwrap(); + assert_ne!(pixels(&reopened), expected); + reopened.execute("layer.smartFilter.setVisible", json!({"index":0,"visible":true})).unwrap(); + assert_eq!(pixels(&reopened), expected); +} + +/// Exercise the CSH-to-action bridge, including duplicate imports and durable ID lookup. +#[test] +fn imported_csh_id_resolves_in_an_action_after_reimport_and_preference_reload() { + let mut s = fixture(16); + let bytes = csh_fixture::triangle_csh(); + let id = photocraft_psd::csh::parse(&bytes).unwrap()[0].id.clone(); + let p = json!({"data":photocraft_paint::tile::b64_encode(&bytes)}); + s.execute("shape.presets.importCsh", p.clone()).unwrap(); + s.execute("shape.presets.importCsh", p).unwrap(); + let matches: Vec<_> = s.presets.shapes.iter().flat_map(|g| &g.items).filter(|shape| shape.id.as_deref() == Some(id.as_str())).collect(); + assert_eq!(matches.len(), 2, "both imports retain the source's opaque ID"); + let path = photocraft_engine::vector_cmds::path_json(&matches[0].path); + let mut expected = fixture(16); + expected.execute("filter.blur.shapeBlur", json!({"radius":5,"shapePath":path})).unwrap(); + let mut square = fixture(16); + square.execute("filter.blur.shapeBlur", json!({"radius":5,"shape":"square"})).unwrap(); + assert_ne!(pixels(&expected), pixels(&square), "the non-square imported triangle must differ from a square kernel"); + let prefs = s.presets.to_json(&s); + let mut s = fixture(16); + s.load_presets_json(prefs); + let shape = + Descriptor::new("customShape").with("Nm ", D::Text(UnicodeString::new_nul("Old shape name"))).with("Idnt", D::Text(UnicodeString::new_nul(&id))); + let d = Descriptor::new("null") + .with("Rds ", D::UnitFloat { unit: *b"#Pxl", value: 5.0 }) + .with("customShape", D::Descriptor(shape)) + .with("keyOriginType", D::Integer(9)); + let imported = atn_cmds::import(&encoded(d)).unwrap(); + assert!(imported.unsupported.is_empty()); + s.actions.list = imported.actions; + let before = saved(&s); + assert_eq!(s.execute("actions.play", json!({"action":"Shape"})).unwrap()["ran"], 1); + assert_eq!(pixels(&s), pixels(&expected)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + // This original fixture has the same ID but a different path. Keep both presets + // available by their unique names, while refusing ambiguous action playback. + let other = csh_fixture::polygon_csh(&[&[(0.25, 0.25), (0.75, 0.25), (0.75, 0.75), (0.25, 0.75)]]); + assert_eq!(photocraft_psd::csh::parse(&other).unwrap()[0].id, id); + s.execute("shape.presets.importCsh", json!({"data":photocraft_paint::tile::b64_encode(&other)})).unwrap(); + let result = s.execute("actions.play", json!({"action":"Shape"})).unwrap(); + assert_eq!(result["ran"], 0); + assert_eq!(result["failed"]["id"], "filter.blur.shapeBlur"); + assert!(result["failed"]["error"].as_str().unwrap().contains("different installed shapes"), "{result}"); + assert_eq!(saved(&s), before); +} + +/// Admission failure must leave both pixels and the Undo history untouched. +#[test] +fn missing_or_invalid_custom_shapes_refuse_before_document_or_history_changes() { + let mut s = fixture(8); + let before = saved(&s); + let history = s.active().unwrap().history.past_len(); + for p in [ + json!({"radius":5,"shapeId":"missing","shapeName":"Triangle"}), + json!({"radius":5,"shapeId":""}), + json!({"radius":5,"shapeName":false}), + json!({"radius":5,"shapePath":{"subpaths":[]}}), + json!({"radius":5,"shapePath":{"subpaths":[{"closed":true,"knots":[[0,0],[1,0],[2,0]]}]}}), + json!({"radius":5,"shapePath":{"subpaths":[{"knots":[[-1.7e308,0],[1.7e308,0],[0,1.7e308]]}]}}), + json!({"radius":5,"shapeOriginType":8}), + ] { + assert!(s.execute("filter.blur.shapeBlur", p.clone()).is_err(), "{p}"); + assert_eq!(saved(&s), before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + let imported = atn_cmds::import(&encoded(descriptor())).unwrap(); + s.actions.list = imported.actions; + // Playback reports a failed step in its successful response; native command errors are not outer errors. + let result = s.execute("actions.play", json!({"action":"Shape"})).unwrap(); + assert_eq!(result["ran"], 0); + assert_eq!(result["failed"]["id"], "filter.blur.shapeBlur"); + assert!(result["failed"]["error"].as_str().unwrap().contains("no installed shape"), "{result}"); + assert_eq!(saved(&s), before); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + +/// New IDs survive preferences serialization without requiring them in older stored presets. +#[test] +fn shape_ids_persist_and_old_preferences_without_ids_still_load() { + let mut s = Session::new(); + install_square(&mut s); + let prefs = s.prefs_to_json(); + let mut reopened = Session::new(); + reopened.load_prefs_json(&prefs).unwrap(); + assert!(reopened.presets.shapes.iter().flat_map(|g| &g.items).any(|shape| shape.id.as_deref() == Some(SHAPE_ID) && shape.name == "Renamed Square")); + let before = reopened.prefs_to_json(); + assert!(reopened.execute("shape.presets.new", json!({"id":SHAPE_ID,"path":"M 0 0 L 10 0 L 5 10 Z"})).is_err()); + assert_eq!(reopened.prefs_to_json(), before); + let mut old: serde_json::Value = serde_json::from_str(&prefs).unwrap(); + for group in old["presets"]["shapes"].as_array_mut().unwrap() { + for shape in group["items"].as_array_mut().unwrap() { + shape.as_object_mut().unwrap().remove("id"); + } + } + reopened.load_prefs_json(&old.to_string()).unwrap(); + assert!(reopened.presets.shapes.iter().flat_map(|g| &g.items).all(|shape| shape.id.is_none())); +} + +/// Outer descriptor metadata and its encoding stay whole when the native mapper cannot reproduce them. +#[test] +fn noncanonical_shape_outer_metadata_stays_on_the_preserved_path() { + for (name, class_id) in [ + (UnicodeString::new_nul("Recorded shape"), Id::new("null")), + (UnicodeString(vec![0xd800, 0]), Id::new("null")), + (UnicodeString::default(), Id::new("otherOuterClass")), + (UnicodeString::default(), Id::Str(b"null".to_vec())), + ] { + let mut d = descriptor(); + d.name = name; + d.class_id = class_id; + let imported = atn_cmds::import(&encoded(d.clone())).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (out, _) = atn_cmds::export("Shapes", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + } +} + +/// Neither unknown recording settings nor portable native geometry may be dropped during export. +#[test] +fn unrepresented_shape_fields_stay_whole_and_native_geometry_refuses_atn_export() { + for (key, value) in [ + ("keyOriginType", D::Integer(8)), + ("Rds ", D::Double(5.0)), + ("customShape", D::Descriptor(Descriptor::new("other").with("Idnt", D::Text(UnicodeString::new_nul(SHAPE_ID))))), + ("extra", D::Boolean(true)), + ] { + let mut d = descriptor(); + d.items.retain(|(k, _)| !k.is(key)); + d.items.push((Id::new(key), value)); + let imported = atn_cmds::import(&encoded(d.clone())).unwrap(); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (out, _) = atn_cmds::export("Shapes", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d)); + } + let imported = atn_cmds::import(&encoded(descriptor())).unwrap(); + for (key, value) in [("shapePath", json!({"subpaths":[]})), ("shape", json!("star")), ("target", json!("mask")), ("shapeId", json!(""))] { + let mut a = imported.actions[0].clone(); + a.steps[0].1[key] = value; + assert!(atn_cmds::export("Shapes", &[&a], &[], false).is_err()); + } +} + +/// A Shape Blur run by an installed shape's id or name records its portable geometry, so the +/// journal (Last Filter, recorded actions) replays it where the preset is not installed. +#[test] +fn recorded_custom_shape_blur_keeps_portable_geometry() { + for background in [false, true] { + for selector in [json!({"radius":5,"shapeId":SHAPE_ID}), json!({"radius":5,"shapeName":"Renamed Square"})] { + let mut s = fixture(16); + install_square(&mut s); + if background { + let photocraft_engine::jobs::Started::Job(job) = s.start("filter.blur.shapeBlur", selector.clone()).unwrap() else { + panic!("background filter job") + }; + s.wait_job(job).unwrap(); + } else { + s.execute("filter.blur.shapeBlur", selector.clone()).unwrap(); + } + let (id, params) = s.journal.last().cloned().unwrap(); + assert_eq!(id, "filter.blur.shapeBlur"); + assert!(params["shapePath"].is_object(), "background={background} {selector}: {params}"); + + let mut replay = fixture(16); + replay.execute(&id, params).unwrap(); + assert_eq!(pixels(&replay), pixels(&s), "{selector}"); + } + } +} diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs new file mode 100644 index 0000000000..fb6e605d4d --- /dev/null +++ b/crates/engine/tests/it/atn_shear.rs @@ -0,0 +1,258 @@ +//! Public load/save/playback witnesses for the measured Shear action mapping. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{Imported, export, import}, +}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn desc(class: &str) -> Descriptor { + Descriptor::new(class) +} +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} +/// Four-byte recorded IDs retain their code form; longer IDs exercise string-event lookup. +fn item(event: &str, label: &str, descriptor: Option) -> AtnItem { + let event = match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::name(event), + }; + AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor, ..Default::default() } +} +fn one_action(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("Saves"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + ..Default::default() + } +} +fn b64(bytes: &[u8]) -> String { + photocraft_engine::paint::tile::b64_encode(bytes) +} +fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported { + import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap() +} +/// Save one real native action and parse its public output, rather than calling a private converter. +fn export_item(id: &str, params: &Value, set: &str) -> Option { + let action = Action::new("A", vec![(id.into(), params.clone())]); + let bytes = export(set, &[&action], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.first()?.items.first().cloned() +} + +/// The measured Shear list uses ordered horizontal/vertical doubles inside point objects. +fn shear_descriptor(points: &[[f64; 2]], undefined: &str) -> Descriptor { + let list = points.iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + desc("null").with("ShrP", D::List(list)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(points.len() as i32 - 1)) +} + +/// Both event identities, changed curves and missing-key defaults save every measured setting in order. +#[test] +fn shear_steps_roundtrip_curves_defaults_and_flags() { + let factory = shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "WrpA"); + for event in ["Shr ", "shear"] { + for d in [factory.clone(), shear_descriptor(&[[-32.0, 1.0], [16.0, 64.5], [0.0, 128.0]], "RptE")] { + let loaded = mapped_recording(event, d.clone()); + assert!(loaded.unsupported.is_empty()); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, "filter.distort.shear"); + let bytes = export("S", &[action], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0].event, AtnEvent::Code(*b"Shr ")); + assert_eq!(saved.actions[0].items[0].descriptor, Some(d)); + assert_eq!(import(&bytes).unwrap().actions[0].steps, action.steps); + } + } + let loaded = mapped_recording("Shr ", desc("null")); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].1, json!({"points":[[0,0],[1,0]],"undefinedAreas":"wrap"})); + assert_eq!(export_item("filter.distort.shear", &loaded.actions[0].steps[0].1, "S").unwrap().descriptor, Some(factory)); + let mut step = item("Shr ", "Shear", Some(shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "RptE"))); + step.enabled = false; + step.with_dialog = true; + step.dialog_options = 2; // The wire mode for showing the enabled dialog toggle. + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0], step); +} + +/// Clamp hostile coordinates, but retain finite fractional wire values that division by127 cannot invert. +#[test] +fn shear_clamps_points_and_preserves_fractional_wire_values() { + let raw = shear_descriptor(&[[-10000.0, -1000.0], [10000.0, 1000.0]], "WrpA"); + let loaded = mapped_recording("Shr ", raw); + assert!(loaded.unsupported.is_empty()); + let p = &loaded.actions[0].steps[0].1; + assert_eq!(p["points"], json!([[0, -1], [1, 63.0 / 64.0]])); + assert_eq!(export_item("filter.distort.shear", p, "S").unwrap().descriptor, Some(shear_descriptor(&[[-64.0, 1.0], [63.0, 128.0]], "WrpA"))); + + let d = shear_descriptor(&[[0.0, 1.0], [7.97, 4.975609756097561], [0.0, 128.0]], "RptE"); + let loaded = mapped_recording("shear", d.clone()); + assert!(loaded.unsupported.is_empty()); + let mut p = loaded.actions[0].steps[0].1.clone(); + assert!(p.get("_atnShearPoints").is_some(), "the measured inverse loses a wire bit"); + assert_eq!(export_item("filter.distort.shear", &p, "S").unwrap().descriptor, Some(d)); + let mut edited = p.clone(); + let position = p["points"][1][0].as_f64().unwrap(); + edited["points"][1][0] = json!(position + 1e-12); + assert_ne!(edited["points"][1][0], p["points"][1][0]); + assert_eq!(edited["points"][1][0].as_f64().unwrap() as f32, position as f32); + assert!(export_item("filter.distort.shear", &edited, "S").is_none(), "stored wire metadata rejects even an edit below native precision"); + p["points"][1][1] = json!(0.25); + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "stale wire metadata must not replace a native edit"); +} + +/// The adapter's engineering budget admits its boundary and preserves larger recordings intact. +#[test] +fn shear_point_budget_preserves_recordings_and_refuses_oversized_native_exports() { + const LIMIT: usize = 4096; + for count in [LIMIT, LIMIT + 1] { + let grid: Vec<_> = (0..count).map(|i| [0.0, 1.0 + 127.0 * i as f64 / (count - 1) as f64]).collect(); + let label = if count == LIMIT { "Shear" } else { "Budget curve" }; + let mut source = item("Shr ", label, Some(shear_descriptor(&grid, "WrpA"))); + source.enabled = false; + let loaded = import(&atn::write(&one_action(vec![source.clone()])).unwrap()).unwrap(); + let action = &loaded.actions[0]; + if count == LIMIT { + assert!(loaded.unsupported.is_empty()); + assert_eq!(action.steps[0].0, "filter.distort.shear"); + assert_eq!(action.steps[0].1["points"].as_array().unwrap().len(), LIMIT); + } else { + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(action.steps[0].0, "actions.photoshopStep"); + } + let saved = export("S", &[action], &[], false).unwrap().0; + assert!(atn::parse(&saved).unwrap().actions[0].items[0] == source, "{count}-point item changed on save"); + + let native = json!({"points":grid.iter().map(|p| [(p[1] - 1.0) / 127.0, p[0] / 64.0]).collect::>()}); + let before = native.clone(); + assert_eq!(export_item("filter.distort.shear", &native, "S").is_some(), count == LIMIT); + assert_eq!(native, before, "export never edits the caller's curve"); + if count > LIMIT { + let metadata = json!({"points":[[0,0],[1,0]],"_atnShearPoints":grid}); + assert!(export_item("filter.distort.shear", &metadata, "S").is_none()); + } + } +} + +/// Unmeasured subranges, malformed points and repeated settings remain whole, including their flags. +#[test] +fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { + let full = shear_descriptor(&[[0.0, 1.0], [8.0, 64.5], [0.0, 128.0]], "WrpA"); + let mut missing_end = full.clone(); + missing_end.items.pop(); // Missing ShrE means1, not the supplied list's last index2. + let mut invalid_start = full.clone(); + invalid_start.items[2].1 = D::Integer(1); + let mut invalid_end = full.clone(); + invalid_end.items[3].1 = D::Integer(100); + let mut wrong_type = full.clone(); + wrong_type.items[2].1 = D::Double(0.0); + for d in [ + missing_end, + invalid_start, + invalid_end, + wrong_type, + full.clone().with("ShrS", D::Integer(0)), + full.clone().with("extra", D::Boolean(true)), + desc("null").with("ShrP", D::List(vec![])), + desc("null").with("ShrP", D::List(vec![D::Integer(7)])), + desc("null").with("ShrP", D::List(vec![D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(0.0)))])), + desc("null").with("UndA", enm("UndA", "Trns")), + desc("null").with("UndA", enm("Other", "WrpA")), + shear_descriptor(&[[0.0, 128.0], [0.0, 1.0]], "WrpA"), + shear_descriptor(&[[0.0, 1.0], [8.0, 1.0], [0.0, 128.0]], "WrpA"), + ] { + let step = item("Shr ", "Shear", Some(d)); + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{step:?}"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], step); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let name = loaded.actions[0].name.clone(); + s.actions.list.extend(loaded.actions); + let report = s.execute("actions.play", json!({"action":name})).unwrap(); + assert_eq!(report["ran"], 0); + assert!(report.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// Native exports follow points-before-amount and refuse settings that the measured curve cannot carry. +#[test] +fn shear_native_exports_keep_effective_defaults_and_refuse_lossy_options() { + for p in [ + json!({}), + json!({"amount":25}), + json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"}), + json!({"points":[[0,0],[0.009,0.125],[1,0]]}), + ] { + let item = export_item("filter.distort.shear", &p, "S").unwrap(); + let loaded = mapped_recording("Shr ", item.descriptor.unwrap()); + assert!(loaded.unsupported.is_empty()); + let steps = &loaded.actions[0].steps; + assert_eq!( + photocraft_engine::filters_ext::params_for("filter.distort.shear", &steps[0].1), + photocraft_engine::filters_ext::params_for("filter.distort.shear", &p) + ); + } + for p in [ + json!({"points":[]}), + json!({"points":[[0,0],[1,"bad"]]}), + json!({"points":[[1,0],[0,0]]}), + json!({"points":[[0,0],[1,1]]}), + json!({"amount":100}), + json!({"undefinedAreas":"transparent"}), + json!({"target":"mask"}), + json!({"layer":1}), + json!({"other":true}), + json!({"points":[[0,0],[1,0]],"_atnShearPoints":[[0,1],[8,128]]}), + ] { + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "{p} exported"); + } +} + +/// A constant measured curve shifts pixels right by two, with independent wrap/repeat border and Undo oracles. +#[test] +fn shear_action_moves_real_pixels_and_undoes_at_each_depth() { + for depth in [8, 16, 32] { + for edge in ["WrpA", "RptE"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.edit("Pattern", |doc, _| { + let surface = doc.layers[0].surface_mut().unwrap(); + let data: Vec = (0..8).flat_map(|y| (0..32).flat_map(move |x| [x as f32 / 31.0, y as f32 / 7.0, (x % 3) as f32 / 2.0, 1.0])).collect(); + surface.write_region(photocraft_geom::Rect::new(0, 0, 32, 8), &data); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before); + let bytes = atn::write(&one_action(vec![item("Shr ", "Shear", Some(shear_descriptor(&[[8.0, 1.0], [8.0, 128.0]], edge)))])).unwrap(); + let loaded = s.execute("actions.load", json!({"data":b64(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + let played = s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let output = photocraft_compose::flatten(&s.active().unwrap().doc); + assert_ne!(output, pixels); + for y in 0..8 { + for x in 0..32 { + let source = if edge == "WrpA" { (x - 2i32).rem_euclid(32) } else { (x - 2i32).max(0) }; + for (got, want) in output.get(x, y).into_iter().zip(pixels.get(source, y)) { + assert!((got - want).abs() < 1e-5, "depth{depth} {edge} ({x},{y}):{got} != {want}"); + } + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} diff --git a/crates/engine/tests/it/atn_stack_modes.rs b/crates/engine/tests/it/atn_stack_modes.rs new file mode 100644 index 0000000000..2de2d887df --- /dev/null +++ b/crates/engine/tests/it/atn_stack_modes.rs @@ -0,0 +1,243 @@ +//! The measured Smart Object Stack Mode events retain their descriptor and native behavior. + +use photocraft_doc::{Group, Layer, LayerContent, StackMode}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_geom::Rect; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::json; + +const APPLY: &str = "applyImageStackPluginRenderer"; +const CLEAR: &str = "imageStackConvertSmartObject"; +const ORIGIN: &str = "_atnStackDescriptor"; + +/// Independent pairs from the measured table in docs/atn.md. +const MODES: &[(&str, &str, StackMode)] = &[ + ("rang", "Range", StackMode::Range), + ("entr", "Entropy", StackMode::Entropy), + ("kurt", "Kurtosis", StackMode::Kurtosis), + ("maxx", "Maximum", StackMode::Maximum), + ("medn", "Median", StackMode::Median), + ("minn", "Minimum", StackMode::Minimum), + ("avrg", "Mean", StackMode::Mean), + ("skew", "Skewness", StackMode::Skewness), + ("stdv", "Standard Deviation", StackMode::StandardDeviation), + ("summ", "Summation", StackMode::Summation), + ("vari", "Variance", StackMode::Variance), +]; + +fn descriptor(code: &str, name: &str) -> Descriptor { + Descriptor::new("null") + .with("imageStackPlugin", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(code) })) + .with("Nm ", D::Text(UnicodeString::new_nul(name))) +} + +fn item(event: &str, descriptor: Option) -> AtnItem { + AtnItem { event: AtnEvent::name(event), descriptor, ..Default::default() } +} + +fn load(item: AtnItem) -> atn_cmds::Imported { + let file = AtnFile { + name: UnicodeString::new_nul("Stacks"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Mode"), items: vec![item], ..Default::default() }], + ..Default::default() + }; + atn_cmds::import(&atn::write(&file).unwrap()).unwrap() +} + +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Stacks", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0) +} + +/// Exercise actual load/save bytes for all plugin classes and the separate None event. +#[test] +fn measured_stack_modes_and_none_import_export_and_reload() { + for &(code, name, mode) in MODES { + let original = item(APPLY, Some(descriptor(code, name))); + let imported = load(original.clone()); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![(format!("layer.smartObjects.stackMode.{}", mode.id()), json!({}))]); + let out = saved(&imported.actions[0]); + assert_eq!(out.event, original.event); + assert_eq!(out.descriptor, original.descriptor); + assert_eq!(load(out).actions[0].steps, imported.actions[0].steps); + } + for original in [item(CLEAR, None), item(CLEAR, Some(Descriptor::new("null")))] { + let imported = load(original.clone()); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].0, "layer.smartObjects.stackMode.none"); + let out = saved(&imported.actions[0]); + assert_eq!(out.event, original.event); + assert_eq!(out.descriptor, original.descriptor); + assert_eq!(load(out).actions[0].steps, imported.actions[0].steps); + } + let native = Action::new("Mode", vec![("layer.smartObjects.stackMode.none".into(), json!({}))]); + let out = saved(&native); + assert_eq!(out.event, AtnEvent::name(CLEAR)); + assert!(out.descriptor.is_none()); +} + +/// Localized names and noncanonical wire metadata still replay the class-selected mode. +#[test] +fn localized_stack_descriptors_keep_original_metadata_and_flags() { + for name in [UnicodeString::new("Moyenne"), UnicodeString::new_nul("平均值"), UnicodeString(vec![0xd800, 0, 0])] { + let mut d = descriptor("avrg", "Mean"); + d.name = UnicodeString::new_nul("Recorded stack"); + d.class_id = Id::Str(b"null".to_vec()); + d.items[1].1 = D::Text(name); + d.items[1].0 = Id::Str(b"Nm ".to_vec()); + d.items.reverse(); + let mut original = item(APPLY, Some(d)); + original.enabled = false; + original.with_dialog = true; + original.dialog_options = 2; + let imported = load(original.clone()); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].0, "layer.smartObjects.stackMode.mean"); + let out = saved(&imported.actions[0]); + assert_eq!(out.descriptor, original.descriptor); + assert!(!out.enabled); + assert!(out.with_dialog); + assert_eq!(out.dialog_options, 2); + } + let mut d = Descriptor::new("null"); + d.name = UnicodeString(vec![0xd800, 0]); + d.class_id = Id::Str(b"null".to_vec()); + let imported = load(item(CLEAR, Some(d.clone()))); + assert!(imported.unsupported.is_empty()); + assert_eq!(saved(&imported.actions[0]).descriptor, Some(d)); +} + +fn stack(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":3,"height":2,"depth":depth})).unwrap(); + s.edit("Fixture", |doc, active| { + let fmt = doc.pixel_format(); + let children = [0.2, 0.2, 0.8] + .into_iter() + .enumerate() + .map(|(index, value)| { + let mut layer = Layer::raster(format!("Frame {index}"), fmt); + let pixel = photocraft_raster::from_rgba(&fmt, [value, value, value, 1.0]); + layer.surface_mut().unwrap().write_region(Rect::new(0, 0, 3, 2), &pixel.repeat(6)); + layer + }) + .collect(); + let group = Layer::new("Stack", LayerContent::Group(Group { children, expanded: true, artboard: None, frame_stroke: None })); + let smart = photocraft_engine::smart_cmds::layer_to_smart(doc, &group)?; + *active = Some(smart.id); + doc.layers = vec![smart]; + Ok(()) + }) + .unwrap(); + s +} + +/// Compare the adapter with existing native commands, including pixel output and one Undo/Redo. +#[test] +fn stack_recordings_play_native_modes_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let mut s = stack(depth); + let modes = MODES.iter().map(|&(code, _, mode)| (item(APPLY, Some(descriptor(code, "Recorded name"))), Some(mode))); + let clear_forms = [None, Some(Descriptor::new("null"))].into_iter().map(|d| (item(CLEAR, d), None)); + for (source, mode) in modes.chain(clear_forms) { + let starting = if mode == Some(StackMode::Maximum) { "none" } else { "maximum" }; + s.execute(&format!("layer.smartObjects.stackMode.{starting}"), json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let before_steps = s.active().unwrap().history.past_len(); + let command = format!("layer.smartObjects.stackMode.{}", mode.map_or("none", StackMode::id)); + s.execute(&command, json!({})).unwrap(); + let expected = s.active().unwrap().doc.clone(); + s.execute("edit.undo", json!({})).unwrap(); + let imported = load(source); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action":"Mode","set":"Stacks"})).unwrap(); + assert_eq!(result["ran"], 1, "{result}"); + let actual = s.active().unwrap(); + assert_eq!(actual.doc.layers, expected.layers, "{command} at {depth}-bit"); + assert_eq!(actual.history.past_len(), before_steps + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers, before.layers); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers, expected.layers); + } + } +} + +/// Unrepresented types, duplicate keys and extra settings stay complete kept steps. +#[test] +fn unsupported_stack_recordings_save_whole_and_refuse_playback() { + let canonical = descriptor("medn", "Median"); + let mut duplicate = canonical.clone(); + duplicate.items.push(duplicate.items[0].clone()); + let mut wrong_name = canonical.clone(); + wrong_name.items[1].1 = D::Integer(5); + let mut global_class = canonical.clone(); + let D::Class(class) = global_class.items[0].1.clone() else { unreachable!() }; + global_class.items[0].1 = D::GlobalClass(class); + let extra = canonical.clone().with("unknown", D::Boolean(true)); + for original in [ + item(APPLY, Some(duplicate)), + item(APPLY, Some(wrong_name)), + item(APPLY, Some(global_class)), + item(APPLY, Some(extra)), + item(APPLY, Some(descriptor("nope", "Median"))), + item(APPLY, None), + item(CLEAR, Some(canonical)), + item(CLEAR, Some(Descriptor::new("nope"))), + ] { + let imported = load(original.clone()); + assert_eq!(imported.unsupported.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let out = saved(&imported.actions[0]); + assert_eq!(out.event, original.event); + assert_eq!(out.descriptor, original.descriptor); + let mut s = stack(8); + s.execute("layer.smartObjects.stackMode.maximum", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action":"Mode","set":"Stacks"})).unwrap(); + assert!(result.get("failed").is_some(), "{result}"); + assert_eq!(s.active().unwrap().doc.layers, before.layers); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} + +/// Native mode edits replace the old class; explicit targets and damaged origin metadata refuse export. +#[test] +fn stack_mode_edits_and_unrepresented_native_parameters_are_safe() { + let imported = load(item(APPLY, Some(descriptor("medn", "Médiane")))); + let mut action = imported.actions[0].clone(); + action.steps[0].0 = "layer.smartObjects.stackMode.maximum".into(); + assert_eq!(saved(&action).descriptor, Some(descriptor("maxx", "Maximum"))); + + action.steps[0].0 = "layer.smartObjects.stackMode.none".into(); + let cleared = saved(&action); + assert_eq!(cleared.event, AtnEvent::name(CLEAR)); + assert_eq!(cleared.descriptor, Some(Descriptor::new("null"))); + + let mut action = load(item(CLEAR, Some(Descriptor::new("null")))).actions.remove(0); + action.steps[0].0 = "layer.smartObjects.stackMode.mean".into(); + let applied = saved(&action); + assert_eq!(applied.event, AtnEvent::name(APPLY)); + assert_eq!(applied.descriptor, Some(descriptor("avrg", "Mean"))); + + let unknown_empty = photocraft_engine::paint::tile::b64_encode(&Descriptor::new("nope").to_bytes()); + let unknown_class = photocraft_engine::paint::tile::b64_encode(&descriptor("nope", "Mean").to_bytes()); + for command in ["layer.smartObjects.stackMode.mean", "layer.smartObjects.stackMode.none"] { + for params in [json!(null), json!([]), json!({"layer":12}), json!({"layer":"Named"}), json!({"target":"mask"}), json!({"unknown":true})] { + let action = Action::new("Mode", vec![(command.into(), params)]); + assert!(atn_cmds::export("Stacks", &[&action], &[], false).is_err()); + } + for origin in [json!(false), json!("!invalid-base64"), json!(""), json!(unknown_class), json!(unknown_empty)] { + let mut action = Action::new("Mode", vec![(command.into(), json!({}))]); + action.steps[0].1[ORIGIN] = origin; + assert!(atn_cmds::export("Stacks", &[&action], &[], false).is_err()); + } + } +} diff --git a/crates/engine/tests/it/atn_type_path.rs b/crates/engine/tests/it/atn_type_path.rs new file mode 100644 index 0000000000..f5b565b849 --- /dev/null +++ b/crates/engine/tests/it/atn_type_path.rs @@ -0,0 +1,183 @@ +//! Recorded Type > Create Work Path preserves its descriptor and uses native glyph outlines. +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const COMMAND: &str = "type.createWorkPath"; +const SOURCE: &str = "_atnTypePathDescriptor"; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } +} + +/// Build the measured descriptor independently of the production converter. +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])) + .with("From", D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])) +} + +fn item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(*b"Mk "), dictionary: b"Make".to_vec(), descriptor: Some(d), ..Default::default() } +} + +/// Exercise the public byte importer rather than invoking the converter directly. +fn load(item: AtnItem) -> photocraft_engine::atn_cmds::Imported { + let bytes = atn::write(&AtnFile { + name: UnicodeString::new_nul("Paths"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Path"), items: vec![item], ..Default::default() }], + ..Default::default() + }) + .unwrap(); + import(&bytes).unwrap() +} + +/// Parse public export bytes so assertions inspect the actual saved ATN item. +fn saved(action: &Action) -> AtnItem { + let bytes = export("Paths", &[action], &[], false).unwrap().0; + atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0) +} + +/// Names, key order, ID encoding and dialog flags must survive both Make event aliases. +#[test] +fn descriptor_identity_flags_and_make_alias_roundtrip() { + for named in [false, true] { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded path"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + if named { + source.event = AtnEvent::name("make"); + } + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let loaded = load(source.clone()); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].0, COMMAND); + let result = saved(&loaded.actions[0]); + assert_eq!(result.descriptor.unwrap().to_bytes(), source.descriptor.unwrap().to_bytes()); + assert!(!result.enabled); + assert!(result.with_dialog); + assert_eq!(result.dialog_options, 2); + } +} + +/// Compare enabled canonical and retained-descriptor playback with native output at all depths. +/// Glyph bounds distinguish the older active text layer from the newest layer independently. +#[test] +fn playback_matches_native_path_and_preserves_text_with_undo_redo() { + for noncanonical in [false, true] { + for depth in [8, 16, 32] { + let mut recorded = Session::new(); + recorded.execute("file.new", json!({"width":160,"height":100,"depth":depth})).unwrap(); + let text = recorded.execute("type.create", json!({"x":90,"y":80,"text":"ill","size":30})).unwrap(); + let text = LayerId(text["layer"].as_u64().unwrap()); + let other = recorded.execute("type.create", json!({"x":8,"y":35,"text":"o","size":24})).unwrap(); + let other = LayerId(other["layer"].as_u64().unwrap()); + assert_eq!(recorded.active().unwrap().active_layer, Some(other)); + recorded.select_layer(text).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let glyphs = before.layer(text).unwrap().surface().unwrap().content_bounds(); + let other_glyphs = before.layer(other).unwrap().surface().unwrap().content_bounds(); + let past = recorded.active().unwrap().history.past_len(); + // Obtain the native result on this very document, then restore the starting state. + recorded.execute(COMMAND, json!({})).unwrap(); + let native = recorded.active().unwrap().doc.clone(); + assert!(!native.work_path.as_ref().unwrap().subpaths.is_empty()); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref()); + let mut d = descriptor(); + if noncanonical { + d.items.reverse(); + d.name = UnicodeString::new_nul("Recorded path"); + } + recorded.actions.list = load(item(d)).actions; + if noncanonical { + assert!(recorded.actions.list[0].steps[0].1.get(SOURCE).is_some()); + } + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.as_ref(), native.as_ref()); + // Rendered glyph bounds are an independent oracle for the implicit active target. + let (x0, y0, x1, y1) = after.work_path.as_ref().unwrap().control_bounds().unwrap(); + for (path, ink) in [(x0, glyphs.x0), (y0, glyphs.y0), (x1, glyphs.x1), (y1, glyphs.y1)] { + assert!((path - f64::from(ink)).abs() < 3.0, "path {path}, active glyph bound {ink}"); + } + assert!(x0 > f64::from(other_glyphs.x1), "the other layer's glyphs must not supply the work path"); + assert!(matches!(after.layer(text).unwrap().content, LayerContent::Text(_))); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +/// Unknown keys and unsupported reference chains remain verbatim kept steps, never guessed mappings. +#[test] +fn unsupported_path_forms_are_kept_and_save_without_loss() { + let original = descriptor(); + let mut malformed = vec![ + original.clone().with("extra", D::Boolean(true)), + original.clone().with("From", D::Boolean(false)), + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])), + ]; + for from in [ + D::Reference(vec![ReferenceItem::Name { class: class("TxLr"), name: UnicodeString::new_nul("Text") }]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Al ") }]), + D::Reference(vec![ReferenceItem::Class(class("TxLr"))]), + ] { + let mut d = original.clone(); + d.items.iter_mut().find(|(k, _)| k.is("From")).unwrap().1 = from; + malformed.push(d); + } + for d in malformed { + let source = item(d); + let loaded = load(source.clone()); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + assert_eq!(saved(&loaded.actions[0]), source); + } +} + +/// A missing document or non-text receiver must fail without changing document or history. +#[test] +fn non_text_and_no_document_playback_fail_without_edits() { + for document in [false, true] { + let mut s = Session::new(); + if document { + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + } + let before = s.active().map(|st| (st.doc.clone(), st.history.past_len())); + s.actions.list = load(item(descriptor())).actions; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some(), "{result}"); + assert_eq!(s.active().map(|st| (st.doc.clone(), st.history.past_len())), before); + } +} + +/// Explicit native targets and corrupt wire metadata cannot export as the implicit measured form. +#[test] +fn native_export_refuses_explicit_targets_and_damaged_metadata() { + for params in [json!({"layer":1}), json!({"layers":[1]}), json!({(SOURCE):"bad base64"}), json!({(SOURCE):Value::Null})] { + let action = Action::new("Path", vec![(COMMAND.into(), params)]); + assert!(export("Paths", &[&action], &[], false).is_err()); + } + let action = Action::new("Path", vec![(COMMAND.into(), json!({}))]); + assert_eq!(saved(&action).descriptor.unwrap(), descriptor()); +} diff --git a/crates/engine/tests/it/atn_video_frames.rs b/crates/engine/tests/it/atn_video_frames.rs new file mode 100644 index 0000000000..43c9261ae1 --- /dev/null +++ b/crates/engine/tests/it/atn_video_frames.rs @@ -0,0 +1,224 @@ +//! #10723: recorded frame edits use the active video layer and the current playhead. +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, UnicodeString, Value as D}, +}; +use serde_json::json; +use std::sync::Arc; + +const EVENTS: [(&str, &str); 3] = [ + ("insertBlankFrame", "layer.videoLayers.insertBlankFrame"), + ("duplicateFrame", "layer.videoLayers.duplicateFrame"), + ("deleteFrame", "layer.videoLayers.deleteFrame"), +]; + +/// Build typed source fixtures without asking the mapper to generate its own oracle. +fn file(item: AtnItem) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Video"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Frame edit"), items: vec![item], ..Default::default() }], + ..Default::default() + }) + .unwrap() +} + +fn item(event: &str, descriptor: Option) -> AtnItem { + AtnItem { event: AtnEvent::name(event), descriptor, ..Default::default() } +} + +/// Exercise strict public export: skipping an unrepresentable step cannot pass this check. +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Video", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions[0].items[0].clone() +} + +/// Playback tests require a native mapping, rather than a successfully loaded kept step. +fn load(s: &mut Session, source: AtnItem) { + let bytes = file(source); + let report = s.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(report["unsupported"], json!([])); +} + +/// Compare actual pixel markers; VideoData equality checks only stack shape. +fn pixels(s: &Session, id: LayerId, original: bool) -> Vec> { + let video = s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap(); + let frames = if original { video.original_frames.as_ref().unwrap() } else { &video.frames }; + frames.iter().map(|frame| frame.pixel(0, 0)).collect() +} + +/// Distinguish both frame order and original footage from the altered display cache. +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":2,"depth":depth,"background":"transparent"})).unwrap(); + s.execute("timeline.create", json!({"duration":3,"fps":24})).unwrap(); + let result = s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + let id = LayerId(result["layer"].as_u64().unwrap()); + let state = s.active_mut().unwrap(); + let doc = Arc::make_mut(&mut state.doc); + let mut frames = Vec::new(); + for red in [0.25, 0.5, 0.75] { + let mut frame = photocraft_raster::Surface::new(doc.pixel_format()); + frame.write_pixel(0, 0, &[red, 0.0, 0.0, 1.0]); + frames.push(frame); + } + let layer = doc.layer_mut(id).unwrap(); + layer.content = LayerContent::Raster(frames[1].clone()); + let video = layer.video.as_mut().unwrap(); + video.original_frames = Some( + frames + .iter() + .rev() + .map(|frame| { + let mut original = frame.clone(); + let red = original.pixel(0, 0)[0]; + original.write_pixel(0, 0, &[red, 1.0, 0.0, 1.0]); + original + }) + .collect(), + ); + video.frames = frames; + let timeline = doc.timeline.as_mut().unwrap(); + timeline.current = 1; + timeline.work_start = 1; + timeline.work_end = 2; + (s, id) +} + +#[test] +/// Import's empty placeholder must not erase the wire's descriptor-presence distinction. +fn descriptors_and_step_flags_round_trip_including_an_absent_descriptor() { + for (event, command) in EVENTS { + for descriptor in [None, Some(Descriptor::default())] { + for enabled in [false, true] { + let mut source = item(event, descriptor.clone()); + source.enabled = enabled; + source.with_dialog = true; + let imported = atn_cmds::import(&file(source)).unwrap(); + assert!(imported.unsupported.is_empty()); + let action = &imported.actions[0]; + assert_eq!(action.steps[0].0, command); + assert_eq!(action.disabled.contains(&0), !enabled); + assert!(action.dialogs.contains(&0)); + let back = saved(action); + assert_eq!(back.event, AtnEvent::name(event)); + assert_eq!(back.descriptor, descriptor); + assert_eq!(back.enabled, enabled); + assert!(back.with_dialog); + } + } + } +} + +#[test] +/// One recorded command must change the correct stack position and remain one undoable edit. +fn playback_edits_the_current_frame_and_original_stack_with_undo_redo_at_each_depth() { + for (depth, has_descriptor) in [(8, true), (16, true), (32, true), (8, false)] { + for (event, _) in EVENTS { + let (mut s, id) = fixture(depth); + let before = pixels(&s, id, false); + let originals = pixels(&s, id, true); + let timeline = s.active().unwrap().doc.timeline.clone(); + let expected = |frames: &[Vec]| match event { + "insertBlankFrame" => vec![frames[0].clone(), frames[1].clone(), vec![0.0; 4], frames[2].clone()], + "duplicateFrame" => vec![frames[0].clone(), frames[1].clone(), frames[1].clone(), frames[2].clone()], + "deleteFrame" => vec![frames[0].clone(), frames[2].clone()], + _ => panic!("fixture event"), + }; + load(&mut s, item(event, has_descriptor.then(Descriptor::default))); + let report = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(report.get("failed").is_none(), "{depth}/{event}: {report}"); + assert_eq!(report["ran"], 1); + assert_eq!(pixels(&s, id, false), expected(&before), "{depth}/{event}"); + assert_eq!(pixels(&s, id, true), expected(&originals), "original {depth}/{event}"); + let t = s.active().unwrap().doc.timeline.as_ref().unwrap(); + assert_eq!((t.current, t.work_start, t.work_end), (1, 1, 2)); + assert_eq!(t.duration, if event == "deleteFrame" { 3 } else { 4 }); + let LayerContent::Raster(display) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("video display") }; + assert_eq!(display.pixel(0, 0), expected(&before)[1]); + s.execute("history.undo", json!({})).unwrap(); + assert_eq!(pixels(&s, id, false), before); + assert_eq!(pixels(&s, id, true), originals); + assert_eq!(s.active().unwrap().doc.timeline, timeline); + s.execute("history.redo", json!({})).unwrap(); + assert_eq!(pixels(&s, id, false), expected(&before)); + assert_eq!(pixels(&s, id, true), expected(&originals)); + } + } +} + +#[test] +/// The native last-frame protection must also hold through recorded playback. +fn deleting_the_last_frame_keeps_its_pixels() { + let (mut s, id) = fixture(8); + let state = s.active_mut().unwrap(); + let doc = Arc::make_mut(&mut state.doc); + let layer = doc.layer_mut(id).unwrap(); + let video = layer.video.as_mut().unwrap(); + video.frames.truncate(1); + video.original_frames.as_mut().unwrap().truncate(1); + layer.content = LayerContent::Raster(video.frames[0].clone()); + let t = doc.timeline.as_mut().unwrap(); + *t = photocraft_doc::video::Timeline::new(1, 24.0); + let before = pixels(&s, id, false); + let original = pixels(&s, id, true); + load(&mut s, item("deleteFrame", Some(Descriptor::default()))); + let report = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(report.get("failed").is_none(), "{report}"); + assert_eq!(pixels(&s, id, false), before); + assert_eq!(pixels(&s, id, true), original); +} + +#[test] +/// Recognizing an event name must never discard settings or reinterpret its unknown header. +fn unrepresented_settings_and_descriptor_metadata_stay_complete_kept_steps() { + let named = Descriptor { name: UnicodeString::new_nul("Unmeasured metadata"), ..Default::default() }; + for (event, _) in EVENTS { + for descriptor in [Descriptor::default().with("frame", D::Integer(2)), Descriptor::new("other"), named.clone()] { + let imported = atn_cmds::import(&file(item(event, Some(descriptor.clone())))).unwrap(); + assert_eq!(imported.unsupported.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep"); + assert_eq!(saved(&imported.actions[0]).descriptor, Some(descriptor)); + } + } +} + +#[test] +/// Current-frame recordings cannot serialize an explicit frame, layer or mask selector. +fn native_options_and_damaged_absence_metadata_refuse_export() { + for (_, command) in EVENTS { + for params in [ + json!({"frame":2}), + json!({"layer":3}), + json!({"target":"mask"}), + json!({"_atnVideoFrameNoDescriptor":false}), + json!({"_atnVideoFrameNoDescriptor":"true"}), + ] { + let action = Action::new("Invalid", vec![(command.into(), params)]); + assert!(atn_cmds::export("Video", &[&action], &[], false).is_err(), "{command}"); + } + } +} + +#[test] +/// Mapping a known event cannot bypass the native video-target precondition. +fn playback_without_a_video_target_fails_without_editing_a_document() { + for (event, _) in EVENTS { + for document in [false, true] { + let mut s = Session::new(); + if document { + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + } + let before = s.active().map(|state| state.doc.clone()); + load(&mut s, item(event, Some(Descriptor::default()))); + let report = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(report.get("failed").is_some(), "{event}: {report}"); + match before { + Some(before) => assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)), + None => assert!(s.active().is_none()), + } + } + } +} diff --git a/crates/engine/tests/it/background_and_mask_depth.rs b/crates/engine/tests/it/background_and_mask_depth.rs index 1fa400e244..b10ef85aea 100644 --- a/crates/engine/tests/it/background_and_mask_depth.rs +++ b/crates/engine/tests/it/background_and_mask_depth.rs @@ -56,7 +56,7 @@ fn painted(depth: u32) -> Session { } #[test] -fn frame_and_remove_background_masks_match_the_document_depth() { +fn frame_clips_and_remove_background_masks_match_the_document_depth() { for depth in [16, 32] { let want = if depth == 16 { SampleType::U16 } else { SampleType::F32 }; for (cmd, params) in [ @@ -67,7 +67,20 @@ fn frame_and_remove_background_masks_match_the_document_depth() { ] { let mut s = painted(depth); s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}")); - assert_eq!(mask_sample(&s), want, "{cmd} {params} in a {depth}-bit document"); + if cmd == "layer.removeBackground" { + assert_eq!(mask_sample(&s), want, "{cmd} {params} in a {depth}-bit document"); + } else { + let st = s.active().unwrap(); + let frame = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert!(frame.frame.as_ref().is_some_and(|f| f.valid())); + assert!(frame.vector_mask.is_some() && frame.mask.is_none(), "frames retain vector geometry at every depth"); + let output = photocraft_compose::flatten_to_surface(&st.doc, st.doc.pixel_format(), None); + assert_eq!(output.format().sample, want); + if cmd == "layer.new.frameFromLayers" { + let image = photocraft_compose::render_layer_in(&st.doc, frame, st.doc.bounds()); + assert!(image.px.iter().any(|p| p[3] > 0.), "framed image remains visible"); + } + } } } } diff --git a/crates/engine/tests/it/canvas_artboard_crop.rs b/crates/engine/tests/it/canvas_artboard_crop.rs new file mode 100644 index 0000000000..31064c2dce --- /dev/null +++ b/crates/engine/tests/it/canvas_artboard_crop.rs @@ -0,0 +1,139 @@ +//! The Crop tool's straight crop of a document whose only artboard covers the whole canvas fits +//! that board to the frame, as the reference application's Crop tool resizes the artboard (#8111). +//! Partial and several boards keep their layout; Image > Crop with a selection and Image > Trim +//! leave boards alone (the reference application's Image > Crop does nothing in an artboard +//! document and its Trim keeps the board's size). + +use photocraft_doc::{Artboard, ArtboardBackground, ColorMode, Document, Group, Layer, LayerContent, Rect, SampleType, Size}; +use photocraft_engine::Session; +use serde_json::json; + +fn board_layer(board: Artboard, children: Vec) -> Layer { + Layer::new("Page", LayerContent::Group(Group { children, expanded: true, artboard: Some(board), frame_stroke: None })) +} + +/// A 100 x 80 document with one artboard per rect, each carrying a size preset. +fn fixture(rects: &[Rect]) -> Session { + let mut doc = Document::new("Synthetic canvas", Size::new(100, 80), ColorMode::Rgb, SampleType::U8); + for rect in rects { + let mut board = Artboard::new(*rect); + board.preset = "original preset".into(); + doc.layers.push(board_layer(board, vec![])); + } + let mut session = Session::new(); + session.add_document(doc, None); + session +} + +/// A sole canvas board follows the frame in both crop modes, with exact undo and redo. +#[test] +fn canvas_artboard_follows_the_crop_frame_with_undo_and_redo() { + for delete in [false, true] { + let mut s = fixture(&[Rect::new(0, 0, 100, 80)]); + let before = s.active().unwrap().doc.clone(); + s.execute("image.crop", json!({"x":20,"y":10,"width":60,"height":40,"deleteCroppedPixels":delete})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.size, Size::new(60, 40)); + assert_eq!(doc.artboards()[0].2.rect, doc.bounds(), "delete {delete}"); + assert!(doc.artboards()[0].2.preset.is_empty()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.artboards()[0].2.rect, Rect::new(0, 0, 60, 40)); + } +} + +/// A crop frame reaching past the canvas grows the canvas board with it. +#[test] +fn extending_crop_grows_the_canvas_artboard() { + for delete in [false, true] { + let mut s = fixture(&[Rect::new(0, 0, 100, 80)]); + s.execute("image.crop", json!({"x":-10,"y":-5,"width":130,"height":95,"deleteCroppedPixels":delete})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.bounds(), Rect::new(0, 0, 130, 95)); + assert_eq!(doc.artboards()[0].2.rect, doc.bounds(), "delete {delete}"); + } +} + +/// Independent boards keep their layout and preset when the canvas origin moves. +#[test] +fn crop_preserves_partial_and_multiple_artboard_layouts_and_presets() { + for rects in [vec![Rect::new(10, 10, 50, 50)], vec![Rect::new(0, 0, 100, 80), Rect::new(60, 10, 90, 50)]] { + let mut s = fixture(&rects); + s.execute("image.crop", json!({"x":20,"y":10,"width":60,"height":40,"deleteCroppedPixels":false})).unwrap(); + for ((_, _, board), original) in s.active().unwrap().doc.artboards().iter().zip(rects) { + assert_eq!(board.rect, original.translate(-20, -10)); + assert_eq!(board.preset, "original preset"); + } + } +} + +/// Image > Crop with a selection and Image > Trim keep the board's size: only the canvas moves. +#[test] +fn selection_crop_and_trim_leave_the_canvas_artboard_alone() { + let mut s = fixture(&[Rect::new(0, 0, 100, 80)]); + s.execute("select.rect", json!({"x":20,"y":10,"width":60,"height":40})).unwrap(); + s.execute("image.crop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.bounds(), Rect::new(0, 0, 60, 40)); + assert_eq!(doc.artboards()[0].2.rect, Rect::new(-20, -10, 80, 70)); + assert_eq!(doc.artboards()[0].2.preset, "original preset"); + + let mut doc = Document::new("Trim", Size::new(100, 80), ColorMode::Rgb, SampleType::U8); + let mut board = Artboard::new(doc.bounds()); + board.background = ArtboardBackground::Transparent; + let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + surface.write_region(Rect::new(20, 10, 80, 50), &[1.0, 0.0, 0.0, 1.0].repeat(60 * 40)); + doc.layers.push(board_layer(board, vec![Layer::new("Paint", LayerContent::Raster(surface))])); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute("image.trim", json!({"basedOn":"transparent"})).unwrap(); + assert_eq!(s.active().unwrap().doc.bounds(), Rect::new(0, 0, 60, 40)); + assert_eq!(s.active().unwrap().doc.artboards()[0].2.rect, Rect::new(-20, -10, 80, 70)); +} + +/// Resampling to the crop's target size keeps the canvas board fitted to the final canvas. +#[test] +fn crop_target_resampling_keeps_the_canvas_board_fitted() { + let mut s = fixture(&[Rect::new(0, 0, 100, 80)]); + s.execute("image.crop", json!({"x":20,"y":10,"width":60,"height":40,"targetWidth":30,"targetHeight":20,"resolution":144})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.bounds(), Rect::new(0, 0, 30, 20)); + assert_eq!(doc.artboards()[0].2.rect, doc.bounds()); + assert_eq!(doc.resolution_dpi, 144.0); +} + +/// A frame of the board's own size (the whole canvas, or a same-size frame moved past an edge) +/// keeps the board's size preset; a new size drops it. +#[test] +fn same_size_crop_keeps_the_canvas_artboard_preset() { + for (x, y, width, height, kept) in [(0, 0, 100, 80, true), (-10, 5, 100, 80, true), (0, 0, 100, 79, false), (0, 0, 99, 80, false)] { + let mut s = fixture(&[Rect::new(0, 0, 100, 80)]); + s.execute("image.crop", json!({"x":x,"y":y,"width":width,"height":height,"deleteCroppedPixels":false})).unwrap(); + let doc = &s.active().unwrap().doc; + let board = &doc.artboards()[0].2; + assert_eq!(board.rect, Rect::new(0, 0, width, height)); + assert_eq!(board.preset == "original preset", kept, "{x},{y} {width}x{height}: {:?}", board.preset); + assert_eq!(board.preset.is_empty(), !kept); + } +} + +/// A turned crop fits a canvas board to its frame like a straight one, with exact undo. +#[test] +fn turned_crop_fits_the_canvas_artboard() { + for delete in [false, true] { + let mut s = fixture(&[Rect::new(0, 0, 100, 80)]); + let before = s.active().unwrap().doc.clone(); + s.execute("image.crop", json!({"x":20,"y":10,"width":60,"height":40,"angle":10,"deleteCroppedPixels":delete})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.bounds(), Rect::new(0, 0, 60, 40)); + assert_eq!(doc.artboards()[0].2.rect, doc.bounds(), "delete {delete}"); + assert!(doc.artboards()[0].2.preset.is_empty()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + // Several boards keep their own layout under a turned crop too. + let mut s = fixture(&[Rect::new(0, 0, 100, 80), Rect::new(60, 10, 90, 50)]); + s.execute("image.crop", json!({"x":20,"y":10,"width":60,"height":40,"angle":10,"deleteCroppedPixels":false})).unwrap(); + assert!(s.active().unwrap().doc.artboards().iter().all(|(_, _, b)| b.preset == "original preset" && b.rect.width() != 60)); +} diff --git a/crates/engine/tests/it/crop_content_aware_layers.rs b/crates/engine/tests/it/crop_content_aware_layers.rs new file mode 100644 index 0000000000..bd3ee0db2c --- /dev/null +++ b/crates/engine/tests/it/crop_content_aware_layers.rs @@ -0,0 +1,392 @@ +//! #10868: crop fill samples the Background and editable active raster independently. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, TextLayer}; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; +const YELLOW: [f32; 4] = [1.0, 1.0, 0.0, 1.0]; + +struct Layers { + background: LayerId, + quads: LayerId, + small: LayerId, + edge: LayerId, + hidden: LayerId, + text: LayerId, +} + +fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn raster(d: &Document, name: &str, bounds: Rect, color: [f32; 4]) -> Layer { + let fmt = d.pixel_format(); + let mut layer = Layer::raster(name, fmt); + layer.surface_mut().unwrap().fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, color)); + layer +} + +/// Keeps the measured straight geometry and fits turned controls under the 1 MiB transform limit +/// that the preference test can set process-wide. +fn scene_divisor(turned: bool) -> i32 { + if turned { 2 } else { 1 } +} + +/// The measured scene, with a deliberately different crop extension colour. +fn scene(turned: bool) -> (Session, Layers) { + let divisor = scene_divisor(turned); + let rect = |x0, y0, x1, y1| Rect::new(x0 / divisor, y0 / divisor, x1 / divisor, y1 / divisor); + let mut s = Session::new(); + s.execute("file.new", json!({"width":800/divisor,"height":500/divisor,"depth":8,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"background":"#ff00ff"})).unwrap(); + let ids = s + .edit("Fixture", |d, _| { + let background = d.background().unwrap().id; + let mut quads = raster(d, "Quads", rect(0, 0, 800, 500), RED); + let fmt = d.pixel_format(); + let surf = quads.surface_mut().unwrap(); + for (bounds, color) in [(rect(400, 0, 800, 250), GREEN), (rect(0, 250, 400, 500), BLUE), (rect(400, 250, 800, 500), YELLOW)] { + surf.fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, color)); + } + let small = raster(d, "Small", rect(350, 200, 450, 300), RED); + let edge = raster(d, "EdgeHalf", rect(600, 0, 800, 250), BLUE); + let mut hidden = raster(d, "Hidden", rect(0, 0, 800, 500), RED); + hidden.visible = false; + let text = Layer::new("T", LayerContent::Text(TextLayer { text: "T".into(), ..Default::default() })); + let ids = Layers { background, quads: quads.id, small: small.id, edge: edge.id, hidden: hidden.id, text: text.id }; + d.layers.extend([quads, small, edge, hidden, text]); + Ok(ids) + }) + .unwrap(); + (s, ids) +} + +fn select(s: &mut Session, id: LayerId) { + s.execute("layer.select", json!({"layer":id.0})).unwrap(); +} + +/// Extends the frame to the right by one eighth of the original width. +fn frame(turned: bool) -> Value { + let divisor = scene_divisor(turned); + json!({"x":0,"y":0,"width":900/divisor,"height":500/divisor,"angle":if turned { 10 } else { 0 },"deleteCroppedPixels":true}) +} + +/// Samples the same relative point inside the new strip in both fixture sizes. +fn extension_sample(turned: bool) -> (i32, i32) { + let divisor = scene_divisor(turned); + (850 / divisor, 400 / divisor) +} + +fn crop(s: &mut Session, mut p: Value, aware: bool) { + p["contentAware"] = json!(aware); + s.execute("image.crop", p).unwrap(); +} + +fn plain_control(s: &mut Session, p: &Value) -> Document { + crop(s, p.clone(), false); + let plain = doc(s).clone(); + s.execute("edit.undo", json!({})).unwrap(); + plain +} + +/// Checks donor colour with room for the resampling and synthesis tolerance. +fn pixel(d: &Document, id: LayerId, x: i32, y: i32, want: [f32; 4]) { + let actual = d.layer(id).unwrap().surface().unwrap().rgba(x, y); + assert!(actual.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.06), "layer {id:?} at ({x},{y}): {actual:?}, expected {want:?}"); +} + +/// Compares complete layers with plain crop, including any legitimate turn or clipping. +fn unchanged(d: &Document, plain: &Document, ids: &[LayerId]) { + for id in ids { + // Includes bounds, pixels and metadata; turned crops legitimately transform these layers. + assert_eq!(d.layer(*id), plain.layer(*id), "non-target {id:?} differs from plain crop"); + } +} + +/// Every pixel of `id` inside `area` (the old canvas) is the plain crop's. +fn covered_as_plain(d: &Document, plain: &Document, id: LayerId, area: Rect) { + let (got, want) = (d.layer(id).unwrap().surface().unwrap(), plain.layer(id).unwrap().surface().unwrap()); + for y in (area.y0..area.y1).step_by(7) { + for x in (area.x0..area.x1).step_by(7) { + assert_eq!(got.rgba(x, y), want.rgba(x, y), "layer {id:?} at ({x},{y}) differs from the plain crop"); + } + } +} + +fn named_one_step(s: &Session, past: usize, name: &str) { + let st = s.active().unwrap(); + assert_eq!(st.history.past_len(), past + 1); + assert_eq!(st.history.undo_label(), Some(name)); +} + +fn undo_redo(s: &mut Session, before: &Document) { + let after = doc(s).clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(doc(s), &after); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Content-aware Crop")); +} + +/// Matches the three measured rows at their original size, bounds and sample points. +#[test] +fn measured_straight_rows_fill_background_and_only_the_active_raster() { + for row in 0..3 { + let (mut s, ids) = scene(false); + let active = if row == 1 { ids.edge } else { ids.quads }; + if row == 2 { + s.edit("Layer 0", |d, _| { + let bg = d.layer_mut(ids.background).unwrap(); + bg.name = "Layer 0".into(); + bg.locks = Default::default(); + Ok(()) + }) + .unwrap(); + } + select(&mut s, active); + let before = doc(&s).clone(); + let p = frame(false); + let plain = plain_control(&mut s, &p); + let past = s.active().unwrap().history.past_len(); + crop(&mut s, p, true); + let d = doc(&s); + assert_eq!((d.size.width, d.size.height), (900, 500)); + let mut untouched = vec![ids.small, ids.hidden, ids.text]; + untouched.push(if row == 1 { ids.quads } else { ids.edge }); + if row == 2 { + assert!(d.background().is_none()); + untouched.push(ids.background); + } else { + assert_eq!(d.layer(ids.background).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 900, 500)); + pixel(d, ids.background, 850, 400, WHITE); + } + let bounds = if row == 1 { Rect::new(600, 0, 900, 500) } else { Rect::new(0, 0, 900, 500) }; + assert_eq!(d.layer(active).unwrap().surface().unwrap().content_bounds(), bounds); + pixel(d, active, 850, if row == 1 { 400 } else { 125 }, if row == 1 { BLUE } else { GREEN }); + // The fill adds pixels only where the crop uncovered the canvas: the active layer's own + // pixels inside the old canvas are the plain crop's. + covered_as_plain(d, &plain, active, Rect::new(0, 0, 800, 500)); + unchanged(d, &plain, &untouched); + named_one_step(&s, past, "Content-aware Crop"); + undo_redo(&mut s, &before); + } +} + +/// Checks own-layer donors and exact non-target parity after rotation. +#[test] +fn turned_rows_sample_each_target_and_leave_other_layers_at_plain_crop() { + let (x, y) = extension_sample(true); + for row in 0..3 { + let (mut s, ids) = scene(true); + // Uniform donors isolate target selection from the existing turned-fill sampling policy. + s.edit("Uniform Quads", |d, _| { + let fmt = d.pixel_format(); + let bounds = d.bounds(); + d.layer_mut(ids.quads).unwrap().surface_mut().unwrap().fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, GREEN)); + if row == 2 { + d.layer_mut(ids.background).unwrap().locks = Default::default(); + } + Ok(()) + }) + .unwrap(); + let active = if row == 1 { ids.edge } else { ids.quads }; + select(&mut s, active); + let before = doc(&s).clone(); + let p = frame(true); + let plain = plain_control(&mut s, &p); + let past = s.active().unwrap().history.past_len(); + let mut p = p; + p["contentAware"] = json!(true); + s.execute("image.crop", p).unwrap_or_else(|error| panic!("turned row {row}, active {active:?}: {error}")); + let d = doc(&s); + pixel(d, active, x, y, if row == 1 { BLUE } else { GREEN }); + assert!(d.layer(active).unwrap().surface().unwrap().content_bounds().contains(x, y)); + let mut untouched = vec![ids.small, ids.hidden, ids.text, if row == 1 { ids.quads } else { ids.edge }]; + if row == 2 { + untouched.push(ids.background); + } else { + pixel(d, ids.background, x, y, WHITE); + } + unchanged(d, &plain, &untouched); + named_one_step(&s, past, "Content-aware Crop"); + undo_redo(&mut s, &before); + } +} + +/// Selecting the Background leaves each other layer identical to its plain crop. +#[test] +fn background_active_is_not_a_second_target() { + for turned in [false, true] { + let (x, y) = extension_sample(turned); + let (mut s, ids) = scene(turned); + select(&mut s, ids.background); + let p = frame(turned); + let plain = plain_control(&mut s, &p); + let past = s.active().unwrap().history.past_len(); + crop(&mut s, p, true); + pixel(doc(&s), ids.background, x, y, WHITE); + unchanged(doc(&s), &plain, &[ids.quads, ids.small, ids.edge, ids.hidden, ids.text]); + named_one_step(&s, past, "Content-aware Crop"); + } +} + +/// Verifies Background-only fill for ineligible active layers and Delete Cropped Pixels off. +#[test] +fn unmeasured_active_targets_and_delete_off_keep_the_legacy_target() { + for turned in [false, true] { + let (x, y) = extension_sample(turned); + for case in 0..10 { + let (mut s, ids) = scene(turned); + let active = match case { + 0 => ids.text, + 1 => ids.hidden, + _ => ids.quads, + }; + if (2..7).contains(&case) { + s.edit("Lock active", |d, _| { + assert!(d.layer_mut(active).unwrap().locks.set(["all", "pixels", "transparency", "position", "artboard"][case - 2], true)); + Ok(()) + }) + .unwrap(); + } + if case >= 8 { + s.edit("Group active", |d, _| { + let i = d.layers.iter().position(|layer| layer.id == active).unwrap(); + let child = d.layers.remove(i); + let mut group = Layer::group("Ancestor", vec![child]); + if case == 8 { + group.visible = false; + } else { + assert!(group.locks.set("pixels", true)); + } + d.layers.insert(i, group); + Ok(()) + }) + .unwrap(); + } + select(&mut s, active); + let mut p = frame(turned); + if case == 7 { + p["deleteCroppedPixels"] = json!(false); + } + let plain = plain_control(&mut s, &p); + crop(&mut s, p, true); + pixel(doc(&s), ids.background, x, y, WHITE); + unchanged(doc(&s), &plain, &[ids.quads, ids.small, ids.edge, ids.hidden, ids.text]); + } + } +} + +/// Checks the plain history name for interior crops and crops with synthesis disabled. +#[test] +fn no_fill_or_content_aware_off_keeps_the_crop_history_name() { + for (p, aware, turned) in [ + (json!({"x":20,"y":20,"width":40,"height":30}), true, false), + (json!({"x":20,"y":20,"width":40,"height":30,"angle":10}), true, true), + (frame(false), false, false), + (frame(true), false, true), + ] { + let (mut s, ids) = scene(turned); + select(&mut s, ids.quads); + let past = s.active().unwrap().history.past_len(); + crop(&mut s, p, aware); + named_one_step(&s, past, "Crop"); + } +} + +/// Gives exactly one target a strip of held pixels outside the original canvas. +fn overhang_scene(held_active: bool) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":64,"height":48,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"background":"#ff00ff"})).unwrap(); + let (bg, active) = s + .edit("Distinct hidden pixels", |d, active| { + let bg = d.background().unwrap().id; + let mut layer = raster(d, "Active", Rect::new(0, 0, 64, 48), GREEN); + let fmt = d.pixel_format(); + let surf = if held_active { layer.surface_mut().unwrap() } else { d.layer_mut(bg).unwrap().surface_mut().unwrap() }; + surf.fill_rect(Rect::new(-16, 0, 0, 48), &photocraft_raster::from_rgba(&fmt, if held_active { BLUE } else { RED })); + let id = layer.id; + d.layers.push(layer); + *active = Some(id); + Ok((bg, id)) + }) + .unwrap(); + (s, bg, active) +} + +/// Confirms that held pixels protect only the layer that owned them before the turn. +#[test] +fn turned_hidden_overhang_coverage_belongs_to_each_layer() { + for held_active in [false, true] { + let (mut s, bg, active) = overhang_scene(held_active); + crop(&mut s, json!({"x":-10,"y":14,"width":20,"height":20,"angle":45}), true); + let mut held_pixels = 0; + for y in 0..20 { + for x in 0..20 { + let (held, other, want) = if held_active { (active, bg, WHITE) } else { (bg, active, GREEN) }; + pixel(doc(&s), other, x, y, want); + let px = doc(&s).layer(held).unwrap().surface().unwrap().rgba(x, y); + let color = if held_active { BLUE } else { RED }; + held_pixels += usize::from(px.iter().zip(color).all(|(a, b)| (a - b).abs() < 0.06)); + } + } + assert!(held_pixels >= 100, "the target's own hidden pixels survive: {held_pixels}"); + } +} + +/// An empty active raster leaves the successful Background fill and undo step intact. +#[test] +fn donorless_added_target_keeps_the_background_fill() { + for turned in [false, true] { + let (x, y) = extension_sample(turned); + let (mut s, ids) = scene(turned); + let empty = s + .edit("Empty target", |d, active| { + let layer = Layer::raster("Empty", d.pixel_format()); + let id = layer.id; + d.layers.push(layer); + *active = Some(id); + Ok(id) + }) + .unwrap(); + assert_ne!(empty, ids.background); + let before = doc(&s).clone(); + let p = frame(turned); + let plain = plain_control(&mut s, &p); + let past = s.active().unwrap().history.past_len(); + crop(&mut s, p, true); + pixel(doc(&s), ids.background, x, y, WHITE); + unchanged(doc(&s), &plain, &[empty, ids.quads, ids.small, ids.edge, ids.hidden, ids.text]); + assert_eq!(s.active().unwrap().active_layer, Some(empty)); + named_one_step(&s, past, "Content-aware Crop"); + undo_redo(&mut s, &before); + } +} + +/// Confirms both layers synthesize their own colours at every RGB depth. +#[test] +fn both_target_colours_remain_native_at_each_rgb_depth() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"depth":depth,"background":"white"})).unwrap(); + let (bg, active) = s + .edit("Own colours", |d, active| { + let bg = d.background().unwrap().id; + let layer = raster(d, "Green", Rect::new(0, 0, 32, 24), GREEN); + let id = layer.id; + d.layers.push(layer); + *active = Some(id); + Ok((bg, id)) + }) + .unwrap(); + crop(&mut s, json!({"x":0,"y":0,"width":40,"height":24}), true); + pixel(doc(&s), bg, 36, 12, WHITE); + pixel(doc(&s), active, 36, 12, GREEN); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8e8553d509..b53745ab2e 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,20 +1,39 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; +mod actions_single_history; mod affinity; mod artboard_export_names; +mod atn_adjustment_make_props; +mod atn_calculations; +mod atn_classic_gradient; +mod atn_color_lookup; +mod atn_content_aware_fill; mod atn_crystallize_cell_size; +mod atn_dialog_filters; mod atn_direct_adjustments; +mod atn_layer_style; mod atn_newer_events; +mod atn_perspective_warp; mod atn_reference_forms; +mod atn_review_regressions; mod atn_ripple_amount; mod atn_selective_color; +mod atn_shape_blur; +mod atn_shear; +mod atn_stack_modes; mod atn_stroke_width; +mod atn_type_path; mod atn_v12; +mod atn_video_frames; mod background_and_mask_depth; mod background_layer_consistency; mod brush_smoothing; mod camera_raw_exposure_clamp; mod camera_raw_noise_clamp; +mod canvas_artboard_crop; mod clipping_semantics; mod close_document; mod command_lookup; @@ -22,6 +41,7 @@ mod convert_keeps_references; mod copy_shape_placement; mod corpus_atn; mod crop_content_aware; +mod crop_content_aware_layers; mod crop_delete_off_background; mod current_affinity_artboards; mod duplicate_layer_comps; diff --git a/crates/engine/tests/it/prefs_usage.rs b/crates/engine/tests/it/prefs_usage.rs index eabad21ed8..e216fde40a 100644 --- a/crates/engine/tests/it/prefs_usage.rs +++ b/crates/engine/tests/it/prefs_usage.rs @@ -13,6 +13,9 @@ //! preferences by path; //! - or `self.` in an inherent `impl` of the section's struct in `prefs.rs` (helpers //! such as `TransparencyAndGamut::colors`). +//! +//! The `impl Session` blocks of `prefs.rs` (e.g. `apply_prefs`) are engine code, not the model, +//! so they are scanned like any other source, as the scorecard's prefs audit does. use photocraft_engine::prefs::{HIDDEN_UNTIL_IMPLEMENTED, Preferences}; use std::path::{Path, PathBuf}; @@ -124,6 +127,7 @@ fn every_preference_is_read_or_hidden() { .filter_map(|p| std::fs::read_to_string(&p).ok().map(|t| (p, production(&t)))) .collect(); let mut sources: Vec = files.iter().map(|(_, t)| t.clone()).collect(); + sources.push(inherent_impls(&production(&prefs_rs), "Session")); // A module split into child files (`foo.rs` + `foo/*.rs`) counts as one file too, so a section // named in the parent and a field read in a child still count as a read. for (path, text) in &files { @@ -201,6 +205,10 @@ fn the_scanner_finds_reads() { assert!(has_ident("let fh = &p.file_handling;", "", "file_handling")); assert!(!has_ident("my_file_handling", "", "file_handling")); assert_eq!(snake("recentFileCount"), "recent_file_count"); + let prefs = "pub struct Performance {\n}\nimpl Session {\n fn apply(&self) { self.prefs().performance.effect_cache_mb; }\n}\nimpl Performance {\n fn x(&self) { self.other; }\n}\n"; + let session = inherent_impls(prefs, "Session"); + assert!(has_ident(&session, ".", "effect_cache_mb") && has_ident(&session, "", "performance")); + assert!(!has_ident(&session, ".", "other")); assert_eq!(production("fn a() {}\n#[cfg(test)]\nmod tests {\n p.x.y\n}"), "fn a() {}\n"); } diff --git a/crates/engine/tests/it/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs index 16d8f3d459..d952225e5c 100644 --- a/crates/engine/tests/it/svg_smart_object.rs +++ b/crates/engine/tests/it/svg_smart_object.rs @@ -62,3 +62,112 @@ fn re_placing_at_another_scale_re_renders_the_vector_source() { assert!(alpha(&surf, 268, 60) > 0.99 && alpha(&surf, 271, 60) < 0.01); assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98); } + +/// A `.pcraft` opens from its cached pixels and parses an embedded SVG source only on a later +/// edit. A source whose DTD entity hides 20 000 nested groups from the depth check used to open +/// clean and then overflow the stack on Image Size or a PSD save; both are an error or a fallback +/// now. Run on a 2 MB stack, as the app's import and command threads are. +#[test] +fn a_pcraft_smart_object_with_a_hostile_svg_does_not_abort_a_later_edit() { + let deep = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let hostile = format!("]>&a;"); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "background": "white"})).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 100})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layers.last_mut().unwrap().content else { panic!("smart object") }; + sm.source = photocraft_doc::SmartSource::Embedded { file_name: "logo.svg".into(), bytes: std::sync::Arc::new(hostile.into_bytes()) }; + let carrier = photocraft_format::save_to_bytes(&doc, &photocraft_format::SaveOptions::default()).unwrap(); + + photocraft_engine::file_cmds::open_bytes_as(&mut s, "carrier.pcraft", &carrier, None, None).unwrap(); + let _ = s.execute("image.imageSize", json!({"width": 50, "height": 50})); + let dir = tempfile::tempdir().unwrap(); + let _ = s.execute("file.saveACopy", json!({"path": dir.path().join("copy.psd").to_string_lossy()})); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Placed through a background job, an SVG is rendered at its placement scale on the worker and +/// lands exactly as the inline place does: still sharp, not a resampled raster. +#[test] +fn an_svg_placed_as_a_job_is_as_sharp_as_the_inline_place() { + use photocraft_engine::jobs::Started; + let dir = std::env::temp_dir().join(format!("photocraft-svg-place-job-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("logo.svg"); + std::fs::write(&path, SVG).unwrap(); + let params = json!({"path": path.to_string_lossy(), "scale": 1000}); + let new_doc = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 400, "background": "white"})).unwrap(); + s + }; + let mut inline = new_doc(); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = new_doc(); + let Started::Job(id) = s.start("file.placeEmbedded", params).unwrap() else { panic!("expected a background job") }; + let mut got = s.wait_job(id).unwrap(); + // Layer ids come from a counter shared by every session: the rest of the result must match. + let mut want = want; + assert!(got.as_object_mut().and_then(|o| o.remove("layer")).is_some() && want.as_object_mut().and_then(|o| o.remove("layer")).is_some()); + assert_eq!(got, want); + assert_eq!(got["bounds"], json!([100.0, 150.0, 300.0, 250.0]), "{got}"); + let doc = s.active().unwrap().doc.clone(); + let layer = doc.layers.last().unwrap(); + assert!(matches!(layer.content, LayerContent::Smart(_))); + let surf = layer.surface().expect("rendered"); + assert!(alpha(surf, 200, 148) < 0.01 && alpha(surf, 200, 151) > 0.99, "a pixel-exact top edge"); + assert!(red(surf, 200, 192) < 0.02, "the thin line is a crisp band: {}", red(surf, 200, 192)); + assert!(red(surf, 200, 187) > 0.98 && red(surf, 200, 198) > 0.98); + let region = Rect::new(90, 140, 310, 260); + let inline_doc = inline.active().unwrap().doc.clone(); + assert_eq!(surf.read_region(region), inline_doc.layers.last().unwrap().surface().unwrap().read_region(region), "same pixels as the inline place"); +} + +/// With no worker threads (the web build) an SVG place is stepped a poll at a time, and renders +/// at its placement scale like the inline place does. +#[test] +fn an_svg_placed_in_steps_is_as_sharp_as_the_inline_place() { + use photocraft_engine::jobs::{JobOutcome, Started}; + let params = json!({"scale": 1000}); + let new_doc = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 400, "background": "white"})).unwrap(); + s + }; + let mut inline = new_doc(); + let want = photocraft_engine::file_cmds::place_bytes(&mut inline, "logo.svg", SVG.as_bytes().to_vec(), None, ¶ms).unwrap(); + + let mut s = new_doc(); + s.set_cooperative_jobs(true); + s.set_job_step_budget(std::time::Duration::ZERO); + let Started::Job(id) = s.start_place_bytes("logo.svg", SVG.as_bytes().to_vec(), None, params).unwrap() else { panic!("expected a job") }; + let mut polls = 0; + let event = loop { + polls += 1; + assert!(polls < 1000, "the place never ended"); + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + break e; + } + }; + assert!(polls > 1, "{polls}"); + let JobOutcome::Done(mut got) = event.outcome else { panic!("{:?}", event.outcome) }; + let mut want = want; + assert!(got.as_object_mut().and_then(|o| o.remove("layer")).is_some() && want.as_object_mut().and_then(|o| o.remove("layer")).is_some()); + assert_eq!(got, want); + let doc = s.active().unwrap().doc.clone(); + let layer = doc.layers.last().unwrap(); + assert!(matches!(layer.content, LayerContent::Smart(_))); + let surf = layer.surface().expect("rendered"); + assert!(alpha(surf, 200, 148) < 0.01 && alpha(surf, 200, 151) > 0.99, "a pixel-exact top edge"); + assert!(red(surf, 200, 192) < 0.02, "the thin line is a crisp band: {}", red(surf, 200, 192)); + let region = Rect::new(90, 140, 310, 260); + let inline_doc = inline.active().unwrap().doc.clone(); + assert_eq!(surf.read_region(region), inline_doc.layers.last().unwrap().surface().unwrap().read_region(region), "same pixels as the inline place"); +} diff --git a/crates/engine/tests/lut_row_cache_purge.rs b/crates/engine/tests/lut_row_cache_purge.rs new file mode 100644 index 0000000000..18d78c0c9f --- /dev/null +++ b/crates/engine/tests/lut_row_cache_purge.rs @@ -0,0 +1,30 @@ +//! One integration-test process keeps its global adjustment-table cache isolated from parallel +//! unit tests. +use photocraft_compose::lut_rows::{self, ROW_LEN}; +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn purge_all_is_enabled_by_cached_adjustment_tables_and_releases_them() { + let mut session = Session::new(); + assert!(!session.is_enabled("edit.purge.all")); + let rows = lut_rows::cached(7, || vec![[0.5; ROW_LEN]; 3]); + let accounted = lut_rows::cache_bytes(); + assert_eq!(accounted, 3 * ROW_LEN * 4); + assert!(session.is_enabled("edit.purge.all"), "only the adjustment table cache enables purge"); + + let result = session.execute("edit.purge.all", json!({})).unwrap(); + assert_eq!(result["purged"], json!(["adjustment table cache"])); + assert_eq!(result["bytes"].as_u64(), Some(accounted as u64)); + assert_eq!(lut_rows::cache_bytes(), 0); + assert!(!session.is_enabled("edit.purge.all")); + + // The next render builds the tables again. + let mut built = false; + let again = lut_rows::cached(7, || { + built = true; + vec![[0.5; ROW_LEN]; 3] + }); + assert!(built); + assert_eq!(rows, again); +} diff --git a/crates/engine/tests/magnetic_lasso_composite.rs b/crates/engine/tests/magnetic_lasso_composite.rs new file mode 100644 index 0000000000..898e62e2e9 --- /dev/null +++ b/crates/engine/tests/magnetic_lasso_composite.rs @@ -0,0 +1,125 @@ +//! The Magnetic Lasso follows the edges of the visible composite, whichever layer is active +//! (#11686), in the live `EdgeSource` and in `select.magneticLasso` with `trace: true`. + +use photocraft_algo::magnetic::Settings; +use photocraft_doc::LayerId; +use photocraft_engine::Session; +use photocraft_engine::magnetic_cmds::EdgeSource; +use photocraft_geom::Rect; +use serde_json::json; + +/// The red rectangle's pixels: x 50..125, y 40..100, 75 x 60 = 4500 px. +const RED: Rect = Rect { x0: 50, y0: 40, x1: 125, y1: 100 }; +const RED_AREA: f32 = 4500.0; +/// Fastening points 4 px outside the rectangle's corners. +const OUTSIDE: [[f64; 2]; 4] = [[46.0, 36.0], [129.0, 36.0], [129.0, 104.0], [46.0, 104.0]]; + +struct Doc { + s: Session, + background: LayerId, + red: LayerId, +} + +/// A 200 x 150 RGB document: a white Background and a layer above it filled red over `RED`. The +/// red layer is active. +fn doc() -> Doc { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 150})).unwrap(); + let background = s.active().unwrap().doc.layers[0].id; + s.edit("paint", |doc, _| { + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 150), &[1.0, 1.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let red = s.active().unwrap().active_layer.unwrap(); + assert_ne!(red, background); + s.edit("paint", |doc, _| { + doc.layer_mut(red).unwrap().surface_mut().unwrap().fill_rect(RED, &[0.86, 0.12, 0.12, 1.0]); + Ok(()) + }) + .unwrap(); + Doc { s, background, red } +} + +fn select(s: &mut Session, id: LayerId) { + s.execute("layer.select", json!({"layer": id.0})).unwrap(); +} + +fn coverage(s: &Session, x: i32, y: i32) -> f32 { + s.active().unwrap().doc.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0)) +} + +fn selected_area(s: &Session) -> f32 { + (0..150).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| coverage(s, x, y)).sum() +} + +/// Traces a border through `OUTSIDE` and asserts it selected the rectangle, not the points' own +/// quadrilateral (83 x 68 = 5644 px). +fn assert_snaps(s: &mut Session, what: &str) { + s.execute("select.magneticLasso", json!({"points": OUTSIDE, "trace": true})).unwrap(); + let area = selected_area(s); + assert!((area - RED_AREA).abs() < 150.0, "{what}: the border should select the rectangle ({RED_AREA} px), selected {area} px"); + assert!(coverage(s, 52, 42) > 0.99 && coverage(s, 122, 97) > 0.99, "{what}: the rectangle's corners are inside"); + assert!(coverage(s, 48, 70) < 0.01 && coverage(s, 127, 70) < 0.01, "{what}: just outside the rectangle is not selected"); + s.execute("select.deselect", json!({})).unwrap(); +} + +/// Whether a fastening point 4 px outside the rectangle's left edge snaps onto it. +fn edge_snaps(s: &Session) -> bool { + let mut src = EdgeSource::new(s).unwrap(); + let p = src.snap([46.0, 70.0], Settings::default()).unwrap(); + (p[0] - 50.0).abs() < 1.5 +} + +#[test] +fn the_border_snaps_whichever_layer_is_active() { + let mut d = doc(); + assert_snaps(&mut d.s, "the red layer active"); + select(&mut d.s, d.background); + assert_snaps(&mut d.s, "the Background active"); + assert!(edge_snaps(&d.s), "Background active: the live source sees the red layer's edge"); + d.s.execute("layer.new.layer", json!({})).unwrap(); + assert!(d.s.active().unwrap().active_layer.is_some_and(|id| id != d.background && id != d.red)); + assert!(edge_snaps(&d.s), "a new empty layer active"); + assert_snaps(&mut d.s, "a new empty layer active"); +} + +#[test] +fn an_adjustment_or_group_layer_active_snaps_too() { + let mut d = doc(); + d.s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + let st = d.s.active().unwrap(); + assert!(st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.surface().is_none())); + assert!(edge_snaps(&d.s), "an adjustment layer active"); + assert_snaps(&mut d.s, "an adjustment layer active"); + select(&mut d.s, d.background); + d.s.execute("layer.new.group", json!({})).unwrap(); + assert!(edge_snaps(&d.s), "an empty group active"); + assert_snaps(&mut d.s, "an empty group active"); +} + +#[test] +fn a_hidden_layer_contributes_no_edges() { + let mut d = doc(); + select(&mut d.s, d.background); + d.s.execute("layer.hideLayers", json!({"layer": d.red.0})).unwrap(); + assert!(!edge_snaps(&d.s), "the hidden red layer has no edge to snap to"); + d.s.execute("select.magneticLasso", json!({"points": OUTSIDE, "trace": true})).unwrap(); + let area = selected_area(&d.s); + // The border follows the pointer: the points' own 83 x 68 quadrilateral. + assert!((area - 5644.0).abs() < 150.0, "selected {area} px"); + // Showing it again brings the edge back, for a source made after the change. + d.s.execute("layer.showLayers", json!({"layer": d.red.0})).unwrap(); + assert!(edge_snaps(&d.s)); +} + +#[test] +fn a_live_source_goes_stale_when_the_composite_changes() { + let mut d = doc(); + select(&mut d.s, d.background); + let src = EdgeSource::new(&d.s).unwrap(); + assert!(src.is_current(&d.s)); + d.s.execute("layer.hideLayers", json!({"layer": d.red.0})).unwrap(); + assert!(!src.is_current(&d.s), "hiding a layer changes what the border follows"); +} diff --git a/crates/engine/tests/memory_gate.rs b/crates/engine/tests/memory_gate.rs new file mode 100644 index 0000000000..a740012c76 --- /dev/null +++ b/crates/engine/tests/memory_gate.rs @@ -0,0 +1,261 @@ +//! On the web build a failed allocation aborts the module and freezes the page. The big +//! operations therefore ask the allocator first (`photocraft_raster::headroom`) and refuse with +//! an error when their estimated need won't fit (#10024). The simulation switch is process-wide, +//! so every test here holds `LOCK` and resets it when it ends. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_engine::Session; +use photocraft_raster::headroom; +use serde_json::json; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Holds the lock and the simulated room; resets the simulation when dropped. +struct Room(#[allow(dead_code)] MutexGuard<'static, ()>); + +impl Room { + fn new(bytes: Option) -> Room { + let guard = Room(LOCK.lock().unwrap_or_else(PoisonError::into_inner)); + headroom::simulate_available(bytes); + guard + } + fn set(&self, bytes: Option) { + headroom::simulate_available(bytes); + } +} + +impl Drop for Room { + fn drop(&mut self) { + headroom::simulate_available(None); + } +} + +const MIB: u64 = 1 << 20; + +/// Enough for the reserve and a small operation, far too little for a big one. +const SMALL: u64 = headroom::RESERVE + 4 * MIB; + +fn png(dir: &std::path::Path, name: &str, w: u32, h: u32, color: &str) -> String { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": color})).unwrap(); + let st = s.active().unwrap(); + let bytes = photocraft_io::export(&st.doc, "png", &Default::default()).unwrap().bytes; + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() +} + +/// What an operation must not change when the gate refuses it. +fn state(s: &Session) -> (u64, usize, usize, (u32, u32)) { + let st = s.active().unwrap(); + (st.revision, st.history.past_len(), st.doc.layers.len(), (st.doc.size.width, st.doc.size.height)) +} + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + s +} + +#[test] +fn place_that_does_not_fit_fails_and_leaves_the_document_alone() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + // 2400 x 1800 px: about 4 MP, about 190 MB by the estimate. + let path = png(dir.path(), "big \u{e9}.png", 2400, 1800, "#ff0000"); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(SMALL)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + let err = s.execute(cmd, json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to place \u{201c}big \u{e9}.png\u{201d}"), "{err}"); + assert!(err.contains("MB"), "names the need: {err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "says what to do: {err}"); + assert_eq!(state(&s), before, "{cmd}"); + } + room.set(None); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1); +} + +#[test] +fn image_size_that_does_not_fit_fails_and_leaves_the_document_alone() { + let room = Room::new(None); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(SMALL)); + let err = s.execute("image.imageSize", json!({"width": 12000, "height": 9000})).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to resize the image to 12000 x 9000 px"), "{err}"); + assert_eq!(state(&s), before); + // Only changing the resolution allocates nothing, so it still goes through. + s.execute("image.imageSize", json!({"resolution": 150, "resample": "none"})).unwrap(); + room.set(None); + s.execute("image.imageSize", json!({"width": 800, "height": 600})).unwrap(); + assert_eq!(state(&s).3, (800, 600)); +} + +/// A smart-object re-render that doesn't fit is refused by name (its own error); the commands +/// that re-render after an edit keep the layer's last appearance instead of failing. +#[test] +fn smart_object_render_that_does_not_fit_is_refused_by_name() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + let path = png(dir.path(), "src.png", 1500, 1500, "#12ab34"); + let bytes = std::fs::read(&path).unwrap(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + room.set(Some(SMALL)); + let err = photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to render the smart object \u{201c}src.png\u{201d}"), "{err}"); + room.set(None); + assert!(photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).is_ok()); +} + +#[test] +fn layered_psd_is_refused_before_decode_without_changing_the_document() { + use photocraft_psd::{Compression, LayerSpec, PixelData, PsdBuilder}; + + let room = Room::new(None); + const SIDE: u32 = 256; + const LAYERS: usize = 24; + let pixels = u64::from(SIDE) * u64::from(SIDE); + let mut builder = PsdBuilder::new(SIDE, SIDE).compression(Compression::Rle); + for layer in 0..LAYERS { + // Nonuniform, opaque pixels populate each full-canvas surface while remaining compressible. + let rgba: Vec = (0..SIDE * SIDE).flat_map(|i| [((i / SIDE + layer as u32) % 256) as u8, 80, 160, 255]).collect(); + builder.push_layer(LayerSpec::new(format!("layer {layer}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba))); + } + let bytes = builder.to_bytes().unwrap(); + let budget = pixels * photocraft_engine::memory_gate::SMART_RENDER_BYTES_PER_PX + bytes.len() as u64; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layers.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(headroom::RESERVE)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory")); + assert_eq!(state(&s), before); + } + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err()); + + // The layers make the need bigger but bounded, so the error names it. + let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("MB"), "{err}"); + + // This budget passed the old canvas-only estimate even though the layers retain more. + room.set(Some(headroom::RESERVE + budget)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory")); + assert_eq!(state(&s), before); + } + assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err()); +} + +#[test] +fn small_flat_psd_still_places_and_renders() { + let room = Room::new(Some(SMALL)); + // Two-by-two RGB PSD: empty color, resource and layer sections, raw planar pixels. + let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + bytes.extend_from_slice(&[0; 12]); + bytes.extend_from_slice(&[0, 0]); + bytes.extend_from_slice(&[128; 12]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flat.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + assert!(photocraft_engine::smart_cmds::source_image("flat.psd", &bytes, fmt).is_ok()); + drop(room); +} + +/// Every Photoshop file has image resources (resolution, ICC profile, thumbnail); they hold no +/// pixels, so a PSD that has them, flat or with a few layers, still places and renders when the +/// memory is there. +#[test] +fn a_psd_with_image_resources_places_and_renders_when_it_fits() { + use photocraft_psd::{LayerSpec, PixelData, PsdBuilder}; + + let room = Room::new(Some(headroom::RESERVE + 256 * MIB)); + const SIDE: u32 = 64; + let rgba = |r: u8| -> Vec { (0..SIDE * SIDE).flat_map(|_| [r, 80, 160, 255]).collect() }; + let mut flat = PsdBuilder::new(SIDE, SIDE).resolution(72.0); + flat.composite(PixelData::Rgba8(rgba(10))); + let mut layered = PsdBuilder::new(SIDE, SIDE).resolution(150.0); + for (i, r) in [20u8, 30].into_iter().enumerate() { + layered.push_layer(LayerSpec::new(format!("layer {i}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba(r)))); + } + let dir = tempfile::tempdir().unwrap(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let mut s = new_doc(); + for (name, builder) in [("resources.psd", flat), ("resources-layers.psd", layered)] { + let bytes = builder.to_bytes().unwrap(); + // No color-mode data for RGB, then a non-empty image resources section. + assert_ne!(&bytes[30..34], &[0u8; 4][..], "{name} has image resources"); + assert!(photocraft_engine::smart_cmds::source_image(name, &bytes, fmt).is_ok(), "{name}"); + let path = dir.path().join(name); + std::fs::write(&path, &bytes).unwrap(); + let before = state(&s); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1, "{name}"); + } + drop(room); +} + +/// A PSD with no safe bound (here 16-bit) is refused whatever the free memory, so the error +/// doesn't suggest freeing some. +#[test] +fn a_psd_without_a_bound_says_to_flatten_it() { + let room = Room::new(Some(headroom::RESERVE + 1024 * MIB)); + let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x10\0\x03".to_vec(); + bytes.extend_from_slice(&[0; 14]); + bytes.extend_from_slice(&[128; 24]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("deep.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string(); + assert!(!err.contains("Purge"), "freeing memory cannot help an unbounded source: {err}"); + assert!(err.contains("flatten"), "{err}"); + assert_eq!(state(&s), before); + drop(room); +} + +#[test] +fn a_smart_object_edit_under_low_memory_keeps_its_appearance() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + let path = png(dir.path(), "edit.png", 1500, 1500, "#a1b2c3"); + let mut s = new_doc(); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + let before = state(&s); + room.set(Some(SMALL)); + s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap(); + assert_eq!(state(&s).2, before.2, "the layer is still there"); +} + +#[test] +fn everything_passes_without_a_simulation() { + let _room = Room::new(None); + assert!(photocraft_engine::memory_gate::ensure("do anything", 64 << 30).is_ok()); + assert!(photocraft_engine::memory_gate::ensure_for_save("save", 64 << 30).is_ok()); +} + +#[test] +fn the_save_wording_leads_with_freeing_memory_and_keeps_the_document() { + let room = Room::new(Some(SMALL)); + let err = photocraft_engine::memory_gate::ensure_for_save("save \u{201c}a.psd\u{201d}", 900 * MIB).unwrap_err().to_string(); + assert!(err.starts_with("Not enough memory to save \u{201c}a.psd\u{201d} (it needs about 900 MB)."), "{err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}"); + assert!(err.contains("still open"), "{err}"); + drop(room); +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index c4d8359e18..81f893ee68 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -89,6 +89,11 @@ fn no_command_panics_or_hangs_on_adversarial_params() { json!({"x": -9, "y": -9, "radius": 0, "amount": 0, "angle": 0, "opacity": 0, "scale": 0, "levels": 0, "tolerance": 0, "gamma": 0, "layer": 999999, "channel": 999, "index": 999999, "count": 0}), json!({"x": 1e5, "y": -1e5, "radius": 300, "amount": 300, "angle": 1e5, "opacity": -100, "scale": -5, "gamma": -1}), json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}), + // Color Range and Replace Color samples (#6280): far off the canvas, malformed, and a + // click order that doesn't match them. + json!({"points": [[1e9, -1e9], [0.5, 0.5]], "subtractPoints": [[-1e9, 1e9], [2, 2]], "order": ["+", "-", "x", 5], "localized": true, "range": 0}), + json!({"points": [[3, 3]], "subtractPoints": [[3]], "order": {}, "fuzziness": -1}), + json!({"subtractPoints": "none", "order": ["-"], "labMinimum": [0, 0, 0], "labMaximum": [100, 0, 0]}), ]; let mut bad: Vec = Vec::new(); for spec in command_specs() { diff --git a/crates/engine/tests/place_steps_measure.rs b/crates/engine/tests/place_steps_measure.rs new file mode 100644 index 0000000000..2bb2c5c077 --- /dev/null +++ b/crates/engine/tests/place_steps_measure.rs @@ -0,0 +1,155 @@ +//! How long each piece of a stepped Place takes (#10031): the numbers behind the web build's +//! promise that a large file does not freeze the window. +//! +//! Not part of the default suite. Run it in release on one thread, as the web build has: +//! +//! ```sh +//! RAYON_NUM_THREADS=1 cargo test --release -p photocraft-engine --test place_steps_measure -- --ignored --nocapture +//! ``` +//! +//! A synthetic 4096×4096 RGBA PNG is placed into a 3600×4200 document three ways: inline as one +//! call (the cost without steps), in steps of one unit each (the cost of every unit of work, so the +//! ones that cannot be cut up show), and in steps of the 8 ms a frame gives it (the longest frame +//! a user would see). + +use std::time::{Duration, Instant}; + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; +use photocraft_engine::jobs::{JobOutcome, Started}; +use photocraft_engine::{Session, file_cmds}; +use serde_json::json; + +const SIZE: u32 = 4096; + +/// A 4096×4096 RGBA PNG of smooth colour and four bits of noise a channel, which makes a file of +/// a few tens of MB as a photo does (no personal photo). +fn synthetic_png() -> Vec { + let mut data = Vec::with_capacity(SIZE as usize * SIZE as usize * 4); + let mut r = 0x9E37_79B9u32; + let mut noise = || { + r = r.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (r >> 28) as u8 + }; + for y in 0..SIZE { + for x in 0..SIZE { + data.extend_from_slice(&[((x / 24) as u8) << 4 | noise(), ((y / 24) as u8) << 4 | noise(), (((x + y) / 48) as u8) << 4 | noise(), 0xF0 | noise()]); + } + } + let img = Image::from_raw(SIZE, SIZE, ChannelLayout::Rgba, SampleType::U8, data).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap() +} + +fn document() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 3600, "height": 4200})).unwrap(); + s +} + +/// A place's result without its layer id, which counts up across sessions. +fn strip_layer(v: &serde_json::Value) -> serde_json::Value { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("layer")); + v +} + +fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1000.0 +} + +/// Polls a started place to the end, timing every poll; returns (stage label, time) per poll. The +/// label is the status the poll left (the message is set as a unit starts, so a poll that ends in +/// a new one began a stage's last unit). +fn timed_polls(s: &mut Session, id: photocraft_engine::jobs::JobId) -> (Vec<(String, String, Duration)>, serde_json::Value) { + let mut polls = Vec::new(); + loop { + let before = s.job(id).map(|j| j.message).unwrap_or_default(); + let t = Instant::now(); + let events = s.poll_jobs(); + let took = t.elapsed(); + let after = s.job(id).map_or_else(|| "finish and apply".to_string(), |j| j.message); + polls.push((before, after, took)); + if let Some(e) = events.into_iter().find(|e| e.id == id) { + let JobOutcome::Done(result) = e.outcome else { panic!("{:?}", e.outcome) }; + return (polls, result); + } + assert!(polls.len() < 1_000_000); + } +} + +fn stage_of(before: &str, after: &str) -> &'static str { + match (before, after) { + ("", "") => "open the PNG (header, buffer)", + ("" | "Decoding", "Decoding") => "decode rows", + ("Decoding", "Flattening") => "image to document (one unit)", + ("Flattening", "Flattening") => "flatten a band", + ("Flattening", "Resizing") => "resize: plan the first pass", + (_, "Resizing") => "resize: bands and passes", + (_, "finish and apply") => "translate, layer and apply (one unit)", + _ => "other", + } +} + +#[test] +#[ignore = "a measurement, run by hand (see the module docs)"] +fn place_steps_timings() { + let t = Instant::now(); + let png = synthetic_png(); + println!("synthetic {SIZE}x{SIZE} RGBA PNG: {:.1} MB, made in {:.0} ms", png.len() as f64 / 1e6, ms(t.elapsed())); + println!("rayon threads: {}", rayon::current_num_threads()); + + // Inline, as one call: no steps. The first run warms the allocator; the second is the time. + let mut want = serde_json::Value::Null; + let mut inline_ms = 0.0; + for run in 1..=2 { + let mut inline = document(); + let t = Instant::now(); + want = file_cmds::place_bytes(&mut inline, "big.png", png.clone(), None, &json!({})).unwrap(); + inline_ms = ms(t.elapsed()); + println!("\ninline place {run} (one call, as a desktop worker does it): {inline_ms:.0} ms"); + } + + // Cooperative, one unit per poll: the time of every unit of work. + let mut s = document(); + s.set_cooperative_jobs(true); + s.set_job_step_budget(Duration::ZERO); + let t = Instant::now(); + let Started::Job(id) = s.start_place_bytes("big.png", png.clone(), None, json!({})).unwrap() else { panic!("expected a job") }; + let (polls, got) = timed_polls(&mut s, id); + let total = ms(t.elapsed()); + assert_eq!(strip_layer(&got), strip_layer(&want), "the stepped place is the inline place"); + println!("\nstepped, one unit per poll: {} units, {total:.0} ms in all", polls.len()); + println!("{:<42} {:>7} {:>10} {:>10}", "stage", "units", "total ms", "longest ms"); + let mut stages: Vec<&str> = Vec::new(); + for (b, a, _) in &polls { + let st = stage_of(b, a); + if !stages.contains(&st) { + stages.push(st); + } + } + for st in stages { + let times: Vec = polls.iter().filter(|(b, a, _)| stage_of(b, a) == st).map(|(_, _, d)| ms(*d)).collect(); + println!("{st:<42} {:>7} {:>10.1} {:>10.1}", times.len(), times.iter().sum::(), times.iter().copied().fold(0.0, f64::max)); + } + let longest = polls.iter().map(|(_, _, d)| ms(*d)).fold(0.0, f64::max); + println!("longest single unit: {longest:.1} ms"); + + // Cooperative with the frame budget: the frames a user would see. + let mut s = document(); + s.set_cooperative_jobs(true); + let t = Instant::now(); + let Started::Job(id) = s.start_place_bytes("big.png", png, None, json!({})).unwrap() else { panic!("expected a job") }; + let (polls, _) = timed_polls(&mut s, id); + let total = ms(t.elapsed()); + let mut times: Vec = polls.iter().map(|(_, _, d)| ms(*d)).collect(); + times.sort_by(f64::total_cmp); + println!( + "\nstepped, 8 ms a poll: {} polls, {total:.0} ms in all ({:+.0}% on inline); median poll {:.1} ms, 95th percentile {:.1} ms, longest {:.1} ms", + times.len(), + (total / inline_ms - 1.0) * 100.0, + times[times.len() / 2], + times[times.len() * 95 / 100], + times[times.len() - 1] + ); + let slow: Vec<_> = polls.iter().filter(|(_, _, d)| ms(*d) > 16.0).map(|(b, a, d)| format!("{} ({:.0} ms)", stage_of(b, a), ms(*d))).collect(); + println!("polls over 16 ms: {}", if slow.is_empty() { "none".to_string() } else { slow.join(", ") }); +} diff --git a/crates/engine/tests/shape_kernel_budget.rs b/crates/engine/tests/shape_kernel_budget.rs new file mode 100644 index 0000000000..e0c065fd3b --- /dev/null +++ b/crates/engine/tests/shape_kernel_budget.rs @@ -0,0 +1,25 @@ +//! A custom Shape Blur kernel is admitted against the working-memory budget before its coverage +//! buffer is allocated, also when a smart filter re-renders it. Its own binary: the budget is +//! process-wide. + +use photocraft_geom::Rect; +use photocraft_raster::memory::{self, Policy}; +use serde_json::json; + +#[test] +fn custom_shape_kernel_is_admitted_before_allocation() { + let knots = json!([[0, 0], [10, 0], [10, 10], [0, 10]]); + let params = json!({"radius":1000,"shapePath":{"subpaths":[{"closed":true,"knots":knots}]}}); + let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + surface.write_region(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + let canvas = Rect::new(0, 0, 1, 1); + let render = || photocraft_engine::filters::apply_filter_to_surface("filter.blur.shapeBlur", ¶ms, &surface, canvas, None, canvas); + // The default budget renders it. + assert!(render().unwrap().is_some()); + // A 1 MiB budget cannot hold the 2001² coverage floats (16 MB). + memory::configure(Policy { percent: None, absolute_bytes: 1 << 20 }); + let refused = render(); + memory::configure(Policy::default()); + let err = refused.expect_err("the kernel must be refused before allocation"); + assert!(err.to_string().contains("working memory"), "{err}"); +} diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index de28655f08..09800b34b4 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -21,6 +21,9 @@ //! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters //! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync` //! works. [`sync_file`] falls back to the plain one there (#1336). +//! - **Hidden flags (network shares):** the temporary file's name never starts with a dot. An SMB +//! server that hides dot files marks one hidden when it is created, the mark survives the +//! rename, and the saved document then doesn't show in Finder (#11624). See [`temp_name`]. //! //! On the web there is no file system: writes go through the platform services (downloads), so //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" @@ -479,13 +482,53 @@ fn resolve_symlink(path: &Path) -> io::Result { Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) } -/// A unique hidden temporary file name to write beside `file` before renaming over it. +/// What every [`temp_name`] ends with. +const TEMP_SUFFIX: &str = ".pcsave.tmp"; + +/// How many characters of the file name a [`temp_name`] keeps. +const TEMP_STEM_CHARS: usize = 64; + +/// A unique temporary file name to write beside `file` before renaming over it: +/// `{file}.{pid}-{n}.pcsave.tmp`. +/// +/// It does not start with a dot of its own, so it is not hidden: an SMB server that hides dot +/// files sets the hidden flag on such a file when it is created, the flag survives the rename, +/// and the saved document would be invisible in Finder (#11624). The cost is that the file shows +/// in a folder listing while a save writes it, and one an interrupted save leaves beside the +/// document stays visible until it is removed (#6604). The +/// process id, counter and suffix keep it apart from the files people name, and it is created +/// with `create_new`, so it can never replace one. Older versions wrote +/// `.{file}.{pid}-{n}.pcsave.tmp`; [`temp_owner`] recognizes both. pub fn temp_name(file: &str) -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); // Keep the name short: a long file name plus the suffix must stay under NAME_MAX. - let stem: String = file.chars().take(64).collect(); - format!(".{stem}.{}-{n}.pcsave.tmp", std::process::id()) + let stem: String = file.chars().take(TEMP_STEM_CHARS).collect(); + format!("{stem}.{}-{n}{TEMP_SUFFIX}", std::process::id()) +} + +/// The id of the process that wrote `name`, if it is a [`temp_name`]: today's +/// `{file}.{pid}-{n}.pcsave.tmp` or the `.{file}.{pid}-{n}.pcsave.tmp` older versions wrote, so +/// their leftovers are still cleaned up. +pub fn temp_owner(name: &str) -> Option { + let rest = name.strip_suffix(TEMP_SUFFIX)?; + let (stem, ids) = rest.rsplit_once('.')?; + let (pid, n) = ids.split_once('-')?; + // Only a file part `temp_name` writes: at most `TEMP_STEM_CHARS` characters, or one more when + // the first is the dot older versions put in front of them. + let chars = stem.chars().count(); + if chars > TEMP_STEM_CHARS && !(chars == TEMP_STEM_CHARS + 1 && stem.starts_with('.')) { + return None; + } + let stem = stem.strip_prefix('.').unwrap_or(stem); + // Only numbers `temp_name` writes: plain decimal digits with no leading zero (a lone "0" is + // fine), a `u32` pid and the `u64` counter it counts with. Any other name is someone else's + // file, and the sweep must not delete it. + let decimal = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && (s == "0" || !s.starts_with('0')); + if stem.is_empty() || !decimal(pid) || !decimal(n) || n.parse::().is_err() { + return None; + } + pid.parse().ok() } fn context(e: io::Error, msg: String) -> io::Error { @@ -494,6 +537,40 @@ fn context(e: io::Error, msg: String) -> io::Error { const UNCHANGED: &str = "the original file was not changed"; +/// Marks the error of a Windows replacement that moved the original to its backup name and then +/// could neither complete the save nor put the original back: the original survives only at that +/// backup, so the destination is not the unchanged original and must never be reported as such +/// (#11178). `rename_error` recognizes the marker and passes the error through untouched. +const RECOVERY_UNRESOLVED: &str = "could not restore the original"; + +/// The error for that unresolved Windows replacement state: names the recovery copy and says the +/// destination is currently missing, instead of the usual `UNCHANGED` reassurance (#11178). +/// When the restoration failed because the backup itself is gone (`NotFound`), nothing survives +/// at that name and saying so would be a second false comfort after the lost original. +/// Its kind is deliberately not `PermissionDenied`: the retry loop must not pick a fresh backup +/// name over the unresolved recovery and forget where the original is. +#[cfg(any(windows, test))] +pub(crate) fn unresolved_recovery_error(dest: &Path, completion: &io::Error, restoration: &io::Error, recovery: &Path) -> io::Error { + let msg = if restoration.kind() == io::ErrorKind::NotFound { + format!( + "cannot save {}: the previous version moved to its backup name but {RECOVERY_UNRESOLVED} from it \ + (completing the save failed: {completion}; the backup {} no longer exists: {restoration}), \ + so the destination currently holds no file under that name — check whether it was moved", + dest.display(), + recovery.display() + ) + } else { + format!( + "cannot save {}: the previous version moved to its backup name but {RECOVERY_UNRESOLVED} from it \ + (completing the save failed: {completion}; restoring the backup failed: {restoration}); \ + your document survives at {}, recover it from there before saving again", + dest.display(), + recovery.display() + ) + }; + io::Error::other(msg) +} + /// Replacement already happened: never claim that the original file is unchanged or undo it. #[cfg(windows)] pub(crate) fn publication_error(error: io::Error, path: &Path) -> io::Error { @@ -515,6 +592,12 @@ fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error { } fn rename_error(e: io::Error, path: &Path) -> io::Error { + // A Windows replacement that moved the original to its backup and failed to restore it is + // already fully described (with the recovery location); never append the `UNCHANGED` + // reassurance, which would be false in that state (#11178). + if e.to_string().contains(RECOVERY_UNRESOLVED) { + return e; + } let msg = if is_cross_device(&e) { format!("cannot save {}: the temporary file is on a different volume than the destination ({e}); {UNCHANGED}", path.display()) } else if e.kind() == io::ErrorKind::PermissionDenied { diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs index 6e1aaa15fe..1a7c5493b5 100644 --- a/crates/format/src/atomic/meta.rs +++ b/crates/format/src/atomic/meta.rs @@ -235,12 +235,46 @@ pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { } } +/// What the recovery ladder leaves behind after a `ReplaceFileW` answered +/// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (the destination's old content now lives at the backup +/// name and the replacement still at its temporary name). +#[cfg(any(windows, test))] +#[derive(Debug)] +pub(crate) enum MoveReplacement2 { + /// The replacement was renamed onto the destination: the save completed with its metadata. + Completed, + /// Completion failed but the original was put back on the destination: the save did not + /// happen and the destination still holds the user's file. Carries the completion error. + Restored(io::Error), + /// Neither completion nor restoration succeeded: the original survives only at `recovery` + /// and the destination is currently missing. The caller must report that location and must + /// not claim an unchanged destination (#11178). + Unresolved { completion: io::Error, restoration: io::Error, recovery: std::path::PathBuf }, +} + +/// The recovery ladder of [`MoveReplacement2`], kept free of the Windows call so the branch is +/// testable on every platform as branch coverage (#11178): first finish the save (`complete`, +/// the rename of the replacement onto the destination); if that fails, put the original back +/// (`restore`, the rename of the backup onto the destination). +#[cfg(any(windows, test))] +pub(crate) fn recover_after_1177( + complete: impl FnOnce() -> io::Result<()>, + restore: impl FnOnce() -> io::Result<()>, + recovery: std::path::PathBuf, +) -> MoveReplacement2 { + let Err(completion) = complete() else { return MoveReplacement2::Completed }; + match restore() { + Ok(()) => MoveReplacement2::Restored(completion), + Err(restoration) => MoveReplacement2::Unresolved { completion, restoration, recovery }, + } +} + /// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. /// /// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before /// the final rename, and documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: "the replaced file no /// longer exists and the replacement file exists under its original name" — the user's document -/// gone, its replacement left under a hidden temporary name. With a backup name the same failure +/// gone, its replacement left under a temporary name. With a backup name the same failure /// leaves both files where they were. So this always passes one, and deletes it on the way out. /// /// The failures and what each leaves behind (from the `ReplaceFileW` reference): @@ -249,7 +283,8 @@ pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { /// replacement keeps its name but has already inherited the destination's streams and /// attributes, and the destination's old content is at the backup name. Finishing the rename /// by hand completes the save *with* its metadata; if even that fails, the backup goes back to -/// the destination so the user's file is where they left it. +/// the destination so the user's file is where they left it — and if even *that* fails, the +/// error names the backup, because the original survives nowhere else (#11178). /// - Every other error leaves the replaced and replacement files under their own names, so a /// plain rename still saves the document — without the metadata, which is where this started. #[cfg(windows)] @@ -273,17 +308,28 @@ fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { remove_backup(backup.as_deref()); Ok(()) } - Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { - Ok(()) => { - remove_backup(backup.as_deref()); - Ok(()) - } - Err(rename_failed) => { - if let Some(backup) = backup.as_deref() { - let _ = std::fs::rename(backup, to); + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match backup.clone() { + // `backup_str` came from `backup`, so it is `Some` here; if that ever changed, leave + // the destination alone rather than guess where the original went (#11178). + None => Err(io::Error::other(format!( + "cannot save {}: the replacement moved the original to an unknown backup name and cannot report where", + to.display() + ))), + Some(backup_path) => match recover_after_1177(|| std::fs::rename(from, to), || std::fs::rename(&backup_path, to), backup_path.clone()) { + MoveReplacement2::Completed => { + remove_backup(Some(&backup_path)); + Ok(()) } - Err(rename_failed) - } + // The original is back where the user left it; the completion error stands, and + // a `PermissionDenied` one may still be retried (the temporary file is intact). + MoveReplacement2::Restored(completion) => Err(completion), + // Unresolved: the destination is missing and the original survives only at the + // backup. Report the recovery location and stop the retry loop from picking a + // fresh backup name over it (#11178). + MoveReplacement2::Unresolved { completion, restoration, recovery } => { + Err(super::unresolved_recovery_error(to, &completion, &restoration, &recovery)) + } + }, }, Err(_) => { remove_backup(backup.as_deref()); diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs index 834e87e66e..b9571ee93b 100644 --- a/crates/format/src/atomic/meta/tests.rs +++ b/crates/format/src/atomic/meta/tests.rs @@ -4,7 +4,9 @@ //! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` //! and are covered by the Windows CI job, not by a developer on a Mac. +use super::{MoveReplacement2, recover_after_1177}; use crate::atomic::atomic_write; +use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -308,3 +310,82 @@ mod windows { assert_no_temp(&d.0); } } + +// ---------------------------------------------------------------- MOVE_REPLACEMENT_2 ladder (#11178) +// +// The Windows `ReplaceFileW` cannot be made to return `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` on +// demand, so these exercise the recovery ladder with injected completion/restoration outcomes on +// every platform: branch coverage of the ladder, not a reproduction of a real Windows incident. + +/// The ladder finishing the rename completes the save; the restoration is never attempted. +#[test] +fn the_move_replacement_2_ladder_completes_the_save_when_the_rename_works() { + let r = recover_after_1177(|| Ok(()), || panic!("restoration must not run after a completed save"), PathBuf::from("doc.bak")); + assert!(matches!(r, MoveReplacement2::Completed)); +} + +/// Completion fails but the original is restored to the destination: the caller keeps the plain +/// completion error (which truthfully says the destination still holds the original), not a +/// recovery report. +#[test] +fn the_move_replacement_2_ladder_restores_the_original_when_completion_fails() { + let r = recover_after_1177(|| Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked")), || Ok(()), PathBuf::from("doc.bak")); + match r { + MoveReplacement2::Restored(e) => assert_eq!(e.kind(), io::ErrorKind::PermissionDenied, "the completion error stands"), + other => panic!("expected Restored, got {other:?}"), + } +} + +/// Completion fails and the restoration fails too: the ladder must surface the unresolved state +/// with the exact recovery location, so the caller can point the user at the surviving original +/// instead of falsely promising an unchanged destination (#11178). +#[test] +fn the_move_replacement_2_ladder_keeps_the_recovery_location_when_both_renames_fail() { + let recovery = PathBuf::from("/tmp/doc.bak.pcsave.tmp"); + let r = recover_after_1177( + || Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked")), + || Err(io::Error::new(io::ErrorKind::PermissionDenied, "still locked")), + recovery.clone(), + ); + match r { + MoveReplacement2::Unresolved { completion, restoration, recovery: got } => { + assert_eq!(got, recovery, "the exact backup path is carried out"); + assert!(completion.to_string().contains("locked") && restoration.to_string().contains("locked"), "{completion}; {restoration}"); + } + other => panic!("expected Unresolved, got {other:?}"), + } +} + +/// The unresolved error names the recovery location and never carries the `UNCHANGED` +/// reassurance, whose kind is deliberately not retryable so a permission retry cannot forget it. +#[test] +fn the_unresolved_recovery_error_names_the_backup_and_is_not_retried() { + let e = crate::atomic::unresolved_recovery_error( + Path::new("/docs/report.psd"), + &io::Error::new(io::ErrorKind::PermissionDenied, "locked"), + &io::Error::new(io::ErrorKind::PermissionDenied, "still locked"), + Path::new("/docs/.report.psd.9.pcsave.tmp"), + ); + let msg = e.to_string(); + assert!(msg.contains(".report.psd.9.pcsave.tmp"), "the backup path is named: {msg}"); + assert!(msg.contains("recover"), "the user is told to recover the document: {msg}"); + assert!(!msg.contains("the original file was not changed"), "no false reassurance: {msg}"); + assert_ne!(e.kind(), io::ErrorKind::PermissionDenied, "the retry loop must stop, not pick a fresh backup name over the unresolved recovery"); +} + +/// When the restoration failed because the backup itself is gone, the error must not promise a +/// recovery copy that does not exist (#11178). +#[test] +fn a_vanished_backup_is_reported_as_gone_rather_than_as_a_recovery_copy() { + let e = crate::atomic::unresolved_recovery_error( + Path::new("/docs/report.psd"), + &io::Error::new(io::ErrorKind::PermissionDenied, "locked"), + &io::Error::from_raw_os_error(2), // `ENOENT` / `ERROR_FILE_NOT_FOUND`: the backup is gone. + Path::new("/docs/.report.psd.9.pcsave.tmp"), + ); + let msg = e.to_string(); + assert!(msg.contains(".report.psd.9.pcsave.tmp") && msg.contains("no longer exists"), "the vanished backup is named: {msg}"); + assert!(msg.contains("no file under that name"), "{msg}"); + assert!(!msg.contains("survives at"), "no recovery promise for a vanished backup: {msg}"); + assert!(!msg.contains("the original file was not changed"), "{msg}"); +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 2baba46fac..84611d1883 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -298,6 +298,33 @@ fn rename_failure_keeps_original() { assert_no_temp(&d.0); } +/// The unresolved Windows recovery state (branch coverage, #11178): the error names the backup +/// where the original survives, `rename_error` passes it through without the false `UNCHANGED` +/// promise, and `retry_rename` does not retry it into a fresh backup name that would forget it. +#[test] +fn an_unresolved_recovery_reports_the_backup_and_is_not_retried() { + let unresolved = || { + unresolved_recovery_error( + Path::new("/docs/report.psd"), + &io::Error::new(io::ErrorKind::PermissionDenied, "completion locked"), + &io::Error::new(io::ErrorKind::PermissionDenied, "restoration locked"), + Path::new("/docs/.report.psd.7.pcsave.tmp"), + ) + }; + let reported = rename_error(unresolved(), Path::new("/docs/report.psd")); + let msg = reported.to_string(); + assert!(msg.contains(".report.psd.7.pcsave.tmp") && msg.contains("recover"), "the recovery location survives reporting: {msg}"); + assert!(!msg.contains(UNCHANGED), "the destination is not the unchanged original in this state: {msg}"); + let calls = Cell::new(0); + let e = retry_rename(RenameRetry { attempts: 7, first_delay_ms: 0 }, || { + calls.set(calls.get() + 1); + Err(unresolved()) + }) + .unwrap_err(); + assert_eq!(calls.get(), 1, "the retry loop must not run again over an unresolved recovery"); + assert!(!e.to_string().contains(UNCHANGED), "{e}"); +} + #[test] fn cross_device_rename_errors_clearly_and_never_writes_in_place() { let d = TempDir::new("exdev"); @@ -636,3 +663,105 @@ fn keeps_the_creation_time_under_a_deny_write_acl() { let _ = exacl::setfacl(&[&p], &[], None); assert_no_temp(&d.0); } + +/// A real file system that records the name of every temporary file a save writes. +#[derive(Default)] +struct TempNames(std::cell::RefCell>); + +impl AtomicFs for TempNames { + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + self.0.borrow_mut().push(tmp.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + RealFs.write_new(tmp, bytes) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.rename(from, to) + } + fn remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + RealFs.sync_dir(dir) + } + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) + } +} + +/// #11624: the temporary file a save renames over the document has no leading dot. An SMB share +/// that hides dot files marks such a file hidden when it is created, and the mark stayed on the +/// saved document, so it didn't show in Finder. +#[test] +fn the_temporary_file_is_not_a_dot_file() { + let d = TempDir::new("visible"); + let p = d.0.join("report.psd"); + let fs = TempNames::default(); + atomic_write_with(&fs, NO_RETRY, &p, b"first").unwrap(); + atomic_write_with(&fs, NO_RETRY, &p, b"second").unwrap(); + let names = fs.0.take(); + assert_eq!(names.len(), 2, "{names:?}"); + for name in &names { + assert!(!name.starts_with('.'), "a dot file is hidden on some shares: {name}"); + assert!(name.starts_with("report.psd.") && name.ends_with(".pcsave.tmp"), "{name}"); + assert_eq!(temp_owner(name), Some(std::process::id()), "{name}"); + } + assert_ne!(names[0], names[1]); + assert_eq!(std::fs::read(&p).unwrap(), b"second"); + assert_no_temp(&d.0); +} + +/// #11624: both temp name forms are recognized, so the leftovers of versions that wrote +/// `.{file}.{pid}-{n}.pcsave.tmp` are still cleaned up; nothing else is. +#[test] +fn temp_owner_reads_both_name_forms_and_nothing_else() { + assert_eq!(temp_owner(&temp_name("doc.pcraft")), Some(std::process::id())); + assert_eq!(temp_owner("doc.pcraft.42-7.pcsave.tmp"), Some(42)); + assert_eq!(temp_owner(".doc.pcraft.42-7.pcsave.tmp"), Some(42), "the dot-prefixed form older versions wrote"); + assert_eq!(temp_owner(".hidden.psd.42-0.pcsave.tmp"), Some(42), "a dot file's own temp file"); + assert_eq!(temp_owner("doc.42-18446744073709551615.pcsave.tmp"), Some(42), "the largest counter"); + assert_eq!(temp_owner("doc.0-0.pcsave.tmp"), Some(0), "a lone zero is a number temp_name writes"); + for other in [ + "doc.pcraft", + "doc.pcraft.42-7.tmp", + "doc.42-7.pcsave", + ".42-7.pcsave.tmp", + "doc.x-7.pcsave.tmp", + "doc.+42-7.pcsave.tmp", + "doc.42-.pcsave.tmp", + "doc.42-x.pcsave.tmp", + "doc.42-18446744073709551616.pcsave.tmp", + "doc.4294967296-7.pcsave.tmp", + "doc.42-07.pcsave.tmp", + "doc.42-00.pcsave.tmp", + "doc.042-7.pcsave.tmp", + "doc.00-7.pcsave.tmp", + "doc.42.pcsave.tmp", + "42-7.pcsave.tmp", + ] { + assert_eq!(temp_owner(other), None, "{other}"); + } +} + +/// A recovery folder can hold someone else's file that only looks like a temp name. `temp_name` +/// keeps at most 64 characters (not bytes) of the file name, and older versions put one dot in +/// front of them, so a longer file part is not a leftover of a save and the sweep must keep it. +#[test] +fn temp_owner_takes_only_a_file_part_temp_name_writes() { + let pid = Some(std::process::id()); + for file in ["a".repeat(200), "🚀".repeat(70), format!(".{}", "a".repeat(100))] { + assert_eq!(temp_owner(&temp_name(&file)), pid, "{file}"); + } + let today = |stem: &str| format!("{stem}.42-7.pcsave.tmp"); + let older = |stem: &str| format!(".{stem}.42-7.pcsave.tmp"); + for c in ["a", "é", "🚀"] { + let (at, over) = (c.repeat(64), c.repeat(65)); + assert_eq!(temp_owner(&today(&at)), Some(42), "64 characters: {c}"); + assert_eq!(temp_owner(&older(&at)), Some(42), "64 characters after the old dot: {c}"); + assert_eq!(temp_owner(&today(&over)), None, "65 characters: {c}"); + assert_eq!(temp_owner(&older(&over)), None, "65 characters after the old dot: {c}"); + // A dot file's own dot counts in its 64 characters; the old form adds one more dot. + let dotted = |k: usize| format!(".{}", c.repeat(k)); + assert_eq!(temp_owner(&today(&dotted(63))), Some(42), "a dot file today: {c}"); + assert_eq!(temp_owner(&older(&dotted(63))), Some(42), "a dot file in the old form: {c}"); + assert_eq!(temp_owner(&older(&dotted(64))), None, "a dot file one character over: {c}"); + } +} diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 2ab8676c66..85798bea7d 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -473,10 +473,8 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) { #[cfg(not(target_arch = "wasm32"))] fn sweep_temp_file(path: &Path, min_age: std::time::Duration) { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return }; - // `.{stem}.{pid}-{n}.pcsave.tmp` - let Some(rest) = name.strip_prefix('.').and_then(|n| n.strip_suffix(".pcsave.tmp")) else { return }; - let Some((_, owner)) = rest.rsplit_once('.') else { return }; - let Some(pid) = owner.split_once('-').and_then(|(pid, _)| pid.parse::().ok()) else { return }; + // `{stem}.{pid}-{n}.pcsave.tmp`, or `.{stem}…` from versions before #11624. + let Some(pid) = crate::atomic::temp_owner(name) else { return }; if pid != std::process::id() && untouched_for(path, min_age, false) { let _ = std::fs::remove_file(path); } @@ -573,6 +571,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #11624: temp files are no longer dot files; the sweep removes another process's in both + /// forms, so what older versions left is still cleaned up. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn sweep_removes_temp_files_of_both_name_forms() { + let dir = std::env::temp_dir().join(format!( + "pcraft-sweep-forms-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let foreign = [dir.join("kept.json.4294967294-3.pcsave.tmp"), dir.join(".kept.json.4294967294-4.pcsave.tmp")]; + let own = [dir.join(crate::atomic::temp_name("kept.json")), dir.join(format!(".kept.json.{}-9.pcsave.tmp", std::process::id()))]; + let others = [dir.join("kept.json"), dir.join("kept.json.4294967294-3.tmp"), dir.join("kept.json.pcsave.tmp")]; + for p in foreign.iter().chain(&own).chain(&others) { + std::fs::write(p, b"tmp").unwrap(); + } + sweep_orphans(&dir, std::time::Duration::ZERO); + assert!(foreign.iter().all(|p| !p.exists()), "another process's temp files are swept, either form"); + assert!(own.iter().all(|p| p.is_file()), "this process's temp files are kept"); + assert!(others.iter().all(|p| p.is_file()), "anything else is left alone"); + let _ = std::fs::remove_dir_all(&dir); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_committed_bundle_without_a_sidecar_is_listed_and_survives_the_sweep() { diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 8ea8c5cc3e..543587ac37 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -211,6 +211,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { feather: m.feather, }), vector_mask: l.vector_mask.clone(), + frame: l.frame.as_deref().cloned(), effects: EffectsM { enabled: l.effects.enabled, items: l.effects.items.clone(), @@ -463,6 +464,9 @@ impl Loader<'_> { /// One layer, given its already converted group children. #[inline(never)] fn layer_with(&mut self, m: &LayerM, children: Vec) -> Result { + // Unusable frame metadata is dropped rather than making the whole document unreadable; + // the group and its vector mask still load. + let frame = m.frame.clone().filter(|f| f.valid() && matches!(m.content, ContentM::Group { .. })); let content = match &m.content { ContentM::Raster { surface } => LayerContent::Raster(self.surface(surface)?), ContentM::Group { expanded, artboard, frame_stroke, .. } => { @@ -569,6 +573,7 @@ impl Loader<'_> { clipped: m.clipped, mask, vector_mask: m.vector_mask.clone(), + frame: frame.map(Box::new), effects: Effects { enabled: m.effects.enabled, items: m.effects.items.clone(), diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 696b52cb72..06b35f8208 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -47,7 +47,7 @@ pub use autosave::RecoveryStore; pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, recover}; pub use convert::MAX_GROUP_DEPTH; pub use manifest::{FORMAT_VERSION, Manifest}; -pub use read::read_file; +pub use read::{read_file, read_file_limited}; pub use store::{ByteSource, DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats, read_entry}; /// File extension of the native format. @@ -138,9 +138,25 @@ pub const MAX_CANVAS_SIDE: u32 = 300_000; /// canvas with no content allocates nothing for its area. pub const MAX_CANVAS_PIXELS: u64 = MAX_CANVAS_SIDE as u64 * MAX_CANVAS_SIDE as u64; +/// Byte budgets of a load in the browser build. A wasm32 instance has at most 4 GiB of linear +/// memory, which the editor, the file's own bytes and the loaded document's composites share, so +/// the desktop defaults (1 GiB a blob, 16 GiB in all) would let a small, highly compressible +/// bundle exhaust it before any budget is reached. +#[cfg(target_arch = "wasm32")] +pub const BROWSER_MAX_BLOB_BYTES: usize = 512 << 20; +/// See [`BROWSER_MAX_BLOB_BYTES`]. +#[cfg(target_arch = "wasm32")] +pub const BROWSER_MAX_TOTAL_BYTES: u64 = 1 << 30; + impl Default for LoadOptions { fn default() -> Self { - let limits = craft_pcraft_format::Limits::default(); + #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] + let mut limits = craft_pcraft_format::Limits::default(); + #[cfg(target_arch = "wasm32")] + { + limits.max_blob_bytes = limits.max_blob_bytes.min(BROWSER_MAX_BLOB_BYTES); + limits.max_total_bytes = limits.max_total_bytes.min(BROWSER_MAX_TOTAL_BYTES); + } LoadOptions { max_manifest_bytes: limits.max_manifest_bytes, max_blob_bytes: limits.max_blob_bytes, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 5d725b6bf2..9392c78312 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -205,6 +205,8 @@ pub struct LayerM { pub effects: EffectsM, pub label: LabelColor, pub content: ContentM, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frame: Option, /// (4-char key as hex, blob) pub psd_blocks: Vec<(String, Hash)>, pub psd_id: Option, diff --git a/crates/format/src/read.rs b/crates/format/src/read.rs index b71e3c8030..5d8856b58d 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -35,6 +35,13 @@ pub fn read_file_with(path: &Path, chunk: usize) -> io::Result> { read_path(path, chunk, MAX_OPEN_BYTES) } +/// [`read_file`] with the caller's own total limit, in bytes: a file larger than `max` is a +/// `FileTooLarge` error and is not read, so a caller holding part of a budget can refuse it +/// before reserving anything. +pub fn read_file_limited(path: &Path, max: u64) -> io::Result> { + read_path(path, READ_CHUNK, max.min(MAX_OPEN_BYTES)) +} + /// [`read_file_with`] with a chosen total limit (tests use small ones). fn read_path(path: &Path, chunk: usize, max: u64) -> io::Result> { #[cfg(unix)] @@ -95,7 +102,12 @@ fn read_all_with(reader: &mut impl Read, size_hint: u64, chunk: usize, max: u64) break; } let needed = buf.len() as u64 + n as u64; - buf.try_reserve(n.max(chunk)).map_err(|_| out_of_memory(needed))?; + // Refuse before reserving, so a caller with a small limit never allocates past it. + if needed > max { + return Err(too_large(max)); + } + let room = usize::try_from(max - buf.len() as u64).unwrap_or(usize::MAX); + buf.try_reserve(n.max(chunk).min(room)).map_err(|_| out_of_memory(needed))?; buf.extend_from_slice(&probe[..n]); } let room = u64::try_from((buf.capacity() - buf.len()).min(chunk)).unwrap_or(u64::MAX); @@ -201,6 +213,28 @@ mod tests { assert_eq!(buf.capacity(), data.len()); } + /// Growing the buffer past the hint never reserves more than the caller's limit, so a small + /// limit is never outgrown by the read chunk. + #[test] + fn growing_past_the_hint_stays_within_the_limit() { + let data = vec![3u8; 4_000]; + let buf = read_all_with(&mut std::io::Cursor::new(&data), 0, 1 << 20, 5_000).unwrap(); + assert_eq!(buf, data); + assert!(buf.capacity() <= 5_000, "reserved {}", buf.capacity()); + let e = read_all_with(&mut std::io::Cursor::new(vec![3u8; 9_000]), 0, 1 << 20, 5_000).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::FileTooLarge); + } + + #[test] + fn a_caller_limit_refuses_a_larger_file_before_reading_it() { + let data = vec![7u8; 10_000]; + let p = temp("limited", &data); + assert_eq!(read_file_limited(&p, data.len() as u64).unwrap(), data, "exactly at the limit"); + let e = read_file_limited(&p, data.len() as u64 - 1).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::FileTooLarge); + assert!(e.to_string().contains("was not read"), "{e}"); + } + #[test] fn empty_and_missing_files() { assert!(read_file(&temp("empty", b"")).unwrap().is_empty()); diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 309151d52d..976c52cc70 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -394,6 +394,11 @@ impl Fetch for LoadFetch<'_> { let Some(path) = blob_path(hash) else { return Err(FormatError::corrupt(format!("invalid blob hash `{hash}`"))); }; + // A new blob is retained beside every earlier one, so it may inflate to no more than + // what the load's total budget has left: checking the total only after decompressing + // would let each of several blobs allocate a full `max_blob_bytes` first. + let left = usize::try_from(self.opts.max_total_bytes.saturating_sub(self.total)).unwrap_or(usize::MAX); + let limit = limit.min(left); // The stored zstd frame of incompressible data is a little larger than the data, so a // blob right at `limit` must still be read; `decompress_bounded` enforces `limit`. let z = self.src.get(&path, limit.saturating_add(limit / 8).saturating_add(4096))?; @@ -1747,4 +1752,10 @@ fn an_incompressible_blob_at_its_limit_still_loads() { let mut fetch = LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }; assert_eq!(fetch.blob_bounded(&hash, data.len()).unwrap().as_slice(), data.as_slice()); assert!(LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }.blob_bounded(&hash, data.len() - 1).is_err()); + // What the load's total budget has left bounds the decompression too. + let opts = crate::LoadOptions { max_total_bytes: 10 * data.len() as u64, ..Default::default() }; + let spent = |total: u64| LoadFetch { src: &src, opts, total, blobs: HashMap::new() }; + assert!(spent(9 * data.len() as u64).blob_bounded(&hash, usize::MAX).is_ok()); + let e = spent(9 * data.len() as u64 + 1).blob_bounded(&hash, usize::MAX).unwrap_err(); + assert!(matches!(e, FormatError::LimitExceeded(ref m) if m.contains(&format!("exceeds {} bytes", data.len() - 1))), "{e}"); } diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index e33ec9c01a..6a5fef4382 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -4,11 +4,20 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +default = [] +vulkan = ["wgpu/vulkan"] +metal = ["wgpu/metal"] +dx12 = ["wgpu/dx12"] +gles = ["wgpu/gles"] +native = ["vulkan", "metal", "dx12", "gles"] + [lints] workspace = true [dependencies] half = "2" +thiserror = { workspace = true } photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-raster = { workspace = true } @@ -24,6 +33,7 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Effect distance fields are computed in parallel bands. rayon = { workspace = true } +pollster = "1" [dev-dependencies] wgpu = { version = "30" } diff --git a/crates/gpu/examples/compositor_build_probe.rs b/crates/gpu/examples/compositor_build_probe.rs new file mode 100644 index 0000000000..932057f1fc --- /dev/null +++ b/crates/gpu/examples/compositor_build_probe.rs @@ -0,0 +1,189 @@ +//! Measure compositor pipeline construction and, optionally, device work during a background build. +//! +//! `cargo run --release -p photocraft-gpu --example compositor_build_probe -- [dx12|vulkan|metal|gl] [fxc|dxc] [rounds=3] [threads=N] [--background] [--json out.json]` +//! +//! The explicit thread count is passed to the dedicated Rayon build pool. + +use photocraft_gpu::Compositor; +use std::fs; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Sample { + build_ms: f64, + ok: bool, + error: Option, +} + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let backend = args.first().map(String::as_str).unwrap_or("dx12"); + let compiler = args.get(1).map(String::as_str).unwrap_or("fxc"); + let rounds = args.iter().find_map(|a| a.strip_prefix("rounds=").and_then(|s| s.parse::().ok())).unwrap_or(3).max(1); + let default_threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)); + let threads = args.iter().find_map(|a| a.strip_prefix("threads=").and_then(|s| s.parse::().ok())).unwrap_or(default_threads).max(1); + let background = args.iter().any(|a| a == "--background"); + let json_path = args.iter().position(|a| a == "--json").and_then(|i| args.get(i + 1)); + let backends = match backend { + "vulkan" => wgpu::Backends::VULKAN, + "metal" => wgpu::Backends::METAL, + "gl" => wgpu::Backends::GL, + _ => wgpu::Backends::DX12, + }; + let shader_compiler = match compiler { + "dxc" => wgpu::Dx12Compiler::default_dynamic_dxc(), + _ => wgpu::Dx12Compiler::Fxc, + }; + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = backends; + descriptor.flags = wgpu::InstanceFlags::empty(); + descriptor.backend_options.dx12.shader_compiler = shader_compiler; + let instance = wgpu::Instance::new(descriptor); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { force_fallback_adapter: true, ..Default::default() }))?; + let info = adapter.get_info(); + println!("adapter={} backend={:?} driver={} {} threads={threads}", info.name, info.backend, info.driver, info.driver_info); + + let mut samples = Vec::with_capacity(rounds); + let mut stall = Vec::new(); + let mut cpu_idle_ms = None; + let mut cpu_during_ms = None; + for round in 0..rounds { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))?; + let format = Compositor::preferred_acc_format(&adapter); + if background { + if round == 0 { + let doc = photocraft_doc::Document::with_background( + "compositor build probe", + photocraft_geom::Size::new(1920, 1080), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::WHITE, + ); + let start = Instant::now(); + let _ = photocraft_compose::render(&doc, doc.bounds()); + cpu_idle_ms = Some(start.elapsed().as_secs_f64() * 1000.0); + } + let (build_tx, build_rx) = std::sync::mpsc::channel(); + let start = Instant::now(); + std::thread::scope(|scope| { + scope.spawn(|| { + let build_start = Instant::now(); + let result = Compositor::try_new_with_format_threads(&device, format, threads); + let elapsed = build_start.elapsed().as_secs_f64() * 1000.0; + let error = result.err().map(|e| e.to_string()); + let _ = build_tx.send((error, elapsed)); + }); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("compositor probe UI target"), + size: wgpu::Extent3d { width: 256, height: 256, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let mut last_cpu = start; + loop { + match build_rx.try_recv() { + Ok((error, build_ms)) => { + samples.push(Sample { build_ms, ok: error.is_none(), error }); + break; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + samples.push(Sample { build_ms: start.elapsed().as_secs_f64() * 1000.0, ok: false, error: Some("build thread terminated".into()) }); + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => (), + } + let tick = Instant::now(); + let mut encoder = device.create_command_encoder(&Default::default()); + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("probe frame"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + drop(_pass); + queue.submit([encoder.finish()]); + let _ = device.poll(wgpu::PollType::Poll); + stall.push(tick.elapsed().as_secs_f64() * 1000.0); + if round == 0 && cpu_during_ms.is_none() && last_cpu.elapsed() >= Duration::from_millis(100) { + let doc = photocraft_doc::Document::with_background( + "compositor build probe", + photocraft_geom::Size::new(1920, 1080), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::WHITE, + ); + let cpu_start = Instant::now(); + let _ = photocraft_compose::render(&doc, doc.bounds()); + cpu_during_ms = Some(cpu_start.elapsed().as_secs_f64() * 1000.0); + last_cpu = Instant::now(); + } + std::thread::sleep(Duration::from_millis(16)); + } + }); + } else { + let start = Instant::now(); + let result = Compositor::try_new_with_format_threads(&device, format, threads); + let build_ms = start.elapsed().as_secs_f64() * 1000.0; + let error = result.err().map(|e| e.to_string()); + samples.push(Sample { build_ms, ok: error.is_none(), error }); + } + let s = samples.last().ok_or("missing compositor build sample")?; + println!("round={round} build_ms={:.3} ok={} error={}", s.build_ms, s.ok, s.error.as_deref().unwrap_or("")); + } + + stall.sort_by(f64::total_cmp); + let p95 = stall.get((stall.len() * 95 / 100).min(stall.len().saturating_sub(1))).copied().unwrap_or(0.0); + let max = stall.last().copied().unwrap_or(0.0); + println!("ui_stall_p95_ms={p95:.3} ui_stall_max_ms={max:.3} cpu_idle_ms={cpu_idle_ms:?} cpu_during_ms={cpu_during_ms:?}"); + if let Some(path) = json_path { + let rounds_json = samples + .iter() + .map(|s| { + format!("{{\"build_ms\":{:.3},\"ok\":{},\"error\":{}}}", s.build_ms, s.ok, s.error.as_deref().map(json_string).unwrap_or_else(|| "null".into())) + }) + .collect::>() + .join(","); + let json = format!( + "{{\"backend\":{},\"compiler\":{},\"adapter\":{},\"threads\":{},\"rounds\":[{}],\"stall\":{{\"p95_ms\":{p95:.3},\"max_ms\":{max:.3}}},\"cpu_composite_ms\":{{\"idle\":{},\"during_build\":{}}}}}", + json_string(backend), + json_string(compiler), + json_string(&info.name), + threads, + rounds_json, + optional_number(cpu_idle_ms), + optional_number(cpu_during_ms) + ); + fs::write(path, json)?; + } + Ok(()) +} + +fn json_string(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn optional_number(n: Option) -> String { + n.map(|v| format!("{v:.3}")).unwrap_or_else(|| "null".into()) +} diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 09dd4cc415..91cc2ff662 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -73,14 +73,10 @@ const F_LAYER_SHAPE: u32 = 1048576u; // fx merge: B is the shape; layer = its co @group(1) @binding(6) var map_tex: texture_2d; @group(1) @binding(7) var pat_tex: texture_2d; +// The full-chunk triangle's vertex stage lives in `vertex_shader` (kit.rs): one entry point per +// pipeline build job, so no two threads share a compiled vertex shader. struct VOut { @builtin(position) pos: vec4 }; -@vertex -fn vs(@builtin(vertex_index) vi: u32) -> VOut { - let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); - return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0)); -} - fn local(pos: vec4) -> vec2 { return vec2(floor(pos.xy)); } fn doc_px(p: vec2) -> vec2 { return chunk.origin + p; } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs new file mode 100644 index 0000000000..c9baa2d23a --- /dev/null +++ b/crates/gpu/src/context.rs @@ -0,0 +1,227 @@ +//! Native windowless wgpu context creation for headless composition and export. + +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use crate::health::DeviceHealth; +use crate::{Compositor, Unsupported}; + +/// Errors encountered when creating or inspecting a native GPU context. +#[derive(Debug, thiserror::Error)] +pub enum GpuContextError { + #[error("no suitable GPU adapter found: {0}")] + NoAdapter(String), + #[error("failed to request GPU device: {0}")] + RequestDevice(String), + #[error("failed to initialize GPU compositor: {0}")] + Compositor(String), + #[error("GPU device was lost: {0}")] + DeviceLost(String), +} + +impl From for GpuContextError { + fn from(e: Unsupported) -> Self { + GpuContextError::Compositor(e.0) + } +} + +/// Configuration options for creating a [`NativeContext`]. +#[derive(Clone, Debug)] +pub struct NativeContextOptions { + /// Desired adapter power tier. Default is [`wgpu::PowerPreference::HighPerformance`] + /// to prefer discrete/dedicated hardware GPUs. + pub power_preference: wgpu::PowerPreference, + /// Force software/fallback adapter selection (e.g. WARP/llvmpipe). + pub force_fallback_adapter: bool, + /// Allowed backend flags. Default is all available native backends. + pub backends: wgpu::Backends, + /// Bounded timeout for adapter/device initialization. When set, requests exceeding + /// this duration return [`GpuContextError::NoAdapter`]. Default is 10 seconds. + /// + /// # Limitation + /// If initialization times out, the background initialization thread cannot be forcibly terminated + /// in standard Rust and continues detached until wgpu completes or fails. Callers should avoid + /// repeatedly requesting initialization in tight loops when timeouts occur to prevent thread and + /// resource leaks. + pub timeout: Option, +} + +impl Default for NativeContextOptions { + fn default() -> Self { + Self { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + backends: wgpu::Backends::all(), + timeout: Some(Duration::from_secs(10)), + } + } +} + +/// A headless, windowless GPU context managing a [`wgpu::Device`], [`wgpu::Queue`], +/// and device health monitor. +#[derive(Clone)] +pub struct NativeContext { + device: Arc, + queue: Arc, + adapter_info: wgpu::AdapterInfo, + health: DeviceHealth, + acc_format: wgpu::TextureFormat, + limits: wgpu::Limits, +} + +impl fmt::Debug for NativeContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NativeContext") + .field("adapter", &self.adapter_info.name) + .field("backend", &self.adapter_info.backend) + .field("device_type", &self.adapter_info.device_type) + .field("acc_format", &self.acc_format) + .field("healthy", &self.is_healthy()) + .finish() + } +} + +impl NativeContext { + /// Create a windowless GPU context using default high-performance options. + pub fn new() -> Result { + Self::request(&NativeContextOptions::default()) + } + + /// Request an adapter and device matching `options`. + pub fn request(options: &NativeContextOptions) -> Result { + let opts = options.clone(); + let init_work = move || -> Result { + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor { backends: opts.backends, ..wgpu::InstanceDescriptor::new_without_display_handle_from_env() }); + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: opts.power_preference, + force_fallback_adapter: opts.force_fallback_adapter, + compatible_surface: None, + ..Default::default() + })) + .or_else(|e| { + if !opts.force_fallback_adapter { + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::None, + force_fallback_adapter: true, + compatible_surface: None, + ..Default::default() + })) + } else { + Err(e) + } + }) + .map_err(|e| GpuContextError::NoAdapter(format!("request_adapter failed: {e}")))?; + + let info = adapter.get_info(); + log::info!("GPU native context: selected adapter '{}' ({:?}, {:?})", info.name, info.backend, info.device_type); + + let limits = adapter.limits(); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("pc_headless_gpu"), + required_features: wgpu::Features::empty(), + required_limits: limits.clone(), + ..Default::default() + })) + .map_err(|e| GpuContextError::RequestDevice(format!("request_device failed for '{}': {e}", info.name)))?; + + let health = DeviceHealth::watch(&device); + let acc_format = Compositor::preferred_acc_format(&adapter); + + Ok(Self { device: Arc::new(device), queue: Arc::new(queue), adapter_info: info, health, acc_format, limits }) + }; + + if let Some(timeout) = options.timeout { + let (tx, rx) = std::sync::mpsc::channel(); + let thread_res = std::thread::Builder::new().name("pc_gpu_init".into()).spawn(move || { + let res = init_work(); + let _ = tx.send(res); + }); + match thread_res { + Ok(_handle) => match rx.recv_timeout(timeout) { + Ok(res) => res, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + Err(GpuContextError::NoAdapter(format!("timed out after {timeout:?} requesting GPU adapter/device"))) + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(GpuContextError::NoAdapter("GPU initialization thread disconnected".into())), + }, + Err(e) => Err(GpuContextError::NoAdapter(format!("failed to spawn GPU initialization thread: {e}"))), + } + } else { + init_work() + } + } + + /// Build a new [`Compositor`] wired to this context's device and health flag. + pub fn create_compositor(&self) -> Result { + if !self.is_healthy() { + let fault = self.health.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()); + return Err(GpuContextError::DeviceLost(fault)); + } + let mut comp = Compositor::try_new_with_format(&self.device, self.acc_format)?; + comp.set_health(self.health.clone()); + Ok(comp) + } + + /// Whether this adapter is a CPU software rasterizer (e.g. llvmpipe, Lavapipe, WARP). + pub fn is_software(&self) -> bool { + self.adapter_info.device_type == wgpu::DeviceType::Cpu + } + + /// Human-readable adapter name reported by the driver. + pub fn adapter_name(&self) -> &str { + &self.adapter_info.name + } + + /// Backend graphics API in use (Vulkan, Metal, Dx12, etc.). + pub fn backend(&self) -> wgpu::Backend { + self.adapter_info.backend + } + + /// The underlying [`wgpu::Device`]. + pub fn device(&self) -> &wgpu::Device { + &self.device + } + + /// Arc clone of the device handle. + pub fn device_arc(&self) -> Arc { + self.device.clone() + } + + /// The underlying [`wgpu::Queue`]. + pub fn queue(&self) -> &wgpu::Queue { + &self.queue + } + + /// Arc clone of the queue handle. + pub fn queue_arc(&self) -> Arc { + self.queue.clone() + } + + /// Shared device health monitor. + pub fn health(&self) -> &DeviceHealth { + &self.health + } + + /// Preferred accumulation texture format for this adapter (`Rgba32Float` or `Rgba16Float`). + pub fn acc_format(&self) -> wgpu::TextureFormat { + self.acc_format + } + + /// Effective device limits. + pub fn limits(&self) -> &wgpu::Limits { + &self.limits + } + + /// Maximum 2D texture side dimension supported by this device. + pub fn max_texture_dimension_2d(&self) -> u32 { + self.limits.max_texture_dimension_2d + } + + /// Whether the device is healthy and has not reported a lost device or uncaptured error. + pub fn is_healthy(&self) -> bool { + self.health.is_ok() + } +} diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 972ccadbea..4f265a3202 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -336,8 +336,10 @@ fn bands(r: Rect, rows: i32) -> Vec { let mut out = Vec::new(); let mut y = r.y0; while y < r.y1 { - out.push(Rect::new(r.x0, y, r.x1, (y + rows).min(r.y1))); - y += rows; + // Saturating near the coordinate ceiling: a wrapped edge would never reach `y1`. + let end = y.saturating_add(rows.max(1)).min(r.y1); + out.push(Rect::new(r.x0, y, r.x1, end)); + y = end; } out } diff --git a/crates/gpu/src/fx/tests.rs b/crates/gpu/src/fx/tests.rs index 1622da28e2..d796288eeb 100644 --- a/crates/gpu/src/fx/tests.rs +++ b/crates/gpu/src/fx/tests.rs @@ -308,3 +308,12 @@ fn shape_tiles_and_group_pins_hold_addresses_not_pixels() { drop(group); assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels"); } + +#[test] +fn bands_end_at_the_coordinate_ceiling() { + let r = Rect::new(0, i32::MAX - 100, 8, i32::MAX); + let bands = super::bands(r, 64); + assert_eq!(bands.len(), 2, "{bands:?}"); + assert_eq!((bands[0].y0, bands[0].y1, bands[1].y1), (i32::MAX - 100, i32::MAX - 36, i32::MAX)); + assert_eq!(super::bands(Rect::new(0, 0, 8, 10), 0).len(), 10, "a zero step still makes progress"); +} diff --git a/crates/gpu/src/health.rs b/crates/gpu/src/health.rs index 5c07fbc180..2738105d24 100644 --- a/crates/gpu/src/health.rs +++ b/crates/gpu/src/health.rs @@ -14,6 +14,20 @@ use std::fmt; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::{Duration, Instant}; + +/// Outcome of a bounded wait for submitted GPU work. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WaitOutcome { + /// The submitted work finished before the deadline. + Finished, + /// The deadline passed (or the device faulted) before the work finished. + TimedOut, + /// The caller cancelled the wait before the work finished. + Cancelled, +} + /// Why the GPU can't be used any more. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Fault { @@ -245,6 +259,93 @@ impl DeviceHealth { self.lost_by_poll(); } } + + /// Like [`Self::wait`], but bounded: returns whether the submitted work finished within + /// `timeout`. A timeout (or a poll that fails without reporting a loss) marks the device + /// faulted, so later exports stop submitting work to a wedged GPU instead of each waiting the + /// full timeout again. A device that was already faulted returns `false` untouched. + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_timeout(&self, device: &wgpu::Device, index: Option, timeout: Duration) -> bool { + self.wait_timeout_cancellable(device, index, timeout, &|| false) == WaitOutcome::Finished + } + + /// [`Self::wait_timeout`] that polls in short slices and checks `cancelled` between them, so a + /// cancelled headless export stops waiting promptly while keeping the same overall deadline. + /// A slice timeout means "not finished yet"; only the overall deadline (or a failed poll) + /// marks the device faulted. + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_timeout_cancellable( + &self, + device: &wgpu::Device, + index: Option, + timeout: Duration, + cancelled: &(dyn Fn() -> bool + Sync), + ) -> WaitOutcome { + if !self.is_ok() { + return WaitOutcome::TimedOut; + } + let mut failure = None; + let outcome = wait_slices(timeout, cancelled, |slice| { + let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + device.poll(wgpu::PollType::Wait { submission_index: index.clone(), timeout: Some(slice) }) + })); + match poll { + Ok(Ok(_)) => { + if self.is_ok() { + SlicePoll::Done + } else { + SlicePoll::Failed + } + } + Ok(Err(wgpu::PollError::Timeout)) => SlicePoll::Pending, + Ok(Err(e)) => { + failure = Some(format!("GPU poll failed: {e}")); + SlicePoll::Failed + } + Err(_) => { + self.lost_by_poll(); + SlicePoll::Failed + } + } + }); + if outcome == WaitOutcome::TimedOut && self.is_ok() { + let fault = failure.map_or_else(|| Fault::Lost(format!("GPU wait timed out after {:.1} s", timeout.as_secs_f64())), Fault::Error); + self.mark(fault); + } + outcome + } +} + +/// What one bounded poll reported. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SlicePoll { + Done, + Pending, + Failed, +} + +/// Wait for `total`, polling in slices no longer than 100 ms and checking `cancelled` between +/// them. A slice that times out is pending, not a failure; only the overall deadline or a failed +/// poll ends the wait early. +#[cfg(not(target_arch = "wasm32"))] +fn wait_slices(total: Duration, cancelled: &(dyn Fn() -> bool + Sync), mut poll: impl FnMut(Duration) -> SlicePoll) -> WaitOutcome { + const SLICE: Duration = Duration::from_millis(100); + let start = Instant::now(); + loop { + if cancelled() { + return WaitOutcome::Cancelled; + } + let remaining = total.saturating_sub(start.elapsed()); + if remaining.is_zero() { + return WaitOutcome::TimedOut; + } + match poll(remaining.min(SLICE)) { + SlicePoll::Done => return WaitOutcome::Finished, + SlicePoll::Pending => {} + SlicePoll::Failed => return WaitOutcome::TimedOut, + } + } } /// The fault a wgpu error stands for. Errors about invalid or lost objects follow a device loss @@ -293,6 +394,49 @@ mod tests { assert!(!h.device_lost()); } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn wait_slices_cancel_timeout_and_completion() { + let never = || false; + assert_eq!(wait_slices(Duration::ZERO, &never, |_| SlicePoll::Done), WaitOutcome::TimedOut); + assert_eq!(wait_slices(Duration::from_secs(1), &never, |_| SlicePoll::Done), WaitOutcome::Finished); + assert_eq!(wait_slices(Duration::from_secs(1), &never, |_| SlicePoll::Failed), WaitOutcome::TimedOut); + + let mut polls = 0; + let outcome = wait_slices(Duration::from_secs(1), &never, |_| { + polls += 1; + if polls < 3 { SlicePoll::Pending } else { SlicePoll::Done } + }); + assert_eq!(outcome, WaitOutcome::Finished); + assert_eq!(polls, 3, "a timed-out slice must be retried, not treated as failure"); + + let cancelled = || true; + let mut called = false; + assert_eq!( + wait_slices(Duration::from_secs(1), &cancelled, |_| { + called = true; + SlicePoll::Done + }), + WaitOutcome::Cancelled + ); + assert!(!called, "cancellation must be checked before polling"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn wait_slices_observes_cancel_while_pending() { + let flag = AtomicBool::new(false); + let cancelled = || flag.load(Ordering::SeqCst); + let mut polls = 0; + let outcome = wait_slices(Duration::from_secs(1), &cancelled, |_| { + polls += 1; + flag.store(true, Ordering::SeqCst); + SlicePoll::Pending + }); + assert_eq!(outcome, WaitOutcome::Cancelled); + assert_eq!(polls, 1, "cancellation set during a pending poll must end the wait"); + } + #[test] fn fault_text() { assert_eq!(Fault::Lost(String::new()).to_string(), "GPU device lost"); diff --git a/crates/gpu/src/kit.rs b/crates/gpu/src/kit.rs index 6ac1b76dc3..12755b2dbf 100644 --- a/crates/gpu/src/kit.rs +++ b/crates/gpu/src/kit.rs @@ -1,4 +1,4 @@ -use super::consts::{CHUNK_UNIFORM, OP_UNIFORM, STRIDE}; +use super::consts::{CHUNK_UNIFORM, MAP16, MAP32, OP_UNIFORM, STRIDE}; use super::convert::{lut_texture, words}; use super::textures::{ResKey, ResidentRef}; use super::{Arc, HashMap, I, Kernel, NonZeroU64, Rect}; @@ -127,6 +127,48 @@ pub(crate) fn uniform_entry(binding: u32, size: u64) -> wgpu::BindGroupLayoutEnt /// The compositor's WGSL source (exposed for validation in tests). pub const SHADER: &str = include_str!("compose.wgsl"); +/// One thread's share of the compositor's pipelines: the vertex entry point (in +/// [`vertex_shader`]) and the (kernel, target format) pairs it builds. +pub(crate) struct BuildJob { + pub(crate) vertex: String, + pub(crate) keys: Vec<(Kernel, wgpu::TextureFormat)>, +} + +/// The compositor's pipelines, grouped by the thread that builds them: one job per kernel, and +/// an effect-map kernel's R32F and R16F pipelines in the same job. Each job has its own vertex +/// entry point, so no two jobs share a shader (see `Compositor::build`). +pub(crate) fn build_jobs(acc_format: wgpu::TextureFormat) -> Vec { + let one = |k: Kernel| if k.is_map() { vec![(k, MAP32), (k, MAP16)] } else { vec![(k, acc_format)] }; + let (plain, maps): (Vec, Vec) = Kernel::DRAWN.iter().partition(|k| !k.is_map()); + plain.into_iter().chain(maps).enumerate().map(|(job, k)| BuildJob { vertex: format!("vs_{job}"), keys: one(k) }).collect() +} + +/// The full-chunk triangle's vertex stage, once per build job under that job's name (`vs_0`, +/// `vs_1`, ...): the same code under distinct names, so each job compiles its own vertex shader. +/// +/// A workaround, not a design: wgpu-hal's D3D12 shader cache shares a compiled FXC blob between +/// threads, and FXC's blobs don't count references atomically, so a shared one can be freed +/// while still cached (gfx-rs/wgpu#10547, #9173). Each copy costs one compile of a three-line +/// shader. Once a wgpu release fixes the cache, go back to one `vs` in `compose.wgsl` and let +/// `build_jobs` split map kernels by format again. +pub(crate) fn vertex_shader(jobs: &[BuildJob]) -> String { + let mut s = String::from( + "struct VOut { @builtin(position) pos: vec4 }; +", + ); + for job in jobs { + s.push_str(&format!( + "@vertex fn {}(@builtin(vertex_index) vi: u32) -> VOut {{ + let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); + return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0)); +}} +", + job.vertex + )); + } + s +} + /// Pass-level resources resolved for one page cell: resident pages (texture, mask) and effect /// maps (view and region) per pass. pub(crate) struct Bound { diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 6fd8c13439..e4f0890884 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -131,14 +131,17 @@ use W::{F, I, U}; mod batch; mod cells; mod consts; +#[cfg(not(target_arch = "wasm32"))] +pub mod context; mod convert; mod encode; mod fx_passes; mod kit; mod paging; #[cfg(not(target_arch = "wasm32"))] -mod readback; +pub mod readback; mod setup; +pub use setup::{BuildFailure, BuildFailureKind}; mod textures; #[cfg(test)] @@ -146,6 +149,8 @@ use batch::{BATCH, batch_kernel, batch_op_words, batch_runs, op_words, run_start #[cfg(test)] use consts::OP_UNIFORM; pub use consts::{ACC_FORMAT, CHUNK, FX_BUDGET, PAGE, PATTERN_BUDGET, PATTERN_IDLE_FRAMES, RESIDENT_BUDGET}; +#[cfg(not(target_arch = "wasm32"))] +pub use context::{GpuContextError, NativeContext, NativeContextOptions}; use convert::W; #[cfg(test)] use convert::{CONVERT_BATCH, colour_managed, convert_tile, convert_tiles, rgb_gray16_to_f16, rgb_gray32_to_f16}; @@ -157,11 +162,26 @@ use paging::first_error; #[cfg(test)] use paging::{lru_victims, uniform_buffer_len}; #[cfg(not(target_arch = "wasm32"))] -pub use readback::{render_to_vec, render_to_vec_stats}; +pub use readback::{ReadbackError, render_bands, render_to_vec, render_to_vec_stats}; #[cfg(test)] use textures::TexKind; use textures::{CachedPattern, FxEntry, FxKey, ResKey, Resident, Tex}; pub use textures::{ChunkOut, Stats}; +/// Stable fingerprint of the compositor WGSL source, for invalidating cached build failures. +/// Uses 64-bit FNV-1a over the exact embedded source bytes. +pub fn shader_fingerprint() -> u64 { + fnv1a(SHADER.as_bytes()) +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x00000100000001b3); + } + hash +} + #[cfg(test)] mod tests; diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index a38cdde867..202f0216e1 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -82,6 +82,68 @@ fn lut_rows(f: impl Fn(usize, f32) -> f32, rows: usize) -> Vec<[f32; 4096]> { type Program = (i32, [[f32; 4]; 4], Option>); +/// What the LUT rows of `adj` at `depth` are built from, hashed bit for bit (so `-0.0` and `0.0` +/// stay apart): every field the row builder reads and nothing else, so settings outside the +/// tables (Hue/Saturation's master sliders) don't add entries. The settings are destructured in +/// full (Hue/Saturation's builder takes only `ranges`), so a new field fails to compile here until +/// it is placed. `None`: rows aren't cached. +pub(super) fn row_key(adj: &Adjustment, depth: photocraft_color::SampleType) -> Option { + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let level = |h: &mut std::collections::hash_map::DefaultHasher, c: &LevelsChannel| { + let LevelsChannel { in_black, in_white, gamma, out_black, out_white } = c; + [in_black, in_white, gamma, out_black, out_white].map(|v| v.to_bits()).hash(h); + }; + let curve = |h: &mut std::collections::hash_map::DefaultHasher, c: &[CurvePoint]| { + c.len().hash(h); + for CurvePoint { input, output } in c { + (input.to_bits(), output.to_bits()).hash(h); + } + }; + match adj { + // adjust::tone_luts_depth, which rounds to the depth's quantum. + Adjustment::Levels { master, per_channel, space, black } => { + (0u8, depth, *space as u8).hash(&mut h); + for c in [master, &per_channel[0], &per_channel[1], &per_channel[2], black] { + level(&mut h, c); + } + } + Adjustment::Curves { master, per_channel, space, black, pencil } => { + (1u8, depth, *space as u8, *pencil).hash(&mut h); + for c in [master, &per_channel[0], &per_channel[1], &per_channel[2], black] { + curve(&mut h, c); + } + } + // adjust::hue_range_tables reads only the ranges, at any depth. + Adjustment::HueSaturation { ranges, .. } => { + 2u8.hash(&mut h); + for HueRange { hue, saturation, lightness, bounds } in ranges { + [hue, saturation, lightness].map(|v| v.to_bits()).hash(&mut h); + bounds.map(f32::to_bits).hash(&mut h); + } + } + // adjust::BalanceCurve::rgb, at any depth. + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + (3u8, *preserve_luminosity).hash(&mut h); + for tone in [shadows, midtones, highlights] { + tone.map(f32::to_bits).hash(&mut h); + } + } + _ => return None, + } + Some(h.finish()) +} + +/// LUT rows for `adj` at `depth`, built by `build` once per [`row_key`] and reused by later plans +/// (`compose::lut_rows`): the Levels tables alone took 1–2 ms per layer on every plan (#11498). +fn cached_rows(adj: &Adjustment, depth: photocraft_color::SampleType, build: impl FnOnce() -> Vec<[f32; 4096]>) -> Vec<[f32; 4096]> { + match row_key(adj, depth) { + Some(key) => photocraft_compose::lut_rows::cached(key, build), + None => build(), + } +} + /// A CPU LUT (4096 entries) as one texture row. fn to_row(t: &[f32]) -> [f32; 4096] { let mut row = [0.0f32; 4096]; @@ -173,7 +235,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf } // p0.w: continue the curve past 0..1 (32-bit RGB Curves, #7157). p[0][3] = if adjust::float_tone_extrapolates(adj, Some(depth), mode) { 1.0 } else { 0.0 }; - (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) + (7, p, Some(cached_rows(adj, depth, || adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { let sat = adjust::colorize_saturation(saturation / 100.0, lightness / 100.0, *colorize, Some(depth)); @@ -181,7 +243,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf if !*colorize && ranges.iter().any(|r| !r.is_neutral()) { // Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU. p[1][0] = 1.0; - (8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect())) + (8, p, Some(cached_rows(adj, depth, || adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))) } else { (8, p, None) } @@ -234,8 +296,11 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { // Per-channel curves (compose::adjust::BalanceCurve), through the Levels kernel. - let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); - (7, p, Some(lut_rows(|ch, v| curves[ch].eval(v), 3))) + let rows = || { + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + lut_rows(|ch, v| curves[ch].eval(v), 3) + }; + (7, p, Some(cached_rows(adj, depth, rows))) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). diff --git a/crates/gpu/src/plan/planner.rs b/crates/gpu/src/plan/planner.rs index ef0924640a..5b4f96026e 100644 --- a/crates/gpu/src/plan/planner.rs +++ b/crates/gpu/src/plan/planner.rs @@ -408,6 +408,7 @@ impl<'a> Planner<'a> { let clip_base_adjusts = !visible_clipped.is_empty() && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content + && layer.frame.is_none() && layer.blend == BlendMode::PassThrough && layer.fill_opacity >= 1.0 && !has_effects(layer) diff --git a/crates/gpu/src/plan/tests.rs b/crates/gpu/src/plan/tests.rs index 83f286bf2b..00153c2748 100644 --- a/crates/gpu/src/plan/tests.rs +++ b/crates/gpu/src/plan/tests.rs @@ -335,3 +335,130 @@ fn advanced_blending_falls_back_to_the_cpu() { d.layers.push(Layer::group("g", vec![inner])); assert!(plan(&d).is_err(), "knockout inside a group"); } + +/// Adjustments whose LUT rows the planner caches (#11498), with settings away from identity. +fn row_cached_adjustments() -> Vec { + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + let ch = |g: f32| LevelsChannel { in_black: 0.05, in_white: 0.9, gamma: g, out_black: 0.02, out_white: 0.97 }; + let mut ranges = HueRange::defaults(); + ranges[1].hue = 20.0; + let pts = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.4, output: 0.55 }, CurvePoint { input: 1.0, output: 1.0 }]; + vec![ + Adjustment::Levels { master: ch(1.3), per_channel: [ch(0.8), ch(1.0), ch(1.6)], space: ToneSpace::Rgb, black: Default::default() }, + Adjustment::Curves { master: pts.clone(), per_channel: [pts.clone(), vec![], pts], space: ToneSpace::Rgb, black: vec![], pencil: false }, + Adjustment::HueSaturation { hue: 0.0, saturation: 10.0, lightness: 0.0, colorize: false, ranges }, + Adjustment::ColorBalance { shadows: [10.0, 0.0, -5.0], midtones: [0.0, 5.0, 0.0], highlights: [0.0, 0.0, 10.0], preserve_luminosity: true }, + ] +} + +#[test] +fn cached_adjustment_rows_match_freshly_built_tables() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for a in row_cached_adjustments() { + let fresh: Vec> = match &a { + Adjustment::Levels { .. } | Adjustment::Curves { .. } => adjust::tone_luts_depth(&a, Some(depth)).into_iter().take(3).collect(), + Adjustment::HueSaturation { ranges, .. } => adjust::hue_range_tables(ranges).into_iter().collect(), + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + (0..3).map(|ch| (0..4096).map(|k| curves[ch].eval(k as f32 / 4095.0)).collect()).collect() + } + _ => panic!("not a row-cached adjustment"), + }; + // The first plan may fill the cache, the second reads it: both give the fresh rows. + for _ in 0..2 { + let (_, _, rows) = adjustment_program(&a, Transfer::Srgb, depth, ColorMode::Rgb); + let rows = rows.expect("LUT rows"); + if matches!(a, Adjustment::Levels { .. }) && depth == SampleType::F32 { + continue; // The analytic float Levels kernel takes coefficients, not tables. + } + assert_eq!(rows.len(), fresh.len(), "{} at {depth:?}", a.label()); + for (row, f) in rows.iter().zip(&fresh) { + assert!(row.iter().zip(f).all(|(x, y)| x.to_bits() == y.to_bits()), "{} at {depth:?}", a.label()); + } + } + } + } +} + +#[test] +fn row_keys_cover_exactly_what_the_rows_are_built_from() { + use photocraft_doc::adjust::LevelsChannel; + let key = |a: &Adjustment, depth| lower::row_key(a, depth).expect("a row-cached adjustment"); + let [levels, curves, hue_sat, balance] = <[Adjustment; 4]>::try_from(row_cached_adjustments()).expect("four adjustments"); + // The same settings give the same key on every plan, so later plans read the stored rows. + for a in [&levels, &curves, &hue_sat, &balance] { + assert_eq!(key(a, SampleType::U8), key(&a.clone(), SampleType::U8), "{}", a.label()); + } + // Levels and Curves round to the document's quantum, so their keys hold the depth. + assert_ne!(key(&levels, SampleType::U8), key(&levels, SampleType::U16)); + assert_ne!(key(&curves, SampleType::U16), key(&curves, SampleType::F32)); + // Hue/Saturation range tables and Color Balance curves don't depend on depth. + assert_eq!(key(&hue_sat, SampleType::U8), key(&hue_sat, SampleType::F32)); + assert_eq!(key(&balance, SampleType::U8), key(&balance, SampleType::U16)); + // Dragging the master Hue/Saturation sliders leaves the range tables (and their entry) alone. + let Adjustment::HueSaturation { ranges, .. } = &hue_sat else { panic!("Hue/Saturation") }; + let master = Adjustment::HueSaturation { hue: 40.0, saturation: -30.0, lightness: 12.0, colorize: false, ranges: ranges.clone() }; + assert_eq!(key(&hue_sat, SampleType::U8), key(&master, SampleType::U8)); + let mut moved = ranges.clone(); + moved[4].bounds[2] += 1.0; + let moved = Adjustment::HueSaturation { hue: 0.0, saturation: 10.0, lightness: 0.0, colorize: false, ranges: moved }; + assert_ne!(key(&hue_sat, SampleType::U8), key(&moved, SampleType::U8)); + // Values `==` can't tell apart still get their own rows. + let black = |out_black: f32| { + let ch = LevelsChannel { out_black, ..Default::default() }; + Adjustment::Levels { master: ch.clone(), per_channel: [ch.clone(), ch.clone(), ch], space: ToneSpace::Rgb, black: Default::default() } + }; + assert_ne!(key(&black(0.0), SampleType::U8), key(&black(-0.0), SampleType::U8)); + // Every field the builders read changes the key. + let Adjustment::Curves { master, per_channel, black, .. } = &curves else { panic!("Curves") }; + let pencil = Adjustment::Curves { master: master.clone(), per_channel: per_channel.clone(), space: ToneSpace::Rgb, black: black.clone(), pencil: true }; + let lab = Adjustment::Curves { master: master.clone(), per_channel: per_channel.clone(), space: ToneSpace::Lab, black: black.clone(), pencil: false }; + assert_ne!(key(&curves, SampleType::U8), key(&pencil, SampleType::U8)); + assert_ne!(key(&curves, SampleType::U8), key(&lab, SampleType::U8)); + let mut nudged = master.clone(); + nudged[1].output += 0.01; + let point = Adjustment::Curves { master: nudged, per_channel: per_channel.clone(), space: ToneSpace::Rgb, black: black.clone(), pencil: false }; + assert_ne!(key(&curves, SampleType::U8), key(&point, SampleType::U8), "a curve point's value"); + let Adjustment::ColorBalance { shadows, midtones, highlights, .. } = balance.clone() else { panic!("Color Balance") }; + let plain = Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity: false }; + assert_ne!(key(&balance, SampleType::U8), key(&plain, SampleType::U8)); + // Levels and Curves don't share keys with each other, and adjustments without rows have none. + assert_ne!(key(&Adjustment::identity_levels(), SampleType::U8), key(&Adjustment::identity_curves(), SampleType::U8)); + assert_eq!(lower::row_key(&Adjustment::Vibrance { vibrance: 10.0, saturation: 0.0 }, SampleType::U8), None); +} + +#[test] +fn planning_reads_cached_rows_instead_of_rebuilding_them() { + use photocraft_compose::lut_rows; + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + // Settings no other test plans, so the planted rows are this test's own; the few keys this + // test binary adds can't evict them from the shared cache. + let mut ranges = HueRange::defaults(); + ranges[2].hue = 33.0; + let point = |input, output| CurvePoint { input, output }; + let adjustments = [ + Adjustment::Levels { + master: LevelsChannel { gamma: 1.37, ..Default::default() }, + per_channel: Default::default(), + space: ToneSpace::Rgb, + black: Default::default(), + }, + Adjustment::Curves { + master: vec![point(0.0, 0.0), point(0.3, 0.61), point(1.0, 1.0)], + per_channel: Default::default(), + space: ToneSpace::Rgb, + black: vec![], + pencil: false, + }, + Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges }, + Adjustment::ColorBalance { shadows: [17.0, 0.0, 0.0], midtones: [0.0; 3], highlights: [0.0; 3], preserve_luminosity: true }, + ]; + for (i, a) in adjustments.iter().enumerate() { + // Rows no builder makes: the planner returns them only if it reads the cache. + let planted = vec![[0.25 + i as f32 / 8.0; lut_rows::ROW_LEN]; 3]; + let key = lower::row_key(a, SampleType::U8).expect("a row-cached adjustment"); + assert!(lut_rows::cached(key, || planted.clone()) == planted, "{}: a key no other test uses", a.label()); + let (_, _, rows) = adjustment_program(a, Transfer::Srgb, SampleType::U8, ColorMode::Rgb); + assert!(rows.as_ref() == Some(&planted), "{}: the plan rebuilt rows the cache held", a.label()); + } +} diff --git a/crates/gpu/src/readback.rs b/crates/gpu/src/readback.rs index 9abf5f071e..5ff08b6f9f 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -1,6 +1,43 @@ +use std::fmt; + +use photocraft_color::SampleType; +use photocraft_raster::Interrupt; + use super::textures::Stats; use super::{Compositor, Document, Rect, Unsupported}; +/// Errors encountered during GPU readback. +#[derive(Debug, thiserror::Error)] +pub enum ReadbackError { + #[error("unsupported document or feature: {0}")] + Unsupported(String), + #[error("GPU device was lost: {0}")] + DeviceLost(String), + #[error("buffer allocation failed: {0}")] + AllocationFailed(String), + #[error("buffer mapping failed: {0}")] + MappingFailed(String), + #[error("rendering was cancelled")] + Cancelled, + #[error("readback failed: {0}")] + Execution(String), + #[error("sink error: {0}")] + Sink(E), +} + +impl From for ReadbackError { + fn from(e: Unsupported) -> Self { + ReadbackError::Unsupported(e.0) + } +} + +/// Default maximum bytes of staging buffer memory in flight during band readback (32 MiB). +pub const DEFAULT_MAX_STAGING_BYTES: usize = 32 << 20; + +/// How long a readback waits for submitted GPU work before treating the device as lost: a +/// headless export has no user to cancel a wedged GPU, so it must never block forever. +pub const READBACK_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + /// Blocking readback of a composite (native tests and tools). #[cfg(not(target_arch = "wasm32"))] pub fn render_to_vec(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu::Queue, doc: &Document, rect: Rect) -> Result, Unsupported> { @@ -42,7 +79,7 @@ pub fn render_to_vec_stats( for (_, b, _) in &staging { b.slice(..).map_async(wgpu::MapMode::Read, |_| {}); } - match &comp.health { + match comp.health() { Some(h) if !h.wait(device, None) => return Err(Unsupported(h.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()))), Some(_) => {} None => { @@ -68,3 +105,271 @@ pub fn render_to_vec_stats( } Ok((out, stats)) } + +/// Wait for submitted readback work in short slices, checking `ctl` between them and keeping the +/// overall `timeout`. A slice timeout means the work is still running, not that the device failed. +#[cfg(not(target_arch = "wasm32"))] +fn poll_wait_cancellable(device: &wgpu::Device, timeout: std::time::Duration, ctl: &Interrupt<'_>) -> Result<(), ReadbackError> { + const SLICE: std::time::Duration = std::time::Duration::from_millis(100); + let start = std::time::Instant::now(); + loop { + if ctl.cancelled() { + return Err(ReadbackError::Cancelled); + } + let remaining = timeout.saturating_sub(start.elapsed()); + if remaining.is_zero() { + return Err(ReadbackError::DeviceLost(format!("GPU readback did not finish within {} s", timeout.as_secs()))); + } + let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(remaining.min(SLICE)) }) + })); + match poll { + Ok(Ok(status)) if status.wait_finished() => return Ok(()), + Ok(Ok(_)) => {} + Ok(Err(wgpu::PollError::Timeout)) => {} + Ok(Err(e)) => return Err(ReadbackError::DeviceLost(format!("GPU device poll failed: {e}"))), + Err(_) => return Err(ReadbackError::DeviceLost("GPU device lost during poll".into())), + } + } +} + +/// Readback of a composite in bounded horizontal bands. +/// +/// Binds staging buffer memory to at most `max_staging_bytes`, mapping and transferring +/// one band of rows at a time, so arbitrarily large documents can be exported without +/// unbounded staging allocation. `ctl` cancels the wait between bounded poll slices. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::too_many_arguments)] +pub fn render_bands( + comp: &mut Compositor, + device: &wgpu::Device, + queue: &wgpu::Queue, + doc: &Document, + rect: Rect, + band_rows: i32, + max_staging_bytes: usize, + ctl: &Interrupt<'_>, + mut sink: impl FnMut(Rect, Vec<[f32; 4]>) -> Result<(), E>, +) -> Result<(), ReadbackError> +where + E: fmt::Display, +{ + let rect = rect.intersect(&doc.bounds()); + if rect.is_empty() { + return Ok(()); + } + ctl.check().map_err(|_| ReadbackError::Cancelled)?; + // Layer content is uploaded as half-precision textures on every adapter. Values above 65504 + // become infinity, and the rest lose mantissa bits: 16-bit samples near 1.0 already merge + // before compositing, so only 8-bit output can be read back losslessly. Keep U16 and F32 + // exports on the CPU compositor, which composites and writes in 32-bit float. + if matches!(doc.depth, SampleType::U16 | SampleType::F32) { + return Err(ReadbackError::Unsupported("16-bit and 32-bit float documents need the CPU compositor".into())); + } + + let bpp = if comp.acc_format() == wgpu::TextureFormat::Rgba32Float { 16u64 } else { 8u64 }; + let max_staging = if max_staging_bytes == 0 { DEFAULT_MAX_STAGING_BYTES } else { max_staging_bytes }; + if max_staging < 256 { + return Err(ReadbackError::AllocationFailed(format!("staging budget {max_staging} bytes is smaller than device minimum row alignment (256 bytes)"))); + } + + // Account for actual aligned chunks staging across the horizontal span. + let chunk_size = comp.chunk_size() as i32; + if chunk_size <= 0 { + return Err(ReadbackError::AllocationFailed("invalid compositor chunk size".into())); + } + let mut row_staging_bytes: u64 = 0; + let mut x = + rect.x0.div_euclid(chunk_size).checked_mul(chunk_size).ok_or_else(|| ReadbackError::AllocationFailed("coordinate calculation overflow".into()))?; + while x < rect.x1 { + let next_x = x.checked_add(chunk_size).ok_or_else(|| ReadbackError::AllocationFailed("coordinate overflow".into()))?; + let chunk_right = next_x.min(rect.x1); + let chunk_left = x.max(rect.x0); + let chunk_w = chunk_right.checked_sub(chunk_left).ok_or_else(|| ReadbackError::AllocationFailed("chunk width underflow".into()))? as u64; + let chunk_row = (chunk_w.checked_mul(bpp).ok_or_else(|| ReadbackError::AllocationFailed("dimension overflow".into()))?) + .checked_add(255) + .ok_or_else(|| ReadbackError::AllocationFailed("dimension overflow".into()))? + / 256 + * 256; + row_staging_bytes = row_staging_bytes.checked_add(chunk_row).ok_or_else(|| ReadbackError::AllocationFailed("staging bytes overflow".into()))?; + x = next_x; + } + + if row_staging_bytes > max_staging as u64 { + return Err(ReadbackError::Unsupported(format!("staging budget {max_staging} bytes cannot fit a full output row ({row_staging_bytes} bytes)"))); + } + + let max_rows = (max_staging as u64 / row_staging_bytes.max(1)) as u32; + let want_rows = if band_rows > 0 { (band_rows as u32).min(max_rows) } else { max_rows }; + let step_rows = want_rows.min(rect.height()).max(1); + + let mut y = rect.y0; + while y < rect.y1 { + let y_span = (rect.y1.checked_sub(y).ok_or_else(|| ReadbackError::AllocationFailed("coordinate underflow".into()))?) as u32; + let step = step_rows.min(y_span); + let y1 = y.checked_add(step as i32).ok_or_else(|| ReadbackError::AllocationFailed("coordinate overflow".into()))?; + let band_rect = Rect::new(rect.x0, y, rect.x1, y1); + + if let Some(fault) = comp.fault() { + return Err(ReadbackError::DeviceLost(fault.to_string())); + } + + // A sink can cancel after its band is delivered: stop before staging or submitting + // another band, instead of finding out only after the next device wait. + ctl.check().map_err(|_| ReadbackError::Cancelled)?; + + let mut staging: Vec<(Rect, wgpu::Buffer, u32)> = Vec::new(); + let mut accumulated_staging: u64 = 0; + let mut alloc_err: Option> = None; + + comp.render(device, queue, doc, band_rect, |_enc, out| { + if alloc_err.is_some() { + return; + } + let chunk_bpp = if out.texture.format() == wgpu::TextureFormat::Rgba32Float { 16u64 } else { 8u64 }; + let chunk_w = out.rect.width() as u64; + let chunk_h = out.rect.height() as u64; + let chunk_row = match (chunk_w.checked_mul(chunk_bpp)).and_then(|v| v.checked_add(255)) { + Some(v) => (v / 256 * 256) as u32, + None => { + alloc_err = Some(ReadbackError::AllocationFailed("row byte calculation overflow".into())); + return; + } + }; + let size = match (chunk_row as u64).checked_mul(chunk_h) { + Some(s) => s, + None => { + alloc_err = Some(ReadbackError::AllocationFailed("staging buffer size overflow".into())); + return; + } + }; + + accumulated_staging = match accumulated_staging.checked_add(size) { + Some(acc) if acc <= max_staging as u64 => acc, + _ => { + alloc_err = Some(ReadbackError::AllocationFailed(format!( + "accumulated staging bytes ({accumulated_staging} + {size}) exceeds strict cap {max_staging}" + ))); + return; + } + }; + + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_bounded_readback"), + size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + _enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: out.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(chunk_row), rows_per_image: Some(out.rect.height()) }, + }, + wgpu::Extent3d { width: out.rect.width(), height: out.rect.height(), depth_or_array_layers: 1 }, + ); + staging.push((out.rect, buf, chunk_row)); + })?; + + if let Some(err) = alloc_err { + return Err(err); + } + + for (_, b, _) in &staging { + b.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + } + + match comp.health() { + Some(h) => match h.wait_timeout_cancellable(device, None, READBACK_WAIT_TIMEOUT, &|| ctl.cancelled()) { + super::health::WaitOutcome::Finished => {} + super::health::WaitOutcome::Cancelled => return Err(ReadbackError::Cancelled), + super::health::WaitOutcome::TimedOut => { + let message = + h.fault().map_or_else(|| format!("GPU readback did not finish within {} s", READBACK_WAIT_TIMEOUT.as_secs()), |f| f.to_string()); + return Err(ReadbackError::DeviceLost(message)); + } + }, + None => { + // No health monitor: poll in the same short slices so cancellation is not + // blocked until the whole readback deadline elapses. + poll_wait_cancellable(device, READBACK_WAIT_TIMEOUT, ctl)?; + } + } + + if let Some(fault) = comp.fault() { + return Err(ReadbackError::DeviceLost(fault.to_string())); + } + + let band_w = band_rect.width() as usize; + let band_h = band_rect.height() as usize; + let total_px = band_w.checked_mul(band_h).ok_or_else(|| ReadbackError::AllocationFailed("band pixel count overflow".into()))?; + let mut band_pixels: Vec<[f32; 4]> = Vec::new(); + band_pixels.try_reserve_exact(total_px).map_err(|e| ReadbackError::AllocationFailed(format!("failed to allocate band pixels: {e}")))?; + band_pixels.resize(total_px, [0.0f32; 4]); + + for (r, b, chunk_row) in &staging { + let data = b.slice(..).get_mapped_range().map_err(|e| ReadbackError::MappingFailed(format!("GPU readback map failed: {e:?}")))?; + let chunk_bpp = bpp as usize; + for cy in 0..r.height() as usize { + for cx in 0..r.width() as usize { + let offset = cy + .checked_mul(*chunk_row as usize) + .and_then(|o| o.checked_add(cx.checked_mul(chunk_bpp)?)) + .ok_or_else(|| ReadbackError::Execution("offset overflow".into()))?; + let slice = data.get(offset..offset + chunk_bpp).ok_or_else(|| ReadbackError::Execution("mapped range out of bounds".into()))?; + let px: [f32; 4] = if chunk_bpp == 16 { + [ + f32::from_le_bytes(slice.get(0..4).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?), + f32::from_le_bytes(slice.get(4..8).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?), + f32::from_le_bytes( + slice.get(8..12).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?, + ), + f32::from_le_bytes( + slice.get(12..16).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?, + ), + ] + } else { + [ + half::f16::from_le_bytes( + slice.get(0..2).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?, + ) + .to_f32(), + half::f16::from_le_bytes( + slice.get(2..4).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?, + ) + .to_f32(), + half::f16::from_le_bytes( + slice.get(4..6).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?, + ) + .to_f32(), + half::f16::from_le_bytes( + slice.get(6..8).and_then(|s| s.try_into().ok()).ok_or_else(|| ReadbackError::Execution("slice bounds".into()))?, + ) + .to_f32(), + ] + }; + let dx = (r.x0.checked_sub(band_rect.x0).ok_or_else(|| ReadbackError::Execution("coord underflow".into()))? as usize) + .checked_add(cx) + .ok_or_else(|| ReadbackError::Execution("coord overflow".into()))?; + let dy = (r.y0.checked_sub(band_rect.y0).ok_or_else(|| ReadbackError::Execution("coord underflow".into()))? as usize) + .checked_add(cy) + .ok_or_else(|| ReadbackError::Execution("coord overflow".into()))?; + let dst_idx = + dy.checked_mul(band_w).and_then(|i| i.checked_add(dx)).ok_or_else(|| ReadbackError::Execution("dst index overflow".into()))?; + if let Some(target) = band_pixels.get_mut(dst_idx) { + *target = px; + } else { + return Err(ReadbackError::Execution("destination pixel out of bounds".into())); + } + } + } + drop(data); + b.unmap(); + } + drop(staging); + + sink(band_rect, band_pixels).map_err(ReadbackError::Sink)?; + y = y1; + } + Ok(()) +} diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index 15217ed236..e84b2571e4 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -1,12 +1,46 @@ use super::batch::tile_grid; -use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; -use super::kit::{Kit, SHADER, tex_entry, uniform_entry}; +use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; +use super::kit::{Kit, SHADER, build_jobs, tex_entry, uniform_entry, vertex_shader}; use super::paging::{cell_rect, field_reaches, first_error, grid_rects}; use super::textures::{FxEntry, Resident, Tex, TexKind}; use super::{ Compositor, DeviceHealth, DocId, Document, Fault, HashMap, Kernel, LayerContent, Plan, Rect, Role, TILE_SIZE, Unsupported, fx, plan, prefix, timing, }; +/// Classification of a synchronous compositor pipeline build failure. +/// +/// Only a recognized shader compiler diagnostic is considered deterministic. Every other +/// error, including generic validation, internal, and out-of-memory errors, is conservative +/// and must be treated as transient or unknown by callers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildFailureKind { + /// A known compiler diagnostic means the same shader is expected to fail on relaunch. + DeterministicShaderCompilation, + /// The failure may depend on device state, resource pressure, or driver behavior. + TransientOrUnknown, +} + +/// A pipeline build error with a conservative retry classification. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildFailure { + pub kind: BuildFailureKind, + pub message: String, +} + +impl std::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for BuildFailure {} + +fn classify_build_diagnostic(diagnostic: &str) -> BuildFailureKind { + // FXC's X3507 diagnostic is a concrete shader compilation failure observed when + // compiling generated compositor shaders. Do not classify generic validation text. + if diagnostic.contains("X3507") { BuildFailureKind::DeterministicShaderCompilation } else { BuildFailureKind::TransientOrUnknown } +} + impl Compositor { /// Create a compositor with the default accumulation format ([`ACC_FORMAT`]). pub fn new(device: &wgpu::Device) -> Self { @@ -26,17 +60,53 @@ impl Compositor { /// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed /// to have worked. pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result { - match first_error(device, || Self::build(device, acc_format)) { + match first_error(device, || Self::build(device, acc_format, false)) { ((comp, None), None) => Ok(comp), ((_, Some(e)), _) | (_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))), } } + /// Build pipelines using a dedicated Rayon pool instead of the process-wide pool. + /// + /// This keeps shader compilation from occupying workers needed by CPU compositing. + /// If the pool cannot be created, pipeline enumeration runs sequentially on the calling + /// thread. The outer and per-pipeline wgpu error scopes are kept on their respective + /// build threads. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_new_with_format_threads(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result { + Self::try_new_with_format_threads_classified(device, acc_format, threads).map_err(|e| Unsupported(e.message)) + } + + /// Build the compositor pipelines with an explicit failure classification for callers + /// that cache deterministic failures across launches. Only recognized shader compiler + /// diagnostics are marked deterministic; all other wgpu errors are transient or unknown. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_new_with_format_threads_classified(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads.max(1)).thread_name(|index| format!("photocraft-gpu-build-{index}")).build(); + let built = match pool { + Ok(pool) => pool.install(|| first_error(device, || Self::build(device, acc_format, false))), + Err(e) => { + log::warn!("GPU compositor: couldn't create dedicated build pool; building sequentially: {e}"); + first_error(device, || Self::build(device, acc_format, true)) + } + }; + match built { + ((comp, None), None) => Ok(comp), + ((_, Some(e)), None) => { + let kind = classify_build_diagnostic(&e.to_string()); + Err(BuildFailure { kind, message: format!("couldn't build the compositor's pipelines: {e}") }) + } + (_, Some(e)) => { + Err(BuildFailure { kind: BuildFailureKind::TransientOrUnknown, message: format!("couldn't build the compositor's pipelines: {e}") }) + } + } + } + /// Create a compositor whose accumulation/render-target format is `acc_format`. Pass /// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers), /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self { - let (comp, error) = Self::build(device, acc_format); + let (comp, error) = Self::build(device, acc_format, false); if let Some(e) = error { log::error!("GPU compositor: couldn't build its pipelines: {e}"); } @@ -47,8 +117,22 @@ impl Compositor { /// built in parallel, each inside its own error scope on the thread that builds it (wgpu's /// scopes are per thread): on DX12, FXC compiles one shader at a time and the ~30 pipelines /// took about 8 s on one core (#6419). - fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> (Self, Option) { + /// + /// No shader is used by two threads at once (#9173): wgpu-hal's D3D12 backend caches each + /// compiled shader and hands every pipeline a reference to it, but FXC's blobs count + /// references without atomics (gfx-rs/wgpu#10547), so threads sharing one could free it + /// while the cache still held it, and dropping the device then aborted the process. So each + /// job has its own vertex entry point and fragment shaders ([`build_jobs`]), and builds of + /// several compositors take turns. + fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat, sequential: bool) -> (Self, Option) { + static BUILDING: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _building = BUILDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let jobs = build_jobs(acc_format); + let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pc_compose_vertex"), + source: wgpu::ShaderSource::Wgsl(vertex_shader(&jobs).into()), + }); let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_uniforms"), entries: &[uniform_entry(0, CHUNK_UNIFORM), uniform_entry(1, OP_UNIFORM)], @@ -63,12 +147,12 @@ impl Compositor { immediate_size: 0, }); // `None` only for kernels without a fragment entry point, which are never drawn. - let pipeline = |k: Kernel, format: wgpu::TextureFormat| { + let pipeline = |k: Kernel, format: wgpu::TextureFormat, vs: &str| { let entry = k.entry()?; Some(device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(entry), layout: Some(&layout), - vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + vertex: wgpu::VertexState { module: &vertex, entry_point: Some(vs), buffers: &[], compilation_options: Default::default() }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), @@ -84,12 +168,17 @@ impl Compositor { }; // Effect maps render to R32Float / R16Float, which some adapters (e.g. GL without float // render targets) can't: then layer effects use the CPU compositor and the rest stays here. - let keys = Kernel::DRAWN.iter().filter(|k| !k.is_map()).map(|&k| (k, acc_format)); - let keys: Vec<_> = keys.chain(Kernel::DRAWN.iter().filter(|k| k.is_map()).flat_map(|&k| [(k, MAP32), (k, MAP16)])).collect(); - let built = fx::par_map(keys, |key| { - let (built, error) = first_error(device, || pipeline(key.0, key.1)); - (key, built, error) - }); + let build_one = |job: super::kit::BuildJob| { + job.keys + .into_iter() + .map(|key| { + let (built, error) = first_error(device, || pipeline(key.0, key.1, &job.vertex)); + (key, built, error) + }) + .collect::>() + }; + let built: Vec<_> = if sequential { jobs.into_iter().map(build_one).collect() } else { fx::par_map(jobs, build_one) }; + let built = built.into_iter().flatten(); let (mut pipelines, mut maps) = (HashMap::new(), Vec::new()); let (mut error, mut map_error) = (None, None); for (key, built, e) in built { @@ -199,6 +288,11 @@ impl Compositor { self.page } + /// The chunk side cells are tiled in. + pub fn chunk_size(&self) -> u32 { + self.chunk + } + /// Test hook: the next `n` layer page textures fail as if the device were out of memory /// (a real one can't be forced), so the fallback paths of #6563 can be tested. #[doc(hidden)] @@ -472,6 +566,24 @@ impl Compositor { self.health.as_ref().and_then(DeviceHealth::fault) } + /// The accumulation texture format used by this compositor. + pub fn acc_format(&self) -> wgpu::TextureFormat { + self.acc_format + } + + /// The device health monitor if one was attached with [`Compositor::set_health`]. + pub fn health(&self) -> Option<&DeviceHealth> { + self.health.as_ref() + } + + /// Clear all resident layer pages, effect maps, pattern textures and lower-stack composites. + pub fn reset_cache(&mut self) { + self.residents.clear(); + self.fx.clear(); + self.patterns.clear(); + self.prefix.clear(); + } + /// Measure how long the GPU spends executing each [`Compositor::render`], with timestamp /// queries read back asynchronously (nothing waits for the GPU). Returns whether the device /// supports it: without [`timing::FEATURES`] there is no measurement at all, and callers @@ -507,3 +619,16 @@ impl Compositor { self.timer.as_mut().and_then(timing::GpuTimer::take) } } + +#[cfg(test)] +mod build_failure_tests { + use super::{BuildFailureKind, classify_build_diagnostic}; + + #[test] + fn only_concrete_shader_compiler_diagnostic_is_deterministic() { + assert_eq!(classify_build_diagnostic("error X3507: loop control variable must be integer"), BuildFailureKind::DeterministicShaderCompilation); + assert_eq!(classify_build_diagnostic("Validation Error: invalid bind group"), BuildFailureKind::TransientOrUnknown); + assert_eq!(classify_build_diagnostic("out of memory"), BuildFailureKind::TransientOrUnknown); + assert_eq!(classify_build_diagnostic("shader compilation failed"), BuildFailureKind::TransientOrUnknown); + } +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 397dcb0124..adca9ef4ef 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -132,6 +132,44 @@ fn half_roundtrip() { } } +/// #9173: no two pipeline build jobs share a shader, so no FXC blob is shared across threads. +#[test] +fn build_jobs_share_no_shader() { + use super::consts::{MAP16, MAP32}; + use super::kit::{build_jobs, vertex_shader}; + let jobs = build_jobs(wgpu::TextureFormat::Rgba32Float); + let mut seen = std::collections::HashSet::new(); + for job in &jobs { + let fragments: std::collections::HashSet<_> = job.keys.iter().filter_map(|k| k.0.entry()).collect(); + for f in fragments { + assert!(seen.insert(f.to_string()), "fragment shader {f} is built by two jobs"); + } + assert!(seen.insert(job.vertex.clone()), "vertex entry {} is shared", job.vertex); + } + // Every drawn pipeline is still built, in the same formats. + let keys: Vec<_> = jobs.iter().flat_map(|job| job.keys.iter().copied()).collect(); + let plain = Kernel::DRAWN.iter().filter(|k| !k.is_map()).count(); + assert_eq!(keys.len(), plain + 2 * (Kernel::DRAWN.len() - plain)); + for k in Kernel::DRAWN { + let formats: Vec<_> = keys.iter().filter(|key| key.0 == k).map(|key| key.1).collect(); + let want = if k.is_map() { vec![MAP32, MAP16] } else { vec![wgpu::TextureFormat::Rgba32Float] }; + assert_eq!(formats, want, "{k:?}"); + } + // The vertex module validates and has every job's entry point. + let src = vertex_shader(&jobs); + let module = wgpu::naga::front::wgsl::parse_str(&src).unwrap_or_else(|e| panic!("{}", e.emit_to_string(&src))); + wgpu::naga::valid::Validator::new(wgpu::naga::valid::ValidationFlags::all(), wgpu::naga::valid::Capabilities::empty()) + .validate(&module) + .unwrap_or_else(|e| panic!("{e:?}")); + for job in &jobs { + assert!(module.entry_points.iter().any(|ep| ep.name == job.vertex && ep.stage == wgpu::naga::ShaderStage::Vertex), "missing {}", job.vertex); + } + // The fragment module has no vertex stage, so the build can't fall back to one shared `vs`. + let compose = wgpu::naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); + let shared: Vec<_> = compose.entry_points.iter().filter(|ep| ep.stage == wgpu::naga::ShaderStage::Vertex).map(|ep| ep.name.as_str()).collect(); + assert!(shared.is_empty(), "compose.wgsl has a vertex entry point every job would share: {shared:?}"); +} + #[test] fn shader_validates() { let module = wgpu::naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); @@ -388,3 +426,9 @@ fn colour_managed_tiles_match_the_per_pixel_conversion() { fn grid_rounds_out() { assert_eq!(tile_grid(Rect::new(0, 0, 300, 256)), Rect::new(0, 0, 512, 256)); } + +#[test] +fn shader_fingerprint_is_fnv1a_of_embedded_source() { + assert_eq!(super::fnv1a(b"hello"), 0xa430_d846_80aa_bd0b); + assert_eq!(super::shader_fingerprint(), super::fnv1a(super::SHADER.as_bytes())); +} diff --git a/crates/gpu/tests/it/bounded_readback.rs b/crates/gpu/tests/it/bounded_readback.rs new file mode 100644 index 0000000000..c723a68c3f --- /dev/null +++ b/crates/gpu/tests/it/bounded_readback.rs @@ -0,0 +1,315 @@ +//! Tests for bounded, checked band readback across chunks, pages, and device failure modes. + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Effect, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_gpu::context::{NativeContext, NativeContextOptions}; +use photocraft_gpu::readback::{DEFAULT_MAX_STAGING_BYTES, ReadbackError, render_bands, render_to_vec}; +use photocraft_gpu::{Compositor, DeviceHealth}; +use photocraft_raster::Surface; + +use crate::GPU_LOCK; + +fn test_gpu() -> Option<(NativeContext, Compositor, std::sync::MutexGuard<'static, ()>)> { + let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let ctx = match NativeContext::request(&NativeContextOptions::default()) { + Ok(c) => c, + Err(e) => { + eprintln!("skipping bounded readback test: {e}"); + return None; + } + }; + let comp = match ctx.create_compositor() { + Ok(c) => c, + Err(e) => { + eprintln!("skipping bounded readback test: {e}"); + return None; + } + }; + Some((ctx, comp, lock)) +} + +fn sample_doc(w: u32, h: u32) -> Document { + let mut doc = Document::with_background("sample", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(Rect::new(10, 10, w as i32 - 10, h as i32 - 10), &[0.2, 0.6, 0.8, 0.7]); + let mut top = Layer::new("top", LayerContent::Raster(s)); + top.opacity = 0.85; + top.blend = BlendMode::Multiply; + doc.layers.push(top); + doc +} + +#[test] +fn bounded_readback_matches_independent_cpu_oracle() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(200, 300); + let rect = doc.bounds(); + + // Independent reference CPU compositor oracle + let mut cpu_pixels = Vec::new(); + photocraft_compose::render_bands(&doc, rect, 0, |buf| { + cpu_pixels.extend_from_slice(&buf.px); + Ok::<(), std::convert::Infallible>(()) + }) + .expect("cpu compose"); + + // GPU bounded readback in bands of 16 rows + let mut gpu_banded = Vec::new(); + let mut total_rows = 0; + render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, rect, 16, 32 * 1024, &photocraft_raster::Interrupt::NONE, |band_rect, px| { + total_rows += band_rect.height(); + gpu_banded.extend_from_slice(&px); + Ok::<(), std::convert::Infallible>(()) + }) + .expect("banded readback"); + + assert_eq!(total_rows, rect.height()); + assert_eq!(cpu_pixels.len(), gpu_banded.len()); + for (i, (c, g)) in cpu_pixels.iter().zip(&gpu_banded).enumerate() { + for ch in 0..4 { + assert!((c[ch] - g[ch]).abs() <= 1.0 / 255.0, "pixel {i} ch {ch} differs from CPU oracle: cpu={}, gpu={}", c[ch], g[ch]); + } + } +} + +#[test] +fn bounded_readback_strictly_respects_staging_budget() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(256, 256); + let rect = doc.bounds(); + let budget = 16 * 1024; // 16 KiB strict cap + + let bpp = if comp.acc_format() == wgpu::TextureFormat::Rgba32Float { 16u64 } else { 8u64 }; + let chunk_size = comp.chunk_size() as i32; + + let mut row_staging_bytes: u64 = 0; + let mut x = rect.x0.div_euclid(chunk_size) * chunk_size; + while x < rect.x1 { + let x_end = (x + chunk_size).min(rect.x1); + let chunk_w = (x_end - x.max(rect.x0)) as u64; + let chunk_row = (chunk_w * bpp).div_ceil(256) * 256; + row_staging_bytes += chunk_row; + x += chunk_size; + } + + render_bands( + &mut comp, + ctx.device(), + ctx.queue(), + &doc, + rect, + 0, // let provider calculate bounded rows + budget, + &photocraft_raster::Interrupt::NONE, + |band_rect, px| { + let actual_staging = band_rect.height() as u64 * row_staging_bytes; + assert!(actual_staging <= budget as u64, "band staging {} exceeds budget {}", actual_staging, budget); + assert_eq!(px.len(), (band_rect.width() * band_rect.height()) as usize); + Ok::<(), std::convert::Infallible>(()) + }, + ) + .expect("bounded readback budget respected"); +} + +#[test] +fn hostile_dimensions_rejects_without_huge_allocation() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + + // 1. Budget smaller than device 256-byte alignment + let doc = sample_doc(64, 64); + let res = render_bands( + &mut comp, + ctx.device(), + ctx.queue(), + &doc, + doc.bounds(), + 0, + 128, // less than 256 + &photocraft_raster::Interrupt::NONE, + |_r, _px| Ok::<(), String>(()), + ); + assert!(matches!(res, Err(ReadbackError::AllocationFailed(_)))); + + // 2. Wide document where 1 full row cannot fit in staging budget + let wide_doc = sample_doc(10_000, 10); + let small_budget = 4096; // 4 KiB cannot fit 1 row of 10,000 pixels (>= 80 KiB) + let res = + render_bands(&mut comp, ctx.device(), ctx.queue(), &wide_doc, wide_doc.bounds(), 0, small_budget, &photocraft_raster::Interrupt::NONE, |_r, _px| { + Ok::<(), String>(()) + }); + assert!(matches!(res, Err(ReadbackError::Unsupported(_))), "must safely fall back when 1 row cannot fit in budget: {res:?}"); + + // 3. Multi-chunk wide document spanning multiple chunk boundaries works via banding + let multi_chunk_doc = sample_doc(2500, 64); + let mut multi_chunk_px = Vec::new(); + let res = render_bands( + &mut comp, + ctx.device(), + ctx.queue(), + &multi_chunk_doc, + multi_chunk_doc.bounds(), + 16, + DEFAULT_MAX_STAGING_BYTES, + &photocraft_raster::Interrupt::NONE, + |_r, px| { + multi_chunk_px.extend_from_slice(&px); + Ok::<(), String>(()) + }, + ); + assert!(res.is_ok(), "multi-chunk canvas must succeed via banding: {res:?}"); + assert_eq!(multi_chunk_px.len(), 2500 * 64); +} + +#[test] +fn sink_cancellation_and_error_preservation() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(128, 128); + + let mut band_count = 0; + let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_r, _px| { + band_count += 1; + Err("abort_sink_now") + }); + + assert_eq!(band_count, 1, "sink failure must immediately stop further band rendering"); + match res { + Err(ReadbackError::Sink(e)) => assert_eq!(e, "abort_sink_now"), + other => panic!("expected ReadbackError::Sink, got {other:?}"), + } +} + +#[test] +fn readback_honors_cancellation_before_waiting() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(128, 128); + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + let mut called = false; + let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &cancelled, |_rect, _px| { + called = true; + Ok::<(), std::convert::Infallible>(()) + }); + assert!(matches!(res, Err(ReadbackError::Cancelled)), "a cancelled readback must stop before the device wait: {res:?}"); + assert!(!called, "no band may be delivered after cancellation"); +} + +#[test] +fn readback_honors_cancellation_between_bands() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(128, 128); + let cancel = std::sync::atomic::AtomicBool::new(false); + let is_cancelled = || cancel.load(std::sync::atomic::Ordering::SeqCst); + let cancelled = photocraft_raster::Interrupt::cancel_only(&is_cancelled); + let mut calls = 0usize; + let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &cancelled, |_rect, _px| { + calls += 1; + cancel.store(true, std::sync::atomic::Ordering::SeqCst); + Ok::<(), std::convert::Infallible>(()) + }); + assert!(matches!(res, Err(ReadbackError::Cancelled)), "cancellation set after the first band must stop the readback: {res:?}"); + assert_eq!(calls, 1, "only the first band may be delivered"); +} + +#[test] +fn bounded_readback_preserves_effect_halos_across_seams() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let (w, h) = (256, 256); + let mut doc = Document::with_background("fx_doc", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE); + + // Layer positioned right across row 128 (the middle of the canvas) + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(Rect::new(50, 110, 200, 145), &[0.9, 0.1, 0.1, 1.0]); + let mut layer = Layer::new("shadow_layer", LayerContent::Raster(s)); + + // Drop shadow with blur radius 16 extending downward and upward + let Effect::DropShadow(mut shadow) = Effect::default_drop_shadow() else { unreachable!() }; + shadow.distance = 8.0; + shadow.size = 16.0; + shadow.common.opacity = 0.8; + layer.effects.items.push(Effect::DropShadow(shadow)); + doc.layers.push(layer); + + let rect = doc.bounds(); + if let Err(reason) = comp.supports(&doc) { + assert!(reason.0.contains("float effect-map render targets"), "unexpected unsupported feature: {reason:?}"); + let result = render_bands( + &mut comp, + ctx.device(), + ctx.queue(), + &doc, + rect, + 32, + 32 * 1024, + &photocraft_raster::Interrupt::NONE, + |_, _| -> Result<(), std::convert::Infallible> { + panic!("unsupported layer effects must not emit pixels"); + }, + ); + assert!(matches!(result, Err(ReadbackError::Unsupported(_)))); + eprintln!("SKIPPED effect seam parity: {reason:?}; verified unsupported-feature rejection instead"); + return; + } + let full = render_to_vec(&mut comp, ctx.device(), ctx.queue(), &doc, rect).expect("full readback"); + + // Render with bands of 32 rows so the shadow seamlessly crosses multiple band boundaries + let mut banded = Vec::new(); + render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, rect, 32, 32 * 1024, &photocraft_raster::Interrupt::NONE, |_band_rect, px| { + banded.extend_from_slice(&px); + Ok::<(), std::convert::Infallible>(()) + }) + .expect("banded readback with effects"); + + assert_eq!(full.len(), banded.len()); + for (i, (f, b)) in full.iter().zip(&banded).enumerate() { + for c in 0..4 { + assert!((f[c] - b[c]).abs() < 2.0 / 255.0, "halo pixel {i} channel {c} differs: full={}, banded={}", f[c], b[c]); + } + } +} + +#[test] +fn high_depth_documents_are_rejected_before_readback() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + // Half-precision uploads cannot preserve 16-bit samples, so U16 joins F32 on the CPU path. + for depth in [SampleType::U16, SampleType::F32] { + let doc = Document::with_background("high_depth", Size::new(64, 64), ColorMode::Rgb, depth, Color::WHITE); + let mut called = false; + let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_rect, _px| { + called = true; + Ok::<(), std::convert::Infallible>(()) + }); + assert!(matches!(res, Err(ReadbackError::Unsupported(_))), "{depth:?} must be rejected before readback: {res:?}"); + assert!(!called, "no band may be read for a {depth:?} document"); + } +} + +#[test] +fn bounded_readback_handles_empty_rect() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(100, 100); + let mut called = false; + let res = + render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, Rect::new(0, 0, 0, 0), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_rect, _px| { + called = true; + Ok::<(), std::convert::Infallible>(()) + }); + assert!(res.is_ok()); + assert!(!called, "sink should not be called for empty rect"); +} + +#[test] +fn bounded_readback_notices_device_fault() { + let Some((ctx, mut comp, _lock)) = test_gpu() else { return }; + let doc = sample_doc(100, 100); + + // Simulate an explicit device fault + let health = DeviceHealth::new(); + health.mark(photocraft_gpu::Fault::Lost("simulated driver crash".into())); + comp.set_health(health); + + let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_rect, _px| { + Ok::<(), std::convert::Infallible>(()) + }); + + assert!(matches!(res, Err(ReadbackError::DeviceLost(_)))); +} diff --git a/crates/gpu/tests/it/main.rs b/crates/gpu/tests/it/main.rs index 027db9d995..a380cd2fb5 100644 --- a/crates/gpu/tests/it/main.rs +++ b/crates/gpu/tests/it/main.rs @@ -4,6 +4,7 @@ /// creates a device takes this lock and holds it until the device is dropped. static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +mod bounded_readback; mod camera_raw; mod device_loss; mod parity; diff --git a/crates/gpu/tests/it/pipeline_errors.rs b/crates/gpu/tests/it/pipeline_errors.rs index 28d7a56791..fc1cd43828 100644 --- a/crates/gpu/tests/it/pipeline_errors.rs +++ b/crates/gpu/tests/it/pipeline_errors.rs @@ -48,3 +48,55 @@ fn a_compositor_with_failed_pipelines_draws_without_panicking() { // Drawing into an unrenderable format can't succeed, but must not panic. let _ = render_to_vec(&mut comp, &device, &queue, &doc, doc.bounds()); } + +/// #9173: the compositor builds its pipelines on several threads (#6419). On Direct3D 12 with FXC, +/// wgpu-hal caches each compiled shader as an `ID3DBlob` whose reference count isn't atomic +/// (gfx-rs/wgpu#10547), so two threads building pipelines from the same shader could free a blob +/// the cache still holds; dropping the device then aborted the process (`null reference +/// produced` in `IUnknown::drop`). Each thread now builds from shaders no other thread uses. +/// +/// Stress test, ignored by default (it builds many compositors). Run it on WARP with FXC, e.g. +/// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc WGPU_ADAPTER_NAME="basic render" +/// PC_BUILD_ROUNDS=10 PC_BUILDS_PER_DEVICE=50 +/// cargo test -p photocraft-gpu --test it parallel_compositor_builds -- --ignored`. +/// Windows' full page heap makes the race abort more reliably. +#[test] +#[ignore = "stress test: builds many compositors"] +fn parallel_compositor_builds_leave_the_shader_cache_intact() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let var = |name: &str, default: u32| std::env::var(name).ok().and_then(|v| v.parse::().ok()).filter(|&n| n > 0).unwrap_or(default); + let (rounds, builds) = (var("PC_BUILD_ROUNDS", 4), var("PC_BUILDS_PER_DEVICE", 8)); + for round in 0..rounds { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let Ok(adapter) = pollster::block_on(wgpu::util::initialize_adapter_from_env_or_default(&instance, None)) else { + eprintln!("skipping: no adapter"); + return; + }; + if round == 0 { + let info = adapter.get_info(); + eprintln!("shader cache stress: {:?} ({}), {rounds} devices x {builds} builds", info.backend, info.name); + } + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { + eprintln!("skipping: no device"); + return; + }; + let format = Compositor::preferred_acc_format(&adapter); + // The first build compiles every shader; the rest are cache hits, so their threads take + // and release references to the cached shaders densely. The builds start together, so + // overlapping `Compositor::build` calls are exercised too (the process-wide lock). + std::thread::scope(|scope| { + let handles: Vec<_> = (0..builds) + .map(|_| { + scope.spawn(|| { + let built = Compositor::try_new_with_format(&device, format); + assert!(built.is_ok(), "round {round}: {:?}", built.err()); + }) + }) + .collect(); + for handle in handles { + handle.join().expect("compositor build thread panicked"); + } + }); + // Dropping the device releases every cached blob: a freed one aborts here. + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 1c6a7324d6..dcd86482db 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -35,6 +35,7 @@ photocraft-vector = { workspace = true } photocraft-format = { path = "../format" } craft-docpath = { workspace = true } thiserror = { workspace = true } +ttf-parser = "0.25.1" serde_json = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } # svg.rs inflates `.svgz` itself (with a size cap) so the XML can be checked before it is parsed; @@ -48,11 +49,14 @@ imagesize = "0.13" # already in the tree through the UI's icon loader; text shaping needs the fonts feature set. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } +# The SVG fallback font search keeps each font file's character map (already in the tree through usvg). +ttf-parser = { version = "0.25", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } [dev-dependencies] +lopdf = { version = "0.38", default-features = false } miniz_oxide = "0.9" crc32fast = "1.5" photocraft-affinity = { workspace = true, features = ["synth"] } diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 27b6c04477..9c41899170 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -13,13 +13,14 @@ //! (`pixelLength`, `logicalLength`, `logicalUnits`) follow Photoshop's scripting names for the //! measurement scale; no sample file was available, so parsing is lenient and the raw resource //! is kept byte-exact while the scale is unchanged. -//! * Count information: image resource 1080 is kept verbatim (its layout is not public) unless -//! the document has its own count markers, which only `.pcraft` stores. +//! * Count information: image resource 1080 (a version-16 `Cnt ` descriptor) maps to the +//! document's ordered Count groups. Unreadable resources are kept while there are no groups. use std::sync::Arc; -use photocraft_doc::{Color, Document, MeasurementScale, Note}; -use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; +use photocraft_doc::{Color, CountGroup, Document, MeasurementScale, Note}; +use photocraft_psd::ImageResource; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; pub const MEASUREMENT_SCALE: u16 = 1074; pub const COUNT_INFO: u16 = 1080; @@ -341,17 +342,118 @@ pub fn fresh_scale_resource(doc: &Document) -> Option> { (!doc.measurement.scale.is_default()).then(|| write_scale_resource(&doc.measurement.scale)) } +/// Decode the measured resource as a whole so unknown or damaged groups stay opaque. +fn count_groups_from_resource(data: &[u8]) -> Option> { + let d = VersionedDescriptor::from_bytes(data).ok()?.descriptor; + if !d.class_id.is("Cnt ") || d.items.len() != 2 || d.get("Vrsn") != Some(&Value::Integer(1)) { + return None; + } + let Value::List(groups) = d.get("countGroupList")? else { return None }; + groups.iter().map(count_group_from_descriptor).collect() +} + +/// Decode the fields the Count tool can use without coercing unrecognized data. +fn count_group_from_descriptor(value: &Value) -> Option { + let Value::Descriptor(d) = value else { return None }; + if !d.class_id.is("countGroup") || d.items.len() != 8 { + return None; + } + let integer = |key| match d.get(key)? { + Value::Integer(n) => u32::try_from(*n).ok(), + _ => None, + }; + let channel = |key| u8::try_from(integer(key)?).ok().map(|n| f32::from(n) / 255.0); + let color = Color::rgb(channel("Rd ")?, channel("Grn ")?, channel("Bl ")?); + let Value::Text(name) = d.get("Nm ")? else { return None }; + let Value::Boolean(visible) = d.get("Vsbl")? else { return None }; + let Value::List(objects) = d.get("countObjectList")? else { return None }; + let marker_size = integer("Rds ")?; + let label_size = integer("fontSize")?; + // Keep out-of-range sizes opaque instead of passing them to the marker and text renderers. + if !(1..=10).contains(&marker_size) || !(8..=72).contains(&label_size) { + return None; + } + let name = String::from_utf16(name.0.strip_suffix(&[0]).unwrap_or(&name.0)).ok()?; + let points = objects.iter().map(count_point_from_descriptor).collect::>()?; + Some(CountGroup { name, color, marker_size, label_size, visible: *visible, points }) +} + +/// Keep document coordinates as doubles and reject nonfinite values. +fn count_point_from_descriptor(value: &Value) -> Option<[f64; 2]> { + let Value::Descriptor(d) = value else { return None }; + if !d.class_id.is("countObject") || d.items.len() != 2 { + return None; + } + let (Value::Double(x), Value::Double(y)) = (d.get("X ")?, d.get("Y ")?) else { return None }; + (x.is_finite() && y.is_finite()).then_some([*x, *y]) +} + +/// Consume readable Count data. Keep only opaque bytes when no resource can be read. +pub(crate) fn import_count_resource(doc: &mut Document) { + if let Some(groups) = doc.metadata.psd_resources.iter().filter(|(id, _, _)| *id == COUNT_INFO).find_map(|(_, _, data)| count_groups_from_resource(data)) { + doc.measurement.count_groups = groups; + doc.metadata.psd_resources.retain(|(id, _, _)| *id != COUNT_INFO); + } +} + +/// Resource 1080 from the current groups, warning when PSD changes sizes or omits points. +pub(crate) fn fresh_count_resource(doc: &Document, warnings: &mut Vec) -> Option { + if doc.measurement.count_groups.is_empty() { + return None; + } + let groups = doc.measurement.count_groups.iter().map(|group| count_group_descriptor(group, warnings)).collect(); + let d = Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(groups)); + Some(ImageResource::new(COUNT_INFO, VersionedDescriptor::new(d).to_bytes())) +} + +/// Write the measured group layout and preserve marker order. +fn count_group_descriptor(group: &CountGroup, warnings: &mut Vec) -> Value { + let marker_size = group.marker_size.clamp(1, 10); + let label_size = group.label_size.clamp(8, 72); + if marker_size != group.marker_size || label_size != group.label_size { + warnings.push(format!( + "Count group {:?} sizes adjusted for PSD (marker {} to {marker_size}, label {} to {label_size})", + group.name, group.marker_size, group.label_size + )); + } + let [r, g, b, _] = group.color.to_rgba8(); + let objects: Vec<_> = group + .points + .iter() + .filter(|[x, y]| x.is_finite() && y.is_finite()) + .map(|&[x, y]| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y)))) + .collect(); + let omitted = group.points.len() - objects.len(); + if omitted != 0 { + warnings.push(format!("Count group {:?} had nonfinite points omitted from PSD export ({omitted} total)", group.name)); + } + Value::Descriptor( + Descriptor::new("countGroup") + .with("Rd ", Value::Integer(i32::from(r))) + .with("Grn ", Value::Integer(i32::from(g))) + .with("Bl ", Value::Integer(i32::from(b))) + .with("Nm ", Value::Text(UnicodeString::new_nul(&group.name))) + .with("Rds ", Value::Integer(marker_size as i32)) + .with("fontSize", Value::Integer(label_size as i32)) + .with("Vsbl", Value::Boolean(group.visible)) + .with("countObjectList", Value::List(objects)), + ) +} + /// Whether to keep a preserved resource with this id on export. pub fn keep_resource(doc: &Document, id: u16) -> bool { match id { MEASUREMENT_SCALE => keep_raw_scale(doc), - // Our count markers can't be written into Photoshop's (unpublished) layout; a stale copy - // would contradict them, so it is dropped once the document has markers of its own. - COUNT_INFO => doc.measurement.count_total() == 0, + // An opaque copy is only used when there are no document groups to write. + COUNT_INFO => doc.measurement.count_groups.is_empty(), _ => true, } } +#[cfg(test)] +#[path = "count_resource_tests.rs"] +mod count_resource_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index ef3b389bee..cf99d28a66 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -16,6 +16,9 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007; pub(crate) const QUICK_MASK_INFO: u16 = 1022; /// Alpha channel identifiers (Photoshop 6): one big-endian u32 per extra channel, no count prefix. pub(crate) const ALPHA_IDENTIFIERS: u16 = 1053; +/// Alternate Spot Colors (PSD resource 1067): version (u16), count (u16), then per spot channel its +/// identifier (u32, as in 1053) and a colour structure (space + four u16 components). +pub(crate) const ALTERNATE_SPOT_COLORS: u16 = 1067; const SPACE_RGB: u16 = 0; const SPACE_HSB: u16 = 1; @@ -90,6 +93,31 @@ pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect() } +/// The colour space of each extra channel's DisplayInfo record, after skipping `skip` records, +/// in the same records [`apply_display_info`] reads. +pub(crate) fn display_spaces(data: &[u8], versioned: bool, skip: usize) -> Vec { + display_records(data, versioned).skip(skip).map(|rec| u16::from_be_bytes([rec[0], rec[1]])).collect() +} + +/// The spot colours of an Alternate Spot Colors resource (1067) by channel identifier, those in +/// a colour space [`decode_known_color`] reads. The reference editor's 24.7 stores a spot's +/// DisplayInfo colour in a space it does not document (psd-tools cmyk-alpha-spot.psd: 0x2331) +/// and its ink here, in Lab. +pub(crate) fn alternate_spot_colors(data: &[u8]) -> Vec<(u32, Color)> { + let count = data.get(2..4).map_or(0, |c| usize::from(u16::from_be_bytes([c[0], c[1]]))); + let body = data.get(4..).unwrap_or_default(); + body.as_chunks::<14>() + .0 + .iter() + .take(count) + .filter_map(|rec| { + let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); + let id = u32::from_be_bytes([rec[0], rec[1], rec[2], rec[3]]); + decode_known_color(u(4), [u(6), u(8), u(10), u(12)]).map(|c| (id, c)) + }) + .collect() +} + /// Whether a DisplayInfo resource 1077 marks any channel as a spot (kind 2), in 13-byte records /// (Photoshop's, and PhotoCraft's since 0.8.2) or in the 14-byte padded records PhotoCraft wrote /// up to 0.7.0. diff --git a/crates/io/src/count_resource_tests.rs b/crates/io/src/count_resource_tests.rs new file mode 100644 index 0000000000..fa84fc2233 --- /dev/null +++ b/crates/io/src/count_resource_tests.rs @@ -0,0 +1,213 @@ +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Color, ColorTable, CountGroup, Document}; +use photocraft_geom::Size; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::{ColorMode as PsdMode, ImageResource, PsdFile}; + +use super::{COUNT_INFO, count_groups_from_resource}; + +fn measured_resource(second_y: f64) -> Vec { + let point = |x, y| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y))); + let first = Descriptor::new("countGroup") + .with("Rd ", Value::Integer(51)) + .with("Grn ", Value::Integer(102)) + .with("Bl ", Value::Integer(204)) + .with("Nm ", Value::Text(UnicodeString::new_nul("Cells"))) + .with("Rds ", Value::Integer(3)) + .with("fontSize", Value::Integer(12)) + .with("Vsbl", Value::Boolean(true)) + .with("countObjectList", Value::List(vec![point(9.5, 10.5), point(1.125, second_y), point(3.5, 4.5)])); + let second = Descriptor::new("countGroup") + .with("Rd ", Value::Integer(255)) + .with("Grn ", Value::Integer(0)) + .with("Bl ", Value::Integer(0)) + .with("Nm ", Value::Text(UnicodeString::new_nul("Café"))) + .with("Rds ", Value::Integer(10)) + .with("fontSize", Value::Integer(72)) + .with("Vsbl", Value::Boolean(false)) + .with("countObjectList", Value::List(vec![point(7.5, 8.5), point(5.5, 6.5)])); + VersionedDescriptor::new( + Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(vec![Value::Descriptor(first), Value::Descriptor(second)])), + ) + .to_bytes() +} + +fn groups() -> Vec { + vec![ + CountGroup { + name: "Cells".into(), + color: Color::rgb(51.0 / 255.0, 102.0 / 255.0, 204.0 / 255.0), + marker_size: 3, + label_size: 12, + visible: true, + points: vec![[9.5, 10.5], [1.125, 2.75], [3.5, 4.5]], + }, + CountGroup { + name: "Café".into(), + color: Color::rgb(1.0, 0.0, 0.0), + marker_size: 10, + label_size: 72, + visible: false, + points: vec![[7.5, 8.5], [5.5, 6.5]], + }, + ] +} + +fn document(mode: ColorMode) -> Document { + Document::new("Counts", Size::new(16, 16), mode, SampleType::U8) +} + +fn to_psd(doc: &Document) -> Vec { + crate::export(doc, "counts.psd", &crate::ExportOptions::default()).unwrap().bytes +} + +fn import_counts(data: Vec) -> Document { + let mut file = PsdFile::from_bytes(&to_psd(&document(ColorMode::Rgb))).unwrap(); + file.resources.push(ImageResource::new(COUNT_INFO, data)); + crate::import("counts.psd", &file.to_bytes().unwrap()).unwrap().document +} + +/// Two independently constructed groups preserve every option and the order of all five points. +#[test] +fn parses_hand_built_count_descriptor() { + assert_eq!(count_groups_from_resource(&measured_resource(2.75)), Some(groups())); + assert!(count_groups_from_resource(&measured_resource(f64::INFINITY)).is_none()); + let mut trailing = measured_resource(2.75); + trailing.push(1); + assert!(count_groups_from_resource(&trailing).is_none()); +} + +/// Count groups round-trip through the real PSD reader and both export routes. +#[test] +fn count_groups_round_trip_through_psd() { + for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap), (ColorMode::Multichannel, PsdMode::Multichannel)] { + let mut doc = document(mode); + doc.measurement.count_groups = groups(); + let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap(); + assert!(!exported.warnings.iter().any(|warning| warning.starts_with("Count group "))); + let bytes = exported.bytes; + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.header.color_mode, wire_mode); + let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].data, measured_resource(2.75)); + let reopened = crate::import("counts.psd", &bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); + assert!(!reopened.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO)); + } +} + +/// Both export routes report clamped sizes and preserve the original Count groups. +#[test] +fn count_size_clamps_warn_without_changing_source_groups() { + for mode in [ColorMode::Rgb, ColorMode::Bitmap] { + let mut doc = document(mode); + let mut original = groups(); + original[0].marker_size = 0; + original[0].label_size = 73; + original[1].marker_size = 11; + original[1].label_size = 7; + doc.measurement.count_groups = original.clone(); + + let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap(); + let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect(); + assert_eq!( + warnings, + [ + "Count group \"Cells\" sizes adjusted for PSD (marker 0 to 1, label 73 to 72)", + "Count group \"Café\" sizes adjusted for PSD (marker 11 to 10, label 7 to 8)", + ] + ); + assert_eq!(doc.measurement.count_groups, original); + + let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document; + let mut expected = original; + expected[0].marker_size = 1; + expected[0].label_size = 72; + expected[1].marker_size = 10; + expected[1].label_size = 8; + assert_eq!(reopened.measurement.count_groups, expected); + } +} + +/// Opaque data survives an untouched save and an add-then-delete, but current groups win. +#[test] +fn unreadable_count_resource_is_preserved_without_groups() { + let raw = vec![0, 0, 0, 16, 0]; + let mut doc = import_counts(raw.clone()); + assert!(doc.measurement.count_groups.is_empty()); + let check_raw = |doc: &Document| { + let file = PsdFile::from_bytes(&to_psd(doc)).unwrap(); + assert_eq!(file.resource(COUNT_INFO).unwrap().data, raw); + }; + check_raw(&doc); + doc.measurement.count_groups.push(CountGroup::default()); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + let mut resources = file.resources.iter().filter(|r| r.id == COUNT_INFO); + assert_eq!(count_groups_from_resource(&resources.next().unwrap().data), Some(doc.measurement.count_groups.clone())); + assert!(resources.next().is_none()); + doc.measurement.count_groups.clear(); + check_raw(&doc); +} + +/// Deleting the imported groups cannot bring back the readable resource. +#[test] +fn deleting_all_readable_count_groups_writes_no_resource() { + let mut doc = import_counts(measured_resource(2.75)); + assert_eq!(doc.measurement.count_groups, groups()); + assert!(!doc.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO)); + doc.measurement.count_groups.clear(); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + assert!(file.resource(COUNT_INFO).is_none()); +} + +/// Indexed export uses the shared layerless writer and keeps exactly one Count resource. +#[test] +fn indexed_count_groups_round_trip_through_psd() { + let mut doc = document(ColorMode::Indexed); + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None }); + let opaque = vec![0, 0, 0, 16, 0]; + doc.metadata.psd_resources.push((COUNT_INFO, String::new(), std::sync::Arc::new(opaque.clone()))); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + assert_eq!(file.header.color_mode, PsdMode::Indexed); + assert_eq!(file.resource(COUNT_INFO).unwrap().data, opaque); + + doc.measurement.count_groups = groups(); + let bytes = to_psd(&doc); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.header.color_mode, PsdMode::Indexed); + assert!(file.layer_info.is_none()); + let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].data, measured_resource(2.75)); + let reopened = crate::import("counts.psd", &bytes).unwrap().document; + assert_eq!(reopened.color_table, doc.color_table); + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); +} + +/// Both PSD routes report omitted points, keeping the finite points in order. +#[test] +fn nonfinite_count_points_warn_on_psd_export() { + for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap)] { + let mut doc = document(mode); + doc.measurement.count_groups = groups(); + let options = crate::ExportOptions::default(); + let clean = crate::export(&doc, "counts.psd", &options).unwrap(); + assert!(!clean.warnings.iter().any(|warning| warning.starts_with("Count group "))); + let reopened = crate::import("counts.psd", &clean.bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); + + let mut expected = groups(); + expected[0].points.remove(1); + doc.measurement.count_groups[0].points[1][1] = f64::INFINITY; + let original = doc.measurement.count_groups.clone(); + let exported = crate::export(&doc, "counts.psd", &options).unwrap(); + let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect(); + assert_eq!(warnings, ["Count group \"Cells\" had nonfinite points omitted from PSD export (1 total)"]); + assert_eq!(doc.measurement.count_groups, original); + let file = PsdFile::from_bytes(&exported.bytes).unwrap(); + assert_eq!(file.header.color_mode, wire_mode); + let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, expected); + } +} diff --git a/crates/io/src/export_estimate.rs b/crates/io/src/export_estimate.rs new file mode 100644 index 0000000000..61836b07f8 --- /dev/null +++ b/crates/io/src/export_estimate.rs @@ -0,0 +1,118 @@ +//! A rough upper bound of the memory an export needs, so a caller can refuse a save that cannot +//! fit before starting it (#10024): on the web a failed allocation aborts the module and the +//! document is lost. + +use std::collections::HashSet; + +use photocraft_doc::{Document, LayerContent, SmartSource}; +use photocraft_geom::TILE_SIZE; + +/// Bytes per pixel of the compositor's working buffer (`RGBA`, `f32`), filled when a flat format +/// is written. +const COMPOSITE_BYTES_PER_PX: u64 = 16; + +/// Formats that keep the layers: their model is built from the layer pixels, not a composite. +fn keeps_layers(ext: &str) -> bool { + matches!(ext, "psd" | "psb" | "pcraft" | "ora" | "kra" | "xcf") +} + +/// Formats whose writer fills a composite of the canvas: every flat format, and OpenRaster for +/// its `mergedimage.png`. +fn fills_composite(ext: &str) -> bool { + !keeps_layers(ext) || ext == "ora" +} + +/// The extension of `name_or_ext` (a file name, path or bare extension), lowercase. +fn extension(name_or_ext: &str) -> String { + let file = name_or_ext.rsplit(['/', '\\']).next().unwrap_or(name_or_ext); + file.rsplit_once('.').map_or(file, |(_, ext)| ext).to_ascii_lowercase() +} + +/// The bytes exporting `doc` to `name_or_ext` may need before anything it allocates can fail +/// cleanly: the output model, about one copy of every layer's and mask's pixel tiles plus the +/// embedded smart-object files (each distinct one counted once); a flat format and OpenRaster +/// also fill a composite of the canvas, and PSD/PSB their merged image ([`merged_image_bytes`]). +/// The encoded file is not counted: its buffer is reserved fallibly (`PsdFile::to_bytes`), so if +/// it doesn't fit the save fails with an error rather than trapping, and counting it would refuse +/// saves that fit. Saturates; never panics. +pub fn estimate_export_bytes(doc: &Document, name_or_ext: &str) -> u64 { + let tile_bytes = (TILE_SIZE as u64) * (TILE_SIZE as u64) * doc.pixel_format().bytes_per_pixel().max(1) as u64; + let mut seen = HashSet::new(); + let mut total = 0u64; + for (_, _, layer) in doc.walk() { + let tiles = layer.surface().map_or(0, |s| s.tile_count()) + layer.mask.as_ref().map_or(0, |m| m.surface.tile_count()); + total = total.saturating_add((tiles as u64).saturating_mul(tile_bytes)); + if let LayerContent::Smart(so) = &layer.content + && let SmartSource::Embedded { bytes, .. } = &so.source + && seen.insert(so.contents_id) + { + total = total.saturating_add(bytes.len() as u64); + } + } + let mut need = total; + let ext = extension(name_or_ext); + if fills_composite(&ext) { + need = need.saturating_add(canvas_px(doc).saturating_mul(COMPOSITE_BYTES_PER_PX)); + } + if matches!(ext.as_str(), "psd" | "psb") { + need = need.saturating_add(merged_image_bytes(doc)); + } + need +} + +fn canvas_px(doc: &Document) -> u64 { + u64::from(doc.size.width).saturating_mul(u64::from(doc.size.height)) +} + +/// PSD and PSB always write a canvas-sized merged image: the real composite with maximum +/// compatibility, a white placeholder without it. Its planes (colour channels plus alpha, in the +/// document's sample size) and their encoded copy are held together while the file is built. +fn merged_image_bytes(doc: &Document) -> u64 { + let fmt = doc.pixel_format(); + let per_px = (fmt.mode.color_channels() as u64 + 1).saturating_mul(fmt.sample.bytes() as u64); + canvas_px(doc).saturating_mul(per_px).saturating_mul(2) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Layer, SmartObject}; + use photocraft_geom::Affine; + use std::sync::Arc; + + fn doc_with_smart_object(len: usize) -> Document { + let mut doc = Document::new("t", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let source = SmartSource::Embedded { file_name: "a.png".into(), bytes: Arc::new(vec![0; len]) }; + doc.layers.push(Layer::new("so", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, None)))); + doc + } + + #[test] + fn counts_embedded_files_once_and_adds_a_composite_for_flat_formats() { + let doc = doc_with_smart_object(10 << 20); + // RGB 8-bit: three colour planes and alpha, held raw and encoded. + let merged = 64 * 64 * 4 * 2; + assert_eq!(estimate_export_bytes(&doc, "x.psd"), (10 << 20) + merged); + assert_eq!(estimate_export_bytes(&doc, "/a/b/x.PSD"), (10 << 20) + merged); + assert_eq!(estimate_export_bytes(&doc, "x.pcraft"), 10 << 20); + assert_eq!(estimate_export_bytes(&doc, "png"), (10 << 20) + 64 * 64 * 16); + // OpenRaster keeps the layers but also writes a merged image, flattened first. + assert_eq!(estimate_export_bytes(&doc, "x.ora"), (10 << 20) + 64 * 64 * 16); + } + + #[test] + fn a_shared_source_is_counted_once() { + let mut doc = doc_with_smart_object(1 << 20); + let dup = doc.layers[0].clone(); + doc.layers.push(dup); + assert_eq!(estimate_export_bytes(&doc, "psd"), (1 << 20) + 64 * 64 * 4 * 2); + } + + #[test] + fn an_empty_document_needs_next_to_nothing() { + let doc = Document::new("t", photocraft_geom::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); + assert_eq!(estimate_export_bytes(&doc, "psd"), 10 * 10 * 4 * 2, "only the merged image"); + assert_eq!(estimate_export_bytes(&doc, "pcraft"), 0); + } +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 3735514514..859ec01eb9 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -6,13 +6,18 @@ use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; use photocraft_geom::{Rect, Size, TILE_SIZE}; -use photocraft_raster::Surface; +use photocraft_raster::{AllocationError, Interrupt, PruneCursor, RowWriteError, Surface}; +use crate::renderer::{BandRenderer, RenderError}; use crate::{ExportOptions, ExportResult, ImportResult, IoError, TgaBits, XmpEmbed}; /// Bytes per band when converting or exporting a band of rows at a time. const BAND_BYTES: usize = 32 << 20; +/// Decoded size from which a PNG is read straight into the tiles. Below it the whole-image path +/// is faster, and the memory the stream saves is small. +const STREAM_MIN_BYTES: u64 = 32 << 20; + #[cfg(test)] thread_local! { /// Called on this thread just before a layered TIFF's composite is decoded. A test proves @@ -48,10 +53,32 @@ fn before_composite_decode() {} /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). +fn decode_limits(opts: &crate::ImportOptions) -> codecs::Limits { + let mut limits = codecs::Limits::default(); + if opts.flat_max_pixel_bytes > 0 { + limits.max_alloc = limits.max_alloc.min(opts.flat_max_pixel_bytes); + } + limits +} + pub fn import_flat(name: &str, bytes: &[u8]) -> Result { import_flat_with_options(name, bytes, &crate::ImportOptions::default(), &photocraft_raster::Interrupt::NONE) } +/// [`import_flat`] with cancellation and progress: checked after the decode and around the +/// building of the tiles. A cancelled import fails with [`IoError::Cancelled`]. +#[cfg(test)] +pub fn import_flat_with(name: &str, bytes: &[u8], ctl: &Interrupt) -> Result { + import_flat_with_options(name, bytes, &crate::ImportOptions::default(), ctl) +} + +/// [`import_flat_with`]; `stream_min` lets a PNG be decoded straight into the tiles (tests turn it +/// off to get the whole-image path to compare with). +#[cfg(test)] +fn import_flat_impl(name: &str, bytes: &[u8], ctl: &Interrupt, stream_min: Option) -> Result { + import_flat_impl_with_options(name, bytes, &crate::ImportOptions::default(), ctl, stream_min) +} + /// [`import_flat`] with the user's import settings and the job's cancellation. /// /// A layered TIFF's composite, layer and retained-image estimate is admitted through @@ -64,13 +91,175 @@ pub(crate) fn import_flat_with_options( bytes: &[u8], opts: &crate::ImportOptions, ctl: &photocraft_raster::Interrupt, +) -> Result { + import_flat_impl_with_options(name, bytes, opts, ctl, Some(STREAM_MIN_BYTES)) +} + +fn import_flat_impl_with_options( + name: &str, + bytes: &[u8], + opts: &crate::ImportOptions, + ctl: &Interrupt, + stream_min: Option, ) -> Result { // Detected once; every step below branches on it. let format = codecs::detect(bytes); if format == Some(Format::Tiff) { - return import_tiff_page_with_options(name, bytes, None, opts, ctl); + // The banded decoder checks the cancellation between groups; a decode error is reported + // as itself, not as the cancel that follows it. + let r = import_tiff_page_with_options(name, bytes, None, opts, ctl)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + return Ok(r); } - let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; + // A PNG whose rows come in final order is decoded straight into the tiles. + let streamed = match stream_min { + Some(min) if format == Some(Format::Png) => import_png_streamed(name, bytes, opts, ctl, min)?, + _ => None, + }; + let (mut r, img) = match streamed { + Some(r) => (r, None), + None => { + let limits = decode_limits(opts); + let img = codecs::decode_as_with(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes, &codecs::DecodeOptions { limits, ..Default::default() })?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(0.6); + (image_to_document_with(name, &img, ctl)?, Some(img)) + } + }; + apply_flat_format(&mut r, bytes, format, img.as_ref())?; + Ok(r) +} + +/// An image becoming a document's surface a bounded band of rows and a bounded batch of empty +/// tiles at a time, so a caller that must not block (the web build has no threads) can step it. +/// [`FlatBuilder::finish`] runs whatever is left, so it also gives the one-shot result. +pub(crate) struct FlatBuilder { + name: String, + img: Image, + mode: ColorMode, + depth: SampleType, + convert: RowConverter, + surface: Surface, + warnings: Vec, + /// Rows copied into `surface` so far. + rows: u32, + /// Rows per [`FlatBuilder::copy_step`]. + band: u32, + /// Whether the tiles are scanned for empty ones: only a source with alpha has any. + scans: bool, + prune: Option, +} + +/// Bytes a copy step converts at most; a step is a band of whole rows, so it is never split. +const COPY_BAND_BYTES: usize = 4 << 20; +/// Rows a copy step writes at most (a narrow image would otherwise copy the whole one in one step). +const COPY_BAND_ROWS: u32 = 256; +/// Tiles a prune step visits at most. +const PRUNE_BATCH: usize = 256; + +impl FlatBuilder { + /// Sets up the surface `img` becomes, without copying a pixel yet. Refuses an image whose + /// tiles the machine can't hold beside it, as [`image_to_document`] does. + pub(crate) fn new(name: &str, img: Image) -> Result { + let (w, h) = img.dimensions(); + let (mode, target_layout, depth, csample) = flat_target(img.layout(), img.sample_type()); + check_layer_fits(w, h, target_layout, csample, crate::memory::available())?; + let convert = RowConverter::new((img.layout(), img.sample_type()), (target_layout, csample)); + let row = (w as usize).saturating_mul(convert.src_px).max(1); + let band = (COPY_BAND_BYTES / row).clamp(1, COPY_BAND_ROWS as usize) as u32; + Ok(Self { + name: name.to_string(), + warnings: flat_warnings(&img), + surface: Surface::new(surface_format(target_layout, csample)), + scans: img.layout().has_alpha(), + convert, + mode, + depth, + rows: 0, + band: band.min(h.max(1)), + prune: None, + img, + }) + } + + /// True once every row is in the surface. + pub(crate) fn copy_done(&self) -> bool { + self.rows >= self.img.dimensions().1 + } + + /// Copies one bounded band of rows into the tiles, converted as [`image_to_document`] does. + pub(crate) fn copy_step(&mut self) -> Result<(), IoError> { + if self.copy_done() { + return Ok(()); + } + let (w, h) = self.img.dimensions(); + let y1 = self.rows.saturating_add(self.band).min(h); + let row = (w as usize).checked_mul(self.convert.src_px).filter(|&b| b > 0).ok_or_else(|| IoError::Unsupported("image too large".into()))?; + let at = (self.rows as usize).saturating_mul(row); + let end = (y1 as usize).saturating_mul(row); + let data = self.img.data().get(at..end).ok_or_else(|| IoError::Unsupported("the decoded image is shorter than its size".into()))?; + let to_i32 = |v: u32| i32::try_from(v).map_err(|_| IoError::Unsupported("image too large".into())); + let rect = Rect::new(0, to_i32(self.rows)?, to_i32(w)?, to_i32(y1)?); + let convert = &self.convert; + let failed = std::sync::atomic::AtomicBool::new(false); + self.surface + .try_write_rows_with_interrupt(rect, &Interrupt::NONE, |y, x0, dst| { + if !convert.fill(data, w as usize, y - rect.y0, x0, dst) { + failed.store(true, std::sync::atomic::Ordering::Relaxed); + } + }) + .map_err(|e| match e { + RowWriteError::Cancelled(_) => IoError::Cancelled, + RowWriteError::Allocation(ae) => alloc_error(ae, w, h), + })?; + if failed.load(std::sync::atomic::Ordering::Relaxed) { + return Err(IoError::Unsupported("the decoded image does not match its size".into())); + } + self.rows = y1; + Ok(()) + } + + /// True once every tile has been through the prune scan (at once for a source without alpha). + pub(crate) fn prune_done(&self) -> bool { + !self.scans || self.prune.as_ref().is_some_and(|c| c.is_done()) + } + + /// Drops one bounded batch of tiles that are entirely the default pixel; true when done. + pub(crate) fn prune_step(&mut self) -> bool { + if !self.scans { + return true; + } + if self.prune.is_none() { + self.prune = Some(self.surface.prune_cursor()); + } + let Some(cursor) = self.prune.as_mut() else { return true }; + self.surface.prune_batch(cursor, PRUNE_BATCH) + } + + /// The document, after copying and pruning whatever is left: the one-shot result and the last + /// step of a stepped one. + pub(crate) fn finish(mut self, bytes: &[u8], format: Option) -> Result { + let copied = self.copy_done(); + while !self.copy_done() { + self.copy_step()?; + } + if !copied { + // Any scan before the last rows missed the tiles they added: scan them all again. + self.prune = None; + } + while !self.prune_step() {} + let Self { name, img, mode, depth, surface, warnings, .. } = self; + let (w, h) = img.dimensions(); + let mut r = flat_document(&name, (w, h, img.layout(), mode, depth), surface, img.icc.clone(), &img.meta, warnings, Some(&img))?; + apply_flat_format(&mut r, bytes, format, Some(&img))?; + Ok(r) + } +} + +/// What every flat format does to the document of its pixels: the palette mode, the save +/// settings, a Targa's alpha channel and the profile an untagged image needs. `img` is the +/// decoded image, when the document was built from one (a streamed PNG has none). +pub(crate) fn apply_flat_format(r: &mut ImportResult, bytes: &[u8], format: Option, img: Option<&Image>) -> Result<(), IoError> { // A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in // Photoshop (#3802, #4804, #7886). A GIF is always palette-based: the first frame's table. let palette = match format { @@ -79,7 +268,6 @@ pub(crate) fn import_flat_with_options( Some(Format::Gif) => codecs::gif_palette(bytes), _ => None, }; - let mut r = image_to_document(name, &img)?; if let Some((colors, transparent)) = palette.filter(|(c, _)| !c.is_empty()) && r.document.mode == ColorMode::Rgb { @@ -87,7 +275,7 @@ pub(crate) fn import_flat_with_options( r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } r.document.file_encoding = file_encoding_as(format, bytes); - if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { + if format == Some(Format::Tga) && img.is_some_and(|i| i.layout() == ChannelLayout::Rgba) { tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless @@ -102,15 +290,16 @@ pub(crate) fn import_flat_with_options( }; } if d.icc_profile.is_none() && d.mode == ColorMode::Rgb { - d.icc_profile = cicp_profile(&img); + // Only an image the whole-image path decoded can carry a CICP description (a PNG never does). + d.icc_profile = img.and_then(cicp_profile); if d.icc_profile.is_none() - && let Some((profile, warning)) = hdr_fallback(&img) + && let Some((profile, warning)) = img.and_then(hdr_fallback) { d.icc_profile = Some(profile); r.warnings.push(warning); } } - Ok(r) + Ok(()) } /// A PQ or HLG image, which the decoder turned into linear light, on primaries @@ -310,7 +499,7 @@ pub fn import_tiff_page_with_options( // Extra samples (alpha and spot channels) are only available from a whole-image decode. if bands.extra_samples() > 0 { drop(bands); - return import_tiff_with_extras(name, bytes, page, ctl); + return import_tiff_with_extras(name, bytes, page, opts, ctl); } tiff_to_document(name, bands.upright(), crate::memory::available, BAND_BYTES, ctl) } @@ -336,7 +525,7 @@ fn import_tiff_layered( before_composite_decode(); // The orientation is applied after the layer check: rotating the composite but not the // layers would misalign them, so a layered TIFF keeps its stored orientation. - let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() }; + let keep = codecs::DecodeOptions { keep_orientation: true, limits: decode_limits(opts) }; let (mut img, orientation) = match page { None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)), Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)), @@ -395,9 +584,15 @@ fn tiff_admission( /// A flat TIFF page with extra samples, decoded whole: the composite becomes the layer, the /// extra samples its channels. -fn import_tiff_with_extras(name: &str, bytes: &[u8], page: Option, ctl: &photocraft_raster::Interrupt) -> Result { +fn import_tiff_with_extras( + name: &str, + bytes: &[u8], + page: Option, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; - let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() }; + let keep = codecs::DecodeOptions { keep_orientation: true, limits: decode_limits(opts) }; let (img, orientation) = match page { None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)), Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)), @@ -471,11 +666,33 @@ fn tiff_to_document( Ok(single_layer_document(name, (w, h), f, layout.has_alpha(), s, bands.icc.take(), meta, warnings)) } +/// The bytes of a layer's tiles holding a `w` × `h` image of `bpp` bytes a pixel: tiles are whole +/// [`TILE_SIZE`] squares, so a small image still rounds up to one. +fn tile_bytes(w: u32, h: u32, bpp: u64) -> u64 { + let tiles = |n: u32| u64::from(n).div_ceil(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64); + tiles(w).saturating_mul(tiles(h)).saturating_mul(bpp) +} + +/// Refuses the layer's tiles of a `w` × `h` image decoded whole when the machine can't hold them +/// beside the decoded pixels, which are still live: Linux overcommits, so the tiles would be +/// allocated and writing them would have the process killed. The whole-image counterpart of +/// [`tiff_to_document`]'s check, which the base PNG path (a small, interlaced, animated or turned +/// image, see [`import_png_streamed`]) also comes through. Below [`STREAM_MIN_BYTES`] of tiles the +/// image is one the whole-image path is for because it is small, and the check's reserve, not the +/// tiles, would decide: those keep opening as they did (as [`needs_memory_check`] does for TIFF). +fn check_layer_fits(w: u32, h: u32, target_layout: ChannelLayout, csample: CSample, available: Option) -> Result<(), IoError> { + let bpp = surface_format(target_layout, csample).bytes_per_pixel() as u64; + let needed = tile_bytes(w, h, bpp); + if needed < STREAM_MIN_BYTES { + return Ok(()); + } + crate::memory::check_fits(w, h, needed, available) +} + /// Bytes a `w` × `h` px document surface of `fmt` allocates: whole tiles, including the padding /// of a partly covered edge tile, as [`Surface`] holds them. fn surface_bytes(w: u32, h: u32, fmt: PixelFormat) -> u64 { - let tiles = |n: u32| u64::from(n).div_ceil(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64); - tiles(w).saturating_mul(tiles(h)).saturating_mul(fmt.bytes_per_pixel() as u64) + tile_bytes(w, h, fmt.bytes_per_pixel() as u64) } /// Bytes a flat page that is decoded whole needs: the decoded image and its extra-sample planes, @@ -554,18 +771,277 @@ fn document_format(layout: ChannelLayout, sample: CSample) -> DocumentFormat { /// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a /// normal "Layer 0" when it has transparency. -pub(crate) fn image_to_document(name: &str, img: &Image) -> Result { - // What the decoder noticed (frames or pages left out, data ending early) comes first. - let mut warnings: Vec = img.warnings.iter().map(ToString::to_string).collect(); - let f = document_format(img.layout(), img.sample_type()); - warnings.extend(f.warning.clone()); - let mut s = image_surface(img, f.layout, f.sample)?; - s.prune(); - let mut r = single_layer_document(name, img.dimensions(), f, img.layout().has_alpha(), s, img.icc.clone(), img.meta.clone(), warnings); - r.warnings.extend(crate::tiff_layers::channels_from_extras(&mut r.document, img)); - Ok(r) +pub fn image_to_document(name: &str, img: &Image) -> Result { + image_to_document_with(name, img, &Interrupt::NONE) } +/// [`image_to_document`], checking `ctl` before and after the tiles are built (progress 0.95 +/// once they are), and refusing tiles the machine can't hold beside the decoded image. +fn image_to_document_with(name: &str, img: &Image, ctl: &Interrupt) -> Result { + image_to_document_with_memory(name, img, ctl, crate::memory::available()) +} + +/// [`image_to_document_with`], with the memory the machine can still use given (see +/// [`crate::memory::available`]): `None` when it isn't known. +fn image_to_document_with_memory(name: &str, img: &Image, ctl: &Interrupt, available: Option) -> Result { + let warnings = flat_warnings(img); + let (mode, target_layout, depth, csample) = flat_target(img.layout(), img.sample_type()); + let (w, h) = img.dimensions(); + ctl.check().map_err(|_| IoError::Cancelled)?; + // The decoded pixels are already here: check the tiles built beside them before they exist. + check_layer_fits(w, h, target_layout, csample, available)?; + let mut s = image_surface_with(img, target_layout, csample, ctl)?; + // Tiles that are entirely the default pixel (zero, so transparent) can only come from a + // source with alpha: an opaque one writes the maximum into every alpha sample. + if img.layout().has_alpha() { + s.prune(); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(0.95); + flat_document(name, (w, h, img.layout(), mode, depth), s, img.icc.clone(), &img.meta, warnings, Some(img)) +} + +/// What the decoder noticed (frames or pages left out, data ending early) and the conversion +/// will add, for the document of `img`. +fn flat_warnings(img: &Image) -> Vec { + let mut warnings: Vec = img.warnings.iter().map(ToString::to_string).collect(); + if img.sample_type() == CSample::F16 { + warnings.push("16-bit float samples are stored as 32-bit float".to_string()); + } + warnings +} + +/// The document colour mode, the layout and sample type of its surface, and its depth, for a +/// decoded image of `layout` and `sample`. +fn flat_target(layout: ChannelLayout, sample: CSample) -> (ColorMode, ChannelLayout, SampleType, CSample) { + let (mode, target_layout) = match layout { + ChannelLayout::Gray | ChannelLayout::GrayA => (ColorMode::Grayscale, ChannelLayout::GrayA), + ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba), + ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA), + ChannelLayout::Lab | ChannelLayout::LabA => (ColorMode::Lab, ChannelLayout::LabA), + }; + let (depth, csample) = match sample { + CSample::U8 => (SampleType::U8, CSample::U8), + CSample::U16 => (SampleType::U16, CSample::U16), + CSample::F16 | CSample::F32 => (SampleType::F32, CSample::F32), + }; + (mode, target_layout, depth, csample) +} + +/// The single-layer document of a flat image's pixels `s` and metadata: `shape` is its size, the +/// layout it was decoded in (whether it has alpha) and the document's mode and depth. `extras` is +/// the decoded image, for the channels of a TIFF's extra samples. +fn flat_document( + name: &str, + shape: (u32, u32, ChannelLayout, ColorMode, SampleType), + s: Surface, + icc: Option>, + meta: &codecs::Metadata, + mut warnings: Vec, + extras: Option<&Image>, +) -> Result { + let (w, h, layout, mode, depth) = shape; + let mut doc = Document::new(name, Size::new(w, h), mode, depth); + // As in Photoshop: an opaque image opens as the locked Background layer, one with + // transparency (an alpha channel, or a tRNS chunk the decoder expands to one) as a normal + // layer named "Layer 0", since a Background can't hold transparency. + let layer = if layout.has_alpha() { + Layer::new("Layer 0", LayerContent::Raster(s)) + } else { + let mut bg = Layer::new("Background", LayerContent::Raster(s)); + bg.locks.transparency = true; + bg.locks.position = true; + bg + }; + doc.layers.push(layer); + if let Some(img) = extras { + warnings.extend(crate::tiff_layers::channels_from_extras(&mut doc, img)); + } + doc.icc_profile = icc.map(Arc::new); + doc.metadata.exif = meta.exif.clone().map(Arc::new); + doc.metadata.xmp = meta.xmp.as_deref().map(Arc::from); + doc.metadata.text = meta.text.clone(); + if let Some((x, y)) = meta.dpi { + doc.resolution_dpi = x; + warnings.extend(crate::unequal_resolution_warning(f64::from(x), f64::from(y))); + } + // Guides in the Photoshop resources a JPEG (APP13) or TIFF carries: resource 1032. + let guides = meta.photoshop_resources.as_deref().and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()); + if let Some(g) = guides.as_ref().and_then(|(res, _)| res.iter().find(|r| r.id == 1032)) { + crate::psd_import::parse_guides(&g.data, &mut doc); + } + Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false }) +} + +/// A PNG opened straight into the tiles of its layer, a group of rows at a time, so the whole +/// decoded image never exists next to them. `None` when it can't be or isn't worth it (under +/// `min_bytes` decoded): an interlaced or animated file, or one whose `eXIf` (which may follow the image data) calls for turning the pixels +/// upright; the caller then decodes the whole image as for any other format. Errors, limits and +/// metadata are the whole-image decoder's. The tiles and one group of rows are checked against +/// free memory before the first tile exists (see [`crate::memory`]). +fn import_png_streamed(name: &str, bytes: &[u8], opts: &crate::ImportOptions, ctl: &Interrupt, min_bytes: u64) -> Result, IoError> { + import_png_streamed_with(name, bytes, opts, ctl, min_bytes, crate::memory::available) +} + +/// A PNG's rows becoming the tiles of its layer as they are decoded: the surface, and what turns a +/// decoded row into its pixels. Shared by [`import_png_streamed`] (all at once) and the stepped +/// import ([`crate::PngImport`], a bounded piece at a time), which give the same document. +pub(crate) struct PngStream { + surface: Surface, + header: codecs::PngHeader, + convert: RowConverter, +} + +impl PngStream { + /// Sets up the surface for rows of `header`, after checking the layer's tiles and a group of + /// rows as decoded and as converted against `available` (the memory the machine can still use, + /// when known): Linux overcommits, so the tiles would be allocated even when they can't be + /// backed, and writing them would have the process killed. + pub(crate) fn new(header: &codecs::PngHeader, available: Option) -> Result { + let (_, target_layout, _, csample) = flat_target(header.layout, header.sample); + // The layer's tiles, plus one group of rows as decoded and as converted into them. + let bpp = surface_format(target_layout, csample).bytes_per_pixel() as u64; + let src_px = (header.layout.channels() * header.sample.bytes()) as u64; + let group = u64::from(header.width).saturating_mul(u64::from(header.height.min(TILE_SIZE as u32))); + let needed = tile_bytes(header.width, header.height, bpp).saturating_add(group.saturating_mul(src_px)).saturating_add(group.saturating_mul(bpp)); + crate::memory::check_fits(header.width, header.height, needed, available)?; + Ok(Self { + surface: Surface::new(surface_format(target_layout, csample)), + header: *header, + convert: RowConverter::new((header.layout, header.sample), (target_layout, csample)), + }) + } + + /// [`PngStream::new`] for a stepped import, which streams every PNG whose rows come in order + /// and not only the big ones: a layer under [`STREAM_MIN_BYTES`] of tiles is not checked + /// against free memory, as the whole-image path leaves it unchecked ([`check_layer_fits`]). + pub(crate) fn for_steps(header: &codecs::PngHeader) -> Result { + let (_, target_layout, _, csample) = flat_target(header.layout, header.sample); + let tiles = tile_bytes(header.width, header.height, surface_format(target_layout, csample).bytes_per_pixel() as u64); + Self::new(header, if tiles < STREAM_MIN_BYTES { None } else { crate::memory::available() }) + } + + /// Converts the whole rows `y0..` in `data` into the tiles, checking `ctl` as it goes. + pub(crate) fn write_rows(&mut self, y0: u32, data: &[u8], ctl: &Interrupt) -> Result<(), IoError> { + let (w, h) = (self.header.width, self.header.height); + let convert = &self.convert; + let row_bytes = (w as usize).checked_mul(convert.src_px).filter(|&b| b > 0).ok_or_else(|| IoError::Unsupported("image too large".into()))?; + let n = u32::try_from(data.len() / row_bytes).unwrap_or(0); + if n == 0 || !data.len().is_multiple_of(row_bytes) { + return Err(IoError::Unsupported("the decoded rows do not match the image size".into())); + } + let to_i32 = |v: u32| i32::try_from(v).map_err(|_| IoError::Unsupported("image too large".into())); + let rect = Rect::new(0, to_i32(y0)?, to_i32(w)?, to_i32(y0.saturating_add(n))?); + let failed = std::sync::atomic::AtomicBool::new(false); + self.surface + .try_write_rows_with_interrupt(rect, ctl, |y, x0, dst| { + if !convert.fill(data, w as usize, y - rect.y0, x0, dst) { + failed.store(true, std::sync::atomic::Ordering::Relaxed); + } + }) + .map_err(|e| match e { + RowWriteError::Cancelled(_) => IoError::Cancelled, + RowWriteError::Allocation(ae) => alloc_error(ae, w, h), + })?; + if failed.load(std::sync::atomic::Ordering::Relaxed) { + return Err(IoError::Unsupported("the decoded image does not match its size".into())); + } + Ok(()) + } + + /// Whether the layer is scanned for tiles of only the default pixel: only a source with alpha + /// has any. + pub(crate) fn scans(&self) -> bool { + self.header.layout.has_alpha() + } + + /// Drops the tiles that hold only the default pixel, all at once. + pub(crate) fn prune(&mut self) { + if self.scans() { + self.surface.prune(); + } + } + + /// Drops one bounded batch of those tiles from where `cursor` left off; true when done. + pub(crate) fn prune_step(&mut self, cursor: &mut photocraft_raster::PruneCursor) -> bool { + !self.scans() || self.surface.prune_batch(cursor, PRUNE_BATCH) + } + + /// A cursor for [`PngStream::prune_step`]. + pub(crate) fn prune_cursor(&self) -> photocraft_raster::PruneCursor { + self.surface.prune_cursor() + } + + /// The document of the layer, with the profile and metadata the file ended with. + pub(crate) fn into_document(self, name: &str, extras: codecs::PngExtras) -> Result { + let Self { surface, header, .. } = self; + let (mode, _, depth, _) = flat_target(header.layout, header.sample); + let warnings = extras.warnings.iter().map(ToString::to_string).collect(); + flat_document(name, (header.width, header.height, header.layout, mode, depth), surface, extras.icc, &extras.meta, warnings, None) + } +} + +/// [`import_png_streamed`], with the memory the machine can still use given (see +/// [`crate::memory::available`]): `None` when it isn't known. +fn import_png_streamed_with( + name: &str, + bytes: &[u8], + opts: &crate::ImportOptions, + ctl: &Interrupt, + min_bytes: u64, + available: impl FnOnce() -> Option, +) -> Result, IoError> { + // Rows are worked off 0.02..0.55 so a fallback to the whole-image path, which reports 0.6 + // after its decode, never takes progress backwards. + let state = std::cell::RefCell::new(None::); + let mut available = Some(available); + let busy = || IoError::Unsupported("the PNG reader re-entered itself".into()); + let mut begin = |header: &codecs::PngHeader| -> Result { + // Decoded size, from the header; a size that overflows is certainly large enough. + let bytes = u64::from(header.width) + .checked_mul(u64::from(header.height)) + .and_then(|px| px.checked_mul((header.layout.channels() * header.sample.bytes()) as u64)); + if bytes.is_some_and(|b| b < min_bytes) { + return Ok(false); + } + // A cancelled import is reported as such, not as the memory it would have needed. + ctl.check().map_err(|_| IoError::Cancelled)?; + *state.try_borrow_mut().map_err(|_| busy())? = Some(PngStream::new(header, available.take().and_then(|f| f()))?); + ctl.progress(0.02); + Ok(true) + }; + let mut rows = |y0: u32, data: &[u8]| -> Result<(), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut guard = state.try_borrow_mut().map_err(|_| busy())?; + let Some(stream) = guard.as_mut() else { return Err(busy()) }; + stream.write_rows(y0, data, ctl)?; + let (h, n) = (stream.header.height, u32::try_from(data.len() / (stream.header.width as usize * stream.convert.src_px).max(1)).unwrap_or(0)); + ctl.progress(0.02 + 0.53 * (y0 + n) as f32 / h.max(1) as f32); + Ok(()) + }; + let Some(extras) = codecs::decode_png_rows::( + bytes, + &codecs::DecodeOptions { limits: decode_limits(opts), ..Default::default() }, + TILE_SIZE as u32, + &mut begin, + &mut rows, + )? + else { + return Ok(None); + }; + ctl.check().map_err(|_| IoError::Cancelled)?; + let Some(mut stream) = state.into_inner() else { return Ok(None) }; + if extras.orientation != 1 { + return Ok(None); + } + ctl.progress(0.95); + stream.prune(); + stream.into_document(name, extras).map(Some) +} + +/// The pixels of a decoded image as a surface of the target layout and sample type, converted +/// straight from the decoded rows into the tiles (on all cores), so there is no second +/// full-size copy of the image and no staging band. /// The document of one layer holding `s`, with the image's profile and metadata. #[allow(clippy::too_many_arguments)] fn single_layer_document( @@ -610,6 +1086,34 @@ fn single_layer_document( /// The pixels of a decoded image as a surface of `fmt`, converting layout and sample type a /// band of rows at a time so no second full-size copy of the image is made. pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: CSample) -> Result { + image_surface_with(img, target_layout, csample, &Interrupt::NONE) +} + +fn image_surface_with(img: &Image, target_layout: ChannelLayout, csample: CSample, ctl: &Interrupt) -> Result { + let csample = if csample == CSample::F16 { CSample::F32 } else { csample }; + let (w, h) = img.dimensions(); + let mut s = Surface::new(surface_format(target_layout, csample)); + let convert = RowConverter::new((img.layout(), img.sample_type()), (target_layout, csample)); + let data = img.data(); + let failed = std::sync::atomic::AtomicBool::new(false); + let (Ok(cw), Ok(ch)) = (i32::try_from(w), i32::try_from(h)) else { return Err(IoError::Unsupported("image too large".into())) }; + let written = s.try_write_rows_with_interrupt(Rect::new(0, 0, cw, ch), ctl, |y, x0, dst| { + if !convert.fill(data, w as usize, y, x0, dst) { + failed.store(true, std::sync::atomic::Ordering::Relaxed); + } + }); + written.map_err(|e| match e { + RowWriteError::Cancelled(_) => IoError::Cancelled, + RowWriteError::Allocation(ae) => alloc_error(ae, w, h), + })?; + if failed.load(std::sync::atomic::Ordering::Relaxed) { + return Err(IoError::Unsupported("the decoded image does not match its size".into())); + } + Ok(s) +} + +/// The pixel format of a surface holding `target_layout` pixels of `csample`. +fn surface_format(target_layout: ChannelLayout, csample: CSample) -> PixelFormat { let mode = match target_layout { ChannelLayout::Gray | ChannelLayout::GrayA => ColorMode::Grayscale, ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk, @@ -621,29 +1125,85 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: CSample::U16 => SampleType::U16, _ => SampleType::F32, }; - let fmt = PixelFormat::new(mode, depth, true); - let (w, h) = img.dimensions(); - let mut s = Surface::new(fmt); - if img.layout() == target_layout && img.sample_type() == csample { - s.write_interleaved(Rect::new(0, 0, w as i32, h as i32), img.data()); - } else { - let row = img.data().len() / (h.max(1) as usize); - let band = (BAND_BYTES / row.max(1)).max(1); - for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() { - let n = (rows.len() / row.max(1)) as u32; - // Band buffers cycle through the scratch pool: at this size each fresh one is an - // mmap that gets page-faulted in. - let mut src = photocraft_raster::scratch::take_cap::(rows.len()); - src.extend_from_slice(rows); - let src = Image::from_raw(w, n, img.layout(), img.sample_type(), src)?; - let part = src.convert(target_layout, csample); - let y0 = (i * band) as i32; - s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data()); - photocraft_raster::scratch::give(src.into_data()); - photocraft_raster::scratch::give(part.into_data()); + PixelFormat::new(mode, depth, true) +} + +/// The conversion of decoded rows into the pixels of a surface: an exact copy, the opaque alpha a +/// source without one gets, or the codec's own sample conversion. +struct RowConverter { + from: (ChannelLayout, CSample), + to: (ChannelLayout, CSample), + /// Bytes per source pixel. + src_px: usize, + dst_px: usize, + /// The opaque alpha an image without one gets, when only that differs. + one: Option<([u8; 4], usize)>, +} + +impl RowConverter { + fn new(from: (ChannelLayout, CSample), to: (ChannelLayout, CSample)) -> Self { + let (sb, db) = (from.1.bytes(), to.1.bytes()); + let (src_px, dst_px) = (from.0.channels() * sb, to.0.channels() * db); + let one = (from.1 == to.1 && !from.0.has_alpha() && to.0 == from.0.with_alpha()).then(|| { + let mut one = [0u8; 4]; + match to.1 { + CSample::U8 => one[0] = u8::MAX, + CSample::U16 => one[..2].copy_from_slice(&u16::MAX.to_ne_bytes()), + _ => one.copy_from_slice(&1.0f32.to_ne_bytes()), + } + (one, db) + }); + Self { from, to, src_px, dst_px, one } + } + + /// Converts the pixels of row `y` from column `x0` that fill `dst` out of `data`, rows of + /// `width` pixels (`y` counts from the first row in `data`). False when `data` is too short. + fn fill(&self, data: &[u8], width: usize, y: i32, x0: i32, dst: &mut [u8]) -> bool { + let n = dst.len() / self.dst_px.max(1); + let span = usize::try_from(y) + .ok() + .zip(usize::try_from(x0).ok()) + .and_then(|(y, x)| (y.checked_mul(width)?.checked_add(x)?).checked_mul(self.src_px)) + .and_then(|o| data.get(o..o.checked_add(n.checked_mul(self.src_px)?)?)); + let Some(src) = span else { return false }; + let (src_px, dst_px) = (self.src_px, self.dst_px); + if self.from == self.to { + dst.copy_from_slice(src); + } else if let Some((one, bytes)) = self.one { + if (src_px, bytes, one[0]) == (3, 1, u8::MAX) { + // RGB8 to RGBA8, what nearly every JPEG and many PNGs are. + for (d, p) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<3>().0) { + *d = [p[0], p[1], p[2], u8::MAX]; + } + } else { + for (d, p) in dst.chunks_exact_mut(dst_px).zip(src.chunks_exact(src_px)) { + if let Some((color, alpha)) = d.split_at_mut_checked(src_px) { + color.copy_from_slice(p); + alpha.copy_from_slice(one.get(..bytes).unwrap_or_default()); + } + } + } + } else { + // Everything else (16-bit float to 32-bit float): the codec's own conversion of this row span. + match codecs::convert_samples(src, n as u32, 1, self.from, self.to) { + Ok(out) if out.len() == dst.len() => dst.copy_from_slice(&out), + _ => return false, + } } + true + } +} + +/// The error an out-of-memory or oversized surface allocation for a `w`×`h` image is reported as. +/// The failed reservation is usually one tile or row band, not the image, so the message names +/// the image by its pixel size rather than by the bytes that could not be reserved. +fn alloc_error(e: AllocationError, w: u32, h: u32) -> IoError { + match e { + AllocationError::ScratchRead(e) => IoError::Unsupported(format!("could not read scratch pixels: {e}")), + AllocationError::LengthMismatch { .. } => IoError::Unsupported("the decoded image does not match its size".into()), + AllocationError::SizeOverflow => IoError::Unsupported(format!("the {w}×{h} image is too large to open")), + AllocationError::NotEnoughMemory { .. } => IoError::Unsupported(format!("not enough memory to open the {w}×{h} image")), } - Ok(s) } /// `Some(surface)` when the document is exactly one visible, unmasked, @@ -695,12 +1255,31 @@ pub(crate) fn csample(s: SampleType) -> CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { - document_to_image_for(doc, warnings, true, false) + document_to_image_with_renderer(doc, warnings, None) } -/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels. -fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool, lab_target: bool) -> Result { +/// [`document_to_image`] with an optional custom [`BandRenderer`]. +pub fn document_to_image_with_renderer(doc: &Document, warnings: &mut Vec, renderer: Option<&mut dyn BandRenderer>) -> Result { + document_to_image_for_with_renderer(doc, warnings, true, false, &Interrupt::NONE, renderer) +} + +/// Reported in the export's own warnings (not through the renderer's diagnostics, which a custom +/// renderer may ignore) when a single plain layer is copied natively although a renderer was given. +const NATIVE_COPY_CPU_NOTICE: &str = + "custom renderer unsupported: a single-layer export copies the document's own pixels with the CPU path; falling back to CPU"; + +fn document_to_image_for_with_renderer( + doc: &Document, + warnings: &mut Vec, + cmyk_target: bool, + lab_target: bool, + ctl: &Interrupt<'_>, + renderer: Option<&mut dyn BandRenderer>, +) -> Result { let src = FlatSource::new(doc, warnings, cmyk_target, lab_target)?; + if renderer.is_some() && src.native.is_some() { + warnings.push(NATIVE_COPY_CPU_NOTICE.into()); + } let (w, h) = (doc.size.width, doc.size.height); // The single layer's alpha is dropped up front when it is opaque. A composite is rendered // with alpha, dropped afterwards, in place, when every pixel turned out opaque. @@ -708,7 +1287,7 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target let bytes = src.sample.bytes(); let mut data = try_buffer((w as usize) * (h as usize), src.layout(alpha).channels() * bytes)?; let mut opaque = true; - src.bands(alpha, |_, band, translucent| { + src.bands(alpha, ctl, renderer, |_, band, translucent| { opaque &= !translucent; data.extend_from_slice(&band); Ok(()) @@ -721,6 +1300,61 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target Ok(Image::from_raw(w, h, layout, src.sample, data)?.with_icc(src.icc.clone()).with_meta(src.meta())) } +/// The document composited as an 8-bit RGBA image, through `renderer` when one is supplied. +fn document_to_preview_with_renderer(doc: &Document, warnings: &mut Vec, renderer: Option<&mut dyn BandRenderer>) -> Result { + let src = FlatSource::preview(doc, warnings)?; + let (w, h) = (doc.size.width, doc.size.height); + let pixels = (w as usize).checked_mul(h as usize).ok_or_else(|| IoError::Unsupported("image too large".into()))?; + let mut data = try_buffer(pixels, ChannelLayout::Rgba.channels())?; + src.bands(true, &Interrupt::NONE, renderer, |_, band, _| { + data.extend_from_slice(&band); + Ok(()) + })?; + Ok(Image::from_raw(w, h, ChannelLayout::Rgba, CSample::U8, data)?) +} + +/// A full-size preview as an 8-bit RGBA PNG: what [`photocraft_compose::thumbnail`] returns at +/// the document's own size, rendered through `renderer` when one is supplied. Keeping previews +/// at 8-bit RGBA stops a 16-bit document's preview from outgrowing the preview size limit; saved +/// exports still keep the document's native depth. Like the CPU preview, it carries no export +/// metadata (EXIF, XMP or resolution). +pub fn export_preview_png_with_renderer(doc: &Document, renderer: Option<&mut dyn BandRenderer>) -> Result { + if let Some(r) = renderer { + let mut warnings = Vec::new(); + match document_to_preview_with_renderer(doc, &mut warnings, Some(&mut *r)) { + Ok(img) => { + // Leave queued diagnostics in the renderer when encoding fails, as a flat + // export does, so the caller can still report the fallback reason. + let mut res = encode_image(&img, Format::Png, &ExportOptions::default(), warnings)?; + res.warnings.extend(r.drain_diagnostics()); + return Ok(res); + } + Err(IoError::Render(render_err)) if render_err.is_retryable() => { + let fallback_warning = format!("custom renderer unavailable ({render_err}); falling back to CPU"); + // Keep the fallback reason in this export's warnings only. Move this attempt's + // queued diagnostics into its retry result too: leaving either on the renderer + // would leak them into a later successful GPU export. If the retry fails, `?` + // returns the CPU error and the fallback reason is queued on the renderer for the caller. + let mut cpu_result = match export_preview_png_with_renderer(doc, None) { + Ok(res) => res, + Err(e) => { + // Keep the reason the CPU retry happened queued on the renderer, so the caller reports it next to the CPU error. + r.push_diagnostic(fallback_warning); + return Err(e); + } + }; + cpu_result.warnings.extend(r.drain_diagnostics()); + cpu_result.warnings.push(fallback_warning); + return Ok(cpu_result); + } + Err(e) => return Err(e), + } + } + let mut warnings = Vec::new(); + let img = document_to_preview_with_renderer(doc, &mut warnings, None)?; + encode_image(&img, Format::Png, &ExportOptions::default(), warnings) +} + /// How a document becomes flat codec pixels, a band of rows at a time: [`document_to_image`] /// gathers the bands into one image, a streamed PNG export ([`export_png_streamed`]) encodes each /// band as it is rendered. @@ -796,6 +1430,20 @@ impl<'a> FlatSource<'a> { Ok(Self { doc, native: None, mode, sample, inks: false, space: None, icc, rows: 0 }) } + /// The full-size preview source: the composite as 8-bit RGBA whatever the document's model + /// and depth, matching [`photocraft_compose::thumbnail`] at the document's own size. Nothing + /// is written to `warnings`: previews do not report flattening or colour conversion, which + /// describe exports, not the image on screen. + fn preview(doc: &'a Document, _warnings: &mut Vec) -> Result { + let mut ignored = Vec::new(); + let mut src = Self::new(doc, &mut ignored, false, false)?; + src.native = None; + src.sample = CSample::U8; + src.mode = ColorMode::Rgb; + src.rows = 0; + Ok(src) + } + fn layout(&self, alpha: bool) -> ChannelLayout { layout_for(self.mode, alpha) } @@ -817,7 +1465,13 @@ impl<'a> FlatSource<'a> { /// whether a composite band held a pixel that isn't fully opaque (its alpha is dropped all /// the same when `alpha` is false). A single layer's band is never translucent without /// `alpha`: drop it only when [`opaque_surface`] says so. - fn bands(&self, alpha: bool, mut sink: impl FnMut(u32, Vec, bool) -> Result<(), IoError>) -> Result<(), IoError> { + fn bands( + &self, + alpha: bool, + ctl: &Interrupt<'_>, + mut renderer: Option<&mut dyn BandRenderer>, + mut sink: impl FnMut(u32, Vec, bool) -> Result<(), IoError>, + ) -> Result<(), IoError> { let doc = self.doc; let (w, h) = (doc.size.width, doc.size.height); let fmt = doc.pixel_format(); @@ -830,6 +1484,7 @@ impl<'a> FlatSource<'a> { let rows = if self.rows > 0 { self.rows } else { (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32 }; let mut y = 0; while y < h as i32 { + ctl.check().map_err(|_| IoError::Cancelled)?; let y1 = y.saturating_add(rows).min(h as i32); let mut band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); if !alpha { @@ -852,6 +1507,9 @@ impl<'a> FlatSource<'a> { let colors = self.layout(false).channels(); // Bands of quantised samples with alpha; the alpha is dropped here when not wanted. let mut emit = |rows: u32, parts: Vec>, translucent: bool| -> Result<(), IoError> { + // The CPU retry after a retryable GPU error composes through this sink with no + // renderer to poll `ctl` for it: check once per band, as `CpuRenderer` does. + ctl.check().map_err(|_| IoError::Cancelled)?; let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum()); for part in parts { data.extend_from_slice(&part); @@ -862,6 +1520,9 @@ impl<'a> FlatSource<'a> { sink(rows, data, translucent) }; if self.inks { + if let Some(r) = renderer.as_mut() { + r.push_diagnostic("custom renderer unsupported: CMYK ink mixing uses the CPU compositor; falling back to CPU".into()); + } // Mixed on ink values, rendered and quantised in bands. let rendered = photocraft_compose::cmyk::render_cmyk_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> { let translucent = band.px.iter().any(|p| p[4] < 1.0 || p[4].is_nan()); @@ -881,15 +1542,27 @@ impl<'a> FlatSource<'a> { let cmyk = self.mode == ColorMode::Cmyk; let gray = self.mode == ColorMode::Grayscale; let space = &self.space; - // Rendered and quantised in bands (no full-size float composite), with alpha. - photocraft_compose::render_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> { - let translucent = band.px.iter().any(|p| p[3] < 1.0 || p[3].is_nan()); - let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { + let mut expected_y = 0i32; + let mut render_one_band = |rect: Rect, px: Vec<[f32; 4]>| -> Result<(), IoError> { + if rect.x0 != 0 || rect.width() != w || rect.y0 != expected_y || rect.y1 <= rect.y0 || rect.y1 > h as i32 { + return Err(IoError::Render(RenderError::Execution(format!( + "invalid band geometry: expected y={expected_y}.. to span full canvas width {w}, got rect {rect:?}" + )))); + } + let expected_px = (rect.width() as usize) + .checked_mul(rect.height() as usize) + .ok_or_else(|| IoError::Render(RenderError::Execution("band pixel count overflow".into())))?; + if px.len() != expected_px { + return Err(IoError::Render(RenderError::Execution(format!("band pixel count mismatch: expected {expected_px}, got {}", px.len())))); + } + expected_y = rect.y1; + let translucent = px.iter().any(|p| p[3] < 1.0 || p[3].is_nan()); + let parts = crate::pixels::par_map(crate::pixels::bands(px.len()), |range| { // A CMYK composite came through the document's CMYK profile: convert back through it. photocraft_color::convert::with_cmyk_space(space.as_ref(), || { let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); let mut converted = [0.0; 5]; - for p in band.px.get(range).unwrap_or_default() { + for p in px.get(range).unwrap_or_default() { if cmyk { let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); for value in converted.iter().take(channels) { @@ -908,8 +1581,18 @@ impl<'a> FlatSource<'a> { out }) }); - emit(band.rect.height(), parts, translucent) - }) + emit(rect.height(), parts, translucent) + }; + + if let Some(r) = renderer { + r.render_bands(doc, doc.bounds(), self.rows, ctl, &mut |band| render_one_band(band.rect, band.pixels))?; + } else { + photocraft_compose::render_bands(doc, doc.bounds(), self.rows, |band| render_one_band(band.rect, band.into_px()))?; + } + if expected_y != h as i32 { + return Err(IoError::Render(RenderError::Execution(format!("incomplete bands: covered up to y={expected_y}, expected canvas height {h}")))); + } + Ok(()) } } @@ -972,34 +1655,109 @@ fn composite_share(format: Format) -> f32 { /// Flattens and encodes as `format`. pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Result { - export_flat_with(doc, format, opts, &photocraft_raster::Interrupt::NONE) + export_flat_with_renderer(doc, format, opts, &photocraft_raster::Interrupt::NONE, None) } /// [`export_flat`] reporting a coarse step through `ctl` once the composite is rendered; the /// encoder does the rest (#3389). pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - if let Some(r) = export_mode_specific(doc, format, opts)? { + export_flat_with_renderer(doc, format, opts, ctl, None) +} + +/// [`export_flat`] with an optional custom [`BandRenderer`] and complete CPU retry upon render failure. +pub fn export_flat_with_renderer( + doc: &Document, + format: Format, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + mut renderer: Option<&mut dyn BandRenderer>, +) -> Result { + if let Some(mut r) = export_mode_specific(doc, format, opts)? { + // Palette PNG/BMP and Duotone exports render on their own paths before the renderer + // branch: say so, or `--gpu` would silently do nothing for them. + if let Some(renderer) = &mut renderer { + let why = match doc.mode { + ColorMode::Indexed => "palette (Indexed Color) PNG/BMP export uses the CPU compositor", + ColorMode::Duotone => "Duotone export uses the CPU compositor", + _ => "this export uses the CPU compositor", + }; + renderer.push_diagnostic(format!("custom renderer unsupported: {why}; falling back to CPU")); + r.warnings.extend(renderer.drain_diagnostics()); + } return Ok(r); } if format == Format::Png && streams_png(doc) { - return export_png_streamed(doc, opts, ctl, 0); + return export_png_streamed_with_renderer(doc, opts, ctl, 0, renderer); } + + if let Some(r) = renderer { + let mut warnings = Vec::new(); + let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); + let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); + match document_to_image_for_with_renderer(doc, &mut warnings, cmyk_target, lab_target, ctl, Some(r)) { + Ok(mut img) => { + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(composite_share(format)); + strip_xmp(&mut img.meta, opts); + let mut img = prepare_flat(img, format, &mut warnings)?; + if format == Format::Tga { + img = tga_image(doc, img, opts.tga_bits, opts.preserve_alpha, &mut warnings)?; + } + if format == Format::Dds { + img = dds_image(doc, img, &mut warnings)?; + } + if format == Format::Tiff { + warnings.extend(crate::tiff_layers::flat_transparency_and_channels(doc, &mut img, opts.tiff_transparency, opts.preserve_alpha)?); + } + // Drain only once encoding succeeded: if it fails, the queued diagnostics stay + // with the renderer for the caller to report alongside the error. + let mut res = encode_image(&img, format, opts, warnings)?; + res.warnings.extend(r.drain_diagnostics()); + return Ok(res); + } + Err(IoError::Render(render_err)) if render_err.is_retryable() => { + let fallback_warning = format!("custom renderer unavailable ({render_err}); falling back to CPU"); + // Keep the fallback reason in this export's warnings only. Move this attempt's + // queued diagnostics into its retry result too: leaving either on the renderer + // would leak them into a later successful GPU export. If the retry fails, `?` + // returns the CPU error and the fallback reason is queued on the renderer for the caller. + // A cancelled export stops before compositing the document again. + if ctl.check().is_err() { + r.push_diagnostic(fallback_warning); + return Err(IoError::Cancelled); + } + let mut cpu_result = match export_flat_with_renderer(doc, format, opts, ctl, None) { + Ok(res) => res, + Err(e) => { + // Keep the reason the CPU retry happened queued on the renderer, so the caller reports it next to the CPU error. + r.push_diagnostic(fallback_warning); + return Err(e); + } + }; + cpu_result.warnings.extend(r.drain_diagnostics()); + cpu_result.warnings.push(fallback_warning); + return Ok(cpu_result); + } + Err(e) => return Err(e), + } + } + let mut warnings = Vec::new(); let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); - let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?; + let mut img = document_to_image_for_with_renderer(doc, &mut warnings, cmyk_target, lab_target, ctl, None)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(composite_share(format)); strip_xmp(&mut img.meta, opts); let mut img = prepare_flat(img, format, &mut warnings)?; if format == Format::Tga { - img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; + img = tga_image(doc, img, opts.tga_bits, opts.preserve_alpha, &mut warnings)?; } if format == Format::Dds { img = dds_image(doc, img, &mut warnings)?; } if format == Format::Tiff { - warnings.extend(crate::tiff_layers::flat_transparency_and_channels(doc, &mut img, opts.tiff_transparency)); + warnings.extend(crate::tiff_layers::flat_transparency_and_channels(doc, &mut img, opts.tiff_transparency, opts.preserve_alpha)?); } encode_image(&img, format, opts, warnings) } @@ -1080,18 +1838,75 @@ fn streams_png(doc: &Document) -> bool { /// when every pixel turns out opaque (that is only known once the last row is written). /// /// `rows`: rows per band, 0 to size them to the document. -fn export_png_streamed(doc: &Document, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt, rows: i32) -> Result { - // A wrong guess (a knockout or a blend mode the guess doesn't foresee) shows in a band and - // the export starts again with alpha. +#[cfg(test)] +pub(crate) fn export_png_streamed(doc: &Document, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt, rows: i32) -> Result { + export_png_streamed_with_renderer(doc, opts, ctl, rows, None) +} + +/// [`export_png_streamed`] with an optional custom [`BandRenderer`] and complete CPU retry upon render failure. +pub fn export_png_streamed_with_renderer( + doc: &Document, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + rows: i32, + renderer: Option<&mut dyn BandRenderer>, +) -> Result { + if let Some(r) = renderer { + let opaque = match single_layer(doc) { + Some(s) => opaque_surface(s, doc.bounds()), + None => opaque_bottom(doc), + }; + let mut progress = 0.0; + let first_try = stream_png_with_renderer(doc, opts, ctl, !opaque, rows, &mut progress, Some(&mut *r)); + let attempt = match first_try { + Ok(Some(res)) => Ok(Some(res)), + Ok(None) => { + // Alpha was mispredicted: retry with alpha enabled, retaining the GPU provider! + stream_png_with_renderer(doc, opts, ctl, true, rows, &mut progress, Some(&mut *r)) + } + Err(e) => Err(e), + }; + match attempt { + Ok(Some(mut res)) => { + res.warnings.extend(r.drain_diagnostics()); + return Ok(res); + } + Ok(None) => return Err(IoError::Unsupported("PNG export lost its alpha channel".into())), + Err(IoError::Render(render_err)) if render_err.is_retryable() => { + let fallback_warning = format!("custom renderer unavailable ({render_err}); falling back to CPU"); + // Keep the fallback reason in this export's warnings only. Move this attempt's + // queued diagnostics into its retry result too: leaving either on the renderer + // would leak them into a later successful GPU export. If the retry fails, `?` + // returns the CPU error and the fallback reason is queued on the renderer for the caller. + // A cancelled export stops before streaming the PNG again. + if ctl.check().is_err() { + r.push_diagnostic(fallback_warning); + return Err(IoError::Cancelled); + } + let mut cpu_result = match export_png_streamed_with_renderer(doc, opts, ctl, rows, None) { + Ok(res) => res, + Err(e) => { + // Keep the reason the CPU retry happened queued on the renderer, so the caller reports it next to the CPU error. + r.push_diagnostic(fallback_warning); + return Err(e); + } + }; + cpu_result.warnings.extend(r.drain_diagnostics()); + cpu_result.warnings.push(fallback_warning); + return Ok(cpu_result); + } + Err(e) => return Err(e), + } + } let opaque = match single_layer(doc) { Some(s) => opaque_surface(s, doc.bounds()), None => opaque_bottom(doc), }; - // Progress never goes back: a second pass reports nothing new until it passes the first. let mut progress = 0.0; - match stream_png(doc, opts, ctl, !opaque, rows, &mut progress)? { + match stream_png_with_renderer(doc, opts, ctl, !opaque, rows, &mut progress, None)? { Some(r) => Ok(r), - None => stream_png(doc, opts, ctl, true, rows, &mut progress)?.ok_or_else(|| IoError::Unsupported("PNG export lost its alpha channel".into())), + None => stream_png_with_renderer(doc, opts, ctl, true, rows, &mut progress, None)? + .ok_or_else(|| IoError::Unsupported("PNG export lost its alpha channel".into())), } } @@ -1104,6 +1919,7 @@ fn opaque_bottom(doc: &Document) -> bool { /// [`export_png_streamed`] with or without `alpha`, in bands of `rows` rows (0: sized to the /// document). `None` when a translucent pixel shows up without `alpha`. `progress` is the most /// reported so far, by this pass or an earlier one. +#[cfg(test)] fn stream_png( doc: &Document, opts: &ExportOptions, @@ -1111,6 +1927,18 @@ fn stream_png( alpha: bool, rows: i32, progress: &mut f32, +) -> Result, IoError> { + stream_png_with_renderer(doc, opts, ctl, alpha, rows, progress, None) +} + +fn stream_png_with_renderer( + doc: &Document, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + alpha: bool, + rows: i32, + progress: &mut f32, + renderer: Option<&mut dyn BandRenderer>, ) -> Result, IoError> { let format = Format::Png; let (w, h) = (doc.size.width, doc.size.height); @@ -1118,6 +1946,9 @@ fn stream_png( let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); let src = FlatSource { rows, ..FlatSource::new(doc, &mut warnings, cmyk_target, lab_target)? }; + if renderer.is_some() && src.native.is_some() { + warnings.push(NATIVE_COPY_CPU_NOTICE.into()); + } let mut encode = opts.encode.clone(); let mut late = Vec::new(); if encode.png_interlaced { @@ -1150,7 +1981,7 @@ fn stream_png( let mut bytes = Vec::new(); let mut png = codecs::PngRows::new(&mut bytes, &first, h, &encode)?; let (mut done, mut translucent) = (0u64, false); - let streamed = src.bands(alpha, |rows, data, see_through| { + let streamed = src.bands(alpha, ctl, renderer, |rows, data, see_through| { if see_through && !alpha { translucent = true; return Err(IoError::Cancelled); @@ -1189,7 +2020,7 @@ pub fn tga_default_bits(doc: &Document) -> TgaBits { /// never premultiplied or cleared under a zero alpha: Targa alphas often hold data such as a /// game texture's specular mask. Grayscale Targas have no alpha channel in Photoshop and are left /// as they are. -fn tga_image(doc: &Document, img: Image, bits: Option, warnings: &mut Vec) -> Result { +fn tga_image(doc: &Document, img: Image, bits: Option, preserve_alpha: bool, warnings: &mut Vec) -> Result { if !img.layout().is_rgb() { if let Some(bits) = bits { warnings.push(format!("{}-bit Targa applies to RGB images; the grayscale image is written as it is", bits.bits())); @@ -1198,6 +2029,9 @@ fn tga_image(doc: &Document, img: Image, bits: Option, warnings: &mut V } let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); let bits = bits.unwrap_or(if !alphas.is_empty() || img.layout().has_alpha() { TgaBits::Bits32 } else { TgaBits::Bits24 }); + if preserve_alpha && bits == TgaBits::Bits24 && (!alphas.is_empty() || img.layout().has_alpha()) { + return Err(IoError::Unsupported("Save and Close cannot preserve alpha in a 24-bit Targa; use 32 bits or a separate output folder".into())); + } match (bits, alphas.as_slice()) { (TgaBits::Bits24, alphas) => { if !alphas.is_empty() { @@ -1828,6 +2662,18 @@ mod streamed_png_tests { assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); } + /// The stream a stepped import builds refuses a layer that won't fit with the error the + /// one-shot streamed import gives, and leaves a small one unchecked, as the whole-image path does. + #[test] + fn a_stream_for_steps_keeps_the_memory_errors() { + let big = codecs::PngHeader { width: 20_000, height: 20_000, layout: ChannelLayout::Rgba, sample: CSample::U8 }; + let e = PngStream::new(&big, Some(1 << 20)).err().expect("refused"); + assert!(matches!(&e, IoError::Unsupported(m) if m.contains("not enough memory to open this 20000 × 20000 px image")), "{e}"); + let small = codecs::PngHeader { width: 64, height: 64, layout: ChannelLayout::Rgba, sample: CSample::U8 }; + assert!(PngStream::for_steps(&small).is_ok(), "a small layer is not checked"); + assert!(PngStream::new(&small, Some(1 << 20)).is_err(), "though the check itself would refuse it"); + } + #[test] fn only_large_documents_stream() { let doc = |w, h, depth| Document::new("size", Size::new(w, h), ColorMode::Rgb, depth); @@ -1863,14 +2709,14 @@ mod streamed_png_tests { let lowered = crate::ImportOptions { psd_max_pixel_bytes: 99, ..Default::default() }; match tiff_admission(100, 0, &lowered, &ctl) { - Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), + Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (100, 99)), other => panic!("expected the caller's limit to refuse, got {:?}", other.map(|_| ())), } assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(99), ..Default::default() }; match tiff_admission(100, 0, &stale, &ctl) { - Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), + Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (100, 99)), other => panic!("expected the approved estimate bound to refuse, got {:?}", other.map(|_| ())), } assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); @@ -1897,10 +2743,292 @@ mod streamed_png_tests { crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(u64::MAX), ..Default::default() }, ] { match tiff_admission(u64::MAX, 0, &opts, &Interrupt::NONE) { - Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)), + Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)), other => panic!("expected a hard refusal for {opts:?}, got {:?}", other.map(|_| ())), } } }); } } + +#[cfg(test)] +mod build_tests { + use super::*; + + /// The stepped builder gives exactly what [`image_to_document`] gives, for every layout, over + /// a surface of several tiles. + #[test] + fn a_stepped_build_equals_image_to_document() { + for (layout, sample) in + [(ChannelLayout::Gray, CSample::U8), (ChannelLayout::GrayA, CSample::U16), (ChannelLayout::Rgb, CSample::U8), (ChannelLayout::Rgba, CSample::U16)] + { + let len = 300 * 260 * layout.channels() * sample.bytes(); + let data: Vec = (0..len).map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8).collect(); + let mut img = Image::from_raw(300, 260, layout, sample, data).unwrap(); + img.meta.dpi = Some((300.0, 300.0)); + img.meta.text.push(("Comment".into(), "kept".into())); + let expected = image_to_document("a.png", &img).unwrap(); + let mut build = FlatBuilder::new("a.png", img).unwrap(); + while !build.copy_done() { + build.copy_step().unwrap(); + } + while !build.prune_step() {} + let got = build.finish(&[], None).unwrap(); + let (mut a, mut b) = (expected.document, got.document); + b.id = a.id; + for (x, y) in a.layers.iter_mut().zip(&mut b.layers) { + y.id = x.id; + } + assert!(a == b, "{layout:?} {sample:?}: the stepped build differs"); + assert_eq!(expected.warnings, got.warnings, "{layout:?} {sample:?}"); + } + } +} + +#[cfg(test)] +mod fused_tile_tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// The band-at-a-time conversion `image_surface` used before it wrote straight into the + /// tiles, kept as the oracle. `band_bytes` is [`BAND_BYTES`] in production; the test makes it + /// small so the bands are crossed at test sizes. + fn banded_surface(img: &Image, target_layout: ChannelLayout, csample: CSample, band_bytes: usize) -> Surface { + let mode = match target_layout { + ChannelLayout::Gray | ChannelLayout::GrayA => ColorMode::Grayscale, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => ColorMode::Lab, + _ => ColorMode::Rgb, + }; + let depth = match csample { + CSample::U8 => SampleType::U8, + CSample::U16 => SampleType::U16, + _ => SampleType::F32, + }; + let (w, h) = img.dimensions(); + let mut s = Surface::new(PixelFormat::new(mode, depth, true)); + if img.layout() == target_layout && img.sample_type() == csample { + s.write_interleaved(Rect::new(0, 0, w as i32, h as i32), img.data()); + } else { + let row = img.data().len() / (h.max(1) as usize); + let band = (band_bytes / row.max(1)).max(1); + for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() { + let n = (rows.len() / row.max(1)) as u32; + let part = Image::from_raw(w, n, img.layout(), img.sample_type(), rows.to_vec()).unwrap().convert(target_layout, csample); + let y0 = (i * band) as i32; + s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data()); + } + } + s + } + + /// Deterministic samples with the extremes (0, max, 1.0, negatives, out of range floats) mixed in. + fn sample_bytes(sample: CSample, n: usize) -> Vec { + let mut out = Vec::new(); + for i in 0..n { + let h = (i as u32).wrapping_mul(2_654_435_761) >> 7; + match sample { + CSample::U8 => out.push(match i % 11 { + 0 => 0, + 1 => u8::MAX, + _ => h as u8, + }), + CSample::U16 => out.extend_from_slice( + &(match i % 11 { + 0 => 0, + 1 => u16::MAX, + _ => h as u16, + }) + .to_ne_bytes(), + ), + CSample::F16 => out.extend_from_slice( + &(match i % 11 { + 0 => 0x0000, + 1 => 0x3c00, // 1.0 + 2 => 0xbc00, // -1.0 + 3 => 0x4000, // 2.0 + _ => (h as u16) & 0x7bff, + }) + .to_ne_bytes(), + ), + CSample::F32 => out.extend_from_slice( + &(match i % 11 { + 0 => 0.0, + 1 => 1.0, + 2 => -0.5, + 3 => 3.0, + _ => (h % 1024) as f32 / 1023.0, + }) + .to_ne_bytes(), + ), + } + } + out + } + + fn image(w: u32, h: u32, layout: ChannelLayout, sample: CSample) -> Image { + let n = w as usize * h as usize * layout.channels(); + Image::from_raw(w, h, layout, sample, sample_bytes(sample, n)).unwrap() + } + + fn target(layout: ChannelLayout, sample: CSample) -> (ChannelLayout, CSample) { + let layout = match layout { + ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA, + ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA, + ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA, + }; + (layout, if sample == CSample::F16 { CSample::F32 } else { sample }) + } + + fn assert_same(a: &Surface, b: &Surface, what: &str) { + assert_eq!(a.format(), b.format(), "{what}: format"); + assert_eq!(a.default_bytes(), b.default_bytes(), "{what}: default pixel"); + let tiles = |s: &Surface| s.tiles().map(|(c, t)| ((c.tx, c.ty), t.clone())).collect::>(); + let (ta, tb) = (tiles(a), tiles(b)); + assert_eq!(ta.keys().collect::>(), tb.keys().collect::>(), "{what}: tile set"); + assert!(ta == tb, "{what}: tile bytes"); + } + + #[test] + fn fused_write_equals_the_banded_conversion() { + let layouts = [ + ChannelLayout::Gray, + ChannelLayout::GrayA, + ChannelLayout::Rgb, + ChannelLayout::Rgba, + ChannelLayout::Cmyk, + ChannelLayout::CmykA, + ChannelLayout::Lab, + ChannelLayout::LabA, + ]; + let samples = [CSample::U8, CSample::U16, CSample::F16, CSample::F32]; + for &(w, h) in &[(1, 1), (1, 300), (255, 3), (257, 257), (513, 260)] { + for &layout in &layouts { + for &sample in &samples { + let img = image(w, h, layout, sample); + let (tl, ts) = target(layout, sample); + let fused = image_surface(&img, tl, ts).unwrap(); + // The band sizes: a band per row, a few rows, and the whole image. + for band in [1, 5000, BAND_BYTES] { + assert_same(&fused, &banded_surface(&img, tl, ts, band), &format!("{w}x{h} {layout:?} {sample:?} band {band}")); + } + } + } + } + } + + #[test] + fn an_f16_target_is_stored_as_f32_with_opaque_alpha() { + for &layout in &[ChannelLayout::Rgb, ChannelLayout::Rgba] { + for &sample in &[CSample::F16, CSample::F32] { + let img = image(257, 3, layout, sample); + let f16 = image_surface(&img, ChannelLayout::Rgba, CSample::F16).unwrap(); + let f32 = image_surface(&img, ChannelLayout::Rgba, CSample::F32).unwrap(); + assert_same(&f16, &f32, &format!("{layout:?} {sample:?} to an F16 target")); + } + } + let img = image(2, 2, ChannelLayout::Rgb, CSample::F32); + let s = image_surface(&img, ChannelLayout::Rgba, CSample::F16).unwrap(); + let (_, tile) = s.tiles().next().unwrap(); + let alpha = f32::from_ne_bytes(tile.bytes()[12..16].try_into().unwrap()); + assert_eq!(alpha, 1.0); + } + + #[test] + fn zero_sized_images_give_an_empty_surface() { + for &(w, h) in &[(0, 0), (0, 5), (5, 0)] { + let img = image(w, h, ChannelLayout::Rgb, CSample::U8); + let s = image_surface(&img, ChannelLayout::Rgba, CSample::U8).unwrap(); + assert_eq!(s.tile_count(), 0); + } + } + + fn pixels(w: u32, h: u32, alpha: bool) -> Image { + let layout = if alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; + let mut data = sample_bytes(CSample::U8, w as usize * h as usize * layout.channels()); + if alpha { + data.as_chunks_mut::<4>().0.iter_mut().enumerate().for_each(|(i, p)| p[3] = if i % 3 == 0 { 0 } else { 200 }); + } + Image::from_raw(w, h, layout, CSample::U8, data).unwrap() + } + + fn doc_pixels(r: &ImportResult) -> Vec { + let LayerContent::Raster(s) = &r.document.layers[0].content else { panic!("not a raster layer") }; + s.to_interleaved(r.document.bounds()) + } + + #[test] + fn opaque_and_alpha_files_open_as_background_and_layer_0() { + for format in [Format::Png, Format::Jpeg] { + for alpha in [false, true] { + if alpha && format == Format::Jpeg { + continue; + } + let img = pixels(300, 270, alpha); + let bytes = codecs::encode(&img, format, &codecs::EncodeOptions::default()).unwrap(); + let r = import_flat("t", &bytes).unwrap(); + let layer = &r.document.layers[0]; + assert_eq!(layer.name, if alpha { "Layer 0" } else { "Background" }, "{format:?}"); + assert_eq!(layer.locks.transparency, !alpha); + // The pixels are the decoded ones with an opaque alpha added. + let decoded = codecs::decode(&bytes).unwrap(); + let want = banded_surface(&decoded, ChannelLayout::Rgba, CSample::U8, BAND_BYTES).to_interleaved(r.document.bounds()); + assert_eq!(doc_pixels(&r), want, "{format:?} alpha {alpha}"); + if format == Format::Png && alpha { + assert_eq!(decoded.data(), img.data(), "lossless"); + } + } + } + } + + #[test] + fn a_cancelled_open_fails_with_cancelled() { + let img = pixels(64, 64, false); + let bytes = codecs::encode(&img, Format::Png, &codecs::EncodeOptions::default()).unwrap(); + let yes = || true; + let r = import_flat_with("t", &bytes, &Interrupt::cancel_only(&yes)); + assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); + // Cancelled only once the decode is over: the tiles are not built. + let calls = AtomicU32::new(0); + let after_decode = || calls.fetch_add(1, Ordering::Relaxed) >= 1; + let r = import_flat_with("t", &bytes, &Interrupt::cancel_only(&after_decode)); + assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); + // TIFF checks cancellation before decoding, including for damaged input. + let tiff = codecs::encode(&img, Format::Tiff, &codecs::EncodeOptions::default()).unwrap(); + let r = import_flat_with("t", &tiff, &Interrupt::cancel_only(&yes)); + assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); + // Without cancellation, damaged input still reports its decode error. + let broken = tiff.get(..8).unwrap_or_default(); + let r = import_flat_with("t", broken, &Interrupt::cancel_only(&yes)); + assert!(matches!(r, Err(IoError::Cancelled))); + let r = import_flat_with("t", broken, &Interrupt::NONE); + assert!(matches!(&r, Err(e) if !matches!(e, IoError::Cancelled)), "{:?}", r.map(|_| ())); + } + + #[test] + fn progress_is_reported_and_never_goes_backwards() { + let bytes = codecs::encode(&pixels(64, 64, false), Format::Png, &codecs::EncodeOptions::default()).unwrap(); + let seen = std::sync::Mutex::new(Vec::new()); + let no = || false; + let progress = |f: f32| seen.lock().unwrap().push(f); + import_flat_with("t", &bytes, &Interrupt::new(&no, &progress)).unwrap(); + let seen = seen.into_inner().unwrap(); + assert!(seen.len() >= 2 && seen.windows(2).all(|w| w[0] <= w[1]), "{seen:?}"); + } + + #[test] + fn truncated_files_still_fail_or_warn_without_panicking() { + let png = codecs::encode(&pixels(40, 40, false), Format::Png, &codecs::EncodeOptions::default()).unwrap(); + let jpeg = codecs::encode(&pixels(40, 40, false), Format::Jpeg, &codecs::EncodeOptions::default()).unwrap(); + assert!(import_flat("t", png.get(..20).unwrap_or_default()).is_err(), "header-only PNG"); + assert!(import_flat("t", jpeg.get(..8).unwrap_or_default()).is_err(), "short JPEG"); + for cut in [png.len() / 2, png.len() - 5] { + let _ = import_flat("t", png.get(..cut).unwrap_or_default()); + } + } +} + +#[cfg(test)] +mod png_stream_tests; diff --git a/crates/io/src/flat/png_stream_tests.rs b/crates/io/src/flat/png_stream_tests.rs new file mode 100644 index 0000000000..1c9b2da921 --- /dev/null +++ b/crates/io/src/flat/png_stream_tests.rs @@ -0,0 +1,494 @@ +//! A PNG opened row by row into the tiles ([`import_png_streamed`]) gives the document the +//! whole-image path gives, for every file, and fails or falls back exactly where it must. + +use std::io::Write; +use std::sync::atomic::{AtomicU32, Ordering}; + +use photocraft_cms::Builtin; + +use super::*; + +fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = (data.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc = crc32fast::Hasher::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(&crc.finalize().to_be_bytes()); + out +} + +fn zlib(data: &[u8]) -> Vec { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() +} + +/// A PNG under construction: the colour type and depth, a palette and `tRNS`, and chunks before +/// and after the image data. +struct Png { + w: u32, + h: u32, + ctype: u8, + depth: u8, + palette: usize, + before: Vec>, + after: Vec>, +} + +impl Png { + fn new(w: u32, h: u32, ctype: u8, depth: u8) -> Self { + Png { w, h, ctype, depth, palette: 0, before: Vec::new(), after: Vec::new() } + } + + fn palette(mut self, n: usize) -> Self { + let colors: Vec = (0..n * 3).map(|i| (i * 37 + 11) as u8).collect(); + self.palette = n; + self.before.push(chunk(b"PLTE", &colors)); + self + } + + fn before(mut self, c: Vec) -> Self { + self.before.push(c); + self + } + + fn after(mut self, c: Vec) -> Self { + self.after.push(c); + self + } + + /// Rows of pseudo-random samples (palette indices below the palette size), filter type 0. + fn raw(&self) -> Vec { + let channels = match self.ctype { + 0 | 3 => 1, + 2 => 3, + 4 => 2, + _ => 4, + }; + let max = if self.ctype == 3 { self.palette as u32 } else { 1 << self.depth }; + let mut seed = 0x2545_f491u32 ^ self.w.wrapping_mul(31) ^ self.h; + let mut out = Vec::new(); + for _ in 0..self.h { + out.push(0); + let (mut acc, mut bits) = (0u32, 0u32); + for _ in 0..self.w * channels { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let v = (seed >> 8) % max; + if self.depth == 16 { + out.extend_from_slice(&(v as u16).to_be_bytes()); + } else { + acc = (acc << self.depth) | v; + bits += u32::from(self.depth); + if bits >= 8 { + out.push((acc >> (bits - 8)) as u8); + bits -= 8; + acc &= (1 << bits) - 1; + } + } + } + if bits > 0 { + out.push((acc << (8 - bits)) as u8); + } + } + out + } + + fn idat(&self) -> Vec { + chunk(b"IDAT", &zlib(&self.raw())) + } + + fn bytes(&self) -> Vec { + self.assemble(&self.idat()) + } + + fn assemble(&self, idat: &[u8]) -> Vec { + let mut ihdr = self.w.to_be_bytes().to_vec(); + ihdr.extend_from_slice(&self.h.to_be_bytes()); + ihdr.extend_from_slice(&[self.depth, self.ctype, 0, 0, 0]); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend(chunk(b"IHDR", &ihdr)); + self.before.iter().for_each(|c| out.extend_from_slice(c)); + out.extend_from_slice(idat); + self.after.iter().for_each(|c| out.extend_from_slice(c)); + out.extend(chunk(b"IEND", &[])); + out + } +} + +/// A big-endian EXIF block holding only an Orientation. +fn exif(orientation: u8) -> Vec { + let mut e = b"MM\x00\x2a\x00\x00\x00\x08\x00\x01\x01\x12\x00\x03\x00\x00\x00\x01\x00".to_vec(); + e.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0]); + chunk(b"eXIf", &e) +} + +fn whole(bytes: &[u8]) -> Result { + import_flat_impl("t", bytes, &Interrupt::NONE, None) +} + +/// `import_flat` with the stream forced on whatever the size. +fn forced(bytes: &[u8]) -> Result { + import_flat_impl("t", bytes, &Interrupt::NONE, Some(0)) +} + +fn streamed(bytes: &[u8]) -> Result, IoError> { + import_png_streamed("t", bytes, &crate::ImportOptions::default(), &Interrupt::NONE, 0) +} + +fn assert_same(a: &ImportResult, b: &ImportResult, what: &str) { + assert_eq!(a.warnings, b.warnings, "{what}: warnings"); + assert_eq!((a.source_read_only, a.preview_only), (b.source_read_only, b.preview_only), "{what}"); + let (da, db) = (&a.document, &b.document); + assert_eq!((da.size, da.mode, da.depth, &da.color_table), (db.size, db.mode, db.depth, &db.color_table), "{what}: shape"); + assert_eq!( + (&da.icc_profile, &da.metadata, da.resolution_dpi, &da.file_encoding), + (&db.icc_profile, &db.metadata, db.resolution_dpi, &db.file_encoding), + "{what}: metadata" + ); + assert_eq!(da.layers.len(), db.layers.len(), "{what}: layers"); + for (la, lb) in da.layers.iter().zip(&db.layers) { + assert_eq!((&la.name, la.locks, la.opacity, la.visible), (&lb.name, lb.locks, lb.opacity, lb.visible), "{what}: layer"); + let (LayerContent::Raster(sa), LayerContent::Raster(sb)) = (&la.content, &lb.content) else { panic!("{what}: not raster") }; + assert_eq!(sa.format(), sb.format(), "{what}: format"); + assert_eq!(sa.tile_count(), sb.tile_count(), "{what}: tile count (pruning)"); + assert!(sa.to_interleaved(da.bounds()) == sb.to_interleaved(db.bounds()), "{what}: pixels"); + } + // Everything else, but for the ids every document and layer gets fresh. + let mut db = db.clone(); + db.id = da.id; + db.layers.iter_mut().zip(&da.layers).for_each(|(l, k)| l.id = k.id); + assert!(da == &db, "{what}: documents differ"); +} + +/// Both paths agree on `bytes`, and the stream is the one that took it. +fn assert_streams(bytes: &[u8], what: &str) { + let want = whole(bytes).unwrap(); + // The stream took it (the palette and file encoding are added by `import_flat`), and the + // document that comes out is the whole-image one. + assert!(streamed(bytes).unwrap().is_some(), "{what}: did not stream"); + assert_same(&forced(bytes).unwrap(), &want, what); +} + +const SIZES: [(u32, u32); 5] = [(1, 1), (1, 300), (255, 3), (257, 257), (513, 520)]; + +#[test] +fn streamed_import_equals_the_whole_image_for_every_colour_type_and_depth() { + // (colour type, depth, palette size, tRNS) + let kinds: &[(u8, u8, usize, Option<&[u8]>)] = &[ + (0, 1, 0, None), + (0, 2, 0, None), + (0, 4, 0, None), + (0, 8, 0, None), + (0, 16, 0, None), + (0, 8, 0, Some(&[0, 77])), + (0, 16, 0, Some(&[0, 5])), + (4, 8, 0, None), + (4, 16, 0, None), + (2, 8, 0, None), + (2, 16, 0, None), + (2, 8, 0, Some(&[0, 1, 0, 2, 0, 3])), + (6, 8, 0, None), + (6, 16, 0, None), + (3, 1, 2, None), + (3, 2, 3, None), + (3, 4, 16, None), + (3, 8, 200, None), + (3, 8, 200, Some(&[255, 255, 0])), + (3, 4, 16, Some(&[128, 255])), + ]; + for &(ctype, depth, palette, trns) in kinds { + for (w, h) in SIZES { + let mut png = Png::new(w, h, ctype, depth); + if ctype == 3 { + png = png.palette(palette); + } + if let Some(t) = trns { + png = png.before(chunk(b"tRNS", t)); + } + assert_streams(&png.bytes(), &format!("type {ctype} depth {depth} palette {palette} trns {} {w}x{h}", trns.is_some())); + } + } +} + +#[test] +fn metadata_before_and_after_the_image_data_is_kept() { + let icc = Builtin::Srgb.profile().to_bytes(); + let mut iccp = b"srgb\0\0".to_vec(); + iccp.extend(zlib(&icc)); + let xmp = { + let mut x = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); + x.extend_from_slice(b""); + x + }; + let png = Png::new(300, 270, 2, 8) + .before(chunk(b"iCCP", &iccp)) + .before(chunk(b"pHYs", &[0, 0, 0x2e, 0x23, 0, 0, 0x2e, 0x23, 1])) + .before(chunk(b"tEXt", b"Author\0Someone")) + .after(chunk(b"iTXt", &xmp)) + .after(chunk(b"tEXt", b"Comment\0late text")) + .after(exif(1)); + let bytes = png.bytes(); + assert_streams(&bytes, "metadata"); + let d = forced(&bytes).unwrap().document; + assert!(d.icc_profile.is_some() && d.metadata.exif.is_some() && d.metadata.xmp.is_some()); + assert_eq!(d.metadata.text.len(), 2); + assert!((d.resolution_dpi - 300.0).abs() < 0.1, "{}", d.resolution_dpi); + // Pixels of the stream and the codec's own decode agree sample for sample. + let img = codecs::decode(&bytes).unwrap(); + assert_eq!((img.width(), img.height()), (300, 270)); +} + +#[test] +fn rows_arrive_in_tile_row_groups_and_match_the_whole_decode() { + for (ctype, depth) in [(2u8, 8u8), (0, 16), (6, 16)] { + let bytes = Png::new(70, 600, ctype, depth).bytes(); + let img = codecs::decode(&bytes).unwrap(); + let (mut got, mut starts) = (Vec::new(), Vec::new()); + let extras = codecs::decode_png_rows::( + &bytes, + &codecs::DecodeOptions::default(), + TILE_SIZE as u32, + &mut |h| { + assert_eq!((h.width, h.height, h.layout, h.sample), (70, 600, img.layout(), img.sample_type())); + Ok(true) + }, + &mut |y0, rows| { + starts.push(y0); + got.extend_from_slice(rows); + Ok(()) + }, + ) + .unwrap(); + assert!(extras.is_some()); + assert_eq!(starts, [0, 256, 512], "type {ctype} depth {depth}"); + assert!(got == img.data(), "type {ctype} depth {depth}"); + } +} + +#[test] +fn rows_arrive_in_groups_of_the_height_the_caller_asks_for() { + let bytes = Png::new(9, 30, 2, 8).bytes(); + let img = codecs::decode(&bytes).unwrap(); + // A group of 0 counts as 1; one taller than the image hands over every row at once. + for (group, want) in [(7, vec![0, 7, 14, 21, 28]), (0, (0..30).collect()), (1, (0..30).collect()), (30, vec![0]), (u32::MAX, vec![0])] { + let (mut got, mut starts) = (Vec::new(), Vec::new()); + let extras = codecs::decode_png_rows::(&bytes, &codecs::DecodeOptions::default(), group, &mut |_| Ok(true), &mut |y0, rows| { + starts.push(y0); + got.extend_from_slice(rows); + Ok(()) + }) + .unwrap(); + assert!(extras.is_some()); + assert_eq!(starts, want, "group {group}"); + assert!(got == img.data(), "group {group}"); + } +} + +#[test] +fn interlaced_and_animated_files_do_not_stream_and_open_as_before() { + let img = Image::from_raw(97, 63, ChannelLayout::Rgb, CSample::U8, (0..97 * 63 * 3).map(|i| (i * 7 % 251) as u8).collect()).unwrap(); + let interlaced = codecs::encode(&img, Format::Png, &codecs::EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap(); + assert!(streamed(&interlaced).unwrap().is_none()); + assert_same(&forced(&interlaced).unwrap(), &whole(&interlaced).unwrap(), "interlaced"); + + // An acTL before the image data makes it an APNG, whose first frame the whole path reads. + let bytes = Png::new(40, 40, 2, 8).before(chunk(b"acTL", &[0, 0, 0, 1, 0, 0, 0, 0])).bytes(); + let called = AtomicU32::new(0); + let r = codecs::decode_png_rows::( + &bytes, + &codecs::DecodeOptions::default(), + TILE_SIZE as u32, + &mut |_| { + called.store(1, Ordering::Relaxed); + Ok(true) + }, + &mut |_, _| { + called.store(2, Ordering::Relaxed); + Ok(()) + }, + ); + assert!(matches!(r, Ok(None)) && called.load(Ordering::Relaxed) == 0); + assert_same(&forced(&bytes).unwrap(), &whole(&bytes).unwrap(), "apng"); +} + +#[test] +fn an_orientation_after_the_image_data_falls_back_to_the_upright_whole_image() { + for o in [3u8, 6, 8] { + let bytes = Png::new(300, 20, 2, 8).after(exif(o)).bytes(); + assert!(streamed(&bytes).unwrap().is_none(), "orientation {o}"); + let got = forced(&bytes).unwrap(); + assert_same(&got, &whole(&bytes).unwrap(), "oriented"); + let want = if o == 3 { (300, 20) } else { (20, 300) }; + assert_eq!((got.document.size.width, got.document.size.height), want, "orientation {o}"); + } + // Orientation 1 streams. + assert_streams(&Png::new(300, 20, 2, 8).after(exif(1)).bytes(), "orientation 1"); +} + +#[test] +fn damaged_files_fail_or_open_exactly_as_with_the_whole_image() { + let png = Png::new(300, 600, 6, 8); + let good = png.bytes(); + let idat = png.idat(); + let at = good.windows(4).position(|w| w == b"IDAT").unwrap() - 4; + let mut files: Vec<(String, Vec)> = Vec::new(); + for cut in [20, 40, at + 10, at + 100, at + idat.len() / 2, at + idat.len() - 20, at + idat.len() - 1, at + idat.len(), good.len() - 12, good.len() - 5] { + files.push((format!("cut at {cut}"), good.get(..cut).unwrap_or_default().to_vec())); + } + for off in [at + 20, at + idat.len() / 2, at + idat.len() - 6, at + idat.len() - 2] { + let mut bad = good.clone(); + bad[off] ^= 0x55; + files.push((format!("flipped byte {off}"), bad)); + } + // Several IDAT chunks, the second one cut short of its CRC. + let zdata = &idat[8..idat.len() - 4]; + let (z1, z2) = zdata.split_at(zdata.len() / 2); + let split = [chunk(b"IDAT", z1), chunk(b"IDAT", z2)].concat(); + files.push(("split".into(), png.assemble(&split))); + files.push(("split, second cut".into(), png.assemble(&split[..split.len() - 30]))); + // Damage after the pixels is tolerated: a text chunk with a bad CRC, and a lone byte of junk. + let mut bad_crc = chunk(b"tEXt", b"a\0b"); + if let Some(last) = bad_crc.last_mut() { + *last ^= 1; + } + files.push(("bad chunk after the image".into(), Png::new(300, 600, 6, 8).after(bad_crc).bytes())); + let mut junk = good.clone(); + junk.truncate(good.len() - 12); + junk.push(7); + files.push(("junk after the image".into(), junk)); + let (mut errors, mut opened) = (0, 0); + for (what, bytes) in &files { + let (w, s, f) = (whole(bytes), streamed(bytes), forced(bytes)); + match (w, s, f) { + (Err(w), Err(s), Err(f)) => { + assert_eq!((w.to_string(), s.to_string()), (f.to_string(), f.to_string()), "{what}"); + errors += 1; + } + (Ok(w), Ok(Some(_)), Ok(f)) => { + assert_same(&f, &w, what); + opened += 1; + } + (w, s, f) => panic!("{what}: whole {:?}, streamed {:?}, import {:?}", w.map(|_| ()), s.map(|s| s.map(|_| ())), f.map(|_| ())), + } + } + assert!(errors > 0 && opened > 0, "errors {errors}, opened {opened}"); +} + +#[test] +fn files_too_small_for_their_size_are_refused_before_any_tile_exists() { + let stream = |bytes: &[u8]| { + let called = AtomicU32::new(0); + let r = codecs::decode_png_rows::( + bytes, + &codecs::DecodeOptions::default(), + TILE_SIZE as u32, + &mut |_| { + called.fetch_add(1, Ordering::Relaxed); + Ok(true) + }, + &mut |_, _| Ok(()), + ); + (r.map(|e| e.is_some()), called.load(Ordering::Relaxed)) + }; + // A decompression bomb: 60000 x 60000 declared, a handful of bytes of data. + let mut bomb = Png::new(60_000, 60_000, 6, 8); + bomb.h = 60_000; + let bomb = bomb.assemble(&chunk(b"IDAT", &zlib(&[0; 64]))); + let (r, called) = stream(&bomb); + assert!(matches!(r, Err(codecs::CodecError::LimitExceeded(_))), "{r:?}"); + assert_eq!(called, 0); + assert!(streamed(&bomb).is_err() && whole(&bomb).is_err()); + // Nothing after the header at all. + let short = Png::new(2000, 2000, 6, 8).bytes(); + let short = short.get(..33).unwrap_or_default(); + let (r, called) = stream(short); + assert!(r.is_err() && called == 0, "{r:?}"); + assert!(streamed(short).is_err() && whole(short).is_err()); +} + +#[test] +fn a_cancelled_stream_stops_with_cancelled() { + let bytes = Png::new(513, 520, 2, 8).bytes(); + for n in [0, 1, 2, 3] { + let calls = AtomicU32::new(0); + let cancel = || calls.fetch_add(1, Ordering::Relaxed) >= n; + let r = import_flat_impl("t", &bytes, &Interrupt::cancel_only(&cancel), Some(0)); + assert!(matches!(r, Err(IoError::Cancelled)), "after {n} checks: {:?}", r.map(|_| ())); + } +} + +#[test] +fn progress_is_monotonic_streamed_and_after_a_fallback() { + for bytes in [Png::new(513, 520, 2, 8).bytes(), Png::new(513, 520, 2, 8).after(exif(6)).bytes()] { + let seen = std::sync::Mutex::new(Vec::new()); + let no = || false; + let progress = |f: f32| seen.lock().unwrap().push(f); + import_flat_impl("t", &bytes, &Interrupt::new(&no, &progress), Some(0)).unwrap(); + let seen = seen.into_inner().unwrap(); + assert!(seen.len() >= 4 && seen.windows(2).all(|w| w[0] <= w[1]), "{seen:?}"); + assert!(seen.last().is_some_and(|&l| l >= 0.95), "{seen:?}"); + } +} + +#[test] +fn only_images_of_the_minimum_decoded_size_stream() { + // 300 x 270 RGB8 decodes to 243000 bytes. + let bytes = Png::new(300, 270, 2, 8).bytes(); + let at = |min| import_png_streamed("t", &bytes, &crate::ImportOptions::default(), &Interrupt::NONE, min).unwrap().is_some(); + assert!(at(243_000) && !at(243_001)); + // The size the importer uses by default: this one is far under it, so `import_flat` opens it whole. + assert!(!at(STREAM_MIN_BYTES)); + assert_same(&import_flat("t", &bytes).unwrap(), &whole(&bytes).unwrap(), "small"); + // 16-bit RGBA is 8 bytes a pixel. + let wide = Png::new(100, 100, 6, 16).bytes(); + assert!(import_png_streamed("t", &wide, &crate::ImportOptions::default(), &Interrupt::NONE, 80_000).unwrap().is_some()); + assert!(import_png_streamed("t", &wide, &crate::ImportOptions::default(), &Interrupt::NONE, 80_001).unwrap().is_none()); +} + +/// A failed reservation is one tile or row band, so the error names the image by its pixel +/// size, never by the bytes of that reservation (which read as a tiny "0 MB image"). +#[test] +fn allocation_errors_name_the_image_not_the_reservation() { + let oom = alloc_error(AllocationError::NotEnoughMemory { bytes: 4096 }, 8000, 6000).to_string(); + assert!(oom.contains("not enough memory to open the 8000×6000 image"), "{oom}"); + assert!(!oom.contains("MB"), "{oom}"); + let overflow = alloc_error(AllocationError::SizeOverflow, 70000, 70000).to_string(); + assert!(overflow.contains("70000×70000 image is too large"), "{overflow}"); +} + +/// The stream asks the machine before it allocates the first tile: an image that can't fit is +/// refused, with an error naming its pixel size, and the stream never starts. +#[test] +fn a_streamed_png_the_machine_can_not_hold_is_refused_before_any_tile() { + let bytes = Png::new(400, 300, 6, 8).bytes(); + match import_png_streamed_with("t", &bytes, &crate::ImportOptions::default(), &Interrupt::NONE, 0, || Some(0)) { + Err(e) => assert!(e.to_string().contains("not enough memory to open this 400 × 300 px image"), "{e}"), + Ok(_) => panic!("opened with no free memory"), + } + // Unknown free memory is no refusal: the image opens as before. + assert!(import_png_streamed_with("t", &bytes, &crate::ImportOptions::default(), &Interrupt::NONE, 0, || None).unwrap().is_some()); + // A cancelled stream is cancelled, not refused for the memory it would have needed. + let yes = || true; + match import_png_streamed_with("t", &bytes, &crate::ImportOptions::default(), &Interrupt::cancel_only(&yes), 0, || Some(0)) { + Err(IoError::Cancelled) => {} + other => panic!("{:?}", other.map(|_| ())), + } +} + +/// The base (whole-image) PNG path checks the layer's tiles against free memory before they are +/// allocated, as the streamed path does, for images big enough for the tiles to matter; unknown +/// free memory is no refusal, and an image below the streaming size opens as it did. +#[test] +fn the_whole_image_path_refuses_tiles_the_machine_can_not_hold() { + let img = codecs::decode(&Png::new(400, 300, 2, 8).bytes()).unwrap(); + // Small tiles are not worth a check: with nothing reported free this still opens. + assert!(image_to_document_with_memory("t", &img, &Interrupt::NONE, Some(0)).is_ok()); + match check_layer_fits(4000, 3000, ChannelLayout::Rgba, CSample::U8, Some(0)) { + Err(e) => assert!(e.to_string().contains("not enough memory to open this 4000 × 3000 px image"), "{e}"), + Ok(()) => panic!("opened with no free memory"), + } + assert!(check_layer_fits(4000, 3000, ChannelLayout::Rgba, CSample::U8, None).is_ok()); + assert!(check_layer_fits(4000, 3000, ChannelLayout::Rgba, CSample::U8, Some(1 << 40)).is_ok()); +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 9364287344..68b1754e61 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,12 +45,14 @@ mod channel_map; pub mod comps_map; pub mod doc_path; pub mod effects_map; +pub mod export_estimate; #[cfg(any(unix, windows))] mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; pub mod guides_map; +mod png_steps; pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod duotone_map; mod kra; @@ -73,6 +75,8 @@ mod psd_import; pub use psd_budget::{DEFAULT_PSD_IMPORT_PIXEL_BYTES, PsdMemoryEstimate, estimate as estimate_psd_memory}; pub mod psd_thumbnail; pub mod raw; +pub mod renderer; +pub use renderer::{BandRenderer, CpuRenderer, RenderBand, RenderError}; pub mod slices_map; pub mod smart_map; pub mod svg; @@ -84,6 +88,7 @@ pub mod vector_preview; mod xcf; pub mod xml; +pub use export_estimate::estimate_export_bytes; use photocraft_codecs::{CodecError, EncodeOptions}; pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; @@ -91,9 +96,14 @@ use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ - DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_tiff_page_with_options, + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, document_to_image_with_renderer, export_flat, export_flat_with_renderer, + export_png_streamed_with_renderer, export_preview_png_with_renderer, file_encoding, import_gif_frames, import_tiff_page, import_tiff_page_with_options, import_webp_frames, tga_alpha_channel_to_transparency, tga_default_bits, }; +pub use png_steps::PngImport; +// Re-exported for the `flat_open_bench` example, which times the tile-building phase of a flat open. +#[doc(hidden)] +pub use flat::image_to_document; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; @@ -116,8 +126,12 @@ pub enum IoError { #[error("PSD: {0}")] Psd(#[from] PsdError), /// The conservative PSD pixel-storage and workspace estimate exceeded its allowance. - #[error("PSD import needs an estimated {required} bytes of pixel memory; limit is {limit} bytes")] - PsdImportBudget { required: u64, limit: u64 }, + /// + /// `free_memory` is the free memory measured when it set the limit (see + /// [`memory::MemoryBudget::measured_free`]); `None` when a caller or attempt allowance, the + /// platform cap or an unknown capacity set it. + #[error("PSD import needs an estimated {required} bytes of pixel memory; limit is {limit} bytes{advice}", advice = crate::memory::low_memory_advice(.free_memory))] + PsdImportBudget { required: u64, limit: u64, free_memory: Option }, /// PSD pixel-storage estimate exceeds the automatic soft limit, but is within the hard limit. /// Explicit approval is required to proceed. #[error( @@ -156,6 +170,19 @@ pub enum IoError { /// A background import or save was cancelled ([`import_with`], [`export_with`]). #[error("cancelled")] Cancelled, + /// A band renderer failed to produce pixels. + #[error("renderer error: {0}")] + Render(RenderError), +} + +impl From for IoError { + fn from(e: RenderError) -> Self { + match e { + RenderError::Cancelled => IoError::Cancelled, + RenderError::Sink(io) => *io, + other => IoError::Render(other), + } + } } /// Result of [`import`]. @@ -377,6 +404,9 @@ pub struct ExportOptions { /// transparency. `None`, the default, keeps it unless the document has alpha or spot /// channels, which are then written as with it off. pub tiff_transparency: Option, + /// Protect RGB 24-bit Targa alpha and flat TIFF composite transparency during in-place + /// Batch saves. Other encoder losses retain their existing policies and warnings. + pub preserve_alpha: bool, /// Which part of the document's XMP packet and free-form text a flat export embeds. /// PSD/PSB always keep XMP; `.pcraft` keeps both. Everything by default, as Save As does; /// Export As offers None. @@ -413,6 +443,7 @@ impl Default for ExportOptions { force_psb: false, tiff_layers: false, tiff_transparency: None, + preserve_alpha: false, xmp: XmpEmbed::All, tga_bits: None, image_previews: true, @@ -468,6 +499,65 @@ fn is_psd_input(name: &str, bytes: &[u8]) -> bool { is_psd(bytes) || (PSD_EXTS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) } +/// How an import bounds its pixel memory before decoding pixels, for a caller that must fit +/// several imports into one memory budget. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImportPreflight { + /// PSD/PSB: the import's own layer/mask/merged preflight, lowered by + /// [`ImportOptions::psd_max_pixel_bytes`]. + Psd, + /// TIFF: a layered page uses [`ImportOptions::psd_max_pixel_bytes`]; a flat page is decoded + /// in bands, so the caller bounds its whole canvas from the page's header + /// ([`photocraft_codecs::tiff_info`]). + Tiff, + /// A flat raster decoded into one image, bounded by + /// [`ImportOptions::flat_max_pixel_bytes`]. + Flat, + /// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw and `.pcraft` files: this build has + /// no header-only estimate of their pixel memory before decoding. + Unbounded, +} + +/// Which [`ImportPreflight`] applies to importing `name` and `bytes`. +/// +/// The order matches [`import_stages`]; keep the two in step. +pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight { + if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { + return ImportPreflight::Unbounded; + } + if is_psd(bytes) { + return ImportPreflight::Psd; + } + if affinity::is_affinity(bytes) || affinity::has_extension(name) { + return ImportPreflight::Unbounded; + } + if ora::is_ora(bytes) || has_extension(name, "ora") { + return ImportPreflight::Unbounded; + } + if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) { + return ImportPreflight::Unbounded; + } + if xcf::is_xcf(bytes) || (has_extension(name, "xcf") && photocraft_codecs::detect(bytes).is_none()) { + return ImportPreflight::Unbounded; + } + if xcf::is_gzipped_xcf(name, bytes) { + return ImportPreflight::Unbounded; + } + if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { + return ImportPreflight::Unbounded; + } + if raw::is_raw(bytes) { + return ImportPreflight::Unbounded; + } + if is_svg_input(name, bytes) { + return ImportPreflight::Unbounded; + } + if is_psd_input(name, bytes) { + return ImportPreflight::Psd; + } + if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat } +} + /// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything /// else is decoded with `photocraft-codecs`. pub fn import(name: &str, bytes: &[u8]) -> Result { @@ -508,6 +598,9 @@ pub struct ImportOptions { /// lower the default cap; 0 uses the adaptive budget. Does not cover metadata or /// smart-filter caches. pub psd_max_pixel_bytes: u64, + /// Decoded-pixel allowance for a flat raster (the buffer a codec decodes into). May lower + /// the codec's default cap; 0 uses it. PSD/PSB and layered TIFF use `psd_max_pixel_bytes`. + pub flat_max_pixel_bytes: u64, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -521,6 +614,7 @@ impl Default for ImportOptions { max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, demosaic: raw::Demosaic::default(), psd_max_pixel_bytes: 0, + flat_max_pixel_bytes: 0, psd_high_memory_approved: false, psd_approved_estimate: None, } @@ -812,59 +906,97 @@ pub fn import_file(name: &str, path: &std::path::Path, opts: &ImportOptions, ctl import_file_for_open_with(name, path, opts, ctl).map(|(result, _)| result) } -/// Dispatches format-specific importers and propagates a structured PSD damage -/// flag alongside the ordinary best-effort import result. -fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { +/// The importer a file goes to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Route { + Pcraft, + Psd, + Affinity, + Ora, + Kra, + Xcf, + GzippedXcf, + Pdn, + Raw, + Svg, + /// A `.psd`/`.psb`/`.psdt` with no raster signature. + NamedPsd, + /// A flat raster format, through the codecs. + Flat, +} + +/// Which importer takes the file `name` holding `bytes`. The checks run in this order. +pub(crate) fn route(name: &str, bytes: &[u8]) -> Route { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { - let document = photocraft_format::load_from_bytes(bytes)?; - return Ok(ImportResult { warnings: open_warnings(&document), document, source_read_only: false, preview_only: false }); + return Route::Pcraft; } // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); + return Route::Psd; } if affinity::is_affinity(bytes) || affinity::has_extension(name) { - return affinity::import(name, bytes); + return Route::Affinity; } if ora::is_ora(bytes) || has_extension(name, "ora") { - return ora::import(name, bytes, ctl); + return Route::Ora; } // Krita files are ZIP containers. A recognized raster signature still wins over a // misleading .kra suffix (as with SVG imports). if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) { - return kra::import(name, bytes, ctl); + return Route::Kra; } // GIMP files are gzip or raw; as above, a recognized raster signature still wins over a // misleading .xcf suffix. if xcf::is_xcf(bytes) || (has_extension(name, "xcf") && photocraft_codecs::detect(bytes).is_none()) { - return xcf::import(name, bytes, ctl); + return Route::Xcf; } if xcf::is_gzipped_xcf(name, bytes) { - return xcf::import(name, &xcf::gunzip(bytes)?, ctl); + return Route::GzippedXcf; } if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { - return pdn::import(name, bytes, ctl); + return Route::Pdn; } if raw::is_raw(bytes) { - return raw::import_raw_demosaic(name, bytes, opts.demosaic); + return Route::Raw; } if is_svg_input(name, bytes) { - return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); + return Route::Svg; } // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); + return Route::NamedPsd; + } + Route::Flat +} + +/// Dispatches format-specific importers and propagates a structured PSD damage +/// flag alongside the ordinary best-effort import result. +fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { + match route(name, bytes) { + Route::Pcraft => { + let document = photocraft_format::load_from_bytes(bytes)?; + Ok(ImportResult { warnings: open_warnings(&document), document, source_read_only: false, preview_only: false }) + } + Route::Psd | Route::NamedPsd => import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)), + Route::Affinity => affinity::import(name, bytes), + Route::Ora => ora::import(name, bytes, ctl), + Route::Kra => kra::import(name, bytes, ctl), + Route::Xcf => xcf::import(name, bytes, ctl), + Route::GzippedXcf => xcf::import(name, &xcf::gunzip(bytes)?, ctl), + Route::Pdn => pdn::import(name, bytes, ctl), + Route::Raw => raw::import_raw_demosaic(name, bytes, opts.demosaic), + Route::Svg => svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth), + Route::Flat => flat::import_flat_with_options(name, bytes, opts, ctl), } - flat::import_flat_with_options(name, bytes, opts, ctl) } /// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`). /// Valid plain SVG is recognized by its XML content; an otherwise unknown `.svg` is passed to the /// SVG parser for a useful malformed-file error, and `.svgz` disambiguates gzip streams. -fn is_svg_input(name: &str, bytes: &[u8]) -> bool { +pub fn is_svg_input(name: &str, bytes: &[u8]) -> bool { // Keep the SVG extension as a fallback for malformed files so the SVG parser can return an // actionable SVG error. A recognized raster signature still wins over a misleading `.svg` // suffix (for example, a WebP downloaded as Firefox_logo.svg). @@ -908,7 +1040,18 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// layered TIFF, in coarse steps for the other formats) and checks it between stages. A cancelled /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - let r = checked_stages(doc, name_or_ext, opts, ctl)?; + export_with_renderer(doc, name_or_ext, opts, ctl, None) +} + +/// [`export_with`] with an optional custom [`BandRenderer`] for flat formats. +pub fn export_with_renderer( + doc: &Document, + name_or_ext: &str, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + renderer: Option<&mut dyn BandRenderer>, +) -> Result { + let r = checked_stages(doc, name_or_ext, opts, ctl, renderer)?; let bytes = r.encoded.into_bytes()?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); @@ -921,22 +1064,42 @@ pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: /// [`export_with`] checks it) before this returns: the model holds the encoded channels, never a /// reference to the document's tiles. Reports progress up to `1.0` once the result is ready. pub fn encode_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - let r = checked_stages(doc, name_or_ext, opts, ctl)?; + let r = checked_stages(doc, name_or_ext, opts, ctl, None)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok(r) } -/// [`export_stages`] refused when the document's scratch was, or became, unreadable while its -/// pixels were read (previews included): their placeholders must never be saved. -fn checked_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { +/// [`export_stages_with_renderer`] refused when the document's scratch was, or became, unreadable +/// while its pixels were read (previews included): their placeholders must never be saved. +fn checked_stages( + doc: &Document, + name_or_ext: &str, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + renderer: Option<&mut dyn BandRenderer>, +) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; photocraft_compose::discard_unreadable_pixel_caches(); doc.scratch_health().map_err(IoError::Unsupported)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); - let r = export_stages(doc, name_or_ext, opts, ctl)?; - photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; - doc.scratch_health().map_err(IoError::Unsupported)?; + let mut renderer = renderer; + // `as_deref_mut` would tie the trait object's lifetime to this function's; the closure shortens it. + #[allow(clippy::option_as_ref_deref)] + let r = export_stages_with_renderer(doc, name_or_ext, opts, ctl, renderer.as_mut().map(|r| -> &mut dyn BandRenderer { &mut **r }))?; + if let Err(e) = photocraft_raster::spill::check_since(scratch_generation) + .map_err(IoError::Unsupported) + .and_then(|()| doc.scratch_health().map_err(IoError::Unsupported)) + { + // The provisional result is discarded: queue its renderer notes (fallback reasons, CPU-only + // formats) back on the renderer so the caller can still report them next to the error. + if let Some(renderer) = renderer { + for w in r.warnings.iter().filter(|w| w.starts_with("custom renderer") || w.contains("CPU compositor")) { + renderer.push_diagnostic(w.clone()); + } + } + return Err(e); + } ctl.check().map_err(|_| IoError::Cancelled)?; Ok(r) } @@ -960,7 +1123,13 @@ const PCRAFT_FULL_COMPOSITE_PIXELS: u64 = 1 << 26; /// before its bytes are assembled: about 2.7 s of 3.0 s for a 24 MP, 5-layer RGB/8 document. const PSD_MODEL_DONE: f32 = 0.9; -fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { +fn export_stages_with_renderer( + doc: &Document, + name_or_ext: &str, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + renderer: Option<&mut dyn BandRenderer>, +) -> Result { let ext = extension(name_or_ext); if ext == "pdn" { return Err(IoError::Unsupported("PDN is import-only; save as .pcraft to preserve all layers and blend modes".into())); @@ -977,10 +1146,20 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & )); } if ext == "ora" { - return ora::export(doc).map(EncodedExport::from); + let mut res = ora::export(doc)?; + if let Some(r) = renderer { + res.warnings.push("OpenRaster export uses CPU compositor; GPU rendering applies to flat formats".into()); + res.warnings.extend(r.drain_diagnostics()); + } + return Ok(res.into()); } if ext == "pdf" { - return pdf::export(doc).map(EncodedExport::from); + let mut res = pdf::export(doc)?; + if let Some(r) = renderer { + res.warnings.push("PDF export uses CPU compositor; GPU rendering applies to flat formats".into()); + res.warnings.extend(r.drain_diagnostics()); + } + return Ok(res.into()); } if ext == photocraft_format::EXTENSION { let doc = named_for_file(doc, name_or_ext); @@ -1035,13 +1214,22 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & } // Never write a header the reader would refuse (e.g. a zero-sized canvas). file.header.validate()?; + if let Some(r) = renderer { + warnings.push("PSD/PSB layered export uses CPU compositor; GPU rendering applies to flat formats".into()); + warnings.extend(r.drain_diagnostics()); + } return Ok(EncodedExport { encoded: Encoded::Psd(Box::new(file)), warnings }); } let format = photocraft_codecs::from_extension(&ext).ok_or_else(|| IoError::UnknownFormat(name_or_ext.to_string()))?; if format == photocraft_codecs::Format::Tiff && opts.tiff_layers && tiff_layers::would_write_layers(doc) { - return tiff_layers::export_layered(doc, opts, ctl).map(EncodedExport::from); + let mut res = tiff_layers::export_layered(doc, opts, ctl)?; + if let Some(r) = renderer { + res.warnings.push("TIFF layered export uses CPU compositor; GPU rendering applies to flat formats".into()); + res.warnings.extend(r.drain_diagnostics()); + } + return Ok(res.into()); } - flat::export_flat_with(doc, format, opts, ctl).map(EncodedExport::from) + flat::export_flat_with_renderer(doc, format, opts, ctl, renderer).map(EncodedExport::from) } /// The flattened document at its canvas size, in 8-bit RGBA like a `.pcraft` file's two previews, @@ -1177,9 +1365,25 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { #[cfg(test)] mod tests { - use super::{ImportOptions, import, import_for_open, is_psd_input, is_svg_input}; + use super::{ImportOptions, ImportPreflight, import, import_for_open, import_preflight, is_psd_input, is_svg_input}; use photocraft_psd::{Compression, Version, testgen}; + /// The callers that fit several imports into one budget choose their option from this + /// classification, so it has to follow the same dispatch as [`super::import`]. + #[test] + fn import_preflight_classifies_each_dispatched_format() { + let png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"; + assert_eq!(import_preflight("x.png", png), ImportPreflight::Flat); + assert_eq!(import_preflight("x.psd", b"8BPS\0\0"), ImportPreflight::Psd); + assert_eq!(import_preflight("x.tif", b"II*\0"), ImportPreflight::Tiff); + assert_eq!(import_preflight("x.ora", b"PK\x03\x04"), ImportPreflight::Unbounded); + assert_eq!(import_preflight("x.svg", b""), ImportPreflight::Unbounded); + assert_eq!(import_preflight("x.pcraft", b"PCF1"), ImportPreflight::Unbounded); + assert_eq!(import_preflight("x.afphoto", b"Affinity"), ImportPreflight::Unbounded); + // A Photoshop extension whose bytes are a readable flat image follows the image. + assert_eq!(import_preflight("misnamed.psd", png), ImportPreflight::Flat); + } + /// Verifies a valid PSD remains editable after import. #[test] fn valid_psd_can_still_be_edited() { @@ -1246,7 +1450,7 @@ mod tests { for (opts, what) in cases { super::psd_gate::set_active_attempt_budget(ample); match super::import_with_options_and_interrupt("nested.psd", &bytes, &opts, &ctl) { - Err(super::IoError::PsdImportBudget { required: r, limit }) => { + Err(super::IoError::PsdImportBudget { required: r, limit, .. }) => { assert_eq!((r, limit), (required, required - 1), "{what} must refuse with its exact fields") } other => panic!("expected {what} to refuse, got {:?}", other.map(|_| ())), diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index c9b8c54378..c4a304defa 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -31,9 +31,23 @@ pub const MAX_SOFT_BUDGET: u64 = 8 << 30; /// platform's actual allocation limits, but the process may run out of memory. pub const UNKNOWN_MEMORY_WARNING_THRESHOLD: u64 = 8 << 30; -/// Reserve subtracted from commit headroom when known. +/// Largest reserve subtracted from commit headroom when known. The reserve is a quarter of the +/// headroom, at least [`LOW_MEMORY_RESERVE`] and at most this, so a machine with little commit +/// headroom still has room for a small file. pub const COMMIT_RESERVE: u64 = 512 << 20; +/// The smallest amount an approved import leaves free: for the app, its display and the system. +/// +/// Free RAM or commit headroom at or below it leaves no room at all, so every file is refused. +pub const LOW_MEMORY_RESERVE: u64 = 256 << 20; + +/// What automatic opening leaves free once that is less than the quarter-of-free-RAM rule. +const AUTOMATIC_RAM_RESERVE: u64 = 1 << 30; + +/// When opening a file would leave less than this free, the approval prompt warns that +/// PhotoCraft may run out of memory. +pub const LOW_MEMORY_WARNING_MARGIN: u64 = 1 << 30; + /// Bytes Rust's layout rules allow for one contiguous allocation on this target /// (`isize::MAX`), not a bound on an import's aggregate peak. /// @@ -171,6 +185,11 @@ pub struct MemoryBudget { /// Set when available memory is unknown: estimates above this threshold still need explicit /// approval but also carry the uncertain-capacity warning. `None` when capacity is known. pub warning_threshold: Option, + /// The free memory measured when measured memory is what set the hard limit: the smaller of + /// the known available RAM and commit headroom. `None` when neither is known, when the + /// platform cap or the caller's limit is what bound the hard limit, and on the web. A refusal + /// reports it so the user can be told to free memory (see [`low_memory_advice`]). + pub measured_free: Option, } /// Admission decision for a given required memory estimate. @@ -213,6 +232,34 @@ impl MemoryBudget { pub fn requires_uncertainty_warning(&self, required: u64) -> bool { self.warning_threshold.is_some_and(|threshold| required > threshold) } + + /// The free memory to report when `required` is refused, or `None` when closing other apps + /// cannot help: the estimate overflowed `u64` (`u64::MAX`), or the limit that refused it was + /// not set by measured memory. + pub fn refusal_free_memory(&self, required: u64) -> Option { + if required == u64::MAX { None } else { self.measured_free } + } +} + +/// The sentence appended to a hard-memory refusal when measured free memory set the limit; empty +/// for `None`. `Some(0)` also covers the fail-closed reading of an unreadable memory limit. +pub fn low_memory_advice(free_memory: &Option) -> String { + match free_memory { + None => String::new(), + Some(0) => ". PhotoCraft measured no free memory, or could not read this system's memory limit. Close other apps and try again".to_string(), + Some(n) => format!(". About {} of memory is free. Close other apps and try again", size_text(*n)), + } +} + +/// A size for people: "3.0 GiB", "900 MiB", "512 B". +fn size_text(bytes: u64) -> String { + if bytes >= 1 << 30 { + format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64) + } else if bytes >= 1 << 20 { + format!("{} MiB", bytes / (1 << 20)) + } else { + format!("{bytes} B") + } } /// Whether an approval-required estimate carries the unknown-capacity warning, given the @@ -252,16 +299,22 @@ pub fn native_platform_cap() -> u64 { /// The limits are for an import's aggregate peak estimate, not for one allocation: a peak may sum /// many buffers, each of which is separately bounded where the decoder allocates it. /// -/// Policy: -/// - Known RAM: H = min(floor(3*A/4), A - 1 GiB, C - reserve if known, [`native_platform_cap`]); -/// S = min(A/2, 8 GiB, H). -/// - Unknown native RAM: S = min(2 GiB, H) where H is [`native_platform_cap`], reduced by known -/// commit headroom; approval above S is possible up to H. 8 GiB is a warning threshold, not H. -/// - Web (wasm32): S = 1 GiB; H = min([`WASM_MAX_PEAK_BUDGET`], caller limit). +/// Policy (A = available RAM, C = commit headroom, each when known): +/// - Known RAM: H = min(floor(3*A/4), A - [`LOW_MEMORY_RESERVE`], C - reserve if known, +/// [`native_platform_cap`]), where the commit reserve is a quarter of C, at least +/// [`LOW_MEMORY_RESERVE`] and at most [`COMMIT_RESERVE`]. S = min(A/2, 8 GiB, max(A - 1 GiB, +/// A/4), max(C - [`COMMIT_RESERVE`], C/4) if known, H). Above 4/3 GiB free this is the usual +/// "leave 1 GiB free"; below it up to a quarter of free RAM opens automatically and the rest +/// goes to approval. At or below [`LOW_MEMORY_RESERVE`] of either there is no room. +/// - Unknown native RAM: S = min(2 GiB, commit soft room if known, H) where H is +/// [`native_platform_cap`], reduced by known commit headroom as above; approval above S is +/// possible up to H. 8 GiB is a warning threshold, not H. /// - <= S: automatic; S < E <= H: explicit approval; > H: refuse, no unlimited override. /// - The saturated `u64::MAX` overflow sentinel is always refused, even when H is `u64::MAX` /// (see [`MemoryBudget::decide`]). -/// - Known zero available RAM stays zero (it never becomes unknown/free). +/// - Known zero available RAM stays zero (it never becomes unknown/free). The fail-closed reading +/// of an unreadable memory limit is `Some(0)`, so it stays refused; only a credited tile share +/// (see [`resolve_budget_with_scratch`]) can pass, as before. /// - swap is never part of the adaptive budget. /// - Explicit caller limits may lower admission, never bypass the hard boundary. pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { @@ -294,47 +347,72 @@ fn resolve_budget_credited(platform: Platform, snapshot: MemorySnapshot, caller_ if let Some(caller) = caller_limit.filter(|&c| c > 0) { hard = hard.min(caller); } - return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None }; + return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None, measured_free: None }; } let platform_cap = native_platform_cap(); - let (mut hard, mut soft, warning_threshold) = match snapshot.available_ram { - Some(a) => { - let h_pct = ((a as u128 * 3) / 4) as u64; - let h_reserve = a.saturating_sub(1 << 30); - let mut h = h_pct.min(h_reserve); + let ram = snapshot.available_ram; + let commit = snapshot.commit_headroom; - if let Some(c) = snapshot.commit_headroom { - let c_room = c.saturating_sub(COMMIT_RESERVE); - h = h.min(c_room); - } - - h = h.min(platform_cap); - - let s_pct = a / 2; - let s = s_pct.min(MAX_SOFT_BUDGET).min(h); - (h, s, None) - } - None => { - let mut h = platform_cap; - if let Some(c) = snapshot.commit_headroom { - let c_room = c.saturating_sub(COMMIT_RESERVE); - h = h.min(c_room); - } - let s = NATIVE_FALLBACK_BUDGET.min(h); - (h, s, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)) - } + // The room measured memory leaves; with neither measurement known only the platform cap bounds. + let measured_room = match (ram.map(ram_hard_room), commit.map(commit_hard_room)) { + (Some(r), Some(c)) => Some(r.min(c)), + (r, c) => r.or(c), + }; + let mut hard = measured_room.map_or(platform_cap, |room| room.min(platform_cap)); + let commit_soft = commit.map(commit_soft_room); + let (mut soft, warning_threshold) = match ram { + Some(a) => (ram_soft_room(a).min(commit_soft.unwrap_or(u64::MAX)).min(hard), None), + None => (NATIVE_FALLBACK_BUDGET.min(commit_soft.unwrap_or(u64::MAX)).min(hard), Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)), }; // Tile pixels the scratch disk takes need no RAM (see [`resolve_budget_with_scratch`]). hard = hard.saturating_add(credit); soft = soft.saturating_add(credit); + let machine_limit = hard; if let Some(caller) = caller_limit.filter(|&c| c > 0) { hard = hard.min(caller); soft = soft.min(hard); } - MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram, warning_threshold } + // Report the measured free memory only when it alone set the hard limit: where the platform + // cap equals the measured room, more free memory could not raise the limit. + let measured_free = match (ram, commit) { + (None, None) => None, + (Some(a), None) => Some(a), + (None, Some(c)) => Some(c), + (Some(a), Some(c)) => Some(a.min(c)), + } + .filter(|_| measured_room.is_some_and(|room| room < platform_cap) && hard >= machine_limit); + + MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: ram, warning_threshold, measured_free } +} + +/// Hard room (what an approved import may use) when `a` bytes of RAM are free: three quarters of +/// it, leaving at least [`LOW_MEMORY_RESERVE`]. +fn ram_hard_room(a: u64) -> u64 { + let quarter_rule = ((a as u128 * 3) / 4) as u64; + quarter_rule.min(a.saturating_sub(LOW_MEMORY_RESERVE)) +} + +/// Automatic room when `a` bytes of RAM are free: half of it, up to [`MAX_SOFT_BUDGET`], leaving +/// 1 GiB free or, when that is less, a quarter of it. +fn ram_soft_room(a: u64) -> u64 { + (a / 2).min(MAX_SOFT_BUDGET).min(a.saturating_sub(AUTOMATIC_RAM_RESERVE).max(a / 4)) +} + +/// Hard room when `c` bytes of commit headroom remain: all but a reserve of a quarter, at least +/// [`LOW_MEMORY_RESERVE`] and at most [`COMMIT_RESERVE`]. +fn commit_hard_room(c: u64) -> u64 { + // `clamp` panics only if its bounds are out of order, and they are constants. + const { assert!(LOW_MEMORY_RESERVE <= COMMIT_RESERVE) }; + let reserve = (c / 4).clamp(LOW_MEMORY_RESERVE, COMMIT_RESERVE); + c.saturating_sub(reserve) +} + +/// Automatic room when `c` bytes of commit headroom remain. +fn commit_soft_room(c: u64) -> u64 { + c.saturating_sub(COMMIT_RESERVE).max(c / 4) } /// Result of evaluating an import admission request. @@ -411,7 +489,9 @@ fn evaluate_psd_admission_for( } cap } - BudgetDecision::Refused => return Err(IoError::PsdImportBudget { required, limit: budget.hard_limit }), + BudgetDecision::Refused => { + return Err(IoError::PsdImportBudget { required, limit: budget.hard_limit, free_memory: budget.refusal_free_memory(required) }); + } }; if required > authorized_total { @@ -448,7 +528,7 @@ pub(crate) fn nested_import_local_bound(required: u64, caller_limit: u64, approv (caller, approved) => caller.or(approved), }; match limit { - Some(limit) if required > limit => Err(IoError::PsdImportBudget { required, limit }), + Some(limit) if required > limit => Err(IoError::PsdImportBudget { required, limit, free_memory: None }), _ => Ok(()), } } @@ -767,7 +847,7 @@ mod tests { for (approved, estimate) in [(false, None), (true, None), (true, Some(u64::MAX))] { match evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, 0, approved, estimate, MemorySnapshot::UNKNOWN) { - Err(IoError::PsdImportBudget { required, limit }) => { + Err(IoError::PsdImportBudget { required, limit, .. }) => { assert_eq!((required, limit), (u64::MAX, u64::MAX), "the typed refusal keeps the estimate and limit unchanged") } other => panic!("approved={approved}, estimate={estimate:?}: expected a hard refusal, got {other:?}"), @@ -777,7 +857,7 @@ mod tests { // A caller limit equal to the sentinel, or an approved estimate of it, is no exception. assert!(matches!( evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN), - Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX }) + Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX, .. }) )); } @@ -816,8 +896,9 @@ mod tests { let snap_tight = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(gb) }; let b_tight = resolve_budget(snap_tight, None); - assert_eq!(b_tight.hard_limit, gb - COMMIT_RESERVE); - assert_eq!(b_tight.soft_limit, gb - COMMIT_RESERVE); + // 1 GiB of headroom keeps a quarter free (256 MiB), not the full 512 MiB. + assert_eq!(b_tight.hard_limit, 768 << 20); + assert_eq!(b_tight.soft_limit, 512 << 20); let snap_room = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(4 * gb) }; let b_room = resolve_budget(snap_room, None); @@ -995,7 +1076,7 @@ mod tests { assert!(evaluate_psd_admission(26 * gb, 18 * gb, 0, true, Some(26 * gb - 1), snap).is_err()); // 10 GiB non-spillable is over H: refused even when approved. let err = evaluate_psd_admission(28 * gb, 18 * gb, 0, true, Some(28 * gb), snap).unwrap_err(); - assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 28 * gb && limit == 27 * gb)); + assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == 28 * gb && limit == 27 * gb)); // A spillable share claimed beyond the total is clamped to the total. assert!(evaluate_psd_admission(5 * gb, u64::MAX, 0, false, None, snap).is_ok()); // The caller's explicit limit still bounds the whole import. @@ -1081,4 +1162,205 @@ mod tests { let without_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; assert_eq!(resolve_budget_for(Platform::Native, with_swap, None), resolve_budget_for(Platform::Native, without_swap, None)); } + + const MIB: u64 = 1 << 20; + const GIB: u64 = 1 << 30; + + fn native_budget(ram: Option, commit: Option) -> MemoryBudget { + resolve_budget_for(Platform::Native, MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit }, None) + } + + fn hard_refusal(result: Result) -> (u64, u64, Option) { + match result { + Err(IoError::PsdImportBudget { required, limit, free_memory }) => (required, limit, free_memory), + other => panic!("expected a hard refusal, got {other:?}"), + } + } + + #[test] + fn low_ram_boundaries_keep_room_for_small_files() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + // (free RAM, soft limit, hard limit) + let table = [ + (0, 0, 0), + (128 * MIB, 0, 0), + (256 * MIB, 0, 0), + (256 * MIB + 1, 64 * MIB, 1), + (512 * MIB, 128 * MIB, 256 * MIB), + (GIB, 256 * MIB, 768 * MIB), + (GIB + 1, 256 * MIB, 768 * MIB), + (1366 * MIB, 342 * MIB, 2049 * MIB / 2), + (3 * GIB / 2, 512 * MIB, 1152 * MIB), + (2 * GIB, GIB, 3 * GIB / 2), + (3 * GIB, 3 * GIB / 2, 9 * GIB / 4), + (4 * GIB, 2 * GIB, 3 * GIB), + (8 * GIB, 4 * GIB, 6 * GIB), + ]; + for (ram, soft, hard) in table { + let b = native_budget(Some(ram), None); + // The soft limit is also bounded by the hard limit: 256 MiB + 1 leaves 1 byte. + let soft = soft.min(hard); + assert_eq!((b.soft_limit, b.hard_limit), (soft, hard), "free RAM {ram}"); + assert_eq!(b.warning_threshold, None); + } + } + + #[test] + fn low_commit_boundaries_keep_room_for_small_files() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + // (commit headroom, soft limit, hard limit) with plenty of RAM, then with RAM unknown. + let table = [ + (0, 0, 0), + (128 * MIB, 0, 0), + (256 * MIB, 0, 0), + (512 * MIB, 128 * MIB, 256 * MIB), + (512 * MIB + 1, 128 * MIB, 256 * MIB + 1), + (GIB, 512 * MIB, 768 * MIB), + (3 * GIB / 2, GIB, 1152 * MIB), + (2 * GIB, 3 * GIB / 2, 3 * GIB / 2), + (5 * GIB, 9 * GIB / 2, 9 * GIB / 2), + ]; + for (commit, soft, hard) in table { + let b = native_budget(Some(64 * GIB), Some(commit)); + assert_eq!((b.soft_limit, b.hard_limit), (soft, hard), "commit {commit}, RAM known"); + let unknown = native_budget(None, Some(commit)); + assert_eq!(unknown.hard_limit, hard, "commit {commit}, RAM unknown"); + assert_eq!(unknown.soft_limit, soft.min(2 * GIB), "commit {commit}, RAM unknown"); + assert_eq!(unknown.warning_threshold, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)); + } + } + + #[test] + fn budgets_grow_with_free_memory_and_leave_the_reserve() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + for by_commit in [false, true] { + let (mut last_soft, mut last_hard) = (0, 0); + let mut measured = 0; + while measured <= 9 * GIB { + let b = if by_commit { native_budget(Some(64 * GIB), Some(measured)) } else { native_budget(Some(measured), None) }; + assert!(b.soft_limit <= b.hard_limit, "commit={by_commit} {measured}"); + assert!(b.hard_limit <= measured, "commit={by_commit} {measured}"); + assert!(b.soft_limit >= last_soft && b.hard_limit >= last_hard, "commit={by_commit} {measured}: not non-decreasing"); + if b.hard_limit > 0 { + assert!(measured - b.hard_limit >= LOW_MEMORY_RESERVE, "commit={by_commit} {measured}: reserve not kept"); + } + (last_soft, last_hard) = (b.soft_limit, b.hard_limit); + measured += 16 * MIB; + } + } + } + + #[test] + fn a_small_file_opens_on_a_machine_with_little_free_memory() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let b = native_budget(Some(900 * MIB), None); + assert_eq!(b.decide(100 * MIB), BudgetDecision::Automatic); + assert_eq!(b.decide(300 * MIB), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(700 * MIB), BudgetDecision::Refused); + + // The reported case: about 190 MB beyond 1 GiB free. + let snap = MemorySnapshot { available_ram: Some(190_693_376 + GIB), swap_free: None, commit_headroom: None }; + let (required, limit, free) = hard_refusal(evaluate_psd_admission_for(Platform::Native, 1_136_504_592, 0, 0, false, None, snap)); + assert_eq!((required, limit), (1_136_504_592, (190_693_376 + GIB) * 3 / 4)); + assert_eq!(free, Some(190_693_376 + GIB)); + assert!(matches!(evaluate_psd_admission_for(Platform::Native, 500_000_000, 0, 0, false, None, snap), Err(IoError::PsdApprovalRequired { .. }))); + } + + #[test] + fn refusals_report_free_memory_only_when_measured_memory_set_the_limit() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let snap = |ram, commit| MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit }; + let refuse = |required, caller, snapshot| hard_refusal(evaluate_psd_admission_for(Platform::Native, required, 0, caller, false, None, snapshot)); + + // RAM binds. + assert_eq!(refuse(2 * GIB, 0, snap(Some(GIB), None)), (2 * GIB, 768 * MIB, Some(GIB))); + // Commit headroom below RAM binds. + assert_eq!(refuse(2 * GIB, 0, snap(Some(64 * GIB), Some(GIB))), (2 * GIB, 768 * MIB, Some(GIB))); + // Commit headroom alone (RAM unknown). + assert_eq!(refuse(2 * GIB, 0, snap(None, Some(GIB))), (2 * GIB, 768 * MIB, Some(GIB))); + // Zero measured RAM (also the fail-closed unreadable limit) refuses everything. + assert_eq!(refuse(1, 0, snap(Some(0), None)), (1, 0, Some(0))); + // A binding caller limit is not the machine's. + assert_eq!(refuse(2 * GIB, GIB, snap(Some(8 * GIB), None)), (2 * GIB, GIB, None)); + assert_eq!(refuse(2 * GIB, GIB, MemorySnapshot::UNKNOWN), (2 * GIB, GIB, None)); + // The overflow sentinel is not helped by closing apps. + assert_eq!(refuse(u64::MAX, 0, snap(Some(8 * GIB), None)), (u64::MAX, 6 * GIB, None)); + assert_eq!(refuse(u64::MAX, 0, MemorySnapshot::UNKNOWN), (u64::MAX, u64::MAX, None)); + + // On the budget itself. + assert_eq!(native_budget(Some(8 * GIB), None).measured_free, Some(8 * GIB)); + assert_eq!(native_budget(Some(8 * GIB), None).refusal_free_memory(u64::MAX), None); + assert_eq!(native_budget(None, None).measured_free, None); + assert_eq!(resolve_budget_for(Platform::Web, snap(Some(GIB), None), None).measured_free, None); + // A caller limit equal to the machine's limit does not hide the measurement. + assert_eq!(resolve_budget_for(Platform::Native, snap(Some(8 * GIB), None), Some(6 * GIB)).measured_free, Some(8 * GIB)); + assert_eq!(resolve_budget_for(Platform::Native, snap(Some(8 * GIB), None), Some(6 * GIB - 1)).measured_free, None); + } + + /// Only 32-bit native has a finite cap: when it equals the measured room, freeing memory + /// cannot raise the limit, so no free-memory figure is reported. + #[test] + fn a_platform_cap_equal_to_the_measured_room_reports_no_free_memory() { + let cap = native_platform_cap(); + if cap == u64::MAX { + return; + } + assert_eq!(native_budget(None, Some(cap + COMMIT_RESERVE)).measured_free, None); + assert_eq!(native_budget(None, Some(cap - 1 + COMMIT_RESERVE)).measured_free, Some(cap - 1 + COMMIT_RESERVE)); + } + + #[test] + fn scratch_credit_adds_to_the_low_memory_limits() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let snap = |ram, commit| MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit }; + with_mock_scratch(Some(40 * GIB), || { + let one_gib = snap(Some(GIB), None); + let out = evaluate_psd_admission_for(Platform::Native, 2 * GIB + 100 * MIB, 2 * GIB, 0, false, None, one_gib).unwrap(); + assert_eq!(out.authorized_total, 256 * MIB + 2 * GIB); + + let err = evaluate_psd_admission_for(Platform::Native, 2 * GIB + 500 * MIB, 2 * GIB, 0, false, None, one_gib).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, hard_limit, .. } + if soft_limit == 2 * GIB + 256 * MIB && hard_limit == 2 * GIB + 768 * MIB)); + + let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 800 * MIB, 2 * GIB, 0, true, None, one_gib)); + assert_eq!(refused, (2 * GIB + 800 * MIB, 2 * GIB + 768 * MIB, Some(GIB))); + + // Fail closed: nothing measured, so only the credited tile share passes. + let zero = snap(Some(0), None); + assert!(evaluate_psd_admission_for(Platform::Native, 2 * GIB, 2 * GIB, 0, false, None, zero).is_ok()); + let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 1, 2 * GIB, 0, true, None, zero)); + assert_eq!(refused, (2 * GIB + 1, 2 * GIB, Some(0))); + + // Low commit headroom with plenty of RAM: H = 256 MiB + credit. + let low_commit = snap(Some(64 * GIB), Some(512 * MIB)); + let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 300 * MIB, 2 * GIB, 0, true, None, low_commit)); + assert_eq!(refused, (2 * GIB + 300 * MIB, 2 * GIB + 256 * MIB, Some(512 * MIB))); + let b = resolve_budget_credited(Platform::Native, low_commit, None, 2 * GIB); + assert_eq!((b.soft_limit, b.hard_limit), (2 * GIB + 128 * MIB, 2 * GIB + 256 * MIB)); + }); + } + + #[test] + fn low_memory_advice_names_the_free_memory() { + assert_eq!(low_memory_advice(&None), ""); + assert_eq!( + low_memory_advice(&Some(0)), + ". PhotoCraft measured no free memory, or could not read this system's memory limit. Close other apps and try again" + ); + assert_eq!(low_memory_advice(&Some(900 << 20)), ". About 900 MiB of memory is free. Close other apps and try again"); + assert_eq!(low_memory_advice(&Some(3 << 30)), ". About 3.0 GiB of memory is free. Close other apps and try again"); + assert_eq!(low_memory_advice(&Some(512)), ". About 512 B of memory is free. Close other apps and try again"); + } } diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 64fb6e2562..b227d28f1f 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -63,6 +63,7 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels); } + crate::psd_import::alternate_spot_inks(file, 0, 0, &mut doc.channels); for (k, ch) in doc.channels.iter_mut().enumerate() { let start = k.checked_mul(plane).ok_or_else(invalid)?; let p = all.get(start..start.checked_add(plane).ok_or_else(invalid)?).ok_or_else(invalid)?; @@ -195,6 +196,7 @@ pub(crate) fn layerless_file( if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } + resources.extend(crate::annotations_map::fresh_count_resource(doc, warnings)); resources.push(version_info_resource(true)); // Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a // kept layer block would describe layers the document doesn't have. Their signatures follow diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 92a5a05850..d693ee941b 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -14,6 +14,10 @@ use photocraft_doc::Document; use crate::{ExportResult, IoError}; +mod selection; +mod shapes; +mod text; + /// The image placed on a page: 8-bit interleaved samples, 1 (gray), 3 (RGB) or 4 (CMYK, /// 1 = full ink), with the profile it was authored in if any. pub struct PrintImage { @@ -42,7 +46,7 @@ pub struct PrintPage { pub label: Option, } -fn pdf_string(s: &str) -> String { +pub(super) fn pdf_string(s: &str) -> String { let mut o = String::from("("); for c in s.chars() { match c { @@ -105,6 +109,10 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { /// A one-page PDF. pub fn print_pdf(page: &PrintPage) -> Vec { + print_pdf_with_vectors(page, "", &selection::SelectionFont::default()) +} + +fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &selection::SelectionFont) -> Vec { let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); let mut offsets: Vec = Vec::new(); let mut obj = |out: &mut Vec, body: &[u8]| { @@ -116,18 +124,20 @@ pub fn print_pdf(page: &PrintPage) -> Vec { let img = &page.image; let (pw, ph) = page.paper; let (x, y, w, h) = page.rect; + let selection_base = if img.icc.is_some() { 8 } else { 7 }; // 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional). obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>"); obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); obj( &mut out, format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>" + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R /FSelect {selection_base} 0 R >> >> /Contents 4 0 R >>" ) .as_bytes(), ); // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n"); + content.push_str(vectors); content.push_str(&marks_ops(page.marks, page.rect)); let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; let pad = if any_marks { 30.0 } else { 6.0 }; @@ -167,6 +177,9 @@ pub fn print_pdf(page: &PrintPage) -> Vec { body.extend_from_slice(b"\nendstream"); obj(&mut out, &body); } + for body in selection_font.objects(selection_base) { + obj(&mut out, &body); + } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); for o in &offsets { @@ -176,6 +189,78 @@ pub fn print_pdf(page: &PrintPage) -> Vec { out } +/// The vector operators for the contiguous visible top prefix of `doc`, top to bottom, with the +/// indices of the layers they replace, and the selection font covering their text (plus, when a +/// lower candidate is rejected during emission, that layer's unused characters). +/// Hidden and blocked layers contribute no characters, so they cannot exhaust the font's codes. +fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::SelectionFont) { + enum Candidate<'a> { + Ops(String), + Text(text::PreparedText<'a>), + } + let mut candidates = Vec::new(); + if doc.icc_profile.is_none() { + let mut repertoire = std::collections::BTreeSet::new(); + for (index, layer) in doc.layers.iter().enumerate().rev() { + if !layer.visible { + continue; + } + if layer.blend != photocraft_color::BlendMode::Normal + || layer.opacity != 1.0 + || layer.fill_opacity != 1.0 + || layer.clipped + || layer.mask.is_some() + || layer.vector_mask.is_some() + || (layer.effects.enabled && !layer.effects.items.is_empty()) + || layer.excluded_channels != 0 + || layer.blend_if != Default::default() + || layer.advanced != Default::default() + || layer.video.is_some() + { + break; + } + let candidate = match &layer.content { + photocraft_doc::LayerContent::Shape(shape) => match shapes::shape_path(shape, doc.mode) { + Some(ops) => Candidate::Ops(ops), + None => break, + }, + photocraft_doc::LayerContent::Text(text_layer) => { + let Some(prepared) = text::prepare(text_layer, doc.mode, doc.resolution_dpi) else { break }; + for ch in text_layer.text.chars() { + repertoire.insert(ch); + if repertoire.len() > 255 { + break; + } + } + if repertoire.len() > 255 { + break; + } + Candidate::Text(prepared) + } + _ => break, + }; + candidates.push((index, candidate)); + } + } + let selection_font = selection::SelectionFont::new(candidates.iter().filter_map(|(_, candidate)| match candidate { + Candidate::Text(prepared) => Some(prepared.text()), + Candidate::Ops(_) => None, + })); + let mut layers = Vec::with_capacity(candidates.len()); + for (index, candidate) in candidates { + let ops = match candidate { + Candidate::Ops(ops) => ops, + Candidate::Text(prepared) => match text::ops(&prepared, doc.mode, &selection_font) { + Some(ops) => ops, + // Keep the successful upper vectors; this layer and everything below stay raster. + None => break, + }, + }; + layers.push((index, ops)); + } + (layers, selection_font) +} + /// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size /// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the /// warning says so — and the embedded profile travels with the image. @@ -193,7 +278,17 @@ pub fn export(doc: &Document) -> std::result::Result { let fmt = doc.pixel_format(); let n = fmt.channels().max(1); let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); - let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None); + // Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing + // compositing. Stop at the first unsupported visible layer, since it may depend on the + // backdrop (adjustments, blend modes, masks, effects, or clipping). + let (vector_layers, selection_font) = collect_vectors(doc); + let mut backdrop = doc.clone(); + for (index, _) in &vector_layers { + if let Some(layer) = backdrop.layers.get_mut(*index) { + layer.visible = false; + } + } + let surface = photocraft_compose::flatten_to_surface(&backdrop, PixelFormat::new(fmt.mode, fmt.sample, true), None); let values = surface.read_region(canvas); let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; // Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink @@ -214,7 +309,13 @@ pub fn export(doc: &Document) -> std::result::Result { if !doc.metadata.text.is_empty() { warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); } - Ok(ExportResult { bytes: print_pdf(&page), warnings }) + let scale = 72.0 / dpi; + let mut vectors = format!("q {scale} 0 0 -{scale} 0 {ph} cm\n"); + for (_, ops) in vector_layers.iter().rev() { + vectors.push_str(ops); + } + vectors.push_str("Q\n"); + Ok(ExportResult { bytes: print_pdf_with_vectors(&page, &vectors, &selection_font), warnings }) } #[cfg(test)] diff --git a/crates/io/src/pdf/selection.rs b/crates/io/src/pdf/selection.rs new file mode 100644 index 0000000000..5b483a4dee --- /dev/null +++ b/crates/io/src/pdf/selection.rs @@ -0,0 +1,111 @@ +//! An embedded, empty Type 3 font for the selection plane. Visible glyphs remain paths. +//! No source font programs are copied into this font; each code has a Unicode mapping. +use std::collections::BTreeSet; +use std::fmt::Write as _; + +#[derive(Default)] +pub(super) struct SelectionFont { + chars: BTreeSet, +} + +impl SelectionFont { + /// Collect distinct characters until the one-byte code space is exceeded. Stopping there, + /// rather than after a fixed number of characters scanned, keeps a long run of repeated + /// text from hiding later distinct characters. + pub(super) fn new<'a>(texts: impl Iterator) -> Self { + let mut chars = BTreeSet::new(); + 'texts: for text in texts { + for ch in text.chars() { + chars.insert(ch); + if chars.len() > 255 { + break 'texts; + } + } + } + Self { chars } + } + + pub(super) fn encode(&self, text: &str) -> Option { + if self.chars.len() > 255 { + return None; + } + let mut encoded = String::new(); + for ch in text.chars() { + let code = self.chars.iter().position(|c| *c == ch)?.checked_add(1)?; + write!(encoded, "{code:02X}").ok()?; + } + Some(encoded) + } + + // Object numbers are assigned by the caller, after the optional ICC object. + pub(super) fn objects(&self, base: usize) -> Vec> { + let count = self.chars.len().min(255); + let mut differences = String::new(); + let mut procs = String::new(); + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /PhotoCraftUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<00> \nendcodespacerange\n", + ); + // PDF permits at most 100 entries in each bfchar block. + let chars: Vec<_> = self.chars.iter().take(count).collect(); + for (block, chunk) in chars.chunks(100).enumerate() { + let _ = writeln!(cmap, "{} beginbfchar", chunk.len()); + for (i, ch) in chunk.iter().enumerate() { + let code = block * 100 + i + 1; + let mut buf = [0; 2]; + let unicode = ch.encode_utf16(&mut buf).iter().map(|u| format!("{u:04X}")).collect::(); + let _ = writeln!(cmap, "<{code:02X}> <{unicode}>"); + } + cmap.push_str("endbfchar\n"); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + for code in 1..=count { + let _ = write!(differences, " /c{code}"); + let _ = write!(procs, " /c{code} {} 0 R", base + 1); + } + let widths = "1000 ".repeat(count.max(1)); + let font = format!( + "<< /Type /Font /Subtype /Type3 /Name /FSelect /FontBBox [0 -250 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs <<{procs} >> /Encoding << /Type /Encoding /Differences [1{differences}] >> /FirstChar 1 /LastChar {} /Widths [{widths}] /Resources << >> /ToUnicode {} 0 R >>", + count.max(1), + base + 2 + ); + let stream = |data: &str| format!("<< /Length {} >>\nstream\n{data}endstream", data.len()).into_bytes(); + vec![font.into_bytes(), stream("1000 0 0 -250 1000 1000 d1\n"), stream(&cmap)] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distinct_characters_after_a_long_repeated_run_still_encode() { + let mut text = "a".repeat(70_000); + text.push('Ω'); + let font = SelectionFont::new(std::iter::once(text.as_str())); + assert!(font.encode("aΩ").is_some(), "the distinct tail character must survive the repetition"); + } + + #[test] + fn the_one_byte_code_space_fits_exactly_255_distinct_characters() { + let fits: String = (0x100u32..0x1FF).filter_map(char::from_u32).collect(); + assert_eq!(fits.chars().count(), 255); + let font = SelectionFont::new(std::iter::once(fits.as_str())); + assert!(font.encode(&fits).is_some(), "255 distinct characters fit one-byte codes"); + + let too_many: String = (0x100u32..0x200).filter_map(char::from_u32).collect(); + assert_eq!(too_many.chars().count(), 256); + let font = SelectionFont::new(std::iter::once(too_many.as_str())); + assert!(font.encode("Ā").is_none(), "256 distinct characters exceed the code space"); + } + + #[test] + fn to_unicode_maps_codes_beyond_the_first_bfchar_block() { + let text: String = (0x100u32..0x196).filter_map(char::from_u32).collect(); + let font = SelectionFont::new(std::iter::once(text.as_str())); + let objects = font.objects(0); + let cmap = String::from_utf8_lossy(&objects[2]); + assert!(cmap.contains("100 beginbfchar")); + assert!(cmap.contains("50 beginbfchar")); + assert!(cmap.contains("<65> <0164>")); + } +} diff --git a/crates/io/src/pdf/shapes.rs b/crates/io/src/pdf/shapes.rs new file mode 100644 index 0000000000..7073c6193d --- /dev/null +++ b/crates/io/src/pdf/shapes.rs @@ -0,0 +1,172 @@ +//! Faithful vector subset for PDF shape export; unsupported shapes use raster fallback. + +use std::fmt::Write as _; + +use photocraft_color::ColorMode; +use photocraft_doc::{Fill, FillRule, LineCap, LineJoin, PathOp, ShapeLayer, StrokeAlign}; + +/// Return a PDF path and fill operator for a directly representable shape. +/// Coordinates remain in document pixels with y increasing down; the caller applies transforms. +pub(super) fn shape_path(layer: &ShapeLayer, mode: ColorMode) -> Option { + if layer.has_soft_mask() || layer.path.inverted || layer.path.subpaths.len() != 1 { + return None; + } + if layer.fill.is_none() && layer.stroke.is_none() { + return None; + } + let channels = match mode { + ColorMode::Rgb => 3, + ColorMode::Grayscale => 1, + ColorMode::Cmyk => 4, + _ => return None, + }; + let mut out = String::new(); + if let Some(fill) = &layer.fill { + let Fill::Solid(color) = fill else { return None }; + if color.mode != mode || !color.alpha.is_finite() || color.alpha != 1.0 || color.c[..channels].iter().any(|v| !v.is_finite()) { + return None; + } + for value in color.c.iter().take(channels) { + if !(0.0..=1.0).contains(value) { + return None; + } + write!(out, "{value} ").ok()?; + } + out.push_str(match mode { + ColorMode::Rgb => "rg\n", + ColorMode::Grayscale => "g\n", + ColorMode::Cmyk => "k\n", + _ => return None, + }); + } + if let Some(stroke) = &layer.stroke { + if stroke.align != StrokeAlign::Center || stroke.opacity != 1.0 || stroke.validate().is_err() || stroke.width <= 0.0 { + return None; + } + let Fill::Solid(stroke_color) = &stroke.paint else { return None }; + if stroke_color.mode != mode || stroke_color.alpha != 1.0 || !stroke_color.alpha.is_finite() { + return None; + } + for value in stroke_color.c.iter().take(channels) { + if !value.is_finite() || !(0.0..=1.0).contains(value) { + return None; + } + write!(out, "{value} ").ok()?; + } + out.push_str(match mode { + ColorMode::Rgb => "RG\n", + ColorMode::Grayscale => "G\n", + ColorMode::Cmyk => "K\n", + _ => return None, + }); + let cap = match stroke.cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + }; + let join = match stroke.join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + }; + writeln!(out, "{} w {cap} J {join} j {} M", stroke.width, stroke.miter_limit).ok()?; + out.push('['); + for dash in &stroke.dashes { + write!(out, "{} ", dash * stroke.width).ok()?; + } + writeln!(out, "] {} d", stroke.dash_offset * stroke.width).ok()?; + } + let subpath = layer.path.subpaths.first()?; + if !subpath.closed || subpath.knots.len() < 2 || subpath.op != PathOp::Combine { + return None; + } + let first = subpath.knots.first()?; + if !point_finite(first.anchor) { + return None; + } + writeln!(out, "{} {} m", first.anchor.x, first.anchor.y).ok()?; + for segment in subpath.segments() { + if segment.iter().any(|p| !point_finite(*p)) { + return None; + } + writeln!(out, "{} {} {} {} {} {} c", segment[1].x, segment[1].y, segment[2].x, segment[2].y, segment[3].x, segment[3].y).ok()?; + } + out.push_str("h\n"); + out.push_str(match (layer.fill.is_some(), layer.stroke.is_some(), layer.path.fill_rule) { + (true, false, FillRule::NonZero) => "f\n", + (true, false, FillRule::EvenOdd) => "f*\n", + (false, true, _) => "S\n", + (true, true, FillRule::NonZero) => "B\n", + (true, true, FillRule::EvenOdd) => "B*\n", + (false, false, _) => return None, + }); + Some(out) +} + +fn point_finite(p: photocraft_geom::Point) -> bool { + p.x.is_finite() && p.y.is_finite() +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::{Knot, Path, Subpath}; + use photocraft_geom::Point; + + #[test] + fn closed_cubic_emits_path_and_fill() { + let layer = ShapeLayer { + fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))), + path: Path::new(vec![Subpath { + closed: true, + knots: vec![Knot::smooth(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(1.0, 0.0)), Knot::corner(10.0, 0.0)], + ..Subpath::default() + }]), + ..Default::default() + }; + let pdf = shape_path(&layer, ColorMode::Rgb).unwrap(); + assert!(pdf.contains("1 0 10 0 10 0 c")); + assert!(pdf.ends_with("h\nf\n")); + } + + #[test] + fn rejects_open_path_and_mismatched_mode() { + let mut layer = ShapeLayer { + fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))), + path: Path::new(vec![Subpath::polyline(&[(0.0, 0.0), (1.0, 1.0)])]), + ..Default::default() + }; + assert!(shape_path(&layer, ColorMode::Rgb).is_none()); + layer.path.subpaths[0].closed = true; + assert!(shape_path(&layer, ColorMode::Cmyk).is_none()); + } + + #[test] + fn centered_stroke_keeps_its_width_color_and_dash_pattern() { + let layer = ShapeLayer { + fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))), + path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)])]), + stroke: Some(photocraft_doc::ShapeStroke { width: 2.0, dashes: vec![2.0, 1.0], ..Default::default() }), + ..Default::default() + }; + let ops = shape_path(&layer, ColorMode::Rgb).unwrap(); + assert!(ops.contains("0 0 0 RG\n")); + assert!(ops.contains("2 w 0 J 0 j 100 M\n[4 2 ] 0 d\n")); + assert!(ops.ends_with("h\nB\n")); + let inside = ShapeLayer { stroke: Some(photocraft_doc::ShapeStroke { align: StrokeAlign::Inside, ..Default::default() }), ..layer }; + assert!(shape_path(&inside, ColorMode::Rgb).is_none()); + } + + #[test] + fn stroke_only_emits_stroke_operator() { + let layer = ShapeLayer { + path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)])]), + stroke: Some(photocraft_doc::ShapeStroke { width: 1.0, ..Default::default() }), + ..Default::default() + }; + let ops = shape_path(&layer, ColorMode::Rgb).unwrap(); + assert!(!ops.contains("rg\n")); + assert!(ops.contains("0 0 0 RG\n")); + assert!(ops.ends_with("h\nS\n")); + } +} diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index 1807b7adc0..e745623e41 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -92,6 +92,85 @@ fn layers_composite_over_white_before_the_page() { assert_eq!(&img[0..3], &[255, 255, 255]); } +#[test] +fn topmost_solid_shape_is_exported_as_a_vector_path() { + use photocraft_doc::{Fill, Path, ShapeLayer, Subpath}; + let mut d = doc(ColorMode::Rgb); + let shape = ShapeLayer { + path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (2.0, 0.0), (2.0, 1.0)])]), + fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))), + ..Default::default() + }; + d.layers.push(Layer::new("Shape", LayerContent::Shape(shape))); + let out = export(&d).unwrap(); + check_structure(&out.bytes); + let content = stream_after(&out.bytes, "/Contents 4 0 R"); + let content = String::from_utf8(content).unwrap(); + assert!(content.contains("1 0 0 rg\n")); + assert!(content.contains("0 0 m\n")); + assert!(content.contains("h\nf\n")); + assert_eq!(stream_after(&out.bytes, "/Subtype /Image"), vec![255; 4 * 2 * 3]); +} + +#[test] +fn topmost_text_layer_is_exported_with_selectable_text_and_vector_outlines() { + use photocraft_doc::TextLayer; + let mut d = doc(ColorMode::Rgb); + let text = TextLayer { + text: "Vector PDF".into(), + size_pt: 24.0, + color: photocraft_color::Color::rgb(0.0, 0.0, 1.0), + transform: photocraft_geom::Affine::translate(1.0, 1.5), + ..Default::default() + }; + let layout = photocraft_text::TextEngine::new().layout(&text, 72.0); + let cluster = layout.clusters.iter().filter(|cluster| !cluster.range.is_empty()).min_by_key(|cluster| cluster.range.start).expect("a shaped cluster"); + let baseline = layout.lines.get(cluster.line).expect("a line").baseline; + let origin = text.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(baseline))); + d.layers.push(Layer::new("Text", LayerContent::Text(text))); + let out = export(&d).unwrap(); + check_structure(&out.bytes); + let content = stream_after(&out.bytes, "/Contents 4 0 R"); + let content = String::from_utf8(content).unwrap(); + assert!(content.contains("/Span << /ActualText >> BDC")); + assert!(content.contains("> Tj")); + assert_eq!(stream_after(&out.bytes, "/Subtype /Image"), vec![255; 4 * 2 * 3]); +} + #[test] fn greyscale_and_cmyk_pages_keep_their_channels() { let g = export(&doc(ColorMode::Grayscale)).unwrap(); @@ -129,3 +208,188 @@ fn other_modes_are_refused_with_an_actionable_error() { assert!(e.contains("RGB") && e.contains("Mode"), "{e}"); assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err()); } + +#[test] +fn unicode_text_over_a_photo_extracts_through_a_pdf_reader() { + let mut d = doc(ColorMode::Rgb); + d.size = Size { width: 128, height: 48 }; + let fmt = d.pixel_format(); + // Synthetic photo-like backdrop; no personal image or third-party asset. + d.layers = vec![Layer::new("Photo", LayerContent::Raster(solid(fmt, 0, 0, 128, 48, &[0.2, 0.4, 0.6, 1.0])))]; + let text = "café Ω 😀"; + d.layers.push(Layer::new( + "Text", + LayerContent::Text(photocraft_doc::TextLayer { + text: text.into(), + size_pt: 12.0, + transform: photocraft_geom::Affine::translate(2.0, 20.0), + ..Default::default() + }), + )); + let pdf = export(&d).unwrap().bytes; + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), text); + assert!(has(&pdf, "/Subtype /Image")); + assert!(has(&pdf, "/Subtype /Type3")); + assert!(has(&pdf, "/ToUnicode")); + assert_eq!(&stream_after(&pdf, "/Subtype /Image")[..3], &[51, 102, 153]); + let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); + assert!(!content.contains("/ActualText layer.opacity = 0.5, + 1 => layer.fill_opacity = 0.5, + 2 => layer.blend = photocraft_color::BlendMode::Multiply, + 3 => { + layer.effects.enabled = true; + layer.effects.items.push(photocraft_doc::effects::Effect::default_drop_shadow()); + } + _ => d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4])), + } + d.layers.push(layer); + let pdf = export(&d).unwrap().bytes; + let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); + assert!(!content.contains("/ActualText"), "case {case} must retain the raster composite"); + assert!(stream_after(&pdf, "/Subtype /Image").iter().any(|v| *v != 255), "case {case} must retain visible text"); + if case == 4 { + assert!(has(&pdf, "[/ICCBased 7 0 R]")); + } + } +} + +/// 300 distinct characters: more than the selection font's 255 one-byte codes. +fn many_distinct_chars() -> String { + let text: String = (0x100u32..0x22C).filter_map(char::from_u32).collect(); + assert_eq!(text.chars().count(), 300); + text +} + +#[test] +fn text_outside_the_vector_prefix_does_not_consume_the_selection_font() { + use photocraft_doc::TextLayer; + let many = many_distinct_chars(); + for case in 0..2 { + let mut d = doc(ColorMode::Rgb); + // Hidden in the first case; in the second a raster layer with reduced opacity sits above + // it, so scanning stops before the oversized repertoire. + let mut lower = Layer::new("Lower text", LayerContent::Text(TextLayer { text: many.clone(), size_pt: 12.0, ..Default::default() })); + lower.visible = case == 1; + d.layers.push(lower); + if case == 1 { + let fmt = d.pixel_format(); + let mut blocker = Layer::new("Blocker", LayerContent::Raster(solid(fmt, 0, 0, 4, 2, &[0.0, 0.0, 0.0, 0.5]))); + blocker.opacity = 0.5; + d.layers.push(blocker); + } + d.layers.push(Layer::new( + "Top text", + LayerContent::Text(TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }), + )); + let pdf = export(&d).unwrap().bytes; + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Hi", "case {case}: only the top text is selectable"); + let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); + assert!(content.contains("/ActualText 8 && i16::from(p[0]) - i16::from(p[2]) > 8), + "the refused red text stays visible in the raster backdrop" + ); +} + +#[test] +fn multiline_text_keeps_its_paragraph_breaks_in_the_selection() { + use photocraft_doc::TextLayer; + let mut d = doc(ColorMode::Rgb); + d.layers.push(Layer::new("Text", LayerContent::Text(TextLayer { text: "A\nB".into(), size_pt: 12.0, ..Default::default() }))); + let pdf = export(&d).unwrap().bytes; + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "A\nB", "the paragraph break survives selection"); + let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); + assert!(content.contains("/ActualText "), "the first span retains the leading newline:\n{content}"); +} diff --git a/crates/io/src/pdf/text.rs b/crates/io/src/pdf/text.rs new file mode 100644 index 0000000000..490a769e5c --- /dev/null +++ b/crates/io/src/pdf/text.rs @@ -0,0 +1,469 @@ +//! Faithful vector and selectable text export for PDF. + +use std::fmt::Write as _; + +use photocraft_color::ColorMode; +use photocraft_doc::TextLayer; + +fn actual_text_hex(text: &str) -> String { + let mut hex = String::from("FEFF"); + for code in text.encode_utf16() { + let _ = write!(hex, "{code:04X}"); + } + hex +} + +fn write_fill_color(out: &mut String, color: &photocraft_color::Color, mode: ColorMode, channels: usize) -> Option<()> { + for value in color.c.iter().take(channels) { + if !value.is_finite() || !(0.0..=1.0).contains(value) { + return None; + } + write!(out, "{value} ").ok()?; + } + writeln!( + out, + "{}", + match mode { + ColorMode::Rgb => "rg", + ColorMode::Grayscale => "g", + ColorMode::Cmyk => "k", + _ => return None, + } + ) + .ok()?; + Some(()) +} + +/// Whether any glyph is thickened by faux bold or by a face lacking the weight: rendering +/// emboldens those glyphs, but [`photocraft_text::render::glyph_outlines`] gives the regular outline. +fn synthetic_bold(layout: &photocraft_text::TextLayout) -> bool { + layout + .glyphs + .iter() + .any(|g| layout.faces.get(g.face as usize).is_some_and(|f| f.embolden) || layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold)) +} + +/// Whether `layer` is laid out in straight horizontal lines with a finite transform and `dpi`. +/// Path text is bent along its path when rendered, and the outlines written here are not. +fn straight_horizontal(layer: &TextLayer, dpi: f32) -> bool { + layer.warp.is_none() + && !matches!(layer.shape, photocraft_doc::text::TextShape::Path { .. }) + && layer.orientation == photocraft_doc::text::Orientation::Horizontal + && dpi.is_finite() + && dpi > 0.0 + && layer.transform.m.iter().all(|v| v.is_finite()) +} + +/// Return PDF vector and invisible text operators for a directly representable text layer. +/// Coordinates are in document pixels with y increasing down; the caller applies the CTM. +#[cfg(test)] +fn text_ops(layer: &TextLayer, mode: ColorMode, dpi: f32) -> Option { + let prepared = prepare(layer, mode, dpi)?; + let font = super::selection::SelectionFont::new(std::iter::once(layer.text.as_str())); + ops(&prepared, mode, &font) +} + +fn channels(mode: ColorMode) -> Option { + match mode { + ColorMode::Rgb => Some(3), + ColorMode::Grayscale => Some(1), + ColorMode::Cmyk => Some(4), + _ => None, + } +} + +fn outlines_permitted(bytes: &[u8], index: u32) -> bool { + let Ok(face) = ttf_parser::Face::parse(bytes, index) else { return false }; + matches!(face.permissions(), Some(ttf_parser::Permissions::Installable | ttf_parser::Permissions::PreviewAndPrint | ttf_parser::Permissions::Editable)) + && face.is_outline_embedding_allowed() + && face.is_subsetting_allowed() +} + +/// A laid-out text layer that is directly representable, except for the selection font: its +/// codes depend on every candidate layer's characters, so the caller builds it afterwards. +pub(super) struct PreparedText<'a> { + layer: &'a TextLayer, + layout: photocraft_text::TextLayout, +} + +impl PreparedText<'_> { + pub(super) fn text(&self) -> &str { + self.layer.text.as_str() + } +} + +/// Lay `layer` out and check it can be emitted as outlines plus selection text. The selection +/// font is deliberately not consulted, so a hidden or already-blocked layer cannot make a +/// supported top layer ineligible. +pub(super) fn prepare<'a>(layer: &'a TextLayer, mode: ColorMode, dpi: f32) -> Option> { + if !straight_horizontal(layer, dpi) || channels(mode).is_none() { + return None; + } + if layer.text.is_empty() { + return Some(PreparedText { layer, layout: photocraft_text::TextLayout::default() }); + } + let mut engine = photocraft_text::TextEngine::new(); + let layout = engine.layout(layer, dpi); + layout_checks(layer, mode, &layout).then_some(PreparedText { layer, layout }) +} + +/// Everything that can refuse a laid-out layer independently of the selection font. The text +/// matrix products are checked here so a layer whose transform overflows is refused during +/// candidate selection, not halfway through emission. +fn layout_checks(layer: &TextLayer, mode: ColorMode, layout: &photocraft_text::TextLayout) -> bool { + let Some(channels) = channels(mode) else { return false }; + if synthetic_bold(layout) || layout.faces.iter().any(|face| !outlines_permitted(face.font.data.as_ref(), face.font.index)) { + return false; + } + for style in &layout.styles { + let color = style.color.in_mode(mode); + if !color.alpha.is_finite() || color.alpha != 1.0 || color.c[..channels].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) { + return false; + } + } + let [a, b, c, d, _, _] = layer.transform.m; + for cluster in &layout.clusters { + let Some(text) = layer.text.get(cluster.range.clone()) else { return false }; + if text.is_empty() { + continue; + } + let Some(line) = layout.lines.get(cluster.line) else { return false }; + let height = (line.ascent + line.descent).max(1.0); + let width = cluster.advance.abs() / text.chars().count().max(1) as f32; + if !height.is_finite() || !width.is_finite() { + return false; + } + let origin = layer.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(line.baseline))); + if !origin.x.is_finite() || !origin.y.is_finite() { + return false; + } + for coefficient in [a * f64::from(width), b * f64::from(width), -c * f64::from(height), -d * f64::from(height)] { + if !coefficient.is_finite() { + return false; + } + } + } + true +} + +pub(super) fn ops(prepared: &PreparedText<'_>, mode: ColorMode, selection_font: &super::selection::SelectionFont) -> Option { + let channels = channels(mode)?; + layout_ops(prepared.layer, mode, channels, &prepared.layout, selection_font) +} + +/// Write one glyph outline as PDF path operators, refusing any coordinate or derived Bézier +/// control that is not finite. A quadratic's cubic controls come from finite differences, which +/// can overflow even when the endpoints are finite. +fn write_path_elements(out: &mut String, elements: &[photocraft_text::render::PathEl]) -> Option<()> { + let mut cur = [0.0f64, 0.0f64]; + for el in elements { + match *el { + photocraft_text::render::PathEl::MoveTo([x, y]) => { + if !x.is_finite() || !y.is_finite() { + return None; + } + cur = [x, y]; + writeln!(out, "{x} {y} m").ok()?; + } + photocraft_text::render::PathEl::LineTo([x, y]) => { + if !x.is_finite() || !y.is_finite() { + return None; + } + cur = [x, y]; + writeln!(out, "{x} {y} l").ok()?; + } + photocraft_text::render::PathEl::QuadTo([cx, cy], [x, y]) => { + if !cx.is_finite() || !cy.is_finite() || !x.is_finite() || !y.is_finite() { + return None; + } + let c1x = cur[0] + 2.0 / 3.0 * (cx - cur[0]); + let c1y = cur[1] + 2.0 / 3.0 * (cy - cur[1]); + let c2x = x + 2.0 / 3.0 * (cx - x); + let c2y = y + 2.0 / 3.0 * (cy - y); + if !c1x.is_finite() || !c1y.is_finite() || !c2x.is_finite() || !c2y.is_finite() { + return None; + } + cur = [x, y]; + writeln!(out, "{c1x} {c1y} {c2x} {c2y} {x} {y} c").ok()?; + } + photocraft_text::render::PathEl::CurveTo([c1x, c1y], [c2x, c2y], [x, y]) => { + if !c1x.is_finite() || !c1y.is_finite() || !c2x.is_finite() || !c2y.is_finite() || !x.is_finite() || !y.is_finite() { + return None; + } + cur = [x, y]; + writeln!(out, "{c1x} {c1y} {c2x} {c2y} {x} {y} c").ok()?; + } + photocraft_text::render::PathEl::Close => { + writeln!(out, "h").ok()?; + } + } + } + Some(()) +} + +fn layout_ops( + layer: &TextLayer, + mode: ColorMode, + channels: usize, + layout: &photocraft_text::TextLayout, + selection_font: &super::selection::SelectionFont, +) -> Option { + if !layout_checks(layer, mode, layout) { + return None; + } + + let outlines = photocraft_text::render::glyph_outlines(layout, &layer.transform, None); + + let mut out = String::new(); + + let mut current_color: Option = None; + + for glyph in &outlines { + let style = layout.styles.get(glyph.style as usize)?; + let color = style.color.in_mode(mode); + if current_color.as_ref() != Some(&color) { + write_fill_color(&mut out, &color, mode, channels)?; + current_color = Some(color); + } + write_path_elements(&mut out, &glyph.elements)?; + writeln!(out, "f*").ok()?; + } + + for d in &layout.decorations { + let style = layout.styles.get(d.style as usize)?; + let color = style.color.in_mode(mode); + if current_color.as_ref() != Some(&color) { + write_fill_color(&mut out, &color, mode, channels)?; + current_color = Some(color); + } + let p0 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x0), f64::from(d.y0))); + let p1 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x1), f64::from(d.y0))); + let p2 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x1), f64::from(d.y1))); + let p3 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x0), f64::from(d.y1))); + if !p0.x.is_finite() + || !p0.y.is_finite() + || !p1.x.is_finite() + || !p1.y.is_finite() + || !p2.x.is_finite() + || !p2.y.is_finite() + || !p3.x.is_finite() + || !p3.y.is_finite() + { + return None; + } + writeln!(out, "{} {} m", p0.x, p0.y).ok()?; + writeln!(out, "{} {} l", p1.x, p1.y).ok()?; + writeln!(out, "{} {} l", p2.x, p2.y).ok()?; + writeln!(out, "{} {} l", p3.x, p3.y).ok()?; + writeln!(out, "h").ok()?; + writeln!(out, "f").ok()?; + } + + selection_ops(&mut out, layer, layout, selection_font)?; + Some(out) +} + +/// Emit the invisible selection text for each shaped cluster. A span reaches the next shaped +/// cluster, so paragraph breaks and other whitespace omitted by shaping survive +/// in the selection; the first span also takes leading characters. Without this a multi-line +/// layer extracts as one run of text. +fn selection_ops(out: &mut String, layer: &TextLayer, layout: &photocraft_text::TextLayout, selection_font: &super::selection::SelectionFont) -> Option<()> { + let [a, b, c, d, _, _] = layer.transform.m; + let mut current_line = None; + let mut clusters: Vec<_> = layout.clusters.iter().collect(); + clusters.sort_by_key(|cluster| cluster.range.start); + let mut first = true; + for (i, cluster) in clusters.iter().enumerate() { + let text = layer.text.get(cluster.range.clone())?; + if text.is_empty() { + continue; + } + let span_start = if first { 0 } else { cluster.range.start }; + first = false; + let next_start = clusters.iter().skip(i + 1).find(|next| !next.range.is_empty()).map_or(layer.text.len(), |next| next.range.start); + let span_end = next_start.max(cluster.range.end).min(layer.text.len()); + // Only layout whitespace may extend a cluster. Truncated box text must stay raster, + // rather than exposing unpainted source text in the selection plane. + if !layer.text.get(span_start..cluster.range.start)?.chars().all(char::is_whitespace) + || !layer.text.get(cluster.range.end..span_end)?.chars().all(char::is_whitespace) + { + return None; + } + let span = layer.text.get(span_start..span_end)?; + let line = layout.lines.get(cluster.line)?; + if current_line != Some(cluster.line) { + if current_line.is_some() { + writeln!(out, "ET").ok()?; + } + writeln!(out, "BT\n3 Tr\n/FSelect 1 Tf").ok()?; + current_line = Some(cluster.line); + } + let height = (line.ascent + line.descent).max(1.0); + let width = cluster.advance.abs() / text.chars().count().max(1) as f32; + if !height.is_finite() || !width.is_finite() { + return None; + } + let x = cluster.x; + let origin = layer.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(line.baseline))); + if !origin.x.is_finite() || !origin.y.is_finite() { + return None; + } + let [ta, tb, tc, td] = [a * f64::from(width), b * f64::from(width), -c * f64::from(height), -d * f64::from(height)]; + if !ta.is_finite() || !tb.is_finite() || !tc.is_finite() || !td.is_finite() { + return None; + } + let hex = actual_text_hex(span); + let encoded = selection_font.encode(span)?; + writeln!(out, "/Span << /ActualText <{hex}> >> BDC").ok()?; + writeln!(out, "{ta} {tb} {tc} {td} {} {} Tm", origin.x, origin.y).ok()?; + writeln!(out, "<{encoded}> Tj\nEMC").ok()?; + } + + if current_line.is_some() { + writeln!(out, "ET").ok()?; + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_geom::Affine; + + #[test] + fn embedding_restrictions_refuse_outlines() { + let original = photocraft_text::fonts::INTER_REGULAR.as_slice(); + assert!(outlines_permitted(original, 0)); + let count = u16::from_be_bytes(original[4..6].try_into().unwrap()) as usize; + let record = (0..count).map(|i| 12 + 16 * i).find(|at| &original[*at..*at + 4] == b"OS/2").unwrap(); + let offset = u32::from_be_bytes(original[record + 8..record + 12].try_into().unwrap()) as usize; + for flags in [0x0002u16, 0x0100, 0x0200] { + let mut restricted = original.to_vec(); + restricted[offset + 8..offset + 10].copy_from_slice(&flags.to_be_bytes()); + assert!(!outlines_permitted(&restricted, 0), "fsType {flags:#x}"); + let layer = TextLayer { text: "Restricted".into(), ..Default::default() }; + let mut layout = photocraft_text::TextEngine::new().layout(&layer, 72.0); + for face in &mut layout.faces { + face.font.data = restricted.clone().into(); + face.font.index = 0; + } + let font = super::super::selection::SelectionFont::new(std::iter::once(layer.text.as_str())); + assert!(layout_ops(&layer, ColorMode::Rgb, 3, &layout, &font).is_none(), "restricted text must remain raster"); + } + assert!(!outlines_permitted(&[], 0)); + } + + #[test] + fn text_layer_emits_actualtext_outlines_and_invisible_text() { + let layer = TextLayer { + text: "Hello".into(), + size_pt: 12.0, + color: photocraft_color::Color::rgb(0.0, 0.5, 1.0), + transform: Affine::translate(10.0, 20.0), + ..Default::default() + }; + let ops = text_ops(&layer, ColorMode::Rgb, 72.0).unwrap(); + assert!(ops.contains("/Span << /ActualText >> BDC")); + assert!(ops.contains("0 0.5 1 rg")); + assert!(ops.contains(" m\n")); + assert!(ops.contains(" c\n")); + assert!(ops.contains("h\nf*\n")); + assert!(ops.contains("BT\n3 Tr\n")); + assert!(ops.contains("/FSelect")); + assert!(ops.contains("> Tj\nEMC\n")); + assert!(ops.ends_with("ET\n")); + } + + #[test] + fn rejects_warp_and_invalid_alpha() { + let warped = TextLayer { text: "Warped".into(), warp: Some(photocraft_doc::text::TextWarp::default()), ..Default::default() }; + assert!(text_ops(&warped, ColorMode::Rgb, 72.0).is_none()); + + let transparent = TextLayer { + text: "Alpha".into(), + color: photocraft_color::Color { mode: ColorMode::Rgb, c: [1.0, 0.0, 0.0, 0.0], alpha: 0.5 }, + ..Default::default() + }; + assert!(text_ops(&transparent, ColorMode::Rgb, 72.0).is_none()); + } + + #[test] + fn rejects_path_text_and_synthetic_bold() { + let on_path = TextLayer { + text: "Path".into(), + shape: photocraft_doc::text::TextShape::Path { path: Default::default(), start: 0.0, flip: false }, + ..Default::default() + }; + assert!(text_ops(&on_path, ColorMode::Rgb, 72.0).is_none()); + + let faux_bold = TextLayer { + text: "Bold".into(), + runs: vec![photocraft_doc::text::TextRun { len: 4, style: photocraft_doc::text::CharStyle { faux_bold: true, ..Default::default() } }], + ..Default::default() + }; + assert!(text_ops(&faux_bold, ColorMode::Rgb, 72.0).is_none()); + + // A face without the requested weight is emboldened by font matching. + let plain = TextLayer { text: "Bold".into(), ..Default::default() }; + let mut layout = photocraft_text::TextEngine::new().layout(&plain, 72.0); + assert!(!synthetic_bold(&layout)); + for face in &mut layout.faces { + face.embolden = true; + } + assert!(synthetic_bold(&layout)); + } + + #[test] + fn decorations_are_emitted_as_rectangles() { + let layer = TextLayer { + text: "Underline".into(), + runs: vec![photocraft_doc::text::TextRun { + len: 9, + style: photocraft_doc::text::CharStyle { underline: true, color: photocraft_color::Color::rgb(1.0, 0.0, 0.0), ..Default::default() }, + }], + ..Default::default() + }; + let ops = text_ops(&layer, ColorMode::Rgb, 72.0).unwrap(); + assert!(ops.contains("h\nf\n")); + } + + #[test] + fn quadratic_cubic_controls_that_overflow_are_refused() { + use photocraft_text::render::PathEl; + // Finite endpoints whose difference is not finite: 1e308 - -1e308 overflows. + let mut out = String::new(); + let overflowing = [PathEl::MoveTo([1e308, 0.0]), PathEl::QuadTo([-1e308, 0.0], [1e308, 0.0])]; + assert!(write_path_elements(&mut out, &overflowing).is_none(), "a non-finite derived control must be refused"); + assert!(!out.contains(" c\n"), "no cubic with a non-finite control is written:\n{out}"); + + // A representable quadratic still becomes the equivalent cubic. + let mut out = String::new(); + let normal = [PathEl::MoveTo([0.0, 0.0]), PathEl::QuadTo([30.0, 60.0], [60.0, 0.0])]; + assert!(write_path_elements(&mut out, &normal).is_some()); + assert!(out.contains("20 40 40 40 60 0 c\n"), "{out}"); + } + + #[test] + fn overflowing_text_matrix_products_are_refused() { + // Whitespace shapes no outlines, so the text matrix is the only place the transform + // reaches the output. Zero the positions: the products overflow, while the cluster + // origin they are added to stays finite. + let layer = + TextLayer { text: " ".into(), size_pt: 100.0, transform: photocraft_geom::Affine { m: [1e308, 1e308, 0.0, 0.0, 0.0, 0.0] }, ..Default::default() }; + let mut layout = photocraft_text::TextEngine::new().layout(&layer, 72.0); + for cluster in &mut layout.clusters { + cluster.x = 0.0; + cluster.advance = cluster.advance.abs().max(2.0); + } + for line in &mut layout.lines { + line.baseline = 0.0; + } + let cluster = layout.clusters.first().expect("a whitespace cluster"); + let line = layout.lines.get(cluster.line).expect("a line"); + let origin = layer.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(line.baseline))); + assert!(origin.x.is_finite() && origin.y.is_finite(), "the fixture's origin stays finite"); + let font = super::super::selection::SelectionFont::new(std::iter::once(layer.text.as_str())); + assert!(layout_ops(&layer, ColorMode::Rgb, 3, &layout, &font).is_none(), "overflowing Tm coefficients must not be emitted"); + assert!(text_ops(&layer, ColorMode::Rgb, 72.0).is_none()); + } +} diff --git a/crates/io/src/perspective_warp_map.rs b/crates/io/src/perspective_warp_map.rs index 654de23b32..cb5815bb81 100644 --- a/crates/io/src/perspective_warp_map.rs +++ b/crates/io/src/perspective_warp_map.rs @@ -38,7 +38,7 @@ const FILTER_ID: i32 = 1023; const NAME: &str = "Perspective Warp..."; pub(super) const TEMPLATE_KEY: &str = "__perspectiveWarpPsd"; /// Far more planes than anyone lays out by hand; bounds the corner linking (quadratic). -const MAX_QUADS: usize = 256; +pub const MAX_QUADS: usize = 256; const MAX_VERTICES: usize = 4 * MAX_QUADS; const MAX_TEMPLATE_BYTES: usize = 1 << 20; @@ -74,29 +74,29 @@ fn quads(f: &Descriptor) -> Option> { .map(|q| { let Value::Descriptor(q) = q else { return None }; let Some(Value::List(ix)) = q.get("indices") else { return None }; - let ix: Vec = ix - .iter() - .map(|i| match i { - Value::Integer(i) => usize::try_from(*i).ok(), - _ => None, - }) - .collect::>()?; - ix.try_into().ok() + let [Value::Integer(a), Value::Integer(b), Value::Integer(c), Value::Integer(d)] = ix.as_slice() else { return None }; + Some([usize::try_from(*a).ok()?, usize::try_from(*b).ok()?, usize::try_from(*c).ok()?, usize::try_from(*d).ok()?]) }) .collect() } -/// The planes a `perspectiveWarpTransform` descriptor describes; `None` when it is inconsistent. -fn planes_of(f: &Descriptor) -> Option> { +/// Reads the document-space planes and original corner indices from Perspective Warp geometry. +/// Callers validate the enclosing descriptor and target according to the file format. +pub fn indexed_geometry(f: &Descriptor) -> Option<(Vec, Vec<[usize; 4]>)> { let (src, dst) = (points(f, "vertices")?, points(f, "warpedVertices")?); if src.len() != dst.len() { return None; } let corners = |list: &[[f64; 2]], q: &[usize; 4]| -> Option<[[f64; 2]; 4]> { Some([*list.get(q[0])?, *list.get(q[1])?, *list.get(q[2])?, *list.get(q[3])?]) }; - let planes: Vec = quads(f)?.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::>()?; + let indices = quads(f)?; + let planes: Vec = indices.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::>()?; PerspectiveMap::new(&planes)?; - Some(planes) + Some((planes, indices)) +} + +fn planes_of(f: &Descriptor) -> Option> { + indexed_geometry(f).map(|(planes, _)| planes) } /// `layer.smartObjects.perspectiveWarp` params for a Perspective Warp item. @@ -137,7 +137,8 @@ fn template(params: &J) -> Result, String> { /// The planes to write: as rendered, so a `straighten` is baked in. fn export_planes(params: &J) -> Result, String> { let object = params.as_object().ok_or("Perspective Warp parameters must be an object")?; - if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["planes", "straighten", "interpolation"].contains(&k.as_str())) { + let supported = |k: &str| super::is_meta(k) || ["planes", "straighten", "interpolation"].contains(&k) || (k == "target" && params["target"] == "pixels"); + if let Some(k) = object.keys().find(|k| !supported(k)) { return Err(format!("Perspective Warp setting `{k}` has no PSD equivalent")); } let mut planes: Vec = @@ -161,7 +162,7 @@ fn pnt(p: [f64; 2]) -> Value { /// `vertices`, `warpedVertices` and `quads` for `planes`; a corner shared by several planes (same /// layout and warped position) is written once. -fn geometry(planes: &[Plane]) -> (Value, Value, Value) { +pub fn geometry(planes: &[Plane]) -> (Value, Value, Value) { let mut corners: Vec<([f64; 2], [f64; 2])> = Vec::new(); let mut quads = Vec::with_capacity(planes.len()); for p in planes { diff --git a/crates/io/src/png_steps.rs b/crates/io/src/png_steps.rs new file mode 100644 index 0000000000..ac786b0a22 --- /dev/null +++ b/crates/io/src/png_steps.rs @@ -0,0 +1,176 @@ +//! A PNG import in resumable steps, for a caller that must not block for the whole file (the web +//! build has no threads): the same document [`crate::import_with_options_and_interrupt`] gives. +//! +//! A PNG whose rows come in final order is streamed: each decoded group of rows becomes tiles at +//! once and is dropped, as [`crate::import_with_options_and_interrupt`] does, so the image never +//! exists next to the layer built from it (a 100 MP RGBA8 file would need about 400 MB more, which +//! a browser tab does not have). Any other PNG (interlaced, animated, or stored in another +//! orientation) is decoded whole, turned upright and copied into tiles, each in bounded steps. + +use std::sync::Arc; + +use photocraft_codecs::{self as codecs, DecodeOptions, Format, Limits, OrientSteps, PngDecodeRows, PngExtras, PngFinish, SharedBytes, begin_png_finish}; +use photocraft_geom::TILE_SIZE; +use photocraft_raster::{Interrupt, PruneCursor}; + +use crate::flat::{FlatBuilder, PngStream, apply_flat_format}; +use crate::{ImportResult, IoError, Route}; + +/// The rows of a file being streamed into the tiles of its layer. +struct Streaming { + rows: PngDecodeRows, + stream: PngStream, +} + +/// A streamed layer being scanned for tiles that hold only the default pixel. +struct Pruning { + stream: PngStream, + cursor: PruneCursor, + extras: PngExtras, +} + +enum Stage { + /// The rows becoming tiles as they are decoded. + Stream(Box), + /// The streamed layer's prune scan. + Prune(Box), + /// The whole image, decoded a row at a time: a file whose rows can't be streamed. + Rows(Box>), + /// The decoded image turned upright (an EXIF orientation 2-8), a band of rows at a time. + Orient(Box), + /// The decoded image becoming the document, a band of rows and a batch of tiles at a time. + Build(Box), + Spent, +} + +/// A plain PNG file being imported as a document, a few rows at a time. +pub struct PngImport { + name: String, + bytes: Arc>, + stage: Stage, +} + +impl PngImport { + /// Starts importing `bytes` (named `name`) when the import would decode them as a plain PNG, + /// which reads the header and refuses what the import refuses; `None` for any other file, + /// which only [`crate::import_with_options_and_interrupt`] reads. The decoder shares `bytes`. + pub fn new(name: &str, bytes: &Arc>) -> Result, IoError> { + if crate::route(name, bytes) != Route::Flat || codecs::detect(bytes) != Some(Format::Png) { + return Ok(None); + } + let rows = PngDecodeRows::new_streaming(SharedBytes(Arc::clone(bytes)), &Limits::default())?; + let stage = if rows.streamable() { + let stream = PngStream::for_steps(&rows.header())?; + Stage::Stream(Box::new(Streaming { rows, stream })) + } else { + Stage::Rows(Box::new(Self::buffered(rows)?)) + }; + Ok(Some(Self { name: name.to_string(), bytes: Arc::clone(bytes), stage })) + } + + /// A decoder that holds the whole image: its output is reserved up front. + fn buffered(mut rows: PngDecodeRows) -> Result, IoError> { + rows.reserve_output()?; + Ok(rows) + } + + /// Whether the rows are streamed into the layer's tiles as they are decoded, so the decoded + /// image never exists whole. False for a file whose rows can't come in final order, and from + /// the moment a late `eXIf` sends a streamed one back to a whole decode. + pub fn streams(&self) -> bool { + matches!(self.stage, Stage::Stream(_) | Stage::Prune(_)) + } + + /// How much of the import is done, `0.0..=1.0`. + pub fn progress(&self) -> f32 { + match &self.stage { + Stage::Stream(s) => s.rows.progress(), + Stage::Rows(rows) => rows.progress(), + Stage::Orient(steps) => steps.progress(), + Stage::Prune(_) | Stage::Build(_) | Stage::Spent => 1.0, + } + } + + /// Works while `more()` returns true (at least one bounded piece per call): rows (into tiles + /// as they come, or into the whole image), the turn upright, the copy into tiles, then the + /// prune scan. `Some` once the document is built, after which the import is spent. + pub fn step(&mut self, more: &mut dyn FnMut() -> bool) -> Result, IoError> { + match std::mem::replace(&mut self.stage, Stage::Spent) { + Stage::Stream(mut s) => { + let result = { + let Streaming { rows, stream } = &mut *s; + let mut sink = |y0: u32, data: &[u8]| stream.write_rows(y0, data, &Interrupt::NONE); + rows.step_stream::(TILE_SIZE as u32, more, &mut sink)? + }; + self.stage = match result { + None => Stage::Stream(s), + // An `eXIf` after the image data turns the image: decode it whole instead, as + // the one-shot import does, with the streamed layer released first. + Some(extras) if extras.orientation != 1 => { + drop(s); + let rows = PngDecodeRows::new_streaming(SharedBytes(Arc::clone(&self.bytes)), &Limits::default())?; + Stage::Rows(Box::new(Self::buffered(rows)?)) + } + Some(extras) => { + let stream = s.stream; + let cursor = stream.prune_cursor(); + Stage::Prune(Box::new(Pruning { stream, cursor, extras })) + } + }; + Ok(None) + } + Stage::Prune(mut p) => loop { + if p.stream.prune_step(&mut p.cursor) { + let Pruning { stream, extras, .. } = *p; + let mut r = stream.into_document(&self.name, extras)?; + apply_flat_format(&mut r, &self.bytes, Some(Format::Png), None)?; + return Ok(Some(r)); + } + if !more() { + self.stage = Stage::Prune(p); + return Ok(None); + } + }, + Stage::Rows(mut rows) => { + self.stage = match rows.step(more)? { + Some(img) => { + // The resolution, upright orientation and final limit checks `decode` does. + match begin_png_finish(img, &DecodeOptions::default())? { + PngFinish::Done(img) => Stage::Build(Box::new(FlatBuilder::new(&self.name, *img)?)), + PngFinish::Orient(steps) => Stage::Orient(steps), + } + } + None => Stage::Rows(rows), + }; + Ok(None) + } + Stage::Orient(mut steps) => { + self.stage = match steps.step(more)? { + Some(img) => Stage::Build(Box::new(FlatBuilder::new(&self.name, img)?)), + None => Stage::Orient(steps), + }; + Ok(None) + } + Stage::Build(mut build) => loop { + if !build.copy_done() { + build.copy_step()?; + if !more() { + self.stage = Stage::Build(build); + return Ok(None); + } + continue; + } + if !build.prune_done() { + build.prune_step(); + if !more() { + self.stage = Stage::Build(build); + return Ok(None); + } + continue; + } + return build.finish(self.bytes.as_slice(), Some(Format::Png)).map(Some); + }, + Stage::Spent => Err(IoError::Unsupported("the PNG was already imported".into())), + } + } +} diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs index a80230322d..9a91469add 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -249,7 +249,7 @@ pub fn estimate(file: &PsdFile) -> Result { pub fn check(file: &PsdFile, limit: u64) -> Result { let est = estimate(file)?; if est.total_bytes > limit { - return Err(IoError::PsdImportBudget { required: est.total_bytes, limit }); + return Err(IoError::PsdImportBudget { required: est.total_bytes, limit, free_memory: None }); } Ok(est) } diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index 9e24676716..edf83b7612 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -284,6 +284,29 @@ fn zero_available_ram_refuses_admission() { }); } +/// A small file opens with 900 MiB free (it used to be refused outright below 1 GiB); with no +/// free memory at all it is refused and the refusal says why. +#[test] +fn small_file_opens_with_little_free_memory_and_a_refusal_names_it() { + if cfg!(target_arch = "wasm32") { + return; // the web policy ignores a host snapshot; it has its own tests + } + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + + let low = crate::memory::MemorySnapshot { available_ram: Some(900 << 20), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(low, || { + let res = crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default()); + assert!(res.is_ok(), "{:?}", res.map(|_| ())); + }); + + let none = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(none, || { + let res = crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default()); + assert!(matches!(res, Err(IoError::PsdImportBudget { limit: 0, free_memory: Some(0), .. }))); + }); +} + #[test] fn preflight_rejection_and_approval_files_release_gate() { if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { @@ -337,7 +360,7 @@ fn retry_estimate_cap_rejects_altered_or_enlarged_file() { // 3. Exceeds hard limit: refused completely regardless of approval let res = crate::memory::evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap); - assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit }) if required == 10 * gb && limit == 9 * gb)); + assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit, .. }) if required == 10 * gb && limit == 9 * gb)); } #[test] @@ -413,7 +436,7 @@ fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() { // Second nested consume exceeds remaining 1 GB let err = crate::psd_gate::consume_attempt_allowance(2 * gb).unwrap_err(); - assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == gb)); + assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == 2 * gb && limit == gb)); // Allowance is still 1 GB (failed attempt does not reset or corrupt parent) assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); @@ -472,7 +495,7 @@ fn real_import_propagates_high_memory_approval_and_its_estimate_bound() { let lowered = crate::ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() }; assert!(matches!( crate::import_with_options("approval.psd", &bytes, &lowered), - Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1 + Err(IoError::PsdImportBudget { required: r, limit: l, .. }) if r == required && l == required - 1 )); }); }); @@ -527,7 +550,7 @@ fn memory_drop_while_waiting_for_the_gate_refuses_before_decoding() { let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding"); match result { - Err(IoError::PsdImportBudget { required, limit: 0 }) => assert!(required > 0), + Err(IoError::PsdImportBudget { required, limit: 0, .. }) => assert!(required > 0), other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"), } handle.join().expect("worker thread"); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 89bf250b4e..8984de49c5 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -605,6 +605,14 @@ impl Ex<'_> { if !matches!(l.content, LayerContent::Shape(_)) { self.vector_mask_block(l, &mut raw); } + // The document field owns frame state, including an imported frame's removal. + raw.retain(|(k, _)| k != b"pcFr"); + if let Some(frame) = &l.frame { + match serde_json::to_vec(frame) { + Ok(data) => raw.push((*b"pcFr", data)), + Err(e) => self.warnings.push(format!("frame metadata could not be written: {e}")), + } + } crate::comps_map::artboard_block(&self.guides, l, &mut raw); if let Some((comps, last)) = &self.comps { // Ids are assigned to every layer before emitting. @@ -1011,6 +1019,39 @@ impl Ex<'_> { /// it reports progress and returns `false` to stop early (a cancelled save, #3389). fn emit(&mut self, layers: &[Layer], tick: &mut dyn FnMut() -> bool) { for l in layers { + // Export a frame as ordinary editable groups, a vector clip and a separate border. + // Photoshop's private native-frame metadata is deliberately not fabricated. + let expanded; + let l = if let (Some(frame), Some(children)) = (&l.frame, l.children()) { + let mut clip = Layer::group("Frame Image", children.to_vec()); + clip.blend = photocraft_color::BlendMode::Normal; + clip.mask = l.mask.clone(); + clip.vector_mask = l.vector_mask.clone(); + clip.psd_blocks.push((*b"pcFc", std::sync::Arc::new(vec![1]))); + let cid = self.fresh_id(); + self.layer_ids.insert(clip.id, cid); + let mut outer = l.clone(); + outer.mask = None; + outer.vector_mask = None; + let mut parts = vec![clip]; + if let Some(mut shape) = frame.stroke_shape() { + // Readers that composite pixels see the border too, as for any shape layer. + shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.canvas)); + let mut border = Layer::new("Frame Stroke", LayerContent::Shape(shape)); + border.psd_blocks.push((*b"pcFs", std::sync::Arc::new(vec![1]))); + let bid = self.fresh_id(); + self.layer_ids.insert(border.id, bid); + parts.push(border); + } + if let Some(target) = outer.children_mut() { + *target = parts; + } + // Emit this prepared group directly, retaining its typed frame for content_blocks. + expanded = outer; + &expanded + } else { + l + }; let extra = self.content_blocks(l); match &l.content { LayerContent::Group(g) => { diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index b46a21fc3c..69231c7aee 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -172,10 +172,11 @@ fn export_supported_document( }) .collect(); let previous = (!text_layers.is_empty()).then(|| preserved.iter().find(|(_, key, _)| key == b"Txt2").map(|(_, _, data)| data.to_vec())).flatten(); - // A fresh document of ordinary (Metrics) text has nothing to put in `Txt2`, and Photoshop never - // writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the - // block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the - // one mode EngineData can't carry; #1348). + // A fresh document of ordinary (Metrics) text with no Txt2-only OpenType settings has nothing + // to put in `Txt2`, and Photoshop never writes its empty `<< /0 << >> >>` stub — readers such + // as Photopea reject it (#3514). Keep the block when the file had one (round-trip its pen + // positions/extras), a layer is Optical (#1348), or a layer carries stylistic sets / other + // Txt2-only features (#10308). let txt2 = (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)) .then(|| photocraft_text::psd::build_txt2_at(&text_layers, previous.as_deref(), ex.dpi)); for (sig, key, data) in preserved { @@ -234,6 +235,7 @@ fn export_supported_document( if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } + resources.extend(crate::annotations_map::fresh_count_resource(doc, &mut ex.warnings)); if let Some(Some(data)) = comps_resource { resources.push(ImageResource::new(crate::comps_map::LAYER_COMPS, data)); } diff --git a/crates/io/src/psd_export/smart.rs b/crates/io/src/psd_export/smart.rs index f1ac7431b6..3bda3e3240 100644 --- a/crates/io/src/psd_export/smart.rs +++ b/crates/io/src/psd_export/smart.rs @@ -236,7 +236,18 @@ pub(super) fn link_group_resource(layers: &[Layer]) -> Option> { for l in layers { if let LayerContent::Group(g) = &l.content { out.push(None); - walk(&g.children, out); + // `emit` writes a frame as its group around a "Frame Image" group (divider, + // children, record) and an optional "Frame Stroke" shape; none of those is linked. + if let Some(frame) = &l.frame { + out.push(None); + walk(&g.children, out); + out.push(None); + if frame.stroke.is_some() { + out.push(None); + } + } else { + walk(&g.children, out); + } } out.push(l.link_group); } diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs index d661166018..ce6c36dab2 100644 --- a/crates/io/src/psd_gate.rs +++ b/crates/io/src/psd_gate.rs @@ -79,14 +79,14 @@ pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> { ATTEMPT_ALLOWANCE.with(|a| match a.get() { Some(remaining) => { if bytes == u64::MAX || bytes > remaining { - return Err(IoError::PsdImportBudget { required: bytes, limit: remaining }); + return Err(IoError::PsdImportBudget { required: bytes, limit: remaining, free_memory: None }); } a.set(Some(remaining - bytes)); Ok(()) } None => { // Unknown allowance during reentrant gate must fail closed. - Err(IoError::PsdImportBudget { required: bytes, limit: 0 }) + Err(IoError::PsdImportBudget { required: bytes, limit: 0, free_memory: None }) } }) } @@ -193,7 +193,7 @@ mod tests { set_active_attempt_budget(u64::MAX); let err = consume_attempt_allowance(u64::MAX).expect_err("the overflow sentinel is never a charge"); - assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == u64::MAX && limit == u64::MAX)); + assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == u64::MAX && limit == u64::MAX)); assert_eq!(active_attempt_remaining(), Some(u64::MAX), "a refused sentinel charge leaves the parent's allowance untouched"); // One byte below the sentinel is an ordinary, enormous charge. diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 12aabf6e8d..5e582ffd81 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -421,6 +421,17 @@ impl Ctx<'_> { } fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { + if l.is_group() + && let Some(data) = rec.block(b"pcFr").filter(|b| b.data.len() <= 8 << 20) + { + let end = data.data.iter().rposition(|b| *b != 0).map_or(0, |n| n.saturating_add(1)); + l.frame = data + .data + .get(..end) + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) + .filter(photocraft_doc::Frame::valid) + .map(Box::new); + } l.visible = rec.is_visible(); l.opacity = f32::from(rec.opacity) / 255.0; l.fill_opacity = f32::from(rec.fill_opacity()) / 255.0; @@ -699,7 +710,7 @@ impl Ctx<'_> { crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source) }) }); - LayerContent::Smart(SmartObject { + let mut sm = SmartObject { contents_id, source: SmartSource::Linked { path: id }, vector_preview, @@ -712,7 +723,9 @@ impl Ctx<'_> { warp, stack_mode: None, perspective, - }) + }; + crate::smart_map::pin_puppet_meshes(&mut sm); + LayerContent::Smart(sm) } /// Typed vector mask from `vsms`/`vmsk` (non-shape layers). A user mask that Photoshop @@ -771,6 +784,17 @@ impl Ctx<'_> { l.psd_blocks = preserved_blocks(rec); self.apply_common(&mut l, rec, sd.and_then(|s| s.blend_mode)); self.apply_vector_mask(&mut l, rec); + if l.frame.is_some() { + let clip = + l.children().and_then(|children| children.first()).filter(|child| child.psd_blocks.iter().any(|(k, _)| k == b"pcFc")).cloned(); + if let Some(mut clip) = clip { + if let (Some(target), Some(children)) = (l.children_mut(), clip.children_mut()) { + *target = std::mem::take(children); + } + l.mask = clip.mask; + l.vector_mask = clip.vector_mask; + } + } l } }) @@ -778,18 +802,32 @@ impl Ctx<'_> { } } -/// Sets `Layer::link_group` from resource 1026's per-record ids (`nodes` and `layers` correspond). -fn apply_link_groups(nodes: &[LayerNode], layers: &mut [Layer], ids: &[u16]) { - fn rec(nodes: &[LayerNode], layers: &mut [Layer], ids: &[u16], depth: usize) { +/// Sets `Layer::link_group` from resource 1026's per-record ids (`nodes` and `layers` correspond, +/// except that a frame holds its `pcFc` clip group's children, as `build` adopted them). +fn apply_link_groups(nodes: &[LayerNode], layers: &mut [Layer], records: &[LayerRecord], ids: &[u16]) { + fn rec(nodes: &[LayerNode], layers: &mut [Layer], records: &[LayerRecord], ids: &[u16], depth: usize) { for (n, l) in nodes.iter().zip(layers.iter_mut()) { let index = match n { LayerNode::Layer { index } => *index, LayerNode::Group { index, children, .. } => { + // The frame's children are its clip group's, one level further down. + let clip = match children.first() { + Some(LayerNode::Group { index, children, .. }) + if l.frame.is_some() && records.get(*index).is_some_and(|r| r.block(b"pcFc").is_some()) => + { + Some(children) + } + _ => None, + }; + let (children, depth) = match clip { + Some(c) => (c, depth + 1), + None => (children, depth), + }; // Matches the importer's nesting cap: deeper children were not imported. if depth < photocraft_doc::MAX_GROUP_DEPTH && let LayerContent::Group(g) = &mut l.content { - rec(children, &mut g.children, ids, depth + 1); + rec(children, &mut g.children, records, ids, depth + 1); } *index } @@ -797,7 +835,7 @@ fn apply_link_groups(nodes: &[LayerNode], layers: &mut [Layer], ids: &[u16]) { l.link_group = ids.get(index).copied().filter(|&g| g != 0).map(u64::from); } } - rec(nodes, layers, ids, 0); + rec(nodes, layers, records, ids, 0); } /// Rejects a file whose groups nest deeper than the document model's cap, with the actionable @@ -1202,6 +1240,7 @@ fn read_initial_metadata( if let Some(scale) = crate::annotations_map::raw_scale(doc) { doc.measurement.scale = scale; } + crate::annotations_map::import_count_resource(doc); let _ = layered; (damaged, alloc_error) } @@ -1408,7 +1447,7 @@ fn import_decoded_content(import: DecodedImport<'_>, doc: &mut Document, cx: &mu fn import_multichannel(file: &PsdFile, merged: &Option>>, doc: &mut Document, cx: &mut Ctx<'_>) { if let Some(Ok(all)) = merged { - let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + let names = file.alpha_channel_names(); if let Err(error) = crate::multichannel_map::import(file, all, doc, &names, &mut cx.warnings) { cx.fatal_error.get_or_insert(error); } @@ -1428,7 +1467,7 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_ if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) { - apply_link_groups(&tree, &mut doc.layers, &groups); + apply_link_groups(&tree, &mut doc.layers, file.layers(), &groups); } let Some((n, plane)) = sizes else { return }; let Some(Ok(all)) = merged else { return }; @@ -1551,6 +1590,32 @@ fn import_flattened( } } +/// Spot inks whose DisplayInfo colour is in a space PhotoCraft doesn't read take their colour +/// from the Alternate Spot Colors (1067), matched by the identifiers of 1053. `ids_from` is the +/// first imported channel's position in 1053 (1 past a merged transparency), `skip` the +/// DisplayInfo records before it. +pub(crate) fn alternate_spot_inks(file: &PsdFile, ids_from: usize, skip: usize, channels: &mut [AlphaChannel]) { + let (Some(alt), Some(info), Some(ids)) = ( + file.resource(crate::channel_map::ALTERNATE_SPOT_COLORS), + file.resource(crate::channel_map::DISPLAY_INFO).map(|r| (r, true)).or_else(|| file.resource(crate::channel_map::DISPLAY_INFO_OLD).map(|r| (r, false))), + file.resource(crate::channel_map::ALPHA_IDENTIFIERS), + ) else { + return; + }; + let alt = crate::channel_map::alternate_spot_colors(&alt.data); + let spaces = crate::channel_map::display_spaces(&info.0.data, info.1, skip); + let ids: Vec = ids.data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).skip(ids_from).collect(); + for ((ch, space), id) in channels.iter_mut().zip(spaces).zip(ids) { + let Some((_, solidity)) = ch.spot else { continue }; + if crate::channel_map::decode_known_color(space, [0; 4]).is_some() { + continue; + } + if let Some((_, ink)) = alt.iter().find(|(alt_id, _)| *alt_id == id) { + ch.spot = Some((*ink, solidity)); + } + } +} + fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_>) { // Extra (alpha / spot) channels of the merged image. if !import.layered { @@ -1561,7 +1626,7 @@ fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut let Some((n, plane)) = sizes else { return }; let cc = cx.cc; let first = cc + usize::from(file.merged_has_alpha()); - let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + let names = file.alpha_channel_names(); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(file.header.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); @@ -1603,6 +1668,7 @@ fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels); } + alternate_spot_inks(file, first.saturating_sub(cc), skip, &mut doc.channels); if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO) && r.data.len() >= 2 && let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first) diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index f2165dbb87..e40c782ef0 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -20,10 +20,11 @@ pub const MAX_SIDE: u32 = 160; /// Size of the header in front of the JFIF data. pub const HEADER_LEN: usize = 28; -/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`], converted to -/// sRGB through the document's profile, matted over white and JPEG-compressed. +/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`] with its spot inks +/// printed over it, converted to sRGB through the document's profile, matted over white and +/// JPEG-compressed. pub(crate) fn resource(doc: &Document) -> Result { - let mut buf = photocraft_compose::thumbnail_buffer(doc, MAX_SIDE); + let mut buf = photocraft_compose::multichannel::thumbnail_with_inks(doc, MAX_SIDE); let (w, h) = (buf.rect.width(), buf.rect.height()); if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || buf.px.len() != (w as usize) * (h as usize) { return Err(IoError::Unsupported("the image preview could not be rendered".into())); diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs new file mode 100644 index 0000000000..f1015af30b --- /dev/null +++ b/crates/io/src/puppet_map.rs @@ -0,0 +1,308 @@ +//! Puppet Warp smart filters (`Fltr` class `rigidTransform`) as observed in Photoshop-authored +//! mockup PSDs. +//! +//! The filter holds a `puppetShapeList` with one `puppetShape` per mesh. Each shape stores its +//! pins (`PinP` where they were placed, `posFinalPins` where they are now, x, y pairs in document +//! pixels) and the mesh Photoshop solved for them, as raw data: +//! +//! ```text +//! originalVertexArray, deformedVertexArray: f32 LE x, y per vertex (document pixels) +//! indexArray: i32 LE, three per triangle +//! ``` +//! +//! and the corners of the layer the mesh was built on (`PuX0..3`, `PuY0..3` on the filter). +//! Replace Contents keeps the mesh: Photoshop redraws the new contents through the stored +//! triangles (measured on a dot grid: 0.1 px mean). So the mesh is imported as the +//! `layer.smartObjects.puppetWarp` filter's `mesh`, which is drawn as it is while the pins are +//! unchanged, and the whole item is kept as a template ([`TEMPLATE_KEY`]) to write back. Pins +//! alone feed PhotoCraft's own solver once they are edited; the mode, density and rotation +//! settings aren't mapped (their stored values aren't identified), so those stay at defaults. + +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Descriptor, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, set, to_hex}; +use crate::blocks::num; + +pub(super) const CLASS: &str = "rigidTransform"; +pub(super) const COMMAND: &str = "layer.smartObjects.puppetWarp"; +pub(super) const TEMPLATE_KEY: &str = "__puppetPsd"; +/// Far beyond the few hundred vertices Photoshop's meshes have, yet small enough that the +/// copies a hostile mesh makes (arrays, JSON params, cloned filters) stay bounded. +const MAX_VERTS: usize = 1 << 16; + +fn raw<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { + match d.get(key)? { + Value::RawData(b) => Some(b), + _ => None, + } +} + +fn floats(d: &Descriptor, key: &str) -> Option> { + let Value::List(v) = d.get(key)? else { return None }; + v.iter().map(|x| num(Some(x)).filter(|f| f.is_finite())).collect() +} + +/// `f32` LE x, y pairs; `None` unless whole, finite and at most [`MAX_VERTS`]. +fn vertices(b: &[u8]) -> Option> { + if !b.len().is_multiple_of(8) || b.len() / 8 > MAX_VERTS { + return None; + } + let f = |c: &[u8]| c.try_into().ok().map(f32::from_le_bytes).filter(|v| v.is_finite()).map(f64::from); + b.as_chunks::<8>().0.iter().map(|c| Some([f(&c[..4])?, f(&c[4..])?])).collect() +} + +/// `i32` LE triples, each index below `n`. +fn triangles(b: &[u8], n: usize) -> Option> { + if !b.len().is_multiple_of(12) || b.len() / 12 > 2 * MAX_VERTS { + return None; + } + let i = |c: &[u8]| c.try_into().ok().map(i32::from_le_bytes).and_then(|v| usize::try_from(v).ok()).filter(|&v| v < n); + b.as_chunks::<12>().0.iter().map(|c| Some([i(&c[..4])?, i(&c[4..8])?, i(&c[8..])?])).collect() +} + +/// One shape's pins (as params), vertices, deformed vertices and triangles. +type Shape = (Vec, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[usize; 3]>); + +/// Pins and mesh of one `puppetShape`, with vertex indices offset by `base`. +fn shape(s: &Descriptor, base: usize) -> Option { + let (from, to) = (floats(s, "PinP")?, floats(s, "posFinalPins")?); + if from.len() != to.len() || !from.len().is_multiple_of(2) { + return None; + } + let pins = from.as_chunks::<2>().0.iter().zip(to.as_chunks::<2>().0).map(|(a, b)| json!({"src": a, "dst": b})).collect(); + let verts = vertices(raw(s, "originalVertexArray")?)?; + let deformed = vertices(raw(s, "deformedVertexArray")?)?; + if verts.len() != deformed.len() { + return None; + } + let tris = triangles(raw(s, "indexArray")?, verts.len())?; + Some((pins, verts, deformed, tris.into_iter().map(|t| t.map(|i| i + base)).collect())) +} + +/// Pins and mesh of a `rigidTransform` descriptor (shapes joined into one mesh), as +/// `{"pins", "mesh"}`. +fn settings(f: &Descriptor) -> Option { + let Some(Value::List(shapes)) = f.get("puppetShapeList") else { return None }; + let (mut pins, mut verts, mut deformed, mut tris) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for s in shapes { + let Value::Descriptor(s) = s else { return None }; + let (p, v, d, t) = shape(s, verts.len())?; + pins.extend(p); + verts.extend(v); + deformed.extend(d); + tris.extend(t); + } + if tris.is_empty() || verts.len() > MAX_VERTS { + return None; + } + let pins = J::Array(pins); + Some(json!({"pins": pins, "mesh": {"pins": pins, "verts": verts, "deformed": deformed, "tris": tris}})) +} + +/// The corners of the layer the mesh was built on (`PuX0`, `PuY0` … `PuX3`, `PuY3`), as x, y +/// pairs. +pub(super) fn built_on(params: &J) -> Option<[f64; 8]> { + let item = template(params).ok()?; + let f = get_desc(&item, "Fltr")?; + let mut q = [0.0; 8]; + for i in 0..4 { + q[2 * i] = num(f.get(&format!("PuX{i}"))).filter(|v| v.is_finite())?; + q[2 * i + 1] = num(f.get(&format!("PuY{i}"))).filter(|v| v.is_finite())?; + } + Some(q) +} + +/// `layer.smartObjects.puppetWarp` params for a Puppet Warp item with a readable mesh. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let mut params = settings(f)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&template.to_bytes()))); + Some(params) +} + +/// The template item of `params`, checked to be a Puppet Warp filter. +fn template(params: &J) -> Result { + let raw = params.get(TEMPLATE_KEY).and_then(J::as_str).ok_or("a Puppet Warp made in PhotoCraft is not written to PSD")?; + let bytes = from_hex(raw).ok_or("the Puppet Warp PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Puppet Warp PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) { + return Err("the Puppet Warp PSD data is not a Puppet Warp filter".into()); + } + Ok(item) +} + +/// The `filterFXList` item: the template, while the pins are still the imported ones. +pub(super) fn export_item(params: &J) -> Result { + let item = template(params)?; + let imported = get_desc(&item, "Fltr").and_then(settings).ok_or("the Puppet Warp PSD data has no readable mesh")?; + if params.get("pins") != imported.get("pins") { + return Err("Puppet Warp pins moved in PhotoCraft are not written to PSD".into()); + } + // Imported params set no solver settings, so a set one is an edit the template doesn't hold. + if let Some(k) = ["mode", "density", "expansion"].into_iter().find(|k| params.get(*k).is_some()) { + return Err(format!("the Puppet Warp `{k}` set in PhotoCraft is not written to PSD")); + } + Ok(item) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter, parse_sold}; + use photocraft_psd::descriptor::{UnicodeString, VersionedDescriptor}; + + const QUAD: [f64; 8] = [10.0, 20.0, 110.0, 22.0, 108.0, 220.0, 12.0, 218.0]; + + fn list(v: &[f64]) -> Value { + Value::List(v.iter().map(|x| Value::Double(*x)).collect()) + } + + fn f32s(v: &[[f32; 2]]) -> Value { + Value::RawData(v.iter().flatten().flat_map(|x| x.to_le_bytes()).collect()) + } + + fn i32s(v: &[i32]) -> Value { + Value::RawData(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + /// A Puppet Warp shape as Photoshop writes it: a box of two triangles with one pin. + fn shape(dx: f32) -> Descriptor { + let verts = [[10.0, 20.0], [110.0, 22.0], [108.0, 220.0], [12.0, 218.0]]; + let moved: Vec<[f32; 2]> = verts.iter().map(|v| [v[0] + dx, v[1]]).collect(); + Descriptor::new("puppetShape") + .with("rigidType", Value::Boolean(true)) + .with("originalVertexArray", f32s(&verts)) + .with("deformedVertexArray", f32s(&moved)) + .with("indexArray", i32s(&[0, 1, 2, 0, 2, 3])) + .with("pinOffsets", list(&[0.0, 0.0])) + .with("posFinalPins", list(&[60.0 + f64::from(dx), 120.0])) + .with("pinVertexIndices", list(&[0.0])) + .with("PinP", list(&[60.0, 120.0])) + .with("meshExpansion", Value::Integer(2)) + } + + fn item(shapes: Vec) -> Descriptor { + let mut f = Descriptor::new(CLASS) + .with("rigidType", Value::Boolean(true)) + .with("puppetShapeList", Value::List(shapes.into_iter().map(Value::Descriptor).collect())); + for i in 0..4 { + f = f.with(&format!("PuX{i}"), Value::Double(QUAD[2 * i])).with(&format!("PuY{i}"), Value::Double(QUAD[2 * i + 1])); + } + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Puppet Warp"))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(973)) + } + + #[test] + fn puppet_items_map_to_the_command_with_their_mesh() { + let src = item(vec![shape(4.0), shape(-2.0)]); + let f = filter_from_item(&src); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["pins"], json!([{"src": [60.0, 120.0], "dst": [64.0, 120.0]}, {"src": [60.0, 120.0], "dst": [58.0, 120.0]}])); + let mesh = &f.params["mesh"]; + assert_eq!(mesh["pins"], f.params["pins"]); + assert_eq!(mesh["verts"].as_array().unwrap().len(), 8); + assert_eq!(mesh["verts"][2], json!([108.0, 220.0])); + assert_eq!(mesh["deformed"][2], json!([112.0, 220.0])); + // The second shape's triangles index its own vertices. + assert_eq!(mesh["tris"], json!([[0, 1, 2], [0, 2, 3], [4, 5, 6], [4, 6, 7]])); + assert_eq!(built_on(&f.params), Some(QUAD)); + // Unchanged pins write the original item back; blending is the model's. + assert_eq!(item_for_filter(&f).unwrap(), src); + let mut faded = f.clone(); + faded.opacity = 0.5; + assert_eq!(filter_from_item(&item_for_filter(&faded).unwrap()).opacity, 0.5); + // Moved pins aren't written (the stored mesh would no longer match them). + let mut moved = f.clone(); + moved.params["pins"][0]["dst"] = json!([70.0, 120.0]); + assert!(item_for_filter(&moved).is_err()); + // Nor are solver settings set after import (the template holds the imported ones). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut edited = f.clone(); + edited.params[k] = v; + assert!(item_for_filter(&edited).is_err(), "{k}"); + } + // The import records the placement the mesh was solved for; the item written back is + // unchanged by it. + let mut sm = photocraft_doc::SmartObject::new( + photocraft_doc::SmartSource::Linked { path: String::new() }, + photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, + None, + ); + sm.smart_filters.push(f.clone()); + crate::smart_map::pin_puppet_meshes(&mut sm); + assert_eq!(sm.smart_filters[0].params["mesh"]["placement"], sm.placement_json()); + assert_eq!(item_for_filter(&sm.smart_filters[0]).unwrap(), src); + // A Puppet Warp made in PhotoCraft has no template: not written, as before. + let own = SmartFilter { params: json!({"pins": f.params["pins"]}), ..f }; + assert!(item_for_filter(&own).is_err()); + } + + #[test] + fn unreadable_puppet_filters_stay_opaque() { + let with = |key: &str, v: Value| { + let mut s = shape(4.0); + set(&mut s, key, v); + item(vec![s]) + }; + for src in [ + with("originalVertexArray", Value::RawData(vec![0; 31])), + with("originalVertexArray", f32s(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])), + with("deformedVertexArray", f32s(&[[f32::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])), + with("indexArray", i32s(&[0, 1, 4])), + with("indexArray", i32s(&[0, 1, -1])), + with("indexArray", Value::RawData(vec![0; 10])), + with("indexArray", i32s(&[])), + with("posFinalPins", list(&[60.0])), + with("PinP", Value::Integer(3)), + item(vec![]), + ] { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn a_mesh_built_on_other_corners_is_dropped() { + // The mesh is in document pixels where the layer was when it was built: a layer whose + // corners differ from the stored ones keeps the pins but not the mesh. + let sold = |quad: [f64; 8]| { + let fx = Descriptor::new("filterFXStyle") + .with("enab", Value::Boolean(true)) + .with("filterFXList", Value::List(vec![Value::Descriptor(item(vec![shape(4.0)]))])); + let d = Descriptor::new("null").with("Trnf", list(&quad)).with("nonAffineTransform", list(&quad)).with("filterFX", Value::Descriptor(fx)); + let mut v = b"soLD".to_vec(); + v.extend_from_slice(&4u32.to_be_bytes()); + v.extend(VersionedDescriptor::new(d).to_bytes()); + v + }; + let filter = |quad| parse_sold(&sold(quad)).and_then(|p| p.stack).map(|s| s.filters[0].clone()).expect("a stack"); + let kept = filter(QUAD); + assert_eq!(kept.command, COMMAND); + assert!(kept.params.get("mesh").is_some()); + let mut moved = QUAD; + moved[0] += 5.0; + let f = filter(moved); + assert_eq!(f.command, COMMAND); + assert!(f.params.get("mesh").is_none()); + assert_eq!(f.params["pins"], kept.params["pins"]); + // Still written back as it was. + assert_eq!(item_for_filter(&f).unwrap(), item(vec![shape(4.0)])); + } +} diff --git a/crates/io/src/renderer.rs b/crates/io/src/renderer.rs new file mode 100644 index 0000000000..158aeb27b0 --- /dev/null +++ b/crates/io/src/renderer.rs @@ -0,0 +1,124 @@ +//! Renderer contract and band provider interface for headless composition and export. + +use photocraft_doc::{DocId, Document}; +use photocraft_geom::Rect; +use photocraft_raster::Interrupt; + +use crate::IoError; + +/// A horizontal band of rendered straight-alpha RGBA float samples (`[R, G, B, A]`). +/// +/// Semantics: +/// - Color space: the document's embedded colour profile for RGB documents (sRGB when the +/// document is untagged), sRGB working composite for Lab and Grayscale, or profile-converted +/// composite for CMYK. +/// - Alpha representation: straight (unpremultiplied) alpha in `pixels[..][3]`, where `1.0` is +/// fully opaque and `0.0` is fully transparent. Color channels `pixels[..][0..3]` carry +/// unpremultiplied sample values. +/// - Memory layout: contiguous row-major vector of length `rect.width() * rect.height()`. +/// Sample at `(x, y)` is located at `(y - rect.y0) * rect.width() + (x - rect.x0)`. +/// - Geometry: `rect` spans document coordinates, matching full document width across horizontal +/// slices from top (`y = 0`) to bottom (`y = height`). +#[derive(Clone, Debug)] +pub struct RenderBand { + /// Document-coordinate bounding rectangle of this band. + pub rect: Rect, + /// Interleaved straight-alpha samples `[r, g, b, a]`, row-major. + pub pixels: Vec<[f32; 4]>, +} + +/// Errors raised by a [`BandRenderer`]. +#[derive(Debug, thiserror::Error)] +pub enum RenderError { + /// The document or requested feature is not supported by this renderer. + #[error("unsupported document or feature: {0}")] + Unsupported(String), + /// The rendering backend, hardware adapter, or pipeline is unavailable. + #[error("rendering backend unavailable: {0}")] + Unavailable(String), + /// A GPU device error, device loss, or driver crash occurred. + #[error("rendering device failure: {0}")] + Device(String), + /// The rendering operation was cancelled by caller request. + #[error("rendering was cancelled")] + Cancelled, + /// Rendering execution failed within the rendering provider. + #[error("rendering failed: {0}")] + Execution(String), + /// Downstream sink callback returned an I/O, encoder, or cancellation error. + #[error("sink error: {0}")] + Sink(#[source] Box), +} + +impl RenderError { + /// Create a new sink error wrapping [`IoError`]. + pub fn sink(err: IoError) -> Self { + match err { + IoError::Cancelled => RenderError::Cancelled, + other => RenderError::Sink(Box::new(other)), + } + } + + /// Whether this error represents an availability or capability limitation of the renderer, + /// where a complete export attempt should restart on the reference CPU compositor. + /// + /// Sink errors (codec failures, disk full, cancellation) are fatal to the export and + /// MUST NEVER be retried on the CPU compositor. Execution errors represent contract or + /// internal provider failures that are rejected. + pub fn is_retryable(&self) -> bool { + match self { + RenderError::Unsupported(_) | RenderError::Unavailable(_) | RenderError::Device(_) => true, + RenderError::Execution(_) | RenderError::Cancelled | RenderError::Sink(_) => false, + } + } +} + +/// A provider of rendered composite bands for flat export. +pub trait BandRenderer: Send { + /// Renders `doc` over `rect` in horizontal bands, passing each band to `sink`. + /// `band_rows`: preferred rows per band (0 to let provider choose). `ctl` reports + /// cancellation; a renderer that waits for the device polls it between bounded waits. + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError>; + + /// Drain renderer diagnostic messages (e.g. backend/adapter status once, fallback reasons). + fn drain_diagnostics(&mut self) -> Vec { + Vec::new() + } + + /// Record a diagnostic message on this renderer. + fn push_diagnostic(&mut self, _diagnostic: String) {} + + /// Drop cached state for a document that was closed or replaced. The default does nothing: + /// renderers without per-document caches stay ready for the next document. + fn forget_document(&mut self, _doc: DocId) {} +} + +/// Default reference band renderer using `photocraft-compose`. +#[derive(Clone, Copy, Debug, Default)] +pub struct CpuRenderer; + +impl BandRenderer for CpuRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + ctl.check().map_err(|_| RenderError::Cancelled)?; + photocraft_compose::render_bands(doc, rect, band_rows, |buf| { + // Stop at the next band when the sink cancels, instead of composing the whole canvas. + ctl.check().map_err(|_| IoError::Cancelled)?; + sink(RenderBand { rect: buf.rect, pixels: buf.into_px() }) + }) + .map_err(RenderError::sink) + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index cacce2b7cb..53c0c258e3 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -25,7 +25,7 @@ //! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`. use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{LayerMask, SmartFilter}; +use photocraft_doc::{LayerMask, SmartFilter, SmartObject}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use photocraft_geom::{Affine, Rect as GeomRect}; use photocraft_psd::Compression; @@ -49,8 +49,13 @@ mod displace; mod hue_sat; #[path = "perspective_warp_map.rs"] mod perspective_warp; +#[path = "puppet_map.rs"] +mod puppet; pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map}; +pub use perspective_warp::{ + MAX_QUADS as MAX_PERSPECTIVE_WARP_PLANES, geometry as perspective_warp_geometry, indexed_geometry as perspective_warp_indexed_geometry, +}; mod export; mod import; @@ -130,10 +135,44 @@ pub fn parse_sold(data: &[u8]) -> Option { let d = sold_descriptor(data)?; let (idnt, transform) = crate::blocks::parse_smart(b"SoLd", data); let placed = text_of(&d, "placed").unwrap_or_default(); - let stack = get_desc(&d, "filterFX").map(filter_stack); + let mut stack = get_desc(&d, "filterFX").map(filter_stack); + if let Some(stack) = &mut stack { + drop_moved_puppet_meshes(stack, &d); + } Some(Placed { descriptor: d, idnt, placed, transform, stack }) } +/// A Puppet Warp's stored mesh sits where the layer was when it was built (document pixels); one +/// built on other corners than the layer's own doesn't fit it, so its pins are solved instead. +fn drop_moved_puppet_meshes(stack: &mut FilterStack, d: &Descriptor) { + let corners = |k: &str| match d.get(k) { + Some(Value::List(v)) if v.len() == 8 => v.iter().map(|x| num(Some(x))).collect::>>(), + _ => None, + }; + let quad = corners("nonAffineTransform").or_else(|| corners("Trnf")); + for f in stack.filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + let fits = match (&quad, puppet::built_on(&f.params)) { + (Some(q), Some(b)) => q.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01), + _ => false, + }; + if !fits && let Some(p) = f.params.as_object_mut() { + p.remove("mesh"); + } + } +} + +/// Records on each imported Puppet Warp mesh the placement it was solved for (`mesh.placement`, +/// [`SmartObject::placement_json`]): once the smart object is moved, transformed or warped, the +/// mesh no longer fits it and the pins are solved instead. +pub(crate) fn pin_puppet_meshes(sm: &mut SmartObject) { + let placement = sm.placement_json(); + for f in sm.smart_filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + if let Some(m) = f.params.get_mut("mesh").and_then(J::as_object_mut) { + m.insert("placement".into(), placement.clone()); + } + } +} + /// Reads a `filterFX` object. pub fn filter_stack(fx: &Descriptor) -> FilterStack { let filters = match fx.get("filterFXList") { diff --git a/crates/io/src/smart_map/export.rs b/crates/io/src/smart_map/export.rs index e057a7032f..bc96de4cd4 100644 --- a/crates/io/src/smart_map/export.rs +++ b/crates/io/src/smart_map/export.rs @@ -7,7 +7,7 @@ use super::import::{ use super::placed::enumv; use super::{ Class, Descriptor, FilterStack, Id, J, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, camera_raw, displace, hue_sat, - perspective_warp, + perspective_warp, puppet, }; // ---------- writing ---------- @@ -309,6 +309,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if perspective_warp::handles(&f.command) { return perspective_warp::export_item(f); } + if f.command == puppet::COMMAND && f.params.get(puppet::TEMPLATE_KEY).is_some() { + let mut d = puppet::export_item(&f.params)?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index f8f3f50e3f..e381efe058 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -2,7 +2,7 @@ use super::export::{CELL_SIZE, blend_options, is_meta, pi, rgbc, whole}; use super::placed::enumv; use super::{ BlendMode, Descriptor, Id, J, Map, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, bool_or, camera_raw, displace, enum_of, get_desc, - hue_sat, json, num, perspective_warp, text_of, + hue_sat, json, num, perspective_warp, puppet, text_of, }; // ---------- the filters we model ---------- @@ -867,6 +867,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), Some(hue_sat::CLASS) => fltr.and_then(hue_sat::import_params).map(|p| (hue_sat::COMMAND, p)), Some(perspective_warp::CLASS) => perspective_warp::import_params(item).map(|p| (perspective_warp::COMMAND, p)), + Some(puppet::CLASS) => puppet::import_params(item).map(|p| (puppet::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), Some(c @ ("Fbrs" | "Clds" | "DfrC")) => fltr.and_then(|f| coloured_params(c, item, f)), // A `Fltr` that is there but not a descriptor is settings we can't read: kept verbatim diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 0728035530..ba8900fb87 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -105,6 +105,19 @@ const MAX_PEAK_PIXEL_BYTES: u64 = 3 << 29; const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). const MAX_RECURSION: usize = 200; +/// Most filter work beyond the ordinary a drawing may ask of resvg, in pixel reads (see +/// [`filter_cost`]): about ten seconds in a release build (resvg manages roughly 200 million reads +/// a second, and its filters can't be interrupted). +const MAX_FILTER_COST: u64 = 2_000_000_000; +/// Reads per pixel any primitive may take for free (a 16 × 16 morphology window or kernel, 16 +/// turbulence octaves), so ordinary filters open on any canvas. +const ORDINARY_FILTER_READS: u64 = 256; +/// Most elements [`filter_cost`] visits, counting a shared mask, clip path or pattern once per +/// use, as resvg renders it: patterns that fill and stroke with the next one double the work at +/// each level, so a few dozen levels would keep the check (and the render) busy for hours. +const MAX_FILTER_VISITS: usize = 10_000_000; +/// One `feTurbulence` octave per pixel, in pixel reads (it measures about 15 times a read). +const TURBULENCE_OCTAVE_COST: u64 = 16; /// Deepest element nesting accepted. The XML parser recurses once per level, so a file nested /// a few thousand deep (70 KB of ``) overflows a 2 MB thread stack, an abort no guard can /// catch; real drawings stay under a hundred levels. @@ -268,15 +281,78 @@ fn in_memory(id: usvg::fontdb::ID, db: &mut Arc) -> usvg loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id) } +/// Most `cmap` table bytes [`has_char`] keeps; past it a face is asked on disk (slow, still right). +#[cfg(not(target_arch = "wasm32"))] +const MAX_CACHED_CMAP_BYTES: usize = 64 << 20; + +/// Whether face `face` maps `c` to a glyph, as usvg's own check decides it. usvg reads a font +/// file's whole font for every face it asks, so one character no font has re-read every +/// installed font once per text element (430 ms each with 273 faces). A file face's `cmap` table +/// is kept instead, read once per process; a file that doesn't load isn't remembered. +#[cfg(not(target_arch = "wasm32"))] +fn has_char(db: &usvg::fontdb::Database, face: &usvg::fontdb::FaceInfo, c: char) -> bool { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::{Mutex, PoisonError}; + use ttf_parser::{Face, Tag, cmap}; + type Cmaps = (HashMap<(PathBuf, u32), Arc>>, usize); + static CMAPS: OnceLock> = OnceLock::new(); + let maps = || CMAPS.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let in_cmap = + |table: &[u8]| cmap::Table::parse(table).is_some_and(|t| t.subtables.into_iter().any(|s| s.is_unicode() && s.glyph_index(u32::from(c)).is_some())); + let usvg_check = |data: &[u8], index| Face::parse(data, index).is_ok_and(|f| f.glyph_index(c).is_some()); + let usvg::fontdb::Source::File(path) = &face.source else { + return db.with_face_data(face.id, usvg_check).unwrap_or(false); + }; + let key = (path.clone(), face.index); + if let Some(table) = maps().0.get(&key).cloned() { + return in_cmap(&table); + } + // usvg's check parses the whole face first: one that doesn't parse has no characters. Its + // `cmap` is the last such table record (a linear walk, unsorted directories included). + let cmap_of = |data: &[u8], index| { + let face = Face::parse(data, index).ok()?; + let raw = face.raw_face(); + let record = raw.table_records.into_iter().filter(|r| r.tag == Tag::from_bytes(b"cmap")).last(); + let table = record.and_then(|r| { + let start = usize::try_from(r.offset).ok()?; + raw.data.get(start..start.checked_add(usize::try_from(r.length).ok()?)?) + }); + Some(table.unwrap_or_default().to_vec()) + }; + let Some(table) = db.with_face_data(face.id, cmap_of).flatten() else { + return false; + }; + let found = in_cmap(&table); + let mut maps = maps(); + // An import that missed at the same time may have kept it already. + if !maps.0.contains_key(&key) && maps.1.saturating_add(table.len()) <= MAX_CACHED_CMAP_BYTES { + maps.1 = maps.1.saturating_add(table.len()); + maps.0.insert(key, Arc::new(table)); + } + found +} + +/// usvg's fallback choice (the first face, in database order, not used yet, sharing the base +/// face's style, weight or stretch and having `c`), asked through [`has_char`]. +#[cfg(not(target_arch = "wasm32"))] +fn fallback_face(c: char, used: &[usvg::fontdb::ID], db: &usvg::fontdb::Database) -> Option { + let base = db.face(*used.first()?)?; + db.faces() + .filter(|f| !used.contains(&f.id)) + .filter(|f| base.style == f.style || base.weight == f.weight || base.stretch == f.stretch) + .find(|f| has_char(db, f, c)) + .map(|f| f.id) +} + /// usvg's font choice, with the chosen faces read from memory ([`in_memory`]). fn font_resolver() -> usvg::FontResolver<'static> { #[cfg(not(target_arch = "wasm32"))] { let select = usvg::FontResolver::default_font_selector(); - let fallback = usvg::FontResolver::default_fallback_selector(); usvg::FontResolver { select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))), - select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))), + select_fallback: Box::new(|c, used, db| fallback_face(c, used, db).map(|id| in_memory(id, db))), } } #[cfg(target_arch = "wasm32")] @@ -316,7 +392,7 @@ fn options(fontdb: Arc, depth_left: usize) -> usvg::Opti Ok(std::borrow::Cow::Owned(xml)) if !xml.starts_with(&[0x1f, 0x8b]) => ("image/svg+xml", Arc::new(xml)), _ => return None, }; - if nesting_depth(&xml, depth_left) > depth_left { + if !nesting_depth(&xml, depth_left).is_ok_and(|depth| depth <= depth_left) { return None; } return default_resolver(mime, xml, opts); @@ -353,7 +429,7 @@ fn parse(bytes: &[u8]) -> Result { /// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one). fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Result { let xml = inflate(bytes)?; - let depth = nesting_depth(&xml, MAX_XML_DEPTH); + let depth = nesting_depth(&xml, MAX_XML_DEPTH)?; if depth > MAX_XML_DEPTH { return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep"))); } @@ -365,10 +441,19 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Resul // 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()))?; + let tree = guarded(|| usvg::Tree::from_str(text, &opt)) + .ok_or_else(|| IoError::Svg("the SVG parser failed on this drawing".into()))? + .map_err(|e| IoError::Svg(e.to_string()))?; Ok(Parsed { tree, fontless_text }) } +/// `f` under `catch_unwind`, `None` when it panics: usvg, resvg and tiny-skia panic on some +/// malformed drawings, which must fail like any unreadable file, not crash (as the HEIF and +/// JPEG XL decoders are guarded). Where a panic aborts (wasm) this changes nothing. +fn guarded(f: impl FnOnce() -> T) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok() +} + const MAX_BOUNDED_SVG_BYTES: u64 = 16 << 20; const MAX_EMBEDDED_SVG_DEPTH: usize = 32; @@ -605,7 +690,7 @@ fn parse_image_bounded_with( if left.is_none() { return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))); } - let nesting = nesting_depth(&xml, xml_left); + let nesting = nesting_depth(&xml, xml_left)?; if nesting > xml_left { return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep"))); } @@ -622,11 +707,11 @@ fn parse_image_bounded_with( // The bounded inflate above is the only decompression step. from_data would detect a // nested gzip payload and inflate it again without the source-byte limit. 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); + let tree = guarded(|| usvg::Tree::from_str(text, &opt)); if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() { return Err(IoError::Svg(error)); } - let tree = tree.map_err(|e| IoError::Svg(e.to_string()))?; + let tree = tree.ok_or_else(|| IoError::Svg("the SVG parser failed on this drawing".into()))?.map_err(|e| IoError::Svg(e.to_string()))?; // Include nested SVG trees too. Paint servers can reuse one parsed raster in many // drawing nodes, so the work estimate must not assume every source is decoded once. let nodes = u64::try_from(count_nodes_with_limit(tree.root(), true, usize::MAX, false)).unwrap_or(u64::MAX); @@ -784,8 +869,10 @@ fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result` or `/>`. Malformed markup is only counted -/// loosely: the parser rejects it afterwards. -pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { +/// loosely: the parser rejects it afterwards. The parser expands the DTD's entities after this +/// count, so an entity holding markup (a `<`) is an error: it could nest deeper than anything +/// counted here. A `<` there stays text. +pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> Result { let after = |from: usize, pat: &[u8]| -> usize { xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len())) }; @@ -807,6 +894,44 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { } (xml.len(), false) }; + // The byte after the DOCTYPE starting at `from`: its internal subset `[...]` is walked + // declaration by declaration, so a quote in a comment there can't hide the markup after it. + let doctype_end = |from: usize| -> Result { + let (mut quote, mut j) = (None, from); + loop { + let Some(&b) = xml.get(j) else { return Ok(xml.len()) }; + j = j.saturating_add(1); + match quote { + Some(q) if b == q => quote = None, + Some(_) => {} + None if b == b'"' || b == b'\'' => quote = Some(b), + None if b == b'>' => return Ok(j), + None if b == b'[' => break, + None => {} + } + } + while let Some(rest) = xml.get(j..).filter(|r| !r.is_empty()) { + j = if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"`, quotes or not. + after(j.saturating_add(2), b">") + } else if rest.starts_with(b"]") { + return Ok(tag_end(j.saturating_add(1)).0); + } else { + j.saturating_add(1) + }; + } + Ok(xml.len()) + }; let (mut depth, mut deepest, mut i) = (0usize, 0usize, 0usize); while let Some(p) = xml.get(i..).and_then(|s| s.iter().position(|&b| b == b'<')) { let at = i.saturating_add(p); @@ -817,6 +942,8 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { after(at.saturating_add(9), b"]]>") } else if rest.starts_with(b"") + } else if rest.starts_with(b" usize { depth = depth.saturating_add(1); deepest = deepest.max(depth); if deepest > limit { - return deepest; + return Ok(deepest); } } end }; } - deepest + Ok(deepest) } /// The document canvas for a drawing: its size in px, scaled down to [`MAX_SIDE`] when needed. @@ -1007,6 +1134,7 @@ fn import_parsed_with_limits( warnings.push("SVG: the text needs a font and none is installed, so it was dropped".to_string()); } let cv = canvas(&tree, &mut warnings); + check_filter_cost(&tree, cv.scale)?; let nodes = count_nodes(tree.root(), max_cached_pixels.is_some()); let over_cache_budget = if let Some(budget) = max_cached_pixels { let area = cv.size.area(); @@ -1040,13 +1168,16 @@ fn import_parsed_with_limits( } let (w, h) = (cv.size.width, cv.size.height); if let Some(mut pixmap) = pixmap(w, h) { - resvg::render(&tree, global, &mut pixmap.as_mut()); - doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); + if guarded(|| resvg::render(&tree, global, &mut pixmap.as_mut())).is_some() { + doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); + } else { + conv.note("raster-panic", "SVG: the drawing could not be rendered".to_string()); + } } } else { doc.layers = conv.convert_children(tree.root().children(), 0, 0, tree.root().abs_transform()); } - if doc.layers.is_empty() { + if doc.layers.is_empty() && !conv.noted.contains(&"raster-panic") { conv.warnings.push("SVG: nothing to draw (an empty drawing)".to_string()); } Ok(ImportResult { document: doc, warnings: conv.warnings, source_read_only: false, preview_only: false }) @@ -1112,12 +1243,15 @@ pub fn rasterize_rgba8(bytes: &[u8]) -> Result<(u32, u32, Vec), IoError> { Ok((pixmap.width(), pixmap.height(), px)) } -/// `tree` drawn at `k` pixels per SVG unit on a transparent pixmap whose sides round up. +/// `tree` drawn at `k` pixels per SVG unit on a transparent pixmap whose sides round up, refused +/// when its filters would cost too much at that scale. fn render(tree: &usvg::Tree, k: f32) -> Result { + check_filter_cost(tree, k)?; let side = |v: f32| ((v * k).ceil() as u32).max(1); let (w, h) = (side(tree.size().width()), side(tree.size().height())); let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?; - resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut()); + guarded(|| resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut())) + .ok_or_else(|| IoError::Svg("the drawing could not be rendered".into()))?; Ok(pixmap) } @@ -1176,6 +1310,92 @@ fn count_nodes_with_limit(root: &usvg::Group, include_text: bool, limit: usize, n } +/// The work beyond [`ORDINARY_FILTER_READS`] per pixel that resvg's filters would do to render +/// the drawing through `base` (drawing units to canvas px), in pixel reads: its morphology reads +/// the whole radius window per pixel, convolution the whole kernel, and turbulence runs every +/// octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, patterns and embedded drawings +/// count too, at the scale of the element that uses them, as resvg renders them. Past `visits` +/// elements, or with a referenced root nested past [`MAX_RECURSION`], the drawing counts as too +/// costly. +fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize, visits: &mut usize) -> u64 { + use usvg::filter::Kind; + let mut cost = 0u64; + let mut stack: Vec<&usvg::Group> = vec![root]; + while let Some(g) = stack.pop() { + let ts = base.pre_concat(g.abs_transform()); + let (sx, sy) = ts.get_scale(); + for f in g.filters() { + let Some(r) = f.rect().transform(ts) else { continue }; + // Float-to-int casts saturate, so a huge region counts as huge. + let (w, h) = (r.width().ceil() as u64, r.height().ceil() as u64); + let window = |radius: f32, s: f32, side: u64| ((radius * s).ceil() as u64).saturating_mul(2).min(side); + for p in f.primitives() { + let reads = match p.kind() { + Kind::ConvolveMatrix(c) => u64::from(c.matrix().columns()) * u64::from(c.matrix().rows()), + Kind::Morphology(m) => window(m.radius_x().get(), sx, w).saturating_mul(window(m.radius_y().get(), sy, h)), + Kind::Turbulence(t) => u64::from(t.num_octaves()).saturating_mul(TURBULENCE_OCTAVE_COST), + _ => 0, + }; + cost = cost.saturating_add(w.saturating_mul(h).saturating_mul(reads.saturating_sub(ORDINARY_FILTER_READS))); + } + } + for node in g.children() { + *visits = visits.saturating_sub(1); + if *visits == 0 { + return u64::MAX; + } + if let usvg::Node::Group(child) = node { + stack.push(child); + } + if rec >= MAX_RECURSION { + // usvg keeps referenced roots past this depth and resvg draws them: one the check + // would skip makes the drawing too costly. Ordinary children still count. + let mut skipped = false; + match node { + usvg::Node::Path(p) => { + skipped = [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())] + .into_iter() + .flatten() + .any(|paint| matches!(paint, usvg::Paint::Pattern(_))) + } + _ => node.subroots(|_| skipped = true), + } + if skipped { + return u64::MAX; + } + continue; + } + let world = base.pre_concat(node.abs_transform()); + let mut sub = |root: &usvg::Group, ts: tiny_skia::Transform| cost = cost.saturating_add(filter_cost(root, ts, rec + 1, visits)); + match node { + // resvg draws a pattern's tile at the path's scale times the pattern's own, and + // only that scale (`get_scale`, no skew or rotation): a skewed patternTransform + // whose full-affine bounds are slim still renders a large tile. + usvg::Node::Path(p) => { + for paint in [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())].into_iter().flatten() { + if let usvg::Paint::Pattern(pt) = paint { + let (sx, sy) = world.pre_concat(pt.transform()).get_scale(); + sub(pt.root(), tiny_skia::Transform::from_scale(sx, sy)); + } + } + } + _ => node.subroots(|root| sub(root, world)), + } + } + } + cost +} + +/// An error when the drawing's filters are too costly to render at `scale` (see [`filter_cost`]). +fn check_filter_cost(tree: &usvg::Tree, scale: f32) -> Result<(), IoError> { + if filter_cost(tree.root(), tiny_skia::Transform::from_scale(scale, scale), 0, &mut { MAX_FILTER_VISITS }) > MAX_FILTER_COST { + return Err(IoError::Svg( + "the drawing's filters are too costly to render (a huge morphology radius, convolution kernel or turbulence octave count, or patterns nested too deep)".into(), + )); + } + Ok(()) +} + /// Converts a parsed tree into layers. struct Conv { fmt: PixelFormat, @@ -1502,7 +1722,11 @@ impl Conv { // and shift so the pixmap starts at (x0, y0). let canvas = tiny_skia::Transform::from_row(k, 0.0, 0.0, k, -(x0 as f32), -(y0 as f32)); let ts = canvas.pre_concat(parent).pre_translate(shift.x(), shift.y()); - resvg::render_node(node, ts, &mut pixmap.as_mut())?; + let Some(rendered) = guarded(|| resvg::render_node(node, ts, &mut pixmap.as_mut())) else { + self.note("raster-panic", "SVG: an element that could not be rendered was dropped".to_string()); + return None; + }; + rendered?; let layer = Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt))); let bytes = pixels * self.fmt.bytes_per_pixel() as u64; self.pixel_budget -= bytes; diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index fd7f2713a7..15b6379359 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -166,6 +166,42 @@ fn sniffing_accepts_svg_and_rejects_other_xml() { assert!(!is_svg(b"")); } +/// Ordinary filters, however many and on the largest canvas, never count against the budget. +#[test] +fn ordinary_filters_cost_nothing_on_any_canvas() { + let svg = format!( + "\ + \ + \ + \ + ", + vec!["1"; 25].join(" ") + ); + let tree = parse(svg.as_bytes()).unwrap().tree; + assert_eq!(filter_cost(tree.root(), tiny_skia::Transform::identity(), 0, &mut { MAX_FILTER_VISITS }), 0); + assert!(check_filter_cost(&tree, 1.0).is_ok()); +} + +/// resvg draws a pattern's tile under the scale part of its transform only (`get_scale`): a +/// skewed patternTransform (determinant 1) whose full-affine bounds of a slim filter region are +/// 180 × 181 px still draws that region at 2400 × 180 px, with a morphology window as large, +/// about 2 × 10^11 reads. Never rendered here: only the estimate is checked. +#[test] +fn a_skewed_pattern_filter_counts_at_resvgs_tile_scale() { + let svg = "\ + \ + \ + \ + "; + let tree = parse(svg.as_bytes()).unwrap().tree; + // The tile resvg would allocate: 20 × 20 units at get_scale ≈ (100, 100). + let (sx, sy) = tiny_skia::Transform::from_row(0.0, -0.01, 100.0, 100.0, 0.0, 0.0).get_scale(); + assert!((sx * 20.0).round() >= 2000.0 && (sy * 20.0).round() >= 1999.0, "{sx} {sy}"); + let cost = filter_cost(tree.root(), tiny_skia::Transform::identity(), 0, &mut { MAX_FILTER_VISITS }); + assert!(cost > 50 * MAX_FILTER_COST, "charged only {cost} reads"); + assert!(check_filter_cost(&tree, 1.0).is_err()); +} + #[test] fn quadratics_become_cubics_and_closing_merges_the_start_knot() { let mut pb = tiny_skia::PathBuilder::new(); @@ -451,6 +487,79 @@ fn imports_missing_the_same_font_at_once_share_one_read() { } } +/// #9671: the fallback search for a character the text's font lacks reads each font file's +/// character map once per process, not the whole file on every text element (430 ms an +/// element with 273 installed faces). A second import still finds the fallback face after +/// its file stopped being readable, so it didn't go back to the disk. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_fallback_search_reads_each_font_file_once() { + use std::sync::atomic::{AtomicBool, Ordering}; + static GONE: AtomicBool = AtomicBool::new(false); + fn path() -> std::path::PathBuf { + std::env::temp_dir().join(format!("photocraft-svg-test-fallback-{}.ttf", std::process::id())) + } + fn inter_and_mono_on_disk() -> Arc { + std::fs::write(path(), include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_file(path()).unwrap(); + if GONE.load(Ordering::SeqCst) { + std::fs::write(path(), b"not a font any more").unwrap(); + } + Arc::new(db) + } + // Inter has no box-drawing characters; JetBrains Mono has. + let svg = "I\u{2500}I"; + let outlines = |parsed: Parsed| { + let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers; + let groups = layers.into_iter().filter_map(|l| if let LayerContent::Group(g) = l.content { Some(g) } else { None }); + groups.flat_map(|g| g.children).filter_map(|l| if let LayerContent::Shape(s) = l.content { Some(s.path) } else { None }).collect::>() + }; + let first = parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap(); + let mono = + first.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_))); + assert!(mono, "the box-drawing character falls back to JetBrains Mono"); + let first = outlines(first); + GONE.store(true, Ordering::SeqCst); + let second = outlines(parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap()); + let _ = std::fs::remove_file(path()); + assert!(first.iter().any(|p| !p.subpaths.is_empty()), "the text has outlines"); + assert!(second == first, "the second import set the character in the same fallback face"); +} + +/// #9671: the kept `cmap` is the one usvg reads (the last such record of a linear walk), also +/// in a font whose table directory isn't sorted by tag, where a binary search misses it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_fallback_search_reads_the_cmap_of_an_unsorted_font() { + fn inter_and_unsorted_mono_on_disk() -> Arc { + let mut font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec(); + let count = usize::from(u16::from_be_bytes([font[4], font[5]])); + // The `cmap` record moves to the end of the directory. + let mut records: Vec> = font[12..12 + count * 16].chunks(16).map(<[u8]>::to_vec).collect(); + let at = records.iter().position(|r| r.starts_with(b"cmap")).unwrap(); + let cmap = records.remove(at); + records.push(cmap); + font.splice(12..12 + count * 16, records.concat()); + let raw = ttf_parser::RawFace::parse(&font, 0).unwrap(); + assert!(raw.table(ttf_parser::Tag::from_bytes(b"cmap")).is_none(), "a binary search misses the cmap"); + assert!(raw.table(ttf_parser::Tag::from_bytes(b"name")).is_some(), "fontdb still finds the name"); + let path = std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id())); + std::fs::write(&path, font).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_file(&path).unwrap(); + Arc::new(db) + } + let svg = b"I\xE2\x94\x80I"; + let parsed = parse_with(svg, inter_and_unsorted_mono_on_disk).unwrap(); + let _ = std::fs::remove_file(std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id()))); + let mono = + parsed.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_))); + assert!(mono, "the box-drawing character falls back to JetBrains Mono"); +} + /// #9406: moving a font collection into memory keeps its faces in their order, so the /// fallback search for a later text still finds the face it found on disk first. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 619e9e53c9..413fee396e 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -934,13 +934,32 @@ fn ne_f32(b: &[u8]) -> Option { /// its alpha and spot channels ([`stamp_flat_channels`]). Off, the image is matted over white /// and keeps no transparency; on, it keeps it next to the channels; unset, it keeps it only /// when there are no channels. -pub(crate) fn flat_transparency_and_channels(doc: &Document, img: &mut Image, save_transparency: Option) -> Vec { +pub(crate) fn flat_transparency_and_channels( + doc: &Document, + img: &mut Image, + save_transparency: Option, + preserve_alpha: bool, +) -> Result, IoError> { let mut warnings = Vec::new(); - if save_transparency == Some(false) && img.layout().has_alpha() && !matte_and_drop_alpha(img) { - warnings.push("the image transparency could not be composited over white and was kept".into()); + if save_transparency == Some(false) && img.layout().has_alpha() { + require_flat_transparency(preserve_alpha)?; + if !matte_and_drop_alpha(img) { + warnings.push("the image transparency could not be composited over white and was kept".into()); + } + } + warnings.extend(stamp_flat_channels(doc, img, save_transparency == Some(true), preserve_alpha)?); + Ok(warnings) +} + +/// In-place writes refuse the same matting decision the flat encoder is about to take. +fn require_flat_transparency(preserve_alpha: bool) -> Result<(), IoError> { + if preserve_alpha { + Err(IoError::Unsupported( + "Save and Close cannot discard TIFF composite transparency; enable tiffTransparency or save to a separate output folder".into(), + )) + } else { + Ok(()) } - warnings.extend(stamp_flat_channels(doc, img, save_transparency == Some(true))); - warnings } /// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the @@ -950,16 +969,16 @@ pub(crate) fn flat_transparency_and_channels(doc: &Document, img: &mut Image, sa /// the channel resources with every sample past the colour channels, in order. /// `keep_transparency` (Save Transparency on) keeps the composite's transparency as the first /// extra sample, ahead of the channels; off, the image is matted over white first. -pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transparency: bool) -> Vec { +pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transparency: bool, preserve_alpha: bool) -> Result, IoError> { let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some())); if total == 0 { - return Vec::new(); + return Ok(Vec::new()); } let mut warnings = Vec::new(); let (w, h) = img.dimensions(); if w != doc.size.width || h != doc.size.height { warnings.push("extra channels were not written: the image size differs from the document".into()); - return warnings; + return Ok(warnings); } let max = codecs::MAX_EXTRA_SAMPLES; let mut picked: Vec<&AlphaChannel> = doc.channels.iter().take(max).collect(); @@ -984,7 +1003,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transpar } picked.truncate(planes.len()); if picked.is_empty() { - return warnings; + return Ok(warnings); } let names: Vec<&str> = picked.iter().map(|c| c.name.as_str()).collect(); let quick_last = matches!((picked.last(), doc.quick_mask.as_ref()), (Some(p), Some(q)) if std::ptr::eq(*p, q)); @@ -994,9 +1013,10 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transpar // composite transparency is gone after this; say so, because the user did not pick // that option themselves. if img.layout().has_alpha() && !keep_transparency { + require_flat_transparency(preserve_alpha)?; if !matte_and_drop_alpha(img) { warnings.push("extra channels were not written: the composite transparency could not be separated from them".into()); - return warnings; + return Ok(warnings); } warnings.push( "transparency composited over white; a TIFF with alpha or spot channels stores them as extra samples and drops image transparency (Save Transparency off)" @@ -1015,7 +1035,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transpar Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), } img.meta.extra_samples = planes.into_iter().map(|data| codecs::ExtraSample { kind: 0, data }).collect(); - warnings + Ok(warnings) } /// Existing Photoshop resources with the channel-name, display-info and quick-mask blocks replaced. @@ -1150,6 +1170,33 @@ mod tests { use crate::{ExportOptions, ImportOptions, IoError, export}; + /// The in-place guard follows actual writable planes, including Quick Mask and early exits. + #[test] + fn flat_alpha_guard_uses_the_prepared_image_and_written_planes() { + use photocraft_codecs::{ChannelLayout, Image}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_doc::AlphaChannel; + let mut doc = Document::new("guard", photocraft_geom::Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + let image = || Image::from_u8(2, 2, ChannelLayout::Rgba, vec![0; 16]).unwrap(); + let mut no_planes = image(); + super::stamp_flat_channels(&doc, &mut no_planes, false, true).unwrap(); + assert!(no_planes.layout().has_alpha()); + let channel = || AlphaChannel::new("Mask", Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false))); + doc.channels.push(channel()); + let mut mismatch = Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0; 4]).unwrap(); + let warnings = super::stamp_flat_channels(&doc, &mut mismatch, false, true).unwrap(); + assert!(warnings.iter().any(|warning| warning.contains("size differs"))); + assert!(mismatch.layout().has_alpha()); + assert!(super::stamp_flat_channels(&doc, &mut image(), false, true).is_err()); + let mut kept = image(); + super::stamp_flat_channels(&doc, &mut kept, true, true).unwrap(); + assert!(kept.layout().has_alpha()); + assert_eq!(kept.meta.extra_samples.len(), 1); + doc.channels.clear(); + doc.quick_mask = Some(channel()); + assert!(super::stamp_flat_channels(&doc, &mut image(), false, true).is_err()); + } + /// Hidden solid fills on a 2048² canvas: counted as canvas-sized planes (16 MiB each at 8 /// bits), 140 of them pass 2 GB, while their real pixels (the cached rendering, pruned to /// nothing) take almost no space. A layered TIFF of such a document must still carry @@ -1272,7 +1319,7 @@ mod tests { let lowered = ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() }; assert!(matches!( crate::import_with_options("l.tif", &bytes, &lowered), - Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1 + Err(IoError::PsdImportBudget { required: r, limit: l, .. }) if r == required && l == required - 1 )); }); }); @@ -1304,7 +1351,7 @@ mod tests { // remaining allowance is untouched. crate::psd_gate::set_active_attempt_budget(charged - 1); match crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl) { - Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (charged, charged - 1)), + Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (charged, charged - 1)), other => panic!("expected an import-budget refusal, got {other:?}"), } assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(charged - 1), "a failed nested import leaves the allowance as it was"); @@ -1415,7 +1462,7 @@ mod tests { crate::memory::with_mock_memory(zero, || { crate::flat::with_before_composite_decode(hook(&decoded), || { match crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()) { - Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0), other => panic!("expected an import-budget refusal, got {other:?}"), } assert_eq!(decoded.get(), 0, "a refused file must not decode its composite"); @@ -1457,7 +1504,7 @@ mod tests { crate::memory::with_mock_memory(zero, || { crate::flat::with_before_composite_decode(hook(&decoded), || { match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) { - Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0), other => panic!("expected an import-budget refusal, got {other:?}"), } assert_eq!(decoded.get(), 0, "a refused file must not decode its composite"); @@ -1542,7 +1589,7 @@ mod tests { let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding"); match result { - Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0), other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"), } assert!(!decoded.load(Ordering::SeqCst), "the composite must not be decoded after the post-gate refusal"); diff --git a/crates/io/src/xml.rs b/crates/io/src/xml.rs index 13e02004d1..423c8c8555 100644 --- a/crates/io/src/xml.rs +++ b/crates/io/src/xml.rs @@ -15,7 +15,7 @@ pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result if opts.allow_dtd && internal_subset(text.as_bytes()) { return Err("internal DTD subsets aren't supported".into()); } - if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH) > MAX_DEPTH { + if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH).map_err(|e| e.to_string())? > MAX_DEPTH { return Err(format!("elements are nested more than {MAX_DEPTH} levels deep")); } Document::parse_with_options(text, opts).map_err(|e| e.to_string()) diff --git a/crates/io/tests/it/flat_export_renderer.rs b/crates/io/tests/it/flat_export_renderer.rs new file mode 100644 index 0000000000..b0a14a75c8 --- /dev/null +++ b/crates/io/tests/it/flat_export_renderer.rs @@ -0,0 +1,504 @@ +//! Tests for custom [`BandRenderer`] injection, complete CPU retry, and renderer error handling. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use photocraft_codecs::Format; +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ + BandRenderer, CpuRenderer, ExportOptions, IoError, RenderBand, RenderError, document_to_image_with_renderer, export_flat, export_flat_with_renderer, + export_png_streamed_with_renderer, export_preview_png_with_renderer, +}; +use photocraft_raster::{Interrupt, Surface}; + +fn synthetic_doc() -> Document { + let mut doc = Document::with_background("synth", Size::new(128, 300), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(Rect::new(20, 20, 100, 260), &[0.1, 0.4, 0.9, 0.7]); + let mut top = Layer::new("top", LayerContent::Raster(s)); + top.opacity = 0.8; + top.blend = BlendMode::Multiply; + doc.layers.push(top); + doc +} + +#[derive(Default)] +struct RecordingRenderer { + diagnostics: Vec, + /// How many times `render_bands` was called. + renders: usize, +} + +impl BandRenderer for RecordingRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + self.renders += 1; + let mut cpu = CpuRenderer; + cpu.render_bands(doc, rect, band_rows, ctl, sink) + } + + fn drain_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.diagnostics) + } + + fn push_diagnostic(&mut self, diagnostic: String) { + self.diagnostics.push(diagnostic); + } +} + +struct FailingRenderer { + fail_after_bands: usize, + call_count: AtomicUsize, + error_kind: &'static str, +} + +impl FailingRenderer { + fn new(fail_after_bands: usize, error_kind: &'static str) -> Self { + Self { fail_after_bands, call_count: AtomicUsize::new(0), error_kind } + } +} + +impl BandRenderer for FailingRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + _ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + let rows = if band_rows > 0 { band_rows } else { 32 }; + photocraft_compose::render_bands(doc, rect, rows, |buf| { + let count = self.call_count.fetch_add(1, Ordering::SeqCst); + if count >= self.fail_after_bands { + return match self.error_kind { + "unsupported" => Err(IoError::Render(RenderError::Unsupported("forced unsupported feature".into()))), + "device" => Err(IoError::Render(RenderError::Device("forced device loss".into()))), + "cancelled" => Err(IoError::Cancelled), + _ => Err(IoError::Render(RenderError::Execution("forced failure".into()))), + }; + } + sink(RenderBand { rect: buf.rect, pixels: buf.into_px() }) + }) + .map_err(|e| match e { + IoError::Render(r) => r, + IoError::Cancelled => RenderError::Cancelled, + other => RenderError::sink(other), + }) + } +} + +struct GeometryViolatingRenderer { + violation: &'static str, +} + +impl BandRenderer for GeometryViolatingRenderer { + fn render_bands( + &mut self, + _doc: &Document, + rect: Rect, + _band_rows: i32, + _ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + let (w, h) = (rect.width(), rect.height()); + match self.violation { + "wrong_pixels" => { + sink(RenderBand { rect: Rect::new(0, 0, w as i32, h as i32), pixels: vec![[0.0; 4]; (w as usize * h as usize).saturating_sub(10)] }) + .map_err(RenderError::sink) + } + "out_of_order" => { + // Yield band starting at y=10 instead of 0 + sink(RenderBand { rect: Rect::new(0, 10, w as i32, 20), pixels: vec![[0.0; 4]; w as usize * 10] }).map_err(RenderError::sink) + } + "wrong_width" => { + sink(RenderBand { rect: Rect::new(0, 0, (w as i32).saturating_sub(5), 10), pixels: vec![[0.0; 4]; (w as usize).saturating_sub(5) * 10] }) + .map_err(RenderError::sink) + } + _ => Ok(()), + } + } +} + +#[test] +fn single_layer_and_cmyk_exports_report_that_the_renderer_was_not_used() { + // A single plain layer takes the native copy path, never calling a renderer. + let mut single = Document::with_background("single", Size::new(24, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + single.layers[0].name = "Background".into(); + let mut renderer = RecordingRenderer::default(); + let mut warnings = Vec::new(); + document_to_image_with_renderer(&single, &mut warnings, Some(&mut renderer)).expect("single-layer export"); + assert!(warnings.iter().any(|d| d.contains("single-layer") && d.contains("CPU")), "a requested renderer that is bypassed must report why: {warnings:?}"); + assert_eq!(renderer.renders, 0, "the native copy never calls the renderer"); + + // A CMYK composite mixed on ink values likewise returns before the renderer branch. + let mut cmyk = Document::with_background("cmyk", Size::new(32, 32), ColorMode::Cmyk, SampleType::U8, Color::WHITE); + let mut s = Surface::new(cmyk.pixel_format()); + s.fill_rect(Rect::new(4, 4, 28, 28), &[0.1, 0.2, 0.3, 0.4, 0.5]); + cmyk.layers.push(Layer::new("ink", LayerContent::Raster(s))); + let mut renderer = RecordingRenderer::default(); + let res = export_flat_with_renderer(&cmyk, Format::Tiff, &ExportOptions::default(), &Interrupt::NONE, Some(&mut renderer)).expect("CMYK TIFF export"); + assert!( + res.warnings.iter().any(|w| w.contains("CMYK ink mixing") && w.contains("CPU")), + "CMYK ink mixing must report its CPU fallback: {:?}", + res.warnings + ); +} + +struct FailOnceDiagnosingRenderer { + failed: bool, + calls: usize, + diagnostics: Vec, +} + +impl BandRenderer for FailOnceDiagnosingRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + self.calls += 1; + if !self.failed { + self.failed = true; + self.push_diagnostic("GPU readback error: forced device loss".into()); + return Err(RenderError::Device("forced device loss".into())); + } + let mut cpu = CpuRenderer; + cpu.render_bands(doc, rect, band_rows, ctl, sink) + } + + fn drain_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.diagnostics) + } + + fn push_diagnostic(&mut self, diagnostic: String) { + self.diagnostics.push(diagnostic); + } +} + +#[test] +fn fallback_diagnostics_stay_with_the_failed_export() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + let mut renderer = FailOnceDiagnosingRenderer { failed: false, calls: 0, diagnostics: Vec::new() }; + let first = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut renderer)).expect("first export retries on CPU"); + assert!(first.warnings.iter().any(|w| w.contains("falling back to CPU")), "the failed export reports its fallback: {:?}", first.warnings); + assert!( + first.warnings.iter().any(|w| w.contains("GPU readback error")), + "the failed attempt's queued diagnostics belong to its retry result: {:?}", + first.warnings + ); + + let calls_before_second = renderer.calls; + let second = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut renderer)).expect("second export uses the GPU path"); + assert!(renderer.calls > calls_before_second, "the second export must call the renderer: {}", renderer.calls); + assert!(!second.warnings.iter().any(|w| w.contains("falling back to CPU")), "a fallback warning must not carry into a later export: {:?}", second.warnings); + assert!( + !second.warnings.iter().any(|w| w.contains("GPU readback error")), + "queued diagnostics must not carry into a later successful export: {:?}", + second.warnings + ); +} + +/// Composes bands through the reference compositor but never polls `ctl` itself, so a band only +/// reaches the export sink when the export checks cancellation. Counts the bands it forwarded. +#[derive(Default)] +struct UnpollingRenderer { + bands: usize, +} + +impl BandRenderer for UnpollingRenderer { + fn render_bands( + &mut self, + doc: &Document, + rect: Rect, + band_rows: i32, + _ctl: &Interrupt<'_>, + sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + let rows = if band_rows > 0 { band_rows } else { 32 }; + photocraft_compose::render_bands(doc, rect, rows, |buf| { + sink(RenderBand { rect: buf.rect, pixels: buf.into_px() })?; + self.bands += 1; + Ok(()) + }) + .map_err(RenderError::sink) + } +} + +#[test] +fn cancellation_stops_the_cpu_band_sink() { + let opts = ExportOptions::default(); + + // RGB bands: cancellation is checked per band, so the second band is never assembled. + let checks = AtomicUsize::new(0); + let cancel = || checks.fetch_add(1, Ordering::SeqCst) + 1 >= 2; + let mut renderer = UnpollingRenderer::default(); + let res = export_flat_with_renderer(&synthetic_doc(), Format::Tiff, &opts, &Interrupt::cancel_only(&cancel), Some(&mut renderer)); + assert!(matches!(res, Err(IoError::Cancelled)), "a cancelled RGB export must stop between bands: {res:?}"); + assert_eq!(renderer.bands, 1, "the band after a cancellation request must not be assembled"); + + // CMYK inks mix in bands through the same sink: none is assembled once cancelled. + let mut cmyk = Document::with_background("cmyk", Size::new(32, 32), ColorMode::Cmyk, SampleType::U8, Color::WHITE); + let mut s = Surface::new(cmyk.pixel_format()); + s.fill_rect(Rect::new(4, 4, 28, 28), &[0.1, 0.2, 0.3, 0.4, 0.5]); + cmyk.layers.push(Layer::new("ink", LayerContent::Raster(s))); + let mut renderer = UnpollingRenderer::default(); + let res = export_flat_with_renderer(&cmyk, Format::Tiff, &opts, &Interrupt::cancel_only(&|| true), Some(&mut renderer)); + assert!(matches!(res, Err(IoError::Cancelled)), "a cancelled CMYK export must stop before its first band: {res:?}"); + assert_eq!(renderer.bands, 0, "no CMYK band may be assembled when the export is already cancelled"); +} + +#[test] +fn cancelled_export_leaves_queued_diagnostics_on_the_renderer() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + let mut renderer = FailOnceDiagnosingRenderer { failed: false, calls: 0, diagnostics: Vec::new() }; + let cancelled = Interrupt::cancel_only(&|| true); + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &cancelled, Some(&mut renderer)); + assert!(matches!(res, Err(IoError::Cancelled)), "cancellation must be observed before the CPU retry: {res:?}"); + assert_eq!(renderer.calls, 1, "the cancelled export must not retry through the renderer: {}", renderer.calls); + let diagnostics = renderer.drain_diagnostics(); + assert!(diagnostics.iter().any(|d| d.contains("GPU readback error")), "diagnostics must stay queued when the export is cancelled: {diagnostics:?}"); +} + +#[test] +fn ora_and_pdf_exports_report_cpu_use_when_a_renderer_is_set() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + for (name, marker) in [("out.ora", "OpenRaster export uses CPU compositor"), ("out.pdf", "PDF export uses CPU compositor")] { + let mut renderer = RecordingRenderer::default(); + let res = photocraft_io::export_with_renderer(&doc, name, &opts, &Interrupt::NONE, Some(&mut renderer)).expect("CPU-only format export"); + assert!(res.warnings.iter().any(|w| w.contains(marker)), "{name} must report CPU use: {:?}", res.warnings); + } +} + +#[test] +fn custom_cpu_renderer_matches_default_export() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + let def = export_flat(&doc, Format::Png, &opts).expect("default export"); + let mut custom = CpuRenderer; + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut custom)).expect("custom export"); + + assert_eq!(def.bytes, res.bytes); + assert_eq!(def.warnings, res.warnings); +} + +#[test] +fn renderer_preview_is_8bit_rgba_while_saved_export_keeps_depth() { + let mut doc = Document::with_background("preview16", Size::new(64, 48), ColorMode::Rgb, SampleType::U16, Color::WHITE); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(Rect::new(8, 8, 56, 40), &[0.2, 0.4, 0.8, 0.5]); + doc.layers.push(Layer::new("top", LayerContent::Raster(s))); + + let mut preview_renderer = RecordingRenderer::default(); + let preview = export_preview_png_with_renderer(&doc, Some(&mut preview_renderer)).expect("preview export"); + assert!(preview_renderer.renders > 0, "the preview must render through the supplied renderer"); + let img = photocraft_codecs::decode(&preview.bytes).expect("decode preview"); + assert_eq!((img.width(), img.height()), (64, 48)); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba, "a preview is always RGBA"); + assert_eq!(img.sample_type(), photocraft_codecs::SampleType::U8, "a preview is always 8-bit"); + + let pixel = |x: u32, y: u32| { + let at = ((y * img.width() + x) * 4) as usize; + &img.data()[at..at + 4] + }; + // Inside the 50 % blue layer: half the layer colour over white. + let inside = pixel(32, 24); + for (got, want) in inside.iter().zip([153u8, 179, 230, 255]) { + assert!(got.abs_diff(want) <= 2, "preview compositing is wrong inside the layer: {inside:?}"); + } + // Outside the layer: the white background. + assert_eq!(pixel(2, 2), [255, 255, 255, 255], "preview must keep the document background"); + + // A saved export keeps the document's native depth: the preview conversion is preview-only. + let opts = ExportOptions::default(); + let mut save_renderer = CpuRenderer; + let saved = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut save_renderer)).expect("saved export"); + let saved_img = photocraft_codecs::decode(&saved.bytes).expect("decode saved export"); + assert_eq!(saved_img.sample_type(), photocraft_codecs::SampleType::U16, "saved exports keep the document's depth"); +} + +#[test] +fn forced_unsupported_renderer_restarts_complete_export_on_cpu() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + let expected = export_flat(&doc, Format::Png, &opts).expect("baseline CPU export"); + + let mut failing = FailingRenderer::new(0, "unsupported"); + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut failing)).expect("export should retry and succeed on CPU"); + + assert_eq!(expected.bytes, res.bytes, "output after retry must match clean CPU output"); + assert!( + res.warnings.iter().any(|w| w.contains("custom renderer unavailable") && w.contains("falling back to CPU")), + "fallback warning must be reported: {:?}", + res.warnings + ); +} + +#[test] +fn forced_mid_band_failure_restarts_complete_export_on_cpu() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + let expected = export_flat(&doc, Format::Png, &opts).expect("baseline CPU export"); + + // Fail on the second band (mid-render) + let mut failing = FailingRenderer::new(1, "device"); + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut failing)).expect("export should restart completely on CPU"); + + assert_eq!(expected.bytes, res.bytes, "output after mid-band restart must match pure CPU output"); + assert!(res.warnings.iter().any(|w| w.contains("falling back to CPU")), "fallback warning must be present: {:?}", res.warnings); +} + +#[test] +fn cancellation_is_not_retried() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + let mut failing = FailingRenderer::new(0, "cancelled"); + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut failing)); + + assert!(matches!(res, Err(IoError::Cancelled)), "cancelled render must not restart on CPU"); +} + +#[test] +fn encoder_and_sink_failures_are_not_retried_on_cpu() { + struct SinkFailingRenderer; + impl BandRenderer for SinkFailingRenderer { + fn render_bands( + &mut self, + _doc: &Document, + _rect: Rect, + _band_rows: i32, + _ctl: &Interrupt<'_>, + _sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + Err(RenderError::sink(IoError::Unsupported("test sink failure".into()))) + } + } + + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + // A non-cancellation sink error with no interrupt: a CPU retry would succeed with `Ok`, so + // this assertion fails if the error is ever retried on the CPU. + let mut renderer = SinkFailingRenderer; + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut renderer)); + assert!(matches!(res, Err(IoError::Unsupported(_))), "sink error must propagate without a CPU retry: {res:?}"); +} + +#[test] +fn band_geometry_violations_are_rejected() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + for violation in ["wrong_pixels", "out_of_order", "wrong_width"] { + let mut renderer = GeometryViolatingRenderer { violation }; + let res = export_flat_with_renderer(&doc, Format::Png, &opts, &Interrupt::NONE, Some(&mut renderer)); + assert!( + matches!(res, Err(IoError::Render(RenderError::Execution(_)))), + "violation '{violation}' must be rejected before codec consumes corrupt data: {res:?}" + ); + } +} + +#[test] +fn streamed_png_retries_on_cpu_upon_renderer_failure() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + let expected = export_png_streamed_with_renderer(&doc, &opts, &Interrupt::NONE, 16, None).expect("baseline streamed PNG"); + + let mut failing = FailingRenderer::new(1, "device"); + let res = export_png_streamed_with_renderer(&doc, &opts, &Interrupt::NONE, 16, Some(&mut failing)).expect("streamed PNG should retry on CPU"); + + assert_eq!(expected.bytes, res.bytes); + assert!(res.warnings.iter().any(|w| w.contains("falling back to CPU"))); +} + +#[test] +fn unknown_export_format_is_rejected() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + + // `export` takes a name or bare extension; an unknown one fails before any codec runs. + let export_err = photocraft_io::export(&doc, "invalid_unknown_extension_xyz", &opts); + assert!(export_err.is_err()); +} + +/// A renderer that fails retryably on its first call and, as it does, requests cancellation, so +/// the complete CPU retry that follows cannot succeed. It queues pushed diagnostics for the test. +struct FailThenCancelRenderer { + cancel: std::sync::Arc, + diagnostics: Vec, +} + +impl BandRenderer for FailThenCancelRenderer { + fn render_bands( + &mut self, + _doc: &Document, + _rect: Rect, + _band_rows: i32, + _ctl: &Interrupt<'_>, + _sink: &mut dyn FnMut(RenderBand) -> Result<(), IoError>, + ) -> Result<(), RenderError> { + self.cancel.store(true, Ordering::SeqCst); + Err(RenderError::Device("forced device loss".into())) + } + + fn drain_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.diagnostics) + } + + fn push_diagnostic(&mut self, diagnostic: String) { + self.diagnostics.push(diagnostic); + } +} + +#[test] +fn a_failed_cpu_retry_keeps_the_fallback_reason_on_the_renderer() { + let doc = synthetic_doc(); + let opts = ExportOptions::default(); + for streamed in [false, true] { + let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = flag.clone(); + let cancel = move || seen.load(Ordering::SeqCst); + let ctl = Interrupt::cancel_only(&cancel); + let mut renderer = FailThenCancelRenderer { cancel: flag, diagnostics: Vec::new() }; + let res = if streamed { + export_png_streamed_with_renderer(&doc, &opts, &ctl, 0, Some(&mut renderer)) + } else { + export_flat_with_renderer(&doc, Format::Png, &opts, &ctl, Some(&mut renderer)) + }; + assert!(matches!(res, Err(IoError::Cancelled)), "the CPU retry is cancelled (streamed: {streamed}): {res:?}"); + let diagnostics = renderer.drain_diagnostics(); + assert!( + diagnostics.iter().any(|d| d.contains("custom renderer unavailable") && d.contains("falling back to CPU")), + "the fallback reason must survive the failed retry (streamed: {streamed}): {diagnostics:?}" + ); + } +} + +#[test] +fn single_layer_notice_reaches_the_export_warnings_without_renderer_diagnostics() { + // `FailingRenderer` keeps the default no-op `push_diagnostic`, like any renderer that ignores it. + let single = Document::with_background("single", Size::new(24, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut renderer = FailingRenderer::new(usize::MAX, "device"); + let res = export_flat_with_renderer(&single, Format::Png, &ExportOptions::default(), &Interrupt::NONE, Some(&mut renderer)).expect("single-layer export"); + assert!(res.warnings.iter().any(|w| w.contains("single-layer") && w.contains("CPU")), "{:?}", res.warnings); + let res = export_png_streamed_with_renderer(&single, &ExportOptions::default(), &Interrupt::NONE, 0, Some(&mut renderer)).expect("streamed single layer"); + assert!(res.warnings.iter().any(|w| w.contains("single-layer") && w.contains("CPU")), "{:?}", res.warnings); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index d9e597297e..db6fc6cdf3 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -26,6 +26,7 @@ mod exif_resolution; mod export_progress; mod exr_part_profile; mod flat; +mod flat_export_renderer; mod gif_frames; mod guides_v2; mod heif; @@ -44,6 +45,7 @@ mod pattern_fill_psd; mod pcraft_full_composite; mod pcraft_manifest_name; mod pdn_corpus; +mod png_steps; mod png_text_metadata; mod psd16_merged_transparency; mod psd32_export; @@ -52,6 +54,7 @@ mod psd_block_alignment; mod psd_block_layout; mod psd_damaged_merged; mod psd_empty_channels; +mod psd_flat_spot_channels; mod psd_font_identity; mod psd_frame_stroke; mod psd_import_file; diff --git a/crates/io/tests/it/multichannel.rs b/crates/io/tests/it/multichannel.rs index f4f7154d41..66f216368b 100644 --- a/crates/io/tests/it/multichannel.rs +++ b/crates/io/tests/it/multichannel.rs @@ -33,6 +33,18 @@ fn roundtrip(d: &photocraft_doc::Document) -> (PsdFile, photocraft_doc::Document (back, doc) } +#[test] +fn multichannel_names_decode_mac_roman_unless_unicode_is_present() { + let mut f = file(8); + f.resources.push(ImageResource::new(1006, b"\x04caf\x8e\x05Alpha".to_vec())); + let opened = import("multi.psd", &f.to_bytes().unwrap()).unwrap().document; + assert_eq!(opened.channels.iter().map(|c| c.name.as_str()).collect::>(), ["café", "Alpha"]); + assert!(opened.channels[0].spot.is_some()); + f.resources.push(ImageResource::new(1045, vec![0, 0, 0, 1, 0, b'Z', 0, 0, 0, 1, 0, b'Y'])); + let opened = import("multi.psd", &f.to_bytes().unwrap()).unwrap().document; + assert_eq!(opened.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Z", "Y"]); +} + #[test] fn alpha_type_channels_import_as_alpha_and_round_trip() { for depth in [8, 16] { @@ -224,6 +236,25 @@ fn corpus_multichannel_round_trip_keeps_channels_and_resources() { assert_eq!(keys(&back), keys(&f)); } +/// A Multichannel spot whose DisplayInfo colour space is unknown takes its ink from the Alternate +/// Spot Colors (1067), matched by the 1053 identifier, as in the other modes. +#[test] +fn unknown_display_space_takes_the_alternate_spot_colour() { + let mut f = file(8); + let info = f.resources.iter_mut().find(|r| r.id == 1077).unwrap(); + info.data[4..6].copy_from_slice(&0x2331u16.to_be_bytes()); + f.resources.push(ImageResource::new(1053, [3u32, 4].iter().flat_map(|id| id.to_be_bytes()).collect())); + let without = psd_to_document(&f).0; + // Lab 58.47 / −33.27 / −14.59 for channel id 3: about sRGB (0, 157, 165). + f.resources.push(ImageResource::new(1067, vec![0, 1, 0, 1, 0, 0, 0, 3, 0x00, 0x07, 0x16, 0xd7, 0xf3, 0x01, 0xfa, 0x4d, 0x00, 0x00])); + let (doc, _) = psd_to_document(&f); + let (ink, _) = doc.channels[0].spot.unwrap(); + let rgb = ink.to_rgb().map(|v| (v * 255.0).round()); + assert!(rgb[0] <= 3.0 && (rgb[1] - 157.0).abs() <= 3.0 && (rgb[2] - 165.0).abs() <= 3.0, "{rgb:?}"); + assert_ne!(without.channels[0].spot.unwrap().0, ink); + assert!(doc.channels[1].spot.is_none(), "the alpha channel stays an alpha channel"); +} + #[test] fn tile_row_conversion_preserves_spot_and_alpha_planes() { for depth in [8, 16, 32] { @@ -251,3 +282,25 @@ fn tile_row_conversion_preserves_spot_and_alpha_planes() { } } } + +#[test] +fn legacy_unknown_display_space_uses_matching_alternate_id() { + let mut f = file(8); + f.resources.retain(|r| r.id != 1077); + let mut info = Vec::new(); + display_record(&mut info, [0, 0, 0], 0, 2); + info[0..2].copy_from_slice(&0x2331u16.to_be_bytes()); + info.push(0); + display_record(&mut info, [0, 0, 0], 50, 1); + info.push(0); + f.resources.push(ImageResource::new(1007, info)); + f.resources.push(ImageResource::new(1053, [3u32, 4].iter().flat_map(|id| id.to_be_bytes()).collect())); + let without = psd_to_document(&f).0; + f.resources.push(ImageResource::new(1067, vec![0, 1, 0, 1, 0, 0, 0, 3, 0, 7, 0x16, 0xd7, 0xf3, 1, 0xfa, 0x4d, 0, 0])); + let doc = psd_to_document(&f).0; + let ink = doc.channels[0].spot.unwrap().0; + assert_ne!(without.channels[0].spot.unwrap().0, ink); + let rgb = ink.to_rgb().map(|v| (v * 255.0).round()); + assert!(rgb[0] <= 3.0 && (rgb[1] - 157.0).abs() <= 3.0 && (rgb[2] - 165.0).abs() <= 3.0, "{rgb:?}"); + assert!(doc.channels[1].spot.is_none()); +} diff --git a/crates/io/tests/it/png_steps.rs b/crates/io/tests/it/png_steps.rs new file mode 100644 index 0000000000..73960301c5 --- /dev/null +++ b/crates/io/tests/it/png_steps.rs @@ -0,0 +1,310 @@ +//! A PNG imported in steps gives the document `import_with_options_and_interrupt` gives. + +use std::sync::Arc; + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, encode}; +use photocraft_doc::Document; +use photocraft_io::{ImportOptions, ImportResult, PngImport, import_with_options_and_interrupt}; +use photocraft_raster::Interrupt; + +/// A pseudo-random `w`×`h` image. +fn noise(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) -> Image { + let len = w as usize * h as usize * layout.channels() * sample.bytes(); + Image::from_raw(w, h, layout, sample, (0..len).map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8).collect()).unwrap() +} + +fn whole(name: &str, bytes: &[u8]) -> ImportResult { + import_with_options_and_interrupt(name, bytes, &ImportOptions::default(), &Interrupt::NONE).unwrap() +} + +/// Imports `bytes` in steps, `per_call` rows at a time (0: unlimited); returns the result and +/// how many calls it took. +fn stepped(name: &str, bytes: &[u8], per_call: usize) -> (ImportResult, usize) { + let bytes = Arc::new(bytes.to_vec()); + let mut import = PngImport::new(name, &bytes).unwrap().expect("a plain PNG"); + let mut calls = 0; + loop { + calls += 1; + let mut left = per_call; + let mut more = || { + left = left.wrapping_sub(1); + per_call == 0 || left > 0 + }; + if let Some(r) = import.step(&mut more).unwrap() { + return (r, calls); + } + assert!(calls < 100_000); + } +} + +/// The documents are the same but for the ids minted for them. +fn assert_same(a: &Document, b: &Document, what: &str) { + let (mut a, mut b) = (a.clone(), b.clone()); + b.id = a.id; + assert_eq!(a.layers.len(), b.layers.len(), "{what}"); + for (x, y) in a.layers.iter_mut().zip(&mut b.layers) { + y.id = x.id; + } + assert!(a == b, "{what}: the documents differ"); +} + +fn assert_stepped_equals_import(name: &str, bytes: &[u8], what: &str) { + let expected = whole(name, bytes); + for per_call in [1, 7, 0] { + let (got, _) = stepped(name, bytes, per_call); + assert_same(&got.document, &expected.document, &format!("{what}, {per_call} rows per call")); + assert_eq!(got.warnings, expected.warnings, "{what}"); + assert_eq!((got.source_read_only, got.preview_only), (expected.source_read_only, expected.preview_only), "{what}"); + } +} + +#[test] +fn stepped_png_import_equals_import() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::U8, SampleType::U16] { + let mut img = noise(45, 38, layout, sample); + img.meta.dpi = Some((144.0, 144.0)); + img.meta.text.push(("Comment".into(), "stepped".into())); + for interlaced in [false, true] { + let bytes = encode(&img, Format::Png, &EncodeOptions { png_interlaced: interlaced, ..Default::default() }).unwrap(); + assert_stepped_equals_import("a.png", &bytes, &format!("{layout:?} {sample:?} interlaced={interlaced}")); + } + } + } +} + +#[test] +fn stepped_png_import_keeps_a_palette_as_indexed_colour() { + let pal = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [9, 9, 9]]; + let idx: Vec = (0..40 * 30).map(|i| (i % 4) as u8).collect(); + let bytes = photocraft_codecs::encode_png_indexed(40, 30, &idx, &pal, Some(3), None, None).unwrap(); + let (got, _) = stepped("p.png", &bytes, 0); + assert!(got.document.color_table.is_some(), "an Indexed Color document"); + assert_stepped_equals_import("p.png", &bytes, "palette"); +} + +#[test] +fn stepped_png_import_takes_a_step_per_row_and_bounded_steps_to_build_the_document() { + let bytes = encode(&noise(8, 20, ChannelLayout::Rgba, SampleType::U8), Format::Png, &EncodeOptions::default()).unwrap(); + let (_, calls) = stepped("a.png", &bytes, 1); + // 19 calls that each decode a row (its rows become tiles as they come), one that decodes the + // last row and reads to the end of the file, then the prune scan of the tiles and the document. + assert_eq!(calls, 21); + let arc = Arc::new(bytes); + let mut import = PngImport::new("a.png", &arc).unwrap().unwrap(); + assert_eq!(import.progress(), 0.0); + while import.step(&mut || false).unwrap().is_none() { + assert!(import.progress() <= 1.0); + } + assert!(import.step(&mut || true).is_err(), "a finished import is spent"); +} + +/// Imports `bytes` with an empty budget (one bounded piece per call); returns the result and the +/// number of calls. +fn stepped_zero_budget(name: &str, bytes: &[u8]) -> (ImportResult, usize) { + let bytes = Arc::new(bytes.to_vec()); + let mut import = PngImport::new(name, &bytes).unwrap().expect("a plain PNG"); + let mut calls = 0; + loop { + calls += 1; + if let Some(r) = import.step(&mut || false).unwrap() { + return (r, calls); + } + assert!(calls < 100_000, "the import never finished"); + } +} + +/// A CRC-valid PNG chunk. +fn png_chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + let mut out = u32::try_from(data.len()).unwrap().to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out +} + +/// A compressed `zTXt` chunk with `text` as its value. +fn ztxt_chunk(keyword: &[u8], text: &[u8]) -> Vec { + use std::io::Write; + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(text).unwrap(); + let packed = encoder.finish().unwrap(); + let mut data = keyword.to_vec(); + data.extend_from_slice(&[0, 0]); + data.extend_from_slice(&packed); + png_chunk(b"zTXt", &data) +} + +/// `png` with `chunks` between the image data and `IEND`. +fn with_chunks_before_iend(png: &[u8], chunks: &[Vec]) -> Vec { + let mut at = 8; + loop { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + if &png[at + 4..at + 8] == b"IEND" { + let mut out = png[..at].to_vec(); + for c in chunks { + out.extend_from_slice(c); + } + out.extend_from_slice(&png[at..]); + return out; + } + at += len + 12; + } +} + +#[test] +fn stepped_png_import_copies_a_multi_tile_image_in_bounded_steps() { + // Four tiles (300 × 260 at 256 px), with a compressed text chunk after the image data. + let mut img = noise(300, 260, ChannelLayout::Rgba, SampleType::U16); + img.meta.dpi = Some((240.0, 240.0)); + let base = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let text = "A".repeat(120_000); + let bytes = with_chunks_before_iend(&base, &[ztxt_chunk(b"Comment", text.as_bytes())]); + assert_stepped_equals_import("tiles.png", &bytes, "multi-tile with compressed text"); + + let expected = whole("tiles.png", &bytes); + let (got, calls) = stepped_zero_budget("tiles.png", &bytes); + assert_same(&got.document, &expected.document, "zero budget"); + assert_eq!(got.warnings, expected.warnings); + assert_eq!(got.document.metadata.text, vec![("Comment".into(), text)], "the compressed text survives the steps"); + // One call per row, then its copy bands, its prune batch and the document step, each a call + // of its own with an empty budget. + assert!(calls >= 260 + 4, "{} calls: an empty budget must not make one step do it all", calls); +} + +#[test] +fn only_a_plain_png_is_stepped() { + let png = Arc::new(encode(&noise(4, 4, ChannelLayout::Rgb, SampleType::U8), Format::Png, &EncodeOptions::default()).unwrap()); + assert!(PngImport::new("a.png", &png).unwrap().is_some()); + // The same bytes under a name that sends them to another importer, or another format. + assert!(PngImport::new("a.pcraft", &png).unwrap().is_none()); + let jpeg = Arc::new(encode(&noise(4, 4, ChannelLayout::Rgb, SampleType::U8), Format::Jpeg, &EncodeOptions::default()).unwrap()); + assert!(PngImport::new("a.jpg", &jpeg).unwrap().is_none()); + assert!(PngImport::new("a.svg", &Arc::new(b"".to_vec())).unwrap().is_none()); +} + +#[test] +fn a_damaged_png_fails_in_steps_as_it_does_whole() { + let bytes = encode(&noise(64, 64, ChannelLayout::Rgb, SampleType::U8), Format::Png, &EncodeOptions::default()).unwrap(); + for cut in [30, bytes.len() / 2] { + let cut = Arc::new(bytes.get(..cut).unwrap().to_vec()); + let whole = import_with_options_and_interrupt("a.png", &cut, &ImportOptions::default(), &Interrupt::NONE); + let stepped = PngImport::new("a.png", &cut).and_then(|import| { + let mut import = import.expect("a PNG by its signature"); + loop { + if let Some(r) = import.step(&mut || true)? { + return Ok(r); + } + } + }); + assert!(whole.is_err() && stepped.is_err(), "cut at {}", cut.len()); + } +} + +/// An `eXIf` chunk (little-endian) holding only the orientation `o`. +fn exif_chunk(o: u16) -> Vec { + let mut t = b"II*\0".to_vec(); + t.extend_from_slice(&8u32.to_le_bytes()); + t.extend_from_slice(&1u16.to_le_bytes()); + for v in [0x0112u16, 3] { + t.extend_from_slice(&v.to_le_bytes()); + } + t.extend_from_slice(&1u32.to_le_bytes()); + t.extend_from_slice(&o.to_le_bytes()); + t.extend_from_slice(&[0, 0, 0, 0, 0, 0]); + png_chunk(b"eXIf", &t) +} + +/// `png` with `chunk` just before its image data. +fn with_chunk_before_idat(png: &[u8], chunk: &[u8]) -> Vec { + let at = png.windows(4).position(|w| w == b"IDAT").unwrap() - 4; + let mut out = png[..at].to_vec(); + out.extend_from_slice(chunk); + out.extend_from_slice(&png[at..]); + out +} + +/// Drives `bytes` to the end a row at a time; true if the import streamed at the start. +fn streams_at_start(bytes: &[u8]) -> bool { + let bytes = Arc::new(bytes.to_vec()); + PngImport::new("a.png", &bytes).unwrap().unwrap().streams() +} + +/// A plain PNG streams its rows into the layer; a file that can't (interlaced, or stored in +/// another orientation) is decoded whole. +#[test] +fn a_plain_png_streams_and_other_files_are_decoded_whole() { + let img = noise(40, 30, ChannelLayout::Rgb, SampleType::U8); + let plain = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(streams_at_start(&plain)); + let interlaced = encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap(); + assert!(!streams_at_start(&interlaced)); + assert!(!streams_at_start(&with_chunk_before_idat(&plain, &exif_chunk(6)))); + // An upright eXIf streams; one after the image data is only seen at the end. + assert!(streams_at_start(&with_chunk_before_idat(&plain, &exif_chunk(1)))); + assert!(streams_at_start(&with_chunks_before_iend(&plain, &[exif_chunk(6)]))); +} + +/// A file with an orientation turns upright in bands and gives the document the one-shot import +/// gives, whether the `eXIf` comes before the image data (decoded whole from the start) or after +/// it (streamed, then decoded whole), for every orientation, and a tall file takes many calls. +#[test] +fn an_oriented_png_is_turned_upright_in_steps_like_import() { + for (layout, sample) in [(ChannelLayout::Gray, SampleType::U8), (ChannelLayout::Rgba, SampleType::U16)] { + let mut img = noise(70, 150, layout, sample); + img.meta.dpi = Some((120.0, 120.0)); + img.icc = Some(vec![7; 40]); + let base = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + for o in 2..=8u16 { + for late in [false, true] { + let bytes = if late { with_chunks_before_iend(&base, &[exif_chunk(o)]) } else { with_chunk_before_idat(&base, &exif_chunk(o)) }; + let what = format!("{layout:?} {sample:?} orientation {o}, eXIf {}", if late { "after" } else { "before" }); + let expected = whole("o.png", &bytes); + assert_eq!(expected.document.size.width, if o >= 5 { 150 } else { 70 }, "{what}: upright"); + let (got, calls) = stepped_zero_budget("o.png", &bytes); + assert_same(&got.document, &expected.document, &what); + assert_eq!(got.warnings, expected.warnings, "{what}"); + // 150 rows decoded one call each, the turn in bands of 64 output rows, the copy. + assert!(calls > 150 + 2, "{what}: {calls} calls"); + assert_stepped_equals_import("o.png", &bytes, &what); + } + } + } +} + +/// A late `eXIf` sends a streamed import back to a whole decode, and the import says so. +#[test] +fn a_late_exif_ends_the_streaming() { + let base = encode(&noise(20, 20, ChannelLayout::Rgb, SampleType::U8), Format::Png, &EncodeOptions::default()).unwrap(); + let bytes = Arc::new(with_chunks_before_iend(&base, &[exif_chunk(3)])); + let mut import = PngImport::new("a.png", &bytes).unwrap().unwrap(); + assert!(import.streams()); + while import.streams() { + assert!(import.step(&mut || false).unwrap().is_none()); + } + // From here the whole image is decoded. + assert!(import.step(&mut || false).unwrap().is_none()); + assert!(!import.streams()); +} + +/// A streamed import with an ICC profile, text and a resolution is the one-shot import, at every +/// depth and layout, 16-bit included. +#[test] +fn a_streamed_png_with_a_profile_and_text_equals_import() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::U8, SampleType::U16] { + let mut img = noise(300, 270, layout, sample); + img.icc = Some(vec![9; 60]); + img.meta.dpi = Some((200.0, 200.0)); + img.meta.text.push(("Title".into(), "streamed".into())); + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(streams_at_start(&bytes)); + assert_stepped_equals_import("s.png", &bytes, &format!("{layout:?} {sample:?} with a profile")); + let (got, _) = stepped("s.png", &bytes, 0); + assert_eq!(got.document.icc_profile.as_deref().map(Vec::as_slice), Some(&[9u8; 60][..])); + } + } +} diff --git a/crates/io/tests/it/psd_flat_spot_channels.rs b/crates/io/tests/it/psd_flat_spot_channels.rs new file mode 100644 index 0000000000..606f662fc0 --- /dev/null +++ b/crates/io/tests/it/psd_flat_spot_channels.rs @@ -0,0 +1,231 @@ +//! Flat PSDs (no layer records) with spot and alpha channels. The reference editor writes one when a +//! document is flattened (psd-tools cmyk-alpha-spot.psd, its version 24.7). Its extra channels are +//! all alpha and spot channels: none is the merged transparency, which every extra channel used +//! to be read as first, so the first spot plane vanished and punched a hole in the image. + +#[cfg(feature = "corpus")] +use std::path::PathBuf; + +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::PsdFile; +use photocraft_psd::resources::ImageResource; +use photocraft_raster::Surface; + +const W: u32 = 8; + +/// Builds a grayscale channel plane at the requested sample depth. +fn plane(depth: SampleType, vals: &[f32]) -> Surface { + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_region(Rect::from_xywh(0, 0, W, 1), vals); + s +} + +/// An opaque document with a spot channel then an alpha channel. +fn flat_doc(mode: ColorMode, depth: SampleType) -> Document { + let mut doc = Document::with_background("flat", Size::new(W, 1), mode, depth, Color::WHITE); + let ink = [1.0, 1.0, 0.5, 0.0, 0.0, 0.25, 1.0, 0.0]; + let sel = [0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + doc.channels.push(AlphaChannel { spot: Some((Color::rgb(0.0, 0.6, 0.65), 0.0)), ..AlphaChannel::new("light teal", plane(depth, &ink)) }); + doc.channels.push(AlphaChannel::new("Alpha 1", plane(depth, &sel))); + doc +} + +/// `doc` saved, then rewritten without layer records, as the reference editor saves a flattened document. +fn without_layer_records(doc: &Document) -> PsdFile { + let mut file = PsdFile::from_bytes(&export(doc, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + file.layer_info = None; + file +} + +/// Gives the first DisplayInfo record (the spot's, after the 4-byte version) the reference editor's +/// undocumented colour space 0x2331. +fn unknown_spot_space(file: &mut PsdFile) { + let info = file.resources.iter_mut().find(|r| r.id == 1077).unwrap(); + info.data[4..14].copy_from_slice(b"#15fc83e\0\0"); +} + +/// Reads the complete synthetic channel row. +fn values(s: &Surface) -> Vec { + s.read_region(Rect::from_xywh(0, 0, W, 1)) +} + +/// Compares channel samples within eight-bit quantization tolerance. +fn assert_close(a: &[f32], b: &[f32], what: &str) { + assert_eq!(a.len(), b.len(), "{what}"); + for (x, y) in a.iter().zip(b) { + assert!((x - y).abs() <= 1.0 / 255.0, "{what}: {a:?} vs {b:?}"); + } +} + +/// Every extra channel of a flat file comes back, spot first with its ink, and the image stays +/// opaque, whether the names are in 1045 or only in 1006. +#[test] +fn flat_file_keeps_its_spot_and_alpha_channels() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale] { + for depth in [SampleType::U8, SampleType::U16] { + for unicode_names in [true, false] { + let what = format!("{mode:?} {depth:?} 1045 {unicode_names}"); + let doc = flat_doc(mode, depth); + let mut file = without_layer_records(&doc); + if !unicode_names { + file.resources.retain(|r| r.id != 1045); + } + assert!(!file.merged_has_alpha(), "{what}"); + let opened = import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; + let names: Vec<_> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["light teal", "Alpha 1"], "{what}"); + assert!(opened.channels[0].spot.is_some() && opened.channels[1].spot.is_none(), "{what}"); + assert_close(&values(&opened.channels[0].surface), &values(&doc.channels[0].surface), &format!("{what}: spot ink")); + assert_close(&values(&opened.channels[1].surface), &values(&doc.channels[1].surface), &format!("{what}: alpha")); + let flat = photocraft_compose::flatten(&opened); + assert!(flat.px.iter().all(|p| p[3] == 1.0), "{what}: the spot plane is not the image's transparency: {:?}", flat.px); + } + } + } +} + +#[test] +fn flat_extra_channel_names_decode_mac_roman_unless_unicode_is_present() { + let doc = flat_doc(ColorMode::Rgb, SampleType::U8); + let mut file = without_layer_records(&doc); + file.resources.iter_mut().find(|r| r.id == 1006).unwrap().data = b"\x04caf\x8e\x07Alpha 1".to_vec(); + let opened = import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; + assert_eq!(opened.channels[0].name, "light teal", "Unicode resource takes precedence"); + file.resources.retain(|r| r.id != 1045); + let opened = import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; + assert_eq!(opened.channels.iter().map(|c| c.name.as_str()).collect::>(), ["café", "Alpha 1"]); + assert!(opened.channels[0].spot.is_some()); +} + +/// A flat file from a writer that names no channel (a plain RGBA file) still reads its extra +/// channel as the merged transparency, and so does one whose first name is "Transparency". +#[test] +fn flat_file_without_channel_names_keeps_its_transparency() { + let mut doc = Document::new("rgba", Size::new(W, 1), ColorMode::Rgb, SampleType::U8); + doc.layers.push(photocraft_doc::Layer::raster("Layer 1", doc.pixel_format())); + let mut file = without_layer_records(&doc); + assert_eq!(file.header.channels, 4); + file.resources.retain(|r| r.id != 1045 && r.id != 1006); + assert!(file.merged_has_alpha()); + let mut named = file.clone(); + named.resources.push(ImageResource::new(1006, b"\x0cTransparency".to_vec())); + assert!(named.merged_has_alpha()); + // One name that is not "Transparency" for the one extra channel: an alpha channel. + let mut alpha = file.clone(); + alpha.resources.push(ImageResource::new(1006, b"\x07Alpha 1".to_vec())); + assert!(!alpha.merged_has_alpha()); +} + +/// A spot whose DisplayInfo colour is in a space PhotoCraft doesn't read takes its ink from the +/// Alternate Spot Colors (1067), as in the reference editor's cmyk-alpha-spot.psd (Lab 58.47 / +/// −33.27 / −14.59 for channel id 3). +#[test] +fn unknown_display_space_takes_the_alternate_spot_colour() { + let doc = flat_doc(ColorMode::Cmyk, SampleType::U8); + let mut file = without_layer_records(&doc); + unknown_spot_space(&mut file); + let ids = file.resources.iter().find(|r| r.id == 1053).unwrap().data.clone(); + let spot_id = u32::from_be_bytes(ids[0..4].try_into().unwrap()); + let mut alt = vec![0, 1, 0, 1]; + alt.extend_from_slice(&spot_id.to_be_bytes()); + alt.extend_from_slice(&[0x00, 0x07, 0x16, 0xd7, 0xf3, 0x01, 0xfa, 0x4d, 0x00, 0x00]); + let without_alt = import("a.psd", &file.to_bytes().unwrap()).unwrap().document; + file.resources.push(ImageResource::new(1067, alt)); + let opened = import("b.psd", &file.to_bytes().unwrap()).unwrap().document; + let (ink, solidity) = opened.channels[0].spot.unwrap(); + let rgb = ink.to_rgb().map(|v| (v * 255.0).round()); + // Lab (58.47, −33.27, −14.59) is about sRGB (0, 157, 165). + assert!((rgb[0] - 0.0).abs() <= 3.0 && (rgb[1] - 157.0).abs() <= 3.0 && (rgb[2] - 165.0).abs() <= 3.0, "{rgb:?}"); + assert_eq!(solidity, 0.0); + assert_ne!(without_alt.channels[0].spot.unwrap().0, ink, "without 1067 the record's components are read as RGB"); + // A known DisplayInfo space wins over 1067. + let mut known = without_layer_records(&doc); + known.resources.push(ImageResource::new(1067, file.resources.iter().find(|r| r.id == 1067).unwrap().data.clone())); + assert_eq!( + import("c.psd", &known.to_bytes().unwrap()).unwrap().document.channels[0].spot, + import("d.psd", &without_layer_records(&doc).to_bytes().unwrap()).unwrap().document.channels[0].spot + ); +} + +/// Malformed 1067 resources (truncated, a count past the end, a hostile count) are ignored. +#[test] +fn malformed_alternate_spot_colors_never_panic() { + let doc = flat_doc(ColorMode::Rgb, SampleType::U8); + for data in [vec![], vec![0], vec![0, 1, 0xff, 0xff], vec![0, 1, 0, 3, 0, 0, 0, 3, 0, 7], vec![0, 1, 0, 1, 0, 0, 0, 3, 0, 7, 0, 0, 0, 0, 0, 0, 0]] { + let mut file = without_layer_records(&doc); + unknown_spot_space(&mut file); + file.resources.push(ImageResource::new(1067, data.clone())); + let opened = import("m.psd", &file.to_bytes().unwrap()).unwrap().document; + assert_eq!(opened.channels.len(), 2, "{data:?}"); + } +} + +/// Duplicate identifiers select the first matching ink, and opaque resource bytes survive +/// both PSD serialization and a document import/export, including unknown colour spaces. +#[test] +fn alternate_resource_duplicates_and_unknown_spaces_round_trip() { + for depth in [SampleType::U8, SampleType::U16] { + let mut file = without_layer_records(&flat_doc(ColorMode::Rgb, depth)); + unknown_spot_space(&mut file); + let ids = &file.resource(1053).unwrap().data; + let id = &ids[..4]; + let mut data = vec![0, 1, 0, 4]; + // An unrelated green ink must be skipped before the matching red ink. + let other_id = u32::from_be_bytes(id.try_into().unwrap()).wrapping_add(1); + data.extend_from_slice(&other_id.to_be_bytes()); + data.extend_from_slice(&[0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 0]); + for color in [[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 0], [0x23, 0x31, 1, 2, 3, 4, 5, 6, 7, 8]] { + data.extend_from_slice(id); + data.extend_from_slice(&color); + } + file.resources.push(ImageResource::new(1067, data.clone())); + let bytes = file.to_bytes().unwrap(); + let parsed = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.resource(1067).unwrap().data, data); + let opened = import("duplicate.psd", &bytes).unwrap().document; + assert_eq!(opened.channels.len(), 2); + assert_eq!(opened.channels[0].spot.unwrap().0.to_rgb(), [1.0, 0.0, 0.0]); + let saved = export(&opened, "roundtrip.psd", &ExportOptions::default()).unwrap(); + let reparsed = PsdFile::from_bytes(&saved.bytes).unwrap(); + assert_eq!(reparsed.resource(1067).unwrap().data, data); + } +} + +#[cfg(feature = "corpus")] +fn psd_tools(name: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools").join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display())) +} + +/// The reference editor's flat CMYK file (24.7): the "light teal" spot (solid at 1348,1492) and "Alpha 1" +/// both open, and the image stays opaque where the spot is inked. +#[cfg(feature = "corpus")] +#[test] +fn corpus_flat_photoshop_file_keeps_its_spot() { + let doc = import("cmyk-alpha-spot.psd", &psd_tools("cmyk-alpha-spot.psd")).unwrap().document; + let names: Vec<_> = doc.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["light teal", "Alpha 1"]); + let (ink, solidity) = doc.channels[0].spot.unwrap(); + let rgb = ink.to_rgb().map(|v| (v * 255.0).round()); + assert!(rgb[0] <= 3.0 && (rgb[1] - 157.0).abs() <= 3.0 && (rgb[2] - 165.0).abs() <= 3.0, "{rgb:?}"); + assert_eq!(solidity, 0.0); + assert!(doc.channels[1].spot.is_none()); + let at = Rect::from_xywh(1348, 1492, 1, 1); + assert_eq!(doc.channels[0].surface.read_region(at), [1.0], "solid ink"); + let p = photocraft_compose::render(&doc, at).px[0]; + assert!(p[3] == 1.0 && p[..3].iter().all(|v| *v > 0.95), "no process ink, opaque: {p:?}"); +} + +/// psd-tools' flat CMYK file with three extra channels: all three open with their 1006 names. +#[cfg(feature = "corpus")] +#[test] +fn corpus_flat_file_with_pascal_names_keeps_every_channel() { + let doc = import("cmyk-spot.psd", &psd_tools("cmyk-spot.psd")).unwrap().document; + let names: Vec<_> = doc.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["blue", "fluorescent pink", "yellow"]); + let flat = photocraft_compose::flatten(&doc); + assert!(flat.px.iter().all(|p| p[3] == 1.0), "opaque"); +} diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index d873d4a3e2..109940b2a0 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -120,6 +120,60 @@ fn fresh_metrics_text_writes_no_txt2_block() { assert!(saves_txt2(Kerning::Optical), "Optical lives only in Txt2"); } +/// #10308: stylistic sets (and other Txt2-only OpenType flags) on a PhotoCraft-native document +/// write a `Txt2` block and round-trip through save and reopen. +#[test] +fn fresh_stylistic_sets_round_trip_through_psd() { + use photocraft_doc::text::FontFeature; + let base = CharStyle { font_family: "Inter".into(), size_pt: 24.0, ..Default::default() }; + let mut t = TextLayer { + text: "Hi".into(), + runs: vec![TextRun { + len: 2, + style: CharStyle { features: vec![FontFeature { tag: "ss01".into(), value: 1 }, FontFeature { tag: "ss05".into(), value: 1 }], ..base }, + }], + ..Default::default() + }; + t.sync_summary(); + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut r = t.clone(); + photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(r))); + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let txt2 = + file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).expect("Txt2 written for stylistic sets"); + let style = txt2 + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(|a| a.first()) + .and_then(|o| o.path(&["0", "6", "0"])) + .and_then(E::as_array) + .and_then(|a| a.first()) + .and_then(|r| r.path(&["0", "0", "6"])) + .expect("style run"); + // Bits 0 and 4 → sets 1 and 5. + assert_eq!(style.get("85").and_then(E::as_i64), Some((1 << 0) | (1 << 4))); + assert_eq!(style.path(&["53", "99"]).and_then(E::as_str), Some("SimplePaint"), "paint stream tag"); + assert_eq!(style.path(&["53", "0", "0"]).and_then(E::as_i64), Some(1), "fill colour type RGB"); + assert!(style.path(&["53", "0", "1"]).and_then(E::as_array).is_some_and(|v| v.len() == 4), "fill colour values"); + assert_eq!(style.get("0").and_then(E::as_i64), Some(0), "font index 0"); + assert_eq!( + txt2.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|a| a.first()).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str), + Some("Inter-Regular"), + "fresh Txt2 seeds a font entry" + ); + assert!(txt2.path(&["0", "8", "0"]).and_then(E::as_array).is_some_and(|f| !f.is_empty()), "fresh Txt2 names a frame"); + let back = photocraft_io::import("k.psd", &bytes).unwrap().document; + let LayerContent::Text(b) = &back.layers[0].content else { panic!("not text") }; + let runs = b.char_runs(); + assert_eq!(runs.iter().map(|r| r.len).sum::(), b.text.len(), "the runs cover the text"); + for r in &runs { + let on = |tag: &str| r.style.features.iter().any(|f| f.tag == tag && f.value == 1); + assert!(on("ss01") && on("ss05"), "both sets on every character: {:?}", r.style.features); + } +} + /// Two imported layers can name the same Txt2 object; export must update the second /// preserved TySh pointer so each layer restores its own automatic kerning mode. #[test] @@ -305,6 +359,33 @@ fn edited_text_layer_takes_its_own_style_runs() { assert!(t.char_runs().iter().all(|r| r.style.features.iter().all(|f| f.tag == "calt")), "the old text's salt came back"); } +/// Stylistic Sets (#10285) on a type layer of an opened file save into its `Txt2` object as the +/// set mask and come back on reopen: Set 1 and Set 5 on "Il", none on "36". +#[test] +fn edited_text_layer_keeps_its_stylistic_sets() { + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut t = layer("Il36", &[(2, Kerning::Metrics, 0.0), (2, Kerning::Metrics, 0.0)]); + if let Some(run) = t.runs.first_mut() { + run.style.features = ["ss01", "ss05"].iter().map(|tag| photocraft_doc::text::FontFeature { tag: (*tag).into(), value: 1 }).collect(); + } + t.sync_summary(); + photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); + let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap())); + doc.layers.push(Layer::new("sets", LayerContent::Text(t))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(&[("Il36", "")])))]; + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("k.psd", &bytes).unwrap().document; + let LayerContent::Text(t) = &back.layers[0].content else { panic!("not text") }; + assert_eq!(t.text, "Il36"); + let on = |r: &TextRun, tag: &str| r.style.features.iter().any(|f| f.tag == tag && f.value > 0); + let runs = t.char_runs(); + let first = runs.first().unwrap(); + assert!(on(first, "ss01") && on(first, "ss05") && !on(first, "ss02"), "{runs:?}"); + assert_eq!(first.len, 2, "{runs:?}"); + assert!(runs.iter().skip(1).all(|r| !on(r, "ss01") && !on(r, "ss05")), "{runs:?}"); +} + /// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated /// `Txt2` is sized by real text objects, never by a file's numbers) and the document still saves /// and reopens with its text. diff --git a/crates/io/tests/it/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs index 7445bbf757..be6fbd360b 100644 --- a/crates/io/tests/it/psd_thumbnail.rs +++ b/crates/io/tests/it/psd_thumbnail.rs @@ -151,3 +151,29 @@ fn the_document_profile_is_honoured() { let mid = px[25 * 50 + 25]; assert!(near(mid, [0.735; 3], 0.03), "{mid:?}"); } + +/// The thumbnail shows the document's spot inks over its composite, as the reference editor's +/// does (psd-tools cmyk-alpha-spot.psd: its spot-only text is in the preview, not in the merged +/// image). +#[test] +fn spot_inks_print_in_the_thumbnail() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut d = doc(mode, SampleType::U8, 64, 32, [1.0, 1.0, 1.0, 1.0], 1.0); + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut ink = photocraft_raster::Surface::new(fmt); + ink.fill_rect(photocraft_geom::Rect::new(0, 0, 32, 32), &[1.0]); + let teal = photocraft_color::Color::rgb(0.0, 157.0 / 255.0, 165.0 / 255.0); + d.channels.push(photocraft_doc::AlphaChannel { spot: Some((teal, 0.0)), ..photocraft_doc::AlphaChannel::new("light teal", ink) }); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let (w, _, px) = thumbnail(&bytes).unwrap(); + let (inked, bare) = (px[(16 * w + 8) as usize], px[(16 * w + 56) as usize]); + assert!(near(inked, [0.0, 157.0 / 255.0, 165.0 / 255.0], 0.06), "{mode:?} {inked:?}"); + assert!(near(bare, [1.0, 1.0, 1.0], 0.04), "{mode:?} {bare:?}"); + // The merged process planes stay free of the ink: white (no CMYK ink is stored as 255 too). + let merged = PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap(); + let plane = 64 * 32; + for c in 0..mode.color_channels() { + assert_eq!(merged[c * plane + 16 * 64 + 8], 255, "{mode:?} merged channel {c}"); + } + } +} diff --git a/crates/io/tests/it/roundtrip.rs b/crates/io/tests/it/roundtrip.rs index 155429eba2..f418c900da 100644 --- a/crates/io/tests/it/roundtrip.rs +++ b/crates/io/tests/it/roundtrip.rs @@ -608,3 +608,69 @@ fn psd_16bit_layers_with_compatibility_section_open_visible() { assert!(imported.layers[0].surface().unwrap().tile_count() > 0); assert!(imported.layers[1].surface().unwrap().tile_count() > 0); } + +fn framed(children: Vec, stroke: Option) -> photocraft_doc::Layer { + let mut g = photocraft_doc::Layer::group("frame", children); + g.frame = Some(Box::new(photocraft_doc::Frame { + path: photocraft_vector::shapes::rect(0.0, 0.0, 1.0, 1.0), + transform: photocraft_geom::Affine { m: [6.0, 0.0, 0.0, 6.0, 1.0, 1.0] }, + stroke, + })); + g +} + +#[test] +fn frame_children_keep_their_links() { + use photocraft_doc::{Document, Layer, LayerContent, Size}; + let mut d = Document::new("l", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let mut a = Layer::raster("a", fmt); + a.link_group = Some(5); + let b = Layer::raster("b", fmt); + let mut outside = Layer::raster("outside", fmt); + outside.link_group = Some(5); + d.layers = vec![framed(vec![a, b], Some(photocraft_doc::ShapeStroke::default())), outside]; + let back = roundtrip(&d); + let LayerContent::Group(g) = &back.layers[0].content else { panic!("frame is not a group") }; + let names: Vec<_> = g.children.iter().map(|l| (l.name.as_str(), l.link_group)).collect(); + assert_eq!(names, [("a", Some(5)), ("b", None)]); + assert_eq!(back.layers[0].link_group, None); + assert_eq!(back.layers[1].link_group, Some(5)); +} + +#[test] +fn frame_stroke_exports_its_pixels() { + use photocraft_doc::{Document, Layer, Size}; + let mut d = Document::new("s", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let stroke = photocraft_doc::ShapeStroke { width: 2.0, ..Default::default() }; + d.layers = vec![framed(vec![Layer::raster("a", fmt)], Some(stroke))]; + let r = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&r.bytes).unwrap(); + let rec = f.layers().iter().find(|r| r.name() == "Frame Stroke").expect("stroke record"); + assert!(!rec.rect.is_empty(), "the border has no pixels: {:?}", rec.rect); +} + +/// Invalidating an imported frame must not restore it from its preserved PSD metadata. +#[test] +fn an_invalidated_frame_stays_removed_after_psd_roundtrip() { + use photocraft_doc::{Document, Layer, Size}; + + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("frame", Size::new(8, 8), ColorMode::Rgb, depth); + doc.layers = vec![framed(vec![Layer::raster("image", doc.pixel_format())], None)]; + let mut imported = roundtrip(&doc); + let frame = &mut imported.layers[0]; + assert!(frame.frame.is_some(), "{depth:?}: the frame must first survive import"); + assert!(frame.psd_blocks.iter().any(|(key, _)| key == b"pcFr")); + frame.frame = None; + + let exported = export(&imported, "invalidated.psd", &ExportOptions::default()).unwrap(); + let psd = PsdFile::from_bytes(&exported.bytes).unwrap(); + assert!(psd.layers().iter().all(|layer| layer.block(b"pcFr").is_none()), "{depth:?}: stale frame metadata was exported"); + let reopened = import("invalidated.psd", &exported.bytes).unwrap().document; + assert!(reopened.layers[0].frame.is_none(), "{depth:?}: the obsolete frame was restored"); + assert!(reopened.layers[0].is_group()); + assert_eq!(reopened.layers[0].children().unwrap().len(), 1); + } +} diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 5c695d1526..3f0ba53462 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -411,6 +411,53 @@ fn deeply_nested_markup_is_an_error_not_a_stack_overflow() { assert_eq!(r.document.layers.len(), 1); } +/// The parser expands a DTD's internal entities after the depth check: an entity holding +/// markup, or a quote in a DTD comment or declaration that made the check skip ahead, hid 20 000 nested groups +/// from it and overflowed the stack again. Both are errors now; same small stack as above. +#[test] +fn a_dtd_cannot_hide_nesting_from_the_depth_check() { + let deep = |open: &str, close: &str| format!("{}{}", open.repeat(20_000), close.repeat(20_000)); + let with_dtd = |subset: &str, body: &str| format!("{body}"); + let cases = [ + ("entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("single-quoted entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("quote in a comment", with_dtd("", &format!("{}'", deep("", "")))), + ("quote in an element declaration", with_dtd("", &format!("{}'", deep("", "")))), + ( + "entity between quoted attribute lists", + with_dtd(&format!("", deep("", "")), "&a;"), + ), + ("entity between quoted notations", with_dtd(&format!("", deep("", "")), "&a;")), + ]; + std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(move || { + for (name, svg) in &cases { + let gz = gzip(svg.as_bytes()); + for bytes in [svg.as_bytes(), gz.as_slice()] { + let e = import("deep.svg", bytes).err().unwrap_or_else(|| panic!("{name} opened")); + assert!(matches!(&e, IoError::Svg(_)), "{name}: {e}"); + } + } + }) + .unwrap() + .join() + .unwrap(); +} + +/// Text-only entities, as Illustrator declares its namespaces, still open, `<` included: +/// the parser keeps a character reference in an entity as text. +#[test] +fn text_entities_in_a_dtd_still_open() { + let r = open( + " ?> ]>\ + &c;", + ); + assert_eq!(r.document.layers.len(), 1); + assert!(close(px(&r.document, 4, 4), [1.0, 0.0, 0.0, 1.0])); +} + /// The depth check reads the markup, not the parser's tree: it must not count `<` in comments, /// CDATA, processing instructions or the DOCTYPE, nor end a tag at a `>` inside quotes. #[test] @@ -476,6 +523,136 @@ fn rasterize_renders_the_whole_drawing_at_a_scale() { assert!(svg::rasterize(huge.as_bytes(), 64.0).is_err(), "a render past the pixel cap is an error, not an allocation"); } +/// resvg's morphology, convolution and turbulence cost pixels × radius², × order² and × octaves: +/// such a drawing is refused up front instead of rendering for minutes. +#[test] +fn filters_too_costly_to_render_are_refused_not_run() { + let drawing = |side: u32, fe: &str| { + format!( + "{fe}" + ) + }; + let kernel = vec!["1"; 100 * 100].join(" "); + // Masks, patterns and embedded drawings filter at the scale of the element using them: a + // 20-unit radius-10 morphology drawn 100× larger. + let f = ""; + let inner = format!("{f}"); + let embedded = base64_data_url("image/svg+xml", inner.as_bytes()); + for svg in [ + drawing(2000, ""), + drawing(2000, &format!("")), + drawing(2000, ""), + format!( + "{f}" + ), + format!( + "{f}" + ), + format!(""), + ] { + let head: String = svg.chars().take(300).collect(); + let e = import("x.svg", svg.as_bytes()).err().unwrap_or_else(|| panic!("opened: {head}")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + assert!(svg::rasterize(svg.as_bytes(), 1.0).is_err(), "rendered: {head}"); + // The budgeted paths (smart-object contents and their renders) refuse it too. + let e = svg::import_svg_bounded_images("x.svg", svg.as_bytes(), 1 << 24).err().unwrap_or_else(|| panic!("opened bounded: {head}")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + let e = svg::rasterize_bounded(svg.as_bytes(), 1.0, 1 << 24).err().unwrap_or_else(|| panic!("rendered bounded: {head}")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + } + // Ordinary filters still open and render. + let ordinary = drawing(500, ""); + assert!(!open(&ordinary).document.layers.is_empty()); + assert!(svg::rasterize(ordinary.as_bytes(), 1.0).is_ok()); + // The smart-object render counts its scale: cheap at 1×, refused at 40×. + let small = drawing(100, ""); + assert!(svg::rasterize(small.as_bytes(), 1.0).is_ok()); + assert!(svg::rasterize(small.as_bytes(), 40.0).is_err()); +} + +/// Patterns that fill and stroke with the next one double resvg's work at every level: past the +/// check's element budget the drawing is refused, not walked (or rendered) for hours. +#[test] +fn patterns_nested_too_deep_are_refused_not_walked() { + let chain = |depth: usize| { + let defs: String = (0..depth) + .map(|i| { + let paint = if i + 1 < depth { format!("url(#p{})", i + 1) } else { "red".into() }; + format!("") + }) + .collect(); + format!("{defs}") + }; + let deep = chain(24); + let e = import("x.svg", deep.as_bytes()).err().unwrap_or_else(|| panic!("opened a 2^24-use pattern chain")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("too costly")), "{e}"); + assert!(svg::rasterize(deep.as_bytes(), 1.0).is_err()); + // A few levels still open. + assert!(!open(&chain(4)).document.layers.is_empty()); +} + +/// usvg keeps a chain of single-use patterns past the check's recursion cutoff and resvg draws +/// every one of them: a costly filter at the end of the chain is refused, not rendered unseen. +#[test] +fn a_costly_filter_past_the_pattern_recursion_cutoff_is_refused() { + let chain = |depth: usize, fe: &str| { + let defs: String = (0..depth) + .map(|i| { + let body = if i + 1 < depth { + format!("", i + 1) + } else { + "".into() + }; + format!("{body}") + }) + .collect(); + format!("{fe}{defs}") + }; + // Just over the budget (about 3.5 billion reads), so a regression fails in seconds, not hours. + let fe = ""; + assert!(import("x.svg", chain(4, fe).as_bytes()).is_err(), "the filter alone is over the budget"); + let deep = chain(210, fe); + let e = import("x.svg", deep.as_bytes()).err().unwrap_or_else(|| panic!("opened a costly filter 210 patterns deep")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("too costly")), "{e}"); + assert!(svg::rasterize(deep.as_bytes(), 1.0).is_err()); + // A short chain with an ordinary filter still opens, and so do groups nested past the cutoff. + assert!(!open(&chain(4, "")).document.layers.is_empty()); + let groups = + format!("{}{}", "".repeat(250), "".repeat(250)); + assert!(!open(&groups).document.layers.is_empty()); +} + +#[test] +fn drawings_the_svg_renderer_panics_on_are_errors_not_crashes() { + // usvg panics laying out an embedded SVG image of a zero-ish width. + let nested = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10'/%3E%3C/svg%3E"; + let bad_image = format!(""); + let e = import("image.svg", bad_image.as_bytes()).err().unwrap_or_else(|| panic!("opened")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + // The budgeted parse (placed and embedded drawings) is guarded too. + let e = svg::import_svg_bounded_images("image.svg", bad_image.as_bytes(), 1 << 24).err().unwrap_or_else(|| panic!("opened")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + // resvg panics rendering an offset past the pixmap's size: that element is dropped with a + // warning, the rest of the drawing still opens. + let offset = "\ + "; + let svg = format!("{offset}"); + let r = open(&svg); + assert_eq!(r.document.layers.len(), 1, "{:?}", r.document.layers.iter().map(|l| &l.name).collect::>()); + assert!(matches!(r.document.layers[0].content, LayerContent::Shape(_))); + assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); + // The same drawing as a smart object, and past the element limit (opened as one pixel layer). + let e = svg::rasterize(svg.as_bytes(), 1.0).err().unwrap_or_else(|| panic!("rendered")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + let e = svg::rasterize_bounded(svg.as_bytes(), 1.0, 1 << 24).err().unwrap_or_else(|| panic!("rendered")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + let many = format!("{offset}{}", "".repeat(2_000)); + let r = open(&many); + assert!(r.document.layers.is_empty()); + assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("nothing to draw")), "not an empty drawing: {:?}", r.warnings); +} + #[test] fn an_svg_round_trips_through_pcraft_as_shapes() { let r = open(&format!( diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs index 7fc6532862..180a82dd85 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -477,6 +477,8 @@ fn read_response(stream: &mut Stream) -> Result> { 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; + // Where the header-end search resumes (three bytes back, for a marker split across reads). + let mut head_at = 0usize; let started = std::time::Instant::now(); loop { if started.elapsed() > REPLY_DEADLINE { @@ -491,14 +493,17 @@ fn read_response(stream: &mut Stream) -> Result> { return Err(Error::Protocol("the reply is larger than 16 MiB".into())); } if body_at.is_none() - && let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") + && let Some(end) = raw.get(head_at..).and_then(|r| r.windows(4).position(|w| w == b"\r\n\r\n")).map(|p| head_at + p) { body_at = Some(end + 4); let (_, headers, _) = http_response(&raw)?; expected = headers.iter().find(|(k, _)| k == "content-length").and_then(|(_, v)| v.parse().ok()); chunked = headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")); } - let Some(at) = body_at else { continue }; + let Some(at) = body_at else { + head_at = raw.len().saturating_sub(3); + continue; + }; let body = raw.get(at..).unwrap_or_default(); let complete = match expected { Some(len) if !chunked => body.len() >= len, diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index 14ddf43846..c428915a49 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -651,3 +651,23 @@ fn printer_names_are_encoded_as_one_request_target_segment() { assert_eq!(queue_path("office printer/1", false).unwrap(), "/printers/office%20printer%2F1"); assert!(queue_path("printer\r\nX-Injected: yes", false).is_err()); } + +/// A reply arriving a byte at a time, its blank line split across reads, is still found: the +/// header search resumes where it stopped instead of rescanning everything read so far. +#[test] +fn a_header_trickled_a_byte_at_a_time_is_found() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + s.set_nodelay(true).unwrap(); + for b in b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokEXTRA" { + s.write_all(&[*b]).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }); + let mut stream = Stream::Tcp(TcpStream::connect(addr).unwrap()); + let raw = read_response(&mut stream).unwrap(); + assert!(raw.ends_with(b"\r\n\r\nok"), "{:?}", String::from_utf8_lossy(&raw)); + server.join().unwrap(); +} diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index bc5dd20ce9..9ec59adad4 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -41,6 +41,13 @@ pub struct HistoryState { pub id: u64, } +/// The context in which a selected action row must remain available after it is folded away. +#[derive(Clone, Debug)] +pub enum ActionBrushSource { + Current(HistoryState), + Cleared(HistoryState), +} + #[derive(Clone, Debug)] pub struct History { /// Past states; the last one is the state *before* the current document. @@ -58,6 +65,10 @@ pub struct History { current_layers: LayerTarget, /// [`HistoryState::visibility`] of the current state. current_visibility: Vec, + /// Optional compound-operation accumulator; snapshots and rollback carry it with the history. + visibility_capture: Option>>, + /// Changes only when every undo/redo state is irreversibly discarded. + purge_generation: u64, /// History Options › Allow Non-Linear History: a step recorded while later states exist /// (after stepping back) keeps them and goes to the end of the list, instead of discarding /// them; see [`History::record`] and [`History::remove`]. @@ -75,11 +86,15 @@ pub struct History { next_id: u64, /// The History Brush's source state, set in the History panel (`None`: the default). brush_source: Option, + /// A snapshot-backed alias of a folded row. Clear History moves this with its row source; + /// a snapshot selected explicitly by the user remains separate in the document state. + brush_source_snapshot: Option, } /// What [`History::rollback`] needs to take the most recent [`History::record`] back. #[derive(Clone, Debug)] struct LastRecord { + visibility_capture_before: Option>>, /// The document it replaced (the state a rollback makes current again). before: Arc, /// How many former redo states it moved onto the undo side (always 0 in linear history). @@ -99,6 +114,7 @@ struct Cleared { undo: VecDeque, redo: Vec, brush_source: Option, + brush_source_snapshot: Option, } /// The undo label of the step [`History::clear_states`] adds (Edit › Undo Clear History). @@ -111,6 +127,7 @@ impl Default for History { } impl History { + /// Initialize history with the supplied document as its starting state and no active action visibility capture. pub fn new(max_states: usize) -> Self { Self { undo: VecDeque::new(), @@ -121,12 +138,15 @@ impl History { current_label: "Open".into(), current_layers: LayerTarget::default(), current_visibility: Vec::new(), + visibility_capture: None, + purge_generation: 0, non_linear: false, last_record: None, cleared: None, current_id: 0, next_id: 1, brush_source: None, + brush_source_snapshot: None, } } @@ -179,7 +199,7 @@ impl History { } evicted.push((at, state)); } - self.last_record = Some(LastRecord { before, moved, evicted }); + self.last_record = Some(LastRecord { before, moved, evicted, visibility_capture_before: self.visibility_capture.clone() }); } pub fn can_undo(&self) -> bool { @@ -221,6 +241,72 @@ impl History { /// again when a later change coalesces into the step). pub fn add_current_visibility(&mut self, ids: &[LayerId]) { merge_ids(&mut self.current_visibility, ids); + if let Some(capture) = &mut self.visibility_capture { + merge_ids(Arc::make_mut(capture), ids); + } + } + + /// Retain only changes that recorded edits make undoable, across trimming and local purges. + pub fn begin_visibility_capture(&mut self, prefix: &[LayerId]) { + self.visibility_capture = Some(Arc::new(prefix.to_vec())); + } + + /// Return the visibility prefix accumulated for the current grouped action, if capture is active. + pub fn captured_visibility(&self) -> Option<&[LayerId]> { + self.visibility_capture.as_deref().map(Vec::as_slice) + } + + /// Release an action's accumulator when a purge leaves no grouped step to publish. + pub fn end_visibility_capture(&mut self) { + self.visibility_capture = None; + } + + /// An irreversible history purge establishes a new starting point for later edits. + pub fn was_purged_since(&self, earlier: &Self) -> bool { + self.purge_generation != earlier.purge_generation + } + + /// Keep source selections when temporary action rows are consolidated. Return a selected + /// intermediate state so the caller can retain its pixels as a normal history snapshot. + /// A source already deleted before consolidation remains unresolved. + pub fn preserve_action_brush_source(&mut self, later: &Self) -> Option { + let current = self.current_id; + let prior_current = later.current_id; + let collapsed = |id| { + if id == prior_current || self.held_states().any(|state| state.id == id) { None } else { later.held_states().find(|state| state.id == id).cloned() } + }; + let source = later + .brush_source + .and_then(collapsed) + .map(ActionBrushSource::Current) + .or_else(|| later.cleared.as_ref().and_then(|cleared| cleared.brush_source).and_then(collapsed).map(ActionBrushSource::Cleared)); + let map = |source: Option| source.map(|id| if id == prior_current { current } else { id }); + self.brush_source = map(later.brush_source); + self.brush_source_snapshot = later.brush_source_snapshot; + if let (Some(dst), Some(src)) = (&mut self.cleared, &later.cleared) { + dst.brush_source = map(src.brush_source); + dst.brush_source_snapshot = src.brush_source_snapshot; + } + source + } + + /// The snapshot retaining a folded row's pixels in the currently active source context. + pub fn brush_source_snapshot(&self) -> Option { + self.brush_source_snapshot + } + + /// Retain a folded row through a native snapshot, without adding a visible Undo state. + pub fn set_action_brush_snapshot(&mut self, snapshot: u64) { + self.brush_source = None; + self.brush_source_snapshot = Some(snapshot); + } + + /// Keep a folded source inactive until Undo Clear History restores its source context. + pub fn set_cleared_brush_snapshot(&mut self, snapshot: u64) { + if let Some(cleared) = &mut self.cleared { + cleared.brush_source = None; + cleared.brush_source_snapshot = Some(snapshot); + } } /// Undo: `current` becomes redoable; returns the document to make current and the layers it @@ -228,10 +314,11 @@ impl History { pub fn undo(&mut self, current: Arc) -> Option<(Arc, LayerTarget)> { // Undo Clear History: the cleared states come back; the document doesn't change. if let Some(cleared) = self.cleared.take() { - let Cleared { undo, redo, brush_source } = *cleared; + let Cleared { undo, redo, brush_source, brush_source_snapshot } = *cleared; self.undo = undo; self.redo = redo; self.brush_source = brush_source; + self.brush_source_snapshot = brush_source_snapshot; self.tiles = None; return Some((current, self.current_layers.clone())); } @@ -244,6 +331,9 @@ impl History { let label = std::mem::replace(&mut self.current_label, prev.label); let layers = std::mem::replace(&mut self.current_layers, prev.layers.clone()); let visibility = std::mem::replace(&mut self.current_visibility, prev.visibility); + if let Some(capture) = &mut self.visibility_capture { + merge_ids(Arc::make_mut(capture), &visibility); + } let id = std::mem::replace(&mut self.current_id, prev.id); self.redo.push(HistoryState { label, doc: current, layers, visibility, id }); Some((prev.doc, prev.layers)) @@ -260,6 +350,9 @@ impl History { let label = std::mem::replace(&mut self.current_label, next.label); let layers = std::mem::replace(&mut self.current_layers, next.layers.clone()); let visibility = std::mem::replace(&mut self.current_visibility, next.visibility); + if let Some(capture) = &mut self.visibility_capture { + merge_ids(Arc::make_mut(capture), &self.current_visibility); + } let id = std::mem::replace(&mut self.current_id, next.id); self.undo.push_back(HistoryState { label, doc: current, layers, visibility, id }); Some((next.doc, next.layers)) @@ -287,6 +380,11 @@ impl History { } } + /// Visible History panel row ids in order, including the current and dimmed redo rows. + pub fn row_ids(&self) -> impl Iterator + '_ { + self.undo.iter().map(|state| state.id).chain(std::iter::once(self.current_id)).chain(self.redo.iter().rev().map(|state| state.id)) + } + /// The History panel row of the state with id `id`, if it is still held. pub fn row_of(&self, id: u64) -> Option { let past = self.undo.len(); @@ -316,6 +414,7 @@ impl History { pub fn set_brush_source(&mut self, i: usize) -> bool { let Some(id) = self.row_id(i) else { return false }; self.brush_source = Some(id); + self.brush_source_snapshot = None; true } @@ -339,7 +438,17 @@ impl History { self.current_label = label.into(); } - /// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the + /// The user's Edit › Purge › Undo also starts a new action-history boundary. Internal + /// compound commands use [`Self::purge_last`] to fold their records without this boundary. + pub fn purge_undo(&mut self) -> bool { + let purged = self.purge_last(); + if purged { + self.purge_generation = self.purge_generation.wrapping_add(1); + } + purged + } + + /// Forget the most recent undo state and every redo state: the /// last step can no longer be undone and its pixels are released. pub fn purge_last(&mut self) -> bool { self.clear_redo(); @@ -429,7 +538,8 @@ impl History { if self.undo.len().saturating_add(held.evicted.len()) <= held.moved { return None; } - let LastRecord { moved, evicted, .. } = self.last_record.take()?; + let LastRecord { moved, evicted, visibility_capture_before, .. } = self.last_record.take()?; + self.visibility_capture = visibility_capture_before; // Put each back where it was taken off, the last one taken off first. for (at, state) in evicted.into_iter().rev() { if let Some(tiles) = &mut self.tiles { @@ -459,6 +569,12 @@ impl History { self.last_record.as_ref().map(|held| held.before.clone()) } + /// Keep identifiers consumed by a completed compound operation reserved when its history + /// is consolidated from an earlier snapshot. Removed rows must not name a later state. + pub fn reserve_ids_from(&mut self, later: &Self) { + self.next_id = self.next_id.max(later.next_id); + } + /// Forget the redo states, e.g. after undoing half of a compound step that failed. pub fn clear_redo(&mut self) { if !self.redo.is_empty() { @@ -476,6 +592,7 @@ impl History { /// goes back to the default, the oldest state held (now the current one): a source set on a /// state the purge removed would otherwise leave no source. pub fn clear(&mut self) { + self.purge_generation = self.purge_generation.wrapping_add(1); self.last_record = None; self.current_visibility.clear(); self.undo.clear(); @@ -488,6 +605,7 @@ impl History { /// Make the History Brush's source the default again (the oldest state held), pinning no state. pub fn reset_brush_source(&mut self) { self.brush_source = None; + self.brush_source_snapshot = None; } /// History panel › Clear History: take every state but the current one off the list without @@ -499,13 +617,27 @@ impl History { if self.undo.is_empty() && self.redo.is_empty() { return false; } + self.clear_states_including_empty(); + true + } + + /// Preserve a Clear already accepted during an action when its command-local rows fold + /// into this captured history, which may have no rows. Ordinary empty Clear still refuses. + pub fn preserve_action_clear(&mut self, later: &Self) { + if later.is_cleared() && !self.is_cleared() { + self.clear_states_including_empty(); + } + } + + /// Apply an accepted Clear to an empty captured history as well as populated histories, preserving its boundary. + fn clear_states_including_empty(&mut self) { let undo = std::mem::take(&mut self.undo); let redo = std::mem::take(&mut self.redo); let brush_source = self.brush_source.take(); - self.cleared = Some(Box::new(Cleared { undo, redo, brush_source })); + let brush_source_snapshot = self.brush_source_snapshot.take(); + self.cleared = Some(Box::new(Cleared { undo, redo, brush_source, brush_source_snapshot })); self.last_record = None; self.tiles = None; - true } /// Whether the last change to the list was Clear History (so undo restores the states). diff --git a/crates/ops/src/tests.rs b/crates/ops/src/tests.rs index a99d1d557a..74c8b014a4 100644 --- a/crates/ops/src/tests.rs +++ b/crates/ops/src/tests.rs @@ -14,6 +14,31 @@ fn edit(h: &mut History, cur: &mut Arc, label: &str, f: impl FnOnce(&m h.record(label, before, LayerTarget::default()); } +/// A failed local transaction drops its visibility flags; a successful local purge keeps them. +#[test] +fn visibility_capture_follows_rollback_snapshot_and_purge() { + let mut doc = base(); + doc.insert_above(None, Layer::raster("Second", doc.pixel_format())); + let ids = [doc.layers[0].id, doc.layers[1].id]; + let mut cur = Arc::new(doc); + let mut history = History::default(); + history.begin_visibility_capture(&[]); + edit(&mut history, &mut cur, "Prefix", |doc| doc.layer_mut(ids[0]).unwrap().visible = false); + history.add_current_visibility(&ids[..1]); + let prefix = cur.clone(); + let checkpoint = history.clone(); + edit(&mut history, &mut cur, "Failed part", |doc| doc.layer_mut(ids[1]).unwrap().visible = false); + history.add_current_visibility(&ids[1..]); + cur = history.rollback().unwrap().0; + assert_eq!(cur.as_ref(), prefix.as_ref()); + assert_eq!(history.captured_visibility(), Some(ids[..1].as_ref())); + assert_eq!(checkpoint.captured_visibility(), Some(ids[..1].as_ref()), "a cloned snapshot is isolated"); + edit(&mut history, &mut cur, "Successful part", |doc| doc.layer_mut(ids[1]).unwrap().visible = false); + history.add_current_visibility(&ids[1..]); + assert!(history.purge_last()); + assert_eq!(history.captured_visibility(), Some(ids.as_ref()), "purging a local record keeps its actual change"); +} + #[test] fn undo_redo_cycle() { let mut h = History::default(); @@ -1133,6 +1158,7 @@ fn transferred_tiles_cannot_make_the_budget_check_underestimate_current() { } #[test] +/// Clear History changes the state list without changing pixels and remains undoable where the operation permits it. fn clear_states_keeps_the_document_and_undo_brings_the_list_back() { let mut h = History::new(10); let mut cur = Arc::new(base()); @@ -1156,6 +1182,34 @@ fn clear_states_keeps_the_document_and_undo_brings_the_list_back() { assert!(!h.is_cleared()); } +#[test] +/// Finalizing a grouped action must not discard an accepted Clear merely because its captured history was empty. +fn an_action_preserves_an_accepted_clear_over_an_empty_capture() { + let mut captured = History::new(50); + assert!(!captured.clear_states(), "ordinary empty Clear still refuses"); + captured.preserve_action_clear(&History::new(50)); + assert!(!captured.is_cleared(), "no boundary without an accepted Clear"); + let mut later = captured.clone(); + let original = Arc::new(base()); + let mut current = original.clone(); + edit(&mut later, &mut current, "Black source", |doc| doc.name = "black".into()); + assert!(later.set_brush_source(1)); + current = later.undo(current).unwrap().0; + assert!(Arc::ptr_eq(¤t, &original)); + assert!(later.clear_states(), "the live redo row makes Clear legitimate"); + captured.preserve_action_clear(&later); + assert!(captured.is_cleared() && captured.can_undo()); + assert_eq!(captured.undo_label(), Some(CLEAR_HISTORY_LABEL)); + let Some(ActionBrushSource::Cleared(source)) = captured.preserve_action_brush_source(&later) else { panic!("the exact cleared source must survive") }; + assert_eq!(source.doc.name, "black"); + captured.set_cleared_brush_snapshot(7); + let restored = captured.undo(current.clone()).unwrap().0; + assert!(Arc::ptr_eq(&restored, ¤t), "Undo Clear restores the list, not pixels"); + assert!(!captured.is_cleared()); + assert_eq!(captured.brush_source_snapshot(), Some(7)); + assert_eq!(captured.past_len(), 0); +} + #[test] fn cleared_states_still_count_in_the_memory_figures_and_the_budget() { let mut h = History::new(50); diff --git a/crates/paint/examples/bench_brush.rs b/crates/paint/examples/bench_brush.rs index efd9c010f1..d6d5567188 100644 --- a/crates/paint/examples/bench_brush.rs +++ b/crates/paint/examples/bench_brush.rs @@ -29,6 +29,7 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { let start = Instant::now(); let mut r = StrokeRenderer::new(brush, Some(fmt), 1.0); let mut worst = 0.0f64; + let (mut worst_push, mut worst_comp) = (0.0f64, 0.0f64); let mut chunks = 0; let (mut t_push, mut t_comp) = (0.0f64, 0.0f64); for ch in pts.chunks(16) { @@ -40,13 +41,15 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { t_push += tp; t_comp += tt - tp; worst = worst.max(tt); + worst_push = worst_push.max(tp); + worst_comp = worst_comp.max(tt - tp); chunks += 1; } r.finish(); r.composite(&pre, &mut s, None, false, false); let total = start.elapsed().as_secs_f64() * 1000.0; println!( - "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms", + "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms worst push/chunk {worst_push:>6.2} ms worst composite/chunk {worst_comp:>6.2} ms", r.dab_count(), total / chunks as f64 ); diff --git a/crates/paint/examples/big_dab_bench.rs b/crates/paint/examples/big_dab_bench.rs index b8231b9003..94ee7b8e8d 100644 --- a/crates/paint/examples/big_dab_bench.rs +++ b/crates/paint/examples/big_dab_bench.rs @@ -46,7 +46,11 @@ fn main() { // Two points a pixel apart: the generator places the first dab at once (a lone point waits). r.push(&[StrokePoint::new(3000.0, 2000.0, 1.0), StrokePoint::new(3001.0, 2000.0, 1.0)]); let push = t.elapsed(); - println!(" push (rasterize+accumulate): {:>5.1} ms (accumulate ≈ {:.1} ms)", push.as_secs_f64() * 1e3, push.saturating_sub(raster).as_secs_f64() * 1e3); + println!( + " push (rasterize+accumulate): {:>5.1} ms (push − warm rasterize, first dab ≈ {:.1} ms)", + push.as_secs_f64() * 1e3, + push.saturating_sub(raster).as_secs_f64() * 1e3 + ); let t = Instant::now(); let dmg = r.composite(&pre, &mut target, None, false, false); let comp = t.elapsed(); diff --git a/crates/paint/examples/coverage_bench.rs b/crates/paint/examples/coverage_bench.rs index 469738c4c8..97746a7f0a 100644 --- a/crates/paint/examples/coverage_bench.rs +++ b/crates/paint/examples/coverage_bench.rs @@ -1,6 +1,8 @@ //! Isolate dab accumulation over a 6016×6016 (36 MP) plane, without rasterization/compositing. //! Run in release; reports median of five runs after warmup. Compare the same harness on both //! revisions. Resident tiles model a long stroke; moving dabs also exercise new tile allocation. +//! A final line times a large soft dab (3457x3457, about 3025 tiles) marching 500 px per dab on +//! fresh maps: first dab and steady-state median, in ms. use std::hint::black_box; use std::time::Instant; @@ -33,6 +35,31 @@ fn run(size: i32, moving: bool) -> (f64, f32) { (micros, checksum) } +/// Large soft dab marching along x on a fresh map: (first dab ms, median of the rest ms). +fn run_large() -> (f64, f64) { + let size = 3457; + let vals: Vec = (0..size * size) + .map(|i| { + let x = (i % size) as f32 + 0.5 - size as f32 / 2.0; + let y = (i / size) as f32 + 0.5 - size as f32 / 2.0; + (1.0 - (x * x + y * y).sqrt() / (size as f32 / 2.0)).max(0.0) * 0.1 + }) + .collect(); + let mut map = CoverageMap::new(3); + let mut times = Vec::new(); + for i in 0..30 { + let x = i * 500; + let start = Instant::now(); + map.accumulate(black_box(Rect::new(x, 0, x + size, size)), black_box(&vals), 0.8, None, None); + times.push(start.elapsed().as_secs_f64() * 1e3); + } + black_box(map.get(0, 0)); + let first = times.first().copied().unwrap_or(0.0); + let mut rest: Vec = times.into_iter().skip(1).collect(); + rest.sort_by(f64::total_cmp); + (first, rest.get(rest.len() / 2).copied().unwrap_or(0.0)) +} + fn main() { for size in [20, 50, 128, 256] { for moving in [false, true] { @@ -50,4 +77,15 @@ fn main() { } } } + let (mut firsts, mut steadies) = (Vec::new(), Vec::new()); + for _ in 0..5 { + let (first, steady) = run_large(); + firsts.push(first); + steadies.push(steady); + } + firsts.sort_by(f64::total_cmp); + steadies.sort_by(f64::total_cmp); + if let (Some(first), Some(steady)) = (firsts.get(2), steadies.get(2)) { + println!("large dab 3457x3457 first_ms={first:.2} steady_ms={steady:.2}"); + } } diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index e43da363c5..fb062934f8 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -630,7 +630,8 @@ impl Default for BrushSettings { spacing_enabled: true, opacity: 1.0, flow: 1.0, - pressure_size: true, + // Off, as the reference application leaves it after Reset Tool (#11809). + pressure_size: false, pressure_opacity: false, color: [0.0, 0.0, 0.0, 1.0], color_cmyk: None, @@ -702,6 +703,15 @@ impl BrushSettings { brush.dual_brush.size = safe_size(brush.dual_brush.size); brush.scattering.scatter.jitter = safe_scatter(brush.scattering.scatter.jitter); brush.dual_brush.scatter = safe_scatter(brush.dual_brush.scatter); + // A NaN here would paint fully opaque (`f32::min` returns the other operand) or not at all; + // it falls back to the default (the values of `BrushSettings::default` and `DualBrush::default`). Every other value, infinities included, is left to the clamps downstream. + let nan_or = |v: f32, d: f32| if v.is_nan() { d } else { v }; + brush.opacity = nan_or(brush.opacity, 1.0); + brush.flow = nan_or(brush.flow, 1.0); + brush.hardness = nan_or(brush.hardness, 1.0); + brush.spacing = nan_or(brush.spacing, 0.1); + brush.dual_brush.hardness = nan_or(brush.dual_brush.hardness, 1.0); + brush.dual_brush.spacing = nan_or(brush.dual_brush.spacing, 0.25); brush } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 8b024de5a6..268ca52b95 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1874,6 +1874,44 @@ fn large_tips_render_the_same_in_parallel_and_serially() { } } +/// The general loop (sampled tips, per-tip textures) paints the same pixels on the thread pool +/// and serially, at 8-bit, 16-bit and float depth. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_strokes_render_the_same_in_parallel_and_serially_at_every_depth() { + let tip = GrayTile::from_fn(16, 16, |x, y| if (x + y) % 3 == 0 { 1.0 } else { 0.4 }); + let mut textured = BrushSettings { size: 120.0, hardness: 0.3, ..brush() }; + textured.texture = Texture { enabled: true, pattern: Pattern::Tile(tip.clone()), each_tip: true, ..Default::default() }; + let strokes = [BrushSettings { size: 120.0, tip: TipShape::Sampled(tip), color: [0.9, 0.2, 0.1, 1.0], ..brush() }, textured]; + let full = Rect::new(0, 0, 400, 200); + let formats = [PixelFormat::RGBA8, PixelFormat::RGBA8.with_sample(SampleType::U16), PixelFormat::RGBA32F]; + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; + for fmt in formats { + for (i, b) in strokes.iter().enumerate() { + let render = |parallel_from: usize| -> Vec { + crate::render::set_parallel_min_pixels(parallel_from); + let mut s = Surface::new(fmt); + s.fill_rect(full, &[0.5, 0.5, 0.5, 1.0]); + render_stroke(&mut s, b, &line(80.0, 320.0, 100.0), None, false, 1.0); + s.read_region(full) + }; + let parallel = render(0); + let serial = render(usize::MAX); + assert_eq!(parallel.len(), serial.len()); + let diff = parallel.iter().zip(&serial).filter(|(a, b)| a.to_bits() != b.to_bits()).count(); + assert_eq!(diff, 0, "stroke {i} at {fmt:?}: {diff} pixels differ between the parallel and the serial render"); + // Colour channels only: the opaque background's alpha (1.0) is always 0.5 away from 0.5. + assert!(parallel.as_chunks::<4>().0.iter().any(|px| px[..3].iter().any(|v| (*v - 0.5).abs() > 0.1)), "stroke {i} at {fmt:?} painted something"); + } + } +} + /// A CMYK document with its own profile: blend modes other than Normal convert each pixel through /// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the /// serial path gives (the workers enter the caller's CMYK space too). diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 02d54c18ff..b3411b7a0b 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -114,6 +114,10 @@ impl Smoother { /// Feed one input point; returns the brush positions to paint through. pub fn push(&mut self, p: StrokePoint, out: &mut Vec) { + // A point without a position is dropped: kept, it would make every later smoothed position NaN. + if !(p.x.is_finite() && p.y.is_finite()) { + return; + } let prev_input = self.last_input.replace(p); if !self.active() { out.push(p); @@ -550,6 +554,13 @@ pub fn base_diameter(b: &BrushSettings, s: &StepInput) -> f32 { b.size * f } +/// Distance to the next step at `p`: the diameter there times the spacing. +fn step_len_for(brush: &BrushSettings, p: &StrokePoint) -> f64 { + let s = StepInput { point: *p, direction: 0.0, initial_direction: 0.0, step: 0 }; + let d = base_diameter(brush, &s).max(1.0); + f64::from(d * brush.spacing.max(0.01)).max(0.5) +} + /// Builds the primary dabs of a step (Shape Dynamics, Scattering, Transfer, Color, Texture depth). #[derive(Clone, Debug)] pub struct DabBuilder { @@ -566,9 +577,7 @@ impl DabBuilder { } pub fn step_len(&self, p: &StrokePoint) -> f64 { - let s = StepInput { point: *p, direction: 0.0, initial_direction: 0.0, step: 0 }; - let d = base_diameter(&self.brush, &s).max(1.0); - f64::from(d * self.brush.spacing.max(0.01)).max(0.5) + step_len_for(&self.brush, p) } pub fn build(&mut self, s: &StepInput, out: &mut Vec) { @@ -768,6 +777,9 @@ pub struct DabGenerator { builder: DabBuilder, dual: Option, scratch: Vec, + /// The brush the step length is read from, apart from the builder so the walker can read it + /// while its callback has the builder. + step_brush: std::sync::Arc, } impl DabGenerator { @@ -798,26 +810,21 @@ impl DabGenerator { builder: DabBuilder::new(&brush), dual: dual_on.then(|| DualBuilder::new(&brush)), scratch: Vec::new(), + step_brush: std::sync::Arc::new(brush), } } fn walk(&mut self, pts: &[StrokePoint], dabs: &mut Vec, dual: &mut Vec) { for p in pts { let p = apply_pose(&self.builder.brush, *p); - let b = &self.builder; - let len = |q: &StrokePoint| b.step_len(q); - let mut steps = Vec::new(); - self.walker.push(p, &len, &mut |s| steps.push(s)); - for s in &steps { - self.builder.build(s, dabs); - } + // Each step becomes its dabs as the walker emits it: a long segment never holds its + // step list (the pixel-grid mode can emit 1e7 steps for one). + let brush = &*self.step_brush; + let builder = &mut self.builder; + self.walker.push(p, &|q| step_len_for(brush, q), &mut |s| builder.build(&s, dabs)); if let (Some(w), Some(db)) = (self.dual_walker.as_mut(), self.dual.as_mut()) { - let mut steps = Vec::new(); - let len = |q: &StrokePoint| db.step_len(q); - w.push(p, &len, &mut |s| steps.push(s)); - for s in &steps { - db.build(s, dual); - } + let step = db.step_len(&p); + w.push(p, &|_| step, &mut |s| db.build(&s, dual)); } } } @@ -838,17 +845,9 @@ impl DabGenerator { let mut sm = Vec::new(); self.smoother.finish(&mut sm); self.walk(&sm, dabs, dual); - let mut steps = Vec::new(); - self.walker.finish(&mut |s| steps.push(s)); - for s in &steps { - self.builder.build(s, dabs); - } + self.walker.finish(&mut |s| self.builder.build(&s, dabs)); if let (Some(w), Some(db)) = (self.dual_walker.as_mut(), self.dual.as_mut()) { - let mut steps = Vec::new(); - w.finish(&mut |s| steps.push(s)); - for s in &steps { - db.build(s, dual); - } + w.finish(&mut |s| db.build(&s, dual)); } } } @@ -887,3 +886,163 @@ mod walker_budget_tests { assert_eq!(steps.get(), before + 1); } } + +#[cfg(test)] +mod streaming_tests { + use super::*; + + /// The step-by-step walk before the steps were streamed: every step of a point is collected + /// first, then built. The streamed walk must produce the same dabs. + fn buffered(brush: &BrushSettings, pts: &[StrokePoint]) -> (Vec, Vec) { + let brush = brush.bounded_for_render(); + let mut smoother = Smoother::new(&brush.smoothing, 1.0); + let mut walker = DabGenerator::new(&brush, 1.0).walker; + let mut dual_walker = brush.dual_brush.enabled.then(|| PathWalker::new(None)); + let (mut builder, mut dual_builder) = (DabBuilder::new(&brush), brush.dual_brush.enabled.then(|| DualBuilder::new(&brush))); + let (mut dabs, mut dual) = (Vec::new(), Vec::new()); + let mut sm = Vec::new(); + for p in pts { + smoother.push(*p, &mut sm); + } + smoother.finish(&mut sm); + for p in sm { + let p = apply_pose(&builder.brush, p); + let mut steps = Vec::new(); + walker.push(p, &|q| builder.step_len(q), &mut |s| steps.push(s)); + for s in &steps { + builder.build(s, &mut dabs); + } + if let (Some(w), Some(db)) = (dual_walker.as_mut(), dual_builder.as_mut()) { + let mut steps = Vec::new(); + w.push(p, &|q| db.step_len(q), &mut |s| steps.push(s)); + for s in &steps { + db.build(s, &mut dual); + } + } + } + let mut steps = Vec::new(); + walker.finish(&mut |s| steps.push(s)); + for s in &steps { + builder.build(s, &mut dabs); + } + if let (Some(w), Some(db)) = (dual_walker.as_mut(), dual_builder.as_mut()) { + let mut steps = Vec::new(); + w.finish(&mut |s| steps.push(s)); + for s in &steps { + db.build(s, &mut dual); + } + } + (dabs, dual) + } + + fn streamed(brush: &BrushSettings, pts: &[StrokePoint], chunk: usize) -> (Vec, Vec) { + let mut g = DabGenerator::new(brush, 1.0); + let (mut dabs, mut dual) = (Vec::new(), Vec::new()); + for c in pts.chunks(chunk) { + g.push(c, &mut dabs, &mut dual); + } + g.finish(&mut dabs, &mut dual); + (dabs, dual) + } + + fn path() -> Vec { + let mut pts = Vec::new(); + for i in 0..40 { + let t = f64::from(i); + let mut p = StrokePoint::new(10.0 + t * 9.3, 50.0 + (t * 0.7).sin() * 30.0, 0.3 + 0.7 * ((t * 0.31).sin().abs() as f32)); + p.time = t * 7.0; + pts.push(p); + } + pts + } + + #[test] + fn streamed_dabs_equal_the_buffered_walk() { + let mut variants: Vec = vec![BrushSettings::default()]; + variants.push(BrushSettings { size: 14.0, spacing: 0.05, ..BrushSettings::default() }); + variants.push(BrushSettings { size: 1.0, aliased: true, ..BrushSettings::default() }); + variants.push(BrushSettings { size: 9.0, spacing_enabled: false, ..BrushSettings::default() }); + variants.push(BrushSettings { size: 12.0, build_up: true, build_up_rate: 40.0, ..BrushSettings::default() }); + let mut rich = BrushSettings { size: 18.0, spacing: 0.2, ..BrushSettings::default() }; + rich.scattering.enabled = true; + rich.scattering.scatter.jitter = 0.5; + rich.scattering.count = 3; + rich.dual_brush.enabled = true; + rich.dual_brush.size = 8.0; + rich.dual_brush.count = 2; + rich.shape_dynamics.enabled = true; + rich.shape_dynamics.size.jitter = 0.6; + rich.smoothing.amount = 0.5; + variants.push(rich); + for (n, b) in variants.iter().enumerate() { + let (want, want_dual) = buffered(b, &path()); + assert!(want.len() > 10, "variant {n}: {} dabs", want.len()); + for chunk in [1, 7, 1000] { + let (got, got_dual) = streamed(b, &path(), chunk); + assert_eq!(got, want, "variant {n}, chunk {chunk}"); + assert_eq!(got_dual, want_dual, "variant {n}, chunk {chunk} (dual)"); + } + } + } + + #[test] + fn a_nan_opacity_flow_hardness_or_spacing_paints_as_the_default() { + let pts = path(); + let base = BrushSettings { size: 10.0, ..BrushSettings::default() }; + let (want, _) = streamed(&base, &pts, 1000); + for broken in [ + BrushSettings { opacity: f32::NAN, ..base.clone() }, + BrushSettings { flow: f32::NAN, ..base.clone() }, + BrushSettings { hardness: f32::NAN, ..base.clone() }, + BrushSettings { spacing: f32::NAN, ..base.clone() }, + ] { + // The default brush has opacity, flow and hardness 1 and spacing 0.1, as `base` does. + let (got, _) = streamed(&broken, &pts, 1000); + assert_eq!(got, want); + let b = broken.bounded_for_render(); + assert!(b.opacity.is_finite() && b.flow.is_finite() && b.hardness.is_finite() && b.spacing.is_finite()); + } + // Finite values are not touched, even out of range. + let odd = BrushSettings { opacity: 3.0, flow: f32::NEG_INFINITY, spacing: f32::INFINITY, ..base }; + let b = odd.bounded_for_render(); + assert_eq!((b.opacity, b.flow, b.spacing), (3.0, f32::NEG_INFINITY, f32::INFINITY)); + } + + #[test] + fn a_nan_dual_hardness_or_spacing_paints_as_the_default() { + let pts = path(); + let mut explicit = BrushSettings { size: 10.0, ..BrushSettings::default() }; + explicit.dual_brush.enabled = true; + explicit.dual_brush.size = 6.0; + explicit.dual_brush.hardness = 1.0; + explicit.dual_brush.spacing = 0.25; + let (_, want_dual) = streamed(&explicit, &pts, 1000); + assert!(!want_dual.is_empty()); + let mut broken = explicit.clone(); + broken.dual_brush.spacing = f32::NAN; + assert_eq!(streamed(&broken, &pts, 1000).1, want_dual); + broken.dual_brush.hardness = f32::NAN; + let b = broken.bounded_for_render(); + assert_eq!((b.dual_brush.hardness, b.dual_brush.spacing), (1.0, 0.25)); + } + + #[test] + fn a_point_without_a_position_does_not_poison_a_smoothed_stroke() { + let mut brush = BrushSettings { size: 8.0, ..BrushSettings::default() }; + brush.smoothing.amount = 0.6; + let clean = path(); + let mut dirty = clean.clone(); + dirty.insert(10, StrokePoint::new(f64::NAN, 40.0, 1.0)); + dirty.insert(20, StrokePoint::new(30.0, f64::INFINITY, 1.0)); + assert_eq!(streamed(&brush, &dirty, 1000).0, streamed(&brush, &clean, 1000).0); + brush.smoothing.amount = 0.0; + assert_eq!(streamed(&brush, &dirty, 1000).0, streamed(&brush, &clean, 1000).0); + } + + #[test] + fn a_long_pixel_grid_segment_streams() { + let brush = BrushSettings { size: 1.0, aliased: true, spacing: 1.0, ..BrushSettings::default() }; + let (dabs, _) = streamed(&brush, &[StrokePoint::new(0.0, 0.0, 1.0), StrokePoint::new(2.0e6, 3.0, 1.0)], 1000); + assert_eq!(dabs.len(), 2_000_001); + } +} diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 214a28177d..7054e11492 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -22,8 +22,10 @@ pub mod bg_erase; pub mod brush; pub mod curve; pub mod dynamics; +mod mip_cache; pub mod mixer; pub mod outline; +mod pattern_cache; pub mod presets; pub mod procedural; pub mod render; @@ -38,6 +40,7 @@ pub use brush::{ Scattering, SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, }; pub use curve::ResponseCurve; +pub use mip_cache::{mip_cache_bytes, purge_mip_cache}; pub use mixer::MixerSettings; pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke}; pub use tile::{GrayTile, StoredTile}; diff --git a/crates/paint/src/mip_cache.rs b/crates/paint/src/mip_cache.rs new file mode 100644 index 0000000000..32c99ce87c --- /dev/null +++ b/crates/paint/src/mip_cache.rs @@ -0,0 +1,236 @@ +//! Prepared sampled tips shared across strokes and symmetry passes. Identity follows the +//! immutable sample allocation, not preset names or brush settings: copy-on-write edits and +//! loading a stored tip's full bitmap must prepare fresh mips. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak}; + +use crate::tile::{GrayTile, Mips}; + +// One maximum-size 5000² tip and its float pyramid fit (~175 MiB). Count both the mip +// buffers and source storage: even a weak Arc key reserves its allocation until eviction. +// Active strokes may keep using evicted pyramids through their own Arcs. +const MAX_BYTES: usize = 256 * 1024 * 1024; +const MAX_ENTRIES: usize = 64; +static CACHE: OnceLock> = OnceLock::new(); + +struct Entry { + width: u32, + height: u32, + // A weak key detects dropped sources and prevents allocation reuse for a different tip. + // Arc::make_mut dissociates weak pointers on an in-place edit. + samples: Weak<[u16]>, + mips: Arc, + bytes: usize, +} + +struct MipCache { + entries: VecDeque, + bytes: usize, + max_bytes: usize, + max_entries: usize, +} + +impl MipCache { + fn new(max_bytes: usize, max_entries: usize) -> Self { + Self { entries: VecDeque::new(), bytes: 0, max_bytes, max_entries } + } + + fn get(&mut self, tip: &GrayTile) -> Option> { + let samples = Arc::downgrade(&tip.data); + let index = self.entries.iter().position(|e| e.width == tip.width && e.height == tip.height && e.samples.ptr_eq(&samples))?; + let entry = self.entries.remove(index)?; + let mips = Arc::clone(&entry.mips); + self.entries.push_back(entry); + Some(mips) + } + + fn insert(&mut self, tip: &GrayTile, mips: Arc) -> Arc { + // Another caller may have finished preparing the same tip while this one built it. + if let Some(existing) = self.get(tip) { + return existing; + } + self.entries.retain(|e| e.samples.strong_count() > 0); + self.bytes = self.entries.iter().fold(0usize, |n, e| n.saturating_add(e.bytes)); + let source_bytes = tip.data.len().saturating_mul(size_of::()); + let bytes = mips.levels.iter().fold(source_bytes, |n, (_, _, values)| n.saturating_add(values.capacity().saturating_mul(size_of::()))); + // An oversized tip still renders normally, without evicting useful cached tips or + // extending the retention budget. Invalid tips retain Mips::new's ordinary fallback. + if bytes > self.max_bytes || self.max_entries == 0 || !tip.is_valid() { + return mips; + } + while self.bytes > self.max_bytes - bytes || self.entries.len() >= self.max_entries { + let Some(old) = self.entries.pop_front() else { break }; + self.bytes = self.bytes.saturating_sub(old.bytes); + } + self.bytes = self.bytes.saturating_add(bytes); + self.entries.push_back(Entry { width: tip.width, height: tip.height, samples: Arc::downgrade(&tip.data), mips: Arc::clone(&mips), bytes }); + mips + } + + fn purge(&mut self) -> usize { + self.entries.clear(); + std::mem::take(&mut self.bytes) + } +} + +fn cached(cache: &Mutex, tip: &GrayTile) -> Arc { + if let Some(mips) = cache.lock().unwrap_or_else(PoisonError::into_inner).get(tip) { + return mips; + } + // Preparing a large pyramid must not hold the global lock: hits for other brushes and + // unrelated documents can proceed. Concurrent misses may build twice, but publish one Arc. + let mips = Arc::new(Mips::new(tip)); + cache.lock().unwrap_or_else(PoisonError::into_inner).insert(tip, mips) +} + +pub(crate) fn tip_mips(tip: &GrayTile) -> Arc { + cached(CACHE.get_or_init(|| Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES))), tip) +} + +/// Sample and mip buffer bytes retained by the prepared-tip cache, including weak-key storage. +pub fn mip_cache_bytes() -> usize { + CACHE.get().map_or(0, |cache| cache.lock().unwrap_or_else(PoisonError::into_inner).bytes) +} + +/// Release prepared-tip cache ownership and return its accounted buffer bytes. Active strokes +/// keep their shared pyramids and continue painting unchanged; the next stroke can prepare anew. +pub fn purge_mip_cache() -> usize { + CACHE.get().map_or(0, |cache| cache.lock().unwrap_or_else(PoisonError::into_inner).purge()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tip(w: u32, h: u32) -> GrayTile { + GrayTile::from_fn(w, h, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0) + } + + #[test] + fn a_repeated_tip_reuses_every_level_after_the_first_stroke_drops() { + let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)); + let tip = tip(31, 17); + let first = cached(&cache, &tip); + let weak = Arc::downgrade(&first); + assert_eq!(first.levels, Mips::new(&tip).levels); + drop(first); + let next = cached(&cache, &tip.clone()); + assert!(Arc::ptr_eq(&weak.upgrade().unwrap(), &next)); + assert_eq!(cache.lock().unwrap().entries.len(), 1); + } + + #[test] + fn dimensions_and_cow_sample_edits_invalidate_cached_mips() { + let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)); + let mut original = tip(8, 4); + let first = cached(&cache, &original); + let reshaped = GrayTile { width: 4, height: 8, data: Arc::clone(&original.data) }; + let changed_shape = cached(&cache, &reshaped); + assert!(!Arc::ptr_eq(&first, &changed_shape)); + assert_eq!(changed_shape.levels, Mips::new(&reshaped).levels); + let saved = original.clone(); + Arc::make_mut(&mut original.data)[0] = 65535; + let changed_samples = cached(&cache, &original); + assert!(!Arc::ptr_eq(&first, &changed_samples)); + assert_eq!(changed_samples.levels, Mips::new(&original).levels); + assert!(Arc::ptr_eq(&first, &cached(&cache, &saved))); + // With no other strong source owner, make_mut must also invalidate a weak cache key. + let mut sole_owner = tip(5, 3); + let old = cached(&cache, &sole_owner); + Arc::make_mut(&mut sole_owner.data)[0] = 65535; + assert!(!Arc::ptr_eq(&old, &cached(&cache, &sole_owner))); + } + + #[test] + fn retention_is_lru_bounded_by_both_bytes_and_entry_count() { + let (a, b, c) = (tip(4, 4), tip(4, 4), tip(4, 4)); + let bytes = 16 * size_of::() + (16 + 4 + 1) * size_of::(); + let cache = Mutex::new(MipCache::new(2 * bytes, 64)); + let ma = cached(&cache, &a); + let mb = cached(&cache, &b); + assert!(Arc::ptr_eq(&ma, &cached(&cache, &a))); + cached(&cache, &c); + assert!(cache.lock().unwrap().get(&b).is_none()); + assert!(Arc::ptr_eq(&ma, &cached(&cache, &a))); + assert_eq!(cache.lock().unwrap().bytes, 2 * bytes); + assert_eq!(mb.levels, Mips::new(&b).levels, "eviction leaves active strokes valid"); + let cache = Mutex::new(MipCache::new(MAX_BYTES, 1)); + cached(&cache, &a); + cached(&cache, &b); + assert_eq!(cache.lock().unwrap().entries.len(), 1); + assert!(cache.lock().unwrap().get(&a).is_none()); + } + + #[test] + fn dead_sources_are_pruned_and_oversized_or_invalid_tips_bypass_the_cache() { + let cache = Mutex::new(MipCache::new(116, 8)); + let small = tip(4, 4); + let weak = Arc::downgrade(&small.data); + cached(&cache, &small); + drop(small); + assert!(weak.upgrade().is_none()); + let live = tip(4, 4); + let kept = cached(&cache, &live); + assert_eq!(cache.lock().unwrap().entries.len(), 1); + let large = tip(8, 8); + let uncached = cached(&cache, &large); + assert!(!Arc::ptr_eq(&uncached, &cached(&cache, &large))); + assert!(Arc::ptr_eq(&kept, &cached(&cache, &live))); + assert_eq!(cache.lock().unwrap().bytes, 116); + let invalid = GrayTile { width: u32::MAX, height: u32::MAX, data: vec![1].into() }; + assert_eq!(cached(&cache, &invalid).levels, Mips::new(&invalid).levels); + assert_eq!(cache.lock().unwrap().entries.len(), 1); + } + + #[test] + fn purging_releases_retention_without_invalidating_an_active_stroke() { + let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)); + let tip = tip(8, 4); + let active = cached(&cache, &tip); + assert!(cache.lock().unwrap().purge() > 0); + assert_eq!(cache.lock().unwrap().bytes, 0); + assert!(cache.lock().unwrap().entries.is_empty()); + let next = cached(&cache, &tip); + assert!(!Arc::ptr_eq(&active, &next)); + assert_eq!(active.levels, next.levels); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_poisoned_cache_remains_usable() { + let cache = Arc::new(Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES))); + let other = Arc::clone(&cache); + assert!( + std::thread::spawn(move || { + let _guard = other.lock().unwrap(); + panic!("test cache poison recovery"); + }) + .join() + .is_err() + ); + let tip = tip(4, 4); + let first = cached(&cache, &tip); + assert!(Arc::ptr_eq(&first, &cached(&cache, &tip))); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn simultaneous_callers_publish_and_reuse_one_pyramid() { + let cache = Arc::new(Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES))); + let barrier = Arc::new(std::sync::Barrier::new(4)); + let tip = tip(257, 129); + let workers: Vec<_> = (0..4) + .map(|_| { + let (cache, barrier, tip) = (Arc::clone(&cache), Arc::clone(&barrier), tip.clone()); + std::thread::spawn(move || { + barrier.wait(); + cached(&cache, &tip) + }) + }) + .collect(); + let results: Vec<_> = workers.into_iter().map(|w| w.join().unwrap()).collect(); + assert!(results.iter().all(|m| Arc::ptr_eq(m, &results[0]))); + assert_eq!(cache.lock().unwrap().entries.len(), 1); + } +} diff --git a/crates/paint/src/mip_cache_render_tests.rs b/crates/paint/src/mip_cache_render_tests.rs new file mode 100644 index 0000000000..69b61782d5 --- /dev/null +++ b/crates/paint/src/mip_cache_render_tests.rs @@ -0,0 +1,107 @@ +//! Exercise the production cache through brush contexts and native-depth stroke rendering. +use super::*; +use crate::brush::{DualBrush, Smoothing}; +use crate::tile::{GrayTile, StoredTile}; +use photocraft_geom::Point; + +#[test] +fn contexts_share_primary_and_dual_mips_across_settings_and_symmetry_passes() { + let tip = GrayTile::from_fn(31, 17, |x, y| ((x + y) % 5) as f32 / 4.0); + let brush = BrushSettings { + tip: TipShape::Sampled(tip.clone()), + dual_brush: DualBrush { enabled: true, tip: TipShape::Sampled(tip), ..Default::default() }, + ..Default::default() + }; + let ctx = BrushContext::new(&brush); + assert!(Arc::ptr_eq(ctx.tip.as_ref().unwrap(), ctx.dual_tip.as_ref().unwrap())); + // Symmetry constructs a renderer per pass; later strokes can change size, angle and flow. + for pass in 0..4 { + let changed = BrushSettings { size: 10.0 + pass as f32 * 11.0, angle: pass as f32 * 0.3, flow: 0.2, ..brush.clone() }; + let renderer = StrokeRenderer::new(&changed, Some(PixelFormat::RGBA8), 1.0); + assert!(Arc::ptr_eq(ctx.tip.as_ref().unwrap(), renderer.ctx.tip.as_ref().unwrap())); + assert!(Arc::ptr_eq(ctx.dual_tip.as_ref().unwrap(), renderer.ctx.dual_tip.as_ref().unwrap())); + } + assert!(BrushContext::new(&BrushSettings::default()).tip.is_none()); +} + +#[test] +fn loading_or_replacing_a_stored_tip_never_reuses_its_preview_mips() { + let preview = GrayTile::from_fn(8, 8, |_, _| 0.1); + let stored = StoredTile { key: "unchanged-key".into(), width: 32, height: 32, preview: Arc::new(preview), full: None }; + let mut brush = BrushSettings { tip: TipShape::Stored(stored.clone()), ..Default::default() }; + let before = BrushContext::new(&brush); + let full = GrayTile::from_fn(32, 32, |_, _| 0.9); + brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(full.clone())), ..stored.clone() }); + let loaded = BrushContext::new(&brush); + assert!(!Arc::ptr_eq(before.tip.as_ref().unwrap(), loaded.tip.as_ref().unwrap())); + assert_eq!(loaded.tip.as_ref().unwrap().levels, Mips::new(&full).levels); + let replacement = GrayTile::from_fn(32, 32, |_, _| 0.3); + brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(replacement.clone())), ..stored }); + let replaced = BrushContext::new(&brush); + assert!(!Arc::ptr_eq(loaded.tip.as_ref().unwrap(), replaced.tip.as_ref().unwrap())); + assert_eq!(replaced.tip.as_ref().unwrap().levels, Mips::new(&replacement).levels); +} + +#[test] +fn cached_and_uncached_strokes_match_exactly_at_native_depths_and_modes() { + let tip = GrayTile::from_fn(31, 17, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0); + let brush = BrushSettings { + tip: TipShape::Sampled(tip.clone()), + dual_brush: DualBrush { enabled: true, size: 19.0, tip: TipShape::Sampled(tip.clone()), ..Default::default() }, + size: 27.0, + angle: 0.4, + roundness: 0.7, + flow: 0.5, + opacity: 0.7, + wet_edges: true, + noise: true, + smoothing: Smoothing { amount: 0.49, ..Default::default() }, + seed: 42, + ..Default::default() + }; + let bounds = Rect::new(-32, -32, 96, 96); + let selection = Surface::with_default(PixelFormat::GRAY8, &[0.6]); + for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::CMYKA8] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = fmt.with_sample(sample); + let mut pre = Surface::new(fmt); + pre.fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, [0.3, 0.6, 0.8, 0.7])); + let render = |uncached: bool| { + let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); + if uncached { + r.ctx.tip = Some(Arc::new(Mips::new(&tip))); + r.ctx.dual_tip = Some(Arc::new(Mips::new(&tip))); + } + let mut target = pre.clone(); + for i in 0..8 { + r.push(&[StrokePoint::new(-3.0 + f64::from(i) * 8.0, 24.0 + f64::from(i % 3) * 4.0, 0.4 + i as f32 * 0.07)]); + r.composite(&pre, &mut target, Some(&selection), false, false); + if let Some(mut tail) = r.tail_preview() { + tail.composite(&pre, &mut target, Some(&selection), false, false); + } + } + r.finish(); + r.composite(&pre, &mut target, Some(&selection), false, true); + (r.bounds(), target.read_region(bounds)) + }; + let cold = render(false); + let uncached = render(true); + let warm = render(false); + assert_eq!(uncached.0, cold.0); + assert_eq!(cold.0, warm.0); + assert!(uncached.1.iter().zip(&cold.1).all(|(a, b)| a.to_bits() == b.to_bits()), "cold {fmt:?}"); + assert!(cold.1.iter().zip(&warm.1).all(|(a, b)| a.to_bits() == b.to_bits()), "warm {fmt:?}"); + assert_ne!(cold.1, pre.read_region(bounds), "the regression must paint pixels"); + } + } + // A public-context caller still gets the identical sampled coverage at sub-pixel centres. + let ctx = BrushContext::new(&brush); + let dab = Dab::round(Point::new(31.25, 12.75), 9.5, 0.8); + let mut cached = Vec::new(); + ctx.rasterize(&dab, false, ctx.dab_rect(&dab, false), &mut cached); + let mut reference = ctx.clone(); + reference.tip = Some(Arc::new(Mips::new(&tip))); + let mut uncached = Vec::new(); + reference.rasterize(&dab, false, reference.dab_rect(&dab, false), &mut uncached); + assert!(cached.iter().zip(&uncached).all(|(a, b)| a.to_bits() == b.to_bits())); +} diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index a9a7485fa5..bb34749065 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -190,12 +190,14 @@ pub fn apply_mixer_dab( state: &mut MixerState, lock_transparency: bool, ) { + // Only the dab's own rectangle is ever read from the sample, not the whole stroke rectangle. let sample = sample.filter(|_| m.samples_canvas(&fp.dab)).map(|surface| { - let source = crate::retouch::Region::read(surface, work.rect); + let rect = fp.rect.intersect(&work.rect); + let source = crate::retouch::Region::read(surface, rect); if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha { source } else { - let mut native = crate::retouch::Region::new(work.rect, fmt.channels()); + let mut native = crate::retouch::Region::new(rect, fmt.channels()); for (src, dst) in source.data.chunks_exact(source.ch).zip(native.data.chunks_exact_mut(native.ch)) { from_rgba_into(&fmt, to_rgba(&surface.format(), src), dst); } @@ -205,9 +207,10 @@ pub fn apply_mixer_dab( apply_mixer_dab_region(work, fp, fmt, sample.as_ref(), m, state, lock_transparency); } -/// Contiguous native-channel sample variant. `sample` must have the same rectangle and -/// channel layout as `work`. The live compositor supplies it directly, avoiding a tiled -/// temporary surface and a heap allocation for every sampled/deposited pixel. +/// Contiguous native-channel sample variant. `sample` must have the channel layout of `work` and +/// cover at least `fp.rect` clipped to `work.rect` (the only pixels read). The live compositor +/// supplies it directly, avoiding a tiled temporary surface and a heap allocation for every +/// sampled/deposited pixel. pub fn apply_mixer_dab_region( work: &mut crate::retouch::Region, fp: &crate::retouch::Footprint, diff --git a/crates/paint/src/mixer/reference_tests.rs b/crates/paint/src/mixer/reference_tests.rs index 0f6f0a2a82..2071f60f01 100644 --- a/crates/paint/src/mixer/reference_tests.rs +++ b/crates/paint/src/mixer/reference_tests.rs @@ -205,3 +205,41 @@ fn dry_and_wet_transitions_match_frozen_kernel() { } } } + +/// A sample surface is read only under the dab: dabs that overhang the working rectangle or sit in +/// a corner of a much larger one still match the frozen kernel. +#[test] +fn sampled_dabs_clipped_by_the_working_rectangle_match_frozen_kernel() { + use crate::retouch::Region; + use crate::{BrushContext, BrushSettings, Dab}; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_geom::Point; + + let work_rect = Rect::new(10, 10, 60, 50); + let fmt = PixelFormat::new(photocraft_color::ColorMode::Cmyk, SampleType::U16, true); + let mut surface = Surface::new(fmt); + surface.fill_rect(Rect::new(0, 0, 80, 80), &from_rgba(&fmt, [0.25, 0.5, 0.75, 0.9])); + let mut source = Surface::new(PixelFormat::RGBA32F); + for y in 0..80 { + for x in 0..80 { + source.fill_rect(Rect::new(x, y, x + 1, y + 1), &[x as f32 / 80.0, y as f32 / 80.0, 0.3, 0.8]); + } + } + let ctx = BrushContext::new(&BrushSettings { size: 30.0, ..Default::default() }); + for sample in [&source, &surface] { + let mut actual = Region::read(&surface, work_rect); + let mut expected = actual.clone(); + let mut state = MixerState::default(); + state.load([0.8, 0.2, 0.1, 1.0]); + let mut expected_state = state.clone(); + // Overhanging the left edge, a corner, and the middle. + for (i, (x, y)) in [(8.0, 30.0), (62.0, 52.0), (30.0, 12.0), (35.0, 30.0)].into_iter().enumerate() { + let fp = ctx.footprint(&Dab::round(Point::new(x, y), 12.0, 1.0), i); + let settings = MixerSettings { wet: 0.8, mix: 0.7, ..Default::default() }; + reference(&mut expected, &fp, fmt, Some(sample), &settings, &mut expected_state, false); + apply_mixer_dab(&mut actual, &fp, fmt, Some(sample), &settings, &mut state, false); + assert_eq!(actual, expected, "dab {i}"); + assert_eq!(state, expected_state, "state after dab {i}"); + } + } +} diff --git a/crates/paint/src/pattern_cache.rs b/crates/paint/src/pattern_cache.rs new file mode 100644 index 0000000000..421367a365 --- /dev/null +++ b/crates/paint/src/pattern_cache.rs @@ -0,0 +1,161 @@ +//! A small process-wide cache for procedural brush textures. +//! +//! A stroke (and every symmetry mirror of it) used to regenerate its procedural texture in +//! [`crate::BrushContext::new`], although the image is a pure function of the style, size, seed and +//! level adjustments. The cache is bounded in entries and bytes and evicts the least recently used +//! entry first. Sampled-tip mip pyramids are not handled here (see PR #9617). +//! +//! The key holds every input of the generator plus the level adjustments (brightness, contrast, +//! invert) that are baked into the cached image, so a hit is always the image a rebuild would give. + +use std::sync::{Arc, Mutex, PoisonError}; + +use crate::brush::{PatternStyle, Texture}; +use crate::tile::PatternImage; + +const PATTERN_MAX_ENTRIES: usize = 4; +const PATTERN_MAX_BYTES: usize = 16 << 20; + +#[derive(PartialEq)] +struct PatternKey { + style: PatternStyle, + size: u32, + seed: u32, + brightness: u32, + contrast: u32, + invert: bool, +} + +struct PatternEntry { + key: PatternKey, + image: Arc, +} + +/// The entries, least recently used first. +struct PatternCache(Vec); + +impl PatternCache { + const fn new() -> Self { + Self(Vec::new()) + } + + /// The image cached for `key`, which is then the most recently used entry. + fn get(&mut self, key: &PatternKey) -> Option> { + let i = self.0.iter().position(|e| e.key == *key)?; + let e = self.0.remove(i); + let image = Arc::clone(&e.image); + self.0.push(e); + Some(image) + } + + /// Caches `image` for `key` (unless it is too big) and evicts the oldest entries past the limits. + fn insert(&mut self, key: PatternKey, image: &Arc) { + if image.data.len() * 4 > PATTERN_MAX_BYTES / 2 { + return; + } + // Another thread may have built the same key meanwhile: keep its image, but it was just asked for. + match self.0.iter().position(|e| e.key == key) { + Some(i) => { + let e = self.0.remove(i); + self.0.push(e); + } + None => self.0.push(PatternEntry { key, image: Arc::clone(image) }), + } + let bytes = |e: &PatternEntry| e.image.data.len() * 4; + while self.0.len() > PATTERN_MAX_ENTRIES || self.0.iter().map(bytes).sum::() > PATTERN_MAX_BYTES { + self.0.remove(0); + } + } +} + +static PATTERNS: Mutex = Mutex::new(PatternCache::new()); + +/// The key of `(style, size, seed)` with `t`'s levels. Brightness and contrast are keyed on the values +/// [`Texture::level`] uses (clamped, non-finite as 0, `-0.0` as `0.0`), so equal levels share an entry. +fn pattern_key(style: PatternStyle, size: u32, seed: u32, t: &Texture) -> PatternKey { + let level_bits = |v: f32| (if v.is_finite() { v.clamp(-1.0, 1.0) + 0.0 } else { 0.0 }).to_bits(); + PatternKey { style, size: size.clamp(8, 1024), seed, brightness: level_bits(t.brightness), contrast: level_bits(t.contrast), invert: t.invert } +} + +/// The procedural pattern `(style, size, seed)` with `t`'s levels applied, from the cache when it +/// was built recently. +pub fn procedural_pattern(style: PatternStyle, size: u32, seed: u32, t: &Texture) -> Arc { + let key = pattern_key(style, size, seed, t); + if let Some(image) = PATTERNS.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { + return image; + } + let mut img = crate::procedural::pattern(style, size, seed); + for v in &mut img.data { + *v = t.level(*v); + } + let image = Arc::new(img); + PATTERNS.lock().unwrap_or_else(PoisonError::into_inner).insert(key, &image); + image +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_cache_keys_on_every_input() { + let base = Texture::default(); + let a = procedural_pattern(PatternStyle::Noise, 64, 3, &base); + assert!(Arc::ptr_eq(&a, &procedural_pattern(PatternStyle::Noise, 64, 3, &base))); + let mut fresh = crate::procedural::pattern(PatternStyle::Noise, 64, 3); + fresh.data.iter_mut().for_each(|v| *v = base.level(*v)); + assert_eq!(a.data, fresh.data); + let variants = [ + (PatternStyle::Canvas, 64, 3, base.clone()), + (PatternStyle::Noise, 32, 3, base.clone()), + (PatternStyle::Noise, 64, 4, base.clone()), + (PatternStyle::Noise, 64, 3, Texture { invert: true, ..base.clone() }), + (PatternStyle::Noise, 64, 3, Texture { brightness: 0.5, ..base.clone() }), + (PatternStyle::Noise, 64, 3, Texture { contrast: 0.5, ..base.clone() }), + ]; + for (style, size, seed, texture) in variants { + // Look the base up again so it is the newest entry and still cached when the variant is + // asked for; a key that ignored the changed input would then hand back the base image. + procedural_pattern(PatternStyle::Noise, 64, 3, &base); + let other = procedural_pattern(style, size, seed, &texture); + assert!(!Arc::ptr_eq(&a, &other) && other.data != a.data, "{style:?} {size} {seed}"); + } + } + + /// The cache holds a few entries: the oldest one goes first. Runs on a cache this test owns, so + /// no other test can evict or refresh an entry. + #[test] + fn pattern_cache_evicts_the_oldest_entry_past_its_limit() { + let base = Texture::default(); + let mut cache = PatternCache::new(); + let key = |seed| pattern_key(PatternStyle::Noise, 16, seed, &base); + let image = |seed| Arc::new(crate::procedural::pattern(PatternStyle::Noise, 16, seed)); + let first = image(0); + cache.insert(key(0), &first); + assert!(Arc::ptr_eq(&first, &cache.get(&key(0)).unwrap())); + for seed in 1..=PATTERN_MAX_ENTRIES as u32 { + cache.insert(key(seed), &image(seed)); + } + assert!(cache.get(&key(0)).is_none()); + assert!(cache.get(&key(1)).is_some()); + } + + #[test] + fn pattern_cache_shares_an_entry_between_equal_levels() { + let tex = |brightness, contrast| Texture { brightness, contrast, ..Texture::default() }; + let pairs = [ + (tex(2.0, 0.0), tex(1.0, 0.0)), + (tex(-7.5, 0.0), tex(-1.0, 0.0)), + (tex(0.0, 3.0), tex(0.0, 1.0)), + (tex(f32::NAN, 0.0), tex(0.0, 0.0)), + (tex(0.0, f32::INFINITY), tex(0.0, 0.0)), + (tex(-0.0, -0.0), tex(0.0, 0.0)), + ]; + for (a, b) in pairs { + assert!(pattern_key(PatternStyle::Noise, 64, 5, &a) == pattern_key(PatternStyle::Noise, 64, 5, &b)); + let (ia, ib) = (procedural_pattern(PatternStyle::Noise, 64, 5, &a), procedural_pattern(PatternStyle::Noise, 64, 5, &b)); + assert_eq!(ia.data, ib.data); + } + assert!(pattern_key(PatternStyle::Noise, 64, 5, &tex(0.5, 0.0)) != pattern_key(PatternStyle::Noise, 64, 5, &tex(1.0, 0.0))); + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 83243bdafa..2a93a713d5 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -48,6 +48,11 @@ pub const COV_TILE: i32 = 64; #[cfg(not(target_arch = "wasm32"))] pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256; +/// The general loop (sampled tips, noise, textures, wet edges) reaches the thread pool at a quarter +/// of the pixels: at about 9-12 ns a pixel, threads already halve a dab of 16,000 pixels or more. +#[cfg(not(target_arch = "wasm32"))] +const GENERAL_LOOP_COST: usize = 4; + /// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it). #[cfg(not(target_arch = "wasm32"))] static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS); @@ -209,12 +214,12 @@ fn prepare_pattern(b: &BrushSettings) -> Option> { if !t.enabled { return None; } - let mut img = match &t.pattern { - Pattern::Procedural { style, size, seed } => crate::procedural::pattern(*style, *size, *seed), - p => match p.bitmap() { - Some(g) if g.is_valid() => PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() }, - _ => return None, - }, + if let Pattern::Procedural { style, size, seed } = &t.pattern { + return Some(crate::pattern_cache::procedural_pattern(*style, *size, *seed, t)); + } + let mut img = match t.pattern.bitmap() { + Some(g) if g.is_valid() => PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() }, + _ => return None, }; for v in &mut img.data { *v = t.level(*v); @@ -235,7 +240,7 @@ impl BrushContext { pub fn new(brush: &BrushSettings) -> Self { let brush = brush.bounded_for_render(); let mips = |t: &TipShape| match t.bitmap() { - Some(g) if g.is_valid() => Some(Arc::new(Mips::new(g))), + Some(g) if g.is_valid() => Some(crate::mip_cache::tip_mips(g)), _ => None, }; Self { @@ -258,9 +263,7 @@ impl BrushContext { let (fx, fy) = ((x as f32 + 0.5) / s - 0.5, (y as f32 + 0.5) / s - 0.5); let (x0, y0) = (fx.floor(), fy.floor()); let (tx, ty) = (fx - x0, fy - y0); - let a = p.sample_wrap(x0, y0) + (p.sample_wrap(x0 + 1.0, y0) - p.sample_wrap(x0, y0)) * tx; - let b = p.sample_wrap(x0, y0 + 1.0) + (p.sample_wrap(x0 + 1.0, y0 + 1.0) - p.sample_wrap(x0, y0 + 1.0)) * tx; - a + (b - a) * ty + p.sample_bilinear_wrap(x0, y0, tx, ty) } /// Bounding rectangle of a dab (rotated sampled tips need the corner reach). @@ -325,16 +328,6 @@ impl BrushContext { } None => (0, 1.0, 1.0, 1.0), }; - // A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. - let to_tip = |dx: f32, dy: f32| { - let (mut ux, mut uy) = (dx, -dy); - if let Some((ax, ay, k)) = proj { - let t = (ux * ax + uy * ay) * k; - ux += t * ax; - uy += t * ay; - } - ((ux * cs + uy * sn) * fx, (-ux * sn + uy * cs) * fy) - }; // A tip a few pixels across takes each pixel's covered area (a 4×4 grid inside it): // sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel // position, and thin lines beaded. @@ -360,75 +353,59 @@ impl BrushContext { rasterize_round(d, rect, out, profile, aliased); return; } - for yy in 0..h { - let y = rect.y0 + yy as i32; - let dy = y as f32 + 0.5 - cy; - for xx in 0..w { - let x = rect.x0 + xx as i32; - let dx = x as f32 + 0.5 - cx; - let (u, v) = to_tip(dx, dy); - let (mut val, rn) = match mips { - // Block: the whole-pixel square, hard (the Eraser's Block mode). - _ if aliased && b.square => { - if dx.abs() >= r || dy.abs() >= r { - continue; - } - (1.0, 0.0) - } - None if small => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= small_reach2 { - continue; - } - let mut sum = 0.0; - for sy in SUBPIXEL { - for sx in SUBPIXEL { - let (su, sv) = to_tip(dx + sx, dy + sy); - let sd = ((su * ro).powi(2) + sv * sv).sqrt(); - // Each sample takes the tip's own edge, so the covered area - // changes smoothly with the dab's position. - sum += profile.falloff(sd); - } - } - (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) - } - None => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= reach2 { - continue; - } - let dd = d2.sqrt(); - let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; - (val, dd / rm) - } - Some(m) => { - let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); - if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { - continue; - } - let s = m.sample_clamped(level, px / tw, py / th); - let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; - (val, 1.0 - s) - } - }; - if val <= 0.0 { - continue; - } - if noise && val < 1.0 { - let n = hash2(x, y, NOISE_SALT); - let hard = if n < val { 1.0 } else { 0.0 }; - val += (hard - val) * 0.7; - } - if tex_tip { - val = texture_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); - } - if wet { - val *= wet_factor(rn); - } - if let Some(slot) = depth.as_deref_mut().and_then(|dp| dp.get_mut(yy * w + xx)) { - *slot = (1.0 - rn).clamp(0.0, 1.0); - } - out[yy * w + xx] = val * d.alpha; + if w == 0 { + return; + } + let tip = GeneralTip { + ctx: self, + d, + mips, + x0: rect.x0, + y0: rect.y0, + aliased, + small, + noise, + tex_tip, + wet, + cx, + cy, + sn, + cs, + fx, + fy, + proj, + r, + ro, + rm, + profile, + reach2, + small_reach2, + level, + inv_scale, + tw, + th, + }; + // Rows are independent (noise is a pure hash of position, everything else is read-only), + // so the pixels are the same whether they run in parallel or not. The serial path stays a + // plain loop over an inlined row (a closure called from several sites cost a small dab a + // tenth of its time). + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len().saturating_mul(GENERAL_LOOP_COST)) { + use rayon::prelude::*; + if let Some(dp) = depth.as_deref_mut() { + out.par_chunks_exact_mut(w).zip(dp.par_chunks_exact_mut(w)).enumerate().for_each(|(yy, (row, drow))| tip.row(yy, row, Some(drow))); + } else { + out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row, None)); + } + return; + } + if let Some(dp) = depth { + for (yy, (row, drow)) in out.chunks_exact_mut(w).zip(dp.chunks_exact_mut(w)).enumerate() { + tip.row(yy, row, Some(drow)); + } + } else { + for (yy, row) in out.chunks_exact_mut(w).enumerate() { + tip.row(yy, row, None); } } } @@ -559,21 +536,24 @@ impl CoverageMap { let keys: Vec<_> = keys.into_iter().zip(touched).filter_map(|(key, touched)| touched.then_some(key)).collect(); if parallel(keys.len().saturating_mul(n)) { use rayon::prelude::*; - // Only genuinely parallel work takes ownership of tiles out of the map. + // Only genuinely parallel work takes ownership of tiles out of the map. A new tile's + // zeroing is the expensive part of a large dab, so it happens on the workers. let mut tiles: Vec<_> = keys .into_iter() .map(|(key, tr)| { self.dirty.insert(key); - let tile = self.tiles.remove(&tile_key(key)).unwrap_or_else(CovTile::new); - (key, tr, tile) + (key, tr, self.tiles.remove(&tile_key(key))) }) .collect(); - tiles.par_iter_mut().for_each(|(key, tr, tile)| { + tiles.par_iter_mut().for_each(|(key, tr, slot)| { + let tile = slot.get_or_insert_with(CovTile::new); dab.prepare(tile); dab.tile(*tr, *key, tile); }); for (key, _, tile) in tiles { - self.tiles.insert(tile_key(key), tile); + if let Some(tile) = tile { + self.tiles.insert(tile_key(key), tile); + } } } else { self.accumulate_serial(keys.into_iter(), dab); @@ -1358,8 +1338,136 @@ impl RoundTip { } } +/// The general rasterizer's per-dab state, one row at a time (see [`BrushContext::rasterize_into`]). +#[derive(Clone, Copy)] +struct GeneralTip<'a> { + ctx: &'a BrushContext, + d: &'a Dab, + mips: Option<&'a Mips>, + x0: i32, + y0: i32, + aliased: bool, + small: bool, + noise: bool, + tex_tip: bool, + wet: bool, + cx: f32, + cy: f32, + sn: f32, + cs: f32, + fx: f32, + fy: f32, + proj: Option<(f32, f32, f32)>, + r: f32, + ro: f32, + rm: f32, + profile: TipProfile, + reach2: f32, + small_reach2: f32, + level: usize, + inv_scale: f32, + tw: f32, + th: f32, +} + +impl GeneralTip<'_> { + /// A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. + #[inline(always)] + fn to_tip(self, dx: f32, dy: f32) -> (f32, f32) { + let (mut ux, mut uy) = (dx, -dy); + if let Some((ax, ay, k)) = self.proj { + let t = (ux * ax + uy * ay) * k; + ux += t * ax; + uy += t * ay; + } + ((ux * self.cs + uy * self.sn) * self.fx, (-ux * self.sn + uy * self.cs) * self.fy) + } + + /// Row `yy` of the dab's rectangle into `row` (and its depth into `depth_row`). + #[inline(always)] + fn row(self, yy: usize, row: &mut [f32], mut depth_row: Option<&mut [f32]>) { + let Self { + ctx, d, mips, x0, y0, aliased, small, noise, tex_tip, wet, cx, cy, r, ro, rm, profile, reach2, small_reach2, level, inv_scale, tw, th, .. + } = self; + let b = &ctx.brush; + let y = y0 + yy as i32; + let dy = y as f32 + 0.5 - cy; + for (xx, dst) in row.iter_mut().enumerate() { + let x = x0 + xx as i32; + let dx = x as f32 + 0.5 - cx; + let (u, v) = self.to_tip(dx, dy); + let (mut val, rn) = match mips { + // Block: the whole-pixel square, hard (the Eraser's Block mode). + _ if aliased && b.square => { + if dx.abs() >= r || dy.abs() >= r { + continue; + } + (1.0, 0.0) + } + None if small => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= small_reach2 { + continue; + } + let mut sum = 0.0; + for sy in SUBPIXEL { + for sx in SUBPIXEL { + let (su, sv) = self.to_tip(dx + sx, dy + sy); + let sd = ((su * ro).powi(2) + sv * sv).sqrt(); + // Each sample takes the tip's own edge, so the covered area + // changes smoothly with the dab's position. + sum += profile.falloff(sd); + } + } + (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) + } + None => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= reach2 { + continue; + } + let dd = d2.sqrt(); + let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; + (val, dd / rm) + } + Some(m) => { + let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); + if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { + continue; + } + let s = m.sample_clamped(level, px / tw, py / th); + let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; + (val, 1.0 - s) + } + }; + if val <= 0.0 { + continue; + } + if noise && val < 1.0 { + let n = hash2(x, y, NOISE_SALT); + let hard = if n < val { 1.0 } else { 0.0 }; + val += (hard - val) * 0.7; + } + if tex_tip { + val = texture_combine(b.texture.mode, val, ctx.texture_at(x, y), d.depth); + } + if wet { + val *= wet_factor(rn); + } + if let Some(slot) = depth_row.as_deref_mut().and_then(|dp| dp.get_mut(xx)) { + *slot = (1.0 - rn).clamp(0.0, 1.0); + } + *dst = val * d.alpha; + } + } +} + #[cfg(test)] mod reference; #[cfg(test)] mod coverage_tests; + +#[cfg(test)] +#[path = "mip_cache_render_tests.rs"] +mod mip_cache_tests; diff --git a/crates/paint/src/render/coverage_tests.rs b/crates/paint/src/render/coverage_tests.rs index 0b992308ad..db8e8d0cd1 100644 --- a/crates/paint/src/render/coverage_tests.rs +++ b/crates/paint/src/render/coverage_tests.rs @@ -188,3 +188,26 @@ fn sparse_large_footprint_only_allocates_and_dirties_nonzero_tiles() { map.accumulate(Rect::new(0, 0, 0, 0), &[], 0.8, None, None); assert_eq!(map.bounds(), bounds); } + +/// `texture_at` at a scale other than 100 % is the bilinear value of the four wrapped texels around +/// the sample: x and y are not swapped, and a different fraction on each axis is honoured. +#[test] +fn texture_at_scaled_is_the_bilinear_of_the_wrapped_texels() { + let (w, h) = (4usize, 3usize); + let data: Vec = (0..w * h).map(|i| (i * i) as f32 / 100.0).collect(); + let mut brush = BrushSettings::default(); + brush.texture.scale = 4.0; + let mut ctx = BrushContext::new(&brush); + ctx.texture = Some(Arc::new(PatternImage { width: w, height: h, data: data.clone() })); + let texel = |x: i64, y: i64| data[y.rem_euclid(h as i64) as usize * w + x.rem_euclid(w as i64) as usize]; + for (x, y) in [(5, 2), (2, 5), (-3, 7), (0, 0), (-9, -4)] { + let (fx, fy) = ((x as f32 + 0.5) / 4.0 - 0.5, (y as f32 + 0.5) / 4.0 - 0.5); + let (x0, y0) = (fx.floor() as i64, fy.floor() as i64); + let (tx, ty) = (fx - fx.floor(), fy - fy.floor()); + let top = texel(x0, y0) + (texel(x0 + 1, y0) - texel(x0, y0)) * tx; + let bottom = texel(x0, y0 + 1) + (texel(x0 + 1, y0 + 1) - texel(x0, y0 + 1)) * tx; + let want = top + (bottom - top) * ty; + let got = ctx.texture_at(x, y); + assert!((got - want).abs() < 1e-5, "({x}, {y}): {got} vs {want}"); + } +} diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs index e10fdd099f..c7dc366bb2 100644 --- a/crates/paint/src/render/reference.rs +++ b/crates/paint/src/render/reference.rs @@ -322,6 +322,153 @@ fn cached_profiles_match_scalar_for_small_and_transformed_dabs() { } } +/// Dabs that take the general loop, each with the context and flags that select a feature. +#[cfg(not(target_arch = "wasm32"))] +fn general_loop_cases() -> Vec<(&'static str, BrushContext, Dab, bool, bool)> { + let tip = GrayTile::from_fn(16, 12, |x, y| ((x + 2 * y) % 7) as f32 / 6.0 + 0.1); + let base = || BrushSettings { hardness: 0.25, ..Default::default() }; + let dab = |angle: f32, roundness: f32, proj_scale: f32| { + let mut d = Dab::round(Point::new(50.3, 60.7), 40.0, 0.8); + d.angle = angle; + d.roundness = roundness; + d.proj_scale = proj_scale; + d.proj_angle = 0.3; + d + }; + let ctx = |b: BrushSettings| BrushContext::new(&b); + vec![ + ("sampled", ctx(BrushSettings { tip: TipShape::Sampled(tip.clone()), ..base() }), dab(0.4, 1.0, 1.0), false, false), + ( + "per-tip texture", + ctx({ + let mut b = base(); + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = Pattern::Tile(tip.clone()); + b + }), + dab(0.0, 1.0, 1.0), + false, + false, + ), + ("noise", ctx(BrushSettings { noise: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges with depth", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, true), + ("rotated ellipse", ctx(base()), dab(0.7, 0.4, 1.0), false, false), + ("projection", ctx(base()), dab(0.0, 1.0, 0.6), false, false), + ( + "dual tip", + ctx({ + let mut b = base(); + b.dual_brush.enabled = true; + b.dual_brush.tip = TipShape::Sampled(tip.clone()); + b + }), + dab(0.2, 1.0, 1.0), + true, + false, + ), + ] +} + +#[cfg(not(target_arch = "wasm32"))] +struct RestoreThreshold; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for RestoreThreshold { + fn drop(&mut self) { + set_parallel_min_pixels(DEFAULT_PARALLEL_MIN_PIXELS); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() +} + +/// Rows of the general loop run on the thread pool from a quarter of the pixels the round path +/// needs, and give the bits the serial loop gives. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_rows_match_serially_and_in_parallel() { + let _restore = RestoreThreshold; + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + assert!(rect.height() > 8, "{label}: many rows"); + let run = |threshold: usize| { + set_parallel_min_pixels(threshold); + let (mut out, mut depth) = (vec![0.75; 7], vec![0.25; 5]); + ctx.rasterize_into(&d, dual, rect, &mut out, with_depth.then_some(&mut depth)); + if !with_depth { + depth.clear(); + } + (out, depth) + }; + let (par, par_depth) = run(0); + let (ser, ser_depth) = run(usize::MAX); + assert_eq!(bits(&par), bits(&ser), "{label}: coverage differs between parallel and serial"); + assert_eq!(bits(&par_depth), bits(&ser_depth), "{label}: depth differs between parallel and serial"); + assert!(par.iter().any(|v| *v != 0.0), "{label}: painted something"); + if with_depth { + assert!(par_depth.iter().any(|v| *v != 0.0), "{label}: depth written"); + } + } +} + +/// The parallel general loop gives the bits of the frozen serial loop. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_general_loop_matches_the_frozen_reference() { + let _restore = RestoreThreshold; + set_parallel_min_pixels(0); + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + let (mut actual, mut expected) = (Vec::new(), Vec::new()); + let (mut actual_depth, mut expected_depth) = (Vec::new(), Vec::new()); + ctx.rasterize_into(&d, dual, rect, &mut actual, with_depth.then_some(&mut actual_depth)); + ctx.rasterize_reference(&d, dual, rect, &mut expected, with_depth.then_some(&mut expected_depth)); + assert_eq!(bits(&actual), bits(&expected), "{label}: coverage differs from the reference"); + assert_eq!(bits(&actual_depth), bits(&expected_depth), "{label}: depth differs from the reference"); + assert!(actual.iter().any(|v| *v != 0.0), "{label}: painted something"); + } +} + +/// Empty rects and non-finite dabs return normally from the general loop at either threshold. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_survives_empty_rects_and_nan_dabs() { + let _restore = RestoreThreshold; + let tip = GrayTile::from_fn(8, 5, |x, y| ((x + y) % 7) as f32 / 6.0); + let sampled = BrushContext::new(&BrushSettings { tip: TipShape::Sampled(tip), ..Default::default() }); + let plain = BrushContext::new(&BrushSettings::default()); + for threshold in [0, usize::MAX] { + set_parallel_min_pixels(threshold); + for ctx in [&sampled, &plain] { + for rect in [Rect::new(0, 0, 0, 5), Rect::new(0, 0, 5, 0), Rect::new(0, 0, 0, 0)] { + for with_depth in [false, true] { + let (mut out, mut depth) = (vec![0.5; 9], vec![0.5; 9]); + ctx.rasterize_into(&Dab::round(Point::new(2.0, 2.0), 6.0, 1.0), false, rect, &mut out, with_depth.then_some(&mut depth)); + assert!(out.is_empty()); + if with_depth { + assert!(depth.is_empty()); + } + } + } + } + for field in 0..2 { + let mut d = Dab::round(Point::new(0.0, 0.0), 6.0, 1.0); + if field == 0 { + d.radius = f32::NAN; + } else { + d.angle = f32::NAN; + } + let mut out = Vec::new(); + sampled.rasterize_into(&d, false, Rect::new(-4, -4, 4, 4), &mut out, None); + assert_eq!(out.len(), 64); + } + } +} + // Keep the pre-cache scalar coverage equations independent of TipProfile. fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { let (r0, s) = tip_shape(radius, hardness); diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 06bafe6cfb..ec66e32f84 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -207,10 +207,22 @@ pub fn apply_coverage( let mut region = target.read_region(rect); let w = rect.width() as usize; let a = alpha_index(&fmt); + // The selection row under the current coverage row, read once per row. + let mut sel_row = vec![1.0f32; if selection.is_some() { w } else { 0 }]; + let mut sel_y = None; for (i, c) in cov.iter().enumerate() { let x = rect.x0 + (i % w) as i32; let y = rect.y0 + (i / w) as i32; - let sel = selection.map_or(1.0, |s| s.sample_channel(x, y, 0)); + let sel = match selection { + Some(s) => { + if sel_y != Some(y) { + s.sample_row_strided(y, rect.x0, 1, 0, &mut sel_row); + sel_y = Some(y); + } + sel_row.get(i % w).copied().unwrap_or(1.0) + } + None => 1.0, + }; let k = (c * opacity * sel).min(1.0); if k <= 0.0 { continue; @@ -275,9 +287,14 @@ pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect, let mut out = orig.crop(rect); let n = out.ch; let opacity = opacity.clamp(0.0, 1.0); + let mut sel_row = vec![1.0f32; if selection.is_some() { rect.width() as usize } else { 0 }]; for y in rect.y0..rect.y1 { + if let Some(s) = selection { + s.sample_row_strided(y, rect.x0, 1, 0, &mut sel_row); + } for x in rect.x0..rect.x1 { - let k = opacity * selection.map_or(1.0, |s| s.sample_channel(x, y, 0)); + let sel = if selection.is_some() { sel_row.get((x - rect.x0) as usize).copied().unwrap_or(1.0) } else { 1.0 }; + let k = opacity * sel; if k <= 0.0 { continue; } @@ -359,29 +376,34 @@ impl Smudge { /// Apply one dab to the working copy. pub fn dab(&mut self, work: &mut Region, fp: &Footprint) { let (r, n) = (self.radius, work.ch); + if n == 0 { + return; + } let side = (2 * r + 1) as usize; let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); let wr = work.rect; let alpha = self.alpha.filter(|a| *a < n); - let clamp_px = |work: &Region, x: i32, y: i32| -> Vec { - let mut v = work.px(x.clamp(wr.x0, wr.x1 - 1), y.clamp(wr.y0, wr.y1 - 1)).to_vec(); - premultiply(&mut v, alpha); - v - }; let carry = self.carry.get_or_insert_with(|| { let mut c = vec![0.0f32; side * side * n]; - for qy in 0..side { - for qx in 0..side { - let v = match &self.finger { - Some(f) => { - let mut f = f.clone(); - f.resize(n, 1.0); - premultiply(&mut f, alpha); - f + match &self.finger { + Some(f) => { + let mut f = f.clone(); + f.resize(n, 1.0); + premultiply(&mut f, alpha); + for dst in c.chunks_exact_mut(n) { + dst.copy_from_slice(&f); + } + } + None => { + // Straight into the buffer: no per-pixel allocation. + for (qy, row) in c.chunks_exact_mut(side * n).enumerate() { + let y = (cy + qy as i32 - r).clamp(wr.y0, wr.y1 - 1); + for (qx, dst) in row.chunks_exact_mut(n).enumerate() { + let x = (cx + qx as i32 - r).clamp(wr.x0, wr.x1 - 1); + dst.copy_from_slice(work.px(x, y)); + premultiply(dst, alpha); } - None => clamp_px(work, cx + qx as i32 - r, cy + qy as i32 - r), - }; - c[(qy * side + qx) * n..(qy * side + qx + 1) * n].copy_from_slice(&v[..n]); + } } } c @@ -421,20 +443,24 @@ impl Smudge { } } } - // Pick up: the carried colour keeps `strength` of itself. + // Pick up: the carried colour keeps `strength` of itself. Row by row over the part of the + // square inside the working copy. let keep = self.strength; - for qy in 0..side { - for qx in 0..side { - let (x, y) = (cx + qx as i32 - r, cy + qy as i32 - r); - if !wr.contains(x, y) { + let (qx0, qx1) = (wr.x0.saturating_sub(cx - r).clamp(0, side as i32) as usize, wr.x1.saturating_sub(cx - r).clamp(0, side as i32) as usize); + if qx0 < qx1 { + for qy in 0..side { + let y = cy + qy as i32 - r; + if y < wr.y0 || y >= wr.y1 { continue; } - let q = (qy * side + qx) * n; - let px = work.px(x, y); - let pa = alpha.map_or(1.0, |a| px[a]); - for c in 0..n { - let v = if alpha.is_some_and(|a| a != c) { px[c] * pa } else { px[c] }; - carry[q + c] = carry[q + c] * keep + v * (1.0 - keep); + let src = &work.data[work.index(cx - r + qx0 as i32, y) * n..][..(qx1 - qx0) * n]; + let dst = &mut carry[(qy * side + qx0) * n..(qy * side + qx1) * n]; + for (px, cv) in src.chunks_exact(n).zip(dst.chunks_exact_mut(n)) { + let pa = alpha.map_or(1.0, |a| px[a]); + for c in 0..n { + let v = if alpha.is_some_and(|a| a != c) { px[c] * pa } else { px[c] }; + cv[c] = cv[c] * keep + v * (1.0 - keep); + } } } } @@ -588,6 +614,230 @@ mod tests { } } + /// A region without channels is left alone: the carry buffers would be chunked by zero. + #[test] + fn smudge_leaves_a_zero_channel_region_alone() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 60, 40), &[1.0, 1.0, 1.0, 1.0]); + let st = stroke(&[(20.0, 20.0), (40.0, 20.0)], 12.0, 1.0); + let mut sm = Smudge::new(0.8, None, 12.0); + let mut calls = 0; + apply_dab_stroke(&mut s, &st, None, false, 0, |w, fp| { + let mut empty = Region::new(w.rect, 0); + sm.dab(&mut empty, fp); + assert!(empty.data.is_empty() && sm.carry.is_none(), "no carry is built for zero channels"); + calls += 1; + }); + assert!(calls > 0); + } + + /// The pre-optimisation `Smudge::dab` (one `Vec` per carry pixel, per-pixel pick-up), kept as an oracle. + fn reference_dab(sm: &mut Smudge, work: &mut Region, fp: &Footprint) { + let (r, n) = (sm.radius, work.ch); + let side = (2 * r + 1) as usize; + let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); + let wr = work.rect; + let alpha = sm.alpha.filter(|a| *a < n); + let clamp_px = |work: &Region, x: i32, y: i32| -> Vec { + let mut v = work.px(x.clamp(wr.x0, wr.x1 - 1), y.clamp(wr.y0, wr.y1 - 1)).to_vec(); + premultiply(&mut v, alpha); + v + }; + let carry = sm.carry.get_or_insert_with(|| { + let mut c = vec![0.0f32; side * side * n]; + for qy in 0..side { + for qx in 0..side { + let v = match &sm.finger { + Some(f) => { + let mut f = f.clone(); + f.resize(n, 1.0); + premultiply(&mut f, alpha); + f + } + None => clamp_px(work, cx + qx as i32 - r, cy + qy as i32 - r), + }; + c[(qy * side + qx) * n..(qy * side + qx + 1) * n].copy_from_slice(&v[..n]); + } + } + c + }); + for y in fp.rect.y0.max(wr.y0)..fp.rect.y1.min(wr.y1) { + for x in fp.rect.x0.max(wr.x0)..fp.rect.x1.min(wr.x1) { + let k = fp.at(x, y) * sm.strength; + let (qx, qy) = (x - cx + r, y - cy + r); + if k <= 0.0 || qx < 0 || qy < 0 || qx >= side as i32 || qy >= side as i32 { + continue; + } + let q = (qy as usize * side + qx as usize) * n; + let px = work.px_mut(x, y); + match alpha { + Some(a) => { + let ca = carry[q + a]; + let mut buf = [0.0f32; 16]; + let m = n.min(buf.len()); + buf[..m].copy_from_slice(&carry[q..q + m]); + let src = &mut buf[..m]; + if ca > 1e-6 { + for (c, v) in src.iter_mut().enumerate() { + if c != a { + *v /= ca; + } + } + } + mix_premultiplied(px, src, a, k); + } + None => { + for c in 0..n { + px[c] += (carry[q + c] - px[c]) * k; + } + } + } + } + } + let keep = sm.strength; + for qy in 0..side { + for qx in 0..side { + let (x, y) = (cx + qx as i32 - r, cy + qy as i32 - r); + if !wr.contains(x, y) { + continue; + } + let q = (qy * side + qx) * n; + let px = work.px(x, y); + let pa = alpha.map_or(1.0, |a| px[a]); + for c in 0..n { + let v = if alpha.is_some_and(|a| a != c) { px[c] * pa } else { px[c] }; + carry[q + c] = carry[q + c] * keep + v * (1.0 - keep); + } + } + } + } + + /// A busy test image: gradients plus a hole, so alpha and colour both vary. + fn busy(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + let n = fmt.channels(); + for y in 0..60 { + for x in 0..90 { + let mut v = vec![(x as f32 / 90.0).min(1.0); n]; + v[0] = (y as f32 / 60.0).min(1.0); + if n > 1 { + v[1] = ((x * 7 + y * 3) % 17) as f32 / 17.0; + } + if fmt.alpha { + v[n - 1] = if (30..45).contains(&x) && (20..40).contains(&y) { 0.0 } else { 0.25 + 0.75 * (((x + y) % 5) as f32 / 4.0) }; + } + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &v); + } + } + s + } + + #[test] + fn smudge_matches_the_per_pixel_reference_bit_for_bit() { + // Strokes that start and end at the page edge exercise the clamped start-up and the clipped pick-up. + let paths: [&[(f64, f64)]; 3] = [&[(2.0, 3.0), (45.0, 30.0), (88.0, 58.0)], &[(0.0, 0.0), (89.0, 0.0)], &[(40.0, 30.0), (50.0, 31.0)]]; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::GRAY8, PixelFormat::CMYKA8] { + let alpha = alpha_index(&fmt); + let n = fmt.channels(); + for finger in [None, Some(vec![0.3; n])] { + for path in paths { + let st = stroke(path, 14.0, 0.6); + let mut a = busy(fmt); + let mut b = a.clone(); + let mut sm_a = Smudge::new(0.7, finger.clone(), 14.0).with_alpha(alpha); + let mut sm_b = sm_a.clone(); + apply_dab_stroke(&mut a, &st, None, false, 0, |w, fp| sm_a.dab(w, fp)); + apply_dab_stroke(&mut b, &st, None, false, 0, |w, fp| reference_dab(&mut sm_b, w, fp)); + let rect = Rect::new(-20, -20, 120, 90); + assert_eq!(a.read_region(rect), b.read_region(rect), "{fmt:?} finger {} {path:?}", finger.is_some()); + assert_eq!(sm_a.carry, sm_b.carry, "{fmt:?} carry"); + } + } + } + } + + /// A selection that varies per pixel and has a gap, so a wrong row or column shows. + fn patterned_selection() -> Surface { + let mut sel = Surface::new(PixelFormat::GRAY8); + for y in 0..60 { + for x in 0..90 { + if (x + 2 * y) % 11 != 0 { + sel.fill_rect(Rect::new(x, y, x + 1, y + 1), &[((x * 5 + y * 13) % 256) as f32 / 255.0]); + } + } + } + sel + } + + #[test] + fn selection_rows_match_per_pixel_sampling() { + let sel = patterned_selection(); + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F, PixelFormat::GRAY8] { + // Mix back: the selection weights each pixel of the stroke. + let st = stroke(&[(10.0, 10.0), (70.0, 40.0)], 16.0, 1.0); + let mut got = busy(fmt); + let mut want = got.clone(); + let effect = |w: &mut Region, fp: &Footprint| { + for y in fp.rect.y0.max(w.rect.y0)..fp.rect.y1.min(w.rect.y1) { + for x in fp.rect.x0.max(w.rect.x0)..fp.rect.x1.min(w.rect.x1) { + w.px_mut(x, y).iter_mut().for_each(|v| *v = (*v * 0.5 + 0.1).min(1.0)); + } + } + }; + let bounds = apply_dab_stroke(&mut got, &st, Some(&sel), false, 0, effect); + let orig = Region::read(&want, bounds); + let mut work = orig.clone(); + let ctx = crate::BrushContext::new(&st.brush); + for (i, d) in dabs(&st).iter().enumerate() { + effect(&mut work, &ctx.footprint(d, i)); + } + let a = alpha_index(&fmt); + let mut out = orig.clone(); + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + let k = st.brush.opacity.clamp(0.0, 1.0) * sel.sample_channel(x, y, 0); + if k <= 0.0 { + continue; + } + let wv = work.px(x, y).to_vec(); + let i = out.index(x, y) * out.ch; + let o = &mut out.data[i..i + wv.len()]; + if *o == wv[..] { + continue; + } + match a { + Some(a) => mix_premultiplied(o, &wv, a, k), + None => (0..wv.len()).for_each(|c| o[c] += (wv[c] - o[c]) * k), + } + } + } + out.write(&mut want); + let rect = Rect::new(-20, -20, 120, 90); + assert_eq!(got.read_region(rect), want.read_region(rect), "mix_back {fmt:?}"); + + // Coverage composite with the same selection. + let r = Rect::new(-5, 3, 60, 50); + let w = r.width() as usize; + let cov: Vec = (0..w * r.height() as usize).map(|i| ((i * 7) % 10) as f32 / 9.0).collect(); + let mut paint = Region::new(r, fmt.channels()); + paint.data.iter_mut().enumerate().for_each(|(i, v)| *v = ((i * 3) % 7) as f32 / 6.0); + let mut got = busy(fmt); + let mut want = got.clone(); + apply_coverage(&mut got, r, &cov, 0.8, Some(&sel), false, &paint, BlendMode::Normal); + let mut region = want.read_region(r); + let n = fmt.channels(); + for (i, c) in cov.iter().enumerate() { + let (x, y) = (r.x0 + (i % w) as i32, r.y0 + (i / w) as i32); + let k = (c * 0.8 * sel.sample_channel(x, y, 0)).min(1.0); + if k > 0.0 { + over_native(&fmt, &mut region[i * n..(i + 1) * n], paint.px(x, y), k, false); + } + } + want.write_region(r, ®ion); + assert_eq!(got.read_region(rect), want.read_region(rect), "apply_coverage {fmt:?}"); + } + } + #[test] fn premultiplied_mix_ignores_colour_of_transparent_pixels() { let mut d = [0.0, 0.0, 0.0, 0.0]; diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 8ea0e776b6..623a264606 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -303,12 +303,92 @@ impl PatternImage { let yi = (y.floor() as i64).rem_euclid(self.height as i64) as usize; self.data[yi * self.width + xi] } + + /// Bilinear sample of the four texels at (`x0`, `y0`) and their right/lower neighbours, all + /// wrapped; `x0`, `y0` are already floored. Same values as four [`sample_wrap`](Self::sample_wrap) + /// calls, with the wrap done once per axis instead of once per texel. + #[inline] + pub fn sample_bilinear_wrap(&self, x0: f32, y0: f32, tx: f32, ty: f32) -> f32 { + // Past 2^24 `x0 + 1.0` rounds back to `x0`, so the neighbour is not `x0 + 1` any more. + const EXACT: f32 = 16_777_216.0; + // `rem_euclid` below is on `i32`, so a side past `i32::MAX` takes the `i64` path, like the coordinates past 2^24. + if !(x0.abs() < EXACT && y0.abs() < EXACT) || self.width.max(self.height) > i32::MAX as usize { + let a = self.sample_wrap(x0, y0) + (self.sample_wrap(x0 + 1.0, y0) - self.sample_wrap(x0, y0)) * tx; + let b = self.sample_wrap(x0, y0 + 1.0) + (self.sample_wrap(x0 + 1.0, y0 + 1.0) - self.sample_wrap(x0, y0 + 1.0)) * tx; + return a + (b - a) * ty; + } + let (w, h) = (self.width, self.height); + let xa = (x0 as i32).rem_euclid(w as i32) as usize; + let ya = (y0 as i32).rem_euclid(h as i32) as usize; + let xb = if xa + 1 == w { 0 } else { xa + 1 }; + let yb = if ya + 1 == h { 0 } else { ya + 1 }; + let (ra, rb) = (ya * w, yb * w); + let (p00, p10, p01, p11) = (self.data[ra + xa], self.data[ra + xb], self.data[rb + xa], self.data[rb + xb]); + let a = p00 + (p10 - p00) * tx; + let b = p01 + (p11 - p01) * tx; + a + (b - a) * ty + } } #[cfg(test)] mod tests { use super::*; + fn old_bilinear(p: &PatternImage, x0: f32, y0: f32, tx: f32, ty: f32) -> f32 { + let a = p.sample_wrap(x0, y0) + (p.sample_wrap(x0 + 1.0, y0) - p.sample_wrap(x0, y0)) * tx; + let b = p.sample_wrap(x0, y0 + 1.0) + (p.sample_wrap(x0 + 1.0, y0 + 1.0) - p.sample_wrap(x0, y0 + 1.0)) * tx; + a + (b - a) * ty + } + + /// The wrapped bilinear sampler returns the same bits as four `sample_wrap` calls: odd and + /// non-square sizes, negative and huge coordinates, the wrap seam, NaN. + #[test] + fn bilinear_wrap_is_bit_identical_to_four_sample_wraps() { + for (w, h) in [(1usize, 1usize), (1, 7), (5, 1), (7, 5), (128, 128), (1024, 3)] { + let data = (0..w * h).map(|i| ((i as u32).wrapping_mul(2_654_435_761) >> 8) as f32 / 16_777_216.0).collect(); + let p = PatternImage { width: w, height: h, data }; + let mut coords = + vec![0.0f32, -1.0, -(w as f32), w as f32 - 1.0, w as f32, 16_777_215.0, 16_777_216.0, -16_777_216.0, 3.0e9, -3.0e9, f32::NAN, f32::INFINITY]; + coords.extend((-300..300).map(|i| i as f32 * 1.0)); + for &x0 in &coords { + for &y0 in coords.iter().filter(|y| !y.is_finite() || y.abs() > 20.0 || **y % 7.0 == 0.0) { + for (tx, ty) in [(0.0, 0.0), (0.25, 0.75), (0.999, 0.5)] { + let (new, old) = (p.sample_bilinear_wrap(x0, y0, tx, ty), old_bilinear(&p, x0, y0, tx, ty)); + assert_eq!(new.to_bits(), old.to_bits(), "{w}x{h} at ({x0}, {y0})"); + } + } + } + } + } + + /// Manual timing: `cargo test -p photocraft-paint --release bilinear_wrap_timing -- --ignored --nocapture`. + #[test] + #[ignore] + fn bilinear_wrap_timing() { + let p = PatternImage { width: 128, height: 128, data: (0..128 * 128).map(|i| (i % 251) as f32 / 251.0).collect() }; + let n = 4_000_000; + for round in 0..3 { + let t = std::time::Instant::now(); + let mut s = 0.0f32; + for i in 0..n { + let (fx, fy) = ((i % 2000) as f32 * 1.25 - 40.5, (i / 2000) as f32 * 1.25 - 40.5); + s += old_bilinear(&p, fx.floor(), fy.floor(), fx.fract(), fy.fract()); + } + let old = t.elapsed(); + let t = std::time::Instant::now(); + let mut s2 = 0.0f32; + for i in 0..n { + let (fx, fy) = ((i % 2000) as f32 * 1.25 - 40.5, (i / 2000) as f32 * 1.25 - 40.5); + s2 += p.sample_bilinear_wrap(fx.floor(), fy.floor(), fx.fract(), fy.fract()); + } + println!( + "round {round}: old {:.2} ns/px, new {:.2} ns/px ({s} {s2})", + old.as_secs_f64() * 1e9 / n as f64, + t.elapsed().as_secs_f64() * 1e9 / n as f64 + ); + } + } + /// Cloning a tile (and the brush settings holding it, on every pointer event of a stroke) /// shares its samples instead of copying them (#4534). #[test] diff --git a/crates/psd/README.md b/crates/psd/README.md index 71d12e9f12..1f6c892af5 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -78,7 +78,14 @@ LayerInfo::unpadded_len(version), LayerInfo::pad_to(version, 4) // Photoshop p // Image resources ImageResource { signature, id, name, data }; ImageResource::parsed() -> Option> ResourceData::{ResolutionInfo, LayerState, LayerGroupInfo, Thumbnail, GlobalAngle, IccProfile, - GlobalAltitude, VersionInfo, Exif, Xmp} + GlobalAltitude, VersionInfo, Exif, Xmp, TimelineInfo, Animation} + +// Legacy animation syntax (no playback/document-model dependency) +animation::AnimationResource::from_bytes / to_bytes // maniIRFR; ordered raw records +animation::AnimationRecord::descriptor() // AnDs; version-16 descriptor +animation::{frame_info, frame_sets, layer_frame_states} // borrowed sparse views, opaque ids +animation::parse_descriptor // descriptor + up to 3 zero alignment bytes +metadata::MetadataItem::animation_descriptor() // mlst / tmln; leaves other metadata alone // Compression Compression::{Raw, Rle, Zip, ZipPrediction, Unknown(u16)} @@ -117,6 +124,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla | luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd | ✓ | ✓ | ✓ | `TaggedBlock::parsed()` | | Layer XMP (`shmd` › `cust` › `layerXMP`) | ✓ | ✓ | ✓ | `metadata::layer_xmp` / `take_layer_xmp` / `put_layer_xmp`, laid out as Photoshop 2026 writes it | | ActionDescriptor (all OSTypes) | ✓ | ✓ | ✓ | including references, `ObAr` (best effort), `UnFl`, `Pth `, version-16 wrapper | +| Legacy frame animation syntax | ✓ | ✓ | partial | `mani` / `AnDs`, `mlst`, `tmln`, resource 1075; raw-preserving descriptor views, not playback or keyframe evaluation | | Layer tree (groups) | ✓ | ✓ | ✓ | open/closed folders plus bounding dividers; tolerant of malformed nesting | | RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | | Builder | – | ✓ | – | RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping | @@ -140,6 +148,25 @@ allocations for padded channels return an error on failure. * Adjustment and fill layers: `curv`, `levl`, `hue2`, `SoCo`, `GdFl`, `PtFl`, …, plus `Patt`, `Txt2`, `FMsk`, 3D and video blocks. * Global layer mask info (raw, with accessors), thumbnails (raw), slices, guides, paths, and every other resource id. +## Legacy animation inspection + +```sh +cargo run -p photocraft-psd --example animation_inspect -- /path/to/input.psd +cargo test -p photocraft-psd --test it animation:: +``` + +The inspection example accepts an explicit local PSD/PSB (up to 128 MiB), reports frame counts, +stored delays and descriptor counts, and checks byte-exact raw round-trips without modifying the +input. It does not print layer names, text or descriptor values. No personal file is bundled; +committed animation tests construct synthetic payloads and PSD/PSB containers. + +`mani` is detected by its payload signature within the plug-in resource range 4000–4999, not by +assuming that resource 4003 always belongs to animation. Unknown records and unsupported descriptor +versions stay raw. `FrIn` definitions and `FSts` playback order are separate; ids are opaque stored +`long` values. Missing frame delays remain absent, and sparse `mlst` properties are not filled with +guessed defaults. Timeline tracks, loops, inheritance and property evaluation are left to higher +layers. This parser alone does **not** make PSD animations play in PhotoCraft. + ## Testing `cargo test -p photocraft-psd --all-features` runs the unit tests, integration tests and proptests: diff --git a/crates/psd/examples/animation_inspect.rs b/crates/psd/examples/animation_inspect.rs new file mode 100644 index 0000000000..2b3f7391dc --- /dev/null +++ b/crates/psd/examples/animation_inspect.rs @@ -0,0 +1,103 @@ +//! Inspect a local PSD/PSB's animation structure without printing layer names, text, or metadata +//! values. No asset is bundled: pass a path explicitly. Does not decode pixels or modify the file. +//! `cargo run -p photocraft-psd --example animation_inspect -- /path/to/input.psd` + +#[cfg(not(target_arch = "wasm32"))] +fn inspect() -> Result<(), Box> { + use std::collections::BTreeMap; + use std::io::Read; + + use photocraft_psd::animation::{frame_info, frame_sets, layer_frame_states}; + use photocraft_psd::metadata::parse_shmd; + use photocraft_psd::{PsdFile, ResourceData}; + + let mut args = std::env::args_os().skip(1); + let path = args.next().ok_or("usage: animation_inspect ")?; + if args.next().is_some() { + return Err("expected exactly one input path".into()); + } + // Diagnostic tool, not the production large-file importer. Bound even a file growing while read. + const MAX_BYTES: u64 = 128 * 1024 * 1024; + let file = std::fs::File::open(path)?; + if !file.metadata()?.is_file() || file.metadata()?.len() > MAX_BYTES { + return Err("expected a regular PSD/PSB no larger than 128 MiB".into()); + } + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BYTES { + return Err("input exceeds the diagnostic tool's 128 MiB limit".into()); + } + let psd = PsdFile::from_bytes(&bytes)?; + println!("canvas={}x{} depth={} layers={}", psd.header.width, psd.header.height, psd.header.depth, psd.layers().len()); + let mut animations = 0; + for resource in &psd.resources { + match resource.parsed() { + Some(Ok(ResourceData::Animation(animation))) => { + animations += 1; + let back = animation.to_bytes()?; + if back != resource.data { + return Err("animation resource failed its byte-exact round trip".into()); + } + println!("animation resource: {} records; byte-exact round trip", animation.records.len()); + for record in &animation.records { + if let Some(d) = record.descriptor() { + let d = d?; + let frames = frame_info(&d.descriptor)?; + let sets = frame_sets(&d.descriptor)?; + let mut delays = BTreeMap::new(); + for frame in &frames { + *delays.entry(frame.delay_centiseconds).or_insert(0usize) += 1; + } + println!(" AnDs: {} definitions, {} frame sets", frames.len(), sets.len()); + println!(" stored delays (centiseconds -> frame count): {delays:?}"); + for set in sets { + let unresolved = set.frame_ids.iter().filter(|id| !frames.iter().any(|f| f.id == **id)).count(); + println!(" frame set: {} ordered references, {unresolved} unresolved", set.frame_ids.len()); + } + } + } + } + Some(Ok(ResourceData::TimelineInfo(d))) => println!("document timeline: {} descriptor fields", d.descriptor.items.len()), + Some(Err(e)) if resource.id == 1075 || (4000..=4999).contains(&resource.id) && resource.data.starts_with(b"mani") => return Err(e.into()), + _ => {} + } + } + let (mut states, mut tracks) = (0usize, 0usize); + for layer in psd.layers() { + for block in &layer.blocks { + if block.key != *b"shmd" { + continue; + } + let items = parse_shmd(&block.data)?; + for item in &items { + if let Some(d) = item.animation_descriptor() { + let d = d?; + if item.key == *b"mlst" { + layer_frame_states(&d.descriptor)?; + states += 1; + } else { + tracks += 1; + } + } + } + if photocraft_psd::metadata::write_shmd(&items) != block.data { + return Err("layer metadata failed its byte-exact round trip".into()); + } + } + } + if psd.to_bytes()? != bytes { + return Err("PSD/PSB failed its byte-exact round trip".into()); + } + println!("animation resources={animations}; layer frame-state descriptors={states}; layer timeline descriptors={tracks}"); + println!("whole PSD/PSB: byte-exact round trip; input unchanged"); + Ok(()) +} + +fn main() -> std::process::ExitCode { + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = inspect() { + eprintln!("animation inspection failed: {e}"); + return std::process::ExitCode::FAILURE; + } + std::process::ExitCode::SUCCESS +} diff --git a/crates/psd/src/animation.rs b/crates/psd/src/animation.rs new file mode 100644 index 0000000000..de29693f6c --- /dev/null +++ b/crates/psd/src/animation.rs @@ -0,0 +1,228 @@ +//! Legacy Photoshop frame-animation syntax, independent of playback or document semantics. +//! +//! Observed in a CS5-authored PSD: a plug-in image resource (id in 4000..=4999) starts with +//! `maniIRFR`, a big-endian u32 byte length, then `8BIM` / key / u32 length / data records. +//! `AnDs` holds a version-16 ActionDescriptor; other records (including `Roll`) stay opaque. +//! Layer `shmd` items `mlst` and `tmln`, and image resource 1075, also hold versioned descriptors. +//! These undocumented layouts were established by inspecting file structure, not proprietary code. +//! +//! Raw records remain the source of truth: typed accessors do not rewrite them, discard unknown +//! fields, interpret sparse layer-state inheritance, or assume a frame id is its sequence index. +//! This module does **not** import an animation into PhotoCraft or evaluate timeline tracks. + +use crate::descriptor::{Descriptor, Value, VersionedDescriptor}; +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// A record in a `mani` resource. Data includes any padding counted in its stored length. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnimationRecord<'a> { + /// Record key (`AnDs`, `Roll`, or an unknown key). + pub key: [u8; 4], + /// Borrowed, unmodified payload. + pub data: &'a [u8], +} + +impl AnimationRecord<'_> { + /// Typed view of `AnDs`; `None` for other records, including `Roll`. + pub fn descriptor(&self) -> Option> { + (self.key == *b"AnDs").then(|| parse_descriptor(self.data)) + } +} + +/// The records of a legacy frame-animation plug-in resource, in stored order. +/// +/// Unknown keys and duplicate records are preserved, not silently collapsed. The observed +/// envelope has no alignment bytes outside record lengths. Unsupported envelopes return an error; +/// callers should keep their original image resource unchanged in that case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnimationResource<'a> { + /// Ordered records, borrowing their payloads from the input. + pub records: Vec>, +} + +impl<'a> AnimationResource<'a> { + /// Parses a complete `maniIRFR` payload, checking the outer and every inner length. + /// Descriptor decoding is lazy, so unknown/future `AnDs` versions can still round-trip raw. + pub fn from_bytes(data: &'a [u8]) -> Result { + let mut r = Reader::new(data); + signature(&mut r, *b"mani", "mani")?; + signature(&mut r, *b"IRFR", "IRFR animation envelope")?; + let len = r.u32()?; + let mut body = r.sub(u64::from(len))?; + if !r.is_empty() { + return Err(PsdError::invalid("trailing bytes after animation envelope")); + } + let mut records = Vec::new(); + while !body.is_empty() { + signature(&mut body, *b"8BIM", "8BIM animation record")?; + let key = body.array()?; + let len = body.u32()?; + let data = body.bytes_u64(u64::from(len))?; + records.push(AnimationRecord { key, data }); + } + Ok(Self { records }) + } + + /// Writes the raw records, preserving their payloads exactly, including unknown fields, + /// unknown descriptor versions and noncanonical descriptor encodings. + pub fn to_bytes(&self) -> Result> { + // Check all lengths before constructing the output; no unchecked 32-bit length casts. + let mut len = 0u32; + for record in &self.records { + let n = u32::try_from(record.data.len()).map_err(|_| PsdError::LimitExceeded("animation record exceeds u32 length"))?; + len = len.checked_add(12).and_then(|v| v.checked_add(n)).ok_or(PsdError::LimitExceeded("animation envelope exceeds u32 length"))?; + } + let mut out = Vec::new(); + out.put(b"maniIRFR"); + out.put_u32(len); + for record in &self.records { + out.put(b"8BIM"); + out.put(&record.key); + out.put_len(record.data.len() as u64, false)?; + out.put(record.data); + } + Ok(out) + } +} + +fn signature(r: &mut Reader<'_>, expected: [u8; 4], name: &'static str) -> Result<()> { + let found = r.array()?; + if found != expected { + return Err(PsdError::InvalidSignature { expected: name, found }); + } + Ok(()) +} + +/// Parses a version-16 animation descriptor, allowing up to three zero alignment bytes. +/// +/// Raw payloads should be retained separately for byte-exact writing. This accessor is strict +/// about non-padding trailing bytes and does not guess how to read a future descriptor version. +/// It is shared by `AnDs`, layer `mlst` / `tmln`, and document timeline resource 1075. +pub fn parse_descriptor(data: &[u8]) -> Result { + let (value, consumed) = VersionedDescriptor::parse_prefix(data)?; + let tail = data.get(consumed..).ok_or_else(|| PsdError::invalid("animation descriptor length"))?; + if tail.len() > 3 || tail.iter().any(|&b| b != 0) { + return Err(PsdError::invalid("trailing bytes after animation descriptor")); + } + Ok(value) +} + +/// A frame-definition view. Its id is an opaque signed `long`, not a frame index. +#[derive(Debug, Clone, PartialEq)] +pub struct FrameInfo<'a> { + /// `FrID`, preserved without reinterpreting its bit pattern. + pub id: i32, + /// `FrDl` in centiseconds, or absent. No playback default or browser clamping is applied. + pub delay_centiseconds: Option, + /// All fields, including unknown ones, in their original order. + pub descriptor: &'a Descriptor, +} + +/// Reads the `FrIn` list from an `AnDs` descriptor. A missing list is not an empty animation. +/// Wrong types, missing frame ids and negative delays return errors. Duplicate ids are retained +/// for the document importer to diagnose rather than being silently overwritten here. +pub fn frame_info(descriptor: &Descriptor) -> Result>> { + let Some(Value::List(list)) = descriptor.get("FrIn") else { + return Err(PsdError::invalid("animation FrIn must be a list")); + }; + list.iter() + .map(|v| { + let Value::Descriptor(d) = v else { return Err(PsdError::invalid("animation frame must be a descriptor")) }; + let Some(Value::Integer(id)) = d.get("FrID") else { return Err(PsdError::invalid("animation frame FrID must be a long")) }; + let delay_centiseconds = match d.get("FrDl") { + None => None, + Some(Value::Integer(n)) => Some(u32::try_from(*n).map_err(|_| PsdError::invalid("negative animation frame delay"))?), + _ => return Err(PsdError::invalid("animation frame FrDl must be a long")), + }; + Ok(FrameInfo { id: *id, delay_centiseconds, descriptor: d }) + }) + .collect() +} + +/// A stored frame set: explicit playback order is separate from the `FrIn` definitions. +#[derive(Debug, Clone, PartialEq)] +pub struct FrameSet<'a> { + /// `FsID`, an opaque identifier. + pub id: i32, + /// `AFrm`, when present. Kept uninterpreted for the importer to validate. + pub active_frame: Option, + /// `FsFr` frame ids in stored order (including any duplicate or unresolved ids). + pub frame_ids: Vec, + /// Complete original descriptor, including fields not yet understood. + pub descriptor: &'a Descriptor, +} + +/// One sparse layer-state entry from `mlst` / `LaSt`. +#[derive(Debug, Clone, PartialEq)] +pub struct LayerFrameState<'a> { + /// `FrLs`: frame ids to which this entry refers. They are not frame indices. + pub frame_ids: Vec, + /// Complete sparse state, including visibility, blend options, offsets, masks, effects, + /// text settings and unknown fields. Omitted properties are deliberately not defaulted. + pub descriptor: &'a Descriptor, +} + +/// A layer's sparse animation states, without attempting to resolve their inheritance. +#[derive(Debug, Clone, PartialEq)] +pub struct LayerFrameStates<'a> { + /// `LaID`: PSD layer identifier, not the layer's record index. + pub layer_id: i32, + /// `LaSt` entries in stored order. + pub states: Vec>, +} + +/// Reads the identifiers and sparse entries of an `mlst` descriptor. This is a syntax view: +/// missing properties stay missing, and duplicate/unresolved references remain available for +/// the importer to validate. The original descriptor remains available to the caller. +pub fn layer_frame_states(descriptor: &Descriptor) -> Result> { + let Some(Value::Integer(layer_id)) = descriptor.get("LaID") else { + return Err(PsdError::invalid("animation layer LaID must be a long")); + }; + let Some(Value::List(list)) = descriptor.get("LaSt") else { + return Err(PsdError::invalid("animation layer LaSt must be a list")); + }; + let states = list + .iter() + .map(|v| { + let Value::Descriptor(d) = v else { return Err(PsdError::invalid("animation layer state must be a descriptor")) }; + let Some(Value::List(ids)) = d.get("FrLs") else { return Err(PsdError::invalid("animation layer FrLs must be a list")) }; + let frame_ids = ids + .iter() + .map(|v| match v { + Value::Integer(n) => Ok(*n), + _ => Err(PsdError::invalid("animation layer frame reference must be a long")), + }) + .collect::>>()?; + Ok(LayerFrameState { frame_ids, descriptor: d }) + }) + .collect::>>()?; + Ok(LayerFrameStates { layer_id: *layer_id, states }) +} + +/// Reads `FSts` without inferring order from frame ids or interpreting loop-related fields. +pub fn frame_sets(descriptor: &Descriptor) -> Result>> { + let Some(Value::List(list)) = descriptor.get("FSts") else { + return Err(PsdError::invalid("animation FSts must be a list")); + }; + list.iter() + .map(|v| { + let Value::Descriptor(d) = v else { return Err(PsdError::invalid("animation frame set must be a descriptor")) }; + let Some(Value::Integer(id)) = d.get("FsID") else { return Err(PsdError::invalid("animation frame set FsID must be a long")) }; + let active_frame = match d.get("AFrm") { + None => None, + Some(Value::Integer(n)) => Some(*n), + _ => return Err(PsdError::invalid("animation frame set AFrm must be a long")), + }; + let Some(Value::List(ids)) = d.get("FsFr") else { return Err(PsdError::invalid("animation frame set FsFr must be a list")) }; + let frame_ids = ids + .iter() + .map(|v| match v { + Value::Integer(n) => Ok(*n), + _ => Err(PsdError::invalid("animation frame reference must be a long")), + }) + .collect::>>()?; + Ok(FrameSet { id: *id, active_frame, frame_ids, descriptor: d }) + }) + .collect() +} diff --git a/crates/psd/src/codepage.rs b/crates/psd/src/codepage.rs index aec13de6bf..af472ea574 100644 --- a/crates/psd/src/codepage.rs +++ b/crates/psd/src/codepage.rs @@ -11,7 +11,15 @@ //! Roman). A candidate that cannot decode every name without errors is dropped. //! 2. Each survivor is scored by the share of non-ASCII bytes that fall in its common-text //! ranges (GB2312 hanzi, JIS level-1 kanji and kana, Big5 level 1, KS X 1001 Hangul, -//! letters, and each page's punctuation). The highest share wins. +//! letters, each page's punctuation, the degree sign, and ¡ opening an exclamation: "¡Hola!"). The +//! highest share wins. Two single-byte readings don't count: the letters windows-1252 alone +//! has at 0x80–0x9F (Š š Ž ž Ÿ ƒ ˆ), where Mac Roman keeps its accented lowercase (Mac +//! "Opacité" is 1252 "OpacitŽ"), unless one starts a word ("Škoda", "šuma") in a file whose +//! own Unicode text doesn't write the Mac Roman letter on that byte instead (a French "état", +//! a German "über", "öffnen"); +//! and a windows-1251 Cyrillic letter glued into an ASCII word (Mac "Höhe" is 1251 "Hшhe"), +//! unless the name's letters are mostly Cyrillic ("CMYKканал", "Копiя") or the letter is in a +//! Cyrillic word of its own ("Layer Слой", "Слой1"). //! 3. A tie is broken by the script of a hint (text the file stores as UTF-16, so it decodes //! fine): kana → Shift_JIS; Hangul → EUC-KR; Cyrillic → windows-1251; Han → GBK, then //! Big5, then Shift_JIS; Latin letters → windows-1252, then Mac Roman. @@ -73,6 +81,8 @@ pub(crate) fn guess(legacy: &[&[u8]], hint: &str) -> Guess { if sample.is_empty() { return Guess { encoding: UTF_8, uncertain: false }; } + // The accented letters the file's own Unicode text uses: what its writer's language writes. + let hint_letters: std::collections::BTreeSet = hint.chars().filter(|c| !c.is_ascii() && c.is_alphabetic()).flat_map(char::to_lowercase).collect(); let mut best: Vec<&'static Encoding> = Vec::new(); let mut best_score = (0u64, 1u64); for enc in candidates() { @@ -80,7 +90,7 @@ pub(crate) fn guess(legacy: &[&[u8]], hint: &str) -> Guess { continue; } let score = sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| { - let (nc, nh) = score(enc, name); + let (nc, nh) = score(enc, name, &hint_letters); (c.saturating_add(nc), h.saturating_add(nh)) }); let score = if score.1 == 0 { (0, 1) } else { score }; @@ -142,7 +152,7 @@ pub(crate) fn reads_as_common(name: &[u8], enc: &'static Encoding) -> bool { if ![SHIFT_JIS, GBK, BIG5, EUC_KR].contains(&enc) { return false; } - let (common, high) = score(enc, name); + let (common, high) = score(enc, name, &std::collections::BTreeSet::new()); high > 0 && common == high && decodes_cleanly(enc, name, false) } @@ -198,12 +208,47 @@ fn decodes_cleanly(enc: &'static Encoding, bytes: &[u8], truncated: bool) -> boo } /// `(common, high)`: how many of a name's non-ASCII bytes `enc` reads as common text, and how -/// many non-ASCII bytes it has. -fn score(enc: &'static Encoding, name: &[u8]) -> (u64, u64) { +/// many non-ASCII bytes it has. `hint_letters` are the accented letters the file's own Unicode +/// text writes (lowercase), which settle a word-initial letter of windows-1252's 0x80–0x9F. +fn score(enc: &'static Encoding, name: &[u8], hint_letters: &std::collections::BTreeSet) -> (u64, u64) { if enc == WINDOWS_1252 || enc == WINDOWS_1251 || enc == MACINTOSH { - // Letters and typographic punctuation are common, as the double-byte pages' punctuation - // rows are. - return single_byte_score(enc, name, |ch| ch.is_alphabetic() || TYPOGRAPHIC.contains(&ch)); + // Letters, typographic punctuation, the degree sign ("Rotation 90°") and ¡ opening an + // exclamation ("¡Hola!") are common, as the double-byte pages' punctuation rows are, with two readings + // held back: the letters windows-1252 alone has at 0x80–0x9F, where Mac Roman keeps its + // accented lowercase (Mac "Opacité" is 1252 "OpacitŽ"), unless one starts a word ("Škoda", + // "ŠKODA", "šuma") and the file's own text doesn't write the Mac Roman letter instead (a + // French "état", a German "öffnen"); and a windows-1251 Cyrillic letter glued into an ASCII + // word (Mac "Höhe" is 1251 "Hшhe"), unless the name's letters are mostly Cyrillic + // ("CMYKканал", Ukrainian "Копiя") or the letter is in a Cyrillic word of its own ("Layer + // Слой"); a lone one right after a digit (Mac "90°" alone is 1251 "90Ў") never counts. + let decoded = Name::new(&decode(name, enc)); + let chars = &decoded.chars; + let cyrillic_name = chars.iter().filter(|c| is_cyrillic(**c)).count() >= chars.iter().filter(|c| c.is_ascii_alphabetic()).count(); + let (mut common, mut high) = (0u64, 0u64); + for (i, &ch) in chars.iter().enumerate() { + if ch.is_ascii() { + continue; + } + high += 1; + let (after_ascii, letters_after) = position(&decoded, i); + let counts = if enc == WINDOWS_1252 && RARE_1252_LETTERS.contains(&ch) { + // Opening a word, capital or not ("Škoda", "šuma"), before an ASCII letter of + // either case: Mac Roman's lowercase ä before a capital ("äKODA") is no word + // either. + let word_initial = !after_ascii && letters_after >= 1; + // The same byte as Mac Roman reads it: a letter the file's text writes, where it + // never writes this one, says the writer meant that ("état", not "Žtat"). + let written = |c: char| c.to_lowercase().all(|l| hint_letters.contains(&l)); + let mac = name.get(i..=i).map(|b| decode(b, MACINTOSH)).and_then(|s| s.chars().next()); + word_initial && !(mac.is_some_and(|m| m.is_alphabetic() && written(m)) && !written(ch)) + } else if enc == WINDOWS_1251 && is_cyrillic(ch) { + (cyrillic_name && !lone_after_digit(&decoded, i)) || cyrillic_word(&decoded, i) + } else { + ch.is_alphabetic() || TYPOGRAPHIC.contains(&ch) || ch == '°' || (ch == '¡' && opens_exclamation(&decoded, i)) + }; + common += u64::from(counts); + } + return (common, high); } let (mut common, mut high) = (0u64, 0u64); let mut i = 0usize; @@ -238,29 +283,135 @@ fn score(enc: &'static Encoding, name: &[u8]) -> (u64, u64) { /// [`score`] for a single-byte page, where one byte is one character and a non-ASCII byte is a /// non-ASCII character: `common` decides which of them count. /// -/// Known limitation: windows-1251 has Cyrillic letters (І і Ј д) where windows-1252 has ² ³ £ ä, -/// so a windows-1252 name like "Fläche m²" or "Preis £5" scores higher as windows-1251 ("Flдche -/// mІ", "Preis Ј5"). Discounting a Cyrillic letter glued to a Latin word would fix those but -/// break ordinary Russian names such as "CMYKканал" or Ukrainian "Копiя" (Latin i), which turn a -/// whole set into windows-1252. -fn single_byte_score(enc: &'static Encoding, name: &[u8], common: impl Fn(char) -> bool) -> (u64, u64) { - decode(name, enc).chars().filter(|c| !c.is_ascii()).fold((0, 0), |(c, h), ch| (c.saturating_add(u64::from(common(ch))), h.saturating_add(1))) +/// windows-1251 has Cyrillic letters (І і Ј д) where windows-1252 has ² ³ £ ä, so a windows-1252 +/// name like "Fläche m²" or "Preis £5" would score higher as windows-1251 ("Flдche mІ", "Preis +/// Ј5"); [`score`] counts a Cyrillic letter only in a name whose letters are mostly Cyrillic, +/// which keeps ordinary Russian names such as "CMYKканал" or Ukrainian "Копiя" (Latin i). +fn single_byte_score(enc: &'static Encoding, name: &[u8], common: impl Fn(&Name, usize) -> bool) -> (u64, u64) { + let name = Name::new(&decode(name, enc)); + (0..name.chars.len()) + .filter(|i| name.chars.get(*i).is_some_and(|c| !c.is_ascii())) + .fold((0, 0), |(c, h), i| (c.saturating_add(u64::from(common(&name, i))), h.saturating_add(1))) } -/// Whether `ch` is common in Western names, for weighing windows-1252 against Mac Roman: letters -/// (but not those windows-1252 has only at 0x80–0x9F, where Mac Roman keeps its accented -/// lowercase: Mac "Größe" is 1252 "Grš§e"), typographic punctuation and [`LATIN_SYMBOLS`]. -fn is_latin_common(ch: char) -> bool { - (ch.is_alphabetic() && !['Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ', 'ƒ', 'ˆ'].contains(&ch)) || TYPOGRAPHIC.contains(&ch) || LATIN_SYMBOLS.contains(&ch) +/// A decoded name with what the position checks of [`score`] need, each found in one pass, so a +/// long name (a hostile file's 64 KiB of one letter) costs linear time rather than a rescan of +/// the rest of the name from every character. +struct Name { + chars: Vec, + /// `letters_from[k]`: how many letters run from `k` on (0 one past the end). + letters_from: Vec, + /// Where the last "!" is. + last_bang: Option, + /// For each Cyrillic letter, the run of Cyrillic letters around it (`start..end`); `i..i + 1` + /// for any other character. + cyrillic_runs: Vec<(usize, usize)>, } -/// Latin-1 symbols people type in names (windows-1252 0xA1–0xB7) whose byte is not a letter in +impl Name { + fn new(text: &str) -> Self { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut letters_from = vec![0usize; n + 1]; + let mut run = 0usize; + for (slot, c) in letters_from.iter_mut().zip(&chars).rev() { + run = if c.is_alphabetic() { run.saturating_add(1) } else { 0 }; + *slot = run; + } + let last_bang = chars.iter().rposition(|c| *c == '!'); + let mut cyrillic_runs: Vec<(usize, usize)> = (0..n).map(|i| (i, i + 1)).collect(); + let mut start = 0; + while start < n { + if !chars.get(start).is_some_and(|c| is_cyrillic(*c)) { + start += 1; + continue; + } + let mut end = start + 1; + while chars.get(end).is_some_and(|c| is_cyrillic(*c)) { + end += 1; + } + for slot in cyrillic_runs.get_mut(start..end).into_iter().flatten() { + *slot = (start, end); + } + start = end; + } + Self { chars, letters_from, last_bang, cyrillic_runs } + } +} + +/// Whether the character at `i` of `chars` is common in Western names, for weighing windows-1252 +/// against Mac Roman: letters (but not those windows-1252 has only at 0x80–0x9F, where Mac Roman +/// keeps its accented lowercase: Mac "Größe" is 1252 "Grš§e"), typographic punctuation, +/// [`LATIN_SYMBOLS`], and ¡ opening an exclamation ("¡Hola!"), as in [`score`]. +fn is_latin_common(name: &Name, i: usize) -> bool { + let Some(&ch) = name.chars.get(i) else { return false }; + (ch.is_alphabetic() && !RARE_1252_LETTERS.contains(&ch) && ch != 'Œ' && ch != 'œ') + || TYPOGRAPHIC.contains(&ch) + || LATIN_SYMBOLS.contains(&ch) + || (ch == '¡' && opens_exclamation(name, i)) +} + +/// Where the character at `i` of `name` sits: right after an ASCII letter or digit (inside a word +/// or a number), and how many letters (accented ones too: "¡Ánimo!") follow it directly. +fn position(name: &Name, i: usize) -> (bool, usize) { + let after_ascii = i.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_alphanumeric()); + let letters_after = name.letters_from.get(i.saturating_add(1)).copied().unwrap_or(0); + (after_ascii, letters_after) +} + +/// Whether ¡ at `i` opens a Spanish exclamation ("¡Hola!", "Capa ¡Atención!", "¡Ánimo!"): no letter +/// or digit right before it, two letters or more after it, and the closing "!" later in the name. +/// Mac Roman's degree sign on the same byte follows a number ("Rotation 90°") or stands before a +/// unit ("20 °C", "20 °Celsius"), with no "!" to close. +fn opens_exclamation(name: &Name, i: usize) -> bool { + let (after_ascii, letters_after) = position(name, i); + !after_ascii && letters_after >= 2 && name.last_bang.is_some_and(|bang| bang > i) +} + +/// The letters windows-1252 has at 0x80–0x9F and Mac Roman doesn't: there Mac Roman keeps its +/// accented lowercase (ä é ö ü ...), so these never decide against it. Œ and œ (0x8C, 0x9C: Mac +/// å, ú) are real French letters and keep counting in [`score`]; [`is_latin_common`] leaves them +/// out as well. +const RARE_1252_LETTERS: [char; 7] = ['Š', 'š', 'Ž', 'ž', 'Ÿ', 'ƒ', 'ˆ']; + +/// A Cyrillic letter (windows-1251's 0xC0–0xFF and the letters among 0x80–0xBF). +fn is_cyrillic(ch: char) -> bool { + ('\u{0400}'..='\u{04FF}').contains(&ch) && ch.is_alphabetic() +} + +/// Whether the Cyrillic letter at `i` is in a Cyrillic word of its own: a run of two or more, +/// not glued to an ASCII letter, and not right after a digit ("Слой" beside "Layer", "Слой1"; +/// not "OpacitЋ", nor "90ЎЋ", which is Mac Roman "90°é": a digit ends a number, not a word, so +/// what follows it without a space is its unit or suffix). +fn cyrillic_word(name: &Name, i: usize) -> bool { + let (start, end) = cyrillic_run(name, i); + let glued_before = start.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_alphanumeric()); + let glued_after = name.chars.get(end).is_some_and(|c| c.is_ascii_alphabetic()); + end.saturating_sub(start) >= 2 && !glued_before && !glued_after +} + +/// The run of Cyrillic letters around the Cyrillic letter at `i` (`start..end`). +fn cyrillic_run(name: &Name, i: usize) -> (usize, usize) { + name.cyrillic_runs.get(i).copied().unwrap_or((i, i.saturating_add(1))) +} + +/// Whether the Cyrillic letter at `i` stands alone right after a digit: a unit or a symbol misread +/// (Mac Roman "90°" is windows-1251 "90Ў"), whatever the rest of the name; a run after a digit is +/// a Russian unit ("5шт") and still counts. +fn lone_after_digit(name: &Name, i: usize) -> bool { + let (start, end) = cyrillic_run(name, i); + end.saturating_sub(start) == 1 && start.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_digit()) +} + +/// Latin-1 symbols people type in names (windows-1252 0xA2–0xB7) whose byte is not a letter in /// Mac Roman. Left out because Mac Roman has a letter there: § ® ¹ » ¼ ½ ¾ ¿ (ß Æ π ª º Ω æ ø, /// as in Mac "Weiß", "Størrelse", "Æble"), € and ™ (0x80, 0x99: Ä, ô), and ¥ (0xA5, Mac Roman's -/// bullet in [`TYPOGRAPHIC`]: Mac "Ebene • Kopie"). So a windows-1252 name whose only non-ASCII -/// character is one of those ("Logo ®", "¿Tamaño?", "Preis ¥100") still reads as Mac Roman -/// ("Logo Æ"). -const LATIN_SYMBOLS: [char; 11] = ['¡', '¢', '£', '©', '«', '°', '±', '²', '³', '¶', '·']; +/// bullet in [`TYPOGRAPHIC`]: Mac "Ebene • Kopie"); and ¡ (0xA1), where Mac Roman has its degree +/// sign, the commoner of the two in names (Mac "Rotation 90°", "20 °C"), which counts only where +/// it opens an exclamation closed by "!" ("¡Hola!"; [`opens_exclamation`], in [`score`] and +/// [`is_latin_common`]). So a windows-1252 name whose only non-ASCII character is one of the +/// others ("Logo ®", "¿Tamaño?", "Preis ¥100") still reads as Mac Roman ("Logo Æ"). +const LATIN_SYMBOLS: [char; 10] = ['¢', '£', '©', '«', '°', '±', '²', '³', '¶', '·']; /// Quotes, dashes, ellipsis and bullet: windows-1252 and windows-1251 share them at 0x82–0x97; /// Mac Roman has them too. @@ -424,6 +575,137 @@ mod tests { } } + #[test] + fn mac_roman_accents_on_1252_letter_bytes_stay_mac_roman() { + // #10774: Mac Roman é ä ö ü Ä (0x8E 0x8A 0x9A 0x9F 0x80) are windows-1252 Ž Š š Ÿ € and + // windows-1251 Ћ Љ ш џ Ђ. The rare 1252 letters don't count, and a Cyrillic letter in a + // Latin word doesn't either, so Mac Roman wins with a Latin hint and without one. + let cases: [(&[&str], &str); 6] = [ + (&["Opacité 50%"], "Définir"), + (&["Opacité 50%"], "Set 1"), + (&["Sélection", "Créer un calque", "Luminosité/Contraste"], "Définir"), + (&["Höhe", "Grün", "Bär"], "Größe ändern"), + (&["Höhe"], "Set 1"), + (&["Ändern"], "Set 1"), + ]; + for (words, hint) in cases { + let g = guess_words(MACINTOSH, words, hint); + assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); + let bytes = encoded(MACINTOSH, words); + for (b, w) in bytes.iter().zip(words) { + assert_eq!(decode(b, g.encoding), *w); + } + } + } + + #[test] + fn mac_roman_degree_sign_is_not_cyrillic() { + // #10774: Mac Roman ° (0xA1) is windows-1251 Ў and windows-1252 ¡; the degree sign is + // common text, a lone Cyrillic letter among Latin ones is not. + // "Layer 90°é" (review): ° and é are windows-1251 Ў and Ћ, two Cyrillic letters in a row, + // but glued to the number, so no word of their own. + let cases: [(&[&str], &str); 6] = [ + (&["Rotation 90°"], "Définir"), + (&["Rotation 90°"], "Set 1"), + (&["Rotation 90°"], "Größe ändern"), + (&["Rotation 90°", "Arrière-plan"], "Définir"), + (&["Layer 90°é"], "Set 1"), + (&["Layer 90°é"], "Définir"), + ]; + for (words, hint) in cases { + let g = guess_words(MACINTOSH, words, hint); + assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); + } + } + + #[test] + fn the_same_names_in_windows_1252_stay_windows_1252() { + for words in [&["Opacité 50%"][..], &["Höhe", "Grün", "Bär"], &["Rotation 90°"], &["Ändern"], &["Fläche m²"], &["Preis £5"]] { + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Größe ändern"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + } + } + + #[test] + fn cyrillic_names_in_latin_hinted_files_and_cyrillic_words_beside_latin_ones_stay_1251() { + // A Cyrillic step name in a set with a French name reads as it did; a Cyrillic word beside + // a Latin one counts as Cyrillic, and the hint settles the tie with windows-1252's letters. + assert_eq!(guess_words(WINDOWS_1251, &["Ёлка"], "Définir"), Guess { encoding: WINDOWS_1251, uncertain: false }); + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой1"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }, "a digit after the word"); + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой", "Ёлка"], "Set 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + // Alone and without a hint, "Layer Слой" is as ambiguous as before (every letter is one in + // windows-1252 too): the flagged fallback. + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой"], "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true }); + } + + #[test] + fn a_rare_letter_starting_a_word_keeps_windows_1252() { + // windows-1252 Š Ž Ÿ š ž (0x8A 0x8E 0x9F 0x9A 0x9E) are Mac Roman ä é ü ö û: one starting + // a word ("Škoda", all caps "ŠKODA", lowercase "šuma") counts, an accent inside a word (Mac + // "Opacité" as "OpacitŽ") does not. + for words in [&["Škoda"][..], &["ŠKODA"], &["Žaba"], &["Šibenik", "Žaba"], &["ŽUPA", "Set"], &["šuma"], &["žaba"]] { + assert_eq!(guess_words(WINDOWS_1252, words, "Nastavení"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + } + // In a file whose own text writes ä and never Š (a German Photoshop), the byte reads as + // the Mac Roman ä it would be there: the file's alphabet decides what the bytes can't. + assert_eq!(guess_words(WINDOWS_1252, &["Škoda"], "Größe ändern").encoding, MACINTOSH); + assert_eq!(guess_words(WINDOWS_1252, &["šuma"], "Größe ändern").encoding, MACINTOSH, "the file writes ö and never š"); + } + + #[test] + fn an_inverted_exclamation_opening_an_exclamation_reads_as_windows_1252() { + // windows-1252 ¡ (0xA1) is Mac Roman °: ¡ opening an exclamation, two letters or more + // after it (accented ones too) and the closing "!" later ("¡Hola!", "¡Ánimo!"), counts for + // windows-1252 (review); after a number ("90°"), before a unit ("20 °C", "20 °Celsius") or + // without a "!" the byte stays the degree sign. + for words in [&["¡Hola!"][..], &["Capa ¡Atención!"], &["¡Ánimo!"]] { + assert_eq!(guess_words(WINDOWS_1252, words, "Configuración"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + } + // (With a Latin hint: without any, a lone 0xA1 before an ASCII letter is also a Big5 + // punctuation pair, a tie the fallback flags.) + for words in [&["Rotation 90°"][..], &["Temperatur 20 °C"], &["20 °Celsius"]] { + for hint in ["Configuración", "Größe ändern"] { + assert_eq!(guess_words(MACINTOSH, words, hint), Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); + } + } + assert_eq!(guess_words(MACINTOSH, &["Rotation 90°"], "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }); + } + + #[test] + fn a_lone_cyrillic_letter_after_a_digit_is_no_cyrillic_name() { + // Mac Roman "90°" alone has no ASCII letter, so its one windows-1251 letter (Ў) would make + // the name "mostly Cyrillic" (review): a lone Cyrillic letter right after a digit never + // counts. A one-letter Cyrillic name and a Russian unit after a digit still do. + for hint in ["Set 1", "Définir"] { + assert_eq!(guess_words(MACINTOSH, &["90°"], hint), Guess { encoding: MACINTOSH, uncertain: false }, "hint {hint:?}"); + } + assert_eq!(guess_words(WINDOWS_1251, &["Я"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + assert_eq!(guess_words(WINDOWS_1251, &["5шт"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + } + + #[test] + fn word_initial_mac_roman_accents_read_as_mac_roman_when_the_file_writes_them() { + // Mac é (0x8E) and ü (0x9F) are windows-1252 Ž and Ÿ, capitals that could start a word: + // in a file whose own text has é or ü and no Ž or Ÿ, they are the Mac Roman letters. + assert_eq!(guess_words(MACINTOSH, &["état"], "Définir"), Guess { encoding: MACINTOSH, uncertain: false }); + assert_eq!(guess_words(MACINTOSH, &["über Ebene", "Höhe"], "Größe ändern"), Guess { encoding: MACINTOSH, uncertain: false }); + // Without such text the capital reading stands, as before: the bytes alone can't tell. + assert_eq!(guess_words(MACINTOSH, &["éclair"], "Set 1").encoding, WINDOWS_1252); + } + + #[test] + fn cyrillic_names_with_latin_words_still_read_as_1251() { + // Mostly Cyrillic letters count; a Latin brand name or a Latin i in Ukrainian doesn't + // turn the set into something else. + let g = guess_words(WINDOWS_1251, &["CMYKканал", "Копiя", "Слой 1"], "Набор 1"); + assert_eq!(g, Guess { encoding: WINDOWS_1251, uncertain: false }); + let bytes = encoded(WINDOWS_1251, &["CMYKканал"]); + assert_eq!(decode(&bytes[0], g.encoding), "CMYKканал"); + } + #[test] fn symbols_on_mac_roman_letters_stay_ambiguous() { // Known limitation: 1252 "Logo ®" is Mac Roman "Logo Æ", and ® is not counted for @@ -514,15 +796,20 @@ mod tests { #[test] fn uncertain_fallback_stays_among_the_highest_scoring_candidates() { - // Mac Roman "Ändern": Mac Roman and windows-1251 both score every non-ASCII byte - // as a letter. Windows-1252 reads "€ndern", so it must not win their unresolved tie. - let names: &[&[u8]] = &[b"\x80ndern"]; + // Mac Roman "éäé" (0x8E 0x8A 0x8E) is windows-1251 "ЋЉЋ", three Cyrillic letters in a + // row, a word of their own: both pages score every non-ASCII byte as a letter (an odd + // count of bytes, so no double-byte page reads it). Windows-1252 reads "ŽŠŽ", no + // word-initial capital among them, so it must not win their unresolved tie. (Mac Roman + // "Ändern" no longer ties: its lone Cyrillic letter glued into a Latin word, 1251 + // "Ђndern", doesn't count, #10774.) + let names: &[&[u8]] = &[b"\x8e\x8a\x8e"]; for hint in ["Set 1", "保存"] { let g = guess(names, hint); assert!([MACINTOSH, WINDOWS_1251].contains(&g.encoding), "{hint}: {g:?}"); assert!(g.uncertain, "{hint}: {g:?}"); - assert_eq!(score(g.encoding, names[0]), (1, 1)); + assert_eq!(score(g.encoding, names[0], &Default::default()), (3, 3)); } + assert_eq!(guess(&[b"\x80ndern"], "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }, "Mac Roman \"Ändern\" is certain now"); } #[test] @@ -572,4 +859,16 @@ mod tests { assert_eq!(resolve_label(" gbk ").ok(), Some(GBK)); assert_eq!(resolve_label("gb2312").ok(), Some(GBK)); } + #[test] + fn a_name_of_one_long_letter_run_is_scored_in_linear_time() { + // 64 KiB of one letter (é in windows-1252, й in windows-1251, Mac Roman's È), of + // alternating Cyrillic letters, and of "¡ab" with no "!": every character used to rescan + // the rest of the name, which took minutes. + for unit in [&[0xe9u8][..], &[0xe9, 0xea][..], &[0xa1, b'a', b'b'][..]] { + let name = unit.repeat(GUESS_SAMPLE / unit.len()); + let started = std::time::Instant::now(); + let _ = guess(&[&name], ""); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?} took {:?}", unit, started.elapsed()); + } + } } diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index 73c71bab1d..513dda76ba 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -497,22 +497,35 @@ pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { } } 16 => { - for i in (1..w).rev() { - let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); - let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); - row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_sub(b).to_be_bytes()); + // One forward pass: the previous *original* sample stays in a + // register, so nothing is re-read after being overwritten. + let mut prev = 0u16; + for s in row.as_chunks_mut::<2>().0 { + let cur = u16::from_be_bytes(*s); + *s = cur.wrapping_sub(prev).to_be_bytes(); + prev = cur; } } 32 => { scratch.clear(); scratch.resize(rb, 0); - for i in 0..w { - for k in 0..4 { - scratch[k * w + i] = row[4 * i + k]; - } + // Split into four byte planes (MSB first) and delta the whole + // plane-major row. Plane k's first delta uses the last byte of + // plane k - 1, so seed each plane's "previous" with it. + let mut prev = [0u8; 4]; + if let Some(l) = row.as_chunks::<4>().0.last() { + prev = [0, l[0], l[1], l[2]]; } - for i in (1..rb).rev() { - scratch[i] = scratch[i].wrapping_sub(scratch[i - 1]); + let (p0, rest) = scratch.split_at_mut(w); + let (p1, rest) = rest.split_at_mut(w); + let (p2, p3) = rest.split_at_mut(w); + let planes = p0.iter_mut().zip(p1.iter_mut()).zip(p2.iter_mut().zip(p3.iter_mut())); + for (s, ((o0, o1), (o2, o3))) in row.as_chunks::<4>().0.iter().zip(planes) { + *o0 = s[0].wrapping_sub(prev[0]); + *o1 = s[1].wrapping_sub(prev[1]); + *o2 = s[2].wrapping_sub(prev[2]); + *o3 = s[3].wrapping_sub(prev[3]); + prev = [s[0], s[1], s[2], s[3]]; } row.copy_from_slice(&scratch); } @@ -538,22 +551,33 @@ pub fn unpredict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { } } 16 => { - for i in 1..w { - let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); - let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); - row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_add(b).to_be_bytes()); + // Running sum carried in a register; one pass over the row. + let mut acc = 0u16; + for s in row.as_chunks_mut::<2>().0 { + acc = acc.wrapping_add(u16::from_be_bytes(*s)); + *s = acc.to_be_bytes(); } } 32 => { - for i in 1..rb { - row[i] = row[i].wrapping_add(row[i - 1]); - } scratch.clear(); scratch.extend_from_slice(row); - for i in 0..w { - for k in 0..4 { - row[4 * i + k] = scratch[k * w + i]; - } + // The byte-wise running sum runs across the four planes, so + // plane k starts at the wrapping total of planes 0..k. + let (p0, rest) = scratch.split_at(w); + let (p1, rest) = rest.split_at(w); + let (p2, p3) = rest.split_at(w); + let sum = |p: &[u8]| p.iter().fold(0u8, |a, &b| a.wrapping_add(b)); + let b1 = sum(p0); + let b2 = b1.wrapping_add(sum(p1)); + let b3 = b2.wrapping_add(sum(p2)); + let mut acc = [0u8, b1, b2, b3]; + let planes = p0.iter().zip(p1).zip(p2.iter().zip(p3)); + for (o, ((&s0, &s1), (&s2, &s3))) in row.as_chunks_mut::<4>().0.iter_mut().zip(planes) { + acc[0] = acc[0].wrapping_add(s0); + acc[1] = acc[1].wrapping_add(s1); + acc[2] = acc[2].wrapping_add(s2); + acc[3] = acc[3].wrapping_add(s3); + *o = acc; } } d => return Err(PsdError::Unsupported(format!("prediction for depth {d}"))), diff --git a/crates/psd/src/compression/tests.rs b/crates/psd/src/compression/tests.rs index a9700ded31..47d7986290 100644 --- a/crates/psd/src/compression/tests.rs +++ b/crates/psd/src/compression/tests.rs @@ -1,5 +1,117 @@ use super::*; +/// The straightforward index-based versions, kept as the oracle for the +/// register-carried rewrites in [`predict`] and [`unpredict`]. +mod reference { + use super::super::*; + + pub fn predict(buf: &mut [u8], layout: &PlaneLayout) { + let rb = layout.row_bytes(); + let w = layout.width; + let mut scratch = Vec::new(); + for row in buf.chunks_exact_mut(rb) { + match layout.depth { + 8 => { + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + 16 => { + for i in (1..w).rev() { + let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); + let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); + row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_sub(b).to_be_bytes()); + } + } + _ => { + scratch.clear(); + scratch.resize(rb, 0); + for i in 0..w { + for k in 0..4 { + scratch[k * w + i] = row[4 * i + k]; + } + } + for i in (1..rb).rev() { + scratch[i] = scratch[i].wrapping_sub(scratch[i - 1]); + } + row.copy_from_slice(&scratch); + } + } + } + } + + pub fn unpredict(buf: &mut [u8], layout: &PlaneLayout) { + let rb = layout.row_bytes(); + let w = layout.width; + let mut scratch = Vec::new(); + for row in buf.chunks_exact_mut(rb) { + match layout.depth { + 8 => { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + } + 16 => { + for i in 1..w { + let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); + let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); + row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_add(b).to_be_bytes()); + } + } + _ => { + for i in 1..rb { + row[i] = row[i].wrapping_add(row[i - 1]); + } + scratch.clear(); + scratch.extend_from_slice(row); + for i in 0..w { + for k in 0..4 { + row[4 * i + k] = scratch[k * w + i]; + } + } + } + } + } + } +} + +#[test] +fn prediction_matches_reference_on_random_planes() { + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for depth in [8u16, 16, 32] { + for width in (1..=70).chain([255, 256, 257, 1000, 1023]) { + for height in [1usize, 2, 3] { + for fill in 0..4 { + let layout = PlaneLayout { planes: 1, width, height, depth, version: Version::Psd }; + let len = layout.row_bytes() * height; + let src: Vec = (0..len) + .map(|_| match fill { + 0 => next() as u8, + 1 => 0xFF, + 2 => [0x00, 0xFF, 0x80, 0x7F][(next() & 3) as usize], + _ => (next() as u8) | 0xF0, + }) + .collect(); + let (mut want, mut got) = (src.clone(), src.clone()); + reference::predict(&mut want, &layout); + predict(&mut got, &layout).unwrap(); + assert_eq!(got, want, "predict depth {depth} width {width} height {height} fill {fill}"); + let (mut want, mut got) = (src.clone(), src); + reference::unpredict(&mut want, &layout); + unpredict(&mut got, &layout).unwrap(); + assert_eq!(got, want, "unpredict depth {depth} width {width} height {height} fill {fill}"); + } + } + } + } +} + #[test] fn checked_zip_validates_trailer_size_and_padding_budget() { let layout = PlaneLayout { planes: 1, width: 3, height: 1, depth: 8, version: Version::Psd }; diff --git a/crates/psd/src/csh.rs b/crates/psd/src/csh.rs new file mode 100644 index 0000000000..a024eb675d --- /dev/null +++ b/crates/psd/src/csh.rs @@ -0,0 +1,127 @@ +//! Photoshop custom shape sets (`.csh`, version 2). +//! +//! Container layout: Michel Mariani's public format research, +//! . +//! Geometry uses Adobe's path record format. No external implementation or shape assets copied. + +use crate::error::{PsdError, Result}; +use crate::io::Reader; +use crate::path::{PathData, RECORD_LEN}; + +/// Maximum encoded file size accepted by this reader. +pub const MAX_FILE_BYTES: usize = 64 << 20; +/// Maximum presets in a set. +pub const MAX_SHAPES: usize = 10_000; +/// Maximum path records across all presets, not per shape. +pub const MAX_RECORDS: usize = 1_000_000; +/// Maximum UTF-16 units in a preset name, including its optional trailing null. +pub const MAX_NAME_UNITS: usize = 32_768; + +/// A named shape with its reference bounds and normalized path records. +#[derive(Debug, Clone, PartialEq)] +pub struct CshShape { + /// Display name. + pub name: String, + /// Source shape identifier (usually a UUID). + pub id: String, + /// Reference rectangle: top, left, bottom, right. Coordinates may be negative. + pub bounds: [i32; 4], + /// Coordinates are fractions of the reference rectangle's width and height. + pub path: PathData, +} + +/// Read a complete set before callers modify their preset libraries. +/// Unsupported versions, truncated records and excessive aggregate sizes return errors. +pub fn parse(bytes: &[u8]) -> Result> { + if bytes.len() > MAX_FILE_BYTES { + return Err(PsdError::LimitExceeded("CSH file bytes")); + } + let mut r = Reader::new(bytes); + let signature = r.array()?; + if &signature != b"cush" { + return Err(PsdError::InvalidSignature { expected: "cush", found: signature }); + } + if r.u32()? != 2 { + return Err(PsdError::Unsupported("CSH file version (expected 2)".into())); + } + let count = r.u32()? as usize; + if count > MAX_SHAPES { + return Err(PsdError::LimitExceeded("CSH shape count")); + } + r.check_count(count as u64, 32)?; + let mut shapes = Vec::new(); + let mut remaining_records = MAX_RECORDS; + for _ in 0..count { + let units = r.u32()? as usize; + if units > MAX_NAME_UNITS { + return Err(PsdError::LimitExceeded("CSH name length")); + } + let raw = r.bytes(units * 2)?; + let mut name: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + if name.last() == Some(&0) { + name.pop(); + } + let name = String::from_utf16(&name).map_err(|_| PsdError::invalid("CSH name is not valid UTF-16"))?; + let padding = (4 - r.pos() % 4) % 4; + zero_padding(r.bytes(padding)?)?; + if r.u32()? != 1 { + return Err(PsdError::Unsupported("CSH shape version (expected 1)".into())); + } + let length = r.u32()?; + if !length.is_multiple_of(4) { + return Err(PsdError::invalid("CSH shape length must be aligned to four bytes")); + } + let mut shape = r.sub(u64::from(length))?; + let id_len = usize::from(shape.u8()?); + let id = std::str::from_utf8(shape.bytes(id_len)?).map_err(|_| PsdError::invalid("CSH identifier is not UTF-8"))?.to_owned(); + let bounds = [shape.i32()?, shape.i32()?, shape.i32()?, shape.i32()?]; + if bounds[2] <= bounds[0] || bounds[3] <= bounds[1] { + return Err(PsdError::invalid("CSH reference bounds are empty or reversed")); + } + let records = shape.remaining() / RECORD_LEN; + remaining_records = remaining_records.checked_sub(records).ok_or(PsdError::LimitExceeded("CSH aggregate path records"))?; + let path = PathData::from_bytes(shape.bytes(records * RECORD_LEN)?)?; + if shape.remaining() > 3 { + return Err(PsdError::invalid("CSH incomplete path record")); + } + zero_padding(shape.peek_rest())?; + validate_path(&path)?; + shapes.push(CshShape { name, id, bounds, path }); + } + if !r.is_empty() { + return Err(PsdError::invalid("CSH trailing data")); + } + Ok(shapes) +} + +/// Refuse nonzero alignment bytes rather than silently discarding extra payload data as padding. +fn zero_padding(bytes: &[u8]) -> Result<()> { + if bytes.iter().any(|&b| b != 0) { + return Err(PsdError::invalid("CSH nonzero padding")); + } + Ok(()) +} + +/// Enforce subpath structure more strictly than the generic PSD path reader. +/// Each length record must be followed by exactly its declared number of knots, with matching +/// open/closed selectors. Fill and clipboard records are accepted only between subpaths. +/// Rejecting inconsistent records prevents a damaged preset from silently changing geometry. +fn validate_path(path: &PathData) -> Result<()> { + let mut pending = 0u16; + let mut closed = false; + for record in &path.records { + match record.selector { + 0 | 3 if pending == 0 => { + pending = u16::from_be_bytes([record.data[0], record.data[1]]); + closed = record.selector == 0; + } + 1 | 2 | 4 | 5 if pending > 0 && closed == (record.selector <= 2) => pending -= 1, + 6..=8 if pending == 0 => {} + _ => return Err(PsdError::invalid("CSH path selectors or knot counts are inconsistent")), + } + } + if pending != 0 { + return Err(PsdError::invalid("CSH subpath is missing knots")); + } + Ok(()) +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 910d19563a..40e8ef5db3 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -11,6 +11,9 @@ use crate::layer::{LayerInfo, LayerRecord}; use crate::resources::{self, ImageResource, ResourceData, ids}; use crate::tagged::{TaggedBlock, read_blocks_with, uses_long_length, write_blocks_to}; +/// The name the reference editor gives the merged transparency among the alpha channel names. +const TRANSPARENCY_NAME: &str = "Transparency"; + /// Where the layer info was stored in the file. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -561,6 +564,11 @@ impl PsdFile { /// the merged transparency "Transparency" and lists it first among the alpha channel names, /// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global /// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too. + /// + /// A file without layer records has one when it is marked, or unless its alpha channel names + /// account for every extra channel without "Transparency" among them: a flat file from the + /// reference editor lists only its alpha and spot channels there (psd-tools + /// cmyk-alpha-spot.psd), while writers of plain RGBA files name nothing. pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -574,14 +582,23 @@ impl PsdFile { let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); li.merged_alpha || marked || (in_block && self.names_merged_transparency(self.header.channels - color)) } - None => true, + None => { + let names = self.alpha_channel_names(); + marked || names.len() != usize::from(self.header.channels - color) || names.first().is_some_and(|n| n == TRANSPARENCY_NAME) + } } } - /// `true` when the alpha channel names (resource 1045, else 1006) hold exactly `extra` names - /// and the first is "Transparency", Photoshop's name for the merged transparency. + /// `true` when the alpha channel names hold exactly `extra` names and the first is + /// "Transparency", the reference editor's name for the merged transparency. fn names_merged_transparency(&self, extra: u16) -> bool { - const NAME: &str = "Transparency"; + let names = self.alpha_channel_names(); + names.len() == usize::from(extra) && names.first().is_some_and(|n| n == TRANSPARENCY_NAME) + } + + /// The alpha channel names: the Unicode names (resource 1045), else the Pascal names (1006) + /// older writers and psd-tools store alone, decoded as MacRoman. Empty when the file names no channel. + pub fn alpha_channel_names(&self) -> Vec { let mut names = Vec::new(); if let Some(r) = self.resource(1045) { let mut at = 0usize; @@ -597,11 +614,11 @@ impl PsdFile { let mut at = 0usize; while let Some(&len) = r.data.get(at) { let Some(name) = r.data.get(at + 1..at + 1 + usize::from(len)) else { break }; - names.push(String::from_utf8_lossy(name).into_owned()); + names.push(crate::codepage::decode(name, encoding_rs::MACINTOSH)); at += 1 + usize::from(len); } } - names.len() == usize::from(extra) && names.first().is_some_and(|n| n == NAME) + names } /// Checks structural invariants that the writer relies on. @@ -1064,6 +1081,56 @@ mod write_tests { } } + /// Without layer records, the extra channels hold a merged transparency unless the alpha + /// channel names (1045, else 1006) name each of them and none first as "Transparency". + #[test] + fn a_flat_file_names_its_extra_channels() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + file.layer_info = None; + file.header.channels = 5; + file.resources.retain(|r| r.id != 1045 && r.id != 1006); + assert!(file.merged_has_alpha(), "nothing named: a plain RGBA file"); + let pascal = |names: &[&str]| names.iter().flat_map(|n| std::iter::once(n.len() as u8).chain(n.bytes())).collect::>(); + let unicode = |names: &[&str]| { + names + .iter() + .flat_map(|n| (n.encode_utf16().count() as u32).to_be_bytes().into_iter().chain(n.encode_utf16().flat_map(u16::to_be_bytes))) + .collect::>() + }; + for (names, transparency) in + [(&["Spot", "Alpha 1"][..], false), (&["Transparency", "Alpha 1"][..], true), (&["Alpha 1"][..], true), (&["A", "B", "C"][..], true)] + { + let mut p = file.clone(); + p.resources.push(ImageResource::new(1006, pascal(names))); + assert_eq!(p.merged_has_alpha(), transparency, "1006 {names:?}"); + assert_eq!(p.alpha_channel_names(), names, "1006"); + let mut u = file.clone(); + u.resources.push(ImageResource::new(1045, unicode(names))); + assert_eq!(u.merged_has_alpha(), transparency, "1045 {names:?}"); + assert_eq!(u.alpha_channel_names(), names, "1045"); + } + // A merged-transparency marker wins over a complete list of names. + let mut marked = file.clone(); + marked.resources.push(ImageResource::new(1006, pascal(&["Spot", "Alpha 1"]))); + marked.global_blocks.push(TaggedBlock::new(*b"Mt32", Vec::new())); + assert!(marked.merged_has_alpha()); + // Truncated names stop at the damage. + let mut cut = file.clone(); + cut.resources.push(ImageResource::new(1006, vec![9, b'a'])); + assert!(cut.alpha_channel_names().is_empty()); + assert!(cut.merged_has_alpha()); + } + + #[test] + fn pascal_channel_names_use_mac_roman_and_unicode_takes_precedence() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + file.resources.retain(|r| !matches!(r.id, 1006 | 1045)); + file.resources.push(ImageResource::new(1006, b"\x04caf\x8e\x03x".to_vec())); + assert_eq!(file.alpha_channel_names(), ["café"]); + file.resources.push(ImageResource::new(1045, vec![0, 0, 0, 1, 0, b'Z'])); + assert_eq!(file.alpha_channel_names(), ["Z"]); + } + /// A positive layer count plus a merged-transparency marker still makes the first extra /// channel the transparency, for the specification's keys and for the `MTrn` Photoshop /// writes in a layered TIFF. diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 3050994216..5d0422f13a 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -43,6 +43,7 @@ pub mod abr; pub mod aco; pub mod act; +pub mod animation; pub mod ase; pub mod asl; pub mod atn; @@ -50,6 +51,7 @@ pub mod blend; pub mod builder; mod codepage; pub mod compression; +pub mod csh; pub mod descriptor; pub mod error; pub mod file; diff --git a/crates/psd/src/metadata.rs b/crates/psd/src/metadata.rs index 24d2eebca6..8e4565fe3f 100644 --- a/crates/psd/src/metadata.rs +++ b/crates/psd/src/metadata.rs @@ -27,6 +27,18 @@ pub struct MetadataItem { } impl MetadataItem { + /// Typed animation descriptor for `mlst` (sparse layer frame states) or `tmln` + /// (layer timeline tracks). Other metadata, including `cust` / XMP, is not interpreted. + /// The original item stays unchanged; inheritance and track evaluation belong to the importer. + pub fn animation_descriptor(&self) -> Option> { + matches!(&self.key, b"mlst" | b"tmln").then(|| { + if self.signature != *b"8BIM" { + return Err(PsdError::InvalidSignature { expected: "8BIM animation metadata", found: self.signature }); + } + crate::animation::parse_descriptor(&self.data) + }) + } + /// A new item; `data` is zero-padded to an even length as Photoshop writes it. pub fn new(key: [u8; 4], mut data: Vec) -> Self { if data.len() % 2 == 1 { diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs index 33ce147101..1ac69ed25d 100644 --- a/crates/psd/src/resources.rs +++ b/crates/psd/src/resources.rs @@ -28,6 +28,8 @@ pub mod ids { pub const EXIF: u16 = 1058; /// XMP metadata. pub const XMP: u16 = 1060; + /// Document timeline settings (versioned descriptor). + pub const TIMELINE_INFO: u16 = 1075; } /// One image resource block. @@ -169,6 +171,10 @@ pub enum ResourceData<'a> { Exif(&'a [u8]), /// 1060: XMP packet (UTF-8). Xmp(&'a [u8]), + /// 1075: document timeline settings; tracks and rational times remain descriptor data. + TimelineInfo(crate::VersionedDescriptor), + /// A `mani` legacy animation resource in the plug-in resource id range (4000..=4999). + Animation(crate::animation::AnimationResource<'a>), } impl ImageResource { @@ -199,6 +205,8 @@ impl ImageResource { ids::VERSION_INFO => VersionInfo::from_bytes(d).map(ResourceData::VersionInfo), ids::EXIF => Ok(ResourceData::Exif(d)), ids::XMP => Ok(ResourceData::Xmp(d)), + ids::TIMELINE_INFO => crate::animation::parse_descriptor(d).map(ResourceData::TimelineInfo), + 4000..=4999 if d.starts_with(b"mani") => crate::animation::AnimationResource::from_bytes(d).map(ResourceData::Animation), _ => return None, }) } diff --git a/crates/psd/tests/common/csh_fixture.rs b/crates/psd/tests/common/csh_fixture.rs new file mode 100644 index 0000000000..aef1e31da4 --- /dev/null +++ b/crates/psd/tests/common/csh_fixture.rs @@ -0,0 +1,49 @@ +pub fn triangle_csh() -> Vec { + polygon_csh(&[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]) +} + +pub fn polygon_csh(polygons: &[&[(f64, f64)]]) -> Vec { + named_polygon_csh("Triangle", polygons) +} + +pub fn named_polygon_csh(label: &str, polygons: &[&[(f64, f64)]]) -> Vec { + let mut out = b"cush".to_vec(); + out.extend_from_slice(&2u32.to_be_bytes()); + out.extend_from_slice(&1u32.to_be_bytes()); + let name: Vec = label.encode_utf16().chain(std::iter::once(0)).collect(); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + for unit in &name { + out.extend_from_slice(&unit.to_be_bytes()); + } + if !name.len().is_multiple_of(2) { + out.extend_from_slice(&[0, 0]); + } + out.extend_from_slice(&1u32.to_be_bytes()); + let mut body = vec![36]; + body.extend_from_slice(b"00000000-0000-4000-8000-000000000001"); + for bound in [0i32, 0, 100, 200] { + body.extend_from_slice(&bound.to_be_bytes()); + } + for selector in [6u16, 8] { + body.extend_from_slice(&selector.to_be_bytes()); + body.extend_from_slice(&[0; 24]); + } + for polygon in polygons { + body.extend_from_slice(&0u16.to_be_bytes()); + body.extend_from_slice(&(polygon.len() as u16).to_be_bytes()); + body.extend_from_slice(&[0; 22]); + for &(x, y) in *polygon { + body.extend_from_slice(&2u16.to_be_bytes()); + for _ in 0..3 { + body.extend_from_slice(&((y * 16_777_216.0) as i32).to_be_bytes()); + body.extend_from_slice(&((x * 16_777_216.0) as i32).to_be_bytes()); + } + } + } + while !body.len().is_multiple_of(4) { + body.push(0); + } + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(&body); + out +} diff --git a/crates/psd/tests/csh.rs b/crates/psd/tests/csh.rs new file mode 100644 index 0000000000..c220d266ae --- /dev/null +++ b/crates/psd/tests/csh.rs @@ -0,0 +1,101 @@ +use photocraft_psd::PsdError; +use photocraft_psd::csh; +use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath}; + +fn fixture(names: &[&str]) -> Vec { + let mut bytes = b"cush".to_vec(); + bytes.extend_from_slice(&2u32.to_be_bytes()); + bytes.extend_from_slice(&(names.len() as u32).to_be_bytes()); + for name in names { + let units: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + bytes.extend_from_slice(&(units.len() as u32).to_be_bytes()); + for unit in units { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + while !bytes.len().is_multiple_of(4) { + bytes.push(0); + } + bytes.extend_from_slice(&1u32.to_be_bytes()); + let mut body = vec![2, b'i', b'd']; + for bound in [-10i32, -20, 90, 180] { + body.extend_from_slice(&bound.to_be_bytes()); + } + let knot = PsdKnot { linked: true, pre: (0.125, 0.25), anchor: (0.5, 0.5), post: (0.75, 0.875) }; + let path = PathData::from_subpaths(&[PsdSubpath { closed: false, operation: 0, knots: vec![knot] }], false); + body.extend_from_slice(&path.to_bytes()); + while !body.len().is_multiple_of(4) { + body.push(0); + } + bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&body); + } + bytes +} + +#[test] +fn reads_multiple_names_padding_signed_bounds_and_cubic_controls() { + let bytes = fixture(&["Even", "Odd", "形😀"]); + let shapes = csh::parse(&bytes).unwrap(); + assert_eq!(shapes.iter().map(|s| s.name.as_str()).collect::>(), ["Even", "Odd", "形😀"]); + for shape in shapes { + assert_eq!(shape.bounds, [-10, -20, 90, 180]); + assert_eq!(shape.id, "id"); + let subpaths = shape.path.subpaths(); + assert_eq!(subpaths.len(), 1); + assert!(!subpaths[0].closed); + let knot = subpaths[0].knots[0]; + assert!(knot.linked); + assert_eq!(knot.pre, (0.125, 0.25)); + assert_eq!(knot.anchor, (0.5, 0.5)); + assert_eq!(knot.post, (0.75, 0.875)); + } +} + +#[test] +fn rejects_every_truncated_prefix_without_panicking() { + let bytes = fixture(&["One", "Two"]); + for end in 0..bytes.len() { + assert!(csh::parse(&bytes[..end]).is_err(), "accepted truncated prefix {end}"); + } +} + +#[test] +fn rejects_unsupported_headers_excessive_counts_and_invalid_unicode() { + let bytes = fixture(&["A"]); + for (offset, value) in [(4, 3u32), (8, u32::MAX), (12, u32::MAX), (24, u32::MAX)] { + let mut broken = bytes.clone(); + broken[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); + assert!(csh::parse(&broken).is_err(), "offset {offset}"); + } + // The count and name limits answer before the reader runs out of bytes, so a removed limit + // cannot hide behind a truncation error. One past each limit is refused by the limit itself. + for (offset, value, limit) in [ + (8, u32::MAX, "CSH shape count"), + (8, csh::MAX_SHAPES as u32 + 1, "CSH shape count"), + (12, u32::MAX, "CSH name length"), + (12, csh::MAX_NAME_UNITS as u32 + 1, "CSH name length"), + ] { + let mut broken = bytes.clone(); + broken[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); + assert_eq!(csh::parse(&broken), Err(PsdError::LimitExceeded(limit)), "offset {offset} value {value}"); + } + let mut broken = bytes.clone(); + broken[16..18].copy_from_slice(&0xd800u16.to_be_bytes()); + assert!(csh::parse(&broken).is_err()); + let mut trailing = bytes; + trailing.push(0); + assert!(csh::parse(&trailing).is_err()); +} + +#[test] +fn rejects_missing_knots_and_mismatched_open_closed_records() { + let bytes = fixture(&["A"]); + // Header/name/version/length = 28; id = 3; bounds = 16; two fill records = 52. + let length_record = 28 + 3 + 16 + 52; + let mut missing = bytes.clone(); + missing[length_record + 2..length_record + 4].copy_from_slice(&2u16.to_be_bytes()); + assert!(csh::parse(&missing).is_err()); + let mut mismatched = bytes; + mismatched[length_record..length_record + 2].copy_from_slice(&0u16.to_be_bytes()); + assert!(csh::parse(&mismatched).is_err()); +} diff --git a/crates/psd/tests/it/animation.rs b/crates/psd/tests/it/animation.rs new file mode 100644 index 0000000000..ac96b6a0b9 --- /dev/null +++ b/crates/psd/tests/it/animation.rs @@ -0,0 +1,262 @@ +//! Synthetic only: no personal PSDs, paths, or copied descriptor payloads. + +use photocraft_psd::animation::{AnimationRecord, AnimationResource, frame_info, frame_sets, layer_frame_states, parse_descriptor}; +use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; +use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd}; +use photocraft_psd::{ImageResource, PsdBuilder, PsdFile, ResourceData, TaggedBlock, Version}; +use proptest::prelude::*; + +fn descriptor() -> VersionedDescriptor { + VersionedDescriptor::new( + Descriptor::default() + .with("AFSt", Value::Integer(7)) + .with( + "FrIn", + Value::List(vec![ + Value::Descriptor( + Descriptor::default().with("FrID", Value::Integer(-3)).with("FrDl", Value::Integer(3)).with("future", Value::Boolean(true)), + ), + Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(21)).with("FrDl", Value::Integer(53))), + ]), + ) + .with( + "FSts", + Value::List(vec![Value::Descriptor( + Descriptor::default() + .with("FsID", Value::Integer(7)) + .with("AFrm", Value::Integer(0)) + .with("FsFr", Value::List(vec![Value::Integer(21), Value::Integer(-3)])) + .with("future", Value::RawData(vec![1, 2, 3])), + )]), + ), + ) +} + +fn resource(data: &[u8]) -> AnimationResource<'_> { + AnimationResource { + records: vec![ + AnimationRecord { key: *b"AnDs", data }, + AnimationRecord { key: *b"Roll", data: &[0; 8] }, + AnimationRecord { key: *b"next", data: &[9, 8, 7] }, + ], + } +} + +#[test] +fn raw_roundtrip_and_typed_views_preserve_order_and_unknown_data() { + let data = descriptor().to_bytes(); + let bytes = resource(&data).to_bytes().unwrap(); + let parsed = AnimationResource::from_bytes(&bytes).unwrap(); + assert_eq!(parsed, resource(&data)); + let d = parsed.records[0].descriptor().unwrap().unwrap(); + assert_eq!(d, descriptor()); + let frames = frame_info(&d.descriptor).unwrap(); + assert_eq!(frames.iter().map(|f| (f.id, f.delay_centiseconds)).collect::>(), [(-3, Some(3)), (21, Some(53))]); + assert_eq!(frames[0].descriptor.get("future"), Some(&Value::Boolean(true))); + let sets = frame_sets(&d.descriptor).unwrap(); + assert_eq!(sets[0].frame_ids, [21, -3], "playback order is not FrIn order or sorted id order"); + assert_eq!(sets[0].id, 7); + assert_eq!(sets[0].active_frame, Some(0)); + assert_eq!(sets[0].descriptor.get("future"), Some(&Value::RawData(vec![1, 2, 3]))); + assert!(parsed.records[1].descriptor().is_none()); + assert_eq!(parsed.to_bytes().unwrap(), bytes); +} + +#[test] +fn plugin_signature_not_a_single_resource_id_selects_the_parser() { + let data = descriptor().to_bytes(); + let bytes = resource(&data).to_bytes().unwrap(); + for id in [4000, 4003, 4999] { + let r = ImageResource::new(id, bytes.clone()); + assert!(matches!(r.parsed(), Some(Ok(ResourceData::Animation(_))))); + } + for id in [3999, 5000] { + assert!(ImageResource::new(id, bytes.clone()).parsed().is_none()); + } + assert!(ImageResource::new(4003, b"different plugin".to_vec()).parsed().is_none()); + assert!(matches!(ImageResource::new(4003, b"mani".to_vec()).parsed(), Some(Err(_)))); + assert!(matches!( + ImageResource::new(1075, VersionedDescriptor::new(Descriptor::default().with("Vrsn", Value::Integer(1))).to_bytes()).parsed(), + Some(Ok(ResourceData::TimelineInfo(_))) + )); +} + +#[test] +fn metadata_views_do_not_touch_xmp_or_other_items() { + let d = VersionedDescriptor::new(Descriptor::default().with("LaID", Value::Integer(42)).with( + "LaSt", + Value::List(vec![Value::Descriptor( + Descriptor::default() + .with("FrLs", Value::List(vec![Value::Integer(-3)])) + .with("blendOptions", Value::Descriptor(Descriptor::default().with("Opct", Value::Double(12.5)))), + )]), + )); + let items = vec![ + MetadataItem::new(*b"cust", vec![7, 9, 11]), + MetadataItem::new(*b"mlst", d.to_bytes()), + MetadataItem::new(*b"tmln", VersionedDescriptor::new(Descriptor::default().with("trackList", Value::List(vec![]))).to_bytes()), + MetadataItem::new(*b"next", vec![255; 4]), + ]; + let raw = write_shmd(&items); + let parsed = parse_shmd(&raw).unwrap(); + assert!(parsed[0].animation_descriptor().is_none()); + let layer_descriptor = parsed[1].animation_descriptor().unwrap().unwrap(); + assert_eq!(layer_descriptor, d); + let states = layer_frame_states(&layer_descriptor.descriptor).unwrap(); + assert_eq!(states.layer_id, 42); + assert_eq!(states.states[0].frame_ids, [-3]); + assert_eq!(states.states[0].descriptor.get("enab"), None, "missing visibility is not false"); + assert_eq!(states.states[0].descriptor.get("blendOptions"), Some(&Value::Descriptor(Descriptor::default().with("Opct", Value::Double(12.5))))); + assert!(parsed[2].animation_descriptor().unwrap().is_ok()); + assert!(parsed[3].animation_descriptor().is_none()); + assert_eq!(write_shmd(&parsed), raw); + let mut bad = parsed[1].clone(); + bad.signature = *b"nope"; + assert!(bad.animation_descriptor().unwrap().is_err()); +} + +#[test] +fn psd_and_psb_keep_animation_resources_and_metadata_byte_stable() { + let data = descriptor().to_bytes(); + let raw = resource(&data).to_bytes().unwrap(); + for version in [Version::Psd, Version::Psb] { + let mut file = PsdBuilder::new(1, 1).version(version).build().unwrap(); + file.resources.push(ImageResource::new(4003, raw.clone())); + file.global_blocks.push(TaggedBlock::new(*b"shmd", write_shmd(&[MetadataItem::new(*b"mlst", data.clone())]))); + let bytes = file.to_bytes().unwrap(); + let back = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(back.to_bytes().unwrap(), bytes); + let animation = back.resource(4003).unwrap(); + assert!(matches!(animation.parsed(), Some(Ok(ResourceData::Animation(_))))); + } +} + +#[test] +fn every_truncation_and_hostile_envelope_length_is_rejected() { + let data = descriptor().to_bytes(); + let bytes = resource(&data).to_bytes().unwrap(); + for end in 0..bytes.len() { + assert!(AnimationResource::from_bytes(&bytes[..end]).is_err(), "prefix {end}"); + } + for at in [8, 20] { + for value in [0, 1, u32::MAX] { + let mut bad = bytes.clone(); + bad[at..at + 4].copy_from_slice(&value.to_be_bytes()); + assert!(AnimationResource::from_bytes(&bad).is_err(), "length {at} = {value}"); + } + } + for at in [0, 4, 12] { + let mut bad = bytes.clone(); + bad[at] = b'?'; + assert!(AnimationResource::from_bytes(&bad).is_err()); + } + let mut trailing = bytes.clone(); + trailing.push(0); + assert!(AnimationResource::from_bytes(&trailing).is_err()); +} + +#[test] +fn descriptor_padding_truncation_and_unknown_versions() { + let raw = descriptor().to_bytes(); + for end in 0..raw.len() { + assert!(parse_descriptor(&raw[..end]).is_err(), "descriptor prefix {end}"); + } + for padding in 0..=3 { + let mut padded = raw.clone(); + padded.extend(vec![0; padding]); + assert_eq!(parse_descriptor(&padded).unwrap(), descriptor()); + } + for tail in [vec![0; 4], vec![1], vec![0, 2]] { + let mut bad = raw.clone(); + bad.extend(tail); + assert!(parse_descriptor(&bad).is_err()); + } + let mut future = raw.clone(); + future[..4].copy_from_slice(&17u32.to_be_bytes()); + let bytes = resource(&future).to_bytes().unwrap(); + let r = AnimationResource::from_bytes(&bytes).unwrap(); + assert!(r.records[0].descriptor().unwrap().is_err()); + assert_eq!(r.to_bytes().unwrap(), bytes, "unsupported typed version must survive raw"); +} + +#[test] +fn absent_delays_and_duplicate_ids_are_not_silently_repaired() { + let d = Descriptor::default().with( + "FrIn", + Value::List(vec![ + Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(4))), + Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(4)).with("FrDl", Value::Integer(0))), + ]), + ); + let frames = frame_info(&d).unwrap(); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].delay_centiseconds, None); + assert_eq!(frames[1].delay_centiseconds, Some(0)); + let bytes = + AnimationResource { records: vec![AnimationRecord { key: *b"next", data: &[] }, AnimationRecord { key: *b"next", data: &[1] }] }.to_bytes().unwrap(); + assert_eq!(AnimationResource::from_bytes(&bytes).unwrap().to_bytes().unwrap(), bytes); +} + +#[test] +fn wrong_frame_and_set_types_return_errors() { + for value in [Value::Boolean(false), Value::List(vec![Value::Integer(1)]), Value::List(vec![Value::Descriptor(Descriptor::default())])] { + assert!(frame_info(&Descriptor::default().with("FrIn", value.clone())).is_err()); + assert!(frame_sets(&Descriptor::default().with("FSts", value)).is_err()); + } + for delay in [Value::Integer(-1), Value::Double(3.0)] { + let frame = Descriptor::default().with("FrID", Value::Integer(1)).with("FrDl", delay); + assert!(frame_info(&Descriptor::default().with("FrIn", Value::List(vec![Value::Descriptor(frame)]))).is_err()); + } + for (key, value) in [("FsID", Value::Boolean(false)), ("AFrm", Value::Double(0.0)), ("FsFr", Value::List(vec![Value::Boolean(true)]))] { + let mut set = Descriptor::default().with("FsID", Value::Integer(1)).with("FsFr", Value::List(vec![])); + set.items.retain(|(id, _)| !id.is(key)); + set = set.with(key, value); + assert!(frame_sets(&Descriptor::default().with("FSts", Value::List(vec![Value::Descriptor(set)]))).is_err()); + } +} + +#[test] +fn sparse_layer_states_retain_missing_properties_and_multiple_references() { + let first = Descriptor::default() + .with("FrLs", Value::List(vec![Value::Integer(21), Value::Integer(-3)])) + .with("enab", Value::Boolean(true)) + .with("Ofst", Value::Descriptor(Descriptor::default().with("Hrzn", Value::Integer(-7)).with("Vrtc", Value::Integer(12)))); + let second = Descriptor::default().with("FrLs", Value::List(vec![Value::Integer(99)])); + let d = Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Descriptor(first.clone()), Value::Descriptor(second)])); + let view = layer_frame_states(&d).unwrap(); + assert_eq!(view.states[0].frame_ids, [21, -3]); + assert_eq!(view.states[0].descriptor, &first); + assert_eq!(view.states[1].descriptor.get("Ofst"), None); + assert_eq!(view.states[1].descriptor.get("enab"), None); + for d in [ + Descriptor::default(), + Descriptor::default().with("LaID", Value::Boolean(true)), + Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::Boolean(true)), + Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Integer(0)])), + ] { + assert!(layer_frame_states(&d).is_err()); + } + for state in [ + Descriptor::default(), + Descriptor::default().with("FrLs", Value::Integer(0)), + Descriptor::default().with("FrLs", Value::List(vec![Value::Boolean(true)])), + ] { + let d = Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Descriptor(state)])); + assert!(layer_frame_states(&d).is_err()); + } +} + +proptest! { + #[test] + fn arbitrary_animation_bytes_never_panic(data in proptest::collection::vec(any::(), 0..2048)) { + let _ = AnimationResource::from_bytes(&data); + let _ = parse_descriptor(&data); + } + + #[test] + fn opaque_record_roundtrip(data in proptest::collection::vec(any::(), 0..2048), key in any::<[u8; 4]>()) { + let r = AnimationResource { records: vec![AnimationRecord { key, data: &data }] }; + let bytes = r.to_bytes().unwrap(); + prop_assert_eq!(AnimationResource::from_bytes(&bytes).unwrap(), r); + } +} diff --git a/crates/psd/tests/it/corpus.rs b/crates/psd/tests/it/corpus.rs index 90abb4fefd..a61f911e23 100644 --- a/crates/psd/tests/it/corpus.rs +++ b/crates/psd/tests/it/corpus.rs @@ -19,6 +19,64 @@ fn collect(dir: &Path, out: &mut Vec) { } } +/// The existing pinned ag-psd corpus includes public animation cases. Exercise typed syntax +/// against those without adding personal files or new corpus pins. +#[test] +fn corpus_animation_syntax() { + use photocraft_psd::ResourceData; + use photocraft_psd::animation::{frame_info, frame_sets, layer_frame_states}; + use photocraft_psd::metadata::parse_shmd; + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd/ag-psd/read-write"); + for (case, delay) in [("animation-frame", Some(30)), ("animation-offset", Some(50)), ("animation-effects", None)] { + for name in ["src.psd", "expected.psd"] { + let path = root.join(case).join(name); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}; run cargo xtask corpus --psd", path.display())); + let psd = PsdFile::from_bytes(&bytes).unwrap(); + let mut found = 0; + let mut decoded_descriptors = 0; + for resource in &psd.resources { + if let Some(Ok(ResourceData::Animation(animation))) = resource.parsed() { + found += 1; + assert_eq!(animation.to_bytes().unwrap(), resource.data); + for record in &animation.records { + if let Some(d) = record.descriptor() { + let d = d.unwrap(); + decoded_descriptors += 1; + let frames = frame_info(&d.descriptor).unwrap(); + assert_eq!(frames.len(), 2, "{case}/{name}"); + assert!(frames.iter().all(|f| f.delay_centiseconds == delay), "{case}/{name}"); + let sets = frame_sets(&d.descriptor).unwrap(); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0].frame_ids.len(), 2); + assert!(sets[0].frame_ids.iter().all(|id| frames.iter().any(|f| f.id == *id))); + } + } + } + } + assert_eq!(found, 1, "{case}/{name}"); + assert_eq!(decoded_descriptors, 1, "expected one decoded AnDs descriptor in {case}/{name}"); + let mut state_layers = 0; + for layer in psd.layers() { + for block in &layer.blocks { + if block.key == *b"shmd" { + for item in parse_shmd(&block.data).unwrap() { + if item.key == *b"mlst" { + let d = item.animation_descriptor().unwrap().unwrap(); + let states = layer_frame_states(&d.descriptor).unwrap(); + assert!(!states.states.is_empty()); + state_layers += 1; + } + } + } + } + } + assert_eq!(state_layers, 2, "{case}/{name}"); + assert_eq!(psd.to_bytes().unwrap(), bytes); + } + } +} + /// Files known to be invalid upstream, with the reason. They are reported but /// do not fail the test. const KNOWN_BAD: &[(&str, &str)] = diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs index db445f849c..fef8708849 100644 --- a/crates/psd/tests/it/main.rs +++ b/crates/psd/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod animation; mod atn_contract; mod atn_v12; mod builder; diff --git a/crates/psd/tests/it/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs index 49ad412e7c..1823900372 100644 --- a/crates/psd/tests/it/roundtrip.rs +++ b/crates/psd/tests/it/roundtrip.rs @@ -313,3 +313,21 @@ fn spec_order_density_only_mask_round_trips_as_a_model() { assert_eq!(g.layers()[0].decode_channel(-2, 8, Version::Psd).unwrap(), vec![64]); assert_eq!(g.to_bytes().unwrap(), b, "byte stability"); } + +/// Embedded data (smart-object files, and the like) is large next to the headers, so the output +/// is reserved at its exact size up front: no growth by doubling, which on the web is a single +/// unrecoverable multi-gigabyte `realloc` (#10024). +#[test] +fn to_bytes_reserves_the_whole_output_up_front() { + for version in [Version::Psd, Version::Psb] { + let mut f = testgen::small(version, Compression::Rle); + f.global_blocks.push(TaggedBlock::new(*b"Q3Dx", vec![0x5a; 3 << 20])); + f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"Vid!", vec![0xab; 1 << 20])); + let b = f.to_bytes().unwrap(); + assert_eq!(b.capacity(), b.len(), "{version:?}: the output buffer grew past its reserve"); + assert_eq!(b.len() as u64, f.encoded_len().unwrap()); + let p = PsdFile::from_bytes(&b).unwrap(); + assert_eq!(p, f); + assert_eq!(p.to_bytes().unwrap(), b); + } +} diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 058e275b60..f319dcee81 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -15,11 +15,19 @@ crc32fast = "1" lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode"] } # Native only: converting a region's tiles is independent per tile, so it runs on all cores; -# sysinfo reads the physical RAM size for the memory limit and the scratch volume's free space. +# sysinfo reads the physical RAM size for the memory limit. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } +sysinfo = { version = "=0.40.1", default-features = false, features = ["system"] } + +# macOS reports capacity including purgeable space only through sysinfo's volume properties. +[target.'cfg(target_os = "macos")'.dependencies] sysinfo = { version = "=0.40.1", default-features = false, features = ["system", "disk"] } +# statvfs on the scratch directory. +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } + # Available physical memory and commit headroom (memory/system.rs) through winsafe's safe # `GlobalMemoryStatusEx` wrapper. [target.'cfg(windows)'.dependencies] diff --git a/crates/raster/src/headroom.rs b/crates/raster/src/headroom.rs new file mode 100644 index 0000000000..9e38b58f31 --- /dev/null +++ b/crates/raster/src/headroom.rs @@ -0,0 +1,167 @@ +//! How much memory can still be allocated, asked of the allocator itself. +//! +//! On the web (`wasm32-unknown-unknown`) a failed infallible allocation aborts the module and the +//! page freezes with no message, and stable Rust cannot turn that into an error. The allocations +//! made with `try_reserve*` do survive, though: the allocator hands back null when the browser +//! refuses to grow the linear memory, and the caller gets an error. So before a big operation +//! (place, resize, re-render a smart object, save) the operation asks [`ensure`] whether its +//! estimated need can be allocated right now, and fails with a message the user can act on +//! instead of trapping part-way. +//! +//! The answer is a trial reservation: one `Vec::try_reserve_exact`, freed at once. It sees the +//! real ceiling of this browser and device (a phone may stop well below 4 GiB) and the +//! fragmentation of the heap, costs nothing per allocation, and needs no allocator wrapper. The +//! freed block returns to the allocator's free list, so the operation reuses it. A reservation +//! is not written to, so the browser commits no physical pages for it. +//! +//! [`below`], which the low-memory warning asks every few seconds, does not reserve anything: +//! wasm linear memory never shrinks, so a periodic trial would raise the module's high-water +//! mark by the amount it asks about, for nothing. It reads the size of linear memory instead. +//! +//! Native targets overcommit, so the question has no useful answer there: every check passes and +//! desktop behaviour is unchanged. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::AllocationError; + +/// Room kept free after an operation's own need: frames, egui, notices and the error path. +pub const RESERVE: u64 = 128 << 20; + +/// Marks [`simulate_available`] as off. +const OFF: u64 = u64::MAX; + +static SIMULATED: AtomicU64 = AtomicU64::new(OFF); + +/// Pretends that only `bytes` can be allocated (`None` stops pretending). The simulation is +/// process-wide and applies on every target. It is for tests and the web build's `?free-mb` +/// diagnostic flag only, so the gates and the low-memory warning can be seen without allocating +/// gigabytes. +pub fn simulate_available(bytes: Option) { + SIMULATED.store(bytes.unwrap_or(OFF), Ordering::SeqCst); +} + +fn simulated() -> Option { + match SIMULATED.load(Ordering::SeqCst) { + OFF => None, + n => Some(n), + } +} + +/// The most linear memory a wasm32 module can address. +#[cfg(target_arch = "wasm32")] +const WASM32_CEILING: u64 = 4 << 30; + +/// `true` when `bytes` can be allocated in one piece right now. Always `true` off the web. +fn can_allocate(bytes: u64) -> bool { + if let Some(available) = simulated() { + return bytes <= available; + } + trial(bytes) +} + +#[cfg(target_arch = "wasm32")] +fn trial(bytes: u64) -> bool { + let Ok(bytes) = usize::try_from(bytes) else { return false }; + let mut probe: Vec = Vec::new(); + probe.try_reserve_exact(bytes).is_ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn trial(_bytes: u64) -> bool { + true +} + +/// `Ok` when `need` bytes (plus [`RESERVE`]) can be allocated right now. On the web, this tries +/// to reserve that much and frees it at once. On other targets it always passes (unless a +/// simulation is set). +pub fn ensure(need: u64) -> Result<(), AllocationError> { + let total = need.saturating_add(RESERVE); + let bytes = usize::try_from(total).map_err(|_| AllocationError::SizeOverflow)?; + if can_allocate(total) { Ok(()) } else { Err(AllocationError::NotEnoughMemory { bytes }) } +} + +/// `true` while the module's linear memory is within `bytes` of the 4 GiB wasm32 ceiling (web +/// only; always `false` elsewhere unless a simulation is set). Allocates nothing, unlike +/// [`ensure`]. Linear memory is a high-water mark, so this errs towards `true`: room freed inside +/// the allocator after a peak doesn't count. A browser that stops growing memory below 4 GiB isn't +/// seen either; the operation checks still are. +pub fn below(bytes: u64) -> bool { + if let Some(available) = simulated() { + return bytes > available; + } + linear_memory_left().is_some_and(|left| left < bytes) +} + +#[cfg(target_arch = "wasm32")] +fn linear_memory_left() -> Option { + let used = (core::arch::wasm32::memory_size::<0>() as u64).saturating_mul(64 << 10); + Some(WASM32_CEILING.saturating_sub(used)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn linear_memory_left() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static LOCK: Mutex<()> = Mutex::new(()); + + /// Serializes the tests (the simulation is global) and resets it when each one ends. + struct Guard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + + impl Guard { + fn new() -> Self { + let guard = Guard(LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + simulate_available(None); + guard + } + } + + impl Drop for Guard { + fn drop(&mut self) { + simulate_available(None); + } + } + + const MIB: u64 = 1 << 20; + + #[test] + fn passes_on_native_without_simulation() { + let _g = Guard::new(); + assert!(ensure(10 << 30).is_ok()); + assert!(!below(64 << 30)); + } + + #[test] + fn simulated_room_counts_the_reserve() { + let _g = Guard::new(); + // 300 MiB of room: a 10 MiB need plus the 128 MiB reserve fits, a 200 MiB need does not. + simulate_available(Some(300 * MIB)); + assert!(ensure(10 * MIB).is_ok()); + assert!(matches!(ensure(200 * MIB), Err(AllocationError::NotEnoughMemory { .. }))); + assert!(!below(300 * MIB)); + assert!(below(301 * MIB)); + } + + #[test] + fn huge_needs_do_not_overflow() { + let _g = Guard::new(); + let _ = ensure(u64::MAX); + simulate_available(Some(100 * MIB)); + assert!(ensure(u64::MAX).is_err()); + } + + #[test] + fn simulation_can_be_switched_off() { + let _g = Guard::new(); + simulate_available(Some(0)); + assert!(ensure(0).is_err()); + simulate_available(None); + assert!(ensure(0).is_ok()); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 77121d0e7d..4dd682a773 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -7,13 +7,16 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod headroom; mod interrupt; pub use interrupt::{Cancelled, Interrupt}; pub mod scratch; +mod shift; +pub use shift::TranslateSteps; use std::collections::BTreeMap; use std::fmt; -use std::ops::{Deref, DerefMut}; +use std::ops::{Bound, Deref, DerefMut}; use std::sync::{Arc, RwLockReadGuard, RwLockWriteGuard}; use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, read_samples, write_sample}; @@ -46,6 +49,30 @@ impl fmt::Display for AllocationError { } impl std::error::Error for AllocationError {} + +/// Failure to prepare an interruptible row write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RowWriteError { + Allocation(AllocationError), + Cancelled(Cancelled), +} + +impl From for RowWriteError { + fn from(error: AllocationError) -> Self { + Self::Allocation(error) + } +} + +impl fmt::Display for RowWriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Allocation(error) => write!(f, "{error}"), + Self::Cancelled(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for RowWriteError {} pub mod memory; mod scratch_file; pub mod spill; @@ -249,6 +276,20 @@ fn last_difference(a: &[u8], b: &[u8]) -> Option { x.iter().zip(y).rposition(|(p, q)| p != q).map(|i| k * DIFF_BLOCK + i) } +/// Where [`Surface::prune_batch`] resumes: after the last tile it visited. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PruneCursor { + after: Option, + done: bool, +} + +impl PruneCursor { + /// True once every tile has been visited. + pub fn is_done(&self) -> bool { + self.done + } +} + #[derive(Clone, Debug)] pub struct Surface { format: PixelFormat, @@ -400,6 +441,61 @@ fn copy_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, bytes: &[u8], w: us tiles.iter_mut().try_for_each(|(tc, t)| copy_tile(*tc, t, r, bytes, w, bpp)) } +/// Fills one tile of [`Surface::try_write_rows_with`]: makes it writable, then calls `fill` for +/// each row of `r` inside it. +fn fill_tile(slot: &mut (TileCoord, Option>), r: Rect, format: &PixelFormat, default_pixel: &[u8], fill: &F, check: &C) -> Result<(), E> +where + F: Fn(i32, i32, &mut [u8]) + Sync, + C: Fn() -> Result<(), E> + Sync, + E: From + Send, +{ + check()?; + let (tc, tile) = slot; + match tile { + None => *tile = Some(Arc::new(Tile::try_filled(format, default_pixel)?)), + Some(t) => { + if Arc::get_mut(t).is_none() { + *t = Arc::new(t.try_clone()?); + } + } + } + let data = tile.as_mut().and_then(Arc::get_mut).ok_or(AllocationError::SizeOverflow)?; + let mut data = data.bytes_mut(); + let bpp = format.bytes_per_pixel(); + let tr = tc.rect().intersect(&r); + let span = tr.width() as usize * bpp; + for y in tr.y0..tr.y1 { + check()?; + let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize; + if let Some(dst) = row.checked_mul(bpp).and_then(|o| data.get_mut(o..o.checked_add(span)?)) { + fill(y, tr.x0, dst); + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn fill_tiles(tiles: &mut [(TileCoord, Option>)], r: Rect, format: &PixelFormat, default_pixel: &[u8], fill: &F, check: &C) -> Result<(), E> +where + F: Fn(i32, i32, &mut [u8]) + Sync, + C: Fn() -> Result<(), E> + Sync, + E: From + Send, +{ + use rayon::prelude::*; + tiles.par_iter_mut().try_for_each(|slot| fill_tile(slot, r, format, default_pixel, fill, check)) +} + +/// Serial counterpart, for the web build. +#[cfg(target_arch = "wasm32")] +fn fill_tiles(tiles: &mut [(TileCoord, Option>)], r: Rect, format: &PixelFormat, default_pixel: &[u8], fill: &F, check: &C) -> Result<(), E> +where + F: Fn(i32, i32, &mut [u8]) + Sync, + C: Fn() -> Result<(), E> + Sync, + E: From + Send, +{ + tiles.iter_mut().try_for_each(|slot| fill_tile(slot, r, format, default_pixel, fill, check)) +} + fn decode_tile_row_rgba(fmt: &PixelFormat, t: &[u8], base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { let n = fmt.channels(); match (fmt.mode, fmt.sample, fmt.alpha) { @@ -696,6 +792,43 @@ impl Surface { self.tiles.retain(|_, t| t.bytes().chunks_exact(bpp).any(|px| px != dp)); } + /// A [`PruneCursor`] for [`Surface::prune_batch`], which drops the same tiles as [`Surface::prune`] + /// a batch at a time (for a caller that must not block: the web build has no threads). + pub fn prune_cursor(&self) -> PruneCursor { + PruneCursor::default() + } + + /// Drops the tiles that are entirely the default pixel, visiting at most `max` tiles in + /// coordinate order and resuming after them on the next call; returns true when every tile + /// has been visited (and so [`Surface::prune`] has nothing left to drop). A `max` of zero + /// visits one tile. The scan reads the live tile map from the cursor on: a tile inserted at or + /// before the last visited coordinate is not visited, one inserted after it is (and dropped + /// when it holds only the default pixel, which reads the same as an absent tile). + pub fn prune_batch(&mut self, cursor: &mut PruneCursor, max: usize) -> bool { + if cursor.done { + return true; + } + let dp = self.default_pixel.as_ref(); + let bpp = dp.len(); + let max = max.max(1); + let keys: Vec = match cursor.after { + None => self.tiles.keys().take(max).copied().collect(), + Some(after) => self.tiles.range((Bound::Excluded(after), Bound::Unbounded)).take(max).map(|(c, _)| *c).collect(), + }; + for c in &keys { + if self.tiles.get(c).is_some_and(|t| !t.bytes().chunks_exact(bpp).any(|px| px != dp)) { + self.tiles.remove(c); + } + } + let Some(last) = keys.last().copied() else { + cursor.done = true; + return true; + }; + cursor.after = Some(last); + cursor.done = keys.len() < max || self.tiles.range((Bound::Excluded(last), Bound::Unbounded)).next().is_none(); + cursor.done + } + #[inline] fn locate(&self, x: i32, y: i32) -> (TileCoord, usize) { let c = TileCoord::containing(x, y); @@ -1086,6 +1219,61 @@ impl Surface { written } + /// Fills `r` row by row, straight into the tiles: `fill(y, x0, dst)` writes the pixels of row + /// `y` from column `x0` into `dst`, the tile's bytes for that span (`dst.len()` is a whole + /// number of pixels in the surface's format). Lets a caller convert from its own buffer + /// without staging a copy of the region first. + /// + /// Tiles are filled on all cores (serially on the web), so `fill` must not depend on call + /// order. Missing tiles start as the default pixel; existing tiles are copied fallibly so + /// the surface is unchanged if preparation fails. An empty `r` writes nothing. + /// + /// Like [`Surface::write_region_par`] this enters Rayon, so don't call it from a `OnceLock` + /// initializer. + pub fn try_write_rows_with(&mut self, r: Rect, fill: F) -> Result<(), AllocationError> + where + F: Fn(i32, i32, &mut [u8]) + Sync, + { + self.try_prepare_rows(r, |tiles, format, default_pixel| fill_tiles(tiles, r, format, default_pixel, &fill, &|| Ok(()))) + } + + /// Interruptible row write. Cancellation or allocation failure leaves the surface unchanged. + /// Checks run before each tile and row; already running callbacks may finish after cancellation. + pub fn try_write_rows_with_interrupt(&mut self, r: Rect, ctl: &Interrupt, fill: F) -> Result<(), RowWriteError> + where + F: Fn(i32, i32, &mut [u8]) + Sync, + { + self.try_prepare_rows(r, |tiles, format, default_pixel| { + fill_tiles(tiles, r, format, default_pixel, &fill, &|| ctl.check().map_err(RowWriteError::Cancelled)) + }) + } + + fn try_prepare_rows(&mut self, r: Rect, prepare: F) -> Result<(), E> + where + F: FnOnce(&mut [(TileCoord, Option>)], &PixelFormat, &[u8]) -> Result<(), E>, + E: From, + { + if r.is_empty() { + return Ok(()); + } + let count = usize::try_from(r.tile_count()).map_err(|_| AllocationError::SizeOverflow)?; + let bytes = count.checked_mul(std::mem::size_of::<(TileCoord, Option>)>()).ok_or(AllocationError::SizeOverflow)?; + let mut tiles: Vec<(TileCoord, Option>)> = Vec::new(); + tiles.try_reserve_exact(count).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; + tiles.extend(r.tiles().map(|c| (c, self.tiles.get(&c).cloned()))); + prepare(&mut tiles, &self.format, &self.default_pixel)?; + for (c, tile) in tiles { + if let Some(tile) = tile { + if let Some(existing) = self.tiles.get_mut(&c) { + *existing = tile; + } else { + self.tiles.insert(c, tile); + } + } + } + Ok(()) + } + /// Read `r` as interleaved encoded bytes in the surface's format. pub fn to_interleaved(&self, r: Rect) -> Vec { let bpp = self.format.bytes_per_pixel(); @@ -1127,144 +1315,12 @@ impl Surface { out.tiles = sources().map(|(c, t)| (TileCoord::new(c.tx.saturating_add(tx), c.ty.saturating_add(ty)), t.clone())).collect(); return out; } - let bpp = self.format.bytes_per_pixel(); - let moved = content.translate(dx, dy); - let targets: std::collections::BTreeSet = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect(); - let mut uniform = UniformTiles::default(); + let mut builder = shift::ShiftBuilder::new(self, dx, dy, content); for (_, t) in sources() { - uniform.value_of(t, bpp); + builder.scan(t); } - // The fill row: one tile row of the default pixel, so the regions a shift vacates - // fill with bulk copies (empty when the default is zero — the tile starts zeroed). - - // One row of the default pixel, so the regions a shift vacates fill with bulk copies. - let fill_row: Vec = if self.default_pixel.iter().any(|&b| b != 0) { - let mut row = Vec::with_capacity(TILE_SIZE as usize * bpp); - for _ in 0..TILE_SIZE { - row.extend_from_slice(&self.default_pixel); - } - row - } else { - Vec::new() - }; - - // 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, Option>>> = Default::default(); - let build_target = |dc: TileCoord| -> Option<(TileCoord, Arc)> { - let dr = dc.rect(); - let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); - // Every pixel of the target comes from a source tile (none from the default pixel). - // `sr` must be the exact preimage: an offset of `i32::MIN` saturates its negation. - let covered = sr.width() == TILE_SIZE.unsigned_abs() - && sr.height() == TILE_SIZE.unsigned_abs() - && sr.translate(dx, dy) == dr - && content.contains_rect(&sr) - && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); - if covered && let Some(shared) = uniform.window(self, sr) { - // A tile of only the default pixel is left out, as `prune` would. - let keep = shared.bytes().get(..bpp) != Some(&*self.default_pixel); - return keep.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> = 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 data = scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8); - let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); - let row_len = TILE_SIZE as usize * bpp; - // Per stored row: copy the source span that falls inside the content in as few - // memcpys as the tile grid allows, and fill the vacated edges from the default - // row — no whole-tile pre-fill, which rewrote nearly every byte twice. - for local_y in 0..i64::from(TILE_SIZE) { - let y = ty0 + local_y; - let Some(row) = data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; - let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); - // The row's target columns whose SOURCE lies inside the content are copied - // (the copy loop crosses source tiles as needed); the rest stays default — - // exactly the pixels the shift vacated. - let cx0 = (i64::from(content.x0) + i64::from(dx)).max(tx0); - let cx1 = (i64::from(content.x1) + i64::from(dx)).min(tx0 + i64::from(TILE_SIZE)); - if !in_content_y || cx1 <= cx0 { - if !fill_row.is_empty() { - row.copy_from_slice(&fill_row); - } - continue; - } - let left = (cx0.max(tx0) - tx0) as usize * bpp; - let copied_end = (cx1.min(tx0 + i64::from(TILE_SIZE)) - tx0) as usize * bpp; - if !fill_row.is_empty() { - if left > 0 { - row[..left].copy_from_slice(&fill_row[..left]); - } - if copied_end < row_len { - row[copied_end..].copy_from_slice(&fill_row[copied_end..]); - } - } - let mut x = cx0.max(tx0); - let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); - // Rows run over the same one or two source tiles: cache the last lookup. - let mut cached: Option<(i64, i64, TileBytes<'_>)> = None; - while x < x_end { - // The source document position this target column reads, and the source - // tile it lives in (the shift crosses source tiles as x runs). - let (sx, sy) = (x - i64::from(dx), y - i64::from(dy)); - let (tx, ty) = (sx.div_euclid(i64::from(TILE_SIZE)), sy.div_euclid(i64::from(TILE_SIZE))); - // A missing source tile inside the content holds the default pixel, not - // zeros: fill the span from the default row instead of skipping it - // (echelon's white-mask-with-sparse-tiles case). - if !matches!(&cached, Some((ctx, cty, _)) if *ctx == tx && *cty == ty) { - match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { - Some(t) => cached = Some((tx, ty, t.bytes())), - None => { - let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); - if !fill_row.is_empty() { - let at = (x - tx0) as usize * bpp; - let len = (span_end - x) as usize * bpp; - if let Some(d) = row.get_mut(at..at + len) { - d.copy_from_slice(&fill_row[at..at + len]); - } - } - x = span_end; - continue; - } - } - } - let Some((_, _, st)) = &cached else { break }; - let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); - let span = (span_end - x) as usize * bpp; - let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; - let dst = (x - tx0) as usize * bpp; - if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.get(src..src + span)) { - d.copy_from_slice(sr); - } - x = span_end; - } - } - // 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. - let finished = if data.chunks(default_row.len()).any(|row| row != default_row) { - Some(Arc::new(Tile::from_vec(data))) - } else { - scratch::give(data); - None - }; - // Workers that missed the memo at the same time each built a copy: all of them take - // the one stored first, so the targets share it however many threads raced. - let finished = match memo_key { - Some(k) => built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(k).or_insert(finished).clone(), - None => finished, - }; - finished.map(|t| (dc, t)) - }; + let targets = builder.targets(self); + let build_target = |dc: TileCoord| builder.build(self, dc); #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; diff --git a/crates/raster/src/memory/system.rs b/crates/raster/src/memory/system.rs index ac2e4b772b..998720b8b5 100644 --- a/crates/raster/src/memory/system.rs +++ b/crates/raster/src/memory/system.rs @@ -206,6 +206,16 @@ pub(super) mod linux { MembershipParse::Malformed => return CgroupLimits::Unreadable, }; let mountinfo = std::fs::read_to_string(proc_root.join("self/mountinfo")).ok(); + // A sandbox with no cgroup filesystem mounted and no `/sys/fs/cgroup` at all (Flatpak) + // shows the process its membership path but no interface files, so no limit is + // observable. Failing closed there reported 0 bytes free and refused every PSD on a + // machine with tens of GB. A present directory still takes the guarded path below. + // Only a confirmed NotFound counts as absent: a stat that fails for another reason + // (permissions) leaves the directory possibly present, so that case stays fail-closed. + let sys_root_absent = matches!(std::fs::metadata(sys_root), Err(e) if e.kind() == std::io::ErrorKind::NotFound); + if sys_root_absent && mountinfo.as_deref().is_some_and(lists_mounts_without_cgroup) { + return CgroupLimits::NotConstrained; + } if let Some(path) = membership.v1_memory { let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup", Some("memory")) else { @@ -336,6 +346,21 @@ pub(super) mod linux { best.map(|(_, _, dir)| dir) } + /// A cgroup (v1 or v2) filesystem. + fn is_cgroup_mount(mount: &Mount) -> bool { + mount.fstype == "cgroup" || mount.fstype == "cgroup2" + } + + /// `mountinfo` parsed to at least one mount, none of them a cgroup filesystem. An empty or + /// garbled file parses to no mounts at all, which proves nothing about cgroups; a live kernel + /// always lists the process's own root, so only a real list can show a cgroup mount is absent. + /// Every non-empty line must parse: a skipped truncated line could be the cgroup mount. + fn lists_mounts_without_cgroup(info: &str) -> bool { + let mounts = parse_mountinfo(info); + let lines = info.lines().filter(|l| !l.trim().is_empty()).count(); + !mounts.is_empty() && mounts.len() == lines && !mounts.iter().any(is_cgroup_mount) + } + /// Trims trailing slashes so `Path` equality holds while walking ancestors, keeping `/`. fn normalize_root(point: &str) -> PathBuf { let trimmed = point.trim_end_matches('/'); @@ -933,6 +958,33 @@ mod tests { assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "without metadata the absence of a constraint cannot be established"); std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + // No cgroup filesystem mounted (Flatpak): the limit is invisible, host memory applies. + let (proc, _) = fixture("no-cgroup-mount"); + let sys = proc.parent().unwrap().join("absent-sys"); + write(&proc.join("self/cgroup"), "0::/app.slice/app-flatpak.scope\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // A truncated mount line may be the cgroup mount: with /sys/fs/cgroup absent, fail closed. + let (proc, _) = fixture("truncated-mountinfo-line"); + let sys = proc.parent().unwrap().join("absent-sys"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n30 20 0:26 / /sys/fs/cgroup rw - cgroup2\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // A cgroup2 mount at an alternate path must be honoured while /sys/fs/cgroup is absent. + let (proc, _) = fixture("alternate-cgroup-mount"); + let sys = proc.parent().unwrap().join("absent-sys"); + let alt = proc.parent().unwrap().join("alt-cgroup"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", alt.display())); + write(&alt.join("app/memory.max"), "1073741824\n"); + write(&alt.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + // Available but not delegated anywhere on the path: no cgroup limit applies. let (proc, sys) = fixture("v2-not-enabled"); write(&proc.join("self/cgroup"), "0::/app\n"); @@ -1153,6 +1205,51 @@ mod tests { std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); } + /// A missing `/sys/fs/cgroup` only means "no limit visible" when `mountinfo` was read, parsed + /// to real mounts and lists no cgroup filesystem; every other combination stays fail-closed. + #[cfg(target_os = "linux")] + #[test] + fn linux_absent_cgroup_root_needs_proof_before_it_means_unconstrained() { + let cases: [(&str, Option<&str>); 3] = + [("mountinfo-missing", None), ("mountinfo-empty", Some("")), ("mountinfo-garbage", Some("not a mountinfo line\n\n- - -\n"))]; + for (name, mountinfo) in cases { + let (proc, _) = fixture(name); + let sys = proc.parent().unwrap().join("absent-sys"); + write(&proc.join("self/cgroup"), "0::/app\n"); + if let Some(text) = mountinfo { + write(&proc.join("self/mountinfo"), text); + } + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "{name}"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + // A stat error that is not NotFound (a path component that is a file) may hide a real + // directory, so it is not "absent" either. + let (proc, _) = fixture("sys-root-not-a-directory"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "25 20 0:23 / /proc rw - proc proc rw\n"); + let sys = proc.join("self/cgroup/fs/cgroup"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + /// A cgroup mount listed at an alternate path means a limit may exist: v1 and v2 alike, and + /// when its files cannot be read the answer is fail-closed, not "unconstrained". + #[cfg(target_os = "linux")] + #[test] + fn linux_alternate_cgroup_mounts_with_unreadable_limits_fail_closed() { + for (name, membership, fstype, options) in [("alt-v1", "4:memory:/app\n", "cgroup", "rw,memory"), ("alt-v2", "0::/app\n", "cgroup2", "rw")] { + let (proc, _) = fixture(name); + let sys = proc.parent().unwrap().join("absent-sys"); + let alt = proc.parent().unwrap().join("alt-cgroup"); + write(&proc.join("self/cgroup"), membership); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - {fstype} {fstype} {options}\n", alt.display())); + std::fs::create_dir_all(alt.join("app")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "{name}"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + } + #[cfg(target_os = "linux")] #[test] fn linux_snapshot_combines_host_meminfo_and_cgroup_room() { diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs index 19a9a1056b..b8797ccac3 100644 --- a/crates/raster/src/scratch_file.rs +++ b/crates/raster/src/scratch_file.rs @@ -21,7 +21,7 @@ use std::fs::File; use std::path::{Path, PathBuf}; #[cfg(windows)] use std::sync::OnceLock; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Mutex, PoisonError}; const ALIGN: u64 = 4096; @@ -48,6 +48,7 @@ pub(crate) struct Allocator { impl Allocator { /// Bytes currently allocated (end of file minus free space). + #[cfg(test)] pub fn used(&self) -> u64 { self.end - self.by_offset.values().sum::() } @@ -108,6 +109,9 @@ pub(crate) struct ScratchFile { #[cfg(not(windows))] linked: bool, allocator: Mutex, + /// What [`ScratchFile::used`] reads. No lock: `free` truncates the file under the allocator lock, + /// and a dead folder would block that, so a status read must not wait for it. + used: AtomicU64, } impl std::fmt::Debug for ScratchFile { @@ -129,6 +133,7 @@ impl ScratchFile { #[cfg(not(windows))] linked, allocator: Mutex::new(Allocator::default()), + used: AtomicU64::new(0), }) } @@ -145,6 +150,7 @@ impl ScratchFile { pub fn write(&self, data: &[u8]) -> std::io::Result { let len = u32::try_from(data.len()).map_err(|_| std::io::Error::other("block larger than 4 GiB"))?; let mut extent = self.lock().alloc(len); + self.used.fetch_add(extent.alloc, Ordering::Relaxed); if let Err(e) = write_all_at(&self.main, data, extent.offset) { self.free(extent); return Err(e); @@ -164,6 +170,7 @@ impl ScratchFile { } pub fn free(&self, extent: Extent) { + self.used.fetch_sub(extent.alloc, Ordering::Relaxed); let mut allocator = self.lock(); allocator.free(extent); if allocator.end == 0 { @@ -175,7 +182,7 @@ impl ScratchFile { } pub fn used(&self) -> u64 { - self.lock().used() + self.used.load(Ordering::Relaxed) } fn lock(&self) -> std::sync::MutexGuard<'_, Allocator> { @@ -483,6 +490,23 @@ mod tests { assert_eq!(a.alloc(10).offset, 4096, "remainder of the split hole is used next"); } + #[test] + fn used_follows_writes_and_frees() { + let dir = std::env::temp_dir().join(format!("pc-scratch-used-{}", std::process::id())); + let scratch = ScratchFile::create(&dir).unwrap(); + assert_eq!(scratch.used(), 0); + let a = scratch.write(b"one").unwrap(); + assert_eq!(scratch.used(), a.alloc); + let b = scratch.write(&[1u8; 9000]).unwrap(); + assert_eq!(scratch.used(), a.alloc + b.alloc); + scratch.free(a); + assert_eq!(scratch.used(), b.alloc); + scratch.free(b); + assert_eq!(scratch.used(), 0); + drop(scratch); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn file_roundtrip_and_crc() { let dir = std::env::temp_dir().join("photocraft-scratch-test"); diff --git a/crates/raster/src/shift.rs b/crates/raster/src/shift.rs new file mode 100644 index 0000000000..6c3a49ffab --- /dev/null +++ b/crates/raster/src/shift.rs @@ -0,0 +1,285 @@ +//! [`Surface::translated`] in pieces: the target tiles of a shift by a whole number of pixels +//! that is not a whole number of tiles, each built from the source tiles it reads. The one-shot +//! call and [`TranslateSteps`] build every tile with the same code, so their results are equal. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, Mutex}; + +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; + +use super::{Surface, Tile, TileBytes, UniformTiles, scratch}; + +/// What the target tiles of one shift share: the offset, the source's content bounds, rows of the +/// default pixel, which source tiles are one solid value, and the tiles already built. +pub(super) struct ShiftBuilder { + dx: i32, + dy: i32, + content: Rect, + bpp: usize, + default_pixel: Box<[u8]>, + /// One row of the default pixel, so the regions a shift vacates fill with bulk copies (empty + /// when the default is zero: the tile starts zeroed). + fill_row: Vec, + /// One tile row of the default pixel, to compare a finished tile with a row at a time. + default_row: Vec, + pub(super) uniform: UniformTiles, + /// 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. + built: Mutex, Option>>>, +} + +impl ShiftBuilder { + /// A builder for shifting `src` by (`dx`, `dy`); `content` must hold every pixel of `src` + /// that isn't the default one. + pub(super) fn new(src: &Surface, dx: i32, dy: i32, content: Rect) -> Self { + let bpp = src.format.bytes_per_pixel(); + let fill_row: Vec = if src.default_pixel.iter().any(|&b| b != 0) { + let mut row = Vec::with_capacity(TILE_SIZE as usize * bpp); + for _ in 0..TILE_SIZE { + row.extend_from_slice(&src.default_pixel); + } + row + } else { + Vec::new() + }; + Self { + dx, + dy, + content, + bpp, + default_pixel: src.default_pixel.clone(), + fill_row, + default_row: src.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize), + uniform: UniformTiles::default(), + built: Mutex::default(), + } + } + + /// The source tiles that can hold content, in coordinate order. + pub(super) fn sources(src: &Surface, content: Rect) -> impl Iterator)> { + src.tiles.iter().filter(move |(c, _)| !c.rect().intersect(&content).is_empty()) + } + + /// Notes whether source tile `t` is one solid value, so targets reading only such tiles share + /// one tile. + pub(super) fn scan(&mut self, t: &Arc) { + self.uniform.value_of(t, self.bpp); + } + + /// The target tiles: every tile a source tile's pixels land on. + pub(super) fn targets(&self, src: &Surface) -> BTreeSet { + let (dx, dy) = (self.dx, self.dy); + let moved = self.content.translate(dx, dy); + Self::sources(src, self.content).flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect() + } + + /// Builds target tile `dc` of `src` shifted; `None` when it would hold only the default pixel. + pub(super) fn build(&self, src: &Surface, dc: TileCoord) -> Option<(TileCoord, Arc)> { + let (dx, dy, content, bpp) = (self.dx, self.dy, self.content, self.bpp); + let (fill_row, default_row, built, uniform) = (&self.fill_row, &self.default_row, &self.built, &self.uniform); + let dr = dc.rect(); + let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); + // Every pixel of the target comes from a source tile (none from the default pixel). + // `sr` must be the exact preimage: an offset of `i32::MIN` saturates its negation. + let covered = sr.width() == TILE_SIZE.unsigned_abs() + && sr.height() == TILE_SIZE.unsigned_abs() + && sr.translate(dx, dy) == dr + && content.contains_rect(&sr) + && sr.tiles().all(|sc| src.tiles.contains_key(&sc)); + if covered && let Some(shared) = uniform.window(src, sr) { + // A tile of only the default pixel is left out, as `prune` would. + let keep = shared.bytes().get(..bpp) != Some(&*self.default_pixel); + return keep.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> = covered.then(|| sr.tiles().map(|sc| src.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 data = scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8); + let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); + let row_len = TILE_SIZE as usize * bpp; + // Per stored row: copy the source span that falls inside the content in as few + // memcpys as the tile grid allows, and fill the vacated edges from the default + // row — no whole-tile pre-fill, which rewrote nearly every byte twice. + for local_y in 0..i64::from(TILE_SIZE) { + let y = ty0 + local_y; + let Some(row) = data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; + let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); + // The row's target columns whose SOURCE lies inside the content are copied + // (the copy loop crosses source tiles as needed); the rest stays default — + // exactly the pixels the shift vacated. + let cx0 = (i64::from(content.x0) + i64::from(dx)).max(tx0); + let cx1 = (i64::from(content.x1) + i64::from(dx)).min(tx0 + i64::from(TILE_SIZE)); + if !in_content_y || cx1 <= cx0 { + if !fill_row.is_empty() { + row.copy_from_slice(fill_row); + } + continue; + } + let left = (cx0.max(tx0) - tx0) as usize * bpp; + let copied_end = (cx1.min(tx0 + i64::from(TILE_SIZE)) - tx0) as usize * bpp; + if !fill_row.is_empty() { + if left > 0 { + row[..left].copy_from_slice(&fill_row[..left]); + } + if copied_end < row_len { + row[copied_end..].copy_from_slice(&fill_row[copied_end..]); + } + } + let mut x = cx0.max(tx0); + let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); + // Rows run over the same one or two source tiles: cache the last lookup. + let mut cached: Option<(i64, i64, TileBytes<'_>)> = None; + while x < x_end { + // The source document position this target column reads, and the source + // tile it lives in (the shift crosses source tiles as x runs). + let (sx, sy) = (x - i64::from(dx), y - i64::from(dy)); + let (tx, ty) = (sx.div_euclid(i64::from(TILE_SIZE)), sy.div_euclid(i64::from(TILE_SIZE))); + // A missing source tile inside the content holds the default pixel, not + // zeros: fill the span from the default row instead of skipping it + // (echelon's white-mask-with-sparse-tiles case). + if !matches!(&cached, Some((ctx, cty, _)) if *ctx == tx && *cty == ty) { + match src.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { + Some(t) => cached = Some((tx, ty, t.bytes())), + None => { + let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); + if !fill_row.is_empty() { + let at = (x - tx0) as usize * bpp; + let len = (span_end - x) as usize * bpp; + if let Some(d) = row.get_mut(at..at + len) { + d.copy_from_slice(&fill_row[at..at + len]); + } + } + x = span_end; + continue; + } + } + } + let Some((_, _, st)) = &cached else { break }; + let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); + let span = (span_end - x) as usize * bpp; + let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; + let dst = (x - tx0) as usize * bpp; + if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.get(src..src + span)) { + d.copy_from_slice(sr); + } + x = span_end; + } + } + // 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. + let finished = if data.chunks(default_row.len()).any(|row| row != default_row) { + Some(Arc::new(Tile::from_vec(data))) + } else { + scratch::give(data); + None + }; + // Workers that missed the memo at the same time each built a copy: all of them take + // the one stored first, so the targets share it however many threads raced. + let finished = match memo_key { + Some(k) => built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(k).or_insert(finished).clone(), + None => finished, + }; + finished.map(|t| (dc, t)) + } +} + +/// A surface shifted by whole pixels, a bounded piece of work at a time: the same surface +/// [`Surface::translated`] returns, for a caller that must not block for the whole shift (the web +/// build has no threads). A unit of work is the scan of one source tile or the build of one +/// target tile, so a call of [`TranslateSteps::step`] that is asked to stop does no more than one. +pub struct TranslateSteps { + src: Surface, + builder: ShiftBuilder, + /// The source tiles still to scan, then the target tiles still to build, both in coordinate + /// order: the targets are the tiles the shifted source tiles land on, found as they are scanned. + scan: std::vec::IntoIter<(TileCoord, Arc)>, + sources: usize, + found: BTreeSet, + targets: Option>, + built: usize, + total_targets: usize, + out: Surface, + /// Set when the shift is by whole tiles, which needs no building. + whole_tiles: bool, + dx: i32, + dy: i32, + content: Rect, +} + +impl TranslateSteps { + /// Prepares to shift `src` by (`dx`, `dy`); `content` must hold every pixel of `src` that + /// isn't the default one (`src.content_bounds()`, say), as for [`Surface::translated`]. + pub fn new(src: Surface, dx: i32, dy: i32, content: Rect) -> Self { + let whole_tiles = dx % TILE_SIZE == 0 && dy % TILE_SIZE == 0; + let builder = ShiftBuilder::new(&src, dx, dy, content); + let tiles: Vec<(TileCoord, Arc)> = + if whole_tiles { Vec::new() } else { ShiftBuilder::sources(&src, content).map(|(c, t)| (*c, Arc::clone(t))).collect() }; + let out = Surface { format: src.format, default_pixel: src.default_pixel.clone(), tiles: Default::default() }; + Self { + sources: tiles.len(), + scan: tiles.into_iter(), + builder, + found: BTreeSet::new(), + targets: None, + built: 0, + total_targets: 0, + out, + whole_tiles, + dx, + dy, + content, + src, + } + } + + /// How much of the shift is done, `0.0..=1.0`. + pub fn progress(&self) -> f32 { + let todo = self.scan.len() + self.targets.as_ref().map_or(0, ExactSizeIterator::len); + let total = self.sources + self.total_targets; + if total == 0 { 0.0 } else { (1.0 - todo as f32 / total as f32).clamp(0.0, 1.0) } + } + + /// Does at least one unit of work, then goes on while `more()` returns true; `Some` with the + /// shifted surface once the last tile is built. + pub fn step(&mut self, more: &mut dyn FnMut() -> bool) -> Option { + if self.whole_tiles { + return Some(self.src.translated(self.dx, self.dy, self.content)); + } + loop { + if self.targets.is_none() { + match self.scan.next() { + Some((c, t)) => { + self.builder.scan(&t); + let moved = self.content.translate(self.dx, self.dy); + self.found.extend(c.rect().translate(self.dx, self.dy).intersect(&moved).tiles()); + } + None => { + let list: Vec = std::mem::take(&mut self.found).into_iter().collect(); + self.total_targets = list.len(); + self.targets = Some(list.into_iter()); + } + } + } else if let Some(dc) = self.targets.as_mut().and_then(Iterator::next) { + if let Some((c, t)) = self.builder.build(&self.src, dc) { + self.out.tiles.insert(c, t); + } + self.built += 1; + } else { + return Some(std::mem::replace( + &mut self.out, + Surface { format: self.src.format, default_pixel: self.src.default_pixel.clone(), tiles: Default::default() }, + )); + } + if !more() { + return None; + } + } + } +} diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs index ac37c85686..2fc5707fa7 100644 --- a/crates/raster/src/spill.rs +++ b/crates/raster/src/spill.rs @@ -22,10 +22,16 @@ //! poisons the tile: edits and saves check the health of the document they publish, plus //! [`check_since`] for failures that came and went during the operation. The //! infallible display API uses a temporary placeholder, never replacement pixel storage. +//! +//! No lock is held across a call into the scratch folder. Opening and measuring it run on a +//! short-lived query thread, at most one per folder and question; callers wait a bounded time +//! for the answer, and a late answer still applies if that folder is still the configured one. +//! Status reads the UI makes every frame stay fast whatever the folder does. +use std::collections::VecDeque; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; +use std::sync::{Arc, Condvar, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; use std::time::{Duration, Instant}; use crate::scratch_file::{Extent, ScratchFile}; @@ -235,6 +241,8 @@ struct Manager { next_space_check: AtomicU64, /// A sweep wrote successfully since the last resume: reset the retry backoff. recovering: AtomicBool, + /// The last free-space query for the scratch directory got no answer. + space_unmeasured: AtomicBool, registry: Mutex, config: Mutex, evictions: AtomicU64, @@ -259,6 +267,8 @@ struct Config { backoff: Duration, /// Explicit scratch size limit ([`set_size_cap`]); the free-space reserve always applies. size_cap: Option, + /// Scratch bytes the volume allows, as of the last answer; `None` = unmeasured. + room: Option, } struct Pause { @@ -272,6 +282,51 @@ const RETRY_MAX: Duration = Duration::from_secs(60); const SPACE_CHECK_EVERY: u64 = 64 << 20; /// Leave at least max(1 GiB, 5 % of the volume) free for the system and other programs. const RESERVE_MIN: u64 = 1 << 30; +/// How long a caller waits for the scratch folder to answer. A healthy disk answers in far less; +/// the query goes on after this and its answer still counts when it comes. +const QUERY_WAIT: Duration = Duration::from_secs(2); +/// Most folder queries in flight at once. A system call on a dead hard mount can't be cancelled, +/// so each stuck one keeps its thread, and every other folder tried on that mount adds one. +const MAX_QUERIES: usize = 16; + +/// A question in flight about a scratch folder (open it and/or measure it). +struct Query { + dir: PathBuf, + prepare: bool, + deadline: Instant, + done: Arc, +} + +/// Set once a query's answer has been applied (or discarded). +#[derive(Default)] +struct QueryDone { + finished: Mutex, + wake: Condvar, +} + +impl QueryDone { + fn finish(&self) { + *self.finished.lock().unwrap_or_else(PoisonError::into_inner) = true; + self.wake.notify_all(); + } + + fn finished(&self) -> bool { + *self.finished.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Wait until the query finishes or `deadline` passes; a deadline already past returns at once. + fn wait_until(&self, deadline: Instant) { + let Some(left) = deadline.checked_duration_since(Instant::now()) else { return }; + let done = self.finished.lock().unwrap_or_else(PoisonError::into_inner); + drop(self.wake.wait_timeout_while(done, left, |done| !*done).unwrap_or_else(PoisonError::into_inner)); + } +} + +static QUERIES: Mutex> = Mutex::new(Vec::new()); +/// Query threads alive, counted until they finish, including closing any file they opened; +/// `QUERIES` only dedupes questions. +static QUERY_THREADS: AtomicUsize = AtomicUsize::new(0); +static QUERY_HOOK: RwLock> = RwLock::new(None); static M: Manager = Manager { failed: AtomicUsize::new(0), @@ -284,8 +339,9 @@ static M: Manager = Manager { space_limit: AtomicU64::new(u64::MAX), next_space_check: AtomicU64::new(0), recovering: AtomicBool::new(false), + space_unmeasured: AtomicBool::new(false), registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }), - config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None }), + config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None, room: None }), evictions: AtomicU64::new(0), reloads: AtomicU64::new(0), written: AtomicU64::new(0), @@ -345,27 +401,73 @@ impl Manager { self.set_active(c.budget); } - /// Open the scratch file and check its volume's room; pause (and say why) if either fails. - /// The caller holds the config lock. Returns whether spilling can go ahead. - fn prepare(&self, c: &mut Config) -> bool { - let Some(dir) = c.dir.clone() else { return false }; - if c.file.is_none() { - match std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir)) { - Ok(f) => c.file = Some(Arc::new(f)), - Err(e) => { - self.pause(c, &format!("could not create a scratch file in {}: {e}", dir.display())); - return false; - } + /// Apply a finished folder query. `file` is the scratch file a `prepare` query opened (or the + /// error), `sampled_used` the scratch bytes in use just before the volume was asked, `space` + /// what the volume reported. Discarded if the folder changed meanwhile. + fn settle(&self, dir: &Path, prepare: bool, file: Option>, sampled_used: u64, space: Option<(u64, u64)>, done: &QueryDone) { + // Declared before the guard so a rejected file closes after the lock is released. + let mut _spare: Option = None; + let mut c = self.config(); + // Under the config lock, so a caller holding the lock can tell answered from not. + done.finish(); + if c.dir.as_deref() != Some(dir) { + return; + } + match file { + Some(Ok(f)) if c.file.is_none() => c.file = Some(Arc::new(f)), + Some(Ok(f)) => _spare = Some(f), + Some(Err(e)) if c.file.is_none() => { + self.pause(&mut c, &format!("could not create a scratch file in {}: {e}", dir.display())); + return; } + Some(Err(_)) | None => {} } let used = c.file.as_ref().map_or(0, |f| f.used()); - refresh_space(c, &dir, used); - if used >= self.space_limit.load(Ordering::Relaxed) { - let msg = no_room(&dir, c.size_cap); - self.pause(c, &msg); - return false; + apply_space(&mut c, sampled_used, space); + if !prepare { + return; } - true + if c.file.is_none() { + // The folder changed away and back while this ran. + self.pause(&mut c, ¬_responding(dir)); + return; + } + if used >= self.space_limit.load(Ordering::Relaxed) { + let msg = no_room(dir, c.size_cap); + self.pause(&mut c, &msg); + return; + } + if c.paused.is_some() { + self.resume(&mut c); + } + } + + /// Open the scratch file and check its volume's room, waiting up to [`QUERY_WAIT`]; an answer + /// that comes later is applied by [`Manager::settle`]. Pauses (and says why) on failure or + /// silence. The caller holds no lock. + fn prepare(&self, dir: &Path) { + let done = ask(dir, true); + let mut c = self.config(); + if c.dir.as_deref() != Some(dir) || done.as_ref().is_some_and(|d| d.finished()) { + return; + } + // No answer in time: it still counts when it comes. Until then: + let used = c.file.as_ref().map_or(0, |f| f.used()); + apply_space(&mut c, used, None); + if c.file.is_none() || c.paused.is_some() { + self.pause(&mut c, ¬_responding(dir)); + } + } + + /// Measure the volume's free space, waiting up to [`QUERY_WAIT`]. The caller holds no lock. + fn measure(&self, dir: &Path) { + let done = ask(dir, false); + let mut c = self.config(); + if c.dir.as_deref() != Some(dir) || done.as_ref().is_some_and(|d| d.finished()) { + return; + } + let used = c.file.as_ref().map_or(0, |f| f.used()); + apply_space(&mut c, used, None); } /// Retry a paused scratch disk once its backoff has elapsed. Cheap when not paused. @@ -373,40 +475,44 @@ impl Manager { if !self.paused.load(Ordering::Acquire) { return; } - let mut c = match self.config.try_lock() { - Ok(c) => c, - Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), - // Someone else is configuring or retrying. - Err(std::sync::TryLockError::WouldBlock) => return, - }; + let c = self.config(); let due = c.paused.as_ref().is_some_and(|p| Instant::now() >= p.retry_at); if !due { return; } - if c.dir.is_none() || c.budget == 0 { - c.paused = None; - self.paused.store(false, Ordering::Release); - return; - } - if self.prepare(&mut c) { - self.resume(&mut c); + self.retry_now(c); + } + + /// Retry a paused scratch disk now (the guard is released before the folder is asked). + fn retry_now(&self, mut c: std::sync::MutexGuard<'_, Config>) { + let dir = match c.dir.clone() { + Some(dir) if c.budget > 0 => dir, + _ => { + c.paused = None; + self.paused.store(false, Ordering::Release); + return; + } + }; + // Push the next retry out so other threads don't all wait on this one. + let wait = c.backoff; + if let Some(p) = &mut c.paused { + p.retry_at = Instant::now().checked_add(wait).unwrap_or_else(Instant::now); } + drop(c); + self.prepare(&dir); } /// The scratch file, if spilling is on and the volume still has room. fn file(&self) -> Option> { - let mut c = self.config(); if self.budget.load(Ordering::Relaxed) == 0 { return None; } - if c.file.is_none() && !self.prepare(&mut c) { - return None; - } - c.file.clone() + self.config().file.clone() } - /// Whether the scratch file may grow further; re-reads free space every - /// [`SPACE_CHECK_EVERY`] bytes and pauses spilling when the volume runs low. + /// Whether the scratch file may grow further; asks for free space every + /// [`SPACE_CHECK_EVERY`] bytes (in the background, never waiting) and pauses spilling when the + /// volume runs low. fn has_room(&self, file: &ScratchFile) -> bool { let used = file.used(); if used < self.next_space_check.load(Ordering::Relaxed) && used < self.space_limit.load(Ordering::Relaxed) { @@ -414,8 +520,20 @@ impl Manager { } let mut c = self.config(); let Some(dir) = c.dir.clone() else { return false }; - refresh_space(&c, &dir, used); if used < self.space_limit.load(Ordering::Relaxed) { + // Ask in the background: this can be any thread, the UI's too. The answer moves the limit. + drop(c); + let query = start_query(&dir, false); + let c = self.config(); + // A rejected request must remain due. Don't overwrite a new folder's state or an + // answer that arrived before this lock was reacquired. + if c.dir.as_deref() == Some(dir.as_path()) && !query.as_ref().is_some_and(|(done, _)| done.finished()) { + if query.is_some() { + self.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed); + } else { + self.space_unmeasured.store(true, Ordering::Relaxed); + } + } return true; } if c.paused.is_none() { @@ -488,14 +606,128 @@ impl Manager { } } -/// Free space on the volume holding `dir`, minus the reserve, sets how far the file may grow. -fn refresh_space(c: &Config, dir: &Path, used: u64) { - let room = free_space(dir).map_or(u64::MAX, |(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20)))); - let limit = c.size_cap.map_or(room, |cap| cap.min(room)); - M.space_limit.store(limit, Ordering::Relaxed); +/// Record a volume's answer (`None` = it didn't say) and derive the growth limit from it. +fn apply_space(c: &mut Config, used: u64, space: Option<(u64, u64)>) { + M.space_unmeasured.store(space.is_none(), Ordering::Relaxed); + c.room = space.map(|(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20)))); + store_limit(c); M.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed); } +/// Publish the scratch size the volume and the size limit allow. +fn store_limit(c: &Config) { + let room = c.room.unwrap_or(u64::MAX); + M.space_limit.store(c.size_cap.map_or(room, |cap| cap.min(room)), Ordering::Relaxed); +} + +fn not_responding(dir: &Path) -> String { + format!("scratch disk {} is not responding", dir.display()) +} + +/// Start (or join) a query about `dir`: the same question is never asked by a second thread, and +/// at most [`MAX_QUERIES`] run at once. `None` if no thread could be started. +fn start_query(dir: &Path, prepare: bool) -> Option<(Arc, Instant)> { + let mut queries = QUERIES.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(q) = queries.iter().find(|q| q.prepare == prepare && q.dir.as_path() == dir) { + return Some((q.done.clone(), q.deadline)); + } + if QUERY_THREADS.load(Ordering::Acquire) >= MAX_QUERIES { + return None; + } + let now = Instant::now(); + let deadline = now.checked_add(QUERY_WAIT).unwrap_or(now); + let done = Arc::new(QueryDone::default()); + queries.push(Query { dir: dir.to_path_buf(), prepare, deadline, done: done.clone() }); + let (job_dir, job_done) = (dir.to_path_buf(), done.clone()); + QUERY_THREADS.fetch_add(1, Ordering::AcqRel); + let spawned = std::thread::Builder::new().name("pc-scratch-query".into()).spawn(move || run_query(job_dir, prepare, job_done)); + if spawned.is_err() { + QUERY_THREADS.fetch_sub(1, Ordering::AcqRel); + queries.pop(); + return None; + } + Some((done, deadline)) +} + +/// Ask about `dir` and wait up to [`QUERY_WAIT`] for the answer. The caller holds no lock. +fn ask(dir: &Path, prepare: bool) -> Option> { + let (done, deadline) = start_query(dir, prepare)?; + done.wait_until(deadline); + Some(done) +} + +/// The query thread: touches the folder, then hands the answer to [`Manager::settle`]. +fn run_query(dir: PathBuf, prepare: bool, done: Arc) { + let hook = *QUERY_HOOK.read().unwrap_or_else(PoisonError::into_inner); + if let Some(hook) = hook { + hook(&dir); + } + // Before measuring: writes made during the query come off the free space and must not be + // counted again on top of it. Another folder's file says nothing about this one. + let (open, used) = { + let c = M.config(); + let current = c.file.as_ref().filter(|_| c.dir.as_deref() == Some(dir.as_path())); + (prepare && current.is_none(), current.map_or(0, |f| f.used())) + }; + let file = open.then(|| std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir))); + let space = free_space(&dir); + // Forget it first: a caller that joined it can rely on the answer, and a later caller asks anew. + QUERIES.lock().unwrap_or_else(PoisonError::into_inner).retain(|q| !Arc::ptr_eq(&q.done, &done)); + M.settle(&dir, prepare, file, used, space, &done); + QUERY_THREADS.fetch_sub(1, Ordering::AcqRel); +} + +/// Whether a query about `dir` is past its wait without an answer. +fn query_overdue(dir: Option<&Path>) -> bool { + let Some(dir) = dir else { return false }; + let now = Instant::now(); + let queries = QUERIES.lock().unwrap_or_else(PoisonError::into_inner); + queries.iter().any(|q| q.dir.as_path() == dir && now >= q.deadline) +} + +/// Scratch files on their way out, oldest first. +struct Retired { + files: VecDeque<(Arc, Arc)>, + /// Closer threads alive. + closers: usize, +} + +/// Most closer threads at once: enough that one close stuck on a dead mount doesn't hold up the +/// files behind it, few enough that stuck closes can't pile up threads. +const MAX_CLOSERS: usize = 4; + +static RETIRED: Mutex = Mutex::new(Retired { files: VecDeque::new(), closers: 0 }); + +/// Let go of a scratch file off this thread: the last reference closes it, which can flush to its +/// folder, and a dead mount blocks that, while leaving a dead folder is how a user recovers. The +/// returned handle finishes when this reference is gone. +fn retire(file: Arc) -> Arc { + let done = Arc::new(QueryDone::default()); + let mut retired = RETIRED.lock().unwrap_or_else(PoisonError::into_inner); + retired.files.push_back((file, done.clone())); + // If no thread can start, the file waits in the queue for the next one rather than closing here. + if retired.closers < MAX_CLOSERS && std::thread::Builder::new().name("pc-scratch-close".into()).spawn(close_retired).is_ok() { + retired.closers += 1; + } + done +} + +fn close_retired() { + loop { + let next = { + let mut retired = RETIRED.lock().unwrap_or_else(PoisonError::into_inner); + let next = retired.files.pop_front(); + if next.is_none() { + retired.closers = retired.closers.saturating_sub(1); + } + next + }; + let Some((file, done)) = next else { return }; + drop(file); + done.finish(); + } +} + fn no_room(dir: &Path, cap: Option) -> String { match cap { Some(cap) => format!("scratch disk {} reached its size limit ({} MB) or is low on free space", dir.display(), cap >> 20), @@ -503,16 +735,97 @@ fn no_room(dir: &Path, cap: Option) -> String { } } -/// (available, total) bytes of the volume holding `dir`: the mount point that is its longest -/// prefix. `None` if unknown, which disables only the free-space check, not spilling. -#[cfg(not(target_arch = "wasm32"))] +/// (available, total) bytes of the volume holding `dir`, asked of the directory itself so that a +/// network folder or any other mount reports its own numbers: `GetDiskFreeSpaceEx` on Windows, +/// `statvfs` on Unix. Available is what the current user can write (per-user quotas on Windows, the +/// root reserve on Unix). `None` if the system can't say (the query failed, or it reports a total +/// of zero, as some FUSE and WebDAV mounts do), which disables only the free-space check, not +/// spilling; [`free_space_unmeasured`] tells the user. +#[cfg(any(unix, windows))] fn free_space(dir: &Path) -> Option<(u64, u64)> { let dir = std::fs::canonicalize(dir).ok()?; - let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()).ok()?; - disks.iter().filter(|d| dir.starts_with(d.mount_point())).max_by_key(|d| d.mount_point().as_os_str().len()).map(|d| (d.available_space(), d.total_space())) + let (available, total) = volume_space(&dir)?; + known_space(with_purgeable_space(&dir, available, total), total) } -#[cfg(target_arch = "wasm32")] +/// A zero total means the system could not say (a failed query, or a mount that reports no +/// capacity); treating that as an empty disk would pause spilling, so it counts as unknown. +#[cfg(any(unix, windows))] +fn known_space(available: u64, total: u64) -> Option<(u64, u64)> { + (total != 0).then_some((available, total)) +} + +/// (available, total) bytes of the volume holding `dir`, from `statvfs`. +#[cfg(unix)] +fn volume_space(dir: &Path) -> Option<(u64, u64)> { + let vfs = rustix::io::retry_on_intr(|| rustix::fs::statvfs(dir)).ok()?; + Some((vfs.f_bavail.saturating_mul(vfs.f_frsize), vfs.f_blocks.saturating_mul(vfs.f_frsize))) +} + +/// (available, total) bytes of the volume holding `dir`, from `GetDiskFreeSpaceEx`. `dir` is +/// canonical, so verbatim (`\\?\C:\x`, `\\?\UNC\server\share\x`), which also reaches paths past +/// MAX_PATH and keeps trailing dots significant; winsafe takes `&str`, so a non-UTF-8 folder name +/// uses its nearest UTF-8 ancestor (the same volume unless a mount point has such a name). +#[cfg(windows)] +fn volume_space(dir: &Path) -> Option<(u64, u64)> { + disk_free_space(dir.ancestors().find_map(Path::to_str)?) +} + +#[cfg(windows)] +fn disk_free_space(dir: &str) -> Option<(u64, u64)> { + // GetDiskFreeSpaceEx requires the trailing backslash on UNC paths. + let arg = if dir.ends_with('\\') { dir.to_string() } else { format!("{dir}\\") }; + let (mut available, mut total) = (0, 0); + winsafe::GetDiskFreeSpaceEx(Some(&arg), Some(&mut available), Some(&mut total), None).ok()?; + Some((available, total)) +} + +/// `statvfs` doesn't count purgeable space (local snapshots and caches macOS frees on demand) but +/// sysinfo does: when the listed disk with the longest mount point holding `dir` is this volume, +/// use its available figure. A listed disk counts as this volume when the mount point's own +/// `statvfs` gives the same numbers (APFS volumes in one container share them, and firmlinks give +/// `/Users` and `/` different device ids, so ids can't be compared); a change between the two +/// readings just keeps the `statvfs` figure. sysinfo asks every mounted volume for its properties, +/// so an unrelated dead mount must not hold up this measurement: the lookup runs on a helper thread +/// that is waited for at most [`PURGEABLE_WAIT`]. Never fails. +#[cfg(target_os = "macos")] +fn with_purgeable_space(dir: &Path, available: u64, total: u64) -> u64 { + static LISTING: AtomicBool = AtomicBool::new(false); + // A listing already running may be stuck on some mount: don't start another. + if LISTING.swap(true, Ordering::AcqRel) { + return available; + } + let dir = dir.to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("pc-scratch-disks".into()).spawn(move || { + let found: Option = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()).ok().and_then(|disks| { + disks + .iter() + .filter(|d| dir.starts_with(d.mount_point())) + .max_by_key(|d| d.mount_point().as_os_str().len()) + .filter(|d| volume_space(d.mount_point()) == Some((available, total))) + .map(|d| d.available_space()) + }); + LISTING.store(false, Ordering::Release); + let _ = tx.send(found); + }); + if spawned.is_err() { + LISTING.store(false, Ordering::Release); + return available; + } + rx.recv_timeout(PURGEABLE_WAIT).ok().flatten().unwrap_or(available) +} + +/// How long a measurement waits for the mounted-volume listing before using the `statvfs` figure. +#[cfg(target_os = "macos")] +const PURGEABLE_WAIT: Duration = Duration::from_millis(500); + +#[cfg(all(any(unix, windows), not(target_os = "macos")))] +fn with_purgeable_space(_dir: &Path, available: u64, _total: u64) -> u64 { + available +} + +#[cfg(not(any(unix, windows)))] fn free_space(_dir: &Path) -> Option<(u64, u64)> { None } @@ -521,7 +834,11 @@ fn free_space(_dir: &Path) -> Option<(u64, u64)> { fn maybe_evict() { let budget = M.budget.load(Ordering::Relaxed); if budget == 0 { - M.retry(); + // The supervisor retries when there is one; this can be any thread, the UI's too, and a + // retry waits for the folder. + if MAINTENANCE.load(Ordering::Acquire) == 0 { + M.retry(); + } return; } let resident = M.resident.load(Ordering::Relaxed); @@ -591,25 +908,28 @@ pub fn set_size_cap(cap: Option) { return; } c.size_cap = cap; + store_limit(&c); M.next_space_check.store(0, Ordering::Relaxed); - if let Some(p) = &mut c.paused { - p.retry_at = Instant::now(); + if c.paused.is_some() { c.backoff = RETRY_FIRST; + M.retry_now(c); } - drop(c); - M.retry(); } /// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk /// stay readable either way. Changing the directory starts a new scratch file there; the old one /// is deleted once its last tile is freed or changed. A directory that can't be used is kept and /// retried with backoff (spilling stays paused meanwhile); the error is returned and reported. +/// Waits up to 2 s for the folder; a folder that doesn't answer counts as unusable until it does. pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> { - let mut c = M.config(); let dir = dir.filter(|_| budget_bytes > 0).map(Path::to_path_buf); - if dir != c.dir { - c.file = None; - } + let mut c = M.config(); + let old = if dir != c.dir { + c.room = None; + c.file.take() + } else { + None + }; c.dir = dir.clone(); c.budget = if dir.is_some() { budget_bytes } else { 0 }; c.backoff = RETRY_FIRST; @@ -617,7 +937,22 @@ pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> c.last_error = None; M.paused.store(false, Ordering::Release); M.next_space_check.store(0, Ordering::Relaxed); - if dir.is_some() && !M.prepare(&mut c) { + drop(c); + let start = Instant::now(); + let closing = old.map(retire); + if let Some(dir) = &dir { + M.prepare(dir); + } + // One wait for the whole change: closing the old file gets what the new folder left over. + if let Some(closing) = closing { + closing.wait_until(start.checked_add(QUERY_WAIT).unwrap_or(start)); + } + let c = M.config(); + if c.dir != dir { + // A newer configure owns the state now. + return Ok(()); + } + if c.paused.is_some() { return Err(c.last_error.clone().unwrap_or_default()); } M.set_active(c.budget); @@ -666,6 +1001,8 @@ pub struct SpillStats { pub paused: bool, /// Scratch file size limit ([`set_size_cap`]); `None` = only the free-space reserve. pub size_cap_bytes: Option, + /// Spilling is on but the scratch directory's volume won't report its free space ([`free_space_unmeasured`]). + pub free_space_unmeasured: bool, } pub fn stats() -> SpillStats { @@ -682,6 +1019,7 @@ pub fn stats() -> SpillStats { last_error: c.last_error.clone(), paused: c.paused.is_some(), size_cap_bytes: c.size_cap, + free_space_unmeasured: enabled() && (M.space_unmeasured.load(Ordering::Relaxed) || query_overdue(c.dir.as_deref())), } } @@ -693,17 +1031,24 @@ pub fn last_error() -> Option { /// Uncompressed tile bytes the scratch disk can still take before it reaches its size limit /// ([`set_size_cap`]) or the volume's free-space reserve, so an import can admit tile pixels /// beyond physical RAM. `None` while spilling is off or paused, and when neither the free space -/// nor a size limit is known (nothing to promise). Tiles are written compressed; this counts -/// them uncompressed, less 1/64 for the allocator's block rounding, so it never over-promises. +/// nor a size limit is known (nothing to promise), which includes a folder that doesn't answer +/// within 2 s. Tiles are written compressed; this counts them uncompressed, less 1/64 for the allocator's block rounding, so it never over-promises. pub fn scratch_capacity() -> Option { if !enabled() || paused() { return None; } - let (dir, used, cap) = { - let c = M.config(); - (c.dir.clone()?, c.file.as_ref().map_or(0, |f| f.used()), c.size_cap) - }; - let room = free_space(&dir).map(|(available, total)| available.saturating_sub(RESERVE_MIN.max(total / 20))); + let dir = M.config().dir.clone()?; + M.measure(&dir); + let c = M.config(); + // A slow query must not describe a directory configured meanwhile, and a sweep can also have + // paused spilling meanwhile. + if c.dir.as_deref() != Some(dir.as_path()) || c.paused.is_some() { + return None; + } + let used = c.file.as_ref().map_or(0, |f| f.used()); + let cap = c.size_cap; + let room = c.room.map(|room| room.saturating_sub(used)); + drop(c); let room = match (room, cap) { (Some(room), Some(cap)) => room.min(cap.saturating_sub(used)), (Some(room), None) => room, @@ -713,6 +1058,21 @@ pub fn scratch_capacity() -> Option { Some(room - room / 64) } +/// Whether spilling is on but the scratch directory's volume won't report its free space, so the +/// reserve doesn't apply and imports earn scratch room only up to a size limit. Two short locks, +/// neither ever held across a query, so it is safe to call every frame. A query past its wait +/// counts as unmeasured even with nobody waiting on it. +pub fn free_space_unmeasured() -> bool { + if !enabled() { + return false; + } + if M.space_unmeasured.load(Ordering::Relaxed) { + return true; + } + let c = M.config(); + query_overdue(c.dir.as_deref()) +} + /// Whether spilling is on (tiles beyond the budget go to disk). pub fn enabled() -> bool { M.budget.load(Ordering::Relaxed) > 0 @@ -762,6 +1122,18 @@ pub fn scratch_handle_for_tests() -> Option { M.config().file.as_ref().and_then(|f| f.duplicate_handle().ok()) } +/// Runs at the start of every folder query, off every lock: tests stall it to simulate a dead mount. +#[doc(hidden)] +pub fn set_query_hook_for_tests(hook: Option) { + *QUERY_HOOK.write().unwrap_or_else(PoisonError::into_inner) = hook; +} + +/// Query threads still alive, so a test can wait until a stalled query has fully settled. +#[doc(hidden)] +pub fn query_threads_for_tests() -> usize { + QUERY_THREADS.load(Ordering::Acquire) +} + /// Evict down to the budget now, on this thread (tests, and before a measurement). pub fn settle() { let budget = M.budget.load(Ordering::Relaxed); @@ -784,6 +1156,210 @@ fn log_error(msg: &str) { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn free_space_keeps_a_trailing_dot_folder() { + let base = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("photocraft-dot-{}", std::process::id())); + let dir = base.join("x."); + if std::fs::create_dir_all(&dir).is_err() { + let _ = std::fs::remove_dir_all(&base); + return; + } + let space = free_space(&dir); + let _ = std::fs::remove_dir_all(&base); + assert!(space.is_some()); + } + + // `free_space` returned `None` on Windows before: `canonicalize` gives `\\?\C:\..`, which never + // started with the mount point `C:\`. + #[cfg(windows)] + #[test] + fn free_space_finds_the_temp_volume_on_windows() { + let temp = std::env::temp_dir(); + let (available, total) = free_space(&temp).unwrap(); + assert!(total > 0); + assert!(available <= total); + let mut temp_volume_total = 0; + winsafe::GetDiskFreeSpaceEx(Some(temp.to_str().unwrap()), None, Some(&mut temp_volume_total), None).unwrap(); + assert_eq!(total, temp_volume_total); + } + + // The real redirector path: the administrative share of this machine's system drive. + #[cfg(windows)] + #[test] + fn free_space_reads_a_unc_path() { + let unc = Path::new(r"\\localhost\C$\Windows"); + if std::fs::canonicalize(unc).is_err() { + eprintln!("the administrative share {} isn't reachable; skipping", unc.display()); + return; + } + let (_, unc_total) = free_space(unc).unwrap(); + let (_, local_total) = free_space(Path::new(r"C:\")).unwrap(); + assert_eq!(unc_total, local_total); + } + + #[cfg(windows)] + #[test] + fn free_space_reads_a_folder_past_max_path() { + let base = std::fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("photocraft-long-{}", std::process::id())); + let mut deep = base.clone(); + while deep.as_os_str().len() <= 300 { + deep.push("a".repeat(40)); + } + if std::fs::create_dir_all(&deep).is_err() { + let _ = std::fs::remove_dir_all(&base); + return; + } + let space = free_space(&deep); + let _ = std::fs::remove_dir_all(&base); + assert!(space.is_some()); + } + + #[cfg(windows)] + #[test] + fn free_space_reads_a_non_utf8_folder() { + use std::os::windows::ffi::OsStringExt; + let mut wide: Vec = format!("photocraft-nonutf8-{}-", std::process::id()).encode_utf16().collect(); + wide.push(0xD800); + let dir = std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide)); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let space = free_space(&dir); + let _ = std::fs::remove_dir_all(&dir); + assert!(space.is_some()); + } + + // `/dev/shm` is tmpfs, which sysinfo never lists: the same situation as an unlisted NFS mount. + // Before the directory itself was asked, it reported the numbers of `/`. + #[cfg(target_os = "linux")] + #[test] + fn free_space_reads_an_unlisted_mount() { + let shm = Path::new("/dev/shm"); + if !shm.is_dir() { + return; + } + let (available, total) = free_space(shm).unwrap(); + assert!(available <= total); + // An independent oracle: `df` asks the same mount for its size. + let Ok(out) = std::process::Command::new("df").args(["-B1", "--output=size"]).arg(shm).output() else { + eprintln!("df couldn't be run; skipping the comparison"); + return; + }; + let text = String::from_utf8_lossy(&out.stdout); + let parsed = text.lines().rev().map(str::trim).find(|l| !l.is_empty()).and_then(|l| l.parse::().ok()); + let Some(parsed) = parsed.filter(|_| out.status.success()) else { + eprintln!("df gave no size for {}; skipping the comparison", shm.display()); + return; + }; + assert_eq!(total, parsed); + } + + #[cfg(target_os = "macos")] + #[test] + fn free_space_reads_the_temp_volume_on_macos() { + let temp = std::fs::canonicalize(std::env::temp_dir()).unwrap(); + assert_eq!(free_space(&temp).unwrap().1, volume_space(&temp).unwrap().1); + } + + #[cfg(target_os = "macos")] + #[test] + fn free_space_reads_an_unlisted_mount_on_macos() { + // `/dev` is devfs; a zero total counts as unknown, so compare only when it is known. + if let Some((_, total)) = free_space(Path::new("/dev")) { + assert_ne!(total, free_space(Path::new("/")).unwrap().1); + } + } + + #[cfg(any(unix, windows))] + #[test] + fn free_space_of_a_missing_directory_is_unknown() { + let missing = std::env::temp_dir().join(format!("photocraft-missing-{}", std::process::id())); + assert_eq!(free_space(&missing), None); + } + + #[cfg(any(unix, windows))] + #[test] + fn a_volume_with_no_total_is_unknown() { + assert_eq!(known_space(0, 0), None); + assert_eq!(known_space(5, 0), None); + assert_eq!(known_space(0, 10), Some((0, 10))); + assert_eq!(known_space(5, 10), Some((5, 10))); + } + + static CAPPED_GATE: (Mutex, Condvar) = (Mutex::new(false), Condvar::new()); + + fn hold_capped(dir: &Path) { + if dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("photocraft-capped-")) { + let mut open = CAPPED_GATE.0.lock().unwrap_or_else(PoisonError::into_inner); + while !*open { + open = CAPPED_GATE.1.wait(open).unwrap_or_else(PoisonError::into_inner); + } + } + } + + #[test] + fn query_threads_stay_capped_and_single_flight() { + set_query_hook_for_tests(Some(hold_capped)); + let base = std::env::temp_dir().join(format!("photocraft-capped-{}", std::process::id())); + let first = start_query(&base, false).unwrap(); + let again = start_query(&base, false).unwrap(); + assert!(Arc::ptr_eq(&first.0, &again.0) && first.1 == again.1, "the same question must not start a second thread"); + let mut started = 1; + for i in 0..2 * MAX_QUERIES { + if start_query(&base.join(format!("photocraft-capped-{i}")), false).is_some() { + started += 1; + } + } + assert_eq!(started, MAX_QUERIES); + // Exercise the real growth path while every query slot is occupied. A separate manager + // keeps this check independent of other tests' process-wide scratch configuration. + let file = ScratchFile::create(&base).unwrap(); + let manager = Manager { + failed: AtomicUsize::new(0), + read_errors: AtomicU64::new(0), + resident: AtomicUsize::new(0), + live: AtomicUsize::new(0), + budget: AtomicUsize::new(1), + paused: AtomicBool::new(false), + epoch: AtomicU64::new(0), + space_limit: AtomicU64::new(u64::MAX), + next_space_check: AtomicU64::new(0), + recovering: AtomicBool::new(false), + space_unmeasured: AtomicBool::new(false), + registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }), + config: Mutex::new(Config { + dir: Some(base.join("growth")), + file: None, + last_error: None, + budget: 1, + paused: None, + backoff: RETRY_FIRST, + size_cap: None, + room: None, + }), + evictions: AtomicU64::new(0), + reloads: AtomicU64::new(0), + written: AtomicU64::new(0), + }; + let allowed = manager.has_room(&file); + let next_check = manager.next_space_check.load(Ordering::Relaxed); + let unmeasured = manager.space_unmeasured.load(Ordering::Relaxed); + *CAPPED_GATE.0.lock().unwrap_or_else(PoisonError::into_inner) = true; + CAPPED_GATE.1.notify_all(); + let start = Instant::now(); + while query_threads_for_tests() != 0 { + assert!(start.elapsed() < Duration::from_secs(30), "queries never finished"); + std::thread::sleep(Duration::from_millis(10)); + } + set_query_hook_for_tests(None); + assert!(allowed, "the last measured growth limit still applies"); + assert_eq!(next_check, 0, "a rejected growth query must stay due"); + assert!(unmeasured, "a rejected growth query must not report the old measurement as current"); + drop(file); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn failed_read_retains_backing_and_blocks_commit_until_repaired() { let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id())); diff --git a/crates/raster/src/tests.rs b/crates/raster/src/tests.rs index 472849c977..cae726b152 100644 --- a/crates/raster/src/tests.rs +++ b/crates/raster/src/tests.rs @@ -448,6 +448,27 @@ fn content_bounds_and_prune() { assert_eq!(s.content_bounds(), Rect::new(300, 10, 310, 20)); } +#[test] +fn prune_in_batches_drops_what_prune_drops() { + let mut whole = Surface::new(PixelFormat::RGBA8); + whole.fill_rect(Rect::new(300, 10, 310, 20), &[1.0, 1.0, 1.0, 1.0]); + whole.write_pixel(-5, -5, &[0.0, 0.0, 0.0, 1.0]); + whole.write_pixel(-5, -5, &[0.0, 0.0, 0.0, 0.0]); + whole.fill_rect(Rect::new(600, 600, 610, 610), &[1.0, 0.0, 0.0, 1.0]); + let mut stepped = whole.clone(); + whole.prune(); + let mut cursor = stepped.prune_cursor(); + let mut calls = 0; + while !stepped.prune_batch(&mut cursor, 1) { + calls += 1; + assert!(calls < 100, "the prune scan never finished"); + } + assert!(calls >= 2, "one tile per call visits every tile over several calls"); + assert_eq!(stepped.tile_count(), whole.tile_count()); + assert_eq!(stepped.content_bounds(), whole.content_bounds()); + assert_eq!(stepped.to_interleaved(whole.content_bounds()), whole.to_interleaved(whole.content_bounds())); +} + #[test] fn interleaved_roundtrip_crossing_tiles() { let r = Rect::new(-20, 250, 280, 262); @@ -676,6 +697,66 @@ fn translated_matches_reading_the_moved_pixels() { let _ = s.translated(i32::MIN + 1, 3, all); } +/// Runs `steps` to the end with `more` always false, counting the calls it took. +fn run_steps(mut steps: TranslateSteps) -> (Surface, usize) { + for calls in 1..=1_000_000 { + if let Some(out) = steps.step(&mut || false) { + return (out, calls); + } + } + panic!("the shift never finished") +} + +/// A shift in pieces gives the surface of the one-shot shift, bit for bit, at every depth and +/// offset (whole tiles, off the grid, negative), over a non-zero default pixel, solid fills and +/// sparse tiles. +#[test] +fn translate_steps_equal_translated() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] { + let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + let r = Rect::new(-37, 200, 530, 300); + let v: Vec = (0..r.width() * r.height() * fmt.channels() as u32).map(|i| (i % 251) as f32 / 250.0).collect(); + s.write_region(r, &v); + s.fill_rect(Rect::new(600, -300, 1700, 900), &vec![0.75; fmt.channels()]); + s.write_pixel(3000, 5, &vec![0.5; fmt.channels()]); + for (dx, dy) in [(0, 0), (256, -512), (1, 0), (-3, 7), (300, -257), (-1000, 999), (10, 7)] { + let content = s.content_bounds(); + let (got, _) = run_steps(TranslateSteps::new(s.clone(), dx, dy, content)); + assert!(got == s.translated(dx, dy, content), "{fmt:?} by ({dx}, {dy})"); + } + } +} + +/// Each call of a shift in pieces builds at most one tile, so a large shift takes as many calls +/// as it has source and target tiles, and none of them rebuilds everything. +#[test] +fn translate_steps_do_one_tile_per_call() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 3 * TILE_SIZE, 2 * TILE_SIZE), &[0.1, 0.2, 0.3, 1.0]); + s.write_pixel(5, 5, &[1.0, 0.0, 0.0, 1.0]); + let content = s.content_bounds(); + let sources = s.tiles().count(); + let targets = s.translated(7, 3, content).tiles().count(); + let (got, calls) = run_steps(TranslateSteps::new(s.clone(), 7, 3, content)); + assert!(got == s.translated(7, 3, content)); + // One call per source tile scanned, one to start building, one per target tile built (a few + // of them hold only the default pixel and are not kept) and one that hands the result over. + assert!(calls >= sources + targets + 2, "{calls} calls for {sources} sources and {targets} targets"); + assert!(calls <= sources + targets + 2 + 8, "{calls} calls for {sources} sources and {targets} targets"); + // Asked to go on, a call does more than one unit. + let mut steps = TranslateSteps::new(s.clone(), 7, 3, content); + let mut left = 4; + assert!( + steps + .step(&mut || { + left -= 1; + left > 0 + }) + .is_none() + ); + assert!(steps.progress() > 0.0 && steps.progress() < 1.0); +} + /// 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] @@ -853,3 +934,124 @@ fn fallible_interleaved_writes_match_encoded_pixels_and_reject_bad_lengths() { assert_eq!(shared.to_interleaved(rect), replacement); } } + +/// The byte a row-callback test writes at pixel (x, y). +fn row_byte(x: i32, y: i32) -> u8 { + (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)) & 0xff) as u8 +} + +fn fill_row_bytes(y: i32, x0: i32, dst: &mut [u8]) { + for (i, b) in dst.iter_mut().enumerate() { + *b = row_byte(x0 + i as i32, y); + } +} + +#[test] +fn write_rows_empty_rect_writes_nothing() { + let mut s = Surface::new(PixelFormat::GRAY8); + let called = std::sync::atomic::AtomicBool::new(false); + for r in [Rect::new(5, 5, 5, 9), Rect::new(0, 0, 10, 0), Rect::new(10, 10, 2, 2), Rect::EMPTY] { + s.try_write_rows_with(r, |_, _, _| called.store(true, std::sync::atomic::Ordering::Relaxed)).expect("empty is fine"); + } + assert!(!called.load(std::sync::atomic::Ordering::Relaxed), "no rows in an empty rect"); + assert_eq!(s.tile_count(), 0); +} + +#[test] +fn write_rows_overflowing_rect_is_an_error_not_a_panic() { + let mut s = Surface::new(PixelFormat::GRAY8); + let r = Rect::new(0, 0, 1, 1); + s.try_write_rows_with(r, |_, _, dst| dst.fill(42)).unwrap(); + let before = s.clone(); + let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX); + assert!(s.try_write_rows_with(huge, |_, _, _| {}).is_err()); + assert_eq!(s.tile_count(), 1); + assert_eq!(s.to_interleaved(r), vec![42]); + assert!(std::sync::Arc::ptr_eq(s.tiles.values().next().unwrap(), before.tiles.values().next().unwrap())); +} + +#[test] +fn write_rows_negative_origin_and_unaligned_rect() { + let mut s = Surface::with_default(PixelFormat::GRAY8, &[0.5]); + let r = Rect::new(-300, -5, 270, 260); + s.try_write_rows_with(r, fill_row_bytes).expect("memory"); + let got = s.to_interleaved(Rect::new(-320, -10, 300, 270)); + let w = 620; + for (i, &b) in got.iter().enumerate() { + let (x, y) = (-320 + (i % w) as i32, -10 + (i / w) as i32); + let want = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { row_byte(x, y) } else { 128 }; + assert_eq!(b, want, "({x}, {y})"); + } +} + +#[test] +fn write_rows_matches_write_interleaved_at_every_depth() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] { + let bpp = fmt.bytes_per_pixel(); + let r = Rect::new(3, 2, 520, 300); + let w = r.width() as usize; + let bytes: Vec = (0..w * r.height() as usize * bpp).map(|i| (i % 251) as u8).collect(); + let mut a = Surface::new(fmt); + a.write_interleaved(r, &bytes); + let mut b = Surface::new(fmt); + b.try_write_rows_with(r, |y, x0, dst| { + let o = (((y - r.y0) as usize) * w + (x0 - r.x0) as usize) * bpp; + dst.copy_from_slice(&bytes[o..o + dst.len()]); + }) + .expect("memory"); + assert_eq!(a.tile_count(), b.tile_count()); + for (c, t) in a.tiles() { + assert!(b.tiles().any(|(c2, t2)| c2 == c && t2 == t), "{fmt:?}: tile {c:?} differs"); + } + } +} + +#[test] +fn write_rows_copies_tiles_a_clone_still_shares() { + let mut s = Surface::new(PixelFormat::GRAY8); + s.try_write_rows_with(Rect::new(0, 0, 300, 300), |_, _, dst| dst.fill(9)).expect("memory"); + let before = s.clone(); + let snapshot = before.to_interleaved(Rect::new(0, 0, 300, 300)); + s.try_write_rows_with(Rect::new(100, 100, 280, 290), fill_row_bytes).expect("memory"); + assert_eq!(before.to_interleaved(Rect::new(0, 0, 300, 300)), snapshot, "the clone is unchanged"); + assert_eq!(s.to_interleaved(Rect::new(150, 150, 151, 151)), vec![row_byte(150, 150)]); + assert_eq!(s.to_interleaved(Rect::new(10, 10, 11, 11)), vec![9], "pixels outside the rect keep their value"); +} + +#[test] +fn failed_row_preparation_preserves_existing_tiles() { + let mut surface = Surface::new(PixelFormat::GRAY8); + let r = Rect::new(0, 0, 300, 1); + surface.try_write_rows_with(Rect::new(0, 0, 1, 1), |_, _, dst| dst.fill(42)).unwrap(); + let before = surface.clone(); + let result = surface.try_prepare_rows(r, |tiles, _, _| { + fill_tile(&mut tiles[0], r, &PixelFormat::GRAY8, &[0], &|_, _, dst: &mut [u8]| dst.fill(7), &|| Ok::<(), AllocationError>(()))?; + Err(AllocationError::NotEnoughMemory { bytes: 1 }) + }); + assert!(matches!(result, Err(AllocationError::NotEnoughMemory { .. }))); + assert_eq!(surface.tile_count(), 1); + assert_eq!(surface.to_interleaved(Rect::new(0, 0, 1, 1)), vec![42]); + assert!(std::sync::Arc::ptr_eq(surface.tiles.values().next().unwrap(), before.tiles.values().next().unwrap())); +} + +#[test] +fn cancelled_row_write_stops_during_tile_and_preserves_surface() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + let mut surface = Surface::new(PixelFormat::GRAY8); + let r = Rect::new(0, 0, 1, 100); + surface.try_write_rows_with(r, |_, _, dst| dst.fill(42)).unwrap(); + let before = surface.clone(); + let cancelled = AtomicBool::new(false); + let rows = AtomicUsize::new(0); + let check = || cancelled.load(Ordering::SeqCst); + let result = surface.try_write_rows_with_interrupt(r, &Interrupt::cancel_only(&check), |_, _, dst| { + dst.fill(7); + rows.fetch_add(1, Ordering::SeqCst); + cancelled.store(true, Ordering::SeqCst); + }); + assert_eq!(result, Err(RowWriteError::Cancelled(Cancelled))); + assert_eq!(rows.load(Ordering::SeqCst), 1); + assert_eq!(surface.to_interleaved(r), before.to_interleaved(r)); + assert!(std::sync::Arc::ptr_eq(surface.tiles.values().next().unwrap(), before.tiles.values().next().unwrap())); +} diff --git a/crates/raster/tests/scratch_capacity.rs b/crates/raster/tests/scratch_capacity.rs index 33698e7d15..f6f0733ad5 100644 --- a/crates/raster/tests/scratch_capacity.rs +++ b/crates/raster/tests/scratch_capacity.rs @@ -4,6 +4,25 @@ use photocraft_raster::spill; +/// The room, once no query is outstanding. A query that outlives its 2 s wait on a slow machine +/// counts as no answer, and its late answer would change the status between two reads, so ask again +/// until the room and the status describe the same answer. +fn answered_capacity() -> Option { + let mut room = None; + let started = std::time::Instant::now(); + for _ in 0..10 { + room = spill::scratch_capacity(); + while spill::query_threads_for_tests() != 0 { + assert!(started.elapsed() < std::time::Duration::from_secs(30), "scratch capacity query did not finish within 30 seconds: {:?}", spill::stats()); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if room.is_none() == spill::free_space_unmeasured() { + break; + } + } + room +} + #[test] fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() { let root = std::env::temp_dir().join(format!("photocraft-scratch-capacity-{}", std::process::id())); @@ -15,11 +34,31 @@ fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() { spill::configure(Some(&dir), 1 << 20).unwrap(); assert!(spill::enabled() && !spill::paused()); + // With no size limit, the flag says exactly when the capacity is unknown. + let unknown = answered_capacity().is_none(); + assert_eq!(spill::free_space_unmeasured(), unknown); // The free-space reserve applies; an unknown volume without a size limit promises nothing. if let Some(room) = spill::scratch_capacity() { assert!(room < u64::MAX / 2, "the reserve and margin are applied: {room}"); } + let measurable = answered_capacity().is_some(); + + // A folder that stops answering is flagged and clears once it answers again. Windows keeps the + // open scratch file's folder, so this part needs a folder that can be removed. + if std::fs::remove_dir_all(&dir).is_ok() { + assert_eq!(spill::scratch_capacity(), None); + assert!(spill::free_space_unmeasured()); + assert!(spill::stats().free_space_unmeasured); + std::fs::create_dir_all(&dir).unwrap(); + let unknown = answered_capacity().is_none(); + assert_eq!(spill::free_space_unmeasured(), unknown); + assert_eq!(spill::stats().free_space_unmeasured, unknown); + if measurable { + assert!(!unknown, "the folder answers again: its room must be known again"); + } + } + // The Scratch Size Limit bounds it, less the 1/64 margin, whatever the volume has free. let cap = 64u64 << 20; spill::set_size_cap(Some(cap)); @@ -36,5 +75,6 @@ fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() { spill::set_size_cap(None); spill::configure(None, 0).unwrap(); assert_eq!(spill::scratch_capacity(), None); + assert!(!spill::free_space_unmeasured()); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/raster/tests/scratch_slow_folder.rs b/crates/raster/tests/scratch_slow_folder.rs new file mode 100644 index 0000000000..6d7350575e --- /dev/null +++ b/crates/raster/tests/scratch_slow_folder.rs @@ -0,0 +1,208 @@ +//! A scratch folder that stops answering (a dead hard-mounted network share) must not freeze +//! anything: status reads stay prompt, opening and measuring wait a bounded time, retries do not +//! pile up threads, leaving the folder works, and a late answer applies only to the folder it +//! was about. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex}; +use std::time::{Duration, Instant}; + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +/// Folders whose name starts with "hung" stall their queries while this is on. +static STALL: AtomicBool = AtomicBool::new(false); +/// Queries that reached the stall since the last reset. +static ENTERED: AtomicUsize = AtomicUsize::new(0); +/// `true` once the stalled queries may go on. +static GATE: (Mutex, Condvar) = (Mutex::new(false), Condvar::new()); + +fn stall(dir: &Path) { + let hung = dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("hung")); + if !STALL.load(Ordering::SeqCst) || !hung { + return; + } + ENTERED.fetch_add(1, Ordering::SeqCst); + let mut open = GATE.0.lock().unwrap(); + while !*open { + open = GATE.1.wait(open).unwrap(); + } +} + +fn arm() { + ENTERED.store(0, Ordering::SeqCst); + *GATE.0.lock().unwrap() = false; + STALL.store(true, Ordering::SeqCst); +} + +fn release() { + STALL.store(false, Ordering::SeqCst); + *GATE.0.lock().unwrap() = true; + GATE.1.notify_all(); +} + +fn poll_until(what: &str, cond: impl Fn() -> bool) { + let start = Instant::now(); + while !cond() { + assert!(start.elapsed() < Duration::from_secs(30), "timed out waiting for {what}: {:?}", spill::stats()); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Run `f` and require that it returns well before a query's wait. +fn prompt(what: &str, f: impl FnOnce() -> T) -> T { + let start = Instant::now(); + let out = f(); + assert!(start.elapsed() < Duration::from_millis(1500), "{what} took {:?}", start.elapsed()); + out +} + +/// A query's 2 s wait plus a scheduling allowance. +const QUERY_DEADLINE: Duration = Duration::from_secs(6); + +/// Run `f` and require that it returns within [`QUERY_DEADLINE`]. +fn bounded(what: &str, f: impl FnOnce() -> T) -> T { + let start = Instant::now(); + let out = f(); + assert!(start.elapsed() < QUERY_DEADLINE, "{what} took {:?}", start.elapsed()); + out +} + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +#[test] +fn a_folder_that_stops_answering_never_freezes_the_app() { + let root = std::env::temp_dir().join(format!("photocraft-scratch-slow-{}", std::process::id())); + let good = root.join("good"); + let good2 = root.join("good2"); + let hung1 = root.join("hung1"); + let hung2 = root.join("hung2"); + let hung3 = root.join("hung3"); + spill::set_query_hook_for_tests(Some(stall)); + + // A. A healthy folder. + spill::configure(Some(&good), 1 << 20).unwrap(); + assert!(spill::enabled() && !spill::paused()); + let measurable = spill::scratch_capacity().is_some(); + + // B. Configuring a folder that never answers: status reads stay prompt meanwhile (they + // blocked on the config lock before), and configure gives up after its wait. + arm(); + let began = Instant::now(); + let configuring = { + let hung1 = hung1.clone(); + std::thread::spawn(move || spill::configure(Some(&hung1), 1 << 20)) + }; + poll_until("the first query to start", || ENTERED.load(Ordering::SeqCst) == 1); + prompt("last_error", spill::last_error); + prompt("check_integrity", spill::check_integrity).ok(); + prompt("stats", spill::stats); + prompt("free_space_unmeasured", spill::free_space_unmeasured); + prompt("paused", spill::paused); + let result = configuring.join().unwrap(); + assert!(began.elapsed() < QUERY_DEADLINE, "configure of a hung folder took {:?}", began.elapsed()); + assert!(result.as_ref().err().is_some_and(|e| e.contains("not responding")), "{result:?}"); + assert!(spill::paused() && !spill::enabled()); + assert_eq!(spill::stats().dir.as_deref(), Some(hung1.as_path())); + + // C. Retries neither wait nor start more threads: the question is already being asked. + for _ in 0..5 { + spill::retry_now_for_tests(); + prompt("maintain_budget", spill::maintain_budget); + } + assert_eq!(ENTERED.load(Ordering::SeqCst), 1, "a retry started a second query"); + assert!(spill::paused()); + + // D. Leaving the dead folder works while its query is stuck. + prompt("configure away from a hung folder", || spill::configure(Some(&good2), 1 << 20)).unwrap(); + assert!(spill::enabled() && !spill::paused()); + + // E. The stuck query's late answer is about a folder that is no longer current: discarded. + release(); + poll_until("the stuck query to settle", || spill::query_threads_for_tests() == 0); + let st = spill::stats(); + assert_eq!(st.dir.as_deref(), Some(good2.as_path()), "{st:?}"); + assert!(spill::enabled() && !spill::paused() && st.last_error.is_none(), "{st:?}"); + + // F. A late answer for the current folder applies: spilling resumes by itself. + arm(); + let result = bounded("configure of a second hung folder", || spill::configure(Some(&hung2), 1 << 20)); + assert!(result.is_err()); + assert!(spill::paused()); + release(); + poll_until("the late answer to resume spilling", || spill::enabled() && !spill::paused() && spill::last_error().is_none()); + assert_eq!(spill::stats().dir.as_deref(), Some(hung2.as_path())); + + // G. The open folder stops answering when measured: no answer counts as unknown, spilling + // goes on, and asking again costs nothing. + spill::configure(Some(&hung3), 1 << 20).unwrap(); + arm(); + assert_eq!(bounded("scratch_capacity", spill::scratch_capacity), None); + assert!(spill::free_space_unmeasured()); + assert!(spill::stats().free_space_unmeasured); + assert!(spill::enabled() && !spill::paused()); + for _ in 0..5 { + assert_eq!(prompt("scratch_capacity", spill::scratch_capacity), None); + } + assert_eq!(ENTERED.load(Ordering::SeqCst), 1, "asking again started a second query"); + release(); + if measurable { + poll_until("the late answer to measure the volume", || !spill::free_space_unmeasured()); + } + + poll_until("the previous query to finish", || spill::query_threads_for_tests() == 0); + + // H. The growth check never stalls eviction: tiles still spill while it is unanswered. + spill::set_budget(8 * TILE_BYTES); + arm(); + spill::set_size_cap(Some(1 << 50)); + let trigger_tiles = surface(1, 32); + poll_until("the growth query to reach the stall", || ENTERED.load(Ordering::SeqCst) >= 1); + let evictions = spill::stats().evictions; + let tiles = surface(33, 32); + prompt("settle", spill::settle); + assert!(spill::stats().evictions > evictions, "{:?}", spill::stats()); + poll_until("the unanswered growth check to count as unknown", spill::free_space_unmeasured); + release(); + if measurable { + poll_until("the growth check to be answered", || !spill::free_space_unmeasured()); + } + spill::set_size_cap(None); + + // Cleanup. + spill::set_query_hook_for_tests(None); + drop(tiles); + drop(trigger_tiles); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/raw/README.md b/crates/raw/README.md index 03c95390a4..bbda6732da 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -38,6 +38,9 @@ Implemented only from public specifications, papers and observation of files: stored in the files; they were recovered by black-box analysis of CC0 raw.pixls.us samples (the same scenes shot compressed and uncompressed) for LightCraft (storytold/lightcraft#86) and verified there on ~45 files from ~35 bodies. + The "lossy after split" fixed-rate section (word size, prefix-to-category tables, bin-centre + dequantisation, end-of-strip rule) was likewise derived by black-box analysis of 11 CC0 + raw.pixls.us files for LightCraft (storytold/lightcraft#633). * Sony cRAW (ARW 2): H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (Electronic Imaging 2016) and the RawDigger / diglloyd write-ups of the 11 + 7-bit scheme; the exact bit layout and tone-curve scale were established by observation of sample files. @@ -72,14 +75,14 @@ camera colour tables were copied. | DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG, lossy JPEG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, gray for CFA, YCbCr-coded RGB for LinearRaw; decoded by `zune-jpeg`, the stored 8-bit values then go through the same LinearizationTable / BlackLevel / WhiteLevel as every other depth) and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, floating-point JPEG XL raw data (DNG 1.7 lossy LinearRaw, as Adobe DNG Converter writes it; scaled by the highest WhiteLevel), OpcodeList2 GainMap (lens shading) and MapPolynomial | | DNG (floating point other than JPEG XL; opcodes other than OpcodeList2 GainMap and MapPolynomial; ProfileGainTableMap) | Unsupported / not applied (reported) | | CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) | -| CR2 sRAW / mRAW | Unsupported | +| CR2 sRAW / mRAW (subsampled YCbCr) | Decoded to camera RGB and developed like LinearRaw: Canon's subsampled lossless JPEG (Y 2×2 or 2×1), BT.601 inverse, bilinear chroma, the per-shot sRAW gains from ColorData (version 15: EOS 6D Mark II, 77D, 200D, 800D; other bodies warn and rely on the fitted camera look). See `src/sraw.rs` | | NEF / NRW, ARW, PEF and other TIFF/EP raws | Uncompressed and lossless-JPEG (incl. Sony lossless ARW) CFA data | -| Nikon compressed NEF (compression 34713): lossless and lossy type 1 / 2, 12 and 14 bit | Decoded: fixed Huffman tables + maker-note `0x0096` seeds and curve (see `src/nefc.rs`). BlackLevel `0x003d` is read in 14-bit units. "Lossy after split" files are unsupported (preview fallback) | +| Nikon compressed NEF (compression 34713): lossless and lossy type 1 / 2, 12 and 14 bit | Decoded: fixed Huffman tables + maker-note `0x0096` seeds and curve (see `src/nefc.rs`). BlackLevel `0x003d` is read in 14-bit units. "Lossy after split" files (a split row in `0x0096`) are decoded: rows from the split on are fixed-rate words of `bits - 4` bits (storytold/lightcraft#633); a strip that does not end as that rule says falls back to the preview | | Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits; applied WB and black from plain tags or encrypted SR2SubIFD with the measured common key. If metadata is unreadable, black falls back to 512 and WB to grey-world. Measured ILCE-7 / ILCE-6000 image area; camera colour calibration remains incomplete | | Panasonic / Leica RW2, RawFormat 5 (12- and 14-bit packed) | Decoded, with PanasonicRaw black / white / WB / sensor borders | | Panasonic / Leica RW2, RawFormat 3 and 4 (vendor-compressed, 12-bit) | Decoded; the black level is read 15 counts above the recorded tag; PanasonicRaw white / WB / sensor borders | | Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop | -| Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 6 and newer, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | +| Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 6 and newer, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | | Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF | | Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback | | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | @@ -88,8 +91,8 @@ camera colour tables were copied. Nikon maker-note compression modes 6/10 select checked little-endian packed 12/14-bit rows even when TIFF Compression is 34713. The modern lossy `0x44 0x40` curve uses its smaller encoded domain while preserving sensor ADUs. Valid Nikon CropArea -coordinates take precedence over model fallbacks. HE/HE* and lossy-after-split -remain unsupported. Four pinned CC0 Z6 files cover both bit depths and both paths. +coordinates take precedence over model fallbacks. HE/HE* remain unsupported; +lossy-after-split strips are decoded (see the NEF row above). Four pinned CC0 Z6 files cover both bit depths and both paths. ## Development pipeline diff --git a/crates/raw/src/cr2.rs b/crates/raw/src/cr2.rs index 8e851c5d59..990745b7e2 100644 --- a/crates/raw/src/cr2.rs +++ b/crates/raw/src/cr2.rs @@ -5,7 +5,8 @@ //! lossless JPEG (T.81 process 14) whose decoded sample sequence fills the //! sensor image in vertical slices: the private tag 0xC640 gives the slice //! count, slice width and last slice width; slice 0 is filled row by row, -//! then slice 1, and so on. sRAW/mRAW (subsampled YCbCr) is not supported. +//! then slice 1, and so on. sRAW / mRAW (subsampled YCbCr) is decoded to +//! camera RGB by `sraw.rs`. //! //! Sensor geometry and the as-shot white balance come from the Canon maker //! note (an IFD at the MakerNote offset, values relative to the TIFF header): @@ -45,7 +46,7 @@ use crate::error::{RawError, Result}; use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; use crate::tiff::{Ifd, Tiff, tag}; -use crate::{Limits, RawFormat, ljpeg}; +use crate::{Limits, RawFormat, ljpeg, sraw}; const CANON_SENSOR_INFO: u16 = 0x00E0; const CANON_COLOR_DATA: u16 = 0x4001; @@ -115,26 +116,38 @@ pub(crate) fn maker_note(t: &Tiff, ifds: &[Ifd]) -> Option { /// As-shot white-balance multipliers (R, G, B) from ColorData. fn as_shot_wb(t: &Tiff, mn: &Ifd) -> Option<[f64; 3]> { - let e = mn.get(CANON_COLOR_DATA)?; - // The entry is UNDEFINED: its bytes are a table of 16-bit words (file byte order), and the - // offsets below count words, not bytes. Reads stay inside the entry's own byte count. - let bytes = e.unit().checked_mul(e.count as usize)?; - let word = |off: usize| { - let at = off.checked_mul(2)?.checked_add(e.at)?; - (at.checked_add(2)? <= e.at.checked_add(bytes)?).then(|| t.u16_at(at)).flatten().map(f64::from) - }; // Word offsets of WB_RGGBLevelsAsShot used by the ColorData versions. - for off in [0x3F, 0x47, 0x19, 0x22] { - let (Some(r), Some(g1), Some(g2), Some(b)) = (word(off), word(off + 1), word(off + 2), word(off + 3)) else { - continue; - }; - let g = (g1 + g2) / 2.0; - let plausible = (256.0..16384.0).contains(&g) && (g1 - g2).abs() <= g * 0.05 && (0.25..8.0).contains(&(r / g)) && (0.25..8.0).contains(&(b / g)); - if plausible { - return Some([r / g, 1.0, b / g]); - } - } - None + [0x3F, 0x47, 0x19, 0x22].into_iter().find_map(|off| color_data_rggb(t, mn, off)) +} + +/// Word `off` of ColorData. The entry is UNDEFINED: its bytes are a table of 16-bit words (file +/// byte order), and the offsets count words, not bytes. Reads stay inside the entry's own byte +/// count. +fn color_data_word(t: &Tiff, mn: &Ifd, off: usize) -> Option { + let e = mn.get(CANON_COLOR_DATA)?; + let bytes = e.unit().checked_mul(e.count as usize)?; + let at = off.checked_mul(2)?.checked_add(e.at)?; + (at.checked_add(2)? <= e.at.checked_add(bytes)?).then(|| t.u16_at(at)).flatten() +} + +/// RGGB levels at ColorData word `off` as multipliers relative to green, when plausible. +fn color_data_rggb(t: &Tiff, mn: &Ifd, off: usize) -> Option<[f64; 3]> { + let word = |k: usize| color_data_word(t, mn, off.checked_add(k)?).map(f64::from); + let (r, g1, g2, b) = (word(0)?, word(1)?, word(2)?, word(3)?); + let g = (g1 + g2) / 2.0; + let plausible = (256.0..16384.0).contains(&g) && (g1 - g2).abs() <= g * 0.05 && (0.25..8.0).contains(&(r / g)) && (0.25..8.0).contains(&(b / g)); + plausible.then(|| [r / g, 1.0, b / g]) +} + +/// ColorData versions whose sRAW gains (see `sraw.rs`) sit at a word offset we observed: +/// version 15 (EOS 6D Mark II, 77D, 200D, 800D) at 0x80, just before the daylight preset. +const SRAW_GAINS: [(u16, usize); 1] = [(15, 0x80)]; + +/// The per-shot gains an sRAW's stored RGB was divided by, relative to green. +fn sraw_gains(t: &Tiff, mn: &Ifd) -> Option<[f64; 3]> { + let version = color_data_word(t, mn, 0)?; + let (_, off) = SRAW_GAINS.iter().find(|(v, _)| *v == version)?; + color_data_rggb(t, mn, *off) } /// The white (clipping) level: the largest value when a meaningful share of @@ -181,6 +194,86 @@ pub(crate) fn masked_black(data: &[u16], width: usize, area: Rect) -> Option<[f3 Some([0, 1, 2, 3].map(|k| sum[k] as f32 / n[k] as f32)) } +/// The slice widths of CR2 tag 0xC640 (count, width, last width), in samples. The tag is SHORT: +/// larger values (a LONG in a damaged file) are refused, which also bounds the sum. +fn slice_widths(tag: &[u32]) -> Option> { + let short = 1..=u32::from(u16::MAX); + match tag { + [n, w, last] if *n < 64 && short.contains(w) && short.contains(last) => { + let mut ws = vec![*w as usize; *n as usize]; + ws.push(*last as usize); + Some(ws) + } + _ => None, + } +} + +/// The image area inside `full` from the maker note's SensorInfo ([1] width, [2] height, [5] +/// left, [6] top, [7] right, [8] bottom border, inclusive); `full` when absent or empty. +fn image_area(t: &Tiff, mn: Option<&Ifd>, full: Rect) -> Rect { + let info = mn.map(|m| t.tag_uints(m, CANON_SENSOR_INFO)).unwrap_or_default(); + let active = match info.get(5..9) { + Some(&[l, tp, r, b]) if r > l && b > tp => Rect::new(l as usize, tp as usize, (r - l + 1) as usize, (b - tp + 1) as usize).intersect(&full), + _ => full, + }; + if active.is_empty() { full } else { active } +} + +/// sRAW / mRAW: subsampled YCbCr, developed as three-sample camera RGB (see `sraw.rs`). +fn decode_sraw(t: &Tiff, ifds: &[Ifd], ifd0: &Ifd, slices: &[u32], src: &[u8], frame: ljpeg::Frame, limits: &Limits) -> Result { + let per_mcu = frame.h * frame.v + 2; + let total = frame.samples().ok_or_else(|| RawError::malformed("CR2 frame too large"))?; + let widths = slice_widths(slices).unwrap_or_else(|| vec![frame.width / frame.h * per_mcu]); + let row: usize = widths.iter().sum(); + if row == 0 || !row.is_multiple_of(per_mcu) || !total.is_multiple_of(row) { + return Err(RawError::malformed("CR2 sRAW slices do not match the JPEG frame")); + } + let (width, height) = (row / per_mcu * frame.h, total / row * frame.v); + // The RGB output: three u16 per pixel. + limits.check(width as u64, height as u64, 6)?; + let (_, samples) = ljpeg::decode_subsampled(src, total)?; + let planes = sraw::planes(&frame, &samples, &widths)?; + drop(samples); + + let mut warnings = Vec::new(); + let mn = maker_note(t, ifds); + let camera_wb = mn.as_ref().and_then(|m| as_shot_wb(t, m)); + let gains = mn.as_ref().and_then(|m| sraw_gains(t, m)); + if gains.is_none() { + warnings.push("the sRAW colour gains of this camera are not known; colours rely on the look fitted to the camera's JPEG".to_string()); + } + let offset = (1u32 << (frame.precision - 1)) as f32; + let data = sraw::camera_rgb(&planes, offset, gains.unwrap_or([1.0; 3])); + drop(planes); + let full = Rect::new(0, 0, width, height); + let active = image_area(t, mn.as_ref(), full); + let white = [0, 1, 2].map(|k| sraw::clip_level(&data, k, sraw::FULL_SCALE)); + Ok(Sensor { + format: RawFormat::Cr2, + make: t.tag_ascii(ifd0, tag::MAKE), + model: t.tag_ascii(ifd0, tag::MODEL), + width, + height, + samples: 3, + data, + cfa: None, + linearization: None, + // Y is stored black-subtracted. + black: BlackLevels::uniform(0.0), + white, + active, + crop: active, + color: Default::default(), + camera_wb, + orientation: crate::tiff::orientation(t.tag_uint(ifd0, tag::ORIENTATION)), + baseline_exposure: 0.0, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings, + }) +} + pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { let ifds = t.all_ifds(); let ifd0 = t.ifd_at(t.first_ifd, 0).ok_or_else(|| RawError::malformed("CR2 has no IFD0"))?; @@ -191,23 +284,16 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { let src = t.bytes(off, len).or_else(|| t.data.get(off..)).ok_or_else(|| RawError::malformed("CR2 raw data lies outside the file"))?; let frame = ljpeg::frame(src).map_err(|e| match e { - RawError::Unsupported(m) if m.contains("subsampled") => RawError::unsupported("Canon sRAW / mRAW"), + RawError::Unsupported(m) if m.contains("subsampled") => RawError::unsupported("this Canon sRAW / mRAW layout"), e => e, })?; let total = frame.samples().ok_or_else(|| RawError::malformed("CR2 frame too large"))?; let slices = t.tag_uints(&raw, tag::CR2_SLICE); - let (width, widths) = match slices.as_slice() { - [n, w, last] if *n < 64 && *w > 0 && *last > 0 => { - let (n, w, last) = (*n as usize, *w as usize, *last as usize); - let mut ws = vec![w; n]; - ws.push(last); - (n * w + last, ws) - } - _ => { - let w = frame.width * frame.components; - (w, vec![w]) - } - }; + if frame.is_subsampled() { + return decode_sraw(t, &ifds, &ifd0, &slices, src, frame, limits); + } + let widths = slice_widths(&slices).unwrap_or_else(|| vec![frame.width * frame.components]); + let width: usize = widths.iter().sum(); if width == 0 || total % width != 0 { return Err(RawError::malformed("CR2 slices do not match the JPEG frame")); } @@ -231,13 +317,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { let mut warnings = Vec::new(); let mn = maker_note(t, &ifds); let full = Rect::new(0, 0, width, height); - // SensorInfo: [1] width, [2] height, [5] left, [6] top, [7] right, [8] bottom border (inclusive). - let info = mn.as_ref().map(|m| t.tag_uints(m, CANON_SENSOR_INFO)).unwrap_or_default(); - let active = match info.get(5..9) { - Some(&[l, tp, r, b]) if r > l && b > tp => Rect::new(l as usize, tp as usize, (r - l + 1) as usize, (b - tp + 1) as usize).intersect(&full), - _ => full, - }; - let active = if active.is_empty() { full } else { active }; + let active = image_area(t, mn.as_ref(), full); // Black: the masked columns left of the image (skipping a few edge columns). let black = if active.x >= 16 { // Only the outer half: the columns next to the image area can be partly exposed. diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 6303931811..ea8493023a 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -147,6 +147,7 @@ impl Padded { } /// Per 2×2 position, per output colour: kernel taps as (offset, weight). +#[cfg(test)] fn kernels(phase: [u8; 4], method: Demosaic, stride: usize) -> [[Vec<(isize, f32)>; 3]; 4] { let color = |x: isize, y: isize| phase[((y & 1) * 2 + (x & 1)) as usize]; let s = stride as isize; @@ -213,9 +214,8 @@ pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Ma ); return out; } - let ks = kernels(phase, method, s); let rows = par::band_rows(w); - par::chunks_mut(&mut out, rows * w * 3, |band, chunk| linear_band(&p.data, w, s, phase, method, &ks, band * rows, 0, chunk)); + par::chunks_mut(&mut out, rows * w * 3, |band, chunk| linear_band(&p.data, w, s, phase, method, band * rows, 0, chunk)); out } @@ -266,13 +266,12 @@ pub(crate) fn demosaic_fused( }); return; } - let ks = kernels(phase, method, s); let rows = h.min(FUSED_ROWS); let init = || (vec![0.0f32; rows * w * 3], vec![0.0f32; (rows + 2 * PAD) * s]); for_bands(out, FUSED_ROWS * w * 3, h.div_ceil(FUSED_ROWS), jobs, init, |band, chunk, (buf, win)| { load(win, band * FUSED_ROWS, chunk.len() / (w * 3)); let buf = &mut buf[..chunk.len()]; - linear_band(win, w, s, phase, method, &ks, band * FUSED_ROWS, band * FUSED_ROWS, buf); + linear_band(win, w, s, phase, method, band * FUSED_ROWS, band * FUSED_ROWS, buf); emit(buf, chunk); }); } @@ -283,40 +282,120 @@ pub(crate) const FUSED_ROWS: usize = 32; /// Rows `y0..` of bilinear or MHC output (`chunk.len() / (w * 3)` of them); `d` is the padded /// plane from image row `dy` on (`dy` 0: all of it). #[allow(clippy::too_many_arguments)] -fn linear_band(d: &[f32], w: usize, s: usize, phase: [u8; 4], method: Demosaic, ks: &[[Vec<(isize, f32)>; 3]; 4], y0: usize, dy: usize, chunk: &mut [f32]) { +fn linear_band(d: &[f32], w: usize, s: usize, phase: [u8; 4], method: Demosaic, y0: usize, dy: usize, chunk: &mut [f32]) { for (r, row) in chunk.chunks_exact_mut(w * 3).enumerate() { let y = y0 + r; let base = (y - dy + PAD) * s + PAD; - for (x, o) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { - let pos = (y & 1) * 2 + (x & 1); - let i = base + x; - if method == Demosaic::Bilinear { - // Direct Bayer bilinear (the kernels' result without the tap loops). - let own = usize::from(phase[pos]).min(2); - let (n, so, e, wv) = (d[i - s], d[i + s], d[i + 1], d[i - 1]); - if own == 1 { - let hc = usize::from(phase[(y & 1) * 2 + ((x + 1) & 1)]).min(2); - o[1] = d[i]; - o[hc] = (e + wv) * 0.5; - o[2 - hc] = (n + so) * 0.5; - } else { - o[own] = d[i]; - o[1] = (n + so + e + wv) * 0.25; - o[2 - own] = (d[i - s - 1] + d[i - s + 1] + d[i + s - 1] + d[i + s + 1]) * 0.25; + // The two colours of this row's 2-pixel period; the row kinds a Bayer pattern has get + // code of their own with the colours as constants. + let (c0, c1) = (usize::from(phase[(y & 1) * 2]).min(2), usize::from(phase[(y & 1) * 2 + 1]).min(2)); + match (method == Demosaic::Bilinear, c0, c1) { + (true, 0, 1) => linear_row::<0, 1, false>(d, base, s, row), + (true, 1, 0) => linear_row::<1, 0, false>(d, base, s, row), + (true, 2, 1) => linear_row::<2, 1, false>(d, base, s, row), + (true, 1, 2) => linear_row::<1, 2, false>(d, base, s, row), + (false, 0, 1) => linear_row::<0, 1, true>(d, base, s, row), + (false, 1, 0) => linear_row::<1, 0, true>(d, base, s, row), + (false, 2, 1) => linear_row::<2, 1, true>(d, base, s, row), + (false, 1, 2) => linear_row::<1, 2, true>(d, base, s, row), + (bilinear, _, _) => { + for (x, o) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (own, hc) = if x & 1 == 0 { (c0, c1) } else { (c1, c0) }; + *o = if bilinear { bilinear_site(d, base + x, s, own, hc) } else { mhc_site(d, base + x, s, own, hc) }; } - continue; - } - for (c, k) in ks[pos].iter().enumerate() { - let mut v = 0.0f32; - for &(off, wt) in k { - v += wt * d[(i as isize + off) as usize]; - } - o[c] = v.clamp(0.0, 1.0); } } } } +/// A row of output whose even pixels are colour `A` and odd pixels colour `B`, MHC or bilinear. +fn linear_row(d: &[f32], base: usize, s: usize, row: &mut [f32]) { + let site = |i: usize, own: usize, hc: usize| if MHC { mhc_site(d, i, s, own, hc) } else { bilinear_site(d, i, s, own, hc) }; + let (pairs, rest) = row.as_chunks_mut::<6>(); + for (j, pair) in pairs.iter_mut().enumerate() { + let i = base + 2 * j; + pair[..3].copy_from_slice(&site(i, A, B)); + pair[3..].copy_from_slice(&site(i + 1, B, A)); + } + if let Some(last) = rest.first_chunk_mut::<3>() { + *last = site(base + 2 * pairs.len(), A, B); + } +} + +/// Direct Bayer bilinear (the kernels' result without the tap loops) for the pixel at plane index +/// `i` of colour `own` (and, when green, its row neighbours' colour `hc`). +#[inline(always)] +fn bilinear_site(d: &[f32], i: usize, s: usize, own: usize, hc: usize) -> [f32; 3] { + let mut o = [0.0f32; 3]; + let (n, so, e, wv) = (d[i - s], d[i + s], d[i + 1], d[i - 1]); + if own == 1 { + o[1] = d[i]; + o[hc] = (e + wv) * 0.5; + o[2 - hc] = (n + so) * 0.5; + } else { + o[own] = d[i]; + o[1] = (n + so + e + wv) * 0.25; + o[2 - own] = (d[i - s - 1] + d[i - s + 1] + d[i + s - 1] + d[i + s + 1]) * 0.25; + } + o +} + +/// Malvar–He–Cutler for the pixel at plane index `i`, whose colour is `own` (and, when green, its +/// row neighbours' `hc`): the kernels of `kernels` written out, the same taps in the same order +/// with their weights (kernel / 8, exact in f32), so the sums are those of the tap loop. +#[inline(always)] +fn mhc_site(d: &[f32], i: usize, s: usize, own: usize, hc: usize) -> [f32; 3] { + let mut o = [0.0f32; 3]; + o[own] = (0.0 + d[i]).clamp(0.0, 1.0); + if own == 1 { + // R and B at a green site: the row neighbours' colour from the row, the other from the + // column. + o[hc] = mhc_at_green(d, i, 1, s, s).clamp(0.0, 1.0); + o[2 - hc] = mhc_at_green(d, i, s, 1, s).clamp(0.0, 1.0); + } else { + let v = 0.0 + + 0.5 * d[i] + + 0.25 * d[i - 1] + + 0.25 * d[i + 1] + + 0.25 * d[i - s] + + 0.25 * d[i + s] + + -0.125 * d[i - 2] + + -0.125 * d[i + 2] + + -0.125 * d[i - 2 * s] + + -0.125 * d[i + 2 * s]; + o[1] = v.clamp(0.0, 1.0); + let v = 0.0 + + 0.75 * d[i] + + 0.25 * d[i - s - 1] + + 0.25 * d[i - s + 1] + + 0.25 * d[i + s - 1] + + 0.25 * d[i + s + 1] + + -0.1875 * d[i - 2] + + -0.1875 * d[i + 2] + + -0.1875 * d[i - 2 * s] + + -0.1875 * d[i + 2 * s]; + o[2 - own] = v.clamp(0.0, 1.0); + } + o +} + +/// MHC's red or blue at a green site: `a` is the step along the colour's own line (1 along the +/// row, `s` down the column), `b` the step across it. +#[inline(always)] +fn mhc_at_green(d: &[f32], i: usize, a: usize, b: usize, s: usize) -> f32 { + 0.0 + 0.625 * d[i] + + 0.5 * d[i - a] + + 0.5 * d[i + a] + + -0.125 * d[i - s - 1] + + -0.125 * d[i - s + 1] + + -0.125 * d[i + s - 1] + + -0.125 * d[i + s + 1] + + -0.125 * d[i - 2 * a] + + -0.125 * d[i + 2 * a] + + 0.0625 * d[i - 2 * b] + + 0.0625 * d[i + 2 * b] +} + /// Runs `f(band, chunk, scratch)` over the `unit`-sized chunks of `out` (`bands` of them), with /// at most `jobs` scratch values alive. When every worker may hold one, each band is its own /// rayon task and borrows a scratch from a pool (one per worker at most); otherwise a few groups @@ -1286,6 +1365,85 @@ mod tests { } } + /// The tap-loop evaluation of bilinear and MHC that `linear_band` replaced. + #[allow(clippy::too_many_arguments)] + fn linear_band_ref( + d: &[f32], + w: usize, + s: usize, + phase: [u8; 4], + method: Demosaic, + ks: &[[Vec<(isize, f32)>; 3]; 4], + y0: usize, + dy: usize, + chunk: &mut [f32], + ) { + for (r, row) in chunk.chunks_exact_mut(w * 3).enumerate() { + let y = y0 + r; + let base = (y - dy + PAD) * s + PAD; + for (x, o) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let pos = (y & 1) * 2 + (x & 1); + let i = base + x; + if method == Demosaic::Bilinear { + // Direct Bayer bilinear (the kernels' result without the tap loops). + let own = usize::from(phase[pos]).min(2); + let (n, so, e, wv) = (d[i - s], d[i + s], d[i + 1], d[i - 1]); + if own == 1 { + let hc = usize::from(phase[(y & 1) * 2 + ((x + 1) & 1)]).min(2); + o[1] = d[i]; + o[hc] = (e + wv) * 0.5; + o[2 - hc] = (n + so) * 0.5; + } else { + o[own] = d[i]; + o[1] = (n + so + e + wv) * 0.25; + o[2 - own] = (d[i - s - 1] + d[i - s + 1] + d[i + s - 1] + d[i + s + 1]) * 0.25; + } + continue; + } + for (c, k) in ks[pos].iter().enumerate() { + let mut v = 0.0f32; + for &(off, wt) in k { + v += wt * d[(i as isize + off) as usize]; + } + o[c] = v.clamp(0.0, 1.0); + } + } + } + } + + #[test] + fn mhc_written_out_gives_the_bits_of_the_tap_loop() { + // Every Bayer phase, odd and tiny sizes, values outside 0..1, signed zeros, NaN and infinities. + for (w, h) in [(1, 1), (2, 3), (5, 4), (17, 13), (64, 9)] { + let mut s = 0x9E37_79B9_7F4A_7C15u64; + let mut plane: Vec = (0..w * h) + .map(|_| { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((s >> 40) as f32 / (1u64 << 24) as f32) * 1.6 - 0.3 + }) + .collect(); + for (k, v) in [0.0, -0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1.0, 1e-30].into_iter().enumerate() { + let n = plane.len(); + plane[(k * 7 + 1) % n] = v; + } + let p = Padded::from_plane(&plane, w, h); + for phase in [[0, 1, 1, 2], [2, 1, 1, 0], [1, 0, 2, 1], [1, 2, 0, 1]] { + for method in [Demosaic::Bilinear, Demosaic::Mhc] { + let ks = kernels(phase, method, p.stride); + let mut want = vec![0.0f32; w * h * 3]; + linear_band_ref(&p.data, w, p.stride, phase, method, &ks, 0, 0, &mut want); + let mut got = vec![0.0f32; w * h * 3]; + linear_band(&p.data, w, p.stride, phase, method, 0, 0, &mut got); + // Equal bits; a NaN may come out with another sign or payload (the order the + // two evaluations meet two NaNs in), which the encoding maps to the same code. + let bad: Vec<_> = + got.iter().zip(&want).enumerate().filter(|(_, (a, b))| a.to_bits() != b.to_bits() && !(a.is_nan() && b.is_nan())).collect(); + assert!(bad.is_empty(), "{w}x{h} {phase:?} {}: {bad:?}", method.id()); + } + } + } + } + #[test] fn lab_table_is_accurate() { let l = LabF::new(); diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index aaeb390eff..ee7136e2cb 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -118,6 +118,70 @@ impl<'a> Scale<'a> { } } +/// Row `y` of a LinearRaw (3-sample or monochrome) frame from column `x0` on, `row.len() / 3` +/// pixels: [`Scale::value`] times the white balance, clipped. Monochrome data fills all three +/// channels with its one sample. +/// +/// The black level of a row depends on the column only through the repeat pattern, so (when +/// there are no per-column deltas) it is worked out once per row and phase instead of per sample. +fn fill_linear_row(sc: &Scale, mult: &[f32; 3], x0: usize, y: usize, row: &mut [f32]) { + let s = sc.s; + let spp = s.samples; + let w = row.len() / 3; + let bl = &s.black; + let cols = bl.cols.max(1); + if !bl.delta_h.is_empty() || !bl.delta_v.is_empty() || x0 < s.active.x || cols > 16 || !matches!(spp, 1 | 3) { + for (i, px) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + for k in 0..3 { + px[k] = (sc.value(x0 + i, y, k.min(spp.saturating_sub(1))) * mult[k]).clamp(0.0, 1.0); + } + } + return; + } + // Black level and range per column phase and channel. + let ay = y.saturating_sub(s.active.y); + let table: Vec<[(f32, f32); 3]> = (0..cols) + .map(|p| { + [0, 1, 2].map(|k| { + let sample = k.min(spp - 1); + let black = bl.at(p, ay, sample, spp); + (black, s.white[sample.min(2)] - black) + }) + }) + .collect(); + let src = &s.data[(y * s.width + x0) * spp..(y * s.width + x0 + w) * spp]; + let phase = (x0 - s.active.x) % cols; + let lin = |raw: u16| match sc.lin { + Some(l) => f32::from(l.get(usize::from(raw)).or_else(|| l.last()).copied().unwrap_or(raw)), + None => f32::from(raw), + }; + match spp { + 3 => fill_samples(row, src.as_chunks::<3>().0.iter().copied(), &table, phase, mult, lin), + _ => fill_samples(row, src.iter().map(|r| [*r; 3]), &table, phase, mult, lin), + } +} + +/// The pixels of [`fill_linear_row`]: `raw` gives each pixel's three raw values (the one sample +/// of monochrome data three times), `table` the black level and range per column phase. +#[inline(always)] +fn fill_samples(row: &mut [f32], raw: impl Iterator, table: &[[(f32, f32); 3]], phase: usize, mult: &[f32; 3], lin: impl Fn(u16) -> f32) { + let mut phase = phase; + for (px, raw) in row.as_chunks_mut::<3>().0.iter_mut().zip(raw) { + let t = &table[phase]; + phase = if phase + 1 == table.len() { 0 } else { phase + 1 }; + for k in 0..3 { + let (black, range) = t[k]; + let n = if range.is_nan() || range < 1.0 { + 0.0 + } else { + let n = (lin(raw[k]) - black) / range; + if n.is_finite() { n } else { 0.0 } + }; + px[k] = (n * mult[k]).clamp(0.0, 1.0); + } + } +} + /// Grey-world neutral (camera-space R, G, B) from unclipped mid-range samples. fn grey_world(s: &Sensor, sc: &Scale) -> [f64; 3] { let c = s.crop; @@ -293,13 +357,7 @@ fn develop(s: &Sensor, opts: &DevelopOptions, allow_fused: bool) -> Result (us } } +/// Side of the square tiles orientations 5-8, which swap rows and columns, move at a time: both +/// sides of a tile stay in L1 instead of one of them being walked a column at a time. +const TILE: usize = 32; + /// Applies a TIFF orientation (1–8) to interleaved RGB. pub(crate) fn orient(src: Vec, w: usize, h: usize, o: u16) -> (Vec, usize, usize) { if !(2..=8).contains(&o) { @@ -715,25 +777,44 @@ pub(crate) fn orient(src: Vec, w: usize, h: usize, o: u16) -> (Vec, us let swap = o >= 5; let (ow, oh) = if swap { (h, w) } else { (w, h) }; let mut out = vec![0u16; src.len()]; - let band = par::band_rows(ow); - par::chunks_mut(&mut out, band * ow * 3, |b, chunk| { - for (r, row) in chunk.chunks_exact_mut(ow * 3).enumerate() { - let y2 = b * band + r; - for x2 in 0..ow { - let (x, y) = match o { - 2 => (w - 1 - x2, y2), - 3 => (w - 1 - x2, h - 1 - y2), - 4 => (x2, h - 1 - y2), - 5 => (y2, x2), - 6 => (y2, h - 1 - x2), - 7 => (w - 1 - y2, h - 1 - x2), - _ => (w - 1 - y2, x2), - }; - let i = (y * w + x) * 3; - row[x2 * 3..x2 * 3 + 3].copy_from_slice(&src[i..i + 3]); + if swap { + // Output pixel (x2, y2) is source pixel `base + x2 * sx + y2 * sy` (see `source_xy`). + let (base, sx, sy): (isize, isize, isize) = match o { + 5 => (0, w as isize, 1), + 6 => (((h - 1) * w) as isize, -(w as isize), 1), + 7 => ((h * w - 1) as isize, -(w as isize), -1), + _ => ((w - 1) as isize, w as isize, -1), + }; + let px = src.as_chunks::<3>().0; + par::chunks_mut(&mut out, TILE * ow * 3, |b, chunk| { + let rows = chunk.len() / (ow * 3); + for x0 in (0..ow).step_by(TILE) { + let x1 = (x0 + TILE).min(ow); + for r in 0..rows { + let y2 = (b * TILE + r) as isize; + for (x2, d) in (x0..x1).zip(chunk[(r * ow + x0) * 3..(r * ow + x1) * 3].as_chunks_mut::<3>().0) { + *d = px[(base + x2 as isize * sx + y2 * sy) as usize]; + } + } } - } - }); + }); + } else { + let band = par::band_rows(ow); + par::chunks_mut(&mut out, band * ow * 3, |b, chunk| { + for (r, row) in chunk.chunks_exact_mut(ow * 3).enumerate() { + let y2 = b * band + r; + for x2 in 0..ow { + let (x, y) = match o { + 2 => (w - 1 - x2, y2), + 3 => (w - 1 - x2, h - 1 - y2), + _ => (x2, h - 1 - y2), + }; + let i = (y * w + x) * 3; + row[x2 * 3..x2 * 3 + 3].copy_from_slice(&src[i..i + 3]); + } + } + }); + } (out, ow, oh) } @@ -1000,6 +1081,64 @@ mod tests { assert!(near(GammaLut::with_curve(&[]).encode(0.25), code(0.25))); } + #[test] + fn linear_fill_gives_the_bits_of_the_per_sample_scale() { + // 3-sample and monochrome frames, black patterns, deltas, a table, an offset crop. + for samples in [1usize, 3] { + for (rows, cols, deltas) in [(1, 1, false), (2, 2, false), (1, 3, false), (2, 1, true), (1, 17, false), (4, 4, false)] { + for table in [false, true] { + let (w, h) = (53, 9); + let mut s = test_sensor(w, h, None, samples); + s.active = crate::Rect::new(2, 1, w - 2, h - 1); + s.black.rows = rows; + s.black.cols = cols; + s.black.values = (0..rows * cols * samples).map(|i| 40.0 + (i * 7 % 23) as f32 + 0.25 * (i % 3) as f32).collect(); + if deltas { + s.black.delta_h = (0..w).map(|i| (i % 5) as f32).collect(); + s.black.delta_v = (0..h).map(|i| 0.5 * i as f32).collect(); + } + s.white = [4000.0, 3900.5, 0.5]; + if table { + s.linearization = Some((0..3000u32).map(|i| (i * 3 / 2) as u16).collect()); + } + let sc = Scale::new(&s); + let mult = [1.3f32, 0.9, f32::INFINITY]; + for x0 in [0usize, 2, 5] { + let n = w - x0 - 1; + for y in 0..h { + let mut got = vec![0.0f32; n * 3]; + fill_linear_row(&sc, &mult, x0, y, &mut got); + let want: Vec = + (0..n * 3).map(|j| (sc.value(x0 + j / 3, y, (j % 3).min(samples - 1)) * mult[j % 3]).clamp(0.0, 1.0)).collect(); + assert!( + got.iter().zip(&want).all(|(a, b)| a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan())), + "{samples} samples, black {rows}x{cols}, table {table}, x0 {x0}, row {y}" + ); + } + } + } + } + } + } + + #[test] + fn every_orientation_moves_each_pixel_to_its_source_position() { + // Sizes around the tile side (and 1-pixel strips), all eight orientations. + for (w, h) in [(1, 1), (1, 70), (70, 1), (31, 33), (32, 32), (33, 31), (65, 97), (100, 37)] { + let src: Vec = (0..w * h).flat_map(|i| [i as u16, (i >> 4) as u16 ^ 0x5a5a, !(i as u16)]).collect(); + for o in 1..=8 { + let (out, ow, oh) = orient(src.clone(), w, h, o); + assert_eq!((ow, oh), if o >= 5 { (h, w) } else { (w, h) }); + for y2 in 0..oh { + for x2 in 0..ow { + let (x, y) = source_xy(o, w, h, x2, y2); + assert_eq!(out[(y2 * ow + x2) * 3..][..3], src[(y * w + x) * 3..][..3], "{w}x{h} orientation {o} at {x2},{y2}"); + } + } + } + } + } + #[test] fn orientations() { // 3×2 image, pixel value = index. diff --git a/crates/raw/src/hasselblad.rs b/crates/raw/src/hasselblad.rs new file mode 100644 index 0000000000..1b9c7ec08c --- /dev/null +++ b/crates/raw/src/hasselblad.rs @@ -0,0 +1,130 @@ +//! Hasselblad 3FR / FFF: a TIFF whose IFD0 holds the camera's JPEG preview and +//! whose CFA image sits in a SubIFD with DNG-style tags (BlackLevel, WhiteLevel, +//! DefaultCropOrigin / DefaultCropSize, AsShotNeutral, ColorMatrix1) but no +//! DNGVersion and no CFA-pattern tag — neither the TIFF/EP 33421/33422 pair nor +//! the EXIF one — so the Bayer phase comes from measurements of each body. +//! +//! Decoded today: the X2D II 100C's 16-bit uncompressed samples (Compression 1, +//! one strip covering the whole sensor, masked borders included; the crop to the +//! usable area comes from the file's DefaultCrop tags). Bodies that store +//! lossless-JPEG strips (Compression 7, the earlier X and H generations) are +//! recognised but not decoded yet. +//! +//! Clean-room: derived from the TIFF 6.0 and DNG specifications, observation of +//! sample files, and the camera's own embedded JPEG — the oracle the +//! camera-profile measurements in [`crate::cameras`] use. No code or tables +//! from dcraw, LibRaw, rawspeed, rawler or darktable. + +use crate::cr2::clip_level; +use crate::error::{RawError, Result}; +use crate::sensor::{BlackLevels, Cfa, JpegLayout, Rect, Sensor, read_plane}; +use crate::tiff::{Ifd, Tiff, tag}; +use crate::tiffep; +use crate::{Limits, RawFormat}; + +/// The Bayer colours at the data origin, row-major (0 red, 1 green, 2 blue). +/// Hasselblad records no CFA tag, so a body's phase is measured against its own +/// embedded JPEG: on an X2D II 100C dusk landscape the blue sky reads red at the +/// origin site at about half the green rate while the blue site holds three +/// quarters of it, the reverse of the warm ground. +fn cfa_for(model: Option<&str>) -> (Cfa, bool) { + let measured = model.is_some_and(|m| m.eq_ignore_ascii_case("X2D II 100C")); + (Cfa { width: 2, height: 2, colors: vec![0, 1, 1, 2], origin_x: 0, origin_y: 0 }, measured) +} + +pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { + let ifds = t.all_ifds(); + let Some(raw) = tiffep::raw_ifd(t, &ifds) else { + return Err(RawError::unsupported("Hasselblad 3FR/FFF: no CFA image found")); + }; + match t.tag_uint(&raw, tag::COMPRESSION).unwrap_or(1) { + 1 => {} + 7 => return Err(RawError::unsupported("lossless-JPEG (compressed 3FR/FFF)")), + c => return Err(RawError::unsupported(format!("raw compression scheme {c}"))), + } + let plane = read_plane(t, &raw, limits, JpegLayout::Flat)?; + if plane.samples != 1 { + return Err(RawError::unsupported(format!("CFA data with {} samples per pixel", plane.samples))); + } + let make = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MAKE)); + let model = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MODEL)); + let (cfa, measured) = cfa_for(model.as_deref()); + let mut warnings = Vec::new(); + if !measured { + warnings.push(format!( + "{}: this body's Bayer phase is not measured yet; assumed the X2D II 100C's (red at the data origin)", + model.as_deref().unwrap_or("Hasselblad") + )); + } + let full = Rect::new(0, 0, plane.width, plane.height); + // DNG-style ActiveArea (top, left, bottom, right) in the raw IFD; an absent, + // inverted or out-of-plane one leaves the whole plane active. + let active = match t.tag_uints(&raw, tag::ACTIVE_AREA).as_slice() { + [top, left, bottom, right] if bottom > top && right > left => { + Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize).intersect(&full) + } + _ => full, + }; + let active = if active.is_empty() { full } else { active }; + // DNG-style DefaultCropOrigin / DefaultCropSize in the raw IFD, relative to + // the active area as in DNG. + let mut crop = active; + if let ([ox, oy], [cw, ch]) = (t.tag_floats(&raw, tag::DEFAULT_CROP_ORIGIN).as_slice(), t.tag_floats(&raw, tag::DEFAULT_CROP_SIZE).as_slice()) { + let f = |v: f64| if v.is_finite() && v >= 0.0 { v.round() as usize } else { 0 }; + let r = Rect::new(active.x.saturating_add(f(*ox)), active.y.saturating_add(f(*oy)), f(*cw), f(*ch)).intersect(&active); + if !r.is_empty() { + crop = r; + } + } + let bl = t.tag_floats(&raw, tag::BLACK_LEVEL); + let black = match bl.as_slice() { + [b] if b.is_finite() && *b >= 0.0 => BlackLevels::uniform(*b as f32), + // A 2×2 repeat is in the CFA's own row-major order. + [r, g1, g2, b] if [*r, *g1, *g2, *b].iter().all(|v| v.is_finite() && *v >= 0.0) => { + BlackLevels { rows: 2, cols: 2, values: [r, g1, g2, b].map(|v| *v as f32).to_vec(), delta_h: Vec::new(), delta_v: Vec::new() } + } + _ => { + warnings.push("Hasselblad 3FR/FFF: black level not recorded; assumed 0".into()); + BlackLevels::uniform(0.0) + } + }; + let white = + t.tag_floats(&raw, tag::WHITE_LEVEL).first().copied().filter(|w| *w > 0.0).map(|w| w as f32).unwrap_or_else(|| clip_level(&plane.data, plane.bits)); + // AsShotNeutral (IFD0) is the camera-space colour of a neutral patch: the + // green-normalised multipliers are the other channels over green. + let camera_wb = ifds + .iter() + .find_map(|i: &Ifd| t.tag_floats(i, tag::AS_SHOT_NEUTRAL).try_into().ok()) + .and_then(|n: [f64; 3]| match n { + [r, g, b] if g > 0.0 && r > 0.0 && b > 0.0 => Some([g / r, 1.0, g / b]), + _ => None, + }) + .filter(|m| m.iter().all(|v| (0.25..8.0).contains(v))); + let ifd0 = t.ifd_at(t.first_ifd, 0); + let baseline_exposure = ifd0.as_ref().map(|i| t.tag_floats(i, tag::BASELINE_EXPOSURE).first().copied().unwrap_or(0.0)).unwrap_or(0.0); + let mut sensor = Sensor { + format: RawFormat::Fff, + make, + model, + width: plane.width, + height: plane.height, + samples: 1, + data: plane.data, + cfa: Some(cfa), + linearization: None, + black, + white: [white; 3], + active, + crop, + color: Default::default(), + camera_wb, + orientation: crate::tiff::orientation(ifd0.as_ref().and_then(|i| t.tag_uint(i, tag::ORIENTATION))), + baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 }, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings, + }; + crate::cameras::apply(&mut sensor, plane.bits); + Ok(sensor) +} diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 8dc2ff064f..8ec7611923 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -34,9 +34,19 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, // than the caller's per-buffer limit. let budget = (w as u64).saturating_mul(h as u64).saturating_mul(samples as u64).saturating_mul(16).min(limits.max_alloc); let budget = usize::try_from(budget).unwrap_or(usize::MAX); + // jxl-oxide inflates Brotli-compressed boxes (`brob`: Exif, XMP) into storage the tracker + // doesn't see, while it reads. A raw tile has no use for metadata, so refuse it unread. + if has_compressed_box(src) { + return Err(RawError::unsupported("JPEG XL raw tile with compressed metadata boxes")); + } guarded(|| { let image = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(budget)).read(src).map_err(|e| RawError::malformed(format!("JPEG XL raw data: {e}")))?; + // Rendering samples spline geometry into an untracked vector with no bound on the sample + // count, whatever their colour; sensor data never draws splines. + if (0..image.num_loaded_frames()).filter_map(|i| image.frame(i)).any(|f| f.header().flags.splines()) { + return Err(RawError::unsupported("JPEG XL raw tile with splines")); + } let (jw, jh) = (image.width() as usize, image.height() as usize); if (jw, jh) != (w, h) { return Err(RawError::malformed(format!("JPEG XL raw tile is {jw}x{jh}, expected {w}x{h}"))); @@ -76,7 +86,65 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, }) } +/// The ISO/IEC 18181-2 container's signature box. +#[cfg(not(target_arch = "wasm32"))] +const CONTAINER_SIGNATURE: &[u8] = &[0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A]; + +/// Whether `src` is a JPEG XL container holding a Brotli-compressed (`brob`) box. A malformed box +/// size ends the walk: the decoder refuses that file itself. +#[cfg(not(target_arch = "wasm32"))] +fn has_compressed_box(src: &[u8]) -> bool { + if !src.starts_with(CONTAINER_SIGNATURE) { + return false; + } + let mut at = 0usize; + while let Some(head) = src.get(at..at.saturating_add(8)) { + if &head[4..] == b"brob" { + return true; + } + let size = match u32::from_be_bytes([head[0], head[1], head[2], head[3]]) { + 0 => return false, // the last box, running to the end + 1 => match src.get(at + 8..at.saturating_add(16)).and_then(|b| <[u8; 8]>::try_from(b).ok()) { + Some(b) => u64::from_be_bytes(b), + None => return false, + }, + n => u64::from(n), + }; + match usize::try_from(size).ok().filter(|&s| s >= 8).and_then(|s| at.checked_add(s)) { + Some(next) => at = next, + None => return false, + } + } + false +} + #[cfg(not(target_arch = "wasm32"))] fn guarded(f: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(RawError::malformed("the JPEG XL decoder failed on this file"))) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::{CONTAINER_SIGNATURE, decode, has_compressed_box}; + use crate::Limits; + + fn container(boxes: &[(&[u8; 4], &[u8])]) -> Vec { + let mut out = CONTAINER_SIGNATURE.to_vec(); + for (kind, body) in boxes { + out.extend_from_slice(&(body.len() as u32 + 8).to_be_bytes()); + out.extend_from_slice(*kind); + out.extend_from_slice(body); + } + out + } + + #[test] + fn a_compressed_metadata_box_is_refused_before_decoding() { + let brob = container(&[(b"ftyp", b"jxl \0\0\0\0jxl "), (b"brob", b"Exif\x8b\x02\x80"), (b"jxlc", &[0xFF, 0x0A])]); + assert!(has_compressed_box(&brob)); + assert!(decode(&brob, 1, 1, 1, 16, None, &Limits::default()).is_err()); + assert!(!has_compressed_box(&container(&[(b"ftyp", b"jxl \0\0\0\0jxl "), (b"jxlc", &[0xFF, 0x0A])]))); + // A bare codestream is not a container. + assert!(!has_compressed_box(&[0xFF, 0x0A, 0, 0, 0, 0, b'b', b'r', b'o', b'b'])); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 43a3ff47c8..b5f241d5e2 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -29,12 +29,13 @@ //! image area (the D800E, the ILCE-7). //! //! Decoded today: DNG (uncompressed, deflate, lossless-JPEG, lossy-JPEG and JPEG XL, strips and -//! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or +//! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices, and sRAW / mRAW), uncompressed or //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), //! Panasonic RW2 (RawFormat 5 packed and RawFormat 3/4 vendor-compressed), -//! uncompressed Olympus ORF and uncompressed Fujifilm RAF (Bayer and -//! X-Trans). Everything else reports [`RawError::Unsupported`]. +//! uncompressed Olympus ORF, uncompressed Fujifilm RAF (Bayer and +//! X-Trans), and Hasselblad 3FR/FFF (the X2D II 100C's uncompressed 16-bit CFA; +//! see [`crate::hasselblad`]). Everything else reports [`RawError::Unsupported`]. //! //! The crate is standalone (no workspace dependencies), does no I/O, builds for //! `wasm32-unknown-unknown` and never panics on hostile input: sizes are @@ -51,6 +52,7 @@ mod demosaic; mod develop; mod dng; mod error; +mod hasselblad; mod jxl; mod ljpeg; mod look; @@ -65,6 +67,7 @@ mod saturation; mod sensor; mod sony; mod sony_metadata; +mod sraw; mod tiff; mod tiffep; mod tone; @@ -118,6 +121,8 @@ pub enum RawFormat { Rw2, /// Fujifilm RAF. Raf, + /// Hasselblad 3FR / FFF. + Fff, /// Another TIFF/EP raw. TiffEp, } @@ -134,13 +139,14 @@ impl RawFormat { RawFormat::Orf => "ORF", RawFormat::Rw2 => "RW2", RawFormat::Raf => "RAF", + RawFormat::Fff => "3FR/FFF", RawFormat::TiffEp => "TIFF/EP raw", } } } /// File extensions of the raw formats this crate recognises. -pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf"]; +pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf", "3fr", "fff"]; /// Decompression-bomb guards, checked on header values before allocating /// (the same shape and defaults as `photocraft-codecs`' limits). @@ -194,6 +200,8 @@ fn make_format(make: Option<&str>) -> RawFormat { RawFormat::Pef } else if m.starts_with("OLYMPUS") || m.starts_with("OM DIGITAL") { RawFormat::Orf + } else if m.starts_with("HASSELBLAD") { + RawFormat::Fff } else { RawFormat::TiffEp } @@ -248,6 +256,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { RawFormat::Cr2 => cr2::decode(&t, limits), RawFormat::Rw2 => rw2::decode(&t, limits), RawFormat::Orf => orf::decode(&t, limits), + RawFormat::Fff => hasselblad::decode(&t, limits), f => tiffep::decode(&t, f, limits), } } diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index 070b3757fd..ffb44f0cb2 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -8,8 +8,10 @@ //! //! Output is the sample sequence in raster order: `height` rows of `width × //! components` interleaved samples. Only one interleaved scan holding every -//! component with sampling factors 1×1 is supported, which is how DNG, CR2 -//! and the TIFF-based raws use it. +//! component is supported, which is how DNG, CR2 and the TIFF-based raws use +//! it; [`decode`] takes sampling factors 1×1 only. Canon's sRAW / mRAW frames +//! (three components, the first sampled 2×1 or 2×2) go through +//! [`decode_subsampled`], which follows Canon's prediction rather than T.81's. use crate::error::{RawError, Result}; @@ -20,11 +22,21 @@ pub(crate) struct Frame { pub width: usize, pub height: usize, pub components: usize, + /// Horizontal and vertical sampling factors of the first component (1 and 1 unless the + /// frame is subsampled; the other components are then sampled 1×1). + pub h: usize, + pub v: usize, } impl Frame { + /// Samples in the frame: per MCU, `h × v` of the first component and one of each other. pub fn samples(&self) -> Option { - self.width.checked_mul(self.height)?.checked_mul(self.components) + let mcus = (self.width / self.h.max(1)).checked_mul(self.height / self.v.max(1))?; + mcus.checked_mul(self.h.checked_mul(self.v)?.checked_add(self.components)?.checked_sub(1)?) + } + + pub fn is_subsampled(&self) -> bool { + self.h != 1 || self.v != 1 } } @@ -343,14 +355,19 @@ fn parse(data: &[u8]) -> Result> { return Err(RawError::malformed(format!("unsupported SOF3 ({precision}-bit, {width}x{height}, {nf} components)"))); } comp_ids.clear(); + let mut factors = Vec::with_capacity(nf); for c in 0..nf { let s = &seg[6 + 3 * c..9 + 3 * c]; - if s[1] != 0x11 { - return Err(RawError::unsupported("lossless JPEG with subsampled components")); - } + factors.push(s[1]); comp_ids.push(s[0]); } - frame = Some(Frame { precision, width, height, components: nf }); + // Canon sRAW / mRAW: Y sampled 2×1 or 2×2, Cb and Cr 1×1, whole MCUs only. + let (h, v) = match factors.as_slice() { + f if f.iter().all(|&s| s == 0x11) => (1, 1), + [y @ (0x21 | 0x22), 0x11, 0x11] if width % 2 == 0 && height % usize::from(y & 0x0F) == 0 => (2, usize::from(y & 0x0F)), + _ => return Err(RawError::unsupported(format!("lossless JPEG with subsampled components {factors:02x?} ({width}x{height})"))), + }; + frame = Some(Frame { precision, width, height, components: nf, h, v }); } 0xC0..=0xC2 => return Err(RawError::unsupported("lossy (DCT) JPEG compressed raw data")), 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Err(RawError::unsupported("hierarchical or arithmetic-coded JPEG")), @@ -422,6 +439,9 @@ fn parse(data: &[u8]) -> Result> { pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec)> { let p = parse(data)?; let f = p.frame; + if f.is_subsampled() { + return Err(RawError::unsupported(format!("lossless JPEG with subsampled components ({}x{} luma)", f.h, f.v))); + } let total = f .samples() .filter(|&n| n <= max_samples) @@ -531,10 +551,100 @@ pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec Ok((f, out)) } +/// Decodes a subsampled frame (Canon sRAW / mRAW: Y sampled 2×1 or 2×2, Cb and Cr 1×1, see +/// [`Frame::h`]) into its sample sequence in decode order: per MCU the `h × v` Y samples +/// row-major, then Cb, then Cr; `width / h` MCUs make a row and `height / v` rows the frame. +/// +/// Only predictor 1 is supported, with Canon's rule instead of T.81's per-component sample +/// grid: each Y sample is predicted from the Y sample decoded just before it (the previous one +/// in the MCU, or the last of the previous MCU), Cb and Cr from those of the previous MCU, and +/// the first MCU of a row from the first MCU of the row above (from `2^(P−Pt−1)` on the first +/// row of a restart interval). +pub(crate) fn decode_subsampled(data: &[u8], max_samples: usize) -> Result<(Frame, Vec)> { + let p = parse(data)?; + let f = p.frame; + let total = f + .samples() + .filter(|&n| n <= max_samples) + .ok_or_else(|| RawError::LimitExceeded(format!("lossless JPEG frame {}x{}x{}", f.width, f.height, f.components)))?; + if !f.is_subsampled() || f.components != 3 { + return Err(RawError::malformed("lossless JPEG frame is not subsampled YCbCr")); + } + if p.predictor != 1 { + return Err(RawError::unsupported(format!("subsampled lossless JPEG with predictor {}", p.predictor))); + } + if p.entropy.len().saturating_mul(8) < total / 2 { + return Err(RawError::malformed("lossless JPEG data is truncated")); + } + // Each component's table (components may share one). + let [ty, tcb, tcr] = match p.comp_table.as_slice() { + &[a, b, c] => [&p.tables[a], &p.tables[b], &p.tables[c]], + _ => return Err(RawError::malformed("lossless JPEG scan tables")), + }; + let luma = f.h * f.v; + let per_mcu = luma + 2; + let mcus = f.width / f.h; + let row_len = mcus * per_mcu; + if row_len == 0 { + return Err(RawError::malformed("empty lossless JPEG frame")); + } + // The restart interval counts MCUs. + if p.restart != 0 && p.restart % mcus != 0 { + return Err(RawError::unsupported("lossless JPEG restart interval that is not whole rows")); + } + let rows_per_interval = if p.restart == 0 { usize::MAX } else { p.restart / mcus }; + let mut out = vec![0u16; total]; + let mut bits = Bits::new(p.entropy); + let initial = 1i32 << (f.precision - p.point_transform - 1); + // First Y, Cb and Cr of the row above: the first MCU's predictors. + let mut top = [initial; 3]; + let mut interval_row = 0usize; + for row in out.chunks_exact_mut(row_len) { + if interval_row == rows_per_interval { + bits.restart()?; + interval_row = 0; + } + if interval_row == 0 { + top = [initial; 3]; + } + let (mut y, mut cb, mut cr) = (top[0], top[1], top[2]); + for (m, mcu) in row.chunks_exact_mut(per_mcu).enumerate() { + let (ys, c) = mcu.split_at_mut(luma); + for (k, s) in ys.iter_mut().enumerate() { + // The first Y of the row comes from the row above, every other from the previous Y. + let pred = if m == 0 && k == 0 { top[0] } else { y }; + *s = (pred + bits.diff(ty)?) as u16; + y = i32::from(*s); + if m == 0 && k == 0 { + top[0] = y; + } + } + if let [scb, scr] = c { + *scb = (cb + bits.diff(tcb)?) as u16; + *scr = (cr + bits.diff(tcr)?) as u16; + (cb, cr) = (i32::from(*scb), i32::from(*scr)); + } + if m == 0 { + (top[1], top[2]) = (cb, cr); + } + } + interval_row += 1; + if bits.fake > 64 { + return Err(RawError::malformed("lossless JPEG data is truncated")); + } + } + if p.point_transform > 0 { + for v in &mut out { + *v <<= p.point_transform; + } + } + Ok((f, out)) +} + #[cfg(test)] mod tests { use super::*; - use crate::testgen::{lj92_encode, lj92_encode_tables}; + use crate::testgen::{lj92_encode, lj92_encode_sraw, lj92_encode_sraw_tables, lj92_encode_tables}; /// Deterministic pseudo-random samples with some structure (smooth + noise). fn samples(n: usize, precision: u8, seed: u64) -> Vec { @@ -559,7 +669,7 @@ mod tests { let v = samples(w * h * comps, precision, u64::from(predictor) * 31 + comps as u64); let j = lj92_encode(&v, w, h, comps, precision, predictor, None); let (f, out) = decode(&j, usize::MAX).unwrap(); - assert_eq!(f, Frame { precision, width: w, height: h, components: comps }); + assert_eq!(f, Frame { precision, width: w, height: h, components: comps, h: 1, v: 1 }); assert_eq!(out, v, "P={precision} pred={predictor} comps={comps}"); } } @@ -591,7 +701,7 @@ mod tests { let v = samples(w * h * comps, 14, u64::from(predictor) * 7 + comps as u64); let j = lj92_encode_tables(&v, w, h, table_of, 14, predictor, restart_rows); let (f, out) = decode(&j, usize::MAX).unwrap(); - assert_eq!(f, Frame { precision: 14, width: w, height: h, components: comps }); + assert_eq!(f, Frame { precision: 14, width: w, height: h, components: comps, h: 1, v: 1 }); assert_eq!(out, v, "tables={table_of:?} pred={predictor} restart={restart_rows:?}"); } } @@ -663,4 +773,67 @@ mod tests { flooded.extend_from_slice(&j[2..]); // the rest of the valid stream assert!(matches!(decode(&flooded, usize::MAX), Err(RawError::Malformed(_)))); } + + #[test] + fn subsampled_roundtrip() { + for (h, v) in [(2usize, 2usize), (2, 1)] { + let (w, ht) = (14, 6); + let n = (w / h) * (ht / v) * (h * v + 2); + for precision in [12u8, 15, 16] { + for restart in [None, Some(1), Some(ht / v)] { + let seq = samples(n, precision, h as u64 * 7 + v as u64); + let j = lj92_encode_sraw(&seq, w, ht, h, v, precision, restart); + let f = frame(&j).unwrap(); + assert_eq!((f.h, f.v, f.samples()), (h, v, Some(n))); + let (_, out) = decode_subsampled(&j, usize::MAX).unwrap(); + assert_eq!(out, seq, "{h}x{v} P={precision} restart {restart:?}"); + // The plain decoder refuses subsampled frames instead of mislaying them. + assert!(matches!(decode(&j, usize::MAX), Err(RawError::Unsupported(_)))); + } + } + } + // Extreme values exercise the 16-bit and 32768 difference categories. + let seq: Vec = (0..7 * 3 * 6).map(|i| if (i / 5) % 2 == 0 { 0 } else { 65535 }).collect(); + let j = lj92_encode_sraw(&seq, 14, 6, 2, 2, 16, Some(1)); + assert_eq!(decode_subsampled(&j, usize::MAX).unwrap().1, seq); + // Y and the chroma on separate tables, or each its own: the scan maps every component + // to its table even when components share one. + for table_of in [[0, 1, 1], [0, 1, 2], [1, 0, 0]] { + let seq = samples(7 * 3 * 6, 15, 5); + let j = lj92_encode_sraw_tables(&seq, 14, 6, (2, 2), table_of, 15, Some(1)); + assert_eq!(decode_subsampled(&j, usize::MAX).unwrap().1, seq, "tables {table_of:?}"); + } + } + + #[test] + fn subsampled_hostile_streams_error_without_panicking() { + let seq = samples(7 * 3 * 6, 15, 3); + let j = lj92_encode_sraw(&seq, 14, 6, 2, 2, 15, Some(1)); + for n in 0..j.len() { + let _ = decode_subsampled(&j[..n], usize::MAX); + } + let mut s = 0x0123_4567u64; + for _ in 0..2000 { + let mut b = j.clone(); + for _ in 0..4 { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1); + let at = (s >> 33) as usize % b.len(); + b[at] = (s >> 20) as u8; + } + let _ = decode_subsampled(&b, 1 << 20); + } + assert!(matches!(decode_subsampled(&j, 10), Err(RawError::LimitExceeded(_)))); + // A frame that is not subsampled, or another predictor, is not Canon sRAW. + let plain = lj92_encode(&samples(14 * 6 * 3, 15, 1), 14, 6, 3, 15, 1, None); + assert!(decode_subsampled(&plain, usize::MAX).is_err()); + let mut p6 = j.clone(); + let sos = p6.windows(2).position(|w| w == [0xFF, 0xDA]).unwrap(); + p6[sos + 11] = 6; // Ss (predictor) after Ns and three component selectors + assert!(matches!(decode_subsampled(&p6, usize::MAX), Err(RawError::Unsupported(_)))); + // Sampling layouts other than Canon's are refused at the frame header. + let mut odd = j.clone(); + let sof = odd.windows(2).position(|w| w == [0xFF, 0xC3]).unwrap(); + odd[sof + 14] = 0x21; // Cb subsampled too + assert!(matches!(frame(&odd), Err(RawError::Unsupported(_)))); + } } diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index e09d934b4b..7582343bf3 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -19,7 +19,8 @@ //! `crates/raw/src/vendor/nefc.rs`): recovered from CC0 raw.pixls.us samples //! shot in both compressed and uncompressed mode, and verified on ~45 files //! from ~35 bodies (D40 … D850, Df, Z 6, Z 50, 1 J1). Only the format facts -//! and the tables were taken over; this module is written against this +//! and the tables were taken over (and, for "lossy after split" below, the +//! dequantisation function); this module is written against this //! crate's own TIFF reader. //! //! Format: @@ -38,9 +39,23 @@ //! samples), not the sensor bit depth; see Bill Claff's NEF Compression tables. //! * Values in the maker note are in the maker note's byte order. //! +//! * "Lossy after split" (type 2 only, a non-zero split row at `0x0096` offset +//! 562): rows above the split row are Huffman coded as above. Row `split` +//! starts at the very next bit; from there every pixel is one fixed-rate +//! word of `bits - 4` bits (8 at 12 bit, 10 at 14 bit), MSB first, with no +//! row padding: a prefix code (every word but all-ones) for the category `n`, +//! followed by the top bits of the T.81 additional bits. The difference is +//! the centre of the magnitude bin that was kept, the predictor is the same +//! as above, and the running value is not clamped. The section fills the +//! strip to its last byte: whole rows (up to 4 more than the image), then +//! 0–7 one-bits of padding. These facts, the prefix +//! tables and the dequantisation (`fixed_words`, ported from LightCraft's +//! `fixed_word_table`) come from LightCraft's black-box analysis of 11 CC0 +//! raw.pixls.us files (storytold/lightcraft#633), like the Huffman tables above. +//! //! Not decoded (reported as unsupported; the embedded preview is used -//! instead): "lossy after split" files (non-zero split row), other `0x0096` -//! versions and bit depths other than 12 / 14. +//! instead): "lossy after split" strips that fail to decode, other +//! `0x0096` versions and bit depths other than 12 / 14. use crate::Limits; use crate::error::{RawError, Result}; @@ -238,6 +253,9 @@ pub(crate) struct DecodeTable { pub encoding: Encoding, /// Predictor seeds `[row parity][column]`. pub seeds: [[i32; 2]; 2], + /// First row of the fixed-rate section ("lossy after split"); 0 when the whole strip is + /// Huffman coded. + pub split: usize, } /// Parses maker note `0x0096` for a `bits`-bit image; `le` is the maker @@ -253,6 +271,7 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result } let (Some(&v0), Some(&v1)) = (t.first(), t.get(1)) else { return Err(short()) }; let mut seeds = [[0i32; 2]; 2]; + let mut split = 0; for (i, s) in seeds.iter_mut().flatten().enumerate() { *s = i32::from(u16_at(2 + 2 * i).ok_or_else(short)?); } @@ -267,9 +286,11 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result if v1 == 0x10 { Encoding::Lossy(points) } else { - let split = u16_at(562).unwrap_or(0); - if split != 0 { - return Err(RawError::unsupported(format!("Nikon \"lossy after split\" compressed NEF (split at row {split})"))); + split = usize::from(u16_at(562).unwrap_or(0)); + // Bytes 12.. hold the curve; with more than 275 points it covers byte 562, so a + // non-zero value there is not a split row: refused as before (preview fallback). + if split != 0 && 12 + 2 * n > 562 { + return Err(RawError::unsupported(format!("Nikon compressed NEF: {n}-point curve overlapping the split row field"))); } // The modern lossy curve has a smaller *encoded* domain while // its output remains in sensor ADUs. Using 2^bits stretches the @@ -292,11 +313,85 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result } _ => return Err(RawError::unsupported(format!("Nikon compressed NEF version {v0:#04x} {v1:#04x}"))), }; - Ok(DecodeTable { encoding, seeds }) + Ok(DecodeTable { encoding, seeds, split }) +} + +/// Difference-category prefix codes of the fixed-rate ("lossy after split") section: +/// `(prefix bits, prefix length, category n)`. A word of `bits - 4` bits is a prefix followed by +/// the top `bits - 4 - length` additional bits of category `n` (storytold/lightcraft#633; the +/// 14-bit `010` prefix never occurred in the analysed files, its category 12 is deduced). +const FIXED_12: &[(u32, u32, u32)] = &[ + (0b00, 2, 9), + (0b010, 3, 10), + (0b011, 3, 8), + (0b100, 3, 7), + (0b101, 3, 6), + (0b110, 3, 5), + (0b1110, 4, 4), + (0b11110, 5, 3), + (0b111110, 6, 2), + (0b1111110, 7, 1), + (0b11111110, 8, 0), +]; +const FIXED_14: &[(u32, u32, u32)] = &[ + (0b00, 2, 8), + (0b010, 3, 12), + (0b011, 3, 11), + (0b100, 3, 10), + (0b101, 3, 9), + (0b110, 3, 7), + (0b1110, 4, 6), + (0b11110, 5, 5), + (0b111110, 6, 4), + (0b1111110, 7, 3), + (0b11111110, 8, 2), + (0b111111110, 9, 1), + (0b1111111110, 10, 0), +]; + +/// Fixed-rate word → dequantised difference, for `bits`-bit samples; `None` for the all-ones +/// word, which no prefix covers. +fn fixed_words(bits: u32) -> Vec> { + let (w, codes) = if bits == 12 { (8, FIXED_12) } else { (10, FIXED_14) }; + let mut lut = vec![None; 1 << w]; + for &(prefix, len, n) in codes { + let kept = w - len; + // `dropped` low bits of the T.81 additional bits are not stored: the word gives the + // bin, the difference is its centre. + let dropped = n.saturating_sub(kept); + let half = (1i32 << dropped) >> 1; + for m in 0..1u32 << kept { + let extra = (m << dropped) as i32; + let d = if n == 0 { + 0 + } else if extra >= 1 << (n - 1) { + extra + half + } else if dropped > 0 { + extra + half - (1 << n) + } else { + extra - ((1 << n) - 1) + }; + if let Some(slot) = lut.get_mut(((prefix << kept) | m) as usize) { + *slot = Some(d); + } + } + } + lut } /// Decodes a `w × h` Nikon Huffman-compressed strip with `bits`-bit samples. +/// +/// A "lossy after split" strip that fails to decode for any reason is reported as unsupported, +/// not malformed: before these files were decoded, every one was refused as unsupported and +/// opened as the camera's embedded preview, and that fallback stays for files the rule does not fit. pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTable) -> Result> { + match decode_strip(src, w, h, bits, table) { + Err(RawError::Malformed(m)) if table.split != 0 => Err(RawError::unsupported(format!("Nikon \"lossy after split\" NEF that does not decode: {m}"))), + r => r, + } +} + +fn decode_strip(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTable) -> Result> { if bits != 12 && bits != 14 { return Err(RawError::unsupported(format!("Nikon compressed NEF with {bits}-bit samples"))); } @@ -316,6 +411,14 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa Encoding::Lossless => (&[], (1i32 << bits) - 1), Encoding::Lossy(curve) => (curve, i32::try_from(curve.len()).unwrap_or(i32::MAX) - 1), }; + if table.split != 0 && table.split >= h { + return Err(RawError::malformed(format!("NEF: split row {} for a {h}-row image", table.split))); + } + if table.split != 0 && !matches!(table.encoding, Encoding::Lossy(_)) { + return Err(RawError::malformed("NEF: split row with a lossless table")); + } + let fixed = if table.split != 0 { fixed_words(bits) } else { Vec::new() }; + let word = bits - 4; let mut out = vec![0u16; n]; let mut stream = Bits::new(src); let mut vpred = table.seeds; @@ -323,8 +426,14 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa for (y, row) in out.chunks_exact_mut(w).enumerate() { let seeds = &mut vpred[y & 1]; let mut hpred = [0i32; 2]; + let fixed_rate = table.split != 0 && y >= table.split; for (x, o) in row.iter_mut().enumerate() { - let d = diff(&mut stream, &huff).ok_or_else(|| RawError::malformed(format!("NEF: invalid Huffman code in row {y}")))?; + let d = if fixed_rate { + let code = stream.take(word) as usize; + fixed.get(code).copied().flatten().ok_or_else(|| RawError::malformed(format!("NEF: unknown fixed-rate word in row {y}")))? + } else { + diff(&mut stream, &huff).ok_or_else(|| RawError::malformed(format!("NEF: invalid Huffman code in row {y}")))? + }; let p = if x < 2 { seeds[x] = seeds[x].saturating_add(d); hpred[x] = seeds[x]; @@ -334,15 +443,29 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa hpred[x & 1] }; let v = p.clamp(0, max); - clipped += usize::from(v != p); + // The fixed-rate section's running value is not clamped and may leave the range. + clipped += usize::from(v != p && !fixed_rate); *o = if lut.is_empty() { v as u16 } else { lut.get(v as usize).copied().unwrap_or(0) }; } // The camera pads the strip; reading well past its end means the data // is truncated or corrupt. - if stream.consumed > src.len() * 8 + 64 { + if stream.consumed > src.len().saturating_mul(8).saturating_add(64) { return Err(RawError::malformed(format!("NEF: compressed data ends in row {y} of {h}"))); } } + // The fixed-rate section fills the rest of the strip: whole rows of `w` words, then 0–7 + // one-bits up to the byte boundary (a strip may hold up to 4 rows more than the image). This + // checks the section's length, not its content: a strip that ends any other way does not + // follow the rule and is refused, but a wrong word inside a well-formed one is not detected. + if table.split != 0 { + let left = src.len().saturating_mul(8).checked_sub(stream.consumed); + let row_bits = w.saturating_mul(word as usize).max(1); + let pad = left.map(|l| l % row_bits); + let ones = |k: usize| src.last().is_some_and(|b| u32::from(*b) & ((1 << k) - 1) == (1 << k) - 1); + if !left.is_some_and(|l| l / row_bits <= 4) || !pad.is_some_and(|k| k < 8 && ones(k)) { + return Err(RawError::malformed(format!("NEF: the fixed-rate section from row {} does not end the strip", table.split))); + } + } // A wrong table or a corrupt stream drifts out of range quickly; real // files stay inside (a handful of edge pixels). if clipped > n / 1000 + 16 { @@ -365,6 +488,17 @@ pub(crate) fn read_compressed(t: &Tiff, raw: &Ifd, note: Option<&(Tiff, Ifd)>, l .and_then(|(n, m)| Some((n.raw(m.get(NIKON_LINEARIZATION_TABLE)?)?, n.le))) .ok_or_else(|| RawError::unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)"))?; let table = parse_table(table.0, table.1, bits)?; + match read_strip(t, raw, width, height, bits, &table, limits) { + // Until split files were decoded, each was refused as unsupported before the image was + // read, and opened as the camera's preview: any failure after that point keeps it so. + Err(e @ (RawError::Malformed(_) | RawError::LimitExceeded(_))) if table.split != 0 => { + Err(RawError::unsupported(format!("Nikon \"lossy after split\" NEF that does not decode: {e}"))) + } + r => r, + } +} + +fn read_strip(t: &Tiff, raw: &Ifd, width: usize, height: usize, bits: u32, table: &DecodeTable, limits: &Limits) -> Result { limits.check(width as u64, height as u64, 2)?; // One strip in every sample; several would be consecutive parts of the // same bit stream. @@ -376,7 +510,7 @@ pub(crate) fn read_compressed(t: &Tiff, raw: &Ifd, note: Option<&(Tiff, Ifd)>, l let start = first as usize; let end = (last as usize).saturating_add(last_len as usize).min(t.data.len()); let src = t.data.get(start..end).filter(|s| !s.is_empty()).ok_or_else(|| RawError::malformed("raw data lies outside the file"))?; - let data = decode(src, width, height, bits, &table)?; + let data = decode(src, width, height, bits, table)?; Ok(Plane { width, height, samples: 1, bits, data, float_scale: None }) } diff --git a/crates/raw/src/nefc/tests.rs b/crates/raw/src/nefc/tests.rs index 296f850093..76701208db 100644 --- a/crates/raw/src/nefc/tests.rs +++ b/crates/raw/src/nefc/tests.rs @@ -85,7 +85,7 @@ fn image(w: usize, h: usize, max: i32, seed: u32) -> Vec { fn lossless(bits: u32) -> DecodeTable { let s = 1 << (bits - 3); - DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]] } + DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]], split: 0 } } fn lossy_table(bits: u32, le: bool) -> Vec { @@ -165,7 +165,7 @@ fn unsupported_and_malformed_tables() { let malformed = |r: Result| matches!(r, Err(RawError::Malformed(_))); let mut split = lossy_table(12, false); split[562..564].copy_from_slice(&345u16.to_be_bytes()); - assert!(unsupported(parse_table(&split, false, 12))); + assert_eq!(parse_table(&split, false, 12).unwrap().split, 345); assert!(unsupported(parse_table(&[0x49, 0x30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], false, 12))); assert!(unsupported(parse_table(&lossy_table(12, false), false, 16))); assert!(malformed(parse_table(&[], false, 12))); @@ -244,7 +244,8 @@ fn decodes_through_the_nef_container() { assert_eq!(s.data, img.iter().map(|&v| curve[v as usize]).collect::>()); assert_eq!(s.black.values, vec![150.0; 4]); assert!(crate::develop_sensor(&s, &crate::DevelopOptions::default()).is_ok()); - // "lossy after split" and a missing table stay unsupported (preview fallback) + // a split row over a stream that does not follow the fixed-rate rule, and a missing + // table, stay unsupported (preview fallback) let mut split = table; split[562..564].copy_from_slice(&3u16.to_le_bytes()); let e = crate::decode(&nef_compressed(w, h, 12, src.clone(), [2, 1, 1, 0], false, vec![(0x0096, Val::Undefined(split))]), &Limits::default()); @@ -304,3 +305,277 @@ fn truncated_and_corrupt_streams_error_never_panic() { assert!(decode(&src, usize::MAX, 2, 14, &table).is_err()); assert!(decode(&src, 0, 2, 14, &table).is_err()); } + +/// Encodes `values` the way a "lossy after split" camera does: Huffman (`t`) for rows above +/// `split` (exact differences), then fixed-rate words of `bits - 4` bits, then one-bits up to the +/// byte boundary. The fixed-rate words keep only the top bits of large differences, so the +/// encoder runs the decoder's own predictor on what it reconstructs (closed loop) and returns the +/// strip together with the values the decoder must produce. +fn encode_split(values: &[i32], w: usize, t: &Table, seeds: [[i32; 2]; 2], bits: u32, split: usize) -> (Vec, Vec) { + let codes = codes(t); + let (word, fixed) = if bits == 12 { (8, FIXED_12) } else { (10, FIXED_14) }; + let lut = fixed_words(bits); + let mut wr = Writer::default(); + let mut vpred = seeds; + let mut recon = Vec::with_capacity(values.len()); + for (y, row) in values.chunks(w).enumerate() { + let mut hpred = [0i32; 2]; + for (x, &v) in row.iter().enumerate() { + let pred = if x < 2 { vpred[y & 1][x] } else { hpred[x & 1] }; + let d = v - pred; + let k = 32 - d.unsigned_abs().leading_zeros(); + let extra = if d < 0 { (d + (1 << k) - 1) as u32 } else { d as u32 }; + let got = if y < split { + let (code, len) = codes[k as usize]; + assert!(len > 0, "no Huffman code for category {k}"); + wr.put(code, len); + wr.put(extra, k); + d + } else { + let &(prefix, len, _) = fixed.iter().find(|c| c.2 == k).unwrap_or_else(|| panic!("no fixed-rate code for category {k}")); + let kept = word - len; + let field = if k <= kept { extra << (kept - k) } else { extra >> (k - kept) }; + wr.put(prefix, len); + wr.put(field, kept); + lut[((prefix << kept) | field) as usize].unwrap() + }; + let r = pred + got; + if x < 2 { + vpred[y & 1][x] = r; + } + hpred[x & 1] = r; + recon.push(r); + } + } + while wr.n > 0 { + wr.put(1, 1); + } + (wr.out, recon) +} + +/// Samples with large jumps (differences up to about ±1000 codes, the largest category both +/// lossy Huffman tables and the 12-bit fixed-rate words code) as well as small steps. +fn rough(w: usize, h: usize, seed: u32) -> Vec { + let mut s = seed; + (0..w * h) + .map(|i| { + s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let jump = if (s >> 28) < 3 { ((s >> 8) % 900) as i32 - 450 } else { ((s >> 8) % 23) as i32 - 11 }; + 700 + jump + ((i % w) as i32 * 7) % 40 + }) + .collect() +} + +fn split_table(bits: u32, split: usize) -> DecodeTable { + let mut table = lossy_table(bits, false); + table[562..564].copy_from_slice(&(split as u16).to_be_bytes()); + let t = parse_table(&table, false, bits).unwrap(); + assert_eq!(t.split, split); + t +} + +#[test] +fn lossy_after_split_known_answers() { + for bits in [12u32, 14] { + let huff = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + // Split right after the first row, in the middle and before the last row. + for (h, split) in [(7usize, 1usize), (7, 3), (7, 6)] { + let w = 24usize; + let t = split_table(bits, split); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + let max = curve.len() as i32 - 1; + // Large jumps: the fixed-rate words drop low bits and decode to bin centres, which + // the closed-loop encoder reproduces; the running value may leave 0..=max. + let img = rough(w, h, 7 + split as u32); + let (src, recon) = encode_split(&img, w, huff, t.seeds, bits, split); + let want: Vec = recon.iter().map(|&r| curve[r.clamp(0, max) as usize]).collect(); + assert_eq!(decode(&src, w, h, bits, &t).unwrap(), want, "{bits}-bit split {split}"); + // Some differences really were quantised. + assert!(recon.iter().zip(&img).any(|(r, v)| r != v), "{bits}-bit split {split}: nothing quantised"); + } + } +} + +#[test] +fn lossy_after_split_dequantises_to_symmetric_bin_centres() { + for bits in [12u32, 14] { + let lut = fixed_words(bits); + let values: Vec = lut.iter().flatten().copied().collect(); + // Every word but the all-ones one codes a difference, and the codes are symmetric. + assert_eq!(values.len(), lut.len() - 1, "{bits}-bit"); + assert!(lut.last().unwrap().is_none()); + for v in &values { + assert!(values.contains(&-v), "{bits}-bit: {v} has no negative twin"); + } + // Small differences are exact; the largest category (10 at 12 bit, 12 at 14 bit) reaches + // past half its range. + for d in -31..=31 { + assert!(values.contains(&d), "{bits}-bit: {d}"); + } + let top = *values.iter().max().unwrap(); + assert!(top >= if bits == 12 { 1 << 9 } else { 1 << 11 }, "{bits}-bit: {top}"); + } +} + +#[test] +fn split_files_that_break_the_rule_fall_back_to_the_preview() { + let unsupported = |r: Result>| matches!(r, Err(RawError::Unsupported(_))); + for bits in [12u32, 14] { + let huff = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + let (w, h, split) = (24usize, 7usize, 3usize); + let t = split_table(bits, split); + let (src, _) = encode_split(&gentle(w, h, 600), w, huff, t.seeds, bits, split); + assert!(decode(&src, w, h, bits, &t).is_ok()); + // Trailing data, a truncated strip, a split row past the image, zero padding and the + // all-ones word: all unsupported (the embedded preview opens), none a panic. + let mut longer = src.clone(); + longer.extend_from_slice(&[0x12; 9]); + assert!(unsupported(decode(&longer, w, h, bits, &t)), "{bits}-bit: trailing data"); + assert!(unsupported(decode(&src[..src.len() - 12], w, h, bits, &t)), "{bits}-bit: truncated"); + assert!(unsupported(decode(&src, w, split, bits, &t)), "{bits}-bit: split past the image"); + let mut zeros = src.clone(); + let last = zeros.len() - 1; + zeros[last] &= 0xf0; + assert_ne!(zeros, src, "{bits}-bit: the fixture must end in padding bits"); + assert!(unsupported(decode(&zeros, w, h, bits, &t)), "{bits}-bit: zero padding"); + let mut all_ones = src.clone(); + let fixed_start = all_ones.len() - (w * (h - split) * (bits as usize - 4)).div_ceil(8); + all_ones[fixed_start + 2..fixed_start + 6].fill(0xff); + assert!(unsupported(decode(&all_ones, w, h, bits, &t)), "{bits}-bit: all-ones word"); + } + // A split row on a lossless table is malformed (no parsed table makes one). + let lossless_split = DecodeTable { split: 3, ..lossless(12) }; + assert!(unsupported(decode(&[0; 64], 24, 7, 12, &lossless_split))); + // A curve long enough to cover byte 562 has no split field: refused as before. + let mut long = vec![0x44, 0x20]; + long.extend(std::iter::repeat_n([0u8, 9], 4).flatten()); + long.extend_from_slice(&513u16.to_be_bytes()); + long.extend((0..513u32).flat_map(|i| ((i * 7) as u16).to_be_bytes())); + assert!(matches!(parse_table(&long, false, 12), Err(RawError::Unsupported(_)))); +} + +#[test] +fn split_nef_decodes_through_the_container() { + use crate::testgen::{Val, nef_compressed}; + use crate::{Limits, decode as decode_raw}; + let (w, h, split) = (40usize, 6usize, 2usize); + let mut table = lossy_table(12, true); + table[562..564].copy_from_slice(&(split as u16).to_le_bytes()); + let t = parse_table(&table, true, 12).unwrap(); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + let (src, recon) = encode_split(&rough(w, h, 3), w, &LOSSY_12, t.seeds, 12, split); + let note = vec![(0x0096, Val::Undefined(table))]; + let s = decode_raw(&nef_compressed(w, h, 12, src, [2, 1, 1, 0], false, note), &Limits::default()).unwrap(); + let max = curve.len() as i32 - 1; + assert_eq!(s.data, recon.iter().map(|&r| curve[r.clamp(0, max) as usize]).collect::>()); + assert!(crate::develop_sensor(&s, &crate::DevelopOptions::default()).is_ok()); +} + +/// Small steps around a level (every difference below 32), so fixed-rate words keep them whole. +fn gentle(w: usize, h: usize, base: i32) -> Vec { + (0..w * h).map(|i| base + ((i % w) as i32 * 3 + (i / w) as i32 * 5) % 13 + if i % 7 == 0 { 7 } else { 0 }).collect() +} + +#[test] +fn fixed_rate_running_value_is_not_clamped() { + // A jump from 900 down to 0 is category 10: its 12-bit word keeps 5 of 10 additional bits + // and decodes to -912, so the running value sits at -12. The next same-colour pixel codes +12 + // exactly and must land on 0 again; clamping the running value would give 12. + let (w, h, split) = (12usize, 4usize, 1usize); + let t = split_table(12, split); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + let img: Vec = (0..w * h).map(|i| if i % w < 2 { 900 } else { 0 }).collect(); + let (src, recon) = encode_split(&img, w, &LOSSY_12, t.seeds, 12, split); + assert!(recon.iter().any(|&r| r < 0), "{recon:?}"); + let max = curve.len() as i32 - 1; + let want: Vec = recon.iter().map(|&r| curve[r.clamp(0, max) as usize]).collect(); + assert_eq!(decode(&src, w, h, 12, &t).unwrap(), want); +} + +#[test] +fn fixed_rate_words_known_answers() { + // Worked by hand from the rule: prefix -> category n; the word keeps the top `kept` of n + // additional bits; T.81: a leading 0 bit is a negative difference; the difference is the + // centre of the kept bin (low dropped bits = half the bin). + let (w12, w14) = (fixed_words(12), fixed_words(14)); + for (word, want) in [ + (0b1111_1110, Some(0)), // category 0 + (0b1110_0000, Some(-15)), // category 4, all 4 bits kept: 0000 = -15 + (0b1110_1111, Some(15)), // 1111 = +15 + (0b101_00000, Some(-63)), // category 6, 5 of 6 bits kept: bin -63..-62, centre -63 + (0b101_10000, Some(33)), // bin 32..33, centre 33 + (0b010_00011, Some(-912)), // category 10, 5 of 10 bits: 00011xxxxx = 96..127 -> -927..-896 + (0b010_11111, Some(1008)), // 11111xxxxx = 992..1023, centre 1008 + (0b1111_1111, None), // the all-ones word codes nothing + ] { + assert_eq!(w12[word], want, "12-bit word {word:#010b}"); + } + for (word, want) in [ + (0b11_1111_1110, Some(0)), // category 0 + (0b010_0000000, Some(-4080)), // category 12, 7 of 12 bits: -4095..-4064, centre -4080 + (0b010_1111111, Some(4080)), // 4064..4095, centre 4080 + (0b011_1000000, Some(1032)), // category 11, 7 of 11 bits: 1024..1039, centre 1032 + (0b00_00000000, Some(-255)), // category 8, all 8 bits kept: 00000000 = -255 + (0b11_1111_1111, None), // the all-ones word + ] { + assert_eq!(w14[word], want, "14-bit word {word:#012b}"); + } +} + +#[test] +fn out_of_range_fixed_rate_values_are_not_counted_as_clipped() { + // Many jumps down to 0 leave the running value just below 0 (see the test above), far more + // than the 16 + n/1000 out-of-range values tolerated in Huffman rows. They are legitimate in + // the fixed-rate section and must not fail the decode. + let (w, h, split) = (40usize, 5usize, 1usize); + let t = split_table(12, split); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + let img: Vec = (0..w * h).map(|i| if (i % w) % 4 < 2 { 900 } else { 0 }).collect(); + let (src, recon) = encode_split(&img, w, &LOSSY_12, t.seeds, 12, split); + assert!(recon.iter().filter(|&&r| r < 0).count() > 16 + w * h / 1000, "{recon:?}"); + let max = curve.len() as i32 - 1; + assert_eq!(decode(&src, w, h, 12, &t).unwrap(), recon.iter().map(|&r| curve[r.clamp(0, max) as usize]).collect::>()); +} + +#[test] +fn split_files_failing_after_the_table_still_fall_back_to_the_preview() { + use crate::testgen::{Val, nef_compressed}; + use crate::{Limits, decode as decode_raw}; + let (w, h, split) = (40usize, 6usize, 2usize); + let mut table = lossy_table(12, true); + table[562..564].copy_from_slice(&(split as u16).to_le_bytes()); + let t = parse_table(&table, true, 12).unwrap(); + let (src, _) = encode_split(&gentle(w, h, 600), w, &LOSSY_12, t.seeds, 12, split); + let note = || vec![(0x0096, Val::Undefined(table.clone()))]; + // The refusal must come from the split path, not from some other part of the file. + let unsupported = |r: Result| matches!(&r, Err(RawError::Unsupported(m)) if m.contains("lossy after split")); + // Over the size limits, and with the strip cut off by the end of the file. + let small = Limits { max_pixels: 100, ..Limits::default() }; + assert!(unsupported(decode_raw(&nef_compressed(w, h, 12, src.clone(), [2, 1, 1, 0], false, note()), &small))); + let file = nef_compressed(w, h, 12, src, [2, 1, 1, 0], false, note()); + let cut = file.len() - 40; + assert!(unsupported(decode_raw(&file[..cut], &Limits::default()))); +} + +#[test] +fn split_strips_with_up_to_four_extra_rows_are_accepted() { + // A strip may hold whole fixed-rate rows past the image (one body stores 4022 rows for + // 4020): up to 4 such rows decode, the image rows unchanged; a fifth is refused. + for bits in [12u32, 14] { + let huff = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + let (w, h, split) = (24usize, 7usize, 3usize); + let t = split_table(bits, split); + let Encoding::Lossy(curve) = &t.encoding else { panic!() }; + let max = curve.len() as i32 - 1; + for extra in 1..=5 { + let (src, recon) = encode_split(&gentle(w, h + extra, 600), w, huff, t.seeds, bits, split); + let got = decode(&src, w, h, bits, &t); + if extra <= 4 { + let want: Vec = recon[..w * h].iter().map(|&r| curve[r.clamp(0, max) as usize]).collect(); + assert_eq!(got.unwrap(), want, "{bits}-bit, {extra} extra rows"); + } else { + assert!(matches!(got, Err(RawError::Unsupported(_))), "{bits}-bit, {extra} extra rows: {got:?}"); + } + } + } +} diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 9b333fd4cf..9722a41158 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -249,6 +249,11 @@ pub(crate) struct Opcodes { /// Those past the cap are named as not applied. const MAX_POLYNOMIALS: usize = 64; +/// The most GainMap opcodes kept from one list, for the same reason: development applies every +/// map to every pixel it covers, so the map count multiplies the whole image's work. Real files +/// carry one per CFA plane (four). +const MAX_GAIN_MAPS: usize = 64; + /// Parses an opcode list. pub(crate) fn parse(list: &[u8]) -> Opcodes { let mut out = Opcodes::default(); @@ -259,7 +264,7 @@ pub(crate) fn parse(list: &[u8]) -> Opcodes { let start = at.saturating_add(16); let Some(params) = list.get(start..start.saturating_add(size as usize)) else { break }; let applied = match id { - 9 => gain_map(params).map(|m| out.gain_maps.push(m)).is_some(), + 9 if out.gain_maps.len() < MAX_GAIN_MAPS => gain_map(params).map(|m| out.gain_maps.push(m)).is_some(), 8 if out.polynomials.len() < MAX_POLYNOMIALS => map_polynomial(params).map(|p| out.polynomials.push(p)).is_some(), _ => false, }; @@ -323,6 +328,15 @@ mod tests { assert!((m.at(&m.row(-3.0, 0), 9.0) - 2.0).abs() < 1e-6); } + #[test] + fn gain_maps_past_the_cap_are_named_not_applied() { + let map = gain_map_params([0, 0, 10, 10], 1, &[[1.0, 2.0], [3.0, 4.0]]); + let b = list(&vec![(9, map); MAX_GAIN_MAPS + 1]); + let Opcodes { gain_maps, skipped, .. } = parse(&b); + assert_eq!(gain_maps.len(), MAX_GAIN_MAPS); + assert_eq!(skipped, vec!["GainMap".to_string()]); + } + #[test] fn hostile_lists_do_not_panic() { let b = list(&[(9, gain_map_params([0, 0, 10, 10], 2, &[[1.0, 2.0], [3.0, 4.0]]))]); diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index a36692a3f6..e5886d444f 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -12,7 +12,7 @@ //! the stated white level. Unclipped samples keep their values, and a file whose data reaches its white //! level, or has no pile-up, is left alone. Same detector as storytold/lightcraft#586. -use crate::sensor::Sensor; +use crate::sensor::{Cfa, Rect, Sensor}; /// A saturation plateau must hold at least this share of its colour's samples (and 100 of them), as /// many must lie below it (a scene, not a flat field)... @@ -29,6 +29,51 @@ const NEAR_WHITE: f32 = 0.03; /// How far below the plateau the "average value just below it" reaches. const NEIGHBOURHOOD: usize = 64; +/// Largest number of row bands counted in parallel: each holds a 768 KiB histogram. +const MAX_PARTS: usize = 16; + +/// Per-colour histogram of the samples of `active` (and the sample count per colour). Row bands are +/// counted in parallel, each row through one repeat of the CFA colours (one `Cfa::color` per pattern +/// column instead of one per sample), and the integer counts are summed, so the result does not depend +/// on the number of workers. +fn colour_histogram(data: &[u16], stride: usize, a: Rect, cfa: &Cfa) -> (Vec<[u32; 3]>, [u64; 3]) { + let parts = crate::par::workers().clamp(1, MAX_PARTS.min(a.height.max(1))); + let rows_per = a.height.div_ceil(parts).max(1); + let pw = cfa.width.max(1); + let partial = crate::par::map(a.height.div_ceil(rows_per), |p| { + let mut hist = vec![[0u32; 3]; 65536]; + let mut pattern = vec![0usize; pw]; + for y in a.y + p * rows_per..(a.y + (p + 1) * rows_per).min(a.y + a.height) { + for (k, c) in pattern.iter_mut().enumerate() { + *c = usize::from(cfa.color(a.x + k, y)).min(2); + } + let row = &data[y * stride + a.x..][..a.width]; + for run in row.chunks(pw) { + for (&v, &c) in run.iter().zip(&pattern) { + hist[usize::from(v)][c] += 1; + } + } + } + hist + }); + let mut parts = partial.into_iter(); + let mut hist = parts.next().unwrap_or_else(|| vec![[0u32; 3]; 65536]); + for part in parts { + for (h, q) in hist.iter_mut().zip(&part) { + for c in 0..3 { + h[c] += q[c]; + } + } + } + let mut total = [0u64; 3]; + for h in &hist { + for c in 0..3 { + total[c] += u64::from(h[c]); + } + } + (hist, total) +} + impl Sensor { /// The value each colour's samples saturate at, when that is clearly below the stated white level /// (see the module docs). Only CFA data without a linearization table is examined: with a table the @@ -55,16 +100,7 @@ impl Sensor { if lo + NEIGHBOURHOOD >= hi { return none; } - let mut hist = vec![[0u32; 3]; 65536]; - let mut total = [0u64; 3]; - for y in a.y..a.y + a.height { - let row = &self.data[y * self.width + a.x..][..a.width]; - for (x, &v) in (a.x..).zip(row) { - let c = usize::from(cfa.color(x, y)).min(2); - total[c] += 1; - hist[usize::from(v)][c] += 1; - } - } + let (hist, total) = colour_histogram(&self.data, self.width, a, cfa); let mut out = none; for c in 0..3 { // the most populated value of the upper half of the range @@ -119,6 +155,7 @@ impl Sensor { #[cfg(test)] mod tests { + use super::colour_histogram; use crate::*; /// A 64 x 64 RGGB mosaic with a gradient, black 528, white `white`, whose samples above `clip` (when @@ -262,4 +299,34 @@ mod tests { s.data.pop(); assert_eq!(s.lift_clipped_samples(), 0); } + + /// The per-row pattern and the parallel bands count exactly what a per-sample `Cfa::color` pass does, + /// for offset patterns, a 6 x 6 pattern, an inner active area and any number of bands. + #[test] + fn histogram_matches_the_per_sample_count() { + let (w, h) = (53usize, 47usize); + let data: Vec = (0..w * h).map(|i| ((i * 2654435761) >> 7) as u16 % 4096).collect(); + let xtrans = Cfa { width: 6, height: 6, colors: (0..36).map(|i| [1, 0, 2, 1, 2, 0][(i * 7 + i / 6) % 6]).collect(), origin_x: 5, origin_y: 2 }; + let mut offset = Cfa::bayer("GBRG").unwrap(); + offset.origin_x = 3; + offset.origin_y = 1; + for cfa in [Cfa::bayer("RGGB").unwrap(), offset, xtrans] { + for a in [Rect::new(0, 0, w, h), Rect::new(7, 5, 31, 22), Rect::new(1, 3, 1, 1), Rect::new(2, 2, 40, 1), Rect::new(0, 0, 0, 0)] { + let mut hist = vec![[0u32; 3]; 65536]; + let mut total = [0u64; 3]; + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + let c = usize::from(cfa.color(x, y)).min(2); + total[c] += 1; + hist[usize::from(data[y * w + x])][c] += 1; + } + } + for threads in [1, 2, 3, 8] { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + let (got, got_total) = pool.install(|| colour_histogram(&data, w, a, &cfa)); + assert!(got == hist && got_total == total, "{threads} threads, active {a:?}"); + } + } + } + } } diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 677361aae5..5c83066c21 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -279,8 +279,24 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou retained_segment_bytes(&segments, samples, limits)?; let le = t.le; let tiled = t.tag_uint(ifd, tag::TILE_WIDTH).is_some(); - let decoded: Vec>> = par::map(segments.len(), |i| { - let s = &segments[i]; + // Segments naming the same bytes at the same geometry decode to the same samples, so each + // distinct one is decoded once and shared. A file can point every tile at one small range; + // decoding it per tile multiplied header work (a lossless JPEG rebuilds its Huffman tables on + // every decode) and retained buffers by the tile count. + let mut first_of = std::collections::HashMap::new(); + let mut unique: Vec = Vec::new(); + let slot: Vec = segments + .iter() + .enumerate() + .map(|(i, s)| { + *first_of.entry((s.offset, s.len, s.w, s.h, s.h.min(height - s.y))).or_insert_with(|| { + unique.push(i); + unique.len() - 1 + }) + }) + .collect(); + let decoded: Vec>> = par::map(unique.len(), |u| { + let s = &segments[unique[u]]; let rows = s.h.min(height - s.y); let src = t.bytes(s.offset, s.len).ok_or_else(|| RawError::malformed("raw data lies outside the file"))?; // Every branch below sizes at least one buffer from the segment's own geometry, which @@ -322,9 +338,9 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou // One segment holding the whole image row for row (a single strip): its buffer is the plane, no // second full-size buffer and no copy. The scatter below then has no segment left to place. let whole = matches!(segments.as_slice(), [s] if s.x == 0 && s.y == 0 && s.w == width && s.h >= height); - let mut decoded = decoded.into_iter(); + let mut decoded: Vec> = decoded.into_iter().collect::>()?; let taken = if whole { - let mut d = decoded.next().ok_or_else(|| RawError::malformed("raw segment missing"))??; + let mut d = decoded.pop().ok_or_else(|| RawError::malformed("raw segment missing"))?; if d.len() < width * height * samples { return Err(RawError::malformed("raw segment is smaller than the image")); } @@ -334,8 +350,9 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou None }; let mut data = taken.unwrap_or_else(|| vec![0u16; width * height * samples]); - for (s, d) in segments.iter().zip(decoded) { - let d = d?; + // The whole-image segment above is the plane itself: nothing is left to scatter. + for (s, &k) in segments.iter().zip(&slot).filter(|_| !whole) { + let d = &decoded[k]; let rows = s.h.min(height - s.y); let cols = s.w.min(width - s.x); for r in 0..rows { @@ -467,6 +484,11 @@ fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, t 3 => ColorSpace::RGB, n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), }; + // zune-jpeg re-sorts and re-checks every pending Extended XMP fragment after each marker it + // parses, which is quadratic in a tile of many small fragments; a raw tile has no metadata. + if has_extended_xmp(src) { + return Err(RawError::unsupported("lossy JPEG tile with Extended XMP metadata")); + } let options = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(s.w).set_max_height(s.h); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), options); dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; @@ -492,6 +514,36 @@ fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, t Ok(v) } +/// Whether a JPEG stream has an APP1 Extended XMP segment before its first scan. Walks the +/// markers as a header parse does: stray bytes skip to the next `FF`. +fn has_extended_xmp(b: &[u8]) -> bool { + const EXTENDED_XMP: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + let mut i = 2; // past SOI + while let Some(&byte) = b.get(i) { + if byte != 0xFF { + match b[i..].iter().position(|&v| v == 0xFF) { + Some(n) => i += n, + None => return false, + } + continue; + } + match b.get(i + 1) { + None | Some(0xD9 | 0xDA) => return false, + Some(0xFF) => i += 1, + Some(0x01 | 0xD0..=0xD8) => i += 2, + Some(&code) => { + let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { return false }; + // The identifier must lie inside the segment's declared payload (length counts its own two bytes). + if code == 0xE1 && b.get(i + 4..(i + 2 + len).min(b.len())).is_some_and(|p| p.starts_with(EXTENDED_XMP)) { + return true; + } + i += 2 + len; + } + } + } + false +} + /// Reverses TIFF predictor 2 (horizontal differencing) on interleaved samples: each sample /// gains its channel's predecessor in the row, wrapping at the sample's container (8 or 16 bits). fn undelta(mut out: Vec, plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec { @@ -592,6 +644,25 @@ fn unpack_row_msb(row: &[u8], out: &mut [u16], bits: u32) { mod tests { use super::*; + #[test] + fn extended_xmp_is_found_before_the_scan_only() { + let app1 = |payload: &[u8]| { + let mut seg = vec![0xFF, 0xE1]; + seg.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + seg.extend_from_slice(payload); + seg + }; + let xmp = app1(b"http://ns.adobe.com/xmp/extension/\0GUID"); + let plain = app1(b"http://ns.adobe.com/xap/1.0/\0"); + let jpeg = |segs: &[&[u8]]| [&[0xFF, 0xD8][..], &segs.concat(), &[0xFF, 0xDA, 0, 2, 0xFF, 0xD9]].concat(); + assert!(has_extended_xmp(&jpeg(&[&plain, &xmp]))); + assert!(!has_extended_xmp(&jpeg(&[&plain]))); + // After the scan it is entropy data, not a marker the header parse reads. + assert!(!has_extended_xmp(&[jpeg(&[]), xmp.clone()].concat())); + // A truncated length ends the walk. + assert!(!has_extended_xmp(&[0xFF, 0xD8, 0xFF, 0xE1, 0x00])); + } + /// The grouped 10/12/14-bit readers equal the plain bit reader on every row width. #[test] fn grouped_unpack_matches_the_bit_reader() { diff --git a/crates/raw/src/sraw.rs b/crates/raw/src/sraw.rs new file mode 100644 index 0000000000..c027700661 --- /dev/null +++ b/crates/raw/src/sraw.rs @@ -0,0 +1,316 @@ +//! Canon sRAW / mRAW (CR2 with subsampled YCbCr). +//! +//! The camera demosaics the sensor data and stores it as Y, Cb, Cr in one lossless JPEG frame +//! whose Y component is sampled 2×2 (sRAW1, which Canon calls mRAW on the later bodies) or 2×1 +//! (sRAW2), sliced like the Bayer CR2 data (tag 0xC640, widths in samples). The decoder +//! ([`crate::ljpeg::decode_subsampled`]) gives the sample sequence; [`planes`] lays it out, +//! [`camera_rgb`] interpolates the chroma and converts to camera RGB, and the result is +//! developed like a three-sample LinearRaw: no CFA, the as-shot white balance, the look fitted +//! to the embedded JPEG (`photocraft-io`), tone and exposure as for every other raw. +//! +//! Observed on EOS 6D Mark II mRAW files (ColorData version 15, 4680 × 3132 data): +//! * Y is already black-subtracted (the darkest samples are 0) and on the sensor's scale; Cb and +//! Cr are colour differences offset by `2^(P−1)`. +//! * The stored RGB is camera RGB divided by per-shot gains, an RGGB entry of ColorData (word +//! 0x80 in version 15; its green is 1170, not the 1024 of the white-balance presets) that +//! roughly neutralises the scene. Multiplying them back gives camera RGB that matches an +//! independent converter's linear camera-RGB output of the same files to a diagonal of +//! (1, 1, 1) ± 1.5 % and 0.6 % RMS; the as-shot white balance then applies as for Bayer data. +//! * The inverse with the BT.601 luma weights (ITU-R BT.601: Y = 0.299 R + 0.587 G + 0.114 B) +//! and unscaled differences (Cb = B − Y, Cr = R − Y) leaves no cross-channel terms above 3 % +//! in that comparison. +//! * Chroma edges follow the luma edges slightly better half an MCU in than at its first +//! sample, so the chroma is treated as centred on its MCU and interpolated bilinearly. +//! +//! Approximate: the chroma siting and the bilinear interpolation (colour edges are softer than +//! in a Bayer development; luma keeps full resolution). The gains are read only for ColorData +//! versions where their offset was observed; for other bodies they are left at 1, which is a +//! diagonal colour error that the matrix fitted to the camera's JPEG absorbs (a warning says +//! so). Older bodies (sRAW before about 2009) are documented elsewhere to use other offsets +//! and chroma scalings; no such sample was available, so they decode with the same rule. +//! Shadow noise below black is clipped at 0 by the conversion. + +use crate::error::{RawError, Result}; +use crate::ljpeg::Frame; +use crate::par; + +/// The de-sliced YCbCr planes: Y at full resolution, Cb and Cr one sample per MCU, stored +/// values (chroma still carries its offset). +pub(crate) struct Planes { + pub width: usize, + pub height: usize, + pub h: usize, + pub v: usize, + pub y: Vec, + pub cb: Vec, + pub cr: Vec, +} + +/// Lays the decoded sample sequence out as planes. `slices` are the slice widths in samples +/// (CR2 tag 0xC640; one slice of the whole MCU row when absent): slice 0 is filled MCU row by +/// MCU row, then slice 1, and so on, as for the Bayer CR2 data. +pub(crate) fn planes(f: &Frame, samples: &[u16], slices: &[usize]) -> Result { + let (h, v) = (f.h, f.v); + let per_mcu = h * v + 2; + let total: usize = slices.iter().sum(); + if total == 0 || !total.is_multiple_of(per_mcu) || slices.iter().any(|w| !w.is_multiple_of(per_mcu)) || !samples.len().is_multiple_of(total) { + return Err(RawError::malformed("CR2 sRAW slices do not match the JPEG frame")); + } + let mcus = total / per_mcu; + let rows = samples.len() / total; + let (width, height) = (mcus * h, rows * v); + let mut p = Planes { width, height, h, v, y: vec![0; width * height], cb: vec![0; mcus * rows], cr: vec![0; mcus * rows] }; + let mut runs = samples.chunks_exact(per_mcu); + let mut x0 = 0usize; + for &sw in slices { + let n = sw / per_mcu; + for r in 0..rows { + for mx in x0..x0 + n { + let mcu = runs.next().ok_or_else(|| RawError::malformed("CR2 sRAW slice data is short"))?; + let (ys, c) = mcu.split_at(h * v); + for (k, &s) in ys.iter().enumerate() { + let (x, y) = (mx * h + k % h, r * v + k / h); + if let Some(d) = p.y.get_mut(y * width + x) { + *d = s; + } + } + if let ([cb, cr], Some(dcb), Some(dcr)) = (c, p.cb.get_mut(r * mcus + mx), p.cr.get_mut(r * mcus + mx)) { + (*dcb, *dcr) = (*cb, *cr); + } + } + } + x0 += n; + } + Ok(p) +} + +/// White level of unclipped frames: 14-bit full scale less Canon's usual black of about 2048. +pub(crate) const FULL_SCALE: f32 = 14335.0; + +/// BT.601 luma weights of R and B (G's is the rest). +const KR: f32 = 0.299; +const KB: f32 = 0.114; + +/// Interpolates the chroma to every pixel and converts to camera RGB, interleaved: +/// `R = Y + Cr`, `B = Y + Cb`, `G = (Y − KR·R − KB·B) / KG`, then each channel multiplied by the +/// camera's sRAW `gains`. `offset` is the chroma's zero (`2^(P−1)`). +pub(crate) fn camera_rgb(p: &Planes, offset: f32, gains: [f64; 3]) -> Vec { + let (w, h) = (p.width, p.height); + let cw = w / p.h.max(1); + let ch = p.cb.len().checked_div(cw).unwrap_or(0); + let mut out = vec![0u16; w * h * 3]; + if w == 0 || cw == 0 || ch == 0 { + return out; + } + let gains = gains.map(|m| if m.is_finite() && m > 0.0 { m as f32 } else { 1.0 }); + let kg = 1.0 - KR - KB; + // Chroma sample positions: centred on the MCU (see the module docs). Per output column (row): + // the two chroma columns (rows) around it and the weight of the second. + let taps = |n: usize, f: usize, len: usize| -> Vec<(usize, usize, f32)> { + (0..n) + .map(|i| { + let c = ((i as f32 + 0.5) / f as f32 - 0.5).clamp(0.0, (len - 1) as f32); + let a = (c as usize).min(len - 1); + let b = (a + 1).min(len - 1); + (a, b, c - a as f32) + }) + .collect() + }; + let xs = taps(w, p.h.max(1), cw); + let ys = taps(h, p.v.max(1), ch); + let band = par::band_rows(w); + par::chunks_mut(&mut out, band * w * 3, |b, chunk| { + for (r, row) in chunk.chunks_exact_mut(w * 3).enumerate() { + let y = b * band + r; + let (Some(luma), Some(&(ya, yb, fy))) = (p.y.get(y * w..(y + 1) * w), ys.get(y)) else { continue }; + let chroma = |plane: &[u16], xa: usize, xb: usize, fx: f32| { + let at = |cy: usize, cx: usize| plane.get(cy * cw + cx).map_or(offset, |&v| f32::from(v)); + let top = at(ya, xa) * (1.0 - fx) + at(ya, xb) * fx; + let bottom = at(yb, xa) * (1.0 - fx) + at(yb, xb) * fx; + top * (1.0 - fy) + bottom * fy - offset + }; + for ((o, &l), &(xa, xb, fx)) in row.as_chunks_mut::<3>().0.iter_mut().zip(luma).zip(&xs) { + let l = f32::from(l); + let (cb, cr) = (chroma(&p.cb, xa, xb, fx), chroma(&p.cr, xa, xb, fx)); + let (red, blue) = (l + cr, l + cb); + let green = (l - KR * red - KB * blue) / kg; + for (k, v) in [red, green, blue].into_iter().enumerate() { + o[k] = (v * gains[k]).round().clamp(0.0, 65535.0) as u16; + } + } + } + }); + out +} + +/// White level of channel `k` of interleaved RGB: the largest value when a meaningful share of +/// samples piles up there (clipped highlights), else `full`. +pub(crate) fn clip_level(rgb: &[u16], k: usize, full: f32) -> f32 { + let step = 3 * (rgb.len() / 3 / 2_000_000).max(1); + let samples = || rgb.iter().skip(k).step_by(step); + let max = samples().copied().max().unwrap_or(0); + if max == 0 { + return full; + } + let near = samples().filter(|&&v| u32::from(v) + u32::from(max / 256) >= u32::from(max)).count(); + if near * 2000 >= samples().count() { f32::from(max) * 0.995 } else { full.max(f32::from(max)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testgen::Cr2SrawSpec; + use crate::{DevelopOptions, Limits, decode, develop_sensor}; + + const OFFSET: f32 = 16384.0; + + /// A linear ramp of camera RGB (bilinear chroma interpolation reproduces it exactly away + /// from the borders). + fn camera(x: usize, y: usize) -> [f32; 3] { + let (x, y) = (x as f32, y as f32); + [1500.0 + 40.0 * x + 10.0 * y, 2500.0 + 15.0 * x + 30.0 * y, 1200.0 + 5.0 * x + 45.0 * y] + } + + /// The camera's stored YCbCr for `camera` divided by `gains`: Y per pixel, Cb / Cr at the + /// centre of each MCU (the siting the decoder assumes). + fn spec(h: usize, v: usize, gains: [f32; 3], slices: Vec, restart_rows: Option) -> Cr2SrawSpec { + let (w, ht) = (48, 24); + let stored = |x: f32, y: f32| { + let c = camera(0, 0); + let (dx, dy) = (camera(1, 0), camera(0, 1)); + [0, 1, 2].map(|k| (c[k] + (dx[k] - c[k]) * x + (dy[k] - c[k]) * y) / gains[k]) + }; + let ycc = |p: [f32; 3]| { + let y = KR * p[0] + (1.0 - KR - KB) * p[1] + KB * p[2]; + (y, p[2] - y + OFFSET, p[0] - y + OFFSET) + }; + let y = (0..w * ht).map(|i| ycc(stored((i % w) as f32, (i / w) as f32)).0.round() as u16).collect(); + let (mw, mh) = (w / h, ht / v); + let centre = |m: usize, f: usize| (m * f) as f32 + (f as f32 - 1.0) / 2.0; + let chroma: Vec<(f32, f32, f32)> = (0..mw * mh).map(|i| ycc(stored(centre(i % mw, h), centre(i / mw, v)))).collect(); + Cr2SrawSpec { + width: w, + height: ht, + h, + v, + precision: 15, + y, + cb: chroma.iter().map(|c| c.1.round() as u16).collect(), + cr: chroma.iter().map(|c| c.2.round() as u16).collect(), + slices, + restart_rows, + wb_rggb: Some([2048, 1024, 1024, 1536]), + gains: Some([(1170.0 * gains[0]) as u16, 1170, 1170, (1170.0 * gains[2]) as u16]), + borders: Some([0, 2, 47, 23]), + } + } + + #[test] + fn sraw_decodes_to_camera_rgb() { + let gains = [0.75, 1.0, 0.4]; + // sRAW1 (2×2: 6 samples an MCU) and sRAW2 (2×1: 4 samples an MCU), with and without + // slices and restart intervals. + for (h, v, slices, restart) in + [(2, 2, vec![], None), (2, 2, vec![48, 48, 48], Some(3)), (2, 2, vec![60, 60, 24], None), (2, 1, vec![], Some(1)), (2, 1, vec![32, 32, 32], None)] + { + let s = spec(h, v, gains, slices.clone(), restart); + let sensor = decode(&s.build(), &Limits::default()).expect("decode"); + assert_eq!((sensor.width, sensor.height, sensor.samples), (48, 24, 3), "{h}x{v} {slices:?}"); + assert!(sensor.cfa.is_none()); + assert_eq!(sensor.crop, crate::Rect::new(0, 2, 48, 22)); + assert_eq!(sensor.camera_wb, Some([2.0, 1.0, 1.5])); + assert!(sensor.warnings.is_empty(), "{:?}", sensor.warnings); + for y in 2..22 { + for x in 2..46 { + let at = (y * 48 + x) * 3; + for (k, (&got, want)) in sensor.data[at..at + 3].iter().zip(camera(x, y)).enumerate() { + let got = f32::from(got); + // Rounding of Y, Cb, Cr and the stored gains (whole 1/1170 steps). + assert!((got - want).abs() <= 4.0 + want * 0.002, "{h}x{v} {slices:?} ({x}, {y}) ch {k}: {got} vs {want}"); + } + } + } + } + } + + #[test] + fn unknown_gains_keep_the_stored_colour_and_say_so() { + let mut s = spec(2, 2, [1.0; 3], vec![], None); + s.gains = None; + let sensor = decode(&s.build(), &Limits::default()).expect("decode"); + assert!(sensor.warnings.iter().any(|w| w.contains("sRAW colour gains")), "{:?}", sensor.warnings); + let want = camera(10, 10); + let got = &sensor.data[(10 * 48 + 10) * 3..(10 * 48 + 10) * 3 + 3]; + assert!(got.iter().zip(want).all(|(&g, w)| (f32::from(g) - w).abs() <= 4.0), "{got:?} vs {want:?}"); + } + + #[test] + fn sraw_develops_as_linear_rgb() { + let s = spec(2, 1, [0.75, 1.0, 0.4], vec![], None); + let sensor = decode(&s.build(), &Limits::default()).expect("decode"); + let d = develop_sensor(&sensor, &DevelopOptions::default()).expect("develop"); + assert_eq!((d.width, d.height), (48, 22)); + assert_eq!(d.info.cfa, None); + assert_eq!(d.info.wb_multipliers, [2.0, 1.0, 1.5]); + } + + #[test] + fn corrupt_sraw_errors_without_panicking() { + let b = spec(2, 2, [0.75, 1.0, 0.4], vec![48, 48, 48], Some(2)).build(); + for n in (0..b.len()).step_by(7) { + let _ = decode(&b[..n], &Limits::default()); + } + let mut s = 0x9E37_79B9u64; + for _ in 0..1500 { + let mut c = b.clone(); + for _ in 0..3 { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let at = (s >> 33) as usize % c.len(); + c[at] = (s >> 20) as u8; + } + if let Ok(sensor) = decode(&c, &Limits::default()) { + let _ = develop_sensor(&sensor, &DevelopOptions::default()); + } + } + // Slices that do not split whole MCUs are refused. + let mut bad = spec(2, 2, [1.0; 3], vec![48, 48, 48], None).build(); + let tag = bad.windows(4).position(|w| w == [0x40, 0xC6, 3, 0]).expect("slice tag"); + let at = u32::from_le_bytes([bad[tag + 8], bad[tag + 9], bad[tag + 10], bad[tag + 11]]) as usize; + bad[at + 2..at + 4].copy_from_slice(&47u16.to_le_bytes()); + assert!(matches!(decode(&bad, &Limits::default()), Err(RawError::Malformed(_)))); + // The planes refuse a sample count that is not whole slice rows. + let f = Frame { precision: 15, width: 4, height: 2, components: 3, h: 2, v: 2 }; + assert!(planes(&f, &[0; 11], &[12]).is_err()); + assert!(planes(&f, &[0; 12], &[0]).is_err()); + } + + #[test] + fn white_level_follows_clipping() { + // A lone bright sample is not clipping. + let mut rgb: Vec = (0..3 * 10_000).map(|i| (i % 5000) as u16).collect(); + rgb[0] = 9000; + assert_eq!(clip_level(&rgb, 0, 14335.0), 14335.0); + for p in rgb.as_chunks_mut::<3>().0.iter_mut().take(100) { + p[1] = 12000; + } + assert!((clip_level(&rgb, 1, 14335.0) - 12000.0 * 0.995).abs() < 1e-3); + assert_eq!(clip_level(&[], 2, 14335.0), 14335.0); + } + + /// Real files: `PHOTOCRAFT_SRAW_CR2` lists CR2 paths (separated by `:`), e.g. EOS 6D Mark II + /// mRAW. Checks the size, the gains and that the development runs. + #[test] + #[ignore = "needs real sRAW CR2 files in PHOTOCRAFT_SRAW_CR2"] + fn real_sraw_files() { + let paths = std::env::var("PHOTOCRAFT_SRAW_CR2").expect("PHOTOCRAFT_SRAW_CR2"); + for path in paths.split(':').filter(|p| !p.is_empty()) { + let bytes = std::fs::read(path).expect("read"); + let s = decode(&bytes, &Limits::default()).expect("decode"); + assert_eq!(s.samples, 3, "{path}"); + assert!(s.cfa.is_none() && s.camera_wb.is_some(), "{path}"); + assert!(s.warnings.is_empty(), "{path}: {:?}", s.warnings); + let d = develop_sensor(&s, &DevelopOptions::default()).expect("develop"); + eprintln!("{path}: {}x{} from {}x{}, white {:?}", d.width, d.height, s.width, s.height, s.white); + } + } +} diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index b5df57e3c0..20ebba446f 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -9,15 +9,17 @@ mod canon; mod dng; +mod hasselblad; mod lj92; mod orf; mod raf; mod rw2; mod tiff; -pub use canon::{Cr2Spec, craw_block, craw_encode, nef_compressed, sony_craw, tiff_ep}; +pub use canon::{Cr2Spec, Cr2SrawSpec, craw_block, craw_encode, nef_compressed, sony_craw, tiff_ep}; pub use dng::{DngSpec, DngStorage, baseline_jpeg, gain_map_opcode_list, map_polynomial_opcode_list}; -pub use lj92::{lj92_encode, lj92_encode_tables}; +pub use hasselblad::Hasselblad3frSpec; +pub use lj92::{lj92_encode, lj92_encode_sraw, lj92_encode_sraw_tables, lj92_encode_tables}; pub use orf::orf; pub use raf::{RafPacking, RafSpec, XTRANS, mosaic, mosaic_cfa, scene}; pub use rw2::{rw2, rw2_format4, rw2_pack, rw2_v4_pack}; diff --git a/crates/raw/src/testgen/canon.rs b/crates/raw/src/testgen/canon.rs index 33a28d057d..2c4ad54f96 100644 --- a/crates/raw/src/testgen/canon.rs +++ b/crates/raw/src/testgen/canon.rs @@ -1,4 +1,4 @@ -use super::lj92::lj92_encode; +use super::lj92::{lj92_encode, lj92_encode_sraw}; use super::tiff::{TiffBuilder, Val}; // ---------------------------------------------------------------- CR2 @@ -94,6 +94,103 @@ impl Cr2Spec { } } +/// A synthetic Canon sRAW / mRAW CR2: Y, Cb and Cr planes in one subsampled lossless JPEG +/// (see `ljpeg::decode_subsampled` and `sraw.rs`). +#[derive(Debug, Clone)] +pub struct Cr2SrawSpec { + /// Image size in pixels (multiples of `h` and `v`). + pub width: usize, + pub height: usize, + /// Y sampling factors: 2×2 (sRAW1 / mRAW) or 2×1 (sRAW2). + pub h: usize, + pub v: usize, + pub precision: u8, + /// `width * height` Y samples, row-major. + pub y: Vec, + /// One Cb and one Cr per MCU (`width / h` by `height / v`), offset by `2^(precision−1)`. + pub cb: Vec, + pub cr: Vec, + /// Slice widths in samples (all but the last equal); empty = no slice tag. + pub slices: Vec, + /// Restart interval in MCU rows. + pub restart_rows: Option, + /// As-shot RGGB levels at ColorData word 0x3F. + pub wb_rggb: Option<[u16; 4]>, + /// sRAW gains (RGGB) at ColorData word 0x80; ColorData version 15 is written either way. + pub gains: Option<[u16; 4]>, + /// Image borders: left, top, right, bottom (inclusive), as in SensorInfo. + pub borders: Option<[u16; 4]>, +} + +impl Cr2SrawSpec { + pub fn build(&self) -> Vec { + let (h, v) = (self.h, self.v); + let per_mcu = h * v + 2; + let (mcus, rows) = (self.width / h, self.height / v); + let widths: Vec = if self.slices.is_empty() { vec![mcus * per_mcu] } else { self.slices.clone() }; + assert_eq!(widths.iter().sum::(), mcus * per_mcu); + // Slice the MCUs into the JPEG sample order. + let mut seq = Vec::with_capacity(mcus * rows * per_mcu); + let mut m0 = 0; + for &sw in &widths { + assert_eq!(sw % per_mcu, 0); + let n = sw / per_mcu; + for r in 0..rows { + for mx in m0..m0 + n { + for k in 0..h * v { + seq.push(self.y[(r * v + k / h) * self.width + mx * h + k % h]); + } + seq.push(self.cb[r * mcus + mx]); + seq.push(self.cr[r * mcus + mx]); + } + } + m0 += n; + } + let jpeg = lj92_encode_sraw(&seq, self.width, self.height, h, v, self.precision, self.restart_rows); + let mut t = TiffBuilder::default(); + let jb = t.blob(jpeg.clone()); + let mut raw = vec![ + (256, Val::Short(vec![self.width as u16])), + (257, Val::Short(vec![self.height as u16])), + (259, Val::Short(vec![6])), + (273, Val::Blobs(vec![jb])), + (279, Val::Long(vec![jpeg.len() as u32])), + ]; + if !self.slices.is_empty() { + let n = widths.len() - 1; + raw.push((50752, Val::Short(vec![n as u16, widths[0] as u16, widths[n] as u16]))); + } + let raw_ifd = t.ifd(raw); + let mut mn: Vec<(u16, Val)> = Vec::new(); + if let Some([l, tp, r, b]) = self.borders { + mn.push((0x00E0, Val::Short(vec![34, self.width as u16, self.height as u16, 0, 0, l, tp, r, b, 0, 0, 0, 0, 0, 0, 0, 0]))); + } + let mut cd = vec![0u8; 2 * 1602]; + cd[0..2].copy_from_slice(&15u16.to_le_bytes()); + for (at, levels) in [(0x3F, self.wb_rggb), (0x80, self.gains)] { + for (i, v) in levels.iter().flatten().enumerate() { + cd[2 * (at + i)..2 * (at + i) + 2].copy_from_slice(&v.to_le_bytes()); + } + } + mn.push((0x4001, Val::Undefined(cd))); + let mi = t.ifd(mn); + let exif_ifd = t.ifd(vec![(33434, Val::Rational(vec![(1, 100)])), (37500, Val::IfdBytes(mi))]); + let ifd0 = t.ifd(vec![ + (256, Val::Long(vec![2])), + (257, Val::Long(vec![2])), + (271, Val::Ascii("Canon".into())), + (272, Val::Ascii("Canon EOS Synthetic".into())), + (274, Val::Short(vec![1])), + (34665, Val::Ifds(vec![exif_ifd])), + ]); + let ifd1 = t.ifd(vec![(256, Val::Long(vec![1]))]); + let ifd2 = t.ifd(vec![(256, Val::Long(vec![1]))]); + t.chain = vec![ifd0, ifd1, ifd2, raw_ifd]; + t.cr2_raw_ifd = Some(raw_ifd); + t.build() + } +} + // ---------------------------------------------------------------- TIFF/EP /// A synthetic uncompressed TIFF/EP raw (NEF / ARW-like): IFD0 with Make, the diff --git a/crates/raw/src/testgen/hasselblad.rs b/crates/raw/src/testgen/hasselblad.rs new file mode 100644 index 0000000000..f234678160 --- /dev/null +++ b/crates/raw/src/testgen/hasselblad.rs @@ -0,0 +1,106 @@ +use super::tiff::{TiffBuilder, Val, urat}; + +// ---------------------------------------------------------------- Hasselblad 3FR + +/// A synthetic Hasselblad 3FR, as the X2D II 100C writes it: IFD0 with Make, +/// Model and DNG-style AsShotNeutral, and the CFA image in a SubIFD — +/// `bits`-bit uncompressed samples with BlackLevel, WhiteLevel and DefaultCrop +/// tags and no CFA-pattern tags. `compression` and the optional tags exist for +/// the unsupported and hostile variants. +#[derive(Debug, Clone)] +pub struct Hasselblad3frSpec { + pub width: usize, + pub height: usize, + /// Row-major sensor samples. + pub data: Vec, + pub bits: u16, + pub compression: u16, + /// The BlackLevel RATIONAL; `None` leaves the tag out. + pub black: Option<(u32, u32)>, + /// The WhiteLevel; `None` leaves the tag out. + pub white: Option, + /// AsShotNeutral; `None` leaves the tag out. + pub as_shot_neutral: Option<[f64; 3]>, + /// DefaultCropOrigin (x, y) and DefaultCropSize (w, h); `None` leaves both out. + pub crop: Option<([u16; 2], [u16; 2])>, + /// ActiveArea (top, left, bottom, right); `None` leaves the tag out. + pub active_area: Option<[u32; 4]>, + pub make: String, + pub model: String, +} + +impl Hasselblad3frSpec { + /// An X2D II 100C-shaped file: 16-bit uncompressed, black 4096, white + /// 64763, the sample file's as-shot neutral, no crop. + pub fn new(width: usize, height: usize, data: Vec) -> Self { + Hasselblad3frSpec { + width, + height, + data, + bits: 16, + compression: 1, + black: Some((4096, 1)), + white: Some(64763), + as_shot_neutral: Some([0.375, 1.0, 0.617]), + crop: None, + active_area: None, + make: "Hasselblad".into(), + model: "X2D II 100C".into(), + } + } + + pub fn build(&self) -> Vec { + let mut t = TiffBuilder::default(); + let len = self.data.len() * 2; + let strip = t.blob(self.data.iter().flat_map(|&v| v.to_le_bytes()).collect()); + let mut raw = vec![ + (254, Val::Long(vec![0])), + (256, Val::Long(vec![self.width as u32])), + (257, Val::Long(vec![self.height as u32])), + (258, Val::Short(vec![self.bits])), + (259, Val::Short(vec![self.compression])), + (262, Val::Short(vec![32803])), + (273, Val::Blobs(vec![strip])), + (277, Val::Short(vec![1])), + (278, Val::Long(vec![self.height as u32])), + (279, Val::Long(vec![len as u32])), + ]; + if let Some((n, d)) = self.black { + raw.push((50714, Val::Rational(vec![(n, d)]))); + } + if let Some(w) = self.white { + raw.push((50717, Val::Short(vec![w]))); + } + if let Some((origin, size)) = &self.crop { + raw.push((50719, Val::Short(origin.to_vec()))); + raw.push((50720, Val::Short(size.to_vec()))); + } + if let Some(area) = self.active_area { + raw.push((50829, Val::Long(area.to_vec()))); + } + let raw_ifd = t.ifd(raw); + let thumb = t.blob(vec![128; 12]); + let mut ifd0 = vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![2])), + (257, Val::Long(vec![2])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![7])), + (262, Val::Short(vec![6])), + (271, Val::Ascii(self.make.clone())), + (272, Val::Ascii(self.model.clone())), + (273, Val::Blobs(vec![thumb])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![2])), + (279, Val::Long(vec![12])), + (330, Val::Ifds(vec![raw_ifd])), + (50708, Val::Ascii(format!("{} {}", self.make, self.model))), + ]; + if let Some(n) = self.as_shot_neutral { + ifd0.push((50728, Val::Rational(n.iter().map(|&v| urat(v)).collect()))); + } + let i0 = t.ifd(ifd0); + t.chain = vec![i0]; + t.build() + } +} diff --git a/crates/raw/src/testgen/lj92.rs b/crates/raw/src/testgen/lj92.rs index 379d8dfdd0..fe5034c65c 100644 --- a/crates/raw/src/testgen/lj92.rs +++ b/crates/raw/src/testgen/lj92.rs @@ -56,7 +56,6 @@ pub fn lj92_encode(samples: &[u16], width: usize, height: usize, components: usi pub fn lj92_encode_tables(samples: &[u16], width: usize, height: usize, table_of: &[u8], precision: u8, predictor: u8, restart_rows: Option) -> Vec { let components = table_of.len(); assert_eq!(samples.len(), width * height * components); - assert!(table_of.iter().all(|&t| t < 4)); let row = width * components; let interval = restart_rows.unwrap_or(usize::MAX); // Differences per sample, following T.81 H.1.2.1 prediction rules. @@ -85,16 +84,118 @@ pub fn lj92_encode_tables(samples: &[u16], width: usize, height: usize, table_of _ => (ra + rb) >> 1, } }; - let mut d = (i32::from(samples[i]) - px) & 0xFFFF; - if d > 32768 { - d -= 65536; - } - diffs.push(d); + diffs.push(wrap_diff(i32::from(samples[i]) - px)); } } } + let frame = Frame { + width, + height, + factors: &vec![0x11; components], + table_of, + pattern: &(0..components as u8).collect::>(), + precision, + predictor, + restart: restart_rows.map(|r| (r * width, interval.saturating_mul(row))), + }; + lj92_stream(&diffs, &frame) +} + +/// A difference modulo 2^16, in −32767..=32768 (T.81 H.1.2.2). +fn wrap_diff(d: i32) -> i32 { + let d = d & 0xFFFF; + if d > 32768 { d - 65536 } else { d } +} + +/// Encodes a Canon sRAW / mRAW frame: `seq` is the sample sequence in decode order (per MCU the +/// `h × v` Y samples, then Cb and Cr; `width / h` MCUs a row), predicted as +/// `ljpeg::decode_subsampled` describes: each Y from the previous Y, Cb and Cr from the previous +/// MCU's, the first MCU of a row from the first of the row above. `restart_rows` counts MCU rows. +pub fn lj92_encode_sraw(seq: &[u16], width: usize, height: usize, h: usize, v: usize, precision: u8, restart_rows: Option) -> Vec { + lj92_encode_sraw_tables(seq, width, height, (h, v), [0, 0, 0], precision, restart_rows) +} + +/// Like [`lj92_encode_sraw`], but Y, Cb and Cr use Huffman tables `table_of` (0–3). +pub fn lj92_encode_sraw_tables( + seq: &[u16], + width: usize, + height: usize, + (h, v): (usize, usize), + table_of: [u8; 3], + precision: u8, + restart_rows: Option, +) -> Vec { + let (luma, mcus) = (h * v, width / h); + let per_mcu = luma + 2; + let row_len = mcus * per_mcu; + assert_eq!(seq.len(), row_len * (height / v)); + let interval = restart_rows.unwrap_or(usize::MAX); + let initial = 1i32 << (precision - 1); + let mut diffs = Vec::with_capacity(seq.len()); + let mut top = [initial; 3]; + for (r, row) in seq.chunks_exact(row_len).enumerate() { + if r % interval == 0 { + top = [initial; 3]; + } + let (mut y, mut cb, mut cr) = (top[0], top[1], top[2]); + for (m, mcu) in row.chunks_exact(per_mcu).enumerate() { + for (k, &s) in mcu[..luma].iter().enumerate() { + let pred = if m == 0 && k == 0 { top[0] } else { y }; + diffs.push(wrap_diff(i32::from(s) - pred)); + y = i32::from(s); + if m == 0 && k == 0 { + top[0] = y; + } + } + diffs.push(wrap_diff(i32::from(mcu[luma]) - cb)); + diffs.push(wrap_diff(i32::from(mcu[luma + 1]) - cr)); + (cb, cr) = (i32::from(mcu[luma]), i32::from(mcu[luma + 1])); + if m == 0 { + (top[1], top[2]) = (cb, cr); + } + } + } + let mut pattern = vec![0u8; luma]; + pattern.extend_from_slice(&[1, 2]); + let frame = Frame { + width, + height, + factors: &[(h as u8) << 4 | v as u8, 0x11, 0x11], + table_of: &table_of, + pattern: &pattern, + precision, + predictor: 1, + restart: restart_rows.map(|r| (r * mcus, interval.saturating_mul(row_len))), + }; + lj92_stream(&diffs, &frame) +} + +/// The frame header [`lj92_stream`] writes around the differences. +struct Frame<'a> { + width: usize, + height: usize, + /// Each component's sampling factors (`h << 4 | v`). + factors: &'a [u8], + /// Each component's Huffman table id (0–3). + table_of: &'a [u8], + /// The component of each difference, repeating: `0, 1, 2` interleaved, or an MCU's Y samples + /// then Cb and Cr. + pattern: &'a [u8], + precision: u8, + predictor: u8, + /// The restart interval `(DRI value, differences per interval)`. + restart: Option<(usize, usize)>, +} + +/// Writes SOI, SOF3, one DHT per table id, the restart interval, SOS and the entropy-coded +/// `diffs`. +fn lj92_stream(diffs: &[i32], f: &Frame<'_>) -> Vec { + let components = f.factors.len(); + assert_eq!(f.table_of.len(), components); + assert!(f.table_of.iter().all(|&t| t < 4)); + let table = |i: usize| f.table_of[usize::from(f.pattern[i % f.pattern.len()])]; // Tables: categories ordered by frequency get increasing code lengths. - let mut ids: Vec = table_of.to_vec(); + let mut ids: Vec = f.table_of.to_vec(); ids.sort_unstable(); ids.dedup(); let mut code_of = [[(0u32, 0u32); 17]; 4]; @@ -102,7 +203,7 @@ pub fn lj92_encode_tables(samples: &[u16], width: usize, height: usize, table_of for &t in &ids { let mut freq = [0usize; 17]; for (i, &d) in diffs.iter().enumerate() { - if table_of[i % components] == t { + if table(i) == t { freq[category(d) as usize] += 1; } } @@ -133,12 +234,12 @@ pub fn lj92_encode_tables(samples: &[u16], width: usize, height: usize, table_of // SOF3 out.extend_from_slice(&[0xFF, 0xC3]); out.extend_from_slice(&((8 + 3 * components) as u16).to_be_bytes()); - out.push(precision); - out.extend_from_slice(&(height as u16).to_be_bytes()); - out.extend_from_slice(&(width as u16).to_be_bytes()); + out.push(f.precision); + out.extend_from_slice(&(f.height as u16).to_be_bytes()); + out.extend_from_slice(&(f.width as u16).to_be_bytes()); out.push(components as u8); - for c in 0..components { - out.extend_from_slice(&[c as u8 + 1, 0x11, 0]); + for (c, &factor) in f.factors.iter().enumerate() { + out.extend_from_slice(&[c as u8 + 1, factor, 0]); } // DHT, one per table id for (t, counts, vals) in &dhts { @@ -148,28 +249,29 @@ pub fn lj92_encode_tables(samples: &[u16], width: usize, height: usize, table_of out.extend_from_slice(counts); out.extend_from_slice(vals); } - if let Some(r) = restart_rows { + if let Some((dri, _)) = f.restart { out.extend_from_slice(&[0xFF, 0xDD, 0, 4]); - out.extend_from_slice(&((r * width) as u16).to_be_bytes()); + out.extend_from_slice(&(dri as u16).to_be_bytes()); } + let every = f.restart.map_or(usize::MAX, |(_, n)| n); // SOS out.extend_from_slice(&[0xFF, 0xDA]); out.extend_from_slice(&((6 + 2 * components) as u16).to_be_bytes()); out.push(components as u8); - for (c, &t) in table_of.iter().enumerate() { + for (c, &t) in f.table_of.iter().enumerate() { out.extend_from_slice(&[c as u8 + 1, t << 4]); } - out.extend_from_slice(&[predictor, 0, 0]); + out.extend_from_slice(&[f.predictor, 0, 0]); let mut w = BitWriter { out, acc: 0, n: 0 }; let mut rst = 0u8; for (i, &d) in diffs.iter().enumerate() { - if i > 0 && i % (interval.saturating_mul(row)) == 0 { + if i > 0 && i % every == 0 { w.flush(); w.out.extend_from_slice(&[0xFF, 0xD0 + rst]); rst = (rst + 1) % 8; } let s = category(d); - let (c, l) = code_of[usize::from(table_of[i % components])][s as usize]; + let (c, l) = code_of[usize::from(table(i))][s as usize]; w.put(c, l); if (1..16).contains(&s) { let extra = if d < 0 { (d - 1) as u32 & ((1 << s) - 1) } else { d as u32 }; diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index fe0ea80a94..3a12f8eff9 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -28,7 +28,7 @@ const NIKON_BLACK_LEVEL: u16 = 0x003D; const SONY_BLACK_LEVEL: u16 = 0x7310; const SONY_WB_RGGB_LEVELS: u16 = 0x7313; -fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option { +pub(crate) fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option { ifds.iter() .filter(|i| t.tag_uint(i, tag::PHOTOMETRIC) == Some(PHOTOMETRIC_CFA)) .max_by_key(|i| u64::from(t.tag_uint(i, tag::IMAGE_WIDTH).unwrap_or(0)) * u64::from(t.tag_uint(i, tag::IMAGE_LENGTH).unwrap_or(0))) diff --git a/crates/raw/src/tone.rs b/crates/raw/src/tone.rs index 4878319317..e398a6da95 100644 --- a/crates/raw/src/tone.rs +++ b/crates/raw/src/tone.rs @@ -688,7 +688,8 @@ fn guided(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec Hasselblad3frSpec { + let (w, h) = (16, 12); + Hasselblad3frSpec::new(w, h, mosaic(&scene(w, h), w, RGGB, 4096, 64763)) +} + +#[test] +fn identifies_as_fff() { + let b = spec().build(); + assert_eq!(identify(&b), Some(RawFormat::Fff)); + assert!(is_raw(&b)); + assert!(EXTENSIONS.contains(&"3fr") && EXTENSIONS.contains(&"fff")); +} + +#[test] +fn decodes_exactly() { + let s = spec(); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!((sensor.width, sensor.height), (16, 12)); + assert_eq!(sensor.samples, 1); + assert_eq!(sensor.data, s.data, "the samples are the stored strip"); + let cfa = sensor.cfa.as_ref().unwrap(); + assert_eq!((cfa.width, cfa.height), (2, 2)); + for (i, &c) in RGGB.iter().enumerate() { + assert_eq!(cfa.color(i % 2, i / 2), c, "position {i}"); + } + assert!(cfa.is_bayer()); + assert_eq!(sensor.format, RawFormat::Fff); +} + +#[test] +fn metadata() { + let mut s = spec(); + s.crop = Some(([2, 2], [12, 8])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(2, 2, 12, 8)); + assert_eq!(sensor.active, Rect::new(0, 0, 16, 12)); + assert_eq!(sensor.black, BlackLevels::uniform(4096.0)); + assert_eq!(sensor.white, [64763.0; 3]); + let wb = sensor.camera_wb.unwrap(); + assert!((wb[0] - 1.0 / 0.375).abs() < 1e-9, "{wb:?}"); + assert!((wb[2] - 1.0 / 0.617).abs() < 1e-9, "{wb:?}"); + assert_eq!((sensor.make.as_deref(), sensor.model.as_deref()), (Some("Hasselblad"), Some("X2D II 100C"))); + assert_eq!(sensor.orientation, 1); + assert!(sensor.warnings.is_empty(), "{:?}", sensor.warnings); +} + +#[test] +fn develops() { + let mut s = spec(); + s.crop = Some(([2, 2], [12, 8])); + let b = s.build(); + for m in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] { + let d = develop(&b, &DevelopOptions { demosaic: m, ..Default::default() }).unwrap_or_else(|e| panic!("{m:?}: {e}")); + assert_eq!((d.width, d.height), (12, 8), "{m:?}"); + } +} + +#[test] +fn unknown_body_warns_about_the_assumed_phase() { + let mut s = spec(); + s.model = "X2D 100C".into(); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert!(sensor.warnings.iter().any(|w| w.contains("Bayer phase")), "{:?}", sensor.warnings); +} + +#[test] +fn compressed_3fr_is_reported_unsupported() { + let mut s = spec(); + s.compression = 7; + let e = decode(&s.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&e, RawError::Unsupported(reason) if reason.contains("lossless-JPEG")), "{e:?}"); +} + +#[test] +fn missing_levels_fall_back() { + let mut s = spec(); + // Clipped highlights, so the clipping point stands in for the missing WhiteLevel. + for v in s.data.iter_mut().step_by(8) { + *v = u16::MAX; + } + s.black = None; + s.white = None; + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.black, BlackLevels::uniform(0.0)); + assert!(sensor.warnings.iter().any(|w| w.contains("black level")), "{:?}", sensor.warnings); + // clip_level backs the clipped plateau off slightly. + assert!((sensor.white[0] - f32::from(u16::MAX) * 0.995).abs() < 1.0, "{:?}", sensor.white); +} + +#[test] +fn a_crop_outside_the_plane_is_dropped() { + let mut s = spec(); + s.crop = Some(([100, 100], [4, 4])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(0, 0, 16, 12)); +} + +#[test] +fn active_area_bounds_the_image_and_the_crop() { + let mut s = spec(); + s.active_area = Some([1, 2, 11, 14]); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.active, Rect::new(2, 1, 12, 10)); + assert_eq!(sensor.crop, Rect::new(2, 1, 12, 10), "no default crop: the active area"); + let d = develop(&s.build(), &DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (12, 10)); + // The default crop is relative to the active area, as in DNG. + s.crop = Some(([1, 1], [8, 6])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(3, 2, 8, 6)); +} + +#[test] +fn an_active_area_outside_the_plane_is_ignored() { + let mut s = spec(); + s.active_area = Some([100, 100, 200, 200]); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!((sensor.active, sensor.crop), (Rect::new(0, 0, 16, 12), Rect::new(0, 0, 16, 12))); +} + +#[test] +fn truncated_strip_is_malformed() { + let mut b = spec().build(); + // The preview blob sits past the strip; cutting 60 bytes eats into the strip. + b.truncate(b.len() - 60); + assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Malformed(_)))); +} + +#[test] +fn zero_dimensions_are_malformed() { + let mut s = spec(); + s.width = 0; + assert!(matches!(decode(&s.build(), &Limits::default()), Err(RawError::Malformed(_)))); +} diff --git a/crates/raw/tests/it/hostile.rs b/crates/raw/tests/it/hostile.rs index 6fdd0e7434..a60025be69 100644 --- a/crates/raw/tests/it/hostile.rs +++ b/crates/raw/tests/it/hostile.rs @@ -232,6 +232,37 @@ fn patch_long(b: &mut [u8], tag: u16, to: u32) -> bool { patched == 1 } +/// Tiles that all point at the same bytes decode once and fill every tile (the range is shared, +/// not decoded per tile), and give the image a lossless JPEG of that one tile repeated. +#[test] +fn tiles_sharing_one_range_fill_every_tile() { + let mut d = DngSpec::cfa(16, 16, mosaic(&scene(16, 16), 16, [0, 1, 1, 2], 64, 4000)); + d.bits = 12; + d.white = 4000; + d.black = vec![64]; + d.storage = DngStorage::Lj92Tiles { width: 8, height: 8 }; + let mut b = d.build(); + // TileOffsets (324) and TileByteCounts (325): LONG x 4, stored out of line. + for tag in [324u16, 325] { + let e = (8..b.len() - 12) + .find(|&i| b[i..i + 2] == tag.to_le_bytes() && b[i + 2..i + 4] == 4u16.to_le_bytes() && b[i + 4..i + 8] == 4u32.to_le_bytes()) + .expect("tile tag"); + let at = u32::from_le_bytes(b[e + 8..e + 12].try_into().unwrap()) as usize; + let first: [u8; 4] = b[at..at + 4].try_into().unwrap(); + for k in 1..4 { + b[at + 4 * k..at + 4 * k + 4].copy_from_slice(&first); + } + } + let shared = decode(&b, &Limits::default()).expect("a repeated tile range decodes"); + let plane = &shared.data; + // Every 8x8 tile holds the first tile's samples. + for (ty, tx) in [(0, 8), (8, 0), (8, 8)] { + for y in 0..8 { + assert_eq!(plane[(ty + y) * 16 + tx..(ty + y) * 16 + tx + 8], plane[y * 16..y * 16 + 8]); + } + } +} + /// The lossless-JPEG intermediate is sized from the segment, so the plane check alone /// does not bound it: it has to be charged against `max_alloc` too (#2925). #[test] diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs index 27a1af788b..666514c594 100644 --- a/crates/raw/tests/it/main.rs +++ b/crates/raw/tests/it/main.rs @@ -3,6 +3,7 @@ mod cr3; mod development_limits; mod dng; +mod hasselblad; mod hostile; mod nef_packed; mod nikon_d4_depth; diff --git a/crates/raw/tests/it/vendors.rs b/crates/raw/tests/it/vendors.rs index 1946899192..e1b70bc88f 100644 --- a/crates/raw/tests/it/vendors.rs +++ b/crates/raw/tests/it/vendors.rs @@ -56,7 +56,8 @@ fn cr2_develops() { #[test] fn cr2_srgb_raw_is_unsupported() { - // A subsampled (sRAW-style) frame: patch the first component's sampling factors. + // A subsampled frame in a layout Canon sRAW does not use (two components, Y 2×1): patch the + // first component's sampling factors. Real sRAW / mRAW decodes (see `sraw.rs`). let mut b = cr2_spec(32, 8, 2, vec![]).build(); let sof = b.windows(2).position(|w| w == [0xFF, 0xC3]).unwrap(); b[sof + 11] = 0x21; diff --git a/crates/tablet/Cargo.toml b/crates/tablet/Cargo.toml index e14e4a5ae0..2eed4b7cb8 100644 --- a/crates/tablet/Cargo.toml +++ b/crates/tablet/Cargo.toml @@ -3,12 +3,12 @@ name = "photocraft-tablet" version.workspace = true edition.workspace = true license.workspace = true -description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button on Windows" +description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit), Linux X11 (XInput2) and Windows (WinTab), and barrel button and eraser end on Windows (Windows Ink)" # The one isolated `unsafe` helper crate (see AGENTS.md, Never crash): the AppKit event -# monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only -# `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is denied, and every other -# crate keeps the workspace's `unsafe_code = "forbid"`. +# monitor on macOS and the WH_GETMESSAGE and WH_CALLWNDPROC pointer hooks on Windows need +# platform interop. Only `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is +# denied, and every other crate keeps the workspace's `unsafe_code = "forbid"`. [lints.rust] unsafe_code = "deny" @@ -31,6 +31,15 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std [target.'cfg(target_os = "linux")'.dependencies] x11rb = { version = "=0.13.2", default-features = false, features = ["xinput"] } +# WinTab 1.4: a system context on PhotoCraft's real window supplies pressure, tilt, rotation and +# the eraser end while Windows Ink is disabled. `wintab` safely wraps the vendor's wintab32.dll, +# loaded at run time; only the Windows target builds it. `windows` is pinned to the version +# `wintab` uses. +[target.'cfg(target_os = "windows")'.dependencies] +log = { workspace = true } +wintab = "=0.1.1" +windows = { version = "=0.62.2", features = ["Win32_Foundation"] } + # Tests build real tablet NSEvents (from CGEvents) and read them through the monitor's path. [target.'cfg(target_os = "macos")'.dev-dependencies] objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "block2", "NSEvent", "NSGraphicsContext", "objc2-core-graphics", "NSApplication", "NSResponder"] } diff --git a/crates/tablet/src/lib.rs b/crates/tablet/src/lib.rs index 41b48f9966..480c888a39 100644 --- a/crates/tablet/src/lib.rs +++ b/crates/tablet/src/lib.rs @@ -5,10 +5,13 @@ //! each `NSEvent` (`pressure`, `tilt`, `rotation`, `subtype`, and `pointingDeviceType` from //! proximity events) before winit sees the event, so every pointer event the UI handles already //! has its pen sample. -//! - **Windows** ([`windows::Monitor`]): a thread-local message hook inspects `WM_POINTER*` -//! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`), -//! which winit 0.30 drops by mapping pen inputs to touch events. -//! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs` +//! - **Windows** ([`windows::Monitor`]): thread-local message hooks inspect `WM_POINTER*` +//! messages on the UI thread to read `PEN_FLAG_BARREL` and the eraser end (`PEN_FLAG_INVERTED`) +//! from `GetPointerPenInfo`, which winit 0.30 drops by mapping pen inputs to touch events. +//! - **Windows WinTab** ([`wintab`]): with Windows Ink disabled for the tablet, a WinTab 1.4 +//! system context on PhotoCraft's window supplies pressure, tilt, rotation and the eraser end. +//! The vendor driver still moves the cursor; [`wintab::poll`] is called once per frame. +//! AppKit's monitor API and Windows' thread hooks need OS interop, so `macos.rs` and `windows.rs` //! allow `unsafe` (an isolated, tested helper crate with `SAFETY:` //! comments on every block). //! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2 @@ -37,6 +40,8 @@ pub mod xi; pub mod macos; #[cfg(target_os = "windows")] pub mod windows; +#[cfg(target_os = "windows")] +pub mod wintab; #[cfg(target_os = "linux")] pub mod x11; diff --git a/crates/tablet/src/windows.rs b/crates/tablet/src/windows.rs index eb5b2b28c2..f509cc5a0f 100644 --- a/crates/tablet/src/windows.rs +++ b/crates/tablet/src/windows.rs @@ -1,17 +1,55 @@ -//! Windows pointer hook for tablet data dropped by winit (such as the pen barrel button). +//! Windows pointer hook for tablet data dropped by winit (the pen barrel button and eraser end). //! -//! winit 0.30 drops `POINTER_PEN_INFO::penFlags` (`PEN_FLAG_BARREL`) on Windows and turns pen -//! inputs into `WindowEvent::Touch`, mapping them to `PointerButton::Primary`. Windows Ink also -//! does not update `GetAsyncKeyState(VK_RBUTTON)` for stylus barrel buttons. This thread-local -//! `WH_GETMESSAGE` hook inspects `WM_POINTER*` messages on the UI thread's message queue to -//! track the physical barrel button state. +//! winit 0.30 drops `POINTER_PEN_INFO::penFlags` (`PEN_FLAG_BARREL`, `PEN_FLAG_INVERTED`, +//! `PEN_FLAG_ERASER`) on Windows and turns pen inputs into `WindowEvent::Touch`, mapping them to +//! `PointerButton::Primary`. Windows Ink also does not update `GetAsyncKeyState(VK_RBUTTON)` for +//! stylus barrel buttons. Thread-local hooks inspect `WM_POINTER*` messages on the UI thread to +//! track the physical barrel button state and which end of the pen is in use: `WH_GETMESSAGE` for +//! the posted ones (`WM_POINTERDOWN`, `WM_POINTERUPDATE`, `WM_POINTERUP`) and `WH_CALLWNDPROC` for +//! `WM_POINTERENTER` and `WM_POINTERLEAVE`, which Windows documents as sent to the window, so the +//! queue hook alone may never see the pen arrive or leave. #![allow(unsafe_code, clippy::upper_case_acronyms)] -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering}; static BARREL_DOWN: AtomicBool = AtomicBool::new(false); +/// The pointer id of the pen last seen, or [`NO_PEN`]. Ids come from a 16-bit field, so +/// `u32::MAX` is never one. +static PEN_ID: AtomicU32 = AtomicU32::new(NO_PEN); +const NO_PEN: u32 = u32::MAX; + +/// The pen end last reported, as [`encode_end`] stores it. +static PEN_END: AtomicU8 = AtomicU8::new(0); + +/// The pen end of the last contact [`take_pen_end`] has not read yet, as [`encode_end`] stores it. +static CONTACT_END: AtomicU8 = AtomicU8::new(0); + +fn encode_end(end: Option) -> u8 { + match end { + None => 0, + Some(false) => 1, + Some(true) => 2, + } +} + +fn decode_end(v: u8) -> Option { + match v { + 1 => Some(false), + 2 => Some(true), + _ => None, + } +} + +#[repr(C)] +struct CWPSTRUCT { + l_param: isize, + w_param: usize, + message: u32, + hwnd: *mut std::ffi::c_void, +} + #[repr(C)] struct POINT { x: i32, @@ -74,17 +112,40 @@ unsafe extern "system" { } const WH_GETMESSAGE: i32 = 3; -const WM_POINTERUPDATE: u32 = 0x0245; -const WM_POINTERDOWN: u32 = 0x0246; -const WM_POINTERUP: u32 = 0x0247; -const WM_POINTERLEAVE: u32 = 0x024A; +const WH_CALLWNDPROC: i32 = 4; +/// `WM_POINTERENTER` (winuser.h). +pub const WM_POINTERENTER: u32 = 0x0249; +/// `WM_POINTERUPDATE` (winuser.h). +pub const WM_POINTERUPDATE: u32 = 0x0245; +/// `WM_POINTERDOWN` (winuser.h). +pub const WM_POINTERDOWN: u32 = 0x0246; +/// `WM_POINTERUP` (winuser.h). +pub const WM_POINTERUP: u32 = 0x0247; +/// `WM_POINTERLEAVE` (winuser.h). +pub const WM_POINTERLEAVE: u32 = 0x024A; /// `PEN_FLAG_BARREL` (winuser.h): the barrel button of the pen is pressed. pub const PEN_FLAG_BARREL: u32 = 0x00000001; +/// `PEN_FLAG_INVERTED` (winuser.h): the pen is inverted (its eraser end is in use). +pub const PEN_FLAG_INVERTED: u32 = 0x00000002; + +/// `PEN_FLAG_ERASER` (winuser.h): the eraser button of the pen is pressed. +pub const PEN_FLAG_ERASER: u32 = 0x00000004; + /// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (winuser.h): the secondary action button is pressed. pub const POINTER_FLAG_SECONDBUTTON: u32 = 0x00000020; +/// Pure decode of which end of the pen is in use: `Some(true)` for the eraser end, `Some(false)` +/// for the tip, `None` when the pointer is not a pen (`GetPointerPenInfo` failed: mouse, touch). +/// +/// Only `PEN_FLAG_INVERTED` means the pen is flipped: it is set the whole time the eraser end is +/// in range, and `PEN_FLAG_ERASER` joins it on contact. `PEN_FLAG_ERASER` alone is an eraser +/// *button*, which some pens put on the barrel; pressing that must not switch tools. +pub fn is_eraser_end(pen_flags: Option) -> Option { + pen_flags.map(|pen| (pen & PEN_FLAG_INVERTED) != 0) +} + /// Pure decode of whether the stylus barrel button is pressed. /// /// Plain tip contact sets `POINTER_FLAG_INCONTACT` (`0x0004`) and optionally `POINTER_FLAG_FIRSTBUTTON` @@ -97,66 +158,159 @@ pub fn is_barrel_pressed(pen_flags: Option, pointer_message_flags: u32) -> } } +/// What the hooks track between pointer messages. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PenState { + /// The barrel button is pressed. + pub barrel: bool, + /// Which end of the pen is in use (`Some(true)` = eraser), `None` = no pen in range. + pub end: Option, + /// The pointer id of the pen last seen, so another pointer leaving does not forget it. + pub pen_id: Option, + /// The end of the last pen contact, kept when the pen leaves: the contact and its release + /// can still be queued for the UI after the leave is handled, and must get that end's tool. + pub contact: Option, +} + +/// Pure transition of [`PenState`] for one pointer message. `w_param` is the message's (pointer +/// id in the low word, pointer flags in the high word); `pen_flags` reads `GetPointerPenInfo`'s +/// `penFlags` for a pointer id (`None`: not a pen), and only down and update read it. +pub fn next_state(prev: PenState, message: u32, w_param: usize, pen_flags: impl FnOnce(u32) -> Option) -> PenState { + let pointer_id = (w_param & 0xFFFF) as u32; + match message { + // A pen entering range while hovering may stay still: its first update can come late. + WM_POINTERDOWN | WM_POINTERUPDATE | WM_POINTERENTER => { + let pointer_message_flags = ((w_param >> 16) & 0xFFFF) as u32; + let pen_flags = pen_flags(pointer_id); + let end = is_eraser_end(pen_flags); + // A mouse or a finger in between leaves the pen's end as it was. + PenState { + barrel: is_barrel_pressed(pen_flags, pointer_message_flags), + end: end.or(prev.end), + pen_id: if pen_flags.is_some() { Some(pointer_id) } else { prev.pen_id }, + contact: if message == WM_POINTERDOWN { end.or(prev.contact) } else { prev.contact }, + } + } + WM_POINTERUP => PenState { barrel: false, ..prev }, + // A finger or a palm leaving while the pen stays in range (pen and touch screens). + WM_POINTERLEAVE if prev.pen_id.is_some_and(|id| id != pointer_id) => prev, + WM_POINTERLEAVE => PenState { contact: prev.contact, ..PenState::default() }, + _ => prev, + } +} + +/// Apply one pointer message to the state [`is_barrel_button_down`], [`pen_end`] and +/// [`take_pen_end`] read. The hooks call this; tests call it with a fake `pen_flags` reader. +pub fn handle_pointer_message(message: u32, w_param: usize, pen_flags: impl FnOnce(u32) -> Option) { + let prev = PenState { + barrel: BARREL_DOWN.load(Ordering::Relaxed), + end: decode_end(PEN_END.load(Ordering::Relaxed)), + pen_id: Some(PEN_ID.load(Ordering::Relaxed)).filter(|&id| id != NO_PEN), + contact: decode_end(CONTACT_END.load(Ordering::Relaxed)), + }; + let next = next_state(prev, message, w_param, pen_flags); + BARREL_DOWN.store(next.barrel, Ordering::Relaxed); + PEN_END.store(encode_end(next.end), Ordering::Relaxed); + PEN_ID.store(next.pen_id.unwrap_or(NO_PEN), Ordering::Relaxed); + CONTACT_END.store(encode_end(next.contact), Ordering::Relaxed); +} + +fn read_pen_flags(pointer_id: u32) -> Option { + // SAFETY: `info` is a stack-allocated buffer initialized to zero that outlives the call, and + // `pointer_id` comes from a WM_POINTER message; an unknown id makes the call fail, not crash. + unsafe { + let mut info: POINTER_PEN_INFO = std::mem::zeroed(); + (GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags) + } +} + +/// `WH_GETMESSAGE`: the posted pointer messages, as the UI thread takes them from its queue. unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize) -> isize { if code >= 0 && l_param != 0 { // SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct // dispatched to this thread's message queue. let msg = unsafe { &*(l_param as *const MSG) }; - match msg.message { - WM_POINTERDOWN | WM_POINTERUPDATE => { - let pointer_id = (msg.w_param & 0xFFFF) as u32; - let pointer_message_flags = (msg.w_param >> 16) as u32; - // SAFETY: `pen_info` is a stack-allocated buffer initialized to zero, and `pointer_id` - // is the pointer identifier from the valid WM_POINTER message. - let pen_flags = unsafe { - let mut info: POINTER_PEN_INFO = std::mem::zeroed(); - (GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags) - }; - let is_barrel = is_barrel_pressed(pen_flags, pointer_message_flags); - BARREL_DOWN.store(is_barrel, Ordering::Relaxed); - } - WM_POINTERUP | WM_POINTERLEAVE => { - BARREL_DOWN.store(false, Ordering::Relaxed); - } - _ => {} - } + // The WinTab backend shares this thread hook: it decodes the driver's proximity + // messages (and ignores everything else) to clear a pen that left the tablet. + crate::wintab::on_message(msg.message, msg.w_param, msg.l_param); + handle_pointer_message(msg.message, msg.w_param, read_pen_flags); } // SAFETY: CallNextHookEx passes the parameters down the hook chain. MSDN notes the first // parameter is ignored and can be null. unsafe { CallNextHookEx(std::ptr::null_mut(), code, w_param, l_param) } } -/// Keeps the thread-local pointer message hook installed; dropping it removes the hook. +/// `WH_CALLWNDPROC`: messages sent to the thread's windows. Only `WM_POINTERENTER` and +/// `WM_POINTERLEAVE` matter here; down, update and up are posted and reach [`get_msg_proc`]. +unsafe extern "system" fn call_wnd_proc(code: i32, w_param: usize, l_param: isize) -> isize { + if code >= 0 && l_param != 0 { + // SAFETY: Win32 guarantees that for code >= 0 (HC_ACTION), l_param points to a valid + // CWPSTRUCT describing the message being sent. + let msg = unsafe { &*(l_param as *const CWPSTRUCT) }; + match msg.message { + WM_POINTERENTER => handle_pointer_message(msg.message, msg.w_param, read_pen_flags), + WM_POINTERLEAVE => handle_pointer_message(msg.message, msg.w_param, |_| None), + _ => {} + } + } + // SAFETY: as in `get_msg_proc`. + unsafe { CallNextHookEx(std::ptr::null_mut(), code, w_param, l_param) } +} + +type HookProc = unsafe extern "system" fn(i32, usize, isize) -> isize; + +/// Keeps the thread-local pointer message hooks installed; dropping it removes them. /// Main-thread only (not `Send`). pub struct Monitor { - hook: *mut std::ffi::c_void, + /// `WH_GETMESSAGE`, then `WH_CALLWNDPROC` (null when it failed to install). + hooks: [*mut std::ffi::c_void; 2], } impl Monitor { - /// Installs the thread-local pointer message hook on the UI thread. + /// Installs the thread-local pointer message hooks on the UI thread. Only a failed + /// `WH_GETMESSAGE` hook is an error: it carries the barrel button and the pen end. Without + /// `WH_CALLWNDPROC` the pen may not be seen leaving (see [`Monitor::tracks_leave`]). pub fn install() -> Result { - // SAFETY: GetCurrentThreadId has no preconditions. SetWindowsHookExW installs - // a thread-specific WH_GETMESSAGE hook on the calling thread's message queue - // using our extern "system" callback. - let hook = unsafe { - let thread_id = GetCurrentThreadId(); - SetWindowsHookExW(WH_GETMESSAGE, get_msg_proc, std::ptr::null_mut(), thread_id) - }; - if hook.is_null() { - return Err(crate::Error::Platform("SetWindowsHookExW returned null".into())); + // SAFETY: GetCurrentThreadId has no preconditions. + let thread_id = unsafe { GetCurrentThreadId() }; + Self::install_with(|id_hook, proc_| { + // SAFETY: installs a thread-specific hook on the calling thread (no module handle + // needed) with one of our extern "system" callbacks. + unsafe { SetWindowsHookExW(id_hook, proc_, std::ptr::null_mut(), thread_id) } + }) + } + + /// [`Monitor::install`] with the hook installer passed in, so tests can fail one hook. + fn install_with(mut set_hook: impl FnMut(i32, HookProc) -> *mut std::ffi::c_void) -> Result { + let get_msg = set_hook(WH_GETMESSAGE, get_msg_proc); + if get_msg.is_null() { + return Err(crate::Error::Platform("SetWindowsHookExW(WH_GETMESSAGE) returned null".into())); } - Ok(Self { hook }) + Ok(Self { hooks: [get_msg, set_hook(WH_CALLWNDPROC, call_wnd_proc)] }) + } + + /// Whether the `WH_CALLWNDPROC` hook installed. Without it the pen leaving range may be + /// missed: the last pen end and barrel state stay until the next pointer message. + pub fn tracks_leave(&self) -> bool { + !self.hooks[1].is_null() } } impl Drop for Monitor { fn drop(&mut self) { - // SAFETY: `self.hook` is the non-null handle returned by `SetWindowsHookExW`, - // unhooked exactly once here on the thread that created it. - unsafe { - UnhookWindowsHookEx(self.hook); + for hook in self.hooks { + if !hook.is_null() { + // SAFETY: `hook` is a non-null handle returned by `SetWindowsHookExW`, unhooked + // exactly once here on the thread that created it. + unsafe { + UnhookWindowsHookEx(hook); + } + } } BARREL_DOWN.store(false, Ordering::Relaxed); + PEN_END.store(encode_end(None), Ordering::Relaxed); + PEN_ID.store(NO_PEN, Ordering::Relaxed); + CONTACT_END.store(encode_end(None), Ordering::Relaxed); } } @@ -165,6 +319,19 @@ pub fn is_barrel_button_down() -> bool { BARREL_DOWN.load(Ordering::Relaxed) } +/// Which end of the pen is in use: `Some(true)` for the eraser end, `Some(false)` for the tip, +/// `None` when no pen is in range of the window. +pub fn pen_end() -> Option { + decode_end(PEN_END.load(Ordering::Relaxed)) +} + +/// [`pen_end`] for the UI's once-per-frame read: the end of a contact not read yet comes first, +/// once, even when the pen has left since, so a click still queued for the UI gets that end's +/// tool. +pub fn take_pen_end() -> Option { + decode_end(CONTACT_END.swap(encode_end(None), Ordering::Relaxed)).or_else(pen_end) +} + #[cfg(test)] mod tests { use super::*; @@ -192,4 +359,129 @@ mod tests { assert!(is_barrel_pressed(Some(0), POINTER_FLAG_SECONDBUTTON)); assert!(is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_SECONDBUTTON)); } + + #[test] + fn eraser_end_is_the_inverted_pen() { + assert_eq!(is_eraser_end(Some(PEN_FLAG_INVERTED)), Some(true), "hovering eraser end"); + assert_eq!(is_eraser_end(Some(PEN_FLAG_INVERTED | PEN_FLAG_ERASER)), Some(true), "eraser end on the tablet"); + assert_eq!(is_eraser_end(Some(PEN_FLAG_ERASER)), Some(false), "an eraser button on the barrel is not the eraser end"); + assert_eq!(is_eraser_end(Some(0)), Some(false), "the tip"); + assert_eq!(is_eraser_end(Some(PEN_FLAG_BARREL)), Some(false), "the barrel button is not the eraser"); + assert_eq!(is_eraser_end(None), None, "not a pen"); + } + + /// `w_param` of a pointer message: pointer id 7 with these pointer flags. + fn wp(flags: u32) -> usize { + 7 | ((flags as usize) << 16) + } + + #[test] + fn pen_state_follows_pointer_messages() { + let pen = |flags: u32| move |id: u32| (id == 7).then_some(flags); + let s = next_state(PenState::default(), WM_POINTERUPDATE, wp(0), pen(0)); + assert_eq!(s, PenState { barrel: false, end: Some(false), pen_id: Some(7), contact: None }, "the tip hovers"); + let s = next_state(s, WM_POINTERUPDATE, wp(0), pen(PEN_FLAG_INVERTED)); + assert_eq!(s.end, Some(true), "the eraser end hovers"); + let s = next_state(s, WM_POINTERDOWN, wp(POINTER_FLAG_INCONTACT), pen(PEN_FLAG_INVERTED | PEN_FLAG_ERASER)); + assert_eq!(s, PenState { barrel: false, end: Some(true), pen_id: Some(7), contact: Some(true) }); + let s = next_state(s, WM_POINTERUPDATE, wp(0), |_| None); + assert_eq!((s.end, s.pen_id), (Some(true), Some(7)), "a mouse in between keeps the pen"); + let s = next_state(s, WM_POINTERUPDATE, wp(0), pen(PEN_FLAG_BARREL)); + assert_eq!(s, PenState { barrel: true, end: Some(false), pen_id: Some(7), contact: Some(true) }, "only a contact sets it"); + let s = next_state(s, WM_POINTERUP, wp(0), |_| panic!("up does not read the pen")); + assert_eq!(s, PenState { barrel: false, end: Some(false), pen_id: Some(7), contact: Some(true) }, "lifting keeps the end"); + let s = next_state(s, WM_POINTERLEAVE, wp(0), |_| panic!("leave does not read the pen")); + assert_eq!(s, PenState { contact: Some(true), ..PenState::default() }, "leaving range forgets the pen, not its contact"); + let mouse_move = 0x0200; + assert_eq!(next_state(s, mouse_move, wp(0), pen(PEN_FLAG_INVERTED)), s, "other messages change nothing"); + } + + #[test] + fn another_pointer_leaving_keeps_the_pen() { + let held = PenState { barrel: true, end: Some(true), pen_id: Some(7), contact: None }; + let finger_leaves = 2 | ((POINTER_FLAG_INCONTACT as usize) << 16); + assert_eq!(next_state(held, WM_POINTERLEAVE, finger_leaves, |_| panic!("leave does not read the pen")), held); + assert_eq!(next_state(held, WM_POINTERLEAVE, wp(0), |_| None), PenState::default(), "the pen itself leaving"); + let unknown = PenState { barrel: true, end: None, pen_id: None, contact: None }; + assert_eq!(next_state(unknown, WM_POINTERLEAVE, finger_leaves, |_| None), PenState::default(), "no pen seen yet"); + } + + /// A pen entering range with its eraser end is known before it moves (`WM_POINTERENTER` can + /// come well before the first hover update). + #[test] + fn entering_range_reads_the_pen_end() { + let s = next_state(PenState::default(), WM_POINTERENTER, wp(0), |id| (id == 7).then_some(PEN_FLAG_INVERTED)); + assert_eq!(s, PenState { barrel: false, end: Some(true), pen_id: Some(7), contact: None }); + let s = next_state(s, WM_POINTERENTER, 9, |_| None); + assert_eq!((s.end, s.pen_id), (Some(true), Some(7)), "a mouse or finger entering keeps the pen"); + } + + /// Serialises the tests that touch the global state the hooks write (tests run in parallel). + static GLOBALS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn handled_messages_reach_the_readers() { + let _globals = GLOBALS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + handle_pointer_message(WM_POINTERUPDATE, wp(0), |_| Some(PEN_FLAG_INVERTED | PEN_FLAG_BARREL)); + assert_eq!((pen_end(), is_barrel_button_down()), (Some(true), true)); + handle_pointer_message(WM_POINTERUP, wp(0), |_| None); + assert_eq!((pen_end(), is_barrel_button_down()), (Some(true), false)); + handle_pointer_message(WM_POINTERUPDATE, wp(0), |_| Some(0)); + assert_eq!(pen_end(), Some(false)); + handle_pointer_message(WM_POINTERLEAVE, 2, |_| None); + assert_eq!(pen_end(), Some(false), "a finger leaving keeps the pen"); + handle_pointer_message(WM_POINTERLEAVE, wp(0), |_| None); + assert_eq!((pen_end(), is_barrel_button_down()), (None, false)); + assert_eq!(take_pen_end(), None, "no contact to report"); + } + + /// An eraser-end tap and the pen leaving range, all handled before the UI's next frame: the + /// frame still reads the eraser end once, so the queued click erases. + #[test] + fn a_contact_queued_past_the_leave_keeps_its_end() { + let _globals = GLOBALS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + handle_pointer_message(WM_POINTERLEAVE, wp(0), |_| None); + let _ = take_pen_end(); + handle_pointer_message(WM_POINTERDOWN, wp(POINTER_FLAG_INCONTACT), |_| Some(PEN_FLAG_INVERTED | PEN_FLAG_ERASER)); + handle_pointer_message(WM_POINTERUP, wp(0), |_| None); + handle_pointer_message(WM_POINTERLEAVE, wp(0), |_| None); + assert_eq!(pen_end(), None, "the pen is out of range"); + assert_eq!(take_pen_end(), Some(true), "the queued contact was the eraser end"); + assert_eq!(take_pen_end(), None, "reported once"); + handle_pointer_message(WM_POINTERDOWN, wp(POINTER_FLAG_INCONTACT), |_| Some(PEN_FLAG_INVERTED)); + handle_pointer_message(WM_POINTERUP, wp(0), |_| None); + handle_pointer_message(WM_POINTERUPDATE, wp(0), |_| Some(0)); + assert_eq!(take_pen_end(), Some(true), "the unread contact comes before the tip hovering since"); + assert_eq!(take_pen_end(), Some(false), "then the end in range"); + handle_pointer_message(WM_POINTERLEAVE, wp(0), |_| None); + } + + #[test] + fn a_failed_leave_hook_keeps_the_message_hook() { + let _globals = GLOBALS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut installed = Vec::new(); + let monitor = Monitor::install_with(|id_hook, proc_| { + installed.push(id_hook); + if id_hook == WH_CALLWNDPROC { + return std::ptr::null_mut(); + } + // SAFETY: a real thread-specific hook on this test thread; the monitor unhooks it. + unsafe { SetWindowsHookExW(id_hook, proc_, std::ptr::null_mut(), GetCurrentThreadId()) } + }) + .expect("the message hook alone is enough"); + assert_eq!(installed, [WH_GETMESSAGE, WH_CALLWNDPROC]); + assert!(!monitor.hooks[0].is_null()); + assert!(!monitor.tracks_leave()); + } + + #[test] + fn a_failed_message_hook_is_an_error() { + let mut installed = Vec::new(); + let r = Monitor::install_with(|id_hook, _| { + installed.push(id_hook); + std::ptr::null_mut() + }); + assert!(matches!(r, Err(crate::Error::Platform(why)) if why.contains("WH_GETMESSAGE"))); + assert_eq!(installed, [WH_GETMESSAGE], "no leave hook without the message hook"); + } } diff --git a/crates/tablet/src/wintab.rs b/crates/tablet/src/wintab.rs new file mode 100644 index 0000000000..3193ae24f1 --- /dev/null +++ b/crates/tablet/src/wintab.rs @@ -0,0 +1,334 @@ +//! WinTab 1.4 backend for Windows. +//! +//! The app opens a WinTab context on its real top-level window and polls queued packets once per +//! frame. This module only translates Wintab packets into [`Sample`]s; it does not synthesise +//! pointer events. With Windows Ink disabled for the tablet, the vendor driver provides the +//! mouse cursor while this module provides pressure, tilt, rotation and the eraser flag. +//! +//! Devices without orientation axes (pressure-only tablets) are opened with a packet that asks +//! only for the fields the driver reports; their tilt is neutral rather than a made-up value. +//! +//! The context is thread-local because Wintab contexts belong to the thread and window that +//! opened them. [`install`] and [`poll`] must therefore run on the UI thread. + +use std::cell::RefCell; +use std::ffi::c_void; +use std::time::{Duration, Instant}; + +use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; + +use crate::{Error, Sample}; + +wintab::packet! { + struct PenPacket { status, buttons, x, y, normal_pressure, orientation } +} + +wintab::packet! { + struct PressurePacket { status, buttons, x, y, normal_pressure } +} + +/// The open context. A device that reports orientation gets tilt and rotation; a pressure-only +/// device gets a packet without the orientation field, so a zeroed field never becomes a tilt. +enum Backend { + Oriented { context: wintab::Context, axes: [wintab::Axis; 3] }, + PressureOnly { context: wintab::Context }, +} + +struct State { + backend: Backend, + pressure_axis: wintab::Axis, + last_packet: Instant, + emitted: bool, + focused: bool, + /// The pen is inside the tablet's sensing range (`WT_PROXIMITY`, hardware transitions). + proximity_in: bool, + /// Requests a frame when a WinTab message arrives, so pressure-only strokes update without + /// cursor movement. + wake: Box, +} + +thread_local! { + static STATE: RefCell> = const { RefCell::new(None) }; +} + +/// Opens a WinTab system context on `hwnd`. +/// +/// Call on the UI thread. A previous context on this thread is replaced, so the caller keeps the +/// app's normal single-window lifetime. The context is polled with [`poll`]. `wake` is called from +/// the thread's message hook for every WinTab message: a frame is then requested even when the +/// pen only changes pressure (no cursor movement, no other input event). +pub fn install(hwnd: isize, wake: Box) -> Result<(), Error> { + if hwnd == 0 { + return Err(Error::Unsupported("no window handle".into())); + } + let wintab = wintab::Wintab::load().map_err(|e| Error::Platform(e.to_string()))?; + let device = wintab.device(0).ok_or_else(|| Error::Unsupported("no Wintab digitizer".into()))?; + let pressure_axis = device.pressure.ok_or_else(|| Error::Unsupported("Wintab device has no pressure axis".into()))?; + let hwnd = HWND(hwnd as *mut c_void); + let options = wintab::ContextOptions::new().system(true).name("PhotoCraft WinTab"); + // Ask only for the fields the device reports: a pressure-only tablet never fills + // orientation, and reading a zeroed orientation would invent a maximum tilt. + let backend = match device.orientation { + Some(axes) => { + let context = wintab.open::(hwnd, &options).map_err(|e| Error::Platform(e.to_string()))?; + if let Err(error) = context.overlap(true) { + log::warn!("WinTab: could not take overlap priority: {error}"); + } + Backend::Oriented { context, axes } + } + None => { + let context = wintab.open::(hwnd, &options).map_err(|e| Error::Platform(e.to_string()))?; + if let Err(error) = context.overlap(true) { + log::warn!("WinTab: could not take overlap priority: {error}"); + } + Backend::PressureOnly { context } + } + }; + STATE.with_borrow_mut(|slot| { + *slot = Some(State { backend, pressure_axis, last_packet: Instant::now(), emitted: false, focused: false, proximity_in: true, wake }); + }); + Ok(()) +} + +/// Drains queued packets and reports the newest sample. +/// +/// Reports `Some` while a pen is in use, `None` once the pen has left the tablet. Leaving is the +/// packet's proximity status, not a quiet period: a pen held still may send no packets for a +/// while, and treating that as left painted a held airbrush build-up at mouse pressure. A long +/// timeout remains only as a fallback for drivers that never report the leave. +/// +/// Coordinates in the packet are not used: with Windows Ink off, the vendor driver moves the +/// normal cursor, and PhotoCraft's existing pointer path supplies the position. +/// +/// `focused` is whether the app's window has focus. Another tablet application can take the top +/// WinTab overlap priority; when PhotoCraft is activated again it has to ask for it back. The +/// window procedure belongs to winit, so focus from the app's input is the activation signal. +pub fn poll(focused: bool, callback: &dyn Fn(Option)) { + STATE.with_borrow_mut(|slot| { + let Some(state) = slot.as_mut() else { return }; + if focused && !state.focused { + let result = match &state.backend { + Backend::Oriented { context, .. } => context.overlap(true), + Backend::PressureOnly { context } => context.overlap(true), + }; + if let Err(error) = result { + log::warn!("WinTab: could not take overlap priority: {error}"); + } + } else if !focused && state.focused { + let result = match &state.backend { + Backend::Oriented { context, .. } => context.overlap(false), + Backend::PressureOnly { context } => context.overlap(false), + }; + if let Err(error) = result { + log::warn!("WinTab: could not release overlap priority: {error}"); + } + } + state.focused = focused; + let State { backend, pressure_axis, last_packet, emitted, proximity_in, .. } = state; + match backend { + Backend::Oriented { context, axes } => { + let mut packets = Vec::new(); + context.drain(&mut packets); + for packet in packets { + if packet.status.out_of_context() { + if *emitted { + *emitted = false; + callback(None); + } + continue; + } + let sample = sample(&packet, pressure_axis, axes); + *last_packet = Instant::now(); + *emitted = true; + callback(Some(sample)); + } + } + Backend::PressureOnly { context } => { + let mut packets = Vec::new(); + context.drain(&mut packets); + for packet in packets { + if packet.status.out_of_context() { + if *emitted { + *emitted = false; + callback(None); + } + continue; + } + let sample = sample_pressure(&packet, pressure_axis); + *last_packet = Instant::now(); + *emitted = true; + callback(Some(sample)); + } + } + } + if !*proximity_in && *emitted { + *emitted = false; + callback(None); + } + if *emitted && last_packet.elapsed() >= Duration::from_secs(60) { + *emitted = false; + callback(None); + } + }); +} + +/// Handles a WinTab window message from the thread's message hook (`windows.rs`). +/// +/// A hardware proximity leave is a `WT_PROXIMITY` message, not a packet status: the context's +/// `TPS_PROXIMITY` status only says the cursor left the context. Clearing the feed here covers +/// drivers that send the hardware leave without a following out-of-context packet. +pub(crate) fn on_message(message: u32, wparam: usize, lparam: isize) { + // This runs inside the Win32 hook, where a panic aborts the app: a driver call under `poll` + // that pumps this thread's messages would find `STATE` borrowed. Such a message is skipped. + let _ = STATE.try_with(|cell| { + let Ok(mut slot) = cell.try_borrow_mut() else { return }; + let Some(state) = slot.as_mut() else { return }; + let decoded = match &state.backend { + Backend::Oriented { context, .. } => context.message(message, WPARAM(wparam), LPARAM(lparam)), + Backend::PressureOnly { context } => context.message(message, WPARAM(wparam), LPARAM(lparam)), + }; + let win_message = decoded.is_some(); + if let Some(wintab::Message::Proximity { entering, hardware }) = decoded + && hardware + { + state.proximity_in = entering; + } + if win_message { + // Packets can arrive with no cursor movement; the frame then polls them. + (state.wake)(); + } + }); +} + +/// Closes the context on this thread. +pub fn uninstall() { + STATE.with_borrow_mut(|slot| *slot = None); +} + +/// One packet from a device with orientation axes as the UI's pen sample. +/// +/// Angles come from the device's own axes (`TU_CIRCLE`), falling back to Wintab's usual tenths +/// of a degree when an axis does not measure angles. +fn sample(packet: &PenPacket, pressure: &wintab::Axis, orientation: &[wintab::Axis; 3]) -> Sample { + let raw = packet.orientation; + let degrees = |index: usize, value: i32| orientation[index].degrees(value).map_or(value as f32 / 10.0, |degrees| degrees as f32); + let azimuth = degrees(0, raw.azimuth).to_radians(); + // Wintab altitude is the angle from the tablet plane (0 = flat, 90 = upright); W3C tilt is + // the angle from the perpendicular, per axis: atan2 of the in-plane projection over height. + // Wintab azimuth 0 points at the tablet's top and grows clockwise, while W3C +x is right and + // +y towards the user, so the in-plane components are (sin(azimuth), -cos(azimuth)). + let elevation = degrees(1, raw.altitude).abs().to_radians(); + let in_plane_x = elevation.cos() * azimuth.sin(); + let in_plane_y = -(elevation.cos() * azimuth.cos()); + let height = elevation.sin(); + Sample { + pressure: pressure.normalize(packet.normal_pressure as i32), + tilt_x: in_plane_x.atan2(height).to_degrees(), + tilt_y: in_plane_y.atan2(height).to_degrees(), + rotation: degrees(2, raw.twist), + eraser: packet.status.inverted(), + } + .sanitized() +} + +/// One packet from a device without orientation axes: pressure and the eraser end, neutral tilt. +fn sample_pressure(packet: &PressurePacket, pressure: &wintab::Axis) -> Sample { + Sample { pressure: pressure.normalize(packet.normal_pressure as i32), tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: packet.status.inverted() } + .sanitized() +} + +#[cfg(test)] +mod tests { + use super::*; + use wintab::Units; + + fn pressure_axis() -> wintab::Axis { + wintab::Axis { min: 0, max: 32767, units: Units::None, resolution: 0.0 } + } + + fn angle_axis() -> wintab::Axis { + wintab::Axis { min: 0, max: 3599, units: Units::Circle, resolution: 3600.0 } + } + + fn axes() -> [wintab::Axis; 3] { + [angle_axis(), angle_axis(), angle_axis()] + } + + fn packet(normal_pressure: u32) -> PenPacket { + PenPacket { status: wintab::Status(0), buttons: wintab::Buttons(0), x: 0, y: 0, normal_pressure, orientation: wintab::Orientation::default() } + } + + fn pressure_packet(normal_pressure: u32) -> PressurePacket { + PressurePacket { status: wintab::Status(0), buttons: wintab::Buttons(0), x: 0, y: 0, normal_pressure } + } + + /// Pressure is normalized through the device's own axis. + #[test] + fn pressure_maps_through_the_device_axis() { + let axes = axes(); + let s = sample(&packet(16384), &pressure_axis(), &axes); + assert!((s.pressure - 0.5).abs() < 0.01, "{}", s.pressure); + let s = sample(&packet(0), &pressure_axis(), &axes); + assert_eq!(s.pressure, 0.0); + } + + /// The inverted status bit is the eraser end. + #[test] + fn eraser_comes_from_the_inverted_status() { + let axes = axes(); + let inverted = PenPacket { status: wintab::Status(wintab::sys::TPS_INVERT), ..packet(0) }; + assert!(sample(&inverted, &pressure_axis(), &axes).eraser); + assert!(!sample(&packet(0), &pressure_axis(), &axes).eraser); + } + + /// Orientation axes are converted to degrees for the W3C tilt projection. + #[test] + fn orientation_axes_report_degrees() { + let axes = axes(); + let tilted = PenPacket { orientation: wintab::Orientation { azimuth: 450, altitude: 300, twist: 900 }, ..packet(0) }; + let sample = sample(&tilted, &pressure_axis(), &axes); + assert!((sample.tilt_x - 50.8).abs() < 0.1, "{}", sample.tilt_x); + assert!((sample.tilt_y + 50.8).abs() < 0.1, "{}", sample.tilt_y); + assert!((sample.rotation - 90.0).abs() < 0.1, "{}", sample.rotation); + } + + /// A pressure-only device keeps pressure and the eraser end, with neutral tilt. + #[test] + fn pressure_only_devices_get_pressure_and_neutral_tilt() { + let s = sample_pressure(&pressure_packet(16384), &pressure_axis()); + assert!((s.pressure - 0.5).abs() < 0.01, "{}", s.pressure); + assert_eq!((s.tilt_x, s.tilt_y, s.rotation), (0.0, 0.0, 0.0)); + let inverted = PressurePacket { status: wintab::Status(wintab::sys::TPS_INVERT), ..pressure_packet(0) }; + assert!(sample_pressure(&inverted, &pressure_axis()).eraser); + } + + /// Wintab azimuth 0 is the tablet top; W3C axes are right and toward the user. + #[test] + fn cardinal_azimuths_match_w3c_tilt() { + let axes = axes(); + let tilt = |azimuth: i32| { + let p = PenPacket { orientation: wintab::Orientation { azimuth, altitude: 450, twist: 0 }, ..packet(0) }; + sample(&p, &pressure_axis(), &axes) + }; + let top = tilt(0); + assert!(top.tilt_x.abs() < 0.1 && (top.tilt_y + 45.0).abs() < 0.1, "{top:?}"); + let right = tilt(900); + assert!((right.tilt_x - 45.0).abs() < 0.1 && right.tilt_y.abs() < 0.1, "{right:?}"); + let bottom = tilt(1800); + assert!(bottom.tilt_x.abs() < 0.1 && (bottom.tilt_y - 45.0).abs() < 0.1, "{bottom:?}"); + let left = tilt(2700); + assert!((left.tilt_x + 45.0).abs() < 0.1 && left.tilt_y.abs() < 0.1, "{left:?}"); + } + + /// An upright pen has no tilt; a flat one has the most. + #[test] + fn an_upright_pen_has_no_tilt_and_a_flat_one_has_the_most() { + let axes = axes(); + let upright = PenPacket { orientation: wintab::Orientation { azimuth: 0, altitude: 900, twist: 0 }, ..packet(0) }; + let s = sample(&upright, &pressure_axis(), &axes); + assert!(s.tilt_x.abs() < 0.1 && s.tilt_y.abs() < 0.1, "{s:?}"); + let flat = PenPacket { orientation: wintab::Orientation { azimuth: 0, altitude: 0, twist: 0 }, ..packet(0) }; + let s = sample(&flat, &pressure_axis(), &axes); + assert!(s.tilt_x.abs() < 0.1 && (s.tilt_y + 90.0).abs() < 0.1, "{s:?}"); + } +} diff --git a/crates/text/src/area.rs b/crates/text/src/area.rs index 407065355d..7ed8525064 100644 --- a/crates/text/src/area.rs +++ b/crates/text/src/area.rs @@ -249,13 +249,23 @@ impl AreaShape { let mid = y0 + (y1 - y0) * 0.5; let mut whole: Vec<(f32, f32)> = Vec::new(); let mut ever: Vec<(f32, f32)> = Vec::new(); + // A run of Combine / Join components is one union: gather its intervals and merge them + // once when the run ends. Folding each component into the growing result rewrote the + // whole list per component, quadratic for a shape of many disjoint components. + let (mut more_whole, mut more_ever) = (Vec::new(), Vec::new()); for c in &self.comps { let inside = self.component_intervals(c, mid); let edges = self.swept(c, y0, y1); let w = boolean(&inside, &edges, |a, b| a && !b); let e = boolean(&inside, &edges, |a, b| a || b); + if matches!(c.op, PathOp::Combine | PathOp::Join) { + more_whole.extend(w); + more_ever.extend(e); + continue; + } + (whole, ever) = (union_all(whole, &mut more_whole), union_all(ever, &mut more_ever)); (whole, ever) = match c.op { - PathOp::Combine | PathOp::Join => (boolean(&whole, &w, |a, b| a || b), boolean(&ever, &e, |a, b| a || b)), + PathOp::Combine | PathOp::Join => (whole, ever), PathOp::Subtract => (boolean(&whole, &e, |a, b| a && !b), boolean(&ever, &w, |a, b| a && !b)), PathOp::Intersect => (boolean(&whole, &w, |a, b| a && b), boolean(&ever, &e, |a, b| a && b)), PathOp::Exclude => { @@ -265,7 +275,7 @@ impl AreaShape { } }; } - whole.into_iter().filter(|(l, r)| r - l >= min_width).collect() + union_all(whole, &mut more_whole).into_iter().filter(|(l, r)| r - l >= min_width).collect() } /// The first baseline at or below `from` whose band `[b - ascent, b + descent]` has spans @@ -308,6 +318,24 @@ pub fn path_bounds(path: &Path) -> Option<(f64, f64, f64, f64)> { path.control_bounds() } +/// The union of the sorted, disjoint intervals `base` and the intervals in `more` (any order, +/// drained), as [`boolean`] with `a || b` gives it: touching intervals join, empty ones vanish. +fn union_all(base: Vec<(f32, f32)>, more: &mut Vec<(f32, f32)>) -> Vec<(f32, f32)> { + if more.is_empty() { + return base; + } + let mut all: Vec<(f32, f32)> = base.into_iter().chain(more.drain(..)).filter(|(l, r)| l < r).collect(); + all.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f32, f32)> = Vec::with_capacity(all.len()); + for (l, r) in all { + match out.last_mut() { + Some(last) if l <= last.1 => last.1 = last.1.max(r), + _ => out.push((l, r)), + } + } + out +} + /// Boolean combination of two sorted, disjoint interval lists, in one linear merge. fn boolean(a: &[(f32, f32)], b: &[(f32, f32)], f: impl Fn(bool, bool) -> bool) -> Vec<(f32, f32)> { let ends = |v: &[(f32, f32)]| -> Vec { v.iter().flat_map(|&(l, r)| [l, r]).collect() }; @@ -370,6 +398,27 @@ mod tests { (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 } + #[test] + fn many_disjoint_components_union_like_the_pairwise_fold() { + // Within MAX_AREA_COMPONENTS: narrow columns, every other one overlapping its neighbour. + let cols: Vec = (0..2000) + .map(|i| { + let x = f64::from(i) * 3.0 + if i % 2 == 1 { -2.5 } else { 0.0 }; + Subpath::polygon(&[(x, 0.0), (x + 1.0, 0.0), (x + 1.0, 100.0), (x, 100.0)]).with_op(PathOp::Combine) + }) + .collect(); + let a = AreaShape::new(&Path::new(cols), false).unwrap(); + let got = a.spans(10.0, 20.0, 0.0); + // Each overlapping pair is one span: [3k, 3k + 1.5] for k = 0, 2, 4, ... + assert_eq!(got.len(), 1000); + for (k, &(l, r)) in got.iter().enumerate() { + let x = k as f32 * 6.0; + assert!((l - x).abs() < 1e-3 && (r - x - 1.5).abs() < 1e-3, "span {k}: {l}..{r}"); + } + // An empty interval adds nothing, a touching one joins. + assert_eq!(union_all(vec![(0.0, 1.0)], &mut vec![(5.0, 5.0), (1.0, 2.0)]), [(0.0, 2.0)]); + } + #[test] fn rectangle_band_is_its_width() { let a = AreaShape::new(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)]), false).unwrap(); diff --git a/crates/text/src/cjk.rs b/crates/text/src/cjk.rs index ab1c7d8992..1fe0f12534 100644 --- a/crates/text/src/cjk.rs +++ b/crates/text/src/cjk.rs @@ -136,18 +136,30 @@ fn os_locale() -> Option { #[cfg(target_os = "windows")] fn os_locale() -> Option { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let out = std::process::Command::new("reg") - .args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"]) - .creation_flags(CREATE_NO_WINDOW) - .output() - .ok()?; + let out = locale_query().output().ok()?; let text = String::from_utf8_lossy(&out.stdout); // " LocaleName REG_SZ ja-JP" text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string) } +/// `reg query` for the UI locale, with `reg.exe` by full path: by bare name, Windows first looks in +/// the app's own folder, so a `reg.exe` lying next to a portable PhotoCraft would start (#9666). +#[cfg(target_os = "windows")] +fn locale_query() -> std::process::Command { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut cmd = std::process::Command::new(windows_root(std::env::var_os("SystemRoot")).join("System32").join("reg.exe")); + cmd.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"]).creation_flags(CREATE_NO_WINDOW); + cmd +} + +/// The Windows folder for the `SystemRoot` value `system_root`: `C:\Windows` when it is unset or +/// not an absolute path (a relative one would resolve against the working directory). +#[cfg(target_os = "windows")] +fn windows_root(system_root: Option) -> std::path::PathBuf { + system_root.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")) +} + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn os_locale() -> Option { None @@ -654,6 +666,30 @@ mod tests { use super::*; use CjkScript::*; + /// `reg.exe` runs from System32, not from wherever a bare name would find one first (#9666), + /// and the query still answers the UI locale. + #[cfg(target_os = "windows")] + #[test] + fn locale_query_runs_the_system_reg() { + let cmd = locale_query(); + let system = windows_root(std::env::var_os("SystemRoot")).join("System32").join("reg.exe"); + assert_eq!(std::path::Path::new(cmd.get_program()), system); + assert!(os_locale().is_some_and(|l| !l.is_empty())); + } + + /// `SystemRoot` unset or relative (which would resolve against the working directory) falls + /// back to `C:\Windows`. + #[cfg(target_os = "windows")] + #[test] + fn the_windows_folder_falls_back_when_system_root_is_unset_or_relative() { + use std::path::PathBuf; + assert_eq!(windows_root(Some(r"D:\WinNT".into())), PathBuf::from(r"D:\WinNT")); + assert_eq!(windows_root(None), PathBuf::from(r"C:\Windows")); + assert_eq!(windows_root(Some(r"Windows".into())), PathBuf::from(r"C:\Windows")); + assert_eq!(windows_root(Some(r"\Windows".into())), PathBuf::from(r"C:\Windows")); + assert_eq!(windows_root(Some("".into())), PathBuf::from(r"C:\Windows")); + } + #[test] fn explicit_locale_scripts_are_distinct_from_the_non_cjk_default() { assert_eq!(locale_script(Some("ja_JP.UTF-8")), Some(Japanese)); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2f95cc6702..e5b800f966 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -137,6 +137,8 @@ pub struct ClusterInfo { pub struct TextLayout { pub faces: Vec, pub glyphs: Vec, + /// Layer-text byte range represented by each glyph, in the same order as `glyphs`. + pub glyph_ranges: Vec>, pub decorations: Vec, pub lines: Vec, pub clusters: Vec, @@ -324,7 +326,7 @@ impl Layouter { if flow.stop { break; } - let ps = paras[para_starts.iter().rposition(|&s| s <= prange.start).unwrap_or(0)].style.clone(); + let ps = paras[run_index_at(¶_starts, prange.start)].style.clone(); self.layout_paragraph(fonts, &mut out, &mut flow, Para::new(&env, fonts, pi, prange, ps)); } out.styles = styles; diff --git a/crates/text/src/layout/lines.rs b/crates/text/src/layout/lines.rs index b8943ef609..928e7de547 100644 --- a/crates/text/src/layout/lines.rs +++ b/crates/text/src/layout/lines.rs @@ -20,6 +20,8 @@ struct KernSlot { run: usize, /// Byte offset in the paragraph text. start: usize, + /// Paragraph-text byte range represented by this visual cluster. + text_range: std::ops::Range, first: Option, last: Option, font: parley::FontData, @@ -53,6 +55,8 @@ struct KernCursor { run: usize, /// Slot of the cluster each glyph of the run belongs to. glyph_slot: Vec, + /// Layer-text byte range represented by each glyph, parallel to `glyph_slot`. + glyph_range: Vec>, /// Glyphs emitted so far. emitted: usize, /// First slot whose kerning isn't applied yet. @@ -136,6 +140,7 @@ fn collect_kern_slots(line: &Line<'_>, para: &Para<'_>) -> Vec { slots.push(KernSlot { run: run.index(), start: c.text_range().start, + text_range: c.text_range(), first, last, font: run.font().clone(), @@ -193,18 +198,24 @@ fn kern_cursors(line: &Line<'_>, slots: &[KernSlot]) -> Vec { continue; } let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0); - cursors.push(KernCursor { run: run.index(), glyph_slot: glyph_slots(run, base), emitted: 0, applied: base }); + let (glyph_slot, glyph_range) = glyph_mapping(run, base, slots); + cursors.push(KernCursor { run: run.index(), glyph_slot, glyph_range, emitted: 0, applied: base }); } cursors } /// For each glyph of `run`, the slot of its cluster (the run's clusters start at slot `base`). -fn glyph_slots(run: &Run<'_, RunBrush>, base: usize) -> Vec { +fn glyph_mapping(run: &Run<'_, RunBrush>, base: usize, slots: &[KernSlot]) -> (Vec, Vec>) { let mut g2s = Vec::new(); + let mut ranges = Vec::new(); for (ci, c) in run.visual_clusters().enumerate() { - g2s.extend(c.glyphs().map(|_| base + ci)); + let range = slots.get(base + ci).map(|s| s.text_range.clone()).unwrap_or_else(|| c.text_range()); + for _ in c.glyphs() { + g2s.push(base + ci); + ranges.push(range.clone()); + } } - g2s + (g2s, ranges) } /// Distributes `slack` between the clusters of the line from `c0` (and their glyphs from `g0`); @@ -303,8 +314,10 @@ impl Para<'_> { let mut pos = self.indent_start + if line_index == 0 { self.first_indent } else { 0.0 }; let mut trailing_tabs = 0.0f32; let mut trailing_growth = 0.0f32; + // Whether every slot after `j` is blank, from one reverse scan rather than one per slot. + let last_inked = slots.iter().rposition(|n| !n.blank); for (j, s) in slots.iter().enumerate() { - let rest_blank = slots[j + 1..].iter().all(|n| n.blank); + let rest_blank = last_inked.is_none_or(|k| k <= j); if s.blank && rest_blank { trailing_growth += s.growth; } @@ -493,6 +506,7 @@ impl Para<'_> { let mut pen = gr.offset(); let mut cursor = state.cursors.iter_mut().find(|c| c.run == run.index()); for g in gr.glyphs() { + let source_range = cursor.as_deref().and_then(|c| c.glyph_range.get(c.emitted)).cloned().unwrap_or_default(); if let Some(c) = cursor.as_deref_mut() { c.before_glyph(lc.kern_px, &mut state.extra); } @@ -504,6 +518,7 @@ impl Para<'_> { style: gr.style().brush.0, orient: GlyphOrient::Horizontal, }); + out.glyph_ranges.push(self.map_offset(source_range.start)..self.map_offset(source_range.end)); if let Some(v) = &vm { let upright = class == 1 || (class == 2 && !plain.contains(&g.id)); state.vinfo.push(VGlyph { diff --git a/crates/text/src/psd/build.rs b/crates/text/src/psd/build.rs index 90568bc06b..755411cd38 100644 --- a/crates/text/src/psd/build.rs +++ b/crates/text/src/psd/build.rs @@ -206,7 +206,7 @@ fn char_spans(layer: &TextLayer) -> Vec<(usize, i64)> { /// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit /// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is /// larger than a template may be (it is copied once per run). -fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Option> { +fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32, seed_empty: bool) -> Option> { if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { return None; } @@ -218,7 +218,7 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> let mut looked_up = std::collections::HashMap::new(); for (ri, n) in spans { let s = runs.get(ri).map_or(&default, |r| &r.style); - let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name)); + let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name, seed_empty)); let mut run = base.clone(); let style = run.get_mut("0")?.get_mut("0")?.get_mut("6")?; if let Some(font) = font { @@ -347,8 +347,9 @@ fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { return false; } // A mask beyond the 20 sets is ignored on import (`add_txt2_features`), so here too. + // Missing or invalid key 85 counts as 0 (the reader's default), not "any mask agrees". let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); - style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) + style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).unwrap_or(0) == sets } /// EngineData's `FontCaps` code of a style: 0 normal, 1 small caps, 2 all caps. @@ -522,14 +523,34 @@ fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec, dpi: f32) -> Op Some(entry) } +/// A `Txt2` font-list entry (`<< /0 << /99 /CoolTypeFont /0 << /0 name /2 type >> >> >>`). +fn txt2_font_entry(name: &str, kind: i64) -> E { + E::Dict(vec![( + "0".into(), + E::Dict(vec![ + ("99".into(), E::Name("CoolTypeFont".into())), + ("0".into(), E::Dict(vec![("0".into(), E::String(name.to_string())), ("2".into(), E::Int(kind))])), + ]), + )]) +} + /// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont /// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry) -/// when missing. `None` for an empty list: there is no entry to shape a new one on. -fn txt2_font(fonts: &mut Vec, name: &str) -> Option { +/// when missing. An empty list gets a seeded CoolTypeFont entry only when `seed_empty` is set — +/// a PhotoCraft-native block (#10308); a file's own block keeps its `/0` as it was (#2374), so +/// `None` there, as before. +fn txt2_font(fonts: &mut Vec, name: &str, seed_empty: bool) -> Option { let named = |f: &E| f.path(&["0", "0", "0"]).and_then(E::as_str) == Some(name); if let Some(i) = fonts.iter().position(named) { return i64::try_from(i).ok(); } + if fonts.is_empty() { + if !seed_empty { + return None; + } + fonts.push(txt2_font_entry(name, 0)); + return Some(0); + } let mut entry = fonts.first()?.clone(); let font = entry.get_mut("0")?.get_mut("0")?; let kind = font.get("2").cloned(); @@ -546,13 +567,69 @@ fn txt2_font(fonts: &mut Vec, name: &str) -> Option { /// `i32::MAX`, and sizing the array by it would make every save allocate gigabytes. pub const MAX_TEXT_INDEX: i32 = 0xFFFF; -/// Whether the document `Txt2` block is needed for `objects`: only a non-Metrics auto-kern mode +/// Whether `style` carries an OpenType setting that lives only in `Txt2` (EngineData has no field +/// for stylistic sets or the [`TXT2_FEATURES`] flags). Defaults that match a plain style do not +/// count: Contextual Alternates on (or absent), and the other flags off. +fn txt2_only_opentype(style: &CharStyle) -> bool { + let on = |tag: &str| style.features.iter().any(|f| f.tag == tag && f.value > 0); + let off = |tag: &str| style.features.iter().any(|f| f.tag == tag && f.value == 0); + if (1..=20).any(|n| on(&format!("ss{n:02}"))) { + return true; + } + for (_, tag) in TXT2_FEATURES { + if tag == "calt" { + if off("calt") { + return true; + } + } else if on(tag) { + return true; + } + } + false +} + +/// Whether the document `Txt2` block is needed for `objects`: a non-Metrics auto-kern mode /// (`Optical`) is exclusive to `Txt2` — EngineData's `AutoKerning` cannot distinguish Metrics from -/// Optical — so a fresh document of ordinary Metrics text has nothing to put in the block. -/// Writing Photoshop's empty `<< /0 << >> >>` stub anyway makes some readers (Photopea) fail -/// (#3514); the export omits the block for such a document. +/// Optical — and so are Stylistic Sets and the other [`TXT2_FEATURES`] flags (#10308). A fresh +/// document of ordinary Metrics text with no such OpenType settings has nothing to put in the +/// block. Writing Photoshop's empty `<< /0 << >> >>` stub anyway makes some readers (Photopea) +/// fail (#3514); the export omits the block for such a document. pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool { - objects.iter().any(|(_, l)| l.char_runs().iter().any(|r| r.style.kerning == Kerning::Optical)) + objects.iter().any(|(_, l)| l.char_runs().iter().any(|r| r.style.kerning == Kerning::Optical || txt2_only_opentype(&r.style))) +} + +/// A `/53` SimplePaint dictionary (`<< /99 /SimplePaint /0 << >> >>`) for a fresh style run. +fn simple_paint_dict() -> E { + E::Dict(vec![("99".into(), E::Name("SimplePaint".into())), ("0".into(), E::dict())]) +} + +/// A minimal `Txt2` style run Photoshop will read: the shape [`txt2_style_runs`] fills from a +/// layer when the file has no template object to copy (a PhotoCraft-native document). Includes an +/// empty `/53` SimplePaint dict so the fill colour can be written. +fn minimal_txt2_style_run() -> E { + let style = E::Dict(vec![("0".into(), E::Int(0)), ("11".into(), E::Int(1)), ("53".into(), simple_paint_dict())]); + let data = E::Dict(vec![("0".into(), E::String(String::new())), ("6".into(), style)]); + E::Dict(vec![("0".into(), E::Dict(vec![("0".into(), data)])), ("1".into(), E::Int(1))]) +} + +/// A point-text object with a view naming frame `frame` and empty style runs, for a layer the +/// file's block has no object for and no template to copy (#10308, #7698). Style runs are filled +/// by [`txt2_style_runs`] next. +fn fresh_point_text_object(frame: usize, text: &str) -> E { + let frame = i64::try_from(frame).unwrap_or(0); + let view = E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(frame))])]))]); + // One paragraph run per `\r`-terminated paragraph (lengths in UTF-16 code units), as + // `edited_object` refits them. + let paragraphs = text + .split_inclusive('\r') + .map(|p| E::Dict(vec![("0".into(), E::dict()), ("1".into(), E::Int(i64::try_from(p.encode_utf16().count()).unwrap_or(i64::MAX)))])) + .collect(); + let model = E::Dict(vec![ + ("0".into(), E::String(text.to_string())), + ("5".into(), E::Dict(vec![("0".into(), E::Array(paragraphs))])), + ("6".into(), E::Dict(vec![("0".into(), E::Array(vec![]))])), + ]); + E::Dict(vec![("0".into(), model), ("1".into(), view)]) } /// The document's `Txt2` block for `objects`: `(TextIndex, layer)` pairs, one text object each @@ -598,6 +675,11 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let td_fonts_before = td_fonts.len(); let mut fonts: Vec = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default(); let fonts_before = fonts.len(); + // A PhotoCraft-native block: the document had no `Txt2` of its own (#10308). Only such a + // block gets the fresh point-text objects, minimal style runs, seeded font list and frame + // list below; a file's own block (Photoshop's, any era) is written as before (#2374, #7698). + let native = prev.is_none(); + let seed_empty = native; // Looked up once per save, not per layer: a crafted block with many objects must not cost // a pass over all of them for every type layer. let all_stubs = prev_objects.iter().all(is_stub); @@ -671,11 +753,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // can't be refitted keeps the frame that object was drawn in. let fresh = (kept.is_none() && edited.is_none()) .then(|| { - let template = template?; + // Without a template, only a PhotoCraft-native block gets a fresh object; a file's + // block keeps the stub it always got. + if template.is_none() && !native { + return None; + } let own = slot.and_then(|o| object_frame(o, true)).filter(|f| frame_count.is_some_and(|n| *f < n)); let frame = match own { Some(f) => f, - None if frame_count.is_some() && taken.contains(&at) => { + None if (frame_count.is_some() || template.is_none()) && taken.contains(&at) => { while taken.contains(&next_free) { next_free += 1; } @@ -683,7 +769,12 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi } None => at, }; - let object = new_object(template, frame, &text)?; + // Prefer a file object as template (#7698); otherwise a point-text object with a + // view naming its own frame so a PhotoCraft-native Txt2 still opens (#10308). + let object = match template { + Some(template) => new_object(template, frame, &text)?, + None => fresh_point_text_object(frame, &text), + }; taken.insert(frame); frames_needed = frames_needed.max(frame.saturating_add(1)); Some(object) @@ -695,11 +786,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); // A new or edited object takes the layer's own style: Photoshop reads the character style // from these runs, so the template's (or the old text's) would show, and come back on - // reopen. They are built on one of the object's own runs, every key Photoshop wrote kept. - let styled = (!unchanged && !is_stub(&object)) - .then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned()) - .flatten() - .and_then(|base| txt2_style_runs(layer, &base, &mut fonts, dpi)); + // reopen. They are built on one of the object's own runs, every key Photoshop wrote kept; + // without a template run, a minimal base is filled the same way (#10308). + let styled = (!unchanged) + .then(|| { + let file_base = (!is_stub(&object)).then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned()).flatten(); + let base = file_base.or_else(|| native.then(minimal_txt2_style_run))?; + txt2_style_runs(layer, &base, &mut fonts, dpi, seed_empty) + }) + .flatten(); let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes)); let runs = styled.or(file_runs).unwrap_or_else(|| { modes @@ -729,8 +824,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi } } // The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and - // `/1` whatever it holds beside the text objects. - let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); + // `/1` whatever it holds beside the text objects. A PhotoCraft-native block gets a frame list + // (`/0 /8 /0`) so each object's view can name a frame (#10308, #7698); a file's block is never + // given a `/0` or a frame list it didn't have (#2374). + let mut outer = prev.clone().unwrap_or_else(|| { + E::Dict(vec![ + ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), + ("0".into(), E::Dict(vec![("8".into(), E::Dict(vec![("0".into(), E::Array(vec![]))]))])), + ]) + }); pad_frames(&mut outer, slots.len().max(frames_needed)); // A path layer's frame holds its path; a layer taken off its path loses its path frame. An // unchanged path leaves the file's frame as it was, and so does a frame the file already had @@ -749,10 +851,18 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi } } } + // Write the font list whenever it grew. Create `/0/1` only in a PhotoCraft-native block; + // otherwise leave the file's `/0` alone (#2374). if fonts.len() != fonts_before - && let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1")) + && let Some(doc @ E::Dict(_)) = outer.get_mut("0") { - list.set("0", E::Array(fonts)); + match doc.get_mut("1") { + Some(list @ E::Dict(_)) => list.set("0", E::Array(fonts)), + _ if seed_empty => { + doc.set("1", E::Dict(vec![("0".into(), E::Array(fonts))])); + } + _ => {} + } } match outer.get_mut("1") { Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)), diff --git a/crates/text/src/psd/import.rs b/crates/text/src/psd/import.rs index aea712d62c..a3852f4ede 100644 --- a/crates/text/src/psd/import.rs +++ b/crates/text/src/psd/import.rs @@ -503,15 +503,18 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .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). + // Each paragraph run's end, once: a scan from the first paragraph for every character run was + // quadratic in the file's run counts. A negative or NaN length ends nothing (it never did). + let para_ends: Vec = plens16 + .iter() + .scan(0.0, |end, n| { + *end += n.max(0.0); + Some(*end) + }) + .collect(); let para_of = |at: f64| -> Option { - let mut end = 0.0; - for (i, n) in plens16.iter().enumerate() { - end += n; - if at < end { - return Some(i); - } - } - plens16.len().checked_sub(1) + let i = para_ends.partition_point(|&end| end <= at); + if i < para_ends.len() { Some(i) } else { plens16.len().checked_sub(1) } }; let mut at16 = 0.0; let bases: Vec<[Option<&E>; 3]> = ssheets diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 4f36c68a83..ffc595aab8 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -8,7 +8,7 @@ use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::DrawSettings; use skrifa::{GlyphId, MetadataProvider}; -use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; +use crate::layout::{DecorationRect, GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; use crate::warp::{Placement, Warp}; @@ -135,45 +135,64 @@ fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) -> true } +/// The glyphs and decorations one colour pass draws, as indices into the layout's lists. +#[derive(Default)] +pub(crate) struct Pass { + pub(crate) glyphs: Vec, + pub(crate) decorations: Vec, +} + /// 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| { +/// whether any placed glyph of that colour uses faux/synthetic bold and the items it draws. Built +/// in one pass with a hash of the colour: a linear search per glyph, and then a walk of every +/// glyph per colour, made text with many coloured runs quadratic. +pub(crate) fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) -> Vec<(Color, bool, Pass)> { + type Key = (std::mem::Discriminant, [u32; 4], u32); + fn slot<'a>(colors: &'a mut Vec<(Color, bool, Pass)>, index: &mut std::collections::HashMap, c: Color, bold: bool) -> Option<&'a mut Pass> { if !(c.alpha.is_finite() && c.alpha > 0.0) { - return; + return None; } - match colors.iter_mut().find(|(existing, _)| *existing == c) { - Some((_, b)) => *b |= bold, - None => colors.push((c, bold)), - } - }; - for g in &layout.glyphs { + // `+ 0.0` folds -0.0 into 0.0, which compare equal. + let key = (std::mem::discriminant(&c.mode), c.c.map(|v| (v + 0.0).to_bits()), c.alpha.to_bits()); + let i = *index.entry(key).or_insert_with(|| { + colors.push((c, false, Pass::default())); + colors.len() - 1 + }); + colors[i].1 |= bold; + Some(&mut colors[i].2) + } + let mut colors: Vec<(Color, bool, Pass)> = Vec::new(); + let mut index = std::collections::HashMap::new(); + for (i, g) in layout.glyphs.iter().enumerate() { 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)); + if glyph_is_placed(layout, g, warp) + && let Some(pass) = slot(&mut colors, &mut index, st.color, is_bold(layout, g)) + { + pass.glyphs.push(i); } } - for d in &layout.decorations { + for (i, d) in layout.decorations.iter().enumerate() { let Some(st) = layout.styles.get(d.style as usize) else { continue }; - record(st.color, false); + if let Some(pass) = slot(&mut colors, &mut index, st.color, false) { + pass.decorations.push(i); + } } colors } -/// Draws every glyph and decoration of `layout` whose style colour is `color` (or all of them -/// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending +/// Draws the glyphs and decorations of `layout` in `only` (or all of them when it is `None`) into `sink`, through `transform` (text space → sink space), bending /// the outlines with `warp` first (in text space). Faux bold glyphs are drawn from their outline /// thickened by [`FAUX_BOLD_RADIUS`] ([`crate::embolden`]). -fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) { - for g in &layout.glyphs { +fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only: Option<&Pass>, warp: Option<&Warp>) { + let glyphs: Box> = match only { + Some(p) => Box::new(p.glyphs.iter().filter_map(|&i| layout.glyphs.get(i))), + None => Box::new(layout.glyphs.iter()), + }; + for g in glyphs { let st = &layout.styles[g.style as usize]; - if only_color.is_some_and(|c| c != &st.color) { - continue; - } let face = &layout.faces[g.face as usize]; let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { continue; @@ -217,11 +236,11 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c None => draw_glyph(&outline, settings, bold.as_ref(), &mut Pen::new(&mut *sink, transform.mul(&glyph))), } } - for d in &layout.decorations { - let st = &layout.styles[d.style as usize]; - if only_color.is_some_and(|c| c != &st.color) { - continue; - } + let decorations: Box> = match only { + Some(p) => Box::new(p.decorations.iter().filter_map(|&i| layout.decorations.get(i))), + None => Box::new(layout.decorations.iter()), + }; + for d in decorations { match warp { Some(w) => { let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; @@ -373,7 +392,7 @@ pub(crate) fn rasterize_passes( } let n = format.mode.color_channels(); let stride = n + 1; - let any_bold = colors.iter().any(|&(_, b)| b); + 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); } @@ -384,13 +403,13 @@ pub(crate) fn rasterize_passes( let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m)); let whole = Rect::new(0, 0, w as i32, h as i32); let mut pass_pixels = 0u64; - for &(c, _) in &colors { + for (c, _, pass) in &colors { // Rasterise each colour only over its own glyphs (faux bold's thickened outlines 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(layout, &xf, &mut b, Some(&c), warp); + draw(layout, &xf, &mut b, Some(pass), warp); b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY).intersect(&whole) } else { whole @@ -411,11 +430,11 @@ pub(crate) fn rasterize_passes( let Some(mut cov) = Coverage::try_new(sw, sh) else { return nothing(format); }; - draw(layout, &xf, &mut cov, Some(&c), warp); + draw(layout, &xf, &mut cov, Some(pass), warp); let Some(cov) = cov.finish() else { return nothing(format); }; - let comps = color_in(&format, &c); + let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias); } @@ -529,14 +548,18 @@ impl skrifa::outline::OutlinePen for Recorder<'_> { } } -/// Glyph outlines of `layout` in document space (through `transform`, bent by `warp`), one -/// element list per glyph, for Type › Create Work Path and Convert to Shape. Faux bold is not -/// applied (Photoshop also converts the regular outline); faux italic and scaling are. Under a -/// warp, control points are mapped directly, which is exact for straight segments and a close -/// approximation for curves. -pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec> { +/// Positioned glyph outline with its style and character run information. +#[derive(Clone, Debug, PartialEq)] +pub struct GlyphOutline { + pub glyph_index: usize, + pub style: u32, + pub elements: Vec, +} + +/// Glyph outlines of `layout` preserving the style index of each glyph. +pub fn glyph_outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec { let mut glyphs = Vec::new(); - for g in &layout.glyphs { + for (i, g) in layout.glyphs.iter().enumerate() { let face = &layout.faces[g.face as usize]; let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { continue; @@ -560,12 +583,21 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> let mut rec = Recorder { glyph, post: Xform(transform.m), warp: bend, out: Vec::new() }; let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() { - glyphs.push(rec.out); + glyphs.push(GlyphOutline { glyph_index: i, style: g.style, elements: rec.out }); } } glyphs } +/// Glyph outlines of `layout` in document space (through `transform`, bent by `warp`), one +/// element list per glyph, for Type › Create Work Path and Convert to Shape. Faux bold is not +/// applied (the regular outline is converted); faux italic and scaling are. Under a +/// warp, control points are mapped directly, which is exact for straight segments and a close +/// approximation for curves. +pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec> { + glyph_outlines(layout, transform, warp).into_iter().map(|g| g.elements).collect() +} + #[cfg(test)] mod tests { use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index b88ec9ec72..a19d9d5208 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,22 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +#[test] +fn glyph_source_ranges_follow_shaped_clusters() { + let mut engine = TextEngine::new(); + for text in ["office", "Aé B", "First\nSecond"] { + let layer = point(text, 24.0); + let layout = engine.layout(&layer, 72.0); + assert!(!layout.glyphs.is_empty()); + assert_eq!(layout.glyphs.len(), layout.glyph_ranges.len()); + for range in &layout.glyph_ranges { + assert!(range.start < range.end, "empty range in {text:?}"); + assert!(text.get(range.clone()).is_some(), "invalid range {range:?} in {text:?}"); + assert!(layout.clusters.iter().any(|cluster| cluster.range == *range), "missing cluster for {range:?}"); + } + } +} + /// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and /// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them /// over threads therefore has to leave every byte as it was, which a one-thread pool checks. @@ -1062,12 +1078,12 @@ fn build_txt2_round_trips_kerning_modes() { assert_eq!(modes, vec![(1, Kerning::Off), (1, Kerning::Metrics)]); } -/// #3514: the document `Txt2` is only needed for a non-Metrics (`Optical`) auto-kern mode. A fresh -/// document of ordinary Metrics (or EngineData-carried Off) text must not write Photoshop's empty -/// stub, which some readers (Photopea) reject. +/// #3514 / #10308: the document `Txt2` is needed for Optical kerning and for OpenType settings +/// EngineData can't carry (stylistic sets, swashes, …). A fresh document of ordinary Metrics text +/// must not write Photoshop's empty stub, which some readers (Photopea) reject. #[test] -fn needs_txt2_only_for_optical() { - use photocraft_doc::text::Kerning; +fn needs_txt2_for_optical_and_txt2_only_opentype() { + use photocraft_doc::text::{FontFeature, Kerning}; let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; let metrics = runs_of("AV", &[(2, style(Kerning::Metrics))]); assert!(!crate::psd::needs_txt2(&[(0, &metrics)])); @@ -1076,6 +1092,57 @@ fn needs_txt2_only_for_optical() { let optical = runs_of("AV", &[(1, style(Kerning::Optical)), (1, CharStyle::default())]); assert!(crate::psd::needs_txt2(&[(0, &optical)])); assert!(!crate::psd::needs_txt2(&[])); + let with_ss = |tag: &str, value: u16| { + let mut s = style(Kerning::Metrics); + s.features = vec![FontFeature { tag: tag.into(), value }]; + runs_of("AV", &[(2, s)]) + }; + assert!(crate::psd::needs_txt2(&[(0, &with_ss("ss01", 1))]), "stylistic set 1 lives only in Txt2"); + assert!(crate::psd::needs_txt2(&[(0, &with_ss("ss05", 1))]), "stylistic set 5 lives only in Txt2"); + assert!(!crate::psd::needs_txt2(&[(0, &with_ss("ss01", 0))]), "an off set is the default"); + assert!(crate::psd::needs_txt2(&[(0, &with_ss("swsh", 1))]), "swash lives only in Txt2"); + assert!(crate::psd::needs_txt2(&[(0, &with_ss("calt", 0))]), "calt off is Txt2-only"); + assert!(!crate::psd::needs_txt2(&[(0, &with_ss("calt", 1))]), "calt on is the default"); +} + +/// #10308: the fresh objects, minimal style runs, seeded font list and frame list are only for a +/// PhotoCraft-native block. A file's own block — here the older Photoshop's, with an empty +/// numbered `/0` or none at all — gets no `/0` key, frame list or font list it didn't have, and +/// a layer without a template object gets the stub it always got. +#[test] +fn photoshop_blocks_get_no_native_txt2_scaffolding() { + use crate::engine_data::Value as E; + use photocraft_doc::text::FontFeature; + let style = CharStyle { font_family: "Inter".into(), features: vec![FontFeature { tag: "ss01".into(), value: 1 }], ..Default::default() }; + let t = styled("Hi", style); + let with_doc = photoshop_era_txt2(&["Line 1"]); + let without_doc = crate::engine_data::write_bare(&[ + ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), + ("DocumentObjects".into(), photoshop_era_objects(&["Line 1"])), + ]); + for (label, prev, doc) in [("empty /0", with_doc, Some(E::dict())), ("no /0", without_doc, None)] { + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + assert_eq!(out.get("0").cloned(), doc, "{label}: the file's /0 is left as it was"); + let object = out.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.first()).expect("the numbered object"); + assert!(object.get("1").is_none(), "{label}: no fresh view naming a frame the file has no list for"); + assert!(object.path(&["0", "5"]).is_none(), "{label}: no fresh paragraph runs"); + } +} + +/// #10308: a fresh native object has one paragraph run per paragraph, each its UTF-16 length. +#[test] +fn fresh_native_txt2_object_has_a_paragraph_run_per_paragraph() { + use crate::engine_data::Value as E; + use photocraft_doc::text::FontFeature; + let style = CharStyle { features: vec![FontFeature { tag: "ss02".into(), value: 1 }], ..Default::default() }; + let t = styled("Hi\nBy\u{e9}e", style); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], None)).unwrap(); + let object = out.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.first()).expect("the object"); + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("Hi\rBy\u{e9}e\r")); + let lens: Vec = + object.path(&["0", "5", "0"]).and_then(E::as_array).expect("paragraph runs").iter().filter_map(|r| r.get("1").and_then(E::as_i64)).collect(); + assert_eq!(lens, vec![3, 5]); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).map(<[E]>::len), Some(1), "a native block names one frame"); } /// A kept `Txt2` object — same text, so its extras are still true — keeps everything the file @@ -3562,3 +3629,44 @@ fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() { let runs = era_runs(&crate::psd::parse_txt2(&out).unwrap()); assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled"); } + +#[test] +fn glyph_outlines_preserves_style_indices() { + let mut e = TextEngine::new(); + let t = TextLayer { + text: "AB".into(), + transform: Affine::translate(10.0, 30.0), + runs: vec![ + photocraft_doc::text::TextRun { len: 1, style: CharStyle { color: Color::rgb(1.0, 0.0, 0.0), ..Default::default() } }, + photocraft_doc::text::TextRun { len: 1, style: CharStyle { color: Color::rgb(0.0, 1.0, 0.0), ..Default::default() } }, + ], + ..Default::default() + }; + let layout = e.layout(&t, 72.0); + let glyphs = crate::render::glyph_outlines(&layout, &t.transform, None); + assert!(glyphs.len() >= 2); + assert_eq!(glyphs[0].style, 0); + assert_eq!(glyphs[1].style, 1); +} + +/// Colour passes are grouped in one walk of the glyphs: with a colour per letter, every placed +/// glyph lands in exactly one pass and each pass holds only its own glyphs. +#[test] +fn colour_passes_hold_each_glyph_once() { + let mut e = TextEngine::new(); + let text = "abcdeghjkm".repeat(200); + let runs = text + .char_indices() + .map(|(i, ch)| TextRun { len: ch.len_utf8(), style: CharStyle { size_pt: 12.0, color: Color::rgb(i as f32 / 2000.0, 0.5, 0.5), ..Default::default() } }) + .collect(); + let t = TextLayer { text, runs, ..Default::default() }; + let l = e.layout(&t, 72.0); + let passes = crate::render::visible_color_passes(&l, None); + assert!(passes.len() > 1000, "{} passes", passes.len()); + let mut seen: Vec = passes.iter().flat_map(|(_, _, p)| p.glyphs.iter().copied()).collect(); + seen.sort_unstable(); + assert_eq!(seen, (0..l.glyphs.len()).collect::>()); + for (c, _, p) in &passes { + assert!(p.glyphs.iter().all(|&i| l.styles[l.glyphs[i].style as usize].color == *c)); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9fccf05de1..e0111e9ca5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true workspace = true [dependencies] +chrono = { workspace = true } craft-print = { workspace = true } craft-ui = { workspace = true } photocraft-geom = { workspace = true } @@ -29,6 +30,8 @@ photocraft-paint = { workspace = true } photocraft-format = { workspace = true } # Decoding the brand mark's PNG (`brand.rs`); already linked through photocraft-io. photocraft-codecs = { workspace = true } +# Reading embedded thumbnail resources (1036/1033) for recent files in Home screen. +photocraft-psd = { workspace = true } egui = { workspace = true } egui_extras = { version = "0.37", default-features = false, features = ["svg"] } eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] } @@ -37,6 +40,9 @@ serde_json = { workspace = true } log = { workspace = true } # Display order of right-to-left labels (`bidi_text.rs`); already linked through usvg. unicode-bidi = "0.3" +# Context-sensitive dotted/dotless I mapping for Azerbaijani and Turkish UI text. +icu_casemap = "2.3" +icu_locale_core = "2.3" base64 = "0.23" # Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io. flate2 = { workspace = true } @@ -79,3 +85,7 @@ flate2 = { workspace = true } epaint_default_fonts = { version = "0.37", features = ["monochrome_emoji_fonts"] } serde_json = { workspace = true } toml = "1" + +[[example]] +name = "flat_open_bench" +test = true diff --git a/crates/ui-egui/examples/cursor_bench.rs b/crates/ui-egui/examples/cursor_bench.rs new file mode 100644 index 0000000000..2c3441dfaa --- /dev/null +++ b/crates/ui-egui/examples/cursor_bench.rs @@ -0,0 +1,560 @@ +//! Release benchmark harness measuring cold rasterization, warm movement lookup, and painted fallback +//! baseline for native Pen tool cursors (#9122). +//! +//! Evaluates all six standard Pen tool cursor variants across multiple display scales, measuring: +//! 1. Cold rasterization latency and bitmap dimensions/hotspots. +//! 2. Warm-cache lookup latency during steady pointer movements across frames. +//! 3. Painted fallback baseline latency using identical pointer moves and harness. +//! 4. Structural gate: zero additional rasterizations during steady warm movement with unchanged appearance. +//! 5. Cost gate: warm-path p95 below 0.1 ms (100 µs). +//! 6. Optional artifact export of cursor images as PNG files for visual regression inspection. +//! +//! ```sh +//! cargo run --release -p photocraft-ui-egui --example cursor_bench -- [--moves 1000] [--reps 5] [--save-artifacts ] [--json [path]] +//! ``` + +#[path = "cursor_bench/args.rs"] +mod args; +#[path = "cursor_bench/stats.rs"] +mod stats; +use args::checked_sample_count; +use stats::percentile; + +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use base64::Engine as _; +use egui::{Context, pos2}; +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, encode}; +use photocraft_testkit::gpu_lock; +use photocraft_ui_egui::canvas::tool_cursor::{PenVariant, native_scale, pen, pen_rasterization_count, rasterize_pen, reset_pen_rasterization_count}; +use photocraft_ui_egui::icons; +use photocraft_ui_egui::state::Tool; + +#[derive(Clone, Debug, serde::Serialize)] +struct BenchmarkRun { + variant: String, + tool: String, + scale: f32, + measured_native_scale: f32, + dimensions: [u16; 2], + hotspot: [u16; 2], + retained_bytes: usize, + cold_raster_us_samples: Vec, + cold_raster_p50_us: f64, + cold_raster_p95_us: f64, + warm_move_us_samples: Vec, + warm_move_p50_us: f64, + warm_move_p95_us: f64, + baseline_painted_us_samples: Vec, + baseline_painted_p50_us: f64, + baseline_painted_p95_us: f64, + preparation_speedup_vs_baseline_p50: f64, + warm_moves_total: usize, + cache_hits: usize, + warm_rasterizations: usize, + structural_gate_pass: bool, + cost_gate_pass: bool, +} + +#[derive(Clone, Debug, serde::Serialize)] +struct PerfReportRow { + name: String, + samples_ms: Vec, + p50_ms: f64, + p95_ms: f64, +} + +#[derive(Clone, Debug, serde::Serialize)] +struct CursorVisualComparison { + variant: String, + scale: f32, + background: String, + dimensions: [u32; 2], + hotspot: [u32; 2], + mae_rgb: f64, + max_abs_diff_rgb: u8, + rmse_rgb: f64, + exact_match_pixels: usize, + total_pixels: usize, + match_percentage: f64, + pixels_diff_gt_2: usize, + pixels_diff_gt_5: usize, + pixels_diff_gt_15: usize, +} + +#[derive(Clone, Debug, serde::Serialize)] +struct PerfReport { + context: serde_json::Value, + rows: Vec, + results: Vec, + visual_comparisons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + contact_sheet_png_base64: Option, +} + +/// Map each benchmark variant to the tool used by the production dispatcher. +fn tool_for_variant(variant: PenVariant) -> Tool { + match variant { + PenVariant::Standard => Tool::Pen, + PenVariant::Freeform => Tool::FreeformPen, + PenVariant::Curvature => Tool::CurvaturePen, + PenVariant::AddAnchor => Tool::AddAnchorPoint, + PenVariant::DeleteAnchor => Tool::DeleteAnchorPoint, + PenVariant::ConvertPoint => Tool::ConvertPoint, + } +} + +/// Read a named benchmark argument without indexing beyond the argument list. +fn arg_value(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned()) +} + +/// Export the native cursor bytes as an inspectable PNG. +fn save_cursor_artifact(output_dir: &Path, variant_name: &str, scale: f32, image: &egui::CustomCursorImage) -> Result<(), Box> { + std::fs::create_dir_all(output_dir)?; + let [w, h] = image.size; + let raw_bytes = image.rgba.to_vec(); + let img = Image::from_raw(u32::from(w), u32::from(h), ChannelLayout::Rgba, SampleType::U8, raw_bytes)?; + let encoded = encode(&img, Format::Png, &EncodeOptions::default())?; + let filename = format!("cursor_pen_{variant_name}_{scale:.2}x.png"); + let file_path = output_dir.join(filename); + std::fs::write(&file_path, encoded)?; + Ok(()) +} + +/// Execute one frame and time only cursor preparation, excluding GPU/presentation latency. +/// Drain frame output so painted geometry cannot accumulate between samples. +fn cursor_frame(ctx: &Context, tool: Tool, variant: PenVariant, at: egui::Pos2, painted: bool) -> (f64, Option) { + let mut elapsed_us = 0.0; + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(512.0, 512.0))), + events: vec![egui::Event::PointerMoved(at)], + ..Default::default() + }; + let output = ctx.run_ui(input, |ui| { + let ctx = ui.ctx(); + ctx.set_cursor_image(None); + let start = Instant::now(); + if painted { + icons::cursor(ctx, variant.icon_name(), at, variant.hotspot_in_box() / 24.0, 22.0); + } else if let Some(icon) = pen(ctx, tool) { + ctx.set_cursor_icon(icon); + } + elapsed_us = start.elapsed().as_secs_f64() * 1_000_000.0; + }); + (elapsed_us, output.platform_output.cursor_image) +} + +/// Render each painted/native pair at the same hotspot and export comparison evidence. +fn run_visual_oracle(save_dir: Option<&Path>) -> Result<(Vec, String), Box> { + let _gpu = gpu_lock(); + let backgrounds = [("light_240", 240u8), ("midgrey_128", 128), ("dark_15", 15)]; + let mut comparisons = Vec::new(); + let mut sheets = Vec::new(); + for scale in [1.0f32, 1.25, 1.5, 2.0] { + // The production fallback uses one fixed Area id. Render only one pair per + // frame, reusing the harness across variants/backgrounds to avoid id collisions. + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(128.0, 64.0)) + .with_pixels_per_point(scale) + .with_context_setup(egui_extras::install_image_loaders) + .wgpu() + .build_ui_state( + |ui, state: &mut (PenVariant, u8, Option)| { + let variant = state.0; + let background = state.1; + ui.painter().rect_filled(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(128.0, 64.0)), 0.0, egui::Color32::from_gray(background)); + ui.painter().text( + pos2(2.0, 2.0), + egui::Align2::LEFT_TOP, + format!("{} {scale}x: old / new", variant.icon_name()), + egui::FontId::monospace(6.0), + egui::Color32::from_gray(if background < 100 { 210 } else { 30 }), + ); + icons::cursor(ui.ctx(), variant.icon_name(), pos2(32.0, 32.0), variant.hotspot_in_box() / 24.0, 22.0); + if let Some(bitmap) = rasterize_pen(variant, scale) { + let [w, h] = bitmap.size; + let [hx, hy] = bitmap.hotspot; + let image = egui::ColorImage::from_rgba_unmultiplied([usize::from(w), usize::from(h)], &bitmap.rgba); + let texture = state.2.get_or_insert_with(|| ui.ctx().load_texture("native-pen-reference", image, egui::TextureOptions::NEAREST)); + let rect = egui::Rect::from_min_size( + pos2(96.0, 32.0) - egui::vec2(f32::from(hx), f32::from(hy)) / scale, + egui::vec2(f32::from(w), f32::from(h)) / scale, + ); + ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), egui::Color32::WHITE); + } + }, + (PenVariant::Standard, 240u8, None), + ); + let cell_w = (128.0 * scale).round() as usize; + let cell_h = (64.0 * scale).round() as usize; + let sheet_w = cell_w * 6; + let sheet_h = cell_h * 3; + let mut sheet = vec![255u8; sheet_w * sheet_h * 4]; + for (bg_index, (background_name, background)) in backgrounds.iter().enumerate() { + for (variant_index, variant) in PenVariant::all().into_iter().enumerate() { + *harness.state_mut() = (variant, *background, None); + harness.run_steps(3); + let rendered = harness.render().map_err(|e| format!("cursor reference render: {e}"))?; + if rendered.width() as usize != cell_w || rendered.height() as usize != cell_h { + return Err("cursor reference dimensions differ from requested display scale".into()); + } + let raw = rendered.as_raw(); + for row in 0..cell_h { + let src = row * cell_w * 4; + let dst = ((bg_index * cell_h + row) * sheet_w + variant_index * cell_w) * 4; + sheet[dst..dst + cell_w * 4].copy_from_slice(&raw[src..src + cell_w * 4]); + } + let bitmap = rasterize_pen(variant, scale).ok_or("missing comparison bitmap")?; + let [w, h] = bitmap.size; + let [hx, hy] = bitmap.hotspot; + let old_x = (32.0 * scale).round() as usize - usize::from(hx); + let new_x = (96.0 * scale).round() as usize - usize::from(hx); + let top = (32.0 * scale).round() as usize - usize::from(hy); + let mut sum = 0.0f64; + let mut squared = 0.0f64; + let mut maximum = 0; + let mut exact = 0; + let mut gt2 = 0; + let mut gt5 = 0; + let mut gt15 = 0; + let mut old_ink = 0; + let mut new_ink = 0; + for y in 0..usize::from(h) { + for x in 0..usize::from(w) { + let a = ((top + y) * cell_w + old_x + x) * 4; + let b = ((top + y) * cell_w + new_x + x) * 4; + let old = raw.get(a..a + 3).ok_or("painted crop outside render")?; + let new = raw.get(b..b + 3).ok_or("native crop outside render")?; + old_ink += usize::from(old.iter().any(|c| c.abs_diff(*background) > 10)); + new_ink += usize::from(new.iter().any(|c| c.abs_diff(*background) > 10)); + let mut pixel_max = 0; + for (a, b) in old.iter().zip(new) { + let d = a.abs_diff(*b); + pixel_max = pixel_max.max(d); + sum += f64::from(d) / 3.0; + squared += f64::from(d).powi(2) / 3.0; + } + maximum = maximum.max(pixel_max); + exact += usize::from(pixel_max == 0); + gt2 += usize::from(pixel_max > 2); + gt5 += usize::from(pixel_max > 5); + gt15 += usize::from(pixel_max > 15); + } + } + if old_ink < 10 || new_ink < 10 { + return Err(format!("cursor reference missing visible ink: {variant:?} {scale}x {background_name}, old={old_ink} new={new_ink}").into()); + } + let total = usize::from(w) * usize::from(h); + comparisons.push(CursorVisualComparison { + variant: variant.icon_name().to_owned(), + scale, + background: (*background_name).to_owned(), + dimensions: [u32::from(w), u32::from(h)], + hotspot: [u32::from(hx), u32::from(hy)], + mae_rgb: sum / total as f64, + max_abs_diff_rgb: maximum, + rmse_rgb: (squared / total as f64).sqrt(), + exact_match_pixels: exact, + total_pixels: total, + match_percentage: exact as f64 / total as f64 * 100.0, + pixels_diff_gt_2: gt2, + pixels_diff_gt_5: gt5, + pixels_diff_gt_15: gt15, + }); + } + } + sheets.push((sheet, sheet_w, sheet_h)); + } + let width = sheets.iter().map(|(_, w, _)| *w).max().ok_or("no cursor renders")?; + let height: usize = sheets.iter().map(|(_, _, h)| h).sum(); + let mut contact = vec![255u8; width * height * 4]; + let mut top = 0; + for (pixels, w, h) in sheets { + for row in 0..h { + let src = row * w * 4; + let dst = (top + row) * width * 4; + contact[dst..dst + w * 4].copy_from_slice(&pixels[src..src + w * 4]); + } + top += h; + } + let image = Image::from_raw(width as u32, height as u32, ChannelLayout::Rgba, SampleType::U8, contact)?; + let png = encode(&image, Format::Png, &EncodeOptions::default())?; + if let Some(dir) = save_dir { + std::fs::create_dir_all(dir)?; + std::fs::write(dir.join("pen_cursor_contact_sheet.png"), &png)?; + } + Ok((comparisons, base64::engine::general_purpose::STANDARD.encode(png))) +} + +/// Measure bounded cursor-preparation samples and export a rendered reference outside timing. +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let moves: usize = arg_value(&args, "--moves").and_then(|v| v.parse().ok()).unwrap_or(1000); + let reps: usize = arg_value(&args, "--reps").or_else(|| arg_value(&args, "--pairs")).and_then(|v| v.parse().ok()).unwrap_or(5); + let save_dir = arg_value(&args, "--save-artifacts"); + + let total_moves = checked_sample_count(moves, reps)?; + + // Check for --json flag and optional path argument + let json_pos = args.iter().position(|a| a == "--json"); + let json_file_path = json_pos.and_then(|i| args.get(i + 1).filter(|val| !val.starts_with("--")).cloned()); + let json_output = json_pos.is_some(); + + let scales = [1.0f32, 1.25, 1.5, 2.0]; + let variants = PenVariant::all(); + + // Warmup cycle to stabilize caches and CPU frequency scaling + for variant in variants { + for &scale in &scales { + let _ = rasterize_pen(variant, scale); + } + } + + let mut results: Vec = Vec::new(); + let mut perf_rows: Vec = Vec::new(); + let mut all_gates_passed = true; + + for variant in variants { + let tool = tool_for_variant(variant); + let variant_name = variant.icon_name(); + + for &scale in &scales { + // Context setup with native scale applied on all platforms + let ctx = Context::default(); + egui_extras::install_image_loaders(&ctx); + ctx.set_zoom_factor(scale); + let at = pos2(100.0, 100.0); + // Apply pending zoom during begin_pass before reading effective scale. + let _ = cursor_frame(&ctx, tool, variant, at, false); + let measured_scale = native_scale(&ctx); + if (measured_scale - scale).abs() > 1e-4 { + return Err(format!("Context scale mismatch for {scale}: measured {measured_scale}").into()); + } + + // 1. Cold rasterization measurements across repetitions + let mut cold_samples = Vec::with_capacity(reps); + let mut sample_image = None; + for _ in 0..reps { + let start_cold = Instant::now(); + let img = rasterize_pen(variant, scale).ok_or_else(|| format!("rasterize_pen failed for {variant:?} at {scale}"))?; + let cold_us = start_cold.elapsed().as_secs_f64() * 1_000_000.0; + cold_samples.push(cold_us); + sample_image = Some(img); + } + let img = sample_image.ok_or("missing rasterized image")?; + let [w, h] = img.size; + let [hx, hy] = img.hotspot; + if w == 0 || h == 0 || hx >= w || hy >= h { + return Err(format!("invalid bitmap geometry for {variant:?} at {scale}: size {w}x{h}, hotspot {hx},{hy}").into()); + } + + // Verify expected dimensions at native scale + let expected_pad = (scale + 2.0).ceil() as usize; + let icon_px = (22.0 * scale).round() as usize; + let expected_side = icon_px + expected_pad * 2; + if usize::from(w) != expected_side || usize::from(h) != expected_side { + return Err(format!("unexpected dimensions for {variant:?} at scale {scale}: got {w}x{h}, expected {expected_side}x{expected_side}").into()); + } + + // 2. Warm movement / cache lookup measurement + // Prime both paths, with actual frames and drained platform/paint output. + let (_, initial_image) = cursor_frame(&ctx, tool, variant, at, false); + let initial_image = initial_image.ok_or("missing native cursor in warmup frame")?; + if initial_image.size != img.size { + return Err("warm native bitmap does not match requested scale".into()); + } + let _ = cursor_frame(&ctx, tool, variant, at, true); + reset_pen_rasterization_count(); + let mut warm_samples = Vec::with_capacity(total_moves); + let mut baseline_samples = Vec::with_capacity(total_moves); + let mut cache_hits = 0; + // Alternate execution order by repetition; coordinates match for both paths. + for rep in 0..reps { + for step in 0..moves { + let p = pos2(100.0 + (step % 800) as f32 * 0.25, 100.0 + (step as f32 * 0.05).sin() * 40.0); + let mut native_image = None; + for painted in if rep % 2 == 0 { [false, true] } else { [true, false] } { + let (elapsed, image) = cursor_frame(&ctx, tool, variant, p, painted); + if painted { + baseline_samples.push(elapsed); + } else { + warm_samples.push(elapsed); + native_image = image; + } + } + let current = native_image.ok_or("native cursor missing from frame output")?; + if Arc::ptr_eq(¤t.rgba, &initial_image.rgba) { + cache_hits += 1; + } + } + } + let warm_rasterizations = pen_rasterization_count(); + + let mut sorted_cold = cold_samples.clone(); + sorted_cold.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let cold_p50 = percentile(&sorted_cold, 0.50); + let cold_p95 = percentile(&sorted_cold, 0.95); + + let mut sorted_warm = warm_samples.clone(); + sorted_warm.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let warm_p50 = percentile(&sorted_warm, 0.50); + let warm_p95 = percentile(&sorted_warm, 0.95); + + let mut sorted_base = baseline_samples.clone(); + sorted_base.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let base_p50 = percentile(&sorted_base, 0.50); + let base_p95 = percentile(&sorted_base, 0.95); + + let speedup = if warm_p50 > 0.0 { base_p50 / warm_p50 } else { 1.0 }; + + // Structural gate: zero additional rasterizations during warm movement, 100% cache hits + let structural_gate_pass = warm_rasterizations == 0 && cache_hits == total_moves; + + // Cost gate: warm-path p95 < 0.1 ms (100 µs) + let cost_gate_pass = warm_p95 < 100.0; + + if !structural_gate_pass || !cost_gate_pass { + all_gates_passed = false; + } + + if let Some(ref dir_path) = save_dir { + save_cursor_artifact(Path::new(dir_path), variant_name, scale, &img)?; + } + + perf_rows.push(PerfReportRow { + name: format!("pen_{variant_name}_{scale:.2}x"), + samples_ms: warm_samples.iter().map(|us| us / 1000.0).collect(), + p50_ms: warm_p50 / 1000.0, + p95_ms: warm_p95 / 1000.0, + }); + results.push(BenchmarkRun { + variant: variant_name.to_string(), + tool: format!("{tool:?}"), + scale, + measured_native_scale: measured_scale, + dimensions: [w, h], + hotspot: [hx, hy], + retained_bytes: img.rgba.len(), + cold_raster_us_samples: cold_samples, + cold_raster_p50_us: cold_p50, + cold_raster_p95_us: cold_p95, + warm_move_us_samples: warm_samples, + warm_move_p50_us: warm_p50, + warm_move_p95_us: warm_p95, + baseline_painted_us_samples: baseline_samples, + baseline_painted_p50_us: base_p50, + baseline_painted_p95_us: base_p95, + preparation_speedup_vs_baseline_p50: speedup, + warm_moves_total: total_moves, + cache_hits, + warm_rasterizations, + structural_gate_pass, + cost_gate_pass, + }); + } + } + + // Run visual consistency oracle and render contact sheet outside timing samples + let (visual_comparisons, contact_sheet_b64) = run_visual_oracle(save_dir.as_deref().map(Path::new))?; + + if json_output { + let perf_report = PerfReport { + context: serde_json::json!({ + "bench": "cursor_bench", + "moves": moves, + "reps": reps, + "all_gates_passed": all_gates_passed, + }), + rows: perf_rows, + results, + visual_comparisons, + contact_sheet_png_base64: Some(contact_sheet_b64), + }; + if let Some(path) = json_file_path { + let file = std::fs::File::create(path)?; + let mut writer = std::io::BufWriter::new(file); + serde_json::to_writer_pretty(&mut writer, &perf_report)?; + std::io::Write::flush(&mut writer)?; + } else { + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + serde_json::to_writer_pretty(&mut writer, &perf_report)?; + std::io::Write::write_all(&mut writer, b"\n")?; + } + } else { + println!("================================================================================================================================="); + println!("PhotoCraft Issue #9122 Native Pen Cursor Benchmark Results"); + println!("Harness: {} pointer moves across {} repetitions per scale/variant", moves, reps); + println!("================================================================================================================================="); + println!( + "{:<18} {:<6} {:<10} {:<10} {:<12} {:<12} {:<12} {:<14} {:<10} {:<8}", + "Variant", "Scale", "Size (px)", "Hotspot", "Cold p50 (µs)", "Warm p50 (µs)", "Warm p95 (µs)", "Baseline p50", "Speedup", "Gates" + ); + println!("---------------------------------------------------------------------------------------------------------------------------------"); + for r in &results { + let gate_str = if r.structural_gate_pass && r.cost_gate_pass { "PASS" } else { "FAIL" }; + println!( + "{:<18} {:<6.2} {:<10} {:<10} {:<12.1} {:<12.2} {:<12.2} {:<14.2} {:<9.1}x {:<8}", + r.variant, + r.scale, + format!("{}x{}", r.dimensions[0], r.dimensions[1]), + format!("{},{}", r.hotspot[0], r.hotspot[1]), + r.cold_raster_p50_us, + r.warm_move_p50_us, + r.warm_move_p95_us, + r.baseline_painted_p50_us, + r.preparation_speedup_vs_baseline_p50, + gate_str + ); + } + println!("================================================================================================================================="); + println!("Measured Truth Gates Summary:"); + let total_warm_moves: usize = results.iter().map(|r| r.warm_moves_total).sum(); + let total_hits: usize = results.iter().map(|r| r.cache_hits).sum(); + let total_rasterizations: usize = results.iter().map(|r| r.warm_rasterizations).sum(); + println!( + "- Structural gate (0 warm rasterizations, 100% cache hits): {} ({}/{} moves hit cache, {} re-rasterizations)", + if results.iter().all(|r| r.structural_gate_pass) { "PASS" } else { "FAIL" }, + total_hits, + total_warm_moves, + total_rasterizations + ); + println!("- Cost gate (warm-path p95 < 0.1 ms / 100 µs): {}", if results.iter().all(|r| r.cost_gate_pass) { "PASS" } else { "FAIL" }); + if let Some(ref dir_path) = save_dir { + println!("- Cursor PNG artifacts saved to: {dir_path}"); + } + if !visual_comparisons.is_empty() { + println!("---------------------------------------------------------------------------------------------------------------------------------"); + println!("Visual Consistency Reference (Painted Baseline vs Native Bitmap 1:1 on wgpu):"); + println!( + "{:<18} {:<6} {:<12} {:<10} {:<10} {:<10} {:<10} {:<10} {:<10}", + "Variant", "Scale", "Background", "Size (px)", "Hotspot", "MAE (RGB)", "Max Diff", "RMSE", "Match %" + ); + println!("---------------------------------------------------------------------------------------------------------------------------------"); + for vc in &visual_comparisons { + println!( + "{:<18} {:<6.2} {:<12} {:<10} {:<10} {:<10.2} {:<10} {:<10.2} {:<9.1}%", + vc.variant, + vc.scale, + vc.background, + format!("{}x{}", vc.dimensions[0], vc.dimensions[1]), + format!("{},{}", vc.hotspot[0], vc.hotspot[1]), + vc.mae_rgb, + vc.max_abs_diff_rgb, + vc.rmse_rgb, + vc.match_percentage + ); + } + } + println!("================================================================================================================================="); + } + + if !all_gates_passed { + return Err("one or more benchmark gates failed".into()); + } + + Ok(()) +} diff --git a/crates/ui-egui/examples/cursor_bench/args.rs b/crates/ui-egui/examples/cursor_bench/args.rs new file mode 100644 index 0000000000..a0eca6c16c --- /dev/null +++ b/crates/ui-egui/examples/cursor_bench/args.rs @@ -0,0 +1,32 @@ +/// Validate before allocating samples. Twenty-four cases retain three timing vectors, +/// so 20,000 samples per case bounds raw timing storage to about 12 MiB. +pub(super) fn checked_sample_count(moves: usize, reps: usize) -> Result { + if moves == 0 || moves > 100_000 { + return Err("moves count must be in 1..=100000"); + } + if reps == 0 || reps > 100 { + return Err("reps count must be in 1..=100"); + } + let count = moves.checked_mul(reps).ok_or("move count overflow")?; + if count > 20_000 { + return Err("moves * reps must not exceed 20000 samples per cursor case"); + } + Ok(count) +} + +#[cfg(test)] +mod argument_tests { + use super::checked_sample_count; + + #[test] + fn sample_budget_accepts_defaults_and_boundary_but_rejects_excess() { + assert_eq!(checked_sample_count(1000, 5), Ok(5000)); + assert_eq!(checked_sample_count(200, 2), Ok(400)); + assert_eq!(checked_sample_count(200, 100), Ok(20_000)); + assert!(checked_sample_count(10_001, 2).is_err()); + assert!(checked_sample_count(100_000, 100).is_err()); + assert!(checked_sample_count(usize::MAX, 2).is_err()); + assert!(checked_sample_count(0, 1).is_err()); + assert!(checked_sample_count(1, 0).is_err()); + } +} diff --git a/crates/ui-egui/examples/cursor_bench/stats.rs b/crates/ui-egui/examples/cursor_bench/stats.rs new file mode 100644 index 0000000000..60306d8330 --- /dev/null +++ b/crates/ui-egui/examples/cursor_bench/stats.rs @@ -0,0 +1,12 @@ +/// Read one nearest-rank percentile from ascending-sorted timing samples: rank `ceil(pct * n)`, +/// clamped to `1..=n`. Twelve samples therefore report the largest value at p95, not the eleventh. +/// An empty slice reports `0.0`; a NaN percentage reports the lowest sample and an infinite one +/// the highest, so hostile input stays inside the bounds. +pub(super) fn percentile(sorted: &[f64], pct: f64) -> f64 { + let Some(last) = sorted.len().checked_sub(1) else { + return 0.0; + }; + let rank = (pct * (last + 1) as f64).ceil(); + let rank = if rank.is_nan() { 1.0 } else { rank.clamp(1.0, (last + 1) as f64) }; + sorted[rank as usize - 1] +} diff --git a/crates/ui-egui/examples/flat_open_bench.rs b/crates/ui-egui/examples/flat_open_bench.rs new file mode 100644 index 0000000000..447291acaf --- /dev/null +++ b/crates/ui-egui/examples/flat_open_bench.rs @@ -0,0 +1,449 @@ +//! Flat-image open benchmark (#10259): File › Open of an ordinary JPEG or PNG, phase by phase, +//! with the peak resident memory of each case. +//! +//! ```sh +//! cargo run --release -p photocraft-ui-egui --example flat_open_bench -- [--quick] [--reps 5] [--only jpeg-36] [--cpu] [--cache DIR] [--json out.json] +//! cargo run --release -p photocraft-ui-egui --example flat_open_bench -- --case png16-36 # one case, one process +//! ``` +//! +//! Cases (`--only` matches a substring of the key): `jpeg-{24,36}` (RGB, quality 90, 4:2:0), +//! `png8-{24,36}` (RGB 8-bit), `png16-{24,36}` (RGB 16-bit), `rgba8-{24,36}` (RGB 8-bit with +//! partial alpha) and `jpeg-24-orient6` (EXIF orientation 6). 24 MP is 6000x4000, 36 MP is +//! 7360x4912; `--quick` uses 1200x800 for every case (the row names keep the nominal size). +//! +//! The inputs are generated in-process (smooth gradients, soft blobs and seeded noise, so the +//! compression is realistic) with the workspace's own encoders and cached in `--cache` (default: +//! `$PHOTOCRAFT_FIXTURE_DIR`, else `fixtures/` in the target directory), never committed. +//! +//! Rows per case, in ms over `--reps` repetitions: +//! - `import`: `import_for_open_with`, the real File › Open path (decode, orientation, tiles) +//! - `admission`: `Session::open_document` of that document +//! - `decode`: the codec alone, orientation kept as stored +//! - `orient`: `Image::oriented` with the file's EXIF orientation (a no-op for an upright file) +//! - `tiles`: `image_to_document` on the decoded image (layout conversion, tile construction, prune) +//! - `first composite`: the canvas's first full refresh (GPU, or the CPU compositor with `--cpu` +//! or without an adapter), as `perf_scenarios` times it +//! - `editable`: import + admission + first composite of one open, the "decode + first composite" rows +//! +//! Peak memory: without `--case` the bench starts itself once per case (`current_exe`), so each +//! case's `VmHWM` is its own process-lifetime high-water mark. `peak_process_after_import_bytes` +//! is read after real import and admission; `peak_process_after_all_passes_bytes` is read after +//! standalone decode/orient/tiles, GPU setup and warm-up, and editable-open passes. The latter +//! is not an isolated composite or editable-open peak. Row `peak_rss_provenance` identifies +//! which reading its `peak_rss_bytes` carries. Off Linux the peak is `n/a`. +//! A case run directly with `--case` generates a missing fixture in its own process, which +//! raises its peak: run the bench once without `--case` first. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use eframe::egui_wgpu::RenderState; +use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image}; +use photocraft_engine::Session; +use photocraft_ui_egui::gpu_canvas::GpuCanvas; +use rayon::prelude::*; +use serde_json::{Value, json}; + +/// Prefix of the line a `--case` process prints for its parent. +const RESULT_PREFIX: &str = "FLAT_OPEN_RESULT "; +/// The phases, in report order. +const PHASES: [&str; 7] = ["import", "admission", "decode", "orient", "tiles", "first composite", "editable"]; + +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Jpeg, + Png8, + Png16, + Rgba8, +} + +struct Case { + key: &'static str, + label: &'static str, + kind: Kind, + /// 36 MP (7360x4912) instead of 24 MP (6000x4000). + big: bool, + /// EXIF orientation 6 (the stored image is turned upright on open). + orient6: bool, +} + +const CASES: [Case; 9] = [ + Case { key: "jpeg-24", label: "open 24 MP JPEG RGB8", kind: Kind::Jpeg, big: false, orient6: false }, + Case { key: "jpeg-36", label: "open 36 MP JPEG RGB8", kind: Kind::Jpeg, big: true, orient6: false }, + Case { key: "png8-24", label: "open 24 MP PNG RGB8", kind: Kind::Png8, big: false, orient6: false }, + Case { key: "png8-36", label: "open 36 MP PNG RGB8", kind: Kind::Png8, big: true, orient6: false }, + Case { key: "png16-24", label: "open 24 MP PNG RGB16", kind: Kind::Png16, big: false, orient6: false }, + Case { key: "png16-36", label: "open 36 MP PNG RGB16", kind: Kind::Png16, big: true, orient6: false }, + Case { key: "rgba8-24", label: "open 24 MP PNG RGBA8", kind: Kind::Rgba8, big: false, orient6: false }, + Case { key: "rgba8-36", label: "open 36 MP PNG RGBA8", kind: Kind::Rgba8, big: true, orient6: false }, + Case { key: "jpeg-24-orient6", label: "open 24 MP JPEG RGB8 (EXIF orientation 6)", kind: Kind::Jpeg, big: false, orient6: true }, +]; + +impl Case { + fn size(&self, quick: bool) -> (u32, u32) { + match (quick, self.big) { + (true, _) => (1200, 800), + (false, false) => (6000, 4000), + (false, true) => (7360, 4912), + } + } + fn ext(&self) -> &'static str { + if self.kind == Kind::Jpeg { "jpg" } else { "png" } + } + fn file_name(&self, w: u32, h: u32) -> String { + format!("flat_open_{}_{w}x{h}.{}", self.key, self.ext()) + } +} + +/// Return the value following a named command-line option. +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +/// `/fixtures`: the binary lives in `/release/examples/`. +fn default_cache() -> PathBuf { + if let Some(d) = std::env::var_os("PHOTOCRAFT_FIXTURE_DIR") { + return PathBuf::from(d); + } + std::env::current_exe().ok().and_then(|e| e.ancestors().nth(3).map(|t| t.join("fixtures"))).unwrap_or_else(std::env::temp_dir) +} + +/// Deterministic noise in [-0.5, 0.5) from the pixel position and a channel/seed. +fn noise(x: usize, y: usize, seed: usize) -> f32 { + let mut h = (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ seed.wrapping_mul(83_492_791)) as u64; + h = (h ^ (h >> 15)).wrapping_mul(0x2c1b_3c6d); + h = (h ^ (h >> 12)).wrapping_mul(0x297a_2d39); + ((h ^ (h >> 15)) % 4096) as f32 / 4096.0 - 0.5 +} + +/// A photo-like sample in [0, 1] for channel `c` (3 is alpha: opaque at the centre, down to +/// about 15 % at the corners, with fine noise, so most pixels are partly transparent). +fn sample(x: usize, y: usize, w: usize, h: usize, c: usize) -> f32 { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let n = noise(x, y, c); + let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp(); + let v = match c { + 0 => 0.55 + 0.17 * (fx * 6.0).sin() + 0.3 * blob + n * 0.06, + 1 => 0.45 + 0.12 * (fy * 9.0 + fx * 2.0).cos() + 0.2 * blob + n * 0.06, + 2 => 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06, + _ => 1.0 - 0.85 * (((fx - 0.5).powi(2) + (fy - 0.5).powi(2)) * 2.0).min(1.0) + n * 0.04, + }; + v.clamp(0.0, 1.0) +} + +/// A TIFF-structured EXIF block holding only the orientation tag. +fn exif_orientation_block(o: u16) -> Vec { + let mut b = b"II*\0\x08\0\0\0\x01\0".to_vec(); + b.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]); + b.extend_from_slice(&o.to_le_bytes()); + b.extend_from_slice(&[0, 0, 0, 0, 0, 0]); + b +} + +/// The encoded file for `case` at `w`x`h`. +fn generate(case: &Case, w: u32, h: u32) -> Result, String> { + let (wu, hu) = (w as usize, h as usize); + let (layout, ch) = if case.kind == Kind::Rgba8 { (ChannelLayout::Rgba, 4) } else { (ChannelLayout::Rgb, 3) }; + let img = if case.kind == Kind::Png16 { + let mut px = vec![0u16; wu * hu * ch]; + px.par_chunks_mut(wu * ch).enumerate().for_each(|(y, row)| { + for (i, v) in row.iter_mut().enumerate() { + *v = (sample(i / ch, y, wu, hu, i % ch) * 65535.0 + 0.5) as u16; + } + }); + Image::from_u16(w, h, layout, &px).map_err(|e| e.to_string())? + } else { + let mut px = vec![0u8; wu * hu * ch]; + px.par_chunks_mut(wu * ch).enumerate().for_each(|(y, row)| { + for (i, v) in row.iter_mut().enumerate() { + *v = (sample(i / ch, y, wu, hu, i % ch) * 255.0 + 0.5) as u8; + } + }); + Image::from_u8(w, h, layout, px).map_err(|e| e.to_string())? + }; + let format = if case.kind == Kind::Jpeg { Format::Jpeg } else { Format::Png }; + let opts = EncodeOptions { jpeg_quality: 90, jpeg_chroma_subsampling: true, ..Default::default() }; + let mut bytes = photocraft_codecs::encode(&img, format, &opts).map_err(|e| e.to_string())?; + if case.orient6 { + // The encoder writes the pixels as given and resets the orientation, so the tag goes in + // as an Exif APP1 segment right after the JPEG's SOI marker. + let exif = exif_orientation_block(6); + let len = u16::try_from(exif.len() + 8).map_err(|e| e.to_string())?; + let mut seg = vec![0xFF, 0xE1]; + seg.extend_from_slice(&len.to_be_bytes()); + seg.extend_from_slice(b"Exif\0\0"); + seg.extend_from_slice(&exif); + bytes.splice(2..2, seg); + } + Ok(bytes) +} + +/// The cached fixture for `case`, generated (and written atomically) when missing. +fn ensure_fixture(case: &Case, dir: &Path, quick: bool) -> Result { + let (w, h) = case.size(quick); + let path = dir.join(case.file_name(w, h)); + if path.metadata().is_ok_and(|m| m.len() > 0) { + return Ok(path); + } + std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let t = Instant::now(); + let bytes = generate(case, w, h).map_err(|e| format!("generate {}: {e}", case.key))?; + let tmp = path.with_extension(format!("{}.tmp{}", case.ext(), std::process::id())); + std::fs::write(&tmp, &bytes).map_err(|e| format!("write {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, &path).map_err(|e| format!("rename {}: {e}", path.display()))?; + eprintln!("generated {} ({:.1} MB) in {:.1} s", path.display(), bytes.len() as f64 / 1e6, t.elapsed().as_secs_f64()); + Ok(path) +} + +/// A headless GPU canvas on a device created like the app's. +fn canvas() -> Option<(GpuCanvas, RenderState)> { + let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup(); + let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?; + Some((GpuCanvas::new(&rs), rs)) +} + +/// The canvas's first full refresh; waits for the GPU like presenting the next frame would. +fn refresh(gpu: Option<&(GpuCanvas, RenderState)>, s: &Session) -> Result { + let st = s.active().ok_or("no document")?; + let t = Instant::now(); + match gpu { + Some((g, rs)) => { + g.refresh(st.doc.id.0, &st.doc, None, None); + let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None }); + } + None => { + std::hint::black_box(photocraft_compose::render(&st.doc, st.doc.bounds())); + } + } + Ok(ms(t)) +} + +/// `import_for_open_with`, File › Open's real path. +fn import_once(name: &str, bytes: &[u8]) -> Result { + let opts = photocraft_io::ImportOptions::default(); + photocraft_io::import_for_open_with(name, bytes, &opts, &Default::default()).map(|(r, _)| r.document).map_err(|e| e.to_string()) +} + +/// Runs every measurement for one case in this process; the result is the JSON line's payload. +fn run_case(case: &Case, dir: &Path, quick: bool, reps: usize, cpu: bool) -> Result { + let (w, h) = case.size(quick); + let path = ensure_fixture(case, dir, quick)?; + let bytes = std::fs::read(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + let name = format!("photo.{}", case.ext()); + let want = if case.orient6 { (h, w) } else { (w, h) }; + let mut ph: Vec> = vec![Vec::new(); PHASES.len()]; + let mut put = |i: usize, v: f64| { + if let Some(p) = ph.get_mut(i) { + p.push(v); + } + }; + + // Pass 1: the real path and the admission, before anything else raises the process's peak. + for _ in 0..reps { + let t = Instant::now(); + let doc = import_once(&name, &bytes)?; + put(0, ms(t)); + if doc.size.width != want.0 || doc.size.height != want.1 { + return Err(format!("{}: opened {}x{}, expected {}x{}", case.key, doc.size.width, doc.size.height, want.0, want.1)); + } + let t = Instant::now(); + let mut s = Session::new(); + s.open_document(doc, Some(name.clone())); + put(1, ms(t)); + } + let peak_process_after_import = photocraft_testkit::perf::process_peak_rss_bytes(); + + // Pass 2: the phases of the import on their own. + let format = photocraft_codecs::detect(&bytes).ok_or("unknown format")?; + let keep = DecodeOptions { keep_orientation: true, ..Default::default() }; + for _ in 0..reps { + let t = Instant::now(); + let img = photocraft_codecs::decode_as_with(format, &bytes, &keep).map_err(|e| e.to_string())?; + put(2, ms(t)); + let o = photocraft_codecs::exif_orientation(img.meta.exif.as_deref().unwrap_or_default()); + let t = Instant::now(); + let upright = img.oriented(o).map_err(|e| e.to_string())?; + put(3, ms(t)); + let t = Instant::now(); + let r = photocraft_io::image_to_document(&name, &upright).map_err(|e| e.to_string())?; + put(4, ms(t)); + drop(r); + } + + // Pass 3: one whole open as perf_scenarios times it: import, admission, first composite. + let gpu = if cpu { None } else { canvas() }; + let adapter = gpu.as_ref().map(|(_, rs)| rs.adapter.get_info().name); + // One untimed open first, so the GPU pipeline and adapter warm-up is not in the first sample. + { + let mut s = Session::new(); + s.open_document(import_once(&name, &bytes)?, Some(name.clone())); + refresh(gpu.as_ref(), &s)?; + } + for _ in 0..reps { + let t = Instant::now(); + let doc = import_once(&name, &bytes)?; + let mut s = Session::new(); + s.open_document(doc, Some(name.clone())); + let open = ms(t); + let c = refresh(gpu.as_ref(), &s)?; + put(5, c); + put(6, open + c); + } + let peak_process_after_all_passes = photocraft_testkit::perf::process_peak_rss_bytes(); + let phases: serde_json::Map = PHASES.iter().zip(ph).map(|(n, v)| ((*n).to_string(), json!(v))).collect(); + Ok(json!({ + "key": case.key, "label": case.label, "width": w, "height": h, "file_bytes": bytes.len(), + "peak_process_after_import_bytes": peak_process_after_import, "peak_process_after_all_passes_bytes": peak_process_after_all_passes, "adapter": adapter, "phases": phases, + })) +} + +/// The report rows of one case's result: `) -> Re } Ok(()) }; - let mut p = params(f); + let has_slices = app.session.active().is_some_and(|st| !st.doc.slices.is_empty()); + let mut p = web_params(f, has_slices)?; authorize(&p)?; // Confirmation may follow a field edit without an intervening frame (Enter or automation). let mut fields = f.clone(); web_preview::prepare_table(app, &mut fields)?; - let prepared = params(&fields); + let prepared = web_params(&fields, has_slices)?; if prepared != p { // The policy must also approve the actual palette-bearing export parameters. authorize(&prepared)?; @@ -570,7 +580,6 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re return save_for_web(app, p); } let doc = app.session.active().ok_or("no document")?.doc.clone(); - let has_slices = !doc.slices.is_empty(); let html = b(f, "html", true); let st = WebSettings::from_params(&p, "file.export.saveForWebLegacy").map_err(|e| e.to_string())?; let base = photocraft_doc::slices::base_name(&doc); @@ -605,6 +614,20 @@ fn kept_name_folder(path: &str, base: &str, ext: &str) -> Option { kept.then(|| file.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default()) } +/// The export parameters: the fields, plus the numbers of the slices selected in the preview +/// when the document's slices are exported as Selected Slices. +pub(super) fn web_params(f: &Map, has_slices: bool) -> Result { + let mut p = params(f); + if has_slices && s(f, "slices", "all") == "selected" { + let numbers = web_preview::selected_slices(f); + if numbers.is_empty() { + return Err(tl!("No slices are selected. Click a slice in the preview to select it.").to_string()); + } + p["numbers"] = json!(numbers); + } + Ok(p) +} + /// Runs Save for Web with `p`, which says where to write. fn save_for_web(app: &mut PhotocraftApp, p: Value) -> Result { #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/dialogs/file_ui/web_preview.rs b/crates/ui-egui/src/dialogs/file_ui/web_preview.rs index c2bdd4ad11..41bcd77b0e 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web_preview.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web_preview.rs @@ -230,7 +230,16 @@ pub fn controls(ui: &mut egui::Ui, f: &mut Map) { nav.write(f); } -pub fn paint(ui: &mut egui::Ui, rect: egui::Rect, response: &egui::Response, tex: &egui::TextureHandle, source: egui::Vec2, f: &mut Map) { +/// Paints the preview image in `rect` and returns where the whole image lies (it may extend +/// past `rect` when zoomed in). +pub fn paint( + ui: &mut egui::Ui, + rect: egui::Rect, + response: &egui::Response, + tex: &egui::TextureHandle, + source: egui::Vec2, + f: &mut Map, +) -> egui::Rect { let mut nav = Navigation::read(f); let ppp = ui.ctx().pixels_per_point(); if response.double_clicked() { @@ -267,6 +276,99 @@ pub fn paint(ui: &mut egui::Ui, rect: egui::Rect, response: &egui::Response, tex f.insert("__effectiveZoom".into(), json!(scale * ppp)); } nav.write(f); + image +} + +/// The numbers of the slices selected in the preview, in ascending order. +pub fn selected_slices(f: &Map) -> Vec { + let mut numbers: Vec = f.get("__selectedSlices").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + numbers.sort_unstable(); + numbers.dedup(); + numbers +} + +/// Draws the document's slices over a preview pane, as Photoshop's Save for Web does: user and +/// layer slices outlined in the slice colour, auto slices dashed, each with its number, and the +/// selected ones highlighted (#10548). A click selects the slice under the pointer and +/// Shift-click adds or removes one, like the dialog's Slice Select tool; the selection is the +/// `__selectedSlices` field, which Selected Slices exports. +pub fn slices( + ui: &egui::Ui, + rect: egui::Rect, + image: egui::Rect, + response: &egui::Response, + doc: &photocraft_doc::Document, + color: egui::Color32, + f: &mut Map, +) { + use photocraft_doc::SliceOrigin; + if doc.slices.is_empty() { + return; + } + let (dw, dh) = (doc.size.width.max(1) as f32, doc.size.height.max(1) as f32); + let to_screen = |r: photocraft_geom::Rect| { + let at = |x: i32, y: i32| image.min + egui::vec2(x as f32 / dw * image.width(), y as f32 / dh * image.height()); + egui::Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)) + }; + let all = photocraft_doc::slices::resolve(doc); + if response.clicked() + && let Some(pos) = response.interact_pointer_pos() + { + let hit = all.iter().find(|r| to_screen(r.rect).contains(pos)).map(|r| r.number as u64); + let mut selected = selected_slices(f); + match hit { + Some(n) if ui.input(|i| i.modifiers.shift) => { + if let Some(i) = selected.iter().position(|v| *v == n) { + selected.remove(i); + } else { + selected.push(n); + } + } + Some(n) => selected = vec![n], + None => {} + } + f.insert("__selectedSlices".into(), json!(selected)); + } + let selected = selected_slices(f); + let highlight = egui::Color32::from_rgb(0xff, 0xb0, 0x00); + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + // Auto slices first (dashed, underneath), then user and layer slices, then the selection. + for pass in 0..3 { + for r in &all { + let auto = r.origin == SliceOrigin::Auto; + let chosen = selected.contains(&(r.number as u64)); + if (pass == 0 && !auto) || (pass == 1 && auto) || (pass == 2 && !chosen) { + continue; + } + let sr = to_screen(r.rect); + match pass { + 0 => { + let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()]; + painter.add(egui::Shape::dashed_line(&pts, egui::Stroke::new(1.0, color.gamma_multiply(0.7)), 3.0, 3.0)); + } + 1 => { + painter.rect_stroke(sr, 0.0, egui::Stroke::new(1.0, color), egui::StrokeKind::Inside); + } + _ => { + painter.rect_filled(sr, 0.0, highlight.gamma_multiply(0.12)); + painter.rect_stroke(sr, 0.0, egui::Stroke::new(2.0, highlight), egui::StrokeKind::Inside); + } + } + if pass < 2 && sr.width() > 18.0 && sr.height() > 12.0 { + let galley = painter.layout_no_wrap(format!("{:02}", r.number), egui::FontId::proportional(10.0), egui::Color32::WHITE); + let badge = egui::Rect::from_min_size(sr.left_top() + egui::vec2(2.0, 2.0), galley.size() + egui::vec2(6.0, 2.0)); + let bg = if chosen { + highlight + } else if auto { + egui::Color32::from_gray(110) + } else { + color + }; + painter.rect_filled(badge, 2.0, bg); + painter.galley(badge.min + egui::vec2(3.0, 1.0), galley, egui::Color32::WHITE); + } + } + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index f2e97a80e8..5923fb4d2d 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -306,7 +306,6 @@ pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 { /// Commands outside `filter.*` that get the schema dialog *with* live preview. pub const PREVIEWED: &[&str] = &[ - "image.adjustments.selectiveColor", "image.adjustments.colorLookup", "image.adjustments.shadowsHighlights", "image.adjustments.replaceColor", @@ -323,6 +322,8 @@ pub const PREVIEWED: &[&str] = &[ // The preview shows the image through the chosen profile, as Photoshop's Preview does. "edit.assignProfile", "edit.convertToProfile", + // Previewed from the session's history (`canvas::filter_preview`), at full size (#8230). + "edit.fade", ]; pub fn has_dialog(command: &str) -> bool { @@ -509,6 +510,8 @@ pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map) { // Drop user params; keep private `__` chrome (and fixed keys outside the schema for `__spec`). let schema_keys: std::collections::HashSet = parse_spec(&spec).into_iter().map(|p| p.key).collect(); fields.retain(|k, _| k.starts_with("__") || !schema_keys.contains(k)); + // The eyedroppers' click order goes with the samples just dropped (Replace Color, #6280). + fields.remove("__order"); for p in parse_spec(&spec) { if let Some(v) = default_value(app, &command, &p) { fields.insert(p.key, v); @@ -784,16 +787,31 @@ pub(crate) fn color_eyedropper_armed(app: &PhotocraftApp) -> bool { /// Sample the merged image at document point (x, y) into the armed dialog's "From" colour. /// Returns true when the click was consumed, even off-image or over transparency, where nothing /// changes and the eyedropper stays armed (like the Curves picker). -pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { +/// With the Add to Sample eyedropper (or Shift) the click adds a sample, with Subtract from +/// Sample (or Alt) it subtracts one, as in Photoshop and Select › Color Range (#6280). +pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) -> bool { let Some(id) = app.ui.dialogs.last().filter(|d| color_eyedropper_owns(&d.fields)).map(|d| d.id) else { return false; }; - if let Some(rgb) = crate::canvas::composite_color(app, x, y) - && let Some(d) = app.ui.dialog_mut(id) - { - d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); - // Where it was picked: Localized Color Clusters match the colour near this point (#4457). - d.fields.insert("points".into(), json!([[x.floor(), y.floor()]])); + let Some(rgb) = crate::canvas::composite_color(app, x, y) else { return true }; + let Some(mut f) = app.ui.dialogs.last().map(|d| d.fields.clone()) else { return true }; + let tool = f.get("__tool").and_then(Value::as_str); + let sample = !mods.shift && !mods.alt && tool.is_none_or(|t| t == "sample"); + // A first Add to Sample, before any sample, starts from the clicked colour too: one point + // matches through `color`, and Color Range's matcher starts its range there as well. + let add = mods.shift || (!mods.alt && tool == Some("add")); + let first_add = add && f.get("points").and_then(Value::as_array).is_none_or(Vec::is_empty); + if sample || first_add { + f.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + } + // Where it was picked: Localized Color Clusters match the colour near this point (#4457); + // added and subtracted samples go to `points` / `subtractPoints` in click order. + crate::color_range_ui::pick(app, &mut f, [x, y], mods); + if let Some(order) = f.get("__order").cloned() { + f.insert("order".into(), order); + } + if let Some(d) = app.ui.dialog_mut(id) { + d.fields = f; } true } @@ -1035,9 +1053,21 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // PhotoCraft's Color Picker, as everywhere else (#2144). clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), &name).clicked(); if eyedropper { + // Photoshop's three eyedroppers (#6280), right to left here: Subtract + // from Sample, Add to Sample, Eyedropper. Clicking the chosen one again + // puts it away. let armed = f.get(COLOR_EYEDROPPER).and_then(Value::as_bool).unwrap_or(false); - if crate::icons::button(ui, "pipette", 24.0, armed, "Pick Color From Image").clicked() { - f.insert(COLOR_EYEDROPPER.into(), json!(!armed)); + let tool = f.get("__tool").and_then(Value::as_str).unwrap_or("sample").to_string(); + for (id, badge, label) in [ + ("subtract", "−", tl!("Subtract from Sample")), + ("add", "+", tl!("Add to Sample")), + ("sample", "", tl!("Pick Color From Image")), + ] { + let on = armed && tool == id; + if crate::color_range_ui::eyedropper(ui, badge, on, label).clicked() { + f.insert(COLOR_EYEDROPPER.into(), json!(!on)); + f.insert("__tool".into(), json!(id)); + } } } ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); @@ -1129,7 +1159,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: fields.insert("mapPath".into(), json!(path)); app.refocus(doc)?; let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?; - let result = app.run(&cmd, params); + let result = crate::actions::run_step_dialog(app, &fields, &cmd, params); if result.is_ok() { remember(app, &cmd, &fields); let _ = crate::actions::resume(app, &fields); diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index dcaa32ddbd..de9f389e23 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -905,20 +905,6 @@ fn notes_after_a_spec_are_not_parameters() { } } -#[test] -fn selective_color_dialog_ok_applies() { - // With the junk `reds: 0` the dialog's OK failed (`reds` must be an array of 4 numbers). - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - let id = open(&mut app, "image.adjustments.selectiveColor").unwrap(); - let fields = app.ui.dialogs.last().unwrap().fields.clone(); - assert!(fields.keys().all(|k| k.starts_with("__") || k.chars().all(|ch| ch.is_ascii_alphanumeric())), "{fields:?}"); - assert!(!fields.contains_key("reds"), "the per-range arrays are not dialog fields"); - app.ui.dialog_mut(id).unwrap().fields.insert("black".into(), json!(40.0)); - crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(app.session.journal.last().map(|j| j.0.as_str()), Some("image.adjustments.selectiveColor")); -} - /// A filtered test image with structure at every scale (low-frequency waves plus a ramp). fn waves(n: u32) -> Document { let mut doc = Document::with_background( @@ -1549,18 +1535,92 @@ fn replace_color_eyedropper_samples_the_image() { assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#ff0000"), "the dialog starts from the foreground colour"); // #6279: armed as it opens, so the first click on the image samples. assert!(color_eyedropper_armed(&app)); - assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); + assert!(color_eyedropper_sample(&mut app, 4.0, 4.0, egui::Modifiers::NONE)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); // #4457: where it was picked, for Localized Color Clusters. assert_eq!(app.ui.dialogs.last().unwrap().fields["points"], json!([[4.0, 4.0]])); // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. - assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); + assert!(color_eyedropper_sample(&mut app, -1.0, 4.0, egui::Modifiers::NONE)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); assert!(color_eyedropper_armed(&app)); // Disarmed, the dialog never claims the click. app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(false)); assert!(!color_eyedropper_armed(&app)); - assert!(!color_eyedropper_sample(&mut app, 4.0, 4.0)); + assert!(!color_eyedropper_sample(&mut app, 4.0, 4.0, egui::Modifiers::NONE)); +} + +/// #6280: Shift-click adds a sample and Alt-click subtracts one (as do the + and − eyedroppers), +/// in click order, as in Photoshop; a plain click starts over from the clicked colour. +#[test] +fn replace_color_shift_and_alt_clicks_add_and_subtract_samples() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#00ff00"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + open(&mut app, REPLACE_COLOR).unwrap(); + let field = |app: &PhotocraftApp, k: &str| app.ui.dialogs.last().unwrap().fields.get(k).cloned().unwrap_or(Value::Null); + assert!(color_eyedropper_sample(&mut app, 2.0, 2.0, egui::Modifiers::NONE)); + assert!(color_eyedropper_sample(&mut app, 5.0, 5.0, egui::Modifiers::SHIFT)); + assert!(color_eyedropper_sample(&mut app, 9.0, 9.0, egui::Modifiers::ALT)); + assert_eq!(field(&app, "points"), json!([[2.0, 2.0], [5.0, 5.0]])); + assert_eq!(field(&app, "subtractPoints"), json!([[9.0, 9.0]])); + assert_eq!(field(&app, "order"), json!(["+", "+", "-"])); + assert_eq!(field(&app, "color"), json!("#00ff00"), "the From colour is the plain sample's"); + // The − eyedropper subtracts without a modifier; a plain click with the plain one starts over. + let id = app.ui.dialogs.last().unwrap().id; + app.ui.dialog_mut(id).unwrap().fields.insert("__tool".into(), json!("subtract")); + assert!(color_eyedropper_sample(&mut app, 3.0, 3.0, egui::Modifiers::NONE)); + assert_eq!(field(&app, "subtractPoints"), json!([[9.0, 9.0], [3.0, 3.0]])); + app.ui.dialog_mut(id).unwrap().fields.insert("__tool".into(), json!("sample")); + assert!(color_eyedropper_sample(&mut app, 7.0, 7.0, egui::Modifiers::NONE)); + assert_eq!((field(&app, "points"), field(&app, "subtractPoints")), (json!([[7.0, 7.0]]), json!([]))); +} + +/// A first click with Add to Sample, before any plain sample, takes the clicked colour as the +/// From colour, so the single-sample command replaces it, not the foreground colour (CodeRabbit +/// review on #10443). Color Range's matcher, used from the second sample on, also starts its +/// range at the first added colour. +#[test] +fn replace_color_first_add_to_sample_click_sets_the_from_colour() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#00ff00"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let id = open(&mut app, REPLACE_COLOR).unwrap(); + let field = |app: &PhotocraftApp, k: &str| app.ui.dialogs.last().unwrap().fields.get(k).cloned().unwrap_or(Value::Null); + assert_ne!(field(&app, "color"), json!("#00ff00"), "the dialog starts from the foreground colour"); + for (tool, mods) in [("sample", egui::Modifiers::SHIFT), ("add", egui::Modifiers::NONE)] { + let mut fields = app.ui.dialogs.last().unwrap().fields.clone(); + reset_fields(&app, &mut fields); + fields.insert("__tool".into(), json!(tool)); + app.ui.dialog_mut(id).unwrap().fields = fields; + assert!(color_eyedropper_sample(&mut app, 4.0, 4.0, mods)); + assert_eq!(field(&app, "points"), json!([[4.0, 4.0]])); + assert_eq!(field(&app, "color"), json!("#00ff00"), "{tool}"); + } + // A later added sample leaves the From colour alone. + app.ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#123456")); + assert!(color_eyedropper_sample(&mut app, 6.0, 6.0, egui::Modifiers::SHIFT)); + assert_eq!(field(&app, "color"), json!("#123456")); +} + +/// Reset (Alt+Cancel) forgets the click order with the samples, so samples added afterwards +/// carry an order that fits them and the command takes it (CodeRabbit review on #10443). +#[test] +fn replace_color_reset_forgets_the_click_order() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#00ff00"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let id = open(&mut app, REPLACE_COLOR).unwrap(); + assert!(color_eyedropper_sample(&mut app, 2.0, 2.0, egui::Modifiers::NONE)); + assert!(color_eyedropper_sample(&mut app, 9.0, 9.0, egui::Modifiers::ALT)); + let mut fields = app.ui.dialogs.last().unwrap().fields.clone(); + reset_fields(&app, &mut fields); + app.ui.dialog_mut(id).unwrap().fields = fields; + assert!(color_eyedropper_sample(&mut app, 5.0, 5.0, egui::Modifiers::SHIFT)); + assert!(color_eyedropper_sample(&mut app, 7.0, 7.0, egui::Modifiers::SHIFT)); + let f = app.ui.dialogs.last().unwrap().fields.clone(); + assert_eq!(f["order"], json!(["+", "+"]), "{f:?}"); + let p = json!({"color": f["color"], "points": f["points"], "order": f["order"], "hue": 30}); + app.session.execute(REPLACE_COLOR, p).unwrap(); } /// #4143: the eyedropper button disarms and re-arms the canvas picker (#6279: it opens armed). @@ -1641,7 +1701,7 @@ fn replace_color_localized_color_clusters() { h.run_steps(3); let _ = h.get_by_label("Localized Color Clusters"); h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); - assert!(color_eyedropper_sample(h.state_mut(), 2.0, 5.0)); + assert!(color_eyedropper_sample(h.state_mut(), 2.0, 5.0, egui::Modifiers::NONE)); h.get_by_label("Localized Color Clusters").click(); h.run_steps(2); let f = h.state().ui.dialogs.last().unwrap().fields.clone(); diff --git a/crates/ui-egui/src/dialogs/gallery_ui.rs b/crates/ui-egui/src/dialogs/gallery_ui.rs index 43c392c1e9..c8c41d87dd 100644 --- a/crates/ui-egui/src/dialogs/gallery_ui.rs +++ b/crates/ui-egui/src/dialogs/gallery_ui.rs @@ -840,7 +840,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } if e.filter.uses_colours() { - ui.label(egui::RichText::new(tl!("Uses the foreground and background colours.")).color(t.text_faint).size(11.0)); + ui.label(egui::RichText::new(tl!("Uses the foreground and background colors.")).color(t.text_faint).size(11.0)); } }); // Effect layers (top of the list = applied last). diff --git a/crates/ui-egui/src/dialogs/guide_edit.rs b/crates/ui-egui/src/dialogs/guide_edit.rs new file mode 100644 index 0000000000..b058ed208e --- /dev/null +++ b/crates/ui-egui/src/dialogs/guide_edit.rs @@ -0,0 +1,208 @@ +//! A guide dialog's draft. Rendering and command validation stay in PhotoCraft; the +//! [`edit_session`](crate::edit_session) module owns proposal validity, stale-context rejection +//! and the single-use/no-op commit decision. +use crate::edit_session::{EditSession, FinishError}; +use photocraft_doc::{DocId, Document, Guides}; +use photocraft_engine::prefs::{PointSize, Unit}; +use serde_json::{Map, Value}; + +use crate::PhotocraftApp; + +#[derive(Clone, Debug, PartialEq)] +struct Guide { + vertical: bool, + position: f64, + color: Option<[u8; 3]>, +} + +#[derive(Debug, PartialEq)] +struct Context { + doc: DocId, + revision: u64, + rulers: Unit, + points: PointSize, + locked: bool, +} + +pub(crate) struct Draft { + pub(crate) dialog: u64, + target: Value, + initial_position: String, + edit: EditSession, + params: Option, + guides: Option, + error: Option, +} + +/// Identity, revision, units and lock state that must still match at confirmation. +fn context(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let units = &app.session.prefs().units_and_rulers; + Some(Context { doc: st.doc.id, revision: st.revision, rulers: units.rulers, points: units.point_size, locked: app.ui.extras.lock_guides }) +} + +/// Read a guide and its explicit colour without trusting a saved index. +fn guide(g: &Guides, vertical: bool, index: usize) -> Option { + let position = *if vertical { &g.vertical } else { &g.horizontal }.get(index)?; + Some(Guide { vertical, position, color: g.color(vertical, position) }) +} + +/// Decode a target only when its orientation and index are representable. +fn target(f: &Value) -> Option<(bool, usize)> { + let vertical = match f.get("orientation")?.as_str()? { + "vertical" => true, + "horizontal" => false, + _ => return None, + }; + Some((vertical, usize::try_from(f.get("index")?.as_u64()?).ok()?)) +} + +/// Capture the original target and context, retiring any previous guide dialog. +pub(crate) fn begin(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result<(), String> { + let target_value = fields.get("__edit").ok_or("missing guide target")?; + let (vertical, index) = target(target_value).ok_or("invalid guide target")?; + let st = app.session.active().ok_or("no document")?; + let value = guide(&st.doc.guides, vertical, index).ok_or("no such guide")?; + let context = context(app).ok_or("no document")?; + if let Some(previous) = app.guide_edit.take() { + app.ui.close_dialog(previous.dialog); + previous.edit.cancel(); + } + app.guide_edit = Some(Draft { + dialog, + target: target_value.clone(), + initial_position: fields.get("position").and_then(Value::as_str).unwrap_or_default().into(), + edit: EditSession::new(value, context), + params: None, + guides: None, + error: None, + }); + Ok(()) +} + +/// Read the live target for comparison with the draft's original context. +fn current(app: &PhotocraftApp, draft: &Draft) -> Option<(Guide, Context)> { + let (vertical, index) = target(&draft.target)?; + Some((guide(&app.session.active()?.doc.guides, vertical, index)?, context(app)?)) +} + +/// Invalid text remains editable; a changed target or context requires a new draft. +fn stale(app: &PhotocraftApp, draft: &Draft) -> bool { + current(app, draft).is_none_or(|(live, ctx)| !matches!(draft.edit.check(&live, &ctx), Ok(()) | Err(FinishError::InvalidProposal))) +} + +/// Allow retry only while the retained draft still belongs to the same live guide. +pub(crate) fn retryable(app: &PhotocraftApp) -> bool { + app.guide_edit.as_ref().is_some_and(|d| !stale(app, d)) +} + +/// Validate changed parameters on a COW document, retaining the last valid preview on error. +fn prepare(app: &PhotocraftApp, draft: &mut Draft, fields: &Map) -> Result<(), String> { + if stale(app, draft) || fields.get("__edit") != Some(&draft.target) { + return Err("The document or guide changed. Reopen Edit Selected Guides.".into()); + } + let mut params = crate::new_guide_ui::params(app, fields); + // Opening a fractional position in cm/percent rounds its display. Leaving the text and + // orientation alone must not move the guide or consume Redo through that rounding. + // Once the user edits Position, even a value equal to the original readout is intentional. + if !fields.get("__positionEdited").and_then(Value::as_bool).unwrap_or(false) + && fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position) + && (params.get("newOrientation").and_then(Value::as_str) == Some("vertical")) == draft.edit.original().vertical + { + params["position"] = serde_json::json!(draft.edit.original().position); + } + if draft.params.as_ref() == Some(¶ms) { + return draft.error.clone().map_or(Ok(()), Err); + } + let st = app.session.active().ok_or("no document")?; + // A cheap COW document on parameter changes only. The existing engine owns units, + // validation, colour inheritance and turning a guide into the other orientation. + let mut preview = photocraft_engine::Session::new(); + preview.prefs = app.session.prefs.clone(); + preview.add_document((*st.doc).clone(), None); + let result = preview.execute("view.editGuide", params.clone()).map_err(|e| e.to_string()).and_then(|_| { + let g = &preview.active().ok_or("no preview document")?.doc.guides; + let (from, old_index) = target(&draft.target).ok_or("invalid guide target")?; + let to = params.get("newOrientation").and_then(Value::as_str) == Some("vertical"); + let index = if from == to { old_index } else { if to { g.vertical.len() } else { g.horizontal.len() }.checked_sub(1).ok_or("no preview guide")? }; + let value = guide(g, to, index).ok_or("no preview guide")?; + draft.guides = Some(g.clone()); + Ok(value) + }); + draft.params = Some(params); + draft.edit.propose(result) +} + +/// Keep the draft and its validation message available after either valid or invalid input. +pub(crate) fn update(app: &mut PhotocraftApp, fields: &Map) -> Result<(), String> { + let mut draft = app.guide_edit.take().ok_or("Reopen Edit Selected Guides to start an edit.")?; + let result = prepare(app, &mut draft, fields); + draft.error = result.as_ref().err().cloned(); + app.guide_edit = Some(draft); + result +} + +/// Commit once through the ordinary command path; a no-op preserves Redo. +/// A command refusal restores the proposal against its original context for a guarded retry. +pub(crate) fn finish(app: &mut PhotocraftApp, fields: &Map) -> Result, String> { + update(app, fields)?; + let mut draft = app.guide_edit.take().ok_or("no guide draft")?; + let (live, ctx) = current(app, &draft).ok_or("the guide is gone")?; + let change = draft.edit.finish(&live, &ctx).map_err(|_| "The document or guide changed. Reopen Edit Selected Guides.")?; + let Some(change) = change else { return Ok(None) }; + let params = draft.params.clone().ok_or("no guide parameters")?; + match app.run("view.editGuide", params.clone()) { + Ok(result) => Ok(Some((params, result))), + Err(error) => { + // Reuse the pre-command context, never a fresh document/revision. The caller's + // retryable check rejects this draft if the failed command changed that context. + draft.edit = EditSession::new(live, ctx); + draft.edit.propose::(Ok(change.after))?; + app.guide_edit = Some(draft); + Err(error) + } + } +} + +/// Discard only the draft owned by the closing dialog, without touching history. +pub(crate) fn cancel(app: &mut PhotocraftApp, id: u64) { + if app.guide_edit.as_ref().is_some_and(|d| d.dialog == id) + && let Some(draft) = app.guide_edit.take() + { + draft.edit.cancel(); + } +} + +/// Refresh after dialog fields change, and drop drafts whose dialog has gone away. +pub(crate) fn sync(app: &mut PhotocraftApp) -> bool { + let Some(id) = app.guide_edit.as_ref().map(|d| d.dialog) else { return false }; + let Some(fields) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| d.fields.clone()) else { + cancel(app, id); + return false; + }; + let old = app.guide_edit.as_ref().and_then(|d| d.params.clone()); + let result = update(app, &fields); + let changed = old != app.guide_edit.as_ref().and_then(|d| d.params.clone()); + if let Some(d) = app.ui.dialog_mut(id) { + match result { + Ok(()) if changed => { + d.fields.remove("__error"); + } + // Unchanged, valid parameters may still have a command-policy refusal to show. + Ok(()) => {} + Err(error) => { + d.fields.insert("__error".into(), Value::String(error)); + } + } + } + changed +} + +/// Expose the last valid preview only for its unchanged document and still-open dialog. +pub(crate) fn preview<'a>(app: &'a PhotocraftApp, doc: &Document) -> Option<&'a Guides> { + let draft = app.guide_edit.as_ref()?; + if draft.edit.context().doc != doc.id || stale(app, draft) || !app.ui.dialogs.iter().any(|d| d.id == draft.dialog) { + return None; + } + draft.guides.as_ref() +} diff --git a/crates/ui-egui/src/dialogs/history_options_ui.rs b/crates/ui-egui/src/dialogs/history_options_ui.rs index 2d4756543b..0ff35dbc0e 100644 --- a/crates/ui-egui/src/dialogs/history_options_ui.rs +++ b/crates/ui-egui/src/dialogs/history_options_ui.rs @@ -1,8 +1,8 @@ //! The History Options dialog (History panel menu › History Options…, #2966): a form over the -//! engine's `history.options` with its two checkboxes, filled with the current values. OK runs -//! the command with the dialog's values (the generic command-dialog confirm), Cancel changes -//! nothing. Agents can also run `history.options` with params directly, or `prefs.set -//! historyOptions.*`; a call with params skips the dialog. +//! engine's `history.options` with Photoshop's five checkboxes in its order, filled with the +//! current values. OK runs the command with the dialog's values (the generic command-dialog +//! confirm), Cancel changes nothing. Agents can also run `history.options` with params directly, +//! or `prefs.set historyOptions.*`; a call with params skips the dialog. use serde_json::{Map, Value, json}; @@ -27,11 +27,16 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { f.insert("__label".into(), json!("History Options…")); f.insert("__form".into(), json!(true)); f.insert("__choices".into(), json!({})); - // Photoshop's capitalised labels, in the UI language (the form would sentence-case the keys). + // Photoshop's order and capitalised labels, in the UI language (the form would sort the keys + // and sentence-case them). + f.insert("__order".into(), json!(photocraft_engine::history_options_cmds::OPTIONS)); f.insert( "__labels".into(), json!({ + "automaticallyCreateFirstSnapshot": tl!("Automatically Create First Snapshot"), + "automaticallyCreateNewSnapshotWhenSaving": tl!("Automatically Create New Snapshot When Saving"), "allowNonLinearHistory": tl!("Allow Non-Linear History"), + "showNewSnapshotDialogByDefault": tl!("Show New Snapshot Dialog by Default"), "makeLayerVisibilityChangesUndoable": tl!("Make Layer Visibility Changes Undoable"), }), ); @@ -58,19 +63,25 @@ mod tests { let d = app.ui.dialogs.iter().find(|d| d.id == id).unwrap(); assert_eq!(d.fields["allowNonLinearHistory"], json!(false)); assert_eq!(d.fields["makeLayerVisibilityChangesUndoable"], json!(true)); + assert_eq!(d.fields["automaticallyCreateFirstSnapshot"], json!(true)); + assert_eq!(d.fields["automaticallyCreateNewSnapshotWhenSaving"], json!(false)); + assert_eq!(d.fields["showNewSnapshotDialogByDefault"], json!(false)); assert_eq!(crate::dialogs::title(d), "History Options"); // Cancel changes nothing. app.ui.dialog_mut(id).unwrap().fields.insert("allowNonLinearHistory".into(), json!(true)); crate::dialogs::cancel(&mut app, id).unwrap(); assert!(!app.session.prefs().history_options.allow_non_linear_history); - // OK applies both checkboxes. + // OK applies every checkbox. let id = open(&mut app); let fields = &mut app.ui.dialog_mut(id).unwrap().fields; - fields.insert("allowNonLinearHistory".into(), json!(true)); - fields.insert("makeLayerVisibilityChangesUndoable".into(), json!(false)); + for key in photocraft_engine::history_options_cmds::OPTIONS { + let on = fields[key].as_bool().unwrap(); + fields.insert(key.into(), json!(!on)); + } crate::dialogs::confirm(&mut app, id).unwrap(); let o = &app.session.prefs().history_options; assert!(o.allow_non_linear_history && !o.make_layer_visibility_changes_undoable); + assert!(!o.automatically_create_first_snapshot && o.automatically_create_new_snapshot_when_saving && o.show_new_snapshot_dialog_by_default); assert!(app.ui.dialogs.is_empty()); // Reopened, it shows what was chosen; with params the command runs without a dialog. let id = open(&mut app); @@ -82,16 +93,24 @@ mod tests { assert!(crate::menus::invoke(&mut app, &ctx, COMMAND, json!({"allowNonLinearHistory": "no"})).is_err()); } + /// The five checkboxes with Photoshop's labels, top to bottom in its dialog's order. #[test] - fn the_dialog_draws_both_checkboxes_with_photoshops_labels() { + fn the_dialog_draws_the_checkboxes_in_photoshops_order() { use egui_kittest::{Harness, kittest::Queryable}; let mut h = Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app()); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); open(h.state_mut()); h.run_steps(3); h.get_by_label("History Options"); - h.get_by_label("Allow Non-Linear History"); - h.get_by_label("Make Layer Visibility Changes Undoable"); + let labels = [ + "Automatically Create First Snapshot", + "Automatically Create New Snapshot When Saving", + "Allow Non-Linear History", + "Show New Snapshot Dialog by Default", + "Make Layer Visibility Changes Undoable", + ]; + let tops: Vec = labels.iter().map(|l| h.get_by_label(l).rect().min.y).collect(); + assert!(tops.windows(2).all(|w| w[0] < w[1]), "top to bottom: {tops:?}"); h.get_by_label("OK"); h.get_by_label("Cancel"); } diff --git a/crates/ui-egui/src/dialogs/jpeg_options_ui.rs b/crates/ui-egui/src/dialogs/jpeg_options_ui.rs index e59d694b58..3da36e3a2b 100644 --- a/crates/ui-egui/src/dialogs/jpeg_options_ui.rs +++ b/crates/ui-egui/src/dialogs/jpeg_options_ui.rs @@ -3,7 +3,9 @@ //! 5–7, High 8–9, Maximum 10–12), mapped to the encoder's 1–100 by //! `photocraft_engine::file_cmds::jpeg_quality`; Format Options are Baseline ("Standard"), Baseline //! Optimized and Progressive. Matte, Scans and the size preview aren't offered yet. Re-saving an -//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032). +//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032). Otherwise the +//! dialog starts at the quality and format it was last answered with, also after a restart, as +//! Photoshop's does (see [`Start::for_active`]). //! //! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written //! once answered. Cancel drops the save. Scripts skip the prompt by passing `quality` or @@ -135,16 +137,60 @@ impl Start { Start { quality: quality_from_percent(quality), own_quality: None, format: JpegFormat::Baseline } } - /// For the active document: its opened JPEG's own quality (`Document::file_encoding`), or - /// else the Export preferences' JPEG quality (the one Quick Export uses). + /// For the active document: the Format Options the dialog was last answered with, else + /// Baseline; and the quality of its opened JPEG (`Document::file_encoding`, which File › Save + /// keeps, #4032), else the quality last answered, else the Export preferences' JPEG quality + /// (the one Quick Export uses). pub fn for_active(app: &PhotocraftApp) -> Self { - match app.session.active().and_then(|st| st.doc.file_encoding.jpeg_quality) { + let remembered = remembered(app); + let start = match app.session.active().and_then(|st| st.doc.file_encoding.jpeg_quality) { Some(q) => Start::own(q), - None => Start::percent(app.session.prefs().export.jpeg_quality), - } + None => match remembered.quality { + Some(quality) => Start { quality, own_quality: None, format: JpegFormat::Baseline }, + None => Start::percent(app.session.prefs().export.jpeg_quality), + }, + }; + Start { format: remembered.format.unwrap_or(start.format), ..start } } } +/// Preferences ▸ remembered dialog settings: JPEG Options writes its last answer under this key +/// and starts the next dialog from it, as Photoshop does (it keeps it across restarts). There is +/// no command of this id; the key only names the dialog among the others. +const REMEMBER_KEY: &str = "file.jpegOptions"; + +/// What the dialog was last answered with; a value that no longer fits (a hand-edited +/// preferences file) is left out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct Remembered { + quality: Option, + format: Option, +} + +fn remembered(app: &PhotocraftApp) -> Remembered { + let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(REMEMBER_KEY) else { return Remembered::default() }; + Remembered { + quality: saved.get("quality").and_then(Value::as_u64).and_then(|q| u8::try_from(q).ok()).filter(|&q| q <= MAX_QUALITY), + format: saved.get("jpegFormat").and_then(Value::as_str).and_then(JpegFormat::parse), + } +} + +/// Records an answered dialog for the next one. A quality left on the opened file's own is not +/// an answer: the earlier one stays, so saving a series of opened JPEGs doesn't forget what a +/// new document was last saved at. +fn remember(app: &mut PhotocraftApp, choice: JpegChoice) { + let before = remembered(app); + let quality = choice.quality.or(before.quality); + let mut saved = serde_json::Map::new(); + if let Some(q) = quality { + saved.insert("quality".into(), q.into()); + } + saved.insert("jpegFormat".into(), choice.format.name().into()); + app.session.prefs.edit(|p| { + p.dialogs.insert(REMEMBER_KEY.into(), Value::Object(saved)); + }); +} + /// A save waiting on the JPEG Options answer. pub struct Prompt { /// The document that requested the save, even if tabs change while this is open. @@ -237,7 +283,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { confirm(app, ctx); } else if cancel { app.jpeg_options = None; - app.ui.status = tl!("Save cancelled").into(); + app.ui.status = tl!("Save canceled").into(); app.ui.status_error = false; } } @@ -250,6 +296,7 @@ pub fn confirm(app: &mut PhotocraftApp, ctx: &egui::Context) { // quality out, so a replay keeps the file's own quality too, as File › Save does (#4032). let own = p.own_quality.filter(|&q| quality_from_percent(u32::from(q)) == p.quality); let choice = JpegChoice { quality: own.is_none().then_some(p.quality), format: p.format }; + remember(app, choice); let step = if p.record == "file.save" { // A plain Save writes whichever file the action is played on (#3531), with the answer. plain_save_step(Some(choice)) diff --git a/crates/ui-egui/src/dialogs/layer_style.rs b/crates/ui-egui/src/dialogs/layer_style.rs index 29396f6022..65772e1573 100644 --- a/crates/ui-egui/src/dialogs/layer_style.rs +++ b/crates/ui-egui/src/dialogs/layer_style.rs @@ -324,6 +324,7 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, gradient_field::KEY, gradient_field::stops_json(stops)); set(&mut v, gradient_field::OPACITY_KEY, gradient_field::opacity_json(&g.opacity_stops)); set(&mut v, "reverse", json!(g.reverse)); + set(&mut v, "offset", json!([g.offset.0, g.offset.1])); set( &mut v, "style", @@ -535,6 +536,82 @@ pub fn drag_shadow(f: &mut Map, start: bool, total: [f64; 2]) { set_param(f, &id, "distance", json!(d.round().clamp(0.0, 30000.0))); } +/// Whether the selected page is a Gradient Overlay, which a drag on the image moves while the +/// dialog is open, as in Photoshop (#10763; [`drag_gradient`]). +pub fn drags_gradient(f: &Map) -> bool { + let selected = f.get("selected").and_then(Value::as_str).unwrap_or_default(); + entry(f, selected).and_then(|e| e.get("kind")).and_then(Value::as_str) == Some("gradientOverlay") +} + +/// Ends a gradient drag's stored origin ([`drag_gradient`]): the canvas calls this whenever its +/// gradient branch is inactive on a Layer Style dialog (another page chosen under a held press, +/// say), so a Gradient Overlay page chosen again starts from the offset shown, not from an origin +/// and a baseline taken before the pointer travelled on another page. +pub fn end_gradient_drag(f: &mut Map) { + f.remove("__gradientDrag"); +} + +/// The width and height of the layer the dialog `f` is open on, what a Gradient Overlay's offset +/// is a fraction of (`photocraft_compose::layer_bounds`, what the renderer paints against): `None` +/// once that layer is gone or while it is empty (a gradient paints nothing there), when a drag on +/// the image has nothing to move and the Hand tool pans. +pub fn gradient_layer_bounds(session: &photocraft_engine::Session, f: &Map) -> Option<[f64; 2]> { + let id = f.get("layer").and_then(Value::as_u64)?; + let st = session.active()?; + let b = photocraft_compose::layer_bounds(st.doc.layer(photocraft_doc::LayerId(id))?, st.doc.bounds()); + let [w, h] = [f64::from(b.width()), f64::from(b.height())]; + (w > 0.0 && h > 0.0).then_some([w, h]) +} + +/// A drag on the image with a Gradient Overlay page selected: `start` is true on the press (it +/// records the gradient's offset then, as does a change of the selected instance under a held +/// press), `total` the pointer's movement since in document pixels +/// and `bounds` the layer's width and height. The gradient's centre moves with the pointer, kept +/// as a fraction of the layer's bounds (`offset`, what the renderer takes), to a thousandth. +pub fn drag_gradient(f: &mut Map, start: bool, total: [f64; 2], bounds: [f64; 2]) { + const KEY: &str = "__gradientDrag"; + let Some(id) = f.get("selected").and_then(Value::as_str).map(str::to_string).filter(|_| drags_gradient(f)) else { return }; + let Some(e) = entry(f, &id) else { return }; + // The offset the gradient shows: `offset` as stored when its first two elements are finite + // numbers (the only form the engine applies, and all it reads of a longer array), else the + // effect's own (its `fx` snapshot), else the centre + // (None: never set); within ±4 of the layer's size, as the renderer keeps it, so the drag + // starts from where the gradient is seen. + let pair = |v: Option<&Value>| { + let a = v?.as_array()?; + let (x, y) = (a.first()?.as_f64()?, a.get(1)?.as_f64()?); + (x.is_finite() && y.is_finite()).then(|| [x.clamp(-4.0, 4.0), y.clamp(-4.0, 4.0)]) + }; + let shown = pair(e.get("params").and_then(|p| p.get("offset"))).or_else(|| pair(e.get("fx").and_then(|fx| fx.pointer("/GradientOverlay/gradient/offset")))); + // The origin: the offset shown, the instance it is for and the pointer's total then. Taken on + // the press, when there is none, and when the page changed under a held press (another + // instance selected, or a Gradient Overlay page chosen during a press that began elsewhere): + // that instance then moves by the pointer's travel from this moment, not from the press. + let read = |v: Option<&Value>| { + let a = v?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) + }; + if start || f.get(KEY).and_then(|o| o.get("id")).and_then(Value::as_str) != Some(id.as_str()) { + f.insert(KEY.into(), json!({"id": id, "origin": shown.unwrap_or([0.0, 0.0]), "base": total})); + } + let Some(origin) = f.get(KEY) else { return }; + let (Some([x0, y0]), Some([bx, by])) = (read(origin.get("origin")), read(origin.get("base"))) else { return }; + let [w, h] = [bounds[0].max(1.0), bounds[1].max(1.0)]; + // Kept within what the renderer takes (±4 of the layer's size), so the next drag starts from + // where the gradient shows. + let (x, y) = ((x0 + (total[0] - bx) / w).clamp(-4.0, 4.0), (y0 + (total[1] - by) / h).clamp(-4.0, 4.0)); + if !(x.is_finite() && y.is_finite()) { + return; + } + // A press that has not moved the gradient (or a drag back to where it began, with nothing + // written since) leaves the offset as it is: a stored one to its full precision, none where + // there was none. + if x == x0 && y == y0 && shown.is_none_or(|s| s == [x0, y0]) { + return; + } + set_param(f, &id, "offset", json!([(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0])); +} + pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map { initial_fields_with(layer, select, light, Map::new()) } diff --git a/crates/ui-egui/src/dialogs/layer_style/tests.rs b/crates/ui-egui/src/dialogs/layer_style/tests.rs index ab221b53b1..dbf0137293 100644 --- a/crates/ui-egui/src/dialogs/layer_style/tests.rs +++ b/crates/ui-egui/src/dialogs/layer_style/tests.rs @@ -33,6 +33,160 @@ fn dragging_on_the_image_moves_the_selected_shadow() { assert_eq!(params(&f, "fx1"), (120.0, 0.0)); } +/// Review: the drag starts from the offset the gradient shows: a stored pair beyond ±4 at the +/// clamp, a malformed pair at the effect's own offset (its `fx` snapshot) or, without one, at the +/// centre, since the engine applies `offset` only when it is two finite numbers. +#[test] +fn a_drag_starts_from_the_offset_the_gradient_shows() { + let mut f = Map::new(); + f.insert("selected".into(), json!("fx1")); + f.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "gradientOverlay", "on": true, "params": {"offset": [5.0, 0.0]}}, + {"id": "fx2", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.5, null]}}, + {"id": "fx3", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.5, null]}, "fx": {"GradientOverlay": {"gradient": {"offset": [0.2, 0.3]}}}}, + {"id": "fx4", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.1, 0.2, 9]}}, + ]), + ); + let offset = |f: &Map, id: &str| entry(f, id).unwrap()["params"].get("offset").cloned(); + // Stored at 5, shown at 4: a short drag left moves it from 4, not from 5. + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([5.0, 0.0])), "a plain press leaves it"); + drag_gradient(&mut f, false, [-20.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([3.9, 0.0]))); + // A malformed pair without a snapshot: the renderer shows the centre, so the drag starts there. + f.insert("selected".into(), json!("fx2")); + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.5, null])), "a plain press leaves it"); + drag_gradient(&mut f, false, [50.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.25, 0.0]))); + // With a snapshot: the renderer shows the effect's own offset, so the drag starts there. + f.insert("selected".into(), json!("fx3")); + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + drag_gradient(&mut f, false, [50.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx3"), Some(json!([0.45, 0.3]))); + // Three elements: the first two count, as the engine reads them. + f.insert("selected".into(), json!("fx4")); + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx4"), Some(json!([0.1, 0.2, 9])), "a plain press leaves it"); + drag_gradient(&mut f, false, [50.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx4"), Some(json!([0.35, 0.2]))); +} + +/// Review: with the dialog's layer gone, a drag on the image has nothing to move, so the Hand +/// tool pans as usual: `gradient_layer_bounds` finds the dialog's layer, or nothing. +#[test] +fn the_gradient_drag_needs_its_layer() { + let mut s = session(); + let empty = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + let mut f = Map::new(); + f.insert("layer".into(), json!(id.0)); + assert_eq!(gradient_layer_bounds(&s, &f), Some([64.0, 64.0]), "the dialog's layer"); + // An empty layer paints no gradient (review): nothing to move, the Hand tool pans. + f.insert("layer".into(), json!(empty.0)); + assert_eq!(gradient_layer_bounds(&s, &f), None, "an empty layer"); + f.insert("layer".into(), json!(id.0 + 1000)); + assert_eq!(gradient_layer_bounds(&s, &f), None, "a layer that is gone"); + f.remove("layer"); + assert_eq!(gradient_layer_bounds(&s, &f), None, "a dialog without a layer"); +} + +/// Review: the selected instance changing under a held press, or a Gradient Overlay page chosen +/// during a press that began on another page, moves that instance from its own offset by the +/// pointer's travel from that moment, not from the press. +#[test] +fn the_drag_rebases_when_the_selected_effect_changes() { + let mut f = Map::new(); + f.insert("selected".into(), json!("fx3")); + f.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "gradientOverlay", "on": true, "params": {}}, + {"id": "fx2", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.1, 0.1]}}, + {"id": "fx3", "kind": "dropShadow", "on": true, "params": {}}, + ]), + ); + let offset = |f: &Map, id: &str| entry(f, id).unwrap()["params"].get("offset").cloned(); + // The press began on the shadow page: nothing for the gradient, until its page is chosen. + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + drag_gradient(&mut f, false, [40.0, 0.0], [200.0, 100.0]); + f.insert("selected".into(), json!("fx1")); + drag_gradient(&mut f, false, [40.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), None, "nothing moved since the page was chosen"); + drag_gradient(&mut f, false, [60.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([0.1, 0.0])), "20 px on from the switch"); + // Another instance selected mid-press: it moves by the travel from here, from its own offset. + f.insert("selected".into(), json!("fx2")); + drag_gradient(&mut f, false, [60.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.1, 0.1])), "nothing moved yet"); + drag_gradient(&mut f, false, [80.0, 20.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.2, 0.3]))); + assert_eq!(offset(&f, "fx1"), Some(json!([0.1, 0.0])), "the first one stays"); + // The page left and the same instance chosen again under the held press (the canvas ends the + // stored drag while off the page): it starts over from the offset shown, not from the old + // origin and baseline, so the travel made meanwhile doesn't land on it. + f.insert("selected".into(), json!("fx3")); + end_gradient_drag(&mut f); + f.insert("selected".into(), json!("fx2")); + drag_gradient(&mut f, false, [120.0, 20.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.2, 0.3])), "nothing moved since the page came back"); + drag_gradient(&mut f, false, [140.0, 20.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.3, 0.3]))); +} + +/// #10763: with a Gradient Overlay page selected, a drag on the image moves the gradient: its +/// centre follows the pointer as a fraction of the layer's bounds. +#[test] +fn dragging_on_the_image_moves_the_selected_gradient_overlay() { + let mut f = Map::new(); + f.insert("selected".into(), json!("fx1")); + f.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "gradientOverlay", "on": true, "params": {"angle": 90.0, "scale": 100.0}}, + {"id": "fx2", "kind": "gradientOverlay", "on": true, "params": {"angle": 90.0, "offset": [0.1, 0.1]}}, + ]), + ); + assert!(drags_gradient(&f)); + let offset = |f: &Map, id: &str| entry(f, id).unwrap()["params"].get("offset").cloned(); + // The press keeps the gradient where it is (centred, no offset yet, and none written); 50 × 25 + // px on a 200 × 100 layer move it a quarter of the way each. + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), None, "a plain press writes nothing"); + drag_gradient(&mut f, false, [50.0, 25.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([0.25, 0.25]))); + assert_eq!(offset(&f, "fx2"), Some(json!([0.1, 0.1])), "another instance is left alone"); + // Back and past the start: the way the pointer went, from where the drag began. + drag_gradient(&mut f, false, [-100.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([-0.5, 0.0]))); + // A new drag starts from the offset the last one left. + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + drag_gradient(&mut f, false, [20.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([-0.4, 0.0]))); + // A press that moves nothing keeps a precise offset as it is; a drag that comes back to where + // it began still writes, since the gradient moved in between. The offset stays within ±4. + f.insert("selected".into(), json!("fx2")); + set_param(&mut f, "fx2", "offset", json!([0.123456, 0.1])); + drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]); + drag_gradient(&mut f, false, [0.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.123456, 0.1])), "nothing moved: nothing rounded"); + drag_gradient(&mut f, false, [20.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.223, 0.1]))); + drag_gradient(&mut f, false, [0.0, 0.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([0.123, 0.1])), "back at the start: the start is written"); + drag_gradient(&mut f, false, [100_000.0, -100_000.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx2"), Some(json!([4.0, -4.0])), "clamped as the renderer clamps"); + // Not on a gradient page: nothing moves. + f.insert("selected".into(), json!(BLENDING)); + assert!(!drags_gradient(&f)); + drag_gradient(&mut f, true, [50.0, 50.0], [200.0, 100.0]); + assert_eq!(offset(&f, "fx1"), Some(json!([-0.4, 0.0]))); +} + fn session() -> photocraft_engine::Session { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/liquify_ui.rs b/crates/ui-egui/src/dialogs/liquify_ui.rs index 5a6725bcc5..f5263b1e81 100644 --- a/crates/ui-egui/src/dialogs/liquify_ui.rs +++ b/crates/ui-egui/src/dialogs/liquify_ui.rs @@ -191,9 +191,12 @@ impl LiquifyOpts { if let Some(t) = v.get("tool") { let tool: LiquifyTool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?; // Strokes can twirl counterclockwise, but no tool does: hold Alt with Twirl Clockwise. - if !LiquifyTool::ALL.contains(&tool) { + if tool == LiquifyTool::TwirlCcw { return Err(format!("{t} is not a Liquify tool: choose twirlCw and hold Alt (Option) to twirl counterclockwise")); } + if !LiquifyTool::ALL.contains(&tool) { + return Err(format!("{t} is not a Liquify tool")); + } self.tool = tool; } Ok(()) @@ -265,6 +268,12 @@ pub struct LiquifyDialog { pub preview_ms: f64, /// The smart filter being edited: OK updates it instead of adding a new Liquify filter. smart_filter: Option, + /// Preview (P): off, the image is drawn unwarped while strokes still change the field, as + /// Photoshop's Preview checkbox does (#7905). Every opening starts with it on. + pub preview: bool, + /// The undisplaced field drawn while Preview is off. It is as large as `field`, so it exists + /// only while Preview is off: made when Preview is turned off, freed when it's turned back on. + identity: Option, } fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> { @@ -297,6 +306,7 @@ impl LiquifyDialog { "strokes": self.strokes.len() + usize::from(self.cur.is_some()), "redo": self.redo.len(), "meshSize": self.cell, + "preview": self.preview, "maxDisplacement": self.field.max_displacement(), "proxy": [self.proxy.w, self.proxy.h], "dabMs": self.dab_ms, @@ -438,16 +448,29 @@ impl LiquifyDialog { /// Re-renders the dirty proxy pixels into `out` (the texture upload happens in `show`). fn render_dirty(&mut self) { if let Some(r) = self.dirty { + let field = self.identity.as_ref().filter(|_| !self.preview).unwrap_or(&self.field); if let Some(source) = &self.native_proxy { let bounds = Rect::new(0, 0, self.proxy.w as i32, self.proxy.h as i32); let dirty = Rect::new(r[0] as i32, r[1] as i32, r[2] as i32, r[3] as i32); - render_components(source, bounds, &self.field, self.proxy.scale, &self.target, bounds, dirty, &mut self.out); + render_components(source, bounds, field, self.proxy.scale, &self.target, bounds, dirty, &mut self.out); } else { - self.proxy.render(&self.field, r, &mut self.out); + self.proxy.render(field, r, &mut self.out); } } } + /// Turns Preview on or off: the whole preview is redrawn with, or without, the warp. + pub fn set_preview(&mut self, on: bool) { + if on == self.preview { + return; + } + self.identity = (!on).then(|| LiquifyField::new(self.canvas, self.cell)); + self.preview = on; + self.dirty = Some([0, 0, self.proxy.w, self.proxy.h]); + self.render_dirty(); + self.view_dirty = self.view_bounds; + } + /// Builds or updates the visible source-resolution viewport. At ordinary zoom the whole-layer /// proxy remains cheaper; above 100%, a full-resolution crop is enough to fill the preview. fn update_view_preview(&mut self, ctx: &egui::Context, area: ERect) { @@ -494,10 +517,11 @@ impl LiquifyDialog { return; } let started = crate::gpu_canvas::now_ms(); + let field = self.identity.as_ref().filter(|_| !self.preview).unwrap_or(&self.field); let rendered = if self.native_proxy.is_some() { - render_components(&self.source, self.canvas, &self.field, 1.0, &self.target, bounds, dirty, &mut self.view_out) + render_components(&self.source, self.canvas, field, 1.0, &self.target, bounds, dirty, &mut self.view_out) } else { - ProxyImage::render_full_resolution(&self.source, &self.field, bounds, dirty, &mut self.view_out, &mut self.view_source_scratch) + ProxyImage::render_full_resolution(&self.source, field, bounds, dirty, &mut self.view_out, &mut self.view_source_scratch) }; if !rendered { self.view_bounds = None; @@ -651,12 +675,19 @@ fn open_on( dab_ms: 0.0, preview_ms: 0.0, smart_filter, + preview: true, + identity: None, }; // A sensible starting brush (about a tenth of the image), then the settings last used, so // Liquify opens as it was left (#418). Saved values are clamped; a bad one is skipped. d.opts.size = ((canvas.width().max(canvas.height()) as f32) / 10.0).round().clamp(10.0, 1500.0); if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) { - let _ = d.opts.apply(saved); + // Older versions had a Twirl Counterclockwise tool; its saved choice is Twirl Clockwise now. + let mut saved = saved.clone(); + if saved.get("tool").and_then(Value::as_str) == Some("twirlCcw") { + saved["tool"] = json!("twirlCw"); + } + let _ = d.opts.apply(&saved); } // Layer ids belong to one document: a remembered backdrop Use starts at All Layers. d.opts.backdrop_layer = None; @@ -720,6 +751,9 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { applied?; let num = |k: &str| ui.get(k).and_then(Value::as_f64).map(|v| v as f32); let flag = |k: &str| ui.get(k).and_then(Value::as_bool); + if let Some(on) = flag("preview") { + d.set_preview(on); + } if flag("undo") == Some(true) { d.undo(); } @@ -839,6 +873,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { _ => 1.0, }; } + // P toggles Preview, as in Photoshop (#7905). + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::P)) { + let on = !d.preview; + d.set_preview(on); + } // Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active. if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { d.opts.show_mask = !d.opts.show_mask; @@ -1078,7 +1117,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let right = ERect::from_min_size(pos2(body.right() - RIGHT_W, body.top()), vec2(RIGHT_W, body.height())); painter.rect_filled(right, 0.0, t.dock); painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator)); - let mut props = ui.new_child(egui::UiBuilder::new().max_rect(right.shrink2(vec2(14.0, 12.0)))); + // The properties scroll above the Preview row and the buttons. + let props_rect = ERect::from_min_max(right.min + vec2(14.0, 12.0), pos2(right.right() - 14.0, (right.bottom() - 84.0).max(right.top() + 12.0))); + let mut props = ui.new_child(egui::UiBuilder::new().max_rect(props_rect)); egui::ScrollArea::vertical().id_salt("liquify-props").show(&mut props, |ui| { ui.spacing_mut().item_spacing.y = 6.0; widgets::section_label(ui, tl!("Brush Tool Options")); @@ -1153,6 +1194,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { .size(11.0), ); }); + // Preview, centred above OK and Cancel as in Photoshop (#7905). + let label_w = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Preview").to_owned(), egui::TextStyle::Body.resolve(ui.style()), t.text).size().x); + let row_w = (label_w + 30.0).min(RIGHT_W - 28.0); + let preview_row = ERect::from_center_size(pos2(right.center().x, right.bottom() - 64.0), vec2(row_w, 24.0)); + let mut pr = ui.new_child(egui::UiBuilder::new().max_rect(preview_row).layout(egui::Layout::left_to_right(egui::Align::Center))); + let mut preview = d.preview; + if widgets::checkbox(&mut pr, &mut preview, tl!("Preview")).changed() { + d.set_preview(preview); + } // Footer buttons. let foot = ERect::from_min_size(pos2(right.left() + 14.0, right.bottom() - 48.0), vec2(RIGHT_W - 28.0, 32.0)); let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); diff --git a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs index 546cd00f91..533494a6ea 100644 --- a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs @@ -199,8 +199,9 @@ fn brush_settings_are_remembered_between_uses() { } /// The reference application's Liquify has Twirl Clockwise only: no Twirl Counterclockwise -/// tool on the strip or a shortcut, and a script or saved setting naming it is refused with a -/// clear error instead of selecting it (Alt with Twirl Clockwise twirls counterclockwise). +/// tool on the strip or a shortcut. A script naming it is refused with a clear error (Alt with +/// Twirl Clockwise twirls counterclockwise); a setting saved by an older version opens on Twirl +/// Clockwise. #[test] fn the_tool_strip_has_no_twirl_counterclockwise() { assert!(!LiquifyTool::ALL.contains(&LiquifyTool::TwirlCcw)); @@ -218,12 +219,18 @@ fn the_tool_strip_has_no_twirl_counterclockwise() { let err = control(&mut app, &json!({"tool": "twirlCcw"})).unwrap_err(); assert!(err.contains("twirlCw") && err.contains("Alt"), "{err}"); assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays"); - // A preference saved by an older version that names it opens, and on another tool. + // Actions that are not tools are refused too, without the Alt hint that fits twirlCcw only. + for action in ["reconstructAll", "freezeAll", "thawAll", "invertFreeze"] { + let err = control(&mut app, &json!({ "tool": action })).unwrap_err(); + assert!(err.contains("not a Liquify tool") && !err.contains("Alt"), "{action}: {err}"); + } + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays"); + // A preference saved by an older version that names it opens on Twirl Clockwise. cancel(&mut app); app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"tool": "twirlCcw", "density": 70}))); open(&mut app, &ctx).unwrap(); let o = &app.distort.liquify.as_ref().unwrap().opts; - assert_ne!(o.tool, LiquifyTool::TwirlCcw); + assert_eq!(o.tool, LiquifyTool::TwirlCw, "the saved twirl keeps its tool"); assert_eq!(o.density, 70.0, "the rest of the saved settings still apply"); } @@ -1017,3 +1024,131 @@ fn workspaces_open_on_a_smart_object_with_its_filter_mask_targeted() { let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { panic!("not a smart object") }; assert_eq!(sm.smart_filters.iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", photocraft_engine::distort_cmds::LIQUIFY]); } + +/// Preview off shows the image unwarped while strokes still change the field, and P turns it back +/// on, as Photoshop's Liquify Preview checkbox does (#7905). Each opening starts with it on. +#[test] +fn preview_off_shows_the_original_and_p_toggles_it() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let render = |d: &LiquifyDialog, field: &LiquifyField| { + let mut out = vec![[0u8; 4]; d.proxy.w * d.proxy.h]; + d.proxy.render(field, [0, 0, d.proxy.w, d.proxy.h], &mut out); + out + }; + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.preview && d.describe()["preview"] == json!(true), "Liquify opens with Preview on"); + let original = render(d, &LiquifyField::new(d.canvas, d.cell)); + let stroke = |app: &mut PhotocraftApp, y: f64| { + for ev in [ToolEvent::Down { x: 35.0, y, pressure: 1.0 }, ToolEvent::Move { x: 60.0, y, pressure: 1.0 }, ToolEvent::Up { x: 60.0, y }] { + pointer(app, ev, egui::Modifiers::NONE); + } + }; + control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap(); + stroke(&mut app, 40.0); + let d = app.distort.liquify.as_ref().unwrap(); + assert_ne!(d.out, original, "the warp shows"); + + let r = control(&mut app, &json!({"preview": false})).unwrap(); + assert_eq!(r["preview"], json!(false)); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.out, original, "Preview off: the image as it was"); + // A stroke with Preview off still warps the field, unseen. + stroke(&mut app, 30.0); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), 2); + assert_eq!(d.out, original, "still unwarped while Preview is off"); + { + let d = app.distort.liquify.as_mut().unwrap(); + d.zoom = 2.0; + d.view_bounds = Some(Rect::new(0, 0, 20, 20)); + d.view_dirty = None; + } + + // P turns it back on, with both strokes. + let mut input = egui::RawInput::default(); + input.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + ctx.begin_pass(input); + keys(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.preview); + assert_eq!(d.out, render(d, &d.field), "Preview on: the warp of both strokes"); + assert_eq!(d.view_dirty, d.view_bounds, "the zoomed preview is redrawn too"); +} + +/// A forward-warp stroke across the test layer, at row `y`. +fn warp_stroke(app: &mut PhotocraftApp, y: f64) { + for ev in [ToolEvent::Down { x: 35.0, y, pressure: 1.0 }, ToolEvent::Move { x: 60.0, y, pressure: 1.0 }, ToolEvent::Up { x: 60.0, y }] { + pointer(app, ev, egui::Modifiers::NONE); + } +} + +/// The active layer's pixels over the whole document. +fn layer_pixels(app: &PhotocraftApp) -> Vec { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 120, 80)) +} + +/// With Preview off, a stroke changes the displacement field but the display stays the original +/// image (#7905). +#[test] +fn a_stroke_with_preview_off_changes_the_field_but_not_the_display() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 40, "preview": false})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + let (field_before, shown_before) = (d.field.clone(), d.out.clone()); + assert_eq!(d.field.max_displacement(), 0.0); + warp_stroke(&mut app, 40.0); + let d = app.distort.liquify.as_ref().unwrap(); + assert_ne!(d.field, field_before, "the stroke changes the field"); + assert!(d.field.max_displacement() > 3.0); + assert_eq!(d.out, shown_before, "the display does not change"); +} + +/// OK with Preview off applies the warp: the layer ends up exactly as it does when the same stroke +/// is committed with Preview on. +#[test] +fn ok_with_preview_off_applies_the_warp() { + let ctx = egui::Context::default(); + let commit_with = |preview: bool| { + let mut app = app_with_layer(); + let before = layer_pixels(&app); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 40, "preview": preview})).unwrap(); + warp_stroke(&mut app, 40.0); + let past = app.session.active().unwrap().history.past_len(); + control(&mut app, &json!({"commit": true})).unwrap(); + assert!(app.distort.liquify.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1, "one Liquify step"); + (before, layer_pixels(&app)) + }; + let (before, unpreviewed) = commit_with(false); + assert_ne!(unpreviewed, before, "OK with Preview off warps the layer"); + assert_eq!(unpreviewed, commit_with(true).1, "the same warp as with Preview on"); +} + +/// The identity field (as large as the image's field) exists only while Preview is off: turning +/// Preview back on frees it, and closing the dialog drops it. +#[test] +fn the_identity_field_exists_only_while_preview_is_off() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let identity = |app: &PhotocraftApp| app.distort.liquify.as_ref().unwrap().identity.is_some(); + assert!(!identity(&app), "not made while Preview is on"); + control(&mut app, &json!({"preview": false})).unwrap(); + assert!(identity(&app)); + control(&mut app, &json!({"preview": true})).unwrap(); + assert!(!identity(&app), "freed when Preview is turned back on"); + control(&mut app, &json!({"preview": false})).unwrap(); + assert!(identity(&app), "made again when Preview is turned off again"); + control(&mut app, &json!({"cancel": true})).unwrap(); + assert!(app.distort.liquify.is_none(), "closing drops the dialog and its identity field"); + open(&mut app, &ctx).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.preview && d.identity.is_none(), "a new opening starts with Preview on and no identity field"); +} diff --git a/crates/ui-egui/src/dialogs/merge_channels_ui.rs b/crates/ui-egui/src/dialogs/merge_channels_ui.rs new file mode 100644 index 0000000000..c7d97512a0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/merge_channels_ui.rs @@ -0,0 +1,365 @@ +//! Channels panel › Merge Channels…: the mode (RGB, CMYK or Lab Color) and, for each of its +//! channels, the open grayscale document that becomes it. Photoshop asks in two steps (the mode, +//! then the documents); here both are one dialog. Only the modes the open documents can fill are +//! offered (RGB and Lab need three, CMYK four), OK waits for a different document per channel, +//! and a merge that still fails opens the dialog again with the reason. OK runs the engine's +//! `channel.merge`, which closes the sources; agents call it directly with params (#11451). +//! +//! The dialog names documents by their [`photocraft_doc::DocId`], not their tab index: work that +//! runs while it is open (a script moving or closing a document) can shift the indices, and OK +//! must merge, and close, the documents that were picked. They are looked up again at OK. + +use photocraft_doc::ColorMode; +use photocraft_engine::channel_cmds; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::theme::Tokens; + +pub const COMMAND: &str = "channel.merge"; +const MARK: &str = "__mergeChannels"; +/// The most channels a mode has (CMYK). +const SLOTS: usize = 4; +const MODES: [(&str, &str); 3] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color"), ("lab", "Lab Color")]; +const TOO_FEW: &str = "Not enough grayscale documents for this mode."; +const REPEATED: &str = "Pick a different document for each channel."; +const CLOSED: &str = "A picked document is no longer open."; +const CHANGED: &str = "A picked document is no longer a grayscale document of the same size."; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +fn mode_of(key: &str) -> ColorMode { + match key { + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => ColorMode::Rgb, + } +} + +/// The modes `count` documents can fill, in the menu's order. +fn modes(count: usize) -> Vec<(String, &'static str)> { + MODES.iter().filter(|(key, _)| mode_of(key).color_channels() <= count).map(|(key, label)| (key.to_string(), *label)).collect() +} + +/// The documents that can be merged, by document id and name: the open grayscale ones of the +/// active document's size, the active one first, as the engine picks them without params. +fn sources(app: &PhotocraftApp) -> Vec<(u64, String)> { + let Some(active) = app.session.active() else { return Vec::new() }; + let docs = app.session.documents(); + let fits = |i: usize| docs.get(i).filter(|d| d.doc.mode == ColorMode::Grayscale && d.doc.size == active.doc.size); + let current = app.session.active_index(); + let others = (0..docs.len()).filter(|i| Some(*i) != current); + current.into_iter().chain(others).filter_map(|i| fits(i).map(|d| (d.doc.id.0, d.doc.name.clone()))).collect() +} + +fn mode_field(f: &Map) -> &str { + f.get("mode").and_then(Value::as_str).unwrap_or("rgb") +} + +fn sources_field(f: &Map) -> Vec<(u64, String)> { + f.get("__sources").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() +} + +/// The picked document ids, one per channel of the mode. +fn picks(f: &Map) -> Vec { + let all: Vec = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + all.into_iter().take(mode_of(mode_field(f)).color_channels()).collect() +} + +/// The dialog's fields: the first mode the documents can fill (RGB Color from three) and the +/// merge-able documents in order, one per channel, or `None` without a document. +pub fn fields(app: &PhotocraftApp) -> Option> { + app.session.active()?; + let sources = sources(app); + let picks: Vec = sources.iter().take(SLOTS).map(|(id, _)| *id).collect(); + let mode = modes(sources.len()).first().map_or_else(|| "rgb".to_string(), |(key, _)| key.clone()); + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Merge Channels…")); + f.insert("__sources".into(), json!(sources)); + f.insert("mode".into(), json!(mode)); + f.insert("documents".into(), json!(picks)); + Some(f) +} + +/// Opens Merge Channels, or `None` without a document. +pub fn open(app: &mut PhotocraftApp) -> Option { + let f = fields(app)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + +/// Why OK is greyed (English, for `tl!`): the mode needs more documents than are offered, or the +/// picks repeat a document or name one the dialog did not offer. `None` when OK can merge, and +/// for another dialog's fields. +pub fn problem(f: &Map) -> Option<&'static str> { + if !owns(f) { + return None; + } + let n = mode_of(mode_field(f)).color_channels(); + let sources = sources_field(f); + let picks = picks(f); + if sources.len() < n || picks.len() < n { + return Some(TOO_FEW); + } + let mut seen = std::collections::HashSet::new(); + let offered = |p: &u64| sources.iter().any(|(id, _)| id == p); + if picks.iter().any(|p| !seen.insert(*p) || !offered(p)) { + return Some(REPEATED); + } + None +} + +/// Whether OK is live: always for other dialogs, here while [`problem`] finds none. +pub fn can_confirm(f: &Map) -> bool { + problem(f).is_none() +} + +/// The picked documents' current tab indices, looked up by id; refused (English, for `tl!`) +/// when one was closed, or is no longer a grayscale document of the others' size. +fn resolve(app: &PhotocraftApp, f: &Map) -> Result, &'static str> { + if let Some(why) = problem(f) { + return Err(why); + } + let docs = app.session.documents(); + let indices = picks(f).iter().map(|id| docs.iter().position(|d| d.doc.id.0 == *id).ok_or(CLOSED)).collect::, _>>()?; + let first = indices.first().and_then(|&i| docs.get(i)).map(|d| d.doc.size); + let fits = |i: &usize| docs.get(*i).is_some_and(|d| d.doc.mode == ColorMode::Grayscale && Some(d.doc.size) == first); + if indices.iter().all(fits) { Ok(indices) } else { Err(CHANGED) } +} + +/// Checked before the dialog closes (`dialogs::confirm_preflight`), so a refused OK keeps it +/// open with the reason under the pickers: too few or repeated documents, or a picked one that +/// was closed or changed while the dialog was open. +pub fn preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { + let Some(f) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| &d.fields).filter(|f| owns(f)) else { return Ok(()) }; + let Err(why) = resolve(app, f) else { return Ok(()) }; + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.insert("__error".into(), json!(why)); + } + Err(why.to_string()) +} + +/// The `channel.merge` params for the dialog's fields: the mode and the picked documents' current +/// indices, one per channel. +pub fn params(app: &PhotocraftApp, f: &Map) -> Result { + let documents = resolve(app, f).map_err(str::to_string)?; + Ok(json!({ "mode": mode_field(f), "documents": documents })) +} + +/// Runs the merge. Should it still be refused, the dialog opens again with its picks and the +/// reason, rather than going away. +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let result = params(app, f).and_then(|p| app.run(COMMAND, p)); + if let Err(e) = &result { + let mut again = f.clone(); + again.insert("__error".into(), json!(e)); + app.ui.open_dialog(crate::state::DialogKind::Command, again); + } + result +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + let sources = sources_field(f); + let mut mode = mode_field(f).to_string(); + let modes = modes(sources.len()); + ui.horizontal(|ui| { + ui.label(tl!("Mode:")); + if crate::widgets::dropdown(ui, "merge-channels-mode", tl!("Mode:"), &mut mode, &modes, 160.0) { + f.insert("mode".into(), json!(mode)); + f.remove("__error"); + } + }); + crate::widgets::section_label(ui, tl!("Specify Channels:")); + crate::widgets::hairline(ui); + let options: Vec<(u64, &str)> = sources.iter().map(|(id, name)| (*id, name.as_str())).collect(); + let mut picks: Vec = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let mut changed = false; + for (k, channel) in channel_cmds::color_names(mode_of(&mode)).iter().enumerate() { + let Some(pick) = picks.get_mut(k) else { break }; + ui.horizontal(|ui| { + let caption = format!("{}:", tl!(channel)); + ui.add_sized([84.0, 22.0], egui::Label::new(&caption)); + changed |= crate::widgets::dropdown_literal(ui, &format!("merge-channels-{k}"), &caption, pick, &options, 200.0); + }); + } + if changed { + f.insert("documents".into(), json!(picks)); + f.remove("__error"); + } + // Why OK is greyed, or why the last OK was refused (the dialog's reasons are catalogued; the + // engine's own are shown as they come). + let why: Option<&str> = problem(f); + let why = why.or_else(|| f.get("__error").and_then(Value::as_str)).map(|w| tl!(w).to_string()); + if let Some(why) = why { + ui.label(egui::RichText::new(why).color(Tokens::get(ui.ctx()).danger).size(11.0)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app(docs: usize) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for _ in 0..docs { + app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + } + app + } + + fn id(app: &PhotocraftApp, index: usize) -> u64 { + app.session.documents()[index].doc.id.0 + } + + fn offered(f: &Map) -> Vec { + modes(sources_field(f).len()).into_iter().map(|(key, _)| key).collect() + } + + /// The merged document's first pixel as 8-bit RGB. + fn merged_pixel(app: &PhotocraftApp) -> [u8; 3] { + let doc = &app.session.active().unwrap().doc; + let px = photocraft_compose::render(doc, doc.bounds()).px[0]; + [px[0], px[1], px[2]].map(|v| (v * 255.0).round() as u8) + } + + /// Three grayscale documents filled black, mid gray and white, then the active one is `active`. + fn shaded(active: usize) -> PhotocraftApp { + let mut app = app(3); + for (i, gray) in ["#000000", "#808080", "#ffffff"].into_iter().enumerate() { + app.session.set_active(i); + app.run("edit.fill", json!({"color": gray})).unwrap(); + } + app.session.set_active(active); + app + } + + #[test] + fn fields_list_the_sources_by_id_and_params_take_their_indices() { + let mut app = app(3); + app.run("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap(); + app.run("file.new", json!({"width": 8, "height": 8, "mode": "rgb"})).unwrap(); + app.session.set_active(2); + let f = fields(&app).unwrap(); + let ids = [id(&app, 2), id(&app, 0), id(&app, 1)]; + assert_eq!(f["__sources"].as_array().unwrap().iter().map(|s| s[0].as_u64().unwrap()).collect::>(), ids, "the active one first"); + assert_eq!((f["mode"].clone(), f["documents"].clone()), (json!("rgb"), json!(ids)), "one each, none repeated"); + assert_eq!(params(&app, &f).unwrap(), json!({"mode": "rgb", "documents": [2, 0, 1]})); + assert!(fields(&PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())).is_none()); + } + + /// Three documents fill RGB and Lab, not CMYK; four fill CMYK too. + #[test] + fn only_the_modes_the_documents_fill_are_offered() { + let f = fields(&app(3)).unwrap(); + assert_eq!(offered(&f), ["rgb", "lab"]); + assert!(can_confirm(&f)); + let mut cmyk = f.clone(); + cmyk.insert("mode".into(), json!("cmyk")); + assert_eq!(problem(&cmyk), Some(TOO_FEW), "CMYK from three documents (as automation could ask) greys OK"); + let app = app(4); + let f = fields(&app).unwrap(); + assert_eq!(offered(&f), ["rgb", "cmyk", "lab"]); + let mut cmyk = f.clone(); + cmyk.insert("mode".into(), json!("cmyk")); + assert!(can_confirm(&cmyk)); + assert_eq!(params(&app, &cmyk).unwrap()["documents"], json!([3, 0, 1, 2]), "the active (last opened) one first"); + } + + /// A document picked for two channels greys OK with the reason; a refused OK keeps the dialog + /// open and merges nothing. + #[test] + fn repeated_picks_grey_ok_and_keep_the_dialog_open() { + let mut app = app(3); + let (a, b) = (id(&app, 0), id(&app, 1)); + let id = open(&mut app).unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("documents".into(), json!([a, a, b])); + assert_eq!(problem(&d.fields), Some(REPEATED)); + assert!(!can_confirm(&d.fields)); + d.fields.insert("documents".into(), json!([a, b, 999_999])); + assert_eq!(problem(&d.fields), Some(REPEATED), "a document the dialog did not offer"); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(app.ui.dialogs.iter().any(|d| d.id == id), "the dialog stays open"); + assert_eq!(app.session.documents().len(), 3, "nothing merged or closed"); + assert!(can_confirm(&Map::new()), "other dialogs are not affected"); + } + + /// Documents moved while the dialog is open: OK still merges the ones picked, by id, into the + /// channels picked for them. + #[test] + fn ok_merges_the_picked_documents_after_a_reorder() { + let mut app = shaded(0); + let (black, gray, white) = (id(&app, 0), id(&app, 1), id(&app, 2)); + let dialog = open(&mut app).unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!([black, white, gray])); + // A script reorders the tabs while the dialog is open: white first, black last. + app.session.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + app.session.execute("document.move", json!({"document": 1, "to": 2})).unwrap(); + assert_eq!([id(&app, 0), id(&app, 1), id(&app, 2)], [white, gray, black]); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!((app.session.documents().len(), app.session.active().unwrap().doc.mode), (1, ColorMode::Rgb)); + assert_eq!(merged_pixel(&app), [0, 255, 128], "red, green and blue from the documents picked, not their old tabs"); + } + + /// A picked document closed while the dialog is open: OK refuses with the reason, keeps the + /// dialog and closes nothing, even when another same-size grayscale document took its tab. + #[test] + fn a_closed_pick_is_refused_and_no_other_document_merges() { + let mut app = shaded(0); + app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + app.session.set_active(0); + let spare = id(&app, 3); + let dialog = open(&mut app).unwrap(); + let picked: Vec = (0..3).map(|i| id(&app, i)).collect(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!(picked)); + app.session.execute("file.close", json!({"document": 1})).unwrap(); + assert_eq!(id(&app, 2), spare, "the spare document is where a picked one was"); + let why = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert_eq!(why, CLOSED); + let d = app.ui.dialogs.iter().find(|d| d.id == dialog).expect("the dialog stays open"); + assert_eq!(d.fields["__error"], json!(CLOSED), "with the reason under the pickers"); + assert_eq!(app.session.documents().len(), 3, "nothing merged or closed"); + assert!(app.session.documents().iter().any(|d| d.doc.id.0 == spare)); + } + + /// A picked document turned into another mode while the dialog is open is refused too; should + /// the engine refuse a merge after OK, the dialog comes back with the reason. + #[test] + fn a_changed_pick_or_a_refused_merge_keeps_the_dialog() { + let mut app = app(3); + let dialog = open(&mut app).unwrap(); + let f = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + app.run("image.mode.rgb", json!({})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app, dialog).unwrap_err(), CHANGED); + assert!(app.ui.dialogs.iter().any(|d| d.id == dialog)); + app.ui.dialogs.clear(); + assert!(confirm(&mut app, &f).is_err()); + let again = &app.ui.dialogs.last().expect("the dialog again").fields; + assert!(owns(again) && again["__error"].is_string()); + assert_eq!(app.session.documents().len(), 3); + } + + #[test] + fn body_renders_every_mode_and_the_reason() { + let app = app(4); + let ids: Vec = (0..4).map(|i| id(&app, i)).collect(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (mode, docs, error) in + [("rgb", json!(ids[..3]), None), ("cmyk", json!([ids[0], ids[1], ids[2], ids[2]]), None), ("lab", json!(ids[1..]), Some(CLOSED))] + { + let mut f = fields(&app).unwrap(); + f.insert("mode".into(), json!(mode)); + f.insert("documents".into(), docs); + if let Some(e) = error { + f.insert("__error".into(), json!(e)); + } + ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear(); + assert!(owns(&f)); + } + } +} diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index 8657576b57..e9a3406b86 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -53,6 +53,7 @@ pub(crate) fn color_label(name: &str) -> &'static str { } } +/// Accept the named guide colours and the explicit custom-colour choice. fn is_color_name(name: &str) -> bool { name == CUSTOM || GUIDE_COLORS.iter().any(|(n, ..)| *n == name) } @@ -65,10 +66,12 @@ fn custom_color(f: &Map) -> String { .map_or_else(|| "#4affff".to_string(), str::to_string) } +/// Identify the specialized New Guide or Edit Selected Guides dialog fields. pub fn owns(fields: &Map) -> bool { fields.contains_key(MARK) } +/// Read the ruler unit used for guide positions without an explicit suffix. fn ruler_unit(app: &PhotocraftApp) -> Unit { app.session.prefs().units_and_rulers.rulers } @@ -79,6 +82,34 @@ fn with_unit(value: &str, unit: Unit) -> String { format!("{value} {}", unit.suffix()) } +/// `typed` with `unit` after its first number when that number has none ("300 / 2" with pixel +/// rulers → "300 px / 2"); anything else as typed. Nor when a length later in its term makes it +/// a plain factor ("2 / 2 * 50 px": the term is in px already). +fn unit_on_first_number(typed: &str, unit: Unit) -> String { + let end = typed + .char_indices() + .find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+')))) + .map_or(typed.len(), |(i, _)| i); + let (number, rest) = typed.split_at(end); + let bare = !number.is_empty() && rest.trim_start().starts_with(['+', '-', '*', '/']) && !factor_has_unit(rest); + if bare { format!("{} {}{rest}", number, unit.suffix()) } else { typed.to_string() } +} + +/// Whether an operand multiplied or divided in at the start of `rest` (up to the first `+` or `-` +/// between operands) carries a unit, as the engine splits operands. +fn factor_has_unit(mut rest: &str) -> bool { + while let Some(operand) = rest.trim_start().strip_prefix(['*', '/']).map(str::trim_start) { + // The operand runs to the next operator; a sign at its start is its own. + let end = operand.char_indices().find(|&(i, c)| i > 0 && matches!(c, '+' | '-' | '*' | '/')).map_or(operand.len(), |(i, _)| i); + let (operand, after) = operand.split_at(end); + if Unit::parse_length(operand).is_some_and(|(_, u)| u.is_some()) { + return true; + } + rest = after; + } + false +} + /// `px` (document pixels) in the ruler unit, as the field shows it: "14.11 cm". Percent is of the /// active document's width for a vertical guide and its height for a horizontal one. fn in_ruler_unit(app: &PhotocraftApp, px: f64, vertical: bool) -> String { @@ -145,9 +176,15 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result { f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() })); f.insert("color".into(), json!(color)); f.insert("customColor".into(), json!(custom)); - Ok(app.ui.open_dialog(DialogKind::Command, f)) + let id = app.ui.open_dialog(DialogKind::Command, f.clone()); + if let Err(error) = crate::guide_edit::begin(app, id, &f) { + app.ui.close_dialog(id); + return Err(error); + } + Ok(id) } +/// Edit the dialog fields and show validation errors without changing the live guide. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string(); let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string(); @@ -164,7 +201,17 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { }); ui.end_row(); let position_label = ui.label(tl!("Position")); - ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); + let response = ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); + if ui.is_enabled() { + crate::field_tab::register(ui.ctx(), response.id); + } + if response.gained_focus() { + crate::field_tab::select_all(ui.ctx(), response.id, &position); + } + if response.changed() && f.contains_key(EDIT) { + // A deliberately retyped rounded readout is a new position, not the untouched one. + f.insert("__positionEdited".into(), json!(true)); + } ui.end_row(); ui.label(tl!("Color")); ui.horizontal(|ui| { @@ -193,10 +240,14 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { /// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". pub fn params(app: &PhotocraftApp, f: &Map) -> Value { let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); - let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); - let position = match Unit::parse_length(typed) { - Some((_, None)) => with_unit(typed, ruler_unit(app)), - _ => typed.to_string(), + // A Unicode minus (U+2212) is a minus, as the engine reads it. + let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim().replace('−', "-"); + let position = match Unit::parse_length(&typed) { + Some((_, None)) => with_unit(&typed, ruler_unit(app)), + Some(_) => typed, + // Arithmetic (#10440): a first number without a unit is in the ruler unit, as in + // Photoshop ("300 / 2" is 150 px with pixel rulers); the engine evaluates the rest. + None => unit_on_first_number(&typed, ruler_unit(app)), }; let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; @@ -212,10 +263,11 @@ pub fn params(app: &PhotocraftApp, f: &Map) -> Value { } } +/// Dispatch New Guide or guarded guide editing, retaining typed fields on a retryable error. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let params = params(app, f); if f.contains_key(EDIT) { - return confirm_edit(app, f, ¶ms); + return confirm_edit(app, f); } match app.run(COMMAND, params.clone()) { Ok(result) => { @@ -244,28 +296,66 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, params: &Value) -> Result { - match app.run(EDIT_COMMAND, params.clone()) { - Ok(result) => { - let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical"); - let index = if from == to { - params["index"].as_u64().and_then(|i| usize::try_from(i).ok()) +fn confirm_edit(app: &mut PhotocraftApp, f: &Map) -> Result { + let (params, result) = match crate::guide_edit::finish(app, f) { + Ok(Some(applied)) => applied, + Ok(None) => return Ok(Value::Null), + Err(error) => { + if crate::guide_edit::retryable(app) { + let mut again = f.clone(); + again.insert("__error".into(), json!(error)); + let id = app.ui.open_dialog(DialogKind::Command, again); + if let Some(draft) = app.guide_edit.as_mut() { + draft.dialog = id; + } } else { - app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1)) - }; - if let Some(i) = index { - crate::guide_select::select(app, to, i); + app.guide_edit = None; } - Ok(result) - } - Err(e) => { - let mut again = f.clone(); - again.insert("__error".into(), json!(e)); - app.ui.open_dialog(DialogKind::Command, again); - Err(e) + return Err(error); } + }; + let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical"); + let index = if from == to { + params["index"].as_u64().and_then(|i| usize::try_from(i).ok()) + } else { + app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1)) + }; + if let Some(i) = index { + crate::guide_select::select(app, to, i); } + Ok(result) +} + +/// The validated draft shown on the canvas, without changing the document or its history. +pub fn edit_preview<'a>(app: &'a PhotocraftApp, doc: &photocraft_doc::Document) -> Option<&'a photocraft_doc::Guides> { + crate::guide_edit::preview(app, doc) } #[cfg(test)] mod tests; + +#[cfg(test)] +mod arithmetic_tests { + use super::{Unit, unit_on_first_number}; + + /// #10440: a first number without a unit is in the ruler unit, the rest goes to the engine. + #[test] + fn a_leading_bare_number_gets_the_ruler_unit() { + assert_eq!(unit_on_first_number("300 / 2", Unit::Pixels), "300 px / 2"); + assert_eq!(unit_on_first_number("3+1in", Unit::Centimeters), "3 cm+1in"); + assert_eq!(unit_on_first_number("100% / 3", Unit::Pixels), "100% / 3"); + assert_eq!(unit_on_first_number("abc", Unit::Pixels), "abc"); + } + + /// A bare number multiplied or divided within a term that has a length is a plain factor: + /// the length is the term's unit, so the ruler unit isn't added (it would make a length + /// times a length). A term of bare numbers still gets it. + #[test] + fn a_leading_factor_of_a_length_gets_no_ruler_unit() { + for typed in ["2 * 50 px", "2 / 2 * 50 px", "2*50px+10", "4 / 2 * 1in - 3", "2 * -1cm"] { + assert_eq!(unit_on_first_number(typed, Unit::Centimeters), typed); + } + assert_eq!(unit_on_first_number("2 * 3 + 1in", Unit::Centimeters), "2 cm * 3 + 1in"); + assert_eq!(unit_on_first_number("6 / 2 / 3 - 1", Unit::Centimeters), "6 cm / 2 / 3 - 1"); + } +} diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index 4bf4fa80d6..5571641527 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -12,21 +12,25 @@ fn app(unit: &str) -> (PhotocraftApp, egui::Context) { (app, egui::Context::default()) } +/// Open New Guide through the same menu entry used by the shell. fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> u64 { menu(app, ctx, "view.newGuide", json!({})).unwrap()["dialog"].as_u64().unwrap() } +/// Change the typed orientation and position without confirming the dialog. fn set(app: &mut PhotocraftApp, d: u64, orientation: &str, position: &str) { let f = &mut app.ui.dialog_mut(d).unwrap().fields; f.insert("orientation".into(), json!(orientation)); f.insert("position".into(), json!(position)); } +/// Read committed guide positions, excluding any local draft preview. fn guides(app: &PhotocraftApp) -> (Vec, Vec) { let g = &app.session.active().unwrap().doc.guides; (g.horizontal.clone(), g.vertical.clone()) } +/// Guide positions accept explicit units and reopen in the current ruler unit. #[test] fn opens_in_the_ruler_unit_and_takes_photoshop_units() { let (mut app, ctx) = app("cm"); @@ -53,6 +57,7 @@ fn opens_in_the_ruler_unit_and_takes_photoshop_units() { assert_eq!(guides(&app).0, vec![100.0, 50.0]); } +/// Bare positions use percent when the ruler unit is percent. #[test] fn percent_rulers_open_at_zero_percent() { let (mut app, ctx) = app("percent"); @@ -63,6 +68,7 @@ fn percent_rulers_open_at_zero_percent() { assert_eq!(guides(&app).0, vec![50.0], "25% of the 200 px height"); } +/// Invalid New Guide input is retained without recording a guide or preferences. #[test] fn a_refused_position_reopens_the_dialog_without_a_guide() { let (mut app, ctx) = app("pixels"); @@ -79,6 +85,7 @@ fn a_refused_position_reopens_the_dialog_without_a_guide() { assert!(!app.session.prefs().dialogs.contains_key("view.newGuide")); } +/// Invalid saved orientation and position fall back to a usable new-guide dialog. #[test] fn remembered_values_that_no_longer_parse_are_ignored() { let (mut app, ctx) = app("mm"); @@ -88,10 +95,12 @@ fn remembered_values_that_no_longer_parse_are_ignored() { assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("horizontal"), Some("0 mm"))); } +/// Read the colour stored on a committed guide. fn color_of(app: &PhotocraftApp, vertical: bool, position: f64) -> Option<[u8; 3]> { app.session.active().unwrap().doc.guides.color(vertical, position) } +/// Named and custom guide colours persist on the guide and in dialog preferences. #[test] fn guides_store_the_chosen_colour_cyan_by_default() { let (mut app, ctx) = app("pixels"); @@ -120,6 +129,7 @@ fn guides_store_the_chosen_colour_cyan_by_default() { assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("custom"), Some("#abcdef"))); } +/// Invalid saved colours fall back to the default cyan choice. #[test] fn remembered_colours_that_are_not_on_the_list_are_ignored() { let (mut app, ctx) = app("pixels"); @@ -129,6 +139,7 @@ fn remembered_colours_that_are_not_on_the_list_are_ignored() { assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("cyan"), Some("#4affff"))); } +/// A remembered pixel position is displayed in each current ruler unit. #[test] fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() { let (mut app, ctx) = app("pixels"); @@ -142,3 +153,311 @@ fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() { crate::dialogs::cancel(&mut app, d).unwrap(); } } + +#[test] +fn position_tab_and_shift_tab_select_all_in_new_and_edit_dialogs() { + use egui_kittest::{Harness, kittest::Queryable}; + + for editing in [false, true] { + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::SHIFT] { + let (mut app, ctx) = app("pixels"); + if editing { + app.run(COMMAND, json!({"orientation": "vertical", "position": 12.5})).unwrap(); + crate::guide_select::select(&mut app, true, 0); + } + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = if editing { open_edit(h.state_mut()).unwrap() } else { open(h.state_mut(), &ctx) }; + h.run_steps(3); + h.key_press_modifiers(modifiers, egui::Key::Tab); + h.run_steps(3); + assert!(h.get_by_label("Position").is_focused()); + h.event(egui::Event::Text("1 in".into())); + h.run_steps(3); + assert_eq!(h.state_mut().ui.dialog_mut(d).unwrap().fields["position"], "1 in", "typing replaces the full readout"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(guides(h.state()), if editing { (vec![], vec![100.0]) } else { (vec![100.0], vec![]) }); + } + } +} + +#[test] +fn focusing_and_recolouring_preserve_the_exact_guide_position() { + use egui_kittest::{Harness, kittest::Queryable}; + + for (rulers, original) in [("pixels", 12.5), ("cm", 123.45678901234567), ("percent", -1e-100)] { + for recolour in [false, true] { + let (mut app, _) = app(rulers); + app.run(COMMAND, json!({"orientation": "vertical", "position": original})).unwrap(); + app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": original + 1.0})).unwrap(); + assert!(app.session.undo()); + crate::guide_select::select(&mut app, true, 0); + let history = app.session.active().unwrap().history.past_len(); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = open_edit(h.state_mut()).unwrap(); + if rulers == "pixels" { + assert_eq!(h.state_mut().ui.dialog_mut(d).unwrap().fields["position"], "13 px"); + } + h.run_steps(3); + h.get_by_label("Position").click(); + h.run_steps(3); + if recolour { + h.state_mut().ui.dialog_mut(d).unwrap().fields.insert("color".into(), json!("magenta")); + } + crate::guide_edit::sync(h.state_mut()); + assert_eq!(edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap().vertical, vec![original]); + assert_eq!(guides(h.state()).1, vec![original], "preview must not modify the document"); + crate::dialogs::confirm(h.state_mut(), d).unwrap(); + assert_eq!(guides(h.state()).1, vec![original]); + if recolour { + assert_eq!(color_of(h.state(), true, original), Some([255, 0, 255])); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + assert!(h.state_mut().session.undo()); + assert_eq!(guides(h.state()).1, vec![original]); + } else { + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert!(h.state().session.active().unwrap().history.can_redo(), "focus alone must preserve Redo"); + } + } + } +} + +#[test] +fn retyping_the_rounded_readout_is_an_intentional_position_edit() { + use egui_kittest::{Harness, kittest::Queryable}; + + let (mut app, _) = app("pixels"); + app.run(COMMAND, json!({"orientation": "vertical", "position": 12.5})).unwrap(); + crate::guide_select::select(&mut app, true, 0); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + let d = open_edit(h.state_mut()).unwrap(); + h.run_steps(3); + h.get_by_label("Position").click(); + h.run_steps(3); + h.event(egui::Event::Text("20 px".into())); + h.run_steps(3); + assert_eq!(h.state_mut().ui.dialog_mut(d).unwrap().fields["position"], "20 px", "click focus selects all"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("13 px".into())); + h.run_steps(3); + crate::guide_edit::sync(h.state_mut()); + assert_eq!(edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap().vertical, vec![13.0]); + crate::dialogs::confirm(h.state_mut(), d).unwrap(); + assert_eq!(guides(h.state()).1, vec![13.0]); + assert!(h.state_mut().session.undo()); + assert_eq!(guides(h.state()).1, vec![12.5]); +} + +// These regressions exercise the ordinary dialog handlers, not a separate model. +/// Open a draft on the first of two guides and propose moving it to 30 px. +fn edit_fixture() -> (PhotocraftApp, u64) { + let (mut app, _) = app("pixels"); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + crate::guide_select::select(&mut app, true, 0); + let id = open_edit(&mut app).unwrap(); + set(&mut app, id, "vertical", "30 px"); + (app, id) +} + +/// A document switch rejects the old draft without touching the new document. +#[test] +fn draft_cannot_edit_a_guide_in_another_document() { + let (mut app, id) = edit_fixture(); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(guides(&app).1, vec![10.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +/// Deleting the target invalidates its draft even when another guide reuses the index. +#[test] +fn draft_cannot_edit_the_guide_that_reused_a_deleted_index() { + let (mut app, id) = edit_fixture(); + app.run("view.deleteGuide", json!({"orientation": "vertical", "index": 0})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(guides(&app).1, vec![20.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +/// Confirming unchanged values records no command and retains the redo stack. +#[test] +fn draft_unchanged_confirmation_preserves_redo() { + let (mut app, id) = edit_fixture(); + crate::dialogs::cancel(&mut app, id).unwrap(); + app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 15})).unwrap(); + assert!(app.session.undo()); + crate::guide_select::select(&mut app, true, 0); + let id = open_edit(&mut app).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(app.session.active().unwrap().history.can_redo()); +} + +/// Preview changes stay local; invalid input can be corrected into exactly one commit. +#[test] +fn draft_preview_is_isolated_and_invalid_text_keeps_the_last_good_preview() { + let (mut app, id) = edit_fixture(); + crate::guide_edit::sync(&mut app); + let shown = |app: &PhotocraftApp| edit_preview(app, &app.session.active().unwrap().doc).unwrap().vertical.clone(); + assert_eq!(shown(&app), vec![30.0, 20.0]); + assert_eq!(guides(&app).1, vec![10.0, 20.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), 2); + set(&mut app, id, "vertical", "far"); + crate::guide_edit::sync(&mut app); + assert_eq!(shown(&app), vec![30.0, 20.0]); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + let id = app.ui.dialogs.last().unwrap().id; + assert!(app.ui.dialog_mut(id).unwrap().fields.contains_key("__error")); + set(&mut app, id, "vertical", "40 px"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(guides(&app).1, vec![40.0, 20.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), 3); + assert_eq!(app.session.journal.iter().filter(|(cmd, _)| cmd == "view.editGuide").count(), 1); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); +} + +/// Opening another edit invalidates the earlier dialog; cancellation removes the draft. +#[test] +fn draft_reopening_retires_the_previous_dialog() { + let (mut app, first) = edit_fixture(); + let second = open_edit(&mut app).unwrap(); + assert!(crate::dialogs::confirm(&mut app, first).is_err()); + crate::dialogs::cancel(&mut app, second).unwrap(); + assert!(app.guide_edit.is_none()); + assert_eq!(guides(&app).1, vec![10.0, 20.0]); +} + +/// Locking guides invalidates an open edit before it can record a command. +#[test] +fn draft_cannot_commit_after_guides_are_locked() { + let (mut app, id) = edit_fixture(); + app.ui.extras.lock_guides = true; + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(guides(&app).1, vec![10.0, 20.0]); + assert!(app.guide_edit.is_none()); +} + +/// A late policy refusal retains the typed proposal and history until a successful retry. +#[test] +fn refused_edit_command_preserves_the_draft_for_retry() { + let (mut app, id) = edit_fixture(); + crate::dialogs::cancel(&mut app, id).unwrap(); + app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 15})).unwrap(); + assert!(app.session.undo()); + crate::guide_select::select(&mut app, true, 0); + let id = open_edit(&mut app).unwrap(); + set(&mut app, id, "vertical", "30 px"); + let history = app.session.active().unwrap().history.past_len(); + let revision = app.session.active().unwrap().revision; + app.automation_input = true; + app.services.automation_command = Some(Box::new(|id, _| if id == "view.editGuide" { Err("guide edits are denied".into()) } else { Ok(()) })); + let mut id = id; + for _ in 0..2 { + assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "guide edits are denied"); + assert_eq!(guides(&app).1, vec![10.0, 20.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(app.session.active().unwrap().history.can_redo()); + assert_eq!(app.session.journal.iter().filter(|(cmd, _)| cmd == "view.editGuide").count(), 0); + crate::guide_edit::sync(&mut app); + let again = app.ui.dialogs.iter().find(|d| d.fields.contains_key(EDIT)).expect("retry dialog remains open"); + id = again.id; + assert_eq!(again.fields["position"], "30 px"); + assert_eq!(again.fields["orientation"], "vertical"); + assert_eq!(again.fields["__error"], "guide edits are denied"); + assert_eq!(edit_preview(&app, &app.session.active().unwrap().doc).unwrap().vertical, vec![30.0, 20.0]); + } + app.services.automation_command = None; + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(guides(&app).1, vec![30.0, 20.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); + assert_eq!(app.session.journal.iter().filter(|(cmd, _)| cmd == "view.editGuide").count(), 1); + assert!(app.guide_edit.is_none()); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(app.session.undo()); + assert_eq!(guides(&app).1, vec![10.0, 20.0]); + assert!(app.session.redo()); + assert_eq!(guides(&app).1, vec![30.0, 20.0]); +} + +/// Restoring a refused proposal must not bind it to a different document at retry time. +#[test] +fn refused_edit_draft_cannot_follow_a_document_switch() { + let (mut app, id) = edit_fixture(); + app.automation_input = true; + app.services.automation_command = Some(Box::new(|_, _| Err("denied".into()))); + assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "denied"); + let id = app.ui.dialogs.last().unwrap().id; + app.services.automation_command = None; + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(guides(&app).1, vec![10.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(app.guide_edit.is_none()); + assert!(!app.ui.dialogs.iter().any(|d| d.fields.contains_key(EDIT))); +} + +/// Normal UI frames retain the command refusal; editing the position clears it. +#[test] +fn refused_edit_shows_the_error_and_typed_fields_in_the_dialog() { + use egui_kittest::kittest::Queryable; + let (mut app, id) = edit_fixture(); + app.automation_input = true; + app.services.automation_command = Some(Box::new(|_, _| Err("guide edits are denied".into()))); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + let evidence = std::env::var_os("PHOTOCRAFT_GUIDE_RETRY_EVIDENCE"); + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)); + let builder = if evidence.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(3); + h.get_by_label("guide edits are denied"); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Position").value().as_deref(), Some("30 px")); + if let Some(path) = evidence { + h.render().unwrap().save(std::path::Path::new(&path)).unwrap(); + } + let id = h.state().ui.dialogs.last().unwrap().id; + set(h.state_mut(), id, "vertical", "40 px"); + h.run_steps(3); + assert!(h.query_by_label("guide edits are denied").is_none()); + assert_eq!(guides(h.state()).1, vec![10.0, 20.0]); +} + +/// #10440: a leading bare number gets the ruler unit after a Unicode minus (U+2212) as after "-", +/// so "5.08 − 2.54" with centimetre rulers is 2.54 cm, not 2.54 px. +#[test] +fn the_ruler_unit_goes_on_a_bare_number_before_a_unicode_minus() { + let (mut app, ctx) = app("cm"); + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", "5.08 − 2.54"); + crate::dialogs::confirm(&mut app, d).unwrap(); + let (_, vertical) = guides(&app); + assert!(vertical.len() == 1 && (vertical[0] - 100.0).abs() < 1e-6, "{vertical:?}"); +} + +/// #10440: a leading bare number that multiplies or divides a length is a plain factor, so it +/// doesn't take the ruler unit (CodeRabbit: "2 / 2 * 50 px" became "2 cm / 2 * 50 px"). +#[test] +fn a_leading_factor_of_a_length_is_a_plain_number() { + let (mut app, ctx) = app("cm"); + for (typed, px) in [("2 * 50 px", 100.0), ("2 / 2 * 1in", 100.0), ("2 * 2.54 - 2.54", 100.0)] { + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", typed); + crate::dialogs::confirm(&mut app, d).unwrap_or_else(|e| panic!("{typed}: {e}")); + let landed = *guides(&app).1.last().unwrap(); + assert!((landed - px).abs() < 1e-6, "{typed}: {landed}"); + } +} diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 2da15f9fb4..78691112c0 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -207,7 +207,7 @@ pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result Result Result Option { + fuzzy_score_in(crate::i18n::Lang::EN, query, text) +} + +/// Score a subsequence after converting both strings in the text's language. +fn fuzzy_score_in(lang: crate::i18n::Lang, query: &str, text: &str) -> Option { if query.is_empty() { return Some(0); } - let t: Vec = text.to_lowercase().chars().collect(); + let t: Vec = lang.lowercase(text).chars().collect(); let mut score = 0; let mut ti = 0; let mut last = None; - for qc in query.to_lowercase().chars() { + for qc in lang.lowercase(query).chars() { let mut found = false; while ti < t.len() { if t[ti] == qc { @@ -39,8 +44,20 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { /// Bonus that puts a command or tool whose name is exactly the query ahead of every fuzzy match /// ("settings" finds Settings before Color Settings). fn exact_bonus(query: &str, names: &[&str]) -> i32 { + exact_bonus_in(crate::i18n::Lang::EN, query, names) +} + +/// Favor a complete displayed name, ignoring its trailing ellipsis and localized case. +fn exact_bonus_in(lang: crate::i18n::Lang, query: &str, names: &[&str]) -> i32 { let q = query.trim(); - if !q.is_empty() && names.iter().any(|n| n.trim_end_matches('…').trim().to_lowercase() == q.to_lowercase()) { 1000 } else { 0 } + if !q.is_empty() && names.iter().any(|n| lang.lowercase(n.trim_end_matches('…').trim()) == lang.lowercase(q)) { 1000 } else { 0 } +} + +/// Rank the UI wording in its language and the documented English aliases independently. +fn bilingual_score(lang: crate::i18n::Lang, query: &str, local: &str, english: &str, local_name: &str, english_name: &str) -> Option { + let local = fuzzy_score_in(lang, query, local).map(|s| s + exact_bonus_in(lang, query, &[local_name])); + let english = fuzzy_score(query, english).map(|s| s + exact_bonus(query, &[english_name])); + local.into_iter().chain(english).max() } /// How many recently run commands the palette remembers. @@ -105,7 +122,7 @@ fn menu_hits(menus: Vec, q: &str, lang: crate::i18n::Lan let path = m.path.iter().map(|p| crate::i18n::tr(lang, p)).collect::>().join(" › "); let label = crate::i18n::tr_id(lang, &m.id, &m.label); // Match what is shown and the English name (commands are documented in English). - let s = fuzzy_score(q, &format!("{label} {path} {} {path_en}", m.label))? + exact_bonus(q, &[label, &m.label]); + let s = bilingual_score(lang, q, &format!("{label} {path}"), &format!("{} {path_en}", m.label), label, &m.label)?; Some(( s, m.id, @@ -176,8 +193,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if !empty || hits.is_empty() { hits.extend(menu_hits(menus, &q, lang)); for tool in crate::state::Tool::ALL { - if let Some(s) = fuzzy_score(&q, &format!("{} {}", tl!(tool.label()), tool.label())) { - let s = s + exact_bonus(&q, &[tl!(tool.label()).as_ref(), tool.label()]); + if let Some(s) = bilingual_score(lang, &q, tl!(tool.label()).as_ref(), tool.label(), tl!(tool.label()).as_ref(), tool.label()) { hits.push(( s + 2, format!("tool:{tool:?}"), @@ -267,6 +283,16 @@ mod tests { use super::{exact_bonus, fuzzy_score, push_recent}; use crate::PhotocraftApp; + /// Localized fuzzy and exact matches use Turkic casing while English aliases use root casing. + #[test] + fn azerbaijani_palette_keeps_dotted_i_and_english_aliases() { + let az = crate::i18n::Lang::from_code("az").unwrap(); + assert!(super::bilingual_score(az, "ŞƏKİL", "Şəkil", "Image", "Şəkil", "Image").unwrap() >= 1000); + assert_eq!(super::bilingual_score(az, "ŞƏKIL", "Şəkil", "Image", "Şəkil", "Image"), None); + assert!(super::bilingual_score(az, "IMAGE", "Şəkil", "Image", "Şəkil", "Image").unwrap() >= 1000); + assert!(super::fuzzy_score_in(az, "I\u{307}ŞIQ", "İşıq").is_some()); + } + #[test] fn open_palette_interrupts_the_ime_only_when_it_takes_focus() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index ebef97f740..5b5fde322a 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -426,6 +426,9 @@ pub struct SelectMaskWorkspace { /// Document px per reduced-copy px (High Quality Preview uses 1). k: u32, pressed: bool, + /// An on-canvas brush resize in progress (#10497): the tool it resizes, where it began on + /// screen, the size and hardness then, and the button that drags it. + resize: Option<(SmTool, Pos2, f64, f32, egui::PointerButton)>, /// The device's texture side limit, as the context reports it each frame. texture_limit: usize, image_tex: Option, @@ -482,6 +485,18 @@ impl SelectMaskWorkspace { } } + /// Esc mid-resize: the brush goes back to its size and hardness when the drag began, as in + /// Photoshop. egui drops the drag on Esc, so the rest of it does nothing. + fn escape_resize(&mut self) { + let Some((tool, _, size, hardness, _)) = self.resize.take() else { return }; + match tool { + SmTool::Quick => self.quick_size = size, + SmTool::RefineEdge => self.refine_size = size, + _ => self.brush_size = size, + } + self.hardness = hardness; + } + fn pick(&mut self, t: SmTool) { self.tool = t; match t { @@ -761,6 +776,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { hide_view: false, k, pressed: false, + resize: None, texture_limit: 2048, image_tex: None, zoom: 0.0, @@ -877,9 +893,16 @@ pub fn enter(app: &mut PhotocraftApp) -> bool { false } -/// Esc: drops a Polygonal Lasso polygon being drawn, else Cancel. +/// A brush resize is in progress (Esc then ends it, with Control+Alt still held too). +pub fn resizing(app: &PhotocraftApp) -> bool { + app.distort.select_mask.as_ref().is_some_and(|w| w.resize.is_some()) +} + +/// Esc: ends a brush resize as it began, else drops a Polygonal Lasso polygon being drawn, else +/// Cancel. pub fn escape(app: &mut PhotocraftApp) { match app.distort.select_mask.as_mut() { + Some(w) if w.resize.is_some() => w.escape_resize(), Some(w) if w.poly.is_some() => w.poly = None, _ => cancel(app), } @@ -1408,6 +1431,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { w.pick(t); } } + // E cycles the tool mode (add, subtract); Hand and Zoom edit nothing, so it leaves them alone. + let bare_e = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::E, pressed: true, modifiers, .. } if modifiers.is_none()))); + if bare_e && w.tool.edits() && pressed(Key::E, Modifiers::NONE) { + w.add = !w.add; + } if pressed(Key::OpenBracket, Modifiers::NONE) { let s = w.size(); w.set_size((s * 0.9).min(s - 1.0)); @@ -1747,13 +1775,28 @@ fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::P ctx.set_cursor_icon(egui::CursorIcon::Grab); } else if w.tool.brushes() { let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); - clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); - clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); - let add = w.add != alt; - for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { - clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); - if add { - clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + // While its size is dragged the circle stays where the drag began, with the + // Diameter/Hardness readout beside the pointer, as on the main canvas (#10497). + let resize = w.resize.filter(|&(tool, ..)| tool == w.tool); + let center = resize.map_or(hp, |(_, anchor, ..)| anchor); + clip.circle_stroke(center, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(center, r, Stroke::new(0.75, Color32::WHITE)); + if resize.is_some() { + let hardness = if w.tool == SmTool::Brush { format!("{}%", w.hardness.round() as i64) } else { "—".into() }; + crate::canvas::draw_readout( + ctx, + "select-mask-brush-resize-readout", + hp, + ["Diameter:", "Hardness:"], + [format!("{} px", w.size().round() as i64), hardness], + ); + } else { + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } } } ctx.set_cursor_icon(egui::CursorIcon::None); @@ -1780,6 +1823,48 @@ fn canvas_input( events: &mut Vec, mods: &mut egui::Modifiers, ) { + // Alt+right-drag, or Control+Alt-drag, resizes the brush instead of selecting (#10497), from + // the press on, so its first frame never starts a stroke. Alt+left-drag still subtracts. + let (secondary, now) = ui.input(|i| (i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); + let gesture = if secondary { crate::brush_resize::is_right_gesture(now) } else { crate::brush_resize::is_gesture(now) }; + // A resize keeps its drag until its button is let go: Space or the middle button may pan + // meanwhile, and if another tool is picked mid-drag the rest of it neither resizes nor + // selects with that tool. + let held = |button| ui.input(|i| i.pointer.button_down(button)); + if let Some((tool, .., button)) = w.resize + && (pan || tool != w.tool) + { + if !held(button) { + w.resize = None; + } + if !pan { + ctx.request_repaint(); + return; + } + } + if !pan && w.tool.brushes() && !w.pressed && (w.resize.is_some() || (resp.is_pointer_button_down_on() && gesture)) { + if w.resize.is_none() + && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) + { + let button = if secondary { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; + w.resize = Some((w.tool, p, w.size(), w.hardness, button)); + } + if let (Some((_, anchor, size0, hardness0, _)), Some(p)) = (w.resize, resp.interact_pointer_pos()) { + let d = p - anchor; + let zoom = if w.zoom.is_finite() && w.zoom > 0.0 { w.zoom } else { 1.0 }; + let (size, hardness) = crate::brush_resize::resized((size0 as f32, hardness0 / 100.0), f64::from(d.x / zoom), f64::from(d.y / zoom), zoom); + w.set_size(f64::from(size)); + // Only the Brush tool has a hardness; Quick Selection and Refine Edge change size only. + if w.tool == SmTool::Brush { + w.hardness = (hardness * 100.0).round(); + } + } + if resp.drag_stopped() || w.resize.is_some_and(|(.., button)| !held(button)) { + w.resize = None; + } + ctx.request_repaint(); + return; + } if pan && resp.dragged() { let dl = resp.drag_delta(); w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; @@ -1973,5 +2058,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +#[cfg(test)] +mod cycle_tool_mode_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/cycle_tool_mode_tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/cycle_tool_mode_tests.rs new file mode 100644 index 0000000000..de138c5801 --- /dev/null +++ b/crates/ui-egui/src/dialogs/select_mask_ui/cycle_tool_mode_tests.rs @@ -0,0 +1,158 @@ +use super::*; +use egui::{Key, Modifiers}; + +fn opened() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 160, "height": 100, "depth": 8})).unwrap(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.remove(REMEMBERED); + }); + app.sync_views(); + open(&mut app).unwrap(); + app +} + +fn ws(app: &PhotocraftApp) -> &SelectMaskWorkspace { + app.distort.select_mask.as_ref().unwrap() +} + +fn ui_selected(app: &mut PhotocraftApp) -> u64 { + control(app, &json!({"waitPreview": true})).unwrap()["selectedProxyPixels"].as_u64().unwrap() +} + +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn e_switches_between_add_and_subtract_and_back() { + let mut app = opened(); + let ctx = egui::Context::default(); + assert!(ws(&app).add); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(!ws(&app).add); + assert_eq!(ws(&app).describe()["mode"].as_str(), Some("subtract")); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(ws(&app).add); + assert_eq!(ws(&app).describe()["mode"].as_str(), Some("add")); +} + +#[test] +fn e_works_with_every_editing_tool_and_keeps_the_tool() { + let ctx = egui::Context::default(); + for tool in [SmTool::Quick, SmTool::RefineEdge, SmTool::Brush, SmTool::Object, SmTool::Lasso, SmTool::PolygonalLasso] { + let mut app = opened(); + app.distort.select_mask.as_mut().unwrap().pick(tool); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(!ws(&app).add, "{tool:?}"); + assert_eq!(ws(&app).tool, tool); + } +} + +#[test] +fn e_does_nothing_with_hand_or_zoom() { + let ctx = egui::Context::default(); + for tool in [SmTool::Hand, SmTool::Zoom] { + let mut app = opened(); + app.distort.select_mask.as_mut().unwrap().pick(tool); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(ws(&app).add, "{tool:?}"); + } +} + +#[test] +fn e_with_command_alt_or_shift_does_nothing_new() { + let ctx = egui::Context::default(); + for m in [Modifiers::COMMAND, Modifiers::ALT, Modifiers::SHIFT, Modifiers::CTRL] { + let mut app = opened(); + press(&mut app, &ctx, Key::E, m); + assert!(ws(&app).add, "{m:?}"); + } +} + +#[test] +fn e_is_ignored_while_a_popup_has_the_keys() { + let mut app = opened(); + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Popup::open_id(ui.ctx(), egui::Id::unique("sm-test-popup")); + }) + .textures_delta + .clear(); + assert!(egui::Popup::is_any_open(&ctx)); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(ws(&app).add); +} + +#[test] +fn e_chooses_the_mode_alt_swaps_while_held() { + let mut app = opened(); + let ctx = egui::Context::default(); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(!ws(&app).add); + // Alt held on top of subtract: the stroke adds, so the pixel under it becomes selected. + let before = ui_selected(&mut app); + let stroke = json!({"tool": "brush", "size": 10, "hardness": 50, "stroke": {"points": [[100.0, 10.0], [100.0, 90.0]], "alt": true}}); + control(&mut app, &stroke).unwrap(); + assert!(!ws(&app).add, "Alt does not change the chosen mode"); + assert!(ui_selected(&mut app) > before, "Alt over subtract adds"); +} + +#[test] +fn a_polygon_in_progress_keeps_its_starting_mode() { + let mut app = opened(); + let ctx = egui::Context::default(); + app.distort.select_mask.as_mut().unwrap().pick(SmTool::PolygonalLasso); + app.distort.select_mask.as_mut().unwrap().poly = Some((true, vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]])); + press(&mut app, &ctx, Key::E, Modifiers::NONE); + assert!(!ws(&app).add); + assert_eq!(ws(&app).poly.as_ref().map(|(a, _)| *a), Some(true)); + assert!(enter(&mut app), "Enter closes the polygon"); + let edit = ws(&app).edits.last().expect("the closed polygon is recorded"); + assert_eq!(edit["tool"].as_str(), Some("lasso")); + assert_eq!(edit["mode"].as_str(), Some("add"), "the mode the polygon started with"); + assert!(!ws(&app).add, "the workspace mode is still the one E chose"); +} + +fn e_event() -> egui::RawInput { + let ev = egui::Event::Key { key: Key::E, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + egui::RawInput { events: vec![ev], ..Default::default() } +} + +/// Through the shortcut handler, as the app runs it: a focused text field keeps the E. +#[test] +fn e_through_handle_is_ignored_while_a_text_field_has_focus() { + let mut app = opened(); + let ctx = egui::Context::default(); + let mut text = String::new(); + ctx.run_ui(Default::default(), |ui| { + ui.text_edit_singleline(&mut text).request_focus(); + }) + .textures_delta + .clear(); + ctx.run_ui(e_event(), |ui| { + assert!(ui.ctx().text_edit_focused()); + crate::shortcuts::handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut text); + }) + .textures_delta + .clear(); + assert!(ws(&app).add); +} + +/// The same path with no text field focused: E flips the mode. +#[test] +fn e_through_handle_flips_the_mode_without_a_focused_text_field() { + let mut app = opened(); + let ctx = egui::Context::default(); + ctx.run_ui(e_event(), |ui| { + assert!(!ui.ctx().text_edit_focused()); + crate::shortcuts::handle(&mut app, ui.ctx()); + }) + .textures_delta + .clear(); + assert!(!ws(&app).add); +} diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 95a3a2ace9..e993830507 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -808,3 +808,292 @@ fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { assert_eq!(selected_pixels(Some(&inverted), smaller), 999_999); assert_eq!(selected_pixels(None, area), 0); } + +/// The workspace in a harness on `tool`, and a point over the image. +fn resize_harness(tool: &str) -> (egui_kittest::Harness<'static, PhotocraftApp>, egui::Pos2) { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": tool})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::distort_ui::keys(app, &ctx); + keys(app, &ctx); + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + (h, egui::Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0)) +} + +/// Presses `button` at `at` with `modifiers` held. +fn pointer_down(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2, button: egui::PointerButton, modifiers: Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.run_steps(1); +} + +/// Moves the held pointer from `from` by `by`, in four steps. +fn pointer_move(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, by: egui::Vec2) { + for k in 1..=4 { + h.event(egui::Event::PointerMoved(from + by * (k as f32 / 4.0))); + h.run_steps(1); + } +} + +fn tap_key(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, key: Key, pressed: bool) { + h.event(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// A whole drag of `button` from `at` by `by` with `modifiers` held. +fn pointer_drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2, button: egui::PointerButton, modifiers: Modifiers, by: egui::Vec2) { + pointer_down(h, at, button, modifiers); + pointer_move(h, at, by); + h.event(egui::Event::PointerButton { pos: at + by, button, pressed: false, modifiers }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// Alt+right-drag (and Control+Alt-drag) on the image resizes the current brush tool instead of +/// selecting, as on the main canvas (#10497): only that tool's size changes, the Brush's hardness +/// follows vertical motion, nothing is painted, and the next Alt-drag still subtracts. +#[test] +fn alt_right_drag_resizes_the_workspace_brush() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + pointer_drag(&mut h, over, PointerButton::Secondary, Modifiers::ALT, vec2(40.0, -60.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + assert_eq!(w.brush_size, want, "horizontal motion sets the diameter (zoom {zoom})"); + assert_eq!(w.hardness, 70.0, "upward motion softens the brush, 200 points for 100 %"); + assert_eq!((w.quick_size, w.refine_size), (30.0, 30.0), "the other tools keep their sizes"); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "resizing selected nothing"); + assert!(w.resize.is_none(), "the gesture ends with the button"); + // Alt with the left button is still the subtracting stroke. + pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::ALT, vec2(30.0, 0.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + // A Brush stroke is an edit (Quick Selection's are strokes). + assert_eq!(w.edits.len(), 1, "Alt+left-drag selects"); + assert_eq!(w.edits[0]["mode"], json!("subtract")); + assert_eq!(w.brush_size, want, "and doesn't resize"); + // Control+Alt-drag (the macOS gesture) resizes Quick Selection on its own. + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick); + pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::CTRL | Modifiers::ALT, vec2(-10.0, 0.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!(w.quick_size, (30.0 - 2.0 * f64::from(10.0 / zoom)).round().max(1.0)); + assert_eq!((w.brush_size, w.hardness), (want, 70.0), "the Brush keeps its size and hardness"); + assert!(w.strokes.is_empty() && w.edits.len() == 1, "resizing Quick Selection selected nothing"); +} + +/// Space pressed mid-resize pans, and the button released before Space still ends the resize +/// (CodeRabbit on #10559): a Space-drag that goes on after Space comes up paints, as any +/// Space-drag does, instead of resizing the brush from the old anchor. +#[test] +fn a_resize_released_while_space_pans_lets_the_next_drag_paint() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, 0.0)); + tap_key(&mut h, Key::Space, true); + h.event(egui::Event::PointerButton { pos: over + vec2(40.0, 0.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().brush_size, want); + // Space+drag pans; Space comes up mid-drag and the drag goes on. + pointer_down(&mut h, over, PointerButton::Primary, Modifiers::NONE); + pointer_move(&mut h, over, vec2(0.0, 20.0)); + tap_key(&mut h, Key::Space, false); + pointer_move(&mut h, over + vec2(0.0, 20.0), vec2(30.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(30.0, 20.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!(w.brush_size, want, "the drag doesn't resize the brush"); + assert_eq!(w.edits.len(), 1, "the drag's rest paints"); + assert_eq!(w.edits[0]["mode"], json!("add")); + assert!(w.resize.is_none()); +} + +/// A resize belongs to the tool it began on (CodeRabbit on #10559): if another tool is picked +/// mid-drag, the rest of the drag neither resizes that tool nor selects with it. +#[test] +fn picking_another_tool_mid_resize_neither_resizes_nor_selects() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + // Alt+right-drag the Brush larger, let go of Alt, press W (Quick Selection) and drag on. + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, 0.0)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + tap_key(&mut h, Key::W, true); + tap_key(&mut h, Key::W, false); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().tool, SmTool::Quick); + pointer_move(&mut h, over + vec2(40.0, 0.0), vec2(40.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(80.0, 0.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.quick_size), (want, 30.0), "Quick Selection keeps its size"); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "nothing was selected"); + // The same with the Lasso (not a brush) picked mid-drag: the rest of the drag draws no lasso. + tap_key(&mut h, Key::B, true); + tap_key(&mut h, Key::B, false); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(20.0, 0.0)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + tap_key(&mut h, Key::L, true); + tap_key(&mut h, Key::L, false); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().tool, SmTool::Lasso); + pointer_move(&mut h, over + vec2(20.0, 0.0), vec2(0.0, 60.0)); + pointer_move(&mut h, over + vec2(20.0, 60.0), vec2(-60.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(-40.0, 60.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "the Lasso selected nothing"); + assert!(w.resize.is_none(), "the gesture ends with the button"); +} + +/// A resize ends when its own button comes up, even while the middle button pans (CodeRabbit on +/// #10559): a left-drag begun before the middle button is let go doesn't resize the brush from +/// the old anchor when the pan ends. +#[test] +fn a_resize_released_while_the_middle_button_pans_ends_with_its_button() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round(); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, 0.0)); + let at = over + vec2(40.0, 0.0); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().unwrap().brush_size, want); + // The left button goes down while the middle one still pans, then the middle one comes up + // and the left drag goes on. + pointer_move(&mut h, at, vec2(0.0, 20.0)); + let at = at + vec2(0.0, 20.0); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + pointer_move(&mut h, at, vec2(30.0, -20.0)); + let at = at + vec2(30.0, -20.0); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + pointer_move(&mut h, at, vec2(60.0, -40.0)); + h.event(egui::Event::PointerButton { pos: at + vec2(60.0, -40.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.hardness), (want, 100.0), "the later drag doesn't resize the brush"); + assert!(w.resize.is_none(), "the gesture ended with its button"); +} + +/// Esc mid-resize puts the brush back and ends only the resize, as in Photoshop (FredApps on +/// #10559): the workspace stays open, the rest of that drag neither resizes nor selects, and the +/// next drag selects as usual. +#[test] +fn escape_mid_resize_restores_the_brush_and_keeps_the_workspace() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + pointer_move(&mut h, over, vec2(40.0, -60.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.resize.is_some() && w.brush_size > 30.0 && w.hardness < 100.0, "the drag resizes"); + // Esc with Alt and the right button still held. + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::ALT }); + h.run_steps(1); + let w = h.state().distort.select_mask.as_ref().expect("Esc ends the resize, not the workspace"); + assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the brush is as it was"); + assert!(w.resize.is_none()); + // The drag goes on with Alt and the right button held: it does nothing. + pointer_move(&mut h, over + vec2(40.0, -60.0), vec2(40.0, 40.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(80.0, -20.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the rest of the drag doesn't resize"); + assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "nor select"); + assert!(w.resize.is_none()); + // The next drag selects with the brush as it was. + pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::NONE, vec2(30.0, 0.0)); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the next drag doesn't resize"); + assert_eq!(w.edits.len(), 1, "it selects"); + assert_eq!(w.edits[0]["mode"], json!("add")); + // Control+Alt-drag (the macOS gesture) on Quick Selection, Esc with both still held. + let ctrl_alt = Modifiers::CTRL | Modifiers::ALT; + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick); + pointer_down(&mut h, over, PointerButton::Primary, ctrl_alt); + pointer_move(&mut h, over, vec2(30.0, 0.0)); + assert!(h.state().distort.select_mask.as_ref().unwrap().quick_size > 30.0, "the drag resizes"); + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: ctrl_alt }); + h.run_steps(1); + let w = h.state().distort.select_mask.as_ref().expect("the workspace stays open"); + assert_eq!(w.quick_size, 30.0, "Quick Selection's size is as it was"); + assert!(w.resize.is_none()); + pointer_move(&mut h, over + vec2(30.0, 0.0), vec2(30.0, 0.0)); + h.event(egui::Event::PointerButton { pos: over + vec2(60.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: ctrl_alt }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert_eq!(w.quick_size, 30.0, "the rest of the drag doesn't resize"); + assert!(w.strokes.is_empty() && w.edits.len() == 1, "nor select"); +} + +/// The brush circles drawn last frame (the black outline's centre and radius). +fn brush_circles(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec<(egui::Pos2, f32)> { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Circle(s) if s.stroke == Stroke::new(1.5, Color32::BLACK) => Some((s.center, s.radius)), + _ => None, + }) + .collect() +} + +/// While the brush is resized its circle stays where the drag began, at the new size, and the +/// Diameter/Hardness readout follows the pointer, as on the main canvas (FredApps on #10559). +#[test] +fn the_resize_circle_stays_put_while_the_readout_follows_the_pointer() { + use egui::PointerButton; + let (mut h, over) = resize_harness("brush"); + let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom; + let readout = || egui::Id::unique("select-mask-brush-resize-readout"); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(brush_circles(&h), [(over, 15.0 * zoom)], "hovering, the circle is at the pointer"); + pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT); + for by in [vec2(40.0, -60.0), vec2(60.0, -60.0)] { + pointer_move(&mut h, over, by); + // The cursor is drawn before the frame's input, so it shows the new size a frame later. + h.run_steps(1); + let at = over + by; + let w = h.state().distort.select_mask.as_ref().unwrap(); + let (size, hardness) = (w.brush_size, w.hardness); + assert_eq!(brush_circles(&h), [(over, size as f32 * 0.5 * zoom)], "the circle stays at the anchor at the new size ({by:?})"); + let rect = h.ctx.memory(|m| m.area_rect(readout())).expect("the readout shows"); + assert_eq!(rect.min, at + vec2(16.0, 18.0), "the readout is beside the pointer"); + assert!(h.query_by_label(&format!("{} px", size.round() as i64)).is_some(), "it shows the diameter"); + assert!(h.query_by_label(&format!("{}%", hardness.round() as i64)).is_some(), "and the hardness"); + } + h.event(egui::Event::PointerButton { pos: over + vec2(60.0, -60.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let size = h.state().distort.select_mask.as_ref().unwrap().brush_size; + assert_eq!(brush_circles(&h), [(over + vec2(60.0, -60.0), size as f32 * 0.5 * zoom)], "after the drag the circle follows the pointer again"); + assert!(h.query_by_label("Diameter:").is_none(), "and the readout is gone"); +} diff --git a/crates/ui-egui/src/dialogs/shape_dialog/tests.rs b/crates/ui-egui/src/dialogs/shape_dialog/tests.rs index 7af252f087..1ca5de28f7 100644 --- a/crates/ui-egui/src/dialogs/shape_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/shape_dialog/tests.rs @@ -354,3 +354,35 @@ fn right_click_picks_the_unit_of_that_field_only_and_it_is_not_remembered() { let s = sides(&frame(&egui::Context::default(), &app, &mut f, vec![])); assert_eq!(shown(&s), [("1.76".to_string(), "cm".to_string()), ("3.53".to_string(), "cm".to_string())]); } + +/// CodeRabbit on #9456: in Path mode a click opens no Create dialog, whose OK would make a shape +/// layer; Path mode never draws one (a drag adds its outline to the work path). +#[test] +fn a_path_mode_click_opens_no_create_dialog() { + for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon] { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + let before = layers(&app); + press(&mut app, tool, [20.4, 30.2], [20.4, 30.2]); + assert!(app.ui.dialogs.is_empty(), "{tool:?}: no Create dialog in Path mode"); + assert_eq!(layers(&app), before, "{tool:?}: and no layer"); + } +} + +/// CodeRabbit on #9456: a Path-mode click that moved a little, still within the click slop, adds +/// nothing to the work path either. Its box would round to a 1 × 1 or 2 × 2 px outline. +#[test] +fn a_path_mode_click_that_moved_a_little_adds_no_subpath() { + let work = |app: &PhotocraftApp| app.session.active().unwrap().doc.work_path.as_ref().map_or(0, |p| p.subpaths.len()); + for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line] { + for end in [[20.8, 30.8], [21.8, 31.9]] { + let mut app = app(); + app.ui.tool_options.shape_mode = "path".into(); + let before = layers(&app); + press(&mut app, tool, [20.2, 30.2], end); + assert_eq!(work(&app), 0, "{tool:?} to {end:?}: no subpath"); + assert!(app.ui.dialogs.is_empty(), "{tool:?} to {end:?}: no Create dialog"); + assert_eq!(layers(&app), before, "{tool:?} to {end:?}: and no layer"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/sizing/tests.rs b/crates/ui-egui/src/dialogs/sizing/tests.rs index a7e7c1dd20..7c26de6c81 100644 --- a/crates/ui-egui/src/dialogs/sizing/tests.rs +++ b/crates/ui-egui/src/dialogs/sizing/tests.rs @@ -50,6 +50,30 @@ fn dialog_maps_to_engine_command() { assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50)); } +/// #9260: OK with a new Width resizes a document whose own resolution is outside the range +/// the command accepts (an imported PNG's pHYs of 1 px/m is 0.0254 ppi); the dialog sends that +/// resolution back unchanged. +#[test] +fn ok_resizes_a_document_with_an_imported_resolution_out_of_range() { + for own in [0.0254_f32, 40_000.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap(); + app.session + .edit("ppi", |doc, _| { + doc.resolution_dpi = own; + Ok(()) + }) + .unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("width".into(), json!(100)); + d.fields.insert("height".into(), json!(50)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(100, 50), own), "{own}"); + } +} + #[test] fn alt_reset_restores_open_dimensions() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/smart_filter_dialog.rs b/crates/ui-egui/src/dialogs/smart_filter_dialog.rs index dbe00fdf9b..bd9d374217 100644 --- a/crates/ui-egui/src/dialogs/smart_filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/smart_filter_dialog.rs @@ -134,7 +134,14 @@ pub(crate) fn edit_command(app: &PhotocraftApp, fields: &Map) -> } Ok((COMMAND.into(), params)) } else { - Ok(("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "index": index, "params": crate::filter_dialog::params_of(fields)}))) + // An adjustment's dialog opens on all the recorded settings and may remove keys, so OK + // replaces them: merged, a removed one stays stored and can override the edit (a stale + // Channel Mixer `gray` is read over `red`, a stale Color Lookup `data` over `lut`). + let replace = fields.get("__command").and_then(Value::as_str).is_some_and(|c| c.starts_with("image.adjustments.")); + Ok(( + "layer.smartFilter.setParams".into(), + json!({"layer": layer.0, "index": index, "params": crate::filter_dialog::params_of(fields), "replace": replace}), + )) } } diff --git a/crates/ui-egui/src/dialogs/snapshot_dialog.rs b/crates/ui-egui/src/dialogs/snapshot_dialog.rs index 052b9b4f84..04c0430dc6 100644 --- a/crates/ui-egui/src/dialogs/snapshot_dialog.rs +++ b/crates/ui-egui/src/dialogs/snapshot_dialog.rs @@ -17,7 +17,12 @@ pub fn owns(fields: &Map) -> bool { } /// Open the dialog on the active document, suggesting the name the camera button would give. +/// Not while a Free Transform is open: it owns the document until committed or cancelled, so the +/// camera button does nothing then either (#6087). pub fn open(app: &mut PhotocraftApp) -> Option { + if app.ui.transform.is_some() { + return None; + } let name = photocraft_engine::history_cmds::next_snapshot_name(app.session.active()?); let mut f = Map::new(); f.insert(MARK.into(), json!(true)); @@ -28,8 +33,17 @@ pub fn open(app: &mut PhotocraftApp) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } +/// Takes the snapshot. A cleared Name gets the next default name, as the camera button would give +/// (#6087); agents calling the command with an empty name still get an error. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - app.run(COMMAND, json!({"name": f.get("name"), "from": f.get("from")})) + let mut p = Map::new(); + if let Some(name) = f.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) { + p.insert("name".into(), json!(name)); + } + if let Some(from) = f.get("from") { + p.insert("from".into(), from.clone()); + } + app.run(COMMAND, Value::Object(p)) } fn label(ui: &mut egui::Ui, text: &str) { @@ -47,11 +61,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // Opens on the name, selected, so typing replaces it (Photoshop). if !ui.is_sizing_pass() && !f.contains_key("__named") { r.request_focus(); - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), r.id); - } + crate::field_tab::select_all(ui.ctx(), r.id, &name); f.insert("__named".into(), json!(true)); } f.insert("name".into(), json!(name)); @@ -69,3 +79,47 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } }); } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::PhotocraftApp; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app + } + + /// An open Free Transform owns the document: New Snapshot does not open under it, whoever + /// asks (#6087). + #[test] + fn does_not_open_during_free_transform() { + let mut app = app(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(app.ui.transform.is_some()); + assert_eq!(super::open(&mut app), None); + assert!(app.ui.dialogs.is_empty()); + crate::transform_tool::cancel(&mut app); + assert!(super::open(&mut app).is_some()); + } + + /// A cleared Name takes the default name on OK, as the camera button would (#6087); a typed + /// one is trimmed. The command itself still refuses an empty name. + #[test] + fn a_cleared_name_gets_the_default_name() { + let mut app = app(); + for (typed, want) in [("", "Snapshot 1"), (" ", "Snapshot 2"), (" Mine ", "Mine")] { + let id = super::open(&mut app).unwrap(); + let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap(); + d.fields.insert("name".into(), json!(typed)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().snapshots.last().unwrap().name, want); + } + assert!(app.run(super::COMMAND, json!({"name": ""})).is_err(), "agents still get the error"); + } +} diff --git a/crates/ui-egui/src/dialogs/targa_options_ui.rs b/crates/ui-egui/src/dialogs/targa_options_ui.rs index 1c6772dfea..f147e98068 100644 --- a/crates/ui-egui/src/dialogs/targa_options_ui.rs +++ b/crates/ui-egui/src/dialogs/targa_options_ui.rs @@ -87,7 +87,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy, (p.record, step)); } else if cancel { app.targa_options = None; - app.ui.status = tl!("Save cancelled").into(); + app.ui.status = tl!("Save canceled").into(); app.ui.status_error = false; } } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 829e59e99e..f6ef5a1e46 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -567,7 +567,7 @@ fn german_color_picker_fits_its_actions_and_translates_the_title() { crate::color_picker_ui::open(h.state_mut(), "foreground"); h.run_steps(4); let title = h.get_by_label("Farbwähler (Vordergrundfarbe)").rect(); - let swatch = h.get_by_label(&text("Click to restore the current colour")).rect(); + let swatch = h.get_by_label(&text("Click to restore the current color")).rect(); let frame = rects(&h.ctx)[0]; for label in ["OK", "Cancel", "Add to Swatches"] { let r = h.get_by_label(&text(label)).rect(); diff --git a/crates/ui-egui/src/dialogs/tiff_options_ui.rs b/crates/ui-egui/src/dialogs/tiff_options_ui.rs index 90f33aaf61..2bd27bf585 100644 --- a/crates/ui-egui/src/dialogs/tiff_options_ui.rs +++ b/crates/ui-egui/src/dialogs/tiff_options_ui.rs @@ -198,7 +198,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { confirm(app, ctx); } else if cancel { app.tiff_options = None; - app.ui.status = tl!("Save cancelled").into(); + app.ui.status = tl!("Save canceled").into(); app.ui.status_error = false; } } diff --git a/crates/ui-egui/src/dialogs/wide_angle_ui.rs b/crates/ui-egui/src/dialogs/wide_angle_ui.rs index b979f71cf4..1b44cd7add 100644 --- a/crates/ui-egui/src/dialogs/wide_angle_ui.rs +++ b/crates/ui-egui/src/dialogs/wide_angle_ui.rs @@ -11,7 +11,7 @@ use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::transform::Interp; -use photocraft_algo::wideangle::{self, Camera, Constraint, Orientation, WideAngle, WideModel}; +use photocraft_algo::wideangle::{self, Camera, Constraint, Orientation, WideAngle, WideMesh, WideModel}; use photocraft_doc::LayerId; use photocraft_geom::Rect; use photocraft_raster::Surface; @@ -38,6 +38,9 @@ pub struct WideAngleDialog { ph: usize, src_tex: Option, out_tex: Option, + /// The proxy warp used by both the rendered preview and its constraint overlays. It is + /// replaced only by `render`, so every edit to `params` must set `dirty`. + mesh: Option, dirty: bool, pub preview: bool, drag: Option<([f64; 2], [f64; 2])>, @@ -71,8 +74,13 @@ impl WideAngleDialog { p } + /// The proxy rectangle shared by the preview and overlays. + fn proxy_rect(&self) -> Rect { + Rect::new(0, 0, self.pw as i32, self.ph as i32) + } + fn preview_surface(&mut self) -> Result { - let pf = Rect::new(0, 0, self.pw as i32, self.ph as i32); + let pf = self.proxy_rect(); let p = self.proxy_params(); let mesh = wideangle::solve(&p, pf); self.residual = mesh.residual / self.scale; @@ -82,6 +90,7 @@ impl WideAngleDialog { { photocraft_engine::color_channels::restrict_surface_in(&self.proxy, &mut out, colors, pf).map_err(|e| e.to_string())?; } + self.mesh = Some(mesh); Ok(out) } @@ -153,6 +162,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> ph, src_tex: None, out_tex: None, + mesh: None, dirty: true, preview: true, drag: None, @@ -343,10 +353,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Orientation::Horizontal => t.warning, Orientation::Vertical => t.danger, }; - let pf = Rect::new(0, 0, d.pw as i32, d.ph as i32); - let mesh = (!show_src && !d.params.constraints.is_empty()).then(|| wideangle::solve(&d.proxy_params(), pf)); + let pf = d.proxy_rect(); + let mesh = if show_src { None } else { d.mesh.as_ref() }; for c in &d.params.constraints { - let pts: Vec = match &mesh { + let pts: Vec = match mesh { Some(m) => cam .arc(c.a, c.b, 24) .iter() @@ -457,6 +467,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" }); } fu.label(egui::RichText::new(format!("{:.0} ms · straightness {:.1} px", d.render_ms, d.residual)).color(t.text_faint)); + // Panel edits mark the warp dirty after the overlays were drawn: redraw so they catch up. + if d.dirty { + ctx.request_repaint(); + } }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { action = Some("cancel"); @@ -546,6 +560,75 @@ mod tests { menu(app, ctx, "filter.adaptiveWideAngle", &json!({"ui": {"add": {"a": [20, 50], "b": [140, 50]}}})).unwrap().unwrap(); } + #[test] + fn constraint_overlay_reuses_the_preview_mesh_on_redraw() { + fn constraint_line(shape: &egui::Shape) -> Option<&[Pos2]> { + match shape { + egui::Shape::Path(p) if p.points.len() == 25 => Some(&p.points), + egui::Shape::Vec(shapes) => shapes.iter().find_map(constraint_line), + _ => None, + } + } + let mut app = grey_layer(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + open_with_a_constraint(&mut app, &ctx); + let input = || egui::RawInput { screen_rect: Some(ERect::from_min_size(Pos2::ZERO, vec2(1000.0, 600.0))), ..Default::default() }; + // Baseline: the source-space line, from which the screen scale and centre are measured. + app.wide_angle.as_mut().unwrap().preview = false; + // egui panics if a frame's texture uploads are dropped unhandled, hence `clear`. + ctx.run_ui(input(), |ui| show(&mut app, ui.ctx())).textures_delta.clear(); + let mut output = ctx.run_ui(input(), |ui| show(&mut app, ui.ctx())); + output.textures_delta.clear(); + let line = output.shapes.iter().find_map(|s| constraint_line(&s.shape)).expect("baseline constraint line"); + let (first, last) = (line.first().unwrap().x, line.last().unwrap().x); + let base_x = (first + last) * 0.5; + // The constraint spans x = 20..140 of the 160 px layer; the proxy is 1:1 with it. + let px = (last - first) / 120.0; + // Move only the cached warp. A fresh solve would draw the original line instead. + let d = app.wide_angle.as_mut().unwrap(); + for (out, _) in &mut d.mesh.as_mut().unwrap().verts { + out[0] += 10.0; + } + let verts = d.mesh.as_ref().unwrap().verts.clone(); + let render_ms = d.render_ms; + for (preview, dragging, shifted) in [(true, false, true), (false, false, false), (true, true, false), (true, false, true)] { + let d = app.wide_angle.as_mut().unwrap(); + d.preview = preview; + d.drag = dragging.then_some(([20.0, 30.0], [140.0, 30.0])); + let mut output = ctx.run_ui(input(), |ui| show(&mut app, ui.ctx())); + output.textures_delta.clear(); + let line = output.shapes.iter().find_map(|s| constraint_line(&s.shape)).expect("painted constraint line"); + // The source line is centred on the image; the altered cached warp moves its + // centre ten proxy pixels to the right. Measure against the unshifted baseline. + let centre_x = (line.first().unwrap().x + line.last().unwrap().x) * 0.5; + let expected_x = base_x + if shifted { 10.0 * px } else { 0.0 }; + assert!((centre_x - expected_x).abs() < 0.1, "preview={preview}, dragging={dragging}: centre={centre_x}, expected={expected_x}"); + let d = app.wide_angle.as_ref().unwrap(); + assert_eq!(d.mesh.as_ref().unwrap().verts, verts, "redraw retains the cached warp"); + assert_eq!(d.render_ms, render_ms, "redraw does not render the preview again"); + } + } + + #[test] + fn preview_mesh_refreshes_after_edits_and_history_steps() { + let mut app = grey_layer(); + let ctx = egui::Context::default(); + open_with_a_constraint(&mut app, &ctx); + let original = app.wide_angle.as_ref().unwrap().mesh.as_ref().unwrap().verts.clone(); + menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"set": {"scale": 120.0}}})).unwrap().unwrap(); + let edited = app.wide_angle.as_ref().unwrap().mesh.as_ref().unwrap().verts.clone(); + assert_ne!(edited, original); + for (step, expected) in [(Step::Undo, &original), (Step::Redo, &edited)] { + let d = app.wide_angle.as_mut().unwrap(); + d.step(step); + assert!(d.dirty); + d.render(&ctx); + assert!(!d.dirty); + assert_eq!(&d.mesh.as_ref().unwrap().verts, expected); + } + } + /// `orient` sets a constraint's orientation, as the right-click menu does; a bad index is /// an error that changes nothing. #[test] diff --git a/crates/ui-egui/src/doc_tab_menu_tests.rs b/crates/ui-egui/src/doc_tab_menu_tests.rs new file mode 100644 index 0000000000..090455bd59 --- /dev/null +++ b/crates/ui-egui/src/doc_tab_menu_tests.rs @@ -0,0 +1,219 @@ +//! #11403: the document tab's right-click menu lists the reference application's eight entries in +//! order, with its two separators, and each entry runs the command it names on the clicked tab. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::{Value, json}; + +use crate::canvas::tabs::{TAB_MENU_SEPARATORS_AFTER, tab_context_items, tab_context_menu}; +use crate::state::DialogKind; +use crate::{FileDialogRequest, PhotocraftApp, Services}; + +/// The app, what the menu is drawn for, and the command the last click returned. +struct Probe { + app: PhotocraftApp, + index: usize, + has_path: bool, + picked: Option<(&'static str, Value)>, +} + +type Requests = Rc>>; + +fn probe(documents: usize, index: usize, has_path: bool) -> (Harness<'static, Probe>, Requests) { + let requests: Requests = Default::default(); + let held = requests.clone(); + let services = Services { file_dialog: Some(Box::new(move |request, _parent, _reply| held.borrow_mut().push(request))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + for n in 0..documents { + app.run("file.new", json!({"width": 10 + n, "height": 8})).unwrap(); + } + let state = Probe { app, index, has_path, picked: None }; + let mut h = Harness::builder().with_size(vec2(400.0, 500.0)).build_ui_state( + |ui, p: &mut Probe| { + let count = p.app.session.documents().len(); + if let Some(hit) = tab_context_menu(ui, p.index, count, p.has_path) { + p.picked = Some(hit); + } + }, + state, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + (h, requests) +} + +/// The drawn rows top to bottom: label, top edge, enabled. +fn rows(h: &Harness<'static, Probe>) -> Vec<(String, f32, bool)> { + let mut rows: Vec<(String, f32, bool)> = h + .query_all_by_role(egui::accesskit::Role::Button) + .map(|n| { + let a = n.accesskit_node(); + (a.label().unwrap_or_default().to_string(), n.rect().top(), !a.is_disabled()) + }) + .collect(); + rows.sort_by(|a, b| a.1.total_cmp(&b.1)); + rows +} + +/// Each document's width, which tells the documents apart (`probe` makes them 10, 11, 12 wide). +fn widths(h: &Harness<'static, Probe>) -> Vec { + h.state().app.session.documents().iter().map(|d| d.doc.size.width).collect() +} + +fn click(h: &mut Harness<'static, Probe>, label: &str) -> (&'static str, Value) { + h.get_by_label(label).click(); + h.run_steps(3); + h.state_mut().picked.take().unwrap_or_else(|| panic!("clicking {label} returned no command")) +} + +#[test] +fn the_menu_lists_the_eight_reference_entries_in_order() { + let (h, _) = probe(1, 0, false); + let labels: Vec = rows(&h).into_iter().map(|r| r.0).collect(); + assert_eq!( + labels, + [ + "Consolidate All to Here", + "Close", + "Close All", + "Close Others", + "Move to New Window", + "New Document…", + "Open Document…", + crate::menus::reveal_label(), + ] + ); +} + +#[test] +fn separators_follow_move_to_new_window_and_open_document() { + assert_eq!(TAB_MENU_SEPARATORS_AFTER, [4, 6]); + let (h, _) = probe(1, 0, false); + let tops: Vec = rows(&h).into_iter().map(|r| r.1).collect(); + let gaps: Vec = tops.windows(2).map(|w| w[1] - w[0]).collect(); + let plain = gaps[0]; + for (i, gap) in gaps.iter().enumerate() { + let separated = TAB_MENU_SEPARATORS_AFTER.contains(&i); + assert_eq!(*gap > plain + 1.0, separated, "gap after row {i} is {gap} against a plain {plain}"); + } +} + +#[test] +fn each_row_names_its_command_and_the_clicked_tabs_index() { + let items = tab_context_items(2, 3, true); + let menu = crate::menus::menu_items(&PhotocraftApp::new(photocraft_engine::Session::new(), Services::default())); + let want = [ + ("Consolidate All to Here", "window.arrange.consolidateAllToTabs", json!({})), + ("Close", "file.close", json!({"document": 2})), + ("Close All", "file.closeAll", json!({})), + ("Close Others", "file.closeOthers", json!({"document": 2})), + ("Move to New Window", "window.arrange.floatInWindow", json!({"document": 2})), + ("New Document…", "file.new", json!({})), + ("Open Document…", "file.open", json!({})), + (crate::menus::reveal_label(), "file.revealInFinder", json!({"document": 2})), + ]; + for ((label, id, params, _), (wl, wi, wp)) in items.iter().zip(want) { + assert_eq!((*label, *id, params), (wl, wi, &wp)); + assert!(menu.iter().any(|i| i.id == *id) || photocraft_engine::commands::find(id).is_some(), "{id} is a command"); + } +} + +#[test] +fn enabled_states_for_one_saved_one_unsaved_and_two_documents() { + let enabled = |documents: usize, has_path: bool| -> Vec { + let (h, _) = probe(documents, 0, has_path); + rows(&h).into_iter().map(|r| r.2).collect() + }; + // Consolidate, Close, Close All, Close Others, Move, New, Open, Reveal. + assert_eq!(enabled(1, true), [false, true, true, false, true, true, true, true], "one saved document"); + assert_eq!(enabled(1, false), [false, true, true, false, true, true, true, false], "one unsaved document"); + assert_eq!(enabled(2, true), [true, true, true, true, true, true, true, true], "two documents"); + assert_eq!(enabled(2, false), [true, true, true, true, true, true, true, false], "two documents, the clicked one unsaved"); +} + +#[test] +fn close_close_all_and_close_others_act_on_the_clicked_tab() { + let (mut h, _) = probe(3, 1, true); + let (id, params) = click(&mut h, "Close"); + assert_eq!((id, ¶ms), ("file.close", &json!({"document": 1}))); + let ctx = h.ctx.clone(); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + let names = widths(&h); + assert_eq!(names, [10, 12]); + + let (mut h, _) = probe(3, 1, true); + let (id, params) = click(&mut h, "Close Others"); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + let names = widths(&h); + assert_eq!(names, [11]); + + let (mut h, _) = probe(3, 1, true); + let (id, params) = click(&mut h, "Close All"); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + assert!(h.state().app.session.documents().is_empty()); +} + +#[test] +fn move_to_new_window_floats_the_clicked_tab_even_with_one_document() { + let ctx = egui::Context::default(); + let (mut h, _) = probe(1, 0, false); + let (id, params) = click(&mut h, "Move to New Window"); + assert_eq!((id, ¶ms), ("window.arrange.floatInWindow", &json!({"document": 0}))); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + assert!(h.state().app.ui.windows.iter().any(|w| w.floating && w.document == 0)); + + // The clicked tab (2) is not the active document (0). + let (mut h, _) = probe(3, 2, true); + h.state_mut().app.session.set_active(0); + assert_eq!(h.state().app.session.active_index(), Some(0)); + let (id, params) = click(&mut h, "Move to New Window"); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + let windows = &h.state().app.ui.windows; + assert!(windows.iter().any(|w| w.floating && w.document == 2), "the clicked tab floats"); + assert!(!windows.iter().any(|w| w.document == 0), "the active document does not float"); +} + +#[test] +fn consolidate_all_to_here_brings_floating_windows_back() { + let ctx = egui::Context::default(); + let (mut h, _) = probe(2, 0, true); + crate::menus::invoke(&mut h.state_mut().app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); + assert_eq!(h.state().app.ui.windows.len(), 2); + let (id, params) = click(&mut h, "Consolidate All to Here"); + assert_eq!((id, ¶ms), ("window.arrange.consolidateAllToTabs", &json!({}))); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + assert!(h.state().app.ui.windows.is_empty()); +} + +#[test] +fn new_document_opens_the_new_document_dialog() { + let ctx = egui::Context::default(); + let (mut h, _) = probe(1, 0, false); + let (id, params) = click(&mut h, "New Document…"); + assert_eq!((id, ¶ms), ("file.new", &json!({}))); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + assert!(h.state().app.ui.dialogs.iter().any(|d| d.kind == DialogKind::NewDocument)); +} + +#[test] +fn open_document_asks_for_files_like_file_open() { + let ctx = egui::Context::default(); + let (mut h, requests) = probe(1, 0, false); + let (id, params) = click(&mut h, "Open Document…"); + assert_eq!((id, ¶ms), ("file.open", &json!({}))); + crate::menus::invoke(&mut h.state_mut().app, &ctx, id, params).unwrap(); + h.state_mut().app.poll_file_dialog(&ctx, None); + let asked = requests.borrow(); + assert!(matches!(asked.as_slice(), [FileDialogRequest::Open { multiple: true, .. }]), "{asked:?}"); +} + +#[test] +fn reveal_keeps_its_platform_label_and_command() { + let (mut h, _) = probe(1, 0, true); + let (id, params) = click(&mut h, crate::menus::reveal_label()); + assert_eq!((id, ¶ms), ("file.revealInFinder", &json!({"document": 0}))); +} diff --git a/crates/ui-egui/src/edit_session.rs b/crates/ui-egui/src/edit_session.rs new file mode 100644 index 0000000000..887a6161b4 --- /dev/null +++ b/crates/ui-egui/src/edit_session.rs @@ -0,0 +1,111 @@ +//! A local draft of a value, independent of rendering and the application's undo engine. +//! +//! The host validates proposals and owns all document mutations. Dropping a session cancels +//! only its local draft. Store small parameters here, not a full document per pointer event. +//! A context must include stable document/target identity and a revision that changes on +//! every relevant edit (including undo/redo). Comparing values alone cannot detect ABA edits. + +/// Why a draft cannot be committed to the current host state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FinishError { + /// The most recent proposal was rejected. Correct it before finishing. + InvalidProposal, + /// Document, target, selection or relevant revision has changed. + ContextChanged, + /// The live value changed without the context being updated. + ValueChanged, +} + +/// A proposed change. The caller must apply it through its existing command engine. +#[derive(Debug, PartialEq, Eq)] +pub struct Change { + pub before: T, + pub after: T, +} + +/// A single-use edit. It never writes to a document or records history itself. +/// +/// This type intentionally does not implement `Clone`. `finish` consumes it, so the same +/// session cannot accidentally emit two commits. A failed finish also consumes the draft; +/// call `check` first if invalid input should remain editable in the UI. +#[must_use = "a draft must be retained, finished, or deliberately cancelled"] +#[derive(Debug)] +pub struct EditSession { + original: T, + preview: T, + context: C, + valid: bool, +} + +impl EditSession { + /// Begin from a value already validated by the application. + pub fn new(original: T, context: C) -> Self { + Self { preview: original.clone(), original, context, valid: true } + } +} + +impl EditSession { + pub fn original(&self) -> &T { + &self.original + } + + /// Last valid preview, even when a later incomplete/invalid input is present. + #[cfg(test)] + pub fn preview(&self) -> &T { + &self.preview + } + + pub fn context(&self) -> &C { + &self.context + } + + #[cfg(test)] + pub fn is_valid(&self) -> bool { + self.valid + } + + /// Accept a host-validated proposal. Errors invalidate commit without replacing the + /// last valid preview. Units, mixed values, finite checks and bounds belong to the host. + pub fn propose(&mut self, proposal: Result) -> Result<(), E> { + match proposal { + Ok(value) => { + self.preview = value; + self.valid = true; + Ok(()) + } + Err(error) => { + self.valid = false; + Err(error) + } + } + } + + /// Discard the local draft. No rollback commands or history entries are generated. + pub fn cancel(self) {} +} + +impl EditSession { + /// Check immediately before applying on the host's serialized command path. + /// This comparison is not a lock and is not a distributed transaction. + pub fn check(&self, live: &T, context: &C) -> Result<(), FinishError> { + if *context != self.context { + Err(FinishError::ContextChanged) + } else if *live != self.original { + Err(FinishError::ValueChanged) + } else if !self.valid { + Err(FinishError::InvalidProposal) + } else { + Ok(()) + } + } + + /// Emit at most one change; a no-op emits none, preserving the host's redo stack. + /// The host still validates and applies the command and handles execution failure. + pub fn finish(self, live: &T, context: &C) -> Result>, FinishError> { + self.check(live, context)?; + if self.original == self.preview { Ok(None) } else { Ok(Some(Change { before: self.original, after: self.preview })) } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/edit_session/tests.rs b/crates/ui-egui/src/edit_session/tests.rs new file mode 100644 index 0000000000..3ed6a35fb9 --- /dev/null +++ b/crates/ui-egui/src/edit_session/tests.rs @@ -0,0 +1,75 @@ +use super::{Change, EditSession, FinishError}; + +#[test] +fn many_previews_produce_one_final_change() { + let mut draft = EditSession::new(100, ("photo", "layer-1", 9)); + for value in 0..1000 { + assert_eq!(draft.propose::<()>(Ok(value)), Ok(())); + assert_eq!(*draft.original(), 100); + } + assert_eq!(draft.finish(&100, &("photo", "layer-1", 9)), Ok(Some(Change { before: 100, after: 999 }))); +} + +#[test] +fn invalid_input_retains_preview_but_blocks_commit_until_corrected() { + let mut draft = EditSession::new(10, 0); + assert_eq!(draft.propose::<&str>(Ok(12)), Ok(())); + assert_eq!(draft.propose(Err("incomplete number")), Err("incomplete number")); + assert_eq!(*draft.preview(), 12); + assert!(!draft.is_valid()); + assert_eq!(draft.check(&10, &0), Err(FinishError::InvalidProposal)); + assert_eq!(draft.propose::<&str>(Ok(14)), Ok(())); + assert_eq!(draft.finish(&10, &0), Ok(Some(Change { before: 10, after: 14 }))); +} + +#[test] +fn unchanged_and_return_to_original_have_no_commit() { + assert_eq!(EditSession::new(20, 0).finish(&20, &0), Ok(None)); + let mut draft = EditSession::new(20, 0); + assert_eq!(draft.propose::<()>(Ok(50)), Ok(())); + assert_eq!(draft.propose::<()>(Ok(20)), Ok(())); + assert_eq!(draft.finish(&20, &0), Ok(None)); +} + +#[test] +fn stale_document_target_or_revision_cannot_commit() { + for context in [(2, 5, 8), (1, 6, 8), (1, 5, 9)] { + let draft = EditSession::new(100, (1, 5, 8)); + assert_eq!(draft.finish(&100, &context), Err(FinishError::ContextChanged)); + } +} + +#[test] +fn unversioned_value_change_is_also_rejected() { + assert_eq!(EditSession::new(100, 1).finish(&50, &1), Err(FinishError::ValueChanged)); +} + +#[test] +fn undo_back_to_equal_value_is_stale_when_revision_has_advanced() { + assert_eq!(EditSession::new(100, 1).finish(&100, &3), Err(FinishError::ContextChanged)); +} + +#[test] +fn invalid_final_input_does_not_silently_commit_last_valid_value() { + let mut draft = EditSession::new(10, 0); + assert_eq!(draft.propose::<&str>(Ok(12)), Ok(())); + assert!(draft.propose(Err("not finite")).is_err()); + assert_eq!(draft.finish(&10, &0), Err(FinishError::InvalidProposal)); +} + +#[test] +fn optional_values_preserve_inheritance_instead_of_flattening_it() { + let mut draft = EditSession::new(None::, 0); + assert_eq!(draft.propose::<()>(Ok(Some(20))), Ok(())); + assert_eq!(draft.propose::<()>(Ok(None)), Ok(())); + assert_eq!(draft.finish(&None, &0), Ok(None)); +} + +#[test] +fn cancel_does_not_touch_host_value() { + let live = String::from("original"); + let mut draft = EditSession::new(live.clone(), 1); + assert_eq!(draft.propose::<()>(Ok("preview".into())), Ok(())); + draft.cancel(); + assert_eq!(live, "original"); +} diff --git a/crates/ui-egui/src/empty_workspace_tests.rs b/crates/ui-egui/src/empty_workspace_tests.rs index f7473dbccc..e1665c3fd4 100644 --- a/crates/ui-egui/src/empty_workspace_tests.rs +++ b/crates/ui-egui/src/empty_workspace_tests.rs @@ -77,12 +77,12 @@ fn double_clicking_the_empty_workspace_shows_file_open() { #[test] fn the_home_screens_own_widgets_keep_their_double_clicks() { let (mut h, requests) = harness(true); - // The subtitle is selectable text: a double-click there selects a word, not File › Open. - let at = h.get_by_label_contains("Create a new document or open an existing file").rect().center(); + // The Home rail button: a double-click there stays on Home, not File › Open. + let at = h.get_by_label_contains("Home").rect().center(); clicks(&mut h, at, 2); - assert_eq!(opens(&requests), 0, "the label took the double-click"); - // The New document button opens the New Document dialog, not File › Open. - let at = h.get_by_label_contains("New document").rect().center(); + assert_eq!(opens(&requests), 0, "the rail item took the double-click"); + // The New file button opens the New Document dialog, not File › Open. + let at = h.get_by_label_contains("New file").rect().center(); clicks(&mut h, at, 1); assert_eq!(opens(&requests), 0); assert_eq!(h.state().ui.dialogs.len(), 1, "the New Document dialog"); diff --git a/crates/ui-egui/src/export_quality_tests.rs b/crates/ui-egui/src/export_quality_tests.rs new file mode 100644 index 0000000000..1f1bfd88ae --- /dev/null +++ b/crates/ui-egui/src/export_quality_tests.rs @@ -0,0 +1,230 @@ +//! #11481: File › Export › Artboards to Files (and Layers / Layer Comps to Files) has a JPEG +//! Quality field behind a Custom checkbox, as Batch has (#3956). The quality reaches the command +//! only for a JPEG with Custom on: left alone, an export writes what the command writes with no +//! quality (the source JPEG's own quality, else the encoder default), a WebP stays lossless and +//! PNG has none. +use crate::PhotocraftApp; +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::{Value, json}; + +const ARTBOARDS: &str = "file.export.artboardsToFiles"; +const EXPORTS: [&str; 3] = [ARTBOARDS, "file.export.layersToFiles", "file.export.layerCompsToFiles"]; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()) + }); + let app = h.state_mut(); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + // A noisy layer inside the board, so the JPEG quality shows in the file. + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + app.run("filter.noise.addNoise", json!({"amount": 60})).unwrap(); + app.run("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap(); + app.run("layerComp.new", json!({"name": "Comp"})).unwrap(); + app.sync_views(); + h +} + +fn open(h: &mut Harness<'_, PhotocraftApp>, id: &str, dir: &str, format: &str) -> u64 { + let ctx = h.ctx.clone(); + let dialog = crate::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("dir".into(), json!(dir)); + fields.insert("prefix".into(), json!("out")); + fields.insert("format".into(), json!(format)); + h.run_steps(4); + dialog +} + +fn params(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Value { + crate::filter_dialog::params_of(&h.state_mut().ui.dialog_mut(dialog).unwrap().fields) +} + +fn exported(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Vec { + let r = crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + std::fs::read(r["files"][0].as_str().unwrap()).unwrap() +} + +/// The same export run straight through the command, into `dir`. +fn direct(h: &mut Harness<'_, PhotocraftApp>, dir: &str, format: &str, quality: Option) -> Vec { + let mut p = json!({"dir": dir, "prefix": "out", "format": format}); + if let Some(q) = quality { + p["quality"] = json!(q); + } + let r = h.state_mut().run(ARTBOARDS, p).unwrap(); + std::fs::read(r["files"][0].as_str().unwrap()).unwrap() +} + +/// Whether the Quality number field (not its row label) takes input. +fn quality_enabled(h: &Harness<'_, PhotocraftApp>) -> bool { + let fields: Vec = + h.query_all_by_label("Quality").filter(|n| n.accesskit_node().role() != Role::Label).map(|n| !n.accesskit_node().is_disabled()).collect(); + assert!(!fields.is_empty(), "the dialog shows a Quality field"); + assert!(fields.iter().all(|e| *e == fields[0]), "{fields:?}"); + fields[0] +} + +/// Whether the Custom checkbox is ticked. +fn custom_ticked(h: &Harness<'_, PhotocraftApp>) -> bool { + h.get_by_role_and_label(Role::CheckBox, "Custom").accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +/// The value the Quality field shows. +fn shown_quality(h: &Harness<'_, PhotocraftApp>) -> Option { + h.get_by_role_and_label(Role::SpinButton, "Quality").accesskit_node().numeric_value() +} + +fn tick_custom(h: &mut Harness<'_, PhotocraftApp>) { + h.get_by_role_and_label(Role::CheckBox, "Custom").click(); + h.run_steps(2); +} + +/// Types `text` into the Quality field and commits it with Tab, as a user does. +fn type_quality(h: &mut Harness<'_, PhotocraftApp>, text: &str) { + h.get_by_role_and_label(Role::SpinButton, "Quality").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + for ch in text.chars() { + h.event(egui::Event::Text(ch.to_string())); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(2); +} + +/// Holds Alt, which turns Cancel into Reset, and clicks it. +fn alt_reset(h: &mut Harness<'_, PhotocraftApp>) { + let alt = egui::Modifiers::ALT; + h.event(egui::Event::ModifiersChanged(alt)); + h.run_steps(2); + let at = h.get_by_label("Reset").rect().center(); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: alt }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); +} + +fn tmp() -> (tempfile::TempDir, String) { + let t = tempfile::tempdir().unwrap(); + let s = t.path().to_string_lossy().into_owned(); + (t, s) +} + +#[test] +fn artboards_to_files_sends_a_custom_jpeg_quality() { + let mut h = harness(); + let (_a, via_dialog) = tmp(); + let (_b, via_command) = tmp(); + let mut sizes = Vec::new(); + for quality in [0, 3, 12] { + let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg"); + assert!(!custom_ticked(&h) && !quality_enabled(&h), "JPEG with Custom off: the field is greyed out"); + tick_custom(&mut h); + assert!(custom_ticked(&h) && quality_enabled(&h), "JPEG with Custom on: the field takes input"); + assert_eq!(params(&mut h, dialog)["quality"], json!(10), "Custom starts at quality 10"); + type_quality(&mut h, &quality.to_string()); + assert_eq!(shown_quality(&h), Some(quality as f64), "the field shows the typed quality"); + assert_eq!(params(&mut h, dialog)["quality"], json!(quality), "the typed quality reaches the command"); + let bytes = exported(&mut h, dialog); + assert!(bytes == direct(&mut h, &via_command, "jpg", Some(quality)), "quality {quality}: the file the command writes at that quality"); + sizes.push(bytes.len()); + } + assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "a higher quality writes a bigger JPEG: {sizes:?}"); +} + +#[test] +fn an_untouched_jpeg_export_writes_what_the_command_writes_with_no_quality() { + let mut h = harness(); + let (_a, via_dialog) = tmp(); + let (_b, via_command) = tmp(); + let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg"); + assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "no quality is sent"); + let bytes = exported(&mut h, dialog); + assert!(bytes == direct(&mut h, &via_command, "jpg", None), "the untouched dialog keeps the command's own JPEG quality"); +} + +#[test] +fn export_to_files_quality_leaves_png_and_webp_as_they_were() { + let mut h = harness(); + let (_a, dir) = tmp(); + for id in EXPORTS { + for format in ["png", "webp"] { + let dialog = open(&mut h, id, &dir, format); + // Custom on from an earlier JPEG choice still sends nothing for these formats. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true)); + h.run_steps(2); + assert!(!quality_enabled(&h), "{id} {format}: the field is greyed out"); + assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "{id} {format}: no quality is sent"); + let bytes = exported(&mut h, dialog); + if format == "webp" { + // The default WebP stays lossless (a VP8L bitstream, not lossy VP8). + assert!(bytes.windows(4).any(|w| w == b"VP8L"), "{id}: the WebP stays lossless"); + } + } + // Back to JPEG with Custom on, the field sends its quality again. + let dialog = open(&mut h, id, &dir, "jpg"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true)); + h.run_steps(2); + assert!(quality_enabled(&h), "{id}"); + assert_eq!(params(&mut h, dialog)["quality"], json!(10), "{id}"); + crate::dialogs::cancel(h.state_mut(), dialog).unwrap(); + } +} + +/// A Custom quality survives a switch to another format and back in the same dialog. +#[test] +fn the_custom_quality_survives_a_format_round_trip() { + let mut h = harness(); + let (_a, dir) = tmp(); + let dialog = open(&mut h, ARTBOARDS, &dir, "jpg"); + tick_custom(&mut h); + type_quality(&mut h, "4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4)); + for format in ["png", "webp"] { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!(format)); + h.run_steps(2); + assert!(!quality_enabled(&h), "{format}: the field is greyed out"); + assert!(params(&mut h, dialog)["quality"].is_null(), "{format}: no quality is sent"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(2); + assert!(custom_ticked(&h) && quality_enabled(&h), "back from {format}: Custom is still on"); + assert_eq!(shown_quality(&h), Some(4.0), "back from {format}: the field still shows 4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4), "back from {format}: quality 4 is sent"); + } +} + +/// Alt+Reset (Alt held, Cancel clicked as Reset) puts the Custom checkbox and its quality back to +/// how the dialog opened, and keeps the dialog open. +#[test] +fn alt_reset_restores_the_custom_quality() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let dialog = crate::menus::invoke(h.state_mut(), &ctx, ARTBOARDS, json!({})).unwrap()["dialog"].as_u64().unwrap(); + // JPEG chosen in the dialog, after it opened: Reset puts the format back too. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(4); + tick_custom(&mut h); + type_quality(&mut h, "4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4)); + alt_reset(&mut h); + assert!(h.state().ui.dialogs.iter().any(|d| d.id == dialog), "Reset keeps the dialog open"); + let p = params(&mut h, dialog); + assert_eq!((&p["format"], &p["quality"]), (&json!("png"), &Value::Null), "the opening format, no quality"); + assert!(!custom_ticked(&h) && !quality_enabled(&h), "Custom is off again"); + assert_eq!(shown_quality(&h), Some(10.0), "the field shows the opening quality"); + // Choosing JPEG again still sends no quality until Custom is ticked. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(2); + assert!(!custom_ticked(&h), "JPEG after Reset: Custom stays off"); + assert!(params(&mut h, dialog)["quality"].is_null(), "JPEG after Reset: no quality is sent"); +} diff --git a/crates/ui-egui/src/eyedropper_mask_sample_tests.rs b/crates/ui-egui/src/eyedropper_mask_sample_tests.rs new file mode 100644 index 0000000000..d36de6b33c --- /dev/null +++ b/crates/ui-egui/src/eyedropper_mask_sample_tests.rs @@ -0,0 +1,178 @@ +use egui::Modifiers; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; +use crate::{PhotocraftApp, Services}; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// Select the Eyedropper and target a fully revealed mask on a red pixel layer or an Invert adjustment above it. +fn app_with_mask(adjustment: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 8, "height": 8, "background": "transparent"})).unwrap(); + app.session + .edit("Paint red", |doc, _| { + let surface = doc.layers[0].surface_mut().unwrap(); + surface.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&surface.format(), RED)); + Ok(()) + }) + .unwrap(); + let add = if adjustment { "layer.newAdjustmentLayer.invert" } else { "layer.layerMask.revealAll" }; + app.run(add, json!({})).unwrap(); + assert!(app.ui.mask_target); + app.ui.tool = Tool::Eyedropper; + app +} + +fn paint_mask(app: &mut PhotocraftApp, rect: Rect, gray: f32) { + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("Paint mask", |doc, _| { + doc.layer_mut(layer).unwrap().mask.as_mut().unwrap().surface.fill_rect(rect, &[gray]); + Ok(()) + }) + .unwrap(); +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, mods: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x, y }, mods); +} + +fn gray(value: f32) -> [f32; 4] { + [value, value, value, 1.0] +} + +/// Both measured modes pick white and mid-grey from the selected mask on either layer kind. +#[test] +fn targeted_pixel_and_adjustment_masks_supply_point_samples() { + for adjustment in [false, true] { + let mut app = app_with_mask(adjustment); + paint_mask(&mut app, Rect::new(2, 2, 3, 3), 0.5); + for mode in ["current", "currentAndBelow"] { + app.ui.tool_options.eyedropper_sample = mode.into(); + app.run("tools.setColors", json!({"foreground": "#222222"})).unwrap(); + click(&mut app, 1.5, 1.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, gray(1.0), "adjustment={adjustment} {mode}"); + click(&mut app, 2.5, 2.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, gray(128.0 / 255.0), "adjustment={adjustment} {mode}"); + } + } +} + +/// The average at the corner counts only four in-canvas mask pixels and Alt writes background. +#[test] +fn mask_average_clips_at_canvas_edge_and_alt_sets_background() { + let mut app = app_with_mask(false); + paint_mask(&mut app, Rect::new(0, 0, 1, 2), 0.0); + app.ui.tool_options.eyedropper_sample = "currentAndBelow".into(); + app.ui.tool_options.eyedropper_size = 3; + app.run("tools.setColors", json!({"foreground": [0.2, 0.2, 0.2, 1.0], "background": [0.8, 0.8, 0.8, 1.0]})).unwrap(); + let foreground = app.session.tools.foreground; + click(&mut app, 0.5, 0.5, Modifiers::ALT); + assert_eq!(app.session.tools.background, gray(0.5)); + assert_eq!(app.session.tools.foreground, foreground); +} + +/// Composite modes use the image, and selecting contents restores the same sampling as before. +#[test] +fn composite_modes_and_pixel_targets_still_sample_the_image() { + let mut app = app_with_mask(true); + for mode in ["all", "allNoAdjustments", "currentAndBelowNoAdjustments"] { + app.ui.tool_options.eyedropper_sample = mode.into(); + let sample = app.run("document.sampleColor", json!({"x": 4.5, "y": 4.5, "sampleLayer": mode})).unwrap(); + let [r, g, b, _]: [f32; 4] = serde_json::from_value(sample).unwrap(); + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, gray(photocraft_color::convert::rgb_to_gray([r, g, b])), "{mode}"); + } + // An adjustment's content thumbnail can still route painting to its mask. That is not an + // explicit Eyedropper target. + app.run("channel.target.composite", json!({})).unwrap(); + assert!(!app.ui.mask_target); + app.ui.tool_options.eyedropper_sample = "current".into(); + let before = app.session.tools.foreground; + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, before, "the adjustment alone has no image pixels"); + + let mut app = app_with_mask(false); + app.run("channel.target.composite", json!({})).unwrap(); + for mode in ["current", "currentAndBelow"] { + app.ui.tool_options.eyedropper_sample = mode.into(); + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, RED, "{mode}"); + } +} + +/// A white raster mask beside another selected target cannot take over that target's sample. +#[test] +fn vector_alpha_quick_mask_and_temporary_samples_keep_their_source() { + let red_gray = gray(photocraft_color::convert::rgb_to_gray([1.0, 0.0, 0.0])); + let mut app = app_with_mask(false); + app.ui.tool_options.eyedropper_sample = "current".into(); + app.ui.tool = Tool::Brush; + click(&mut app, 4.5, 4.5, Modifiers::ALT); + assert_eq!(app.session.tools.foreground, red_gray, "the brush's Alt-click still samples the image"); + + app.ui.tool = Tool::Eyedropper; + app.run("layer.vectorMask.revealAll", json!({})).unwrap(); + assert!(app.ui.vector_mask_target); + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, RED, "a vector target does not pick the raster mask"); + + app.run("channel.new", json!({"name": "Alpha"})).unwrap(); + app.run("channel.target", json!({"channel": {"alpha": 0}})).unwrap(); + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, red_gray, "an alpha target does not pick the raster mask"); + + app.run("channel.target.layerMask", json!({})).unwrap(); + app.run("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, red_gray, "Quick Mask does not pick the raster mask"); +} + +/// A smart filter's mask keeps its image-based sample even when the same layer has a raster mask. +#[test] +fn filter_mask_target_keeps_the_image_based_sample() { + let mut app = app_with_mask(false); + app.background_jobs = false; + app.run("channel.target.composite", json!({})).unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + assert!(app.session.active().unwrap().doc.layer(layer).unwrap().mask.is_some()); + app.select_filter_mask_target(Some(layer.0)); + assert!(app.filter_mask_targeted()); + app.ui.tool_options.eyedropper_sample = "current".into(); + let sample = app.run("document.sampleColor", json!({"x": 4.5, "y": 4.5, "sampleLayer": "current"})).unwrap(); + let [r, g, b, a]: [f32; 4] = serde_json::from_value(sample).unwrap(); + let want = gray(photocraft_color::convert::rgb_to_gray([r, g, b])); + assert!(a > 0.0 && want != gray(1.0), "the image and layer mask are distinct"); + click(&mut app, 4.5, 4.5, Modifiers::NONE); + assert_eq!(app.session.tools.foreground, want); +} + +/// Out-of-canvas points and invalid sample options leave both swatches intact. +#[test] +fn invalid_mask_samples_leave_both_swatches_unchanged() { + let mut app = app_with_mask(false); + app.ui.tool_options.eyedropper_sample = "current".into(); + app.run("tools.setColors", json!({"foreground": [0.2, 0.2, 0.2, 1.0], "background": [0.8, 0.8, 0.8, 1.0]})).unwrap(); + let before = (app.session.tools.foreground, app.session.tools.background); + let assert_unchanged = |app: &PhotocraftApp| assert_eq!((app.session.tools.foreground, app.session.tools.background), before); + for (x, y) in [(-0.1, 1.0), (1.0, 8.0), (f64::NAN, 1.0), (1.0, f64::INFINITY)] { + for mods in [Modifiers::NONE, Modifiers::ALT] { + click(&mut app, x, y, mods); + assert_unchanged(&app); + } + } + app.ui.tool_options.eyedropper_size = 2; + click(&mut app, 1.0, 1.0, Modifiers::NONE); + assert_unchanged(&app); + app.ui.tool_options.eyedropper_size = 1; + app.ui.tool_options.eyedropper_sample = "invalid".into(); + click(&mut app, 1.0, 1.0, Modifiers::ALT); + assert_unchanged(&app); +} diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 52149ab8e0..abebcac5f4 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -322,6 +322,39 @@ fn menu_place_starts_transform_for_each_chosen_image() { } } +/// #10031: File › Place decodes in the background; Free Transform starts when the layer lands, and +/// a second place is refused while the first runs. +#[test] +fn menu_place_runs_in_the_background_and_starts_transform_when_it_lands() { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + app.background_jobs = true; + let ctx = egui::Context::default(); + let layers = app.session.active().unwrap().doc.layer_count(); + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("drawing.png".into(), bytes.clone()))); + app.poll_file_dialog(&ctx, None); + let place = app.jobs.place.clone().expect("being placed"); + assert_eq!(place.name, "drawing.png"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + assert_eq!(app.session.active().unwrap().doc.layer_count(), layers); + // Another place is refused while this one runs, and the first one stays tracked. + assert!(crate::jobs_ui::start_place_bytes(&mut app, "other.png", bytes, None, &serde_json::json!({})).is_err()); + assert_eq!(app.jobs.place.as_ref().map(|p| p.job), Some(place.job)); + let t = std::time::Instant::now(); + while app.jobs.place.is_some() { + assert!(t.elapsed() < std::time::Duration::from_secs(30), "the place did not finish"); + crate::jobs_ui::tick(&mut app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer_count(), layers + 1); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "drawing"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); +} + #[test] fn cancelled_or_invalid_menu_place_does_not_start_transform() { for command in ["file.placeEmbedded", "file.placeLinked"] { diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 814fc4e114..865f65d7ef 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -6,6 +6,7 @@ //! are shown as errors (status bar + notice); import warnings as a notice. use crate::{PhotocraftApp, notices}; +use photocraft_doc::DocId; use photocraft_engine::file_cmds::{is_template, untitled_name}; /// A request from the operating system, delivered by the platform shell through @@ -33,8 +34,11 @@ pub enum DropTarget { Elsewhere, } -/// A file dropped on the canvas, waiting its turn to be placed. +/// A file dropped on the canvas, waiting its turn to be placed in the document it was dropped on +/// (queued with its [`DocId`] in `drop_places`). pub(crate) enum DropPlace { + /// Destination captured when the file is dropped, before a later pointer move. + At { source: Box, frame: Option }, /// Dropped onto the window (desktop): read from its path, or through egui. File(egui::DroppedFileHandle), /// Read by the shell itself (the browser reads dropped files asynchronously): its name and @@ -42,6 +46,48 @@ pub(crate) enum DropPlace { Bytes(String, Vec), } +impl DropPlace { + /// The file contents held while it waits for its document (a path drop holds none yet). + fn queued_bytes(&self) -> usize { + match self { + Self::At { source, .. } => source.queued_bytes(), + Self::File(_) => 0, + Self::Bytes(_, bytes) => bytes.len(), + } + } + + /// Whether it holds its file's contents while it waits (a browser drop); a path drop is read + /// only when its turn comes. + fn holds_bytes(&self) -> bool { + match self { + Self::At { source, .. } => source.holds_bytes(), + Self::File(_) => false, + Self::Bytes(..) => true, + } + } +} + +/// The most browser drops, each holding its whole file, that can wait for their document to come +/// back in view. Path drops hold only their path and aren't counted: dropping a folder's worth of +/// files on the canvas places them all. +const MAX_QUEUED_DROPS: usize = 32; +/// The most dropped file contents kept in memory while they wait: a browser drop carries its whole +/// file, and anything queued on a document in another tab stays until it is back in view. +const MAX_QUEUED_DROP_BYTES: usize = 256 * 1024 * 1024; + +/// Whether the drop queue takes a browser drop of `incoming` bytes when it holds `queued` of them +/// and `bytes` in all. +fn queue_takes_drop(queued: usize, bytes: usize, incoming: usize) -> bool { + queued < MAX_QUEUED_DROPS && bytes.saturating_add(incoming) <= MAX_QUEUED_DROP_BYTES +} + +/// Whether the shell's place inbox, holding `files` files of `bytes` bytes together, takes one more +/// of `incoming` bytes: the limits of the drop queue it is drained into, so browser reads that +/// finish between two frames can't hold more than the queue itself would. +pub fn place_inbox_takes(files: usize, bytes: usize, incoming: usize) -> bool { + queue_takes_drop(files, bytes, incoming) +} + /// `warnings` from an import, plus one for each font family the active document's type uses that /// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and /// automation already report them; the type would otherwise silently render in a fallback face. @@ -245,6 +291,11 @@ impl PhotocraftApp { }); } + /// Show "Couldn't place : " as an error (status bar + notice). + pub fn place_failed(&mut self, name: &str, err: &str) { + self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", name), ("error", err)])); + } + /// Log `msg` and show it as an error (status bar + notice). fn failed(&mut self, msg: String) { log::warn!("{msg}"); @@ -256,6 +307,7 @@ impl PhotocraftApp { /// absolute path, read in bounded reads (#375) rather than egui's whole-file read. pub fn open_dropped(&mut self, ctx: &egui::Context, files: Vec, at: Option) { let target = self.drop_target(ctx, at); + let frame = at.and_then(|at| crate::canvas::ViewXform::active(self).map(|xf| xf.to_doc(at))).and_then(|p| crate::frame_tool::frame_at(self, p)); let mut slot = match target { DropTarget::Tabs(slot) => Some(slot), DropTarget::Canvas | DropTarget::Elsewhere => None, @@ -269,7 +321,9 @@ impl PhotocraftApp { for f in files { let name = dropped_name(&f); if target == DropTarget::Canvas && !crate::preset_files_ui::is_preset_file(&name) { - self.drop_places.push_back(DropPlace::File(f)); + if let Some(doc) = self.session.active().map(|st| st.doc.id) { + self.queue_drop(doc, DropPlace::At { source: Box::new(DropPlace::File(f)), frame }); + } continue; } match self.open_dropped_file(&f, &name, slot) { @@ -292,8 +346,12 @@ impl PhotocraftApp { let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); - if places.contains(&true) && self.ui.transform.is_some() { - crate::transform_tool::commit(self); + if places.contains(&true) { + // The shell reads the files after this returns: they go to this document even if the + // tab changes before they arrive. + if self.ui.transform.is_some() { + crate::transform_tool::commit(self); + } } places } @@ -334,38 +392,132 @@ impl PhotocraftApp { /// Place the next file dropped on the canvas and start Free Transform on it, once no other /// transform is in progress (so several files are placed and transformed one after another). + /// With background jobs on (or a session that steps its jobs, as on the web), a file is placed + /// by a job: the next one waits until that job has landed and its Free Transform has been + /// applied (see `jobs_ui::finish_place`). + /// + /// Each file goes into the document it was dropped on, never into whichever one is active now: + /// files dropped on a document in another tab wait until it is back in view, and are discarded + /// (with a message) when it is closed. pub(crate) fn place_next_dropped(&mut self, ctx: &egui::Context) { - while self.ui.transform.is_none() { - let Some(next) = self.drop_places.pop_front() else { return }; + // Leave the closed-document notice up for this frame: starting the next queued placement + // would clear the status bar. The next frame retries the queue, so ask for one at once: + // another document's files would otherwise wait for the next input event. + if self.discard_drops_of_closed_documents() { + if !self.drop_places.is_empty() { + ctx.request_repaint(); + } + return; + } + while self.ui.transform.is_none() && self.jobs.place.is_none() && self.session.active_job().is_none() { + let Some(active) = self.session.active().map(|st| st.doc.id) else { return }; + let Some((_, next)) = self.drop_places.iter().position(|(doc, _)| *doc == active).and_then(|i| self.drop_places.remove(i)) else { + return; + }; + let (next, frame) = match next { + DropPlace::At { source, frame } => (*source, Some(frame)), + next => (next, None), + }; + let params = frame.map_or_else( + || serde_json::json!({}), + |frame| serde_json::json!({"frame": frame.map_or(serde_json::Value::Bool(false), |id| serde_json::json!(id.0))}), + ); let (name, placed) = match next { - DropPlace::File(f) if f.path().is_absolute() => { - (dropped_name(&f), path_text(f.path(), false).and_then(|path| self.run("file.placeEmbedded", serde_json::json!({"path": path})))) + DropPlace::At { .. } => { + self.failed(tl!("Invalid nested file drop").into()); + continue; } + DropPlace::File(f) if f.path().is_absolute() => ( + dropped_name(&f), + path_text(f.path(), false).and_then(|path| { + let mut p = params.clone(); + p["path"] = serde_json::json!(path); + self.run("file.placeEmbedded", p) + }), + ), DropPlace::File(f) => { let name = dropped_name(&f); - let placed = crate::read_dropped(&*f).and_then(|bytes| self.place_bytes(&name, bytes, None)); + let placed = crate::read_dropped(&*f).and_then(|bytes| self.place_bytes_with(&name, bytes, None, ¶ms)); (name, placed) } DropPlace::Bytes(name, bytes) => { - let placed = self.place_bytes(&name, bytes, None); + let placed = self.place_bytes_with(&name, bytes, None, ¶ms); (name, placed) } }; match placed { - Ok(_) => { + // Running in the background: Free Transform starts when the layer has landed. + Ok(started) if crate::jobs_ui::track_place(self, &name, &started) => return, + Ok(result) => { + if result.get("frame").and_then(serde_json::Value::as_u64).is_some() { + continue; + } if let Err(e) = crate::transform_tool::begin_placed(self, ctx) { self.ui.status = e; } } - Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &name), ("error", &e)])), + Err(e) => self.place_failed(&name, &e), } } } + /// Queue the web drops onto the canvas that the shell has read since the last frame + /// ([`Services::place_inbox`](crate::Services::place_inbox)), for the document they were + /// dropped on. + pub(crate) fn queue_web_places(&mut self) { + let arrived: Vec<(DocId, String, Vec)> = + self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + for (doc, name, bytes) in arrived { + self.queue_drop(doc, DropPlace::Bytes(name, bytes)); + } + } + + /// Queue a canvas drop for the document it was dropped on, keeping the file contents it holds + /// bounded: files dropped on a document in another tab wait here, and a browser drop carries + /// its whole file. A path drop holds no contents, so any number of them queue. + fn queue_drop(&mut self, doc: DocId, place: DropPlace) { + let held = self.drop_places.iter().filter(|(_, p)| p.holds_bytes()).count(); + let bytes = self.drop_places.iter().fold(0usize, |sum, (_, p)| sum.saturating_add(p.queued_bytes())); + if place.holds_bytes() && !queue_takes_drop(held, bytes, place.queued_bytes()) { + self.ui.status = tl!("Dropped files weren't placed: too many are waiting for their document.").into(); + self.ui.status_error = true; + return; + } + self.drop_places.push_back((doc, place)); + } + + /// Drop the queued files whose document has been closed, saying so. True when one was dropped + /// (the caller lets the notice stand). + fn discard_drops_of_closed_documents(&mut self) -> bool { + if self.drop_places.is_empty() { + return false; + } + let before = self.drop_places.len(); + let open: Vec = self.session.documents().iter().map(|st| st.doc.id).collect(); + self.drop_places.retain(|(doc, _)| open.contains(doc)); + let discarded = self.drop_places.len() < before; + if discarded { + self.ui.status = tl!("Dropped files weren't placed: their document was closed.").into(); + self.ui.status_error = false; + } + discarded + } + /// Place a file's bytes as a smart object layer (File › Place; `linked`: Place Linked, to that - /// path), for files that weren't read from a path the command can read. + /// path), for files that weren't read from a path the command can read. With background jobs + /// on it starts a job (`{"job", "pending": true}`; see [`crate::jobs_ui::track_place`]). pub(crate) fn place_bytes(&mut self, name: &str, bytes: Vec, linked: Option) -> Result { - let r = photocraft_engine::file_cmds::place_bytes(&mut self.session, name, bytes, linked, &serde_json::json!({})).map_err(|e| e.to_string()); + self.place_bytes_with(name, bytes, linked, &serde_json::json!({})) + } + + /// [`PhotocraftApp::place_bytes`] with the command's `params` (a frame to fill): a job where + /// places run in the background, else synchronously. + fn place_bytes_with(&mut self, name: &str, bytes: Vec, linked: Option, params: &serde_json::Value) -> Result { + let r = if crate::jobs_ui::place_in_background(self) { + crate::jobs_ui::start_place_bytes(self, name, bytes, linked, params) + } else { + photocraft_engine::file_cmds::place_bytes(&mut self.session, name, bytes, linked, params).map_err(|e| e.to_string()) + }; self.sync_views(); r } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index bd74eceb9e..a617332bd1 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -189,8 +189,9 @@ fn web_drops_onto_the_canvas_are_placed_from_their_bytes() { at.set(egui::pos2(20.0, 20.0)); assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false], "off the canvas: opened"); let layers = layer_count(&app); - app.drop_places.push_back(crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3))); - app.drop_places.push_back(crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec())); + let doc = app.session.active().unwrap().doc.id; + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3)))); + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec()))); app.place_next_dropped(&ctx); assert_eq!(layer_count(&app), layers + 1); assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); @@ -538,6 +539,548 @@ fn a_separate_drop_applies_the_open_transform_and_places_at_once() { assert_eq!(history_steps(&app), steps + 2); } +/// Apply the jobs that have finished, frame by frame, until `done` holds (or 30 s pass). +fn tick_until(app: &mut PhotocraftApp, ctx: &egui::Context, done: impl Fn(&PhotocraftApp) -> bool) { + let t = std::time::Instant::now(); + while !done(app) { + assert!(t.elapsed() < std::time::Duration::from_secs(30), "not done after 30 s"); + crate::jobs_ui::tick(app, ctx); + app.place_next_dropped(ctx); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// #10031: with background jobs a dropped file is decoded on a worker. Free Transform starts when +/// the layer has landed, and the next file is started only after that transform is applied. +#[test] +fn background_drops_are_placed_by_a_job_one_after_another() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let (layers, steps) = (layer_count(&app), history_steps(&app)); + let at = Some(egui::pos2(400.0, 300.0)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], at); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("the first file is being placed"); + assert_eq!(place.name, "first.png"); + assert!(app.session.job(place.job).is_some(), "as a background job"); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps), "nothing lands before the job is applied"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + // Frames while it runs don't start the second file next to it. + app.place_next_dropped(&ctx); + assert_eq!(app.drop_places.len(), 1); + assert_eq!(app.jobs.place.as_ref().map(|p| p.job), Some(place.job)); + + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "first"); + // The second file waits for the first transform. + app.place_next_dropped(&ctx); + assert!(app.jobs.place.is_none() && app.drop_places.len() == 1); + crate::transform_tool::commit(&mut app); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("second.png")); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + crate::transform_tool::commit(&mut app); + assert_eq!((layer_count(&app), history_steps(&app)), (layers + 2, steps + 2)); + assert!(app.drop_places.is_empty()); +} + +/// #10031: where there are no threads (the web build) a dropped file is placed in slices, a few +/// milliseconds per frame, with `background_jobs` still off. Free Transform starts when the +/// layer has landed, and the next file waits for it. +#[test] +fn a_dropped_file_is_placed_over_several_frames_without_background_jobs() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + assert!(!app.background_jobs); + app.session.set_cooperative_jobs(true); + // One unit of work per frame, so the number of frames does not depend on the machine. + app.session.set_job_step_budget(std::time::Duration::ZERO); + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(40, 40))), dropped("second.png", Ok(png_bytes(8, 8)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("the first file is being placed"); + assert_eq!(place.name, "first.png"); + assert!(app.session.job(place.job).is_some() && app.session.has_jobs()); + assert!(crate::jobs_ui::keep_polling(&app, &ctx), "frames keep coming while it runs"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps)); + // The status bar shows it, and it can be cancelled like any job. + assert_eq!(crate::jobs_ui::job_in_view(&app).map(|j| j.id), Some(place.job)); + + let mut frames = 0; + while app.jobs.place.is_some() { + frames += 1; + assert!(frames < 10_000, "the place never landed"); + assert_eq!(layer_count(&app), layers, "nothing lands before the last frame"); + crate::jobs_ui::tick(&mut app, &ctx); + app.place_next_dropped(&ctx); + } + assert!(frames > 10, "placed in {frames} frames"); + assert!(!app.background_jobs); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "first"); + // The second file waits for the first transform, then is placed the same way. + app.place_next_dropped(&ctx); + assert!(app.jobs.place.is_none() && app.drop_places.len() == 1); + crate::transform_tool::commit(&mut app); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("second.png")); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + crate::transform_tool::commit(&mut app); + assert_eq!((layer_count(&app), history_steps(&app)), (layers + 2, steps + 2)); + assert!(app.drop_places.is_empty()); +} + +/// A file dropped from a path, a damaged file and a cancel all end as they do with worker threads. +#[test] +fn stepped_places_from_a_path_fail_and_cancel_like_background_ones() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-place-steps-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let good = dir.join("good.png"); + std::fs::write(&good, png_bytes(30, 30)).unwrap(); + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.session.set_cooperative_jobs(true); + app.session.set_job_step_budget(std::time::Duration::ZERO); + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped( + &ctx, + vec![dropped(&good, Ok(Vec::new())), dropped("bad.png", Ok(b"bad".to_vec())), dropped("last.png", Ok(png_bytes(4, 4)))], + Some(egui::pos2(400.0, 300.0)), + ); + app.place_next_dropped(&ctx); + // The first is a path: the `file.placeEmbedded` job, tracked like the others. + let first = app.jobs.place.clone().expect("being placed"); + assert_eq!(first.name, "good.png"); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + crate::jobs_ui::tick(&mut app, &ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps), "a cancelled place leaves the document alone"); + assert!(!app.ui.status_error); + // The queue went on to the damaged file, which fails by name. + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("bad.png")); + tick_until(&mut app, &ctx, |app| app.ui.notices.iter().any(|n| n.error && n.title.contains("bad.png"))); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("last.png")); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A cooperative drop from a path completes in slices (the path read and the placement job, not +/// egui's reader), making its layer once. +#[test] +fn a_cooperative_drop_from_a_path_completes_in_slices() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-path-slices-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let good = dir.join("good.png"); + std::fs::write(&good, png_bytes(30, 30)).unwrap(); + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.session.set_cooperative_jobs(true); + app.session.set_job_step_budget(std::time::Duration::ZERO); + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped(&good, Ok(Vec::new()))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("good.png")); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file that can't be decoded is reported by name and the files dropped after it go on. +#[test] +fn a_background_place_that_fails_reports_it_and_the_queue_continues() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped("bad.png", Ok(b"bad".to_vec())), dropped("good.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("bad.png")); + tick_until(&mut app, &ctx, |app| app.ui.notices.iter().any(|n| n.error && n.title.contains("bad.png"))); + assert_eq!(layer_count(&app), layers, "nothing was placed"); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("good.png"), "the next file started at once"); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert_eq!(app.ui.notices.iter().filter(|n| n.error).count(), 1); +} + +/// Cancelling the place says so, places nothing and goes on with the queue. +#[test] +fn cancelling_a_background_place_continues_the_queue() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + let first = app.jobs.place.clone().expect("being placed"); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + crate::jobs_ui::tick(&mut app, &ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps)); + let second = app.jobs.place.clone().expect("the queue went on"); + assert_eq!(second.name, "second.png"); + assert_ne!(second.job, first.job); + assert!(!app.ui.status_error); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!(layer_count(&app), layers + 1); +} + +/// #8492: with Skip Transform when Placing on, dropped files still go through the background place +/// job one at a time, but no Free Transform opens when a layer lands: the next file starts as soon +/// as the previous one has landed, without waiting for ↩. +#[test] +fn skip_transform_when_placing_places_background_drops_without_free_transform() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + app.session.execute("prefs.set", json!({"path": "general.skipTransformWhenPlacing", "value": true})).unwrap(); + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + let first = app.jobs.place.clone().expect("the first file is being placed"); + assert_eq!(first.name, "first.png"); + assert!(app.session.job(first.job).is_some(), "as a background job"); + assert_eq!(app.drop_places.len(), 1, "the second file waits for the first job"); + tick_until(&mut app, &ctx, |app| app.jobs.place.as_ref().is_some_and(|p| p.name == "second.png")); + assert!(app.ui.transform.is_none(), "no Free Transform on the first file"); + assert_eq!(layer_count(&app), layers + 1); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert!(app.ui.transform.is_none() && app.drop_places.is_empty()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!((layer_count(&app), history_steps(&app)), (layers + 2, steps + 2), "one Place Embedded step each"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Place Embedded")); +} + +/// #8492: inline (background jobs off, as on the web) the preference places every queued drop +/// without Free Transform, a file that can't be placed reported on the way. +#[test] +fn skip_transform_when_placing_places_inline_drops_without_free_transform() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.session.execute("prefs.set", json!({"path": "general.skipTransformWhenPlacing", "value": true})).unwrap(); + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped( + &ctx, + vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("bad.png", Ok(b"bad".to_vec())), dropped("second.png", Ok(png_bytes(2, 2)))], + Some(egui::pos2(400.0, 300.0)), + ); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_none() && app.drop_places.is_empty()); + assert_eq!((layer_count(&app), history_steps(&app)), (layers + 2, steps + 2)); + assert_eq!(app.ui.notices.iter().filter(|n| n.error && n.title.contains("bad.png")).count(), 1); +} + +/// #8492: Skip Transform when Placing only concerns the interactive place. `file.placeEmbedded` +/// with a `path` (actions, scripts, the control channel, MCP) runs the engine command, which never +/// opens Free Transform, whatever the preference. +#[test] +fn a_scripted_place_never_opens_free_transform() { + let dir = std::env::temp_dir().join(format!("pc-scripted-place-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("dot.png"); + std::fs::write(&path, png_bytes(4, 3)).unwrap(); + let mut app = app_with_canvas(); + for skip in [false, true] { + app.session.execute("prefs.set", json!({"path": "general.skipTransformWhenPlacing", "value": skip})).unwrap(); + let layers = layer_count(&app); + app.run("file.placeEmbedded", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(layer_count(&app), layers + 1, "skip={skip}"); + assert!(app.ui.transform.is_none(), "skip={skip}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Frames with nothing to wait for: jobs applied and the drop queue served. +fn idle_frames(app: &mut PhotocraftApp, ctx: &egui::Context) { + for _ in 0..3 { + crate::jobs_ui::tick(app, ctx); + app.place_next_dropped(ctx); + } +} + +/// Drops two files on the canvas of document A (the app's only one) with background jobs on, starts +/// placing the first, then opens document B, which becomes active. Returns A's id and layer count. +fn two_drops_then_switch(app: &mut PhotocraftApp, ctx: &egui::Context) -> (photocraft_doc::DocId, usize) { + app.background_jobs = true; + let (a, layers) = (app.session.active().unwrap().doc.id, layer_count(app)); + app.open_dropped(ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("first.png", a))); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id, a); + (a, layers) +} + +/// FredApps' review of #10147: files dropped on A stay A's when the tab changes while the first is +/// placed. It lands in A, no Free Transform opens over B, the second waits rather than going into B, +/// and goes on in A when A is back in view. +#[test] +fn background_drops_stay_with_their_document_when_the_tab_changes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let (a, layers) = two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.session.documents()[0].doc.layers.len(), layers + 1, "the first landed in A"); + assert_eq!(app.drop_places.len(), 1, "the second waits for A"); + + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("second.png", a))); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 2); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(app.drop_places.is_empty()); + assert_eq!(app.session.documents()[1].doc.layers.len(), b_layers); +} + +/// Cancelling A's place while B is in view doesn't hand A's next file to B. +#[test] +fn cancelling_a_background_place_from_another_tab_keeps_the_queue_in_its_document() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let (a, layers) = two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + let first = app.jobs.place.clone().unwrap(); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.session.documents()[0].doc.layers.len(), layers, "the cancelled file wasn't placed"); + assert_eq!(app.drop_places.len(), 1); + assert!(app.session.set_active(0)); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("second.png", a))); +} + +/// Closing A drops its waiting files with a message; none goes into B. +#[test] +fn closing_the_document_discards_its_dropped_files() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + two_drops_then_switch(&mut app, &ctx); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + app.run("file.close", json!({"document": 0})).unwrap(); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.place_next_dropped(&ctx); + assert!(app.drop_places.is_empty()); + assert!(app.ui.status.contains("closed") && !app.ui.status_error, "{}", app.ui.status); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps)); +} + +/// The closed-document notice survives the same call that would start the next queued file: the +/// placement waits one frame, so the message is drawn. That frame is asked for at once, so B's file +/// doesn't wait for the next input event. +#[test] +fn the_closed_document_notice_stays_when_the_next_queued_file_starts() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let a = app.session.active().unwrap().doc.id; + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let b = app.session.active().unwrap().doc.id; + let b_layers = layer_count(&app); + app.drop_places.push_back((a, crate::file_open::DropPlace::Bytes("a.png".into(), png_bytes(4, 3)))); + app.drop_places.push_back((b, crate::file_open::DropPlace::Bytes("b.png".into(), png_bytes(4, 3)))); + app.run("file.close", json!({"document": 0})).unwrap(); + for _ in 0..4 { + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } + assert!(!ctx.has_requested_repaint(), "idle"); + app.place_next_dropped(&ctx); + assert!(app.ui.status.contains("closed"), "{}", app.ui.status); + assert_eq!(app.drop_places.len(), 1, "B's file waits for the next frame"); + assert!(ctx.has_requested_repaint(), "and asks for it"); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), b_layers + 1); + assert!(app.drop_places.is_empty()); +} + +/// The drop queue is bounded: files dropped on another tab wait, and a browser drop holds its whole +/// file, so neither a flood of drops nor a huge one grows it without limit. +#[test] +fn the_drop_queue_is_bounded() { + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + for _ in 0..crate::file_open::MAX_QUEUED_DROPS { + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("p.png".into(), vec![0])); + } + assert_eq!(app.drop_places.len(), crate::file_open::MAX_QUEUED_DROPS); + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("over.png".into(), vec![0])); + assert_eq!(app.drop_places.len(), crate::file_open::MAX_QUEUED_DROPS, "the queue keeps its cap"); + assert!(app.ui.status.contains("too many") && app.ui.status_error, "{}", app.ui.status); + assert!(crate::file_open::queue_takes_drop(0, crate::file_open::MAX_QUEUED_DROP_BYTES, 0)); + assert!(!crate::file_open::queue_takes_drop(0, crate::file_open::MAX_QUEUED_DROP_BYTES, 1)); + assert!(!crate::file_open::queue_takes_drop(crate::file_open::MAX_QUEUED_DROPS, 0, 0)); +} + +/// Path drops hold no file contents while they wait, so the cap on browser drops doesn't apply to +/// them: dropping 40 files on the canvas queues all of them, and a browser drop after them still fits. +#[test] +fn many_path_drops_all_queue() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + // More than the browser drops' cap; none is read while it waits. + let n = crate::file_open::MAX_QUEUED_DROPS + 8; + app.open_dropped(&ctx, (0..n).map(|i| dropped(format!("p{i}.png"), Ok(Vec::new()))).collect(), Some(egui::pos2(400.0, 300.0))); + assert_eq!(app.drop_places.len(), n); + assert!(!app.ui.status_error, "{}", app.ui.status); + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("web.png".into(), vec![0])); + assert_eq!(app.drop_places.len(), n + 1, "path drops don't use up the browser drops' room"); +} + +/// The shell's place inbox, which the browser's reads fill between two frames, takes no more than +/// the drop queue it is drained into. +#[test] +fn the_place_inbox_has_the_drop_queues_limits() { + use crate::file_open::{MAX_QUEUED_DROP_BYTES, MAX_QUEUED_DROPS, place_inbox_takes}; + assert!(place_inbox_takes(0, MAX_QUEUED_DROP_BYTES - 1, 1)); + assert!(!place_inbox_takes(0, MAX_QUEUED_DROP_BYTES, 1), "a read that would pass the byte cap is refused"); + assert!(!place_inbox_takes(MAX_QUEUED_DROPS, 0, 0), "so is one past the file cap"); +} + +/// Closing A while its file is being placed: nothing lands anywhere, and A's next file is dropped. +#[test] +fn closing_the_document_while_its_file_is_placed_places_nothing_elsewhere() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.run("file.close", json!({"document": 0})).unwrap(); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.drop_places.is_empty() && app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps)); +} + +/// Without background jobs too: leaving A's Free Transform for B cancels that place (#1099) and A's +/// next file waits for A instead of going into B. +#[test] +fn inline_drops_stay_with_their_document_when_the_tab_changes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_some()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + crate::transform_tool::end_if_left(&mut app); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.drop_places.len(), 1); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert_eq!(layer_count(&app), layers + 1, "the first was cancelled by leaving it, the second placed"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); +} + +/// A web drop goes to the document it was dropped on, even when the tab changes before the shell +/// has read the files. +#[test] +fn web_drops_go_to_the_document_they_were_dropped_on() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let layers = layer_count(&app); + let inbox: crate::PlaceInbox = Default::default(); + app.services.place_inbox = Some(inbox.clone()); + app.services.cursor_pos = Some(Box::new(|_: &egui::Context| Some(egui::pos2(400.0, 300.0)))); + assert_eq!(app.dropped_on_canvas(&ctx, &["photo.png".to_string()]), [true]); + let target = app.session.active().map(|st| st.doc.id).unwrap(); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + inbox.lock().unwrap().push((target, "photo.png".into(), png_bytes(4, 3))); + app.queue_web_places(); + app.place_next_dropped(&ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); +} + +#[test] +fn overlapping_web_drop_deliveries_keep_each_target() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let a = app.session.active().unwrap().doc.id; + let a_layers = layer_count(&app); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let b = app.session.active().unwrap().doc.id; + let b_layers = layer_count(&app); + let inbox: crate::PlaceInbox = Default::default(); + app.services.place_inbox = Some(inbox.clone()); + // B's read finishes before A's, although A was dropped first. + inbox.lock().unwrap().extend([(b, "b.png".into(), png_bytes(3, 2)), (a, "a.png".into(), png_bytes(4, 3))]); + app.queue_web_places(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), b_layers + 1); + crate::transform_tool::commit(&mut app); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), a_layers + 1); + assert!(app.drop_places.is_empty()); +} + +/// A file dropped from a path is placed by the `file.placeEmbedded` job, read on its worker, and a +/// drop that arrives meanwhile queues behind it. +#[test] +fn a_background_drop_from_a_path_is_tracked_and_later_drops_queue() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-place-job-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let abs = dir.join("photo.png"); + std::fs::write(&abs, png_bytes(40, 20)).unwrap(); + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let layers = layer_count(&app); + let at = Some(egui::pos2(400.0, 300.0)); + app.open_dropped(&ctx, vec![dropped(abs, Err("egui's reader must not be used".into()))], at); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("being placed"); + assert_eq!(place.name, "photo.png"); + assert_eq!(app.jobs.last_started, Some(place.job)); + // A drop while it runs is queued, not started beside it. + app.open_dropped(&ctx, vec![dropped("later.png", Ok(png_bytes(2, 2)))], at); + app.place_next_dropped(&ctx); + assert_eq!(app.drop_places.len(), 1); + assert_eq!(app.session.jobs().len(), 1); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.drop_places.len(), 1, "waiting for the transform"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1099: switching tabs after an actual canvas drop cancels the place in its source document. #[test] fn switching_documents_after_a_canvas_drop_undoes_only_the_place() { @@ -694,6 +1237,12 @@ fn drag_and_drop_in_the_running_app() { pointer.set(h.state().drop_canvas_rect.expect("the canvas is shown").center()); h.input_mut().dropped_files.push(dropped(file, Err("read from the path".into()))); h.run_steps(2); + // Placement runs on a worker; frame count does not imply completion. + let started = std::time::Instant::now(); + while h.state().jobs.place.is_some() && started.elapsed() < std::time::Duration::from_secs(30) { + h.step(); + } + assert!(h.state().jobs.place.is_none(), "placement completed"); assert_eq!(h.state().session.documents().len(), 3, "no new document"); assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1); assert_eq!(h.state().ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); @@ -1127,3 +1676,21 @@ fn opening_an_open_file_again_shows_its_tab() { let _ = std::fs::remove_dir_all(&d); } } + +#[test] +fn queued_drop_waits_for_an_external_document_job() { + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + app.session.start_job("test.external", json!({}), "External job", true, |_| Ok(()), |_, ()| Ok(json!(null))).unwrap(); + let job = app.session.active_job().unwrap().id; + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("queued.png".into(), png_bytes(4, 3)))); + app.place_next_dropped(&egui::Context::default()); + assert_eq!(app.drop_places.len(), 1, "busy document retains its drop"); + assert!(app.jobs.place.is_none()); + let layers = layer_count(&app); + app.session.wait_job(job).unwrap(); + app.place_next_dropped(&egui::Context::default()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(app.drop_places.is_empty()); +} diff --git a/crates/ui-egui/src/gpu_memo.rs b/crates/ui-egui/src/gpu_memo.rs new file mode 100644 index 0000000000..710fec97fa --- /dev/null +++ b/crates/ui-egui/src/gpu_memo.rs @@ -0,0 +1,178 @@ +//! Persistence model for deterministic GPU compositor build failures. +//! +//! Call [`GpuMemo::remember`] only for deterministic build failures. Transient device or +//! allocation errors are deliberately not classified here and are never saved automatically. + +use serde::{Deserialize, Serialize}; + +/// Current on-disk schema version. +pub const GPU_MEMO_VERSION: u32 = 1; +/// Maximum accepted serialized document size. +pub const MAX_GPU_MEMO_BYTES: usize = 64 * 1024; +/// Number of failure records retained, newest last. +pub const MAX_GPU_MEMO_ENTRIES: usize = 8; + +/// Identifies the application, shader set, and graphics stack that produced a failure. +#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct MemoKey { + pub app_version: String, + pub shader_fingerprint: u64, + pub adapter_name: String, + pub vendor: u32, + pub device: u32, + pub driver: String, + pub driver_info: String, + pub backend: String, + pub shader_compiler: String, +} + +/// One remembered deterministic build failure. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MemoEntry { + pub key: MemoKey, + pub error: String, + /// RFC3339 timestamp supplied by the caller. + pub when: String, +} + +/// Bounded, versioned compositor failure memo. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GpuMemo { + version: u32, + failures: Vec, +} + +impl Default for GpuMemo { + fn default() -> Self { + Self::new() + } +} + +impl GpuMemo { + /// Creates an empty memo using the current schema version. + pub fn new() -> Self { + Self { version: GPU_MEMO_VERSION, failures: Vec::new() } + } + + /// Parse persisted JSON. Rejects oversized, malformed, and unknown-version data. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_GPU_MEMO_BYTES { + return Err("GPU memo exceeds 64 KiB".into()); + } + let mut memo: Self = serde_json::from_str(text).map_err(|e| e.to_string())?; + if memo.version != GPU_MEMO_VERSION { + return Err(format!("unsupported GPU memo version {}", memo.version)); + } + if memo.failures.len() > MAX_GPU_MEMO_ENTRIES { + let excess = memo.failures.len() - MAX_GPU_MEMO_ENTRIES; + memo.failures.drain(..excess); + } + Ok(memo) + } + + /// Serialize this memo as JSON. + pub fn serialize(&self) -> Result { + let text = serde_json::to_string(self).map_err(|e| e.to_string())?; + if text.len() > MAX_GPU_MEMO_BYTES { + return Err("GPU memo exceeds 64 KiB".into()); + } + Ok(text) + } + + /// Returns the remembered failure for `key`, if present. + pub fn get(&self, key: &MemoKey) -> Option<&MemoEntry> { + self.failures.iter().rev().find(|entry| &entry.key == key) + } + + /// Records a deterministic failure. Callers must exclude transient device-loss and OOM errors. + /// Existing entries for the key are replaced and the oldest record is dropped at capacity. + pub fn remember(&mut self, key: MemoKey, error: String, when: String) { + if self.version != GPU_MEMO_VERSION { + *self = Self::new(); + } + self.remove(&key); + if self.failures.len() == MAX_GPU_MEMO_ENTRIES { + self.failures.remove(0); + } + self.failures.push(MemoEntry { key, error, when }); + } + + /// Removes a memoized failure for `key`; returns whether one existed. + pub fn remove(&mut self, key: &MemoKey) -> bool { + let before = self.failures.len(); + self.failures.retain(|entry| &entry.key != key); + before != self.failures.len() + } + + /// Number of retained failure records. + pub fn len(&self) -> usize { + self.failures.len() + } + + /// Whether the memo has no records. + pub fn is_empty(&self) -> bool { + self.failures.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(n: usize) -> MemoKey { + MemoKey { app_version: n.to_string(), ..MemoKey::default() } + } + + #[test] + fn round_trip_and_remove() { + let mut memo = GpuMemo::new(); + memo.remember(key(1), "compiler failed".into(), "2026-10-28T00:00:00Z".into()); + let parsed = GpuMemo::parse(&memo.serialize().expect("serializes")).expect("parses"); + assert_eq!(parsed.get(&key(1)).map(|entry| entry.error.as_str()), Some("compiler failed")); + let mut parsed = parsed; + assert!(parsed.remove(&key(1))); + assert!(!parsed.remove(&key(1))); + assert!(parsed.is_empty()); + } + + #[test] + fn changing_any_identity_field_invalidates_a_failure() { + let original = MemoKey::default(); + let mut memo = GpuMemo::new(); + memo.remember(original.clone(), "compiler failed".into(), "now".into()); + let variants = [ + MemoKey { app_version: "new".into(), ..original.clone() }, + MemoKey { shader_fingerprint: 1, ..original.clone() }, + MemoKey { adapter_name: "new".into(), ..original.clone() }, + MemoKey { vendor: 1, ..original.clone() }, + MemoKey { device: 1, ..original.clone() }, + MemoKey { driver: "new".into(), ..original.clone() }, + MemoKey { driver_info: "new".into(), ..original.clone() }, + MemoKey { backend: "new".into(), ..original.clone() }, + MemoKey { shader_compiler: "new".into(), ..original.clone() }, + ]; + for variant in variants { + assert!(memo.get(&variant).is_none()); + } + } + + #[test] + fn retains_only_eight_newest_entries() { + let mut memo = GpuMemo::new(); + for n in 0..10 { + memo.remember(key(n), "failure".into(), "now".into()); + } + assert_eq!(memo.len(), MAX_GPU_MEMO_ENTRIES); + assert!(memo.get(&key(0)).is_none()); + assert!(memo.get(&key(1)).is_none()); + assert!(memo.get(&key(2)).is_some()); + assert!(memo.get(&key(9)).is_some()); + } + + #[test] + fn rejects_corrupt_oversized_and_unknown_versions() { + assert!(GpuMemo::parse("{").is_err()); + assert!(GpuMemo::parse(&" ".repeat(MAX_GPU_MEMO_BYTES + 1)).is_err()); + assert!(GpuMemo::parse(r#"{"version":2,"failures":[]}"#).is_err()); + } +} diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index ca832645b7..6c5eee78ad 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -34,6 +34,7 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { if fault.is_out_of_memory() && app.gpu_oom_retries < OOM_AUTO_RETRIES && can_retry_now(app) { app.gpu_oom_retries += 1; if retry_now(app) { + app.retry_compositor(ctx); log::warn!("{fault}; rebuilt the GPU canvas (automatic retry {} of {OOM_AUTO_RETRIES})", app.gpu_oom_retries); app.ui.gpu_fallback_notice = None; } @@ -292,10 +293,12 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { } }); }); - if let Some(retry) = choice - && let Err(error) = choose_recovery(app, retry) - { - crate::notices::error(app, error); + if let Some(retry) = choice { + match choose_recovery(app, retry) { + Ok(()) if retry => app.retry_compositor(ctx), + Ok(()) => (), + Err(error) => crate::notices::error(app, error), + } } } diff --git a/crates/ui-egui/src/home_chrome_tests.rs b/crates/ui-egui/src/home_chrome_tests.rs new file mode 100644 index 0000000000..4e7c3c9876 --- /dev/null +++ b/crates/ui-egui/src/home_chrome_tests.rs @@ -0,0 +1,90 @@ +//! The Home screen is only the menu bar and the Home content: the tool strip, options bar, status +//! bar and docks are not drawn under it, and come back when Home is dismissed. The saved panel +//! preferences are never touched by showing Home. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::theme::ThemeKind; + +/// The real app (every panel on) in `theme`, with a document open when `document`. +fn app(theme: ThemeKind, document: bool, auto_show: bool) -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + if document { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + } + s.edit_prefs(|p| p.general.auto_show_home_screen = auto_show); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.theme = theme; + app + }); + h.run_steps(6); + h +} + +fn chrome_shown(h: &Harness<'static, PhotocraftApp>, when: &str) -> bool { + // Tool buttons are named "Move Tool (V)" (the shortcut follows), the dock's footer button "Create a new layer". + let toolbar = h.query_all_by_label_contains("Move Tool").next().is_some(); + let dock = h.query_all_by_label_contains("Create a new layer").next().is_some(); + assert_eq!(toolbar, dock, "{when}: the tool strip and the dock come and go together (toolbar {toolbar}, dock {dock})"); + toolbar +} + +/// The options bar and the status bar: a widget of each ("Auto-Select:" of the Move tool's options, +/// the status bar's zoom field). +fn bars_shown(h: &Harness<'static, PhotocraftApp>) -> (bool, bool) { + (h.query_all_by_label_contains("Auto-Select").next().is_some(), h.query_all_by_label("Zoom").next().is_some()) +} + +/// Click the Home entry: the options bar's button with a document in front, the rail's on Home. +fn click_home(h: &mut Harness<'static, PhotocraftApp>) { + let top = h.query_all_by_label("Home").min_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y)).expect("a Home button"); + top.click(); + h.run_steps(6); +} + +#[test] +fn no_documents_with_auto_show_draws_only_the_home_content() { + let h = app(ThemeKind::Pro, false, true); + assert!(h.query_by_label("New file").is_some(), "Home content"); + assert!(!chrome_shown(&h, "no documents, auto-show"), "no tool strip or dock under Home"); + assert!(h.query_by_label("No document").is_none(), "no status bar"); + assert_eq!(h.query_all_by_label("Home").count(), 1, "only the rail's entry: nothing to go back to"); +} + +#[test] +fn toggling_home_swaps_the_chrome_and_keeps_the_panel_prefs() { + for theme in ThemeKind::ALL { + let mut h = app(theme, true, true); + let panels = h.state().ui.panels.clone(); + assert!(chrome_shown(&h, &format!("{theme:?}, document open, before Home")), "{theme:?}: chrome with a document"); + assert_eq!(bars_shown(&h), (true, true), "{theme:?}: options bar and status bar with a document"); + click_home(&mut h); + assert_eq!(bars_shown(&h), (false, false), "{theme:?}: no options bar or status bar on Home"); + assert!(h.query_by_label("New file").is_some(), "{theme:?}: Home content"); + assert!(!chrome_shown(&h, &format!("{theme:?}, on Home")), "{theme:?}: no chrome on Home"); + assert!(h.query_all_by_label("Home").count() >= 1, "{theme:?}: the header's Home toggle or the rail's entry stays to go back"); + assert_eq!(h.state().ui.panels, panels, "{theme:?}: showing Home leaves the saved panels alone"); + click_home(&mut h); + assert!(h.query_by_label("New file").is_none(), "{theme:?}: Home dismissed"); + assert!(chrome_shown(&h, &format!("{theme:?}, after Home")), "{theme:?}: chrome back"); + assert_eq!(bars_shown(&h), (true, true), "{theme:?}: bars back"); + assert_eq!(h.state().ui.panels, panels); + } +} + +#[test] +fn tab_in_full_screen_does_not_bring_the_chrome_back_over_home() { + let mut h = app(ThemeKind::Pro, true, true); + click_home(&mut h); + h.state_mut().ui.view.screen_mode = "fullScreen".into(); + h.state_mut().ui.panels.full_screen_chrome = true; + h.run_steps(6); + assert!(h.query_by_label("New file").is_some(), "Home content"); + assert!(!chrome_shown(&h, "Tab-revealed chrome on Home"), "no tool strip or dock"); + assert_eq!(bars_shown(&h), (false, false), "no options bar or status bar"); +} diff --git a/crates/ui-egui/src/home_recent_ui.rs b/crates/ui-egui/src/home_recent_ui.rs new file mode 100644 index 0000000000..3351ad9468 --- /dev/null +++ b/crates/ui-egui/src/home_recent_ui.rs @@ -0,0 +1,1535 @@ +//! Home screen Recent Files Thumbnail Card Grid and List views. +//! +//! Provides thumbnail preview loading and caching, interactive file cards with +//! thumbnail image, document icon, filename, and file path/location, along with +//! Grid/List view switching matching Photoshop 2026 Home screen. + +use std::collections::HashMap; +use std::path::Path; + +use egui::{Color32, ColorImage, Rect, Response, Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2, pos2, vec2}; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, icons}; + +/// Cache key identifying the origin state of a loaded thumbnail. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CacheKey { + /// Rendered from an active in-memory document with a specific revision. + OpenDoc { revision: u64 }, + /// Read from a file on disk with modification time and file size. + #[cfg(not(target_arch = "wasm32"))] + DiskFile { modified: Option, len: u64 }, +} + +#[derive(Clone)] +pub(crate) struct ThumbnailEntry { + pub(crate) key: CacheKey, + pub(crate) texture: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) last_checked: std::time::Instant, +} + +/// Cache for loaded recent file preview textures. +#[derive(Default)] +pub(crate) struct ThumbnailCache { + pub(crate) entries: HashMap, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) was_focused: bool, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) in_flight: HashMap, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) next_req_id: u64, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) pending: Vec<(String, u64, CacheKey, Option)>, +} + +#[cfg(not(target_arch = "wasm32"))] +const MAX_CONCURRENT_THUMBNAIL_LOADS: usize = 1; + +/// Minimum time between two live-document thumbnail compositions for the same path. +#[cfg(all(not(target_arch = "wasm32"), not(test)))] +const OPEN_DOC_REGEN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); +#[cfg(all(not(target_arch = "wasm32"), test))] +const OPEN_DOC_REGEN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + +#[cfg(not(target_arch = "wasm32"))] +fn load_thumbnail_catching_panic(load: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(load)).ok().flatten() +} + +impl ThumbnailCache { + /// Invalidate the cached thumbnail for a single path (e.g. after save, open, or remove). + pub(crate) fn invalidate(&mut self, path: &str) { + self.entries.remove(path); + #[cfg(not(target_arch = "wasm32"))] + { + self.in_flight.remove(path); + self.pending.retain(|(p, ..)| p != path); + } + } + + /// Clear all cached thumbnails (e.g. on Clear Recent Files). + pub(crate) fn clear(&mut self) { + self.entries.clear(); + #[cfg(not(target_arch = "wasm32"))] + { + self.in_flight.clear(); + self.pending.clear(); + } + } +} + +/// Check if two path strings match, tolerating slash direction and Windows case differences. +fn paths_match(a: &str, b: &str) -> bool { + if a == b { + return true; + } + #[cfg(windows)] + { + let a_norm = a.replace('\\', "/"); + let b_norm = b.replace('\\', "/"); + a_norm.eq_ignore_ascii_case(&b_norm) + } + #[cfg(not(windows))] + { + false + } +} + +/// Downscale an RGBA8 buffer to fit within `max_dim` on its longest side. +#[cfg(not(target_arch = "wasm32"))] +fn downsample_rgba(w: u32, h: u32, src_data: &[u8], max_dim: u32) -> Option { + if w == 0 || h == 0 || src_data.len() != (w as usize) * (h as usize) * 4 { + return None; + } + let (tw, th) = if w > max_dim || h > max_dim { + let scale = (max_dim as f32) / (w.max(h) as f32); + (((w as f32) * scale).round().max(1.0) as u32, ((h as f32) * scale).round().max(1.0) as u32) + } else { + (w, h) + }; + if tw == w && th == h { + Some(ColorImage::from_rgba_unmultiplied([w as usize, h as usize], src_data)) + } else { + let mut thumb_px = vec![0u8; (tw as usize) * (th as usize) * 4]; + for y in 0..th { + let sy = ((y as f32 / th as f32) * h as f32) as usize; + for x in 0..tw { + let sx = ((x as f32 / tw as f32) * w as f32) as usize; + let src_idx = (sy * w as usize + sx) * 4; + let dst_idx = (y as usize * tw as usize + x as usize) * 4; + if src_idx + 4 <= src_data.len() + && dst_idx + 4 <= thumb_px.len() + && let Some(src) = src_data.get(src_idx..src_idx + 4) + && let Some(dst) = thumb_px.get_mut(dst_idx..dst_idx + 4) + { + dst.copy_from_slice(src); + } + } + } + Some(ColorImage::from_rgba_unmultiplied([tw as usize, th as usize], &thumb_px)) + } +} + +/// Read a PSD/PSB thumbnail preview by streaming only the header and image resources section. +/// +/// Bounded range reader: inspects the 26-byte header and reads only the resource blocks +/// section (up to 32 MB), extracting resource 1036 (JFIF JPEG) or 1033 without reading +/// the layer records, channel data, or multi-gigabyte merged payload. If no embedded thumbnail +/// exists, fallback composite decoding is only permitted if header dimensions are <= 2048x2048 +/// and the total file size on disk is <= 64 MB. +#[cfg(not(target_arch = "wasm32"))] +fn read_psd_thumbnail_bounded(path: &Path, file_len: u64) -> Option { + use std::fs::File; + use std::io::{Read, Seek, SeekFrom}; + + let mut file = File::open(path).ok()?; + + // 1. Read 26-byte header + let mut header = [0u8; 26]; + file.read_exact(&mut header).ok()?; + + if &header[0..4] != b"8BPS" { + return None; + } + let version = u16::from_be_bytes([header[4], header[5]]); + if version != 1 && version != 2 { + return None; + } + let channels = u16::from_be_bytes([header[12], header[13]]); + let height = u32::from_be_bytes([header[14], header[15], header[16], header[17]]); + let width = u32::from_be_bytes([header[18], header[19], header[20], header[21]]); + let depth = u16::from_be_bytes([header[22], header[23]]); + + // 2. Read color mode data length (4 bytes) + let mut cmd_len_buf = [0u8; 4]; + file.read_exact(&mut cmd_len_buf).ok()?; + let cmd_len = u32::from_be_bytes(cmd_len_buf); + if cmd_len > 16 * 1024 * 1024 { + return None; + } + file.seek(SeekFrom::Current(cmd_len as i64)).ok()?; + + // 3. Read image resources section length (4 bytes) + let mut res_len_buf = [0u8; 4]; + file.read_exact(&mut res_len_buf).ok()?; + let res_len = u32::from_be_bytes(res_len_buf); + + // 4. Read only the resources section (bounded up to 32 MB and within file length) + if res_len > 0 && res_len <= 32 * 1024 * 1024 && (res_len as u64) <= file_len { + let mut res_bytes = vec![0u8; res_len as usize]; + if file.read_exact(&mut res_bytes).is_ok() + && let Ok((resources, _)) = photocraft_psd::tiff::resources_from_bytes(&res_bytes) + { + let decode_opts = photocraft_codecs::DecodeOptions { + limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 4096 * 4096 * 4 }, + ..Default::default() + }; + for r in &resources { + if (r.id == photocraft_psd::resources::ids::THUMBNAIL || r.id == photocraft_psd::resources::ids::THUMBNAIL_PS4) + && let Some(jfif) = r.data.get(28..) + && let Ok(img) = photocraft_codecs::decode_with(jfif, &decode_opts) + { + let rgba = img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + if rgba.data().len() == rgba.width() as usize * rgba.height() as usize * 4 { + return downsample_rgba(rgba.width(), rgba.height(), rgba.data(), 256); + } + } + } + } + } + + // 5. Fallback for PSDs without embedded JFIF preview: + // First, check header dimensions: must be <= 2048x2048 before reading any more of the file! + if width == 0 || height == 0 || width > 2048 || height > 2048 { + return None; + } + + // Second, enforce input budget: if the file size on disk exceeds 64 MB, do NOT allocate/read the whole document + const MAX_FALLBACK_FILE_SIZE: u64 = 64 * 1024 * 1024; + if file_len > MAX_FALLBACK_FILE_SIZE { + return None; + } + + // Third, guard against uncompressed memory spikes / bombs from high channel counts or 32-bit depth: + let sample_bytes: u64 = match depth { + 1 | 8 => 1, + 16 => 2, + 32 => 4, + _ => return None, + }; + const MAX_FALLBACK_DECODED_BYTES: u64 = 64 * 1024 * 1024; + let decoded_bytes = (width as u64).checked_mul(height as u64)?.checked_mul(channels as u64)?.checked_mul(sample_bytes)?; + if decoded_bytes > MAX_FALLBACK_DECODED_BYTES { + return None; + } + + // Fourth, within budget and safe dimensions: read file and decode merged composite + let bytes = std::fs::read(path).ok()?; + if let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) + && let Ok(comp) = photocraft_io::merged_composite(&file) + { + let w = file.header.width; + let h = file.header.height; + if w > 0 && h > 0 && comp.len() == (w as usize) * (h as usize) { + let mut raw_bytes = Vec::with_capacity(comp.len() * 4); + for &[r, g, b, a] in comp.iter() { + raw_bytes.push((r.clamp(0.0, 1.0) * 255.0).round() as u8); + raw_bytes.push((g.clamp(0.0, 1.0) * 255.0).round() as u8); + raw_bytes.push((b.clamp(0.0, 1.0) * 255.0).round() as u8); + raw_bytes.push((a.clamp(0.0, 1.0) * 255.0).round() as u8); + } + return downsample_rgba(w, h, &raw_bytes, 256); + } + } + + None +} + +/// Load thumbnail image with bounded streaming I/O and strict size limits. +#[cfg(not(target_arch = "wasm32"))] +fn load_thumbnail_image_bounded(path: &str, file_len: u64) -> Option { + let std_path = Path::new(path); + let ext = std_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + + // 1. PSD / PSB: read header and resources with bounded range reading + if ext.eq_ignore_ascii_case("psd") || ext.eq_ignore_ascii_case("psb") { + return read_psd_thumbnail_bounded(std_path, file_len); + } + + // Enforce 64 MB input budget for all other file types (.pcraft, PNG, JPEG, TIFF, etc.) + const MAX_THUMBNAIL_FILE_SIZE: u64 = 64 * 1024 * 1024; + if file_len > MAX_THUMBNAIL_FILE_SIZE { + return None; + } + + // Guard against decompression bombs: bound decoder allocation and dimensions up front + let decode_opts = photocraft_codecs::DecodeOptions { + limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 4096 * 4096 * 4 }, + ..Default::default() + }; + + // 2. If .pcraft, read the embedded thumbnail PNG + if ext.eq_ignore_ascii_case("pcraft") { + if std_path.is_dir() { + let thumb_path = std_path.join("thumb.png"); + if let Ok(thumb_meta) = std::fs::metadata(&thumb_path) + && thumb_meta.len() <= MAX_THUMBNAIL_FILE_SIZE + && let Ok(bytes) = std::fs::read(&thumb_path) + && let Ok(img) = photocraft_codecs::decode_with(&bytes, &decode_opts) + { + let rgba = img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + if rgba.data().len() == rgba.width() as usize * rgba.height() as usize * 4 { + return downsample_rgba(rgba.width(), rgba.height(), rgba.data(), 256); + } + } + } else if let Ok(bytes) = std::fs::read(std_path) + && let Ok(Some(png_bytes)) = photocraft_format::read_thumbnail(&bytes) + && let Ok(img) = photocraft_codecs::decode_with(&png_bytes, &decode_opts) + { + let rgba = img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + if rgba.data().len() == rgba.width() as usize * rgba.height() as usize * 4 { + return downsample_rgba(rgba.width(), rgba.height(), rgba.data(), 256); + } + } + return None; + } + + // 3. Flat raster formats (PNG, JPG, WebP, TIFF, GIF, BMP, etc.) + if let Ok(bytes) = std::fs::read(std_path) + && let Ok(img) = photocraft_codecs::decode_with(&bytes, &decode_opts) + { + let (w, h) = img.dimensions(); + // Guard against decompression bombs: don't expand > 4096 images to gigabytes of RGBA + if w > 0 && h > 0 && w <= 4096 && h <= 4096 { + let rgba = img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + return downsample_rgba(w, h, rgba.data(), 256); + } + } + + None +} + +/// Drain completed asynchronous thumbnail results and update the texture cache on the UI thread. +#[cfg(not(target_arch = "wasm32"))] +fn drain_pending_thumbnails(app: &PhotocraftApp, ctx: &egui::Context, opts: TextureOptions) { + let pending: Vec<(String, u64, CacheKey, Option)> = { + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if cache.pending.is_empty() { + return; + } + std::mem::take(&mut cache.pending) + }; + + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for (path, req_id, key, opt_img) in pending { + if cache.in_flight.get(&path).copied() == Some(req_id) { + cache.in_flight.remove(&path); + } else { + continue; + } + // If the document is currently open in the session, do not overwrite its live in-memory thumbnail + if app.session.documents().iter().any(|d| d.path.as_deref().is_some_and(|p| paths_match(p, &path))) { + continue; + } + let tex = opt_img.map(|ci| { + if let Some(existing) = cache.entries.get_mut(&path).and_then(|e| e.texture.as_mut()) { + existing.set(ci, opts); + existing.clone() + } else { + ctx.load_texture(format!("recent_thumb_{path}"), ci, opts) + } + }); + cache.entries.insert(path, ThumbnailEntry { key, texture: tex, last_checked: std::time::Instant::now() }); + } +} + +/// Retrieve, lazily load, or regenerate the preview texture for `path`. +/// +/// If `path` is currently open in `app.session`, the thumbnail is rendered from +/// the live document composite and re-rendered whenever its revision changes. +/// If `path` is closed, the thumbnail is decoded from disk in the background (or inline +/// in non-background test environments), with bounded range reads and input budgets. +pub(crate) fn get_or_load_thumbnail(app: &PhotocraftApp, ctx: &egui::Context, path: &str) -> Option { + let opts = TextureOptions { + magnification: egui::TextureFilter::Linear, + minification: egui::TextureFilter::Linear, + wrap_mode: egui::TextureWrapMode::ClampToEdge, + mipmap_mode: Some(egui::TextureFilter::Linear), + }; + + #[cfg(not(target_arch = "wasm32"))] + drain_pending_thumbnails(app, ctx, opts); + + // 1. Is this document currently open in the session? + if let Some(st) = app.session.documents().iter().find(|d| d.path.as_deref().is_some_and(|p| paths_match(p, path))) { + let current_key = CacheKey::OpenDoc { revision: st.revision }; + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(entry) = cache.entries.get(path) + && entry.key == current_key + { + return entry.texture.clone(); + } + + // Composing the live document runs on the UI thread, so while the document is + // being edited regenerate at most once per interval and show the previous preview + // in between. A repaint is scheduled so the final revision is still picked up. + #[cfg(not(target_arch = "wasm32"))] + if let Some(entry) = cache.entries.get(path) + && matches!(entry.key, CacheKey::OpenDoc { .. }) + && entry.texture.is_some() + { + let since = entry.last_checked.elapsed(); + if since < OPEN_DOC_REGEN_INTERVAL { + ctx.request_repaint_after(OPEN_DOC_REGEN_INTERVAL.saturating_sub(since)); + return entry.texture.clone(); + } + } + + let rgba = photocraft_compose::thumbnail(&st.doc, 256); + let color_image = ColorImage::from_rgba_unmultiplied([rgba.width as usize, rgba.height as usize], &rgba.pixels); + + let tex = if let Some(existing) = cache.entries.get_mut(path).and_then(|e| e.texture.as_mut()) { + existing.set(color_image, opts); + existing.clone() + } else { + ctx.load_texture(format!("recent_thumb_{path}"), color_image, opts) + }; + + cache.entries.insert( + path.to_string(), + ThumbnailEntry { + key: current_key, + texture: Some(tex.clone()), + #[cfg(not(target_arch = "wasm32"))] + last_checked: std::time::Instant::now(), + }, + ); + return Some(tex); + } + + // 2. Otherwise inspect the file on disk + #[cfg(not(target_arch = "wasm32"))] + { + #[cfg(test)] + const RECHECK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); + #[cfg(not(test))] + const RECHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); + + let (focus_gained, cached_entry, is_in_flight) = { + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let focused = ctx.input(|i| i.focused); + let focus_gained = focused && !cache.was_focused; + if focus_gained { + cache.was_focused = true; + } else if !focused { + cache.was_focused = false; + } + + let entry = cache.entries.get(path).cloned(); + let in_flight = cache.in_flight.contains_key(path); + (focus_gained, entry, in_flight) + }; + + // Fast path: if already cached and neither focus gained nor recheck interval elapsed, + // return the texture immediately from memory without touching the filesystem. + if !focus_gained + && let Some(entry) = &cached_entry + && entry.last_checked.elapsed() < RECHECK_INTERVAL + { + return entry.texture.clone(); + } + + let std_path = Path::new(path); + let Ok(meta) = std::fs::metadata(std_path) else { + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + cache.entries.remove(path); + cache.in_flight.remove(path); + return None; + }; + + let current_key = CacheKey::DiskFile { modified: meta.modified().ok(), len: meta.len() }; + + if let Some(entry) = &cached_entry + && entry.key == current_key + { + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(e) = cache.entries.get_mut(path) { + e.last_checked = std::time::Instant::now(); + } + return entry.texture.clone(); + } + + if is_in_flight { + return cached_entry.and_then(|e| e.texture); + } + + if app.background_jobs { + let req_id = { + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if cache.in_flight.len() >= MAX_CONCURRENT_THUMBNAIL_LOADS { + return cached_entry.and_then(|e| e.texture); + } + let id = cache.next_req_id; + cache.next_req_id = cache.next_req_id.wrapping_add(1); + cache.in_flight.insert(path.to_string(), id); + id + }; + let cache_arc = std::sync::Arc::clone(&app.thumbnail_cache); + let ctx_clone = ctx.clone(); + let path_str = path.to_string(); + let key_clone = current_key; + let file_len = meta.len(); + std::thread::spawn(move || { + let opt_img = load_thumbnail_catching_panic(|| load_thumbnail_image_bounded(&path_str, file_len)); + let mut cache = cache_arc.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // Only enqueue if this request generation is still the active one + if cache.in_flight.get(&path_str).copied() == Some(req_id) { + cache.pending.push((path_str, req_id, key_clone, opt_img)); + ctx_clone.request_repaint(); + } + }); + cached_entry.and_then(|e| e.texture) + } else { + let opt_img = load_thumbnail_image_bounded(path, meta.len()); + let tex = opt_img.map(|ci| { + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = cache.entries.get_mut(path).and_then(|e| e.texture.as_mut()) { + existing.set(ci, opts); + existing.clone() + } else { + ctx.load_texture(format!("recent_thumb_{path}"), ci, opts) + } + }); + let mut cache = app.thumbnail_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + cache.entries.insert(path.to_string(), ThumbnailEntry { key: current_key, texture: tex.clone(), last_checked: std::time::Instant::now() }); + tex + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + let _ = path; + None + } +} + +/// Action output from the recent files view. +pub(crate) enum RecentAction { + Open(String), + Remove(String), + Clear, + OpenDialog, +} + +/// Card width in the grid. +pub const GRID_CARD_WIDTH: f32 = 184.0; +/// Card height in the grid. +pub const GRID_CARD_HEIGHT: f32 = 244.0; +/// Preview area height within a grid card. +pub const GRID_PREVIEW_HEIGHT: f32 = 168.0; +/// Row height in list view. +pub const LIST_ROW_HEIGHT: f32 = 34.0; + +/// Render the header for Recent files with title and Grid/List toggle buttons. +pub(crate) fn recent_header(ui: &mut Ui, is_grid: &mut bool, total_width: f32) { + let t = Tokens::get(ui.ctx()); + let (header_rect, _) = ui.allocate_exact_size(vec2(total_width, 28.0), Sense::hover()); + + // Left label: "Recent" + let label_galley = ui.painter().layout_no_wrap(tl!("Recent").to_string(), crate::theme::semibold(17.0), t.text); + let label_pos = pos2(header_rect.left(), header_rect.center().y - label_galley.size().y / 2.0); + ui.painter().galley(label_pos, label_galley, t.text); + let label_rect = Rect::from_min_size(label_pos, vec2(100.0, 24.0)); + let label_resp = ui.interact(label_rect, ui.scope_id().with("recent_title"), Sense::hover()); + label_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tl!("Recent"))); + + // Container box for the toggles, aligned flush to the right edge of header_rect + let box_size = vec2(60.0, 28.0); + let box_rect = Rect::from_min_size(pos2(header_rect.right() - box_size.x, header_rect.top()), box_size); + ui.painter().rect_filled(box_rect, 4.0, t.field); + ui.painter().rect_stroke(box_rect, 4.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + + let grid_rect = Rect::from_min_size(box_rect.min + vec2(2.0, 2.0), vec2(28.0, 24.0)); + let list_rect = Rect::from_min_size(pos2(box_rect.min.x + 30.0, box_rect.min.y + 2.0), vec2(28.0, 24.0)); + + let grid_resp = ui.interact(grid_rect, ui.scope_id().with("toggle_grid"), Sense::click()); + let list_resp = ui.interact(list_rect, ui.scope_id().with("toggle_list"), Sense::click()); + + grid_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Grid view"))); + list_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("List view"))); + + let grid_resp = grid_resp.on_hover_text(tl!("Grid view")).on_hover_cursor(egui::CursorIcon::PointingHand); + let list_resp = list_resp.on_hover_text(tl!("List view")).on_hover_cursor(egui::CursorIcon::PointingHand); + + if *is_grid { + ui.painter().rect_filled(grid_rect, 3.0, t.hover); + ui.painter().rect_stroke(grid_rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + if !*is_grid { + ui.painter().rect_filled(list_rect, 3.0, t.hover); + ui.painter().rect_stroke(list_rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + + let grid_tint = if *is_grid { t.text } else { t.text_dim }; + let list_tint = if !*is_grid { t.text } else { t.text_dim }; + + icons::paint(ui, grid_rect, "grid-2x2", 15.0, grid_tint); + icons::paint(ui, list_rect, "marquee-row", 15.0, list_tint); + + if grid_resp.clicked() { + *is_grid = true; + } + if list_resp.clicked() { + *is_grid = false; + } +} + +/// Render a single visual thumbnail card in grid view. +pub(crate) fn render_grid_card(ui: &mut Ui, path: &str, name: &str, folder: &str, thumb: Option<&TextureHandle>) -> (Response, Option) { + let t = Tokens::get(ui.ctx()); + let (card_rect, resp) = ui.allocate_exact_size(vec2(GRID_CARD_WIDTH, GRID_CARD_HEIGHT), Sense::click()); + let hovered = ui.rect_contains_pointer(card_rect); + + // Card background & border + let bg_fill = t.card; + ui.painter().rect_filled(card_rect, 6.0, bg_fill); + let border_stroke = if hovered { Stroke::new(1.0, t.accent) } else { Stroke::new(1.0, t.card_border) }; + ui.painter().rect_stroke(card_rect, 6.0, border_stroke, egui::StrokeKind::Inside); + + // Preview area + let preview_rect = Rect::from_min_size(card_rect.min + vec2(1.0, 1.0), vec2(GRID_CARD_WIDTH - 2.0, GRID_PREVIEW_HEIGHT)); + // Inner well for preview area with rounded top corners + ui.painter().rect_filled(preview_rect, egui::CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.field); + + if let Some(tex) = thumb { + let tex_size = tex.size_vec2(); + let avail_size = preview_rect.size() - vec2(8.0, 8.0); + if tex_size.x > 0.0 && tex_size.y > 0.0 { + let aspect = tex_size.x / tex_size.y; + let target_w = (avail_size.y * aspect).min(avail_size.x); + let target_h = target_w / aspect; + let img_rect = Rect::from_center_size(preview_rect.center(), vec2(target_w, target_h)); + ui.painter().image(tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } else { + // Fallback document vector icon + let icon_box = Rect::from_center_size(preview_rect.center(), Vec2::splat(40.0)); + icons::paint(ui, icon_box, "file", 34.0, t.text_faint); + } + + // Small photo icon badge at bottom-left of preview + let badge_rect = Rect::from_center_size(pos2(preview_rect.left() + 16.0, preview_rect.bottom() - 14.0), vec2(16.0, 16.0)); + icons::paint(ui, badge_rect, "image", 13.0, t.text_dim); + + // Bottom info area + let info_top = preview_rect.bottom() + 8.0; + let text_x = card_rect.left() + 14.0; + let text_max_w = (card_rect.right() - text_x - 12.0).max(10.0); + + // Filename + let name_galley = crate::tab_strip::elided(ui, name, crate::theme::semibold(13.0), t.text, text_max_w); + ui.painter().galley(pos2(text_x, info_top), name_galley, t.text); + + // Monitor icon + folder / "On your computer" + let monitor_rect = Rect::from_center_size(pos2(text_x + 6.0, info_top + 28.0), vec2(14.0, 14.0)); + icons::paint(ui, monitor_rect, "monitor", 12.0, t.text_faint); + + let folder_display = if folder.is_empty() { tl!("On your computer").to_owned() } else { folder.to_string() }; + let folder_galley = crate::tab_strip::elided(ui, &folder_display, egui::FontId::proportional(11.0), t.text_faint, text_max_w - 18.0); + ui.painter().galley(pos2(text_x + 18.0, info_top + 22.0), folder_galley, t.text_faint); + + // Hover 'x' close button in top right of card + let mut action = None; + if hovered { + let close_rect = Rect::from_center_size(pos2(card_rect.right() - 14.0, card_rect.top() + 14.0), vec2(20.0, 20.0)); + let x_resp = ui.interact(close_rect, resp.id.with("remove"), Sense::click()); + let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", name)]); + x_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + ui.painter().rect_filled(close_rect, 10.0, Color32::from_black_alpha(190)); + let x_color = if x_resp.hovered() { Color32::WHITE } else { t.text_dim }; + icons::paint(ui, close_rect, "x", 11.0, x_color); + if x_resp.on_hover_text(tl!("Remove from Recent")).clicked() { + action = Some(RecentAction::Remove(path.to_string())); + } + } + + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + let resp = resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.clicked() && action.is_none() { + action = Some(RecentAction::Open(path.to_string())); + } + + (resp, action) +} + +/// Render a dashed/hairline drop target card inside the recent grid. +pub(crate) fn render_inline_drop_card(ui: &mut Ui, drop_text: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (card_rect, resp) = ui.allocate_exact_size(vec2(GRID_CARD_WIDTH, GRID_CARD_HEIGHT), Sense::click()); + let hovered = ui.rect_contains_pointer(card_rect); + + let border_color = if hovered { t.accent } else { t.separator }; + let bg_color = if hovered { t.hover } else { Color32::TRANSPARENT }; + + ui.painter().rect_filled(card_rect, 6.0, bg_color); + ui.painter().rect_stroke(card_rect, 6.0, Stroke::new(1.0, border_color), egui::StrokeKind::Inside); + + // Centered plus / image icon + let icon_box = Rect::from_center_size(pos2(card_rect.center().x, card_rect.center().y - 20.0), vec2(36.0, 36.0)); + let icon_color = if hovered { t.text } else { t.text_dim }; + icons::paint(ui, icon_box, "image", 28.0, icon_color); + + // Message + let max_w = card_rect.width() - 24.0; + let text_color = if hovered { t.text } else { t.text_faint }; + let job = egui::text::LayoutJob::simple(drop_text.to_string(), egui::FontId::proportional(11.5), text_color, max_w); + let galley = ui.painter().layout_job(job); + let text_pos = pos2(card_rect.center().x - galley.size().x / 2.0, card_rect.center().y + 8.0); + ui.painter().galley(text_pos, galley, text_color); + + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open…"))); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Open…")).clicked() +} + +fn fit_image_rect(image_size: Vec2, bounds: Rect) -> Rect { + if image_size.x > 0.0 && image_size.y > 0.0 { + let scale = (bounds.width() / image_size.x).min(bounds.height() / image_size.y); + Rect::from_center_size(bounds.center(), image_size * scale) + } else { + bounds + } +} + +/// Render a single row in list view with thumbnail icon. +pub(crate) fn render_list_row( + ui: &mut Ui, + path: &str, + name: &str, + folder: &str, + thumb: Option<&TextureHandle>, + row_width: f32, +) -> (Response, Option) { + let t = Tokens::get(ui.ctx()); + let (row, resp) = ui.allocate_exact_size(vec2(row_width, LIST_ROW_HEIGHT), Sense::click()); + let hovered = ui.rect_contains_pointer(row); + if hovered { + ui.painter().rect_filled(row, t.radius_sm, t.hover); + } + + let close_rect = Rect::from_center_size(pos2(row.right() - 16.0, row.center().y), vec2(22.0, 22.0)); + let right_limit = if hovered { close_rect.left() - 4.0 } else { row.right() - 10.0 }; + + // Thumbnail icon on the left (24x24) + let thumb_box = Rect::from_center_size(pos2(row.left() + 18.0, row.center().y), vec2(24.0, 24.0)); + ui.painter().rect_filled(thumb_box, t.radius_sm, t.field); + if let Some(tex) = thumb { + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + ui.painter().image(tex.id(), fit_image_rect(tex.size_vec2(), thumb_box), uv, Color32::WHITE); + } else { + icons::paint(ui, thumb_box, "file", 14.0, t.text_dim); + } + + let text_x = row.left() + 38.0; + let name_g = crate::tab_strip::elided(ui, name, crate::theme::semibold(12.5), t.text, 220.0); + let name_w = name_g.size().x; + ui.painter().galley(pos2(text_x, row.center().y - name_g.size().y / 2.0), name_g, t.text); + + let folder_max = (right_limit - (text_x + name_w + 14.0)).max(0.0); + if folder_max > 20.0 && !folder.is_empty() { + let fg = crate::tab_strip::elided(ui, folder, egui::FontId::proportional(11.5), t.text_faint, folder_max); + ui.painter().galley(pos2(right_limit - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint); + } + + let mut action = None; + if hovered { + let x_resp = ui.interact(close_rect, resp.id.with("remove"), Sense::click()); + let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", name)]); + x_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + let color = icons::button_chrome(ui, close_rect, false, x_resp.hovered()); + icons::paint(ui, close_rect, "x", 12.0, color); + if x_resp.on_hover_text(tl!("Remove from Recent")).clicked() { + action = Some(RecentAction::Remove(path.to_string())); + } + } + + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + let resp = resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.clicked() && action.is_none() { + action = Some(RecentAction::Open(path.to_string())); + } + + (resp, action) +} + +/// The main entry point to render the Home Recent Files visual grid or list. +pub(crate) fn show_home_recent(app: &mut PhotocraftApp, ui: &mut Ui, recent: &[String], avail_width: f32) { + #[cfg(not(target_arch = "wasm32"))] + { + let opts = TextureOptions { + magnification: egui::TextureFilter::Linear, + minification: egui::TextureFilter::Linear, + wrap_mode: egui::TextureWrapMode::ClampToEdge, + mipmap_mode: Some(egui::TextureFilter::Linear), + }; + drain_pending_thumbnails(app, ui.ctx(), opts); + } + + let is_grid = app.ui.chrome.home_recent_grid; + let content_width = avail_width; + + let mut toggle_grid = is_grid; + recent_header(ui, &mut toggle_grid, content_width); + if toggle_grid != is_grid { + app.ui.chrome.home_recent_grid = toggle_grid; + } + + ui.add_space(14.0); + + if recent.is_empty() { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("No recent files")).color(t.text_dim).size(13.0)); + return; + } + + let mut action_to_take = None; + + if is_grid { + let spacing = 16.0; + let card_w = GRID_CARD_WIDTH; + let cols = ((content_width + spacing) / (card_w + spacing)).floor().max(1.0) as usize; + + egui::Grid::new("home_recent_grid_layout").spacing(vec2(spacing, spacing)).show(ui, |ui| { + for (idx, path) in recent.iter().enumerate() { + let name = crate::file_open::display_name(path); + let folder = Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + let thumb = get_or_load_thumbnail(app, ui.ctx(), path); + + let (resp, action) = render_grid_card(ui, path, &name, &folder, thumb.as_ref()); + if let Some(act) = action { + action_to_take = Some(act); + } + + let path_clone = path.clone(); + resp.context_menu(|ui| { + if ui.button(tl!("Remove from Recent")).clicked() { + action_to_take = Some(RecentAction::Remove(path_clone)); + ui.close(); + } + if ui.button(tl!("Clear Recent Files")).clicked() { + action_to_take = Some(RecentAction::Clear); + ui.close(); + } + }); + + if (idx + 1) % cols == 0 { + ui.end_row(); + } + } + + // Inline drop card as the last card in the grid + let drop_idx = recent.len(); + let drop_hint = crate::canvas::start_screen_drop_hint(app); + if render_inline_drop_card(ui, &drop_hint) { + action_to_take = Some(RecentAction::OpenDialog); + } + if (drop_idx + 1).is_multiple_of(cols) { + ui.end_row(); + } + }); + } else { + for path in recent { + let name = crate::file_open::display_name(path); + let folder = Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + let thumb = get_or_load_thumbnail(app, ui.ctx(), path); + + let (resp, action) = render_list_row(ui, path, &name, &folder, thumb.as_ref(), content_width); + if let Some(act) = action { + action_to_take = Some(act); + } + + let path_clone = path.clone(); + resp.context_menu(|ui| { + if ui.button(tl!("Remove from Recent")).clicked() { + action_to_take = Some(RecentAction::Remove(path_clone)); + ui.close(); + } + if ui.button(tl!("Clear Recent Files")).clicked() { + action_to_take = Some(RecentAction::Clear); + ui.close(); + } + }); + } + } + + if let Some(action) = action_to_take { + match action { + RecentAction::Open(path) => { + if let Err(e) = app.open_path(&path) { + app.open_failed(&crate::file_open::display_name(&path), &e); + } + } + RecentAction::Remove(path) => { + app.remove_recent(&path); + } + RecentAction::Clear => { + app.clear_recent(); + } + RecentAction::OpenDialog => { + let _ = app.open_dialog_file(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn list_thumbnail_fit_preserves_aspect_ratio() { + let bounds = Rect::from_min_size(pos2(0.0, 0.0), vec2(24.0, 24.0)); + let fitted = fit_image_rect(vec2(256.0, 144.0), bounds); + assert_eq!(fitted.size(), vec2(24.0, 13.5)); + assert_eq!(fitted.center(), bounds.center()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn panicking_thumbnail_load_returns_no_preview() { + let loaded = load_thumbnail_catching_panic(|| panic!("simulated decoder panic")); + assert!(loaded.is_none()); + } + + #[test] + fn recent_grid_and_list_toggle_switches_view_mode() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent("/tmp/photocraft-recent-grid/a.png"); + app.push_recent("/tmp/photocraft-recent-grid/b.psd"); + + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + let recent: Vec = app.ui.recent_files.clone(); + show_home_recent(app, ui, &recent, 600.0); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(2); + + // Initially in Grid mode + assert!(h.state().0.ui.chrome.home_recent_grid); + assert!(h.query_by_label("b.psd").is_some()); + assert!(h.query_by_label("a.png").is_some()); + + // Toggle to List view + h.get_by_label("List view").click(); + h.run_steps(2); + assert!(!h.state().0.ui.chrome.home_recent_grid); + + // Toggle back to Grid view + h.get_by_label("Grid view").click(); + h.run_steps(2); + assert!(h.state().0.ui.chrome.home_recent_grid); + } + + #[test] + fn recent_grid_card_shows_filename_and_folder() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent("/tmp/photocraft-work/document.psd"); + + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + let recent: Vec = app.ui.recent_files.clone(); + show_home_recent(app, ui, &recent, 600.0); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(2); + + assert!(h.query_by_label("document.psd").is_some()); + } + + #[test] + fn recent_header_view_switch_buttons_align_to_right_edge() { + let content_w = 640.0; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent("/tmp/photocraft-work/document.psd"); + + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + let recent: Vec = app.ui.recent_files.clone(); + show_home_recent(app, ui, &recent, content_w); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(2); + + let list_node = h.get_by_label("List view"); + let list_rect = list_node.rect(); + let box_right = list_rect.max.x + 2.0; + + let start_x = h.get_by_label("Recent").rect().min.x; + assert_eq!(box_right, start_x + content_w, "toggle buttons must align flush to content right edge"); + } + + #[test] + fn inline_drop_card_renders_hint_and_open_button() { + let hint = "Drop an image or PSD anywhere to open it."; + let mut clicked = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 400.0)).build_ui(|ui| { + if render_inline_drop_card(ui, hint) { + clicked = true; + } + }); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(2); + assert!(h.query_by_label("Open…").is_some()); + let _ = clicked; + } + + #[test] + fn recent_thumbnail_cleared_on_remove_and_clear_recent() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let path1 = "/tmp/photocraft-test/thumb1.psd"; + let path2 = "/tmp/photocraft-test/thumb2.psd"; + + app.push_recent(path1); + app.push_recent(path2); + + // Pre-populate dummy entries in thumbnail_cache + { + let mut cache = app.thumbnail_cache.lock().unwrap(); + cache.entries.insert( + path1.to_string(), + ThumbnailEntry { + key: CacheKey::DiskFile { modified: None, len: 100 }, + texture: None, + #[cfg(not(target_arch = "wasm32"))] + last_checked: std::time::Instant::now(), + }, + ); + cache.entries.insert( + path2.to_string(), + ThumbnailEntry { + key: CacheKey::DiskFile { modified: None, len: 200 }, + texture: None, + #[cfg(not(target_arch = "wasm32"))] + last_checked: std::time::Instant::now(), + }, + ); + } + + // Verify entries exist + assert!(app.thumbnail_cache.lock().unwrap().entries.contains_key(path1)); + assert!(app.thumbnail_cache.lock().unwrap().entries.contains_key(path2)); + + // remove_recent removes from thumbnail_cache + app.remove_recent(path1); + assert!(!app.thumbnail_cache.lock().unwrap().entries.contains_key(path1)); + assert!(app.thumbnail_cache.lock().unwrap().entries.contains_key(path2)); + + // clear_recent clears all entries from thumbnail_cache + app.clear_recent(); + assert!(app.thumbnail_cache.lock().unwrap().entries.is_empty()); + } + + #[test] + fn recent_thumbnail_updates_when_open_document_revision_changes() { + use photocraft_doc::{ColorMode, Document, SampleType, Size}; + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let path = "/tmp/photocraft-test/open_doc.psd"; + app.push_recent(path); + + let doc = Document::new("open_doc.psd", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + app.session.open_document(doc, Some(path.to_string())); + if let Some(st) = app.session.active_mut() { + st.path = Some(path.to_string()); + st.revision = 0; + } + + let ctx = egui::Context::default(); + let tex1 = get_or_load_thumbnail(&app, &ctx, path); + assert!(tex1.is_some()); + + // Verify cache key matches revision 0 + { + let cache = app.thumbnail_cache.lock().unwrap(); + let entry = cache.entries.get(path).unwrap(); + assert_eq!(entry.key, CacheKey::OpenDoc { revision: 0 }); + } + + // Increment revision (simulate an edit to the open document) + if let Some(st) = app.session.active_mut() { + st.revision = 1; + } + + #[cfg(not(target_arch = "wasm32"))] + std::thread::sleep(OPEN_DOC_REGEN_INTERVAL + std::time::Duration::from_millis(20)); + let tex2 = get_or_load_thumbnail(&app, &ctx, path); + assert!(tex2.is_some()); + + // Verify cache key updated to revision 1 + { + let cache = app.thumbnail_cache.lock().unwrap(); + let entry = cache.entries.get(path).unwrap(); + assert_eq!(entry.key, CacheKey::OpenDoc { revision: 1 }); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn open_document_thumbnail_regeneration_is_rate_limited() { + use photocraft_doc::{ColorMode, Document, SampleType, Size}; + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let path = "/tmp/photocraft-test/open_doc_throttle.psd"; + app.push_recent(path); + let doc = Document::new("open_doc_throttle.psd", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + app.session.open_document(doc, Some(path.to_string())); + if let Some(st) = app.session.active_mut() { + st.path = Some(path.to_string()); + st.revision = 0; + } + let ctx = egui::Context::default(); + assert!(get_or_load_thumbnail(&app, &ctx, path).is_some()); + + // Rapid edits: the previous preview is served instead of recomposing every frame. + for rev in 1..=5 { + if let Some(st) = app.session.active_mut() { + st.revision = rev; + } + assert!(get_or_load_thumbnail(&app, &ctx, path).is_some()); + let cache = app.thumbnail_cache.lock().unwrap(); + assert_eq!(cache.entries.get(path).unwrap().key, CacheKey::OpenDoc { revision: 0 }); + } + + // Once the interval has passed the latest revision is composed. + std::thread::sleep(OPEN_DOC_REGEN_INTERVAL + std::time::Duration::from_millis(20)); + assert!(get_or_load_thumbnail(&app, &ctx, path).is_some()); + let cache = app.thumbnail_cache.lock().unwrap(); + assert_eq!(cache.entries.get(path).unwrap().key, CacheKey::OpenDoc { revision: 5 }); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_regenerates_when_disk_file_changes() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_thumb_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("test_file.png"); + let path_str = file_path.to_string_lossy().into_owned(); + + // Write an initial 4x4 red PNG + let red_png = { + let img = photocraft_codecs::Image::from_raw( + 4, + 4, + photocraft_codecs::ChannelLayout::Rgba, + photocraft_codecs::SampleType::U8, + [255, 0, 0, 255].repeat(16), + ) + .unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + std::fs::write(&file_path, &red_png).unwrap(); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent(&path_str); + + let ctx = egui::Context::default(); + let tex1 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex1.is_some()); + + let initial_len = { + let cache = app.thumbnail_cache.lock().unwrap(); + let entry = cache.entries.get(&path_str).unwrap(); + match entry.key { + CacheKey::DiskFile { len, .. } => len, + _ => panic!("expected DiskFile cache key"), + } + }; + + // Write a larger 32x32 multi-color PNG to change file size and content + let blue_png = { + let mut px = Vec::with_capacity(32 * 32 * 4); + for i in 0..(32 * 32) { + px.extend_from_slice(&[(i % 256) as u8, ((i * 7) % 256) as u8, ((i * 13) % 256) as u8, 255]); + } + let img = photocraft_codecs::Image::from_raw(32, 32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, px).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + std::fs::write(&file_path, &blue_png).unwrap(); + + // Let the recheck interval elapse (10ms in tests) + std::thread::sleep(std::time::Duration::from_millis(20)); + + // get_or_load_thumbnail detects the disk metadata change and regenerates + let tex2 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex2.is_some()); + + let new_len = { + let cache = app.thumbnail_cache.lock().unwrap(); + let entry = cache.entries.get(&path_str).unwrap(); + match entry.key { + CacheKey::DiskFile { len, .. } => len, + _ => panic!("expected DiskFile cache key"), + } + }; + + assert_ne!(initial_len, new_len, "thumbnail cache must reflect the updated file size on disk"); + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_recheck_interval_avoids_per_frame_disk_io() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_fast_thumb_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("test_file.png"); + let path_str = file_path.to_string_lossy().into_owned(); + + let img = + photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, [0, 255, 0, 255].repeat(16)) + .unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap(); + std::fs::write(&file_path, &png).unwrap(); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent(&path_str); + + let ctx = egui::Context::default(); + let tex1 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex1.is_some()); + + // Immediately remove the file from disk + std::fs::remove_file(&file_path).unwrap(); + + // Calling get_or_load_thumbnail immediately within the recheck window serves from + // memory without touching disk or erroring out + let tex2 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex2.is_some(), "subsequent frame within recheck interval must serve from cache"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_skips_composite_for_oversized_psd_without_preview() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_psd_bound_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("oversized.psd"); + let path_str = file_path.to_string_lossy().into_owned(); + + // Create an oversized PSD (3000 x 3000) with no thumbnail resource + let header = photocraft_psd::Header::new(photocraft_psd::Version::Psd, 3000, 3000, 3, 8, photocraft_psd::ColorMode::Rgb); + let file = photocraft_psd::PsdFile { + header, + color_mode_data: Vec::new(), + resources: Vec::new(), + layer_info: None, + layer_info_placement: photocraft_psd::LayerInfoPlacement::Section, + global_layer_mask: None, + global_blocks: Vec::new(), + layer_mask_trailing: Vec::new(), + image_data: photocraft_psd::ImageData { compression: photocraft_psd::Compression::Raw, data: Vec::new(), stored: None }, + write_as_psb: false, + }; + let mut bytes = file.to_bytes().unwrap(); + // Append a 2 MB dummy/corrupt image payload to simulate a real multi-megabyte payload + bytes.extend_from_slice(&vec![0xAA; 2 * 1024 * 1024]); + std::fs::write(&file_path, &bytes).unwrap(); + + // load_thumbnail_image_bounded should return None due to <= 2048 bound checked from 26-byte header, + // without attempting to read or parse the 2 MB payload. + let thumb = load_thumbnail_image_bounded(&path_str, bytes.len() as u64); + assert!(thumb.is_none(), "oversized PSD with payload must skip composite fallback"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_skips_composite_for_high_channel_psd_without_preview() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_psd_channels_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("high_channels.psd"); + let path_str = file_path.to_string_lossy().into_owned(); + + // 2000 x 2000 with 56 channels: decoded uncompressed size = 2000 * 2000 * 56 = 224 MB (> 64 MB cap) + let header = photocraft_psd::Header::new(photocraft_psd::Version::Psd, 2000, 2000, 56, 8, photocraft_psd::ColorMode::Rgb); + let file = photocraft_psd::PsdFile { + header, + color_mode_data: Vec::new(), + resources: Vec::new(), + layer_info: None, + layer_info_placement: photocraft_psd::LayerInfoPlacement::Section, + global_layer_mask: None, + global_blocks: Vec::new(), + layer_mask_trailing: Vec::new(), + image_data: photocraft_psd::ImageData { compression: photocraft_psd::Compression::Raw, data: Vec::new(), stored: None }, + write_as_psb: false, + }; + let bytes = file.to_bytes().unwrap(); + std::fs::write(&file_path, &bytes).unwrap(); + + let thumb = load_thumbnail_image_bounded(&path_str, bytes.len() as u64); + assert!(thumb.is_none(), "high-channel PSD exceeding decoded memory budget must skip composite fallback"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_extracts_embedded_preview_from_large_psd_without_reading_payload() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_psd_large_preview_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("large_payload.psd"); + let path_str = file_path.to_string_lossy().into_owned(); + + let img = photocraft_codecs::Image::from_raw( + 512, + 512, + photocraft_codecs::ChannelLayout::Rgb, + photocraft_codecs::SampleType::U8, + [255, 0, 0].repeat(512 * 512), + ) + .unwrap(); + let jfif = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &photocraft_codecs::EncodeOptions::default()).unwrap(); + let mut res_data = vec![0u8; 28]; + res_data[0..4].copy_from_slice(&1u32.to_be_bytes()); // format: 1 (kJpegRGB) + res_data[4..8].copy_from_slice(&512u32.to_be_bytes()); // width: 512 + res_data[8..12].copy_from_slice(&512u32.to_be_bytes()); // height: 512 + res_data.extend_from_slice(&jfif); + let resource = photocraft_psd::resources::ImageResource::new(photocraft_psd::resources::ids::THUMBNAIL, res_data); + + let header = photocraft_psd::Header::new(photocraft_psd::Version::Psd, 400, 400, 3, 8, photocraft_psd::ColorMode::Rgb); + let file = photocraft_psd::PsdFile { + header, + color_mode_data: Vec::new(), + resources: vec![resource], + layer_info: None, + layer_info_placement: photocraft_psd::LayerInfoPlacement::Section, + global_layer_mask: None, + global_blocks: Vec::new(), + layer_mask_trailing: Vec::new(), + image_data: photocraft_psd::ImageData { compression: photocraft_psd::Compression::Raw, data: Vec::new(), stored: None }, + write_as_psb: false, + }; + let mut bytes = file.to_bytes().unwrap(); + // Append 3 MB of corrupt payload bytes after the resources section + bytes.extend_from_slice(&vec![0xEE; 3 * 1024 * 1024]); + std::fs::write(&file_path, &bytes).unwrap(); + + // The bounded streaming range reader must extract the embedded 1036 JFIF preview + // from the initial resources section without erroring on or allocating the 3 MB trailing payload. + let thumb = load_thumbnail_image_bounded(&path_str, bytes.len() as u64); + assert!(thumb.is_some(), "must extract embedded JFIF preview despite trailing multi-megabyte payload"); + let thumb_img = thumb.unwrap(); + assert_eq!(thumb_img.size, [256, 256], "embedded previews must be downsampled before texture creation"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_loads_in_background_when_background_jobs_enabled() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_async_thumb_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("bg_test.png"); + let path_str = file_path.to_string_lossy().into_owned(); + + let img = + photocraft_codecs::Image::from_raw(8, 8, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, [0, 0, 255, 255].repeat(64)) + .unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap(); + std::fs::write(&file_path, &png).unwrap(); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = true; + + let ctx = egui::Context::default(); + // First call on cache miss: should dispatch to background worker and return None immediately (no UI stall) + let tex1 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex1.is_none(), "cache miss with background_jobs must not block the UI thread"); + + // Wait for worker thread to complete + let start = std::time::Instant::now(); + while start.elapsed() < std::time::Duration::from_secs(2) { + let pending_done = { + let cache = app.thumbnail_cache.lock().unwrap(); + !cache.pending.is_empty() || cache.entries.contains_key(&path_str) + }; + if pending_done { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + // Second call: drains pending and returns the loaded texture + let tex2 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex2.is_some(), "subsequent frame receives the loaded thumbnail from background worker"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_discards_background_result_if_invalidated_while_loading() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_inval_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("inval_test.png"); + let path_str = file_path.to_string_lossy().into_owned(); + + let img = + photocraft_codecs::Image::from_raw(8, 8, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, [255, 0, 0, 255].repeat(64)) + .unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap(); + std::fs::write(&file_path, &png).unwrap(); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = true; + + let ctx = egui::Context::default(); + let tex1 = get_or_load_thumbnail(&app, &ctx, &path_str); + assert!(tex1.is_none()); + + // Immediately invalidate while thread is in flight + app.thumbnail_cache.lock().unwrap().invalidate(&path_str); + + // Wait a short time for thread to complete + std::thread::sleep(std::time::Duration::from_millis(50)); + + // The background thread should have detected it was removed from in_flight and discarded its result + let cache = app.thumbnail_cache.lock().unwrap(); + assert!(!cache.in_flight.contains_key(&path_str)); + assert!(cache.pending.iter().all(|(p, ..)| p != &path_str)); + assert!(!cache.entries.contains_key(&path_str)); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_does_not_overwrite_active_document_in_drain() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let doc_path = "C:/fake/path/active_project.psd".to_string(); + + let doc = photocraft_doc::Document::new( + "active_project", + photocraft_geom::Size::new(100, 100), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ); + let (doc_id, _) = app.session.open_document(doc, Some(doc_path.clone())); + let st = app.session.active().unwrap(); + let revision = st.revision; + + let ctx = egui::Context::default(); + // Load initial thumbnail for open document + let open_tex = get_or_load_thumbnail(&app, &ctx, &doc_path); + assert!(open_tex.is_some()); + + // Simulate an obsolete disk result queued in pending with active in_flight matching generation 1 + { + let mut cache = app.thumbnail_cache.lock().unwrap(); + let disk_key = CacheKey::DiskFile { modified: None, len: 1000 }; + cache.in_flight.insert(doc_path.clone(), 1); + cache.pending.push((doc_path.clone(), 1, disk_key, None)); + } + + // Drain pending thumbnails + let opts = egui::TextureOptions::default(); + drain_pending_thumbnails(&app, &ctx, opts); + + // Verify the cache still holds the OpenDoc entry, NOT overwritten by disk entry + let cache = app.thumbnail_cache.lock().unwrap(); + let entry = cache.entries.get(&doc_path).expect("entry must exist"); + assert_eq!(entry.key, CacheKey::OpenDoc { revision }); + + let _ = app.session.close(0); + let _ = doc_id; + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_superseded_generation_is_discarded() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let test_path = "C:/fake/path/image.png".to_string(); + let ctx = egui::Context::default(); + let opts = egui::TextureOptions::default(); + + // Simulate request generation 1 was superseded by generation 2 + { + let mut cache = app.thumbnail_cache.lock().unwrap(); + cache.in_flight.insert(test_path.clone(), 2); + // Stale worker 1 pushes with req_id 1 + let disk_key = CacheKey::DiskFile { modified: None, len: 500 }; + cache.pending.push((test_path.clone(), 1, disk_key, None)); + } + + // Drain pending: request 1 should be discarded because generation 2 is active + drain_pending_thumbnails(&app, &ctx, opts); + + let cache = app.thumbnail_cache.lock().unwrap(); + // Generation 2 must remain in flight and not be removed or overwritten by generation 1 + assert_eq!(cache.in_flight.get(&test_path).copied(), Some(2)); + assert!(!cache.entries.contains_key(&test_path)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_skips_flat_image_larger_than_4k_dimensions() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_huge_img_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let file_path = temp_dir.join("huge_img.png"); + let path_str = file_path.to_string_lossy().into_owned(); + + // Write a PNG with 4097x1 dimensions (exceeds 4096 cap on longest side) + let img = photocraft_codecs::Image::from_raw(4097, 1, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, vec![0u8; 4097 * 4]) + .unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap(); + std::fs::write(&file_path, &png).unwrap(); + + let thumb = load_thumbnail_image_bounded(&path_str, png.len() as u64); + assert!(thumb.is_none(), "flat images exceeding 4096 on longest side must be skipped"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn recent_thumbnail_skips_oversized_thumb_in_pcraft_dir() { + let temp_dir = std::env::temp_dir().join(format!("photocraft_oversized_pcraft_test_{}.pcraft", std::process::id())); + let _ = std::fs::create_dir_all(&temp_dir); + let thumb_path = temp_dir.join("thumb.png"); + let path_str = temp_dir.to_string_lossy().into_owned(); + + // Create a 65 MiB thumb.png (exceeds 64 MiB input budget) using set_len (instant sparse file) + let f = std::fs::File::create(&thumb_path).unwrap(); + f.set_len(65 * 1024 * 1024).unwrap(); + drop(f); + + // Directory metadata len is small (typically 0 or 4096), but the inner thumb.png exceeds budget + let dir_len = std::fs::metadata(&temp_dir).map(|m| m.len()).unwrap_or(0); + let thumb = load_thumbnail_image_bounded(&path_str, dir_len); + assert!(thumb.is_none(), "oversized thumb.png in .pcraft directory must be rejected before reading"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index a8fa5c2bcf..b0de64ffff 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -20,6 +20,7 @@ //! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`]; //! translators may reorder placeholders freely. +mod casing; mod catalog; mod system; @@ -42,7 +43,7 @@ pub struct LangInfo { /// [`LangInfo::text`]. pub source: &'static [u8], /// Plural form index for a count (English: 0 = one, 1 = other; Japanese, Chinese and Hungarian: always 0; - /// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per + /// Czech: 0 = one, 1 = few (2–4), 2 = other; Russian, Ukrainian and Bosnian: 0 = one (1, 21 …), 1 = few (2–4, 22–24 …), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per /// index. pub plural: fn(u64) -> usize, /// Does the catalog cover every menu string? (`cargo xtask i18n-coverage` lists what it lacks) @@ -104,6 +105,20 @@ fn plural_pt(n: u64) -> usize { usize::from(n > 1) } +/// Romanian (CLDR): 1 → one; 0, or n % 100 in 1–19 → few (these take the bare plural noun, e.g. +/// "2 straturi", "0 straturi", "119 straturi"); everything else (20, 100, 121, ...) → other (these +/// take "de" plus the plural noun, e.g. "20 de straturi", "121 de straturi"), which matches +/// Romanian's grammatical rule for when counted nouns need "de" inserted before them. +fn plural_romanian(n: u64) -> usize { + if n == 1 { + 0 + } else if n == 0 || (1..=19).contains(&(n % 100)) { + 1 + } else { + 2 + } +} + /// Polish: 1 → one; 2–4, except 12–14 → few; everything else → many. fn plural_polish(n: u64) -> usize { let last = n % 10; @@ -201,7 +216,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 27] = [ +pub static LANGUAGES: [LangInfo; 35] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -216,6 +231,10 @@ pub static LANGUAGES: [LangInfo; 27] = [ // `sr-Latn-*` and `sr_RS@latin` resolve here; so do Cyrillic `sr` locales, which read Latin // rather than English. LangInfo { code: "sr", name: "Srpski", source: catalog!("sr"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, + // Bosnian (Latin script, Ijekavian), the same one/few/other rule for integer counts. `bs-BA`, + // `bs-Latn-*` and `bs_BA.UTF-8` resolve here; so do Cyrillic `bs` locales, which read Latin + // rather than English. + LangInfo { code: "bs", name: "Bosanski", source: catalog!("bs"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "cs", name: "Čeština", source: catalog!("cs"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "fr", name: "Français", source: catalog!("fr"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "id", name: "Bahasa Indonesia", source: catalog!("id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -232,8 +251,13 @@ pub static LANGUAGES: [LangInfo; 27] = [ LangInfo { code: "hu", name: "Magyar", source: catalog!("hu"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Turkish; `tr`, `tr-TR` and `tr_TR` locales resolve here. LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + // Azerbaijani (Latin script); regional and explicit Latin-script tags resolve here. + LangInfo { code: "az", name: "Azərbaycanca", source: catalog!("az"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese; `vi-VN` resolves here. LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + // Thai; `th`, `th-TH` and `th_TH.UTF-8` locales resolve here. Thai has no plural inflection, + // so counts use one form. + LangInfo { code: "th", name: "ไทย", source: catalog!("th"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Hindi; `hi`, `hi-IN` locales resolve here. LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, // Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The @@ -249,6 +273,19 @@ pub static LANGUAGES: [LangInfo; 27] = [ // Norwegian Bokmål; `nb-NO`, and the macrolanguage `no` and Nynorsk `nn` from POSIX locales, // resolve here (see `candidates`). LangInfo { code: "nb", name: "Norsk bokmål", source: catalog!("nb"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Hebrew; `he-il` and `he_IL` resolve here. The chrome mirrors right to left (`dir_for_code`), + // like Arabic. Modern Hebrew software UI collapses CLDR's one/two/many/other + // into a plain one|other, so this uses `plural_one_other`. + LangInfo { code: "he", name: "עברית", source: catalog!("he"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Urdu; written right to left like Arabic (`dir_for_code`). CLDR plural: one|other. + LangInfo { code: "ur", name: "اردو", source: catalog!("ur"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Romanian; `ro-RO` and `ro-MD` resolve here. Standard left-to-right; see `plural_romanian` + // for the one/few/other rule. + LangInfo { code: "ro", name: "Română", source: catalog!("ro"), plural: plural_romanian, complete_menus: true, catalog: OnceLock::new() }, + // Tamil; `ta-IN`, `ta-LK` and `ta_IN.UTF-8` resolve here. CLDR plural: one|other. + LangInfo { code: "ta", name: "தமிழ்", source: catalog!("ta"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Georgian: nouns stay singular after a numeral (`შრე: 5`, `5 შრე`), so one form serves every count. + LangInfo { code: "ka", name: "ქართული", source: catalog!("ka"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index bcc690f7b7..a3cf7d99ae 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,8 +1,14 @@ PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, -Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish and Norwegian Bokmål UI translation -catalogs are licensed under MIT OR Apache-2.0, at your option. +Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish, Norwegian Bokmål, Bosnian, Hebrew, +Urdu, Romanian, Azerbaijani, Thai and Tamil UI translation catalogs are licensed under MIT OR +Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. +Azerbaijani (Latin script) translations: machine-assisted drafts of PhotoCraft's +English labels using Google Translate, with core menu/tool terminology, contextual meanings and +plural messages edited by OpenAI Codex. No proprietary application localization resources were +used. Native-speaker review is pending; no human contributor name is inferred or assigned. + Korean translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. @@ -57,3 +63,25 @@ translations of PhotoCraft's English labels, and no proprietary translation reso Norwegian Bokmål translations: PhotoCraft contributors, combined from the catalogs contributed by @nikolainyegaard and @Bolla76 and reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's English labels, and no proprietary translation resources were used. + +Bosnian translations: Nine (@nine-codes) with Claude Code (AI-assisted), original translations of +PhotoCraft's English labels, shared with storytold/craft-i18n (locales/bs/); no proprietary +translation resources were used. Not yet reviewed by a native speaker. +Hebrew translations: machine-translated for @tronicum with Claude (Anthropic) as an AI +contributor and not yet reviewed by a native speaker; original translations of PhotoCraft's +English labels, and no proprietary translation resources were used. +Urdu translations: machine-translated for @tronicum with Claude (Anthropic) as an AI +contributor and not yet reviewed by a native speaker; original translations of PhotoCraft's +English labels, and no proprietary translation resources were used. +Romanian translations: machine-translated for @tronicum with Claude (Anthropic) as an AI +contributor and not yet reviewed by a native speaker; original translations of PhotoCraft's +English labels, and no proprietary translation resources were used. + +Thai translations: PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor; a +machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology. The +resulting catalog is licensed under MIT OR Apache-2.0 as stated above, and no proprietary +localisation resource was used. + +Tamil translations: @newsbaba24x7 and PhotoCraft contributors, original translations of +PhotoCraft's English labels written from their plain meaning; no proprietary localisation +resource was used. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0c67a91ec6..1cfced87ac 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -81,6 +81,7 @@ Right يمين Open Recent فتح الملفات الأخيرة Clear Recent Files مسح الملفات الأخيرة + Clear Recent File List مسح الملفات الأخيرة Remove from Recent إزالة من الملفات الأخيرة Remove {name} from Recent إزالة {name} من الملفات الأخيرة Close إغلاق @@ -89,6 +90,10 @@ Restore استعادة Close All إغلاق الكل Close Others إغلاق المستندات الأخرى + New Document… مستند جديد… + Open Document… فتح… + Consolidate All to Here توحيد الكل هنا + Move to New Window نقل إلى نافذة جديدة Save حفظ Save As… حفظ باسم… Save a Copy… حفظ نسخة… @@ -141,6 +146,7 @@ Create Smart Object after Loading Layers إنشاء كائن ذكي بعد تحميل الطبقات Reveal in Finder إظهار في Finder Show in Explorer إظهار في Explorer + Reveal in Explorer إظهار في Explorer Show in Folder إظهار في المجلد File Info… معلومات الملف… Print… طباعة… @@ -229,6 +235,7 @@ Units & Rulers… الوحدات والمساطر… Guides, Grid & Slices… خطوط التوجيه والشبكة والشرائح… Plug-ins… المكونات الإضافية… + Plugins… المكونات الإضافية… Type… النص… Enhanced Controls… عناصر التحكم المحسنة… Camera Raw… Camera Raw… @@ -386,6 +393,8 @@ Smart Object كائن ذكي Convert to Smart Object تحويل إلى كائن ذكي Convert to Embedded تحويل إلى مضمّن + Embed Linked تضمين المرتبط + Embed All Linked تضمين كل المرتبط Convert to Layers تحويل إلى طبقات Convert to Linked… تحويل إلى مرتبط… Edit Contents تحرير المحتويات @@ -459,7 +468,9 @@ Unite Shapes توحيد الأشكال Subtract Front Shape طرح الشكل الأمامي Intersect Shape Areas تقاطع مناطق الأشكال + Unite Shapes at Overlap تقاطع مناطق الأشكال Exclude Overlapping Shapes استبعاد الأشكال المتداخلة + Subtract Shapes at Overlap استبعاد الأشكال المتداخلة Merge Shape Components دمج مكونات الشكل Align محاذاة Top Edges الحواف العلوية @@ -535,7 +546,6 @@ Deselect إلغاء التحديد Reselect إعادة التحديد Inverse عكس التحديد - Inverse Selection عكس التحديد Similar مشابه Color Range… نطاق الألوان… Focus Area… منطقة التركيز… @@ -660,6 +670,7 @@ Minimum… الحد الأدنى… Offset… إزاحة… Plug-ins المكونات الإضافية + Plugins المكونات الإضافية Proof Setup إعداد المعاينة الطباعية Proof Colors ألوان المعاينة الطباعية Gamut Warning تحذير النطاق اللوني @@ -668,12 +679,12 @@ Working Magenta Plate لوح الأرجواني العامل Working Yellow Plate لوح الأصفر العامل Working Black Plate لوح الأسود العامل - Working CMY Plate لوح CMY العامل - Legacy Macintosh RGB RGB القديم لنظام Macintosh - Internet Standard RGB RGB القياسي للإنترنت + Working CMY Plates ألواح CMY العاملة + Legacy Macintosh RGB (Gamma 1.8) RGB القديم لنظام Macintosh (Gamma 1.8) + Internet Standard RGB (sRGB) RGB القياسي للإنترنت (sRGB) Monitor RGB RGB الشاشة - Color Blindness — Protanopia-type عمى الألوان — نوع بروتانوبيا - Color Blindness — Deuteranopia-type عمى الألوان — نوع ديوتيرانوبيا + Color Blindness - Protanopia-type عمى الألوان — نوع بروتانوبيا + Color Blindness - Deuteranopia-type عمى الألوان — نوع ديوتيرانوبيا Zoom In تكبير Zoom Out تصغير Fit on Screen ملاءمة للشاشة @@ -752,7 +763,7 @@ Consolidate All to Tabs توحيد الكل في علامات تبويب Float in Window تعويم في نافذة Float All in Windows تعويم الكل في نوافذ - New Window for Document نافذة جديدة للمستند + New Window نافذة جديدة Workspace مساحة العمل Essentials الأساسيات Graphic and Web الرسومات والويب @@ -814,6 +825,12 @@ Timeline المخطط الزمني Tool Presets الإعدادات المسبقة للأدوات Tools الأدوات + Contextual Task Bar شريط المهام السياقي + Hide bar إخفاء الشريط + Reset bar position إعادة تعيين موضع الشريط + Pin bar position تثبيت موضع الشريط + Move bar نقل الشريط + More options مزيد من الخيارات About PhotoCraft حول PhotoCraft PhotoCraft Website موقع PhotoCraft PhotoCraft on GitHub PhotoCraft على GitHub @@ -975,7 +992,7 @@ Click to add a point · drag off or {key}-click or Delete to remove انقر لإضافة نقطة · اسحبها بعيدًا أو {key}+نقر أو Delete للإزالة Click to apply, {key}-click to clear overrides, double-click for Style Options انقر للتطبيق، {key}+نقر لمسح التجاوزات، نقر مزدوج لخيارات النمط Click to edit the gradient انقر لتحرير التدرج - Click to restore the current colour انقر لاستعادة اللون الحالي + Click to restore the current color انقر لاستعادة اللون الحالي Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out انقر للتكبير · {key}+نقر للتصغير · اسحب يمينًا/يسارًا للتكبير/التصغير Click with the Note tool to add a note. انقر بأداة الملاحظة لإضافة ملاحظة. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel نقر: زاوية · سحب: ناعم · نقر على النقطة الأولى: إغلاق · {key} إنهاء · Esc إلغاء @@ -1061,7 +1078,7 @@ Darks الدرجات الداكنة Data Sets مجموعات البيانات Default افتراضي - Default colours (D) الألوان الافتراضية (D) + Default Foreground and Background Colors (D) لونا المقدمة والخلفية الافتراضيان (D) Default printer الطابعة الافتراضية Default restored. تمت استعادة الإعدادات الافتراضية. Define تعريف @@ -1347,7 +1364,7 @@ Opacity Jitter تذبذب العتامة Opacity: العتامة: Open a document to define variables. افتح مستندًا لتعريف المتغيرات. - Open a document to select a colour range. افتح مستندًا لتحديد نطاق ألوان. + Open a document to select a color range. افتح مستندًا لتحديد نطاق ألوان. Open a document to use the timeline. افتح مستندًا لاستخدام المخطط الزمني. Optical بصري Optimized محسّن @@ -1423,7 +1440,6 @@ Purity النقاء Quality الجودة Quick Actions إجراءات سريعة - Quick Export As PNG تصدير سريع بتنسيق PNG Radial Gradient تدرج شعاعي Radius نصف القطر Radius: نصف القطر: @@ -1565,7 +1581,7 @@ Subtract from Sample طرح من العينة Subtract from selection ({key}) طرح من التحديد ({key}) Suggestions: الاقتراحات: - Swap colours (X) تبديل الألوان (X) + Switch Foreground and Background Colors (X) تبديل لوني المقدمة والخلفية (X) Swaps height and width يبدّل الارتفاع والعرض Switch between free transform and warp modes التبديل بين وضعي التحويل الحر والالتواء Switch theme تبديل السمة @@ -1668,7 +1684,7 @@ Choke تضييق Classic كلاسيكي Click to add text انقر لإضافة نص - Click to fill similar colours انقر لتعبئة الألوان المتشابهة + Click to fill similar colors انقر لتعبئة الألوان المتشابهة Clone Stamp Tool أداة ختم النسخ Color Burn تغميق اللون Color Dodge تفتيح اللون @@ -1769,6 +1785,7 @@ Move Tool أداة التحريك Could not use the move tool because the layer is locked. تعذر استخدام أداة التحريك لأن الطبقة مقفلة. Could not complete the Free Transform command because the layer is locked. تعذر إكمال أمر التحويل الحر لأن الطبقة مقفلة. + Could not transform because the layer is locked. تعذر التحويل لأن الطبقة مقفلة. Multiply ضرب My Brushes الفُرش الخاصة بي Neutrals الدرجات المحايدة @@ -1827,7 +1844,7 @@ Rectangular Marquee Tool أداة إطار التحديد المستطيل Reds درجات الأحمر Reflected منعكس - Sample: foreground colour العينة: لون المقدمة + Sample: foreground color العينة: لون المقدمة Sampled Colors الألوان المأخوذة كعينات Satin ساتان Save Pixel Aspect Ratio حفظ نسبة أبعاد البكسل @@ -1865,7 +1882,7 @@ Transfer نقل Triangle Tool أداة المثلث Unsaved changes تغييرات غير محفوظة - Uses the foreground and background colours. يستخدم لوني المقدمة والخلفية. + Uses the foreground and background colors. يستخدم لوني المقدمة والخلفية. Web الويب Web Large ويب كبير Web Minimum ويب أدنى @@ -1924,6 +1941,11 @@ Show canvas dots إظهار نقاط مساحة الرسم Clip thumbnails to layer bounds قصر الصور المصغرة على حدود الطبقة Clip Thumbnails to Layer Bounds قصر الصور المصغرة على حدود الطبقة + No Thumbnails بلا صور مصغرة + Small Thumbnails صور مصغرة صغيرة + Medium Thumbnails صور مصغرة متوسطة + Large Thumbnails صور مصغرة كبيرة + Clip Thumbnails to Document Bounds قصر الصور المصغرة على حدود المستند Canvas color لون مساحة الرسم Canvas custom color لون مخصص لمساحة الرسم Color picker منتقي الألوان @@ -2041,7 +2063,6 @@ Show only crosshair while painting إظهار نقطة التصويب فقط أثناء الرسم Show slice numbers إظهار أرقام الشرائح Show tooltips إظهار تلميحات الأدوات - Enable animations تمكين الحركات Show highlight on rollover إظهار التمييز عند التمرير Show bounding box when dragging layer إظهار مربع الإحاطة عند سحب الطبقة System title bar شريط عنوان النظام @@ -2133,7 +2154,7 @@ Graphics الرسومات Reset GPU Backend إعادة تعيين واجهة GPU الخلفية Applies at next launch. يُطبّق عند التشغيل التالي. - Click to sample the foreground colour · {key}-click for background انقر لأخذ عينة من لون المقدمة · {key}+نقر للون الخلفية + Click to sample the foreground color · {key}-click for background انقر لأخذ عينة من لون المقدمة · {key}+نقر للون الخلفية {key}-click to set the source {key}+نقر لضبط المصدر {key} to subtract {key} للطرح Clone source {n} ({key}-click with Clone Stamp to set) مصدر النسخ {n} ({key}+نقر بأداة ختم النسخ للضبط) @@ -2194,7 +2215,7 @@ Gradient Options خيارات التدرج Align with layer محاذاة مع الطبقة Reset Alignment إعادة تعيين المحاذاة - Centre the gradient (offset 0, 0) توسيط التدرج (إزاحة 0، 0) + Center the gradient (offset 0, 0) توسيط التدرج (إزاحة 0، 0) # Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor محرر التدرج Gradient picker منتقي التدرج @@ -2229,7 +2250,7 @@ Alt-click to define a source point to clone from انقر مع Alt لتحديد نقطة مصدر للنسخ منها No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. لا يوجد مسار لإنشاء تحديد منه: ارسم مسارًا بأداة القلم أو حدد مسارًا في لوحة المسارات. Cancel opening إلغاء الفتح - Cancelled opening {name} تم إلغاء فتح {name} + Canceled opening {name} تم إلغاء فتح {name} Damaged Photoshop document مستند Photoshop تالف The document “{name}” may be damaged. قد يكون المستند «{name}» تالفًا. Some layers, pixels or masks could not be read. Do you want to continue opening it? تعذرت قراءة بعض الطبقات أو البكسلات أو الأقنعة. هل تريد متابعة فتحه؟ @@ -2239,7 +2260,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. يحتوي المستند «{name}» على ملف تعريف ألوان مضمّن لا يطابق مساحة عمل {mode} الحالية. Working: {working} مساحة العمل: {working} Developing {name} جارٍ معالجة {name} - Cancelled {label} تم إلغاء {label} + Canceled {label} تم إلغاء {label} Esc to cancel Esc للإلغاء Opened {name} تم فتح {name} Opening {name} جارٍ فتح {name} @@ -2247,7 +2268,7 @@ Saving {name} جارٍ حفظ {name} Saving {percent} جارٍ الحفظ {percent} A save to {name} is still running; try again when it finishes لا تزال عملية حفظ في {name} جارية؛ حاول مرة أخرى عند انتهائها - {label} is writing the file and can no longer be cancelled {label} يكتب الملف ولم يعد بالإمكان إلغاؤه + {label} is writing the file and can no longer be canceled {label} يكتب الملف ولم يعد بالإمكان إلغاؤه Opening {name}… جارٍ فتح {name}… Opening… جارٍ الفتح… Reading… جارٍ القراءة… @@ -2290,6 +2311,7 @@ Punctuation علامات الترقيم Make Work Path إنشاء مسار عمل Make Selection إنشاء تحديد + Make Shape إنشاء شكل Fill Path تعبئة المسار Stroke Path رسم حدود للمسار Delete Path حذف المسار @@ -2309,7 +2331,7 @@ Pixel بكسل Adjustment تعديل Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract اسحب (حبل) أو انقر على النقاط (مضلع) · {add} إضافة · {sub} طرح - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels اسحب مربع اقتصاص · اسحب من الداخل للتحريك · الحواف لتغيير الحجم ({ratio} للنسبة، {centre} للمركز) · Space للتحريك أثناء الرسم · الأسهم للدفع الدقيق · X لتبديل الاتجاه · {commit} للتأكيد · Esc للإلغاء + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels اسحب مربع اقتصاص · اسحب من الداخل للتحريك · الحواف لتغيير الحجم ({ratio} للنسبة، {centre} للمركز) · Space للتحريك أثناء الرسم · الأسهم للدفع الدقيق · X لتبديل الاتجاه · {commit} للتأكيد · Esc للإلغاء {mode} Color · {bits} bit لون {mode} · {bits} بت Scrubby Zoom تكبير سحبي Auto Erase محو تلقائي @@ -2404,7 +2426,7 @@ Discard Layers and Save a Copy تجاهل الطبقات وحفظ نسخة Don't show again عدم الإظهار مرة أخرى Save failed: {error} فشل الحفظ: {error} - Save cancelled تم إلغاء الحفظ + Save canceled تم إلغاء الحفظ # Layer Style dialog: style presets and per-effect defaults Style {name} saved تم حفظ النمط {name} @@ -2557,7 +2579,7 @@ fontWeight Italic مائل Rotate View تدوير العرض Rotate View Tool أداة تدوير العرض Reset View إعادة تعيين العرض - Drag around the centre to rotate the view · Shift constrains to 15° اسحب حول المركز لتدوير العرض · Shift يقيّد الزاوية بخطوات 15° + Drag around the center to rotate the view · Shift constrains to 15° اسحب حول المركز لتدوير العرض · Shift يقيّد الزاوية بخطوات 15° # Painting symmetry presets. Dual Axis محور مزدوج @@ -2578,8 +2600,8 @@ fontWeight Italic مائل Show Sampling Ring إظهار حلقة أخذ العينات # Color Picker warnings - Out of gamut for printing: click for the nearest printable colour خارج النطاق اللوني للطباعة: انقر لأقرب لون قابل للطباعة - Not a web colour: click for the nearest web colour ليس لون ويب: انقر لأقرب لون ويب + Out of gamut for printing: click for the nearest printable color خارج النطاق اللوني للطباعة: انقر لأقرب لون قابل للطباعة + Not a web color: click for the nearest web color ليس لون ويب: انقر لأقرب لون ويب Color to Alpha… اللون إلى ألفا… Transparency Threshold عتبة الشفافية Opacity Threshold عتبة العتامة @@ -3281,6 +3303,8 @@ curves Smooth تنعيم Step {step} is waiting for its dialog الخطوة {step} بانتظار مربع الحوار الخاص بها @plural {n} action|{n} actions {n} إجراء|{n} إجراء|{n} إجراءان|{n} إجراءات|{n} إجراءً|{n} إجراء Background (Default) الخلفية (الافتراضية) + Background (default) الخلفية (الافتراضية) + Transparent (default) شفاف (افتراضي) Content-Aware Fill تعبئة مراعية للمحتوى # Batch / Image Processor Include All Subfolders تضمين كل المجلدات الفرعية @@ -3289,7 +3313,7 @@ curves Smooth تنعيم # Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. لا تُحدد أيضًا صورة معكوسة في إعدادات الطباعة…: فالانعكاسان يلغي كل منهما الآخر. Print Settings… إعدادات الطباعة… - Remember to turn off the printer's own colour management in Print Settings… تذكر إيقاف إدارة الألوان الخاصة بالطابعة في إعدادات الطباعة… + Remember to turn off the printer's own color management in Print Settings… تذكر إيقاف إدارة الألوان الخاصة بالطابعة في إعدادات الطباعة… Sent to {printer} تم الإرسال إلى {printer} set in Print Settings… يتم ضبطه في إعدادات الطباعة… ICO is limited to 256 × 256 pixels ملفات ICO محدودة بـ 256 × 256 بكسل @@ -3412,6 +3436,7 @@ curves Smooth تنعيم Low-Latency Drawing (vsync off, applies at next launch) رسم بزمن استجابة منخفض (إيقاف vsync، يسري عند التشغيل التالي) # Free Transform: a new layer while the box is open (#5859) Reference point location موقع النقطة المرجعية + Toggle reference point إظهار/إخفاء النقطة المرجعية Free Transform is still being applied; try again when it has finished. لا يزال التحويل الحر قيد التطبيق؛ حاول مرة أخرى عند انتهائه. The transform couldn't be applied. تعذر تطبيق التحويل. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -3429,12 +3454,12 @@ curves Smooth تنعيم {name} was changed by another app after it was opened or saved here. تم تغيير {name} بواسطة تطبيق آخر بعد فتحه أو حفظه هنا. Replace it with this document, or cancel and use File › Save As to keep both. استبدله بهذا المستند، أو ألغِ واستخدم ملف › حفظ باسم للاحتفاظ بكليهما. Demosaic إزالة الفسيفساء - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. طريقة ملء فسيفساء مرشح الألوان في الكاميرا. تُطبَّق عند فتح الملف؛ وتعرض المعاينة الطريقة التي فُتح بها. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. طريقة ملء فسيفساء مرشح الألوان في الكاميرا. تُطبَّق عند فتح الملف؛ وتعرض المعاينة الطريقة التي فُتح بها. AHD (adaptive homogeneity) AHD (التجانس التكيفي) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: أنظف الحواف وأقل هالات لونية، والأبطأ. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. استيفاء مصحح بالتدرج: حاد وسريع، مع بعض الهالات اللونية في التفاصيل الدقيقة. - The fastest and the softest, with colour fringing on fine detail. الأسرع والأنعم، مع هالات لونية في التفاصيل الدقيقة. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: أنظف الحواف وأقل هالات لونية، والأبطأ. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. استيفاء مصحح بالتدرج: حاد وسريع، مع بعض الهالات اللونية في التفاصيل الدقيقة. + The fastest and the softest, with color fringing on fine detail. الأسرع والأنعم، مع هالات لونية في التفاصيل الدقيقة. “{name}” already exists. Replace it? “{name}” موجود بالفعل. هل تريد استبداله؟ The name in the save dialog was “{name}”. كان الاسم في مربع حوار الحفظ “{name}”. Move Up تحريك لأعلى @@ -3549,3 +3574,46 @@ curves Smooth تنعيم The path was kept as the Work Path تم الاحتفاظ بالمسار كمسار عمل The path was discarded تم تجاهل المسار Edit Brush… تحرير الفرشاة… + +# Action Stop message (#8386) + Message رسالة +# Playback Options (#5212) + Playback Options… خيارات التشغيل… + Playback Options خيارات التشغيل + Accelerated مُسرَّع + Step by Step خطوة بخطوة + Pause For: إيقاف مؤقت لمدة: + Pause For إيقاف مؤقت لمدة + seconds ثانية + Create a single history state إنشاء حالة سجل واحدة + +# Channels panel menu (#11451) + New Channel… قناة جديدة… + Duplicate Channel تكرار القناة + Delete Channel حذف القناة + New Spot Channel… قناة لون موضعي جديدة… + Merge Spot Channel دمج قناة اللون الموضعي + Channel Options… خيارات القناة… + Split Channels تقسيم القنوات + Merge Channels… دمج القنوات… + Specify Channels: تحديد القنوات: + Not enough grayscale documents for this mode. لا توجد مستندات كافية بتدرج الرمادي لهذا الوضع. + Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة. + A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا. + A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه. +# Move tool options bar (#11381) + Align top edges محاذاة الحواف العلوية + Align vertical centers محاذاة المراكز الرأسية + Align bottom edges محاذاة الحواف السفلية + Distribute vertically توزيع رأسي + Distribute horizontally توزيع أفقي + Show layer bounds on hover إظهار حدود الطبقة عند التمرير + Expand layer group on click توسيع مجموعة الطبقات عند النقر + Show hover bounds from Layers إظهار حدود التمرير من الطبقات +# Artboard tool options bar (#11385) + Distribute Widths توزيع العروض + Distribute Heights توزيع الارتفاعات + Auto-nest Layers تداخل الطبقات تلقائيًا + Auto-size Canvas تحديد حجم مساحة الرسم تلقائيًا + Keep Relative Position During Layer Reordering الإبقاء على الموضع النسبي عند إعادة ترتيب الطبقات + Shrinkwrap Canvas on Save تقليص مساحة الرسم إلى المحتوى عند الحفظ diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv new file mode 100644 index 0000000000..c228ba73d0 --- /dev/null +++ b/crates/ui-egui/src/i18n/az.tsv @@ -0,0 +1,4635 @@ +# Azerbaijani (Latin script) (az) UI catalog. UTF-8, contextsourcetranslation. +# Format: ja.tsv. Original translations of PhotoCraft English source labels; no proprietary +# localization resources were used. Machine-assisted drafts (Google Translate), with core +# menu/tool terminology, contexts and plural messages edited by OpenAI Codex. Native-speaker +# review is pending. Keep command IDs, placeholders, product names and trailing ellipses. +# Licensed under MIT OR Apache-2.0; see LICENSE-translations.txt. +# @plural forms: one|other (integer 1 vs all other counts; counted nouns stay singular). + + Mask Options… Maska Seçimləri… + Layer Mask Display Options… Layer Maska Ekran Seçimləri… + Channel Color Kanal Rəngi + Color Indicates: Rəngi göstərir: + Solidity: Möhkəmlik: + Ink Characteristics Mürəkkəb Xüsusiyyətləri + Paper Width Kağız eni + Paper Height Kağız Hündürlüyü + Rows Sıralar + Number Nömrə + Margin Marja + Top: Yuxarı: + Left: Sol: + Bottom: Aşağı: + Right: Sağ: + Target: Hədəf: + Selected Artboards Seçilmiş İş lövhələri + Center Columns Mərkəzi Sütunlar + Clear Existing Guides Mövcud Bələdçiləri Sil + Invalid guide layout Yanlış bələdçi düzümü + Leave empty for automatic size Avtomatik ölçü üçün boş buraxın + 5 Columns, 3 Rows 5 Sütun, 3 Sıra + 8 Columns 8 sütun + 12 Columns 12 Sütun + File Fayl + Edit Redaktə + Image Şəkil + Layer Qat + Type Mətn + Select Seçim + Selected Seçildi + Scope Əhatə dairəsi + Top Left Yuxarı Sol + Top Right Yuxarı sağ + Bottom Left Aşağı Sol + Bottom Right Aşağı sağ + Filter Filtr + View Görünüş + Window Pəncərə + Help Kömək + New… Yeni… + New from Clipboard Buferdən yeni + New Yeni + Open… Aç… + Open Aç + Open As… Fərqli aç… + Open EXR Parts… EXR hissələrini aç… + Add beauty precomp from the AOVs AOV-lərdən gözəllik prekompunu əlavə edin + Both Hər ikisi + Left Sol + Right Sağ + Open Recent Son faylları aç + Clear Recent Files Son Faylları Sil + Remove from Recent Ən Sondan Sil + Remove {name} from Recent {name} -i Sondan çıxarın + Close Bağla + Minimize minimuma endir + Maximize Maksimallaşdırın + Restore Bərpa et + Close All Hamısını bağla + Close Others Digərlərini bağla + Save Saxla + Save As… Fərqli saxla… + Save a Copy… Nüsxəsini saxla… + {name} can't keep everything in this document {name} bu sənəddə hər şeyi saxlaya bilməz + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Photoshop (PSD) kimi onu saxlayan format seçin və ya nüsxəsini saxlayın. + Save… Saxla… + Revert Əvvəlki vəziyyətə qaytar + Import İdxal + Import Presets Əvvəlcədən parametrləri idxal edin + Export As… Fərqli ixrac et… + Export İxrac + Export… İxrac… + Quick Export As PNG PNG kimi sürətli ixrac + Quick Export as PNG PNG kimi sürətli ixrac + Place Embedded… Daxilə yerləşdir… + Place Linked… Əlaqəli yerləşdir… + Package… Paket… + Save for Web (Legacy)… Veb üçün saxla (köhnə)… + Generate Yarat + Image Assets Şəkil resursları + Undo Geri al + Redo Yenidən et + Fade… Təsiri zəiflət… + Cut Kəs + Copy Kopyala + Copy Merged Birləşdirilmişi kopyala + Paste Yapışdır + Paste Special Xüsusi yapışdırın + Paste Into Daxilə yapışdır + Paste Outside Xaricə yapışdır + Paste in Place Eyni yerə yapışdır + Clear Təmizlə + Search Axtarış + Search… Axtar… + Check Spelling… Yazılışı yoxla… + Fill… Doldur… + Stroke… Kontur çək… + Content-Aware Fill… Məzmuna uyğun doldur… + Delete and Fill Selection Seçimi Sil və Doldur + Define Brush Preset… Hazır fırça ayarı təyin et… + Define Pattern… Naxış təyin et… + Define Custom Shape… Fərdi fiqur təyin et… + Purge Təmizləmə + Image Size… Şəkil ölçüsü… + Image Size Şəkil ölçüsü + Canvas Size… Kətan ölçüsü… + Image Rotation Şəklin fırlanması + Rotate 90° Clockwise Saat əqrəbi istiqamətində 90° fırladın + Rotate 90° Counter Clockwise Saat əqrəbinin əksinə 90° fırladın + Rotate 180° 180° döndərin + Flip Canvas Horizontal Kətanı üfüqi çevir + Flip Canvas Vertical Kətanı şaquli çevir + Crop Kəsmə + Crop and Straighten Photos Fotoları kəs və düzləndir + Trim… Kənarları kəs… + Reveal All Hamısını göstər + Hide Selection Seçimi gizlədin + Auto Tone Avtomatik Ton + Auto Contrast Avtomatik Kontrast + Auto Color Avtomatik Rəng + Adjustments Tənzimləmələr + 16 Bits/Channel 16 Bit/Kanal + 32 Bits/Channel 32 Bit/Kanal + 8 Bits/Channel 8 Bit/Kanal + 8 bit 8 bit + Integer Tam ədəd + 16 bit 16 bit + 32 bit (float) 32 bit (float) + Floating point Üzən nöqtə + Mode Rejim + Grayscale Boz rəng + RGB Color RGB Rəngi + CMYK Color CMYK Rəngi + Lab Color Lab rəngi + Indexed Color İndeksli Rəng + Multichannel Çoxkanallı + Bitmap… Bitmap… + Bitmap Bitmap + Duotone… İki ton… + Duotone Duoton + Trap… Rəng örtüşməsi… + Layer via Copy Kopya vasitəsilə qat + Layer via Cut Kəsərək qat yarat + Duplicate Layer… Dublikat qat… + Duplicate… Dublikat yarat… + Delete Sil + Delete Layer Qatı silin + Delete Layers Qatları silin + Rename Layer Qatın adını dəyiş + Layer From Background… Fondan Layer… + Layer from Background… Fondan qat… + Background from Layer Qatdan arxa plan yarat + Background From Layer Layerdən Fon + Group Layers Qatları qruplaşdır + Group… Qrup… + Ungroup Layers Qatların qruplaşdırmasını ləğv et + Group from Layers… Qatlardan Qrup… + New Group from Layers… Layers-dən Yeni Qrup… + Hide Layers Qatları gizlət + Arrange Təşkil et + Bring to Front Ən önə gətir + Bring Forward Bir pillə önə gətir + Send Backward Bir pillə arxaya göndər + Send to Back Ən arxaya göndər + Convert to Smart Object Ağıllı Obyektə çevirin + Convert for Smart Filters Ağıllı Filtrlər üçün çevirin + New Smart Object via Copy Kopiya vasitəsilə Yeni Ağıllı Obyekt + Edit Contents Məzmunu redaktə edin + Replace Contents… Məzmunu dəyişdirin… + Export Contents… Məzmunu ixrac edin… + Rasterize Rasterləşdir + Rasterize Type Rasterləşdirmə növü + Rasterize Shape Şəklin rasterləşdirilməsi + Rasterize Layer Layeri Rasterləşdirin + Rasterize Layers Qatları Rasterləşdirin + New Adjustment Layer Yeni Tənzimləmə Layeri + New Fill Layer Yeni Doldurma Layeri + Layer Mask Qat maskası + Add Layer Mask Qat maskası əlavə edin + Delete Layer Mask Qat maskasını silin + Enable Layer Mask Qat maskasını aktivləşdir + Disable Layer Mask Qat maskasını deaktiv edin + Apply Layer Mask Qat maskası tətbiq edin + Add Mask to Selection Seçimə maska əlavə edin + Subtract Mask from Selection Maskanı Seçimdən çıxarın + Intersect Mask with Selection Seçimlə kəsişən maska + Link Layer Mask Qat maskasını əlaqələndir + Unlink Layer Mask Layer Maskasını ayırın + Vector Mask Vektor maskası + Add Vector Mask Vektor maskası əlavə edin + Delete Vector Mask Vektor maskasını sil + Enable Vector Mask Vektor maskasını aktivləşdir + Link Vector Mask Vektor maskasını əlaqələndir + Create Clipping Mask Kəsici maska yarat + Release Clipping Mask Kəsmə maskasını buraxın + New Shape Layer Yeni Forma Layeri + New Type Layer Yeni mətn qatı + New Video Layer from File… Fayldan yeni video qatı… + New Blank Video Layer Yeni boş video qatı + Smart Filters Ağıllı filtrlər + Delete Smart Filters Ağıllı Filtrləri silin + Enable Smart Filters Ağıllı Filtrləri aktivləşdirin + Disable Smart Filters Ağıllı Filtrləri söndürün + Clear Smart Filters Ağıllı Filtrləri təmizləyin + Delete Filter Mask Filtr maskasını silin + Disable Filter Mask Filtr maskasını söndürün + New Layer Based Slice Qata əsaslanan yeni dilim + Layer Style Qat üslubu + Blending Options… Qarışdırma seçimləri… + Blending Options Qarışıq Seçimləri + Drop Shadow… Düşən kölgə… + Inner Shadow… Daxili kölgə… + Outer Glow… Xarici parıltı… + Inner Glow… Daxili parıltı… + Bevel & Emboss… Pah və qabartma… + Use Global Light Qlobal İşıqdan istifadə edin + Overprint Üst çap + Satin… Satin… + Color Overlay… Rəng örtüyü… + Gradient Overlay… Qradient örtüyü… + Pattern Overlay… Naxış örtüyü… + Bevel & Emboss Pah və qabartma + Inner Shadow Daxili kölgə + Inner Glow Daxili parıltı + Satin Satin + Color Overlay Rəng Üstü + Gradient Overlay Qradient örtüyü + Pattern Overlay Nümunə örtüyü + Outer Glow Xarici parıltı + Drop Shadow Düşən kölgə + Copy Layer Style Qat üslubunu kopyala + Paste Layer Style Qat üslubunu yapışdır + Clear Layer Style Qat üslubunu təmizlə + Layer Content Options… Qat məzmunu seçimləri… + Scale Effects… Effektləri miqyasla… + New Layer… Yeni qat… + Delete Layer… Qatı silin… + Font Şrift + Font Preview Size Şrift Ölçüsü + Orientation Orientasiya + Horizontal Üfüqi + Vertical Şaquli + Rasterize Type Layer Mətn qatını rasterləşdir + Create Work Path İş yolu yarat + Convert to Shape Formaya çevirin + Convert to Paragraph Text Paraqraf mətninə çevirin + Convert to Point Text Nöqtə mətninə çevirin + Warp Text… Mətni deformasiya et… + Character Panel Simvol paneli + Paragraph Panel Abzas paneli + Check Spelling Orfoqrafiyanı yoxlayın + Language Options Dil Seçimləri + Find and Replace Text… Mətni tap və əvəz et… + Add to Dictionary Lüğətə əlavə et + No Spelling Suggestions Orfoqrafiya təklifləri yoxdur + Paste Lorem Ipsum Lorem Ipsum mətni yapışdır + Glyphs Panel Qliflər Paneli + Type Styles Növ üslubları + Character Styles Xarakter Üslubları + Paragraph Styles Paraqraf üslubları + Load Default Type Styles İlkin mətn üslublarını yüklə + Save Default Type Styles İlkin mətn üslublarını saxla + Type… Mətn… + All Hamısı + Deselect Seçimi ləğv edin + Reselect Yenidən seçin + Inverse Selection Tərs seçim + Inverse Tərs + Color Range… Rəng diapazonu… + Select and Mask… Seç və maskala… + Smart Radius Ağıllı radius + Shift Edge Kənarı sürüşdür + Decontaminate Zərərsizləşdirin + Output Çıxış + Refine Edge Kənarı dəqiqləşdirin + Select Subject Mövzu seçin + Sky Göy + Focus Area… Fokus sahəsi… + Subject Obyekt + Color Range Rəng diapazonu + Similar Oxşar + Grow Seçimi böyüt + Select Similar Colors Oxşar Rəngləri seçin + Load Selection… Seçimi Yüklə… + Save Selection… Seçimi yadda saxla… + New Selection Yeni seçim + Select All Hamısını seçin + Deselect All Hamısının seçimini ləğv edin + Deselect Layers Qatların seçimini ləğv edin + Find Layers Qatları tapın + Isolate Layers Qatları təcrid et + Select Linked Layers Əlaqəli qatları seç + All Layers Bütün qatlar + Current & Below Cari və Aşağı + Current Layer Cari qat + Refine Strokes Fırça izlərini dəqiqləşdir + Edits Düzəlişlər + Last Filter Son Filtr + Filter Gallery… Qalereya filtri… + Liquify… Likvidləşdirin… + Camera Raw Filter… Camera Raw filtri… + Adaptive Wide Angle… Uyğunlaşan Geniş Bucaq… + Lens Correction… Lens korreksiyası… + Vanishing Point… Ufuq Nöqtəsi… + Oil Paint… Yağlı Boya… + Blur Gallery Bulanıq Qalereya + Field Blur… Sahə bulanıqlığı… + No pin selected PIN seçilməyib + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Sancaq əlavə etmək üçün şəkilə klikləyin; hərəkət etdirmək üçün sancağı və ya bulanıqlığını dəyişmək üçün halqasını dartın. Sil seçilmiş pin silinir. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Konturun ölçüsünü dəyişmək və çevirmək üçün tutacaqları, künclərini yuvarlaqlaşdırmaq üçün kvadratı və bulanıqlığın başladığı yeri təyin etmək üçün içindəki nöqtələri dartın. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Qrupu hərəkət etdirmək üçün sancağı və ya bulanıqlığını dəyişmək üçün halqasını dartın. Kəskin zolağı genişləndirmək üçün bərk xətləri, keçidi təyin etmək üçün kəsikli xətləri və çevirmək üçün nöqtələri dartın. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Ölçüsünü dəyişmək və çevirmək üçün konturdakı tutacaqları dartın; üzük bulanıqlıq bucağını təyin edir. + Blur Angle Bulanıq bucaq + Iris Blur… İris bulanıqlığı… + Tilt-Shift… Meyil-sürüşmə… + Path Blur… Yol Bulanıqlığı… + Spin Blur… Fırlanma bulanıqlığı… + Auto-Align Layers… Qatları avtomatik hizala… + Auto-Blend Layers… Qatları avtomatik qarışdır… + Photomerge… Foto birləşdirin… + Content-Aware Scale Məzmuna uyğun miqyaslama + Adaptive Wide Angle Uyğunlaşan Geniş Bucaq + Apply Image… Şəkil tətbiq edin… + Calculations… Hesablamalar… + Crystallize… Kristallaşma… + Facet Faset + Fragment Fraqment + Mosaic… Mozaika… + Pointillize… Pointilize… + Chalk & Charcoal Təbaşir və kömür + Charcoal Kömür + Chrome Chrome + Conté Crayon Conté Crayon + Graphic Pen Qrafik Qələm + Halftone Pattern Yarım ton nümunəsi + Note Paper Qeyd kağızı + Photocopy Fotosurət + Plaster Gips + Reticulation Retikulyasiya + Stamp Möhür + Torn Edges Cırıq kənarlar + Water Paper Su kağızı + Clouds Buludlar + Difference Clouds Fərq Buludları + Fibers… Liflər… + Lens Flare… Obyektiv parıltısı… + Picture Frame… Şəkil çərçivəsi… + Tree… Ağac… + Flame… Alov… + Sharpen Edges Kənarları kəskinləşdirin + Dust & Scratches… Toz və cızıqlar… + Noise Səs-küy + Add Noise… Səs-küy əlavə edin… + Reduce Noise… Səs-küyü azaldın… + Despeckle Xalları təmizlə + Median… Orta… + Median Median + Maximum… Maksimum… + Maximum Maksimum + Minimum… Minimum… + Minimum Minimum + Custom… Fərdi… + High Pass… Yüksək tezlik filtri… + Emboss… Qabartma… + Extrude… Extrude… + Find Edges Kənarları tapın + Solarize Solarizasiya + Tiles… Kafel… + Trace Contour… Kontur İzi… + Wind… Külək… + Offset… Ofset… + Pinch… Çimdik… + Polar Coordinates… Qütb koordinatları… + Ripple… Dalğalanma… + Shear… Kəsmək… + Spherize… Kürələşdir… + Twirl… Burulma… + Wave… Dalğa… + ZigZag… ZiqZaq… + Average Orta + Blur More Daha çox bulanıqlaşdır + Box Blur… Qutu Bulanıqlığı… + Gaussian Blur… Qauss bulanıqlığı… + Motion Blur… Hərəkət bulanıqlığı… + Radial Blur… Radial Bulanıqlıq… + Shape Blur… Fiqur bulanıqlığı… + Smart Blur… Ağıllı Bulanıqlıq… + Surface Blur… Səth bulanıqlığı… + Sharpen Kəskinləşdir + Sharpen More Daha çox itiləyin + Unsharp Mask… Kəskin olmayan maska… + Smart Sharpen… Ağıllı kəskinləşdirmə… + Diffuse… Diffuz… + Mezzotint… Metsotinto… + Color Halftone… Rəngli Yarımton… + Layer… Qat… + Proof Setup Sübut Quraşdırma + Proof Colors Sınaq rəngləri + Pixel Aspect Ratio Piksel Aspekt Nisbəti + Pixel Aspect Ratio Correction Piksel Aspekt Nisbətinin Korreksiyası + Zoom In Böyüt + Zoom Out Uzaqlaşdır + Fit on Screen Ekrana uyğun + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Çap Ölçüsü + Screen Mode Ekran rejimi + Standard Screen Mode Standart Ekran rejimi + Full Screen Mode With Menu Bar Menyu paneli ilə tam ekran rejimi + Full Screen Mode Tam ekran rejimi + Extras Əlavələr + Show Göstər + Rulers Xətkeşlər + Grid Tor + Guides İstiqamətləndiricilər + Slices Dilimlər + Snap Snap + Snap To Snap To + Lock Guides Kilid Bələdçiləri + Clear Guides Aydın bələdçilər + New Guide… Yeni Bələdçi… + Lock Slices Dilimləri kilidləyin + Clear Slices Dilimləri təmizlə + New Guide Layout… Yeni Bələdçi Düzeni… + New Guides From Shape Formadan Yeni Bələdçilər + Canvas rotation Kətan fırlanması + Zoom in Böyüt + Zoom out Uzaqlaşdırın + Cascade Kaskad + Tile Kafel + 2-up Horizontal 2-yuxarı Horizontal + 3-up Horizontal 3-yuxarı Horizontal + 2-up Vertical 2 şaquli görünüş + 3-up Vertical 3 şaquli görünüş + 3-up Stacked 3-yuxarı Yığılmış + 4-up 4-dən yuxarı + 6-up 6-dan yuxarı + Consolidate All to Tabs Hamısını Nişanlara birləşdirin + Match Zoom Uyğun Böyütmə + Match Location Uyğun Yer + Match Rotation Qarşılaşmanın fırlanması + Match All Hamısını Uyğunlaşdırın + New Window for Document Sənəd üçün yeni pəncərə + Workspace İş sahəsi + Extensions Genişləndirmələr + Panels Panellər + Actions Əməliyyatlar + Brush Settings Fırça parametrləri + Brushes Fırçalar + Channels Kanallar + Character Simvol + Clone Source Klon Mənbə + Color Mixer Rəng qarışdırıcı + Glyphs Qliflər + Gradients Qradientlər + Histogram Histoqram + History Tarixçə + Info Məlumat + Layer Comps Qat kompozisiyaları + Layers Qatlar + Measurement Log Ölçmə jurnalı + Notes Qeydlər + Navigator Naviqator + Paragraph Abzas + Paths Yollar + Patterns Naxışlar + Properties Xüsusiyyətlər + Styles Üslublar + Swatches Rəng nümunələri + Timeline Zaman xətti + Tool Presets Alətin Əvvəlcədən Tənzimləmələri + Tools Alətlər + Contextual Task Bar Kontekstual tapşırıq paneli + Libraries Kitabxanalar + 3D 3D + Toolbars Alət panelləri + Hide All Hamısını gizlət + About PhotoCraft PhotoCraft haqqında + PhotoCraft Website PhotoCraft Veb saytı + PhotoCraft on GitHub GitHub-da PhotoCraft + Report an Issue… Problemi bildirin… + ArtCraft Website ArtCraft Veb saytı + Join the ArtCraft Discord… ArtCraft Discord-a qoşulun… + System Info… Sistem məlumatları… + Move Köçür + Marquee Marquee + Lasso Kəmənd + Wand Çubuq + Eyedropper Damcılıq + Brush Fırça + Block Blok + Pencil Karandaş + Eraser Silgi + Clone Stamp Klonlama möhürü + Healing Şəfa + Blur Bulanıqlaşdırma + Dodge İşıqlandırma + Burn Tündləşdirmə + Gradient Qradient + Shape Forma + Pen Qələm + Hand Əl + Zoom Böyüt + Sampler Nümunə götürən + Ruler Tool Xətkeş Aləti + Count Tool Say aləti + Note tool Qeyd aləti + Quick Selection Tez seçim + Magnetic Lasso Maqnit Kəməndi + Mixer Brush Mikser fırçası + Mixer Brush Tool Qarışdırıcı fırça aləti + Color Replacement Tool Rəng əvəzləmə aləti + History Brush Tarixçə fırçası + Art History Brush Bədii tarixçə fırçası + Pattern Stamp Nümunə möhürü + Spot Healing Ləkə Müalicəsi + Red Eye Qırmızı Göz + Patch Yamaq + Content-Aware Move Məzmundan xəbərdar Hərəkət + Rotate View Görünüşü fırladın + Perspective Crop Perspektiv kəsmə + Color Replacement Rəngin dəyişdirilməsi + Dodge/Burn İşıqlandırma/tündləşdirmə + Foreground Ön plan + Background Arxa plan + Set foreground color Ön plan rəngini təyin edin + Set background color Fon rəngini təyin edin + Swap colours (X) Rəngləri dəyişdirin (X) + Default colours (D) Defolt rənglər (D) + Brush Tool Options Fırça Alət Seçimləri + Opacity Qeyri-şəffaflıq + Flow Axın + Smoothing Hamarlama + Size Ölçü + Hardness Sərtlik + Angle: Bucaq: + Roundness: Dairəvilik: + Spacing Aralıq + Dynamics Dinamikalar + Pressure Təzyiq + Tilt əyilmək + Rotation Fırlanma + Align Hizalayın + Aligned Hizalanmış + OK Oldu + Cancel Ləğv et + Cancel (Esc) Ləğv et (Esc) + Manage submitted jobs in the system print queue Sistem çap növbəsində təqdim edilmiş işləri idarə edin + Done Hazırdır + Apply Tətbiq et + Reset Sıfırla + Reset Section Bölməni Sıfırlayın + Reset All to Defaults Hamısını Standartlara Sıfırlayın + Import Shortcuts… İdxal Qısayolları… + Load a keyboard shortcut set saved as .kys .kys kimi saxlanmış klaviatura qısayol dəstini yükləyin + Imported {n} shortcuts from {file}. Click OK to keep them. {file} -dən idxal edilmiş {n} qısa yolları. Onları saxlamaq üçün OK düyməsini klikləyin. + {n} commands are not in PhotoCraft. {n} əmrləri PhotoCraft -də deyil. + Keyboard shortcuts imported Klaviatura qısa yolları idxal edildi + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Sizin klaviatura qısayol dəstiniz {set} burada istifadə olunur: {n} qısayolları defoltlardan fərqlidir. Redaktə › Klaviatura Qısayolları onları göstərir; Hamısını Defoltlara Sıfırla bunu ləğv edir. + Import keyboard shortcuts? Klaviatura qısa yolları idxal edilsin? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft bu kompüterdəki başqa şəkil redaktorundan {set} klaviatura qısayol dəstinizi tapdı: {n} onun qısa yolları PhotoCraft -dən fərqlidir. Import edilsin? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Redaktə et › Klaviatura Qısayolları › Hamısını Defoltlara Sıfırla idxalı ləğv edir. + Don't Import İdxal etməyin + The keyboard shortcut set was not imported: {error} Klaviatura qısa yol dəsti idxal edilmədi: {error} + Defaults restored. Defoltlar bərpa edildi. + Restore Defaults Defoltları bərpa edin + Restore original size Orijinal ölçüsü bərpa edin + Use Default Defoltdan istifadə edin + Dismiss İşdən çıxarın + Preview Önizləmə + Options Seçimlər + Settings… Parametrlər… + Rename… Adını dəyiş… + Load… Yüklə… + Define… Müəyyən et… + Custom Fərdi + Default İlkin + None Yoxdur + Add Əlavə et + Remove Sil + Remove Background Fonu silin + Rename Adını dəyiş + Ignore Məhəl qoymayın + Ignore All Hamısına məhəl qoyma + Change Dəyişiklik + Change All Hamısını dəyişdirin + Next frame Növbəti çərçivə + Previous frame Əvvəlki çərçivə + Play Oynayın + Pause Fasilə + Stop Dayan + Begin recording Yazmağa başlayın + Stop playing/recording Oynatmağı/yazmağı dayandırın + Record ● a sequence of edits, then play ▶ it on any document. ● redaktə ardıcıllığını yazın, sonra ▶ onu istənilən sənəddə oynatın. + Create new action Yeni fəaliyyət yaradın + Play selection Seçim çalın + Create new set Yeni dəst yaradın + Export selected measurements (CSV) Seçilmiş ölçmələri ixrac edin (CSV) + Record Measurements Ölçmələri qeyd edin + Delete selected measurements Seçilmiş ölçmələri silin + Select Data Points… Məlumat nöqtələrini seçin… + Set Measurement Scale… Ölçmə Şkalasını təyin edin… + Use Measurement Scale Ölçmə Şkalasından istifadə edin + Preferences Tərcihlər + General… Ümumi… + Interface… İnterfeys… + File Handling… Faylla işləmə… + Performance… Performans… + Cursors… Kursorlar… + Transparency & Gamut… Şəffaflıq və rəng əhatəsi… + Units & Rulers… Vahidlər və xətkeşlər… + Guides, Grid & Slices… İstiqamətləndiricilər, tor və dilimlər… + Plug-ins… Pluginlər… + Technology Previews Texnologiyaya baxış + Scratch Disks… Müvəqqəti saxlama diskləri… + Export Preferences… İxrac tərcihləri… + Keyboard Shortcuts… Klaviatura qısayolları… + Menus… Menyular… + Export/Import Presets… Hazır ayarları ixrac/idxal et… + Additional plugins folder Əlavə plaginlər qovluğu + Install Plug-in… Plug-in quraşdırın… + Export Presets İxrac üçün hazır ayarlar + Import brushes (.abr) Fırçaları idxal edin (.abr) + Migrate Presets Hazır ayarları köçür + Thumbnail size (smallest: list) Miniatür ölçüsü (ən kiçik: siyahı) + Reset GPU Backend GPU arxa ucunu sıfırlayın + Applies at next launch. Növbəti buraxılışda tətbiq edilir. + Untitled Başlıqsız + Untitled-1 Başlıqsız-1 + Doc: {flat}/{layered} Sənəd: {flat} / {layered} + Scratch: {size} cızıq: {size} + Efficiency: 100% Səmərəlilik: 100% + Dimensions: Ölçülər: + Width En + Slant Maili + Height Hündürlük + Width: En: + Height: Hündürlük: + Resolution Ayırdetmə + Resolution: Ayırdetmə: + Resolution: {dpi} pixels/inch Qətnamə: {dpi} piksel/düym + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ( {ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ( {ppi} ppi) + Pixels: Piksel: + Pixel Length: Piksel Uzunluğu: + Logical Length: Məntiqi Uzunluq: + Logical Units: Məntiqi vahidlər: + Length: Length: + Interpolation: İnterpolyasiya: + Resample Resample + Constrain proportions Məhdud nisbətləri + Link width and height Bağlantı eni və hündürlüyü + Maintain aspect ratio Aspekt nisbətini qoruyun + Current Size Cari ölçü + New Size Yeni Ölçü + Fit Fit + Percent Percent + Pixels/Inch Piksel/düym + Pixels/Centimeter Piksel/Santimetr + Relative qohum + Canvas extension color: Kətan uzadılması rəngi: + Anchor: Anchor: + Both Axes Hər iki ox + Horizontally Üfüqi + Vertically Şaquli + Layers to Files… Qatları fayllara ixrac et… + Duplicate Layers… Dublikat Qatları… + Group name Qrupun adı + New Group Yeni Qrup + Layer Comp Qat kompozisiyası + Create new layer Yeni qat yaradın + Create new group Yeni qrup yarat + Create new channel Yeni kanal yaradın + Create new path Yeni yol yarat + Toggle visibility Görünməni dəyiş + Clipped Kəsilmiş + Group · {n} layer Qrup · {n} qatsi + Lock: keep these settings when picking another preset Kilidləyin: başqa əvvəlcədən təyini seçərkən bu parametrləri saxlayın + Unlock: picking a preset replaces these settings Kilidi aç: əvvəlcədən təyin edilmiş parametrlərin seçilməsi bu parametrləri əvəz edir + Show the layer's effects ({key}-click: all layers) Qatın effektlərini göstərin ( {key} -klik: bütün qatlar) + Hide the layer's effects ({key}-click: all layers) Qatın effektlərini gizlədin ( {key} -klik: bütün qatlar) + Expand group ({key}-click: all groups) Qrupu genişləndirin ( {key} -klik: bütün qruplar) + Collapse group ({key}-click: all groups) Qrupu yığcamlaşdırın ( {key} -klikləyin: bütün qruplar) + Effects Effects + Blending Qarışdırma + Blend If Qarışdırın + General Blend Ümumi qarışıq + Advanced Blending Təkmil qarışdırma + Opacity: Qeyri-şəffaflıq: + Fill: Doldurma: + Lock: Kilid: + Smart Filter Ağıllı Filtr + Fill Content Məzmunu doldurun + Adjustment Preview Tənzimləmə Ön Baxışı + Channel: Kanal: + Channel Mixer… Kanal qarışdırıcısı… + Output Channel: Çıxış Kanalı: + RGB RGB + Red Qırmızı + Green Yaşıl + Blue Mavi + Alpha Alfa + Load channel as selection Kanalı seçim kimi yükləyin + Save selection as channel Seçimi kanal olaraq yadda saxlayın + Delete current channel Cari kanalı silin + Spot Channel Spot Kanal + Mask All Hamısını maska + Mask All Objects Bütün obyektləri maskala + Color Rəng + Color: Rəng: + Colors Rənglər + HSB/HSL HSB/HSL + No Color Rəng yoxdur + Set shape fill type Forma doldurma növünü təyin edin + Set shape stroke type Fiqurun kontur növünü təyin et + Set the text color Mətnin rəngini təyin edin + Create warped text əyri mətn yaradın + Only Web Colors Yalnız Veb Rəngləri + Overlay Color Üstün Rəng + Overlay Opacity Örtük qeyri-şəffaflığı + Glow Color Parıltı Rəngi + Web Snap Veb Snap + Brightness Parlaqlıq + Contrast Kontrast + Brightness/Contrast… Parlaqlıq/Kontrast… + Levels… Səviyyələr… + Curves… Əyrilər… + Curve Əyri + Exposure… Ekspozisiya… + Exposure Ekspozisiya + Vibrance… Rəng canlılığı… + Vibrance Vibrance + Hue/Saturation… Rəng tonu/doyma… + Hue Rəng tonu + Saturation Doyma + Lightness Yüngüllük + Color Balance… Rəng balansı… + Black & White… Qara və Ağ… + Photo Filter… Foto Filtr… + Color Lookup… Rəng Axtarışı… + Color Lookup Tables… Rəng axtarış cədvəlləri… + 3D LUT File 3D LUT Faylı + Load 3D LUT… 3D LUT yükləyin… + Invert… Tərsinə çevir… + Invert Tərsinə çevir + Invert has no settings. İnvertdə heç bir parametr yoxdur. + Threshold… Hədd… + Threshold Level Həddi Səviyyə + Posterize… Posterləşdirin… + Gradient Map… Qradient xəritəsi… + Selective Color… Seçilmiş Rəng… + Match Color… Uyğun Rəng… + Replace Color… Rəngi dəyişdirin… + Equalize Bərabərləşdirin + HDR Toning… HDR tonlama… + Shadows/Highlights… Kölgələr/işıqlı sahələr… + Amount Məbləğ + Radius Radius + Radius: Radius: + Tolerance: Tolerantlıq: + Density Sıxlıq + Density: Sıxlıq: + Feather Kənar yumşaltma + Feather… Kənarları yumşalt… + Anti-alias Kənar hamarlama + Anti-Alias Kənar hamarlama + Contiguous Bitişik + Sample All Layers Bütün qatlari nümunə götürün + Protect Foreground Color Ön plan rəngini qoruyun + Matte tutqun + Matting Matlama + Color Decontaminate… Rəngi zərərsizləşdirin… + Remove Black Matte Qara fon qalığını sil + Remove White Matte Ağ fon qalığını sil + Defringe… Kənar rəng saçağını təmizlə… + Source Mənbə + Working CMYK CMYK işləyir + Working Cyan Plate İşləyən Cyan Plate + Working Magenta Plate İşləyən Magenta Plate + Working Yellow Plate İşləyən Sarı Lövhə + Working Black Plate İşləyən Qara Boşqab + Working Gray Plate İşləyən Boz Boşqab + Basic Əsas + Detail Təfərrüat + HSL/Grayscale HSL/Boz rəng + Tone Curve Ton əyrisi + Split Toning Ayrı tonlama + Lens Corrections Lens düzəlişləri + Camera Calibration Kameranın kalibrlənməsi + Presets Əvvəlcədən təyinlər + Snapshots Ani görüntülər + Temperature Temperatur + Tint Çalar + Exposure: Ekspozisiya: + Recovery Bərpa + Fill Light İşığı doldurun + Blacks Qara sahələr + Clarity Aydınlıq + Convert to Grayscale Boz rəngə çevirin + Auto Avtomatik + Auto collapse icon panels Avtomatik yığcam ikona panelləri + Auto show hidden panels Gizli panelləri avtomatik göstərin + Auto show home screen Əsas ekranı avtomatik göstərin + Sharpness Kəskinlik + Noise Reduction Səs-küyün azaldılması + Color Noise Reduction Rəng səsinin azaldılması + Luminance Detail Parlaqlıq Təfərrüatı + Color Detail Rəng detalı + Luminance Smoothness Parlaqlıq Hamarlığı + Vignetting Vinyetləmə + White Balance: As Shot Ağ Balans: Çəkiliş kimi + Camera Raw… Kamera Raw… + Camera Raw isn't open Camera Raw açıq deyil + Dehaze Dehaze + Texture Tekstura + Focal Length Fokus Uzunluğu + Make et + Model Model + Lens Lens + F F + ISO ISO + Focal Length35mm Fokus Uzunluğu35 mm + Aspect Ratio Aspekt nisbəti + Time Vaxt + Artist Rəssam + Copyright Müəllif hüququ + Enter description… Təsviri daxil edin… + Adobe RGB (1998) compatible Adobe RGB (1998) uyğun + Gray Gamma 2.2 Boz qamma 2.2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Sənaye Standartı + Web Veb + Internet Standard RGB İnternet standart RGB + Legacy Macintosh RGB Köhnə Macintosh RGB + Monitor RGB Monitor RGB + Windows LCD Windows LCD + Coated CMYK Örtülü CMYK + Uncoated CMYK Örtülməmiş CMYK + Newspaper Qəzet + Working Spaces İş yerləri + Color Management Policies Rəng İdarəetmə Siyasətləri + Assign working space İş yerini təyin edin + Leave as is (don't color manage) olduğu kimi buraxın (rəngi idarə etməyin) + Discard the embedded profile (don't color manage) Daxil edilmiş profili atın (rəngi idarə etməyin) + Embed Color Profile Rəng Profilini Yerləşdirin + Assign Profile… Profil təyin et… + Convert to Profile… Profilə çevir… + PhotoCraft Manages Colors PhotoCraft Rəngləri idarə edir + Printer Manages Colors Printer Rəngləri İdarə edir + (print to the printer) (printerdə çap edin) + Absolute Colorimetric Mütləq Kolorimetrik + Relative Colorimetric Nisbi Kolorimetrik + Perceptual Qavrayışa uyğun + Black Point Compensation Qara nöqtə kompensasiyası + Use Legacy Legacy istifadə edin + Dither Dither + No Dither Dither yoxdur + Diffusion Diffuziya + Pattern Naxış + Fuzziness: Qeyri-səlislik: + Range: Aralığı: + Select: Seçim: + Detect Faces Üzləri aşkar edin + Localize Color Clusters Rəng Klasterlərini Lokallaşdırın + Sample: Nümunə: + Add to Sample Nümunəyə əlavə edin + Subtract from Sample Nümunədən çıxarın + Selections Seçimlər + Duplicated selection name Təkrarlanan seçim adı + Operation Əməliyyat + New channel only Yalnız yeni kanal + Masked Areas Maskalı ərazilər + Selected Areas Seçilmiş ərazilər + Spot Color Ləkə Rəngi + Color indicates Rəngi göstərir + Curves Əyrilər + Levels Səviyyələr + Color Balance Rəng balansı + Black & White Qara və Ağ + Photo Filter Foto filtri + Color Lookup Rəng Axtarışı + Threshold eşik + Posterize Posterləşdirin + Brightness/Contrast Parlaqlıq/Kontrast + Channel Mixer Kanal Mikser + Gradient Map Qradient xəritəsi + Selective Color Seçilmiş Rəng + History Log… Tarixçə jurnalı… + Create Snapshot Snapshot yaradın + Delete Snapshot Anlık şəkli silin + New Snapshot Yeni Snapshot + Snapshot 1 Ani görüntü 1 + Step Forward İrəli addımlayın + Step Backward Geri addım + Histories Tarixçələr + Nonlinear History Qeyri-xətti tarix + Set New Snapshot as Default Yeni Snapshot-ı Defolt olaraq təyin edin + Automatically Create First Snapshot Avtomatik İlk Snapshot Yaradın + Automatically Create New Snapshot When Saving Saxlayan zaman avtomatik olaraq yeni şəkil yaradın + Show New Snapshot Dialog by Default Defolt olaraq Yeni Snapshot Dialoqunu göstərin + Clear History Tarixi təmizləyin + Panel Options… Panel Seçimləri… + View menu Menyuya baxın + Keep view in sync Görünüşü sinxronlaşdırın + {zoom}% {zoom} % + Brush name Fırça adı + Brush Preview Fırçanın Önizləməsi + Brush Tip Shape Fırça ucu forması + Shape Dynamics Forma dinamikası + Scattering Səpələnmə + Dual Brush İkili Fırça + Color Dynamics Rəng dinamikası + Transfer Transfer + Brush Pose Fırça pozası + Mouse Controls Siçan idarələri + Wet Edges Yaş kənarlar + Build-up Quraşdırma + Protect Texture Teksturanı qoruyun + Blend Mode Qarışdırma rejimi + Size Jitter Ölçü titrəməsi + Minimum Diameter Minimum Diametr + Tilt Scale Tilt Ölçüsü + Angle Jitter Bucaq titrəməsi + Roundness Jitter Yuvarlaqlıq titrəməsi + Minimum Roundness Minimum Yuvarlaqlıq + Flip X Jitter X çevirmə dəyişkənliyi + Flip Y Jitter Y çevirmə dəyişkənliyi + Brush Projection Fırçanın proyeksiyası + Control: Nəzarət: + Off: the speed of the pointer sets the spacing Off: göstəricinin sürəti məsafəni təyin edir + Fade steps Zəifləmə addımları + Pen Pressure Qələm təzyiqi + Pen Tilt Qələm Tilt + Stylus Wheel Stylus Təkəri + Initial Direction of Stroke Fırça izinin ilkin istiqaməti + Direction of Stroke Fırça izinin istiqaməti + Opacity Jitter qeyri-şəffaflıq titrəməsi + Flow Jitter Axın titrəməsi + Humidity Rütubət + Load Yüklə + Mix Qarışdırın + Auto Clean Brush Avtomatik təmizləmə fırçası + Wet Yaş + Current Brush Load Cari Fırça Yükü + Load Brush Fırça yükləyin + Clean Brush Təmiz Fırça + Load the brush after each stroke Hər vuruşdan sonra fırçanı yükləyin + Clean the brush after each stroke Hər vuruşdan sonra fırçanı təmizləyin + Rate Qiymətləndirmə + Airbrush: keeps depositing paint while the pointer rests. Airbrush: göstərici dayanarkən boya qoymağa davam edir. + Pulled String Mode Çəkilmiş sim rejimi + Catch-up on Stroke End Fırça izinin sonunda çatdırma + Adjust for Zoom Böyütmə üçün tənzimləyin + Apply Per Tip Məsləhət üçün müraciət edin + Hardness: Sərtlik: + Spacing: Aralıq: + Size: Ölçü: + Flip X Flip X + Flip Y Flip Y + Brush Tip Fırça ucu + Diameter: Çap: + New Layer from Brush Fırçadan yeni qat + Rename Brush… Fırçanın adını dəyişin… + Delete Brush Fırçanı silin + Import Brushes… Fırçaları idxal edin… + Import Brushes… (.abr or image) Fırçaları idxal edin... (.abr və ya şəkil) + Import Brush from Image… Fırçanı Şəkildən idxal edin… + Gradient Preset Options Gradient Əvvəlcədən Seçimlər + Import .GRD .GRD idxal et + Choose a gradient file (.grd). Qradiyent faylı (.grd) seçin. + Brush Name Fırça adı + Brush Stroke Fırça izi + New Brush Preset… Yeni Fırça Əvvəlcədən Ayar… + Capture Brush Size in Preset Əvvəlcədən təyin edilmiş Fırça Ölçüsünü çəkin + Include Tool Settings Alət Parametrlərini daxil edin + Include Color Rəngi daxil et + Enter a name for the brush preset Fırça əvvəlcədən təyini üçün ad daxil edin + A brush preset with this name already exists Bu adda bir fırça əvvəlcədən qurulmuşdur + Brush Preset Options Fırçanın Əvvəlcədən Seçimləri + New Brush Yeni Fırça + Create new brush from these settings Bu parametrlərdən yeni fırça yaradın + Update the current brush with these settings Cari fırçanı bu parametrlərlə yeniləyin + Reset the brush to the defaults Fırçanı standart parametrlərə sıfırlayın + No brush presets Fırçanın əvvəlcədən təyini yoxdur + Override Pressure Ləğv etmə təzyiqi + Override Tilt Eğimi ləğv edin + Override Tilt X Tilt X-i ləğv edin + Override Tilt Y Y meylini əvəz et + Override Rotation Rotasiyanı ləğv edin + Apply the pen's tilt and rotation to the tip shape Qələmin əyilməsini və fırlanmasını uc şəklinə tətbiq edin + Preset Type Əvvəlcədən təyin edilmiş Növ + Preset Manager… Hazır ayarlar meneceri… + Rename Tool Preset… Alətin Əvvəlcədən Adını dəyişdirin… + Delete Tool Preset Hazır alət ayarını sil + New Tool Preset Yeni Alətin Əvvəlcədən qurulması + Create new tool preset Yeni alət ilkin təyinatı yaradın + Current Tool Only Yalnız Cari Alət + No tool presets. Alətin əvvəlcədən təyini yoxdur. + No presets for the current tool. Cari alət üçün əvvəlcədən təyin yoxdur. + No presets of this type. Bu tip əvvəlcədən təyin yoxdur. + Preset: Hazır ayar: + Preset Hazır ayar + Presets: Əvvəlcədən təyinatlar: + Font Size: Şrift Ölçüsü: + Font size Şrift ölçüsü + Type: Mətn: + Style Üslub + Style: Normal Üslub: Normal + Tracking İzləmə + Kerning Kerning + Leading Aparıcı + Auto-Leading Avtomatik aparıcı + Scale Horizontally Üfüqi olaraq miqyaslayın + Scale Vertically Şaquli olaraq ölçün + Baseline Shift Əsas xəttin dəyişməsi + Language Dil + Anti-aliasing method Anti-aliasing üsulu + Sharp Kəskin + Crisp Dəqiq + Strong Güclü + Smooth Hamar + Mac LCD Mac LCD + Mac Sharp Mac Sharp + Mac Crisp Mac Crisp + Mac Strong Mac Güclü + Mac Smooth Mac Smooth + Faux Bold Süni qalın + Faux Italic Saxta kursiv + All Caps Bütün böyük hərflər + Small Caps Kiçik başlıqlar + Superscript Üst indeks + Subscript Alt indeks + Underline Altını çəkin + Strikethrough cızıq + Oldstyle Köhnə üslub + Ligature Bağlama + Alternative Alternativ + Hyphenate tire + Roman Roman + Italic Kursiv + Bold Qalın + Bold Italic Qalın kursiv + Light İşıq + Medium Orta + Black Qara + Thin İncə + Ultra Light Ən incə + Extra Light Həddindən artıq incə + Semi Bold Yarı Qalın + Extra Bold Çox qalın + Ultra Bold Ən qalın + Condensed Qatılaşdırılmış + Left align text Mətni sola hizalayın + Center text Mərkəz mətn + Right align text Mətni sağa hizalayın + Justify last left Sonuncu sola haqq qazandırın + Justify last centered Sonuncu mərkəzdə əsaslandırın + Justify last right Sonuncu hüququ əsaslandırın + Justify all Hamısına haqq qazandırın + Indent Left Sola girinti + Indent Right Sağa girinti + First Line Indent Birinci sətir abzası + Space Before Əvvəlki boşluq + Space After Sonrakı boşluq + Hanging Punctuation Durğu işarələri asılır + Roman Hanging Punctuation Roman asılı durğu işarələri + Avoid Widows/Orphans Dul / Yetimlərdən çəkinin + Compose Yarat + Adobe Single-line Composer Adobe təksətirli tərtibatçısı + Adobe Every-line Composer Adobe çoxsətirli tərtibatçısı + Justification Əsaslandırma + Word Spacing Söz aralığı + Letter Spacing Hərf Aralığı + Glyph Scaling Qlif miqyası + Auto-leading % Avtomatik aparıcı % + Single Word Justification Tək sözlə əsaslandırma + Alignment Düzləşdirmə + Indent First Əvvəlcə girinti + Center Mərkəz + Justify Əsaslandır + Glyph Qlif + Select a type layer or click into text to insert glyphs Qliflər daxil etmək üçün bir növ qat seçin və ya mətnə klikləyin + Entire Font Bütün Şrift + Alternates for Current Selection Cari seçim üçün alternativlər + Recently Used Son istifadə olunub + Digits Rəqəmlər + Symbols Simvollar + Punctuation Durğu işarələri + OpenType OpenType + Standard Ligatures Standart liqaturalar + Discretionary Ligatures İxtiyari liqaturalar + Contextual Alternates Kontekstual Alternativlər + Swash Bəzəkli uzantı + Titling Alternates Başlıq alternativləri + Ornaments Ornamentlər + Fractions Kəsrlər + Ordinals Ordinallar + East Asian Features Şərqi Asiya Xüsusiyyətləri + Middle Eastern Features Yaxın Şərq Xüsusiyyətləri + Path Yol + Path: Yol: + Work Path İş Yolu + Path to Illustrator… Illustrator-a gedən yol… + Make Work Path İş yolunu düzəldin + Make Selection Seçim edin + Fill Path Doldurma Yolu + Stroke Path Yol boyunca kontur çək + Clipping Path Kəsmə Yolu + New Path Yeni yol + Duplicate Path Dublikat yolu + Rename Path… Yolun adını dəyişdirin… + Delete Path Yolu silin + Current Path Cari yol + Vector Mask from Current Path Cari yoldan vektor maskası + Create new shape from the current path Cari yoldan yeni forma yaradın + Drag to move the active shape's path or the Work Path Aktiv formanın yolunu və ya İş yolunu hərəkət etdirmək üçün dartın + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klikləyin: lövbər seçin · Çəkin: lövbəri, sapı və ya seqmenti köçürün · {shift} -klikləyin: əlavə edin · {alt} -klikləyin: bütün alt yol + Draw with the Pen tool (P) or make a work path from a selection. Qələm aləti (P) ilə çəkin və ya seçimdən iş yolu yaradın. + Source: not set ({key}-click with Clone Stamp) Mənbə: təyin edilməyib ( {key} -klon möhürü ilə klikləyin) + Clone source {n} ({key}-click with Clone Stamp to set) Klon mənbəyi {n} ( {key} -quraşdırmaq üçün Klon möhürü ilə klikləyin) + {key}-click to set the source {key} -mənbəni təyin etmək üçün klikləyin + Option-click to define a source point to clone from Klonlamaq üçün mənbə nöqtəsini təyin etmək üçün seçim düyməsini sıxın + Show Overlay Üst örtüyü göstərin + Auto-Hide Avtomatik Gizlət + W W + H H + Angle Bucaq + Scale Miqyas + X X + Y Y + Offset Ofset + Reset Transform Transformanı sıfırlayın + Rotate Döndür + Set horizontal skew Üfüqi əyriliyi təyin edin + Set vertical skew Şaquli əyriliyi təyin edin + Flip Horizontal Üfüqi çevir + Flip Vertical Şaquli çevir + Frame Sync Çərçivə Sinxronizasiyası + Quick Sync Tez sinxronizasiya + Enabled Aktivdir + Create Video Timeline Video Taymlayn yaradın + Open a document to use the timeline. Zaman qrafikindən istifadə etmək üçün sənəd açın. + Video Layers Video qatlari + Video Video + Audio Audio + Frame Duration Çərçivə müddəti + 30 frames @ 30 fps 30 kadr @ 30 kadr + Loop Döngə + Render Video… Videonu render et… + Split at Playhead Oynatma başlığında böl + Delete Timeline Zaman qrafikini silin + Reload Frame Çərçivəni yenidən yükləyin + Restore Frame Çərçivəni bərpa edin + Insert Blank Frame Boş Çərçivə daxil edin + Duplicate Frame Dublikat çərçivə + Delete Frame Çərçivəni silin + Restore All Frames Bütün çərçivələri bərpa edin + Flatten Frames Into Clips Çərçivələri kliplərə düzəldin + Flatten Frames Into Layers Çərçivələri qatlara düzəldin + Convert Frames > Video Layers Çərçivələri Çevir > Video Qatlarını + New Video Layer Yeni Video Layer + New Video Group Yeni Video Qrupu + Interpret Footage… Görüntüləri şərh edin… + Replace Footage… Görüntüləri dəyişdirin… + Show Altered Video Dəyişdirilmiş Videonu göstərin + Limit Playback Oxumanı məhdudlaşdırın + Enable Timeline Quick Config Timeline Tez Konfiqurasiyasını aktivləşdirin + Set Start Timecode… Başlama vaxtı kodunu təyin edin… + Timecode Zaman kodu + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 kadr/s + Current Time Cari vaxt + Out Çıxdı + In In + Duration Müddət + Repeat Təkrar edin + Play Once Bir dəfə oynayın + Video Cache Video keşi + Info Options… Məlumat Seçimləri… + First Color Readout İlk Rəng Oxunması + Second Color Readout İkinci Rəng Oxunması + Actual Color Həqiqi Rəng + Grayscale Slider Boz rəngli sürüşdürmə + Web Color Veb rəngi + HSB Color HSB Rəngi + Total Ink Ümumi Mürəkkəb + Opacity Mask Opaklıq maskası + Ruler Xətkeş + Current Tool Cari Alət + Document Size Sənədin ölçüsü + Document Profile Sənəd Profili + Document Dimensions Sənəd Ölçüləri + Scratch Sizes Çizilmə Ölçüləri + Efficiency Səmərəlilik + Timing Zamanlama + Status Information Status Məlumatı + Saved Multichannel Saxlanmış Çoxkanal + Mouse Coordinates Siçan koordinatları + Ruler Units Xətkeş vahidləri + Hide Gizlət + Layer Comps to Files… Qat kompozisiyalarını fayllara ixrac et… + Apply next selected layer comp Növbəti seçilmiş qat kompozisiyasını tətbiq edin + Apply previous selected layer comp Əvvəlki seçilmiş qat kompozisiyasını tətbiq edin + Update layer comp (visibility, position and appearance) Qat kompozisiyasını yeniləyin (görünmə, mövqe və görünüş) + New Layer Comp Yeni qat kompozisiyası + Duplicate Layer Comp Dublikat Layer Comp + Delete layer comp Qat kompozisiyasını silin + Rename Layer Comp Layer Comp. Adını dəyişdirin + Create new layer comp Yeni qat kompozisiyasını yaradın + Capture the visibility, position and style of every layer with + below, then switch between versions. Aşağıdakı + işarəsi ilə hər qatnin görünürlüğünü, mövqeyini və üslubunu çəkin, sonra versiyalar arasında keçid edin. + Apply Layer Comp Tətbiq Layer Comp + Update Layer Comp Layer Comp. Yeniləyin + Restore last viewed comp Son baxılmış kompozisiyanı bərpa edin + Last Document State Son Sənəd Vəziyyəti + Visibility Görünüş + Position Vəzifə + Appearance (Layer Style) Görünüş (Layer Style) + Analysis Təhlil + Variables Dəyişənlər + Variable Data Sets… Dəyişən Məlumat Dəstləri… + Data Sets… Məlumat dəstləri… + Data Sets Məlumat dəstləri + Data Sets as Files… Verilənlər dəstlərini fayl kimi ixrac et… + Apply Data Set… Məlumat dəstini tətbiq edin… + Define Müəyyən edin + + Add variable + Dəyişən əlavə edin + Remove variable dəyişəni silin + Define at least one variable first. Əvvəlcə ən azı bir dəyişəni təyin edin. + Open a document to define variables. Dəyişənləri müəyyən etmək üçün sənəd açın. + Variables bind a layer's visibility, text or pixels to a named data slot. Dəyişənlər qatnin görünməsini, mətnini və ya piksellərini adlandırılmış məlumat yuvasına bağlayır. + Pixel Replacement Pikselin dəyişdirilməsi + Text Replacement Mətnin dəyişdirilməsi + Name Adı + Method Metod + Select All Layers Bütün qatlari seçin + Fill Doldurma + Keep proportions Proporsiyaları saxlayın + Interpolation İnterpolyasiya + Author: Müəllif: + Author Müəllif + Name: Adı: + Caption Başlıq + Caption Credits Başlıq Kreditləri + Description Təsvir + Keywords Açar sözlər + Copyright Info URL Müəllif Hüququ Məlumatı URL + Copyright Status Müəlliflik hüququ statusu + Copyright Notice Müəllif Hüququ Bildirişi + Document Title Sənədin Başlığı + Copyrighted Müəllif hüquqları qorunur + Public Domain İctimai Sahə + Unknown Naməlum + Copyright and Contact Info Müəllif Hüququ və Əlaqə Məlumatı + Credit Kredit + Transmission Reference Transmissiya arayışı + Urgency Təcili + High Yüksək + Normal Normal + Lossless İtkisiz + Low Aşağı + Category Kateqoriya + Supplemental Categories Əlavə Kateqoriyalar + Object Name / Title Obyekt Adı / Başlığı + Date Created Yaradılma tarixi + City Şəhər + Location Yer + Province / State Əyalət / Ştat + Country Ölkə + Byline Müəllif + Byline Title Müəllif adı + Phone Telefon + Email E-poçt + URLs URL-lər + Last Modified Son Dəyişiklik + EXIF EXIF + GPS GPS + Camera Data Kamera məlumatları + Camera Model Kamera modeli + Camera Make Kamera istehsalı + EXIF Version EXIF versiyası + Exposure Program Ekspozisiya proqramı + Aperture Diyafram + Shutter Speed Çekim sürəti + Metering Mode Ölçmə rejimi + Light Source İşıq mənbəyi + Flash Flaş + Lens Info Lens məlumatı + ISO Speed ISO sürəti + Subject Distance Mövzu məsafəsi + DateTime Original İlkin tarix və vaxt + Create Date Tarix yarat + Modify Date Tarixi dəyişdirin + Software Proqram təminatı + Raw Data Xam məlumat + Version {version} Versiya {version} + Advanced Qabaqcıl + Import… İdxal… + Automate Avtomatlaşdırma + Batch… Toplu emal… + Create Droplet… Avtomatik emal tətbiqi yarat… + Create Droplet Damcı yaradın + PDF Presentation… PDF təqdimatı… + Create Multiple Picture Packages Birdən çox şəkil paketi yaradın + Contact Sheet II Əlaqə vərəqi II + Contact Sheet II… Kontakt vərəqi II… + Conditional Mode Change… Şərti rejim dəyişikliyi… + Fit Image… Şəkli ölçüyə uyğunlaşdır… + Picture Package… Şəkil Paketi… + Web Photo Gallery… Veb Foto Qalereya… + Merge to HDR Pro… HDR Pro-da birləşdir… + Load Files into Stack… Faylları yığına yüklə… + Scripts Ssenarilər + Script Events Manager… Skript Hadisələri Meneceri… + Image Processor… Şəkil prosessoru… + Destination Təyinat + Choose… Seçin… + Select Folder… Qovluğu seçin… + Override Action "Save" Commands "Saxla" əmrlərini ləğv edin + Compatibility Uyğunluq + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile ICC Profilini daxil edin + Error File Səhv faylı + Stop For Errors Səhvləri dayandırın + Log Errors To File Fayl üçün xətaları qeyd edin + Errors Səhvlər + Color Settings… Rəng parametrləri… + Settings Parametrlər + Settings: Parametrlər: + Working Space İş sahəsi + RGB: RGB: + CMYK: CMYK: + Gray: Boz: + Spot: Spot: + Advanced Controls Qabaqcıl İdarəetmələr + Desaturate Monitor Colors By Monitor Rənglərini Desaturate By + Blend RGB Colors Using Gamma 1.0 Gamma 1.0 istifadə edərək RGB rənglərini qarışdırın + Profile Mismatches: Ask When Opening Profil uyğunsuzluqları: Açarkən soruşun + Ask When Pasting Yapışdırarkən soruşun + Missing Profiles: Ask When Opening Çatışmayan Profillər: Açarkən soruşun + Embed profiles Profilləri yerləşdirin + Ask Before Saving Saved Embedded Daxil edilmiş Saxlanılmış Saxlamadan əvvəl soruşun + Coerce to sRGB sRGB-yə məcbur edin + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Geniş Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Theme Mövzu + Switch theme Mövzunu dəyişdirin + Next Theme Növbəti Mövzu + Classic Theme Klassik Mövzu + Solarized dark Günəşli qaranlıq + Solarized Dark Theme Solarized Qaranlıq Mövzu + Breeze dark Qaranlıq meh + Breeze Dark Theme Breeze Qaranlıq Mövzu + Breeze light Külək işığı + Breeze Light Theme Breeze açıq mövzusu + Soft dark Yumşaq qaranlıq + Soft light Yumşaq işıq + Soft Dark Theme Soft Dark Theme + Soft Light Theme Soft açıq mövzusu + Studio Theme Studiya mövzusu + Studio Light Theme Studio açıq mövzusu + Adwaita Light Theme Adwaita açıq mövzusu + Adwaita Dark Theme Adwaita Qaranlıq Mövzu + Pro Theme Pro Mövzu + Pro Medium Gray Theme Pro Orta Boz Mövzu + Pro Light Gray Theme Pro Açıq Boz Mövzu + Pro Light Theme Pro açıq mövzusu + Workspace… İş sahəsi… + Essentials Əsaslar + New Workspace… Yeni İş Sahəsi… + Delete Workspace… İş yerini silin… + Reset Workspace İş yerini sıfırlayın + Reset Tool Aləti sıfırlayın + Reset All Tools Bütün Alətləri Sıfırlayın + Reset all tools to their default settings? Bütün alətlər standart parametrlərinə qaytarılsın? + Lock Workspace İş yerini kilidləyin + Photography Fotoqrafiya + Graphic and web Qrafik və veb + Painting Rəsm + Motion Hərəkət + Minimalistic Minimalist + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panel yerləri iş yerində saxlanılır. Klaviatura qısa yolları, menyular və alətlər paneli isteğe bağlıdır. + Keyboard Shortcuts Klaviatura qısa yolları + Keyboard Shortcuts and Menus Klaviatura Qısayolları və Menyular + Menus Menyular + More panels Daha çox panellər + More documents Daha çox sənədlər + Collapse All Groups Bütün Qrupları yığışdırın + Expand All Groups Bütün Qrupları genişləndirin + Collapse Dağılma + Expand Genişləndirin + Collapse Panel Group Panel Qrupunu yığışdırın + Expand Panel Group Panel qrupunu genişləndir + Close Tab Group Tab Qrupunu bağlayın + PRESET DETAILS ƏVVƏLDƏN AYARLANMIŞ TƏFƏRRÜATLAR + BLANK DOCUMENT PRESETS ({n}) BOŞ SƏNƏD HAZIRLANIŞLARI ( {n} ) + Create a new document or open an existing file. Yeni sənəd yaradın və ya mövcud faylı açın. + Drop an image or PSD anywhere to open it. Şəkil və ya PSD-ni açmaq üçün istənilən yerə buraxın. + Use File › Open, or paste a copied image with {paste}. Fayldan istifadə edin › Açın və ya kopyalanmış şəkli {paste} ilə yapışdırın. + Native file drag-and-drop is unavailable Doğma faylı sürükləmə və buraxma funksiyası mövcud deyil + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Doğma faylı sürükləyib buraxma hələ Wayland-da dəstəklənmir. Fayldan istifadə edin › Açın və ya şəkli fayl menecerinizdə kopyalayın və {paste} ilə yapışdırın. + To drop files, start PhotoCraft under XWayland: `{command}` Faylları atmaq üçün XWayland altında PhotoCraft işə salın: ` {command} ` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Və ya Üstünlüklər › Performans › Linux displey serverini X11: PhotoCraft olaraq təyin edin, sonra həmişə XWayland altında başlayır. + Edit › Paste Redaktə et › Yapışdır + Photo Şəkil + Print Çap et + Art & Illustration İncəsənət və İllüstrasiya + Mobile Mobil + Film & Video Film və Video + Default PhotoCraft Size Defolt PhotoCraft Ölçüsü + Clipboard Mübadilə buferi + Photo (2 × 3 in) Foto (2 × 3 düym) + Photo (4 × 6 in) Foto (4 × 6 düym) + Photo (5 × 7 in) Foto (5 × 7 düym) + Photo (8 × 10 in) Foto (8 × 10 düym) + Landscape Mənzərə + Portrait Portret + Recent Items Son maddələr + Save Preset… Əvvəlcədən ayarı yadda saxla… + Delete Preset… Əvvəlcədən təyinatı silin… + Preset Name: Əvvəlcədən təyin olunmuş ad: + Width and Height En və Hündürlük + Background Contents Fon məzmunu + Custom background color Fərdi fon rəngi + White Ağ + Background Color Arxa plan rəngi + Transparent Şəffaf + Color Mode Rəng rejimi + Depth Dərinlik + Color Profile Rəng Profili + Create Yaradın + Document Sənəd + Units Vahidlər + Pixels Piksel + Inches düym + Centimeters Santimetr + Millimeters Millimetr + Points Xallar + Picas Pikas + Columns Sütunlar + {name} options {name} seçimləri + Align {what} {what} -ni hizalayın + Untagged {mode} Etiketsiz {mode} + Invalid {mode} profile Yanlış {mode} profili + Unnamed {mode} profile Adsız {mode} profili + Constraints ({n}) Məhdudiyyətlər ( {n} ) + Count: {n} Sayı: {n} + Click to add a point · drag off or {key}-click or Delete to remove Nöqtə əlavə etmək üçün klikləyin · kənara çəkin və ya silmək üçün {key} -klikləyin və ya Sil seçin + Click to apply, {key}-click to clear overrides, double-click for Style Options Tətbiq etmək üçün klikləyin, {key} - ləğv edilmişləri silmək üçün klikləyin, Stil Seçimləri üçün iki dəfə klikləyin + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klikləyin: künc · Çək: hamar · Birinci nöqtəyə klikləyin: bağlayın · {key} bitir · Esc ləğv + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klikləyin: künc · Çək: hamar · {alt} -dartın: qolu qırın · Birinci nöqtəni klikləyin və ya sürükləyin: bağlayın · {key} bitir · Esc ləğv + Commit Perspective Warp ({key}) Perspektiv Çarpma ( {key} ) + Commit Puppet Warp ({key}) Kukla Çarpması ( {key} ) + Commit any current edits ({key}) İstənilən cari redaktə edin ( {key} ) + Commit transform ({key}) Transformasiyaya əməl edin ( {key} ) + Commit warp ({key}) Dəyişmə ( {key} ) + Cancel any current edits (Esc) İstənilən cari redaktələri ləğv edin (Esc) + Cancel transform (Esc) Transformasiyanı ləğv edin (Esc) + Cancel warp (Esc) Çürüməyi ləğv edin (Esc) + Do you want to save the changes you made to “{name}” before closing? Bağlamadan əvvəl “ {name} ” üçün etdiyiniz dəyişiklikləri saxlamaq istəyirsiniz? + Revert “{name}” to the last saved version? Your changes will be lost. “ {name} ” son saxlanmış versiyaya qaytarılsın? Dəyişiklikləriniz itiriləcək. + {key} to subtract çıxmaq üçün {key} + ↑↓ navigate {key} run esc close ↑↓ naviqasiya {key} run esc yaxın + Click to restore the current colour Cari rəngi bərpa etmək üçün klikləyin + Click with the Note tool to add a note. Qeyd əlavə etmək üçün Qeyd aləti ilə klikləyin. + Drag a rectangle around the object Obyektin ətrafında düzbucaqlı çəkin + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Məhdudiyyət əlavə etmək üçün şəklin üzərinə çəkin; Üfüqi/şaquli üçün keçid; məhdudiyyəti dəyişdirmək və ya silmək üçün üzərinə sağ klikləyin. + Drag the arrow to set the angle, the dots to set the roundness Bucağı təyin etmək üçün oxu, yuvarlaqlığı təyin etmək üçün nöqtələri dartın + Create new pattern from the selection Seçimdən yeni naxış yaradın + Create new gradient Yeni gradient yaradın + Create new count group Yeni hesab qrupu yaradın + Search commands, tools and panels… Axtarış əmrləri, alətlər və panellər… + Search Brushes Axtarış fırçaları + Search fonts Şriftləri axtarın + No matching commands Uyğun əmrlər yoxdur + No document Sənəd yoxdur + Pixel Layer Piksel qatı + Type Layer Mətn qatı + Shape Layer Forma qatı + Group Layer Qrup qatı + Fill Layer Doldurma Layeri + Smart Object Layer Ağıllı Obyekt Layeri + Adjustment Layer Tənzimləmə qatı + {name} Properties {name} Xüsusiyyətləri + Move Tool Köçürmə aləti + Could not use the move tool because the layer is locked. Qat kilidləndiyi üçün köçürmə alətindən istifadə etmək mümkün olmadı. + Could not complete the Free Transform command because the layer is locked. Sərbəst Transformasiya əmrini tamamlamaq mümkün olmadı, çünki qat kilidlidir. + Rectangular Marquee Tool Düzbucaqlı seçim aləti + Elliptical Marquee Tool Elliptik seçim aləti + Single Row Marquee Tool Təksətirli seçim aləti + Single Column Marquee Tool Təksütunlu seçim aləti + Brush Tool Fırça aləti + Pencil Tool Karandaş aləti + Eraser Tool Pozan aləti + Background Eraser Tool Arxa plan pozanı aləti + Magic Eraser Tool Sehrli pozan aləti + Eyedropper Tool Damcıtökən aləti + Note Tool Qeyd aləti + Lasso Tool Kəmənd aləti + Polygonal Lasso Tool Çoxbucaqlı kəmənd aləti + Magic Wand Tool Sehrli çubuq aləti + Crop Tool Kəsmə aləti + Slice Tool Dilim aləti + Slice Select Tool Dilim seçimi aləti + Gradient Tool Qradient aləti + Paint Bucket Tool Boya vedrəsi aləti + Horizontal Type Tool Üfüqi mətn aləti + Horizontal Type Mask Tool Üfüqi mətn maskası aləti + Vertical Type Mask Tool Şaquli mətn maskası aləti + Hand Tool Əl aləti + Zoom Tool Miqyas aləti + Spot Healing Brush Tool Nöqtəvi bərpa fırçası aləti + Healing Brush Tool Bərpa fırçası aləti + Clone Stamp Tool Klonlama möhürü aləti + History Brush Tool Tarixçə fırçası aləti + Blur Tool Bulanıqlaşdırma aləti + Sharpen Tool Kəskinləşdirmə aləti + Smudge Tool Yayma aləti + Dodge Tool İşıqlandırma aləti + Burn Tool Tündləşdirmə aləti + Sponge Tool Süngər aləti + Quick Selection Tool Sürətli seçim aləti + Object Selection Tool Obyekt seçimi aləti + Pen Tool Qələm aləti + Convert Point Tool Nöqtə çevirmə aləti + Path Selection Tool Yol seçimi aləti + Direct Selection Tool Birbaşa seçim aləti + Add Anchor Point Tool Dayaq nöqtəsi əlavə etmə aləti + Delete Anchor Point Tool Dayaq nöqtəsi silmə aləti + Click a segment to add an anchor point · {alt}-click an anchor to delete it Anker nöqtəsi əlavə etmək üçün seqmentə klikləyin · {alt} - silmək üçün lövbərə klikləyin + Click an anchor point to delete it · {alt}-click a segment to add one Silmək üçün lövbər nöqtəsinə klikləyin · {alt} -seqment əlavə etmək üçün üzərinə klikləyin + Rectangle Tool Düzbucaqlı aləti + Ellipse Tool Ellips aləti + Triangle Tool Üçbucaq aləti + Polygon Tool Çoxbucaqlı aləti + Line Tool Xətt aləti + Custom Shape Tool Fərdi fiqur aləti + Pixel Piksel + Group Qrup + Adjustment Tənzimləmə + Smart Object Ağıllı obyekt + Drag to move the active layer Aktiv qatni köçürmək üçün dartın + Drag to pan · hold Space with any tool Panorama üçün sürükləyin · istənilən alətlə Space düyməsini basıb saxlayın + Drag to draw a gradient Qradiyent çəkmək üçün dartın + Click to fill similar colours Oxşar rəngləri doldurmaq üçün klikləyin + Click to add text Mətn əlavə etmək üçün klikləyin + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Çəkin (kəmənd) və ya klikləyin (poliqonal) · {add} əlavə edin · {sub} çıxarın + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kəsmə qutusunu dartın · köçürmək üçün içəriyə sürükləyin · kənarların ölçüsünü dəyişdirin ( {ratio} nisbəti, {centre} mərkəz) · Rəsm zamanı boşluq hərəkət edir · oxlar sürüşdürür · X oriyentasiyanı dəyişdirir · {commit} yerinə yetirir · Esc ləğv edir + {mode} Color · {bits} bit {mode} Rəng · {bits} bit + Drag to move the layer Qatı köçürmək üçün dartın + Fit Screen Ekrana sığdır + Fill Screen Ekranı doldurun + Scrubby Zoom Sürükləyərək miqyasla + Auto Erase Avtomatik silmə + Color Adaptation Rəng Uyğunlaşması + Preserve Transparency Şəffaflığı qoruyun + Reverse tərs + Align with layer Qatla hizalayın + Join us on Discord Discord-da bizə qoşulun + PhotoCraft website PhotoCraft veb saytı + ArtCraft ArtCraft + GitHub GitHub + No character style Xarakter tərzi yoxdur + No author Müəllif yoxdur + Not in Dictionary: Lüğətdə yoxdur: + Suggestions: Təkliflər: + Spell check complete. Orfoqrafiya yoxlanışı tamamlandı. + Check All Layers Bütün qatlari yoxlayın + Select a type layer to edit its character and paragraph settings. Simvol və paraqraf parametrlərini redaktə etmək üçün tip qatsi seçin. + Select text to apply a character style Simvol üslubunu tətbiq etmək üçün mətn seçin + Redefine style by current selection Cari seçimlə üslubu yenidən müəyyənləşdirin + Duplicate style Dublikat üslub + Delete style Üslubu silin + Style Options Üslub Seçimləri + Style Options… Üslub Seçimləri… + Close Style Options Stil Seçimlərini bağlayın + Appearance Görünüş + Text Position: Mətn yeri: + Text color Mətn rəngi + Uses the foreground and background colours. Ön və arxa fon rənglərindən istifadə edir. + Answer the unsaved-changes prompt first Əvvəlcə yadda saxlanmamış dəyişikliklər sorğusuna cavab verin + Unsaved changes Saxlanmamış dəyişikliklər + Don't Save Saxlamayın + No Xeyr + Yes Bəli + These settings aren't available in PhotoCraft yet. Bu parametrlər hələ PhotoCraft -də mövcud deyil. + Shortcut removed. Qısayol silindi. + All shortcuts reset to PhotoCraft defaults. Bütün qısa yollar PhotoCraft defoltlarına sıfırlanır. + Add Shortcut Qısayol əlavə edin + Delete Shortcut Qısayolu silin + Press keys… düymələri basın… + shortcut qısa yol + command or shortcut əmr və ya qısa yol + current cari + image file path şəkil faylı yolu + modified dəyişdirilmiş + name adı + new yeni + Go to Layer Layerə keçin + Open a document to select a colour range. Rəng diapazonunu seçmək üçün sənədi açın. + Saved Saxlanıldı + Reveal in Finder Finder-da göstər + Show in Explorer Explorer-də göstərin + Show in Folder Qovluqda göstərin + Before (Y) Əvvəl (Y) + Save Settings… Parametrləri yadda saxla… + Load Settings… Parametrləri yükləyin… + Save these settings as a Camera Raw .xmp file Bu parametrləri Camera Raw .xmp faylı kimi yadda saxlayın + Load settings from a Camera Raw .xmp file Camera Raw .xmp faylından parametrləri yükləyin + Loaded Camera Raw settings from {file} {file} -dən Camera Raw parametrləri yükləndi + Loaded Camera Raw settings from {file} ({n} not supported) {file} -dən yüklənmiş Camera Raw parametrləri ( {n} dəstəklənmir) + After (X) Sonra (X) + Original Orijinal + Current Cari + {added}, {removed} subtracted {added} , {removed} çıxılır + Filter: Filtr: + Free Transform Sərbəst transformasiya + Free Transform a Copy Surəti Sərbəst çevirin + Transform Transformasiya + Transform Selection Seçimi dəyişdirin + Switch between free transform and warp modes Sərbəst çevirmə və əyilmə rejimləri arasında keçid + Perspective Perspektiv + Skew Maililəşdir + Distort Təhrif et + Rotate: Döndür: + Flip Flip + Warp: Deformasiya: + Warp Deformasiya + Bend: əyilmək: + Reference point location İstinad nöqtəsinin yeri + Toggle reference point İstinad nöqtəsini dəyişdirin + Free Pulsuz + Arc Qövs + Arch Arch + Bulge qabarıqlıq + Shell Lower Alt qabıq + Shell Upper Üst qabıq + Flag Bayraq + Fish Balıq + Wave Dalğa + Rise qalx + Fisheye Balıqgözü + Inflate Şişirin + Twist Twist + Squeeze sıxın + Corner radius Künc radiusu + Split Warp Crosswise Deformasiyanı çarpaz böl + Split Warp Horizontally Deformasiyanı üfüqi böl + Split Warp Vertically Deformasiyanı şaquli böl + Remove Warp Split Deformasiya bölgüsünü sil + Puppet Warp Kukla deformasiyası + Puppet Warp is not active Kukla Çarpması aktiv deyil + Perspective Warp Perspektiv deformasiyası + Perspective Warp is not active Perspective Warp aktiv deyil + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektiv Çarpma: təsvirin perspektivi boyunca təyyarələr çəkin, sonra Çarpma rejiminə keçin + Remove Planes Təyyarələri çıxarın + Edit Pins Pinləri redaktə edin + Add Pins Sancaqlar əlavə edin + Delete Pins Sancaqları silin + Remove All Pins Bütün sancaqları silin + Remove Tool Silmə aləti + Pin Depth: Pin Dərinliyi: + Bring the selected pin forward Seçilmiş sancağı irəli gətirin + Send the selected pin backward Seçilmiş sancağı geri göndərin + Rotate Auto Avtomatik fırladın + Rotate Fixed Döndürmə Sabitdir + Adaptive Wide Angle isn't open Adaptiv Geniş Bucaq açıq deyil + Liquify is not open Liquify açıq deyil + Create Plane Təyyarə yaradın + Edit Plane Təyyarəni redaktə edin + Select Plane Təyyarə seçin + Measure Ölçü + Heal Sağal + Off Söndür + Symmetry Off Simmetriya bağlıdır + Heal Off Sağal + Heal On Heal On + Heal Auto Avtomatik bərpa + Blend Qarışdırın + Brush Size Fırça ölçüsü + Heal Color Sağalma Rəngi + Mask Maska + Channel Kanal + Rectangle Düzbucaqlı + Subtract Çıxar + Multiply Vurma + Divide Bölün + Result Nəticə + New Document Yeni Sənəd + New Document With Mask Maskalı Yeni Sənəd + New Layer With Mask Maskalı Yeni Qat + New Layer Yeni qat + Preset Name Əvvəlcədən təyin olunmuş ad + No saved presets yet. Hələ saxlanmış ön ayar yoxdur. + New Channel Yeni Kanal + Selection Seçim + Save Selection Seçimi yadda saxla + Load Selection Yük seçimi + Duplicate Dublikat yarat + Fast Tez + Accurate Dəqiq + Add to Əlavə et + Subtract from -dən çıxın + Intersect with ilə kəsişir + Pass Through Keçid + Dissolve həll edin + Darken Tündləşdir + Color Burn Rəngi tündləşdir + Linear Burn Xətti tündləşdirmə + Darker Color Tünd Rəng + Lighten Açıqlaşdır + Screen Ekran + Color Dodge Rəngi işıqlandır + Linear Dodge (Add) Xətti işıqlandırma (toplama) + Lighter Color Açıq Rəng + Overlay Üst-üstə qoyma + Soft Light Yumşaq işıq + Hard Light Sərt işıq + Vivid Light Canlı İşıq + Linear Light Xətti İşıq + Pin Light Nöqtəvi işıq + Hard Mix Sərt Qarışıq + Difference Fərq + Exclusion İstisna + Luminosity İşıqlılıq + Capture Comp Kompozisiyanı qeydə al + No Layer Comps Layer Comps yoxdur +@plural {n} item|{n} items {n} element|{n} element +@plural {n} Layer|{n} Layers {n} qat|{n} qat +@plural {n} layer|{n} layers {n} qat|{n} qat +@plural Group · {n} layer|Group · {n} layers Qrup · {n} qat|Qrup · {n} qat +@plural {n} sample|{n} samples {n} nümunə|{n} nümunə + 180° 180° + 32-bit Preview Options… 32-bit Önizləmə Seçimləri… + 90° Clockwise 90° Saat əqrəbi istiqamətində + 90° Counter Clockwise 90° Saat əqrəbinin əksinə + AI Integrations… AI İnteqrasiyaları… + Again Yenə + Transform Again on a Copy Bir nüsxədə yenidən çevirin + Anamorphic 2:1 (2) Anamorfik 2:1 (2) + Arbitrary… Özbaşına… + Artboard Guides Artboard Bələdçiləri + Artboard from Group… Qrupdan iş lövhəsi… + Artboard from Layers… Qatlardan iş lövhəsi… + Artboards to Files… İş lövhələrini fayllara ixrac et… + Artboards to PDF… İş lövhələrini PDF-ə ixrac et… + Artboard… İş lövhəsi… + Border… Sərhəd… + Bottom Edges Aşağı kənarlar + Browse… Gözdən keçir… + Source Files Mənbə Fayllar + Use: İstifadə edin: + Files Fayllar + Folder Qovluq + Add Open Files Açıq faylları əlavə edin + Sort By Name Ada görə çeşidləyin + Attempt to Automatically Align Source Images Mənbə şəkillərini avtomatik uyğunlaşdırmağa çalışın + Create Smart Object after Loading Layers Qatları Yüklədikdən sonra Ağıllı Obyekt yaradın + Canvas Guides Kətan Bələdçiləri + Character Styles Panel Simvol üslubları paneli + Clear Canvas Guides Təmiz Kətan Bələdçiləri + Clear Selected Artboard Guides Seçilmiş Artboard Bələdçilərini Sil + Color Blindness — Deuteranopia-type Rəng korluğu - Deuteranopiya növü + Color Blindness — Protanopia-type Rəng korluğu — Protanopiya növü + Color Table… Rəng Cədvəli… + Combine Shapes Formaları birləşdirin + Contract… Daralt… + Convert to Embedded Daxil edilmişə çevirin + Convert to Layers Qatlara çevirin + Convert to Linked… Bağlıya çevirin… + Count sayın + Create Layer Qat yaradın + Custom Pixel Aspect Ratio… Fərdi Piksel Aspekt Nisbəti… + Cycle Screen Mode Ekran rejimini dəyiş + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC Geniş Ekran (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL Geniş Ekran (1.46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + De-Interlace… Sətirarası hörülməni aradan qaldır… + Default Features Defolt Xüsusiyyətlər + Delete All Empty Layers Bütün boş qatları sil + Desaturate Desaturasiya + Displace… yerdəyişmə… + Distribute Yaymaq + Document Bounds Sənəd Sərhədləri + Edit in Quick Mask Mode Tez Maska rejimində redaktə edin + Enhanced Controls… Təkmilləşdirilmiş İdarəetmələr… + Entropy Entropiya + Exclude Overlapping Shapes Üst-üstə düşən formaları istisna edin + Exit Çıxış + Expand… Genişləndir… + Extra Large Çox böyük + File Info… Fayl məlumatları… + Fit Artboard on Screen İş lövhəsini ekrana yerləşdirin + Fit Layer(s) on Screen Layer(ləri) ekrana uyğunlaşdırın + Flatten All Layer Effects Bütün qat effektlərini rasterləşdir + Flatten All Masks Bütün maskaları tətbiq et + Flatten Image Şəkli tək qatda birləşdir + Float All in Windows Hamısını Windows-da Float + Float in Window Pəncərədə üzmək + Frame from Layers Qatlardan Çərçivə + From Transparency Şəffaflıqdan + Gamut Warning Rəng əhatəsi xəbərdarlığı + Global Light… Qlobal İşıq… + Gradient… Qradient… + Graphic and Web Qrafik və Veb + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + Hidden Layers Gizli qatlar + Hide All Effects Bütün effektləri gizlədin + Horizontal Centers Üfüqi Mərkəzlər + Huge Böyük + Indexed Color… İndekslənmiş Rəng… + Integrations… İnteqrasiya… + Intersect Shape Areas kəsişən forma sahələri + Kurtosis Kurtosis + Large Böyük + Layer Edges Qat kənarları + Left Edges Sol kənarlar + Lens Blur… Obyektiv bulanıqlığı… + Lighting Effects… İşıq effektləri… + Link Layers Qatları əlaqələndir + Lock Layers… Qatları kilidləyin… + Mean Orta + Merge Down Aşağıdakı ilə birləşdir + Merge Group Qrupu birləşdir + Merge Layers Qatları birləşdir + Merge Shape Components Forma komponentlərini birləşdirin + Merge Visible Görünənləri birləşdir + Stamp Visible Marka görünür + Select Layer Above Yuxarıdakı Layer seçin + Select Layer Below Aşağıdakı Layer seçin + Select Top Layer Üst qat seçin + Select Bottom Layer Alt qat seçin + Add Layer Above to Selection Seçimə yuxarı qat əlavə edin + Add Layer Below to Selection Seçimə Aşağı Layer əlavə edin + Stamp Down Aşağı qata möhürlə + Mesh Mesh + Middle Eastern & South Asian Composer Yaxın Şərq və Cənubi Asiya mətn tərtibatçısı + Modifier Keys Dəyişdirici açarlar + Modify Dəyişdirin + NTSC Colors NTSC Rəngləri + Notes… Qeydlər… + Other Digər + Other… Digər… + Paragraph Styles Panel Abzas üslubları paneli + Paths to Illustrator… Yolları Illustrator-a ixrac et… + Pattern Preview Nümunə Ön Baxış + Pattern… Naxış… + Pixel Art Piksel Art + Pixel Art Preview Piksel sənəti önbaxışı + Pixel Grid Piksel şəbəkəsi + Pixelate Pixelate + Place Scale Marker… Miqyas nişanı yerləşdir… + Plug-ins Pluginlər + Print One Copy Bir nüsxə çap et + Print… Çap et… + Range Aralıq + Raw Defaults… RAW üçün ilkin parametrlər… + Relink to File… Fayla yenidən keçid… + Relight… Yenidən işıqlandırma… + Render Render + Replace All Missing Fonts Bütün çatışmayan şriftləri dəyişdirin + Resolve Missing Fonts… Çatışmayan Şriftləri Həll Edin… + Reveal Selection Seçimi aşkar et + Right Edges Sağ kənarlar + Selection Edges Seçim kənarları + Shapes Formalar + Show Extras Options… Əlavə seçimləri göstərin… + Skewness Çarpıqlıq + Small Kiçik + Smart Guides Ağıllı Bələdçilər + Smart Objects Ağıllı obyektlər + Smooth… Hamarla… + Solid Color… Solid Rəng… + Square Kvadrat + Stack Mode Yığın rejimi + Choose Stack Mode: Yığın rejimini seçin: + Standard Deviation Standart sapma + Statistics… Statistika… + Stylistic Alternates Stilistik Alternativlər + Stylize Stilizasiya + Subtract Front Shape Ön formanı çıxarın + Summation Toplama + Target Path Hədəf Yol + Tile All Horizontally Hamısını üfüqi şəkildə döşəyin + Tile All Vertically Hamısını şaquli olaraq döşəyin + Toggle Last State Son vəziyyəti dəyişin + Toolbar… Alətlər paneli… + Tools… Alətlər… + Top Edges Üst kənarlar + Unite Shapes Fiqurları birləşdir + Update All Modified Content Bütün dəyişdirilmiş məzmunu yeniləyin + Update All Text Layers Bütün mətn qatlarını yeniləyin + Update Modified Content Dəyişdirilmiş Məzmunu Yeniləyin + Variance Variasiya + Vertical Centers Şaquli Mərkəzlər + Video Frames to Layers… Video kadrlarını qatlara çevir… + WIA Support… WIA dəstəyi… + Working CMY Plate İşləyən CMY Plitəsi + Absolute Mütləq + Action Fəaliyyət + Adaptive Uyğunlaşan + Add Stop Stop əlavə edin + Add disk Disk əlavə edin + Align and Distribute Hizalayın və Yayınlayın + All Slices Bütün Dilimlər + All User Slices Bütün İstifadəçi Dilimləri + Alt Alt + Artboard İş lövhəsi + Artboard Background Color Artboard Fon Rəngi + Auto Hide Avtomatik Gizlət + Automatically level near-horizontal line segments Yaxın üfüqi xətt seqmentlərini avtomatik düzəldin + Automatically straighten and level Avtomatik olaraq düzəldin və səviyyəni düzəldin + Automatically straighten near-vertical line segments Şaquli xətt seqmentlərini avtomatik düzəldin + Balance Balans + Baseline Baza + Bicubic Bikubik + Bicubic (smooth gradients) Bikubik (hamar gradientlər) + Automatic Avtomatik + Bicubic Smoother (enlargement) Bicubic Smoother (böyütmə) + Bicubic Sharper (reduction) Bicubic Sharper (azaltma) + Bilinear Billinear + Black, White Qara, Ağ + Blue, Red, Yellow Mavi, Qırmızı, Sarı + Bottom Aşağı + Brightness Jitter Parlaqlıq titrəməsi + Brush Reconstruct Options Fırça ilə Yenidənqurma Seçimləri + Cache Level: Keş Səviyyəsi: + Canvas Kətan + Capture Tutmaq + Center Crop Marks Mərkəzi Kəsmə İşarələri + Centre the gradient (offset 0, 0) Qradienti mərkəzləşdirin (ofset 0, 0) + Change Screen Mode (F) Ekran rejimini dəyişdirin (F) + Change To: Dəyişdirin: + Clear All Hamısını Sil + Clear Override Ləğv etməni silin + Clear Samples Təmiz nümunələr + Cmd Cmd + Color Grading Rəng tonlaması + Color Management Rəng İdarəetmə + Color Priority Rəng prioriteti + Color Range preview Rəng diapazonuna baxış + Colorize Rəngləndirin + Constant Daimi + Content-Aware Məzmundan xəbərdar + Convert to sRGB sRGB-ə çevirin + Copies Nüsxələr + Copper Mis + Corner Crop Marks Künc kəsmə işarələri + Correction Düzəliş + Count Jitter Say dəyişkənliyi + Create Texture Tekstura yaradın + Create a new count group Yeni hesab qrupu yaradın + Crop Factor Kəsmə faktoru + Ctrl Ctrl + Custom Shapes Fərdi Formalar + Cyan Mavi + Cyan · Red Mavi · Qırmızı + Darks Qaranlıqlar + Default printer Defolt printer + Default restored. Defolt bərpa edildi. + Delete Group Qrupu Sil + Delete count group Hesab qrupunu silin + Delete effect layer Effekt qatını silin + Delete note Qeydi silin + Delete tool preset Alətin əvvəlcədən təyinini silin + Depth Jitter Dərinlik dəyişkənliyi + Discontiguous Bitişik + Discord Mübahisə + Display P3 Ekran P3 + Display Text Mətni göstərin + Divide… Bölün… + Do you want to save the changes you made to “{name}” before quitting? Çıxmazdan əvvəl “ {name} ” üçün etdiyiniz dəyişiklikləri yadda saxlamaq istəyirsiniz? + Edit Toolbar… Alətlər panelini redaktə et… + Edit in Quick Mask Mode (Q) Sürətli Maska rejimində redaktə edin (Q) + Edit in Standard Mode (Q) Standart rejimdə redaktə edin (Q) + Embedded Profile Mismatch Daxili Profil Uyğunsuzluğu + Enhance Edge Kənarı təkmilləşdirin + Exact Dəqiq + Expansion: Genişləndirmə: + Exposure and Gamma Ekspozisiya və Qamma + Factor: Amil: + Fewer Points Daha az xal + File Settings Fayl Parametrləri + Finger Painting Barmaqlarla rəsm + Fit in View Görünüşə sığdır + Fixed Düzəltildi + Foreground to Background Ön plandan arxa plana + Foreground/Background Jitter Ön plan/Arxa plan titrəməsi + Format Format + Frame from Layers… Qatlardan Çərçivə… + Full Spherical Tam Sferik + Gamma Correction Qamma korreksiyası + Gamma: Qamma: + Global Qlobal + Grain Taxıl + Graphics Qrafika + Gray Boz + Heart Ürək + Hide Auto Slices Avtomatik dilimləri gizlədin + Highlight Compression Vurğulanmış sıxılma + Highlight Priority Prioriteti vurğulayın + Highlights İşıqlı sahələr + Home Ev + Hue Jitter Rəng tonu dəyişkənliyi + Image Size: Şəkil ölçüsü: + Imported İdxal edilib + Input: Giriş: + Interlaced Keçmiş + Invert All Hamısını çevir + Label Size Etiket Ölçüsü + Labels Etiketlər + Lanczos (sharp) Lanczos (kəskin) + Level Səviyyə + Lights İşıqlar + Limits: Limitlər: + Link the mask to the layer Maskanı qat ilə əlaqələndirin + Localized Color Clusters Lokallaşdırılmış Rəng Klasterləri + Luminance Parlaqlıq + Magenta Magenta + Magenta · Green Magenta · Yaşıl + Marker Size Marker Ölçüsü + Mask Options Maska Seçimləri + Masking Maskalama + Master Ustad + Mean: Orta: + Median: Median: + Mesh Size Mesh ölçüsü + Metadata Metadata + Midpoint Orta nöqtə + Midtones Orta tonlar + Midtones from: Orta tonlar: + Midtones to: Orta tonlar: + Minimum Depth Minimum Dərinlik + Mix Jitter Qarışıq dəyişkənliyi + Mode: Rejim: + Monochrome Monoxrom + More Points Daha çox xal + Move Group Down Qrupu Aşağı köçürün + Move Group Up Qrupu yuxarı köçürün + Move effect layer down Effekt qatını aşağı köçürün + Move effect layer up Effekt qatını yuxarı köçürün + Move to keçin + Nearest Neighbor Ən yaxın qonşu + Nearest Neighbor (hard edges) Ən yaxın qonşu (sərt kənarlar) + New effect layer Yeni effekt qatsi + Next note Növbəti qeyd + No Color Management Rəng İdarəetmə Yoxdur + Optimized Optimallaşdırılmışdır + Output Levels: Çıxış Səviyyələri: + Output: Çıxış: + Paint Overlay Rəngli örtük + Pattern Size Naxış Ölçüsü + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — Rust dilində yazılmış açıq mənbəli, yerli şəkil redaktoru. + Position and Size Mövqe və Ölçü + Preserve Details Təfərrüatları qoruyun + Preserve Luminosity Parlaqlığı qoruyun + Previous note Əvvəlki qeyd + Printer Printer + Printer Setup Printer Quraşdırma + Printing Marks Çap markaları + Progressive Proqressiv + Promote Təşviq et + Protect Detail Təfərrüatı qoruyun + Protect Tones Tonları qoruyun + Proximity Match Yaxınlıq uyğunluğu + Purity Saflıq + Quality Keyfiyyət + Recent Son + Reconstruct Yenidənqurma + Refine Təmizləyin + Registration Marks Qeydiyyat nişanları + Regular Daimi + Remove stop Dayanmağı çıxarın + Rename Group… Qrupun adını dəyişin… + Rename Layer… Qatın adını dəyiş… + Restore All Hamısını bərpa edin + Restrictive (Web) Məhdudiyyətli (Veb) + Rigid Sərt + Roughness Kobudluq + Roundness Dairəvilik + Rulers & Grids Xətkeşlər və Şəbəkələr + Sample: foreground colour Nümunə: ön plan rəngi + Sampling: Background Swatch Nümunə götürmə: Fon nümunəsi + Sampling: Continuous Nümunə götürmə: Davamlı + Sampling: Once Nümunə götürmə: bir dəfə + Saturate Doymaq + Saturation Jitter Doyma dəyişkənliyi + Save HTML and Images HTML və Şəkilləri yadda saxlayın + Save as PDF PDF olaraq saxla + Scale to Fit Media Kağıza sığdır + Scatter Dağılma + Scratch disks cızma diskləri + Selective Seçici + Shadows Kölgələr + Shape: Forma: + Sharpening İtiləşdirmə + Shift shift + Show All Menu Items Bütün menyu elementlərini göstərin + Show Backdrop Fonu göstərin + Show Mask Maskanı göstərin + Show Mesh Toru göstər + Show or hide the Notes panel Qeydlər panelini göstərin və ya gizləyin + Show the layer mask as an overlay Qat maskasını üst-üstə düşmə kimi göstərin + Show: Göstər: + Sides Tərəflər + Sides: Tərəflər: + Slice Options… Dilim Seçimləri… + Slices From Guides Bələdçilərdən Dilimlər + Slices are locked (View › Lock Slices) Dilimlər kilidlənib (Bax › Dilimləri Kilidləyin) + Split: Ayrılma: + Star ratio Ulduz nisbəti + Std Dev: Std Dev: + Straighten Düzləşdirin + Straighten Layer Qatı düzəldin + Strength: Güc: + Stroke Kontur + Stroke Catch-up Vuruşun tutulması + Stroke: Kontur: + Location: Yer: + Inside İçəridə + Outside Çöldə + Tetrahedral Tetraedral + Texture Each Tip Hər İpucu Tekstur + Gradient Editor Gradient Redaktoru + Gradient picker Qradient seçici + Save As Preset Əvvəlcədən təyin edilmiş kimi saxla + The gradient the Gradient tool paints with Gradient alətinin rənglədiyi gradient + Adds the current gradient to the Gradients panel Qradientlər panelinə cari qradiyenti əlavə edir + Gradient stops Gradient dayanır + Click to edit the gradient Qradienti redaktə etmək üçün klikləyin + Tilt X X əymək + Tilt Y Y əymək + Toggle count group visibility Qrupların sayının görünməsini dəyişdirin + Toggle text orientation Mətn oriyentasiyasını dəyişdirin + Tone: Ton: + Toolbar Alətlər paneli + Top Yuxarı + Total: Cəmi: + Transparency Şəffaflıq + Trilinear Üçxətli + Unlink the mask from the layer Maskanı qatdən ayırın + View Options Seçimlərə baxın + Violet, Orange Bənövşə, Narıncı + Visible Görünən + Weight Çəki + Weight: Çəki: + Wetness Jitter Nəmlik dəyişkənliyi + Whites Ağ sahələr + Yellow Sarı + Yellow · Blue Sarı · Mavi + Adobe Adobe + Adobe rgb Adobe rgb + Allow agent control Agent nəzarətinə icazə verin + Allow scripts to connect Skriptlərin qoşulmasına icazə verin + Always Həmişə + Always create smart objects when placing Yerləşdirərkən həmişə ağıllı obyektlər yaradın + Animated zoom Animasiyalı böyütmə + Apply auto tone Avtomatik ton tətbiq edin + Ask Soruşun + Ask before saving layered tiff Laylı tiff yadda saxlamazdan əvvəl soruşun + Autosave Avtomatik saxla + Autosave minutes Dəqiqələri avtomatik saxla + Beep when done Bitirdikdə səs siqnalı + Bicubic automatic Avtomatik bikubik + Bicubic sharper Bikub daha kəskin + Bicubic smoother Bikubik hamar + Bit depth Bit dərinliyi + Brush picker Fırça seçici + Brush preview color Fırçanın önizləmə rəngi + CPU (no GPU acceleration) CPU (GPU sürətləndirilməsi yoxdur) + Cache levels Keş səviyyələri + Cache tile size Keş kafel ölçüsü + Camera Raw Defaults Camera Raw ilkin ayarları + Canvas border Kətan haşiyəsi + Show canvas dots Kətan nöqtələrini göstərin + Clip thumbnails to layer bounds Miniatürləri qat sərhədlərinə kəsin + Clip Thumbnails to Layer Bounds Miniatürləri Layer Sərhədlərinə Klip + Canvas color Kətan rəngi + Canvas custom color Kətan xüsusi rəng + Classic Klassik + Color picker Rəng seçici + Color space Rəng məkanı + Column width Sütun eni + Concise Qısa + Control port İdarəetmə portu + Convert to srgb srgb-ə çevirin + Cursors Kursorlar + Custom dark Fərdi qaranlıq + Custom light Fərdi işıq + Dark Tünd + Dark gray Tünd boz + Dashed lines kəsik xətlər + Detailed Ətraflı + DirectX 12 DirectX 12 + Disks Disklər + Display p3 Ekran p3 + Dots Nöqtələr + Double click layer mask launches select and mask İki dəfə kliklənən qat maskası seçin və maskanı işə salır + Drop shadow Düşən kölgə + Dynamic color sliders Dinamik rəng sürgüləri + East asian Şərqi Asiya + Effect cache mb Effekt önbelleği mb + Enable flick panning Flick panning funksiyasını aktivləşdirin + Enable floating document window docking Üzən sənəd pəncərəsinin yerləşdirilməsini aktivləşdirin + Enable narrow options bar Dar seçimlər panelini aktivləşdirin + Enhanced Controls Təkmilləşdirilmiş İdarəetmələr + Erase Silin + Export clipboard Buferi ixrac edin + Extra large Çox böyük + File Handling Faylların idarə edilməsi + File path Fayl yolu + Fill new type layers with placeholder Yeni tipli qatlari yer tutucu ilə doldurun + Font preview Şriftin önizləməsi + Full size tip Tam ölçülü tip + GPU backend GPU arxa ucu + Linux display server Linux displey serveri + X11 X11 + Gamut warning color Gamut xəbərdarlıq rəngi + Gamut warning opacity Gamut xəbərdarlıq qeyri-şəffaflığı + General Ümumi + Enable Generator Generatoru aktivləşdirin + Gif Gif + Glossy Parlaq + Grid color Tor rəngi + Grid colors Grid rəngləri + Grid size Şəbəkə ölçüsü + Grid style Şəbəkə üslubu + Grid unit Şəbəkə vahidi + Gridline every Gridline hər + Guide color Bələdçi rəngi + Guide style Bələdçi üslubu + Guides, Grid & Slices Bələdçilər, tor və dilimlər + Gutter Oluk + History Log Tarixçə jurnalı + History states Tarix dövlətləri + Ignore exif profile tag Exif profil etiketinə məhəl qoymayın + Image interpolation Şəkil interpolasiyası + Image previews Şəkil önizləmələri + Integrations İnteqrasiya + Interface İnterfeys + Jpeg quality Jpeg keyfiyyəti + Jpg Jpg + Large tabs Böyük nişanlar + Legacy compositing Köhnə kompozisiya + Light gray Açıq boz + Line Xətt + Lines Xətlər + Lowercase extension Kiçik hərf uzantısı + Maximize psd compatibility PSD uyğunluğunu maksimuma çatdırın + Medium gray Orta boz + Memory usage mb Yaddaş istifadəsi mb + Low resolution previews Aşağı ayırdetmə önizləmələri + Metal Metal + Missing glyph protection Qlif qoruması çatışmır + Modern Müasir + Nearest neighbor Ən yaxın qonşu + Never Heç vaxt + Normal tip Adi uc + Open as smart object Ağıllı obyekt kimi açın + Open in camera raw Raw kamerada açın + Open documents as tabs Sənədləri nişanlar kimi açın + OpenGL OpenGL + Orange Narıncı + Overscroll Kətandan kənara sürüşdürmə + Performance Performans + Png png + Point size Nöqtə ölçüsü + PostScript (72 points/inch) PostScript (72 xal/düym) + Precise Dəqiq + Preserve details Detalları qoruyun + Print resolution Çap qətnaməsi + Pro Pro + Pro light Pro işıq + Pro light gray Pro açıq boz + Pro medium Pro orta + Pro photo Pro şəkil + Purple Bənövşəyi + Quick export format Sürətli ixrac formatı + Quick export location Sürətli ixrac yeri + Recent file count Son faylların sayı + LUT folder LUT qovluğu + Recent files Son fayllar + Recent fonts Son şriftlər + Recover on launch Başladıqda bərpa edin + Remember workspace changes İş yerindəki dəyişiklikləri xatırlayın + Resize image during place Yer zamanı şəklin ölçüsünü dəyişdirin + Right click with painting tools Rəsm alətləri ilə sağ klikləyin + Rotate view with trackpad Trackpad ilə görünüşü fırladın + Same folder Eyni qovluq + Save in background Fonda saxla + Scratch Disks Müvəqqəti saxlama diskləri + Screen resolution Ekran həlli + Scrubby slider acceleration Scrubby sürüşmə sürətlənməsi + Sessions only Yalnız seanslar + Sharpen for üçün itiləyin + Show channels in color Kanalları rəngli göstərin + Show crosshair in brush tip Fırça ucunda çarpaz işarəni göstərin + Show extension panels Uzatma panellərini göstərin + Show font names in english Şrift adlarını ingilis dilində göstərin + Show menu colors Menyu rənglərini göstərin + Show only crosshair while painting Rəsm edərkən yalnız çarpaz işarəni göstərin + Show slice numbers Dilim nömrələrini göstərin + Show tooltips Alət ipuclarını göstər + Enable animations Animasiyaları aktivləşdirin + Show highlight on rollover Köçürmə zamanı vurğunu göstərin + Show bounding box when dragging layer Qatı sürükləyərkən məhdudlaşdırıcı qutunu göstərin + System title bar Sistem başlıq çubuğu + Show transformation values Transformasiya dəyərlərini göstərin + Slice color Dilim rəngi + Smart guide color Ağıllı bələdçi rəngi + Smart quotes Ağıllı sitatlar + Snap vector tools and transforms to pixel grid Snap vektor alətləri və piksel şəbəkəsinə çevrilir + Srgb Srgb + Standard Standart + Studio Studiya + Studio light Studiya işığı + Adwaita Adwaita + Adwaita dark Adwaita qaranlıq + Subdivisions Bölmələr + System Sistem + Text engine Mətn mühərriki + Text file Mətn faylı + Tiny Kiçik + Touch gestures Toxunma jestləri + Traditional (72.27 points/inch) Ənənəvi (72,27 bal/düym) + Transparency & Gamut Şəffaflıq və rəng əhatəsi + Type units Növ vahidləri + UI font size UI şriftinin ölçüsü + UI scale UI miqyası + Units & Rulers Vahidlər və xətkeşlər + Use additional plugins folder Əlavə plaginlər qovluğundan istifadə edin + Use esc to commit Təhvil vermək üçün esc istifadə edin + Use gpu Gpu istifadə edin + Use legacy free transform Köhnə sərbəst transformasiyadan istifadə edin + Use shift key for tool switch Alət keçidi üçün shift düyməsini istifadə edin + Use tablet pressure Tablet təzyiqindən istifadə edin + Vary round brush hardness on hud Hud üzərində yuvarlaq fırça sərtliyini dəyişin + Vulkan Vulkan + Webp Webp + World ready Dünya hazırdır + Zoom clicked point to center Kliklənmiş nöqtəni mərkəzə yaxınlaşdırın + Zoom resizes windows Böyütmə pəncərələrin ölçüsünü dəyişir + Zoom with scroll wheel Sürüşdürmə çarxı ilə böyüt + Zoom with trackpad pinch Trackpad çimdiklə böyüt + Alt-click to define a source point to clone from Klonlamaq üçün mənbə nöqtəsini təyin etmək üçün Alt düyməsini sıxın + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Seçim etmək üçün yol yoxdur: Qələm aləti ilə birini çəkin və ya Yollar panelində birini seçin. + Cancel opening Açılışı ləğv edin + Cancelled opening {name} {name} açılışı ləğv edildi + Damaged Photoshop document Zədələnmiş Photoshop sənədi + The document “{name}” may be damaged. “ {name} ” sənədi zədələnə bilər. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Bəzi qatlari, pikselləri və ya maskaları oxumaq mümkün olmadı. Onu açmağa davam etmək istəyirsiniz? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Davam etsəniz, orijinal üzərində yadda saxlamaq oxunmayan məlumatları həmişəlik ləğv edə bilər. Orijinalı saxlamaq üçün surətini saxlayın. + Continue Davam et + The document “{name}” does not have an embedded {mode} profile. “ {name} ” sənədində daxil edilmiş {mode} profili yoxdur. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. “ {name} ” sənədində mövcud {mode} iş sahəsinə uyğun gəlməyən daxili rəng profili var. + Working: {working} İşləyir: {working} + Developing {name} {name} hazırlanır + Cancelled {label} Ləğv edildi {label} + Esc to cancel Ləğv etmək üçün Esc + Opened {name} {name} açıldı + Opening {name} {name} açılır + Saved {name} Saxlanmış {name} + Saving {name} {name} qənaət edilir + Saving {percent} {percent} qənaət edilir + A save to {name} is still running; try again when it finishes {name} -də saxlama hələ də işləyir; bitdikdə yenidən cəhd edin + {label} is writing the file and can no longer be cancelled {label} faylı yazır və artıq ləğv edilə bilməz + Opening {name}… {name} açılır… + Opening… Açılış… + Reading… Oxuyur… + Working… İşləyir… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Tor: + Rendering Mode Göstərmə rejimi + Automatic (recommended) Avtomatik (tövsiyə olunur) + CPU / Compatibility CPU / Uyğunluq + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Avtomatik mövcud olduqda GPU sürətləndirməsindən istifadə edir və səhvlər zamanı CPU renderinə qayıdır. + Switched to CPU rendering CPU renderinə keçdi + GPU acceleration could not continue. Your documents are unchanged. GPU sürətləndirilməsi davam edə bilmədi. Sənədləriniz dəyişməzdir. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft CPU şəkil tərtibatçısından istifadə edir. Pəncərə hələ də qrafik adapterinizdən istifadə edə bilər. + Retry GPU requires a restart. Save your work first. Yenidən cəhd edin GPU yenidən başladılmağı tələb edir. Əvvəlcə işinizi qeyd edin. + Retry GPU tries the graphics processor again now. Yenidən cəhd edin GPU indi qrafik prosessorunu yenidən sınayır. + GPU acceleration resumed. GPU sürətləndirilməsi bərpa edildi. + Keep Using CPU CPU istifadə etməyə davam edin + Retry GPU GPU-nu yenidən cəhd edin + GPU Backend GPU arxa ucu + Details Təfərrüatlar + GPU GPU + Rendering mode Göstərmə rejimi + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Kəməndi dondurun: ərazini dondurmaq üçün sürükləyin, əritmək üçün Alt düyməsi ilə sürükləyin (L) + Shadow Clipping Warning (U) Kölgə Kəsmə Xəbərdarlığı (U) + Highlight Clipping Warning (O) Vurğulanan Kəsmə Xəbərdarlığı (O) + Show Lab Color Readouts Laboratoriya Rəng Oxunuşlarını göstərin + Show Vectorscope Vektorskopunu göstərin + Show Red at 3 o'clock Saat 3-də Qırmızı göstər + Show Skin Tone Indicator Dəri Tonu Göstəricisini göstərin + Tone Histogram Ton histoqramı + Show Selected Region Seçilmiş Bölgəni göstərin +cameraRaw Light İşıq + Camera Raw Filter ({layer}) Kamera Raw Filtri ( {layer} ) + Camera Raw ({name}) Kamera Raw ( {name} ) + Aqua Su +cameraRaw Amount Miqdar +cameraRaw Blacks Qara sahələr +cameraRaw Vibrance Rəng canlılığı +cameraRaw Color Grading Rəng tonlaması +cameraRaw Lights Açıq tonlar +cameraRaw Darks Tünd tonlar +cameraRaw Global Qlobal +cameraRaw Red Qırmızı +cameraRaw Orange Narıncı +cameraRaw Yellow Sarı +cameraRaw Green Yaşıl +cameraRaw Aqua Su +cameraRaw Blue Mavi +cameraRaw Purple Bənövşəyi +cameraRaw Magenta Magenta +cameraRaw Hue Rəng tonu +cameraRaw Blending Qarışdırma +cameraRaw Luminance Detail Parlaqlıq təfərrüatı +cameraRaw Color Detail Rəng təfərrüatı +cameraRaw Roughness Kələ-kötürlük + Patch Tool Yamaq aləti + Patch: Yamaq: + Lasso around an area, then drag the selection Sahənin ətrafında kəmənd çəkin, sonra seçimi dartın + Transform On Drop Dropda Transform + Scale or rotate the moved area, then press {key} Köçürülən ərazini miqyaslandırın və ya fırladın, sonra {key} düyməsini basın + Now drag the selection onto another area İndi seçimi başqa sahəyə sürükləyin + Edit Type Növü redaktə edin + New Style… Yeni Stil… + Style {name} saved {name} üslubu saxlanıldı + Create new style (from the selected type) Yeni üslub yaradın (seçilmiş növdən) + Create new style from the selected layer Seçilmiş qatdən yeni üslub yaradın + Make Default Defolt et + Reset to Default Sıfırla Defolt + Full-resolution preview unavailable Tam rezolyusiyaya baxış mövcud deyil + No patterns Naxış yoxdur + Enable {name} {name} -ni aktivləşdirin + Hold Alt while drawing for straight segments Düz seqmentlər üçün çəkərkən Alt düyməsini basıb saxlayın + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Çoxbucaqlı nöqtələrini dartın və ya klikləyin · düz seqmentlər üçün kəmənd zamanı Alt düyməsini basıb saxlayın · {add} əlavə edin · {sub} çıxarışları çəkməzdən əvvəl + Extend uzadın + Structure: Struktur: + Content-Aware Move Tool Məzmuna uyğun köçürmə aləti + TIFF Options TIFF Seçimləri + Targa Options Tarqa Seçimləri + JPEG Options JPEG Seçimləri + Image Options Şəkil Seçimləri + Format Options Format Seçimləri + Baseline ("Standard") Baza ("Standart") + Baseline Optimized Əsas optimallaşdırılıb + 24 bits/pixel 24 bit/piksel + 32 bits/pixel 32 bit/piksel + Including layers will increase file size. Qatların daxil edilməsi faylın ölçüsünü artıracaq. + Discard Layers and Save a Copy Qatları ləğv et və nüsxə saxla + Don't show again Bir daha göstərmə + Save failed: {error} Saxlama alınmadı: {error} + Save cancelled Saxlama ləğv edildi + Couldn't place {name}: {error} {name} yerləşdirmək mümkün olmadı: {error} + Magnetic Lasso Tool Maqnitli kəmənd aləti + Frequency Tezlik + Use tablet pressure to change pen width Qələm enini dəyişmək üçün planşet təzyiqindən istifadə edin + About Haqqında + Contributors Töhfə verənlər + Models Modellər + Sort Sırala + Username İstifadəçi adı + Display name Ekran adı + Real name Əsl adı + Reverse the order Sifarişi geri qaytarın + Grab bag Çantanı götür + Table Cədvəl + Name (A–Z) Ad (A–Z) + Merged PRs Birləşdirilmiş PR + Commits Kommitlər + Lines added Sətirlər əlavə edildi + Lines deleted Sətirlər silindi + Line delta Xətt deltası + Binary assets added İkili aktivlər əlavə edildi + Binary assets removed İkili aktivlər silindi + First commit İlk öhdəlik + First Birinci + Last commit Son öhdəlik + Last Son + Contributors: {contributors} · Commits: {commits} Əmanətçilər: {contributors} · Öhdəliklər: {commits} + No contributor data was built into this copy. Bu nüsxəyə heç bir töhfə verən məlumat daxil edilməyib. + No model credits were built into this copy. Bu nüsxəyə heç bir model krediti daxil edilməyib. + Company Şirkət + Version Versiya + % of all commits bütün öhdəliklərin %-i + Lines +/− Xətlər +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} kommit, +{added} / −{deleted} sətir (Δ {delta}), +{binAdded} / −{binDeleted} ikili resurs, {first} – {last} + Create new swatch Yeni nümunə yaradın + New Swatch… Yeni Swatch… + New Swatch Group… Yeni Swatch Qrupu… + Import Swatches… Nümunələri idxal edin… + Replace Swatches… Rəng nümunələrini əvəz et… + Export Swatches… Rəng nümunələrini ixrac et… + Export Swatches for Exchange… Mübadilə üçün rəng nümunələrini ixrac et… + Reset Swatches Rəng nümunələrini sıfırla + Add to Swatches Rəng nümunələrinə əlavə et + Blend If: Bu şərtlə qarışdır: + Channels: Kanallar: + Knockout Alt qatları deşmə + Apply At Canvas Bounds Kətan sərhədlərində tətbiq et + Color Picker (Text Color) Rəng seçici (mətn rəngi) + Color Picker ({name}) Rəng seçici ({name}) + Color Picker (Fill Color) Rəng seçici (doldurma rəngi) + Fill color Doldurma rəngi + Color Picker (Stroke Color) Rəng seçici (kontur rəngi) + Stroke color Kontur rəngi + Pick screen color Ekrandan rəng seç + Sample a pixel anywhere on the screen Ekranın istənilən yerindən piksel nümunəsi götür + Click to pick · Esc to cancel Seçmək üçün klikləyin · Ləğv etmək üçün Esc + Pick a screen pixel or press Esc to cancel Ekran pikselini seçin və ya ləğv etmək üçün Esc düyməsini basın + Scroll to zoom Böyütmək üçün sürüşdürün + Drag to pan · Scroll to zoom · Double-click to fit Panorama üçün dartın · Böyütmək üçün sürüşdürün · Uyğunlaşdırmaq üçün iki dəfə klikləyin + Show/Hide All Panels Bütün Panelləri Göstər/Gizlət + Show/Hide Panels Panelləri Göstər/Gizlət + Could not open açmaq mümkün olmadı + {path}: the path is not valid Unicode; rename the file and open it again. {path} : yol etibarlı Unicode deyil; faylın adını dəyişdirin və yenidən açın. + Contents: Məzmun: + Custom Pattern: Fərdi Nümunə: + Foreground Color Ön plan rəngi + Color… Rəng… + 50% Gray 50% Boz + {size} (was {old}) {size} ( {old} idi) + Cone Konus + Cone (Inverted) Konus (Ters çevrilmiş) + Domed Qübbəli + Domed (Inverted) Qübbəli (Ters çevrilmiş) + Diagonal (Descending) Diaqonal (Azalan) + Contour Editor Kontur redaktoru + Anti-aliased Kənarları hamarlanmış + Corner Künc + Gloss Contour Parıldadıcı Kontur + Contour Kontur + Cone - Inverted Konus - Tərs + Cove - Deep Koy - Dərin + Cove - Shallow Koy - Dayaz + Gaussian Qauss + Half Round Yarım Dəyirmi + Ring Üzük + Ring - Double Üzük - İkiqat + Rolling Slope - Descending Rolling Slope - Azalan + Rounded Steps Dairəvi addımlar + Sawtooth 1 mişar dişi 1 + Linear Xətti + Red Eye Tool Qırmızı göz aləti + Pupil Size Şagird ölçüsü + Darken Amount Qaranlıq məbləğ + Click a red pupil to neutralize it Qırmızı göz bəbəyini zərərsizləşdirmək üçün üzərinə klikləyin + open source açıq mənbə + …and {n} more …və daha çox {n} + Eyedroppers: Damlalar: + Set Black Point Qara nöqtəni təyin edin + Set Neutral Gray Point Neytral Boz Nöqtə təyin edin + Set White Point Ağ nöqtəni təyin edin + Unavailable in this color mode Bu rəng rejimində mövcud deyil + Selection Preview: Seçimə baxış: + Black Matte Qara Tutqun + White Matte Ağ Tutqun + Quick Mask Sürətli maska + Seafoam Dəniz köpüyü + Indigo İndiqo + Fuchsia Fuşya + Violet Bənövşəyi + Large document: redrawing on the CPU Böyük sənəd: CPU-da yenidən çəkiliş + Redrawing Yenidən çəkmək + This redraw took {seconds} s on the CPU. Bu yenidən çəkiliş CPU-da {seconds} s çəkdi. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Qatın qarışıq rejimi, qeyri-şəffaflıq və ya görünürlük kimi bütün sənədi dəyişdirən redaktələr hamısını yenidən çəkir. + The GPU compositor wasn't used: {reason} GPU kompozitoru istifadə edilmədi: {reason} +fontWeight Thin Çox incə +fontWeight ExtraLight Həddindən artıq incə +fontWeight Light İncə +fontWeight Regular Adi +fontWeight Medium Orta +fontWeight SemiBold Yarımqalın +fontWeight Bold Qalın +fontWeight ExtraBold Çox qalın +fontWeight Black Ən qalın +fontWeight Italic Kursiv + Pattern Stamp Tool Naxış möhürü aləti + Impressionist İmpressionist + Rotate View Tool Görünüşü döndərmə aləti + Reset View Görünüşü Sıfırlayın + Drag around the centre to rotate the view · Shift constrains to 15° Görünüşü fırlatmaq üçün mərkəz ətrafında dartın · Məhdudiyyətləri 15°-ə köçürün + Dual Axis İkili ox + Diagonal Diaqonal + Transform Symmetry Transform Simmetriya + Set painting symmetry options Rəsm simmetriya seçimlərini təyin edin + Point Sample Nöqtə nümunəsi + 3 by 3 Average 3-ə 3 Orta + 5 by 5 Average 5-ə 5-ə Orta + 11 by 11 Average 11-ə 11 Orta + 31 by 31 Average 31-ə 31 Orta + 51 by 51 Average 51-ə 51 Orta + 101 by 101 Average 101 ilə 101 Orta + Sample Size: Nümunə ölçüsü: + All Layers No Adjustments Bütün qatlardə düzəliş yoxdur + Current & Below No Adjustments Cari və Aşağıda Düzəlişlər Yoxdur + Show Sampling Ring Nümunə götürmə halqasını göstərin + Click to sample the foreground colour · {key}-click for background Ön plan rəngini seçmək üçün klikləyin · {key} -fon üçün klikləyin + Out of gamut for printing: click for the nearest printable colour Çap üçün gamut yoxdur: ən yaxın çap edilə bilən rəng üçün klikləyin + Not a web colour: click for the nearest web colour Veb rəngi deyil: ən yaxın veb rəngi üçün klikləyin + Color to Alpha… Rəngdən Alfaya… + Transparency Threshold Şəffaflıq həddi + Opacity Threshold Qeyri-şəffaflıq həddi + PhotoCraft — Screen Color Picker PhotoCraft — Ekran Rəngi Seçici + Rasterize svg groups deeper than svg qruplarını daha dərindən rasterləşdirin + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 bu həddi söndürür; 100 səviyyədən daha dərin yerləşdirilmiş SVG qrupları həmişə rasterləşdirilir. + Rule of Thirds Üçüncülərin qaydası + Triangle Üçbucaq + Golden Ratio Qızıl nisbət + Golden Spiral Qızıl spiral + Auto Show Overlay Örtüyü avtomatik göstər + Always Show Overlay Həmişə Overlay göstərin + Never Show Overlay Heç vaxt üst-üstə düşməyi göstərməyin + Cycle Overlay Örtüyü dəyiş + Cycle Orientation İstiqaməti dəyiş + Set the overlay options for the Crop tool Kəsmə aləti üçün üst-üstə düşmə seçimlərini təyin edin + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik, Alt-sürükləmə ilə pəncərələri hərəkət etdirən masaüstünə keçdi. Pəncərənin hərəkət düyməsini Super olaraq təyin edin və ya Pəncərə › Dəyişdirici Açarlarda Alt düyməsini sıxın. + Color Picker (Layer Style Color) Rəng Seçici (Layer Style Rəngi) + Auto Hide Notifications Bildirişləri avtomatik gizlət + Notification Duration (seconds) Bildiriş müddəti (saniyələr) + Bundle Arrow-Key Nudges Ox düyməsi köçürmələrini qruplaşdır + Nudge Bundle Pause (ms) Köçürmə qruplaşdırma fasiləsi (ms) + Appearance Mode Görünüş rejimi + Dark Theme Tünd mövzu + Light Theme Açıq mövzu + Pro Medium Pro Orta + Studio Light Studiya işığı + Next Appearance Mode Növbəti Görünüş Rejimi + Sync with system Sistemlə sinxronizasiya + Active Aktivdir + Color Picker (Solid Color) Rəng Seçici (Tək Rəng) + Straighten the image by drawing a line on it Şəklin üzərinə xətt çəkərək onu düzəldin + Set additional Crop options Əlavə Kəsmə seçimlərini təyin edin + Show Cropped Area Kəsilmiş ərazini göstərin + Enable Crop Shield Bitki Qalxanını aktivləşdirin + Match Canvas Kətana uyğunlaşdır + Auto Adjust Opacity Opaklığı avtomatik tənzimləyin + Crop Shield color Bitki Qalxanı rəngi + Color Picker (Crop Shield Color) Rəng Seçici (Kəsmə Qalxan Rəngi) + From Center Mərkəzdən + Link radii Bağlantı radiusu + Star Ratio: Ulduz nisbəti: + Create Rectangle Düzbucaqlı yaradın + Create Ellipse Ellips yaradın + Create Triangle Üçbucaq yaradın + Create Polygon Poliqon yaradın + Pressure curve Təzyiq əyrisi + Click to add a point, right-click to remove Nöqtə əlavə etmək üçün klikləyin, silmək üçün sağ klikləyin + Soft Yumşaq + Hard Çətin + Some brush presets could not be loaded Bəzi fırça hafızası yüklənə bilmədi + Retry saving presets Əvvəlcədən parametrləri yadda saxlamağa yenidən cəhd edin + Brush preset changes are not saved: {error} Fırçanın əvvəlcədən təyin edilmiş dəyişiklikləri yadda saxlanmayıb: {error} + Keep this tab open to retain your brushes. Fırçalarınızı saxlamaq üçün bu nişanı açıq saxlayın. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Brauzer yaddaşı əlçatan deyil; fırçalar yalnız seans üçündür. Onları saxlamaq üçün bu tabı açıq saxlayın. + Saving brush presets… Keep this tab open until saving finishes. Fırçanın ilkin parametrləri yadda saxlanılır... Yadda saxlama tamamlanana qədər bu nişanı açıq saxlayın. + Reflect əks etdirin + Glow Parıltı + Negation İnkar + XOR XOR + Color Burn (Paint.NET) Rəngi tündləşdir (Paint.NET) + Color Dodge (Paint.NET) Rəngi işıqlandır (Paint.NET) + Stroke Options Vuruş Seçimləri + Solid Möhkəm + Dashed Kəsik + Dotted Nöqtəli + Caps Qapaqlar + Butt / Flat Butt / Yastı + Corners Künclər + Miter Gönye + Round Dəyirmi + Bevel əyilmə + Miter limit Mitre limiti + Dashed line kəsik xətt + Dash tire + Gap Boşluq + Dash / gap lengths × stroke width Tire / boşluq uzunluqları × vuruş eni + Add pair Cüt əlavə edin + Remove pair cütü çıxarın + Dash offset Dash ofset + Open paths use centered strokes Açıq yollar mərkəzləşdirilmiş vuruşlardan istifadə edir + Use finite lengths and a positive pattern total Sonlu uzunluqlardan və müsbət nümunə cəmindən istifadə edin + No subject found — try Object Selection or the Lasso Mövzu tapılmadı — Obyekt Seçimi və ya Kəməndi cəhd edin + Quick Selection found nothing there Tez Seçim orada heç nə tapmadı + Object Selection found nothing there — try a tighter rectangle Obyekt seçimi orada heç nə tapmadı — daha sıx düzbucaqlı sınayın + No subject found — try a rectangle around the object Mövzu tapılmadı — obyektin ətrafında düzbucaqlı sınayın + Delete layer ‘{name}’? ' {name} ' qatı silinsin? + Delete {count} layers? {names} {count} qatlari silinsin? {names} + Delete layer? Qat silinsin? + The document is closed Sənəd bağlanıb + The document or selected layers changed. Please request deletion again. Sənəd və ya seçilmiş qatlar dəyişdirildi. Lütfən, yenidən silinməyi tələb edin. + Missing delete command Sil əmri çatışmır + Invalid delete command Yanlış silmə əmri + Layer deleted, but confirmation preference could not be saved: {error} Qat silindi, lakin təsdiqləmə seçimini saxlamaq mümkün olmadı: {error} + Add a layer style Qat üslubu əlavə edin + Add a mask (from the selection; {key} inverts) Maska əlavə edin (seçimdən; {key} çevrilir) + Add an adjustment Düzəliş əlavə edin + Add to selection ({key}) Seçimə əlavə edin ( {key} ) + Adjustment visible Tənzimləmə görünür + Align and distribute Hizalayın və paylayın + Align horizontal centers Üfüqi mərkəzləri hizalayın + Align left edges Sol kənarları hizalayın + Align right edges Sağ kənarları hizalayın + Always use pressure for opacity Həmişə qeyri-şəffaflıq üçün təzyiqdən istifadə edin + Always use pressure for size Həmişə ölçü üçün təzyiqdən istifadə edin + Angle Gradient Bucaq Gradienti + Auto-Select: Avtomatik Seçim: + Brush Preset picker Əvvəlcədən təyin edilmiş fırça seçicisi + Cancel current crop operation (Esc) Cari kəsmə əməliyyatını ləğv edin (Esc) + Click the first point or press {key} to close · Esc cancels Birinci nöqtəyə klikləyin və ya bağlamaq üçün {key} düyməsini basın · Esc ləğv edir + Click the first point or press {key} to close · {del} removes a point · Esc cancels Birinci nöqtəyə klikləyin və ya bağlamaq üçün {key} düyməsini basın · {del} nöqtəni silir · Esc ləğv edir + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klikləyin, sonra kənar boyunca hərəkət edin · nöqtəni bərkitmək üçün klikləyin · {alt} -düz seqment üçün klikləyin · bağlamaq üçün iki dəfə klikləyin + Clip to layer below Aşağıdakı qatyə yapışdırın + Color & Swatches Rəng və nümunələr + Commit current crop operation ({key}) Cari kəsmə əməliyyatını yerinə yetirin ( {key} ) + Create a new group Yeni qrup yaradın + Create a new layer Yeni qat yaradın + Create new document from current state Cari vəziyyətdən yeni sənəd yaradın + Create new fill or adjustment layer Yeni doldurma və ya tənzimləmə qatı yaradın + Create new snapshot Yeni şəkil yaradın + From: Kimdən: + Full Document Tam sənəd + Merged Layers Birləşdirilmiş qatlar + Delete Cropped Pixels Kəsilmiş pikselləri silin + Delete current state Cari vəziyyəti silin + Delete layer Qatı silin + Diamond Gradient Almaz Gradient + Doc: {w} × {h} px Sənəd: {w} × {h} px + Enable airbrush-style build-up effects Airbrush üslubunda qurma effektlərini aktivləşdirin + Filter for adjustment layers Tənzimləmə qatlari üçün filtr + Filter for pixel layers Piksel qatlari üçün filtr + Filter for shape layers Forma qatlari üçün filtr + Filter for smart objects Ağıllı obyektlər üçün filtr + Filter for type layers Tip qatlari üçün filtr + Fixed Ratio Sabit nisbət + Fixed Size Sabit Ölçü + Follows only edges this close to the pointer ([ and ] change it) Yalnız göstəriciyə bu qədər yaxın olan kənarları izləyir ([ və ] onu dəyişdirin) + Hide Properties Xüsusiyyətləri gizlədin + Higher values follow only edges that contrast sharply with their surroundings Daha yüksək dəyərlər yalnız ətrafı ilə kəskin ziddiyyət təşkil edən kənarları izləyir + How often fastening points are placed by themselves Bərkitmə nöqtələri özləri tərəfindən nə qədər tez-tez yerləşdirilir + Hue/Saturation Rəng/Doyma + Intersect with selection ({key}) Seçimlə kəsişir ( {key} ) + Kind Növ + Linear Gradient Xətti Gradient + Link layers Bağlantı qatları + Lock Kilid + Lock all Hamısını kilidləyin + Lock image pixels Şəkil piksellərini kilidləyin + Lock layer Qatı kilidləyin + Lock position Kilid mövqeyi + Lock transparent pixels Şəffaf pikselləri kilidləyin + New selection Yeni seçim + No properties Əmlak yoxdur + Pressure for Opacity Opaklıq üçün təzyiq + Pressure for Size Ölçü üçün təzyiq + Prevent auto-nesting in and out of Artboards and Frames İş Lövhəsi və Çərçivələrin içərisinə və xaricinə avtomatik yerləşdirmənin qarşısını alın + Radial Gradient Radial qradient + Reflected Gradient Əks olunan qradient + Reset to defaults Defoltlara sıfırlayın + Set additional smoothing options Əlavə hamarlama seçimlərini təyin edin + Show Transform Controls Transform nəzarətlərini göstərin + Subtract from selection ({key}) Seçimdən çıxarın ( {key} ) + Swaps height and width Hündürlüyü və eni dəyişdirir + Tolerance Tolerantlıq + Add vector mask Vektor maskası əlavə edin + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Böyütmək üçün klikləyin · {key} -kiçiltmək üçün klikləyin · böyütmək/kiçiltmək üçün sağa/sola dartın + Appearance Settings… Görünüş Parametrləri… + Add to favorites Sevimlilərə əlavə edin + Remove from favorites Sevimlilərdən silin +cameraData Make İstehsalçı +cameraData Model Model +cameraData Date Taken Çəkilmə tarixi +cameraData Exposure Ekspozisiya müddəti +cameraData F-Stop Diafraqma +cameraData ISO Speed ISO həssaslığı +cameraData Focal Length Fokus məsafəsi +cameraData Focal Length (35 mm) Fokus məsafəsi (35 mm) +cameraData Lens Obyektiv + Activate Panel Paneli aktivləşdirin + Change Panel Group Panel Qrupunu dəyişdirin + Change Panel Layout Panel Düzenini dəyişdirin + Close Panel Paneli bağlayın + Dock Panel Dok Paneli + Float Panel Paneli ayır + Move Panel Paneli köçürün + Group with ilə qrup + Panel menu Panel menyusu + Resize panel window Panel pəncərəsinin ölçüsünü dəyişdirin + Resize panels Panellərin ölçüsünü dəyişdirin + Use Classic Mode Klassik rejimi istifadə edin + Auto Center Preview Önbaxışı avtomatik mərkəzləşdir + Next Document Növbəti Sənəd + Previous Document Əvvəlki Sənəd + W x H x Resolution G x Y x Çözünürlük + Front Image Ön şəkil + Frame Tool Çərçivə aləti + Create a new rectangular frame Yeni düzbucaqlı çərçivə yaradın + Create a new elliptical frame Yeni elliptik çərçivə yaradın + Include in playback Oxumağa daxil edin + Perspective Crop Tool Perspektiv kəsmə aləti + Show Grid Şəbəkəni göstərin + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Qutunu dartın və ya dörd küncə klikləyin · küncləri əyri formanın kənarlarına dartın · {commit} yerinə yetirir · Esc ləğv edir + Artboard Tool İş lövhəsi aləti + Make Portrait Portret çəkin + Make Landscape Landşaft yaradın + Add New Artboard Yeni İş lövhəsi əlavə edin + Freeform Pen Tool Sərbəst qələm aləti + Curvature Pen Tool Əyrilik qələmi aləti + Curve Fit: Əyri Uyğun: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Çək: əllə çəkin · {alt} -klik edin: düz seqment · Başlanğıc nöqtəsində buraxın: bağlayın + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klikləyin: hamar nöqtə · İki dəfə klikləyin: künc · Nöqtəni sürüşdürün: köçürün · Birinci nöqtəni basın: bağlayın · {key} bitir + Scale: Miqyas: + Snap to Origin Başlanğıca yapışdır + Link with Layer Layer ilə əlaqə + Rasterize Layer Style Layer Üslubunu Rasterləşdirin + Search settings… Axtarış parametrləri… + No matching settings Uyğun parametrlər yoxdur + Sampling Brush Tool Nümunə Fırçası Aləti + Sampling Area Overlay Nümunə alma sahəsinin üst-üstə düşməsi + Show Sampling Area Nümunə götürmə sahəsini göstərin + Indicates Göstərir + Sampling Area Nümunə götürmə sahəsi + Excluded Area İstisna Sahə + Sampling Area Options Nümunə Alma Sahəsi Seçimləri + Rectangular Düzbucaqlı + Fill Settings Doldurma Parametrləri + Rotation Adaptation Rotasiya Uyğunlaşması + Very High Çox yüksək + Full Tam + Mirror Güzgü + Output Settings Çıxış Parametrləri + Output To Çıxış üçün + Duplicate Layer Dublikat qat + Contract Müqavilə + Animation Options Animasiya Seçimləri + Entire Document Bütün sənəd + Filter Options Filtr Seçimləri + Layer Bounds Qat sərhədləri + Layers Panel Options Laylar Panel Seçimləri + Lock Layers Qatları Kilidləyin + New Artboard… Yeni iş lövhəsi… + New Group… Yeni Qrup… + Prevent Auto-Nesting Avtomatik Yerləşdirmənin qarşısını alın + Show Layers Qatları göstərin + Thumbnail Contents Miniatür məzmunu + Thumbnail Size Miniatür ölçüsü + Use Default Masks on Fill Layers Doldurma Qatlarında Defolt Maskalardan istifadə edin + Expand New Effects Yeni Effektləri genişləndirin + Add “copy” to Copied Layers and Groups Kopyalanan Layers və Qruplara "kopya" əlavə edin + Preview on canvas when reordering layers Qatları yenidən sıralayarkən kətan üzərində önizləmə + Show layer mask badges Qat maskası nişanlarını göstərin + Blending Options ({filter}) Qarışıq Seçimləri ( {filter} ) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Qatın effektlərini və Ağıllı Filtrləri gizlədin ( {key} -klik: bütün qatlar) + Show the layer's effects and Smart Filters ({key}-click: all layers) Qatın effektlərini və Ağıllı Filtrləri göstərin ( {key} -klik: bütün qatlar) + Wait for the current job to finish. Cari işin bitməsini gözləyin. + The document or Smart Filter changed. Reopen its dialog. Sənəd və ya Ağıllı Filtr dəyişdirildi. Onun dialoqunu yenidən açın. + Opacity must be between 0% and 100%. Şəffaflıq 0% ilə 100% arasında olmalıdır. + Dissolve is approximated: it blends without Photoshop's dither. Dissolve təqribəndir: o, Photoshop-un titrəməsi olmadan qarışır. + Loading fonts… Şriftlər yüklənir… + Response curve Cavab əyrisi + Tool Alət + Simulate Pressure Təzyiq simulyasiyası + Stroke Path… Yol boyunca kontur çək… + Background Eraser Fon Silgisi + Healing Brush Bərpa fırçası + Spot Healing Brush Nöqtəvi bərpa fırçası + Smudge Yayma + Sponge Süngər + Amount: Məbləğ: + Protect: Qorun: + Protect skin tones Dəri tonlarını qoruyun + Edit points to modify the curve Əyri dəyişdirmək üçün nöqtələri redaktə edin + Draw to modify the curve Əyri dəyişdirmək üçün çəkin +curves Smooth Hamarlama + Smooth the curve values Əyri dəyərləri hamarlayın + Drag to draw the curve · {key}-click draws a straight line Əyri çəkmək üçün dartın · {key} -klik edərək düz xətt çəkir + Grid Size: Şəbəkə ölçüsü: + Vanishing Point: click four corners to create a plane Ufuq nöqtəsi: təyyarə yaratmaq üçün dörd küncə klikləyin + Vanishing Point is not active Vanishing Point aktiv deyil + Apply Vanishing Point ({key}) Vanishing Point tətbiq edin ( {key} ) + Fix the red plane first: its corners must form a convex quad Əvvəlcə qırmızı müstəvini düzəldin: onun küncləri qabarıq dördlük təşkil etməlidir + Alt-click a plane to set the clone source Klon mənbəyini təyin etmək üçün Alt-klikləyin + Too many clone strokes: apply Vanishing Point first Həddindən artıq klon vuruşları: əvvəlcə Vanishing Point tətbiq edin + Onion Skin Soğan qabığı + Marching Ants Yürüyən Qarışqalar + On Black Qara + On White Ağ üzərində + On Layers Qatlarda + Show Edge Kənarı göstər + Show Original Orijinalı göstərin + F cycles the views; hold X to see the image F baxışları dövrələyir; şəkli görmək üçün X düyməsini basıb saxlayın + Refine Edge Brush (R) İncə Kənar Fırçası (R) + Expand Detection Area Aşkarlama sahəsini genişləndirin + Restore Original Edge Original Edge bərpa edin + Paint over hair or fur on the image; Alt paints the other mode Şəkil üzərində saç və ya xəz üzərində boya; Alt digər rejimi rəngləyir + Clear Brush Strokes Fırça vuruşlarını təmizləyin + Show rulers Xətkeşlərı göstərin + New Crop Preset… Yeni Kəsmə Əvvəlcədən Ayar… + Delete Crop Preset… Əvvəlcədən Kəsməni Sil… + Select the crop presets to delete: Silmək üçün kəsmə hafızası seçin: + Select the crop presets to delete Silmək üçün kəsmə hafızası seçin + Enter a name for the crop preset Əvvəlcədən kəsmə üçün ad daxil edin + There is no crop size or ratio to save Saxlamaq üçün kəsmə ölçüsü və ya nisbəti yoxdur + Presentation Təqdimat + Click to place · Alt-click or drag off-canvas to remove Yerləşdirmək üçün klikləyin · Alt-klikləyin və ya silmək üçün kətandan kənara dartın + No document open Açıq sənəd yoxdur + {n} of {max} sample points {max} nümunə nöqtələrinin {n} + New Snapshot… Yeni Snapshot… + History Options… Tarixçə Seçimləri… + Allow Non-Linear History Qeyri-xətti tarixçəyə icazə verin + Make Layer Visibility Changes Undoable Layer Görünüşü Dəyişikliklərini Geri Alınmaz edin + Emulsion Down Emulsiya Aşağı + Functions Funksiyalar + Print Resolution: Çap Çözünürlüğü: + Use Previous Layer to Create Clipping Mask Kəsmə maskası yaratmaq üçün əvvəlki qatdən istifadə edin + Fill with {mode}-neutral color (white) {mode} neytral rənglə doldurun (ağ) + Fill with {mode}-neutral color (black) {mode} neytral rənglə doldurun (qara) + Fill with {mode}-neutral color (50% gray) {mode} neytral rənglə doldurun (50% boz) + (No neutral color exists for {mode} mode.) ( {mode} rejimi üçün neytral rəng yoxdur.) + Apply mask to layer before removing? Maskanı çıxarmazdan əvvəl qatyə tətbiq edin? + The layer or its mask changed. Drag the mask onto the Trash again. Qat və ya onun maskası dəyişdi. Maskanı yenidən Zibil qutusuna çəkin. + Delete the vector mask? Vektor maskası silinsin? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {file} faylına {n} rəng nümunəsi ixrac edildi|{file} faylına {n} rəng nümunəsi ixrac edildi +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} qrupundan {n} fırça idxal edildi|{group} qrupundan {n} fırça idxal edildi +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} faylından {n} rəng nümunəsi idxal edildi|{file} faylından {n} rəng nümunəsi idxal edildi +@plural Played {n} step|Played {n} steps {n} addım icra edildi|{n} addım icra edildi +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Diskdə dəyişdirilmiş {n} əlaqəli ağıllı obyekt yeniləndi|Diskdə dəyişdirilmiş {n} əlaqəli ağıllı obyekt yeniləndi +@plural Saved for Web: {n} file|Saved for Web: {n} files Veb üçün saxlanıldı: {n} fayl|Veb üçün saxlanıldı: {n} fayl +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Bu kompozisiyada qeydə alınmış {n} qat silinib. Kompozisiyanı yeniləyin və ya xəbərdarlığı təmizləyin.|Bu kompozisiyada qeydə alınmış {n} qat silinib. Kompozisiyanı yeniləyin və ya xəbərdarlığı təmizləyin. +@plural {n} plane|{n} planes {n} müstəvi|{n} müstəvi +@plural {n} preset|{n} presets {n} hazır ayar|{n} hazır ayar +@plural {n} sample point removed|{n} sample points removed {n} nümunə nöqtəsi silindi|{n} nümunə nöqtəsi silindi +@plural {n} slice|{n} slices {n} dilim|{n} dilim +@plural {n} step|{n} steps {n} addım|{n} addım +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (hazır fırça ayarları barədə daha {n} xəbərdarlıq)|{warning} (hazır fırça ayarları barədə daha {n} xəbərdarlıq) +@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} xəbərdarlıq|{what}: {n} xəbərdarlıq + Light Blue Açıq Mavi + Light Red Açıq Qırmızı + Medium Blue Orta Göy + Light Gray Açıq boz + Object Finder Obyekt Tapıcı + No object found there — drag a rectangle around it Orada heç bir obyekt tapılmadı — onun ətrafında düzbucaqlı çəkin + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktiv qatnin burada pikseli yoxdur — Nümunə Bütün Qatları yandırın və ya pikselli qat seçin + Remove after each stroke Hər vuruşdan sonra çıxarın + Apply the removal ({key}) Silinməni tətbiq edin ( {key} ) + Strokes to remove: {n} (Enter applies, Esc cancels) Silinəcək vuruşlar: {n} (Daxil edin, Esc ləğv edir) + Load Contours… Konturları yükləyin… + Replace Contours… Konturları dəyişdirin… + Save Contours… Konturları Saxla… + Delete Contour Konturu silin + Loaded contours from {file} {file} -dən yüklənmiş konturlar + Saved contours to {file} Saxlanılan konturlar {file} + Show home recent files Ana səhifədə son faylları göstər + Clear Brush Controls Fırça nəzarətlərini təmizləyin + Reset All Locked Settings Bütün Kilidli Parametrləri Sıfırlayın + Copy Texture to Other Tools Teksturanı Digər Alətlərə Kopyalayın + Load Actions… Əməliyyatları Yüklə… + Save Actions… Əməliyyatları yadda saxla… + New Action… Yeni Fəaliyyət… + Start Recording Yazmağa başlayın + Stop Recording Qeydi dayandırın + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ( {note} ) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} faylından {n} əməliyyat yükləndi|{file} faylından {n} əməliyyat yükləndi +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} addımın hələ PhotoCraft qarşılığı yoxdur|{n} addımın hələ PhotoCraft qarşılığı yoxdur +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file} faylına {n} əməliyyat saxlanıldı|{file} faylına {n} əməliyyat saxlanıldı +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop qarşılığı olmayan {n} addım buraxıldı|Photoshop qarşılığı olmayan {n} addım buraxıldı + File Naming Faylın Adlandırılması + Starting serial Serialın başlanğıcı + Insert Stop… Dayandırın… + Save Set… Dəsti saxla… + Message: Mesaj: + Allow Continue Davam etməyə icazə verin + Show dialog during playback Oynatma zamanı dialoqu göstərin + Select an action first Əvvəlcə hərəkət seçin + Action playback stopped Fəaliyyətin səsləndirilməsi dayandırıldı + Step {step} is waiting for its dialog Step {step} dialoqunu gözləyir +@plural {n} action|{n} actions {n} əməliyyat|{n} əməliyyat + Background (Default) Fon (Defolt) + Content-Aware Fill Məzmundan xəbərdar Doldurma + Include All Subfolders Bütün alt qovluqları daxil edin + Auto Add/Delete Avtomatik Əlavə et/Sil + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Çap Parametrlərində Güzgü Şəklini də işarələməyin…: iki çevirmə ləğv edilir. + Print Settings… Çap Parametrləri… + Remember to turn off the printer's own colour management in Print Settings… Çap Parametrlərində printerin öz rəng idarəçiliyini söndürməyi unutmayın… + Sent to {printer} {printer} -ə göndərildi + set in Print Settings… Çap Parametrlərində təyin edin… + ICO is limited to 256 × 256 pixels ICO 256 × 256 piksellə məhduddur + Calibration Kalibrləmə + Red Primary Qırmızı İbtidai + Green Primary Yaşıl İbtidai + Blue Primary Mavi İlkin + Write xmp sidecars xmp yan arabalarını yazın + Set the tone sliders from the image Şəkildən ton sürgülərini təyin edin + Settings from {name} {name} -dən parametrlər + Settings from {name}; {count} not supported: {names} {name} -dən parametrlər; {count} dəstəklənmir: {names} + Could not write {name}: {error} {name} yazmaq mümkün olmadı: {error} + Kept {name}: written by another application Saxlanıldı {name} : başqa proqram tərəfindən yazılmışdır + Shadow Tint Kölgə Rəngi + Paints from the History panel's source state Tarix panelinin mənbə vəziyyətindən olan boyalar + Sets the source for the history brush Tarix fırçası üçün mənbə təyin edir + Select Inverse Tərs seçin + Pro (Light) Pro (İşıq) + Pro (Light Gray) Pro (Açıq Boz) + Label Etiket + Date and Time Tarix və vaxt + Scale Units Ölçək vahidləri + Scale Factor Ölçək Faktoru + Area Sahə + Perimeter Perimetr + Circularity Dairəvilik + Gray Value (Minimum) Boz Dəyər (Minimum) + Gray Value (Maximum) Boz Dəyər (Maksimum) + Gray Value (Mean) Boz Dəyər (Orta) + Gray Value (Median) Boz Dəyər (Media) + Integrated Density İnteqrasiya edilmiş sıxlıq + Length Uzunluq + Show Favorite Fonts Sevimli şriftləri göstərin + No favorite fonts yet: click the star beside a font to add it. Hələ sevimli şrift yoxdur: onu əlavə etmək üçün şriftin yanındakı ulduza klikləyin. + Favorite fonts Sevimli şriftlər + Add to Favorites Sevimlilərə əlavə edin + Remove from Favorites Sevimlilərdən silin + Show All Fonts Bütün şriftləri göstərin + All Classes Bütün siniflər + Blackletter Qara məktub + Decorative Dekorativ + Font preview: Şrift önizləməsi: + Handwritten Əl yazısı + Hide Font Classes Şrift siniflərini gizlədin + Hide Styles Üslubları gizlədin + Monospace Bərabərenli + No fonts match these filters. Bu filtrlərə uyğun şrift yoxdur. + No similar fonts: the current font doesn't say what it looks like. Oxşar şrift yoxdur: cari şrift onun necə göründüyünü demir. + OpenType font OpenType şrifti + Sans Serif Sans Serif + Script Ssenari + Serif Serif + Show Font Classes Şrift Siniflərini göstərin + Show Similar Fonts Oxşar şriftləri göstərin + Show Styles Üslubları göstərin + Show Variable Fonts Dəyişən şriftləri göstərin + Slab Serif Slab Serif + TrueType font TrueType şrifti + Variable font Dəyişən şrift + Highlight Mode Vurğulama rejimi + Highlight Color Vurğu Rəngi + Highlight Opacity Şəffaflığı vurğulayın + Shadow Mode Kölgə rejimi + Shadow Color Kölgə Rəngi + Shadow Opacity Kölgə qeyri-şəffaflığı + Check for Updates… Yeniləmələri yoxlayın… + Open the release page to choose an installer for your system. Sisteminiz üçün quraşdırıcı seçmək üçün buraxılış səhifəsini açın. + Automatically Check for Updates Yeniləmələri avtomatik yoxlayın + Use the embedded profile ({profile}) Daxili profildən istifadə edin ( {profile} ) + Convert to the working space ({profile}) İş sahəsinə çevirin ( {profile} ) + Remember this choice for future images Gələcək şəkillər üçün bu seçimi yadda saxlayın + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Redaktə ›Rəng Parametrlərində Uyğunsuzluğu Sorğu ilə əmri yenidən yandırın. + Button Mode Düymə rejimi + Button Mode On Düymə Rejimi Aktivdir + Darken Interface Qaranlıq interfeys + Lighten Interface İşıqlandırma interfeysi + Technique Texnika + Chisel Hard Sərt kəsik + Chisel Soft Yumşaq çisel + Open color-less EXR as ACEScg Rəngsiz EXR-ni ACEScg kimi açın + Do not append "copy" to filename when saving a copy Surəti saxlayarkən fayl adına "kopya" əlavə etməyin + Edit Selected Guides Seçilmiş bələdçiləri redaktə edin + Clear Selected Guides Seçilmiş Bələdçiləri Sil + Bicubic Automatic Avtomatik bikubik + Bicubic Smoother Daha hamar bikubik + Bicubic Sharper Daha kəskin bikubik + Pasting from other apps is unavailable Digər proqramlardan yapışdırmaq mümkün deyil + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft bu sessiyada sistem buferinə daxil ola bilmir. Wayland-da Flatpak qum qutusu digər proqramlardakı şəkillərin oxunduğu protokolu gizlədir. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Digər proqramlardan yapışdırmaq üçün XWayland altında PhotoCraft işə salın: ` {command} ` + Nothing to paste: PhotoCraft can't read the system clipboard here Yapışdırmaq üçün heç nə yoxdur: PhotoCraft burada sistem buferini oxuya bilmir + Delete this set Bu dəsti silin + New Set… Yeni Dəst… + PhotoCraft Defaults PhotoCraft Defoltlar + Save a list of these shortcuts as a web page Bu qısayolların siyahısını veb səhifə kimi yadda saxlayın + Save the changes to this set Dəyişiklikləri bu dəstdə yadda saxlayın + Save these shortcuts as a .kys file Bu qısa yolları .kys faylı kimi yadda saxlayın + Save these shortcuts as a new set Bu qısayolları yeni dəst kimi yadda saxlayın + Set deleted. Set silindi. + Set saved. Set saxlandı. + Set: Set: + Summarize… Ümumiləşdirin… + There are too many sets: delete one first. Çox çox dəst var: əvvəlcə birini silin. + set name adı təyin edin + {set} (modified) {set} (dəyişdirilmiş) + Random Təsadüfi + Show file name in title Başlıqda fayl adını göstərin + 50% Threshold 50% eşik + Pattern Dither Naxışlı titrəmə + Diffusion Dither Diffuziya Dither + Halftone Screen Yarım ton ekranı + Custom Pattern Fərdi Nümunə + Frequency: Tezlik: + Lines/Inch Xətlər/düym + Lines/Centimeter Xətlər/Santimetr + Ellipse Ellips + Diamond Almaz + Cross Xaç + Accept and Go to Conflict Qəbul edin və Münaqişəyə gedin + Changes undone. Dəyişikliklər geri alındı. + Choose a new shortcut for this command, or leave it without one. Bu əmr üçün yeni qısayol seçin və ya onu olmadan buraxın. + Put this command's shortcuts back as they were Bu əmrin qısa yollarını olduğu kimi geri qoyun + Take the shortcut from the other command and select it Digər komandadan qısa yolu götürün və onu seçin + Undo Changes Dəyişiklikləri geri qaytarın + {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tərəfindən qorunur və təyin edilə bilməz. + Low-Latency Drawing (vsync off, applies at next launch) Aşağı Gecikmə Rəsmi (vsync söndürülür, növbəti işə salındıqda tətbiq olunur) + Free Transform is still being applied; try again when it has finished. Sərbəst Transformasiya hələ də tətbiq edilir; bitdikdə yenidən cəhd edin. + The transform couldn't be applied. Çevrilmə tətbiq oluna bilmədi. + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Onun qatlari GPU yaddaşının {needed} tələb edir; büdcə onları tərk edir {budget} . + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Üstünlüklərdə GPU Yaddaşını təyin edin › Qrafik kartınızın yaddaşının ən çoxu 3/8-i tuta biləcəyi performans. Avtomatik bu kompüterin yaddaşının dörddə biri və kartın 3/8-i daxilində Yaddaş İstifadəsini izləyir və yaddaşı məlum olan kartla sənədi saymır. Vəziyyət paneli uyğun gəlmədiyi halda GPU Yaddaşının Dolu olduğunu göstərir. + GPU Memory Full GPU yaddaşı doludur + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Qatlara GPU yaddaşının {needed} ehtiyacı var və {budget} tuta bilər, ona görə də bütün sənədi yenidən çəkən redaktələr bunu CPU-da edir. GPU Yaddaşını qurmaq üçün klikləyin. + GPU memory budget: {budget} GPU yaddaş büdcəsi: {budget} + Graphics memory: {size} Qrafik yaddaş: {size} + This document's layers: {needed} of {budget} for layers Bu sənədin qatlari: qatlar üçün {needed} və {budget} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu sənədin qatlari {needed} tələb edir, lakin {budget} saxlaya bilər: bütün sənədin yenidən çəkilişləri CPU-dan istifadə edir + GPU Memory (MB, 0 = Automatic) GPU yaddaşı (MB, 0 = Avtomatik) + {path}: the path is not valid Unicode; save under another name. {path} : yol etibarlı Unicode deyil; başqa ad altında qeyd edin. + Replace dəyişdirin + {name} was changed by another app after it was opened or saved here. {name} burada açıldıqdan və ya saxlandıqdan sonra başqa proqram tərəfindən dəyişdirildi. + Replace it with this document, or cancel and use File › Save As to keep both. Onu bu sənədlə əvəz edin və ya ləğv edin və hər ikisini saxlamaq üçün Fayl › Fərqli Saxla seçin. + (coming soon) (tezliklə) + (none) (heç biri) + 32-bit Preview Options 32-bit Önizləmə Seçimləri + 3D… 3D… + A: A: + AI Integrations AI inteqrasiyaları + About {app} {app} haqqında + Accented Edges Vurğulanmış kənarlar + Action playback failed: {error} Fəaliyyət oxutma uğursuz oldu: {error} + Actual Pixels Həqiqi piksellər + Add Mask Maska əlavə edin + Add Noise Səs-küy əlavə edin + Add a mask (from the selection; ⌥ inverts) Maska əlavə edin (seçimdən; ⌥ çevirir) + Add space after paragraph Paraqrafdan sonra boşluq əlavə edin + Add space before paragraph Abzasdan əvvəl boşluq əlavə edin + Add to selection (⇧) Seçimə əlavə edin (⇧) + Added swatch {name} {name} nümunəsi əlavə edildi + Adds grain to the soft edges of the brush tip. Fırça ucunun yumşaq kənarlarına taxıl əlavə edir. + Adjust Interval For Loops Döngələr üçün intervalı tənzimləyin + Adjustment Layers Tənzimləmə Layları + Adjustment Properties Tənzimləmə xassələri + Adjustment layers have nothing to transform Tənzimləmə qatlarinin çevriləcək heç nəsi yoxdur + Adobe PDF Presets… Hazır Adobe PDF ayarları… + Adobe-rgb-compat Adobe-rgb-uyğundur + Adwaita (Dark) Adwaita (Qaranlıq) + Adwaita (Light) Adwaita (İşıq) + After Sonra + Airbrush: keeps painting while the pointer rests. Airbrush: göstərici dayanarkən rəngləməyə davam edir. + All shortcuts reset to Photoshop defaults. Bütün qısayollar Photoshop defoltlarına sıfırlanır. + Altitude Hündürlük + Ambience Mühit + Ambient Ətraf mühit + Amplitude Max Amplituda Maks + Amplitude Min Amplituda Min + Android 1080p Android 1080p + Angled Strokes Bucaqlı vuruşlar + Anisotropic Anizotrop + Any İstənilən + Apple Watch 45mm Apple Watch 45 mm + Apply Data Set Məlumat dəstini tətbiq edin + Apply Image Şəkil tətbiq edin + Apply Mask Maska tətbiq edin + Apply to All Hamıya müraciət edin + Apply {option}: {state} {option} tətbiq edin: {state} + Arbitrary Özbaşına + Arc Lower Aşağı Qövs + Arc Upper Üst qövs + Are you sure you want to quit? İşdən çıxmaq istədiyinizə əminsiniz? + Are you sure? Əminsinizmi? + Around Center Mərkəzin ətrafında + Arrangement Aranjıman + Arrows Oxlar + Artboard from Group Qrupdan iş lövhəsi + Artboard from Layers Qatlardan iş lövhəsi + Artboards İş lövhələri + Artboards to Files İş lövhələrindən Fayllara + Artboards to PDF İş lövhələrini PDF-ə ixrac et + Artistic Bədii + Ask On Mismatch Uyğunsuzluğu soruşun + Ask On Missing İtkin haqqında soruşun + Ask On Paste Yapışdırarkən soruş + Assign Profile Profil təyin edin + At At + Author Title Müəllif adı + Auto Scale Avtomatik Ölçək + Auto-Align Layers Qatları Avtomatik Hizalayın + Auto-Blend Layers Qatların avtomatik qarışdırılması + Autosave failed: {error} Avtomatik saxlama uğursuz oldu: {error} + Back Geri + Backend: {backend} ({type}) Arxa tərəf: {backend} ( {type} ) + Background Level Fon Səviyyəsi + Bamboo Bambuk + Bas Relief Bas Relyef + Base Tree Type Əsas ağac növü + Based On əsasında + Baseline shift Baza dəyişikliyi + Basic Latin Əsas Latın + Batch Dəstə + Before Əvvəl + Bend əyilmək + Berries Giləmeyvə + Best Ən yaxşısı + Black Clip Qara Klip + Black Intensity Qara intensivliyi + Blade Curvature Bıçağın əyriliyi + Blast Partlayış + Bleach Bypass Ağardıcı bypass + Blend Clipped Layers as Group Qrup şəklində kəsilmiş qatlari qarışdırın + Blend Interior Effects as Group Daxili Effektləri Qrup olaraq qarışdırın + Blend Text Gamma Mətn qarışdırma qamması + Blending Mode Qarışdırma rejimi + Bloat Şişkinlik + Blocks Bloklar + Blue Yellow Mavi Sarı + Blues Blues + Border Sərhəd + Border Thickness Sərhəd qalınlığı + Box Blur Qutu Bulanıqlığı + Bpc Bpc + Branches Color Budaqların Rəngi + Branches Height Filialların hündürlüyü + Branches Thickness Budaqların qalınlığı + Breeze Dark Qaranlıq meh + Breeze Light Breeze İşıq + Brick Kərpic + Bristle Detail Kıl detalı + Browse Gözdən keçirin + Brush Detail Fırça detalı + Brush Settings… (F5): every option of this brush Fırça Parametrləri... (F5): bu fırçanın hər bir seçimi + Brush Strokes Fırça vuruşları + Brush Type Fırça növü + Burlap Çuval bezi + Calculations Hesablamalar + Camera Raw Kamera Raw + Camera Raw Filter Camera Raw filtri + Cancel transform Çevrilməni ləğv edin + Candle Light Şam işığı + Canvas Size Kətan Ölçüsü + Case Sensitive Hərflərə Həssas + Ccw Ccw + Cell Size Hüceyrə ölçüsü + Center X Mərkəz X + Center Y Mərkəz Y + Chalk Area Təbaşir sahəsi + Change Find Tapmağı dəyişdirin + Changes Saved Dəyişikliklər Saxlandı + Changes have not been saved. Dəyişikliklər yadda saxlanmayıb. + Change… Dəyiş… + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Charcoal Area Kömür sahəsi + Charcoal Thickness Kömür qalınlığı + Check for Updates Yeniləmələri yoxlayın + Checkerboard Dama taxtası + Choke Boğulma + Choose Folder Qovluq seçin + Circle Dairə + Cleanliness Təmizlik + Clear Amount Təmiz məbləğ + Clear Selection Seçimi təmizləyin + Click sets foreground · right-click sets background Ön plana klikləyin · arxa plana sağ klikləyin + Click the first point or press ↵ to close · Esc cancels Birinci nöqtəyə klikləyin və ya bağlamaq üçün ↵ basın · Esc ləğv edir + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Nöqtə əlavə etmək üçün klikləyin · kənara çəkin və ya silmək üçün ⌘/Ctrl düyməsini sıxın və ya Sil + Click to apply, ⌥-click to clear overrides, double-click for Style Options Tətbiq etmək üçün klikləyin, ləğvetmələri silmək üçün ⌥-klikləyin, Stil Seçimləri üçün iki dəfə klikləyin + Click to zoom in · ⌥-click to zoom out Böyütmək üçün klikləyin · ⌥-kiçiltmək üçün klikləyin + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klikləyin: künc · Çək: hamar · Birinci nöqtəyə klikləyin: bağla · ↩ bitir · Esc ləğv + Clipping Mask Kəsmə maskası + Clipping Path… Kəsmə yolu… + Clone Klon + Clone source set at {x}, {y} {x} , {y} -də klon mənbəyi təyin edildi + Close Window Pəncərəni Bağlayın + Clumped Yığılmış + Cmyk Cmyk + Coarse Dots Kobud nöqtələr + Coated-cmyk örtüklü-cmyk + Collapse group (⌥-click: all groups) Qrupu yığcamlaşdırın (⌥ klikləyin: bütün qruplar) + Color Decontaminate Rəngi zərərsizləşdirin + Color Halftone Rəngli Yarımton + Color Indicates Masked Areas Rəng maskalı əraziləri göstərir + Color Indicates Selected Areas Rəng Seçilmiş Sahələri Göstərir + Color Lookup Tables Rəng Axtarış Cədvəlləri + Color Picker Rəng Seçici + Color Picker (Background Color) Rəng Seçici (Arxa Fon Rəngi) + Color Picker (Custom Canvas Color) Rəng Seçici (Fərdi Kətan Rəngi) + Color Picker (Foreground Color) Rəng Seçici (Ön Plan Rəngi) + Color Sampler Tool Rəng nümunəsi aləti + Color Settings Rəng Parametrləri + Color Swatches Rəng nümunələri + Color Table Rəng Cədvəli + Color management: {error} Rəngin idarə edilməsi: {error} + Colored Pencil Rəngli Qələm + Comic Book Komiks + Commit Perspective Warp (↩) Perspektiv deformasiyasını tətbiq et (↩) + Commit Puppet Warp (↩) Kukla deformasiyasını tətbiq et (↩) + Commit any current edits (⌘↩) İstənilən cari redaktə edin (⌘↩) + Commit current crop operation (↵) Cari kəsmə əməliyyatını yerinə yetirin (↵) + Commit transform Transformasiyanı həyata keçirin + Commit transform (↩) Transformasiyaya əməl edin (↩) + Commit warp (↩) Deformasiyanı tətbiq et (↩) + Compare Müqayisə edin + Conditional Mode Change Şərti rejimin dəyişdirilməsi + Confirm Təsdiq edin + Conform Uyğundur + Constraints Məhdudiyyətlər + Content-Aware Fill needs a selection on a pixel layer Məzmundan xəbərdar Doldurma piksel qatında seçim tələb edir + Contrasty Kontrast + Convert Çevir + Convert to Alpha Channel Alfa Kanalına çevirin + Convert to Linked Bağlıya çevirin + Convert to Profile Profilə çevirin + Cool Sərin + Cooling L B B Soyutma L B B + Cooling80 Soyutma80 + Cooling82 Soyutma82 + Copy Complete Stroke Tam Vuruşu Kopyala + Copy Fill Dolduru Kopyala + Copyright Url Müəllif hüququ URL-i + Correct C A Düzgün C A + Correct Distortion Düzgün təhrif + Correct Vignette Düzgün vinyet + Could not rename the action: {error} Fəaliyyətin adını dəyişmək mümkün olmadı: {error} + Could not transform: the layer is empty Transformasiya edilə bilmədi: qat boşdur + Could not use Perspective Warp: the layer is empty Perspektiv Çarpma istifadə edilə bilmədi: qat boşdur + Could not use Puppet Warp: no opaque pixels Kukla Çarpması istifadə edilə bilmədi: qeyri-şəffaf piksel yoxdur + Could not use Puppet Warp: the layer is empty Kukla Çözgü istifadə edilə bilmədi: qat boşdur + Couldn't open {name}: {error} {name} aça bilmədi: {error} + Couldn't save preferences: {error}. Retrying… Tərcihləri saxlamaq mümkün olmadı: {error} . Yenidən cəhd edilir… + Crack Brightness Çat parlaqlığı + Crack Depth Çatların Dərinliyi + Crack Spacing Çatlaq Aralığı + Craquelure Çat effekti + Create By Yarat + Create Vector Mask Vektor maskası yaradın + Create Warped Text Çarpıq mətn yaradın + Create a new layer (⇧⌘N) Yeni qat yaradın (⇧⌘N) + Create new brush from the current settings Cari parametrlərdən yeni fırça yaradın + Create new brush preset from the current settings Cari parametrlərdən yeni fırça ilkin təyinatı yaradın + Create… Yarat… + Cross Process Çarpaz Proses + Crosshatch Qarşılaşma + Crosswise Çarpaz + Crystallize Kristallaşmaq + Currency Valyuta + Curve Darks Əyri Qaranlıqlar + Curve Highlights Əyrinin işıqlı sahələri + Curve Lights Əyri İşıqlar + Curve Shadows Əyri kölgələr + Curve Splits Əyrilərin bölünməsi + Custom Branch Color Xüsusi Filial Rəngi + Custom Pixel Aspect Ratio Fərdi Piksel Aspekt Nisbəti + Custom pattern Fərdi naxış + Cutout Kəsmə + Cw Cw + Cyans Cyans + Cyrillic Kiril əlifbası + Daisies Papatyalar + Dark Gray Tünd Boz + Dark Intensity Qaranlıq İntensivlik + Dark Rough Tünd və nahamar + Dark Strokes Tünd fırça izləri + Darken Only Yalnız Qaranlıq + Darkness Qaranlıq + Data Məlumat + Data Set: {name} ({index}/{total}) Məlumat dəsti: {name} ( {index} / {total} ) + Data Sets as Files Fayllar kimi məlumat dəstləri + Date Tarix + Day For Night Gündüz Gecə + De-Interlace Sətirarası hörülməni aradan qaldır + Decrease Brush Hardness Fırçanın sərtliyini azaldın + Decrease Brush Size Fırçanın ölçüsünü azaldın + Decrease Type Size Növ ölçüsünü azaldın + Decrease Type Size by 10 Tip ölçüsünü 10 azaldın + Deep Dərin + Deep Blue Tünd mavi + Deep Emerald Dərin Zümrüd + Deep Red Tünd Qırmızı + Deep Yellow Tünd Sarı + Default Foreground and Background Colors Defolt Ön və Fon Rəngləri + Default Leaves Defolt yarpaqlar + Default Photoshop Size Defolt Photoshop Ölçüsü + Defaults Standartlar + Define Brush Preset Fırçanın Əvvəlcədən təyinini təyin edin + Define Custom Shape Fərdi Formanı müəyyənləşdirin + Define Pattern Nümunəni müəyyənləşdirin + Definition Tərif + Defringe Məhdudlaşdırma + Degrees Dərəcələr + Delete Channel Kanalı silin + Delete Mask Maskanı silin + Delete Workspace İş yerini silin + Delete brush Fırçanı silin + Delete path Yolu silin + Delete vector mask Vektor maskasını silin + Delete… Sil… + Depth Map Dərinlik xəritəsi + Depth Mode Dərinlik rejimi + Diameter Çap + Diffuse Diffuz + Diffuse Glow Diffuz parıltı + Direction İstiqamət + Direction Balance İstiqamət balansı + Disable Deaktiv edin + Disable Mask Maskanı söndürün + Disable Symmetry Path Simmetriya yolunu deaktiv edin + Disabled Deaktivdir + Discard Changes Dəyişiklikləri ləğv edin + Displace yerdəyişmə + Display Ekran + Display profile not used Ekran profili istifadə edilmir + Display profiles: {reason} Profilləri göstərin: {reason} + Display-p3 Ekran-p3 + Distance Məsafə + Distortion Təhrif + Distribution Paylanma + Do you want to save you made to “{name}” before quitting? İşdən çıxmazdan əvvəl " {name} " üçün etdiyinizi saxlamaq istəyirsiniz? + Document Sizes Sənəd Ölçüləri + Documentation Sənədləşdirmə + Documents Sənədlər + Dont Enlarge Böyütməyin + Dot Nöqtə + Double Line İkiqat xətt + Down Aşağı + Draft Qaralama + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Kəsmə qutusunu dartın · köçürmək üçün içəri sürükləyin · kənarların ölçüsünü dəyişdirin ( {ratio} nisbəti, {centre} mərkəzi) · Rəsm zamanı boşluq hərəkət edir · {commit} yerinə yetirilir · Esc ləğv edilir + Drag: draw freehand · Release on the start point: close Çək: əllə çəkin · Başlanğıc nöqtəsində buraxın: bağlayın + Driver: {driver} Sürücü: {driver} + Dry Brush Quru Fırça + Duplicate Channel Dublikat Kanal + Duplicate Layers Dublikat qatlar + Duplication Dublikasiya + Dust & Scratches Toz və cızıqlar + Edge Kənar + Edge Brightness Kənar Parlaqlığı + Edge Extension Kənar Uzatma + Edge Fidelity Kənar dəqiqliyi + Edge Intensity Kənarın intensivliyi + Edge Only Yalnız kənar + Edge Simplicity Kənar sadəliyi + Edge Thickness Kənar qalınlığı + Edge Width Kenar eni + Edit Toolbar Alətlər panelini redaktə edin + Edit… Redaktə… + Elevation Yüksəklik + Eliminate aradan qaldırın + Emboss Qabartma + Enable Aktivləşdirin + Enable Mask Maskanı aktivləşdirin + End X X sonu + End Y Son Y + Enlarged Genişləndirilmiş + Erase with Brush or Pencil (hold) Fırça və ya Qələmlə silin (basılı tutun) + Error Xəta + Even Fields Hətta sahələr + Exit Quick Mask Tez Maskadan çıxın + Expand group (⌥-click: all groups) Qrupu genişləndirin (⌥ klikləyin: bütün qruplar) + Export As Fərqli ixrac et + Export As: {name} İxrac: {name} + Export Contents Məzmunu ixrac edin + Export Image Şəkili ixrac edin + Export Options İxrac Seçimləri + Export Preferences İxrac üstünlükləri + Export Settings İxrac Parametrləri + Export to İxrac et + Export/Import Presets Hazır ayarları ixrac/idxal et + Exported contents to {path} {path} -ə məzmun ixrac edildi + Exported presets to {path} Əvvəlcədən təyinlər {path} -ə ixrac edildi + Exported {path} İxrac edilmiş {path} + Exported {path} ({size}) İxrac edilmiş {path} ( {size} ) + Exporting… İxrac olunur… + ExtraBold Çox qalın + ExtraLight Həddindən artıq incə + Extrude Extrude + Fade Solğun + Faded Film Solğun Film + Failed Uğursuz oldu + Failed to open file Faylı açmaq alınmadı + Failed to save file Faylı saxlamaq alınmadı + Fallback: {reason} Geri qayıtma: {reason} + Feather Radius Lələk radiusu + Fiber Length Fiber Uzunluğu + Fibers Liflər + Field Blur Sahə bulanıqlığı + File Format Fayl formatı + File Info Fayl məlumatı + File Name Fayl adı + File Size Fayl ölçüsü + File not found Fayl tapılmadı + Filename Fayl adı + Fill Color Doldurma rəngi + Fill Opacity Şəffaflığı doldurun + Fill Path… Yol doldurun… + Fill path with foreground color Ön plan rəngi ilə yolu doldurun + Fill with Background Color Fon Rəngi ilə Doldurun + Fill with Background Color, Preserve Transparency Fon rəngi ilə doldurun, şəffaflığı qoruyun + Fill with Foreground Color Ön Plan Rəngi ilə Doldurun + Fill with Foreground Color, Preserve Transparency Ön plan rəngi ilə doldurun, şəffaflığı qoruyun + Film Grain Film Taxıl + Filter Gallery Filtr Qalereyası + Find Tap + Find and Replace Text Mətni tapın və dəyişdirin + Fine Yaxşı + Fine Dots İncə nöqtələr + Finish Bitir + Fit Image Şəkli ölçüyə uyğunlaşdır + Fit to Window Pəncərəyə sığdır + Flame Alov + Flame Bottom Alignment Alovun alt hizalanması + Flame Lines Alov xətləri + Flame Shape Alov forması + Flame Style Alov üslubu + Flame Type Alov növü + Flat Düz + Flat Shading Düz kölgələmə + Flower Color Çiçək Rəngi + Focal Distance Fokus məsafəsi + Focus Fokus + Focus Area Fokus sahəsi + Foreground Level Ön plan səviyyəsi + Forward İrəli + Forward Warp İrəli əyilmə + Frame Çərçivə + Frame {current} / {total} Çərçivə {current} / {total} + Free Transform Path Sərbəst Çevrilmə Yolu + Freeze Lasso Kəməndi dondurun + Freeze Mask Dondurma maskası + Fresco Fresk + From Kimdən + From Left Soldan + From Right Sağdan + Frosted Şaxtalı + Full Screen Tam ekran + Fullscreen Tam ekran + Fuzziness Qeyri-səlislik + GPU backend preference: {backend} GPU backend üstünlükləri: {backend} + GPU device was lost; using the CPU renderer. GPU cihazı itirildi; CPU renderindən istifadə etməklə. + GPU error; using the CPU renderer. GPU xətası; CPU renderindən istifadə etməklə. + GPU retry scheduled GPU yenidən cəhdi planlaşdırılıb + Gamma Qamma + Gaussian Blur Qauss bulanıqlığı + General Blending Ümumi qarışdırma + General Brushes Ümumi Fırçalar + Generators Generatorlar + Generic Ümumi + Glass Şüşə + Global Light Qlobal İşıq + Gloss Parıldadıcı + Glow Amount Parıltı miqdarı + Glow Brightness Parlaq Parlaqlıq + Glow Size Parıltı Ölçüsü + Glowing Edges Parıldayan kənarlar + Good Yaxşı + Grade Balance Qiymət balansı + Grade Blending Dərəcə qarışdırma + Grade Global Qlobal dərəcə + Grade Highlights Qiymətləndiricilər + Grade Midtones Orta tonlar + Grade Shadows Dərəcəli Kölgələr + Gradient Options Gradient Seçimləri + Grain Amount Taxıl miqdarı + Grain Roughness Taxıl kobudluğu + Grain Size Taxıl ölçüsü + Grain Type Taxıl növü + Graininess Dənəlik + Grainy Dots Dəli Nöqtələr + Graphics adapter Qrafik adapter + Graphics adapter: {adapter} Qrafik adapter: {adapter} + Gray-gamma-2.2 Boz-qamma-2.2 + Greek Yunan + Greens Yaşıllar + Grid (⌘') Şəbəkə (⌘') + Grid view Şəbəkə görünüşü + Group from Layers Qatlardan qrup + Groups Qruplar + Grout Width Məhlulun eni + Guide Layout… Bələdçi Düzeni… + HDR Toning HDR Tonlama + Hand Tool (hold) Əl aləti (tutma) + Hearts Ürəklər + Heptagon Heptaqon + Hexagon Altıbucaqlı + Hidden Gizli + Hide Grid Şəbəkəni gizlət + Hide Others Başqalarını gizlət + Hide the layer's effects (⌥-click: all layers) Qatın effektlərini gizlədin (⌥ klikləyin: bütün qatlar) + Hide {app} {app} gizlədin + High Pass Yüksək tezlik filtri + Highlight Vurğulayın + Highlight Amount Məbləği vurğulayın + Highlight Area Vurğulama sahəsi + Highlight Radius Vurğulama radiusu + Highlight Strength Gücü vurğulamaq + Highlight Tone Vurğulama Tonu + Highlights from: Əsas məqamlar: + Horizontal Distortion Üfüqi təhrif + Horizontal Only Yalnız Horizontal + Horizontal scale Üfüqi miqyas + Horizontal type Üfüqi tip + Hotspot Qaynar nöqtə + Hsb Hsb + Hsl Hsl + Hsl Hue Hsl Hue + Hsl Lum Hsl Lum + Hsl Sat Hsl Şənbə + Image Balance Şəkil balansı + Image Processor Şəkil prosessoru + Image compositor: {processor} Şəkil tərtibatı: {processor} + Import Brushes Fırçaları idxal edin + Import Brushes… (.abr) Fırçaları idxal edin... (.abr) + Import Image Şəkil idxal edin + Import Options İdxal Seçimləri + Import Photoshop brushes (.abr) Photoshop fırçalarını idxal edin (.abr) + Import Settings İdxal Parametrləri + Importing {what} from {file}… {file} -dən {what} idxal edilir… + Importing… İdxal olunur… + In Black Qara + In White Ağ rəngdə + Increase Brush Hardness Fırçanın sərtliyini artırın + Demosaic Demozaik + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kameranın rəng filtri mozaikası necə doldurulur. Fayl açıldığında tətbiq edilir; önizləmə onun açıldığı metodu göstərir. + AHD (adaptive homogeneity) AHD (adaptiv homojenlik) + Malvar–He–Cutler Malvar – He – Katler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakava-Parklar: ən təmiz kənarlar və ən az rəng saçaqlığı, ən yavaşı. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Qradiyentlə düzəldilmiş interpolyasiya: kəskin və sürətli, incə detallarda bəzi rəng saçaqları ilə. + The fastest and the softest, with colour fringing on fine detail. Ən sürətli və ən yumşaq, incə detallarda rəngli saçaqlı. + “{name}” already exists. Replace it? “ {name} ” artıq mövcuddur. Əvəz edilsin? + The name in the save dialog was “{name}”. Saxlama dialoqunda ad “ {name} ” idi. + Move Up Yuxarı hərəkət edin + Move Down Aşağı hərəkət edin + Previous Brush Əvvəlki Fırça + Next Brush Növbəti fırça + First Brush İlk Fırça + Last Brush Son Fırça + Preview size Önizləmə ölçüsü + Memory Usage Yaddaş İstifadəsi + Per-document RAM limit (MB) Sənəd başına RAM limiti (MB) + Installed RAM: {ram} MB; this limit: {percent}% Quraşdırılmış RAM: {ram} MB; bu limit: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Hər açıq sənədin piksellərini və Tarixçəni məhdudlaşdırır. Həddini aşdıqda köhnə geri qaytarma halları ləğv edilir. + Applies immediately when saved. Memory is not reserved in advance. Yadda saxlandıqda dərhal tətbiq edilir. Yaddaş əvvəlcədən qorunmur. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU Yaddaşının Avtomatik olaraq təyin edilməsi ilə bu, həm də GPU qatsinin büdcəsini məhdudlaşdırır. Bu büdcə quraşdırılmış RAM-ın dörddə biri və məlum olduqda, minimum 512 MB olan qrafik kartının yaddaşının səkkizdə üçü ilə məhdudlaşır. Bu həddi həmin hədlərdən kənara qaldırmaq GPU büdcəsini artırmır. + Command Əmr + Use İstifadə edin + In Front Öndə + Behind Arxada + DDS Options DDS Seçimləri + Compression Sıxılma + Generate Mipmaps Mipmaps yaradın + There is no color table to save. Saxlamaq üçün rəng cədvəli yoxdur. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file} faylına {n} rəng saxlanıldı|{file} faylına {n} rəng saxlanıldı + GPU adapter GPU adapteri + Import Styles… Üslubları idxal edin… + Export Selected Styles… Seçilmiş Üslubları Eksport edin… + Export Styles… Üslubları ixrac et… + {file} is not a style library (.asl) {file} üslub kitabxanası deyil (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file} faylından {n} üslub idxal edildi|{file} faylından {n} üslub idxal edildi +@plural Exported {n} style to {file}|Exported {n} styles to {file} {file} faylına {n} üslub ixrac edildi|{file} faylına {n} üslub ixrac edildi + Confirm or cancel the open action step dialog first Əvvəlcə açıq fəaliyyət addımı dialoqunu təsdiq edin və ya ləğv edin + Pro Medium Gray Pro Orta Boz + Solarized Solarizasiya + Breeze meh + Pro Light Gray Pro Açıq Boz + Pro Light Pro İşıq + Resize Windows to Fit Uyğunlaşdırmaq üçün Windows ölçüsünü dəyişdirin + EXR EXR + Increase Brush Size Fırçanın ölçüsünü artırın + Increase Type Size Növ ölçüsünü artırın + Increase Type Size by 10 Tip ölçüsünü 10 artırın + Indent first line Birinci sətirdən girinti + Indent left margin Sol kənar boşluğu daxil edin + Indent right margin Sağ kənardan girinti + Indexed İndekslənmiş + Infinite Sonsuz + Information Məlumat + Initial Direction İlkin istiqamət + Ink Outlines Mürəkkəb konturları + Inner Bevel Daxili əyilmə + Input Giriş + Input Levels Giriş səviyyələri + Input Mode Giriş rejimi + Install Plug-in Plug-in quraşdırın + Intensity İntensivlik + Intent Niyyət + Interpret Footage Görüntüləri şərh edin + Intersect with selection (⇧⌥) Seçimlə kəsişir (⇧⌥) + Interval Interval + Invalid panel layout; restored the default dock Yanlış panel düzümü; standart dok bərpa etdi + Invert Mask Ters maska + Invert Selection Seçimi ters çevir + Iris Blur İris bulanıqlığı + Ivy Sarmaşıq + Jag Jag + Join the ArtCraft Discord ArtCraft Discord-a qoşulun + Join the ArtCraft Discord ({url}) ArtCraft Discord-a qoşulun ( {url} ) + Keeps the current texture when switching presets. Əvvəlcədən parametrləri dəyişdirərkən cari fakturanı saxlayır. + Kernel Kernel + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: Metriklər, Optik və ya 1/1000 em dəyər (Alt+←/→ karetdə) + Keyboard Klaviatura + Lab Laboratoriya + Landscape, 10 x 8 Landşaft, 10 x 8 + Landscape, 6 x 4 Landşaft, 6 x 4 + Landscape, 7 x 5 Landşaft, 7 x 5 + Latin 1 Latın 1 + Latin Extended-A Latın Genişləndirilmiş-A + Latin Extended-B Latın Genişləndirilmiş-B + Layer Comps to Files Qat kompozisiyalarını fayllara ixrac et + Layer Content Options Layer Məzmun Seçimləri + Layer Count Qat sayı + Layer From Background Fondan Layer + Layer Knocks Out Drop Shadow Layer Düşən Kölgədən Çıxarır + Layer Mask Hides Effects Qat maskası effektləri gizlədir + Layer Properties Layer Xüsusiyyətləri + Layer Styles Qat Üslubları + Layer from Background Fondan qat + Layers to Files Qatlardan Fayllara + Layout Düzəldici + Leading (set to the font size × auto-leading when Auto) Aparıcı (Avtomatik olduqda şrift ölçüsünə təyin × avtomatik aparıcı) + Leaf Color Yarpaq Rəngi + Learn More Ətraflı məlumat əldə edin + Leaves Amount Vərəqlərin məbləği + Leaves Color Yarpaqların Rəngi + Leaves Size Yarpaqların ölçüsü + Left Diagonal Sol diaqonal + Legacy Miras + Legal Hüquqi + Lens Blur Obyektiv bulanıqlığı + Lens Correction Lens korreksiyası + Lens Flare Obyektiv parıltısı + Less Daha az + Letter Məktub + Level Based Səviyyəyə əsaslanan + Library Kitabxana + Light Dark Balance Açıq-tünd balansı + Light Direction İşıq istiqaməti + Light Intensity İşıq intensivliyi + Light Rough Yüngül kobud + Light Type İşıq növü + Light X İşıq X + Light Y İşıq Y + Light Z İşıq Z + Lighten Grout Qarışığı yüngülləşdirin + Lighten Only Yalnız işıqlandırın + Lighting İşıqlandırma + Lighting Effects İşıq effektləri + Linear Dodge Xətti işıqlandırma + Linear Height Xətti Hündürlük + Linear-srgb Xətti-srgb + Link Link + Liquify Likvidləşdirin + List Siyahı + List view Siyahı görünüşü + Live Preview Canlı Önizləmə + Load 3D LUT 3D LUT yükləyin + Load Files into Stack Faylları yığına yükləyin + Load path as a selection Seçim kimi yolu yükləyin + Loading… Yüklənir… + Lock All Hamısını Kilidləyin + Lock Image Pixels Şəkil piksellərini kilidləyin + Lock Position Kilid mövqeyi + Lock Transparent Pixels Şəffaf pikselləri kilidləyin + Locked Bağlanıb + Long Lines Uzun xətlər + Long Strokes Uzun vuruşlar + Lorem Ipsum Lorem Ipsum + Lower Aşağı + Lum Lum + Lut Lut + Magentas Macenta + Make Selection from Path Yoldan seçim edin + Make Selection… Seçim edin… + Make Symmetry Path Simmetriya yolunu düzəldin + Make Work Path… İş Yolunu Yarat… + Make work path from selection Seçimdən iş yolunu düzəldin + Manual Təlimat + Map Document Xəritə sənədi + Map Layer Xəritə Layeri + Map Path Xəritə yolu + Mask Channel Maska kanalı + Mask Incomplete Blocks Natamam blokları maska + Mask None Maska Yoxdur + Masks Maskalar + Match Color Uyğun Rəng + Math Symbols Riyaziyyat simvolları + Max Offset Maks ofset + Max Radius Maks Radius + Measurement Scale Ölçmə Şkalası + Measurement Scale Marker Ölçmə Şkalasının Markeri + Medium Dots Orta Nöqtələr + Medium Gray Orta Boz + Medium Lines Orta xətlər + Medium Strokes Orta Vuruşlar + Merge Channels… Kanalları birləşdirin… + Merge Spot Channel Xüsusi rəng kanalını birləşdir + Merge to HDR Pro HDR Pro ilə birləşdirin + Metallic Metalik + Metrics Metriklər + Mezzotint Metsotinto + Middle Orta + Midtone Orta ton + Monitor Profile Monitor Profili + Monitor profile in use on {display}: {profile} {display} -də istifadə olunan monitor profili: {profile} + Monitor profile in use: {profile} İstifadə olunan monitor profili: {profile} + Monitor profile on {display} (main window): {profile} {display} -də monitor profili (əsas pəncərə): {profile} + Monitor profile on {display}: {profile} {display} -də monitor profili: {profile} + Monitor profile: {profile} Monitor profili: {profile} + Mono Contrast Mono Kontrast + Monochromatic Monoxromatik + More Daha çox + Mosaic Mozaika + Mosaic Tiles Mozaika plitələr + Motion Blur Hərəkət bulanıqlığı + Mouse Siçan + Move Layer Layeri köçürün + Movie Prime Baş film + Multiple Flames Along Path Yol boyu birdən çox alov + Multiple Flames One Direction Bir İstiqamətdə Çoxlu Alov + Multiple Flames Path Directions Çoxlu Alov Yol İstiqamətləri + Multiple Flames Various Length Çoxlu Alov Müxtəlif Uzunluqda + My Brushes Fırçalarım + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Doğma faylı sürükləyib buraxma hələ Wayland-da dəstəklənmir. Fayldan istifadə edin › Açın və ya PhotoCraft -ni XWayland altında `WAYLAND_DISPLAY= photocraft` ilə işlədin. + Nearest Ən yaxın + Neon Glow Neon parıltısı + Neutralize Zərərsizləşdirin + Neutrals neytrallar + New Brush Preset Yeni Fırça Əvvəlcədən + New Channel… Yeni Kanal… + New Guide Yeni Bələdçi + New Guide Layout Yeni Bələdçi Layout + New Spot Channel… Yeni Spot Kanal… + New Video Layer from File Fayldan Yeni Video Layer + New Window Yeni Pəncərə + New Workspace Yeni İş sahəsi + New document Yeni sənəd + New document… Yeni sənəd… + New document… ⌘N Yeni sənəd... ⌘N + Next Sonrakı + No Document Sənəd yoxdur + No Image Şəkil yoxdur + No Layers Laylar yoxdur + No Results Nəticə yoxdur + No Results Found Nəticə tapılmadı + No Selection Seçim yoxdur + No custom shape "{name}": pick one in the options bar or Window › Shapes " {name} " fərdi forması yoxdur: seçimlər panelindən və ya Pəncərədən birini seçin › Formalar + No layers Qat yoxdur + No recent files Son fayllar yoxdur + No selection Seçim yoxdur + Noise Color Səs Rəngi + Noise Color Detail Rəng küyünün təfərrüatı + Noise Luminance Səs-küy parlaqlığı + Noise Luminance Detail Səs-küy Parlaqlığı Təfərrüatı + Nothing to Redo Yenidən etmək üçün heç nə yoxdur + Nothing to Undo Geri qaytarılası heç nə yoxdur + Nothing to paste: the clipboard holds no image Yapışdırılacaq heç nə yoxdur: mübadilə buferində heç bir şəkil yoxdur + Number Of Levels Səviyyələrin sayı + Numbers Nömrələr + Ocean Ripple Okean dalğalanması + Octagon səkkizbucaqlı + Odd Fields Qəribə sahələr + Oil Paint Yağlı boya + On Aktivdir + One Flame Along Path Yol boyu bir alov + Open As Açın + Open File Faylı açın + Open Folder Qovluğu açın + Open Image Şəkili açın + Open Recent is unavailable on the web Açıq Sonlar internetdə mövcud deyil + Opened {url} {url} açıldı + Opening Açılış + Open… ⌘O Açıq… ⌘O + Operation canceled Əməliyyat ləğv edildi + Operation cancelled Əməliyyat ləğv edildi + Operation completed Əməliyyat tamamlandı + Operation failed Əməliyyat uğursuz oldu + Optical Optik + Original Ratio Orijinal nisbət + Out Black Çıxış qarası + Out From Center Mərkəzdən kənarda + Out Of Gamut Rəng əhatəsindən kənar + Out White Ağdan çıxdı + Out of memory Yaddaş tükəndi + Outer Bevel Xarici əyilmə + Output Levels Çıxış Səviyyələri + Oval Oval + Overlay Edge Üst-üstə düşmə kənarı + Overlay: Rule of Thirds Üst-üstə düşmə: Üçüncülərin Qaydası + PSD PSD + Package Paket + Page Setup… Səhifə Quraşdırma… + Paint Daubs Boya yaxmaları + Paints from the document's opening state Sənədin açılış vəziyyətindən olan boyalar + Palette Knife Palitra Bıçağı + Panel Panel + Paper Kağız + Paper Brightness Kağız Parlaqlığı + Parallel Paralel + Paste Complete Stroke Tam fırça izini yapışdır + Paste Fill Doldurmağı Yapışdırın + Patchwork Yamaq işi + Path Blur Yol Bulanıqlığı + Paths to Illustrator Illustrator-a gedən yollar + Pattern Fill… Nümunə Doldur… + Pattern Type Nümunə Növü + Pencil Width Qələm eni + Pentagon Pentaqon + Percentage Faiz + Permission denied İcazə rədd edildi + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft yenidən cəhd etməyə davam edir; saxlama müvəffəqiyyətli olana qədər, bağlandıqda üstünlük dəyişiklikləri itirilir. + Photomerge Foto birləşdirin + Pick Color From Image Şəkildən Rəng Seçin + Picture Frame Şəkil çərçivəsi + Pillow Emboss Yastıq Qabartma + Pinch Çimdik + Pins Sancaqlar + Place Embedded Daxil edilmiş yer + Place Linked Əlaqələndirilmiş yer + Place Scale Marker Yer Ölçüsü Markeri + Plane Təyyarə + Planes Təyyarələr + Plastic Wrap Plastik Qapaq + Platform: {os} {arch} Platforma: {os} {arch} + Please wait… Zəhmət olmasa gözləyin… + Point Nöqtə + Point Curve Nöqtə əyrisi + Point Curve Blue Mavi nöqtə əyrisi + Point Curve Green Nöqtə Əyri Yaşıl + Point Curve Red Nöqtə əyrisi Qırmızı + Pointed İşarələnmiş + Pointillize Pointilizasiya + Polar Coordinates Qütb koordinatları + Polar To Rectangular Qütbdən Düzbucaqlıya + Policy Cmyk Siyasət Cmyk + Policy Gray Siyasət Boz + Policy Rgb Siyasət Rgb + Pond Ripples Gölməçə dalğaları + Portrait, 4 x 6 Portret, 4 x 6 + Portrait, 5 x 7 Portret, 5 x 7 + Postcard Açıqca + Poster Poster + Poster Edges Afişanın kənarları + Posterization Posterləşdirmə + Preferences can't be saved Tərcihləri yadda saxlamaq mümkün deyil + Preferences saved Tercihlər yadda saxlanıldı + Preferences were reset: {error} Tercihlər sıfırlandı: {error} + Preferences… Tərcihlər… + Preserve Qoruyucu + Preset Manager Əvvəlcədən təyin edilmiş menecer + Press keys Düymələr basın + Preview Image Şəklin önizləməsi + Previous Əvvəlki + Prime105 Prime105 + Prime35 Prime35 + Printed to {path} {path} -ə çap edilib + Pro (Dark) Pro (Qaranlıq) + Pro (Medium Gray) Pro (Orta Boz) + Processing… Emal edilir… + Profile Profil + Proof Setup… Sübut Quraşdırma… + Prophoto-compat Profoto uyğunluğu + Pucker Pucker + Push Left Sola basın + Pyramids Piramidalar + Quick Actions Sürətli hərəkətlər + Quit Çıxın + Quit PhotoCraft PhotoCraft -dən çıxın + Quit {app} {app} -dən çıxın + R G B RGB + R g b R g b + Radial Radial + Radial Blur Radial Bulanıqlıq + Radius X Radius X + Radius Y Radius Y + Ran script {name} İşlənmiş skript {name} + Randomize Length Uzunluğu təsadüfiləşdir + Randomize Shapes Formaları təsadüfiləşdirin + Rasterize Fill Content Doldurma Məzmunu Rasterləşdirin + Rasterize Smart Object Ağıllı obyekti rasterləşdirin + Ratio Nisbət + Raw Defaults Xam defoltlar + Reading Oxumaq + Ready Hazırdır + Rec2020 Rec2020 + Recent Files Son Fayllar + Reconstruct All Hamısını yenidən qurun + Recovered {name} {name} bərpa edildi + Rectangular Selection Düzbucaqlı Seçim + Rectangular To Polar Düzbucaqlıdan Qütbə + Red Cyan Qırmızı Cyan + Reds Qırmızılar + Reduce Color Noise Rəng səsini azaldın + Reduce Noise Səs-küyü azaldın + Reflected əks olundu + Relief Relyef + Relink to File Fayla yenidən keçid + Remove All Hamısını Sil + Remove Jpeg Artifact Jpeg artefaktını silin + Rename Brush Fırçanın adını dəyişdirin + Rename Channel… Kanalın adını dəyişin… + Rename Group Qrupun adını dəyişin + Rename Tool Preset Alətin Əvvəlcədən Adını dəyişdirin + Render Video Videonu göstərin + Rendering… Render edilir… + Reorder Layer Layeri yenidən sıralayın + Replace Color Rəngi dəyişdirin + Replace Contents Məzmunu dəyişdirin + Replace Footage Görüntüləri dəyişdirin + Report a Bug Səhv haqqında məlumat verin + Report an Issue Problemi bildirin + Reset Alignment Hizalanmanı Sıfırlayın + Reset All Hamısını sıfırlayın + Reset Preferences Tercihləri sıfırlayın + Resolve Missing Fonts Çatışmayan şriftləri həll edin + Restart Yenidən başladın + Restart Required Yenidən başlatma tələb olunur + Resume CV + Retry Yenidən cəhd edin + Rgb Rgb + Ridges Silsilələr + Right Diagonal Sağ diaqonal + Ripple Dalğalanma + Ripple Magnitude Dalğalanma miqyası + Ripple Size Dalğalanma Ölçüsü + Rope İp + Roses Güllər + Rough Pastels Kobud pastellər + Row Order Sıra sırası + Rulers (⌘R) Xətkeşlər (⌘R) + Sample Size Nümunə ölçüsü + Sampled Colors Nümunələnmiş Rənglər + Sandstone Qumdaşı + Sat Şənbə + Save As Saxla + Save Changes Dəyişiklikləri Saxla + Save File Faylı Saxla + Save Image Şəkli Saxla + Save Path Yolu Saxla + Save Pixel Aspect Ratio Piksel Aspekt Nisbətini Saxla + Save a Copy Surəti saxla + Save before closing? Bağlamadan əvvəl yadda saxla? + Save for Web (Legacy) Veb üçün Saxla (Legacy) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. GPU sürətləndirilməsini yenidən sınamaq üçün işinizi yadda saxlayın və PhotoCraft -i yenidən başladın. CPU göstərilməsi bu sessiya üçün aktiv olaraq qalır. + Saved a copy as {path} Bir nüsxə {path} olaraq yadda saxlanıldı + Saved {path}; image assets: {n} in {dir} Saxlanmış {path} ; şəkil aktivləri: {dir} -də {n} + Saving… Yadda saxlanılır… + Scale Effects Ölçək effektləri + Scaling Ölçəkləmə + Scallops Tarak + Script Events Manager Skript Hadisələri Meneceri + Scroll menu down Menyunu aşağı sürüşdürün + Scroll menu up Menyu yuxarı sürüşdürün + Search commands Axtarış əmrləri + Search commands (⌘K) Axtarış əmrləri (⌘K) + Search commands, tools and panels Axtarış əmrləri, alətlər və panellər + Search menus Menyuları axtarın + Seed Toxum + Select Custom Color… Fərdi Rəng seçin… + Select Data Points Məlumat nöqtələrini seçin + Select Layer Layer seçin + Select and Mask Seç və Maska + Color Aware Rəngdən xəbərdar + Decontaminate Colors Rəngləri zərərsizləşdirin + Edge Detection Kənarın aşkarlanması + Global Refinements Qlobal Təkmilləşdirmələr + High Quality Preview Yüksək Keyfiyyətli Baxış + Object Aware Obyektdən xəbərdardır + Object Aware needs a segmentation model, which isn't available yet Object Aware üçün hələ mövcud olmayan seqmentləşdirmə modelinə ehtiyac var + Real-Time Refinement Real Zamanda Təkmilləşdirmə + Refine Edge Brush Tool Kənar Fırça Alətini Təkmilləşdirin + Refine Hair Saçları zərifləşdirin + Refine Hair needs hair detection, which isn't available yet Refine Hair üçün saç aşkarlanması lazımdır, bu hələ mövcud deyil + Refine Mode Dəqiqləşdirmə rejimi + Select and Mask was closed: another layer was selected Seçin və Maska bağlandı: başqa qat seçildi + Select and Mask was closed: its document is no longer the active one Seçin və Maska bağlandı: onun sənədi artıq aktiv deyil + View Mode Baxış rejimi + Selected at launch: {backend} Başlanğıcda seçildi: {backend} + SemiBold Yarı Qalın + Sensitivity Həssaslıq + Sent to the printer ({spooler}) Printerə göndərildi ( {spooler} ) + Sepia Sepiya + Services Xidmətlər + Set Colors Rəngləri təyin edin + Set Measurement Scale Ölçmə Şkalasını təyin edin + Sgray Boz + Shadow Kölgə + Shadow Amount Kölgə məbləği + Shadow Intensity Kölgə intensivliyi + Shadow Radius Kölgə radiusu + Shadow Tone Kölgə Tonu + Shadows up to: Kölgələr: + Shadows/Highlights Kölgələr/Vurğular + Shallow Dayaz + Shape Blur Fiqur bulanıqlığı + Sharpen Amount Məbləği kəskinləşdirin + Sharpen Detail Təfərrüatı kəskinləşdirin + Sharpen Details Təfərrüatları kəskinləşdirin + Sharpen Masking Maskanı kəskinləşdirin + Sharpen Radius Radiusu kəskinləşdirin + Shear Kəsmə + Shine Parıldamaq + Short Lines Qısa xətlər + Short Strokes Qısa vuruşlar + Shortcut Qısa yol + Shortcuts Qısa yollar + Show All Hamısını göstər + Show Extras Options Əlavə seçimləri göstərin + Show Guides Bələdçiləri göstərin + Show Guides (⌘;) Bələdçiləri göstər (⌘;) + Show Rulers Xətkeşlərı göstərin + Show the layer's effects (⌥-click: all layers) Qatın effektlərini göstərin (⌥-klikləyin: bütün qatlar) + Simple Sadə + Simulate Paper Kağızı simulyasiya edin + Sine Sinus + Sketch Eskiz + Skip Keçin + Slice Options Dilim Seçimləri + Smart Blur Ağıllı Bulanıqlıq + Smart Sharpen Ağıllı kəskinləşdirmə + Smoothness Hamarlıq + Smudge Stick Yayma çubuğu + Snap to Grid Tora yapışdır + Snowflakes Qar dənəcikləri + Soften Yumşaldın + Softness Yumşaqlıq + Software adapter Proqram təminatı adapteri + Solarized Dark Günəşləşdirilmiş Qaranlıq + Solid Color Bərk Rəng + Solid Front Faces Bərk Ön Üzlər + Source Channel Mənbə Kanalı + Source Invert Mənbə Çevir + Source Layer Mənbə qatı + Source1 Channel Mənbə 1 Kanalı + Source1 Invert Mənbə1 Tərs + Source2 Channel Mənbə 2 Kanalı + Source2 Invert Mənbə2 Çevir + Source: set Mənbə: set + Space Kosmos + Sparkle Parıldamaq + Spatter Sıçrayış + Speckle Ləkə + Speed Sürət + Spherize Kürələşdir + Spin Spin + Spin Blur Fırlanma bulanıqlığı + Split Channels Bölünmüş Kanallar + Spot Spot + Spray Radius Sprey radiusu + Sprayed Strokes Püskürən fırça izləri + Spread Yayılma + Sprinkles səpilir + Square Size Kvadrat ölçüsü + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Kvadratlıq + Stagger səndələmək + Stained Glass Vitraj + Star Ulduz + Stars Ulduzlar + Start X X-ə başlayın + Start Y Y ilə başlayın + Statistics Statistika + Step {step} ({id}) failed: {error} {step} ({id}) addımı alınmadı: {error} + Stippled Kəsik + Stops Dayanır + Strength Güc + Stretch Uzatma + Stroke Color Kontur rəngi + Stroke Detail Vuruş detalı + Stroke Direction Fırça izinin istiqaməti + Stroke Length Vuruş Uzunluğu + Stroke Pressure vuruş təzyiqi + Stroke Size Vuruş Ölçüsü + Stroke Width Kontur qalınlığı + Stroke path with brush Fırça ilə vuruş yolu + Strokes Fırça izləri + Studio (Dark) Studiya (Qaranlıq) + Studio (Light) Studiya (İşıq) + Stylization Stilizasiya + Subtract Shapes at Overlap Üst-üstə düşən formaları çıxarın + Subtract from selection (⌥) Seçimdən çıxın (⌥) + Sumi-e Sumi-e + Surface Blur Səth bulanıqlığı + Switch Foreground and Background Colors Ön və arxa plan rənglərini dəyiş + System Info Sistem məlumatları + Tablet Tablet + Tabloid Tabloid + Taper Konik + Target Hədəf + Target Channel 3 Hədəf Kanal 3 + Target Channel 4 Hədəf Kanal 4 + Target Channel 5 Hədəf Kanal 5 + Target Channel 6 Hədəf Kanal 6 + Target Channel 7 Hədəf Kanal 7 + Target Channel 8 Hədəf Kanal 8 + Target Channel 9 Hədəf kanal 9 + Target Composite Channel Hədəf kompozit kanal + Target X Hədəf X + Target Y Hədəf Y + Teal Orange Mavi-yaşıl və narıncı + Technology Previews… Texnologiya önbaxışları… + Temporary Müvəqqəti + Text Layer Mətn qatı + Texture Coverage Tekstura örtüyü + Texturizer Teksturalaşdırma + Thaw Mask Maskanı ərit + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Kətan sRGB kimi göstərilir. Redaktə › Rəng parametrləri bölməsində monitor profili seçin. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. {display} üzərindəki kətan sRGB olaraq göstərilir. Redaktə ›Rəng Parametrlərində monitor profili seçin. + The current gradient (pick one in Window › Gradients) Cari qradiyent (Pəncərə › Qradientlərdən birini seçin) + The document does not have an embedded colour profile. Working space: {working}. Sənədin daxili rəng profili yoxdur. İş sahəsi: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Sənədin daxili rəng profili iş rəng məkanına uyğun gəlmir.\nDaxili: {embedded}\nİş rəng məkanı: {working} + The file could not be opened. Faylı açmaq mümkün olmadı. + The file could not be saved. Faylı saxlamaq mümkün olmadı. + Thickness Qalınlıq + This Layer: Bu qat: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Bu Ağıllı Obyekt davam etməzdən əvvəl rasterləşdirilməlidir. Onun məzmunu artıq redaktə edilə bilməyəcək. Ağıllı Obyekt rasterləşdirilsin? + This adjustment ({key}) is kept as imported and can't be edited yet. Bu tənzimləmə ({key}) idxal edilmiş kimi saxlanılır və hələ redaktə edilə bilməz. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Davam etməzdən əvvəl bu doldurma qatsi rasterləşdirilməlidir. Onun doldurma məzmunu artıq redaktə edilə bilməyəcək. Qat rasterləşdirilsin? + This layer must be rasterized before proceeding. Davam etməzdən əvvəl bu qat rasterləşdirilməlidir. + This session: {reason} Bu sessiya: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Davam etməzdən əvvəl bu forma qatsi rasterləşdirilməlidir. O, artıq forma kimi redaktə edilə bilməyəcək. Forma rasterləşdirilsin? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Bu tip qat davam etməzdən əvvəl rasterləşdirilməlidir. Onun mətni artıq redaktə edilə bilməyəcək. Növü rasterləşdirin? + Tile Size Kafel ölçüsü + Tiles Kafel + Tilt-Shift Meyil-sürüşmə + Tint Color Rəng Rəngi + Tiny Lens Kiçik obyektiv + Title Başlıq + To Kimə + To Center Mərkəzə + Toggle Layer Mask Overlay Qat maskası örtüyünü dəyişdirin + Toggle the Brush Settings panel Fırça Parametrləri panelini dəyişin + Toggle the Brush Settings panel (F5) Fırça Parametrləri panelini dəyişin (F5) + Tool Options Alət Seçimləri + Trace Contour Kontur İzi + Tracking (1/1000 em) İzləmə (1/1000 em) + Transformation Transformasiya + Transition Keçid + Transparency Shapes Layer Şəffaflıq Formaları Layeri + Trap Tələ + Tree Ağac + Trim Kəsmə + Turbulent Burulğanlı + Twirl Burulma + Twirl Clockwise Saat əqrəbinə çevirin + Twirl Counterclockwise Saat əqrəbinin əksinə çevirin + Type Options Növ Seçimləri + Unaltered Dəyişməmiş + Undefined Areas Müəyyən edilməmiş ərazilər + Underlying Layer: Əsas qat: + Underpainting Alt rəngləmə + Underwater Sualtı + Ungroup Qrupu ayırın + Uniform Uniforma + Unite Shapes at Overlap Üst-üstə düşən formaları birləşdirin + Unlink Bağlantını kəsin + Unlock Kilidi açın + Unlocked Kilidi açılıb + Unsaved Changes Saxlanmamış Dəyişikliklər + Unsharp Mask Kəskin olmayan maska + Unsupported file format Dəstəklənməyən fayl formatı + Unsupported format Dəstəklənməyən format + Up Yuxarı + Update Yeniləmə + Updates Yeniləmələr + Upper Yuxarı + Use Custom Color Xüsusi Rəngdən istifadə edin + Use File › Open to open an image. Şəkli açmaq üçün Fayl ›Aç seçimindən istifadə edin. + Use Selection In Source Mənbədə Seçimdən istifadə edin + Use Selection In Target Hədəfdə Seçimdən istifadə edin + Uses the foreground and background colors. Ön və arxa fon rənglərindən istifadə edir. + Vanishing Point Ufuq nöqtəsi + Variable Data Sets Dəyişən məlumat dəstləri + Vector Mask Hides Effects Vektor maskası effektləri gizlədir + Vertical Distortion Şaquli Təhrif + Vertical Only Yalnız Şaquli + Vertical Type Tool Şaquli mətn aləti + Vertical scale Şaquli miqyas + Vertical type Şaquli tip + Video Frames to Layers Video Çərçivələrdən Laylara + Vignette Amount Vignette məbləği + Vignette Feather Vignette Lələk + Vignette Highlights Vinyetin işıqlı sahələri + Vignette Midpoint Vinyetin orta nöqtəsi + Vignette Roundness Vignette yuvarlaqlığı + Vignette Style Vignette Stili + Vine Color Üzüm tənəyi rəngi + Vine With Flowers Çiçəkli Üzüm + Vine With Leaves Yarpaqlı üzüm + Violent Zorakılıq + W: W: + WIA Support WIA dəstəyi + Warm İsti + Warming Filter (85) İstiləşmə filtri (85) + Warming L B A İstiləşmə L B A + Warming81 İstiləşmə81 + Warming85 İstiləşmə85 + Warmth İstilik + Warning Xəbərdarlıq + Warp Text Çarpma mətni + Watercolor Akvarel + Watercolour look: lighter interior, darker rims. Akvarel görünüşü: daha yüngül salon, daha tünd təkərlər. + Wavelength Max Dalğa Uzunluğu Maks + Wavelength Min Dalğa Uzunluğu Min + Waves Dalğalar + Web Large Veb üçün böyük + Web Minimum Veb üçün minimum + Web Most Common Ən çox yayılmış İnternet + WebP (lossless) WebP (itkisiz) + Wetness Nəmlik + White Clip Ağ Klip + White Intensity Ağ intensivliyi + White Is High Ağ yüksəkdir + Whole Word Bütün söz + Wide Blurry Geniş Bulanıqlıq + Wide Sharp Geniş və kəskin + Wind Külək + Window renderer: {renderer} Pəncərə rendereri: {renderer} + Working İşləyir + Working Cmyk İşləyən Cmyk + Working Gray İşləyən Boz + Working Rgb İşləyən Rgb + Working-cmyk İşləyən-cmyk + Workspaces İş yerləri + Wrap Sarın + Yellows Sarılar + ZigZag ZiqZaq + Zigzag Ziqzaq + Zoom In (hold, drag to scrub) Böyüt (baxıb saxlayın, ovuşdurmaq üçün dartın) + Zoom Out (hold) Uzaqlaşdırın (basılı tutun) + auto avtomatik + blue mavi + brushes fırçalar + fallback geri dönüş + gradients gradientlər + gray boz + green yaşıl + layer mask qat maskası + manual təlimat + none yoxdur + off söndürülür + on açıq + orange narıncı + previous reading kept; the last re-read failed: {reason} əvvəlki oxu saxlanılır; sonuncu təkrar oxumaq alınmadı: {reason} + red qırmızı + unknown naməlum + unknown type naməlum növ + vector mask vektor maskası + violet bənövşəyi + yellow sarı + {index} of {total} {index} və {total} + {label} is not available: {reason} {label} mövcud deyil: {reason} + {message} (notes: {n}; {warning}) {message} (qeydlər: {n}; {warning}) + {shortcut} assigned. {shortcut} təyin edildi. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} artıq {commands} tərəfindən istifadə olunur və OK düyməsini kliklədiyiniz zaman ondan silinəcək. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ naviqasiya ↵ esc-i yaxından işə salın + ✓ Color Indicates Masked Areas ✓ Rəng Maskalı Sahələri Göstərir + ✓ Color Indicates Selected Areas ✓ Rəng Seçilmiş Sahələri Göstərir +@id edit.clear Təmizlə +@id edit.purge.undo Geri al +@id edit.stroke Kontur çək… +@id edit.transform.scale Miqyas +@id file.close Bağla +@id filter.sharpen.sharpen Kəskinləşdir +@id layer.layerMask.apply Tətbiq et +@id select.all Hamısı +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file} faylından {n} fırça idxal edildi|{file} faylından {n} fırça idxal edildi +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file} faylından {n} qradient idxal edildi|{file} faylından {n} qradient idxal edildi +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file} faylından {n} naxış idxal edildi|{file} faylından {n} naxış idxal edildi +cameraRaw Balance Balans +cameraRaw Clarity Aydınlıq +cameraRaw Color Rəng +cameraRaw Color Mixer Rəng qarışdırıcı +cameraRaw Contrast Kontrast +cameraRaw Curve Əyri +cameraRaw Dehaze Dumanı aradan qaldırma +cameraRaw Detail Təfərrüat +cameraRaw Effects Effects +cameraRaw Exposure Ekspozisiya +cameraRaw Feather Kənar yumşaltma +cameraRaw Highlights İşıqlı sahələr +cameraRaw Luminance Parlaqlıq +cameraRaw Masking Maskalama +cameraRaw Midpoint Orta nöqtə +cameraRaw Midtones Orta tonlar +cameraRaw Radius Radius +cameraRaw Roundness Dairəvilik +cameraRaw Saturation Doyma +cameraRaw Shadows Kölgələr +cameraRaw Size Ölçü +cameraRaw Temperature Temperatur +cameraRaw Texture Tekstura +cameraRaw Tint Çalar +cameraRaw Whites Ağ sahələr +colorRange Invert Seçimi tərsinə çevir +document Orientation İstiqamət +layerLabel Magenta Macenta +layerLabel No Color Rəngsiz +layerLabel Red Qırmızı +layers Fill Doldurma +layers Fill: Doldurma: +navigator Zoom Miqyas +shape Appearance Görünüş + Automatic paragraph direction Avtomatik paraqraf istiqaməti + Left-to-right paragraph direction Soldan sağa paraqraf istiqaməti + Right-to-left paragraph direction Sağdan sola paraqraf istiqaməti + Not rendered: this adjustment ({key}) is kept as imported. Göstərilmədi: bu düzəliş ({key}) idxal edilmiş kimi saxlanılır. + Print preview image Önizləmə şəklini çap edin + Hue Cube Hue Cube + Brightness Cube Parlaqlıq kubu + Color Wheel Rəng çarxı + RGB Sliders RGB Sürgüləri + HSB Sliders HSB Sürgüləri + CMYK Sliders CMYK Sürgüləri + Lab Sliders Laboratoriya Sürgüləri + Web Color Sliders Veb Rəng Sürgüləri + Copy Color as HTML Rəngi HTML olaraq kopyalayın + Copy Color's Hex Code Rəngin Hex Kodunu kopyalayın + RGB Spectrum RGB Spektr + CMYK Spectrum CMYK Spektr + Grayscale Ramp Boz rəng enişi + Current Colors Cari Rənglər + Make Ramp Web Safe Ramp Web Təhlükəsiz olun + Art History Brush Tool Bədii tarixçə fırçası aləti + Tight Short Sıx və qısa + Tight Medium Sıx Orta + Tight Long Sıx uzun + Loose Medium Boş orta + Loose Long Seyrək və uzun + Dab Dab + Tight Curl Sıx buruq + Loose Curl Boş Qıvrım + Stylized dabs from a history state Tarixi vəziyyətdən stilizə edilmiş dablar + Font size presets Şrift ölçüsünün əvvəlcədən təyin edilməsi + Leading presets Aparıcı əvvəlcədən təyinlər + Tracking presets İlkin parametrləri izləmək + CPU Rendering CPU göstərilməsi + Calculating… Hesablanır… + Click to refresh histogram with uncached data Keşlənməmiş verilənlərlə histoqramı yeniləmək üçün klikləyin + Convert to Shape… Formaya çevir… + Cooling Filter (80) Soyutma filtri (80) + Cooling Filter (82) Soyuducu filtr (82) + Cooling Filter (LBB) Soyuducu Filtr (LBB) + Count: Sayı: + Delete Hidden Layers Gizli qatlari silin + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Məhdudiyyət əlavə etmək üçün şəklin üzərinə çəkin; Üfüqi/şaquli üçün keçid; silmək üçün sağ klikləyin. + Drawn on the CPU: {reason}. Click for System Info. CPU üzərində çəkilmişdir: {reason}. Sistem Məlumatı üçün klikləyin. + Exported {n} files İxrac edilmiş {n} faylları + Fit Layer on Screen Layeri ekrana uyğunlaşdırın + Flow: Axın: + Level: Səviyyə: + Percentile: Faiz: + Refine Edge… Kənarı dəqiqləşdirin… + Remember Settings Parametrləri yadda saxla + Reset Essentials Əsasları sıfırlayın + Rounded Rectangle Tool Dairəvi Düzbucaqlı Aləti + Show Channels in Color Kanalları Rəngli Göstərin + Show Statistics Statistikanı göstərin + Source: Mənbə: + Status Bar Vəziyyət paneli + Tile Horizontally Üfüqi olaraq kafel + Tile Vertically Şaquli olaraq kafel + Uncached Refresh Keşlənməmiş Yeniləmə + Warming Filter (81) İstiləşmə filtri (81) + Warming Filter (LBA) İstiləşmə Filtri (LBA) +@plural {n} document|{n} documents {n} sənəd|{n} sənəd +@plural {n} pixel|{n} pixels {n} piksel|{n} piksel +cameraRaw Before (Y) Əvvəl (Y) +cameraRaw Camera Raw ({name}) Kamera Raw ( {name} ) +cameraRaw Camera Raw Filter ({layer}) Kamera Raw Filtri ( {layer} ) +cameraRaw Camera Raw isn't open Camera Raw açıq deyil +cameraRaw Cancel Ləğv et +cameraRaw Cancelled opening {name} {name} açılışı ləğv edildi +cameraRaw Cancelled {label} Ləğv edildi {label} +cameraRaw Color Priority Rəng prioriteti +cameraRaw Developing {name} {name} hazırlanır +cameraRaw Full-resolution preview unavailable Tam rezolyusiyaya baxış mövcud deyil +cameraRaw Grain Taxıl +cameraRaw Highlight Clipping Warning (O) Vurğulanan Kəsmə Xəbərdarlığı (O) +cameraRaw Highlight Priority Prioriteti vurğulayın +cameraRaw Load Settings… Parametrləri yükləyin… +cameraRaw Load settings from a Camera Raw .xmp file Camera Raw .xmp faylından parametrləri yükləyin +cameraRaw Loaded Camera Raw settings from {file} {file} -dən Camera Raw parametrləri yükləndi +cameraRaw Noise Reduction Səs-küyün azaldılması +cameraRaw OK Oldu +cameraRaw Open Aç +cameraRaw Opened {name} {name} açıldı +cameraRaw Paint Overlay Rəngli örtük +cameraRaw Save Settings… Parametrləri yadda saxla… +cameraRaw Save these settings as a Camera Raw .xmp file Bu parametrləri Camera Raw .xmp faylı kimi yadda saxlayın +cameraRaw Shadow Clipping Warning (U) Kölgə Kəsmə Xəbərdarlığı (U) +cameraRaw Sharpening İtiləşdirmə +cameraRaw Show Lab Color Readouts Laboratoriya Rəng Oxunuşlarını göstərin +cameraRaw Show Red at 3 o'clock Saat 3-də Qırmızı göstər +cameraRaw Show Selected Region Seçilmiş Bölgəni göstərin +cameraRaw Show Skin Tone Indicator Dəri Tonu Göstəricisini göstərin +cameraRaw Show Vectorscope Vektorskopunu göstərin +cameraRaw Tone Histogram Ton histoqramı +cameraRaw Vignetting Vinyetləmə +cameraRaw White Balance: As Shot Ağ Balans: Çəkiliş kimi + Current Layer: Cari qat: + Delete effect Effekti silin + Move effect down Effekti aşağı köçürün + Move effect up Effekti yuxarı köçürün + Path operations Yol əməliyyatları + The path was discarded Yol ləğv edildi + The path was kept as the Work Path Yol İş Yolu kimi saxlanıldı + Edit Brush… Fırçanı redaktə et… +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS məzmunu SVG kimi redaktə ediləcək. Saxlama daxil edilmiş mənbəni əvəz edir; formata xas xüsusiyyətlər dəyişə bilər. + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Yanlış rəqəmsal giriş. {min}% və {max}% arasında tam ədəd tələb olunur. Əvvəlki dəyər daxil edildi. + Drag to move the selected path Seçilmiş yolu hərəkət etdirmək üçün dartın + Recently Used Colors Son İstifadə olunan Rənglər + Stop Color Rəngi dayandırın + Shapes can't use patterns yet Formalar hələ naxışlardan istifadə edə bilməz +smartSvg Edit SVG Smart Object SVG Ağıllı Obyektini redaktə edin +smartSvg Choose the application used to edit this SVG: Bu SVG-ni redaktə etmək üçün istifadə olunan proqramı seçin: +smartSvg Other application Digər proqram +smartSvg PhotoCraft (internal editor) PhotoCraft (daxili redaktor) +smartSvg Choose executable… İcra olunanı seçin… +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Açılmış SVG faylında yadda saxlayın. Saxlanmış hər bir dəyişiklik PhotoCraft-də bir geri qaytarma addımına çevrilir. +smartSvg Updates wait while another document or an editing dialog is active. Yeniləmələr başqa sənəd və ya redaktə dialoqu aktiv olduqda gözləyir. +smartSvg Stop syncing this Smart Object Bu Ağıllı Obyektin sinxronizasiyasını dayandırın +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG redaktoru açıldı. Bu Ağıllı Obyekti yeniləmək üçün SVG-ni yadda saxlayın. + Select Layer Under Cursor Kursorun altından Layer seçin + No matching fonts Uyğun şrift tapılmadı + Numerals Rəqəmlər + Lining Eyni hündürlüklü + Proportional Mütənasib + Tabular Sabit enli + Proportional Lining Mütənasib, eyni hündürlüklü + Tabular Lining Sabit enli, eyni hündürlüklü + Proportional Oldstyle Mütənasib, köhnə üslublu + Tabular Oldstyle Sabit enli, köhnə üslublu + Stylistic Sets Üslub dəstləri + Set 1 Dəst 1 + Set 2 Dəst 2 + Set 3 Dəst 3 + Set 4 Dəst 4 + Set 5 Dəst 5 + Set 6 Dəst 6 + Set 7 Dəst 7 + Set 8 Dəst 8 + Set 9 Dəst 9 + Set 10 Dəst 10 + Set 11 Dəst 11 + Set 12 Dəst 12 + Set 13 Dəst 13 + Set 14 Dəst 14 + Set 15 Dəst 15 + Set 16 Dəst 16 + Set 17 Dəst 17 + Set 18 Dəst 18 + Set 19 Dəst 19 + Set 20 Dəst 20 + Convert to Frame… Çərçivəyə çevir… + Replace Image… Şəkli əvəz edin… + Import Image… Şəkil idxal edin… + Fit Content to Frame Məzmunu çərçivəyə uyğunlaşdır + Fit Frame to Content Çərçivəni məzmuna uyğunlaşdır + Plugins… Plaginlər… + Embed Linked Əlaqəli obyekti daxil et + Embed All Linked Bütün əlaqəli obyektləri daxil et + Working CMY Plates İşçi CMY kanalları + Legacy Macintosh RGB (Gamma 1.8) Köhnə Macintosh RGB (Qamma 1.8) + Internet Standard RGB (sRGB) İnternet standartı RGB (sRGB) + +# Strings main added after this catalog was written (integration batch 20). + Background (default) Fon (defolt) + Transparent (default) Şəffaf (defolt) +# Move tool options bar (#11381) + Align top edges Üst kənarları hizalayın + Align vertical centers Şaquli mərkəzləri hizalayın + Align bottom edges Alt kənarları hizalayın + Distribute vertically Şaquli yaymaq + Distribute horizontally Üfüqi yaymaq + Show layer bounds on hover Üzərinə gətirəndə qat sərhədlərini göstərin + Expand layer group on click Klikləyəndə qat qrupunu genişləndirin + Show hover bounds from Layers Üzərinə gətirmə sərhədlərini Qatlardan göstərin +# Artboard tool options bar (#11385) + Distribute Widths Enləri yaymaq + Distribute Heights Hündürlükləri yaymaq + Auto-nest Layers Qatları avtomatik yuvalayın + Auto-size Canvas Kətanın ölçüsünü avtomatik təyin edin + Keep Relative Position During Layer Reordering Qatları yenidən sıralayarkən nisbi mövqeyi saxlayın + Shrinkwrap Canvas on Save Saxlayarkən kətanı məzmuna sıxın diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv new file mode 100644 index 0000000000..3a9cdb8137 --- /dev/null +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -0,0 +1,4649 @@ +# Bosnian (bs) catalog, Latin script, Ijekavian. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists +# the Bosnian forms "one|few|other" (1, 21, 101 … | 2–4, 22–24 … but +# not 12–14 | everything else), see `plural` in i18n/mod.rs +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Bosnian computing vocabulary (sloj, maska, odabir, četkica, gradijent, zamućenje, neprozirnost +# ...). No proprietary localisation resource, string table or product UI was consulted or copied; +# in particular not any localisation of Adobe Photoshop. Add entries the same way. AI-assisted +# (Claude Code), not yet reviewed by a native speaker; shared with storytold/craft-i18n +# (locales/bs/photocraft.tsv and common.tsv). +# Conventions: Bosnian Latin script, Ijekavian, standard Bosnian vocabulary where the neighbouring +# languages differ (historija, sačuvaj, tačka, datoteka, postavke), sentence case. Commands are +# imperatives (Otvori…, Sačuvaj kao…), tools read "Alat …" (Alat četkica). Quotation marks „…” +# around names. Product and technology names (PhotoCraft, Camera Raw, OpenType, Lab ...) and +# RGB/CMYK stay as they are; the trailing "…" of dialog commands is kept. + + Rows Redovi + Number Broj + Margin Margina + Top: Vrh: + Left: Lijevo: + Bottom: Dno: + Right: Desno: + Target: Cilj: + Selected Artboards Odabrane radne ploče + Center Columns Kolone po sredini + Clear Existing Guides Očisti postojeće vodilice + Invalid guide layout Nevažeći raspored vodilica + Leave empty for automatic size Ostavite prazno za automatsku veličinu + 5 Columns, 3 Rows 5 kolona, 3 reda + 8 Columns 8 kolona + 12 Columns 12 kolona + File Datoteka + Edit Uredi + Image Slika + Layer Sloj + Type Vrsta + Select Odaberi + Selected Odabrano + Scope Doseg + Top Left Gore lijevo + Top Right Gore desno + Bottom Left Dolje lijevo + Bottom Right Dolje desno + Filter Filter + View Prikaz + Window Prozor + Help Pomoć + (coming soon) (uskoro) + New… Novo… + Open… Otvori… + Open As… Otvori kao… + Open EXR Parts… Otvori EXR dijelove… + Add beauty precomp from the AOVs Dodaj beauty predkompoziciju iz AOV-ova + Both Oboje + Left Lijevo + Right Desno + Open Recent Otvori nedavno + Clear Recent Files Očisti nedavne datoteke + Clear Recent File List Očisti nedavne datoteke + Remove from Recent Ukloni iz nedavnih + Remove {name} from Recent Ukloni {name} iz nedavnih + Close Zatvori + Minimize Minimiziraj + Maximize Maksimiziraj + Restore Vrati + Close All Zatvori sve + Close Others Zatvori ostale + New Document… Novi dokument… + Open Document… Otvori… + Consolidate All to Here Objedini sve ovdje + Move to New Window Premjesti u novi prozor + Save Sačuvaj + Save As… Sačuvaj kao… + Save a Copy… Sačuvaj kopiju… + {name} can't keep everything in this document {name} ne može zadržati sve u ovom dokumentu + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Odaberite format koji to zadržava, npr. Photoshop (PSD), ili sačuvajte kopiju. + Revert Vrati na sačuvano + Export Izvezi + Quick Export as PNG Brzi izvoz kao PNG + Export As… Izvezi kao… + Export Preferences… Izvezi postavke… + Save for Web (Legacy)… Sačuvaj za web (naslijeđeno)… + Artboards to Files… Radne ploče u datoteke… + Artboards to PDF… Radne ploče u PDF… + Layers to Files… Slojevi u datoteke… + Layer Comps to Files… Kompozicije slojeva u datoteke… + Color Lookup Tables… Tabele za pretragu boja… + Data Sets as Files… Skupovi podataka kao datoteke… + Paths to Illustrator… Putanje u Illustrator… + Render Video… Iscrtaj video… + Generate Generiši + Image Assets Slikovni resursi + Place Embedded… Postavi ugrađeno… + Place Linked… Postavi povezano… + Package… Paket… + Automate Automatizacija + Batch… Paketna obrada… + Create Droplet… Kreiraj droplet… + Crop and Straighten Photos Izreži i ispravi fotografije + Contact Sheet II… Kontaktni list II… + Conditional Mode Change… Uslovna promjena načina… + Fit Image… Uklopi sliku… + Lens Correction… Korekcija objektiva… + Merge to HDR Pro… Spoji u HDR Pro… + Photomerge… Photomerge… + Scripts Skripte + Image Processor… Procesor slika… + Delete All Empty Layers Izbriši sve prazne slojeve + Flatten All Layer Effects Spljošti sve efekte sloja + Flatten All Masks Spljošti sve maske + Script Events Manager… Upravitelj skriptnih događaja… + Import Uvezi + Browse… Pregledaj… + Source Files Izvorne datoteke + Use: Koristi: + Files Datoteke + Add Open Files Dodaj otvorene datoteke + Sort By Name Sortiraj po nazivu + Attempt to Automatically Align Source Images Pokušaj automatski poravnati izvorne slike + Create Smart Object after Loading Layers Kreiraj pametni objekat nakon učitavanja slojeva + Reveal in Finder Prikaži u Finderu + Show in Explorer Prikaži u Exploreru + Reveal in Explorer Prikaži u Exploreru + Show in Folder Prikaži u folderu + File Info… Informacije o datoteci… + Print… Štampaj… + Print One Copy Štampaj jednu kopiju + Exit Izlaz + Undo Poništi + Redo Ponovi + Toggle Last State Prebaci na posljednje stanje + Fade… Izblijedi… + Cut Izreži + Copy Kopiraj + Copy Merged Kopiraj spojeno + Paste Zalijepi + Paste Special Posebno lijepljenje + Paste in Place Zalijepi na mjesto + Paste Into Zalijepi u + Paste Outside Zalijepi izvan + Paste Lorem Ipsum Zalijepi Lorem Ipsum + Clear Očisti + Search… Pretraga… + Check Spelling… Provjeri pravopis… + Find and Replace Text… Pronađi i zamijeni tekst… + Fill… Ispuni… + Stroke… Potez… + Content-Aware Fill… Ispuna svjesna sadržaja… + Delete and Fill Selection Izbriši i ispuni odabir + Content-Aware Scale Skaliranje svjesno sadržaja + Puppet Warp Iskrivljenje lutkom + Perspective Warp Iskrivljenje perspektive + Free Transform Slobodna transformacija + Transform Transformiši + Again Ponovo + Scale Skaliraj + Rotate Rotiraj + Skew Iskosi + Set horizontal skew Postavi vodoravno iskošenje + Set vertical skew Postavi okomito iskošenje + Distort Izobliči + Perspective Perspektiva + Warp Iskrivi + Flip Horizontal Preokreni vodoravno + Flip Vertical Preokreni okomito + Rotate 180° Rotiraj za 180° + Rotate 90° Clockwise Rotiraj za 90° u smjeru kazaljke na satu + Rotate 90° Counter Clockwise Rotiraj za 90° suprotno od kazaljke na satu + 180° 180° + 90° Clockwise 90° u smjeru kazaljke na satu + 90° Counter Clockwise 90° suprotno od kazaljke na satu + Arbitrary… Proizvoljno… + Auto-Align Layers… Automatski poravnaj slojeve… + Auto-Blend Layers… Automatski stopi slojeve… + Define Brush Preset… Definiši unaprijed postavljenu četkicu… + Define Pattern… Definiši uzorak… + Define Custom Shape… Definiši prilagođeni oblik… + Purge Očisti memoriju + Clipboard Međuspremnik + Histories Historije + Video Cache Video keš + All Sve + Presets Unaprijed postavljene vrijednosti + Preset Manager… Upravitelj predložaka… + Migrate Presets Prenesi predloške + Export/Import Presets… Izvoz/uvoz predložaka… + Color Settings… Postavke boja… + Assign Profile… Dodijeli profil… + Convert to Profile… Pretvori u profil… + Keyboard Shortcuts… Prečice na tastaturi… + Menus… Meniji… + Toolbar… Traka s alatima… + Preferences Postavke + General… Općenito… + Interface… Interfejs… + Workspace… Radni prostor… + Tools… Alati… + History Log… Zapisnik historije… + File Handling… Rukovanje datotekama… + Export… Izvezi… + Performance… Performanse… + Scratch Disks… Radni diskovi… + Cursors… Kursori… + Transparency & Gamut… Prozirnost i gamut… + Units & Rulers… Jedinice i ravnala… + Guides, Grid & Slices… Vodilice, mreža i isječci… + Plug-ins… Dodaci… + Plugins… Dodaci… + Type… Vrsta… + Enhanced Controls… Poboljšane kontrole… + Camera Raw… Camera Raw… + Raw Defaults… Zadane postavke za RAW… + Integrations… Integracije… + AI Integrations… AI integracije… + Install Plug-in… Instaliraj dodatak… + Language Options Jezičke opcije + Settings… Postavke… + Mode Način + Bitmap Bitmapa + Grayscale Nijanse sive + Duotone Duoton + Indexed Color Indeksirana boja + RGB Color RGB boja + CMYK Color CMYK boja + Lab Color Lab boja + Multichannel Višekanalno + 8 Bits/Channel 8 bita/kanal + 16 Bits/Channel 16 bita/kanal + 32 Bits/Channel 32 bita/kanal + Color Table… Tabela boja… + Bitmap… Bitmapa… + Duotone… Duoton… + Indexed Color… Indeksirana boja… + Adjustments Podešavanja + Brightness/Contrast… Svjetlina/kontrast… + Levels… Nivoi… + Curves… Krive… + Exposure… Ekspozicija… + Vibrance… Živost… + Hue/Saturation… Nijansa/zasićenje… + Color Balance… Balans boja… + Black & White… Crno-bijelo… + Photo Filter… Foto filter… + Channel Mixer… Mikser kanala… + Color Lookup… Pretraga boja… + Invert Obrni + Posterize… Posterizuj… + Threshold… Prag… + Gradient Map… Mapa gradijenta… + Selective Color… Selektivna boja… + Shadows/Highlights… Sjene/svijetli tonovi… + HDR Toning… HDR toniranje… + Desaturate Ukloni zasićenje + Match Color… Uskladi boju… + Replace Color… Zamijeni boju… + Equalize Izjednači + Auto Tone Automatski ton + Auto Contrast Automatski kontrast + Auto Color Automatska boja + Image Size… Veličina slike… + Canvas Size… Veličina platna… + Image Rotation Rotacija slike + Flip Canvas Horizontal Preokreni platno vodoravno + Flip Canvas Vertical Preokreni platno okomito + Crop Izreži okvir + Trim… Obreži… + Reveal All Otkrij sve + Duplicate… Dupliciraj… + Apply Image… Primijeni sliku… + Calculations… Proračuni… + Variables Varijable + Define… Definiši… + Data Sets… Skupovi podataka… + Apply Data Set… Primijeni skup podataka… + Variable Data Sets… Skupovi varijabilnih podataka… + Trap… Preklapanje boja… + Analysis Analiza + Set Measurement Scale… Postavi skalu mjerenja… + Select Data Points… Odaberi tačke podataka… + Record Measurements Zabilježi mjerenja + Ruler Tool Alat ravnalo + Count Tool Alat za brojanje + Place Scale Marker… Postavi oznaku skale… + Custom… Prilagođeno… + New Novo + Layer… Sloj… + Group… Grupa… + Group from Layers… Grupa iz slojeva… + Artboard… Radna ploča… + Artboard from Group… Radna ploča iz grupe… + Artboard from Layers… Radna ploča iz slojeva… + Layer via Copy Sloj kopiranjem + Layer via Cut Sloj izrezivanjem + Layer From Background… Sloj iz pozadine… + Layer from Background… Sloj iz pozadine… + Background From Layer Pozadina iz sloja + New Shape Layer Novi sloj oblika + New Type Layer Novi tekstualni sloj + New Fill Layer Novi sloj ispune + New Adjustment Layer Novi sloj podešavanja + New Layer Based Slice Novi isječak zasnovan na sloju + New Smart Object via Copy Novi pametni objekat kopiranjem + New Blank Video Layer Novi prazan video sloj + New Video Layer from File… Novi video sloj iz datoteke… + Duplicate Layer… Dupliciraj sloj… + Delete Layer Izbriši sloj + Delete Izbriši + Rename Layer Preimenuj sloj + Create Layer Kreiraj sloj + Layer Style Stil sloja + Blending Options… Opcije stapanja… + Bevel & Emboss… Zakošenje i reljef… + Inner Shadow… Unutrašnja sjena… + Inner Glow… Unutrašnji sjaj… + Satin… Saten… + Color Overlay… Prekrivanje bojom… + Gradient Overlay… Prekrivanje gradijentom… + Pattern Overlay… Prekrivanje uzorkom… + Outer Glow… Vanjski sjaj… + Drop Shadow… Sjena… + Copy Layer Style Kopiraj stil sloja + Paste Layer Style Zalijepi stil sloja + Clear Layer Style Očisti stil sloja + Global Light… Globalno svjetlo… + Hide All Effects Sakrij sve efekte + Scale Effects… Skaliraj efekte… + Smart Filter Pametni filter + Convert for Smart Filters Pretvori za pametne filtere + Clear Smart Filters Očisti pametne filtere + Disable Smart Filters Onemogući pametne filtere + Delete Filter Mask Izbriši masku filtera + Disable Filter Mask Onemogući masku filtera + Solid Color… Puna boja… + Gradient… Gradijent… + Pattern… Uzorak… + Layer Mask Maska sloja + Vector Mask Vektorska maska + Reveal Selection Otkrij odabir + Hide Selection Sakrij odabir + Hide All Sakrij sve + From Transparency Iz prozirnosti + Delete Vector Mask Izbriši vektorsku masku + Enable Layer Mask Omogući masku sloja + Disable Layer Mask Onemogući masku sloja + Enable Vector Mask Omogući vektorsku masku + Link Layer Mask Poveži masku sloja + Unlink Layer Mask Prekini vezu maske sloja + Link Vector Mask Poveži vektorsku masku + Add Vector Mask Dodaj vektorsku masku + Vector Mask from Current Path Vektorska maska iz trenutne putanje + Create Clipping Mask Kreiraj masku isijecanja + Release Clipping Mask Oslobodi masku isijecanja + Smart Objects Pametni objekti + Smart Object Pametni objekat + Convert to Smart Object Pretvori u pametni objekat + Convert to Embedded Pretvori u ugrađeno + Embed Linked Ugradi povezano + Embed All Linked Ugradi sve povezano + Convert to Layers Pretvori u slojeve + Convert to Linked… Pretvori u povezano… + Edit Contents Uredi sadržaj + Replace Contents… Zamijeni sadržaj… + Relink to File… Ponovo poveži s datotekom… + Export Contents… Izvezi sadržaj… + Update Modified Content Ažuriraj izmijenjeni sadržaj + Update All Modified Content Ažuriraj sav izmijenjeni sadržaj + Stack Mode Način slaganja + Choose Stack Mode: Odaberite način slaganja: + Entropy Entropija + Kurtosis Kurtoza + Maximum Maksimum + Mean Srednja vrijednost + Median Medijan + Minimum Minimum + Range Raspon + Skewness Asimetrija + Standard Deviation Standardna devijacija + Summation Sumacija + Variance Varijansa + Video Layers Video slojevi + Interpret Footage… Interpretiraj snimak… + Replace Footage… Zamijeni snimak… + Reload Frame Ponovo učitaj sličicu + Show Altered Video Prikaži izmijenjeni video + Restore Frame Vrati sličicu + Restore All Frames Vrati sve sličice + Insert Blank Frame Umetni praznu sličicu + Delete Frame Izbriši sličicu + Duplicate Frame Dupliciraj sličicu + Frame from Layers Sličica iz slojeva + Video Frames to Layers… Video sličice u slojeve… + Rasterize Rasteriziraj + Rasterize Type Rasteriziraj tekst + Rasterize Type Layer Rasteriziraj tekstualni sloj + Rasterize Shape Rasteriziraj oblik + New Guide Layout… Novi raspored vodilica… + New Guides From Shape Nove vodilice iz oblika + Lock Layers… Zaključaj slojeve… + Link Layers Poveži slojeve + Select Linked Layers Odaberi povezane slojeve + Merge Layers Spoji slojeve + Merge Down Spoji nadolje + Merge Group Spoji grupu + Merge Visible Spoji vidljivo + Stamp Visible Pečat vidljivog + Stamp Down Pečat nadolje + Flatten Image Spljošti sliku + Matting Uklanjanje rubova + Color Decontaminate… Dekontaminacija boja… + Remove Black Matte Ukloni crnu podlogu + Remove White Matte Ukloni bijelu podlogu + Defringe… Ukloni rub… + Group Layers Grupiši slojeve + Ungroup Layers Razgrupiši slojeve + Hide Layers Sakrij slojeve + Arrange Rasporedi + Bring to Front Postavi na vrh + Bring Forward Pomakni naprijed + Send Backward Pomakni nazad + Send to Back Postavi na dno + Reverse Obrnuto + Combine Shapes Kombinuj oblike + Unite Shapes Ujedini oblike + Subtract Front Shape Oduzmi prednji oblik + Intersect Shape Areas Presijeci područja oblika + Exclude Overlapping Shapes Isključi preklapajuće oblike + Merge Shape Components Spoji komponente oblika + Align Poravnaj + Top Edges Gornje ivice + Vertical Centers Okomita središta + Bottom Edges Donje ivice + Left Edges Lijeve ivice + Horizontal Centers Vodoravna središta + Right Edges Desne ivice + Distribute Rasporedi ravnomjerno + Vertically Okomito + Horizontally Vodoravno + Layer Edges Ivice sloja + Isolate Layers Izoliraj slojeve + Layer Content Options… Opcije sadržaja sloja… + Fill Content Sadržaj ispune + Deselect Layers Poništi odabir slojeva + Find Layers Pronađi slojeve + Layer Comps Kompozicije slojeva + Layers Slojevi + All Layers Svi slojevi + Hidden Layers Skriveni slojevi + Mask All Objects Maskiraj sve objekte + Subject Predmet + Sky Nebo + Panels Paneli + Character Panel Panel znakova + Paragraph Panel Panel pasusa + Character Styles Panel Panel stilova znakova + Paragraph Styles Panel Panel stilova pasusa + Glyphs Panel Panel glifova + Anti-Alias Zaglađivanje ivica + Sharp Oštro + Crisp Oštro + Strong Snažno + Smooth Glatko + Windows LCD Windows LCD + Windows Prozori + Orientation Orijentacija + Horizontal Vodoravno + Vertical Okomito + OpenType OpenType + Standard Ligatures Standardne ligature + Contextual Alternates Kontekstualne alternative + Discretionary Ligatures Diskrecione ligature + Swash Ukrasni potez + Oldstyle Stari stil + Stylistic Alternates Stilske alternative + Titling Alternates Alternative za naslove + Ornaments Ornamenti + Ordinals Redni brojevi + Fractions Razlomci + Create Work Path Kreiraj radnu putanju + Convert to Shape Pretvori u oblik + Warp Text… Iskrivi tekst… + Convert to Point Text Pretvori u tačkasti tekst + Convert to Paragraph Text Pretvori u tekst pasusa + Default Features Zadane mogućnosti + East Asian Features Istočnoazijske mogućnosti + Middle Eastern Features Mogućnosti za Bliski istok + Middle Eastern & South Asian Composer Slagač za Bliski istok i Južnu Aziju + Update All Text Layers Ažuriraj sve tekstualne slojeve + Replace All Missing Fonts Zamijeni sve nedostajuće fontove + Resolve Missing Fonts… Riješi nedostajuće fontove… + Font Preview Size Veličina pregleda fonta + None Ništa + Small Malo + Medium Srednje + Large Veliko + Extra Large Ekstra veliko + Huge Ogromno + Load Default Type Styles Učitaj zadane stilove teksta + Save Default Type Styles Sačuvaj zadane stilove teksta + Deselect Poništi odabir + Reselect Ponovo odaberi + Inverse Obrnuto + Similar Slično + Color Range… Raspon boja… + Focus Area… Područje fokusa… + Select and Mask… Odaberi i maskiraj… + Smart Radius Pametni poluprečnik + Shift Edge Pomakni ivicu + Decontaminate Dekontaminiraj + Output Izlaz + Refine Edge Doradi ivicu + Modify Izmijeni + Border… Okvir… + Smooth… Zagladi… + Expand… Proširi… + Contract… Suzi… + Feather… Omekšaj ivice… + Grow Proširi sličnim + Transform Selection Transformiši odabir + Edit in Quick Mask Mode Uredi u načinu brze maske + Load Selection… Učitaj odabir… + Save Selection… Sačuvaj odabir… + Match All Uskladi sve + Last Filter Posljednji filter + Filter Gallery… Galerija filtera… + Adaptive Wide Angle… Prilagodljivi široki ugao… + Camera Raw Filter… Camera Raw filter… + Liquify… Ukapljivanje… + Vanishing Point… Tačka nestajanja… + Blur Zamućenje + Blur Gallery Galerija zamućenja + Field Blur… Zamućenje polja… + No pin selected Nije odabrana pribadača + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknite sliku da dodate pribadaču; prevucite pribadaču da je pomjerite ili njen prsten da promijenite zamućenje. Delete uklanja odabranu pribadaču. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Prevucite ručke na obrisu da promijenite veličinu i okrenete ga, kvadrat da zaoblite uglove, a tačke unutra da postavite gdje zamućenje počinje. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Prevucite pribadaču da pomjerite traku ili njen prsten da promijenite zamućenje. Prevucite pune linije da proširite oštru traku, isprekidane da postavite prijelaz, a tačke da je okrenete. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Prevucite ručke na obrisu da promijenite veličinu i okrenete ga; prsten postavlja ugao zamućenja. + Iris Blur… Zamućenje irisa… + Tilt-Shift… Tilt-shift… + Path Blur… Zamućenje putanjom… + Spin Blur… Zamućenje okretanjem… + Blur More Jače zamućenje + Box Blur… Kutijasto zamućenje… + Gaussian Blur… Gaussovo zamućenje… + Lens Blur… Zamućenje objektiva… + Motion Blur… Zamućenje pokreta… + Radial Blur… Radijalno zamućenje… + Shape Blur… Zamućenje oblikom… + Smart Blur… Pametno zamućenje… + Surface Blur… Površinsko zamućenje… + Average Prosjek + Displace… Pomjeri piksele… + Pinch… Uštipni… + Polar Coordinates… Polarne koordinate… + Ripple… Talasanje… + Shear… Smicanje… + Spherize… Sferiziraj… + Twirl… Kovitlac… + Wave… Talas… + ZigZag… Cik-cak… + Noise Šum + Add Noise… Dodaj šum… + Despeckle Ukloni mrlje + Dust & Scratches… Prašina i ogrebotine… + Median… Medijan… + Reduce Noise… Smanji šum… + Pixelate Pikseliziraj + Color Halftone… Polutonovi u boji… + Crystallize… Kristalizuj… + Facet Fasete + Fragment Fragment + Mezzotint… Mecotinta… + Mosaic… Mozaik… + Pointillize… Tačkasto slikanje… + Render Iscrtaj + Clouds Oblaci + Difference Clouds Oblaci razlike + Fibers… Vlakna… + Flame… Plamen… + Lens Flare… Odsjaj sočiva… + Lighting Effects… Efekti osvjetljenja… + Relight… Ponovo osvijetli… + Picture Frame… Okvir slike… + Tree… Drvo… + Sharpen Izoštri + Sharpen Edges Izoštri ivice + Sharpen More Jače izoštravanje + Smart Sharpen… Pametno izoštravanje… + Unsharp Mask… Maska izoštravanja… + High Pass… Visokopropusni filter… + Stylize Stilizuj + Diffuse… Difuzno… + Emboss… Reljef… + Extrude… Istisni… + Find Edges Pronađi ivice + Oil Paint… Uljana boja… + Solarize Solarizuj + Tiles… Pločice… + Trace Contour… Praćenje konture… + Wind… Vjetar… + Video Video + De-Interlace… Ukloni preplitanje… + NTSC Colors NTSC boje + Other Ostalo + Maximum… Maksimum… + Minimum… Minimum… + Offset… Pomak… + Plug-ins Dodaci + Plugins Dodaci + Proof Setup Postavke probnog prikaza + Proof Colors Probne boje + Gamut Warning Upozorenje o gamutu + Working CMYK Radni CMYK + Working Cyan Plate Radna cijan ploča + Working Magenta Plate Radna magenta ploča + Working Yellow Plate Radna žuta ploča + Working Black Plate Radna crna ploča + Working CMY Plates Radne CMY ploče + Legacy Macintosh RGB (Gamma 1.8) Naslijeđeni Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internet standardni RGB (sRGB) + Monitor RGB RGB monitora + Color Blindness - Protanopia-type Daltonizam — tip protanopije + Color Blindness - Deuteranopia-type Daltonizam — tip deuteranopije + Zoom In Uvećaj + Zoom Out Umanji + Fit on Screen Uklopi na ekran + Fit Layer(s) on Screen Uklopi slojeve na ekran + Fit Artboard on Screen Uklopi radnu ploču na ekran + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Veličina štampe + Pixel Aspect Ratio Omjer stranica piksela + Pixel Aspect Ratio Correction Korekcija omjera stranica piksela + Custom Pixel Aspect Ratio… Prilagođeni omjer stranica piksela… + Square Kvadrat + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC široki ekran (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL široki ekran (1,46) + Anamorphic 2:1 (2) Anamorfno 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + 32-bit Preview Options… Opcije 32-bitnog pregleda… + Screen Mode Način prikaza ekrana + Standard Screen Mode Standardni način prikaza ekrana + Full Screen Mode With Menu Bar Način rada preko cijelog ekrana s trakom menija + Full Screen Mode Način rada preko cijelog ekrana + Cycle Screen Mode Kruženje načina prikaza ekrana + Show Prikaži + Extras Dodaci prikaza + Selection Edges Ivice odabira + Target Path Ciljna putanja + Current Path Trenutna putanja + Grid Mreža + Guides Vodilice + Canvas Guides Vodilice platna + Artboard Guides Vodilice radne ploče + Count Broj + Smart Guides Pametne vodilice + Slices Isječci + Notes Bilješke + Pixel Grid Mreža piksela + Pixel Art Pikselna umjetnost + Pixel Art Preview Pregled pikselne umjetnosti + Pattern Preview Pregled uzorka + Brush Preview Pregled četkice + Mesh Mreža + Edit Pins Uredi pribadače + Document Bounds Granice dokumenta + Show Extras Options… Prikaži opcije dodataka prikaza… + Rulers Ravnala + Snap Hvatanje + Snap To Prilijepi uz + Lock Guides Zaključaj vodilice + Clear Guides Očisti vodilice + Clear Selected Artboard Guides Očisti odabrane vodilice radne ploče + Clear Canvas Guides Očisti vodilice platna + New Guide… Nova vodilica… + Lock Slices Zaključaj isječke + Clear Slices Očisti isječke + Match Zoom Uskladi zumiranje + Match Location Uskladi lokaciju + Match Rotation Uskladi rotaciju + Cascade Kaskadno + Tile Popločaj + Tile All Horizontally Popločaj sve vodoravno + Tile All Vertically Popločaj sve okomito + 2-up Horizontal 2 vodoravno + 2-up Vertical 2 okomito + 3-up Horizontal 3 vodoravno + 3-up Stacked 3 složeno + 3-up Vertical 3 okomito + 4-up 4 odjednom + 6-up 6 odjednom + Consolidate All to Tabs Objedini sve u kartice + Float in Window Odvoji u prozor + Float All in Windows Odvoji sve u prozore + Workspace Radni prostor + Essentials Osnove + Graphic and Web Grafika i web + Painting Slikanje + Photography Fotografija + New Workspace… Novi radni prostor… + Delete Workspace… Izbriši radni prostor… + Reset Workspace Vrati radni prostor na početno + Reset Tool Vrati alat na početno + Reset All Tools Vrati sve alate na početno + Reset all tools to their default settings? Vratiti sve alate na zadane postavke? + Lock Workspace Zaključaj radni prostor + Theme Tema + Pro Theme Pro tema + Pro Medium Gray Theme Pro srednje siva tema + Pro Light Gray Theme Pro svijetlosiva tema + Pro Light Theme Pro svijetla tema + Studio Theme Studio tema + Studio Light Theme Studio svijetla tema + Adwaita Light Theme Svijetla tema Adwaita + Adwaita Dark Theme Tamna tema Adwaita + Classic Theme Klasična tema + Solarized dark Solarized tamna + Solarized Dark Theme Solarized tamna tema + Breeze dark Breeze tamna + Breeze Dark Theme Tamna tema Breeze + Breeze light Breeze svijetla + Breeze Light Theme Svijetla tema Breeze + Soft dark Meka tamna + Soft Dark Theme Meka tamna tema + Soft light Meka svijetla + Soft Light Theme Meka svijetla tema + Next Theme Sljedeća tema + Actions Akcije + Brush Settings Postavke četkice + Brushes Četkice + Channels Kanali + Character Znak + Character Styles Stilovi znakova + Clone Source Izvor kloniranja + Color Boja + Glyphs Glifovi + Gradients Gradijenti + Histogram Histogram + History Historija + Info Informacije + Measurement Log Zapisnik mjerenja + Modifier Keys Modifikatorske tipke + Navigator Navigator + Options Opcije + Paragraph Pasus + Paragraph Styles Stilovi pasusa + Paths Putanje + Patterns Uzorci + Properties Svojstva + Shapes Oblici + Styles Stilovi + Swatches Uzorci boja + Timeline Vremenska linija + Tool Presets Predlošci alata + Tools Alati + Contextual Task Bar Kontekstualna traka zadataka + Hide bar Sakrij traku + Reset bar position Vrati položaj trake + Pin bar position Zakači položaj trake + Move bar Pomjeri traku + More options Više opcija + About PhotoCraft O PhotoCraftu + PhotoCraft Website Web stranica PhotoCrafta + PhotoCraft on GitHub PhotoCraft na GitHubu + ArtCraft Website Web stranica ArtCrafta + Join the ArtCraft Discord… Pridružite se ArtCraft Discordu… + Report an Issue… Prijavi problem… + WIA Support… WIA podrška… + Apply Primijeni + HSB/HSL HSB/HSL + Invert… Obrni… + Load Files into Stack… Učitaj datoteke u stog… + Motion Pokret + Notes… Bilješke… + Other… Ostalo… + Remove Warp Split Ukloni podjelu iskrivljenja + Shape Oblik + Split Warp Crosswise Podijeli iskrivljenje poprečno + Split Warp Horizontally Podijeli iskrivljenje vodoravno + Split Warp Vertically Podijeli iskrivljenje okomito + Statistics… Statistika… + Refine Strokes Doradi poteze + Edits Izmjene +@plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki + Auto Automatski + (print to the printer) (štampaj na štampač) + + Add variable + Dodaj varijablu + 30 frames @ 30 fps 30 sličica pri 30 fps + 3D LUT File 3D LUT datoteka + Absolute Apsolutno + Absolute Colorimetric Apsolutna kolorimetrija + Action Akcija + Adaptive Prilagodljivo + Adaptive Wide Angle isn't open Prilagodljivi široki ugao nije otvoren + Add Dodaj + Add Layer Mask Dodaj masku sloja + Add Mask to Selection Dodaj masku u odabir + Subtract Mask from Selection Oduzmi masku od odabira + Intersect Mask with Selection Presijeci masku s odabirom + Add Stop Dodaj tačku + Add a layer style Dodaj stil sloja + Add a mask (from the selection; {key} inverts) Dodaj masku (iz odabira; {key} obrće) + Add an adjustment Dodaj podešavanje + Add disk Dodaj disk + Add space after paragraph Dodaj razmak nakon pasusa + Add space before paragraph Dodaj razmak prije pasusa + Add to Sample Dodaj u uzorak + Add to selection ({key}) Dodaj u odabir ({key}) + Adjust for Zoom Prilagodi za zumiranje + Adjustment visible Podešavanje vidljivo + Adobe RGB (1998) compatible Kompatibilno s Adobe RGB (1998) + Align and Distribute Poravnaj i rasporedi + Align and distribute Poravnaj i rasporedi + Align horizontal centers Poravnaj vodoravna središta + Align left edges Poravnaj lijeve ivice + Align right edges Poravnaj desne ivice + Aligned Poravnato + All Caps Sva velika slova + All Slices Svi isječci + All User Slices Svi korisnički isječci + All shortcuts reset to PhotoCraft defaults. Sve prečice su vraćene na zadane vrijednosti PhotoCrafta. + Alt Alt + Always use pressure for opacity Uvijek koristi pritisak za neprozirnost + Always use pressure for size Uvijek koristi pritisak za veličinu + Amount Količina + Anchor: Sidro: + Angle Ugao + Angle Gradient Ugaoni gradijent + Angle Jitter Varijacija ugla + Answer the unsaved-changes prompt first Prvo odgovorite na upit o nesačuvanim promjenama + Anti-alias Zaglađivanje ivica + Appearance Izgled + Appearance (Layer Style) Izgled (stil sloja) + Apply Layer Mask Primijeni masku sloja + Apply Per Tip Primijeni po vrhu + Apply next selected layer comp Primijeni sljedeću odabranu kompoziciju slojeva + Apply previous selected layer comp Primijeni prethodnu odabranu kompoziciju slojeva + Artboard Radna ploča + Artboard Background Color Boja pozadine radne ploče + Assign working space Dodijeli radni prostor + Author: Autor: + Auto Hide Automatski sakrij + Auto-Select: Automatski odabir: + Automatically level near-horizontal line segments Automatski izravnaj gotovo vodoravne segmente linija + Automatically straighten and level Automatski ispravi i izravnaj + Automatically straighten near-vertical line segments Automatski ispravi gotovo okomite segmente linija + Background Pozadina + Background Color Boja pozadine + Background Contents Sadržaj pozadine + Custom background color Prilagođena boja pozadine + Balance Balans + Baseline Osnovna linija + Baseline shift Pomak osnovne linije + Basic Osnovno + Before (Y) Prije (Y) + Begin recording Započni snimanje + Bend: Savijanje: + Bicubic Bikubično + Bicubic (smooth gradients) Bikubično (glatki gradijenti) + Automatic Automatski + Bicubic Smoother (enlargement) Bikubično glađe (povećavanje) + Bicubic Sharper (reduction) Bikubično oštrije (smanjivanje) + Bilinear Bilinearno + Black Crna + Black & White Crno-bijelo + Black Point Compensation Kompenzacija crne tačke + Black, White Crna, bijela + Blacks Crne + Blending Stapanje + Blending Options Opcije stapanja + Blue Plava + Blue, Red, Yellow Plava, crvena, žuta + Both Axes Obje ose + Bottom Dno + Brightness Svjetlina + Brightness Jitter Varijacija svjetline + Brightness/Contrast Svjetlina/kontrast + Bring the selected pin forward Pomakni odabranu pribadaču naprijed + Brush Preset picker Birač unaprijed postavljenih četkica + Brush Reconstruct Options Opcije rekonstrukcije četkicom + Brush Tool Options Opcije alata četkica + Cache Level: Nivo keša: + Camera Raw isn't open Camera Raw nije otvoren + Cancel Otkaži + Cancel (Esc) Otkaži (Esc) + Cancel any current edits (Esc) Otkaži sve trenutne izmjene (Esc) + Cancel current crop operation (Esc) Otkaži trenutno izrezivanje (Esc) + Cancel transform (Esc) Otkaži transformaciju (Esc) + Cancel warp (Esc) Otkaži iskrivljenje (Esc) + Canvas Platno + Canvas extension color: Boja proširenja platna: + Capture Snimi + Capture the visibility, position and style of every layer with + below, then switch between versions. Zabilježite vidljivost, položaj i stil svakog sloja pomoću + ispod, zatim prebacujte između verzija. + Catch-up on Stroke End Sustigni na kraju poteza + Center Sredina + Center Crop Marks Središnje oznake za rezanje + Center text Centriraj tekst + Change Promijeni + Change All Promijeni sve + Change Screen Mode (F) Promijeni način prikaza ekrana (F) + Change To: Promijeni u: + Channel Mixer Mikser kanala + Channel: Kanal: + Check All Layers Provjeri sve slojeve + Check Spelling Provjeri pravopis + Clarity Jasnoća + Clear All Očisti sve + Clear Override Očisti zamjenu + Clear Samples Očisti uzorke + Click sets foreground · right-click sets background Klik postavlja prednji plan · desni klik postavlja pozadinu + Click the first point or press {key} to close · Esc cancels Kliknite prvu tačku ili pritisnite {key} za zatvaranje · Esc otkazuje + Click to add a point · drag off or {key}-click or Delete to remove Kliknite da dodate tačku · prevucite van ili {key}-klik ili Delete za uklanjanje + Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknite za primjenu, {key}-klik za brisanje zamjena, dvostruki klik za opcije stila + Click to edit the gradient Kliknite da uredite gradijent + Click to restore the current color Kliknite da vratite trenutnu boju + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknite za uvećanje · {key}-klik za umanjenje · prevucite desno/lijevo za uvećanje/umanjenje + Click with the Note tool to add a note. Kliknite alatom Bilješka da dodate bilješku. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · {key} završi · Esc otkaži + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · {alt}-prevlačenje: prelomi ručku · Klik ili prevlačenje prve tačke: zatvori · {key} završi · Esc otkaži + Clip to layer below Isijeci prema sloju ispod + Clipped Isječeno + Close Style Options Zatvori opcije stila + Cmd Cmd + Coated CMYK Premazni CMYK + Color & Swatches Boja i uzorci boja + Color Balance Balans boja + Color Detail Detalji boje + Color Grading Gradiranje boja + Color Lookup Pretraga boja + Color Management Upravljanje bojama + Color Mixer Mikser boja + Color Mode Način boja + Color Priority Prioritet boje + Color Range preview Pregled raspona boja + Color: Boja: + Colorize Oboji + Colors Boje + Commit Perspective Warp ({key}) Potvrdi iskrivljenje perspektive ({key}) + Commit Puppet Warp ({key}) Potvrdi iskrivljenje lutkom ({key}) + Commit any current edits ({key}) Potvrdi sve trenutne izmjene ({key}) + Commit current crop operation ({key}) Potvrdi trenutno izrezivanje ({key}) + Commit transform ({key}) Potvrdi transformaciju ({key}) + Commit warp ({key}) Potvrdi iskrivljenje ({key}) + Constant Konstantno + Constrain proportions Zadrži proporcije + Contact Sheet II Kontaktni list II + Content-Aware Svjesno sadržaja + Contiguous Susjedno + Contrast Kontrast + Control: Kontrola: + Convert to sRGB Pretvori u sRGB + Copies Kopije + Copper Bakar + Copyright Autorska prava + Copyright and Contact Info Autorska prava i kontakt informacije + Corner Crop Marks Ugaone oznake za rezanje + Corner radius Poluprečnik ugla + Correction Korekcija + Count Jitter Varijacija broja + Create Kreiraj + Create Droplet Kreiraj droplet + Create Texture Kreiraj teksturu + Create Video Timeline Kreiraj video vremensku liniju + Create a new count group Kreiraj novu grupu brojanja + Create a new document or open an existing file. Kreirajte novi dokument ili otvorite postojeću datoteku. + Create a new group Kreiraj novu grupu + Create a new layer Kreiraj novi sloj + Create new action Kreiraj novu akciju + Create new channel Kreiraj novi kanal + Create new document from current state Kreiraj novi dokument iz trenutnog stanja + Create new fill or adjustment layer Kreiraj novi sloj ispune ili podešavanja + Create new gradient Kreiraj novi gradijent + Create new group Kreiraj novu grupu + Create new layer comp Kreiraj novu kompoziciju slojeva + Create new pattern from the selection Kreiraj novi uzorak iz odabira + Create new shape from the current path Kreiraj novi oblik iz trenutne putanje + Create new snapshot Kreiraj novi snimak + Create new tool preset Kreiraj novi predložak alata + Crop Factor Faktor izrezivanja + Ctrl Ctrl + Current & Below Trenutni i ispod + Current Layer Trenutni sloj + Current Size Trenutna veličina + Current Tool Only Samo trenutni alat + Curve Kriva + Curves Krive + Custom Prilagođeno + Custom Shapes Prilagođeni oblici + Cyan Cijan + Cyan · Red Cijan · crvena + Darken Potamni + Darks Tamni dijelovi + Data Sets Skupovi podataka + Default Zadano + Default Foreground and Background Colors (D) Zadane boje prednjeg plana i pozadine (D) + Default printer Zadani štampač + Default restored. Zadano vraćeno. + Define Definiši + Define at least one variable first. Prvo definišite barem jednu varijablu. + Dehaze Uklanjanje izmaglice + Delete Cropped Pixels Izbriši izrezane piksele + Delete Group Izbriši grupu + Delete Layer Mask Izbriši masku sloja + Delete Layers Izbriši slojeve + Add Shortcut Dodaj prečicu + Delete Shortcut Izbriši prečicu + Delete Timeline Izbriši vremensku liniju + Delete Tool Preset Izbriši predložak alata + Delete count group Izbriši grupu brojanja + Delete current channel Izbriši trenutni kanal + Delete current state Izbriši trenutno stanje + Delete effect layer Izbriši sloj efekta + Delete layer ‘{name}’? Izbrisati sloj ‘{name}’? + Delete {count} layers? {names} Izbrisati slojeve ({count})? {names} + Delete layer? Izbrisati sloj? + The document is closed Dokument je zatvoren + The document or selected layers changed. Please request deletion again. Dokument ili odabrani slojevi su se promijenili. Ponovo zatražite brisanje. + Missing delete command Nedostaje naredba brisanja + Invalid delete command Nevažeća naredba brisanja + Layer deleted, but confirmation preference could not be saved: {error} Sloj je izbrisan, ali postavku potvrde nije moguće sačuvati: {error} + Delete layer Izbriši sloj + Delete layer comp Izbriši kompoziciju slojeva + Delete note Izbriši bilješku + Delete selected measurements Izbriši odabrana mjerenja + Delete style Izbriši stil + Delete tool preset Izbriši predložak alata + Density Gustoća + Density: Gustoća: + Depth Dubina + Depth Jitter Varijacija dubine + Description Opis + Deselect All Poništi sav odabir + Detail Detalji + Diamond Gradient Rombni gradijent + Difference Razlika + Diffusion Difuzija + Discard the embedded profile (don't color manage) Odbaci ugrađeni profil (bez upravljanja bojama) + Discontiguous Nesusjedno + Discord Discord + Dismiss Odbaci + Display P3 Display P3 + Display Text Tekst za prikaz + Dither Dithering + Divide… Podijeli… + Document Dokument + Don't Save Nemoj sačuvati + No Ne + Yes Da + Done Gotovo + Dots Tačke + Drag a rectangle around the object Prevucite pravougaonik oko objekta + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Prevucite po slici da dodate ograničenje; Shift za vodoravno/okomito; desni klik na ograničenje da ga promijenite ili izbrišete. + Drag to move the active shape's path or the Work Path Prevucite da pomjerite putanju aktivnog oblika ili radnu putanju + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: odaberi sidro · Prevlačenje: pomjeri sidro, ručku ili segment · {shift}-klik: dodaj · {alt}-klik: cijela podputanja + Draw with the Pen tool (P) or make a work path from a selection. Crtajte alatom Pero (P) ili napravite radnu putanju iz odabira. + Drop an image or PSD anywhere to open it. Ispustite sliku ili PSD bilo gdje da ih otvorite. + Duplicate Layers… Dupliciraj slojeve… + Duplicate style Dupliciraj stil + Edit Toolbar… Uredi traku s alatima… + Edit in Quick Mask Mode (Q) Uredi u načinu brze maske (Q) + Edit in Standard Mode (Q) Uredi u standardnom načinu (Q) + Effects Efekti + Embed Color Profile Ugradi profil boja + Embedded Profile Mismatch Neusklađenost ugrađenog profila + Enable airbrush-style build-up effects Omogući efekte nanošenja poput zračnog kista + Enhance Edge Poboljšaj ivicu + Entire Font Cijeli font + Exact Tačno + Expansion: Proširenje: + Export Presets Izvezi predloške + Export selected measurements (CSV) Izvezi odabrana mjerenja (CSV) + Exposure Ekspozicija + Exposure and Gamma Ekspozicija i gama + Exposure: Ekspozicija: + Eyedropper Kapaljka + Factor: Faktor: + Faux Bold Lažno podebljano + Faux Italic Lažni kurziv + Feather Omekšaj ivice + Fewer Points Manje tačaka + File Settings Postavke datoteke + Fill Ispuna + Fill: Ispuna: + Filter for adjustment layers Filter za slojeve podešavanja + Filter for pixel layers Filter za pikselne slojeve + Filter for shape layers Filter za slojeve oblika + Filter for smart objects Filter za pametne objekte + Filter for type layers Filter za tekstualne slojeve + Filter: Filter: + Finger Painting Slikanje prstima + Fisheye Riblje oko + Fit Uklopi + Fit Screen Uklopi ekran + Fill Screen Ispuni ekran + Fit in View Uklopi u prikaz + Fixed Fiksno + Fixed Ratio Fiksni omjer + Fixed Size Fiksna veličina + Flip Preokreni + Flip X Preokreni X + Flip X Jitter Varijacija preokretanja X + Flip Y Preokreni Y + Flip Y Jitter Varijacija preokretanja Y + Flow Protok + Flow Jitter Varijacija protoka + Focal Length Žarišna dužina + Font Font + Font Size: Veličina fonta: + Font size Veličina fonta + Foreground Prednji plan + Foreground to Background Prednji plan prema pozadini + Foreground/Background Jitter Varijacija prednjeg plana/pozadine + Format Format + Frame from Layers… Sličica iz slojeva… + Free Slobodno + Full Spherical Potpuno sferno + Fuzziness: Nejasnoća: + GPU GPU + Gamma Correction Gama korekcija + Gamma: Gama: + Global Globalno + Glow Color Boja sjaja + Gradient Map Mapa gradijenta + Grain Zrno + Gray Siva + Gray Gamma 2.2 Siva gama 2.2 + Green Zelena + Group Grupiši + Hardness Tvrdoća + Heart Srce + Height Visina + Height: Visina: + Hide Auto Slices Sakrij automatske isječke + Hide Properties Sakrij svojstva + Highlight Compression Kompresija svijetlih tonova + Highlight Priority Prioritet svijetlih tonova + Highlights Svijetli tonovi + Home Početna + Horizontal scale Vodoravna skala + Hue Nijansa + Hue Jitter Varijacija nijanse + Hue/Saturation Nijansa/zasićenje + Hyphenate Rastavljaj riječi + Ignore Zanemari + Ignore All Zanemari sve + Image Size Veličina slike + Import Brushes… Uvezi četkice… + Import brushes (.abr) Uvezi četkice (.abr) + Import Presets Uvezi predloške + Imported Uvezeno + Indent First Uvlaka prvog + Indent Left Uvlaka lijevo + Indent Right Uvlaka desno + Indent first line Uvuci prvi red + Indent left margin Uvuci lijevu marginu + Indent right margin Uvuci desnu marginu + Input: Ulaz: + Inside Unutra + Interlaced Isprepleteno + Interpolation: Interpolacija: + Intersect with selection ({key}) Presijeci s odabirom ({key}) + Invert All Obrni sve + Invert has no settings. Obrni nema postavki. + Italic Kurziv + Join us on Discord Pridružite nam se na Discordu + Justify all Obostrano poravnaj sve + Justify last centered Obostrano, posljednji red po sredini + Justify last left Obostrano, posljednji red lijevo + Justify last right Obostrano, posljednji red desno + Kerning Podrezivanje + Keyboard Shortcuts Prečice na tastaturi + Keyboard Shortcuts and Menus Prečice na tastaturi i meniji + Kind Vrsta + Label Size Veličina oznake + Labels Oznake + Lanczos (sharp) Lanczos (oštro) + Landscape Položeno + Last Document State Posljednje stanje dokumenta + Layout Raspored + Leading Prored + Leading (set to the font size × auto-leading when Auto) Prored (postavlja se na veličinu fonta × automatski prored kad je Automatski) + Leave as is (don't color manage) Ostavi kako jeste (bez upravljanja bojama) + Left align text Poravnaj tekst lijevo + Length: Dužina: + Level Nivo + Levels Nivoi + Lighten Posvijetli + Lightness Svjetloća + Lights Svijetli dijelovi + Limits: Ograničenja: + Linear Gradient Linearni gradijent + Link layers Poveži slojeve + Link width and height Poveži širinu i visinu + Liquify is not open Ukapljivanje nije otvoreno + Load Učitaj + Load 3D LUT… Učitaj 3D LUT… + Load channel as selection Učitaj kanal kao odabir + Load… Učitaj… + Localized Color Clusters Lokalizovane skupine boja + Lock Zaključaj + Lock all Zaključaj sve + Lock image pixels Zaključaj piksele slike + Lock layer Zaključaj sloj + Lock position Zaključaj položaj + Lock transparent pixels Zaključaj prozirne piksele + Lock: Zaključaj: + Logical Length: Logička dužina: + Logical Units: Logičke jedinice: + Luminance Luminansa + Luminance Detail Detalji luminanse + Magenta Magenta + Magenta · Green Magenta · zelena + Maintain aspect ratio Zadrži omjer stranica + Marker Size Veličina markera + Mask All Maskiraj sve + Mask Options Opcije maske + Masking Maskiranje + Master Glavno + Matte Mat + Median: Medijan: + Menus Meniji + Mesh Size Veličina mreže poligona + Metadata Metapodaci + Metrics Metrika + Midpoint Srednja tačka + Midtones Srednji tonovi + Midtones from: Srednji tonovi iz: + Midtones to: Srednji tonovi u: + Minimum Depth Minimalna dubina + Minimum Diameter Minimalni prečnik + Minimum Roundness Minimalna zaobljenost + Mode: Način: + Monochrome Jednobojno + More Points Više tačaka + Move effect layer down Pomakni sloj efekta dolje + Move effect layer up Pomakni sloj efekta gore + Move to Premjesti u + Name Naziv + Name: Naziv: + Nearest Neighbor Najbliži susjed + Nearest Neighbor (hard edges) Najbliži susjed (oštre ivice) + New Size Nova veličina + New document… Novi dokument… + New effect layer Novi sloj efekta + New selection Novi odabir + Next frame Sljedeća sličica + Next note Sljedeća bilješka + No Color Bez boje + No Color Management Bez upravljanja bojama + No Dither Bez ditheringa + No author Bez autora + No character style Bez stila znakova + No document Nema dokumenta + No matching commands Nema odgovarajućih naredbi + No presets for the current tool. Nema predložaka za trenutni alat. + No presets of this type. Nema predložaka ove vrste. + No properties Nema svojstava + No tool presets. Nema predložaka alata. + Noise Reduction Smanjenje šuma + Normal Normalno + Not in Dictionary: Nije u rječniku: + OK U redu + Offset Pomak + Only Web Colors Samo web boje + Opacity Neprozirnost + Opacity Jitter Varijacija neprozirnosti + Opacity: Neprozirnost: + Open a document to define variables. Otvorite dokument da definišete varijable. + Open a document to select a color range. Otvorite dokument da odaberete raspon boja. + Open a document to use the timeline. Otvorite dokument da koristite vremensku liniju. + Optical Optičko + Optimized Optimizirano + Option-click to define a source point to clone from Option-klik za definisanje izvorne tačke za kloniranje + Original Original + Output Channel: Izlazni kanal: + Output Levels: Izlazni nivoi: + Output: Izlaz: + Outside Vani + Overlay Color Boja preklopa + Overlay Opacity Neprozirnost preklopa + Overlay: Rule of Thirds Preklop: pravilo trećina + Override Pressure Zamijeni pritisak + Override Rotation Zamijeni rotaciju + Override Tilt Zamijeni nagib + Override Tilt X Zamijeni nagib X + Override Tilt Y Zamijeni nagib Y + PRESET DETAILS DETALJI PREDLOŠKA + PSD PSD + Paint Overlay Preklop boje + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Lokacije panela čuvaju se u radnom prostoru. Prečice na tastaturi, meniji i traka s alatima su neobavezni. + Paper Papir + Path Putanja + Pattern Uzorak + Pattern Size Veličina uzorka + Pause Pauza + Percent Postotak + Perceptual Perceptivno + Perspective Warp is not active Iskrivljenje perspektive nije aktivno + Perspective Warp: draw planes along the image's perspective, then switch to Warp Iskrivljenje perspektive: nacrtajte ravni duž perspektive slike, zatim se prebacite na Iskrivljenje + Photo Filter Foto filter + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft upravlja bojama + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — izvorni uređivač slika otvorenog koda, napisan u Rustu. + Pin Depth: Dubina pribadače: + Pixel Length: Dužina u pikselima: + Pixels/Centimeter Piksela/centimetar + Pixels/Inch Piksela/inč + Pixels: Pikseli: + Play Reprodukuj + Play selection Reprodukuj odabir + Portrait Uspravno + Position Položaj + Position and Size Položaj i veličina + Posterize Posterizuj + Preserve Details Očuvaj detalje + Preserve Luminosity Očuvaj osvijetljenost + Preset Unaprijed postavljeno + Preset Type Vrsta predloška + Preset: Unaprijed postavljeno: + Presets: Unaprijed postavljene vrijednosti: + Press keys… Pritisnite tipke… + Pressure Pritisak + Pressure for Opacity Pritisak za neprozirnost + Pressure for Size Pritisak za veličinu + Prevent auto-nesting in and out of Artboards and Frames Spriječi automatsko ugnježđivanje u radne ploče i okvire i iz njih + Preview Pregled + Previous frame Prethodna sličica + Previous note Prethodna bilješka + Print Štampaj + Printer Štampač + Printer Manages Colors Štampač upravlja bojama + Printer Setup Podešavanje štampača + Printing Marks Štamparske oznake + Progressive Progresivno + Promote Podigni + Protect Detail Zaštiti detalje + Protect Foreground Color Zaštiti boju prednjeg plana + Protect Tones Zaštiti tonove + Proximity Match Podudaranje blizine + Pulled String Mode Način povučenog konca + Puppet Warp is not active Iskrivljenje lutkom nije aktivno + Purity Čistoća + Quality Kvalitet + Quick Actions Brze akcije + Radial Gradient Radijalni gradijent + Radius Poluprečnik + Radius: Poluprečnik: + Range: Raspon: + Rate Brzina + Recently Used Nedavno korišteno + Reconstruct Rekonstruiši + Record ● a sequence of edits, then play ▶ it on any document. Snimite ● niz izmjena, zatim ga reprodukujte ▶ na bilo kojem dokumentu. + Red Crvena + Redefine style by current selection Ponovo definiši stil prema trenutnom odabiru + Refine Doradi + Reflected Gradient Reflektovani gradijent + Registration Marks Registracijske oznake + Regular Obično + Relative Relativno + Relative Colorimetric Relativna kolorimetrija + Remove Ukloni + Remove All Pins Ukloni sve pribadače + Remove Background Ukloni pozadinu + Remove Planes Ukloni ravni + Remove Tool Alat za uklanjanje + Remove stop Ukloni tačku + Remove variable Ukloni varijablu + Rename Preimenuj + Rename Group… Preimenuj grupu… + Rename Layer… Preimenuj sloj… + Rename Tool Preset… Preimenuj predložak alata… + Rename… Preimenuj… + Resample Ponovo uzorkuj + Reset All to Defaults Vrati sve na zadano + Import Shortcuts… Uvezi prečice… + Load a keyboard shortcut set saved as .kys Učitaj skup prečica na tastaturi sačuvan kao .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Uvezeno prečica iz {file}: {n}. Kliknite U redu da ih zadržite. + {n} commands are not in PhotoCraft. Naredbi kojih nema u PhotoCraftu: {n}. + Keyboard shortcuts imported Prečice na tastaturi uvezene + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Vaš skup prečica na tastaturi {set} koristi se ovdje: {n} prečica razlikuje se od zadanih. Uredi › Prečice na tastaturi ih prikazuje; Vrati sve na zadano to poništava. + Import keyboard shortcuts? Uvesti prečice na tastaturi? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft je pronašao vaš skup prečica na tastaturi {set} iz drugog uređivača slika na ovom računaru: {n} njegovih prečica razlikuje se od PhotoCraftovih. Uvesti ga? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Uredi › Prečice na tastaturi › Vrati sve na zadano poništava uvoz. + Don't Import Nemoj uvesti + The keyboard shortcut set was not imported: {error} Skup prečica na tastaturi nije uvezen: {error} + Reset Section Vrati odjeljak na početno + Reset Transform Vrati transformaciju na početno + Reset to defaults Vrati na zadano + Resolution Rezolucija + Resolution: Rezolucija: + Restore All Vrati sve + Restore Defaults Vrati zadane vrijednosti + Restrictive (Web) Restriktivno (web) + Right align text Poravnaj tekst desno + Rigid Kruto + Rotate: Rotiraj: + Rotation Rotacija + Roughness Grubost + Roundness Zaobljenost + Roundness Jitter Varijacija zaobljenosti + Rulers & Grids Ravnala i mreže + Sample All Layers Uzorkuj sve slojeve + Sample: Uzorak: + Sampling: Background Swatch Uzorkovanje: uzorak pozadine + Sampling: Continuous Uzorkovanje: neprekidno + Sampling: Once Uzorkovanje: jednom + Saturate Zasiti + Saturation Zasićenje + Saturation Jitter Varijacija zasićenja + Save HTML and Images Sačuvaj HTML i slike + Save as PDF Sačuvaj kao PDF + Save selection as channel Sačuvaj odabir kao kanal + Save… Sačuvaj… + Scale to Fit Media Skaliraj da stane na medij + Scatter Raspršenost + Scratch disks Radni diskovi + Search Pretraga + Search commands Pretraži naredbe + Search commands, tools and panels… Pretraži naredbe, alate i panele… + Search fonts Pretraži fontove + Select All Odaberi sve + Select Subject Odaberi subjekat + Select a type layer or click into text to insert glyphs Odaberite tekstualni sloj ili kliknite u tekst da umetnete glifove + Select text to apply a character style Odaberite tekst da primijenite stil znakova + Select: Odaberi: + Selections Odabiri + Selective Selektivno + Selective Color Selektivna boja + Send the selected pin backward Pomakni odabranu pribadaču nazad + Set additional smoothing options Postavi dodatne opcije zaglađivanja + Set background color Postavi boju pozadine + Set foreground color Postavi boju prednjeg plana + Set painting symmetry options Postavi opcije simetrije slikanja + Symmetry Off Simetrija isključena + Set shape fill type Postavi vrstu ispune oblika + Set shape stroke type Postavi vrstu poteza oblika + Set the text color Postavi boju teksta + Create warped text Napravi iskrivljeni tekst + Shadows Sjene + Shape: Oblik: + Sharpening Izoštravanje + Shift Shift + Shortcut removed. Prečica uklonjena. + Show All Menu Items Prikaži sve stavke menija + Show Backdrop Prikaži pozadinu + Show Guides Prikaži vodilice + Show Mask Prikaži masku + Show Mesh Prikaži mrežu poligona + Show Overlay Prikaži preklop + Show Transform Controls Prikaži kontrole transformacije + Show or hide the Notes panel Prikaži ili sakrij panel Bilješke + Show: Prikaži: + Sides Stranice + Sides: Stranice: + Size Veličina + Size Jitter Varijacija veličine + Size: Veličina: + Slice Options… Opcije isječka… + Slices From Guides Isječci iz vodilica + Slices are locked (View › Lock Slices) Isječci su zaključani (Prikaz › Zaključaj isječke) + Small Caps Mala velika slova + Smoothing Zaglađivanje + Space After Razmak poslije + Space Before Razmak prije + Spacing Razmak + Spell check complete. Provjera pravopisa je završena. + Split: Podjela: + Star ratio Omjer zvijezde + Stop playing/recording Zaustavi reprodukciju/snimanje + Straighten Ispravi + Straighten Layer Ispravi sloj + Strength: Jačina: + Strikethrough Precrtano + Stroke Potez + Stroke Catch-up Sustizanje poteza + Stroke: Potez: + Location: Lokacija: + Style Stil + Style Options Opcije stila + Style Options… Opcije stila… + Style: Normal Stil: Normalni + Subtract from Sample Oduzmi od uzorka + Subtract from selection ({key}) Oduzmi od odabira ({key}) + Suggestions: Prijedlozi: + Switch Foreground and Background Colors (X) Zamijeni boje prednjeg plana i pozadine (X) + Swaps height and width Zamjenjuje visinu i širinu + Switch between free transform and warp modes Prebacuj između slobodne transformacije i iskrivljenja + Switch theme Promijeni temu + Temperature Temperatura + Tetrahedral Tetraedarsko + Text Position: Položaj teksta: + Texture Tekstura + Texture Each Tip Tekstura za svaki vrh + Threshold Prag + Threshold Level Nivo praga + Thumbnail size (smallest: list) Veličina sličica (najmanja: lista) + Tilt X Nagib X + Tilt Y Nagib Y + Tint Preliv + Toggle count group visibility Uključi/isključi vidljivost grupe brojanja + Toggle text orientation Promijeni orijentaciju teksta + Tolerance Tolerancija + Tolerance: Tolerancija: + Tone: Ton: + Toolbar Traka s alatima + Top Vrh + Total: Ukupno: + Tracking Razmak slova + Tracking (1/1000 em) Razmak slova (1/1000 em) + Transparency Prozirnost + Transparent Prozirno + Trilinear Trilinearno + Type Styles Stilovi teksta + Type: Vrsta: + Underline Podvučeno + Untitled Bez naslova + Untitled-1 Bez naslova-1 + Update layer comp (visibility, position and appearance) Ažuriraj kompoziciju slojeva (vidljivost, položaj i izgled) + Use Default Koristi zadano + Use Legacy Koristi naslijeđeno + Use Measurement Scale Koristi skalu mjerenja + Variables bind a layer's visibility, text or pixels to a named data slot. Varijable vežu vidljivost, tekst ili piksele sloja za imenovano mjesto za podatke. + Vertical scale Okomita skala + Vibrance Živost + View Options Opcije prikaza + Vignetting Vinjetiranje + Violet, Orange Ljubičasta, narandžasta + Visibility Vidljivost + Visible Vidljivo + Warp: Iskrivljenje: + Web Snap Hvatanje web boja + Weight Debljina + Weight: Debljina: + White Bijela + White Balance: As Shot Balans bijele: kako je snimljeno + Whites Bijele + Width Širina + Slant Nagib + Width: Širina: + Work Path Radna putanja + Yellow Žuta + Yellow · Blue Žuta · plava + Zoom Zumiranje + Zoom in Uvećaj + Zoom out Umanji + command or shortcut naredba ili prečica + current trenutno + image file path putanja slikovne datoteke + modified izmijenjeno + name naziv + new novo + {name} options Opcije: {name} + ↑↓ navigate {key} run esc close ↑↓ kretanje {key} pokreni esc zatvori + Version {version} Verzija {version} + New Document Novi dokument + New Document With Mask Novi dokument s maskom + New Layer With Mask Novi sloj s maskom + New Layer Novi sloj + Preset Name Naziv predloška + No saved presets yet. Još nema sačuvanih predložaka. + Error Greška + Command Naredba + Adjustment layers have nothing to transform Slojevi podešavanja nemaju ništa za transformisanje + Altitude Visina + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua Akva + Art & Illustration Umjetnost i ilustracija + ArtCraft ArtCraft + Background Eraser Tool Alat gumica za pozadinu + Bevel & Emboss Zakošenje i reljef + Blend Mode Način stapanja + Blues Plave + Blur Tool Alat za zamućenje + Bold Podebljano + Brush Četkica + Block Blok + Brush Pose Poza četkice + Brush Tip Shape Oblik vrha četkice + Brush Tool Alat četkica + Build-up Nanošenje + Burn Tool Alat za zatamnjivanje + Centimeters Centimetri + Choke Sužavanje + Classic Klasično + Click to add text Kliknite da dodate tekst + Click to fill similar colors Kliknite da ispunite slične boje + Clone Stamp Tool Alat pečat za kloniranje + Color Burn Tamnjenje boje + Color Dodge Posvjetljivanje boje + Color Dynamics Dinamika boje + Color Overlay Prekrivanje bojom + Comic Book Strip + Conform Uskladi + Content-Aware Fill needs a selection on a pixel layer Ispuna svjesna sadržaja zahtijeva odabir na pikselnom sloju + Could not transform: the layer is empty Nije moguće transformisati: sloj je prazan + Could not use Perspective Warp: the layer is empty Nije moguće koristiti iskrivljenje perspektive: sloj je prazan + Could not use Puppet Warp: no opaque pixels Nije moguće koristiti iskrivljenje lutkom: nema neprozirnih piksela + Could not use Puppet Warp: the layer is empty Nije moguće koristiti iskrivljenje lutkom: sloj je prazan + Crop Tool Alat za izrezivanje + Crosswise Poprečno + Current Tool Trenutni alat + Custom Shape Tool Alat za prilagođeni oblik + Cyans Cijan tonovi + Default PhotoCraft Size Zadana veličina PhotoCrafta + Delete Workspace Izbriši radni prostor + Diamond Romb + Dimensions: Dimenzije: + Direction Smjer + Distance Udaljenost + Document Dimensions Dimenzije dokumenta + Document Profile Profil dokumenta + Document Sizes Veličine dokumenta + Dodge Tool Alat za posvjetljivanje + Down Dolje + Drag to draw a gradient Prevucite da nacrtate gradijent + Drag to move the active layer Prevucite da pomjerite aktivni sloj + Drag to pan · hold Space with any tool Prevucite za pomjeranje · držite Razmak s bilo kojim alatom + Drop Shadow Sjena + Dual Brush Dvostruka četkica + Edge Ivica + Efficiency Efikasnost + Efficiency: 100% Efikasnost: 100% + Ellipse Tool Alat za elipsu + Elliptical Marquee Tool Alat eliptični markiz + Single Row Marquee Tool Alat markiz jednog reda + Single Column Marquee Tool Alat markiz jedne kolone + Emboss Reljef + Eraser Gumica + Eraser Tool Alat gumica + ExtraBold Ekstra podebljano + ExtraLight Ekstra tanko + Eyedropper Tool Alat kapaljka + Fade Izblijedi + Fill Opacity Neprozirnost ispune + Film & Video Film i video + From Od + General Općenito + GitHub GitHub + Gradient Overlay Prekrivanje gradijentom + Gradient Tool Alat za gradijent + Greens Zelene + Hand Tool Alat ruka + Hard Tvrdo + Hard Mix Tvrdo miješanje + Healing Brush Tool Alat četkica za liječenje + Highlights from: Svijetli tonovi iz: + History Brush Tool Alat četkica historije + Horizontal Type Tool Alat vodoravni tekst + Horizontal Type Mask Tool Alat maska vodoravnog teksta + Vertical Type Mask Tool Alat maska okomitog teksta + Image Size: Veličina slike: + Inches Inči + Indexed Indeksirano + Initial Direction Početni smjer + Inner Bevel Unutrašnje zakošenje + Inner Glow Unutrašnji sjaj + Inner Shadow Unutrašnja sjena + Lab Lab + Landscape, 10 x 8 Položeno, 10 x 8 + Landscape, 6 x 4 Položeno, 6 x 4 + Landscape, 7 x 5 Položeno, 7 x 5 + Lasso Laso + Lasso Tool Alat laso + Layer Count Broj slojeva + Layer Knocks Out Drop Shadow Sloj izbija sjenu + Legal Legal + Letter Letter + Light Svijetlo + Line Tool Alat linija + Linear Linearno + Linear Burn Linearno tamnjenje + Linear Height Linearna visina + Link with Layer Poveži sa slojem + Lorem Ipsum Lorem Ipsum + Magentas Magenta tonovi + Magic Eraser Tool Alat čarobna gumica + Magic Wand Tool Alat čarobni štapić + Marquee Markiz + Mean: Srednja vrijednost: + Measurement Scale Skala mjerenja + Measurement Scale Marker Oznaka skale mjerenja + Millimeters Milimetri + Mobile Mobilno + Move Tool Alat za pomjeranje + Could not use the move tool because the layer is locked. Nije moguće koristiti alat za pomjeranje jer je sloj zaključan. + Could not complete the Free Transform command because the layer is locked. Naredbu Slobodna transformacija nije moguće dovršiti jer je sloj zaključan. + Could not transform because the layer is locked. Transformacija nije moguća jer je sloj zaključan. + Multiply Množenje + My Brushes Moje četkice + Neutrals Neutralni tonovi + New Workspace Novi radni prostor + Note Tool Alat bilješka + Object Selection Tool Alat za odabir objekta + Off Isključeno + Open Otvori + Orange Narandžasta + Original Ratio Originalni omjer + Out Of Gamut Izvan gamuta + Outer Bevel Vanjsko zakošenje + Outer Glow Vanjski sjaj + Overlay Prekrivanje + Paint Bucket Tool Alat kanta za boju + Paints from the document's opening state Slika iz početnog stanja dokumenta + Paints from the History panel's source state Slika iz izvornog stanja panela Historija + Sets the source for the history brush Postavlja izvor za četkicu historije + Path Selection Tool Alat za odabir putanje + Direct Selection Tool Alat za direktni odabir + Add Anchor Point Tool Alat za dodavanje sidrene tačke + Delete Anchor Point Tool Alat za brisanje sidrene tačke + Click a segment to add an anchor point · {alt}-click an anchor to delete it Kliknite segment da dodate sidrenu tačku · {alt}-klik na sidro da ga izbrišete + Click an anchor point to delete it · {alt}-click a segment to add one Kliknite sidrenu tačku da je izbrišete · {alt}-klik na segment da dodate tačku + Pattern Overlay Prekrivanje uzorkom + Pen Pressure Pritisak olovke + Pen Tilt Nagib olovke + Pen Tool Alat pero + Convert Point Tool Alat za pretvaranje tačke + Photo Fotografija + PhotoCraft website Web stranica PhotoCrafta + Picas Pice + Pillow Emboss Jastučasti reljef + Pixel Replacement Zamjena piksela + Pixels Pikseli + Points Tačke + Polygon Tool Alat mnogougao + Polygonal Lasso Tool Alat mnogougaoni laso + Portrait, 4 x 6 Uspravno, 4 x 6 + Portrait, 5 x 7 Uspravno, 5 x 7 + PostScript (72 points/inch) PostScript (72 tačke/inč) + Postcard Razglednica + Poster Poster + Pro (Dark) Pro (tamna) + Pro (Light) Pro (svijetla) + Pro (Light Gray) Pro (svijetlosiva) + Pro (Medium Gray) Pro (srednje siva) + Protect Texture Zaštiti teksturu + Purple Ljubičasta + Quick Mask Brza maska + Quick Selection Tool Alat za brzi odabir + Radial Radijalno + Rasterize Layer Rasteriziraj sloj + Rasterize Layers Rasteriziraj slojeve + Ratio Omjer + Recent Nedavno + Rectangle Tool Alat pravougaonik + Rectangular Marquee Tool Alat pravougaoni markiz + Reds Crvene + Reflected Reflektovano + Sample: foreground color Uzorak: boja prednjeg plana + Sampled Colors Uzorkovane boje + Satin Saten + Save Pixel Aspect Ratio Sačuvaj omjer stranica piksela + Scattering Raspršivanje + Scratch Sizes Veličine radnog prostora + Select Data Points Odaberi tačke podataka + Selection Odabir + SemiBold Polupodebljano + Shadows up to: Sjene do: + Shape Dynamics Dinamika oblika + Sharpen Tool Alat za izoštravanje + Show Extras Options Prikaži opcije dodataka prikaza + Slice Select Tool Alat za odabir isječka + Slice Tool Alat za isječke + Smart Filters Pametni filteri + Smudge Tool Alat za razmazivanje + Soft Meko + Soften Omekšaj + Source Izvor + Source: set Izvor: postavljen + Sponge Tool Alat spužva + Spot Healing Brush Tool Alat četkica za tačkasto liječenje + Spread Širenje + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Std Dev: Std. dev.: + Studio (Dark) Studio (tamna) + Studio (Light) Studio (svijetla) + Stylus Wheel Točkić olovke + Subtract Oduzmi + Tabloid Tabloid + Text Replacement Zamjena teksta + Thin Tanko + Traditional (72.27 points/inch) Tradicionalno (72,27 tačaka/inč) + Transfer Prijenos + Triangle Tool Alat trougao + Unsaved changes Nesačuvane promjene + Uses the foreground and background colors. Koristi boje prednjeg plana i pozadine. + Web Web + Web Large Web veliko + Web Minimum Web minimum + Web Most Common Web najčešće + WebP (lossless) WebP (bez gubitaka) + Wet Edges Mokre ivice + Yellows Žute + Zoom Tool Alat za zumiranje + {name} Properties Svojstva: {name} + Revert “{name}” to the last saved version? Your changes will be lost. Vratiti „{name}” na posljednju sačuvanu verziju? Vaše promjene će biti izgubljene. + Do you want to save the changes you made to “{name}” before quitting? Želite li sačuvati promjene koje ste napravili u „{name}” prije izlaska? + Do you want to save the changes you made to “{name}” before closing? Želite li sačuvati promjene koje ste napravili u „{name}” prije zatvaranja? + Doc: {flat}/{layered} Dok.: {flat}/{layered} + Scratch: {size} Radni prostor: {size} + Untagged {mode} {mode} bez oznake + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch Rezolucija: {dpi} piksela/inč + Doc: {w} × {h} px Dok.: {w} × {h} px + Frame {current} / {total} Sličica {current} / {total} + Count: {n} Broj: {n} + BLANK DOCUMENT PRESETS ({n}) PREDLOŠCI PRAZNIH DOKUMENATA ({n}) + Constraints ({n}) Ograničenja ({n}) + Align {what} Poravnaj: {what} +@plural {n} Layer|{n} Layers {n} sloj|{n} sloja|{n} slojeva +@plural {n} layer|{n} layers {n} sloj|{n} sloja|{n} slojeva +@plural Group · {n} layer|Group · {n} layers Grupa · {n} sloj|Grupa · {n} sloja|Grupa · {n} slojeva + Additional plugins folder Dodatni folder dodataka + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Dozvoli kontrolu agentu + Allow scripts to connect Dozvoli skriptama povezivanje + Always Uvijek + Always create smart objects when placing Uvijek kreiraj pametne objekte pri postavljanju + Animated zoom Animirano zumiranje + Apply auto tone Primijeni automatski ton + Ask Pitaj + Ask before saving layered tiff Pitaj prije čuvanja TIFF-a sa slojevima + Auto collapse icon panels Automatski skupi panele ikona + Auto show hidden panels Automatski prikaži skrivene panele + Auto show home screen Automatski prikaži početni ekran + Autosave Automatsko čuvanje + Autosave minutes Minute automatskog čuvanja + Beep when done Zvučni signal po završetku + Bicubic automatic Bikubično automatski + Bicubic sharper Bikubično oštrije + Bicubic smoother Bikubično glađe + Bit depth Dubina bita + Brush preview color Boja pregleda četkice + Cache levels Nivoi keša + Cache tile size Veličina pločice keša + Camera Raw Defaults Zadane postavke Camera Raw + Canvas border Okvir platna + Show canvas dots Prikaži tačke platna + Clip thumbnails to layer bounds Ograniči sličice na granice sloja + Clip Thumbnails to Layer Bounds Ograniči sličice na granice sloja + No Thumbnails Bez sličica + Small Thumbnails Male sličice + Medium Thumbnails Srednje sličice + Large Thumbnails Velike sličice + Clip Thumbnails to Document Bounds Ograniči sličice na granice dokumenta + Canvas color Boja platna + Canvas custom color Prilagođena boja platna + Color picker Birač boja + Color space Prostor boja + Column width Širina kolone + Concise Sažeto + Control port Kontrolni port + Convert to srgb Pretvori u sRGB + Cursors Kursori + Custom dark Prilagođena tamna + Custom light Prilagođena svijetla + Dark Tamno + Dark gray Tamnosiva + Dashed lines Isprekidane linije + Destination Odredište + Detailed Detaljno + Disks Diskovi + Display p3 Display P3 + Double click layer mask launches select and mask Dvostruki klik na masku sloja pokreće Odaberi i maskiraj + Drop shadow Sjena + Dynamic color sliders Dinamični klizači boja + East asian Istočnoazijsko + Effect cache mb Keš efekata (MB) + Enable flick panning Omogući pomjeranje zamahom + Enable floating document window docking Omogući sidrenje plutajućih prozora dokumenata + Enable narrow options bar Omogući usku traku opcija + Enabled Omogućeno + Enhanced Controls Poboljšane kontrole + Export clipboard Izvezi međuspremnik + Extra large Ekstra veliko + File Handling Rukovanje datotekama + File path Putanja datoteke + Fill new type layers with placeholder Ispuni nove tekstualne slojeve tekstom čuvara mjesta + Font preview Pregled fonta + Full size tip Vrh pune veličine + Gamut warning color Boja upozorenja o gamutu + Gamut warning opacity Neprozirnost upozorenja o gamutu + Enable Generator Omogući Generator + Gif GIF + Glossy Sjajno + Grid color Boja mreže + Grid colors Boje mreže + Grid size Veličina mreže + Grid style Stil mreže + Grid unit Jedinica mreže + Gridline every Linija mreže svakih + Guide color Boja vodilica + Guide style Stil vodilica + Guides, Grid & Slices Vodilice, mreža i isječci + Gutter Međuprostor + History Log Zapisnik historije + History states Stanja historije + Ignore exif profile tag Zanemari EXIF oznaku profila + Image interpolation Interpolacija slike + Image previews Pregledi slika + Integrations Integracije + Interface Interfejs + Jpeg quality Kvalitet JPEG-a + Jpg JPG + Language Jezik + Large tabs Velike kartice + Legacy compositing Naslijeđeno kompozitiranje + Light gray Svijetlosiva + Line Linija + Lines Linije + Lossless Bez gubitaka + Lowercase extension Ekstenzija malim slovima + Maximize psd compatibility Maksimiziraj kompatibilnost PSD-a + Medium gray Srednje siva + Memory usage mb Upotreba memorije (MB) + Low resolution previews Pregledi niske rezolucije + Missing glyph protection Zaštita od nedostajućih glifova + Modern Moderno + Nearest neighbor Najbliži susjed + Never Nikad + Normal tip Normalni vrh + Open as smart object Otvori kao pametni objekat + Open in camera raw Otvori u Camera Raw + Open documents as tabs Otvaraj dokumente kao kartice + Overscroll Prekoračenje pomjeranja + Performance Performanse + Png PNG + Point size Veličina u tačkama + Precise Precizno + Preserve details Očuvaj detalje + Print resolution Rezolucija štampe + Pro Pro + Pro light Pro svijetla + Pro light gray Pro svijetlosiva + Pro medium Pro srednja + Pro photo Pro photo + Quick export format Format brzog izvoza + Quick export location Lokacija brzog izvoza + Recent file count Broj nedavnih datoteka + LUT folder Folder za LUT + Recent files Nedavne datoteke + Recent fonts Nedavni fontovi + Recover on launch Oporavi pri pokretanju + Remember workspace changes Zapamti promjene radnog prostora + Resize image during place Promijeni veličinu slike pri postavljanju + Rotate view with trackpad Rotiraj prikaz dodirnom pločom + Same folder Isti folder + Save in background Čuvaj u pozadini + Scratch Disks Radni diskovi + Screen Ekran + Screen resolution Rezolucija ekrana + Scrubby slider acceleration Ubrzanje klizača prevlačenjem + Sessions only Samo sesije + Sharpen for Izoštri za + Show channels in color Prikaži kanale u boji + Show crosshair in brush tip Prikaži nišan u vrhu četkice + Show extension panels Prikaži panele proširenja + Show font names in english Prikaži nazive fontova na engleskom + Show menu colors Prikaži boje menija + Show only crosshair while painting Prikaži samo nišan tokom slikanja + Show slice numbers Prikaži brojeve isječaka + Show tooltips Prikaži opise alata + Enable animations Omogući animacije + Show highlight on rollover Prikaži isticanje pri prelasku + Show transformation values Prikaži vrijednosti transformacije + Slice color Boja isječka + Smart guide color Boja pametnih vodilica + Smart quotes Pametni navodnici + Snap vector tools and transforms to pixel grid Prilijepi vektorske alate i transformacije uz mrežu piksela + Srgb sRGB + Standard Standardno + Studio Studio + Studio light Studio svijetla + Adwaita Adwaita + Adwaita dark Adwaita tamna + Subdivisions Podjele + System Sistem + Text engine Mehanizam teksta + Text file Tekstualna datoteka + Tiny Sićušno + Touch gestures Pokreti dodirom + Transparency & Gamut Prozirnost i gamut + Type units Jedinice teksta + UI font size Veličina fonta interfejsa + UI scale Skala interfejsa + Units & Rulers Jedinice i ravnala + Use additional plugins folder Koristi dodatni folder dodataka + Use esc to commit Koristi Esc za potvrdu + Use gpu Koristi GPU + Use legacy free transform Koristi naslijeđenu slobodnu transformaciju + Use shift key for tool switch Koristi tipku Shift za promjenu alata + Vary round brush hardness on hud Mijenjaj tvrdoću okrugle četkice na HUD-u + Webp WebP + World ready Spremno za svijet + Zoom clicked point to center Zumiraj kliknutu tačku u središte + Zoom resizes windows Zumiranje mijenja veličinu prozora + Zoom with scroll wheel Zumiraj točkićem za pomjeranje + Zoom with trackpad pinch Zumiraj štipanjem na dodirnoj ploči + Pass Through Propuštanje + Dissolve Rastvaranje + Darker Color Tamnija boja + Linear Dodge (Add) Linearno posvjetljivanje (dodavanje) + Lighter Color Svjetlija boja + Soft Light Meko svjetlo + Hard Light Jako svjetlo + Vivid Light Živo svjetlo + Linear Light Linearno svjetlo + Pin Light Tačkasto svjetlo + Exclusion Isključivanje + Divide Podijeli + Luminosity Osvijetljenost +@plural {n} sample|{n} samples {n} uzorak|{n} uzorka|{n} uzoraka + {added}, {removed} subtracted {added}, {removed} oduzeto + Adjustment Preview Pregled podešavanja + Airbrush: keeps depositing paint while the pointer rests. Zračni kist: nastavlja nanositi boju dok pokazivač miruje. + Angle: Ugao: + Blend If Stopi ako + Close Tab Group Zatvori grupu kartica + Collapse Skupi + Collapse All Groups Skupi sve grupe + Collapse Panel Group Skupi grupu panela + Collapse group ({key}-click: all groups) Skupi grupu ({key}-klik: sve grupe) + Create new brush from the current settings Kreiraj novu četkicu iz trenutnih postavki + Create new brush from these settings Kreiraj novu četkicu iz ovih postavki + Update the current brush with these settings Ažuriraj trenutnu četkicu ovim postavkama + Delete brush Izbriši četkicu + Drag the arrow to set the angle, the dots to set the roundness Prevucite strelicu da postavite ugao, tačke da postavite zaobljenost + Expand Proširi + Expand Panel Group Proširi grupu panela + Expand group ({key}-click: all groups) Proširi grupu ({key}-klik: sve grupe) + Fade steps Koraci blijeđenja + Hide the layer's effects ({key}-click: all layers) Sakrij efekte sloja ({key}-klik: svi slojevi) + Import Brushes… (.abr) Uvezi četkice… (.abr) + Move Group Down Pomakni grupu dolje + Move Group Up Pomakni grupu gore + Reset the brush to the defaults Vrati četkicu na zadane vrijednosti + Restore original size Vrati originalnu veličinu + Roundness: Zaobljenost: + Search Brushes Pretraži četkice + Show the layer's effects ({key}-click: all layers) Prikaži efekte sloja ({key}-klik: svi slojevi) + Brush picker Birač četkica + Erase Briši + Right click with painting tools Desni klik s alatima za slikanje + System Info… Informacije o sistemu… + System Info Informacije o sistemu + More panels Više panela + More documents Više dokumenata + These settings aren't available in PhotoCraft yet. Ove postavke još nisu dostupne u PhotoCraftu. + Graphics Grafika + Reset GPU Backend Vrati GPU pozadinski sistem na početno + Applies at next launch. Primjenjuje se pri sljedećem pokretanju. + Click to sample the foreground color · {key}-click for background Kliknite da uzmete uzorak boje prednjeg plana · {key}-klik za pozadinu + {key}-click to set the source {key}-klik za postavljanje izvora + {key} to subtract {key} za oduzimanje + Clone source {n} ({key}-click with Clone Stamp to set) Izvor kloniranja {n} ({key}-klik pečatom za kloniranje za postavljanje) + Source: not set ({key}-click with Clone Stamp) Izvor: nije postavljen ({key}-klik pečatom za kloniranje) + Pixel Layer Pikselni sloj + Type Layer Tekstualni sloj + Shape Layer Sloj oblika + Group Layer Sloj grupe + Fill Layer Sloj ispune + Smart Object Layer Sloj pametnog objekta + Create Warped Text Kreiraj iskrivljeni tekst + Rasterize Smart Object Rasteriziraj pametni objekat + Rasterize Fill Content Rasteriziraj sadržaj ispune + Rasterize Layer Style Rasteriziraj stil sloja + Unlock: picking a preset replaces these settings Otključaj: odabir predloška zamjenjuje ove postavke + Lock: keep these settings when picking another preset Zaključaj: zadrži ove postavke pri odabiru drugog predloška + Diameter: Prečnik: + Hardness: Tvrdoća: + No brush presets Nema unaprijed postavljenih četkica + Toggle the Brush Settings panel (F5) Uključi/isključi panel Postavke četkice (F5) + Create new brush preset from the current settings Kreiraj novu unaprijed postavljenu četkicu iz trenutnih postavki + Brush Settings… (F5): every option of this brush Postavke četkice… (F5): sve opcije ove četkice + Rename Brush… Preimenuj četkicu… + Delete Brush Izbriši četkicu + New Brush Preset… Nova unaprijed postavljena četkica… + Capture Brush Size in Preset Zabilježi veličinu četkice u predlošku + Include Tool Settings Uključi postavke alata + Include Color Uključi boju + Enter a name for the brush preset Unesite naziv za unaprijed postavljenu četkicu + A brush preset with this name already exists Unaprijed postavljena četkica s ovim nazivom već postoji + Brush Preset Options Opcije unaprijed postavljene četkice + Group name Naziv grupe + Brush name Naziv četkice + Brush Name Naziv četkice + Brush Stroke Potez četkicom + Off: the speed of the pointer sets the spacing Isključeno: brzina pokazivača određuje razmak + Tilt Scale Skala nagiba + Brush Projection Projekcija četkice + Apply the pen's tilt and rotation to the tip shape Primijeni nagib i rotaciju olovke na oblik vrha + Mixer Brush Četkica za miješanje + Wet Mokro + Current Brush Load Trenutno punjenje četkice + Load Brush Napuni četkicu + Clean Brush Očisti četkicu + Load the brush after each stroke Napuni četkicu nakon svakog poteza + Clean the brush after each stroke Očisti četkicu nakon svakog poteza + Mix Miješanje + Wetness Jitter Varijacija vlažnosti + Mix Jitter Varijacija miješanja + Show the layer mask as an overlay Prikaži masku sloja kao preklop + Toggle visibility Uključi/isključi vidljivost + Unlink the mask from the layer Prekini vezu maske sa slojem + Link the mask to the layer Poveži masku sa slojem + Gradient Gradijent + Gradient Options Opcije gradijenta + Align with layer Poravnaj sa slojem + Reset Alignment Vrati poravnanje na početno + Center the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) + Gradient Editor Uređivač gradijenta + Gradient picker Birač gradijenta + Save As Preset Sačuvaj kao predložak + The gradient the Gradient tool paints with Gradijent kojim slika alat za gradijent + Adds the current gradient to the Gradients panel Dodaje trenutni gradijent u panel Gradijenti + Gradient stops Tačke gradijenta + Select a type layer to edit its character and paragraph settings. Odaberite tekstualni sloj da uredite njegove postavke znakova i pasusa. + Text color Boja teksta + Type Options Opcije teksta + Anti-aliasing method Metoda zaglađivanja ivica + Horizontal type Vodoravni tekst + Vertical type Okomiti tekst + Fill path with foreground color Ispuni putanju bojom prednjeg plana + Stroke path with brush Potez putanje četkicom + Load path as a selection Učitaj putanju kao odabir + Make work path from selection Napravi radnu putanju iz odabira + Add vector mask Dodaj vektorsku masku + Create new path Kreiraj novu putanju + Delete path Izbriši putanju + Delete vector mask Izbriši vektorsku masku + CPU (no GPU acceleration) CPU (bez GPU ubrzanja) + DirectX 12 DirectX 12 + GPU backend GPU pozadinski sistem + Linux display server Linux server prikaza + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure Koristi pritisak tableta + Vulkan Vulkan + Alt-click to define a source point to clone from Alt-klik za definisanje izvorne tačke za kloniranje + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje iz koje bi se napravio odabir: nacrtajte je alatom Pero ili odaberite jednu u panelu Putanje. + Cancel opening Otkaži otvaranje + Canceled opening {name} Otkazano otvaranje {name} + Damaged Photoshop document Oštećen Photoshop dokument + The document “{name}” may be damaged. Dokument „{name}” je možda oštećen. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Neki slojevi, pikseli ili maske nisu mogli biti pročitani. Želite li nastaviti s otvaranjem? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Ako nastavite, čuvanje preko originala može trajno odbaciti nečitljive podatke. Sačuvajte kopiju da zadržite original. + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nema ugrađeni {mode} profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ima ugrađeni profil boja koji ne odgovara trenutnom {mode} radnom prostoru. + Working: {working} Radni: {working} + Developing {name} Razvijanje {name} + Canceled {label} Otkazano: {label} + Esc to cancel Esc za otkazivanje + Opened {name} Otvoreno: {name} + Opening {name} Otvaranje {name} + Saved {name} Sačuvano: {name} + Saving {name} Čuvanje {name} + Saving {percent} Čuvanje {percent} + A save to {name} is still running; try again when it finishes Čuvanje u {name} je još u toku; pokušajte ponovo kad završi + {label} is writing the file and can no longer be canceled {label} zapisuje datoteku i više se ne može otkazati + Opening {name}… Otvaranje {name}… + Opening… Otvaranje… + Reading… Čitanje… + Working… Rad u toku… +@id select.all Odaberi sve +@id edit.purge.undo Poništavanje + Delete Smart Filters Izbriši pametne filtere + Enable Smart Filters Omogući pametne filtere + New Layer… Novi sloj… + Delete Layer… Izbriši sloj… + Color Range Raspon boja + Select Similar Colors Odaberi slične boje + New Selection Novi odabir + Adaptive Wide Angle Prilagodljivi široki ugao + Chalk & Charcoal Kreda i ugljen + Charcoal Ugljen + Chrome Hrom + Conté Crayon Konte krejon + Graphic Pen Grafičko pero + Halftone Pattern Uzorak polutonova + Note Paper Papir za bilješke + Photocopy Fotokopija + Plaster Gips + Reticulation Retikulacija + Stamp Pečat + Torn Edges Pocijepane ivice + Water Paper Vodeni papir + Canvas rotation Rotacija platna + Extensions Proširenja + Libraries Biblioteke + 3D 3D + Toolbars Trake s alatima + Move Pomakni + Wand Štapić + Pencil Olovka + Clone Stamp Pečat za kloniranje + Healing Liječenje + Dodge Posvijetli + Burn Zatamni + Pen Pero + Hand Ruka + Sampler Uzorkivač + Note tool Alat bilješka + Quick Selection Brzi odabir + Magnetic Lasso Magnetni laso + History Brush Četkica historije + Art History Brush Četkica umjetničke historije + Pattern Stamp Pečat uzorka + Spot Healing Tačkasto liječenje + Red Eye Crvene oči + Patch Zakrpa + Content-Aware Move Pomjeranje svjesno sadržaja + Rotate View Rotiraj prikaz + Perspective Crop Izrezivanje s perspektivom + Color Replacement Zamjena boje + Dodge/Burn Posvjetljivanje/zatamnjivanje + Dynamics Dinamika + Tilt Nagib + Reset Vrati na početno + Defaults restored. Zadane vrijednosti vraćene. + Stop Zaustavi + Create new set Kreiraj novi skup + Technology Previews Pregledi tehnologije + New Group Nova grupa + New Group from Layers… Nova grupa od slojeva… + Layer Comp Kompozicija slojeva + Create new layer Kreiraj novi sloj + Group · {n} layer Grupa · {n} sloj + General Blend Opće stapanje + Advanced Blending Napredno stapanje + RGB RGB + Alpha Alfa + Spot Channel Spot kanal + Working Gray Plate Radna siva ploča + HSL/Grayscale HSL/nijanse sive + Tone Curve Tonska kriva + Split Toning Podijeljeno toniranje + Lens Corrections Korekcije objektiva + Camera Calibration Kalibracija kamere + Snapshots Snimci + Recovery Oporavak + Fill Light Svjetlo ispune + Convert to Grayscale Pretvori u nijanse sive + Sharpness Oštrina + Color Noise Reduction Smanjenje šuma boje + Luminance Smoothness Glatkoća luminanse + Make Proizvođač + Model Model + Lens Objektiv + F F + ISO ISO + Focal Length35mm Žarišna dužina 35 mm + Aspect Ratio Omjer stranica + Time Vrijeme + Artist Umjetnik + Enter description… Unesite opis… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Industrijski standard + Uncoated CMYK Nepremazni CMYK + Newspaper Novine + Working Spaces Radni prostori boja + Color Management Policies Pravila upravljanja bojama + Detect Faces Otkrij lica + Localize Color Clusters Lokalizuj skupine boja + Duplicated selection name Naziv dupliciranog odabira + Operation Operacija + New channel only Samo novi kanal + Masked Areas Maskirana područja + Selected Areas Odabrana područja + Spot Color Spot boja + Color indicates Boja označava + Create Snapshot Kreiraj snimak + Delete Snapshot Izbriši snimak + New Snapshot Novi snimak + From: Od: + Full Document Cijeli dokument + Merged Layers Spojeni slojevi + New Snapshot… Novi snimak… + History Options… Opcije historije… + Snapshot 1 Snimak 1 + Step Forward Korak naprijed + Step Backward Korak unazad + Nonlinear History Nelinearna historija + Set New Snapshot as Default Postavi novi snimak kao zadano + Automatically Create First Snapshot Automatski kreiraj prvi snimak + Automatically Create New Snapshot When Saving Automatski kreiraj novi snimak pri čuvanju + Show New Snapshot Dialog by Default Prikaži dijalog novog snimka prema zadanom + Clear History Očisti historiju + Allow Non-Linear History Dozvoli nelinearnu historiju + Make Layer Visibility Changes Undoable Omogući poništavanje promjena vidljivosti slojeva + Panel Options… Opcije panela… + View menu Meni prikaza + Keep view in sync Drži prikaz usklađenim + {zoom}% {zoom}% + Mouse Controls Kontrole miša + Initial Direction of Stroke Početni smjer poteza + Direction of Stroke Smjer poteza + Humidity Vlažnost + Auto Clean Brush Automatski čisti četkicu + Spacing: Razmak: + Brush Tip Vrh četkice + New Layer from Brush Novi sloj iz četkice + New Brush Nova četkica + New Tool Preset Novi predložak alata + Auto-Leading Automatski prored + Scale Horizontally Skaliraj vodoravno + Scale Vertically Skaliraj okomito + Baseline Shift Pomak osnovne linije + Mac LCD Mac LCD + Mac Sharp Mac oštro + Mac Crisp Mac jasno + Mac Strong Mac snažno + Mac Smooth Mac glatko + Superscript Eksponent + Subscript Indeks + Ligature Ligatura + Alternative Alternativa + Roman Latinično + Bold Italic Podebljani kurziv + Ultra Light Ultra tanko + Extra Light Ekstra tanko + Semi Bold Polupodebljano + Extra Bold Ekstra podebljano + Ultra Bold Ultra podebljano + Condensed Suženo + First Line Indent Uvlaka prvog reda + Hanging Punctuation Viseća interpunkcija + Roman Hanging Punctuation Latinična viseća interpunkcija + Avoid Widows/Orphans Izbjegavaj udovice/siročad + Compose Sastavi + Adobe Single-line Composer Adobe slagač jednog reda + Adobe Every-line Composer Adobe slagač svih redova + Justification Obostrano poravnanje + Word Spacing Razmak riječi + Letter Spacing Razmak slova + Glyph Scaling Skaliranje glifova + Auto-leading % Automatski prored % + Single Word Justification Obostrano poravnanje jedne riječi + Alignment Poravnanje + Justify Poravnaj obostrano + Glyph Glif + Alternates for Current Selection Alternative za trenutni odabir + Digits Cifre + Symbols Simboli + Punctuation Interpunkcija + Path: Putanja: + Path to Illustrator… Putanja u Illustrator… + Make Work Path Napravi radnu putanju + Make Selection Napravi odabir + Fill Path Ispuni putanju + Stroke Path Potez putanje + Clipping Path Putanja isijecanja + New Path Nova putanja + Duplicate Path Dupliciraj putanju + Rename Path… Preimenuj putanju… + Delete Path Izbriši putanju + Auto-Hide Automatsko skrivanje + W Š + H H + X X + Y Y + Frame Sync Sinhronizacija sličica + Quick Sync Brza sinhronizacija + Audio Zvuk + Frame Duration Trajanje sličice + Loop Petlja + Split at Playhead Razdvoji na glavi za reprodukciju + Flatten Frames Into Clips Spljošti sličice u klipove + Flatten Frames Into Layers Spljošti sličice u slojeve + Convert Frames > Video Layers Pretvori sličice > video slojevi + New Video Layer Novi video sloj + New Video Group Nova video grupa + Limit Playback Ograniči reprodukciju + Enable Timeline Quick Config Omogući brzu konfiguraciju vremenske linije + Set Start Timecode… Postavi početni vremenski kod… + Timecode Vremenski kod + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Trenutno vrijeme + Out Izlaz + In Ulaz + Duration Trajanje + Repeat Ponavljaj + Play Once Reprodukuj jednom + Info Options… Opcije informacija… + First Color Readout Prvo očitanje boje + Second Color Readout Drugo očitanje boje + Actual Color Stvarna boja + Grayscale Slider Klizač nijansi sive + Web Color Web boja + HSB Color HSB boja + Total Ink Ukupna tinta + Opacity Mask Maska neprozirnosti + Ruler Ravnalo + Document Size Veličina dokumenta + Timing Tajming + Status Information Statusne informacije + Saved Multichannel Sačuvano višekanalno + Mouse Coordinates Koordinate miša + Ruler Units Jedinice ravnala + Hide Sakrij + New Layer Comp Nova kompozicija slojeva + Duplicate Layer Comp Dupliciraj kompoziciju slojeva + Rename Layer Comp Preimenuj kompoziciju slojeva + Apply Layer Comp Primijeni kompoziciju slojeva + Update Layer Comp Ažuriraj kompoziciju slojeva + Restore last viewed comp Vrati posljednju pregledanu kompoziciju + Method Metoda + Select All Layers Odaberi sve slojeve + Keep proportions Zadrži proporcije + Interpolation Interpolacija + Author Autor + Caption Natpis + Caption Credits Zasluge natpisa + Keywords Ključne riječi + Copyright Info URL URL informacija o autorskim pravima + Copyright Status Status autorskih prava + Copyright Notice Obavještenje o autorskim pravima + Document Title Naslov dokumenta + Copyrighted Zaštićeno autorskim pravima + Public Domain Javno vlasništvo + Unknown Nepoznato + Credit Zasluge + Transmission Reference Referenca prijenosa + Urgency Hitnost + High Visoko + Low Nisko + Category Kategorija + Supplemental Categories Dodatne kategorije + Object Name / Title Naziv / naslov objekta + Date Created Datum kreiranja + City Grad + Location Lokacija + Province / State Pokrajina / država + Country Država + Byline Autor + Byline Title Titula autora + Phone Telefon + Email E-mail + URLs URL-ovi + Last Modified Posljednja izmjena + EXIF EXIF + GPS GPS + Camera Data Podaci kamere + Camera Model Model kamere + Camera Make Proizvođač kamere + EXIF Version EXIF verzija + Exposure Program Program ekspozicije + Aperture Otvor blende + Shutter Speed Brzina zatvarača + Metering Mode Način mjerenja svjetla + Light Source Izvor svjetla + Flash Blic + Lens Info Informacije o objektivu + ISO Speed ISO osjetljivost + Subject Distance Udaljenost subjekta + DateTime Original Originalni datum i vrijeme + Create Date Datum kreiranja + Modify Date Datum izmjene + Software Softver + Raw Data Sirovi podaci + Advanced Napredno + Import… Uvezi… + PDF Presentation… PDF prezentacija… + Create Multiple Picture Packages Kreiraj više paketa slika + Picture Package… Paket slika… + Web Photo Gallery… Web foto galerija… + Choose… Odaberi… + Select Folder… Odaberi folder… + Override Action "Save" Commands Zamijeni naredbe "Sačuvaj" u akcijama + File Naming Imenovanje datoteka + Compatibility Kompatibilnost + Mac OS Mac OS + Unix Unix + Include ICC Profile Uključi ICC profil + Error File Datoteka grešaka + Stop For Errors Zaustavi pri greškama + Log Errors To File Zapisuj greške u datoteku + Errors Greške + Settings Postavke + Settings: Postavke: + Working Space Radni prostor boja + RGB: RGB: + CMYK: CMYK: + Gray: Siva: + Spot: Spot: + Advanced Controls Napredne kontrole + Desaturate Monitor Colors By Smanji zasićenje boja monitora za + Blend RGB Colors Using Gamma 1.0 Stapaj RGB boje koristeći gamu 1.0 + Profile Mismatches: Ask When Opening Neusklađenosti profila: pitaj pri otvaranju + Ask When Pasting Pitaj pri lijepljenju + Missing Profiles: Ask When Opening Nedostajući profili: pitaj pri otvaranju + Embed profiles Ugradi profile + Ask Before Saving Saved Embedded Pitaj prije čuvanja sačuvanog ugrađenog + Coerce to sRGB Prisili na sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB RGB širokog gamuta + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web Grafika i web + Minimalistic Minimalistički + Expand All Groups Proširi sve grupe + Photo (2 × 3 in) Fotografija (2 × 3 in) + Photo (4 × 6 in) Fotografija (4 × 6 in) + Photo (5 × 7 in) Fotografija (5 × 7 in) + Photo (8 × 10 in) Fotografija (8 × 10 in) + Recent Items Nedavne stavke + Save Preset… Sačuvaj postavku… + Delete Preset… Izbriši predložak… + Preset Name: Naziv predloška: + Width and Height Širina i visina + Color Profile Profil boja + Units Jedinice + Columns Kolone + Create new count group Kreiraj novu grupu brojanja + Adjustment Layer Sloj podešavanja + Pencil Tool Alat olovka + Pixel Piksel + Adjustment Podešavanje + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Prevucite (laso) ili kliknite tačke (mnogougaono) · {add} dodaj · {sub} oduzmi + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Prevucite okvir izrezivanja · prevucite unutra za pomjeranje · ivice mijenjaju veličinu ({ratio} omjer, {centre} središte) · Razmak pomjera tokom crtanja · strelice pomiču · X mijenja orijentaciju · {commit} potvrđuje · Esc otkazuje + {mode} Color · {bits} bit {mode} boja · {bits} bita + Drag to move the layer Prevucite da pomjerite sloj + Scrubby Zoom Zumiranje prevlačenjem + Auto Erase Automatsko brisanje + Color Adaptation Prilagođavanje boja + Preserve Transparency Očuvaj prozirnost + shortcut prečica + Go to Layer Idi na sloj + Saved Sačuvano + After (X) Poslije (X) + Current Trenutno + Reference point location Lokacija referentne tačke + Toggle reference point Uključi/isključi referentnu tačku + Arc Luk + Arch Svod + Bulge Ispupčenje + Shell Lower Donja školjka + Shell Upper Gornja školjka + Flag Zastava + Fish Riba + Wave Talas + Rise Uspon + Inflate Napuhni + Twist Uvrtanje + Squeeze Stisni + Add Pins Dodaj pribadače + Delete Pins Izbriši pribadače + Rotate Auto Automatska rotacija + Rotate Fixed Fiksna rotacija + Create Plane Kreiraj ravan + Edit Plane Uredi ravan + Select Plane Odaberi ravan + Measure Mjeri + Heal Liječi + Heal Off Liječenje isključeno + Heal On Liječenje uključeno + Heal Auto Liječenje automatski + Blend Stopi + Brush Size Veličina četkice + Heal Color Liječenje boje + Mask Maska + Channel Kanal + Rectangle Pravougaonik + Result Rezultat + New Channel Novi kanal + Save Selection Sačuvaj odabir + Load Selection Učitaj odabir + Duplicate Dupliciraj + Fast Brzo + Accurate Precizno + Add to Dodaj u + Subtract from Oduzmi od + Intersect with Presijeci sa + Capture Comp Zabilježi kompoziciju + No Layer Comps Nema kompozicija slojeva + Adobe PDF Presets… Adobe PDF predlošci… + 3D… 3D… + Technology Previews… Pregledi tehnologije… + Background from Layer Pozadina iz sloja + Add a mask (from the selection; ⌥ inverts) Dodaj masku (iz odabira; ⌥ obrće) + Add to selection (⇧) Dodaj u odabir (⇧) + Airbrush: keeps painting while the pointer rests. Zračni kist: nastavlja slikati dok pokazivač miruje. + Click the first point or press ↵ to close · Esc cancels Kliknite prvu tačku ili pritisnite ↵ za zatvaranje · Esc otkazuje + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Kliknite da dodate tačku · prevucite van ili ⌘/Ctrl-klik ili Delete za uklanjanje + Click to apply, ⌥-click to clear overrides, double-click for Style Options Kliknite za primjenu, ⌥-klik za brisanje zamjena, dvostruki klik za opcije stila + Click to zoom in · ⌥-click to zoom out Kliknite za uvećanje · ⌥-klik za umanjenje + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · ↩ završi · Esc otkaži + Commit Perspective Warp (↩) Potvrdi iskrivljenje perspektive (↩) + Commit Puppet Warp (↩) Potvrdi iskrivljenje lutkom (↩) + Commit any current edits (⌘↩) Potvrdi sve trenutne izmjene (⌘↩) + Commit current crop operation (↵) Potvrdi trenutno izrezivanje (↵) + Commit transform (↩) Potvrdi transformaciju (↩) + Commit warp (↩) Potvrdi iskrivljenje (↩) + Create a new layer (⇧⌘N) Kreiraj novi sloj (⇧⌘N) + Custom pattern Prilagođeni uzorak + General Brushes Opće četkice + Grid (⌘') Mreža (⌘') + Intersect with selection (⇧⌥) Presijeci s odabirom (⇧⌥) + New document… ⌘N Novi dokument… ⌘N + Open… ⌘O Otvori… ⌘O + Rulers (⌘R) Ravnala (⌘R) + Search commands (⌘K) Pretraži naredbe (⌘K) + Show Guides (⌘;) Prikaži vodilice (⌘;) + Subtract from selection (⌥) Oduzmi od odabira (⌥) + ↑↓ navigate ↵ run esc close ↑↓ kretanje ↵ pokreni esc zatvori + Adds grain to the soft edges of the brush tip. Dodaje zrno mekim ivicama vrha četkice. + Keeps the current texture when switching presets. Zadržava trenutnu teksturu pri promjeni predloška. + Watercolour look: lighter interior, darker rims. Izgled akvarela: svjetlija unutrašnjost, tamniji rubovi. + Collapse group (⌥-click: all groups) Skupi grupu (⌥-klik: sve grupe) + Expand group (⌥-click: all groups) Proširi grupu (⌥-klik: sve grupe) + Hide the layer's effects (⌥-click: all layers) Sakrij efekte sloja (⌥-klik: svi slojevi) + Show the layer's effects (⌥-click: all layers) Prikaži efekte sloja (⌥-klik: svi slojevi) + New from Clipboard Novo iz međuspremnika + Free Transform a Copy Slobodno transformiši kopiju + Transform Again on a Copy Ponovo transformiši na kopiji + Select Layer Above Odaberi sloj iznad + Select Layer Below Odaberi sloj ispod + Select Top Layer Odaberi gornji sloj + Select Bottom Layer Odaberi donji sloj + Add Layer Above to Selection Dodaj sloj iznad u odabir + Add Layer Below to Selection Dodaj sloj ispod u odabir + 8 bit 8-bitno + Integer Cijeli broj + 16 bit 16-bitno + 32 bit (float) 32-bitno (pokretni zarez) + Floating point Pokretni zarez + Use Global Light Koristi globalno svjetlo + Overprint Pretisak + Native file drag-and-drop is unavailable Izvorno prevlačenje i ispuštanje datoteka nije dostupno + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Izvorno prevlačenje i ispuštanje datoteka još nije podržano na Waylandu. Koristite Datoteka › Otvori ili kopirajte sliku u upravitelju datoteka i zalijepite je pomoću {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Da ispuštate datoteke, pokrenite PhotoCraft pod XWaylandom: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ili postavite Postavke › Performanse › Linux server prikaza na X11: PhotoCraft se tada uvijek pokreće pod XWaylandom. + Edit › Paste Uredi › Zalijepi + Use File › Open, or paste a copied image with {paste}. Koristite Datoteka › Otvori ili zalijepite kopiranu sliku pomoću {paste}. + Mixer Brush Tool Alat četkica za miješanje + Color Replacement Tool Alat za zamjenu boje + Invalid {mode} profile Nevažeći {mode} profil + Unnamed {mode} profile Neimenovani {mode} profil + Show bounding box when dragging layer Prikaži granični okvir pri prevlačenju sloja + System title bar Sistemska naslovna traka + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso za zamrzavanje: prevucite da zamrznete područje, Alt-prevlačenje da ga odmrznete (L) + Rendering Mode Način iscrtavanja + Automatic (recommended) Automatski (preporučeno) + CPU / Compatibility CPU / kompatibilnost + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatski koristi GPU ubrzanje kad je dostupno i vraća se na CPU iscrtavanje pri greškama. + Switched to CPU rendering Prebačeno na CPU iscrtavanje + GPU acceleration could not continue. Your documents are unchanged. GPU ubrzanje nije moglo nastaviti. Vaši dokumenti su nepromijenjeni. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft koristi CPU kompozitor slika. Prozor i dalje može koristiti vaš grafički adapter. + Retry GPU requires a restart. Save your work first. Ponovni pokušaj GPU-a zahtijeva ponovno pokretanje. Prvo sačuvajte svoj rad. + Retry GPU tries the graphics processor again now. Ponovo pokušaj GPU odmah ponovo isprobava grafički procesor. + GPU acceleration resumed. GPU ubrzanje je nastavljeno. + Keep Using CPU Nastavi koristiti CPU + Retry GPU Ponovo pokušaj GPU + GPU Backend GPU pozadinski sistem + Details Detalji + Rendering mode Način iscrtavanja + Shadow Clipping Warning (U) Upozorenje o odsijecanju sjena (U) + Highlight Clipping Warning (O) Upozorenje o odsijecanju svijetlih tonova (O) + Show Lab Color Readouts Prikaži Lab očitanja boja + Show Vectorscope Prikaži vektorskop + Show Red at 3 o'clock Prikaži crvenu na 3 sata + Show Skin Tone Indicator Prikaži indikator tona kože + Tone Histogram Tonski histogram + Show Selected Region Prikaži odabranu regiju +cameraRaw Light Svjetlo + Camera Raw Filter ({layer}) Camera Raw filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights Svijetli dijelovi +cameraRaw Darks Tamni dijelovi +cameraRaw Blacks Crne +cameraRaw Amount Količina +cameraRaw Tint Preliv +cameraRaw Masking Maskiranje +cameraRaw Blending Stapanje + Patch Tool Alat zakrpa + Patch: Zakrpa: + Lasso around an area, then drag the selection Lasom zaokružite područje, zatim prevucite odabir + Transform On Drop Transformiši pri ispuštanju + Scale or rotate the moved area, then press {key} Skalirajte ili rotirajte pomjereno područje, zatim pritisnite {key} + Now drag the selection onto another area Sada prevucite odabir na drugo područje + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Mreža: + Edit Type Uredi tekst + New Style… Novi stil… + Style {name} saved Stil {name} sačuvan + Create new style (from the selected type) Kreiraj novi stil (iz odabranog teksta) + Create new style from the selected layer Kreiraj novi stil iz odabranog sloja + Make Default Postavi kao zadano + Reset to Default Vrati na zadano + Full-resolution preview unavailable Pregled u punoj rezoluciji nije dostupan + No patterns Nema uzoraka + Enable {name} Omogući {name} + Hold Alt while drawing for straight segments Držite Alt tokom crtanja za ravne segmente + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Prevucite ili kliknite tačke mnogougla · držite Alt tokom lasa za ravne segmente · {add} dodaj · {sub} prije crtanja oduzima + Extend Proširi + Structure: Struktura: + Content-Aware Move Tool Alat za pomjeranje svjesno sadržaja + TIFF Options TIFF opcije + Targa Options Targa opcije + JPEG Options JPEG opcije + Image Options Opcije slike + Format Options Opcije formata + Baseline ("Standard") Osnovni („Standardni”) + Baseline Optimized Osnovni optimizovani + 24 bits/pixel 24 bita/piksel + 32 bits/pixel 32 bita/piksel + Including layers will increase file size. Uključivanje slojeva povećat će veličinu datoteke. + Discard Layers and Save a Copy Odbaci slojeve i sačuvaj kopiju + Don't show again Ne prikazuj ponovo + Save failed: {error} Čuvanje nije uspjelo: {error} + Save canceled Čuvanje otkazano + Couldn't place {name}: {error} Nije moguće postaviti {name}: {error} + Magnetic Lasso Tool Alat magnetni laso + Frequency Frekvencija + Use tablet pressure to change pen width Koristi pritisak tableta za promjenu širine olovke + About O programu + Contributors Saradnici + Models Modeli + Sort Sortiraj + Username Korisničko ime + Display name Ime za prikaz + Real name Pravo ime + Reverse the order Obrni redoslijed + Grab bag Svaštara + Table Tabela + Name (A–Z) Naziv (A–Z) + Merged PRs Spojeni PR-ovi + Commits Commitovi + Lines added Dodane linije + Lines deleted Izbrisane linije + Line delta Razlika u linijama + Binary assets added Dodani binarni resursi + Binary assets removed Uklonjeni binarni resursi + First commit Prvi commit + First Prvo + Last commit Posljednji commit + Last Posljednje + Contributors: {contributors} · Commits: {commits} Saradnici: {contributors} · Commitovi: {commits} + No contributor data was built into this copy. U ovu kopiju nisu ugrađeni podaci o saradnicima. + No model credits were built into this copy. U ovu kopiju nisu ugrađene zasluge za modele. + Company Firma + Version Verzija + % of all commits % svih commitova + Lines +/− Linije +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR-ova: {prs}, commitova: {commits}, +{added} / −{deleted} linija (Δ {delta}), +{binAdded} / −{binDeleted} binarnih resursa, {first} – {last} + Create new swatch Kreiraj novi uzorak boje + New Swatch… Novi uzorak boje… + New Swatch Group… Nova grupa uzoraka boja… + Import Swatches… Uvezi uzorke boja… + Replace Swatches… Zamijeni uzorke boja… + Export Swatches… Izvezi uzorke boja… + Export Swatches for Exchange… Izvezi uzorke boja za razmjenu… + Reset Swatches Vrati uzorke boja na početno + Add to Swatches Dodaj u uzorke boja + Blend If: Stopi ako: + Channels: Kanali: + Knockout Izbijanje + Apply At Canvas Bounds Primijeni na granicama platna + Color Picker (Text Color) Birač boja (boja teksta) + Color Picker ({name}) Birač boja ({name}) + Color Picker (Fill Color) Birač boja (boja ispune) + Fill color Boja ispune + Color Picker (Stroke Color) Birač boja (boja poteza) + Stroke color Boja poteza + Pick screen color Uzmi boju s ekrana + Sample a pixel anywhere on the screen Uzmi uzorak piksela bilo gdje na ekranu + Click to pick · Esc to cancel Kliknite za odabir · Esc za otkazivanje + Pick a screen pixel or press Esc to cancel Odaberite piksel ekrana ili pritisnite Esc za otkazivanje + Scroll to zoom Pomjeranje za zumiranje + Show/Hide All Panels Prikaži/sakrij sve panele + Show/Hide Panels Prikaži/sakrij panele + Could not open Nije moguće otvoriti + {path}: the path is not valid Unicode; rename the file and open it again. {path}: putanja nije važeći Unicode; preimenujte datoteku i ponovo je otvorite. + Contents: Sadržaj: + Custom Pattern: Prilagođeni uzorak: + Foreground Color Boja prednjeg plana + Color… Boja… + 50% Gray 50% siva + {size} (was {old}) {size} (bilo je {old}) + Cone Kupa + Cone (Inverted) Kupa (obrnuta) + Domed Zasvođeno + Domed (Inverted) Zasvođeno (obrnuto) + Diagonal (Descending) Dijagonalno (silazno) + Contour Editor Uređivač kontura + Anti-aliased Zaglađene ivice + Corner Ugao + Gloss Contour Kontura sjaja + Contour Kontura + Cone - Inverted Kupa - obrnuta + Cove - Deep Udubina - duboka + Cove - Shallow Udubina - plitka + Gaussian Gaussovo + Half Round Poluokruglo + Ring Prsten + Ring - Double Prsten - dvostruki + Rolling Slope - Descending Valoviti nagib - silazni + Rounded Steps Zaobljeni koraci + Sawtooth 1 Pila 1 + Red Eye Tool Alat za crvene oči + Pupil Size Veličina zjenice + Darken Amount Količina zatamnjenja + Click a red pupil to neutralize it Kliknite crvenu zjenicu da je neutralizirate + open source otvoreni kod + …and {n} more …i još {n} + Eyedroppers: Kapaljke: + Set Black Point Postavi crnu tačku + Set Neutral Gray Point Postavi neutralnu sivu tačku + Set White Point Postavi bijelu tačku + Unavailable in this color mode Nije dostupno u ovom načinu boja + Selection Preview: Pregled odabira: + Black Matte Crna podloga + White Matte Bijela podloga + Seafoam Morska pjena + Indigo Indigo + Fuchsia Fuksija + Violet Ljubičasta + Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje na CPU + This redraw took {seconds} s on the CPU. Ovo ponovno iscrtavanje trajalo je {seconds} s na CPU. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmjene koje mijenjaju cijeli dokument, poput načina stapanja, neprozirnosti ili vidljivosti sloja, ponovo iscrtavaju cijeli dokument. + The GPU compositor wasn't used: {reason} GPU kompozitor nije korišten: {reason} +fontWeight Thin Tanko +fontWeight ExtraLight Ekstra tanko +fontWeight Light Tanko +fontWeight Regular Obično +fontWeight Medium Srednje +fontWeight SemiBold Polupodebljano +fontWeight Bold Podebljano +fontWeight ExtraBold Ekstra podebljano +fontWeight Black Crno +fontWeight Italic Kurziv + Pattern Stamp Tool Alat pečat uzorka + Impressionist Impresionistički + Rotate View Tool Alat za rotaciju prikaza + Reset View Vrati prikaz na početno + Drag around the center to rotate the view · Shift constrains to 15° Prevucite oko središta da rotirate prikaz · Shift ograničava na 15° + Dual Axis Dvije ose + Diagonal Dijagonalno + Transform Symmetry Simetrija transformacije + Point Sample Uzorak tačke + 3 by 3 Average Prosjek 3 x 3 + 5 by 5 Average Prosjek 5 x 5 + 11 by 11 Average Prosjek 11 x 11 + 31 by 31 Average Prosjek 31 x 31 + 51 by 51 Average Prosjek 51 x 51 + 101 by 101 Average Prosjek 101 x 101 + Sample Size: Veličina uzorka: + All Layers No Adjustments Svi slojevi bez podešavanja + Current & Below No Adjustments Trenutni i ispod bez podešavanja + Show Sampling Ring Prikaži prsten uzorkovanja + Out of gamut for printing: click for the nearest printable color Izvan gamuta za štampu: kliknite za najbližu boju koja se može štampati + Not a web color: click for the nearest web color Nije web boja: kliknite za najbližu web boju + Color to Alpha… Boja u alfu… + Transparency Threshold Prag prozirnosti + Opacity Threshold Prag neprozirnosti + PhotoCraft — Screen Color Picker PhotoCraft — birač boja s ekrana + Rasterize svg groups deeper than Rasteriziraj SVG grupe dublje od + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 isključuje ovaj prag; SVG grupe ugniježđene dublje od 100 nivoa uvijek se rasteriziraju. + Rule of Thirds Pravilo trećina + Triangle Trougao + Golden Ratio Zlatni rez + Golden Spiral Zlatna spirala + Auto Show Overlay Automatski prikaži preklop + Always Show Overlay Uvijek prikaži preklop + Never Show Overlay Nikad ne prikazuj preklop + Cycle Overlay Kruženje preklopa + Cycle Orientation Kruženje orijentacije + Set the overlay options for the Crop tool Postavi opcije preklopa za alat za izrezivanje + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik je otišao radnoj površini, koja pomjera prozore Alt-prevlačenjem. Postavite njenu tipku za pomjeranje prozora na Super ili zaključajte Alt u Prozor › Modifikatorske tipke. + Color Picker (Layer Style Color) Birač boja (boja stila sloja) + Auto Hide Notifications Automatski sakrij obavještenja + Notification Duration (seconds) Trajanje obavještenja (sekunde) + Bundle Arrow-Key Nudges Grupiši pomake tipkama sa strelicama + Nudge Bundle Pause (ms) Pauza grupisanja pomaka (ms) + Appearance Mode Način izgleda + Presentation Prezentacija + Dark Theme Tamna tema + Light Theme Svijetla tema + Pro Medium Pro srednja + Studio Light Studio svijetla + Next Appearance Mode Sljedeći način izgleda + Sync with system Usklađeno sa sistemom + Active Aktivno + Color Picker (Solid Color) Birač boja (puna boja) + Straighten the image by drawing a line on it Ispravite sliku crtanjem linije na njoj + Set additional Crop options Postavi dodatne opcije izrezivanja + Show Cropped Area Prikaži izrezano područje + Enable Crop Shield Omogući štit izrezivanja + Match Canvas Uskladi platno + Auto Adjust Opacity Automatski podesi neprozirnost + Crop Shield color Boja štita izrezivanja + Color Picker (Crop Shield Color) Birač boja (boja štita izrezivanja) + From Center Iz središta + Link radii Poveži poluprečnike + Star Ratio: Omjer zvijezde: + Create Rectangle Kreiraj pravougaonik + Create Ellipse Kreiraj elipsu + Create Triangle Kreiraj trougao + Create Polygon Kreiraj mnogougao + Pressure curve Kriva pritiska + Click to add a point, right-click to remove Kliknite da dodate tačku, desni klik za uklanjanje + Some brush presets could not be loaded Neke unaprijed postavljene četkice nije moguće učitati + Retry saving presets Ponovo pokušaj sačuvati predloške + Brush preset changes are not saved: {error} Promjene unaprijed postavljenih četkica nisu sačuvane: {error} + Keep this tab open to retain your brushes. Držite ovu karticu otvorenom da zadržite četkice. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Pohrana preglednika nije dostupna; četkice traju samo tokom sesije. Držite ovu karticu otvorenom da ih zadržite. + Saving brush presets… Keep this tab open until saving finishes. Čuvanje unaprijed postavljenih četkica… Držite ovu karticu otvorenom dok se čuvanje ne završi. + Reflect Odrazi + Glow Sjaj + Negation Negacija + XOR XOR + Color Burn (Paint.NET) Tamnjenje boje (Paint.NET) + Color Dodge (Paint.NET) Posvjetljivanje boje (Paint.NET) + Stroke Options Opcije poteza + Solid Puno + Dashed Isprekidano + Dotted Tačkasto + Caps Velika slova + Butt / Flat Ravno + Corners Uglovi + Miter Šiljasti spoj + Round Okruglo + Bevel Zakošenje + Miter limit Ograničenje šiljastog spoja + Dashed line Isprekidana linija + Dash Crtica + Gap Razmak + Dash / gap lengths × stroke width Dužine crtica / razmaka × širina poteza + Add pair Dodaj par + Remove pair Ukloni par + Dash offset Pomak crtica + Open paths use centered strokes Otvorene putanje koriste centrirane poteze + Use finite lengths and a positive pattern total Koristite konačne dužine i pozitivan ukupni uzorak + No subject found — try Object Selection or the Lasso Nije pronađen subjekat — pokušajte Odabir objekta ili Laso + Quick Selection found nothing there Brzi odabir tamo nije ništa pronašao + Object Selection found nothing there — try a tighter rectangle Odabir objekta tamo nije ništa pronašao — pokušajte uži pravougaonik + No subject found — try a rectangle around the object Nije pronađen subjekat — pokušajte pravougaonik oko objekta + Click the first point or press {key} to close · {del} removes a point · Esc cancels Kliknite prvu tačku ili pritisnite {key} za zatvaranje · {del} uklanja tačku · Esc otkazuje + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kliknite, zatim se krećite duž ivice · kliknite da učvrstite tačku · {alt}-klik za ravan segment · dvostruki klik za zatvaranje + Follows only edges this close to the pointer ([ and ] change it) Prati samo ivice ovoliko blizu pokazivača ([ i ] je mijenjaju) + Higher values follow only edges that contrast sharply with their surroundings Više vrijednosti prate samo ivice koje oštro kontrastiraju s okolinom + How often fastening points are placed by themselves Koliko često se tačke učvršćivanja postavljaju same + Appearance Settings… Postavke izgleda… + Add to favorites Dodaj u omiljene + Remove from favorites Ukloni iz omiljenih +cameraData Make Proizvođač +cameraData Model Model +cameraData Date Taken Datum snimanja +cameraData Exposure Ekspozicija +cameraData F-Stop F-broj +cameraData ISO Speed ISO osjetljivost +cameraData Focal Length Žarišna dužina +cameraData Focal Length (35 mm) Žarišna dužina (35 mm) +cameraData Lens Objektiv + Activate Panel Aktiviraj panel + Change Panel Group Promijeni grupu panela + Change Panel Layout Promijeni raspored panela + Close Panel Zatvori panel + Dock Panel Usidri panel + Float Panel Odvoji panel + Move Panel Pomakni panel + Group with Grupiši sa + Panel menu Meni panela + Resize panel window Promijeni veličinu prozora panela + Resize panels Promijeni veličinu panela + Use Classic Mode Koristi klasični način + Auto Center Preview Automatski centriraj pregled + Next Document Sljedeći dokument + Previous Document Prethodni dokument + W x H x Resolution Š x V x rezolucija + Front Image Prednja slika + Frame Tool Alat okvir + Create a new rectangular frame Kreiraj novi pravougaoni okvir + Create a new elliptical frame Kreiraj novi eliptični okvir + Include in playback Uključi u reprodukciju + Include All Subfolders Uključi sve podfoldere + Perspective Crop Tool Alat za izrezivanje s perspektivom + Show Grid Prikaži mrežu + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Prevucite okvir ili kliknite četiri ugla · prevucite uglove na ivice iskošenog oblika · {commit} potvrđuje · Esc otkazuje + Artboard Tool Alat radna ploča + Make Portrait Učini uspravnim + Make Landscape Učini položenim + Add New Artboard Dodaj novu radnu ploču + Freeform Pen Tool Alat slobodno pero + Curvature Pen Tool Alat pero zakrivljenosti + Curve Fit: Uklapanje krive: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Prevlačenje: crtaj prostoručno · {alt}-klik: ravan segment · Otpuštanje na početnoj tački: zatvori + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: glatka tačka · Dvostruki klik: ugao · Prevlačenje tačke: pomjeri · Klik na prvu tačku: zatvori · {key} završi + New Crop Preset… Novi predložak izrezivanja… + Delete Crop Preset… Izbriši predložak izrezivanja… + Select the crop presets to delete: Odaberite predloške izrezivanja za brisanje: + Select the crop presets to delete Odaberite predloške izrezivanja za brisanje + Enter a name for the crop preset Unesite naziv za predložak izrezivanja + There is no crop size or ratio to save Nema veličine ili omjera izrezivanja za čuvanje + Scale: Skala: + Snap to Origin Prilijepi uz ishodište + Search settings… Pretraži postavke… + No matching settings Nema odgovarajućih postavki + Loading fonts… Učitavanje fontova… + Tool Alat + Simulate Pressure Simuliraj pritisak + Stroke Path… Potez putanje… + Background Eraser Gumica za pozadinu + Healing Brush Četkica za liječenje + Spot Healing Brush Četkica za tačkasto liječenje + Smudge Razmazivanje + Sponge Spužva + Amount: Količina: + Protect: Zaštiti: + Protect skin tones Zaštiti tonove kože + Edit points to modify the curve Uredite tačke da izmijenite krivu + Draw to modify the curve Crtajte da izmijenite krivu +curves Smooth Zagladi + Smooth the curve values Zagladi vrijednosti krive + Drag to draw the curve · {key}-click draws a straight line Prevucite da nacrtate krivu · {key}-klik crta ravnu liniju + Grid Size: Veličina mreže: + Vanishing Point: click four corners to create a plane Tačka nestajanja: kliknite četiri ugla da kreirate ravan + Vanishing Point is not active Tačka nestajanja nije aktivna + Apply Vanishing Point ({key}) Primijeni tačku nestajanja ({key}) + Fix the red plane first: its corners must form a convex quad Prvo popravite crvenu ravan: njeni uglovi moraju činiti konveksan četverougao + Alt-click a plane to set the clone source Alt-klik na ravan postavlja izvor kloniranja + Too many clone strokes: apply Vanishing Point first Previše poteza kloniranja: prvo primijenite Tačku nestajanja + Onion Skin Lukovičasta koža + Marching Ants Mravlja linija + On Black Na crnom + On White Na bijelom + On Layers Na slojevima + Show Edge Prikaži ivicu + Show Original Prikaži original + F cycles the views; hold X to see the image F kruži kroz prikaze; držite X da vidite sliku + Refine Edge Brush (R) Četkica za doradu ivice (R) + Expand Detection Area Proširi područje otkrivanja + Restore Original Edge Vrati originalnu ivicu + Paint over hair or fur on the image; Alt paints the other mode Slikajte preko kose ili krzna na slici; Alt slika drugim načinom + Clear Brush Strokes Očisti poteze četkicom + Show rulers Prikaži ravnala + Click to place · Alt-click or drag off-canvas to remove Kliknite za postavljanje · Alt-klik ili prevucite van platna za uklanjanje + No document open Nijedan dokument nije otvoren + {n} of {max} sample points {n} od {max} tačaka uzorka + Emulsion Down Emulzija dolje + Functions Funkcije + Print Resolution: Rezolucija štampe: + Use Previous Layer to Create Clipping Mask Koristi prethodni sloj za kreiranje maske isijecanja + Fill with {mode}-neutral color (white) Ispuni bojom neutralnom za {mode} (bijela) + Fill with {mode}-neutral color (black) Ispuni bojom neutralnom za {mode} (crna) + Fill with {mode}-neutral color (50% gray) Ispuni bojom neutralnom za {mode} (50% siva) + (No neutral color exists for {mode} mode.) (Za način {mode} ne postoji neutralna boja.) + Apply mask to layer before removing? Primijeniti masku na sloj prije uklanjanja? + The layer or its mask changed. Drag the mask onto the Trash again. Sloj ili njegova maska su se promijenili. Ponovo prevucite masku u Smeće. + Delete the vector mask? Izbrisati vektorsku masku? + Light Blue Svijetloplava + Light Red Svijetlocrvena + Medium Blue Srednje plava + Light Gray Svijetlosiva + Remove after each stroke Ukloni nakon svakog poteza + Apply the removal ({key}) Primijeni uklanjanje ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Potezi za uklanjanje: {n} (Enter primjenjuje, Esc otkazuje) + Load Contours… Učitaj konture… + Replace Contours… Zamijeni konture… + Save Contours… Sačuvaj konture… + Delete Contour Izbriši konturu + Loaded contours from {file} Konture su učitane iz {file} + Saved contours to {file} Konture su sačuvane u {file} + The current gradient (pick one in Window › Gradients) Trenutni gradijent (odaberite jedan u Prozor › Gradijenti) +cameraRaw Red Crvena +cameraRaw Orange Narandžasta +cameraRaw Yellow Žuta +cameraRaw Green Zelena +cameraRaw Aqua Akva +cameraRaw Blue Plava +cameraRaw Purple Ljubičasta +cameraRaw Magenta Magenta +cameraRaw Temperature Temperatura +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Izvezen {n} uzorak boje u {file}|Izvezena {n} uzorka boja u {file}|Izvezeno {n} uzoraka boja u {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Uvezena {n} četkica iz {group}|Uvezene {n} četkice iz {group}|Uvezeno {n} četkica iz {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Uvezen {n} uzorak boje iz {file}|Uvezena {n} uzorka boja iz {file}|Uvezeno {n} uzoraka boja iz {file} +@plural Played {n} step|Played {n} steps Reprodukovan {n} korak|Reprodukovana {n} koraka|Reprodukovano {n} koraka +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ažuriran {n} povezani pametni objekat promijenjen na disku|Ažurirana {n} povezana pametna objekta promijenjena na disku|Ažurirano {n} povezanih pametnih objekata promijenjenih na disku +@plural Saved for Web: {n} file|Saved for Web: {n} files Sačuvano za web: {n} datoteka|Sačuvano za web: {n} datoteke|Sačuvano za web: {n} datoteka +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Izbrisan je {n} sloj koji je zabilježila ova kompozicija. Ažurirajte kompoziciju ili očistite upozorenje.|Izbrisana su {n} sloja koja je zabilježila ova kompozicija. Ažurirajte kompoziciju ili očistite upozorenje.|Izbrisano je {n} slojeva koje je zabilježila ova kompozicija. Ažurirajte kompoziciju ili očistite upozorenje. +@plural {n} plane|{n} planes {n} ravan|{n} ravni|{n} ravni +@plural {n} preset|{n} presets {n} predložak|{n} predloška|{n} predložaka +@plural {n} sample point removed|{n} sample points removed Uklonjena {n} tačka uzorka|Uklonjene {n} tačke uzorka|Uklonjeno {n} tačaka uzorka +@plural {n} slice|{n} slices {n} isječak|{n} isječka|{n} isječaka +@plural {n} step|{n} steps {n} korak|{n} koraka|{n} koraka +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} upozorenje o unaprijed postavljenoj četkici)|{warning} (+{n} upozorenja o unaprijed postavljenim četkicama)|{warning} (+{n} upozorenja o unaprijed postavljenim četkicama) +@plural {what} with {n} warning|{what} with {n} warnings {what} s {n} upozorenjem|{what} s {n} upozorenja|{what} s {n} upozorenja +@id edit.stroke Potez… +@id edit.transform.scale Skaliraj +cameraRaw Luminance Luminansa +cameraRaw Color Boja +cameraRaw Vibrance Živost +cameraRaw Color Grading Gradiranje boja +cameraRaw Global Globalno +cameraRaw Hue Nijansa +cameraRaw Luminance Detail Detalji luminanse +cameraRaw Color Detail Detalji boje +cameraRaw Roughness Grubost +cameraRaw Whites Bijele +layerLabel No Color Bez boje + Preferences… Postavke… + Actual Pixels Stvarni pikseli + Clipping Mask Maska isijecanja + Tool Options Opcije alata + Color Picker Birač boja + Color Swatches Uzorci boja + Linear Dodge Linearno posvjetljivanje + Preview Image Pregled slike + Save As Sačuvaj kao + Remove All Ukloni sve + Unlock Otključaj + Enable Omogući + Disable Onemogući + On Uključeno + Clear Selection Očisti odabir + Invert Selection Obrni odabir + Select Inverse Obrni odabir + Contract Suzi + Sample Size Veličina uzorka + Strength Jačina + Gamma Gama + Input Ulaz + Input Levels Ulazni nivoi + Output Levels Izlazni nivoi + Layer Styles Stilovi sloja + Blending Mode Način stapanja + Fill Color Boja ispune + Stroke Color Boja poteza + Stroke Width Širina poteza + Shadow Sjena + Adjustment Layers Slojevi podešavanja + Text Layer Tekstualni sloj + Documents Dokumenti + File Name Naziv datoteke + File Format Format datoteke + Compression Kompresija + Artboards Radne ploče + Snap to Grid Priljepi uz mrežu + Full Screen Cijeli ekran + Fullscreen Cijeli ekran + Workspaces Radni prostori + Show All Prikaži sve + Hidden Skriveno + Duplicate Layer Dupliciraj sloj + Ungroup Razgrupiši + Lock Transparent Pixels Zaključaj prozirne piksele + Lock Image Pixels Zaključaj piksele slike + Lock Position Zaključaj položaj + Lock All Zaključaj sve + Show Rulers Prikaži ravnala + Guide Layout… Raspored vodilica… + Page Setup… Postavke stranice… + Reset Preferences Vrati postavke na početne + Documentation Dokumentacija + Check for Updates Provjeri ažuriranja + Loading… Učitavanje… + Processing… Obrada… + Saving… Čuvanje… + Exporting… Izvoz… + Please wait… Molimo sačekajte… + No selection Nema odabira + No layers Nema slojeva + No recent files Nema nedavnih datoteka + Unsupported file format Nepodržani format datoteke + Failed to open file Otvaranje datoteke nije uspjelo + Failed to save file Čuvanje datoteke nije uspjelo + Operation canceled Operacija otkazana + Changes have not been saved. Promjene nisu sačuvane. + Are you sure? Jeste li sigurni? + Discard Changes Odbaci promjene + Recent Files Nedavne datoteke + Open File Otvori datoteku + Save File Sačuvaj datoteku + Save Changes Sačuvaj promjene + Rendering… Iscrtavanje… + Importing… Uvoz… + Ready Spremno + Failed Nije uspjelo + Warning Upozorenje + Information Informacije + Retry Pokušaj ponovo + Continue Nastavi + Skip Preskoči + Resume Nastavi + Reset All Vrati sve na početno + Defaults Zadano + Apply to All Primijeni na sve + Confirm Potvrdi + Finish Završi + Back Nazad + Next Sljedeće + Previous Prethodno + More Više + Less Manje + Manual Ručno + Disabled Onemogućeno + Locked Zaključano + Unlocked Otključano + Delete… Izbriši… + Create… Kreiraj… + Edit… Uredi… + Change… Promijeni… + Transformation Transformacija + Scaling Skaliranje + Canvas Size Veličina platna + Percentage Procenat + Degrees Stepeni + Middle Sredina + Diameter Prečnik + Intensity Intenzitet + Sensitivity Osjetljivost + Softness Mekoća + Feather Radius Poluprečnik omekšavanja + Library Biblioteka + Layer Properties Svojstva sloja + Groups Grupe + Masks Maske + Add Mask Dodaj masku + Delete Mask Izbriši masku + Invert Mask Obrni masku + Enable Mask Omogući masku + Disable Mask Onemogući masku + Apply Mask Primijeni masku + Import Settings Postavke uvoza + Export Settings Postavke izvoza + Shortcut Prečica + Shortcuts Prečice + Keyboard Tastatura + Mouse Miš + Tablet Tablet + Display Prikaz + Panel Panel + Hide Grid Sakrij mrežu + Fit to Window Uklopi u prozor + Live Preview Pregled uživo + Before Prije + After Poslije + Compare Uporedi + File Size Veličina datoteke + Filename Naziv datoteke + Folder Folder + Open Folder Otvori folder + Choose Folder Odaberi folder + Browse Pregledaj + Find Pronađi + Replace Zamijeni + Date Datum + Learn More Saznajte više + Report a Bug Prijavi grešku + Updates Ažuriranja + Update Ažuriraj + Restart Required Potrebno ponovno pokretanje + Restart Ponovo pokreni + Quit Izlaz + Quit PhotoCraft Zatvori PhotoCraft + Close Window Zatvori prozor + New Window Novi prozor + Open Image Otvori sliku + Save Image Sačuvaj sliku + Export Image Izvezi sliku + Import Image Uvezi sliku + Export As Izvezi kao + Export to Izvezi u + Export Options Opcije izvoza + Import Options Opcije uvoza + No Selection Nema odabira + No Document Nema dokumenta + No Image Nema slike + No Layers Nema slojeva + No Results Nema rezultata + No Results Found Nisu pronađeni rezultati + Nothing to Undo Nema ničeg za poništavanje + Nothing to Redo Nema ničeg za ponavljanje + Changes Saved Promjene sačuvane + Unsaved Changes Nesačuvane promjene + Save before closing? Sačuvati prije zatvaranja? + Are you sure you want to quit? Jeste li sigurni da želite izaći? + The file could not be opened. Datoteku nije moguće otvoriti. + The file could not be saved. Datoteku nije moguće sačuvati. + Unsupported format Nepodržani format + Out of memory Nema dovoljno memorije + Permission denied Pristup odbijen + File not found Datoteka nije pronađena + Operation failed Operacija nije uspjela + Operation completed Operacija završena + Operation cancelled Operacija otkazana + Sharpen Amount Količina izoštravanja + Sharpen Radius Poluprečnik izoštravanja + Sharpen Detail Detalji izoštravanja + Sharpen Masking Maskiranje izoštravanja + Noise Luminance Luminansa šuma + Noise Luminance Detail Detalji luminanse šuma + Noise Color Boja šuma + Noise Color Detail Detalji boje šuma + Grain Amount Količina zrna + Grain Size Veličina zrna + Grain Roughness Grubost zrna + Vignette Roundness Zaobljenost vinjete + Vignette Feather Omekšavanje vinjete + Vignette Highlights Svijetli tonovi vinjete + Vignette Style Stil vinjete + Planes Ravni + Plane Ravan + Shadow Amount Količina sjena + Shadow Tone Ton sjena + Shadow Radius Poluprečnik sjena + Highlight Amount Količina svijetlih tonova + Highlight Tone Ton svijetlih tonova + Highlight Radius Poluprečnik svijetlih tonova + Midtone Srednji ton + Black Clip Odsijecanje crne + White Clip Odsijecanje bijele + Fuzziness Nejasnoća + Source Layer Izvorni sloj + Neutralize Neutraliziraj + Use Selection In Source Koristi odabir u izvoru + Use Selection In Target Koristi odabir u cilju + Flame Type Vrsta plamena + Randomize Length Nasumična dužina + Interval Interval + Adjust Interval For Loops Prilagodi interval za petlje + Use Custom Color Koristi prilagođenu boju + Flame Lines Linije plamena + Flame Bottom Alignment Poravnanje dna plamena + Flame Style Stil plamena + Flame Shape Oblik plamena + Randomize Shapes Nasumični oblici + Frame Okvir + Arrangement Raspored + Thickness Debljina + Vine Color Boja loze + Flower Color Boja cvijeta + Leaf Color Boja lista + Base Tree Type Osnovna vrsta drveta + Light Direction Smjer svjetla + Leaves Amount Količina lišća + Leaves Size Veličina lišća + Branches Height Visina grana + Branches Thickness Debljina grana + Default Leaves Zadano lišće + Leaves Color Boja lišća + Custom Branch Color Prilagođena boja grana + Branches Color Boja grana + Flat Shading Ravno sjenčenje + Date and Time Datum i vrijeme + Scale Units Jedinice skale + Scale Factor Faktor skale + Area Područje + Perimeter Obim + Circularity Kružnost + Gray Value (Minimum) Vrijednost sive (minimum) + Gray Value (Maximum) Vrijednost sive (maksimum) + Gray Value (Mean) Vrijednost sive (srednja) + Gray Value (Median) Vrijednost sive (medijan) + Integrated Density Integrisana gustoća + Length Dužina + In Black U crnu + In White U bijelu + Out Black Izlaz crne + Out White Izlaz bijele + Tint Color Boja preliva + Stops Tačke + Center X Središte X + Center Y Središte Y + Distribution Raspodjela + Monochromatic Monohromatski + Cell Size Veličina ćelije + Generators Generatori + Wavelength Min Minimalna talasna dužina + Wavelength Max Maksimalna talasna dužina + Amplitude Min Minimalna amplituda + Amplitude Max Maksimalna amplituda + Undefined Areas Nedefinisana područja + Preserve Očuvaj + Max Radius Maksimalni poluprečnik + Depth Mode Način dubine + Solid Front Faces Puna prednja lica + Mask Incomplete Blocks Maskiraj nepotpune blokove + Stylization Stilizacija + Cleanliness Čistoća + Bristle Detail Detalji čekinja + Lighting Osvjetljenje + Shine Sjaj + Max Offset Maksimalni pomak + Map Document Mapiraj dokument + Map Path Mapiraj putanju + Map Layer Mapiraj sloj + Ridges Grebeni + Light Type Vrsta svjetla + Light X Svjetlo X + Light Y Svjetlo Y + Light Z Svjetlo Z + Target X Cilj X + Target Y Cilj Y + Hotspot Vruća tačka + Elevation Elevacija + Ambient Ambijentalno + Warmth Toplina + Gloss Sjaj + Metallic Metalik + Ambience Ambijent + White Is High Bijelo je visoko + Reduce Color Noise Smanji šum boje + Sharpen Details Detalji izoštravanja + Remove Jpeg Artifact Ukloni JPEG artefakte + Blade Curvature Zakrivljenost lamela + Depth Map Mapa dubine + Focal Distance Žarišna udaljenost + Focus Fokus + Transition Prijelaz + Radius X Poluprečnik X + Radius Y Poluprečnik Y + Pins Pribadače + Blur Angle Ugao zamućenja + Speed Brzina + Taper Suženje + Start X Početak X + Start Y Početak Y + End X Kraj X + End Y Kraj Y + Kernel Jezgro + Input Mode Način unosa + Row Order Redoslijed redova + Eliminate Eliminiši + Create By Kreirao + List Lista + Pencil Width Širina olovke + Stroke Pressure Pritisak poteza + Paper Brightness Svjetlina papira + Number Of Levels Broj nivoa + Edge Simplicity Jednostavnost ivica + Edge Fidelity Vjernost ivica + Brush Detail Detalji četkice + Highlight Area Područje isticanja + Glow Size Veličina sjaja + Glow Brightness Svjetlina sjaja + Brush Type Vrsta četkice + Stroke Size Veličina poteza + Stroke Detail Detalji poteza + Highlight Strength Jačina svijetlih tonova + Smoothness Glatkoća + Edge Thickness Debljina ivica + Edge Intensity Intenzitet ivica + Posterization Posterizacija + Stroke Length Dužina poteza + Relief Reljef + Definition Definicija + Texture Coverage Pokrivenost teksturom + Shadow Intensity Intenzitet sjene + Edge Width Širina ivica + Edge Brightness Svjetlina ivica + Direction Balance Balans smjera + Black Intensity Intenzitet crne + White Intensity Intenzitet bijele + Dark Intensity Intenzitet tame + Custom Pixel Aspect Ratio Prilagođeni omjer stranica piksela + 32-bit Preview Options Opcije 32-bitnog pregleda + New Guide Nova vodilica + Join the ArtCraft Discord Pridružite se ArtCraft Discordu + Report an Issue Prijavi problem + Load Files into Stack Učitaj datoteke u stog + Statistics Statistika + Duplicate Layers Dupliciraj slojeve + Edit Toolbar Uredi traku s alatima + Import Brushes Uvezi četkice + Load 3D LUT Učitaj 3D LUT + New document Novi dokument + Press keys Pritisnite tipke + Rename Group Preimenuj grupu + Rename Tool Preset Preimenuj predložak alata + Search commands, tools and panels Pretraži naredbe, alate i panele + Slice Options Opcije isječka + Rename Brush Preimenuj četkicu + Opening Otvaranje + Reading Čitanje + Working Radno + Accented Edges Naglašene ivice + Angled Strokes Ukošeni potezi + Anisotropic Anizotropno + Around Center Oko središta + Artistic Umjetnički + Bamboo Bambus + Bas Relief Bareljef + Berries Bobice + Blast Prasak + Bleach Bypass Preskakanje izbjeljivanja + Blocks Blokovi + Brick Cigla + Brush Strokes Potezi četkicom + Burlap Juta + Candle Light Svjetlo svijeće + Circle Krug + Clumped Nakupljeno + Coarse Dots Krupne tačke + Colored Pencil Drvene bojice + Contrasty Kontrastno + Cool Hladno + Craquelure Krakelura + Cross Križ + Cross Process Unakrsni proces + Crosshatch Unakrsno šrafiranje + Cutout Izrezak + Daisies Tratinčice + Dark Rough Tamno grubo + Dark Strokes Tamni potezi + Darken Only Samo zatamni + Day For Night Dan za noć + Deep Blue Duboka plava + Deep Emerald Duboka smaragdna + Deep Red Duboka crvena + Deep Yellow Duboka žuta + Dot Tačka + Double Line Dvostruka linija + Draft Nacrt + Dry Brush Suhi kist + Duplication Dupliciranje + Edge Extension Produžetak ivica + Edge Only Samo ivica + Enlarged Uvećano + Even Fields Parna polja + Faded Film Izblijedjeli film + Film Grain Filmsko zrno + Fine Fino + Fine Dots Sitne tačke + Flat Ravno + Fresco Freska + From Left Slijeva + From Right Zdesna + Frosted Mat staklo + Generic Generičko + Glass Staklo + Glowing Edges Užarene ivice + Grainy Dots Zrnate tačke + Hearts Srca + Heptagon Sedmougao + Hexagon Šesterougao + Horizontal Only Samo vodoravno + Infinite Beskonačno + Ink Outlines Obrisi tintom + Ivy Bršljan + Left Diagonal Lijeva dijagonala + Level Based Zasnovano na nivou + Light Rough Svijetlo grubo + Lighten Only Samo posvijetli + Long Lines Duge linije + Long Strokes Dugi potezi + Lower Niže + Medium Dots Srednje tačke + Medium Lines Srednje linije + Medium Strokes Srednji potezi + Mono Contrast Mono kontrast + Mosaic Tiles Mozaik pločice + Multiple Flames Along Path Više plamenova duž putanje + Multiple Flames One Direction Više plamenova u jednom smjeru + Multiple Flames Path Directions Više plamenova u smjerovima putanje + Multiple Flames Various Length Više plamenova različite dužine + Octagon Osmougao + Odd Fields Neparna polja + One Flame Along Path Jedan plamen duž putanje + Out From Center Prema van iz središta + Oval Oval + Overlay Edge Ivica preklopa + Palette Knife Špahtla + Parallel Paralelno + Patchwork Krpice + Pentagon Peterougao + Point Tačka + Pointed Šiljasto + Pond Ripples Talasi u bari + Pyramids Piramide + Random Nasumično + Right Diagonal Desna dijagonala + Rope Uže + Roses Ruže + Sandstone Pješčar + Scallops Školjke + Sepia Sepija + Short Lines Kratke linije + Short Strokes Kratki potezi + Simple Jednostavno + Light Intensity Intenzitet svjetla + Spray Radius Poluprečnik prskanja + Stroke Direction Smjer poteza + Graininess Zrnatost + Glow Amount Količina sjaja + Clear Amount Očisti količinu + Distortion Izobličenje + Ripple Size Veličina talasanja + Ripple Magnitude Jačina talasanja + Charcoal Area Područje ugljena + Chalk Area Područje krede + Charcoal Thickness Debljina ugljena + Light Dark Balance Balans svijetlog i tamnog + Foreground Level Nivo prednjeg plana + Background Level Nivo pozadine + Pattern Type Vrsta uzorka + Image Balance Balans slike + Darkness Tama + Fiber Length Dužina vlakana + Crack Spacing Razmak pukotina + Crack Depth Dubina pukotina + Crack Brightness Svjetlina pukotina + Grain Type Vrsta zrna + Tile Size Veličina pločice + Grout Width Širina fuga + Lighten Grout Posvijetli fuge + Square Size Veličina kvadrata + Border Thickness Debljina okvira + Correct Distortion Ispravi izobličenje + Correct Vignette Ispravi vinjetu + Correct C A Ispravi hromatsku aberaciju + Auto Scale Automatsko skaliranje + Red Cyan Crveno-cijan + Blue Yellow Plavo-žuta + Vignette Amount Količina vinjete + Vignette Midpoint Srednja tačka vinjete + Constraints Ograničenja + Curve Highlights Najsvjetliji dijelovi krive + Curve Lights Svijetli dijelovi krive + Curve Darks Tamni dijelovi krive + Curve Shadows Sjene krive + Curve Splits Podjele krive + Point Curve Tačkasta kriva + Point Curve Red Tačkasta kriva crvena + Point Curve Green Tačkasta kriva zelena + Point Curve Blue Tačkasta kriva plava + Hsl Hue HSL nijansa + Hsl Sat HSL zasićenje + Hsl Lum HSL svjetloća + Grade Shadows Gradiranje sjena + Grade Midtones Gradiranje srednjih tonova + Grade Highlights Gradiranje svijetlih tonova + Grade Global Globalno gradiranje + Grade Blending Stapanje gradiranja + Grade Balance Balans gradiranja + Sine Sinus + Sketch Skica + Snowflakes Pahulje + Sparkle Iskrenje + Spatter Prskanje + Speckle Pjegavost + Spin Okretanje + Sprinkles Mrvice + Squareness Kvadratnost + Stagger Raspored u cik-cak + Stained Glass Vitraž + Star Zvijezda + Stars Zvijezde + Stippled Tačkasto sjenčenje + Stretch Razvuci + Teal Orange Tirkizno-narandžasto + Tiny Lens Sićušni objektiv + To Center Prema središtu + Unaltered Neizmijenjeno + Underpainting Podslikavanje + Underwater Pod vodom + Uniform Ujednačeno + Upper Gornje + Vertical Only Samo okomito + Vine With Flowers Loza s cvjetovima + Vine With Leaves Loza s listovima + Violent Silovito + Warm Toplo + Watercolor Akvarel + Waves Talasi + Wide Blurry Široko mutno + Wide Sharp Široko oštro + Wrap Omotaj + Zigzag Cik-cak + Wetness Vlažnost + AI Integrations AI integracije + Add Noise Dodaj šum + Apply Data Set Primijeni skup podataka + Apply Image Primijeni sliku + Arbitrary Proizvoljno + Artboard from Group Radna ploča iz grupe + Artboard from Layers Radna ploča iz slojeva + Artboards to Files Radne ploče u datoteke + Artboards to PDF Radne ploče u PDF + Assign Profile Dodijeli profil + At Na + Auto-Align Layers Automatski poravnaj slojeve + Auto-Blend Layers Automatski stopi slojeve + Batch Paketna obrada + Border Okvir + Box Blur Kutijasto zamućenje + Calculations Proračuni + Camera Raw Camera Raw + Camera Raw Filter Camera Raw filter + Channel1 Kanal1 + Channel2 Kanal2 + Channel3 Kanal3 + Channel4 Kanal4 + Clone Kloniraj + Color Decontaminate Dekontaminacija boja + Color Halftone Polutonovi u boji + Color Lookup Tables Tabele za pretragu boja + Color Settings Postavke boja + Color Table Tabela boja + Conditional Mode Change Uslovna promjena načina + Background (Default) Pozadina (zadano) + Background (default) Pozadina (zadano) + Transparent (default) Prozirno (zadano) + Content-Aware Fill Ispuna svjesna sadržaja + Convert to Linked Pretvori u povezano + Convert to Profile Pretvori u profil + Crystallize Kristalizuj + Data Podaci + Data Sets as Files Skupovi podataka kao datoteke + De-Interlace Ukloni preplitanje + Define Brush Preset Definiši unaprijed postavljenu četkicu + Define Custom Shape Definiši prilagođeni oblik + Define Pattern Definiši uzorak + Defringe Ukloni rub + Diffuse Difuzno + Diffuse Glow Difuzni sjaj + Displace Pomjeri piksele + Do you want to save you made to “{name}” before quitting? Želite li sačuvati promjene u „{name}” prije izlaska? + Dust & Scratches Prašina i ogrebotine + Export Contents Izvezi sadržaj + Export Preferences Izvezi postavke + Export/Import Presets Izvoz/uvoz predložaka + Extrude Istisni + Fibers Vlakna + Field Blur Zamućenje polja + File Info Informacije o datoteci + Filter Gallery Galerija filtera + Find and Replace Text Pronađi i zamijeni tekst + Fit Image Uklopi sliku + Flame Plamen + Focus Area Područje fokusa + Gaussian Blur Gaussovo zamućenje + Global Light Globalno svjetlo + Group from Layers Grupa iz slojeva + HDR Toning HDR toniranje + High Pass Visokopropusni filter + Highlight Svijetli ton + Image Processor Procesor slika + Install Plug-in Instaliraj dodatak + Interpret Footage Interpretiraj snimak + Iris Blur Zamućenje irisa + Jag Nazubljeno + Label Oznaka + Layer Comps to Files Kompozicije slojeva u datoteke + Layer Content Options Opcije sadržaja sloja + Layer From Background Sloj iz pozadine + Layer from Background Sloj iz pozadine + Layers to Files Slojevi u datoteke + Legacy Naslijeđeno + Lens Blur Zamućenje objektiva + Lens Correction Korekcija objektiva + Lens Flare Odsjaj sočiva + Lighting Effects Efekti osvjetljenja + Liquify Ukapljivanje + Lock Layers Zaključaj slojeve + Lum Svj. + Match Color Uskladi boju + Merge to HDR Pro Spoji u HDR Pro + Mezzotint Mecotinta + Mosaic Mozaik + Motion Blur Zamućenje pokreta + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Izvorno prevlačenje i ispuštanje datoteka još nije podržano na Waylandu. Koristite Datoteka › Otvori ili pokrenite PhotoCraft pod XWaylandom s `WAYLAND_DISPLAY= photocraft`. + Neon Glow Neonski sjaj + New Guide Layout Novi raspored vodilica + New Video Layer from File Novi video sloj iz datoteke + Ocean Ripple Talasanje okeana + Oil Paint Uljana boja + Open As Otvori kao + Package Paket + Paint Daubs Mrlje boje + Path Blur Zamućenje putanjom + Paths to Illustrator Putanje u Illustrator + Photomerge Photomerge + Picture Frame Okvir slike + Pinch Uštipni + Place Embedded Postavi ugrađeno + Place Linked Postavi povezano + Place Scale Marker Postavi oznaku skale + Plastic Wrap Plastična folija + Pointillize Tačkasto slikanje + Polar Coordinates Polarne koordinate + Polar To Rectangular Polarne u pravougaone + Poster Edges Posterske ivice + Preset Manager Upravitelj predložaka postavki + Profile Profil + Radial Blur Radijalno zamućenje + Raw Defaults Zadane postavke za RAW + Rectangular To Polar Pravougaone u polarne + Reduce Noise Smanji šum + Relink to File Ponovo poveži s datotekom + Render Video Iscrtaj video + Replace Color Zamijeni boju + Replace Contents Zamijeni sadržaj + Replace Footage Zamijeni snimak + Resolve Missing Fonts Riješi nedostajuće fontove + Ripple Talasanje + Rough Pastels Grubi pasteli + Sat Zas. + Save a Copy Sačuvaj kopiju + Save for Web (Legacy) Sačuvaj za web (naslijeđeno) + Scale Effects Skaliraj efekte + Script Events Manager Upravitelj skriptnih događaja + Search menus Pretraži menije + Seed Sjeme + Select and Mask Odaberi i maskiraj + Set Measurement Scale Postavi skalu mjerenja + Shadows/Highlights Sjene/svijetli tonovi + Shape Blur Zamućenje oblikom + Shear Smicanje + Smart Blur Pametno zamućenje + Smart Sharpen Pametno izoštravanje + Smudge Stick Štapić za razmazivanje + Solid Color Puna boja + Spherize Sferiziraj + Spin Blur Zamućenje okretanjem + Spot Spot + Sprayed Strokes Raspršeni potezi + Strokes Potezi + Sumi-e Sumi-e + Surface Blur Površinsko zamućenje + Texturizer Teksturizator + Tiles Pločice + Tilt-Shift Tilt-shift + Trace Contour Praćenje konture + Trap Preklapanje boja + Tree Drvo + Trim Obreži + Turbulent Turbulentno + Twirl Kovitlac + Unsharp Mask Maska izoštravanja + Use File › Open to open an image. Koristite Datoteka › Otvori da otvorite sliku. + Vanishing Point Tačka nestajanja + Variable Data Sets Skupovi varijabilnih podataka + Video Frames to Layers Video sličice u slojeve + WIA Support WIA podrška + Warp Text Iskrivi tekst + Wind Vjetar + ZigZag Cik-cak + Vertical Type Tool Alat za okomiti tekst + About {app} O aplikaciji {app} + Hide {app} Sakrij {app} + Quit {app} Zatvori {app} + Services Usluge + Hide Others Sakrij ostale + Lut LUT + Cooling L B B Hlađenje LBB + Cooling80 Hlađenje 80 + Cooling82 Hlađenje 82 + Hsb HSB + Hsl HSL + Movie Prime Filmski prime + Prime105 Prime105 + Prime35 Prime35 + Rgb RGB + Warming L B A Zagrijavanje LBA + Warming81 Zagrijavanje 81 + Warming85 Zagrijavanje 85 +@id filter.sharpen.sharpen Izoštri + Good Dobro + Best Najbolje + Adjustment Properties Svojstva podešavanja + Default Foreground and Background Colors Zadane boje prednjeg plana i pozadine + Move Layer Pomakni sloj + Rectangular Selection Pravougaoni odabir + Reorder Layer Promijeni redoslijed sloja + Select Layer Odaberi sloj + Set Colors Postavi boje + Switch Foreground and Background Colors Zamijeni boje prednjeg plana i pozadine + Target Cilj + Make Work Path… Napravi radnu putanju… + Create Vector Mask Kreiraj vektorsku masku + Make Selection… Napravi odabir… + Fill Path… Ispuni putanju… + Clipping Path… Putanja isijecanja… + Free Transform Path Slobodno transformiši putanju + Unite Shapes at Overlap Ujedini oblike na preklapanju + Subtract Shapes at Overlap Oduzmi oblike na preklapanju + Copy Fill Kopiraj ispunu + Copy Complete Stroke Kopiraj cijeli potez + Paste Fill Zalijepi ispunu + Paste Complete Stroke Zalijepi cijeli potez + Make Symmetry Path Napravi putanju simetrije + Disable Symmetry Path Onemogući putanju simetrije + Dark Gray Tamnosiva + Medium Gray Srednje siva + Select Custom Color… Odaberi prilagođenu boju… + Color Picker (Custom Canvas Color) Birač boja (prilagođena boja platna) + Duplicate Channel Dupliciraj kanal + Delete Channel Izbriši kanal + Rename Channel… Preimenuj kanal… + Merge Spot Channel Spoji spot kanal + Convert to Alpha Channel Pretvori u alfa kanal + Exit Quick Mask Izađi iz brze maske + New Channel… Novi kanal… + New Spot Channel… Novi spot kanal… + Split Channels Razdvoji kanale + Merge Channels… Spoji kanale… + Color Indicates Masked Areas Boja označava maskirana područja + Color Indicates Selected Areas Boja označava odabrana područja + ✓ Color Indicates Masked Areas ✓ Boja označava maskirana područja + ✓ Color Indicates Selected Areas ✓ Boja označava odabrana područja + Up Gore + To Do + Shallow Plitko + Deep Duboko + Blend Interior Effects as Group Stopi unutrašnje efekte kao grupu + Blend Clipped Layers as Group Stopi isječene slojeve kao grupu + Transparency Shapes Layer Prozirnost oblikuje sloj + Layer Mask Hides Effects Maska sloja skriva efekte + Vector Mask Hides Effects Vektorska maska skriva efekte + General Blending Opće stapanje + This Layer: Ovaj sloj: + Underlying Layer: Donji sloj: + Nothing to paste: the clipboard holds no image Nema ničeg za lijepljenje: međuspremnik ne sadrži sliku + Preferences saved Postavke sačuvane + Invalid panel layout; restored the default dock Nevažeći raspored panela; vraćeno je zadano sidrište + Preferences were reset: {error} Postavke su vraćene na početne: {error} + Recovered {name} Oporavljeno: {name} + Couldn't save preferences: {error}. Retrying… Nije moguće sačuvati postavke: {error}. Ponovni pokušaj… + Autosave failed: {error} Automatsko čuvanje nije uspjelo: {error} + Exported presets to {path} Predlošci izvezeni u {path} + Ran script {name} Pokrenuta skripta {name} + Sent to the printer ({spooler}) Poslano na štampač ({spooler}) + Printed to {path} Odštampano u {path} + Exported {path} ({size}) Izvezeno {path} ({size}) + Exported {path} Izvezeno: {path} + Added swatch {name} Dodan uzorak boje {name} + Step {step} ({id}) failed: {error} Korak {step} ({id}) nije uspio: {error} + Could not rename the action: {error} Nije moguće preimenovati akciju: {error} + Action playback failed: {error} Reprodukcija akcije nije uspjela: {error} + Clone source set at {x}, {y} Izvor kloniranja postavljen na {x}, {y} + Color management: {error} Upravljanje bojama: {error} + Opened {url} Otvoreno: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nema prilagođenog oblika "{name}": odaberite jedan u traci opcija ili u Prozor › Oblici + Saved a copy as {path} Kopija sačuvana kao {path} + Exported contents to {path} Sadržaj izvezen u {path} + {label} is not available: {reason} {label} nije dostupno: {reason} + Saved {path}; image assets: {n} in {dir} Sačuvano {path}; slikovni resursi: {n} u {dir} + {message} (notes: {n}; {warning}) {message} (bilješke: {n}; {warning}) + Preferences can't be saved Postavke se ne mogu sačuvati + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft nastavlja pokušavati; dok čuvanje ne uspije, promjene postavki se gube pri zatvaranju. + GPU retry scheduled Zakazan ponovni pokušaj GPU-a + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Sačuvajte svoj rad i ponovo pokrenite PhotoCraft da ponovo pokušate GPU ubrzanje. CPU iscrtavanje ostaje aktivno za ovu sesiju. + GPU device was lost; using the CPU renderer. GPU uređaj je izgubljen; koristi se CPU iscrtavanje. + GPU error; using the CPU renderer. GPU greška; koristi se CPU iscrtavanje. + Couldn't open {name}: {error} Nije moguće otvoriti {name}: {error} + Toggle the Brush Settings panel Uključi/isključi panel Postavke četkice + New Brush Preset Nova unaprijed postavljena četkica + Grid view Prikaz mreže + List view Prikaz liste + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink Prekini vezu + Link Veza + layer mask maska sloja + vector mask vektorska maska + Commit transform Potvrdi transformaciju + Cancel transform Otkaži transformaciju + Scroll menu up Pomjeri meni gore + Scroll menu down Pomjeri meni dolje + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora se rasterizirati prije nastavka. Njegov tekst više neće biti moguće uređivati. Rasterizirati tekst? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora se rasterizirati prije nastavka. Više se neće moći uređivati kao oblik. Rasterizirati oblik? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora se rasterizirati prije nastavka. Njegov sadržaj više neće biti moguće uređivati. Rasterizirati pametni objekat? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj ispune mora se rasterizirati prije nastavka. Njegov sadržaj ispune više neće biti moguće uređivati. Rasterizirati sloj? + This layer must be rasterized before proceeding. Ovaj sloj mora se rasterizirati prije nastavka. + Color Picker (Background Color) Birač boja (boja pozadine) + Color Picker (Foreground Color) Birač boja (boja prednjeg plana) + Adwaita (Light) Adwaita (svijetla) + Adwaita (Dark) Adwaita (tamna) + Breeze Light Breeze svijetla + Breeze Dark Breeze tamna + Solarized Dark Solarized tamna + Open Recent is unavailable on the web Otvori nedavno nije dostupno na webu + Graphics adapter: {adapter} Grafički adapter: {adapter} + Backend: {backend} ({type}) Pozadinski sistem: {backend} ({type}) + Driver: {driver} Upravljački program: {driver} + GPU backend preference: {backend} Preferirani GPU pozadinski sistem: {backend} + Selected at launch: {backend} Odabrano pri pokretanju: {backend} + Image compositor: {processor} Kompozitor slike: {processor} + Fallback: {reason} Zamjena: {reason} + This session: {reason} Ova sesija: {reason} + Window renderer: {renderer} Iscrtavač prozora: {renderer} + Software adapter Softverski adapter + Graphics adapter Grafički adapter + unknown nepoznato + unknown type nepoznata vrsta + auto automatski + Platform: {os} {arch} Platforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} zadržano je prethodno očitanje; posljednje ponovno čitanje nije uspjelo: {reason} + Monitor profile: {profile} Profil monitora: {profile} + Display profiles: {reason} Profili ekrana: {reason} + Monitor profile in use on {display}: {profile} Profil monitora u upotrebi na {display}: {profile} + Monitor profile in use: {profile} Profil monitora u upotrebi: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno se prikazuje kao sRGB. Odaberite profil monitora u Uredi › Postavke boja. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno na {display} prikazuje se kao sRGB. Odaberite profil monitora u Uredi › Postavke boja. + Display profile not used Profil ekrana se ne koristi + Monitor profile on {display} (main window): {profile} Profil monitora na {display} (glavni prozor): {profile} + Monitor profile on {display}: {profile} Profil monitora na {display}: {profile} + manual ručno + fallback zamjenski + The document does not have an embedded colour profile. Working space: {working}. Dokument nema ugrađeni profil boja. Radni prostor: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument ima ugrađeni profil boja koji ne odgovara radnom prostoru.\nUgrađeni: {embedded}\nRadni: {working} + Importing {what} from {file}… Uvoz {what} iz {file}… + brushes četkice + gradients gradijenti +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Uvezena {n} četkica iz {file}|Uvezene {n} četkice iz {file}|Uvezeno {n} četkica iz {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Uvezen {n} gradijent iz {file}|Uvezena {n} gradijenta iz {file}|Uvezeno {n} gradijenata iz {file} + Data Set: {name} ({index}/{total}) Skup podataka: {name} ({index}/{total}) + (none) (nijedno) +navigator Zoom Zumiranje + Apply {option}: {state} Primijeni {option}: {state} + on uključeno + off isključeno + This adjustment ({key}) is kept as imported and can't be edited yet. Ovo podešavanje ({key}) zadržano je kako je uvezeno i još se ne može uređivati. + Join the ArtCraft Discord ({url}) Pridružite se ArtCraft Discordu ({url}) + {index} of {total} {index} od {total} + {shortcut} assigned. Dodijeljena prečica {shortcut}. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} već koristi {commands} i bit će uklonjena odatle kad kliknete U redu. +@id file.close Zatvori +@id layer.layerMask.apply Primijeni +@id edit.clear Očisti +layerLabel Red Crvena +layerLabel Magenta Magenta + Drag: draw freehand · Release on the start point: close Prevlačenje: crtaj prostoručno · Otpuštanje na početnoj tački: zatvori + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Prevucite okvir izrezivanja · prevucite unutra za pomjeranje · ivice mijenjaju veličinu ({ratio} omjer, {centre} središte) · Razmak pomjera tokom crtanja · {commit} potvrđuje · Esc otkazuje + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Podrezivanje: metrika, optičko ili vrijednost u 1/1000 em (Alt+←/→ kod kursora) + Basic Latin Osnovna latinica + Latin 1 Latinica 1 + Latin Extended-A Proširena latinica A + Latin Extended-B Proširena latinica B + Greek Grčko + Cyrillic Ćirilica + Numbers Brojevi + Currency Valuta + Math Symbols Matematički simboli + Arrows Strelice + Save Path Sačuvaj putanju +cameraRaw Highlights Svijetli tonovi +cameraRaw Shadows Sjene + All shortcuts reset to Photoshop defaults. Sve prečice su vraćene na zadane vrijednosti Photoshopa. + Temporary Privremeno + none nijedno + red crvena + orange narandžasta + yellow žuta + green zelena + blue plava + violet ljubičasta + gray siva + Working Rgb Radni RGB + Working Cmyk Radni CMYK + Working Gray Radna siva + Policy Rgb Pravilo za RGB + Policy Cmyk Pravilo za CMYK + Policy Gray Pravilo za sivu + Convert Pretvori + Ask On Mismatch Pitaj pri neusklađenosti + Ask On Paste Pitaj pri lijepljenju + Ask On Missing Pitaj kad nedostaje + Intent Namjera + Bpc KCT + Blend Text Gamma Gama stapanja teksta + Monitor Profile Profil monitora + Display-p3 Display-P3 + Adobe-rgb-compat Adobe-rgb-compat + Prophoto-compat Prophoto-compat + Linear-srgb Linearni sRGB + Rec2020 Rec2020 + Coated-cmyk Premazni CMYK (coated) + Sgray sGray + Gray-gamma-2.2 Siva gama 2.2 + Import Photoshop brushes (.abr) Uvezi Photoshop četkice (.abr) +cameraRaw Effects Efekti +cameraRaw Exposure Ekspozicija +cameraRaw Contrast Kontrast +cameraRaw Texture Tekstura +cameraRaw Clarity Jasnoća +cameraRaw Dehaze Uklanjanje izmaglice +cameraRaw Saturation Zasićenje +cameraRaw Curve Kriva +cameraRaw Color Mixer Mikser boja +cameraRaw Midtones Srednji tonovi +cameraRaw Balance Balans +cameraRaw Detail Detalji +cameraRaw Radius Poluprečnik +cameraRaw Feather Omekšavanje ivica +cameraRaw Midpoint Srednja tačka +cameraRaw Roundness Zaobljenost +cameraRaw Size Veličina + Default Photoshop Size Zadana veličina Photoshopa +document Orientation Orijentacija + Arc Lower Donji luk + Arc Upper Gornji luk + Forward Warp Iskrivljenje naprijed + Twirl Clockwise Kovitlac u smjeru kazaljke na satu + Twirl Counterclockwise Kovitlac suprotno od kazaljke na satu + Pucker Nabiranje + Bloat Napuhni + Push Left Gurni ulijevo + Freeze Mask Maska zamrzavanja + Thaw Mask Odmrzni masku + Freeze Lasso Laso za zamrzavanje + Reconstruct All Rekonstruiši sve + Mask None Bez maske +colorRange Invert Obrni +layers Fill Ispuna +layers Fill: Ispuna: +shape Appearance Izgled + Add to Dictionary Dodaj u rječnik + No Spelling Suggestions Nema pravopisnih prijedloga + Save Settings… Sačuvaj postavke… + Load Settings… Učitaj postavke… + Save these settings as a Camera Raw .xmp file Sačuvaj ove postavke kao Camera Raw .xmp datoteku + Load settings from a Camera Raw .xmp file Učitaj postavke iz Camera Raw .xmp datoteke + Loaded Camera Raw settings from {file} Učitane postavke Camera Raw iz {file} + Loaded Camera Raw settings from {file} ({n} not supported) Učitane postavke Camera Raw iz {file} ({n} nije podržano) + Manage submitted jobs in the system print queue Upravljajte poslanim zadacima u sistemskom redu za štampanje + Pick Color From Image Uzmi boju iz slike + Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomjeranje · Pomjerite točkić za zumiranje · Dvostruki klik za uklapanje + Object Finder Pronalazač objekata + No object found there — drag a rectangle around it Tamo nije pronađen objekat — prevucite pravougaonik oko njega + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivni sloj ovdje nema piksela — uključite Uzorkuj sve slojeve ili odaberite sloj s pikselima + Clear Brush Controls Očisti kontrole četkice + Reset All Locked Settings Vrati sve zaključane postavke na početno + Copy Texture to Other Tools Kopiraj teksturu u druge alate + Load Actions… Učitaj akcije… + Save Actions… Sačuvaj akcije… + New Action… Nova akcija… + Start Recording Pokreni snimanje + Stop Recording Zaustavi snimanje + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Učitana {n} akcija iz {file}|Učitane {n} akcije iz {file}|Učitano {n} akcija iz {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} korak još nema ekvivalent u PhotoCraftu|{n} koraka još nemaju ekvivalent u PhotoCraftu|{n} koraka još nema ekvivalent u PhotoCraftu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Sačuvana {n} akcija u {file}|Sačuvane {n} akcije u {file}|Sačuvano {n} akcija u {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Izostavljen {n} korak bez ekvivalenta u Photoshopu|Izostavljena {n} koraka bez ekvivalenta u Photoshopu|Izostavljeno {n} koraka bez ekvivalenta u Photoshopu + Insert Stop… Umetni tačku… + Save Set… Sačuvaj skup… + Message: Poruka: + Allow Continue Dozvoli nastavak + Show dialog during playback Prikaži dijalog tokom reprodukcije + Select an action first Prvo odaberite akciju + Action playback stopped Reprodukcija akcije zaustavljena + Step {step} is waiting for its dialog Korak {step} čeka svoj dijalog +@plural {n} action|{n} actions {n} akcija|{n} akcije|{n} akcija + Gradient Preset Options Opcije predloška gradijenta + Import .GRD Uvezi .GRD + Choose a gradient file (.grd). Odaberite datoteku gradijenta (.grd). + Auto Add/Delete Automatsko dodavanje/brisanje + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nemojte također označiti Zrcalna slika u Postavke štampanja…: dva preokretanja se poništavaju. + Print Settings… Postavke štampanja… + Remember to turn off the printer's own color management in Print Settings… Ne zaboravite isključiti vlastito upravljanje bojama štampača u Postavke štampanja… + Sent to {printer} Poslano na {printer} + set in Print Settings… postavlja se u Postavke štampanja… + Paper Width Širina papira + Paper Height Visina papira + ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela + Mask Options… Opcije maske… + Layer Mask Display Options… Opcije prikaza maske sloja… + Channel Color Boja kanala + Color Indicates: Boja označava: + Ink Characteristics Karakteristike tinte + Solidity: Čvrstoća: + Calibration Kalibracija + Red Primary Crvena primarna + Green Primary Zelena primarna + Blue Primary Plava primarna + Write xmp sidecars Zapisuj xmp prateće datoteke + Set the tone sliders from the image Postavi klizače tonova prema slici + Settings from {name} Postavke iz {name} + Settings from {name}; {count} not supported: {names} Postavke iz {name}; nije podržano ({count}): {names} + Could not write {name}: {error} Nije moguće zapisati {name}: {error} + Kept {name}: written by another application Zadržano {name}: zapisala ga je druga aplikacija + Shadow Tint Nijansa sjene + Redrawing Ponovno iscrtavanje + Show Favorite Fonts Prikaži omiljene fontove + No favorite fonts yet: click the star beside a font to add it. Još nema omiljenih fontova: kliknite zvjezdicu pored fonta da ga dodate. + Favorite fonts Omiljeni fontovi + Add to Favorites Dodaj u favorite + Remove from Favorites Ukloni iz favorita + Show All Fonts Prikaži sve fontove + All Classes Sve klase + Blackletter Gotica + Decorative Dekorativni + Font preview: Pregled fonta: + Handwritten Rukopisni + Hide Font Classes Sakrij klase fontova + Hide Styles Sakrij stilove + Monospace Jednake širine + No fonts match these filters. Nijedan font ne odgovara ovim filterima. + No similar fonts: the current font doesn't say what it looks like. Nema sličnih fontova: trenutni font ne navodi kako izgleda. + OpenType font OpenType font + Sans Serif Bez serifa + Script Pisani + Serif Serifni + Show Font Classes Prikaži klase fontova + Show Similar Fonts Prikaži slične fontove + Show Styles Prikaži stilove + Show Variable Fonts Prikaži varijabilne fontove + Slab Serif Pločasti serif + TrueType font TrueType font + Variable font Varijabilni font + Highlight Mode Način svijetlih tonova + Highlight Color Boja svijetlih tonova + Highlight Opacity Neprozirnost svijetlih tonova + Shadow Mode Način sjene + Shadow Color Boja sjene + Shadow Opacity Neprozirnost sjene + Sampling Brush Tool Alat četkica za uzorkovanje + Sampling Area Overlay Preklop područja uzorkovanja + Show Sampling Area Prikaži područje uzorkovanja + Indicates Označava + Sampling Area Područje uzorkovanja + Excluded Area Isključeno područje + Sampling Area Options Opcije područja uzorkovanja + Rectangular Pravougaono + Fill Settings Postavke ispune + Rotation Adaptation Prilagođavanje rotacije + Very High Vrlo visoko + Full Puno + Mirror Zrcali + Output Settings Postavke izlaza + Output To Izlaz u + Response curve Kriva odziva + Use the embedded profile ({profile}) Koristi ugrađeni profil ({profile}) + Convert to the working space ({profile}) Pretvori u radni prostor ({profile}) + Remember this choice for future images Zapamti ovaj izbor za buduće slike + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Ponovo uključite upit opcijom Pitaj pri neusklađenosti u Uredi › Postavke boja. + Button Mode Način dugmadi + Button Mode On Način dugmadi uključen + Darken Interface Zatamni interfejs + Lighten Interface Posvijetli interfejs + Technique Tehnika + Chisel Hard Tvrdo dlijeto + Chisel Soft Meko dlijeto + Open color-less EXR as ACEScg Otvori EXR bez boje kao ACEScg + Do not append "copy" to filename when saving a copy Ne dodaji „kopija” nazivu datoteke pri čuvanju kopije + Show home recent files Prikaži nedavne datoteke na početnoj + Edit Selected Guides Uredi odabrane vodilice + Clear Selected Guides Očisti odabrane vodilice + Bicubic Automatic Bikubično automatski + Bicubic Smoother Bikubično glađe + Bicubic Sharper Bikubično oštrije + Pasting from other apps is unavailable Lijepljenje iz drugih aplikacija nije dostupno + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft u ovoj sesiji ne može pristupiti sistemskom međuspremniku. Na Waylandu Flatpak okruženje skriva protokol preko kojeg se čitaju slike iz drugih aplikacija. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Da lijepite iz drugih aplikacija, pokrenite PhotoCraft pod XWaylandom: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nema šta zalijepiti: PhotoCraft ovdje ne može čitati sistemski međuspremnik + Delete this set Izbriši ovaj skup + New Set… Novi skup… + PhotoCraft Defaults Zadane postavke PhotoCrafta + Save a list of these shortcuts as a web page Sačuvaj listu ovih prečica kao web stranicu + Save the changes to this set Sačuvaj promjene u ovom skupu + Save these shortcuts as a .kys file Sačuvaj ove prečice kao .kys datoteku + Save these shortcuts as a new set Sačuvaj ove prečice kao novi skup + Set deleted. Skup izbrisan. + Set saved. Skup sačuvan. + Set: Skup: + Summarize… Sažmi… + There are too many sets: delete one first. Previše je skupova: prvo izbrišite jedan. + set name naziv skupa + {set} (modified) {set} (izmijenjeno) + Show file name in title Prikaži naziv datoteke u naslovu + 50% Threshold Prag 50% + Pattern Dither Rasipanje šarom + Diffusion Dither Difuzijsko rasipanje + Halftone Screen Raster polutonova + Custom Pattern Prilagođena šara + Frequency: Frekvencija: + Lines/Inch Linija/inč + Lines/Centimeter Linija/centimetar + Ellipse Elipsa + Add “copy” to Copied Layers and Groups Dodaj „kopija” kopiranim slojevima i grupama + Animation Options Opcije animacije + Entire Document Cijeli dokument + Expand New Effects Proširi nove efekte + Filter Options Opcije filtera + Layer Bounds Granice sloja + Layers Panel Options Opcije panela Slojevi + New Artboard… Nova radna ploča… + New Group… Nova grupa… + Prevent Auto-Nesting Spriječi automatsko ugnježđivanje + Preview on canvas when reordering layers Pregled na platnu pri promjeni redoslijeda slojeva + Show Layers Prikaži slojeve + Show layer mask badges Prikaži oznake maski slojeva + Thumbnail Contents Sadržaj sličice + Thumbnail Size Veličina sličice + Use Default Masks on Fill Layers Koristi zadane maske na slojevima ispune + Blending Options ({filter}) Opcije stapanja ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Sakrij efekte i pametne filtere sloja ({key}-klik: svi slojevi) + Opacity must be between 0% and 100%. Neprozirnost mora biti između 0% i 100%. + Dissolve is approximated: it blends without Photoshop's dither. Rastvaranje je približno: stapa se bez Photoshopovog rasipanja. + Show the layer's effects and Smart Filters ({key}-click: all layers) Prikaži efekte i pametne filtere sloja ({key}-klik: svi slojevi) + The document or Smart Filter changed. Reopen its dialog. Dokument ili pametni filter se promijenio. Ponovo otvorite njegov dijalog. + Wait for the current job to finish. Sačekajte da se trenutni posao završi. + Accept and Go to Conflict Prihvati i idi na konflikt + Changes undone. Promjene poništene. + Choose a new shortcut for this command, or leave it without one. Odaberite novu prečicu za ovu naredbu ili je ostavite bez nje. + Put this command's shortcuts back as they were Vrati prečice ove naredbe kakve su bile + Take the shortcut from the other command and select it Preuzmi prečicu od druge naredbe i odaberi je + Undo Changes Poništi promjene + {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana za sistem i ne može se dodijeliti. + Low-Latency Drawing (vsync off, applies at next launch) Crtanje s malim kašnjenjem (vsync isključen, primjenjuje se pri sljedećem pokretanju) + Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primjenjuje; pokušajte ponovo kad završi. + The transform couldn't be applied. Transformaciju nije moguće primijeniti. + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovi slojevi trebaju {needed} GPU memorije; budžet im ostavlja {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Postavite GPU memoriju u Postavke › Performanse na onoliko koliko vaša grafička kartica može podnijeti, najviše 3/8 njene memorije. Automatski prati Upotrebu memorije, unutar četvrtine memorije ovog računara i 3/8 memorije kartice, i ne računa dokument prema kartici čija je memorija poznata. Statusna traka prikazuje GPU memorija je puna dok ne stanu. + GPU Memory Full GPU memorija je puna + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Slojevi trebaju {needed} GPU memorije, a mogu imati {budget}, pa se izmjene koje ponovo iscrtavaju cijeli dokument rade na CPU. Kliknite da postavite GPU memoriju. + GPU memory budget: {budget} Budžet GPU memorije: {budget} + Graphics memory: {size} Grafička memorija: {size} + This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevi ovog dokumenta trebaju {needed}, a mogu imati {budget}: ponovno iscrtavanje cijelog dokumenta koristi CPU + GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = automatski) + {path}: the path is not valid Unicode; save under another name. {path}: putanja nije važeći Unicode; sačuvajte pod drugim nazivom. + {name} was changed by another app after it was opened or saved here. {name} je promijenila druga aplikacija nakon što je ovdje otvoren ili sačuvan. + Replace it with this document, or cancel and use File › Save As to keep both. Zamijenite je ovim dokumentom ili otkažite i koristite Datoteka › Sačuvaj kao da zadržite oboje. + Decrease Brush Size Smanji veličinu četkice + Increase Brush Size Povećaj veličinu četkice + Decrease Brush Hardness Smanji tvrdoću četkice + Increase Brush Hardness Povećaj tvrdoću četkice + Demosaic Demozaik + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kako se popunjava mozaik filtera boja kamere. Primjenjuje se pri otvaranju datoteke; pregled prikazuje metodu kojom je otvorena. + AHD (adaptive homogeneity) AHD (adaptivna homogenost) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: najčišće ivice i najmanje obojenih rubova, najsporiji. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolacija s korekcijom gradijenta: oštra i brza, uz nešto obojenih rubova na finim detaljima. + The fastest and the softest, with color fringing on fine detail. Najbrži i najmekši, uz obojene rubove na finim detaljima. + “{name}” already exists. Replace it? „{name}” već postoji. Zamijeniti? + The name in the save dialog was “{name}”. Naziv u dijalogu za čuvanje bio je „{name}”. + Move Up Pomakni gore + Move Down Pomakni dolje + Previous Brush Prethodna četkica + Next Brush Sljedeća četkica + First Brush Prva četkica + Last Brush Posljednja četkica + Preview size Veličina pregleda + Memory Usage Upotreba memorije + Per-document RAM limit (MB) Ograničenje RAM-a po dokumentu (MB) + Installed RAM: {ram} MB; this limit: {percent}% Instalirani RAM: {ram} MB; ovo ograničenje: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ograničava piksele svakog otvorenog dokumenta zajedno s historijom. Najstarija stanja poništavanja odbacuju se kada se ograničenje prekorači. + Applies immediately when saved. Memory is not reserved in advance. Primjenjuje se odmah nakon čuvanja. Memorija se ne rezerviše unaprijed. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Kada je GPU memorija postavljena na Automatski, ovo ograničava i GPU budžet za slojeve. Taj budžet je ograničen na četvrtinu instaliranog RAM-a i, kada je poznato, tri osmine memorije grafičke kartice, uz najmanje 512 MB. Povećanje ovog ograničenja iznad tih granica ne povećava GPU budžet. + Use Koristi + In Front Ispred + Behind Iza + DDS Options DDS opcije + Generate Mipmaps Generiši mipmape + There is no color table to save. Nema tabele boja za čuvanje. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Sačuvana {n} boja u {file}|Sačuvane {n} boje u {file}|Sačuvano {n} boja u {file} + GPU adapter GPU adapter + Import Styles… Uvezi stilove… + Export Selected Styles… Izvezi odabrane stilove… + Export Styles… Izvezi stilove… + {file} is not a style library (.asl) {file} nije biblioteka stilova (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Uvezen {n} stil iz {file}|Uvezena {n} stila iz {file}|Uvezeno {n} stilova iz {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Izvezen {n} stil u {file}|Izvezena {n} stila u {file}|Izvezeno {n} stilova u {file} + Confirm or cancel the open action step dialog first Prvo potvrdite ili otkažite dijalog otvorenog koraka akcije + Pro Medium Gray Pro srednje siva + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro svijetlosiva + Pro Light Pro svijetla + Resize Windows to Fit Promijeni veličinu prozora da stane + EXR EXR + Automatic paragraph direction Automatski smjer pasusa + Left-to-right paragraph direction Smjer pasusa slijeva nadesno + Right-to-left paragraph direction Smjer pasusa zdesna nalijevo + Not rendered: this adjustment ({key}) is kept as imported. Nije iscrtano: ovo podešavanje ({key}) zadržano je kako je uvezeno. + Print preview image Slika za pregled štampe + Color Aware Svjesno boje + Decontaminate Colors Dekontaminiraj boje + Edge Detection Otkrivanje ivica + Global Refinements Globalna poboljšanja + High Quality Preview Pregled visokog kvaliteta + Object Aware Svjesno objekata + Real-Time Refinement Poboljšanje u stvarnom vremenu + Refine Edge Brush Tool Alat četkica za poboljšanje ivice + Refine Hair Poboljšaj kosu + Refine Mode Način poboljšanja + Remember Settings Zapamti postavke + View Mode Način prikaza + Object Aware needs a segmentation model, which isn't available yet Svjesno objekata zahtijeva model segmentacije, koji još nije dostupan + Refine Hair needs hair detection, which isn't available yet Poboljšaj kosu zahtijeva otkrivanje kose, koje još nije dostupno + Check for Updates… Provjeri ažuriranja… + PhotoCraft Updates PhotoCraft ažuriranja + Checking for updates… Provjera ažuriranja… + Open the release page to choose an installer for your system. Otvorite stranicu izdanja da odaberete instalacijski program za svoj sistem. + Open Download Page Otvori stranicu za preuzimanje + PhotoCraft is up to date. PhotoCraft je ažuran. + Could not check for updates. Nije moguće provjeriti ažuriranja. + Updates have not been checked yet. Ažuriranja još nisu provjerena. + Check Again Provjeri ponovo + Automatically Check for Updates Automatski provjeravaj ažuriranja + Installed version: {version} Instalirana verzija: {version} + PhotoCraft {version} is available. Dostupan je PhotoCraft {version}. + Hue Cube Kocka nijanse + Brightness Cube Kocka svjetline + Color Wheel Kotač boja + RGB Sliders RGB klizači + HSB Sliders HSB klizači + CMYK Sliders CMYK klizači + Lab Sliders Lab klizači + Web Color Sliders Klizači web boja + Copy Color as HTML Kopiraj boju kao HTML + Copy Color's Hex Code Kopiraj heksadecimalni kod boje + RGB Spectrum RGB spektar + CMYK Spectrum CMYK spektar + Grayscale Ramp Rampa sivih tonova + Current Colors Trenutne boje + Make Ramp Web Safe Prilagodi rampu za web + Select and Mask was closed: its document is no longer the active one Odaberi i maskiraj je zatvoreno: njegov dokument više nije aktivan + Select and Mask was closed: another layer was selected Odaberi i maskiraj je zatvoreno: odabran je drugi sloj + Art History Brush Tool Alat umjetnička četkica historije + Tight Short Čvrsti kratki + Tight Medium Čvrsti srednji + Tight Long Čvrsti dugi + Loose Medium Labavi srednji + Loose Long Labavi dugi + Dab Otisak + Tight Curl Čvrsti kovrdžavi + Loose Curl Labavi kovrdžavi + Stylized dabs from a history state Stilizovani otisci iz stanja historije + Font size presets Unaprijed zadane veličine fonta + Leading presets Unaprijed zadani prored + Tracking presets Unaprijed zadani razmak slova + Import Brushes… (.abr or image) Uvezi četkice… (.abr ili slika) + Import Brush from Image… Uvezi četkicu iz slike… +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Uvezen {n} uzorak iz {file}|Uvezena {n} uzorka iz {file}|Uvezeno {n} uzoraka iz {file} + Export As: {name} Izvezi kao: {name} + Color Sampler Tool Alat za uzorkovanje boje + Proof Setup… Postavke probnog prikaza… + Toggle Layer Mask Overlay Uključi/isključi preklop maske sloja + Increase Type Size Povećaj veličinu slova + Decrease Type Size Smanji veličinu slova + Increase Type Size by 10 Povećaj veličinu slova za 10 + Decrease Type Size by 10 Smanji veličinu slova za 10 + Make Selection from Path Napravi odabir iz putanje + Target Composite Channel Ciljni kompozitni kanal + Target Channel 3 Ciljni kanal 3 + Target Channel 4 Ciljni kanal 4 + Target Channel 5 Ciljni kanal 5 + Target Channel 6 Ciljni kanal 6 + Target Channel 7 Ciljni kanal 7 + Target Channel 8 Ciljni kanal 8 + Target Channel 9 Ciljni kanal 9 + Fill with Foreground Color Ispuni bojom prednjeg plana + Fill with Background Color Ispuni bojom pozadine + Fill with Foreground Color, Preserve Transparency Ispuni bojom prednjeg plana, očuvaj prozirnost + Fill with Background Color, Preserve Transparency Ispuni bojom pozadine, očuvaj prozirnost + Space Razmak + Hand Tool (hold) Alat ruka (držanje) + Zoom In (hold, drag to scrub) Uvećaj (držite, prevucite za prelazak) + Zoom Out (hold) Umanji (držite) + Erase with Brush or Pencil (hold) Briši četkicom ili olovkom (držanje) + Author Title Titula autora + Copyright Url URL autorskih prava + Title Naslov + Working-cmyk Radni CMYK (working-cmyk) + Simulate Paper Simuliraj papir + Warming Filter (85) Filter zagrijavanja (85) + Cw U smjeru kazaljke + Ccw Suprotno kazaljci + Nearest Najbliže + Mask Channel Kanal maske + R G B R G B + Source Channel Izvorni kanal + Source Invert Obrni izvor + Source1 Channel Kanal izvora 1 + Source1 Invert Obrni izvor 1 + Source2 Channel Kanal izvora 2 + Source2 Invert Obrni izvor 2 + W: Š: + A: A: + Pattern Fill… Ispuna uzorkom… + Any Bilo koje + Cmyk CMYK + Dont Enlarge Ne uvećavaj + Change Find Promijeni pretragu + Case Sensitive Razlikuj velika i mala slova + Whole Word Cijela riječ + Forward Naprijed + Based On Zasnovano na + Checkerboard Šahovsko polje + Bend Savij + Horizontal Distortion Vodoravno izobličenje + Vertical Distortion Okomito izobličenje + Starting serial Početni serijski broj + CPU Rendering Iscrtavanje na CPU-u + Calculating… Računanje… + Click to refresh histogram with uncached data Kliknite da osvježite histogram nekeširanim podacima + Count: Broj: + Drawn on the CPU: {reason}. Click for System Info. Iscrtano na CPU-u: {reason}. Kliknite za informacije o sistemu. + Level: Nivo: + Percentile: Percentil: + Release Notes Bilješke o izdanju + Show Channels in Color Prikaži kanale u boji + Show Statistics Prikaži statistiku + Source: Izvor: + Uncached Refresh Osvježavanje bez keša + Update and Restart Ažuriraj i pokreni ponovo + Warming Filter (LBA) Filter za zagrijavanje (LBA) + Warming Filter (81) Filter za zagrijavanje (81) + Cooling Filter (80) Filter za hlađenje (80) + Cooling Filter (LBB) Filter za hlađenje (LBB) + Cooling Filter (82) Filter za hlađenje (82) + R g b R g b + Convert to Shape… Pretvori u oblik… + Delete Hidden Layers Izbriši skrivene slojeve + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Prevucite po slici da dodate ograničenje; Shift za vodoravno/okomito; desni klik za brisanje. + Exported {n} files Izvezeno datoteka: {n} + Fit Layer on Screen Uklopi sloj na ekran + Flow: Protok: + Refine Edge… Poboljšaj ivicu… + Reset Essentials Vrati Osnove + Rounded Rectangle Tool Alat zaobljeni pravougaonik + Status Bar Statusna traka + Tile Horizontally Poslaži vodoravno + Tile Vertically Poslaži okomito +@plural {n} document|{n} documents {n} dokument|{n} dokumenta|{n} dokumenata +@plural {n} pixel|{n} pixels {n} piksel|{n} piksela|{n} piksela +cameraRaw Before (Y) Prije (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw filter ({layer}) +cameraRaw Camera Raw isn't open Camera Raw nije otvoren +cameraRaw Cancel Otkaži +cameraRaw Canceled opening {name} Otkazano otvaranje {name} +cameraRaw Canceled {label} Otkazano: {label} +cameraRaw Color Priority Prioritet boje +cameraRaw Developing {name} Razvijanje {name} +cameraRaw Full-resolution preview unavailable Pregled u punoj rezoluciji nije dostupan +cameraRaw Grain Zrno +cameraRaw Highlight Clipping Warning (O) Upozorenje o odsijecanju svijetlih tonova (O) +cameraRaw Highlight Priority Prioritet svijetlih tonova +cameraRaw Load Settings… Učitaj postavke… +cameraRaw Load settings from a Camera Raw .xmp file Učitaj postavke iz Camera Raw .xmp datoteke +cameraRaw Loaded Camera Raw settings from {file} Učitane postavke Camera Raw iz {file} +cameraRaw Noise Reduction Smanjenje šuma +cameraRaw OK U redu +cameraRaw Open Otvori +cameraRaw Opened {name} Otvoreno: {name} +cameraRaw Paint Overlay Preklop boje +cameraRaw Save Settings… Sačuvaj postavke… +cameraRaw Save these settings as a Camera Raw .xmp file Sačuvaj ove postavke kao Camera Raw .xmp datoteku +cameraRaw Shadow Clipping Warning (U) Upozorenje o odsijecanju sjena (U) +cameraRaw Sharpening Izoštravanje +cameraRaw Show Lab Color Readouts Prikaži Lab očitanja boja +cameraRaw Show Red at 3 o'clock Prikaži crvenu na 3 sata +cameraRaw Show Selected Region Prikaži odabranu regiju +cameraRaw Show Skin Tone Indicator Prikaži indikator tona kože +cameraRaw Show Vectorscope Prikaži vektorskop +cameraRaw Tone Histogram Tonski histogram +cameraRaw Vignetting Vinjetiranje +cameraRaw White Balance: As Shot Balans bijele: kako je snimljeno + Path operations Operacije s putanjama + Move effect up Pomakni efekat gore + Move effect down Pomakni efekat dolje + Delete effect Izbriši efekat + Current Layer: Trenutni sloj: +@plural {n} action file loaded|{n} action files loaded Učitana je {n} datoteka akcija|Učitane su {n} datoteke akcija|Učitano je {n} datoteka akcija + {set} from {file} replaced the one from {earlier} Skup {set} iz {file} zamijenio je skup iz {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Nije moguće učitati {n} datoteku: {errors}|Nije moguće učitati {n} datoteke: {errors}|Nije moguće učitati {n} datoteka: {errors} + Memory usage percent Postotak upotrebe memorije + Save Full-Size Composite in .pcraft Files Sačuvaj složenu sliku pune veličine u .pcraft datotekama + Scratch Size Limit (MB, 0 = No Limit) Ograničenje veličine radnog diska (MB, 0 = bez ograničenja) +smartSvg Choose executable… Odaberi izvršnu datoteku… +smartSvg Choose the application used to edit this SVG: Odaberite aplikaciju za uređivanje ovog SVG-a: +smartSvg Edit SVG Smart Object Uredi SVG pametni objekat +smartSvg Other application Druga aplikacija +smartSvg PhotoCraft (internal editor) PhotoCraft (interni uređivač) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG uređivač je otvoren. Sačuvajte SVG da biste ažurirali ovaj pametni objekat. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Sačuvajte u otvorenu SVG datoteku. Svaka sačuvana izmjena postaje jedan korak poništavanja u PhotoCraftu. +smartSvg Stop syncing this Smart Object Zaustavi sinhronizaciju ovog pametnog objekta +smartSvg Updates wait while another document or an editing dialog is active. Ažuriranja čekaju dok je aktivan drugi dokument ili dijalog za uređivanje. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Sadržaj AI/PDF/EPS uređivat će se kao SVG. Čuvanje zamjenjuje ugrađeni izvor; svojstva specifična za format mogu se promijeniti. + The path was kept as the Work Path Putanja je zadržana kao radna putanja + The path was discarded Putanja je odbačena + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neispravan numerički unos. Potreban je cijeli broj između {min}% i {max}%. Umetnuta je prethodna vrijednost. + Select Layer Under Cursor Odaberi sloj ispod kursora + Drag to move the selected path Prevucite da pomaknete odabranu putanju + Recently Used Colors Nedavno korištene boje + Stop Color Boja tačke + Shapes can't use patterns yet Oblici još ne mogu koristiti uzorke + Edit Brush… Uredi četkicu… + +# Channels panel menu (#11451) + Channel Options… Opcije kanala… + Specify Channels: Odredi kanale: + Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj način. + Pick a different document for each channel. Odaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan odabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan odabrani dokument više nije dokument u nijansama sive iste veličine. +# Move tool options bar (#11381) + Align top edges Poravnaj gornje ivice + Align vertical centers Poravnaj vertikalne centre + Align bottom edges Poravnaj donje ivice + Distribute vertically Rasporedi vertikalno + Distribute horizontally Rasporedi horizontalno + Show layer bounds on hover Prikaži granice sloja pri prelasku miša + Expand layer group on click Proširi grupu slojeva klikom + Show hover bounds from Layers Prikaži granice pri prelasku iz panela Slojevi +# Artboard tool options bar (#11385) + Distribute Widths Rasporedi širine + Distribute Heights Rasporedi visine + Auto-nest Layers Automatski ugnijezdi slojeve + Auto-size Canvas Automatski promijeni veličinu platna + Keep Relative Position During Layer Reordering Zadrži relativni položaj pri promjeni redoslijeda slojeva + Shrinkwrap Canvas on Save Smanji platno na sadržaj pri sačuvanju diff --git a/crates/ui-egui/src/i18n/casing.rs b/crates/ui-egui/src/i18n/casing.rs new file mode 100644 index 0000000000..a8b10258ad --- /dev/null +++ b/crates/ui-egui/src/i18n/casing.rs @@ -0,0 +1,60 @@ +//! Case conversion of translated UI text; command IDs and English aliases use `Lang::EN`. + +use icu_casemap::CaseMapper; +use icu_locale_core::{LanguageIdentifier, langid}; + +use super::Lang; + +impl Lang { + /// Locales that tailor Unicode's dotted/dotless I mappings in this UI. + fn case_locale(self) -> Option { + match self.code() { + "az" => Some(langid!("az")), + "tr" => Some(langid!("tr")), + _ => None, + } + } + + /// Lowercase UI text in its language, including Turkic I and combining-dot contexts. + pub fn lowercase(self, text: &str) -> String { + match self.case_locale() { + Some(locale) => CaseMapper::new().lowercase_to_string(text, &locale).into_owned(), + None => text.to_lowercase(), + } + } + + /// Uppercase UI text in its language (`i` → `İ`, `ı` → `I` in Azerbaijani and Turkish). + pub fn uppercase(self, text: &str) -> String { + match self.case_locale() { + Some(locale) => CaseMapper::new().uppercase_to_string(text, &locale).into_owned(), + None => text.to_uppercase(), + } + } + + /// Match visible text without case distinctions in this language. + pub fn contains_ignore_case(self, text: &str, query: &str) -> bool { + self.lowercase(text).contains(&self.lowercase(query)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unicode Turkic mappings keep the two I pairs distinct, including decomposed input. + #[test] + fn azerbaijani_and_turkish_case_mapping_handles_combining_dots() { + for code in ["az", "tr"] { + let lang = Lang::from_code(code).unwrap(); + assert_eq!(lang.lowercase("Iİıi"), "ıiıi"); + assert_eq!(lang.uppercase("Iİıi"), "IİIİ"); + assert_eq!(lang.lowercase("I\u{307} I\u{323}\u{307} I\u{301}"), "i i\u{323} ı\u{301}"); + assert!(lang.contains_ignore_case("İşıq", "İŞIQ")); + assert!(!lang.contains_ignore_case("İşıq", "IŞIQ")); + assert!(!lang.contains_ignore_case("ı", "i")); + } + assert_eq!(Lang::EN.lowercase("Iİıi"), "ii\u{307}ıi"); + assert_eq!(Lang::EN.uppercase("iı"), "II"); + assert_eq!(Lang::EN.lowercase("ΟΣ"), "ος", "keep contextual final sigma"); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8cd95ed30d..eb8d5dc546 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -203,6 +203,7 @@ Right Vpravo Open Recent Otevřít nedávné Clear Recent Files Vymazat nedávné soubory + Clear Recent File List Vymazat nedávné soubory Remove from Recent Odebrat z nedávných Remove {name} from Recent Odebrat {name} z nedávných Close Zavřít @@ -211,6 +212,10 @@ Restore Obnovit Close All Zavřít vše Close Others Zavřít ostatní + New Document… Nový dokument… + Open Document… Otevřít… + Consolidate All to Here Sloučit vše sem + Move to New Window Přesunout do nového okna Save Uložit Save As… Uložit jako… Save a Copy… Uložit kopii… @@ -262,6 +267,7 @@ Create Smart Object after Loading Layers Po načtení vrstev vytvořit chytrý objekt Reveal in Finder Zobrazit ve Finderu Show in Explorer Zobrazit v Průzkumníkovi + Reveal in Explorer Zobrazit v Průzkumníkovi Show in Folder Zobrazit ve složce File Info… Informace o souboru… Print… Tisk… @@ -350,6 +356,7 @@ Units & Rulers… Jednotky a pravítka… Guides, Grid & Slices… Vodítka, mřížka a řezy… Plug-ins… Zásuvné moduly… + Plugins… Zásuvné moduly… Type… Text… Enhanced Controls… Rozšířené ovládání… Camera Raw… Camera Raw… @@ -403,6 +410,8 @@ Match Color… Přizpůsobit barvy… Replace Color… Nahradit barvu… Equalize Vyrovnat + Equalize Selected Area Only Vyrovnat pouze výběr + Equalize Entire Image Based On Selected Area Vyrovnat celý obraz podle výběru Auto Tone Automatický tón Auto Contrast Automatický kontrast Auto Color Automatické barvy @@ -507,6 +516,8 @@ Smart Object Chytrý objekt Convert to Smart Object Převést na chytrý objekt Convert to Embedded Převést na vložený + Embed Linked Vložit propojené + Embed All Linked Vložit všechna propojená Convert to Layers Převést na vrstvy Convert to Linked… Převést na propojený… Edit Contents Upravit obsah @@ -657,7 +668,6 @@ Deselect Zrušit výběr Reselect Znovu vybrat Inverse Invertovat výběr - Inverse Selection Invertovat výběr Similar Podobné Color Range… Rozsah barev… Focus Area… Oblast zaostření… @@ -764,6 +774,7 @@ Minimum… Minimum… Offset… Posun… Plug-ins Zásuvné moduly + Plugins Zásuvné moduly Proof Setup Nastavení nátisku Proof Colors Nátiskové barvy Gamut Warning Upozornění na gamut @@ -772,12 +783,12 @@ Working Magenta Plate Pracovní purpurový výtažek Working Yellow Plate Pracovní žlutý výtažek Working Black Plate Pracovní černý výtažek - Working CMY Plate Pracovní výtažky CMY - Legacy Macintosh RGB Starší Macintosh RGB - Internet Standard RGB Internetový standard RGB + Working CMY Plates Pracovní výtažky CMY + Legacy Macintosh RGB (Gamma 1.8) Starší Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internetový standard RGB (sRGB) Monitor RGB RGB monitoru - Color Blindness — Protanopia-type Barvoslepost — protanopie - Color Blindness — Deuteranopia-type Barvoslepost — deuteranopie + Color Blindness - Protanopia-type Barvoslepost - protanopie + Color Blindness - Deuteranopia-type Barvoslepost - deuteranopie Zoom In Přiblížit Zoom Out Oddálit Fit on Screen Přizpůsobit obrazovce @@ -866,7 +877,7 @@ Consolidate All to Tabs Sloučit vše do záložek Float in Window Uvolnit do plovoucího okna Float All in Windows Uvolnit vše do plovoucích oken - New Window for Document Nové okno dokumentu + New Window Nové okno Workspace Pracovní prostor Essentials Základy Graphic and Web Grafika a web @@ -928,6 +939,12 @@ Timeline Časová osa Tool Presets Přednastavení nástrojů Tools Nástroje + Contextual Task Bar Kontextový panel úloh + Hide bar Skrýt panel + Reset bar position Obnovit polohu panelu + Pin bar position Připnout polohu panelu + Move bar Přesunout panel + More options Další možnosti About PhotoCraft O aplikaci PhotoCraft PhotoCraft Website Web PhotoCraft PhotoCraft on GitHub PhotoCraft na GitHubu @@ -1095,7 +1112,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Kliknutím přidáte bod · odstraníte tažením ven, {key}+kliknutím nebo klávesou Delete Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknutím použijete, {key}+kliknutím zrušíte přepsání, dvojklikem otevřete možnosti stylu Click to edit the gradient Kliknutím upravíte přechod - Click to restore the current colour Kliknutím obnovíte aktuální barvu + Click to restore the current color Kliknutím obnovíte aktuální barvu Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknutím přiblížíte · {key}+kliknutím oddálíte · tažením doprava/doleva přiblížíte/oddálíte Click with the Note tool to add a note. Poznámku přidáte kliknutím nástrojem poznámka. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Kliknutí: roh · tažení: hladký bod · kliknutí na první bod: uzavřít · {key} dokončit · Esc zrušit @@ -1178,7 +1195,7 @@ Darks Tmavé tóny Data Sets Datové sady Default Výchozí - Default colours (D) Výchozí barvy (D) + Default Foreground and Background Colors (D) Výchozí barvy popředí a pozadí (D) Default printer Výchozí tiskárna Default restored. Výchozí hodnota obnovena. Define Definovat @@ -1460,7 +1477,7 @@ Opacity Jitter Kolísání krytí Opacity: Krytí: Open a document to define variables. Chcete-li definovat proměnné, otevřete dokument. - Open a document to select a colour range. Chcete-li vybrat rozsah barev, otevřete dokument. + Open a document to select a color range. Chcete-li vybrat rozsah barev, otevřete dokument. Open a document to use the timeline. Chcete-li použít časovou osu, otevřete dokument. Optical Optické Optimized Optimalizovaný @@ -1536,7 +1553,6 @@ Purity Čistota Quality Kvalita Quick Actions Rychlé akce - Quick Export As PNG Rychlý export do PNG Radial Gradient Radiální přechod Radius Poloměr Radius: Poloměr: @@ -1679,7 +1695,7 @@ Subtract from Sample Odebrat ze vzorku Subtract from selection ({key}) Odebrat z výběru ({key}) Suggestions: Návrhy: - Swap colours (X) Prohodit barvy (X) + Switch Foreground and Background Colors (X) Prohodit barvy popředí a pozadí (X) Swaps height and width Prohodí výšku a šířku Switch between free transform and warp modes Přepnout mezi volnou transformací a deformací Switch theme Přepnout motiv @@ -1784,7 +1800,7 @@ Choke Stažení Classic Klasický Click to add text Kliknutím přidáte text - Click to fill similar colours Kliknutím vyplníte podobné barvy + Click to fill similar colors Kliknutím vyplníte podobné barvy Clone Stamp Tool Nástroj klonovací razítko Color Burn Ztmavit barvy Color Dodge Zesvětlit barvy @@ -1885,6 +1901,7 @@ Move Tool Nástroj přesun Could not use the move tool because the layer is locked. Nástroj Přesun nelze použít, protože vrstva je zamčená. Could not complete the Free Transform command because the layer is locked. Příkaz Volná transformace nelze dokončit, protože vrstva je zamčená. + Could not transform because the layer is locked. Transformaci nelze provést, protože vrstva je zamčená. Multiply Násobit My Brushes Moje štětce Neutrals Neutrální @@ -1943,7 +1960,7 @@ Rectangular Marquee Tool Nástroj obdélníkový výběr Reds Červené Reflected Zrcadlený - Sample: foreground colour Vzorek: barva popředí + Sample: foreground color Vzorek: barva popředí Sampled Colors Vzorkované barvy Satin Satén Save Pixel Aspect Ratio Uložit poměr stran pixelu @@ -1964,6 +1981,7 @@ Soften Změkčení Source Zdroj Source: set Zdroj: nastaven + Source: {name} Zdroj: {name} Sponge Tool Nástroj houba Spot Healing Brush Tool Nástroj bodový retušovací štětec Spread Rozprostření @@ -1981,7 +1999,7 @@ Transfer Přenos Triangle Tool Nástroj trojúhelník Unsaved changes Neuložené změny - Uses the foreground and background colours. Používá barvy popředí a pozadí. + Uses the foreground and background colors. Používá barvy popředí a pozadí. Web Web Web Large Web velký Web Minimum Web minimální @@ -2018,6 +2036,7 @@ Allow scripts to connect Povolit připojení skriptů Always Vždy Always create smart objects when placing Při umísťování vždy vytvořit chytré objekty + Skip transform when placing Při umísťování přeskočit transformaci Animated zoom Animované zvětšování Apply auto tone Použít automatický tón Ask Zeptat se @@ -2040,6 +2059,11 @@ Show canvas dots Zobrazit tečky plátna Clip thumbnails to layer bounds Oříznout miniatury na hranice vrstvy Clip Thumbnails to Layer Bounds Oříznout miniatury na hranice vrstvy + No Thumbnails Bez miniatur + Small Thumbnails Malé miniatury + Medium Thumbnails Střední miniatury + Large Thumbnails Velké miniatury + Clip Thumbnails to Document Bounds Oříznout miniatury na hranice dokumentu Canvas color Barva plátna Canvas custom color Vlastní barva plátna Color picker Výběr barvy @@ -2157,7 +2181,6 @@ Show only crosshair while painting Při malování zobrazit jen nitkový kříž Show slice numbers Zobrazit čísla řezů Show tooltips Zobrazit popisky nástrojů - Enable animations Povolit animace Show highlight on rollover Zobrazit zvýraznění při přejetí Show bounding box when dragging layer Při přetahování vrstvy zobrazit ohraničovací rámeček System title bar Systémové záhlaví okna @@ -2249,7 +2272,7 @@ Graphics Grafika Reset GPU Backend Obnovit grafický backend Applies at next launch. Projeví se při příštím spuštění. - Click to sample the foreground colour · {key}-click for background Kliknutím nasnímáte barvu popředí · {key}+kliknutím barvu pozadí + Click to sample the foreground color · {key}-click for background Kliknutím nasnímáte barvu popředí · {key}+kliknutím barvu pozadí {key}-click to set the source {key}+kliknutím nastavíte zdroj {key} to subtract {key} pro odebrání Clone source {n} ({key}-click with Clone Stamp to set) Zdroj klonování {n} (nastavíte {key}+kliknutím klonovacím razítkem) @@ -2311,7 +2334,7 @@ Gradient Options Možnosti přechodu Align with layer Zarovnat s vrstvou Reset Alignment Obnovit zarovnání - Centre the gradient (offset 0, 0) Vystředit přechod (posun 0, 0) + Center the gradient (offset 0, 0) Vystředit přechod (posun 0, 0) Gradient Editor Editor přechodů Gradient picker Výběr přechodu Save As Preset Uložit jako preset @@ -2344,7 +2367,7 @@ Alt-click to define a source point to clone from Kliknutím s klávesou Alt určete zdrojový bod pro klonování No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Není k dispozici žádná cesta, ze které by šlo vytvořit výběr: nakreslete ji nástrojem Pero nebo ji vyberte na panelu Cesty. Cancel opening Zrušit otevírání - Cancelled opening {name} Otevírání {name} zrušeno + Canceled opening {name} Otevírání {name} zrušeno Damaged Photoshop document Poškozený dokument Photoshopu The document “{name}” may be damaged. Dokument „{name}“ může být poškozený. Some layers, pixels or masks could not be read. Do you want to continue opening it? Některé vrstvy, pixely nebo masky nebylo možné načíst. Chcete pokračovat v otevírání? @@ -2353,7 +2376,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ má vložený barevný profil, který neodpovídá aktuálnímu pracovnímu prostoru barev {mode}. Working: {working} Pracovní: {working} Developing {name} Vyvolávání {name} - Cancelled {label} Zrušeno: {label} + Canceled {label} Zrušeno: {label} Esc to cancel Esc pro zrušení Opened {name} Otevřeno: {name} Opening {name} Otevírání {name} @@ -2361,7 +2384,7 @@ Saving {name} Ukládání {name} Saving {percent} Ukládání {percent} A save to {name} is still running; try again when it finishes Ukládání do {name} stále probíhá; zkuste to znovu, až skončí - {label} is writing the file and can no longer be cancelled {label} zapisuje soubor a už ho nelze zrušit + {label} is writing the file and can no longer be canceled {label} zapisuje soubor a už ho nelze zrušit Opening {name}… Otevírání {name}… Opening… Otevírání… Reading… Načítání… @@ -2445,7 +2468,7 @@ cameraRaw Color Barvy Discard Layers and Save a Copy Zahodit vrstvy a uložit kopii Don't show again Příště nezobrazovat Save failed: {error} Uložení se nezdařilo: {error} - Save cancelled Ukládání zrušeno + Save canceled Ukládání zrušeno Couldn't place {name}: {error} Nelze umístit {name}: {error} Magnetic Lasso Tool Nástroj magnetické laso Frequency Frekvence @@ -2578,7 +2601,7 @@ fontWeight Italic Kurzíva Rotate View Otočit zobrazení Rotate View Tool Nástroj otočení zobrazení Reset View Obnovit zobrazení - Drag around the centre to rotate the view · Shift constrains to 15° Tažením kolem středu otočíte zobrazení · Shift omezí na 15° + Drag around the center to rotate the view · Shift constrains to 15° Tažením kolem středu otočíte zobrazení · Shift omezí na 15° Dual Axis Dvojitá osa Diagonal Diagonální Transform Symmetry Transformovat symetrii @@ -2593,8 +2616,8 @@ fontWeight Italic Kurzíva All Layers No Adjustments Všechny vrstvy bez korekcí Current & Below No Adjustments Aktuální a pod ní bez korekcí Show Sampling Ring Zobrazit vzorkovací kroužek - Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu - Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu + Out of gamut for printing: click for the nearest printable color Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu + Not a web color: click for the nearest web color Není webová barva: klepnutím zvolíte nejbližší webovou barvu Color to Alpha… Barva na alfu… Transparency Threshold Práh průhlednosti Opacity Threshold Práh krytí @@ -2688,7 +2711,7 @@ fontWeight Italic Kurzíva How often fastening points are placed by themselves Jak často se kotevní body umisťují samy Scrubby Zoom Plynulá lupa {mode} Color · {bits} bit Barva {mode} · {bits} bitů - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Tažením nakreslete ořez · tažením uvnitř posunete · okraje mění velikost ({ratio} poměr, {centre} střed) · Mezerník posouvá při kreslení · šipky posunují · X prohodí orientaci · {commit} potvrdí · Esc zruší + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Tažením nakreslete ořez · tažením uvnitř posunete · okraje mění velikost ({ratio} poměr, {centre} střed) · Mezerník posouvá při kreslení · šipky posunují · X prohodí orientaci · {commit} potvrdí · Esc zruší Appearance Settings… Nastavení vzhledu… Add to favorites Přidat do oblíbených Remove from favorites Odebrat z oblíbených @@ -3224,6 +3247,7 @@ cameraData Lens Objektiv Magnetic Lasso Magnetické laso Make Výrobce Make Selection Vytvořit výběr + Make Shape Vytvořit tvar Make Work Path Vytvořit pracovní cestu Manual Ručně Map Document Dokument mapy @@ -3899,14 +3923,19 @@ navigator Zoom Zvětšení Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí Background (Default) Pozadí (výchozí) + Background (default) Pozadí (výchozí) + Transparent (default) Průhledné (výchozí) Include All Subfolders Zahrnout všechny podsložky Auto Add/Delete Automaticky přidat/odstranit Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nezapínejte současně zrcadlení v nastavení tisku: obě převrácení se navzájem zruší. Print Settings… Nastavení tisku… - Remember to turn off the printer's own colour management in Print Settings… Nezapomeňte v nastavení tisku vypnout vlastní správu barev tiskárny… + Remember to turn off the printer's own color management in Print Settings… Nezapomeňte v nastavení tisku vypnout vlastní správu barev tiskárny… Sent to {printer} Odesláno do {printer} set in Print Settings… nastavuje se v nastavení tisku… ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů + Automatically Create First Snapshot Automaticky vytvořit první snímek + Automatically Create New Snapshot When Saving Automaticky vytvořit nový snímek při ukládání + Show New Snapshot Dialog by Default Ve výchozím nastavení zobrazit dialog Nový snímek Memory usage percent Využití paměti (%) Show Favorite Fonts Zobrazit oblíbená písma No favorite fonts yet: click the star beside a font to add it. Zatím žádná oblíbená písma: přidejte písmo kliknutím na hvězdičku vedle něj. @@ -3951,6 +3980,7 @@ navigator Zoom Zvětšení Automatically Check for Updates Automaticky kontrolovat aktualizace Paints from the History panel's source state Maluje ze zdrojového stavu panelu Historie Sets the source for the history brush Nastaví zdroj pro štětec historie + The History Brush source was deleted: set a new source in the History panel Zdroj štětce historie byl smazán: nastavte nový zdroj v panelu Historie Highlight Mode Režim světel Highlight Color Barva světel Highlight Opacity Krytí světel @@ -4013,6 +4043,7 @@ navigator Zoom Zvětšení {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění) Reference point location Umístění referenčního bodu + Toggle reference point Zobrazit/skrýt referenční bod Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí. The transform couldn't be applied. Transformaci nelze použít. Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}. @@ -4033,12 +4064,12 @@ navigator Zoom Zvětšení Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce Demosaic Demozaikování - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Jak se doplňuje mozaika barevného filtru fotoaparátu. Použije se při otevření souboru; náhled ukazuje metodu, se kterou byl otevřen. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Jak se doplňuje mozaika barevného filtru fotoaparátu. Použije se při otevření souboru; náhled ukazuje metodu, se kterou byl otevřen. AHD (adaptive homogeneity) AHD (adaptivní homogenita) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: nejčistší hrany a nejméně barevných lemů, nejpomalejší. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolace s korekcí gradientu: ostrá a rychlá, s mírnými barevnými lemy v jemných detailech. - The fastest and the softest, with colour fringing on fine detail. Nejrychlejší a nejměkčí, s barevnými lemy v jemných detailech. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: nejčistší hrany a nejméně barevných lemů, nejpomalejší. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolace s korekcí gradientu: ostrá a rychlá, s mírnými barevnými lemy v jemných detailech. + The fastest and the softest, with color fringing on fine detail. Nejrychlejší a nejměkčí, s barevnými lemy v jemných detailech. “{name}” already exists. Replace it? „{name}“ již existuje. Nahradit? The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“. Export As: {name} Exportovat jako: {name} @@ -4242,6 +4273,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Předvolby velikosti písma Leading presets Předvolby prokladu Tracking presets Předvolby prostrkání + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neplatná číselná hodnota. Je vyžadováno celé číslo mezi {min}% a {max}%. Byla vložena předchozí hodnota. Color Aware Podle barev Decontaminate Colors Dekontaminovat barvy @@ -4266,3 +4299,72 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Barva zarážky Shapes can't use patterns yet Tvary zatím nemohou používat vzorky Edit Brush… Upravit štětec… + +# Action Stop message (#8386) + Message Zpráva +# Playback Options (#5212) + Playback Options… Možnosti přehrávání… + Playback Options Možnosti přehrávání + Accelerated Zrychlené + Step by Step Krok za krokem + Pause For: Pozastavit na: + Pause For Pozastavit na + seconds sekund + Create a single history state Vytvořit jeden stav historie + Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu + Generator enabled Generátor zapnut + This Layer: Tato vrstva: + Move Up Přesunout nahoru + Move Down Přesunout dolů + On your computer V tomto počítači + New file Nový soubor + Learn Učit se + No recent files Žádné nedávné soubory + Website Web + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Rámeček podporuje afinní transformace; pro perspektivní transformace vyberte jeho obrázek + A frame supports affine transforms; select its image to warp it Rámeček podporuje afinní transformace; pro pokřivení vyberte jeho obrázek + Convert to Frame… Převést na rámeček… + Create a new custom frame Vytvořit nový vlastní rámeček + Create a new polygonal frame Vytvořit nový mnohoúhelníkový rámeček + Create a new triangular frame Vytvořit nový trojúhelníkový rámeček + Fit Content to Frame Přizpůsobit obsah rámečku + Fit Frame to Content Přizpůsobit rámeček obsahu + Frame coordinates must be finite and within 600000 pixels Souřadnice rámečku musí být konečné a v rozsahu 600000 pixelů + Import Image… Importovat obrázek… + Invalid frame transform Neplatná transformace rámečku + Invalid nested file drop Neplatné vnořené přetažení souboru + New Frame Nový rámeček + Replace Image… Nahradit obrázek… + The frame is locked Rámeček je zamčený + The frame is unavailable Rámeček není dostupný + +# Channels panel menu (#11451) + Channel Options… Možnosti kanálu… + Specify Channels: Určit kanály: + Not enough grayscale documents for this mode. Pro tento režim není dost dokumentů ve stupních šedi. + Pick a different document for each channel. Pro každý kanál vyberte jiný dokument. + A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený. + A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti. +# Move tool options bar (#11381) + Align top edges Zarovnat horní okraje + Align vertical centers Zarovnat svislé středy + Align bottom edges Zarovnat dolní okraje + Distribute vertically Rozmístit svisle + Distribute horizontally Rozmístit vodorovně + Show layer bounds on hover Zobrazit hranice vrstvy při najetí + Expand layer group on click Rozbalit skupinu vrstev kliknutím + Show hover bounds from Layers Zobrazit hranice při najetí z panelu Vrstvy + Saved Camera Raw settings to {file} Nastavení Camera Raw uložena do {file} + Save the settings to each raw's sidecar without opening it Uložit nastavení do souboru sidecar každého snímku raw bez jeho otevření + No settings saved: these raws can't keep a sidecar here Nic neuloženo: tyto snímky raw zde nemohou mít soubor sidecar + Some settings could not be saved; the raws stay open Některá nastavení nelze uložit; snímky raw zůstávají otevřené + Nothing to save: these raws are at their default settings Není co ukládat: tyto snímky raw mají výchozí nastavení +# Artboard tool options bar (#11385) + Distribute Widths Rozmístit šířky + Distribute Heights Rozmístit výšky + Auto-nest Layers Automaticky vnořovat vrstvy + Auto-size Canvas Automaticky měnit velikost plátna + Keep Relative Position During Layer Reordering Zachovat relativní polohu při změně pořadí vrstev + Shrinkwrap Canvas on Save Při uložení zmenšit plátno na obsah diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 46e71ed2cf..cf9105b865 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -86,6 +86,7 @@ Right Højre Open Recent Åbn seneste Clear Recent Files Ryd seneste filer + Clear Recent File List Ryd seneste filer Remove from Recent Fjern fra seneste Remove {name} from Recent Fjern {name} fra seneste Close Luk @@ -94,6 +95,10 @@ Restore Gendan Close All Luk alle Close Others Luk andre + New Document… Nyt dokument… + Open Document… Åbn… + Consolidate All to Here Saml alle her + Move to New Window Flyt til nyt vindue Save Gem Save As… Gem som… Save a Copy… Gem en kopi… @@ -106,7 +111,6 @@ Export As… Eksportér som… Export Eksportér Export… Eksportér… - Quick Export As PNG Hurtig eksport som PNG Quick Export as PNG Hurtig eksport som PNG Place Embedded… Placér indlejret… Place Linked… Placér sammenkædet… @@ -310,7 +314,6 @@ All Alle Deselect Fjern markering Reselect Markér igen - Inverse Selection Invertér markering Inverse Invertér Color Range… Farveområde… Select and Mask… Markér og maskér… @@ -508,7 +511,6 @@ Match Location Afstem placering Match Rotation Afstem rotation Match All Afstem alle - New Window for Document Nyt vindue til dokument Workspace Arbejdsområde Extensions Udvidelser Panels Paneler @@ -538,6 +540,12 @@ Timeline Tidslinje Tool Presets Værktøjsforudindstillinger Tools Værktøjer + Contextual Task Bar Kontekstafhængig opgavelinje + Hide bar Skjul linjen + Reset bar position Nulstil linjens placering + Pin bar position Fastgør linjens placering + Move bar Flyt linjen + More options Flere indstillinger Libraries Biblioteker 3D 3D Toolbars Værktøjslinjer @@ -596,8 +604,8 @@ Background Baggrund Set foreground color Angiv forgrundsfarve Set background color Angiv baggrundsfarve - Swap colours (X) Ombyt farver (X) - Default colours (D) Standardfarver (D) + Switch Foreground and Background Colors (X) Byt om på forgrunds- og baggrundsfarver (X) + Default Foreground and Background Colors (D) Standard forgrunds- og baggrundsfarver (D) Brush Tool Options Penselværktøjsindstillinger Opacity Opacitet Flow Gennemstrømning @@ -686,6 +694,7 @@ Units & Rulers… Enheder og linealer… Guides, Grid & Slices… Hjælpelinjer, gitter og udsnit… Plug-ins… Plug-ins… + Plugins… Plug-ins… Technology Previews Teknologieksempler Scratch Disks… Arbejdsdiske… Export Preferences… Eksportpræferencer… @@ -920,8 +929,8 @@ ACES ACES Industry Standard Industristandard Web Web - Internet Standard RGB Internetstandard-RGB - Legacy Macintosh RGB Ældre Macintosh RGB + Internet Standard RGB (sRGB) Internetstandard-RGB (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Ældre Macintosh RGB (Gamma 1.8) Monitor RGB Skærm-RGB Windows LCD Windows LCD Coated CMYK Bestrøget CMYK @@ -1209,6 +1218,7 @@ Path to Illustrator… Sti til Illustrator… Make Work Path Opret arbejdssti Make Selection Opret markering + Make Shape Opret form Fill Path Udfyld sti Stroke Path Lav kontur på sti Clipping Path Udklipningssti @@ -1602,7 +1612,7 @@ Revert “{name}” to the last saved version? Your changes will be lost. Gendan »{name}« til den sidst gemte version? Dine ændringer går tabt. {key} to subtract {key} for at trække fra ↑↓ navigate {key} run esc close ↑↓ navigér {key} kør esc luk - Click to restore the current colour Klik for at gendanne den aktuelle farve + Click to restore the current color Klik for at gendanne den aktuelle farve Click with the Note tool to add a note. Klik med noteværktøjet for at tilføje en note. Drag a rectangle around the object Træk et rektangel omkring objektet Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Træk på billedet for at tilføje en begrænsning; Shift for vandret/lodret; højreklik på en begrænsning for at ændre eller slette den. @@ -1626,6 +1636,7 @@ Move Tool Flytteværktøj Could not use the move tool because the layer is locked. Kunne ikke bruge flytteværktøjet, fordi laget er låst. Could not complete the Free Transform command because the layer is locked. Kunne ikke fuldføre kommandoen Fri transformering, fordi laget er låst. + Could not transform because the layer is locked. Kunne ikke transformere, fordi laget er låst. Rectangular Marquee Tool Rektangulært markeringsværktøj Elliptical Marquee Tool Elliptisk markeringsværktøj Single Row Marquee Tool Markeringsværktøj til enkelt række @@ -1683,10 +1694,10 @@ Drag to move the active layer Træk for at flytte det aktive lag Drag to pan · hold Space with any tool Træk for at panorere · hold mellemrumstasten nede med et hvilket som helst værktøj Drag to draw a gradient Træk for at tegne en gradient - Click to fill similar colours Klik for at udfylde lignende farver + Click to fill similar colors Klik for at udfylde lignende farver Click to add text Klik for at tilføje tekst Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Træk (lasso) eller klik på punkter (polygonalt) · {add} tilføj · {sub} træk fra - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Træk en beskæringsramme · træk inde i den for at flytte · kanter ændrer størrelse ({ratio}-forhold, {centre} centreret) · Mellemrumstasten flytter, mens du tegner · piletaster skubber · X bytter orientering · {commit} bekræfter · Esc annullerer + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Træk en beskæringsramme · træk inde i den for at flytte · kanter ændrer størrelse ({ratio}-forhold, {centre} centreret) · Mellemrumstasten flytter, mens du tegner · piletaster skubber · X bytter orientering · {commit} bekræfter · Esc annullerer {mode} Color · {bits} bit {mode}-farve · {bits} bit Drag to move the layer Træk for at flytte laget Fit Screen Tilpas til skærm @@ -1718,7 +1729,7 @@ Appearance Udseende Text Position: Tekstplacering: Text color Tekstfarve - Uses the foreground and background colours. Bruger forgrundsfarven og baggrundsfarven. + Uses the foreground and background colors. Bruger forgrundsfarven og baggrundsfarven. Answer the unsaved-changes prompt first Besvar først dialogen om ikke-gemte ændringer Unsaved changes Ugemte ændringer Don't Save Gem ikke @@ -1738,10 +1749,11 @@ name navn new ny Go to Layer Gå til lag - Open a document to select a colour range. Åbn et dokument for at markere et farveområde. + Open a document to select a color range. Åbn et dokument for at markere et farveområde. Saved Gemt Reveal in Finder Vis i Finder Show in Explorer Vis i Stifinder + Reveal in Explorer Vis i Stifinder Show in Folder Vis i mappe Before (Y) Før (Y) Save Settings… Gem indstillinger… @@ -1915,12 +1927,14 @@ Character Styles Panel Tegnstilpanel Clear Canvas Guides Ryd lærredshjælpelinjer Clear Selected Artboard Guides Ryd hjælpelinjer på markeret tavle - Color Blindness — Deuteranopia-type Farveblindhed — deuteranopi-type - Color Blindness — Protanopia-type Farveblindhed — protanopi-type + Color Blindness - Deuteranopia-type Farveblindhed — deuteranopi-type + Color Blindness - Protanopia-type Farveblindhed — protanopi-type Color Table… Farvetabel… Combine Shapes Kombinér former Contract… Formindsk… Convert to Embedded Konvertér til indlejret + Embed Linked Indlejr sammenkædet + Embed All Linked Indlejr alle sammenkædede Convert to Layers Konvertér til lag Convert to Linked… Konvertér til sammenkædet… Count Optælling @@ -2008,6 +2022,7 @@ Pixelate Pixelér Place Scale Marker… Placér skalamarkør… Plug-ins Plug-ins + Plugins Plug-ins Print One Copy Udskriv én kopi Print… Udskriv… Range Spændvidde @@ -2052,7 +2067,7 @@ Vertical Centers Lodrette midtpunkter Video Frames to Layers… Videobilleder til lag… WIA Support… WIA-support… - Working CMY Plate Arbejds-CMY-plade + Working CMY Plates Arbejds-CMY-plader # tl! literals (third pass) Absolute Absolut @@ -2087,7 +2102,7 @@ Canvas Lærred Capture Optag Center Crop Marks Centrerede beskæringsmærker - Centre the gradient (offset 0, 0) Centrér gradienten (forskydning 0, 0) + Center the gradient (offset 0, 0) Centrér gradienten (forskydning 0, 0) Change Screen Mode (F) Skift skærmtilstand (F) Change To: Ændr til: Clear All Ryd alt @@ -2239,7 +2254,7 @@ Roughness Ruhed Roundness Rundhed Rulers & Grids Linealer og gitter - Sample: foreground colour Prøve: forgrundsfarve + Sample: foreground color Prøve: forgrundsfarve Sampling: Background Swatch Prøvetagning: baggrundsfarveprøve Sampling: Continuous Prøvetagning: fortløbende Sampling: Once Prøvetagning: én gang @@ -2338,6 +2353,11 @@ Show canvas dots Vis lærredsprikker Clip thumbnails to layer bounds Klip miniaturer til laggrænser Clip Thumbnails to Layer Bounds Klip miniaturer til laggrænser + No Thumbnails Ingen miniaturer + Small Thumbnails Små miniaturer + Medium Thumbnails Mellemstore miniaturer + Large Thumbnails Store miniaturer + Clip Thumbnails to Document Bounds Klip miniaturer til dokumentgrænser Canvas color Lærredsfarve Canvas custom color Brugerdefineret lærredsfarve Classic Klassisk @@ -2464,7 +2484,6 @@ Show only crosshair while painting Vis kun trådkors under maling Show slice numbers Vis udsnitsnumre Show tooltips Vis værktøjstips - Enable animations Aktivér animationer Show highlight on rollover Fremhæv ved pegning Show bounding box when dragging layer Vis afgrænsningsramme, når et lag trækkes System title bar Systemtitellinje @@ -2508,7 +2527,7 @@ Alt-click to define a source point to clone from Alt-klik for at definere et kildepunkt at klone fra No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Ingen sti at lave en markering ud fra: tegn én med penværktøjet, eller markér én i panelet Stier. Cancel opening Annullér åbning - Cancelled opening {name} Annullerede åbning af {name} + Canceled opening {name} Annullerede åbning af {name} Damaged Photoshop document Beskadiget Photoshop-dokument The document “{name}” may be damaged. Dokumentet »{name}« er muligvis beskadiget. Some layers, pixels or masks could not be read. Do you want to continue opening it? Nogle lag, pixels eller masker kunne ikke læses. Vil du fortsætte med at åbne det? @@ -2518,7 +2537,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumentet »{name}« har en indlejret farveprofil, der ikke stemmer overens med det aktuelle {mode}-arbejdsfarverum. Working: {working} Arbejdsfarverum: {working} Developing {name} Fremkalder {name} - Cancelled {label} Annullerede {label} + Canceled {label} Annullerede {label} Esc to cancel Esc for at annullere Opened {name} Åbnede {name} Opening {name} Åbner {name} @@ -2526,7 +2545,7 @@ Saving {name} Gemmer {name} Saving {percent} Gemmer {percent} A save to {name} is still running; try again when it finishes Der gemmes stadig til {name}; prøv igen, når det er afsluttet - {label} is writing the file and can no longer be cancelled {label} skriver filen og kan ikke længere annulleres + {label} is writing the file and can no longer be canceled {label} skriver filen og kan ikke længere annulleres Opening {name}… Åbner {name}… Opening… Åbner… Reading… Læser… @@ -2623,7 +2642,7 @@ cameraRaw Roughness Ruhed Discard Layers and Save a Copy Kassér lag, og gem en kopi Don't show again Vis ikke igen Save failed: {error} Kunne ikke gemme: {error} - Save cancelled Gemning annulleret + Save canceled Gemning annulleret Couldn't place {name}: {error} Kunne ikke placere {name}: {error} Magnetic Lasso Tool Magnetisk lassoværktøj Frequency Frekvens @@ -2767,7 +2786,7 @@ fontWeight Italic Kursiv Impressionist Impressionistisk Rotate View Tool Værktøj til at rotere visningen Reset View Nulstil visning - Drag around the centre to rotate the view · Shift constrains to 15° Træk rundt om midtpunktet for at rotere visningen · Shift låser til 15° + Drag around the center to rotate the view · Shift constrains to 15° Træk rundt om midtpunktet for at rotere visningen · Shift låser til 15° # Painting symmetry presets. Dual Axis Dobbelt akse @@ -2787,11 +2806,11 @@ fontWeight Italic Kursiv All Layers No Adjustments Alle lag uden justeringer Current & Below No Adjustments Aktuelt og nedenunder uden justeringer Show Sampling Ring Vis prøvetagningsring - Click to sample the foreground colour · {key}-click for background Klik for at tage prøve af forgrundsfarven · {key}-klik for baggrundsfarven + Click to sample the foreground color · {key}-click for background Klik for at tage prøve af forgrundsfarven · {key}-klik for baggrundsfarven # Color Picker warnings - Out of gamut for printing: click for the nearest printable colour Uden for gamut ved udskrivning: klik for at få den nærmeste udskrivbare farve - Not a web colour: click for the nearest web colour Ikke en webfarve: klik for at få den nærmeste webfarve + Out of gamut for printing: click for the nearest printable color Uden for gamut ved udskrivning: klik for at få den nærmeste udskrivbare farve + Not a web color: click for the nearest web color Ikke en webfarve: klik for at få den nærmeste webfarve Color to Alpha… Farve til alfa… Transparency Threshold Grænseværdi for gennemsigtighed Opacity Threshold Grænseværdi for opacitet @@ -3199,6 +3218,8 @@ curves Smooth Udjævn Step {step} is waiting for its dialog Trin {step} venter på sin dialog @plural {n} action|{n} actions {n} handling|{n} handlinger Background (Default) Baggrund (standard) + Background (default) Baggrund (standard) + Transparent (default) Gennemsigtig (standard) Content-Aware Fill Indholdsbevidst udfyldning # Batch / Image Processor Include All Subfolders Medtag alle undermapper @@ -3207,7 +3228,7 @@ curves Smooth Udjævn # Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Kryds ikke også Spejlbillede af i Udskriftsindstillinger…: de to spejlvendinger ophæver hinanden. Print Settings… Udskriftsindstillinger… - Remember to turn off the printer's own colour management in Print Settings… Husk at slå printerens egen farvestyring fra i Udskriftsindstillinger… + Remember to turn off the printer's own color management in Print Settings… Husk at slå printerens egen farvestyring fra i Udskriftsindstillinger… Sent to {printer} Sendt til {printer} set in Print Settings… angivet i Udskriftsindstillinger… ICO is limited to 256 × 256 pixels ICO er begrænset til 256 × 256 pixels @@ -3871,12 +3892,12 @@ curves Smooth Udjævn In White Input-hvid Increase Brush Hardness Forøg penselhårdheden Demosaic Demosaicering - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hvordan kameraets farvefiltermosaik udfyldes. Det anvendes, når filen åbnes; forhåndsvisningen viser den metode, den blev åbnet med. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hvordan kameraets farvefiltermosaik udfyldes. Det anvendes, når filen åbnes; forhåndsvisningen viser den metode, den blev åbnet med. AHD (adaptive homogeneity) AHD (adaptiv homogenitet) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de reneste kanter og de mindste farvefrynser, men den langsomste. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigeret interpolation: skarp og hurtig, med nogle farvefrynser i fine detaljer. - The fastest and the softest, with colour fringing on fine detail. Den hurtigste og den blødeste, med farvefrynser i fine detaljer. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: de reneste kanter og de mindste farvefrynser, men den langsomste. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradientkorrigeret interpolation: skarp og hurtig, med nogle farvefrynser i fine detaljer. + The fastest and the softest, with color fringing on fine detail. Den hurtigste og den blødeste, med farvefrynser i fine detaljer. “{name}” already exists. Replace it? »{name}« findes allerede. Vil du erstatte den? The name in the save dialog was “{name}”. Navnet i dialogen Gem var »{name}«. Move Up Flyt op @@ -4527,7 +4548,6 @@ curves Smooth Udjævn Use File › Open to open an image. Brug Filer › Åbn for at åbne et billede. Use Selection In Source Brug markering i kilde Use Selection In Target Brug markering i mål - Uses the foreground and background colors. Bruger forgrunds- og baggrundsfarverne. Vanishing Point Forsvindingspunkt Variable Data Sets Variable datasæt Vector Mask Hides Effects Vektormaske skjuler effekter @@ -4737,8 +4757,8 @@ cameraRaw Camera Raw ({name}) Camera Raw ({name}) cameraRaw Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) cameraRaw Camera Raw isn't open Camera Raw er ikke åben cameraRaw Cancel Annullér -cameraRaw Cancelled opening {name} Annullerede åbning af {name} -cameraRaw Cancelled {label} Annullerede {label} +cameraRaw Canceled opening {name} Annullerede åbning af {name} +cameraRaw Canceled {label} Annullerede {label} cameraRaw Color Priority Farveprioritet cameraRaw Developing {name} Fremkalder {name} cameraRaw Full-resolution preview unavailable Forhåndsvisning i fuld opløsning er ikke tilgængelig @@ -4773,3 +4793,39 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet The path was discarded Stien blev kasseret The path was kept as the Work Path Stien blev bevaret som arbejdssti Edit Brush… Rediger pensel… + +# Action Stop message (#8386) + Message Meddelelse +# Playback Options (#5212) + Playback Options… Afspilningsindstillinger… + Playback Options Afspilningsindstillinger + Accelerated Accelereret + Step by Step Trin for trin + Pause For: Pause i: + Pause For Pause i + seconds sekunder + Create a single history state Opret én historiktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalindstillinger… + Specify Channels: Angiv kanaler: + Not enough grayscale documents for this mode. Der er ikke nok dokumenter i gråtoner til denne tilstand. + Pick a different document for each channel. Vælg et andet dokument til hver kanal. + A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent. + A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse. +# Move tool options bar (#11381) + Align top edges Juster øverste kanter + Align vertical centers Juster lodrette midter + Align bottom edges Juster nederste kanter + Distribute vertically Fordel lodret + Distribute horizontally Fordel vandret + Show layer bounds on hover Vis lagets grænser ved svæv + Expand layer group on click Udvid lagergruppe ved klik + Show hover bounds from Layers Vis svævegrænser fra Lag +# Artboard tool options bar (#11385) + Distribute Widths Fordel bredder + Distribute Heights Fordel højder + Auto-nest Layers Indlejr lag automatisk + Auto-size Canvas Tilpas lærredsstørrelse automatisk + Keep Relative Position During Layer Reordering Bevar relativ placering ved omorganisering af lag + Shrinkwrap Canvas on Save Beskær lærredet til indholdet ved lagring diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 45fb326bea..6a970a2c7a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -173,6 +173,7 @@ Right Rechts Open Recent Zuletzt geöffnete Dateien Clear Recent Files Zuletzt geöffnete Dateien leeren + Clear Recent File List Zuletzt geöffnete Dateien leeren Remove from Recent Aus zuletzt geöffneten Dateien entfernen Remove {name} from Recent {name} aus zuletzt geöffneten Dateien entfernen Close Schließen @@ -181,6 +182,10 @@ Restore Wiederherstellen Close All Alle schließen Close Others Andere schließen + New Document… Neues Dokument… + Open Document… Öffnen… + Consolidate All to Here Alle hier zusammenfassen + Move to New Window In neues Fenster verschieben Save Speichern Save As… Speichern unter… Save a Copy… Kopie speichern… @@ -233,6 +238,7 @@ Create Smart Object after Loading Layers Nach dem Laden der Ebenen Smart-Objekt erstellen Reveal in Finder Im Finder anzeigen Show in Explorer Im Explorer anzeigen + Reveal in Explorer Im Explorer anzeigen Show in Folder Im Ordner anzeigen File Info… Dateiinformationen… Print… Drucken… @@ -321,6 +327,7 @@ Units & Rulers… Einheiten & Lineale… Guides, Grid & Slices… Hilfslinien, Raster & Slices… Plug-ins… Plug-ins… + Plugins… Plug-ins… Type… Text… Enhanced Controls… Erweiterte Bedienelemente… Camera Raw… Camera Raw… @@ -374,6 +381,8 @@ Match Color… Farbabgleich… Replace Color… Farbe ersetzen… Equalize Angleichen + Equalize Selected Area Only Nur ausgewählten Bereich angleichen + Equalize Entire Image Based On Selected Area Gesamtes Bild anhand des ausgewählten Bereichs angleichen Auto Tone Automatische Tonwertkorrektur Auto Contrast Automatischer Kontrast Auto Color Automatische Farbe @@ -478,6 +487,8 @@ Smart Object Smart-Objekt Convert to Smart Object In Smart-Objekt konvertieren Convert to Embedded In eingebettetes Smart-Objekt konvertieren + Embed Linked Verknüpftes einbetten + Embed All Linked Alle verknüpften einbetten Convert to Layers In Ebenen konvertieren Convert to Linked… In verknüpftes Smart-Objekt konvertieren… Edit Contents Inhalt bearbeiten @@ -551,7 +562,9 @@ Unite Shapes Formen vereinigen Subtract Front Shape Vordere Form subtrahieren Intersect Shape Areas Schnittmenge der Formen + Unite Shapes at Overlap Schnittmenge der Formen Exclude Overlapping Shapes Überlappende Formen ausschließen + Subtract Shapes at Overlap Überlappende Formen ausschließen Merge Shape Components Formkomponenten zusammenfügen Path operations Pfadvorgänge Align Ausrichten @@ -628,7 +641,6 @@ Deselect Auswahl aufheben Reselect Erneut auswählen Inverse Umkehren - Inverse Selection Auswahl umkehren Similar Ähnliche Color Range… Farbbereich… Focus Area… Schärfebereich… @@ -735,6 +747,7 @@ Minimum… Minimum… Offset… Versatz… Plug-ins Plug-ins + Plugins Plug-ins Proof Setup Proof-Setup Proof Colors Proof-Farben Gamut Warning Farbumfang-Warnung @@ -743,12 +756,12 @@ Working Magenta Plate Arbeits-Magenta-Druckplatte Working Yellow Plate Arbeits-Gelb-Druckplatte Working Black Plate Arbeits-Schwarz-Druckplatte - Working CMY Plate Arbeits-CMY-Druckplatte - Legacy Macintosh RGB Legacy-Macintosh-RGB - Internet Standard RGB Internet-Standard-RGB + Working CMY Plates Arbeits-CMY-Druckplatten + Legacy Macintosh RGB (Gamma 1.8) Legacy-Macintosh-RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internet-Standard-RGB (sRGB) Monitor RGB Monitor-RGB - Color Blindness — Protanopia-type Farbenblindheit — Protanopie-Typ - Color Blindness — Deuteranopia-type Farbenblindheit — Deuteranopie-Typ + Color Blindness - Protanopia-type Farbenblindheit - Protanopie-Typ + Color Blindness - Deuteranopia-type Farbenblindheit - Deuteranopie-Typ Zoom In Vergrößern Zoom Out Verkleinern Fit on Screen Auf den Bildschirm einpassen @@ -827,7 +840,7 @@ Consolidate All to Tabs Alle in Registerkarten zusammenfassen Float in Window In Fenster schweben lassen Float All in Windows Alle in Fenstern schweben lassen - New Window for Document Neues Fenster für Dokument + New Window Neues Fenster Workspace Arbeitsbereich Essentials Essentials Graphic and Web Grafik und Web @@ -889,6 +902,12 @@ Timeline Zeitleiste Tool Presets Werkzeugvorgaben Tools Werkzeuge + Contextual Task Bar Kontextbezogene Taskleiste + Hide bar Leiste ausblenden + Reset bar position Leistenposition zurücksetzen + Pin bar position Leistenposition fixieren + Move bar Leiste verschieben + More options Weitere Optionen About PhotoCraft Über PhotoCraft PhotoCraft Website PhotoCraft-Website PhotoCraft on GitHub PhotoCraft auf GitHub @@ -1053,7 +1072,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Klicken fügt einen Punkt hinzu · Wegziehen, {key}-Klick oder Entfernen löscht ihn Click to apply, {key}-click to clear overrides, double-click for Style Options Klicken wendet an, {key}-Klick löscht Überschreibungen, Doppelklick öffnet die Stiloptionen Click to edit the gradient Klicke, um den Verlauf zu bearbeiten - Click to restore the current colour Klicke, um die aktuelle Farbe wiederherzustellen + Click to restore the current color Klicke, um die aktuelle Farbe wiederherzustellen Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klicken vergrößert · {key}-Klick verkleinert · nach rechts/links ziehen zoomt rein/raus Click with the Note tool to add a note. Klicke mit dem Notiz-Werkzeug, um eine Notiz hinzuzufügen. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klick: Ecke · Ziehen: weich · Ersten Punkt klicken: schließen · {key} fertig · Esc abbrechen @@ -1139,7 +1158,7 @@ Darks Dunkle Data Sets Datensätze Default Standard - Default colours (D) Standardfarben (D) + Default Foreground and Background Colors (D) Standardfarben für Vorder- und Hintergrund (D) Default printer Standarddrucker Default restored. Standard wiederhergestellt. Define Definieren @@ -1422,7 +1441,7 @@ Opacity Jitter Deckkraftschwankung Opacity: Deckkraft: Open a document to define variables. Öffne ein Dokument, um Variablen zu definieren. - Open a document to select a colour range. Öffne ein Dokument, um einen Farbbereich auszuwählen. + Open a document to select a color range. Öffne ein Dokument, um einen Farbbereich auszuwählen. Open a document to use the timeline. Öffne ein Dokument, um die Zeitleiste zu nutzen. Optical Optisch Optimized Optimiert @@ -1498,7 +1517,6 @@ Purity Reinheit Quality Qualität Quick Actions Schnellaktionen - Quick Export As PNG Schnellexport als PNG Radial Gradient Radialer Verlauf Radius Radius Radius: Radius: @@ -1640,7 +1658,7 @@ Subtract from Sample Von der Probe abziehen Subtract from selection ({key}) Von Auswahl abziehen ({key}) Suggestions: Vorschläge: - Swap colours (X) Farben tauschen (X) + Switch Foreground and Background Colors (X) Vorder- und Hintergrundfarbe vertauschen (X) Swaps height and width Tauscht Höhe und Breite Switch between free transform and warp modes Zwischen freier Transformation und Verkrümmen wechseln Switch theme Design wechseln @@ -1743,7 +1761,7 @@ Choke Drosselung Classic Klassisch Click to add text Klicken, um Text hinzuzufügen - Click to fill similar colours Klicken, um ähnliche Farben zu füllen + Click to fill similar colors Klicken, um ähnliche Farben zu füllen Clone Stamp Tool Kopierstempel-Werkzeug Color Burn Farbig nachbelichten Color Dodge Farbig abwedeln @@ -1844,6 +1862,7 @@ Move Tool Verschieben-Werkzeug Could not use the move tool because the layer is locked. Das Verschieben-Werkzeug konnte nicht verwendet werden, weil die Ebene gesperrt ist. Could not complete the Free Transform command because the layer is locked. Der Befehl „Frei transformieren“ konnte nicht ausgeführt werden, weil die Ebene gesperrt ist. + Could not transform because the layer is locked. Transformieren nicht möglich, weil die Ebene gesperrt ist. Multiply Multiplizieren My Brushes Meine Pinsel Neutrals Neutrale @@ -1902,7 +1921,7 @@ Rectangular Marquee Tool Rechteckauswahl-Werkzeug Reds Rottöne Reflected Reflektiert - Sample: foreground colour Abtasten: Vordergrundfarbe + Sample: foreground color Abtasten: Vordergrundfarbe Sampled Colors Aufgenommene Farben Satin Satin Save Pixel Aspect Ratio Pixel-Seitenverhältnis speichern @@ -1923,6 +1942,7 @@ Soften Erweichen Source Quelle Source: set Quelle: gesetzt + Source: {name} Quelle: {name} Sponge Tool Schwamm-Werkzeug Spot Healing Brush Tool Punktkorrektur-Pinsel-Werkzeug Spread Spreizung @@ -1940,7 +1960,7 @@ Transfer Übertragung Triangle Tool Dreieck-Werkzeug Unsaved changes Ungespeicherte Änderungen - Uses the foreground and background colours. Nutzt die Vordergrund- und Hintergrundfarben. + Uses the foreground and background colors. Nutzt die Vordergrund- und Hintergrundfarben. Web Web Web Large Web Groß Web Minimum Web Minimum @@ -1977,6 +1997,7 @@ Allow scripts to connect Skriptverbindungen erlauben Always Immer Always create smart objects when placing Beim Platzieren immer Smart-Objekte erstellen + Skip transform when placing Transformieren beim Platzieren überspringen Animated zoom Animierter Zoom Apply auto tone Automatische Tonwertkorrektur anwenden Ask Nachfragen @@ -1999,6 +2020,11 @@ Show canvas dots Punkte der Zeichenfläche anzeigen Clip thumbnails to layer bounds Miniaturbilder auf Ebenenbegrenzung beschneiden Clip Thumbnails to Layer Bounds Miniaturbilder auf Ebenenbegrenzung beschneiden + No Thumbnails Keine Miniaturbilder + Small Thumbnails Kleine Miniaturbilder + Medium Thumbnails Mittlere Miniaturbilder + Large Thumbnails Große Miniaturbilder + Clip Thumbnails to Document Bounds Miniaturbilder auf Dokumentbegrenzung beschneiden Canvas color Zeichenflächenfarbe Canvas custom color Benutzerdefinierte Zeichenflächenfarbe Color picker Farbwähler @@ -2116,7 +2142,6 @@ Show only crosshair while painting Beim Malen nur Fadenkreuz zeigen Show slice numbers Slice-Nummern anzeigen Show tooltips Tooltips anzeigen - Enable animations Animationen aktivieren Show highlight on rollover Hervorhebung bei Rollover anzeigen Show bounding box when dragging layer Begrenzungsrahmen beim Ziehen einer Ebene anzeigen System title bar System-Titelleiste @@ -2208,7 +2233,7 @@ Graphics Grafik Reset GPU Backend GPU-Backend zurücksetzen Applies at next launch. Wird beim nächsten Start angewendet. - Click to sample the foreground colour · {key}-click for background Klicken nimmt die Vordergrundfarbe auf · {key}-Klick für den Hintergrund + Click to sample the foreground color · {key}-click for background Klicken nimmt die Vordergrundfarbe auf · {key}-Klick für den Hintergrund {key}-click to set the source {key}-Klick setzt die Quelle {key} to subtract {key} zum Abziehen Clone source {n} ({key}-click with Clone Stamp to set) Klonquelle {n} ({key}-Klick mit dem Kopierstempel zum Setzen) @@ -2269,7 +2294,7 @@ Gradient Options Verlaufsoptionen Align with layer An Ebene ausrichten Reset Alignment Ausrichtung zurücksetzen - Centre the gradient (offset 0, 0) Verlauf zentrieren (Versatz 0, 0) + Center the gradient (offset 0, 0) Verlauf zentrieren (Versatz 0, 0) Gradient Editor Gradienten-Editor Gradient picker Verlaufsauswahl Gradient stops Verlaufsstufen @@ -2303,7 +2328,7 @@ Alt-click to define a source point to clone from Alt-Klick legt einen Quellpunkt zum Klonen fest No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Kein Pfad für eine Auswahl vorhanden: zeichne einen mit dem Stift-Werkzeug oder wähle einen im Pfade-Bedienfeld. Cancel opening Öffnen abbrechen - Cancelled opening {name} Öffnen von {name} abgebrochen + Canceled opening {name} Öffnen von {name} abgebrochen Damaged Photoshop document Beschädigtes Photoshop-Dokument The document “{name}” may be damaged. Das Dokument „{name}“ ist möglicherweise beschädigt. Some layers, pixels or masks could not be read. Do you want to continue opening it? Einige Ebenen, Pixel oder Masken konnten nicht gelesen werden. Möchten Sie das Öffnen fortsetzen? @@ -2313,7 +2338,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Das Dokument „{name}“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen {mode}-Arbeitsfarbraum passt. Working: {working} Arbeitsfarbraum: {working} Developing {name} {name} wird entwickelt - Cancelled {label} {label} abgebrochen + Canceled {label} {label} abgebrochen Esc to cancel Esc zum Abbrechen Opened {name} Geöffnet: {name} Opening {name} Öffne {name} @@ -2321,7 +2346,7 @@ Saving {name} Speichere {name} Saving {percent} Speichern {percent} A save to {name} is still running; try again when it finishes {name} wird noch gespeichert; versuchen Sie es erneut, wenn der Vorgang abgeschlossen ist - {label} is writing the file and can no longer be cancelled {label} schreibt die Datei und kann nicht mehr abgebrochen werden + {label} is writing the file and can no longer be canceled {label} schreibt die Datei und kann nicht mehr abgebrochen werden Opening {name}… Öffne {name}… Opening… Öffnen… Reading… Lesen… @@ -2364,6 +2389,7 @@ Punctuation Zeichensetzung Make Work Path Arbeitspfad erzeugen Make Selection Auswahl erzeugen + Make Shape Form erzeugen Fill Path Pfad füllen Stroke Path Pfad nachziehen Delete Path Pfad löschen @@ -2383,7 +2409,7 @@ Pixel Pixel Adjustment Anpassung Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Ziehen (Lasso) oder Punkte klicken (polygonal) · {add} hinzufügen · {sub} abziehen - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Freistellrahmen ziehen · innen ziehen verschiebt · Kanten skalieren ({ratio} Verhältnis, {centre} zentriert) · Leertaste verschiebt beim Zeichnen · Pfeiltasten verschieben · X tauscht Ausrichtung · {commit} übernimmt · Esc bricht ab + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Freistellrahmen ziehen · innen ziehen verschiebt · Kanten skalieren ({ratio} Verhältnis, {centre} zentriert) · Leertaste verschiebt beim Zeichnen · Pfeiltasten verschieben · X tauscht Ausrichtung · {commit} übernimmt · Esc bricht ab {mode} Color · {bits} bit {mode}-Farbe · {bits} Bit Scrubby Zoom Schleppzoom Auto Erase Automatisch löschen @@ -2478,7 +2504,7 @@ Discard Layers and Save a Copy Ebenen verwerfen und Kopie speichern Don't show again Nicht mehr anzeigen Save failed: {error} Speichern fehlgeschlagen: {error} - Save cancelled Speichern abgebrochen + Save canceled Speichern abgebrochen Style {name} saved Stil {name} gespeichert Make Default Als Standard festlegen Reset to Default Auf Standard zurücksetzen @@ -2615,7 +2641,7 @@ fontWeight Italic Kursiv Rotate View Ansicht drehen Rotate View Tool Ansicht-drehen-Werkzeug Reset View Ansicht zurücksetzen - Drag around the centre to rotate the view · Shift constrains to 15° Ansicht drehen: im Bogen ziehen · Umschalt: 15°-Schritte + Drag around the center to rotate the view · Shift constrains to 15° Ansicht drehen: im Bogen ziehen · Umschalt: 15°-Schritte Dual Axis Duale Achse Diagonal Diagonal Transform Symmetry Symmetrie transformieren @@ -2630,8 +2656,8 @@ fontWeight Italic Kursiv All Layers No Adjustments Alle Ebenen ohne Einstellungen Current & Below No Adjustments Aktuelle und darunter ohne Einstellungen Show Sampling Ring Aufnahmering anzeigen - Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe - Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe + Out of gamut for printing: click for the nearest printable color Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe + Not a web color: click for the nearest web color Keine Webfarbe: Klicken für die nächstliegende Webfarbe Color to Alpha… Farbe zu Alpha… Transparency Threshold Transparenz-Schwellenwert Opacity Threshold Deckkraft-Schwellenwert @@ -3297,15 +3323,20 @@ curves Smooth Glätten Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog @plural {n} action|{n} actions {n} Aktion|{n} Aktionen Background (Default) Hintergrund (Standard) + Background (default) Hintergrund (Standard) + Transparent (default) Transparent (Standard) Content-Aware Fill Inhaltsbasiert füllen Include All Subfolders Alle Unterordner einschließen Auto Add/Delete Autom. hinzuf./löschen Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Aktiviere nicht zusätzlich das Spiegelbild in den Druckeinstellungen: Die beiden Spiegelungen heben sich auf. Print Settings… Druckeinstellungen… - Remember to turn off the printer's own colour management in Print Settings… Denke daran, die eigene Farbverwaltung des Druckers in den Druckeinstellungen auszuschalten… + Remember to turn off the printer's own color management in Print Settings… Denke daran, die eigene Farbverwaltung des Druckers in den Druckeinstellungen auszuschalten… Sent to {printer} An {printer} gesendet set in Print Settings… in den Druckeinstellungen festlegen… ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt + Automatically Create First Snapshot Ersten Snapshot automatisch erstellen + Automatically Create New Snapshot When Saving Beim Speichern automatisch neuen Snapshot erstellen + Show New Snapshot Dialog by Default Dialogfeld „Neuer Snapshot“ standardmäßig anzeigen Memory usage percent Speichernutzung (%) Show Favorite Fonts Favorisierte Schriften anzeigen No favorite fonts yet: click the star beside a font to add it. Noch keine favorisierten Schriften: Klicken Sie auf den Stern neben einer Schrift, um sie hinzuzufügen. @@ -3350,6 +3381,7 @@ curves Smooth Glätten Automatically Check for Updates Automatisch nach Updates suchen Paints from the History panel's source state Malt aus dem Quellzustand des Protokollbedienfelds Sets the source for the history brush Legt die Quelle für den Protokollpinsel fest + The History Brush source was deleted: set a new source in the History panel Die Quelle des Protokollpinsels wurde gelöscht: Legen Sie eine neue Quelle im Protokollbedienfeld fest Highlight Mode Lichtermodus Highlight Color Lichterfarbe Highlight Opacity Lichterdeckkraft @@ -3414,6 +3446,7 @@ curves Smooth Glätten {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start) Reference point location Position des Referenzpunkts + Toggle reference point Referenzpunkt ein-/ausblenden Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist. The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}. @@ -3430,12 +3463,12 @@ curves Smooth Glätten {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. Demosaic Demosaicing - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Wie das Farbfiltermosaik der Kamera aufgefüllt wird. Es wird beim Öffnen der Datei angewendet; die Vorschau zeigt die Methode, mit der sie geöffnet wurde. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Wie das Farbfiltermosaik der Kamera aufgefüllt wird. Es wird beim Öffnen der Datei angewendet; die Vorschau zeigt die Methode, mit der sie geöffnet wurde. AHD (adaptive homogeneity) AHD (adaptive Homogenität) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: die saubersten Kanten und die wenigsten Farbsäume, am langsamsten. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientenkorrigierte Interpolation: scharf und schnell, mit etwas Farbsäumen an feinen Details. - The fastest and the softest, with colour fringing on fine detail. Am schnellsten und weichsten, mit Farbsäumen an feinen Details. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: die saubersten Kanten und die wenigsten Farbsäume, am langsamsten. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradientenkorrigierte Interpolation: scharf und schnell, mit etwas Farbsäumen an feinen Details. + The fastest and the softest, with color fringing on fine detail. Am schnellsten und weichsten, mit Farbsäumen an feinen Details. “{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen? The name in the save dialog was “{name}”. Der Name im Speichern-Dialog war „{name}“. Memory Usage Speichernutzung @@ -3524,6 +3557,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Schriftgrößen-Vorgaben Leading presets Zeilenabstand-Vorgaben Tracking presets Laufweiten-Vorgaben + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ungültige Zahleneingabe. Es ist eine ganze Zahl zwischen {min}% und {max}% erforderlich. Der vorherige Wert wurde eingesetzt. Clear Selection Auswahl löschen Color Aware Farbbezogen @@ -3551,3 +3586,80 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color Volltonfarbe Tint Color Tönungsfarbe Edit Brush… Pinsel bearbeiten… + +# Action Stop message (#8386) + Message Meldung +# Playback Options (#5212) + Playback Options… Wiedergabeoptionen… + Playback Options Wiedergabeoptionen + Accelerated Beschleunigt + Step by Step Schrittweise + Pause For: Pause für: + Pause For Pause für + seconds Sekunden + Create a single history state Einen einzigen Protokollobjekt-Status erstellen + Generator enabled Generator aktiviert + This Layer: Diese Ebene: + Move Up Nach oben + Move Down Nach unten + On your computer Auf Ihrem Computer + New file Neue Datei + Learn Lernen + No recent files Keine zuletzt verwendeten Dateien + Website Website + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Ein Rahmen unterstützt affine Transformationen; für perspektivische Transformationen sein Bild auswählen + A frame supports affine transforms; select its image to warp it Ein Rahmen unterstützt affine Transformationen; zum Verformen sein Bild auswählen + Convert to Frame… In Rahmen konvertieren… + Create a new custom frame Neuen benutzerdefinierten Rahmen erstellen + Create a new polygonal frame Neuen polygonalen Rahmen erstellen + Create a new triangular frame Neuen dreieckigen Rahmen erstellen + Fit Content to Frame Inhalt an Rahmen anpassen + Fit Frame to Content Rahmen an Inhalt anpassen + Frame coordinates must be finite and within 600000 pixels Rahmenkoordinaten müssen endlich sein und innerhalb von 600000 Pixeln liegen + Import Image… Bild importieren… + Invalid frame transform Ungültige Rahmentransformation + Invalid nested file drop Ungültiges verschachteltes Ablegen einer Datei + New Frame Neuer Rahmen + Replace Image… Bild ersetzen… + The frame is locked Der Rahmen ist gesperrt + The frame is unavailable Der Rahmen ist nicht verfügbar + X X + Y Y + +# Channels panel menu (#11451) + New Channel… Neuer Kanal… + Duplicate Channel Kanal duplizieren + Delete Channel Kanal löschen + New Spot Channel… Neuer Schmuckfarbenkanal… + Merge Spot Channel Schmuckfarbenkanal zusammenfügen + Channel Options… Kanaloptionen… + Split Channels Kanäle teilen + Merge Channels… Kanäle zusammenfügen… + Specify Channels: Kanäle angeben: + Not enough grayscale documents for this mode. Nicht genug Graustufen-Dokumente für diesen Modus. + Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument. + A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet. + A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr. +# Move tool options bar (#11381) + Align top edges Obere Kanten ausrichten + Align vertical centers Vertikale Mitten ausrichten + Align bottom edges Untere Kanten ausrichten + Distribute vertically Vertikal verteilen + Distribute horizontally Horizontal verteilen + Show layer bounds on hover Ebenenbegrenzung beim Darüberfahren anzeigen + Expand layer group on click Ebenengruppe per Klick erweitern + Show hover bounds from Layers Begrenzung beim Darüberfahren aus Ebenen anzeigen + Saved Camera Raw settings to {file} Camera-Raw-Einstellungen in {file} gespeichert + Save the settings to each raw's sidecar without opening it Die Einstellungen in der Filialdatei jeder Raw-Datei speichern, ohne sie zu öffnen + No settings saved: these raws can't keep a sidecar here Nichts gespeichert: Diese Raw-Dateien können hier keine Filialdatei haben + Some settings could not be saved; the raws stay open Einige Einstellungen konnten nicht gespeichert werden; die Raw-Dateien bleiben geöffnet + Nothing to save: these raws are at their default settings Nichts zu speichern: Diese Raw-Dateien haben ihre Standardeinstellungen +# Artboard tool options bar (#11385) + Distribute Widths Breiten verteilen + Distribute Heights Höhen verteilen + Auto-nest Layers Ebenen automatisch verschachteln + Auto-size Canvas Zeichenflächengröße automatisch anpassen + Keep Relative Position During Layer Reordering Relative Position beim Neuanordnen von Ebenen beibehalten + Shrinkwrap Canvas on Save Zeichenfläche beim Speichern auf den Inhalt verkleinern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8f20dd6705..af6353f692 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -144,6 +144,7 @@ Right Δεξιά Open Recent Πρόσφατα αρχεία Clear Recent Files Εκκαθάριση πρόσφατων αρχείων + Clear Recent File List Εκκαθάριση πρόσφατων αρχείων Remove from Recent Αφαίρεση από τα πρόσφατα Remove {name} from Recent Αφαίρεση του {name} από τα πρόσφατα Close Κλείσιμο @@ -152,6 +153,10 @@ Restore Επαναφορά Close All Κλείσιμο όλων Close Others Κλείσιμο των υπόλοιπων + New Document… Νέο έγγραφο… + Open Document… Άνοιγμα… + Consolidate All to Here Συγκέντρωση όλων εδώ + Move to New Window Μετακίνηση σε νέο παράθυρο Save Αποθήκευση Save As… Αποθήκευση ως… Save a Copy… Αποθήκευση αντιγράφου… @@ -203,6 +208,7 @@ Create Smart Object after Loading Layers Δημιουργία έξυπνου αντικειμένου μετά τη φόρτωση επιπέδων Reveal in Finder Εμφάνιση στο Finder Show in Explorer Εμφάνιση στην Εξερεύνηση αρχείων + Reveal in Explorer Εμφάνιση στην Εξερεύνηση αρχείων Show in Folder Εμφάνιση στον φάκελο File Info… Πληροφορίες αρχείου… Print… Εκτύπωση… @@ -260,6 +266,12 @@ Info Πληροφορίες Actions Ενέργειες Tools Εργαλεία + Contextual Task Bar Γραμμή εργασιών περιβάλλοντος + Hide bar Απόκρυψη γραμμής + Reset bar position Επαναφορά θέσης γραμμής + Pin bar position Καρφίτσωμα θέσης γραμμής + Move bar Μετακίνηση γραμμής + More options Περισσότερες επιλογές Zoom In Μεγέθυνση Zoom Out Σμίκρυνση Fit on Screen Προσαρμογή στην οθόνη @@ -755,6 +767,8 @@ Channel Mixer Μείκτης καναλιών Desaturate Αφαίρεση κορεσμού Equalize Εξισορρόπηση + Equalize Selected Area Only Εξισορρόπηση μόνο της επιλεγμένης περιοχής + Equalize Entire Image Based On Selected Area Εξισορρόπηση ολόκληρης της εικόνας βάσει της επιλεγμένης περιοχής Auto Tone Αυτόματη διόρθωση τόνων Auto Contrast Αυτόματη αντίθεση Auto Color Αυτόματη διόρθωση χρωμάτων @@ -855,7 +869,7 @@ Current Layer Τρέχον επίπεδο Current Size Τρέχον μέγεθος Custom Shapes Προσαρμοσμένα σχήματα - Default colours (D) Προεπιλεγμένα χρώματα (D) + Default Foreground and Background Colors (D) Προεπιλεγμένα χρώματα (D) Default printer Προεπιλεγμένος εκτυπωτής Default restored. Έγινε επαναφορά των προεπιλογών. Define at least one variable first. Ορίστε πρώτα τουλάχιστον μία μεταβλητή. @@ -881,7 +895,7 @@ Drop an image or PSD anywhere to open it. Σύρετε μια εικόνα ή ένα PSD εδώ για να το ανοίξετε. Native file drag-and-drop is unavailable Η μεταφορά και απόθεση αρχείων δεν είναι διαθέσιμη Open a document to define variables. Ανοίξτε ένα έγγραφο για να ορίσετε μεταβλητές. - Open a document to select a colour range. Ανοίξτε ένα έγγραφο για να επιλέξετε εύρος χρωμάτων. + Open a document to select a color range. Ανοίξτε ένα έγγραφο για να επιλέξετε εύρος χρωμάτων. Open a document to use the timeline. Ανοίξτε ένα έγγραφο για να χρησιμοποιήσετε τη χρονογραμμή. Option-click to define a source point to clone from Πατήστε Option και κάντε κλικ για να ορίσετε σημείο προέλευσης κλωνοποίησης Output Channel: Κανάλι εξόδου: @@ -916,7 +930,6 @@ Protect Foreground Color Προστασία χρώματος προσκηνίου Protect Tones Προστασία τονικών τιμών Quick Actions Γρήγορες ενέργειες - Quick Export As PNG Γρήγορη εξαγωγή ως PNG Radial Gradient Ακτινική διαβάθμιση Paints from the document's opening state Ζωγραφίζει χρησιμοποιώντας την αρχική κατάσταση του εγγράφου PhotoCraft website Ιστότοπος PhotoCraft @@ -926,7 +939,7 @@ Portrait, 5 x 7 Κατακόρυφο, 5 × 7 Protect Texture Προστασία υφής Recent Πρόσφατα - Sample: foreground colour Δείγμα: χρώμα προσκηνίου + Sample: foreground color Δείγμα: χρώμα προσκηνίου Sampled Colors Χρώματα δείγματος Save Pixel Aspect Ratio Διατήρηση αναλογίας εικονοστοιχείων Scratch Sizes Μεγέθη πρόχειρης μνήμης @@ -936,6 +949,7 @@ Show Extras Options Εμφάνιση επιπλέον επιλογών Soften Απαλότητα Source: set Πηγή: ορίστηκε + Source: {name} Πηγή: {name} Std Dev: Τυπική απόκλιση: Subtract Αφαίρεση Text Replacement Αντικατάσταση κειμένου @@ -1226,7 +1240,7 @@ Discard Layers and Save a Copy Απόρριψη επιπέδων και αποθήκευση αντιγράφου Don't show again Να μην εμφανιστεί ξανά Save failed: {error} Αποτυχία αποθήκευσης: {error} - Save cancelled Η αποθήκευση ακυρώθηκε + Save canceled Η αποθήκευση ακυρώθηκε Use tablet pressure to change pen width Χρησιμοποιήστε την πίεση της γραφίδας για να αλλάξετε το πλάτος της Date and Time Ημερομηνία και ώρα Scale Units Μονάδες κλίμακας @@ -1605,7 +1619,6 @@ Show only crosshair while painting Εμφάνιση μόνο σταυρονήματος κατά τη ζωγραφική Show slice numbers Εμφάνιση αριθμών τομών Show tooltips Εμφάνιση επεξηγήσεων - Enable animations Ενεργοποίηση κινούμενων εφέ Show highlight on rollover Εμφάνιση επισήμανσης κατά την κατάδειξη Show bounding box when dragging layer Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση επιπέδου Show transformation values Εμφάνιση τιμών μετασχηματισμού @@ -1683,7 +1696,7 @@ Gradient Options Επιλογές διαβάθμισης Align with layer Στοίχιση με το επίπεδο Reset Alignment Επαναφορά στοίχισης - Centre the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0) + Center the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0) Refine Strokes Πινελιές βελτίωσης Edits Επεξεργασίες Gradient Editor Επεξεργασία διαβάθμισης @@ -1713,7 +1726,7 @@ Alt-click to define a source point to clone from Alt-κλικ για ορισμό σημείου προέλευσης κλωνοποίησης No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Δεν υπάρχει διαδρομή για δημιουργία επιλογής: σχεδιάστε μία με το εργαλείο Πένα (Pen Tool) ή επιλέξτε μία στο πλαίσιο Διαδρομές. Cancel opening Ακύρωση ανοίγματος - Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name} + Canceled opening {name} Ακυρώθηκε το άνοιγμα του {name} Damaged Photoshop document Κατεστραμμένο έγγραφο Photoshop The document “{name}” may be damaged. Το έγγραφο «{name}» μπορεί να είναι κατεστραμμένο. Some layers, pixels or masks could not be read. Do you want to continue opening it? Δεν ήταν δυνατή η ανάγνωση ορισμένων επιπέδων, εικονοστοιχείων ή μασκών. Θέλετε να συνεχίσετε το άνοιγμα; @@ -1721,7 +1734,7 @@ The document “{name}” does not have an embedded {mode} profile. Το έγγραφο «{name}» δεν έχει ενσωματωμένο προφίλ {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Το έγγραφο «{name}» έχει ενσωματωμένο χρωματικό προφίλ που δεν ταιριάζει με τον τρέχοντα χώρο εργασίας {mode}. Working: {working} Χώρος εργασίας: {working} - Cancelled {label} Ακυρώθηκε: {label} + Canceled {label} Ακυρώθηκε: {label} Esc to cancel Esc για ακύρωση Opened {name} Άνοιξε το {name} Opening {name} Άνοιγμα του {name} @@ -1729,7 +1742,7 @@ Saving {name} Αποθήκευση του {name} Saving {percent} Αποθήκευση {percent} A save to {name} is still running; try again when it finishes Η αποθήκευση στο {name} βρίσκεται ακόμη σε εξέλιξη· δοκιμάστε ξανά όταν ολοκληρωθεί - {label} is writing the file and can no longer be cancelled {label}: γίνεται εγγραφή του αρχείου και δεν μπορεί πλέον να ακυρωθεί + {label} is writing the file and can no longer be canceled {label}: γίνεται εγγραφή του αρχείου και δεν μπορεί πλέον να ακυρωθεί Opening {name}… Άνοιγμα του {name}… Opening… Άνοιγμα… Reading… Ανάγνωση… @@ -1744,6 +1757,7 @@ Allow agent control Να επιτρέπεται ο έλεγχος από πράκτορες Allow scripts to connect Να επιτρέπεται η σύνδεση σεναρίων Always create smart objects when placing Να δημιουργούνται πάντα έξυπνα αντικείμενα κατά την τοποθέτηση + Skip transform when placing Παράλειψη μετασχηματισμού κατά την τοποθέτηση Animated zoom Κινούμενη μεγέθυνση Apply auto tone Εφαρμογή αυτόματης τονικής διόρθωσης Ask before saving layered tiff Ερώτηση πριν από την αποθήκευση TIFF με επίπεδα @@ -1765,6 +1779,11 @@ Show canvas dots Εμφάνιση κουκκίδων καμβά Clip thumbnails to layer bounds Περικοπή μικρογραφιών στα όρια του επιπέδου Clip Thumbnails to Layer Bounds Περικοπή μικρογραφιών στα όρια του επιπέδου + No Thumbnails Χωρίς μικρογραφίες + Small Thumbnails Μικρές μικρογραφίες + Medium Thumbnails Μεσαίες μικρογραφίες + Large Thumbnails Μεγάλες μικρογραφίες + Clip Thumbnails to Document Bounds Περικοπή μικρογραφιών στα όρια του εγγράφου Canvas color Χρώμα καμβά Canvas custom color Προσαρμοσμένο χρώμα καμβά Color picker Επιλογέας χρώματος @@ -1846,6 +1865,7 @@ Units & Rulers… Μονάδες και χάρακες… Guides, Grid & Slices… Οδηγοί, πλέγμα και τομές… Plug-ins… Πρόσθετα… + Plugins… Πρόσθετα… Type… Κείμενο… Enhanced Controls… Προηγμένα χειριστήρια… Raw Defaults… Προεπιλογές RAW… @@ -1950,6 +1970,8 @@ Hide Selection Απόκρυψη επιλογής From Transparency Από διαφάνεια Convert to Embedded Μετατροπή σε ενσωματωμένο + Embed Linked Ενσωμάτωση συνδεδεμένου + Embed All Linked Ενσωμάτωση όλων των συνδεδεμένων Convert to Linked… Μετατροπή σε συνδεδεμένο… Edit Contents Επεξεργασία περιεχομένου Replace Contents… Αντικατάσταση περιεχομένου… @@ -2008,7 +2030,9 @@ Unite Shapes Ένωση σχημάτων Subtract Front Shape Αφαίρεση μπροστινού σχήματος Intersect Shape Areas Τομή περιοχών σχημάτων + Unite Shapes at Overlap Τομή περιοχών σχημάτων Exclude Overlapping Shapes Εξαίρεση επικαλυπτόμενων σχημάτων + Subtract Shapes at Overlap Εξαίρεση επικαλυπτόμενων σχημάτων Merge Shape Components Συγχώνευση συστατικών σχήματος Path operations Λειτουργίες διαδρομών Top Edges Επάνω ακμές @@ -2294,9 +2318,9 @@ Click to add text Κάντε κλικ για προσθήκη κειμένου Click to apply, {key}-click to clear overrides, double-click for Style Options Κλικ για εφαρμογή, {key}-κλικ για απαλοιφή αλλαγών, διπλό κλικ για επιλογές στυλ Click to edit the gradient Κάντε κλικ για επεξεργασία της διαβάθμισης - Click to fill similar colours Κάντε κλικ για γέμισμα παρόμοιων χρωμάτων - Click to restore the current colour Κάντε κλικ για επαναφορά του τρέχοντος χρώματος - Click to sample the foreground colour · {key}-click for background Κλικ για δειγματοληψία χρώματος προσκηνίου · {key}-κλικ για φόντο + Click to fill similar colors Κάντε κλικ για γέμισμα παρόμοιων χρωμάτων + Click to restore the current color Κάντε κλικ για επαναφορά του τρέχοντος χρώματος + Click to sample the foreground color · {key}-click for background Κλικ για δειγματοληψία χρώματος προσκηνίου · {key}-κλικ για φόντο Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Κλικ για μεγέθυνση · {key}-κλικ για σμίκρυνση · σύρσιμο δεξιά/αριστερά για αλλαγή μεγέθυνσης Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Κλικ: γωνία · Σύρσιμο: εξομάλυνση · Κλικ στο πρώτο σημείο: κλείσιμο · {key}: τέλος · Esc: ακύρωση Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Κλικ: γωνία · Σύρσιμο: εξομάλυνση · {alt}+σύρσιμο: διάσπαση λαβής · Κλικ ή σύρσιμο στο πρώτο σημείο: κλείσιμο · {key}: τέλος · Esc: ακύρωση @@ -2313,8 +2337,8 @@ Collapse Σύμπτυξη Collapse group ({key}-click: all groups) Σύμπτυξη ομάδας ({key}-κλικ: όλες οι ομάδες) Color & Swatches Χρώμα και δείγματα - Color Blindness — Deuteranopia-type Αχρωματοψία — τύπος δευτερανωπίας - Color Blindness — Protanopia-type Αχρωματοψία — τύπος πρωτανωπίας + Color Blindness - Deuteranopia-type Αχρωματοψία - τύπος δευτερανωπίας + Color Blindness - Protanopia-type Αχρωματοψία - τύπος πρωτανωπίας Color Decontaminate Απομάκρυνση χρωματικής επιμόλυνσης Color Dynamics Δυναμική χρώματος Color Halftone Χρωματικός ημιτονισμός @@ -2447,8 +2471,8 @@ Dots Κουκκίδες Down Κάτω Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Σύρετε για λάσο ή κάντε κλικ για πολυγωνική επιλογή · {add} προσθήκη · {sub} αφαίρεση - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Σύρετε για περιοχή περικοπής · σύρετε στο εσωτερικό για μετακίνηση · από τα άκρα αλλάζει μέγεθος ({ratio} αναλογία, {centre} κέντρο) · Space για μετακίνηση κατά τη σχεδίαση · βέλη για μετακίνηση · X εναλλαγή προσανατολισμού · {commit} εφαρμογή · Esc ακύρωση - Drag around the centre to rotate the view · Shift constrains to 15° Σύρετε γύρω από το κέντρο για να περιστρέψετε την προβολή · Shift περιορίζει στις 15° + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Σύρετε για περιοχή περικοπής · σύρετε στο εσωτερικό για μετακίνηση · από τα άκρα αλλάζει μέγεθος ({ratio} αναλογία, {centre} κέντρο) · Space για μετακίνηση κατά τη σχεδίαση · βέλη για μετακίνηση · X εναλλαγή προσανατολισμού · {commit} εφαρμογή · Esc ακύρωση + Drag around the center to rotate the view · Shift constrains to 15° Σύρετε γύρω από το κέντρο για να περιστρέψετε την προβολή · Shift περιορίζει στις 15° Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Σύρετε πάνω στην εικόνα για προσθήκη περιορισμού· Shift για οριζόντιο/κατακόρυφο· δεξί κλικ σε έναν περιορισμό για αλλαγή ή διαγραφή. Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Σύρετε ή κάντε κλικ για πολυγωνικά σημεία · κρατήστε Alt κατά το λάσο για ευθείες · {add} προσθήκη · {sub} αφαίρεση πριν από τη σχεδίαση Drag to draw a gradient Σύρετε για να σχεδιάσετε διαβάθμιση @@ -2664,13 +2688,12 @@ Install Plug-in Εγκατάσταση προσθέτου Integrations Ενσωματώσεις Interlaced Πεπλεγμένο - Internet Standard RGB Τυπικό RGB διαδικτύου + Internet Standard RGB (sRGB) Τυπικό RGB διαδικτύου (sRGB) Interpolation: Παρεμβολή: Interpret Footage Ερμηνεία πλάνων Intersect with selection ({key}) Τομή με την επιλογή ({key}) Intersect with selection (⇧⌥) Τομή με την επιλογή (⇧⌥) Invalid {mode} profile Μη έγκυρο προφίλ {mode} - Inverse Selection Αντιστροφή επιλογής Invert All Αντιστροφή όλων Invert has no settings. Η αντιστροφή δεν διαθέτει ρυθμίσεις. Invert… Αντιστροφή… @@ -2717,7 +2740,7 @@ Leave as is (don't color manage) Διατήρηση ως έχει (χωρίς διαχείριση χρώματος) Left align text Αριστερή στοίχιση κειμένου Legacy Παλαιού τύπου - Legacy Macintosh RGB Παλαιό Macintosh RGB + Legacy Macintosh RGB (Gamma 1.8) Παλαιό Macintosh RGB (Gamma 1.8) Legacy compositing Παλαιός τρόπος σύνθεσης Legal Μέγεθος Legal Length: Μήκος: @@ -2781,6 +2804,7 @@ Make Κατασκευαστής Make Default Ορισμός ως προεπιλογής Make Selection Δημιουργία επιλογής + Make Shape Δημιουργία σχήματος Make Work Path Δημιουργία διαδρομής εργασίας Marker Size Μέγεθος δείκτη Master Κύριο @@ -2854,7 +2878,6 @@ New Video Group Νέα ομάδα βίντεο New Video Layer Νέο επίπεδο βίντεο New Video Layer from File Νέο επίπεδο βίντεο από αρχείο - New Window for Document Νέο παράθυρο για το έγγραφο New Workspace Νέος χώρος εργασίας New Workspace… Νέος χώρος εργασίας… New document… Νέο έγγραφο… @@ -2947,6 +2970,7 @@ Play Αναπαραγωγή Play selection Αναπαραγωγή επιλογής Plug-ins Πρόσθετα + Plugins Πρόσθετα Point size Μέγεθος σε σημεία Pointillize Πουαντιγισμός Pointillize… Πουαντιγισμός… @@ -3261,7 +3285,7 @@ Sumi-e Ιαπωνική ζωγραφική με μελάνι Surface Blur Θόλωση επιφάνειας Surface Blur… Θόλωση επιφάνειας… - Swap colours (X) Εναλλαγή χρωμάτων (X) + Switch Foreground and Background Colors (X) Εναλλαγή χρωμάτων (X) Swaps height and width Εναλλαγή ύψους και πλάτους Swash Διακοσμητικά τελειώματα Switch between free transform and warp modes Εναλλαγή ελεύθερου μετασχηματισμού και παραμόρφωσης @@ -3334,7 +3358,6 @@ Use File › Open to open an image. Χρησιμοποιήστε Αρχείο › Άνοιγμα για να ανοίξετε μια εικόνα. Use Legacy Χρήση παλαιάς λειτουργίας Use Measurement Scale Χρήση κλίμακας μέτρησης - Uses the foreground and background colours. Χρησιμοποιεί τα χρώματα προσκηνίου και φόντου. Vanishing Point Σημείο φυγής Vanishing Point… Σημείο φυγής… Variable Data Sets Σύνολα δεδομένων μεταβλητών @@ -3370,7 +3393,7 @@ Word Spacing Διάστημα λέξεων Work Path Διαδρομή εργασίας Working Black Plate Κανάλι μαύρου εργασίας - Working CMY Plate Κανάλια CMY εργασίας + Working CMY Plates Κανάλια CMY εργασίας Working CMYK Χώρος εργασίας CMYK Working Cyan Plate Κανάλι κυανού εργασίας Working Gray Plate Κανάλι γκρι εργασίας @@ -3458,6 +3481,7 @@ cameraRaw Yellow Κίτρινο Move Tool Μετακίνηση (Move Tool) Could not use the move tool because the layer is locked. Δεν ήταν δυνατή η χρήση του εργαλείου Μετακίνηση, επειδή το επίπεδο είναι κλειδωμένο. Could not complete the Free Transform command because the layer is locked. Δεν ήταν δυνατή η ολοκλήρωση της εντολής Ελεύθερος μετασχηματισμός, επειδή το επίπεδο είναι κλειδωμένο. + Could not transform because the layer is locked. Δεν ήταν δυνατός ο μετασχηματισμός, επειδή το επίπεδο είναι κλειδωμένο. Note Tool Σημείωση (Note Tool) Object Selection Tool Επιλογή αντικειμένου (Object Selection Tool) Paint Bucket Tool Κουβάς γεμίσματος (Paint Bucket Tool) @@ -3686,8 +3710,8 @@ fontWeight Thin Πολύ λεπτά All Layers No Adjustments Όλα τα επίπεδα χωρίς προσαρμογές Current & Below No Adjustments Τρέχον και υποκείμενα χωρίς προσαρμογές Show Sampling Ring Εμφάνιση δακτυλίου δειγματοληψίας - Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα - Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web + Out of gamut for printing: click for the nearest printable color Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα + Not a web color: click for the nearest web color Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web Color to Alpha… Χρώμα σε άλφα… Transparency Threshold Κατώφλι διαφάνειας Opacity Threshold Κατώφλι αδιαφάνειας @@ -3982,11 +4006,13 @@ curves Smooth Εξομάλυνση Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες Background (Default) Φόντο (προεπιλογή) + Background (default) Φόντο (προεπιλογή) + Transparent (default) Διαφανές (προεπιλογή) Include All Subfolders Συμπερίληψη όλων των υποφακέλων Auto Add/Delete Αυτόματη προσθήκη/διαγραφή Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Μην ενεργοποιείτε επίσης τον κατοπτρισμό στις ρυθμίσεις εκτύπωσης: οι δύο αναστροφές αλληλοαναιρούνται. Print Settings… Ρυθμίσεις εκτύπωσης… - Remember to turn off the printer's own colour management in Print Settings… Θυμηθείτε να απενεργοποιήσετε τη διαχείριση χρωμάτων του εκτυπωτή στις ρυθμίσεις εκτύπωσης… + Remember to turn off the printer's own color management in Print Settings… Θυμηθείτε να απενεργοποιήσετε τη διαχείριση χρωμάτων του εκτυπωτή στις ρυθμίσεις εκτύπωσης… Sent to {printer} Στάλθηκε στον {printer} set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία @@ -4034,6 +4060,7 @@ curves Smooth Εξομάλυνση Automatically Check for Updates Αυτόματος έλεγχος ενημερώσεων Paints from the History panel's source state Βάφει από την κατάσταση πηγής του πίνακα Ιστορικό Sets the source for the history brush Ορίζει την πηγή για το πινέλο ιστορικού + The History Brush source was deleted: set a new source in the History panel Η πηγή για το πινέλο ιστορικού διαγράφηκε: ορίστε νέα πηγή στον πίνακα Ιστορικό Highlight Mode Λειτουργία φωτεινών τόνων Highlight Color Χρώμα φωτεινών τόνων Highlight Opacity Αδιαφάνεια φωτεινών τόνων @@ -4116,12 +4143,12 @@ curves Smooth Εξομάλυνση Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου Demosaic Αποψηφιδοποίηση - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Πώς συμπληρώνεται το ψηφιδωτό του χρωματικού φίλτρου της κάμερας. Εφαρμόζεται όταν ανοίγει το αρχείο· η προεπισκόπηση δείχνει τη μέθοδο με την οποία άνοιξε. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Πώς συμπληρώνεται το ψηφιδωτό του χρωματικού φίλτρου της κάμερας. Εφαρμόζεται όταν ανοίγει το αρχείο· η προεπισκόπηση δείχνει τη μέθοδο με την οποία άνοιξε. AHD (adaptive homogeneity) AHD (προσαρμοστική ομοιογένεια) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: οι πιο καθαρές ακμές και τα λιγότερα χρωματικά περιθώρια, η πιο αργή. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Παρεμβολή με διόρθωση κλίσης: ευκρινής και γρήγορη, με λίγα χρωματικά περιθώρια στις λεπτές λεπτομέρειες. - The fastest and the softest, with colour fringing on fine detail. Η πιο γρήγορη και η πιο μαλακή, με χρωματικά περιθώρια στις λεπτές λεπτομέρειες. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: οι πιο καθαρές ακμές και τα λιγότερα χρωματικά περιθώρια, η πιο αργή. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Παρεμβολή με διόρθωση κλίσης: ευκρινής και γρήγορη, με λίγα χρωματικά περιθώρια στις λεπτές λεπτομέρειες. + The fastest and the softest, with color fringing on fine detail. Η πιο γρήγορη και η πιο μαλακή, με χρωματικά περιθώρια στις λεπτές λεπτομέρειες. “{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί; The name in the save dialog was “{name}”. Το όνομα στο παράθυρο αποθήκευσης ήταν «{name}». Previous Brush Προηγούμενο πινέλο @@ -4196,6 +4223,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Προεπιλογές μεγέθους γραμματοσειράς Leading presets Προεπιλογές διάστιχου Tracking presets Προεπιλογές διαστήματος χαρακτήρων + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Μη έγκυρη αριθμητική τιμή. Απαιτείται ακέραιος αριθμός μεταξύ {min}% και {max}%. Εισήχθη η προηγούμενη τιμή. Color Aware Με βάση το χρώμα Decontaminate Colors Απολύμανση χρωμάτων @@ -4220,3 +4249,81 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Χρώμα στάσης Shapes can't use patterns yet Τα σχήματα δεν μπορούν ακόμη να χρησιμοποιούν μοτίβα Edit Brush… Επεξεργασία πινέλου… + +# Action Stop message (#8386) + Message Μήνυμα +# Playback Options (#5212) + Playback Options… Επιλογές αναπαραγωγής… + Playback Options Επιλογές αναπαραγωγής + Accelerated Επιταχυνόμενη + Step by Step Βήμα προς βήμα + Pause For: Παύση για: + Pause For Παύση για + seconds δευτερόλεπτα + Create a single history state Δημιουργία μίας κατάστασης ιστορικού + Generator enabled Ενεργοποίηση γεννήτριας + Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας + Move Up Μετακίνηση επάνω + Move Down Μετακίνηση κάτω + Grid view Προβολή πλέγματος + List view Προβολή λίστας + On your computer Στον υπολογιστή σας + New file Νέο αρχείο + Learn Εκμάθηση + Website Ιστότοπος + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Το πλαίσιο υποστηρίζει αφινικούς μετασχηματισμούς· επιλέξτε την εικόνα του για μετασχηματισμούς προοπτικής + A frame supports affine transforms; select its image to warp it Το πλαίσιο υποστηρίζει αφινικούς μετασχηματισμούς· επιλέξτε την εικόνα του για στρέβλωση + Convert to Frame… Μετατροπή σε πλαίσιο… + Create a new custom frame Δημιουργία νέου προσαρμοσμένου πλαισίου + Create a new polygonal frame Δημιουργία νέου πολυγωνικού πλαισίου + Create a new triangular frame Δημιουργία νέου τριγωνικού πλαισίου + Fit Content to Frame Προσαρμογή περιεχομένου στο πλαίσιο + Fit Frame to Content Προσαρμογή πλαισίου στο περιεχόμενο + Frame coordinates must be finite and within 600000 pixels Οι συντεταγμένες του πλαισίου πρέπει να είναι πεπερασμένες και εντός 600000 pixel + Import Image… Εισαγωγή εικόνας… + Invalid frame transform Μη έγκυρος μετασχηματισμός πλαισίου + Invalid nested file drop Μη έγκυρη ένθετη απόθεση αρχείου + New Frame Νέο πλαίσιο + Replace Image… Αντικατάσταση εικόνας… + The frame is locked Το πλαίσιο είναι κλειδωμένο + The frame is unavailable Το πλαίσιο δεν είναι διαθέσιμο + X X + Y Y + +# Channels panel menu (#11451) + New Channel… Νέο κανάλι… + Duplicate Channel Δημιουργία αντιγράφου καναλιού + Delete Channel Διαγραφή καναλιού + New Spot Channel… Νέο κανάλι πλακάτου χρώματος… + Merge Spot Channel Συγχώνευση καναλιού πλακάτου χρώματος + Channel Options… Επιλογές καναλιού… + Split Channels Διαχωρισμός καναλιών + Merge Channels… Συγχώνευση καναλιών… + Specify Channels: Καθορισμός καναλιών: + Not enough grayscale documents for this mode. Δεν υπάρχουν αρκετά έγγραφα σε κλίμακα του γκρι για αυτή τη λειτουργία. + Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι. + A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό. + A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους. +# Move tool options bar (#11381) + Align top edges Στοίχιση πάνω άκρων + Align vertical centers Στοίχιση κατακόρυφων κέντρων + Align bottom edges Στοίχιση κάτω άκρων + Distribute vertically Κατανομή κατακόρυφα + Distribute horizontally Κατανομή οριζόντια + Show layer bounds on hover Εμφάνιση ορίων στρώσης στο πέρασμα του δείκτη + Expand layer group on click Ανάπτυξη ομάδας στρώσεων με κλικ + Show hover bounds from Layers Εμφάνιση ορίων στο πέρασμα από τις Στρώσεις + Saved Camera Raw settings to {file} Οι ρυθμίσεις Camera Raw αποθηκεύτηκαν στο {file} + Save the settings to each raw's sidecar without opening it Αποθήκευση των ρυθμίσεων στο συνοδευτικό αρχείο κάθε raw χωρίς άνοιγμα + No settings saved: these raws can't keep a sidecar here Δεν αποθηκεύτηκε τίποτα: αυτά τα raw δεν μπορούν να έχουν συνοδευτικό αρχείο εδώ + Some settings could not be saved; the raws stay open Ορισμένες ρυθμίσεις δεν ήταν δυνατό να αποθηκευτούν· τα raw παραμένουν ανοιχτά + Nothing to save: these raws are at their default settings Τίποτα για αποθήκευση: αυτά τα raw έχουν τις προεπιλεγμένες ρυθμίσεις +# Artboard tool options bar (#11385) + Distribute Widths Κατανομή πλατών + Distribute Heights Κατανομή υψών + Auto-nest Layers Αυτόματη ένθεση επιπέδων + Auto-size Canvas Αυτόματη αλλαγή μεγέθους καμβά + Keep Relative Position During Layer Reordering Διατήρηση σχετικής θέσης κατά την αναδιάταξη επιπέδων + Shrinkwrap Canvas on Save Σμίκρυνση καμβά στο περιεχόμενο κατά την αποθήκευση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6b365ce2be..e9b758a917 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -139,6 +139,7 @@ Right Derecha Open Recent Abrir recientes Clear Recent Files Vaciar archivos recientes + Clear Recent File List Vaciar archivos recientes Remove from Recent Quitar de recientes Remove {name} from Recent Quitar {name} de recientes Close Cerrar @@ -147,6 +148,10 @@ Restore Restaurar Close All Cerrar todo Close Others Cerrar otros + New Document… Documento nuevo… + Open Document… Abrir… + Consolidate All to Here Consolidar todo aquí + Move to New Window Mover a una nueva ventana Save Guardar Save As… Guardar como… Save a Copy… Guardar una copia… @@ -199,6 +204,7 @@ Create Smart Object after Loading Layers Crear objeto inteligente tras cargar las capas Reveal in Finder Mostrar en Finder Show in Explorer Mostrar en el Explorador + Reveal in Explorer Mostrar en el Explorador Show in Folder Mostrar en la carpeta File Info… Información del archivo… Print… Imprimir… @@ -287,6 +293,7 @@ Units & Rulers… Unidades y reglas… Guides, Grid & Slices… Guías, cuadrícula y sectores… Plug-ins… Plugins… + Plugins… Plugins… Type… Texto… Enhanced Controls… Controles mejorados… Camera Raw… Camera Raw… @@ -340,6 +347,8 @@ Match Color… Igualar color… Replace Color… Reemplazar color… Equalize Ecualizar + Equalize Selected Area Only Ecualizar solo la selección + Equalize Entire Image Based On Selected Area Ecualizar toda la imagen según la selección Auto Tone Tono automático Auto Contrast Contraste automático Auto Color Color automático @@ -444,6 +453,8 @@ Smart Object Objeto inteligente Convert to Smart Object Convertir en objeto inteligente Convert to Embedded Convertir en incrustado + Embed Linked Incrustar vinculado + Embed All Linked Incrustar todos los vinculados Convert to Layers Convertir en capas Convert to Linked… Convertir en vinculado… Edit Contents Editar contenido @@ -594,7 +605,6 @@ Deselect Deseleccionar Reselect Volver a seleccionar Inverse Invertir - Inverse Selection Invertir selección Similar Similar Color Range… Gama de colores… Focus Area… Área de enfoque… @@ -702,6 +712,7 @@ Minimum… Mínimo… Offset… Desplazamiento… Plug-ins Plugins + Plugins Plugins Proof Setup Ajuste de prueba Proof Colors Colores de prueba Gamut Warning Advertencia de gama @@ -710,12 +721,12 @@ Working Magenta Plate Placa magenta de trabajo Working Yellow Plate Placa amarilla de trabajo Working Black Plate Placa negra de trabajo - Working CMY Plate Placa CMY de trabajo - Legacy Macintosh RGB RGB heredado de Macintosh - Internet Standard RGB RGB estándar de Internet + Working CMY Plates Placas CMY de trabajo + Legacy Macintosh RGB (Gamma 1.8) RGB heredado de Macintosh (Gamma 1.8) + Internet Standard RGB (sRGB) RGB estándar de Internet (sRGB) Monitor RGB RGB del monitor - Color Blindness — Protanopia-type Daltonismo — tipo protanopia - Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia + Color Blindness - Protanopia-type Daltonismo - tipo protanopia + Color Blindness - Deuteranopia-type Daltonismo - tipo deuteranopia Zoom In Acercar Zoom Out Alejar Fit on Screen Encajar en pantalla @@ -804,7 +815,6 @@ Consolidate All to Tabs Consolidar todo en pestañas Float in Window Hacer flotante en ventana Float All in Windows Hacer flotantes todas en ventanas - New Window for Document Nueva ventana para el documento Workspace Espacio de trabajo Essentials Esenciales Graphic and Web Gráficos y web @@ -866,6 +876,12 @@ Timeline Línea de tiempo Tool Presets Ajustes preestablecidos de herramienta Tools Herramientas + Contextual Task Bar Barra de tareas contextual + Hide bar Ocultar barra + Reset bar position Restablecer posición de la barra + Pin bar position Fijar posición de la barra + Move bar Mover barra + More options Más opciones About PhotoCraft Acerca de PhotoCraft PhotoCraft Website Sitio web de PhotoCraft PhotoCraft on GitHub PhotoCraft en GitHub @@ -1029,7 +1045,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Clic para añadir un punto · arrastre fuera, {key}-clic o Delete para quitar Click to apply, {key}-click to clear overrides, double-click for Style Options Clic para aplicar, {key}-clic para quitar excepciones, doble clic para Opciones de estilo Click to edit the gradient Haga clic para editar el degradado - Click to restore the current colour Haga clic para restaurar el color actual + Click to restore the current color Haga clic para restaurar el color actual Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Clic para acercar · {key}-clic para alejar · arrastre a la derecha/izquierda para acercar/alejar Click with the Note tool to add a note. Haga clic con la herramienta Nota para añadir una nota. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic: esquina · Arrastrar: suavizar · Clic en el primer punto: cerrar · {key}: terminar · Esc: cancelar @@ -1112,7 +1128,7 @@ Darks Oscuros Data Sets Conjuntos de datos Default Predeterminado - Default colours (D) Colores predeterminados (D) + Default Foreground and Background Colors (D) Colores frontal y de fondo predeterminados (D) Default printer Impresora predeterminada Default restored. Se restauraron los valores predeterminados. Define Definir @@ -1394,7 +1410,7 @@ Opacity Jitter Variación de opacidad Opacity: Opacidad: Open a document to define variables. Abra un documento para definir variables. - Open a document to select a colour range. Abra un documento para seleccionar una gama de colores. + Open a document to select a color range. Abra un documento para seleccionar una gama de colores. Open a document to use the timeline. Abra un documento para usar la línea de tiempo. Optical Óptico Optimized Optimizado @@ -1470,7 +1486,6 @@ Purity Pureza Quality Calidad Quick Actions Acciones rápidas - Quick Export As PNG Exportación rápida como PNG Radial Gradient Degradado radial Radius Radio Radius: Radio: @@ -1612,7 +1627,7 @@ Subtract from Sample Restar de la muestra Subtract from selection ({key}) Restar de la selección ({key}) Suggestions: Sugerencias: - Swap colours (X) Intercambiar colores (X) + Switch Foreground and Background Colors (X) Intercambiar colores frontal y de fondo (X) Swaps height and width Intercambia la altura y el ancho Switch between free transform and warp modes Cambiar entre los modos de transformación libre y deformación Switch theme Cambiar tema @@ -1715,7 +1730,7 @@ Choke Contraer Classic Clásico Click to add text Haga clic para añadir texto - Click to fill similar colours Haga clic para rellenar colores similares + Click to fill similar colors Haga clic para rellenar colores similares Clone Stamp Tool Herramienta Tampón de clonar Color Burn Subexposición de color Color Dodge Sobreexposición de color @@ -1817,6 +1832,7 @@ Move Tool Herramienta Mover Could not use the move tool because the layer is locked. No se pudo usar la herramienta Mover porque la capa está bloqueada. Could not complete the Free Transform command because the layer is locked. No se pudo completar el comando Transformación libre porque la capa está bloqueada. + Could not transform because the layer is locked. No se pudo transformar porque la capa está bloqueada. Multiply Multiplicar My Brushes Mis pinceles Neutrals Neutros @@ -1875,7 +1891,7 @@ Rectangular Marquee Tool Herramienta Marco rectangular Reds Rojos Reflected Reflejado - Sample: foreground colour Muestra: color frontal + Sample: foreground color Muestra: color frontal Sampled Colors Colores muestreados Satin Satinado Save Pixel Aspect Ratio Guardar relación de aspecto de píxel @@ -1896,6 +1912,7 @@ Soften Suavizar Source Origen Source: set Origen: establecido + Source: {name} Origen: {name} Sponge Tool Herramienta Esponja Spot Healing Brush Tool Herramienta Pincel corrector puntual Spread Extensión @@ -1913,7 +1930,6 @@ Transfer Transferencia Triangle Tool Herramienta Triángulo Unsaved changes Cambios sin guardar - Uses the foreground and background colours. Usa los colores frontal y de fondo. Web Web Web Large Web (grande) Web Minimum Web (mínimo) @@ -1950,6 +1966,7 @@ Allow scripts to connect Permitir que las secuencias de comandos se conecten Always Siempre Always create smart objects when placing Crear siempre objetos inteligentes al colocar + Skip transform when placing Omitir la transformación al colocar Animated zoom Zoom animado Apply auto tone Aplicar el tono automático Ask Preguntar @@ -1972,6 +1989,11 @@ Show canvas dots Mostrar puntos del lienzo Clip thumbnails to layer bounds Recortar miniaturas a los límites de la capa Clip Thumbnails to Layer Bounds Recortar miniaturas a los límites de la capa + No Thumbnails Sin miniaturas + Small Thumbnails Miniaturas pequeñas + Medium Thumbnails Miniaturas medianas + Large Thumbnails Miniaturas grandes + Clip Thumbnails to Document Bounds Recortar miniaturas a los límites del documento Canvas color Color del lienzo Canvas custom color Color personalizado del lienzo Color picker Selector de color @@ -2089,7 +2111,6 @@ Show only crosshair while painting Mostrar solo la retícula al pintar Show slice numbers Mostrar los números de sector Show tooltips Mostrar las descripciones emergentes - Enable animations Activar las animaciones Show highlight on rollover Mostrar resaltado al pasar el cursor Show bounding box when dragging layer Mostrar el cuadro delimitador al arrastrar la capa System title bar Barra de título del sistema @@ -2181,7 +2202,7 @@ Graphics Gráficos Reset GPU Backend Restablecer el backend de GPU Applies at next launch. Se aplicará en el próximo inicio. - Click to sample the foreground colour · {key}-click for background Clic para muestrear el color frontal · {key}-clic para el de fondo + Click to sample the foreground color · {key}-click for background Clic para muestrear el color frontal · {key}-clic para el de fondo {key}-click to set the source {key}-clic para establecer el origen {key} to subtract {key} para restar Clone source {n} ({key}-click with Clone Stamp to set) Origen de clonado {n} ({key}-clic con Tampón de clonar para establecerlo) @@ -2242,7 +2263,7 @@ Gradient Options Opciones de degradado Align with layer Alinear con la capa Reset Alignment Restablecer la alineación - Centre the gradient (offset 0, 0) Centrar el degradado (desplazamiento 0, 0) + Center the gradient (offset 0, 0) Centrar el degradado (desplazamiento 0, 0) Gradient Editor Editor de degradados Gradient picker Selector de degradados Save As Preset Guardar como ajuste preestablecido @@ -2275,7 +2296,7 @@ Alt-click to define a source point to clone from Alt+clic para definir un punto de origen de clonación No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. No hay ningún trazado para crear una selección: dibuje uno con la herramienta Pluma o seleccione uno en el panel Trazados. Cancel opening Cancelar apertura - Cancelled opening {name} Se canceló la apertura de {name} + Canceled opening {name} Se canceló la apertura de {name} Damaged Photoshop document Documento de Photoshop dañado The document “{name}” may be damaged. Es posible que el documento «{name}» esté dañado. Some layers, pixels or masks could not be read. Do you want to continue opening it? No se pudieron leer algunas capas, píxeles o máscaras. ¿Desea seguir abriéndolo? @@ -2284,7 +2305,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. El documento «{name}» tiene un perfil de color incrustado que no coincide con el espacio de trabajo {mode} actual. Working: {working} Espacio de trabajo: {working} Developing {name} Revelando {name} - Cancelled {label} Se canceló {label} + Canceled {label} Se canceló {label} Esc to cancel Esc para cancelar Opened {name} Se abrió {name} Opening {name} Abriendo {name} @@ -2292,7 +2313,7 @@ Saving {name} Guardando {name} Saving {percent} Guardando {percent} A save to {name} is still running; try again when it finishes Todavía se está guardando {name}; inténtelo de nuevo cuando termine - {label} is writing the file and can no longer be cancelled {label} está escribiendo el archivo y ya no se puede cancelar + {label} is writing the file and can no longer be canceled {label} está escribiendo el archivo y ya no se puede cancelar Opening {name}… Abriendo {name}… Opening… Abriendo… Reading… Leyendo… @@ -2339,6 +2360,7 @@ Punctuation Puntuación Make Work Path Crear trazado de trabajo Make Selection Crear selección + Make Shape Crear forma Fill Path Rellenar trazado Stroke Path Contornear trazado Delete Path Eliminar trazado @@ -2357,7 +2379,7 @@ Pixel Píxel Adjustment Ajuste Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Arrastre (lazo) o haga clic en los puntos (poligonal) · {add} añadir · {sub} restar - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Arrastre un marco de recorte · arrastre dentro para mover · los bordes cambian el tamaño ({ratio} proporción, {centre} centro) · Espacio mueve mientras dibuja · flechas desplazan · X cambia orientación · {commit} aplica · Esc cancela + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Arrastre un marco de recorte · arrastre dentro para mover · los bordes cambian el tamaño ({ratio} proporción, {centre} centro) · Espacio mueve mientras dibuja · flechas desplazan · X cambia orientación · {commit} aplica · Esc cancela {mode} Color · {bits} bit Color {mode} · {bits} bits Scrubby Zoom Zoom por arrastre Auto Erase Borrado automático @@ -2460,7 +2482,7 @@ cameraRaw Amount Cantidad Discard Layers and Save a Copy Descartar capas y guardar una copia Don't show again No volver a mostrar Save failed: {error} Error al guardar: {error} - Save cancelled Guardado cancelado + Save canceled Guardado cancelado Magnetic Lasso Tool Herramienta Lazo magnético Frequency Frecuencia Use tablet pressure to change pen width Usar la presión de la tableta para cambiar la anchura de la pluma @@ -2590,7 +2612,7 @@ fontWeight Italic Cursiva Rotate View Rotar vista Rotate View Tool Herramienta Rotar vista Reset View Restablecer vista - Drag around the centre to rotate the view · Shift constrains to 15° Arrastre alrededor del centro para rotar la vista · Mayús fija a 15° + Drag around the center to rotate the view · Shift constrains to 15° Arrastre alrededor del centro para rotar la vista · Mayús fija a 15° Dual Axis Doble eje Diagonal Diagonal Transform Symmetry Transformar simetría @@ -2605,8 +2627,8 @@ fontWeight Italic Cursiva All Layers No Adjustments Todas las capas sin ajustes Current & Below No Adjustments Actual e inferiores sin ajustes Show Sampling Ring Mostrar anillo de muestreo - Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano - Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano + Out of gamut for printing: click for the nearest printable color Fuera de la gama de impresión: haga clic para el color imprimible más cercano + Not a web color: click for the nearest web color No es un color web: haga clic para el color web más cercano Color to Alpha… Color a alfa… Transparency Threshold Umbral de transparencia Opacity Threshold Umbral de opacidad @@ -4195,11 +4217,13 @@ navigator Zoom Zoom Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo @plural {n} action|{n} actions {n} acción|{n} acciones Background (Default) Fondo (predeterminado) + Background (default) Fondo (predeterminado) + Transparent (default) Transparente (predeterminado) Include All Subfolders Incluir todas las subcarpetas Auto Add/Delete Añadir/eliminar automáticamente Don't also tick Mirror Image in Print Settings…: the two flips cancel out. No actives también Imagen especular en Ajustes de impresión: las dos inversiones se anulan. Print Settings… Ajustes de impresión… - Remember to turn off the printer's own colour management in Print Settings… Recuerda desactivar la gestión de color de la impresora en Ajustes de impresión… + Remember to turn off the printer's own color management in Print Settings… Recuerda desactivar la gestión de color de la impresora en Ajustes de impresión… Sent to {printer} Enviado a {printer} set in Print Settings… se establece en Ajustes de impresión… ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles @@ -4247,6 +4271,7 @@ navigator Zoom Zoom Automatically Check for Updates Buscar actualizaciones automáticamente Paints from the History panel's source state Pinta a partir del estado de origen del panel Historia Sets the source for the history brush Establece el origen del pincel de historia + The History Brush source was deleted: set a new source in the History panel Se eliminó el origen del pincel de historia: establece un nuevo origen en el panel Historia Highlight Mode Modo de luces Highlight Color Color de luces Highlight Opacity Opacidad de luces @@ -4328,12 +4353,12 @@ navigator Zoom Zoom Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel Demosaic Demosaico - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cómo se completa el mosaico del filtro de color de la cámara. Se aplica al abrir el archivo; la vista previa muestra el método con el que se abrió. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cómo se completa el mosaico del filtro de color de la cámara. Se aplica al abrir el archivo; la vista previa muestra el método con el que se abrió. AHD (adaptive homogeneity) AHD (homogeneidad adaptativa) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: los bordes más limpios y el menor halo de color; el más lento. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolación con corrección de gradiente: nítida y rápida, con algo de halo de color en el detalle fino. - The fastest and the softest, with colour fringing on fine detail. El más rápido y el más suave, con halo de color en el detalle fino. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: los bordes más limpios y el menor halo de color; el más lento. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolación con corrección de gradiente: nítida y rápida, con algo de halo de color en el detalle fino. + The fastest and the softest, with color fringing on fine detail. El más rápido y el más suave, con halo de color en el detalle fino. “{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo? The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}». Previous Brush Pincel anterior @@ -4407,6 +4432,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Valores predefinidos de tamaño de fuente Leading presets Valores predefinidos de interlineado Tracking presets Valores predefinidos de tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica no válida. Se requiere un número entero entre {min}% y {max}%. Se ha insertado el valor anterior. Color Aware Según color Decontaminate Colors Descontaminar colores @@ -4432,3 +4459,71 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Color de la parada Shapes can't use patterns yet Las formas aún no pueden usar motivos Edit Brush… Editar pincel… + +# Action Stop message (#8386) + Message Mensaje +# Playback Options (#5212) + Playback Options… Opciones de reproducción… + Playback Options Opciones de reproducción + Accelerated Acelerada + Step by Step Paso a paso + Pause For: Pausa de: + Pause For Pausa de + seconds segundos + Create a single history state Crear un solo estado de historia + Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el trazado de trabajo + Generator enabled Generador activado + This Layer: Esta capa: + Move Up Mover arriba + Move Down Mover abajo + On your computer En su ordenador + New file Nuevo archivo + Learn Aprender + Website Sitio web + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Un marco admite transformaciones afines; seleccione su imagen para transformaciones de perspectiva + A frame supports affine transforms; select its image to warp it Un marco admite transformaciones afines; seleccione su imagen para deformarla + Convert to Frame… Convertir en marco… + Create a new custom frame Crear un nuevo marco personalizado + Create a new polygonal frame Crear un nuevo marco poligonal + Create a new triangular frame Crear un nuevo marco triangular + Fit Content to Frame Ajustar contenido al marco + Fit Frame to Content Ajustar marco al contenido + Frame coordinates must be finite and within 600000 pixels Las coordenadas del marco deben ser finitas y estar dentro de 600000 píxeles + Import Image… Importar imagen… + Invalid frame transform Transformación de marco no válida + Invalid nested file drop Soltado de archivo anidado no válido + New Frame Nuevo marco + Replace Image… Reemplazar imagen… + The frame is locked El marco está bloqueado + The frame is unavailable El marco no está disponible + +# Channels panel menu (#11451) + Channel Options… Opciones de canal… + Specify Channels: Especificar canales: + Not enough grayscale documents for this mode. No hay suficientes documentos en escala de grises para este modo. + Pick a different document for each channel. Elija un documento distinto para cada canal. + A picked document is no longer open. Uno de los documentos elegidos ya no está abierto. + A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño. +# Move tool options bar (#11381) + Align top edges Alinear bordes superiores + Align vertical centers Alinear centros verticales + Align bottom edges Alinear bordes inferiores + Distribute vertically Distribuir verticalmente + Distribute horizontally Distribuir horizontalmente + Show layer bounds on hover Mostrar límites de capa al pasar el puntero + Expand layer group on click Expandir grupo de capas al hacer clic + Show hover bounds from Layers Mostrar límites al pasar el puntero desde Capas + Saved Camera Raw settings to {file} Ajustes de Camera Raw guardados en {file} + Save the settings to each raw's sidecar without opening it Guardar los ajustes en el archivo sidecar de cada raw sin abrirlo + No settings saved: these raws can't keep a sidecar here No se guardó nada: estos raw no pueden tener un archivo sidecar aquí + Some settings could not be saved; the raws stay open No se pudieron guardar algunos ajustes; los raw siguen abiertos + Nothing to save: these raws are at their default settings Nada que guardar: estos raw tienen los ajustes predeterminados +# Artboard tool options bar (#11385) + Distribute Widths Distribuir anchos + Distribute Heights Distribuir alturas + Auto-nest Layers Anidar capas automáticamente + Auto-size Canvas Ajustar tamaño de lienzo automáticamente + Keep Relative Position During Layer Reordering Mantener la posición relativa al reordenar capas + Shrinkwrap Canvas on Save Reducir el lienzo al contenido al guardar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b08a224b27..eb99627d4f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -87,6 +87,7 @@ Right Oikea Open Recent Viimeksi avatut Clear Recent Files Tyhjennä viimeisimmät tiedostot + Clear Recent File List Tyhjennä viimeisimmät tiedostot Remove from Recent Poista viimeisimmistä Remove {name} from Recent Poista {name} viimeisimmistä Close Sulje @@ -95,6 +96,10 @@ Restore Palauta Close All Sulje kaikki Close Others Sulje muut + New Document… Uusi asiakirja… + Open Document… Avaa… + Consolidate All to Here Kokoa kaikki tähän + Move to New Window Siirrä uuteen ikkunaan Save Tallenna Save As… Tallenna nimellä… Save a Copy… Tallenna kopio… @@ -107,7 +112,6 @@ Export As… Vie nimellä… Export Vie Export… Vie… - Quick Export As PNG Pikavienti PNG-muodossa Quick Export as PNG Pikavienti PNG-muotoon Place Embedded… Sijoita upotettuna… Place Linked… Sijoita linkitettynä… @@ -311,7 +315,6 @@ All Kaikki Deselect Poista valinta Reselect Valitse uudelleen - Inverse Selection Käänteinen valinta Inverse Käännä valinta Color Range… Värialue… Select and Mask… Valitse ja maskaa… @@ -509,7 +512,6 @@ Match Location Täsmäytä sijainti Match Rotation Täsmäytä kierto Match All Täsmäytä kaikki - New Window for Document Uusi ikkuna asiakirjalle Workspace Työtila Extensions Laajennukset Panels Paneelit @@ -539,6 +541,12 @@ Timeline Aikajana Tool Presets Työkalujen esiasetukset Tools Työkalut + Contextual Task Bar Kontekstuaalinen tehtäväpalkki + Hide bar Piilota palkki + Reset bar position Palauta palkin sijainti + Pin bar position Kiinnitä palkin sijainti + Move bar Siirrä palkkia + More options Lisää vaihtoehtoja Libraries Kirjastot 3D 3D Toolbars Työkalurivit @@ -597,8 +605,8 @@ Background Tausta Set foreground color Aseta edustaväri Set background color Aseta taustaväri - Swap colours (X) Vaihda värit (X) - Default colours (D) Oletusvärit (D) + Switch Foreground and Background Colors (X) Vaihda edusta- ja taustaväri keskenään (X) + Default Foreground and Background Colors (D) Oletusarvoiset edusta- ja taustavärit (D) Brush Tool Options Sivellintyökalun asetukset Opacity Peittävyys Flow Virtaus @@ -687,6 +695,7 @@ Units & Rulers… Yksiköt ja viivaimet… Guides, Grid & Slices… Apuviivat, ruudukko ja viipaleet… Plug-ins… Laajennukset… + Plugins… Laajennukset… Technology Previews Teknologian esikatselut Scratch Disks… Työlevyt… Export Preferences… Vientiasetukset… @@ -921,8 +930,8 @@ ACES ACES Industry Standard Alan standardi Web Verkko - Internet Standard RGB Internetin standardi-RGB - Legacy Macintosh RGB Vanha Macintosh RGB + Internet Standard RGB (sRGB) Internetin standardi-RGB (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Vanha Macintosh RGB (Gamma 1.8) Monitor RGB Näytön RGB Windows LCD Windows LCD Coated CMYK CMYK, päällystetty paperi @@ -1210,6 +1219,7 @@ Path to Illustrator… Polku Illustratoriin… Make Work Path Luo työpolku Make Selection Luo valinta + Make Shape Luo muoto Fill Path Täytä polku Stroke Path Piirrä polun reunaviiva Clipping Path Leikkauspolku @@ -1603,7 +1613,7 @@ Revert “{name}” to the last saved version? Your changes will be lost. Palautetaanko ”{name}” viimeksi tallennettuun versioon? Muutokset menetetään. {key} to subtract {key}: vähennä ↑↓ navigate {key} run esc close ↑↓ siirry {key} suorita esc sulje - Click to restore the current colour Palauta nykyinen väri napsauttamalla + Click to restore the current color Palauta nykyinen väri napsauttamalla Click with the Note tool to add a note. Lisää muistiinpano napsauttamalla Muistiinpano-työkalulla. Drag a rectangle around the object Vedä suorakulmio kohteen ympärille Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Lisää rajoite vetämällä kuvassa; Shift vaaka-/pystysuunnalle; muuta tai poista rajoite napsauttamalla sitä hiiren kakkospainikkeella. @@ -1627,6 +1637,7 @@ Move Tool Siirtotyökalu Could not use the move tool because the layer is locked. Siirtotyökalua ei voitu käyttää, koska taso on lukittu. Could not complete the Free Transform command because the layer is locked. Vapaa muunnos -komentoa ei voitu suorittaa, koska taso on lukittu. + Could not transform because the layer is locked. Muuntaminen ei onnistunut, koska taso on lukittu. Rectangular Marquee Tool Suorakulmainen valintakehys Elliptical Marquee Tool Elliptinen valintakehys Single Row Marquee Tool Yhden rivin valintakehys @@ -1684,10 +1695,10 @@ Drag to move the active layer Siirrä aktiivista tasoa vetämällä Drag to pan · hold Space with any tool Panoroi vetämällä · tai pidä Välilyöntiä painettuna millä tahansa työkalulla Drag to draw a gradient Piirrä liukuväri vetämällä - Click to fill similar colours Täytä samankaltaiset värit napsauttamalla + Click to fill similar colors Täytä samankaltaiset värit napsauttamalla Click to add text Lisää teksti napsauttamalla Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Vedä (lasso) tai napsauta pisteitä (monikulmio) · {add} lisää · {sub} vähennä - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Vedä rajauslaatikko · siirrä vetämällä sisältä · reunat muuttavat kokoa ({ratio} suhde, {centre} keskeltä) · Välilyönti siirtää piirrettäessä · nuolet siirtävät · X vaihtaa suunnan · {commit} vahvistaa · Esc peruuttaa + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Vedä rajauslaatikko · siirrä vetämällä sisältä · reunat muuttavat kokoa ({ratio} suhde, {centre} keskeltä) · Välilyönti siirtää piirrettäessä · nuolet siirtävät · X vaihtaa suunnan · {commit} vahvistaa · Esc peruuttaa {mode} Color · {bits} bit {mode}-väri · {bits}-bittinen Drag to move the layer Siirrä tasoa vetämällä Fit Screen Sovita näyttöön @@ -1719,7 +1730,7 @@ Appearance Ulkoasu Text Position: Tekstin sijainti: Text color Tekstin väri - Uses the foreground and background colours. Käyttää edusta- ja taustaväriä. + Uses the foreground and background colors. Käyttää edusta- ja taustaväriä. Answer the unsaved-changes prompt first Vastaa ensin tallentamattomien muutosten kyselyyn Unsaved changes Tallentamattomat muutokset Don't Save Älä tallenna @@ -1739,10 +1750,11 @@ name nimi new uusi Go to Layer Siirry tasoon - Open a document to select a colour range. Avaa asiakirja valitaksesi värialueen. + Open a document to select a color range. Avaa asiakirja valitaksesi värialueen. Saved Tallennettu Reveal in Finder Näytä Finderissa Show in Explorer Näytä Resurssienhallinnassa + Reveal in Explorer Näytä Resurssienhallinnassa Show in Folder Näytä kansiossa Before (Y) Ennen (Y) Save Settings… Tallenna asetukset… @@ -1916,12 +1928,14 @@ Character Styles Panel Merkkityylipaneeli Clear Canvas Guides Poista kankaan apuviivat Clear Selected Artboard Guides Poista valitun piirtoalustan apuviivat - Color Blindness — Deuteranopia-type Värisokeus — deuteranopia - Color Blindness — Protanopia-type Värisokeus — protanopia + Color Blindness - Deuteranopia-type Värisokeus — deuteranopia + Color Blindness - Protanopia-type Värisokeus — protanopia Color Table… Väritaulukko… Combine Shapes Yhdistä muodot Contract… Supista… Convert to Embedded Muunna upotetuksi + Embed Linked Upota linkitetty + Embed All Linked Upota kaikki linkitetyt Convert to Layers Muunna tasoiksi Convert to Linked… Muunna linkitetyksi… Count Laskuri @@ -2009,6 +2023,7 @@ Pixelate Pikselöinti Place Scale Marker… Sijoita mittakaavamerkki… Plug-ins Laajennukset + Plugins Laajennukset Print One Copy Tulosta yksi kopio Print… Tulosta… Range Alue @@ -2053,7 +2068,7 @@ Vertical Centers Pystykeskikohdat Video Frames to Layers… Videoruudut tasoiksi… WIA Support… WIA-tuki… - Working CMY Plate Työ-CMY-levy + Working CMY Plates Työ-CMY-levyt # tl! literals (third pass) Absolute Absoluuttinen @@ -2088,7 +2103,7 @@ Canvas Kangas Capture Kaappaa Center Crop Marks Keskikohdan rajausmerkit - Centre the gradient (offset 0, 0) Keskitä liukuväri (siirtymä 0, 0) + Center the gradient (offset 0, 0) Keskitä liukuväri (siirtymä 0, 0) Change Screen Mode (F) Vaihda näyttötilaa (F) Change To: Muuta muotoon: Clear All Tyhjennä kaikki @@ -2240,7 +2255,7 @@ Roughness Karheus Roundness Pyöreys Rulers & Grids Viivaimet ja ruudukot - Sample: foreground colour Näyte: edustaväri + Sample: foreground color Näyte: edustaväri Sampling: Background Swatch Näytteistys: taustan värimalli Sampling: Continuous Näytteistys: jatkuva Sampling: Once Näytteistys: kerran @@ -2339,6 +2354,11 @@ Show canvas dots Näytä kankaan pisteet Clip thumbnails to layer bounds Rajaa pikkukuvat tason rajoihin Clip Thumbnails to Layer Bounds Rajaa pikkukuvat tason rajoihin + No Thumbnails Ei pikkukuvia + Small Thumbnails Pienet pikkukuvat + Medium Thumbnails Keskikokoiset pikkukuvat + Large Thumbnails Suuret pikkukuvat + Clip Thumbnails to Document Bounds Rajaa pikkukuvat asiakirjan rajoihin Canvas color Kankaan väri Canvas custom color Kankaan mukautettu väri Classic Klassinen @@ -2465,7 +2485,6 @@ Show only crosshair while painting Näytä vain hiusristikko maalattaessa Show slice numbers Näytä viipaleiden numerot Show tooltips Näytä työkaluvihjeet - Enable animations Ota animaatiot käyttöön Show highlight on rollover Korosta kohde osoittimen alla Show bounding box when dragging layer Näytä rajauslaatikko tasoa vedettäessä System title bar Järjestelmän otsikkorivi @@ -2509,7 +2528,7 @@ Alt-click to define a source point to clone from Määritä kloonauksen lähdepiste Alt-napsautuksella No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Ei polkua, josta luoda valinta: piirrä polku kynätyökalulla tai valitse polku Polut-paneelissa. Cancel opening Peruuta avaaminen - Cancelled opening {name} Tiedoston {name} avaaminen peruutettu + Canceled opening {name} Tiedoston {name} avaaminen peruutettu Damaged Photoshop document Vioittunut Photoshop-asiakirja The document “{name}” may be damaged. Asiakirja ”{name}” saattaa olla vioittunut. Some layers, pixels or masks could not be read. Do you want to continue opening it? Joitakin tasoja, pikseleitä tai maskeja ei voitu lukea. Haluatko jatkaa avaamista? @@ -2519,7 +2538,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Asiakirjassa ”{name}” on upotettu väriprofiili, joka ei vastaa nykyistä {mode}-työavaruutta. Working: {working} Työavaruus: {working} Developing {name} Kehitetään tiedostoa {name} - Cancelled {label} Peruutettu: {label} + Canceled {label} Peruutettu: {label} Esc to cancel Peruuta painamalla Esc Opened {name} Avattu {name} Opening {name} Avataan tiedostoa {name} @@ -2527,7 +2546,7 @@ Saving {name} Tallennetaan tiedostoa {name} Saving {percent} Tallennetaan {percent} A save to {name} is still running; try again when it finishes Tallennus tiedostoon {name} on vielä käynnissä; yritä uudelleen sen päätyttyä - {label} is writing the file and can no longer be cancelled {label}: tiedostoa kirjoitetaan, eikä toimintoa voi enää peruuttaa + {label} is writing the file and can no longer be canceled {label}: tiedostoa kirjoitetaan, eikä toimintoa voi enää peruuttaa Opening {name}… Avataan tiedostoa {name}… Opening… Avataan… Reading… Luetaan… @@ -2624,7 +2643,7 @@ cameraRaw Roughness Karheus Discard Layers and Save a Copy Hylkää tasot ja tallenna kopio Don't show again Älä näytä uudelleen Save failed: {error} Tallennus epäonnistui: {error} - Save cancelled Tallennus peruutettu + Save canceled Tallennus peruutettu Couldn't place {name}: {error} Kohteen {name} sijoittaminen epäonnistui: {error} Magnetic Lasso Tool Magneettinen lassotyökalu Frequency Taajuus @@ -2768,7 +2787,7 @@ fontWeight Italic Kursivoitu Impressionist Impressionistinen Rotate View Tool Näkymän kiertotyökalu Reset View Palauta näkymä - Drag around the centre to rotate the view · Shift constrains to 15° Kierrä näkymää vetämällä keskipisteen ympäri · Shift rajoittaa 15°:n välein + Drag around the center to rotate the view · Shift constrains to 15° Kierrä näkymää vetämällä keskipisteen ympäri · Shift rajoittaa 15°:n välein # Painting symmetry presets. Dual Axis Kaksi akselia @@ -2788,11 +2807,11 @@ fontWeight Italic Kursivoitu All Layers No Adjustments Kaikki tasot ilman säätöjä Current & Below No Adjustments Nykyinen ja alemmat ilman säätöjä Show Sampling Ring Näytä näytteenottorengas - Click to sample the foreground colour · {key}-click for background Ota edustavärin näyte napsauttamalla · {key}-napsautus taustavärille + Click to sample the foreground color · {key}-click for background Ota edustavärin näyte napsauttamalla · {key}-napsautus taustavärille # Color Picker warnings - Out of gamut for printing: click for the nearest printable colour Tulostusvärialueen ulkopuolella: napsauta valitaksesi lähimmän tulostettavan värin - Not a web colour: click for the nearest web colour Ei verkkoväri: napsauta valitaksesi lähimmän verkkovärin + Out of gamut for printing: click for the nearest printable color Tulostusvärialueen ulkopuolella: napsauta valitaksesi lähimmän tulostettavan värin + Not a web color: click for the nearest web color Ei verkkoväri: napsauta valitaksesi lähimmän verkkovärin Color to Alpha… Väri alfaksi… Transparency Threshold Läpinäkyvyyskynnys Opacity Threshold Peittävyyskynnys @@ -3200,6 +3219,8 @@ curves Smooth Tasoita Step {step} is waiting for its dialog Vaihe {step} odottaa valintaikkunaansa @plural {n} action|{n} actions {n} toiminto|{n} toimintoa Background (Default) Tausta (oletus) + Background (default) Tausta (oletus) + Transparent (default) Läpinäkyvä (oletus) Content-Aware Fill Sisältötietoinen täyttö # Batch / Image Processor Include All Subfolders Sisällytä kaikki alikansiot @@ -3208,7 +3229,7 @@ curves Smooth Tasoita # Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Älä valitse myös Peilikuva-asetusta kohdassa Tulostusasetukset…: kaksi peilausta kumoaa toisensa. Print Settings… Tulostusasetukset… - Remember to turn off the printer's own colour management in Print Settings… Muista poistaa tulostimen oma värinhallinta käytöstä kohdassa Tulostusasetukset… + Remember to turn off the printer's own color management in Print Settings… Muista poistaa tulostimen oma värinhallinta käytöstä kohdassa Tulostusasetukset… Sent to {printer} Lähetetty tulostimelle {printer} set in Print Settings… asetetaan kohdassa Tulostusasetukset… ICO is limited to 256 × 256 pixels ICO-tiedosto on rajoitettu kokoon 256 × 256 pikseliä @@ -3872,12 +3893,12 @@ curves Smooth Tasoita In White Tulon valkoinen Increase Brush Hardness Lisää siveltimen kovuutta Demosaic Mosaiikin purku - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Miten kameran värisuodatinmosaiikki täydennetään. Menetelmä otetaan käyttöön tiedostoa avattaessa; esikatselu näyttää menetelmän, jolla tiedosto avattiin. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Miten kameran värisuodatinmosaiikki täydennetään. Menetelmä otetaan käyttöön tiedostoa avattaessa; esikatselu näyttää menetelmän, jolla tiedosto avattiin. AHD (adaptive homogeneity) AHD (adaptiivinen homogeenisuus) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: siisteimmät reunat ja vähiten värireunuksia, hitain. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradienttikorjattu interpolointi: terävä ja nopea, hienoissa yksityiskohdissa jonkin verran värireunuksia. - The fastest and the softest, with colour fringing on fine detail. Nopein ja pehmein, hienoissa yksityiskohdissa värireunuksia. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: siisteimmät reunat ja vähiten värireunuksia, hitain. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradienttikorjattu interpolointi: terävä ja nopea, hienoissa yksityiskohdissa jonkin verran värireunuksia. + The fastest and the softest, with color fringing on fine detail. Nopein ja pehmein, hienoissa yksityiskohdissa värireunuksia. “{name}” already exists. Replace it? ”{name}” on jo olemassa. Korvataanko se? The name in the save dialog was “{name}”. Tallennusikkunassa nimi oli ”{name}”. Move Up Siirrä ylös @@ -4528,7 +4549,6 @@ curves Smooth Tasoita Use File › Open to open an image. Avaa kuva komennolla Tiedosto › Avaa. Use Selection In Source Käytä valintaa lähteessä Use Selection In Target Käytä valintaa kohteessa - Uses the foreground and background colors. Käyttää edusta- ja taustaväriä. Vanishing Point Pakopiste Variable Data Sets Muuttuvat tietojoukot Vector Mask Hides Effects Vektorimaski piilottaa tehosteet @@ -4738,8 +4758,8 @@ cameraRaw Camera Raw ({name}) Camera Raw ({name}) cameraRaw Camera Raw Filter ({layer}) Camera Raw -suodatin ({layer}) cameraRaw Camera Raw isn't open Camera Raw ei ole auki cameraRaw Cancel Peruuta -cameraRaw Cancelled opening {name} Tiedoston {name} avaaminen peruutettu -cameraRaw Cancelled {label} Peruutettu: {label} +cameraRaw Canceled opening {name} Tiedoston {name} avaaminen peruutettu +cameraRaw Canceled {label} Peruutettu: {label} cameraRaw Color Priority Väriprioriteetti cameraRaw Developing {name} Kehitetään tiedostoa {name} cameraRaw Full-resolution preview unavailable Täyden resoluution esikatselu ei ole käytettävissä @@ -4774,3 +4794,39 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen The path was discarded Polku hylättiin The path was kept as the Work Path Polku säilytettiin työpolkuna Edit Brush… Muokkaa sivellintä… + +# Action Stop message (#8386) + Message Viesti +# Playback Options (#5212) + Playback Options… Toistoasetukset… + Playback Options Toistoasetukset + Accelerated Kiihdytetty + Step by Step Vaihe kerrallaan + Pause For: Tauko: + Pause For Tauko + seconds sekuntia + Create a single history state Luo yksi historiatila + +# Channels panel menu (#11451) + Channel Options… Kanavan asetukset… + Specify Channels: Määritä kanavat: + Not enough grayscale documents for this mode. Harmaasävyasiakirjoja ei ole tarpeeksi tähän tilaan. + Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja. + A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki. + A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja. +# Move tool options bar (#11381) + Align top edges Tasaa yläreunat + Align vertical centers Tasaa pystykeskikohdat + Align bottom edges Tasaa alareunat + Distribute vertically Jaa pystysuunnassa + Distribute horizontally Jaa vaakasuunnassa + Show layer bounds on hover Näytä tason rajat osoitettaessa + Expand layer group on click Laajenna tasoryhmä napsautuksella + Show hover bounds from Layers Näytä rajat osoitettaessa Tasot-paneelista +# Artboard tool options bar (#11385) + Distribute Widths Jaa leveydet tasaisesti + Distribute Heights Jaa korkeudet tasaisesti + Auto-nest Layers Sisäkkäistä tasot automaattisesti + Auto-size Canvas Muuta kankaan kokoa automaattisesti + Keep Relative Position During Layer Reordering Säilytä suhteellinen sijainti tasojen järjestystä muutettaessa + Shrinkwrap Canvas on Save Pienennä kangas sisältöön tallennettaessa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 89bdec45d4..c0a008b3e4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -171,6 +171,7 @@ Right Droite Open Recent Ouvrir un fichier récent Clear Recent Files Effacer la liste des fichiers récents + Clear Recent File List Effacer la liste des fichiers récents Remove from Recent Retirer des fichiers récents Remove {name} from Recent Retirer {name} des fichiers récents Close Fermer @@ -179,6 +180,10 @@ Restore Restaurer Close All Tout fermer Close Others Fermer les autres + New Document… Nouveau document… + Open Document… Ouvrir… + Consolidate All to Here Tout regrouper ici + Move to New Window Déplacer vers une nouvelle fenêtre Save Enregistrer Save As… Enregistrer sous… Save a Copy… Enregistrer une copie… @@ -231,6 +236,7 @@ Create Smart Object after Loading Layers Créer un objet intelligent après le chargement des calques Reveal in Finder Afficher dans le Finder Show in Explorer Afficher dans l'Explorateur + Reveal in Explorer Afficher dans l'Explorateur Show in Folder Afficher dans le dossier File Info… Informations sur le fichier… Print… Imprimer… @@ -317,6 +323,7 @@ Units & Rulers… Unités et règles… Guides, Grid & Slices… Repères, grille et tranches… Plug-ins… Extensions… + Plugins… Extensions… Type… Texte… Enhanced Controls… Commandes avancées… Camera Raw… Camera Raw… @@ -365,6 +372,8 @@ Match Color… Faire correspondre la couleur… Replace Color… Remplacer la couleur… Equalize Égaliser + Equalize Selected Area Only Égaliser uniquement la sélection + Equalize Entire Image Based On Selected Area Égaliser toute l'image d'après la sélection Auto Tone Tonalité automatique Auto Contrast Contraste automatique Auto Color Couleur automatique @@ -466,6 +475,8 @@ Smart Object Objet intelligent Convert to Smart Object Convertir en objet intelligent Convert to Embedded Convertir en objet incorporé + Embed Linked Incorporer le lié + Embed All Linked Incorporer tous les liés Convert to Layers Convertir en calques Convert to Linked… Convertir en objet lié… Edit Contents Modifier le contenu @@ -610,7 +621,6 @@ Deselect Désélectionner Reselect Resélectionner Inverse Inverser - Inverse Selection Inverser la sélection Similar Similaire Color Range… Plage de couleurs… Focus Area… Zone de mise au point… @@ -718,6 +728,7 @@ Minimum… Minimum… Offset… Décalage… Plug-ins Extensions + Plugins Extensions Proof Setup Configuration d'épreuve Proof Colors Couleurs d'épreuve Gamut Warning Alerte de gamut @@ -726,12 +737,12 @@ Working Magenta Plate Plaque magenta de travail Working Yellow Plate Plaque jaune de travail Working Black Plate Plaque noire de travail - Working CMY Plate Plaque CMJ de travail - Legacy Macintosh RGB RVB Macintosh (ancien) - Internet Standard RGB RVB standard d'Internet + Working CMY Plates Plaques CMJ de travail + Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh (ancien) (Gamma 1.8) + Internet Standard RGB (sRGB) RVB standard d'Internet (sRGB) Monitor RGB RVB du moniteur - Color Blindness — Protanopia-type Daltonisme — type protanopie - Color Blindness — Deuteranopia-type Daltonisme — type deutéranopie + Color Blindness - Protanopia-type Daltonisme - type protanopie + Color Blindness - Deuteranopia-type Daltonisme - type deutéranopie Zoom In Zoom avant Zoom Out Zoom arrière Fit on Screen Adapter à l'écran @@ -820,7 +831,6 @@ Consolidate All to Tabs Tout regrouper dans des onglets Float in Window Flottante dans une fenêtre Float All in Windows Toutes flottantes dans des fenêtres - New Window for Document Nouvelle fenêtre pour le document Workspace Espace de travail Essentials Essentiel Graphic and Web Graphisme et Web @@ -882,6 +892,12 @@ Timeline Montage Tool Presets Préréglages d'outils Tools Outils + Contextual Task Bar Barre des tâches contextuelle + Hide bar Masquer la barre + Reset bar position Réinitialiser la position de la barre + Pin bar position Épingler la position de la barre + Move bar Déplacer la barre + More options Plus d'options About PhotoCraft À propos de PhotoCraft PhotoCraft Website Site web de PhotoCraft PhotoCraft on GitHub PhotoCraft sur GitHub @@ -1045,7 +1061,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Cliquez pour ajouter un point · faites-le glisser hors du cadre, {key}-clic ou Suppr pour le retirer Click to apply, {key}-click to clear overrides, double-click for Style Options Cliquez pour appliquer, {key}-clic pour effacer les substitutions, double-clic pour les options de style Click to edit the gradient Cliquez pour modifier le dégradé - Click to restore the current colour Cliquez pour rétablir la couleur actuelle + Click to restore the current color Cliquez pour rétablir la couleur actuelle Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Cliquez pour zoomer · {key}-clic pour dézoomer · faites glisser vers la droite/gauche pour zoomer/dézoomer Click with the Note tool to add a note. Cliquez avec l'outil Note pour ajouter une note. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic : angle · Glisser : lisse · Clic sur le premier point : fermer · {key} : terminer · Échap : annuler @@ -1128,7 +1144,7 @@ Darks Tons sombres Data Sets Jeux de données Default Par défaut - Default colours (D) Couleurs par défaut (D) + Default Foreground and Background Colors (D) Couleurs par défaut (D) Default printer Imprimante par défaut Default restored. Valeurs par défaut rétablies. Define Définir @@ -1405,7 +1421,7 @@ Opacity Jitter Variation de l'opacité Opacity: Opacité : Open a document to define variables. Ouvrez un document pour définir des variables. - Open a document to select a colour range. Ouvrez un document pour sélectionner une plage de couleurs. + Open a document to select a color range. Ouvrez un document pour sélectionner une plage de couleurs. Open a document to use the timeline. Ouvrez un document pour utiliser le montage. Optical Optique Optimized Optimisé @@ -1481,7 +1497,6 @@ Purity Pureté Quality Qualité Quick Actions Actions rapides - Quick Export As PNG Exportation rapide au format PNG Radial Gradient Dégradé radial Radius Rayon Radius: Rayon : @@ -1622,7 +1637,7 @@ Subtract from Sample Soustraire de l'échantillon Subtract from selection ({key}) Soustraire de la sélection ({key}) Suggestions: Suggestions : - Swap colours (X) Permuter les couleurs (X) + Switch Foreground and Background Colors (X) Permuter les couleurs (X) Swaps height and width Permute la hauteur et la largeur Switch between free transform and warp modes Basculer entre les modes transformation libre et déformation Switch theme Changer de thème @@ -1724,7 +1739,7 @@ Choke Resserrement Classic Classique Click to add text Cliquez pour ajouter du texte - Click to fill similar colours Cliquez pour remplir les couleurs similaires + Click to fill similar colors Cliquez pour remplir les couleurs similaires Clone Stamp Tool Outil Tampon de clonage Color Burn Densité couleur + Color Dodge Densité couleur − @@ -1825,6 +1840,7 @@ Move Tool Outil Déplacement Could not use the move tool because the layer is locked. Impossible d'utiliser l'outil Déplacement, car le calque est verrouillé. Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation libre, car le calque est verrouillé. + Could not transform because the layer is locked. Impossible de transformer, car le calque est verrouillé. Multiply Produit My Brushes Mes pinceaux Neutrals Neutres @@ -1883,7 +1899,7 @@ Rectangular Marquee Tool Outil Sélection rectangulaire Reds Rouges Reflected Réfléchi - Sample: foreground colour Échantillon : couleur de premier plan + Sample: foreground color Échantillon : couleur de premier plan Sampled Colors Couleurs échantillonnées Satin Satin Save Pixel Aspect Ratio Enregistrer le format des pixels @@ -1904,6 +1920,7 @@ Soften Adoucir Source Source Source: set Source : définie + Source: {name} Source : {name} Sponge Tool Outil Éponge Spot Healing Brush Tool Outil Correcteur localisé Spread Grossissement @@ -1921,7 +1938,6 @@ Transfer Transfert Triangle Tool Outil Triangle Unsaved changes Modifications non enregistrées - Uses the foreground and background colours. Utilise les couleurs de premier plan et d'arrière-plan. Web Web Web Large Web (grand) Web Minimum Web (minimum) @@ -1956,6 +1972,7 @@ Allow scripts to connect Autoriser la connexion des scripts Always Toujours Always create smart objects when placing Toujours créer des objets intelligents lors du placement + Skip transform when placing Ignorer la transformation lors du placement Animated zoom Zoom animé Apply auto tone Appliquer la tonalité automatique Ask Demander @@ -1978,6 +1995,11 @@ Show canvas dots Afficher les points du canevas Clip thumbnails to layer bounds Rogner les vignettes aux limites du calque Clip Thumbnails to Layer Bounds Rogner les vignettes aux limites du calque + No Thumbnails Aucune vignette + Small Thumbnails Petites vignettes + Medium Thumbnails Vignettes moyennes + Large Thumbnails Grandes vignettes + Clip Thumbnails to Document Bounds Rogner les vignettes aux limites du document Canvas color Couleur du canevas Canvas custom color Couleur personnalisée du canevas Color picker Sélecteur de couleurs @@ -2095,7 +2117,6 @@ Show only crosshair while painting N'afficher que le réticule pendant la peinture Show slice numbers Afficher les numéros de tranche Show tooltips Afficher les info-bulles - Enable animations Activer les animations Show highlight on rollover Afficher la mise en surbrillance au survol Show transformation values Afficher les valeurs de transformation Slice color Couleur des tranches @@ -2185,7 +2206,7 @@ Graphics Graphismes Reset GPU Backend Réinitialiser le moteur GPU Applies at next launch. Prend effet au prochain démarrage. - Click to sample the foreground colour · {key}-click for background Cliquez pour prélever la couleur de premier plan · {key}-clic pour l'arrière-plan + Click to sample the foreground color · {key}-click for background Cliquez pour prélever la couleur de premier plan · {key}-clic pour l'arrière-plan {key}-click to set the source {key}-clic pour définir la source {key} to subtract {key} pour soustraire Clone source {n} ({key}-click with Clone Stamp to set) Source de clonage {n} ({key}-clic avec le tampon de clonage pour la définir) @@ -2243,7 +2264,7 @@ Gradient Options Options de dégradé Align with layer Aligner sur le calque Reset Alignment Réinitialiser l'alignement - Centre the gradient (offset 0, 0) Centrer le dégradé (décalage 0, 0) + Center the gradient (offset 0, 0) Centrer le dégradé (décalage 0, 0) Gradient Editor Éditeur de dégradé Gradient picker Sélecteur de dégradé Save As Preset Enregistrer comme préreglage @@ -2276,7 +2297,7 @@ Alt-click to define a source point to clone from Alt-clic pour définir le point source du clonage No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Aucun tracé à partir duquel créer une sélection : dessinez-en un avec l'outil Plume ou sélectionnez-en un dans le panneau Tracés. Cancel opening Annuler l'ouverture - Cancelled opening {name} Ouverture de {name} annulée + Canceled opening {name} Ouverture de {name} annulée Damaged Photoshop document Document Photoshop endommagé The document “{name}” may be damaged. Le document « {name} » est peut-être endommagé. Some layers, pixels or masks could not be read. Do you want to continue opening it? Certains calques, pixels ou masques n’ont pas pu être lus. Voulez-vous poursuivre l’ouverture ? @@ -2286,7 +2307,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Le document « {name} » a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail {mode} actuel. Working: {working} Espace de travail : {working} Developing {name} Développement de {name} - Cancelled {label} {label} : annulé + Canceled {label} {label} : annulé Esc to cancel Échap pour annuler Opened {name} Ouvert : {name} Opening {name} Ouverture de {name} @@ -2294,7 +2315,7 @@ Saving {name} Enregistrement de {name} Saving {percent} Enregistrement {percent} A save to {name} is still running; try again when it finishes Un enregistrement de {name} est encore en cours ; réessayez lorsqu’il sera terminé - {label} is writing the file and can no longer be cancelled {label} écrit le fichier et ne peut plus être annulé + {label} is writing the file and can no longer be canceled {label} écrit le fichier et ne peut plus être annulé Opening {name}… Ouverture de {name}… Opening… Ouverture… Reading… Lecture… @@ -2479,6 +2500,7 @@ Path to Illustrator… Tracé vers Illustrator… Make Work Path Créer un tracé de travail Make Selection Créer une sélection + Make Shape Créer une forme Fill Path Remplir le tracé Stroke Path Contourner le tracé Clipping Path Tracé de détourage @@ -2659,7 +2681,7 @@ Pixel Pixel Adjustment Ajustement Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Faites glisser (lasso) ou cliquez des points (polygonal) · {add} ajouter · {sub} soustraire - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · flèches le décalent · X permute l'orientation · {commit} valide · Échap annule + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · flèches le décalent · X permute l'orientation · {commit} valide · Échap annule {mode} Color · {bits} bit Couleurs {mode} · {bits} bits Drag to move the layer Faites glisser pour déplacer le calque Scrubby Zoom Zoom par glissement @@ -2853,7 +2875,7 @@ cameraRaw Blending Fusion Discard Layers and Save a Copy Supprimer les calques et enregistrer une copie Don't show again Ne plus afficher Save failed: {error} Échec de l'enregistrement : {error} - Save cancelled Enregistrement annulé + Save canceled Enregistrement annulé Couldn't place {name}: {error} Impossible d'importer {name} : {error} Magnetic Lasso Tool Outil Lasso magnétique Frequency Fréquence @@ -2982,7 +3004,7 @@ fontWeight Italic Italique Impressionist Impressionniste Rotate View Tool Outil Rotation de la vue Reset View Réinitialiser la vue - Drag around the centre to rotate the view · Shift constrains to 15° Faites glisser autour du centre pour tourner la vue · Maj contraint à 15° + Drag around the center to rotate the view · Shift constrains to 15° Faites glisser autour du centre pour tourner la vue · Maj contraint à 15° Dual Axis Deux axes Diagonal Diagonale Transform Symmetry Transformer la symétrie @@ -2997,8 +3019,8 @@ fontWeight Italic Italique All Layers No Adjustments Tous les calques sans ajustements Current & Below No Adjustments Actif et inférieurs sans ajustements Show Sampling Ring Afficher l'anneau d'échantillonnage - Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche - Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche + Out of gamut for printing: click for the nearest printable color Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche + Not a web color: click for the nearest web color Pas une couleur web : cliquez pour la couleur web la plus proche Color to Alpha… Couleur vers alpha… Transparency Threshold Seuil de transparence Opacity Threshold Seuil d'opacité @@ -3288,12 +3310,14 @@ curves Smooth Lisser Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue @plural {n} action|{n} actions {n} action|{n} actions Background (Default) Arrière-plan (par défaut) + Background (default) Arrière-plan (par défaut) + Transparent (default) Transparent (par défaut) Content-Aware Fill Remplissage d'après le contenu Include All Subfolders Inclure tous les sous-dossiers Auto Add/Delete Ajout/suppr. auto Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Ne cochez pas aussi Image miroir dans les paramètres d’impression : les deux inversions s’annulent. Print Settings… Paramètres d’impression… - Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… + Remember to turn off the printer's own color management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… Sent to {printer} Envoyé à {printer} set in Print Settings… défini dans les paramètres d’impression… ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels @@ -3341,6 +3365,7 @@ curves Smooth Lisser Automatically Check for Updates Rechercher automatiquement des mises à jour Paints from the History panel's source state Peint à partir de l'état source du panneau Historique Sets the source for the history brush Définit la source du pinceau d'historique + The History Brush source was deleted: set a new source in the History panel La source du pinceau d'historique a été supprimée : définissez une nouvelle source dans le panneau Historique Highlight Mode Mode des tons clairs Highlight Color Couleur des tons clairs Highlight Opacity Opacité des tons clairs @@ -3425,12 +3450,12 @@ curves Smooth Lisser Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau Demosaic Dématriçage - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Comment la mosaïque du filtre couleur de l'appareil est complétée. Appliqué à l'ouverture du fichier ; l'aperçu montre la méthode avec laquelle il a été ouvert. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Comment la mosaïque du filtre couleur de l'appareil est complétée. Appliqué à l'ouverture du fichier ; l'aperçu montre la méthode avec laquelle il a été ouvert. AHD (adaptive homogeneity) AHD (homogénéité adaptative) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks : les bords les plus nets et le moins de franges colorées ; le plus lent. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolation corrigée par gradient : nette et rapide, avec quelques franges colorées sur les détails fins. - The fastest and the softest, with colour fringing on fine detail. Le plus rapide et le plus doux, avec des franges colorées sur les détails fins. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks : les bords les plus nets et le moins de franges colorées ; le plus lent. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolation corrigée par gradient : nette et rapide, avec quelques franges colorées sur les détails fins. + The fastest and the softest, with color fringing on fine detail. Le plus rapide et le plus doux, avec des franges colorées sur les détails fins. “{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ? The name in the save dialog was “{name}”. Le nom dans la boîte de dialogue d'enregistrement était « {name} ». Previous Brush Pinceau précédent @@ -4533,6 +4558,8 @@ shape Appearance Apparence Font size presets Tailles de police prédéfinies Leading presets Interlignages prédéfinis Tracking presets Approches prédéfinies + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrée numérique non valide. Un entier compris entre {min}% et {max}% est requis. La valeur précédente a été insérée. Color Aware Selon la couleur Decontaminate Colors Décontaminer les couleurs @@ -4556,3 +4583,70 @@ shape Appearance Apparence Stop Color Couleur du point Shapes can't use patterns yet Les formes ne peuvent pas encore utiliser de motifs Edit Brush… Modifier le pinceau… + +# Action Stop message (#8386) + Message Message +# Playback Options (#5212) + Playback Options… Options d'exécution… + Playback Options Options d'exécution + Accelerated Accélérée + Step by Step Pas à pas + Pause For: Pause de : + Pause For Pause de + seconds secondes + Create a single history state Créer un seul état d'historique + Generator enabled Générateur activé + Move Up Déplacer vers le haut + Move Down Déplacer vers le bas + Hidden Masqué + On your computer Sur votre ordinateur + New file Nouveau fichier + Learn Apprendre + Website Site web + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Un cadre accepte les transformations affines ; sélectionnez son image pour les transformations de perspective + A frame supports affine transforms; select its image to warp it Un cadre accepte les transformations affines ; sélectionnez son image pour la déformer + Convert to Frame… Convertir en cadre… + Create a new custom frame Créer un cadre personnalisé + Create a new polygonal frame Créer un cadre polygonal + Create a new triangular frame Créer un cadre triangulaire + Fit Content to Frame Adapter le contenu au cadre + Fit Frame to Content Adapter le cadre au contenu + Frame coordinates must be finite and within 600000 pixels Les coordonnées du cadre doivent être finies et comprises dans une limite de 600000 pixels + Import Image… Importer une image… + Invalid frame transform Transformation du cadre non valide + Invalid nested file drop Dépôt de fichier imbriqué non valide + New Frame Nouveau cadre + Replace Image… Remplacer l’image… + The frame is locked Le cadre est verrouillé + The frame is unavailable Le cadre n’est pas disponible + +# Channels panel menu (#11451) + Channel Options… Options de canal… + Specify Channels: Indiquer les canaux : + Not enough grayscale documents for this mode. Pas assez de documents en niveaux de gris pour ce mode. + Pick a different document for each channel. Choisissez un document différent pour chaque canal. + A picked document is no longer open. Un des documents choisis n'est plus ouvert. + A picked document is no longer a grayscale document of the same size. Un des documents choisis n'est plus un document en niveaux de gris de même taille. +# Move tool options bar (#11381) + Align top edges Aligner les bords supérieurs + Align vertical centers Aligner les centres verticaux + Align bottom edges Aligner les bords inférieurs + Distribute vertically Répartir verticalement + Distribute horizontally Répartir horizontalement + Show layer bounds on hover Afficher les limites du calque au survol + Expand layer group on click Développer le groupe de calques au clic + Show hover bounds from Layers Afficher les limites au survol depuis Calques + Saved Camera Raw settings to {file} Réglages Camera Raw enregistrés dans {file} + Save the settings to each raw's sidecar without opening it Enregistrer les réglages dans le fichier annexe de chaque raw sans l’ouvrir + No settings saved: these raws can't keep a sidecar here Rien n’a été enregistré : ces raw ne peuvent pas avoir de fichier annexe ici + Some settings could not be saved; the raws stay open Certains réglages n’ont pas pu être enregistrés ; les raw restent ouverts + Nothing to save: these raws are at their default settings Rien à enregistrer : ces raw ont leurs réglages par défaut +# Artboard tool options bar (#11385) + Distribute Widths Répartir les largeurs + Distribute Heights Répartir les hauteurs + Auto-nest Layers Imbriquer les calques automatiquement + Auto-size Canvas Redimensionner le canevas automatiquement + Keep Relative Position During Layer Reordering Conserver la position relative lors de la réorganisation des calques + Shrinkwrap Canvas on Save Réduire le canevas au contenu à l'enregistrement diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv new file mode 100644 index 0000000000..d38b62ed3a --- /dev/null +++ b/crates/ui-egui/src/i18n/he.tsv @@ -0,0 +1,4574 @@ +# Hebrew (he) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# modern Hebrew computing and photo-editing vocabulary (שכבה, מברשת, בחירה, קנבס, מסכה, ערוץ …). No +# proprietary localisation resource, string table or product UI was consulted or copied. Product and +# technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab, GPU …) and key names stay in Latin +# letters; the trailing "…" of dialog commands is kept. Plural rule: Hebrew distinguishes one/two/ +# many/other in formal grammar, but everyday Hebrew software UI collapses this to a simple +# singular/plural (one|other, `plural_one_other`), the same simplification Farsi and English use +# here. Reviewed for consistency by an AI model; native-speaker review of the specialist terms is +# welcome. +# Machine-translated with Claude (Anthropic) for @tronicum; not yet reviewed by a native speaker. + Mask Options… אפשרויות מסכה… + Layer Mask Display Options… אפשרויות תצוגת מסכת שכבה… + Channel Color צבע ערוץ + Color Indicates: הצבע מציין: + Solidity: אטימות: + Masked Areas אזורים ממוסכים + Selected Areas אזורים נבחרים + Ink Characteristics מאפייני דיו + Paper Width רוחב נייר + Paper Height גובה נייר + Columns עמודות + Rows שורות + Number מספר + Margin שוליים + Top: עליון: + Left: שמאל: + Bottom: תחתון: + Right: ימין: + Target: יעד: + Selected Artboards לוחות ציור נבחרים + Center Columns מרכוז עמודות + Clear Existing Guides נקה קווים מנחים קיימים + Invalid guide layout פריסת קווים מנחים לא תקינה + Leave empty for automatic size השאר ריק לגודל אוטומטי + 5 Columns, 3 Rows 5 עמודות, 3 שורות + 8 Columns 8 עמודות + 12 Columns 12 עמודות + File קובץ + Edit עריכה + Image תמונה + Layer שכבה + Type טקסט + Select בחירה + Selected נבחר + Scope היקף + Top Left שמאל עליון + Top Right ימין עליון + Bottom Left שמאל תחתון + Bottom Right ימין תחתון + Filter סינון + View תצוגה + Window חלון + Help עזרה + (coming soon) (בקרוב) + New… חדש… + New from Clipboard חדש מלוח הגזירים + Open… פתח… + Open As… פתח בתור… + Open EXR Parts… פתח חלקי EXR… + Add beauty precomp from the AOVs הוסף הרכבה (beauty) מראש מתוך AOVs + Both שניהם + Left שמאל + Right ימין + Open Recent פתח אחרונים + Clear Recent Files נקה קבצים אחרונים + Remove from Recent הסר מהרשימה האחרונה + Remove {name} from Recent הסר את {name} מהרשימה האחרונה + Close סגור + Minimize מזער + Maximize הגדל למסך מלא + Restore שחזר + Close All סגור הכול + Close Others סגור את האחרים + Save שמור + Save As… שמור בשם… + Save a Copy… שמור עותק… + {name} can't keep everything in this document {name} אינו יכול לשמר את כל התוכן במסמך זה + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. בחר תבנית ששומרת זאת, כגון Photoshop ‏(PSD), או שמור עותק. + Revert חזור למצב השמור + Export ייצוא + Quick Export as PNG ייצוא מהיר כ-PNG + Export As… ייצוא בשם… + Export Preferences… העדפות ייצוא… + Save for Web (Legacy)… שמור לאינטרנט (ישן)… + Artboards to Files… לוחות ציור לקבצים… + Artboards to PDF… לוחות ציור ל-PDF… + Layers to Files… שכבות לקבצים… + Layer Comps to Files… קומפים של שכבות לקבצים… + Color Lookup Tables… טבלאות חיפוש צבע… + Data Sets as Files… ערכות נתונים כקבצים… + Paths to Illustrator… נתיבים ל-Illustrator… + Render Video… עיבוד וידאו… + Generate יצירה + Image Assets נכסי תמונה + Place Embedded… הצבה מוטבעת… + Place Linked… הצבה מקושרת… + Package… אריזה… + Automate אוטומציה + Batch… אצווה… + Create Droplet… יצירת Droplet… + Crop and Straighten Photos חיתוך ויישור תמונות + Contact Sheet II… גיליון מגע II… + Conditional Mode Change… שינוי מצב מותנה… + Fit Image… התאמת תמונה… + Lens Correction… תיקון עדשה… + Merge to HDR Pro… מיזוג ל-HDR Pro… + Photomerge… מיזוג תמונות (Photomerge)… + Scripts סקריפטים + Image Processor… מעבד תמונות… + Delete All Empty Layers מחק את כל השכבות הריקות + Flatten All Layer Effects שטח את כל אפקטי השכבה + Flatten All Masks שטח את כל המסכות + Script Events Manager… מנהל אירועי סקריפט… + Import ייבוא + Browse… עיון… + Source Files קובצי מקור + Use: השתמש ב: + Files קבצים + Folder תיקייה + Add Open Files הוסף קבצים פתוחים + Sort By Name מיין לפי שם + Attempt to Automatically Align Source Images נסה ליישר אוטומטית את תמונות המקור + Create Smart Object after Loading Layers צור אובייקט חכם לאחר טעינת השכבות + Reveal in Finder הצג ב-Finder + Show in Explorer הצג ב-Explorer + Show in Folder הצג בתיקייה + File Info… פרטי קובץ… + Print… הדפסה… + Print One Copy הדפס עותק אחד + Exit יציאה + Undo בטל + Redo בצע שוב + Toggle Last State החלף למצב האחרון + Fade… דהייה… + Cut גזור + Copy העתק + Copy Merged העתק ממוזג + Paste הדבק + Paste Special הדבקה מיוחדת + Paste in Place הדבק במקום + Paste Into הדבק לתוך + Paste Outside הדבק בחוץ + Paste Lorem Ipsum הדבק Lorem Ipsum + Clear נקה + Search… חיפוש… + Check Spelling… בדיקת איות… + Find and Replace Text… חיפוש והחלפת טקסט… + Add to Dictionary הוסף למילון + No Spelling Suggestions אין הצעות איות + Fill… מילוי… + Stroke… קו מתאר… + Content-Aware Fill… מילוי מודע לתוכן… + Delete and Fill Selection מחק ומלא בחירה + Content-Aware Scale שינוי קנה מידה מודע לתוכן + Puppet Warp עיוות בובה (Puppet Warp) + Perspective Warp עיוות פרספקטיבה + Free Transform שינוי צורה חופשי + Free Transform a Copy שינוי צורה חופשי לעותק + Transform שינוי צורה + Again שוב + Transform Again on a Copy חזור על שינוי הצורה על עותק + Scale קנה מידה + Rotate סיבוב + Set horizontal skew הגדר הטיה אופקית + Set vertical skew הגדר הטיה אנכית + Skew הטיה + Distort עיוות + Perspective פרספקטיבה + Warp עיוות (Warp) + Flip Horizontal היפוך אופקי + Flip Vertical היפוך אנכי + Rotate 180° סובב 180° + Rotate 90° Clockwise סובב 90° עם כיוון השעון + Rotate 90° Counter Clockwise סובב 90° נגד כיוון השעון + 180° 180° + 90° Clockwise 90° עם כיוון השעון + 90° Counter Clockwise 90° נגד כיוון השעון + Arbitrary… שרירותי… + Auto-Align Layers… יישור שכבות אוטומטי… + Auto-Blend Layers… מיזוג שכבות אוטומטי… + Define Brush Preset… הגדר ערכת מברשת… + Define Pattern… הגדר תבנית… + Define Custom Shape… הגדר צורה מותאמת אישית… + Purge ניקוי זיכרון + Clipboard לוח גזירים + Histories היסטוריות + Video Cache מטמון וידאו + All הכול + Presets ערכות קבועות מראש + Preset Manager… מנהל ערכות קבועות מראש… + Migrate Presets העבר ערכות קבועות מראש + Export/Import Presets… ייצוא/ייבוא ערכות קבועות מראש… + Color Settings… הגדרות צבע… + Assign Profile… הקצה פרופיל… + Convert to Profile… המר לפרופיל… + Keyboard Shortcuts… קיצורי מקלדת… + Menus… תפריטים… + Toolbar… סרגל כלים… + Preferences העדפות + General… כללי… + Interface… ממשק… + Workspace… סביבת עבודה… + Tools… כלים… + History Log… יומן היסטוריה… + File Handling… טיפול בקבצים… + Export… ייצוא… + Performance… ביצועים… + Scratch Disks… כונני עבודה (Scratch Disks)… + Cursors… סמנים… + Transparency & Gamut… שקיפות וטווח צבעים… + Units & Rulers… יחידות וסרגלים… + Guides, Grid & Slices… קווים מנחים, רשת ופרוסות… + Plug-ins… תוספים… + Type… טקסט… + Enhanced Controls… פקדים משופרים… + Camera Raw… Camera Raw… + Raw Defaults… ברירות מחדל ל-Raw… + Integrations… שילובים… + AI Integrations… שילובי בינה מלאכותית… + Install Plug-in… התקן תוסף… + Language Options אפשרויות שפה + Settings… הגדרות… + Mode מצב + Bitmap מפת סיביות (Bitmap) + Grayscale גווני אפור + Duotone דו-גוון (Duotone) + Indexed Color צבע מאונדקס + RGB Color צבע RGB + CMYK Color צבע CMYK + Lab Color צבע Lab + Multichannel רב-ערוצי + 8 Bits/Channel 8 סיביות/ערוץ + 8 bit 8 סיביות + Integer מספר שלם + 16 Bits/Channel 16 סיביות/ערוץ + 16 bit 16 סיביות + 32 Bits/Channel 32 סיביות/ערוץ + 32 bit (float) 32 סיביות (נקודה צפה) + Floating point נקודה צפה + Color Table… טבלת צבעים… + Bitmap… מפת סיביות… + Duotone… דו-גוון… + Indexed Color… צבע מאונדקס… + Adjustments התאמות + Brightness/Contrast… בהירות/ניגודיות… + Levels… רמות… + Curves… עקומות… + Exposure… חשיפה… + Vibrance… תוססות… + Hue/Saturation… גוון/רוויה… + Color Balance… איזון צבע… + Black & White… שחור ולבן… + Photo Filter… מסנן תמונה… + Channel Mixer… מערבל ערוצים… + Color Lookup… חיפוש צבע… + Invert היפוך + Posterize… פוסטריזציה… + Threshold… סף… + Gradient Map… מפת מעברי צבע… + Selective Color… צבע סלקטיבי… + Shadows/Highlights… צללים/הדגשות… + HDR Toning… גוון HDR… + Desaturate הפחת רוויה + Match Color… התאמת צבע… + Replace Color… החלפת צבע… + Equalize איזון היסטוגרמה + Auto Tone גוון אוטומטי + Auto Contrast ניגודיות אוטומטית + Auto Color צבע אוטומטי + Image Size… גודל תמונה… + Canvas Size… גודל קנבס… + Image Rotation סיבוב תמונה + Flip Canvas Horizontal היפוך קנבס אופקי + Flip Canvas Vertical היפוך קנבס אנכי + Crop חיתוך + Trim… גיזום… + Reveal All חשוף הכול + Duplicate… שכפל… + Apply Image… החל תמונה… + Calculations… חישובים… + Variables משתנים + Define… הגדר… + Data Sets… ערכות נתונים… + Apply Data Set… החל ערכת נתונים… + Variable Data Sets… ערכות נתונים משתנות… + Trap… לכידה (Trap)… + Analysis ניתוח + Set Measurement Scale… הגדר קנה מידה למדידה… + Select Data Points… בחר נקודות נתונים… + Record Measurements תעד מדידות + Ruler Tool כלי סרגל + Count Tool כלי ספירה + Place Scale Marker… הצב סמן קנה מידה… + Custom… מותאם אישית… + New חדש + Layer… שכבה… + Group… קבוצה… + Group from Layers… קבוצה משכבות… + New Group from Layers… קבוצה חדשה משכבות… + Artboard… לוח ציור… + Artboard from Group… לוח ציור מקבוצה… + Artboard from Layers… לוח ציור משכבות… + Layer via Copy שכבה דרך העתקה + Layer via Cut שכבה דרך גזירה + Layer From Background… שכבה מרקע… + Layer from Background… שכבה מרקע… + Background from Layer רקע משכבה + Background From Layer רקע משכבה + New Shape Layer שכבת צורה חדשה + New Type Layer שכבת טקסט חדשה + New Fill Layer שכבת מילוי חדשה + New Adjustment Layer שכבת התאמה חדשה + New Layer Based Slice פרוסה מבוססת שכבה חדשה + New Smart Object via Copy אובייקט חכם חדש דרך העתקה + New Blank Video Layer שכבת וידאו ריקה חדשה + New Video Layer from File… שכבת וידאו חדשה מקובץ… + Duplicate Layer… שכפל שכבה… + Delete Layer מחק שכבה + Delete מחק + Rename Layer שנה שם שכבה + Create Layer צור שכבה + Layer Style סגנון שכבה + Blending Options… אפשרויות מיזוג… + Bevel & Emboss… הטבעה ותבליט… + Use Global Light השתמש באור גלובלי + Overprint הדפסת יתר (Overprint) + Inner Shadow… צל פנימי… + Inner Glow… זוהר פנימי… + Satin… סאטן… + Color Overlay… שכבת-על צבע… + Gradient Overlay… שכבת-על מעבר צבעים… + Pattern Overlay… שכבת-על תבנית… + Outer Glow… זוהר חיצוני… + Drop Shadow… צל נפל… + Copy Layer Style העתק סגנון שכבה + Paste Layer Style הדבק סגנון שכבה + Clear Layer Style נקה סגנון שכבה + Global Light… אור גלובלי… + Hide All Effects הסתר את כל האפקטים + Scale Effects… שנה קנה מידה של אפקטים… + Smart Filter מסנן חכם + Convert for Smart Filters המר למסננים חכמים + Clear Smart Filters נקה מסננים חכמים + Disable Smart Filters השבת מסננים חכמים + Delete Filter Mask מחק מסכת מסנן + Disable Filter Mask השבת מסכת מסנן + Solid Color… צבע אחיד… + Gradient… מעבר צבעים… + Pattern… תבנית… + Layer Mask מסכת שכבה + Vector Mask מסכת וקטור + Reveal Selection חשוף בחירה + Hide Selection הסתר בחירה + Hide All הסתר הכול + From Transparency משקיפות + Delete Vector Mask מחק מסכת וקטור + Enable Layer Mask אפשר מסכת שכבה + Disable Layer Mask השבת מסכת שכבה + Enable Vector Mask אפשר מסכת וקטור + Link Layer Mask קשר מסכת שכבה + Unlink Layer Mask בטל קישור מסכת שכבה + Link Vector Mask קשר מסכת וקטור + Add Vector Mask הוסף מסכת וקטור + Vector Mask from Current Path מסכת וקטור מהנתיב הנוכחי + Create Clipping Mask צור מסכת גזירה + Release Clipping Mask שחרר מסכת גזירה + Smart Objects אובייקטים חכמים + Smart Object אובייקט חכם + Convert to Smart Object המר לאובייקט חכם + Convert to Embedded המר למוטבע + Convert to Layers המר לשכבות + Convert to Linked… המר למקושר… + Edit Contents ערוך תוכן + Replace Contents… החלף תוכן… + Relink to File… קשר מחדש לקובץ… + Export Contents… ייצוא תוכן… + Update Modified Content עדכן תוכן ששונה + Update All Modified Content עדכן את כל התוכן ששונה + Stack Mode מצב ערימה + Choose Stack Mode: בחר מצב ערימה: + Entropy אנטרופיה + Kurtosis קורטוזיס + Maximum מקסימום + Mean ממוצע + Median חציון + Minimum מינימום + Range טווח + Skewness אלכסוניות + Standard Deviation סטיית תקן + Summation סכימה + Variance שונות + Video Layers שכבות וידאו + Interpret Footage… פרש חומר גלם… + Replace Footage… החלף חומר גלם… + Reload Frame טען מחדש פריים + Show Altered Video הצג וידאו ששונה + Restore Frame שחזר פריים + Restore All Frames שחזר את כל הפריימים + Insert Blank Frame הוסף פריים ריק + Delete Frame מחק פריים + Duplicate Frame שכפל פריים + Frame from Layers פריים משכבות + Video Frames to Layers… פריימי וידאו לשכבות… + Rasterize רסטור + Rasterize Type רסטור טקסט + Rasterize Type Layer רסטור שכבת טקסט + Rasterize Shape רסטור צורה + New Guide Layout… פריסת קווים מנחים חדשה… + New Guides From Shape קווים מנחים חדשים מצורה + Lock Layers… נעל שכבות… + Link Layers קשר שכבות + Select Linked Layers בחר שכבות מקושרות + Merge Layers מזג שכבות + Merge Down מזג למטה + Merge Group מזג קבוצה + Merge Visible מזג גלויות + Stamp Visible חותמת שכבות גלויות + Select Layer Above בחר שכבה מעל + Select Layer Below בחר שכבה מתחת + Select Top Layer בחר שכבה עליונה + Select Bottom Layer בחר שכבה תחתונה + Add Layer Above to Selection הוסף שכבה מעל לבחירה + Add Layer Below to Selection הוסף שכבה מתחת לבחירה + Stamp Down חותמת למטה + Flatten Image שטח תמונה + Matting מיסוך קצוות + Color Decontaminate… ניקוי זיהום צבע… + Remove Black Matte הסר מסגרת שחורה + Remove White Matte הסר מסגרת לבנה + Defringe… הסרת שוליים… + Group Layers קבץ שכבות + Ungroup Layers פרק קיבוץ שכבות + Hide Layers הסתר שכבות + Arrange סדר + Bring to Front הבא לחזית + Bring Forward הבא קדימה + Send Backward שלח אחורה + Send to Back שלח לרקע + Reverse הפוך סדר + Combine Shapes שלב צורות + Unite Shapes אחד צורות + Subtract Front Shape חסר צורה קדמית + Intersect Shape Areas חתוך אזורי צורה + Exclude Overlapping Shapes הוצא צורות חופפות + Merge Shape Components מזג רכיבי צורה + Path operations פעולות נתיב + Align יישור + Top Edges קצוות עליונים + Vertical Centers מרכזים אנכיים + Bottom Edges קצוות תחתונים + Left Edges קצוות שמאליים + Horizontal Centers מרכזים אופקיים + Right Edges קצוות ימניים + Distribute חלוקה + Vertically אנכית + Horizontally אופקית + Layer Edges קצוות שכבה + Isolate Layers בודד שכבות + Layer Content Options… אפשרויות תוכן שכבה… + Fill Content תוכן מילוי + Deselect Layers בטל בחירת שכבות + Find Layers חפש שכבות + Layer Comps קומפים של שכבות + Layers שכבות + All Layers כל השכבות + Hidden Layers שכבות מוסתרות + Mask All Objects מסך את כל האובייקטים + Subject נושא + Sky שמיים + Panels פאנלים + Character Panel פאנל תווים + Paragraph Panel פאנל פסקה + Character Styles Panel פאנל סגנונות תווים + Paragraph Styles Panel פאנל סגנונות פסקה + Glyphs Panel פאנל גליפים + Anti-Alias החלקת קצוות + Sharp חד + Crisp קריספי + Strong חזק + Smooth חלק + Windows LCD Windows LCD + Windows Windows + Orientation כיוון + Horizontal אופקי + Vertical אנכי + OpenType OpenType + Standard Ligatures ליגטורות תקניות + Contextual Alternates חלופות הקשריות + Discretionary Ligatures ליגטורות רשותיות + Swash סוופ + Oldstyle ספרות עתיקות + Stylistic Alternates חלופות סגנוניות + Titling Alternates חלופות כותרת + Ornaments קישוטים + Ordinals סודרים + Fractions שברים + Create Work Path צור נתיב עבודה + Convert to Shape המר לצורה + Warp Text… עיוות טקסט… + Convert to Point Text המר לטקסט נקודתי + Convert to Paragraph Text המר לטקסט פסקה + Default Features תכונות ברירת מחדל + East Asian Features תכונות מזרח אסייתיות + Middle Eastern Features תכונות מזרח תיכוניות + Middle Eastern & South Asian Composer מרכיב מזרח תיכון ודרום אסיה + Update All Text Layers עדכן את כל שכבות הטקסט + Replace All Missing Fonts החלף את כל הגופנים החסרים + Resolve Missing Fonts… פתור גופנים חסרים… + Font Preview Size גודל תצוגה מקדימה של גופן + None ללא + Small קטן + Medium בינוני + Large גדול + Extra Large גדול מאוד + Huge ענקי + Load Default Type Styles טען סגנונות טקסט ברירת מחדל + Save Default Type Styles שמור סגנונות טקסט ברירת מחדל + Deselect בטל בחירה + Reselect בחר מחדש + Inverse היפוך בחירה + Inverse Selection היפוך בחירה + Similar דומה + Color Range… טווח צבעים… + Focus Area… אזור מיקוד… + Select and Mask… בחירה ומיסוך… + Smart Radius רדיוס חכם + Shift Edge הזז קצה + Decontaminate נקה זיהום צבע + Output פלט + Refine Edge חידוד קצה + Modify שינוי + Border… גבול… + Smooth… החלקה… + Expand… הרחבה… + Contract… כיווץ… + Feather… הכהיה (Feather)… + Grow הגדל + Transform Selection שנה צורת בחירה + Edit in Quick Mask Mode ערוך במצב מסכה מהירה + Load Selection… טען בחירה… + Save Selection… שמור בחירה… + Match All התאם הכול + Last Filter המסנן האחרון + Filter Gallery… גלריית מסננים… + Adaptive Wide Angle… זווית רחבה אדפטיבית… + Camera Raw Filter… מסנן Camera Raw… + Liquify… נזילות… + Vanishing Point… נקודת היעלמות… + Blur טשטוש + Blur Gallery גלריית טשטוש + Field Blur… טשטוש שדה… + No pin selected לא נבחרה סיכה + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. לחץ על התמונה כדי להוסיף סיכה; גרור סיכה כדי להזיז אותה, או את הטבעת שלה כדי לשנות את הטשטוש. Delete מסיר את הסיכה הנבחרת. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. גרור את הידיות שעל קו המתאר כדי לשנות את גודלו ולסובב אותו, את הריבוע כדי לעגל את פינותיו, ואת הנקודות שבפנים כדי לקבוע היכן הטשטוש מתחיל. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. גרור את הסיכה כדי להזיז את הרצועה, או את הטבעת שלה כדי לשנות את הטשטוש. גרור את הקווים הרציפים כדי להרחיב את הרצועה החדה, את הקווים המקווקווים כדי לקבוע את המעבר, ואת הנקודות כדי לסובב אותה. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. גרור את הידיות שעל קו המתאר כדי לשנות את גודלו ולסובב אותו; הטבעת קובעת את זווית הטשטוש. + Blur Angle זווית טשטוש + Iris Blur… טשטוש קשתית… + Tilt-Shift… הטיה-הזזה (Tilt-Shift)… + Path Blur… טשטוש נתיב… + Spin Blur… טשטוש סיבוב… + Blur More טשטש עוד + Box Blur… טשטוש קופסה… + Gaussian Blur… טשטוש גאוסי… + Lens Blur… טשטוש עדשה… + Motion Blur… טשטוש תנועה… + Radial Blur… טשטוש רדיאלי… + Shape Blur… טשטוש צורה… + Smart Blur… טשטוש חכם… + Surface Blur… טשטוש משטח… + Average ממוצע + Displace… הזזה (Displace)… + Pinch… צביטה… + Polar Coordinates… קואורדינטות קוטביות… + Ripple… אדווה… + Shear… גזירה (Shear)… + Spherize… כדוריות… + Twirl… סחרור… + Wave… גל… + ZigZag… זיגזג… + Noise רעש + Add Noise… הוסף רעש… + Despeckle הסר נקודות + Dust & Scratches… אבק ושריטות… + Median… חציון… + Reduce Noise… הפחת רעש… + Pixelate פיקסול + Color Halftone… חצי-טון צבעוני… + Crystallize… גיבוש (Crystallize)… + Facet קטעים + Fragment פיצול + Mezzotint… מצו-טינטו (Mezzotint)… + Mosaic… פסיפס… + Pointillize… פואנטיליזם… + Render עיבוד (Render) + Clouds עננים + Difference Clouds עננים דיפרנציאליים + Fibers… סיבים… + Flame… להבה… + Lens Flare… זוהר עדשה… + Lighting Effects… אפקטי תאורה… + Relight… תאורה מחדש… + Picture Frame… מסגרת תמונה… + Tree… עץ… + Sharpen חידוד + Sharpen Edges חדד קצוות + Sharpen More חדד יותר + Smart Sharpen… חידוד חכם… + Unsharp Mask… מסכת חידוד (Unsharp Mask)… + High Pass… מעבר עליון (High Pass)… + Stylize סגנון (Stylize) + Diffuse… פיזור… + Emboss… תבליט… + Extrude… הבלטה תלת-ממדית (Extrude)… + Find Edges מצא קצוות + Oil Paint… צבעי שמן… + Solarize סולריזציה + Tiles… אריחים… + Trace Contour… עקוב אחר קווי מתאר… + Wind… רוח… + Video וידאו + De-Interlace… ביטול שזירה (De-Interlace)… + NTSC Colors צבעי NTSC + Other אחר + Maximum… מקסימום… + Minimum… מינימום… + Offset… היסט… + Plug-ins תוספים + Proof Setup הגדרת הדמיית הדפסה + Proof Colors הדמיית צבעי הדפסה + Gamut Warning אזהרת טווח צבעים + Working CMYK CMYK עבודה + Working Cyan Plate לוח Cyan עבודה + Working Magenta Plate לוח Magenta עבודה + Working Yellow Plate לוח Yellow עבודה + Working Black Plate לוח Black עבודה + Working CMY Plate לוח CMY עבודה + Legacy Macintosh RGB RGB ישן של Macintosh + Internet Standard RGB RGB תקני לאינטרנט + Monitor RGB RGB של הצג + Color Blindness — Protanopia-type עיוורון צבעים — מסוג פרוטנופיה + Color Blindness — Deuteranopia-type עיוורון צבעים — מסוג דאוטרנופיה + Zoom In הגדל תצוגה + Zoom Out הקטן תצוגה + Fit on Screen התאם למסך + Fit Layer(s) on Screen התאם שכבה/ות למסך + Fit Artboard on Screen התאם לוח ציור למסך + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size גודל הדפסה + Pixel Aspect Ratio יחס גובה-רוחב של פיקסל + Pixel Aspect Ratio Correction תיקון יחס גובה-רוחב של פיקסל + Custom Pixel Aspect Ratio… יחס גובה-רוחב מותאם אישית… + Square ריבוע + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC מסך רחב (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL מסך רחב (1.46) + Anamorphic 2:1 (2) אנמורפי 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… אפשרויות תצוגה מקדימה של 32 סיביות… + Screen Mode מצב מסך + Standard Screen Mode מצב מסך תקני + Full Screen Mode With Menu Bar מצב מסך מלא עם סרגל תפריטים + Full Screen Mode מצב מסך מלא + Cycle Screen Mode החלף מצב מסך + Show הצג + Extras תוספות + Selection Edges קצוות בחירה + Target Path נתיב יעד + Current Path נתיב נוכחי + Grid רשת + Guides קווים מנחים + Canvas Guides קווים מנחים של קנבס + Artboard Guides קווים מנחים של לוח ציור + Count ספירה + Smart Guides קווים מנחים חכמים + Slices פרוסות + Notes הערות + Pixel Grid רשת פיקסלים + Pixel Art פיקסל ארט + Pixel Art Preview תצוגה מקדימה של פיקסל ארט + Pattern Preview תצוגה מקדימה של תבנית + Brush Preview תצוגה מקדימה של מברשת + Mesh רשת (Mesh) + Edit Pins ערוך סיכות + Document Bounds גבולות המסמך + Show Extras Options… הצג אפשרויות תוספות… + Rulers סרגלים + Snap הצמדה + Snap To הצמד אל + Lock Guides נעל קווים מנחים + Clear Guides נקה קווים מנחים + Clear Selected Artboard Guides נקה קווים מנחים של לוח הציור הנבחר + Clear Canvas Guides נקה קווים מנחים של הקנבס + New Guide… קו מנחה חדש… + Lock Slices נעל פרוסות + Clear Slices נקה פרוסות + Match Zoom התאם מידת תקריב + Match Location התאם מיקום + Match Rotation התאם סיבוב + Cascade מדורג + Tile פריסה באריחים + Tile All Horizontally פרוס הכול אופקית + Tile All Vertically פרוס הכול אנכית + 2-up Horizontal שתי תמונות אופקית + 2-up Vertical שתי תמונות אנכית + 3-up Horizontal שלוש תמונות אופקית + 3-up Stacked שלוש תמונות בערימה + 3-up Vertical שלוש תמונות אנכית + 4-up ארבע תמונות + 6-up שש תמונות + Consolidate All to Tabs אחד הכול לכרטיסיות + Float in Window צף בחלון + Float All in Windows צף הכול בחלונות + New Window for Document חלון חדש למסמך + Workspace סביבת עבודה + Essentials בסיסי + Graphic and Web גרפיקה ואינטרנט + Painting ציור + Photography צילום + New Workspace… סביבת עבודה חדשה… + Delete Workspace… מחק סביבת עבודה… + Reset Workspace אפס סביבת עבודה + Reset Tool אפס כלי + Reset All Tools אפס את כל הכלים + Reset all tools to their default settings? לאפס את כל הכלים להגדרות ברירת המחדל שלהם? + Lock Workspace נעל סביבת עבודה + Theme עיצוב + Pro Theme עיצוב Pro + Pro Medium Gray Theme עיצוב Pro אפור בינוני + Pro Light Gray Theme עיצוב Pro אפור בהיר + Pro Light Theme עיצוב Pro בהיר + Studio Theme עיצוב Studio + Studio Light Theme עיצוב Studio בהיר + Adwaita Light Theme עיצוב Adwaita בהיר + Adwaita Dark Theme עיצוב Adwaita כהה + Classic Theme עיצוב קלאסי + Solarized dark Solarized כהה + Solarized Dark Theme עיצוב Solarized כהה + Breeze dark Breeze כהה + Breeze Dark Theme עיצוב Breeze כהה + Breeze light Breeze בהיר + Breeze Light Theme עיצוב Breeze בהיר + Soft dark רך כהה + Soft light רך בהיר + Soft Dark Theme עיצוב רך כהה + Soft Light Theme עיצוב רך בהיר + Next Theme העיצוב הבא + Actions פעולות + Brush Settings הגדרות מברשת + Brushes מברשות + Channels ערוצים + Character תו + Character Styles סגנונות תווים + Clone Source מקור שכפול + Color צבע + Glyphs גליפים + Gradients מעברי צבעים + Histogram היסטוגרמה + History היסטוריה + Info מידע + Measurement Log יומן מדידה + Modifier Keys מקשי עריכה + Navigator נווט + Options אפשרויות + Paragraph פסקה + Paragraph Styles סגנונות פסקה + Paths נתיבים + Patterns תבניות + Properties מאפיינים + Shapes צורות + Styles סגנונות + Swatches דוגמיות צבע + Timeline ציר זמן + Tool Presets ערכות כלים קבועות מראש + Tools כלים + About PhotoCraft אודות PhotoCraft + PhotoCraft Website אתר PhotoCraft + PhotoCraft on GitHub PhotoCraft ב-GitHub + ArtCraft Website אתר ArtCraft + Join the ArtCraft Discord… הצטרף לדיסקורד של ArtCraft… + Report an Issue… דווח על תקלה… + WIA Support… תמיכת WIA… + Apply החל + HSB/HSL HSB/HSL + Invert… היפוך… + Load Files into Stack… טען קבצים לערימה… + Motion תנועה + Notes… הערות… + Other… אחר… + Remove Warp Split הסר פיצול עיוות + Shape צורה + Split Warp Crosswise פצל עיוות לרוחב + Split Warp Horizontally פצל עיוות אופקית + Split Warp Vertically פצל עיוות אנכית + Statistics… סטטיסטיקה… + Refine Strokes חידוד משיכות + Edits עריכות +@plural {n} item|{n} items {n} פריט|{n} פריטים + Auto אוטומטי + (print to the printer) (הדפסה למדפסת) + + Add variable + הוסף משתנה + 30 frames @ 30 fps 30 פריימים ב-30 fps + 3D LUT File קובץ LUT תלת-ממדי + Absolute מוחלט + Absolute Colorimetric קולורימטרי מוחלט + Action פעולה + Adaptive אדפטיבי + Adaptive Wide Angle isn't open זווית רחבה אדפטיבית אינה פתוחה + Add הוסף + Add Layer Mask הוסף מסכת שכבה + Add Stop הוסף נקודת עצירה + Add a layer style הוסף סגנון שכבה + Move effect up הזז אפקט מעלה + Move effect down הזז אפקט מטה + Delete effect מחק אפקט + Add a mask (from the selection; {key} inverts) הוסף מסכה (מהבחירה; {key} הופך) + Add an adjustment הוסף התאמה + Add disk הוסף דיסק + Add space after paragraph הוסף רווח אחרי פסקה + Add space before paragraph הוסף רווח לפני פסקה + Add to Sample הוסף לדוגמה + Add to selection ({key}) הוסף לבחירה ({key}) + Adjust for Zoom התאם לתקריב + Adjustment visible התאמה גלויה + Adobe RGB (1998) compatible תואם Adobe RGB (1998) + Align and Distribute יישור וחלוקה + Align and distribute יישור וחלוקה + Align horizontal centers יישר מרכזים אופקיים + Align left edges יישר קצוות שמאליים + Align right edges יישר קצוות ימניים + Aligned מיושר + All Caps אותיות רישיות + All Slices כל הפרוסות + All User Slices כל פרוסות המשתמש + All shortcuts reset to PhotoCraft defaults. כל הקיצורים אופסו לברירת המחדל של PhotoCraft. + Alt Alt + Always use pressure for opacity השתמש בלחיצה לקביעת אטימות בכל פעם + Always use pressure for size השתמש בלחיצה לקביעת גודל בכל פעם + Amount כמות + Anchor: עוגן: + Angle זווית + Angle Gradient מעבר צבעים זוויתי + Angle Jitter ריצוד זווית + Answer the unsaved-changes prompt first ענה קודם על הודעת השינויים שלא נשמרו + Anti-alias החלקת קצוות + Appearance מראה + Appearance (Layer Style) מראה (סגנון שכבה) + Apply Layer Mask החל מסכת שכבה + Add Mask to Selection הוסף מסכה לבחירה + Subtract Mask from Selection חסר מסכה מהבחירה + Intersect Mask with Selection חתוך מסכה עם הבחירה + Apply Per Tip החל על כל קצה + Apply next selected layer comp החל את קומפ השכבות הנבחר הבא + Apply previous selected layer comp החל את קומפ השכבות הנבחר הקודם + Artboard לוח ציור + Artboard Background Color צבע רקע של לוח הציור + Assign working space הקצה מרחב עבודה + Author: מחבר: + Auto Hide הסתרה אוטומטית + Auto-Select: בחירה אוטומטית: + Automatically level near-horizontal line segments ישר אוטומטית קטעי קו קרובים לאופקי + Automatically straighten and level ישר ויישר אוטומטית + Automatically straighten near-vertical line segments ישר אוטומטית קטעי קו קרובים לאנכי + Background רקע + Background Color צבע רקע + Background Contents תוכן רקע + Custom background color צבע רקע מותאם אישית + Balance איזון + Baseline קו בסיס + Baseline shift הזזת קו בסיס + Basic בסיסי + Before (Y) לפני (Y) + Save Settings… שמור הגדרות… + Load Settings… טען הגדרות… + Save these settings as a Camera Raw .xmp file שמור הגדרות אלה כקובץ ‎.xmp של Camera Raw + Load settings from a Camera Raw .xmp file טען הגדרות מקובץ ‎.xmp של Camera Raw + Loaded Camera Raw settings from {file} נטענו הגדרות Camera Raw מתוך {file} + Loaded Camera Raw settings from {file} ({n} not supported) נטענו הגדרות Camera Raw מתוך {file} ({n} לא נתמכות) + Begin recording התחל הקלטה + Bend: כיפוף: + Bicubic דו-מעוקב (Bicubic) + Bicubic (smooth gradients) דו-מעוקב (מעברים חלקים) + Automatic אוטומטי + Bicubic Smoother (enlargement) דו-מעוקב חלק יותר (הגדלה) + Bicubic Sharper (reduction) דו-מעוקב חד יותר (הקטנה) + Bilinear דו-ליניארי (Bilinear) + Black שחור + Black & White שחור ולבן + Black Point Compensation פיצוי נקודה שחורה + Black, White שחור, לבן + Blacks שחורים + Blending מיזוג + Blending Options אפשרויות מיזוג + Blue כחול + Blue, Red, Yellow כחול, אדום, צהוב + Both Axes שני הצירים + Bottom תחתון + Brightness בהירות + Brightness Jitter ריצוד בהירות + Brightness/Contrast בהירות/ניגודיות + Bring the selected pin forward הבא את הסיכה הנבחרת קדימה + Brush Preset picker בוחר ערכת מברשת + Brush Reconstruct Options אפשרויות שחזור מברשת + Brush Tool Options אפשרויות כלי מברשת + Cache Level: רמת מטמון: + Camera Raw isn't open Camera Raw אינו פתוח + Cancel ביטול + Cancel (Esc) ביטול (Esc) + Manage submitted jobs in the system print queue נהל עבודות שנשלחו בתור ההדפסה של המערכת + Cancel any current edits (Esc) בטל עריכות נוכחיות (Esc) + Cancel current crop operation (Esc) בטל פעולת חיתוך נוכחית (Esc) + Cancel transform (Esc) בטל שינוי צורה (Esc) + Cancel warp (Esc) בטל עיוות (Esc) + Canvas קנבס + Canvas extension color: צבע הרחבת קנבס: + Capture לכוד + Capture the visibility, position and style of every layer with + below, then switch between versions. לכוד את מצב התצוגה, המיקום והסגנון של כל שכבה עם + שלהלן, ולאחר מכן החלף בין גרסאות. + Catch-up on Stroke End השלמה בסוף המשיכה + Center מרכז + Center Crop Marks סימוני חיתוך מרכזיים + Center text מרכז את הטקסט + Change שנה + Change All שנה הכול + Change Screen Mode (F) שנה מצב מסך (F) + Change To: שנה ל: + Channel Mixer מערבל ערוצים + Channel: ערוץ: + Check All Layers בדוק את כל השכבות + Check Spelling בדיקת איות + Clarity בהירות-על (Clarity) + Clear All נקה הכול + Clear Override נקה דריסה + Clear Samples נקה דגימות + Click sets foreground · right-click sets background לחיצה קובעת חזית · לחיצה ימנית קובעת רקע + Click the first point or press {key} to close · Esc cancels לחץ על הנקודה הראשונה או הקש {key} לסגירה · Esc מבטל + Click to add a point · drag off or {key}-click or Delete to remove לחץ להוספת נקודה · גרור הצידה או {key}-לחיצה או Delete להסרה + Click to apply, {key}-click to clear overrides, double-click for Style Options לחץ להחלה, {key}-לחיצה לניקוי דריסות, לחיצה כפולה לאפשרויות סגנון + Click to edit the gradient לחץ לעריכת מעבר הצבעים + Click to restore the current colour לחץ לשחזור הצבע הנוכחי + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out לחץ להגדלה · {key}-לחיצה להקטנה · גרור ימינה/שמאלה להגדלה/הקטנה + Click with the Note tool to add a note. לחץ עם כלי ההערות כדי להוסיף הערה. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel לחיצה: פינה · גרירה: החלקה · לחיצה על הנקודה הראשונה: סגירה · {key} סיום · Esc ביטול + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel לחיצה: פינה · גרירה: החלקה · {alt}-גרירה: שבירת ידית · לחיצה או גרירה של הנקודה הראשונה: סגירה · {key} סיום · Esc ביטול + Clip to layer below גזור לשכבה שמתחת + Clipped גזור (Clipped) + Close Style Options סגור אפשרויות סגנון + Cmd Cmd + Coated CMYK CMYK מצופה + Color & Swatches צבע ודוגמיות + Color Balance איזון צבע + Color Detail פרטי צבע + Color Grading דירוג צבע (Color Grading) + Color Lookup חיפוש צבע + Color Management ניהול צבע + Color Mixer מערבל צבעים + Color Mode מצב צבע + Color Priority עדיפות צבע + Color Range preview תצוגה מקדימה של טווח צבעים + Color: צבע: + Colorize צביעה (Colorize) + Colors צבעים + Commit Perspective Warp ({key}) אשר עיוות פרספקטיבה ({key}) + Commit Puppet Warp ({key}) אשר עיוות בובה ({key}) + Commit any current edits ({key}) אשר עריכות נוכחיות ({key}) + Commit current crop operation ({key}) אשר פעולת חיתוך נוכחית ({key}) + Commit transform ({key}) אשר שינוי צורה ({key}) + Commit warp ({key}) אשר עיוות ({key}) + Constant קבוע + Constrain proportions שמור על פרופורציות + Contact Sheet II גיליון מגע II + Content-Aware מודע לתוכן + Contiguous רציף + Contrast ניגודיות + Control: בקרה: + Convert to sRGB המר ל-sRGB + Copies עותקים + Copper נחושת + Copyright זכויות יוצרים + Copyright and Contact Info זכויות יוצרים ופרטי קשר + Corner Crop Marks סימוני חיתוך בפינות + Corner radius רדיוס פינה + Correction תיקון + Count Jitter ריצוד ספירה + Create צור + Create Droplet צור Droplet + Create Texture צור מרקם + Create Video Timeline צור ציר זמן וידאו + Create a new count group צור קבוצת ספירה חדשה + Create a new document or open an existing file. צור מסמך חדש או פתח קובץ קיים. + Create a new group צור קבוצה חדשה + Create a new layer צור שכבה חדשה + Create new action צור פעולה חדשה + Create new channel צור ערוץ חדש + Create new document from current state צור מסמך חדש מהמצב הנוכחי + Create new fill or adjustment layer צור שכבת מילוי או התאמה חדשה + Create new gradient צור מעבר צבעים חדש + Create new group צור קבוצה חדשה + Create new layer comp צור קומפ שכבות חדש + Create new pattern from the selection צור תבנית חדשה מהבחירה + Create new shape from the current path צור צורה חדשה מהנתיב הנוכחי + Create new snapshot צור תצלום מצב חדש + From: מ: + Full Document מסמך מלא + Merged Layers שכבות ממוזגות + Create new tool preset צור ערכת כלי חדשה + Crop Factor מקדם חיתוך + Ctrl Ctrl + Current & Below נוכחי ומתחת + Current Layer שכבה נוכחית + Current Size גודל נוכחי + Current Tool Only הכלי הנוכחי בלבד + Curve עקומה + Curves עקומות + Custom מותאם אישית + Custom Shapes צורות מותאמות אישית + Cyan ציאן + Cyan · Red ציאן · אדום + Darken הכהה + Darks כהים + Data Sets ערכות נתונים + Default ברירת מחדל + Default colours (D) צבעי ברירת מחדל (D) + Default printer מדפסת ברירת מחדל + Default restored. ברירת המחדל שוחזרה. + Define הגדר + Define at least one variable first. הגדר לפחות משתנה אחד קודם. + Dehaze הסרת אובך (Dehaze) + Delete Cropped Pixels מחק פיקסלים חתוכים + Delete Group מחק קבוצה + Delete Layer Mask מחק מסכת שכבה + Delete Layers מחק שכבות + Add Shortcut הוסף קיצור + Delete Shortcut מחק קיצור + Delete Timeline מחק ציר זמן + Delete Tool Preset מחק ערכת כלי + Delete count group מחק קבוצת ספירה + Delete current channel מחק ערוץ נוכחי + Delete current state מחק מצב נוכחי + Delete effect layer מחק שכבת אפקט + Delete layer ‘{name}’? למחוק את השכבה '{name}'? + Delete {count} layers? {names} למחוק {count} שכבות? {names} + Delete layer? למחוק שכבה? + 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} השכבה נמחקה, אך לא ניתן היה לשמור את העדפת האישור: {error} + Delete layer מחק שכבה + Delete layer comp מחק קומפ שכבות + Delete note מחק הערה + Delete selected measurements מחק מדידות נבחרות + Delete style מחק סגנון + Delete tool preset מחק ערכת כלי + Density צפיפות + Density: צפיפות: + Depth עומק + Depth Jitter ריצוד עומק + Description תיאור + Deselect All בטל בחירת הכול + Detail פרט + Diamond Gradient מעבר צבעים יהלומי + Difference הבדל (Difference) + Diffusion פיזור + Discard the embedded profile (don't color manage) התעלם מהפרופיל המוטבע (אל תנהל צבע) + Discontiguous לא רציף + Discord Discord + Dismiss סגור + Display P3 Display P3 + Display Text הצג טקסט + Dither דיתור (Dither) + Divide… חלוקה… + Document מסמך + Don't Save אל תשמור + No לא + Yes כן + Done בוצע + Dots נקודות + Drag a rectangle around the object גרור מלבן מסביב לאובייקט + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. גרור על התמונה כדי להוסיף אילוץ; Shift לאופקי/אנכי; לחיצה ימנית על אילוץ כדי לשנות או למחוק אותו. + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath לחיצה: בחירת עוגן · גרירה: הזזת עוגן, ידית או קטע · {shift}-לחיצה: הוספה · {alt}-לחיצה: תת-נתיב שלם + Draw with the Pen tool (P) or make a work path from a selection. צייר עם כלי הקולמוס (P) או צור נתיב עבודה מבחירה. + Drop an image or PSD anywhere to open it. שחרר תמונה או PSD בכל מקום כדי לפתוח אותה. + Native file drag-and-drop is unavailable גרירה ושחרור טבעיים של קבצים אינם זמינים + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. גרירה ושחרור טבעיים של קבצים עדיין לא נתמכים ב-Wayland. השתמש בקובץ › פתח, או העתק את התמונה במנהל הקבצים שלך והדבק אותה עם {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` כדי לשחרר קבצים, הפעל את PhotoCraft תחת XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. או הגדר העדפות › ביצועים › שרת תצוגה ב-Linux ל-X11: PhotoCraft יופעל אז תמיד תחת XWayland. + Edit › Paste עריכה › הדבק + Duplicate Layers… שכפל שכבות… + Duplicate style שכפל סגנון + Edit Toolbar… ערוך סרגל כלים… + Use File › Open, or paste a copied image with {paste}. השתמש בקובץ › פתח, או הדבק תמונה שהועתקה עם {paste}. + Edit in Quick Mask Mode (Q) ערוך במצב מסכה מהירה (Q) + Edit in Standard Mode (Q) ערוך במצב תקני (Q) + Effects אפקטים + Embed Color Profile הטבע פרופיל צבע + Embedded Profile Mismatch חוסר התאמה בפרופיל המוטבע + Enable airbrush-style build-up effects אפשר אפקטי הצטברות בסגנון אירוגרף + Enhance Edge שפר קצה + Entire Font כל הגופן + Exact מדויק + Expansion: התפשטות: + Export Presets ייצוא ערכות קבועות מראש + Export selected measurements (CSV) ייצוא מדידות נבחרות (CSV) + Exposure חשיפה + Exposure and Gamma חשיפה וגמא + Exposure: חשיפה: + Eyedropper טפטפת + Factor: מקדם: + Faux Bold מודגש מדומה (Faux Bold) + Faux Italic נטוי מדומה (Faux Italic) + Feather הכהיה (Feather) + Fewer Points פחות נקודות + File Settings הגדרות קובץ + Fill מילוי + Fill: מילוי: + Filter for adjustment layers סנן שכבות התאמה + Filter for pixel layers סנן שכבות פיקסלים + Filter for shape layers סנן שכבות צורה + Filter for smart objects סנן אובייקטים חכמים + Filter for type layers סנן שכבות טקסט + Filter: סינון: + Finger Painting ציור באצבע + Fisheye עין דג (Fisheye) + Fit התאמה + Fit Screen התאם למסך + Fill Screen מלא מסך + Fit in View התאם לתצוגה + Fixed קבוע + Fixed Ratio יחס קבוע + Fixed Size גודל קבוע + Flip היפוך + Flip X היפוך X + Flip X Jitter ריצוד היפוך X + Flip Y היפוך Y + Flip Y Jitter ריצוד היפוך Y + Flow זרימה + Flow Jitter ריצוד זרימה + Focal Length אורך מוקד + Font גופן + Font Size: גודל גופן: + Font size גודל גופן + Foreground חזית + Foreground to Background חזית לרקע + Foreground/Background Jitter ריצוד חזית/רקע + Format תבנית + Frame from Layers… פריים משכבות… + Free חופשי + Full Spherical כדורי מלא + Fuzziness: עמימות: + GPU GPU + Gamma Correction תיקון גמא + Gamma: גמא: + Global גלובלי + Glow Color צבע זוהר + Gradient Map מפת מעברי צבע + Grain גרגיריות + Gray אפור + Gray Gamma 2.2 אפור גמא 2.2 + Green ירוק + Group קבוצה + Hardness קשיחות + Heart לב + Height גובה + Height: גובה: + Hide Auto Slices הסתר פרוסות אוטומטיות + Hide Properties הסתר מאפיינים + Highlight Compression דחיסת הדגשות + Highlight Priority עדיפות הדגשות + Highlights הדגשות + Home בית + Horizontal scale קנה מידה אופקי + Hue גוון + Hue Jitter ריצוד גוון + Hue/Saturation גוון/רוויה + Hyphenate מקף חלוקה + Ignore התעלם + Ignore All התעלם מהכול + Image Size גודל תמונה + Import Brushes… ייבוא מברשות… + Gradient Preset Options אפשרויות ערכת מעבר צבעים + Import .GRD ייבוא .GRD + Choose a gradient file (.grd). בחר קובץ מעבר צבעים (‎.grd). + Import brushes (.abr) ייבוא מברשות (‎.abr) + Import Presets ייבוא ערכות קבועות מראש + Imported יובא + Indent First הזחת שורה ראשונה + Indent Left הזחה שמאלית + Indent Right הזחה ימנית + Indent first line הזח שורה ראשונה + Indent left margin הזח שוליים שמאליים + Indent right margin הזח שוליים ימניים + Input: קלט: + Inside בפנים + Interlaced שזור + Interpolation: אינטרפולציה: + Intersect with selection ({key}) חתוך עם הבחירה ({key}) + Invert All הפוך הכול + Invert has no settings. להיפוך אין הגדרות. + Italic נטוי + Join us on Discord הצטרף אלינו בדיסקורד + Justify all יישור לשני הצדדים (כולל שורה אחרונה) + Justify last centered יישור לשני הצדדים, שורה אחרונה ממורכזת + Justify last left יישור לשני הצדדים, שורה אחרונה לשמאל + Justify last right יישור לשני הצדדים, שורה אחרונה לימין + Kerning ריווח תווים (Kerning) + Keyboard Shortcuts קיצורי מקלדת + Keyboard Shortcuts and Menus קיצורי מקלדת ותפריטים + Kind סוג + Label Size גודל תווית + Labels תוויות + Lanczos (sharp) Lanczos (חד) + Landscape לרוחב + Last Document State המצב האחרון של המסמך + Layout פריסה + Leading ריווח שורות (Leading) + Leading (set to the font size × auto-leading when Auto) ריווח שורות (נקבע לגודל הגופן × ריווח אוטומטי כאשר אוטומטי) + Leave as is (don't color manage) השאר כמו שהוא (אל תנהל צבע) + Left align text יישר טקסט לשמאל + Length: אורך: + Level רמה + Levels רמות + Lighten הבהר + Lightness בהירות גוון (Lightness) + Lights תאורה (Lights) + Limits: גבולות: + Linear Gradient מעבר צבעים לינארי + Link layers קשר שכבות + Link width and height קשר רוחב וגובה + Liquify is not open נזילות אינה פתוחה + Load טען + Load 3D LUT… טען LUT תלת-ממדי… + Load channel as selection טען ערוץ כבחירה + Load… טען… + Localized Color Clusters אשכולות צבע ממוקמים + Lock נעל + Lock all נעל הכול + Lock image pixels נעל פיקסלי תמונה + Lock layer נעל שכבה + Lock position נעל מיקום + Lock transparent pixels נעל פיקסלים שקופים + Lock: נעילה: + Logical Length: אורך לוגי: + Logical Units: יחידות לוגיות: + Luminance בהירות אור (Luminance) + Luminance Detail פרטי בהירות אור + Magenta מגנטה + Magenta · Green מגנטה · ירוק + Maintain aspect ratio שמור על יחס גובה-רוחב + Marker Size גודל סמן + Mask All מסך הכול + Mask Options אפשרויות מסכה + Masking מיסוך + Master ראשי + Matte מסגרת (Matte) + Median: חציון: + Menus תפריטים + Mesh Size גודל רשת + Metadata מטא-נתונים + Metrics מדדים (Metrics) + Midpoint נקודת ביניים + Midtones גוני ביניים + Midtones from: גוני ביניים מ: + Midtones to: גוני ביניים אל: + Minimum Depth עומק מינימלי + Minimum Diameter קוטר מינימלי + Minimum Roundness עגילות מינימלית + Mode: מצב: + Monochrome מונוכרום + More Points נקודות נוספות + Move effect layer down הזז שכבת אפקט מטה + Move effect layer up הזז שכבת אפקט מעלה + Move to הזז אל + Name שם + Name: שם: + Nearest Neighbor שכן קרוב (Nearest Neighbor) + Nearest Neighbor (hard edges) שכן קרוב (קצוות קשים) + New Size גודל חדש + New document… מסמך חדש… + New effect layer שכבת אפקט חדשה + New selection בחירה חדשה + Next frame פריים הבא + Next note הערה הבאה + No Color בלי צבע + No Color Management בלי ניהול צבע + No Dither בלי דיתור + No author בלי מחבר + No character style בלי סגנון תווים + No document אין מסמך + No matching commands אין פקודות תואמות + No presets for the current tool. אין ערכות קבועות מראש לכלי הנוכחי. + No presets of this type. אין ערכות קבועות מראש מהסוג הזה. + No properties אין מאפיינים + No tool presets. אין ערכות כלים קבועות מראש. + Noise Reduction הפחתת רעש + Normal רגיל + Not in Dictionary: לא במילון: + OK אישור + Offset היסט + Only Web Colors רק צבעי אינטרנט + Opacity אטימות + Opacity Jitter ריצוד אטימות + Opacity: אטימות: + Open a document to define variables. פתח מסמך כדי להגדיר משתנים. + Open a document to select a colour range. פתח מסמך כדי לבחור טווח צבעים. + Open a document to use the timeline. פתח מסמך כדי להשתמש בציר הזמן. + Optical אופטי + Optimized מותאם (Optimized) + Option-click to define a source point to clone from לחיצת Option כדי להגדיר נקודת מקור לשכפול + Original מקורי + Output Channel: ערוץ פלט: + Output Levels: רמות פלט: + Output: פלט: + Outside בחוץ + Overlay Color צבע שכבת-על + Overlay Opacity אטימות שכבת-על + Overlay: Rule of Thirds שכבת-על: חלוקה לשלישים + Override Pressure דרוס לחיצה + Override Rotation דרוס סיבוב + Override Tilt דרוס הטיה + Override Tilt X דרוס הטיה X + Override Tilt Y דרוס הטיה Y + PRESET DETAILS פרטי ערכה קבועה מראש + PSD PSD + Paint Overlay שכבת-על צבע + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. מיקומי הפאנלים נשמרים בסביבת העבודה. קיצורי מקלדת, תפריטים וסרגל כלים הם אופציונליים. + Paper נייר + Path נתיב + Pattern תבנית + Pattern Size גודל תבנית + Pause השהה + Percent אחוז + Perceptual תפיסתי (Perceptual) + Perspective Warp is not active עיוות פרספקטיבה אינו פעיל + Perspective Warp: draw planes along the image's perspective, then switch to Warp עיוות פרספקטיבה: צייר מישורים לאורך הפרספקטיבה של התמונה, ולאחר מכן עבור לעיוות + Photo Filter מסנן תמונה + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft מנהל צבעים + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — עורך תמונות קוד-פתוח וטבעי, שנכתב ב-Rust. + Pin Depth: עומק סיכה: + Pixel Length: אורך פיקסלים: + Pixels/Centimeter פיקסלים/סנטימטר + Pixels/Inch פיקסלים/אינץ' + Pixels: פיקסלים: + Play הפעל + Play selection הפעל בחירה + Portrait לאורך + Position מיקום + Position and Size מיקום וגודל + Posterize פוסטריזציה + Preserve Details שמור על פרטים + Preserve Luminosity שמור על בהירות אור + Preset ערכה קבועה מראש + Preset Type סוג ערכה קבועה מראש + Preset: ערכה קבועה מראש: + Presets: ערכות קבועות מראש: + Press keys… הקש מקשים… + Pressure לחיצה + Pressure for Opacity לחיצה לאטימות + Pressure for Size לחיצה לגודל + Prevent auto-nesting in and out of Artboards and Frames מנע קינון אוטומטי אל/מתוך לוחות ציור ומסגרות + Preview תצוגה מקדימה + Previous frame פריים קודם + Previous note הערה קודמת + Print הדפסה + Printer מדפסת + Printer Manages Colors המדפסת מנהלת צבעים + Printer Setup הגדרת מדפסת + Printing Marks סימוני הדפסה + Progressive מתקדם + Promote העלה בדרגה (Promote) + Protect Detail הגן על פרטים + Protect Foreground Color הגן על צבע חזית + Protect Tones הגן על גוונים + Proximity Match התאמת קרבה + Pulled String Mode מצב חוט מתוח + Puppet Warp is not active עיוות בובה אינו פעיל + Purity טוהר + Quality איכות + Quick Actions פעולות מהירות + Quick Export As PNG ייצוא מהיר כ-PNG + Radial Gradient מעבר צבעים רדיאלי + Radius רדיוס + Radius: רדיוס: + Range: טווח: + Rate קצב + Recently Used נעשה בו שימוש לאחרונה + Reconstruct שחזור בנייה (Reconstruct) + Record ● a sequence of edits, then play ▶ it on any document. הקלט ● רצף עריכות, ואז הפעל ▶ אותו על כל מסמך. + Red אדום + Redefine style by current selection הגדר מחדש סגנון לפי הבחירה הנוכחית + Refine חידוד (Refine) + Reflected Gradient מעבר צבעים מוחזר + Registration Marks סימוני רישום + Regular רגיל + Relative יחסי + Relative Colorimetric קולורימטרי יחסי + Remove הסר + Remove All Pins הסר את כל הסיכות + Remove Background הסר רקע + Remove Planes הסר מישורים + Remove Tool הסר כלי + Remove stop הסר נקודת עצירה + Remove variable הסר משתנה + Rename שנה שם + Rename Group… שנה שם קבוצה… + Rename Layer… שנה שם שכבה… + Rename Tool Preset… שנה שם ערכת כלי… + Rename… שנה שם… + Resample שינוי דגימה מחדש (Resample) + Reset All to Defaults אפס הכול לברירת המחדל + Import Shortcuts… ייבוא קיצורים… + Load a keyboard shortcut set saved as .kys טען ערכת קיצורי מקלדת שנשמרה כ-‎.kys + Imported {n} shortcuts from {file}. Click OK to keep them. יובאו {n} קיצורים מתוך {file}. לחץ על אישור כדי לשמור אותם. + {n} commands are not in PhotoCraft. {n} פקודות אינן קיימות ב-PhotoCraft. + Keyboard shortcuts imported קיצורי המקלדת יובאו + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. ערכת קיצורי המקלדת שלך {set} נמצאת בשימוש כאן: {n} קיצורים שונים מברירת המחדל. עריכה › קיצורי מקלדת מציג אותם; אפס הכול לברירת המחדל מבטל זאת. + Import keyboard shortcuts? לייבא קיצורי מקלדת? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft מצא את ערכת קיצורי המקלדת שלך {set} מעורך תמונות אחר במחשב זה: {n} מהקיצורים שלה שונים מאלה של PhotoCraft. לייבא אותה? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. עריכה › קיצורי מקלדת › אפס הכול לברירת המחדל מבטל ייבוא. + Don't Import אל תייבא + The keyboard shortcut set was not imported: {error} ערכת קיצורי המקלדת לא יובאה: {error} + Reset Section אפס מקטע + Reset Transform אפס שינוי צורה + Reset to defaults אפס לברירת המחדל + Resolution רזולוציה + Resolution: רזולוציה: + Restore All שחזר הכול + Restore Defaults שחזר ברירת מחדל + Restrictive (Web) מגביל (אינטרנט) + Right align text יישר טקסט לימין + Rigid קשיח + Rotate: סיבוב: + Rotation סיבוב + Roughness גסות (Roughness) + Roundness עגילות + Roundness Jitter ריצוד עגילות + Rulers & Grids סרגלים ורשתות + Sample All Layers דגום את כל השכבות + Sample: דגימה: + Sampling: Background Swatch דגימה: דוגמית רקע + Sampling: Continuous דגימה: רציפה + Sampling: Once דגימה: חד-פעמית + Saturate הגבר רוויה + Saturation רוויה + Saturation Jitter ריצוד רוויה + Save HTML and Images שמור HTML ותמונות + Save as PDF שמור כ-PDF + Save selection as channel שמור בחירה כערוץ + Save… שמור… + Scale to Fit Media שנה קנה מידה להתאמה למדיה + Scatter פיזור (Scatter) + Scratch disks כונני עבודה + Search חיפוש + Search commands חפש פקודות + Search commands, tools and panels… חפש פקודות, כלים ופאנלים… + Search fonts חפש גופנים + Select All בחר הכול + Select Subject בחר נושא + Select a type layer or click into text to insert glyphs בחר שכבת טקסט או לחץ בתוך הטקסט כדי להוסיף גליפים + Select text to apply a character style בחר טקסט כדי להחיל סגנון תווים + Select: בחירה: + Selections בחירות + Selective סלקטיבי + Selective Color צבע סלקטיבי + Send the selected pin backward שלח את הסיכה הנבחרת אחורה + Set additional smoothing options הגדר אפשרויות החלקה נוספות + Set background color הגדר צבע רקע + Set foreground color הגדר צבע חזית + Set painting symmetry options הגדר אפשרויות סימטריה בציור + Symmetry Off סימטריה כבויה + Set shape fill type הגדר סוג מילוי צורה + Set shape stroke type הגדר סוג קו מתאר צורה + Set the text color הגדר את צבע הטקסט + Create warped text צור טקסט מעוות + Shadows צללים + Shape: צורה: + Sharpening חידוד + Shift Shift + Shortcut removed. הקיצור הוסר. + Show All Menu Items הצג את כל פריטי התפריט + Show Backdrop הצג רקע (Backdrop) + Show Guides הצג קווים מנחים + Show Mask הצג מסכה + Show Mesh הצג רשת + Show Overlay הצג שכבת-על + Show Transform Controls הצג פקדי שינוי צורה + Show or hide the Notes panel הצג או הסתר את פאנל ההערות + Show: הצג: + Sides צלעות + Sides: צלעות: + Size גודל + Size Jitter ריצוד גודל + Size: גודל: + Slice Options… אפשרויות פרוסה… + Slices From Guides פרוסות מקווים מנחים + Slices are locked (View › Lock Slices) הפרוסות נעולות (תצוגה › נעל פרוסות) + Small Caps אותיות רישיות קטנות + Smoothing החלקה + Space After רווח אחרי + Space Before רווח לפני + Spacing ריווח + Spell check complete. בדיקת האיות הושלמה. + Split: פיצול: + Star ratio יחס כוכב + Stop playing/recording עצור הפעלה/הקלטה + Straighten יישור + Straighten Layer ישר שכבה + Strength: עוצמה: + Strikethrough קו חוצה + Stroke קו מתאר + Stroke Catch-up השלמת משיכה + Stroke: קו מתאר: + Location מיקום + Location: מיקום: + Style סגנון + Style Options אפשרויות סגנון + Style Options… אפשרויות סגנון… + Style: Normal סגנון: רגיל + Subtract from Sample החסר מהדוגמה + Subtract from selection ({key}) החסר מהבחירה ({key}) + Suggestions: הצעות: + Swap colours (X) החלף צבעים (X) + Swaps height and width מחליף גובה ורוחב + Switch between free transform and warp modes החלף בין מצבי שינוי צורה חופשי ועיוות + Switch theme החלף עיצוב + Temperature טמפרטורה + Tetrahedral טטרהדרלי + Text Position: מיקום טקסט: + Texture מרקם + Texture Each Tip מרקם לכל קצה + Threshold סף + Threshold Level רמת סף + Thumbnail size (smallest: list) גודל תמונה ממוזערת (הקטן ביותר: רשימה) + Tilt X הטיה X + Tilt Y הטיה Y + Tint טינט + Toggle count group visibility הצג/הסתר קבוצת ספירה + Toggle text orientation החלף כיוון טקסט + Tolerance סבילות (Tolerance) + Tolerance: סבילות: + Tone: גוון: + Toolbar סרגל כלים + Top עליון + Total: סך הכול: + Tracking מעקב תווים (Tracking) + Tracking (1/1000 em) מעקב תווים (1/1000 em) + Transparency שקיפות + Transparent שקוף + Trilinear תלת-ליניארי (Trilinear) + Type Styles סגנונות טקסט + Type: סוג: + Underline קו תחתי + Untitled ללא שם + Untitled-1 ללא שם-1 + Update layer comp (visibility, position and appearance) עדכן קומפ שכבות (מצב תצוגה, מיקום ומראה) + Use Default השתמש בברירת המחדל + Use Legacy השתמש בגרסה הישנה + Use Measurement Scale השתמש בקנה מידה למדידה + Variables bind a layer's visibility, text or pixels to a named data slot. משתנים מקשרים את מצב התצוגה, הטקסט או הפיקסלים של שכבה לחריץ נתונים בעל שם. + Vertical scale קנה מידה אנכי + Vibrance תוססות + View Options אפשרויות תצוגה + Vignetting ויניטה (Vignetting) + Violet, Orange סגול, כתום + Visibility מצב תצוגה + Visible גלוי + Warp: עיוות: + Web Snap הצמדה לאינטרנט + Weight משקל + Weight: משקל: + White לבן + White Balance: As Shot איזון לבן: כבצילום + Whites לבנים + Width רוחב + Slant שיפוע + Width: רוחב: + Work Path נתיב עבודה + Yellow צהוב + Yellow · Blue צהוב · כחול + Zoom תקריב + Zoom in הגדל תצוגה + Zoom out הקטן תצוגה + command or shortcut פקודה או קיצור + current נוכחי + image file path נתיב קובץ תמונה + modified שונה + name שם + new חדש + {name} options אפשרויות {name} + ↑↓ navigate {key} run esc close ↑↓ ניווט {key} הפעלה esc סגירה + Version {version} גרסה {version} + New Document מסמך חדש + New Document With Mask מסמך חדש עם מסכה + New Layer With Mask שכבה חדשה עם מסכה + New Layer שכבה חדשה + Save Preset… שמור ערכה קבועה מראש… + Preset Name שם ערכה קבועה מראש + No saved presets yet. אין עדיין ערכות קבועות מראש שמורות. + Error שגיאה + Command פקודה + Adjustment layers have nothing to transform לשכבות התאמה אין מה לשנות + Altitude גובה (זווית) (Altitude) + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua אקווה (Aqua) + Art & Illustration אמנות ואיורים + ArtCraft ArtCraft + Background Eraser Tool כלי מחק רקע + Bevel & Emboss הטבעה ותבליט + Blend Mode מצב מיזוג + Blues כחולים + Blur Tool כלי טשטוש + Bold מודגש + Brush מברשת + Block בלוק + Brush Pose תנוחת מברשת + Brush Tip Shape צורת קצה מברשת + Brush Tool כלי מברשת + Build-up הצטברות + Burn Tool כלי כהייה (Burn) + Centimeters סנטימטרים + Choke כיווץ (Choke) + Classic קלאסי + Click to add text לחץ כדי להוסיף טקסט + Click to fill similar colours לחץ כדי למלא צבעים דומים + Clone Stamp Tool כלי חותמת שכפול + Color Burn כהיית צבע (Color Burn) + Color Dodge הבהרת צבע (Color Dodge) + Color Dynamics דינמיקת צבע + Color Overlay שכבת-על צבע + Comic Book קומיקס + Conform התאמה (Conform) + Content-Aware Fill needs a selection on a pixel layer מילוי מודע לתוכן דורש בחירה בשכבת פיקסלים + Couldn't place {name}: {error} לא ניתן להציב את {name}: {error} + Could not transform: the layer is empty לא ניתן לשנות צורה: השכבה ריקה + Could not use Perspective Warp: the layer is empty לא ניתן להשתמש בעיוות פרספקטיבה: השכבה ריקה + Could not use Puppet Warp: no opaque pixels לא ניתן להשתמש בעיוות בובה: אין פיקסלים אטומים + Could not use Puppet Warp: the layer is empty לא ניתן להשתמש בעיוות בובה: השכבה ריקה + Crop Tool כלי חיתוך + Crosswise לרוחב (Crosswise) + Current Tool הכלי הנוכחי + Custom Shape Tool כלי צורה מותאמת אישית + Cyans ציאנים + Default PhotoCraft Size גודל ברירת מחדל של PhotoCraft + Delete Workspace מחק סביבת עבודה + Diamond יהלום + Dimensions: מידות: + Direction כיוון + Distance מרחק + Document Dimensions מידות המסמך + Document Profile פרופיל המסמך + Document Sizes גדלי מסמך + Dodge Tool כלי הבהרה (Dodge) + Down מטה + Drag to draw a gradient גרור כדי לצייר מעבר צבעים + Drag to move the active layer גרור כדי להזיז את השכבה הפעילה + Drag to pan · hold Space with any tool גרור כדי לגלול · החזק Space עם כל כלי + Drop Shadow צל נפל + Dual Brush מברשת כפולה + Edge קצה + Efficiency יעילות + Efficiency: 100% יעילות: 100% + Ellipse Tool כלי אליפסה + Elliptical Marquee Tool כלי בחירה אליפטי + Single Row Marquee Tool כלי בחירה שורה בודדת + Single Column Marquee Tool כלי בחירה עמודה בודדת + Emboss תבליט + Eraser מחק + Eraser Tool כלי מחק + ExtraBold מודגש מאוד + ExtraLight קלוש מאוד + Eyedropper Tool כלי טפטפת + Fade דהייה + Fill Opacity אטימות מילוי + Film & Video סרט ווידאו + From מ- + General כללי + GitHub GitHub + Gradient Overlay שכבת-על מעבר צבעים + Gradient Tool כלי מעבר צבעים + Greens ירוקים + Hand Tool כלי יד + Hard קשה + Hard Mix מיקס קשה (Hard Mix) + Healing Brush Tool כלי מברשת ריפוי + Highlights from: הדגשות מ: + History Brush Tool כלי מברשת היסטוריה + Horizontal Type Tool כלי טקסט אופקי + Horizontal Type Mask Tool כלי מסכת טקסט אופקי + Vertical Type Mask Tool כלי מסכת טקסט אנכי + Image Size: גודל תמונה: + Inches אינצ'ים + Indexed מאונדקס + Initial Direction כיוון התחלתי + Inner Bevel הטבעה פנימית + Inner Glow זוהר פנימי + Inner Shadow צל פנימי + Lab Lab + Landscape, 10 x 8 לרוחב, 10 x 8 + Landscape, 6 x 4 לרוחב, 6 x 4 + Landscape, 7 x 5 לרוחב, 7 x 5 + Lasso לאסו + Lasso Tool כלי לאסו + Layer Count מספר שכבות + Layer Knocks Out Drop Shadow השכבה מנקבת את הצל הנפל (Knocks Out) + Legal Legal + Letter Letter + Light קליל (Light) + Line Tool כלי קו + Linear לינארי + Linear Burn כהייה לינארית + Linear Height גובה לינארי + Link with Layer קשר עם שכבה + Lorem Ipsum Lorem Ipsum + Magentas מגנטות + Magic Eraser Tool כלי מחק קסם + Magic Wand Tool כלי שרביט קסם + Marquee בחירה מלבנית (Marquee) + Mean: ממוצע: + Measurement Scale קנה מידה למדידה + Measurement Scale Marker סמן קנה מידה למדידה + Millimeters מילימטרים + Mobile נייד + Move Tool כלי הזזה + Could not use the move tool because the layer is locked. לא ניתן להשתמש בכלי ההזזה כי השכבה נעולה. + Could not complete the Free Transform command because the layer is locked. לא ניתן היה להשלים את פקודת שינוי הצורה החופשי כי השכבה נעולה. + Multiply הכפלה + My Brushes המברשות שלי + Neutrals ניטרליים + New Workspace סביבת עבודה חדשה + Note Tool כלי הערה + Object Selection Tool כלי בחירת אובייקטים + Off כבוי + Open פתח + Orange כתום + Original Ratio יחס מקורי + Out Of Gamut מחוץ לטווח הצבעים + Outer Bevel הטבעה חיצונית + Outer Glow זוהר חיצוני + Overlay שכבת-על (Overlay) + Paint Bucket Tool כלי דלי צבע + Paints from the document's opening state צובע מהמצב הפתיחה של המסמך + Path Selection Tool כלי בחירת נתיב + Direct Selection Tool כלי בחירה ישירה + Add Anchor Point Tool כלי הוספת נקודת עוגן + Delete Anchor Point Tool כלי מחיקת נקודת עוגן + Click a segment to add an anchor point · {alt}-click an anchor to delete it לחץ על קטע כדי להוסיף נקודת עוגן · {alt}-לחיצה על עוגן כדי למחוק אותו + Click an anchor point to delete it · {alt}-click a segment to add one לחץ על נקודת עוגן כדי למחוק אותה · {alt}-לחיצה על קטע כדי להוסיף אחת + Pattern Overlay שכבת-על תבנית + Pen Pressure לחיצת עט + Pen Tilt הטיית עט + Pen Tool כלי קולמוס + Convert Point Tool כלי המרת נקודה + Photo תמונה + PhotoCraft website אתר PhotoCraft + Picas פיקות (Picas) + Pillow Emboss תבליט כרית + Pixel Replacement החלפת פיקסלים + Pixels פיקסלים + Points נקודות + Polygon Tool כלי מצולע + Polygonal Lasso Tool כלי לאסו מצולע + Portrait, 4 x 6 לאורך, 4 x 6 + Portrait, 5 x 7 לאורך, 5 x 7 + PostScript (72 points/inch) PostScript ‏(72 נקודות/אינץ') + Postcard גלויה + Poster פוסטר + Pro (Dark) Pro (כהה) + Pro (Light) Pro (בהיר) + Pro (Light Gray) Pro (אפור בהיר) + Pro (Medium Gray) Pro (אפור בינוני) + Protect Texture הגן על מרקם + Purple סגול + Quick Mask מסכה מהירה + Quick Selection Tool כלי בחירה מהירה + Radial רדיאלי + Rasterize Layer רסטור שכבה + Rasterize Layers רסטור שכבות + Ratio יחס + Recent אחרונים + Rectangle Tool כלי מלבן + Rectangular Marquee Tool כלי בחירה מלבני + Reds אדומים + Reflected מוחזר + Sample: foreground colour דגימה: צבע חזית + Sampled Colors צבעים נדגמים + Satin סאטן + Save Pixel Aspect Ratio שמור יחס גובה-רוחב של פיקסל + Scattering פיזור + Scratch Sizes גדלי זיכרון עבודה + Select Data Points בחר נקודות נתונים + Selection בחירה + SemiBold מודגש-חלקי + Shadows up to: צללים עד: + Shape Dynamics דינמיקת צורה + Sharpen Tool כלי חידוד + Show Extras Options הצג אפשרויות תוספות + Slice Select Tool כלי בחירת פרוסה + Slice Tool כלי פרוסה + Smart Filters מסננים חכמים + Smudge Tool כלי מריחה + Soft רך + Soften החלשה (Soften) + Source מקור + Source: set מקור: הוגדר + Sponge Tool כלי ספוג + Spot Healing Brush Tool כלי מברשת ריפוי נקודתי + Spread התפשטות + Square, 12 x 12 ריבוע, 12 x 12 + Square, 5 x 5 ריבוע, 5 x 5 + Std Dev: סטיית תקן: + Studio (Dark) Studio (כהה) + Studio (Light) Studio (בהיר) + Stylus Wheel גלגל עט (Stylus) + Subtract הפחתה + Tabloid Tabloid + Text Replacement החלפת טקסט + Thin דק + Traditional (72.27 points/inch) מסורתי (72.27 נקודות/אינץ') + Transfer העברה (Transfer) + Triangle Tool כלי משולש + Unsaved changes שינויים שלא נשמרו + Uses the foreground and background colours. משתמש בצבעי החזית והרקע. + Web אינטרנט + Web Large אינטרנט גדול + Web Minimum אינטרנט מינימלי + Web Most Common אינטרנט נפוץ ביותר + WebP (lossless) WebP (ללא אובדן) + Wet Edges קצוות רטובים + Yellows צהובים + Zoom Tool כלי תקריב + {name} Properties מאפייני {name} + Revert “{name}” to the last saved version? Your changes will be lost. לשחזר את "{name}" לגרסה האחרונה שנשמרה? השינויים שלך יאבדו. + Do you want to save the changes you made to “{name}” before quitting? האם לשמור את השינויים שעשית ב"{name}" לפני היציאה? + Do you want to save the changes you made to “{name}” before closing? האם לשמור את השינויים שעשית ב"{name}" לפני הסגירה? + Doc: {flat}/{layered} מסמך: {flat}/{layered} + Scratch: {size} זיכרון עבודה: {size} + Untagged {mode} {mode} ללא תג + Invalid {mode} profile פרופיל {mode} לא תקין + Unnamed {mode} profile פרופיל {mode} ללא שם + {w} px x {h} px ({ppi} ppi) {w} פיקס' x {h} פיקס' ‏({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ‏({ppi} ppi) + Resolution: {dpi} pixels/inch רזולוציה: {dpi} פיקסלים/אינץ' + Doc: {w} × {h} px מסמך: {w} × {h} פיקס' + Frame {current} / {total} פריים {current} / {total} + Count: {n} ספירה: {n} + BLANK DOCUMENT PRESETS ({n}) ערכות מסמך ריק ({n}) + Constraints ({n}) אילוצים ({n}) + Align {what} יישר {what} +@plural {n} Layer|{n} Layers {n} שכבה|{n} שכבות +@plural {n} layer|{n} layers {n} שכבה|{n} שכבות +@plural Group · {n} layer|Group · {n} layers קבוצה · {n} שכבה|קבוצה · {n} שכבות + Additional plugins folder תיקיית תוספים נוספת + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control אפשר שליטת סוכן + Allow scripts to connect אפשר לסקריפטים להתחבר + Always תמיד + Always create smart objects when placing צור תמיד אובייקטים חכמים בעת הצבה + Animated zoom תקריב מונפש + Apply auto tone החל גוון אוטומטי + Ask שאל + Ask before saving layered tiff שאל לפני שמירת TIFF עם שכבות + Auto collapse icon panels קפל אוטומטית פאנלי סמלים + Auto show hidden panels הצג אוטומטית פאנלים מוסתרים + Auto show home screen הצג אוטומטית מסך הבית + Autosave שמירה אוטומטית + Autosave minutes דקות לשמירה אוטומטית + Beep when done צפצף בסיום + Bicubic automatic דו-מעוקב אוטומטי + Bicubic sharper דו-מעוקב חד יותר + Bicubic smoother דו-מעוקב חלק יותר + Bit depth עומק סיביות + Brush preview color צבע תצוגה מקדימה של מברשת + Cache levels רמות מטמון + Cache tile size גודל אריח מטמון + Camera Raw Defaults ברירות מחדל של Camera Raw + Canvas border גבול קנבס + Show canvas dots הצג נקודות קנבס + Clip thumbnails to layer bounds גזור תמונות ממוזערות לגבולות השכבה + Clip Thumbnails to Layer Bounds גזור תמונות ממוזערות לגבולות השכבה + Canvas color צבע קנבס + Canvas custom color צבע קנבס מותאם אישית + Color picker בוחר צבעים + Color space מרחב צבע + Column width רוחב עמודה + Concise תמציתי + Control port פורט בקרה + Convert to srgb המר ל-sRGB + Cursors סמנים + Custom dark מותאם אישית כהה + Custom light מותאם אישית בהיר + Dark כהה + Dark gray אפור כהה + Dashed lines קווים מקווקווים + Destination יעד + Detailed מפורט + Disks דיסקים + Display p3 Display P3 + Double click layer mask launches select and mask לחיצה כפולה על מסכת שכבה מפעילה בחירה ומיסוך + Drop shadow צל נפל + Dynamic color sliders מחוונים דינמיים לצבע + East asian מזרח אסייתי + Effect cache mb מטמון אפקטים (MB) + Enable flick panning אפשר גלילה בתנועת הברשה + Enable floating document window docking אפשר עגינה של חלון מסמך צף + Enable narrow options bar אפשר סרגל אפשרויות צר + Enabled מאופשר + Enhanced Controls פקדים משופרים + Export clipboard ייצוא לוח גזירים + Extra large גדול מאוד + File Handling טיפול בקבצים + File path נתיב קובץ + Fill new type layers with placeholder מלא שכבות טקסט חדשות בטקסט placeholder + Font preview תצוגה מקדימה של גופן + Full size tip קצה בגודל מלא + Gamut warning color צבע אזהרת טווח צבעים + Gamut warning opacity אטימות אזהרת טווח צבעים + Enable Generator אפשר Generator + Gif GIF + Glossy מבריק + Grid color צבע רשת + Grid colors צבעי רשת + Grid size גודל רשת + Grid style סגנון רשת + Grid unit יחידת רשת + Gridline every קו רשת כל + Guide color צבע קו מנחה + Guide style סגנון קו מנחה + Guides, Grid & Slices קווים מנחים, רשת ופרוסות + Gutter מרווח (Gutter) + History Log יומן היסטוריה + History states מצבי היסטוריה + Ignore exif profile tag התעלם מתג פרופיל EXIF + Image interpolation אינטרפולציית תמונה + Image previews תצוגות מקדימות של תמונה + Integrations שילובים + Interface ממשק + Jpeg quality איכות JPEG + Jpg JPG + Language שפה + Large tabs כרטיסיות גדולות + Legacy compositing הרכבה ישנה (Legacy) + Light gray אפור בהיר + Line שורה + Lines שורות + Lossless ללא אובדן + Lowercase extension סיומת באותיות קטנות + Maximize psd compatibility מקסם תאימות PSD + Medium gray אפור בינוני + Memory usage mb שימוש בזיכרון (MB) + Low resolution previews תצוגות מקדימות ברזולוציה נמוכה + Missing glyph protection הגנה על גליפים חסרים + Modern מודרני + Nearest neighbor שכן קרוב + Never לעולם לא + Normal tip קצה רגיל + Open as smart object פתח כאובייקט חכם + Open in camera raw פתח ב-Camera Raw + Open documents as tabs פתח מסמכים כלשוניות + Overscroll גלילת יתר + Performance ביצועים + Png PNG + Point size גודל נקודה + Precise מדויק + Preserve details שמור על פרטים + Print resolution רזולוציית הדפסה + Pro Pro + Pro light Pro בהיר + Pro light gray Pro אפור בהיר + Pro medium Pro בינוני + Pro photo Pro Photo + Quick export format תבנית ייצוא מהיר + Quick export location מיקום ייצוא מהיר + Recent file count מספר קבצים אחרונים + LUT folder תיקיית LUT + Recent files קבצים אחרונים + Recent fonts גופנים אחרונים + Recover on launch שחזר בעת הפעלה + Remember workspace changes זכור שינויי סביבת עבודה + Resize image during place שנה גודל תמונה בעת הצבה + Rotate view with trackpad סובב תצוגה עם משטח מגע + Same folder אותה תיקייה + Save in background שמור ברקע + Scratch Disks כונני עבודה + Screen מסך + Screen resolution רזולוציית מסך + Scrubby slider acceleration האצת מחוון scrubby + Sessions only הפעלות בלבד (Sessions) + Sharpen for חדד בשביל + Show channels in color הצג ערוצים בצבע + Show crosshair in brush tip הצג כוונת בקצה המברשת + Show extension panels הצג פאנלי הרחבה + Show font names in english הצג שמות גופנים באנגלית + Show menu colors הצג צבעי תפריט + Show only crosshair while painting הצג רק כוונת בעת ציור + Show slice numbers הצג מספרי פרוסות + Show tooltips הצג רמזי מקלדת + Enable animations אפשר הנפשות + Show highlight on rollover הצג הדגשה במעבר עכבר + Show bounding box when dragging layer הצג תיבה תוחמת בעת גרירת שכבה + System title bar סרגל כותרת מערכת + Show transformation values הצג ערכי שינוי צורה + Slice color צבע פרוסה + Smart guide color צבע קו מנחה חכם + Smart quotes מירכאות חכמות + Snap vector tools and transforms to pixel grid הצמד כלי וקטור ושינויי צורה לרשת פיקסלים + Srgb sRGB + Standard תקני + Studio Studio + Studio light Studio בהיר + Adwaita Adwaita + Adwaita dark Adwaita כהה + Subdivisions חלוקות משנה + System מערכת + Text engine מנוע טקסט + Text file קובץ טקסט + Tiny קטן מאוד + Touch gestures תנועות מגע + Transparency & Gamut שקיפות וטווח צבעים + Type units יחידות טקסט + UI font size גודל גופן ממשק + UI scale קנה מידה של ממשק + Units & Rulers יחידות וסרגלים + Use additional plugins folder השתמש בתיקיית תוספים נוספת + Use esc to commit השתמש ב-Esc לאישור + Use gpu השתמש ב-GPU + Use legacy free transform השתמש בשינוי צורה חופשי ישן + Use shift key for tool switch השתמש במקש Shift להחלפת כלי + Vary round brush hardness on hud שנה קשיחות מברשת עגולה ב-HUD + Webp WebP + World ready מוכן גלובלית (World Ready) + Zoom clicked point to center תקרב את הנקודה שנלחצה למרכז + Zoom resizes windows תקריב משנה גודל חלונות + Zoom with scroll wheel תקריב עם גלגל גלילה + Zoom with trackpad pinch תקריב עם צביטה במשטח מגע + Pass Through מעבר (Pass Through) + Dissolve פירוק (Dissolve) + Darker Color צבע כהה יותר + Linear Dodge (Add) הבהרה לינארית (חיבור) + Lighter Color צבע בהיר יותר + Soft Light אור רך + Hard Light אור קשה + Vivid Light אור חזק (Vivid) + Linear Light אור לינארי + Pin Light אור סיכה (Pin Light) + Exclusion החרגה (Exclusion) + Divide חלוקה + Luminosity בהירות אור +@plural {n} sample|{n} samples {n} דוגמה|{n} דוגמאות + {added}, {removed} subtracted {added}, {removed} הוחסרו + Adjustment Preview תצוגה מקדימה של התאמה + Airbrush: keeps depositing paint while the pointer rests. אירוגרף: ממשיך להפקיד צבע כל עוד המצביע נשאר במקום. + Angle: זווית: + Blend If מיזוג אם (Blend If) + Close Tab Group סגור קבוצת כרטיסיות + Collapse כווץ + Collapse All Groups כווץ את כל הקבוצות + Collapse Panel Group כווץ קבוצת פאנלים + Collapse group ({key}-click: all groups) כווץ קבוצה ({key}-לחיצה: כל הקבוצות) + Create new brush from the current settings צור מברשת חדשה מההגדרות הנוכחיות + Create new brush from these settings צור מברשת חדשה מהגדרות אלה + Update the current brush with these settings עדכן את המברשת הנוכחית עם הגדרות אלה + Delete brush מחק מברשת + Drag the arrow to set the angle, the dots to set the roundness גרור את החץ כדי להגדיר את הזווית, את הנקודות כדי להגדיר את העגילות + Expand הרחב + Expand Panel Group הרחב קבוצת פאנלים + Expand group ({key}-click: all groups) הרחב קבוצה ({key}-לחיצה: כל הקבוצות) + Fade steps צעדי דהייה + Hide the layer's effects ({key}-click: all layers) הסתר את אפקטי השכבה ({key}-לחיצה: כל השכבות) + Import Brushes… (.abr or image) ייבוא מברשות… (‎.abr או תמונה) + Import Brush from Image… ייבוא מברשת מתמונה… + Move Group Down הזז קבוצה מטה + Move Group Up הזז קבוצה מעלה + Reset the brush to the defaults אפס את המברשת לברירת המחדל + Restore original size שחזר גודל מקורי + Roundness: עגילות: + Search Brushes חפש מברשות + Show the layer's effects ({key}-click: all layers) הצג את אפקטי השכבה ({key}-לחיצה: כל השכבות) + Brush picker בוחר מברשות + Erase מחק + Right click with painting tools לחיצה ימנית עם כלי ציור + System Info… פרטי מערכת… + System Info פרטי מערכת + More panels פאנלים נוספים + More documents מסמכים נוספים + These settings aren't available in PhotoCraft yet. הגדרות אלה אינן זמינות עדיין ב-PhotoCraft. + Graphics גרפיקה + Reset GPU Backend אפס מנגנון GPU (Backend) + Applies at next launch. חל בהפעלה הבאה. + Click to sample the foreground colour · {key}-click for background לחץ כדי לדגום את צבע החזית · {key}-לחיצה לרקע + {key}-click to set the source {key}-לחיצה כדי להגדיר את המקור + {key} to subtract {key} להפחתה + Clone source {n} ({key}-click with Clone Stamp to set) מקור שכפול {n} ‏({key}-לחיצה עם חותמת השכפול כדי להגדיר) + Source: not set ({key}-click with Clone Stamp) מקור: לא הוגדר ‏({key}-לחיצה עם חותמת השכפול) + Pixel Layer שכבת פיקסלים + Type Layer שכבת טקסט + Shape Layer שכבת צורה + Group Layer שכבת קבוצה + Fill Layer שכבת מילוי + Smart Object Layer שכבת אובייקט חכם + Create Warped Text צור טקסט מעוות + Rasterize Smart Object רסטור אובייקט חכם + Rasterize Fill Content רסטור תוכן מילוי + Rasterize Layer Style רסטור סגנון שכבה + Unlock: picking a preset replaces these settings בטל נעילה: בחירת ערכה קבועה מראש מחליפה הגדרות אלה + Lock: keep these settings when picking another preset נעל: שמור הגדרות אלה בעת בחירת ערכה אחרת + Diameter: קוטר: + Hardness: קשיחות: + No brush presets אין ערכות מברשת קבועות מראש + Toggle the Brush Settings panel (F5) הצג/הסתר את פאנל הגדרות המברשת (F5) + Create new brush preset from the current settings צור ערכת מברשת חדשה מההגדרות הנוכחיות + Brush Settings… (F5): every option of this brush הגדרות מברשת… ‏(F5): כל אפשרות של מברשת זו + Rename Brush… שנה שם מברשת… + Delete Brush מחק מברשת + New Brush Preset… ערכת מברשת חדשה… + Capture Brush Size in Preset לכוד גודל מברשת בערכה + Include Tool Settings הכלל הגדרות כלי + Include Color הכלל צבע + Enter a name for the brush preset הזן שם לערכת המברשת + A brush preset with this name already exists ערכת מברשת בשם זה כבר קיימת + Brush Preset Options אפשרויות ערכת מברשת + Group name שם קבוצה + Brush name שם מברשת + Brush Name שם מברשת + Brush Stroke משיכת מברשת + Brush Tip קצה מברשת + Off: the speed of the pointer sets the spacing כבוי: מהירות המצביע קובעת את הריווח + Tilt Scale קנה מידה של הטיה + Brush Projection הטלת מברשת + Apply the pen's tilt and rotation to the tip shape החל את הטיית העט והסיבוב שלו על צורת הקצה + Mixer Brush מברשת מערבלת + Mixer Brush Tool כלי מברשת מערבלת + Color Replacement Tool כלי החלפת צבע + Wet רטוב + Current Brush Load כמות הצבע הנוכחית במברשת + Load Brush טען מברשת + Clean Brush נקה מברשת + Load the brush after each stroke טען את המברשת לאחר כל משיכה + Clean the brush after each stroke נקה את המברשת לאחר כל משיכה + Mix מיזוג + Wetness Jitter ריצוד לחות + Mix Jitter ריצוד מיזוג + Show the layer mask as an overlay הצג את מסכת השכבה כשכבת-על + Toggle visibility הצג/הסתר + Unlink the mask from the layer בטל קישור בין המסכה לשכבה + Link the mask to the layer קשר את המסכה לשכבה + Gradient מעבר צבעים + Gradient Options אפשרויות מעבר צבעים + Align with layer יישר עם השכבה + Reset Alignment אפס יישור + Centre the gradient (offset 0, 0) מרכז את מעבר הצבעים (היסט 0, 0) + Gradient Editor עורך מעברי צבעים + Gradient picker בוחר מעברי צבעים + Save As Preset שמור כערכה קבועה מראש + The gradient the Gradient tool paints with מעבר הצבעים שבו צובע כלי מעבר הצבעים + Adds the current gradient to the Gradients panel מוסיף את מעבר הצבעים הנוכחי לפאנל מעברי הצבעים + Gradient stops נקודות עצירה של מעבר צבעים + Select a type layer to edit its character and paragraph settings. בחר שכבת טקסט כדי לערוך את הגדרות התווים והפסקה שלה. + Text color צבע טקסט + Type Options אפשרויות טקסט + Anti-aliasing method שיטת החלקת קצוות + Horizontal type טקסט אופקי + Vertical type טקסט אנכי + Fill path with foreground color מלא נתיב בצבע חזית + Stroke path with brush צייר קו מתאר לנתיב עם מברשת + Load path as a selection טען נתיב כבחירה + Make work path from selection צור נתיב עבודה מבחירה + Add vector mask הוסף מסכת וקטור + Create new path צור נתיב חדש + Delete path מחק נתיב + Delete vector mask מחק מסכת וקטור + CPU (no GPU acceleration) CPU ‏(ללא האצת GPU) + DirectX 12 DirectX 12 + GPU backend מנגנון GPU + Linux display server שרת תצוגה ב-Linux + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure השתמש בלחיצת טאבלט + Vulkan Vulkan + Alt-click to define a source point to clone from Alt-לחיצה כדי להגדיר נקודת מקור לשכפול + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. אין נתיב ליצירת בחירה ממנו: צייר אחד עם כלי הקולמוס או בחר אחד בפאנל הנתיבים. + Cancel opening בטל פתיחה + Cancelled opening {name} פתיחת {name} בוטלה + Damaged Photoshop document מסמך Photoshop פגום + The document “{name}” may be damaged. המסמך "{name}" עשוי להיות פגום. + Some layers, pixels or masks could not be read. Do you want to continue opening it? לא ניתן לקרוא חלק מהשכבות, הפיקסלים או המסכות. האם להמשיך בפתיחתו? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. אם תמשיך, שמירה על גבי הקובץ המקורי עלולה להשליך לצמיתות נתונים שלא ניתנים לקריאה. שמור עותק כדי לשמור על המקור. + The document “{name}” does not have an embedded {mode} profile. למסמך "{name}" אין פרופיל {mode} מוטבע. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. למסמך "{name}" יש פרופיל צבע מוטבע שאינו תואם למרחב העבודה הנוכחי של {mode}. + Working: {working} מרחב עבודה: {working} + Developing {name} מפתח את {name} + Cancelled {label} {label} בוטל + Esc to cancel Esc לביטול + Opened {name} {name} נפתח + Opening {name} פותח את {name} + Saved {name} {name} נשמר + Saving {name} שומר את {name} + Saving {percent} שומר {percent} + A save to {name} is still running; try again when it finishes שמירה ל-{name} עדיין פועלת; נסה שוב כשתסתיים + {label} is writing the file and can no longer be cancelled {label} כותב את הקובץ ולא ניתן יותר לבטל + Opening {name}… פותח את {name}… + Opening… פותח… + Reading… קורא… + Working… עובד… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: רשת: + Color Range טווח צבעים + Adaptive Wide Angle זווית רחבה אדפטיבית + Chalk & Charcoal גיר ופחם + Charcoal פחם + Chrome כרום + Conté Crayon עפרון קונטה + Graphic Pen עט גרפי + Halftone Pattern תבנית חצי-טון + Note Paper נייר הערות + Photocopy העתק צילומי + Plaster טיח (Plaster) + Reticulation רשתור (Reticulation) + Stamp חותמת + Torn Edges קצוות קרועים + Water Paper נייר מים + Move הזזה + Pencil עפרון + Clone Stamp חותמת שכפול + Dodge הבהרה (Dodge) + Burn כהייה (Burn) + Quick Selection בחירה מהירה + History Brush מברשת היסטוריה + Color Replacement החלפת צבע + New Group קבוצה חדשה + Layer Comp קומפ שכבות + Alpha אלפא + Make תוצר (Make) + Model דגם + F F + Artist אמן + Spot Color צבע ספוט (Spot) + Roman רומי (Roman) + Bold Italic מודגש ונטוי + Justification יישור לשני הצדדים + Symbols סמלים + Punctuation סימני פיסוק + Make Work Path צור נתיב עבודה + Make Selection צור בחירה + Fill Path מלא נתיב + Stroke Path צייר קו מתאר לנתיב + Delete Path מחק נתיב + W W + H H + Ruler סרגל + New Layer Comp קומפ שכבות חדש + Duplicate Layer Comp שכפל קומפ שכבות + Rename Layer Comp שנה שם קומפ שכבות + Apply Layer Comp החל קומפ שכבות + Update Layer Comp עדכן קומפ שכבות + Method שיטה + Author מחבר + Software תוכנה + Pencil Tool כלי עיפרון + Pixel פיקסל + Adjustment התאמה + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract גרור (לאסו) או לחץ על נקודות (מצולע) · {add} הוספה · {sub} הפחתה + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels גרור תיבת חיתוך · גרירה בפנים להזזה · קצוות משנים גודל (יחס {ratio}, מרכז {centre}) · Space מזיז בעת ציור · חצים מזיזים בעדינות · X מחליף כיוון · {commit} מאשר · Esc מבטל + {mode} Color · {bits} bit צבע {mode} · ‏{bits} סיביות + Scrubby Zoom תקריב scrubby + Auto Erase מחיקה אוטומטית + Color Adaptation הסתגלות צבע + Preserve Transparency שמור על שקיפות + shortcut קיצור + Saved נשמר + Arc קשת + Arch כיפה (Arch) + Bulge בליטה + Shell Lower קונכייה תחתית + Shell Upper קונכייה עליונה + Flag דגל + Fish דג + Wave גל + Rise עלייה + Inflate נפיחה + Twist פיתול + Squeeze לחיצה (Squeeze) + Mask מסכה + Channel ערוץ + Rectangle מלבן + New Channel ערוץ חדש + Save Selection שמור בחירה + Load Selection טען בחירה +@id edit.purge.undo בטל (Purge) + Rendering Mode מצב עיבוד + Automatic (recommended) אוטומטי (מומלץ) + CPU / Compatibility CPU / תאימות + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. אוטומטי משתמש באצת GPU כשהיא זמינה ועובר לעיבוד CPU במקרה של שגיאות. + Switched to CPU rendering הוחלף לעיבוד CPU + GPU acceleration could not continue. Your documents are unchanged. האצת ה-GPU לא יכלה להמשיך. המסמכים שלך לא השתנו. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft משתמש במרכיב התמונה של ה-CPU. החלון עדיין עשוי להשתמש במאיץ הגרפי שלך. + Retry GPU requires a restart. Save your work first. ניסיון חזרה ל-GPU מחייב הפעלה מחדש. שמור את העבודה שלך קודם. + Retry GPU tries the graphics processor again now. ניסיון חזרה ל-GPU מנסה את המעבד הגרפי מחדש כעת. + GPU acceleration resumed. האצת ה-GPU התחדשה. + Keep Using CPU המשך להשתמש ב-CPU + Retry GPU נסה GPU שוב + Advanced מתקדם + GPU Backend מנגנון GPU + Details פרטים + Rendering mode מצב עיבוד + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) הקפאת לאסו: גרור כדי להקפיא אזור, Alt-גרירה כדי להפשיר אותו (L) + Shadow Clipping Warning (U) אזהרת גזירת צללים (U) + Highlight Clipping Warning (O) אזהרת גזירת הדגשות (O) + Show Lab Color Readouts הצג קריאות צבע Lab + Show Vectorscope הצג וקטורוסקופ + Show Red at 3 o'clock הצג אדום בשעה 3 + Show Skin Tone Indicator הצג מחוון גוון עור + Tone Histogram היסטוגרמת גוון + Show Selected Region הצג אזור נבחר +cameraRaw Light אור + Camera Raw Filter ({layer}) מסנן Camera Raw ‏({layer}) + Camera Raw ({name}) Camera Raw ‏({name}) +cameraRaw Lights בהירים +cameraRaw Darks כהים +cameraRaw Red אדום +cameraRaw Orange כתום +cameraRaw Yellow צהוב +cameraRaw Green ירוק +cameraRaw Aqua אקווה +cameraRaw Blue כחול +cameraRaw Purple סגול +cameraRaw Magenta מגנטה +cameraRaw Blending מיזוג +cameraRaw Tint טינט +cameraRaw Temperature טמפרטורה +cameraRaw Amount מידה + Patch Tool כלי טלאי + Patch: טלאי: + Lasso around an area, then drag the selection צייר לאסו מסביב לאזור, ואז גרור את הבחירה + Transform On Drop שנה צורה בעת שחרור + Scale or rotate the moved area, then press {key} שנה קנה מידה או סובב את האזור שהוזז, ואז הקש {key} + Now drag the selection onto another area כעת גרור את הבחירה לאזור אחר + Edit Type ערוך טקסט + New Style… סגנון חדש… + Style {name} saved הסגנון {name} נשמר + Create new style (from the selected type) צור סגנון חדש (מהטקסט הנבחר) + Create new style from the selected layer צור סגנון חדש מהשכבה הנבחרת + Make Default הגדר כברירת מחדל + Reset to Default אפס לברירת המחדל + Full-resolution preview unavailable תצוגה מקדימה ברזולוציה מלאה אינה זמינה + No patterns אין תבניות + Enable {name} אפשר {name} + Hold Alt while drawing for straight segments החזק Alt בעת ציור לקטעים ישרים + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts גרור או לחץ על נקודות מצולע · החזק Alt במהלך לאסו לקטעים ישרים · {add} הוספה · {sub} לפני הציור מפחית + Extend הרחבה + Structure: מבנה: + Content-Aware Move Tool כלי הזזה מודע לתוכן + TIFF Options אפשרויות TIFF + Targa Options אפשרויות Targa + JPEG Options אפשרויות JPEG + Image Options אפשרויות תמונה + Format Options אפשרויות תבנית + Baseline ("Standard") בסיס ("תקני") + Baseline Optimized בסיס מותאם + 24 bits/pixel 24 סיביות/פיקסל + 32 bits/pixel 32 סיביות/פיקסל + Including layers will increase file size. הכללת שכבות תגדיל את גודל הקובץ. + Discard Layers and Save a Copy השלך שכבות ושמור עותק + Don't show again אל תציג שוב + Save failed: {error} השמירה נכשלה: {error} + Save cancelled השמירה בוטלה + Magnetic Lasso Tool כלי לאסו מגנטי + Frequency תדירות + Use tablet pressure to change pen width השתמש בלחיצת טאבלט לשינוי רוחב הקו + About אודות + Contributors תורמים + Models דגמים + Sort מיון + Username שם משתמש + Display name שם תצוגה + Real name שם אמיתי + Reverse the order הפוך את הסדר + Grab bag תיבת אוסף (Grab bag) + Table טבלה + Name (A–Z) שם (א-ת) + Merged PRs בקשות מיזוג ממוזגות (PRs) + Commits קומיטים (Commits) + Lines added שורות שהוספו + Lines deleted שורות שנמחקו + Line delta שינוי בשורות + Binary assets added נכסים בינאריים שהוספו + Binary assets removed נכסים בינאריים שהוסרו + First commit קומיט ראשון + First ראשון + Last commit קומיט אחרון + Last אחרון + Contributors: {contributors} · Commits: {commits} תורמים: {contributors} · קומיטים: {commits} + No contributor data was built into this copy. לא נבנו נתוני תורמים בעותק זה. + No model credits were built into this copy. לא נבנו קרדיטים של דגמים בעותק זה. + Company חברה + Version גרסה + % of all commits % מכל הקומיטים + Lines +/− שורות +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: ‏{prs} PRs, ‏{commits} קומיטים, ‏+{added} / −{deleted} שורות (Δ {delta}), ‏+{binAdded} / −{binDeleted} נכסים בינאריים, ‏{first} – {last} + Create new swatch צור דוגמית צבע חדשה + New Swatch… דוגמית צבע חדשה… + New Swatch Group… קבוצת דוגמיות חדשה… + Import Swatches… ייבוא דוגמיות… + Replace Swatches… החלף דוגמיות… + Export Swatches… ייצוא דוגמיות… + Export Swatches for Exchange… ייצוא דוגמיות להחלפה… + Reset Swatches אפס דוגמיות + Add to Swatches הוסף לדוגמיות + Blend If: מיזוג אם: + Current Layer: שכבה נוכחית: + Channels: ערוצים: + Knockout ניקוב (Knockout) + Apply At Canvas Bounds החל בגבולות הקנבס + Color Picker (Text Color) בוחר צבעים (צבע טקסט) + Color Picker ({name}) בוחר צבעים ({name}) + Color Picker (Fill Color) בוחר צבעים (צבע מילוי) + Fill color צבע מילוי + Color Picker (Stroke Color) בוחר צבעים (צבע קו מתאר) + Stroke color צבע קו מתאר + Pick screen color בחר צבע מהמסך + Sample a pixel anywhere on the screen דגום פיקסל בכל מקום על המסך + Click to pick · Esc to cancel לחץ לבחירה · Esc לביטול + Pick a screen pixel or press Esc to cancel בחר פיקסל במסך או הקש Esc לביטול + Scroll to zoom גלול לתקריב + Drag to pan · Scroll to zoom · Double-click to fit גרור לגלילה · גלול לתקריב · לחיצה כפולה להתאמה + Show/Hide All Panels הצג/הסתר את כל הפאנלים + Show/Hide Panels הצג/הסתר פאנלים + Could not open לא ניתן לפתוח + {path}: the path is not valid Unicode; rename the file and open it again. {path}: הנתיב אינו Unicode תקין; שנה את שם הקובץ ופתח אותו מחדש. + Contents: תוכן: + Custom Pattern: תבנית מותאמת אישית: + Foreground Color צבע חזית + Color… צבע… + 50% Gray 50% אפור + {size} (was {old}) {size} (היה {old}) + Cone חרוט + Cone (Inverted) חרוט (הפוך) + Domed כיפתי + Domed (Inverted) כיפתי (הפוך) + Diagonal (Descending) אלכסוני (יורד) + Contour Editor עורך קו מתאר + Anti-aliased מוחלק קצוות + Corner פינה + Gloss Contour קו מתאר ברק + Contour קו מתאר + Cone - Inverted חרוט - הפוך + Cove - Deep שוקע - עמוק + Cove - Shallow שוקע - שטחי + Half Round חצי עגול + Ring טבעת + Ring - Double טבעת - כפולה + Rolling Slope - Descending מדרון מתגלגל - יורד + Rounded Steps מדרגות מעוגלות + Sawtooth 1 שן-מסור 1 + Red Eye Tool כלי עיניים אדומות + Red Eye עיניים אדומות + Pupil Size גודל אישון + Darken Amount מידת הכהיה + Click a red pupil to neutralize it לחץ על אישון אדום כדי לנטרל אותו + open source קוד פתוח + …and {n} more ...ועוד {n} + Eyedroppers: טפטפות: + Set Black Point הגדר נקודה שחורה + Set Neutral Gray Point הגדר נקודת אפור נייטרלי + Set White Point הגדר נקודה לבנה + Unavailable in this color mode לא זמין במצב צבע זה + Selection Preview: תצוגה מקדימה של בחירה: + Black Matte מסגרת שחורה + White Matte מסגרת לבנה + Seafoam קצף ים + Indigo אינדיגו + Fuchsia פוקסיה + Violet סגלגל + Reset אפס + Large document: redrawing on the CPU מסמך גדול: ציור מחדש על ה-CPU + Redrawing ציור מחדש + This redraw took {seconds} s on the CPU. הציור מחדש הזה לקח {seconds} שניות על ה-CPU. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. עריכות שמשנות את כל המסמך, כגון מצב המיזוג, האטימות או מצב התצוגה של שכבה, מציירות אותו מחדש במלואו. + The GPU compositor wasn't used: {reason} מרכיב ה-GPU לא נוצל: {reason} +fontWeight Thin דק +fontWeight ExtraLight קלוש מאוד +fontWeight Light קלוש +fontWeight Regular רגיל +fontWeight Medium בינוני +fontWeight SemiBold מודגש-חלקי +fontWeight Bold מודגש +fontWeight ExtraBold מודגש מאוד +fontWeight Black שחור +fontWeight Italic נטוי + Pattern Stamp Tool כלי חותמת תבנית + Pattern Stamp חותמת תבנית + Impressionist אימפרסיוניסטי + Rotate View סבב תצוגה + Rotate View Tool כלי סיבוב תצוגה + Reset View אפס תצוגה + Drag around the centre to rotate the view · Shift constrains to 15° גרור מסביב למרכז כדי לסבב את התצוגה · Shift מגביל ל-15° + Dual Axis שני צירים + Diagonal אלכסוני + Transform Symmetry שנה צורת סימטריה + Point Sample דגימת נקודה + 3 by 3 Average ממוצע 3 על 3 + 5 by 5 Average ממוצע 5 על 5 + 11 by 11 Average ממוצע 11 על 11 + 31 by 31 Average ממוצע 31 על 31 + 51 by 51 Average ממוצע 51 על 51 + 101 by 101 Average ממוצע 101 על 101 + Sample Size: גודל דגימה: + All Layers No Adjustments כל השכבות ללא התאמות + Current & Below No Adjustments נוכחי ומתחת ללא התאמות + Show Sampling Ring הצג טבעת דגימה + Out of gamut for printing: click for the nearest printable colour מחוץ לטווח הצבעים להדפסה: לחץ לצבע המדפיס הקרוב ביותר + Not a web colour: click for the nearest web colour לא צבע אינטרנט: לחץ לצבע האינטרנט הקרוב ביותר + Color to Alpha… צבע לאלפא… + Transparency Threshold סף שקיפות + Opacity Threshold סף אטימות + PhotoCraft — Screen Color Picker PhotoCraft — בוחר צבעים מהמסך + Rasterize svg groups deeper than רסטר קבוצות SVG עמוקות יותר מ + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 מכבה את הסף הזה; קבוצות SVG מקוננות עמוק מ-100 רמות מרוסטרות תמיד. + Rule of Thirds חלוקה לשלישים + Triangle משולש + Golden Ratio היחס הזהב + Golden Spiral ספירלת הזהב + Auto Show Overlay הצג שכבת-על אוטומטית + Always Show Overlay הצג שכבת-על תמיד + Never Show Overlay לעולם לא הצג שכבת-על + Cycle Overlay החלף שכבת-על + Cycle Orientation החלף כיוון + Set the overlay options for the Crop tool הגדר את אפשרויות שכבת-העל לכלי החיתוך + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-לחיצה עברה לשולחן העבודה, שמזיז חלונות עם Alt-גרירה. הגדר את מקש הזזת החלון שלו ל-Super, או נעל את Alt בחלון › מקשי עריכה. + Color Picker (Layer Style Color) בוחר צבעים (צבע סגנון שכבה) + Auto Hide Notifications הסתר התראות אוטומטית + Notification Duration (seconds) משך התראה (שניות) + Bundle Arrow-Key Nudges אגד הזזות עדינות עם מקשי חצים + Nudge Bundle Pause (ms) השהיית אגד הזזה עדינה (ms) + Appearance Mode מצב מראה + Dark Theme עיצוב כהה + Light Theme עיצוב בהיר + Pro Medium Pro בינוני + Studio Light Studio בהיר + Next Appearance Mode מצב המראה הבא + Sync with system סנכרן עם המערכת + Active פעיל + Color Picker (Solid Color) בוחר צבעים (צבע אחיד) + Straighten the image by drawing a line on it ישר את התמונה על ידי ציור קו עליה + Set additional Crop options הגדר אפשרויות חיתוך נוספות + Show Cropped Area הצג אזור חתוך + Enable Crop Shield אפשר מגן חיתוך + Match Canvas התאם לקנבס + Auto Adjust Opacity התאם אטימות אוטומטית + Crop Shield color צבע מגן חיתוך + Color Picker (Crop Shield Color) בוחר צבעים (צבע מגן חיתוך) + From Center מהמרכז + Link radii קשר רדיוסים + Star Ratio: יחס כוכב: + Create Rectangle צור מלבן + Create Ellipse צור אליפסה + Create Triangle צור משולש + Create Polygon צור מצולע + Pressure curve עקומת לחיצה + Click to add a point, right-click to remove לחץ להוספת נקודה, לחיצה ימנית להסרה + Some brush presets could not be loaded לא ניתן היה לטעון חלק מערכות המברשת הקבועות מראש + Retry saving presets נסה לשמור ערכות קבועות מראש שוב + Brush preset changes are not saved: {error} שינויי ערכת המברשת הקבועה מראש אינם נשמרים: {error} + Keep this tab open to retain your brushes. השאר כרטיסייה זו פתוחה כדי לשמור על המברשות שלך. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. אחסון הדפדפן אינו זמין; המברשות הן להפעלה זו בלבד. השאר כרטיסייה זו פתוחה כדי לשמור עליהן. + Saving brush presets… Keep this tab open until saving finishes. שומר ערכות מברשת קבועות מראש… השאר כרטיסייה זו פתוחה עד לסיום השמירה. + Reflect שיקוף + Glow זוהר + Negation שלילה + XOR XOR + Color Burn (Paint.NET) כהיית צבע (Paint.NET) + Color Dodge (Paint.NET) הבהרת צבע (Paint.NET) + Stroke Options אפשרויות קו מתאר + Solid אחיד + Dashed מקווקו + Dotted מנוקד + Alignment יישור + Caps קצוות (Caps) + Butt / Flat שטוח + Corners פינות + Miter זוויתי (Miter) + Round עגול + Bevel משופע (Bevel) + Miter limit גבול זוויתי (Miter) + Dashed line קו מקווקו + Dash קו + Gap רווח + Dash / gap lengths × stroke width אורכי קו/רווח × רוחב קו מתאר + Add pair הוסף זוג + Remove pair הסר זוג + Dash offset היסט קו מקווקו + Open paths use centered strokes נתיבים פתוחים משתמשים בקווי מתאר ממורכזים + Use finite lengths and a positive pattern total השתמש באורכים סופיים ובסכום תבנית חיובי + No subject found — try Object Selection or the Lasso לא נמצא נושא — נסה בחירת אובייקטים או לאסו + Quick Selection found nothing there בחירה מהירה לא מצאה שם כלום + Object Selection found nothing there — try a tighter rectangle בחירת אובייקטים לא מצאה שם כלום — נסה מלבן צפוף יותר + No subject found — try a rectangle around the object לא נמצא נושא — נסה מלבן מסביב לאובייקט + Click the first point or press {key} to close · {del} removes a point · Esc cancels לחץ על הנקודה הראשונה או הקש {key} לסגירה · {del} מסיר נקודה · Esc מבטל + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close לחץ, ואז עבור לאורך קצה · לחץ להצמדת נקודה · {alt}-לחיצה לקטע ישר · לחיצה כפולה לסגירה + Follows only edges this close to the pointer ([ and ] change it) עוקב רק אחרי קצוות קרובים כך למצביע ‏([ ו-] משנים זאת) + Higher values follow only edges that contrast sharply with their surroundings ערכים גבוהים יותר עוקבים רק אחרי קצוות הניגודיים בחדות לסביבתם + How often fastening points are placed by themselves באיזו תדירות נקודות הצמדה מוצבות מעצמן + Appearance Settings… הגדרות מראה… + Add to favorites הוסף למועדפים + Remove from favorites הסר מהמועדפים + Camera Data נתוני מצלמה +cameraData Make יצרן +cameraData Model דגם +cameraData Date Taken תאריך צילום +cameraData Exposure חשיפה +cameraData F-Stop צמצם (F-Stop) +cameraData ISO Speed רגישות ISO +cameraData Focal Length אורך מוקד +cameraData Focal Length (35 mm) אורך מוקד (35 מ"מ) +cameraData Lens עדשה + Activate Panel הפעל פאנל + Change Panel Group שנה קבוצת פאנלים + Change Panel Layout שנה פריסת פאנלים + Close Panel סגור פאנל + Dock Panel עגן פאנל + Float Panel צף פאנל + Move Panel הזז פאנל + Group with קבץ עם + Panel menu תפריט פאנל + Resize panel window שנה גודל חלון פאנל + Resize panels שנה גודל פאנלים + Distribution התפלגות + Uniform אחידה + Gaussian גאוסית + Monochromatic מונוכרומטית + Seed זרע (Seed) + Strength עוצמה + Reduce Color Noise הפחת רעש צבע + Remove Jpeg Artifact הסר ארטיפקט JPEG + Sharpen Details חדד פרטים + Reduce Noise הפחת רעש + Use Classic Mode השתמש במצב קלאסי + Auto Center Preview מרכז תצוגה מקדימה אוטומטית + Next Document המסמך הבא + Previous Document המסמך הקודם + W x H x Resolution W x H x רזולוציה + Front Image תמונה קדמית + Frame Tool כלי מסגרת + Create a new rectangular frame צור מסגרת מלבנית חדשה + Create a new elliptical frame צור מסגרת אליפטית חדשה + Include in playback הכלל בהפעלה + Perspective Crop Tool כלי חיתוך פרספקטיבה + Show Grid הצג רשת + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels גרור תיבה או לחץ על ארבע פינות · גרור את הפינות לקצוות של צורה משוכה · {commit} מאשר · Esc מבטל + Artboard Tool כלי לוח ציור + Make Portrait הפוך לאורך + Make Landscape הפוך לרוחב + Add New Artboard הוסף לוח ציור חדש + Freeform Pen Tool כלי קולמוס חופשי + Curvature Pen Tool כלי קולמוס עקומה + Curve Fit: התאמת עקומה: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close גרור: ציור חופשי · {alt}-לחיצה: קטע ישר · שחרור על נקודת ההתחלה: סגירה + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish לחיצה: נקודה חלקה · לחיצה כפולה: פינה · גרירת נקודה: הזזה · לחיצה על הנקודה הראשונה: סגירה · {key} סיום + New Crop Preset… ערכת חיתוך חדשה… + Delete Crop Preset… מחק ערכת חיתוך… + Select the crop presets to delete: בחר את ערכות החיתוך למחיקה: + Select the crop presets to delete בחר את ערכות החיתוך למחיקה + Enter a name for the crop preset הזן שם לערכת החיתוך + There is no crop size or ratio to save אין גודל או יחס חיתוך לשמירה + Scale: קנה מידה: + Snap to Origin הצמד למקור + Search settings… חפש הגדרות… + No matching settings אין הגדרות תואמות + Sampling Brush Tool כלי מברשת דגימה + Sampling Area Overlay שכבת-על של אזור דגימה + Show Sampling Area הצג אזור דגימה + Indicates מציין + Sampling Area אזור דגימה + Excluded Area אזור מוחרג + Sampling Area Options אפשרויות אזור דגימה + Rectangular מלבני + Fill Settings הגדרות מילוי + Rotation Adaptation הסתגלות סיבוב + Very High גבוה מאוד + Full מלא + Mirror מראה + Output Settings הגדרות פלט + Output To פלט אל + Animation Options אפשרויות הנפשה + Entire Document כל המסמך + Filter Options אפשרויות מסנן + Layer Bounds גבולות שכבה + Layers Panel Options אפשרויות פאנל שכבות + Lock Layers נעל שכבות + New Artboard… לוח ציור חדש… + New Group… קבוצה חדשה… + New Layer… שכבה חדשה… + Panel Options… אפשרויות פאנל… + Prevent Auto-Nesting מנע קינון אוטומטי + Show Layers הצג שכבות + Thumbnail Contents תוכן תמונה ממוזערת + Thumbnail Size גודל תמונה ממוזערת + Use Default Masks on Fill Layers השתמש במסכות ברירת מחדל בשכבות מילוי + Expand New Effects הרחב אפקטים חדשים + Add “copy” to Copied Layers and Groups הוסף "עותק" לשכבות וקבוצות מועתקות + Preview on canvas when reordering layers תצוגה מקדימה על הקנבס בעת סידור מחדש של שכבות + Show layer mask badges הצג תגי מסכת שכבה + Blending Options ({filter}) אפשרויות מיזוג ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) הסתר את אפקטי השכבה והמסננים החכמים ({key}-לחיצה: כל השכבות) + Show the layer's effects and Smart Filters ({key}-click: all layers) הצג את אפקטי השכבה והמסננים החכמים ({key}-לחיצה: כל השכבות) + Wait for the current job to finish. המתן לסיום העבודה הנוכחית. + The document or Smart Filter changed. Reopen its dialog. המסמך או המסנן החכם השתנו. פתח מחדש את תיבת הדו-שיח שלו. + Opacity must be between 0% and 100%. האטימות חייבת להיות בין 0% ל-100%. + Dissolve is approximated: it blends without Photoshop's dither. פירוק (Dissolve) מקורב: הוא ממזג בלי הדיתור של Photoshop. + Loading fonts… טוען גופנים… + Response curve עקומת תגובה + Tool כלי + Simulate Pressure דמה לחיצה + Stroke Path… צייר קו מתאר לנתיב… + Background Eraser מחק רקע + Healing Brush מברשת ריפוי + Spot Healing Brush מברשת ריפוי נקודתי + Smudge מריחה + Sponge ספוג + Amount: מידה: + Protect: הגן: + Protect skin tones הגן על גווני עור + Edit points to modify the curve ערוך נקודות כדי לשנות את העקומה + Draw to modify the curve צייר כדי לשנות את העקומה +curves Smooth החלק + Smooth the curve values החלק את ערכי העקומה + Drag to draw the curve · {key}-click draws a straight line גרור כדי לצייר את העקומה · {key}-לחיצה מציירת קו ישר + Edit Plane ערוך מישור + Create Plane צור מישור + Grid Size: גודל רשת: + Vanishing Point: click four corners to create a plane נקודת היעלמות: לחץ על ארבע פינות כדי ליצור מישור + Vanishing Point is not active נקודת היעלמות אינה פעילה + Apply Vanishing Point ({key}) החל נקודת היעלמות ({key}) + Fix the red plane first: its corners must form a convex quad תקן קודם את המישור האדום: הפינות שלו צריכות ליצור מרובע קמור + Alt-click a plane to set the clone source Alt-לחיצה על מישור כדי להגדיר את מקור השכפול + Too many clone strokes: apply Vanishing Point first יותר מדי משיכות שכפול: החל קודם נקודת היעלמות + Onion Skin קליפת בצל (Onion Skin) + Marching Ants נמלים צועדות (Marching Ants) + On Black על שחור + On White על לבן + On Layers על שכבות + Show Edge הצג קצה + Show Original הצג מקור + F cycles the views; hold X to see the image F מחליף בין התצוגות; החזק X כדי לראות את התמונה + Refine Edge Brush (R) מברשת חידוד קצה (R) + Expand Detection Area הרחב אזור גילוי + Restore Original Edge שחזר קצה מקורי + Paint over hair or fur on the image; Alt paints the other mode צבע על שיער או פרווה בתמונה; Alt צובע את המצב האחר + Clear Brush Strokes נקה משיכות מברשת + Show rulers הצג סרגלים + Presentation מצגת + Click to place · Alt-click or drag off-canvas to remove לחץ להצבה · Alt-לחיצה או גרירה מחוץ לקנבס להסרה + No document open אין מסמך פתוח + {n} of {max} sample points {n} מתוך {max} נקודות דגימה + New Snapshot… תצלום מצב חדש… + History Options… אפשרויות היסטוריה… + Allow Non-Linear History אפשר היסטוריה לא לינארית + Make Layer Visibility Changes Undoable אפשר ביטול לשינויי מצב תצוגה של שכבות + Emulsion Down אמולסיה למטה + Functions פונקציות + Print Resolution: רזולוציית הדפסה: + Use Previous Layer to Create Clipping Mask השתמש בשכבה הקודמת כדי ליצור מסכת גזירה + Fill with {mode}-neutral color (white) מלא בצבע נייטרלי ל-{mode} (לבן) + Fill with {mode}-neutral color (black) מלא בצבע נייטרלי ל-{mode} (שחור) + Fill with {mode}-neutral color (50% gray) מלא בצבע נייטרלי ל-{mode} (50% אפור) + (No neutral color exists for {mode} mode.) (אין צבע נייטרלי למצב {mode}.) + Apply mask to layer before removing? להחיל את המסכה על השכבה לפני ההסרה? + The layer or its mask changed. Drag the mask onto the Trash again. השכבה או המסכה שלה השתנו. גרור את המסכה לאשפה מחדש. + Delete the vector mask? למחוק את מסכת הווקטור? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} יוצאה {n} דוגמית צבע אל {file}|יוצאו {n} דוגמיות צבע אל {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} יובאה {n} מברשת מתוך {group}|יובאו {n} מברשות מתוך {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} יובאה {n} דוגמית צבע מתוך {file}|יובאו {n} דוגמיות צבע מתוך {file} +@plural Played {n} step|Played {n} steps הופעל {n} שלב|הופעלו {n} שלבים +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk עודכן {n} אובייקט חכם מקושר שהשתנה בדיסק|עודכנו {n} אובייקטים חכמים מקושרים שהשתנו בדיסק +@plural Saved for Web: {n} file|Saved for Web: {n} files נשמר לאינטרנט: {n} קובץ|נשמרו לאינטרנט: {n} קבצים +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} שכבה שתועדה בקומפ זה נמחקה. עדכן את הקומפ, או מחק את האזהרה.|{n} שכבות שתועדו בקומפ זה נמחקו. עדכן את הקומפ, או מחק את האזהרה. +@plural {n} plane|{n} planes {n} מישור|{n} מישורים +@plural {n} preset|{n} presets {n} ערכה קבועה מראש|{n} ערכות קבועות מראש +@plural {n} sample point removed|{n} sample points removed {n} נקודת דגימה הוסרה|{n} נקודות דגימה הוסרו +@plural {n} slice|{n} slices {n} פרוסה|{n} פרוסות +@plural {n} step|{n} steps {n} שלב|{n} שלבים +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} ‏(+{n} אזהרת ערכת מברשת נוספת)|{warning} ‏(+{n} אזהרות ערכת מברשת נוספות) +@plural {what} with {n} warning|{what} with {n} warnings {what} עם {n} אזהרה|{what} עם {n} אזהרות + Light Blue כחול בהיר + Light Red אדום בהיר + Medium Blue כחול בינוני + Light Gray אפור בהיר + Object Finder מאתר אובייקטים + No object found there — drag a rectangle around it לא נמצא אובייקט שם — גרור מלבן מסביבו + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels לשכבה הפעילה אין פיקסלים כאן — הפעל דגימת כל השכבות או בחר שכבה עם פיקסלים + Remove after each stroke הסר אחרי כל משיכה + Apply the removal ({key}) החל את ההסרה ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) משיכות להסרה: {n} ‏(Enter מאשר, Esc מבטל) + Load Contours… טען קווי מתאר… + Replace Contours… החלף קווי מתאר… + Save Contours… שמור קווי מתאר… + Delete Contour מחק קו מתאר + Loaded contours from {file} נטענו קווי מתאר מתוך {file} + Saved contours to {file} נשמרו קווי מתאר אל {file} +@id select.all בחר הכול + Delete Smart Filters מחק מסננים חכמים + Enable Smart Filters אפשר מסננים חכמים + Delete Layer… מחק שכבה… + Select Similar Colors בחר צבעים דומים + Canvas rotation סיבוב קנבס + Extensions הרחבות + Libraries ספריות + 3D 3D + Toolbars סרגלי כלים + Wand שרביט + Healing ריפוי + Pen קולמוס + Hand יד + Sampler דוגם (Sampler) + Magnetic Lasso לאסו מגנטי + Art History Brush מברשת היסטוריה אמנותית + Spot Healing ריפוי נקודתי + Patch טלאי + Content-Aware Move הזזה מודעת לתוכן + Perspective Crop חיתוך פרספקטיבה + Dodge/Burn הבהרה/כהייה + Dynamics דינמיקה + Tilt הטיה + Defaults restored. ברירת המחדל שוחזרה. + Stop עצור + Create new set צור קבוצה חדשה + Technology Previews תצוגות מקדימות של טכנולוגיה + Create new layer צור שכבה חדשה + Group · {n} layer קבוצה · {n} שכבה + General Blend מיזוג כללי + Advanced Blending מיזוג מתקדם + RGB RGB + Spot Channel ערוץ ספוט + Working Gray Plate לוח אפור עבודה + HSL/Grayscale HSL/גווני אפור + Tone Curve עקומת גוון + Split Toning גוון מפוצל + Lens Corrections תיקוני עדשה + Camera Calibration כיול מצלמה + Snapshots תצלומי מצב + Recovery שחזור + Fill Light תאורת מילוי + Convert to Grayscale המר לגווני אפור + Sharpness חדות + Color Noise Reduction הפחתת רעש צבע + Luminance Smoothness חלקות בהירות אור + Lens עדשה + ISO ISO + Focal Length35mm אורך מוקד 35 מ"מ + Aspect Ratio יחס גובה-רוחב + Time זמן + Enter description… הזן תיאור… + ColorSync ColorSync + ProPhoto ProPhoto + ACES ACES + Industry Standard תקן תעשייתי + Uncoated CMYK CMYK לא מצופה + Newspaper עיתון + Working Spaces מרחבי עבודה + Color Management Policies מדיניות ניהול צבע + Detect Faces גלה פנים + Localize Color Clusters מקם אשכולות צבע + Duplicated selection name שם בחירה כפול + Operation פעולה + New channel only ערוץ חדש בלבד + Color indicates הצבע מציין + Create Snapshot צור תצלום מצב + Delete Snapshot מחק תצלום מצב + New Snapshot תצלום מצב חדש + Snapshot 1 תצלום מצב 1 + Step Forward צעד קדימה + Step Backward צעד אחורה + Nonlinear History היסטוריה לא לינארית + Set New Snapshot as Default הגדר תצלום מצב חדש כברירת מחדל + Automatically Create First Snapshot צור תצלום מצב ראשון אוטומטית + Automatically Create New Snapshot When Saving צור תצלום מצב חדש אוטומטית בעת שמירה + Show New Snapshot Dialog by Default הצג תיבת דו-שיח של תצלום מצב חדש כברירת מחדל + Clear History נקה היסטוריה + View menu תפריט תצוגה + Keep view in sync שמור על סנכרון תצוגה + {zoom}% {zoom}% + Mouse Controls פקדי עכבר + Initial Direction of Stroke כיוון התחלתי של המשיכה + Direction of Stroke כיוון המשיכה + Humidity לחות + Auto Clean Brush נקה מברשת אוטומטית + Spacing: ריווח: + New Layer from Brush שכבה חדשה ממברשת + New Brush מברשת חדשה + New Tool Preset ערכת כלי חדשה + Auto-Leading ריווח שורות אוטומטי + Scale Horizontally קנה מידה אופקי + Scale Vertically קנה מידה אנכי + Mac LCD Mac LCD + Mac Sharp Mac חד + Mac Crisp Mac קריספי + Mac Strong Mac חזק + Mac Smooth Mac חלק + Superscript כתב עילי + Subscript כתב תחתי + Ligature ליגטורה + Alternative חלופה + Ultra Light קלוש ביותר + Extra Light קלוש מאוד + Semi Bold מודגש-חלקי + Extra Bold מודגש מאוד + Ultra Bold מודגש ביותר + Condensed מצומצם (Condensed) + First Line Indent הזחת שורה ראשונה + Hanging Punctuation פיסוק תלוי + Roman Hanging Punctuation פיסוק תלוי רומי + Avoid Widows/Orphans הימנע משורות יתומות/אלמנות + Compose הרכב + Adobe Single-line Composer מרכיב שורה בודדת של Adobe + Adobe Every-line Composer מרכיב כל השורות של Adobe + Word Spacing ריווח מילים + Letter Spacing ריווח אותיות + Glyph Scaling שינוי קנה מידה של גליפים + Auto-leading % % ריווח שורות אוטומטי + Single Word Justification יישור מילה בודדת + Justify יישור לשני הצדדים + Glyph גליף + Alternates for Current Selection חלופות לבחירה הנוכחית + Digits ספרות + Path: נתיב: + Path to Illustrator… נתיב ל-Illustrator… + Clipping Path נתיב גזירה + New Path נתיב חדש + Duplicate Path שכפל נתיב + Rename Path… שנה שם נתיב… + Auto-Hide הסתרה אוטומטית + X X + Y Y + Frame Sync סנכרון פריימים + Quick Sync סנכרון מהיר + Audio שמע + Frame Duration משך פריים + Loop לופ (חזרה) + Split at Playhead פצל בראש ההפעלה + Flatten Frames Into Clips שטח פריימים לקליפים + Flatten Frames Into Layers שטח פריימים לשכבות + Convert Frames > Video Layers המר פריימים > שכבות וידאו + New Video Layer שכבת וידאו חדשה + New Video Group קבוצת וידאו חדשה + Limit Playback הגבל הפעלה + Enable Timeline Quick Config אפשר תצורה מהירה של ציר הזמן + Set Start Timecode… הגדר קוד זמן התחלה… + Timecode קוד זמן + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time זמן נוכחי + Out יציאה (Out) + In כניסה (In) + Duration משך + Repeat חזרה + Play Once הפעל פעם אחת + Info Options… אפשרויות מידע… + First Color Readout קריאת צבע ראשונה + Second Color Readout קריאת צבע שנייה + Actual Color צבע ממשי + Grayscale Slider מחוון גווני אפור + Web Color צבע אינטרנט + HSB Color צבע HSB + Total Ink דיו כולל + Opacity Mask מסכת אטימות + Document Size גודל מסמך + Timing תזמון + Status Information מידע מצב + Saved Multichannel נשמר כרב-ערוצי + Mouse Coordinates קואורדינטות עכבר + Ruler Units יחידות סרגל + Hide הסתר + Restore last viewed comp שחזר את הקומפ שנצפה לאחרונה + Select All Layers בחר את כל השכבות + Keep proportions שמור על פרופורציות + Interpolation אינטרפולציה + Caption כיתוב + Caption Credits קרדיטים לכיתוב + Keywords מילות מפתח + Copyright Info URL כתובת URL למידע זכויות יוצרים + Copyright Status מצב זכויות יוצרים + Copyright Notice הודעת זכויות יוצרים + Document Title כותרת מסמך + Copyrighted מוגן בזכויות יוצרים + Public Domain נחלת הכלל + Unknown לא ידוע + Credit קרדיט + Transmission Reference התייחסות שידור + Urgency דחיפות + High גבוה + Low נמוך + Category קטגוריה + Supplemental Categories קטגוריות משלימות + Object Name / Title שם אובייקט / כותרת + Date Created תאריך יצירה + City עיר + Province / State מחוז / מדינה + Country מדינה + Byline שם המחבר (Byline) + Byline Title תפקיד המחבר + Phone טלפון + Email דוא"ל + URLs כתובות URL + Last Modified שונה לאחרונה + EXIF EXIF + GPS GPS + Camera Model דגם מצלמה + Camera Make יצרן מצלמה + EXIF Version גרסת EXIF + Exposure Program תוכנית חשיפה + Aperture צמצם + Shutter Speed מהירות צמצם + Metering Mode מצב מדידה + Light Source מקור אור + Flash פלאש + Lens Info מידע עדשה + ISO Speed רגישות ISO + Subject Distance מרחק הנושא + DateTime Original תאריך ושעה מקוריים + Create Date תאריך יצירה + Modify Date תאריך שינוי + Raw Data נתוני Raw + Import… ייבוא… + PDF Presentation… מצגת PDF… + Create Multiple Picture Packages צור חוברות תמונה מרובות + Picture Package… חוברת תמונה… + Web Photo Gallery… גלריית תמונות לאינטרנט… + Choose… בחר… + Select Folder… בחר תיקייה… + Override Action "Save" Commands דרוס פקודות "שמור" בפעולה + File Naming שם קובץ + Compatibility תאימות + Mac OS Mac OS + Unix Unix + Include ICC Profile הכלל פרופיל ICC + Error File קובץ שגיאות + Stop For Errors עצור בשגיאות + Log Errors To File תעד שגיאות לקובץ + Errors שגיאות + Settings הגדרות + Settings: הגדרות: + Working Space מרחב עבודה + RGB: RGB: + CMYK: CMYK: + Gray: אפור: + Spot: ספוט: + Advanced Controls פקדים מתקדמים + Desaturate Monitor Colors By הפחת רוויית צבעי הצג ב- + Blend RGB Colors Using Gamma 1.0 מזג צבעי RGB באמצעות גמא 1.0 + Profile Mismatches: Ask When Opening חוסר התאמת פרופיל: שאל בעת פתיחה + Ask When Pasting שאל בעת הדבקה + Missing Profiles: Ask When Opening פרופילים חסרים: שאל בעת פתיחה + Embed profiles הטבע פרופילים + Ask Before Saving Saved Embedded שאל לפני שמירה של הטבעה שנשמרה + Coerce to sRGB כפה ל-sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Minimalistic מינימליסטי + Expand All Groups הרחב את כל הקבוצות + Photo (2 × 3 in) תמונה (2 × 3 אינץ') + Photo (4 × 6 in) תמונה (4 × 6 אינץ') + Photo (5 × 7 in) תמונה (5 × 7 אינץ') + Photo (8 × 10 in) תמונה (8 × 10 אינץ') + Recent Items פריטים אחרונים + Delete Preset… מחק ערכה קבועה מראש… + Preset Name: שם ערכה קבועה מראש: + Width and Height רוחב וגובה + Color Profile פרופיל צבע + Units יחידות + Create new count group צור קבוצת ספירה חדשה + Adjustment Layer שכבת התאמה + Drag to move the layer גרור כדי להזיז את השכבה + Go to Layer עבור לשכבה + After (X) אחרי (X) + Reference point location מיקום נקודת ייחוס + Toggle reference point הצג/הסתר נקודת ייחוס + Add Pins הוסף סיכות + Delete Pins מחק סיכות + Rotate Auto סיבוב אוטומטי + Rotate Fixed סיבוב קבוע + Select Plane בחר מישור + Measure מדוד + Heal ריפוי + Heal Off ריפוי כבוי + Heal On ריפוי פעיל + Heal Auto ריפוי אוטומטי + Blend מיזוג + Brush Size גודל מברשת + Heal Color צבע ריפוי + Result תוצאה + Duplicate שכפל + Fast מהיר + Accurate מדויק + Add to הוסף אל + Subtract from החסר מ- + Intersect with חתוך עם + Capture Comp לכוד קומפ + No Layer Comps אין קומפים של שכבות + Adobe PDF Presets… ערכות PDF של Adobe… + 3D… 3D… + Technology Previews… תצוגות מקדימות של טכנולוגיה… + Add a mask (from the selection; ⌥ inverts) הוסף מסכה (מהבחירה; ⌥ הופך) + Add to selection (⇧) הוסף לבחירה (⇧) + Airbrush: keeps painting while the pointer rests. אירוגרף: ממשיך לצבוע כל עוד המצביע נשאר במקום. + Click the first point or press ↵ to close · Esc cancels לחץ על הנקודה הראשונה או הקש ↵ לסגירה · Esc מבטל + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove לחץ להוספת נקודה · גרור הצידה או ⌘/Ctrl-לחיצה או Delete להסרה + Click to apply, ⌥-click to clear overrides, double-click for Style Options לחץ להחלה, ⌥-לחיצה לניקוי דריסות, לחיצה כפולה לאפשרויות סגנון + Click to zoom in · ⌥-click to zoom out לחץ להגדלה · ⌥-לחיצה להקטנה + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel לחיצה: פינה · גרירה: החלקה · לחיצה על הנקודה הראשונה: סגירה · ↩ סיום · Esc ביטול + Commit Perspective Warp (↩) אשר עיוות פרספקטיבה (↩) + Commit Puppet Warp (↩) אשר עיוות בובה (↩) + Commit any current edits (⌘↩) אשר עריכות נוכחיות (⌘↩) + Commit current crop operation (↵) אשר פעולת חיתוך נוכחית (↵) + Commit transform (↩) אשר שינוי צורה (↩) + Commit warp (↩) אשר עיוות (↩) + Create a new layer (⇧⌘N) צור שכבה חדשה (⇧⌘N) + Custom pattern תבנית מותאמת אישית + General Brushes מברשות כלליות + Grid (⌘') רשת (⌘') + Intersect with selection (⇧⌥) חתוך עם הבחירה (⇧⌥) + New document… ⌘N מסמך חדש… ⌘N + Open… ⌘O פתח… ⌘O + Rulers (⌘R) סרגלים (⌘R) + Search commands (⌘K) חפש פקודות (⌘K) + Show Guides (⌘;) הצג קווים מנחים (⌘;) + Subtract from selection (⌥) החסר מהבחירה (⌥) + Uses the foreground and background colors. משתמש בצבעי החזית והרקע. + ↑↓ navigate ↵ run esc close ↑↓ ניווט ↵ הפעלה esc סגירה + Adds grain to the soft edges of the brush tip. מוסיף גרגיריות לקצוות הרכים של קצה המברשת. + Keeps the current texture when switching presets. שומר על המרקם הנוכחי בעת החלפת ערכות קבועות מראש. + Watercolour look: lighter interior, darker rims. מראה צבעי מים: פנים בהיר יותר, שפות כהות יותר. + Collapse group (⌥-click: all groups) כווץ קבוצה (⌥-לחיצה: כל הקבוצות) + Expand group (⌥-click: all groups) הרחב קבוצה (⌥-לחיצה: כל הקבוצות) + Hide the layer's effects (⌥-click: all layers) הסתר את אפקטי השכבה (⌥-לחיצה: כל השכבות) + Show the layer's effects (⌥-click: all layers) הצג את אפקטי השכבה (⌥-לחיצה: כל השכבות) +cameraRaw Blacks שחורים +cameraRaw Masking מיסוך +@id edit.stroke קו מתאר +@id edit.transform.scale קנה מידה +cameraRaw Luminance בהירות אור +cameraRaw Color צבע +cameraRaw Vibrance תוססות +cameraRaw Color Grading דירוג צבע +cameraRaw Global גלובלי +cameraRaw Hue גוון +cameraRaw Luminance Detail פרטי בהירות אור +cameraRaw Color Detail פרטי צבע +cameraRaw Roughness גסות +cameraRaw Whites לבנים +layerLabel No Color בלי צבע + Preferences… העדפות… + Actual Pixels פיקסלים ממשיים + Clipping Mask מסכת גזירה + Tool Options אפשרויות כלי + Color Swatches דוגמיות צבע + Linear Dodge הבהרה לינארית + Preview Image תצוגה מקדימה של תמונה + Save As שמור בשם + Remove All הסר הכול + Unlock בטל נעילה + Enable אפשר + Disable השבת + On פעיל + Clear Selection נקה בחירה + Invert Selection הפוך בחירה + Select Inverse בחר היפוך + Contract כווץ + Sample Size גודל דגימה + Gamma גמא + Input קלט + Input Levels רמות קלט + Output Levels רמות פלט + Layer Styles סגנונות שכבה + Blending Mode מצב מיזוג + Stroke Width רוחב קו מתאר + Shadow צל + Adjustment Layers שכבות התאמה + Text Layer שכבת טקסט + Documents מסמכים + File Name שם קובץ + File Format תבנית קובץ + Compression דחיסה + Artboards לוחות ציור + Snap to Grid הצמד לרשת + Full Screen מסך מלא + Fullscreen מסך מלא + Workspaces סביבות עבודה + Show All הצג הכול + Hidden מוסתר + Duplicate Layer שכפל שכבה + Ungroup פרק קיבוץ + Guide Layout… פריסת קווים מנחים… + Page Setup… הגדרת עמוד… + Reset Preferences אפס העדפות + Documentation תיעוד + Check for Updates בדוק עדכונים + Loading… טוען… + Processing… מעבד… + Saving… שומר… + Exporting… מייצא… + Please wait… המתן בבקשה… + No selection אין בחירה + No layers אין שכבות + No recent files אין קבצים אחרונים + Unsupported file format תבנית קובץ לא נתמכת + Failed to open file פתיחת הקובץ נכשלה + Failed to save file שמירת הקובץ נכשלה + Operation canceled הפעולה בוטלה + Changes have not been saved. השינויים לא נשמרו. + Are you sure? האם אתה בטוח? + Discard Changes השלך שינויים + Open File פתח קובץ + Save File שמור קובץ + Save Changes שמור שינויים + Rendering… מעבד… + Importing… מייבא… + Ready מוכן + Failed נכשל + Warning אזהרה + Information מידע + Retry נסה שוב + Continue המשך + Skip דלג + Resume המשך + Reset All אפס הכול + Defaults ברירות מחדל + Apply to All החל על הכול + Confirm אשר + Finish סיים + Back חזרה + Next הבא + Previous הקודם + More עוד + Less פחות + Manual ידני + Disabled מושבת + Locked נעול + Unlocked לא נעול + Delete… מחק… + Create… צור… + Edit… ערוך… + Change… שנה… + Transformation שינוי צורה + Scaling שינוי קנה מידה + Canvas Size גודל קנבס + Percentage אחוז + Degrees מעלות + Middle אמצע + Diameter קוטר + Intensity עוצמה + Sensitivity רגישות + Softness רכות + Feather Radius רדיוס הכהיה + Library ספרייה + Layer Properties מאפייני שכבה + Groups קבוצות + Masks מסכות + Add Mask הוסף מסכה + Delete Mask מחק מסכה + Invert Mask הפוך מסכה + Enable Mask אפשר מסכה + Disable Mask השבת מסכה + Apply Mask החל מסכה + Import Settings ייבוא הגדרות + Export Settings ייצוא הגדרות + Shortcuts קיצורים + Keyboard מקלדת + Mouse עכבר + Tablet טאבלט + Display תצוגה + Panel פאנל + Hide Grid הסתר רשת + Fit to Window התאם לחלון + Live Preview תצוגה מקדימה חייה + Before לפני + After אחרי + Compare השווה + File Size גודל קובץ + Filename שם קובץ + Open Folder פתח תיקייה + Choose Folder בחר תיקייה + Browse עיון + Find חפש + Replace החלף + Date תאריך + Learn More למד עוד + Report a Bug דווח על באג + Updates עדכונים + Update עדכן + Restart Required נדרשת הפעלה מחדש + Restart הפעל מחדש + Quit צא + Quit PhotoCraft צא מ-PhotoCraft + Close Window סגור חלון + New Window חלון חדש + Open Image פתח תמונה + Save Image שמור תמונה + Export Image ייצוא תמונה + Import Image ייבוא תמונה + Export As ייצוא בשם + Export to ייצוא אל + Export Options אפשרויות ייצוא + Import Options אפשרויות ייבוא + No Selection אין בחירה + No Image אין תמונה + No Layers אין שכבות + No Results אין תוצאות + No Results Found לא נמצאו תוצאות + Nothing to Undo אין מה לבטל + Nothing to Redo אין מה לבצע שוב + Changes Saved השינויים נשמרו + Save before closing? לשמור לפני הסגירה? + Are you sure you want to quit? האם אתה בטוח שברצונך לצאת? + The file could not be opened. לא ניתן היה לפתוח את הקובץ. + The file could not be saved. לא ניתן היה לשמור את הקובץ. + Unsupported format תבנית לא נתמכת + Out of memory חסר זיכרון + Permission denied ההרשאה נדחתה + File not found הקובץ לא נמצא + Operation failed הפעולה נכשלה + Operation completed הפעולה הושלמה + Operation cancelled הפעולה בוטלה + Sharpen Amount מידת חידוד + Sharpen Radius רדיוס חידוד + Sharpen Detail פרטי חידוד + Sharpen Masking מיסוך חידוד + Noise Luminance בהירות רעש + Noise Luminance Detail פרטי בהירות רעש + Noise Color צבע רעש + Noise Color Detail פרטי צבע רעש + Grain Amount מידת גרגיריות + Grain Size גודל גרגיריות + Grain Roughness גסות גרגיריות + Vignette Roundness עגילות ויניטה + Vignette Feather הכהיית ויניטה + Vignette Highlights הדגשות ויניטה + Vignette Style סגנון ויניטה + Planes מישורים + Plane מישור + Shadow Amount מידת צל + Shadow Tone גוון צל + Shadow Radius רדיוס צל + Highlight Amount מידת הדגשה + Highlight Tone גוון הדגשה + Highlight Radius רדיוס הדגשה + Midtone גוון ביניים + Black Clip גזירת שחור + White Clip גזירת לבן + Fuzziness עמימות + Source Layer שכבת מקור + Neutralize נטרל + Use Selection In Source השתמש בבחירה במקור + Use Selection In Target השתמש בבחירה ביעד + Flame Type סוג להבה + Randomize Length אורך אקראי + Interval מרווח + Adjust Interval For Loops התאם מרווח ללופים + Use Custom Color השתמש בצבע מותאם אישית + Flame Lines קווי להבה + Flame Bottom Alignment יישור תחתית להבה + Flame Style סגנון להבה + Flame Shape צורת להבה + Randomize Shapes צורות אקראיות + Frame מסגרת + Arrangement סידור + Thickness עובי + Vine Color צבע גפן + Flower Color צבע פרח + Leaf Color צבע עלה + Base Tree Type סוג עץ בסיסי + Light Direction כיוון אור + Leaves Amount כמות עלים + Leaves Size גודל עלים + Branches Height גובה ענפים + Branches Thickness עובי ענפים + Default Leaves עלים ברירת מחדל + Leaves Color צבע עלים + Custom Branch Color צבע ענף מותאם אישית + Branches Color צבע ענפים + Flat Shading הצללה שטוחה + Date and Time תאריך ושעה + Scale Units יחידות קנה מידה + Scale Factor מקדם קנה מידה + Area שטח + Perimeter היקף + Circularity עגוליות + Gray Value (Minimum) ערך אפור (מינימום) + Gray Value (Maximum) ערך אפור (מקסימום) + Gray Value (Mean) ערך אפור (ממוצע) + Gray Value (Median) ערך אפור (חציון) + Integrated Density צפיפות משולבת + Length אורך + In Black שחור קלט + In White לבן קלט + Out Black שחור פלט + Out White לבן פלט + Tint Color צבע טינט + Stops נקודות עצירה + Center X מרכז X + Center Y מרכז Y + Cell Size גודל תא + Generators מחוללים + Wavelength Min אורך גל מינימלי + Wavelength Max אורך גל מקסימלי + Amplitude Min אמפליטודה מינימלית + Amplitude Max אמפליטודה מקסימלית + Undefined Areas אזורים לא מוגדרים + Preserve שמור + Max Radius רדיוס מקסימלי + Depth Mode מצב עומק + Solid Front Faces פאות קדמיות אחידות + Mask Incomplete Blocks מסך בלוקים לא שלמים + Stylization סטיילזציה + Cleanliness נקיות + Bristle Detail פרטי זיף + Lighting תאורה + Shine זוהר (Shine) + Max Offset היסט מקסימלי + Map Document מפה את המסמך + Map Path מפה את הנתיב + Map Layer מפה את השכבה + Ridges רכסים + Light Type סוג אור + Light X אור X + Light Y אור Y + Light Z אור Z + Target X יעד X + Target Y יעד Y + Hotspot נקודת מוקד (Hotspot) + Elevation גובה (זווית) + Ambient סביבתי (Ambient) + Warmth חמימות + Gloss ברק + Metallic מטאלי + Ambience אווירה (Ambience) + White Is High לבן הוא גבוה + Blade Curvature עקמומיות להב + Depth Map מפת עומק + Focal Distance מרחק מוקד + Focus מוקד + Transition מעבר + Radius X רדיוס X + Radius Y רדיוס Y + Pins סיכות + Speed מהירות + Taper חידוד קצה (Taper) + Start X התחלה X + Start Y התחלה Y + End X סוף X + End Y סוף Y + Kernel ליבה (Kernel) + Input Mode מצב קלט + Row Order סדר שורות + Eliminate מחק + Create By צור לפי + List רשימה + Pencil Width רוחב עיפרון + Stroke Pressure לחיצת משיכה + Paper Brightness בהירות נייר + Number Of Levels מספר רמות + Edge Simplicity פשטות קצוות + Edge Fidelity דיוק קצוות + Brush Detail פרטי מברשת + Highlight Area אזור הדגשה + Glow Size גודל זוהר + Glow Brightness בהירות זוהר + Brush Type סוג מברשת + Stroke Size גודל משיכה + Stroke Detail פרטי משיכה + Highlight Strength עוצמת הדגשה + Smoothness חלקות + Edge Thickness עובי קצה + Edge Intensity עוצמת קצה + Posterization פוסטריזציה + Stroke Length אורך משיכה + Relief תבליט (Relief) + Definition חדות (Definition) + Texture Coverage כיסוי מרקם + Shadow Intensity עוצמת צל + Edge Width רוחב קצה + Edge Brightness בהירות קצה + Direction Balance איזון כיוון + Black Intensity עוצמת שחור + White Intensity עוצמת לבן + Dark Intensity עוצמת כהה + Custom Pixel Aspect Ratio יחס גובה-רוחב מותאם אישית של פיקסל + 32-bit Preview Options אפשרויות תצוגה מקדימה של 32 סיביות + New Guide קו מנחה חדש + Join the ArtCraft Discord הצטרף לדיסקורד של ArtCraft + Report an Issue דווח על תקלה + Load Files into Stack טען קבצים לערימה + Statistics סטטיסטיקה + Duplicate Layers שכפל שכבות + Edit Toolbar ערוך סרגל כלים + Import Brushes ייבוא מברשות + Load 3D LUT טען LUT תלת-ממדי + Press keys הקש מקשים + Rename Group שנה שם קבוצה + Rename Tool Preset שנה שם ערכת כלי + Search commands, tools and panels חפש פקודות, כלים ופאנלים + Slice Options אפשרויות פרוסה + Rename Brush שנה שם מברשת + Opening פותח + Reading קורא + Working עובד + Accented Edges קצוות מודגשים + Angled Strokes משיכות באלכסון + Anisotropic אניזוטרופי + Around Center מסביב למרכז + Artistic אמנותי + Bamboo במבוק + Bas Relief תבליט שקוע + Berries יער פירות (Berries) + Blast התפוצצות + Bleach Bypass דילוג הלבנה (Bleach Bypass) + Blocks בלוקים + Brick לבנים + Brush Strokes משיכות מברשת + Burlap יוטה (Burlap) + Candle Light אור נר + Circle עיגול + Clumped מגובש + Coarse Dots נקודות גסות + Colored Pencil עפרון צבעוני + Contrasty ניגודי + Cool קריר + Craquelure סדקים (Craquelure) + Cross צלב + Cross Process עיבוד חילופי (Cross Process) + Crosshatch הצללה צולבת + Cutout גזירה (Cutout) + Daisies חרציות + Dark Rough כהה וגס + Dark Strokes משיכות כהות + Darken Only הכהה בלבד + Day For Night יום כלילה + Deep Blue כחול עמוק + Deep Emerald אמרלד עמוק + Deep Red אדום עמוק + Deep Yellow צהוב עמוק + Dot נקודה + Double Line קו כפול + Draft טיוטה + Dry Brush מברשת יבשה + Duplication שכפול + Edge Extension הרחבת קצה + Edge Only קצה בלבד + Enlarged מוגדל + Even Fields שדות זוגיים + Faded Film סרט דהוי + Film Grain גרגיריות סרט + Fine דק + Fine Dots נקודות דקות + Flat שטוח + Fresco פרסקו + From Left משמאל + From Right מימין + Frosted מכוסה כפור + Generic גנרי + Glass זכוכית + Glowing Edges קצוות זוהרים + Grainy Dots נקודות גרגיריות + Hearts לבבות + Heptagon שבעון (Heptagon) + Hexagon משושה + Horizontal Only אופקי בלבד + Infinite אינסופי + Ink Outlines קווי מתאר בדיו + Ivy קיסוס + Left Diagonal אלכסון שמאלי + Level Based מבוסס רמה + Light Rough בהיר וגס + Lighten Only הבהר בלבד + Long Lines קווים ארוכים + Long Strokes משיכות ארוכות + Lower תחתון + Medium Dots נקודות בינוניות + Medium Lines קווים בינוניים + Medium Strokes משיכות בינוניות + Mono Contrast ניגודיות מונוכרומטית + Mosaic Tiles אריחי פסיפס + Multiple Flames Along Path להבות מרובות לאורך הנתיב + Multiple Flames One Direction להבות מרובות בכיוון אחד + Multiple Flames Path Directions להבות מרובות בכיווני הנתיב + Multiple Flames Various Length להבות מרובות באורכים שונים + Octagon מתומן + Odd Fields שדות אי-זוגיים + One Flame Along Path להבה אחת לאורך הנתיב + Out From Center מהמרכז חוצה + Oval אובלי + Overlay Edge שכבת-על קצה + Palette Knife סכין צבעים (Palette Knife) + Parallel מקביל + Patchwork טלאים (Patchwork) + Pentagon מחומש + Point נקודה + Pointed מחודד + Pond Ripples אדוות בריכה + Pyramids פירמידות + Random אקראי + Right Diagonal אלכסון ימני + Rope חבל + Roses שושנים + Sandstone אבן חול + Scallops קונכיות (Scallops) + Sepia ספיה + Short Lines קווים קצרים + Short Strokes משיכות קצרות + Simple פשוט + Light Intensity עוצמת אור + Spray Radius רדיוס ריסוס + Stroke Direction כיוון משיכה + Graininess גרגיריות + Glow Amount מידת זוהר + Clear Amount מידת ניקוי + Distortion עיוות + Ripple Size גודל אדווה + Ripple Magnitude עוצמת אדווה + Charcoal Area אזור פחם + Chalk Area אזור גיר + Charcoal Thickness עובי פחם + Light Dark Balance איזון בהיר-כהה + Foreground Level רמת חזית + Background Level רמת רקע + Pattern Type סוג תבנית + Image Balance איזון תמונה + Darkness כהות + Fiber Length אורך סיבים + Crack Spacing ריווח סדקים + Crack Depth עומק סדקים + Crack Brightness בהירות סדקים + Grain Type סוג גרגיריות + Tile Size גודל אריח + Grout Width רוחב רובה (Grout) + Lighten Grout הבהר רובה + Square Size גודל ריבוע + Border Thickness עובי גבול + Correct Distortion תקן עיוות + Correct Vignette תקן ויניטה + Correct C A תקן סטיית צבע (CA) + Auto Scale קנה מידה אוטומטי + Red Cyan אדום ציאן + Blue Yellow כחול צהוב + Vignette Amount מידת ויניטה + Vignette Midpoint נקודת ביניים של ויניטה + Constraints אילוצים + Curve Highlights הדגשות עקומה + Curve Lights בהירים בעקומה + Curve Darks כהים בעקומה + Curve Shadows צללי עקומה + Curve Splits פיצולי עקומה + Point Curve עקומת נקודה + Point Curve Red עקומת נקודה אדומה + Point Curve Green עקומת נקודה ירוקה + Point Curve Blue עקומת נקודה כחולה + Hsl Hue גוון HSL + Hsl Sat רוויה HSL + Hsl Lum בהירות אור HSL + Grade Shadows דירוג צללים + Grade Midtones דירוג גוני ביניים + Grade Highlights דירוג הדגשות + Grade Global דירוג גלובלי + Grade Blending מיזוג דירוג + Grade Balance איזון דירוג + Sine סינוס + Sketch סקיצה + Snowflakes פתיתי שלג + Sparkle ניצוץ + Spatter התזה + Speckle נקודות (Speckle) + Spin סביבוב + Sprinkles פיזור קטן (Sprinkles) + Squareness ריבועיות + Stagger דילוג (Stagger) + Stained Glass זכוכית צבועה + Star כוכב + Stars כוכבים + Stippled מנוקד (Stippled) + Stretch מתיחה + Teal Orange טורקיז כתום + Tiny Lens עדשה קטנה + To Center למרכז + Unaltered לא משתנה + Underpainting שכבת ציור בסיסית (Underpainting) + Underwater תת-מימי + Upper עליון + Vertical Only אנכי בלבד + Vine With Flowers גפן עם פרחים + Vine With Leaves גפן עם עלים + Violent אלים (Violent) + Warm חמים + Watercolor צבעי מים + Waves גלים + Wide Blurry רחב וטשטוש + Wide Sharp רחב וחד + Wrap עטיפה + Zigzag זיגזג + Wetness לחות + AI Integrations שילובי בינה מלאכותית + Add Noise הוסף רעש + Apply Data Set החל ערכת נתונים + Apply Image החל תמונה + Arbitrary שרירותי + Artboard from Group לוח ציור מקבוצה + Artboard from Layers לוח ציור משכבות + Artboards to Files לוחות ציור לקבצים + Artboards to PDF לוחות ציור ל-PDF + Assign Profile הקצה פרופיל + At ב- + Auto-Align Layers יישור שכבות אוטומטי + Auto-Blend Layers מיזוג שכבות אוטומטי + Batch אצווה + Border גבול + Box Blur טשטוש קופסה + Calculations חישובים + Camera Raw Camera Raw + Camera Raw Filter מסנן Camera Raw + Channel1 ערוץ1 + Channel2 ערוץ2 + Channel3 ערוץ3 + Channel4 ערוץ4 + Clone שכפול + Color Decontaminate ניקוי זיהום צבע + Color Halftone חצי-טון צבעוני + Color Lookup Tables טבלאות חיפוש צבע + Color Settings הגדרות צבע + Color Table טבלת צבעים + Conditional Mode Change שינוי מצב מותנה + Content-Aware Fill מילוי מודע לתוכן + Convert to Linked המר למקושר + Convert to Profile המר לפרופיל + Crystallize גיבוש + Data נתונים + Data Sets as Files ערכות נתונים כקבצים + De-Interlace ביטול שזירה + Define Brush Preset הגדר ערכת מברשת + Define Custom Shape הגדר צורה מותאמת אישית + Define Pattern הגדר תבנית + Defringe הסרת שוליים + Diffuse פיזור + Diffuse Glow זוהר מפוזר + Displace הזזה + Do you want to save you made to “{name}” before quitting? האם לשמור את השינויים שעשית ב"{name}" לפני היציאה? + Dust & Scratches אבק ושריטות + Export Contents ייצוא תוכן + Export Preferences העדפות ייצוא + Export/Import Presets ייצוא/ייבוא ערכות קבועות מראש + Extrude הבלטה תלת-ממדית + Fibers סיבים + Field Blur טשטוש שדה + File Info פרטי קובץ + Filter Gallery גלריית מסננים + Find and Replace Text חיפוש והחלפת טקסט + Fit Image התאמת תמונה + Flame להבה + Focus Area אזור מיקוד + Gaussian Blur טשטוש גאוסי + Global Light אור גלובלי + Group from Layers קבוצה משכבות + HDR Toning גוון HDR + High Pass מעבר עליון + Highlight הדגשה + Image Processor מעבד תמונות + Install Plug-in התקן תוסף + Interpret Footage פרש חומר גלם + Iris Blur טשטוש קשתית + Jag שבר (Jag) + Label תווית + Layer Comps to Files קומפים של שכבות לקבצים + Layer Content Options אפשרויות תוכן שכבה + Layer From Background שכבה מרקע + Layer from Background שכבה מרקע + Layers to Files שכבות לקבצים + Legacy ישן (Legacy) + Lens Blur טשטוש עדשה + Lens Correction תיקון עדשה + Lens Flare זוהר עדשה + Lighting Effects אפקטי תאורה + Liquify נזילות + Lum בהירות אור + Match Color התאמת צבע + Merge to HDR Pro מיזוג ל-HDR Pro + Mezzotint מצו-טינטו + Mosaic פסיפס + Motion Blur טשטוש תנועה + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. גרירה ושחרור טבעיים של קבצים עדיין לא נתמכים ב-Wayland. השתמש בקובץ › פתח, או הפעל את PhotoCraft תחת XWayland עם `WAYLAND_DISPLAY= photocraft`. + Neon Glow זוהר ניאון + New Guide Layout פריסת קווים מנחים חדשה + New Video Layer from File שכבת וידאו חדשה מקובץ + Ocean Ripple אדוות אוקיינוס + Oil Paint צבעי שמן + Open As פתח בתור + Package אריזה + Paint Daubs כתמי צבע + Path Blur טשטוש נתיב + Paths to Illustrator נתיבים ל-Illustrator + Photomerge מיזוג תמונות + Picture Frame מסגרת תמונה + Pinch צביטה + Place Embedded הצבה מוטבעת + Place Linked הצבה מקושרת + Place Scale Marker הצב סמן קנה מידה + Plastic Wrap עטיפת פלסטיק + Pointillize פואנטיליזם + Polar Coordinates קואורדינטות קוטביות + Polar To Rectangular מקוטבי למלבני + Poster Edges קצוות פוסטר + Preset Manager מנהל ערכות קבועות מראש + Profile פרופיל + Radial Blur טשטוש רדיאלי + Raw Defaults ברירות מחדל ל-Raw + Rectangular To Polar ממלבני לקוטבי + Relink to File קשר מחדש לקובץ + Render Video עיבוד וידאו + Replace Color החלפת צבע + Replace Contents החלף תוכן + Replace Footage החלף חומר גלם + Resolve Missing Fonts פתור גופנים חסרים + Ripple אדווה + Rough Pastels פסטלים גסים + Sat רוויה + Save a Copy שמור עותק + Save for Web (Legacy) שמור לאינטרנט (ישן) + Scale Effects שנה קנה מידה של אפקטים + Script Events Manager מנהל אירועי סקריפט + Search menus חפש בתפריטים + Select and Mask בחירה ומיסוך + Set Measurement Scale הגדר קנה מידה למדידה + Shadows/Highlights צללים/הדגשות + Shape Blur טשטוש צורה + Shear גזירה + Smart Blur טשטוש חכם + Smart Sharpen חידוד חכם + Smudge Stick מריחת גיר (Smudge Stick) + Solid Color צבע אחיד + Spherize כדוריות + Spin Blur טשטוש סיבוב + Spot ספוט + Sprayed Strokes משיכות מרוססות + Strokes משיכות + Sumi-e סומי-אה (Sumi-e) + Surface Blur טשטוש משטח + Texturizer יוצר מרקם (Texturizer) + Tiles אריחים + Tilt-Shift הטיה-הזזה + Trace Contour עקוב אחר קווי מתאר + Trap לכידה + Tree עץ + Trim גיזום + Turbulent טורבולנטי + Twirl סחרור + Unsharp Mask מסכת חידוד + Use File › Open to open an image. השתמש בקובץ › פתח כדי לפתוח תמונה. + Vanishing Point נקודת היעלמות + Variable Data Sets ערכות נתונים משתנות + Video Frames to Layers פריימי וידאו לשכבות + WIA Support תמיכת WIA + Warp Text עיוות טקסט + Wind רוח + Vertical Type Tool כלי טקסט אנכי + About {app} אודות {app} + Hide {app} הסתר את {app} + Quit {app} צא מ-{app} + Services שירותים + Hide Others הסתר אחרים + Lut LUT + Cooling L B B קירור LBB + Cooling80 קירור 80 + Cooling82 קירור 82 + Hsb HSB + Hsl HSL + Movie Prime Movie Prime + Prime105 Prime105 + Prime35 Prime35 + Warming L B A חימום LBA + Warming81 חימום 81 + Warming85 חימום 85 +@id filter.sharpen.sharpen חידוד + Good טוב + Best הטוב ביותר + Adjustment Properties מאפייני התאמה + Default Foreground and Background Colors צבעי חזית ורקע ברירת מחדל + Move Layer הזז שכבה + Rectangular Selection בחירה מלבנית + Reorder Layer סדר שכבה מחדש + Select Layer בחר שכבה + Set Colors הגדר צבעים + Switch Foreground and Background Colors החלף צבעי חזית ורקע + Target יעד + Make Work Path… צור נתיב עבודה… + Create Vector Mask צור מסכת וקטור + Make Selection… צור בחירה… + Fill Path… מלא נתיב… + Clipping Path… נתיב גזירה… + Free Transform Path שינוי צורה חופשי לנתיב + Unite Shapes at Overlap אחד צורות בחפיפה + Subtract Shapes at Overlap חסר צורות בחפיפה + Copy Fill העתק מילוי + Copy Complete Stroke העתק קו מתאר מלא + Paste Fill הדבק מילוי + Paste Complete Stroke הדבק קו מתאר מלא + Make Symmetry Path צור נתיב סימטריה + Disable Symmetry Path השבת נתיב סימטריה + Select Custom Color… בחר צבע מותאם אישית… + Color Picker (Custom Canvas Color) בוחר צבעים (צבע קנבס מותאם אישית) + Duplicate Channel שכפל ערוץ + Delete Channel מחק ערוץ + Rename Channel… שנה שם ערוץ… + Merge Spot Channel מזג ערוץ ספוט + Convert to Alpha Channel המר לערוץ אלפא + Exit Quick Mask צא ממסכה מהירה + New Channel… ערוץ חדש… + New Spot Channel… ערוץ ספוט חדש… + Split Channels פצל ערוצים + Merge Channels… מזג ערוצים… + Color Indicates Masked Areas הצבע מציין אזורים ממוסכים + Color Indicates Selected Areas הצבע מציין אזורים נבחרים + ✓ Color Indicates Masked Areas ✓ הצבע מציין אזורים ממוסכים + ✓ Color Indicates Selected Areas ✓ הצבע מציין אזורים נבחרים + Up מעלה + To אל + Shallow שטחי + Deep עמוק + Blend Interior Effects as Group מזג אפקטים פנימיים כקבוצה + Blend Clipped Layers as Group מזג שכבות גזורות כקבוצה + Transparency Shapes Layer השקיפות מעצבת את השכבה + Layer Mask Hides Effects מסכת שכבה מסתירה אפקטים + Vector Mask Hides Effects מסכת וקטור מסתירה אפקטים + General Blending מיזוג כללי + Underlying Layer: שכבה שמתחת: + Nothing to paste: the clipboard holds no image אין מה להדביק: לוח הגזירים אינו מכיל תמונה + Preferences saved ההעדפות נשמרו + Invalid panel layout; restored the default dock פריסת פאנלים לא תקינה; העוגן ברירת המחדל שוחזר + Preferences were reset: {error} ההעדפות אופסו: {error} + Recovered {name} {name} שוחזר + Couldn't save preferences: {error}. Retrying… לא ניתן היה לשמור את ההעדפות: {error}. מנסה שוב… + Autosave failed: {error} השמירה האוטומטית נכשלה: {error} + Exported presets to {path} ערכות קבועות מראש יוצאו אל {path} + Ran script {name} הסקריפט {name} הופעל + Sent to the printer ({spooler}) נשלח למדפסת ({spooler}) + Printed to {path} הודפס אל {path} + Exported {path} ({size}) יוצא {path} ‏({size}) + Exported {path} יוצא {path} + Added swatch {name} נוספה דוגמית הצבע {name} + Step {step} ({id}) failed: {error} השלב {step} ‏({id}) נכשל: {error} + Could not rename the action: {error} לא ניתן היה לשנות את שם הפעולה: {error} + Action playback failed: {error} הפעלת הפעולה נכשלה: {error} + Clone source set at {x}, {y} מקור השכפול הוגדר ב-{x}, {y} + Color management: {error} ניהול צבע: {error} + Opened {url} {url} נפתח + No custom shape "{name}": pick one in the options bar or Window › Shapes אין צורה מותאמת אישית "{name}": בחר אחת בסרגל האפשרויות או בחלון › צורות + Saved a copy as {path} נשמר עותק בשם {path} + Exported contents to {path} התוכן יוצא אל {path} + {label} is not available: {reason} {label} אינו זמין: {reason} + Saved {path}; image assets: {n} in {dir} {path} נשמר; נכסי תמונה: {n} ב-{dir} + {message} (notes: {n}; {warning}) {message} ‏(הערות: {n}; {warning}) + Preferences can't be saved לא ניתן לשמור את ההעדפות + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft ממשיך לנסות; עד שהשמירה תצליח, שינויי ההעדפות יאבדו בסגירה. + GPU retry scheduled ניסיון חזרה ל-GPU מתוזמן + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. שמור את העבודה שלך והפעל את PhotoCraft מחדש כדי לנסות שוב את האצת ה-GPU. עיבוד ה-CPU ימשיך לפעול להפעלה זו. + GPU device was lost; using the CPU renderer. מכשיר ה-GPU אבד; משתמש במעבד ה-CPU. + GPU error; using the CPU renderer. שגיאת GPU; משתמש במעבד ה-CPU. + Couldn't open {name}: {error} לא ניתן היה לפתוח את {name}: {error} + Toggle the Brush Settings panel הצג/הסתר את פאנל הגדרות המברשת + New Brush Preset ערכת מברשת חדשה + Grid view תצוגת רשת + List view תצוגת רשימה + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink בטל קישור + Link קשר + Commit transform אשר שינוי צורה + Cancel transform בטל שינוי צורה + Scroll menu up גלול תפריט למעלה + Scroll menu down גלול תפריט למטה + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? שכבת הטקסט הזו חייבת להיות מרוסטרת לפני ההמשך. הטקסט שלה לא יהיה עוד עריך. לרסטר את הטקסט? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? שכבת הצורה הזו חייבת להיות מרוסטרת לפני ההמשך. היא לא תהיה עוד עריכה כצורה. לרסטר את הצורה? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? האובייקט החכם הזה חייב להיות מרוסטר לפני ההמשך. התוכן שלו לא יהיה עוד עריך. לרסטר את האובייקט החכם? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? שכבת המילוי הזו חייבת להיות מרוסטרת לפני ההמשך. תוכן המילוי שלה לא יהיה עוד עריך. לרסטר את השכבה? + This layer must be rasterized before proceeding. השכבה הזו חייבת להיות מרוסטרת לפני ההמשך. + Color Picker (Background Color) בוחר צבעים (צבע רקע) + Color Picker (Foreground Color) בוחר צבעים (צבע חזית) + Adwaita (Light) Adwaita (בהיר) + Adwaita (Dark) Adwaita (כהה) + Open Recent is unavailable on the web פתח אחרונים אינו זמין בגרסת האינטרנט + Graphics adapter: {adapter} מאיץ גרפי: {adapter} + Backend: {backend} ({type}) מנגנון: {backend} ‏({type}) + Driver: {driver} מנהל התקן: {driver} + GPU backend preference: {backend} העדפת מנגנון GPU: {backend} + Selected at launch: {backend} נבחר בעת ההפעלה: {backend} + Image compositor: {processor} מרכיב תמונה: {processor} + Fallback: {reason} חזרה לברירת מחדל: {reason} + This session: {reason} הפעלה זו: {reason} + Window renderer: {renderer} מעבד חלון: {renderer} + Software adapter מאיץ תוכנה + Graphics adapter מאיץ גרפי + unknown type סוג לא ידוע + Platform: {os} {arch} פלטפורמה: {os} {arch} + previous reading kept; the last re-read failed: {reason} הקריאה הקודמת נשמרה; הקריאה החדשה נכשלה: {reason} + Monitor profile: {profile} פרופיל צג: {profile} + Display profiles: {reason} פרופילי תצוגה: {reason} + Monitor profile in use on {display}: {profile} פרופיל הצג בשימוש ב-{display}: {profile} + Monitor profile in use: {profile} פרופיל הצג בשימוש: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. הקנבס מוצג כ-sRGB. בחר פרופיל צג בעריכה › הגדרות צבע. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. הקנבס ב-{display} מוצג כ-sRGB. בחר פרופיל צג בעריכה › הגדרות צבע. + Display profile not used פרופיל תצוגה לא בשימוש + Monitor profile on {display} (main window): {profile} פרופיל צג ב-{display} (חלון עיקרי): {profile} + Monitor profile on {display}: {profile} פרופיל צג ב-{display}: {profile} + fallback חזרה לברירת מחדל + The document does not have an embedded colour profile. Working space: {working}. למסמך אין פרופיל צבע מוטבע. מרחב עבודה: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} למסמך יש פרופיל צבע מוטבע שאינו תואם למרחב העבודה.\nמוטבע: {embedded}\nעבודה: {working} + Importing {what} from {file}… מייבא {what} מתוך {file}… +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} יובאה {n} מברשת מתוך {file}|יובאו {n} מברשות מתוך {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} יובא {n} מעבר צבעים מתוך {file}|יובאו {n} מעברי צבעים מתוך {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} יובאה {n} תבנית מתוך {file}|יובאו {n} תבניות מתוך {file} + Data Set: {name} ({index}/{total}) ערכת נתונים: {name} ‏({index}/{total}) + (none) (ללא) +navigator Zoom תקריב + Apply {option}: {state} החל {option}: {state} + This adjustment ({key}) is kept as imported and can't be edited yet. התאמה זו ({key}) נשמרת כמיובאת ולא ניתן עדיין לערוך אותה. + Join the ArtCraft Discord ({url}) הצטרף לדיסקורד של ArtCraft ‏({url}) + {index} of {total} {index} מתוך {total} + {shortcut} assigned. {shortcut} הוקצה. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} נמצא כבר בשימוש על ידי {commands} ויוסר ממנו בעת לחיצה על אישור. + The current gradient (pick one in Window › Gradients) מעבר הצבעים הנוכחי (בחר אחד בחלון › מעברי צבעים) + auto אוטומטי + Baseline Shift הזזת קו בסיס + Breeze Dark Breeze כהה + Breeze Light Breeze בהיר + brushes מברשות + Color Picker בוחר צבעים + Current נוכחי + Dark Gray אפור כהה + Fill Color צבע מילוי + gradients מעברי צבעים + Graphic and web גרפיקה ואינטרנט + layer mask מסכת שכבה + Lock All נעל הכול + Lock Image Pixels נעל פיקסלי תמונה + Lock Position נעל מיקום + Lock Transparent Pixels נעל פיקסלים שקופים + manual ידני + Medium Gray אפור בינוני + New document מסמך חדש + New Selection בחירה חדשה + No Document אין מסמך + Note tool כלי הערה + off כבוי + on פעיל + Recent Files קבצים אחרונים + Rgb RGB + Shortcut קיצור + Show Rulers הצג סרגלים + Solarized Dark Solarized כהה + sRGB sRGB + Stroke Color צבע קו מתאר + unknown לא ידוע + Unsaved Changes שינויים שלא נשמרו + vector mask מסכת וקטור + ZigZag זיגזג + Show home recent files הצג קבצים אחרונים במסך הבית + Clear Brush Controls נקה פקדי מברשת + Reset All Locked Settings אפס את כל ההגדרות הנעולות + Copy Texture to Other Tools העתק מרקם לכלים אחרים + Load Actions… טען פעולות… + Save Actions… שמור פעולות… + New Action… פעולה חדשה… + Start Recording התחל הקלטה + Stop Recording עצור הקלטה + {name} (Photoshop) {name} ‏(Photoshop) + {message} ({note}) {message} ‏({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} נטענה {n} פעולה מתוך {file}|נטענו {n} פעולות מתוך {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet ל-{n} שלב אין עדיין מקביל ב-PhotoCraft|ל-{n} שלבים אין עדיין מקביל ב-PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} נשמרה {n} פעולה אל {file}|נשמרו {n} פעולות אל {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} שלב בלי מקביל ב-Photoshop הושמט|{n} שלבים בלי מקביל ב-Photoshop הושמטו + Starting serial מספר סידורי התחלתי + Insert Stop… הוסף נקודת עצירה… + Save Set… שמור קבוצה… + Message: הודעה: + Allow Continue אפשר המשך + Show dialog during playback הצג תיבת דו-שיח בעת ההפעלה + Select an action first בחר פעולה קודם + Action playback stopped הפעלת הפעולה נעצרה + Step {step} is waiting for its dialog השלב {step} מחכה לתיבת הדו-שיח שלו +@plural {n} action|{n} actions {n} פעולה|{n} פעולות + Background (Default) רקע (ברירת מחדל) + Include All Subfolders הכלל את כל תיקיות המשנה + Auto Add/Delete הוספה/מחיקה אוטומטית + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. אל תסמן גם היפוך תמונה בהגדרות הדפסה…: שני ההיפוכים מבטלים זה את זה. + Print Settings… הגדרות הדפסה… + Remember to turn off the printer's own colour management in Print Settings… זכור לכבות את ניהול הצבע של המדפסת עצמה בהגדרות הדפסה… + Sent to {printer} נשלח אל {printer} + set in Print Settings… נקבע בהגדרות הדפסה… + ICO is limited to 256 × 256 pixels ICO מוגבל ל-256 × 256 פיקסלים + Show Favorite Fonts הצג גופנים מועדפים + No favorite fonts yet: click the star beside a font to add it. אין עדיין גופנים מועדפים: לחץ על הכוכב לצד גופן כדי להוסיף אותו. + Favorite fonts גופנים מועדפים + Add to Favorites הוסף למועדפים + Remove from Favorites הסר מהמועדפים + Show All Fonts הצג את כל הגופנים + All Classes כל הסוגים + Blackletter בלאקלטר (Blackletter) + Decorative דקורטיבי + Font preview: תצוגה מקדימה של גופן: + Handwritten כתב יד + Hide Font Classes הסתר סוגי גופנים + Hide Styles הסתר סגנונות + Monospace ברוחב אחיד (Monospace) + No fonts match these filters. אין גופנים התואמים למסננים אלה. + No similar fonts: the current font doesn't say what it looks like. אין גופנים דומים: הגופן הנוכחי לא מציין איך הוא נראה. + OpenType font גופן OpenType + Sans Serif Sans Serif + Script כתב (Script) + Serif Serif + Show Font Classes הצג סוגי גופנים + Show Similar Fonts הצג גופנים דומים + Show Styles הצג סגנונות + Show Variable Fonts הצג גופנים משתנים + Slab Serif Slab Serif + TrueType font גופן TrueType + Variable font גופן משתנה + Calibration כיול + Red Primary ראשי אדום + Green Primary ראשי ירוק + Blue Primary ראשי כחול + Write xmp sidecars כתוב קבצי XMP נלווים + Set the tone sliders from the image הגדר את מחווני הגוון מהתמונה + Settings from {name} הגדרות מתוך {name} + Settings from {name}; {count} not supported: {names} הגדרות מתוך {name}; {count} לא נתמכות: {names} + Could not write {name}: {error} לא ניתן היה לכתוב את {name}: {error} + Kept {name}: written by another application {name} נשמר ללא שינוי: נכתב על ידי יישום אחר + Shadow Tint טינט צל + Check for Updates… בדוק עדכונים… + PhotoCraft Updates עדכוני PhotoCraft + Checking for updates… בודק עדכונים… + Open the release page to choose an installer for your system. פתח את עמוד הגרסה כדי לבחור מתקין למערכת שלך. + Open Download Page פתח עמוד הורדה + PhotoCraft is up to date. PhotoCraft מעודכן. + Could not check for updates. לא ניתן היה לבדוק עדכונים. + Updates have not been checked yet. העדכונים לא נבדקו עדיין. + Check Again בדוק שוב + Automatically Check for Updates בדוק עדכונים אוטומטית + Installed version: {version} גרסה מותקנת: {version} + PhotoCraft {version} is available. PhotoCraft {version} זמין. + Paints from the History panel's source state צובע מהמצב המקור של פאנל ההיסטוריה + Sets the source for the history brush מגדיר את המקור למברשת ההיסטוריה + Highlight Mode מצב הדגשה + Highlight Color צבע הדגשה + Highlight Opacity אטימות הדגשה + Shadow Mode מצב צל + Shadow Color צבע צל + Shadow Opacity אטימות צל + Use the embedded profile ({profile}) השתמש בפרופיל המוטבע ({profile}) + Convert to the working space ({profile}) המר למרחב העבודה ({profile}) + Remember this choice for future images זכור בחירה זו לתמונות עתידיות + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. הפעל מחדש את ההודעה עם שאל בעת חוסר התאמה בעריכה › הגדרות צבע. + Button Mode מצב כפתורים + Button Mode On מצב כפתורים פעיל + Darken Interface הכהה ממשק + Lighten Interface הבהר ממשק + Technique שיטה + Chisel Hard אזמל קשה + Chisel Soft אזמל רך + Open color-less EXR as ACEScg פתח EXR חסר-צבע כ-ACEScg + Do not append "copy" to filename when saving a copy אל תוסיף "עותק" לשם הקובץ בעת שמירת עותק + Edit Selected Guides ערוך קווים מנחים נבחרים + Clear Selected Guides נקה קווים מנחים נבחרים + Bicubic Automatic דו-מעוקב אוטומטי + Bicubic Smoother דו-מעוקב חלק יותר + Bicubic Sharper דו-מעוקב חד יותר + Pasting from other apps is unavailable הדבקה מיישומים אחרים אינה זמינה + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft לא יכול להגיע ללוח הגזירים של המערכת בהפעלה זו. ב-Wayland, ארגז חול של Flatpak מסתיר את הפרוטוקול שבאמצעותו נקראות תמונות מיישומים אחרים. + To paste from other apps, start PhotoCraft under XWayland: `{command}` כדי להדביק מיישומים אחרים, הפעל את PhotoCraft תחת XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here אין מה להדביק: PhotoCraft לא יכול לקרוא את לוח הגזירים של המערכת כאן + Delete this set מחק קבוצה זו + New Set… קבוצה חדשה… + PhotoCraft Defaults ברירות המחדל של PhotoCraft + Save a list of these shortcuts as a web page שמור רשימה של קיצורים אלה כעמוד אינטרנט + Save the changes to this set שמור את השינויים בקבוצה זו + Save these shortcuts as a .kys file שמור קיצורים אלה כקובץ ‎.kys + Save these shortcuts as a new set שמור קיצורים אלה כקבוצה חדשה + Set deleted. הקבוצה נמחקה. + Set saved. הקבוצה נשמרה. + Set: קבוצה: + Summarize… סכם… + There are too many sets: delete one first. יש יותר מדי קבוצות: מחק אחת קודם. + set name שם קבוצה + {set} (modified) {set} (שונה) + Show file name in title הצג שם קובץ בכותרת + 50% Threshold סף 50% + Pattern Dither דיתור תבנית + Diffusion Dither דיתור פיזור + Halftone Screen מסך חצי-טון + Custom Pattern תבנית מותאמת אישית + Frequency: תדירות: + Lines/Inch קווים/אינץ' + Lines/Centimeter קווים/סנטימטר + Ellipse אליפסה + Accept and Go to Conflict אשר ועבור לסתירה + Changes undone. השינויים בוטלו. + Choose a new shortcut for this command, or leave it without one. בחר קיצור חדש לפקודה זו, או השאר אותה בלי קיצור. + Put this command's shortcuts back as they were החזר את הקיצורים של הפקודה הזו כמו שהיו + Take the shortcut from the other command and select it קח את הקיצור מהפקודה האחרת ובחר אותו + Undo Changes בטל שינויים + {shortcut} is reserved by the system and can't be assigned. {shortcut} שמור למערכת ולא ניתן להקצות אותו. + Low-Latency Drawing (vsync off, applies at next launch) ציור בחיוג נמוך (vsync כבוי, חל בהפעלה הבאה) + Free Transform is still being applied; try again when it has finished. שינוי הצורה החופשי עדיין מוחל; נסה שוב כשיסתיים. + The transform couldn't be applied. לא ניתן היה להחיל את שינוי הצורה. + Its layers need {needed} of GPU memory; the budget leaves them {budget}. השכבות שלו צריכות {needed} של זיכרון GPU; התקציב משאיר להן {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. הגדר זיכרון GPU בהעדפות › ביצועים למה שכרטיס המסך שלך יכול להכיל, לכל היותר 3/8 מהזיכרון שלו. אוטומטי עוקב אחר שימוש בזיכרון, במסגרת רבע מזיכרון המחשב הזה ו-3/8 מזיכרון הכרטיס, ולא סופר את המסמך מול כרטיס שהזיכרון שלו ידוע. סרגל המצב מציג זיכרון GPU מלא כשהם לא מתאימים. + GPU Memory Full זיכרון GPU מלא + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. השכבות צריכות {needed} של זיכרון GPU ועשויות להכיל {budget}, כך שעריכות שמציירות מחדש את כל המסמך עושות זאת על ה-CPU. לחץ כדי להגדיר זיכרון GPU. + GPU memory budget: {budget} תקציב זיכרון GPU: {budget} + Graphics memory: {size} זיכרון גרפי: {size} + This document's layers: {needed} of {budget} for layers שכבות המסמך הזה: {needed} מתוך {budget} לשכבות + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU שכבות המסמך הזה צריכות {needed} אבל עשויות להכיל {budget}: ציור מחדש של כל המסמך משתמש ב-CPU + GPU Memory (MB, 0 = Automatic) זיכרון GPU (MB, 0 = אוטומטי) + {path}: the path is not valid Unicode; save under another name. {path}: הנתיב אינו Unicode תקין; שמור בשם אחר. + {name} was changed by another app after it was opened or saved here. {name} השתנה על ידי יישום אחר לאחר שנפתח או נשמר כאן. + Replace it with this document, or cancel and use File › Save As to keep both. החלף אותו במסמך זה, או בטל והשתמש בקובץ › שמור בשם כדי לשמור על שניהם. + Decrease Brush Size הקטן גודל מברשת + Increase Brush Size הגדל גודל מברשת + Decrease Brush Hardness הקטן קשיחות מברשת + Increase Brush Hardness הגדל קשיחות מברשת + Demosaic פענוח מוזאיקה (Demosaic) + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. איך מוזאיקת מסנני הצבע של המצלמה ממולאת. היא מוחלת בעת פתיחת הקובץ; התצוגה המקדימה מציגה את השיטה שבה הוא נפתח. + AHD (adaptive homogeneity) AHD (הומוגניות אדפטיבית) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: הקצוות הנקיים ביותר והכי פחות גזירת צבע, האיטי ביותר. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. אינטרפולציה מתוקנת-מעבר צבעים: חדה ומהירה, עם מעט גזירת צבע בפרטים דקים. + The fastest and the softest, with colour fringing on fine detail. המהירה והרכה ביותר, עם גזירת צבע בפרטים דקים. + “{name}” already exists. Replace it? "{name}" כבר קיים. להחליף אותו? + The name in the save dialog was “{name}”. השם בתיבת הדו-שיח של השמירה היה "{name}". + Previous Brush המברשת הקודמת + Next Brush המברשת הבאה + First Brush המברשת הראשונה + Last Brush המברשת האחרונה + Preview size גודל תצוגה מקדימה + Memory Usage שימוש בזיכרון + Per-document RAM limit (MB) מגבלת RAM למסמך (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM מותקן: {ram} MB; מגבלה זו: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. מגביל את הפיקסלים של כל מסמך פתוח בתוספת ההיסטוריה. מצבי הביטול הישנים ביותר נמחקים כשחורגים מהמגבלה. + Applies immediately when saved. Memory is not reserved in advance. חל באופן מיידי בעת השמירה. הזיכרון לא שמור מראש. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. כאשר זיכרון GPU מוגדר לאוטומטי, זה גם מגביל את תקציב שכבת ה-GPU. התקציב הזה מוגבל לרבע מה-RAM המותקן, וכאשר ידוע, לשלושה שמיניות מזיכרון כרטיס המסך, עם מינימום של 512MB. הגדלת המגבלה הזו מעבר לתקרות הללו לא מגדילה את תקציב ה-GPU. + Use השתמש + In Front בחזית + Behind מאחור + DDS Options אפשרויות DDS + Generate Mipmaps צור Mipmaps + There is no color table to save. אין טבלת צבעים לשמירה. +@plural Saved {n} color to {file}|Saved {n} colors to {file} נשמר {n} צבע אל {file}|נשמרו {n} צבעים אל {file} + GPU adapter מאיץ GPU + Import Styles… ייבוא סגנונות… + Export Selected Styles… ייצוא סגנונות נבחרים… + Export Styles… ייצוא סגנונות… + {file} is not a style library (.asl) {file} אינו ספריית סגנונות (‎.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} יובא {n} סגנון מתוך {file}|יובאו {n} סגנונות מתוך {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} יוצא {n} סגנון אל {file}|יוצאו {n} סגנונות אל {file} + Confirm or cancel the open action step dialog first אשר או בטל קודם את תיבת הדו-שיח של שלב הפעולה הפתוחה + Pro Medium Gray Pro אפור בינוני + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro אפור בהיר + Pro Light Pro בהיר + Resize Windows to Fit שנה גודל חלונות להתאמה + EXR EXR + Automatic paragraph direction כיוון פסקה אוטומטי + Left-to-right paragraph direction כיוון פסקה משמאל לימין + Right-to-left paragraph direction כיוון פסקה מימין לשמאל + Not rendered: this adjustment ({key}) is kept as imported. לא מעובד: התאמה זו ({key}) נשמרת כמיובאת. + Print preview image הדפס תצוגה מקדימה של תמונה + Hue Cube קובייה של גוון + Brightness Cube קובייה של בהירות + Color Wheel גלגל צבעים + RGB Sliders מחווני RGB + HSB Sliders מחווני HSB + CMYK Sliders מחווני CMYK + Lab Sliders מחווני Lab + Web Color Sliders מחווני צבע אינטרנט + Copy Color as HTML העתק צבע כ-HTML + Copy Color's Hex Code העתק את קוד ה-Hex של הצבע + RGB Spectrum ספקטרום RGB + CMYK Spectrum ספקטרום CMYK + Grayscale Ramp שיפוע גווני אפור + Current Colors צבעים נוכחיים + Make Ramp Web Safe הפוך את השיפוע לבטוח לאינטרנט + The path was kept as the Work Path הנתיב נשמר כנתיב עבודה + The path was discarded הנתיב הושלך + Art History Brush Tool כלי מברשת היסטוריה אמנותית + Tight Short צפוף וקצר + Tight Medium צפוף ובינוני + Tight Long צפוף וארוך + Loose Medium רפוי ובינוני + Loose Long רפוי וארוך + Dab נגיעה (Dab) + Tight Curl תלתל צפוף + Loose Curl תלתל רפוי + Stylized dabs from a history state נגיעות מסוגננות ממצב היסטוריה + Font size presets ערכות גודל גופן קבועות מראש + Leading presets ערכות ריווח שורות קבועות מראש + Tracking presets ערכות מעקב תווים קבועות מראש + Color Aware מודע לצבע + Decontaminate Colors נקה זיהום צבעים + Edge Detection גילוי קצוות + Global Refinements חידודים גלובליים + High Quality Preview תצוגה מקדימה באיכות גבוהה + Object Aware מודע לאובייקטים + Real-Time Refinement חידוד בזמן אמת + Refine Edge Brush Tool כלי מברשת חידוד קצה + Refine Hair חדד שיער + Refine Mode מצב חידוד + Remember Settings זכור הגדרות + View Mode מצב תצוגה + Object Aware needs a segmentation model, which isn't available yet מודע לאובייקטים דורש מודל פילוח, שאינו זמין עדיין + Refine Hair needs hair detection, which isn't available yet חידוד שיער דורש גילוי שיער, שאינו זמין עדיין + Select and Mask was closed: its document is no longer the active one בחירה ומיסוך נסגר: המסמך שלו אינו הפעיל יותר + Select and Mask was closed: another layer was selected בחירה ומיסוך נסגר: שכבה אחרת נבחרה + Select Layer Under Cursor בחר שכבה מתחת לסמן + Drag to move the selected path גרור כדי להזיז את הנתיב הנבחר + Drag to move the active shape's path or the Work Path גרור כדי להזיז את הנתיב של הצורה הפעילה או את נתיב העבודה + Export As: {name} ייצוא בשם: {name} + Color Sampler Tool כלי דוגם צבעים + Proof Setup… הגדרת הדמיית הדפסה… + Toggle Layer Mask Overlay הצג/הסתר שכבת-על של מסכת שכבה + Increase Type Size הגדל גודל טקסט + Decrease Type Size הקטן גודל טקסט + Increase Type Size by 10 הגדל גודל טקסט ב-10 + Decrease Type Size by 10 הקטן גודל טקסט ב-10 + Make Selection from Path צור בחירה מנתיב + Target Composite Channel ערוץ מרוכב יעד + Target Channel 3 ערוץ יעד 3 + Target Channel 4 ערוץ יעד 4 + Target Channel 5 ערוץ יעד 5 + Target Channel 6 ערוץ יעד 6 + Target Channel 7 ערוץ יעד 7 + Target Channel 8 ערוץ יעד 8 + Target Channel 9 ערוץ יעד 9 + Fill with Foreground Color מלא בצבע חזית + Fill with Background Color מלא בצבע רקע + Fill with Foreground Color, Preserve Transparency מלא בצבע חזית, שמור על שקיפות + Fill with Background Color, Preserve Transparency מלא בצבע רקע, שמור על שקיפות + Temporary זמני + Space Space + Hand Tool (hold) כלי יד (החזק) + Zoom In (hold, drag to scrub) הגדל תצוגה (החזק, גרור לכיוון) + Zoom Out (hold) הקטן תצוגה (החזק) + Erase with Brush or Pencil (hold) מחק עם מברשת או עיפרון (החזק) + Author Title תפקיד המחבר + Copyright Url כתובת URL לזכויות יוצרים + Title כותרת + Working-cmyk CMYK עבודה + Coated-cmyk CMYK מצופה + Linear-srgb sRGB לינארי + Gray-gamma-2.2 אפור גמא 2.2 + Adobe-rgb-compat תואם Adobe RGB + Prophoto-compat תואם ProPhoto + Display-p3 Display P3 + Intent כוונה (Intent) + Bpc Bpc + Simulate Paper דמה נייר + Warming Filter (85) מסנן חימום (85) + Cw עם כיוון השעון + Ccw נגד כיוון השעון + Nearest שכן קרוב + Mask Channel ערוץ מסכה + R G B R G B + Source Channel ערוץ מקור + Source Invert היפוך מקור + Source1 Channel ערוץ מקור1 + Source1 Invert היפוך מקור1 + Source2 Channel ערוץ מקור2 + Source2 Invert היפוך מקור2 + W: W: + A: A: + Pattern Fill… מילוי תבנית… + Any כלשהו + Cmyk CMYK + Dont Enlarge אל תגדיל + Change Find שנה חיפוש + Case Sensitive תלוי רישיות + Whole Word מילה שלמה + Forward קדימה + Based On מבוסס על + This Layer: שכבה זו: + Checkerboard לוח שחמט + Arc Lower קשת תחתית + Arc Upper קשת עליונה + Bend כיפוף + Horizontal Distortion עיוות אופקי + Vertical Distortion עיוות אנכי + Move Up הזז מעלה + Move Down הזז מטה + CPU Rendering עיבוד CPU + Calculating… מחשב… + Click to refresh histogram with uncached data לחץ לרענון ההיסטוגרמה עם נתונים לא ממוטמנים + Count: ספירה: + Drawn on the CPU: {reason}. Click for System Info. צויר על ה-CPU: {reason}. לחץ לפרטי מערכת. + Level: רמה: + Percentile: אחוזון: + Release Notes הערות גרסה + Show Channels in Color הצג ערוצים בצבע + Show Statistics הצג סטטיסטיקה + Source: מקור: + Uncached Refresh רענון לא ממוטמן + Update and Restart עדכן והפעל מחדש + +# Color picker, gradient map and font list strings added on main + No matching fonts אין גופנים תואמים + Recently Used Colors צבעים שנעשה בהם שימוש לאחרונה + Shapes can't use patterns yet צורות עדיין לא יכולות להשתמש בתבניות + Stop Color צבע נקודת עצירה + +# Strings added on main after the catalog was completed (2026-11-06) + A command was cancelled or stopped during batch processing. Continue with the next file or stop? פקודה בוטלה או נעצרה במהלך עיבוד האצווה. להמשיך לקובץ הבא או לעצור? + A file is still being placed; try again when it has finished. קובץ עדיין נמצא בתהליך הצבה; נסה שוב כשההצבה תסתיים. + A frame supports affine transforms; select its image for perspective transforms מסגרת תומכת בהתמרות אפיניות; בחר את התמונה שלה להתמרות פרספקטיבה + A frame supports affine transforms; select its image to warp it מסגרת תומכת בהתמרות אפיניות; בחר את התמונה שלה כדי לעוות אותה + Accelerated מואץ + Answer the open import question first, then import this file again. ענה תחילה על שאלת הייבוא הפתוחה, ואז ייבא את הקובץ הזה שוב. + Arrowhead options אפשרויות ראש חץ + Arrowheads ראשי חץ + Attribute מאפיין + Background (default) רקע (ברירת מחדל) + Batch preview תצוגה מקדימה של אצווה + Cancelled placing {name} ההצבה של {name} בוטלה + Channel Options… אפשרויות ערוץ… + Concavity קעירות + Convert to Frame… המר למסגרת… + Create a new custom frame צור מסגרת מותאמת אישית חדשה + Create a new polygonal frame צור מסגרת מצולעת חדשה + Create a new triangular frame צור מסגרת משולשת חדשה + Create a single history state צור מצב היסטוריה יחיד + Data Sets as Files is unavailable on the web ערכות נתונים כקבצים אינן זמינות בגרסת האינטרנט + Dropped files weren't placed: their document was closed. הקבצים שנגררו לא הוצבו: המסמך שלהם נסגר. + Dropped files weren't placed: too many are waiting for their document. הקבצים שנגררו לא הוצבו: יותר מדי קבצים ממתינים למסמך שלהם. + Effect אפקט + Empty ריק + End סוף + Filter for embedded smart objects סנן אובייקטים חכמים מוטבעים + Filter for linked smart objects that are missing סנן אובייקטים חכמים מקושרים שחסרים + Filter for smart objects linked locally סנן אובייקטים חכמים המקושרים מקומית + Fit Content to Frame התאם תוכן למסגרת + Fit Frame to Content התאם מסגרת לתוכן + Frame coordinates must be finite and within 600000 pixels קואורדינטות המסגרת חייבות להיות סופיות ובטווח של 600000 פיקסלים + High Memory Photoshop Document מסמך Photoshop עתיר זיכרון + Image Compression דחיסת תמונה + Import Image… ייבוא תמונה… + Import as New Group ייבא כקבוצה חדשה + Import as New Group keeps “{group}” and adds the file as a new group beside it. ייבוא כקבוצה חדשה שומר את "{group}" ומוסיף את הקובץ כקבוצה חדשה לצידה. + Invalid frame transform התמרת מסגרת לא חוקית + Invalid nested file drop גרירת קובץ מקונן לא חוקית + LZW LZW + Layer Effects אפקטי שכבה + Learn למידה + Lining ספרות בגובה אחיד + Linked מקושר + New Frame מסגרת חדשה + New file קובץ חדש + No Adv. Blending ללא מיזוג מתקדם + No Layer Effects ללא אפקטי שכבה + No Layer Mask ללא מסכת שכבה + No Vector Mask ללא מסכת וקטור + No import is waiting. Import the file again. אין ייבוא ממתין. ייבא את הקובץ שוב. + Not Clipped לא גזור + Not Empty לא ריק + Not Linked לא מקושר + Not Locked לא נעול + Not Visible לא גלוי + Numerals ספרות + On your computer במחשב שלך + Open anyway פתח בכל זאת + Opening this document uses more memory and may slow down your computer or other running applications. פתיחת המסמך הזה צורכת יותר זיכרון ועלולה להאט את המחשב שלך או יישומים אחרים הפועלים בו. + Opening “{name}” requires an estimated {est} GiB of memory ({avail} GiB available). פתיחת "{name}" דורשת כ-{est} GiB זיכרון ({avail} GiB זמינים). + Opening “{name}” requires an estimated {est} GiB of memory. The available memory could not be measured. פתיחת "{name}" דורשת כ-{est} GiB זיכרון. לא ניתן היה למדוד את הזיכרון הזמין. + Pause For השהה למשך + Pause For: השהה למשך: + Playback Options… אפשרויות הפעלה… + Proportional יחסי + Proportional Lining יחסי בגובה אחיד + Proportional Oldstyle יחסי עתיק + Replace Image… החלף תמונה… + Replace deletes everything in “{group}” first, including presets you edited or moved there. This can't be undone. החלפה מוחקת תחילה את כל התוכן של "{group}", כולל ערכות קבועות מראש שערכת או העברת לשם. לא ניתן לבטל פעולה זו. + Save Image Pyramid שמור פירמידת תמונה + Save Transparency שמור שקיפות + Set ערכה + Set 1 ערכה 1 + Set 10 ערכה 10 + Set 11 ערכה 11 + Set 12 ערכה 12 + Set 13 ערכה 13 + Set 14 ערכה 14 + Set 15 ערכה 15 + Set 16 ערכה 16 + Set 17 ערכה 17 + Set 18 ערכה 18 + Set 19 ערכה 19 + Set 2 ערכה 2 + Set 20 ערכה 20 + Set 3 ערכה 3 + Set 4 ערכה 4 + Set 5 ערכה 5 + Set 6 ערכה 6 + Set 7 ערכה 7 + Set 8 ערכה 8 + Set 9 ערכה 9 + Set as default הגדר כברירת מחדל + Source: {name} מקור: {name} + Specify Channels: ציין ערוצים: + Start התחלה + Step by Step צעד אחר צעד + Stylistic Sets ערכות סגנוניות + Tabular טבלאי + Tabular Lining טבלאי בגובה אחיד + Tabular Oldstyle טבלאי עתיק + The GPU compositor is still being prepared מרכיב ה-GPU עדיין בהכנה + The History Brush source was deleted: set a new source in the History panel מקור מברשת ההיסטוריה נמחק: הגדר מקור חדש בחלונית ההיסטוריה + The frame is locked המסגרת נעולה + The frame is unavailable המסגרת אינה זמינה + Transparent (default) שקוף (ברירת מחדל) + Use these settings for new arrows השתמש בהגדרות אלה עבור חצים חדשים + Website אתר אינטרנט + ZIP ZIP + seconds שניות + {message} into “{group}” {message} אל "{group}" + {ok} written to {dir} {ok} נכתבו אל {dir} + {set} from {file} replaced the one from {earlier} {set} מתוך {file} החליף את זה מתוך {earlier} +@plural The brush group “{group}” already has {n} brush of yours.|The brush group “{group}” already has {n} brushes of yours. לקבוצת המברשות "{group}" כבר יש {n} מברשת שלך.|לקבוצת המברשות "{group}" כבר יש {n} מברשות שלך. +@plural The gradient group “{group}” already exists ({n} gradient).|The gradient group “{group}” already exists ({n} gradients). קבוצת מעברי הצבעים "{group}" כבר קיימת ({n} מעבר צבעים).|קבוצת מעברי הצבעים "{group}" כבר קיימת ({n} מעברי צבעים). +@plural {n} action file loaded|{n} action files loaded נטען {n} קובץ פעולות|נטענו {n} קובצי פעולות +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} לא ניתן היה לטעון {n} קובץ: {errors}|לא ניתן היה לטעון {n} קבצים: {errors} +@plural {what}: {n} Image Assets output failed|{what}: {n} Image Assets outputs failed {what}: {n} פלט נכסי תמונה נכשל|{what}: {n} פלטי נכסי תמונה נכשלו diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0acf7e4bf1..2e6854129a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -65,6 +65,10 @@ Close बंद करें Close All सभी बंद करें Close Others अन्य सभी बंद करें + New Document… New Document… + Open Document… खोलें… + Consolidate All to Here सभी को यहाँ समेकित करें + Move to New Window नई विंडो में ले जाएँ Save सहेजें Save As… इस रूप में सहेजें… Save a Copy… एक प्रति सहेजें… @@ -156,6 +160,8 @@ Layer Mask लेयर मास्क Hide All सभी छिपाएँ Reveal Selection चयन दिखाएँ + Reveal in Finder Finder में दिखाएँ + Reveal in Explorer Explorer में दिखाएँ Hide Selection चयन छिपाएँ From Transparency पारदर्शिता से Apply लागू करें @@ -231,6 +237,12 @@ Info जानकारी Timeline टाइमलाइन Tools टूल्स + Contextual Task Bar प्रासंगिक कार्य पट्टी + Hide bar पट्टी छिपाएँ + Reset bar position पट्टी की स्थिति रीसेट करें + Pin bar position पट्टी की स्थिति पिन करें + Move bar पट्टी खिसकाएँ + More options अधिक विकल्प Options विकल्प Status Bar स्टेटस बार Move Tool मूव टूल @@ -346,6 +358,7 @@ Aligned संरेखित Fit in View दृश्य में फ़िट करें Camera Raw Camera Raw + Camera Raw… Camera Raw… cameraRaw Amount मात्रा Amount मात्रा cameraRaw Aqua एक्वा @@ -366,10 +379,10 @@ cameraRaw Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer}) cameraRaw Camera Raw isn't open Camera Raw खुला नहीं है Camera Raw isn't open Camera Raw खुला नहीं है cameraRaw Cancel रद्द करें -cameraRaw Cancelled opening {name} {name} खोलना रद्द किया गया - Cancelled opening {name} {name} खोलना रद्द किया गया -cameraRaw Cancelled {label} {label} रद्द किया गया - Cancelled {label} {label} रद्द किया गया +cameraRaw Canceled opening {name} {name} खोलना रद्द किया गया + Canceled opening {name} {name} खोलना रद्द किया गया +cameraRaw Canceled {label} {label} रद्द किया गया + Canceled {label} {label} रद्द किया गया cameraRaw Clarity स्पष्टता Clarity स्पष्टता cameraRaw Color रंग @@ -567,6 +580,8 @@ cameraRaw Yellow पीला Target लक्ष्य Units इकाइयाँ Demosaic डिमोज़ेक + + The GPU compositor is still being prepared The GPU compositor is still being prepared Clear Selection चयन साफ़ करें Color Aware रंग-आधारित Decontaminate Colors रंग शुद्ध करें @@ -590,6 +605,37 @@ cameraRaw Yellow पीला Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. अमान्य संख्यात्मक प्रविष्टि। {min}% और {max}% के बीच एक पूर्णांक आवश्यक है। पिछला मान डाला गया। Edit Brush… ब्रश संपादित करें… +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms फ़्रेम एफ़ाइन रूपांतरणों का समर्थन करता है; परिप्रेक्ष्य रूपांतरणों के लिए उसकी छवि चुनें + A frame supports affine transforms; select its image to warp it फ़्रेम एफ़ाइन रूपांतरणों का समर्थन करता है; वार्प करने के लिए उसकी छवि चुनें + Convert to Frame… फ़्रेम में बदलें… + Create a new custom frame नया कस्टम फ़्रेम बनाएँ + Create a new polygonal frame नया बहुभुजीय फ़्रेम बनाएँ + Create a new triangular frame नया त्रिकोणीय फ़्रेम बनाएँ + Fit Content to Frame सामग्री को फ़्रेम में फ़िट करें + Fit Frame to Content फ़्रेम को सामग्री के अनुसार फ़िट करें + Frame coordinates must be finite and within 600000 pixels फ़्रेम के निर्देशांक परिमित और 600000 पिक्सेल की सीमा में होने चाहिए + Import Image… छवि आयात करें… + Invalid frame transform अमान्य फ़्रेम रूपांतरण + Invalid nested file drop अमान्य नेस्टेड फ़ाइल ड्रॉप + New Frame नया फ़्रेम + Replace Image… छवि बदलें… + The frame is locked फ़्रेम लॉक है + The frame is unavailable फ़्रेम उपलब्ध नहीं है + Both दोनों + Center केंद्र + Create a new elliptical frame नया अंडाकार फ़्रेम बनाएँ + Create a new rectangular frame नया आयताकार फ़्रेम बनाएँ + Frame फ़्रेम + H H + Inside अंदर + Outside बाहर + Sides: भुजाएँ: + Stroke स्ट्रोक + W W + X X + Y Y + # Actions panel: loading several action files at once {message} ({note}) {message} ({note}) @plural {n} action|{n} actions {n} एक्शन|{n} एक्शन @@ -597,3 +643,51 @@ cameraRaw Yellow पीला @plural {n} action file loaded|{n} action files loaded {n} एक्शन फ़ाइल लोड हुई|{n} एक्शन फ़ाइलें लोड हुईं {set} from {file} replaced the one from {earlier} {file} के सेट {set} ने {earlier} वाले सेट की जगह ले ली @plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} फ़ाइल लोड नहीं हो सकी: {errors}|{n} फ़ाइलें लोड नहीं हो सकीं: {errors} + Embed Linked लिंक किए गए को एम्बेड करें + Embed All Linked सभी लिंक किए गए को एम्बेड करें + Toggle reference point संदर्भ बिंदु दिखाएँ/छिपाएँ + +# Action Stop message (#8386) + Message संदेश + Continue जारी रखें + Stop रोकें +# Playback Options (#5212) + Playback Options… प्लेबैक विकल्प… + Playback Options प्लेबैक विकल्प + Accelerated त्वरित + Step by Step चरण दर चरण + Pause For: इतने समय रुकें: + Pause For इतने समय रुकें + seconds सेकंड + Create a single history state एक ही इतिहास स्थिति बनाएँ + +# Channels panel menu (#11451) + New Channel… नया चैनल… + Duplicate Channel चैनल को डुप्लीकेट करें + Delete Channel चैनल हटाएँ + New Spot Channel… नया स्पॉट चैनल… + Merge Spot Channel स्पॉट चैनल मर्ज करें + Channel Options… चैनल विकल्प… + Split Channels चैनल्स विभाजित करें + Merge Channels… चैनल्स मर्ज करें… + Specify Channels: चैनल्स निर्दिष्ट करें: + Not enough grayscale documents for this mode. इस मोड के लिए पर्याप्त ग्रेस्केल डॉक्यूमेंट नहीं हैं। + Pick a different document for each channel. हर चैनल के लिए अलग डॉक्यूमेंट चुनें। + A picked document is no longer open. चुना गया एक डॉक्यूमेंट अब खुला नहीं है। + A picked document is no longer a grayscale document of the same size. चुना गया एक डॉक्यूमेंट अब उसी आकार का ग्रेस्केल डॉक्यूमेंट नहीं है। +# Move tool options bar (#11381) + Align top edges ऊपरी किनारे संरेखित करें + Align vertical centers ऊर्ध्वाधर केंद्र संरेखित करें + Align bottom edges निचले किनारे संरेखित करें + Distribute vertically ऊर्ध्वाधर रूप से वितरित करें + Distribute horizontally क्षैतिज रूप से वितरित करें + Show layer bounds on hover होवर पर लेयर की सीमाएँ दिखाएँ + Expand layer group on click क्लिक पर लेयर समूह विस्तृत करें + Show hover bounds from Layers लेयर्स से होवर सीमाएँ दिखाएँ +# Artboard tool options bar (#11385) + Distribute Widths चौड़ाइयाँ वितरित करें + Distribute Heights ऊंचाइयाँ वितरित करें + Auto-nest Layers लेयर्स को स्वचालित रूप से नेस्ट करें + Auto-size Canvas कैनवास का आकार स्वचालित रूप से बदलें + Keep Relative Position During Layer Reordering लेयर्स का क्रम बदलते समय सापेक्ष स्थिति बनाए रखें + Shrinkwrap Canvas on Save सहेजते समय कैनवास को सामग्री के अनुसार छोटा करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c5a61baed8..ced7e54f43 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -158,6 +158,10 @@ Clipboard Vágólap Close All Összes bezárása Close Others Többi bezárása + New Document… Új dokumentum… + Open Document… Megnyitás… + Consolidate All to Here Összes ide + Move to New Window Áthelyezés új ablakba Color Balance… Színegyensúly… Color Lookup Tables… Színleképezési táblák… Color Lookup… Színleképezés… @@ -190,6 +194,8 @@ Edit Szerkesztés Enhanced Controls… Bővített vezérlők… Equalize Kiegyenlítés + Equalize Entire Image Based On Selected Area A teljes kép kiegyenlítése a kijelölt terület alapján + Equalize Selected Area Only Csak a kijelölt terület kiegyenlítése Exit Kilépés Export As… Exportálás másként… Export Preferences… Beállítások exportálása… @@ -257,6 +263,7 @@ Place Linked… Csatolt elhelyezése… Place Scale Marker… Méretarány-jelölő elhelyezése… Plug-ins… Bővítmények… + Plugins… Bővítmények… Posterize… Poszterizálás… Preferences Beállítások Preset Manager… Előbeállítás-kezelő… @@ -342,6 +349,8 @@ Combine Shapes Alakzatok kombinálása Contract… Szűkítés… Convert to Embedded Átalakítás beágyazottá + Embed Linked Csatolt beágyazása + Embed All Linked Összes csatolt beágyazása Convert to Layers Átalakítás rétegekké Convert to Linked… Átalakítás csatolttá… Convert to Smart Object Átalakítás intelligens objektummá @@ -393,7 +402,6 @@ Insert Blank Frame Üres képkocka beszúrása Interpret Footage… Videoanyag értelmezése… Intersect Shape Areas Alakzatterületek metszete - Inverse Selection Kijelölés megfordítása Invert… Invertálás… Isolate Layers Rétegek elkülönítése Kurtosis Csúcsosság @@ -550,6 +558,7 @@ Clear Canvas Guides Vászon-segédvonalak törlése Clear Guides Segédvonalak törlése Clear Recent Files Legutóbbi fájlok listájának törlése + Clear Recent File List Legutóbbi fájlok listájának törlése Clear Selected Artboard Guides Kijelölt rajztábla segédvonalainak törlése Clear Slices Szeletek törlése Clipping Mask Vágómaszk @@ -558,8 +567,8 @@ Close Panel Panel bezárása Close Window Ablak bezárása Clouds Felhők - Color Blindness — Deuteranopia-type Színtévesztés — deuteranópia típusú - Color Blindness — Protanopia-type Színtévesztés — protanópia típusú + Color Blindness - Deuteranopia-type Színtévesztés - deuteranópia típusú + Color Blindness - Protanopia-type Színtévesztés - protanópia típusú Color Halftone… Színes féltónus… Color Lookup Tables Színleképezési táblák Color Picker (Custom Canvas Color) Színválasztó (egyéni vászonszín) @@ -681,7 +690,7 @@ Info Információ Install Plug-in Bővítmény telepítése Install Plug-in… Bővítmény telepítése… - Internet Standard RGB Internetes szabványos RGB + Internet Standard RGB (sRGB) Internetes szabványos RGB (sRGB) Inverse Megfordítás Iris Blur… Íriszelmosás… Join the ArtCraft Discord Csatlakozás az ArtCraft Discordhoz @@ -698,7 +707,7 @@ Layer From Background… Réteg a háttérből… Layers Rétegek Layers to Files Rétegek fájlokba - Legacy Macintosh RGB Régi Macintosh RGB + Legacy Macintosh RGB (Gamma 1.8) Régi Macintosh RGB (Gamma 1.8) Lens Blur… Objektívelmosás… Lens Flare… Lencsecsillanás… Light gray Világosszürke @@ -746,7 +755,6 @@ New Type Layer Új szövegréteg New Video Layer Új videoréteg New Window Új ablak - New Window for Document Új ablak a dokumentumhoz New Workspace… Új munkaterület… Next Appearance Mode Következő megjelenési mód Next Document Következő dokumentum @@ -791,6 +799,7 @@ Pixel Grid Pixelrács Pixelate Pixelesítés Plug-ins Bővítmények + Plugins Bővítmények Pointillize… Pontozás… Polar Coordinates… Polárkoordináták… Preferences… Beállítások… @@ -803,7 +812,6 @@ Proof Colors Színpróba Proof Setup Színpróba beállítása Properties Tulajdonságok - Quick Export As PNG Gyors exportálás PNG-be Quit Kilépés Quit {app} Kilépés: {app} Radial Blur… Sugárirányú elmosás… @@ -854,6 +862,7 @@ Show All Összes megjelenítése Show Extras Options… Segédelemek beállításai… Show in Explorer Megjelenítés az Intézőben + Reveal in Explorer Megjelenítés az Intézőben Show in Folder Megjelenítés mappában Show menu colors Menüszínek megjelenítése Show/Hide All Panels Összes panel megjelenítése/elrejtése @@ -899,6 +908,12 @@ Titling Alternates Címbetű-változatok Tool Presets Eszköz-előbeállítások Tools Eszközök + Contextual Task Bar Környezetfüggő feladatsáv + Hide bar Sáv elrejtése + Reset bar position Sáv helyzetének visszaállítása + Pin bar position Sáv helyzetének rögzítése + Move bar Sáv áthelyezése + More options További beállítások Trace Contour… Kontúrkövetés… Transform Symmetry Szimmetria alakítása Tree… Fa… @@ -919,7 +934,7 @@ Windows LCD Windows LCD Wind… Szél… Working Black Plate Fekete munkalemez - Working CMY Plate CMY munkalemezek + Working CMY Plates CMY munkalemezek Working CMYK Munka-CMYK Working Cyan Plate Cián munkalemez Working Magenta Plate Magenta munkalemez @@ -1020,7 +1035,7 @@ Catch-up on Stroke End Utolérés a vonás végén Center Középen Center text Szöveg középre igazítása - Centre the gradient (offset 0, 0) Színátmenet középre helyezése (eltolás: 0, 0) + Center the gradient (offset 0, 0) Színátmenet középre helyezése (eltolás: 0, 0) Change Cserél Change All Mindet cserél Change To: Csere erre: @@ -1033,7 +1048,7 @@ Click to add a point, right-click to remove Kattintással pontot adhatsz hozzá, jobb kattintással eltávolíthatod Click to apply, {key}-click to clear overrides, double-click for Style Options Kattintással alkalmazhatod, {key}+kattintással törölheted a felülbírálásokat, dupla kattintással megnyithatod a stílusbeállításokat Click to edit the gradient Kattints a színátmenet szerkesztéséhez - Click to sample the foreground colour · {key}-click for background Kattintás: mintavétel az előtérszínhez · {key}+kattintás: a háttérszínhez + Click to sample the foreground color · {key}-click for background Kattintás: mintavétel az előtérszínhez · {key}+kattintás: a háttérszínhez Clone source set at {x}, {y} Klónozási forrás beállítva: {x}, {y} Clone Stamp Klónozó bélyegző Close Style Options Stílusbeállítások bezárása @@ -1058,6 +1073,7 @@ Control: Vezérlés: Corners Sarkok Could not complete the Free Transform command because the layer is locked. A szabad alakítás nem hajtható végre, mert a réteg zárolva van. + Could not transform because the layer is locked. Az alakítás nem hajtható végre, mert a réteg zárolva van. Could not transform: the layer is empty Az alakítás nem hajtható végre: a réteg üres Could not use Puppet Warp: no opaque pixels A bábtorzítás nem használható: nincsenek átlátszatlan pixelek Could not use Puppet Warp: the layer is empty A bábtorzítás nem használható: a réteg üres @@ -1181,6 +1197,7 @@ History Brush Előzményecset Paints from the History panel's source state Az Előzmények panel forrásállapotából fest Sets the source for the history brush Beállítja az előzményecset forrását + The History Brush source was deleted: set a new source in the History panel Az előzményecset forrása törölve lett: állítson be új forrást az Előzmények panelen Horizontal scale Vízszintes méretarány Horizontal type Vízszintes szöveg Hue Jitter Színárnyalat ingadozása @@ -1807,7 +1824,7 @@ layerLabel Magenta Bíbor Change Screen Mode (F) Képernyőmód váltása (F) Click the first point or press {key} to close · Esc cancels Lezárás: kattintás az első pontra vagy {key} · Esc: megszakítás Click the first point or press {key} to close · {del} removes a point · Esc cancels Lezárás: kattintás az első pontra vagy {key} · {del}: pont eltávolítása · Esc: megszakítás - Click to fill similar colours Kattintással kitöltheted a hasonló színeket + Click to fill similar colors Kattintással kitöltheted a hasonló színeket Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kattintás: nagyítás · {key}+kattintás: kicsinyítés · húzás jobbra/balra: nagyítás/kicsinyítés Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kattints, majd haladj végig egy élen · kattintás: pont rögzítése · {alt}+kattintás: egyenes szakasz · dupla kattintás: lezárás Clip to layer below Vágás az alatta lévő réteghez @@ -1836,7 +1853,7 @@ layerLabel Magenta Bíbor Dark Sötét Data Set: {name} ({index}/{total}) Adatkészlet: {name} ({index}/{total}) Data Sets Adatkészletek - Default colours (D) Alapértelmezett színek (D) + Default Foreground and Background Colors (D) Alapértelmezett előtér- és háttérszín (D) Define Megadás Define at least one variable first. Előbb adj meg legalább egy változót. Delete Cropped Pixels Levágott pixelek törlése @@ -1849,8 +1866,8 @@ layerLabel Magenta Bíbor Discord Discord Doc: {w} × {h} px Dok.: {w} × {h} px Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Húzz egy keretet, vagy kattints a négy sarokra · húzd a sarkokat a ferde alakzat éleire · {commit}: véglegesítés · Esc: megszakítás - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Húzz egy körbevágási keretet · belül húzva mozgatod · az élekkel átméretezed ({ratio}: arány, {centre}: középről) · Space: mozgatás rajzolás közben · nyilak: léptetés · X: tájolás cseréje · {commit}: véglegesítés · Esc: megszakítás - Drag around the centre to rotate the view · Shift constrains to 15° A középpont körül húzva forgatod a nézetet · Shift: 15°-os lépések + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Húzz egy körbevágási keretet · belül húzva mozgatod · az élekkel átméretezed ({ratio}: arány, {centre}: középről) · Space: mozgatás rajzolás közben · nyilak: léptetés · X: tájolás cseréje · {commit}: véglegesítés · Esc: megszakítás + Drag around the center to rotate the view · Shift constrains to 15° A középpont körül húzva forgatod a nézetet · Shift: 15°-os lépések Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Húzz, vagy kattints sokszögpontokat · lasszózás közben Alt: egyenes szakaszok · {add}: hozzáadás · {sub} rajzolás előtt: kivonás Drag to draw a gradient Húzással rajzolhatsz színátmenetet Drag to move the active layer Húzással mozgathatod az aktív réteget @@ -1979,9 +1996,10 @@ layerLabel Magenta Bíbor Size: Méret: Source: not set ({key}-click with Clone Stamp) Forrás: nincs beállítva ({key}+kattintás a klónozó bélyegzővel) Source: set Forrás: beállítva + Source: {name} Forrás: {name} Style Stílus Subtract from selection ({key}) Kivonás a kijelölésből ({key}) - Swap colours (X) Színek cseréje (X) + Switch Foreground and Background Colors (X) Előtér- és háttérszín cseréje (X) System title bar Rendszer címsora Text Replacement Szövegcsere Thumbnail size (smallest: list) Bélyegképméret (a legkisebb: lista) @@ -1996,7 +2014,7 @@ layerLabel Magenta Bíbor Working… Feldolgozás… Workspaces Munkaterületek Zoom Nagyító - {label} is writing the file and can no longer be cancelled {label}: a fájl írása folyamatban, már nem szakítható meg + {label} is writing the file and can no longer be canceled {label}: a fájl írása folyamatban, már nem szakítható meg {message} (notes: {n}; {warning}) {message} (megjegyzések: {n}; {warning}) {mode} Color · {bits} bit Színmód: {mode} · {bits} bit {name} Properties Tulajdonságok: {name} @@ -2319,7 +2337,7 @@ navigator Zoom Nagyítás mértéke One Flame Along Path Egy láng az útvonal mentén Opacity Threshold Átlátszatlansági küszöb Open Megnyitás - Open a document to select a colour range. Színtartomány kijelöléséhez nyiss meg egy dokumentumot. + Open a document to select a color range. Színtartomány kijelöléséhez nyiss meg egy dokumentumot. Orange Narancs Out Black Kimeneti fekete Out From Center Középponttól kifelé @@ -2404,7 +2422,7 @@ navigator Zoom Nagyítás mértéke Roses Rózsák Rough Pastels Durva pasztell Ruler Vonalzó - Sample: foreground colour Minta: előtérszín + Sample: foreground color Minta: előtérszín Sampled Colors Mintavételezett színek Sandstone Homokkő Saturation Telítettség @@ -2512,7 +2530,6 @@ navigator Zoom Nagyítás mértéke Use Measurement Scale Mérési méretarány használata Use Selection In Source Kijelölés használata a forrásban Use Selection In Target Kijelölés használata a célban - Uses the foreground and background colours. Az előtér- és a háttérszínt használja. Vertical Only Csak függőleges Vibrance Élénkség View Options Nézetbeállítások @@ -2561,7 +2578,7 @@ navigator Zoom Nagyítás mértéke Camera Raw Filter Camera Raw szűrő Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) Camera Raw isn't open A Camera Raw nincs megnyitva - Cancelled opening {name} Megnyitás megszakítva: {name} + Canceled opening {name} Megnyitás megszakítva: {name} Damaged Photoshop document Sérült Photoshop-dokumentum The document “{name}” may be damaged. A(z) „{name}” dokumentum sérült lehet. Some layers, pixels or masks could not be read. Do you want to continue opening it? Egyes rétegeket, képpontokat vagy maszkokat nem sikerült beolvasni. Folytatod a megnyitást? @@ -2569,7 +2586,7 @@ navigator Zoom Nagyítás mértéke The document “{name}” does not have an embedded {mode} profile. Nincs beágyazott profil ({mode}) ebben a dokumentumban: „{name}” The document “{name}” has an embedded color profile that does not match the current {mode} working space. A dokumentum beágyazott színprofilja nem egyezik az aktuális munkaszíntérrel ({mode}): „{name}” Working: {working} Munkaszíntér: {working} - Cancelled {label} Megszakítva: {label} + Canceled {label} Megszakítva: {label} Clarity Tisztaság Color Detail Színrészletesség Color Grading Színtónusozás @@ -2713,6 +2730,7 @@ cameraRaw Yellow Sárga All Slices Összes szelet All User Slices Összes felhasználói szelet Always create smart objects when placing Elhelyezéskor mindig intelligens objektum létrehozása + Skip transform when placing Alakítás kihagyása elhelyezéskor Answer the unsaved-changes prompt first Előbb válaszolj a nem mentett módosításokra vonatkozó kérdésre Appearance Settings… Megjelenési beállítások… Apple Watch 45mm Apple Watch 45 mm @@ -2749,7 +2767,7 @@ cameraRaw Yellow Sárga Centimeters Centiméter Clear Kiürít Clear Existing Guides Meglévő segédvonalak törlése - Click to restore the current colour Kattints a jelenlegi szín visszaállításához + Click to restore the current color Kattints a jelenlegi szín visszaállításához Cmd Cmd CMYK Color CMYK-szín Coated CMYK Mázolt papíros CMYK @@ -2930,7 +2948,7 @@ cameraRaw Yellow Sárga No model credits were built into this copy. Ez a példány nem tartalmaz adatokat a modellek készítőiről. No presets of this type. Ilyen típusú előbeállítás nincs. No saved presets yet. Még nincs mentett előbeállítás. - Not a web colour: click for the nearest web colour Nem webes szín: kattints a legközelebbi webes színért + Not a web color: click for the nearest web color Nem webes szín: kattints a legközelebbi webes színért Notification Duration (seconds) Értesítés időtartama (másodperc) Bundle Arrow-Key Nudges Nyílbillentyűs mozgatások összevonása Nudge Bundle Pause (ms) Összevonási szünet (ms) @@ -2941,7 +2959,7 @@ cameraRaw Yellow Sárga OpenGL OpenGL Optimized Optimalizált Original Eredeti - Out of gamut for printing: click for the nearest printable colour Nyomtatási színtartományon kívül: kattints a legközelebbi nyomtatható színért + Out of gamut for printing: click for the nearest printable color Nyomtatási színtartományon kívül: kattints a legközelebbi nyomtatható színért Out of memory Elfogyott a memória Package Csomagolás Percent Százalék @@ -3020,7 +3038,7 @@ cameraRaw Yellow Sárga Rulers & Grids Vonalzók és rácsok Save a Copy Másolat mentése Save as PDF Mentés PDF-ként - Save cancelled Mentés megszakítva + Save canceled Mentés megszakítva Save failed: {error} A mentés nem sikerült: {error} Save for Web (Legacy) Mentés webre (régi) Save HTML and Images HTML és képek mentése @@ -3208,6 +3226,8 @@ cameraRaw Yellow Sárga Constrain proportions Arányok megtartása Constraints Megkötések Background (Default) Háttér (alapértelmezett) + Background (default) Háttér (alapértelmezett) + Transparent (default) Átlátszó (alapértelmezett) Content-Aware Fill Tartalomérzékeny kitöltés Content-Aware Move Tool Tartalomérzékeny mozgatás Contract Szűkítés @@ -3286,7 +3306,6 @@ cameraRaw Yellow Sárga Ellipse Tool Ellipszis Elliptical Marquee Tool Ellipszis-kijelölés Enable Bekapcsolás - Enable animations Animációk engedélyezése Enable flick panning Lendítéses pásztázás engedélyezése Enable floating document window docking Lebegő dokumentumablakok dokkolásának engedélyezése Enable narrow options bar Keskeny beállítósáv engedélyezése @@ -3431,6 +3450,7 @@ cameraRaw Yellow Sárga Magnetic Lasso Mágneses lasszó Magnetic Lasso Tool Mágneses lasszó Make Selection Kijelölés készítése + Make Shape Alakzat készítése Make work path from selection Munkaútvonal készítése a kijelölésből Manual Kézi manual kézi @@ -3626,6 +3646,11 @@ cameraRaw Yellow Sárga Show canvas dots Vászonpontok megjelenítése Clip thumbnails to layer bounds Bélyegképek levágása a réteg határaira Clip Thumbnails to Layer Bounds Bélyegképek levágása a réteg határaira + No Thumbnails Nincs bélyegkép + Small Thumbnails Kis bélyegképek + Medium Thumbnails Közepes bélyegképek + Large Thumbnails Nagy bélyegképek + Clip Thumbnails to Document Bounds Bélyegképek levágása a dokumentum határaira Show channels in color Csatornák megjelenítése színesen Show crosshair in brush tip Szálkereszt az ecsethegyben Show extension panels Bővítménypanelek megjelenítése @@ -4173,7 +4198,7 @@ cameraRaw Yellow Sárga Auto Add/Delete Automatikus hozzáadás/törlés Don't also tick Mirror Image in Print Settings…: the two flips cancel out. A nyomtatási beállításokban ne jelöld be a tükrözést is: a két tükrözés kioltja egymást. Print Settings… Nyomtatási beállítások… - Remember to turn off the printer's own colour management in Print Settings… Ne felejtsd el kikapcsolni a nyomtató saját színkezelését a nyomtatási beállításokban… + Remember to turn off the printer's own color management in Print Settings… Ne felejtsd el kikapcsolni a nyomtató saját színkezelését a nyomtatási beállításokban… Sent to {printer} Elküldve ide: {printer} set in Print Settings… a nyomtatási beállításokban adható meg… ICO is limited to 256 × 256 pixels Az ICO legfeljebb 256 × 256 képpont lehet @@ -4330,12 +4355,12 @@ cameraRaw Yellow Sárga Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése Demosaic Demozaikolás - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hogyan tölti ki a program a kamera színszűrő-mozaikját. A fájl megnyitásakor érvényesül; az előnézet a megnyitáskor használt módszert mutatja. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hogyan tölti ki a program a kamera színszűrő-mozaikját. A fájl megnyitásakor érvényesül; az előnézet a megnyitáskor használt módszert mutatja. AHD (adaptive homogeneity) AHD (adaptív homogenitás) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: a legtisztább élek és a legkevesebb színes szegély, a leglassabb. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradienskorrigált interpoláció: éles és gyors, apró részleteken némi színes szegéllyel. - The fastest and the softest, with colour fringing on fine detail. A leggyorsabb és a leglágyabb, apró részleteken színes szegéllyel. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: a legtisztább élek és a legkevesebb színes szegély, a leglassabb. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradienskorrigált interpoláció: éles és gyors, apró részleteken némi színes szegéllyel. + The fastest and the softest, with color fringing on fine detail. A leggyorsabb és a leglágyabb, apró részleteken színes szegéllyel. “{name}” already exists. Replace it? „{name}” már létezik. Lecseréli? The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”. Previous Brush Előző ecset @@ -4416,6 +4441,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Betűméret-előbeállítások Leading presets Sorköz-előbeállítások Tracking presets Betűköz-előbeállítások + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Érvénytelen számbevitel. {min}% és {max}% közötti egész szám szükséges. Az előző érték lett beillesztve. Color Aware Színalapú Decontaminate Colors Színek fertőtlenítése @@ -4440,3 +4467,70 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Színpont színe Shapes can't use patterns yet Az alakzatok még nem használhatnak mintákat Edit Brush… Ecset szerkesztése… + +# Action Stop message (#8386) + Message Üzenet +# Playback Options (#5212) + Playback Options… Lejátszási beállítások… + Playback Options Lejátszási beállítások + Accelerated Gyorsított + Step by Step Lépésenként + Pause For: Szünet ennyi ideig: + Pause For Szünet ennyi ideig + seconds másodperc + Create a single history state Egyetlen előzményállapot létrehozása + This Layer: Ez a réteg: + Generator enabled Generátor bekapcsolva + Move Up Mozgatás felfelé + Move Down Mozgatás lefelé + On your computer A számítógépen + New file Új fájl + Learn Tanulás + Website Weboldal + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms A keret affin transzformációkat támogat; perspektivikus transzformációhoz válassza ki a képét + A frame supports affine transforms; select its image to warp it A keret affin transzformációkat támogat; a hajlításhoz válassza ki a képét + Convert to Frame… Átalakítás keretté… + Create a new custom frame Új egyéni keret létrehozása + Create a new polygonal frame Új sokszögű keret létrehozása + Create a new triangular frame Új háromszögű keret létrehozása + Fit Content to Frame Tartalom igazítása a kerethez + Fit Frame to Content Keret igazítása a tartalomhoz + Frame coordinates must be finite and within 600000 pixels A keret koordinátáinak végesnek és 600000 képponton belülinek kell lenniük + Import Image… Kép importálása… + Invalid frame transform Érvénytelen kerettranszformáció + Invalid nested file drop Érvénytelen beágyazott fájlejtés + New Frame Új keret + Replace Image… Kép cseréje… + The frame is locked A keret zárolva van + The frame is unavailable A keret nem érhető el + +# Channels panel menu (#11451) + Channel Options… Csatornabeállítások… + Specify Channels: Csatornák megadása: + Not enough grayscale documents for this mode. Nincs elég szürkeárnyalatos dokumentum ehhez a módhoz. + Pick a different document for each channel. Minden csatornához másik dokumentumot válasszon. + A picked document is no longer open. Az egyik kiválasztott dokumentum már nincs megnyitva. + A picked document is no longer a grayscale document of the same size. Az egyik kiválasztott dokumentum már nem azonos méretű szürkeárnyalatos dokumentum. +# Move tool options bar (#11381) + Align top edges Felső szélek igazítása + Align vertical centers Függőleges középpontok igazítása + Align bottom edges Alsó szélek igazítása + Distribute vertically Egyenletes elosztás függőlegesen + Distribute horizontally Egyenletes elosztás vízszintesen + Show layer bounds on hover Réteghatárok megjelenítése rámutatáskor + Expand layer group on click Rétegcsoport kibontása kattintásra + Show hover bounds from Layers Határok megjelenítése a Rétegek panelről rámutatáskor + Saved Camera Raw settings to {file} Camera Raw-beállítások mentve ide: {file} + Save the settings to each raw's sidecar without opening it A beállítások mentése minden raw kísérőfájljába megnyitás nélkül + No settings saved: these raws can't keep a sidecar here Nincs mentve semmi: ezek a raw fájlok itt nem kaphatnak kísérőfájlt + Some settings could not be saved; the raws stay open Néhány beállítást nem sikerült menteni; a raw fájlok nyitva maradnak + Nothing to save: these raws are at their default settings Nincs mit menteni: ezek a raw fájlok alapértelmezett beállításokon vannak +# Artboard tool options bar (#11385) + Distribute Widths Szélességek elosztása + Distribute Heights Magasságok elosztása + Auto-nest Layers Rétegek automatikus beágyazása + Auto-size Canvas Vászonméret automatikus módosítása + Keep Relative Position During Layer Reordering Relatív pozíció megtartása a rétegek átrendezésekor + Shrinkwrap Canvas on Save Vászon összehúzása a tartalomra mentéskor diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e5058af2c9..4ba6f52758 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -164,6 +164,7 @@ Right Kanan Open Recent Terakhir Dibuka Clear Recent Files Hapus Daftar Berkas Terakhir + Clear Recent File List Hapus Daftar Berkas Terakhir Remove from Recent Hapus dari Daftar Terakhir Remove {name} from Recent Hapus {name} dari Daftar Terakhir Close Tutup @@ -172,6 +173,10 @@ Restore Pulihkan Close All Tutup Semua Close Others Tutup Lainnya + New Document… Dokumen Baru… + Open Document… Buka… + Consolidate All to Here Gabungkan Semua ke Sini + Move to New Window Pindahkan ke Jendela Baru Save Simpan Save As… Simpan Sebagai… Save a Copy… Simpan Salinan… @@ -224,6 +229,7 @@ Create Smart Object after Loading Layers Buat Smart Object setelah Memuat Layer Reveal in Finder Tampilkan di Finder Show in Explorer Tampilkan di Explorer + Reveal in Explorer Tampilkan di Explorer Show in Folder Tampilkan di Folder File Info… Info Berkas… Print… Cetak… @@ -310,6 +316,7 @@ Units & Rulers… Satuan & Penggaris… Guides, Grid & Slices… Panduan, Grid & Slice… Plug-ins… Plug-in… + Plugins… Plug-in… Type… Teks… Enhanced Controls… Kontrol Lanjutan… Camera Raw… Camera Raw… @@ -358,6 +365,8 @@ Match Color… Cocokkan Warna… Replace Color… Ganti Warna… Equalize Ekualisasi + Equalize Selected Area Only Ekualisasi seleksi saja + Equalize Entire Image Based On Selected Area Ekualisasi seluruh gambar berdasarkan seleksi Auto Tone Auto Tone Auto Contrast Auto Kontras Auto Color Auto Warna @@ -459,6 +468,8 @@ Smart Object Smart Object Convert to Smart Object Konversi ke Smart Object Convert to Embedded Konversi ke Tertanam + Embed Linked Tanamkan yang Tertaut + Embed All Linked Tanamkan Semua yang Tertaut Convert to Layers Konversi ke Layer Convert to Linked… Konversi ke Tertaut… Edit Contents Edit Isi @@ -526,7 +537,9 @@ Unite Shapes Satukan Bentuk Subtract Front Shape Kurangi Bentuk Depan Intersect Shape Areas Irisan Area Bentuk + Unite Shapes at Overlap Irisan Area Bentuk Exclude Overlapping Shapes Kecualikan Bentuk yang Tumpang Tindih + Subtract Shapes at Overlap Kecualikan Bentuk yang Tumpang Tindih Merge Shape Components Gabungkan Komponen Bentuk Path operations Operasi path Align Rata @@ -603,7 +616,6 @@ Deselect Batalkan Seleksi Reselect Pilih Ulang Inverse Kebalikan - Inverse Selection Balik Seleksi Similar Serupa Color Range… Rentang Warna… Focus Area… Area Fokus… @@ -711,6 +723,7 @@ Minimum… Minimum… Offset… Offset… Plug-ins Plug-in + Plugins Plug-in Proof Setup Pengaturan Proof Proof Colors Warna Proof Gamut Warning Peringatan Gamut @@ -719,12 +732,12 @@ Working Magenta Plate Pelat Magenta Kerja Working Yellow Plate Pelat Kuning Kerja Working Black Plate Pelat Hitam Kerja - Working CMY Plate Pelat CMY Kerja - Legacy Macintosh RGB RGB Macintosh Lawas - Internet Standard RGB RGB Standar Internet + Working CMY Plates Pelat CMY Kerja + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh Lawas (Gamma 1.8) + Internet Standard RGB (sRGB) RGB Standar Internet (sRGB) Monitor RGB RGB Monitor - Color Blindness — Protanopia-type Buta Warna — Tipe Protanopia - Color Blindness — Deuteranopia-type Buta Warna — Tipe Deuteranopia + Color Blindness - Protanopia-type Buta Warna - Tipe Protanopia + Color Blindness - Deuteranopia-type Buta Warna - Tipe Deuteranopia Zoom In Perbesar Zoom Out Perkecil Fit on Screen Pas di Layar @@ -813,7 +826,7 @@ Consolidate All to Tabs Gabungkan Semua ke Tab Float in Window Mengambang di Jendela Float All in Windows Mengambangkan Semua di Jendela - New Window for Document Jendela Baru untuk Dokumen + New Window Jendela Baru Workspace Ruang Kerja Essentials Esensial Graphic and Web Grafis dan Web @@ -875,6 +888,12 @@ Timeline Timeline Tool Presets Preset Alat Tools Alat + Contextual Task Bar Bilah Tugas Kontekstual + Hide bar Sembunyikan bilah + Reset bar position Atur ulang posisi bilah + Pin bar position Sematkan posisi bilah + Move bar Pindahkan bilah + More options Opsi lainnya About PhotoCraft Tentang PhotoCraft PhotoCraft Website Situs Web PhotoCraft PhotoCraft on GitHub PhotoCraft di GitHub @@ -1053,7 +1072,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Klik untuk menambah titik · seret keluar atau {key}-klik atau Delete untuk menghapus Click to apply, {key}-click to clear overrides, double-click for Style Options Klik untuk menerapkan, {key}-klik untuk menghapus override, klik dua kali untuk Opsi Gaya Click to edit the gradient Klik untuk mengedit gradien - Click to restore the current colour Klik untuk memulihkan warna saat ini + Click to restore the current color Klik untuk memulihkan warna saat ini Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klik untuk zoom in · {key}-klik untuk zoom out · seret kanan/kiri untuk zoom in/out Click with the Note tool to add a note. Klik dengan alat Catatan untuk menambah catatan. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: sudut · Seret: halus · Klik titik pertama: tutup · {key} selesai · Esc batal @@ -1136,7 +1155,7 @@ Darks Gelap Data Sets Data Set Default Default - Default colours (D) Warna default (D) + Default Foreground and Background Colors (D) Warna default (D) Default printer Printer default Default restored. Default dipulihkan. Define Tentukan @@ -1413,7 +1432,7 @@ Opacity Jitter Jitter Opasitas Opacity: Opasitas: Open a document to define variables. Buka dokumen untuk menentukan variabel. - Open a document to select a colour range. Buka dokumen untuk memilih rentang warna. + Open a document to select a color range. Buka dokumen untuk memilih rentang warna. Open a document to use the timeline. Buka dokumen untuk memakai timeline. Optical Optik Optimized Dioptimalkan @@ -1489,7 +1508,6 @@ Purity Kemurnian Quality Kualitas Quick Actions Aksi Cepat - Quick Export As PNG Ekspor Cepat Sebagai PNG Radial Gradient Gradien Radial Radius Radius Radius: Radius: @@ -1630,7 +1648,7 @@ Subtract from Sample Kurangi dari Sampel Subtract from selection ({key}) Kurangi dari seleksi ({key}) Suggestions: Saran: - Swap colours (X) Tukar warna (X) + Switch Foreground and Background Colors (X) Tukar warna (X) Swaps height and width Tukar tinggi dan lebar Switch between free transform and warp modes Beralih antara mode transformasi bebas dan warp Switch theme Ganti tema @@ -1732,7 +1750,7 @@ Choke Choke Classic Klasik Click to add text Klik untuk menambah teks - Click to fill similar colours Klik untuk mengisi warna serupa + Click to fill similar colors Klik untuk mengisi warna serupa Clone Stamp Tool Alat Clone Stamp Color Burn Color Burn Color Dodge Color Dodge @@ -1833,6 +1851,7 @@ Move Tool Alat Pindah Could not use the move tool because the layer is locked. Tidak dapat menggunakan alat Pindahkan karena layer terkunci. Could not complete the Free Transform command because the layer is locked. Tidak dapat menyelesaikan perintah Transformasi Bebas karena layer terkunci. + Could not transform because the layer is locked. Tidak dapat mentransformasi karena layer terkunci. Multiply Multiply My Brushes Kuas Saya Neutrals Netral @@ -1891,7 +1910,7 @@ Rectangular Marquee Tool Alat Rectangular Marquee Reds Merah Reflected Pantulan - Sample: foreground colour Sampel: warna foreground + Sample: foreground color Sampel: warna foreground Sampled Colors Warna Sampel Satin Satin Save Pixel Aspect Ratio Simpan Rasio Aspek Piksel @@ -1912,6 +1931,7 @@ Soften Lembutkan Source Sumber Source: set Sumber: sudah diatur + Source: {name} Sumber: {name} Sponge Tool Alat Sponge Spot Healing Brush Tool Alat Spot Healing Brush Spread Sebar @@ -1929,7 +1949,6 @@ Transfer Transfer Triangle Tool Alat Triangle Unsaved changes Perubahan belum disimpan - Uses the foreground and background colours. Memakai warna foreground dan background. Web Web Web Large Web Besar Web Minimum Web Minimum @@ -1964,6 +1983,7 @@ Allow scripts to connect Izinkan skrip terhubung Always Selalu Always create smart objects when placing Selalu buat smart object saat menempatkan + Skip transform when placing Lewati transformasi saat menempatkan Animated zoom Zoom beranimasi Apply auto tone Terapkan auto tone Ask Tanya @@ -1986,6 +2006,11 @@ Show canvas dots Tampilkan titik kanvas Clip thumbnails to layer bounds Pangkas thumbnail ke batas layer Clip Thumbnails to Layer Bounds Pangkas Thumbnail ke Batas Layer + No Thumbnails Tanpa Thumbnail + Small Thumbnails Thumbnail Kecil + Medium Thumbnails Thumbnail Sedang + Large Thumbnails Thumbnail Besar + Clip Thumbnails to Document Bounds Pangkas Thumbnail ke Batas Dokumen Canvas color Warna kanvas Canvas custom color Warna kustom kanvas Color picker Pemilih warna @@ -2103,7 +2128,6 @@ Show only crosshair while painting Tampilkan crosshair saja saat melukis Show slice numbers Tampilkan nomor slice Show tooltips Tampilkan tooltip - Enable animations Aktifkan animasi Show highlight on rollover Tampilkan sorotan saat kursor melintas Show transformation values Tampilkan nilai transformasi Slice color Warna slice @@ -2193,7 +2217,7 @@ Graphics Grafis Reset GPU Backend Reset Backend GPU Applies at next launch. Berlaku saat dibuka berikutnya. - Click to sample the foreground colour · {key}-click for background Klik untuk mengambil sampel warna foreground · {key}-klik untuk background + Click to sample the foreground color · {key}-click for background Klik untuk mengambil sampel warna foreground · {key}-klik untuk background {key}-click to set the source {key}-klik untuk mengatur sumber {key} to subtract {key} untuk mengurangi Clone source {n} ({key}-click with Clone Stamp to set) Sumber clone {n} ({key}-klik dengan Clone Stamp untuk mengatur) @@ -2251,7 +2275,7 @@ Gradient Options Opsi Gradien Align with layer Sejajarkan dengan layer Reset Alignment Reset Penyelarasan - Centre the gradient (offset 0, 0) Tengahkan gradien (offset 0, 0) + Center the gradient (offset 0, 0) Tengahkan gradien (offset 0, 0) Gradient Editor Editor Gradien Gradient picker Pemilih gradien Save As Preset Simpan sebagai Preset @@ -2283,7 +2307,7 @@ Vulkan Vulkan No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Tidak ada path untuk dibuat seleksi: gambar dengan alat Pen atau pilih salah satu di panel Path. Cancel opening Batalkan pembukaan - Cancelled opening {name} Pembukaan {name} dibatalkan + Canceled opening {name} Pembukaan {name} dibatalkan Damaged Photoshop document Dokumen Photoshop rusak The document “{name}” may be damaged. Dokumen “{name}” mungkin rusak. Some layers, pixels or masks could not be read. Do you want to continue opening it? Beberapa layer, piksel, atau mask tidak dapat dibaca. Lanjutkan membukanya? @@ -2293,7 +2317,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumen “{name}” memiliki profil warna tertanam yang tidak cocok dengan working space {mode} saat ini. Working: {working} Working space: {working} Developing {name} Mengembangkan {name} - Cancelled {label} {label} dibatalkan + Canceled {label} {label} dibatalkan Esc to cancel Esc untuk batal Opened {name} Dibuka: {name} Opening {name} Membuka {name} @@ -2301,7 +2325,7 @@ Saving {name} Menyimpan {name} Saving {percent} Menyimpan {percent} A save to {name} is still running; try again when it finishes Penyimpanan ke {name} masih berjalan; coba lagi setelah selesai - {label} is writing the file and can no longer be cancelled {label} sedang menulis berkas dan tidak dapat dibatalkan lagi + {label} is writing the file and can no longer be canceled {label} sedang menulis berkas dan tidak dapat dibatalkan lagi Opening {name}… Membuka {name}… Opening… Membuka… Reading… Membaca… @@ -2484,6 +2508,7 @@ Path to Illustrator… Path ke Illustrator… Make Work Path Buat Work Path Make Selection Buat Seleksi + Make Shape Buat Bentuk Fill Path Isi Path Stroke Path Stroke Path Clipping Path Clipping Path @@ -2664,7 +2689,7 @@ Pixel Piksel Adjustment Penyesuaian Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Seret (lasso) atau klik titik (polygonal) · {add} tambah · {sub} kurangi - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Seret kotak crop · seret di dalam untuk memindah · tepi mengubah ukuran (rasio {ratio}, tengah {centre}) · Space memindah saat menggambar · panah menggeser · X menukar orientasi · {commit} menerapkan · Esc batal + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Seret kotak crop · seret di dalam untuk memindah · tepi mengubah ukuran (rasio {ratio}, tengah {centre}) · Space memindah saat menggambar · panah menggeser · X menukar orientasi · {commit} menerapkan · Esc batal {mode} Color · {bits} bit Warna {mode} · {bits} bit Drag to move the layer Seret untuk memindah layer Scrubby Zoom Zoom Scrubby @@ -2858,7 +2883,7 @@ cameraRaw Blending Pembauran Discard Layers and Save a Copy Buang Layer dan Simpan Salinan Don't show again Jangan tampilkan lagi Save failed: {error} Gagal menyimpan: {error} - Save cancelled Penyimpanan dibatalkan + Save canceled Penyimpanan dibatalkan Couldn't place {name}: {error} Tidak dapat menempatkan {name}: {error} Magnetic Lasso Tool Alat Magnetic Lasso Frequency Frekuensi @@ -2987,7 +3012,7 @@ fontWeight Italic Miring Impressionist Impresionis Rotate View Tool Alat Putar Tampilan Reset View Atur Ulang Tampilan - Drag around the centre to rotate the view · Shift constrains to 15° Seret di sekitar pusat untuk memutar tampilan · Shift membatasi ke 15° + Drag around the center to rotate the view · Shift constrains to 15° Seret di sekitar pusat untuk memutar tampilan · Shift membatasi ke 15° Dual Axis Sumbu Ganda Diagonal Diagonal Transform Symmetry Transformasi Simetri @@ -3002,8 +3027,8 @@ fontWeight Italic Miring All Layers No Adjustments Semua Layer Tanpa Penyesuaian Current & Below No Adjustments Saat Ini & Di Bawahnya Tanpa Penyesuaian Show Sampling Ring Tampilkan Cincin Sampel - Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat - Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat + Out of gamut for printing: click for the nearest printable color Di luar gamut cetak: klik untuk warna cetak terdekat + Not a web color: click for the nearest web color Bukan warna web: klik untuk warna web terdekat Color to Alpha… Warna ke Alpha… Transparency Threshold Ambang transparansi Opacity Threshold Ambang opasitas @@ -3293,12 +3318,14 @@ curves Smooth Haluskan Step {step} is waiting for its dialog Langkah {step} menunggu dialognya @plural {n} action|{n} actions {n} tindakan Background (Default) Background (Default) + Background (default) Background (Default) + Transparent (default) Transparan (bawaan) Content-Aware Fill Isi Content-Aware Include All Subfolders Sertakan semua subfolder Auto Add/Delete Tambah/Hapus Otomatis Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Jangan aktifkan juga Gambar Cermin di Pengaturan Cetak: kedua pembalikan saling meniadakan. Print Settings… Pengaturan Cetak… - Remember to turn off the printer's own colour management in Print Settings… Ingat untuk menonaktifkan pengelolaan warna milik printer di Pengaturan Cetak… + Remember to turn off the printer's own color management in Print Settings… Ingat untuk menonaktifkan pengelolaan warna milik printer di Pengaturan Cetak… Sent to {printer} Dikirim ke {printer} set in Print Settings… diatur di Pengaturan Cetak… ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel @@ -3346,6 +3373,7 @@ curves Smooth Haluskan Automatically Check for Updates Periksa Pembaruan Secara Otomatis Paints from the History panel's source state Melukis dari status sumber panel Riwayat Sets the source for the history brush Menetapkan sumber untuk kuas riwayat + The History Brush source was deleted: set a new source in the History panel Sumber kuas riwayat telah dihapus: atur sumber baru di panel Riwayat Highlight Mode Mode Sorotan Highlight Color Warna Sorotan Highlight Opacity Opasitas Sorotan @@ -3430,12 +3458,12 @@ curves Smooth Haluskan Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas Demosaic Demosaik - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cara mosaik filter warna kamera diisi. Diterapkan saat berkas dibuka; pratinjau menampilkan metode yang dipakai saat dibuka. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cara mosaik filter warna kamera diisi. Diterapkan saat berkas dibuka; pratinjau menampilkan metode yang dipakai saat dibuka. AHD (adaptive homogeneity) AHD (homogenitas adaptif) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: tepi paling bersih dan pinggiran warna paling sedikit, paling lambat. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolasi terkoreksi gradien: tajam dan cepat, dengan sedikit pinggiran warna pada detail halus. - The fastest and the softest, with colour fringing on fine detail. Paling cepat dan paling lembut, dengan pinggiran warna pada detail halus. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: tepi paling bersih dan pinggiran warna paling sedikit, paling lambat. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolasi terkoreksi gradien: tajam dan cepat, dengan sedikit pinggiran warna pada detail halus. + The fastest and the softest, with color fringing on fine detail. Paling cepat dan paling lembut, dengan pinggiran warna pada detail halus. “{name}” already exists. Replace it? “{name}” sudah ada. Ganti? The name in the save dialog was “{name}”. Nama di dialog simpan adalah “{name}”. Previous Brush Kuas sebelumnya @@ -3515,6 +3543,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Preset ukuran font Leading presets Preset leading Tracking presets Preset tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entri numerik tidak valid. Diperlukan bilangan bulat antara {min}% dan {max}%. Nilai sebelumnya telah disisipkan. Clear Selection Hapus Seleksi Color Aware Berbasis Warna @@ -3543,3 +3573,80 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color Warna Solid Tint Color Warna Tint Edit Brush… Edit Kuas… + +# Action Stop message (#8386) + Message Pesan +# Playback Options (#5212) + Playback Options… Opsi pemutaran… + Playback Options Opsi pemutaran + Accelerated Dipercepat + Step by Step Langkah demi langkah + Pause For: Jeda selama: + Pause For Jeda selama + seconds detik + Create a single history state Buat satu status riwayat + Generator enabled Generator aktif + Move Up Pindah ke atas + Move Down Pindah ke bawah + Grid view Tampilan kisi + List view Tampilan daftar + On your computer Di komputer Anda + New file File baru + Learn Pelajari + No recent files Tidak ada file terbaru + Website Situs web + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Bingkai mendukung transformasi afin; pilih gambarnya untuk transformasi perspektif + A frame supports affine transforms; select its image to warp it Bingkai mendukung transformasi afin; pilih gambarnya untuk dilengkungkan + Convert to Frame… Konversi ke Bingkai… + Create a new custom frame Buat bingkai kustom baru + Create a new polygonal frame Buat bingkai poligon baru + Create a new triangular frame Buat bingkai segitiga baru + Fit Content to Frame Sesuaikan Konten ke Bingkai + Fit Frame to Content Sesuaikan Bingkai ke Konten + Frame coordinates must be finite and within 600000 pixels Koordinat bingkai harus berhingga dan berada dalam batas 600000 piksel + Import Image… Impor Gambar… + Invalid frame transform Transformasi bingkai tidak valid + Invalid nested file drop Penjatuhan berkas bersarang tidak valid + New Frame Bingkai Baru + Replace Image… Ganti Gambar… + The frame is locked Bingkai terkunci + The frame is unavailable Bingkai tidak tersedia + Frame Bingkai + +# Channels panel menu (#11451) + New Channel… Channel Baru… + Duplicate Channel Duplikat Channel + Delete Channel Hapus Channel + New Spot Channel… Spot Channel Baru… + Merge Spot Channel Gabungkan Spot Channel + Channel Options… Opsi Channel… + Split Channels Pisahkan Channel + Merge Channels… Gabungkan Channel… + Specify Channels: Tentukan Channel: + Not enough grayscale documents for this mode. Dokumen grayscale tidak cukup untuk mode ini. + Pick a different document for each channel. Pilih dokumen yang berbeda untuk setiap channel. + A picked document is no longer open. Salah satu dokumen yang dipilih sudah tidak terbuka. + A picked document is no longer a grayscale document of the same size. Salah satu dokumen yang dipilih bukan lagi dokumen grayscale dengan ukuran yang sama. +# Move tool options bar (#11381) + Align top edges Ratakan tepi atas + Align vertical centers Ratakan pusat vertikal + Align bottom edges Ratakan tepi bawah + Distribute vertically Distribusikan secara vertikal + Distribute horizontally Distribusikan secara horizontal + Show layer bounds on hover Tampilkan batas layer saat kursor di atasnya + Expand layer group on click Perluas grup layer saat diklik + Show hover bounds from Layers Tampilkan batas saat kursor di atas dari Layer + Saved Camera Raw settings to {file} Pengaturan Camera Raw disimpan ke {file} + Save the settings to each raw's sidecar without opening it Simpan pengaturan ke file sidecar setiap raw tanpa membukanya + No settings saved: these raws can't keep a sidecar here Tidak ada yang disimpan: raw ini tidak dapat memiliki file sidecar di sini + Some settings could not be saved; the raws stay open Beberapa pengaturan tidak dapat disimpan; raw tetap terbuka + Nothing to save: these raws are at their default settings Tidak ada yang perlu disimpan: raw ini memakai pengaturan default +# Artboard tool options bar (#11385) + Distribute Widths Distribusi lebar + Distribute Heights Distribusi tinggi + Auto-nest Layers Sarangkan layer secara otomatis + Auto-size Canvas Ubah ukuran kanvas otomatis + Keep Relative Position During Layer Reordering Pertahankan posisi relatif saat mengurutkan ulang layer + Shrinkwrap Canvas on Save Kecilkan kanvas sesuai konten saat menyimpan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 367c7267f5..1d671feece 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -186,6 +186,7 @@ Right Destra Open Recent Apri recenti Clear Recent Files Cancella file recenti + Clear Recent File List Cancella file recenti Remove from Recent Rimuovi dai recenti Remove {name} from Recent Rimuovi {name} dai recenti Close Chiudi @@ -194,6 +195,10 @@ Restore Ripristina Close All Chiudi tutto Close Others Chiudi altri + New Document… Nuovo documento… + Open Document… Apri… + Consolidate All to Here Raggruppa tutto qui + Move to New Window Sposta in una nuova finestra Save Salva Save As… Salva con nome… Save a Copy… Salva una copia… @@ -245,6 +250,7 @@ Create Smart Object after Loading Layers Crea oggetto avanzato dopo il caricamento dei livelli Reveal in Finder Mostra nel Finder Show in Explorer Mostra in Esplora file + Reveal in Explorer Mostra in Esplora file Show in Folder Mostra nella cartella File Info… Info file… Print… Stampa… @@ -333,6 +339,7 @@ Units & Rulers… Unità e righelli… Guides, Grid & Slices… Guide, griglia e sezioni… Plug-ins… Plug-in… + Plugins… Plug-in… Type… Testo… Enhanced Controls… Controlli avanzati… Camera Raw… Camera Raw… @@ -386,6 +393,8 @@ Match Color… Corrispondenza colore… Replace Color… Sostituisci colore… Equalize Equalizza + Equalize Selected Area Only Equalizza solo l'area selezionata + Equalize Entire Image Based On Selected Area Equalizza l'intera immagine in base all'area selezionata Auto Tone Tono automatico Auto Contrast Contrasto automatico Auto Color Colore automatico @@ -490,6 +499,8 @@ Smart Object Oggetto avanzato Convert to Smart Object Converti in oggetto avanzato Convert to Embedded Converti in incorporato + Embed Linked Incorpora collegato + Embed All Linked Incorpora tutti i collegati Convert to Linked… Converti in oggetto collegato… Edit Contents Modifica contenuto Replace Contents… Sostituisci contenuto… @@ -639,7 +650,6 @@ Deselect Deseleziona Reselect Riseleziona Inverse Inverti - Inverse Selection Inverti selezione Similar Simile Color Range… Intervallo colori… Focus Area… Area di messa a fuoco… @@ -745,6 +755,7 @@ Minimum… Minimo… Offset… Scostamento… Plug-ins Plug-in + Plugins Plug-in Proof Setup Configurazione prova Proof Colors Colori di prova Gamut Warning Avviso gamma @@ -753,12 +764,12 @@ Working Magenta Plate Lastra magenta di lavoro Working Yellow Plate Lastra gialla di lavoro Working Black Plate Lastra nera di lavoro - Working CMY Plate Lastra CMY di lavoro - Legacy Macintosh RGB RGB legacy Macintosh - Internet Standard RGB RGB standard Internet + Working CMY Plates Lastre CMY di lavoro + Legacy Macintosh RGB (Gamma 1.8) RGB legacy Macintosh (Gamma 1.8) + Internet Standard RGB (sRGB) RGB standard Internet (sRGB) Monitor RGB RGB monitor - Color Blindness — Protanopia-type Daltonismo — tipo protanopia - Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia + Color Blindness - Protanopia-type Daltonismo - tipo protanopia + Color Blindness - Deuteranopia-type Daltonismo - tipo deuteranopia Zoom In Ingrandisci Zoom Out Riduci Fit on Screen Adatta a schermo @@ -847,7 +858,6 @@ Consolidate All to Tabs Raggruppa tutto in schede Float in Window Flottante in finestra Float All in Windows Flottante tutto in finestre - New Window for Document Nuova finestra per documento Workspace Area di lavoro Essentials Essenziali Graphic and Web Grafica e web @@ -909,6 +919,12 @@ Timeline Timeline Tool Presets Predefiniti strumento Tools Strumenti + Contextual Task Bar Barra delle attività contestuale + Hide bar Nascondi barra + Reset bar position Ripristina posizione barra + Pin bar position Blocca posizione barra + Move bar Sposta barra + More options Altre opzioni About PhotoCraft Informazioni su PhotoCraft PhotoCraft Website Sito web PhotoCraft PhotoCraft on GitHub PhotoCraft su GitHub @@ -1073,7 +1089,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Clic per aggiungere un punto · trascina fuori, {key}-clic o Canc per rimuovere Click to apply, {key}-click to clear overrides, double-click for Style Options Clic per applicare, {key}-clic per rimuovere eccezioni, doppio clic per Opzioni stile Click to edit the gradient Clic per modificare la sfumatura - Click to restore the current colour Clic per ripristinare il colore corrente + Click to restore the current color Clic per ripristinare il colore corrente Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Clic per ingrandire · {key}-clic per ridurre · trascina dx/sx per zoom Click with the Note tool to add a note. Fai clic con lo strumento Nota per aggiungere una nota. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic: angolo · Trascina: morbido · Clic primo punto: chiudi · {key} termina · Esc annulla @@ -1158,7 +1174,7 @@ Darks Scuri Data Sets Set di dati Default Predefinito - Default colours (D) Colori predefiniti (D) + Default Foreground and Background Colors (D) Colori di primo piano e sfondo predefiniti (D) Default printer Stampante predefinita Default restored. Predefinito ripristinato. Define Definisci @@ -1440,7 +1456,7 @@ Opacity Jitter Variazione opacità Opacity: Opacità: Open a document to define variables. Apri un documento per definire variabili. - Open a document to select a colour range. Apri un documento per selezionare un intervallo colori. + Open a document to select a color range. Apri un documento per selezionare un intervallo colori. Open a document to use the timeline. Apri un documento per usare la timeline. Optical Ottico Optimized Ottimizzato @@ -1516,7 +1532,6 @@ Purity Purezza Quality Qualità Quick Actions Azioni rapide - Quick Export As PNG Esportazione rapida come PNG Radial Gradient Sfumatura radiale Radius Raggio Radius: Raggio: @@ -1658,7 +1673,7 @@ Subtract from Sample Sottrai da campione Subtract from selection ({key}) Sottrai da selezione ({key}) Suggestions: Suggerimenti: - Swap colours (X) Scambia colori (X) + Switch Foreground and Background Colors (X) Scambia colori di primo piano e sfondo (X) Swaps height and width Scambia altezza e larghezza Switch between free transform and warp modes Passa tra trasformazione libera e altera Switch theme Cambia tema @@ -1761,7 +1776,7 @@ Choke Strozza Classic Classico Click to add text Fai clic per aggiungere testo - Click to fill similar colours Fai clic per riempire colori simili + Click to fill similar colors Fai clic per riempire colori simili Clone Stamp Tool Strumento Timbro clone Color Burn Colore brucia Color Dodge Colore scherma @@ -1862,6 +1877,7 @@ Move Tool Strumento Sposta Could not use the move tool because the layer is locked. Impossibile usare lo strumento Sposta perché il livello è bloccato. Could not complete the Free Transform command because the layer is locked. Impossibile completare il comando Trasformazione libera perché il livello è bloccato. + Could not transform because the layer is locked. Impossibile trasformare perché il livello è bloccato. Multiply Moltiplica My Brushes I miei pennelli Neutrals Neutri @@ -1920,7 +1936,7 @@ Rectangular Marquee Tool Strumento Selezione rettangolare Reds Rossi Reflected Riflesso - Sample: foreground colour Campione: colore primo piano + Sample: foreground color Campione: colore primo piano Sampled Colors Colori campionati Satin Raso Save Pixel Aspect Ratio Salva proporzioni pixel @@ -1941,6 +1957,7 @@ Soften Ammorbidisci Source Sorgente Source: set Sorgente: impostata + Source: {name} Sorgente: {name} Sponge Tool Strumento Spugna Spot Healing Brush Tool Strumento Pennello correttivo al volo Spread Estendi @@ -1958,7 +1975,6 @@ Transfer Trasferimento Triangle Tool Strumento Triangolo Unsaved changes Modifiche non salvate - Uses the foreground and background colours. Usa i colori di primo piano e sfondo. Web Web Web Large Web grande Web Minimum Web minimo @@ -1995,6 +2011,7 @@ Allow scripts to connect Consenti connessione script Always Sempre Always create smart objects when placing Crea sempre oggetti avanzati all'inserimento + Skip transform when placing Salta la trasformazione all'inserimento Animated zoom Zoom animato Apply auto tone Applica tono automatico Ask Chiedi @@ -2017,6 +2034,11 @@ Show canvas dots Mostra punti del quadro Clip thumbnails to layer bounds Ritaglia le miniature ai limiti del livello Clip Thumbnails to Layer Bounds Ritaglia le miniature ai limiti del livello + No Thumbnails Nessuna miniatura + Small Thumbnails Miniature piccole + Medium Thumbnails Miniature medie + Large Thumbnails Miniature grandi + Clip Thumbnails to Document Bounds Ritaglia le miniature ai limiti del documento Canvas color Colore quadro Canvas custom color Colore personalizzato quadro Color picker Selettore colore @@ -2134,7 +2156,6 @@ Show only crosshair while painting Mostra solo mirino durante pittura Show slice numbers Mostra numeri sezioni Show tooltips Mostra tooltip - Enable animations Attiva animazioni Show highlight on rollover Mostra evidenziazione al passaggio del mouse Show bounding box when dragging layer Mostra riquadro selezione al trascinamento livello System title bar Barra del titolo di sistema @@ -2227,7 +2248,7 @@ Graphics Grafica Reset GPU Backend Reimposta backend GPU Applies at next launch. Si applica al prossimo avvio. - Click to sample the foreground colour · {key}-click for background Clic per campionare colore primo piano · {key}-clic per sfondo + Click to sample the foreground color · {key}-click for background Clic per campionare colore primo piano · {key}-clic per sfondo {key}-click to set the source {key}-clic per impostare sorgente {key} to subtract {key} per sottrarre Clone source {n} ({key}-click with Clone Stamp to set) Sorgente clone {n} ({key}-clic con Timbro clone per impostare) @@ -2288,7 +2309,7 @@ Gradient Options Opzioni sfumatura Align with layer Allinea con livello Reset Alignment Reimposta allineamento - Centre the gradient (offset 0, 0) Centra sfumatura (offset 0,0) + Center the gradient (offset 0, 0) Centra sfumatura (offset 0,0) Gradient Editor Editor sfumatura Gradient picker Selettore sfumatura Gradient stops Punti della sfumatura @@ -2322,7 +2343,7 @@ Alt-click to define a source point to clone from Alt-clic per definire un punto sorgente da clonare No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nessun tracciato da cui creare selezione: disegnane uno con Penna o selezionane uno nel pannello Tracciati. Cancel opening Annulla apertura - Cancelled opening {name} Apertura {name} annullata + Canceled opening {name} Apertura {name} annullata Damaged Photoshop document Documento Photoshop danneggiato The document “{name}” may be damaged. Il documento “{name}” potrebbe essere danneggiato. Some layers, pixels or masks could not be read. Do you want to continue opening it? Non è stato possibile leggere alcuni livelli, pixel o maschere. Continuare ad aprirlo? @@ -2331,7 +2352,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Il documento “{name}” ha un profilo colore incorporato che non corrisponde allo spazio di lavoro {mode} corrente. Working: {working} Di lavoro: {working} Developing {name} Sviluppo di {name} - Cancelled {label} {label} annullato + Canceled {label} {label} annullato Esc to cancel Esc per annullare Opened {name} Aperto: {name} Opening {name} Apertura {name} @@ -2339,7 +2360,7 @@ Saving {name} Salvataggio {name} Saving {percent} Salvataggio {percent} A save to {name} is still running; try again when it finishes Un salvataggio in {name} è ancora in corso; riprova quando termina - {label} is writing the file and can no longer be cancelled {label} sta scrivendo il file e non può più essere annullato + {label} is writing the file and can no longer be canceled {label} sta scrivendo il file e non può più essere annullato Opening {name}… Apertura {name}… Opening… Apertura… Reading… Lettura… @@ -2386,6 +2407,7 @@ Punctuation Punteggiatura Make Work Path Crea tracciato di lavoro Make Selection Crea selezione + Make Shape Crea forma Fill Path Riempi tracciato Stroke Path Traccia tracciato Delete Path Elimina tracciato @@ -2404,7 +2426,7 @@ Pixel Pixel Adjustment Regolazione Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Trascina (lazo) o clic su punti (poligonale) · {add} aggiungi · {sub} sottrai - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Trascina un riquadro ritaglio · trascina dentro per spostare · bordi ridimensionano ({ratio} proporzione, {centre} centro) · Spazio sposta durante disegno · frecce spostano · X scambia orientamento · {commit} conferma · Esc annulla + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Trascina un riquadro ritaglio · trascina dentro per spostare · bordi ridimensionano ({ratio} proporzione, {centre} centro) · Spazio sposta durante disegno · frecce spostano · X scambia orientamento · {commit} conferma · Esc annulla {mode} Color · {bits} bit Colore {mode} · {bits} bit Scrubby Zoom Zoom scrubby Auto Erase Cancellazione automatica @@ -2499,7 +2521,7 @@ cameraRaw Amount Quantità Discard Layers and Save a Copy Elimina i livelli e salva una copia Don't show again Non mostrare più Save failed: {error} Salvataggio non riuscito: {error} - Save cancelled Salvataggio annullato + Save canceled Salvataggio annullato Extend Estendi Structure: Struttura: Style {name} saved Stile {name} salvato @@ -2636,7 +2658,7 @@ fontWeight Italic Corsivo Rotate View Ruota vista Rotate View Tool Strumento Ruota vista Reset View Reimposta vista - Drag around the centre to rotate the view · Shift constrains to 15° Trascina intorno al centro per ruotare la vista · Maiusc vincola a 15° + Drag around the center to rotate the view · Shift constrains to 15° Trascina intorno al centro per ruotare la vista · Maiusc vincola a 15° Dual Axis Doppio asse Diagonal Diagonale Transform Symmetry Trasforma simmetria @@ -2651,8 +2673,8 @@ fontWeight Italic Corsivo All Layers No Adjustments Tutti i livelli senza regolazioni Current & Below No Adjustments Corrente e inferiori senza regolazioni Show Sampling Ring Mostra anello di campionamento - Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino - Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino + Out of gamut for printing: click for the nearest printable color Fuori gamma di stampa: fai clic per il colore stampabile più vicino + Not a web color: click for the nearest web color Non è un colore web: fai clic per il colore web più vicino Color to Alpha… Colore ad alfa… Transparency Threshold Soglia di trasparenza Opacity Threshold Soglia di opacità @@ -4245,11 +4267,13 @@ navigator Zoom Zoom Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo @plural {n} action|{n} actions {n} azione|{n} azioni Background (Default) Sfondo (predefinito) + Background (default) Sfondo (predefinito) + Transparent (default) Trasparente (predefinito) Include All Subfolders Includi tutte le sottocartelle Auto Add/Delete Aggiungi/elimina automaticamente Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Non attivare anche Immagine speculare nelle impostazioni di stampa: le due inversioni si annullano. Print Settings… Impostazioni di stampa… - Remember to turn off the printer's own colour management in Print Settings… Ricorda di disattivare la gestione del colore della stampante nelle impostazioni di stampa… + Remember to turn off the printer's own color management in Print Settings… Ricorda di disattivare la gestione del colore della stampante nelle impostazioni di stampa… Sent to {printer} Inviato a {printer} set in Print Settings… impostato nelle impostazioni di stampa… ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel @@ -4297,6 +4321,7 @@ navigator Zoom Zoom Automatically Check for Updates Cerca automaticamente aggiornamenti Paints from the History panel's source state Dipinge dallo stato di origine del pannello Cronologia Sets the source for the history brush Imposta l'origine del pennello storia + The History Brush source was deleted: set a new source in the History panel L'origine del pennello storia è stata eliminata: imposta una nuova origine nel pannello Cronologia Highlight Mode Metodo luci Highlight Color Colore luci Highlight Opacity Opacità luci @@ -4535,12 +4560,12 @@ shape Appearance Aspetto {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. Demosaic Demosaicizzazione - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Come viene completato il mosaico del filtro colore della fotocamera. Si applica all'apertura del file; l'anteprima mostra il metodo con cui è stato aperto. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Come viene completato il mosaico del filtro colore della fotocamera. Si applica all'apertura del file; l'anteprima mostra il metodo con cui è stato aperto. AHD (adaptive homogeneity) AHD (omogeneità adattiva) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: i bordi più puliti e le minori frange di colore, il più lento. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolazione con correzione del gradiente: nitida e veloce, con qualche frangia di colore nei dettagli fini. - The fastest and the softest, with colour fringing on fine detail. Il più veloce e il più morbido, con frange di colore nei dettagli fini. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: i bordi più puliti e le minori frange di colore, il più lento. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolazione con correzione del gradiente: nitida e veloce, con qualche frangia di colore nei dettagli fini. + The fastest and the softest, with color fringing on fine detail. Il più veloce e il più morbido, con frange di colore nei dettagli fini. “{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo? The name in the save dialog was “{name}”. Il nome nella finestra di salvataggio era “{name}”. Memory Usage Utilizzo memoria @@ -4609,6 +4634,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Predefiniti dimensione font Leading presets Predefiniti interlinea Tracking presets Predefiniti spaziatura + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Valore numerico non valido. È richiesto un numero intero compreso tra {min}% e {max}%. È stato inserito il valore precedente. Color Aware In base al colore Decontaminate Colors Decontamina colori @@ -4632,3 +4659,70 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Colore del punto Shapes can't use patterns yet Le forme non possono ancora usare pattern Edit Brush… Modifica pennello… + +# Action Stop message (#8386) + Message Messaggio +# Playback Options (#5212) + Playback Options… Opzioni di riproduzione… + Playback Options Opzioni di riproduzione + Accelerated Accelerata + Step by Step Passo dopo passo + Pause For: Pausa di: + Pause For Pausa di + seconds secondi + Create a single history state Crea un solo stato di storia + Generator enabled Generatore abilitato + This Layer: Questo livello: + Move Up Sposta su + Move Down Sposta giù + On your computer Sul computer + New file Nuovo file + Learn Impara + Website Sito web + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Una cornice supporta trasformazioni affini; selezionate la sua immagine per le trasformazioni prospettiche + A frame supports affine transforms; select its image to warp it Una cornice supporta trasformazioni affini; selezionate la sua immagine per alterarla + Convert to Frame… Converti in cornice… + Create a new custom frame Crea una nuova cornice personalizzata + Create a new polygonal frame Crea una nuova cornice poligonale + Create a new triangular frame Crea una nuova cornice triangolare + Fit Content to Frame Adatta contenuto alla cornice + Fit Frame to Content Adatta cornice al contenuto + Frame coordinates must be finite and within 600000 pixels Le coordinate della cornice devono essere finite e comprese entro 600000 pixel + Import Image… Importa immagine… + Invalid frame transform Trasformazione della cornice non valida + Invalid nested file drop Rilascio di file annidato non valido + New Frame Nuova cornice + Replace Image… Sostituisci immagine… + The frame is locked La cornice è bloccata + The frame is unavailable La cornice non è disponibile + +# Channels panel menu (#11451) + Channel Options… Opzioni canale… + Specify Channels: Specifica canali: + Not enough grayscale documents for this mode. Documenti in scala di grigio insufficienti per questo metodo. + Pick a different document for each channel. Scegli un documento diverso per ogni canale. + A picked document is no longer open. Uno dei documenti scelti non è più aperto. + A picked document is no longer a grayscale document of the same size. Uno dei documenti scelti non è più un documento in scala di grigio delle stesse dimensioni. +# Move tool options bar (#11381) + Align top edges Allinea i bordi superiori + Align vertical centers Allinea i centri verticali + Align bottom edges Allinea i bordi inferiori + Distribute vertically Distribuisci verticalmente + Distribute horizontally Distribuisci orizzontalmente + Show layer bounds on hover Mostra i limiti del livello al passaggio del mouse + Expand layer group on click Espandi il gruppo di livelli al clic + Show hover bounds from Layers Mostra i limiti al passaggio dal pannello Livelli + Saved Camera Raw settings to {file} Impostazioni Camera Raw salvate in {file} + Save the settings to each raw's sidecar without opening it Salva le impostazioni nel file sidecar di ogni raw senza aprirlo + No settings saved: these raws can't keep a sidecar here Nulla salvato: questi raw non possono avere un file sidecar qui + Some settings could not be saved; the raws stay open Alcune impostazioni non sono state salvate; i raw restano aperti + Nothing to save: these raws are at their default settings Niente da salvare: questi raw hanno le impostazioni predefinite +# Artboard tool options bar (#11385) + Distribute Widths Distribuisci larghezze + Distribute Heights Distribuisci altezze + Auto-nest Layers Annida livelli automaticamente + Auto-size Canvas Ridimensiona quadro automaticamente + Keep Relative Position During Layer Reordering Mantieni la posizione relativa durante il riordino dei livelli + Shrinkwrap Canvas on Save Riduci il quadro al contenuto al salvataggio diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2eee5145a2..c464084b38 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -195,6 +195,7 @@ Right 右 Open Recent 最近使ったファイルを開く Clear Recent Files 最近使ったファイルを消去 + Clear Recent File List 最近使ったファイルを消去 Remove from Recent 最近使ったファイルから削除 Remove {name} from Recent {name} を最近使ったファイルから削除 Close 閉じる @@ -203,6 +204,10 @@ Restore 元に戻す (縮小) Close All すべて閉じる Close Others ほかを閉じる + New Document… 新規ドキュメント… + Open Document… 開く… + Consolidate All to Here すべてをここに統合 + Move to New Window 新規ウィンドウに移動 Save 保存 Save As… 名前を付けて保存… Save a Copy… コピーを保存… @@ -255,6 +260,7 @@ Create Smart Object after Loading Layers レイヤーの読み込み後にスマートオブジェクトを作成 Reveal in Finder Finder で表示 Show in Explorer エクスプローラーで表示 + Reveal in Explorer エクスプローラーで表示 Show in Folder フォルダーで表示 File Info… ファイル情報… Print… プリント… @@ -343,6 +349,7 @@ Units & Rulers… 単位・定規… Guides, Grid & Slices… ガイド・グリッド・スライス… Plug-ins… プラグイン… + Plugins… プラグイン… Type… 文字… Enhanced Controls… 拡張コントロール… Camera Raw… Camera Raw… @@ -396,6 +403,8 @@ Match Color… カラーを一致… Replace Color… 色を置き換え… Equalize 平均化 + Equalize Selected Area Only 選択範囲のみ平均化 + Equalize Entire Image Based On Selected Area 選択範囲に基づいて画像全体を平均化 Auto Tone 自動トーン補正 Auto Contrast 自動コントラスト Auto Color 自動カラー補正 @@ -499,6 +508,8 @@ Smart Object スマートオブジェクト Convert to Smart Object スマートオブジェクトに変換 Convert to Embedded 埋め込みに変換 + Embed Linked リンクを埋め込む + Embed All Linked すべてのリンクを埋め込む Convert to Layers レイヤーに変換 Convert to Linked… リンクに変換… Edit Contents コンテンツを編集 @@ -572,7 +583,9 @@ Unite Shapes シェイプを結合 Subtract Front Shape 前面シェイプで型抜き Intersect Shape Areas シェイプの重なりを抽出 + Unite Shapes at Overlap シェイプの重なりを抽出 Exclude Overlapping Shapes シェイプの重なりを除外 + Subtract Shapes at Overlap シェイプの重なりを除外 Merge Shape Components シェイプの構成要素を結合 Path operations パスの操作 Align 整列 @@ -649,7 +662,6 @@ Deselect 選択を解除 Reselect 再選択 Inverse 選択範囲を反転 - Inverse Selection 選択範囲を反転 Similar 近似色を選択 Color Range… 色域指定… Focus Area… 焦点領域… @@ -756,6 +768,7 @@ Minimum… 最小値… Offset… オフセット… Plug-ins プラグイン + Plugins プラグイン Proof Setup 校正設定 Proof Colors 色の校正 Gamut Warning 色域外警告 @@ -764,12 +777,12 @@ Working Magenta Plate 作業用マゼンタ版 Working Yellow Plate 作業用イエロー版 Working Black Plate 作業用ブラック版 - Working CMY Plate 作業用 CMY 版 - Legacy Macintosh RGB 旧 Macintosh RGB - Internet Standard RGB インターネット標準 RGB + Working CMY Plates 作業用 CMY 版 + Legacy Macintosh RGB (Gamma 1.8) 旧 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) インターネット標準 RGB (sRGB) Monitor RGB モニター RGB - Color Blindness — Protanopia-type 色覚特性 — 1 型(P 型) - Color Blindness — Deuteranopia-type 色覚特性 — 2 型(D 型) + Color Blindness - Protanopia-type 色覚特性 - 1 型(P 型) + Color Blindness - Deuteranopia-type 色覚特性 - 2 型(D 型) Zoom In 拡大 Zoom Out 縮小 Fit on Screen 画面サイズに合わせる @@ -858,7 +871,7 @@ Consolidate All to Tabs すべてをタブに統合 Float in Window ウィンドウ内に浮動 Float All in Windows すべてをウィンドウ内に浮動 - New Window for Document ドキュメントの新規ウィンドウ + New Window 新規ウィンドウ Workspace ワークスペース Essentials 基本 Graphic and Web グラフィックと Web @@ -920,6 +933,12 @@ Timeline タイムライン Tool Presets ツールプリセット Tools ツール + Contextual Task Bar コンテキストタスクバー + Hide bar バーを隠す + Reset bar position バーの位置をリセット + Pin bar position バーの位置を固定 + Move bar バーを移動 + More options その他のオプション About PhotoCraft PhotoCraft について PhotoCraft Website PhotoCraft の Web サイト PhotoCraft on GitHub GitHub 上の PhotoCraft @@ -1084,7 +1103,7 @@ Click to add a point · drag off or {key}-click or Delete to remove クリックで点を追加 · 外へドラッグ、{key}+クリック、Delete で削除 Click to apply, {key}-click to clear overrides, double-click for Style Options クリックで適用、{key}+クリックで上書き解除、ダブルクリックでスタイルオプション Click to edit the gradient クリックしてグラデーションを編集 - Click to restore the current colour クリックして現在の色に戻す + Click to restore the current color クリックして現在の色に戻す Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out クリックで拡大 · {key}+クリックで縮小 · 右/左にドラッグで拡大/縮小 Click with the Note tool to add a note. 注釈ツールでクリックすると注釈を追加できます。 Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel クリック: 角 · ドラッグ: 滑らか · 最初の点をクリック: 閉じる · {key} 完了 · Esc キャンセル @@ -1167,7 +1186,7 @@ Darks 暗部 Data Sets データセット Default 既定 - Default colours (D) 初期カラーに戻す(D) + Default Foreground and Background Colors (D) 前景色と背景色を既定に戻す (D) Default printer 既定のプリンター Default restored. 既定に戻しました。 Define 定義 @@ -1449,7 +1468,7 @@ Opacity Jitter 不透明度のジッター Opacity: 不透明度: Open a document to define variables. 変数を定義するには、ドキュメントを開いてください。 - Open a document to select a colour range. 色域を指定するには、ドキュメントを開いてください。 + Open a document to select a color range. 色域を指定するには、ドキュメントを開いてください。 Open a document to use the timeline. タイムラインを使うには、ドキュメントを開いてください。 Optical オプティカル Optimized 最適化 @@ -1525,7 +1544,6 @@ Purity 純度 Quality 画質 Quick Actions クイックアクション - Quick Export As PNG PNG としてクイック書き出し Radial Gradient 円形グラデーション Radius 半径 Radius: 半径: @@ -1665,7 +1683,7 @@ Subtract from Sample サンプルから削除 Subtract from selection ({key}) 選択範囲から削除 ({key}) Suggestions: 候補: - Swap colours (X) 描画色と背景色を入れ替え(X) + Switch Foreground and Background Colors (X) 前景色と背景色を入れ替え (X) Swaps height and width 高さと幅を入れ替えます Switch between free transform and warp modes 自由変形とワープのモードを切り替え Switch theme テーマを切り替え @@ -1768,7 +1786,7 @@ Choke 境界のぼかし幅 Classic クラシック Click to add text クリックしてテキストを追加 - Click to fill similar colours クリックして近い色を塗りつぶし + Click to fill similar colors クリックして近い色を塗りつぶし Clone Stamp Tool コピースタンプツール Color Burn 焼き込みカラー Color Dodge 覆い焼きカラー @@ -1870,6 +1888,7 @@ Move Tool 移動ツール Could not use the move tool because the layer is locked. レイヤーがロックされているため、移動ツールを使用できません。 Could not complete the Free Transform command because the layer is locked. レイヤーがロックされているため、自由変形コマンドを完了できません。 + Could not transform because the layer is locked. レイヤーがロックされているため、変形できません。 Multiply 乗算 My Brushes マイブラシ Neutrals 中間色系 @@ -1928,7 +1947,7 @@ Rectangular Marquee Tool 長方形選択ツール Reds レッド系 Reflected 反射形 - Sample: foreground colour サンプル: 描画色 + Sample: foreground color サンプル: 描画色 Sampled Colors サンプルしたカラー Satin サテン Save Pixel Aspect Ratio ピクセル縦横比を保存 @@ -1949,6 +1968,7 @@ Soften ぼかし Source ソース Source: set ソース: 設定済み + Source: {name} ソース: {name} Sponge Tool スポンジツール Spot Healing Brush Tool スポット修復ブラシツール Spread スプレッド @@ -1966,7 +1986,6 @@ Transfer 伝達 Triangle Tool 三角形ツール Unsaved changes 未保存の変更 - Uses the foreground and background colours. 描画色と背景色を使用します。 Web Web Web Large Web (大) Web Minimum Web (最小) @@ -2003,6 +2022,7 @@ Allow scripts to connect スクリプトからの接続を許可 Always 常に Always create smart objects when placing 配置時に常にスマートオブジェクトを作成 + Skip transform when placing 配置時に変形をスキップ Animated zoom アニメーションズーム Apply auto tone 自動トーン補正を適用 Ask 確認する @@ -2025,6 +2045,11 @@ Show canvas dots カンバスのドットを表示 Clip thumbnails to layer bounds サムネールをレイヤーの境界でクリップ Clip Thumbnails to Layer Bounds サムネールをレイヤーの境界でクリップ + No Thumbnails サムネールなし + Small Thumbnails 小さいサムネール + Medium Thumbnails 中サイズのサムネール + Large Thumbnails 大きいサムネール + Clip Thumbnails to Document Bounds サムネールをドキュメントの境界でクリップ Canvas color カンバスの色 Canvas custom color カンバスのカスタム色 Color picker カラーピッカー @@ -2142,7 +2167,6 @@ Show only crosshair while painting 描画中は十字線のみ表示 Show slice numbers スライス番号を表示 Show tooltips ツールチップを表示 - Enable animations アニメーションを有効にする Show highlight on rollover ロールオーバー時にハイライトを表示 Show bounding box when dragging layer レイヤーのドラッグ中にバウンディングボックスを表示 System title bar システムのタイトルバー @@ -2235,7 +2259,7 @@ Graphics グラフィックス Reset GPU Backend GPU バックエンドをリセット Applies at next launch. 次回の起動時に適用されます。 - Click to sample the foreground colour · {key}-click for background クリックで描画色を取得 · {key}+クリックで背景色を取得 + Click to sample the foreground color · {key}-click for background クリックで描画色を取得 · {key}+クリックで背景色を取得 {key}-click to set the source {key}+クリックでソースを設定 {key} to subtract {key} で削除 Clone source {n} ({key}-click with Clone Stamp to set) コピーソース {n} ({key}+クリックでコピースタンプのソースを設定) @@ -2294,7 +2318,7 @@ Gradient Options グラデーションオプション Align with layer レイヤーに揃える Reset Alignment 位置をリセット - Centre the gradient (offset 0, 0) グラデーションを中央に配置(オフセット 0, 0) + Center the gradient (offset 0, 0) グラデーションを中央に配置(オフセット 0, 0) Gradient Editor グラデーションエディター Gradient picker グラデーションピッカー Save As Preset プリセットとして保存 @@ -2326,7 +2350,7 @@ Vulkan Vulkan No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 選択範囲を作成するパスがありません。ペンツールで描くか、パスパネルでパスを選択してください。 Cancel opening 開くのをキャンセル - Cancelled opening {name} {name} を開くのをキャンセルしました + Canceled opening {name} {name} を開くのをキャンセルしました Damaged Photoshop document 破損した Photoshop ドキュメント The document “{name}” may be damaged. ドキュメント「{name}」は破損している可能性があります。 Some layers, pixels or masks could not be read. Do you want to continue opening it? 一部のレイヤー、ピクセル、またはマスクを読み込めませんでした。開く処理を続行しますか? @@ -2336,7 +2360,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. ドキュメント「{name}」の埋め込みカラープロファイルは、現在の {mode} 作業用スペースと一致しません。 Working: {working} 作業用スペース: {working} Developing {name} {name} を現像中 - Cancelled {label} {label} をキャンセルしました + Canceled {label} {label} をキャンセルしました Esc to cancel Esc キーでキャンセル Opened {name} {name} を開きました Opening {name} {name} を開いています @@ -2344,7 +2368,7 @@ Saving {name} {name} を保存しています Saving {percent} 保存中 {percent} A save to {name} is still running; try again when it finishes {name} への保存がまだ実行中です。完了してからもう一度お試しください - {label} is writing the file and can no longer be cancelled {label} はファイルを書き込み中のため、キャンセルできません + {label} is writing the file and can no longer be canceled {label} はファイルを書き込み中のため、キャンセルできません Opening {name}… {name} を開いています… Opening… 開いています… Reading… 読み込み中… @@ -2422,7 +2446,7 @@ cameraRaw Color カラー Discard Layers and Save a Copy レイヤーを破棄してコピーを保存 Don't show again 次回から表示しない Save failed: {error} 保存に失敗しました: {error} - Save cancelled 保存をキャンセルしました + Save canceled 保存をキャンセルしました Magnetic Lasso Tool マグネット選択ツール Frequency 頻度 Use tablet pressure to change pen width タブレットの筆圧を使用してペンの幅を変更 @@ -2551,7 +2575,7 @@ fontWeight Italic 斜体 Rotate View 表示を回転 Rotate View Tool 表示回転ツール Reset View 表示をリセット - Drag around the centre to rotate the view · Shift constrains to 15° 中心の周りをドラッグして表示を回転 · Shift で 15° 単位 + Drag around the center to rotate the view · Shift constrains to 15° 中心の周りをドラッグして表示を回転 · Shift で 15° 単位 Dual Axis 二軸 Diagonal 斜め Transform Symmetry 対称軸を変形 @@ -2566,8 +2590,8 @@ fontWeight Italic 斜体 All Layers No Adjustments すべてのレイヤー (色調補正なし) Current & Below No Adjustments 現在のレイヤーとその下 (色調補正なし) Show Sampling Ring サンプリングリングを表示 - Out of gamut for printing: click for the nearest printable colour 印刷の色域外: クリックすると最も近い印刷可能な色になります - Not a web colour: click for the nearest web colour Web カラーではありません: クリックすると最も近い Web カラーになります + Out of gamut for printing: click for the nearest printable color 印刷の色域外: クリックすると最も近い印刷可能な色になります + Not a web color: click for the nearest web color Web カラーではありません: クリックすると最も近い Web カラーになります Accented Edges 輪郭の強調 Adjust Interval For Loops ループ用に間隔を調整 Adjustment Properties 色調補正のプロパティ @@ -3024,6 +3048,7 @@ fontWeight Italic 斜体 Magnetic Lasso マグネット投げ縄 Make 作成 Make Selection 選択範囲を作成 + Make Shape シェイプを作成 Make Work Path 作業用パスを作成 Match Color カラーを一致 Mezzotint メゾティント @@ -3409,7 +3434,7 @@ fontWeight Italic 斜体 Document Size ドキュメントサイズ Dodge/Burn 覆い焼き/焼き込み Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract ドラッグ (投げ縄) またはクリックでポイント指定 (多角形) · {add} 追加 · {sub} 除外 - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 切り抜きボックスをドラッグ · 内側をドラッグで移動 · 縁でサイズ変更 ({ratio} で比率、{centre} で中心) · 描画中は Space で移動 · 矢印キーで微調整 · X で縦横を切り替え · {commit} で確定 · Esc で取り消し + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 切り抜きボックスをドラッグ · 内側をドラッグで移動 · 縁でサイズ変更 ({ratio} で比率、{centre} で中心) · 描画中は Space で移動 · 矢印キーで微調整 · X で縦横を切り替え · {commit} で確定 · Esc で取り消し Drag to move the layer ドラッグしてレイヤーを移動 Duplicated selection name 複製した選択範囲の名前 Dynamics ダイナミクス @@ -3900,6 +3925,8 @@ curves Smooth スムーズ Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 Background (Default) 背景(既定) + Background (default) 背景(既定) + Transparent (default) 透明(既定) Include All Subfolders すべてのサブフォルダーを含める Forward Warp 前方ワープ Twirl Clockwise 時計回りに渦巻き @@ -3957,7 +3984,7 @@ curves Smooth スムーズ Auto Add/Delete 自動追加・削除 Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 印刷設定の「鏡像」も有効にしないでください。2回反転すると元に戻ります。 Print Settings… 印刷設定… - Remember to turn off the printer's own colour management in Print Settings… 印刷設定でプリンター独自のカラーマネジメントを無効にしてください… + Remember to turn off the printer's own color management in Print Settings… 印刷設定でプリンター独自のカラーマネジメントを無効にしてください… Sent to {printer} {printer} に送信しました set in Print Settings… 印刷設定で指定… Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 @@ -4009,6 +4036,7 @@ curves Smooth スムーズ Automatically Check for Updates アップデートを自動的に確認 Paints from the History panel's source state ヒストリーパネルのソース状態からペイントします Sets the source for the history brush ヒストリーブラシのソースを設定します + The History Brush source was deleted: set a new source in the History panel ヒストリーブラシのソースが削除されました: ヒストリーパネルで新しいソースを設定してください Highlight Mode ハイライトモード Highlight Color ハイライトカラー Highlight Opacity ハイライト不透明度 @@ -4091,12 +4119,12 @@ curves Smooth スムーズ Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる Demosaic デモザイク - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. カメラのカラーフィルター配列をどのように補間するか。ファイルを開くときに適用されます。プレビューは開いたときの方式で表示されます。 + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. カメラのカラーフィルター配列をどのように補間するか。ファイルを開くときに適用されます。プレビューは開いたときの方式で表示されます。 AHD (adaptive homogeneity) AHD(適応的均質性) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:エッジが最もきれいで色にじみが最も少ないが、最も遅い。 - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 勾配補正補間:シャープで高速だが、細部に多少の色にじみが出る。 - The fastest and the softest, with colour fringing on fine detail. 最も高速で最も柔らかく、細部に色にじみが出る。 + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks:エッジが最もきれいで色にじみが最も少ないが、最も遅い。 + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. 勾配補正補間:シャープで高速だが、細部に多少の色にじみが出る。 + The fastest and the softest, with color fringing on fine detail. 最も高速で最も柔らかく、細部に色にじみが出る。 “{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか? The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。 Previous Brush 前のブラシ @@ -4171,6 +4199,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets フォントサイズのプリセット Leading presets 行送りのプリセット Tracking presets トラッキングのプリセット + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 無効な数値です。{min}% から {max}% までの整数が必要です。以前の値が挿入されました。 Clear Selection 選択範囲をクリア Color Aware カラー認識 @@ -4197,3 +4227,74 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color 分岐点のカラー Shapes can't use patterns yet シェイプではまだパターンを使用できません Edit Brush… ブラシを編集… + +# Action Stop message (#8386) + Message メッセージ +# Playback Options (#5212) + Playback Options… 再生オプション… + Playback Options 再生オプション + Accelerated 高速 + Step by Step ステップ単位 + Pause For: 一時停止: + Pause For 一時停止 + seconds 秒 + Create a single history state 単一のヒストリー状態を作成 + Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動 + Generator enabled 自動生成を有効にする + This Layer: 現在のレイヤー: + Move Up 上へ移動 + Move Down 下へ移動 + Grid view グリッド表示 + List view リスト表示 + On your computer このコンピューター上 + New file 新規ファイル + Learn 学ぶ + No recent files 最近使ったファイルはありません + Website Web サイト + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms フレームはアフィン変換に対応しています。遠近法変換を行うにはフレーム内の画像を選択してください + A frame supports affine transforms; select its image to warp it フレームはアフィン変換に対応しています。ワープするにはフレーム内の画像を選択してください + Convert to Frame… フレームに変換… + Create a new custom frame 新しいカスタムフレームを作成 + Create a new polygonal frame 新しい多角形フレームを作成 + Create a new triangular frame 新しい三角形フレームを作成 + Fit Content to Frame 内容をフレームに合わせる + Fit Frame to Content フレームを内容に合わせる + Frame coordinates must be finite and within 600000 pixels フレームの座標は有限値で、600000 ピクセル以内である必要があります + Import Image… 画像を読み込み… + Invalid frame transform フレームの変形が無効です + Invalid nested file drop 入れ子のファイルドロップは無効です + New Frame 新規フレーム + Replace Image… 画像を置き換え… + The frame is locked フレームはロックされています + The frame is unavailable フレームを利用できません + +# Channels panel menu (#11451) + Channel Options… チャンネルオプション… + Specify Channels: チャンネルを指定: + Not enough grayscale documents for this mode. このモードに必要なグレースケールのドキュメントが足りません。 + Pick a different document for each channel. チャンネルごとに別のドキュメントを選択してください。 + A picked document is no longer open. 選択したドキュメントの一つが開かれていません。 + A picked document is no longer a grayscale document of the same size. 選択したドキュメントの一つが、同じサイズのグレースケールのドキュメントではなくなりました。 +# Move tool options bar (#11381) + Align top edges 上端を揃える + Align vertical centers 垂直方向中央を揃える + Align bottom edges 下端を揃える + Distribute vertically 垂直方向に分布 + Distribute horizontally 水平方向に分布 + Show layer bounds on hover ホバー時にレイヤーの境界を表示 + Expand layer group on click クリックでレイヤーグループを展開 + Show hover bounds from Layers レイヤーパネルからのホバー境界を表示 + Saved Camera Raw settings to {file} Camera Raw の設定を {file} に保存しました + Save the settings to each raw's sidecar without opening it 各 Raw のサイドカーファイルに設定を保存し、開かずに閉じます + No settings saved: these raws can't keep a sidecar here 保存されませんでした:これらの Raw にはここでサイドカーファイルを作成できません + Some settings could not be saved; the raws stay open 一部の設定を保存できませんでした。Raw は開いたままです + Nothing to save: these raws are at their default settings 保存するものはありません:これらの Raw は初期設定のままです +# Artboard tool options bar (#11385) + Distribute Widths 幅を分布 + Distribute Heights 高さを分布 + Auto-nest Layers レイヤーを自動でネスト + Auto-size Canvas カンバスサイズを自動調整 + Keep Relative Position During Layer Reordering レイヤーの並べ替え中も相対位置を保持 + Shrinkwrap Canvas on Save 保存時にカンバスを内容に合わせて縮小 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv new file mode 100644 index 0000000000..02e5270e11 --- /dev/null +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -0,0 +1,4719 @@ +# Georgian (ka) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); Georgian has one form: +# nouns stay singular after a numeral (`შრე: {n}`) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Georgian computing vocabulary (შრე, ნიღაბი, მონიშნული, ფუნჯი ...). No proprietary localisation +# resource, string table or product UI was consulted or copied; add entries the same way. +# Commands are verbal nouns (შენახვა, გახსნა); Georgian has no letter case, so nothing is +# capitalised (never Mtavruli). Product and technology names (PhotoCraft, OpenType, CMYK, Lab ...) +# stay as they are; the trailing "…" of dialog commands is kept. +# Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. + + Rows სტრიქონები + Number რაოდენობა + Margin ველები + Top: ზემოთ: + Left: მარცხნივ: + Bottom: ქვემოთ: + Right: მარჯვნივ: + Target: სამიზნე: + Selected Artboards მონიშნული სამუშაო დაფები + Center Columns სვეტების ცენტრირება + Clear Existing Guides არსებული დამხმარე ხაზების წაშლა + Invalid guide layout დამხმარე ხაზების არასწორი განლაგება + Leave empty for automatic size ავტომატური ზომისთვის დატოვეთ ცარიელი + 5 Columns, 3 Rows 5 სვეტი, 3 სტრიქონი + 8 Columns 8 სვეტი + 12 Columns 12 სვეტი + + File ფაილი + Edit რედაქტირება + Image გამოსახულება + Layer შრე + Type ტექსტი + Select მონიშვნა + Selected მონიშნული + Scope მოქმედების არე + Top Left ზედა მარცხენა + Top Right ზედა მარჯვენა + Bottom Left ქვედა მარცხენა + Bottom Right ქვედა მარჯვენა + Filter ფილტრი + View ხედი + Window ფანჯარა + Help დახმარება + (coming soon) (მალე) + New… ახალი… + Open… გახსნა… + Open As… გახსნა, როგორც… + Open EXR Parts… EXR ნაწილების გახსნა… + Add beauty precomp from the AOVs საბაზო კომპოზიციის (beauty) დამატება AOV-ებიდან + Both ორივე + Left მარცხნივ + Right მარჯვნივ + Open Recent ბოლოს გახსნილი ფაილების გახსნა + Clear Recent Files ბოლოს გახსნილი ფაილების სიის გასუფთავება + Remove from Recent ბოლოს გახსნილებიდან ამოშლა + Remove {name} from Recent {name}-ის ამოშლა ბოლოს გახსნილებიდან + Close დახურვა + Minimize ჩაკეცვა + Maximize გაშლა + Restore აღდგენა + Close All ყველას დახურვა + Close Others სხვების დახურვა + Save შენახვა + Save As… შენახვა როგორც… + Save a Copy… ასლის შენახვა… + Revert წინა ვერსიაზე დაბრუნება + Export ექსპორტი + Quick Export as PNG სწრაფი ექსპორტი PNG ფორმატში + Export As… ექსპორტი სხვა ფორმატით… + Export Preferences… ექსპორტის პარამეტრები… + Save for Web (Legacy)… ვებისთვის შენახვა (მოძველებული)… + Artboards to Files… სამუშაო დაფები ფაილებად… + Artboards to PDF… სამუშაო დაფები PDF-ად… + Layers to Files… შრეები ფაილებად… + Layer Comps to Files… შრეების კომპოზიციები ფაილებად… + Color Lookup Tables… ფერების ცხრილები (Color Lookup)… + Data Sets as Files… მონაცემთა ნაკრებები ფაილებად… + Paths to Illustrator… ტრაექტორიები Illustrator-ში… + Render Video… ვიდეოს რენდერი… + Generate გენერაცია + Image Assets გამოსახულების რესურსები + Place Embedded… ჩადგმულის განთავსება… + Place Linked… დაკავშირებულის განთავსება… + Package… შეფუთვა… + Automate ავტომატიზაცია + Batch… პაკეტური დამუშავება… + Create Droplet… დროპლეტის შექმნა… + Crop and Straighten Photos ფოტოების ჩამოჭრა და გასწორება + Contact Sheet II… საკონტაქტო ფურცელი II… + Conditional Mode Change… რეჟიმის პირობითი შეცვლა… + Fit Image… გამოსახულების ჩატევა… + Lens Correction… ობიექტივის კორექცია… + Merge to HDR Pro… HDR Pro-ში შერწყმა… + Photomerge… Photomerge… + Scripts სკრიპტები + Image Processor… გამოსახულებების დამმუშავებელი… + Delete All Empty Layers ყველა ცარიელი შრის წაშლა + Flatten All Layer Effects შრეების ყველა ეფექტის რასტრირება + Flatten All Masks ყველა ნიღბის გამოყენება + Script Events Manager… სკრიპტის მოვლენების მართვა… + Import იმპორტი + Browse… არჩევა… + Reveal in Finder Finder-ში ჩვენება + Show in Explorer Explorer-ში ჩვენება + Show in Folder საქაღალდეში ჩვენება + File Info… ფაილის ინფორმაცია… + Print… დაბეჭდვა… + Print One Copy ერთი ასლის დაბეჭდვა + Exit გასვლა + Undo დაბრუნება + Redo გამეორება + Toggle Last State ბოლო მდგომარეობის გადართვა + Fade… მილევა… + Cut ამოჭრა + Copy კოპირება + Copy Merged შერწყმულის კოპირება + Paste ჩასმა + Paste Special სპეციალური ჩასმა + Paste in Place ადგილზე ჩასმა + Paste Into შიგნით ჩასმა + Paste Outside გარეთ ჩასმა + Paste Lorem Ipsum Lorem Ipsum-ის ჩასმა + Clear გასუფთავება + Search… ძიება… + Check Spelling… მართლწერის შემოწმება… + Find and Replace Text… ტექსტის ძებნა და ჩანაცვლება… + Fill… შევსება… + Stroke… კონტური… + Content-Aware Fill… შიგთავსის გათვალისწინებით შევსება… + Delete and Fill Selection მონიშნულის წაშლა და შევსება + Content-Aware Scale შიგთავსის გათვალისწინებით მასშტაბირება + Puppet Warp მარიონეტული დეფორმაცია + Perspective Warp პერსპექტიული დეფორმაცია + Free Transform თავისუფალი გარდაქმნა + Transform გარდაქმნა + Again ხელახლა + Scale მასშტაბირება + Rotate შემობრუნება + Skew დახრა + Distort დამახინჯება + Perspective პერსპექტივა + Warp დეფორმაცია + Flip Horizontal ჰორიზონტალურად არეკვლა + Flip Vertical ვერტიკალურად არეკვლა + Rotate 180° 180°-ით შემობრუნება + Rotate 90° Clockwise 90°-ით შემობრუნება საათის ისრის მიმართულებით + Rotate 90° Counter Clockwise 90°-ით შემობრუნება საათის ისრის საწინააღმდეგოდ + 180° 180° + 90° Clockwise 90° საათის ისრის მიმართულებით + 90° Counter Clockwise 90° საათის ისრის საწინააღმდეგოდ + Arbitrary… ნებისმიერი… + Auto-Align Layers… შრეების ავტოსწორება… + Auto-Blend Layers… შრეების ავტოშერევა… + Define Brush Preset… ფუნჯის პრესეტის განსაზღვრა… + Define Pattern… პატერნის განსაზღვრა… + Define Custom Shape… მომხმარებლის ფიგურის განსაზღვრა… + Purge გაწმენდა + Clipboard გაცვლის ბუფერი + Histories ისტორიები + Video Cache ვიდეოს ქეში + All ყველა + Presets პრესეტები + Preset Manager… პრესეტების მართვა… + Migrate Presets პრესეტების მიგრაცია + Export/Import Presets… პრესეტების ექსპორტი/იმპორტი… + Color Settings… ფერის პარამეტრები… + Assign Profile… პროფილის მინიჭება… + Convert to Profile… პროფილში გარდაქმნა… + Keyboard Shortcuts… კლავიატურის მალსახმობები… + Menus… მენიუები… + Toolbar… ხელსაწყოთა ზოლი… + Preferences პარამეტრები + General… ზოგადი… + Interface… ინტერფეისი… + Workspace… სამუშაო სივრცე… + Tools… ხელსაწყოები… + History Log… ისტორიის ჟურნალი… + File Handling… ფაილებთან მუშაობა… + Export… ექსპორტი… + Performance… წარმადობა… + Scratch Disks… დროებითი დისკები… + Cursors… კურსორები… + Transparency & Gamut… გამჭვირვალობა და ფერთა დიაპაზონი… + Units & Rulers… ერთეულები და სახაზავები… + Guides, Grid & Slices… დამხმარე ხაზები, ბადე და ნაჭრები… + Plug-ins… პლაგინები… + Type… ტექსტი… + Enhanced Controls… გაუმჯობესებული მართვის ელემენტები… + Camera Raw… Camera Raw… + Raw Defaults… Raw-ის ნაგულისხმევი პარამეტრები… + Integrations… ინტეგრაციები… + AI Integrations… AI ინტეგრაციები… + Install Plug-in… პლაგინის დაყენება… + Language Options ენის პარამეტრები + Settings… პარამეტრები… + Mode რეჟიმი + Bitmap ბიტური + Grayscale ნაცრისფერი ტონები + Duotone ორტონიანი + Indexed Color ინდექსირებული ფერი + RGB Color RGB ფერი + CMYK Color CMYK ფერი + Lab Color Lab ფერი + Multichannel მრავალარხიანი + 8 Bits/Channel 8 ბიტი/არხი + 16 Bits/Channel 16 ბიტი/არხი + 32 Bits/Channel 32 ბიტი/არხი + Color Table… ფერების ცხრილი… + Bitmap… ბიტური… + Duotone… ორტონიანი… + Indexed Color… ინდექსირებული ფერი… + Adjustments კორექცია + Brightness/Contrast… სიკაშკაშე/კონტრასტი… + Levels… დონეები… + Curves… მრუდები… + Exposure… ექსპოზიცია… + Vibrance… სიხალასე… + Hue/Saturation… ფერის ტონი/გაჯერება… + Color Balance… ფერთა ბალანსი… + Black & White… შავ-თეთრი… + Photo Filter… ფოტოფილტრი… + Channel Mixer… არხების მიქშერი… + Color Lookup… ფერების ცხრილი (Color Lookup)… + Invert ინვერსია + Posterize… პოსტერიზაცია… + Threshold… ზღვარი… + Gradient Map… გრადიენტული რუკა… + Selective Color… შერჩევითი ფერი… + Shadows/Highlights… ჩრდილები/ღია ტონები… + HDR Toning… HDR ტონირება… + Desaturate გაუფერულება + Match Color… ფერის შეთანხმება… + Replace Color… ფერის ჩანაცვლება… + Equalize გათანაბრება + Auto Tone ავტოტონი + Auto Contrast ავტოკონტრასტი + Auto Color ავტოფერი + Image Size… გამოსახულების ზომა… + Canvas Size… ტილოს ზომა… + Image Rotation გამოსახულების შემობრუნება + Flip Canvas Horizontal ტილოს ჰორიზონტალურად არეკვლა + Flip Canvas Vertical ტილოს ვერტიკალურად არეკვლა + Crop ჩამოჭრა + Trim… შეკვეცა… + Reveal All ყველაფრის გამოჩენა + Duplicate… დუბლირება… + Apply Image… გამოსახულების გამოყენება… + Calculations… გამოთვლები… + Variables ცვლადები + Define… განსაზღვრა… + Data Sets… მონაცემთა ნაკრებები… + Apply Data Set… მონაცემთა ნაკრების გამოყენება… + Variable Data Sets… ცვლადი მონაცემების ნაკრებები… + Trap… ტრეპინგი… + Analysis ანალიზი + Set Measurement Scale… საზომი მასშტაბის დაყენება… + Select Data Points… მონაცემთა წერტილების არჩევა… + Record Measurements გაზომვების ჩაწერა + Ruler Tool სახაზავის ხელსაწყო + Count Tool დათვლის ხელსაწყო + Place Scale Marker… მასშტაბის მარკერის განთავსება… + Custom… მომხმარებლის… + New ახალი + Layer… შრე… + Group… ჯგუფი… + Group from Layers… ჯგუფი შრეებიდან… + Artboard… სამუშაო დაფა… + Artboard from Group… სამუშაო დაფა ჯგუფიდან… + Artboard from Layers… სამუშაო დაფა შრეებიდან… + Layer via Copy შრე კოპირებით + Layer via Cut შრე ამოჭრით + Layer From Background… შრე ფონიდან… + Layer from Background… შრე ფონიდან… + Background From Layer ფონი შრიდან + New Shape Layer ახალი ფიგურის შრე + New Type Layer ახალი ტექსტის შრე + New Fill Layer ახალი შევსების შრე + New Adjustment Layer ახალი მაკორექტირებელი შრე + New Layer Based Slice ახალი შრეზე დაფუძნებული ნაჭერი + New Smart Object via Copy ახალი ჭკვიანი ობიექტი კოპირებით + New Blank Video Layer ახალი ცარიელი ვიდეოშრე + New Video Layer from File… ახალი ვიდეოშრე ფაილიდან… + Duplicate Layer… შრის დუბლირება… + Delete Layer შრის წაშლა + Delete წაშლა + Rename Layer შრის სახელის შეცვლა + Create Layer შრის შექმნა + Layer Style შრის სტილი + Blending Options… შერევის პარამეტრები… + Bevel & Emboss… ფასკა და რელიეფი… + Inner Shadow… შიდა ჩრდილი… + Inner Glow… შიდა ნათება… + Satin… ატლასი… + Color Overlay… ფერის გადაფარვა… + Gradient Overlay… გრადიენტის გადაფარვა… + Pattern Overlay… პატერნის გადაფარვა… + Outer Glow… გარე ნათება… + Drop Shadow… ჩრდილი… + Copy Layer Style შრის სტილის კოპირება + Paste Layer Style შრის სტილის ჩასმა + Clear Layer Style შრის სტილის გასუფთავება + Global Light… გლობალური განათება… + Hide All Effects ყველა ეფექტის დამალვა + Scale Effects… ეფექტების მასშტაბირება… + Smart Filter ჭკვიანი ფილტრი + Convert for Smart Filters ჭკვიანი ფილტრებისთვის გარდაქმნა + Clear Smart Filters ჭკვიანი ფილტრების გასუფთავება + Disable Smart Filters ჭკვიანი ფილტრების გამორთვა + Delete Filter Mask ფილტრის ნიღბის წაშლა + Disable Filter Mask ფილტრის ნიღბის გამორთვა + Solid Color… მყარი ფერი… + Gradient… გრადიენტი… + Pattern… პატერნი… + Layer Mask შრის ნიღაბი + Vector Mask ვექტორული ნიღაბი + Reveal Selection მონიშნულის გამოჩენა + Hide Selection მონიშნულის დამალვა + Hide All ყველას დამალვა + From Transparency გამჭვირვალობიდან + Delete Vector Mask ვექტორული ნიღბის წაშლა + Enable Layer Mask შრის ნიღბის ჩართვა + Disable Layer Mask შრის ნიღბის გამორთვა + Enable Vector Mask ვექტორული ნიღბის ჩართვა + Link Layer Mask შრის ნიღბის დაკავშირება + Unlink Layer Mask შრის ნიღბის კავშირის გაწყვეტა + Link Vector Mask ვექტორული ნიღბის დაკავშირება + Add Vector Mask ვექტორული ნიღბის დამატება + Vector Mask from Current Path ვექტორული ნიღაბი მიმდინარე ტრაექტორიიდან + Create Clipping Mask მომჭრელი ნიღბის შექმნა + Release Clipping Mask მომჭრელი ნიღბის მოხსნა + Smart Objects ჭკვიანი ობიექტები + Smart Object ჭკვიანი ობიექტი + Convert to Smart Object ჭკვიან ობიექტად გარდაქმნა + Convert to Embedded ჩადგმულად გარდაქმნა + Convert to Layers შრეებად გარდაქმნა + Convert to Linked… დაკავშირებულად გარდაქმნა… + Edit Contents შიგთავსის რედაქტირება + Replace Contents… შიგთავსის ჩანაცვლება… + Relink to File… ფაილთან ხელახლა დაკავშირება… + Export Contents… შიგთავსის ექსპორტი… + Update Modified Content შეცვლილი შიგთავსის განახლება + Update All Modified Content ყველა შეცვლილი შიგთავსის განახლება + Stack Mode დასტის რეჟიმი + Entropy ენტროპია + Kurtosis ექსცესი + Maximum მაქსიმუმი + Mean საშუალო + Median მედიანა + Minimum მინიმუმი + Range დიაპაზონი + Skewness ასიმეტრია + Standard Deviation სტანდარტული გადახრა + Summation ჯამი + Variance დისპერსია + Video Layers ვიდეოშრეები + Interpret Footage… მასალის ინტერპრეტაცია… + Replace Footage… მასალის ჩანაცვლება… + Reload Frame კადრის ხელახლა ჩატვირთვა + Show Altered Video შეცვლილი ვიდეოს ჩვენება + Restore Frame კადრის აღდგენა + Restore All Frames ყველა კადრის აღდგენა + Insert Blank Frame ცარიელი კადრის ჩასმა + Delete Frame კადრის წაშლა + Duplicate Frame კადრის დუბლირება + Frame from Layers კადრი შრეებიდან + Video Frames to Layers… ვიდეოკადრები შრეებად… + Rasterize რასტრირება + Rasterize Type ტექსტის რასტრირება + Rasterize Type Layer ტექსტის შრის რასტრირება + Rasterize Shape ფიგურის რასტრირება + New Guide Layout… დამხმარე ხაზების ახალი განლაგება… + New Guides From Shape ახალი დამხმარე ხაზები ფიგურიდან + Lock Layers… შრეების ჩაკეტვა… + Link Layers შრეების დაკავშირება + Select Linked Layers დაკავშირებული შრეების მონიშვნა + Merge Layers შრეების შერწყმა + Merge Down ქვედასთან შერწყმა + Merge Visible ხილულების შერწყმა + Stamp Visible ხილულის ანაბეჭდი + Stamp Down ქვედაზე ანაბეჭდი + Flatten Image ერთ შრედ გაერთიანება + Matting კიდის ფონის მოცილება + Color Decontaminate… ფერის გაწმენდა… + Remove Black Matte შავი შემოსაზღვრის მოცილება + Remove White Matte თეთრი შემოსაზღვრის მოცილება + Defringe… კიდის შარავანდის მოცილება… + Group Layers შრეების დაჯგუფება + Ungroup Layers შრეების დაჯგუფების მოხსნა + Hide Layers შრეების დამალვა + Arrange დალაგება + Bring to Front წინა პლანზე გადმოტანა + Bring Forward წინ გადმოტანა + Send Backward უკან გადატანა + Send to Back უკანა პლანზე გადატანა + Reverse შებრუნება + Combine Shapes ფიგურების შეერთება + Unite Shapes ფიგურების გაერთიანება + Subtract Front Shape წინა ფიგურის გამოკლება + Intersect Shape Areas ფიგურების გადაკვეთის არე + Exclude Overlapping Shapes გადაფარული არეების გამორიცხვა + Merge Shape Components ფიგურის კომპონენტების შერწყმა + Align სწორება + Top Edges ზედა კიდეები + Vertical Centers ვერტიკალური ცენტრები + Bottom Edges ქვედა კიდეები + Left Edges მარცხენა კიდეები + Horizontal Centers ჰორიზონტალური ცენტრები + Right Edges მარჯვენა კიდეები + Distribute განაწილება + Vertically ვერტიკალურად + Horizontally ჰორიზონტალურად + Layer Edges შრის კიდეები + Isolate Layers შრეების იზოლირება + Layer Content Options… შრის შიგთავსის პარამეტრები… + Fill Content შევსების შიგთავსი + Deselect Layers შრეების მონიშვნის მოხსნა + Find Layers შრეების ძებნა + Layer Comps შრეების კომპოზიციები + Layers შრეები + All Layers ყველა შრე + Hidden Layers დამალული შრეები + Mask All Objects ყველა ობიექტის ნიღბით დაფარვა + Subject საგანი + Sky ცა + Panels პანელები + Character Panel სიმბოლოს პანელი + Paragraph Panel აბზაცის პანელი + Character Styles Panel სიმბოლოს სტილების პანელი + Paragraph Styles Panel აბზაცის სტილების პანელი + Glyphs Panel გლიფების პანელი + Anti-Alias დაგლუვება + Sharp მკვეთრი + Crisp მკაფიო + Strong ძლიერი + Smooth გლუვი + Windows LCD Windows LCD + Windows Windows + Orientation ორიენტაცია + Horizontal ჰორიზონტალური + Vertical ვერტიკალური + OpenType OpenType + Standard Ligatures სტანდარტული ლიგატურები + Contextual Alternates კონტექსტური ალტერნატივები + Discretionary Ligatures დამატებითი ლიგატურები + Swash მოხატული ასოები (swash) + Oldstyle ძველი სტილის ციფრები + Stylistic Alternates სტილისტური ალტერნატივები + Titling Alternates სათაურის ალტერნატივები + Ornaments ორნამენტები + Ordinals რიგობითი რიცხვები + Fractions წილადები + Create Work Path სამუშაო ტრაექტორიის შექმნა + Convert to Shape ფიგურად გარდაქმნა + Warp Text… ტექსტის დეფორმაცია… + Convert to Point Text წერტილოვან ტექსტად გარდაქმნა + Convert to Paragraph Text აბზაცის ტექსტად გარდაქმნა + Default Features ნაგულისხმევი შესაძლებლობები + East Asian Features აღმოსავლეთაზიური შესაძლებლობები + Middle Eastern Features ახლოაღმოსავლური შესაძლებლობები + Middle Eastern & South Asian Composer აწყობა ახლო აღმოსავლეთისა და სამხრეთ აზიის დამწერლობებისთვის + Update All Text Layers ყველა ტექსტური შრის განახლება + Replace All Missing Fonts ყველა ნაკლული შრიფტის ჩანაცვლება + Resolve Missing Fonts… ნაკლული შრიფტების მოგვარება… + Font Preview Size შრიფტის წინასწარი ნახვის ზომა + None არცერთი + Small პატარა + Medium საშუალო + Large დიდი + Extra Large ძალიან დიდი + Huge უზარმაზარი + Load Default Type Styles ტექსტის ნაგულისხმევი სტილების ჩატვირთვა + Save Default Type Styles ტექსტის ნაგულისხმევი სტილების შენახვა + Deselect მონიშვნის მოხსნა + Reselect ხელახლა მონიშვნა + Inverse შებრუნება + Inverse Selection მონიშვნის შებრუნება + Similar მსგავსი + Color Range… ფერების დიაპაზონი… + Focus Area… ფოკუსის არე… + Select and Mask… მონიშვნა და ნიღაბი… +# Select and Mask dialog labels + Smart Radius ჭკვიანი რადიუსი + Shift Edge კიდის წანაცვლება + Decontaminate ფერის გაწმენდა + Refine Edge კიდის დაზუსტება + Modify შეცვლა + Border… საზღვარი… + Smooth… დაგლუვება… + Expand… გაფართოება… + Contract… შევიწროება… + Feather… კიდეების შერბილება… + Grow ზრდა + Transform Selection მონიშნულის გარდაქმნა + Edit in Quick Mask Mode სწრაფი ნიღბის რეჟიმში რედაქტირება + Load Selection… მონიშნულის ჩატვირთვა… + Save Selection… მონიშნულის შენახვა… + Match All ყველას შესაბამისობა + Last Filter ბოლო ფილტრი + Filter Gallery… ფილტრების გალერეა… + Adaptive Wide Angle… ადაპტიური ფართოკუთხა… + Camera Raw Filter… Camera Raw ფილტრი… + Liquify… გათხევადება… + Vanishing Point… გაქრობის წერტილი… + Blur გაბუნდოვნება + Blur Gallery გაბუნდოვნების გალერეა + Field Blur… ველის გაბუნდოვნება… + Iris Blur… დიაფრაგმის გაბუნდოვნება… + Tilt-Shift… დახრა-წანაცვლება… + Path Blur… ტრაექტორიის გასწვრივ გაბუნდოვნება… + Spin Blur… ბრუნვითი გაბუნდოვნება… + Blur More მეტად გაბუნდოვნება + Box Blur… ბლოკური გაბუნდოვნება… + Gaussian Blur… გაუსის გაბუნდოვნება… + Lens Blur… ლინზის ბუნდოვნება… + Motion Blur… მოძრაობის გაბუნდოვნება… + Radial Blur… რადიალური გაბუნდოვნება… + Shape Blur… ფიგურით გაბუნდოვნება… + Smart Blur… ჭკვიანი გაბუნდოვნება… + Surface Blur… ზედაპირის გაბუნდოვნება… + Average გასაშუალოება + Displace… წანაცვლება… + Pinch… შეკუმშვა… + Polar Coordinates… პოლარული კოორდინატები… + Ripple… ტალღოვნება… + Shear… დახრა… + Spherize… სფერიზება… + Twirl… დაგრეხა… + Wave… ტალღა… + ZigZag… ზიგზაგი… + Noise ხმაური + Add Noise… ხმაურის დამატება… + Despeckle ლაქების მოცილება + Dust & Scratches… მტვერი და ნაკაწრები… + Median… მედიანა… + Reduce Noise… ხმაურის შემცირება… + Pixelate პიქსელიზაცია + Color Halftone… ფერადი ნახევარტონი… + Crystallize… კრისტალიზაცია… + Facet წახნაგი + Fragment ფრაგმენტი + Mezzotint… მეცოტინტა… + Mosaic… მოზაიკა… + Pointillize… პუანტილიზმი… + Render რენდერი + Clouds ღრუბლები + Difference Clouds სხვაობის ღრუბლები + Fibers… ბოჭკოები… + Flame… ალი… + Lens Flare… ობიექტივის ანარეკლი… + Lighting Effects… განათების ეფექტები… + Relight… ხელახალი განათება… + Picture Frame… სურათის ჩარჩო… + Tree… ხე… + Sharpen გამკვეთრება + Sharpen Edges კიდეების გამკვეთრება + Sharpen More მეტად გამკვეთრება + Smart Sharpen… ჭკვიანი გამკვეთრება… + Unsharp Mask… არამკვეთრი ნიღაბი… + High Pass… მაღალსიხშირული ფილტრი… + Stylize სტილიზაცია + Diffuse… დიფუზია… + Emboss… რელიეფი… + Extrude… ამოწევა… + Find Edges კიდეების პოვნა + Oil Paint… ზეთის საღებავი… + Solarize სოლარიზაცია + Tiles… ფილები… + Trace Contour… კონტურის გავლება… + Wind… ქარი… + Video ვიდეო + De-Interlace… სტრიქონგამოტოვების მოცილება… + NTSC Colors NTSC ფერები + Other სხვა + Maximum… მაქსიმუმი… + Minimum… მინიმუმი… + Offset… წანაცვლება… + Plug-ins პლაგინები + Proof Setup საცდელი ჩვენების პარამეტრები + Proof Colors ფერების საცდელი ჩვენება + Gamut Warning ფერთა გამიდან გასვლის გაფრთხილება + Working CMYK სამუშაო CMYK + Working Cyan Plate სამუშაო ცისფერი ფირფიტა + Working Magenta Plate სამუშაო მეწამული ფირფიტა + Working Yellow Plate სამუშაო ყვითელი ფირფიტა + Working Black Plate სამუშაო შავი ფირფიტა + Working CMY Plate სამუშაო CMY ფირფიტა + Legacy Macintosh RGB მოძველებული Macintosh RGB + Internet Standard RGB ინტერნეტის სტანდარტული RGB + Monitor RGB მონიტორის RGB + Color Blindness — Protanopia-type დალტონიზმი — პროტანოპიის ტიპი + Color Blindness — Deuteranopia-type დალტონიზმი — დეიტერანოპიის ტიპი + Zoom In გადიდება + Zoom Out დაპატარავება + Fit on Screen ეკრანში ჩატევა + Fit Layer(s) on Screen შრე(ებ)ის ეკრანში ჩატევა + Fit Artboard on Screen სამუშაო დაფის ეკრანში ჩატევა + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size საბეჭდი ზომა + Pixel Aspect Ratio პიქსელის გვერდების თანაფარდობა + Pixel Aspect Ratio Correction პიქსელის გვერდების თანაფარდობის კორექცია + Custom Pixel Aspect Ratio… პიქსელის გვერდების მომხმარებლის თანაფარდობა… + Square კვადრატული + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC ფართოეკრანიანი (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL ფართოეკრანიანი (1,46) + Anamorphic 2:1 (2) ანამორფული 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + 32-bit Preview Options… 32-ბიტიანი გადახედვის პარამეტრები… + Screen Mode ეკრანის რეჟიმი + Standard Screen Mode ეკრანის სტანდარტული რეჟიმი + Full Screen Mode With Menu Bar სრულეკრანიანი რეჟიმი მენიუს ზოლით + Full Screen Mode სრულეკრანიანი რეჟიმი + Cycle Screen Mode ეკრანის რეჟიმების მორიგეობით გადართვა + Show ჩვენება + Extras დამატებითი ელემენტები + Selection Edges მონიშნულის კიდეები + Target Path სამიზნე ტრაექტორია + Current Path მიმდინარე ტრაექტორია + Grid ბადე + Guides დამხმარე ხაზები + Canvas Guides ტილოს დამხმარე ხაზები + Artboard Guides სამუშაო დაფის დამხმარე ხაზები + Count დათვლა + Smart Guides ჭკვიანი დამხმარე ხაზები + Slices ნაჭრები + Notes შენიშვნები + Pixel Grid პიქსელური ბადე + Pixel Art პიქსელ-არტი + Pixel Art Preview პიქსელ-არტის გადახედვა + Pattern Preview პატერნის გადახედვა + Brush Preview ფუნჯის გადახედვა + Mesh დეფორმაციის ბადე + Edit Pins სამაგრების რედაქტირება + Document Bounds დოკუმენტის საზღვრები + Show Extras Options… დამატებითი ელემენტების ჩვენების პარამეტრები… + Rulers სახაზავები + Snap მიბმა + Snap To მიბმის ობიექტები + Lock Guides დამხმარე ხაზების ჩაკეტვა + Clear Guides დამხმარე ხაზების გასუფთავება + Clear Selected Artboard Guides მონიშნული სამუშაო დაფის დამხმარე ხაზების წაშლა + Clear Canvas Guides ტილოს დამხმარე ხაზების წაშლა + New Guide… ახალი დამხმარე ხაზი… + Lock Slices ნაჭრების ჩაკეტვა + Clear Slices ნაჭრების წაშლა + Match Zoom მასშტაბის შეთანხმება + Match Location მდებარეობის შეთანხმება + Match Rotation შემობრუნების შეთანხმება + Cascade კასკადი + Tile ფილებად + Tile All Horizontally ყველას ჰორიზონტალურად განლაგება + Tile All Vertically ყველას ვერტიკალურად განლაგება + 2-up Horizontal 2 ჰორიზონტალურად + 2-up Vertical 2 ვერტიკალურად + 3-up Horizontal 3 ჰორიზონტალურად + 3-up Stacked 3 დასტად + 3-up Vertical 3 ვერტიკალურად + 4-up 4 ფანჯარა + 6-up 6 ფანჯარა + Consolidate All to Tabs ყველას ჩანართებად გაერთიანება + Float in Window ცალკე ფანჯარაში გამოტანა + Float All in Windows ყველას მცურავ ფანჯრებში + New Window for Document დოკუმენტის ახალი ფანჯარა + Workspace სამუშაო სივრცე + Essentials ძირითადი + Graphic and Web გრაფიკა და ვები + Painting ხატვა + Photography ფოტოგრაფია + New Workspace… ახალი სამუშაო სივრცე… + Delete Workspace… სამუშაო სივრცის წაშლა… + Reset Workspace სამუშაო სივრცის საწყისზე დაბრუნება + Reset Tool ხელსაწყოს ჩამოყრა + Reset All Tools ყველა ხელსაწყოს ჩამოყრა + Reset all tools to their default settings? ჩამოიყაროს ყველა ხელსაწყო ნაგულისხმევ პარამეტრებზე? + Lock Workspace სამუშაო სივრცის ჩაკეტვა + Theme თემა + Pro Theme Pro თემა + Pro Medium Gray Theme Pro საშუალო ნაცრისფერი თემა + Pro Light Gray Theme Pro ღია ნაცრისფერი თემა + Pro Light Theme Pro ღია თემა + Studio Theme Studio თემა + Studio Light Theme Studio ღია თემა + Adwaita Light Theme Adwaita ღია თემა + Adwaita Dark Theme Adwaita ბნელი თემა + Classic Theme კლასიკური თემა + Solarized dark Solarized ბნელი + Solarized Dark Theme Solarized ბნელი თემა + Breeze dark Breeze ბნელი + Breeze Dark Theme Breeze ბნელი თემა + Breeze light Breeze ღია + Breeze Light Theme Breeze ღია თემა + Next Theme შემდეგი თემა + Actions მოქმედებები + Brush Settings ფუნჯის პარამეტრები + Brushes ფუნჯები + Channels არხები + Character სიმბოლო + Character Styles სიმბოლოს სტილები + Clone Source კლონირების წყარო + Color ფერი + Glyphs გლიფები + Gradients გრადიენტები + Histogram ჰისტოგრამა + History ისტორია + Info ინფორმაცია + Measurement Log გაზომვების ჟურნალი + Modifier Keys მოდიფიკატორი კლავიშები + Navigator ნავიგატორი + Options პარამეტრები + Paragraph აბზაცი + Paragraph Styles აბზაცის სტილები + Paths ტრაექტორიები + Patterns პატერნები + Properties თვისებები + Shapes ფიგურები + Styles სტილები + Swatches ფერის ნიმუშები + Timeline დროის ხაზი + Tool Presets ხელსაწყოს პრესეტები + Tools ხელსაწყოები + About PhotoCraft PhotoCraft-ის შესახებ + PhotoCraft Website PhotoCraft-ის ვებსაიტი + PhotoCraft on GitHub PhotoCraft GitHub-ზე + ArtCraft Website ArtCraft-ის ვებსაიტი + Join the ArtCraft Discord… შემოუერთდით ArtCraft-ის Discord-ს… + Report an Issue… ხარვეზის შეტყობინება… + WIA Support… WIA მხარდაჭერა… + Apply გამოყენება + HSB/HSL HSB/HSL + Invert… ინვერსია… + Load Files into Stack… ფაილების დასტაში ჩატვირთვა… + Motion მოძრაობა + Notes… შენიშვნები… + Other… სხვა… + Remove Warp Split დეფორმაციის გაყოფის მოცილება + Shape ფიგურა + Split Warp Crosswise დეფორმაციის ჯვარედინად გაყოფა + Split Warp Horizontally დეფორმაციის ჰორიზონტალურად გაყოფა + Split Warp Vertically დეფორმაციის ვერტიკალურად გაყოფა + Statistics… სტატისტიკა… + Refine Strokes დაზუსტების შტრიხები + Edits ცვლილებები +@plural {n} item|{n} items {n} ელემენტი + Auto ავტომატური + (print to the printer) (პრინტერზე ბეჭდვა) + + Add variable + ცვლადის დამატება + 30 frames @ 30 fps 30 კადრი @ 30 fps + 3D LUT File 3D LUT ფაილი + Absolute აბსოლუტური + Absolute Colorimetric აბსოლუტური კოლორიმეტრიული + Action მოქმედება + Adaptive ადაპტური + Adaptive Wide Angle isn't open ადაპტიური ფართოკუთხა არ არის გახსნილი + Add დამატება + Add Layer Mask შრის ნიღბის დამატება + Add Stop გადასვლის წერტილის დამატება + Add a layer style შრის სტილის დამატება + Add a mask (from the selection; {key} inverts) ნიღბის დამატება (მონიშნულიდან; {key} აბრუნებს) + Add an adjustment კორექციის დამატება + Add disk დისკის დამატება + Add space after paragraph ინტერვალის დამატება აბზაცის შემდეგ + Add space before paragraph ინტერვალის დამატება აბზაცის წინ + Add to Sample ნიმუშში დამატება + Add to selection ({key}) მონიშნულში დამატება ({key}) + Adjust for Zoom მასშტაბის გათვალისწინება + Adjustment visible კორექცია ხილულია + Adobe RGB (1998) compatible Adobe RGB (1998)-თან თავსებადი + Align and Distribute სწორება და განაწილება + Align and distribute სწორება და განაწილება + Align horizontal centers ჰორიზონტალური ცენტრების სწორება + Align left edges მარცხენა კიდეების სწორება + Align right edges მარჯვენა კიდეების სწორება + Aligned გასწორებული + All Caps მთავრული + All Slices ყველა ნაჭერი + All User Slices მომხმარებლის ყველა ნაჭერი + All shortcuts reset to PhotoCraft defaults. ყველა მალსახმობი დაბრუნდა PhotoCraft-ის ნაგულისხმევზე. + Alt Alt + Always use pressure for opacity გაუმჭვირვალობისთვის ყოველთვის დაწოლის გამოყენება + Always use pressure for size ზომისთვის ყოველთვის დაწოლის გამოყენება + Amount რაოდენობა + Anchor: მიმაგრება: + Angle კუთხე + Angle Gradient კუთხური გრადიენტი + Angle Jitter კუთხის ცვალებადობა + Answer the unsaved-changes prompt first ჯერ უპასუხეთ შეუნახავი ცვლილებების შეკითხვას + Anti-alias დაგლუვება + Appearance იერსახე + Appearance (Layer Style) იერსახე (შრის სტილი) + Apply Layer Mask შრის ნიღბის გამოყენება + Apply Per Tip თითოეულ წვერზე გამოყენება + Apply next selected layer comp შემდეგი მონიშნული შრეების კომპოზიციის გამოყენება + Apply previous selected layer comp წინა მონიშნული შრეების კომპოზიციის გამოყენება + Artboard სამუშაო დაფა + Artboard Background Color სამუშაო დაფის ფონის ფერი + Assign working space სამუშაო სივრცის მინიჭება + Author: ავტორი: + Auto Hide ავტომატური დამალვა + Auto-Select: ავტომონიშვნა: + Automatically level near-horizontal line segments თითქმის ჰორიზონტალური მონაკვეთების ავტომატური გასწორება + Automatically straighten and level ავტომატური გასწორება და დონეზე მოყვანა + Automatically straighten near-vertical line segments თითქმის ვერტიკალური მონაკვეთების ავტომატური გასწორება + Background ფონი + Background Color ფონის ფერი + Background Contents ფონის შიგთავსი + Balance ბალანსი + Baseline საბაზისო ხაზი + Baseline shift საბაზისო ხაზის წანაცვლება + Basic საბაზო + Before (Y) მანამდე (Y) + Begin recording ჩაწერის დაწყება + Bend: მოხრა: + Bicubic ბიკუბური + Bicubic (smooth gradients) ბიკუბური (გლუვი გრადიენტებისთვის) + Automatic ავტომატური + Bicubic Smoother (enlargement) ბიკუბური, უფრო გლუვი (გადიდებისთვის) + Bicubic Sharper (reduction) ბიკუბური, უფრო მკვეთრი (შემცირებისთვის) + Bilinear ბიწრფივი + Black შავი + Black & White შავ-თეთრი + Black Point Compensation შავი წერტილის კომპენსაცია + Black, White შავი, თეთრი + Blacks შავები + Blending შერევა + Blending Options შერევის პარამეტრები + Blue ლურჯი + Blue, Red, Yellow ლურჯი, წითელი, ყვითელი + Both Axes ორივე ღერძი + Bottom ქვემოთ + Brightness სიკაშკაშე + Brightness Jitter სიკაშკაშის ცვალებადობა + Brightness/Contrast სიკაშკაშე/კონტრასტი + Bring the selected pin forward მონიშნული სამაგრის წინ გადმოტანა + Brush Preset picker ფუნჯის პრესეტის ამრჩევი + Brush Reconstruct Options ფუნჯით აღდგენის პარამეტრები + Brush Tool Options ფუნჯის ხელსაწყოს პარამეტრები + Cache Level: ქეშის დონე: + Camera Raw isn't open Camera Raw არ არის გახსნილი + Cancel გაუქმება + Cancel (Esc) გაუქმება (Esc) + Cancel any current edits (Esc) მიმდინარე ცვლილებების გაუქმება (Esc) + Cancel current crop operation (Esc) მიმდინარე ჩამოჭრის გაუქმება (Esc) + Cancel transform (Esc) გარდაქმნის გაუქმება (Esc) + Cancel warp (Esc) დეფორმაციის გაუქმება (Esc) + Canvas ტილო + Canvas extension color: ტილოს გაფართოების ფერი: + Capture ჩაწერა + Capture the visibility, position and style of every layer with + below, then switch between versions. + ღილაკით ქვემოთ ჩაწერეთ ყოველი შრის ხილვადობა, პოზიცია და სტილი, შემდეგ კი ვერსიებს შორის გადართეთ. + Catch-up on Stroke End მონასმის ბოლოს დაწევა + Center ცენტრში + Center Crop Marks ცენტრის ჭრის ნიშნები + Center text ტექსტის ცენტრირება + Change ჩანაცვლება + Change All ყველას ჩანაცვლება + Change Screen Mode (F) ეკრანის რეჟიმის შეცვლა (F) + Change To: ჩანაცვლება: + Channel Mixer არხების მიქშერი + Channel: არხი: + Check All Layers ყველა შრის შემოწმება + Check Spelling მართლწერის შემოწმება + Clarity სიცხადე + Clear All ყველას გასუფთავება + Clear Override გადაფარვის მოხსნა + Clear Samples ნიმუშების გასუფთავება + Click sets foreground · right-click sets background დაწკაპუნება აყენებს წინა პლანის ფერს · მარჯვენა დაწკაპუნება — ფონის ფერს + Click the first point or press {key} to close · Esc cancels დასახურად დაწკაპეთ პირველ წერტილზე ან დააჭირეთ {key}-ს · Esc აუქმებს + Click to add a point · drag off or {key}-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, {key}-დაწკაპეთ ან დააჭირეთ Delete-ს + Click to apply, {key}-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, {key}-დაწკაპუნება ხსნის გადაფარვებს, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to edit the gradient დაწკაპეთ გრადიენტის რედაქტირებისთვის + Click to restore the current colour დაწკაპეთ მიმდინარე ფერის აღსადგენად + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out დაწკაპეთ გასადიდებლად · {key}-დაწკაპუნება — დასაპატარავებლად · მარჯვნივ/მარცხნივ გადათრევა — გადიდება/დაპატარავება + Click with the Note tool to add a note. შენიშვნის დასამატებლად დაწკაპეთ შენიშვნის ხელსაწყოთი. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · {key} დასრულება · Esc გაუქმება + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · {alt}-გადათრევა: სახელურის გაწყვეტა · პირველ წერტილზე დაწკაპუნება ან გადათრევა: დახურვა · {key} დასრულება · Esc გაუქმება + Clip to layer below ქვედა შრეში ჩაჭრა + Clipped ჩაჭრილი + Close Style Options სტილის პარამეტრების დახურვა + Cmd Cmd + Coated CMYK ცარცული ქაღალდის CMYK + Color & Swatches ფერი და ფერის ნიმუშები + Color Balance ფერთა ბალანსი + Color Detail ფერის დეტალები + Color Grading ფერის კორექცია (grading) + Color Lookup ფერების ცხრილი (Color Lookup) + Color Management ფერების მართვა + Color Mixer ფერების მიქშერი + Color Mode ფერის რეჟიმი + Color Priority ფერის პრიორიტეტი + Color Range preview ფერების დიაპაზონის გადახედვა + Color: ფერი: + Colorize გაფერადება + Colors ფერები + Commit Perspective Warp ({key}) პერსპექტიული დეფორმაციის დადასტურება ({key}) + Commit Puppet Warp ({key}) მარიონეტული დეფორმაციის დადასტურება ({key}) + Commit any current edits ({key}) მიმდინარე ცვლილებების დადასტურება ({key}) + Commit current crop operation ({key}) მიმდინარე ჩამოჭრის დადასტურება ({key}) + Commit transform ({key}) გარდაქმნის დადასტურება ({key}) + Commit warp ({key}) დეფორმაციის დადასტურება ({key}) + Constant მუდმივი + Constrain proportions პროპორციების შენარჩუნება + Contact Sheet II საკონტაქტო ფურცელი II + Content-Aware შიგთავსის გათვალისწინებით + Contiguous მომიჯნავე + Contrast კონტრასტი + Control: მართვა: + Convert to sRGB sRGB-ად გარდაქმნა + Copies ასლები + Copper სპილენძი + Copyright საავტორო უფლება + Copyright and Contact Info საავტორო უფლება და საკონტაქტო ინფორმაცია + Corner Crop Marks კუთხის ჭრის ნიშნები + Corner radius კუთხის რადიუსი + Correction კორექცია + Count Jitter რაოდენობის ცვალებადობა + Create შექმნა + Create Droplet დროპლეტის შექმნა + Create Texture ტექსტურის შექმნა + Create Video Timeline ვიდეოს დროის ხაზის შექმნა + Create a new count group ახალი დათვლის ჯგუფის შექმნა + Create a new document or open an existing file. შექმენით ახალი დოკუმენტი ან გახსენით არსებული ფაილი. + Create a new group ახალი ჯგუფის შექმნა + Create a new layer ახალი შრის შექმნა + Create new action ახალი მოქმედების შექმნა + Create new channel ახალი არხის შექმნა + Create new document from current state ახალი დოკუმენტის შექმნა მიმდინარე მდგომარეობიდან + Create new fill or adjustment layer ახალი შევსების ან მაკორექტირებელი შრის შექმნა + Create new gradient ახალი გრადიენტის შექმნა + Create new group ახალი ჯგუფის შექმნა + Create new layer comp შრეების ახალი კომპოზიციის შექმნა + Create new pattern from the selection ახალი პატერნის შექმნა მონიშნულიდან + Create new shape from the current path ახალი ფიგურის შექმნა მიმდინარე ტრაექტორიიდან + Create new snapshot ახალი სნეპშოტის შექმნა + Create new style (from the selected type) ახალი სტილის შექმნა (მონიშნული ტექსტიდან) + Create new style from the selected layer ახალი სტილის შექმნა მონიშნული შრიდან + Create new tool preset ხელსაწყოს ახალი პრესეტის შექმნა + Crop Factor ჩამოჭრის კოეფიციენტი + Ctrl Ctrl + Current & Below მიმდინარე და ქვედა + Current Layer მიმდინარე შრე + Current Size მიმდინარე ზომა + Current Tool Only მხოლოდ მიმდინარე ხელსაწყო + Curve მრუდი + Curves მრუდები + Custom მორგებული + Custom Shapes მორგებული ფიგურები + Cyan ცისფერი + Cyan · Red ცისფერი · წითელი + Darken ჩამუქება + Darks მუქი ტონები + Data Sets მონაცემთა ნაკრებები + Default ნაგულისხმევი + Default colours (D) ნაგულისხმევი ფერები (D) + Default printer ნაგულისხმევი პრინტერი + Default restored. ნაგულისხმევი აღდგენილია. + Define განსაზღვრა + Define at least one variable first. ჯერ განსაზღვრეთ ერთი ცვლადი მაინც. + Dehaze ბურუსის მოცილება + Delete Cropped Pixels ჩამოჭრილი პიქსელების წაშლა + Delete Group ჯგუფის წაშლა + Delete Layer Mask შრის ნიღბის წაშლა + Delete Layers შრეების წაშლა + Delete Shortcut მალსახმობის წაშლა + Delete Timeline დროის ხაზის წაშლა + Delete Tool Preset ხელსაწყოს პრესეტის წაშლა + Delete count group დათვლის ჯგუფის წაშლა + Delete current channel მიმდინარე არხის წაშლა + Delete current state მიმდინარე მდგომარეობის წაშლა + Delete effect layer ეფექტის შრის წაშლა + +# Layers-panel trash confirmation + Delete layer ‘{name}’? წაიშალოს შრე ‘{name}’? + Delete {count} layers? {names} წაიშალოს შრეები ({count})? {names} + Delete layer? წაიშალოს შრე? + 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} შრე წაიშალა, მაგრამ დადასტურების პარამეტრი ვერ შეინახა: {error} + + Delete layer შრის წაშლა + Delete layer comp შრეების კომპოზიციის წაშლა + Delete note შენიშვნის წაშლა + Delete selected measurements მონიშნული გაზომვების წაშლა + Delete style სტილის წაშლა + Delete tool preset ხელსაწყოს პრესეტის წაშლა + Density სიმკვრივე + Density: სიმკვრივე: + Depth სიღრმე + Depth Jitter სიღრმის ცვალებადობა + Description აღწერა + Deselect All ყველა მონიშვნის მოხსნა + Detail დეტალი + Diamond Gradient ბრილიანტისებრი გრადიენტი + Difference სხვაობა + Diffusion დიფუზია + Discard the embedded profile (don't color manage) ჩაშენებული პროფილის უგულებელყოფა (ფერების მართვის გარეშე) + Discontiguous არამომიჯნავე + Discord Discord + Dismiss დახურვა + Display P3 Display P3 + Display Text ტექსტის ჩვენება + Dither დიზერინგი + Divide… გაყოფა… + Document დოკუმენტი + Don't Save არ შეინახო + No არა + Yes დიახ + Done მზადაა + Dots წერტილები + Drag a rectangle around the object ობიექტის გარშემო მართკუთხედი გაავლეთ + Drag to move the active shape's path or the Work Path გადაათრიეთ აქტიური ფიგურის ტრაექტორიის ან სამუშაო ტრაექტორიის გადასაადგილებლად + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath დაწკაპუნება: საყრდენი წერტილის მონიშვნა · გადათრევა: საყრდენი წერტილის, სახელურის ან სეგმენტის გადაადგილება · {shift}-დაწკაპუნება: დამატება · {alt}-დაწკაპუნება: მთელი ქვეტრაექტორია + Draw with the Pen tool (P) or make a work path from a selection. დახაზეთ კალმით (P) ან შექმენით სამუშაო ტრაექტორია მონიშნულიდან. + Drop an image or PSD anywhere to open it. გასახსნელად ჩააგდეთ გამოსახულება ან PSD ნებისმიერ ადგილას. + Duplicate Layers… შრეების დუბლირება… + Duplicate style სტილის დუბლირება + Edit Toolbar… ხელსაწყოთა ზოლის რედაქტირება… + Edit in Quick Mask Mode (Q) რედაქტირება სწრაფი ნიღბის რეჟიმში (Q) + Edit in Standard Mode (Q) რედაქტირება სტანდარტულ რეჟიმში (Q) + Effects ეფექტები + Embed Color Profile ფერის პროფილის ჩაშენება + Embedded Profile Mismatch ჩაშენებული პროფილის შეუსაბამობა + Enable airbrush-style build-up effects აეროგრაფის სტილის დაგროვების ეფექტის ჩართვა + Enhance Edge კიდის გაძლიერება + Entire Font მთელი შრიფტი + Exact ზუსტი + Expansion: გაფართოება: + Export Presets ექსპორტის პრესეტები + Export selected measurements (CSV) მონიშნული გაზომვების ექსპორტი (CSV) + Exposure ექსპოზიცია + Exposure and Gamma ექსპოზიცია და გამა + Exposure: ექსპოზიცია: + Eyedropper პიპეტი + Factor: კოეფიციენტი: + Faux Bold ხელოვნური მუქი + Faux Italic ხელოვნური დახრილი + Feather კიდეების შერბილება + Fewer Points ნაკლები წერტილი + File Settings ფაილის პარამეტრები + Fill შევსება + Fill: შევსება: + Filter for adjustment layers მაკორექტირებელი შრეების ფილტრი + Filter for pixel layers პიქსელური შრეების ფილტრი + Filter for shape layers ფიგურის შრეების ფილტრი + Filter for smart objects ჭკვიანი ობიექტების ფილტრი + Filter for type layers ტექსტური შრეების ფილტრი + Filter: ფილტრი: + Finger Painting თითით ხატვა + Fisheye თევზის თვალი + Fit მორგება + Fit Screen ეკრანში ჩატევა + Fill Screen ეკრანის შევსება + Fit in View ხედში ჩატევა + Fixed ფიქსირებული + Fixed Ratio ფიქსირებული პროპორცია + Fixed Size ფიქსირებული ზომა + Flip არეკვლა + Flip X X-ით არეკვლა + Flip X Jitter X-ით არეკვლის ცვალებადობა + Flip Y Y-ით არეკვლა + Flip Y Jitter Y-ით არეკვლის ცვალებადობა + Flow ნაკადი + Flow Jitter ნაკადის ცვალებადობა + Focal Length ფოკუსური მანძილი + Font შრიფტი + Font Size: შრიფტის ზომა: + Font size შრიფტის ზომა + Foreground წინა პლანი + Foreground to Background წინა პლანიდან ფონისკენ + Foreground/Background Jitter წინა პლანის/ფონის ცვალებადობა + Format ფორმატი + Frame from Layers… კადრი შრეებიდან… + Free თავისუფალი + Full Spherical სრული სფერული + Fuzziness: გაბნეულობა: + GPU GPU + Gamma Correction გამა-კორექცია + Gamma: გამა: + Global გლობალური + Glow Color ნათების ფერი + Gradient Map გრადიენტული რუკა + Grain მარცვლოვნება + Gray ნაცრისფერი + Gray Gamma 2.2 Gray Gamma 2.2 + Green მწვანე + Group ჯგუფი + Hardness სიხისტე + Heart გული + Height სიმაღლე + Height: სიმაღლე: + Hide Auto Slices ავტომატური ნაჭრების დამალვა + Hide Properties თვისებების დამალვა + Highlight Compression ღია ტონების შეკუმშვა + Highlight Priority ღია ტონების პრიორიტეტი + Highlights ღია ტონები + Home საწყისი ეკრანი + Horizontal scale ჰორიზონტალური მასშტაბი + Hue ფერის ტონი + Hue Jitter ფერის ტონის ცვალებადობა + Hue/Saturation ფერის ტონი/გაჯერება + Hyphenate სიტყვების გადატანა + Ignore უგულებელყოფა + Ignore All ყველას გამოტოვება + Image Size გამოსახულების ზომა + Import Brushes… ფუნჯების იმპორტი… + Import brushes (.abr) ფუნჯების იმპორტი (.abr) + Import Presets პრესეტების იმპორტი + Imported იმპორტირებული + Indent First პირველი სტრიქონის შეწევა + Indent Left მარცხენა შეწევა + Indent Right მარჯვენა შეწევა + Indent first line პირველი სტრიქონის შეწევა + Indent left margin მარცხენა ველის შეწევა + Indent right margin მარჯვენა ველის შეწევა + Input: შემავალი: + Inside შიგნით + Interlaced ინტერლეისი + Interpolation: ინტერპოლაცია: + Intersect with selection ({key}) მონიშნულთან გადაკვეთა ({key}) + Invert All ყველაფრის ინვერსია + Invert has no settings. ინვერსიას პარამეტრები არ აქვს. + Italic დახრილი + Join us on Discord შემოგვიერთდით Discord-ზე + Justify all სრული ორმხრივი სწორება + Justify last centered ორმხრივი სწორება, ბოლო სტრიქონი ცენტრში + Justify last left ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ + Justify last right ორმხრივი სწორება, ბოლო სტრიქონი მარჯვნივ + Kerning კერნინგი + Keyboard Shortcuts კლავიატურის მალსახმობები + Keyboard Shortcuts and Menus კლავიატურის მალსახმობები და მენიუები + Kind სახეობა + Label Size წარწერის ზომა + Labels წარწერები + Lanczos (sharp) Lanczos (მკვეთრი) + Landscape ჰორიზონტალური + Last Document State დოკუმენტის ბოლო მდგომარეობა + Layout განლაგება + Leading სტრიქონთა დაშორება + Leading (set to the font size × auto-leading when Auto) სტრიქონთა დაშორება (ავტომატურ რეჟიმში უდრის შრიფტის ზომას × ავტომატური დაშორება) + Leave as is (don't color manage) დატოვება როგორც არის (ფერების მართვის გარეშე) + Left align text ტექსტის მარცხნივ სწორება + Length: სიგრძე: + Level დონე + Levels დონეები + Lighten გაღიავება + Lightness სიღიავე + Lights ნათელი ტონები + Limits: ზღვრები: + Linear Gradient წრფივი გრადიენტი + Link layers შრეების დაკავშირება + Link width and height სიგანისა და სიმაღლის დაკავშირება + Liquify is not open გათხევადება არ არის გახსნილი + Load ჩატვირთვა + Load 3D LUT… 3D LUT-ის ჩატვირთვა… + Load channel as selection არხის ჩატვირთვა მონიშნულად + Load… ჩატვირთვა… + Localized Color Clusters ფერთა ლოკალური კლასტერები + Lock ჩაკეტვა + Lock all ყველაფრის ჩაკეტვა + Lock image pixels გამოსახულების პიქსელების ჩაკეტვა + Lock layer შრის ჩაკეტვა + Lock position პოზიციის ჩაკეტვა + Lock transparent pixels გამჭვირვალე პიქსელების ჩაკეტვა + Lock: ჩაკეტვა: + Logical Length: ლოგიკური სიგრძე: + Logical Units: ლოგიკური ერთეულები: + Luminance სიკაშკაშე + Luminance Detail სიკაშკაშის დეტალი + Magenta მეწამული + Magenta · Green მეწამული · მწვანე + Maintain aspect ratio თანაფარდობის შენარჩუნება + Marker Size მარკერის ზომა + Mask All ყველაფრის დაფარვა ნიღბით + Mask Options ნიღბის პარამეტრები + Masking ნიღბვა + Master საერთო + Matte მატი + Median: მედიანა: + Menus მენიუები + Mesh Size ბადის ზომა + Metadata მეტამონაცემები + Metrics მეტრიკა + Midpoint შუა წერტილი + Midtones შუალედური ტონები + Midtones from: შუალედური ტონები, საწყისი: + Midtones to: შუალედური ტონები, საბოლოო: + Minimum Depth მინიმალური სიღრმე + Minimum Diameter მინიმალური დიამეტრი + Minimum Roundness მინიმალური სიმრგვალე + Mode: რეჟიმი: + Monochrome მონოქრომული + More Points მეტი წერტილი + Move effect layer down ეფექტის შრის ჩამოწევა + Move effect layer up ეფექტის შრის აწევა + Move to გადატანა + Name სახელი + Name: სახელი: + Nearest Neighbor უახლოესი მეზობელი + Nearest Neighbor (hard edges) უახლოესი მეზობელი (მკვეთრი კიდეები) + New Size ახალი ზომა + New document… ახალი დოკუმენტი… + New effect layer ახალი ეფექტის შრე + New selection ახალი მონიშვნა + Next frame შემდეგი კადრი + Next note შემდეგი შენიშვნა + No Color ფერის გარეშე + No Color Management ფერების მართვის გარეშე + No Dither დიზერინგის გარეშე + No author ავტორის გარეშე + No character style სიმბოლოს სტილის გარეშე + No document დოკუმენტი არ არის + No matching commands შესაბამისი ბრძანება ვერ მოიძებნა + No presets for the current tool. მიმდინარე ხელსაწყოს პრესეტები არ არის. + No presets of this type. ამ ტიპის პრესეტები არ არის. + No properties თვისებები არ არის + No tool presets. ხელსაწყოს პრესეტები არ არის. + Noise Reduction ხმაურის შემცირება + Normal ჩვეულებრივი + Not in Dictionary: ლექსიკონში არ არის: + OK OK + Offset წანაცვლება + Only Web Colors მხოლოდ ვებ-ფერები + Opacity გაუმჭვირვალობა + Opacity Jitter გაუმჭვირვალობის ცვალებადობა + Opacity: გაუმჭვირვალობა: + Open a document to define variables. ცვლადების განსასაზღვრად გახსენით დოკუმენტი. + Open a document to select a colour range. ფერების დიაპაზონის მოსანიშნად გახსენით დოკუმენტი. + Open a document to use the timeline. დროის ხაზის გამოსაყენებლად გახსენით დოკუმენტი. + Optical ოპტიკური + Optimized ოპტიმიზებული + Option-click to define a source point to clone from Option-დაწკაპუნებით განსაზღვრეთ კლონირების საწყისი წერტილი + Original ორიგინალი + Output Channel: გამომავალი არხი: + Output Levels: გამომავალი დონეები: + Output: გამომავალი: + Outside გარეთ + Overlay Color გადაფარვის ფერი + Overlay Opacity გადაფარვის გაუმჭვირვალობა + Overlay: Rule of Thirds გადაფარვა: მესამედების წესი + Override Pressure დაწოლის უგულებელყოფა + Override Rotation შემობრუნების უგულებელყოფა + Override Tilt დახრის უგულებელყოფა + PRESET DETAILS პრესეტის დეტალები + PSD PSD + Paint Overlay საღებავით გადაფარვა + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. პანელების მდებარეობა ინახება სამუშაო სივრცეში. კლავიატურის მალსახმობები, მენიუები და ხელსაწყოთა ზოლი არასავალდებულოა. + Paper ქაღალდი + Path ტრაექტორია + Pattern პატერნი + Pattern Size პატერნის ზომა + Pause პაუზა + Percent პროცენტი + Perceptual აღქმითი + Perspective Warp is not active პერსპექტიული დეფორმაცია აქტიური არ არის + Perspective Warp: draw planes along the image's perspective, then switch to Warp პერსპექტიული დეფორმაცია: დახაზეთ სიბრტყეები გამოსახულების პერსპექტივის გასწვრივ, შემდეგ გადაერთეთ დეფორმაციაზე + Photo Filter ფოტოფილტრი + PhotoCraft PhotoCraft + PhotoCraft Manages Colors ფერებს მართავს PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — ღია კოდის ნატიური გამოსახულების რედაქტორი, დაწერილი Rust-ზე. + Pin Depth: სამაგრის სიღრმე: + Pixel Length: სიგრძე პიქსელებში: + Pixels/Centimeter პიქსელი/სანტიმეტრი + Pixels/Inch პიქსელი/დუიმი + Pixels: პიქსელები: + Play დაკვრა + Play selection მონიშნულის დაკვრა + Portrait ვერტიკალური + Position პოზიცია + Position and Size პოზიცია და ზომა + Posterize პოსტერიზაცია + Preserve Details დეტალების შენარჩუნება + Preserve Luminosity სიკაშკაშის შენარჩუნება + Preset პრესეტი + Preset Type პრესეტის ტიპი + Preset: პრესეტი: + Presets: პრესეტები: + Press keys… დააჭირეთ კლავიშებს… + Pressure დაწოლა + Pressure for Opacity დაწოლა გაუმჭვირვალობისთვის + Pressure for Size დაწოლა ზომისთვის + Prevent auto-nesting in and out of Artboards and Frames სამუშაო დაფებსა და ჩარჩოებში ავტომატური ჩასმისა და ამოღების აკრძალვა + Preview წინასწარი ნახვა + Previous frame წინა კადრი + Previous note წინა შენიშვნა + Print დაბეჭდვა + Printer პრინტერი + Printer Manages Colors ფერებს მართავს პრინტერი + Printer Setup პრინტერის პარამეტრები + Printing Marks საბეჭდი ნიშნები + Progressive პროგრესული + Promote დაწინაურება + Protect Detail დეტალის დაცვა + Protect Foreground Color წინა პლანის ფერის დაცვა + Protect Tones ტონების დაცვა + Proximity Match სიახლოვით შესაბამისობა + Pulled String Mode დაჭიმული ძაფის რეჟიმი + Puppet Warp is not active მარიონეტული დეფორმაცია აქტიური არ არის + Purity სისუფთავე + Quality ხარისხი + Quick Actions სწრაფი მოქმედებები + Quick Export As PNG სწრაფი ექსპორტი PNG-ად + Radial Gradient რადიალური გრადიენტი + Radius რადიუსი + Radius: რადიუსი: + Range: დიაპაზონი: + Rate სიხშირე + Recently Used ბოლოს გამოყენებული + Reconstruct რეკონსტრუქცია + Record ● a sequence of edits, then play ▶ it on any document. ჩაწერეთ ● რედაქტირებების თანმიმდევრობა, შემდეგ დაუკარით ▶ ნებისმიერ დოკუმენტზე. + Red წითელი + Redefine style by current selection სტილის ხელახლა განსაზღვრა მიმდინარე მონიშნულით + Refine დაზუსტება + Reflected Gradient არეკლილი გრადიენტი + Registration Marks შეთავსების ნიშნები + Regular ჩვეულებრივი + Relative ფარდობითი + Relative Colorimetric ფარდობითი კოლორიმეტრიული + Remove მოშორება + Remove All Pins ყველა სამაგრის მოშორება + Remove Background ფონის მოშორება + Remove Planes სიბრტყეების მოშორება + Remove Tool მოშორების ხელსაწყო + Remove stop გადასვლის წერტილის წაშლა + Remove variable ცვლადის მოშორება + Rename სახელის შეცვლა + Rename Group… ჯგუფის სახელის შეცვლა… + Rename Layer… შრის სახელის შეცვლა… + Rename Tool Preset… ხელსაწყოს პრესეტის სახელის შეცვლა… + Rename… სახელის შეცვლა… + Resample ხელახალი დისკრეტიზაცია + Reset All to Defaults ყველაფრის ნაგულისხმევზე დაბრუნება + Import Shortcuts… მალსახმობების იმპორტი… + Load a keyboard shortcut set saved as .kys .kys ფაილად შენახული კლავიატურის მალსახმობების ნაკრების ჩატვირთვა + Imported {n} shortcuts from {file}. Click OK to keep them. {file}-დან იმპორტირებულია მალსახმობი: {n}. მათ შესანარჩუნებლად დაწკაპეთ OK-ზე. + {n} commands are not in PhotoCraft. PhotoCraft-ში არ არის ბრძანება: {n}. + Keyboard shortcuts imported კლავიატურის მალსახმობები იმპორტირებულია + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. აქ გამოიყენება თქვენი კლავიატურის მალსახმობების ნაკრები {set}: ნაგულისხმევისგან განსხვავდება მალსახმობი: {n}. მათ აჩვენებს რედაქტირება › კლავიატურის მალსახმობები; ყველაფრის ნაგულისხმევზე დაბრუნება ამას გააუქმებს. + Import keyboard shortcuts? მოხდეს კლავიატურის მალსახმობების იმპორტი? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft-მა ამ კომპიუტერზე იპოვა თქვენი კლავიატურის მალსახმობების ნაკრები {set} სხვა გამოსახულების რედაქტორიდან: PhotoCraft-ისგან განსხვავდება მისი მალსახმობი: {n}. მოხდეს იმპორტი? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. რედაქტირება › კლავიატურის მალსახმობები › ყველაფრის ნაგულისხმევზე დაბრუნება აუქმებს იმპორტს. + Don't Import იმპორტის გარეშე + The keyboard shortcut set was not imported: {error} კლავიატურის მალსახმობების ნაკრების იმპორტი ვერ მოხერხდა: {error} + Reset Section სექციის საწყისზე დაბრუნება + Reset Transform გარდაქმნის საწყისზე დაბრუნება + Reset to defaults ნაგულისხმევზე დაბრუნება + Resolution გარჩევადობა + Resolution: გარჩევადობა: + Restore All ყველაფრის აღდგენა + Restore Defaults ნაგულისხმევის აღდგენა + Restrictive (Web) შეზღუდული (ვები) + Right align text ტექსტის მარჯვნივ სწორება + Rigid ხისტი + Rotate: შემობრუნება: + Rotation შემობრუნება + Roughness უხეშობა + Roundness სიმრგვალე + Roundness Jitter სიმრგვალის ცვალებადობა + Rulers & Grids სახაზავები და ბადეები + Sample All Layers ყველა შრიდან ნიმუშის აღება + Sample: ნიმუში: + Sampling: Background Swatch ნიმუშის აღება: ფონის ფერის ნიმუში + Sampling: Continuous ნიმუშის აღება: უწყვეტი + Sampling: Once ნიმუშის აღება: ერთხელ + Saturate გაჯერება + Saturation გაჯერება + Saturation Jitter გაჯერების ცვალებადობა + Save HTML and Images HTML-ისა და გამოსახულებების შენახვა + Save as PDF PDF-ად შენახვა + Save selection as channel მონიშნულის არხად შენახვა + Save… შენახვა… + Scale to Fit Media მედიაში ჩატევით მასშტაბირება + Scatter გაბნევა + Scratch disks დროებითი დისკები + Search ძიება + Search commands ბრძანებების ძიება + Search commands, tools and panels… ბრძანებების, ხელსაწყოებისა და პანელების ძიება… + Search fonts შრიფტების ძიება + Select All ყველაფრის მონიშვნა + Select Subject ობიექტის მონიშვნა + Select a type layer or click into text to insert glyphs გლიფების ჩასასმელად მონიშნეთ ტექსტური შრე ან დაწკაპეთ ტექსტში + Select text to apply a character style სიმბოლოს სტილის გამოსაყენებლად მონიშნეთ ტექსტი + Select: მონიშვნა: + Selections მონიშნულები + Selective შერჩევითი + Selective Color შერჩევითი ფერი + Send the selected pin backward მონიშნული სამაგრის უკან გადატანა + Set additional smoothing options დაგლუვების დამატებითი პარამეტრები + Set background color ფონის ფერის დაყენება + Set foreground color წინა პლანის ფერის დაყენება + Set painting symmetry options ხატვის სიმეტრიის პარამეტრები + Symmetry Off სიმეტრია გამორთულია + Set shape fill type ფიგურის შევსების ტიპის დაყენება + Set shape stroke type ფიგურის კონტურის ტიპის დაყენება + Set the text color ტექსტის ფერის დაყენება + Shadows ჩრდილები + Shape: ფორმა: + Sharpening გამკვეთრება + Shift Shift + Shortcut removed. მალსახმობი მოშორებულია. + Show All Menu Items მენიუს ყველა პუნქტის ჩვენება + Show Backdrop ფონის ჩვენება + Show Guides დამხმარე ხაზების ჩვენება + Show Mask ნიღბის ჩვენება + Show Mesh ბადის ჩვენება + Show Overlay გადაფარვის ჩვენება + Show Transform Controls გარდაქმნის მართვის ელემენტების ჩვენება + Show or hide the Notes panel შენიშვნების პანელის ჩვენება ან დამალვა + Show: ჩვენება: + Sides გვერდები + Sides: გვერდები: + Size ზომა + Size Jitter ზომის ცვალებადობა + Size: ზომა: + Slice Options… ნაჭრის პარამეტრები… + Slices From Guides ნაჭრები დამხმარე ხაზებიდან + Slices are locked (View › Lock Slices) ნაჭრები ჩაკეტილია (ხედი › ნაჭრების ჩაკეტვა) + Small Caps პატარა მთავრული + Smoothing დაგლუვება + Space After ინტერვალი შემდეგ + Space Before ინტერვალი წინ + Spacing ინტერვალი + Spell check complete. მართლწერის შემოწმება დასრულდა. + Split: გაყოფა: + Star ratio ვარსკვლავის თანაფარდობა + Stop playing/recording დაკვრის/ჩაწერის შეჩერება + Straighten გასწორება + Straighten Layer შრის გასწორება + Strength: სიძლიერე: + Strikethrough გადახაზული + Stroke კონტური + Stroke Catch-up მონასმის დაწევა + Stroke: კონტური: + Location: მდებარეობა: + Style სტილი + Style Options სტილის პარამეტრები + Style Options… სტილის პარამეტრები… + Style: Normal სტილი: ჩვეულებრივი + Subtract from Sample ნიმუშს გამოკლება + Subtract from selection ({key}) მონიშნულს გამოკლება ({key}) + Suggestions: შეთავაზებები: + Swap colours (X) ფერების გაცვლა (X) + Swaps height and width სიმაღლისა და სიგანის გაცვლა + Switch between free transform and warp modes თავისუფალი გარდაქმნისა და დეფორმაციის რეჟიმებს შორის გადართვა + Switch theme თემის გადართვა + Temperature ტემპერატურა + Tetrahedral ტეტრაედრული + Text Position: ტექსტის პოზიცია: + Texture ტექსტურა + Texture Each Tip ტექსტურა თითოეულ წვერზე + Threshold ზღვარი + Threshold Level ზღვრის დონე + Thumbnail size (smallest: list) ესკიზის ზომა (უმცირესი: სია) + Tilt X X-დახრა + Tilt Y Y-დახრა + Tint ტონირება + Toggle count group visibility დათვლის ჯგუფის ხილვადობის გადართვა + Toggle text orientation ტექსტის ორიენტაციის გადართვა + Tolerance დაშვება + Tolerance: დაშვება: + Tone: ტონი: + Toolbar ხელსაწყოთა ზოლი + Top ზემოთ + Total: სულ: + Tracking ტრეკინგი + Tracking (1/1000 em) ტრეკინგი (1/1000 em) + Transparency გამჭვირვალობა + Transparent გამჭვირვალე + Trilinear სამწრფივი + Type Styles ტექსტის სტილები + Type: ტიპი: + Underline ხაზგასმული + Untitled უსათაურო + Untitled-1 უსათაურო-1 + Update layer comp (visibility, position and appearance) შრეების კომპოზიციის განახლება (ხილვადობა, პოზიცია და იერსახე) + Use Default ნაგულისხმევის გამოყენება + Use Legacy ძველი ვერსიის გამოყენება + Use Measurement Scale საზომი მასშტაბის გამოყენება + Variables bind a layer's visibility, text or pixels to a named data slot. ცვლადები შრის ხილვადობას, ტექსტს ან პიქსელებს აკავშირებს მონაცემთა დასახელებულ ველთან. + Vertical scale ვერტიკალური მასშტაბი + Vibrance სიხალასე + View Options ხედის პარამეტრები + Vignetting ვინიეტირება + Violet, Orange იისფერი, ნარინჯისფერი + Visibility ხილვადობა + Visible ხილული + Warp: დეფორმაცია: + Web Snap ვებ-ფერებზე მიბმა + Weight სისქე + Weight: სისქე: + White თეთრი + White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია + Whites თეთრები + Width სიგანე + Slant დახრა + Width: სიგანე: + Work Path სამუშაო ტრაექტორია + Yellow ყვითელი + Yellow · Blue ყვითელი · ლურჯი + Zoom მასშტაბი + Zoom in გადიდება + Zoom out დაპატარავება + command or shortcut ბრძანება ან მალსახმობი + current მიმდინარე + image file path გამოსახულების ფაილის მისამართი + modified შეცვლილი + name სახელი + new ახალი + {name} options {name}: პარამეტრები + ↑↓ navigate {key} run esc close ↑↓ ნავიგაცია {key} გაშვება esc დახურვა + Version {version} ვერსია {version} + New Document ახალი დოკუმენტი + New Document With Mask ახალი დოკუმენტი ნიღბით + New Layer With Mask ახალი შრე ნიღბით + Preset Name პრესეტის სახელი + No saved presets yet. შენახული პრესეტები ჯერ არ არის. + Error შეცდომა + Command ბრძანება + Adjustment layers have nothing to transform მაკორექტირებელ შრეებში გარდასაქმნელი არაფერია + Altitude სიმაღლე + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua ზღვისფერი + Art & Illustration ხელოვნება და ილუსტრაცია + ArtCraft ArtCraft + Background Eraser Tool ფონის საშლელის ხელსაწყო + Bevel & Emboss ფასკა და რელიეფი + Blend Mode შერევის რეჟიმი + Blues ლურჯები + Blur Tool გაბუნდოვნების ხელსაწყო + Bold მუქი + Brush ფუნჯი + Block ბლოკი + Brush Pose ფუნჯის პოზა + Brush Tip Shape ფუნჯის წვერის ფორმა + Brush Tool ფუნჯის ხელსაწყო + Build-up აეროგრაფი + Burn Tool გამამუქებლის ხელსაწყო + Centimeters სანტიმეტრი + Choke შევიწროება + Classic კლასიკური + Click to add text დაწკაპეთ ტექსტის დასამატებლად + Click to fill similar colours დაწკაპეთ მსგავსი ფერების შესავსებად + Clone Stamp Tool კლონირების შტამპის ხელსაწყო + Color Burn ფერით ჩამუქება + Color Dodge ფერით გაღიავება + Color Dynamics ფერის დინამიკა + Color Overlay ფერის გადაფარვა + Comic Book კომიქსი + Conform მორგება + Content-Aware Fill needs a selection on a pixel layer შიგთავსის გათვალისწინებით შევსებას სჭირდება მონიშნული პიქსელურ შრეზე + Could not transform: the layer is empty გარდაქმნა ვერ მოხერხდა: შრე ცარიელია + Could not use Perspective Warp: the layer is empty პერსპექტიული დეფორმაცია ვერ გამოიყენება: შრე ცარიელია + Could not use Puppet Warp: no opaque pixels მარიონეტული დეფორმაცია ვერ გამოიყენება: გაუმჭვირვალე პიქსელები არ არის + Could not use Puppet Warp: the layer is empty მარიონეტული დეფორმაცია ვერ გამოიყენება: შრე ცარიელია + Crop Tool ჩამოჭრის ხელსაწყო + Crosswise განივად + Current Tool მიმდინარე ხელსაწყო + Custom Shape Tool მორგებული ფიგურის ხელსაწყო + Cyans ცისფრები + Default PhotoCraft Size ნაგულისხმევი ზომა + Delete Workspace სამუშაო სივრცის წაშლა + Diamond ბრილიანტი + Dimensions: ზომები: + Direction მიმართულება + Distance მანძილი + Document Dimensions დოკუმენტის ზომები + Document Profile დოკუმენტის პროფილი + Document Sizes დოკუმენტის ზომები + Dodge Tool გამაღიავებლის ხელსაწყო + Down ქვემოთ + Drag to draw a gradient გადაათრიეთ გრადიენტის დასახაზად + Drag to move the active layer გადაათრიეთ აქტიური შრის გადასაადგილებლად + Drag to pan · hold Space with any tool გადაათრიეთ ხედის გადასაწევად · ან ნებისმიერ ხელსაწყოსთან დააჭირეთ Space-ს + Drop Shadow ჩრდილი + Dual Brush ორმაგი ფუნჯი + Edge კიდე + Efficiency ეფექტიანობა + Efficiency: 100% ეფექტიანობა: 100% + Ellipse Tool ელიფსის ხელსაწყო + Elliptical Marquee Tool ელიფსური მონიშვნის ხელსაწყო + Single Row Marquee Tool ერთი სტრიქონის მონიშვნის ხელსაწყო + Single Column Marquee Tool ერთი სვეტის მონიშვნის ხელსაწყო + Emboss რელიეფი + Eraser საშლელი + Eraser Tool საშლელის ხელსაწყო + ExtraBold ძალიან მუქი + ExtraLight ძალიან ღია + Eyedropper Tool პიპეტის ხელსაწყო + Fade მილევა + Fill Opacity შევსების გაუმჭვირვალობა + Film & Video კინო და ვიდეო + From საწყისი + General ზოგადი + GitHub GitHub + Gradient Overlay გრადიენტის გადაფარვა + Gradient Tool გრადიენტის ხელსაწყო + Greens მწვანეები + Hand Tool ხელის ხელსაწყო + Hard მკვეთრი + Hard Mix მკვეთრი შერევა + Healing Brush Tool აღმდგენი ფუნჯის ხელსაწყო + Highlights from: ღია ტონები, საწყისი: + History Brush Tool ისტორიის ფუნჯის ხელსაწყო + Horizontal Type Tool ჰორიზონტალური ტექსტის ხელსაწყო + Horizontal Type Mask Tool ჰორიზონტალური ტექსტური ნიღბის ხელსაწყო + Vertical Type Mask Tool ვერტიკალური ტექსტური ნიღბის ხელსაწყო + Image Size: გამოსახულების ზომა: + Inches დუიმი + Indexed ინდექსირებული + Initial Direction საწყისი მიმართულება + Inner Bevel შიდა ფასკა + Inner Glow შიდა ნათება + Inner Shadow შიდა ჩრდილი + Lab Lab + Landscape, 10 x 8 ჰორიზონტალური, 10 x 8 + Landscape, 6 x 4 ჰორიზონტალური, 6 x 4 + Landscape, 7 x 5 ჰორიზონტალური, 7 x 5 + Lasso ლასო + Lasso Tool ლასოს ხელსაწყო + Layer Count შრეების რაოდენობა + Layer Knocks Out Drop Shadow შრე ჩრდილს ამოკვეთს + Legal Legal + Letter Letter + Light ღია + Line Tool ხაზის ხელსაწყო + Linear წრფივი + Linear Burn წრფივი ჩამუქება + Linear Height წრფივი სიმაღლე + Link with Layer შრესთან დაკავშირება + Lorem Ipsum Lorem Ipsum + Magentas მეწამულები + Magic Eraser Tool ჯადოსნური საშლელის ხელსაწყო + Magic Wand Tool ჯადოსნური ჯოხის ხელსაწყო + Marquee მონიშვნა + Mean: საშუალო: + Measurement Scale საზომი მასშტაბი + Measurement Scale Marker საზომი მასშტაბის მარკერი + Millimeters მილიმეტრი + Mobile მობილური + Move Tool გადაადგილების ხელსაწყო + Could not use the move tool because the layer is locked. გადაადგილების ხელსაწყო ვერ გამოიყენება, რადგან შრე ჩაკეტილია. + Could not complete the Free Transform command because the layer is locked. თავისუფალი გარდაქმნის ბრძანება ვერ შესრულდა, რადგან შრე ჩაკეტილია. + Could not transform because the layer is locked. გარდაქმნა ვერ მოხერხდა, რადგან შრე ჩაკეტილია. + Multiply გამრავლება + My Brushes ჩემი ფუნჯები + Neutrals ნეიტრალურები + New Workspace ახალი სამუშაო სივრცე + Note Tool შენიშვნის ხელსაწყო + Object Selection Tool ობიექტის მონიშვნის ხელსაწყო + Off გამორთული + Open გახსნა + Orange ნარინჯისფერი + Original Ratio საწყისი პროპორცია + Out Of Gamut ფერთა დიაპაზონს გარეთ + Outer Bevel გარე ფასკა + Outer Glow გარე ნათება + Overlay გადაფარვა + Paint Bucket Tool საღებავის ვედროს ხელსაწყო + Paints from the document's opening state ხატავს დოკუმენტის გახსნისდროინდელი მდგომარეობიდან + Path Selection Tool ტრაექტორიის მონიშვნის ხელსაწყო + Direct Selection Tool პირდაპირი მონიშვნის ხელსაწყო + Add Anchor Point Tool საყრდენი წერტილის დამატების ხელსაწყო + Delete Anchor Point Tool საყრდენი წერტილის წაშლის ხელსაწყო + Click a segment to add an anchor point · {alt}-click an anchor to delete it საყრდენი წერტილის დასამატებლად დაწკაპეთ სეგმენტზე · წასაშლელად — {alt}-დაწკაპუნება საყრდენ წერტილზე + Click an anchor point to delete it · {alt}-click a segment to add one წასაშლელად დაწკაპეთ საყრდენ წერტილზე · დასამატებლად — {alt}-დაწკაპუნება სეგმენტზე + Pattern Overlay პატერნის გადაფარვა + Pen Pressure კალმის დაწოლა + Pen Tilt კალმის დახრა + Pen Tool კალმის ხელსაწყო + Convert Point Tool წერტილის გარდაქმნის ხელსაწყო + Photo ფოტო + PhotoCraft website PhotoCraft-ის ვებსაიტი + Picas პიკა + Pillow Emboss ბალიშისებრი რელიეფი + Pixel Replacement პიქსელების ჩანაცვლება + Pixels პიქსელი + Points პუნქტი + Polygon Tool მრავალკუთხედის ხელსაწყო + Polygonal Lasso Tool მრავალკუთხა ლასოს ხელსაწყო + Portrait, 4 x 6 ვერტიკალური, 4 x 6 + Portrait, 5 x 7 ვერტიკალური, 5 x 7 + PostScript (72 points/inch) PostScript (72 პუნქტი/დუიმი) + Postcard ღია ბარათი + Poster პოსტერი + Pro (Dark) Pro (ბნელი) + Pro (Light) Pro (ღია) + Pro (Light Gray) Pro (ღია ნაცრისფერი) + Pro (Medium Gray) Pro (საშუალო ნაცრისფერი) + Protect Texture ტექსტურის დაცვა + Purple იისფერი + Quick Mask სწრაფი ნიღაბი + Quick Selection Tool სწრაფი მონიშვნის ხელსაწყო + Radial რადიალური + Rasterize Layer შრის რასტრირება + Rasterize Layers შრეების რასტრირება + Ratio პროპორცია + Recent ბოლოდროინდელი + Rectangle Tool მართკუთხედის ხელსაწყო + Rectangular Marquee Tool მართკუთხა მონიშვნის ხელსაწყო + Reds წითლები + Reflected არეკლილი + Sample: foreground colour ნიმუში: წინა პლანის ფერი + Sampled Colors ნიმუშად აღებული ფერები + Satin ატლასი + Save Pixel Aspect Ratio პიქსელის გვერდების თანაფარდობის შენახვა + Scattering გაბნევა + Scratch Sizes დროებითი დისკის ზომები + Select Data Points მონაცემთა წერტილების მონიშვნა + Selection მონიშნული + SemiBold ნახევრად მუქი + Shadows up to: ჩრდილები, ზედა ზღვარი: + Shape Dynamics ფორმის დინამიკა + Sharpen Tool გამკვეთრების ხელსაწყო + Show Extras Options დამატებითი ელემენტების პარამეტრების ჩვენება + Slice Select Tool ნაჭრის მონიშვნის ხელსაწყო + Slice Tool ნაჭრის ხელსაწყო + Smart Filters ჭკვიანი ფილტრები + Smudge Tool გადღაბნის ხელსაწყო + Soft რბილი + Soften დარბილება + Source წყარო + Source: set წყარო: დაყენებულია + Sponge Tool ღრუბლის ხელსაწყო + Spot Healing Brush Tool წერტილოვანი აღმდგენი ფუნჯის ხელსაწყო + Spread გავრცობა + Square, 12 x 12 კვადრატი, 12 x 12 + Square, 5 x 5 კვადრატი, 5 x 5 + Std Dev: სტანდ. გადახრა: + Studio (Dark) Studio (ბნელი) + Studio (Light) Studio (ღია) + Stylus Wheel სტილუსის ბორბალი + Subtract გამოკლება + Tabloid Tabloid + Text Replacement ტექსტის ჩანაცვლება + Thin თხელი + Traditional (72.27 points/inch) ტრადიციული (72.27 პუნქტი/დუიმი) + Transfer გადაცემა + Triangle Tool სამკუთხედის ხელსაწყო + Unsaved changes შეუნახავი ცვლილებები + Uses the foreground and background colours. იყენებს წინა პლანისა და ფონის ფერებს. + Web ვები + Web Large ვები, დიდი + Web Minimum ვები, მინიმალური + Web Most Common ვები, ყველაზე გავრცელებული + WebP (lossless) WebP (უდანაკარგო) + Wet Edges სველი კიდეები + Yellows ყვითლები + Zoom Tool მასშტაბის ხელსაწყო + {name} Properties {name}: თვისებები + Revert “{name}” to the last saved version? Your changes will be lost. დაუბრუნდეს „{name}“ ბოლოს შენახულ ვერსიას? თქვენი ცვლილებები დაიკარგება. + Do you want to save the changes you made to “{name}” before quitting? გსურთ, გასვლამდე შეინახოთ „{name}“-ში შეტანილი ცვლილებები? + Do you want to save the changes you made to “{name}” before closing? გსურთ, დახურვამდე შეინახოთ „{name}“-ში შეტანილი ცვლილებები? + Doc: {flat}/{layered} დოკ.: {flat}/{layered} + Scratch: {size} დროებითი: {size} + Untagged {mode} {mode} ტეგის გარეშე + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch გარჩევადობა: {dpi} პიქსელი/დუიმი + Doc: {w} × {h} px დოკ.: {w} × {h} px + Frame {current} / {total} კადრი {current} / {total} + Count: {n} რაოდენობა: {n} + BLANK DOCUMENT PRESETS ({n}) ცარიელი დოკუმენტის პრესეტები ({n}) + Constraints ({n}) შეზღუდვები ({n}) + Align {what} სწორება: {what} +@plural {n} Layer|{n} Layers შრე: {n} +@plural {n} layer|{n} layers შრე: {n} +@plural Group · {n} layer|Group · {n} layers ჯგუფი · შრე: {n} + Additional plugins folder პლაგინების დამატებითი საქაღალდე + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control აგენტის მიერ მართვის დაშვება + Allow scripts to connect სკრიპტების დაკავშირების დაშვება + Always ყოველთვის + Always create smart objects when placing განთავსებისას ყოველთვის ჭკვიანი ობიექტების შექმნა + Animated zoom ანიმირებული მასშტაბირება + Apply auto tone ავტოტონის გამოყენება + Ask კითხვა + Ask before saving layered tiff შრეებიანი TIFF-ის შენახვამდე კითხვა + Auto collapse icon panels ხატულების პანელების ავტომატური ჩაკეცვა + Auto show hidden panels დამალული პანელების ავტომატური ჩვენება + Auto show home screen საწყისი ეკრანის ავტომატური ჩვენება + Autosave ავტოშენახვა + Autosave minutes ავტოშენახვის ინტერვალი (წუთი) + Beep when done დასრულებისას ხმოვანი სიგნალი + Bicubic automatic ბიკუბური, ავტომატური + Bicubic sharper ბიკუბური, უფრო მკვეთრი + Bicubic smoother ბიკუბური, უფრო გლუვი + Bit depth ბიტური სიღრმე + Brush preview color ფუნჯის წინასწარი ნახვის ფერი + Cache levels ქეშის დონეები + Cache tile size ქეშის ფილის ზომა + Camera Raw Defaults Camera Raw-ის ნაგულისხმევი პარამეტრები + Canvas border ტილოს საზღვარი + Show canvas dots ტილოს წერტილების ჩვენება + Canvas color ტილოს ფერი + Canvas custom color ტილოს მორგებული ფერი + Color picker ფერის ამრჩევი + Color space ფერთა სივრცე + Column width სვეტის სიგანე + Concise მოკლე + Control port მართვის პორტი + Convert to srgb sRGB-ად გარდაქმნა + Cursors კურსორები + Custom dark მორგებული ბნელი + Custom light მორგებული ღია + Dark ბნელი + Dark gray მუქი ნაცრისფერი + Dashed lines წყვეტილი ხაზები + Destination დანიშნულება + Detailed დეტალური + Disks დისკები + Display p3 Display P3 + Double click layer mask launches select and mask შრის ნიღაბზე ორმაგი დაწკაპუნება ხსნის „მონიშვნა და ნიღაბს“ + Drop shadow ჩრდილი + Dynamic color sliders ფერის დინამიკური სლაიდერები + East asian აღმოსავლეთაზიური + Effect cache mb ეფექტების ქეში (მბ) + Enable flick panning ინერციული გადაწევის ჩართვა + Enable floating document window docking მცურავი დოკუმენტის ფანჯრების მიმაგრების ჩართვა + Enable narrow options bar ვიწრო პარამეტრების ზოლის ჩართვა + Enabled ჩართულია + Enhanced Controls გაუმჯობესებული მართვის ელემენტები + Export clipboard გაცვლის ბუფერის ექსპორტი + Extra large ძალიან დიდი + File Handling ფაილებთან მუშაობა + File path ფაილის მისამართი + Fill new type layers with placeholder ახალი ტექსტური შრეების შევსება ჩანაცვლების ტექსტით + Font preview შრიფტის წინასწარი ნახვა + Full size tip სრული ზომის წვერი + Gamut warning color ფერთა გამიდან გასვლის გაფრთხილების ფერი + Gamut warning opacity ფერთა გამიდან გასვლის გაფრთხილების გაუმჭვირვალობა + Enable Generator გენერატორის ჩართვა + Gif GIF + Glossy პრიალა + Grid color ბადის ფერი + Grid colors ბადის ფერები + Grid size ბადის ზომა + Grid style ბადის სტილი + Grid unit ბადის ერთეული + Gridline every ბადის ხაზი ყოველ + Guide color დამხმარე ხაზის ფერი + Guide style დამხმარე ხაზის სტილი + Guides, Grid & Slices დამხმარე ხაზები, ბადე და ნაჭრები + Gutter შუალედი + History Log ისტორიის ჟურნალი + History states ისტორიის მდგომარეობები + Ignore exif profile tag EXIF პროფილის ტეგის უგულებელყოფა + Image interpolation გამოსახულების ინტერპოლაცია + Image previews გამოსახულების მინიატურები + Integrations ინტეგრაციები + Interface ინტერფეისი + Jpeg quality JPEG-ის ხარისხი + Jpg JPG + Language ენა + Large tabs დიდი ჩანართები + Legacy compositing ძველი კომპოზიტინგი + Light gray ღია ნაცრისფერი + Line ხაზი + Lines ხაზები + Lossless უდანაკარგო + Lowercase extension გაფართოება პატარა ასოებით + Maximize psd compatibility PSD-თავსებადობის მაქსიმიზაცია + Medium gray საშუალო ნაცრისფერი + Memory usage mb მეხსიერების გამოყენება (მბ) + Low resolution previews დაბალი გარჩევადობის მინიატურები + Missing glyph protection დაკარგული გლიფებისგან დაცვა + Modern თანამედროვე + Nearest neighbor უახლოესი მეზობელი + Never არასოდეს + Normal tip ჩვეულებრივი წვერი + Open as smart object ჭკვიან ობიექტად გახსნა + Open in camera raw Camera Raw-ში გახსნა + Open documents as tabs დოკუმენტების ჩანართებად გახსნა + Overscroll საზღვრებს მიღმა გადახვევა + Performance წარმადობა + Png PNG + Point size პუნქტის ზომა + Precise ზუსტი + Preserve details დეტალების შენარჩუნება + Print resolution ბეჭდვის გარჩევადობა + Pro Pro + Pro light Pro ღია + Pro light gray Pro ღია ნაცრისფერი + Pro medium Pro საშუალო + Pro photo ProPhoto + Quick export format სწრაფი ექსპორტის ფორმატი + Quick export location სწრაფი ექსპორტის მდებარეობა + Recent file count ბოლოს გახსნილი ფაილების რაოდენობა + Recent files ბოლოს გახსნილი ფაილები + Recent fonts ბოლოდროინდელი შრიფტები + Recover on launch გაშვებისას აღდგენა + Remember workspace changes სამუშაო სივრცის ცვლილებების დამახსოვრება + Resize image during place განთავსებისას გამოსახულების ზომის შეცვლა + Rotate view with trackpad ხედის შემობრუნება სენსორული პანელით + Same folder იგივე საქაღალდე + Save in background ფონურ რეჟიმში შენახვა + Scratch Disks დროებითი დისკები + Screen ეკრანი + Screen resolution ეკრანის გარჩევადობა + Scrubby slider acceleration მცოცავი სლაიდერის აჩქარება + Sessions only მხოლოდ სესიები + Sharpen for გამკვეთრება: + Show channels in color არხების ფერად ჩვენება + Show crosshair in brush tip ფუნჯის წვერში ჯვრის ჩვენება + Show extension panels გაფართოების პანელების ჩვენება + Show font names in english შრიფტების სახელების ინგლისურად ჩვენება + Show menu colors მენიუს ფერების ჩვენება + Show only crosshair while painting ხატვისას მხოლოდ ჯვრის ჩვენება + Show slice numbers ნაჭრების ნომრების ჩვენება + Show tooltips მინიშნებების ჩვენება + Show highlight on rollover მიტანისას გამოკვეთის ჩვენება + Show transformation values გარდაქმნის მნიშვნელობების ჩვენება + Slice color ნაჭრის ფერი + Smart guide color ჭკვიანი დამხმარე ხაზების ფერი + Smart quotes ჭკვიანი ბრჭყალები + Snap vector tools and transforms to pixel grid ვექტორული ხელსაწყოებისა და გარდაქმნების პიქსელურ ბადეზე მიბმა + Srgb sRGB + Standard სტანდარტული + Studio Studio + Studio light Studio ღია + Adwaita Adwaita + Adwaita dark Adwaita ბნელი + Subdivisions ქვედანაყოფები + System სისტემა + Text engine ტექსტის ძრავა + Text file ტექსტური ფაილი + Tiny ძალიან პატარა + Touch gestures სენსორული ჟესტები + Transparency & Gamut გამჭვირვალობა და ფერთა დიაპაზონი + Type units ტექსტის ერთეულები + UI font size ინტერფეისის შრიფტის ზომა + UI scale ინტერფეისის მასშტაბი + Units & Rulers ერთეულები და სახაზავები + Use additional plugins folder პლაგინების დამატებითი საქაღალდის გამოყენება + Use esc to commit დადასტურება Esc-ით + Use gpu GPU-ის გამოყენება + Use legacy free transform ძველი თავისუფალი გარდაქმნის გამოყენება + Use shift key for tool switch ხელსაწყოს გადართვა Shift კლავიშით + Vary round brush hardness on hud მრგვალი ფუნჯის სიხისტის ცვლა HUD-ზე + Webp WebP + World ready მსოფლიო (World-Ready) + Zoom clicked point to center დაწკაპუნებული წერტილის ცენტრში მოქცევა მასშტაბირებისას + Zoom resizes windows მასშტაბირება ფანჯრების ზომას ცვლის + Zoom with scroll wheel მასშტაბირება გორგოლაჭით + Zoom with trackpad pinch მასშტაბირება სენსორულ პანელზე თითების მოჭერით + Pass Through გატარება + Dissolve გაფანტვა + Darker Color უფრო მუქი ფერი + Linear Dodge (Add) წრფივი გაღიავება (დამატება) + Lighter Color უფრო ღია ფერი + Soft Light რბილი სინათლე + Hard Light მკვეთრი სინათლე + Vivid Light კაშკაშა სინათლე + Linear Light წრფივი სინათლე + Pin Light წერტილოვანი სინათლე + Exclusion გამორიცხვა + Divide გაყოფა + Luminosity ნათება +@plural {n} sample|{n} samples {n} ნიმუში + {added}, {removed} subtracted {added}, გამოკლებულია {removed} + Adjustment Preview კორექციის გადახედვა + Airbrush: keeps depositing paint while the pointer rests. აეროგრაფი: საღებავის დატანა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. + Angle: კუთხე: + Blend If შერევა, თუ + Close Tab Group ჩანართების ჯგუფის დახურვა + Collapse ჩაკეცვა + Collapse All Groups ყველა ჯგუფის ჩაკეცვა + Collapse Panel Group პანელების ჯგუფის ჩაკეცვა + Collapse group ({key}-click: all groups) ჯგუფის ჩაკეცვა ({key}+დაწკაპუნება: ყველა ჯგუფი) + Create new brush from the current settings ახალი ფუნჯის შექმნა მიმდინარე პარამეტრებით + Create new brush from these settings ახალი ფუნჯის შექმნა ამ პარამეტრებით + Update the current brush with these settings მიმდინარე ფუნჯის განახლება ამ პარამეტრებით + Delete brush ფუნჯის წაშლა + Drag the arrow to set the angle, the dots to set the roundness გადაათრიეთ ისარი კუთხის დასაყენებლად, წერტილები — სიმრგვალისთვის + Expand გაშლა + Expand Panel Group პანელების ჯგუფის გაშლა + Expand group ({key}-click: all groups) ჯგუფის გაშლა ({key}+დაწკაპუნება: ყველა ჯგუფი) + Fade steps მილევის ბიჯები + Hide the layer's effects ({key}-click: all layers) შრის ეფექტების დამალვა ({key}+დაწკაპუნება: ყველა შრე) + Import Brushes… (.abr) ფუნჯების იმპორტი… (.abr) + Move Group Down ჯგუფის ჩამოწევა + Move Group Up ჯგუფის აწევა + Reset the brush to the defaults ფუნჯის ნაგულისხმევ პარამეტრებზე დაბრუნება + Restore original size საწყისი ზომის აღდგენა + Roundness: სიმრგვალე: + Search Brushes ფუნჯების ძიება + Show the layer's effects ({key}-click: all layers) შრის ეფექტების ჩვენება ({key}+დაწკაპუნება: ყველა შრე) + Brush picker ფუნჯის ამრჩევი + Erase წაშლა + Right click with painting tools მარჯვენა დაწკაპუნება სახატავი ხელსაწყოებით + System Info… სისტემის ინფორმაცია… + System Info სისტემის ინფორმაცია + More panels სხვა პანელები + More documents სხვა დოკუმენტები + These settings aren't available in PhotoCraft yet. ეს პარამეტრები PhotoCraft-ში ჯერ ხელმისაწვდომი არ არის. + Graphics გრაფიკა + Reset GPU Backend GPU-ის ბექენდის ჩამოყრა + Applies at next launch. ძალაში შედის შემდეგი გაშვებისას. + Click to sample the foreground colour · {key}-click for background დაწკაპეთ წინა პლანის ფერის ნიმუშის ასაღებად · {key}+დაწკაპუნება — ფონისთვის + {key}-click to set the source {key}+დაწკაპუნება წყაროს დასაყენებლად + {key} to subtract {key} — გამოსაკლებად + Clone source {n} ({key}-click with Clone Stamp to set) კლონირების წყარო {n} (დასაყენებლად {key}+დაწკაპუნება კლონირების შტამპით) + Source: not set ({key}-click with Clone Stamp) წყარო: არ არის დაყენებული ({key}+დაწკაპუნება კლონირების შტამპით) + Pixel Layer პიქსელური შრე + Type Layer ტექსტური შრე + Shape Layer ფიგურის შრე + Group Layer ჯგუფის შრე + Fill Layer შევსების შრე + Smart Object Layer ჭკვიანი ობიექტის შრე + Create Warped Text დეფორმირებული ტექსტის შექმნა + Rasterize Smart Object ჭკვიანი ობიექტის რასტრირება + Rasterize Fill Content შევსების შიგთავსის რასტრირება + Rasterize Layer Style შრის სტილის რასტრირება + Unlock: picking a preset replaces these settings განბლოკვა: პრესეტის არჩევა ამ პარამეტრებს ჩაანაცვლებს + Lock: keep these settings when picking another preset ჩაკეტვა: ამ პარამეტრების შენარჩუნება სხვა პრესეტის არჩევისას + Diameter: დიამეტრი: + Hardness: სიხისტე: + No brush presets ფუნჯის პრესეტები არ არის + Toggle the Brush Settings panel (F5) ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა (F5) + Create new brush preset from the current settings ფუნჯის ახალი პრესეტის შექმნა მიმდინარე პარამეტრებით + Brush Settings… (F5): every option of this brush ფუნჯის პარამეტრები… (F5): ამ ფუნჯის ყველა პარამეტრი + Rename Brush… ფუნჯის სახელის შეცვლა… + Delete Brush ფუნჯის წაშლა + New Brush Preset… ფუნჯის ახალი პრესეტი… + Brush Preset Options ფუნჯის პრესეტის პარამეტრები + Group name ჯგუფის სახელი + Brush name ფუნჯის სახელი + Brush Name ფუნჯის სახელი + Brush Stroke ფუნჯის მონასმი + Off: the speed of the pointer sets the spacing გამორთული: ინტერვალს მაჩვენებლის სიჩქარე განსაზღვრავს + Tilt Scale დახრის მასშტაბი + Brush Projection ფუნჯის პროექცია + Apply the pen's tilt and rotation to the tip shape კალმის დახრისა და შემობრუნების გამოყენება წვერის ფორმაზე + Mixer Brush შემრევი ფუნჯი + Wet სინოტივე + Mix მიქსი + Wetness Jitter სინოტივის ცვალებადობა + Mix Jitter მიქსის ცვალებადობა + Show the layer mask as an overlay შრის ნიღბის გადაფარვად ჩვენება + Toggle visibility ხილვადობის ჩართვა/გამორთვა + Unlink the mask from the layer ნიღბის შრისგან გათიშვა + Link the mask to the layer ნიღბის შრესთან დაკავშირება + Gradient გრადიენტი + Gradient Options გრადიენტის პარამეტრები + Align with layer შრესთან სწორება + Reset Alignment სწორების ჩამოყრა + Centre the gradient (offset 0, 0) გრადიენტის ცენტრირება (წანაცვლება 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor გრადიენტის რედაქტორი + Gradient picker გრადიენტის ამრჩევი + Gradient stops გრადიენტის გადასვლის წერტილები + Save As Preset პრესეტად შენახვა + The gradient the Gradient tool paints with გრადიენტი, რომლითაც გრადიენტის ხელსაწყო ხატავს + Adds the current gradient to the Gradients panel მიმდინარე გრადიენტის დამატება გრადიენტების პანელში + The current gradient (pick one in Window › Gradients) მიმდინარე გრადიენტი (აირჩიეთ ფანჯარა › გრადიენტები) + Select a type layer to edit its character and paragraph settings. სიმბოლოსა და აბზაცის პარამეტრების შესაცვლელად მონიშნეთ ტექსტური შრე. + Text color ტექსტის ფერი + Type Options ტექსტის პარამეტრები + Anti-aliasing method დაგლუვების მეთოდი + Horizontal type ჰორიზონტალური ტექსტი + Vertical type ვერტიკალური ტექსტი + Fill path with foreground color ტრაექტორიის წინა პლანის ფერით შევსება + Stroke path with brush ტრაექტორიის კონტური ფუნჯით + Load path as a selection ტრაექტორიის მონიშნულად ჩატვირთვა + Make work path from selection მონიშნულიდან სამუშაო ტრაექტორიის შექმნა + Add vector mask ვექტორული ნიღბის დამატება + Create new path ახალი ტრაექტორიის შექმნა + Delete path ტრაექტორიის წაშლა + Delete vector mask ვექტორული ნიღბის წაშლა + CPU (no GPU acceleration) CPU (GPU-აჩქარების გარეშე) + DirectX 12 DirectX 12 + GPU backend GPU ბექენდი + Linux display server Linux-ის ეკრანის სერვერი + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure პლანშეტის დაწოლის გამოყენება + Vulkan Vulkan + Alt-click to define a source point to clone from Alt+დაწკაპუნებით მიუთითეთ კლონირების საწყისი წერტილი + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. მონიშნულის შესაქმნელად ტრაექტორია არ არის: დახაზეთ კალმით ან მონიშნეთ ტრაექტორიების პანელში. + Cancel opening გახსნის გაუქმება + Cancelled opening {name} {name}-ის გახსნა გაუქმდა + Developing {name} მუშავდება {name} + Cancelled {label} გაუქმდა: {label} + Esc to cancel Esc — გასაუქმებლად + Opened {name} გაიხსნა {name} + Opening {name} იხსნება {name} + Saved {name} შენახულია {name} + Saving {name} ინახება {name} + A save to {name} is still running; try again when it finishes {name}-ში შენახვა ჯერ კიდევ მიმდინარეობს; სცადეთ მისი დასრულების შემდეგ + {label} is writing the file and can no longer be cancelled {label} ფაილს წერს და მისი გაუქმება აღარ შეიძლება + Opening {name}… იხსნება {name}… + Opening… იხსნება… + Reading… იკითხება… + Working… მუშავდება… +@id select.all ყველაფრის მონიშვნა +@id edit.purge.undo დაბრუნების ისტორია + Delete Smart Filters ჭკვიანი ფილტრების წაშლა + Enable Smart Filters ჭკვიანი ფილტრების ჩართვა + New Layer… ახალი შრე… + Delete Layer… შრის წაშლა… + Color Range ფერების დიაპაზონი + Select Similar Colors მსგავსი ფერების მონიშვნა + New Selection ახალი მონიშვნა + Adaptive Wide Angle ადაპტიური ფართოკუთხა + Chalk & Charcoal ცარცი და ნახშირი + Charcoal ნახშირი + Chrome ქრომი + Conté Crayon კონტეს ფანქარი + Graphic Pen გრაფიკული კალამი + Halftone Pattern ნახევარტონური პატერნი + Note Paper ქაღალდი ჩანაწერებისთვის + Photocopy ფოტოასლი + Plaster თაბაშირი + Reticulation რეტიკულაცია + Stamp შტამპი + Torn Edges დახეული კიდეები + Water Paper სველი ქაღალდი + Canvas rotation ტილოს შემობრუნება + Extensions გაფართოებები + Libraries ბიბლიოთეკები + 3D 3D + Toolbars ხელსაწყოთა ზოლები + Move გადაადგილება + Wand ჯოხი + Pencil ფანქარი + Clone Stamp კლონირების შტამპი + Healing აღდგენა + Dodge გამაღიავებელი + Burn გამამუქებელი + Pen კალამი + Hand ხელი + Sampler სინჯის ამღები + Note tool შენიშვნის ხელსაწყო + Quick Selection სწრაფი მონიშვნა + Magnetic Lasso მაგნიტური ლასო + History Brush ისტორიის ფუნჯი + Art History Brush მხატვრული ისტორიის ფუნჯი + Pattern Stamp პატერნის შტამპი + Spot Healing წერტილოვანი აღდგენა + Red Eye წითელი თვალები + Patch საკერებელი + Content-Aware Move შიგთავსის გათვალისწინებით გადაადგილება + Rotate View ხედის შემობრუნება + Perspective Crop პერსპექტიული ჩამოჭრა + Color Replacement ფერის ჩანაცვლება + Dodge/Burn გამაღიავებელი/გამამუქებელი + Dynamics დინამიკა + Tilt დახრა + Reset ჩამოყრა + Defaults restored. ნაგულისხმევი პარამეტრები აღდგენილია. + Stop შეწყვეტა + Create new set ახალი ნაკრების შექმნა + Technology Previews ტექნოლოგიების წინასწარი ვერსიები + New Group ახალი ჯგუფი + Layer Comp შრეების კომპოზიცია + Create new layer ახალი შრის შექმნა + Group · {n} layer ჯგუფი · {n} შრე + General Blend ზოგადი შერევა + Advanced Blending გაფართოებული შერევა + RGB RGB + Alpha ალფა + Spot Channel სპოტ-ფერის არხი + Working Gray Plate სამუშაო რუხი ფირფიტა + HSL/Grayscale HSL/ნაცრისფერი ტონები + Tone Curve ტონალური მრუდი + Split Toning გაყოფილი ტონირება + Lens Corrections ობიექტივის კორექციები + Camera Calibration კამერის კალიბრაცია + Snapshots სნეპშოტები + Recovery აღდგენა + Fill Light შევსების სინათლე + Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა + Sharpness სიმკვეთრე + Color Noise Reduction ფერის ხმაურის შემცირება + Luminance Smoothness სიკაშკაშის სიგლუვე + Make მწარმოებელი + Model მოდელი + Lens ობიექტივი + F F + ISO ISO + Focal Length35mm ფოკუსური მანძილი (35 მმ-ის ეკვივალენტი) + Aspect Ratio თანაფარდობა + Time დრო + Artist ავტორი + Enter description… შეიყვანეთ აღწერა… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard ინდუსტრიული სტანდარტი + Uncoated CMYK CMYK უცარცო ქაღალდისთვის + Newspaper გაზეთი + Working Spaces სამუშაო სივრცეები + Color Management Policies ფერების მართვის წესები + Detect Faces სახეების ამოცნობა + Localize Color Clusters ფერთა კლასტერების ლოკალიზება + Duplicated selection name მონიშნულის სახელი დუბლირებულია + Operation ოპერაცია + New channel only მხოლოდ ახალი არხი + Masked Areas ნიღბიანი არეები + Selected Areas მონიშნული არეები + Spot Color სპოტ-ფერი + Color indicates ფერი აღნიშნავს + Create Snapshot სნეპშოტის შექმნა + Delete Snapshot სნეპშოტის წაშლა + New Snapshot ახალი სნეპშოტი + Snapshot 1 სნეპშოტი 1 + Step Forward ნაბიჯით წინ + Step Backward ნაბიჯით უკან + Nonlinear History არაწრფივი ისტორია + Set New Snapshot as Default ახალი სნეპშოტის ნაგულისხმევად დაყენება + Automatically Create First Snapshot პირველი სნეპშოტის ავტომატურად შექმნა + Automatically Create New Snapshot When Saving შენახვისას ახალი სნეპშოტის ავტომატურად შექმნა + Show New Snapshot Dialog by Default ახალი სნეპშოტის დიალოგის ნაგულისხმევად ჩვენება + Clear History ისტორიის გასუფთავება + Panel Options… პანელის პარამეტრები… + View menu ხედის მენიუ + Keep view in sync ხედის სინქრონიზაცია + {zoom}% {zoom}% + Mouse Controls თაგვის მართვა + Initial Direction of Stroke მონასმის საწყისი მიმართულება + Direction of Stroke მონასმის მიმართულება + Humidity ტენიანობა + Auto Clean Brush ფუნჯის ავტომატური გაწმენდა + Spacing: ინტერვალი: + Brush Tip ფუნჯის წვერი + New Layer from Brush ახალი შრე ფუნჯიდან + New Brush ახალი ფუნჯი + New Tool Preset ხელსაწყოს ახალი პრესეტი + Auto-Leading ავტომატური სტრიქონთა დაშორება + Scale Horizontally ჰორიზონტალური მასშტაბირება + Scale Vertically ვერტიკალური მასშტაბირება + Baseline Shift საბაზისო ხაზის წანაცვლება + Mac LCD Mac LCD + Mac Sharp Mac მკვეთრი + Mac Crisp Mac მკაფიო + Mac Strong Mac ძლიერი + Mac Smooth Mac გლუვი + Superscript ზედა ინდექსი + Subscript ქვედა ინდექსი + Ligature ლიგატურა + Alternative ალტერნატიული + Roman რომაული + Bold Italic მუქი დახრილი + Ultra Light ულტრა ღია + Extra Light ძალიან ღია + Semi Bold ნახევრად მუქი + Extra Bold ძალიან მუქი + Ultra Bold ულტრა მუქი + Condensed შევიწროებული + First Line Indent პირველი სტრიქონის შეწევა + Hanging Punctuation ჩამოკიდებული პუნქტუაცია + Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Avoid Widows/Orphans ობოლი სტრიქონების თავიდან აცილება + Compose აწყობა + Adobe Single-line Composer Adobe-ის სტრიქონის მიხედვით აწყობა + Adobe Every-line Composer Adobe-ის აბზაცის მიხედვით აწყობა + Justification სწორება + Word Spacing სიტყვებს შორის ინტერვალი + Letter Spacing ასოებს შორის ინტერვალი + Glyph Scaling გლიფების მასშტაბირება + Auto-leading % ავტომატური სტრიქონთა დაშორება, % + Single Word Justification ერთი სიტყვის ორმხრივი სწორება + Alignment სწორება + Justify ორმხრივი სწორება + Glyph გლიფი + Alternates for Current Selection ალტერნატივები მიმდინარე მონიშნულისთვის + Digits ციფრები + Symbols სიმბოლოები + Punctuation პუნქტუაცია + Path: ტრაექტორია: + Path to Illustrator… ტრაექტორია Illustrator-ისთვის… + Make Work Path სამუშაო ტრაექტორიის შექმნა + Make Selection მონიშნულის შექმნა + Fill Path ტრაექტორიის შევსება + Stroke Path ტრაექტორიის კონტური + Clipping Path ჩაჭრის ტრაექტორია + New Path ახალი ტრაექტორია + Duplicate Path ტრაექტორიის დუბლირება + Delete Path ტრაექტორიის წაშლა + Auto-Hide ავტომატური დამალვა + W W + H H + X X + Y Y + Frame Sync კადრების სინქრონიზაცია + Quick Sync სწრაფი სინქრონიზაცია + Audio აუდიო + Frame Duration კადრის ხანგრძლივობა + Loop ციკლი + Split at Playhead გაყოფა დაკვრის მაჩვენებელთან + Flatten Frames Into Clips კადრების კლიპებად გაერთიანება + Flatten Frames Into Layers კადრების შრეებად გაერთიანება + Convert Frames > Video Layers კადრების გარდაქმნა > ვიდეოშრეები + New Video Layer ახალი ვიდეოშრე + New Video Group ახალი ვიდეოჯგუფი + Limit Playback დაკვრის შეზღუდვა + Enable Timeline Quick Config დროის ხაზის სწრაფი კონფიგურაციის ჩართვა + Set Start Timecode… საწყისი დროის კოდის დაყენება… + Timecode დროის კოდი + 30 fps 30 კადრი/წმ + 25 fps 25 კადრი/წმ + 24 fps 24 კადრი/წმ + 15 fps 15 კადრი/წმ + 10 fps 10 კადრი/წმ + Current Time მიმდინარე დრო + Out გამოსვლა + In შესვლა + Duration ხანგრძლივობა + Repeat გამეორება + Play Once ერთხელ დაკვრა + Info Options… ინფორმაციის პარამეტრები… + First Color Readout ფერის პირველი ანათვალი + Second Color Readout ფერის მეორე ანათვალი + Actual Color ფაქტობრივი ფერი + Grayscale Slider ნაცრისფერი ტონების სლაიდერი + Web Color ვებფერი + HSB Color HSB ფერი + Total Ink საღებავის ჯამი + Opacity Mask გაუმჭვირვალობის ნიღაბი + Ruler სახაზავი + Document Size დოკუმენტის ზომა + Timing დროის აღრიცხვა + Status Information მდგომარეობის ინფორმაცია + Saved Multichannel შენახული მრავალარხიანი + Mouse Coordinates თაგვის კოორდინატები + Ruler Units სახაზავის ერთეულები + Hide დამალვა + New Layer Comp შრეების ახალი კომპოზიცია + Duplicate Layer Comp შრეების კომპოზიციის დუბლირება + Rename Layer Comp შრეების კომპოზიციის სახელის შეცვლა + Apply Layer Comp შრეების კომპოზიციის გამოყენება + Update Layer Comp შრეების კომპოზიციის განახლება + Restore last viewed comp ბოლოს ნანახი კომპოზიციის აღდგენა + Method მეთოდი + Select All Layers ყველა შრის მონიშვნა + Keep proportions პროპორციების შენარჩუნება + Interpolation ინტერპოლაცია + Author ავტორი + Caption წარწერა + Caption Credits წარწერის ავტორები + Keywords საკვანძო სიტყვები + Copyright Info URL საავტორო უფლებების ინფორმაციის URL + Copyright Status საავტორო უფლებების სტატუსი + Copyrighted დაცულია საავტორო უფლებით + Public Domain საზოგადოებრივი საკუთრება + Unknown უცნობი + Credit ავტორობა + Transmission Reference გადაცემის მითითება + Urgency სასწრაფოობა + High მაღალი + Low დაბალი + Category კატეგორია + Supplemental Categories დამატებითი კატეგორიები + Object Name / Title ობიექტის სახელი / სათაური + Date Created შექმნის თარიღი + City ქალაქი + Location მდებარეობა + Province / State რეგიონი / შტატი + Country ქვეყანა + Byline ავტორის ხელმოწერა + Byline Title ავტორის თანამდებობა + Phone ტელეფონი + Email ელფოსტა + URLs URL-ები + Last Modified ბოლო ცვლილება + EXIF EXIF + GPS GPS + Camera Data კამერის მონაცემები + Camera Model კამერის მოდელი + Camera Make კამერის მწარმოებელი + EXIF Version EXIF-ის ვერსია + Exposure Program ექსპოზიციის პროგრამა + Aperture დიაფრაგმა + Shutter Speed ჩამკეტის სიჩქარე + Metering Mode გაზომვის რეჟიმი + Light Source სინათლის წყარო + Flash განათება (Flash) + Lens Info ობიექტივის ინფორმაცია + ISO Speed ISO მგრძნობიარობა + Subject Distance მანძილი ობიექტამდე + DateTime Original ორიგინალის თარიღი და დრო + Create Date შექმნის თარიღი + Modify Date ცვლილების თარიღი + Software პროგრამა + Raw Data დაუმუშავებელი მონაცემები + Advanced დამატებითი + Import… იმპორტი… + PDF Presentation… PDF პრეზენტაცია… + Create Multiple Picture Packages რამდენიმე ფოტოპაკეტის შექმნა + Picture Package… ფოტოპაკეტი… + Web Photo Gallery… ვებფოტოგალერეა… + Choose… არჩევა… + Select Folder… საქაღალდის არჩევა… + Override Action "Save" Commands მოქმედების „შენახვის“ ბრძანებების უგულებელყოფა + File Naming ფაილების დასახელება + Compatibility თავსებადობა + Mac OS Mac OS + Unix Unix + Include ICC Profile ICC პროფილის ჩართვა + Error File შეცდომების ფაილი + Stop For Errors შეცდომისას შეჩერება + Log Errors To File შეცდომების ფაილში ჩაწერა + Errors შეცდომები + Settings პარამეტრები + Settings: პარამეტრები: + Working Space სამუშაო სივრცე + RGB: RGB: + CMYK: CMYK: + Gray: რუხი: + Spot: სპოტ-ფერი: + Advanced Controls დამატებითი მართვის ელემენტები + Desaturate Monitor Colors By მონიტორის ფერების გაუფერულება: + Blend RGB Colors Using Gamma 1.0 RGB ფერების შერევა გამით 1.0 + Profile Mismatches: Ask When Opening პროფილების შეუსაბამობა: გახსნისას შეკითხვა + Ask When Pasting ჩასმისას შეკითხვა + Missing Profiles: Ask When Opening აკლია პროფილები: გახსნისას შეკითხვა + Embed profiles პროფილების ჩაშენება + Ask Before Saving Saved Embedded შენახვამდე შეკითხვა ჩაშენებული პროფილის შესახებ + Coerce to sRGB sRGB-ზე იძულებით გადაყვანა + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web გრაფიკა და ვები + Minimalistic მინიმალისტური + Expand All Groups ყველა ჯგუფის ჩამოშლა + Photo (2 × 3 in) ფოტო (2 × 3 დუიმი) + Photo (4 × 6 in) ფოტო (4 × 6 დუიმი) + Photo (5 × 7 in) ფოტო (5 × 7 დუიმი) + Photo (8 × 10 in) ფოტო (8 × 10 დუიმი) + Recent Items ბოლოდროინდელი ელემენტები + Save Preset… პრესეტის შენახვა… + Delete Preset… პრესეტის წაშლა… + Preset Name: პრესეტის სახელი: + Width and Height სიგანე და სიმაღლე + Color Profile ფერის პროფილი + Units ერთეულები + Columns სვეტები + Create new count group ახალი დათვლის ჯგუფის შექმნა + Adjustment Layer მაკორექტირებელი შრე + Pencil Tool ფანქრის ხელსაწყო + Pixel პიქსელი + Adjustment კორექცია + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract გადაათრიეთ (ლასო) ან დაწკაპეთ წერტილებზე (მრავალკუთხა) · {add} დამატება · {sub} გამოკლება + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels გადაათრიეთ ჩამოჭრის ჩარჩო · შიგნით გადათრევით გადაადგილება · კიდეებით ზომის შეცვლა ({ratio} თანაფარდობა, {centre} ცენტრიდან) · Space — გადაადგილება ხაზვისას · ისრები — მცირე წანაცვლება · X — ორიენტაციის შეცვლა · {commit} — დადასტურება · Esc — გაუქმება + {mode} Color · {bits} bit {mode} ფერი · {bits} ბიტი + Drag to move the layer გადაათრიეთ შრის გადასაადგილებლად + Scrubby Zoom მასშტაბირება გადათრევით + Auto Erase ავტომატური წაშლა + Color Adaptation ფერის ადაპტაცია + Preserve Transparency გამჭვირვალობის შენარჩუნება + New Style… ახალი სტილი… + shortcut მალსახმობი + Go to Layer შრეზე გადასვლა + Saved შენახულია + After (X) შემდეგ (X) + Current მიმდინარე + Reference point location საყრდენი წერტილის მდებარეობა + Toggle reference point საყრდენი წერტილის ჩართვა/გამორთვა + Arc რკალი + Arch თაღი + Bulge ამობურცვა + Shell Lower ქვედა ნიჟარა + Shell Upper ზედა ნიჟარა + Flag დროშა + Fish თევზი + Wave ტალღა + Rise აწევა + Inflate გაბერვა + Twist გრეხა + Squeeze შეკუმშვა + Add Pins სამაგრების დამატება + Delete Pins სამაგრების წაშლა + Rotate Auto ავტომატური შემობრუნება + Rotate Fixed ფიქსირებული შემობრუნება + Create Plane სიბრტყის შექმნა + Edit Plane სიბრტყის რედაქტირება + Select Plane სიბრტყის მონიშვნა + Measure გაზომვა + Heal აღდგენა + Heal Off აღდგენა გამორთულია + Heal On აღდგენა ჩართულია + Heal Auto ავტომატური აღდგენა + Blend შერევა + Brush Size ფუნჯის ზომა + Heal Color აღდგენის ფერი + Mask ნიღაბი + Channel არხი + Rectangle მართკუთხედი + Result შედეგი + New Channel ახალი არხი + Save Selection მონიშნულის შენახვა + Load Selection მონიშნულის ჩატვირთვა + Duplicate დუბლირება + Fast სწრაფი + Accurate ზუსტი + Add to დამატება: + Subtract from გამოკლება: + Intersect with გადაკვეთა: + Capture Comp კომპოზიციის ჩაწერა + No Layer Comps შრეების კომპოზიციები არ არის + Adobe PDF Presets… Adobe PDF-ის პრესეტები… + 3D… 3D… + Technology Previews… ტექნოლოგიების წინასწარი ვერსიები… + Background from Layer ფონი შრიდან + Add a mask (from the selection; ⌥ inverts) ნიღბის დამატება (მონიშნულიდან; ⌥ შებრუნებს) + Add to selection (⇧) მონიშნულზე დამატება (⇧) + Airbrush: keeps painting while the pointer rests. აეროგრაფი: ხატვა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. + Click the first point or press ↵ to close · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ ↵-ს დასახურად · Esc — გაუქმება + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, ⌘/Ctrl+დაწკაპუნება ან Delete + Click to apply, ⌥-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, ⌥+დაწკაპუნება — გადაფარვების მოხსნა, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to zoom in · ⌥-click to zoom out დაწკაპეთ გასადიდებლად · ⌥+დაწკაპუნება — დასაპატარავებლად + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · ↩ დასრულება · Esc გაუქმება + Commit Perspective Warp (↩) პერსპექტიული დეფორმაციის დადასტურება (↩) + Commit Puppet Warp (↩) მარიონეტული დეფორმაციის დადასტურება (↩) + Commit any current edits (⌘↩) მიმდინარე ცვლილებების დადასტურება (⌘↩) + Commit current crop operation (↵) მიმდინარე ჩამოჭრის დადასტურება (↵) + Commit transform (↩) გარდაქმნის დადასტურება (↩) + Commit warp (↩) დეფორმაციის დადასტურება (↩) + Create a new layer (⇧⌘N) ახალი შრის შექმნა (⇧⌘N) + Custom pattern მორგებული პატერნი + General Brushes ზოგადი ფუნჯები + Grid (⌘') ბადე (⌘') + Intersect with selection (⇧⌥) მონიშნულთან გადაკვეთა (⇧⌥) + New document… ⌘N ახალი დოკუმენტი… ⌘N + Open… ⌘O გახსნა… ⌘O + Rulers (⌘R) სახაზავები (⌘R) + Search commands (⌘K) ბრძანებების ძიება (⌘K) + Show Guides (⌘;) დამხმარე ხაზების ჩვენება (⌘;) + Subtract from selection (⌥) მონიშნულიდან გამოკლება (⌥) + Uses the foreground and background colors. იყენებს წინა პლანისა და ფონის ფერებს. + ↑↓ navigate ↵ run esc close ↑↓ ნავიგაცია ↵ გაშვება esc დახურვა + Adds grain to the soft edges of the brush tip. ფუნჯის წვერის რბილ კიდეებს მარცვლოვნებას მატებს. + Keeps the current texture when switching presets. პრესეტების გადართვისას მიმდინარე ტექსტურას ინარჩუნებს. + Watercolour look: lighter interior, darker rims. აკვარელის ეფექტი: ღია შიგნით, მუქი კიდეები. + Collapse group (⌥-click: all groups) ჯგუფის ჩაკეცვა (⌥+დაწკაპუნება: ყველა ჯგუფი) + Expand group (⌥-click: all groups) ჯგუფის გაშლა (⌥+დაწკაპუნება: ყველა ჯგუფი) + Hide the layer's effects (⌥-click: all layers) შრის ეფექტების დამალვა (⌥+დაწკაპუნება: ყველა შრე) + Show the layer's effects (⌥-click: all layers) შრის ეფექტების ჩვენება (⌥+დაწკაპუნება: ყველა შრე) + New from Clipboard ახალი გაცვლის ბუფერიდან + Free Transform a Copy ასლის თავისუფალი გარდაქმნა + Transform Again on a Copy ასლზე გარდაქმნის გამეორება + Select Layer Above ზედა შრის მონიშვნა + Select Layer Below ქვედა შრის მონიშვნა + Select Top Layer უზედაესი შრის მონიშვნა + Select Bottom Layer უქვედაესი შრის მონიშვნა + Add Layer Above to Selection ზედა შრის მონიშნულზე დამატება + Add Layer Below to Selection ქვედა შრის მონიშნულზე დამატება + 8 bit 8 ბიტი + Integer მთელი რიცხვი + 16 bit 16 ბიტი + 32 bit (float) 32 ბიტი (მცურავი) + Floating point მცურავი წერტილი + Use Global Light გლობალური განათების გამოყენება + Native file drag-and-drop is unavailable ფაილების სისტემური გადათრევა მიუწვდომელია + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. ფაილების სისტემური გადათრევა Wayland-ზე ჯერ მხარდაჭერილი არ არის. გამოიყენეთ ფაილი › გახსნა, ან დააკოპირეთ სურათი ფაილების მენეჯერში და ჩასვით {paste}-ით. + To drop files, start PhotoCraft under XWayland: `{command}` ფაილების ჩასაგდებად გაუშვით PhotoCraft XWayland-ში: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. ან დააყენეთ პარამეტრები › წარმადობა › Linux-ის ეკრანის სერვერი X11-ზე: მაშინ PhotoCraft ყოველთვის XWayland-ში გაეშვება. + Edit › Paste რედაქტირება › ჩასმა + Use File › Open, or paste a copied image with {paste}. გამოიყენეთ ფაილი › გახსნა, ან ჩასვით დაკოპირებული სურათი {paste}-ით. + Mixer Brush Tool შემრევი ფუნჯის ხელსაწყო + Color Replacement Tool ფერის ჩანაცვლების ხელსაწყო + Invalid {mode} profile {mode} პროფილი არასწორია + Unnamed {mode} profile უსახელო {mode} პროფილი + Show bounding box when dragging layer შრის გადათრევისას შემომსაზღვრელი ჩარჩოს ჩვენება + System title bar სისტემური სათაურის ზოლი + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) ლასოთი გაყინვა: გადათრევით არე იყინება, Alt+გადათრევით — დნება (L) + Rendering Mode რენდერის რეჟიმი + Automatic (recommended) ავტომატური (რეკომენდებული) + CPU / Compatibility CPU / თავსებადობა + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. ავტომატური რეჟიმი იყენებს GPU-აჩქარებას, როცა ის ხელმისაწვდომია, ხოლო შეცდომისას CPU-რენდერზე გადადის. + Switched to CPU rendering გადართულია CPU-რენდერზე + GPU acceleration could not continue. Your documents are unchanged. GPU-აჩქარების გაგრძელება ვერ მოხერხდა. თქვენი დოკუმენტები უცვლელია. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft იყენებს CPU-ზე მომუშავე გამოსახულების კომპოზიტორს. ფანჯარამ შეიძლება მაინც გამოიყენოს თქვენი ვიდეოადაპტერი. + Retry GPU requires a restart. Save your work first. GPU-ის ხელახლა ცდას გადატვირთვა სჭირდება. ჯერ შეინახეთ ნამუშევარი. + Keep Using CPU CPU-ის გამოყენების გაგრძელება + Retry GPU GPU-ის ხელახლა ცდა + GPU Backend GPU ბექენდი + Details დეტალები + Rendering mode რენდერის რეჟიმი + Shadow Clipping Warning (U) ჩრდილების მოკვეთის გაფრთხილება (U) + Highlight Clipping Warning (O) ღია ტონების მოკვეთის გაფრთხილება (O) + Show Lab Color Readouts Lab ფერის მნიშვნელობების ჩვენება + Show Vectorscope ვექტორსკოპის ჩვენება + Show Red at 3 o'clock წითლის ჩვენება 3 საათის მიმართულებით + Show Skin Tone Indicator კანის ტონის მაჩვენებლის ჩვენება + Tone Histogram ტონების ჰისტოგრამა + Show Selected Region მონიშნული რეგიონის ჩვენება +cameraRaw Light სინათლე + Camera Raw Filter ({layer}) Camera Raw ფილტრი ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights ნათელი ტონები +cameraRaw Darks მუქი ტონები +cameraRaw Blacks შავები +cameraRaw Amount რაოდენობა +cameraRaw Tint ტონირება +cameraRaw Masking ნიღბვა +cameraRaw Blending შერევა + Patch Tool საკერებლის ხელსაწყო + Patch: საკერებელი: + Lasso around an area, then drag the selection შემოხაზეთ არე ლასოთი, შემდეგ გადაათრიეთ მონიშნული + Now drag the selection onto another area ახლა გადაათრიეთ მონიშნული სხვა არეზე + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: ბადე: + Edit Type ტექსტის რედაქტირება +@id edit.stroke კონტური… +@id edit.transform.scale მასშტაბირება +cameraRaw Red წითელი +cameraRaw Orange ნარინჯისფერი +cameraRaw Yellow ყვითელი +cameraRaw Green მწვანე +cameraRaw Aqua ზღვისფერი +cameraRaw Blue ლურჯი +cameraRaw Purple იისფერი +cameraRaw Magenta მეწამული +cameraRaw Luminance სიკაშკაშე +cameraRaw Temperature ტემპერატურა +cameraRaw Color ფერი +cameraRaw Vibrance სიხალასე +cameraRaw Color Grading ფერის კორექცია (grading) +cameraRaw Global გლობალური +cameraRaw Hue ფერის ტონი +cameraRaw Luminance Detail სიკაშკაშის დეტალები +cameraRaw Color Detail ფერის დეტალები +cameraRaw Roughness უხეშობა +cameraRaw Whites თეთრები + No patterns პატერნები არ არის + Enable {name} {name}-ის ჩართვა + Hold Alt while drawing for straight segments სწორი მონაკვეთებისთვის ხაზვისას დააჭირეთ Alt-ს + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts გადაათრიეთ ან დაწკაპეთ მრავალკუთხედის წერტილებზე · სწორი მონაკვეთებისთვის ლასოს დროს დააჭირეთ Alt-ს · {add} დამატება · {sub} ხაზვამდე — გამოკლება + Extend გაფართოება + Structure: სტრუქტურა: + Content-Aware Move Tool შიგთავსის გათვალისწინებით გადაადგილების ხელსაწყო + TIFF Options TIFF-ის პარამეტრები + Targa Options Targa-ს პარამეტრები + 24 bits/pixel 24 ბიტი/პიქსელი + 32 bits/pixel 32 ბიტი/პიქსელი + Including layers will increase file size. შრეების ჩართვა ფაილის ზომას გაზრდის. + Discard Layers and Save a Copy შრეების უგულებელყოფა და ასლის შენახვა + Don't show again აღარ აჩვენო + Save failed: {error} შენახვა ვერ მოხერხდა: {error} + Save cancelled შენახვა გაუქმდა + +# Layer Style dialog: style presets and per-effect defaults + Style {name} saved სტილი {name} შენახულია + Make Default ნაგულისხმევად დაყენება + Reset to Default ნაგულისხმევზე დაბრუნება + Couldn't place {name}: {error} {name}-ის ჩასმა ვერ მოხერხდა: {error} + Full-resolution preview unavailable სრული გარჩევადობის გადახედვა მიუწვდომელია + Magnetic Lasso Tool მაგნიტური ლასოს ხელსაწყო + Frequency სიხშირე + Use tablet pressure to change pen width კალმის სისქის შეცვლა პლანშეტის დაწოლით + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About პროგრამის შესახებ + Contributors მონაწილეები + Models მოდელები + Sort სორტირება + Username მომხმარებლის სახელი + Display name საჩვენებელი სახელი + Real name ნამდვილი სახელი + Reverse the order რიგის შებრუნება + Grab bag სხვადასხვა + Table ცხრილი + Name (A–Z) სახელი (ა–ჰ) + Merged PRs გაერთიანებული PR-ები + Commits კომიტები + Lines added დამატებული სტრიქონები + Lines deleted წაშლილი სტრიქონები + Line delta სტრიქონების სხვაობა + Binary assets added დამატებული ბინარული ფაილები + Binary assets removed წაშლილი ბინარული ფაილები + First commit პირველი კომიტი + First პირველი + Last commit ბოლო კომიტი + Last ბოლო + Contributors: {contributors} · Commits: {commits} მონაწილეები: {contributors} · კომიტები: {commits} + No contributor data was built into this copy. ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული. + No model credits were built into this copy. ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული. + Company კომპანია + Version ვერსია + % of all commits ყველა კომიტის % + Lines +/− სტრიქონები +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, კომიტი: {commits}, სტრიქონი: +{added} / −{deleted} (Δ {delta}), ბინარული ფაილი: +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch ახალი ფერის ნიმუშის შექმნა + New Swatch… ფერის ახალი ნიმუში… + New Swatch Group… ფერის ნიმუშების ახალი ჯგუფი… + Import Swatches… ფერის ნიმუშების იმპორტი… + Replace Swatches… ფერის ნიმუშების ჩანაცვლება… + Export Swatches… ფერის ნიმუშების ექსპორტი… + Export Swatches for Exchange… ფერის ნიმუშების ექსპორტი გასაცვლელად… + Reset Swatches ფერის ნიმუშების ჩამოყრა + Add to Swatches ფერის ნიმუშებში დამატება +# Layer Style > Blending Options (Advanced Blending) + Blend If: შერევა, თუ: + Channels: არხები: + Knockout ამოგდება + Apply At Canvas Bounds ტილოს საზღვრებზე გამოყენება + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) ფერის ამრჩევი (ტექსტის ფერი) + Color Picker ({name}) ფერის ამრჩევი ({name}) + Color Picker (Fill Color) ფერის ამრჩევი (შევსების ფერი) + Fill color შევსების ფერი + Color Picker (Stroke Color) ფერის ამრჩევი (კონტურის ფერი) + Stroke color კონტურის ფერი + Pick screen color ეკრანის ფერის აღება + Sample a pixel anywhere on the screen პიქსელის ნიმუშის აღება ეკრანის ნებისმიერი ადგილიდან + Click to pick · Esc to cancel დაწკაპეთ ასაღებად · Esc — გაუქმება + Pick a screen pixel or press Esc to cancel აიღეთ ეკრანის პიქსელი ან დააჭირეთ Esc-ს გასაუქმებლად + Scroll to zoom გადახვევით მასშტაბირება + Show/Hide All Panels ყველა პანელის ჩვენება/დამალვა + Show/Hide Panels პანელების ჩვენება/დამალვა + Could not open გახსნა ვერ მოხერხდა + {path}: the path is not valid Unicode; rename the file and open it again. {path}: გზა არ არის სწორი Unicode; გადაარქვით ფაილს სახელი და ხელახლა გახსენით. + +# Fill dialog, layer style contours and Image Size + Contents: შიგთავსი: + Custom Pattern: მორგებული პატერნი: + Foreground Color წინა პლანის ფერი + Color… ფერი… + 50% Gray 50% ნაცრისფერი + {size} (was {old}) {size} (იყო {old}) + Cone კონუსი + Cone (Inverted) კონუსი (შებრუნებული) + Domed გუმბათოვანი + Domed (Inverted) გუმბათოვანი (შებრუნებული) + Diagonal (Descending) დიაგონალური (დაღმავალი) + Contour Editor კონტურის რედაქტორი + Anti-aliased დაგლუვებული + Corner კუთხე + Gloss Contour ბზინვარების კონტური + Contour კონტური + Cone - Inverted კონუსი - შებრუნებული + Cove - Deep ღარი - ღრმა + Cove - Shallow ღარი - ზედაპირული + Gaussian გაუსის + Half Round ნახევარწრიული + Ring რგოლი + Ring - Double რგოლი - ორმაგი + Rolling Slope - Descending ტალღოვანი ფერდობი - დაღმავალი + Rounded Steps მომრგვალებული საფეხურები + Sawtooth 1 ხერხკბილა 1 + + Red Eye Tool წითელი თვალების ხელსაწყო + Pupil Size გუგის ზომა + Darken Amount ჩამუქების რაოდენობა + Click a red pupil to neutralize it დაწკაპეთ წითელ გუგაზე მის გასანეიტრალებლად + open source ღია კოდი + …and {n} more …და კიდევ {n} + + Eyedroppers: პიპეტები: + Set Black Point შავი წერტილის დაყენება + Set Neutral Gray Point ნეიტრალური ნაცრისფერი წერტილის დაყენება + Set White Point თეთრი წერტილის დაყენება + Unavailable in this color mode მიუწვდომელია ამ ფერის რეჟიმში + Selection Preview: მონიშნულის გადახედვა: + Black Matte შავი ფონი + White Matte თეთრი ფონი + +# Layer colour labels. + Seafoam ზღვის ქაფი + Indigo ინდიგო + Fuchsia ფუქსია + Violet იისფერი + Large document: redrawing on the CPU დიდი დოკუმენტი: გადახატვა CPU-ზე + This redraw took {seconds} s on the CPU. ამ გადახატვას CPU-ზე {seconds} წმ დასჭირდა. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. ცვლილებები, რომლებიც მთელ დოკუმენტს ცვლის, მაგალითად შრის შერევის რეჟიმი, გაუმჭვირვალობა ან ხილვადობა, მთელ დოკუმენტს ხელახლა ხატავს. + The GPU compositor wasn't used: {reason} GPU-კომპოზიტორი არ იქნა გამოყენებული: {reason} +fontWeight Thin თხელი +fontWeight ExtraLight ძალიან ღია +fontWeight Light ღია +fontWeight Regular ჩვეულებრივი +fontWeight Medium საშუალო +fontWeight SemiBold ნახევრად მუქი +fontWeight Bold მუქი +fontWeight ExtraBold ძალიან მუქი +fontWeight Black შავი +fontWeight Italic დახრილი + +# Pattern Stamp tool + Pattern Stamp Tool პატერნის შტამპის ხელსაწყო + Impressionist იმპრესიონისტული + Rotate View Tool ხედის შემობრუნების ხელსაწყო + Reset View ხედის ჩამოყრა + Drag around the centre to rotate the view · Shift constrains to 15° გადაათრიეთ ცენტრის გარშემო ხედის შესაბრუნებლად · Shift — 15°-იანი ბიჯით + +# Painting symmetry presets. + Dual Axis ორი ღერძი + Diagonal დიაგონალური + Transform Symmetry სიმეტრიის გარდაქმნა + +# Eyedropper Sample Size and Sample (#1649). + Point Sample წერტილოვანი ნიმუში + 3 by 3 Average საშუალო 3×3 + 5 by 5 Average საშუალო 5×5 + 11 by 11 Average საშუალო 11×11 + 31 by 31 Average საშუალო 31×31 + 51 by 51 Average საშუალო 51×51 + 101 by 101 Average საშუალო 101×101 + Sample Size: ნიმუშის ზომა: + All Layers No Adjustments ყველა შრე, კორექციის გარეშე + Current & Below No Adjustments მიმდინარე და ქვედა, კორექციის გარეშე + Show Sampling Ring ნიმუშის აღების რგოლის ჩვენება + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour ბეჭდვისთვის ფერთა დიაპაზონს გარეთაა: დაწკაპეთ უახლოესი დასაბეჭდი ფერისთვის + Not a web colour: click for the nearest web colour ვებფერი არ არის: დაწკაპეთ უახლოესი ვებფერისთვის + Color to Alpha… ფერიდან ალფაში… + Transparency Threshold გამჭვირვალობის ზღვარი + Opacity Threshold გაუმჭვირვალობის ზღვარი + PhotoCraft — Screen Color Picker PhotoCraft — ეკრანის ფერის ამრჩევი + + Rasterize svg groups deeper than SVG ჯგუფების რასტრირება სიღრმიდან + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 ამ ზღვარს გამორთავს; 100 დონეზე ღრმად ჩადგმული SVG ჯგუფები ყოველთვის რასტრირდება. + Rule of Thirds მესამედების წესი + Triangle სამკუთხედი + Golden Ratio ოქროს კვეთა + Golden Spiral ოქროს სპირალი + Auto Show Overlay გადაფარვის ავტომატური ჩვენება + Always Show Overlay გადაფარვის ყოველთვის ჩვენება + Never Show Overlay გადაფარვის არასოდეს ჩვენება + Cycle Overlay გადაფარვების გადართვა + Cycle Orientation ორიენტაციის გადართვა + Set the overlay options for the Crop tool ჩამოჭრის ხელსაწყოს გადაფარვის პარამეტრები + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-დაწკაპუნება სამუშაო მაგიდამ მიიღო, რომელიც Alt-გადათრევით ფანჯრებს ამოძრავებს. ფანჯრის გადაადგილების კლავიშად Super დააყენეთ, ან Alt დააფიქსირეთ: ფანჯარა › მოდიფიკატორი კლავიშები. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) ფერის ამრჩევი (შრის სტილის ფერი) + Auto Hide Notifications შეტყობინებების ავტომატური დამალვა + Notification Duration (seconds) შეტყობინების ხანგრძლივობა (წამი) + Appearance Mode იერსახის რეჟიმი + Dark Theme ბნელი თემა + Light Theme ღია თემა + Pro Medium Pro საშუალო + Studio Light Studio ღია + Next Appearance Mode შემდეგი იერსახის რეჟიმი + Sync with system სისტემასთან სინქრონიზაცია + Active აქტიური + Color Picker (Solid Color) ფერის ამრჩევი (მყარი ფერი) + Straighten the image by drawing a line on it გამოსახულების გასწორება მასზე ხაზის გავლებით + Set additional Crop options ჩამოჭრის დამატებითი პარამეტრები + Show Cropped Area ჩამოჭრილი არის ჩვენება + Enable Crop Shield ჩამოჭრის ფარის ჩართვა + Match Canvas ტილოს მიხედვით + Auto Adjust Opacity გაუმჭვირვალობის ავტომატური მორგება + Crop Shield color ჩამოჭრის ფარის ფერი + Color Picker (Crop Shield Color) ფერის ამრჩევი (ჩამოჭრის ფარის ფერი) + From Center ცენტრიდან + Link radii რადიუსების დაკავშირება + Star Ratio: ვარსკვლავის თანაფარდობა: + Create Rectangle მართკუთხედის შექმნა + Create Ellipse ელიფსის შექმნა + Create Triangle სამკუთხედის შექმნა + Create Polygon მრავალკუთხედის შექმნა + Pressure curve დაწოლის მრუდი + Click to add a point, right-click to remove დაწკაპეთ წერტილის დასამატებლად, მარჯვენა დაწკაპუნებით — წასაშლელად + Some brush presets could not be loaded ფუნჯის ზოგიერთი პრესეტი ვერ ჩაიტვირთა + Retry saving presets პრესეტების შენახვის ხელახლა ცდა + Brush preset changes are not saved: {error} ფუნჯის პრესეტების ცვლილებები არ ინახება: {error} + Keep this tab open to retain your brushes. ფუნჯების შესანარჩუნებლად ეს ჩანართი ღია დატოვეთ. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. ბრაუზერის საცავი მიუწვდომელია; ფუნჯები მხოლოდ ამ სეანსში შეინახება. მათ შესანარჩუნებლად ეს ჩანართი ღია დატოვეთ. + Saving brush presets… Keep this tab open until saving finishes. ფუნჯის პრესეტები ინახება… ეს ჩანართი ღია დატოვეთ შენახვის დასრულებამდე. + Reflect არეკვლა + Glow ნათება + Negation უარყოფა + XOR XOR + Color Burn (Paint.NET) ფერით ჩამუქება (Paint.NET) + Color Dodge (Paint.NET) ფერით გაღიავება (Paint.NET) + Stroke Options კონტურის პარამეტრები + Solid უწყვეტი + Dashed ტირეებიანი + Dotted პუნქტირი + Caps ბოლოები + Butt / Flat ბლაგვი / ბრტყელი + Corners კუთხეები + Miter მახვილი + Round მომრგვალებული + Bevel წაკვეთილი + Miter limit მახვილის ზღვარი + Dashed line ტირეებიანი ხაზი + Dash ტირე + Gap შუალედი + Dash / gap lengths × stroke width ტირის / შუალედის სიგრძეები × კონტურის სისქე + Add pair წყვილის დამატება + Remove pair წყვილის წაშლა + Dash offset ტირის წანაცვლება + Open paths use centered strokes ღია ტრაექტორიების კონტური ცენტრირებულია + Use finite lengths and a positive pattern total გამოიყენეთ სასრული სიგრძეები და ნიმუშის დადებითი ჯამური სიგრძე + No subject found — try Object Selection or the Lasso ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის მონიშვნა ან ლასო + Quick Selection found nothing there სწრაფმა მონიშვნამ იქ ვერაფერი იპოვა + Object Selection found nothing there — try a tighter rectangle ობიექტის მონიშვნამ იქ ვერაფერი იპოვა — სცადეთ უფრო ვიწრო მართკუთხედი + No subject found — try a rectangle around the object ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის გარშემო მართკუთხედი + Click the first point or press {key} to close · {del} removes a point · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ {key}-ს დასახურად · {del} წერტილს შლის · Esc აუქმებს + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close დაწკაპეთ, შემდეგ იმოძრავეთ კიდის გასწვრივ · დაწკაპეთ წერტილის დასამაგრებლად · {alt}-დაწკაპუნება — სწორი მონაკვეთი · ორმაგი დაწკაპუნება — დახურვა + Follows only edges this close to the pointer ([ and ] change it) მიჰყვება მხოლოდ მაჩვენებელთან ამდენად ახლოს მყოფ კიდეებს ([ და ] ცვლის) + Higher values follow only edges that contrast sharply with their surroundings მაღალი მნიშვნელობები მხოლოდ გარემოსთან მკვეთრად კონტრასტულ კიდეებს მიჰყვება + How often fastening points are placed by themselves რა სიხშირით ისმება დამაგრების წერტილები ავტომატურად + Appearance Settings… იერსახის პარამეტრები… + Add to favorites რჩეულებში დამატება + Remove from favorites რჩეულებიდან ამოშლა +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make მწარმოებელი +cameraData Model მოდელი +cameraData Date Taken გადაღების თარიღი +cameraData Exposure ექსპოზიცია +cameraData F-Stop დიაფრაგმა +cameraData ISO Speed ISO მგრძნობიარობა +cameraData Focal Length ფოკუსური მანძილი +cameraData Focal Length (35 mm) ფოკუსური მანძილი (35 მმ) +cameraData Lens ობიექტივი +# Shared panel docking + Activate Panel პანელის გააქტიურება + Change Panel Group პანელების ჯგუფის შეცვლა + Change Panel Layout პანელების განლაგების შეცვლა + Close Panel პანელის დახურვა + Dock Panel პანელის მიმაგრება + Float Panel მცურავი პანელი + Move Panel პანელის გადაადგილება + Group with ერთად დაჯგუფება + Panel menu პანელის მენიუ + Resize panel window პანელის ფანჯრის ზომის შეცვლა + Resize panels პანელების ზომის შეცვლა + Use Classic Mode კლასიკური რეჟიმის გამოყენება + Auto Center Preview გადახედვის ავტომატური ცენტრირება + Next Document შემდეგი დოკუმენტი + Previous Document წინა დოკუმენტი + W x H x Resolution სიგ. x სიმ. x გარჩევადობა + Front Image წინა გამოსახულება + Frame Tool ჩარჩოს ხელსაწყო + Create a new rectangular frame ახალი მართკუთხა ჩარჩოს შექმნა + Create a new elliptical frame ახალი ელიფსური ჩარჩოს შექმნა + Include in playback დაკვრაში ჩართვა + Perspective Crop Tool პერსპექტიული ჩამოჭრის ხელსაწყო + Show Grid ბადის ჩვენება + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels გადაათრიეთ ჩარჩო ან დაწკაპეთ ოთხ კუთხეზე · კუთხეები დახრილი ფიგურის კიდეებზე გადაათრიეთ · {commit} ადასტურებს · Esc აუქმებს + Artboard Tool სამუშაო დაფის ხელსაწყო + Make Portrait ვერტიკალურად + Make Landscape ჰორიზონტალურად + Add New Artboard ახალი სამუშაო დაფის დამატება + Freeform Pen Tool თავისუფალი ფორმის კალმის ხელსაწყო + Curvature Pen Tool მრუდის კალმის ხელსაწყო + Curve Fit: მრუდის მორგება: + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish დაწკაპუნება: გლუვი წერტილი · ორმაგი დაწკაპუნება: კუთხე · წერტილის გადათრევა: გადაადგილება · პირველ წერტილზე დაწკაპუნება: დახურვა · {key} დასრულება + New Crop Preset… ჩამოჭრის ახალი პრესეტი… + Delete Crop Preset… ჩამოჭრის პრესეტის წაშლა… + Select the crop presets to delete: აირჩიეთ წასაშლელი ჩამოჭრის პრესეტები: + Select the crop presets to delete აირჩიეთ წასაშლელი ჩამოჭრის პრესეტები + Enter a name for the crop preset შეიყვანეთ ჩამოჭრის პრესეტის სახელი + There is no crop size or ratio to save შესანახი ჩამოჭრის ზომა ან თანაფარდობა არ არის + Scale: მასშტაბი: + Snap to Origin სათავესთან მიბმა + Search settings… პარამეტრების ძიება… + No matching settings შესაბამისი პარამეტრები არ არის +# Edit › Content-Aware Fill workspace + Sampling Brush Tool ნიმუშის აღების ფუნჯის ხელსაწყო + Sampling Area Overlay ნიმუშის აღების არის გადაფარვა + Show Sampling Area ნიმუშის აღების არის ჩვენება + Indicates აღნიშნავს + Sampling Area ნიმუშის აღების არე + Excluded Area გამორიცხული არე + Sampling Area Options ნიმუშის აღების არის პარამეტრები + Rectangular მართკუთხა + Fill Settings შევსების პარამეტრები + Rotation Adaptation ბრუნვის ადაპტაცია + Very High ძალიან მაღალი + Full სრული + Mirror არეკვლა + Output Settings გამოტანის პარამეტრები + Output To გამოტანის ადგილი + Loading fonts… შრიფტები იტვირთება… +# Select and Mask view modes (#1754) + Onion Skin ხახვის ფენები + Marching Ants მოსიარულე ჭიანჭველები + On Black შავ ფონზე + On White თეთრ ფონზე + On Layers შრეებზე + Show Edge კიდის ჩვენება + Show Original ორიგინალის ჩვენება + F cycles the views; hold X to see the image F ხედებს ცვლის; X-ის დაჭერით გამოსახულება გამოჩნდება +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) კიდის დაზუსტების ფუნჯი (R) + Expand Detection Area აღმოჩენის არის გაფართოება + Restore Original Edge საწყისი კიდის აღდგენა + Paint over hair or fur on the image; Alt paints the other mode გამოსახულებაზე თმა ან ბეწვი გადაღებეთ; Alt მეორე რეჟიმით ხატავს + Clear Brush Strokes ფუნჯის მონასმების გასუფთავება + Show rulers სახაზავების ჩვენება + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove დაწკაპეთ განსათავსებლად · Alt-დაწკაპუნება ან ტილოს გარეთ გადათრევა — წაშლა + No document open დოკუმენტი გახსნილი არ არის + {n} of {max} sample points სინჯის წერტილი: {n} / {max} +layerLabel No Color ფერის გარეშე + Preferences… პარამეტრები… + Actual Pixels რეალური პიქსელები + Clipping Mask მომჭრელი ნიღაბი + Tool Options ხელსაწყოების პარამეტრები + Color Picker ფერის ამრჩევი + Color Swatches ფერის ნიმუშები + Linear Dodge წრფივი გაღიავება + Preview Image გამოსახულების გადახედვა + Save As შენახვა როგორც + Remove All ყველას წაშლა + Unlock განბლოკვა + Enable ჩართვა + Disable გამორთვა + On ჩართული + Clear Selection მონიშნულის გასუფთავება + Invert Selection მონიშვნის შებრუნება + Select Inverse მონიშვნის შებრუნება + Contract შევიწროება + Sample Size ნიმუშის ზომა + Strength სიძლიერე + Gamma გამა + Input შემავალი + Output გამომავალი + Input Levels შემავალი დონეები + Output Levels გამომავალი დონეები + Layer Styles შრის სტილები + Blending Mode შერევის რეჟიმი + Fill Color შევსების ფერი + Stroke Color კონტურის ფერი + Stroke Width კონტურის სისქე + Shadow ჩრდილი + Adjustment Layers მაკორექტირებელი შრეები + Text Layer ტექსტური შრე + Documents დოკუმენტები + File Name ფაილის სახელი + File Format ფაილის ფორმატი + Compression შეკუმშვა + Artboards სამუშაო დაფები + Snap to Grid ბადეზე მიბმა + Full Screen მთელ ეკრანზე + Fullscreen მთელ ეკრანზე + Workspaces სამუშაო სივრცეები + Show All ყველას ჩვენება + Hidden დამალული + New Layer ახალი შრე + Duplicate Layer შრის დუბლირება + Ungroup დაჯგუფების მოხსნა + Lock Transparent Pixels გამჭვირვალე პიქსელების ჩაკეტვა + Lock Image Pixels გამოსახულების პიქსელების ჩაკეტვა + Lock Position პოზიციის ჩაკეტვა + Lock All ყველაფრის ჩაკეტვა + Show Rulers სახაზავების ჩვენება + Guide Layout… დამხმარე ხაზების განლაგება… + Page Setup… გვერდის პარამეტრები… + Reset Preferences პარამეტრების ჩამოყრა + Documentation დოკუმენტაცია + Check for Updates განახლებების შემოწმება + Loading… იტვირთება… + Processing… მუშავდება… + Saving… ინახება… + Exporting… ექსპორტირდება… + Please wait… გთხოვთ, მოიცადოთ… + No selection მონიშნული არაფერია + No layers შრეები არ არის + No recent files ბოლოს გახსნილი ფაილები არ არის + Unsupported file format ფაილის ფორმატი მხარდაჭერილი არ არის + Failed to open file ფაილის გახსნა ვერ მოხერხდა + Failed to save file ფაილის შენახვა ვერ მოხერხდა + Operation canceled ოპერაცია გაუქმდა + Changes have not been saved. ცვლილებები შენახული არ არის. + Are you sure? დარწმუნებული ხართ? + Discard Changes ცვლილებების უარყოფა + Recent Files ბოლოს გახსნილი ფაილები + Open File ფაილის გახსნა + Save File ფაილის შენახვა + Save Changes ცვლილებების შენახვა + Rendering… რენდერდება… + Importing… იმპორტირდება… + Ready მზადაა + Failed ვერ მოხერხდა + Warning გაფრთხილება + Information ინფორმაცია + Retry ხელახლა ცდა + Continue გაგრძელება + Skip გამოტოვება + Resume განახლება + Reset All ყველაფრის ჩამოყრა + Defaults ნაგულისხმევი + Apply to All ყველაზე გამოყენება + Confirm დადასტურება + Finish დასრულება + Back უკან + Next შემდეგი + Previous წინა + More მეტი + Less ნაკლები + Manual ხელით + Disabled გამორთული + Locked ჩაკეტილი + Unlocked განბლოკილი + Delete… წაშლა… + Create… შექმნა… + Edit… რედაქტირება… + Change… შეცვლა… + Transformation გარდაქმნა + Scaling მასშტაბირება + Canvas Size ტილოს ზომა + Percentage პროცენტული + Degrees გრადუსი + Middle შუა + Diameter დიამეტრი + Intensity ინტენსივობა + Sensitivity მგრძნობელობა + Softness სირბილე + Feather Radius კიდეების შერბილების რადიუსი + Library ბიბლიოთეკა + Layer Properties შრის თვისებები + Groups ჯგუფები + Masks ნიღბები + Add Mask ნიღბის დამატება + Delete Mask ნიღბის წაშლა + Invert Mask ნიღბის ინვერსია + Enable Mask ნიღბის ჩართვა + Disable Mask ნიღბის გამორთვა + Apply Mask ნიღბის გამოყენება + Import Settings პარამეტრების იმპორტი + Export Settings პარამეტრების ექსპორტი + Shortcut მალსახმობი + Shortcuts მალსახმობები + Keyboard კლავიატურა + Mouse მაუსი + Tablet პლანშეტი + Display ეკრანი + Panel პანელი + Hide Grid ბადის დამალვა + Fit to Window ფანჯარაში ჩატევა + Live Preview ცოცხალი გადახედვა + Before მანამდე + After შემდეგ + Compare შედარება + File Size ფაილის ზომა + Filename ფაილის სახელი + Folder საქაღალდე + Open Folder საქაღალდის გახსნა + Choose Folder საქაღალდის არჩევა + Browse არჩევა + Find ძებნა + Replace ჩანაცვლება + Date თარიღი + Learn More დაწვრილებით + Report a Bug შეცდომის შეტყობინება + Updates განახლებები + Update განახლება + Restart Required საჭიროა გადატვირთვა + Restart გადატვირთვა + Quit გასვლა + Quit PhotoCraft PhotoCraft-იდან გასვლა + Close Window ფანჯრის დახურვა + New Window ახალი ფანჯარა + Open Image გამოსახულების გახსნა + Save Image გამოსახულების შენახვა + Export Image გამოსახულების ექსპორტი + Import Image გამოსახულების იმპორტი + Export As ექსპორტი სხვა ფორმატით + Export to ექსპორტი ფორმატში + Export Options ექსპორტის პარამეტრები + Import Options იმპორტის პარამეტრები + No Selection მონიშნული არაფერია + No Document დოკუმენტი არ არის + No Image გამოსახულება არ არის + No Layers შრეები არ არის + No Results შედეგები არ არის + No Results Found შედეგები ვერ მოიძებნა + Nothing to Undo დასაბრუნებელი არაფერია + Nothing to Redo ხელახლა შესასრულებელი არაფერია + Changes Saved ცვლილებები შენახულია + Unsaved Changes შეუნახავი ცვლილებები + Save before closing? შევინახო დახურვამდე? + Are you sure you want to quit? ნამდვილად გსურთ გასვლა? + The file could not be opened. ფაილის გახსნა ვერ მოხერხდა. + The file could not be saved. ფაილის შენახვა ვერ მოხერხდა. + Unsupported format ფორმატი მხარდაჭერილი არ არის + Out of memory მეხსიერება არ კმარა + Permission denied წვდომა აკრძალულია + File not found ფაილი ვერ მოიძებნა + Operation failed ოპერაცია ვერ შესრულდა + Operation completed ოპერაცია დასრულდა + Operation cancelled ოპერაცია გაუქმდა + Sharpen Amount გამკვეთრების ოდენობა + Sharpen Radius გამკვეთრების რადიუსი + Sharpen Detail გამკვეთრების დეტალი + Sharpen Masking გამკვეთრების ნიღბვა + Noise Luminance ხმაურის სიკაშკაშე + Noise Luminance Detail ხმაურის სიკაშკაშის დეტალი + Noise Color ხმაურის ფერი + Noise Color Detail ხმაურის ფერის დეტალი + Grain Amount მარცვლოვნების ოდენობა + Grain Size მარცვლის ზომა + Grain Roughness მარცვლის უხეშობა + Vignette Roundness ვინიეტის სიმრგვალე + Vignette Feather ვინიეტის კიდეების შერბილება + Vignette Highlights ვინიეტის ღია ტონები + Vignette Style ვინიეტის სტილი + Planes სიბრტყეები + Plane სიბრტყე + Shadow Amount ჩრდილის ოდენობა + Shadow Tone ჩრდილის ტონი + Shadow Radius ჩრდილის რადიუსი + Highlight Amount ღია ტონების ოდენობა + Highlight Tone ღია ტონების ტონი + Highlight Radius ღია ტონების რადიუსი + Midtone შუალედური ტონი + Black Clip შავის მოკვეთა + White Clip თეთრის მოკვეთა + Fuzziness გაბნეულობა + Source Layer წყარო შრე + Neutralize ნეიტრალიზება + Use Selection In Source წყაროში მონიშნულის გამოყენება + Use Selection In Target სამიზნეში მონიშნულის გამოყენება + Flame Type ალის ტიპი + Randomize Length სიგრძის შემთხვევითობა + Interval ინტერვალი + Adjust Interval For Loops ინტერვალის მორგება ციკლებისთვის + Use Custom Color საკუთარი ფერის გამოყენება + Flame Lines ალის ხაზები + Flame Bottom Alignment ალის ქვედა სწორება + Flame Style ალის სტილი + Flame Shape ალის ფორმა + Randomize Shapes ფორმების შემთხვევითობა + Frame ჩარჩო + Arrangement განლაგება + Thickness სისქე + Vine Color ვაზის ფერი + Flower Color ყვავილის ფერი + Leaf Color ფოთლის ფერი + Base Tree Type ხის ძირითადი ტიპი + Light Direction სინათლის მიმართულება + Leaves Amount ფოთლების რაოდენობა + Leaves Size ფოთლების ზომა + Branches Height ტოტების სიმაღლე + Branches Thickness ტოტების სისქე + Default Leaves ნაგულისხმევი ფოთლები + Leaves Color ფოთლების ფერი + Custom Branch Color ტოტების საკუთარი ფერი + Branches Color ტოტების ფერი + Flat Shading ბრტყელი დაჩრდილვა + Date and Time თარიღი და დრო + Scale Units მასშტაბის ერთეულები + Scale Factor მასშტაბის კოეფიციენტი + Area ფართობი + Perimeter პერიმეტრი + Circularity წრიულობა + Gray Value (Minimum) რუხი მნიშვნელობა (მინიმუმი) + Gray Value (Maximum) რუხი მნიშვნელობა (მაქსიმუმი) + Gray Value (Mean) რუხი მნიშვნელობა (საშუალო) + Gray Value (Median) რუხი მნიშვნელობა (მედიანა) + Integrated Density ინტეგრალური სიმკვრივე + Length სიგრძე + In Black შემავალი შავი + In White შემავალი თეთრი + Out Black გამომავალი შავი + Out White გამომავალი თეთრი + Tint Color ტონირების ფერი + Stops გადასვლის წერტილები + Center X ცენტრი X + Center Y ცენტრი Y + Distribution განაწილება + Monochromatic მონოქრომული + Cell Size უჯრის ზომა + Generators გენერატორები + Wavelength Min ტალღის სიგრძე მინ. + Wavelength Max ტალღის სიგრძე მაქს. + Amplitude Min ამპლიტუდა მინ. + Amplitude Max ამპლიტუდა მაქს. + Undefined Areas განუსაზღვრელი არეები + Preserve შენარჩუნება + Max Radius მაქს. რადიუსი + Depth Mode სიღრმის რეჟიმი + Solid Front Faces უწყვეტი წინა წახნაგები + Mask Incomplete Blocks არასრული ბლოკების დაფარვა + Stylization სტილიზაცია + Cleanliness სისუფთავე + Bristle Detail ჯაგრის დეტალი + Lighting განათება + Shine ბზინვარება + Max Offset მაქს. წანაცვლება + Map Document რუკის დოკუმენტი + Map Path რუკის ფაილი + Map Layer რუკის შრე + Ridges ქედები + Light Type სინათლის ტიპი + Light X სინათლე X + Light Y სინათლე Y + Light Z სინათლე Z + Target X სამიზნე X + Target Y სამიზნე Y + Hotspot ცხელი წერტილი + Elevation სიმაღლე + Ambient გარემოს განათება + Warmth სითბო + Gloss სიპრიალე + Metallic მეტალიკური + Ambience გარემო + White Is High თეთრი მაღალია + Reduce Color Noise ფერადი ხმაურის შემცირება + Sharpen Details დეტალების გამკვეთრება + Remove Jpeg Artifact JPEG არტეფაქტის მოცილება + Blade Curvature ფრთების სიმრუდე + Depth Map სიღრმის რუკა + Focal Distance ფოკუსური მანძილი + Focus ფოკუსი + Transition გადასვლა + Radius X რადიუსი X + Radius Y რადიუსი Y + Pins სამაგრები + Blur Angle გაბუნდოვნების კუთხე + Speed სიჩქარე + Taper წაწვეტება + Start X დასაწყისი X + Start Y დასაწყისი Y + End X დასასრული X + End Y დასასრული Y + Kernel ბირთვი + Input Mode შეყვანის რეჟიმი + Row Order სტრიქონების რიგი + Eliminate აღმოფხვრა + Create By შექმნის ხერხი + List სია + Pencil Width ფანქრის სისქე + Stroke Pressure შტრიხის დაწოლა + Paper Brightness ქაღალდის სიკაშკაშე + Number Of Levels დონეების რაოდენობა + Edge Simplicity კიდის სიმარტივე + Edge Fidelity კიდის სიზუსტე + Brush Detail ფუნჯის დეტალი + Highlight Area ნათელი არე + Glow Size ნათების ზომა + Glow Brightness ნათების სიკაშკაშე + Brush Type ფუნჯის ტიპი + Stroke Size შტრიხის ზომა + Stroke Detail შტრიხის დეტალი + Highlight Strength ღია ტონების სიძლიერე + Smoothness სიგლუვე + Edge Thickness კიდის სისქე + Edge Intensity კიდის ინტენსივობა + Posterization პოსტერიზაცია + Stroke Length შტრიხის სიგრძე + Relief რელიეფი + Definition სიცხადე + Texture Coverage ტექსტურის დაფარვა + Shadow Intensity ჩრდილის ინტენსივობა + Edge Width კიდის სიგანე + Edge Brightness კიდის სიკაშკაშე + Direction Balance მიმართულების ბალანსი + Black Intensity შავის ინტენსივობა + White Intensity თეთრის ინტენსივობა + Dark Intensity მუქის ინტენსივობა + Custom Pixel Aspect Ratio პიქსელის საკუთარი თანაფარდობა + 32-bit Preview Options 32-ბიტიანი გადახედვის პარამეტრები + New Guide ახალი დამხმარე ხაზი + Join the ArtCraft Discord შემოუერთდით ArtCraft-ის Discord-ს + Report an Issue ხარვეზის შეტყობინება + Load Files into Stack ფაილების დასტაში ჩატვირთვა + Statistics სტატისტიკა + Duplicate Layers შრეების დუბლირება + Edit Toolbar ხელსაწყოთა ზოლის რედაქტირება + Import Brushes ფუნჯების იმპორტი + Load 3D LUT 3D LUT-ის ჩატვირთვა + New document ახალი დოკუმენტი + Press keys დააჭირეთ კლავიშებს + Rename Group ჯგუფის სახელის შეცვლა + Rename Tool Preset ხელსაწყოს პრესეტის სახელის შეცვლა + Search commands, tools and panels ბრძანებების, ხელსაწყოებისა და პანელების ძიება + Slice Options ნაჭრის პარამეტრები + Rename Brush ფუნჯის სახელის შეცვლა + Opening იხსნება + Reading იკითხება + Working მუშავდება + Accented Edges აქცენტირებული კიდეები + Angled Strokes დახრილი შტრიხები + Anisotropic ანიზოტროპული + Around Center ცენტრის გარშემო + Artistic მხატვრული + Bamboo ბამბუკი + Bas Relief ბარელიეფი + Berries კენკრა + Blast აფეთქება + Bleach Bypass გაუთეთრებელი დამუშავება + Blocks ბლოკები + Brick აგური + Brush Strokes ფუნჯის შტრიხები + Burlap ტომრის ქსოვილი + Candle Light სანთლის შუქი + Circle წრე + Clumped შეჯგუფებული + Coarse Dots მსხვილი წერტილები + Colored Pencil ფერადი ფანქარი + Contrasty კონტრასტული + Cool ცივი + Craquelure კრაკელიური + Cross ჯვარი + Cross Process ჯვარედინი დამუშავება + Crosshatch ჯვარედინი დაშტრიხვა + Cutout აპლიკაცია + Daisies გვირილები + Dark Rough მუქი უხეში + Dark Strokes მუქი შტრიხები + Darken Only მხოლოდ ჩამუქება + Day For Night დღე ღამედ + Deep Blue მუქი ლურჯი + Deep Emerald მუქი ზურმუხტისფერი + Deep Red მუქი წითელი + Deep Yellow მუქი ყვითელი + Dot წერტილი + Double Line ორმაგი ხაზი + Draft მონახაზი + Dry Brush მშრალი ფუნჯი + Duplication დუბლირება + Edge Extension კიდის გაგრძელება + Edge Only მხოლოდ კიდე + Enlarged გადიდებული + Even Fields ლუწი ველები + Faded Film გახუნებული ფირი + Film Grain ფირის მარცვლოვნება + Fine წვრილი + Fine Dots წვრილი წერტილები + Flat ბრტყელი + Fresco ფრესკა + From Left მარცხნიდან + From Right მარჯვნიდან + Frosted მქრქალი + Generic ზოგადი + Glass მინა + Glowing Edges მანათობელი კიდეები + Grainy Dots მარცვლოვანი წერტილები + Hearts გულები + Heptagon შვიდკუთხედი + Hexagon ექვსკუთხედი + Horizontal Only მხოლოდ ჰორიზონტალური + Infinite უსასრულო + Ink Outlines მელნის კონტურები + Ivy სურო + Left Diagonal მარცხენა დიაგონალი + Level Based დონეზე დაფუძნებული + Light Rough ღია უხეში + Lighten Only მხოლოდ გაღიავება + Long Lines გრძელი ხაზები + Long Strokes გრძელი შტრიხები + Lower ქვედა + Medium Dots საშუალო წერტილები + Medium Lines საშუალო ხაზები + Medium Strokes საშუალო შტრიხები + Mono Contrast მონოკონტრასტი + Mosaic Tiles მოზაიკის ფილები + Multiple Flames Along Path მრავალი ალი ტრაექტორიის გასწვრივ + Multiple Flames One Direction მრავალი ალი ერთი მიმართულებით + Multiple Flames Path Directions მრავალი ალი ტრაექტორიის მიმართულებებით + Multiple Flames Various Length მრავალი ალი სხვადასხვა სიგრძით + Octagon რვაკუთხედი + Odd Fields კენტი ველები + One Flame Along Path ერთი ალი ტრაექტორიის გასწვრივ + Out From Center ცენტრიდან გარეთ + Oval ოვალი + Overlay Edge კიდის გადაფარვა + Palette Knife პალიტრის დანა + Parallel პარალელური + Patchwork ნაჭრების მოზაიკა + Pentagon ხუთკუთხედი + Point წერტილი + Pointed წვეტიანი + Pond Ripples ტბორის ტალღები + Pyramids პირამიდები + Random შემთხვევითი + Right Diagonal მარჯვენა დიაგონალი + Rope თოკი + Roses ვარდები + Sandstone ქვიშაქვა + Scallops ნიჟარები + Sepia სეპია + Short Lines მოკლე ხაზები + Short Strokes მოკლე შტრიხები + Simple მარტივი + Light Intensity სინათლის ინტენსივობა + Spray Radius შესხურების რადიუსი + Stroke Direction შტრიხის მიმართულება + Graininess მარცვლოვნება + Glow Amount ნათების ოდენობა + Clear Amount სისუფთავის ოდენობა + Distortion დეფორმაცია + Ripple Size ტალღის ზომა + Ripple Magnitude ტალღის სიდიდე + Charcoal Area ნახშირის არე + Chalk Area ცარცის არე + Charcoal Thickness ნახშირის სისქე + Light Dark Balance ღია-მუქის ბალანსი + Foreground Level წინა პლანის დონე + Background Level ფონის დონე + Pattern Type პატერნის ტიპი + Image Balance გამოსახულების ბალანსი + Darkness სიმუქე + Fiber Length ბოჭკოს სიგრძე + Crack Spacing ბზარებს შორის მანძილი + Crack Depth ბზარის სიღრმე + Crack Brightness ბზარის სიკაშკაშე + Grain Type მარცვლის ტიპი + Tile Size ფილის ზომა + Grout Width ნაკერის სიგანე + Lighten Grout ნაკერის გაღიავება + Square Size კვადრატის ზომა + Border Thickness საზღვრის სისქე + Correct Distortion დეფორმაციის კორექცია + Correct Vignette ვინიეტის კორექცია + Correct C A ქრომატული აბერაციის კორექცია + Auto Scale ავტომატური მასშტაბირება + Red Cyan წითელი/ცისფერი + Blue Yellow ლურჯი/ყვითელი + Vignette Amount ვინიეტის ოდენობა + Vignette Midpoint ვინიეტის შუა წერტილი + Constraints შეზღუდვები + Curve Highlights მრუდის ღია ტონები + Curve Lights მრუდის ნათელი ტონები + Curve Darks მრუდის მუქი ტონები + Curve Shadows მრუდის ჩრდილები + Curve Splits მრუდის გამყოფები + Point Curve წერტილოვანი მრუდი + Point Curve Red წერტილოვანი მრუდი, წითელი + Point Curve Green წერტილოვანი მრუდი, მწვანე + Point Curve Blue წერტილოვანი მრუდი, ლურჯი + Hsl Hue HSL ფერის ტონი + Hsl Sat HSL გაჯერება + Hsl Lum HSL სიკაშკაშე + Grade Shadows გრადირება: ჩრდილები + Grade Midtones გრადირება: შუალედური ტონები + Grade Highlights გრადირება: ღია ტონები + Grade Global გრადირება: გლობალური + Grade Blending გრადირება: შერევა + Grade Balance გრადირება: ბალანსი + Sine სინუსი + Sketch ესკიზი + Snowflakes ფიფქები + Sparkle ბრჭყვიალი + Spatter შხეფები + Speckle ლაქები + Spin ბრუნვა + Sprinkles მოფრქვეული ნაწილაკები + Squareness კვადრატულობა + Stagger ჭადრაკული წანაცვლება + Stained Glass ვიტრაჟი + Star ვარსკვლავი + Stars ვარსკვლავები + Stippled პუნქტირებული + Stretch გაწელვა + Teal Orange ზღვისფერი/ნარინჯისფერი + Tiny Lens პატარა ლინზა + To Center ცენტრისკენ + Unaltered უცვლელი + Underpainting ქვედა ფენის ხატვა + Underwater წყალქვეშა + Uniform ერთგვაროვანი + Upper ზედა + Vertical Only მხოლოდ ვერტიკალური + Vine With Flowers ვაზი ყვავილებით + Vine With Leaves ვაზი ფოთლებით + Violent მძვინვარე + Warm თბილი + Watercolor აკვარელი + Waves ტალღები + Wide Blurry ფართო ბუნდოვანი + Wide Sharp ფართო მკვეთრი + Wrap შემოხვევა + Zigzag ზიგზაგი + Wetness სინოტივე + AI Integrations AI ინტეგრაციები + Add Noise ხმაურის დამატება + Apply Data Set მონაცემთა ნაკრების გამოყენება + Apply Image გამოსახულების გამოყენება + Arbitrary ნებისმიერი + Artboard from Group სამუშაო დაფა ჯგუფიდან + Artboard from Layers სამუშაო დაფა შრეებიდან + Artboards to Files სამუშაო დაფები ფაილებად + Artboards to PDF სამუშაო დაფები PDF-ად + Assign Profile პროფილის მინიჭება + At მდებარეობა + Auto-Align Layers შრეების ავტომატური სწორება + Auto-Blend Layers შრეების ავტომატური შერევა + Batch პაკეტური დამუშავება + Border საზღვარი + Box Blur კვადრატული გაბუნდოვნება + Calculations გამოთვლები + Camera Raw Camera Raw + Camera Raw Filter Camera Raw ფილტრი + Channel1 არხი1 + Channel2 არხი2 + Channel3 არხი3 + Channel4 არხი4 + Clone კლონირება + Color Decontaminate ფერის გაწმენდა + Color Halftone ფერადი ნახევარტონი + Color Lookup Tables ფერების ცხრილები (Color Lookup) + Color Settings ფერის პარამეტრები + Color Table ფერთა ცხრილი + Conditional Mode Change პირობითი რეჟიმის შეცვლა + Content-Aware Fill შიგთავსის გათვალისწინებით შევსება + Convert to Linked დაკავშირებულად გარდაქმნა + Convert to Profile პროფილში გარდაქმნა + Crystallize კრისტალიზაცია + Data მონაცემები + Data Sets as Files მონაცემთა ნაკრებები ფაილებად + De-Interlace სტრიქონგამოტოვების მოცილება + Define Brush Preset ფუნჯის პრესეტის განსაზღვრა + Define Custom Shape საკუთარი ფიგურის განსაზღვრა + Define Pattern პატერნის განსაზღვრა + Defringe კიდის შარავანდის მოცილება + Diffuse დიფუზია + Diffuse Glow გაბნეული ნათება + Displace წანაცვლება + Do you want to save you made to “{name}” before quitting? გსურთ „{name}“-ში შეტანილი ცვლილებების შენახვა გასვლამდე? + Dust & Scratches მტვერი და ნაკაწრები + Export Contents შიგთავსის ექსპორტი + Export Preferences ექსპორტის პარამეტრები + Export/Import Presets პრესეტების ექსპორტი/იმპორტი + Extrude ამოწევა + Fibers ბოჭკოები + Field Blur ველის გაბუნდოვნება + File Info ფაილის ინფორმაცია + Filter Gallery ფილტრების გალერეა + Find and Replace Text ტექსტის ძებნა და ჩანაცვლება + Fit Image გამოსახულების ჩატევა + Flame ალი + Focus Area ფოკუსის არე + Gaussian Blur გაუსის გაბუნდოვნება + Global Light გლობალური განათება + Group from Layers ჯგუფი შრეებიდან + HDR Toning HDR ტონირება + High Pass მაღალსიხშირული ფილტრი + Highlight ღია ტონი + Image Processor გამოსახულებების დამმუშავებელი + Install Plug-in პლაგინის დაყენება + Interpret Footage ვიდეომასალის ინტერპრეტაცია + Iris Blur ირისის გაბუნდოვნება + Jag დაკბილულობა + Label იარლიყი + Layer Comps to Files შრეების კომპოზიციები ფაილებად + Layer Content Options შრის შიგთავსის პარამეტრები + Layer From Background შრე ფონიდან + Layer from Background შრე ფონიდან + Layers to Files შრეები ფაილებად + Legacy მოძველებული + Lens Blur ლინზის ბუნდოვნება + Lens Correction ობიექტივის კორექცია + Lens Flare ობიექტივის ანარეკლი + Lighting Effects განათების ეფექტები + Liquify გათხევადება + Lock Layers შრეების ჩაკეტვა + Lum სიკ. + Match Color ფერის შეთანხმება + Merge to HDR Pro HDR Pro-ში შერწყმა + Mezzotint მეცოტინტო + Mosaic მოზაიკა + Motion Blur მოძრაობის გაბუნდოვნება + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. ფაილების ჩაშენებული გადათრევა-ჩაგდება Wayland-ზე ჯერ მხარდაჭერილი არ არის. გამოიყენეთ ფაილი › გახსნა, ან გაუშვით PhotoCraft XWayland-ში: `WAYLAND_DISPLAY= photocraft`. + Neon Glow ნეონის ნათება + New Guide Layout დამხმარე ხაზების ახალი განლაგება + New Video Layer from File ახალი ვიდეოშრე ფაილიდან + Ocean Ripple ოკეანის ტალღები + Oil Paint ზეთის საღებავი + Open As გახსნა როგორც + Package შეფუთვა + Paint Daubs საღებავის ლაქები + Path Blur ტრაექტორიის გასწვრივ გაბუნდოვნება + Paths to Illustrator ტრაექტორიები Illustrator-ში + Photomerge Photomerge + Picture Frame სურათის ჩარჩო + Pinch მოჭყლეტა + Place Embedded ჩაშენებულის განთავსება + Place Linked დაკავშირებულის განთავსება + Place Scale Marker მასშტაბის მარკერის განთავსება + Plastic Wrap პლასტიკური შეფუთვა + Pointillize პუანტილიზმი + Polar Coordinates პოლარული კოორდინატები + Polar To Rectangular პოლარულიდან მართკუთხაში + Poster Edges პოსტერის კიდეები + Preset Manager პრესეტების მართვა + Profile პროფილი + Radial Blur რადიალური გაბუნდოვნება + Raw Defaults Raw ნაგულისხმევი პარამეტრები + Rectangular To Polar მართკუთხიდან პოლარულში + Reduce Noise ხმაურის შემცირება + Relink to File ფაილთან ხელახლა დაკავშირება + Render Video ვიდეოს რენდერი + Replace Color ფერის ჩანაცვლება + Replace Contents შიგთავსის ჩანაცვლება + Replace Footage ვიდეომასალის ჩანაცვლება + Resolve Missing Fonts დაკარგული შრიფტების აღდგენა + Ripple ტალღა + Rough Pastels უხეში პასტელი + Sat გაჯ. + Save a Copy ასლის შენახვა + Save for Web (Legacy) ვებისთვის შენახვა (მოძველებული) + Scale Effects ეფექტების მასშტაბირება + Script Events Manager სკრიპტის მოვლენების მართვა + Search menus მენიუების ძიება + Seed საწყისი რიცხვი + Select and Mask მონიშვნა და ნიღაბი + Set Measurement Scale საზომი მასშტაბის დაყენება + Shadows/Highlights ჩრდილები/ღია ტონები + Shape Blur ფიგურით გაბუნდოვნება + Shear დახრა + Smart Blur ჭკვიანი გაბუნდოვნება + Smart Sharpen ჭკვიანი გამკვეთრება + Smudge Stick გამღაბნელი ჩხირი + Solid Color მყარი ფერი + Spherize სფერიზება + Spin Blur ბრუნვითი გაბუნდოვნება + Spot პროჟექტორი + Sprayed Strokes შესხურებული შტრიხები + Strokes შტრიხები + Sumi-e სუმი-ე + Surface Blur ზედაპირის გაბუნდოვნება + Texturizer ტექსტურირება + Tiles ფილები + Tilt-Shift დახრა-წანაცვლება + Tool ხელსაწყო + Trace Contour კონტურის მოხაზვა + Trap ტრეპინგი + Tree ხე + Trim შეკვეცა + Turbulent ტურბულენტობა + Twirl გრიგალი + Unsharp Mask არამკვეთრი ნიღაბი + Use File › Open to open an image. გამოსახულების გასახსნელად გამოიყენეთ ფაილი › გახსნა. + Vanishing Point გაქრობის წერტილი + Variable Data Sets ცვლადი მონაცემთა ნაკრებები + Video Frames to Layers ვიდეოკადრები შრეებად + WIA Support WIA მხარდაჭერა + Warp Text ტექსტის დეფორმაცია + Wind ქარი + ZigZag ზიგზაგი + Vertical Type Tool ვერტიკალური ტექსტის ხელსაწყო + About {app} {app}-ის შესახებ + Hide {app} {app}-ის დამალვა + Quit {app} {app}-იდან გასვლა + Services სერვისები + Hide Others სხვების დამალვა + Lut LUT + Cooling L B B გამაციებელი LBB + Cooling80 გამაციებელი 80 + Cooling82 გამაციებელი 82 + Hsb HSB + Hsl HSL + Movie Prime ფიქსირებული კინოობიექტივი + Prime105 ფიქსირებული 105 მმ + Prime35 ფიქსირებული 35 მმ + Rgb RGB + Sponge ღრუბელი + Warming L B A გამათბობელი LBA + Warming81 გამათბობელი 81 + Warming85 გამათბობელი 85 +@id filter.sharpen.sharpen გამკვეთრება + Good კარგი + Best საუკეთესო + Adjustment Properties კორექციის თვისებები + Default Foreground and Background Colors ნაგულისხმევი წინა პლანისა და ფონის ფერები + Move Layer შრის გადაადგილება + Rectangular Selection მართკუთხა მონიშვნა + Reorder Layer შრის გადალაგება + Select Layer შრის მონიშვნა + Set Colors ფერების დაყენება + Switch Foreground and Background Colors წინა პლანისა და ფონის ფერების შენაცვლება + Target სამიზნე +# Print dialog (#2697) + Emulsion Down ემულსია ქვემოთ + Functions ფუნქციები + Print Resolution: ბეჭდვის გარჩევადობა: + +# Added upstream (sync 2026-10-13) + Merge Group ჯგუფის შერწყმა + Manage submitted jobs in the system print queue სისტემის ბეჭდვის რიგში გაგზავნილი დავალებების მართვა + Enable animations ანიმაციების ჩართვა + Saving {percent} ინახება: {percent} + Transform On Drop გარდაქმნა ჩაგდებისას + Scale or rotate the moved area, then press {key} გადაადგილებული არის მასშტაბირება ან შემობრუნება, შემდეგ დააჭირეთ {key}-ს + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close გადათრევა: თავისუფალი ხატვა · {alt}-დაწკაპუნება: სწორი მონაკვეთი · გაშვება საწყის წერტილზე: დახურვა + Amount: რაოდენობა: + Protect: დაცვა: + Protect skin tones კანის ტონების დაცვა + Edit points to modify the curve მრუდის შესაცვლელად წერტილების რედაქტირება + Draw to modify the curve მრუდის შესაცვლელად ხატვა + Smooth the curve values მრუდის მნიშვნელობების გაგლუვება + Drag to draw the curve · {key}-click draws a straight line გადაათრიეთ მრუდის დასახატად · {key}-დაწკაპუნება ხაზავს სწორ ხაზს + Grid Size: ბადის ზომა: + Vanishing Point: click four corners to create a plane გაქრობის წერტილი: სიბრტყის შესაქმნელად დაწკაპეთ ოთხ კუთხეზე + Vanishing Point is not active გაქრობის წერტილი არ არის აქტიური + Apply Vanishing Point ({key}) გაქრობის წერტილის გამოყენება ({key}) + Fix the red plane first: its corners must form a convex quad ჯერ გაასწორეთ წითელი სიბრტყე: მისი კუთხეები ამოზნექილ ოთხკუთხედს უნდა ქმნიდეს + Use Previous Layer to Create Clipping Mask წინა შრის გამოყენება მომჭრელი ნიღბის შესაქმნელად + Fill with {mode}-neutral color (white) შევსება {mode}-ისთვის ნეიტრალური ფერით (თეთრი) + Fill with {mode}-neutral color (black) შევსება {mode}-ისთვის ნეიტრალური ფერით (შავი) + Fill with {mode}-neutral color (50% gray) შევსება {mode}-ისთვის ნეიტრალური ფერით (50% ნაცრისფერი) + (No neutral color exists for {mode} mode.) ({mode} რეჟიმისთვის ნეიტრალური ფერი არ არსებობს.) + The layer or its mask changed. Drag the mask onto the Trash again. შრე ან მისი ნიღაბი შეიცვალა. კვლავ გადაათრიეთ ნიღაბი ნაგვის ყუთზე. + Light Blue ღია ლურჯი + Light Red ღია წითელი + Medium Blue საშუალო ლურჯი + Light Gray ღია ნაცრისფერი + Remove after each stroke ყოველი შტრიხის შემდეგ მოშორება + Apply the removal ({key}) მოშორების გამოყენება ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) მოსაშორებელი შტრიხი: {n} (Enter — გამოყენება, Esc — გაუქმება) + Simulate Pressure დაწოლის იმიტაცია + Stroke Path… ტრაექტორიის კონტური… + Background Eraser ფონის საშლელი + Healing Brush აღმდგენი ფუნჯი + Spot Healing Brush წერტილოვანი აღმდგენი ფუნჯი + Smudge გადღაბნა +curves Smooth გაგლუვება + Apply mask to layer before removing? გამოვიყენო ნიღაბი შრეზე მოშორებამდე? + Delete the vector mask? წავშალო ვექტორული ნიღაბი? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} ფერის ნიმუში: {n} ექსპორტირებულია {file}-ში +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}-დან იმპორტირებულია ფუნჯი: {n} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}-დან იმპორტირებულია ფერის ნიმუში: {n} +@plural Played {n} step|Played {n} steps შესრულებულია ნაბიჯი: {n} +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk განახლდა დისკზე შეცვლილი დაკავშირებული ჭკვიანი ობიექტი: {n} +@plural Saved for Web: {n} file|Saved for Web: {n} files შენახულია ვებისთვის: ფაილი: {n} +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. ამ კომპოზიციის მიერ ჩაწერილი შრე წაიშალა: {n}. განაახლეთ კომპოზიცია ან გაასუფთავეთ გაფრთხილება. +@plural {n} plane|{n} planes სიბრტყე: {n} +@plural {n} preset|{n} presets პრესეტი: {n} +@plural {n} sample point removed|{n} sample points removed მოშორებულია სინჯის წერტილი: {n} +@plural {n} slice|{n} slices ნაჭერი: {n} +@plural {n} step|{n} steps ნაბიჯი: {n} +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ფუნჯის პრესეტის დამატებითი გაფრთხილება: +{n}) +@plural {what} with {n} warning|{what} with {n} warnings {what}, გაფრთხილება: {n} + Duplicate Channel არხის დუბლირება + Delete Channel არხის წაშლა + Rename Channel… არხის სახელის შეცვლა… + Merge Spot Channel სპოტ-ფერის არხის შერწყმა + Convert to Alpha Channel ალფა-არხად გარდაქმნა + Exit Quick Mask სწრაფი ნიღბიდან გამოსვლა + New Channel… ახალი არხი… + New Spot Channel… ახალი სპოტ-ფერის არხი… + Split Channels არხების დაყოფა + Merge Channels… არხების შერწყმა… + Color Indicates Masked Areas ფერი აღნიშნავს ნიღბიან არეებს + Color Indicates Selected Areas ფერი აღნიშნავს მონიშნულ არეებს + Up ზემოთ + To საბოლოო + Make Work Path… სამუშაო ტრაექტორიის შექმნა… + Create Vector Mask ვექტორული ნიღბის შექმნა + Make Selection… მონიშნულის შექმნა… + Fill Path… ტრაექტორიის შევსება… + Clipping Path… ჩაჭრის ტრაექტორია… + Free Transform Path ტრაექტორიის თავისუფალი გარდაქმნა + Unite Shapes at Overlap ფიგურების გაერთიანება გადაფარვის არეში + Subtract Shapes at Overlap ფიგურების გამოკლება გადაფარვის არეში + Copy Fill შევსების კოპირება + Copy Complete Stroke სრული კონტურის კოპირება + Paste Fill შევსების ჩასმა + Paste Complete Stroke სრული კონტურის ჩასმა + Make Symmetry Path სიმეტრიის ტრაექტორიის შექმნა + Disable Symmetry Path სიმეტრიის ტრაექტორიის გამორთვა + Dark Gray მუქი ნაცრისფერი + Medium Gray საშუალო ნაცრისფერი + Select Custom Color… მორგებული ფერის არჩევა… + Color Picker (Custom Canvas Color) ფერის ამრჩევი (ტილოს მორგებული ფერი) + ✓ Color Indicates Masked Areas ✓ ფერი აღნიშნავს ნიღბიან არეებს + ✓ Color Indicates Selected Areas ✓ ფერი აღნიშნავს მონიშნულ არეებს + Shallow ზედაპირული + Deep ღრმა + Blend Interior Effects as Group შიდა ეფექტების შერევა ჯგუფად + Blend Clipped Layers as Group ჩაჭრილი შრეების შერევა ჯგუფად + Transparency Shapes Layer გამჭვირვალობა განსაზღვრავს შრის ფორმას + Layer Mask Hides Effects შრის ნიღაბი მალავს ეფექტებს + Vector Mask Hides Effects ვექტორული ნიღაბი მალავს ეფექტებს + General Blending ზოგადი შერევა + This Layer: ეს შრე: + Underlying Layer: ქვედა შრე: + Nothing to paste: the clipboard holds no image ჩასასმელი არაფერია: გაცვლის ბუფერში გამოსახულება არ არის + Preferences saved პარამეტრები შენახულია + Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგა ნაგულისხმევი დოკი + Preferences were reset: {error} პარამეტრები ჩამოიყარა: {error} + Recovered {name} აღდგენილია {name} + Couldn't save preferences: {error}. Retrying… პარამეტრების შენახვა ვერ მოხერხდა: {error}. ხელახლა ცდა… + Autosave failed: {error} ავტოშენახვა ვერ მოხერხდა: {error} + Exported presets to {path} პრესეტები ექსპორტირებულია: {path} + Ran script {name} სკრიპტი შესრულდა: {name} + Sent to the printer ({spooler}) გაიგზავნა პრინტერზე ({spooler}) + Printed to {path} დაიბეჭდა: {path} + Exported {path} ({size}) ექსპორტირებულია: {path} ({size}) + Exported {path} ექსპორტირებულია: {path} + Added swatch {name} დაემატა ფერის ნიმუში: {name} + Step {step} ({id}) failed: {error} ნაბიჯი {step} ({id}) ვერ შესრულდა: {error} + Could not rename the action: {error} მოქმედების სახელის შეცვლა ვერ მოხერხდა: {error} + Action playback failed: {error} მოქმედების შესრულება ვერ მოხერხდა: {error} + Clone source set at {x}, {y} კლონირების წყარო დაყენდა: {x}, {y} + Color management: {error} ფერების მართვა: {error} + Opened {url} გაიხსნა: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes მორგებული ფიგურა „{name}“ არ არსებობს: აირჩიეთ პარამეტრების ზოლში ან ფანჯარა › ფიგურები + Saved a copy as {path} ასლი შენახულია როგორც {path} + Exported contents to {path} შიგთავსი ექსპორტირებულია: {path} + {label} is not available: {reason} {label} მიუწვდომელია: {reason} + Saved {path}; image assets: {n} in {dir} შენახულია {path}; გამოსახულების რესურსები: {n}, {dir}-ში + {message} (notes: {n}; {warning}) {message} (შენიშვნა: {n}; {warning}) + Preferences can't be saved პარამეტრების შენახვა შეუძლებელია + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft კვლავ ცდილობს; სანამ შენახვა არ მოხერხდება, პარამეტრების ცვლილებები დახურვისას დაიკარგება. + GPU retry scheduled GPU-ს ხელახალი ცდა დაგეგმილია + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. შეინახეთ ნამუშევარი და გადატვირთეთ PhotoCraft GPU-აჩქარების ხელახლა საცდელად. ამ სესიაში CPU-რენდერინგი აქტიური რჩება. + GPU device was lost; using the CPU renderer. GPU მოწყობილობა დაიკარგა; გამოიყენება CPU-რენდერერი. + GPU error; using the CPU renderer. GPU-ს შეცდომა; გამოიყენება CPU-რენდერერი. + Couldn't open {name}: {error} {name}-ის გახსნა ვერ მოხერხდა: {error} + Toggle the Brush Settings panel ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა + New Brush Preset ფუნჯის ახალი პრესეტი + Grid view ბადის ხედი + List view სიის ხედი + {verb} {mask} {layer} {verb}: {mask}, {layer} + Unlink კავშირის გაწყვეტა + Link დაკავშირება + layer mask შრის ნიღაბი + vector mask ვექტორული ნიღაბი + Commit transform გარდაქმნის დადასტურება + Cancel transform გარდაქმნის გაუქმება + Scroll menu up მენიუს ზემოთ გადახვევა + Scroll menu down მენიუს ქვემოთ გადახვევა + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? გასაგრძელებლად ეს ტექსტის შრე უნდა დარასტრირდეს. მისი ტექსტი აღარ იქნება რედაქტირებადი. დავარასტრირო ტექსტი? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? გასაგრძელებლად ეს ფიგურის შრე უნდა დარასტრირდეს. ის ფიგურად აღარ იქნება რედაქტირებადი. დავარასტრირო ფიგურა? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? გასაგრძელებლად ეს შევსების შრე უნდა დარასტრირდეს. მისი შევსება აღარ იქნება რედაქტირებადი. დავარასტრირო შრე? + This layer must be rasterized before proceeding. გასაგრძელებლად ეს შრე უნდა დარასტრირდეს. + Color Picker (Background Color) ფერის ამრჩევი (ფონის ფერი) + Color Picker (Foreground Color) ფერის ამრჩევი (წინა პლანის ფერი) + Adwaita (Light) Adwaita (ღია) + Adwaita (Dark) Adwaita (ბნელი) + Breeze Light Breeze ღია + Breeze Dark Breeze ბნელი + Solarized Dark Solarized ბნელი + Open Recent is unavailable on the web ბოლოს გახსნილი ფაილების გახსნა ვებში მიუწვდომელია + Graphics adapter: {adapter} გრაფიკული ადაპტერი: {adapter} + Backend: {backend} ({type}) ბექენდი: {backend} ({type}) + Driver: {driver} დრაივერი: {driver} + GPU backend preference: {backend} GPU ბექენდის არჩევანი: {backend} + Selected at launch: {backend} არჩეულია გაშვებისას: {backend} + Image compositor: {processor} გამოსახულების კომპოზიტორი: {processor} + Fallback: {reason} სარეზერვო ვარიანტი: {reason} + This session: {reason} ეს სესია: {reason} + Window renderer: {renderer} ფანჯრის რენდერერი: {renderer} + Software adapter პროგრამული ადაპტერი + Graphics adapter გრაფიკული ადაპტერი + unknown უცნობი + unknown type უცნობი ტიპი + auto ავტომატური + Platform: {os} {arch} პლატფორმა: {os} {arch} + previous reading kept; the last re-read failed: {reason} შენარჩუნებულია წინა მონაცემები; ბოლო ხელახალი წაკითხვა ვერ მოხერხდა: {reason} + Monitor profile: {profile} მონიტორის პროფილი: {profile} + Display profiles: {reason} ეკრანის პროფილები: {reason} + Monitor profile in use on {display}: {profile} {display}-ზე გამოყენებული მონიტორის პროფილი: {profile} + Monitor profile in use: {profile} გამოყენებული მონიტორის პროფილი: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ტილო ნაჩვენებია როგორც sRGB. აირჩიეთ მონიტორის პროფილი: რედაქტირება › ფერის პარამეტრები. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ტილო {display}-ზე ნაჩვენებია როგორც sRGB. აირჩიეთ მონიტორის პროფილი: რედაქტირება › ფერის პარამეტრები. + Display profile not used ეკრანის პროფილი არ გამოიყენება + Monitor profile on {display} (main window): {profile} მონიტორის პროფილი {display}-ზე (მთავარი ფანჯარა): {profile} + Monitor profile on {display}: {profile} მონიტორის პროფილი {display}-ზე: {profile} + manual ხელით + fallback სარეზერვო + The document does not have an embedded colour profile. Working space: {working}. დოკუმენტს არ აქვს ჩაშენებული ფერის პროფილი. სამუშაო სივრცე: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} დოკუმენტს აქვს ჩაშენებული ფერის პროფილი, რომელიც სამუშაო სივრცეს არ შეესაბამება.\nჩაშენებული: {embedded}\nსამუშაო: {working} + Importing {what} from {file}… {file}-დან {what}-ის იმპორტი… + brushes ფუნჯები + gradients გრადიენტები +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file}-დან იმპორტირებულია ფუნჯი: {n} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file}-დან იმპორტირებულია გრადიენტი: {n} + Data Set: {name} ({index}/{total}) მონაცემთა ნაკრები: {name} ({index}/{total}) + (none) (არცერთი) +navigator Zoom მასშტაბი + Apply {option}: {state} {option}-ის გამოყენება: {state} + on ჩართული + off გამორთული + This adjustment ({key}) is kept as imported and can't be edited yet. ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით და ჯერ ვერ რედაქტირდება. + Join the ArtCraft Discord ({url}) შემოუერთდით ArtCraft-ის Discord-ს ({url}) + {index} of {total} {index} / {total} + {shortcut} assigned. {shortcut} მინიჭებულია. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} უკვე გამოიყენება ბრძანებისთვის {commands} და OK-ზე დაწკაპუნებისას იქიდან მოიხსნება. + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) კერნინგი: მეტრიკა, ოპტიკური ან მნიშვნელობა em-ის 1/1000-ში (Alt+←/→ კურსორთან) + Basic Latin საბაზო ლათინური + Latin 1 ლათინური 1 + Latin Extended-A ლათინური გაფართოებული-A + Latin Extended-B ლათინური გაფართოებული-B + Greek ბერძნული + Cyrillic კირილიცა + Numbers რიცხვები + Currency ვალუტა + Math Symbols მათემატიკური სიმბოლოები + Arrows ისრები + Save Path ტრაექტორიის შენახვა +cameraRaw Highlights ღია ტონები +cameraRaw Shadows ჩრდილები + Temporary დროებითი + none არცერთი + red წითელი + orange ნარინჯისფერი + yellow ყვითელი + green მწვანე + blue ლურჯი + violet იისფერი + gray ნაცრისფერი + Working Rgb სამუშაო RGB + Working Cmyk სამუშაო CMYK + Working Gray სამუშაო ნაცრისფერი + Policy Rgb RGB-ის წესი + Policy Cmyk CMYK-ის წესი + Policy Gray ნაცრისფერის წესი + Convert გარდაქმნა + Ask On Mismatch შეუსაბამობისას შეკითხვა + Ask On Paste ჩასმისას შეკითხვა + Ask On Missing არარსებობისას შეკითხვა + Intent განზრახვა + Bpc შავი წერტილის კომპენსაცია + Blend Text Gamma ტექსტის ფერების შერევა გამით + Monitor Profile მონიტორის პროფილი + Display-p3 Display P3 + Adobe-rgb-compat Adobe RGB-თავსებადი + Prophoto-compat ProPhoto-თავსებადი + Linear-srgb წრფივი sRGB + Rec2020 Rec. 2020 + Coated-cmyk ცარცული ქაღალდის CMYK + Sgray sGray + Gray-gamma-2.2 Gray Gamma 2.2 +cameraRaw Effects ეფექტები +cameraRaw Exposure ექსპოზიცია +cameraRaw Contrast კონტრასტი +cameraRaw Texture ტექსტურა +cameraRaw Clarity სიცხადე +cameraRaw Dehaze ბურუსის მოცილება +cameraRaw Saturation გაჯერება +cameraRaw Curve მრუდი +cameraRaw Color Mixer ფერების მიქშერი +cameraRaw Midtones შუალედური ტონები +cameraRaw Balance ბალანსი +cameraRaw Detail დეტალი +cameraRaw Radius რადიუსი +cameraRaw Feather კიდეების შერბილება +cameraRaw Midpoint შუა წერტილი +cameraRaw Roundness სიმრგვალე +cameraRaw Size ზომა +document Orientation ორიენტაცია + Arc Lower ქვედა რკალი + Arc Upper ზედა რკალი + Forward Warp წინ დეფორმაცია + Twirl Clockwise საათის ისრის მიმართულებით დაგრეხა + Twirl Counterclockwise საათის ისრის საწინააღმდეგოდ დაგრეხა + Pucker შეჭმუხვნა + Bloat გაბერვა + Push Left მარცხნივ გაწევა + Freeze Mask ნიღბით გაყინვა + Thaw Mask ნიღბის გალღობა + Freeze Lasso ლასოთი გაყინვა + Reconstruct All ყველაფრის რეკონსტრუქცია + Mask None ნიღბის გარეშე +colorRange Invert ინვერსია +layers Fill შევსება +layers Fill: შევსება: +shape Appearance იერსახე + Export As: {n} layers ექსპორტი სხვა ფორმატით: შრე: {n} + Exported {n} files ექსპორტირებულია ფაილი: {n} +# Added upstream (sync 2 2026-10-13) + A brush preset with this name already exists ფუნჯის პრესეტი ამ სახელით უკვე არსებობს + Action playback stopped მოქმედების შესრულება შეწყდა + Add Mask to Selection ნიღბის მონიშნულში დამატება + Add Open Files გახსნილი ფაილების დამატება + Add to Dictionary ლექსიკონში დამატება + All shortcuts reset to Photoshop defaults. ყველა მალსახმობი დაბრუნდა Photoshop-ის ნაგულისხმევზე. + Allow Continue გაგრძელების დაშვება + Attempt to Automatically Align Source Images წყაროს გამოსახულებების ავტომატური სწორების მცდელობა + Auto Add/Delete ავტომატური დამატება/წაშლა + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. ბეჭდვის პარამეტრებში… დამატებით ნუ ჩართავთ „სარკისებურ ასახვას“: ორი ასახვა ერთმანეთს აბათილებს. + Print Settings… ბეჭდვის პარამეტრები… + Remember to turn off the printer's own colour management in Print Settings… არ დაგავიწყდეთ თავად პრინტერის ფერების მართვის გამორთვა ბეჭდვის პარამეტრებში… + Sent to {printer} გაიგზავნა პრინტერზე: {printer} + set in Print Settings… განისაზღვრება ბეჭდვის პარამეტრებში… + Based On საფუძველი + Bend მოხრა + Capture Brush Size in Preset ფუნჯის ზომის პრესეტში შენახვა + Channel Color არხის ფერი + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. აირჩიეთ ფორმატი, რომელიც მას შეინარჩუნებს, მაგალითად Photoshop (PSD), ან შეინახეთ ასლი. + Choose a gradient file (.grd). აირჩიეთ გრადიენტის ფაილი (.grd). + Clear Brush Controls ფუნჯის პარამეტრების გასუფთავება + Color Indicates: ფერი აღნიშნავს: + Copy Texture to Other Tools ტექსტურის სხვა ხელსაწყოებში კოპირება + Create Smart Object after Loading Layers შრეების ჩატვირთვის შემდეგ ჭკვიანი ობიექტის შექმნა + Default Photoshop Size Photoshop-ის ნაგულისხმევი ზომა + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels გადაათრიეთ ჩამოჭრის ჩარჩო · შიგნით გადათრევით გადაადგილება · კიდეებით ზომის შეცვლა ({ratio} თანაფარდობა, {centre} ცენტრიდან) · Space — გადაადგილება ხაზვისას · {commit} — დადასტურება · Esc — გაუქმება + Drag to pan · Scroll to zoom · Double-click to fit გადათრევა: გადაადგილება · გადახვევა: მასშტაბი · ორმაგი დაწკაპუნება: ჩატევა + Drag: draw freehand · Release on the start point: close გადათრევა: თავისუფალი ხატვა · გაშვება საწყის წერტილზე: დახურვა + Enter a name for the brush preset შეიყვანეთ ფუნჯის პრესეტის სახელი + Files ფაილები + Gradient Preset Options გრადიენტის პრესეტის პარამეტრები + Horizontal Distortion ჰორიზონტალური დეფორმაცია + ICO is limited to 256 × 256 pixels ICO შეზღუდულია 256 × 256 პიქსელით + Import .GRD .GRD-ის იმპორტი + Import Photoshop brushes (.abr) Photoshop-ის ფუნჯების იმპორტი (.abr) + Include Color ფერის ჩართვა + Include Tool Settings ხელსაწყოს პარამეტრების ჩართვა + Ink Characteristics მელნის მახასიათებლები + Insert Stop… შეჩერების ჩასმა… + Intersect Mask with Selection ნიღბის მონიშნულთან გადაკვეთა + Load Actions… მოქმედებების ჩატვირთვა… + Load Settings… პარამეტრების ჩატვირთვა… + Load settings from a Camera Raw .xmp file პარამეტრების ჩატვირთვა Camera Raw-ის .xmp ფაილიდან + Loaded Camera Raw settings from {file} Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან (მხარდაუჭერელი: {n}) + Mask Channel ნიღბის არხი + Message: შეტყობინება: + No Spelling Suggestions მართლწერის შეთავაზებები არ არის + No object found there — drag a rectangle around it იქ ობიექტი ვერ მოიძებნა — გადაათრიეთ მის გარშემო მართკუთხედი + Object Finder ობიექტების მაძიებელი + Paper Height ქაღალდის სიმაღლე + Paper Width ქაღალდის სიგანე + Pick Color From Image ფერის აღება გამოსახულებიდან + R g b RGB + Reset All Locked Settings ყველა ჩაკეტილი პარამეტრის ჩამოყრა + Save Actions… მოქმედებების შენახვა… + Save Settings… პარამეტრების შენახვა… + Save Set… ნაკრების შენახვა… + Save these settings as a Camera Raw .xmp file ამ პარამეტრების შენახვა Camera Raw-ის .xmp ფაილად + Select an action first ჯერ აირჩიეთ მოქმედება + Set horizontal skew ჰორიზონტალური დახრის დაყენება + Set vertical skew ვერტიკალური დახრის დაყენება + Show dialog during playback დიალოგის ჩვენება შესრულებისას + Soft Dark Theme რბილი ბნელი თემა + Soft Light Theme რბილი ღია თემა + Soft dark რბილი ბნელი + Soft light რბილი ღია + Solidity: სიმკვრივე: + Sort By Name სახელის მიხედვით დალაგება + Source Channel წყაროს არხი + Source Files წყაროს ფაილები + Source Invert წყაროს ინვერსია + Source1 Channel წყარო 1-ის არხი + Source1 Invert წყარო 1-ის ინვერსია + Source2 Channel წყარო 2-ის არხი + Source2 Invert წყარო 2-ის ინვერსია + Step {step} is waiting for its dialog ნაბიჯი {step} თავის დიალოგს ელოდება + Subtract Mask from Selection ნიღბის მონიშნულიდან გამოკლება + Use: გამოყენება: + Vertical Distortion ვერტიკალური დეფორმაცია + {message} ({note}) {message} ({note}) + {name} (Photoshop) {name} (Photoshop) + {name} can't keep everything in this document {name} ვერ შეინარჩუნებს ამ დოკუმენტის ყველაფერს +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file}-დან იმპორტირებულია პატერნი: {n} +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}-დან ჩაიტვირთა მოქმედება: {n} +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file}-ში შეინახა მოქმედება: {n} +@plural {n} action|{n} actions მოქმედება: {n} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet ნაბიჯი, რომელსაც PhotoCraft-ში ჯერ შესატყვისი არ აქვს: {n} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out გამოტოვებული ნაბიჯი, რომელსაც Photoshop-ში შესატყვისი არ აქვს: {n} +liquify Rate სიჩქარე + +# Sync 2026-10-14 (strings added upstream) + Choose Stack Mode: აირჩიეთ დასტის რეჟიმი: + No pin selected სამაგრი არ არის მონიშნული + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. სამაგრის დასამატებლად დაწკაპეთ გამოსახულებაზე; სამაგრის გადასაადგილებლად გადაათრიეთ ის, გაბუნდოვნების შესაცვლელად კი — მისი რგოლი. Delete წაშლის მონიშნულ სამაგრს. + The document “{name}” does not have an embedded {mode} profile. დოკუმენტს „{name}“ არ აქვს ჩაშენებული {mode} პროფილი. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. დოკუმენტს „{name}“ აქვს ჩაშენებული ფერის პროფილი, რომელიც მიმდინარე {mode} სამუშაო სივრცეს არ შეესაბამება. + Working: {working} სამუშაო: {working} + Custom background color ფონის მორგებული ფერი + Redrawing გადახატვა + Starting serial საწყისი რიგითი ნომერი + Presentation ჩვენება +@id file.close დახურვა +@id layer.layerMask.apply გამოყენება +@id edit.clear გასუფთავება +layerLabel Red წითელი +layerLabel Magenta მეწამული + Export As: {name} ექსპორტი სხვა ფორმატით: {name} + Color Sampler Tool ფერის სინჯის აღების ხელსაწყო + Proof Setup… საცდელი ჩვენების პარამეტრები… + Toggle Layer Mask Overlay შრის ნიღბის გადაფარვის გადართვა + Increase Type Size ტექსტის ზომის გაზრდა + Decrease Type Size ტექსტის ზომის შემცირება + Increase Type Size by 10 ტექსტის ზომის 10-ით გაზრდა + Decrease Type Size by 10 ტექსტის ზომის 10-ით შემცირება + Make Selection from Path ტრაექტორიიდან მონიშნულის შექმნა + Target Composite Channel კომპოზიტური არხის არჩევა + Target Channel 3 მე-3 არხის არჩევა + Target Channel 4 მე-4 არხის არჩევა + Target Channel 5 მე-5 არხის არჩევა + Target Channel 6 მე-6 არხის არჩევა + Target Channel 7 მე-7 არხის არჩევა + Target Channel 8 მე-8 არხის არჩევა + Target Channel 9 მე-9 არხის არჩევა + Fill with Foreground Color წინა პლანის ფერით შევსება + Fill with Background Color ფონის ფერით შევსება + Fill with Foreground Color, Preserve Transparency წინა პლანის ფერით შევსება, გამჭვირვალობის შენარჩუნებით + Fill with Background Color, Preserve Transparency ფონის ფერით შევსება, გამჭვირვალობის შენარჩუნებით + Decrease Brush Size ფუნჯის ზომის შემცირება + Increase Brush Size ფუნჯის ზომის გაზრდა + Decrease Brush Hardness ფუნჯის სიხისტის შემცირება + Increase Brush Hardness ფუნჯის სიხისტის გაზრდა + Hand Tool (hold) ხელის ხელსაწყო (დაჭერით) + Zoom In (hold, drag to scrub) გადიდება (დაჭერით; გადათრევა — მასშტაბის მდორე ცვლა) + Zoom Out (hold) დაპატარავება (დაჭერით) + Erase with Brush or Pencil (hold) ფუნჯით ან ფანქრით წაშლა (დაჭერით) + Author Title ავტორის თანამდებობა + Copyright Url საავტორო უფლებების URL + Title სათაური + Working-cmyk სამუშაო CMYK + Simulate Paper ქაღალდის იმიტაცია + Warming Filter (85) გამათბობელი ფილტრი (85) + Warming Filter (LBA) გამათბობელი ფილტრი (LBA) + Warming Filter (81) გამათბობელი ფილტრი (81) + Cooling Filter (80) გამაციებელი ფილტრი (80) + Cooling Filter (LBB) გამაციებელი ფილტრი (LBB) + Cooling Filter (82) გამაციებელი ფილტრი (82) + Cw საათის ისრის მიმართულებით + Ccw საათის ისრის საწინააღმდეგოდ + Nearest უახლოესი მეზობელი + R G B RGB + Pattern Fill… პატერნით შევსება… + Any ნებისმიერი + Cmyk CMYK + Dont Enlarge გადიდების გარეშე + Change Find ჩანაცვლება და ძებნა + Case Sensitive რეგისტრის გათვალისწინებით + Whole Word მთლიანი სიტყვა + Forward წინ + Checkerboard ჭადრაკის დაფა + browser preset storage timed out ბრაუზერის პრესეტების საცავმა დროულად არ უპასუხა + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად, კვადრატი — კუთხეების მოსამრგვალებლად, შიგნით მდებარე წერტილები კი — გაბუნდოვნების დაწყების ადგილის დასაყენებლად. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ სამაგრი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად; რგოლი გაბუნდოვნების კუთხეს აყენებს. + Add Shortcut მალსახმობის დამატება + Clip thumbnails to layer bounds ესკიზების შრის საზღვრებით მოჭრა + Clip Thumbnails to Layer Bounds ესკიზების შრის საზღვრებით მოჭრა + Import Brushes… (.abr or image) ფუნჯების იმპორტი… (.abr ან გამოსახულება) + Import Brush from Image… ფუნჯის იმპორტი გამოსახულებიდან… + Bundle Arrow-Key Nudges ისრებით წანაცვლებების გაერთიანება + Nudge Bundle Pause (ms) გაერთიანებული წანაცვლების პაუზა (მწმ) + Response curve რეაქციის მრუდი + Space ჰარი + W: სიგ.: + A: კუთხე: + Show home recent files ბოლო ფაილების ჩვენება საწყის ეკრანზე + Background (Default) ფონი (ნაგულისხმევი) + Include All Subfolders ყველა ქვესაქაღალდის ჩართვა + Use the embedded profile ({profile}) ჩაშენებული პროფილის გამოყენება ({profile}) + Convert to the working space ({profile}) სამუშაო სივრცეში გარდაქმნა ({profile}) + Remember this choice for future images ამ არჩევანის დამახსოვრება მომავალი გამოსახულებებისთვის + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. შეკითხვის ხელახლა ჩასართავად გამოიყენეთ „შეუსაბამობისას შეკითხვა“: რედაქტირება › ფერის პარამეტრები. + Button Mode ღილაკების რეჟიმი + Button Mode On ღილაკების რეჟიმი ჩართულია + Darken Interface ინტერფეისის გამუქება + Lighten Interface ინტერფეისის გაღიავება + Technique ტექნიკა + Chisel Hard მკვეთრი კვეთა + Chisel Soft რბილი კვეთა + Open color-less EXR as ACEScg ფერის სივრცის გარეშე EXR-ის გახსნა ACEScg-ად + Edit Selected Guides მონიშნული დამხმარე ხაზების რედაქტირება + Clear Selected Guides მონიშნული დამხმარე ხაზების გასუფთავება + Delete this set ამ ნაკრების წაშლა + New Set… ახალი ნაკრები… + PhotoCraft Defaults PhotoCraft-ის ნაგულისხმევი + Save a list of these shortcuts as a web page ამ მალსახმობების სიის ვებგვერდად შენახვა + Save the changes to this set ამ ნაკრებში ცვლილებების შენახვა + Save these shortcuts as a .kys file ამ მალსახმობების .kys ფაილად შენახვა + Save these shortcuts as a new set ამ მალსახმობების ახალ ნაკრებად შენახვა + Set deleted. ნაკრები წაიშალა. + Set saved. ნაკრები შენახულია. + Set: ნაკრები: + Summarize… შეჯამება… + There are too many sets: delete one first. ნაკრები ძალიან ბევრია: ჯერ ერთი წაშალეთ. + set name ნაკრების სახელი + {set} (modified) {set} (შეცვლილი) + Accept and Go to Conflict მიღება და კონფლიქტზე გადასვლა + Changes undone. ცვლილებები დაბრუნდა. + Choose a new shortcut for this command, or leave it without one. აირჩიეთ ამ ბრძანების ახალი მალსახმობი ან დატოვეთ ის მალსახმობის გარეშე. + Put this command's shortcuts back as they were ამ ბრძანების მალსახმობების წინანდელ მდგომარეობაში დაბრუნება + Take the shortcut from the other command and select it მალსახმობის მეორე ბრძანებისთვის წართმევა და იმ ბრძანების მონიშვნა + Undo Changes ცვლილებების დაბრუნება + {shortcut} is reserved by the system and can't be assigned. {shortcut} სისტემისთვისაა დაჯავშნული და მისი მინიჭება შეუძლებელია. + Low-Latency Drawing (vsync off, applies at next launch) დაბალი დაყოვნებით ხატვა (vsync გამორთულია, ძალაში შედის შემდეგი გაშვებისას) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. თავისუფალი გარდაქმნის გამოყენება ჯერ არ დასრულებულა; სცადეთ ხელახლა, როცა დასრულდება. + The transform couldn't be applied. გარდაქმნის გამოყენება ვერ მოხერხდა. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. მის შრეებს GPU-ის მეხსიერებიდან სჭირდება {needed}; ლიმიტი მათ უტოვებს {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. პარამეტრები › წარმადობა › GPU-ის მეხსიერება დააყენეთ იმ მოცულობაზე, რასაც თქვენი ვიდეობარათი იტევს, მაქსიმუმ მისი მეხსიერების 3/8-ზე. ავტომატური მნიშვნელობა მიჰყვება მეხსიერების გამოყენებას, ამ კომპიუტერის მეხსიერების მეოთხედისა და ბარათის მეხსიერების 3/8-ის ფარგლებში, ხოლო თუ ბარათის მეხსიერება ცნობილია, დოკუმენტს მის ხარჯზე არ ითვლის. სანამ შრეები არ ეტევა, სტატუსის ზოლში ჩანს „GPU-ის მეხსიერება სავსეა“. + GPU Memory Full GPU-ის მეხსიერება სავსეა + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. შრეებს GPU-ის მეხსიერებიდან სჭირდება {needed}, დასაშვებია კი {budget}, ამიტომ ცვლილებები, რომლებიც მთელ დოკუმენტს ხელახლა ხატავს, ამას CPU-ზე აკეთებს. დაწკაპეთ GPU-ის მეხსიერების დასაყენებლად. + GPU memory budget: {budget} GPU-ის მეხსიერების ლიმიტი: {budget} + This document's layers: {needed} of {budget} for layers ამ დოკუმენტის შრეები: {needed} / {budget} (შრეებისთვის) + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU ამ დოკუმენტის შრეებს სჭირდება {needed}, დასაშვებია კი {budget}: მთელი დოკუმენტის გადახატვა CPU-ს იყენებს + GPU Memory (MB, 0 = Automatic) GPU-ის მეხსიერება (მბ, 0 = ავტომატური) + {name} was changed by another app after it was opened or saved here. {name} სხვა აპლიკაციამ შეცვალა მას შემდეგ, რაც აქ გაიხსნა ან შეინახა. + Replace it with this document, or cancel and use File › Save As to keep both. ჩაანაცვლეთ ის ამ დოკუმენტით, ან გააუქმეთ და ორივეს შესანარჩუნებლად გამოიყენეთ ფაილი › შენახვა როგორც. + Pasting from other apps is unavailable სხვა აპლიკაციებიდან ჩასმა მიუწვდომელია + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ამ სესიაში სისტემის გაცვლის ბუფერთან წვდომას ვერ ახერხებს. Wayland-ზე Flatpak-ის იზოლირებული გარემო მალავს პროტოკოლს, რომლითაც სხვა აპლიკაციების გამოსახულებები იკითხება. + To paste from other apps, start PhotoCraft under XWayland: `{command}` სხვა აპლიკაციებიდან ჩასასმელად გაუშვით PhotoCraft XWayland-ში: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here ჩასასმელი არაფერია: PhotoCraft აქ სისტემის გაცვლის ბუფერს ვერ კითხულობს + Show file name in title ფაილის სახელის ჩვენება სათაურში + Memory Usage მეხსიერების გამოყენება + Per-document RAM limit (MB) RAM-ის ლიმიტი თითო დოკუმენტზე (მბ) + Installed RAM: {ram} MB; this limit: {percent}% კომპიუტერის RAM: {ram} მბ; ეს ლიმიტი: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. ზღუდავს თითოეული ღია დოკუმენტის პიქსელებსა და ისტორიას ერთად. ლიმიტის გადაჭარბებისას დაბრუნების უძველესი მდგომარეობები იშლება. + Applies immediately when saved. Memory is not reserved in advance. შენახვისთანავე მოქმედებს. მეხსიერება წინასწარ არ რეზერვდება. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. როცა GPU-ის მეხსიერება ავტომატურზეა დაყენებული, ეს GPU-ზე შრეების ლიმიტსაც ზღუდავს. ის არ აღემატება კომპიუტერის RAM-ის მეოთხედს და, თუ ცნობილია, ვიდეობარათის მეხსიერების სამ მერვედს; მინიმუმი 512 მბ-ია. ამ ზღვრებს ზემოთ ამ ლიმიტის გაზრდა GPU-ის ლიმიტს არ ზრდის. + Use გამოყენება + In Front წინ + Behind უკან + Overprint ზედდაბეჭდვა + Current Brush Load ფუნჯზე აღებული საღებავი + Load Brush ფუნჯზე საღებავის აღება + Clean Brush ფუნჯის გაწმენდა + Load the brush after each stroke ყოველი შტრიხის შემდეგ ფუნჯზე საღებავის აღება + Clean the brush after each stroke ყოველი შტრიხის შემდეგ ფუნჯის გაწმენდა + Animation Options ანიმაციის პარამეტრები + Entire Document მთელი დოკუმენტი + Filter Options ფილტრის პარამეტრები + Layer Bounds შრის საზღვრები + Layers Panel Options შრეების პანელის პარამეტრები + New Artboard… ახალი სამუშაო დაფა… + New Group… ახალი ჯგუფი… + Prevent Auto-Nesting ავტომატური ჩადგმის აკრძალვა + Show Layers შრეების ჩვენება + Thumbnail Contents ესკიზის შიგთავსი + Thumbnail Size ესკიზის ზომა + Use Default Masks on Fill Layers შევსების შრეებზე ნაგულისხმევი ნიღბების გამოყენება + Expand New Effects ახალი ეფექტების გაშლა + Add “copy” to Copied Layers and Groups დაკოპირებულ შრეებსა და ჯგუფებზე „copy“-ის დამატება + Preview on canvas when reordering layers შრეების გადალაგებისას ტილოზე წინასწარი ნახვა + Show layer mask badges შრის ნიღბის ნიშნების ჩვენება + Blending Options ({filter}) შერევის პარამეტრები ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) შრის ეფექტებისა და ჭკვიანი ფილტრების დამალვა ({key}+დაწკაპუნება: ყველა შრე) + Show the layer's effects and Smart Filters ({key}-click: all layers) შრის ეფექტებისა და ჭკვიანი ფილტრების ჩვენება ({key}+დაწკაპუნება: ყველა შრე) + Wait for the current job to finish. დაელოდეთ მიმდინარე დავალების დასრულებას. + The document or Smart Filter changed. Reopen its dialog. დოკუმენტი ან ჭკვიანი ფილტრი შეიცვალა. ხელახლა გახსენით მისი დიალოგი. + Opacity must be between 0% and 100%. გაუმჭვირვალობა უნდა იყოს 0%-სა და 100%-ს შორის. + Dissolve is approximated: it blends without Photoshop's dither. „გაფანტვა“ მიახლოებითია: შერევა Photoshop-ის დიზერინგის გარეშე ხდება. + New Snapshot… ახალი სნეპშოტი… + History Options… ისტორიის პარამეტრები… +# History panel › History Options… (#2966) + Allow Non-Linear History არაწრფივი ისტორიის დაშვება + Make Layer Visibility Changes Undoable შრის ხილვადობის ცვლილებებისთვის დაბრუნების ჩართვა + Paints from the History panel's source state ხატავს ისტორიის პანელის წყარო მდგომარეობიდან + Sets the source for the history brush ადგენს ისტორიის ფუნჯის წყაროს + Bicubic Automatic ბიკუბური, ავტომატური + Bicubic Smoother ბიკუბური, უფრო გლუვი + Bicubic Sharper ბიკუბური, უფრო მკვეთრი + 50% Threshold 50%-იანი ზღვარი + Pattern Dither პატერნული დიზერინგი + Diffusion Dither დიფუზიური დიზერინგი + Halftone Screen ნახევარტონური რასტრი + Custom Pattern მორგებული პატერნი + Frequency: სიხშირე: + Lines/Inch ხაზი/დუიმი + Lines/Centimeter ხაზი/სანტიმეტრი + Ellipse ელიფსი + DDS Options DDS-ის პარამეტრები + Generate Mipmaps მიპმაპების გენერაცია + There is no color table to save. შესანახი ფერთა ცხრილი არ არის. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file}-ში შეინახა ფერი: {n} + GPU adapter GPU ადაპტერი + +# Strings added upstream (sync 2026-10-22) + Mask Options… ნიღბის პარამეტრები… + Layer Mask Display Options… შრის ნიღბის ჩვენების პარამეტრები… + Create warped text დეფორმირებული ტექსტის შექმნა + Override Tilt X X-დახრის უგულებელყოფა + Override Tilt Y Y-დახრის უგულებელყოფა + Rename Path… ტრაექტორიის სახელის შეცვლა… + LUT folder LUT-ების საქაღალდე + Retry GPU tries the graphics processor again now. „GPU-ის ხელახლა ცდა“ გრაფიკული პროცესორის გამოყენებას ახლავე ხელახლა სცდის. + GPU acceleration resumed. GPU-აჩქარება აღდგა. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. შეზღუდვის დასამატებლად გამოსახულებაზე გაავლეთ; Shift — ჰორიზონტალური/ვერტიკალური; შეზღუდვის შესაცვლელად ან წასაშლელად — მასზე მარჯვენა დაწკაპუნება. + JPEG Options JPEG-ის პარამეტრები + Image Options გამოსახულების პარამეტრები + Format Options ფორმატის პარამეტრები + Baseline ("Standard") საბაზისო („სტანდარტული“) + Baseline Optimized საბაზისო ოპტიმიზებული + Copyright Notice საავტორო უფლების შენიშვნა + Document Title დოკუმენტის სათაური + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels აქტიურ შრეს აქ პიქსელები არ აქვს — ჩართეთ „ყველა შრიდან ნიმუშის აღება“ ან აირჩიეთ შრე, რომელსაც პიქსელები აქვს + Load Contours… კონტურების ჩატვირთვა… + Replace Contours… კონტურების ჩანაცვლება… + Save Contours… კონტურების შენახვა… + Delete Contour კონტურის წაშლა + Loaded contours from {file} {file}-დან ჩაიტვირთა კონტურები + Saved contours to {file} {file}-ში შეინახა კონტურები + New Action… ახალი მოქმედება… + Start Recording ჩაწერის დაწყება + Stop Recording ჩაწერის შეჩერება + Highlight Mode ღია ტონების რეჟიმი + Highlight Color ღია ტონების ფერი + Highlight Opacity ღია ტონების გაუმჭვირვალობა + Shadow Mode ჩრდილის რეჟიმი + Shadow Color ჩრდილის ფერი + Shadow Opacity ჩრდილის გაუმჭვირვალობა + Do not append "copy" to filename when saving a copy ასლის შენახვისას ფაილის სახელს „copy“ არ დაემატოს + Graphics memory: {size} გრაფიკული მეხსიერება: {size} + {path}: the path is not valid Unicode; save under another name. {path}: გზა არ არის სწორი Unicode; შეინახეთ სხვა სახელით. + Demosaic დემოზაიკა + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. როგორ ივსება კამერის ფერადი ფილტრის მოზაიკა. გამოიყენება ფაილის გახსნისას; წინასწარი ნახვა აჩვენებს მეთოდს, რომლითაც ფაილი გაიხსნა. + AHD (adaptive homogeneity) AHD (ადაპტური ერთგვაროვნება) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: ყველაზე სუფთა კიდეები და ყველაზე ნაკლები ფერადი შარავანდი, ყველაზე ნელი. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. გრადიენტით კორექტირებული ინტერპოლაცია: მკვეთრი და სწრაფი, წვრილ დეტალებზე მცირე ფერადი შარავანდით. + The fastest and the softest, with colour fringing on fine detail. ყველაზე სწრაფი და ყველაზე რბილი, წვრილ დეტალებზე ფერადი შარავანდით. + “{name}” already exists. Replace it? „{name}“ უკვე არსებობს. ჩავანაცვლოთ? + The name in the save dialog was “{name}”. შენახვის დიალოგში სახელი იყო „{name}“. + Move Up აწევა + Move Down ჩამოწევა + Previous Brush წინა ფუნჯი + Next Brush შემდეგი ფუნჯი + First Brush პირველი ფუნჯი + Last Brush ბოლო ფუნჯი + Preview size გადახედვის ზომა + Confirm or cancel the open action step dialog first ჯერ დაადასტურეთ ან გააუქმეთ მოქმედების ნაბიჯის გახსნილი დიალოგი + Pro Medium Gray Pro საშუალო ნაცრისფერი + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ღია ნაცრისფერი + Pro Light Pro ღია + Resize Windows to Fit ფანჯრების ზომის მორგება + EXR EXR + Automatic paragraph direction აბზაცის ავტომატური მიმართულება + Left-to-right paragraph direction აბზაცის მიმართულება მარცხნიდან მარჯვნივ + Right-to-left paragraph direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ + Not rendered: this adjustment ({key}) is kept as imported. არ არის დარენდერებული: ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით. + Print preview image ამობეჭდვამდე ნახვის გამოსახულება + +# Strings added upstream (sync 2026-10-27) + 105% 105% + 110% 110% + 115% 115% + 120% 120% + From: საიდან: + Full Document მთელი დოკუმენტი + Merged Layers შერწყმული შრეები + Damaged Photoshop document დაზიანებული Photoshop-ის დოკუმენტი + The document “{name}” may be damaged. დოკუმენტი „{name}“ შეიძლება დაზიანებული იყოს. + Some layers, pixels or masks could not be read. Do you want to continue opening it? ზოგიერთი შრის, პიქსელის ან ნიღბის წაკითხვა ვერ მოხერხდა. გსურთ გახსნის გაგრძელება? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ და ორიგინალს გადააწერთ, წაუკითხავი მონაცემები შეიძლება სამუდამოდ დაიკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. + Alt-click a plane to set the clone source სიბრტყეზე Alt-დაწკაპუნებით მიუთითეთ კლონირების წყარო + Too many clone strokes: apply Vanishing Point first კლონირების შტრიხები ზედმეტად ბევრია: ჯერ გამოიყენეთ გაქრობის წერტილი + Calibration კალიბრაცია + Red Primary წითელი ძირითადი + Green Primary მწვანე ძირითადი + Blue Primary ლურჯი ძირითადი + Write xmp sidecars XMP თანმხლები ფაილების ჩაწერა + Set the tone sliders from the image ტონის სლაიდერების დაყენება გამოსახულების მიხედვით + Settings from {name} პარამეტრები {name}-დან + Settings from {name}; {count} not supported: {names} პარამეტრები {name}-დან; მხარდაუჭერელი ({count}): {names} + Could not write {name}: {error} {name}-ის ჩაწერა ვერ მოხერხდა: {error} + Kept {name}: written by another application {name} შენარჩუნებულია: ჩაწერილია სხვა აპლიკაციის მიერ + Shadow Tint ჩრდილების ტონირება + Check for Updates… განახლებების შემოწმება… + Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ თქვენი სისტემის ინსტალატორი. + Automatically Check for Updates განახლებების ავტომატური შემოწმება + Import Styles… სტილების იმპორტი… + Export Selected Styles… მონიშნული სტილების ექსპორტი… + Export Styles… სტილების ექსპორტი… + {file} is not a style library (.asl) {file} სტილების ბიბლიოთეკა არ არის (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file}-დან იმპორტირებულია სტილი: {n} +@plural Exported {n} style to {file}|Exported {n} styles to {file} სტილი: {n} ექსპორტირებულია {file}-ში + Hue Cube ფერის ტონის კუბი + Brightness Cube სიკაშკაშის კუბი + Color Wheel ფერთა წრე + RGB Sliders RGB სლაიდერები + HSB Sliders HSB სლაიდერები + CMYK Sliders CMYK სლაიდერები + Lab Sliders Lab სლაიდერები + Web Color Sliders ვებფერის სლაიდერები + Copy Color as HTML ფერის კოპირება HTML კოდად + Copy Color's Hex Code ფერის Hex კოდის კოპირება + RGB Spectrum RGB სპექტრი + CMYK Spectrum CMYK სპექტრი + Grayscale Ramp ნაცრისფერი ტონების შკალა + Current Colors მიმდინარე ფერები + Make Ramp Web Safe ვებისთვის უსაფრთხო შკალა + Art History Brush Tool მხატვრული ისტორიის ფუნჯის ხელსაწყო + Tight Short მჭიდრო მოკლე + Tight Medium მჭიდრო საშუალო + Tight Long მჭიდრო გრძელი + Loose Medium თავისუფალი საშუალო + Loose Long თავისუფალი გრძელი + Dab ლაქა + Tight Curl მჭიდრო ხვია + Loose Curl თავისუფალი ხვია + Stylized dabs from a history state სტილიზებული ლაქები ისტორიის მდგომარეობიდან + New Group from Layers… ახალი ჯგუფი შრეებიდან… + Show Favorite Fonts რჩეული შრიფტების ჩვენება + No favorite fonts yet: click the star beside a font to add it. რჩეული შრიფტები ჯერ არ არის: დასამატებლად დაწკაპეთ ვარსკვლავზე შრიფტის გვერდით. + Favorite fonts რჩეული შრიფტები + Add to Favorites რჩეულებში დამატება + Remove from Favorites რჩეულებიდან ამოშლა + Show All Fonts ყველა შრიფტის ჩვენება + All Classes ყველა კლასი + Blackletter გოთიკური + Decorative დეკორატიული + Font preview: შრიფტის ნიმუში: + Handwritten ხელნაწერი + Hide Font Classes შრიფტის კლასების დამალვა + Hide Styles სტილების დამალვა + Monospace მონოსიგანიანი + No fonts match these filters. ამ ფილტრებს არცერთი შრიფტი არ შეესაბამება. + No similar fonts: the current font doesn't say what it looks like. მსგავსი შრიფტები არ არის: მიმდინარე შრიფტი არ აღწერს, როგორ გამოიყურება. + OpenType font OpenType შრიფტი + Sans Serif უსერიფო + Script კალიგრაფიული + Serif სერიფიანი + Show Font Classes შრიფტის კლასების ჩვენება + Show Similar Fonts მსგავსი შრიფტების ჩვენება + Show Styles სტილების ჩვენება + Show Variable Fonts ცვლადი შრიფტების ჩვენება + Slab Serif სლაბ-სერიფი + TrueType font TrueType შრიფტი + Variable font ცვლადი შრიფტი + Font size presets შრიფტის ზომის პრესეტები + Leading presets სტრიქონთა დაშორების პრესეტები + Tracking presets ტრეკინგის პრესეტები +# Select and Mask workspace + Color Aware ფერის გათვალისწინებით + Decontaminate Colors ფერების გაწმენდა + Edge Detection კიდის აღმოჩენა + Global Refinements გლობალური დაზუსტებები + High Quality Preview მაღალი ხარისხის წინასწარი ნახვა + Object Aware ობიექტის გათვალისწინებით + Real-Time Refinement დაზუსტება რეალურ დროში + Refine Edge Brush Tool კიდის დაზუსტების ფუნჯის ხელსაწყო + Refine Hair თმის დაზუსტება + Refine Mode დაზუსტების რეჟიმი + Remember Settings პარამეტრების დამახსოვრება + View Mode ხედის რეჟიმი + Object Aware needs a segmentation model, which isn't available yet „ობიექტის გათვალისწინებით“ რეჟიმს სჭირდება სეგმენტაციის მოდელი, რომელიც ჯერ მიუწვდომელია + Refine Hair needs hair detection, which isn't available yet „თმის დაზუსტებას“ სჭირდება თმის აღმოჩენა, რომელიც ჯერ მიუწვდომელია + Select and Mask was closed: its document is no longer the active one „მონიშვნა და ნიღაბი“ დაიხურა: მისი დოკუმენტი აღარ არის აქტიური + Select and Mask was closed: another layer was selected „მონიშვნა და ნიღაბი“ დაიხურა: მონიშნულია სხვა შრე + Path operations ტრაექტორიის ოპერაციები + Drag to move the selected path გადაათრიეთ მონიშნული ტრაექტორიის გადასაადგილებლად + Move effect up ეფექტის აწევა + Move effect down ეფექტის ჩამოწევა + Delete effect ეფექტის წაშლა + Current Layer: მიმდინარე შრე: + The path was kept as the Work Path ტრაექტორია შენარჩუნდა სამუშაო ტრაექტორიად + The path was discarded ტრაექტორია გაუქმდა + Select Layer Under Cursor კურსორის ქვეშ არსებული შრის მონიშვნა + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დაწკაპეთ მარჯვენა ღილაკით. + CPU Rendering CPU-რენდერინგი + Calculating… გამოითვლება… + Click to refresh histogram with uncached data დაწკაპეთ ჰისტოგრამის ქეშის გარეშე მონაცემებით განსაახლებლად + Count: რაოდენობა: + Drawn on the CPU: {reason}. Click for System Info. დახატულია CPU-ზე: {reason}. დაწკაპეთ სისტემის ინფორმაციის სანახავად. + Level: დონე: + Percentile: პროცენტილი: + Show Channels in Color არხების ფერად ჩვენება + Show Statistics სტატისტიკის ჩვენება + Source: წყარო: + Uncached Refresh განახლება ქეშის გარეშე + Convert to Shape… ფიგურად გარდაქმნა… + Delete Hidden Layers დამალული შრეების წაშლა + Fit Layer on Screen შრის ეკრანში ჩატევა + Flow: ნაკადი: + Refine Edge… კიდის დაზუსტება… + Reset Essentials ძირითადის საწყისზე დაბრუნება + Rounded Rectangle Tool მომრგვალებული მართკუთხედის ხელსაწყო + Status Bar სტატუსის ზოლი + Tile Horizontally ჰორიზონტალურად განლაგება + Tile Vertically ვერტიკალურად განლაგება + Edit Brush… ფუნჯის რედაქტირება… +@plural {n} document|{n} documents {n} დოკუმენტი +@plural {n} pixel|{n} pixels {n} პიქსელი +cameraRaw Before (Y) მანამდე (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw ფილტრი ({layer}) +cameraRaw Camera Raw isn't open Camera Raw არ არის გახსნილი +cameraRaw Cancel გაუქმება +cameraRaw Cancelled opening {name} {name}-ის გახსნა გაუქმდა +cameraRaw Cancelled {label} გაუქმდა: {label} +cameraRaw Color Priority ფერის პრიორიტეტი +cameraRaw Developing {name} მუშავდება {name} +cameraRaw Full-resolution preview unavailable სრული გარჩევადობის გადახედვა მიუწვდომელია +cameraRaw Grain მარცვლოვნება +cameraRaw Highlight Clipping Warning (O) ღია ტონების მოკვეთის გაფრთხილება (O) +cameraRaw Highlight Priority ღია ტონების პრიორიტეტი +cameraRaw Load Settings… პარამეტრების ჩატვირთვა… +cameraRaw Load settings from a Camera Raw .xmp file პარამეტრების ჩატვირთვა Camera Raw-ის .xmp ფაილიდან +cameraRaw Loaded Camera Raw settings from {file} Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან +cameraRaw Noise Reduction ხმაურის შემცირება +cameraRaw OK OK +cameraRaw Open გახსნა +cameraRaw Opened {name} გაიხსნა {name} +cameraRaw Paint Overlay საღებავით გადაფარვა +cameraRaw Save Settings… პარამეტრების შენახვა… +cameraRaw Save these settings as a Camera Raw .xmp file ამ პარამეტრების შენახვა Camera Raw-ის .xmp ფაილად +cameraRaw Shadow Clipping Warning (U) ჩრდილების მოკვეთის გაფრთხილება (U) +cameraRaw Sharpening გამკვეთრება +cameraRaw Show Lab Color Readouts Lab ფერის მნიშვნელობების ჩვენება +cameraRaw Show Red at 3 o'clock წითლის ჩვენება 3 საათის მიმართულებით +cameraRaw Show Selected Region მონიშნული რეგიონის ჩვენება +cameraRaw Show Skin Tone Indicator კანის ტონის მაჩვენებლის ჩვენება +cameraRaw Show Vectorscope ვექტორსკოპის ჩვენება +cameraRaw Tone Histogram ტონების ჰისტოგრამა +cameraRaw Vignetting ვინიეტირება +cameraRaw White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია + No matching fonts შესაბამისი შრიფტები არ არის + +# Sync 2026-11-03: Load Actions summary, SVG Smart Objects, zoom field, Shape Fill/Stroke popover +@plural {n} action file loaded|{n} action files loaded ჩატვირთულია მოქმედებების ფაილი: {n} + {set} from {file} replaced the one from {earlier} {file}-დან {set}-მა ჩაანაცვლა {earlier}-დან ჩატვირთული +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} ვერ ჩაიტვირთა ფაილი: {n} — {errors} +smartSvg Choose executable… შესრულებადი ფაილის არჩევა… +smartSvg Choose the application used to edit this SVG: აირჩიეთ აპლიკაცია, რომლითაც ეს SVG დარედაქტირდება: +smartSvg Edit SVG Smart Object SVG ჭკვიანი ობიექტის რედაქტირება +smartSvg Other application სხვა აპლიკაცია +smartSvg PhotoCraft (internal editor) PhotoCraft (ჩაშენებული რედაქტორი) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-რედაქტორი გაიხსნა. ამ ჭკვიანი ობიექტის განსაახლებლად შეინახეთ SVG. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. შეინახეთ გახსნილ SVG ფაილში. ყოველი შენახული ცვლილება PhotoCraft-ში ერთ დაბრუნების ნაბიჯად იქცევა. +smartSvg Stop syncing this Smart Object ამ ჭკვიანი ობიექტის სინქრონიზაციის შეწყვეტა +smartSvg Updates wait while another document or an editing dialog is active. განახლება დაელოდება, სანამ სხვა დოკუმენტი ან რედაქტირების დიალოგი აქტიურია. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS შიგთავსი SVG-ის სახით დარედაქტირდება. შენახვა ჩაანაცვლებს ჩაშენებულ წყაროს; ფორმატისთვის დამახასიათებელი შესაძლებლობები შეიძლება შეიცვალოს. + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. არასწორი რიცხვითი მნიშვნელობა. საჭიროა მთელი რიცხვი {min}%-დან {max}%-მდე. ჩასმულია წინა მნიშვნელობა. + Recently Used Colors ბოლოს გამოყენებული ფერები + Stop Color გადასვლის წერტილის ფერი + Shapes can't use patterns yet ფიგურებს პატერნების გამოყენება ჯერ არ შეუძლიათ + Memory usage percent მეხსიერების გამოყენება (%) + Save Full-Size Composite in .pcraft Files სრული ზომის გაერთიანებული გამოსახულების შენახვა .pcraft ფაილებში + Scratch Size Limit (MB, 0 = No Limit) დროებითი დისკის ზომის ლიმიტი (მბ, 0 = ლიმიტის გარეშე) +# Sync 2026-11-07: strings added upstream + Clear Recent File List ბოლოს გახსნილი ფაილების სიის გასუფთავება + New Document… ახალი დოკუმენტი… + Open Document… დოკუმენტის გახსნა… + Consolidate All to Here ყველას აქ გაერთიანება + Move to New Window ახალ ფანჯარაში გადატანა + Reveal in Explorer Explorer-ში ჩვენება + Plugins… პლაგინები… + Embed Linked დაკავშირებულის ჩაშენება + Embed All Linked ყველა დაკავშირებულის ჩაშენება + Plugins პლაგინები + Working CMY Plates სამუშაო CMY ფირფიტები + Legacy Macintosh RGB (Gamma 1.8) მოძველებული Macintosh RGB (გამა 1.8) + Internet Standard RGB (sRGB) ინტერნეტის სტანდარტული RGB (sRGB) + Contextual Task Bar კონტექსტური ამოცანების ზოლი + Hide bar ზოლის დამალვა + Reset bar position ზოლის პოზიციის ჩამოყრა + Pin bar position ზოლის პოზიციის ჩამაგრება + Move bar ზოლის გადაადგილება + More options მეტი პარამეტრი + Source: {name} წყარო: {name} + No Thumbnails ესკიზების გარეშე + Small Thumbnails პატარა ესკიზები + Medium Thumbnails საშუალო ესკიზები + Large Thumbnails დიდი ესკიზები + Clip Thumbnails to Document Bounds ესკიზების დოკუმენტის საზღვრებით მოჭრა + Make Shape ფიგურის შექმნა + Background (default) ფონი (ნაგულისხმევი) + Transparent (default) გამჭვირვალე (ნაგულისხმევი) + The History Brush source was deleted: set a new source in the History panel ისტორიის ფუნჯის წყარო წაიშალა: მიუთითეთ ახალი წყარო ისტორიის პანელში + The GPU compositor is still being prepared GPU-კომპოზიტორი ჯერ კიდევ მზადდება + Message შეტყობინება + Playback Options… შესრულების პარამეტრები… + Playback Options შესრულების პარამეტრები + Accelerated დაჩქარებული + Step by Step ნაბიჯ-ნაბიჯ + Pause For: პაუზა: + Pause For პაუზა + seconds წამი + Create a single history state ისტორიის ერთი მდგომარეობის შექმნა + Generator enabled გენერატორი ჩართულია + On your computer თქვენს კომპიუტერზე + New file ახალი ფაილი + Learn სწავლა + Website ვებსაიტი + A frame supports affine transforms; select its image for perspective transforms ჩარჩო აფინურ გარდაქმნებს უჭერს მხარს; პერსპექტიული გარდაქმნისთვის მონიშნეთ მისი გამოსახულება + A frame supports affine transforms; select its image to warp it ჩარჩო აფინურ გარდაქმნებს უჭერს მხარს; დეფორმაციისთვის მონიშნეთ მისი გამოსახულება + Convert to Frame… ჩარჩოდ გარდაქმნა… + Create a new custom frame ახალი მორგებული ჩარჩოს შექმნა + Create a new polygonal frame ახალი მრავალკუთხა ჩარჩოს შექმნა + Create a new triangular frame ახალი სამკუთხა ჩარჩოს შექმნა + Fit Content to Frame შიგთავსის ჩარჩოზე მორგება + Fit Frame to Content ჩარჩოს შიგთავსზე მორგება + Frame coordinates must be finite and within 600000 pixels ჩარჩოს კოორდინატები სასრული უნდა იყოს და 600000 პიქსელს არ უნდა აღემატებოდეს + Import Image… გამოსახულების იმპორტი… + Invalid frame transform ჩარჩოს არასწორი გარდაქმნა + Invalid nested file drop ფაილის ჩაგდების არასწორი მოთხოვნა + New Frame ახალი ჩარჩო + Replace Image… გამოსახულების ჩანაცვლება… + The frame is locked ჩარჩო ჩაკეტილია + The frame is unavailable ჩარჩო მიუწვდომელია + Channel Options… არხის პარამეტრები… + Specify Channels: არხების მითითება: + Not enough grayscale documents for this mode. ამ რეჟიმისთვის ნაცრისფერი ტონების დოკუმენტები საკმარისი არ არის. + Pick a different document for each channel. თითოეული არხისთვის სხვა დოკუმენტი აირჩიეთ. + A picked document is no longer open. ერთ-ერთი არჩეული დოკუმენტი აღარ არის გახსნილი. + A picked document is no longer a grayscale document of the same size. ერთ-ერთი არჩეული დოკუმენტი აღარ არის იმავე ზომის ნაცრისფერი ტონების დოკუმენტი. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 43f7dea83e..4d4815caf2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -166,6 +166,7 @@ Right 오른쪽 Open Recent 최근 파일 열기 Clear Recent Files 최근 파일 목록 지우기 + Clear Recent File List 최근 파일 목록 지우기 Remove from Recent 최근 항목에서 제거 Remove {name} from Recent 최근 항목에서 {name} 제거 Close 닫기 @@ -174,6 +175,10 @@ Restore 이전 크기로 복원 Close All 모두 닫기 Close Others 다른 문서 닫기 + New Document… 새 문서… + Open Document… 열기… + Consolidate All to Here 모두 여기로 통합 + Move to New Window 새 창으로 이동 Save 저장 Save As… 다른 이름으로 저장… Save a Copy… 사본 저장… @@ -226,6 +231,7 @@ Create Smart Object after Loading Layers 레이어를 불러온 후 스마트 오브젝트 만들기 Reveal in Finder Finder에서 보기 Show in Explorer 파일 탐색기에서 보기 + Reveal in Explorer 파일 탐색기에서 보기 Show in Folder 폴더에서 보기 File Info… 파일 정보… Print… 인쇄… @@ -314,6 +320,7 @@ Units & Rulers… 단위 및 눈금자… Guides, Grid & Slices… 안내선, 격자 및 분할 영역… Plug-ins… 플러그인… + Plugins… 플러그인… Type… 문자… Enhanced Controls… 고급 컨트롤… Camera Raw… Camera Raw… @@ -362,6 +369,8 @@ Match Color… 색상 일치… Replace Color… 색상 대체… Equalize 균일화 + Equalize Selected Area Only 선택 영역만 균일화 + Equalize Entire Image Based On Selected Area 선택 영역을 기준으로 전체 이미지 균일화 Auto Tone 자동 톤 Auto Contrast 자동 대비 Auto Color 자동 색상 @@ -464,6 +473,8 @@ Smart Object 스마트 오브젝트 Convert to Smart Object 스마트 오브젝트로 변환 Convert to Embedded 포함된 오브젝트로 변환 + Embed Linked 연결된 항목 포함 + Embed All Linked 모든 연결된 항목 포함 Convert to Layers 레이어로 변환 Convert to Linked… 연결된 오브젝트로 변환… Edit Contents 내용 편집 @@ -537,7 +548,9 @@ Unite Shapes 모양 합치기 Subtract Front Shape 앞쪽 모양 빼기 Intersect Shape Areas 모양 영역 교차 + Unite Shapes at Overlap 모양 영역 교차 Exclude Overlapping Shapes 겹치는 모양 제외 + Subtract Shapes at Overlap 겹치는 모양 제외 Merge Shape Components 모양 구성 요소 병합 Path operations 패스 작업 Align 정렬 @@ -614,7 +627,6 @@ Deselect 선택 해제 Reselect 다시 선택 Inverse 반전 - Inverse Selection 선택 영역 반전 Similar 유사한 항목 Color Range… 색상 범위… Focus Area… 초점 영역… @@ -721,6 +733,7 @@ Minimum… 최소값… Offset… 오프셋… Plug-ins 플러그인 + Plugins 플러그인 Proof Setup 교정 설정 Proof Colors 교정 색상 Gamut Warning 색상 범위 경고 @@ -729,12 +742,12 @@ Working Magenta Plate 작업용 마젠타 판 Working Yellow Plate 작업용 노랑 판 Working Black Plate 작업용 검정 판 - Working CMY Plate 작업용 CMY 판 - Legacy Macintosh RGB 기존 Macintosh RGB - Internet Standard RGB 인터넷 표준 RGB + Working CMY Plates 작업용 CMY 판 + Legacy Macintosh RGB (Gamma 1.8) 기존 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) 인터넷 표준 RGB (sRGB) Monitor RGB 모니터 RGB - Color Blindness — Protanopia-type 색각 이상 — 적색맹 - Color Blindness — Deuteranopia-type 색각 이상 — 녹색맹 + Color Blindness - Protanopia-type 색각 이상 - 적색맹 + Color Blindness - Deuteranopia-type 색각 이상 - 녹색맹 Zoom In 확대 Zoom Out 축소 Fit on Screen 화면에 맞추기 @@ -813,7 +826,7 @@ Consolidate All to Tabs 모두 탭으로 통합 Float in Window 별도 창으로 띄우기 Float All in Windows 모두 별도 창으로 띄우기 - New Window for Document 문서의 새 창 + New Window 새 창 Workspace 작업 영역 Essentials 필수 Graphic and Web 그래픽 및 웹 @@ -876,6 +889,12 @@ Timeline 타임라인 Tool Presets 도구 사전 설정 Tools 도구 + Contextual Task Bar 상황별 작업 표시줄 + Hide bar 막대 숨기기 + Reset bar position 막대 위치 재설정 + Pin bar position 막대 위치 고정 + Move bar 막대 이동 + More options 추가 옵션 About PhotoCraft PhotoCraft 정보 PhotoCraft Website PhotoCraft 웹사이트 PhotoCraft on GitHub GitHub의 PhotoCraft @@ -1055,7 +1074,7 @@ Click to add a point · drag off or {key}-click or Delete to remove 클릭하여 점 추가 · 바깥으로 드래그하거나 {key} 키를 누른 채 클릭하거나 Delete 키로 제거 Click to apply, {key}-click to clear overrides, double-click for Style Options 클릭하여 적용, {key} 키를 누른 채 클릭하여 재정의 지우기, 두 번 클릭하여 스타일 옵션 열기 Click to edit the gradient 클릭하여 그레이디언트 편집 - Click to restore the current colour 클릭하여 현재 색상 복원 + Click to restore the current color 클릭하여 현재 색상 복원 Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out 클릭하여 확대 · {key} 키를 누른 채 클릭하여 축소 · 오른쪽/왼쪽으로 드래그하여 확대/축소 Click with the Note tool to add a note. 메모 도구로 클릭하여 메모를 추가하세요. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel 클릭: 모서리 · 드래그: 곡선 · 첫 점 클릭: 닫기 · {key}: 완료 · Esc: 취소 @@ -1144,7 +1163,7 @@ Darks 어두운 계열 Data Sets 데이터 세트 Default 기본값 - Default colours (D) 기본 색상 (D) + Default Foreground and Background Colors (D) 기본 색상 (D) Default printer 기본 프린터 Default restored. 기본값을 복원했습니다. Define 정의 @@ -1421,7 +1440,7 @@ Opacity Jitter 불투명도 지터 Opacity: 불투명도: Open a document to define variables. 변수를 정의하려면 문서를 여세요. - Open a document to select a colour range. 색상 범위를 선택하려면 문서를 여세요. + Open a document to select a color range. 색상 범위를 선택하려면 문서를 여세요. Open a document to use the timeline. 타임라인을 사용하려면 문서를 여세요. Optical 광학 Optimized 최적화 @@ -1497,7 +1516,6 @@ Purity 순도 Quality 품질 Quick Actions 빠른 작업 - Quick Export As PNG PNG로 빠르게 내보내기 Radial Gradient 방사형 그레이디언트 Radius 반경 Radius: 반경: @@ -1640,7 +1658,7 @@ Subtract from Sample 샘플에서 빼기 Subtract from selection ({key}) 선택 영역에서 빼기 ({key}) Suggestions: 제안: - Swap colours (X) 색상 교환 (X) + Switch Foreground and Background Colors (X) 색상 교환 (X) Swaps height and width 높이와 너비 바꾸기 Switch between free transform and warp modes 자유 변형과 뒤틀기 모드 간 전환 Switch theme 테마 전환 @@ -1744,7 +1762,7 @@ Choke 조이기 Classic 클래식 Click to add text 클릭하여 텍스트 추가 - Click to fill similar colours 클릭하여 유사한 색상 채우기 + Click to fill similar colors 클릭하여 유사한 색상 채우기 Clone Stamp Tool 복제 도장 도구 Color Burn 색상 번 Color Dodge 색상 닷지 @@ -1845,6 +1863,7 @@ Move Tool 이동 도구 Could not use the move tool because the layer is locked. 레이어가 잠겨 있어 이동 도구를 사용할 수 없습니다. Could not complete the Free Transform command because the layer is locked. 레이어가 잠겨 있어 자유 변형 명령을 완료할 수 없습니다. + Could not transform because the layer is locked. 레이어가 잠겨 있어 변형할 수 없습니다. Multiply 곱하기 My Brushes 내 브러시 Neutrals 중립색 @@ -1903,7 +1922,7 @@ Rectangular Marquee Tool 사각형 선택 윤곽 도구 Reds 빨강 계열 Reflected 반사 - Sample: foreground colour 샘플: 전경색 + Sample: foreground color 샘플: 전경색 Sampled Colors 샘플링한 색상 Satin 새틴 Save Pixel Aspect Ratio 픽셀 종횡비 저장 @@ -1924,6 +1943,7 @@ Soften 부드럽게 하기 Source 원본 Source: set 원본: 설정됨 + Source: {name} 원본: {name} Sponge Tool 스폰지 도구 Spot Healing Brush Tool 스팟 복구 브러시 도구 Spread 확산 @@ -1941,7 +1961,7 @@ Transfer 전달 Triangle Tool 삼각형 도구 Unsaved changes 저장하지 않은 변경 사항 - Uses the foreground and background colours. 전경색과 배경색을 사용합니다. + Uses the foreground and background colors. 전경색과 배경색을 사용합니다. Web 웹 Web Large 웹 대형 Web Minimum 웹 최소 @@ -1976,6 +1996,7 @@ Allow scripts to connect 스크립트 연결 허용 Always 항상 Always create smart objects when placing 가져올 때 항상 스마트 오브젝트 만들기 + Skip transform when placing 가져올 때 변형 건너뛰기 Animated zoom 확대/축소 애니메이션 Apply auto tone 자동 톤 적용 Ask 묻기 @@ -1998,6 +2019,11 @@ Show canvas dots 캔버스 점 표시 Clip thumbnails to layer bounds 썸네일을 레이어 경계로 자르기 Clip Thumbnails to Layer Bounds 썸네일을 레이어 경계로 자르기 + No Thumbnails 썸네일 없음 + Small Thumbnails 작은 썸네일 + Medium Thumbnails 중간 썸네일 + Large Thumbnails 큰 썸네일 + Clip Thumbnails to Document Bounds 썸네일을 문서 경계로 자르기 Canvas color 캔버스 색상 Canvas custom color 캔버스 사용자 정의 색상 Color picker 색상 피커 @@ -2115,7 +2141,6 @@ Show only crosshair while painting 칠하는 동안 십자선만 표시 Show slice numbers 분할 영역 번호 표시 Show tooltips 도구 설명 표시 - Enable animations 애니메이션 사용 Show highlight on rollover 롤오버 시 강조 표시 Show transformation values 변형 값 표시 Slice color 분할 영역 색상 @@ -2205,7 +2230,7 @@ Graphics 그래픽 Reset GPU Backend GPU 백엔드 초기화 Applies at next launch. 다음 실행 시 적용됩니다. - Click to sample the foreground colour · {key}-click for background 클릭하여 전경색 샘플링 · {key} 키를 누른 채 클릭하여 배경색 샘플링 + Click to sample the foreground color · {key}-click for background 클릭하여 전경색 샘플링 · {key} 키를 누른 채 클릭하여 배경색 샘플링 {key}-click to set the source {key} 키를 누른 채 클릭하여 원본 설정 {key} to subtract {key} 키로 빼기 Clone source {n} ({key}-click with Clone Stamp to set) 복제 원본 {n} (복제 도장으로 {key} 키를 누른 채 클릭하여 설정) @@ -2264,7 +2289,7 @@ Gradient Options 그레이디언트 옵션 Align with layer 레이어에 맞추기 Reset Alignment 정렬 초기화 - Centre the gradient (offset 0, 0) 그레이디언트를 중앙에 배치(오프셋 0, 0) + Center the gradient (offset 0, 0) 그레이디언트를 중앙에 배치(오프셋 0, 0) The current gradient (pick one in Window › Gradients) 현재 그레이디언트(창 › 그레이디언트에서 선택) Select a type layer to edit its character and paragraph settings. 문자 및 단락 설정을 편집하려면 문자 레이어를 선택하세요. Gradient Editor 그레이디언트 편집기 @@ -2297,7 +2322,7 @@ Vulkan Vulkan No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 선택 영역으로 만들 패스가 없습니다: 펜 도구로 그리거나 패스 패널에서 선택하세요. Cancel opening 열기 취소 - Cancelled opening {name} {name} 열기를 취소했습니다 + Canceled opening {name} {name} 열기를 취소했습니다 Damaged Photoshop document 손상된 Photoshop 문서 The document “{name}” may be damaged. 문서 “{name}”이(가) 손상되었을 수 있습니다. Some layers, pixels or masks could not be read. Do you want to continue opening it? 일부 레이어, 픽셀 또는 마스크를 읽을 수 없습니다. 계속 여시겠습니까? @@ -2307,7 +2332,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. 문서 “{name}”에 포함된 색상 프로파일이 현재 {mode} 작업 색상 공간과 일치하지 않습니다. Working: {working} 작업 색상 공간: {working} Developing {name} {name} 현상 중 - Cancelled {label} {label} 작업을 취소했습니다 + Canceled {label} {label} 작업을 취소했습니다 Esc to cancel Esc 키로 취소 Opened {name} {name} 열림 Opening {name} {name} 여는 중 @@ -2315,7 +2340,7 @@ Saving {name} {name} 저장 중 Saving {percent} 저장 중 {percent} A save to {name} is still running; try again when it finishes {name}에 저장하는 작업이 아직 진행 중입니다. 완료된 후 다시 시도하세요 - {label} is writing the file and can no longer be cancelled {label}: 파일을 쓰는 중이므로 더 이상 취소할 수 없습니다 + {label} is writing the file and can no longer be canceled {label}: 파일을 쓰는 중이므로 더 이상 취소할 수 없습니다 Opening {name}… {name} 여는 중… Opening… 여는 중… Reading… 읽는 중… @@ -3042,7 +3067,7 @@ cameraRaw Color 색상 Discard Layers and Save a Copy 레이어를 삭제하고 사본 저장 Don't show again 다시 표시 안 함 Save failed: {error} 저장 실패: {error} - Save cancelled 저장이 취소되었습니다 + Save canceled 저장이 취소되었습니다 Couldn't place {name}: {error} {name}을(를) 가져올 수 없습니다: {error} Magnetic Lasso Tool 자석 올가미 도구 Frequency 빈도 @@ -3168,7 +3193,7 @@ fontWeight Italic 기울임 Rotate View 보기 회전 Rotate View Tool 보기 회전 도구 Reset View 보기 재설정 - Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기 회전 · Shift로 15° 단위 제한 + Drag around the center to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기 회전 · Shift로 15° 단위 제한 Dual Axis 이중 축 Diagonal 대각선 Transform Symmetry 대칭 변형 @@ -3183,8 +3208,8 @@ fontWeight Italic 기울임 All Layers No Adjustments 모든 레이어(조정 제외) Current & Below No Adjustments 현재 및 이하(조정 제외) Show Sampling Ring 샘플링 링 표시 - Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 - Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 + Out of gamut for printing: click for the nearest printable color 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 + Not a web color: click for the nearest web color 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 Color to Alpha… 색상을 알파로… Transparency Threshold 투명도 한계값 Opacity Threshold 불투명도 한계값 @@ -3276,7 +3301,7 @@ fontWeight Italic 기울임 How often fastening points are placed by themselves 고정점이 자동으로 놓이는 빈도 Scrubby Zoom 스크러비 확대/축소 {mode} Color · {bits} bit {mode} 색상 · {bits}비트 - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 드래그하여 자르기 상자 만들기 · 안쪽을 드래그하여 이동 · 가장자리로 크기 조절({ratio} 비율, {centre} 중심) · 그리는 중 Space로 이동 · 화살표로 미세 이동 · X로 방향 전환 · {commit} 적용 · Esc 취소 + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 드래그하여 자르기 상자 만들기 · 안쪽을 드래그하여 이동 · 가장자리로 크기 조절({ratio} 비율, {centre} 중심) · 그리는 중 Space로 이동 · 화살표로 미세 이동 · X로 방향 전환 · {commit} 적용 · Esc 취소 Appearance Settings… 화면 설정… Add to favorites 즐겨찾기에 추가 Remove from favorites 즐겨찾기에서 제거 @@ -3488,14 +3513,19 @@ curves Smooth 매끄럽게 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 Background (Default) 배경(기본값) + Background (default) 배경(기본값) + Transparent (default) 투명(기본값) Include All Subfolders 모든 하위 폴더 포함 Auto Add/Delete 자동 추가/삭제 Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 인쇄 설정에서도 이미지 반전을 켜지 마세요. 두 번 반전하면 원래대로 돌아갑니다. Print Settings… 인쇄 설정… - Remember to turn off the printer's own colour management in Print Settings… 인쇄 설정에서 프린터 자체의 색상 관리를 끄세요… + Remember to turn off the printer's own color management in Print Settings… 인쇄 설정에서 프린터 자체의 색상 관리를 끄세요… Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + Automatically Create First Snapshot 첫 번째 스냅샷 자동 만들기 + Automatically Create New Snapshot When Saving 저장할 때 새 스냅샷 자동 만들기 + Show New Snapshot Dialog by Default 기본적으로 새 스냅샷 대화 상자 표시 Memory usage percent 메모리 사용 비율(%) Show Favorite Fonts 즐겨찾는 글꼴 표시 No favorite fonts yet: click the star beside a font to add it. 아직 즐겨찾는 글꼴이 없습니다. 글꼴 옆의 별을 클릭하여 추가하세요. @@ -3540,6 +3570,7 @@ curves Smooth 매끄럽게 Automatically Check for Updates 자동으로 업데이트 확인 Paints from the History panel's source state 작업 내역 패널의 소스 상태에서 칠합니다 Sets the source for the history brush 작업 내역 브러시의 소스를 설정합니다 + The History Brush source was deleted: set a new source in the History panel 작업 내역 브러시 소스가 삭제되었습니다: 작업 내역 패널에서 새 소스를 설정하세요 Highlight Mode 하이라이트 모드 Highlight Color 하이라이트 색상 Highlight Opacity 하이라이트 불투명도 @@ -3565,6 +3596,7 @@ curves Smooth 매끄럽게 Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 Make Selection 선택 영역 만들기 + Make Shape 모양 만들기 Bicubic Automatic 쌍입방 자동 Bicubic Smoother 쌍입방 더 부드럽게 Bicubic Sharper 쌍입방 더 선명하게 @@ -3605,6 +3637,7 @@ curves Smooth 매끄럽게 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) Reference point location 기준점 위치 + Toggle reference point 기준점 표시/숨기기 Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. The transform couldn't be applied. 변형을 적용할 수 없습니다. Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다. @@ -3626,12 +3659,12 @@ curves Smooth 매끄럽게 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 Demosaic 디모자이크 - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 카메라 컬러 필터 모자이크를 채우는 방법입니다. 파일을 열 때 적용되며, 미리 보기는 파일을 연 방식으로 표시됩니다. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 카메라 컬러 필터 모자이크를 채우는 방법입니다. 파일을 열 때 적용되며, 미리 보기는 파일을 연 방식으로 표시됩니다. AHD (adaptive homogeneity) AHD (적응형 균질성) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: 가장 깨끗한 가장자리와 가장 적은 색 번짐, 가장 느림. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 기울기 보정 보간: 선명하고 빠르지만 미세한 디테일에 약간의 색 번짐이 있음. - The fastest and the softest, with colour fringing on fine detail. 가장 빠르고 가장 부드러우며, 미세한 디테일에 색 번짐이 있음. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: 가장 깨끗한 가장자리와 가장 적은 색 번짐, 가장 느림. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. 기울기 보정 보간: 선명하고 빠르지만 미세한 디테일에 약간의 색 번짐이 있음. + The fastest and the softest, with color fringing on fine detail. 가장 빠르고 가장 부드러우며, 미세한 디테일에 색 번짐이 있음. “{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까? The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}” Previous Brush 이전 브러시 @@ -3713,6 +3746,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 글꼴 크기 사전 설정 Leading presets 행간 사전 설정 Tracking presets 자간 사전 설정 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 잘못된 숫자 입력입니다. {min}%에서 {max}% 사이의 정수가 필요합니다. 이전 값이 입력되었습니다. Clear Selection 선택 영역 지우기 Color Aware 색상 인식 @@ -3740,3 +3775,428 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color 정지점 색상 Shapes can't use patterns yet 모양에는 아직 패턴을 사용할 수 없습니다 Edit Brush… 브러시 편집… + +# Action Stop message (#8386) + Message 메시지 +# Playback Options (#5212) + Playback Options… 재생 옵션… + Playback Options 재생 옵션 + Accelerated 가속 + Step by Step 단계별 + Pause For: 일시 정지: + Pause For 일시 정지 + seconds 초 + Create a single history state 단일 작업 내역 상태 만들기 + Drag to move the active shape's path or the Work Path 드래그하여 활성 모양의 패스 또는 작업 패스 이동 + Generator enabled 생성기 활성화 + Move Up 위로 이동 + Move Down 아래로 이동 + Grid view 격자 보기 + List view 목록 보기 + On your computer 내 컴퓨터에서 + New file 새 파일 + Learn 학습 + No recent files 최근 파일 없음 + Website 웹사이트 + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms 프레임은 아핀 변환을 지원합니다. 원근 변환을 하려면 프레임의 이미지를 선택하세요 + A frame supports affine transforms; select its image to warp it 프레임은 아핀 변환을 지원합니다. 뒤틀려면 프레임의 이미지를 선택하세요 + Convert to Frame… 프레임으로 변환… + Create a new custom frame 새 사용자 정의 프레임 만들기 + Create a new polygonal frame 새 다각형 프레임 만들기 + Create a new triangular frame 새 삼각형 프레임 만들기 + Fit Content to Frame 내용을 프레임에 맞추기 + Fit Frame to Content 프레임을 내용에 맞추기 + Frame coordinates must be finite and within 600000 pixels 프레임 좌표는 유한한 값이며 600000픽셀 이내여야 합니다 + Import Image… 이미지 가져오기… + Invalid frame transform 잘못된 프레임 변환 + Invalid nested file drop 잘못된 중첩 파일 드롭 + New Frame 새 프레임 + Replace Image… 이미지 바꾸기… + The frame is locked 프레임이 잠겨 있습니다 + The frame is unavailable 프레임을 사용할 수 없습니다 + X X + Y Y + +# Channels panel menu (#11451) + New Channel… 새 채널… + Duplicate Channel 채널 복제 + Delete Channel 채널 삭제 + New Spot Channel… 새 별색 채널… + Merge Spot Channel 별색 채널 병합 + Channel Options… 채널 옵션… + Split Channels 채널 분할 + Merge Channels… 채널 병합… + Specify Channels: 채널 지정: + Not enough grayscale documents for this mode. 이 모드에 필요한 회색 음영 문서가 부족합니다. + Pick a different document for each channel. 채널마다 다른 문서를 선택하십시오. + A picked document is no longer open. 선택한 문서 중 하나가 더 이상 열려 있지 않습니다. + A picked document is no longer a grayscale document of the same size. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다. + +# Rows that #8185 adds on top of the catalog above. + Working CMY Plate 작업용 CMY 판 + Legacy Macintosh RGB 기존 Macintosh RGB + Internet Standard RGB 인터넷 표준 RGB + New Window for Document 문서의 새 창 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 오른쪽 클릭하여 삭제. +@id edit.stroke 획 +@id edit.transform.scale 크기 조절 +@id edit.purge.undo 실행 취소 기록 +cameraRaw Red 빨강 +cameraRaw Orange 주황 +cameraRaw Yellow 노랑 +cameraRaw Green 초록 +cameraRaw Aqua 청록 +cameraRaw Blue 파랑 +cameraRaw Purple 보라 +cameraRaw Magenta 자홍 +cameraRaw Luminance 휘도 +cameraRaw Temperature 색온도 + 3D 3D + About {app} {app} 정보 + Adjustment 조정 + Adjustment Properties 조정 속성 + Adobe RGB (1998) Adobe RGB (1998) + Advanced Blending 고급 혼합 + Apply Layer Comp 레이어 구성 적용 + Arc 호 + Arch 아치 + Artist 아티스트 + Back 뒤로 + Before 적용 전 + Bulge 볼록하게 + Clipping Path 클리핑 패스 + Close Window 창 닫기 + Color Swatches 색상 견본 + Content-Aware Move 내용 인식 이동 + Default Foreground and Background Colors 기본 전경색 및 배경색 + Delete Path 패스 삭제 + Documents 문서 + Duplicate Layer Comp 레이어 구성 복제 + Duplicate Path 패스 복제 + F F + Filename 파일 이름 + Fill Path 패스 칠 + Fish 물고기 + Flag 깃발 + Group · {n} layer 그룹 · 레이어 {n}개 + Hide 숨기기 + Hide Others 다른 앱 숨기기 + Hide {app} {app} 숨기기 + ISO Speed ISO 감도 + Inflate 팽창 + Invert Mask 마스크 반전 + Justification 양쪽 정렬 + Layer Comp 레이어 구성 + Layer Properties 레이어 속성 + Loop 반복 + Magnetic Lasso 자석 올가미 + Make 제조사 + Make Work Path 작업 패스 만들기 + Move Layer 레이어 이동 + New Channel 새 채널 + New Layer Comp 새 레이어 구성 + Patch 패치 + Pencil Tool 연필 도구 + Perspective Crop 원근 자르기 + Pixel 픽셀 + Punctuation 문장 부호 + Quick Selection 빠른 선택 + Quit 종료 + Quit {app} {app} 종료 + RGB RGB + Recovery 복구 + Rectangle 사각형 + Rectangular Selection 사각형 선택 + Rename Layer Comp 레이어 구성 이름 바꾸기 + Reorder Layer 레이어 순서 변경 + Reset Preferences 환경 설정 초기화 + Rise 상승 + Ruler 눈금자 + Search menus 메뉴 검색 + Select All Layers 모든 레이어 선택 + Select Inverse 선택 영역 반전 + Select Layer 레이어 선택 + Services 서비스 + Set Colors 색상 설정 + Shell Lower 아래쪽 조개껍데기 + Shell Upper 위쪽 조개껍데기 + Show All 모두 표시 + Software 소프트웨어 + Squeeze 압축 + Stroke Path 패스에 획 적용 + Switch Foreground and Background Colors 전경색과 배경색 전환 + Symbols 기호 + Twist 비틀기 + Unknown 알 수 없음 + Update Layer Comp 레이어 구성 업데이트 + Vertical Type Tool 세로 문자 도구 + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + shortcut 단축키 +cameraRaw Color Detail 색상 세부 정보 +cameraRaw Color Grading 색상 등급 +cameraRaw Darks 어두운 영역 +cameraRaw Global 전체 +cameraRaw Hue 색조 +cameraRaw Luminance Detail 휘도 세부 정보 +cameraRaw Masking 마스킹 +cameraRaw Roughness 거칠기 +cameraRaw Tint 색조 보정 +cameraRaw Vibrance 활기 +cameraRaw Whites 흰색 계열 +layerLabel No Color 색상 없음 + Make Work Path… 작업 패스 만들기… + Create Vector Mask 벡터 마스크 만들기 + Make Selection… 선택 영역 만들기… + Fill Path… 패스 칠… + Clipping Path… 클리핑 패스… + Free Transform Path 패스 자유 변형 + Copy Fill 칠 복사 + Copy Complete Stroke 전체 획 복사 + Paste Fill 칠 붙여넣기 + Paste Complete Stroke 전체 획 붙여넣기 + Make Symmetry Path 대칭 패스 만들기 + Disable Symmetry Path 대칭 패스 비활성화 + Dark Gray 진한 회색 + Medium Gray 중간 회색 + Select Custom Color… 사용자 지정 색상 선택… + Color Picker (Custom Canvas Color) 색상 선택기(사용자 지정 캔버스 색상) + Rename Channel… 채널 이름 바꾸기… + Convert to Alpha Channel 알파 채널로 변환 + Exit Quick Mask 빠른 마스크 종료 + Color Indicates Masked Areas 색상으로 마스크 영역 표시 + Color Indicates Selected Areas 색상으로 선택 영역 표시 + Up 위로 + To 대상 + Shallow 얕게 + Deep 깊게 + Blend Interior Effects as Group 내부 효과를 그룹으로 혼합 + Blend Clipped Layers as Group 클리핑된 레이어를 그룹으로 혼합 + Transparency Shapes Layer 투명도로 레이어 모양 결정 + Layer Mask Hides Effects 레이어 마스크로 효과 숨기기 + Vector Mask Hides Effects 벡터 마스크로 효과 숨기기 + General Blending 일반 혼합 + This Layer: 현재 레이어: + Nothing to paste: the clipboard holds no image 붙여넣을 이미지가 클립보드에 없습니다. + Preferences saved 환경 설정을 저장했습니다. + Invalid panel layout; restored the default dock 패널 배치가 잘못되어 기본 도킹 배치로 복원했습니다. + Preferences were reset: {error} 환경 설정을 초기화했습니다: {error} + Recovered {name} {name}을(를) 복구했습니다. + Couldn't save preferences: {error}. Retrying… 환경 설정을 저장할 수 없습니다: {error}. 다시 시도하는 중… + Autosave failed: {error} 자동 저장 실패: {error} + Exported presets to {path} 사전 설정을 {path}(으)로 내보냈습니다. + Ran script {name} {name} 스크립트를 실행했습니다. + Sent to the printer ({spooler}) 프린터로 전송했습니다({spooler}). + Printed to {path} {path}(으)로 인쇄했습니다. + Exported {path} ({size}) {path}을(를) 내보냈습니다({size}). + Exported {path} {path}을(를) 내보냈습니다. + Added swatch {name} {name} 견본을 추가했습니다. + Step {step} ({id}) failed: {error} {step}단계({id}) 실패: {error} + Could not rename the action: {error} 액션 이름을 바꿀 수 없습니다: {error} + Action playback failed: {error} 액션 재생 실패: {error} + Clone source set at {x}, {y} 복제 소스를 {x}, {y}에 설정했습니다. + Color management: {error} 색상 관리: {error} + Opened {url} {url}을(를) 열었습니다. + No custom shape "{name}": pick one in the options bar or Window › Shapes 사용자 지정 도형 "{name}"이(가) 없습니다. 옵션 막대 또는 창 › 도형에서 선택하세요. + Saved a copy as {path} 사본을 {path}(으)로 저장했습니다. + Exported contents to {path} 내용을 {path}(으)로 내보냈습니다. + {label} is not available: {reason} {label}을(를) 사용할 수 없습니다: {reason} + Saved {path}; image assets: {n} in {dir} {path} 저장 완료 · 이미지 에셋: {dir}에 {n}개 + {message} (notes: {n}; {warning}) {message}(참고: {n}개; {warning}) + Preferences can't be saved 환경 설정을 저장할 수 없습니다. + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft가 계속 다시 시도합니다. 저장에 성공하기 전까지는 종료 시 환경 설정 변경 사항이 사라집니다. + GPU retry scheduled GPU 재시도가 예약되었습니다. + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. 작업을 저장하고 PhotoCraft를 다시 시작하면 GPU 가속을 다시 시도합니다. 현재 세션에서는 CPU 렌더링을 계속 사용합니다. + GPU device was lost; using the CPU renderer. GPU 장치 연결이 끊어져 CPU 렌더러를 사용합니다. + GPU error; using the CPU renderer. GPU 오류로 CPU 렌더러를 사용합니다. + Couldn't open {name}: {error} {name}을(를) 열 수 없습니다: {error} + Toggle the Brush Settings panel 브러시 설정 패널 표시 전환 + New Brush Preset 새 브러시 사전 설정 + {verb} {mask} {layer} {layer} {mask} {verb} + Unlink 연결 해제 + Link 연결 + layer mask 레이어 마스크 + vector mask 벡터 마스크 + Commit transform 변형 적용 + Cancel transform 변형 취소 + Scroll menu up 메뉴 위로 스크롤 + Scroll menu down 메뉴 아래로 스크롤 + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? 계속하려면 이 문자 레이어를 래스터화해야 합니다. 이후에는 텍스트를 편집할 수 없습니다. 문자를 래스터화할까요? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? 계속하려면 이 도형 레이어를 래스터화해야 합니다. 이후에는 도형으로 편집할 수 없습니다. 도형을 래스터화할까요? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? 계속하려면 이 고급 개체를 래스터화해야 합니다. 이후에는 내용을 편집할 수 없습니다. 고급 개체를 래스터화할까요? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? 계속하려면 이 칠 레이어를 래스터화해야 합니다. 이후에는 칠 내용을 편집할 수 없습니다. 레이어를 래스터화할까요? + This layer must be rasterized before proceeding. 계속하려면 이 레이어를 래스터화해야 합니다. + Color Picker (Background Color) 색상 선택기(배경색) + Color Picker (Foreground Color) 색상 선택기(전경색) + Adwaita (Light) Adwaita(밝게) + Adwaita (Dark) Adwaita(어둡게) + Breeze Light Breeze 밝게 + Breeze Dark Breeze 어둡게 + Solarized Dark Solarized 어둡게 + Open Recent is unavailable on the web 웹에서는 최근 파일 열기를 사용할 수 없습니다. + Graphics adapter: {adapter} 그래픽 어댑터: {adapter} + Backend: {backend} ({type}) 백엔드: {backend}({type}) + Driver: {driver} 드라이버: {driver} + GPU backend preference: {backend} 선호 GPU 백엔드: {backend} + Selected at launch: {backend} 시작 시 선택된 백엔드: {backend} + Image compositor: {processor} 이미지 합성 처리기: {processor} + Fallback: {reason} 대체 사용 사유: {reason} + This session: {reason} 현재 세션: {reason} + Window renderer: {renderer} 창 렌더러: {renderer} + Software adapter 소프트웨어 어댑터 + Graphics adapter 그래픽 어댑터 + unknown 알 수 없음 + unknown type 알 수 없는 유형 + auto 자동 + Platform: {os} {arch} 플랫폼: {os} {arch} + previous reading kept; the last re-read failed: {reason} 이전 값을 유지합니다. 마지막으로 다시 읽기에 실패했습니다: {reason} + Monitor profile: {profile} 모니터 프로파일: {profile} + Display profiles: {reason} 디스플레이 프로파일: {reason} + Monitor profile in use on {display}: {profile} {display}에서 사용 중인 모니터 프로파일: {profile} + Monitor profile in use: {profile} 사용 중인 모니터 프로파일: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. 캔버스를 sRGB로 표시합니다. 편집 › 색상 설정에서 모니터 프로파일을 선택하세요. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. {display}의 캔버스를 sRGB로 표시합니다. 편집 › 색상 설정에서 모니터 프로파일을 선택하세요. + Display profile not used 디스플레이 프로파일 사용 안 함 + Monitor profile on {display} (main window): {profile} {display}의 모니터 프로파일(주 창): {profile} + Monitor profile on {display}: {profile} {display}의 모니터 프로파일: {profile} + manual 수동 + fallback 대체 + Importing {what} from {file}… {file}에서 {what} 가져오는 중… + brushes 브러시 + gradients 그레이디언트 +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file}에서 브러시 {n}개를 가져왔습니다. +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file}에서 그레이디언트 {n}개를 가져왔습니다. +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file}에서 패턴 {n}개를 가져왔습니다. + Data Set: {name} ({index}/{total}) 데이터 세트: {name} ({index}/{total}) + (none) (없음) +navigator Zoom 확대/축소 + Apply {option}: {state} {option} 적용: {state} + on 켜짐 + off 꺼짐 + This adjustment ({key}) is kept as imported and can't be edited yet. 이 조정({key})은 가져온 상태로 유지되며 아직 편집할 수 없습니다. + {index} of {total} {total}개 중 {index}번째 + {shortcut} assigned. {shortcut}을(를) 할당했습니다. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut}은(는) 이미 {commands}에서 사용 중입니다. 확인을 누르면 기존 할당이 해제됩니다. + Export As: {name} 다른 형식으로 내보내기: {name} + Color Sampler Tool 색상 샘플러 도구 + Proof Setup… 교정 설정… + Toggle Layer Mask Overlay 레이어 마스크 오버레이 전환 + Increase Type Size 문자 크기 늘리기 + Decrease Type Size 문자 크기 줄이기 + Increase Type Size by 10 문자 크기 10 늘리기 + Decrease Type Size by 10 문자 크기 10 줄이기 + Make Selection from Path 패스에서 선택 영역 만들기 + Target Composite Channel 합성 채널 지정 + Target Channel 3 채널 3 지정 + Target Channel 4 채널 4 지정 + Target Channel 5 채널 5 지정 + Target Channel 6 채널 6 지정 + Target Channel 7 채널 7 지정 + Target Channel 8 채널 8 지정 + Target Channel 9 채널 9 지정 + Fill with Foreground Color 전경색으로 칠 + Fill with Background Color 배경색으로 칠 + Fill with Foreground Color, Preserve Transparency 전경색으로 칠, 투명도 유지 + Fill with Background Color, Preserve Transparency 배경색으로 칠, 투명도 유지 + Temporary 임시 + Space 스페이스 + Hand Tool (hold) 손 도구(누르고 유지) + Zoom In (hold, drag to scrub) 확대(누르고 유지, 드래그로 조절) + Zoom Out (hold) 축소(누르고 유지) + Erase with Brush or Pencil (hold) 브러시 또는 연필로 지우기(누르고 유지) + Warming Filter (85) 따뜻한 필터(85) + W: 너비: + A: 각도: + Pattern Fill… 패턴 칠… + Checkerboard 체커보드 + Arc Lower 아래쪽 호 + Arc Upper 위쪽 호 + Bend 구부리기 + Lock Transparent Pixels 투명 픽셀 잠금 + Detect Faces 얼굴 감지 + Delete Snapshot 스냅샷 삭제 + Snapshot 1 스냅샷 1 + ProPhoto RGB ProPhoto RGB +@id file.close 닫기 +@id layer.layerMask.apply 레이어 마스크 적용 +@id edit.clear 지우기 +layerLabel Red 빨강 +layerLabel Magenta 자홍 + Pick Color From Image 이미지에서 색상 선택 + Arrows 화살표 + Basic Latin 기본 라틴 문자 + Bloat 볼록하게 + Currency 통화 기호 + Cyrillic 키릴 문자 + Forward Warp 앞으로 뒤틀기 + Freeze Lasso 고정 올가미 + Freeze Mask 고정 마스크 + Greek 그리스 문자 + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) 커닝: 메트릭, 광학 또는 1/1000 em 단위 값(커서 위치에서 Alt+←/→) + Latin 1 라틴 문자 1 + Latin Extended-A 라틴 문자 확장-A + Latin Extended-B 라틴 문자 확장-B + Mask None 마스크 없음 + Math Symbols 수학 기호 + Numbers 숫자 + Pucker 오목하게 + Push Left 왼쪽으로 밀기 + Reconstruct All 모두 복원 + Save Path 패스 저장 + Thaw Mask 마스크 고정 해제 + Twirl Clockwise 시계 방향 돌리기 + Twirl Counterclockwise 반시계 방향 돌리기 + blue 파랑 + gray 회색 + green 초록 + none 없음 + orange 주황 + red 빨강 + violet 보라 + yellow 노랑 +cameraRaw Balance 균형 +cameraRaw Clarity 명료도 +cameraRaw Color Mixer 색상 혼합기 +cameraRaw Contrast 대비 +cameraRaw Curve 곡선 +cameraRaw Dehaze 안개 제거 +cameraRaw Detail 세부 정보 +cameraRaw Effects 효과 +cameraRaw Exposure 노출 +cameraRaw Feather 페더 +cameraRaw Highlights 밝은 영역 +cameraRaw Midpoint 중간 지점 +cameraRaw Midtones 중간톤 +cameraRaw Radius 반경 +cameraRaw Roundness 원형률 +cameraRaw Saturation 채도 +cameraRaw Shadows 어두운 영역 +cameraRaw Size 크기 +cameraRaw Texture 텍스처 +colorRange Invert 반전 +document Orientation 방향 +layers Fill 칠 +layers Fill: 칠: +shape Appearance 화면 모양 +# Move tool options bar (#11381) + Align top edges 위쪽 가장자리 정렬 + Align vertical centers 세로 중심 정렬 + Align bottom edges 아래쪽 가장자리 정렬 + Distribute vertically 세로로 분포 + Distribute horizontally 가로로 분포 + Show layer bounds on hover 마우스를 올리면 레이어 경계 표시 + Expand layer group on click 클릭하면 레이어 그룹 펼치기 + Show hover bounds from Layers 레이어 패널에서 마우스를 올릴 때 경계 표시 + Saved Camera Raw settings to {file} Camera Raw 설정을 {file}에 저장했습니다 + Save the settings to each raw's sidecar without opening it 각 Raw의 사이드카 파일에 설정을 저장하고 열지 않습니다 + No settings saved: these raws can't keep a sidecar here 저장된 항목 없음: 이 Raw 파일은 여기에 사이드카 파일을 둘 수 없습니다 + Some settings could not be saved; the raws stay open 일부 설정을 저장할 수 없습니다. Raw 파일은 열린 상태로 유지됩니다 + Nothing to save: these raws are at their default settings 저장할 항목 없음: 이 Raw 파일은 기본 설정입니다 +# Artboard tool options bar (#11385) + Distribute Widths 너비 분포 + Distribute Heights 높이 분포 + Auto-nest Layers 레이어 자동 중첩 + Auto-size Canvas 캔버스 크기 자동 조정 + Keep Relative Position During Layer Reordering 레이어 순서 변경 시 상대 위치 유지 + Shrinkwrap Canvas on Save 저장 시 캔버스를 내용에 맞게 축소 diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index f5e18da1fd..ac54c8adc7 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -15,6 +15,8 @@ fn lang(code: &str) -> Lang { Lang::from_code(code).expect("registered language") } +/// Regional locale tags (`fr-CA`, `ko-KR`, `bs-BA` …) resolve to their registered catalog, as +/// a preference and as a system tag. #[test] fn requested_languages_resolve_regional_preferences() { for (tag, code) in [ @@ -28,6 +30,8 @@ fn requested_languages_resolve_regional_preferences() { ("id-ID", "id"), ("vi-VN", "vi"), ("sr-RS", "sr"), + ("az-Latn-AZ", "az"), + ("bs-BA", "bs"), ] { assert_eq!(Lang::from_pref(tag), lang(code), "{tag}"); } @@ -36,6 +40,80 @@ fn requested_languages_resolve_regional_preferences() { assert_eq!(lang_from_tag("ko"), Some(lang("ko"))); } +/// Switching redraws the menu and persists through the shell's save/load services on restart. +#[test] +fn azerbaijani_draw_after_switching_and_restore_after_restart() { + use std::{cell::RefCell, rc::Rc}; + + let code = "az"; + let file = "Fayl"; + with_language(Lang::EN, || { + let saved = Rc::new(RefCell::new(None::)); + let (load, store) = (saved.clone(), saved.clone()); + let services = Services { + load_prefs: Some(Box::new(move || load.borrow().clone())), + save_prefs: Some(Box::new(move |text| { + *store.borrow_mut() = Some(text.to_owned()); + Ok(()) + })), + ..Default::default() + }; + let mut h = harness_with_services(services); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language"); + h.run_steps(4); + assert_eq!(current(), lang(code)); + assert_eq!(tr(current(), "File"), file); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code)))); + let persisted = saved.borrow().clone().expect("shell saved preferences"); + let value: serde_json::Value = serde_json::from_str(&persisted).expect("saved JSON"); + assert_eq!(value["interface"]["language"], code); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "en"})).expect("switch back"); + h.run_steps(4); + assert_eq!(current(), Lang::EN); + assert_eq!( + serde_json::from_str::(&saved.borrow().clone().expect("saved English preferences")).expect("saved JSON")["interface"]["language"], + "en" + ); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language again"); + h.run_steps(4); + assert_eq!( + serde_json::from_str::(&saved.borrow().clone().expect("saved language preferences")).expect("saved JSON")["interface"]["language"], + code + ); + drop(h); + // The restarted shell, not the old one's drawing language, must bring Azerbaijani back. + set_current(Lang::EN); + let services = Services { load_prefs: Some(Box::new(move || saved.borrow().clone())), ..Default::default() }; + let restarted = harness_with_services(services); + assert_eq!(restarted.state().session.prefs().interface.language, code); + assert_eq!(current(), lang(code)); + assert!(drawn_text(&restarted).iter().any(|text| *text == file_title(lang(code)))); + }); +} + +/// Preferences draw the catalog terms verbatim and the bundled UI font covers their letters. +#[test] +fn azerbaijani_preferences_draw_terms_and_glyphs() { + let code = "az"; + let interface = "İnterfeys"; + let light = "Açıq mövzu"; + with_language(Lang::EN, || { + let mut h = harness(); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language"); + crate::prefs_ui::open_preferences(h.state_mut(), "interface"); + h.run_steps(4); + let text = drawn_text(&h); + for expected in [interface, light, tr(lang(code), "UI font size"), tr(lang(code), "UI scale")] { + assert!(text.iter().any(|text| text == expected), "{code}: missing {expected:?}"); + h.ctx.fonts_mut(|fonts| { + for c in expected.chars().chain("ƏəĞğIıİiÖöŞşÜüÇç".chars()).filter(|c| c.is_alphabetic()) { + assert!(fonts.has_glyph(&egui::FontId::proportional(12.0), c), "{code}: missing glyph {c}"); + } + }); + } + }); +} + #[test] fn ukrainian_native_app_menu_keeps_product_and_language_names() { with_language(Lang::EN, || { @@ -228,6 +306,7 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, P harness } +/// Auto draws the detected system language on first launch while keeping the Auto preference. #[test] fn first_launch_renders_the_system_language_without_overwriting_auto() { for (tag, code) in [ @@ -242,6 +321,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() { ("fi-FI", "fi"), ("da-DK", "da"), ("nb-NO", "nb"), + ("az-AZ", "az"), ] { system::with_system_tags(&[tag], || { with_language(Lang::EN, || { diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index d7232004b6..83643360f3 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -86,6 +86,7 @@ Right Høyre Open Recent Åpne nylig brukte Clear Recent Files Tøm nylig brukte filer + Clear Recent File List Tøm nylig brukte filer Remove from Recent Fjern fra nylig brukte Remove {name} from Recent Fjern {name} fra nylig brukte Close Lukk @@ -94,6 +95,10 @@ Restore Gjenopprett Close All Lukk alle Close Others Lukk andre + New Document… Nytt dokument… + Open Document… Åpne… + Consolidate All to Here Samle alle her + Move to New Window Flytt til nytt vindu Save Lagre Save As… Lagre som… Save a Copy… Lagre en kopi… @@ -106,7 +111,6 @@ Export As… Eksporter som… Export Eksporter Export… Eksporter… - Quick Export As PNG Hurtigeksport som PNG Quick Export as PNG Hurtigeksport som PNG Place Embedded… Plasser innebygd… Place Linked… Plasser koblet… @@ -310,7 +314,6 @@ All Alle Deselect Opphev utvalg Reselect Velg på nytt - Inverse Selection Inverter utvalget Inverse Inverter Color Range… Fargeområde… Select and Mask… Velg og masker… @@ -508,7 +511,6 @@ Match Location Samkjør plassering Match Rotation Samkjør rotasjon Match All Samkjør alle - New Window for Document Nytt vindu for dokument Workspace Arbeidsområde Extensions Utvidelser Panels Paneler @@ -538,6 +540,12 @@ Timeline Tidslinje Tool Presets Verktøyforvalg Tools Verktøy + Contextual Task Bar Kontekstavhengig oppgavelinje + Hide bar Skjul linjen + Reset bar position Tilbakestill linjens plassering + Pin bar position Fest linjens plassering + Move bar Flytt linjen + More options Flere alternativer Libraries Biblioteker 3D 3D Toolbars Verktøylinjer @@ -596,8 +604,8 @@ Background Bakgrunn Set foreground color Angi forgrunnsfarge Set background color Angi bakgrunnsfarge - Swap colours (X) Bytt farger (X) - Default colours (D) Standardfarger (D) + Switch Foreground and Background Colors (X) Bytt forgrunns- og bakgrunnsfarge (X) + Default Foreground and Background Colors (D) Standard forgrunns- og bakgrunnsfarge (D) Brush Tool Options Alternativer for penselverktøy Opacity Dekkevne Flow Flyt @@ -686,6 +694,7 @@ Units & Rulers… Enheter og linjaler… Guides, Grid & Slices… Hjelpelinjer, rutenett og segmenter… Plug-ins… Programtillegg… + Plugins… Programtillegg… Technology Previews Teknologiforhåndsvisninger Scratch Disks… Arbeidsdisker… Export Preferences… Eksportinnstillinger… @@ -920,8 +929,8 @@ ACES ACES Industry Standard Bransjestandard Web Web - Internet Standard RGB Internettstandard RGB - Legacy Macintosh RGB Eldre Macintosh RGB + Internet Standard RGB (sRGB) Internettstandard RGB (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Eldre Macintosh RGB (Gamma 1.8) Monitor RGB Skjerm-RGB Windows LCD Windows LCD Coated CMYK Bestrøket CMYK @@ -1209,6 +1218,7 @@ Path to Illustrator… Bane til Illustrator… Make Work Path Opprett arbeidsbane Make Selection Opprett utvalg + Make Shape Opprett form Fill Path Fyll bane Stroke Path Strek opp bane Clipping Path Klippebane @@ -1602,7 +1612,7 @@ Revert “{name}” to the last saved version? Your changes will be lost. Tilbakestille «{name}» til sist lagrede versjon? Endringene dine går tapt. {key} to subtract {key} for å trekke fra ↑↓ navigate {key} run esc close ↑↓ naviger {key} kjør esc lukk - Click to restore the current colour Klikk for å gjenopprette gjeldende farge + Click to restore the current color Klikk for å gjenopprette gjeldende farge Click with the Note tool to add a note. Klikk med notatverktøyet for å legge til et notat. Drag a rectangle around the object Dra et rektangel rundt objektet Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Dra på bildet for å legge til en begrensning. Shift for vannrett/loddrett. Høyreklikk på en begrensning for å endre eller slette den. @@ -1626,6 +1636,7 @@ Move Tool Flytteverktøy Could not use the move tool because the layer is locked. Kunne ikke bruke flytteverktøyet fordi laget er låst. Could not complete the Free Transform command because the layer is locked. Kunne ikke fullføre Fri transformering fordi laget er låst. + Could not transform because the layer is locked. Kunne ikke transformere fordi laget er låst. Rectangular Marquee Tool Rektangulær markeringsramme Elliptical Marquee Tool Elliptisk markeringsramme Single Row Marquee Tool Markeringsramme for enkeltrad @@ -1683,10 +1694,10 @@ Drag to move the active layer Dra for å flytte det aktive laget Drag to pan · hold Space with any tool Dra for å panorere · hold mellomrom med et hvilket som helst verktøy Drag to draw a gradient Dra for å tegne en gradient - Click to fill similar colours Klikk for å fylle lignende farger + Click to fill similar colors Klikk for å fylle lignende farger Click to add text Klikk for å legge til tekst Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Dra (lasso) eller klikk punkter (polygon) · {add} legg til · {sub} trekk fra - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Dra en beskjæringsramme · dra innenfor for å flytte · kantene endrer størrelse ({ratio} forhold, {centre} midtpunkt) · mellomrom flytter under tegning · piltaster forskyver · X bytter retning · {commit} bekrefter · Esc avbryter + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Dra en beskjæringsramme · dra innenfor for å flytte · kantene endrer størrelse ({ratio} forhold, {centre} midtpunkt) · mellomrom flytter under tegning · piltaster forskyver · X bytter retning · {commit} bekrefter · Esc avbryter {mode} Color · {bits} bit {mode}-farge · {bits} biter Drag to move the layer Dra for å flytte laget Fit Screen Tilpass til skjerm @@ -1718,7 +1729,7 @@ Appearance Utseende Text Position: Tekstplassering: Text color Tekstfarge - Uses the foreground and background colours. Bruker forgrunns- og bakgrunnsfargene. + Uses the foreground and background colors. Bruker forgrunns- og bakgrunnsfargene. Answer the unsaved-changes prompt first Svar først på spørsmålet om ulagrede endringer Unsaved changes Ulagrede endringer Don't Save Ikke lagre @@ -1738,10 +1749,11 @@ name navn new ny Go to Layer Gå til lag - Open a document to select a colour range. Åpne et dokument for å velge et fargeområde. + Open a document to select a color range. Åpne et dokument for å velge et fargeområde. Saved Lagret Reveal in Finder Vis i Finder Show in Explorer Vis i Utforsker + Reveal in Explorer Vis i Utforsker Show in Folder Vis i mappe Before (Y) Før (Y) Save Settings… Lagre innstillinger… @@ -1915,12 +1927,14 @@ Character Styles Panel Tegnstilpanel Clear Canvas Guides Fjern lerrethjelpelinjer Clear Selected Artboard Guides Fjern hjelpelinjer på valgt tegneflate - Color Blindness — Deuteranopia-type Fargeblindhet: deuteranopi-type - Color Blindness — Protanopia-type Fargeblindhet: protanopi-type + Color Blindness - Deuteranopia-type Fargeblindhet: deuteranopi-type + Color Blindness - Protanopia-type Fargeblindhet: protanopi-type Color Table… Fargetabell… Combine Shapes Kombiner former Contract… Trekk sammen… Convert to Embedded Konverter til innebygd + Embed Linked Bygg inn koblet + Embed All Linked Bygg inn alle koblede Convert to Layers Konverter til lag Convert to Linked… Konverter til koblet… Count Telling @@ -2008,6 +2022,7 @@ Pixelate Pikseler Place Scale Marker… Plasser skalamarkør… Plug-ins Programtillegg + Plugins Programtillegg Print One Copy Skriv ut ett eksemplar Print… Skriv ut… Range Område @@ -2052,7 +2067,7 @@ Vertical Centers Loddrette sentre Video Frames to Layers… Videorammer til lag… WIA Support… WIA-støtte… - Working CMY Plate Arbeidsplate for CMY + Working CMY Plates Arbeidsplater for CMY # tl! literals (third pass) Absolute Absolutt @@ -2087,7 +2102,7 @@ Canvas Lerret Capture Opptak Center Crop Marks Sentrerte beskjæringsmerker - Centre the gradient (offset 0, 0) Sentrer gradienten (forskyvning 0, 0) + Center the gradient (offset 0, 0) Sentrer gradienten (forskyvning 0, 0) Change Screen Mode (F) Bytt skjermmodus (F) Change To: Endre til: Clear All Fjern alle @@ -2239,7 +2254,7 @@ Roughness Ruhet Roundness Rundhet Rulers & Grids Linjaler og rutenett - Sample: foreground colour Prøve: forgrunnsfarge + Sample: foreground color Prøve: forgrunnsfarge Sampling: Background Swatch Prøvetaking: bakgrunnsfargeprøve Sampling: Continuous Prøvetaking: kontinuerlig Sampling: Once Prøvetaking: én gang @@ -2338,6 +2353,11 @@ Show canvas dots Vis lerretprikker Clip thumbnails to layer bounds Klipp miniatyrer til laggrensene Clip Thumbnails to Layer Bounds Klipp miniatyrer til laggrensene + No Thumbnails Ingen miniatyrer + Small Thumbnails Små miniatyrer + Medium Thumbnails Middels miniatyrer + Large Thumbnails Store miniatyrer + Clip Thumbnails to Document Bounds Klipp miniatyrer til dokumentgrensene Canvas color Lerretfarge Canvas custom color Egendefinert lerretfarge Classic Klassisk @@ -2464,7 +2484,6 @@ Show only crosshair while painting Vis bare trådkors under maling Show slice numbers Vis segmentnumre Show tooltips Vis verktøytips - Enable animations Aktiver animasjoner Show highlight on rollover Vis utheving ved pekerberøring Show bounding box when dragging layer Vis avgrensningsboks når lag dras System title bar Systemets tittellinje @@ -2508,7 +2527,7 @@ Alt-click to define a source point to clone from Alt-klikk for å angi et kildepunkt å klone fra No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Ingen bane å lage et utvalg fra: tegn en med pennverktøyet eller velg en i banepanelet. Cancel opening Avbryt åpning - Cancelled opening {name} Avbrøt åpning av {name} + Canceled opening {name} Avbrøt åpning av {name} Damaged Photoshop document Skadet Photoshop-dokument The document “{name}” may be damaged. Dokumentet «{name}» kan være skadet. Some layers, pixels or masks could not be read. Do you want to continue opening it? Noen lag, piksler eller masker kunne ikke leses. Vil du fortsette å åpne det? @@ -2518,7 +2537,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumentet «{name}» har en innebygd fargeprofil som ikke samsvarer med gjeldende {mode}-arbeidsfargerom. Working: {working} Arbeidsfargerom: {working} Developing {name} Fremkaller {name} - Cancelled {label} Avbrøt {label} + Canceled {label} Avbrøt {label} Esc to cancel Esc for å avbryte Opened {name} Åpnet {name} Opening {name} Åpner {name} @@ -2526,7 +2545,7 @@ Saving {name} Lagrer {name} Saving {percent} Lagrer {percent} A save to {name} is still running; try again when it finishes En lagring til {name} pågår fortsatt. Prøv igjen når den er ferdig - {label} is writing the file and can no longer be cancelled {label} skriver filen og kan ikke lenger avbrytes + {label} is writing the file and can no longer be canceled {label} skriver filen og kan ikke lenger avbrytes Opening {name}… Åpner {name}… Opening… Åpner… Reading… Leser… @@ -2623,7 +2642,7 @@ cameraRaw Roughness Ruhet Discard Layers and Save a Copy Forkast lag og lagre en kopi Don't show again Ikke vis igjen Save failed: {error} Lagring mislyktes: {error} - Save cancelled Lagring avbrutt + Save canceled Lagring avbrutt Couldn't place {name}: {error} Kunne ikke plassere {name}: {error} Magnetic Lasso Tool Magnetlasso Frequency Frekvens @@ -2767,7 +2786,7 @@ fontWeight Italic Kursiv Impressionist Impresjonistisk Rotate View Tool Roter visningverktøy Reset View Tilbakestill visning - Drag around the centre to rotate the view · Shift constrains to 15° Dra rundt midtpunktet for å rotere visningen · Shift begrenser til 15° + Drag around the center to rotate the view · Shift constrains to 15° Dra rundt midtpunktet for å rotere visningen · Shift begrenser til 15° # Painting symmetry presets. Dual Axis Dobbel akse @@ -2787,11 +2806,11 @@ fontWeight Italic Kursiv All Layers No Adjustments Alle lag uten justeringer Current & Below No Adjustments Gjeldende og under uten justeringer Show Sampling Ring Vis prøvetakingsring - Click to sample the foreground colour · {key}-click for background Klikk for å hente forgrunnsfargen · {key}-klikk for bakgrunn + Click to sample the foreground color · {key}-click for background Klikk for å hente forgrunnsfargen · {key}-klikk for bakgrunn # Color Picker warnings - Out of gamut for printing: click for the nearest printable colour Utenfor fargeomfanget for utskrift: klikk for nærmeste utskrivbare farge - Not a web colour: click for the nearest web colour Ikke en webfarge: klikk for nærmeste webfarge + Out of gamut for printing: click for the nearest printable color Utenfor fargeomfanget for utskrift: klikk for nærmeste utskrivbare farge + Not a web color: click for the nearest web color Ikke en webfarge: klikk for nærmeste webfarge Color to Alpha… Farge til alfa… Transparency Threshold Gjennomsiktighetsterskel Opacity Threshold Dekkevneterskel @@ -3199,6 +3218,8 @@ curves Smooth Jevn ut Step {step} is waiting for its dialog Trinn {step} venter på dialogen sin @plural {n} action|{n} actions {n} handling|{n} handlinger Background (Default) Bakgrunn (standard) + Background (default) Bakgrunn (standard) + Transparent (default) Gjennomsiktig (standard) Content-Aware Fill Innholdsbasert fyll # Batch / Image Processor Include All Subfolders Ta med alle undermapper @@ -3207,7 +3228,7 @@ curves Smooth Jevn ut # Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Ikke kryss også av for Speilvend bilde i Utskriftsinnstillinger…: de to vendingene opphever hverandre. Print Settings… Utskriftsinnstillinger… - Remember to turn off the printer's own colour management in Print Settings… Husk å slå av skriverens egen fargestyring i Utskriftsinnstillinger… + Remember to turn off the printer's own color management in Print Settings… Husk å slå av skriverens egen fargestyring i Utskriftsinnstillinger… Sent to {printer} Sendt til {printer} set in Print Settings… angitt i Utskriftsinnstillinger… ICO is limited to 256 × 256 pixels ICO er begrenset til 256 × 256 piksler @@ -3871,12 +3892,12 @@ curves Smooth Jevn ut In White I hvitt Increase Brush Hardness Øk penselhardhet Demosaic Demosaikk - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hvordan kameraets fargefiltermosaikk fylles ut. Det brukes når filen åpnes. Forhåndsvisningen viser metoden den ble åpnet med. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hvordan kameraets fargefiltermosaikk fylles ut. Det brukes når filen åpnes. Forhåndsvisningen viser metoden den ble åpnet med. AHD (adaptive homogeneity) AHD (adaptiv homogenitet) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de reneste kantene og minst fargefrynsing, den tregeste. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigert interpolering: skarp og rask, med noe fargefrynsing på fine detaljer. - The fastest and the softest, with colour fringing on fine detail. Den raskeste og mykeste, med fargefrynsing på fine detaljer. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: de reneste kantene og minst fargefrynsing, den tregeste. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradientkorrigert interpolering: skarp og rask, med noe fargefrynsing på fine detaljer. + The fastest and the softest, with color fringing on fine detail. Den raskeste og mykeste, med fargefrynsing på fine detaljer. “{name}” already exists. Replace it? «{name}» finnes allerede. Erstatte den? The name in the save dialog was “{name}”. Navnet i lagringsdialogen var «{name}». Move Up Flytt opp @@ -4527,7 +4548,6 @@ curves Smooth Jevn ut Use File › Open to open an image. Bruk Fil › Åpne for å åpne et bilde. Use Selection In Source Bruk utvalg i kilde Use Selection In Target Bruk utvalg i mål - Uses the foreground and background colors. Bruker forgrunns- og bakgrunnsfargene. Vanishing Point Fluktpunkt Variable Data Sets Variable datasett Vector Mask Hides Effects Vektormaske skjuler effekter @@ -4737,8 +4757,8 @@ cameraRaw Camera Raw ({name}) Camera Raw ({name}) cameraRaw Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) cameraRaw Camera Raw isn't open Camera Raw er ikke åpen cameraRaw Cancel Avbryt -cameraRaw Cancelled opening {name} Avbrøt åpning av {name} -cameraRaw Cancelled {label} Avbrøt {label} +cameraRaw Canceled opening {name} Avbrøt åpning av {name} +cameraRaw Canceled {label} Avbrøt {label} cameraRaw Color Priority Fargeprioritet cameraRaw Developing {name} Fremkaller {name} cameraRaw Full-resolution preview unavailable Forhåndsvisning i full oppløsning er ikke tilgjengelig @@ -4773,3 +4793,39 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert The path was discarded Banen ble forkastet The path was kept as the Work Path Banen ble beholdt som arbeidsbane Edit Brush… Rediger pensel… + +# Action Stop message (#8386) + Message Melding +# Playback Options (#5212) + Playback Options… Avspillingsalternativer… + Playback Options Avspillingsalternativer + Accelerated Akselerert + Step by Step Trinn for trinn + Pause For: Pause i: + Pause For Pause i + seconds sekunder + Create a single history state Opprett én historikktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalalternativer… + Specify Channels: Angi kanaler: + Not enough grayscale documents for this mode. Ikke nok dokumenter i gråtoner for denne modusen. + Pick a different document for each channel. Velg et annet dokument for hver kanal. + A picked document is no longer open. Et av de valgte dokumentene er ikke lenger åpent. + A picked document is no longer a grayscale document of the same size. Et av de valgte dokumentene er ikke lenger et dokument i gråtoner av samme størrelse. +# Move tool options bar (#11381) + Align top edges Juster øverste kanter + Align vertical centers Juster loddrette midtpunkter + Align bottom edges Juster nederste kanter + Distribute vertically Fordel loddrett + Distribute horizontally Fordel vannrett + Show layer bounds on hover Vis lagets grenser ved hovring + Expand layer group on click Utvid lagergruppe ved klikk + Show hover bounds from Layers Vis hovringsgrenser fra Lag +# Artboard tool options bar (#11385) + Distribute Widths Fordel bredder + Distribute Heights Fordel høyder + Auto-nest Layers Neste lag automatisk + Auto-size Canvas Endre lerretstørrelse automatisk + Keep Relative Position During Layer Reordering Behold relativ posisjon ved omordning av lag + Shrinkwrap Canvas on Save Krymp lerretet til innholdet ved lagring diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 7ad2900a28..07088b2d3e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -128,6 +128,7 @@ Right Rechts Open Recent Recent geopend Clear Recent Files Recente bestanden wissen + Clear Recent File List Recente bestanden wissen Remove from Recent Verwijderen uit recente bestanden Remove {name} from Recent {name} verwijderen uit recente bestanden Close Sluiten @@ -136,6 +137,10 @@ Restore Herstellen Close All Alles sluiten Close Others Andere sluiten + New Document… Nieuw document… + Open Document… Openen… + Consolidate All to Here Alles hier samenvoegen + Move to New Window Verplaatsen naar nieuw venster Save Opslaan Save As… Opslaan als… Save a Copy… Kopie opslaan… @@ -187,6 +192,7 @@ Create Smart Object after Loading Layers Slim object maken na laden van lagen Reveal in Finder Weergeven in Finder Show in Explorer Weergeven in Verkenner + Reveal in Explorer Weergeven in Verkenner Show in Folder Weergeven in map File Info… Bestandsinformatie… Print… Afdrukken… @@ -275,6 +281,7 @@ Units & Rulers… Eenheden en linialen… Guides, Grid & Slices… Hulplijnen, raster en segmenten… Plug-ins… Plug-ins… + Plugins… Plug-ins… Type… Tekst… Enhanced Controls… Uitgebreide bediening… Camera Raw… Camera Raw… @@ -328,6 +335,8 @@ Match Color… Kleur afstemmen… Replace Color… Kleur vervangen… Equalize Egaliseren + Equalize Selected Area Only Alleen geselecteerd gebied egaliseren + Equalize Entire Image Based On Selected Area Hele afbeelding egaliseren op basis van geselecteerd gebied Auto Tone Automatische tooncorrectie Auto Contrast Automatisch contrast Auto Color Automatische kleur @@ -431,6 +440,8 @@ Smart Object Slim object Convert to Smart Object Omzetten naar slim object Convert to Embedded Omzetten naar ingesloten + Embed Linked Gekoppeld insluiten + Embed All Linked Alles gekoppeld insluiten Convert to Layers Omzetten naar lagen Convert to Linked… Omzetten naar gekoppeld… Edit Contents Inhoud bewerken @@ -504,7 +515,9 @@ Unite Shapes Vormen verenigen Subtract Front Shape Voorste vorm aftrekken Intersect Shape Areas Overlappende vormgebieden + Unite Shapes at Overlap Overlappende vormgebieden Exclude Overlapping Shapes Overlappende vormen uitsluiten + Subtract Shapes at Overlap Overlappende vormen uitsluiten Merge Shape Components Vormcomponenten samenvoegen Path operations Padbewerkingen Align Uitlijnen @@ -581,7 +594,6 @@ Deselect Deselecteren Reselect Opnieuw selecteren Inverse Omkeren - Inverse Selection Selectie omkeren Similar Vergelijkbaar Color Range… Kleurbereik… Focus Area… Scherptegebied… @@ -687,6 +699,7 @@ Minimum… Minimum… Offset… Verschuiving… Plug-ins Plug-ins + Plugins Plug-ins Proof Setup Proefinstellingen Proof Colors Proefkleuren Gamut Warning Waarschuwing kleurbereik @@ -695,12 +708,12 @@ Working Magenta Plate Werkplaat magenta Working Yellow Plate Werkplaat geel Working Black Plate Werkplaat zwart - Working CMY Plate Werkplaat CMY - Legacy Macintosh RGB Verouderde Macintosh RGB - Internet Standard RGB Internetstandaard RGB + Working CMY Plates CMY-werkplaten + Legacy Macintosh RGB (Gamma 1.8) Verouderde Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internetstandaard RGB (sRGB) Monitor RGB Monitor-RGB - Color Blindness — Protanopia-type Kleurenblindheid — type protanopie - Color Blindness — Deuteranopia-type Kleurenblindheid — type deuteranopie + Color Blindness - Protanopia-type Kleurenblindheid - type protanopie + Color Blindness - Deuteranopia-type Kleurenblindheid - type deuteranopie Zoom In Inzoomen Zoom Out Uitzoomen Fit on Screen Passend in scherm @@ -779,7 +792,6 @@ Consolidate All to Tabs Alles samenvoegen in tabbladen Float in Window Zwevend in venster Float All in Windows Alles zwevend in vensters - New Window for Document Nieuw venster voor document Workspace Werkruimte Essentials Basis Graphic and Web Grafisch en web @@ -843,6 +855,12 @@ Timeline Tijdlijn Tool Presets Gereedschapsvoorinstellingen Tools Gereedschappen + Contextual Task Bar Contextuele taakbalk + Hide bar Balk verbergen + Reset bar position Balkpositie herstellen + Pin bar position Balkpositie vastzetten + Move bar Balk verplaatsen + More options Meer opties About PhotoCraft Over PhotoCraft PhotoCraft Website Website van PhotoCraft PhotoCraft on GitHub PhotoCraft op GitHub @@ -1006,7 +1024,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Klik om een punt toe te voegen · sleep weg, {key}-klik of Delete om te verwijderen Click to apply, {key}-click to clear overrides, double-click for Style Options Klik om toe te passen, {key}-klik om overschrijvingen te wissen, dubbelklik voor stijlopties Click to edit the gradient Klik om het verloop te bewerken - Click to restore the current colour Klik om de huidige kleur te herstellen + Click to restore the current color Klik om de huidige kleur te herstellen Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klik om in te zoomen · {key}-klik om uit te zoomen · sleep naar rechts/links om in/uit te zoomen Click with the Note tool to add a note. Klik met het notitiegereedschap om een notitie toe te voegen. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: hoek · Slepen: vloeiend · Klik op eerste punt: sluiten · {key} voltooien · Esc annuleren @@ -1091,7 +1109,7 @@ Darks Donkere tinten Data Sets Gegevenssets Default Standaard - Default colours (D) Standaardkleuren (D) + Default Foreground and Background Colors (D) Standaard voorgrond- en achtergrondkleur (D) Default printer Standaardprinter Default restored. Standaard hersteld. Define Definiëren @@ -1374,7 +1392,7 @@ Opacity Jitter Dekkingsvariatie Opacity: Dekking: Open a document to define variables. Open een document om variabelen te definiëren. - Open a document to select a colour range. Open een document om een kleurbereik te selecteren. + Open a document to select a color range. Open een document om een kleurbereik te selecteren. Open a document to use the timeline. Open een document om de tijdlijn te gebruiken. Optical Optisch Optimized Geoptimaliseerd @@ -1450,7 +1468,6 @@ Purity Zuiverheid Quality Kwaliteit Quick Actions Snelle acties - Quick Export As PNG Snel exporteren als PNG Radial Gradient Radiaal verloop Radius Straal Radius: Straal: @@ -1589,7 +1606,7 @@ Subtract from Sample Van monster aftrekken Subtract from selection ({key}) Van selectie aftrekken ({key}) Suggestions: Suggesties: - Swap colours (X) Kleuren omwisselen (X) + Switch Foreground and Background Colors (X) Voorgrond- en achtergrondkleur omwisselen (X) Swaps height and width Wisselt hoogte en breedte om Switch between free transform and warp modes Wisselen tussen vrije transformatie en verbuigen Switch theme Thema wisselen @@ -1691,7 +1708,7 @@ Choke Inkrimpen Classic Klassiek Click to add text Klik om tekst toe te voegen - Click to fill similar colours Klik om vergelijkbare kleuren te vullen + Click to fill similar colors Klik om vergelijkbare kleuren te vullen Clone Stamp Tool Kloonstempelgereedschap Color Burn Kleur doordrukken Color Dodge Kleur tegenhouden @@ -1792,6 +1809,7 @@ Move Tool Verplaatsgereedschap Could not use the move tool because the layer is locked. Kan het verplaatsgereedschap niet gebruiken omdat de laag is vergrendeld. Could not complete the Free Transform command because the layer is locked. Kan de opdracht Vrije transformatie niet voltooien omdat de laag is vergrendeld. + Could not transform because the layer is locked. Kan niet transformeren omdat de laag is vergrendeld. Multiply Vermenigvuldigen My Brushes Mijn penselen Neutrals Neutrale tinten @@ -1850,7 +1868,7 @@ Rectangular Marquee Tool Rechthoekig selectiekader Reds Roodtinten Reflected Gespiegeld - Sample: foreground colour Monster: voorgrondkleur + Sample: foreground color Monster: voorgrondkleur Sampled Colors Bemonsterde kleuren Satin Satijn Save Pixel Aspect Ratio Pixelverhouding opslaan @@ -1871,6 +1889,7 @@ Soften Verzachten Source Bron Source: set Bron: ingesteld + Source: {name} Bron: {name} Sponge Tool Sponsgereedschap Spot Healing Brush Tool Snel herstelpenseel Spread Spreiden @@ -1888,7 +1907,6 @@ Transfer Overdracht Triangle Tool Driehoekgereedschap Unsaved changes Niet-opgeslagen wijzigingen - Uses the foreground and background colours. Gebruikt de voorgrond- en achtergrondkleur. Web Web Web Large Web groot Web Minimum Web minimum @@ -1925,6 +1943,7 @@ Allow scripts to connect Scripts toestaan verbinding te maken Always Altijd Always create smart objects when placing Bij plaatsen altijd slimme objecten maken + Skip transform when placing Transformatie overslaan bij plaatsen Animated zoom Geanimeerd zoomen Apply auto tone Automatische tooncorrectie toepassen Ask Vragen @@ -1947,6 +1966,11 @@ Show canvas dots Canvasstippen tonen Clip thumbnails to layer bounds Miniaturen bijsnijden tot laaggrenzen Clip Thumbnails to Layer Bounds Miniaturen bijsnijden tot laaggrenzen + No Thumbnails Geen miniaturen + Small Thumbnails Kleine miniaturen + Medium Thumbnails Middelgrote miniaturen + Large Thumbnails Grote miniaturen + Clip Thumbnails to Document Bounds Miniaturen bijsnijden tot documentgrenzen Canvas color Canvaskleur Canvas custom color Aangepaste canvaskleur Color picker Kleurkiezer @@ -2064,7 +2088,6 @@ Show only crosshair while painting Alleen dradenkruis tonen tijdens schilderen Show slice numbers Segmentnummers tonen Show tooltips Knopinfo tonen - Enable animations Animaties inschakelen Show highlight on rollover Markering bij aanwijzen tonen Show bounding box when dragging layer Selectiekader tonen bij slepen van laag System title bar Titelbalk van systeem @@ -2154,7 +2177,7 @@ Graphics Grafisch Reset GPU Backend GPU-backend herstellen Applies at next launch. Wordt toegepast bij de volgende start. - Click to sample the foreground colour · {key}-click for background Klik om de voorgrondkleur te bemonsteren · {key}-klik voor achtergrond + Click to sample the foreground color · {key}-click for background Klik om de voorgrondkleur te bemonsteren · {key}-klik voor achtergrond {key}-click to set the source {key}-klik om de bron in te stellen {key} to subtract {key} om af te trekken Clone source {n} ({key}-click with Clone Stamp to set) Kloonbron {n} ({key}-klik met kloonstempel om in te stellen) @@ -2213,7 +2236,7 @@ Gradient Options Verloopopties Align with layer Uitlijnen met laag Reset Alignment Uitlijning herstellen - Centre the gradient (offset 0, 0) Het verloop centreren (verschuiving 0, 0) + Center the gradient (offset 0, 0) Het verloop centreren (verschuiving 0, 0) Gradient Editor Verloopeditor Gradient picker Verloopkiezer Gradient stops Verloopstops @@ -2247,7 +2270,7 @@ Alt-click to define a source point to clone from Alt-klik om een bronpunt te definiëren om van te klonen No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Geen pad om een selectie van te maken: teken er een met het pengereedschap of selecteer er een in het paneel Paden. Cancel opening Openen annuleren - Cancelled opening {name} Openen van {name} geannuleerd + Canceled opening {name} Openen van {name} geannuleerd Damaged Photoshop document Beschadigd Photoshop-document The document “{name}” may be damaged. Het document ‘{name}’ is mogelijk beschadigd. Some layers, pixels or masks could not be read. Do you want to continue opening it? Sommige lagen, pixels of maskers konden niet worden gelezen. Wilt u doorgaan met openen? @@ -2256,7 +2279,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Het document “{name}” heeft een ingesloten kleurprofiel dat niet overeenkomt met de huidige {mode}-werkruimte. Working: {working} Werkruimte: {working} Developing {name} {name} wordt ontwikkeld - Cancelled {label} {label} geannuleerd + Canceled {label} {label} geannuleerd Esc to cancel Esc om te annuleren Opened {name} {name} geopend Opening {name} {name} openen @@ -2264,7 +2287,7 @@ Saving {name} {name} opslaan Saving {percent} Opslaan {percent} A save to {name} is still running; try again when it finishes Er wordt nog opgeslagen naar {name}; probeer het opnieuw zodra dat klaar is - {label} is writing the file and can no longer be cancelled {label} schrijft het bestand en kan niet meer worden geannuleerd + {label} is writing the file and can no longer be canceled {label} schrijft het bestand en kan niet meer worden geannuleerd Opening {name}… {name} openen… Opening… Openen… Reading… Lezen… @@ -2307,6 +2330,7 @@ Punctuation Interpunctie Make Work Path Werkpad maken Make Selection Selectie maken + Make Shape Vorm maken Fill Path Pad vullen Stroke Path Pad omlijnen Delete Path Pad verwijderen @@ -2325,7 +2349,7 @@ Pixel Pixel Adjustment Aanpassing Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Slepen (lasso) of punten klikken (veelhoek) · {add} toevoegen · {sub} aftrekken - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Sleep een bijsnijkader · sleep binnenin om te verplaatsen · randen wijzigen formaat ({ratio} verhouding, {centre} midden) · Spatie verplaatst tijdens tekenen · pijltjes verschuiven · X wisselt oriëntatie · {commit} past toe · Esc annuleert + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Sleep een bijsnijkader · sleep binnenin om te verplaatsen · randen wijzigen formaat ({ratio} verhouding, {centre} midden) · Spatie verplaatst tijdens tekenen · pijltjes verschuiven · X wisselt oriëntatie · {commit} past toe · Esc annuleert {mode} Color · {bits} bit {mode}-kleur · {bits} bit Scrubby Zoom Sleepzoom Auto Erase Automatisch gummen @@ -2419,7 +2443,7 @@ Discard Layers and Save a Copy Lagen negeren en kopie opslaan Don't show again Niet meer tonen Save failed: {error} Opslaan mislukt: {error} - Save cancelled Opslaan geannuleerd + Save canceled Opslaan geannuleerd Style {name} saved Stijl {name} opgeslagen Make Default Als standaard instellen Reset to Default Terugzetten naar standaard @@ -2555,7 +2579,7 @@ fontWeight Italic Cursief Rotate View Weergave roteren Rotate View Tool Weergave-roteergereedschap Reset View Weergave herstellen - Drag around the centre to rotate the view · Shift constrains to 15° Sleep rond het midden om de weergave te roteren · Shift beperkt tot 15° + Drag around the center to rotate the view · Shift constrains to 15° Sleep rond het midden om de weergave te roteren · Shift beperkt tot 15° @id select.all Alles selecteren @id edit.stroke Omlijnen… @id edit.transform.scale Schalen @@ -3729,8 +3753,8 @@ cameraRaw Whites Witten @id filter.sharpen.sharpen Verscherpen Good Goed Best Beste - Out of gamut for printing: click for the nearest printable colour Buiten afdrukbereik: klik voor de dichtstbijzijnde afdrukbare kleur - Not a web colour: click for the nearest web colour Geen webkleur: klik voor de dichtstbijzijnde webkleur + Out of gamut for printing: click for the nearest printable color Buiten afdrukbereik: klik voor de dichtstbijzijnde afdrukbare kleur + Not a web color: click for the nearest web color Geen webkleur: klik voor de dichtstbijzijnde webkleur Adjustment Properties Eigenschappen aanpassing Brush Stroke Penseelstreek Default Foreground and Background Colors Standaard voorgrond- en achtergrondkleur @@ -4041,11 +4065,13 @@ curves Smooth Vloeiend maken Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster @plural {n} action|{n} actions {n} handeling|{n} handelingen Background (Default) Achtergrond (standaard) + Background (default) Achtergrond (standaard) + Transparent (default) Transparant (standaard) Include All Subfolders Alle submappen opnemen Auto Add/Delete Automatisch toevoegen/verwijderen Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Schakel niet ook Spiegelbeeld in bij de afdrukinstellingen: de twee spiegelingen heffen elkaar op. Print Settings… Afdrukinstellingen… - Remember to turn off the printer's own colour management in Print Settings… Vergeet niet het eigen kleurbeheer van de printer uit te schakelen in de afdrukinstellingen… + Remember to turn off the printer's own color management in Print Settings… Vergeet niet het eigen kleurbeheer van de printer uit te schakelen in de afdrukinstellingen… Sent to {printer} Verzonden naar {printer} set in Print Settings… instellen in de afdrukinstellingen… ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels @@ -4093,6 +4119,7 @@ curves Smooth Vloeiend maken Automatically Check for Updates Automatisch op updates controleren Paints from the History panel's source state Schildert vanuit de bronstatus van het deelvenster Geschiedenis Sets the source for the history brush Stelt de bron voor het historiepenseel in + The History Brush source was deleted: set a new source in the History panel De bron voor het historiepenseel is verwijderd: stel een nieuwe bron in het deelvenster Geschiedenis in Highlight Mode Hooglichtmodus Highlight Color Hooglichtkleur Highlight Opacity Hooglichtdekking @@ -4174,12 +4201,12 @@ curves Smooth Vloeiend maken Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen Demosaic Demosaicing - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hoe het kleurfiltermozaïek van de camera wordt ingevuld. Wordt toegepast bij het openen van het bestand; de voorvertoning toont de methode waarmee het is geopend. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hoe het kleurfiltermozaïek van de camera wordt ingevuld. Wordt toegepast bij het openen van het bestand; de voorvertoning toont de methode waarmee het is geopend. AHD (adaptive homogeneity) AHD (adaptieve homogeniteit) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de schoonste randen en de minste kleurranden, het traagst. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradiëntgecorrigeerde interpolatie: scherp en snel, met wat kleurranden in fijne details. - The fastest and the softest, with colour fringing on fine detail. Het snelst en het zachtst, met kleurranden in fijne details. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: de schoonste randen en de minste kleurranden, het traagst. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradiëntgecorrigeerde interpolatie: scherp en snel, met wat kleurranden in fijne details. + The fastest and the softest, with color fringing on fine detail. Het snelst en het zachtst, met kleurranden in fijne details. “{name}” already exists. Replace it? “{name}” bestaat al. Vervangen? The name in the save dialog was “{name}”. De naam in het venster Opslaan was “{name}”. Previous Brush Vorig penseel @@ -4253,6 +4280,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Voorinstellingen lettergrootte Leading presets Voorinstellingen interlinie Tracking presets Voorinstellingen spatiëring + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ongeldige numerieke invoer. Er is een geheel getal tussen {min}% en {max}% vereist. De vorige waarde is ingevoegd. Color Aware Kleurbewust Decontaminate Colors Kleuren zuiveren @@ -4277,3 +4306,78 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Kleur van het punt Shapes can't use patterns yet Vormen kunnen nog geen patronen gebruiken Edit Brush… Penseel bewerken… + +# Action Stop message (#8386) + Message Bericht +# Playback Options (#5212) + Playback Options… Afspeelopties… + Playback Options Afspeelopties + Accelerated Versneld + Step by Step Stap voor stap + Pause For: Pauzeren gedurende: + Pause For Pauzeren gedurende + seconds seconden + Create a single history state Eén historiestatus maken + Generator enabled Generator ingeschakeld + Move Up Omhoog + Move Down Omlaag + Grid view Rasterweergave + List view Lijstweergave + On your computer Op je computer + New file Nieuw bestand + Learn Leren + Website Website + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Een kader ondersteunt affiene transformaties; selecteer de afbeelding voor perspectieftransformaties + A frame supports affine transforms; select its image to warp it Een kader ondersteunt affiene transformaties; selecteer de afbeelding om deze te verdraaien + Convert to Frame… Omzetten naar kader… + Create a new custom frame Nieuw aangepast kader maken + Create a new polygonal frame Nieuw veelhoekig kader maken + Create a new triangular frame Nieuw driehoekig kader maken + Fit Content to Frame Inhoud aanpassen aan kader + Fit Frame to Content Kader aanpassen aan inhoud + Frame coordinates must be finite and within 600000 pixels Kadercoördinaten moeten eindig zijn en binnen 600000 pixels liggen + Import Image… Afbeelding importeren… + Invalid frame transform Ongeldige kadertransformatie + Invalid nested file drop Ongeldige geneste bestandsneerzetting + New Frame Nieuw kader + Replace Image… Afbeelding vervangen… + The frame is locked Het kader is vergrendeld + The frame is unavailable Het kader is niet beschikbaar + +# Channels panel menu (#11451) + New Channel… Nieuw kanaal… + Duplicate Channel Kanaal dupliceren + Delete Channel Kanaal verwijderen + New Spot Channel… Nieuw steunkleurkanaal… + Merge Spot Channel Steunkleurkanaal samenvoegen + Channel Options… Kanaalopties… + Split Channels Kanalen splitsen + Merge Channels… Kanalen samenvoegen… + Specify Channels: Kanalen opgeven: + Not enough grayscale documents for this mode. Niet genoeg grijswaardendocumenten voor deze modus. + Pick a different document for each channel. Kies voor elk kanaal een ander document. + A picked document is no longer open. Een gekozen document is niet meer geopend. + A picked document is no longer a grayscale document of the same size. Een gekozen document is geen grijswaardendocument van hetzelfde formaat meer. +# Move tool options bar (#11381) + Align top edges Bovenranden uitlijnen + Align vertical centers Verticale middens uitlijnen + Align bottom edges Onderranden uitlijnen + Distribute vertically Verticaal verdelen + Distribute horizontally Horizontaal verdelen + Show layer bounds on hover Laaggrenzen tonen bij aanwijzen + Expand layer group on click Laaggroep uitvouwen bij klikken + Show hover bounds from Layers Aanwijsgrenzen tonen vanuit Lagen + Saved Camera Raw settings to {file} Camera Raw-instellingen opgeslagen in {file} + Save the settings to each raw's sidecar without opening it De instellingen in het sidecarbestand van elke raw opslaan zonder die te openen + No settings saved: these raws can't keep a sidecar here Niets opgeslagen: deze raws kunnen hier geen sidecarbestand krijgen + Some settings could not be saved; the raws stay open Sommige instellingen konden niet worden opgeslagen; de raws blijven open + Nothing to save: these raws are at their default settings Niets op te slaan: deze raws staan op hun standaardinstellingen +# Artboard tool options bar (#11385) + Distribute Widths Breedtes verdelen + Distribute Heights Hoogtes verdelen + Auto-nest Layers Lagen automatisch nesten + Auto-size Canvas Canvasgrootte automatisch aanpassen + Keep Relative Position During Layer Reordering Relatieve positie behouden bij het wijzigen van de laagvolgorde + Shrinkwrap Canvas on Save Canvas bij opslaan verkleinen tot de inhoud diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fe13d8f177..b6dd0211f0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -171,6 +171,7 @@ Right Prawa Open Recent Otwórz ostatnie Clear Recent Files Wyczyść listę ostatnich plików + Clear Recent File List Wyczyść listę ostatnich plików Remove from Recent Usuń z ostatnich plików Remove {name} from Recent Usuń {name} z ostatnich plików Close Zamknij @@ -179,6 +180,10 @@ Restore Przywróć Close All Zamknij wszystko Close Others Zamknij pozostałe + New Document… Nowy dokument… + Open Document… Otwórz… + Consolidate All to Here Połącz wszystko tutaj + Move to New Window Przenieś do nowego okna Save Zapisz Save As… Zapisz jako… Save a Copy… Zapisz kopię… @@ -231,6 +236,7 @@ Create Smart Object after Loading Layers Utwórz obiekt inteligentny po wczytaniu warstw Reveal in Finder Pokaż w Finderze Show in Explorer Pokaż w Eksploratorze + Reveal in Explorer Pokaż w Eksploratorze Show in Folder Pokaż w folderze File Info… Informacje o pliku… Print… Drukuj… @@ -318,6 +324,7 @@ Units & Rulers… Jednostki i linijki… Guides, Grid & Slices… Prowadnice, siatka i plasterki… Plug-ins… Wtyczki… + Plugins… Wtyczki… Type… Tekst… Enhanced Controls… Ulepszone elementy sterujące… Camera Raw… Camera Raw… @@ -371,6 +378,8 @@ Match Color… Dopasuj kolor… Replace Color… Zamień kolor… Equalize Wyrównaj + Equalize Selected Area Only Wyrównaj tylko zaznaczony obszar + Equalize Entire Image Based On Selected Area Wyrównaj cały obraz na podstawie zaznaczonego obszaru Auto Tone Automatyczne tony Auto Contrast Automatyczny kontrast Auto Color Automatyczny kolor @@ -474,6 +483,8 @@ Smart Object Obiekt inteligentny Convert to Smart Object Konwertuj na obiekt inteligentny Convert to Embedded Konwertuj na osadzony + Embed Linked Osadź połączony + Embed All Linked Osadź wszystkie połączone Convert to Layers Konwertuj na warstwy Convert to Linked… Konwertuj na połączony… Edit Contents Edytuj zawartość @@ -547,7 +558,9 @@ Unite Shapes Złącz kształty Subtract Front Shape Odejmij kształt z przodu Intersect Shape Areas Część wspólna obszarów kształtów + Unite Shapes at Overlap Część wspólna obszarów kształtów Exclude Overlapping Shapes Wyklucz nakładające się kształty + Subtract Shapes at Overlap Wyklucz nakładające się kształty Merge Shape Components Scal składniki kształtu Path operations Operacje na ścieżkach Align Wyrównaj @@ -624,7 +637,6 @@ Deselect Odznacz Reselect Zaznacz ponownie Inverse Odwróć - Inverse Selection Odwróć zaznaczenie Similar Podobne Color Range… Zakres kolorów… Focus Area… Obszar ostrości… @@ -732,6 +744,7 @@ Minimum… Minimum… Offset… Przesunięcie… Plug-ins Wtyczki + Plugins Wtyczki Proof Setup Ustawienia próbnego wydruku Proof Colors Kolory próbne Gamut Warning Ostrzeżenie o gamucie @@ -740,12 +753,12 @@ Working Magenta Plate Robocza płyta magenty Working Yellow Plate Robocza płyta żółci Working Black Plate Robocza płyta czerni - Working CMY Plate Robocza płyta CMY - Legacy Macintosh RGB Starszy Macintosh RGB - Internet Standard RGB Standardowy RGB dla Internetu + Working CMY Plates Robocze płyty CMY + Legacy Macintosh RGB (Gamma 1.8) Starszy Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Standardowy RGB dla Internetu (sRGB) Monitor RGB RGB monitora - Color Blindness — Protanopia-type Ślepota barw — typ protanopii - Color Blindness — Deuteranopia-type Ślepota barw — typ deuteranopii + Color Blindness - Protanopia-type Ślepota barw - typ protanopii + Color Blindness - Deuteranopia-type Ślepota barw - typ deuteranopii Zoom In Powiększ Zoom Out Pomniejsz Fit on Screen Dopasuj do ekranu @@ -834,7 +847,7 @@ Consolidate All to Tabs Połącz wszystko w karty Float in Window Odłącz do okna Float All in Windows Odłącz wszystko do okien - New Window for Document Nowe okno dla dokumentu + New Window Nowe okno Workspace Obszar roboczy Essentials Podstawowe Graphic and Web Grafika i Internet @@ -896,6 +909,12 @@ Timeline Oś czasu Tool Presets Ustawienia wstępne narzędzi Tools Narzędzia + Contextual Task Bar Kontekstowy pasek zadań + Hide bar Ukryj pasek + Reset bar position Resetuj położenie paska + Pin bar position Przypnij położenie paska + Move bar Przesuń pasek + More options Więcej opcji About PhotoCraft O programie PhotoCraft PhotoCraft Website Strona internetowa PhotoCraft PhotoCraft on GitHub PhotoCraft na GitHubie @@ -1073,7 +1092,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Kliknij, aby dodać punkt · przeciągnij poza obszar, kliknij z {key} lub naciśnij Delete, aby usunąć Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknij, aby zastosować, kliknij z {key}, aby wyczyścić zastąpienia, kliknij dwukrotnie, aby otworzyć opcje stylu Click to edit the gradient Kliknij, aby edytować gradient - Click to restore the current colour Kliknij, aby przywrócić bieżący kolor + Click to restore the current color Kliknij, aby przywrócić bieżący kolor Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknij, aby powiększyć · kliknij z {key}, aby pomniejszyć · przeciągnij w prawo/lewo, aby powiększyć/pomniejszyć Click with the Note tool to add a note. Kliknij narzędziem Notatka, aby dodać notatkę. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: narożnik · Przeciągnięcie: gładki · Klik w pierwszy punkt: zamknij · {key} zakończ · Esc anuluj @@ -1158,7 +1177,7 @@ Darks Ciemne Data Sets Zestawy danych Default Domyślne - Default colours (D) Kolory domyślne (D) + Default Foreground and Background Colors (D) Kolory domyślne (D) Default printer Drukarka domyślna Default restored. Przywrócono wartość domyślną. Define Zdefiniuj @@ -1437,7 +1456,7 @@ Opacity Jitter Rozrzut krycia Opacity: Krycie: Open a document to define variables. Otwórz dokument, aby zdefiniować zmienne. - Open a document to select a colour range. Otwórz dokument, aby wybrać zakres kolorów. + Open a document to select a color range. Otwórz dokument, aby wybrać zakres kolorów. Open a document to use the timeline. Otwórz dokument, aby użyć osi czasu. Optical Optyczne Optimized Zoptymalizowany @@ -1513,7 +1532,6 @@ Purity Czystość Quality Jakość Quick Actions Szybkie akcje - Quick Export As PNG Szybki eksport jako PNG Radial Gradient Gradient promienisty Radius Promień Radius: Promień: @@ -1653,7 +1671,7 @@ Subtract from Sample Odejmij od próbki Subtract from selection ({key}) Odejmij od zaznaczenia ({key}) Suggestions: Sugestie: - Swap colours (X) Zamień kolory (X) + Switch Foreground and Background Colors (X) Zamień kolory (X) Swaps height and width Zamienia wysokość i szerokość Switch between free transform and warp modes Przełącz między trybami dowolnego przekształcania i wypaczenia Switch theme Przełącz motyw @@ -1754,7 +1772,7 @@ Choke Zwężenie Classic Klasyczny Click to add text Kliknij, aby dodać tekst - Click to fill similar colours Kliknij, aby wypełnić podobne kolory + Click to fill similar colors Kliknij, aby wypełnić podobne kolory Clone Stamp Tool Narzędzie Pieczątka Color Burn Wypalanie koloru Color Dodge Rozjaśnianie koloru @@ -1856,6 +1874,7 @@ Move Tool Narzędzie Przesunięcie Could not use the move tool because the layer is locked. Nie można użyć narzędzia Przesunięcie, ponieważ warstwa jest zablokowana. Could not complete the Free Transform command because the layer is locked. Nie można ukończyć polecenia Dowolne przekształcanie, ponieważ warstwa jest zablokowana. + Could not transform because the layer is locked. Nie można przekształcić, ponieważ warstwa jest zablokowana. Multiply Mnożenie My Brushes Moje pędzle Neutrals Neutralne @@ -1914,7 +1933,7 @@ Rectangular Marquee Tool Narzędzie Zaznaczenie prostokątne Reds Czerwone Reflected Odbity - Sample: foreground colour Próbka: kolor pierwszego planu + Sample: foreground color Próbka: kolor pierwszego planu Sampled Colors Próbkowane kolory Satin Satyna Save Pixel Aspect Ratio Zapisz proporcje pikseli @@ -1935,6 +1954,7 @@ Soften Zmiękcz Source Źródło Source: set Źródło: ustawione + Source: {name} Źródło: {name} Sponge Tool Narzędzie Gąbka Spot Healing Brush Tool Narzędzie Punktowy pędzel korygujący Spread Rozszerzenie @@ -1952,7 +1972,6 @@ Transfer Transfer Triangle Tool Narzędzie Trójkąt Unsaved changes Niezapisane zmiany - Uses the foreground and background colours. Używa kolorów pierwszego planu i tła. Web Internet Web Large Internet — duży Web Minimum Internet — minimalny @@ -1989,6 +2008,7 @@ Allow scripts to connect Zezwalaj skryptom na połączenie Always Zawsze Always create smart objects when placing Zawsze twórz obiekty inteligentne podczas umieszczania + Skip transform when placing Pomijaj przekształcanie podczas umieszczania Animated zoom Animowane powiększanie Apply auto tone Zastosuj automatyczne tony Ask Pytaj @@ -2011,6 +2031,11 @@ Show canvas dots Pokaż kropki płótna Clip thumbnails to layer bounds Przycinaj miniatury do granic warstwy Clip Thumbnails to Layer Bounds Przycinaj miniatury do granic warstwy + No Thumbnails Bez miniaturek + Small Thumbnails Małe miniaturki + Medium Thumbnails Średnie miniaturki + Large Thumbnails Duże miniaturki + Clip Thumbnails to Document Bounds Przycinaj miniatury do granic dokumentu Canvas color Kolor płótna Canvas custom color Własny kolor płótna Color picker Próbnik kolorów @@ -2127,7 +2152,6 @@ Show only crosshair while painting Podczas malowania pokaż tylko celownik Show slice numbers Pokaż numery plasterków Show tooltips Pokaż podpowiedzi - Enable animations Włącz animacje Show highlight on rollover Pokaż podświetlenie po najechaniu Show bounding box when dragging layer Pokaż ramkę ograniczającą podczas przeciągania warstwy Show transformation values Pokaż wartości przekształcenia @@ -2217,7 +2241,7 @@ Graphics Grafika Reset GPU Backend Resetuj backend GPU Applies at next launch. Zadziała po następnym uruchomieniu. - Click to sample the foreground colour · {key}-click for background Kliknij, aby pobrać kolor pierwszego planu · kliknij z {key}, aby pobrać kolor tła + Click to sample the foreground color · {key}-click for background Kliknij, aby pobrać kolor pierwszego planu · kliknij z {key}, aby pobrać kolor tła {key}-click to set the source Kliknij z {key}, aby ustawić źródło {key} to subtract {key}, aby odjąć Clone source {n} ({key}-click with Clone Stamp to set) Źródło klonowania {n} (kliknij z {key} Pieczątką, aby ustawić) @@ -2270,7 +2294,7 @@ Gradient Options Opcje gradientu Align with layer Wyrównaj do warstwy Reset Alignment Resetuj wyrównanie - Centre the gradient (offset 0, 0) Wyśrodkuj gradient (przesunięcie 0, 0) + Center the gradient (offset 0, 0) Wyśrodkuj gradient (przesunięcie 0, 0) Gradient Editor Edytor gradientu Gradient picker Wybór gradientu Gradient stops Punkty gradientu @@ -2304,7 +2328,7 @@ Alt-click to define a source point to clone from Kliknij z Alt, aby zdefiniować punkt źródłowy klonowania No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Brak ścieżki, z której można utworzyć zaznaczenie: narysuj ją narzędziem Pióro lub wybierz w panelu Ścieżki. Cancel opening Anuluj otwieranie - Cancelled opening {name} Anulowano otwieranie {name} + Canceled opening {name} Anulowano otwieranie {name} Damaged Photoshop document Uszkodzony dokument programu Photoshop The document “{name}” may be damaged. Dokument „{name}” może być uszkodzony. Some layers, pixels or masks could not be read. Do you want to continue opening it? Nie udało się odczytać niektórych warstw, pikseli lub masek. Czy kontynuować otwieranie? @@ -2313,7 +2337,7 @@ The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nie ma osadzonego profilu {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ma osadzony profil kolorów, który nie pasuje do bieżącej przestrzeni roboczej {mode}. Working: {working} Przestrzeń robocza: {working} - Cancelled {label} Anulowano: {label} + Canceled {label} Anulowano: {label} Esc to cancel Esc, aby anulować Opened {name} Otworzono: {name} Opening {name} Otwieranie {name} @@ -2321,7 +2345,7 @@ Saving {name} Zapisywanie {name} Saving {percent} Zapisywanie {percent} A save to {name} is still running; try again when it finishes Zapisywanie do {name} wciąż trwa; spróbuj ponownie, gdy się zakończy - {label} is writing the file and can no longer be cancelled {label} zapisuje plik i nie można już tego anulować + {label} is writing the file and can no longer be canceled {label} zapisuje plik i nie można już tego anulować Opening {name}… Otwieranie {name}… Opening… Otwieranie… Reading… Odczyt… @@ -2508,6 +2532,7 @@ Path to Illustrator… Ścieżka do Illustratora… Make Work Path Utwórz ścieżkę roboczą Make Selection Utwórz zaznaczenie + Make Shape Utwórz kształt Fill Path Wypełnij ścieżkę Stroke Path Obrysuj ścieżkę Clipping Path Ścieżka przycinająca @@ -2688,7 +2713,7 @@ Pixel Piksel Adjustment Korekta Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Przeciągaj (lasso) lub klikaj punkty (wielokątne) · {add} dodaj · {sub} odejmij - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Przeciągnij ramkę przycinania · przeciągnij wewnątrz, aby przesunąć · krawędzie zmieniają rozmiar ({ratio} proporcje, {centre} od środka) · Spacja przesuwa podczas rysowania · strzałki przesuwają · X zamienia orientację · {commit} zatwierdza · Esc anuluje + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Przeciągnij ramkę przycinania · przeciągnij wewnątrz, aby przesunąć · krawędzie zmieniają rozmiar ({ratio} proporcje, {centre} od środka) · Spacja przesuwa podczas rysowania · strzałki przesuwają · X zamienia orientację · {commit} zatwierdza · Esc anuluje {mode} Color · {bits} bit Kolor {mode} · {bits} bitów Drag to move the layer Przeciągnij, aby przesunąć warstwę Scrubby Zoom Powiększanie przeciąganiem @@ -2881,7 +2906,7 @@ cameraRaw Roughness Chropowatość Discard Layers and Save a Copy Odrzuć warstwy i zapisz kopię Don't show again Nie pokazuj ponownie Save failed: {error} Zapis nie powiódł się: {error} - Save cancelled Anulowano zapis + Save canceled Anulowano zapis Magnetic Lasso Tool Narzędzie Lasso magnetyczne Frequency Częstotliwość Use tablet pressure to change pen width Użyj nacisku tabletu do zmiany szerokości pióra @@ -3598,7 +3623,7 @@ cameraRaw Roughness Chropowatość Impressionist Impresjonistyczny Rotate View Tool Narzędzie Obróć widok Reset View Resetuj widok - Drag around the centre to rotate the view · Shift constrains to 15° Przeciągnij wokół środka, aby obrócić widok · Shift ogranicza do 15° + Drag around the center to rotate the view · Shift constrains to 15° Przeciągnij wokół środka, aby obrócić widok · Shift ogranicza do 15° Dual Axis Podwójna oś Diagonal Ukośna Transform Symmetry Przekształć symetrię @@ -3613,8 +3638,8 @@ cameraRaw Roughness Chropowatość All Layers No Adjustments Wszystkie warstwy bez korekt Current & Below No Adjustments Bieżąca i poniżej bez korekt Show Sampling Ring Pokaż pierścień próbkowania - Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku - Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy + Out of gamut for printing: click for the nearest printable color Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku + Not a web color: click for the nearest web color To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy Color to Alpha… Kolor na alfę… Transparency Threshold Próg przezroczystości Opacity Threshold Próg krycia @@ -3898,11 +3923,13 @@ curves Smooth Wygładź Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji Background (Default) Tło (domyślne) + Background (default) Tło (domyślne) + Transparent (default) Przezroczyste (domyślne) Include All Subfolders Uwzględnij wszystkie podfoldery Auto Add/Delete Automatyczne dodawanie/usuwanie Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nie włączaj też odbicia lustrzanego w ustawieniach druku: oba odbicia wzajemnie się znoszą. Print Settings… Ustawienia druku… - Remember to turn off the printer's own colour management in Print Settings… Pamiętaj o wyłączeniu zarządzania kolorami drukarki w ustawieniach druku… + Remember to turn off the printer's own color management in Print Settings… Pamiętaj o wyłączeniu zarządzania kolorami drukarki w ustawieniach druku… Sent to {printer} Wysłano do {printer} set in Print Settings… ustawiane w ustawieniach druku… ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli @@ -3950,6 +3977,7 @@ curves Smooth Wygładź Automatically Check for Updates Automatycznie sprawdzaj aktualizacje Paints from the History panel's source state Maluje ze stanu źródłowego panelu Historia Sets the source for the history brush Ustawia źródło dla pędzla historii + The History Brush source was deleted: set a new source in the History panel Źródło pędzla historii zostało usunięte: ustaw nowe źródło w panelu Historia Highlight Mode Tryb świateł Highlight Color Kolor świateł Highlight Opacity Krycie świateł @@ -4032,12 +4060,12 @@ curves Smooth Wygładź Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla Demosaic Demozaikowanie - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Sposób uzupełniania mozaiki filtra barwnego aparatu. Stosowany przy otwieraniu pliku; podgląd pokazuje metodę, z którą plik został otwarty. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Sposób uzupełniania mozaiki filtra barwnego aparatu. Stosowany przy otwieraniu pliku; podgląd pokazuje metodę, z którą plik został otwarty. AHD (adaptive homogeneity) AHD (adaptacyjna jednorodność) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: najczystsze krawędzie i najmniej barwnych obwódek, najwolniejsza. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolacja z korekcją gradientu: ostra i szybka, z niewielkimi barwnymi obwódkami na drobnych szczegółach. - The fastest and the softest, with colour fringing on fine detail. Najszybsza i najmiększa, z barwnymi obwódkami na drobnych szczegółach. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: najczystsze krawędzie i najmniej barwnych obwódek, najwolniejsza. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolacja z korekcją gradientu: ostra i szybka, z niewielkimi barwnymi obwódkami na drobnych szczegółach. + The fastest and the softest, with color fringing on fine detail. Najszybsza i najmiększa, z barwnymi obwódkami na drobnych szczegółach. “{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić? The name in the save dialog was “{name}”. Nazwa w oknie zapisywania: „{name}”. Previous Brush Poprzedni pędzel @@ -4113,6 +4141,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Ustawienia wstępne rozmiaru czcionki Leading presets Ustawienia wstępne interlinii Tracking presets Ustawienia wstępne światła + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Nieprawidłowa wartość liczbowa. Wymagana jest liczba całkowita od {min}% do {max}%. Wstawiono poprzednią wartość. Clear Selection Wyczyść zaznaczenie Color Aware Według kolorów @@ -4139,3 +4169,79 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Kolor punktu Shapes can't use patterns yet Kształty nie mogą jeszcze używać wzorków Edit Brush… Edytuj pędzel… + +# Action Stop message (#8386) + Message Komunikat +# Playback Options (#5212) + Playback Options… Opcje odtwarzania… + Playback Options Opcje odtwarzania + Accelerated Przyspieszone + Step by Step Krok po kroku + Pause For: Wstrzymaj na: + Pause For Wstrzymaj na + seconds s + Create a single history state Utwórz jeden stan historii + Generator enabled Generator włączony + Move Up Przesuń w górę + Move Down Przesuń w dół + Grid view Widok siatki + List view Widok listy + On your computer Na tym komputerze + New file Nowy plik + Learn Nauka + No recent files Brak ostatnich plików + Website Strona internetowa + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Ramka obsługuje przekształcenia afiniczne; do przekształceń perspektywicznych wybierz jej obraz + A frame supports affine transforms; select its image to warp it Ramka obsługuje przekształcenia afiniczne; aby ją wypaczyć, wybierz obraz + Convert to Frame… Konwertuj na ramkę… + Create a new custom frame Utwórz nową ramkę niestandardową + Create a new polygonal frame Utwórz nową ramkę wielokątną + Create a new triangular frame Utwórz nową ramkę trójkątną + Fit Content to Frame Dopasuj zawartość do ramki + Fit Frame to Content Dopasuj ramkę do zawartości + Frame coordinates must be finite and within 600000 pixels Współrzędne ramki muszą być skończone i mieścić się w granicach 600000 pikseli + Import Image… Importuj obraz… + Invalid frame transform Nieprawidłowe przekształcenie ramki + Invalid nested file drop Nieprawidłowe zagnieżdżone upuszczenie pliku + New Frame Nowa ramka + Replace Image… Zastąp obraz… + The frame is locked Ramka jest zablokowana + The frame is unavailable Ramka jest niedostępna + +# Channels panel menu (#11451) + New Channel… Nowy kanał… + Duplicate Channel Duplikuj kanał + Delete Channel Usuń kanał + New Spot Channel… Nowy kanał kolorów dodatkowych… + Merge Spot Channel Scal kanał kolorów dodatkowych + Channel Options… Opcje kanału… + Split Channels Podziel kanały + Merge Channels… Scal kanały… + Specify Channels: Określ kanały: + Not enough grayscale documents for this mode. Za mało dokumentów w skali szarości dla tego trybu. + Pick a different document for each channel. Wybierz inny dokument dla każdego kanału. + A picked document is no longer open. Jeden z wybranych dokumentów nie jest już otwarty. + A picked document is no longer a grayscale document of the same size. Jeden z wybranych dokumentów nie jest już dokumentem w skali szarości o tym samym rozmiarze. +# Move tool options bar (#11381) + Align top edges Wyrównaj górne krawędzie + Align vertical centers Wyrównaj środki w pionie + Align bottom edges Wyrównaj dolne krawędzie + Distribute vertically Rozmieść w pionie + Distribute horizontally Rozmieść w poziomie + Show layer bounds on hover Pokaż granice warstwy po najechaniu + Expand layer group on click Rozwiń grupę warstw po kliknięciu + Show hover bounds from Layers Pokaż granice po najechaniu z panelu Warstwy + Saved Camera Raw settings to {file} Zapisano ustawienia Camera Raw w {file} + Save the settings to each raw's sidecar without opening it Zapisz ustawienia w pliku sidecar każdego pliku raw bez otwierania go + No settings saved: these raws can't keep a sidecar here Nic nie zapisano: te pliki raw nie mogą tu mieć pliku sidecar + Some settings could not be saved; the raws stay open Nie udało się zapisać niektórych ustawień; pliki raw pozostają otwarte + Nothing to save: these raws are at their default settings Nic do zapisania: te pliki raw mają ustawienia domyślne +# Artboard tool options bar (#11385) + Distribute Widths Rozmieść szerokości równomiernie + Distribute Heights Rozmieść wysokości równomiernie + Auto-nest Layers Automatycznie zagnieżdżaj warstwy + Auto-size Canvas Automatycznie zmieniaj rozmiar płótna + Keep Relative Position During Layer Reordering Zachowaj położenie względne przy zmianie kolejności warstw + Shrinkwrap Canvas on Save Przytnij płótno do zawartości przy zapisie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f12a596d1a..15fdbbdbeb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -168,6 +168,7 @@ Right Direita Open Recent Abrir recentes Clear Recent Files Limpar arquivos recentes + Clear Recent File List Limpar arquivos recentes Remove from Recent Remover dos recentes Remove {name} from Recent Remover {name} dos recentes Close Fechar @@ -176,6 +177,10 @@ Restore Restaurar Close All Fechar tudo Close Others Fechar outros + New Document… Novo documento… + Open Document… Abrir… + Consolidate All to Here Consolidar tudo aqui + Move to New Window Mover para nova janela Save Salvar Save As… Salvar como… Save a Copy… Salvar uma cópia… @@ -227,6 +232,7 @@ Create Smart Object after Loading Layers Criar objeto inteligente após carregar as camadas Reveal in Finder Mostrar no Finder Show in Explorer Mostrar no Explorador + Reveal in Explorer Mostrar no Explorador Show in Folder Mostrar na pasta File Info… Informações do arquivo… Print… Imprimir… @@ -313,6 +319,7 @@ Units & Rulers… Unidades e réguas… Guides, Grid & Slices… Guias, grade e fatias… Plug-ins… Plug-ins… + Plugins… Plug-ins… Type… Tipo… Enhanced Controls… Controles aprimorados… Camera Raw… Camera Raw… @@ -361,6 +368,8 @@ Match Color… Corresponder cores… Replace Color… Substituir cor… Equalize Equalizar + Equalize Selected Area Only Equalizar somente a área selecionada + Equalize Entire Image Based On Selected Area Equalizar a imagem inteira com base na área selecionada Auto Tone Tom automático Auto Contrast Contraste automático Auto Color Cor automática @@ -462,6 +471,8 @@ Smart Object Objeto inteligente Convert to Smart Object Converter em objeto inteligente Convert to Embedded Converter em incorporado + Embed Linked Incorporar vinculado + Embed All Linked Incorporar todos os vinculados Convert to Layers Converter em camadas Convert to Linked… Converter em vinculado… Edit Contents Editar conteúdo @@ -606,7 +617,6 @@ Deselect Desmarcar Reselect Selecionar novamente Inverse Inverter - Inverse Selection Inverter seleção Similar Semelhante Color Range… Intervalo de cores… Focus Area… Área de foco… @@ -712,6 +722,7 @@ Minimum… Mínimo… Offset… Deslocamento… Plug-ins Plug-ins + Plugins Plug-ins Proof Setup Configuração de prova Proof Colors Cores de prova Gamut Warning Aviso de gama @@ -720,12 +731,12 @@ Working Magenta Plate Chapa magenta de trabalho Working Yellow Plate Chapa amarela de trabalho Working Black Plate Chapa preta de trabalho - Working CMY Plate Chapa CMY de trabalho - Legacy Macintosh RGB RGB Macintosh legado - Internet Standard RGB RGB padrão da Internet + Working CMY Plates Chapas CMY de trabalho + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh legado (Gamma 1.8) + Internet Standard RGB (sRGB) RGB padrão da Internet (sRGB) Monitor RGB RGB do monitor - Color Blindness — Protanopia-type Daltonismo — tipo protanopia - Color Blindness — Deuteranopia-type Daltonismo — tipo deuteranopia + Color Blindness - Protanopia-type Daltonismo - tipo protanopia + Color Blindness - Deuteranopia-type Daltonismo - tipo deuteranopia Zoom In Aumentar zoom Zoom Out Diminuir zoom Fit on Screen Ajustar à tela @@ -814,7 +825,6 @@ Consolidate All to Tabs Consolidar tudo em guias Float in Window Flutuar na janela Float All in Windows Flutuar tudo em janelas - New Window for Document Nova janela para o documento Workspace Espaço de trabalho Essentials Essenciais Graphic and Web Gráficos e Web @@ -876,6 +886,12 @@ Timeline Linha do tempo Tool Presets Predefinições de ferramentas Tools Ferramentas + Contextual Task Bar Barra de tarefas contextual + Hide bar Ocultar barra + Reset bar position Redefinir posição da barra + Pin bar position Fixar posição da barra + Move bar Mover barra + More options Mais opções About PhotoCraft Sobre o PhotoCraft PhotoCraft Website Site do PhotoCraft PhotoCraft on GitHub PhotoCraft no GitHub @@ -1039,7 +1055,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Clique para adicionar um ponto · arraste para fora, {key}-clique ou Delete para remover Click to apply, {key}-click to clear overrides, double-click for Style Options Clique para aplicar, {key}-clique para limpar substituições, clique duplo para Opções de estilo Click to edit the gradient Clique para editar o degradê - Click to restore the current colour Clique para restaurar a cor atual + Click to restore the current color Clique para restaurar a cor atual Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Clique para aumentar o zoom · {key}-clique para diminuir · arraste para a direita/esquerda para aumentar/diminuir Click with the Note tool to add a note. Clique com a ferramenta Anotação para adicionar uma anotação. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clique: canto · Arrastar: suave · Clique no primeiro ponto: fechar · {key}: concluir · Esc: cancelar @@ -1124,7 +1140,7 @@ Darks Escuros Data Sets Conjuntos de dados Default Padrão - Default colours (D) Cores padrão (D) + Default Foreground and Background Colors (D) Cores padrão de primeiro plano e plano de fundo (D) Default printer Impressora padrão Default restored. Padrão restaurado. Define Definir @@ -1400,7 +1416,7 @@ Opacity Jitter Variação da opacidade Opacity: Opacidade: Open a document to define variables. Abra um documento para definir variáveis. - Open a document to select a colour range. Abra um documento para selecionar um intervalo de cores. + Open a document to select a color range. Abra um documento para selecionar um intervalo de cores. Open a document to use the timeline. Abra um documento para usar a linha do tempo. Optical Óptico Optimized Otimizado @@ -1476,7 +1492,6 @@ Purity Pureza Quality Qualidade Quick Actions Ações rápidas - Quick Export As PNG Exportação rápida como PNG Radial Gradient Degradê radial Radius Raio Radius: Raio: @@ -1617,7 +1632,7 @@ Subtract from Sample Subtrair da amostra Subtract from selection ({key}) Subtrair da seleção ({key}) Suggestions: Sugestões: - Swap colours (X) Trocar cores (X) + Switch Foreground and Background Colors (X) Alternar cores de primeiro plano e plano de fundo (X) Swaps height and width Troca a altura e a largura Switch between free transform and warp modes Alternar entre os modos de transformação livre e deformação Switch theme Alternar tema @@ -1718,7 +1733,7 @@ Choke Contrair Classic Clássico Click to add text Clique para adicionar texto - Click to fill similar colours Clique para preencher cores semelhantes + Click to fill similar colors Clique para preencher cores semelhantes Clone Stamp Tool Ferramenta Carimbo Color Burn Superexposição de cores Color Dodge Subexposição de cores @@ -1819,6 +1834,7 @@ Move Tool Ferramenta Mover Could not use the move tool because the layer is locked. Não foi possível usar a ferramenta Mover porque a camada está bloqueada. Could not complete the Free Transform command because the layer is locked. Não foi possível concluir o comando Transformação livre porque a camada está bloqueada. + Could not transform because the layer is locked. Não foi possível transformar porque a camada está bloqueada. Multiply Multiplicação My Brushes Meus pincéis Neutrals Neutros @@ -1877,7 +1893,7 @@ Rectangular Marquee Tool Ferramenta Letreiro retangular Reds Vermelhos Reflected Refletido - Sample: foreground colour Amostra: cor de primeiro plano + Sample: foreground color Amostra: cor de primeiro plano Sampled Colors Cores da amostra Satin Acetinado Save Pixel Aspect Ratio Salvar proporção de pixels @@ -1898,6 +1914,7 @@ Soften Suavizar Source Origem Source: set Origem: definida + Source: {name} Origem: {name} Sponge Tool Ferramenta Esponja Spot Healing Brush Tool Ferramenta Pincel de recuperação para manchas Spread Propagação @@ -1915,7 +1932,6 @@ Transfer Transferência Triangle Tool Ferramenta Triângulo Unsaved changes Alterações não salvas - Uses the foreground and background colours. Usa as cores de primeiro plano e de fundo. Web Web Web Large Web grande Web Minimum Web mínimo @@ -1950,6 +1966,7 @@ Allow scripts to connect Permitir conexão de scripts Always Sempre Always create smart objects when placing Sempre criar objetos inteligentes ao inserir + Skip transform when placing Ignorar transformação ao inserir Animated zoom Zoom animado Apply auto tone Aplicar tom automático Ask Perguntar @@ -1972,6 +1989,11 @@ Show canvas dots Mostrar pontos da tela de pintura Clip thumbnails to layer bounds Recortar miniaturas aos limites da camada Clip Thumbnails to Layer Bounds Recortar miniaturas aos limites da camada + No Thumbnails Sem miniaturas + Small Thumbnails Miniaturas pequenas + Medium Thumbnails Miniaturas médias + Large Thumbnails Miniaturas grandes + Clip Thumbnails to Document Bounds Recortar miniaturas aos limites do documento Canvas color Cor da tela de pintura Canvas custom color Cor personalizada da tela de pintura Color picker Seletor de cores @@ -2089,7 +2111,6 @@ Show only crosshair while painting Mostrar apenas a mira ao pintar Show slice numbers Mostrar números das fatias Show tooltips Mostrar dicas de ferramentas - Enable animations Ativar animações Show highlight on rollover Mostrar realce ao passar o mouse Show transformation values Mostrar valores de transformação Slice color Cor das fatias @@ -2179,7 +2200,7 @@ Graphics Gráficos Reset GPU Backend Redefinir backend da GPU Applies at next launch. Aplica-se na próxima inicialização. - Click to sample the foreground colour · {key}-click for background Clique para obter a cor de primeiro plano · {key}-clique para a cor de fundo + Click to sample the foreground color · {key}-click for background Clique para obter a cor de primeiro plano · {key}-clique para a cor de fundo {key}-click to set the source {key}-clique para definir a origem {key} to subtract {key} para subtrair Clone source {n} ({key}-click with Clone Stamp to set) Origem da clonagem {n} ({key}-clique com o Carimbo para definir) @@ -2237,7 +2258,7 @@ Gradient Options Opções de degradê Align with layer Alinhar com a camada Reset Alignment Redefinir alinhamento - Centre the gradient (offset 0, 0) Centralizar o degradê (deslocamento 0, 0) + Center the gradient (offset 0, 0) Centralizar o degradê (deslocamento 0, 0) Gradient Editor Editor de degradê Gradient picker Seletor de degradê Gradient stops Paradas do degradê @@ -2271,7 +2292,7 @@ Alt-click to define a source point to clone from Alt-clique para definir um ponto de origem da clonagem No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nenhum demarcador para criar a seleção: desenhe um com a ferramenta Caneta ou selecione um no painel Demarcadores. Cancel opening Cancelar abertura - Cancelled opening {name} Abertura de {name} cancelada + Canceled opening {name} Abertura de {name} cancelada Damaged Photoshop document Documento do Photoshop danificado The document “{name}” may be damaged. O documento “{name}” pode estar danificado. Some layers, pixels or masks could not be read. Do you want to continue opening it? Não foi possível ler algumas camadas, pixels ou máscaras. Deseja continuar abrindo? @@ -2280,7 +2301,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. O documento “{name}” tem um perfil de cores incorporado que não corresponde ao espaço de trabalho {mode} atual. Working: {working} Espaço de trabalho: {working} Developing {name} Revelando {name} - Cancelled {label} {label} cancelado + Canceled {label} {label} cancelado Esc to cancel Esc para cancelar Opened {name} Aberto: {name} Opening {name} Abrindo {name} @@ -2288,7 +2309,7 @@ Saving {name} Salvando {name} Saving {percent} Salvando {percent} A save to {name} is still running; try again when it finishes Um salvamento em {name} ainda está em andamento; tente novamente quando terminar - {label} is writing the file and can no longer be cancelled {label} está gravando o arquivo e não pode mais ser cancelado + {label} is writing the file and can no longer be canceled {label} está gravando o arquivo e não pode mais ser cancelado Opening {name}… Abrindo {name}… Opening… Abrindo… Reading… Lendo… @@ -2473,6 +2494,7 @@ Path to Illustrator… Demarcador para o Illustrator… Make Work Path Criar demarcador de trabalho Make Selection Fazer seleção + Make Shape Fazer forma Fill Path Preencher demarcador Stroke Path Traçar demarcador Clipping Path Demarcador de corte @@ -2653,7 +2675,7 @@ Pixel Pixel Adjustment Ajuste Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Arraste (laço) ou clique nos pontos (poligonal) · {add} adicionar · {sub} subtrair - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Arraste uma caixa de corte · arraste dentro para mover · as bordas redimensionam ({ratio} proporção, {centre} centro) · Espaço move durante o desenho · setas deslocam · X troca a orientação · {commit} confirma · Esc cancela + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Arraste uma caixa de corte · arraste dentro para mover · as bordas redimensionam ({ratio} proporção, {centre} centro) · Espaço move durante o desenho · setas deslocam · X troca a orientação · {commit} confirma · Esc cancela {mode} Color · {bits} bit Cor {mode} · {bits} bits Drag to move the layer Arraste para mover a camada Scrubby Zoom Zoom por arraste @@ -2862,7 +2884,7 @@ cameraRaw Whites Brancos Discard Layers and Save a Copy Descartar camadas e salvar uma cópia Don't show again Não mostrar novamente Save failed: {error} Falha ao salvar: {error} - Save cancelled Salvamento cancelado + Save canceled Salvamento cancelado Style {name} saved Estilo {name} salvo Make Default Definir como padrão Reset to Default Restaurar padrão @@ -2995,7 +3017,7 @@ fontWeight Italic Itálico Impressionist Impressionista Rotate View Tool Ferramenta Girar visualização Reset View Redefinir visualização - Drag around the centre to rotate the view · Shift constrains to 15° Arraste ao redor do centro para girar a visualização · Shift restringe a 15° + Drag around the center to rotate the view · Shift constrains to 15° Arraste ao redor do centro para girar a visualização · Shift restringe a 15° Dual Axis Eixo duplo Diagonal Diagonal Transform Symmetry Transformar simetria @@ -3010,8 +3032,8 @@ fontWeight Italic Itálico All Layers No Adjustments Todas as camadas sem ajustes Current & Below No Adjustments Atual e abaixo sem ajustes Show Sampling Ring Mostrar anel de amostragem - Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima - Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima + Out of gamut for printing: click for the nearest printable color Fora da gama de impressão: clique para a cor imprimível mais próxima + Not a web color: click for the nearest web color Não é uma cor da Web: clique para a cor da Web mais próxima Color to Alpha… Cor para alfa… Transparency Threshold Limiar de transparência Opacity Threshold Limiar de opacidade @@ -4081,11 +4103,13 @@ layerLabel No Color Sem cor Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo @plural {n} action|{n} actions {n} ação|{n} ações Background (Default) Plano de fundo (padrão) + Background (default) Plano de fundo (padrão) + Transparent (default) Transparente (padrão) Include All Subfolders Incluir todas as subpastas Auto Add/Delete Adicionar/excluir automaticamente Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Não ative também a imagem espelhada nas configurações de impressão: as duas inversões se anulam. Print Settings… Configurações de impressão… - Remember to turn off the printer's own colour management in Print Settings… Lembre-se de desativar o gerenciamento de cores da própria impressora nas configurações de impressão… + Remember to turn off the printer's own color management in Print Settings… Lembre-se de desativar o gerenciamento de cores da própria impressora nas configurações de impressão… Sent to {printer} Enviado para {printer} set in Print Settings… definido nas configurações de impressão… ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels @@ -4133,6 +4157,7 @@ layerLabel No Color Sem cor Automatically Check for Updates Verificar atualizações automaticamente Paints from the History panel's source state Pinta a partir do estado de origem do painel Histórico Sets the source for the history brush Define a origem do pincel do histórico + The History Brush source was deleted: set a new source in the History panel A origem do pincel do histórico foi excluída: defina uma nova origem no painel Histórico Highlight Mode Modo de realces Highlight Color Cor dos realces Highlight Opacity Opacidade dos realces @@ -4214,12 +4239,12 @@ layerLabel No Color Sem cor Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel Demosaic Demosaico - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Como o mosaico do filtro de cor da câmera é preenchido. É aplicado ao abrir o arquivo; a visualização mostra o método com que ele foi aberto. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Como o mosaico do filtro de cor da câmera é preenchido. É aplicado ao abrir o arquivo; a visualização mostra o método com que ele foi aberto. AHD (adaptive homogeneity) AHD (homogeneidade adaptativa) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: as bordas mais limpas e o menor franjamento de cor; o mais lento. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolação com correção de gradiente: nítida e rápida, com algum franjamento de cor em detalhes finos. - The fastest and the softest, with colour fringing on fine detail. O mais rápido e o mais suave, com franjamento de cor em detalhes finos. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: as bordas mais limpas e o menor franjamento de cor; o mais lento. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolação com correção de gradiente: nítida e rápida, com algum franjamento de cor em detalhes finos. + The fastest and the softest, with color fringing on fine detail. O mais rápido e o mais suave, com franjamento de cor em detalhes finos. “{name}” already exists. Replace it? “{name}” já existe. Substituir? The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”. Previous Brush Pincel anterior @@ -4423,6 +4448,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Predefinições de tamanho da fonte Leading presets Predefinições de entrelinha Tracking presets Predefinições de tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica inválida. É necessário um número inteiro entre {min}% e {max}%. O valor anterior foi inserido. Color Aware Com reconhecimento de cor Decontaminate Colors Descontaminar cores @@ -4447,3 +4474,70 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Cor da parada Shapes can't use patterns yet Formas ainda não podem usar padrões Edit Brush… Editar pincel… + +# Action Stop message (#8386) + Message Mensagem +# Playback Options (#5212) + Playback Options… Opções de reprodução… + Playback Options Opções de reprodução + Accelerated Acelerada + Step by Step Passo a passo + Pause For: Pausar por: + Pause For Pausar por + seconds segundos + Create a single history state Criar um único estado de histórico + Drag to move the active shape's path or the Work Path Arraste para mover o demarcador da forma ativa ou o demarcador de trabalho + Generator enabled Gerador ativado + Move Up Mover para cima + Move Down Mover para baixo + On your computer No seu computador + New file Novo arquivo + Learn Aprender + Website Site + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Uma moldura permite transformações afins; selecione sua imagem para transformações de perspectiva + A frame supports affine transforms; select its image to warp it Uma moldura permite transformações afins; selecione sua imagem para deformá-la + Convert to Frame… Converter em moldura… + Create a new custom frame Criar nova moldura personalizada + Create a new polygonal frame Criar nova moldura poligonal + Create a new triangular frame Criar nova moldura triangular + Fit Content to Frame Ajustar conteúdo à moldura + Fit Frame to Content Ajustar moldura ao conteúdo + Frame coordinates must be finite and within 600000 pixels As coordenadas da moldura devem ser finitas e estar dentro de 600000 pixels + Import Image… Importar imagem… + Invalid frame transform Transformação de moldura inválida + Invalid nested file drop Soltura de arquivo aninhada inválida + New Frame Nova moldura + Replace Image… Substituir imagem… + The frame is locked A moldura está bloqueada + The frame is unavailable A moldura não está disponível + +# Channels panel menu (#11451) + Channel Options… Opções de canal… + Specify Channels: Especificar canais: + Not enough grayscale documents for this mode. Não há documentos em tons de cinza suficientes para este modo. + Pick a different document for each channel. Escolha um documento diferente para cada canal. + A picked document is no longer open. Um dos documentos escolhidos não está mais aberto. + A picked document is no longer a grayscale document of the same size. Um dos documentos escolhidos não é mais um documento em tons de cinza do mesmo tamanho. +# Move tool options bar (#11381) + Align top edges Alinhar bordas superiores + Align vertical centers Alinhar centros verticais + Align bottom edges Alinhar bordas inferiores + Distribute vertically Distribuir verticalmente + Distribute horizontally Distribuir horizontalmente + Show layer bounds on hover Mostrar limites da camada ao passar o cursor + Expand layer group on click Expandir grupo de camadas ao clicar + Show hover bounds from Layers Mostrar limites ao passar o cursor em Camadas + Saved Camera Raw settings to {file} Configurações do Camera Raw salvas em {file} + Save the settings to each raw's sidecar without opening it Salvar as configurações no arquivo sidecar de cada raw sem abri-lo + No settings saved: these raws can't keep a sidecar here Nada foi salvo: estes raw não podem ter um arquivo sidecar aqui + Some settings could not be saved; the raws stay open Não foi possível salvar algumas configurações; os raw continuam abertos + Nothing to save: these raws are at their default settings Nada para salvar: estes raw estão nas configurações padrão +# Artboard tool options bar (#11385) + Distribute Widths Distribuir larguras + Distribute Heights Distribuir alturas + Auto-nest Layers Aninhar camadas automaticamente + Auto-size Canvas Ajustar tamanho da tela automaticamente + Keep Relative Position During Layer Reordering Manter a posição relativa ao reordenar camadas + Shrinkwrap Canvas on Save Reduzir a tela ao conteúdo ao salvar diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv new file mode 100644 index 0000000000..5bf7d924c8 --- /dev/null +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -0,0 +1,4616 @@ +# Romanian (ro) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @plural `source` is "one|other" (English forms); the translation lists +# the language's forms separated by "|" (see `plural` in i18n/mod.rs) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Romanian computing and photo-editing vocabulary (strat, pensulă, selecție, pânză/canvas ...). No +# proprietary localisation resource, string table or product UI was consulted or copied; add +# entries the same way. +# Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin +# letters; the trailing "…" of dialog commands is kept. +# Machine-translated with Claude (Anthropic) for @tronicum; not yet reviewed by a native speaker. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) +# Channel Options and New Spot Channel (#3761) +# Guide layout dialog +# Custom print paper (#3820) +# Select and Mask dialog labels +# Field Blur pins (#3373) +# Dialogs and panels +# Layers-panel trash confirmation +# Tool names, brush and style option tables +# Templates with placeholders +# Preferences (labels are generated from the setting names) +# Added with the rebase onto the dock / brush picker work +# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). +# Gradient Editor window, opened from the options bar's gradient swatch. +# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test. +# Command-id override: under Edit > Purge a bare "Undo" reads as an action, not the history. +# Rendering mode and recovery +# Camera Raw: contextual controls and window title +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) +# Shared screen eyedropper and text Color Picker (#1128). +# Fill dialog, layer style contours and Image Size +# Layer colour labels. +# Pattern Stamp tool +# Painting symmetry presets. +# Eyedropper Sample Size and Sample (#1649). +# Color Picker warnings +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Add Noise, Reduce Noise and Smart Sharpen dialog controls (Filter > Noise, Filter > Sharpen). +# Edit › Content-Aware Fill workspace +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) +# Color Sampler (added with the i18n coverage repair) +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454) +# View › Guides › New Guide… › Color +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) translated from +# the English source, following the pattern of the other complete catalogs. +# Path, channel, layer style, status and System Info labels +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps +# Entries added so this catalog covers the keys any complete catalog (Spanish, Arabic, Hebrew) +# covers, keeping the UI usable from top to bottom in Romanian. + Mask Options… Opțiuni mască… + Layer Mask Display Options… Opțiuni de afișare a măștii de strat… + Channel Color Culoare canal + Color Indicates: Culoarea indică: + Solidity: Soliditate: + Masked Areas Zone mascate + Selected Areas Zone selectate + Ink Characteristics Caracteristici cerneală + Paper Width Lățime hârtie + Paper Height Înălțime hârtie + Columns Coloane + Rows Rânduri + Number Număr + Margin Margine + Top: Sus: + Left: Stânga: + Bottom: Jos: + Right: Dreapta: + Target: Destinație: + Selected Artboards Planșe de lucru selectate + Center Columns Centrare coloane + Clear Existing Guides Ștergere ghidaje existente + Invalid guide layout Aranjament de ghidaje nevalid + Leave empty for automatic size Lăsați necompletat pentru dimensiune automată + 5 Columns, 3 Rows 5 coloane, 3 rânduri + 8 Columns 8 coloane + 12 Columns 12 coloane + File Fișier + Edit Editare + Image Imagine + Layer Strat + Type Text + Select Selectare + Selected Selectate + Scope Domeniu + Top Left Sus stânga + Top Right Sus dreapta + Bottom Left Jos stânga + Bottom Right Jos dreapta + Filter Filtru + View Vizualizare + Window Fereastră + Help Ajutor + (coming soon) (în curând) + New… Nou… + New from Clipboard Nou din clipboard + Open… Deschidere… + Open As… Deschidere ca… + Open EXR Parts… Deschidere părți EXR… + Add beauty precomp from the AOVs Adăugare precompunere „beauty” din AOV-uri + Both Ambele + Left Stânga + Right Dreapta + Open Recent Deschidere recente + Clear Recent Files Golire fișiere recente + Remove from Recent Eliminare din recente + Remove {name} from Recent Eliminare {name} din recente + Close Închidere + Minimize Minimizare + Maximize Maximizare + Restore Restaurare + Close All Închidere tot + Close Others Închidere celelalte + Save Salvare + Save As… Salvare ca… + Save a Copy… Salvare copie… + {name} can't keep everything in this document {name} nu poate păstra tot conținutul acestui document + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Alegeți un format care îl păstrează, precum Photoshop (PSD), sau salvați o copie. + Revert Revenire + Export Export + Quick Export as PNG Export rapid ca PNG + Export As… Export ca… + Export Preferences… Preferințe export… + Save for Web (Legacy)… Salvare pentru web (vechi)… + Artboards to Files… Planșe de lucru în fișiere… + Artboards to PDF… Planșe de lucru în PDF… + Layers to Files… Straturi în fișiere… + Layer Comps to Files… Compoziții de straturi în fișiere… + Color Lookup Tables… Tabele de căutare a culorilor… + Data Sets as Files… Seturi de date ca fișiere… + Paths to Illustrator… Trasee către Illustrator… + Render Video… Randare video… + Generate Generare + Image Assets Resurse imagine + Place Embedded… Plasare încorporată… + Place Linked… Plasare conectată… + Package… Împachetare… + Automate Automatizare + Batch… Lot… + Create Droplet… Creare droplet… + Crop and Straighten Photos Decupare și îndreptare fotografii + Contact Sheet II… Planșă de contact II… + Conditional Mode Change… Schimbare condiționată a modului… + Fit Image… Încadrare imagine… + Lens Correction… Corecție obiectiv… + Merge to HDR Pro… Îmbinare în HDR Pro… + Photomerge… Photomerge… + Scripts Scripturi + Image Processor… Procesor de imagini… + Delete All Empty Layers Ștergere toate straturile goale + Flatten All Layer Effects Aplatizare toate efectele de strat + Flatten All Masks Aplatizare toate măștile + Script Events Manager… Administrator evenimente script… + Import Import + Browse… Răsfoire… + Source Files Fișiere sursă + Use: Utilizare: + Files Fișiere + Folder Folder + Add Open Files Adăugare fișiere deschise + Sort By Name Sortare după nume + Attempt to Automatically Align Source Images Se încearcă alinierea automată a imaginilor sursă + Create Smart Object after Loading Layers Creare obiect inteligent după încărcarea straturilor + Reveal in Finder Dezvăluire în Finder + Show in Explorer Afișare în Explorer + Show in Folder Afișare în folder + File Info… Informații fișier… + Print… Imprimare… + Print One Copy Imprimare o copie + Exit Ieșire + Undo Anulare + Redo Refacere + Toggle Last State Comutare ultima stare + Fade… Atenuare… + Cut Decupare + Copy Copiere + Copy Merged Copiere combinată + Paste Inserare + Paste Special Inserare specială + Paste in Place Inserare pe loc + Paste Into Inserare în interior + Paste Outside Inserare în exterior + Paste Lorem Ipsum Inserare Lorem Ipsum + Clear Golire + Search… Căutare… + Check Spelling… Verificare ortografică… + Find and Replace Text… Găsire și înlocuire text… + Add to Dictionary Adăugare în dicționar + No Spelling Suggestions Nicio sugestie ortografică + Fill… Umplere… + Stroke… Contur… + Content-Aware Fill… Umplere sensibilă la conținut… + Delete and Fill Selection Ștergere și umplere selecție + Content-Aware Scale Scalare sensibilă la conținut + Puppet Warp Deformare tip marionetă + Perspective Warp Deformare de perspectivă + Free Transform Transformare liberă + Free Transform a Copy Transformare liberă a unei copii + Transform Transformare + Again Din nou + Transform Again on a Copy Transformare din nou pe o copie + Scale Scalare + Rotate Rotire + Set horizontal skew Definire înclinare orizontală + Set vertical skew Definire înclinare verticală + Skew Înclinare + Distort Distorsionare + Perspective Perspectivă + Warp Deformare + Flip Horizontal Răsturnare orizontală + Flip Vertical Răsturnare verticală + Rotate 180° Rotire 180° + Rotate 90° Clockwise Rotire 90° în sensul acelor de ceasornic + Rotate 90° Counter Clockwise Rotire 90° în sens invers acelor de ceasornic + 180° 180° + 90° Clockwise 90° în sensul acelor de ceasornic + 90° Counter Clockwise 90° în sens invers acelor de ceasornic + Arbitrary… Arbitrar… + Auto-Align Layers… Alinierea automată a straturilor… + Auto-Blend Layers… Combinarea automată a straturilor… + Define Brush Preset… Definire presetare pensulă… + Define Pattern… Definire model… + Define Custom Shape… Definire formă personalizată… + Purge Golire memorie + Clipboard Clipboard + Histories Istorice + Video Cache Cache video + All Tot + Presets Presetări + Preset Manager… Administrator presetări… + Migrate Presets Migrare presetări + Export/Import Presets… Export/import presetări… + Color Settings… Setări de culoare… + Assign Profile… Asignare profil… + Convert to Profile… Conversie la profil… + Keyboard Shortcuts… Comenzi rapide de la tastatură… + Menus… Meniuri… + Toolbar… Bară de instrumente… + Preferences Preferințe + General… General… + Interface… Interfață… + Workspace… Spațiu de lucru… + Tools… Instrumente… + History Log… Jurnal de istoric… + File Handling… Gestionare fișiere… + Export… Export… + Performance… Performanță… + Scratch Disks… Discuri temporare… + Cursors… Cursoare… + Transparency & Gamut… Transparență și gamă… + Units & Rulers… Unități și rigle… + Guides, Grid & Slices… Ghidaje, grilă și felii… + Plug-ins… Plugin-uri… + Type… Text… + Enhanced Controls… Controale îmbunătățite… + Camera Raw… Camera Raw… + Raw Defaults… Valori implicite Raw… + Integrations… Integrări… + AI Integrations… Integrări AI… + Install Plug-in… Instalare plugin… + Language Options Opțiuni de limbă + Settings… Setări… + Mode Mod + Bitmap Bitmap + Grayscale Tonuri de gri + Duotone Duoton + Indexed Color Culoare indexată + RGB Color Culoare RGB + CMYK Color Culoare CMYK + Lab Color Culoare Lab + Multichannel Multicanal + 8 Bits/Channel 8 biți/canal + 8 bit 8 biți + Integer Întreg + 16 Bits/Channel 16 biți/canal + 16 bit 16 biți + 32 Bits/Channel 32 biți/canal + 32 bit (float) 32 biți (virgulă mobilă) + Floating point Virgulă mobilă + Color Table… Tabel de culori… + Bitmap… Bitmap… + Duotone… Duoton… + Indexed Color… Culoare indexată… + Adjustments Ajustări + Brightness/Contrast… Luminozitate/contrast… + Levels… Niveluri… + Curves… Curbe… + Exposure… Expunere… + Vibrance… Vibranță… + Hue/Saturation… Nuanță/saturație… + Color Balance… Balans de culoare… + Black & White… Negru și alb… + Photo Filter… Filtru foto… + Channel Mixer… Mixer de canale… + Color Lookup… Căutare culoare… + Invert Inversare + Posterize… Posterizare… + Threshold… Prag… + Gradient Map… Hartă de degrade… + Selective Color… Culoare selectivă… + Shadows/Highlights… Umbre/luminile… + HDR Toning… Tonalizare HDR… + Desaturate Desaturare + Match Color… Potrivire culoare… + Replace Color… Înlocuire culoare… + Equalize Egalizare + Auto Tone Ton automat + Auto Contrast Contrast automat + Auto Color Culoare automată + Image Size… Dimensiune imagine… + Canvas Size… Dimensiune pânză… + Image Rotation Rotire imagine + Flip Canvas Horizontal Răsturnare pânză orizontal + Flip Canvas Vertical Răsturnare pânză vertical + Crop Decupare + Trim… Ajustare margini… + Reveal All Dezvăluire totală + Duplicate… Duplicare… + Apply Image… Aplicare imagine… + Calculations… Calcule… + Variables Variabile + Define… Definire… + Data Sets… Seturi de date… + Apply Data Set… Aplicare set de date… + Variable Data Sets… Seturi de date variabile… + Trap… Trapping… + Analysis Analiză + Set Measurement Scale… Definire scară de măsurare… + Select Data Points… Selectare puncte de date… + Record Measurements Înregistrare măsurători + Ruler Tool Instrument Riglă + Count Tool Instrument Numărare + Place Scale Marker… Plasare marcator de scară… + Custom… Personalizat… + New Nou + Layer… Strat… + Group… Grup… + Group from Layers… Grup din straturi… + New Group from Layers… Grup nou din straturi… + Artboard… Planșă de lucru… + Artboard from Group… Planșă de lucru din grup… + Artboard from Layers… Planșă de lucru din straturi… + Layer via Copy Strat prin copiere + Layer via Cut Strat prin decupare + Layer From Background… Strat din fundal… + Layer from Background… Strat din fundal… + Background from Layer Fundal din strat + Background From Layer Fundal din strat + New Shape Layer Strat de formă nou + New Type Layer Strat de text nou + New Fill Layer Strat de umplere nou + New Adjustment Layer Strat de ajustare nou + New Layer Based Slice Felie nouă bazată pe strat + New Smart Object via Copy Obiect inteligent nou prin copiere + New Blank Video Layer Strat video gol nou + New Video Layer from File… Strat video nou din fișier… + Duplicate Layer… Duplicare strat… + Delete Layer Ștergere strat + Delete Ștergere + Rename Layer Redenumire strat + Create Layer Creare strat + Layer Style Stil de strat + Blending Options… Opțiuni de fuziune… + Bevel & Emboss… Relief și teșire… + Use Global Light Utilizare lumină globală + Overprint Suprimprimare + Inner Shadow… Umbră interioară… + Inner Glow… Strălucire interioară… + Satin… Satin… + Color Overlay… Suprapunere culoare… + Gradient Overlay… Suprapunere degrade… + Pattern Overlay… Suprapunere model… + Outer Glow… Strălucire exterioară… + Drop Shadow… Umbră paralelă… + Copy Layer Style Copiere stil de strat + Paste Layer Style Inserare stil de strat + Clear Layer Style Ștergere stil de strat + Global Light… Lumină globală… + Hide All Effects Ascundere toate efectele + Scale Effects… Scalare efecte… + Smart Filter Filtru inteligent + Convert for Smart Filters Conversie pentru filtre inteligente + Clear Smart Filters Ștergere filtre inteligente + Disable Smart Filters Dezactivare filtre inteligente + Delete Filter Mask Ștergere mască de filtru + Disable Filter Mask Dezactivare mască de filtru + Solid Color… Culoare solidă… + Gradient… Degrade… + Pattern… Model… + Layer Mask Mască de strat + Vector Mask Mască vectorială + Reveal Selection Dezvăluire selecție + Hide Selection Ascundere selecție + Hide All Ascundere tot + From Transparency Din transparență + Delete Vector Mask Ștergere mască vectorială + Enable Layer Mask Activare mască de strat + Disable Layer Mask Dezactivare mască de strat + Enable Vector Mask Activare mască vectorială + Link Layer Mask Conectare mască de strat + Unlink Layer Mask Deconectare mască de strat + Link Vector Mask Conectare mască vectorială + Add Vector Mask Adăugare mască vectorială + Vector Mask from Current Path Mască vectorială din traseul curent + Create Clipping Mask Creare mască de tăiere + Release Clipping Mask Eliberare mască de tăiere + Smart Objects Obiecte inteligente + Smart Object Obiect inteligent + Convert to Smart Object Conversie în obiect inteligent + Convert to Embedded Conversie în încorporat + Convert to Layers Conversie în straturi + Convert to Linked… Conversie în conectat… + Edit Contents Editare conținut + Replace Contents… Înlocuire conținut… + Relink to File… Reconectare la fișier… + Export Contents… Export conținut… + Update Modified Content Actualizare conținut modificat + Update All Modified Content Actualizare tot conținutul modificat + Stack Mode Mod stivuire + Choose Stack Mode: Alegere mod stivuire: + Entropy Entropie + Kurtosis Boltire (curtoză) + Maximum Maximum + Mean Medie + Median Mediană + Minimum Minimum + Range Interval + Skewness Asimetrie + Standard Deviation Deviație standard + Summation Sumă + Variance Varianță + Video Layers Straturi video + Interpret Footage… Interpretare material video… + Replace Footage… Înlocuire material video… + Reload Frame Reîncărcare cadru + Show Altered Video Afișare video modificat + Restore Frame Restaurare cadru + Restore All Frames Restaurare toate cadrele + Insert Blank Frame Inserare cadru gol + Delete Frame Ștergere cadru + Duplicate Frame Duplicare cadru + Frame from Layers Cadru din straturi + Video Frames to Layers… Cadre video în straturi… + Rasterize Rasterizare + Rasterize Type Rasterizare text + Rasterize Type Layer Rasterizare strat de text + Rasterize Shape Rasterizare formă + New Guide Layout… Aranjament nou de ghidaje… + New Guides From Shape Ghidaje noi din formă + Lock Layers… Blocare straturi… + Link Layers Conectare straturi + Select Linked Layers Selectare straturi conectate + Merge Layers Combinare straturi + Merge Down Combinare cu stratul de dedesubt + Merge Group Combinare grup + Merge Visible Combinare vizibile + Stamp Visible Ștampilare vizibile + Select Layer Above Selectare strat de sus + Select Layer Below Selectare strat de jos + Select Top Layer Selectare strat superior + Select Bottom Layer Selectare strat inferior + Add Layer Above to Selection Adăugare strat de sus la selecție + Add Layer Below to Selection Adăugare strat de jos la selecție + Stamp Down Ștampilare în jos + Flatten Image Aplatizare imagine + Matting Halouri + Color Decontaminate… Decontaminare culoare… + Remove Black Matte Eliminare halou negru + Remove White Matte Eliminare halou alb + Defringe… Eliminare halouri… + Group Layers Grupare straturi + Ungroup Layers Degrupare straturi + Hide Layers Ascundere straturi + Arrange Aranjare + Bring to Front Aducere în față + Bring Forward Aducere mai în față + Send Backward Trimitere mai în spate + Send to Back Trimitere în spate + Reverse Inversare + Combine Shapes Combinare forme + Unite Shapes Unire forme + Subtract Front Shape Scădere formă din față + Intersect Shape Areas Intersectare zone de forme + Exclude Overlapping Shapes Exclude forme suprapuse + Merge Shape Components Combinare componente de formă + Path operations Operații cu trasee + Align Alinere + Top Edges Margini superioare + Vertical Centers Centre verticale + Bottom Edges Margini inferioare + Left Edges Margini din stânga + Horizontal Centers Centre orizontale + Right Edges Margini din dreapta + Distribute Distribuire + Vertically Vertical + Horizontally Orizontal + Layer Edges Margini strat + Isolate Layers Izolare straturi + Layer Content Options… Opțiuni conținut strat… + Fill Content Conținut umplere + Deselect Layers Deselectare straturi + Find Layers Căutare straturi + Layer Comps Compoziții de straturi + Layers Straturi + All Layers Toate straturile + Hidden Layers Straturi ascunse + Mask All Objects Mascare toate obiectele + Subject Subiect + Sky Cer + Panels Panouri + Character Panel Panou Caracter + Paragraph Panel Panou Paragraf + Character Styles Panel Panou Stiluri de caracter + Paragraph Styles Panel Panou Stiluri de paragraf + Glyphs Panel Panou Glife + Anti-Alias Anticrenelare + Sharp Clar + Crisp Net + Strong Puternic + Smooth Lin + Windows LCD LCD Windows + Windows Windows + Orientation Orientare + Horizontal Orizontal + Vertical Vertical + OpenType OpenType + Standard Ligatures Ligaturi standard + Contextual Alternates Alternative contextuale + Discretionary Ligatures Ligaturi discreționare + Swash Caractere ornamentale + Oldstyle Stil vechi + Stylistic Alternates Alternative stilistice + Titling Alternates Alternative pentru titluri + Ornaments Ornamente + Ordinals Ordinale + Fractions Fracții + Create Work Path Creare traseu de lucru + Convert to Shape Conversie în formă + Warp Text… Deformare text… + Convert to Point Text Conversie în text punctual + Convert to Paragraph Text Conversie în text în paragraf + Default Features Funcții implicite + East Asian Features Funcții pentru Asia de Est + Middle Eastern Features Funcții pentru Orientul Mijlociu + Middle Eastern & South Asian Composer Compozitor pentru Orientul Mijlociu și Asia de Sud + Update All Text Layers Actualizare toate straturile de text + Replace All Missing Fonts Înlocuire toate fonturile lipsă + Resolve Missing Fonts… Rezolvare fonturi lipsă… + Font Preview Size Dimensiune previzualizare font + None Niciuna + Small Mic + Medium Mediu + Large Mare + Extra Large Foarte mare + Huge Enorm + Load Default Type Styles Încărcare stiluri de text implicite + Save Default Type Styles Salvare stiluri de text implicite + Deselect Deselectare + Reselect Reselectare + Inverse Inversare + Inverse Selection Inversare selecție + Similar Similar + Color Range… Gamă de culori… + Focus Area… Zonă de focalizare… + Select and Mask… Selecție și mască… + Smart Radius Rază inteligentă + Shift Edge Deplasare margine + Decontaminate Decontaminare + Output Rezultat + Refine Edge Perfecționare margine + Modify Modificare + Border… Margine… + Smooth… Netezire… + Expand… Expandare… + Contract… Contractare… + Feather… Calare… + Grow Extindere + Transform Selection Transformare selecție + Edit in Quick Mask Mode Editare în mod mască rapidă + Load Selection… Încărcare selecție… + Save Selection… Salvare selecție… + Match All Potrivire totală + Last Filter Ultimul filtru + Filter Gallery… Galerie de filtre… + Adaptive Wide Angle… Unghi larg adaptabil… + Camera Raw Filter… Filtru Camera Raw… + Liquify… Lichefiere… + Vanishing Point… Punct de fugă… + Blur Neclaritate + Blur Gallery Galerie neclaritate + Field Blur… Neclaritate câmp… + No pin selected Niciun pin selectat + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Faceți clic pe imagine pentru a adăuga un pin; trageți un pin pentru a-l deplasa sau inelul său pentru a modifica neclaritatea. Delete elimină pinul selectat. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Trageți manetele de pe contur pentru a-l redimensiona și roti, pătratul pentru a-i rotunji colțurile și punctele interioare pentru a stabili de unde începe neclaritatea. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Trageți pinul pentru a deplasa banda sau inelul său pentru a modifica neclaritatea. Trageți liniile continue pentru a lărgi banda clară, liniile întrerupte pentru a defini tranziția și punctele pentru a o roti. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Trageți manetele de pe contur pentru a-l redimensiona și roti; inelul stabilește unghiul de neclaritate. + Blur Angle Unghi neclaritate + Iris Blur… Neclaritate iris… + Tilt-Shift… Înclinare-deplasare… + Path Blur… Neclaritate traseu… + Spin Blur… Neclaritate rotație… + Blur More Neclaritate mai accentuată + Box Blur… Neclaritate cutie… + Gaussian Blur… Neclaritate gaussiană… + Lens Blur… Neclaritate lentilă… + Motion Blur… Neclaritate mișcare… + Radial Blur… Neclaritate radială… + Shape Blur… Neclaritate formă… + Smart Blur… Neclaritate inteligentă… + Surface Blur… Neclaritate suprafață… + Average Medie + Displace… Deplasare… + Pinch… Ciupire… + Polar Coordinates… Coordonate polare… + Ripple… Ondulație… + Shear… Forfecare… + Spherize… Sferizare… + Twirl… Vârtej… + Wave… Val… + ZigZag… Zigzag… + Noise Zgomot + Add Noise… Adăugare zgomot… + Despeckle Eliminare pete + Dust & Scratches… Praf și zgârieturi… + Median… Mediană… + Reduce Noise… Reducere zgomot… + Pixelate Pixelizare + Color Halftone… Semiton color… + Crystallize… Cristalizare… + Facet Fațetare + Fragment Fragmentare + Mezzotint… Mezzotintă… + Mosaic… Mozaic… + Pointillize… Pointilism… + Render Randare + Clouds Nori + Difference Clouds Nori de diferență + Fibers… Fibre… + Flame… Flacără… + Lens Flare… Reflexie lentilă… + Lighting Effects… Efecte de iluminare… + Relight… Reiluminare… + Picture Frame… Ramă foto… + Tree… Copac… + Sharpen Claritate + Sharpen Edges Clarificare margini + Sharpen More Clarificare mai accentuată + Smart Sharpen… Clarificare inteligentă… + Unsharp Mask… Mască de claritate… + High Pass… Trecere de sus… + Stylize Stilizare + Diffuse… Difuzare… + Emboss… Relief… + Extrude… Extrudare… + Find Edges Găsire margini + Oil Paint… Pictură în ulei… + Solarize Solarizare + Tiles… Plăci… + Trace Contour… Trasare contur… + Wind… Vânt… + Video Video + De-Interlace… Dezîntrețesere… + NTSC Colors Culori NTSC + Other Altul + Maximum… Maximum… + Minimum… Minimum… + Offset… Decalaj… + Plug-ins Plugin-uri + Proof Setup Configurare probă + Proof Colors Culori de probă + Gamut Warning Avertisment gamă + Working CMYK CMYK de lucru + Working Cyan Plate Placă cyan de lucru + Working Magenta Plate Placă magenta de lucru + Working Yellow Plate Placă galben de lucru + Working Black Plate Placă negru de lucru + Working CMY Plate Placă CMY de lucru + Legacy Macintosh RGB RGB vechi Macintosh + Internet Standard RGB RGB standard Internet + Monitor RGB RGB monitor + Color Blindness — Protanopia-type Daltonism — tip protanopie + Color Blindness — Deuteranopia-type Daltonism — tip deuteranopie + Zoom In Mărire + Zoom Out Micșorare + Fit on Screen Încadrare în ecran + Fit Layer(s) on Screen Încadrare strat(uri) în ecran + Fit Artboard on Screen Încadrare planșă de lucru în ecran + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Dimensiune imprimare + Pixel Aspect Ratio Raport de aspect pixel + Pixel Aspect Ratio Correction Corecție raport de aspect pixel + Custom Pixel Aspect Ratio… Raport de aspect pixel personalizat… + Square Pătrat + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC panoramic (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL panoramic (1,46) + Anamorphic 2:1 (2) Anamorfic 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + 32-bit Preview Options… Opțiuni previzualizare 32 biți… + Screen Mode Mod ecran + Standard Screen Mode Mod ecran standard + Full Screen Mode With Menu Bar Mod ecran complet cu bară de meniuri + Full Screen Mode Mod ecran complet + Cycle Screen Mode Comutare mod ecran + Show Afișare + Extras Elemente suplimentare + Selection Edges Margini selecție + Target Path Traseu destinație + Current Path Traseu curent + Grid Grilă + Guides Ghidaje + Canvas Guides Ghidaje pânză + Artboard Guides Ghidaje planșă de lucru + Count Numărare + Smart Guides Ghidaje inteligente + Slices Felii + Notes Note + Pixel Grid Grilă de pixeli + Pixel Art Artă în pixeli + Pixel Art Preview Previzualizare artă în pixeli + Pattern Preview Previzualizare model + Brush Preview Previzualizare pensulă + Mesh Plasă + Edit Pins Editare pini + Document Bounds Limite document + Show Extras Options… Opțiuni elemente suplimentare… + Rulers Rigle + Snap Fixare + Snap To Fixare la + Lock Guides Blocare ghidaje + Clear Guides Ștergere ghidaje + Clear Selected Artboard Guides Ștergere ghidaje planșă de lucru selectată + Clear Canvas Guides Ștergere ghidaje pânză + New Guide… Ghidaj nou… + Lock Slices Blocare felii + Clear Slices Ștergere felii + Match Zoom Potrivire zoom + Match Location Potrivire poziție + Match Rotation Potrivire rotație + Cascade Cascadă + Tile Mozaic + Tile All Horizontally Mozaic orizontal pentru toate + Tile All Vertically Mozaic vertical pentru toate + 2-up Horizontal 2 ferestre (orizontal) + 2-up Vertical 2 ferestre (vertical) + 3-up Horizontal 3 ferestre (orizontal) + 3-up Stacked 3 ferestre (stivuite) + 3-up Vertical 3 ferestre (vertical) + 4-up 4 ferestre + 6-up 6 ferestre + Consolidate All to Tabs Consolidare tot în file + Float in Window Flotare în fereastră + Float All in Windows Flotare toate în ferestre + New Window for Document Fereastră nouă pentru document + Workspace Spațiu de lucru + Essentials Esențiale + Graphic and Web Grafică și web + Painting Pictură + Photography Fotografie + New Workspace… Spațiu de lucru nou… + Delete Workspace… Ștergere spațiu de lucru… + Reset Workspace Resetare spațiu de lucru + Reset Tool Resetare instrument + Reset All Tools Resetare toate instrumentele + Reset all tools to their default settings? Se resetează toate instrumentele la valorile implicite? + Lock Workspace Blocare spațiu de lucru + Theme Temă + Pro Theme Temă Pro + Pro Medium Gray Theme Temă Pro (gri mediu) + Pro Light Gray Theme Temă Pro (gri clar) + Pro Light Theme Temă Pro (clară) + Studio Theme Temă Studio + Studio Light Theme Temă Studio (clară) + Adwaita Light Theme Temă Adwaita (clară) + Adwaita Dark Theme Temă Adwaita (întunecată) + Classic Theme Temă Clasică + Solarized dark Solarized întunecat + Solarized Dark Theme Temă Solarized întunecată + Breeze dark Breeze întunecat + Breeze Dark Theme Temă Breeze întunecată + Breeze light Breeze clar + Breeze Light Theme Temă Breeze clară + Soft dark Suave întunecat + Soft light Suave clar + Soft Dark Theme Temă suave întunecată + Soft Light Theme Temă suave clară + Next Theme Temă următoare + Actions Acțiuni + Brush Settings Setări pensulă + Brushes Pensule + Channels Canale + Character Caracter + Character Styles Stiluri de caracter + Clone Source Sursă de clonare + Color Culoare + Glyphs Glife + Gradients Degradeuri + Histogram Histogramă + History Istoric + Info Informații + Measurement Log Jurnal de măsurători + Modifier Keys Taste modificatoare + Navigator Navigator + Options Opțiuni + Paragraph Paragraf + Paragraph Styles Stiluri de paragraf + Paths Trasee + Patterns Modele + Properties Proprietăți + Shapes Forme + Styles Stiluri + Swatches Mostre + Timeline Cronologie + Tool Presets Presetări instrument + Tools Instrumente + About PhotoCraft Despre PhotoCraft + PhotoCraft Website Site-ul PhotoCraft + PhotoCraft on GitHub PhotoCraft pe GitHub + ArtCraft Website Site-ul ArtCraft + Join the ArtCraft Discord… Alăturați-vă Discord-ului ArtCraft… + Report an Issue… Raportare problemă… + WIA Support… Suport WIA… + Apply Aplicare + HSB/HSL HSB/HSL + Invert… Inversare… + Load Files into Stack… Încărcare fișiere în stivă… + Motion Mișcare + Notes… Note… + Other… Altul… + Remove Warp Split Eliminare divizare deformare + Shape Formă + Split Warp Crosswise Divizare deformare în cruce + Split Warp Horizontally Divizare deformare orizontal + Split Warp Vertically Divizare deformare vertical + Statistics… Statistici… + Refine Strokes Perfecționare trasee + Edits Editări +@plural {n} item|{n} items {n} element|{n} elemente|{n} de elemente + Auto Automat + (print to the printer) (se imprimă pe imprimantă) + + Add variable + Adăugare variabilă + 30 frames @ 30 fps 30 de cadre @ 30 fps + 3D LUT File Fișier LUT 3D + Absolute Absolut + Absolute Colorimetric Colorimetric absolut + Action Acțiune + Adaptive Adaptiv + Adaptive Wide Angle isn't open Unghiul larg adaptabil nu este deschis + Add Adăugare + Add Layer Mask Adăugare mască de strat + Add Stop Adăugare marcaj + Add a layer style Adăugare stil de strat + Move effect up Deplasare efect sus + Move effect down Deplasare efect jos + Delete effect Ștergere efect + Add a mask (from the selection; {key} inverts) Adăugare mască (din selecție; {key} o inversează) + Add an adjustment Adăugare ajustare + Add disk Adăugare disc + Add space after paragraph Adăugare spațiu după paragraf + Add space before paragraph Adăugare spațiu înainte de paragraf + Add to Sample Adăugare la probă + Add to selection ({key}) Adăugare la selecție ({key}) + Adjust for Zoom Ajustare în funcție de zoom + Adjustment visible Ajustare vizibilă + Adobe RGB (1998) compatible Compatibil cu Adobe RGB (1998) + Align and Distribute Alinere și distribuire + Align and distribute Alinere și distribuire + Align horizontal centers Aliniere centre orizontale + Align left edges Aliniere margini din stânga + Align right edges Aliniere margini din dreapta + Aligned Aliniat + All Caps Toate majuscule + All Slices Toate feliile + All User Slices Toate feliile utilizatorului + All shortcuts reset to PhotoCraft defaults. Toate comenzile rapide au fost resetate la valorile implicite ale PhotoCraft. + Alt Alt + Always use pressure for opacity Utilizare permanentă a presiunii pentru opacitate + Always use pressure for size Utilizare permanentă a presiunii pentru dimensiune + Amount Cantitate + Anchor: Ancorare: + Angle Unghi + Angle Gradient Degrade unghiular + Angle Jitter Variație unghi + Answer the unsaved-changes prompt first Răspundeți mai întâi la solicitarea privind modificările nesalvate + Anti-alias Anticrenelare + Appearance Aspect + Appearance (Layer Style) Aspect (stil de strat) + Apply Layer Mask Aplicare mască de strat + Add Mask to Selection Adăugare mască la selecție + Subtract Mask from Selection Scădere mască din selecție + Intersect Mask with Selection Intersectare mască cu selecția + Apply Per Tip Aplicare pe fiecare vârf + Apply next selected layer comp Aplicare următoarea compoziție de strat selectată + Apply previous selected layer comp Aplicare compoziția de strat selectată anterioară + Artboard Planșă de lucru + Artboard Background Color Culoare de fundal a planșei de lucru + Assign working space Asignare spațiu de lucru + Author: Autor: + Auto Hide Ascundere automată + Auto-Select: Selecție automată: + Automatically level near-horizontal line segments Nivelare automată a segmentelor aproape orizontale + Automatically straighten and level Îndreptare și nivelare automată + Automatically straighten near-vertical line segments Îndreptare automată a segmentelor aproape verticale + Background Fundal + Background Color Culoare de fundal + Background Contents Conținut fundal + Custom background color Culoare de fundal personalizată + Balance Balans + Baseline Linia de bază + Baseline shift Deplasare linie de bază + Basic De bază + Before (Y) Înainte (Y) + Save Settings… Salvare setări… + Load Settings… Încărcare setări… + Save these settings as a Camera Raw .xmp file Salvare acestor setări ca fișier .xmp Camera Raw + Load settings from a Camera Raw .xmp file Încărcare setări dintr-un fișier .xmp Camera Raw + Loaded Camera Raw settings from {file} Setări Camera Raw încărcate din {file} + Loaded Camera Raw settings from {file} ({n} not supported) Setări Camera Raw încărcate din {file} ({n} nesuportate) + Begin recording Pornire înregistrare + Bend: Curbură: + Bicubic Bicubică + Bicubic (smooth gradients) Bicubică (degradeuri netede) + Automatic Automat + Bicubic Smoother (enlargement) Bicubică mai netedă (mărire) + Bicubic Sharper (reduction) Bicubică mai clară (reducere) + Bilinear Biliniară + Black Negru + Black & White Negru și alb + Black Point Compensation Compensare punct negru + Black, White Negru, alb + Blacks Negre + Blending Fuziune + Blending Options Opțiuni de fuziune + Blue Albastru + Blue, Red, Yellow Albastru, roșu, galben + Both Axes Ambele axe + Bottom Jos + Brightness Luminozitate + Brightness Jitter Variație luminozitate + Brightness/Contrast Luminozitate/contrast + Bring the selected pin forward Aducere pin selectat în față + Brush Preset picker Selector presetări pensulă + Brush Reconstruct Options Opțiuni reconstrucție pensulă + Brush Tool Options Opțiuni instrument Pensulă + Cache Level: Nivel cache: + Camera Raw isn't open Camera Raw nu este deschis + Cancel Anulare + Cancel (Esc) Anulare (Esc) + Manage submitted jobs in the system print queue Gestionarea lucrărilor trimise în coada de imprimare a sistemului + Cancel any current edits (Esc) Anulare editări curente (Esc) + Cancel current crop operation (Esc) Anulare operație de decupare curentă (Esc) + Cancel transform (Esc) Anulare transformare (Esc) + Cancel warp (Esc) Anulare deformare (Esc) + Canvas Pânză + Canvas extension color: Culoare extindere pânză: + Capture Captură + Capture the visibility, position and style of every layer with + below, then switch between versions. Capturați vizibilitatea, poziția și stilul fiecărui strat cu + de mai jos, apoi comutați între versiuni. + Catch-up on Stroke End Recuperare la finalul tusei + Center Centru + Center Crop Marks Marcaje de tăiere centrate + Center text Centrare text + Change Modificare + Change All Modificare toate + Change Screen Mode (F) Comutare mod ecran (F) + Change To: Modificare în: + Channel Mixer Mixer de canale + Channel: Canal: + Check All Layers Verificare toate straturile + Check Spelling Verificare ortografică + Clarity Claritate + Clear All Ștergere tot + Clear Override Eliminare excepție + Clear Samples Ștergere probe + Click sets foreground · right-click sets background Clic stabilește culoarea frontală · clic dreapta stabilește culoarea de fundal + Click the first point or press {key} to close · Esc cancels Faceți clic pe primul punct sau apăsați {key} pentru a încheia · Esc anulează + Click to add a point · drag off or {key}-click or Delete to remove Clic pentru a adăuga un punct · trageți departe sau {key}-clic sau Delete pentru a elimina + Click to apply, {key}-click to clear overrides, double-click for Style Options Clic pentru a aplica, {key}-clic pentru a elimina excepțiile, dublu clic pentru Opțiuni de stil + Click to edit the gradient Faceți clic pentru a edita degradeul + Click to restore the current colour Faceți clic pentru a restaura culoarea curentă + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Clic pentru mărire · {key}-clic pentru micșorare · trageți dreapta/stânga pentru mărire/micșorare + Click with the Note tool to add a note. Faceți clic cu instrumentul Notă pentru a adăuga o notă. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic: colț · Tragere: netezire · Clic pe primul punct: încheiere · {key}: finalizare · Esc: anulare + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Clic: colț · Tragere: netezire · {alt}+tragere: separare manetă · Clic sau tragere pe primul punct: încheiere · {key}: finalizare · Esc: anulare + Clip to layer below Tăiere la stratul de dedesubt + Clipped Tăiat + Close Style Options Închidere opțiuni de stil + Cmd Cmd + Coated CMYK CMYK pentru hârtie cretată + Color & Swatches Culoare și mostre + Color Balance Balans de culoare + Color Detail Detaliu culoare + Color Grading Gradare culoare + Color Lookup Căutare culoare + Color Management Gestionare culoare + Color Mixer Mixer de culoare + Color Mode Mod culoare + Color Priority Prioritate culoare + Color Range preview Previzualizare gamă de culori + Color: Culoare: + Colorize Colorare + Colors Culori + Commit Perspective Warp ({key}) Confirmare deformare perspectivă ({key}) + Commit Puppet Warp ({key}) Confirmare deformare marionetă ({key}) + Commit any current edits ({key}) Confirmare editări curente ({key}) + Commit current crop operation ({key}) Confirmare operație de decupare curentă ({key}) + Commit transform ({key}) Confirmare transformare ({key}) + Commit warp ({key}) Confirmare deformare ({key}) + Constant Constant + Constrain proportions Restricționare proporții + Contact Sheet II Planșă de contact II + Content-Aware Sensibil la conținut + Contiguous Contiguu + Contrast Contrast + Control: Control: + Convert to sRGB Conversie la sRGB + Copies Exemplare + Copper Cupru + Copyright Drepturi de autor + Copyright and Contact Info Drepturi de autor și informații de contact + Corner Crop Marks Marcaje de tăiere în colțuri + Corner radius Rază colț + Correction Corecție + Count Jitter Variație numărare + Create Creare + Create Droplet Creare droplet + Create Texture Creare textură + Create Video Timeline Creare cronologie video + Create a new count group Creare grup de numărare nou + Create a new document or open an existing file. Creați un document nou sau deschideți un fișier existent. + Create a new group Creare grup nou + Create a new layer Creare strat nou + Create new action Creare acțiune nouă + Create new channel Creare canal nou + Create new document from current state Creare document nou din starea curentă + Create new fill or adjustment layer Creare strat de umplere sau de ajustare nou + Create new gradient Creare degrade nou + Create new group Creare grup nou + Create new layer comp Creare compoziție de strat nouă + Create new pattern from the selection Creare model nou din selecție + Create new shape from the current path Creare formă nouă din traseul curent + Create new snapshot Creare captură nouă + From: Din: + Full Document Document complet + Merged Layers Straturi combinate + Create new tool preset Creare presetare instrument nouă + Crop Factor Factor de decupare + Ctrl Ctrl + Current & Below Curent și inferioare + Current Layer Stratul curent + Current Size Dimensiune curentă + Current Tool Only Numai instrumentul curent + Curve Curbă + Curves Curbe + Custom Personalizat + Custom Shapes Forme personalizate + Cyan Cian + Cyan · Red Cian · roșu + Darken Întunecare + Darks Întunecate + Data Sets Seturi de date + Default Implicit + Default colours (D) Culori implicite (D) + Default printer Imprimantă implicită + Default restored. Valorile implicite au fost restaurate. + Define Definire + Define at least one variable first. Definiți cel puțin o variabilă întâi. + Dehaze Eliminare ceață + Delete Cropped Pixels Ștergere pixeli decupați + Delete Group Ștergere grup + Delete Layer Mask Ștergere mască de strat + Delete Layers Ștergere straturi + Add Shortcut Adăugare comandă rapidă + Delete Shortcut Ștergere comandă rapidă + Delete Timeline Ștergere cronologie + Delete Tool Preset Ștergere presetare instrument + Delete count group Ștergere grup de numărare + Delete current channel Ștergere canal curent + Delete current state Ștergere stare curentă + Delete effect layer Ștergere strat de efect + Delete layer ‘{name}’? Ștergeți stratul „{name}”? + Delete {count} layers? {names} Ștergeți {count} straturi? {names} + Delete layer? Ștergeți stratul? + The document is closed Documentul este închis + The document or selected layers changed. Please request deletion again. Documentul sau straturile selectate s-au modificat. Solicitați din nou ștergerea. + Missing delete command Comandă de ștergere lipsă + Invalid delete command Comandă de ștergere nevalidă + Layer deleted, but confirmation preference could not be saved: {error} Stratul a fost șters, dar preferința de confirmare nu a putut fi salvată: {error} + Delete layer Ștergere strat + Delete layer comp Ștergere compoziție de strat + Delete note Ștergere notă + Delete selected measurements Ștergere măsurători selectate + Delete style Ștergere stil + Delete tool preset Ștergere presetare instrument + Density Densitate + Density: Densitate: + Depth Profunzime + Depth Jitter Variație profunzime + Description Descriere + Deselect All Deselectare tot + Detail Detaliu + Diamond Gradient Degrade romb + Difference Diferență + Diffusion Difuzie + Discard the embedded profile (don't color manage) Renunțare la profilul încorporat (fără gestionarea culorii) + Discontiguous Necontiguu + Discord Discord + Dismiss Închidere + Display P3 Display P3 + Display Text Text afișat + Dither Tramare + Divide… Împărțire… + Document Document + Don't Save Nu salvați + No Nu + Yes Da + Done Terminat + Dots Puncte + Drag a rectangle around the object Trageți un rectangul în jurul obiectului + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Trageți pe imagine pentru a adăuga o constrângere; Shift pentru orizontal/vertical; clic dreapta pe o constrângere pentru a o modifica sau șterge. + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: selectare punct de ancorare · Tragere: deplasare punct, manetă sau segment · {shift}-clic: adăugare · {alt}-clic: subtraseu complet + Draw with the Pen tool (P) or make a work path from a selection. Desenați cu instrumentul Pană (P) sau creați un traseu de lucru dintr-o selecție. + Drop an image or PSD anywhere to open it. Plasați o imagine sau un PSD oriunde pentru a-l deschide. + Native file drag-and-drop is unavailable Tragerea și plasarea nativă a fișierelor nu este disponibilă pe Wayland + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Tragerea și plasarea nativă a fișierelor nu este încă suportată pe Wayland. Utilizați Fișier › Deschidere, sau copiați imaginea din gestionarul de fișiere și inserați-o cu {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Pentru a plasa fișiere, porniți PhotoCraft sub XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Sau setați Preferințe › Performanță › Server de afișare Linux la X11: PhotoCraft va porni mereu sub XWayland. + Edit › Paste Editare › Inserare + Duplicate Layers… Duplicare straturi… + Duplicate style Duplicare stil + Edit Toolbar… Editare bară de instrumente… + Use File › Open, or paste a copied image with {paste}. Utilizați Fișier › Deschidere, sau inserați o imagine copiată cu {paste}. + Edit in Quick Mask Mode (Q) Editare în mod mască rapidă (Q) + Edit in Standard Mode (Q) Editare în mod standard (Q) + Effects Efecte + Embed Color Profile Încorporare profil de culoare + Embedded Profile Mismatch Profilul încorporat nu corespunde + Enable airbrush-style build-up effects Activare efecte de acumulare tip aerograf + Enhance Edge Îmbunătățire margine + Entire Font Fontul complet + Exact Exact + Expansion: Expansiune: + Export Presets Export presetări + Export selected measurements (CSV) Export măsurători selectate (CSV) + Exposure Expunere + Exposure and Gamma Expunere și gamma + Exposure: Expunere: + Eyedropper Pipetă + Factor: Factor: + Faux Bold Bold fals + Faux Italic Cursiv fals + Feather Calare + Fewer Points Mai puține puncte + File Settings Setări fișier + Fill Umplere + Fill: Umplere: + Filter for adjustment layers Filtrare după straturi de ajustare + Filter for pixel layers Filtrare după straturi de pixeli + Filter for shape layers Filtrare după straturi de formă + Filter for smart objects Filtrare după obiecte inteligente + Filter for type layers Filtrare după straturi de text + Filter: Filtru: + Finger Painting Pictură cu degetele + Fisheye Ochi de pește + Fit Încadrare + Fit Screen Încadrare în ecran + Fill Screen Umplere ecran + Fit in View Încadrare în vizualizare + Fixed Fix + Fixed Ratio Raport fix + Fixed Size Dimensiune fixă + Flip Răsturnare + Flip X Răsturnare X + Flip X Jitter Variație răsturnare X + Flip Y Răsturnare Y + Flip Y Jitter Variație răsturnare Y + Flow Flux + Flow Jitter Variație flux + Focal Length Distanță focală + Font Font + Font Size: Dimensiune font: + Font size Dimensiune font + Foreground Frontală + Foreground to Background De la frontală la fundal + Foreground/Background Jitter Variație frontal/fundal + Format Format + Frame from Layers… Cadru din straturi… + Free Liber + Full Spherical Sferic complet + Fuzziness: Toleranță: + GPU GPU + Gamma Correction Corecție gamma + Gamma: Gamma: + Global Global + Glow Color Culoare strălucire + Gradient Map Hartă de degrade + Grain Granulație + Gray Gri + Gray Gamma 2.2 Gri gamma 2,2 + Green Verde + Group Grup + Hardness Duritate + Heart Inimă + Height Înălțime + Height: Înălțime: + Hide Auto Slices Ascundere felii automate + Hide Properties Ascundere proprietăți + Highlight Compression Compresie luminile + Highlight Priority Prioritate luminile + Highlights Luminile + Home Acasă + Horizontal scale Scalare orizontală + Hue Nuanță + Hue Jitter Variație nuanță + Hue/Saturation Nuanță/saturație + Hyphenate Silabisire + Ignore Ignorare + Ignore All Ignorare tot + Image Size Dimensiune imagine + Import Brushes… Import pensule… + Gradient Preset Options Opțiuni presetare degrade + Import .GRD Import .GRD + Choose a gradient file (.grd). Selectați un fișier de degradeuri (.grd). + Import brushes (.abr) Import pensule (.abr) + Import Presets Import presetări + Imported Importat + Indent First Indentare primă linie + Indent Left Indentare stânga + Indent Right Indentare dreapta + Indent first line Indentare primă linie + Indent left margin Indentare margine stânga + Indent right margin Indentare margine dreapta + Input: Intrare: + Inside Interior + Interlaced Întrețesut + Interpolation: Interpolare: + Intersect with selection ({key}) Intersectare cu selecția ({key}) + Invert All Inversare tot + Invert has no settings. Inversare nu are setări. + Italic Cursiv + Join us on Discord Alăturați-vă Discord-ului nostru + Justify all Aliniere completă pentru toate + Justify last centered Aliniere completă, ultima centrată + Justify last left Aliniere completă, ultima la stânga + Justify last right Aliniere completă, ultima la dreapta + Kerning Interliterare + Keyboard Shortcuts Comenzi rapide de la tastatură + Keyboard Shortcuts and Menus Comenzi rapide de la tastatură și meniuri + Kind Tip + Label Size Dimensiune etichetă + Labels Etichete + Lanczos (sharp) Lanczos (clar) + Landscape Peisaj + Last Document State Ultima stare a documentului + Layout Aranjament + Leading Interlinie + Leading (set to the font size × auto-leading when Auto) Interlinie (egală cu dimensiunea fontului × interlinie automată când e pe Automat) + Leave as is (don't color manage) Lăsare ca este (fără gestionarea culorii) + Left align text Aliniere text la stânga + Length: Lungime: + Level Nivel + Levels Niveluri + Lighten Luminare + Lightness Luminozitate + Lights Lumini + Limits: Limite: + Linear Gradient Degrade liniar + Link layers Conectare straturi + Link width and height Legare lățime și înălțime + Liquify is not open Lichefierea nu este deschisă + Load Încărcare + Load 3D LUT… Încărcare LUT 3D… + Load channel as selection Încărcare canal ca selecție + Load… Încărcare… + Localized Color Clusters Grupuri de culori localizate + Lock Blocare + Lock all Blocare tot + Lock image pixels Blocare pixeli imagine + Lock layer Blocare strat + Lock position Blocare poziție + Lock transparent pixels Blocare pixeli transparenți + Lock: Blocare: + Logical Length: Lungime logică: + Logical Units: Unități logice: + Luminance Luminanță + Luminance Detail Detaliu luminanță + Magenta Magenta + Magenta · Green Magenta · verde + Maintain aspect ratio Păstrare raport de aspect + Marker Size Dimensiune marcator + Mask All Mascare tot + Mask Options Opțiuni mască + Masking Mascare + Master Toți + Matte Mat + Median: Mediană: + Menus Meniuri + Mesh Size Dimensiune plasă + Metadata Metadate + Metrics Metrici + Midpoint Punct median + Midtones Tonuri medii + Midtones from: Tonuri medii de la: + Midtones to: Tonuri medii până la: + Minimum Depth Profunzime minimă + Minimum Diameter Diametru minim + Minimum Roundness Rotunjime minimă + Mode: Mod: + Monochrome Monocrom + More Points Mai multe puncte + Move effect layer down Deplasare strat de efect jos + Move effect layer up Deplasare strat de efect sus + Move to Deplasare la + Name Nume + Name: Nume: + Nearest Neighbor Vecinul cel mai apropiat + Nearest Neighbor (hard edges) Vecinul cel mai apropiat (margini dure) + New Size Dimensiune nouă + New document… Document nou… + New effect layer Strat de efect nou + New selection Selecție nouă + Next frame Cadrul următor + Next note Nota următoare + No Color Fără culoare + No Color Management Fără gestionare a culorii + No Dither Fără tramare + No author Fără autor + No character style Fără stil de caracter + No document Fără document + No matching commands Nicio comandă corespunzătoare + No presets for the current tool. Nu există presetări pentru instrumentul curent. + No presets of this type. Nu există presetări de acest tip. + No properties Fără proprietăți + No tool presets. Nu există presetări de instrument. + Noise Reduction Reducere zgomot + Normal Normal + Not in Dictionary: Nu este în dicționar: + OK OK + Offset Decalaj + Only Web Colors Numai culori web + Opacity Opacitate + Opacity Jitter Variație opacitate + Opacity: Opacitate: + Open a document to define variables. Deschideți un document pentru a defini variabile. + Open a document to select a colour range. Deschideți un document pentru a selecta o gamă de culori. + Open a document to use the timeline. Deschideți un document pentru a utiliza cronologia. + Optical Optic + Optimized Optimizat + Option-click to define a source point to clone from Clic cu Opțiune pentru a defini un punct sursă din care să clonați + Original Original + Output Channel: Canal de ieșire: + Output Levels: Niveluri de ieșire: + Output: Rezultat: + Outside Exterior + Overlay Color Culoare suprapunere + Overlay Opacity Opacitate suprapunere + Overlay: Rule of Thirds Suprapunere: regula terților + Override Pressure Anulare presiune + Override Rotation Anulare rotație + Override Tilt Anulare înclinare + Override Tilt X Anulare înclinare X + Override Tilt Y Anulare înclinare Y + PRESET DETAILS DETALII PRESETARE + PSD PSD + Paint Overlay Suprapunere pictură + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Pozițiile panourilor sunt salvate în spațiul de lucru. Comenzile rapide de la tastatură, meniurile și bara de instrumente sunt opționale. + Paper Hârtie + Path Traseu + Pattern Model + Pattern Size Dimensiune model + Pause Pauză + Percent Procent + Perceptual Perceptual + Perspective Warp is not active Deformarea de perspectivă nu este activă + Perspective Warp: draw planes along the image's perspective, then switch to Warp Deformare de perspectivă: desenați planuri de-a lungul perspectivei imaginii, apoi comutați la Deformare + Photo Filter Filtru foto + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft gestionează culorile + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — un editor de imagini nativ, open-source, scris în Rust. + Pin Depth: Profunzime pin: + Pixel Length: Lungime în pixeli: + Pixels/Centimeter Pixeli/centimetru + Pixels/Inch Pixeli/inch + Pixels: Pixeli: + Play Redare + Play selection Redare selecție + Portrait Portret + Position Poziție + Position and Size Poziție și dimensiune + Posterize Posterizare + Preserve Details Conservare detalii + Preserve Luminosity Conservare luminozitate + Preset Presetare + Preset Type Tip presetare + Preset: Presetare: + Presets: Presetări: + Press keys… Apăsați tastele… + Pressure Presiune + Pressure for Opacity Presiune pentru opacitate + Pressure for Size Presiune pentru dimensiune + Prevent auto-nesting in and out of Artboards and Frames Prevenire cuibărire automată în și din planșe de lucru și cadre + Preview Previzualizare + Previous frame Cadrul anterior + Previous note Nota anterioară + Print Imprimare + Printer Imprimantă + Printer Manages Colors Imprimanta gestionează culorile + Printer Setup Configurare imprimantă + Printing Marks Marcaje de imprimare + Progressive Progresiv + Promote Promovare + Protect Detail Protejare detalii + Protect Foreground Color Protejare culoare frontală + Protect Tones Protejare tonuri + Proximity Match Potrivire de proximitate + Pulled String Mode Mod coardă trasă + Puppet Warp is not active Deformarea marionetă nu este activă + Purity Puritate + Quality Calitate + Quick Actions Acțiuni rapide + Quick Export As PNG Export rapid ca PNG + Radial Gradient Degrade radial + Radius Rază + Radius: Rază: + Range: Interval: + Rate Viteză + Recently Used Utilizate recent + Reconstruct Reconstruire + Record ● a sequence of edits, then play ▶ it on any document. Înregistrați ● o secvență de editări, apoi redați-o ▶ pe orice document. + Red Roșu + Redefine style by current selection Redefinire stil prin selecția curentă + Refine Rafinare + Reflected Gradient Degrade reflectat + Registration Marks Marcaje de registru + Regular Normal + Relative Relativ + Relative Colorimetric Colorimetric relativ + Remove Eliminare + Remove All Pins Eliminare toate pinii + Remove Background Eliminare fundal + Remove Planes Eliminare planuri + Remove Tool Instrument Eliminare + Remove stop Eliminare marcaj de oprire + Remove variable Eliminare variabilă + Rename Redenumire + Rename Group… Redenumire grup… + Rename Layer… Redenumire strat… + Rename Tool Preset… Redenumire presetare instrument… + Rename… Redenumire… + Resample Reeșantionare + Reset All to Defaults Resetare tot la valorile implicite + Import Shortcuts… Import comenzi rapide… + Load a keyboard shortcut set saved as .kys Încărcare un set de comenzi rapide de la tastatură salvat ca .kys + Imported {n} shortcuts from {file}. Click OK to keep them. S-au importat {n} comenzi rapide din {file}. Faceți clic pe OK pentru a le păstra. + {n} commands are not in PhotoCraft. {n} comenzi nu există în PhotoCraft. + Keyboard shortcuts imported Comenzi rapide de la tastatură importate + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Setul dvs. de comenzi rapide de la tastatură {set} este în uz aici: {n} comenzi rapide diferă de valorile implicite. Editare › Comenzi rapide de la tastatură le afișează; Resetare tot la valorile implicite anulează acest lucru. + Import keyboard shortcuts? Importați comenzile rapide de la tastatură? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft a găsit pe acest computer setul dvs. de comenzi rapide de la tastatură {set} dintr-un alt editor de imagini: {n} dintre comenzile sale rapide diferă de cele ale PhotoCraft. Doriți să îl importați? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Editare › Comenzi rapide de la tastatură › Resetare tot la valorile implicite anulează un import. + Don't Import Nu importați + The keyboard shortcut set was not imported: {error} Setul de comenzi rapide de la tastatură nu a fost importat: {error} + Reset Section Resetare secțiune + Reset Transform Resetare transformare + Reset to defaults Resetare la valorile implicite + Resolution Rezoluție + Resolution: Rezoluție: + Restore All Restaurare tot + Restore Defaults Restaurare valori implicite + Restrictive (Web) Restrictiv (web) + Right align text Aliniere text la dreapta + Rigid Rigid + Rotate: Rotație: + Rotation Rotație + Roughness Rugozitate + Roundness Rotunjime + Roundness Jitter Variație rotunjime + Rulers & Grids Rigle și grile + Sample All Layers Eșantionare toate straturile + Sample: Probă: + Sampling: Background Swatch Eșantionare: mostra de fundal + Sampling: Continuous Eșantionare: continuă + Sampling: Once Eșantionare: o singură dată + Saturate Creștere saturație + Saturation Saturație + Saturation Jitter Variație saturație + Save HTML and Images Salvare HTML și imagini + Save as PDF Salvare ca PDF + Save selection as channel Salvare selecție ca canal + Save… Salvare… + Scale to Fit Media Scalare pentru a încadra mediul + Scatter Dispersie + Scratch disks Discuri temporare + Search Căutare + Search commands Căutare comenzi + Search commands, tools and panels… Căutare comenzi, instrumente și panouri… + Search fonts Căutare fonturi + Select All Selectare tot + Select Subject Selectare subiect + Select a type layer or click into text to insert glyphs Selectați un strat de text sau faceți clic în text pentru a insera glife + Select text to apply a character style Selectați text pentru a aplica un stil de caracter + Select: Selecție: + Selections Selecții + Selective Selectiv + Selective Color Culoare selectivă + Send the selected pin backward Trimitere pin selectat în spate + Set additional smoothing options Setare opțiuni de netezire suplimentare + Set background color Setare culoare de fundal + Set foreground color Setare culoare frontală + Set painting symmetry options Setare opțiuni de simetrie pentru pictură + Symmetry Off Simetrie dezactivată + Set shape fill type Setare tip de umplere a formei + Set shape stroke type Setare tip de contur al formei + Set the text color Setare culoare text + Create warped text Creare text deformat + Shadows Umbre + Shape: Formă: + Sharpening Clarificare + Shift Shift + Shortcut removed. Comandă rapidă eliminată. + Show All Menu Items Afișare toate elementele de meniu + Show Backdrop Afișare fundal + Show Guides Afișare ghidaje + Show Mask Afișare mască + Show Mesh Afișare plasă + Show Overlay Afișare suprapunere + Show Transform Controls Afișare controale de transformare + Show or hide the Notes panel Afișare sau ascundere panou Note + Show: Afișare: + Sides Laturi + Sides: Laturi: + Size Dimensiune + Size Jitter Variație dimensiune + Size: Dimensiune: + Slice Options… Opțiuni felie… + Slices From Guides Felii din ghidaje + Slices are locked (View › Lock Slices) Feliile sunt blocate (Vizualizare › Blocare felii) + Small Caps Capitale mici + Smoothing Netezire + Space After Spațiu după + Space Before Spațiu înainte + Spacing Spațiere + Spell check complete. Verificarea ortografică s-a încheiat. + Split: Divizare: + Star ratio Raport stea + Stop playing/recording Întrerupere redare/înregistrare + Straighten Îndreptare + Straighten Layer Îndreptare strat + Strength: Intensitate: + Strikethrough Tăiat (strikethrough) + Stroke Trasou + Stroke Catch-up Recuperare tusă + Stroke: Trasou: + Location Locație + Location: Locație: + Style Stil + Style Options Opțiuni de stil + Style Options… Opțiuni de stil… + Style: Normal Stil: Normal + Subtract from Sample Scădere din probă + Subtract from selection ({key}) Scădere din selecție ({key}) + Suggestions: Sugestii: + Swap colours (X) Interschimbare culori (X) + Swaps height and width Interschimbă înălțimea și lățimea + Switch between free transform and warp modes Comutare între modurile transformare liberă și deformare + Switch theme Comutare temă + Temperature Temperatură + Tetrahedral Tetraedric + Text Position: Poziție text: + Texture Textură + Texture Each Tip Textură pe fiecare vârf + Threshold Prag + Threshold Level Nivel prag + Thumbnail size (smallest: list) Dimensiune miniatură (cel mai mic: listă) + Tilt X Înclinare X + Tilt Y Înclinare Y + Tint Tentă + Toggle count group visibility Comutare vizibilitate grup de numărare + Toggle text orientation Comutare orientare text + Tolerance Toleranță + Tolerance: Toleranță: + Tone: Ton: + Toolbar Bară de instrumente + Top Sus + Total: Total: + Tracking Spațiere + Tracking (1/1000 em) Spațiere (1/1000 em) + Transparency Transparență + Transparent Transparent + Trilinear Triliniar + Type Styles Stiluri de text + Type: Tip: + Underline Subliniere + Untitled Fără titlu + Untitled-1 Fără titlu-1 + Update layer comp (visibility, position and appearance) Actualizare compoziție de strat (vizibilitate, poziție și aspect) + Use Default Utilizare valoare implicită + Use Legacy Utilizare versiune veche + Use Measurement Scale Utilizare scară de măsurare + Variables bind a layer's visibility, text or pixels to a named data slot. Variabilele leagă vizibilitatea, textul sau pixelii unui strat de un slot de date cu nume. + Vertical scale Scalare verticală + Vibrance Vibranță + View Options Opțiuni de vizualizare + Vignetting Vinietare + Violet, Orange Violet, portocaliu + Visibility Vizibilitate + Visible Vizibil + Warp: Deformare: + Web Snap Fixare web + Weight Grosime + Weight: Grosime: + White Alb + White Balance: As Shot Balans de alb: ca la fotografiere + Whites Albe + Width Lățime + Slant Înclinare + Width: Lățime: + Work Path Traseu de lucru + Yellow Galben + Yellow · Blue Galben · albastru + Zoom Zoom + Zoom in Mărire + Zoom out Micșorare + command or shortcut comandă sau comandă rapidă + current curent + image file path cale fișier imagine + modified modificat + name nume + new nou + {name} options Opțiuni {name} + ↑↓ navigate {key} run esc close ↑↓ navigare {key} executare esc închidere + Version {version} Versiune {version} + New Document Document nou + New Document With Mask Document nou cu mască + New Layer With Mask Strat nou cu mască + New Layer Strat nou + Save Preset… Salvare presetare… + Preset Name Nume presetare + No saved presets yet. Nu există încă presetări salvate. + Error Eroare + Command Comandă + Adjustment layers have nothing to transform Straturile de ajustare nu au nimic de transformat + Altitude Altitudine + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua Aqua + Art & Illustration Artă și ilustrație + ArtCraft ArtCraft + Background Eraser Tool Instrument Radieră de fundal + Bevel & Emboss Relief și teșire + Blend Mode Mod de amestecare + Blues Albaștri + Blur Tool Instrument Neclaritate + Bold Bold (gras) + Brush Pensulă + Block Bloc + Brush Pose Postura pensulei + Brush Tip Shape Forma vârfului pensulei + Brush Tool Instrument Pensulă + Build-up Acumulare + Burn Tool Instrument Întunecare + Centimeters Centimetri + Choke Strângere + Classic Clasic + Click to add text Faceți clic pentru a adăuga text + Click to fill similar colours Faceți clic pentru a umple culori similare + Clone Stamp Tool Instrument Ștampilă de clonare + Color Burn Întunecare culoare + Color Dodge Luminare culoare + Color Dynamics Dinamică culoare + Color Overlay Suprapunere culoare + Comic Book Bandă desenată + Conform Conformare + Content-Aware Fill needs a selection on a pixel layer Umplerea sensibilă la conținut necesită o selecție pe un strat de pixeli + Couldn't place {name}: {error} Nu s-a putut plasa {name}: {error} + Could not transform: the layer is empty Nu s-a putut transforma: stratul este gol + Could not use Perspective Warp: the layer is empty Nu s-a putut folosi Deformarea de perspectivă: stratul este gol + Could not use Puppet Warp: no opaque pixels Nu s-a putut folosi Deformarea marionetă: nu există pixeli opaci + Could not use Puppet Warp: the layer is empty Nu s-a putut folosi Deformarea marionetă: stratul este gol + Crop Tool Instrument Decupare + Crosswise În cruce + Current Tool Instrument curent + Custom Shape Tool Instrument Formă personalizată + Cyans Cianuri + Default PhotoCraft Size Dimensiune implicită PhotoCraft + Delete Workspace Ștergere spațiu de lucru + Diamond Diamant + Dimensions: Dimensiuni: + Direction Direcție + Distance Distanță + Document Dimensions Dimensiuni document + Document Profile Profil document + Document Sizes Dimensiuni document + Dodge Tool Instrument Luminare + Down Jos + Drag to draw a gradient Trageți pentru a desena un degrade + Drag to move the active layer Trageți pentru a deplasa stratul activ + Drag to pan · hold Space with any tool Trageți pentru a deplasa vizualizarea · țineți apăsat Spațiu cu orice instrument + Drop Shadow Umbră paralelă + Dual Brush Pensulă duală + Edge Margine + Efficiency Eficiență + Efficiency: 100% Eficiență: 100% + Ellipse Tool Instrument Elipsă + Elliptical Marquee Tool Instrument Selecție elipsă + Single Row Marquee Tool Instrument Selecție rând unic + Single Column Marquee Tool Instrument Selecție coloană unică + Emboss Relief + Eraser Radieră + Eraser Tool Instrument Radieră + ExtraBold Extra Bold (extra gras) + ExtraLight Extra fină + Eyedropper Tool Instrument Pipetă + Fade Atenuare + Fill Opacity Opacitate umplere + Film & Video Film și video + From De la + General General + GitHub GitHub + Gradient Overlay Suprapunere degrade + Gradient Tool Instrument Degrade + Greens Verzi + Hand Tool Instrument Mână + Hard Dur + Hard Mix Mixaj dur + Healing Brush Tool Instrument Pensulă corectoare + Highlights from: Luminile de la: + History Brush Tool Instrument Pensulă istoric + Horizontal Type Tool Instrument Text orizontal + Horizontal Type Mask Tool Instrument Mască text orizontal + Vertical Type Mask Tool Instrument Mască text vertical + Image Size: Dimensiune imagine: + Inches Inchi + Indexed Indexat + Initial Direction Direcție inițială + Inner Bevel Teșire interioară + Inner Glow Strălucire interioară + Inner Shadow Umbră interioară + Lab Lab + Landscape, 10 x 8 Peisaj, 10 x 8 + Landscape, 6 x 4 Peisaj, 6 x 4 + Landscape, 7 x 5 Peisaj, 7 x 5 + Lasso Lasou + Lasso Tool Instrument Lasou + Layer Count Număr straturi + Layer Knocks Out Drop Shadow Stratul elimină umbra paralelă + Legal Legal + Letter Scrisoare + Light Fină + Line Tool Instrument Linie + Linear Liniar + Linear Burn Întunecare liniară + Linear Height Înălțime liniară + Link with Layer Conectare cu stratul + Lorem Ipsum Lorem Ipsum + Magentas Magenta-uri + Magic Eraser Tool Instrument Radieră magică + Magic Wand Tool Instrument Baghetă magică + Marquee Selecție + Mean: Media: + Measurement Scale Scară de măsurare + Measurement Scale Marker Marcator scară de măsurare + Millimeters Milimetri + Mobile Mobil + Move Tool Instrument Deplasare + Could not use the move tool because the layer is locked. Nu s-a putut folosi instrumentul Deplasare pentru că stratul este blocat. + Could not complete the Free Transform command because the layer is locked. Nu s-a putut finaliza comanda Transformare liberă pentru că stratul este blocat. + Multiply Multiplicare + My Brushes Pensulele mele + Neutrals Neutri + New Workspace Spațiu de lucru nou + Note Tool Instrument Notă + Object Selection Tool Instrument Selecție obiect + Off Dezactivat + Open Deschidere + Orange Portocaliu + Original Ratio Raport original + Out Of Gamut În afara gamei + Outer Bevel Teșire exterioară + Outer Glow Strălucire exterioară + Overlay Suprapunere + Paint Bucket Tool Instrument Găleată de vopsea + Paints from the document's opening state Pictează pe baza stării de la deschiderea documentului + Path Selection Tool Instrument Selecție traseu + Direct Selection Tool Instrument Selecție directă + Add Anchor Point Tool Instrument Adăugare punct de ancoră + Delete Anchor Point Tool Instrument Ștergere punct de ancoră + Click a segment to add an anchor point · {alt}-click an anchor to delete it Faceți clic pe un segment pentru a adăuga un punct de ancoră · {alt}-clic pe un punct de ancoră pentru a-l șterge + Click an anchor point to delete it · {alt}-click a segment to add one Faceți clic pe un punct de ancoră pentru a-l șterge · {alt}-clic pe un segment pentru a adăuga unul + Pattern Overlay Suprapunere model + Pen Pressure Presiune peniță + Pen Tilt Înclinare peniță + Pen Tool Instrument Pană + Convert Point Tool Instrument Conversie punct + Photo Fotografie + PhotoCraft website Site-ul web PhotoCraft + Picas Pica + Pillow Emboss Relief tip pernă + Pixel Replacement Înlocuire pixeli + Pixels Pixeli + Points Puncte + Polygon Tool Instrument Poligon + Polygonal Lasso Tool Instrument Lasou poligonal + Portrait, 4 x 6 Portret, 4 x 6 + Portrait, 5 x 7 Portret, 5 x 7 + PostScript (72 points/inch) PostScript (72 puncte/inch) + Postcard Carte poștală + Poster Poster + Pro (Dark) Pro (întunecat) + Pro (Light) Pro (clar) + Pro (Light Gray) Pro (gri clar) + Pro (Medium Gray) Pro (gri mediu) + Protect Texture Protejare textură + Purple Mov + Quick Mask Mască rapidă + Quick Selection Tool Instrument Selecție rapidă + Radial Radial + Rasterize Layer Rasterizare strat + Rasterize Layers Rasterizare straturi + Ratio Raport + Recent Recent + Rectangle Tool Instrument Rectangul + Rectangular Marquee Tool Instrument Selecție rectangulară + Reds Roșii + Reflected Reflectat + Sample: foreground colour Probă: culoare frontală + Sampled Colors Culori eșantionate + Satin Satin + Save Pixel Aspect Ratio Salvare raport de aspect pixel + Scattering Dispersare + Scratch Sizes Dimensiuni memorie virtuală + Select Data Points Selectare puncte de date + Selection Selecție + SemiBold Semi Bold (semigras) + Shadows up to: Umbre până la: + Shape Dynamics Dinamică formă + Sharpen Tool Instrument Clarificare + Show Extras Options Opțiuni elemente suplimentare + Slice Select Tool Instrument Selecție felie + Slice Tool Instrument Felie + Smart Filters Filtre inteligente + Smudge Tool Instrument Deget + Soft Moale + Soften Înmuiere + Source Sursă + Source: set Sursă: stabilit + Sponge Tool Instrument Burete + Spot Healing Brush Tool Instrument Pensulă corectoare punctuală + Spread Extindere + Square, 12 x 12 Pătrat, 12 x 12 + Square, 5 x 5 Pătrat, 5 x 5 + Std Dev: Deviație std.: + Studio (Dark) Studio (întunecat) + Studio (Light) Studio (clar) + Stylus Wheel Rotiță stylus + Subtract Scădere + Tabloid Tabloid + Text Replacement Înlocuire text + Thin Fin + Traditional (72.27 points/inch) Tradițional (72,27 puncte/inch) + Transfer Transfer + Triangle Tool Instrument Triunghi + Unsaved changes Modificări nesalvate + Uses the foreground and background colours. Utilizează culorile frontală și de fundal. + Web Web + Web Large Web (mare) + Web Minimum Web (minim) + Web Most Common Web (cel mai comun) + WebP (lossless) WebP (fără pierderi) + Wet Edges Margini umede + Yellows Galbene + Zoom Tool Instrument Zoom + {name} Properties Proprietăți {name} + Revert “{name}” to the last saved version? Your changes will be lost. Reveniți „{name}” la ultima versiune salvată? Modificările vor fi pierdute. + Do you want to save the changes you made to “{name}” before quitting? Doriți să salvați modificările făcute la „{name}” înainte de a închide programul? + Do you want to save the changes you made to “{name}” before closing? Doriți să salvați modificările făcute la „{name}” înainte de a închide? + Doc: {flat}/{layered} Doc.: {flat}/{layered} + Scratch: {size} Memorie virtuală: {size} + Untagged {mode} Netichetat {mode} + Invalid {mode} profile Profil {mode} nevalid + Unnamed {mode} profile Profil {mode} fără nume + {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch Rezoluție: {dpi} pixeli/inch + Doc: {w} × {h} px Doc.: {w} × {h} px + Frame {current} / {total} Cadru {current} / {total} + Count: {n} Numărare: {n} + BLANK DOCUMENT PRESETS ({n}) PRESETĂRI DOCUMENT GOL ({n}) + Constraints ({n}) Constrângeri ({n}) + Align {what} Aliniere {what} +@plural {n} Layer|{n} Layers {n} strat|{n} straturi|{n} de straturi +@plural {n} layer|{n} layers {n} strat|{n} straturi|{n} de straturi +@plural Group · {n} layer|Group · {n} layers Grup · {n} strat|Grup · {n} straturi|Grup · {n} de straturi + Additional plugins folder Folder adițional de plugin-uri + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Permitere control de la agenți + Allow scripts to connect Permitere conectare scripturi + Always Întotdeauna + Always create smart objects when placing Creare întotdeauna de obiecte inteligente la plasare + Animated zoom Zoom animat + Apply auto tone Aplicare ton automat + Ask Întrebare + Ask before saving layered tiff Întrebare înainte de a salva TIFF cu straturi + Auto collapse icon panels Restrângere automată panouri de iconițe + Auto show hidden panels Afișare automată panouri ascunse + Auto show home screen Afișare automată ecran de pornire + Autosave Salvare automată + Autosave minutes Minute între salvări automate + Beep when done Semnal sonor la finalizare + Bicubic automatic Bicubică automată + Bicubic sharper Bicubică mai clară + Bicubic smoother Bicubică mai netedă + Bit depth Profunzime biți + Brush preview color Culoare previzualizare pensulă + Cache levels Niveluri cache + Cache tile size Dimensiune mozaic cache + Camera Raw Defaults Valori implicite Camera Raw + Canvas border Margine pânză + Show canvas dots Afișare puncte pânză + Clip thumbnails to layer bounds Decupare miniaturi la limitele stratului + Clip Thumbnails to Layer Bounds Decupare miniaturi la limitele stratului + Canvas color Culoare pânză + Canvas custom color Culoare personalizată pânză + Color picker Selector de culoare + Color space Spațiu de culoare + Column width Lățime coloană + Concise Concis + Control port Port de control + Convert to srgb Conversie la sRGB + Cursors Cursoare + Custom dark Personalizat (întunecat) + Custom light Personalizat (clar) + Dark Întunecat + Dark gray Gri întunecat + Dashed lines Linii întrerupte + Destination Destinație + Detailed Detaliat + Disks Discuri + Display p3 Display P3 + Double click layer mask launches select and mask Dublu clic pe masca de strat lansează Selecție și mască + Drop shadow Umbră paralelă + Dynamic color sliders Glisoare de culoare dinamice + East asian Asia de Est + Effect cache mb Cache efecte (MB) + Enable flick panning Activare derulare prin gest + Enable floating document window docking Acoplare ferestre de document flotante + Enable narrow options bar Bară de opțiuni îngustă + Enabled Activat + Enhanced Controls Controale îmbunătățite + Export clipboard Export clipboard + Extra large Extra mare + File Handling Gestionare fișiere + File path Cale fișier + Fill new type layers with placeholder Umplere straturi de text noi cu text substituent + Font preview Previzualizare font + Full size tip Vârf la dimensiune reală + Gamut warning color Culoare avertisment gamă + Gamut warning opacity Opacitate avertisment gamă + Enable Generator Activare Generator + Gif GIF + Glossy Lucios + Grid color Culoare grilă + Grid colors Culori grilă + Grid size Dimensiune grilă + Grid style Stil grilă + Grid unit Unitate grilă + Gridline every Linie de grilă la fiecare + Guide color Culoare ghidaje + Guide style Stil ghidaje + Guides, Grid & Slices Ghidaje, grilă și felii + Gutter Interstițiu + History Log Jurnal de istoric + History states Stări de istoric + Ignore exif profile tag Ignorare etichetă profil EXIF + Image interpolation Interpolare imagine + Image previews Previzualizări imagine + Integrations Integrări + Interface Interfață + Jpeg quality Calitate JPEG + Jpg JPG + Language Limbă + Large tabs File mari + Legacy compositing Compoziție veche + Light gray Gri clar + Line Linie + Lines Linii + Lossless Fără pierderi + Lowercase extension Extensie cu minuscule + Maximize psd compatibility Maximizare compatibilitate PSD + Medium gray Gri mediu + Memory usage mb Utilizare memorie (MB) + Low resolution previews Previzualizări cu rezoluție scăzută + Missing glyph protection Protecție glife lipsă + Modern Modern + Nearest neighbor Vecinul cel mai apropiat + Never Niciodată + Normal tip Vârf normal + Open as smart object Deschidere ca obiect inteligent + Open in camera raw Deschidere în Camera Raw + Open documents as tabs Deschidere documente ca file + Overscroll Suprascrolare + Performance Performanță + Png PNG + Point size Dimensiune puncte + Precise Precis + Preserve details Conservare detalii + Print resolution Rezoluție imprimare + Pro Pro + Pro light Pro (clar) + Pro light gray Pro (gri clar) + Pro medium Pro (mediu) + Pro photo ProPhoto + Quick export format Format export rapid + Quick export location Locație export rapid + Recent file count Număr fișiere recente + LUT folder Folder LUT + Recent files Fișiere recente + Recent fonts Fonturi recente + Recover on launch Recuperare la pornire + Remember workspace changes Reținere modificări spațiu de lucru + Resize image during place Redimensionare imagine la plasare + Rotate view with trackpad Rotire vizualizare cu trackpad + Same folder Același folder + Save in background Salvare în fundal + Scratch Disks Discuri temporare + Screen Ecran + Screen resolution Rezoluție ecran + Scrubby slider acceleration Accelerare glisor + Sessions only Numai sesiuni + Sharpen for Clarificare pentru + Show channels in color Afișare canale în culoare + Show crosshair in brush tip Afișare reticulă în vârful pensulei + Show extension panels Afișare panouri de extensie + Show font names in english Afișare nume fonturi în engleză + Show menu colors Afișare culori meniu + Show only crosshair while painting Afișare numai reticulă la pictare + Show slice numbers Afișare numere felie + Show tooltips Afișare indicii + Enable animations Activare animații + Show highlight on rollover Afișare evidențiere la survolare + Show bounding box when dragging layer Afișare chenar de delimitare la tragerea stratului + System title bar Bară de titlu a sistemului + Show transformation values Afișare valori transformare + Slice color Culoare felie + Smart guide color Culoare ghidaje inteligente + Smart quotes Ghilimele tipografice + Snap vector tools and transforms to pixel grid Fixare instrumente vectoriale și transformări la grila de pixeli + Srgb sRGB + Standard Standard + Studio Studio + Studio light Studio (clar) + Adwaita Adwaita + Adwaita dark Adwaita (întunecat) + Subdivisions Subdiviziuni + System Sistem + Text engine Motor de text + Text file Fișier text + Tiny Minuscul + Touch gestures Gesturi tactile + Transparency & Gamut Transparență și gamă + Type units Unități text + UI font size Dimensiune font interfață + UI scale Scalare interfață + Units & Rulers Unități și rigle + Use additional plugins folder Utilizare folder adițional de plugin-uri + Use esc to commit Utilizare Esc pentru confirmare + Use gpu Utilizare GPU + Use legacy free transform Utilizare transformare liberă veche + Use shift key for tool switch Utilizare tastă Shift pentru comutare instrument + Vary round brush hardness on hud Variere duritate pensulă rotundă în HUD + Webp WebP + World ready Universal + Zoom clicked point to center Centrare punct apăsat la zoom + Zoom resizes windows Zoom redimensionează ferestrele + Zoom with scroll wheel Zoom cu rotița de derulare + Zoom with trackpad pinch Zoom cu ciupire pe trackpad + Pass Through Transmitere directă + Dissolve Dizolvare + Darker Color Culoare mai întunecată + Linear Dodge (Add) Sobreexpunere liniară (adunare) + Lighter Color Culoare mai clară + Soft Light Lumină slabă + Hard Light Lumină puternică + Vivid Light Lumină vie + Linear Light Lumină liniară + Pin Light Lumină punctuală + Exclusion Exclundere + Divide Împărțire + Luminosity Luminozitate +@plural {n} sample|{n} samples {n} probă|{n} probe|{n} de probe + {added}, {removed} subtracted {added}, {removed} scăzute + Adjustment Preview Previzualizare ajustare + Airbrush: keeps depositing paint while the pointer rests. Aerograf: continuă să depună vopsea cât timp cursorul rămâne nemișcat. + Angle: Unghi: + Blend If Fuziune dacă + Close Tab Group Închidere grup de file + Collapse Restrângere + Collapse All Groups Restrângere toate grupurile + Collapse Panel Group Restrângere grup de panouri + Collapse group ({key}-click: all groups) Restrângere grup ({key}-clic: toate grupurile) + Create new brush from the current settings Creare pensulă nouă din setările curente + Create new brush from these settings Creare pensulă nouă din aceste setări + Update the current brush with these settings Actualizare pensulă curentă cu aceste setări + Delete brush Ștergere pensulă + Drag the arrow to set the angle, the dots to set the roundness Trageți săgeata pentru a fixa unghiul și punctele pentru a fixa rotunjimea + Expand Expandare + Expand Panel Group Expandare grup de panouri + Expand group ({key}-click: all groups) Expandare grup ({key}-clic: toate grupurile) + Fade steps Pași de estompare + Hide the layer's effects ({key}-click: all layers) Ascundere efectele stratului ({key}-clic: toate straturile) + Import Brushes… (.abr or image) Import pensule… (.abr sau imagine) + Import Brush from Image… Import pensulă din imagine… + Move Group Down Deplasare grup jos + Move Group Up Deplasare grup sus + Reset the brush to the defaults Resetare pensulă la valorile implicite + Restore original size Restaurare dimensiune originală + Roundness: Rotunjime: + Search Brushes Căutare pensule + Show the layer's effects ({key}-click: all layers) Afișare efectele stratului ({key}-clic: toate straturile) + Brush picker Selector de pensule + Erase Ștergere + Right click with painting tools Clic dreapta cu instrumente de pictură + System Info… Informații sistem… + System Info Informații sistem + More panels Mai multe panouri + More documents Mai multe documente + These settings aren't available in PhotoCraft yet. Aceste setări nu sunt încă disponibile în PhotoCraft. + Graphics Grafică + Reset GPU Backend Resetare backend GPU + Applies at next launch. Se aplică la următoarea pornire. + Click to sample the foreground colour · {key}-click for background Clic pentru a eșantiona culoarea frontală · {key}-clic pentru fundal + {key}-click to set the source {key}-clic pentru a stabili sursa + {key} to subtract {key} pentru a scădea + Clone source {n} ({key}-click with Clone Stamp to set) Sursă de clonare {n} ({key}-clic cu Ștampila de clonare pentru a stabili) + Source: not set ({key}-click with Clone Stamp) Sursă: nestabilit ({key}-clic cu Ștampila de clonare) + Pixel Layer Strat de pixeli + Type Layer Strat de text + Shape Layer Strat de formă + Group Layer Strat de grup + Fill Layer Strat de umplere + Smart Object Layer Strat de obiect inteligent + Create Warped Text Creare text deformat + Rasterize Smart Object Rasterizare obiect inteligent + Rasterize Fill Content Rasterizare conținut de umplere + Rasterize Layer Style Rasterizare stil de strat + Unlock: picking a preset replaces these settings Deblocare: alegerea unei presetări înlocuiește aceste setări + Lock: keep these settings when picking another preset Blocare: păstrare aceste setări la alegerea altei presetări + Diameter: Diametru: + Hardness: Duritate: + No brush presets Nicio presetare de pensulă + Toggle the Brush Settings panel (F5) Comutare panou Setări pensulă (F5) + Create new brush preset from the current settings Creare presetare de pensulă nouă din setările curente + Brush Settings… (F5): every option of this brush Setări pensulă… (F5): toate opțiunile acestei pensule + Rename Brush… Redenumire pensulă… + Delete Brush Ștergere pensulă + New Brush Preset… Presetare de pensulă nouă… + Capture Brush Size in Preset Capturare dimensiune pensulă în presetare + Include Tool Settings Includere setări instrument + Include Color Includere culoare + Enter a name for the brush preset Introduceți un nume pentru presetarea de pensulă + A brush preset with this name already exists Există deja o presetare de pensulă cu acest nume + Brush Preset Options Opțiuni presetare pensulă + Group name Nume grup + Brush name Nume pensulă + Brush Name Nume pensulă + Brush Stroke Tușă pensulă + Brush Tip Vârf pensulă + Off: the speed of the pointer sets the spacing Dezactivat: viteza cursorului stabilește spațierea + Tilt Scale Scală înclinare + Brush Projection Proiecție pensulă + Apply the pen's tilt and rotation to the tip shape Aplicare înclinarea și rotația peniței la forma vârfului + Mixer Brush Pensulă de mixare + Mixer Brush Tool Instrument Pensulă de mixare + Color Replacement Tool Instrument Înlocuire culoare + Wet Umed + Current Brush Load Încărcare curentă a pensulei + Load Brush Încărcare pensulă + Clean Brush Curățare pensulă + Load the brush after each stroke Încărcare pensula după fiecare tușă + Clean the brush after each stroke Curățare pensula după fiecare tușă + Mix Mixare + Wetness Jitter Variație umiditate + Mix Jitter Variație mixare + Show the layer mask as an overlay Afișare mască de strat ca suprapunere + Toggle visibility Comutare vizibilitate + Unlink the mask from the layer Deconectare mască de strat + Link the mask to the layer Conectare mască la strat + Gradient Degrade + Gradient Options Opțiuni degrade + Align with layer Aliniere cu stratul + Reset Alignment Resetare aliniere + Centre the gradient (offset 0, 0) Centrare degrade (decalaj 0, 0) + Gradient Editor Editor de degrade + Gradient picker Selector de degrade + Save As Preset Salvare ca presetare + The gradient the Gradient tool paints with Degradeul cu care pictează instrumentul Degrade + Adds the current gradient to the Gradients panel Adaugă degradeul curent la panoul Degradeuri + Gradient stops Marcaje de degrade + Select a type layer to edit its character and paragraph settings. Selectați un strat de text pentru a-i edita setările de caracter și paragraf. + Text color Culoare text + Type Options Opțiuni text + Anti-aliasing method Metodă de anticrenelare + Horizontal type Text orizontal + Vertical type Text vertical + Fill path with foreground color Umplere traseu cu culoarea frontală + Stroke path with brush Trasare contur al traseului cu pensula + Load path as a selection Încărcare traseu ca selecție + Make work path from selection Creare traseu de lucru din selecție + Add vector mask Adăugare mască vectorială + Create new path Creare traseu nou + Delete path Ștergere traseu + Delete vector mask Ștergere mască vectorială + CPU (no GPU acceleration) CPU (fără accelerare GPU) + DirectX 12 DirectX 12 + GPU backend Backend GPU + Linux display server Server de afișare Linux + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure Utilizare presiune tabletă + Vulkan Vulkan + Alt-click to define a source point to clone from Alt+clic pentru a defini un punct sursă din care să clonați + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Niciun traseu din care să se creeze o selecție: desenați unul cu instrumentul Pană sau selectați unul în panoul Trasee. + Cancel opening Anulare deschidere + Cancelled opening {name} S-a anulat deschiderea {name} + Damaged Photoshop document Document Photoshop deteriorat + The document “{name}” may be damaged. Documentul „{name}” poate fi deteriorat. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Unele straturi, pixeli sau măști nu au putut fi citite. Doriți să continuați deschiderea lui? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Dacă continuați, salvarea peste original poate elimina permanent datele ilizibile. Salvați o copie pentru a păstra originalul. + The document “{name}” does not have an embedded {mode} profile. Documentul „{name}” nu are un profil {mode} încorporat. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Documentul „{name}” are un profil de culoare încorporat care nu corespunde cu spațiul de lucru {mode} curent. + Working: {working} Spațiu de lucru: {working} + Developing {name} Se procesează {name} + Cancelled {label} S-a anulat {label} + Esc to cancel Esc pentru a anula + Opened {name} S-a deschis {name} + Opening {name} Se deschide {name} + Saved {name} S-a salvat {name} + Saving {name} Se salvează {name} + Saving {percent} Se salvează {percent} + A save to {name} is still running; try again when it finishes O salvare a {name} este încă în curs; încercați din nou când se termină + {label} is writing the file and can no longer be cancelled {label} scrie fișierul și nu mai poate fi anulat + Opening {name}… Se deschide {name}… + Opening… Se deschide… + Reading… Se citește… + Working… Se procesează… + 3 x 3 3 × 3 + 4 x 4 4 × 4 + 5 x 5 5 × 5 + Grid: Grilă: + Color Range Gamă de culori + Adaptive Wide Angle Unghi larg adaptabil + Chalk & Charcoal Cretă și cărbune + Charcoal Cărbune + Chrome Crom + Conté Crayon Creion Conté + Graphic Pen Peniță grafică + Halftone Pattern Model de semitonuri + Note Paper Hârtie de notițe + Photocopy Fotocopie + Plaster Ipsos + Reticulation Reticulare + Stamp Ștampilă + Torn Edges Margini rupte + Water Paper Hârtie umedă + Move Deplasare + Pencil Creion + Clone Stamp Ștampilă de clonare + Dodge Luminare + Burn Întunecare + Quick Selection Selecție rapidă + History Brush Pensulă istoric + Color Replacement Înlocuire culoare + New Group Grup nou + Layer Comp Compoziție de strat + Alpha Alfa + Make Producător + Model Model + F Diafragmă f + Artist Artist + Spot Color Culoare plană + Roman Normal + Bold Italic Bold cursiv + Justification Justificare + Symbols Simboluri + Punctuation Punctuație + Make Work Path Creare traseu de lucru + Make Selection Creare selecție + Fill Path Umplere traseu + Stroke Path Trasare contur al traseului + Delete Path Ștergere traseu + W L + H Î + Ruler Riglă + New Layer Comp Compoziție de strat nouă + Duplicate Layer Comp Duplicare compoziție de strat + Rename Layer Comp Redenumire compoziție de strat + Apply Layer Comp Aplicare compoziție de strat + Update Layer Comp Actualizare compoziție de strat + Method Metodă + Author Autor + Software Software + Pencil Tool Instrument Creion + Pixel Pixel + Adjustment Ajustare + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Trageți (lasou) sau faceți clic pe puncte (poligonal) · {add} adăugare · {sub} scădere + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Trageți un cadru de decupare · trageți în interior pentru a deplasa · marginile redimensionează ({ratio} raport, {centre} centru) · Spațiu deplasează în timpul desenării · săgețile ajustează fin · X schimbă orientarea · {commit} confirmă · Esc anulează + {mode} Color · {bits} bit Culoare {mode} · {bits} biți + Scrubby Zoom Zoom prin tragere + Auto Erase Ștergere automată + Color Adaptation Adaptare culoare + Preserve Transparency Conservare transparență + shortcut comandă rapidă + Saved Salvat + Arc Arc + Arch Arc inferior + Bulge Umflătură + Shell Lower Scoică inferioară + Shell Upper Scoică superioară + Flag Steag + Fish Pește + Wave Val + Rise Ascensiune + Inflate Inflare + Twist Torsiune + Squeeze Strângere + Mask Mască + Channel Canal + Rectangle Rectangul + New Channel Canal nou + Save Selection Salvare selecție + Load Selection Încărcare selecție +@id edit.purge.undo Istoric de anulări + Rendering Mode Mod de randare + Automatic (recommended) Automat (recomandat) + CPU / Compatibility CPU / Compatibilitate + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automat utilizează accelerarea GPU atunci când este disponibilă și trece la randare pe CPU în caz de erori. + Switched to CPU rendering S-a comutat la randare pe CPU + GPU acceleration could not continue. Your documents are unchanged. Accelerarea GPU nu a putut continua. Documentele dvs. sunt neschimbate. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft compune imaginile pe CPU. Fereastra poate utiliza totuși adaptorul grafic. + Retry GPU requires a restart. Save your work first. Reîncercarea GPU necesită o repornire. Salvați mai întâi munca dvs. + Retry GPU tries the graphics processor again now. Reîncercarea GPU încearcă din nou procesorul grafic acum. + GPU acceleration resumed. Accelerarea GPU a fost reluată. + Keep Using CPU Continuare utilizare CPU + Retry GPU Reîncercare GPU + Advanced Avansat + GPU Backend Backend GPU + Details Detalii + Rendering mode Mod de randare + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Lasou de îngheț: trageți pentru a îngheța o zonă, Alt+tragere pentru a o dezgheța (L) + Shadow Clipping Warning (U) Avertisment tăiere umbre (U) + Highlight Clipping Warning (O) Avertisment tăiere lumini (O) + Show Lab Color Readouts Afișare valori culoare Lab + Show Vectorscope Afișare vectoroscop + Show Red at 3 o'clock Afișare roșu la ora 3 + Show Skin Tone Indicator Afișare indicator ton piele + Tone Histogram Histogramă tonală + Show Selected Region Afișare regiune selectată +cameraRaw Light Lumină + Camera Raw Filter ({layer}) Filtru Camera Raw ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights Luminile +cameraRaw Darks Umbrele +cameraRaw Red Roșii +cameraRaw Orange Portocalii +cameraRaw Yellow Galbeni +cameraRaw Green Verzi +cameraRaw Aqua Aguamarine +cameraRaw Blue Albaștri +cameraRaw Purple Violeți +cameraRaw Magenta Magenta +cameraRaw Blending Fuziune +cameraRaw Tint Tentă +cameraRaw Temperature Temperatură +cameraRaw Amount Cantitate + Patch Tool Instrument Plasture + Patch: Plasture: + Lasso around an area, then drag the selection Încercuiți o zonă cu lasoul, apoi trageți selecția + Transform On Drop Transformare la plasare + Scale or rotate the moved area, then press {key} Scalați sau rotiți zona deplasată, apoi apăsați {key} + Now drag the selection onto another area Acum trageți selecția peste o altă zonă + Edit Type Editare text + New Style… Stil nou… + Style {name} saved Stilul {name} a fost salvat + Create new style (from the selected type) Creare stil nou (din textul selectat) + Create new style from the selected layer Creare stil nou din stratul selectat + Make Default Stabilire ca implicit + Reset to Default Resetare la valoarea implicită + Full-resolution preview unavailable Previzualizare la rezoluție completă indisponibilă + No patterns Niciun model + Enable {name} Activare {name} + Hold Alt while drawing for straight segments Țineți Alt în timp ce desenați pentru segmente drepte + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Trageți sau faceți clic pe punctele poligonului · țineți Alt în timpul lasoului pentru segmente drepte · {add} adăugare · {sub} înainte de desenare scade + Extend Extindere + Structure: Structură: + Content-Aware Move Tool Instrument Deplasare cu detecție de conținut + TIFF Options Opțiuni TIFF + Targa Options Opțiuni Targa + JPEG Options Opțiuni JPEG + Image Options Opțiuni imagine + Format Options Opțiuni format + Baseline ("Standard") Linie de bază („Standard”) + Baseline Optimized Linie de bază optimizată + 24 bits/pixel 24 biți/pixel + 32 bits/pixel 32 biți/pixel + Including layers will increase file size. Includerea straturilor va crește dimensiunea fișierului. + Discard Layers and Save a Copy Eliminare straturi și salvare copie + Don't show again Nu afișa din nou + Save failed: {error} Salvare nereușită: {error} + Save cancelled Salvare anulată + Magnetic Lasso Tool Instrument Lasou magnetic + Frequency Frecvență + Use tablet pressure to change pen width Utilizare presiune tabletă pentru a modifica lățimea peniței + About Despre + Contributors Contribuitori + Models Modele + Sort Sortare + Username Nume de utilizator + Display name Nume afișat + Real name Nume real + Reverse the order Inversare ordine + Grab bag Listă liberă + Table Tabel + Name (A–Z) Nume (A–Z) + Merged PRs PR-uri fuzionate + Commits Commit-uri + Lines added Linii adăugate + Lines deleted Linii șterse + Line delta Diferență linii + Binary assets added Fișiere binare adăugate + Binary assets removed Fișiere binare eliminate + First commit Primul commit + First Primul + Last commit Ultimul commit + Last Ultimul + Contributors: {contributors} · Commits: {commits} Contribuitori: {contributors} · Commit-uri: {commits} + No contributor data was built into this copy. Această copie nu include date despre contribuitori. + No model credits were built into this copy. Această copie nu include credite de modele. + Company Companie + Version Versiune + % of all commits % din toate commit-urile + Lines +/− Linii +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit-uri, +{added} / −{deleted} linii (Δ {delta}), +{binAdded} / −{binDeleted} fișiere binare, {first} – {last} + Create new swatch Creare mostră nouă + New Swatch… Mostră nouă… + New Swatch Group… Grup de mostre nou… + Import Swatches… Import mostre… + Replace Swatches… Înlocuire mostre… + Export Swatches… Export mostre… + Export Swatches for Exchange… Export mostre pentru schimb… + Reset Swatches Resetare mostre + Add to Swatches Adăugare la mostre + Blend If: Fuzionare dacă: + Current Layer: Strat curent: + Channels: Canale: + Knockout Extracție + Apply At Canvas Bounds Aplicare la limitele pânzei + Color Picker (Text Color) Selector de culoare (culoare text) + Color Picker ({name}) Selector de culoare ({name}) + Color Picker (Fill Color) Selector de culoare (culoare umplere) + Fill color Culoare umplere + Color Picker (Stroke Color) Selector de culoare (culoare trasou) + Stroke color Culoare trasou + Pick screen color Alegere culoare de pe ecran + Sample a pixel anywhere on the screen Eșantionați un pixel oriunde pe ecran + Click to pick · Esc to cancel Clic pentru a alege · Esc pentru a anula + Pick a screen pixel or press Esc to cancel Alegeți un pixel de pe ecran sau apăsați Esc pentru a anula + Scroll to zoom Derulați pentru a mări + Drag to pan · Scroll to zoom · Double-click to fit Trageți pentru a deplasa · Derulați pentru a mări · Dublu clic pentru a încadra + Show/Hide All Panels Afișare/ascundere toate panourile + Show/Hide Panels Afișare/ascundere panouri + Could not open Nu s-a putut deschide + {path}: the path is not valid Unicode; rename the file and open it again. {path}: calea nu este Unicode valid; redenumiți fișierul și deschideți-l din nou. + Contents: Conținut: + Custom Pattern: Model personalizat: + Foreground Color Culoare frontală + Color… Culoare… + 50% Gray Gri 50% + {size} (was {old}) {size} (anterior {old}) + Cone Con + Cone (Inverted) Con (inversat) + Domed Bombat + Domed (Inverted) Bombat (inversat) + Diagonal (Descending) Diagonală (descrescătoare) + Contour Editor Editor contur + Anti-aliased Anticrenelat + Corner Colț + Gloss Contour Contur luciu + Contour Contur + Cone - Inverted Con - inversat + Cove - Deep Golf - adânc + Cove - Shallow Golf - puțin adânc + Half Round Semirotund + Ring Inel + Ring - Double Inel - dublu + Rolling Slope - Descending Pantă ondulată - descrescătoare + Rounded Steps Trepte rotunjite + Sawtooth 1 Dinte de fierăstrău 1 + Red Eye Tool Instrument Ochi roșii + Red Eye Ochi roșii + Pupil Size Dimensiune pupilă + Darken Amount Cantitate întunecare + Click a red pupil to neutralize it Faceți clic pe o pupilă roșie pentru a o neutraliza + open source open source + …and {n} more …și încă {n} + Eyedroppers: Pipete: + Set Black Point Stabilire punct negru + Set Neutral Gray Point Stabilire punct gri neutru + Set White Point Stabilire punct alb + Unavailable in this color mode Indisponibil în acest mod de culoare + Selection Preview: Previzualizare selecție: + Black Matte Mat negru + White Matte Mat alb + Seafoam Verde spumă de mare + Indigo Indigo + Fuchsia Fucsia + Violet Violet + Reset Resetare + Large document: redrawing on the CPU Document mare: redesenare pe CPU + Redrawing Redesenare + This redraw took {seconds} s on the CPU. Această redesenare a durat {seconds} s pe CPU. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Editările care schimbă tot documentul, precum modul de amestecare, opacitatea sau vizibilitatea unui strat, îl redesenează integral. + The GPU compositor wasn't used: {reason} Compozitorul GPU nu a fost folosit: {reason} +fontWeight Thin Ultrafină +fontWeight ExtraLight Extrafină +fontWeight Light Fină +fontWeight Regular Normal +fontWeight Medium Medie +fontWeight SemiBold Semi Bold +fontWeight Bold Bold +fontWeight ExtraBold Extra Bold +fontWeight Black Ultra Bold +fontWeight Italic Cursivă + Pattern Stamp Tool Instrument Ștampilă de model + Pattern Stamp Ștampilă de model + Impressionist Impresionist + Rotate View Rotire vizualizare + Rotate View Tool Instrument Rotire vizualizare + Reset View Resetare vizualizare + Drag around the centre to rotate the view · Shift constrains to 15° Trageți în jurul centrului pentru a roti vizualizarea · Shift fixează la 15° + Dual Axis Dublă axă + Diagonal Diagonal + Transform Symmetry Transformare simetrie + Point Sample Eșantion punctual + 3 by 3 Average Medie 3 × 3 + 5 by 5 Average Medie 5 × 5 + 11 by 11 Average Medie 11 × 11 + 31 by 31 Average Medie 31 × 31 + 51 by 51 Average Medie 51 × 51 + 101 by 101 Average Medie 101 × 101 + Sample Size: Dimensiune eșantion: + All Layers No Adjustments Toate straturile fără ajustări + Current & Below No Adjustments Curent și inferioare fără ajustări + Show Sampling Ring Afișare inel de eșantionare + Out of gamut for printing: click for the nearest printable colour În afara gamei de imprimare: faceți clic pentru cea mai apropiată culoare imprimabilă + Not a web colour: click for the nearest web colour Nu este o culoare web: faceți clic pentru cea mai apropiată culoare web + Color to Alpha… Culoare la alfa… + Transparency Threshold Prag transparență + Opacity Threshold Prag opacitate + PhotoCraft — Screen Color Picker PhotoCraft — Selector de culoare de pe ecran + Rasterize svg groups deeper than Rasterizare grupuri SVG mai profunde decât + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 dezactivează acest prag; grupurile SVG imbricate la mai mult de 100 de niveluri sunt întotdeauna rasterizate. + Rule of Thirds Regula terților + Triangle Triunghi + Golden Ratio Raport auriu + Golden Spiral Spirală aurie + Auto Show Overlay Afișare automată suprapunere + Always Show Overlay Afișare întotdeauna suprapunere + Never Show Overlay Nu afișa niciodată suprapunerea + Cycle Overlay Comutare suprapunere + Cycle Orientation Comutare orientare + Set the overlay options for the Crop tool Definire opțiuni de suprapunere pentru instrumentul Decupare + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+clic a fost primit de desktop, care deplasează ferestrele cu Alt+tragere. Setați tasta de deplasare a ferestrelor la Super, sau blocați Alt în Fereastră › Taste modificatoare. + Color Picker (Layer Style Color) Selector de culoare (culoare stil de strat) + Auto Hide Notifications Ascundere automată notificări + Notification Duration (seconds) Durată notificare (secunde) + Bundle Arrow-Key Nudges Grupare ajustări fine cu săgețile + Nudge Bundle Pause (ms) Pauză grupare ajustări fine (ms) + Appearance Mode Mod aspect + Dark Theme Temă întunecată + Light Theme Temă clară + Pro Medium Pro (mediu) + Studio Light Studio (clar) + Next Appearance Mode Modul următor de aspect + Sync with system Sincronizare cu sistemul + Active Activ + Color Picker (Solid Color) Selector de culoare (culoare solidă) + Straighten the image by drawing a line on it Îndreptare imagine desenând o linie pe ea + Set additional Crop options Definire opțiuni adiționale de decupare + Show Cropped Area Afișare zonă decupată + Enable Crop Shield Activare ecran de decupare + Match Canvas Potrivire cu pânza + Auto Adjust Opacity Ajustare automată opacitate + Crop Shield color Culoare ecran de decupare + Color Picker (Crop Shield Color) Selector de culoare (culoare ecran de decupare) + From Center Din centru + Link radii Conectare raze + Star Ratio: Raport stea: + Create Rectangle Creare rectangul + Create Ellipse Creare elipsă + Create Triangle Creare triunghi + Create Polygon Creare poligon + Pressure curve Curbă de presiune + Click to add a point, right-click to remove Faceți clic pentru a adăuga un punct; clic dreapta pentru a-l elimina + Some brush presets could not be loaded Unele presetări de pensulă nu au putut fi încărcate + Retry saving presets Reîncercare salvare presetări + Brush preset changes are not saved: {error} Modificările presetărilor de pensulă nu sunt salvate: {error} + Keep this tab open to retain your brushes. Păstrați această filă deschisă pentru a vă conserva pensulele. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Stocarea browserului nu este disponibilă; pensulele durează numai pentru această sesiune. Păstrați această filă deschisă pentru a le conserva. + Saving brush presets… Keep this tab open until saving finishes. Se salvează presetările de pensulă… Păstrați această filă deschisă până la finalizare. + Reflect Reflectare + Glow Strălucire + Negation Negație + XOR XOR + Color Burn (Paint.NET) Întunecare culoare (Paint.NET) + Color Dodge (Paint.NET) Luminare culoare (Paint.NET) + Stroke Options Opțiuni trasou + Solid Continuu + Dashed Întrerupt + Dotted Punctat + Alignment Aliniere + Caps Capete + Butt / Flat Plat + Corners Colțuri + Miter Unghi ascuțit + Round Rotund + Bevel Teșit + Miter limit Limită unghi ascuțit + Dashed line Linie întreruptă + Dash Linie + Gap Spațiu + Dash / gap lengths × stroke width Lungimi linie / spațiu × lățime trasou + Add pair Adăugare pereche + Remove pair Eliminare pereche + Dash offset Decalaj liniuțe + Open paths use centered strokes Traseele deschise folosesc trasee centrate + Use finite lengths and a positive pattern total Utilizați lungimi finite și un total de model pozitiv + No subject found — try Object Selection or the Lasso Niciun subiect găsit — încercați Selecție obiect sau Lasoul + Quick Selection found nothing there Selecția rapidă nu a găsit nimic acolo + Object Selection found nothing there — try a tighter rectangle Selecția obiectului nu a găsit nimic acolo — încercați un rectangul mai restrâns + No subject found — try a rectangle around the object Niciun subiect găsit — încercați un rectangul în jurul obiectului + Click the first point or press {key} to close · {del} removes a point · Esc cancels Faceți clic pe primul punct sau apăsați {key} pentru a încheia · {del} elimină un punct · Esc anulează + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Faceți clic, apoi mișcați de-a lungul unei margini · faceți clic pentru a fixa un punct · {alt}-clic pentru un segment drept · dublu clic pentru a încheia + Follows only edges this close to the pointer ([ and ] change it) Urmărește numai marginile atât de apropiate de cursor ([ și ] îl modifică) + Higher values follow only edges that contrast sharply with their surroundings Valorile mai mari urmăresc numai marginile cu contrast puternic față de mediul lor + How often fastening points are placed by themselves Cât de des se plasează automat punctele de fixare + Appearance Settings… Setări de aspect… + Add to favorites Adăugare la favorite + Remove from favorites Eliminare din favorite + Camera Data Date cameră +cameraData Make Producător +cameraData Model Model +cameraData Date Taken Data capturii +cameraData Exposure Timp de expunere +cameraData F-Stop Diafragmă f +cameraData ISO Speed Sensibilitate ISO +cameraData Focal Length Distanță focală +cameraData Focal Length (35 mm) Distanță focală (35 mm) +cameraData Lens Obiectiv + Activate Panel Activare panou + Change Panel Group Modificare grup de panouri + Change Panel Layout Modificare aranjament panouri + Close Panel Închidere panou + Dock Panel Acoplare panou + Float Panel Panou flotant + Move Panel Deplasare panou + Group with Grupare cu + Panel menu Meniu panou + Resize panel window Redimensionare fereastră panou + Resize panels Redimensionare panouri + Distribution Distribuție + Uniform Uniform + Gaussian Gaussian + Monochromatic Monocromatic + Seed Sămânță + Strength Intensitate + Reduce Color Noise Reducere zgomot de culoare + Remove Jpeg Artifact Eliminare artefacte JPEG + Sharpen Details Clarificare detalii + Reduce Noise Reducere zgomot + Use Classic Mode Utilizare mod clasic + Auto Center Preview Centrare automată previzualizare + Next Document Documentul următor + Previous Document Documentul anterior + W x H x Resolution L x Î x Rezoluție + Front Image Imagine frontală + Frame Tool Instrument Cadru + Create a new rectangular frame Creare cadru rectangular nou + Create a new elliptical frame Creare cadru elipsoidal nou + Include in playback Includere în redare + Perspective Crop Tool Instrument Decupare cu perspectivă + Show Grid Afișare grilă + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Trageți un cadru sau faceți clic pe patru colțuri · trageți colțurile pe marginile unei forme înclinate · {commit} confirmă · Esc anulează + Artboard Tool Instrument Planșă de lucru + Make Portrait Transformare în portret + Make Landscape Transformare în peisaj + Add New Artboard Adăugare planșă de lucru nouă + Freeform Pen Tool Instrument Pană formă liberă + Curvature Pen Tool Instrument Pană curbură + Curve Fit: Potrivire curbă: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Tragere: desenare la liberă mână · {alt}-clic: segment drept · Eliberare pe punctul de start: încheiere + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Clic: punct neted · Dublu clic: colț · Tragere punct: deplasare · Clic pe primul punct: încheiere · {key}: finalizare + New Crop Preset… Presetare de decupare nouă… + Delete Crop Preset… Ștergere presetare de decupare… + Select the crop presets to delete: Selectați presetările de decupare de șters: + Select the crop presets to delete Selectați presetările de decupare de șters + Enter a name for the crop preset Introduceți un nume pentru presetarea de decupare + There is no crop size or ratio to save Nu există dimensiune sau raport de decupare de salvat + Scale: Scală: + Snap to Origin Fixare la origine + Search settings… Căutare setări… + No matching settings Nicio setare corespunzătoare + Sampling Brush Tool Instrument Pensulă de eșantionare + Sampling Area Overlay Suprapunere zonă de eșantionare + Show Sampling Area Afișare zonă de eșantionare + Indicates Indică + Sampling Area Zonă de eșantionare + Excluded Area Zonă exclusă + Sampling Area Options Opțiuni zonă de eșantionare + Rectangular Rectangular + Fill Settings Setări umplere + Rotation Adaptation Adaptare rotație + Very High Foarte mare + Full Completă + Mirror Oglindă + Output Settings Setări de ieșire + Output To Ieșire către + Animation Options Opțiuni animație + Entire Document Document întreg + Filter Options Opțiuni filtru + Layer Bounds Limite strat + Layers Panel Options Opțiuni panou Straturi + Lock Layers Blocare straturi + New Artboard… Planșă de lucru nouă… + New Group… Grup nou… + New Layer… Strat nou… + Panel Options… Opțiuni panou… + Prevent Auto-Nesting Prevenire cuibărire automată + Show Layers Afișare straturi + Thumbnail Contents Conținut miniaturi + Thumbnail Size Dimensiune miniaturi + Use Default Masks on Fill Layers Utilizare măști implicite pe straturile de umplere + Expand New Effects Expandare efecte noi + Add “copy” to Copied Layers and Groups Adăugare „copie” la straturile și grupurile duplicate + Preview on canvas when reordering layers Previzualizare pe pânză la reordonarea straturilor + Show layer mask badges Afișare insigne de mască de strat + Blending Options ({filter}) Opțiuni de fuziune ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ascundere efectele și filtrele inteligente ale stratului ({key}-clic: toate straturile) + Show the layer's effects and Smart Filters ({key}-click: all layers) Afișare efectele și filtrele inteligente ale stratului ({key}-clic: toate straturile) + Wait for the current job to finish. Așteptați finalizarea sarcinii curente. + The document or Smart Filter changed. Reopen its dialog. Documentul sau filtrul inteligent s-a modificat. Deschideți din nou dialogul acestuia. + Opacity must be between 0% and 100%. Opacitatea trebuie să fie între 0% și 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dizolvarea este aproximată: fuzionează fără tramarea din Photoshop. + Loading fonts… Se încarcă fonturile… + Response curve Curbă de răspuns + Tool Instrument + Simulate Pressure Simulare presiune + Stroke Path… Trasare contur al traseului… + Background Eraser Radieră de fundal + Healing Brush Pensulă corectoare + Spot Healing Brush Pensulă corectoare punctuală + Smudge Deget + Sponge Burete + Amount: Cantitate: + Protect: Protejare: + Protect skin tones Protejare tonuri de piele + Edit points to modify the curve Editare puncte pentru a modifica curba + Draw to modify the curve Desenare pentru a modifica curba +curves Smooth Netezire + Smooth the curve values Netezire valorile curbei + Drag to draw the curve · {key}-click draws a straight line Trageți pentru a desena curba · {key}-clic trasează o linie dreaptă + Edit Plane Editare plan + Create Plane Creare plan + Grid Size: Dimensiune grilă: + Vanishing Point: click four corners to create a plane Punct de fugă: faceți clic pe patru colțuri pentru a crea un plan + Vanishing Point is not active Punctul de fugă nu este activ + Apply Vanishing Point ({key}) Aplicare punct de fugă ({key}) + Fix the red plane first: its corners must form a convex quad Corectați mai întâi planul roșu: colțurile lui trebuie să formeze un patrulater convex + Alt-click a plane to set the clone source Alt+clic pe un plan pentru a stabili sursa de clonare + Too many clone strokes: apply Vanishing Point first Prea multe tușe de clonare: aplicați mai întâi Punctul de fugă + Onion Skin Hârtie de ceapă + Marching Ants Furnici în mișcare + On Black Pe negru + On White Pe alb + On Layers Pe straturi + Show Edge Afișare margine + Show Original Afișare original + F cycles the views; hold X to see the image F comută vizualizările; țineți X pentru a vedea imaginea + Refine Edge Brush (R) Pensulă de perfecționare margine (R) + Expand Detection Area Expandare zonă de detecție + Restore Original Edge Restaurare margine originală + Paint over hair or fur on the image; Alt paints the other mode Pictați peste părul sau blana din imagine; Alt pictează în modul opus + Clear Brush Strokes Ștergere tușe de pensulă + Show rulers Afișare rigle + Presentation Prezentare + Click to place · Alt-click or drag off-canvas to remove Clic pentru a plasa · Alt+clic sau tragere în afara pânzei pentru a elimina + No document open Niciun document deschis + {n} of {max} sample points {n} din {max} puncte de eșantionare + New Snapshot… Captură nouă… + History Options… Opțiuni istoric… + Allow Non-Linear History Permitere istoric neliniar + Make Layer Visibility Changes Undoable Permitere anulare pentru modificările de vizibilitate a straturilor + Emulsion Down Emulsie în jos + Functions Funcții + Print Resolution: Rezoluție imprimare: + Use Previous Layer to Create Clipping Mask Utilizare strat anterior pentru a crea masca de tăiere + Fill with {mode}-neutral color (white) Umplere cu culoarea neutră {mode} (alb) + Fill with {mode}-neutral color (black) Umplere cu culoarea neutră {mode} (negru) + Fill with {mode}-neutral color (50% gray) Umplere cu culoarea neutră {mode} (gri 50%) + (No neutral color exists for {mode} mode.) (Nu există o culoare neutră pentru modul {mode}.) + Apply mask to layer before removing? Aplicați masca pe strat înainte de a o elimina? + The layer or its mask changed. Drag the mask onto the Trash again. Stratul sau masca sa s-au modificat. Trageți din nou masca în Coș de gunoi. + Delete the vector mask? Ștergeți masca vectorială? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} S-a exportat {n} mostră în {file}|S-au exportat {n} mostre în {file}|S-au exportat {n} de mostre în {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} S-a importat {n} pensulă din {group}|S-au importat {n} pensule din {group}|S-au importat {n} de pensule din {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} S-a importat {n} mostră din {file}|S-au importat {n} mostre din {file}|S-au importat {n} de mostre din {file} +@plural Played {n} step|Played {n} steps S-a redat {n} pas|S-au redat {n} pași|S-au redat {n} de pași +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk S-a actualizat {n} obiect inteligent conectat modificat pe disc|S-au actualizat {n} obiecte inteligente conectate modificate pe disc|S-au actualizat {n} de obiecte inteligente conectate modificate pe disc +@plural Saved for Web: {n} file|Saved for Web: {n} files Salvat pentru web: {n} fișier|Salvat pentru web: {n} fișiere|Salvat pentru web: {n} de fișiere +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} strat înregistrat de această compoziție a fost șters. Actualizați compoziția sau ștergeți avertismentul.|{n} straturi înregistrate de această compoziție au fost șterse. Actualizați compoziția sau ștergeți avertismentul.|{n} de straturi înregistrate de această compoziție au fost șterse. Actualizați compoziția sau ștergeți avertismentul. +@plural {n} plane|{n} planes {n} plan|{n} planuri|{n} de planuri +@plural {n} preset|{n} presets {n} presetare|{n} presetări|{n} de presetări +@plural {n} sample point removed|{n} sample points removed S-a eliminat {n} punct de eșantionare|S-au eliminat {n} puncte de eșantionare|S-au eliminat {n} de puncte de eșantionare +@plural {n} slice|{n} slices {n} felie|{n} felii|{n} de felii +@plural {n} step|{n} steps {n} pas|{n} pași|{n} de pași +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} avertisment suplimentar de presetare de pensulă)|{warning} (+{n} avertismente suplimentare de presetare de pensulă)|{warning} (+{n} de avertismente suplimentare de presetare de pensulă) +@plural {what} with {n} warning|{what} with {n} warnings {what} cu {n} avertisment|{what} cu {n} avertismente|{what} cu {n} de avertismente + Light Blue Albastru deschis + Light Red Roșu deschis + Medium Blue Albastru mediu + Light Gray Gri deschis + Object Finder Căutător de obiecte + No object found there — drag a rectangle around it Niciun obiect găsit acolo — trageți un rectangul în jurul lui + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Stratul activ nu are pixeli aici — activați Eșantionare toate straturile sau selectați un strat cu pixeli + Remove after each stroke Eliminare după fiecare tușă + Apply the removal ({key}) Aplicare eliminare ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Tușe de eliminat: {n} (Enter aplică, Esc anulează) + Load Contours… Încărcare contururi… + Replace Contours… Înlocuire contururi… + Save Contours… Salvare contururi… + Delete Contour Ștergere contur + Loaded contours from {file} S-au încărcat contururile din {file} + Saved contours to {file} S-au salvat contururile în {file} +@id select.all Selectare tot + Delete Smart Filters Ștergere filtre inteligente + Enable Smart Filters Activare filtre inteligente + Delete Layer… Ștergere strat… + Select Similar Colors Selectare culori similare + Canvas rotation Rotație pânză + Extensions Extensii + Libraries Biblioteci + 3D 3D + Toolbars Bare de instrumente + Wand Baghetă + Healing Corector + Pen Pană + Hand Mână + Sampler Eșantionator + Magnetic Lasso Lasou magnetic + Art History Brush Pensulă istoric artistic + Spot Healing Corector punctual + Patch Plasture + Content-Aware Move Deplasare cu detecție de conținut + Perspective Crop Decupare cu perspectivă + Dodge/Burn Luminare/Întunecare + Dynamics Dinamică + Tilt Înclinare + Defaults restored. Valorile implicite au fost restaurate. + Stop Stop + Create new set Creare set nou + Technology Previews Previzualizări de tehnologie + Create new layer Creare strat nou + Group · {n} layer Grup · {n} strat + General Blend Fuziune generală + Advanced Blending Fuziune avansată + RGB RGB + Spot Channel Canal de culoare plană + Working Gray Plate Placă gri de lucru + HSL/Grayscale HSL/Tonuri de gri + Tone Curve Curbă de tonuri + Split Toning Divizare de tonuri + Lens Corrections Corecții obiectiv + Camera Calibration Calibrare cameră + Snapshots Instantanee + Recovery Recuperare + Fill Light Lumină de umplere + Convert to Grayscale Conversie în tonuri de gri + Sharpness Claritate + Color Noise Reduction Reducere zgomot de culoare + Luminance Smoothness Netezime luminanță + Lens Obiectiv + ISO ISO + Focal Length35mm Distanță focală echivalentă (35 mm) + Aspect Ratio Raport de aspect + Time Oră + Enter description… Introduceți o descriere… + ColorSync ColorSync + ProPhoto ProPhoto + ACES ACES + Industry Standard Standard industrial + Uncoated CMYK CMYK pentru hârtie nemăsluită + Newspaper Hârtie de ziar + Working Spaces Spații de lucru + Color Management Policies Politici de gestionare a culorii + Detect Faces Detectare fețe + Localize Color Clusters Localizare grupuri de culoare + Duplicated selection name Nume de selecție duplicat + Operation Operație + New channel only Numai canal nou + Color indicates Culoarea indică + Create Snapshot Creare captură + Delete Snapshot Ștergere captură + New Snapshot Captură nouă + Snapshot 1 Captură 1 + Step Forward Avansare un pas + Step Backward Retragere un pas + Nonlinear History Istoric neliniar + Set New Snapshot as Default Stabilire captură nouă ca implicită + Automatically Create First Snapshot Creare automată a primei capturi + Automatically Create New Snapshot When Saving Creare automată a unei capturi noi la salvare + Show New Snapshot Dialog by Default Afișare implicită a dialogului Captură nouă + Clear History Ștergere istoric + View menu Meniu Vizualizare + Keep view in sync Menținere vizualizare sincronizată + {zoom}% {zoom}% + Mouse Controls Controale mouse + Initial Direction of Stroke Direcție inițială a tușei + Direction of Stroke Direcție tușă + Humidity Umiditate + Auto Clean Brush Curățare automată pensulă + Spacing: Spațiere: + New Layer from Brush Strat nou din pensulă + New Brush Pensulă nouă + New Tool Preset Presetare de instrument nouă + Auto-Leading Interlinie automată + Scale Horizontally Scalare orizontală + Scale Vertically Scalare verticală + Mac LCD LCD Mac + Mac Sharp Clar Mac + Mac Crisp Definit Mac + Mac Strong Puternic Mac + Mac Smooth Suavizat Mac + Superscript Exponent superior + Subscript Exponent inferior + Ligature Ligatură + Alternative Alternativă + Ultra Light Ultra fină + Extra Light Extra fină + Semi Bold Semi Bold + Extra Bold Extra Bold + Ultra Bold Ultra Bold + Condensed Condensată + First Line Indent Indentare primă linie + Hanging Punctuation Punctuație suspendată + Roman Hanging Punctuation Punctuație latină suspendată + Avoid Widows/Orphans Evitare linii văduve/orfane + Compose Compunere + Adobe Single-line Composer Compozitor Adobe cu o linie + Adobe Every-line Composer Compozitor Adobe multilinie + Word Spacing Spațiu între cuvinte + Letter Spacing Spațiu între caractere + Glyph Scaling Scalare glife + Auto-leading % Interlinie automată % + Single Word Justification Justificare pentru un singur cuvânt + Justify Justificare + Glyph Glif + Alternates for Current Selection Alternative pentru selecția curentă + Digits Cifre + Path: Traseu: + Path to Illustrator… Traseu către Illustrator… + Clipping Path Traseu de tăiere + New Path Traseu nou + Duplicate Path Duplicare traseu + Rename Path… Redenumire traseu… + Auto-Hide Ascundere automată + X X + Y Y + Frame Sync Sincronizare cadre + Quick Sync Sincronizare rapidă + Audio Audio + Frame Duration Durată cadru + Loop Buclă + Split at Playhead Divizare la capul de redare + Flatten Frames Into Clips Aplatizare cadre în clipuri + Flatten Frames Into Layers Aplatizare cadre în straturi + Convert Frames > Video Layers Conversie cadre > Straturi video + New Video Layer Strat video nou + New Video Group Grup video nou + Limit Playback Limitare redare + Enable Timeline Quick Config Activare configurare rapidă a cronologiei + Set Start Timecode… Stabilire cod de timp inițial… + Timecode Cod de timp + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Timp curent + Out Ieșire + In Intrare + Duration Durată + Repeat Repetare + Play Once Redare o dată + Info Options… Opțiuni informații… + First Color Readout Prima lectură de culoare + Second Color Readout A doua lectură de culoare + Actual Color Culoare reală + Grayscale Slider Regulator tonuri de gri + Web Color Culoare web + HSB Color Culoare HSB + Total Ink Cerneală totală + Opacity Mask Mască de opacitate + Document Size Dimensiune document + Timing Temporizare + Status Information Informații de stare + Saved Multichannel Multicanal salvat + Mouse Coordinates Coordonate mouse + Ruler Units Unități riglă + Hide Ascundere + Restore last viewed comp Restaurare ultima compoziție vizualizată + Select All Layers Selectare toate straturile + Keep proportions Păstrare proporții + Interpolation Interpolare + Caption Legendă + Caption Credits Credite legendă + Keywords Cuvinte cheie + Copyright Info URL URL informații drepturi de autor + Copyright Status Stare drepturi de autor + Copyright Notice Mențiune drepturi de autor + Document Title Titlu document + Copyrighted Cu drepturi de autor + Public Domain Domeniu public + Unknown Necunoscut + Credit Credit + Transmission Reference Referință transmisie + Urgency Urgență + High Ridicată + Low Scăzută + Category Categorie + Supplemental Categories Categorii suplimentare + Object Name / Title Nume obiect / titlu + Date Created Data creării + City Oraș + Province / State Provincie / stat + Country Țară + Byline Autor + Byline Title Funcția autorului + Phone Telefon + Email E-mail + URLs URL-uri + Last Modified Ultima modificare + EXIF EXIF + GPS GPS + Camera Model Model cameră + Camera Make Producător cameră + EXIF Version Versiune EXIF + Exposure Program Program de expunere + Aperture Diafragmă + Shutter Speed Viteză obturator + Metering Mode Mod de măsurare + Light Source Sursă de lumină + Flash Blitz + Lens Info Informații obiectiv + ISO Speed Sensibilitate ISO + Subject Distance Distanță subiect + DateTime Original Dată și oră originale + Create Date Dată creare + Modify Date Dată modificare + Raw Data Date brute + Import… Import… + PDF Presentation… Prezentare PDF… + Create Multiple Picture Packages Creare mai multe seturi de imagini + Picture Package… Set de imagini… + Web Photo Gallery… Galerie foto web… + Choose… Alegere… + Select Folder… Selectare folder… + Override Action "Save" Commands Omitere comenzi „Salvare” ale acțiunii + File Naming Denumire fișiere + Compatibility Compatibilitate + Mac OS Mac OS + Unix Unix + Include ICC Profile Includere profil ICC + Error File Fișier de erori + Stop For Errors Întrerupere la erori + Log Errors To File Înregistrare erori într-un fișier + Errors Erori + Settings Setări + Settings: Setări: + Working Space Spațiu de lucru + RGB: RGB: + CMYK: CMYK: + Gray: Gri: + Spot: Plană: + Advanced Controls Controale avansate + Desaturate Monitor Colors By Desaturare culori monitor cu + Blend RGB Colors Using Gamma 1.0 Fuziune culori RGB folosind gamma 1,0 + Profile Mismatches: Ask When Opening Diferențe de profil: întrebare la deschidere + Ask When Pasting Întrebare la inserare + Missing Profiles: Ask When Opening Profiluri lipsă: întrebare la deschidere + Embed profiles Încorporare profiluri + Ask Before Saving Saved Embedded Întrebare înainte de a salva profilul încorporat + Coerce to sRGB Forțare sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB RGB cu gamă largă + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Minimalistic Minimalist + Expand All Groups Expandare toate grupurile + Photo (2 × 3 in) Foto (2 × 3 in.) + Photo (4 × 6 in) Foto (4 × 6 in.) + Photo (5 × 7 in) Foto (5 × 7 in.) + Photo (8 × 10 in) Foto (8 × 10 in.) + Recent Items Elemente recente + Delete Preset… Ștergere presetare… + Preset Name: Nume presetare: + Width and Height Lățime și înălțime + Color Profile Profil de culoare + Units Unități + Create new count group Creare grup de numărare nou + Adjustment Layer Strat de ajustare + Drag to move the layer Trageți pentru a deplasa stratul + Go to Layer Salt la strat + After (X) După (X) + Reference point location Locație punct de referință + Toggle reference point Afișare sau ascundere punct de referință + Add Pins Adăugare pini + Delete Pins Ștergere pini + Rotate Auto Rotire automată + Rotate Fixed Rotire fixă + Select Plane Selectare plan + Measure Măsurare + Heal Corectare + Heal Off Corecție dezactivată + Heal On Corecție activată + Heal Auto Corecție automată + Blend Fuziune + Brush Size Dimensiune pensulă + Heal Color Culoare corecție + Result Rezultat + Duplicate Duplicare + Fast Rapid + Accurate Precisă + Add to Adăugare la + Subtract from Scădere din + Intersect with Intersectare cu + Capture Comp Capturare compoziție + No Layer Comps Nicio compoziție de strat + Adobe PDF Presets… Presetări Adobe PDF… + 3D… 3D… + Technology Previews… Previzualizări de tehnologie… + Add a mask (from the selection; ⌥ inverts) Adăugare mască (din selecție; ⌥ inversează) + Add to selection (⇧) Adăugare la selecție (⇧) + Airbrush: keeps painting while the pointer rests. Aerograf: continuă să picteze cât timp cursorul rămâne nemișcat. + Click the first point or press ↵ to close · Esc cancels Faceți clic pe primul punct sau apăsați ↵ pentru a încheia · Esc anulează + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Faceți clic pentru a adăuga un punct · trageți departe sau ⌘/Ctrl-clic sau Delete pentru a elimina + Click to apply, ⌥-click to clear overrides, double-click for Style Options Faceți clic pentru a aplica, ⌥-clic pentru a elimina excepțiile, dublu clic pentru Opțiuni de stil + Click to zoom in · ⌥-click to zoom out Clic pentru mărire · ⌥-clic pentru micșorare + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Clic: colț · Tragere: netezire · Clic pe primul punct: încheiere · ↩ finalizare · Esc anulare + Commit Perspective Warp (↩) Confirmare Deformare de perspectivă (↩) + Commit Puppet Warp (↩) Confirmare Deformare marionetă (↩) + Commit any current edits (⌘↩) Confirmare editări curente (⌘↩) + Commit current crop operation (↵) Confirmare operație de decupare curentă (↵) + Commit transform (↩) Confirmare transformare (↩) + Commit warp (↩) Confirmare deformare (↩) + Create a new layer (⇧⌘N) Creare strat nou (⇧⌘N) + Custom pattern Model personalizat + General Brushes Pensule generale + Grid (⌘') Grilă (⌘') + Intersect with selection (⇧⌥) Formare intersecție cu selecția (⇧⌥) + New document… ⌘N Document nou… ⌘N + Open… ⌘O Deschidere… ⌘O + Rulers (⌘R) Rigle (⌘R) + Search commands (⌘K) Căutare comenzi (⌘K) + Show Guides (⌘;) Afișare ghidaje (⌘;) + Subtract from selection (⌥) Scădere din selecție (⌥) + Uses the foreground and background colors. Utilizează culorile frontală și de fundal. + ↑↓ navigate ↵ run esc close ↑↓ navigare ↵ executare esc închidere + Adds grain to the soft edges of the brush tip. Adaugă granulație marginilor moi ale vârfului pensulei. + Keeps the current texture when switching presets. Păstrează textura curentă la comutarea presetărilor. + Watercolour look: lighter interior, darker rims. Aspect de acuarelă: interior mai clar, margini mai întunecate. + Collapse group (⌥-click: all groups) Restrângere grup (⌥-clic: toate grupurile) + Expand group (⌥-click: all groups) Expandare grup (⌥-clic: toate grupurile) + Hide the layer's effects (⌥-click: all layers) Ascundere efectele stratului (⌥-clic: toate straturile) + Show the layer's effects (⌥-click: all layers) Afișare efectele stratului (⌥-clic: toate straturile) +cameraRaw Blacks Negre +cameraRaw Masking Mascare +@id edit.stroke Contur… +@id edit.transform.scale Scalare +cameraRaw Luminance Luminanță +cameraRaw Color Culoare +cameraRaw Vibrance Vibranță +cameraRaw Color Grading Gradare culoare +cameraRaw Global Global +cameraRaw Hue Nuanță +cameraRaw Luminance Detail Detaliu luminanță +cameraRaw Color Detail Detaliu culoare +cameraRaw Roughness Rugozitate +cameraRaw Whites Albe +layerLabel No Color Fără culoare + Preferences… Preferințe… + Actual Pixels Pixeli reali + Clipping Mask Mască de tăiere + Tool Options Opțiuni instrument + Color Swatches Mostre de culoare + Linear Dodge Sobreexpunere liniară + Preview Image Previzualizare imagine + Save As Salvare ca + Remove All Eliminare tot + Unlock Deblocare + Enable Activare + Disable Dezactivare + On Activat + Clear Selection Golire selecție + Invert Selection Inversare selecție + Select Inverse Inversare selecție + Contract Contractare + Sample Size Dimensiune eșantion + Gamma Gamma + Input Intrare + Input Levels Niveluri de intrare + Output Levels Niveluri de ieșire + Layer Styles Stiluri de strat + Blending Mode Mod de amestecare + Stroke Width Lățime trasou + Shadow Umbră + Adjustment Layers Straturi de ajustare + Text Layer Strat de text + Documents Documente + File Name Nume fișier + File Format Format fișier + Compression Compresie + Artboards Planșe de lucru + Snap to Grid Fixare la grilă + Full Screen Ecran complet + Fullscreen Ecran complet + Workspaces Spații de lucru + Show All Afișare tot + Hidden Ascuns + Duplicate Layer Duplicare strat + Ungroup Degrupare + Guide Layout… Aranjament de ghidaje… + Page Setup… Configurare pagină… + Reset Preferences Resetare preferințe + Documentation Documentație + Check for Updates Căutare actualizări + Loading… Se încarcă… + Processing… Se procesează… + Saving… Se salvează… + Exporting… Se exportă… + Please wait… Așteptați… + No selection Fără selecție + No layers Niciun strat + No recent files Niciun fișier recent + Unsupported file format Format de fișier neacceptat + Failed to open file Deschiderea fișierului a eșuat + Failed to save file Salvarea fișierului a eșuat + Operation canceled Operație anulată + Changes have not been saved. Modificările nu au fost salvate. + Are you sure? Sunteți sigur? + Discard Changes Renunțare la modificări + Open File Deschidere fișier + Save File Salvare fișier + Save Changes Salvare modificări + Rendering… Se randează… + Importing… Se importă… + Ready Pregătit + Failed Nereușit + Warning Avertisment + Information Informații + Retry Reîncercare + Continue Continuare + Skip Omitere + Resume Reluare + Reset All Resetare tot + Defaults Valori implicite + Apply to All Aplicare la tot + Confirm Confirmare + Finish Finalizare + Back Înapoi + Next Următor + Previous Anterior + More Mai mult + Less Mai puțin + Manual Manual + Disabled Dezactivat + Locked Blocat + Unlocked Deblocat + Delete… Ștergere… + Create… Creare… + Edit… Editare… + Change… Modificare… + Transformation Transformare + Scaling Scalare + Canvas Size Dimensiune pânză + Percentage Procentaj + Degrees Grade + Middle Centru + Diameter Diametru + Intensity Intensitate + Sensitivity Sensibilitate + Softness Moliciune + Feather Radius Rază calare + Library Bibliotecă + Layer Properties Proprietăți strat + Groups Grupuri + Masks Măști + Add Mask Adăugare mască + Delete Mask Ștergere mască + Invert Mask Inversare mască + Enable Mask Activare mască + Disable Mask Dezactivare mască + Apply Mask Aplicare mască + Import Settings Import setări + Export Settings Export setări + Shortcuts Comenzi rapide + Keyboard Tastatură + Mouse Mouse + Tablet Tabletă + Display Afișaj + Panel Panou + Hide Grid Ascundere grilă + Fit to Window Încadrare în fereastră + Live Preview Previzualizare în direct + Before Înainte + After După + Compare Comparare + File Size Dimensiune fișier + Filename Nume fișier + Open Folder Deschidere folder + Choose Folder Alegere folder + Browse Răsfoire + Find Găsire + Replace Înlocuire + Date Dată + Learn More Aflați mai multe + Report a Bug Raportare eroare + Updates Actualizări + Update Actualizare + Restart Required Este necesară repornirea + Restart Repornire + Quit Ieșire + Quit PhotoCraft Ieșire din PhotoCraft + Close Window Închidere fereastră + New Window Fereastră nouă + Open Image Deschidere imagine + Save Image Salvare imagine + Export Image Export imagine + Import Image Import imagine + Export As Export ca + Export to Export către + Export Options Opțiuni export + Import Options Opțiuni import + No Selection Fără selecție + No Image Fără imagine + No Layers Fără straturi + No Results Niciun rezultat + No Results Found Nu s-au găsit rezultate + Nothing to Undo Nimic de anulat + Nothing to Redo Nimic de refăcut + Changes Saved Modificări salvate + Save before closing? Salvați înainte de a închide? + Are you sure you want to quit? Sunteți sigur că doriți să ieșiți? + The file could not be opened. Fișierul nu a putut fi deschis. + The file could not be saved. Fișierul nu a putut fi salvat. + Unsupported format Format neacceptat + Out of memory Memorie insuficientă + Permission denied Permisiune refuzată + File not found Fișierul nu a fost găsit + Operation failed Operație nereușită + Operation completed Operație finalizată + Operation cancelled Operație anulată + Sharpen Amount Cantitate clarificare + Sharpen Radius Rază clarificare + Sharpen Detail Detaliu clarificare + Sharpen Masking Mascare clarificare + Noise Luminance Luminanță zgomot + Noise Luminance Detail Detaliu luminanță zgomot + Noise Color Culoare zgomot + Noise Color Detail Detaliu culoare zgomot + Grain Amount Cantitate granulație + Grain Size Dimensiune granulație + Grain Roughness Rugozitate granulație + Vignette Roundness Rotunjime vinietă + Vignette Feather Calare vinietă + Vignette Highlights Luminile vinietei + Vignette Style Stil vinietă + Planes Planuri + Plane Plan + Shadow Amount Cantitate umbră + Shadow Tone Ton umbră + Shadow Radius Rază umbră + Highlight Amount Cantitate lumini + Highlight Tone Ton lumini + Highlight Radius Rază lumini + Midtone Ton mediu + Black Clip Tăiere negru + White Clip Tăiere alb + Fuzziness Toleranță + Source Layer Strat sursă + Neutralize Neutralizare + Use Selection In Source Utilizare selecție în sursă + Use Selection In Target Utilizare selecție în destinație + Flame Type Tip flacără + Randomize Length Aleatorizare lungime + Interval Interval + Adjust Interval For Loops Ajustare interval pentru bucle + Use Custom Color Utilizare culoare personalizată + Flame Lines Linii de flacără + Flame Bottom Alignment Aliniere inferioară a flăcării + Flame Style Stil flacără + Flame Shape Formă flacără + Randomize Shapes Aleatorizare forme + Frame Cadru + Arrangement Aranjare + Thickness Grosime + Vine Color Culoare viță + Flower Color Culoare floare + Leaf Color Culoare foi + Base Tree Type Tip arbore de bază + Light Direction Direcție lumină + Leaves Amount Cantitate foi + Leaves Size Dimensiune foi + Branches Height Înălțime ramuri + Branches Thickness Grosime ramuri + Default Leaves Foi implicite + Leaves Color Culoare foi + Custom Branch Color Culoare personalizată ramuri + Branches Color Culoare ramuri + Flat Shading Umbrire plată + Date and Time Dată și oră + Scale Units Unități scară + Scale Factor Factor de scară + Area Zonă + Perimeter Perimetru + Circularity Circularitate + Gray Value (Minimum) Valoare gri (minimum) + Gray Value (Maximum) Valoare gri (maximum) + Gray Value (Mean) Valoare gri (medie) + Gray Value (Median) Valoare gri (mediană) + Integrated Density Densitate integrată + Length Lungime + In Black Negru intrare + In White Alb intrare + Out Black Negru ieșire + Out White Alb ieșire + Tint Color Culoare tentă + Stops Marcaje + Center X Centru X + Center Y Centru Y + Cell Size Dimensiune celulă + Generators Generatoare + Wavelength Min Lungime de undă min. + Wavelength Max Lungime de undă max. + Amplitude Min Amplitudine minimă + Amplitude Max Amplitudine maximă + Undefined Areas Zone nedefinite + Preserve Conservare + Max Radius Rază maximă + Depth Mode Mod profunzime + Solid Front Faces Fețe frontale solide + Mask Incomplete Blocks Mascare blocuri incomplete + Stylization Stilizare + Cleanliness Curățenie + Bristle Detail Detaliu peri + Lighting Iluminare + Shine Lucire + Max Offset Decalaj maxim + Map Document Hartă document + Map Path Cale hartă + Map Layer Strat hartă + Ridges Creste + Light Type Tip lumină + Light X Lumină X + Light Y Lumină Y + Light Z Lumină Z + Target X Destinație X + Target Y Destinație Y + Hotspot Punct focal + Elevation Elevație + Ambient Ambiant + Warmth Căldură + Gloss Luciu + Metallic Metalic + Ambience Ambianță + White Is High Albul este ridicat + Blade Curvature Curbură lamă + Depth Map Hartă de profunzime + Focal Distance Distanță focală + Focus Focalizare + Transition Tranziție + Radius X Rază X + Radius Y Rază Y + Pins Pini + Speed Viteză + Taper Îngustare + Start X Start X + Start Y Start Y + End X Final X + End Y Final Y + Kernel Nucleu + Input Mode Mod de intrare + Row Order Ordine rânduri + Eliminate Eliminare + Create By Creare prin + List Listă + Pencil Width Lățime creion + Stroke Pressure Presiune tușă + Paper Brightness Luminozitate hârtie + Number Of Levels Număr de niveluri + Edge Simplicity Simplitate margine + Edge Fidelity Fidelitate margine + Brush Detail Detaliu pensulă + Highlight Area Zonă lumini + Glow Size Dimensiune strălucire + Glow Brightness Luminozitate strălucire + Brush Type Tip pensulă + Stroke Size Dimensiune tușă + Stroke Detail Detaliu tușă + Highlight Strength Intensitate lumini + Smoothness Netezime + Edge Thickness Grosime margine + Edge Intensity Intensitate margine + Posterization Posterizare + Stroke Length Lungime tușă + Relief Relief + Definition Definiție + Texture Coverage Acoperire textură + Shadow Intensity Intensitate umbre + Edge Width Lățime margine + Edge Brightness Luminozitate margine + Direction Balance Balans direcție + Black Intensity Intensitate negre + White Intensity Intensitate albe + Dark Intensity Intensitate întunecate + Custom Pixel Aspect Ratio Raport de aspect pixel personalizat + 32-bit Preview Options Opțiuni previzualizare 32 biți + New Guide Ghidaj nou + Join the ArtCraft Discord Alăturați-vă Discord-ului ArtCraft + Report an Issue Raportare problemă + Load Files into Stack Încărcare fișiere în stivă + Statistics Statistici + Duplicate Layers Duplicare straturi + Edit Toolbar Editare bară de instrumente + Import Brushes Import pensule + Load 3D LUT Încărcare LUT 3D + Press keys Apăsare taste + Rename Group Redenumire grup + Rename Tool Preset Redenumire presetare instrument + Search commands, tools and panels Căutare comenzi, instrumente și panouri + Slice Options Opțiuni felie + Rename Brush Redenumire pensulă + Opening Se deschide + Reading Se citește + Working Se procesează + Accented Edges Margini accentuate + Angled Strokes Tușe înclinate + Anisotropic Anizotropic + Around Center În jurul centrului + Artistic Artistic + Bamboo Bambus + Bas Relief Basorelief + Berries Fructe de pădure + Blast Explozie + Bleach Bypass Albire fotografică + Blocks Blocuri + Brick Cărămidă + Brush Strokes Tușe de pensulă + Burlap Pânză de sac + Candle Light Lumină de lumânare + Circle Cerc + Clumped Grupat + Coarse Dots Puncte groase + Colored Pencil Creion colorat + Contrasty Contrastant + Cool Rece + Craquelure Craclură + Cross Cruce + Cross Process Procesare încrucișată + Crosshatch Hașură încrucișată + Cutout Decupaje + Daisies Margarete + Dark Rough Rugos întunecat + Dark Strokes Tușe întunecate + Darken Only Numai întunecare + Day For Night Noapte americană + Deep Blue Albastru profund + Deep Emerald Smarald profund + Deep Red Roșu profund + Deep Yellow Galben profund + Dot Punct + Double Line Linie dublă + Draft Schiță + Dry Brush Pensulă uscată + Duplication Duplicare + Edge Extension Extensie margine + Edge Only Numai margine + Enlarged Mărit + Even Fields Câmpuri pare + Faded Film Film decolorat + Film Grain Granulație film + Fine Fin + Fine Dots Puncte fine + Flat Plat + Fresco Fresco + From Left Din stânga + From Right Din dreapta + Frosted Glazurat + Generic Generic + Glass Sticlă + Glowing Edges Margini strălucitoare + Grainy Dots Puncte granulate + Hearts Inimi + Heptagon Heptagon + Hexagon Hexagon + Horizontal Only Numai orizontal + Infinite Infinit + Ink Outlines Contururi cu tuș + Ivy Iederă + Left Diagonal Diagonală stânga + Level Based Bazat pe niveluri + Light Rough Rugos clar + Lighten Only Numai luminare + Long Lines Linii lungi + Long Strokes Tușe lungi + Lower Inferior + Medium Dots Puncte medii + Medium Lines Linii medii + Medium Strokes Tușe medii + Mono Contrast Contrast monocrom + Mosaic Tiles Plăci de mozaic + Multiple Flames Along Path Mai multe flăcări de-a lungul traseului + Multiple Flames One Direction Mai multe flăcări într-o direcție + Multiple Flames Path Directions Mai multe flăcări în direcțiile traseului + Multiple Flames Various Length Mai multe flăcări de lungimi diferite + Octagon Octagon + Odd Fields Câmpuri impare + One Flame Along Path O flacără de-a lungul traseului + Out From Center Dinspre centru în exterior + Oval Oval + Overlay Edge Margine suprapusă + Palette Knife Spatulă + Parallel Paralel + Patchwork Petice + Pentagon Pentagon + Point Punct + Pointed Ascuțit + Pond Ripples Unde de iaz + Pyramids Piramide + Random Aleatoriu + Right Diagonal Diagonală dreapta + Rope Coardă + Roses Trandafiri + Sandstone Gresie + Scallops Scoici + Sepia Sepia + Short Lines Linii scurte + Short Strokes Tușe scurte + Simple Simplu + Light Intensity Intensitate lumină + Spray Radius Rază pulverizare + Stroke Direction Direcție tușă + Graininess Granulozitate + Glow Amount Cantitate strălucire + Clear Amount Cantitate transparență + Distortion Distorsiune + Ripple Size Dimensiune undă + Ripple Magnitude Magnitudine undă + Charcoal Area Zonă carboncium + Chalk Area Zonă cretă + Charcoal Thickness Grosime carboncium + Light Dark Balance Balans clar/întunecat + Foreground Level Nivel frontal + Background Level Nivel fundal + Pattern Type Tip model + Image Balance Balans imagine + Darkness Întuneciune + Fiber Length Lungime fibră + Crack Spacing Spațiere crăpături + Crack Depth Profunzime crăpături + Crack Brightness Luminozitate crăpături + Grain Type Tip granulație + Tile Size Dimensiune plăcuță + Grout Width Lățime rost + Lighten Grout Luminare rost + Square Size Dimensiune pătrat + Border Thickness Grosime margine + Correct Distortion Corectare distorsiune + Correct Vignette Corectare vinietă + Correct C A Corectare AC + Auto Scale Scalare automată + Red Cyan Roșu/cian + Blue Yellow Albastru/galben + Vignette Amount Cantitate vinietă + Vignette Midpoint Punct median vinietă + Constraints Constrângeri + Curve Highlights Curbă lumini + Curve Lights Curbă luminile + Curve Darks Curbă umbrele + Curve Shadows Curbă umbre + Curve Splits Diviziuni curbă + Point Curve Curbă de puncte + Point Curve Red Curbă de puncte roșie + Point Curve Green Curbă de puncte verde + Point Curve Blue Curbă de puncte albastră + Hsl Hue Nuanță HSL + Hsl Sat Saturație HSL + Hsl Lum Luminanță HSL + Grade Shadows Gradare umbre + Grade Midtones Gradare tonuri medii + Grade Highlights Gradare lumini + Grade Global Gradare globală + Grade Blending Fuziune gradare + Grade Balance Balans gradare + Sine Sinusoidal + Sketch Schiță + Snowflakes Fulgi de nea + Sparkle Sclipiri + Spatter Stropi + Speckle Pigmentare + Spin Rotire + Sprinkles Presărare + Squareness Caracter pătrat + Stagger Decalat + Stained Glass Vitraliu + Star Stea + Stars Stele + Stippled Punctat + Stretch Întindere + Teal Orange Verde-azuriu și portocaliu + Tiny Lens Lentilă mică + To Center Spre centru + Unaltered Nealterat + Underpainting Pictură de bază + Underwater Subacvatic + Upper Superior + Vertical Only Numai vertical + Vine With Flowers Enredadera cu flori + Vine With Leaves Viță cu foi + Violent Violent + Warm Cald + Watercolor Acuarelă + Waves Unde + Wide Blurry Larg neclar + Wide Sharp Larg clar + Wrap Învăluire + Zigzag Zigzag + Wetness Umiditate + AI Integrations Integrări AI + Add Noise Adăugare zgomot + Apply Data Set Aplicare set de date + Apply Image Aplicare imagine + Arbitrary Arbitrar + Artboard from Group Planșă de lucru din grup + Artboard from Layers Planșă de lucru din straturi + Artboards to Files Planșe de lucru în fișiere + Artboards to PDF Planșe de lucru în PDF + Assign Profile Asignare profil + At La + Auto-Align Layers Alinierea automată a straturilor + Auto-Blend Layers Combinarea automată a straturilor + Batch Lot + Border Margine + Box Blur Neclaritate cutie + Calculations Calcule + Camera Raw Camera Raw + Camera Raw Filter Filtru Camera Raw + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 + Clone Clonare + Color Decontaminate Decontaminare culoare + Color Halftone Semiton color + Color Lookup Tables Tabele de căutare a culorilor + Color Settings Setări de culoare + Color Table Tabel de culori + Conditional Mode Change Schimbare condiționată a modului + Content-Aware Fill Umplere sensibilă la conținut + Convert to Linked Conversie în conectat + Convert to Profile Conversie la profil + Crystallize Cristalizare + Data Date + Data Sets as Files Seturi de date ca fișiere + De-Interlace Dezîntrețesere + Define Brush Preset Definire presetare pensulă + Define Custom Shape Definire formă personalizată + Define Pattern Definire model + Defringe Eliminare halouri + Diffuse Difuzare + Diffuse Glow Strălucire difuză + Displace Deplasare + Do you want to save you made to “{name}” before quitting? Doriți să salvați modificările făcute la „{name}” înainte de a închide programul? + Dust & Scratches Praf și zgârieturi + Export Contents Export conținut + Export Preferences Preferințe export + Export/Import Presets Export/import presetări + Extrude Extrudare + Fibers Fibre + Field Blur Neclaritate câmp + File Info Informații fișier + Filter Gallery Galerie de filtre + Find and Replace Text Găsire și înlocuire text + Fit Image Încadrare imagine + Flame Flacără + Focus Area Zonă de focalizare + Gaussian Blur Neclaritate gaussiană + Global Light Lumină globală + Group from Layers Grup din straturi + HDR Toning Tonalizare HDR + High Pass Trecere de sus + Highlight Lumini + Image Processor Procesor de imagini + Install Plug-in Instalare plugin + Interpret Footage Interpretare material video + Iris Blur Neclaritate iris + Jag Zimțat + Label Etichetă + Layer Comps to Files Compoziții de straturi în fișiere + Layer Content Options Opțiuni conținut strat + Layer From Background Strat din fundal + Layer from Background Strat din fundal + Layers to Files Straturi în fișiere + Legacy Veche + Lens Blur Neclaritate lentilă + Lens Correction Corecție obiectiv + Lens Flare Reflexie lentilă + Lighting Effects Efecte de iluminare + Liquify Lichefiere + Lum Lum + Match Color Potrivire culoare + Merge to HDR Pro Îmbinare în HDR Pro + Mezzotint Mezzotintă + Mosaic Mozaic + Motion Blur Neclaritate mișcare + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Tragerea și plasarea nativă a fișierelor nu este încă suportată pe Wayland. Utilizați Fișier › Deschidere, sau porniți PhotoCraft prin XWayland cu `WAYLAND_DISPLAY= photocraft`. + Neon Glow Strălucire neon + New Guide Layout Aranjament nou de ghidaje + New Video Layer from File Strat video nou din fișier + Ocean Ripple Ondulație oceanică + Oil Paint Pictură în ulei + Open As Deschidere ca + Package Împachetare + Paint Daubs Pete de pictură + Path Blur Neclaritate traseu + Paths to Illustrator Trasee către Illustrator + Photomerge Photomerge + Picture Frame Ramă foto + Pinch Ciupire + Place Embedded Plasare încorporată + Place Linked Plasare conectată + Place Scale Marker Plasare marcator de scară + Plastic Wrap Înveliș de plastic + Pointillize Pointilism + Polar Coordinates Coordonate polare + Polar To Rectangular Polar la rectangular + Poster Edges Margini posterizate + Preset Manager Administrator presetări + Profile Profil + Radial Blur Neclaritate radială + Raw Defaults Valori implicite Raw + Rectangular To Polar Rectangular la polar + Relink to File Reconectare la fișier + Render Video Randare video + Replace Color Înlocuire culoare + Replace Contents Înlocuire conținut + Replace Footage Înlocuire material video + Resolve Missing Fonts Rezolvare fonturi lipsă + Ripple Ondulație + Rough Pastels Pasteluri aspre + Sat Sat + Save a Copy Salvare copie + Save for Web (Legacy) Salvare pentru web (vechi) + Scale Effects Scalare efecte + Script Events Manager Administrator evenimente script + Search menus Căutare în meniuri + Select and Mask Selecție și mască + Set Measurement Scale Definire scară de măsurare + Shadows/Highlights Umbre/luminile + Shape Blur Neclaritate formă + Shear Forfecare + Smart Blur Neclaritate inteligentă + Smart Sharpen Clarificare inteligentă + Smudge Stick Difumino + Solid Color Culoare solidă + Spherize Sferizare + Spin Blur Neclaritate rotație + Spot Foc + Sprayed Strokes Tușe pulverizate + Strokes Tușe + Sumi-e Sumi-e + Surface Blur Neclaritate suprafață + Texturizer Texturizare + Tiles Plăci + Tilt-Shift Înclinare-deplasare + Trace Contour Trasare contur + Trap Trapping + Tree Copac + Trim Ajustare margini + Turbulent Turbulent + Twirl Vârtej + Unsharp Mask Mască de claritate + Use File › Open to open an image. Utilizați Fișier › Deschidere pentru a deschide o imagine. + Vanishing Point Punct de fugă + Variable Data Sets Seturi de date variabile + Video Frames to Layers Cadre video în straturi + WIA Support Suport WIA + Warp Text Deformare text + Wind Vânt + Vertical Type Tool Instrument Text vertical + About {app} Despre {app} + Hide {app} Ascundere {app} + Quit {app} Ieșire din {app} + Services Servicii + Hide Others Ascundere celelalte + Lut LUT + Cooling L B B Răcire LBB + Cooling80 Răcire 80 + Cooling82 Răcire 82 + Hsb HSB + Hsl HSL + Movie Prime Film Prime + Prime105 Obiectiv fix de 105 mm + Prime35 Obiectiv fix de 35 mm + Warming L B A Încălzire LBA + Warming81 Încălzire 81 + Warming85 Încălzire 85 +@id filter.sharpen.sharpen Clarificare + Good Bună + Best Cea mai bună + Adjustment Properties Proprietăți ajustare + Default Foreground and Background Colors Culori frontală și de fundal implicite + Move Layer Deplasare strat + Rectangular Selection Selecție rectangulară + Reorder Layer Reordonare strat + Select Layer Selectare strat + Set Colors Stabilire culori + Switch Foreground and Background Colors Interschimbare culori frontală și de fundal + Target Destinație + Make Work Path… Creare traseu de lucru… + Create Vector Mask Creare mască vectorială + Make Selection… Creare selecție… + Fill Path… Umplere traseu… + Clipping Path… Traseu de tăiere… + Free Transform Path Transformare liberă traseu + Unite Shapes at Overlap Unire forme la suprapunere + Subtract Shapes at Overlap Scădere forme la suprapunere + Copy Fill Copiere umplere + Copy Complete Stroke Copiere trasou complet + Paste Fill Inserare umplere + Paste Complete Stroke Inserare trasou complet + Make Symmetry Path Creare traseu de simetrie + Disable Symmetry Path Dezactivare traseu de simetrie + Select Custom Color… Selectare culoare personalizată… + Color Picker (Custom Canvas Color) Selector de culoare (culoare pânză personalizată) + Duplicate Channel Duplicare canal + Delete Channel Ștergere canal + Rename Channel… Redenumire canal… + Merge Spot Channel Combinare canal de culoare plană + Convert to Alpha Channel Conversie în canal alfa + Exit Quick Mask Ieșire din Mască rapidă + New Channel… Canal nou… + New Spot Channel… Canal de culoare plană nou… + Split Channels Divizare canale + Merge Channels… Combinare canale… + Color Indicates Masked Areas Culoarea indică zone mascate + Color Indicates Selected Areas Culoarea indică zone selectate + ✓ Color Indicates Masked Areas ✓ Culoarea indică zone mascate + ✓ Color Indicates Selected Areas ✓ Culoarea indică zone selectate + Up Sus + To Până la + Shallow Superficial + Deep Profund + Blend Interior Effects as Group Fuziune efecte interioare ca grup + Blend Clipped Layers as Group Fuziune straturi tăiate ca grup + Transparency Shapes Layer Strat de forme de transparență + Layer Mask Hides Effects Masca de strat ascunde efectele + Vector Mask Hides Effects Masca vectorială ascunde efectele + General Blending Fuziune generală + Underlying Layer: Strat subiacent: + Nothing to paste: the clipboard holds no image Nimic de inserat: clipboard-ul nu conține nicio imagine + Preferences saved Preferințe salvate + Invalid panel layout; restored the default dock Aranjament de panouri nevalid; s-a restaurat acoplarea implicită + Preferences were reset: {error} Preferințele au fost resetate: {error} + Recovered {name} S-a recuperat {name} + Couldn't save preferences: {error}. Retrying… Nu s-au putut salva preferințele: {error}. Se reîncearcă… + Autosave failed: {error} Salvarea automată a eșuat: {error} + Exported presets to {path} S-au exportat presetările în {path} + Ran script {name} S-a executat scriptul {name} + Sent to the printer ({spooler}) S-a trimis la imprimantă ({spooler}) + Printed to {path} S-a imprimat în {path} + Exported {path} ({size}) S-a exportat {path} ({size}) + Exported {path} S-a exportat {path} + Added swatch {name} S-a adăugat mostra {name} + Step {step} ({id}) failed: {error} Pasul {step} ({id}) a eșuat: {error} + Could not rename the action: {error} Nu s-a putut redenumi acțiunea: {error} + Action playback failed: {error} Redarea acțiunii a eșuat: {error} + Clone source set at {x}, {y} Sursă de clonare stabilită la {x}, {y} + Color management: {error} Gestionare culoare: {error} + Opened {url} S-a deschis {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nu există formă personalizată „{name}”: alegeți una din bara de opțiuni sau Fereastră › Forme + Saved a copy as {path} S-a salvat o copie ca {path} + Exported contents to {path} S-a exportat conținutul în {path} + {label} is not available: {reason} {label} nu este disponibil: {reason} + Saved {path}; image assets: {n} in {dir} S-a salvat {path}; resurse imagine: {n} în {dir} + {message} (notes: {n}; {warning}) {message} (note: {n}; {warning}) + Preferences can't be saved Preferințele nu pot fi salvate + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft va continua să încerce; până când o salvare are succes, modificările preferințelor se vor pierde la închidere. + GPU retry scheduled Reîncercare GPU programată + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Salvați-vă munca și reporniți PhotoCraft pentru a reîncerca accelerarea GPU. Randarea pe CPU rămâne activă pentru această sesiune. + GPU device was lost; using the CPU renderer. Dispozitivul GPU s-a pierdut; se va folosi randorul CPU. + GPU error; using the CPU renderer. Eroare GPU; se va folosi randorul CPU. + Couldn't open {name}: {error} Nu s-a putut deschide {name}: {error} + Toggle the Brush Settings panel Afișare sau ascundere panou Setări pensulă + New Brush Preset Presetare de pensulă nouă + Grid view Vizualizare grilă + List view Vizualizare listă + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink Deconectare + Link Conectare + Commit transform Confirmare transformare + Cancel transform Anulare transformare + Scroll menu up Derulare meniu sus + Scroll menu down Derulare meniu jos + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Acest strat de text trebuie rasterizat înainte de a continua. Textul său nu va mai fi editabil. Rasterizați textul? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Acest strat de formă trebuie rasterizat înainte de a continua. Nu va mai fi editabil ca formă. Rasterizați forma? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Acest obiect inteligent trebuie rasterizat înainte de a continua. Conținutul său nu va mai fi editabil. Rasterizați obiectul inteligent? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Acest strat de umplere trebuie rasterizat înainte de a continua. Conținutul său de umplere nu va mai fi editabil. Rasterizați stratul? + This layer must be rasterized before proceeding. Acest strat trebuie rasterizat înainte de a continua. + Color Picker (Background Color) Selector de culoare (culoare de fundal) + Color Picker (Foreground Color) Selector de culoare (culoare frontală) + Adwaita (Light) Adwaita (clar) + Adwaita (Dark) Adwaita (întunecat) + Open Recent is unavailable on the web Deschidere recente nu este disponibilă pe web + Graphics adapter: {adapter} Adaptor grafic: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Driver: {driver} + GPU backend preference: {backend} Preferință backend GPU: {backend} + Selected at launch: {backend} Selectat la pornire: {backend} + Image compositor: {processor} Compozitor imagini: {processor} + Fallback: {reason} Alternativă: {reason} + This session: {reason} Această sesiune: {reason} + Window renderer: {renderer} Randor fereastră: {renderer} + Software adapter Adaptor software + Graphics adapter Adaptor grafic + unknown type tip necunoscut + Platform: {os} {arch} Platformă: {os} {arch} + previous reading kept; the last re-read failed: {reason} s-a păstrat citirea anterioară; ultima recitire a eșuat: {reason} + Monitor profile: {profile} Profil monitor: {profile} + Display profiles: {reason} Profiluri afișaj: {reason} + Monitor profile in use on {display}: {profile} Profil monitor în uz pe {display}: {profile} + Monitor profile in use: {profile} Profil monitor în uz: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Pânza este afișată ca sRGB. Alegeți un profil de monitor în Editare › Setări de culoare. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Pânza de pe {display} este afișată ca sRGB. Alegeți un profil de monitor în Editare › Setări de culoare. + Display profile not used Profil de afișaj neutilizat + Monitor profile on {display} (main window): {profile} Profil monitor pe {display} (fereastra principală): {profile} + Monitor profile on {display}: {profile} Profil monitor pe {display}: {profile} + fallback alternativă + The document does not have an embedded colour profile. Working space: {working}. Documentul nu are un profil de culoare încorporat. Spațiu de lucru: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Documentul are un profil de culoare încorporat care nu corespunde cu spațiul de lucru.\nÎncorporat: {embedded}\nLucru: {working} + Importing {what} from {file}… Se importă {what} din {file}… +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} S-a importat {n} pensulă din {file}|S-au importat {n} pensule din {file}|S-au importat {n} de pensule din {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} S-a importat {n} degrade din {file}|S-au importat {n} degradeuri din {file}|S-au importat {n} de degradeuri din {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} S-a importat {n} model din {file}|S-au importat {n} modele din {file}|S-au importat {n} de modele din {file} + Data Set: {name} ({index}/{total}) Set de date: {name} ({index}/{total}) + (none) (niciunul) +navigator Zoom Zoom + Apply {option}: {state} Aplicare {option}: {state} + This adjustment ({key}) is kept as imported and can't be edited yet. Acest ajustaj ({key}) este conservat ca a fost importat și nu poate fi editat încă. + Join the ArtCraft Discord ({url}) Alăturare la Discord-ul ArtCraft ({url}) + {index} of {total} {index} din {total} + {shortcut} assigned. {shortcut} asignat. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} este deja folosit de {commands} și va fi eliminat de acolo când faceți clic pe OK. + The current gradient (pick one in Window › Gradients) Degradeul curent (alegeți unul în Fereastră › Degradeuri) + auto automat + Baseline Shift Deplasare linie de bază + Breeze Dark Breeze întunecat + Breeze Light Breeze clar + brushes pensule + Color Picker Selector de culoare + Current Curent + Dark Gray Gri întunecat + Fill Color Culoare umplere + gradients degradeuri + Graphic and web Grafică și web + layer mask mască de strat + Lock All Blocare tot + Lock Image Pixels Blocare pixeli imagine + Lock Position Blocare poziție + Lock Transparent Pixels Blocare pixeli transparenți + manual manual + Medium Gray Gri mediu + New document Document nou + New Selection Selecție nouă + No Document Fără document + Note tool Instrument Notă + off dezactivat + on activat + Recent Files Fișiere recente + Rgb RGB + Shortcut Comandă rapidă + Show Rulers Afișare rigle + Solarized Dark Solarized întunecat + sRGB sRGB + Stroke Color Culoare trasou + unknown necunoscut + Unsaved Changes Modificări nesalvate + vector mask mască vectorială + ZigZag Zigzag + Show home recent files Afișare fișiere recente la pornire + Clear Brush Controls Ștergere controale pensulă + Reset All Locked Settings Restaurare toate setările blocate + Copy Texture to Other Tools Copiere textură în alte instrumente + Load Actions… Încărcare acțiuni… + Save Actions… Salvare acțiuni… + New Action… Acțiune nouă… + Start Recording Pornire înregistrare + Stop Recording Oprire înregistrare + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} S-a încărcat {n} acțiune din {file}|S-au încărcat {n} acțiuni din {file}|S-au încărcat {n} de acțiuni din {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} pas nu are încă echivalent în PhotoCraft|{n} pași nu au încă echivalent în PhotoCraft|{n} de pași nu au încă echivalent în PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} S-a salvat {n} acțiune în {file}|S-au salvat {n} acțiuni în {file}|S-au salvat {n} de acțiuni în {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} pas fără echivalent în Photoshop a fost omis|{n} pași fără echivalent în Photoshop au fost omiși|{n} de pași fără echivalent în Photoshop au fost omiși + Starting serial Număr de serie inițial + Insert Stop… Inserare oprire… + Save Set… Salvare set… + Message: Mesaj: + Allow Continue Permitere continuare + Show dialog during playback Afișare dialog în timpul redării + Select an action first Selectați mai întâi o acțiune + Action playback stopped Redarea acțiunii s-a oprit + Step {step} is waiting for its dialog Pasul {step} își așteaptă dialogul +@plural {n} action|{n} actions {n} acțiune|{n} acțiuni|{n} de acțiuni + Background (Default) Fundal (implicit) + Include All Subfolders Includere toate subfolderele + Auto Add/Delete Adăugare/ștergere automată + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nu activați și Imagine în oglindă în Setări de imprimare…: cele două răsturnări se anulează. + Print Settings… Setări de imprimare… + Remember to turn off the printer's own colour management in Print Settings… Nu uitați să dezactivați gestionarea culorii proprie a imprimantei în Setări de imprimare… + Sent to {printer} Trimis la {printer} + set in Print Settings… se stabilește în Setări de imprimare… + ICO is limited to 256 × 256 pixels ICO este limitat la 256 × 256 pixeli + Show Favorite Fonts Afișare fonturi favorite + No favorite fonts yet: click the star beside a font to add it. Nicio fontă favorită încă: faceți clic pe steaua de lângă o fontă pentru a o adăuga. + Favorite fonts Fonturi favorite + Add to Favorites Adăugare la favorite + Remove from Favorites Eliminare din favorite + Show All Fonts Afișare toate fonturile + All Classes Toate clasele + Blackletter Gotică + Decorative Decorativă + Font preview: Previzualizare fontă: + Handwritten Manuscrisă + Hide Font Classes Ascundere clase de fonturi + Hide Styles Ascundere stiluri + Monospace Monospațiată + No fonts match these filters. Niciun font nu corespunde acestor filtre. + No similar fonts: the current font doesn't say what it looks like. Niciun font similar: fontul curent nu descrie cum arată. + OpenType font Font OpenType + Sans Serif Sans serif + Script Caligrafică + Serif Serif + Show Font Classes Afișare clase de fonturi + Show Similar Fonts Afișare fonturi similare + Show Styles Afișare stiluri + Show Variable Fonts Afișare fonturi variabile + Slab Serif Serif egipteană + TrueType font Font TrueType + Variable font Font variabil + Calibration Calibrare + Red Primary Primar roșu + Green Primary Primar verde + Blue Primary Primar albastru + Write xmp sidecars Scriere fișiere XMP auxiliare + Set the tone sliders from the image Stabilire glisoarele de ton din imagine + Settings from {name} Setări din {name} + Settings from {name}; {count} not supported: {names} Setări din {name}; {count} nesuportate: {names} + Could not write {name}: {error} Nu s-a putut scrie {name}: {error} + Kept {name}: written by another application S-a păstrat {name}: a fost scris de altă aplicație + Shadow Tint Tentă umbre + Check for Updates… Căutare actualizări… + PhotoCraft Updates Actualizări PhotoCraft + Checking for updates… Se caută actualizări… + Open the release page to choose an installer for your system. Deschideți pagina versiunii pentru a alege un instalator pentru sistemul dvs. + Open Download Page Deschidere pagină de descărcare + PhotoCraft is up to date. PhotoCraft este actualizat. + Could not check for updates. Nu s-au putut căuta actualizări. + Updates have not been checked yet. Actualizările nu au fost încă căutate. + Check Again Căutare din nou + Automatically Check for Updates Căutare automată a actualizărilor + Installed version: {version} Versiune instalată: {version} + PhotoCraft {version} is available. PhotoCraft {version} este disponibil. + Paints from the History panel's source state Pictează pe baza stării sursă din panoul Istoric + Sets the source for the history brush Stabilește sursa pentru pensula istoric + Highlight Mode Mod luminile + Highlight Color Culoare luminile + Highlight Opacity Opacitate luminile + Shadow Mode Mod umbre + Shadow Color Culoare umbre + Shadow Opacity Opacitate umbre + Use the embedded profile ({profile}) Utilizare profil încorporat ({profile}) + Convert to the working space ({profile}) Conversie la spațiul de lucru ({profile}) + Remember this choice for future images Reținere această opțiune pentru imaginile viitoare + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Reactivați solicitarea cu Ask On Mismatch în Editare › Setări de culoare. + Button Mode Mod buton + Button Mode On Mod buton activat + Darken Interface Întunecare interfață + Lighten Interface Luminare interfață + Technique Tehnică + Chisel Hard Cioplire dură + Chisel Soft Cioplire moale + Open color-less EXR as ACEScg Deschidere EXR fără spațiu de culoare ca ACEScg + Do not append "copy" to filename when saving a copy Nu adăugați „copy” la numele fișierului la salvarea unei copii + Edit Selected Guides Editare ghidaje selectate + Clear Selected Guides Ștergere ghidaje selectate + Bicubic Automatic Bicubică automată + Bicubic Smoother Bicubică mai netedă + Bicubic Sharper Bicubică mai clară + Pasting from other apps is unavailable Inserarea din alte aplicații nu este disponibilă + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft nu poate accesa clipboard-ul sistemului în această sesiune. Pe Wayland, un sandbox Flatpak ascunde protocolul prin care se citesc imaginile din alte aplicații. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Pentru a insera din alte aplicații, porniți PhotoCraft sub XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nimic de inserat: PhotoCraft nu poate citi aici clipboard-ul sistemului + Delete this set Ștergere acest set + New Set… Set nou… + PhotoCraft Defaults Valori implicite PhotoCraft + Save a list of these shortcuts as a web page Salvare o listă a acestor comenzi rapide ca pagină web + Save the changes to this set Salvare modificările în acest set + Save these shortcuts as a .kys file Salvare aceste comenzi rapide ca fișier .kys + Save these shortcuts as a new set Salvare aceste comenzi rapide ca set nou + Set deleted. Set șters. + Set saved. Set salvat. + Set: Set: + Summarize… Rezumare… + There are too many sets: delete one first. Există prea multe seturi: ștergeți unul mai întâi. + set name nume set + {set} (modified) {set} (modificat) + Show file name in title Afișare nume fișier în titlu + 50% Threshold Prag 50% + Pattern Dither Tramă model + Diffusion Dither Tramă difuzie + Halftone Screen Trama de semitonuri + Custom Pattern Model personalizat + Frequency: Frecvență: + Lines/Inch Linii/inch + Lines/Centimeter Linii/centimetru + Ellipse Elipsă + Accept and Go to Conflict Acceptare și salt la conflict + Changes undone. Modificări anulate. + Choose a new shortcut for this command, or leave it without one. Alegeți o comandă rapidă nouă pentru această comandă, sau lăsați-o fără una. + Put this command's shortcuts back as they were Restaurare comenzile rapide ale acestei comenzi cum erau + Take the shortcut from the other command and select it Preluare comanda rapidă de la cealaltă comandă și selectarea ei + Undo Changes Anulare modificări + {shortcut} is reserved by the system and can't be assigned. {shortcut} este rezervat de sistem și nu poate fi asignat. + Low-Latency Drawing (vsync off, applies at next launch) Desenare cu latență redusă (fără sincronizare verticală, se aplică la următoarea pornire) + Free Transform is still being applied; try again when it has finished. Transformarea liberă încă se aplică; încercați din nou când se termină. + The transform couldn't be applied. Transformarea nu a putut fi aplicată. + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Straturile sale necesită {needed} de memorie GPU; bugetul le lasă {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Setați Memorie GPU în Preferințe › Performanță la ce poate aloca placa dvs. grafică, cel mult 3/8 din memoria ei. Automat urmează Utilizarea memoriei, în limita unui sfert din memoria acestui computer și 3/8 din cea a plăcii, și nu contorizează documentul pe o placă a cărei memorie este cunoscută. Bara de stare arată Memorie GPU plină cât timp nu încap. + GPU Memory Full Memorie GPU plină + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Straturile necesită {needed} de memorie GPU și pot ocupa {budget}, așa că editările care redesenează tot documentul se fac pe CPU. Faceți clic pentru a stabili Memoria GPU. + GPU memory budget: {budget} Buget memorie GPU: {budget} + Graphics memory: {size} Memorie grafică: {size} + This document's layers: {needed} of {budget} for layers Straturile acestui document: {needed} din {budget} pentru straturi + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Straturile acestui document necesită {needed} dar pot ocupa {budget}: redesenările întregului document folosesc CPU + GPU Memory (MB, 0 = Automatic) Memorie GPU (MB, 0 = Automat) + {path}: the path is not valid Unicode; save under another name. {path}: calea nu este Unicode valid; salvați sub alt nume. + {name} was changed by another app after it was opened or saved here. {name} a fost modificat de o altă aplicație după ce a fost deschis sau salvat aici. + Replace it with this document, or cancel and use File › Save As to keep both. Înlocuiți-l cu acest document, sau anulați și utilizați Fișier › Salvare ca pentru a păstra ambele. + Decrease Brush Size Reducere dimensiune pensulă + Increase Brush Size Creștere dimensiune pensulă + Decrease Brush Hardness Reducere duritate pensulă + Increase Brush Hardness Creștere duritate pensulă + Demosaic Demozaicare + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cum este completat mozaicul filtrului de culoare al camerei. Se aplică la deschiderea fișierului; previzualizarea arată metoda cu care a fost deschis. + AHD (adaptive homogeneity) AHD (omogenitate adaptivă) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: marginile cele mai curate și cea mai mică franjare de culoare; cea mai lentă. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolare cu corecție de gradient: clară și rapidă, cu o anumită franjare de culoare pe detalii fine. + The fastest and the softest, with colour fringing on fine detail. Cea mai rapidă și cea mai moale, cu franjare de culoare pe detalii fine. + “{name}” already exists. Replace it? „{name}” există deja. Îl înlocuiți? + The name in the save dialog was “{name}”. Numele din dialogul de salvare a fost „{name}”. + Previous Brush Pensula anterioară + Next Brush Pensula următoare + First Brush Prima pensulă + Last Brush Ultima pensulă + Preview size Dimensiune previzualizare + Memory Usage Utilizare memorie + Per-document RAM limit (MB) Limită RAM per document (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalată: {ram} MB; această limită: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limitează pixelii fiecărui document deschis plus Istoricul. Cele mai vechi stări de anulare sunt eliminate la depășirea limitei. + Applies immediately when saved. Memory is not reserved in advance. Se aplică imediat la salvare. Memoria nu este rezervată în avans. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Cu Memoria GPU pe Automat, aceasta limitează și bugetul de strat GPU. Acel buget este plafonat la un sfert din RAM-ul instalat și, când se cunoaște, trei optimi din memoria plăcii grafice, cu un minim de 512 MB. Mărirea acestei limite dincolo de aceste plafoane nu crește bugetul GPU. + Use Utilizare + In Front În față + Behind În spate + DDS Options Opțiuni DDS + Generate Mipmaps Generare mipmap-uri + There is no color table to save. Nu există o tabelă de culori de salvat. +@plural Saved {n} color to {file}|Saved {n} colors to {file} S-a salvat {n} culoare în {file}|S-au salvat {n} culori în {file}|S-au salvat {n} de culori în {file} + GPU adapter Adaptor GPU + Import Styles… Import stiluri… + Export Selected Styles… Export stiluri selectate… + Export Styles… Export stiluri… + {file} is not a style library (.asl) {file} nu este o bibliotecă de stiluri (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} S-a importat {n} stil din {file}|S-au importat {n} stiluri din {file}|S-au importat {n} de stiluri din {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} S-a exportat {n} stil în {file}|S-au exportat {n} stiluri în {file}|S-au exportat {n} de stiluri în {file} + Confirm or cancel the open action step dialog first Confirmați sau anulați mai întâi dialogul deschis al pasului de acțiune + Pro Medium Gray Pro (gri mediu) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (gri clar) + Pro Light Pro (clar) + Resize Windows to Fit Redimensionare ferestre pentru încadrare + EXR EXR + Automatic paragraph direction Direcție paragraf automată + Left-to-right paragraph direction Direcție paragraf de la stânga la dreapta + Right-to-left paragraph direction Direcție paragraf de la dreapta la stânga + Not rendered: this adjustment ({key}) is kept as imported. Nu este randat: acest ajustaj ({key}) este conservat cum a fost importat. + Print preview image Previzualizare imagine de imprimare + Hue Cube Cub de nuanță + Brightness Cube Cub de luminozitate + Color Wheel Roată de culoare + RGB Sliders Regulatoare RGB + HSB Sliders Regulatoare HSB + CMYK Sliders Regulatoare CMYK + Lab Sliders Regulatoare Lab + Web Color Sliders Regulatoare de culori web + Copy Color as HTML Copiere culoare ca HTML + Copy Color's Hex Code Copiere cod hexadecimal al culorii + RGB Spectrum Spectru RGB + CMYK Spectrum Spectru CMYK + Grayscale Ramp Rampă de tonuri de gri + Current Colors Culori curente + Make Ramp Web Safe Transformare rampă în sigură pentru web + The path was kept as the Work Path Traseul a fost conservat ca Traseu de lucru + The path was discarded Traseul a fost abandonat + Art History Brush Tool Instrument Pensulă istoric artistic + Tight Short Scurt strâns + Tight Medium Mediu strâns + Tight Long Lung strâns + Loose Medium Mediu lejer + Loose Long Lung lejer + Dab Tamponare + Tight Curl Buclă strânsă + Loose Curl Buclă lejeră + Stylized dabs from a history state Tușe stilizate dintr-o stare din istoric + Font size presets Presetări dimensiune font + Leading presets Presetări interlinie + Tracking presets Presetări spațiere + Color Aware Sensibil la culoare + Decontaminate Colors Decontaminare culori + Edge Detection Detecție margini + Global Refinements Perfecționări globale + High Quality Preview Previzualizare de înaltă calitate + Object Aware Sensibil la obiect + Real-Time Refinement Perfecționare în timp real + Refine Edge Brush Tool Instrument Pensulă de perfecționare margine + Refine Hair Perfecționare păr + Refine Mode Mod perfecționare + Remember Settings Reținere setări + View Mode Mod de vizualizare + Object Aware needs a segmentation model, which isn't available yet Sensibil la obiect necesită un model de segmentare, care nu este încă disponibil + Refine Hair needs hair detection, which isn't available yet Perfecționare păr necesită detecție de păr, care nu este încă disponibilă + Select and Mask was closed: its document is no longer the active one S-a închis Selecție și mască: documentul său nu mai este cel activ + Select and Mask was closed: another layer was selected S-a închis Selecție și mască: a fost selectat alt strat + Select Layer Under Cursor Selectare strat sub cursor + Drag to move the selected path Trageți pentru a deplasa traseul selectat + A: A: + Adobe-rgb-compat Compatibil Adobe RGB + Any Oricare + Arc Lower Arc inferior + Arc Upper Arc superior + Author Title Titlu autor + Based On Bazat pe + Bend Curbură + Bpc Bpc + CPU Rendering Randare pe CPU + Calculating… Se calculează… + Case Sensitive Sensibil la majuscule + Ccw Anti-orar + Change Find Comutare căutare + Checkerboard Tablă de șah + Click to refresh histogram with uncached data Faceți clic pentru a reîmprospăta histograma cu date neutilizate din cache + Cmyk CMYK + Coated-cmyk CMYK pentru hârtie cretată + Color Sampler Tool Instrument Eșantionator de culoare + Copyright Url URL drepturi de autor + Count: Numărare: + Cw Orar + Decrease Type Size Reducere dimensiune text + Decrease Type Size by 10 Reducere dimensiune text cu 10 + Display-p3 Display P3 + Dont Enlarge Fără mărire + Drag to move the active shape's path or the Work Path Trageți pentru a deplasa traseul formei active sau Traseul de lucru + Drawn on the CPU: {reason}. Click for System Info. Desenat pe CPU: {reason}. Faceți clic pentru informații despre sistem. + Erase with Brush or Pencil (hold) Ștergere cu Pensula sau Creionul (țineți apăsat) + Export As: {name} Export ca: {name} + Fill with Background Color Umplere cu culoarea de fundal + Fill with Background Color, Preserve Transparency Umplere cu culoarea de fundal, conservare transparență + Fill with Foreground Color Umplere cu culoarea frontală + Fill with Foreground Color, Preserve Transparency Umplere cu culoarea frontală, conservare transparență + Forward Înainte + Gray-gamma-2.2 Gri gamma 2,2 + Hand Tool (hold) Instrument Mână (țineți apăsat) + Horizontal Distortion Distorsiune orizontală + Increase Type Size Creștere dimensiune text + Increase Type Size by 10 Creștere dimensiune text cu 10 + Intent Intenție + Level: Nivel: + Linear-srgb sRGB liniar + Make Selection from Path Creare selecție din traseu + Mask Channel Canal mască + Move Down Deplasare jos + Move Up Deplasare sus + Nearest Cel mai apropiat + Pattern Fill… Umplere cu model… + Percentile: Percentilă: + Proof Setup… Configurare probă… + Prophoto-compat Compatibil ProPhoto + R G B R G B + Release Notes Note de lansare + Show Channels in Color Afișare canale în culoare + Show Statistics Afișare statistici + Simulate Paper Simulare hârtie + Source Channel Canal sursă + Source Invert Inversare sursă + Source1 Channel Canal sursă 1 + Source1 Invert Inversare sursă 1 + Source2 Channel Canal sursă 2 + Source2 Invert Inversare sursă 2 + Source: Sursă: + Space Spațiu + Target Channel 3 Canal destinație 3 + Target Channel 4 Canal destinație 4 + Target Channel 5 Canal destinație 5 + Target Channel 6 Canal destinație 6 + Target Channel 7 Canal destinație 7 + Target Channel 8 Canal destinație 8 + Target Channel 9 Canal destinație 9 + Target Composite Channel Canal compus destinație + Temporary Temporar + This Layer: Acest strat: + Title Titlu + Toggle Layer Mask Overlay Comutare suprapunere mască de strat + Uncached Refresh Reîmprospătare fără cache + Update and Restart Actualizare și repornire + Vertical Distortion Distorsiune verticală + W: L: + Warming Filter (85) Filtru de încălzire (85) + Whole Word Cuvânt întreg + Working-cmyk CMYK de lucru + Zoom In (hold, drag to scrub) Mărire (țineți apăsat, trageți pentru a derula) + Zoom Out (hold) Micșorare (țineți apăsat) + +# Color picker, gradient map and font list strings added on main + No matching fonts Niciun font corespunzător + Recently Used Colors Culori utilizate recent + Shapes can't use patterns yet Formele nu pot folosi încă modele + Stop Color Culoarea marcajului + +# Strings added on main after the catalog was completed (2026-11-06) + A command was cancelled or stopped during batch processing. Continue with the next file or stop? O comandă a fost anulată sau oprită în timpul procesării în lot. Continuați cu următorul fișier sau opriți? + A file is still being placed; try again when it has finished. Un fișier este încă în curs de plasare; încercați din nou după ce s-a terminat. + A frame supports affine transforms; select its image for perspective transforms Un cadru acceptă transformări afine; selectați imaginea acestuia pentru transformări de perspectivă + A frame supports affine transforms; select its image to warp it Un cadru acceptă transformări afine; selectați imaginea acestuia pentru a o deforma + Accelerated Accelerat + Answer the open import question first, then import this file again. Răspundeți mai întâi la întrebarea de import deschisă, apoi importați din nou acest fișier. + Arrowhead options Opțiuni vârfuri de săgeată + Arrowheads Vârfuri de săgeată + Attribute Atribut + Background (default) Fundal (implicit) + Batch preview Previzualizare lot + Cancelled placing {name} Plasarea {name} a fost anulată + Channel Options… Opțiuni canal… + Concavity Concavitate + Convert to Frame… Conversie în cadru… + Create a new custom frame Creare cadru personalizat nou + Create a new polygonal frame Creare cadru poligonal nou + Create a new triangular frame Creare cadru triunghiular nou + Create a single history state Creare o singură stare de istoric + Data Sets as Files is unavailable on the web Seturi de date ca fișiere nu este disponibil pe web + Dropped files weren't placed: their document was closed. Fișierele plasate prin tragere nu au fost plasate: documentul lor a fost închis. + Dropped files weren't placed: too many are waiting for their document. Fișierele plasate prin tragere nu au fost plasate: prea multe așteaptă după documentul lor. + Effect Efect + Empty Gol + End Sfârșit + Filter for embedded smart objects Filtrare după obiecte inteligente încorporate + Filter for linked smart objects that are missing Filtrare după obiecte inteligente conectate care lipsesc + Filter for smart objects linked locally Filtrare după obiecte inteligente conectate local + Fit Content to Frame Încadrare conținut în cadru + Fit Frame to Content Încadrare cadru la conținut + Frame coordinates must be finite and within 600000 pixels Coordonatele cadrului trebuie să fie finite și în limita a 600000 de pixeli + High Memory Photoshop Document Document Photoshop cu memorie mare + Image Compression Compresie imagine + Import Image… Import imagine… + Import as New Group Import ca grup nou + Import as New Group keeps “{group}” and adds the file as a new group beside it. Import ca grup nou păstrează „{group}” și adaugă fișierul ca grup nou alături de acesta. + Invalid frame transform Transformare de cadru nevalidă + Invalid nested file drop Plasare imbricată de fișiere nevalidă + LZW LZW + Layer Effects Efecte de strat + Learn Învățare + Lining Aliniate + Linked Conectat + New Frame Cadru nou + New file Fișier nou + No Adv. Blending Fără fuziune avansată + No Layer Effects Fără efecte de strat + No Layer Mask Fără mască de strat + No Vector Mask Fără mască vectorială + No import is waiting. Import the file again. Niciun import nu este în așteptare. Importați din nou fișierul. + Not Clipped Netăiat + Not Empty Nu este gol + Not Linked Neconectat + Not Locked Neblocat + Not Visible Invizibil + Numerals Cifre + On your computer Pe computerul dvs. + Open anyway Deschidere oricum + Opening this document uses more memory and may slow down your computer or other running applications. Deschiderea acestui document folosește mai multă memorie și poate încetini computerul sau alte aplicații în execuție. + Opening “{name}” requires an estimated {est} GiB of memory ({avail} GiB available). Deschiderea „{name}” necesită aproximativ {est} GiB de memorie ({avail} GiB disponibili). + Opening “{name}” requires an estimated {est} GiB of memory. The available memory could not be measured. Deschiderea „{name}” necesită aproximativ {est} GiB de memorie. Memoria disponibilă nu a putut fi măsurată. + Pause For Pauză de + Pause For: Pauză de: + Playback Options… Opțiuni de redare… + Proportional Proporționale + Proportional Lining Proporționale aliniate + Proportional Oldstyle Proporționale stil vechi + Replace Image… Înlocuire imagine… + Replace deletes everything in “{group}” first, including presets you edited or moved there. This can't be undone. Înlocuirea șterge mai întâi tot conținutul din „{group}”, inclusiv presetările pe care le-ați editat sau mutat acolo. Această acțiune nu poate fi anulată. + Save Image Pyramid Salvare piramidă de imagini + Save Transparency Salvare transparență + Set Set + Set 1 Set 1 + Set 10 Set 10 + Set 11 Set 11 + Set 12 Set 12 + Set 13 Set 13 + Set 14 Set 14 + Set 15 Set 15 + Set 16 Set 16 + Set 17 Set 17 + Set 18 Set 18 + Set 19 Set 19 + Set 2 Set 2 + Set 20 Set 20 + Set 3 Set 3 + Set 4 Set 4 + Set 5 Set 5 + Set 6 Set 6 + Set 7 Set 7 + Set 8 Set 8 + Set 9 Set 9 + Set as default Setare ca implicit + Source: {name} Sursă: {name} + Specify Channels: Specificare canale: + Start Început + Step by Step Pas cu pas + Stylistic Sets Seturi stilistice + Tabular Tabelare + Tabular Lining Tabelare aliniate + Tabular Oldstyle Tabelare stil vechi + The GPU compositor is still being prepared Compozitorul GPU este încă în curs de pregătire + The History Brush source was deleted: set a new source in the History panel Sursa Pensulei istoric a fost ștearsă: setați o sursă nouă în panoul Istoric + The frame is locked Cadrul este blocat + The frame is unavailable Cadrul nu este disponibil + Transparent (default) Transparent (implicit) + Use these settings for new arrows Utilizare aceste setări pentru săgețile noi + Website Site web + ZIP ZIP + seconds secunde + {message} into “{group}” {message} în „{group}” + {ok} written to {dir} {ok} scrise în {dir} + {set} from {file} replaced the one from {earlier} Setul {set} din {file} l-a înlocuit pe cel din {earlier} +@plural The brush group “{group}” already has {n} brush of yours.|The brush group “{group}” already has {n} brushes of yours. Grupul de pensule „{group}” are deja {n} pensulă de-a dvs.|Grupul de pensule „{group}” are deja {n} pensule de-ale dvs.|Grupul de pensule „{group}” are deja {n} de pensule de-ale dvs. +@plural The gradient group “{group}” already exists ({n} gradient).|The gradient group “{group}” already exists ({n} gradients). Grupul de degradeuri „{group}” există deja ({n} degrade).|Grupul de degradeuri „{group}” există deja ({n} degradeuri).|Grupul de degradeuri „{group}” există deja ({n} de degradeuri). +@plural {n} action file loaded|{n} action files loaded {n} fișier de acțiuni încărcat|{n} fișiere de acțiuni încărcate|{n} de fișiere de acțiuni încărcate +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fișier nu a putut fi încărcat: {errors}|{n} fișiere nu au putut fi încărcate: {errors}|{n} de fișiere nu au putut fi încărcate: {errors} +@plural {what}: {n} Image Assets output failed|{what}: {n} Image Assets outputs failed {what}: {n} ieșire Resurse imagine a eșuat|{what}: {n} ieșiri Resurse imagine au eșuat|{what}: {n} de ieșiri Resurse imagine au eșuat diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 60f3929e5f..65575eaf9a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -272,6 +272,7 @@ Right Правый Open Recent Открыть недавние Clear Recent Files Очистить список недавних файлов + Clear Recent File List Очистить список недавних файлов Remove from Recent Удалить из недавних Remove {name} from Recent Удалить {name} из недавних Close Закрыть @@ -280,6 +281,10 @@ Restore Свернуть в окно Close All Закрыть все Close Others Закрыть остальные + New Document… Новый документ… + Open Document… Открыть… + Consolidate All to Here Объединить всё здесь + Move to New Window Переместить в новое окно Save Сохранить Save As… Сохранить как… Save a Copy… Сохранить копию… @@ -292,7 +297,6 @@ Export As… Экспортировать как… Export Экспорт Export… Экспортировать… - Quick Export As PNG Быстрый экспорт в PNG Quick Export as PNG Быстрый экспорт в PNG Place Embedded… Поместить встроенный… Place Linked… Поместить связанный… @@ -486,7 +490,6 @@ All Всё Deselect Отменить выделение Reselect Повторить выделение - Inverse Selection Инвертировать выделение Inverse Инверсия Color Range… Цветовой диапазон… Select and Mask… Выделение и маска… @@ -676,7 +679,6 @@ Match Location Согласовать положение Match Rotation Согласовать поворот Match All Согласовать всё - New Window for Document Новое окно документа Workspace Рабочая среда Extensions Расширения Panels Панели @@ -706,6 +708,12 @@ Timeline Шкала времени Tool Presets Наборы инструментов Tools Инструменты + Contextual Task Bar Контекстная панель задач + Hide bar Скрыть панель + Reset bar position Сбросить положение панели + Pin bar position Закрепить положение панели + Move bar Переместить панель + More options Другие параметры Libraries Библиотеки 3D 3D Toolbars Панели инструментов @@ -760,8 +768,8 @@ Background Фон Set foreground color Основной цвет Set background color Цвет фона - Swap colours (X) Поменять цвета (X) - Default colours (D) Цвета по умолчанию (D) + Switch Foreground and Background Colors (X) Поменять цвета (X) + Default Foreground and Background Colors (D) Цвета по умолчанию (D) Brush Tool Options Параметры кисти Opacity Непрозрачность Flow Нажим @@ -846,6 +854,7 @@ Units & Rulers… Единицы и линейки… Guides, Grid & Slices… Направляющие, сетка и фрагменты… Plug-ins… Плагины… + Plugins… Плагины… Technology Previews Предпросмотр технологий Scratch Disks… Рабочие диски… Export Preferences… Экспорт настроек… @@ -987,6 +996,8 @@ Match Color… Подбор цвета… Replace Color… Замена цвета… Equalize Выравнивание + Equalize Selected Area Only Выровнять только выделенную область + Equalize Entire Image Based On Selected Area Выровнять всё изображение по выделенной области HDR Toning… HDR-тонирование… Shadows/Highlights… Тени/Света… Amount Эффект @@ -1068,8 +1079,8 @@ ACES ACES Industry Standard Отраслевой стандарт Web Web - Internet Standard RGB Стандартный RGB для Интернета - Legacy Macintosh RGB Устаревший RGB Macintosh + Internet Standard RGB (sRGB) Стандартный RGB для Интернета (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Устаревший RGB Macintosh (Gamma 1.8) Monitor RGB Монитор RGB Windows LCD Windows LCD Coated CMYK Мелованная бумага CMYK @@ -1341,6 +1352,7 @@ Path to Illustrator… Контур в Illustrator… Make Work Path Создать рабочий контур Make Selection Создать выделение + Make Shape Создать фигуру Fill Path Залить контур Stroke Path Обвести контур Clipping Path Обтравочный контур @@ -1355,6 +1367,7 @@ Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Щелчок: выбрать узел · Перетаскивание: переместить узел, маркер или сегмент · {shift}-щелчок: добавить · {alt}-щелчок: весь подконтур Draw with the Pen tool (P) or make a work path from a selection. Нарисуйте пером (P) или создайте рабочий контур из выделения. Source: not set ({key}-click with Clone Stamp) Источник: не задан ({key}-клик штампом) + Source: {name} Источник: {name} Clone source {n} ({key}-click with Clone Stamp to set) Источник клонирования {n} ({key}-клик штампом, чтобы задать) {key}-click to set the source {key}-клик, чтобы задать источник Option-click to define a source point to clone from Option-клик, чтобы задать точку источника клонирования @@ -1712,7 +1725,7 @@ Revert “{name}” to the last saved version? Your changes will be lost. Вернуть «{name}» к последней сохранённой версии? Изменения будут потеряны. {key} to subtract {key} — вычесть ↑↓ navigate {key} run esc close ↑↓ переход {key} выполнить esc закрыть - Click to restore the current colour Щёлкните, чтобы восстановить текущий цвет + Click to restore the current color Щёлкните, чтобы восстановить текущий цвет Click with the Note tool to add a note. Щёлкните инструментом «Заметки», чтобы добавить заметку. Drag a rectangle around the object Обведите объект прямоугольником Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Перетащите по изображению, чтобы добавить ограничение; Shift — горизонталь/вертикаль; щелчок правой кнопкой по ограничению — изменить или удалить. @@ -1736,6 +1749,7 @@ Move Tool Перемещение Could not use the move tool because the layer is locked. Не удалось использовать инструмент «Перемещение», потому что слой заблокирован. Could not complete the Free Transform command because the layer is locked. Не удалось выполнить команду «Свободное трансформирование», потому что слой заблокирован. + Could not transform because the layer is locked. Не удалось выполнить трансформирование, потому что слой заблокирован. Rectangular Marquee Tool Прямоугольная область Elliptical Marquee Tool Овальная область Single Row Marquee Tool Область (горизонтальная строка) @@ -1793,10 +1807,10 @@ Drag to move the active layer Перетащите, чтобы переместить активный слой Drag to pan · hold Space with any tool Перетащите для панорамирования · удерживайте Space с любым инструментом Drag to draw a gradient Перетащите, чтобы нарисовать градиент - Click to fill similar colours Щёлкните, чтобы залить похожие цвета + Click to fill similar colors Щёлкните, чтобы залить похожие цвета Click to add text Щёлкните, чтобы добавить текст Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Перетащите (лассо) или щёлкайте точки (многоугольное) · {add} добавить · {sub} вычесть - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Выделите рамку кадрирования · перетащите внутри для перемещения · края меняют размер ({ratio} пропорции, {centre} центр) · Space двигает при рисовании · стрелки сдвигают · X меняет ориентацию · {commit} применить · Esc отмена + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Выделите рамку кадрирования · перетащите внутри для перемещения · края меняют размер ({ratio} пропорции, {centre} центр) · Space двигает при рисовании · стрелки сдвигают · X меняет ориентацию · {commit} применить · Esc отмена {mode} Color · {bits} bit {mode}, {bits} бит Drag to move the layer Перетащите, чтобы переместить слой Fit Screen По размеру экрана @@ -1828,7 +1842,6 @@ Appearance Внешний вид Text Position: Положение текста: Text color Цвет текста - Uses the foreground and background colours. Использует основной цвет и цвет фона. Answer the unsaved-changes prompt first Сначала ответьте на запрос о несохранённых изменениях Unsaved changes Несохранённые изменения Don't Save Не сохранять @@ -1848,10 +1861,11 @@ name имя new новый Go to Layer Перейти к слою - Open a document to select a colour range. Откройте документ, чтобы выбрать цветовой диапазон. + Open a document to select a color range. Откройте документ, чтобы выбрать цветовой диапазон. Saved Сохранено Reveal in Finder Показать в Finder Show in Explorer Показать в Проводнике + Reveal in Explorer Показать в Проводнике Show in Folder Показать в папке Before (Y) До (Y) Save Settings… Сохранить настройки… @@ -2011,12 +2025,14 @@ Character Styles Panel Панель «Стили символов» Clear Canvas Guides Удалить направляющие холста Clear Selected Artboard Guides Удалить направляющие выбранной области - Color Blindness — Deuteranopia-type Цветовая слепота — дейтеранопия - Color Blindness — Protanopia-type Цветовая слепота — протанопия + Color Blindness - Deuteranopia-type Цветовая слепота - дейтеранопия + Color Blindness - Protanopia-type Цветовая слепота - протанопия Color Table… Цветовая таблица… Combine Shapes Комбинировать фигуры Contract… Сжать… Convert to Embedded Преобразовать во встроенный + Embed Linked Встроить связанный + Embed All Linked Встроить все связанные Convert to Layers Преобразовать в слои Convert to Linked… Преобразовать в связанный… Count Счёт @@ -2105,6 +2121,7 @@ Pixelate Оформление пикселями Place Scale Marker… Шкала масштаба… Plug-ins Плагины + Plugins Плагины Print One Copy Печать одной копии Print… Печать… Range Диапазон @@ -2149,7 +2166,7 @@ Vertical Centers Центры по вертикали Video Frames to Layers… Видеокадры в слои… WIA Support… Поддержка WIA… - Working CMY Plate Рабочая голубая форма + Working CMY Plates Рабочие формы CMY Absolute Абсолютный Action Операция Adaptive Адаптивный @@ -2182,7 +2199,7 @@ Canvas Холст Capture Снять Center Crop Marks Центральные метки кадрирования - Centre the gradient (offset 0, 0) Центрировать градиент (смещение 0, 0) + Center the gradient (offset 0, 0) Центрировать градиент (смещение 0, 0) Change Screen Mode (F) Сменить режим экрана (F) Change To: Изменить на: Clear All Очистить всё @@ -2334,7 +2351,7 @@ Roughness Шероховатость Roundness Круглость Rulers & Grids Линейки и сетка - Sample: foreground colour Выборка: основной цвет + Sample: foreground color Выборка: основной цвет Sampling: Background Swatch Выборка: фоновый образец Sampling: Continuous Выборка: непрерывная Sampling: Once Выборка: однократная @@ -2409,6 +2426,7 @@ Allow scripts to connect Разрешить подключение сценариев Always Всегда Always create smart objects when placing Всегда создавать смарт-объекты при помещении + Skip transform when placing Пропускать трансформирование при помещении Animated zoom Анимированное масштабирование Apply auto tone Применять автотон Ask Спрашивать @@ -2430,6 +2448,11 @@ Show canvas dots Показывать точки холста Clip thumbnails to layer bounds Обрезать миниатюры по границам слоя Clip Thumbnails to Layer Bounds Обрезать миниатюры по границам слоя + No Thumbnails Без миниатюр + Small Thumbnails Мелкие миниатюры + Medium Thumbnails Средние миниатюры + Large Thumbnails Крупные миниатюры + Clip Thumbnails to Document Bounds Обрезать миниатюры по границам документа Canvas color Цвет холста Canvas custom color Свой цвет холста Classic Классическая @@ -2556,7 +2579,6 @@ Show only crosshair while painting Только перекрестье при рисовании Show slice numbers Показывать номера фрагментов Show tooltips Показывать подсказки - Enable animations Включить анимацию Show highlight on rollover Показывать подсветку при наведении Show bounding box when dragging layer Показывать рамку при перетаскивании слоя System title bar Системная строка заголовка @@ -2600,7 +2622,7 @@ Alt-click to define a source point to clone from Alt+щелчок, чтобы задать точку-источник клонирования No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Нет контура для создания выделения: нарисуйте его инструментом «Перо» или выберите на панели «Контуры». Cancel opening Отменить открытие - Cancelled opening {name} Открытие {name} отменено + Canceled opening {name} Открытие {name} отменено Damaged Photoshop document Повреждённый документ Photoshop The document “{name}” may be damaged. Документ «{name}», возможно, повреждён. Some layers, pixels or masks could not be read. Do you want to continue opening it? Не удалось прочитать некоторые слои, пикселы или маски. Продолжить открытие? @@ -2610,7 +2632,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Встроенный цветовой профиль документа «{name}» не совпадает с текущим рабочим пространством {mode}. Working: {working} Рабочее пространство: {working} Developing {name} Обработка {name} - Cancelled {label} {label}: отменено + Canceled {label} {label}: отменено Esc to cancel Esc — отмена Opened {name} Открыто: {name} Opening {name} Открытие {name} @@ -2618,7 +2640,7 @@ Saving {name} Сохранение {name} Saving {percent} Сохранение {percent} A save to {name} is still running; try again when it finishes Сохранение в {name} ещё выполняется; повторите попытку после его завершения - {label} is writing the file and can no longer be cancelled {label}: идёт запись файла, отменить уже нельзя + {label} is writing the file and can no longer be canceled {label}: идёт запись файла, отменить уже нельзя Opening {name}… Открытие {name}… Opening… Открытие… Reading… Чтение… @@ -2710,7 +2732,7 @@ cameraRaw Roughness Неровность Discard Layers and Save a Copy Удалить слои и сохранить копию Don't show again Больше не показывать Save failed: {error} Не удалось сохранить: {error} - Save cancelled Сохранение отменено + Save canceled Сохранение отменено Couldn't place {name}: {error} Не удалось поместить {name}: {error} Magnetic Lasso Tool Магнитное лассо Frequency Частота @@ -2841,7 +2863,7 @@ fontWeight Italic Курсив Impressionist Импрессионист Rotate View Tool Инструмент поворота вида Reset View Сбросить вид - Drag around the centre to rotate the view · Shift constrains to 15° Перетащите вокруг центра, чтобы повернуть вид · Shift фиксирует шаг 15° + Drag around the center to rotate the view · Shift constrains to 15° Перетащите вокруг центра, чтобы повернуть вид · Shift фиксирует шаг 15° Dual Axis Две оси Diagonal Диагональная Transform Symmetry Трансформировать симметрию @@ -2857,9 +2879,9 @@ fontWeight Italic Курсив All Layers No Adjustments Все слои без коррекции Current & Below No Adjustments Текущий и ниже без коррекции Show Sampling Ring Показать кольцо выборки - Click to sample the foreground colour · {key}-click for background Клик — взять основной цвет · {key}-клик — фоновый цвет - Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет - Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет + Click to sample the foreground color · {key}-click for background Клик — взять основной цвет · {key}-клик — фоновый цвет + Out of gamut for printing: click for the nearest printable color Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет + Not a web color: click for the nearest web color Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет Color to Alpha… Цвет в альфа-канал… Transparency Threshold Порог прозрачности Opacity Threshold Порог непрозрачности @@ -3245,12 +3267,14 @@ curves Smooth Сгладить Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций Background (Default) Фон (по умолчанию) + Background (default) Фон (по умолчанию) + Transparent (default) Прозрачный (по умолчанию) Content-Aware Fill Заливка с учётом содержимого Include All Subfolders Включить все вложенные папки Auto Add/Delete Автодобавление/удаление Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не включайте также зеркальное отражение в параметрах печати: два отражения отменяют друг друга. Print Settings… Параметры печати… - Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… + Remember to turn off the printer's own color management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… Sent to {printer} Отправлено на {printer} set in Print Settings… задаётся в параметрах печати… ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей @@ -3267,6 +3291,7 @@ curves Smooth Сгладить Shadow Tint Оттенок теней Paints from the History panel's source state Рисует из исходного состояния панели «История» Sets the source for the history brush Задаёт источник для кисти истории + The History Brush source was deleted: set a new source in the History panel Источник кисти истории был удалён: задайте новый источник на панели «История» Select Inverse Инвертировать выделение Pro (Light) Pro (светлая) Pro (Light Gray) Pro (светло-серая) @@ -3893,12 +3918,12 @@ curves Smooth Сгладить In White Входной белый Increase Brush Hardness Увеличить жёсткость кисти Demosaic Демозаик - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Как восстанавливается мозаика цветного фильтра камеры. Применяется при открытии файла; превью показывает метод, с которым файл был открыт. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Как восстанавливается мозаика цветного фильтра камеры. Применяется при открытии файла; превью показывает метод, с которым файл был открыт. AHD (adaptive homogeneity) AHD (адаптивная однородность) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Хиракава–Паркс: самые чистые края и меньше всего цветной каймы, самый медленный. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Интерполяция с коррекцией градиента: резкая и быстрая, с небольшой цветной каймой на мелких деталях. - The fastest and the softest, with colour fringing on fine detail. Самый быстрый и самый мягкий, с цветной каймой на мелких деталях. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Хиракава–Паркс: самые чистые края и меньше всего цветной каймы, самый медленный. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Интерполяция с коррекцией градиента: резкая и быстрая, с небольшой цветной каймой на мелких деталях. + The fastest and the softest, with color fringing on fine detail. Самый быстрый и самый мягкий, с цветной каймой на мелких деталях. “{name}” already exists. Replace it? «{name}» уже существует. Заменить? The name in the save dialog was “{name}”. В окне сохранения было указано имя «{name}». Previous Brush Предыдущая кисть @@ -4694,6 +4719,8 @@ shape Appearance Оформление Font size presets Наборы размеров шрифта Leading presets Наборы интерлиньяжа Tracking presets Наборы трекинга + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Недопустимое числовое значение. Требуется целое число от {min}% до {max}%. Вставлено предыдущее значение. Color Aware С учетом цвета Decontaminate Colors Очистить цвета @@ -4717,3 +4744,71 @@ shape Appearance Оформление Stop Color Цвет контрольной точки Shapes can't use patterns yet Фигуры пока не могут использовать узоры Edit Brush… Изменить кисть… + +# Action Stop message (#8386) + Message Сообщение +# Playback Options (#5212) + Playback Options… Параметры воспроизведения… + Playback Options Параметры воспроизведения + Accelerated Ускоренное + Step by Step Пошаговое + Pause For: Пауза на: + Pause For Пауза на + seconds с + Create a single history state Создавать одно состояние истории + Drag to move the active shape's path or the Work Path Перетащите, чтобы переместить контур активной фигуры или рабочий контур + Generator enabled Генератор включён + Hidden Скрыто + Move Up Переместить вверх + Move Down Переместить вниз + On your computer На вашем компьютере + New file Новый файл + Learn Обучение + Website Сайт + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Рамка поддерживает аффинные преобразования; для перспективных преобразований выберите её изображение + A frame supports affine transforms; select its image to warp it Рамка поддерживает аффинные преобразования; для деформации выберите её изображение + Convert to Frame… Преобразовать в рамку… + Create a new custom frame Создать новую произвольную рамку + Create a new polygonal frame Создать новую многоугольную рамку + Create a new triangular frame Создать новую треугольную рамку + Fit Content to Frame Подогнать содержимое под рамку + Fit Frame to Content Подогнать рамку под содержимое + Frame coordinates must be finite and within 600000 pixels Координаты рамки должны быть конечными и находиться в пределах 600000 пикселей + Import Image… Импортировать изображение… + Invalid frame transform Недопустимое преобразование рамки + Invalid nested file drop Недопустимое вложенное перетаскивание файла + New Frame Новая рамка + Replace Image… Заменить изображение… + The frame is locked Рамка заблокирована + The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + Channel Options… Параметры канала… + Specify Channels: Указать каналы: + Not enough grayscale documents for this mode. Недостаточно документов в градациях серого для этого режима. + Pick a different document for each channel. Выберите отдельный документ для каждого канала. + A picked document is no longer open. Один из выбранных документов больше не открыт. + A picked document is no longer a grayscale document of the same size. Один из выбранных документов больше не является документом в градациях серого того же размера. +# Move tool options bar (#11381) + Align top edges Выровнять верхние края + Align vertical centers Выровнять центры по вертикали + Align bottom edges Выровнять нижние края + Distribute vertically Распределить по вертикали + Distribute horizontally Распределить по горизонтали + Show layer bounds on hover Показывать границы слоя при наведении + Expand layer group on click Раскрывать группу слоёв по щелчку + Show hover bounds from Layers Показывать границы при наведении из панели «Слои» + Saved Camera Raw settings to {file} Настройки Camera Raw сохранены в {file} + Save the settings to each raw's sidecar without opening it Сохранить настройки в сопроводительный файл каждого raw, не открывая его + No settings saved: these raws can't keep a sidecar here Ничего не сохранено: у этих raw здесь не может быть сопроводительного файла + Some settings could not be saved; the raws stay open Не удалось сохранить некоторые настройки; raw остаются открытыми + Nothing to save: these raws are at their default settings Нечего сохранять: у этих raw настройки по умолчанию +# Artboard tool options bar (#11385) + Distribute Widths Распределить ширину + Distribute Heights Распределить высоту + Auto-nest Layers Автоматически вкладывать слои + Auto-size Canvas Автоматически менять размер холста + Keep Relative Position During Layer Reordering Сохранять относительное положение при изменении порядка слоев + Shrinkwrap Canvas on Save Уменьшать холст до содержимого при сохранении diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2ebc6c2e70..b2c1d42cab 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -196,6 +196,7 @@ Right Desno Open Recent Otvori nedavno Clear Recent Files Obriši spisak nedavnih datoteka + Clear Recent File List Obriši spisak nedavnih datoteka Remove from Recent Ukloni iz nedavnih Remove {name} from Recent Ukloni {name} iz nedavnih Close Zatvori @@ -204,6 +205,10 @@ Restore Vrati Close All Zatvori sve Close Others Zatvori ostale + New Document… Novi dokument… + Open Document… Otvori… + Consolidate All to Here Objedini sve ovde + Move to New Window Premesti u novi prozor Save Sačuvaj Save As… Sačuvaj kao… Save a Copy… Sačuvaj kopiju… @@ -255,6 +260,7 @@ Create Smart Object after Loading Layers Napravi pametni objekat posle učitavanja slojeva Reveal in Finder Prikaži u Finderu Show in Explorer Prikaži u Exploreru + Reveal in Explorer Prikaži u Exploreru Show in Folder Prikaži u fascikli File Info… Informacije o datoteci… Print… Štampaj… @@ -339,6 +345,7 @@ Units & Rulers… Jedinice i lenjiri… Guides, Grid & Slices… Vodilje, mreža i isečci… Plug-ins… Dodaci… + Plugins… Dodaci… Type… Tekst… Enhanced Controls… Napredne kontrole… Camera Raw… Camera Raw… @@ -387,6 +394,8 @@ Match Color… Uskladi boje… Replace Color… Zameni boju… Equalize Izjednači + Equalize Selected Area Only Izjednači samo selekciju + Equalize Entire Image Based On Selected Area Izjednači celu sliku prema selekciji Auto Tone Automatski ton Auto Contrast Automatski kontrast Auto Color Automatske boje @@ -487,6 +496,8 @@ Smart Object Pametni objekat Convert to Smart Object Pretvori u pametni objekat Convert to Embedded Pretvori u ugrađeni + Embed Linked Ugradi povezani + Embed All Linked Ugradi sve povezane Convert to Layers Pretvori u slojeve Convert to Linked… Pretvori u povezani… Edit Contents Uredi sadržaj @@ -631,7 +642,6 @@ Deselect Poništi selekciju Reselect Ponovo izaberi Inverse Inverzno - Inverse Selection Obrni selekciju Similar Slično Color Range… Opseg boja… Focus Area… Oblast fokusa… @@ -738,6 +748,7 @@ Minimum… Minimum… Offset… Pomak… Plug-ins Dodaci + Plugins Dodaci Proof Setup Podešavanje probnog prikaza Proof Colors Probne boje Gamut Warning Upozorenje o gamutu @@ -746,12 +757,12 @@ Working Magenta Plate Radna magenta ploča Working Yellow Plate Radna žuta ploča Working Black Plate Radna crna ploča - Working CMY Plate Radna CMY ploča - Legacy Macintosh RGB Stari Macintosh RGB - Internet Standard RGB Internet standard RGB + Working CMY Plates Radne CMY ploče + Legacy Macintosh RGB (Gamma 1.8) Stari Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internet standard RGB (sRGB) Monitor RGB RGB monitora - Color Blindness — Protanopia-type Daltonizam — tip protanopije - Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije + Color Blindness - Protanopia-type Daltonizam - tip protanopije + Color Blindness - Deuteranopia-type Daltonizam - tip deuteranopije Zoom In Uvećaj prikaz Zoom Out Umanji prikaz Fit on Screen Uklopi u ekran @@ -840,7 +851,6 @@ Consolidate All to Tabs Objedini sve u kartice Float in Window Odvoji u prozor Float All in Windows Odvoji sve u prozore - New Window for Document Novi prozor za dokument Workspace Radni prostor Essentials Osnovno Graphic and Web Grafika i veb @@ -902,6 +912,12 @@ Timeline Vremenska linija Tool Presets Šabloni alatki Tools Alatke + Contextual Task Bar Kontekstualna traka zadataka + Hide bar Sakrij traku + Reset bar position Vrati položaj trake + Pin bar position Zakači položaj trake + Move bar Pomeri traku + More options Više opcija About PhotoCraft O programu PhotoCraft PhotoCraft Website Veb-sajt PhotoCraft-a PhotoCraft on GitHub PhotoCraft na GitHub-u @@ -1058,7 +1074,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Kliknite da dodate tačku · prevucite van, {key}-klik ili Delete da uklonite Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknite da primenite, {key}-klik da uklonite nadjačavanja, dvoklik za opcije stila Click to edit the gradient Kliknite da uredite gradijent - Click to restore the current colour Kliknite da vratite tekuću boju + Click to restore the current color Kliknite da vratite tekuću boju Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknite da uvećate · {key}-klik da umanjite · prevucite udesno/ulevo da uvećate/umanjite Click with the Note tool to add a note. Kliknite alatkom za beleške da dodate belešku. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · {key} završi · Esc otkaži @@ -1138,7 +1154,7 @@ Darks Tamni tonovi Data Sets Skupovi podataka Default Podrazumevano - Default colours (D) Podrazumevane boje (D) + Default Foreground and Background Colors (D) Podrazumevane boje prednjeg plana i pozadine (D) Default printer Podrazumevani štampač Default restored. Podrazumevano stanje je vraćeno. Define Definiši @@ -1412,7 +1428,7 @@ Opacity Jitter Kolebanje neprozirnosti Opacity: Neprozirnost: Open a document to define variables. Otvorite dokument da biste definisali promenljive. - Open a document to select a colour range. Otvorite dokument da biste izabrali opseg boja. + Open a document to select a color range. Otvorite dokument da biste izabrali opseg boja. Open a document to use the timeline. Otvorite dokument da biste koristili vremensku liniju. Optical Optički Optimized Optimizovano @@ -1488,7 +1504,6 @@ Purity Čistoća Quality Kvalitet Quick Actions Brze radnje - Quick Export As PNG Brzi izvoz u PNG Radial Gradient Radijalni gradijent Radius Poluprečnik Radius: Poluprečnik: @@ -1629,7 +1644,7 @@ Subtract from Sample Oduzmi od uzorka Subtract from selection ({key}) Oduzmi od selekcije ({key}) Suggestions: Predlozi: - Swap colours (X) Zameni boje (X) + Switch Foreground and Background Colors (X) Zameni boje prednjeg plana i pozadine (X) Swaps height and width Zamenjuje visinu i širinu Switch between free transform and warp modes Prebaci između režima slobodne transformacije i krivljenja Switch theme Promeni temu @@ -1731,7 +1746,7 @@ Choke Sužavanje Classic Klasično Click to add text Kliknite da dodate tekst - Click to fill similar colours Kliknite da popunite slične boje + Click to fill similar colors Kliknite da popunite slične boje Clone Stamp Tool Alatka pečat za kloniranje Color Burn Zatamnjivanje boje Color Dodge Posvetljivanje boje @@ -1832,6 +1847,7 @@ Move Tool Alatka za pomeranje Could not use the move tool because the layer is locked. Alatka za pomeranje ne može da se koristi jer je sloj zaključan. Could not complete the Free Transform command because the layer is locked. Komanda Slobodna transformacija ne može da se izvrši jer je sloj zaključan. + Could not transform because the layer is locked. Transformacija nije moguća jer je sloj zaključan. Multiply Množenje My Brushes Moje četkice Neutrals Neutralne @@ -1850,6 +1866,7 @@ Paints from the document's opening state Slika iz stanja dokumenta pri otvaranju Paints from the History panel's source state Slika iz izvornog stanja panela Istorija Sets the source for the history brush Postavlja izvor za četkicu istorije + The History Brush source was deleted: set a new source in the History panel Izvor četkice istorije je izbrisan: postavite novi izvor u panelu Istorija Path Selection Tool Alatka za selekciju putanje Direct Selection Tool Alatka za direktnu selekciju Add Anchor Point Tool Alatka za dodavanje sidrišne tačke @@ -1892,7 +1909,7 @@ Rectangular Marquee Tool Alatka za pravougaonu selekciju Reds Crvene Reflected Reflektovano - Sample: foreground colour Uzorak: boja prednjeg plana + Sample: foreground color Uzorak: boja prednjeg plana Sampled Colors Uzorkovane boje Satin Saten Save Pixel Aspect Ratio Sačuvaj odnos stranica piksela @@ -1913,6 +1930,7 @@ Soften Omekšaj Source Izvor Source: set Izvor: postavljen + Source: {name} Izvor: {name} Sponge Tool Alatka sunđer Spot Healing Brush Tool Alatka četkica za tačkastu popravku Spread Raširenost @@ -1930,7 +1948,6 @@ Transfer Prenos Triangle Tool Alatka trougao Unsaved changes Nesačuvane izmene - Uses the foreground and background colours. Koristi boje prednjeg plana i pozadine. Web Veb Web Large Veb, veliko Web Minimum Veb, minimum @@ -1965,6 +1982,7 @@ Allow scripts to connect Dozvoli skriptama da se povežu Always Uvek Always create smart objects when placing Uvek pravi pametne objekte pri postavljanju + Skip transform when placing Preskoči transformaciju pri postavljanju Animated zoom Animirano zumiranje Apply auto tone Primeni automatski ton Ask Pitaj @@ -1987,6 +2005,11 @@ Show canvas dots Prikaži tačke na platnu Clip thumbnails to layer bounds Iseci sličice prema granicama sloja Clip Thumbnails to Layer Bounds Iseci sličice prema granicama sloja + No Thumbnails Bez sličica + Small Thumbnails Male sličice + Medium Thumbnails Srednje sličice + Large Thumbnails Velike sličice + Clip Thumbnails to Document Bounds Iseci sličice prema granicama dokumenta Canvas color Boja platna Canvas custom color Prilagođena boja platna Color picker Birač boja @@ -2104,7 +2127,6 @@ Show only crosshair while painting Prikaži samo krstić tokom slikanja Show slice numbers Prikaži brojeve isečaka Show tooltips Prikaži opise alatki - Enable animations Omogući animacije Show highlight on rollover Istakni pri prelasku pokazivačem Show transformation values Prikaži vrednosti transformacije Slice color Boja isečka @@ -2193,7 +2215,7 @@ Graphics Grafika Reset GPU Backend Resetuj GPU pozadinski sistem Applies at next launch. Primenjuje se pri sledećem pokretanju. - Click to sample the foreground colour · {key}-click for background Kliknite da uzorkujete boju prednjeg plana · {key}-klik za pozadinu + Click to sample the foreground color · {key}-click for background Kliknite da uzorkujete boju prednjeg plana · {key}-klik za pozadinu {key}-click to set the source {key}-klik da postavite izvor {key} to subtract {key} za oduzimanje Clone source {n} ({key}-click with Clone Stamp to set) Izvor kloniranja {n} ({key}-klik pečatom za kloniranje da postavite) @@ -2251,7 +2273,7 @@ Gradient Options Opcije gradijenta Align with layer Poravnaj sa slojem Reset Alignment Resetuj poravnanje - Centre the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) + Center the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) Gradient Editor Uređivač gradijenta Gradient picker Birač gradijenta Save As Preset Sačuvaj kao šablon @@ -2284,7 +2306,7 @@ Alt-click to define a source point to clone from Alt-klik da odredite izvornu tačku za kloniranje No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje. Cancel opening Otkaži otvaranje - Cancelled opening {name} Otkazano otvaranje: {name} + Canceled opening {name} Otkazano otvaranje: {name} Damaged Photoshop document Oštećen Photoshop dokument The document “{name}” may be damaged. Dokument „{name}“ je možda oštećen. Some layers, pixels or masks could not be read. Do you want to continue opening it? Neki slojevi, pikseli ili maske nisu mogli da se pročitaju. Želite li da nastavite sa otvaranjem? @@ -2293,7 +2315,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ ima ugrađeni profil boja koji se ne slaže sa trenutnim {mode} radnim prostorom. Working: {working} Radni prostor: {working} Developing {name} Razvijanje: {name} - Cancelled {label} Otkazano: {label} + Canceled {label} Otkazano: {label} Esc to cancel Esc za otkazivanje Opened {name} Otvoreno: {name} Opening {name} Otvaranje: {name} @@ -2301,7 +2323,7 @@ Saving {name} Čuvanje: {name} Saving {percent} Čuvanje {percent} A save to {name} is still running; try again when it finishes Čuvanje u {name} je još u toku; pokušajte ponovo kada se završi - {label} is writing the file and can no longer be cancelled {label}: datoteka se upisuje i više ne može da se otkaže + {label} is writing the file and can no longer be canceled {label}: datoteka se upisuje i više ne može da se otkaže Opening {name}… Otvaranje: {name}… Opening… Otvaranje… Reading… Čitanje… @@ -2494,6 +2516,7 @@ Path to Illustrator… Putanja u Illustrator… Make Work Path Napravi radnu putanju Make Selection Napravi selekciju + Make Shape Napravi oblik Fill Path Popuni putanju Stroke Path Ocrtaj putanju Clipping Path Putanja isecanja @@ -2675,7 +2698,7 @@ Pixel Piksel Adjustment Korekcija Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Prevucite (laso) ili kliknite tačke (mnogougao) · {add} dodaje · {sub} oduzima - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · strelice pomeraju malo · X menja orijentaciju · {commit} potvrđuje · Esc otkazuje + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · strelice pomeraju malo · X menja orijentaciju · {commit} potvrđuje · Esc otkazuje {mode} Color · {bits} bit {mode} boje · {bits} bita Drag to move the layer Prevucite da pomerite sloj Scrubby Zoom Zumiranje prevlačenjem @@ -2869,7 +2892,7 @@ cameraRaw Blending Stapanje Discard Layers and Save a Copy Odbaci slojeve i sačuvaj kopiju Don't show again Ne prikazuj ponovo Save failed: {error} Čuvanje nije uspelo: {error} - Save cancelled Čuvanje je otkazano + Save canceled Čuvanje je otkazano Couldn't place {name}: {error} Postavljanje {name} nije uspelo: {error} Magnetic Lasso Tool Alatka magnetni laso Frequency Frekvencija @@ -2994,7 +3017,7 @@ fontWeight Italic Kurziv Impressionist Impresionistički Rotate View Tool Alatka za rotiranje prikaza Reset View Resetuj prikaz - Drag around the centre to rotate the view · Shift constrains to 15° Prevlačite oko centra da rotirate prikaz · Shift ograničava na korake od 15° + Drag around the center to rotate the view · Shift constrains to 15° Prevlačite oko centra da rotirate prikaz · Shift ograničava na korake od 15° Dual Axis Dvostruka osa Diagonal Dijagonala Transform Symmetry Transformiši simetriju @@ -3009,8 +3032,8 @@ fontWeight Italic Kurziv All Layers No Adjustments Svi slojevi bez korekcija Current & Below No Adjustments Tekući i ispod bez korekcija Show Sampling Ring Prikaži prsten uzorkovanja - Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa - Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju + Out of gamut for printing: click for the nearest printable color Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa + Not a web color: click for the nearest web color Nije veb boja: kliknite za najbližu veb boju Color to Alpha… Boja u alfu… Transparency Threshold Prag providnosti Opacity Threshold Prag neprozirnosti @@ -3860,6 +3883,8 @@ layerLabel No Color Bez boje Color Table Tabela boja Conditional Mode Change Uslovna promena režima Background (Default) Pozadina (podrazumevano) + Background (default) Pozadina (podrazumevano) + Transparent (default) Providno (podrazumevano) Content-Aware Fill Popuna prema sadržaju Convert to Linked Pretvori u povezani Convert to Profile Pretvori u profil @@ -4310,7 +4335,7 @@ shape Appearance Izgled Auto Add/Delete Automatsko dodavanje/brisanje Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nemojte uključiti i ogledalsku sliku u podešavanjima štampanja: dva preslikavanja se poništavaju. Print Settings… Podešavanja štampanja… - Remember to turn off the printer's own colour management in Print Settings… Ne zaboravite da isključite upravljanje bojama samog štampača u podešavanjima štampanja… + Remember to turn off the printer's own color management in Print Settings… Ne zaboravite da isključite upravljanje bojama samog štampača u podešavanjima štampanja… Sent to {printer} Poslato na {printer} set in Print Settings… podešava se u podešavanjima štampanja… Paper Width Širina papira @@ -4483,12 +4508,12 @@ shape Appearance Izgled Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice Demosaic Demozaikovanje - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kako se popunjava mozaik filtera boja kamere. Primenjuje se pri otvaranju datoteke; pregled prikazuje metod kojim je datoteka otvorena. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kako se popunjava mozaik filtera boja kamere. Primenjuje se pri otvaranju datoteke; pregled prikazuje metod kojim je datoteka otvorena. AHD (adaptive homogeneity) AHD (adaptivna homogenost) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: najčistije ivice i najmanje obojenih rubova, najsporiji. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolacija sa korekcijom gradijenta: oštra i brza, sa malo obojenih rubova na finim detaljima. - The fastest and the softest, with colour fringing on fine detail. Najbrži i najmekši, sa obojenim rubovima na finim detaljima. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: najčistije ivice i najmanje obojenih rubova, najsporiji. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Interpolacija sa korekcijom gradijenta: oštra i brza, sa malo obojenih rubova na finim detaljima. + The fastest and the softest, with color fringing on fine detail. Najbrži i najmekši, sa obojenim rubovima na finim detaljima. “{name}” already exists. Replace it? „{name}“ već postoji. Zameniti? The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“. Previous Brush Prethodna četkica @@ -4574,6 +4599,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Unapred podešene veličine fonta Leading presets Unapred podešeni proredi Tracking presets Unapred podešeni razmaci slova + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neispravan numerički unos. Potreban je ceo broj između {min}% i {max}%. Umetnuta je prethodna vrednost. Color Aware Prema boji Decontaminate Colors Dekontaminiraj boje @@ -4599,3 +4626,70 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Boja tačke Shapes can't use patterns yet Oblici još ne mogu da koriste šare Edit Brush… Uredi četkicu… + +# Action Stop message (#8386) + Message Poruka +# Playback Options (#5212) + Playback Options… Opcije reprodukcije… + Playback Options Opcije reprodukcije + Accelerated Ubrzano + Step by Step Korak po korak + Pause For: Pauza od: + Pause For Pauza od + seconds sekundi + Create a single history state Napravi jedno stanje istorije + Generator enabled Generator je omogućen + This Layer: Ovaj sloj: + Move Up Pomeri nagore + Move Down Pomeri nadole + On your computer На вашем рачунару + New file Novi fajl + Learn Učenje + Website Veb-sajt + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Оквир подржава афине трансформације; за перспективне трансформације изаберите његову слику + A frame supports affine transforms; select its image to warp it Оквир подржава афине трансформације; за изобличавање изаберите његову слику + Convert to Frame… Претвори у оквир… + Create a new custom frame Направи нови прилагођени оквир + Create a new polygonal frame Направи нови многоугаони оквир + Create a new triangular frame Направи нови троугаони оквир + Fit Content to Frame Прилагоди садржај оквиру + Fit Frame to Content Прилагоди оквир садржају + Frame coordinates must be finite and within 600000 pixels Координате оквира морају бити коначне и унутар 600000 пиксела + Import Image… Увези слику… + Invalid frame transform Неважећа трансформација оквира + Invalid nested file drop Неважеће угнежђено испуштање датотеке + New Frame Нови оквир + Replace Image… Замени слику… + The frame is locked Оквир је закључан + The frame is unavailable Оквир није доступан + +# Channels panel menu (#11451) + Channel Options… Opcije kanala… + Specify Channels: Odredi kanale: + Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj režim. + Pick a different document for each channel. Izaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan izabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan izabrani dokument više nije dokument u nijansama sive iste veličine. +# Move tool options bar (#11381) + Align top edges Poravnaj gornje ivice + Align vertical centers Poravnaj vertikalne centre + Align bottom edges Poravnaj donje ivice + Distribute vertically Raspodeli vertikalno + Distribute horizontally Raspodeli horizontalno + Show layer bounds on hover Prikaži granice sloja pri prelasku miša + Expand layer group on click Proširi grupu slojeva klikom + Show hover bounds from Layers Prikaži granice pri prelasku iz panela Slojevi + Saved Camera Raw settings to {file} Подешавања Camera Raw сачувана у {file} + Save the settings to each raw's sidecar without opening it Sačuvaj podešavanja u prateću datoteku svakog raw snimka bez otvaranja + No settings saved: these raws can't keep a sidecar here Ništa nije sačuvano: ovi raw snimci ovde ne mogu imati prateću datoteku + Some settings could not be saved; the raws stay open Neka podešavanja nije moguće sačuvati; raw snimci ostaju otvoreni + Nothing to save: these raws are at their default settings Nema šta da se sačuva: ovi raw snimci imaju podrazumevana podešavanja +# Artboard tool options bar (#11385) + Distribute Widths Raspodeli širine + Distribute Heights Raspodeli visine + Auto-nest Layers Automatski ugnezdi slojeve + Auto-size Canvas Automatski promeni veličinu platna + Keep Relative Position During Layer Reordering Zadrži relativni položaj pri promeni redosleda slojeva + Shrinkwrap Canvas on Save Smanji platno na sadržaj pri čuvanju diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b9c3721765..2157f234de 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -84,6 +84,7 @@ Right Höger Open Recent Öppna senaste Clear Recent Files Rensa senaste filer + Clear Recent File List Rensa senaste filer Remove from Recent Ta bort från senaste Remove {name} from Recent Ta bort {name} från senaste Close Stäng @@ -92,6 +93,10 @@ Restore Återställ Close All Stäng alla Close Others Stäng övriga + New Document… Nytt dokument… + Open Document… Öppna… + Consolidate All to Here Samla alla här + Move to New Window Flytta till nytt fönster Save Spara Save As… Spara som… Save a Copy… Spara en kopia… @@ -104,7 +109,6 @@ Export As… Exportera som… Export Exportera Export… Exportera… - Quick Export As PNG Snabbexportera som PNG Quick Export as PNG Snabbexportera som PNG Place Embedded… Placera inbäddad… Place Linked… Placera länkad… @@ -308,7 +312,6 @@ All Alla Deselect Avmarkera Reselect Markera igen - Inverse Selection Omvänd markering Inverse Omvänd Color Range… Färgintervall… Select and Mask… Markera och maskera… @@ -506,7 +509,6 @@ Match Location Matcha plats Match Rotation Matcha rotation Match All Matcha alla - New Window for Document Nytt fönster för dokument Workspace Arbetsyta Extensions Tillägg Panels Paneler @@ -536,6 +538,12 @@ Timeline Tidslinje Tool Presets Verktygsförinställningar Tools Verktyg + Contextual Task Bar Kontextuellt aktivitetsfält + Hide bar Dölj fältet + Reset bar position Återställ fältets position + Pin bar position Fäst fältets position + Move bar Flytta fältet + More options Fler alternativ Libraries Bibliotek 3D 3D Toolbars Verktygsfält @@ -594,8 +602,8 @@ Background Bakgrund Set foreground color Ange förgrundsfärg Set background color Ange bakgrundsfärg - Swap colours (X) Byt plats på färgerna (X) - Default colours (D) Standardfärger (D) + Switch Foreground and Background Colors (X) Växla förgrunds- och bakgrundsfärg (X) + Default Foreground and Background Colors (D) Standardfärger för förgrund och bakgrund (D) Brush Tool Options Alternativ för penselverktyget Opacity Opacitet Flow Flöde @@ -684,6 +692,7 @@ Units & Rulers… Enheter och linjaler… Guides, Grid & Slices… Stödlinjer, rutnät och skivor… Plug-ins… Plugin-program… + Plugins… Plugin-program… Technology Previews Teknikförhandsgranskningar Scratch Disks… Arbetsdiskar… Export Preferences… Exportinställningar… @@ -918,8 +927,8 @@ ACES ACES Industry Standard Branschstandard Web Webb - Internet Standard RGB Internetstandard-RGB - Legacy Macintosh RGB Äldre Macintosh RGB + Internet Standard RGB (sRGB) Internetstandard-RGB (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Äldre Macintosh RGB (Gamma 1.8) Monitor RGB Bildskärms-RGB Windows LCD Windows LCD Coated CMYK Bestruket CMYK @@ -1207,6 +1216,7 @@ Path to Illustrator… Bana till Illustrator… Make Work Path Skapa arbetsbana Make Selection Skapa markering + Make Shape Skapa form Fill Path Fyll bana Stroke Path Linje på bana Clipping Path Urklippsbana @@ -1600,7 +1610,7 @@ Revert “{name}” to the last saved version? Your changes will be lost. Vill du återgå till den senast sparade versionen av ”{name}”? Dina ändringar går förlorade. {key} to subtract {key} för att subtrahera ↑↓ navigate {key} run esc close ↑↓ navigera {key} kör esc stäng - Click to restore the current colour Klicka för att återställa den aktuella färgen + Click to restore the current color Klicka för att återställa den aktuella färgen Click with the Note tool to add a note. Klicka med anteckningsverktyget för att lägga till en anteckning. Drag a rectangle around the object Dra en rektangel runt objektet Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Dra i bilden för att lägga till en begränsning; Shift för vågrätt/lodrätt; högerklicka på en begränsning för att ändra eller ta bort den. @@ -1624,6 +1634,7 @@ Move Tool Flyttverktyg Could not use the move tool because the layer is locked. Det gick inte att använda flyttverktyget eftersom lagret är låst. Could not complete the Free Transform command because the layer is locked. Det gick inte att slutföra kommandot Ombilda fritt eftersom lagret är låst. + Could not transform because the layer is locked. Det gick inte att omforma eftersom lagret är låst. Rectangular Marquee Tool Verktyget Rektangulär markeringsram Elliptical Marquee Tool Verktyget Elliptisk markeringsram Single Row Marquee Tool Verktyget Enradsmarkeringsram @@ -1681,10 +1692,10 @@ Drag to move the active layer Dra för att flytta det aktiva lagret Drag to pan · hold Space with any tool Dra för att panorera · håll ned blanksteg med valfritt verktyg Drag to draw a gradient Dra för att rita en gradient - Click to fill similar colours Klicka för att fylla liknande färger + Click to fill similar colors Klicka för att fylla liknande färger Click to add text Klicka för att lägga till text Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Dra (lasso) eller klicka på punkter (polygonal) · {add} lägg till · {sub} subtrahera - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Dra en beskärningsram · dra inuti för att flytta · kanterna ändrar storlek ({ratio} proportioner, {centre} centrerat) · Blanksteg flyttar medan du ritar · piltangenterna flyttar stegvis · X byter orientering · {commit} verkställer · Esc avbryter + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Dra en beskärningsram · dra inuti för att flytta · kanterna ändrar storlek ({ratio} proportioner, {centre} centrerat) · Blanksteg flyttar medan du ritar · piltangenterna flyttar stegvis · X byter orientering · {commit} verkställer · Esc avbryter {mode} Color · {bits} bit {mode}-färg · {bits} bitar Drag to move the layer Dra för att flytta lagret Fit Screen Anpassa till skärmen @@ -1716,7 +1727,7 @@ Appearance Utseende Text Position: Textposition: Text color Textfärg - Uses the foreground and background colours. Använder förgrunds- och bakgrundsfärgerna. + Uses the foreground and background colors. Använder förgrunds- och bakgrundsfärgerna. Answer the unsaved-changes prompt first Svara först på frågan om osparade ändringar Unsaved changes Osparade ändringar Don't Save Spara inte @@ -1736,10 +1747,11 @@ name namn new ny Go to Layer Gå till lager - Open a document to select a colour range. Öppna ett dokument för att välja ett färgintervall. + Open a document to select a color range. Öppna ett dokument för att välja ett färgintervall. Saved Sparat Reveal in Finder Visa i Finder Show in Explorer Visa i Utforskaren + Reveal in Explorer Visa i Utforskaren Show in Folder Visa i mapp Before (Y) Före (Y) Save Settings… Spara inställningar… @@ -1913,12 +1925,14 @@ Character Styles Panel Panelen Teckenformat Clear Canvas Guides Rensa stödlinjer för arbetsytan Clear Selected Artboard Guides Rensa stödlinjer för markerad rityta - Color Blindness — Deuteranopia-type Färgblindhet — deuteranopityp - Color Blindness — Protanopia-type Färgblindhet — protanopityp + Color Blindness - Deuteranopia-type Färgblindhet — deuteranopityp + Color Blindness - Protanopia-type Färgblindhet — protanopityp Color Table… Färgtabell… Combine Shapes Kombinera former Contract… Dra ihop… Convert to Embedded Konvertera till inbäddat + Embed Linked Bädda in länkat + Embed All Linked Bädda in alla länkade Convert to Layers Konvertera till lager Convert to Linked… Konvertera till länkat… Count Antal @@ -2006,6 +2020,7 @@ Pixelate Pixla Place Scale Marker… Placera skalmarkör… Plug-ins Plugin-program + Plugins Plugin-program Print One Copy Skriv ut en kopia Print… Skriv ut… Range Intervall @@ -2050,7 +2065,7 @@ Vertical Centers Lodräta mittpunkter Video Frames to Layers… Videobildrutor till lager… WIA Support… WIA-stöd… - Working CMY Plate CMY-arbetsplåt + Working CMY Plates CMY-arbetsplåtar # tl! literals (third pass) Absolute Absolut @@ -2085,7 +2100,7 @@ Canvas Arbetsyta Capture Fånga Center Crop Marks Centrerade beskärningsmärken - Centre the gradient (offset 0, 0) Centrera gradienten (förskjutning 0, 0) + Center the gradient (offset 0, 0) Centrera gradienten (förskjutning 0, 0) Change Screen Mode (F) Ändra skärmläge (F) Change To: Ändra till: Clear All Rensa alla @@ -2237,7 +2252,7 @@ Roughness Grovhet Roundness Rundhet Rulers & Grids Linjaler och rutnät - Sample: foreground colour Prov: förgrundsfärg + Sample: foreground color Prov: förgrundsfärg Sampling: Background Swatch Provtagning: bakgrundsfärgruta Sampling: Continuous Provtagning: kontinuerlig Sampling: Once Provtagning: en gång @@ -2336,6 +2351,11 @@ Show canvas dots Visa punkter på arbetsytan Clip thumbnails to layer bounds Begränsa miniatyrer till lagergränser Clip Thumbnails to Layer Bounds Begränsa miniatyrer till lagergränser + No Thumbnails Inga miniatyrer + Small Thumbnails Små miniatyrer + Medium Thumbnails Medelstora miniatyrer + Large Thumbnails Stora miniatyrer + Clip Thumbnails to Document Bounds Begränsa miniatyrer till dokumentgränser Canvas color Arbetsytans färg Canvas custom color Anpassad färg för arbetsytan Classic Klassisk @@ -2462,7 +2482,6 @@ Show only crosshair while painting Visa bara hårkors vid målning Show slice numbers Visa skivnummer Show tooltips Visa verktygstips - Enable animations Aktivera animeringar Show highlight on rollover Visa framhävning när pekaren förs över Show bounding box when dragging layer Visa begränsningsram när lager dras System title bar Systemets namnlist @@ -2506,7 +2525,7 @@ Alt-click to define a source point to clone from Alt-klicka för att ange en källpunkt att klona från No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Det finns ingen bana att skapa en markering från: rita en med pennverktyget eller markera en i panelen Banor. Cancel opening Avbryt öppning - Cancelled opening {name} Öppningen av {name} avbröts + Canceled opening {name} Öppningen av {name} avbröts Damaged Photoshop document Skadat Photoshop-dokument The document “{name}” may be damaged. Dokumentet ”{name}” kan vara skadat. Some layers, pixels or masks could not be read. Do you want to continue opening it? Vissa lager, pixlar eller masker kunde inte läsas. Vill du fortsätta att öppna det? @@ -2516,7 +2535,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumentet ”{name}” har en inbäddad färgprofil som inte matchar den aktuella arbetsfärgrymden för {mode}. Working: {working} Arbetsfärgrymd: {working} Developing {name} Framkallar {name} - Cancelled {label} {label} avbröts + Canceled {label} {label} avbröts Esc to cancel Esc för att avbryta Opened {name} Öppnade {name} Opening {name} Öppnar {name} @@ -2524,7 +2543,7 @@ Saving {name} Sparar {name} Saving {percent} Sparar {percent} A save to {name} is still running; try again when it finishes En sparning till {name} pågår fortfarande; försök igen när den är klar - {label} is writing the file and can no longer be cancelled {label} skriver filen och kan inte längre avbrytas + {label} is writing the file and can no longer be canceled {label} skriver filen och kan inte längre avbrytas Opening {name}… Öppnar {name}… Opening… Öppnar… Reading… Läser… @@ -2621,7 +2640,7 @@ cameraRaw Roughness Grovhet Discard Layers and Save a Copy Släng lager och spara en kopia Don't show again Visa inte igen Save failed: {error} Det gick inte att spara: {error} - Save cancelled Sparandet avbröts + Save canceled Sparandet avbröts Couldn't place {name}: {error} Det gick inte att placera {name}: {error} Magnetic Lasso Tool Verktyget Magnetiskt lasso Frequency Frekvens @@ -2765,7 +2784,7 @@ fontWeight Italic Kursiv Impressionist Impressionistisk Rotate View Tool Verktyget Rotera vy Reset View Återställ vy - Drag around the centre to rotate the view · Shift constrains to 15° Dra runt mitten för att rotera vyn · Shift begränsar till 15° + Drag around the center to rotate the view · Shift constrains to 15° Dra runt mitten för att rotera vyn · Shift begränsar till 15° # Painting symmetry presets. Dual Axis Dubbelaxel @@ -2785,11 +2804,11 @@ fontWeight Italic Kursiv All Layers No Adjustments Alla lager utan justeringar Current & Below No Adjustments Aktuellt och under utan justeringar Show Sampling Ring Visa provtagningsring - Click to sample the foreground colour · {key}-click for background Klicka för att ta prov på förgrundsfärgen · {key}-klicka för bakgrundsfärgen + Click to sample the foreground color · {key}-click for background Klicka för att ta prov på förgrundsfärgen · {key}-klicka för bakgrundsfärgen # Color Picker warnings - Out of gamut for printing: click for the nearest printable colour Utanför färgomfånget för utskrift: klicka för närmaste utskrivbara färg - Not a web colour: click for the nearest web colour Inte en webbfärg: klicka för närmaste webbfärg + Out of gamut for printing: click for the nearest printable color Utanför färgomfånget för utskrift: klicka för närmaste utskrivbara färg + Not a web color: click for the nearest web color Inte en webbfärg: klicka för närmaste webbfärg Color to Alpha… Färg till alfa… Transparency Threshold Tröskelvärde för genomskinlighet Opacity Threshold Opacitetströskelvärde @@ -3197,6 +3216,8 @@ curves Smooth Jämna ut Step {step} is waiting for its dialog Steg {step} väntar på sin dialogruta @plural {n} action|{n} actions {n} åtgärd|{n} åtgärder Background (Default) Bakgrund (standard) + Background (default) Bakgrund (standard) + Transparent (default) Genomskinlig (standard) Content-Aware Fill Innehållsmedveten fyllning # Batch / Image Processor Include All Subfolders Ta med alla undermappar @@ -3205,7 +3226,7 @@ curves Smooth Jämna ut # Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Markera inte också Spegelvänd bild i Utskriftsinställningar…: de två vändningarna tar ut varandra. Print Settings… Utskriftsinställningar… - Remember to turn off the printer's own colour management in Print Settings… Kom ihåg att stänga av skrivarens egen färghantering i Utskriftsinställningar… + Remember to turn off the printer's own color management in Print Settings… Kom ihåg att stänga av skrivarens egen färghantering i Utskriftsinställningar… Sent to {printer} Skickat till {printer} set in Print Settings… anges i Utskriftsinställningar… ICO is limited to 256 × 256 pixels ICO är begränsat till 256 × 256 pixlar @@ -3869,12 +3890,12 @@ curves Smooth Jämna ut In White Vitt in Increase Brush Hardness Öka penselns hårdhet Demosaic Demosaicering - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hur kamerans färgfiltermosaik fylls i. Den tillämpas när filen öppnas; förhandsgranskningen visar metoden som den öppnades med. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hur kamerans färgfiltermosaik fylls i. Den tillämpas när filen öppnas; förhandsgranskningen visar metoden som den öppnades med. AHD (adaptive homogeneity) AHD (adaptiv homogenitet) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: renast kanter och minst färgfransning, men långsammast. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigerad interpolering: skarp och snabb, med viss färgfransning i fina detaljer. - The fastest and the softest, with colour fringing on fine detail. Snabbast och mjukast, med färgfransar vid fina detaljer. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: renast kanter och minst färgfransning, men långsammast. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradientkorrigerad interpolering: skarp och snabb, med viss färgfransning i fina detaljer. + The fastest and the softest, with color fringing on fine detail. Snabbast och mjukast, med färgfransar vid fina detaljer. “{name}” already exists. Replace it? ”{name}” finns redan. Vill du ersätta den? The name in the save dialog was “{name}”. Namnet i dialogrutan Spara var ”{name}”. Move Up Flytta uppåt @@ -4525,7 +4546,6 @@ curves Smooth Jämna ut Use File › Open to open an image. Öppna en bild med Arkiv › Öppna. Use Selection In Source Använd markering i källan Use Selection In Target Använd markering i målet - Uses the foreground and background colors. Använder förgrunds- och bakgrundsfärgerna. Vanishing Point Fluktpunkt Variable Data Sets Variabla datauppsättningar Vector Mask Hides Effects Vektormask döljer effekter @@ -4735,8 +4755,8 @@ cameraRaw Camera Raw ({name}) Camera Raw ({name}) cameraRaw Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) cameraRaw Camera Raw isn't open Camera Raw är inte öppet cameraRaw Cancel Avbryt -cameraRaw Cancelled opening {name} Öppningen av {name} avbröts -cameraRaw Cancelled {label} {label} avbröts +cameraRaw Canceled opening {name} Öppningen av {name} avbröts +cameraRaw Canceled {label} {label} avbröts cameraRaw Color Priority Färgprioritet cameraRaw Developing {name} Framkallar {name} cameraRaw Full-resolution preview unavailable Förhandsgranskning i full upplösning är inte tillgänglig @@ -4771,3 +4791,39 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen The path was discarded Banan kasserades The path was kept as the Work Path Banan behölls som arbetsbana Edit Brush… Redigera pensel… + +# Action Stop message (#8386) + Message Meddelande +# Playback Options (#5212) + Playback Options… Uppspelningsalternativ… + Playback Options Uppspelningsalternativ + Accelerated Accelererad + Step by Step Steg för steg + Pause For: Paus i: + Pause For Paus i + seconds sekunder + Create a single history state Skapa ett enda historikläge + +# Channels panel menu (#11451) + Channel Options… Kanalalternativ… + Specify Channels: Ange kanaler: + Not enough grayscale documents for this mode. Det finns inte tillräckligt många gråskaledokument för det här läget. + Pick a different document for each channel. Välj ett annat dokument för varje kanal. + A picked document is no longer open. Ett av de valda dokumenten är inte längre öppet. + A picked document is no longer a grayscale document of the same size. Ett av de valda dokumenten är inte längre ett gråskaledokument i samma storlek. +# Move tool options bar (#11381) + Align top edges Justera överkanter + Align vertical centers Justera vertikala mittpunkter + Align bottom edges Justera underkanter + Distribute vertically Fördela vertikalt + Distribute horizontally Fördela horisontellt + Show layer bounds on hover Visa lagrets gränser vid hovring + Expand layer group on click Expandera lagergrupp vid klick + Show hover bounds from Layers Visa hovringsgränser från Lager +# Artboard tool options bar (#11385) + Distribute Widths Fördela bredder + Distribute Heights Fördela höjder + Auto-nest Layers Kapsla in lager automatiskt + Auto-size Canvas Ändra storlek på arbetsytan automatiskt + Keep Relative Position During Layer Reordering Behåll relativ position vid ändring av lagerordning + Shrinkwrap Canvas on Save Krymp arbetsytan till innehållet vid sparning diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index b2bef186e9..b660029cdb 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -172,6 +172,7 @@ mod tests { (vec!["nb-NO", "en-US"], "nb"), (vec!["nn_NO.UTF-8", "en-US"], "nb"), (vec!["is-IS", "no-NO"], "nb"), + (vec!["ka-GE", "ru-RU"], "ka"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/i18n/ta.tsv b/crates/ui-egui/src/i18n/ta.tsv new file mode 100644 index 0000000000..3c45b9a218 --- /dev/null +++ b/crates/ui-egui/src/i18n/ta.tsv @@ -0,0 +1,4275 @@ +# Tamil (ta) catalog. Columns, separated by a TAB: context source translation +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Tamil computing vocabulary. No proprietary localisation resource, string table or product UI +# was consulted or copied; add entries the same way. +# Product and technology names (OpenType, CMYK, Lab ...) stay in Latin letters; +# the trailing "…" of dialog commands is kept. + + Mask Options… மறைப்பு விருப்பங்கள்… + Layer Mask Display Options… அடுக்கு மறைப்பு காட்சி விருப்பங்கள்… + Channel Color சேனல் நிறம் + Color Indicates: நிறம் குறிப்பது: + Solidity: திண்மை: + Masked Areas மறைக்கப்பட்ட பகுதிகள் + Selected Areas தேர்ந்தெடுக்கப்பட்ட பகுதிகள் + Ink Characteristics மை பண்புகள் + Paper Width காகித அகலம் + Paper Height காகித உயரம் + Columns நெடுவரிசைகள் + Rows வரிசைகள் + Number எண் + Margin ஓரம் + Top: மேல்: + Left: இடது: + Bottom: கீழ்: + Right: வலது: + Target: இலக்கு: + Selected Artboards தேர்ந்தெடுக்கப்பட்ட ஆர்ட்போர்டுகள் + Center Columns நெடுவரிசைகளை மையப்படுத்து + Clear Existing Guides இருக்கும் அளவுகோட்டு வரிகளை அழி + Invalid guide layout தவறான அளவுகோட்டு வரி அமைப்பு + Leave empty for automatic size தானியங்கி அளவுக்கு காலியாக விடவும் + 5 Columns, 3 Rows 5 நெடுவரிசைகள், 3 வரிசைகள் + 8 Columns 8 நெடுவரிசைகள் + 12 Columns 12 நெடுவரிசைகள் + File கோப்பு + Edit திருத்து + Image படம் + Layer அடுக்கு + Type உரை + Select தேர்வு + Selected தேர்ந்தெடுக்கப்பட்டவை + Scope நோக்கம் + Top Left மேல் இடது + Top Right மேல் வலது + Bottom Left கீழ் இடது + Bottom Right கீழ் வலது + Filter வடிகட்டி + View காட்சி + Window சாளரம் + Help உதவி + (coming soon) (விரைவில்) + New… புதியது… + New from Clipboard கிளிப்போர்டிலிருந்து புதியது + Open… திற… + Open As… இவ்வாறு திற… + Open EXR Parts… EXR பகுதிகளைத் திற… + Add beauty precomp from the AOVs AOV-களிலிருந்து பியூட்டி ப்ரீகாம்ப் சேர் + Both இரண்டும் + Left இடது + Right வலது + Open Recent சமீபத்தியவற்றைத் திற + Clear Recent Files சமீபத்திய கோப்புகளை அழி + Remove from Recent சமீபத்தியவற்றிலிருந்து நீக்கு + Remove {name} from Recent {name} ஐ சமீபத்தியவற்றிலிருந்து நீக்கு + Close மூடு + Minimize சிறிதாக்கு + Minimize Panel பலகத்தை சிறிதாக்கு + Maximize பெரிதாக்கு + Restore மீட்டமை + Close All அனைத்தையும் மூடு + Close Others மற்றவற்றை மூடு + Close Other Panels in Group குழுவில் உள்ள மற்ற பலகங்களை மூடு + Open Panel in Own Window தனிச் சாளரத்தில் பலகத்தைத் திற + Arrange Panels பலகத்தை வரிசைப்படுத்து + Save சேமி + Save As… இவ்வாறு சேமி… + Save a Copy… நகலைச் சேமி… + {name} can't keep everything in this document இந்த ஆவணத்தில் உள்ள அனைத்தையும் {name} ஆல் தக்கவைக்க முடியாது + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. அனைத்தையும் தக்கவைக்கும் Photoshop (PSD) போன்ற வடிவமைப்பைத் தேர்ந்தெடுக்கவும், அல்லது நகலைச் சேமிக்கவும். + Revert முந்தைய நிலைக்குத் திரும்பு + Export ஏற்றுமதி செய் + Quick Export as PNG PNG ஆக விரைவு ஏற்றுமதி + Export As… இவ்வாறு ஏற்றுமதி செய்… + Export Preferences… ஏற்றுமதி விருப்பத்தேர்வுகள்… + Save for Web (Legacy)… வலைக்காகச் சேமி (பழையது)… + Artboards to Files… ஆர்ட்போர்டுகளைக் கோப்புகளாக… + Artboards to PDF… ஆர்ட்போர்டுகளை PDF ஆக… + Layers to Files… அடுக்குகளைக் கோப்புகளாக… + Layer Comps to Files… அடுக்கு அமைப்புகளைக் கோப்புகளாக… + Color Lookup Tables… நிறத் தேடல் அட்டவணைகள்… + Data Sets as Files… தரவுத் தொகுப்புகளைக் கோப்புகளாக… + Paths to Illustrator… பாதைகளை Illustrator-க்கு… + Render Video… வீடியோவை ரெண்டர் செய்… + Generate உருவாக்கு + Image Assets பட சொத்துகள் + Place Embedded… உட்பொதிந்து வை… + Place Linked… இணைத்து வை… + Package… தொகுப்பாக்கு… + Automate தானியக்கு + Batch… தொகுதி… + Create Droplet… ட்ராப்லெட் உருவாக்கு… + Crop and Straighten Photos புகைப்படங்களை வெட்டி நேராக்கு + Contact Sheet II… காண்டாக்ட் ஷீட் II… + Conditional Mode Change… நிபந்தனை முறை மாற்றம்… + Fit Image… படத்தைப் பொருத்து… + Lens Correction… லென்ஸ் திருத்தம்… + Merge to HDR Pro… HDR Pro ஆக இணை… + Photomerge… புகைப்பட ஒருங்கிணைப்பு… + Scripts ஸ்கிரிப்டுகள் + Image Processor… பட செயலி… + Delete All Empty Layers காலியான அனைத்து அடுக்குகளையும் நீக்கு + Flatten All Layer Effects அனைத்து அடுக்கு விளைவுகளையும் தட்டையாக்கு + Flatten All Masks அனைத்து மறைப்புகளையும் தட்டையாக்கு + Script Events Manager… ஸ்கிரிப்ட் நிகழ்வு மேலாளர்… + Import இறக்குமதி செய் + Browse… உலாவு… + Source Files மூலக் கோப்புகள் + Use: பயன்படுத்து: + Files கோப்புகள் + Folder கோப்புறை + Add Open Files திறந்த கோப்புகளைச் சேர் + Sort By Name பெயரால் வரிசைப்படுத்து + Attempt to Automatically Align Source Images மூலப் படங்களைத் தானாக சீரமைக்க முயற்சி + Create Smart Object after Loading Layers அடுக்குகளை ஏற்றிய பின் திறன்மிகு பொருளை உருவாக்கு + Reveal in Finder Finder-இல் காண்பி + Show in Explorer Explorer-இல் காண்பி + Show in Folder கோப்புறையில் காண்பி + File Info… கோப்பு தகவல்… + Print… அச்சிடு… + Print One Copy ஒரு நகலை அச்சிடு + Exit வெளியேறு + Undo செயல்தவிர் + Redo மீண்டும் செய் + Toggle Last State கடைசி நிலையை மாற்று + Fade… மங்கலாக்கு… + Cut வெட்டு + Copy நகலெடு + Copy Merged இணைத்து நகலெடு + Paste ஒட்டு + Paste Special சிறப்பு ஒட்டு + Paste in Place அதே இடத்தில் ஒட்டு + Paste Into உள்ளே ஒட்டு + Paste Outside வெளியே ஒட்டு + Paste Lorem Ipsum Lorem Ipsum ஒட்டு + Clear அழி + Search… தேடு… + Check Spelling… எழுத்துப்பிழை சரிபார்… + Find and Replace Text… உரையைத் தேடி மாற்று… + Add to Dictionary அகராதியில் சேர் + No Spelling Suggestions எழுத்துப்பிழை பரிந்துரைகள் இல்லை + Fill… நிரப்பு… + Stroke… கோடிடு… + Fill with Foreground Color முன்னணி நிறத்தால் நிரப்பு + Fill with Background Color பின்னணி நிறத்தால் நிரப்பு + Fill with Foreground Color, Preserve Transparency முன்னணி நிறத்தால் நிரப்பு, ஒளிஊடுருவும் தன்மையைப் பாதுகாக்கவும் + Fill with Background Color, Preserve Transparency பின்னணி நிறத்தால் நிரப்பு, ஒளிஊடுருவும் தன்மையைப் பாதுகாக்கவும் + Fill from History வரலாற்று நிலையிலிருந்து நிரப்பு + Content-Aware Fill… உள்ளடக்கம் அறிந்த நிரப்பு… + Delete and Fill Selection தேர்வை நீக்கி நிரப்பு + Content-Aware Scale உள்ளடக்கம் அறிந்த அளவீடு + Puppet Warp உருவ வளைப்பு + Perspective Warp பார்வைக்கோண வளைப்பு + Free Transform தன்னிச்சை உருமாற்றம் + Free Transform a Copy நகலைத் தன்னிச்சையாக உருமாற்று + Transform உருமாற்று + Again மீண்டும் + Transform Again on a Copy நகலை மீண்டும் உருமாற்று + Scale அளவு மாற்று + Rotate சுழற்று + Set horizontal skew கிடைமட்ட சாய்வை அமை + Set vertical skew செங்குத்து சாய்வை அமை + Skew சாய் + Distort திரிபாக்கு + Perspective பார்வைக்கோணம் + Warp வளைப்பு + Flip Horizontal கிடைமட்டமாகப் புரட்டு + Flip Vertical செங்குத்தாகப் புரட்டு + Rotate 180° 180° சுழற்று + Rotate 90° Clockwise 90° வலஞ்சுழியாகச் சுழற்று + Rotate 90° Counter Clockwise 90° இடஞ்சுழியாகச் சுழற்று + 180° 180° + 90° Clockwise 90° வலஞ்சுழி + 90° Counter Clockwise 90° இடஞ்சுழி + Arbitrary… விருப்பம்போல்… + Auto-Align Layers… அடுக்குகளைத் தானாக சீரமை… + Auto-Blend Layers… அடுக்குகளைத் தானாகக் கலக்கு… + Define Brush Preset… தூரிகை முன்னமைவை வரையறு… + Define Pattern… வடிவமைப்பை வரையறு… + Define Custom Shape… தனிப்பயன் வடிவத்தை வரையறு… + Purge தூய்மையாக்கு + Clipboard கிளிப்போர்டு + Histories வரலாறுகள் + Video Cache வீடியோ தற்காலிக சேமிப்பு + All அனைத்தும் + Presets முன்னமைவுகள் + Preset Manager… முன்னமைவு மேலாளர்… + Migrate Presets முன்னமைவுகளை இடம்பெயர் + Export/Import Presets… முன்னமைவுகளை ஏற்றுமதி/இறக்குமதி செய்… + Color Settings… நிற அமைப்புகள்… + Assign Profile… சுயவிவரத்தை ஒதுக்கு… + Convert to Profile… சுயவிவரமாக மாற்று… + Keyboard Shortcuts… விசைப்பலகை குறுக்குவழிகள்… + Menus… பட்டிகள்… + Toolbar… கருவிப்பட்டி… + Preferences விருப்பத்தேர்வுகள் + General… பொது… + Interface… இடைமுகம்… + Workspace… பணியிடம்… + Tools… கருவிகள்… + History Log… வரலாற்றுப் பதிவு… + File Handling… கோப்பு கையாளுதல்… + Export… ஏற்றுமதி… + Performance… செயல்திறன்… + Scratch Disks… நினைவக வட்டுகள்… + Cursors… சுட்டிகள்… + Transparency & Gamut… ஒளிஊடுருவல் & நிற வரம்பு… + Units & Rulers… அலகுகள் & அளவுகோல்கள்… + Guides, Grid & Slices… அளவுகோட்டு வரிகள், கட்டம் & துண்டுகள்… + Plug-ins… செருகுநிரல்கள்… + Type… உரை… + Enhanced Controls… மேம்படுத்தப்பட்ட கட்டுப்பாடுகள்… + Camera Raw… கேமரா Raw… + Raw Defaults… Raw இயல்புநிலைகள்… + Integrations… ஒருங்கிணைப்புகள்… + AI Integrations… AI ஒருங்கிணைப்புகள்… + Install Plug-in… செருகுநிரலை நிறுவு… + Language Options மொழி விருப்பங்கள் + Settings… அமைப்புகள்… + Mode முறை + Bitmap பிட்மேப் + Grayscale சாம்பல் அளவு + Duotone இருநிறம் + Indexed Color குறியீட்டு நிறம் + RGB Color RGB நிறம் + CMYK Color CMYK நிறம் + Lab Color Lab நிறம் + Multichannel பல்சேனல் + 8 Bits/Channel 8 பிட்/சேனல் + 8 bit 8 பிட் + Integer முழுஎண் + 16 Bits/Channel 16 பிட்/சேனல் + 16 bit 16 பிட் + 32 Bits/Channel 32 பிட்/சேனல் + 32 bit (float) 32 பிட் (மிதவை) + Floating point மிதவைப் புள்ளி + Color Table… நிற அட்டவணை… + Bitmap… பிட்மேப்… + Duotone… இருநிறம்… + Indexed Color… குறியீட்டு நிறம்… + Adjustments சரிசெய்தல்கள் + Brightness/Contrast… ஒளிர்வு/மாறுபாடு… + Levels… நிலைகள்… + Curves… வளைவுகள்… + Exposure… வெளிப்பாடு… + Vibrance… துடிப்பு… + Hue/Saturation… சாயல்/செறிவு… + Color Balance… நிறச் சமநிலை… + Black & White… கருப்பு & வெள்ளை… + Photo Filter… புகைப்பட வடிகட்டி… + Channel Mixer… சேனல் கலப்பி… + Color Lookup… நிறத் தேடல்… + Invert தலைகீழாக்கு + Posterize… போஸ்டரைஸ்… + Threshold… வாசல் மதிப்பு… + Gradient Map… நிறப் படித்திறன் வரைபடம்… + Selective Color… தேர்ந்தெடுத்த நிறம்… + Shadows/Highlights… நிழல்கள்/ஒளிர்வுகள்… + HDR Toning… HDR டோனிங்… + Desaturate செறிவைக் குறை + Match Color… நிறத்தைப் பொருத்து… + Replace Color… நிறத்தை மாற்று… + Equalize சமப்படுத்து + Auto Tone தானியங்கி தொனி + Auto Contrast தானியங்கி மாறுபாடு + Auto Color தானியங்கி நிறம் + Image Size… பட அளவு… + Canvas Size… படச்சட்ட அளவு… + Image Rotation பட சுழற்சி + Flip Canvas Horizontal படச்சட்டத்தை கிடைமட்டமாகப் புரட்டு + Flip Canvas Vertical படச்சட்டத்தை செங்குத்தாகப் புரட்டு + Crop வெட்டு + Trim… ஒதுக்கு… + Reveal All அனைத்தையும் வெளிப்படுத்து + Duplicate… நகல் உருவாக்கு… + Apply Image… படத்தைப் பயன்படுத்து… + Calculations… கணக்கீடுகள்… + Variables மாறிகள் + Define… வரையறு… + Data Sets… தரவுத் தொகுப்புகள்… + Apply Data Set… தரவுத் தொகுப்பைப் பயன்படுத்து… + Variable Data Sets… மாறி தரவுத் தொகுப்புகள்… + Trap… பிடிப்பு… + Analysis பகுப்பாய்வு + Set Measurement Scale… அளவீட்டு அளவுகோலை அமை… + Select Data Points… தரவுப் புள்ளிகளைத் தேர்ந்தெடு… + Record Measurements அளவீடுகளைப் பதிவு செய் + Ruler Tool அளவுகோல் கருவி + Count Tool எண்ணிக்கைக் கருவி + Place Scale Marker… அளவுகோல் குறியை வை… + Custom… தனிப்பயன்… + New புதியது + Layer… அடுக்கு… + Group… குழு… + Group from Layers… அடுக்குகளிலிருந்து குழு… + Artboard… ஆர்ட்போர்டு… + Artboard from Group… குழுவிலிருந்து ஆர்ட்போர்டு… + Artboard from Layers… அடுக்குகளிலிருந்து ஆர்ட்போர்டு… + Layer via Copy நகல் வழியாக அடுக்கு + Layer via Cut வெட்டு வழியாக அடுக்கு + Layer From Background… பின்னணியிலிருந்து அடுக்கு… + Layer from Background… பின்னணியிலிருந்து அடுக்கு… + Background from Layer அடுக்கிலிருந்து பின்னணி + Background From Layer அடுக்கிலிருந்து பின்னணி + New Shape Layer புதிய வடிவ அடுக்கு + New Type Layer புதிய உரை அடுக்கு + New Fill Layer புதிய நிரப்பு அடுக்கு + New Adjustment Layer புதிய சரிசெய்தல் அடுக்கு + New Layer Based Slice புதிய அடுக்கு அடிப்படையிலான துண்டு + New Smart Object via Copy நகல் வழியாகப் புதிய திறன்மிகு பொருள் + New Blank Video Layer புதிய வெற்று வீடியோ அடுக்கு + New Video Layer from File… கோப்பிலிருந்து புதிய வீடியோ அடுக்கு… + Duplicate Layer… அடுக்கை நகலெடு… + Delete Layer அடுக்கை நீக்கு + Delete நீக்கு + Rename Layer அடுக்கின் பெயரை மாற்று + Create Layer அடுக்கை உருவாக்கு + Layer Style அடுக்கு பாணி + Blending Options… கலப்பு விருப்பங்கள்… + Bevel & Emboss… சரிவு & புடைப்பு… + Use Global Light பொது ஒளியைப் பயன்படுத்து + Overprint மேல் அச்சிடு + Inner Shadow… உள் நிழல்… + Inner Glow… உள் பிரகாசம்… + Satin… சாடின்… + Color Overlay… நிற மேலடுக்கு… + Gradient Overlay… நிறப் படித்திறன் மேலடுக்கு… + Pattern Overlay… வடிவ மேலடுக்கு… + Outer Glow… வெளிப் பிரகாசம்… + Drop Shadow… விழும் நிழல்… + Copy Layer Style அடுக்கு பாணியை நகலெடு + Paste Layer Style அடுக்கு பாணியை ஒட்டு + Clear Layer Style அடுக்கு பாணியை அழி + Global Light… பொது ஒளி… + Hide All Effects அனைத்து விளைவுகளையும் மறை + Scale Effects… விளைவுகளை அளவு மாற்று… + Smart Filter திறன்மிகு வடிகட்டி + Convert for Smart Filters திறன்மிகு வடிகட்டிகளுக்கு மாற்று + Clear Smart Filters திறன்மிகு வடிகட்டிகளை அழி + Disable Smart Filters திறன்மிகு வடிகட்டிகளை முடக்கு + Delete Filter Mask வடிகட்டி மறைப்பை நீக்கு + Disable Filter Mask வடிகட்டி மறைப்பை முடக்கு + Solid Color… திட நிறம்… + Gradient… நிறப் படித்திறன்… + Pattern… வடிவமைப்பு… + Layer Mask அடுக்கு மறைப்பு + Vector Mask திசையன் மறைப்பு + Reveal Selection தேர்வை வெளிப்படுத்து + Hide Selection தேர்வை மறை + Hide All அனைத்தையும் மறை + From Transparency ஒளிஊடுருவலிலிருந்து + Delete Vector Mask திசையன் மறைப்பை நீக்கு + Enable Layer Mask அடுக்கு மறைப்பை இயக்கு + Disable Layer Mask அடுக்கு மறைப்பை முடக்கு + Enable Vector Mask திசையன் மறைப்பை இயக்கு + Link Layer Mask அடுக்கு மறைப்பை இணை + Unlink Layer Mask அடுக்கு மறைப்பு இணைப்பை நீக்கு + Toggle Layer Mask Overlay அடுக்கு மறைப்பு மேலடுக்கை மாற்று + Link Vector Mask திசையன் மறைப்பை இணை + Add Vector Mask வெக்டர் மறைப்பைச் சேர் + Vector Mask from Current Path தற்போதைய பாதையிலிருந்து திசையன் மறைப்பு + Create Clipping Mask கிளிப்பிங் மறைப்பை உருவாக்கு + Release Clipping Mask கிளிப்பிங் மறைப்பை விடுவி + Smart Objects திறன்மிகு பொருட்கள் + Smart Object திறன்மிகு பொருள் + Convert to Smart Object திறன்மிகு பொருளாக மாற்று + Convert to Embedded உட்பொதிந்ததாக மாற்று + Convert to Layers அடுக்குகளாக மாற்று + Convert to Linked… இணைக்கப்பட்டதாக மாற்று… + Edit Contents உள்ளடக்கத்தைத் திருத்து + Replace Contents… உள்ளடக்கத்தை மாற்று… + Relink to File… கோப்புடன் மீண்டும் இணை… + Export Contents… உள்ளடக்கத்தை ஏற்றுமதி செய்… + Update Modified Content மாற்றிய உள்ளடக்கத்தைப் புதுப்பி + Update All Modified Content மாற்றிய அனைத்து உள்ளடக்கத்தையும் புதுப்பி + Stack Mode குவியல் முறை + Choose Stack Mode: குவியல் முறையைத் தேர்ந்தெடு: + Entropy என்ட்ரோபி + Kurtosis குர்டோசிஸ் + Maximum அதிகபட்சம் + Mean சராசரி + Median இடைநிலை + Minimum குறைந்தபட்சம் + Range வீச்சு + Skewness ஸ்கீவ்னெஸ் + Standard Deviation திட்ட விலகல் + Summation கூட்டுத்தொகை + Variance மாறுபடுதிறன் + Video Layers வீடியோ அடுக்குகள் + Interpret Footage… காட்சிப்பதிவை விளக்கு… + Replace Footage… காட்சிப்பதிவை மாற்று… + Reload Frame சட்டத்தை மீண்டும் ஏற்று + Show Altered Video மாற்றப்பட்ட வீடியோவைக் காண்பி + Restore Frame சட்டத்தை மீட்டமை + Restore All Frames அனைத்து சட்டங்களையும் மீட்டமை + Insert Blank Frame வெற்று சட்டத்தைச் செருகு + Delete Frame சட்டத்தை நீக்கு + Duplicate Frame சட்டத்தை நகலெடு + Frame from Layers அடுக்குகளிலிருந்து சட்டம் + Video Frames to Layers… வீடியோ சட்டங்களை அடுக்குகளாக… + Rasterize ராஸ்டராக்கு + Rasterize Type உரையை ராஸ்டராக்கு + Rasterize Type Layer உரை அடுக்கை ராஸ்டராக்கு + Rasterize Shape வடிவத்தை ராஸ்டராக்கு + New Guide Layout… புதிய அளவுகோட்டு வரி அமைப்பு… + New Guides From Shape வடிவத்திலிருந்து புதிய அளவுகோட்டு வரிகள் + Lock Layers… அடுக்குகளைப் பூட்டு… + Link Layers அடுக்குகளை இணை + Select Linked Layers இணைக்கப்பட்ட அடுக்குகளைத் தேர்ந்தெடு + Merge Layers அடுக்குகளை இணை + Merge Down கீழே இணை + Merge Group குழுவை இணை + Merge Visible தெரிவதை இணை + Stamp Visible தெரிவதை முத்திரையிடு + Select Layer Above மேலே உள்ள அடுக்கைத் தேர்ந்தெடு + Select Layer Below கீழே உள்ள அடுக்கைத் தேர்ந்தெடு + Select Top Layer மேல் அடுக்கைத் தேர்ந்தெடு + Select Bottom Layer கீழ் அடுக்கைத் தேர்ந்தெடு + Add Layer Above to Selection மேலே உள்ள அடுக்கைத் தேர்வில் சேர் + Add Layer Below to Selection கீழே உள்ள அடுக்கைத் தேர்வில் சேர் + Stamp Down கீழே முத்திரையிடு + Flatten Image படத்தைத் தட்டையாக்கு + Matting மேட்டிங் + Color Decontaminate… நிறத் தூய்மையாக்கு… + Remove Black Matte கருப்பு ஒளிமறுப்பை நீக்கு + Remove White Matte வெள்ளை ஒளிமறுப்பை நீக்கு + Defringe… விளிம்பு நீக்கு… + Group Layers அடுக்குகளைக் குழுவாக்கு + Ungroup Layers அடுக்குகளின் குழுவை நீக்கு + Hide Layers அடுக்குகளை மறை + Arrange ஒழுங்குபடுத்து + Bring to Front முன்னே கொண்டுவா + Bring Forward முன்னோக்கி கொண்டுவா + Send Backward பின்னோக்கி அனுப்பு + Send to Back பின்னே அனுப்பு + Reverse தலைகீழ் + Combine Shapes வடிவங்களை இணை + Unite Shapes வடிவங்களை ஒன்றிணை + Subtract Front Shape முன் வடிவத்தைக் கழி + Intersect Shape Areas வடிவப் பகுதிகளின் வெட்டுப்பகுதி + Exclude Overlapping Shapes ஒன்றுக்கொன்று மேலேறும் வடிவங்களை நீக்கு + Merge Shape Components வடிவக் கூறுகளை இணை + Align சீரமை + Top Edges மேல் விளிம்புகள் + Vertical Centers செங்குத்து மையங்கள் + Bottom Edges கீழ் விளிம்புகள் + Left Edges இடது விளிம்புகள் + Horizontal Centers கிடைமட்ட மையங்கள் + Right Edges வலது விளிம்புகள் + Distribute பகிர் + Vertically செங்குத்தாக + Horizontally கிடைமட்டமாக + Layer Edges அடுக்கு விளிம்புகள் + Isolate Layers அடுக்குகளைத் தனிமைப்படுத்து + Layer Content Options… அடுக்கு உள்ளடக்க விருப்பங்கள்… + Fill Content நிரப்பு உள்ளடக்கம் + Deselect Layers அடுக்குகளின் தேர்வை நீக்கு + Find Layers அடுக்குகளைத் தேடு + Layer Comps அடுக்கு அமைப்புகள் + Layers அடுக்குகள் + All Layers அனைத்து அடுக்குகள் + Hidden Layers மறைந்த அடுக்குகள் + Mask All Objects அனைத்துப் பொருட்களையும் மறையச் செய் + Subject பொருள் + Sky வானம் + Panels பலகங்கள் + Character Panel எழுத்துப் பலகம் + Paragraph Panel பத்திப் பலகம் + Character Styles Panel எழுத்துப் பாணிகள் பலகம் + Paragraph Styles Panel பத்திப் பாணிகள் பலகம் + Glyphs Panel குறியுருப் பலகம் + Anti-Alias விளிம்பு மென்மையாக்கு + Sharp கூர்மை + Crisp துல்லியம் + Strong வலிமை + Smooth மென்மை + Windows LCD விண்டோஸ் எல்.சி.டி + Windows Windows + Orientation திசையமைவு + Horizontal கிடைமட்டம் + Vertical செங்குத்து + OpenType ஓபன் டைப் + Standard Ligatures நிலையான இணைப்பெழுத்துகள் + Contextual Alternates சூழல் மாற்றுகள் + Discretionary Ligatures விருப்ப இணைப்பெழுத்துகள் + Swash அலங்கார எழுத்து + Oldstyle பழைய பாணி + Stylistic Alternates பாணி மாற்றுகள் + Titling Alternates தலைப்பு மாற்றுகள் + Ornaments அலங்காரங்கள் + Ordinals வரிசை எண்கள் + Fractions பின்னங்கள் + Create Work Path பணிப் பாதையை உருவாக்கு + Convert to Shape வடிவமாக மாற்று + Warp Text… உரை வளைப்பு… + Convert to Point Text புள்ளி உரையாக மாற்று + Convert to Paragraph Text பத்தி உரையாக மாற்று + Default Features இயல்புநிலை அம்சங்கள் + East Asian Features கிழக்கு ஆசிய அம்சங்கள் + Middle Eastern Features மத்திய கிழக்கு அம்சங்கள் + Middle Eastern & South Asian Composer மத்திய கிழக்கு & தெற்காசிய அமைப்பி + Update All Text Layers அனைத்து உரை அடுக்குகளையும் புதுப்பி + Replace All Missing Fonts காணாமல்போன அனைத்து எழுத்துருக்களையும் மாற்று + Resolve Missing Fonts… காணாமல்போன எழுத்துருக்களைத் தீர்… + Font Preview Size எழுத்துரு முன்னோட்ட அளவு + None எதுவுமில்லை + Small சிறியது + Medium நடுத்தரம் + Large பெரியது + Extra Large மிகப் பெரியது + Huge பிரம்மாண்டம் + Load Default Type Styles இயல்புநிலை உரைப் பாணிகளை ஏற்று + Save Default Type Styles இயல்புநிலை உரைப் பாணிகளைச் சேமி + Deselect தேர்வை நீக்கு + Reselect மீண்டும் தேர்ந்தெடு + Inverse தலைகீழ் + Inverse Selection தேர்வைத் தலைகீழாக்கு + Similar ஒத்தவை + Color Range… நிற வரம்பு… + Focus Area… கவனப் பகுதி… + Select and Mask… தேர்ந்தெடுத்து மறையச் செய்… + Smart Radius திறன்மிகு ஆரம் + Shift Edge விளிம்பை நகர்த்து + Decontaminate தூய்மையாக்கு + Output வெளியீடு + Refine Edge விளிம்பைச் செம்மைப்படுத்து + Modify மாற்றியமை + Border… எல்லை… + Smooth… மென்மையாக்கு… + Expand… விரிவாக்கு… + Contract… சுருக்கு… + Feather… இறகாக்கு… + Grow வளர் + Transform Selection தேர்வை உருமாற்று + Edit in Quick Mask Mode விரைவு மறைப்பு முறையில் திருத்து + Load Selection… தேர்வை ஏற்று… + Save Selection… தேர்வைச் சேமி… + Match All அனைத்தையும் பொருத்து + Last Filter கடைசி வடிகட்டி + Filter Gallery… வடிகட்டி காட்சியகம்… + Adaptive Wide Angle… தகவமைக்கும் அகலக் கோணம்… + Camera Raw Filter… கேமரா Raw வடிகட்டி… + Liquify… திரவமாக்கு… + Vanishing Point… மறையும் புள்ளி… + Blur மங்கலாக்கு + Blur Gallery மங்கல் காட்சியகம் + Field Blur… புல மங்கல்… + No pin selected பின் எதுவும் தேர்ந்தெடுக்கப்படவில்லை + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. பின்னைச் சேர்க்க படத்தில் கிளிக் செய்யவும்; நகர்த்த பின்னை இழுக்கவும், மங்கலை மாற்ற அதன் வளையத்தை இழுக்கவும். Delete தேர்ந்தெடுத்த பின்னை நீக்கும். + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. அளவை மாற்றவும் சுழற்றவும் வெளிக்கோட்டிலுள்ள கைப்பிடிகளையும், மூலைகளை வளைக்க சதுரத்தையும், மங்கல் தொடங்கும் இடத்தை அமைக்க உள்ளே உள்ள புள்ளிகளையும் இழுக்கவும். + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. பட்டையை நகர்த்த பின்னையும், மங்கலை மாற்ற அதன் வளையத்தையும் இழுக்கவும். கூர்மையான பட்டையை அகலமாக்க தொடர் கோடுகளையும், மாற்றத்தை அமைக்க புள்ளிக்கோடுகளையும், சுழற்ற புள்ளிகளையும் இழுக்கவும். + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. அளவை மாற்றவும் சுழற்றவும் வெளிக்கோட்டிலுள்ள கைப்பிடிகளை இழுக்கவும்; வளையம் மங்கல் கோணத்தை அமைக்கிறது. + Blur Angle மங்கல் கோணம் + Iris Blur… கருவிழி மங்கல்… + Tilt-Shift… டில்ட்-ஷிப்ட்… + Path Blur… பாதை மங்கல்… + Spin Blur… சுழல் மங்கல்… + Blur More மேலும் மங்கலாக்கு + Box Blur… பெட்டி மங்கல்… + Gaussian Blur… காசியன் மங்கல்… + Lens Blur… லென்ஸ் மங்கல்… + Motion Blur… இயக்க மங்கல்… + Radial Blur… ஆரை மங்கல்… + Shape Blur… வடிவ மங்கல்… + Smart Blur… திறன்மிகு மங்கல்… + Surface Blur… மேற்பரப்பு மங்கல்… + Average சராசரி + Displace… இடம்பெயர்… + Pinch… கிள்ளு… + Polar Coordinates… துருவ ஆயத்தொலைவுகள்… + Ripple… அலைவளையம்… + Shear… ஷியர்… + Spherize… கோளமாக்கு… + Twirl… சுழலச்செய்… + Wave… அலை… + ZigZag… ஜிக்ஜாக்… + Noise இரைச்சல் + Add Noise… இரைச்சலைச் சேர்… + Despeckle புள்ளிகளை நீக்கு + Dust & Scratches… தூசு & கீறல்கள்… + Median… இடைநிலை… + Reduce Noise… இரைச்சலைக் குறை… + Pixelate பிக்சலாக்கு + Color Halftone… நிற அரைத்தொனி… + Crystallize… படிகமாக்கு… + Facet முகப்பு + Fragment துண்டம் + Mezzotint… மெசோடின்ட்… + Mosaic… மொசைக்… + Pointillize… புள்ளிப்படுத்து… + Render ரெண்டர் + Clouds மேகங்கள் + Difference Clouds வேறுபாட்டு மேகங்கள் + Fibers… இழைகள்… + Flame… தீச்சுடர்… + Lens Flare… லென்ஸ் ஒளிப்பிழம்பு… + Lighting Effects… ஒளி விளைவுகள்… + Relight… மறுஒளியூட்டு… + Picture Frame… படச் சட்டகம்… + Tree… மரம்… + Sharpen கூர்மையாக்கு + Sharpen Edges விளிம்புகளைக் கூர்மையாக்கு + Sharpen More மேலும் கூர்மையாக்கு + Smart Sharpen… திறன்மிகு கூர்மையாக்கல்… + Unsharp Mask… கூர்மைப்படுத்துதல் மறைப்பு… + High Pass… உயர் அனுமதி… + Stylize பாணியாக்கு + Diffuse… பரப்பு… + Emboss… புடைப்பு… + Extrude… நீட்டு… + Find Edges விளிம்புகளைக் கண்டறி + Oil Paint… எண்ணெய் ஓவியம்… + Solarize சோலரைஸ் + Tiles… ஓடுகள்… + Trace Contour… வெளிக்கோட்டைத் தடமிடு… + Wind… காற்று… + Video வீடியோ + De-Interlace… இடைப்பின்னலை நீக்கு… + NTSC Colors NTSC நிறங்கள் + Other மற்றவை + Maximum… அதிகபட்சம்… + Minimum… குறைந்தபட்சம்… + Offset… நகர்வு… + Plug-ins செருகுநிரல்கள் + Proof Setup சரிபார்ப்பு அமைப்பு + Proof Colors சரிபார்ப்பு நிறங்கள் + Gamut Warning நிற வரம்பு எச்சரிக்கை + Working CMYK பணி CMYK + Working Cyan Plate பணி நீலப்பச்சை தட்டு + Working Magenta Plate பணி கருஞ்சிவப்பு தட்டு + Working Yellow Plate பணி மஞ்சள் தட்டு + Working Black Plate பணி கருப்பு தட்டு + Working CMY Plate பணி CMY தட்டு + Legacy Macintosh RGB பழைய மேகிண்டோஷ் RGB + Internet Standard RGB இணைய நிலையான RGB + Monitor RGB திரை RGB + Color Blindness — Protanopia-type சிவப்பு நிறக்குருடு + Color Blindness — Deuteranopia-type பச்சை நிறக்குருடு + Zoom In பெரிதாக்கு + Zoom In (hold, drag to scrub) பெரிதாக்கு (அழுத்திப் பிடித்து, நகர்த்தித் தேடவும்) + Zoom Out சிறிதாக்கு + Zoom Out (hold) சிறிதாக்கு (அழுத்திப் பிடிக்கவும்) + Fit on Screen திரைக்குப் பொருத்து + Fit Layer(s) on Screen அடுக்குகளைத் திரைக்குப் பொருத்து + Fit Artboard on Screen ஆர்ட்போர்டைத் திரைக்குப் பொருத்து + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size அச்சு அளவு + Pixel Aspect Ratio பிக்சல் விகிதம் + Pixel Aspect Ratio Correction பிக்சல் விகிதத் திருத்தம் + Custom Pixel Aspect Ratio… தனிப்பயன் பிக்சல் விகிதம்… + Square சதுரம் + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC அகலத்திரை (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL அகலத்திரை (1.46) + Anamorphic 2:1 (2) அனாமார்பிக் 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… 32 பிட் முன்னோட்ட விருப்பங்கள்… + Screen Mode திரை முறை + Standard Screen Mode நிலையான திரை முறை + Full Screen Mode With Menu Bar பட்டிப்பட்டையுடன் முழுத்திரை முறை + Full Screen Mode முழுத்திரை முறை + Cycle Screen Mode திரை முறைகளை மாற்றி மாற்று + Show காண்பி + Extras கூடுதல்கள் + Selection Edges தேர்வு விளிம்புகள் + Target Path இலக்குப் பாதை + Current Path தற்போதைய பாதை + Grid கட்டம் + Guides அளவுகோட்டு வரிகள் + Canvas Guides படச்சட்ட அளவுகோட்டு வரிகள் + Artboard Guides ஆர்ட்போர்டு அளவுகோட்டு வரிகள் + Count எண்ணிக்கை + Smart Guides திறன்மிகு அளவுகோட்டு வரிகள் + Slices துண்டுகள் + Notes குறிப்புகள் + Pixel Grid பிக்சல் கட்டம் + Pixel Art பிக்சல் கலை + Pixel Art Preview பிக்சல் கலை முன்னோட்டம் + Pattern Preview வடிவமைப்பு முன்னோட்டம் + Brush Preview தூரிகை முன்னோட்டம் + Mesh வலை + Edit Pins பின்களைத் திருத்து + Document Bounds ஆவண எல்லைகள் + Show Extras Options… கூடுதல் விருப்பங்களைக் காண்பி… + Rulers அளவுகோல்கள் + Snap ஒட்டு + Snap To இதனுடன் ஒட்டு + Lock Guides அளவுகோட்டு வரிகளை நிலைநிறுத்து + Clear Guides அளவுகோட்டு வரிகளை அழி + Clear Selected Artboard Guides தேர்ந்தெடுத்த ஆர்ட்போர்டு அளவுகோட்டு வரிகளை அழி + Clear Canvas Guides படச்சட்ட அளவுகோட்டு வரிகளை அழி + New Guide… புதிய அளவுகோட்டு வரி… + Lock Slices துண்டுகளைப் பூட்டு + Clear Slices துண்டுகளை அழி + Match Zoom பெரிதாக்கலைப் பொருத்து + Match Location இடத்தைப் பொருத்து + Match Rotation சுழற்சியைப் பொருத்து + Cascade அடுக்கு அடுக்காக + Tile ஓடுகளாக + Tile All Horizontally அனைத்தையும் கிடைமட்டமாக ஓடுகளாக்கு + Tile All Vertically அனைத்தையும் செங்குத்தாக ஓடுகளாக்கு + 2-up Horizontal 2-சாளரம் கிடைமட்டம் + 2-up Vertical 2-சாளரம் செங்குத்து + 3-up Horizontal 3-சாளரம் கிடைமட்டம் + 3-up Stacked 3-சாளரம் அடுக்கு + 3-up Vertical 3-சாளரம் செங்குத்து + 4-up 4-சாளரம் + 6-up 6-சாளரம் + Consolidate All to Tabs அனைத்தையும் தாவல்களாக ஒருங்கிணை + Float in Window சாளரத்தில் மிதக்கவிடு + Float All in Windows அனைத்தையும் சாளரங்களில் மிதக்கவிடு + New Window for Document ஆவணத்துக்குப் புதிய சாளரம் + Workspace பணியிடம் + Essentials அடிப்படைகள் + Graphic and Web வரைகலை & வலை + Painting ஓவியம் + Photography புகைப்படக்கலை + New Workspace… புதிய பணியிடம்… + Delete Workspace… பணியிடத்தை நீக்கு… + Reset Workspace பணியிடத்தை மீட்டமை + Reset Tool கருவியை மீட்டமை + Reset All Tools அனைத்து கருவிகளையும் மீட்டமை + Reset all tools to their default settings? அனைத்து கருவிகளையும் இயல்புநிலை அமைப்புகளுக்கு மீட்டமைக்கவா? + Lock Workspace பணியிடத்தைப் பூட்டு + Theme தோற்றம் + Pro Theme புரோ தோற்றம் + Pro Medium Gray Theme புரோ நடுத்தர சாம்பல் தோற்றம் + Pro Light Gray Theme புரோ வெளிர் சாம்பல் தோற்றம் + Pro Light Theme புரோ வெளிர் தோற்றம் + Studio Theme ஸ்டுடியோ தோற்றம் + Studio Light Theme ஸ்டுடியோ வெளிர் தோற்றம் + Adwaita Light Theme அத்வைதா வெளிர் தோற்றம் + Adwaita Dark Theme அத்வைதா கருமை தோற்றம் + Classic Theme கிளாசிக் தோற்றம் + Solarized dark சோலரைஸ்டு கருமை + Solarized Dark Theme சோலரைஸ்டு கருமை தோற்றம் + Breeze dark பிரீஸ் கருமை + Breeze Dark Theme பிரீஸ் கருமை தோற்றம் + Breeze light பிரீஸ் வெளிர் + Breeze Light Theme பிரீஸ் வெளிர் தோற்றம் + Soft dark மென் கருமை + Soft light மென் வெளிர் + Soft Dark Theme மென் கருமை தோற்றம் + Soft Light Theme மென் வெளிர் தோற்றம் + Next Theme அடுத்த தோற்றம் + Actions செயல்கள் + Brush Settings தூரிகை அமைப்புகள் + Brushes தூரிகைகள் + Channels சேனல்கள் + Character எழுத்து + Character Styles எழுத்துப் பாணிகள் + Clone Source நகலெடுப்பு மூலம் + Color நிறம் + Glyphs குறியுருக்கள் + Gradients நிறப் படித்திறன்கள் + Histogram ஹிஸ்டோகிராம் + History வரலாறு + Info தகவல் + Measurement Log அளவீட்டுப் பதிவு + Modifier Keys மாற்றி விசைகள் + Navigator வழிகாட்டி + Options விருப்பங்கள் + Paragraph பத்தி + Paragraph Styles பத்திப் பாணிகள் + Paths பாதைகள் + Patterns வடிவமைப்புகள் + Properties பண்புகள் + Shapes வடிவங்கள் + Styles பாணிகள் + Swatches நிற மாதிரிகள் + Timeline காலவரிசை + Tool Presets கருவி முன்னமைவுகள் + Tools கருவிகள் + About PhotoCraft போட்டோகிராப்ட் பற்றி + PhotoCraft Website போட்டோகிராப்ட் இணையதளம் + PhotoCraft on GitHub கிட்ஹப்பில் போட்டோகிராப்ட் + ArtCraft Website ஆர்ட்கிராப்ட் இணையதளம் + Join the ArtCraft Discord… ஆர்ட்கிராப்ட் டிஸ்கார்டில் சேர்… + Report an Issue… சிக்கலைப் புகாரளி… + WIA Support… WIA ஆதரவு… + Apply பயன்படுத்து + HSB/HSL HSB/HSL + Invert… தலைகீழாக்கு… + Load Files into Stack… கோப்புகளைக் குவியலில் ஏற்று… + Motion இயக்கம் + Notes… குறிப்புகள்… + Other… மற்றவை… + Remove Warp Split வளைப்பு பிரிவை நீக்கு + Shape வடிவம் + Split Warp Crosswise வளைப்பைக் குறுக்காகப் பிரி + Split Warp Horizontally வளைப்பைக் கிடைமட்டமாகப் பிரி + Split Warp Vertically வளைப்பைச் செங்குத்தாகப் பிரி + Statistics… புள்ளிவிவரங்கள்… + Refine Strokes செம்மைப்படுத்தும் கோடுகள் + Edits திருத்தங்கள் +@plural {n} item|{n} items {n} உருப்படி|{n} உருப்படிகள் + Auto தானியங்கி + (print to the printer) (அச்சுப்பொறியில் அச்சிடு) + + Add variable + மாறியைச் சேர் + 30 frames @ 30 fps 30 சட்டங்கள் @ 30 fps + 3D LUT File 3D LUT கோப்பு + Absolute முழுமையான + Absolute Colorimetric முழுமையான நிற அளவியல் + Action செயல் + Adaptive தகவமைவு + Adaptive Wide Angle isn't open தகவமைக்கும் அகலக் கோணம் திறக்கப்படவில்லை + Add சேர் + Add Layer Mask அடுக்கு மறைப்பைச் சேர் + Add Stop நிறுத்தத்தைச் சேர் + Add a layer style அடுக்கு பாணியைச் சேர் + Add a mask (from the selection; {key} inverts) மறைப்பைச் சேர் (தேர்விலிருந்து; {key} தலைகீழாக்கும்) + Add an adjustment சரிசெய்தலைச் சேர் + Add disk வட்டைச் சேர் + Add space after paragraph பத்திக்குப் பின் இடைவெளியைச் சேர் + Add space before paragraph பத்திக்கு முன் இடைவெளியைச் சேர் + Add to Sample மாதிரியில் சேர் + Add to selection ({key}) தேர்வில் சேர் ({key}) + Adjust for Zoom பெரிதாக்கலுக்கேற்ப சரிசெய் + Adjustment visible சரிசெய்தல் தெரிகிறது + Adobe RGB (1998) compatible Adobe RGB (1998) இணக்கமானது + Align and Distribute சீரமை & பகிர் + Align and distribute சீரமை & பகிர் + Align horizontal centers கிடைமட்ட மையங்களைச் சீரமை + Align left edges இடது விளிம்புகளைச் சீரமை + Align right edges வலது விளிம்புகளைச் சீரமை + Aligned சீரமைக்கப்பட்டது + All Caps அனைத்தும் பெரிய எழுத்து + All Slices அனைத்து துண்டுகள் + All User Slices அனைத்து பயனர் துண்டுகள் + All shortcuts reset to PhotoCraft defaults. அனைத்து குறுக்குவழிகளும் போட்டோகிராப்ட் இயல்புநிலைகளுக்கு மீட்டமைக்கப்பட்டன. + Alt Alt + Always use pressure for opacity ஒளிபுகாமைக்கு எப்போதும் அழுத்தத்தைப் பயன்படுத்து + Always use pressure for size அளவுக்கு எப்போதும் அழுத்தத்தைப் பயன்படுத்து + Amount அளவு + Anchor: நங்கூரம்: + Angle கோணம் + Angle Gradient கோண நிறப் படித்திறன் + Angle Jitter கோண அதிர்வு + Answer the unsaved-changes prompt first முதலில் சேமிக்கப்படாத மாற்றங்கள் குறித்த கேள்விக்குப் பதிலளிக்கவும் + Anti-alias விளிம்பு மென்மையாக்கு + Appearance தோற்றம் + Appearance (Layer Style) தோற்றம் (அடுக்கு பாணி) + Apply Layer Mask அடுக்கு மறைப்பைப் பயன்படுத்து + Add Mask to Selection மறைப்பைத் தேர்வில் சேர் + Subtract Mask from Selection தேர்விலிருந்து மறைப்பைக் கழி + Intersect Mask with Selection மறைப்புடன் தேர்வை வெட்டிக்கொள் + Apply Per Tip ஒவ்வொரு முனைக்கும் பயன்படுத்து + Apply next selected layer comp தேர்ந்தெடுத்த அடுத்த அடுக்கு அமைப்பைப் பயன்படுத்து + Apply previous selected layer comp தேர்ந்தெடுத்த முந்தைய அடுக்கு அமைப்பைப் பயன்படுத்து + Artboard ஆர்ட்போர்டு + Artboard Background Color ஆர்ட்போர்டு பின்னணி நிறம் + Assign working space பணி வெளியை ஒதுக்கு + Author: ஆசிரியர்: + Auto Hide தானாக மறை + Auto-Select: தானாகத் தேர்ந்தெடு: + Automatically level near-horizontal line segments கிட்டத்தட்ட கிடைமட்டமான கோடுகளைத் தானாக சமப்படுத்து + Automatically straighten and level தானாக நேராக்கி சமப்படுத்து + Automatically straighten near-vertical line segments கிட்டத்தட்ட செங்குத்தான கோடுகளைத் தானாக நேராக்கு + Background பின்னணி + Background Color பின்னணி நிறம் + Background Contents பின்னணி உள்ளடக்கம் + Custom background color தனிப்பயன் பின்னணி நிறம் + Balance சமநிலை + Baseline அடிப்படைக் கோடு + Baseline shift அடிப்படைக் கோடு மாற்றம் + Basic அடிப்படை + Before (Y) முன் (Y) + Save Settings… அமைப்புகளைச் சேமி… + Load Settings… அமைப்புகளை ஏற்று… + Save these settings as a Camera Raw .xmp file இந்த அமைப்புகளை கேமரா Raw .xmp கோப்பாகச் சேமி + Load settings from a Camera Raw .xmp file கேமரா Raw .xmp கோப்பிலிருந்து அமைப்புகளை ஏற்று + Loaded Camera Raw settings from {file} {file} இலிருந்து கேமரா Raw அமைப்புகள் ஏற்றப்பட்டன + Loaded Camera Raw settings from {file} ({n} not supported) {file} இலிருந்து கேமரா Raw அமைப்புகள் ஏற்றப்பட்டன ({n} ஆதரிக்கப்படவில்லை) + Begin recording பதிவைத் தொடங்கு + Bend: வளைவு: + Bicubic இருபக்கக் கனச்சதுர + Bicubic (smooth gradients) இருபக்கக் கனச்சதுர (மென்மையான நிறப் படித்திறன்கள்) + Automatic தானியங்கி + Bicubic Smoother (enlargement) இருபக்கக் கனச்சதுர மென்மையாக்கல் (பெரிதாக்குதல்) + Bicubic Sharper (reduction) இருபக்கக் கனச்சதுர கூர்மையாக்கல் (சிறிதாக்குதல்) + Bilinear பைலீனியர் + Black கருப்பு + Black & White கருப்பு & வெள்ளை + Black Point Compensation கருப்புப் புள்ளி ஈடுசெய்தல் + Black, White கருப்பு, வெள்ளை + Blacks கருப்புகள் + Blending கலத்தல் + Blending Options கலப்பு விருப்பங்கள் + Blue நீலம் + Blue, Red, Yellow நீலம், சிவப்பு, மஞ்சள் + Both Axes இரு அச்சுகளும் + Bottom கீழ் + Brightness ஒளிர்வு + Brightness Jitter ஒளிர்வு அதிர்வு + Brightness/Contrast ஒளிர்வு/மாறுபாடு + Bring the selected pin forward தேர்ந்தெடுத்த பின்னை முன்னே கொண்டுவா + Brush Preset picker தூரிகை முன்னமைவு தேர்வி + Brush Reconstruct Options தூரிகை மறுகட்டமைப்பு விருப்பங்கள் + Brush Tool Options தூரிகைக் கருவி விருப்பங்கள் + Cache Level: தற்காலிக சேமிப்பு நிலை: + Camera Raw isn't open கேமரா Raw திறக்கப்படவில்லை + Cancel ரத்துசெய் + Cancel (Esc) ரத்துசெய் (Esc) + Manage submitted jobs in the system print queue சமர்ப்பித்த வேலைகளைக் கணினி அச்சு வரிசையில் நிர்வகி + Cancel any current edits (Esc) தற்போதைய திருத்தங்களை ரத்துசெய் (Esc) + Cancel current crop operation (Esc) தற்போதைய வெட்டுச் செயலை ரத்துசெய் (Esc) + Cancel transform (Esc) உருமாற்றத்தை ரத்துசெய் (Esc) + Cancel warp (Esc) வளைப்பை ரத்துசெய் (Esc) + Canvas படச்சட்டம் + Canvas extension color: படச்சட்ட நீட்டிப்பு நிறம்: + Capture பதிவுசெய் + Capture the visibility, position and style of every layer with + below, then switch between versions. கீழே உள்ள + மூலம் ஒவ்வொரு அடுக்கின் தெரிவுநிலை, நிலை, பாணியைப் பதிவுசெய்து, பின்னர் பதிப்புகளுக்கு இடையே மாறவும். + Catch-up on Stroke End கோடு முடிவில் ஈடுசெய் + Center மையம் + Center Crop Marks மைய வெட்டுக் குறிகள் + Center text உரையை மையப்படுத்து + Change மாற்று + Change All அனைத்தையும் மாற்று + Change Screen Mode (F) திரை முறையை மாற்று (F) + Change To: இதற்கு மாற்று: + Channel Mixer சேனல் கலப்பி + Channel: சேனல்: + Check All Layers அனைத்து அடுக்குகளையும் சரிபார் + Check Spelling எழுத்துப்பிழை சரிபார் + Clarity தெளிவு + Clear All அனைத்தையும் அழி + Clear Override மேலெழுதலை அழி + Clear Samples மாதிரிகளை அழி + Click sets foreground · right-click sets background கிளிக் முன்புறத்தை அமைக்கும் · வலது கிளிக் பின்னணியை அமைக்கும் + Click the first point or press {key} to close · Esc cancels மூட முதல் புள்ளியைக் கிளிக் செய்யவும் அல்லது {key} அழுத்தவும் · Esc ரத்துசெய்யும் + Click to add a point · drag off or {key}-click or Delete to remove புள்ளியைச் சேர்க்க கிளிக் செய்யவும் · நீக்க வெளியே இழுக்கவும் அல்லது {key}-கிளிக் அல்லது Delete + Click to apply, {key}-click to clear overrides, double-click for Style Options பயன்படுத்த கிளிக், மேலெழுதல்களை அழிக்க {key}-கிளிக், பாணி விருப்பங்களுக்கு இருமுறை கிளிக் + Click to edit the gradient நிறப் படித்திறனைத் திருத்த கிளிக் செய்யவும் + Click to restore the current colour தற்போதைய நிறத்தை மீட்டமைக்க கிளிக் செய்யவும் + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out பெரிதாக்க கிளிக் · சிறிதாக்க {key}-கிளிக் · பெரிதாக்க/சிறிதாக்க வலது/இடதுபுறம் இழுக்கவும் + Click with the Note tool to add a note. குறிப்பைச் சேர்க்க குறிப்புக் கருவியால் கிளிக் செய்யவும். + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel கிளிக்: மூலை · இழு: மென்மை · முதல் புள்ளி கிளிக்: மூடு · {key} முடி · Esc ரத்து + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel கிளிக்: மூலை · இழு: மென்மை · {alt}-இழு: கைப்பிடியை உடை · முதல் புள்ளியைக் கிளிக்/இழு: மூடு · {key} முடி · Esc ரத்து + Clip to layer below கீழ் அடுக்குக்கு வெட்டு + Clipped வெட்டப்பட்டது + Close Style Options பாணி விருப்பங்களை மூடு + Cmd Cmd + Coated CMYK பூசிய CMYK + Color & Swatches நிறம் & நிற மாதிரிகள் + Color Balance நிறச் சமநிலை + Color Detail நிற விவரம் + Color Grading நிற தரப்படுத்தல் + Color Lookup நிறத் தேடல் + Color Management நிற மேலாண்மை + Color Mixer நிறக் கலப்பி + Color Mode நிற முறை + Color Priority நிற முன்னுரிமை + Color Range preview நிற வரம்பு முன்னோட்டம் + Color: நிறம்: + Colorize நிறமூட்டு + Colors நிறங்கள் + Commit Perspective Warp ({key}) பார்வைக்கோண வளைப்பை உறுதிசெய் ({key}) + Commit Puppet Warp ({key}) உருவ வளைப்பை உறுதிசெய் ({key}) + Commit any current edits ({key}) தற்போதைய திருத்தங்களை உறுதிசெய் ({key}) + Commit current crop operation ({key}) தற்போதைய வெட்டுச் செயலை உறுதிசெய் ({key}) + Commit transform ({key}) உருமாற்றத்தை உறுதிசெய் ({key}) + Commit warp ({key}) வளைப்பை உறுதிசெய் ({key}) + Constant மாறிலி + Constrain proportions விகிதங்களைக் கட்டுப்படுத்து + Contact Sheet II காண்டாக்ட் ஷீட் II + Content-Aware உள்ளடக்கம் அறிந்த + Contiguous தொடர்ச்சியான + Contrast மாறுபாடு + Control: கட்டுப்பாடு: + Convert to sRGB sRGB ஆக மாற்று + Copies நகல்கள் + Copper செம்பு + Copyright பதிப்புரிமை + Copyright and Contact Info பதிப்புரிமை & தொடர்புத் தகவல் + Corner Crop Marks மூலை வெட்டுக் குறிகள் + Corner radius மூலை ஆரம் + Correction திருத்தம் + Count Jitter எண்ணிக்கை அதிர்வு + Create உருவாக்கு + Create Droplet ட்ராப்லெட் உருவாக்கு + Create Texture பரப்பமைப்பை உருவாக்கு + Create Video Timeline வீடியோ காலவரிசையை உருவாக்கு + Create a new count group புதிய எண்ணிக்கைக் குழுவை உருவாக்கு + Create a new document or open an existing file. புதிய ஆவணத்தை உருவாக்கவும் அல்லது இருக்கும் கோப்பைத் திறக்கவும். + Create a new group புதிய குழுவை உருவாக்கு + Create a new layer புதிய அடுக்கை உருவாக்கு + Create new action புதிய செயலை உருவாக்கு + Create new channel புதிய சேனலை உருவாக்கு + Create new document from current state தற்போதைய நிலையிலிருந்து புதிய ஆவணத்தை உருவாக்கு + Create new fill or adjustment layer புதிய நிரப்பு அல்லது சரிசெய்தல் அடுக்கை உருவாக்கு + Create new gradient புதிய நிறப் படித்திறன் உருவாக்கு + Create new group புதிய குழுவை உருவாக்கு + Create new layer comp புதிய அடுக்கு அமைப்பை உருவாக்கு + Create new pattern from the selection தேர்விலிருந்து புதிய வடிவமைப்பை உருவாக்கு + Create new shape from the current path தற்போதைய பாதையிலிருந்து புதிய வடிவத்தை உருவாக்கு + Create new snapshot புதிய ஸ்னாப்ஷாட்டை உருவாக்கு + Create new tool preset புதிய கருவி முன்னமைவை உருவாக்கு + Crop Factor வெட்டுக் காரணி + Ctrl Ctrl + Current & Below தற்போதையது & கீழுள்ளவை + Current Layer தற்போதைய அடுக்கு + Current Size தற்போதைய அளவு + Current Tool Only தற்போதைய கருவி மட்டும் + Curve வளைவு + Curves வளைவுகள் + Custom தனிப்பயன் + Custom Shapes தனிப்பயன் வடிவங்கள் + Cyan நீலப்பச்சை + Cyan · Red நீலப்பச்சை · சிவப்பு + Darken கருமையாக்கு + Darks கருமைகள் + Data Sets தரவுத் தொகுப்புகள் + Default இயல்புநிலை + Default colours (D) இயல்புநிலை நிறங்கள் (D) + Default printer இயல்புநிலை அச்சுப்பொறி + Default restored. இயல்புநிலை மீட்டமைக்கப்பட்டது. + Define வரையறு + Define at least one variable first. முதலில் குறைந்தது ஒரு மாறியை வரையறுக்கவும். + Dehaze மூடுபனி நீக்கு + Delete Cropped Pixels வெட்டப்பட்ட பிக்சல்களை நீக்கு + Delete Group குழுவை நீக்கு + Delete Layer Mask அடுக்கு மறைப்பை நீக்கு + Delete Layers அடுக்குகளை நீக்கு + Add Shortcut குறுக்குவழியைச் சேர் + Delete Shortcut குறுக்குவழியை நீக்கு + Delete Timeline காலவரிசையை நீக்கு + Delete Tool Preset கருவி முன்னமைவை நீக்கு + Delete count group எண்ணிக்கைக் குழுவை நீக்கு + Delete current channel தற்போதைய சேனலை நீக்கு + Delete current state தற்போதைய நிலையை நீக்கு + Delete effect layer விளைவு அடுக்கை நீக்கு + Delete layer ‘{name}’? அடுக்கு ‘{name}’ ஐ நீக்கவா? + Delete {count} layers? {names} {count} அடுக்குகளை நீக்கவா? {names} + Delete layer? அடுக்கை நீக்கவா? + 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} அடுக்கு நீக்கப்பட்டது, ஆனால் உறுதிப்படுத்தல் விருப்பத்தைச் சேமிக்க முடியவில்லை: {error} + Delete layer அடுக்கை நீக்கு + Delete layer comp அடுக்கு அமைப்பை நீக்கு + Delete note குறிப்பை நீக்கு + Delete selected measurements தேர்ந்தெடுத்த அளவீடுகளை நீக்கு + Delete style பாணியை நீக்கு + Delete tool preset கருவி முன்னமைவை நீக்கு + Density அடர்த்தி + Density: அடர்த்தி: + Depth ஆழம் + Depth Jitter ஆழ அதிர்வு + Description விளக்கம் + Deselect All அனைத்தின் தேர்வையும் நீக்கு + Detail விவரம் + Diamond Gradient வைரச் நிறப் படித்திறன் + Difference வேறுபாடு + Diffusion பரவல் + Discard the embedded profile (don't color manage) உட்பொதிந்த சுயவிவரத்தை நிராகரி (நிற மேலாண்மை வேண்டாம்) + Discontiguous தொடர்ச்சியற்றது + Discord Discord + Dismiss விலக்கு + Display P3 Display P3 + Display Text உரையைக் காண்பி + Dither நிறக் கலப்பு + Divide… வகு… + Document ஆவணம் + Don't Save சேமிக்க வேண்டாம் + No இல்லை + Yes ஆம் + Done முடிந்தது + Dots புள்ளிகள் + Drag a rectangle around the object பொருளைச் சுற்றி ஒரு செவ்வகத்தை இழுக்கவும் + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. கட்டுப்பாட்டைச் சேர்க்க படத்தின் மீது இழுக்கவும்; கிடைமட்டம்/செங்குத்துக்கு Shift; மாற்ற அல்லது நீக்க கட்டுப்பாட்டை வலது கிளிக் செய்யவும். + Drag to move the active shape's path or the Work Path செயலில் உள்ள வடிவத்தின் பாதையை அல்லது பணிப் பாதையை நகர்த்த இழுக்கவும் + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath கிளிக்: நங்கூரத்தைத் தேர்ந்தெடு · இழு: நங்கூரம், கைப்பிடி அல்லது பகுதியை நகர்த்து · {shift}-கிளிக்: சேர் · {alt}-கிளிக்: முழு துணைப்பாதை + Draw with the Pen tool (P) or make a work path from a selection. பேனா கருவியால் (P) வரையவும் அல்லது தேர்விலிருந்து பணிப் பாதையை உருவாக்கவும். + Drop an image or PSD anywhere to open it. படத்தையோ PSD-யையோ எங்கு வேண்டுமானாலும் இட்டு திறக்கவும். + Native file drag-and-drop is unavailable சொந்த கோப்பு இழுத்துவிடுதல் கிடைக்கவில்லை + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland-இல் சொந்த கோப்பு இழுத்துவிடுதல் இன்னும் ஆதரிக்கப்படவில்லை. கோப்பு › திற என்பதைப் பயன்படுத்தவும், அல்லது கோப்பு மேலாளரில் படத்தை நகலெடுத்து {paste} மூலம் ஒட்டவும். + To drop files, start PhotoCraft under XWayland: `{command}` கோப்புகளை இட, போட்டோகிராப்டை XWayland-இல் தொடங்கவும்: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. அல்லது விருப்பத்தேர்வுகள் › செயல்திறன் › லினக்ஸ் காட்சி சேவையகத்தை X11 ஆக அமைக்கவும்: அப்போது போட்டோகிராப்ட் எப்போதும் XWayland-இல் தொடங்கும். + Edit › Paste திருத்து › ஒட்டு + Duplicate Layers… அடுக்குகளை நகலெடு… + Duplicate style பாணியை நகலெடு + Edit Toolbar… கருவிப்பட்டியைத் திருத்து… + Use File › Open, or paste a copied image with {paste}. கோப்பு › திற என்பதைப் பயன்படுத்தவும், அல்லது நகலெடுத்த படத்தை {paste} மூலம் ஒட்டவும். + Edit in Quick Mask Mode (Q) விரைவு மறைப்பு முறையில் திருத்து (Q) + Edit in Standard Mode (Q) நிலையான முறையில் திருத்து (Q) + Effects விளைவுகள் + Embed Color Profile நிறச் சுயவிவரத்தை உட்பொதி + Embedded Profile Mismatch உட்பொதிந்த சுயவிவரம் பொருந்தவில்லை + Enable airbrush-style build-up effects ஏர்பிரஷ் பாணி குவிப்பு விளைவுகளை இயக்கு + Enhance Edge விளிம்பை மேம்படுத்து + Entire Font முழு எழுத்துரு + Exact துல்லியம் + Expansion: விரிவாக்கம்: + Export Presets முன்னமைவுகளை ஏற்றுமதி செய் + Export selected measurements (CSV) தேர்ந்தெடுத்த அளவீடுகளை ஏற்றுமதி செய் (CSV) + Exposure வெளிப்பாடு + Exposure and Gamma வெளிப்பாடு & காமா + Exposure: வெளிப்பாடு: + Eyedropper நிறத் தேர்வி + Factor: காரணி: + Faux Bold போலி தடித்த எழுத்து + Faux Italic போலி சாய்வெழுத்து + Feather இறகாக்கு + Fewer Points குறைவான புள்ளிகள் + File Settings கோப்பு அமைப்புகள் + Fill நிரப்பு + Fill: நிரப்பு: + Filter for adjustment layers சரிசெய்தல் அடுக்குகளுக்கான வடிகட்டி + Filter for pixel layers பிக்சல் அடுக்குகளுக்கான வடிகட்டி + Filter for shape layers வடிவ அடுக்குகளுக்கான வடிகட்டி + Filter for smart objects திறன்மிகு பொருட்களுக்கான வடிகட்டி + Filter for type layers உரை அடுக்குகளுக்கான வடிகட்டி + Filter: வடிகட்டி: + Finger Painting விரல் ஓவியம் + Fisheye மீன்கண் + Fit பொருத்து + Fit Screen திரைக்குப் பொருத்து + Fill Screen திரையை நிரப்பு + Fit in View காட்சிக்குள் பொருத்து + Fixed நிலையானது + Fixed Ratio நிலையான விகிதம் + Fixed Size நிலையான அளவு + Flip புரட்டு + Flip X X புரட்டு + Flip X Jitter X புரட்டல் அதிர்வு + Flip Y Y புரட்டு + Flip Y Jitter Y புரட்டல் அதிர்வு + Flow ஓட்டம் + Flow Jitter ஓட்ட அதிர்வு + Focal Length குவிய நீளம் + Font எழுத்துரு + Font Size: எழுத்துரு அளவு: + Font size எழுத்துரு அளவு + Foreground முன்புறம் + Foreground to Background முன்புறத்திலிருந்து பின்னணிக்கு + Foreground/Background Jitter முன்புறம்/பின்னணி அதிர்வு + Format வடிவமைப்பு + Frame from Layers… அடுக்குகளிலிருந்து சட்டம்… + Free தன்னிச்சை + Full Spherical முழு கோளம் + Fuzziness: மங்கலான தன்மை: + GPU GPU + Gamma Correction காமா திருத்தம் + Gamma: காமா: + Global பொது + Glow Color பிரகாச நிறம் + Gradient Map நிறப் படித்திறன் வரைபடம் + Grain தானியம் + Gray சாம்பல் + Gray Gamma 2.2 சாம்பல் காமா 2.2 + Green பச்சை + Group குழு + Hardness கடினத்தன்மை + Heart இதயம் + Height உயரம் + Height: உயரம்: + Hide Auto Slices தானியங்கி துண்டுகளை மறை + Hide Properties பண்புகளை மறை + Highlight Compression ஒளிர்வு அழுத்தம் + Highlight Priority ஒளிர்வு முன்னுரிமை + Highlights ஒளிர்வுகள் + Home முகப்பு + Horizontal scale கிடைமட்ட அளவீடு + Hue சாயல் + Hue Jitter சாயல் அதிர்வு + Hue/Saturation சாயல்/செறிவு + Hyphenate இணைப்புக்கோடிடு + Ignore புறக்கணி + Ignore All அனைத்தையும் புறக்கணி + Image Size பட அளவு + Import Brushes… தூரிகைகளை இறக்குமதி செய்… + Gradient Preset Options நிறப் படித்திறன் முன்னமைவு விருப்பங்கள் + Import .GRD .GRD ஐ இறக்குமதி செய் + Choose a gradient file (.grd). நிறப் படித்திறன் கோப்பைத் (.grd) தேர்ந்தெடுக்கவும். + Import brushes (.abr) தூரிகைகளை இறக்குமதி செய் (.abr) + Import Presets முன்னமைவுகளை இறக்குமதி செய் + Imported இறக்குமதி செய்யப்பட்டது + Increase Type Size எழுத்துரு அளவை அதிகரி + Decrease Type Size எழுத்துரு அளவைக் குறை + Increase Type Size by 10 எழுத்துரு அளவை 10 புள்ளிகளால் அதிகரி + Decrease Type Size by 10 எழுத்துரு அளவை 10 புள்ளிகளால் குறை + Indent First முதல் வரி உள்தள்ளல் + Indent Left இடது உள்தள்ளல் + Indent Right வலது உள்தள்ளல் + Indent first line முதல் வரியை உள்தள்ளு + Indent left margin இடது ஓரத்தை உள்தள்ளு + Indent right margin வலது ஓரத்தை உள்தள்ளு + Input: உள்ளீடு: + Inside உள்ளே + Interlaced இடைப்பின்னல் + Interpolation: இடைக்கணிப்பு: + Intersect with selection ({key}) தேர்வுடன் வெட்டிக்கொள் ({key}) + Invert All அனைத்தையும் தலைகீழாக்கு + Invert has no settings. தலைகீழாக்கலுக்கு அமைப்புகள் இல்லை. + Italic சாய்வு + Join us on Discord Discord-இல் எங்களுடன் இணையுங்கள் + Justify all அனைத்தையும் இருபுறமும் சமப்படுத்து + Justify last centered கடைசி வரியை மையப்படுத்தி சமப்படுத்து + Justify last left கடைசி வரியை இடதுபுறம் சமப்படுத்து + Justify last right கடைசி வரியை வலதுபுறம் சமப்படுத்து + Kerning எழுத்திடைவெளி + Keyboard Shortcuts விசைப்பலகை குறுக்குவழிகள் + Keyboard Shortcuts and Menus விசைப்பலகை குறுக்குவழிகள் & பட்டிகள் + Kind வகை + Label Size குறியீட்டு அளவு + Labels குறியீடுகள் + Lanczos (sharp) Lanczos (கூர்மை) + Landscape கிடைமட்டம் + Last Document State கடைசி ஆவண நிலை + Layout அமைப்பு + Leading வரியிடைவெளி + Leading (set to the font size × auto-leading when Auto) வரியிடைவெளி (தானியங்கி எனில் எழுத்துரு அளவு × தானியங்கி வரியிடைவெளி) + Leave as is (don't color manage) அப்படியே விடு (நிற மேலாண்மை வேண்டாம்) + Left align text உரையை இடதுபுறம் சீரமை + Length: நீளம்: + Level நிலை + Levels நிலைகள் + Lighten ஒளிரச்செய் + Lightness வெளிர்மை + Lights ஒளிகள் + Limits: வரம்புகள்: + Linear Gradient நேர்கோட்டுச் நிறப் படித்திறன் + Link layers அடுக்குகளை இணை + Link width and height அகலத்தையும் உயரத்தையும் இணை + Liquify is not open Liquify திறக்கப்படவில்லை + Load ஏற்று + Load 3D LUT… 3D LUT ஐ ஏற்று… + Load channel as selection சேனலைத் தேர்வாக ஏற்று + Load… ஏற்று… + Localized Color Clusters உள்ளூர் நிறக் கொத்துகள் + Lock பூட்டு + Lock all அனைத்தையும் பூட்டு + Lock image pixels பட பிக்சல்களைப் பூட்டு + Lock layer அடுக்கைப் பூட்டு + Lock position நிலையைப் பூட்டு + Lock transparent pixels ஒளிஊடுருவும் பிக்சல்களைப் பூட்டு + Lock: பூட்டு: + Logical Length: தருக்க நீளம்: + Logical Units: தருக்க அலகுகள்: + Luminance ஒளிர்வுத்திறன் + Luminance Detail ஒளிர்வுத்திறன் விவரம் + Magenta கருஞ்சிவப்பு + Magenta · Green கருஞ்சிவப்பு · பச்சை + Maintain aspect ratio விகிதத்தைப் பேணு + Marker Size குறி அளவு + Mask All அனைத்தையும் மறை + Mask Options மறைப்புத் தேர்வுகள் + Masking மறைப்புருவாக்கம் + Master முதன்மை + Matte ஒளிமறுப்பு + Median: இடைநிலை: + Menus பட்டிகள் + Mesh Size வலை அளவு + Metadata மெட்டாடேட்டா + Metrics அளவீடுகள் + Midpoint நடுப்புள்ளி + Midtones நடுத்தொனிகள் + Midtones from: நடுத்தொனிகள் இதிலிருந்து: + Midtones to: நடுத்தொனிகள் இதுவரை: + Minimum Depth குறைந்தபட்ச ஆழம் + Minimum Diameter குறைந்தபட்ச விட்டம் + Minimum Roundness குறைந்தபட்ச வட்டத்தன்மை + Mode: முறை: + Monochrome ஒற்றைநிறம் + More Points மேலும் புள்ளிகள் + Move effect layer down விளைவு அடுக்கைக் கீழே நகர்த்து + Move effect layer up விளைவு அடுக்கை மேலே நகர்த்து + Move to இங்கு நகர்த்து + Name பெயர் + Name: பெயர்: + Nearest Neighbor அருகிலுள்ள அண்டை + Nearest Neighbor (hard edges) அருகிலுள்ள அண்டை (கடின விளிம்புகள்) + New Size புதிய அளவு + New document… புதிய ஆவணம்… + New effect layer புதிய விளைவு அடுக்கு + New selection புதிய தேர்வு + Next frame அடுத்த சட்டம் + Next note அடுத்த குறிப்பு + No Color நிறம் இல்லை + No Color Management நிற மேலாண்மை இல்லை + No Dither நிறக் கலப்பு இல்லை + No author ஆசிரியர் இல்லை + No character style எழுத்துப் பாணி இல்லை + No document ஆவணம் இல்லை + No matching commands பொருந்தும் கட்டளைகள் இல்லை + No presets for the current tool. தற்போதைய கருவிக்கு முன்னமைவுகள் இல்லை. + No presets of this type. இந்த வகைக்கு முன்னமைவுகள் இல்லை. + No properties பண்புகள் இல்லை + No tool presets. கருவி முன்னமைவுகள் இல்லை. + Noise Reduction இரைச்சல் குறைப்பு + Normal இயல்பு + Not in Dictionary: அகராதியில் இல்லை: + OK சரி + Offset நகர்வு + Only Web Colors வலை நிறங்கள் மட்டும் + Opacity ஒளிபுகாமை + Opacity Jitter ஒளிபுகாமை அதிர்வு + Opacity: ஒளிபுகாமை: + Open a document to define variables. மாறிகளை வரையறுக்க ஆவணத்தைத் திறக்கவும். + Open a document to select a colour range. நிற வரம்பைத் தேர்ந்தெடுக்க ஆவணத்தைத் திறக்கவும். + Open a document to use the timeline. காலவரிசையைப் பயன்படுத்த ஆவணத்தைத் திறக்கவும். + Optical ஒளியியல் + Optimized மேம்படுத்தப்பட்டது + Option-click to define a source point to clone from நகலெடுக்கும் மூலப் புள்ளியை வரையறுக்க Option-கிளிக் செய்யவும் + Original அசல் + Output Channel: வெளியீட்டுச் சேனல்: + Output Levels: வெளியீட்டு நிலைகள்: + Output: வெளியீடு: + Outside வெளியே + Overlay Color மேலடுக்கு நிறம் + Overlay Opacity மேலடுக்கு ஒளிபுகாமை + Overlay: Rule of Thirds மேலடுக்கு: மூன்றில் ஒரு பங்கு விதி + Override Pressure அழுத்தத்தை மேலெழுது + Override Rotation சுழற்சியை மேலெழுது + Override Tilt சாய்வை மேலெழுது + Override Tilt X X சாய்வை மேலெழுது + Override Tilt Y Y சாய்வை மேலெழுது + PRESET DETAILS முன்னமைவு விவரங்கள் + PSD PSD + Paint Overlay வண்ண மேலடுக்கு + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. பலகங்களின் இடங்கள் பணியிடத்தில் சேமிக்கப்படுகின்றன. விசைப்பலகை குறுக்குவழிகள், பட்டிகள், கருவிப்பட்டி விருப்பத்திற்குரியவை. + Paper காகிதம் + Path பாதை + Pattern வடிவமைப்பு + Pattern Size வடிவமைப்பு அளவு + Pause இடைநிறுத்து + Percent சதவீதம் + Perceptual உணர்வுசார் + Perspective Warp is not active கோண வளைப்பு செயலில் இல்லை + Perspective Warp: draw planes along the image's perspective, then switch to Warp கோண வளைப்பு: படத்தின் பார்வைக்கோணத்தில் தளங்களை வரைந்து, பின்னர் வளைப்புக்கு மாறவும் + Photo Filter புகைப்பட வடிகட்டி + PhotoCraft போட்டோகிராப்ட் + PhotoCraft Manages Colors போட்டோகிராப்ட் நிறங்களை நிர்வகிக்கிறது + PhotoCraft — an open-source, native image editor written in Rust. போட்டோகிராப்ட் — ரஸ்டில் எழுதப்பட்ட திறந்த மூல, சொந்த பட எடிட்டர். + Pin Depth: பின் ஆழம்: + Pixel Length: பிக்சல் நீளம்: + Pixels/Centimeter பிக்சல்/சென்டிமீட்டர் + Pixels/Inch பிக்சல்/அங்குலம் + Pixels: பிக்சல்கள்: + Play இயக்கு + Play selection தேர்வை இயக்கு + Portrait செங்குத்து + Position நிலை + Position and Size நிலை & அளவு + Posterize வண்ணக் குறைப்பு + Preserve Details விவரங்களைப் பேணு + Preserve Luminosity ஒளிர்வுத்திறனைப் பேணு + Preset முன்னமைவு + Preset Type முன்னமைவு வகை + Preset: முன்னமைவு: + Presets: முன்னமைவுகள்: + Press keys… விசைகளை அழுத்தவும்… + Pressure அழுத்தம் + Pressure for Opacity ஒளிபுகாமைக்கான அழுத்தம் + Pressure for Size அளவுக்கான அழுத்தம் + Prevent auto-nesting in and out of Artboards and Frames ஆர்ட்போர்டுகள் மற்றும் சட்டங்களுக்குள்/வெளியே தானியங்கி உட்பொதிவைத் தடு + Preview முன்னோட்டம் + Previous frame முந்தைய சட்டம் + Previous note முந்தைய குறிப்பு + Print அச்சிடு + Printer அச்சுப்பொறி + Printer Manages Colors அச்சுப்பொறி நிறங்களை நிர்வகிக்கிறது + Printer Setup அச்சுப்பொறி அமைப்பு + Printing Marks அச்சுக் குறிகள் + Progressive படிப்படியானது + Promote உயர்த்து + Protect Detail விவரத்தைப் பாதுகாக்கவும் + Protect Foreground Color முன்புற நிறத்தைப் பாதுகாக்கவும் + Protect Tones தொனிகளைப் பாதுகாக்கவும் + Proximity Match அண்மைப் பொருத்தம் + Pulled String Mode இழுத்த கயிறு முறை + Puppet Warp is not active உருவ வளைப்பு செயலில் இல்லை + Purity தூய்மை + Quality தரம் + Quick Actions விரைவு செயல்கள் + Quick Export As PNG PNG ஆக விரைவு ஏற்றுமதி + Radial Gradient ஆரை நிறப் படித்திறன் + Radius ஆரம் + Radius: ஆரம்: + Range: வீச்சு: + Rate வேகம் + Recently Used சமீபத்தில் பயன்படுத்தியவை + Reconstruct மறுகட்டமை + Record ● a sequence of edits, then play ▶ it on any document. திருத்தங்களின் வரிசையை ● பதிவுசெய்து, பின்னர் எந்த ஆவணத்திலும் ▶ இயக்கவும். + Red சிவப்பு + Redefine style by current selection தற்போதைய தேர்வின்படி பாணியை மறுவரையறு + Refine செம்மைப்படுத்து + Reflected Gradient பிரதிபலித்த நிறப் படித்திறன் + Registration Marks பதிவுக் குறிகள் + Regular வழக்கமானது + Relative சார்புடைய + Relative Colorimetric சார்பு நிற அளவியல் + Remove நீக்கு + Remove All Pins அனைத்து பின்களையும் நீக்கு + Remove Background பின்னணியை நீக்கு + Remove Planes தளங்களை நீக்கு + Remove Tool கருவியை நீக்கு + Remove stop நிறுத்தத்தை நீக்கு + Remove variable மாறியை நீக்கு + Rename பெயர்மாற்று + Rename Group… குழுவின் பெயரை மாற்று… + Rename Layer… அடுக்கின் பெயரை மாற்று… + Rename Tool Preset… கருவி முன்னமைவின் பெயரை மாற்று… + Rename… பெயர்மாற்று… + Resample மறுமாதிரியெடு + Reset All to Defaults அனைத்தையும் இயல்புநிலைக்கு மீட்டமை + Import Shortcuts… குறுக்குவழிகளை இறக்குமதி செய்… + Load a keyboard shortcut set saved as .kys .kys ஆகச் சேமித்த விசைப்பலகை குறுக்குவழித் தொகுப்பை ஏற்று + Imported {n} shortcuts from {file}. Click OK to keep them. {file} இலிருந்து {n} குறுக்குவழிகள் இறக்குமதி செய்யப்பட்டன. அவற்றை வைத்திருக்க சரி என்பதைக் கிளிக் செய்யவும். + {n} commands are not in PhotoCraft. {n} கட்டளைகள் போட்டோகிராப்டில் இல்லை. + Keyboard shortcuts imported விசைப்பலகை குறுக்குவழிகள் இறக்குமதி செய்யப்பட்டன + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. உங்கள் விசைப்பலகை குறுக்குவழித் தொகுப்பு {set} இங்கு பயன்பாட்டில் உள்ளது: {n} குறுக்குவழிகள் இயல்புநிலையிலிருந்து வேறுபடுகின்றன. திருத்து › விசைப்பலகை குறுக்குவழிகள் அவற்றைக் காட்டும்; அனைத்தையும் இயல்புநிலைக்கு மீட்டமை இதைச் செயல்தவிர்க்கும். + Import keyboard shortcuts? விசைப்பலகை குறுக்குவழிகளை இறக்குமதி செய்யவா? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? இந்தக் கணினியில் மற்றொரு பட எடிட்டரிலிருந்து உங்கள் விசைப்பலகை குறுக்குவழித் தொகுப்பு {set} ஐ போட்டோகிராப்ட் கண்டறிந்தது: அதன் {n} குறுக்குவழிகள் போட்டோகிராப்டின் குறுக்குவழிகளிலிருந்து வேறுபடுகின்றன. இறக்குமதி செய்யவா? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. திருத்து › விசைப்பலகை குறுக்குவழிகள் › அனைத்தையும் இயல்புநிலைக்கு மீட்டமை என்பது இறக்குமதியைச் செயல்தவிர்க்கும். + Don't Import இறக்குமதி செய்யாதே + The keyboard shortcut set was not imported: {error} விசைப்பலகை குறுக்குவழித் தொகுப்பு இறக்குமதி செய்யப்படவில்லை: {error} + Reset Section பகுதியை மீட்டமை + Reset Transform உருமாற்றத்தை மீட்டமை + Reset to defaults இயல்புநிலைக்கு மீட்டமை + Resolution தெளிவுத்திறன் + Resolution: தெளிவுத்திறன்: + Restore All அனைத்தையும் மீட்டமை + Restore Defaults இயல்புநிலைகளை மீட்டமை + Restrictive (Web) கட்டுப்பாடான (வலை) + Right align text உரையை வலதுபுறம் சீரமை + Rigid உறுதியான + Rotate: சுழற்று: + Rotation சுழற்சி + Roughness கரடுமுரடு + Roundness வட்டத்தன்மை + Roundness Jitter வட்டத்தன்மை அதிர்வு + Rulers & Grids அளவுகோல்கள் & கட்டங்கள் + Sample All Layers அனைத்து அடுக்குகளையும் மாதிரியெடு + Sample: மாதிரி: + Sampling: Background Swatch மாதிரியெடுப்பு: பின்னணி நிற மாதிரி + Sampling: Continuous மாதிரியெடுப்பு: தொடர்ச்சியானது + Sampling: Once மாதிரியெடுப்பு: ஒருமுறை + Saturate செறிவூட்டு + Saturation செறிவு + Saturation Jitter செறிவு அதிர்வு + Save HTML and Images HTML & படங்களைச் சேமி + Save as PDF PDF ஆகச் சேமி + Save selection as channel தேர்வைச் சேனலாகச் சேமி + Save… சேமி… + Scale to Fit Media ஊடகத்துக்குப் பொருத்த அளவு மாற்று + Scatter சிதறல் + Scratch disks நினைவக வட்டுகள் + Search தேடு + Search commands கட்டளைகளைத் தேடு + Search commands, tools and panels… கட்டளைகள், கருவிகள், பலகங்களைத் தேடு… + Search fonts எழுத்துருக்களைத் தேடு + Select All அனைத்தையும் தேர்ந்தெடு + Select Subject பொருளைத் தேர்ந்தெடு + Select a type layer or click into text to insert glyphs குறியுருக்களைச் செருக உரை அடுக்கைத் தேர்ந்தெடுக்கவும் அல்லது உரையில் கிளிக் செய்யவும் + Select text to apply a character style எழுத்துப் பாணியைப் பயன்படுத்த உரையைத் தேர்ந்தெடுக்கவும் + Select: தேர்வு: + Selections தேர்வுகள் + Selective தேர்ந்தெடுத்தது + Selective Color தேர்ந்தெடுத்த நிறம் + Send the selected pin backward தேர்ந்தெடுத்த பின்னைப் பின்னே அனுப்பு + Set additional smoothing options கூடுதல் மென்மையாக்கல் விருப்பங்களை அமை + Set background color பின்னணி நிறத்தை அமை + Set foreground color முன்புற நிறத்தை அமை + Set painting symmetry options ஓவிய சமச்சீர் விருப்பங்களை அமை + Symmetry Off சமச்சீர் அணைப்பு + Set shape fill type வடிவ நிரப்பு வகையை அமை + Set shape stroke type வடிவக் கோடு வகையை அமை + Set the text color உரை நிறத்தை அமை + Create warped text வளைப்பு செய்த உரையை உருவாக்கு + Shadows நிழல்கள் + Shape: வடிவம்: + Sharpening கூர்மையாக்கல் + Shift Shift + Shortcut removed. குறுக்குவழி நீக்கப்பட்டது. + Show All Menu Items அனைத்து பட்டி உருப்படிகளையும் காண்பி + Show Backdrop பின்புலத்தைக் காண்பி + Show Guides அளவுகோட்டு வரிகளை காண்பி + Show Mask மறைப்பைக் காண்பி + Show Mesh வலையைக் காண்பி + Show Overlay மேலடுக்கைக் காண்பி + Show Transform Controls உருமாற்றக் கட்டுப்பாடுகளைக் காண்பி + Show or hide the Notes panel குறிப்புகள் பலகத்தைக் காண்பி அல்லது மறை + Show: காண்பி: + Sides பக்கங்கள் + Sides: பக்கங்கள்: + Size அளவு + Size Jitter அளவு அதிர்வு + Size: அளவு: + Slice Options… துண்டு விருப்பங்கள்… + Slices From Guides அளவுகோட்டு வரிகளிலிருந்து துண்டுகள் + Slices are locked (View › Lock Slices) துண்டுகள் பூட்டப்பட்டுள்ளன (காட்சி › துண்டுகளைப் பூட்டு) + Small Caps சிறிய பெரிய எழுத்துகள் + Smoothing மென்மையாக்கல் + Space After பின் இடைவெளி + Space Before முன் இடைவெளி + Spacing இடைவெளி + Spell check complete. எழுத்துப்பிழை சரிபார்ப்பு முடிந்தது. + Split: பிரி: + Star ratio நட்சத்திர விகிதம் + Stop playing/recording இயக்கம்/பதிவை நிறுத்து + Straighten நேராக்கு + Straighten Layer அடுக்கை நேராக்கு + Strength: வலிமை: + Strikethrough குறுக்குக்கோடு + Stroke கோடு + Stroke Catch-up கோடு ஈடுசெய்தல் + Stroke: கோடு: + Location இடம் + Location: இடம்: + Style பாணி + Style Options பாணி விருப்பங்கள் + Style Options… பாணி விருப்பங்கள்… + Style: Normal பாணி: இயல்பு + Subtract from Sample மாதிரியிலிருந்து கழி + Subtract from selection ({key}) தேர்விலிருந்து கழி ({key}) + Suggestions: பரிந்துரைகள்: + Swap colours (X) நிறங்களை மாற்றிக்கொள் (X) + Swaps height and width உயரத்தையும் அகலத்தையும் மாற்றிக்கொள்கிறது + Switch between free transform and warp modes தன்னிச்சை உருமாற்றம் மற்றும் வளைப்பு முறைகளுக்கு இடையே மாறு + Switch theme தோற்றத்தை மாற்று + Temperature வெப்பநிலை + Tetrahedral நான்முகம் + Text Position: உரை நிலை: + Texture பரப்பமைப்பு + Texture Each Tip ஒவ்வொரு முனைக்கும் பரப்பமைப்பு + Threshold வாசல் மதிப்பு + Threshold Level வாசல் நிலை + Thumbnail size (smallest: list) சிறுபடம் அளவு (மிகச் சிறியது: பட்டியல்) + Tilt X X சாய்வு + Tilt Y Y சாய்வு + Tint சாயம் + Toggle count group visibility எண்ணிக்கைக் குழுவின் தெரிவுநிலையை மாற்று + Toggle text orientation உரைத் திசையமைவை மாற்று + Tolerance சகிப்புத்தன்மை + Tolerance: சகிப்புத்தன்மை: + Tone: தொனி: + Toolbar கருவிப்பட்டி + Top மேல் + Total: மொத்தம்: + Tracking எழுத்து இடைவெளி + Tracking (1/1000 em) எழுத்து இடைவெளி (1/1000 em) + Transparency ஒளிஊடுருவல் + Transparent ஒளிஊடுருவும் + Trilinear மும்மடி நேரிய + Type Styles உரைப் பாணிகள் + Type: வகை: + Underline அடிக்கோடு + Untitled பெயரிடப்படாதது + Untitled-1 பெயரிடப்படாதது-1 + Update layer comp (visibility, position and appearance) அடுக்கு அமைப்பைப் புதுப்பி (தெரிவுநிலை, நிலை, தோற்றம்) + Use Default இயல்புநிலையைப் பயன்படுத்து + Use Legacy பழையதைப் பயன்படுத்து + Use Measurement Scale அளவீட்டு அளவுகோலைப் பயன்படுத்து + Variables bind a layer's visibility, text or pixels to a named data slot. மாறிகள் ஒரு அடுக்கின் தோற்றநிலை, உரை அல்லது பிக்சல்களை பெயரிடப்பட்ட தரவு இடைவெளியுடன் இணைக்கின்றன. + Vertical scale செங்குத்து அளவீடு + Vibrance துடிப்பு + View Options காட்சி விருப்பங்கள் + Vignetting ஓரங்கள் மங்குதல் + Violet, Orange ஊதா, ஆரஞ்சு + Visibility தெரிவுநிலை + Visible தெரிவது + Warp: வளைப்பு: + Web Snap வலை ஒட்டு + Weight எடை + Weight: எடை: + White வெள்ளை + White Balance: As Shot வெள்ளைச் சமநிலை: படமெடுத்தபடி + Whites வெள்ளைகள் + Width அகலம் + Slant சாய்வு + Width: அகலம்: + Work Path பணிப் பாதை + Yellow மஞ்சள் + Yellow · Blue மஞ்சள் · நீலம் + Zoom பெரிதாக்கு + Zoom in பெரிதாக்கு + Zoom out சிறிதாக்கு + command or shortcut கட்டளை அல்லது குறுக்குவழி + current தற்போதையது + image file path பட கோப்புப் பாதை + modified மாற்றப்பட்டது + name பெயர் + new புதியது + {name} options {name} விருப்பங்கள் + ↑↓ navigate {key} run esc close ↑↓ வழிசெல் {key} இயக்கு esc மூடு + Version {version} பதிப்பு {version} + New Document புதிய ஆவணம் + New Document With Mask மறைப்புடன் புதிய ஆவணம் + New Layer With Mask மறைப்புடன் புதிய அடுக்கு + New Layer புதிய அடுக்கு + Save Preset… முன்னமைவைச் சேமி… + Preset Name முன்னமைவு பெயர் + No saved presets yet. சேமித்த முன்னமைவுகள் இன்னும் இல்லை. + Error பிழை + Command கட்டளை + Adjustment layers have nothing to transform சரிசெய்தல் அடுக்குகளில் உருமாற்ற எதுவும் இல்லை + Altitude உயரக்கோணம் + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua அக்வா + Art & Illustration கலை & விளக்கப்படம் + ArtCraft ஆர்ட்கிராப்ட் + Art History Brush Tool கலை வரலாற்றுத் தூரிகைக் கருவி + Background Eraser Tool பின்னணி அழிப்புக் கருவி + Bevel & Emboss சரிவு & புடைப்பு + Blend Mode கலப்பு முறை + Blues நீலங்கள் + Blur Tool மங்கல் கருவி + Bold தடித்த + Brush தூரிகை + Block தொகுதி + Brush Pose தூரிகை நிலை + Brush Tip Shape தூரிகை முனை வடிவம் + Brush Tool தூரிகைக் கருவி + Build-up குவிப்பு + Burn Tool கருமையாக்கு கருவி + Centimeters சென்டிமீட்டர் + Choke நெருக்கு + Classic கிளாசிக் + Click to add text உரையைச் சேர்க்க கிளிக் செய்யவும் + Click to fill similar colours ஒத்த நிறங்களை நிரப்ப கிளிக் செய்யவும் + Clone Stamp Tool நகலெடுப்பு முத்திரைக் கருவி + Color Burn நிறக் கருமையாக்கு + Color Dodge நிற ஒளிர்வு + Color Dynamics நிற இயக்கவியல் + Color Overlay நிற மேலடுக்கு + Color Sampler Tool நிற மாதிரி கருவி + Comic Book காமிக் புத்தகம் + Conform இணங்கு + Content-Aware Fill needs a selection on a pixel layer உள்ளடக்கம் அறிந்த நிரப்புக்கு பிக்சல் அடுக்கில் தேர்வு தேவை + Couldn't place {name}: {error} {name} ஐ வைக்க முடியவில்லை: {error} + Could not transform: the layer is empty உருமாற்ற முடியவில்லை: அடுக்கு காலியாக உள்ளது + Could not use Perspective Warp: the layer is empty கோண வளைப்பைப் பயன்படுத்த முடியவில்லை: அடுக்கு காலியாக உள்ளது + Could not use Puppet Warp: no opaque pixels உருவ வளைப்பைப் பயன்படுத்த முடியவில்லை: ஒளிபுகா பிக்சல்கள் இல்லை + Could not use Puppet Warp: the layer is empty உருவ வளைப்பைப் பயன்படுத்த முடியவில்லை: அடுக்கு காலியாக உள்ளது + Crop Tool வெட்டுக் கருவி + Crosswise குறுக்காக + Current Tool தற்போதைய கருவி + Custom Shape Tool தனிப்பயன் வடிவக் கருவி + Cyans நீலப்பச்சைகள் + Default PhotoCraft Size இயல்புநிலை போட்டோகிராப்ட் அளவு + Delete Workspace பணியிடத்தை நீக்கு + Diamond வைரம் + Dimensions: பரிமாணங்கள்: + Direction திசை + Distance தூரம் + Document Dimensions ஆவணப் பரிமாணங்கள் + Document Profile ஆவணச் சுயவிவரம் + Document Sizes ஆவண அளவுகள் + Dodge Tool ஒளிர்வுக் கருவி + Down கீழ் + Drag to draw a gradient நிறப் படித்திறனை வரைய இழுக்கவும் + Drag to move the active layer செயலில் உள்ள அடுக்கை நகர்த்த இழுக்கவும் + Drag to pan · hold Space with any tool நகர்த்திப் பார்க்க இழுக்கவும் · எந்தக் கருவியிலும் Space ஐ அழுத்திப் பிடிக்கவும் + Drop Shadow விழும் நிழல் + Dual Brush இரட்டைத் தூரிகை + Edge விளிம்பு + Efficiency திறன் + Efficiency: 100% திறன்: 100% + Ellipse Tool நீள்வட்டக் கருவி + Elliptical Marquee Tool நீள்வட்டத் தேர்வுக் கருவி + Single Row Marquee Tool ஒற்றை வரிசைத் தேர்வுக் கருவி + Single Column Marquee Tool ஒற்றை நெடுவரிசைத் தேர்வுக் கருவி + Emboss புடைப்பு + Eraser அழிப்பான் + Eraser Tool அழிப்பான் கருவி + ExtraBold மிகத் தடித்த + ExtraLight மிக மெல்லிய + Eyedropper Tool நிறத் தேர்வி கருவி + Fade மங்கச்செய் + Fill Opacity நிரப்பு ஒளிபுகாமை + Film & Video திரைப்படம் & வீடியோ + From இதிலிருந்து + General பொது + GitHub GitHub + Gradient Overlay நிறப் படித்திறன் மேலடுக்கு + Gradient Tool நிறப் படித்திறன் கருவி + Greens பச்சைகள் + Hand Tool கை கருவி + Hand Tool (hold) கை கருவி (அழுத்திப் பிடிக்கவும்) + Hard கடினம் + Hard Mix கடின கலவை + Healing Brush Tool குணப்படுத்தும் தூரிகைக் கருவி + Highlights from: ஒளிர்வுகள் இதிலிருந்து: + History Brush Tool வரலாற்றுத் தூரிகைக் கருவி + Horizontal Type Tool கிடைமட்ட உரைக் கருவி + Horizontal Type Mask Tool கிடைமட்ட உரை மறைப்புக் கருவி + Vertical Type Mask Tool செங்குத்து உரை மறைப்புக் கருவி + Image Size: பட அளவு: + Inches அங்குலங்கள் + Indexed குறியீட்டுடையது + Initial Direction தொடக்கத் திசை + Inner Bevel உள் சரிவு + Inner Glow உள் பிரகாசம் + Inner Shadow உள் நிழல் + Lab Lab + Landscape, 10 x 8 கிடைமட்டம், 10 x 8 + Landscape, 6 x 4 கிடைமட்டம், 6 x 4 + Landscape, 7 x 5 கிடைமட்டம், 7 x 5 + Lasso லாஸ்ஸோ + Lasso Tool லாஸ்ஸோ கருவி + Layer Count அடுக்கு எண்ணிக்கை + Layer Knocks Out Drop Shadow அடுக்கு விழும் நிழலை நீக்குகிறது + Legal Legal + Letter Letter + Light வெளிர் + Line Tool கோட்டுக் கருவி + Linear நேர்கோட்டு + Linear Burn நேர்கோட்டு எரிப்பு + Linear Height நேர்கோட்டு உயரம் + Link with Layer அடுக்குடன் இணை + Lorem Ipsum தமிழ் வாழ்க + Magentas கருஞ்சிவப்புகள் + Magic Eraser Tool மாய அழிப்பான் கருவி + Magic Wand Tool மாயக்கோல் கருவி + Marquee தேர்வுச்சட்டம் + Mean: சராசரி: + Measurement Scale அளவீட்டு அளவுகோல் + Measurement Scale Marker அளவீட்டு அளவுகோல் குறி + Millimeters மில்லிமீட்டர் + Mobile மொபைல் + Move Tool நகர்த்தும் கருவி + Could not use the move tool because the layer is locked. அடுக்கு பூட்டப்பட்டிருப்பதால் நகர்த்தும் கருவியைப் பயன்படுத்த முடியவில்லை. + Could not complete the Free Transform command because the layer is locked. அடுக்கு பூட்டப்பட்டிருப்பதால் தன்னிச்சை உருமாற்றக் கட்டளையை முடிக்க முடியவில்லை. + Multiply பெருக்கு + My Brushes என் தூரிகைகள் + Neutrals நடுநிலைகள் + New Workspace புதிய பணியிடம் + Note Tool குறிப்புக் கருவி + Object Selection Tool பொருள் தேர்வுக் கருவி + Off அணை + Open திற + Orange ஆரஞ்சு + Original Ratio அசல் விகிதம் + Out Of Gamut நிற வரம்புக்கு வெளியே + Outer Bevel வெளிச் சரிவு + Outer Glow வெளிப் பிரகாசம் + Overlay மேலடுக்கு + Paint Bucket Tool வண்ண வாளிக் கருவி + Paints from the document's opening state ஆவணம் திறந்த நிலையிலிருந்து வண்ணமிடுகிறது + Path Selection Tool பாதைத் தேர்வுக் கருவி + Direct Selection Tool நேரடித் தேர்வுக் கருவி + Add Anchor Point Tool நங்கூரப் புள்ளி சேர்க்கும் கருவி + Delete Anchor Point Tool நங்கூரப் புள்ளி நீக்கும் கருவி + Click a segment to add an anchor point · {alt}-click an anchor to delete it தொகுதியைச் சொடுக்கி ஒரு நங்கூரப் புள்ளியைச் சேர்க்கவும் · நங்கூரப் புள்ளியை நீக்க {alt}-சொடுக்கவும் + Click an anchor point to delete it · {alt}-click a segment to add one நங்கூரப் புள்ளியை நீக்க அதைச் சொடுக்கவும் · புதிய புள்ளியைச் சேர்க்க தொகுதியை {alt}-சொடுக்கவும் + Pattern Overlay வடிவ மேலடுக்கு + Pen Pressure பேனா அழுத்தம் + Pen Tilt பேனா சாய்வு + Pen Tool பேனா கருவி + Convert Point Tool புள்ளி மாற்றும் கருவி + Photo புகைப்படம் + PhotoCraft website போட்டோகிராப்ட் இணையதளம் + Picas பிகாக்கள் + Pillow Emboss தலையணைப் புடைப்பு + Pixel Replacement பிக்சல் மாற்றீடு + Pixels பிக்சல்கள் + Points புள்ளிகள் + Polygon Tool பலகோணக் கருவி + Polygonal Lasso Tool பலகோண லாஸ்ஸோ கருவி + Portrait, 4 x 6 செங்குத்து, 4 x 6 + Portrait, 5 x 7 செங்குத்து, 5 x 7 + PostScript (72 points/inch) PostScript (72 புள்ளிகள்/அங்குலம்) + Postcard அஞ்சல் அட்டை + Poster சுவரொட்டி + Pro (Dark) புரோ (கருமை) + Pro (Light) புரோ (வெளிர்) + Pro (Light Gray) புரோ (வெளிர் சாம்பல்) + Pro (Medium Gray) புரோ (நடுத்தர சாம்பல்) + Protect Texture பரப்பமைப்பைப் பாதுகாக்கவும் + Purple ஊதா + Quick Mask விரைவு மறைப்பு + Quick Selection Tool விரைவுத் தேர்வுக் கருவி + Radial ஆரை + Rasterize Layer அடுக்கை ராஸ்டராக்கு + Rasterize Layers அடுக்குகளை ராஸ்டராக்கு + Ratio விகிதம் + Recent சமீபத்தியவை + Rectangle Tool செவ்வகக் கருவி + Rectangular Marquee Tool செவ்வகத் தேர்வுக் கருவி + Reds சிவப்புகள் + Reflected பிரதிபலித்தது + Sample: foreground colour மாதிரி: முன்புற நிறம் + Sampled Colors மாதிரியெடுத்த நிறங்கள் + Satin சாடின் + Save Pixel Aspect Ratio பிக்சல் விகிதத்தைச் சேமி + Scattering சிதறல் + Scratch Sizes நினைவக அளவுகள் + Select Data Points தரவுப் புள்ளிகளைத் தேர்ந்தெடு + Selection தேர்வு + SemiBold அரைத் தடித்த + Shadows up to: நிழல்கள் இதுவரை: + Shape Dynamics வடிவ இயக்கவியல் + Sharpen Tool கூர்மையாக்கும் கருவி + Show Extras Options கூடுதல் விருப்பங்களைக் காண்பி + Slice Select Tool துண்டுத் தேர்வுக் கருவி + Slice Tool துண்டுக் கருவி + Smart Filters திறன்மிகு வடிகட்டிகள் + Smudge Tool தேய்க்கும் கருவி + Soft மென்மை + Soften மென்மையாக்கு + Source மூலம் + Source: set மூலம்: அமை + Sponge Tool உறிஞ்சிக் கருவி + Spot Healing Brush Tool புள்ளி குணப்படுத்தும் தூரிகைக் கருவி + Spread பரவல் + Square, 12 x 12 சதுரம், 12 x 12 + Square, 5 x 5 சதுரம், 5 x 5 + Std Dev: திட்ட விலகல்: + Studio (Dark) ஸ்டுடியோ (கருமை) + Studio (Light) ஸ்டுடியோ (வெளிர்) + Stylus Wheel தொடுபேனா சக்கரம் + Subtract கழி + Tabloid டேப்ளாய்ட் + Text Replacement உரை மாற்றீடு + Thin மெல்லிய + Traditional (72.27 points/inch) பாரம்பரிய (72.27 புள்ளிகள்/அங்குலம்) + Transfer பரிமாற்றம் + Triangle Tool முக்கோணக் கருவி + Unsaved changes சேமிக்கப்படாத மாற்றங்கள் + Uses the foreground and background colours. முன்புற மற்றும் பின்னணி நிறங்களைப் பயன்படுத்துகிறது. + Web வலை + Web Large வலை பெரியது + Web Minimum வலை குறைந்தபட்சம் + Web Most Common வலை மிகவும் பொதுவானது + WebP (lossless) WebP (இழப்பற்றது) + Wet Edges ஈர விளிம்புகள் + Yellows மஞ்சள்கள் + Zoom Tool பெரிதாக்கும் கருவி + {name} Properties {name} பண்புகள் + Revert “{name}” to the last saved version? Your changes will be lost. “{name}” ஐ கடைசியாகச் சேமித்த பதிப்புக்குத் திருப்பவா? உங்கள் மாற்றங்கள் இழக்கப்படும். + Do you want to save the changes you made to “{name}” before quitting? வெளியேறும் முன் “{name}” இல் செய்த மாற்றங்களைச் சேமிக்க விரும்புகிறீர்களா? + Do you want to save the changes you made to “{name}” before closing? மூடும் முன் “{name}” இல் செய்த மாற்றங்களைச் சேமிக்க விரும்புகிறீர்களா? + Doc: {flat}/{layered} ஆவணம்: {flat}/{layered} + Scratch: {size} நினைவக: {size} + Untagged {mode} குறிச்சொல்லிடப்படாத {mode} + Invalid {mode} profile தவறான {mode} சுயவிவரம் + Unnamed {mode} profile பெயரிடப்படாத {mode} சுயவிவரம் + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch தெளிவுத்திறன்: {dpi} பிக்சல்/அங்குலம் + Doc: {w} × {h} px ஆவணம்: {w} × {h} px + Frame {current} / {total} சட்டம் {current} / {total} + Count: {n} எண்ணிக்கை: {n} + BLANK DOCUMENT PRESETS ({n}) வெற்று ஆவண முன்னமைவுகள் ({n}) + Constraints ({n}) கட்டுப்பாடுகள் ({n}) + Align {what} {what} ஐ சீரமை +@plural {n} Layer|{n} Layers {n} அடுக்கு|{n} அடுக்குகள் +@plural {n} layer|{n} layers {n} அடுக்கு|{n} அடுக்குகள் +@plural Group · {n} layer|Group · {n} layers குழு · {n} அடுக்கு|குழு · {n} அடுக்குகள் + Additional plugins folder கூடுதல் செருகுநிரல் கோப்புறை + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control முகவர் கட்டுப்பாட்டை அனுமதி + Allow scripts to connect ஸ்கிரிப்டுகள் இணைய அனுமதி + Always எப்போதும் + Always create smart objects when placing வைக்கும்போது எப்போதும் திறன்மிகு பொருட்களை உருவாக்கு + Animated zoom அனிமேஷன் பெரிதாக்கல் + Apply auto tone தானியங்கி தொனியைப் பயன்படுத்து + Ask கேள் + Ask before saving layered tiff அடுக்கு TIFF ஐச் சேமிக்கும் முன் கேள் + Auto collapse icon panels ஐகான் பலகங்களைத் தானாகச் சுருக்கு + Auto show hidden panels மறைந்த பலகங்களைத் தானாகக் காண்பி + Auto show home screen முகப்புத் திரையைத் தானாகக் காண்பி + Autosave தானியங்கிச் சேமிப்பு + Autosave minutes தானியங்கிச் சேமிப்பு நிமிடங்கள் + Beep when done முடிந்ததும் ஒலி எழுப்பு + Bicubic automatic இருபக்கக் கனச்சதுர தானியங்கி + Bicubic sharper இருபக்கக் கனச்சதுர கூர்மை + Bicubic smoother இருபக்கக் கனச்சதுர மென்மை + Bit depth பிட் ஆழம் + Brush preview color தூரிகை முன்னோட்ட நிறம் + Cache levels தற்காலிக சேமிப்பு நிலைகள் + Cache tile size தற்காலிக சேமிப்பு ஓடு அளவு + Camera Raw Defaults கேமரா Raw இயல்புநிலைகள் + Canvas border படச்சட்ட விளிம்பு + Show canvas dots படச்சட்ட புள்ளிகளைக் காண்பி + Clip thumbnails to layer bounds சிறுபடங்களை அடுக்கு எல்லைக்கு வெட்டு + Clip Thumbnails to Layer Bounds சிறுபடங்களை அடுக்கு எல்லைக்கு வெட்டு + Canvas color படச்சட்ட நிறம் + Canvas custom color விருப்பமான படச்சட்ட நிறம் + Color picker நிறத் தேர்வி + Color space நிற வெளி + Column width நெடுவரிசை அகலம் + Concise சுருக்கமான + Control port கட்டுப்பாட்டு முனையம் + Convert to srgb sRGB ஆக மாற்று + Cursors சுட்டிகள் + Custom dark தனிப்பயன் கருமை + Custom light தனிப்பயன் வெளிர் + Dark கருமை + Dark gray கருஞ்சாம்பல் + Dashed lines கீற்றுக் கோடுகள் + Destination இலக்கு + Detailed விரிவான + Disks வட்டுகள் + Display p3 Display P3 + Double click layer mask launches select and mask அடுக்கு மறைப்பை இருமுறை சொடுக்கும்போது தேர்ந்தெடுத்து மறைப்புச் செய் திறக்கும் + Drop shadow விழும் நிழல் + Dynamic color sliders இயங்குநிலை நிற நழுவிகள் + East asian கிழக்கு ஆசிய + Effect cache mb விளைவு தற்காலிக சேமிப்பு MB + Enable flick panning தட்டி நகர்த்துதலை இயக்கு + Enable floating document window docking மிதக்கும் ஆவணச் சாளர இணைப்பை இயக்கு + Enable narrow options bar குறுகிய விருப்பப் பட்டையை இயக்கு + Enabled இயக்கப்பட்டது + Enhanced Controls மேம்படுத்தப்பட்ட கட்டுப்பாடுகள் + Export clipboard கிளிப்போர்டை ஏற்றுமதி செய் + Extra large மிகப் பெரியது + File Handling கோப்பு கையாளுதல் + File path கோப்புப் பாதை + Fill new type layers with placeholder புதிய உரை அடுக்குகளை இடஒதுக்கீட்டு உரையால் நிரப்பு + Font preview எழுத்துரு முன்னோட்டம் + Full size tip முழு அளவு முனை + Gamut warning color நிற வரம்பு எச்சரிக்கை நிறம் + Gamut warning opacity நிற வரம்பு எச்சரிக்கை ஒளிபுகாமை + Generator enabled உருவாக்கி இயக்கப்பட்டது + Gif GIF + Glossy பளபளப்பான + Grid color கட்ட நிறம் + Grid colors கட்ட நிறங்கள் + Grid size கட்ட அளவு + Grid style கட்ட பாணி + Grid unit கட்ட அலகு + Gridline every கட்டக்கோடு ஒவ்வொரு + Guide color அளவுகோட்டு வரிகள் நிறம் + Guide style அளவுகோட்டு வரிகள் பாணி + Guides, Grid & Slices அளவுகோட்டு வரிகள், கட்டம் & துண்டுகள் + Gutter இடைவெளிப்பட்டை + History Log வரலாற்றுப் பதிவு + History states வரலாற்று நிலைகள் + Ignore exif profile tag exif சுயவிவரக் குறிச்சொல்லைப் புறக்கணி + Image interpolation பட இடைக்கணிப்பு + Image previews பட முன்னோட்டங்கள் + Integrations ஒருங்கிணைப்புகள் + Interface இடைமுகம் + Jpeg quality JPEG தரம் + Jpg JPG + Language மொழி + Large tabs பெரிய தாவல்கள் + Legacy compositing பழைய கூட்டிணைப்பு + Light gray வெளிர் சாம்பல் + Line கோடு + Lines கோடுகள் + Lossless இழப்பற்றது + Lowercase extension சிறிய எழுத்து நீட்டிப்பு + Maximize psd compatibility PSD இணக்கத்தன்மையை அதிகப்படுத்து + Medium gray நடுத்தர சாம்பல் + Memory usage mb நினைவகப் பயன்பாடு MB + Low resolution previews குறைந்த தெளிவுத்திறன் முன்னோட்டங்கள் + Missing glyph protection காணாமல்போன குறியுரு பாதுகாப்பு + Modern நவீன + Nearest neighbor அருகிலுள்ள அண்டை + Never ஒருபோதும் இல்லை + Normal tip இயல்பான முனை + Open as smart object திறன்மிகு பொருளாகத் திற + Open in camera raw கேமரா Raw இல் திற + Open documents as tabs ஆவணங்களைத் தாவல்களாகத் திற + Overscroll மிகைஉருட்டல் + Performance செயல்திறன் + Png PNG + Point size புள்ளி அளவு + Precise துல்லியமான + Preserve details விவரங்களைப் பேணு + Print resolution அச்சுத் தெளிவுத்திறன் + Pro புரோ + Pro light புரோ வெளிர் + Pro light gray புரோ வெளிர் சாம்பல் + Pro medium புரோ நடுத்தரம் + Pro photo புரோ போட்டோ + Quick export format விரைவு ஏற்றுமதி வடிவம் + Quick export location விரைவு ஏற்றுமதி இடம் + Recent file count சமீபத்திய கோப்பு எண்ணிக்கை + LUT folder LUT கோப்புறை + Recent files சமீபத்திய கோப்புகள் + Recent fonts சமீபத்திய எழுத்துருக்கள் + Recover on launch தொடங்கும்போது மீட்டெடு + Remember workspace changes பணியிட மாற்றங்களை நினைவில் வை + Resize image during place வைக்கும்போது படத்தின் அளவை மாற்று + Rotate view with trackpad டிராக்பேடால் காட்சியைச் சுழற்று + Same folder அதே கோப்புறை + Save in background பின்னணியில் சேமி + Scratch Disks நினைவக வட்டுகள் + Screen திரை + Screen resolution திரைத் தெளிவுத்திறன் + Scrubby slider acceleration ஸ்க்ரப்பி நழுவி முடுக்கம் + Sessions only அமர்வுகள் மட்டும் + Sharpen for இதற்காகக் கூர்மையாக்கு + Show channels in color சேனல்களை நிறத்தில் காண்பி + Show crosshair in brush tip தூரிகை முனையில் குறுக்குக்கோடு காண்பி + Show extension panels நீட்டிப்புப் பலகங்களைக் காண்பி + Show font names in english எழுத்துரு பெயர்களை ஆங்கிலத்தில் காண்பி + Show menu colors பட்டி நிறங்களைக் காண்பி + Show only crosshair while painting வண்ணமிடும்போது குறுக்குக்கோடு மட்டும் காண்பி + Show slice numbers துண்டு எண்களைக் காண்பி + Show tooltips கருவிக்குறிப்புகளைக் காண்பி + Enable animations அனிமேஷன்களை இயக்கு + Show highlight on rollover சுட்டி மேலே செல்லும்போது ஒளிர்வைக் காண்பி + Show bounding box when dragging layer அடுக்கை இழுக்கும்போது எல்லைப் பெட்டியைக் காண்பி + System title bar கணினி தலைப்புப் பட்டை + Show transformation values உருமாற்ற மதிப்புகளைக் காண்பி + Slice color துண்டு நிறம் + Smart guide color திறன்மிகு அளவுகோட்டு வரி நிறம் + Smart quotes திறன்மிகு மேற்கோள்கள் + Snap vector tools and transforms to pixel grid திசையன் கருவிகளையும் உருமாற்றங்களையும் பிக்சல் கட்டத்துடன் ஒட்டு + Srgb sRGB + Standard நிலையான + Studio ஸ்டுடியோ + Studio light ஸ்டுடியோ வெளிர் + Adwaita அத்வைதா + Adwaita dark அத்வைதா கருமை + Subdivisions உட்பிரிவுகள் + System கணினி + Text engine உரை இயந்திரம் + Text file உரைக் கோப்பு + Tiny மிகச் சிறியது + Touch gestures தொடு சைகைகள் + Transparency & Gamut ஒளிஊடுருவல் & நிற வரம்பு + Type units உரை அலகுகள் + UI font size UI எழுத்துரு அளவு + UI scale UI அளவு + Units & Rulers அலகுகள் & அளவுகோல்கள் + Use additional plugins folder கூடுதல் செருகுநிரல் கோப்புறையைப் பயன்படுத்து + Use esc to commit உறுதிசெய்ய Esc ஐப் பயன்படுத்து + Use gpu GPU ஐப் பயன்படுத்து + Use legacy free transform பழைய தன்னிச்சை உருமாற்றத்தைப் பயன்படுத்து + Use shift key for tool switch கருவி மாற்ற Shift விசையைப் பயன்படுத்து + Vary round brush hardness on hud HUD இல் வட்டத் தூரிகை கடினத்தன்மையை மாற்று + Webp WebP + World ready உலகளாவிய ஆதரவு + Zoom clicked point to center கிளிக் செய்த புள்ளியை மையத்துக்குப் பெரிதாக்கு + Zoom resizes windows பெரிதாக்கல் சாளர அளவை மாற்றும் + Zoom with scroll wheel உருள் சக்கரத்தால் பெரிதாக்கு + Zoom with trackpad pinch டிராக்பேட் கிள்ளலால் பெரிதாக்கு + Pass Through கடந்துசெல் + Dissolve கரை + Darker Color கருமையான நிறம் + Linear Dodge (Add) நேர்கோட்டு ஒளிர்வு (சேர்) + Lighter Color வெளிர்ந்த நிறம் + Soft Light மென் ஒளி + Hard Light கடின ஒளி + Vivid Light துடிப்பான ஒளி + Linear Light நேர்கோட்டு ஒளி + Pin Light பின் ஒளி + Exclusion விலக்கு + Divide வகு + Luminosity ஒளிர்வுத்திறன் +@plural {n} sample|{n} samples {n} மாதிரி|{n} மாதிரிகள் + {added}, {removed} subtracted {added}, {removed} கழிக்கப்பட்டது + Adjustment Preview சரிசெய்தல் முன்னோட்டம் + Airbrush: keeps depositing paint while the pointer rests. ஏர்பிரஷ்: சுட்டி நிற்கும் போதும் வண்ணத்தைத் தொடர்ந்து படியவைக்கிறது. + Angle: கோணம்: + Blend If கலப்பு நிபந்தனை + Close Tab Group தாவல் குழுவை மூடு + Collapse சுருக்கு + Collapse All Groups அனைத்துக் குழுக்களையும் சுருக்கு + Collapse Panel Group பலகக் குழுவைச் சுருக்கு + Collapse group ({key}-click: all groups) குழுவைச் சுருக்கு ({key}-கிளிக்: அனைத்துக் குழுக்கள்) + Create new brush from the current settings தற்போதைய அமைப்புகளிலிருந்து புதிய தூரிகையை உருவாக்கு + Create new brush from these settings இந்த அமைப்புகளிலிருந்து புதிய தூரிகையை உருவாக்கு + Update the current brush with these settings தற்போதைய தூரிகையை இந்த அமைப்புகளால் புதுப்பி + Delete brush தூரிகையை நீக்கு + Drag the arrow to set the angle, the dots to set the roundness கோணத்தை அமைக்க அம்புக்குறியையும், வட்டத்தன்மையை அமைக்க புள்ளிகளையும் இழுக்கவும் + Expand விரிவாக்கு + Expand Panel Group பலகக் குழுவை விரிவாக்கு + Expand group ({key}-click: all groups) குழுவை விரிவாக்கு ({key}-கிளிக்: அனைத்துக் குழுக்கள்) + Fade steps மங்கல் படிகள் + Hide the layer's effects ({key}-click: all layers) அடுக்கின் விளைவுகளை மறை ({key}-கிளிக்: அனைத்து அடுக்குகள்) + Import Brushes… (.abr or image) தூரிகைகளை இறக்குமதி செய்… (.abr அல்லது படம்) + Import Brush from Image… படத்திலிருந்து தூரிகையை இறக்குமதி செய்… + Move Group Down குழுவைக் கீழே நகர்த்து + Move Group Up குழுவை மேலே நகர்த்து + Reset the brush to the defaults தூரிகையை இயல்புநிலைக்கு மீட்டமை + Restore original size அசல் அளவை மீட்டமை + Roundness: வட்டத்தன்மை: + Search Brushes தூரிகைகளைத் தேடு + Show the layer's effects ({key}-click: all layers) அடுக்கின் விளைவுகளைக் காண்பி ({key}-கிளிக்: அனைத்து அடுக்குகள்) + Brush picker தூரிகைத் தேர்வி + Erase அழி + Erase with Brush or Pencil (hold) தூரிகை அல்லது பென்சிலைக் கொண்டு அழி (அழுத்திப் பிடி) + Right click with painting tools ஓவியக் கருவிகளுடன் வலது கிளிக் + System Info… கணினித் தகவல்… + System Info கணினித் தகவல் + More panels மேலும் பலகங்கள் + More documents மேலும் ஆவணங்கள் + These settings aren't available in PhotoCraft yet. இந்த அமைப்புகள் போட்டோகிராப்டில் இன்னும் கிடைக்கவில்லை. + Graphics வரைகலை + Reset GPU Backend GPU பின்புலத்தை மீட்டமை + Applies at next launch. அடுத்த தொடக்கத்தில் பொருந்தும். + Click to sample the foreground colour · {key}-click for background முன்புற நிறத்தை மாதிரியெடுக்க கிளிக் செய்யவும் · பின்னணிக்கு {key}-கிளிக் + {key}-click to set the source மூலத்தை அமைக்க {key}-கிளிக் செய்யவும் + {key} to subtract கழிக்க {key} + Clone source {n} ({key}-click with Clone Stamp to set) நகலெடுப்பு மூலம் {n} (அமைக்க நகலெடுப்பு முத்திரையுடன் {key}-கிளிக்) + Source: not set ({key}-click with Clone Stamp) மூலம்: அமைக்கப்படவில்லை (நகலெடுப்பு முத்திரையுடன் {key}-கிளிக்) + Pixel Layer பிக்சல் அடுக்கு + Type Layer உரை அடுக்கு + Shape Layer வடிவ அடுக்கு + Group Layer குழு அடுக்கு + Fill Layer நிரப்பு அடுக்கு + Smart Object Layer திறன்மிகு பொருள் அடுக்கு + Create Warped Text உரை வளைவை உருவாக்கு + Rasterize Smart Object திறன்மிகு பொருளை ராஸ்டராக்கு + Rasterize Fill Content நிரப்பு உள்ளடக்கத்தை ராஸ்டராக்கு + Rasterize Layer Style அடுக்கு பாணியை ராஸ்டராக்கு + Unlock: picking a preset replaces these settings பூட்டைத் திற: முன்னமைவைத் தேர்ந்தெடுத்தால் இந்த அமைப்புகள் மாற்றப்படும் + Lock: keep these settings when picking another preset பூட்டு: மற்றொரு முன்னமைவைத் தேர்ந்தெடுக்கும்போது இந்த அமைப்புகளைத் தக்கவை + Diameter: விட்டம்: + Hardness: கடினத்தன்மை: + No brush presets தூரிகை முன்னமைவுகள் இல்லை + Toggle the Brush Settings panel (F5) தூரிகை அமைப்புகள் பலகத்தை மாற்று (F5) + Create new brush preset from the current settings தற்போதைய அமைப்புகளிலிருந்து புதிய தூரிகை முன்னமைவை உருவாக்கு + Brush Settings… (F5): every option of this brush தூரிகை அமைப்புகள்… (F5): இந்தத் தூரிகையின் அனைத்து விருப்பங்களும் + Rename Brush… தூரிகையின் பெயரை மாற்று… + Delete Brush தூரிகையை நீக்கு + New Brush Preset… புதிய தூரிகை முன்னமைவு… + Capture Brush Size in Preset முன்னமைவில் தூரிகை அளவைப் பதிவுசெய் + Include Tool Settings கருவி அமைப்புகளைச் சேர் + Include Color நிறத்தைச் சேர் + Enter a name for the brush preset தூரிகை முன்னமைவுக்குப் பெயரை உள்ளிடவும் + A brush preset with this name already exists இந்தப் பெயரில் ஒரு தூரிகை முன்னமைவு ஏற்கனவே உள்ளது + Brush Preset Options தூரிகை முன்னமைவு விருப்பங்கள் + Group name குழுவின் பெயர் + Brush name தூரிகையின் பெயர் + Brush Name தூரிகையின் பெயர் + Brush Stroke தூரிகைக் கோடு + Brush Tip தூரிகை முனை + Off: the speed of the pointer sets the spacing அணை: சுட்டியின் வேகம் இடைவெளியை அமைக்கும் + Tilt Scale சாய்வு அளவு + Brush Projection தூரிகை எறிவு + Apply the pen's tilt and rotation to the tip shape பேனாவின் சாய்வையும் சுழற்சியையும் முனை வடிவத்துக்குப் பயன்படுத்து + Mixer Brush கலப்புத் தூரிகை + Mixer Brush Tool கலப்புத் தூரிகைக் கருவி + Color Replacement Tool நிறம் மாற்றும் கருவி + Wet ஈரம் + Current Brush Load தற்போதைய தூரிகை ஏற்றம் + Load Brush தூரிகையை ஏற்று + Clean Brush தூரிகையைச் சுத்தம்செய் + Load the brush after each stroke ஒவ்வொரு கோட்டிற்கும் பிறகு தூரிகையை ஏற்று + Clean the brush after each stroke ஒவ்வொரு கோட்டிற்கும் பிறகு தூரிகையைச் சுத்தம்செய் + Mix கலவை + Wetness Jitter ஈரப்பத அதிர்வு + Mix Jitter கலவை அதிர்வு + Show the layer mask as an overlay அடுக்கு மறைப்பை மேலடுக்காகக் காண்பி + Toggle visibility தெரிவுநிலையை மாற்று + Unlink the mask from the layer மறைப்பு இணைப்பை அடுக்கிலிருந்து நீக்கு + Link the mask to the layer மறைப்பை அடுக்குடன் இணை + Gradient நிறப் படித்திறன் + Gradient Options நிறப் படித்திறன் விருப்பங்கள் + Align with layer அடுக்குடன் சீரமை + Reset Alignment சீரமைப்பை மீட்டமை + Centre the gradient (offset 0, 0) நிறப் படித்திறனை மையப்படுத்து (நகர்வு 0, 0) + Gradient Editor நிறப் படித்திறன் திருத்தி + Gradient picker நிறப் படித்திறன் தேர்வி + Save As Preset முன்னமைவாகச் சேமி + The gradient the Gradient tool paints with நிறப் படித்திறன் கருவி வண்ணமிடப் பயன்படுத்தும் நிறப் படித்திறன் + Adds the current gradient to the Gradients panel தற்போதைய நிறப் படித்திறனை நிறப் படித்திறன்கள் பலகத்தில் சேர்க்கிறது + Gradient stops நிறப் படித்திறன் நிறுத்தங்கள் + Select a type layer to edit its character and paragraph settings. எழுத்து மற்றும் பத்தி அமைப்புகளைத் திருத்த உரை அடுக்கைத் தேர்ந்தெடுக்கவும். + Text color உரை நிறம் + Type Options உரை விருப்பங்கள் + Anti-aliasing method விளிம்பு மென்மையாக்கல் முறை + Horizontal type கிடைமட்ட உரை + Vertical type செங்குத்து உரை + Fill path with foreground color பாதையை முன்புற நிறத்தால் நிரப்பு + Stroke path with brush பாதையைத் தூரிகையால் கோடிடு + Load path as a selection பாதையைத் தேர்வாக ஏற்று + Make work path from selection தேர்விலிருந்து பணிப் பாதையை உருவாக்கு + Add vector mask திசையன் மறைப்பைச் சேர் + Create new path புதிய பாதையை உருவாக்கு + Delete path பாதையை நீக்கு + Delete vector mask திசையன் மறைப்பை நீக்கு + CPU (no GPU acceleration) CPU (GPU முடுக்கம் இல்லை) + DirectX 12 DirectX 12 + GPU backend GPU பின்புலம் + Linux display server Linux காட்சி சேவையகம் + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure டேப்லெட் அழுத்தத்தைப் பயன்படுத்து + Vulkan Vulkan + Alt-click to define a source point to clone from நகலெடுக்கும் மூலப் புள்ளியை வரையறுக்க Alt-கிளிக் செய்யவும் + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. தேர்வை உருவாக்க பாதை இல்லை: பேனா கருவியால் ஒன்றை வரையவும் அல்லது பாதைகள் பலகத்தில் ஒன்றைத் தேர்ந்தெடுக்கவும். + Cancel opening திறப்பதை ரத்துசெய் + Cancelled opening {name} {name} ஐத் திறப்பது ரத்துசெய்யப்பட்டது + The document “{name}” does not have an embedded {mode} profile. ஆவணம் “{name}” இல் உட்பொதிந்த {mode} சுயவிவரம் இல்லை. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. ஆவணம் “{name}” இல் உள்ள உட்பொதிந்த நிறச் சுயவிவரம் தற்போதைய {mode} பணி வெளியுடன் பொருந்தவில்லை. + Working: {working} பணி: {working} + Developing {name} {name} ஐ டெவலப் செய்கிறது + Cancelled {label} {label} ரத்துசெய்யப்பட்டது + Esc to cancel ரத்துசெய்ய Esc + Opened {name} {name} திறக்கப்பட்டது + Opening {name} {name} ஐத் திறக்கிறது + Saved {name} {name} சேமிக்கப்பட்டது + Saving {name} {name} ஐச் சேமிக்கிறது + Saving {percent} சேமிக்கிறது {percent} + A save to {name} is still running; try again when it finishes {name} க்கான சேமிப்பு இன்னும் நடைபெறுகிறது; முடிந்ததும் மீண்டும் முயலவும் + {label} is writing the file and can no longer be cancelled {label} கோப்பை எழுதுகிறது; இனி ரத்துசெய்ய முடியாது + Opening {name}… {name} ஐத் திறக்கிறது… + Opening… திறக்கிறது… + Reading… படிக்கிறது… + Working… செயல்படுகிறது… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: கட்டம்: + Color Range நிற வரம்பு + Adaptive Wide Angle தகவமைக்கும் அகலக் கோணம் + Chalk & Charcoal சுண்ணாம்பு & கரி + Charcoal கரி + Chrome குரோம் + Conté Crayon கோன்டே க்ரேயான் + Graphic Pen வரைகலைப் பேனா + Halftone Pattern அரைத்தொனி வடிவம் + Note Paper குறிப்புத் தாள் + Photocopy ஒளிநகல் + Plaster சாந்து + Reticulation வலைப்பின்னல் + Stamp முத்திரை + Torn Edges கிழிந்த விளிம்புகள் + Water Paper நீர்த் தாள் + Move நகர்த்து + Pencil பென்சில் + Clone Stamp நகலெடுப்பு முத்திரை + Dodge ஒளிர்வு + Burn எரிப்பு + Quick Selection விரைவுத் தேர்வு + History Brush வரலாற்றுத் தூரிகை + Color Replacement நிற மாற்றீடு + New Group புதிய குழு + Layer Comp அடுக்கு அமைப்பு + Alpha ஆல்பா + Make தயாரிப்பாளர் + Model மாதிரி + F F + Artist கலைஞர் + Spot Color புள்ளி நிறம் + Roman ரோமன் + Bold Italic தடித்த சாய்வு + Justification இருபுறச் சீரமைப்பு + Symbols குறியீடுகள் + Punctuation நிறுத்தற்குறிகள் + Make Work Path பணிப் பாதையை உருவாக்கு + Make Selection தேர்வை உருவாக்கு + Fill Path பாதையை நிரப்பு + Stroke Path பாதையைக் கோடிடு + Delete Path பாதையை நீக்கு + W அ + H உ + Ruler அளவுகோல் + New Layer Comp புதிய அடுக்கு அமைப்பு + Duplicate Layer Comp அடுக்கு அமைப்பை நகலெடு + Rename Layer Comp அடுக்கு அமைப்பின் பெயரை மாற்று + Apply Layer Comp அடுக்கு அமைப்பைப் பயன்படுத்து + Update Layer Comp அடுக்கு அமைப்பைப் புதுப்பி + Method முறை + Author ஆசிரியர் + Software மென்பொருள் + Pencil Tool பென்சில் கருவி + Pixel பிக்சல் + Adjustment சரிசெய்தல் + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract இழு (லாஸ்ஸோ) அல்லது புள்ளிகளைக் கிளிக் செய் (பலகோணம்) · {add} சேர் · {sub} கழி + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels வெட்டுப் பெட்டியை இழுக்கவும் · நகர்த்த உள்ளே இழுக்கவும் · விளிம்புகள் அளவை மாற்றும் ({ratio} விகிதம், {centre} மையம்) · வரையும்போது Space நகர்த்தும் · அம்புகள் சிறிதாக நகர்த்தும் · X திசையமைவை மாற்றும் · {commit} உறுதிசெய்யும் · Esc ரத்துசெய்யும் + {mode} Color · {bits} bit {mode} நிறம் · {bits} பிட் + Scrubby Zoom ஸ்க்ரப்பி பெரிதாக்கல் + Auto Erase தானியங்கி அழிப்பு + Color Adaptation நிறத் தகவமைப்பு + Preserve Transparency ஒளிஊடுருவலைப் பேணு + shortcut குறுக்குவழி + Saved சேமிக்கப்பட்டது + Arc வில் + Arch வில்வளைவு + Bulge வீக்கம் + Shell Lower கீழ் ஓடு + Shell Upper மேல் ஓடு + Flag கொடி + Fish மீன் + Wave அலை + Rise உயர்வு + Inflate ஊதிப்பெருக்கு + Twist முறுக்கு + Squeeze அழுத்து + Mask மறைப்பு + Channel சேனல் + Rectangle செவ்வகம் + New Channel புதிய சேனல் + Save Selection தேர்வைச் சேமி + Load Selection தேர்வை ஏற்று +@id edit.purge.undo செயல்தவிர் வரலாறு + Rendering Mode ரெண்டரிங் முறை + Automatic (recommended) தானியங்கி (பரிந்துரைக்கப்படுகிறது) + CPU / Compatibility CPU / இணக்கத்தன்மை + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. தானியங்கி முறை கிடைக்கும்போது GPU முடுக்கத்தைப் பயன்படுத்தும்; பிழைகளின்போது CPU ரெண்டரிங்குக்கு மாறும். + Switched to CPU rendering CPU ரெண்டரிங்குக்கு மாறியது + GPU acceleration could not continue. Your documents are unchanged. GPU முடுக்கம் தொடர முடியவில்லை. உங்கள் ஆவணங்கள் மாறவில்லை. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. போட்டோகிராப்ட் CPU பட இணைப்பியைப் பயன்படுத்துகிறது. சாளரம் இன்னும் உங்கள் வரைகலை அடாப்டரைப் பயன்படுத்தலாம். + Retry GPU requires a restart. Save your work first. GPU ஐ மீண்டும் முயல மறுதொடக்கம் தேவை. முதலில் உங்கள் பணியைச் சேமிக்கவும். + Retry GPU tries the graphics processor again now. GPU ஐ மீண்டும் முயல் இப்போது வரைகலைச் செயலியை மீண்டும் முயற்சிக்கும். + GPU acceleration resumed. GPU முடுக்கம் மீண்டும் தொடங்கியது. + Keep Using CPU CPU ஐத் தொடர்ந்து பயன்படுத்து + Retry GPU GPU ஐ மீண்டும் முயல் + Advanced மேம்பட்டவை + GPU Backend GPU பின்புலம் + Details விவரங்கள் + Rendering mode ரெண்டரிங் முறை + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) உறைய வைக்கும் லாஸ்ஸோ: பகுதியை உறைய வைக்க இழுக்கவும், உருக்க Alt-இழு (L) + Shadow Clipping Warning (U) நிழல் வெட்டு எச்சரிக்கை (U) + Highlight Clipping Warning (O) ஒளிர்வு வெட்டு எச்சரிக்கை (O) + Show Lab Color Readouts Lab நிற அளவீடுகளைக் காண்பி + Show Vectorscope வண்ணத் திசையன் காட்டியைக் காண்பி + Show Red at 3 o'clock சிவப்பை 3 மணி நிலையில் காண்பி + Show Skin Tone Indicator தோல் நிறக் குறிகாட்டியைக் காண்பி + Tone Histogram தொனி ஹிஸ்டோகிராம் + Show Selected Region தேர்ந்தெடுத்த பகுதியைக் காண்பி +cameraRaw Light ஒளி + Camera Raw Filter ({layer}) கேமரா Raw வடிகட்டி ({layer}) + Camera Raw ({name}) கேமரா Raw ({name}) +cameraRaw Lights ஒளிகள் +cameraRaw Darks கருமைகள் +cameraRaw Red சிவப்பு +cameraRaw Orange ஆரஞ்சு +cameraRaw Yellow மஞ்சள் +cameraRaw Green பச்சை +cameraRaw Aqua அக்வா +cameraRaw Blue நீலம் +cameraRaw Purple ஊதா +cameraRaw Magenta கருஞ்சிவப்பு +cameraRaw Blending கலத்தல் +cameraRaw Tint சாயம் +cameraRaw Temperature வெப்பநிலை +cameraRaw Amount அளவு + Patch Tool ஒட்டுக் கருவி + Patch: ஒட்டு: + Lasso around an area, then drag the selection ஒரு பகுதியைச் சுற்றி லாஸ்ஸோ செய்து, பின்னர் தேர்வை இழுக்கவும் + Transform On Drop இடும்போது உருமாற்று + Scale or rotate the moved area, then press {key} நகர்த்திய பகுதியின் அளவை மாற்றவும் அல்லது சுழற்றவும், பின்னர் {key} ஐ அழுத்தவும் + Now drag the selection onto another area இப்போது தேர்வை மற்றொரு பகுதிக்கு இழுக்கவும் + Edit Type உரையைத் திருத்து + New Style… புதிய பாணி… + Style {name} saved பாணி {name} சேமிக்கப்பட்டது + Create new style (from the selected type) புதிய பாணியை உருவாக்கு (தேர்ந்தெடுத்த உரையிலிருந்து) + Create new style from the selected layer தேர்ந்தெடுத்த அடுக்கிலிருந்து புதிய பாணியை உருவாக்கு + Make Default இயல்புநிலையாக்கு + Reset to Default இயல்புநிலைக்கு மீட்டமை + Full-resolution preview unavailable முழுத் தெளிவுத்திறன் முன்னோட்டம் கிடைக்கவில்லை + No patterns வடிவமைப்புகள் இல்லை + Enable {name} {name} ஐ இயக்கு + Hold Alt while drawing for straight segments நேரான பகுதிகளுக்கு வரையும்போது Alt ஐ அழுத்திப் பிடிக்கவும் + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts பலகோணப் புள்ளிகளை இழுக்கவும் அல்லது கிளிக் செய்யவும் · நேரான பகுதிகளுக்கு லாஸ்ஸோவின்போது Alt ஐ அழுத்திப் பிடிக்கவும் · {add} சேர் · வரையும் முன் {sub} கழிக்கும் + Extend நீட்டி + Structure: கட்டமைப்பு: + Content-Aware Move Tool உள்ளடக்கம் அறிந்த நகர்த்தும் கருவி + TIFF Options TIFF விருப்பங்கள் + Targa Options Targa விருப்பங்கள் + JPEG Options JPEG விருப்பங்கள் + Image Options பட விருப்பங்கள் + Format Options வடிவமைப்பு விருப்பங்கள் + Baseline ("Standard") அடிப்படை ("நிலையானது") + Baseline Optimized அடிப்படை மேம்படுத்தப்பட்டது + 24 bits/pixel 24 பிட்/பிக்சல் + 32 bits/pixel 32 பிட்/பிக்சல் + Including layers will increase file size. அடுக்குகளைச் சேர்ப்பது கோப்பு அளவை அதிகரிக்கும். + Discard Layers and Save a Copy அடுக்குகளை நிராகரித்து நகலைச் சேமி + Don't show again மீண்டும் காட்டாதே + Save failed: {error} சேமிப்பு தோல்வியடைந்தது: {error} + Save cancelled சேமிப்பு ரத்துசெய்யப்பட்டது + Magnetic Lasso Tool காந்த லாஸ்ஸோ கருவி + Frequency அதிர்வெண் + Use tablet pressure to change pen width பேனா அகலத்தை மாற்ற டேப்லெட் அழுத்தத்தைப் பயன்படுத்து + About பற்றி + Contributors பங்களிப்பாளர்கள் + Models மாதிரிகள் + Sort வரிசைப்படுத்து + Username பயனர்பெயர் + Display name காட்சிப் பெயர் + Real name உண்மைப் பெயர் + Reverse the order வரிசையைத் தலைகீழாக்கு + Grab bag கலவைப் பை + Table அட்டவணை + Name (A–Z) பெயர் (A–Z) + Merged PRs இணைக்கப்பட்ட PRகள் + Commits கமிட்கள் + Lines added சேர்த்த வரிகள் + Lines deleted நீக்கிய வரிகள் + Line delta வரி வேறுபாடு + Binary assets added சேர்த்த பைனரி சொத்துகள் + Binary assets removed நீக்கிய பைனரி சொத்துகள் + First commit முதல் கமிட் + First முதல் + Last commit கடைசி கமிட் + Last கடைசி + Contributors: {contributors} · Commits: {commits} பங்களிப்பாளர்கள்: {contributors} · சமர்ப்பிப்புகள்: {commits} + No contributor data was built into this copy. இந்த நகலில் பங்களிப்பாளர் தரவு சேர்க்கப்படவில்லை. + No model credits were built into this copy. இந்த நகலில் மாதிரி நன்றிகள் சேர்க்கப்படவில்லை. + Company நிறுவனம் + Version பதிப்பு + % of all commits அனைத்து சமர்ப்பிப்புகளில் % + Lines +/− வரிகள் +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRகள், {commits} கமிட்கள், +{added} / −{deleted} வரிகள் (Δ {delta}), +{binAdded} / −{binDeleted} பைனரி சொத்துகள், {first} – {last} + Create new swatch புதிய நிற மாதிரியை உருவாக்கு + New Swatch… புதிய நிற மாதிரி… + New Swatch Group… புதிய நிற மாதிரிக் குழு… + Import Swatches… நிற மாதிரிகளை இறக்குமதி செய்… + Replace Swatches… நிற மாதிரிகளை மாற்று… + Export Swatches… நிற மாதிரிகளை ஏற்றுமதி செய்… + Export Swatches for Exchange… பரிமாற்றத்துக்கு நிற மாதிரிகளை ஏற்றுமதி செய்… + Reset Swatches நிற மாதிரிகளை மீட்டமை + Add to Swatches நிற மாதிரிகளில் சேர் + Blend If: கலப்பு நிபந்தனை: + Channels: சேனல்கள்: + Knockout நாக்அவுட் + Apply At Canvas Bounds படச்சட்ட எல்லைகளில் பயன்படுத்து + Color Picker (Text Color) நிறத் தேர்வி (உரை நிறம்) + Color Picker ({name}) நிறத் தேர்வி ({name}) + Color Picker (Fill Color) நிறத் தேர்வி (நிரப்பு நிறம்) + Fill color நிரப்பு நிறம் + Color Picker (Stroke Color) நிறத் தேர்வி (கோடு நிறம்) + Stroke color கோடு நிறம் + Pick screen color திரை நிறத்தைத் தேர்ந்தெடு + Sample a pixel anywhere on the screen திரையில் எங்கும் ஒரு பிக்சலை மாதிரியெடு + Click to pick · Esc to cancel தேர்ந்தெடுக்க கிளிக் · ரத்துசெய்ய Esc + Pick a screen pixel or press Esc to cancel திரை பிக்சலைத் தேர்ந்தெடுக்கவும் அல்லது ரத்துசெய்ய Esc அழுத்தவும் + Scroll to zoom பெரிதாக்க உருட்டவும் + Drag to pan · Scroll to zoom · Double-click to fit நகர்த்த இழுக்கவும் · பெரிதாக்க உருட்டவும் · பொருத்த இருமுறை கிளிக் செய்யவும் + Show/Hide All Panels அனைத்துப் பலகங்களையும் காண்பி/மறை + Show/Hide Panels பலகங்களைக் காண்பி/மறை + Could not open திறக்க முடியவில்லை + {path}: the path is not valid Unicode; rename the file and open it again. {path}: பாதை சரியான யூனிகோட் அல்ல; கோப்பின் பெயரை மாற்றி மீண்டும் திறக்கவும். + Contents: உள்ளடக்கம்: + Custom Pattern: தனிப்பயன் வடிவமைப்பு: + Foreground Color முன்புற நிறம் + Color… நிறம்… + 50% Gray 50% சாம்பல் + {size} (was {old}) {size} (முன்பு {old}) + Cone கூம்பு + Cone (Inverted) கூம்பு (தலைகீழ்) + Domed குவிமாடம் + Domed (Inverted) குவிமாடம் (தலைகீழ்) + Diagonal (Descending) மூலைவிட்டம் (இறங்குமுகம்) + Contour Editor வெளிக்கோடு திருத்தி + Anti-aliased விளிம்பு மென்மையாக்கப்பட்டது + Corner மூலை + Gloss Contour பளபளப்பு வெளிக்கோடு + Contour வெளிக்கோடு + Cone - Inverted கூம்பு - தலைகீழ் + Cove - Deep குடைவு - ஆழம் + Cove - Shallow குடைவு - ஆழமற்றது + Half Round அரை வட்டம் + Ring வளையம் + Ring - Double வளையம் - இரட்டை + Rolling Slope - Descending உருளும் சரிவு - இறங்குமுகம் + Rounded Steps வட்டப் படிகள் + Sawtooth 1 ரம்பப் பல் 1 + Red Eye Tool சிவப்புக் கண் கருவி + Red Eye சிவப்புக் கண் + Pupil Size பாவை அளவு + Darken Amount கருமையாக்கும் அளவு + Click a red pupil to neutralize it சிவப்புப் பாவையைச் சரிசெய்ய அதைக் கிளிக் செய்யவும் + open source திறந்த மூலம் + …and {n} more …மேலும் {n} + Eyedroppers: நிறத் தேர்விகள்: + Set Black Point கருப்புப் புள்ளியை அமை + Set Neutral Gray Point நடுநிலைச் சாம்பல் புள்ளியை அமை + Set White Point வெள்ளைப் புள்ளியை அமை + Unavailable in this color mode இந்த நிற முறையில் கிடைக்காது + Selection Preview: தேர்வு முன்னோட்டம்: + Black Matte கருப்பு ஒளிமறுப்பு + White Matte வெள்ளை ஒளிமறுப்பு + Seafoam கடல்நுரை + Indigo அவுரி + Fuchsia ஃபுக்சியா + Violet ஊதா + Reset மீட்டமை + Large document: redrawing on the CPU பெரிய ஆவணம்: CPU இல் மறுவரைவு செய்கிறது + Redrawing மறுவரைவு + This redraw took {seconds} s on the CPU. இந்த மறுவரைவு CPU இல் {seconds} வி எடுத்தது. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. ஒரு அடுக்கின் கலப்பு முறை, ஒளிபுகாமை அல்லது தெரிவுநிலை போன்ற முழு ஆவணத்தையும் மாற்றும் திருத்தங்கள் அனைத்தையும் மறுவரைவு செய்கின்றன. + The GPU compositor wasn't used: {reason} GPU இணைப்பி பயன்படுத்தப்படவில்லை: {reason} +fontWeight Thin மெல்லிய +fontWeight ExtraLight மிக மெல்லிய +fontWeight Light இலேசான +fontWeight Regular வழக்கமான +fontWeight Medium நடுத்தரம் +fontWeight SemiBold அரைத் தடித்த +fontWeight Bold தடித்த +fontWeight ExtraBold மிகத் தடித்த +fontWeight Black அடர் +fontWeight Italic சாய்வு + Pattern Stamp Tool வடிவ முத்திரைக் கருவி + Pattern Stamp வடிவ முத்திரை + Impressionist இம்ப்ரெஷனிஸ்ட் + Rotate View காட்சியைச் சுழற்று + Rotate View Tool காட்சி சுழற்றும் கருவி + Reset View காட்சியை மீட்டமை + Drag around the centre to rotate the view · Shift constrains to 15° காட்சியைச் சுழற்ற மையத்தைச் சுற்றி இழுக்கவும் · Shift 15° க்குக் கட்டுப்படுத்தும் + Dual Axis இரட்டை அச்சு + Diagonal மூலைவிட்டம் + Transform Symmetry சமச்சீரை உருமாற்று + Point Sample புள்ளி மாதிரி + 3 by 3 Average 3 x 3 சராசரி + 5 by 5 Average 5 x 5 சராசரி + 11 by 11 Average 11 x 11 சராசரி + 31 by 31 Average 31 x 31 சராசரி + 51 by 51 Average 51 x 51 சராசரி + 101 by 101 Average 101 x 101 சராசரி + Sample Size: மாதிரி அளவு: + All Layers No Adjustments அனைத்து அடுக்குகள், சரிசெய்தல்கள் இல்லாமல் + Current & Below No Adjustments தற்போதையது & கீழுள்ளவை, சரிசெய்தல்கள் இல்லாமல் + Show Sampling Ring மாதிரியெடுப்பு வளையத்தைக் காண்பி + Out of gamut for printing: click for the nearest printable colour அச்சிடுவதற்கான நிற வரம்புக்கு வெளியே: அருகிலுள்ள அச்சிடக்கூடிய நிறத்துக்கு கிளிக் செய்யவும் + Not a web colour: click for the nearest web colour வலை நிறம் அல்ல: அருகிலுள்ள வலை நிறத்துக்கு கிளிக் செய்யவும் + Color to Alpha… நிறத்தை ஆல்பாவாக… + Transparency Threshold ஒளிஊடுருவல் வாசல் + Opacity Threshold ஒளிபுகாமை வாசல் + PhotoCraft — Screen Color Picker போட்டோகிராப்ட் — திரை நிறத் தேர்வி + Rasterize svg groups deeper than இதைவிட ஆழமான svg குழுக்களை ராஸ்டராக்கு + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 இந்த வாசலை அணைக்கும்; 100 நிலைகளுக்கு மேல் ஆழமாக உள்ள SVG குழுக்கள் எப்போதும் ராஸ்டராக்கப்படும். + Rule of Thirds மூன்றில் ஒரு பங்கு விதி + Triangle முக்கோணம் + Golden Ratio தங்க விகிதம் + Golden Spiral தங்கச் சுருள் + Auto Show Overlay மேலடுக்கைத் தானாகக் காண்பி + Always Show Overlay மேலடுக்கை எப்போதும் காண்பி + Never Show Overlay மேலடுக்கை ஒருபோதும் காட்டாதே + Cycle Overlay மேலடுக்கை மாற்றி மாற்று + Cycle Orientation திசையமைவை மாற்றி மாற்று + Set the overlay options for the Crop tool வெட்டுக் கருவிக்கான மேலடுக்கு விருப்பங்களை அமை + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-கிளிக் டெஸ்க்டாப்புக்குச் சென்றது; அது Alt-இழுப்பால் சாளரங்களை நகர்த்துகிறது. அதன் சாளர நகர்த்தும் விசையை Super ஆக அமைக்கவும், அல்லது சாளரம் › மாற்றி விசைகளில் Alt ஐப் பூட்டவும். + Color Picker (Layer Style Color) நிறத் தேர்வி (அடுக்கு பாணி நிறம்) + Auto Hide Notifications அறிவிப்புகளைத் தானாக மறை + Notification Duration (seconds) அறிவிப்பு கால அளவு (விநாடிகள்) + Bundle Arrow-Key Nudges அம்புக்குறி விசை நகர்வுகளைத் தொகுத்திடு + Nudge Bundle Pause (ms) நகர்வுத் தொகுப்பு இடைநிறுத்தம் (ms) + Appearance Mode தோற்ற முறை + Dark Theme கருமை தோற்றம் + Light Theme வெளிர் தோற்றம் + Pro Medium புரோ நடுத்தரம் + Studio Light ஸ்டுடியோ வெளிர் + Next Appearance Mode அடுத்த தோற்ற முறை + Sync with system கணினியுடன் ஒத்திசை + Active செயலில் + Color Picker (Solid Color) நிறத் தேர்வி (திட நிறம்) + Straighten the image by drawing a line on it படத்தின் மீது கோடு வரைந்து நேராக்கு + Set additional Crop options கூடுதல் வெட்டு விருப்பங்களை அமை + Show Cropped Area வெட்டப்பட்ட பகுதியைக் காண்பி + Enable Crop Shield வெட்டுக் கவசத்தை இயக்கு + Match Canvas படச்சட்டத்துடன் பொருத்து + Auto Adjust Opacity ஒளிபுகாமையைத் தானாகச் சரிசெய் + Crop Shield color வெட்டுக் கவச நிறம் + Color Picker (Crop Shield Color) நிறத் தேர்வி (வெட்டுக் கவச நிறம்) + From Center மையத்திலிருந்து + Link radii ஆரங்களை இணை + Star Ratio: நட்சத்திர விகிதம்: + Create Rectangle செவ்வகத்தை உருவாக்கு + Create Ellipse நீள்வட்டத்தை உருவாக்கு + Create Triangle முக்கோணத்தை உருவாக்கு + Create Polygon பலகோணத்தை உருவாக்கு + Pressure curve அழுத்த வளைவு + Click to add a point, right-click to remove புள்ளியைச் சேர்க்க கிளிக், நீக்க வலது கிளிக் + Some brush presets could not be loaded சில தூரிகை முன்னமைவுகளை ஏற்ற முடியவில்லை + Retry saving presets முன்னமைவுகளைச் சேமிக்க மீண்டும் முயல் + Brush preset changes are not saved: {error} தூரிகை முன்னமைவு மாற்றங்கள் சேமிக்கப்படவில்லை: {error} + Keep this tab open to retain your brushes. உங்கள் தூரிகைகளைத் தக்கவைக்க இந்தத் தாவலைத் திறந்து வைக்கவும். + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. உலாவி சேமிப்பகம் கிடைக்கவில்லை; தூரிகைகள் இந்த அமர்வில் மட்டுமே இருக்கும். அவற்றைத் தக்கவைக்க இந்தத் தாவலைத் திறந்து வைக்கவும். + Saving brush presets… Keep this tab open until saving finishes. தூரிகை முன்னமைவுகளைச் சேமிக்கிறது… சேமிப்பு முடியும் வரை இந்தத் தாவலைத் திறந்து வைக்கவும். + Reflect பிரதிபலி + Glow பிரகாசம் + Negation மறுப்பு + XOR XOR + Color Burn (Paint.NET) நிற எரிப்பு (Paint.NET) + Color Dodge (Paint.NET) நிற ஒளிர்வு (Paint.NET) + Stroke Options கோடு விருப்பங்கள் + Solid திடம் + Dashed கீற்றுக்கோடு + Dotted புள்ளிக்கோடு + Alignment சீரமைப்பு + Caps முனைகள் + Butt / Flat தட்டை / சமதளம் + Corners மூலைகள் + Miter மிட்டர் + Round வட்டம் + Bevel சரிவு + Miter limit மிட்டர் வரம்பு + Dashed line கீற்றுக் கோடு + Dash கீற்று + Gap இடைவெளி + Dash / gap lengths × stroke width கீற்று / இடைவெளி நீளங்கள் × கோடு அகலம் + Add pair இணையைச் சேர் + Remove pair இணையை நீக்கு + Dash offset கீற்று நகர்வு + Open paths use centered strokes திறந்த பாதைகள் மையக் கோடுகளைப் பயன்படுத்தும் + Use finite lengths and a positive pattern total வரையறுத்த நீளங்களையும் நேர்மறை வடிவ மொத்தத்தையும் பயன்படுத்தவும் + No subject found — try Object Selection or the Lasso பொருள் எதுவும் கிடைக்கவில்லை — பொருள் தேர்வு அல்லது லாஸ்ஸோவை முயலவும் + Quick Selection found nothing there விரைவுத் தேர்வு அங்கு எதையும் கண்டறியவில்லை + Object Selection found nothing there — try a tighter rectangle பொருள் தேர்வு அங்கு எதையும் கண்டறியவில்லை — இறுக்கமான செவ்வகத்தை முயலவும் + No subject found — try a rectangle around the object பொருள் எதுவும் கிடைக்கவில்லை — பொருளைச் சுற்றி செவ்வகத்தை முயலவும் + Click the first point or press {key} to close · {del} removes a point · Esc cancels மூட முதல் புள்ளியைக் கிளிக் செய்யவும் அல்லது {key} அழுத்தவும் · {del} ஒரு புள்ளியை நீக்கும் · Esc ரத்துசெய்யும் + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close கிளிக் செய்து, விளிம்பில் நகர்த்தவும் · புள்ளியை இணைக்க கிளிக் · நேரான பகுதிக்கு {alt}-கிளிக் · மூட இருமுறை கிளிக் + Follows only edges this close to the pointer ([ and ] change it) சுட்டிக்கு இவ்வளவு அருகிலுள்ள விளிம்புகளை மட்டும் பின்தொடரும் ([ மற்றும் ] இதை மாற்றும்) + Higher values follow only edges that contrast sharply with their surroundings அதிக மதிப்புகள் சுற்றுப்புறத்துடன் கூர்மையாக மாறுபடும் விளிம்புகளை மட்டும் பின்தொடரும் + How often fastening points are placed by themselves இணைப்புப் புள்ளிகள் தானாக எவ்வளவு அடிக்கடி வைக்கப்படுகின்றன + Appearance Settings… தோற்ற அமைப்புகள்… + Add to favorites பிடித்தவற்றில் சேர் + Remove from favorites பிடித்தவற்றிலிருந்து நீக்கு + Camera Data கேமரா தரவு +cameraData Make தயாரிப்பாளர் +cameraData Model மாதிரி +cameraData Date Taken எடுத்த தேதி +cameraData Exposure வெளிப்பாடு +cameraData F-Stop F-நிறுத்தம் +cameraData ISO Speed ISO வேகம் +cameraData Focal Length குவிய நீளம் +cameraData Focal Length (35 mm) குவிய நீளம் (35 மிமீ) +cameraData Lens லென்ஸ் + Activate Panel பலகத்தைச் செயல்படுத்து + Change Panel Group பலகக் குழுவை மாற்று + Change Panel Layout பலக அமைப்பை மாற்று + Close Panel பலகத்தை மூடு + Dock Panel பலகத்தை இணை + Float Panel பலகத்தை மிதக்கவிடு + Move Panel பலகத்தை நகர்த்து + Group with இதனுடன் குழுவாக்கு + Panel menu பலகப் பட்டி + Resize panel window பலகச் சாளர அளவை மாற்று + Resize panels பலகங்களின் அளவை மாற்று + Distribution பகிர்வு + Uniform சீரான + Gaussian காசியன் + Monochromatic ஒற்றைநிறமான + Seed விதை + Strength வலிமை + Reduce Color Noise நிற இரைச்சலைக் குறை + Remove Jpeg Artifact JPEG குறைபாட்டை நீக்கு + Sharpen Details விவரங்களைக் கூர்மையாக்கு + Reduce Noise இரைச்சலைக் குறை + Use Classic Mode கிளாசிக் முறையைப் பயன்படுத்து + Auto Center Preview முன்னோட்டத்தைத் தானாக மையப்படுத்து + Next Document அடுத்த ஆவணம் + Previous Document முந்தைய ஆவணம் + W x H x Resolution அ x உ x தெளிவுத்திறன் + Front Image முன் படம் + Frame Tool சட்டக் கருவி + Create a new rectangular frame புதிய செவ்வகச் சட்டத்தை உருவாக்கு + Create a new elliptical frame புதிய நீள்வட்டச் சட்டத்தை உருவாக்கு + Include in playback இயக்கத்தில் சேர் + Perspective Crop Tool பார்வைக்கோண வெட்டுக் கருவி + Show Grid கட்டத்தைக் காண்பி + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels பெட்டியை இழுக்கவும் அல்லது நான்கு மூலைகளைக் கிளிக் செய்யவும் · மூலைகளை சாய்ந்த வடிவத்தின் விளிம்புகளுக்கு இழுக்கவும் · {commit} உறுதிசெய்யும் · Esc ரத்துசெய்யும் + Artboard Tool ஆர்ட்போர்டு கருவி + Make Portrait செங்குத்தாக்கு + Make Landscape கிடைமட்டமாக்கு + Add New Artboard புதிய ஆர்ட்போர்டைச் சேர் + Freeform Pen Tool தன்வடிவப் பேனா கருவி + Curvature Pen Tool வளைவுப் பேனா கருவி + Curve Fit: வளைவுப் பொருத்தம்: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close இழு: கையால் வரை · {alt}-கிளிக்: நேரான பகுதி · தொடக்கப் புள்ளியில் விடு: மூடு + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish கிளிக்: மென்மையான புள்ளி · இருமுறை கிளிக்: மூலை · புள்ளியை இழு: நகர்த்து · முதல் புள்ளி கிளிக்: மூடு · {key} முடி + New Crop Preset… புதிய வெட்டு முன்னமைவு… + Delete Crop Preset… வெட்டு முன்னமைவை நீக்கு… + Select the crop presets to delete: நீக்க வேண்டிய வெட்டு முன்னமைவுகளைத் தேர்ந்தெடுக்கவும்: + Select the crop presets to delete நீக்க வேண்டிய வெட்டு முன்னமைவுகளைத் தேர்ந்தெடுக்கவும் + Enter a name for the crop preset வெட்டு முன்னமைவுக்குப் பெயரை உள்ளிடவும் + There is no crop size or ratio to save சேமிக்க வெட்டு அளவோ விகிதமோ இல்லை + Scale: அளவு: + Snap to Origin தோற்றத்துடன் ஒட்டு + Search settings… அமைப்புகளைத் தேடு… + No matching settings பொருந்தும் அமைப்புகள் இல்லை + Sampling Brush Tool மாதிரியெடுப்புத் தூரிகைக் கருவி + Sampling Area Overlay மாதிரியெடுப்புப் பகுதி மேலடுக்கு + Show Sampling Area மாதிரியெடுப்புப் பகுதியைக் காண்பி + Indicates குறிப்பது + Sampling Area மாதிரியெடுப்புப் பகுதி + Excluded Area விலக்கப்பட்ட பகுதி + Sampling Area Options மாதிரியெடுப்புப் பகுதி விருப்பங்கள் + Rectangular செவ்வக + Fill Settings நிரப்பு அமைப்புகள் + Rotation Adaptation சுழற்சித் தகவமைப்பு + Very High மிக உயர் + Full முழு + Mirror கண்ணாடி + Output Settings வெளியீட்டு அமைப்புகள் + Output To வெளியீட்டு இடம் + Animation Options அனிமேஷன் விருப்பங்கள் + Entire Document முழு ஆவணம் + Filter Options வடிகட்டி விருப்பங்கள் + Layer Bounds அடுக்கு எல்லைகள் + Layers Panel Options அடுக்குகள் பலக விருப்பங்கள் + Lock Layers அடுக்குகளைப் பூட்டு + New Artboard… புதிய ஆர்ட்போர்டு… + New Group… புதிய குழு… + New Layer… புதிய அடுக்கு… + Panel Options… பலக விருப்பங்கள்… + Prevent Auto-Nesting தானியங்கி உட்பொதிவைத் தடு + Show Layers அடுக்குகளைக் காண்பி + Thumbnail Contents சிறுபட உள்ளடக்கம் + Thumbnail Size சிறுபட அளவு + Use Default Masks on Fill Layers நிரப்பு அடுக்குகளில் இயல்புநிலை மறைப்புகளைப் பயன்படுத்து + Expand New Effects புதிய விளைவுகளை விரிவாக்கு + Add “copy” to Copied Layers and Groups நகலெடுத்த அடுக்குகள் மற்றும் குழுக்களில் “நகல்” ஐச் சேர் + Preview on canvas when reordering layers அடுக்குகளை மறுவரிசைப்படுத்தும்போது படச்சட்டத்தில் முன்னோட்டம் காண்பி + Show layer mask badges அடுக்கு மறைப்பு அடையாளங்களைக் காண்பி + Blending Options ({filter}) கலப்பு விருப்பங்கள் ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) அடுக்கின் விளைவுகளையும் திறன்மிகு வடிகட்டிகளையும் மறை ({key}-கிளிக்: அனைத்து அடுக்குகள்) + Show the layer's effects and Smart Filters ({key}-click: all layers) அடுக்கின் விளைவுகளையும் திறன்மிகு வடிகட்டிகளையும் காண்பி ({key}-கிளிக்: அனைத்து அடுக்குகள்) + Wait for the current job to finish. தற்போதைய வேலை முடியும் வரை காத்திருக்கவும். + The document or Smart Filter changed. Reopen its dialog. ஆவணம் அல்லது திறன்மிகு வடிகட்டி மாறிவிட்டது. அதன் உரையாடலை மீண்டும் திறக்கவும். + Opacity must be between 0% and 100%. ஒளிபுகாமை 0% முதல் 100% வரை இருக்க வேண்டும். + Dissolve is approximated: it blends without Photoshop's dither. கரைவு தோராயமாக்கப்பட்டுள்ளது: இது Photoshop இன் நிறக் கலப்பு இல்லாமல் கலக்கிறது. + Loading fonts… எழுத்துருக்களை ஏற்றுகிறது… + Response curve பதில் வளைவு + Tool கருவி + Simulate Pressure அழுத்தத்தை உருவகப்படுத்து + Stroke Path… பாதையைக் கோடிடு… + Background Eraser பின்னணி அழிப்பான் + Healing Brush குணப்படுத்தும் தூரிகை + Spot Healing Brush புள்ளி குணப்படுத்தும் தூரிகை + Smudge தேய்ப்பு + Sponge கடற்பாசி + Amount: அளவு: + Protect: பாதுகாப்பு: + Protect skin tones தோல் நிறங்களைப் பாதுகாக்கவும் + Edit points to modify the curve வளைவை மாற்ற புள்ளிகளைத் திருத்தவும் + Draw to modify the curve வளைவை மாற்ற வரையவும் +curves Smooth மென்மையாக்கு + Smooth the curve values வளைவு மதிப்புகளை மென்மையாக்கு + Drag to draw the curve · {key}-click draws a straight line வளைவை வரைய இழுக்கவும் · {key}-கிளிக் நேர்கோடு வரையும் + Edit Plane தளத்தைத் திருத்து + Create Plane தளத்தை உருவாக்கு + Grid Size: கட்ட அளவு: + Vanishing Point: click four corners to create a plane மறையும் புள்ளி: தளத்தை உருவாக்க நான்கு மூலைகளைக் கிளிக் செய்யவும் + Vanishing Point is not active மறையும் புள்ளி செயலில் இல்லை + Apply Vanishing Point ({key}) மறையும் புள்ளியைப் பயன்படுத்து ({key}) + Fix the red plane first: its corners must form a convex quad முதலில் சிவப்புத் தளத்தைச் சரிசெய்யவும்: அதன் மூலைகள் குவிந்த நாற்கரத்தை உருவாக்க வேண்டும் + Onion Skin வெங்காயத் தோல் + Marching Ants அணிவகுக்கும் எறும்புகள் + On Black கருப்பின் மேல் + On White வெள்ளையின் மேல் + On Layers அடுக்குகளின் மேல் + Show Edge விளிம்பைக் காண்பி + Show Original அசலைக் காண்பி + F cycles the views; hold X to see the image F காட்சிகளை மாற்றி மாற்றும்; படத்தைக் காண X ஐ அழுத்திப் பிடிக்கவும் + Refine Edge Brush (R) விளிம்பு செம்மைத் தூரிகை (R) + Expand Detection Area கண்டறியும் பகுதியை விரிவாக்கு + Restore Original Edge அசல் விளிம்பை மீட்டமை + Paint over hair or fur on the image; Alt paints the other mode படத்தில் முடி அல்லது ரோமத்தின் மேல் வண்ணமிடவும்; Alt மற்ற முறையில் வண்ணமிடும் + Clear Brush Strokes தூரிகைக் கோடுகளை அழி + Show rulers அளவுகோல்களைக் காண்பி + Presentation விளக்கக்காட்சி + Click to place · Alt-click or drag off-canvas to remove வைக்க கிளிக் · நீக்க Alt-கிளிக் அல்லது படச்சட்டத்துக்கு வெளியே இழுக்கவும் + No document open திறந்த ஆவணம் இல்லை + {n} of {max} sample points {max} மாதிரிப் புள்ளிகளில் {n} + New Snapshot… புதிய ஸ்னாப்ஷாட்… + History Options… வரலாற்று விருப்பங்கள்… + Emulsion Down குழம்பு கீழ்நோக்கி + Functions செயல்பாடுகள் + Print Resolution: அச்சுத் தெளிவுத்திறன்: + Use Previous Layer to Create Clipping Mask கிளிப்பிங் மறைப்பை உருவாக்க முந்தைய அடுக்கைப் பயன்படுத்து + Fill with {mode}-neutral color (white) {mode}-நடுநிலை நிறத்தால் நிரப்பு (வெள்ளை) + Fill with {mode}-neutral color (black) {mode}-நடுநிலை நிறத்தால் நிரப்பு (கருப்பு) + Fill with {mode}-neutral color (50% gray) {mode}-நடுநிலை நிறத்தால் நிரப்பு (50% சாம்பல்) + (No neutral color exists for {mode} mode.) ({mode} முறைக்கு நடுநிலை நிறம் இல்லை.) + Apply mask to layer before removing? நீக்கும் முன் மறைப்பை அடுக்கில் பயன்படுத்தவா? + The layer or its mask changed. Drag the mask onto the Trash again. அடுக்கு அல்லது அதன் மறைப்பு மாறிவிட்டது. மறைப்பை மீண்டும் குப்பைக்கு இழுக்கவும். + Delete the vector mask? திசையன் மறைப்பை நீக்கவா? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {file} க்கு {n} நிற மாதிரி ஏற்றுமதி செய்யப்பட்டது|{file} க்கு {n} நிற மாதிரிகள் ஏற்றுமதி செய்யப்பட்டன +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} இலிருந்து {n} தூரிகை இறக்குமதி செய்யப்பட்டது|{group} இலிருந்து {n} தூரிகைகள் இறக்குமதி செய்யப்பட்டன +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} இலிருந்து {n} நிற மாதிரி இறக்குமதி செய்யப்பட்டது|{file} இலிருந்து {n} நிற மாதிரிகள் இறக்குமதி செய்யப்பட்டன +@plural Played {n} step|Played {n} steps {n} படி இயக்கப்பட்டது|{n} படிகள் இயக்கப்பட்டன +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk வட்டில் மாறிய {n} இணைக்கப்பட்ட திறன்மிகு பொருள் புதுப்பிக்கப்பட்டது|வட்டில் மாறிய {n} இணைக்கப்பட்ட திறன்மிகு பொருட்கள் புதுப்பிக்கப்பட்டன +@plural Saved for Web: {n} file|Saved for Web: {n} files வலைக்காகச் சேமித்தது: {n} கோப்பு|வலைக்காகச் சேமித்தது: {n} கோப்புகள் +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. இந்த அமைப்பில் பதிவான {n} அடுக்கு நீக்கப்பட்டது. அமைப்பைப் புதுப்பிக்கவும், அல்லது எச்சரிக்கையை அழிக்கவும்.|இந்த அமைப்பில் பதிவான {n} அடுக்குகள் நீக்கப்பட்டன. அமைப்பைப் புதுப்பிக்கவும், அல்லது எச்சரிக்கையை அழிக்கவும். +@plural {n} plane|{n} planes {n} தளம்|{n} தளங்கள் +@plural {n} preset|{n} presets {n} முன்னமைவு|{n} முன்னமைவுகள் +@plural {n} sample point removed|{n} sample points removed {n} மாதிரிப் புள்ளி நீக்கப்பட்டது|{n} மாதிரிப் புள்ளிகள் நீக்கப்பட்டன +@plural {n} slice|{n} slices {n} துண்டு|{n} துண்டுகள் +@plural {n} step|{n} steps {n} படி|{n} படிகள் +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} மேலும் தூரிகை முன்னமைவு எச்சரிக்கை)|{warning} (+{n} மேலும் தூரிகை முன்னமைவு எச்சரிக்கைகள்) +@plural {what} with {n} warning|{what} with {n} warnings {what} {n} எச்சரிக்கையுடன்|{what} {n} எச்சரிக்கைகளுடன் + Light Blue வெளிர் நீலம் + Light Red வெளிர் சிவப்பு + Medium Blue நடுத்தர நீலம் + Light Gray வெளிர் சாம்பல் + Object Finder பொருள் தேடி + No object found there — drag a rectangle around it அங்கு பொருள் கிடைக்கவில்லை — அதைச் சுற்றி செவ்வகத்தை இழுக்கவும் + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels செயலில் உள்ள அடுக்கில் இங்கு பிக்சல்கள் இல்லை — அனைத்து அடுக்குகளையும் மாதிரியெடு என்பதை இயக்கவும் அல்லது பிக்சல்கள் உள்ள அடுக்கைத் தேர்ந்தெடுக்கவும் + Remove after each stroke ஒவ்வொரு கோட்டிற்கும் பிறகு நீக்கு + Apply the removal ({key}) நீக்கத்தைப் பயன்படுத்து ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) நீக்க வேண்டிய கோடுகள்: {n} (Enter பயன்படுத்தும், Esc ரத்துசெய்யும்) + Load Contours… வெளிக்கோடுகளை ஏற்று… + Replace Contours… வெளிக்கோடுகளை மாற்று… + Save Contours… வெளிக்கோடுகளைச் சேமி… + Delete Contour வெளிக்கோட்டை நீக்கு + Loaded contours from {file} {file} இலிருந்து வெளிக்கோடுகள் ஏற்றப்பட்டன + Saved contours to {file} {file} க்கு வெளிக்கோடுகள் சேமிக்கப்பட்டன +@id select.all அனைத்தையும் தேர்ந்தெடு + Delete Smart Filters திறன்மிகு வடிகட்டிகளை நீக்கு + Enable Smart Filters திறன்மிகு வடிகட்டிகளை இயக்கு + Delete Layer… அடுக்கை நீக்கு… + Select Similar Colors ஒத்த நிறங்களைத் தேர்ந்தெடு + Canvas rotation படச்சட்ட சுழற்சி + Extensions நீட்டிப்புகள் + Libraries நூலகங்கள் + 3D 3D + Toolbars கருவிப்பட்டிகள் + Wand மாயக்கோல் + Healing குணப்படுத்தல் + Pen பேனா + Hand கை + Sampler மாதிரியெடுப்பி + Magnetic Lasso காந்த லாஸ்ஸோ + Art History Brush கலை வரலாற்றுத் தூரிகை + Spot Healing புள்ளி குணப்படுத்தல் + Patch ஒட்டு + Content-Aware Move உள்ளடக்கம் அறிந்த நகர்த்தல் + Perspective Crop பார்வைக்கோண வெட்டு + Dodge/Burn ஒளிர்வு/எரிப்பு + Dynamics இயக்கவியல் + Tilt சாய்வு + Defaults restored. இயல்புநிலைகள் மீட்டமைக்கப்பட்டன. + Stop நிறுத்து + Create new set புதிய தொகுப்பை உருவாக்கு + Technology Previews தொழில்நுட்ப முன்னோட்டங்கள் + Create new layer புதிய அடுக்கை உருவாக்கு + Group · {n} layer குழு · {n} அடுக்கு + General Blend பொதுக் கலப்பு + Advanced Blending மேம்பட்ட கலப்பு + RGB RGB + Spot Channel புள்ளிச் சேனல் + Working Gray Plate பணிச் சாம்பல் தட்டு + HSL/Grayscale HSL/சாம்பல் அளவு + Tone Curve தொனி வளைவு + Split Toning பிளவு தொனியாக்கம் + Lens Corrections லென்ஸ் திருத்தங்கள் + Camera Calibration கேமரா அளவுதிருத்தம் + Snapshots ஸ்னாப்ஷாட்கள் + Recovery மீட்பு + Fill Light நிரப்பு ஒளி + Convert to Grayscale சாம்பல் அளவாக மாற்று + Sharpness கூர்மை + Color Noise Reduction நிற இரைச்சல் குறைப்பு + Luminance Smoothness ஒளிர்வுத்திறன் மென்மை + Lens லென்ஸ் + ISO ISO + Focal Length35mm குவிய நீளம் 35மிமீ + Aspect Ratio விகிதம் + Time நேரம் + Enter description… விளக்கத்தை உள்ளிடவும்… + ColorSync ColorSync + ProPhoto ProPhoto + ACES ACES + Industry Standard தொழில்துறை நிலையானது + Uncoated CMYK பூசாத CMYK + Newspaper செய்தித்தாள் + Working Spaces பணி வெளிகள் + Color Management Policies நிற மேலாண்மைக் கொள்கைகள் + Detect Faces முகங்களைக் கண்டறி + Localize Color Clusters நிறக் கொத்துகளை உள்ளூராக்கு + Duplicated selection name நகலெடுக்கப்பட்ட தேர்வுப் பெயர் + Operation செயல்பாடு + New channel only புதிய சேனல் மட்டும் + Color indicates நிறம் குறிப்பது + Create Snapshot ஸ்னாப்ஷாட்டை உருவாக்கு + Delete Snapshot ஸ்னாப்ஷாட்டை நீக்கு + New Snapshot புதிய ஸ்னாப்ஷாட் + Snapshot 1 ஸ்னாப்ஷாட் 1 + Step Forward முன்னோக்கிச் செல் + Step Backward பின்னோக்கிச் செல் + Nonlinear History நேர்கோட்டற்ற வரலாறு + Set New Snapshot as Default புதிய ஸ்னாப்ஷாட்டை இயல்புநிலையாக அமை + Automatically Create First Snapshot முதல் ஸ்னாப்ஷாட்டைத் தானாக உருவாக்கு + Automatically Create New Snapshot When Saving சேமிக்கும்போது புதிய ஸ்னாப்ஷாட்டைத் தானாக உருவாக்கு + Show New Snapshot Dialog by Default புதிய ஸ்னாப்ஷாட் உரையாடலை இயல்பாகக் காண்பி + Clear History வரலாற்றை அழி + View menu காட்சிப் பட்டி + Keep view in sync காட்சியை ஒத்திசைவாக வை + {zoom}% {zoom}% + Mouse Controls சுட்டிக் கட்டுப்பாடுகள் + Initial Direction of Stroke கோட்டின் தொடக்கத் திசை + Direction of Stroke கோட்டின் திசை + Humidity ஈரப்பதம் + Auto Clean Brush தூரிகையைத் தானாகச் சுத்தம்செய் + Spacing: இடைவெளி: + New Layer from Brush தூரிகையிலிருந்து புதிய அடுக்கு + New Brush புதிய தூரிகை + New Tool Preset புதிய கருவி முன்னமைவு + Auto-Leading தானியங்கி வரியிடைவெளி + Scale Horizontally கிடைமட்டமாக அளவு மாற்று + Scale Vertically செங்குத்தாக அளவு மாற்று + Mac LCD Mac LCD + Mac Sharp Mac கூர்மை + Mac Crisp Mac துல்லியம் + Mac Strong Mac வலிமை + Mac Smooth Mac மென்மை + Superscript மேலொட்டு + Subscript கீழொட்டு + Ligature இணைப்பெழுத்து + Alternative மாற்று + Ultra Light மிக மிக மெல்லிய + Extra Light மிக மெல்லிய + Semi Bold அரைத் தடித்த + Extra Bold மிகத் தடித்த + Ultra Bold மிக மிகத் தடித்த + Condensed நெருக்கமான + First Line Indent முதல் வரி உள்தள்ளல் + Hanging Punctuation தொங்கும் நிறுத்தற்குறிகள் + Roman Hanging Punctuation ரோமன் தொங்கும் நிறுத்தற்குறிகள் + Avoid Widows/Orphans தனித்த வரிகளைத் தவிர் + Compose அமை + Adobe Single-line Composer Adobe ஒற்றை வரி அமைப்பி + Adobe Every-line Composer Adobe ஒவ்வொரு வரி அமைப்பி + Word Spacing சொல் இடைவெளி + Letter Spacing எழுத்து இடைவெளி + Glyph Scaling குறியுரு அளவீடு + Auto-leading % தானியங்கி வரியிடைவெளி % + Single Word Justification ஒற்றைச் சொல் சீரமைப்பு + Justify இருபுறமும் சமப்படுத்து + Glyph குறியுரு + Alternates for Current Selection தற்போதைய தேர்வுக்கான மாற்றுகள் + Digits இலக்கங்கள் + Path: பாதை: + Path to Illustrator… பாதையை Illustrator-க்கு… + Clipping Path வெட்டுப் பாதை + New Path புதிய பாதை + Duplicate Path பாதையை நகலெடு + Rename Path… பாதையின் பெயரை மாற்று… + Auto-Hide தானாக மறை + X X + Y Y + Frame Sync சட்ட ஒத்திசைவு + Quick Sync விரைவு ஒத்திசைவு + Audio ஒலி + Frame Duration சட்ட கால அளவு + Loop மீளியக்கம் + Split at Playhead இயக்கத்தலையில் பிரி + Flatten Frames Into Clips சட்டங்களைக் கிளிப்களாகத் தட்டையாக்கு + Flatten Frames Into Layers சட்டங்களை அடுக்குகளாகத் தட்டையாக்கு + Convert Frames > Video Layers சட்டங்களை > வீடியோ அடுக்குகளாக மாற்று + New Video Layer புதிய வீடியோ அடுக்கு + New Video Group புதிய வீடியோ குழு + Limit Playback இயக்கத்தை வரம்பிடு + Enable Timeline Quick Config காலவரிசை விரைவு உள்ளமைவை இயக்கு + Set Start Timecode… தொடக்க நேரக்குறியீட்டை அமை… + Timecode நேரக்குறியீடு + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time தற்போதைய நேரம் + Out வெளியே + In உள்ளே + Duration கால அளவு + Repeat திரும்பச்செய் + Play Once ஒருமுறை இயக்கு + Info Options… தகவல் விருப்பங்கள்… + First Color Readout முதல் நிற அளவீடு + Second Color Readout இரண்டாம் நிற அளவீடு + Actual Color உண்மை நிறம் + Grayscale Slider சாம்பல் அளவு நழுவி + Web Color வலை நிறம் + HSB Color HSB நிறம் + Total Ink மொத்த மை + Opacity Mask ஒளிபுகா மறைப்பு + Document Size ஆவண அளவு + Timing நேரக்கணிப்பு + Status Information நிலைத் தகவல் + Saved Multichannel சேமித்த பல்சேனல் + Mouse Coordinates சுட்டி ஆயத்தொலைவுகள் + Ruler Units அளவுகோல் அலகுகள் + Hide மறை + Restore last viewed comp கடைசியாகப் பார்த்த அமைப்பை மீட்டமை + Select All Layers அனைத்து அடுக்குகளையும் தேர்ந்தெடு + Keep proportions விகிதங்களைப் பேணு + Interpolation இடைக்கணிப்பு + Caption தலைப்புரை + Caption Credits தலைப்புரை நன்றி + Keywords முக்கிய சொற்கள் + Copyright Info URL பதிப்புரிமைத் தகவல் URL + Copyright Status பதிப்புரிமை நிலை + Copyright Notice பதிப்புரிமை அறிவிப்பு + Document Title ஆவணத் தலைப்பு + Copyrighted பதிப்புரிமை உள்ளது + Public Domain பொதுப் பயன்பாடு + Unknown தெரியாதது + Credit நன்றி + Transmission Reference பரிமாற்றக் குறிப்பு + Urgency அவசரம் + High உயர் + Low குறைவு + Category வகைப்பாடு + Supplemental Categories துணை வகைப்பாடுகள் + Object Name / Title பொருள் பெயர் / தலைப்பு + Date Created உருவாக்கிய தேதி + City நகரம் + Province / State மாகாணம் / மாநிலம் + Country நாடு + Byline ஆக்குநர் பெயர் + Byline Title ஆக்குநர் தலைப்பு + Phone தொலைபேசி + Email மின்னஞ்சல் + URLs URLகள் + Last Modified கடைசியாக மாற்றியது + EXIF EXIF + GPS GPS + Camera Model கேமரா மாதிரி + Camera Make கேமரா தயாரிப்பாளர் + EXIF Version EXIF பதிப்பு + Exposure Program வெளிப்பாட்டு நிரல் + Aperture துளை + Shutter Speed ஷட்டர் வேகம் + Metering Mode அளவீட்டு முறை + Light Source ஒளி மூலம் + Flash ஃபிளாஷ் + Lens Info லென்ஸ் தகவல் + ISO Speed ISO வேகம் + Subject Distance பொருள் தூரம் + DateTime Original அசல் தேதிநேரம் + Create Date உருவாக்கிய தேதி + Modify Date மாற்றிய தேதி + Raw Data மூலத் தரவு + Import… இறக்குமதி செய்… + PDF Presentation… PDF விளக்கக்காட்சி… + Create Multiple Picture Packages பல பட தொகுப்புகளை உருவாக்கு + Picture Package… பட தொகுப்பு… + Web Photo Gallery… வலைப் புகைப்படக் காட்சியகம்… + Choose… தேர்ந்தெடு… + Select Folder… கோப்புறையைத் தேர்ந்தெடு… + Override Action "Save" Commands செயல் "சேமி" கட்டளைகளை மேலெழுது + File Naming கோப்புப் பெயரிடல் + Compatibility இணக்கத்தன்மை + Mac OS Mac OS + Unix Unix + Include ICC Profile ICC சுயவிவரத்தைச் சேர் + Error File பிழைக் கோப்பு + Stop For Errors பிழைகளுக்கு நிறுத்து + Log Errors To File பிழைகளைக் கோப்பில் பதிவுசெய் + Errors பிழைகள் + Settings அமைப்புகள் + Settings: அமைப்புகள்: + Working Space பணி வெளி + RGB: RGB: + CMYK: CMYK: + Gray: சாம்பல்: + Spot: புள்ளி: + Advanced Controls மேம்பட்ட கட்டுப்பாடுகள் + Desaturate Monitor Colors By திரை நிறங்களின் செறிவைக் குறை + Blend RGB Colors Using Gamma 1.0 காமா 1.0 மூலம் RGB நிறங்களைக் கலக்கு + Profile Mismatches: Ask When Opening சுயவிவரப் பொருத்தமின்மை: திறக்கும்போது கேள் + Ask When Pasting ஒட்டும்போது கேள் + Missing Profiles: Ask When Opening காணாமல்போன சுயவிவரங்கள்: திறக்கும்போது கேள் + Embed profiles சுயவிவரங்களை உட்பொதி + Ask Before Saving Saved Embedded சேமித்த உட்பொதிவைச் சேமிக்கும் முன் கேள் + Coerce to sRGB sRGB ஆக வலியுறுத்து + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Minimalistic குறைந்தபட்ச வடிவம் + Expand All Groups அனைத்துக் குழுக்களையும் விரிவாக்கு + Photo (2 × 3 in) புகைப்படம் (2 × 3 அங்.) + Photo (4 × 6 in) புகைப்படம் (4 × 6 அங்.) + Photo (5 × 7 in) புகைப்படம் (5 × 7 அங்.) + Photo (8 × 10 in) புகைப்படம் (8 × 10 அங்.) + Recent Items சமீபத்திய உருப்படிகள் + Delete Preset… முன்னமைவை நீக்கு… + Preset Name: முன்னமைவு பெயர்: + Width and Height அகலமும் உயரமும் + Color Profile நிறச் சுயவிவரம் + Units அலகுகள் + Create new count group புதிய எண்ணிக்கைக் குழுவை உருவாக்கு + Adjustment Layer சரிசெய்தல் அடுக்கு + Drag to move the layer அடுக்கை நகர்த்த இழுக்கவும் + Go to Layer அடுக்குக்குச் செல் + After (X) பின் (X) + Reference point location குறிப்புப் புள்ளி இடம் + Toggle reference point குறிப்புப் புள்ளியை மாற்று + Add Pins பின்களைச் சேர் + Delete Pins பின்களை நீக்கு + Rotate Auto சுழற்சி தானியங்கி + Rotate Fixed சுழற்சி நிலையானது + Select Plane தளத்தைத் தேர்ந்தெடு + Measure அளவிடு + Heal குணப்படுத்து + Heal Off குணப்படுத்தல் அணை + Heal On குணப்படுத்தல் இயக்கு + Heal Auto குணப்படுத்தல் தானியங்கி + Blend கலப்பு + Brush Size தூரிகை அளவு + Heal Color குணப்படுத்தும் நிறம் + Result முடிவு + Duplicate நகலெடு + Fast வேகமான + Accurate துல்லியமான + Add to இதில் சேர் + Subtract from இதிலிருந்து கழி + Intersect with இதனுடன் வெட்டிக்கொள் + Capture Comp அமைப்பைப் பதிவுசெய் + No Layer Comps அடுக்கு அமைப்புகள் இல்லை + Adobe PDF Presets… Adobe PDF முன்னமைவுகள்… + 3D… 3D… + Technology Previews… தொழில்நுட்ப முன்னோட்டங்கள்… + Add a mask (from the selection; ⌥ inverts) மறைப்பை சேர் (தேர்விலிருந்து; ⌥ தலைகீழாக்கும்) + Add to selection (⇧) தேர்வில் சேர் (⇧) + Airbrush: keeps painting while the pointer rests. ஏர்பிரஷ்: சுட்டி நிற்கும் போதும் வண்ணமிடுவதைத் தொடரும். + Click the first point or press ↵ to close · Esc cancels மூட முதல் புள்ளியைக் கிளிக் செய்யவும் அல்லது ↵ அழுத்தவும் · Esc ரத்துசெய்யும் + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove புள்ளியைச் சேர்க்க கிளிக் செய்யவும் · நீக்க வெளியே இழுக்கவும் அல்லது ⌘/Ctrl-கிளிக் அல்லது Delete + Click to apply, ⌥-click to clear overrides, double-click for Style Options பயன்படுத்த கிளிக், மேலெழுதல்களை அழிக்க ⌥-கிளிக், பாணி விருப்பங்களுக்கு இருமுறை கிளிக் + Click to zoom in · ⌥-click to zoom out பெரிதாக்க கிளிக் · சிறிதாக்க ⌥-கிளிக் + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel கிளிக்: மூலை · இழு: மென்மை · முதல் புள்ளி கிளிக்: மூடு · ↩ முடி · Esc ரத்து + Commit Perspective Warp (↩) கோண வளைப்பை உறுதிசெய் (↩) + Commit Puppet Warp (↩) உருவ வளைப்பை உறுதிசெய் (↩) + Commit any current edits (⌘↩) தற்போதைய திருத்தங்களை உறுதிசெய் (⌘↩) + Commit current crop operation (↵) தற்போதைய வெட்டுச் செயலை உறுதிசெய் (↵) + Commit transform (↩) உருமாற்றத்தை உறுதிசெய் (↩) + Commit warp (↩) வளைப்பை உறுதிசெய் (↩) + Create a new layer (⇧⌘N) புதிய அடுக்கை உருவாக்கு (⇧⌘N) + Custom pattern தனிப்பயன் வடிவமைப்பு + General Brushes பொதுத் தூரிகைகள் + Grid (⌘') கட்டம் (⌘') + Intersect with selection (⇧⌥) தேர்வுடன் வெட்டிக்கொள் (⇧⌥) + New document… ⌘N புதிய ஆவணம்… ⌘N + Open… ⌘O திற… ⌘O + Rulers (⌘R) அளவுகோல்கள் (⌘R) + Search commands (⌘K) கட்டளைகளைத் தேடு (⌘K) + Show Guides (⌘;) அளவுகோட்டு வரிகளைக் காண்பி (⌘;) + Subtract from selection (⌥) தேர்விலிருந்து கழி (⌥) + Uses the foreground and background colors. முன்புற மற்றும் பின்னணி நிறங்களைப் பயன்படுத்துகிறது. + ↑↓ navigate ↵ run esc close ↑↓ வழிசெல் ↵ இயக்கு esc மூடு + Adds grain to the soft edges of the brush tip. தூரிகை முனையின் மென்மையான விளிம்புகளில் தானியத்தைச் சேர்க்கிறது. + Keeps the current texture when switching presets. முன்னமைவுகளை மாற்றும்போது தற்போதைய பரப்பமைப்பைத் தக்கவைக்கிறது. + Watercolour look: lighter interior, darker rims. நீர்வண்ணத் தோற்றம்: வெளிர்ந்த உட்பகுதி, கருமையான விளிம்புகள். + Collapse group (⌥-click: all groups) குழுவைச் சுருக்கு (⌥-கிளிக்: அனைத்துக் குழுக்கள்) + Expand group (⌥-click: all groups) குழுவை விரிவாக்கு (⌥-கிளிக்: அனைத்துக் குழுக்கள்) + Hide the layer's effects (⌥-click: all layers) அடுக்கின் விளைவுகளை மறை (⌥-கிளிக்: அனைத்து அடுக்குகள்) + Show the layer's effects (⌥-click: all layers) அடுக்கின் விளைவுகளைக் காண்பி (⌥-கிளிக்: அனைத்து அடுக்குகள்) +cameraRaw Blacks கருப்புகள் +cameraRaw Masking மறைப்புருவாக்கம் +@id edit.stroke கோடிடு +@id edit.transform.scale அளவு மாற்று +cameraRaw Luminance ஒளிர்வுத்திறன் +cameraRaw Color நிறம் +cameraRaw Vibrance துடிப்பு +cameraRaw Color Grading நிற தரப்படுத்தல் +cameraRaw Global பொது +cameraRaw Hue சாயல் +cameraRaw Luminance Detail ஒளிர்வுத்திறன் விவரம் +cameraRaw Color Detail நிற விவரம் +cameraRaw Roughness கரடுமுரடு +cameraRaw Whites வெள்ளைகள் +layerLabel No Color நிறம் இல்லை + Preferences… விருப்பத்தேர்வுகள்… + Actual Pixels உண்மை பிக்சல்கள் + Clipping Mask வெட்டு மறைப்பு + Tool Options கருவி விருப்பங்கள் + Color Swatches நிற மாதிரிகள் + Linear Dodge நேர்கோட்டு ஒளிர்வு + Preview Image முன்னோட்டப் படம் + Save As இவ்வாறு சேமி + Remove All அனைத்தையும் நீக்கு + Unlock பூட்டைத் திற + Enable இயக்கு + Disable முடக்கு + On இயக்கத்தில் + Clear Selection தேர்வை அழி + Invert Selection தேர்வைத் தலைகீழாக்கு + Select Inverse தலைகீழைத் தேர்ந்தெடு + Contract சுருக்கு + Sample Size மாதிரி அளவு + Gamma காமா + Input உள்ளீடு + Input Levels உள்ளீட்டு நிலைகள் + Output Levels வெளியீட்டு நிலைகள் + Layer Styles அடுக்கு பாணிகள் + Blending Mode கலப்பு முறை + Stroke Width கோடு அகலம் + Shadow நிழல் + Adjustment Layers சரிசெய்தல் அடுக்குகள் + Text Layer உரை அடுக்கு + Documents ஆவணங்கள் + File Name கோப்புப் பெயர் + File Format கோப்பு வடிவம் + Compression சுருக்கம் + Artboards ஆர்ட்போர்டுகள் + Snap to Grid கட்டத்துடன் ஒட்டு + Full Screen முழுத்திரை + Fullscreen முழுத்திரை + Workspaces பணியிடங்கள் + Show All அனைத்தையும் காண்பி + Hidden மறைந்தது + Duplicate Layer அடுக்கை நகலெடு + Ungroup குழுவை நீக்கு + Guide Layout… அளவுகோட்டு வரி அமைப்பு… + Page Setup… பக்க அமைப்பு… + Reset Preferences விருப்பத்தேர்வுகளை மீட்டமை + Documentation ஆவணப்படுத்தல் + Check for Updates புதுப்பிப்புகளைச் சரிபார் + Check for Updates… புதுப்பிப்புகளைச் சரிபார்… + Loading… ஏற்றுகிறது… + Processing… செயலாக்குகிறது… + Saving… சேமிக்கிறது… + Exporting… ஏற்றுமதி செய்கிறது… + Please wait… காத்திருக்கவும்… + No selection தேர்வு இல்லை + No layers அடுக்குகள் இல்லை + No recent files சமீபத்திய கோப்புகள் இல்லை + Unsupported file format ஆதரிக்கப்படாத கோப்பு வடிவம் + Failed to open file கோப்பைத் திறக்க முடியவில்லை + Failed to save file கோப்பைச் சேமிக்க முடியவில்லை + Operation canceled செயல் ரத்துசெய்யப்பட்டது + Changes have not been saved. மாற்றங்கள் சேமிக்கப்படவில்லை. + Are you sure? உறுதியா? + Discard Changes மாற்றங்களை நிராகரி + Open File கோப்பைத் திற + Save File கோப்பைச் சேமி + Save Changes மாற்றங்களைச் சேமி + Rendering… ரெண்டர் செய்கிறது… + Importing… இறக்குமதி செய்கிறது… + Ready தயார் + Failed தோல்வி + Warning எச்சரிக்கை + Information தகவல் + Retry மீண்டும் முயல் + Continue தொடர் + Skip தவிர் + Resume மீண்டும் தொடங்கு + Reset All அனைத்தையும் மீட்டமை + Defaults இயல்புநிலைகள் + Apply to All அனைத்திற்கும் பயன்படுத்து + Confirm உறுதிசெய் + Finish முடி + Back பின் + Next அடுத்து + Previous முந்தைய + More மேலும் + Less குறைவு + Manual கைமுறை + Disabled முடக்கப்பட்டது + Locked பூட்டப்பட்டது + Unlocked பூட்டு நீக்கப்பட்டது + Delete… நீக்கு… + Create… உருவாக்கு… + Edit… திருத்து… + Change… மாற்று… + Transformation உருமாற்றம் + Scaling அளவு மாற்றம் + Canvas Size படச்சட்ட அளவு + Percentage சதவீதம் + Degrees பாகைகள் + Middle நடு + Diameter விட்டம் + Intensity தீவிரம் + Sensitivity உணர்திறன் + Softness மென்மை + Feather Radius இறகு ஆரம் + Library நூலகம் + Layer Properties அடுக்குப் பண்புகள் + Groups குழுக்கள் + Masks மறைப்புகள் + Add Mask மறைப்பைச் சேர் + Delete Mask மறைப்பை நீக்கு + Invert Mask மறைப்பைத் தலைகீழாக்கு + Enable Mask மறைப்பை இயக்கு + Disable Mask மறைப்பை முடக்கு + Apply Mask மறைப்பைப் பயன்படுத்து + Import Settings அமைப்புகளை இறக்குமதி செய் + Export Settings அமைப்புகளை ஏற்றுமதி செய் + Shortcuts குறுக்குவழிகள் + Keyboard விசைப்பலகை + Mouse சுட்டி + Tablet டேப்லெட் + Display காட்சி + Panel பலகம் + Hide Grid கட்டத்தை மறை + Fit to Window சாளரத்துக்குப் பொருத்து + Live Preview நேரலை முன்னோட்டம் + Before முன் + After பின் + Compare ஒப்பிடு + File Size கோப்பு அளவு + Filename கோப்புப் பெயர் + Open Folder கோப்புறையைத் திற + Choose Folder கோப்புறையைத் தேர்ந்தெடு + Browse உலாவு + Find தேடு + Replace மாற்று + Date தேதி + Learn More மேலும் அறிக + Report a Bug பிழையைப் புகாரளி + Updates புதுப்பிப்புகள் + Update புதுப்பி + Restart Required மறுதொடக்கம் தேவை + Restart மறுதொடக்கம் + Quit வெளியேறு + Quit PhotoCraft போட்டோகிராப்டிலிருந்து வெளியேறு + Close Window சாளரத்தை மூடு + New Window புதிய சாளரம் + Open Image படத்தைத் திற + Save Image படத்தைச் சேமி + Export Image படத்தை ஏற்றுமதி செய் + Import Image படத்தை இறக்குமதி செய் + Export As இவ்வாறு ஏற்றுமதி செய் + Export to இதற்கு ஏற்றுமதி செய் + Export Options ஏற்றுமதி விருப்பங்கள் + Import Options இறக்குமதி விருப்பங்கள் + No Selection தேர்வு இல்லை + No Image படம் இல்லை + No Layers அடுக்குகள் இல்லை + No Results முடிவுகள் இல்லை + No Results Found முடிவுகள் கிடைக்கவில்லை + Nothing to Undo செயல்தவிர்க்க எதுவும் இல்லை + Nothing to Redo மீண்டும் செய்ய எதுவும் இல்லை + Changes Saved மாற்றங்கள் சேமிக்கப்பட்டன + Save before closing? மூடும் முன் சேமிக்கவா? + Are you sure you want to quit? வெளியேற விரும்புவது உறுதியா? + The file could not be opened. கோப்பைத் திறக்க முடியவில்லை. + The file could not be saved. கோப்பைச் சேமிக்க முடியவில்லை. + Unsupported format ஆதரிக்கப்படாத வடிவம் + Out of memory நினைவகம் போதவில்லை + Permission denied அனுமதி மறுக்கப்பட்டது + File not found கோப்பு கிடைக்கவில்லை + Operation failed செயல் தோல்வியடைந்தது + Operation completed செயல் முடிந்தது + Operation cancelled செயல் ரத்துசெய்யப்பட்டது + Sharpen Amount கூர்மையாக்கும் அளவு + Sharpen Radius கூர்மையாக்கும் ஆரம் + Sharpen Detail கூர்மையாக்கும் விவரம் + Sharpen Masking கூர்மையாக்கும் மறைப்பு + Noise Luminance இரைச்சல் ஒளிர்வுத்திறன் + Noise Luminance Detail இரைச்சல் ஒளிர்வுத்திறன் விவரம் + Noise Color இரைச்சல் நிறம் + Noise Color Detail இரைச்சல் நிற விவரம் + Grain Amount தானிய அளவு + Grain Size தானியப் பருமன் + Grain Roughness தானியக் கரடுமுரடு + Vignette Roundness ஓர மங்கல் வட்டத்தன்மை + Vignette Feather ஓர மங்கல் இறகு + Vignette Highlights ஓர மங்கல் ஒளிர்வுகள் + Vignette Style ஓர மங்கல் பாணி + Planes தளங்கள் + Plane தளம் + Shadow Amount நிழல் அளவு + Shadow Tone நிழல் தொனி + Shadow Radius நிழல் ஆரம் + Highlight Amount ஒளிர்வு அளவு + Highlight Tone ஒளிர்வு தொனி + Highlight Radius ஒளிர்வு ஆரம் + Midtone நடுத்தொனி + Black Clip கருப்பு வெட்டு + White Clip வெள்ளை வெட்டு + Fuzziness மங்கலான தன்மை + Source Layer மூல அடுக்கு + Neutralize நடுநிலையாக்கு + Use Selection In Source மூலத்தில் தேர்வைப் பயன்படுத்து + Use Selection In Target இலக்கில் தேர்வைப் பயன்படுத்து + Flame Type தீச்சுடர் வகை + Randomize Length நீளத்தை சீரற்றதாக்கு + Interval கால இடைவெளி + Adjust Interval For Loops சுழல்களுக்கு இடைவெளியைச் சரிசெய் + Use Custom Color தனிப்பயன் நிறத்தைப் பயன்படுத்து + Flame Lines தீச்சுடர் கோடுகள் + Flame Bottom Alignment தீச்சுடர் அடிப்பகுதி சீரமைப்பு + Flame Style தீச்சுடர் பாணி + Flame Shape தீச்சுடர் வடிவம் + Randomize Shapes வடிவங்களை சீரற்றதாக்கு + Frame சட்டம் + Arrangement ஒழுங்கமைவு + Thickness தடிமன் + Vine Color கொடி நிறம் + Flower Color பூ நிறம் + Leaf Color இலை நிறம் + Base Tree Type அடிப்படை மர வகை + Light Direction ஒளித் திசை + Leaves Amount இலைகளின் அளவு + Leaves Size இலைகளின் பருமன் + Branches Height கிளைகளின் உயரம் + Branches Thickness கிளைகளின் தடிமன் + Default Leaves இயல்புநிலை இலைகள் + Leaves Color இலைகளின் நிறம் + Custom Branch Color தனிப்பயன் கிளை நிறம் + Branches Color கிளைகளின் நிறம் + Flat Shading தட்டையான நிழலிடல் + Date and Time தேதி & நேரம் + Scale Units அளவுகோல் அலகுகள் + Scale Factor அளவுக் காரணி + Area பரப்பளவு + Perimeter சுற்றளவு + Circularity வட்ட நிலை + Gray Value (Minimum) சாம்பல் மதிப்பு (குறைந்தபட்சம்) + Gray Value (Maximum) சாம்பல் மதிப்பு (அதிகபட்சம்) + Gray Value (Mean) சாம்பல் மதிப்பு (சராசரி) + Gray Value (Median) சாம்பல் மதிப்பு (இடைநிலை) + Integrated Density ஒருங்கிணைந்த அடர்த்தி + Length நீளம் + In Black உள் கருப்பு + In White உள் வெள்ளை + Out Black வெளி கருப்பு + Out White வெளி வெள்ளை + Tint Color சாய நிறம் + Stops நிறுத்தங்கள் + Center X மையம் X + Center Y மையம் Y + Cell Size செல் அளவு + Generators ஜெனரேட்டர்கள் + Wavelength Min அலைநீளம் குறைந்தபட்சம் + Wavelength Max அலைநீளம் அதிகபட்சம் + Amplitude Min வீச்சு குறைந்தபட்சம் + Amplitude Max வீச்சு அதிகபட்சம் + Undefined Areas வரையறுக்கப்படாத பகுதிகள் + Preserve பேணு + Max Radius அதிகபட்ச ஆரம் + Depth Mode ஆழ முறை + Solid Front Faces திட முன் முகங்கள் + Mask Incomplete Blocks முழுமையற்ற தொகுதிகளை மறையச் செய் + Stylization பாணியாக்கம் + Cleanliness தூய்மை + Bristle Detail முட்கள் விவரம் + Lighting ஒளியூட்டல் + Shine பளபளப்பு + Max Offset அதிகபட்ச நகர்வு + Map Document வரைபட ஆவணம் + Map Path வரைபடப் பாதை + Map Layer வரைபட அடுக்கு + Ridges முகடுகள் + Light Type ஒளி வகை + Light X ஒளி X + Light Y ஒளி Y + Light Z ஒளி Z + Target X இலக்கு X + Target Y இலக்கு Y + Hotspot ஹாட்ஸ்பாட் + Elevation ஏற்றம் + Ambient சுற்றுப்புறம் + Warmth வெப்பம் + Gloss மினுமினுப்பு + Metallic உலோகம் + Ambience சூழல் + White Is High வெள்ளை உயரம் + Blade Curvature கத்தி வளைவு + Depth Map ஆழ வரைபடம் + Focal Distance குவிய தூரம் + Focus குவியம் + Transition மாற்றம் + Radius X ஆரம் X + Radius Y ஆரம் Y + Pins பின்கள் + Speed வேகம் + Taper குறுகல் + Start X தொடக்கம் X + Start Y தொடக்கம் Y + End X முடிவு X + End Y முடிவு Y + Kernel கெர்னல் + Input Mode உள்ளீட்டு முறை + Row Order வரிசை ஒழுங்கு + Eliminate அகற்று + Create By இதன்படி உருவாக்கு + List பட்டியல் + Pencil Width பென்சில் அகலம் + Stroke Pressure கோட்டு அழுத்தம் + Paper Brightness காகித ஒளிர்வு + Number Of Levels நிலைகளின் எண்ணிக்கை + Edge Simplicity விளிம்பு எளிமை + Edge Fidelity விளிம்பு துல்லியம் + Brush Detail தூரிகை விவரம் + Highlight Area ஒளிர்வுப் பகுதி + Glow Size பிரகாச அளவு + Glow Brightness பிரகாச ஒளிர்வு + Brush Type தூரிகை வகை + Stroke Size கோட்டு அளவு + Stroke Detail கோட்டு விவரம் + Highlight Strength ஒளிர்வு வலிமை + Smoothness மென்மை + Edge Thickness விளிம்பு தடிமன் + Edge Intensity விளிம்பு தீவிரம் + Posterization போஸ்டரைசேஷன் + Stroke Length கோட்டு நீளம் + Relief புடைப்பு + Definition தெளிவுநிலை + Texture Coverage பரப்பமைப்பு மூடல் + Shadow Intensity நிழல் தீவிரம் + Edge Width விளிம்பு அகலம் + Edge Brightness விளிம்பு ஒளிர்வு + Direction Balance திசைச் சமநிலை + Black Intensity கருப்பு தீவிரம் + White Intensity வெள்ளை தீவிரம் + Dark Intensity கருமை தீவிரம் + Custom Pixel Aspect Ratio தனிப்பயன் பிக்சல் விகிதம் + 32-bit Preview Options 32 பிட் முன்னோட்ட விருப்பங்கள் + New Guide புதிய அளவுகோட்டு வரி + Join the ArtCraft Discord ArtCraft Discord-இல் சேர் + Report an Issue சிக்கலைப் புகாரளி + Load Files into Stack கோப்புகளைக் குவியலில் ஏற்று + Statistics புள்ளிவிவரங்கள் + Duplicate Layers அடுக்குகளை நகலெடு + Edit Toolbar கருவிப்பட்டியைத் திருத்து + Import Brushes தூரிகைகளை இறக்குமதி செய் + Load 3D LUT 3D LUT ஐ ஏற்று + Press keys விசைகளை அழுத்தவும் + Rename Group குழுவின் பெயரை மாற்று + Rename Tool Preset கருவி முன்னமைவின் பெயரை மாற்று + Search commands, tools and panels கட்டளைகள், கருவிகள், பலகங்களைத் தேடு + Slice Options துண்டு விருப்பங்கள் + Rename Brush தூரிகையின் பெயரை மாற்று + Opening திறக்கிறது + Reading படிக்கிறது + Working செயல்படுகிறது + Accented Edges அழுத்தமான விளிம்புகள் + Angled Strokes கோணக் கோடுகள் + Anisotropic அனிசோட்ரோபிக் + Around Center மையத்தைச் சுற்றி + Artistic கலைநயம் + Bamboo மூங்கில் + Bas Relief தாழ் புடைப்புச் சிற்பம் + Berries பெர்ரிகள் + Blast வெடிப்பு + Bleach Bypass ப்ளீச் பைபாஸ் + Blocks தொகுதிகள் + Brick செங்கல் + Brush Strokes தூரிகைக் கோடுகள் + Burlap சாக்குத்துணி + Candle Light மெழுகுவர்த்தி ஒளி + Circle வட்டம் + Clumped கொத்தாக + Coarse Dots பருமனான புள்ளிகள் + Colored Pencil வண்ணப் பென்சில் + Contrasty மாறுபாடு மிக்க + Cool குளிர் + Craquelure விரிசல் அமைப்பு + Cross குறுக்கு + Cross Process குறுக்குச் செயலாக்கம் + Crosshatch குறுக்குப் பொறிப்பு + Cutout வெட்டுருவம் + Daisies டெய்சிகள் + Dark Rough கருமை கரடுமுரடு + Dark Strokes கருமைக் கோடுகள் + Darken Only கருமையாக்கு மட்டும் + Day For Night பகலை இரவாக்கு + Deep Blue ஆழ் நீலம் + Deep Emerald ஆழ் மரகதம் + Deep Red ஆழ் சிவப்பு + Deep Yellow ஆழ் மஞ்சள் + Dot புள்ளி + Double Line இரட்டைக் கோடு + Draft வரைவு + Dry Brush உலர் தூரிகை + Duplication நகலாக்கம் + Edge Extension விளிம்பு நீட்டிப்பு + Edge Only விளிம்பு மட்டும் + Enlarged பெரிதாக்கப்பட்டது + Even Fields இரட்டைப் புலங்கள் + Faded Film மங்கிய படச்சுருள் + Film Grain படச்சுருள் தானியம் + Fine நுண் + Fine Dots நுண் புள்ளிகள் + Flat தட்டை + Fresco ஃப்ரெஸ்கோ + From Left இடமிருந்து + From Right வலமிருந்து + Frosted பனிபடர்ந்த + Generic பொது + Glass கண்ணாடி + Glowing Edges ஒளிரும் விளிம்புகள் + Grainy Dots தானியப் புள்ளிகள் + Hearts இதயங்கள் + Heptagon ஏழுகோணம் + Hexagon அறுகோணம் + Horizontal Only கிடைமட்டம் மட்டும் + Infinite முடிவிலி + Ink Outlines மை வெளிக்கோடுகள் + Ivy ஐவி கொடி + Left Diagonal இடது மூலைவிட்டம் + Level Based நிலை அடிப்படையிலான + Light Rough வெளிர் கரடுமுரடு + Lighten Only ஒளிரச்செய் மட்டும் + Long Lines நீண்ட கோடுகள் + Long Strokes நீண்ட தூரிகைக் கோடுகள் + Lower கீழ் + Medium Dots நடுத்தரப் புள்ளிகள் + Medium Lines நடுத்தரக் கோடுகள் + Medium Strokes நடுத்தரத் தூரிகைக் கோடுகள் + Mono Contrast ஒற்றை மாறுபாடு + Mosaic Tiles மொசைக் ஓடுகள் + Multiple Flames Along Path பாதையில் பல தீச்சுடர்கள் + Multiple Flames One Direction ஒரே திசையில் பல தீச்சுடர்கள் + Multiple Flames Path Directions பாதைத் திசைகளில் பல தீச்சுடர்கள் + Multiple Flames Various Length பல்வேறு நீளங்களில் பல தீச்சுடர்கள் + Numerals எண்கள் + Lining நேர்கோட்டு எண்கள் + Proportional விகிதாசார எண்கள் + Proportional Lining விகிதாசார நேர்கோட்டு எண்கள் + Proportional Oldstyle விகிதாசார பழைய பாணி எண்கள் + Tabular அட்டவணை வடிவ எண்கள் + Tabular Lining அட்டவணை நேர்கோட்டு எண்கள் + Tabular Oldstyle அட்டவணைப் பழைய பாணி எண்கள் + Octagon எண்கோணம் + Odd Fields ஒற்றைப் புலங்கள் + One Flame Along Path பாதையில் ஒரு தீச்சுடர் + Out From Center மையத்திலிருந்து வெளியே + Oval முட்டைவடிவம் + Overlay Edge மேலடுக்கு விளிம்பு + Palette Knife வண்ணக் கத்தி + Parallel இணை + Patchwork ஒட்டுவேலை + Pentagon ஐங்கோணம் + Point புள்ளி + Pointed கூர்மையான + Pond Ripples குள அலைகள் + Pyramids பிரமிடுகள் + Random சீரற்ற + Right Diagonal வலது மூலைவிட்டம் + Rope கயிறு + Roses ரோஜாக்கள் + Sandstone மணற்கல் + Scallops சிப்பி விளிம்புகள் + Sepia செபியா + Short Lines குறுகிய கோடுகள் + Short Strokes குறுகிய தூரிகைக் கோடுகள் + Simple எளிய + Light Intensity ஒளித் தீவிரம் + Spray Radius தெளிப்பு ஆரம் + Stroke Direction கோட்டுத் திசை + Graininess தானியத்தன்மை + Glow Amount பிரகாச அளவு + Clear Amount தெளிவு அளவு + Distortion திரிபு + Ripple Size அலை அளவு + Ripple Magnitude அலை பருமை + Charcoal Area கரிப் பகுதி + Chalk Area சுண்ணாம்புப் பகுதி + Charcoal Thickness கரி தடிமன் + Light Dark Balance ஒளி-கருமை சமநிலை + Foreground Level முன்புற நிலை + Background Level பின்னணி நிலை + Pattern Type வடிவ வகை + Image Balance பட சமநிலை + Darkness கருமை + Fiber Length இழை நீளம் + Crack Spacing விரிசல் இடைவெளி + Crack Depth விரிசல் ஆழம் + Crack Brightness விரிசல் ஒளிர்வு + Grain Type தானிய வகை + Tile Size ஓடு அளவு + Grout Width சாந்து அகலம் + Lighten Grout சாந்தை ஒளிரச்செய் + Square Size சதுர அளவு + Border Thickness எல்லை தடிமன் + Correct Distortion திரிபைத் திருத்து + Correct Vignette ஓர மங்கலைத் திருத்து + Correct C A C A ஐத் திருத்து + Auto Scale தானியங்கி அளவு + Red Cyan சிவப்பு நீலப்பச்சை + Blue Yellow நீலம் மஞ்சள் + Vignette Amount ஓர மங்கல் அளவு + Vignette Midpoint ஓர மங்கல் நடுப்புள்ளி + Constraints கட்டுப்பாடுகள் + Curve Highlights வளைவு ஒளிர்வுகள் + Curve Lights வளைவு ஒளிகள் + Curve Darks வளைவு கருமைகள் + Curve Shadows வளைவு நிழல்கள் + Curve Splits வளைவுப் பிளவுகள் + Point Curve புள்ளி வளைவு + Point Curve Red புள்ளி வளைவு சிவப்பு + Point Curve Green புள்ளி வளைவு பச்சை + Point Curve Blue புள்ளி வளைவு நீலம் + Hsl Hue HSL சாயல் + Hsl Sat HSL செறிவு + Hsl Lum HSL ஒளிர்வுத்திறன் + Grade Shadows தரப்படுத்து நிழல்கள் + Grade Midtones தரப்படுத்து நடுத்தொனிகள் + Grade Highlights தரப்படுத்து ஒளிர்வுகள் + Grade Global தரப்படுத்து பொது + Grade Blending தரப்படுத்து கலத்தல் + Grade Balance தரப்படுத்து சமநிலை + Sine சைன் + Sketch ஓவியக்கோடு + Snowflakes பனித்துகள்கள் + Sparkle மின்னல் + Spatter தெறிப்பு + Speckle புள்ளிப்பொடி + Spin சுழற்சி + Sprinkles தூவல்கள் + Squareness சதுரத்தன்மை + Stagger தடுமாற்றம் + Stained Glass வண்ணக் கண்ணாடி + Star நட்சத்திரம் + Stars நட்சத்திரங்கள் + Stippled புள்ளியிட்ட + Stretch நீட்டு + Teal Orange டீல் ஆரஞ்சு + Tiny Lens சிறு லென்ஸ் + To Center மையத்தை நோக்கி + Unaltered மாற்றப்படாதது + Underpainting அடிவண்ணம் + Underwater நீருக்கடியில் + Upper மேல் + Vertical Only செங்குத்து மட்டும் + Vine With Flowers பூக்களுடன் கொடி + Vine With Leaves இலைகளுடன் கொடி + Violent தீவிரமான + Warm வெப்பம் + Watercolor நீர்வண்ணம் + Waves அலைகள் + Wide Blurry அகலமான மங்கல் + Wide Sharp அகலமான கூர்மை + Wrap சுற்று + Zigzag ஜிக்ஜாக் + Wetness ஈரத்தன்மை + AI Integrations AI ஒருங்கிணைப்புகள் + Add Noise இரைச்சலைச் சேர் + Apply Data Set தரவுத் தொகுப்பைப் பயன்படுத்து + Apply Image படத்தைப் பயன்படுத்து + Arbitrary விருப்பம்போல் + Artboard from Group குழுவிலிருந்து ஆர்ட்போர்டு + Artboard from Layers அடுக்குகளிலிருந்து ஆர்ட்போர்டு + Artboards to Files ஆர்ட்போர்டுகளைக் கோப்புகளாக + Artboards to PDF ஆர்ட்போர்டுகளை PDF ஆக + Assign Profile சுயவிவரத்தை ஒதுக்கு + At இடத்தில் + Auto-Align Layers அடுக்குகளைத் தானாக சீரமை + Auto-Blend Layers அடுக்குகளைத் தானாகக் கலக்கு + Batch தொகுதி + Border எல்லை + Box Blur பெட்டி மங்கல் + Calculations கணக்கீடுகள் + Camera Raw கேமரா ரா + Camera Raw Filter கேமரா ரா வடிகட்டி + Channel1 சேனல்1 + Channel2 சேனல்2 + Channel3 சேனல்3 + Channel4 சேனல்4 + Clone நகலெடு + Color Decontaminate நிறத் தூய்மையாக்கு + Color Halftone நிற அரைத்தொனி + Color Lookup Tables நிறத் தேடல் அட்டவணைகள் + Color Settings நிற அமைப்புகள் + Color Table நிற அட்டவணை + Conditional Mode Change நிபந்தனை முறை மாற்றம் + Content-Aware Fill உள்ளடக்கம் அறிந்த நிரப்பு + Convert to Linked இணைக்கப்பட்டதாக மாற்று + Convert to Profile சுயவிவரமாக மாற்று + Crystallize படிகமாக்கு + Data தரவு + Data Sets as Files தரவுத் தொகுப்புகளைக் கோப்புகளாக + De-Interlace இடைப்பின்னலை நீக்கு + Define Brush Preset தூரிகை முன்னமைவை வரையறு + Define Custom Shape தனிப்பயன் வடிவத்தை வரையறு + Define Pattern வடிவமைப்பை வரையறு + Defringe விளிம்பு நீக்கு + Diffuse பரப்பு + Diffuse Glow பரவிய பிரகாசம் + Displace இடம்பெயர் + Do you want to save you made to “{name}” before quitting? வெளியேறும் முன் “{name}” இல் செய்த மாற்றங்களைச் சேமிக்க விரும்புகிறீர்களா? + Dust & Scratches தூசு & கீறல்கள் + Export Contents உள்ளடக்கத்தை ஏற்றுமதி செய் + Export Preferences ஏற்றுமதி விருப்பத்தேர்வுகள் + Export/Import Presets முன்னமைவுகளை ஏற்றுமதி/இறக்குமதி செய் + Extrude நீட்டு + Fibers இழைகள் + Field Blur புல மங்கல் + File Info கோப்பு தகவல் + Filter Gallery வடிகட்டி காட்சியகம் + Find and Replace Text உரையைத் தேடி மாற்று + Fit Image படத்தைப் பொருத்து + Flame தீச்சுடர் + Focus Area கவனப் பகுதி + Gaussian Blur காசியன் மங்கல் + Global Light பொது ஒளி + Group from Layers அடுக்குகளிலிருந்து குழு + HDR Toning HDR டோனிங் + High Pass உயர் அனுமதி + Highlight ஒளிர்வு + Image Processor பட செயலி + Install Plug-in செருகுநிரலை நிறுவு + Interpret Footage காட்சிப்பதிவை விளக்கு + Iris Blur கருவிழி மங்கல் + Jag ஜாக் + Label குறியீடு + Layer Comps to Files அடுக்கு அமைப்புகளைக் கோப்புகளாக + Layer Content Options அடுக்கு உள்ளடக்க விருப்பங்கள் + Layer From Background பின்னணியிலிருந்து அடுக்கு + Layer from Background பின்னணியிலிருந்து அடுக்கு + Layers to Files அடுக்குகளைக் கோப்புகளாக + Legacy பழையது + Lens Blur லென்ஸ் மங்கல் + Lens Correction லென்ஸ் திருத்தம் + Lens Flare லென்ஸ் ஒளிப்பிழம்பு + Lighting Effects ஒளி விளைவுகள் + Liquify திரவமாக்கு + Lum ஒளிர் + Match Color நிறத்தைப் பொருத்து + Merge to HDR Pro HDR Pro ஆக இணை + Mezzotint மெசோடின்ட் + Mosaic மொசைக் + Motion Blur இயக்க மங்கல் + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Wayland-இல் சொந்த கோப்பு இழுத்துவிடுதல் இன்னும் ஆதரிக்கப்படவில்லை. கோப்பு › திற என்பதைப் பயன்படுத்தவும், அல்லது `WAYLAND_DISPLAY= photocraft` உடன் போட்டோகிராப்டை XWayland-இல் இயக்கவும். + Neon Glow நியான் பிரகாசம் + New Guide Layout புதிய அளவுகோட்டு வரி அமைப்பு + New Video Layer from File கோப்பிலிருந்து புதிய வீடியோ அடுக்கு + Ocean Ripple கடல் அலை + Oil Paint எண்ணெய் ஓவியம் + Open As இவ்வாறு திற + Package தொகுப்பாக்கு + Paint Daubs வண்ணத் தீற்றல்கள் + Path Blur பாதை மங்கல் + Paths to Illustrator பாதைகளை Illustrator-க்கு + Photomerge புகைப்பட ஒருங்கிணைப்பு + Picture Frame படச் சட்டகம் + Pinch கிள்ளு + Place Embedded உட்பொதிந்து வை + Place Linked இணைத்து வை + Place Scale Marker அளவுகோல் குறியை வை + Plastic Wrap பிளாஸ்டிக் உறை + Pointillize புள்ளிப்படுத்து + Polar Coordinates துருவ ஆயத்தொலைவுகள் + Polar To Rectangular துருவத்திலிருந்து செவ்வகத்துக்கு + Poster Edges சுவரொட்டி விளிம்புகள் + Preset Manager முன்னமைவு மேலாளர் + Profile சுயவிவரம் + Radial Blur ஆரை மங்கல் + Raw Defaults Raw இயல்புநிலைகள் + Rectangular To Polar செவ்வகத்திலிருந்து துருவத்துக்கு + Relink to File கோப்புடன் மீண்டும் இணை + Render Video வீடியோவை ரெண்டர் செய் + Replace Color நிறத்தை மாற்று + Replace Contents உள்ளடக்கத்தை மாற்று + Replace Footage காட்சிப்பதிவை மாற்று + Resolve Missing Fonts காணாமல்போன எழுத்துருக்களைத் தீர் + Ripple அலைவளையம் + Rough Pastels கரடுமுரடான பேஸ்டல்கள் + Sat செறிவு + Save a Copy நகலைச் சேமி + Save for Web (Legacy) வலைக்காகச் சேமி (பழையது) + Scale Effects விளைவுகளை அளவு மாற்று + Script Events Manager ஸ்கிரிப்ட் நிகழ்வு மேலாளர் + Search menus பட்டிகளைத் தேடு + Select and Mask தேர்ந்தெடுத்து மறையச் செய் + Set Measurement Scale அளவீட்டு அளவுகோலை அமை + Shadows/Highlights நிழல்கள்/ஒளிர்வுகள் + Shape Blur வடிவ மங்கல் + Shear ஷியர் + Smart Blur திறன்மிகு மங்கல் + Smart Sharpen திறன்மிகு கூர்மையாக்கல் + Smudge Stick தேய்ப்புக் குச்சி + Solid Color திட நிறம் + Spherize கோளமாக்கு + Spin Blur சுழல் மங்கல் + Spot புள்ளி + Sprayed Strokes தெளித்த கோடுகள் + Strokes கோடுகள் + Sumi-e சுமி-இ + Surface Blur மேற்பரப்பு மங்கல் + Texturizer பரப்பமைப்பாக்கி + Tiles ஓடுகள் + Tilt-Shift சாய்வு-இடமாற்றம் + Trace Contour வெளிக்கோட்டைத் தடமிடு + Trap பிடிப்பு + Tree மரம் + Trim ஒதுக்கு + Turbulent கொந்தளிப்பான + Twirl சுழலச்செய் + Unsharp Mask கூர்மைப்படுத்துதல் மறைப்பு + Use File › Open to open an image. படத்தைத் திறக்க கோப்பு › திற என்பதைப் பயன்படுத்தவும். + Vanishing Point மறையும் புள்ளி + Variable Data Sets மாறி தரவுத் தொகுப்புகள் + Video Frames to Layers வீடியோ சட்டங்களை அடுக்குகளாக + WIA Support WIA ஆதரவு + Warp Text உரை வளைப்பு + Wind காற்று + Vertical Type Tool செங்குத்து உரைக் கருவி + About {app} {app} பற்றி + Hide {app} {app} மறை + Quit {app} {app} வெளியேறு + Services சேவைகள் + Hide Others மற்றவற்றை மறை + Lut LUT + Cooling L B B குளிர்வித்தல் LBB + Cooling80 குளிர்வித்தல்80 + Cooling82 குளிர்வித்தல்82 + Hsb HSB + Hsl HSL + Movie Prime Movie Prime + Prime105 Prime105 + Prime35 Prime35 + Warming L B A வெப்பமூட்டல் LBA + Warming81 வெப்பமூட்டல்81 + Warming85 வெப்பமூட்டல்85 +@id filter.sharpen.sharpen கூர்மையாக்கு + Good நன்று + Best சிறந்தது + Adjustment Properties சரிசெய்தல் பண்புகள் + Default Foreground and Background Colors இயல்புநிலை முன்புற & பின்னணி நிறங்கள் + Move Layer அடுக்கை நகர்த்து + Rectangular Selection செவ்வகத் தேர்வு + Reorder Layer அடுக்கை மறுவரிசைப்படுத்து + Select Layer அடுக்கைத் தேர்ந்தெடு + Set Colors நிறங்களை அமை + Switch Foreground and Background Colors முன்புற & பின்னணி நிறங்களை மாற்றிக்கொள் + Target இலக்கு + Target Composite Channel கூட்டுக் சேனலை இலக்காக்கு + Target Layer Mask அடுக்கு மறைப்பை இலக்காக்கு + Target Channel 3 சேனல் 3 ஐ இலக்காக்கு + Target Channel 4 சேனல் 4 ஐ இலக்காக்கு + Target Channel 5 சேனல் 5 ஐ இலக்காக்கு + Target Channel 6 சேனல் 6 ஐ இலக்காக்கு + Target Channel 7 சேனல் 7 ஐ இலக்காக்கு + Target Channel 8 சேனல் 8 ஐ இலக்காக்கு + Target Channel 9 சேனல் 9 ஐ இலக்காக்கு + Load Luminosity ஒளிர்வுத்திறனை ஏற்று + Make Work Path… பணிப் பாதையை உருவாக்கு… + Create Vector Mask திசையன் மறைப்பை உருவாக்கு + Make Selection… தேர்வை உருவாக்கு… + Make Selection from Path பாதையிலிருந்து தேர்வு செய் + Add Path to Selection தேர்வுடன் பாதையைச் சேர் + Subtract Path from Selection தேர்விலிருந்து பாதையைக் கழி + Fill Path… பாதையை நிரப்பு… + Clipping Path… வெட்டுப் பாதை… + Free Transform Path பாதையைத் தன்னிச்சையாக உருமாற்று + Unite Shapes at Overlap மேலேறுமிடத்தில் வடிவங்களை ஒன்றிணை + Subtract Shapes at Overlap மேலேறுமிடத்தில் வடிவங்களைக் கழி + Copy Fill நிரப்பை நகலெடு + Copy Complete Stroke முழுக் கோட்டை நகலெடு + Paste Fill நிரப்பை ஒட்டு + Paste Complete Stroke முழுக் கோட்டை ஒட்டு + Make Symmetry Path சமச்சீர்ப் பாதையை உருவாக்கு + Disable Symmetry Path சமச்சீர்ப் பாதையை முடக்கு + Select Custom Color… தனிப்பயன் நிறத்தைத் தேர்ந்தெடு… + Color Picker (Custom Canvas Color) நிறத் தேர்வி (தனிப்பயன் படச்சட்ட நிறம்) + Duplicate Channel சேனலை நகலெடு + Delete Channel சேனலை நீக்கு + Rename Channel… சேனலின் பெயரை மாற்று… + Merge Spot Channel புள்ளிச் சேனலை இணை + Convert to Alpha Channel ஆல்பா சேனலாக மாற்று + Exit Quick Mask விரைவு மறைப்பிலிருந்து வெளியேறு + New Channel… புதிய சேனல்… + New Spot Channel… புதிய புள்ளிச் சேனல்… + Split Channels சேனல்களைப் பிரி + Merge Channels… சேனல்களை இணை… + Color Indicates Masked Areas நிறம் மறைக்கப்பட்ட பகுதிகளைக் குறிக்கிறது + Color Indicates Selected Areas நிறம் தேர்ந்தெடுத்த பகுதிகளைக் குறிக்கிறது + ✓ Color Indicates Masked Areas ✓ நிறம் மறைக்கப்பட்ட பகுதிகளைக் குறிக்கிறது + ✓ Color Indicates Selected Areas ✓ நிறம் தேர்ந்தெடுத்த பகுதிகளைக் குறிக்கிறது + Up மேலே + To வரை + Shallow ஆழமற்ற + Deep ஆழமான + Blend Interior Effects as Group உள் விளைவுகளைக் குழுவாகக் கலக்கு + Blend Clipped Layers as Group வெட்டிய அடுக்குகளைக் குழுவாகக் கலக்கு + Transparency Shapes Layer ஒளிஊடுருவல் வடிவங்கள் அடுக்கு + Layer Mask Hides Effects அடுக்கு மறைப்பு விளைவுகளை மறைக்கிறது + Vector Mask Hides Effects திசையன் மறைப்பு விளைவுகளை மறைக்கிறது + General Blending பொதுக் கலப்பு + This Layer: இந்த அடுக்கு: + Underlying Layer: அடியிலுள்ள அடுக்கு: + Nothing to paste: the clipboard holds no image ஒட்ட எதுவும் இல்லை: கிளிப்போர்டில் படம் இல்லை + Preferences saved விருப்பத்தேர்வுகள் சேமிக்கப்பட்டன + Invalid panel layout; restored the default dock தவறான பலக அமைப்பு; இயல்புநிலை டாக் மீட்டமைக்கப்பட்டது + Preferences were reset: {error} விருப்பத்தேர்வுகள் மீட்டமைக்கப்பட்டன: {error} + Recovered {name} {name} மீட்டெடுக்கப்பட்டது + Couldn't save preferences: {error}. Retrying… விருப்பத்தேர்வுகளைச் சேமிக்க முடியவில்லை: {error}. மீண்டும் முயல்கிறது… + Autosave failed: {error} தானியங்கிச் சேமிப்பு தோல்வியடைந்தது: {error} + Exported presets to {path} முன்னமைவுகள் {path} க்கு ஏற்றுமதி செய்யப்பட்டன + Ran script {name} ஸ்கிரிப்ட் {name} இயக்கப்பட்டது + Sent to the printer ({spooler}) அச்சுப்பொறிக்கு அனுப்பப்பட்டது ({spooler}) + Printed to {path} {path} க்கு அச்சிடப்பட்டது + Exported {path} ({size}) {path} ஏற்றுமதி செய்யப்பட்டது ({size}) + Exported {path} {path} ஏற்றுமதி செய்யப்பட்டது + Added swatch {name} நிற மாதிரி {name} சேர்க்கப்பட்டது + Step {step} ({id}) failed: {error} படி {step} ({id}) தோல்வியடைந்தது: {error} + Could not rename the action: {error} செயலின் பெயரை மாற்ற முடியவில்லை: {error} + Action playback failed: {error} செயல் இயக்கம் தோல்வியடைந்தது: {error} + Clone source set at {x}, {y} நகலெடுப்பு மூலம் {x}, {y} இல் அமைக்கப்பட்டது + Color management: {error} நிற மேலாண்மை: {error} + Opened {url} {url} திறக்கப்பட்டது + No custom shape "{name}": pick one in the options bar or Window › Shapes தனிப்பயன் வடிவம் "{name}" இல்லை: விருப்பப் பட்டையில் அல்லது சாளரம் › வடிவங்களில் ஒன்றைத் தேர்ந்தெடுக்கவும் + Saved a copy as {path} நகல் {path} ஆகச் சேமிக்கப்பட்டது + Exported contents to {path} உள்ளடக்கம் {path} க்கு ஏற்றுமதி செய்யப்பட்டது + {label} is not available: {reason} {label} கிடைக்கவில்லை: {reason} + Saved {path}; image assets: {n} in {dir} {path} சேமிக்கப்பட்டது; பட சொத்துகள்: {dir} இல் {n} + {message} (notes: {n}; {warning}) {message} (குறிப்புகள்: {n}; {warning}) + Preferences can't be saved விருப்பத்தேர்வுகளைச் சேமிக்க முடியவில்லை + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. போட்டோகிராப்ட் தொடர்ந்து முயல்கிறது; சேமிப்பு வெற்றிபெறும் வரை, மூடும்போது விருப்பத்தேர்வு மாற்றங்கள் இழக்கப்படும். + GPU retry scheduled GPU மறுமுயற்சி திட்டமிடப்பட்டது + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. GPU முடுக்கத்தை மீண்டும் முயல உங்கள் பணியைச் சேமித்து போட்டோகிராப்டை மறுதொடக்கம் செய்யவும். இந்த அமர்வில் CPU ரெண்டரிங் தொடர்ந்து செயலில் இருக்கும். + GPU device was lost; using the CPU renderer. GPU சாதனம் இழக்கப்பட்டது; CPU ரெண்டரரைப் பயன்படுத்துகிறது. + GPU error; using the CPU renderer. GPU பிழை; CPU ரெண்டரரைப் பயன்படுத்துகிறது. + Couldn't open {name}: {error} {name} ஐத் திறக்க முடியவில்லை: {error} + Toggle the Brush Settings panel தூரிகை அமைப்புகள் பலகத்தை மாற்று + New Brush Preset புதிய தூரிகை முன்னமைவு + Grid view கட்டக் காட்சி + List view பட்டியல் காட்சி + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink இணைப்பை நீக்கு + Link இணை + Commit transform உருமாற்றத்தை உறுதிசெய் + Cancel transform உருமாற்றத்தை ரத்துசெய் + Scroll menu up பட்டியை மேலே உருட்டு + Scroll menu down பட்டியைக் கீழே உருட்டு + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? தொடர்வதற்கு முன் இந்த உரை அடுக்கு ராஸ்டராக்கப்பட வேண்டும். அதன் உரையை இனி திருத்த முடியாது. உரையை ராஸ்டராக்கவா? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? தொடர்வதற்கு முன் இந்த வடிவ அடுக்கு ராஸ்டராக்கப்பட வேண்டும். இதை இனி வடிவமாகத் திருத்த முடியாது. வடிவத்தை ராஸ்டராக்கவா? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? தொடர்வதற்கு முன் இந்த திறன்மிகு பொருள் ராஸ்டராக்கப்பட வேண்டும். அதன் உள்ளடக்கத்தை இனி திருத்த முடியாது. திறன்மிகு பொருளை ராஸ்டராக்கவா? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? தொடர்வதற்கு முன் இந்த நிரப்பு அடுக்கு ராஸ்டராக்கப்பட வேண்டும். அதன் நிரப்பு உள்ளடக்கத்தை இனி திருத்த முடியாது. அடுக்கை ராஸ்டராக்கவா? + This layer must be rasterized before proceeding. தொடர்வதற்கு முன் இந்த அடுக்கு ராஸ்டராக்கப்பட வேண்டும். + Color Picker (Background Color) நிறத் தேர்வி (பின்னணி நிறம்) + Color Picker (Foreground Color) நிறத் தேர்வி (முன்புற நிறம்) + Adwaita (Light) அத்வைதா (வெளிர்) + Adwaita (Dark) அத்வைதா (கருமை) + Open Recent is unavailable on the web வலையில் சமீபத்தியவற்றைத் திற கிடைக்காது + Graphics adapter: {adapter} வரைகலை அடாப்டர்: {adapter} + Backend: {backend} ({type}) பின்புலம்: {backend} ({type}) + Driver: {driver} இயக்கி: {driver} + GPU backend preference: {backend} GPU பின்புல விருப்பம்: {backend} + Selected at launch: {backend} தொடக்கத்தில் தேர்ந்தெடுத்தது: {backend} + Image compositor: {processor} பட இணைப்பி: {processor} + Fallback: {reason} மாற்று: {reason} + This session: {reason} இந்த அமர்வு: {reason} + Window renderer: {renderer} சாளர ரெண்டரர்: {renderer} + Software adapter மென்பொருள் அடாப்டர் + Graphics adapter வரைகலை அடாப்டர் + unknown type தெரியாத வகை + Platform: {os} {arch} தளம்: {os} {arch} + previous reading kept; the last re-read failed: {reason} முந்தைய அளவீடு தக்கவைக்கப்பட்டது; கடைசி மறுவாசிப்பு தோல்வியடைந்தது: {reason} + Monitor profile: {profile} திரை சுயவிவரம்: {profile} + Display profiles: {reason} காட்சிச் சுயவிவரங்கள்: {reason} + Monitor profile in use on {display}: {profile} {display} இல் பயன்பாட்டில் உள்ள திரை சுயவிவரம்: {profile} + Monitor profile in use: {profile} பயன்பாட்டில் உள்ள திரை சுயவிவரம்: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. படச்சட்டம் sRGB ஆகக் காட்டப்படுகிறது. திருத்து › நிற அமைப்புகளில் திரை சுயவிவரத்தைத் தேர்ந்தெடுக்கவும். + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. {display} இல் உள்ள படச்சட்டம் sRGB ஆகக் காட்டப்படுகிறது. திருத்து › நிற அமைப்புகளில் திரை சுயவிவரத்தைத் தேர்ந்தெடுக்கவும். + Display profile not used காட்சிச் சுயவிவரம் பயன்படுத்தப்படவில்லை + Monitor profile on {display} (main window): {profile} {display} இல் திரை சுயவிவரம் (main window): {profile} + Monitor profile on {display}: {profile} {display} இல் திரை சுயவிவரம்: {profile} + fallback மாற்று + The document does not have an embedded colour profile. Working space: {working}. ஆவணத்தில் உட்பொதிந்த நிறச் சுயவிவரம் இல்லை. பணி வெளி: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} ஆவணத்தில் உள்ள உட்பொதிந்த நிறச் சுயவிவரம் பணி வெளியுடன் பொருந்தவில்லை.\nஉட்பொதிந்தது: {embedded}\nபணி: {working} + Importing {what} from {file}… {file} இலிருந்து {what} ஐ இறக்குமதி செய்கிறது… +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file} இலிருந்து {n} தூரிகை இறக்குமதி செய்யப்பட்டது|{file} இலிருந்து {n} தூரிகைகள் இறக்குமதி செய்யப்பட்டன +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file} இலிருந்து {n} நிறப் படித்திறன் இறக்குமதி செய்யப்பட்டது|{file} இலிருந்து {n} நிறப் படித்திறன்கள் இறக்குமதி செய்யப்பட்டன +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file} இலிருந்து {n} வடிவமைப்பு இறக்குமதி செய்யப்பட்டது|{file} இலிருந்து {n} வடிவமைப்புகள் இறக்குமதி செய்யப்பட்டன + Data Set: {name} ({index}/{total}) தரவுத் தொகுப்பு: {name} ({index}/{total}) + (none) (எதுவுமில்லை) +navigator Zoom பெரிதாக்கல் + Apply {option}: {state} {option} ஐப் பயன்படுத்து: {state} + This adjustment ({key}) is kept as imported and can't be edited yet. இந்தச் சரிசெய்தல் ({key}) இறக்குமதி செய்தபடியே வைக்கப்பட்டுள்ளது; இன்னும் திருத்த முடியாது. + Join the ArtCraft Discord ({url}) ArtCraft Discord-இல் சேர் ({url}) + {index} of {total} {total} இல் {index} + {shortcut} assigned. {shortcut} ஒதுக்கப்பட்டது. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} ஏற்கனவே {commands} ஆல் பயன்படுத்தப்படுகிறது; சரி என்பதைக் கிளிக் செய்தால் அதிலிருந்து நீக்கப்படும். + The current gradient (pick one in Window › Gradients) தற்போதைய நிறப் படித்திறன் (சாளரம் › நிறப் படித்திறன்களில் ஒன்றைத் தேர்ந்தெடுக்கவும்) + auto தானியங்கி + Baseline Shift அடிப்படைக் கோடு மாற்றம் + Breeze Dark பிரீஸ் கருமை + Breeze Light பிரீஸ் வெளிர் + brushes தூரிகைகள் + Color Picker நிறத் தேர்வி + Current தற்போதையது + Dark Gray கருஞ்சாம்பல் + Fill Color நிரப்பு நிறம் + gradients நிறப் படித்திறன்கள் + Graphic and web வரைகலை & வலை + layer mask அடுக்கு மறைப்பு + Lock All அனைத்தையும் பூட்டு + Lock Image Pixels பட பிக்சல்களைப் பூட்டு + Lock Position நிலையைப் பூட்டு + Lock Transparent Pixels ஒளிஊடுருவும் பிக்சல்களைப் பூட்டு + manual கைமுறை + Medium Gray நடுத்தர சாம்பல் + New document புதிய ஆவணம் + New Selection புதிய தேர்வு + No Document ஆவணம் இல்லை + Note tool குறிப்புக் கருவி + off அணை + on இயக்கம் + Recent Files சமீபத்திய கோப்புகள் + Rgb RGB + Shortcut குறுக்குவழி + Show Rulers அளவுகோல்களைக் காண்பி + Solarized Dark சோலரைஸ்டு கருமை + sRGB sRGB + Stroke Color கோடு நிறம் + unknown தெரியாதது + Unsaved Changes சேமிக்கப்படாத மாற்றங்கள் + vector mask திசையன் மறைப்பு + ZigZag ஜிக்ஜாக் + Show home recent files முகப்பில் சமீபத்திய கோப்புகளைக் காண்பி + Clear Brush Controls தூரிகைக் கட்டுப்பாடுகளை அழி + Reset All Locked Settings பூட்டிய அனைத்து அமைப்புகளையும் மீட்டமை + Copy Texture to Other Tools பரப்பமைப்பை மற்ற கருவிகளுக்கு நகலெடு + Load Actions… செயல்களை ஏற்று… + Save Actions… செயல்களைச் சேமி… + New Action… புதிய செயல்… + Start Recording பதிவைத் தொடங்கு + Stop Recording பதிவை நிறுத்து + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} இலிருந்து {n} செயல் ஏற்றப்பட்டது|{file} இலிருந்து {n} செயல்கள் ஏற்றப்பட்டன +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} படிக்கு இன்னும் போட்டோகிராப்ட் சமமானது இல்லை|{n} படிகளுக்கு இன்னும் போட்டோகிராப்ட் சமமானது இல்லை +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file} க்கு {n} செயல் சேமிக்கப்பட்டது|{file} க்கு {n} செயல்கள் சேமிக்கப்பட்டன +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop இணை எதுவும் விடுபடாத {n} படிநிலை|Photoshop இணை எதுவும் விடுபடாத {n} படிநிலைகள் + Starting serial தொடர் எண் தொடக்கம் + Insert Stop… நிறுத்தத்தைச் செருகு… + Save Set… தொகுப்பைச் சேமி… + Message: செய்தி: + Allow Continue தொடர அனுமதி + Show dialog during playback இயக்கத்தின்போது உரையாடலைக் காண்பி + Select an action first முதலில் ஒரு செயலைத் தேர்ந்தெடுக்கவும் + Action playback stopped செயல் இயக்கம் நிறுத்தப்பட்டது + Step {step} is waiting for its dialog படி {step} அதன் உரையாடலுக்காகக் காத்திருக்கிறது +@plural {n} action|{n} actions {n} செயல்|{n} செயல்கள் + Background (Default) பின்னணி (இயல்புநிலை) + Include All Subfolders அனைத்து உட்கோப்புறைகளையும் சேர் + Auto Add/Delete தானாகச் சேர்/நீக்கு + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. அச்சு அமைப்புகளில்… எதிரொளிப் படத்தையும் தேர்ந்தெடுக்க வேண்டாம்: இரண்டு புரட்டல்களும் ஒன்றையொன்று ரத்துசெய்யும். + Print Settings… அச்சு அமைப்புகள்… + Remember to turn off the printer's own colour management in Print Settings… அச்சு அமைப்புகளில் அச்சுப்பொறியின் சொந்த வண்ண மேலாண்மையை அணைக்க நினைவில் கொள்ளவும்… + Sent to {printer} {printer} க்கு அனுப்பப்பட்டது + set in Print Settings… அச்சு அமைப்புகளில் அமைக்கப்பட்டுள்ளது… + ICO is limited to 256 × 256 pixels ICO 256 × 256 பிக்சல்களுக்கு வரையறுக்கப்பட்டுள்ளது + Paints from the History panel's source state வரலாற்றுப் பலகத்தின் மூல நிலையிலிருந்து வண்ணமிடுகிறது + Sets the source for the history brush வரலாற்றுத் தூரிகைக்கான மூலத்தை அமைக்கிறது + Highlight Mode ஒளிர்வு முறை + Highlight Color ஒளிர்வு நிறம் + Highlight Opacity ஒளிர்வு ஒளிபுகாமை + Shadow Mode நிழல் முறை + Shadow Color நிழல் நிறம் + Shadow Opacity நிழல் ஒளிபுகாமை + Use the embedded profile ({profile}) உட்பொதிந்த சுயவிவரத்தைப் பயன்படுத்து ({profile}) + Convert to the working space ({profile}) பணி வெளிக்கு மாற்று ({profile}) + Remember this choice for future images எதிர்காலப் படங்களுக்கு இந்தத் தேர்வை நினைவில் வை + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. திருத்து › நிற அமைப்புகளில் "Ask On Mismatch" மூலம் அறிவுறுத்தலை மீண்டும் இயக்கவும். + Button Mode பொத்தான் முறை + Button Mode On பொத்தான் முறை இயக்கம் + Darken Interface இடைமுகத்தைக் கருமையாக்கு + Lighten Interface இடைமுகத்தை ஒளிரச்செய் + Technique நுட்பம் + Chisel Hard உளி கடினம் + Chisel Soft உளி மென்மை + Open color-less EXR as ACEScg நிறமற்ற EXR ஐ ACEScg ஆகத் திற + Do not append "copy" to filename when saving a copy நகலைச் சேமிக்கும்போது கோப்புப் பெயருடன் "copy" ஐச் சேர்க்க வேண்டாம் + Edit Selected Guides தேர்ந்தெடுத்த அளவுகோட்டு வரிகளைத் திருத்து + Clear Selected Guides தேர்ந்தெடுத்த அளவுகோட்டு வரிகளை அழி + Bicubic Automatic இருபக்கக் கனச்சதுர தானியங்கி + Bicubic Smoother இருபக்கக் கனச்சதுர மென்மை + Bicubic Sharper இருபக்கக் கனச்சதுர கூர்மை + Pasting from other apps is unavailable மற்ற பயன்பாடுகளிலிருந்து ஒட்டுவது கிடைக்கவில்லை + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. இந்த அமர்வில் போட்டோகிராப்டால் கணினி கிளிப்போர்டை அணுக முடியவில்லை. Wayland-இல், Flatpak சாண்ட்பாக்ஸ் மற்ற பயன்பாடுகளிலிருந்து படங்களைப் படிக்கும் நெறிமுறையை மறைக்கிறது. + To paste from other apps, start PhotoCraft under XWayland: `{command}` மற்ற பயன்பாடுகளிலிருந்து ஒட்ட, போட்டோகிராப்டை XWayland-இல் தொடங்கவும்: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here ஒட்ட எதுவும் இல்லை: இங்கு போட்டோகிராப்டால் கணினி கிளிப்போர்டைப் படிக்க முடியவில்லை + Delete this set இந்தத் தொகுப்பை நீக்கு + New Set… புதிய தொகுப்பு… + PhotoCraft Defaults போட்டோகிராப்ட் இயல்புநிலைகள் + Save a list of these shortcuts as a web page இந்தக் குறுக்குவழிகளின் பட்டியலை வலைப்பக்கமாகச் சேமி + Save the changes to this set இந்தத் தொகுப்பின் மாற்றங்களைச் சேமி + Save these shortcuts as a .kys file இந்தக் குறுக்குவழிகளை .kys கோப்பாகச் சேமி + Save these shortcuts as a new set இந்தக் குறுக்குவழிகளை புதிய தொகுப்பாகச் சேமி + Set deleted. தொகுப்பு நீக்கப்பட்டது. + Set saved. தொகுப்பு சேமிக்கப்பட்டது. + Set: தொகுப்பு: + Summarize… சுருக்கமாகக் காண்பி… + There are too many sets: delete one first. மிக அதிகமான தொகுப்புகள் உள்ளன: முதலில் ஒன்றை நீக்கவும். + set name தொகுப்பின் பெயர் + {set} (modified) {set} (மாற்றப்பட்டது) + Show file name in title தலைப்பில் கோப்புப் பெயரைக் காண்பி + 50% Threshold 50% வாசல் மதிப்பு + Pattern Dither வடிவ நிறக் கலப்பு + Diffusion Dither பரவல் நிறக் கலப்பு + Halftone Screen அரைத்தொனித் திரை + Custom Pattern தனிப்பயன் வடிவமைப்பு + Frequency: அதிர்வெண்: + Lines/Inch கோடுகள்/அங்குலம் + Lines/Centimeter கோடுகள்/சென்டிமீட்டர் + Ellipse நீள்வட்டம் + Accept and Go to Conflict ஏற்று, முரண்பாட்டுக்குச் செல் + Changes undone. மாற்றங்கள் செயல்தவிர்க்கப்பட்டன. + Choose a new shortcut for this command, or leave it without one. இந்தக் கட்டளைக்குப் புதிய குறுக்குவழியைத் தேர்ந்தெடுக்கவும், அல்லது ஒன்றும் இல்லாமல் விடவும். + Put this command's shortcuts back as they were இந்தக் கட்டளையின் குறுக்குவழிகளை முன்பிருந்தபடி மீட்டமை + Take the shortcut from the other command and select it மற்றக் கட்டளையிலிருந்து குறுக்குவழியை எடுத்து அதைத் தேர்ந்தெடு + Undo Changes மாற்றங்களைச் செயல்தவிர் + {shortcut} is reserved by the system and can't be assigned. {shortcut} கணினியால் ஒதுக்கப்பட்டுள்ளது; ஒதுக்க முடியாது. + Low-Latency Drawing (vsync off, applies at next launch) குறைந்த தாமத வரைதல் (vsync அணை, அடுத்த தொடக்கத்தில் பொருந்தும்) + Free Transform is still being applied; try again when it has finished. தன்னிச்சை உருமாற்றம் இன்னும் பயன்படுத்தப்படுகிறது; முடிந்ததும் மீண்டும் முயலவும். + The transform couldn't be applied. உருமாற்றத்தைப் பயன்படுத்த முடியவில்லை. + Its layers need {needed} of GPU memory; the budget leaves them {budget}. அதன் அடுக்குகளுக்கு {needed} GPU நினைவகம் தேவை; ஒதுக்கீடு அவற்றுக்கு {budget} மட்டுமே விடுகிறது. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. விருப்பத்தேர்வுகள் › செயல்திறனில் வரைகலை செயலி நினைவகத்தை உங்கள் வரைகலை அட்டை தாங்கக்கூடிய அளவுக்கு, அதிகபட்சம் அதன் நினைவகத்தின் 3/8, என அமைக்கவும். தானியங்கி முறை நினைவகப் பயன்பாட்டைப் பின்பற்றும்; இந்தக் கணினியின் நினைவகத்தின் கால் பங்கு மற்றும் அட்டையின் 3/8 க்குள், நினைவகம் அறியப்பட்ட அட்டைக்கு எதிராக ஆவணத்தைக் கணக்கிடாது. அவை பொருந்தாதபோது நிலைப் பட்டை "வரைகலை செயலி நினைவகம் நிரம்பியது" எனக் காட்டும். + GPU Memory Full GPU நினைவகம் நிரம்பியது + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. அடுக்குகளுக்கு {needed} வரைகலை செயலி நினைவகம் தேவை, ஆனால் {budget} மட்டுமே உள்ளது; எனவே முழு ஆவணத்தையும் மறுவரையும் திருத்தங்கள் கணினிச் செயலியில் மூலம் செய்யப்படும். வரைகலை செயலி நினைவகத்தை அமைக்கக் கிளிக் செய்யவும். + GPU memory budget: {budget} வரைகலை செயலி நினைவக ஒதுக்கீடு: {budget} + Graphics memory: {size} வரைகலை நினைவகம்: {size} + This document's layers: {needed} of {budget} for layers இந்த ஆவணத்தின் அடுக்குகள்: அடுக்குகளுக்கான {budget} இல் {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU இந்த ஆவணத்தின் அடுக்குகளுக்கு {needed} தேவை, ஆனால் {budget} மட்டுமே வைத்திருக்க முடியும்: முழு ஆவண மறுவரைவுகள் கணினிச் செயலியைப் பயன்படுத்துகின்றன + GPU Memory (MB, 0 = Automatic) வரைகலை செயலி நினைவகம் (எம்பி, 0 = தானியங்கி) + {path}: the path is not valid Unicode; save under another name. {path}: பாதை சரியான யூனிகோட் அல்ல; வேறு பெயரில் சேமிக்கவும். + {name} was changed by another app after it was opened or saved here. இங்கு திறந்த அல்லது சேமித்த பிறகு {name} மற்றொரு பயன்பாட்டால் மாற்றப்பட்டுள்ளது. + Replace it with this document, or cancel and use File › Save As to keep both. இந்த ஆவணத்தால் அதை மாற்றவும், அல்லது ரத்துசெய்து இரண்டையும் வைத்திருக்க கோப்பு › இவ்வாறு சேமி என்பதைப் பயன்படுத்தவும். + Decrease Brush Size தூரிகை அளவைக் குறை + Increase Brush Size தூரிகை அளவை அதிகரி + Decrease Brush Hardness தூரிகை கடினத்தன்மையைக் குறை + Increase Brush Hardness தூரிகை கடினத்தன்மையை அதிகரி + Demosaic டிமொசைக் + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. கேமராவின் நிற வடிகட்டி மொசைக் எவ்வாறு நிரப்பப்படுகிறது. கோப்பு திறக்கும்போது இது பயன்படுத்தப்படும்; முன்னோட்டம் அது திறக்கப்பட்ட முறையைக் காட்டும். + AHD (adaptive homogeneity) AHD (தகவமைவு ஒருபடித்தன்மை) + Malvar–He–Cutler மல்வார்–ஹே–கட்லர் + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. ஹிரகாவா-பார்க்ஸ்: மிகத் தூய்மையான விளிம்புகள், மிகக் குறைந்த நிற ஓரங்கள், மிக மெதுவானது. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. நிறப் படித்திறன்-திருத்திய இடைக்கணிப்பு: கூர்மையும் வேகமும், நுண் விவரங்களில் சிறிது நிற ஓரங்களுடன். + The fastest and the softest, with colour fringing on fine detail. மிக வேகமானது, மிக மென்மையானது, நுண் விவரங்களில் நிற ஓரங்களுடன். + “{name}” already exists. Replace it? “{name}” ஏற்கனவே உள்ளது. அதை மாற்றவா? + The name in the save dialog was “{name}”. சேமிப்பு உரையாடலில் உள்ள பெயர் “{name}”. + Move Up மேலே நகர்த்து + Move Down கீழே நகர்த்து + Previous Brush முந்தைய தூரிகை + Next Brush அடுத்த தூரிகை + First Brush முதல் தூரிகை + Last Brush கடைசி தூரிகை + Preview size முன்னோட்ட அளவு + Memory Usage நினைவகப் பயன்பாடு + Per-document RAM limit (MB) ஆவணத்துக்கான நினைவக வரம்பு (எம்பி) + Installed RAM: {ram} MB; this limit: {percent}% பொருத்தப்பட்டுள்ள நினைவகம்: {ram} எம்பி; இந்த வரம்பு: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. ஒவ்வொரு திறந்த ஆவணத்தின் பிக்சல்களையும் வரலாற்றையும் வரம்பிடுகிறது. வரம்பு மீறும்போது பழைய செயல்தவிர் நிலைகள் நிராகரிக்கப்படும். + Applies immediately when saved. Memory is not reserved in advance. சேமித்ததும் உடனே பொருந்தும். நினைவகம் முன்கூட்டியே ஒதுக்கப்படுவதில்லை. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU நினைவகம் தானியங்கி என அமைக்கப்பட்டிருந்தால், இது GPU அடுக்கு ஒதுக்கீட்டையும் வரம்பிடுகிறது. அந்த ஒதுக்கீடு நிறுவிய RAM இன் கால் பங்கு, மற்றும் அறியப்பட்டால் வரைகலை அட்டை நினைவகத்தின் மூன்றில் எட்டு பங்கு, குறைந்தபட்சம் 512 MB என வரம்பிடப்படுகிறது. இந்த வரம்பை அவற்றைத் தாண்டி உயர்த்தினாலும் GPU ஒதுக்கீடு அதிகரிக்காது. + Use பயன்படுத்து + In Front முன்னால் + Behind பின்னால் + DDS Options DDS விருப்பங்கள் + Generate Mipmaps Mipmap-களை உருவாக்கு + There is no color table to save. சேமிக்க நிற அட்டவணை இல்லை. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file} க்கு {n} நிறம் சேமிக்கப்பட்டது|{file} க்கு {n} நிறங்கள் சேமிக்கப்பட்டன + GPU adapter வரைகலைச் செயலி பொருத்தி + Confirm or cancel the open action step dialog first முதலில் திறந்துள்ள செயல் படி உரையாடலை உறுதிசெய்யவும் அல்லது ரத்துசெய்யவும் + Pro Medium Gray புரோ நடுத்தர சாம்பல் + Solarized கதிர்மாற்றம் + Breeze பிரீஸ் + Pro Light Gray புரோ வெளிர் சாம்பல் + Pro Light புரோ வெளிர் + Resize Windows to Fit சாளரங்களைப் பொருத்த அளவு மாற்று + EXR EXR + Automatic paragraph direction தானியங்கிப் பத்தித் திசை + Left-to-right paragraph direction இடமிருந்து வலமாக பத்தித் திசை + Right-to-left paragraph direction வலமிருந்து இடமாக பத்தித் திசை + Not rendered: this adjustment ({key}) is kept as imported. ரெண்டர் செய்யப்படவில்லை: இந்தச் சரிசெய்தல் ({key}) இறக்குமதி செய்தபடியே வைக்கப்பட்டுள்ளது. + Print preview image அச்சு முன்னோட்டப் படம் + +# Strings main added after this catalog was written (integration batch 20). + Background (default) பின்னணி (இயல்புநிலை) + Transparent (default) ஒளிஊடுருவும் (இயல்புநிலை) + The path was kept as the Work Path பாதை பணிப் பாதையாக வைக்கப்பட்டது + The path was discarded பாதை நிராகரிக்கப்பட்டது diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a67be38ff5..36c0c6e3ae 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -7,6 +7,17 @@ const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered"); const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered"); const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered"); const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered"); +const KA: fn() -> Lang = || Lang::from_code("ka").expect("ka registered"); + +#[test] +fn hindi_has_the_reveal_and_camera_raw_rows() { + assert_eq!(tr(HI(), "Reveal in Explorer"), "Explorer में दिखाएँ"); + assert_eq!(tr(HI(), "Reveal in Finder"), "Finder में दिखाएँ"); + assert_eq!(tr(HI(), "Camera Raw…"), "Camera Raw…"); + let (rows, errors) = parse_entries(include_str!("hi.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + assert!(rows.iter().any(|(ctx, src, _)| ctx.is_empty() && src == "Camera Raw…"), "Camera Raw… is a catalog row, not a fallback"); +} #[test] fn hindi_tags_resolve() { @@ -18,6 +29,20 @@ fn hindi_tags_resolve() { assert_eq!(Lang::from_pref("HI"), HI()); } +#[test] +fn georgian_tags_resolve() { + for tag in ["ka", "ka-GE", "ka_GE", "ka_GE.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(KA()), "{tag}"); + } + assert_eq!(KA().name(), "ქართული"); + assert_eq!(Lang::from_pref("ka"), KA()); + assert_eq!(Lang::from_pref("KA"), KA()); + assert_eq!(tr(KA(), "Layer"), "შრე"); + assert_eq!(tr(KA(), "File"), "ფაილი"); + assert_eq!(trn(KA(), 1, "{n} layer", "{n} layers"), "შრე: 1"); + assert_eq!(trn(KA(), 3, "{n} layer", "{n} layers"), "შრე: 3"); +} + #[test] fn indonesian_tags_resolve() { for tag in ["id", "id-ID", "id_ID", "id_ID.UTF-8"] { @@ -36,6 +61,34 @@ fn vietnamese_tags_resolve() { assert_eq!(Lang::from_pref("VI"), VI()); } +#[test] +fn thai_tags_resolve() { + let th = Lang::from_code("th").expect("th registered"); + for tag in ["th", "TH", "th-TH", "th_TH", "th_TH.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(th), "{tag}"); + assert_eq!(Lang::from_pref(tag), th, "{tag}"); + } + assert_eq!(th.name(), "ไทย"); + assert_eq!((th.0.plural)(5), 0, "Thai has one plural form"); + assert_eq!(tr(th, "File"), "ไฟล์"); + assert_eq!(tr(th, "Layer"), "เลเยอร์"); +} + +/// Tamil: regional tags resolve, counts take one|other, and placeholders stay untranslated. +#[test] +fn tamil_tags_plurals_and_placeholders() { + let ta = Lang::from_code("ta").expect("ta registered"); + for tag in ["ta", "ta-IN", "ta_LK", "ta_IN.UTF-8"] { + assert_eq!(Lang::from_pref(tag), ta, "{tag}"); + } + let forms: Vec = [0, 1, 2, 21].into_iter().map(ta.0.plural).collect(); + assert_eq!(forms, [1, 0, 1, 1]); + assert_eq!(trn(ta, 1, "{n} layer", "{n} layers"), "1 அடுக்கு"); + assert_eq!(trn(ta, 3, "{n} layer", "{n} layers"), "3 அடுக்குகள்"); + assert!(tr(ta, "Scratch: {size}").contains("{size}")); + assert_ne!(tr(ta, "Zoom In (hold, drag to scrub)"), "Zoom In (hold, drag to scrub)"); +} + #[test] fn picker_lists_english_first_then_names_alphabetically() { let order = Lang::for_picker(); @@ -229,6 +282,16 @@ fn lookups_fall_back_to_english() { assert_eq!(trn(JA(), 3, "{n} no such message", "{n} no such messages"), "3 no such messages"); } +/// Romanian: 1 is one; 0 and counts ending in 01–19 are few ("2 straturi", "119 straturi"); +/// the rest take "de" ("20 de straturi", "121 de straturi"). +#[test] +fn romanian_plural_rule() { + let ro = Lang::from_code("ro").expect("ro registered"); + let counts = [0, 1, 2, 19, 20, 21, 100, 101, 119, 120, 121, 1001]; + let forms: Vec = counts.into_iter().map(ro.0.plural).collect(); + assert_eq!(forms, [1, 0, 1, 1, 2, 2, 2, 1, 1, 2, 2, 1]); +} + #[test] fn russian_plural_rule() { let ru = Lang::from_code("ru").expect("ru registered"); @@ -292,6 +355,67 @@ fn serbian_plural_rule_handles_teens_and_compound_counts() { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]); } +/// The catalog resolves regional tags, exposes its native name and falls back to English. +#[test] +fn azerbaijani_resolves_locales_and_preferences() { + let lang = Lang::from_code("az").expect("az registered"); + assert_eq!(lang.name(), "Azərbaycanca"); + assert_eq!(lang.dir(), Dir::Ltr); + assert!(lang.complete_menus()); + for tag in ["az", "AZ", "az-AZ", "az_AZ.UTF-8", "az-Latn", "AZ-LATN-AZ"] { + assert_eq!(lang_from_tag(tag), Some(lang), "{tag}"); + assert_eq!(Lang::from_pref(tag), lang, "{tag}"); + } + assert_eq!(tr(lang, "unknown translation"), "unknown translation"); +} + +/// Integer plural boundaries select one only at 1, including large counts without overflow. +#[test] +fn azerbaijani_plural_counts_include_hundreds_and_large_values() { + let az = Lang::from_code("az").expect("az registered"); + let counts = [0, 1, 2, 11, 19, 20, 21, 99, 100, 101, 102, 119, 120, 1000, 1001, u64::MAX]; + assert_eq!(counts.map(az.0.plural), [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); + for n in counts { + assert_eq!(trn(az, n, "{n} layer", "{n} layers"), format!("{n} qat")); + } +} + +/// Exact lookups retain contextual meanings, dotted/dotless letters and product placeholders. +#[test] +fn azerbaijani_keeps_context_placeholders_and_command_names() { + let lang = Lang::from_code("az").expect("az registered"); + for (source, translated) in [("File", "Fayl"), ("Layer", "Qat"), ("Kind", "Növ"), ("Type Layer", "Mətn qatı"), ("Light Theme", "Açıq mövzu")] { + assert_eq!(tr(lang, source), translated); + } + assert_eq!(tr_ctx(lang, "cameraRaw", "Light"), "İşıq"); + assert_eq!(tr_ctx(lang, "fontWeight", "Light"), "İncə"); + assert_ne!(tr_id(lang, "edit.clear", "Clear"), "Clear"); + assert!(fmt(tr(lang, "About {app}"), &[("app", "PhotoCraft")]).contains("PhotoCraft")); +} + +/// Bosnian locales, with or without region, script or encoding, select the Latin catalog. +#[test] +fn bosnian_resolves_locales_and_preferences() { + let bs = Lang::from_code("bs").expect("bs registered"); + assert_eq!(bs.name(), "Bosanski"); + for tag in ["bs", "BS", "bs-BA", "bs_BA", "bs_BA.UTF-8", "bs-Latn", "bs-Latn-BA", "bs-Cyrl-BA", "bs_BA@cyrillic"] { + assert_eq!(lang_from_tag(tag), Some(bs), "{tag}"); + assert_eq!(Lang::from_pref(tag), bs, "{tag}"); + } + assert_ne!(lang_from_tag("hr-HR"), Some(bs), "Croatian is not Bosnian"); +} + +/// Bosnian counts: 1, 21, 101 are `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`. +#[test] +fn bosnian_plural_rule_handles_teens_and_compound_counts() { + let bs = Lang::from_code("bs").expect("bs registered"); + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112, 122]; + let forms: Vec = counts.into_iter().map(bs.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]); + assert_eq!(trn(bs, 3, "{n} pixel", "{n} pixels"), "3 piksela"); + assert_eq!(trn(bs, 5, "{n} document", "{n} documents"), "5 dokumenata"); +} + #[test] fn catalog_kinds_are_parsed_and_looked_up() { let c = Catalog::parse("# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n"); diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv new file mode 100644 index 0000000000..ef34287b6b --- /dev/null +++ b/crates/ui-egui/src/i18n/th.tsv @@ -0,0 +1,4775 @@ +# Thai (th) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: translated from the plain meaning of the English labels using ordinary Thai +# computing and image-editing vocabulary (เลเยอร์, มาสก์, ฟิลเตอร์, การเลือก ...). No proprietary +# localisation resource, string table or product UI was consulted or copied; add entries the same +# way. Product and technology names (PhotoCraft, ArtCraft, OpenType, RGB, CMYK, Lab, PSD, PNG, +# JPEG, WebP, OpenEXR, AVIF ...) stay in Latin letters; the trailing "…" of dialog commands is kept. +# Thai has no plural inflection, so plural entries use one form (`plural_none` in i18n.rs). +# Machine-assisted first draft, reviewed for UI terminology; native-speaker review is welcome. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… ตัวเลือกมาสก์… + Layer Mask Display Options… ตัวเลือกการแสดงเลเยอร์มาสก์… + +# Channel Options and New Spot Channel (#3761) + Channel Color สีของช่อง + Color Indicates: สีบ่งบอกถึง: + Solidity: ความแข็งแกร่ง: + Ink Characteristics ลักษณะหมึก + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width ความกว้างของกระดาษ + Paper Height ความสูงของกระดาษ + Rows แถว + Number หมายเลข + Margin ขอบ + Top: ด้านบน: + Left: ซ้าย: + Bottom: ก้น: + Right: ขวา: + Target: เป้าหมาย: + Selected Artboards อาร์ตบอร์ดที่เลือก + Center Columns คอลัมน์กลาง + Clear Existing Guides ล้างคำแนะนำที่มีอยู่ + Invalid guide layout รูปแบบคำแนะนำไม่ถูกต้อง + Leave empty for automatic size เว้นว่างไว้สำหรับขนาดอัตโนมัติ + 5 Columns, 3 Rows 5 คอลัมน์ 3 แถว + 8 Columns 8 คอลัมน์ + 12 Columns 12 คอลัมน์ + + File ไฟล์ + Edit แก้ไข + Image ภาพ + Layer เลเยอร์ + Type ตัวอักษร + Select เลือก + Selected เลือกแล้ว + Scope ขอบเขต + Top Left ซ้ายบน + Top Right ขวาบน + Bottom Left ล่างซ้าย + Bottom Right ล่างขวา + Filter ฟิลเตอร์ + View มุมมอง + Window หน้าต่าง + Help ช่วยเหลือ + +# File + New… ใหม่… + New from Clipboard ใหม่จากคลิปบอร์ด + New ใหม่ + Open… เปิด… + Open เปิด + Open As… เปิดเป็น… + Open EXR Parts… เปิดอะไหล่ EXR… + Add beauty precomp from the AOVs เพิ่มความสวยงามพรีคอมพ์จาก AOVs + Both ทั้งสองอย่าง + Left ซ้าย + Right ถูกต้อง + Open Recent เปิดล่าสุด + Clear Recent Files ล้างไฟล์ล่าสุด + Remove from Recent ลบออกจากล่าสุด + Remove {name} from Recent ลบ {name} ออกจากล่าสุด + Close ปิด + Minimize ย่อเล็กสุด + Maximize ขยายใหญ่สุด + Restore คืนค่า + Close All ปิดทั้งหมด + Close Others ปิดเอกสารอื่น + Save บันทึก + Save As… บันทึกเป็น… + Save a Copy… บันทึกสำเนา… + {name} can't keep everything in this document {name} ไม่สามารถเก็บทุกอย่างไว้ในเอกสารนี้ได้ + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. เลือกรูปแบบที่จะเก็บไว้ เช่น Photoshop (PSD) หรือบันทึกสำเนา + Save… บันทึก… + Revert ย้อนกลับ + Import นำเข้า + Import Presets นำเข้าค่าที่ตั้งล่วงหน้า + Export As… ส่งออกเป็น… + Export ส่งออก + Export… ส่งออก… + Quick Export As PNG ส่งออกอย่างรวดเร็วเป็น PNG + Quick Export as PNG ส่งออกเป็น PNG อย่างรวดเร็ว + Place Embedded… วางที่ฝัง… + Place Linked… สถานที่ที่เชื่อมโยง… + Package… แพ็คเกจ… + Save for Web (Legacy)… บันทึกสำหรับเว็บ (ดั้งเดิม)… + Generate สร้าง + Image Assets เนื้อหาภาพ + +# Edit + Undo เลิกทำ + Redo ทำซ้ำ + Fade… จางลง… + Cut ตัด + Copy คัดลอก + Copy Merged คัดลอกรวม + Paste วาง + Paste Special วางแบบพิเศษ + Paste Into วางลงใน + Paste Outside วางด้านนอก + Paste in Place วางในตำแหน่งเดิม + Clear ล้าง + Search ค้นหา + Search… ค้นหา… + Check Spelling… ตรวจสอบการสะกด… + Fill… เติม… + Stroke… เส้นขอบ… + Content-Aware Fill… เติมอย่างชาญฉลาด… + Delete and Fill Selection ลบและเติมส่วนที่เลือก + Define Brush Preset… กำหนดพรีเซ็ตแปรง… + Define Pattern… กำหนดลวดลาย… + Define Custom Shape… กำหนดรูปร่างที่กำหนดเอง… + Purge ล้างข้อมูล + +# Image + Image Size… ขนาดภาพ… + Image Size ขนาดภาพ + Canvas Size… ขนาดผ้าใบ… + Image Rotation การหมุนภาพ + Rotate 90° Clockwise หมุน 90° ตามเข็มนาฬิกา + Rotate 90° Counter Clockwise หมุน 90° ทวนเข็มนาฬิกา + Rotate 180° หมุน 180° + Flip Canvas Horizontal พลิกผ้าใบแนวนอน + Flip Canvas Vertical พลิกผ้าใบแนวตั้ง + Crop ครอป + Crop and Straighten Photos ครอบตัดและปรับภาพให้ตรง + Trim… ตัดขอบ… + Reveal All แสดงทั้งหมด + Hide Selection ซ่อนการเลือก + Auto Tone โทนอัตโนมัติ + Auto Contrast คอนทราสต์อัตโนมัติ + Auto Color สีอัตโนมัติ + Adjustments การปรับแต่ง + 16 Bits/Channel 16 บิต/ช่อง + 32 Bits/Channel 32 บิต/ช่อง + 8 Bits/Channel 8 บิต/ช่อง + 8 bit 8 บิต + Integer จำนวนเต็ม + 16 bit 16 บิต + 32 bit (float) 32 บิต (โฟลต) + Floating point จุดลอยตัว + Mode โหมด + Grayscale โทนสีเทา + RGB Color สี RGB + CMYK Color สี CMYK + Lab Color สี Lab + Indexed Color สีแบบจัดดัชนี + Multichannel หลายช่องสัญญาณ + Bitmap… บิตแมป… + Bitmap บิตแมป + Duotone… ดูโอโทน… + Duotone ดูโอโทน + Trap… กับดัก… + +# Layer + Layer via Copy เลเยอร์ผ่านการคัดลอก + Layer via Cut เลเยอร์ผ่านการตัด + Duplicate Layer… ทำซ้ำเลเยอร์… + Duplicate… ทำสำเนา… + Delete ลบ + Delete Layer ลบเลเยอร์ + Delete Layers ลบเลเยอร์ + Rename Layer เปลี่ยนชื่อเลเยอร์ + Layer From Background… เลเยอร์จากพื้นหลัง… + Layer from Background… เลเยอร์จากพื้นหลัง… + Background from Layer พื้นหลังจากเลเยอร์ + Background From Layer พื้นหลังจากเลเยอร์ + Group Layers จัดกลุ่มเลเยอร์ + Group… กลุ่ม… + Ungroup Layers แยกกลุ่มเลเยอร์ + Group from Layers… จัดกลุ่มจากเลเยอร์… + New Group from Layers… กลุ่มใหม่จากเลเยอร์… + Hide Layers ซ่อนเลเยอร์ + Arrange จัดเรียง + Bring to Front นำมาไว้ข้างหน้า + Bring Forward นำไปข้างหน้า + Send Backward ส่งกลับ + Send to Back ส่งกลับ + Convert to Smart Object แปลงเป็นวัตถุอัจฉริยะ + Convert for Smart Filters แปลงสำหรับสมาร์ตฟิลเตอร์ + New Smart Object via Copy สมาร์ตออบเจกต์ใหม่ผ่านการคัดลอก + Edit Contents แก้ไขเนื้อหา + Replace Contents… แทนที่เนื้อหา… + Export Contents… ส่งออกเนื้อหา… + Rasterize แรสเตอร์ไรซ์ + Rasterize Type แรสเตอร์ไรซ์ตัวอักษร + Rasterize Shape แปลงรูปร่างเป็นแรสเตอร์ + Rasterize Layer แรสเตอร์เลเยอร์ + Rasterize Layers แรสเตอร์เลเยอร์ + New Adjustment Layer เลเยอร์ปรับแต่งใหม่ + New Fill Layer เลเยอร์เติมสีใหม่ + Layer Mask มาสก์เลเยอร์ + Add Layer Mask เพิ่มเลเยอร์มาสก์ + Delete Layer Mask ลบเลเยอร์มาสก์ + Enable Layer Mask เปิดใช้งานเลเยอร์มาสก์ + Disable Layer Mask ปิดการใช้งานเลเยอร์มาสก์ + Apply Layer Mask ใช้เลเยอร์มาสก์ + Add Mask to Selection เพิ่มมาสก์ลงในส่วนที่เลือก + Subtract Mask from Selection ลบมาสก์ออกจากส่วนที่เลือก + Intersect Mask with Selection ตัดกันมาสก์กับส่วนที่เลือก + Link Layer Mask ลิงค์เลเยอร์มาสก์ + Unlink Layer Mask ยกเลิกการเชื่อมโยงเลเยอร์มาสก์ + Vector Mask มาสก์เวกเตอร์ + Add Vector Mask เพิ่มเวกเตอร์มาสก์ + Delete Vector Mask ลบเวกเตอร์มาสก์ + Enable Vector Mask เปิดใช้งานเวกเตอร์มาสก์ + Link Vector Mask ลิงค์เว็กเตอร์มาสก์ + Create Clipping Mask สร้างมาสก์ตัด + Release Clipping Mask ปล่อยมาสก์ตัด + New Shape Layer เลเยอร์รูปร่างใหม่ + New Type Layer เลเยอร์ตัวอักษรใหม่ + New Video Layer from File… เลเยอร์วิดีโอใหม่จากไฟล์… + New Blank Video Layer เลเยอร์วิดีโอเปล่าใหม่ + Smart Filters ฟิลเตอร์อัจฉริยะ + Delete Smart Filters ลบฟิลเตอร์อัจฉริยะ + Enable Smart Filters เปิดใช้งานฟิลเตอร์อัจฉริยะ + Disable Smart Filters ปิดการใช้งานฟิลเตอร์อัจฉริยะ + Clear Smart Filters ล้างฟิลเตอร์อัจฉริยะ + Delete Filter Mask ลบมาสก์ฟิลเตอร์ + Disable Filter Mask ปิดการใช้งานฟิลเตอร์มาสก์ + New Layer Based Slice สไลซ์ใหม่ที่อ้างอิงเลเยอร์ + Layer Style สไตล์เลเยอร์ + Blending Options… ตัวเลือกการผสม… + Blending Options ตัวเลือกการผสม + Drop Shadow… วางเงา… + Inner Shadow… เงาภายใน… + Outer Glow… ภายนอกเรืองแสง… + Inner Glow… เปล่งประกายภายใน… + Bevel & Emboss… เอียงและนูน… + Use Global Light ใช้แสงสากล + Overprint พิมพ์ทับ + Satin… ผ้าซาติน… + Color Overlay… การซ้อนทับสี… + Gradient Overlay… การซ้อนทับแบบไล่ระดับสี… + Pattern Overlay… การซ้อนทับรูปแบบ… + Bevel & Emboss เอียงและนูน + Inner Shadow เงาภายใน + Inner Glow เรืองแสงภายใน + Satin ผ้าซาติน + Color Overlay การซ้อนทับสี + Gradient Overlay การซ้อนทับแบบไล่ระดับสี + Pattern Overlay การซ้อนทับรูปแบบ + Outer Glow เรืองแสงด้านนอก + Drop Shadow วางเงา + Copy Layer Style คัดลอกสไตล์เลเยอร์ + Paste Layer Style วางสไตล์เลเยอร์ + Clear Layer Style ล้างสไตล์เลเยอร์ + Layer Content Options… ตัวเลือกเนื้อหาเลเยอร์… + Scale Effects… สเกลเอฟเฟกต์… + New Layer… เลเยอร์ใหม่… + Delete Layer… ลบเลเยอร์… + +# Type + Font แบบอักษร + Font Preview Size ขนาดตัวอย่างแบบอักษร + Orientation ปฐมนิเทศ + Horizontal แนวนอน + Vertical แนวตั้ง + Rasterize Type Layer แรสเตอร์ไรซ์เลเยอร์ตัวอักษร + Create Work Path สร้างเส้นทางการทำงาน + Convert to Shape แปลงเป็นรูปร่าง + Convert to Paragraph Text แปลงเป็นข้อความย่อหน้า + Convert to Point Text แปลงเป็นจุดข้อความ + Warp Text… ข้อความวาร์ป… + Character Panel แผงตัวละคร + Paragraph Panel แผงย่อหน้า + Check Spelling ตรวจสอบการสะกด + Language Options ตัวเลือกภาษา + Find and Replace Text… ค้นหาและแทนที่ข้อความ… + Add to Dictionary เพิ่มลงในพจนานุกรม + No Spelling Suggestions ไม่มีคำแนะนำการสะกดคำ + Paste Lorem Ipsum วาง Lorem Ipsum + Glyphs Panel แผงร่ายมนตร์ + Type Styles สไตล์ตัวอักษร + Character Styles ลักษณะตัวละคร + Paragraph Styles ลักษณะย่อหน้า + Load Default Type Styles โหลดสไตล์ตัวอักษรเริ่มต้น + Save Default Type Styles บันทึกสไตล์ตัวอักษรเริ่มต้น + Type… ตัวอักษร… + +# Select + All ทั้งหมด + Deselect ยกเลิกการเลือก + Reselect เลือกใหม่ + Inverse Selection การเลือกแบบผกผัน + Inverse กลับด้าน + Color Range… ช่วงสี… + Select and Mask… เลือกและมาสก์… +# Select and Mask dialog labels + Smart Radius รัศมีอัจฉริยะ + Shift Edge เลื่อนขอบ + Decontaminate ฆ่าเชื้อ + Output เอาท์พุต + Refine Edge ปรับแต่งขอบ + Select Subject เลือกตัวแบบ + Sky ท้องฟ้า + Focus Area… พื้นที่โฟกัส… + Subject เรื่อง + Color Range ช่วงสี + Similar คล้ายกัน + Grow ขยาย + Select Similar Colors เลือกสีที่คล้ายกัน + Load Selection… โหลดการเลือก… + Save Selection… บันทึกการเลือก… + New Selection การคัดเลือกใหม่ + Select All เลือกทั้งหมด + Deselect All ยกเลิกการเลือกทั้งหมด + Deselect Layers ยกเลิกการเลือกเลเยอร์ + Find Layers ค้นหาเลเยอร์ + Isolate Layers แยกเลเยอร์ + Select Linked Layers เลือกเลเยอร์ที่เชื่อมโยง + All Layers ทุกเลเยอร์ + Current & Below ปัจจุบันและต่ำกว่า + Current Layer เลเยอร์ปัจจุบัน + Refine Strokes ปรับแต่งจังหวะ + Edits แก้ไข + +# Filter + Last Filter ฟิลเตอร์ล่าสุด + Filter Gallery… แกลเลอรีฟิลเตอร์… + Liquify… ทำให้เหลว… + Camera Raw Filter… ฟิลเตอร์ Camera Raw… + Adaptive Wide Angle… มุมกว้างแบบปรับได้… + Lens Correction… การแก้ไขเลนส์… + Vanishing Point… จุดลับสายตา… + Oil Paint… สีน้ำมัน… + Blur Gallery แกลเลอรี่เบลอ + Field Blur… ฟิลด์เบลอ… +# Field Blur pins (#3373) + No pin selected ไม่ได้เลือกพิน + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. คลิกที่ภาพเพื่อเพิ่มหมุด ลากหมุดเพื่อย้าย หรือลากวงแหวนเพื่อเปลี่ยนความเบลอ ลบ ลบพินที่เลือก + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. ลากขอบจับบนโครงร่างเพื่อปรับขนาดและหมุน ลากสี่เหลี่ยมเพื่อปัดเศษมุม และลากจุดด้านในเพื่อกำหนดจุดเริ่มต้นของการเบลอ + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. ลากหมุดเพื่อย้ายสายรัด หรือลากวงแหวนเพื่อเปลี่ยนความเบลอ ลากเส้นทึบเพื่อขยายแถบคม เส้นประเพื่อกำหนดการเปลี่ยนภาพ และลากจุดเพื่อหมุน + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. ลากขอบจับบนโครงร่างเพื่อปรับขนาดและหมุน วงแหวนจะกำหนดมุมเบลอ + Blur Angle มุมเบลอ + Iris Blur… ไอริสเบลอ… + Tilt-Shift… เอียง-Shift… + Path Blur… เส้นทางเบลอ… + Spin Blur… หมุนเบลอ… + Auto-Align Layers… ปรับแนวเลเยอร์อัตโนมัติ… + Auto-Blend Layers… ผสมผสานเลเยอร์อัตโนมัติ… + Photomerge… รวมภาพ… + Content-Aware Scale ปรับขนาดอย่างชาญฉลาด + Adaptive Wide Angle มุมกว้างแบบปรับได้ + Apply Image… ใช้ภาพ… + Calculations… การคำนวณ… + Crystallize… ตกผลึก… + Facet ด้าน + Fragment แฟรกเมนต์ + Mosaic… โมเสก… + Pointillize… เล็ง… + Chalk & Charcoal ชอล์กและถ่าน + Charcoal ถ่าน + Chrome โครเมียม + Conté Crayon คอนเต้ เครยอน + Graphic Pen ปากกากราฟฟิค + Halftone Pattern รูปแบบฮาล์ฟโทน + Note Paper กระดาษโน๊ต + Photocopy ถ่ายเอกสาร + Plaster พลาสเตอร์ + Reticulation การกำหนดซ้ำ + Stamp แสตมป์ + Torn Edges ขอบฉีกขาด + Water Paper กระดาษน้ำ + Clouds เมฆ + Difference Clouds ความแตกต่างเมฆ + Fibers… เส้นใย… + Lens Flare… เลนส์แฟลร์… + Picture Frame… กรอบรูป… + Tree… ต้นไม้… + Flame… เปลวไฟ… + Sharpen Edges ปรับขอบให้คมขึ้น + Dust & Scratches… ฝุ่นและรอยขีดข่วน… + Noise สัญญาณรบกวน + Add Noise… เพิ่มเสียงรบกวน… + Reduce Noise… ลดเสียงรบกวน… + Despeckle เดสเปคเคิล + Median… ค่ามัธยฐาน… + Median ค่ามัธยฐาน + Maximum… สูงสุด… + Maximum สูงสุด + Minimum… ขั้นต่ำ… + Minimum ขั้นต่ำ + Custom… กำหนดเอง… + High Pass… ผ่านสูง… + Emboss… ลายนูน… + Extrude… ขับไล่… + Find Edges ค้นหาขอบ + Solarize แสงอาทิตย์ + Tiles… กระเบื้อง… + Trace Contour… ติดตาม Contour… + Wind… ลม… + Offset… ออฟเซ็ต… + Pinch… หยิก… + Polar Coordinates… พิกัดเชิงขั้ว… + Ripple… ระลอกคลื่น… + Shear… แรงเฉือน… + Spherize… ทรงกลม… + Twirl… หมุน… + Wave… คลื่น… + ZigZag… ซิกแซก… + Average เฉลี่ย + Blur More เบลอมากขึ้น + Box Blur… กล่องเบลอ… + Gaussian Blur… เบลอแบบเกาส์เซียน… + Motion Blur… โมชั่นเบลอ… + Radial Blur… เรเดียลเบลอ… + Shape Blur… รูปร่างเบลอ… + Smart Blur… สมาร์ทเบลอ… + Surface Blur… พื้นผิวเบลอ… + Sharpen ทำให้คมชัด + Sharpen More ทำให้คมชัดมากขึ้น + Unsharp Mask… มาสก์ปรับความคมชัด… + Smart Sharpen… ทำให้คมชัดอัจฉริยะ… + Diffuse… กระจาย… + Mezzotint… เมซโซตินต์… + Color Halftone… ฮาล์ฟโทนสี… + Layer… เลเยอร์… + +# View + Proof Setup การตั้งค่าการพิสูจน์ + Proof Colors สีหลักฐาน + Pixel Aspect Ratio อัตราส่วนพิกเซล + Pixel Aspect Ratio Correction การแก้ไขอัตราส่วนพิกเซล + Zoom In ซูมเข้า + Zoom Out ซูมออก + Fit on Screen พอดีหน้าจอ + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size ขนาดการพิมพ์ + Screen Mode โหมดหน้าจอ + Standard Screen Mode โหมดหน้าจอมาตรฐาน + Full Screen Mode With Menu Bar โหมดเต็มหน้าจอพร้อมแถบเมนู + Full Screen Mode โหมดเต็มหน้าจอ + Extras บริการพิเศษ + Show แสดง + Rulers ไม้บรรทัด + Grid ตาราง + Guides เส้นไกด์ + Slices ชิ้น + Snap ดูดติด + Snap To สแน็ปไปที่ + Lock Guides ล็อคคำแนะนำ + Clear Guides ล้างคำแนะนำ + New Guide… คู่มือใหม่… + Lock Slices ล็อคชิ้น + Clear Slices ล้างชิ้น + New Guide Layout… เค้าโครงคำแนะนำใหม่… + New Guides From Shape คำแนะนำใหม่จาก Shape + Canvas rotation การหมุนผืนผ้าใบ + Zoom in ซูมเข้า + Zoom out ซูมออก + +# Window + Cascade น้ำตก + Tile กระเบื้อง + 2-up Horizontal แนวนอน 2 อัพ + 3-up Horizontal แนวนอน 3 อัพ + 2-up Vertical แนวตั้ง 2 ขึ้นไป + 3-up Vertical แนวตั้ง 3 ขึ้นไป + 3-up Stacked 3-up ซ้อนกัน + 4-up 4 อัพ + 6-up 6 อัพ + Consolidate All to Tabs รวมทั้งหมดไว้ในแท็บ + Match Zoom จับคู่ซูม + Match Location จับคู่ตำแหน่ง + Match Rotation การหมุนการแข่งขัน + Match All ตรงทั้งหมด + New Window for Document หน้าต่างใหม่สำหรับเอกสาร + Workspace พื้นที่ทำงาน + Extensions ส่วนขยาย + Panels แผง + Actions การดำเนินการ + Brush Settings การตั้งค่าแปรง + Brushes แปรง + Channels ช่อง + Character ตัวละคร + Clone Source แหล่งที่มาของโคลน + Color Mixer เครื่องผสมสี + Glyphs ร่ายมนตร์ + Gradients การไล่ระดับสี + Histogram ฮิสโตแกรม + History ประวัติศาสตร์ + Info ข้อมูล + Layer Comps คอมพ์เลเยอร์ + Layers เลเยอร์ + Measurement Log บันทึกการวัด + Notes หมายเหตุ + Navigator นาวิเกเตอร์ + Paragraph ย่อหน้า + Paths เส้นทาง + Patterns รูปแบบ + Properties คุณสมบัติ + Styles สไตล์ + Swatches สวอตช์ + Timeline ไทม์ไลน์ + Tool Presets ค่าที่ตั้งไว้ล่วงหน้าของเครื่องมือ + Tools เครื่องมือ + Libraries ห้องสมุด + 3D 3D + Toolbars แถบเครื่องมือ + Hide All ซ่อนทั้งหมด + +# Help + About PhotoCraft เกี่ยวกับ PhotoCraft + PhotoCraft Website เว็บไซต์PhotoCraft + PhotoCraft on GitHub PhotoCraftบน GitHub + Report an Issue… รายงานปัญหา… + ArtCraft Website เว็บไซต์อาร์ตคราฟต์ + Join the ArtCraft Discord… เข้าร่วม ArtCraft Discord… + System Info… ข้อมูลระบบ… + +# Tools and options bar + Move ย้าย + Marquee กระโจม + Lasso ลาสโซ + Wand ไม้กายสิทธิ์ + Eyedropper ยาหยอดตา + Brush แปรง + Block บล็อก + Pencil ดินสอ + Eraser ยางลบ + Clone Stamp โคลนแสตมป์ + Healing การรักษา + Blur เบลอ + Dodge ดอดจ์ + Burn เผา + Gradient การไล่ระดับสี + Shape รูปร่าง + Pen ปากกา + Hand มือ + Zoom ซูม + Sampler เครื่องเก็บตัวอย่าง + Ruler Tool เครื่องมือไม้บรรทัด + Count Tool เครื่องมือนับ + Note tool เครื่องมือบันทึก + Quick Selection การเลือกอย่างรวดเร็ว + Magnetic Lasso บ่วงแม่เหล็ก + Mixer Brush แปรงมิกเซอร์ + Mixer Brush Tool เครื่องมือแปรงมิกเซอร์ + Color Replacement Tool เครื่องมือเปลี่ยนสี + History Brush แปรงประวัติศาสตร์ + Art History Brush แปรงประวัติศาสตร์ศิลปะ + Pattern Stamp แสตมป์รูปแบบ + Spot Healing การรักษาเฉพาะจุด + Red Eye ตาแดง + Patch แพทช์ + Content-Aware Move การเคลื่อนไหวแบบรับรู้เนื้อหา + Rotate View หมุนมุมมอง + Perspective Crop ครอบตัดเปอร์สเปคทีฟ + Color Replacement การเปลี่ยนสี + Dodge/Burn ดอดจ์/เบิร์น + Foreground เบื้องหน้า + Background พื้นหลัง + Set foreground color ตั้งค่าสีพื้นหน้า + Set background color ตั้งค่าสีพื้นหลัง + Swap colours (X) สลับสี (X) + Default colours (D) สีเริ่มต้น (D) + Brush Tool Options ตัวเลือกเครื่องมือแปรง + Opacity ความทึบ + Flow ไหล + Smoothing ปรับให้เรียบ + Size ขนาด + Hardness ความแข็ง + Angle: มุม: + Roundness: ความกลม: + Spacing ระยะห่าง + Dynamics ไดนามิกส์ + Pressure ความกดดัน + Tilt เอียง + Rotation การหมุน + Align จัดตำแหน่ง + Aligned จัดตำแหน่ง + +# Panels, dialogs and common widgets + OK ตกลง + Cancel ยกเลิก + Cancel (Esc) ยกเลิก (Esc) + Manage submitted jobs in the system print queue จัดการงานที่ส่งในคิวการพิมพ์ของระบบ + Done เสร็จสิ้น + Apply นำไปใช้ + Reset รีเซ็ต + Reset Section รีเซ็ตส่วน + Reset All to Defaults รีเซ็ตทั้งหมดเป็นค่าเริ่มต้น + Import Shortcuts… นำเข้าทางลัด… + Load a keyboard shortcut set saved as .kys โหลดชุดแป้นพิมพ์ลัดที่บันทึกเป็น .kys + Imported {n} shortcuts from {file}. Click OK to keep them. นำเข้าทางลัด {n} จาก {file} คลิกตกลงเพื่อเก็บไว้ + {n} commands are not in PhotoCraft. คำสั่ง {n} ไม่ได้อยู่ใน PhotoCraft + Keyboard shortcuts imported นำเข้าแป้นพิมพ์ลัดแล้ว + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. ชุดแป้นพิมพ์ลัดของคุณ {set} ใช้งานอยู่ที่นี่: แป้นพิมพ์ลัด {n} แตกต่างจากค่าเริ่มต้น แก้ไข › แป้นพิมพ์ลัดจะแสดงข้อมูลเหล่านั้น รีเซ็ตทั้งหมดเป็นค่าเริ่มต้นจะเลิกทำสิ่งนี้ + Import keyboard shortcuts? นำเข้าแป้นพิมพ์ลัดใช่ไหม + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft พบชุดแป้นพิมพ์ลัดของคุณ {set} จากโปรแกรมแก้ไขภาพอื่นบนคอมพิวเตอร์เครื่องนี้: ทางลัด {n} แตกต่างจากของ PhotoCraft นำเข้าเหรอ? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. แก้ไข › แป้นพิมพ์ลัด › รีเซ็ตทั้งหมดเป็นค่าเริ่มต้น ยกเลิกการนำเข้า + Don't Import อย่านำเข้า + The keyboard shortcut set was not imported: {error} ไม่ได้นำเข้าชุดแป้นพิมพ์ลัด: {error} + Defaults restored. คืนค่าเริ่มต้นแล้ว + Restore Defaults คืนค่าเริ่มต้น + Restore original size คืนขนาดเดิม + Use Default ใช้ค่าเริ่มต้น + Dismiss ยกเลิก + Preview ดูตัวอย่าง + Options ตัวเลือก + Settings… การตั้งค่า… + Rename… เปลี่ยนชื่อ… + Load… โหลด… + Define… กำหนด… + Custom กำหนดเอง + Default ค่าเริ่มต้น + None ไม่มี + Add เพิ่ม + Remove ลบ + Remove Background ลบพื้นหลัง + Rename เปลี่ยนชื่อ + Ignore ไม่สนใจ + Ignore All ละเว้นทั้งหมด + Change เปลี่ยน + Change All เปลี่ยนทั้งหมด + Next frame เฟรมถัดไป + Previous frame เฟรมก่อนหน้า + Play เล่น + Pause หยุดชั่วคราว + Stop หยุด + Begin recording เริ่มการบันทึก + Stop playing/recording หยุดเล่น/บันทึก + Record ● a sequence of edits, then play ▶ it on any document. บันทึก ● ลำดับการแก้ไข จากนั้นเล่น ▶ บนเอกสารใดก็ได้ + Create new action สร้างการกระทำใหม่ + Play selection เลือกเล่น + Create new set สร้างชุดใหม่ + Export selected measurements (CSV) ส่งออกการวัดที่เลือก (CSV) + Record Measurements บันทึกการวัด + Delete selected measurements ลบการวัดที่เลือก + Select Data Points… เลือกจุดข้อมูล… + Set Measurement Scale… ตั้งค่าสเกลการวัด… + Use Measurement Scale ใช้มาตราส่วนการวัด + +# Preferences dialog + Preferences การตั้งค่า + General… ทั่วไป… + Interface… อินเทอร์เฟซ… + File Handling… การจัดการไฟล์… + Performance… ประสิทธิภาพ… + Cursors… เคอร์เซอร์… + Transparency & Gamut… ความโปร่งใสและขอบเขต… + Units & Rulers… หน่วยและผู้ปกครอง… + Guides, Grid & Slices… เส้นบอกแนว ตาราง และสไลซ์… + Plug-ins… ปลั๊กอิน… + Technology Previews ตัวอย่างเทคโนโลยี + Scratch Disks… รอยขีดข่วนดิสก์… + Export Preferences… การตั้งค่าการส่งออก… + Keyboard Shortcuts… แป้นพิมพ์ลัด… + Menus… เมนู… + Export/Import Presets… ส่งออก/นำเข้าค่าที่ตั้งล่วงหน้า… + Additional plugins folder โฟลเดอร์ปลั๊กอินเพิ่มเติม + Install Plug-in… ติดตั้งปลั๊กอิน… + Export Presets ส่งออกค่าที่ตั้งล่วงหน้า + Import brushes (.abr) นำเข้าแปรง (.abr) + Migrate Presets ย้ายค่าที่ตั้งล่วงหน้า + Thumbnail size (smallest: list) ขนาดรูปขนาดย่อ (เล็กที่สุด: รายการ) + Reset GPU Backend รีเซ็ตแบ็กเอนด์ GPU + Applies at next launch. นำไปใช้ในการเปิดตัวครั้งถัดไป + +# Document and canvas status bar + Untitled ไม่มีชื่อ + Untitled-1 ไม่มีชื่อ-1 + Doc: {flat}/{layered} เอกสาร: {flat}/{layered} + Scratch: {size} Scratch: {size} + Efficiency: 100% ประสิทธิภาพ: 100% + Dimensions: ขนาด: + Width ความกว้าง + Slant เอียง + Height ความสูง + Width: ความกว้าง: + Height: ส่วนสูง: + Resolution ความละเอียด + Resolution: ความละเอียด: + Resolution: {dpi} pixels/inch ความละเอียด: {dpi} พิกเซล/นิ้ว + {w} px x {h} px ({ppi} ppi) {w} พิกเซล x {h} พิกเซล ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Pixels: พิกเซล: + Pixel Length: ความยาวพิกเซล: + Logical Length: ความยาวลอจิก: + Logical Units: หน่วยลอจิคัล: + Length: ความยาว: + Interpolation: การแก้ไข: + Resample ตัวอย่าง + Constrain proportions จำกัดสัดส่วน + Link width and height ความกว้างและความสูงของลิงค์ + Maintain aspect ratio รักษาอัตราส่วนภาพ + Current Size ขนาดปัจจุบัน + New Size ขนาดใหม่ + Fit พอดี + Percent เปอร์เซ็นต์ + Pixels/Inch พิกเซล/นิ้ว + Pixels/Centimeter พิกเซล/เซนติเมตร + Relative ญาติ + Canvas extension color: สีส่วนขยายของผ้าใบ: + Anchor: สมอ: + Both Axes ขวานทั้งสอง + Horizontally แนวนอน + Vertically ในแนวตั้ง + +# Layers panel + Layers to Files… เลเยอร์เป็นไฟล์… + Duplicate Layers… ทำซ้ำเลเยอร์… + Group name ชื่อกลุ่ม + New Group กลุ่มใหม่ + Layer Comp คอมพ์เลเยอร์ + Create new layer สร้างเลเยอร์ใหม่ + Create new group สร้างกลุ่มใหม่ + Create new channel สร้างช่องใหม่ + Create new path สร้างเส้นทางใหม่ + Toggle visibility สลับการมองเห็น + Clipped ตัดแล้ว + Group · {n} layer กลุ่ม · {n} เลเยอร์ + Lock: keep these settings when picking another preset ล็อค: เก็บการตั้งค่าเหล่านี้ไว้เมื่อเลือกค่าที่ตั้งไว้ล่วงหน้าอื่น + Unlock: picking a preset replaces these settings ปลดล็อค: การเลือกค่าที่ตั้งล่วงหน้าจะแทนที่การตั้งค่าเหล่านี้ + Show the layer's effects ({key}-click: all layers) แสดงเอฟเฟกต์ของเลเยอร์ ({key}-click: ทุกเลเยอร์) + Hide the layer's effects ({key}-click: all layers) ซ่อนเอฟเฟกต์ของเลเยอร์ ({key}-click: ทุกเลเยอร์) + Expand group ({key}-click: all groups) ขยายกลุ่ม ({key}-คลิก: ทุกกลุ่ม) + Collapse group ({key}-click: all groups) ยุบกลุ่ม ({key}-คลิก: ทุกกลุ่ม) + Effects ผลกระทบ + Blending การผสม + Blend If ผสมผสานถ้า + General Blend ส่วนผสมทั่วไป + Advanced Blending การผสมขั้นสูง + Opacity: ความทึบ: + Fill: กรอก: + Lock: ล็อค: + Smart Filter ฟิลเตอร์อัจฉริยะ + Fill Content เติมเนื้อหา + Adjustment Preview ดูตัวอย่างการปรับ + +# Channels panel + Channel: ช่องทาง: + Channel Mixer… ผสมช่องสัญญาณ… + Output Channel: ช่องสัญญาณออก: + RGB RGB + Red สีแดง + Green สีเขียว + Blue สีฟ้า + Alpha อัลฟ่า + Load channel as selection โหลดช่องเป็นตัวเลือก + Save selection as channel บันทึกการเลือกเป็นช่อง + Delete current channel ลบช่องปัจจุบัน + Spot Channel ช่องสปอต + Mask All มาสก์ทั้งหมด + Mask All Objects ปิดบังวัตถุทั้งหมด + +# Color picker and swatches + Color สี + Color: สี: + Colors สี + HSB/HSL HSB/HSL + No Color ไม่มีสี + Set shape fill type กำหนดประเภทการเติมรูปร่าง + Set shape stroke type กำหนดประเภทเส้นโครงร่าง + Set the text color ตั้งค่าสีข้อความ + Create warped text สร้างข้อความบิดเบี้ยว + Only Web Colors เฉพาะสีเว็บเท่านั้น + Overlay Color สีซ้อนทับ + Overlay Opacity ความทึบของการซ้อนทับ + Glow Color สีเรืองแสง + Web Snap เว็บสแนป + +# Filters and adjustments dialogs + Brightness ความสว่าง + Contrast คอนทราสต์ + Brightness/Contrast… ความสว่าง/คอนทราสต์… + Levels… ระดับ… + Curves… เส้นโค้ง… + Curve เส้นโค้ง + Exposure… การรับแสง… + Exposure การเปิดรับแสง + Vibrance… ความสดใส… + Vibrance ความมีชีวิตชีวา + Hue/Saturation… สี/ความอิ่มตัว… + Hue สี + Saturation ความอิ่มตัว + Lightness ความเบา + Color Balance… สมดุลสี… + Black & White… ขาวดำ… + Photo Filter… ฟิลเตอร์ภาพถ่าย… + Color Lookup… ค้นหาสี… + Color Lookup Tables… ตารางค้นหาสี… + 3D LUT File ไฟล์ 3D LUT + Load 3D LUT… โหลด 3D LUT… + Invert… กลับด้าน… + Invert กลับสี + Invert has no settings. Invert ไม่มีการตั้งค่า + Threshold… ขีดจำกัด… + Threshold Level ระดับเกณฑ์ + Posterize… โพสเตอร์ไรซ์… + Gradient Map… แมปไล่ระดับ… + Selective Color… เลือกสี… + Match Color… จับคู่สี… + Replace Color… แทนที่สี… + Equalize ปรับให้เท่ากัน + HDR Toning… โทน HDR… + Shadows/Highlights… เงา/ไฮไลต์… + Amount จำนวน + Radius รัศมี + Radius: รัศมี: + Tolerance: ความอดทน: + Density ความหนาแน่น + Density: ความหนาแน่น: + Feather ขนนก + Feather… ขอบฟุ้ง… + Anti-alias ต่อต้านนามแฝง + Anti-Alias ต่อต้านนามแฝง + Contiguous ติดกัน + Sample All Layers ตัวอย่างเลเยอร์ทั้งหมด + Protect Foreground Color ปกป้องสีพื้นหน้า + Matte แมท + Matting เครื่องปูลาด + Color Decontaminate… ขจัดคราบสี… + Remove Black Matte ลบสีดำด้าน + Remove White Matte ลบไวท์แมตต์ + Defringe… กำลังละลาย… + Source แหล่งที่มา + Working CMYK การทำงานของ CMYK + Working Cyan Plate แผงสีฟ้าทำงาน + Working Magenta Plate แผงสีม่วงแดงทำงาน + Working Yellow Plate จานเหลืองทำงาน + Working Black Plate จานดำทำงาน + Working Gray Plate แผ่นสีเทาทำงาน + +# Camera Raw and lens dialogs + Basic ขั้นพื้นฐาน + Detail รายละเอียด + HSL/Grayscale HSL/ระดับสีเทา + Tone Curve โทนสีโค้ง + Split Toning แยกโทนนิ่ง + Lens Corrections การแก้ไขเลนส์ + Camera Calibration การปรับเทียบกล้อง + Presets ค่าที่ตั้งล่วงหน้า + Snapshots สแนปชอต + Temperature อุณหภูมิ + Tint โทนสี + Exposure: การสัมผัส: + Recovery การกู้คืน + Fill Light เติมแสง + Blacks คนผิวดำ + Clarity ความชัดเจน + Convert to Grayscale แปลงเป็นระดับสีเทา + Auto อัตโนมัติ + Auto collapse icon panels แผงไอคอนยุบอัตโนมัติ + Auto show hidden panels แสดงแผงที่ซ่อนอยู่โดยอัตโนมัติ + Auto show home screen แสดงหน้าจอหลักอัตโนมัติ + Sharpness ความคม + Noise Reduction ลดเสียงรบกวน + Color Noise Reduction การลดสัญญาณรบกวนสี + Luminance Detail รายละเอียดความสว่าง + Color Detail รายละเอียดสี + Luminance Smoothness ความนุ่มนวลของความสว่าง + Vignetting วิกเนต + White Balance: As Shot สมดุลแสงขาว: เท่าที่ถูกถ่าย + Camera Raw… กล้องดิบ… + Camera Raw isn't open Camera Raw ไม่เปิด + Dehaze ลดหมอกควัน + Texture พื้นผิว + Focal Length ทางยาวโฟกัส + Make ทำ + Model รุ่น + Lens เลนส์ + F เอฟ + ISO ไอเอสโอ + Focal Length35mm ทางยาวโฟกัส 35 มม + Aspect Ratio อัตราส่วนภาพ + Time เวลา + Artist ศิลปิน + Copyright ลิขสิทธิ์ + Enter description… ป้อนคำอธิบาย… + Adobe RGB (1998) compatible รองรับ Adobe RGB (1998) + Gray Gamma 2.2 แกมมาสีเทา 2.2 + ColorSync ColorSync + ProPhoto โปรโฟโต้ + sRGB sRGB + ACES เอซ + Industry Standard มาตรฐานอุตสาหกรรม + Web เว็บ + Internet Standard RGB RGB มาตรฐานอินเทอร์เน็ต + Legacy Macintosh RGB Macintosh RGB รุ่นเก่า + Monitor RGB ตรวจสอบ RGB + Windows LCD วินโดว์จอแอลซีดี + Coated CMYK เคลือบสี CMYK + Uncoated CMYK CMYK ที่ไม่เคลือบ + Newspaper หนังสือพิมพ์ + Working Spaces พื้นที่ทำงาน + Color Management Policies นโยบายการจัดการสี + Assign working space กำหนดพื้นที่ทำงาน + Leave as is (don't color manage) ปล่อยไว้เหมือนเดิม (อย่าจัดการสี) + Discard the embedded profile (don't color manage) ละทิ้งโปรไฟล์ที่ฝังไว้ (อย่าจัดการสี) + Embed Color Profile ฝังโปรไฟล์สี + Assign Profile… กำหนดโปรไฟล์… + Convert to Profile… แปลงเป็นโปรไฟล์… + PhotoCraft Manages Colors PhotoCraft จัดการสี + Printer Manages Colors เครื่องพิมพ์จัดการสี + (print to the printer) (พิมพ์ไปยังเครื่องพิมพ์) + Absolute Colorimetric การวัดสีแบบสัมบูรณ์ + Relative Colorimetric การวัดสีสัมพัทธ์ + Perceptual การรับรู้ + Black Point Compensation การชดเชยจุดดำ + Use Legacy ใช้มรดก + Dither ดิเธอร์ + No Dither ไม่มีดิเธอร์ + Diffusion การแพร่กระจาย + Pattern รูปแบบ + Fuzziness: ความคลุมเครือ: + Range: ช่วง: + Select: เลือก: + Detect Faces ตรวจจับใบหน้า + Localize Color Clusters แปลคลัสเตอร์สี + Sample: ตัวอย่าง: + Add to Sample เพิ่มลงในตัวอย่าง + Subtract from Sample ลบออกจากตัวอย่าง + Selections การเลือก + Duplicated selection name ชื่อการเลือกซ้ำกัน + Operation การดำเนินงาน + New channel only ช่องใหม่เท่านั้น + Masked Areas พื้นที่สวมมาสก์ + Selected Areas พื้นที่ที่เลือก + Spot Color สีเฉพาะจุด + Color indicates สีบ่งบอกถึง + +# Adjustments panel + Curves เส้นโค้ง + Levels ระดับ + Color Balance ความสมดุลของสี + Black & White ขาวดำ + Photo Filter ฟิลเตอร์ภาพถ่าย + Color Lookup การค้นหาสี + Threshold เกณฑ์ + Posterize โปสเตอร์ + Brightness/Contrast ความสว่าง/คอนทราสต์ + Channel Mixer มิกเซอร์ช่อง + Gradient Map แผนที่ไล่ระดับสี + Selective Color เลือกสี + +# History panel + History Log… บันทึกประวัติ… + Create Snapshot สร้างสแนปชอต + Delete Snapshot ลบสแนปชอต + New Snapshot สแนปชอตใหม่ + Snapshot 1 ภาพรวม 1 + Step Forward ก้าวไปข้างหน้า + Step Backward ก้าวถอยหลัง + Histories ประวัติศาสตร์ + Nonlinear History ประวัติศาสตร์ไม่เชิงเส้น + Set New Snapshot as Default ตั้งค่าสแนปชอตใหม่เป็นค่าเริ่มต้น + Automatically Create First Snapshot สร้างสแนปชอตแรกโดยอัตโนมัติ + Automatically Create New Snapshot When Saving สร้างสแนปชอตใหม่โดยอัตโนมัติเมื่อบันทึก + Show New Snapshot Dialog by Default แสดงกล่องโต้ตอบสแนปชอตใหม่ตามค่าเริ่มต้น + Clear History ล้างประวัติ + +# Navigator panel + Panel Options… ตัวเลือกแผง… + View menu เมนูมุมมอง + Keep view in sync ให้มุมมองตรงกัน + {zoom}% {zoom}% + +# Brush Settings panel + Brush name ชื่อแปรง + Brush Preview ดูตัวอย่างแปรง + Brush Tip Shape รูปร่างปลายแปรง + Shape Dynamics ไดนามิกส์ของรูปร่าง + Scattering กระจัดกระจาย + Dual Brush แปรงคู่ + Color Dynamics ไดนามิกของสี + Transfer โอน + Brush Pose แปรงโพสท่า + Mouse Controls การควบคุมเมาส์ + Wet Edges ขอบเปียก + Build-up สร้างขึ้น + Protect Texture ปกป้องพื้นผิว + Blend Mode โหมดผสมผสาน + Size Jitter ขนาดกระวนกระวายใจ + Minimum Diameter เส้นผ่านศูนย์กลางขั้นต่ำ + Tilt Scale สเกลเอียง + Angle Jitter มุมกระวนกระวายใจ + Roundness Jitter ความกลมกระวนกระวายใจ + Minimum Roundness ความกลมขั้นต่ำ + Flip X Jitter พลิก X กระวนกระวายใจ + Flip Y Jitter พลิกและกระวนกระวายใจ + Brush Projection การฉายภาพด้วยแปรง + Control: การควบคุม: + Off: the speed of the pointer sets the spacing ปิด: ความเร็วของตัวชี้จะกำหนดระยะห่าง + Fade steps ขั้นตอนจางลง + Pen Pressure แรงกดของปากกา + Pen Tilt ปากกาเอียง + Stylus Wheel ล้อสไตลัส + Initial Direction of Stroke ทิศทางเริ่มต้นของโรคหลอดเลือดสมอง + Direction of Stroke ทิศทางของโรคหลอดเลือดสมอง + Opacity Jitter ความทึบ กระวนกระวายใจ + Flow Jitter กระแสกระวนกระวายใจ + Humidity ความชื้น + Load โหลด + Mix ผสม + Auto Clean Brush แปรงทำความสะอาดอัตโนมัติ + Wet เปียก + Current Brush Load โหลดแปรงปัจจุบัน + Load Brush โหลดแปรง + Clean Brush แปรงทำความสะอาด + Load the brush after each stroke ใส่แปรงหลังการปัดแต่ละครั้ง + Clean the brush after each stroke ทำความสะอาดแปรงหลังการแปรงแต่ละครั้ง + Rate อัตรา + Airbrush: keeps depositing paint while the pointer rests. พู่กัน: เก็บสีไว้ในขณะที่ตัวชี้วางอยู่ + Pulled String Mode โหมดสายดึง + Catch-up on Stroke End ติดตามเรื่อง Stroke End + Adjust for Zoom ปรับเพื่อซูม + Apply Per Tip ใช้ต่อเคล็ดลับ + Hardness: ความแข็ง: + Spacing: ระยะห่าง: + Size: ขนาด: + Flip X พลิก X + Flip Y พลิก Y + Brush Tip ปลายแปรง + Diameter: เส้นผ่านศูนย์กลาง: + New Layer from Brush เลเยอร์ใหม่จากแปรง + Rename Brush… เปลี่ยนชื่อแปรง… + Delete Brush ลบแปรง + Import Brushes… นำเข้าแปรง… + Import Brushes… (.abr or image) นำเข้าแปรง… (.abr หรือภาพ) + Import Brush from Image… นำเข้าแปรงจากภาพ… + Gradient Preset Options ตัวเลือกการไล่ระดับสีล่วงหน้า + Import .GRD นำเข้า .GRD + Choose a gradient file (.grd). เลือกไฟล์ไล่ระดับสี (.grd) + Brush Name ชื่อแปรง + Brush Stroke แปรงจังหวะ + New Brush Preset… พรีเซ็ตแปรงใหม่… + Capture Brush Size in Preset จับขนาดแปรงในการตั้งค่าล่วงหน้า + Include Tool Settings รวมการตั้งค่าเครื่องมือ + Include Color รวมสี + Enter a name for the brush preset ป้อนชื่อสำหรับการตั้งค่าแปรงล่วงหน้า + A brush preset with this name already exists มีแปรงที่ตั้งไว้ล่วงหน้าด้วยชื่อนี้อยู่แล้ว + Brush Preset Options ตัวเลือกพรีเซ็ตแปรง + New Brush แปรงใหม่ + Create new brush from these settings สร้างแปรงใหม่จากการตั้งค่าเหล่านี้ + Update the current brush with these settings อัปเดตแปรงปัจจุบันด้วยการตั้งค่าเหล่านี้ + Reset the brush to the defaults รีเซ็ตแปรงเป็นค่าเริ่มต้น + No brush presets ไม่มีการตั้งค่าแปรงล่วงหน้า + Override Pressure แทนที่ความดัน + Override Tilt แทนที่การเอียง + Override Tilt X แทนที่การเอียง X + Override Tilt Y แทนที่การเอียง Y + Override Rotation แทนที่การหมุน + Apply the pen's tilt and rotation to the tip shape ใช้การเอียงและหมุนของปากกากับรูปร่างปลาย + +# Tool presets + Preset Type ประเภทพรีเซ็ต + Preset Manager… ผู้จัดการที่ตั้งไว้ล่วงหน้า… + Rename Tool Preset… เปลี่ยนชื่อเครื่องมือที่ตั้งไว้ล่วงหน้า… + Delete Tool Preset ลบเครื่องมือที่ตั้งไว้ล่วงหน้า + New Tool Preset พรีเซ็ตเครื่องมือใหม่ + Create new tool preset สร้างพรีเซ็ตเครื่องมือใหม่ + Current Tool Only เครื่องมือปัจจุบันเท่านั้น + No tool presets. ไม่มีการตั้งค่าเครื่องมือล่วงหน้า + No presets for the current tool. ไม่มีการตั้งค่าล่วงหน้าสำหรับเครื่องมือปัจจุบัน + No presets of this type. ไม่มีการตั้งค่าล่วงหน้าประเภทนี้ + Preset: ที่ตั้งไว้ล่วงหน้า: + Preset ที่ตั้งไว้ล่วงหน้า + Presets: ค่าที่ตั้งล่วงหน้า: + +# Paragraph and character panels + Font Size: ขนาดตัวอักษร: + Font size ขนาดตัวอักษร + Type: ตัวอักษร: + Style สไตล์ + Style: Normal สไตล์: ปกติ + Tracking การติดตาม + Kerning การจัดช่องไฟ + Leading เป็นผู้นำ + Auto-Leading อัตโนมัติเลเยอร์นำ + Scale Horizontally มาตราส่วนในแนวนอน + Scale Vertically ปรับขนาดในแนวตั้ง + Baseline Shift การเปลี่ยนแปลงพื้นฐาน + Language ภาษา + Anti-aliasing method วิธีการต่อต้านนามแฝง + Sharp ชาร์ป + Crisp กรอบ + Strong แข็งแรง + Smooth เรียบเนียน + Mac LCD แมคแอลซีดี + Mac Sharp แม็ก ชาร์ป + Mac Crisp แม็ก คริสป์ + Mac Strong แม็ก สตรอง + Mac Smooth แม็ก สมูท + Faux Bold ตัวหนาเทียม + Faux Italic ตัวเอียงเทียม + All Caps ตัวพิมพ์ใหญ่ทั้งหมด + Small Caps หมวกเล็ก + Superscript ตัวยก + Subscript ตัวห้อย + Underline ขีดเส้นใต้ + Strikethrough ขีดทับ + Oldstyle แบบเก่า + Ligature มัด + Alternative ทางเลือก + Hyphenate ยัติภังค์ + Roman โรมัน + Italic ตัวเอียง + Bold ตัวหนา + Bold Italic ตัวหนาตัวเอียง + Light เบา + Medium ปานกลาง + Black ดำ + Thin บาง + Ultra Light อัลตร้าไลท์ + Extra Light แสงพิเศษ + Semi Bold กึ่งหนา + Extra Bold ตัวหนาเป็นพิเศษ + Ultra Bold ตัวหนาเป็นพิเศษ + Condensed อัดแน่น + Left align text จัดข้อความชิดซ้าย + Center text ข้อความตรงกลาง + Right align text จัดข้อความชิดขวา + Justify last left จัดชิดขอบซ้ายสุดท้าย + Justify last centered จัดชิดขอบสุดท้ายให้อยู่กึ่งกลาง + Justify last right ชิดขวาสุดท้าย + Justify all ให้เหตุผลทั้งหมด + Indent Left เยื้องซ้าย + Indent Right เยื้องขวา + First Line Indent เยื้องบรรทัดแรก + Space Before พื้นที่ก่อน + Space After อวกาศหลังจาก + Hanging Punctuation เครื่องหมายวรรคตอนแขวน + Roman Hanging Punctuation เครื่องหมายวรรคตอนแขวนโรมัน + Avoid Widows/Orphans หลีกเลี่ยงแม่ม่าย/เด็กกำพร้า + Compose เขียน + Adobe Single-line Composer Adobe นักแต่งเพลงบรรทัดเดียว + Adobe Every-line Composer Adobe นักแต่งเพลงทุกบรรทัด + Justification การให้เหตุผล + Word Spacing การเว้นวรรคคำ + Letter Spacing การเว้นวรรคจดหมาย + Glyph Scaling การปรับขนาดสัญลักษณ์ + Auto-leading % % นำอัตโนมัติ + Single Word Justification การให้เหตุผลคำเดียว + Alignment การจัดตำแหน่ง + Indent First เยื้องก่อน + Center ศูนย์ + Justify จัดชิดขอบ + +# Glyphs panel + Glyph สัญลักษณ์ + Select a type layer or click into text to insert glyphs เลือกเลเยอร์ตัวอักษรหรือคลิกในข้อความเพื่อแทรกอักขระ + Entire Font แบบอักษรทั้งหมด + Alternates for Current Selection ทางเลือกสำหรับการเลือกปัจจุบัน + Recently Used ใช้ล่าสุด + Digits ตัวเลข + Symbols สัญลักษณ์ + Punctuation เครื่องหมายวรรคตอน + OpenType OpenType + Standard Ligatures ลิเกเจอร์มาตรฐาน + Discretionary Ligatures ดุลยพินิจผูกมัด + Contextual Alternates ทางเลือกตามบริบท + Swash หวด + Titling Alternates การตั้งชื่อทางเลือก + Ornaments เครื่องประดับ + Fractions เศษส่วน + Ordinals ลำดับ + East Asian Features คุณสมบัติของเอเชียตะวันออก + Middle Eastern Features คุณสมบัติของตะวันออกกลาง + +# Paths panel + Path เส้นทาง + Path: เส้นทาง: + Work Path เส้นทางการทำงาน + Path to Illustrator… เส้นทางสู่นักวาดภาพประกอบ… + Make Work Path สร้างเส้นทางการทำงาน + Make Selection ทำการเลือก + Fill Path เติมเส้นทาง + Stroke Path เส้นทางโรคหลอดเลือดสมอง + Clipping Path เส้นทางการตัด + New Path เส้นทางใหม่ + Duplicate Path เส้นทางที่ซ้ำกัน + Rename Path… เปลี่ยนชื่อเส้นทาง… + Delete Path ลบเส้นทาง + Current Path เส้นทางปัจจุบัน + Vector Mask from Current Path เว็กเตอร์มาสก์จากเส้นทางปัจจุบัน + Create new shape from the current path สร้างรูปร่างใหม่จากเส้นทางปัจจุบัน + Drag to move the active shape's path or the Work Path ลากเพื่อย้ายเส้นทางของรูปร่างที่ใช้งานอยู่หรือเส้นทางการทำงาน + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath คลิก: เลือกจุดยึด · ลาก: ย้ายจุดยึด จุดจับ หรือส่วน · {shift}-คลิก: เพิ่ม · {alt}-คลิก: เส้นทางย่อยทั้งหมด + Draw with the Pen tool (P) or make a work path from a selection. วาดด้วยเครื่องมือปากกา (P) หรือสร้างเส้นทางการทำงานจากส่วนที่เลือก + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) ที่มา: ไม่ได้ตั้งค่า ({key}-คลิกพร้อม Clone Stamp) + Clone source {n} ({key}-click with Clone Stamp to set) แหล่งที่มาของโคลน {n} ({key} คลิกพร้อมกับ Clone Stamp เพื่อตั้งค่า) + {key}-click to set the source {key}-คลิกเพื่อตั้งค่าแหล่งที่มา + Option-click to define a source point to clone from คลิกตัวเลือกเพื่อกำหนดจุดต้นทางที่จะโคลน + Show Overlay แสดงภาพซ้อนทับ + Auto-Hide ซ่อนอัตโนมัติ + W ว + H ฮ + Angle มุม + Scale ปรับขนาด + X เอ็กซ์ + Y ย + Offset ออฟเซ็ต + Reset Transform รีเซ็ตการแปลง + Rotate หมุน + Set horizontal skew ตั้งค่าการเอียงแนวนอน + Set vertical skew ตั้งค่าการเอียงแนวตั้ง + Flip Horizontal พลิกแนวนอน + Flip Vertical พลิกแนวตั้ง + Frame Sync เฟรมซิงค์ + Quick Sync ซิงค์ด่วน + Enabled เปิดใช้งานแล้ว + +# Timeline panel + Create Video Timeline สร้างไทม์ไลน์วิดีโอ + Open a document to use the timeline. เปิดเอกสารเพื่อใช้ไทม์ไลน์ + Video Layers เลเยอร์วิดีโอ + Video วีดีโอ + Audio เสียง + Frame Duration ระยะเวลาของเฟรม + 30 frames @ 30 fps 30 เฟรมที่ 30 เฟรมต่อวินาที + Loop วนซ้ำ + Render Video… เรนเดอร์วิดีโอ… + Split at Playhead แยกที่ Playhead + Delete Timeline ลบไทม์ไลน์ + Reload Frame โหลดเฟรมอีกครั้ง + Restore Frame คืนค่าเฟรม + Insert Blank Frame ใส่กรอบเปล่า + Duplicate Frame เฟรมที่ซ้ำกัน + Delete Frame ลบเฟรม + Restore All Frames คืนค่าเฟรมทั้งหมด + Flatten Frames Into Clips แผ่เฟรมออกเป็นคลิป + Flatten Frames Into Layers แผ่เฟรมออกเป็นเลเยอร์ๆ + Convert Frames > Video Layers แปลงเฟรม > เลเยอร์วิดีโอ + New Video Layer เลเยอร์วิดีโอใหม่ + New Video Group กลุ่มวิดีโอใหม่ + Interpret Footage… ตีความภาพ… + Replace Footage… แทนที่ฟุตเทจ… + Show Altered Video แสดงวิดีโอที่เปลี่ยนแปลง + Limit Playback จำกัดการเล่น + Enable Timeline Quick Config เปิดใช้งานการกำหนดค่าด่วนของไทม์ไลน์ + Set Start Timecode… ตั้งรหัสเวลาเริ่มต้น… + Timecode รหัสเวลา + 30 fps 30 เฟรมต่อวินาที + 25 fps 25 เฟรมต่อวินาที + 24 fps 24 เฟรมต่อวินาที + 15 fps 15 เฟรมต่อวินาที + 10 fps 10 เฟรมต่อวินาที + Current Time เวลาปัจจุบัน + Out ออก + In ใน + Duration ระยะเวลา + Repeat ทำซ้ำ + Play Once เล่นครั้งเดียว + Video Cache แคชวิดีโอ + +# Info panel + Info Options… ตัวเลือกข้อมูล… + First Color Readout การอ่านค่าสีครั้งแรก + Second Color Readout การอ่านค่าสีที่สอง + Actual Color สีจริง + Grayscale Slider ตัวเลื่อนระดับสีเทา + Web Color สีเว็บ + HSB Color สี HSB + Total Ink หมึกทั้งหมด + Opacity Mask มาสก์ทึบแสง + Ruler ไม้บรรทัด + Current Tool เครื่องมือปัจจุบัน + Document Size ขนาดเอกสาร + Document Profile โปรไฟล์เอกสาร + Document Dimensions ขนาดเอกสาร + Scratch Sizes ขนาดรอยขีดข่วน + Efficiency ประสิทธิภาพ + Timing เวลา + Status Information ข้อมูลสถานะ + Saved Multichannel บันทึกหลายช่องแล้ว + Mouse Coordinates พิกัดเมาส์ + Ruler Units หน่วยไม้บรรทัด + Hide ซ่อน + +# Layer Comps panel + Layer Comps to Files… เลเยอร์ Comps เป็นไฟล์… + Apply next selected layer comp ใช้คอมพ์เลเยอร์ที่เลือกถัดไป + Apply previous selected layer comp ใช้คอมพ์เลเยอร์ที่เลือกก่อนหน้า + Update layer comp (visibility, position and appearance) อัปเดตเลเยอร์คอมพ์ (การมองเห็น ตำแหน่ง และรูปลักษณ์) + New Layer Comp คอมพ์เลเยอร์ใหม่ + Duplicate Layer Comp คอมพ์เลเยอร์ซ้ำ + Delete layer comp ลบเลเยอร์คอมพ์ + Rename Layer Comp เปลี่ยนชื่อเลเยอร์คอมพ์ + Create new layer comp สร้างคอมพ์เลเยอร์ใหม่ + Capture the visibility, position and style of every layer with + below, then switch between versions. จับภาพการมองเห็น ตำแหน่ง และสไตล์ของทุกเลเยอร์ด้วยเครื่องหมาย + ด้านล่าง จากนั้นสลับระหว่างเวอร์ชันต่างๆ + Apply Layer Comp ใช้เลเยอร์คอมพ์ + Update Layer Comp อัปเดตเลเยอร์คอมพ์ + Restore last viewed comp คืนค่าคอมพ์ที่ดูล่าสุด + Last Document State สถานะเอกสารล่าสุด + Visibility การมองเห็น + Position ตำแหน่ง + Appearance (Layer Style) ลักษณะที่ปรากฏ (สไตล์เลเยอร์) + +# Analysis and variables + Analysis การวิเคราะห์ + Variables ตัวแปร + Variable Data Sets… ชุดข้อมูลตัวแปร… + Data Sets… ชุดข้อมูล… + Data Sets ชุดข้อมูล + Data Sets as Files… ชุดข้อมูลเป็นไฟล์… + Apply Data Set… ใช้ชุดข้อมูล… + Define กำหนด + + Add variable + เพิ่มตัวแปร + Remove variable ลบตัวแปร + Define at least one variable first. กำหนดตัวแปรอย่างน้อยหนึ่งตัวก่อน + Open a document to define variables. เปิดเอกสารเพื่อกำหนดตัวแปร + Variables bind a layer's visibility, text or pixels to a named data slot. ตัวแปรจะผูกการมองเห็น ข้อความ หรือพิกเซลของเลเยอร์เข้ากับช่องข้อมูลที่มีชื่อ + Pixel Replacement การเปลี่ยนพิกเซล + Text Replacement การแทนที่ข้อความ + Name ชื่อ + Method วิธีการ + Select All Layers เลือกเลเยอร์ทั้งหมด + Fill เติม + Keep proportions รักษาสัดส่วน + Interpolation การแก้ไข + Author: ผู้เขียน: + Author ผู้เขียน + Name: ชื่อ: + Caption คำบรรยายภาพ + Caption Credits เครดิตคำบรรยาย + Description คำอธิบาย + Keywords คำหลัก + Copyright Info URL URL ข้อมูลลิขสิทธิ์ + Copyright Status สถานะลิขสิทธิ์ + Copyright Notice ประกาศเกี่ยวกับลิขสิทธิ์ + Document Title ชื่อเอกสาร + Copyrighted มีลิขสิทธิ์ + Public Domain โดเมนสาธารณะ + Unknown ไม่ทราบ + Copyright and Contact Info ลิขสิทธิ์และข้อมูลการติดต่อ + Credit เครดิต + Transmission Reference การอ้างอิงการส่ง + Urgency ความเร่งด่วน + High สูง + Normal ปกติ + Lossless ไม่มีการสูญเสีย + Low ต่ำ + Category หมวดหมู่ + Supplemental Categories หมวดหมู่เสริม + Object Name / Title ชื่อวัตถุ / ชื่อเรื่อง + Date Created วันที่สร้าง + City เมือง + Location ที่ตั้ง + Province / State จังหวัด/รัฐ + Country ประเทศ + Byline ทางสายย่อย + Byline Title ชื่อทางสายย่อย + Phone โทรศัพท์ + Email อีเมล + URLs URL + Last Modified แก้ไขครั้งล่าสุด + EXIF EXIF + GPS จีพีเอส + Camera Data ข้อมูลกล้อง + Camera Model รุ่นกล้อง + Camera Make ยี่ห้อกล้อง + EXIF Version เวอร์ชัน EXIF + Exposure Program โปรแกรมการสัมผัส + Aperture รูรับแสง + Shutter Speed ความเร็วชัตเตอร์ + Metering Mode โหมดการวัดแสง + Light Source แหล่งกำเนิดแสง + Flash แฟลช + Lens Info ข้อมูลเลนส์ + ISO Speed ความไวแสง ISO + Subject Distance ระยะห่างของเรื่อง + DateTime Original วันที่และเวลา ต้นฉบับ + Create Date สร้างวันที่ + Modify Date แก้ไขวันที่ + Software ซอฟต์แวร์ + Raw Data ข้อมูลดิบ + Version {version} Version {version} + Advanced ขั้นสูง + Import… นำเข้า… + +# Automate + Automate อัตโนมัติ + Batch… แบทช์… + Create Droplet… สร้างหยด… + Create Droplet สร้างหยด + PDF Presentation… การนำเสนอ PDF… + Create Multiple Picture Packages สร้างแพ็คเกจภาพหลายชุด + Contact Sheet II เอกสารการติดต่อ II + Contact Sheet II… เอกสารการติดต่อ II… + Conditional Mode Change… การเปลี่ยนโหมดตามเงื่อนไข… + Fit Image… พอดีภาพ… + Picture Package… ภาพแพ็คเกจ… + Web Photo Gallery… เว็บแกลเลอรี่ภาพ… + Merge to HDR Pro… ผสานเป็น HDR Pro… + Load Files into Stack… โหลดไฟล์ลงในสแต็ก… + Scripts สคริปต์ + Script Events Manager… ผู้จัดการกิจกรรมสคริปต์… + Image Processor… โปรเซสเซอร์ภาพ… + Destination จุดหมายปลายทาง + Choose… เลือก… + Select Folder… เลือกโฟลเดอร์… + Override Action "Save" Commands แทนที่คำสั่ง "บันทึก" ของการดำเนินการ + Compatibility ความเข้ากันได้ + Windows หน้าต่าง + Mac OS แมคโอเอส + Unix ยูนิกซ์ + Include ICC Profile รวมโปรไฟล์ ICC + Error File ไฟล์ข้อผิดพลาด + Stop For Errors หยุดเพื่อหาข้อผิดพลาด + Log Errors To File บันทึกข้อผิดพลาดไปยังไฟล์ + Errors ข้อผิดพลาด + +# Color Settings dialog + Color Settings… การตั้งค่าสี… + Settings การตั้งค่า + Settings: การตั้งค่า: + Working Space พื้นที่ทำงาน + RGB: RGB: + CMYK: สี CMYK: + Gray: สีเทา: + Spot: จุด: + Advanced Controls การควบคุมขั้นสูง + Desaturate Monitor Colors By ลดความอิ่มตัวของสีของจอภาพ By + Blend RGB Colors Using Gamma 1.0 ผสมผสานสี RGB โดยใช้ Gamma 1.0 + Profile Mismatches: Ask When Opening โปรไฟล์ไม่ตรงกัน: ถามเมื่อเปิด + Ask When Pasting ถามเมื่อวาง + Missing Profiles: Ask When Opening โปรไฟล์ที่หายไป: ถามเมื่อเปิด + Embed profiles ฝังโปรไฟล์ + Ask Before Saving Saved Embedded ถามก่อนบันทึกการฝังที่บันทึกไว้ + Coerce to sRGB บังคับ sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) อะโดบี RGB (1998) + ProPhoto RGB โปรโฟโต้ RGB + ColorMatch RGB การจับคู่สี RGB + Wide Gamut RGB ขอบเขตสีกว้าง RGB + Apple RGB แอปเปิ้ล RGB + NTSC (1953) เอ็นทีเอสซี (1953) + PAL/SECAM ระบบ PAL/SECAM + SMPTE C พีทีอี ซี + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + +# Themes + Theme ธีม + Switch theme สลับธีม + Next Theme ธีมถัดไป + Classic Theme ธีมคลาสสิก + Solarized dark แสงอาทิตย์มืด + Solarized Dark Theme ธีมมืดแสงอาทิตย์ + Breeze dark สายลมมืด + Breeze Dark Theme ธีมสายลมมืด + Breeze light สายลมเบาๆ + Breeze Light Theme ธีมบรีซไลท์ + Soft dark เข้มนุ่ม + Soft light แสงนุ่มนวล + Soft Dark Theme ธีมสีเข้มอ่อน + Soft Light Theme ธีมแสงนุ่มนวล + Studio Theme ธีมสตูดิโอ + Studio Light Theme ธีมแสงสตูดิโอ + Adwaita Light Theme ธีม Adwaita Light + Adwaita Dark Theme ธีม Adwaita Dark + Pro Theme ธีมโปร + Pro Medium Gray Theme ธีม Pro สีเทากลาง + Pro Light Gray Theme ธีม Pro สีเทาอ่อน + Pro Light Theme ธีมแสงโปร + +# Workspaces + Workspace… พื้นที่ทำงาน… + Essentials สิ่งจำเป็น + New Workspace… พื้นที่ทำงานใหม่… + Delete Workspace… ลบพื้นที่ทำงาน… + Reset Workspace รีเซ็ตพื้นที่ทำงาน + Reset Tool รีเซ็ตเครื่องมือ + Reset All Tools รีเซ็ตเครื่องมือทั้งหมด + Reset all tools to their default settings? รีเซ็ตเครื่องมือทั้งหมดเป็นการตั้งค่าเริ่มต้นหรือไม่ + Lock Workspace ล็อคพื้นที่ทำงาน + Photography การถ่ายภาพ + Graphic and web กราฟิกและเว็บ + Painting จิตรกรรม + Motion การเคลื่อนไหว + Minimalistic เรียบง่าย + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. ตำแหน่งแผงจะถูกบันทึกไว้ในพื้นที่ทำงาน แป้นพิมพ์ลัด เมนู และแถบเครื่องมือเป็นทางเลือก + Keyboard Shortcuts แป้นพิมพ์ลัด + Keyboard Shortcuts and Menus แป้นพิมพ์ลัดและเมนู + Menus เมนู + More panels แผงเพิ่มเติม + More documents เอกสารเพิ่มเติม + Collapse All Groups ยุบกลุ่มทั้งหมด + Expand All Groups ขยายกลุ่มทั้งหมด + Collapse ยุบ + Expand ขยาย + Collapse Panel Group ยุบกลุ่มแผง + Expand Panel Group ขยายกลุ่มแผง + Close Tab Group ปิดกลุ่มแท็บ + +# New Document dialog + PRESET DETAILS รายละเอียดที่ตั้งไว้ล่วงหน้า + BLANK DOCUMENT PRESETS ({n}) การตั้งค่าเอกสารเปล่า ({n}) + Create a new document or open an existing file. สร้างเอกสารใหม่หรือเปิดไฟล์ที่มีอยู่ + Drop an image or PSD anywhere to open it. วางภาพหรือ PSD ที่ใดก็ได้เพื่อเปิด + Use File › Open, or paste a copied image with {paste}. ใช้ไฟล์ › เปิดหรือวางภาพที่คัดลอกด้วย {paste} + Native file drag-and-drop is unavailable การลากและวางไฟล์เนทิฟไม่พร้อมใช้งาน + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland ยังไม่รองรับการลากและวางไฟล์เนทิฟ ใช้ไฟล์ › เปิดหรือคัดลอกภาพในตัวจัดการไฟล์ของคุณแล้ววางด้วย {paste} + To drop files, start PhotoCraft under XWayland: `{command}` หากต้องการวางไฟล์ ให้เริ่ม PhotoCraft ภายใต้ XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. หรือตั้งค่า Preferences > Performance > Linux display server เป็น X11: PhotoCraft จะเริ่มทำงานภายใต้ XWayland เสมอ + Edit › Paste แก้ไข › วาง + Photo ภาพ + Print พิมพ์ + Art & Illustration ศิลปะและภาพประกอบ + Mobile มือถือ + Film & Video ภาพยนตร์และวิดีโอ + Default PhotoCraft Size ขนาด PhotoCraft เริ่มต้น + Clipboard คลิปบอร์ด + Photo (2 × 3 in) ภาพ (2 × 3 นิ้ว) + Photo (4 × 6 in) ภาพ (4 × 6 นิ้ว) + Photo (5 × 7 in) ภาพ (5 × 7 นิ้ว) + Photo (8 × 10 in) ภาพ (8 × 10 นิ้ว) + Landscape ภูมิทัศน์ + Portrait แนวตั้ง + Recent Items รายการล่าสุด + Save Preset… บันทึกค่าที่ตั้งล่วงหน้า… + Delete Preset… ลบค่าที่ตั้งล่วงหน้า… + Preset Name: ชื่อที่ตั้งไว้ล่วงหน้า: + Width and Height ความกว้างและความสูง + Background Contents เนื้อหาพื้นหลัง + Custom background color สีพื้นหลังที่กำหนดเอง + White ขาว + Background Color สีพื้นหลัง + Transparent โปร่งใส + Color Mode โหมดสี + Depth ความลึก + Color Profile โปรไฟล์สี + Create สร้าง + Document เอกสาร + Units หน่วย + Pixels พิกเซล + Inches นิ้ว + Centimeters เซนติเมตร + Millimeters มิลลิเมตร + Points คะแนน + Picas ปิกัส + Columns คอลัมน์ + +# Placeholders, tooltips, status and keyboard hints + {name} options {name} options + Align {what} Align {what} + Untagged {mode} Untagged {mode} + Invalid {mode} profile โปรไฟล์ {mode} ไม่ถูกต้อง + Unnamed {mode} profile โปรไฟล์ {mode} ที่ไม่มีชื่อ + Constraints ({n}) ข้อจำกัด ({n}) + Count: {n} นับ: {n} + Click to add a point · drag off or {key}-click or Delete to remove คลิกเพื่อเพิ่มจุด · ลากออก หรือ {key}-คลิก หรือ ลบ เพื่อลบ + Click to apply, {key}-click to clear overrides, double-click for Style Options คลิกเพื่อใช้ {key}-คลิกเพื่อล้างการแทนที่ ดับเบิลคลิกเพื่อดูตัวเลือกสไตล์ + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel คลิก: มุม · ลาก: ราบรื่น · คลิกจุดแรก: ปิด · {key} เสร็จสิ้น · ยกเลิก Esc + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel คลิก: มุม · ลาก: ราบรื่น · {alt}-drag: ตัวแบ่ง · คลิกหรือลากจุดแรก: ปิด · {key} เสร็จสิ้น · ยกเลิก Esc + Commit Perspective Warp ({key}) คอมมิต Perspective Warp ({key}) + Commit Puppet Warp ({key}) กระทำ Puppet Warp ({key}) + Commit any current edits ({key}) ยืนยันการแก้ไขปัจจุบัน ({key}) + Commit transform ({key}) ยอมรับการแปลง ({key}) + Commit warp ({key}) คอมมิตวิปริต ({key}) + Cancel any current edits (Esc) ยกเลิกการแก้ไขปัจจุบัน (Esc) + Cancel transform (Esc) ยกเลิกการแปลง (Esc) + Cancel warp (Esc) ยกเลิกการวาร์ป (Esc) + Do you want to save the changes you made to “{name}” before closing? คุณต้องการบันทึกการเปลี่ยนแปลงที่คุณทำกับ “{name}” ก่อนปิดหรือไม่ + Revert “{name}” to the last saved version? Your changes will be lost. เปลี่ยนกลับ “{name}” เป็นเวอร์ชันที่บันทึกไว้ล่าสุดหรือไม่ การเปลี่ยนแปลงของคุณจะหายไป + {key} to subtract {key} ที่จะลบ + ↑↓ navigate {key} run esc close ↑↓ นำทาง {key} วิ่ง esc ปิด + Click to restore the current colour คลิกเพื่อคืนค่าสีปัจจุบัน + Click with the Note tool to add a note. คลิกด้วยเครื่องมือ Note เพื่อเพิ่มบันทึก + Drag a rectangle around the object ลากสี่เหลี่ยมรอบๆ วัตถุ + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. ลากบนภาพเพื่อเพิ่มข้อจำกัด เลื่อนแนวนอน/แนวตั้ง; คลิกขวาที่ข้อจำกัดเพื่อเปลี่ยนแปลงหรือลบ + Drag the arrow to set the angle, the dots to set the roundness ลากลูกศรเพื่อกำหนดมุม ลากจุดเพื่อกำหนดความกลม + Create new pattern from the selection สร้างรูปแบบใหม่จากส่วนที่เลือก + Create new gradient สร้างการไล่ระดับสีใหม่ + Create new count group สร้างกลุ่มการนับใหม่ + Search commands, tools and panels… ค้นหาคำสั่ง เครื่องมือ และพาเนล… + Search Brushes ค้นหาแปรง + Search fonts ค้นหาแบบอักษร + No matching commands ไม่มีคำสั่งที่ตรงกัน + No document ไม่มีเอกสาร + Pixel Layer เลเยอร์พิกเซล + Type Layer เลเยอร์ตัวอักษร + Shape Layer เลเยอร์รูปร่าง + Group Layer เลเยอร์กลุ่ม + Fill Layer เติมเลเยอร์ + Smart Object Layer เลเยอร์วัตถุอัจฉริยะ + Adjustment Layer เลเยอร์ปรับแต่ง + {name} Properties {name} Properties + Move Tool เครื่องมือย้าย + Could not use the move tool because the layer is locked. ไม่สามารถใช้เครื่องมือย้ายได้เนื่องจากเลเยอร์ถูกล็อค + Could not complete the Free Transform command because the layer is locked. ไม่สามารถดำเนินการคำสั่ง Free Transform ได้เนื่องจากเลเยอร์ถูกล็อก + Rectangular Marquee Tool เครื่องมือปะรำสี่เหลี่ยม + Elliptical Marquee Tool เครื่องมือปะรำวงรี + Single Row Marquee Tool เครื่องมือปะรำแถวเดียว + Single Column Marquee Tool เครื่องมือปะรำคอลัมน์เดียว + Brush Tool เครื่องมือแปรง + Pencil Tool เครื่องมือดินสอ + Eraser Tool เครื่องมือยางลบ + Background Eraser Tool เครื่องมือยางลบพื้นหลัง + Magic Eraser Tool เครื่องมือยางลบวิเศษ + Eyedropper Tool เครื่องมือหยดสี + Note Tool เครื่องมือหมายเหตุ + Lasso Tool เครื่องมือบ่วง + Polygonal Lasso Tool เครื่องมือ Lasso เหลี่ยม + Magic Wand Tool เครื่องมือไม้กายสิทธิ์ + Crop Tool เครื่องมือครอป + Slice Tool เครื่องมือชิ้น + Slice Select Tool เครื่องมือเลือกชิ้น + Gradient Tool เครื่องมือไล่ระดับสี + Paint Bucket Tool เครื่องมือถังสี + Horizontal Type Tool เครื่องมือตัวอักษรแนวนอน + Horizontal Type Mask Tool เครื่องมือมาสก์ตัวอักษรแนวนอน + Vertical Type Mask Tool เครื่องมือมาสก์ตัวอักษรแนวตั้ง + Hand Tool เครื่องมือมือ + Zoom Tool เครื่องมือซูม + Spot Healing Brush Tool เครื่องมือแปรงรักษาเฉพาะจุด + Healing Brush Tool เครื่องมือแปรงรักษา + Clone Stamp Tool เครื่องมือแสตมป์โคลน + History Brush Tool เครื่องมือแปรงประวัติศาสตร์ + Blur Tool เครื่องมือเบลอ + Sharpen Tool เครื่องมือลับคม + Smudge Tool เครื่องมือรอยเปื้อน + Dodge Tool เครื่องมือดอดจ์ + Burn Tool เครื่องมือเบิร์น + Sponge Tool เครื่องมือฟองน้ำ + Quick Selection Tool เครื่องมือการเลือกด่วน + Object Selection Tool เครื่องมือเลือกวัตถุ + Pen Tool เครื่องมือปากกา + Convert Point Tool แปลงเครื่องมือจุด + Path Selection Tool เครื่องมือเลือกเส้นทาง + Direct Selection Tool เครื่องมือการเลือกโดยตรง + Add Anchor Point Tool เพิ่มเครื่องมือจุดยึด + Delete Anchor Point Tool ลบเครื่องมือจุดยึด + Click a segment to add an anchor point · {alt}-click an anchor to delete it คลิกส่วนเพื่อเพิ่มจุดยึด · {alt}-คลิกจุดยึดเพื่อลบ + Click an anchor point to delete it · {alt}-click a segment to add one คลิกจุดยึดเพื่อลบ · {alt}-คลิกส่วนที่ต้องการเพิ่ม + Rectangle Tool เครื่องมือสี่เหลี่ยมผืนผ้า + Ellipse Tool เครื่องมือวงรี + Triangle Tool เครื่องมือสามเหลี่ยม + Polygon Tool เครื่องมือรูปหลายเหลี่ยม + Line Tool เครื่องมือเส้น + Custom Shape Tool เครื่องมือรูปร่างแบบกำหนดเอง + Pixel พิกเซล + Group กลุ่ม + Adjustment การปรับ + Smart Object สมาร์ตออบเจกต์ + Drag to move the active layer ลากเพื่อย้ายเลเยอร์ที่ใช้งานอยู่ + Drag to pan · hold Space with any tool ลากเพื่อเลื่อน · กด Space ด้วยเครื่องมือใดก็ได้ + Drag to draw a gradient ลากเพื่อวาดการไล่ระดับสี + Click to fill similar colours คลิกเพื่อเติมสีที่คล้ายกัน + Click to add text คลิกเพื่อเพิ่มข้อความ + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels ลากกล่องครอบตัด · ลากเข้าไปข้างในเพื่อย้าย · ปรับขนาดขอบ (อัตราส่วน {ratio}, กึ่งกลาง {centre}) · เคลื่อนที่ในอวกาศขณะวาด · ลูกศรดัน · การวางแนว X สลับ · คอมมิต {commit} · ยกเลิก Esc + {mode} Color · {bits} bit {mode} Color · {bits} bit + Drag to move the layer ลากเพื่อย้ายเลเยอร์ + Fit Screen พอดีกับหน้าจอ + Fill Screen เติมหน้าจอ + Scrubby Zoom สครับบี้ซูม + Auto Erase ลบอัตโนมัติ + Color Adaptation การปรับสี + Preserve Transparency รักษาความโปร่งใส + Reverse ย้อนกลับ + Align with layer จัดแนวกับเลเยอร์ + Join us on Discord เข้าร่วมกับเราบน Discord + PhotoCraft website เว็บไซต์PhotoCraft + ArtCraft อาร์ตคราฟต์ + GitHub GitHub + No character style ไม่มีสไตล์ตัวละคร + No author ไม่มีผู้เขียน + Not in Dictionary: ไม่ได้อยู่ในพจนานุกรม: + Suggestions: คำแนะนำ: + Spell check complete. ตรวจตัวสะกดเสร็จแล้ว + Check All Layers ตรวจสอบทุกเลเยอร์ + Select a type layer to edit its character and paragraph settings. เลือกเลเยอร์ตัวอักษรเพื่อแก้ไขการตั้งค่าอักขระและย่อหน้า + Select text to apply a character style เลือกข้อความเพื่อใช้ลักษณะอักขระ + Redefine style by current selection กำหนดสไตล์ใหม่โดยการเลือกปัจจุบัน + Duplicate style สไตล์ที่ซ้ำกัน + Delete style ลบสไตล์ + Style Options ตัวเลือกสไตล์ + Style Options… ตัวเลือกสไตล์… + Close Style Options ปิดตัวเลือกสไตล์ + Appearance ลักษณะที่ปรากฏ + Text Position: ตำแหน่งข้อความ: + Text color สีข้อความ + Uses the foreground and background colours. ใช้สีพื้นหน้าและพื้นหลัง + Answer the unsaved-changes prompt first ตอบคำถามการเปลี่ยนแปลงที่ไม่ได้บันทึกก่อน + Unsaved changes การเปลี่ยนแปลงที่ไม่ได้บันทึก + Don't Save อย่าบันทึก + No ไม่ + Yes ใช่ + These settings aren't available in PhotoCraft yet. การตั้งค่าเหล่านี้ยังไม่มีใน PhotoCraft + Shortcut removed. ลบทางลัดแล้ว + All shortcuts reset to PhotoCraft defaults. ทางลัดทั้งหมดรีเซ็ตเป็นค่าเริ่มต้นของ PhotoCraft + Add Shortcut เพิ่มทางลัด + Delete Shortcut ลบทางลัด + Press keys… กดปุ่ม… + shortcut ทางลัด + command or shortcut คำสั่งหรือทางลัด + current ปัจจุบัน + image file path เส้นทางไฟล์ภาพ + modified แก้ไข + name ชื่อ + new ใหม่ + Go to Layer ไปที่เลเยอร์ + Open a document to select a colour range. เปิดเอกสารเพื่อเลือกช่วงสี + Saved บันทึกแล้ว + Reveal in Finder เปิดเผยใน Finder + Show in Explorer แสดงใน Explorer + Show in Folder แสดงในโฟลเดอร์ + Before (Y) ก่อน (ญ) + Save Settings… บันทึกการตั้งค่า… + Load Settings… โหลดการตั้งค่า… + Save these settings as a Camera Raw .xmp file บันทึกการตั้งค่าเหล่านี้เป็นไฟล์ Camera Raw .xmp + Load settings from a Camera Raw .xmp file โหลดการตั้งค่าจากไฟล์ Camera Raw .xmp + Loaded Camera Raw settings from {file} โหลดการตั้งค่า Camera Raw จาก {file} + Loaded Camera Raw settings from {file} ({n} not supported) โหลดการตั้งค่า Camera Raw จาก {file} (ไม่รองรับ {n}) + After (X) หลัง (เอ็กซ์) + Original ต้นฉบับ + Current ปัจจุบัน + {added}, {removed} subtracted {added}, {removed} ลบแล้ว + Filter: ฟิลเตอร์: + +# Transform / warp overlays + Free Transform แปลงอิสระ + Free Transform a Copy แปลงสำเนาฟรี + Transform แปลงร่าง + Transform Selection แปลงการเลือก + Switch between free transform and warp modes สลับระหว่างโหมดการแปลงอิสระและโหมดวาร์ป + Perspective เปอร์สเปกทีฟ + Skew เอียง + Distort บิดเบี้ยว + Rotate: หมุน: + Flip พลิก + Warp: วาร์ป: + Warp บิด + Bend: โค้งงอ: + Reference point location ตำแหน่งจุดอ้างอิง + Toggle reference point สลับจุดอ้างอิง + Free ฟรี + Arc อาร์ค + Arch อาร์ค + Bulge นูน + Shell Lower เชลล์ตัวล่าง + Shell Upper เชลล์ตอนบน + Flag ธง + Fish ปลา + Wave คลื่น + Rise ลุกขึ้น + Fisheye ฟิชอาย + Inflate พองตัว + Twist บิด + Squeeze บีบ + Corner radius รัศมีมุม + Split Warp Crosswise แยก Warp ตามขวาง + Split Warp Horizontally แยก Warp ในแนวนอน + Split Warp Vertically แยก Warp ในแนวตั้ง + Remove Warp Split ลบ Warp Split + Puppet Warp บิดภาพ + Puppet Warp is not active Puppet Warp ไม่ทำงาน + Perspective Warp บิดภาพเปอร์สเปกทีฟ + Perspective Warp is not active Perspective Warp ไม่ทำงาน + Perspective Warp: draw planes along the image's perspective, then switch to Warp การบิดงอเปอร์สเปคทีฟ: วาดระนาบตามเปอร์สเปคทีฟของภาพ จากนั้นสลับไปที่การบิดงอ + Remove Planes ลบระนาบ + Edit Pins แก้ไขพิน + Add Pins เพิ่มพิน + Delete Pins ลบพิน + Remove All Pins ลบพินทั้งหมด + Remove Tool ลบเครื่องมือ + Pin Depth: ความลึกของพิน: + Bring the selected pin forward นำพินที่เลือกไปข้างหน้า + Send the selected pin backward ส่งพินที่เลือกไปข้างหลัง + Rotate Auto หมุนอัตโนมัติ + Rotate Fixed หมุนคงที่ + Adaptive Wide Angle isn't open มุมกว้างแบบปรับอัตโนมัติไม่ได้เปิดอยู่ + Liquify is not open ของเหลวไม่เปิด + +# Vanishing Point + Create Plane สร้างระนาบ + Edit Plane แก้ไขระนาบ + Select Plane เลือกระนาบ + Measure วัด + Heal รักษา + Off ปิด + Symmetry Off ปิดสมมาตร + Heal Off ปิดการรักษา + Heal On รักษาต่อ + Heal Auto ฮีลออโต้ + Blend ผสมผสาน + Brush Size ขนาดแปรง + Heal Color สีรักษา + +# Misc dialog strings + Mask มาสก์ + Channel ช่อง + Rectangle สี่เหลี่ยมผืนผ้า + Subtract ลบ + Multiply คูณ + Divide แบ่ง + Result ผลลัพธ์ + New Document เอกสารใหม่ + New Document With Mask เอกสารใหม่พร้อมมาสก์ + New Layer With Mask เลเยอร์ใหม่พร้อมมาสก์ + New Layer เลเยอร์ใหม่ + Preset Name ชื่อที่ตั้งไว้ล่วงหน้า + No saved presets yet. ยังไม่มีค่าที่ตั้งไว้ล่วงหน้าที่บันทึกไว้ + New Channel ช่องใหม่ + Selection การคัดเลือก + Save Selection บันทึกการเลือก + Load Selection การเลือกโหลด + Duplicate ทำซ้ำ + Fast รวดเร็ว + Accurate แม่นยำ + Add to เพิ่มไปที่ + Subtract from ลบออกจาก + Intersect with ตัดกับ + +# Blend modes + Pass Through ผ่าน + Dissolve ละลาย + Darken ทำให้มืดลง + Color Burn ทับสี + Linear Burn การเผาไหม้เชิงเส้น + Darker Color สีเข้มขึ้น + Lighten ทำให้สว่างขึ้น + Screen สกรีน + Color Dodge ทับแสงสี + Linear Dodge (Add) ลิเนียร์ดอดจ์ (เพิ่ม) + Lighter Color สีอ่อนกว่า + Overlay โอเวอร์เลย์ + Soft Light แสงนุ่ม + Hard Light แสงแข็ง + Vivid Light แสงสดใส + Linear Light แสงเชิงเส้น + Pin Light พินไลท์ + Hard Mix ฮาร์ดมิกซ์ + Difference แตกต่าง + Exclusion ยกเว้น + Luminosity ความสว่าง + +# Saved views and other bits + Capture Comp จับภาพคอมพ์ + No Layer Comps ไม่มีเลเยอร์ Comps + +# Plurals +@plural {n} item|{n} items {n} รายการ +@plural {n} Layer|{n} Layers {n} เลเยอร์ +@plural {n} layer|{n} layers {n} เลเยอร์ +@plural Group · {n} layer|Group · {n} layers กลุ่ม · {n} เลเยอร์ +@plural {n} sample|{n} samples {n} ตัวอย่าง + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… ตัวเลือกการแสดงตัวอย่าง 32 บิต… + 90° Clockwise 90° ตามเข็มนาฬิกา + 90° Counter Clockwise 90° ทวนเข็มนาฬิกา + AI Integrations… การบูรณาการ AI… + Again อีกครั้ง + Transform Again on a Copy แปลงร่างอีกครั้งด้วยการคัดลอก + Anamorphic 2:1 (2) อนามอร์ฟิก 2:1 (2) + Arbitrary… กำหนดเอง… + Artboard Guides คำแนะนำอาร์ตบอร์ด + Artboard from Group… อาร์ตบอร์ดจากกลุ่ม… + Artboard from Layers… อาร์ตบอร์ดจากเลเยอร์… + Artboards to Files… อาร์ตบอร์ดเป็นไฟล์… + Artboards to PDF… อาร์ตบอร์ดเป็น PDF… + Artboard… อาร์ตบอร์ด… + Border… ขอบ… + Bottom Edges ขอบล่าง + Browse… เรียกดู… + Source Files ไฟล์ต้นฉบับ + Use: ใช้: + Files ไฟล์ + Folder โฟลเดอร์ + Add Open Files เพิ่มไฟล์เปิด + Sort By Name เรียงตามชื่อ + Attempt to Automatically Align Source Images พยายามจัดแนวภาพต้นฉบับโดยอัตโนมัติ + Create Smart Object after Loading Layers สร้าง Smart Object หลังจากโหลดเลเยอร์ + Canvas Guides คู่มือผ้าใบ + Character Styles Panel แผงสไตล์ตัวละคร + Clear Canvas Guides คำแนะนำผ้าใบที่ชัดเจน + Clear Selected Artboard Guides ล้างคำแนะนำ Artboard ที่เลือก + Color Blindness — Deuteranopia-type ตาบอดสี - ประเภท Deuteranopia + Color Blindness — Protanopia-type ตาบอดสี — ประเภท Protanopia + Color Table… ตารางสี… + Combine Shapes รวมรูปร่าง + Contract… หด… + Convert to Embedded แปลงเป็นแบบฝัง + Convert to Layers แปลงเป็นเลเยอร์ + Convert to Linked… แปลงเป็นลิงก์… + Count นับ + Create Layer สร้างเลเยอร์ + Custom Pixel Aspect Ratio… อัตราส่วนพิกเซลแบบกำหนดเอง… + Cycle Screen Mode โหมดหน้าจอวงจร + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC ไวด์สกรีน (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) จอไวด์สกรีน D1/DV PAL (1.46) + DVCPRO HD 1080/HDV 1080 (1.5) ดีวีซีโปร HD 1080/HDV 1080 (1.5) + De-Interlace… ดีอินเทอร์เลซ… + Default Features คุณสมบัติเริ่มต้น + Delete All Empty Layers ลบเลเยอร์ว่างทั้งหมด + Desaturate ลดความอิ่มตัว + Displace… แทนที่… + Distribute แจกจ่าย + Document Bounds ขอบเขตเอกสาร + Edit in Quick Mask Mode แก้ไขในโหมด Quick Mask + Enhanced Controls… การควบคุมที่ได้รับการปรับปรุง… + Entropy เอนโทรปี + Exclude Overlapping Shapes ไม่รวมรูปร่างที่ทับซ้อนกัน + Exit ออก + Expand… ขยาย… + Extra Large ใหญ่พิเศษ + File Info… ข้อมูลไฟล์… + Fit Artboard on Screen ติดตั้ง Artboard บนหน้าจอ + Fit Layer(s) on Screen พอดีเลเยอร์บนหน้าจอ + Flatten All Layer Effects ทำให้เอฟเฟ็กต์เลเยอร์ทั้งหมดเรียบขึ้น + Flatten All Masks ทำให้มาสก์ทั้งหมดเรียบขึ้น + Flatten Image แบนภาพ + Float All in Windows ลอยทั้งหมดใน Windows + Float in Window ลอยอยู่ในหน้าต่าง + Frame from Layers กรอบจากเลเยอร์ + From Transparency จากความโปร่งใส + Gamut Warning คำเตือนขอบเขต + Global Light… โกลบอลไลท์… + Gradient… ไล่ระดับสี… + Graphic and Web กราฟิกและเว็บ + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + Hidden Layers เลเยอร์ที่ซ่อนอยู่ + Hide All Effects ซ่อนเอฟเฟกต์ทั้งหมด + Horizontal Centers ศูนย์แนวนอน + Huge ใหญ่โต + Indexed Color… สีที่จัดทำดัชนี… + Integrations… บูรณาการ… + Intersect Shape Areas ตัดกันพื้นที่รูปร่าง + Kurtosis เคิร์ตโทซิส + Large ใหญ่ + Layer Edges ขอบเลเยอร์ + Left Edges ขอบซ้าย + Lens Blur… เลนส์เบลอ… + Lighting Effects… เอฟเฟกต์แสง… + Link Layers ลิงค์เลเยอร์ + Lock Layers… ล็อคเลเยอร์… + Mean หมายถึง + Merge Down รวมลง + Merge Group รวมกลุ่ม + Merge Layers รวมเลเยอร์ + Merge Shape Components ผสานส่วนประกอบรูปร่าง + Merge Visible รวมเลเยอร์ที่มองเห็น + Stamp Visible มองเห็นแสตมป์ได้ + Select Layer Above เลือกเลเยอร์ด้านบน + Select Layer Below เลือกเลเยอร์ด้านล่าง + Select Top Layer เลือกเลเยอร์บนสุด + Select Bottom Layer เลือกเลเยอร์ล่างสุด + Add Layer Above to Selection เพิ่มเลเยอร์ด้านบนให้กับส่วนที่เลือก + Add Layer Below to Selection เพิ่มเลเยอร์ด้านล่างลงในส่วนที่เลือก + Stamp Down ประทับตราลง + Mesh ตาข่าย + Middle Eastern & South Asian Composer นักแต่งเพลงชาวตะวันออกกลางและเอเชียใต้ + Modifier Keys คีย์ตัวปรับแต่ง + Modify แก้ไข + NTSC Colors สี NTSC + Notes… หมายเหตุ… + Other อื่นๆ + Other… อื่นๆ… + Paragraph Styles Panel แผงสไตล์ย่อหน้า + Paths to Illustrator… เส้นทางสู่ Illustrator… + Pattern Preview ดูตัวอย่างรูปแบบ + Pattern… รูปแบบ… + Pixel Art ศิลปะพิกเซล + Pixel Art Preview การแสดงตัวอย่างศิลปะพิกเซล + Pixel Grid ตารางพิกเซล + Pixelate พิกเซลเลต + Place Scale Marker… วางเครื่องหมายมาตราส่วน… + Plug-ins ปลั๊กอิน + Print One Copy พิมพ์หนึ่งสำเนา + Print… พิมพ์… + Range พิสัย + Raw Defaults… ค่าเริ่มต้นดิบ… + Relink to File… เชื่อมโยงไปยังไฟล์… + Relight… รีไลท์… + Render เรนเดอร์ + Replace All Missing Fonts แทนที่แบบอักษรที่หายไปทั้งหมด + Resolve Missing Fonts… แก้ไขแบบอักษรที่หายไป… + Reveal Selection เปิดเผยการคัดเลือก + Right Edges ขอบขวา + Selection Edges ขอบการเลือก + Shapes รูปร่าง + Show Extras Options… แสดงตัวเลือกพิเศษ… + Skewness ความเบ้ + Small เล็ก + Smart Guides ไกด์อัจฉริยะ + Smart Objects สมาร์ตออบเจกต์ + Smooth… ปรับเรียบ… + Solid Color… สีทึบ… + Square สี่เหลี่ยมจัตุรัส + Stack Mode โหมดสแต็ค + Choose Stack Mode: เลือกโหมดสแต็ก: + Standard Deviation ส่วนเบี่ยงเบนมาตรฐาน + Statistics… สถิติ… + Stylistic Alternates โวหารสำรอง + Stylize จัดสไตล์ + Subtract Front Shape ลบรูปร่างด้านหน้า + Summation การสรุป + Target Path เส้นทางเป้าหมาย + Tile All Horizontally เรียงต่อกันในแนวนอนทั้งหมด + Tile All Vertically เรียงต่อกันในแนวตั้งทั้งหมด + Toggle Last State สลับสถานะสุดท้าย + Toolbar… แถบเครื่องมือ… + Tools… เครื่องมือ… + Top Edges ขอบด้านบน + Unite Shapes รวมรูปร่าง + Update All Modified Content อัปเดตเนื้อหาที่แก้ไขทั้งหมด + Update All Text Layers อัปเดตเลเยอร์ข้อความทั้งหมด + Update Modified Content อัปเดตเนื้อหาที่แก้ไข + Variance ความแปรปรวน + Vertical Centers ศูนย์แนวตั้ง + Video Frames to Layers… เฟรมวิดีโอเป็นเลเยอร์… + WIA Support… การสนับสนุน WIA… + Working CMY Plate แผ่น CMY ทำงาน + +# tl! literals (third pass) + Absolute แน่นอน + Action การดำเนินการ + Adaptive ปรับตัว + Add Stop เพิ่มหยุด + Add disk เพิ่มดิสก์ + Align and Distribute จัดตำแหน่งและกระจาย + All Slices ชิ้นทั้งหมด + All User Slices ส่วนผู้ใช้ทั้งหมด + Alt Alt + Artboard อาร์ตบอร์ด + Artboard Background Color สีพื้นหลังอาร์ตบอร์ด + Auto Hide ซ่อนอัตโนมัติ + Automatically level near-horizontal line segments ปรับระดับส่วนของเส้นตรงใกล้แนวนอนโดยอัตโนมัติ + Automatically straighten and level ยืดและปรับระดับอัตโนมัติ + Automatically straighten near-vertical line segments ปรับส่วนของเส้นตรงใกล้แนวตั้งให้ตรงโดยอัตโนมัติ + Balance ยอดคงเหลือ + Baseline พื้นฐาน + Bicubic ไบคิวบิค + Bicubic (smooth gradients) Bicubic (การไล่ระดับสีเรียบ) + Automatic อัตโนมัติ + Bicubic Smoother (enlargement) Bicubic Smoother (ขยาย) + Bicubic Sharper (reduction) เครื่องลับ Bicubic (ลด) + Bilinear ไบลิเนียร์ + Black, White ดำขาว + Blue, Red, Yellow น้ำเงินแดงเหลือง + Bottom ก้น + Brightness Jitter ความสว่างกระวนกระวายใจ + Brush Reconstruct Options ตัวเลือกการสร้างแปรงใหม่ + Cache Level: ระดับแคช: + Canvas ผ้าใบ + Capture จับภาพ + Center Crop Marks เครื่องหมายครอบตัดตรงกลาง + Centre the gradient (offset 0, 0) จัดกึ่งกลางการไล่ระดับสี (ออฟเซ็ต 0, 0) + Change Screen Mode (F) เปลี่ยนโหมดหน้าจอ (F) + Change To: เปลี่ยนเป็น: + Clear All ล้างทั้งหมด + Clear Override ล้างการแทนที่ + Clear Samples ล้างตัวอย่าง + Cmd คำสั่ง + Color Grading การจัดระดับสี + Color Management การจัดการสี + Color Priority ลำดับความสำคัญของสี + Color Range preview การแสดงตัวอย่างช่วงสี + Colorize ปรับสี + Constant ค่าคงที่ + Content-Aware การรับรู้เนื้อหา + Convert to sRGB แปลงเป็น sRGB + Copies สำเนา + Copper ทองแดง + Corner Crop Marks เครื่องหมายครอบตัดมุม + Correction การแก้ไข + Count Jitter นับกระวนกระวายใจ + Create Texture สร้างพื้นผิว + Create a new count group สร้างกลุ่มการตรวจนับใหม่ + Crop Factor ครอบตัดปัจจัย + Ctrl กดปุ่ม Ctrl + Custom Shapes รูปร่างที่กำหนดเอง + Cyan สีฟ้า + Cyan · Red สีฟ้า · สีแดง + Darks ความมืด + Default printer เครื่องพิมพ์เริ่มต้น + Default restored. กู้คืนค่าเริ่มต้นแล้ว + Delete Group ลบกลุ่ม + Delete count group ลบกลุ่มการนับ + Delete effect layer ลบเลเยอร์เอฟเฟกต์ + Delete note ลบบันทึก + Delete tool preset ลบการตั้งค่าล่วงหน้าของเครื่องมือ + Depth Jitter กระวนกระวายใจความลึก + Discontiguous ไม่ต่อเนื่องกัน + Discord ความไม่ลงรอยกัน + Display P3 แสดงผล P3 + Display Text ข้อความที่แสดง + Divide… แบ่ง… + Do you want to save the changes you made to “{name}” before quitting? คุณต้องการบันทึกการเปลี่ยนแปลงที่คุณทำกับ “{name}” ก่อนที่จะออกหรือไม่ + Edit Toolbar… แก้ไขแถบเครื่องมือ… + Edit in Quick Mask Mode (Q) แก้ไขในโหมด Quick Mask (Q) + Edit in Standard Mode (Q) แก้ไขในโหมดมาตรฐาน (Q) + Embedded Profile Mismatch โปรไฟล์ที่ฝังไม่ตรงกัน + Enhance Edge ปรับปรุงขอบ + Exact แน่นอน + Expansion: การขยายตัว: + Exposure and Gamma การเปิดรับแสงและแกมมา + Factor: ปัจจัย: + Fewer Points คะแนนน้อยลง + File Settings การตั้งค่าไฟล์ + Finger Painting ลายนิ้วมือ + Fit in View พอดีในมุมมอง + Fixed แก้ไขแล้ว + Foreground to Background เบื้องหน้าเป็นเบื้องหลัง + Foreground/Background Jitter กระวนกระวายใจเบื้องหน้า/พื้นหลัง + Format รูปแบบ + Frame from Layers… เฟรมจากเลเยอร์… + Full Spherical ทรงกลมเต็ม + Gamma Correction การแก้ไขแกมมา + Gamma: แกมมา: + Global ทั่วโลก + Grain ธัญพืช + Graphics กราฟิก + Gray สีเทา + Heart หัวใจ + Hide Auto Slices ซ่อนชิ้นอัตโนมัติ + Highlight Compression ไฮไลท์การบีบอัด + Highlight Priority เน้นลำดับความสำคัญ + Highlights ไฮไลท์ + Home บ้าน + Hue Jitter เว้ จิตเตอร์ + Image Size: ขนาดภาพ: + Imported นำเข้า + Input: อินพุต: + Interlaced อินเทอร์เลซ + Invert All สลับกลับทั้งหมด + Label Size ขนาดฉลาก + Labels ป้ายกำกับ + Lanczos (sharp) Lanczos (คม) + Level ระดับ + Lights ไฟ + Limits: ขีดจำกัด: + Link the mask to the layer เชื่อมโยงมาสก์กับเลเยอร์ + Localized Color Clusters กลุ่มสีที่มีการแปล + Luminance ความสว่าง + Magenta สีม่วงแดง + Magenta · Green สีม่วงแดง · สีเขียว + Marker Size ขนาดเครื่องหมาย + Mask Options ตัวเลือกมาสก์ + Masking การกำบัง + Master อาจารย์ + Mean: หมายถึง: + Median: ค่ามัธยฐาน: + Mesh Size ขนาดตาข่าย + Metadata ข้อมูลเมตา + Midpoint จุดกึ่งกลาง + Midtones มิดโทน + Midtones from: มิดโทนจาก: + Midtones to: มิดโทนถึง: + Minimum Depth ความลึกขั้นต่ำ + Mix Jitter ผสมกระวนกระวายใจ + Mode: โหมด: + Monochrome ขาวดำ + More Points คะแนนเพิ่มเติม + Move Group Down ย้ายกลุ่มลง + Move Group Up ย้ายกลุ่มขึ้น + Move effect layer down ย้ายเลเยอร์เอฟเฟกต์ลง + Move effect layer up ย้ายเลเยอร์เอฟเฟกต์ขึ้น + Move to ย้ายไปที่ + Nearest Neighbor เพื่อนบ้านที่ใกล้ที่สุด + Nearest Neighbor (hard edges) เพื่อนบ้านที่ใกล้ที่สุด (ขอบแข็ง) + New effect layer เลเยอร์เอฟเฟกต์ใหม่ + Next note หมายเหตุถัดไป + No Color Management ไม่มีการจัดการสี + Optimized ปรับให้เหมาะสม + Output Levels: ระดับเอาท์พุท: + Output: เอาท์พุท: + Paint Overlay การทาสีทับซ้อน + Pattern Size ขนาดรูปแบบ + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — โปรแกรมแก้ไขภาพแบบโอเพ่นซอร์สที่เขียนด้วยภาษา Rust + Position and Size ตำแหน่งและขนาด + Preserve Details เก็บรายละเอียด + Preserve Luminosity รักษาความส่องสว่าง + Previous note บันทึกก่อนหน้า + Printer เครื่องพิมพ์ + Printer Setup การตั้งค่าเครื่องพิมพ์ + Printing Marks เครื่องหมายการพิมพ์ + Progressive ก้าวหน้า + Promote โปรโมท + Protect Detail ปกป้องรายละเอียด + Protect Tones ปกป้องโทนเสียง + Proximity Match การจับคู่ความใกล้ชิด + Purity ความบริสุทธิ์ + Quality คุณภาพ + Recent ล่าสุด + Reconstruct สร้างใหม่ + Refine ปรับแต่ง + Registration Marks เครื่องหมายทะเบียน + Regular ปกติ + Remove stop ลบหยุด + Rename Group… เปลี่ยนชื่อกลุ่ม… + Rename Layer… เปลี่ยนชื่อเลเยอร์… + Restore All คืนค่าทั้งหมด + Restrictive (Web) จำกัด (เว็บ) + Rigid แข็ง + Roughness ความหยาบ + Roundness ความกลม + Rulers & Grids ไม้บรรทัดและกริด + Sample: foreground colour ตัวอย่าง: สีพื้นหน้า + Sampling: Background Swatch การสุ่มตัวอย่าง: สวอตช์พื้นหลัง + Sampling: Continuous การสุ่มตัวอย่าง: ต่อเนื่อง + Sampling: Once การสุ่มตัวอย่าง: ครั้งเดียว + Saturate อิ่มตัว + Saturation Jitter ความอิ่มตัวของสี กระวนกระวายใจ + Save HTML and Images บันทึก HTML และภาพ + Save as PDF บันทึกเป็น PDF + Scale to Fit Media ปรับขนาดให้พอดีกับสื่อ + Scatter กระจาย + Scratch disks รอยขีดข่วนดิสก์ + Selective คัดเลือก + Shadows เงา + Shape: รูปร่าง: + Sharpening การลับคม + Shift กะ + Show All Menu Items แสดงรายการเมนูทั้งหมด + Show Backdrop แสดงฉากหลัง + Show Mask โชว์มาสก์ + Show Mesh แสดงตาข่าย + Show or hide the Notes panel แสดงหรือซ่อนแผงบันทึกย่อ + Show the layer mask as an overlay แสดงเลเยอร์มาสก์เป็นภาพซ้อนทับ + Show: แสดง: + Sides ด้านข้าง + Sides: ด้านข้าง: + Slice Options… ตัวเลือกชิ้น… + Slices From Guides ชิ้นจากคำแนะนำ + Slices are locked (View › Lock Slices) ชิ้นถูกล็อค (ดู › ล็อคชิ้น) + Split: แยก: + Star ratio อัตราส่วนดาว + Std Dev: มาตรฐานการพัฒนา: + Straighten ยืดตรง + Straighten Layer ปรับเลเยอร์ให้ตรง + Strength: ความแข็งแกร่ง: + Stroke โรคหลอดเลือดสมอง + Stroke Catch-up จังหวะจับขึ้น + Stroke: โรคหลอดเลือดสมอง: + Location: ที่ตั้ง: + Inside ข้างใน + Outside ภายนอก + Tetrahedral จัตุรมุข + Texture Each Tip พื้นผิวแต่ละเคล็ดลับ +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor ตัวแก้ไขการไล่ระดับสี + Gradient picker ตัวเลือกการไล่ระดับสี + Save As Preset บันทึกเป็นค่าที่ตั้งล่วงหน้า + The gradient the Gradient tool paints with การไล่ระดับสีที่เครื่องมือไล่ระดับสีใช้ + Adds the current gradient to the Gradients panel เพิ่มการไล่ระดับสีปัจจุบันให้กับแผงการไล่ระดับสี + Gradient stops การไล่ระดับสีหยุด + Click to edit the gradient คลิกเพื่อแก้ไขการไล่ระดับสี + Tilt X เอียง X + Tilt Y เอียง Y + Toggle count group visibility สลับการเปิดเผยกลุ่มการนับ + Toggle text orientation สลับการวางแนวข้อความ + Tone: โทนเสียง: + Toolbar แถบเครื่องมือ + Top ด้านบน + Total: รวม: + Transparency ความโปร่งใส + Trilinear ไตรลิเนียร์ + Unlink the mask from the layer ยกเลิกการเชื่อมโยงมาสก์ออกจากเลเยอร์ + View Options ตัวเลือกมุมมอง + Violet, Orange สีม่วง,สีส้ม + Visible มองเห็นได้ + Weight น้ำหนัก + Weight: น้ำหนัก: + Wetness Jitter ความเปียกชื้น กระวนกระวายใจ + Whites คนผิวขาว + Yellow สีเหลือง + Yellow · Blue สีเหลือง · สีฟ้า + +# Preference dialog: generated setting labels and dropdown choices + Adobe อะโดบี + Adobe rgb อะโดบี RGB + Allow agent control อนุญาตให้ตัวแทนควบคุม + Allow scripts to connect อนุญาตให้สคริปต์เชื่อมต่อ + Always เสมอ + Always create smart objects when placing สร้างวัตถุอัจฉริยะเสมอเมื่อวาง + Animated zoom ซูมภาพเคลื่อนไหว + Apply auto tone ใช้โทนเสียงอัตโนมัติ + Ask ถาม + Ask before saving layered tiff ถามก่อนที่จะบันทึก TIFF แบบเลเยอร์ + Autosave บันทึกอัตโนมัติ + Autosave minutes บันทึกนาทีอัตโนมัติ + Beep when done ส่งเสียงบี๊บเมื่อเสร็จแล้ว + Bicubic automatic ไบคิวบิคอัตโนมัติ + Bicubic sharper Bicubic คมชัดยิ่งขึ้น + Bicubic smoother Bicubic เรียบเนียนยิ่งขึ้น + Bit depth ความลึกบิต + Brush picker ตัวเลือกแปรง + Brush preview color สีตัวอย่างแปรง + CPU (no GPU acceleration) CPU (ไม่มีการเร่งความเร็ว GPU) + Cache levels ระดับแคช + Cache tile size ขนาดไทล์แคช + Camera Raw Defaults ค่าเริ่มต้นCamera Raw + Canvas border ขอบผ้าใบ + Show canvas dots แสดงจุดผ้าใบ + Clip thumbnails to layer bounds คลิปภาพขนาดย่อให้เป็นขอบเขตของเลเยอร์ + Clip Thumbnails to Layer Bounds คลิปรูปขนาดย่อไปที่ Layer Bounds + Canvas color สีแคนวาส + Canvas custom color ผ้าใบสีตามสั่ง + Classic คลาสสิค + Color picker ตัวเลือกสี + Color space พื้นที่สี + Column width ความกว้างของคอลัมน์ + Concise กระชับ + Control port พอร์ตควบคุม + Convert to srgb แปลงเป็น srgb + Cursors เคอร์เซอร์ + Custom dark มืดแบบกำหนดเอง + Custom light แสงที่กำหนดเอง + Dark มืด + Dark gray สีเทาเข้ม + Dashed lines เส้นประ + Detailed รายละเอียด + DirectX 12 ไดเร็คเอ็กซ์ 12 + Disks ดิสก์ + Display p3 แสดงผล p3 + Dots จุด + Double click layer mask launches select and mask ดับเบิลคลิกเลเยอร์มาสก์เปิดตัวเลือกและมาสก์ + Drop shadow เงาตก + Dynamic color sliders แถบเลื่อนสีแบบไดนามิก + East asian เอเชียตะวันออก + Effect cache mb เอฟเฟกต์แคช mb + Enable flick panning เปิดใช้งานการเลื่อนแบบสะบัด + Enable floating document window docking เปิดใช้งานการเชื่อมต่อหน้าต่างเอกสารแบบลอย + Enable narrow options bar เปิดใช้งานแถบตัวเลือกแบบแคบ + Enhanced Controls การควบคุมที่ได้รับการปรับปรุง + Erase ลบ + Export clipboard ส่งออกคลิปบอร์ด + Extra large ใหญ่พิเศษ + File Handling การจัดการไฟล์ + File path เส้นทางไฟล์ + Fill new type layers with placeholder เติมเลเยอร์ตัวอักษรใหม่ด้วยตัวยึดตำแหน่ง + Font preview การแสดงตัวอย่างแบบอักษร + Full size tip ทิปขนาดเต็ม + GPU backend แบ็กเอนด์ GPU + Linux display server เซิร์ฟเวอร์แสดงผลลินุกซ์ + X11 X11 + Gamut warning color สีคำเตือนขอบเขต + Gamut warning opacity ความทึบของคำเตือน Gamut + General ทั่วไป + Enable Generator เปิดใช้งานเครื่องกำเนิดไฟฟ้า + Gif กิฟ + Glossy มันเงา + Grid color สีตาราง + Grid colors สีตาราง + Grid size ขนาดตาราง + Grid style สไตล์กริด + Grid unit หน่วยกริด + Gridline every เส้นตารางทุกๆ + Guide color สีไกด์ + Guide style สไตล์ไกด์ + Guides, Grid & Slices เส้นบอกแนว กริด และสไลซ์ + Gutter รางน้ำ + History Log บันทึกประวัติศาสตร์ + History states รัฐประวัติศาสตร์ + Ignore exif profile tag ละเว้นแท็กโปรไฟล์ EXIF + Image interpolation การแก้ไขภาพ + Image previews ตัวอย่างภาพ + Integrations บูรณาการ + Interface อินเทอร์เฟซ + Jpeg quality คุณภาพของเจเพ็ก + Jpg JPG + Large tabs แท็บขนาดใหญ่ + Legacy compositing การผสมผสานแบบเดิม + Light gray สีเทาอ่อน + Line เส้น + Lines เส้น + Lowercase extension ส่วนขยายตัวพิมพ์เล็ก + Maximize psd compatibility เพิ่มความเข้ากันได้ของ PSD ให้สูงสุด + Medium gray สีเทากลาง + Memory usage mb การใช้หน่วยความจำ เมกะไบต์ + Low resolution previews ตัวอย่างความละเอียดต่ำ + Metal โลหะ + Missing glyph protection การป้องกันสัญลักษณ์หายไป + Modern ทันสมัย + Nearest neighbor เพื่อนบ้านที่ใกล้ที่สุด + Never ไม่เคย + Normal tip ทิปปกติ + Open as smart object เปิดเป็นวัตถุอัจฉริยะ + Open in camera raw เปิดในกล้องดิบ + Open documents as tabs เปิดเอกสารเป็นแท็บ + OpenGL OpenGL + Orange ส้ม + Overscroll โอเวอร์สโครล + Performance ประสิทธิภาพ + Png PNG + Point size ขนาดพอยต์ + PostScript (72 points/inch) PostScript (72 จุด/นิ้ว) + Precise แม่นยำ + Preserve details เก็บรายละเอียด + Print resolution ความละเอียดในการพิมพ์ + Pro โปร + Pro light โปรไลท์ + Pro light gray โปรสีเทาอ่อน + Pro medium โปรกลาง + Pro photo ภาพโปร + Purple สีม่วง + Quick export format รูปแบบการส่งออกด่วน + Quick export location สถานที่ส่งออกด่วน + Recent file count จำนวนไฟล์ล่าสุด + LUT folder โฟลเดอร์ LUT + Recent files ไฟล์ล่าสุด + Recent fonts แบบอักษรล่าสุด + Recover on launch กู้คืนเมื่อเปิดตัว + Remember workspace changes จำการเปลี่ยนแปลงพื้นที่ทำงาน + Resize image during place ปรับขนาดภาพระหว่างสถานที่ + Right click with painting tools คลิกขวาด้วยเครื่องมือวาดภาพ + Rotate view with trackpad หมุนมุมมองด้วยแทร็กแพด + Same folder โฟลเดอร์เดียวกัน + Save in background บันทึกในพื้นหลัง + Scratch Disks รอยขีดข่วนดิสก์ + Screen resolution ความละเอียดหน้าจอ + Scrubby slider acceleration การเร่งความเร็วของตัวเลื่อนแบบสครับ + Sessions only เซสชันเท่านั้น + Sharpen for ลับคมเพื่อ + Show channels in color แสดงช่องเป็นสี + Show crosshair in brush tip แสดงเป้าเล็งที่ปลายแปรง + Show extension panels แสดงแผงส่วนขยาย + Show font names in english แสดงชื่อแบบอักษรเป็นภาษาอังกฤษ + Show menu colors แสดงสีเมนู + Show only crosshair while painting แสดงเฉพาะเป้าเล็งขณะวาดภาพ + Show slice numbers แสดงหมายเลขชิ้น + Show tooltips แสดงคำแนะนำเครื่องมือ + Enable animations เปิดใช้งานภาพเคลื่อนไหว + Show highlight on rollover แสดงไฮไลท์เมื่อโรลโอเวอร์ + Show bounding box when dragging layer แสดงกรอบขอบเมื่อลากเลเยอร์ + System title bar แถบหัวเรื่องของระบบ + Show transformation values แสดงค่าการแปลง + Slice color สีชิ้น + Smart guide color สีไกด์อัจฉริยะ + Smart quotes คำพูดที่ชาญฉลาด + Snap vector tools and transforms to pixel grid ถ่ายภาพเครื่องมือเวกเตอร์และแปลงเป็นตารางพิกเซล + Srgb สร + Standard มาตรฐาน + Studio สตูดิโอ + Studio light ไฟสตูดิโอ + Adwaita แอดเวตา + Adwaita dark แอดไวตามืด + Subdivisions เขตการปกครอง + System ระบบ + Text engine เอ็นจิ้นข้อความ + Text file ไฟล์ข้อความ + Tiny จิ๋ว + Touch gestures ท่าทางสัมผัส + Traditional (72.27 points/inch) แบบดั้งเดิม (72.27 จุด/นิ้ว) + Transparency & Gamut ความโปร่งใสและขอบเขต + Type units หน่วยตัวอักษร + UI font size ขนาดตัวอักษรของ UI + UI scale ขนาด UI + Units & Rulers หน่วยและผู้ปกครอง + Use additional plugins folder ใช้โฟลเดอร์ปลั๊กอินเพิ่มเติม + Use esc to commit ใช้ esc เพื่อคอมมิต + Use gpu ใช้จีพียู + Use legacy free transform ใช้การแปลงแบบฟรีแบบเดิม + Use shift key for tool switch ใช้ปุ่ม Shift สำหรับสวิตช์เครื่องมือ + Use tablet pressure ใช้แรงกดแท็บเล็ต + Vary round brush hardness on hud ความแข็งของแปรงกลมแปรผันบนฮัด + Vulkan วัลแคน + Webp เว็บพี + World ready โลกพร้อมแล้ว + Zoom clicked point to center ซูมคลิกชี้ไปที่กึ่งกลาง + Zoom resizes windows ซูมปรับขนาดหน้าต่าง + Zoom with scroll wheel ซูมด้วยล้อเลื่อน + Zoom with trackpad pinch ซูมด้วยการบีบแทร็กแพด + Alt-click to define a source point to clone from คลิก Alt เพื่อกำหนดจุดต้นทางที่จะโคลน + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. ไม่มีเส้นทางให้เลือก: วาดเส้นทางด้วยเครื่องมือปากกา หรือเลือกเส้นทางในแผงเส้นทาง + Cancel opening ยกเลิกการเปิด + Cancelled opening {name} ยกเลิกการเปิด {name} + Damaged Photoshop document เอกสาร Photoshop ที่เสียหาย + The document “{name}” may be damaged. เอกสาร “{name}” อาจเสียหาย + Some layers, pixels or masks could not be read. Do you want to continue opening it? ไม่สามารถอ่านเลเยอร์ พิกเซล หรือมาสก์บางเลเยอร์ได้ คุณต้องการเปิดมันต่อหรือไม่? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. หากคุณดำเนินการต่อ การบันทึกทับข้อมูลต้นฉบับอาจละทิ้งข้อมูลที่อ่านไม่ได้อย่างถาวร บันทึกสำเนาเพื่อเก็บต้นฉบับ + Continue ดำเนินการต่อ + The document “{name}” does not have an embedded {mode} profile. เอกสาร “{name}” ไม่มีโปรไฟล์ {mode} ที่ฝังอยู่ + The document “{name}” has an embedded color profile that does not match the current {mode} working space. เอกสาร “{name}” มีโปรไฟล์สีฝังอยู่ซึ่งไม่ตรงกับพื้นที่ทำงาน {mode} ปัจจุบัน + Working: {working} Working: {working} + Developing {name} กำลังพัฒนา {name} + Cancelled {label} ยกเลิก {label} + Esc to cancel Esc เพื่อยกเลิก + Opened {name} Opened {name} + Opening {name} Opening {name} + Saved {name} Saved {name} + Saving {name} กำลังบันทึก {name} + Saving {percent} Saving {percent} + A save to {name} is still running; try again when it finishes การบันทึกไปยัง {name} ยังคงทำงานอยู่ ลองอีกครั้งเมื่อเสร็จสิ้น + {label} is writing the file and can no longer be cancelled {label} กำลังเขียนไฟล์และไม่สามารถยกเลิกได้อีกต่อไป + Opening {name}… กำลังเปิด {name}… + Opening… กำลังเปิด… + Reading… กำลังอ่าน… + Working… กำลังทำงาน… + 3 x 3 3x3 + 4 x 4 4x4 + 5 x 5 5x5 + Grid: ตาราง: +# Rendering mode and recovery + Rendering Mode โหมดการเรนเดอร์ + Automatic (recommended) อัตโนมัติ (แนะนำ) + CPU / Compatibility ซีพียู / ความเข้ากันได้ + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. อัตโนมัติใช้การเร่งความเร็ว GPU เมื่อพร้อมใช้งานและถอยกลับไปที่การเรนเดอร์ CPU เมื่อมีข้อผิดพลาด + Switched to CPU rendering เปลี่ยนไปใช้การเรนเดอร์ CPU + GPU acceleration could not continue. Your documents are unchanged. การเร่งความเร็ว GPU ไม่สามารถดำเนินการต่อได้ เอกสารของคุณไม่มีการเปลี่ยนแปลง + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft กำลังใช้ตัวแต่งภาพ CPU หน้าต่างอาจยังคงใช้อะแดปเตอร์กราฟิกของคุณ + Retry GPU requires a restart. Save your work first. ลอง GPU อีกครั้งต้องรีสตาร์ท บันทึกงานของคุณก่อน + Retry GPU tries the graphics processor again now. ลองอีกครั้ง GPU ลองใช้โปรเซสเซอร์กราฟิกอีกครั้งทันที + GPU acceleration resumed. การเร่งความเร็ว GPU กลับมาทำงานอีกครั้ง + Keep Using CPU ใช้ CPU ต่อไป + Retry GPU ลอง GPU อีกครั้ง + GPU Backend แบ็กเอนด์ GPU + Details รายละเอียด + GPU จีพียู + Rendering mode โหมดการเรนเดอร์ + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) ตรึง Lasso: ลากเพื่อตรึงพื้นที่ กด Alt แล้วลากเพื่อละลาย (L) + Shadow Clipping Warning (U) คำเตือนเงาตัด (U) + Highlight Clipping Warning (O) คำเตือนการตัดไฮไลท์ (O) + Show Lab Color Readouts แสดงการอ่านค่าสีของแล็บ + Show Vectorscope แสดงเวคเตอร์สโคป + Show Red at 3 o'clock โชว์สีแดงเวลา 3 นาฬิกา + Show Skin Tone Indicator แสดงตัวบ่งชี้สีผิว + Tone Histogram โทนฮิสโตแกรม + Show Selected Region แสดงภูมิภาคที่เลือก +cameraRaw Light เบา +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) ฟิลเตอร์ Camera Raw ({layer}) + Camera Raw ({name}) กล้องดิบ ({name}) + Aqua อควา +cameraRaw Amount จำนวน +cameraRaw Blacks คนผิวดำ +cameraRaw Vibrance ความมีชีวิตชีวา +cameraRaw Color Grading การจัดระดับสี +cameraRaw Lights ไฟ +cameraRaw Darks ความมืด +cameraRaw Global ทั่วโลก +cameraRaw Red สีแดง +cameraRaw Orange ส้ม +cameraRaw Yellow สีเหลือง +cameraRaw Green สีเขียว +cameraRaw Aqua อควา +cameraRaw Blue สีฟ้า +cameraRaw Purple สีม่วง +cameraRaw Magenta สีม่วงแดง +cameraRaw Hue สี +cameraRaw Blending การผสม +cameraRaw Luminance Detail รายละเอียดความสว่าง +cameraRaw Color Detail รายละเอียดสี +cameraRaw Roughness ความหยาบ + Patch Tool เครื่องมือแก้ไข + Patch: แพทช์: + Lasso around an area, then drag the selection เชือกรอบๆ พื้นที่ จากนั้นลากส่วนที่เลือก + Transform On Drop แปลงร่างเมื่อหล่น + Scale or rotate the moved area, then press {key} ปรับขนาดหรือหมุนพื้นที่ที่ถูกย้าย จากนั้นกด {key} + Now drag the selection onto another area ตอนนี้ลากส่วนที่เลือกไปยังพื้นที่อื่น + Edit Type แก้ไขตัวอักษร + + New Style… รูปแบบใหม่… + Style {name} saved บันทึกสไตล์ {name} แล้ว + + Create new style (from the selected type) สร้างสไตล์ใหม่ (จากประเภทที่เลือก) + Create new style from the selected layer สร้างสไตล์ใหม่จากเลเยอร์ที่เลือก + + Make Default ทำให้เป็นค่าเริ่มต้น + Reset to Default รีเซ็ตเป็นค่าเริ่มต้น + Full-resolution preview unavailable ไม่สามารถแสดงตัวอย่างแบบเต็มความละเอียดได้ + No patterns ไม่มีลวดลาย + Enable {name} Enable {name} + Hold Alt while drawing for straight segments กด Alt ค้างไว้ขณะวาดส่วนที่เป็นเส้นตรง + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts + Extend ขยาย + Structure: โครงสร้าง: + Content-Aware Move Tool เครื่องมือย้ายเนื้อหา Aware + TIFF Options ตัวเลือก TIFF + Targa Options ตัวเลือกทาร์กา + JPEG Options ตัวเลือก JPEG + Image Options ตัวเลือกภาพ + Format Options ตัวเลือกรูปแบบ + Baseline ("Standard") พื้นฐาน ("มาตรฐาน") + Baseline Optimized ปรับให้เหมาะสมพื้นฐานแล้ว + 24 bits/pixel 24 บิต/พิกเซล + 32 bits/pixel 32 บิต/พิกเซล + Including layers will increase file size. รวมเลเยอร์จะทำให้ขนาดไฟล์เพิ่มขึ้น + Discard Layers and Save a Copy ละทิ้งเลเยอร์และบันทึกสำเนา + Don't show again อย่าแสดงอีก + Save failed: {error} บันทึกล้มเหลว: {error} + Save cancelled บันทึกถูกยกเลิก + Couldn't place {name}: {error} ไม่สามารถวาง {name}: {error} + Magnetic Lasso Tool เครื่องมือ Lasso แม่เหล็ก + Frequency ความถี่ + Use tablet pressure to change pen width ใช้แรงกดของแท็บเล็ตเพื่อเปลี่ยนความกว้างของปากกา + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About เกี่ยวกับ + Contributors ผู้ร่วมให้ข้อมูล + Models โมเดล + Sort จัดเรียง + Username ชื่อผู้ใช้ + Display name ชื่อที่แสดง + Real name ชื่อจริง + Reverse the order ย้อนกลับคำสั่งซื้อ + Grab bag หยิบกระเป๋า + Table ตาราง + Name (A–Z) ชื่อ (ก-ฮ) + Merged PRs รวมประชาสัมพันธ์ + Commits มุ่งมั่น + Lines added เพิ่มบรรทัดแล้ว + Lines deleted ลบบรรทัดแล้ว + Line delta เส้นเดลต้า + Binary assets added เพิ่มสินทรัพย์ไบนารีแล้ว + Binary assets removed ลบเนื้อหาไบนารีแล้ว + First commit กระทำครั้งแรก + First อันดับแรก + Last commit กระทำครั้งสุดท้าย + Last สุดท้าย + Contributors: {contributors} · Commits: {commits} Contributors: {contributors} · Commits: {commits} + No contributor data was built into this copy. ไม่มีข้อมูลผู้ร่วมสร้างไว้ในสำเนานี้ + No model credits were built into this copy. ไม่มีการสร้างเครดิตแบบจำลองในสำเนานี้ + Company บริษัท + Version เวอร์ชัน + % of all commits % ของการกระทำทั้งหมด + Lines +/− เส้น +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} คอมมิต, +{added} / −{deleted} บรรทัด (Δ {delta}), +{binAdded} / −{binDeleted} สินทรัพย์ไบนารี, {first} – {last} + +# Swatches panel + Create new swatch สร้างตัวอย่างใหม่ + New Swatch… สวอตช์ใหม่… + New Swatch Group… กลุ่มสวอตช์ใหม่… + Import Swatches… นำเข้าตัวอย่าง… + Replace Swatches… แทนที่ Swatches… + Export Swatches… ส่งออกตัวอย่าง… + Export Swatches for Exchange… ส่งออก Swatches เพื่อการแลกเปลี่ยน… + Reset Swatches รีเซ็ต Swatches + Add to Swatches เพิ่มลงในสวอตช์ +# Layer Style > Blending Options (Advanced Blending) + Blend If: ผสมผสานหาก: + Channels: ช่องทาง: + Knockout น็อคเอาท์ + Apply At Canvas Bounds สมัครได้ที่ Canvas Bounds + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) เครื่องมือเลือกสี (สีข้อความ) + Color Picker ({name}) เครื่องมือเลือกสี ({name}) + Color Picker (Fill Color) เครื่องมือเลือกสี (เติมสี) + Fill color เติมสี + Color Picker (Stroke Color) เครื่องมือเลือกสี (สีเส้น) + Stroke color สีเส้นขีด + Pick screen color เลือกสีหน้าจอ + Sample a pixel anywhere on the screen ตัวอย่างพิกเซลที่ใดก็ได้บนหน้าจอ + Click to pick · Esc to cancel คลิกเพื่อเลือก · Esc เพื่อยกเลิก + Pick a screen pixel or press Esc to cancel เลือกพิกเซลของหน้าจอหรือกด Esc เพื่อยกเลิก + Scroll to zoom เลื่อนเพื่อซูม + Drag to pan · Scroll to zoom · Double-click to fit ลากเพื่อเลื่อน · เลื่อนเพื่อซูม · ดับเบิลคลิกเพื่อให้พอดี + Show/Hide All Panels แสดง/ซ่อนแผงทั้งหมด + Show/Hide Panels แสดง/ซ่อนแผง + Could not open ไม่สามารถเปิดได้ + {path}: the path is not valid Unicode; rename the file and open it again. {path}: เส้นทางไม่ถูกต้อง Unicode; เปลี่ยนชื่อไฟล์แล้วเปิดอีกครั้ง + +# Fill dialog, layer style contours and Image Size + Contents: สารบัญ: + Custom Pattern: รูปแบบที่กำหนดเอง: + Foreground Color สีพื้นหน้า + Color… สี… + 50% Gray 50% สีเทา + {size} (was {old}) {size} (เป็น {old}) + Cone กรวย + Cone (Inverted) กรวย (คว่ำ) + Domed ทรงโดม + Domed (Inverted) ทรงโดม (กลับหัว) + Diagonal (Descending) เส้นทแยงมุม (จากมากไปน้อย) + Contour Editor ตัวแก้ไขรูปร่าง + Anti-aliased ต่อต้านนามแฝง + Corner มุม + Gloss Contour คอนทัวร์แบบเงา + Contour คอนทัวร์ + Cone - Inverted กรวย - คว่ำ + Cove - Deep โคฟ - ลึก + Cove - Shallow โคฟ - ตื้น + Gaussian เกาส์เซียน + Half Round ครึ่งรอบ + Ring แหวน + Ring - Double แหวน-คู่ + Rolling Slope - Descending เนินลาด - จากมากไปน้อย + Rounded Steps ขั้นตอนที่โค้งมน + Sawtooth 1 ฟันเลื่อย 1 + Linear เชิงเส้น + + Red Eye Tool เครื่องมือตาแดง + Pupil Size ขนาดรูม่านตา + Darken Amount ทำให้ปริมาณเข้มขึ้น + Click a red pupil to neutralize it คลิกที่รูม่านตาสีแดงเพื่อทำให้เป็นกลาง + open source โอเพ่นซอร์ส + …and {n} more …และอีก {n} รายการ + + Eyedroppers: ยาหยอดตา: + Set Black Point ตั้งค่าจุดดำ + Set Neutral Gray Point ตั้งค่าจุดสีเทากลาง + Set White Point ตั้งค่าจุดขาว + Unavailable in this color mode ไม่พร้อมใช้งานในโหมดสีนี้ + Selection Preview: ดูตัวอย่างการเลือก: + Black Matte สีดำด้าน + White Matte สีขาวแมท + Quick Mask มาสก์ด่วน + +# Layer colour labels. + Seafoam ซีโฟม + Indigo สีคราม + Fuchsia บานเย็น + Violet สีม่วง + Large document: redrawing on the CPU เอกสารขนาดใหญ่: วาดใหม่บน CPU + Redrawing วาดใหม่ + This redraw took {seconds} s on the CPU. การวาดใหม่นี้ใช้เวลา {seconds} วินาทีบน CPU + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. การแก้ไขที่เปลี่ยนแปลงทั้งเอกสาร เช่น โหมดผสมผสานของเลเยอร์ ความทึบ หรือการมองเห็น ให้วาดใหม่ทั้งหมด + The GPU compositor wasn't used: {reason} ไม่ได้ใช้ตัวแต่ง GPU: {reason} +fontWeight Thin บาง +fontWeight ExtraLight เอ็กซ์ตร้าไลท์ +fontWeight Light เบา +fontWeight Regular ปกติ +fontWeight Medium ปานกลาง +fontWeight SemiBold กึ่งหนา +fontWeight Bold ตัวหนา +fontWeight ExtraBold โดดเด่นเป็นพิเศษ +fontWeight Black ดำ +fontWeight Italic ตัวเอียง + +# Pattern Stamp tool + Pattern Stamp Tool เครื่องมือประทับตรารูปแบบ + Impressionist อิมเพรสชั่นนิสต์ + Rotate View Tool เครื่องมือหมุนมุมมอง + Reset View รีเซ็ตมุมมอง + Drag around the centre to rotate the view · Shift constrains to 15° ลากไปรอบๆ ศูนย์กลางเพื่อหมุนมุมมอง · Shift จำกัดไว้ที่ 15° + +# Painting symmetry presets. + Dual Axis แกนคู่ + Diagonal เส้นทแยงมุม + Transform Symmetry แปลงสมมาตร + Set painting symmetry options ตั้งค่าตัวเลือกสมมาตรของการวาดภาพ + +# Eyedropper Sample Size and Sample (#1649). + Point Sample ตัวอย่างจุด + 3 by 3 Average เฉลี่ย 3 x 3 + 5 by 5 Average เฉลี่ย 5 x 5 + 11 by 11 Average เฉลี่ย 11 x 11 + 31 by 31 Average 31 x 31 เฉลี่ย + 51 by 51 Average 51 x 51 เฉลี่ย + 101 by 101 Average เฉลี่ย 101 x 101 + Sample Size: ขนาดตัวอย่าง: + All Layers No Adjustments ทุกเลเยอร์ไม่มีการปรับเปลี่ยน + Current & Below No Adjustments ปัจจุบันและต่ำกว่าไม่มีการปรับเปลี่ยน + Show Sampling Ring แสดงแหวนเก็บตัวอย่าง + Click to sample the foreground colour · {key}-click for background คลิกเพื่อดูตัวอย่างสีพื้นหน้า · {key}-คลิกเพื่อดูพื้นหลัง + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour อยู่นอกช่วงการพิมพ์: คลิกเพื่อดูสีที่ใกล้ที่สุดที่สามารถพิมพ์ได้ + Not a web colour: click for the nearest web colour ไม่ใช่สีเว็บ: คลิกเพื่อดูสีเว็บที่ใกล้ที่สุด + Color to Alpha… สีเป็นอัลฟ่า… + Transparency Threshold เกณฑ์ความโปร่งใส + Opacity Threshold เกณฑ์ความทึบ + PhotoCraft — Screen Color Picker PhotoCraft — ตัวเลือกสีของหน้าจอ + + Rasterize svg groups deeper than แรสเตอร์กลุ่ม svg ให้ลึกกว่า + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 ปิดเกณฑ์นี้ กลุ่ม SVG ที่ซ้อนกันลึกกว่า 100 ระดับจะถูกแรสเตอร์เสมอ + Rule of Thirds กฎข้อที่สาม + Triangle สามเหลี่ยม + Golden Ratio อัตราส่วนทองคำ + Golden Spiral เกลียวทอง + Auto Show Overlay การแสดงภาพซ้อนทับอัตโนมัติ + Always Show Overlay แสดงภาพซ้อนทับเสมอ + Never Show Overlay ไม่ต้องแสดงภาพซ้อนทับ + Cycle Overlay การซ้อนทับวงจร + Cycle Orientation การวางแนววงจร + Set the overlay options for the Crop tool ตั้งค่าตัวเลือกการซ้อนทับสำหรับเครื่องมือครอบตัด + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. คลิก Alt ไปที่เดสก์ท็อป ซึ่งย้ายหน้าต่างด้วย Alt-drag ตั้งค่าปุ่มย้ายหน้าต่างเป็น Super หรือล็อค Alt ใน Window › Modifier Keys + +# Shared Layer Style color picker + Color Picker (Layer Style Color) เครื่องมือเลือกสี (สีสไตล์เลเยอร์) + Auto Hide Notifications ซ่อนการแจ้งเตือนอัตโนมัติ + Notification Duration (seconds) ระยะเวลาการแจ้งเตือน (วินาที) + Bundle Arrow-Key Nudges มัดปุ่มลูกศรแบบมัด + Nudge Bundle Pause (ms) เขยิบ Bundle หยุดชั่วคราว (มิลลิวินาที) + Appearance Mode โหมดรูปลักษณ์ + Dark Theme ธีมสีเข้ม + Light Theme ธีมแสง + Pro Medium โปรมีเดียม + Studio Light ไฟสตูดิโอ + Next Appearance Mode โหมดรูปลักษณ์ถัดไป + Sync with system ซิงค์กับระบบ + Active ใช้งานอยู่ + Color Picker (Solid Color) เครื่องมือเลือกสี (สีทึบ) + Straighten the image by drawing a line on it ปรับภาพให้ตรงโดยลากเส้นลงไป + Set additional Crop options ตั้งค่าตัวเลือกการครอบตัดเพิ่มเติม + Show Cropped Area แสดงพื้นที่ครอบตัด + Enable Crop Shield เปิดใช้งานครอบตัดโล่ + Match Canvas จับคู่ผ้าใบ + Auto Adjust Opacity ปรับความทึบอัตโนมัติ + Crop Shield color สีของครอบตัดโล่ + Color Picker (Crop Shield Color) ตัวเลือกสี (สีโล่ครอบตัด) + From Center จากศูนย์ + Link radii รัศมีลิงก์ + Star Ratio: อัตราส่วนดาว: + Create Rectangle สร้างสี่เหลี่ยมผืนผ้า + Create Ellipse สร้างวงรี + Create Triangle สร้างรูปสามเหลี่ยม + Create Polygon สร้างรูปหลายเหลี่ยม + Pressure curve เส้นโค้งความดัน + Click to add a point, right-click to remove คลิกเพื่อเพิ่มจุด คลิกขวาเพื่อลบ + Soft นุ่มนวล + Hard ยาก + Some brush presets could not be loaded ไม่สามารถโหลดค่าที่ตั้งไว้ล่วงหน้าของแปรงบางค่าได้ + Retry saving presets ลองบันทึกค่าที่ตั้งไว้ล่วงหน้าอีกครั้ง + Brush preset changes are not saved: {error} การเปลี่ยนแปลงที่ตั้งไว้ล่วงหน้าของแปรงจะไม่ถูกบันทึก: {error} + Keep this tab open to retain your brushes. เปิดแท็บนี้ไว้เพื่อรักษาแปรงของคุณ + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. พื้นที่เก็บข้อมูลเบราว์เซอร์ไม่พร้อมใช้งาน แปรงเป็นแบบเซสชันเท่านั้น เปิดแท็บนี้ไว้เพื่อเก็บไว้ + Saving brush presets… Keep this tab open until saving finishes. กำลังบันทึกค่าที่ตั้งล่วงหน้าของแปรง... เปิดแท็บนี้ไว้จนกว่าการบันทึกจะเสร็จสิ้น + Reflect ไตร่ตรอง + Glow เรืองแสง + Negation การปฏิเสธ + XOR แฮคเกอร์ + Color Burn (Paint.NET) เบิร์นสี (Paint.NET) + Color Dodge (Paint.NET) ดอดจ์สี (Paint.NET) + Stroke Options ตัวเลือกจังหวะ + Solid แข็ง + Dashed ประ + Dotted ลายจุด + Caps หมวก + Butt / Flat ก้น/แบน + Corners มุม + Miter ตุ้มปี่ + Round รอบ + Bevel เบเวล + Miter limit ขีด จำกัด ตุ้มปี่ + Dashed line เส้นประ + Dash แดช + Gap ช่องว่าง + Dash / gap lengths × stroke width เส้นประ / ความยาวช่องว่าง × ความกว้างเส้นขีด + Add pair เพิ่มคู่ + Remove pair ถอดคู่ออก + Dash offset แดชออฟเซ็ต + Open paths use centered strokes เส้นทางที่เปิดใช้จังหวะที่กึ่งกลาง + Use finite lengths and a positive pattern total ใช้ความยาวจำกัดและรูปแบบผลรวมที่เป็นบวก + No subject found — try Object Selection or the Lasso ไม่พบหัวเรื่อง — ลองใช้ Object Selection หรือ Lasso + Quick Selection found nothing there Quick Selection ไม่พบสิ่งใดที่นั่น + Object Selection found nothing there — try a tighter rectangle การเลือกวัตถุไม่พบสิ่งใดเลย ลองใช้สี่เหลี่ยมแคบกว่านี้ + No subject found — try a rectangle around the object ไม่พบวัตถุ — ลองใช้สี่เหลี่ยมรอบๆ วัตถุ + +# Layers-panel trash confirmation + Delete layer ‘{name}’? ลบเลเยอร์ '{name}' หรือไม่ + Delete {count} layers? {names} Delete {count} layers? {names} + Delete layer? ลบเลเยอร์ใช่ไหม + 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} ลบเลเยอร์แล้ว แต่ไม่สามารถบันทึกการตั้งค่าการยืนยันได้: {error} + Add a layer style เพิ่มสไตล์เลเยอร์ + Add a mask (from the selection; {key} inverts) เพิ่มมาสก์ (จากส่วนที่เลือก; {key} กลับด้าน) + Add an adjustment เพิ่มการปรับ + Add to selection ({key}) เพิ่มไปยังส่วนที่เลือก ({key}) + Adjustment visible มองเห็นการปรับแล้ว + Align and distribute จัดตำแหน่งและกระจาย + Align horizontal centers จัดกึ่งกลางแนวนอน + Align left edges จัดแนวขอบด้านซ้าย + Align right edges จัดขอบด้านขวา + Always use pressure for opacity ใช้แรงกดเพื่อความทึบเสมอ + Always use pressure for size ใช้แรงกดเพื่อขนาดเสมอ + Angle Gradient การไล่ระดับมุม + Auto-Select: เลือกอัตโนมัติ: + Brush Preset picker เครื่องมือเลือกแปรงที่ตั้งไว้ล่วงหน้า + Cancel current crop operation (Esc) ยกเลิกการดำเนินการครอบตัดปัจจุบัน (Esc) + Click the first point or press {key} to close · Esc cancels คลิกจุดแรกหรือกด {key} เพื่อปิด · ยกเลิก Esc + Click the first point or press {key} to close · {del} removes a point · Esc cancels คลิกจุดแรกหรือกด {key} เพื่อปิด · {del} ลบจุด · Esc ยกเลิก + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close คลิก จากนั้นเลื่อนไปตามขอบ · คลิกเพื่อยึดจุด · {alt}-คลิกเพื่อดูส่วนตรง · ดับเบิลคลิกเพื่อปิด + Clip to layer below คลิปลงเลเยอร์ด้านล่าง + Color & Swatches สีและตัวอย่าง + Commit current crop operation ({key}) ยอมรับการดำเนินการครอบตัดปัจจุบัน ({key}) + Create a new group สร้างกลุ่มใหม่ + Create a new layer สร้างเลเยอร์ใหม่ + Create new document from current state สร้างเอกสารใหม่จากสถานะปัจจุบัน + Create new fill or adjustment layer สร้างเลเยอร์การเติมหรือการปรับใหม่ + Create new snapshot สร้างสแนปชอตใหม่ + From: จาก: + Full Document เอกสารฉบับเต็ม + Merged Layers เลเยอร์ที่ผสาน + Delete Cropped Pixels ลบพิกเซลที่ครอบตัด + Delete current state ลบสถานะปัจจุบัน + Delete layer ลบเลเยอร์ + Diamond Gradient ไล่ระดับเพชร + Doc: {w} × {h} px เอกสาร: {w} × {h} พิกเซล + Enable airbrush-style build-up effects เปิดใช้งานเอฟเฟกต์การสร้างแบบแอร์บรัช + Filter for adjustment layers ฟิลเตอร์เลเยอร์การปรับ + Filter for pixel layers ฟิลเตอร์เลเยอร์พิกเซล + Filter for shape layers ฟิลเตอร์เลเยอร์รูปร่าง + Filter for smart objects ฟิลเตอร์สำหรับวัตถุอัจฉริยะ + Filter for type layers ฟิลเตอร์สำหรับเลเยอร์ตัวอักษร + Fixed Ratio อัตราส่วนคงที่ + Fixed Size ขนาดคงที่ + Follows only edges this close to the pointer ([ and ] change it) ติดตามเฉพาะขอบใกล้กับตัวชี้เท่านั้น ([ และ ] เปลี่ยน) + Hide Properties ซ่อนคุณสมบัติ + Higher values follow only edges that contrast sharply with their surroundings ค่าที่สูงกว่าจะติดตามเฉพาะขอบที่ตัดกันอย่างมากกับสภาพแวดล้อมเท่านั้น + How often fastening points are placed by themselves มีการวางจุดยึดด้วยตัวเองบ่อยเพียงใด + Hue/Saturation ฮิว/ความอิ่มตัว + Intersect with selection ({key}) ตัดกับส่วนที่เลือก ({key}) + Kind ชนิด + Linear Gradient การไล่ระดับสีเชิงเส้น + Link layers ลิงค์เลเยอร์ + Lock ล็อก + Lock all ล็อคทั้งหมด + Lock image pixels ล็อคพิกเซลภาพ + Lock layer ล็อคเลเยอร์ + Lock position ตำแหน่งล็อค + Lock transparent pixels ล็อคพิกเซลโปร่งใส + New selection การคัดเลือกใหม่ + No properties ไม่มีคุณสมบัติ + Pressure for Opacity ความกดดันสำหรับความทึบ + Pressure for Size ความดันสำหรับขนาด + Prevent auto-nesting in and out of Artboards and Frames ป้องกันการซ้อนเข้าและออกจากอาร์ตบอร์ดและเฟรมโดยอัตโนมัติ + Radial Gradient การไล่ระดับสีแบบเรเดียล + Reflected Gradient ไล่ระดับสีสะท้อน + Reset to defaults รีเซ็ตเป็นค่าเริ่มต้น + Set additional smoothing options ตั้งค่าตัวเลือกการปรับให้เรียบเพิ่มเติม + Show Transform Controls แสดงการควบคุมการแปลง + Subtract from selection ({key}) ลบออกจากส่วนที่เลือก ({key}) + Swaps height and width สลับความสูงและความกว้าง + Tolerance ความอดทน + Add vector mask เพิ่มมาสก์เวกเตอร์ + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out คลิกเพื่อซูมเข้า · {key}-คลิกเพื่อซูมออก · ลากไปทางขวา/ซ้ายเพื่อซูมเข้า/ออก + Appearance Settings… การตั้งค่าลักษณะที่ปรากฏ… + Add to favorites เพิ่มในรายการโปรด + Remove from favorites ลบออกจากรายการโปรด +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make ทำ +cameraData Model รุ่น +cameraData Date Taken วันที่ถ่าย +cameraData Exposure การเปิดรับแสง +cameraData F-Stop F-หยุด +cameraData ISO Speed ความไวแสง ISO +cameraData Focal Length ทางยาวโฟกัส +cameraData Focal Length (35 mm) ทางยาวโฟกัส (35 มม.) +cameraData Lens เลนส์ +# Shared panel docking + Activate Panel เปิดใช้งานแผง + Change Panel Group เปลี่ยนกลุ่มแผง + Change Panel Layout เปลี่ยนเค้าโครงแผง + Close Panel ปิดแผง + Dock Panel แผงท่าเรือ + Float Panel แผงลอย + Move Panel ย้ายแผง + Group with กลุ่มด้วย + Panel menu เมนูแผง + Resize panel window ปรับขนาดหน้าต่างแผง + Resize panels ปรับขนาดแผง + Use Classic Mode ใช้โหมดคลาสสิก + Auto Center Preview ดูตัวอย่างศูนย์อัตโนมัติ + Next Document เอกสารถัดไป + Previous Document เอกสารก่อนหน้า + W x H x Resolution กว้าง x สูง x ความละเอียด + Front Image ภาพด้านหน้า + Frame Tool เครื่องมือเฟรม + Create a new rectangular frame สร้างกรอบสี่เหลี่ยมใหม่ + Create a new elliptical frame สร้างกรอบรูปวงรีใหม่ + Include in playback รวมไว้ในการเล่น + Perspective Crop Tool เครื่องมือครอบตัดมุมมอง + Show Grid แสดงกริด + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels + Artboard Tool เครื่องมืออาร์ตบอร์ด + Make Portrait สร้างภาพบุคคล + Make Landscape ทำภูมิทัศน์ + Add New Artboard เพิ่มอาร์ตบอร์ดใหม่ + Freeform Pen Tool เครื่องมือปากการูปแบบอิสระ + Curvature Pen Tool เครื่องมือปากกาโค้ง + Curve Fit: ความโค้งพอดี: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close ลาก: วาดด้วยมือเปล่า · {alt}-คลิก: ส่วนตรง · ปล่อยที่จุดเริ่มต้น: ปิด + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish คลิก: จุดที่ราบรื่น · ดับเบิลคลิก: มุม · ลากจุด: ย้าย · คลิกจุดแรก: ปิด · {key} เสร็จสิ้น + Scale: ขนาด: + Snap to Origin สแน็ปไปที่จุดเริ่มต้น + Link with Layer เชื่อมโยงกับเลเยอร์ + Rasterize Layer Style แรสเตอร์สไตล์เลเยอร์ + Search settings… การตั้งค่าการค้นหา… + No matching settings ไม่มีการตั้งค่าที่ตรงกัน +# Edit › Content-Aware Fill workspace + Sampling Brush Tool เครื่องมือแปรงสุ่มตัวอย่าง + Sampling Area Overlay การซ้อนทับพื้นที่สุ่มตัวอย่าง + Show Sampling Area แสดงพื้นที่สุ่มตัวอย่าง + Indicates บ่งชี้ + Sampling Area พื้นที่สุ่มตัวอย่าง + Excluded Area พื้นที่ยกเว้น + Sampling Area Options ตัวเลือกพื้นที่สุ่มตัวอย่าง + Rectangular สี่เหลี่ยม + Fill Settings กรอกการตั้งค่า + Rotation Adaptation การปรับตัวแบบหมุน + Very High สูงมาก + Full เต็ม + Mirror กระจกเงา + Output Settings การตั้งค่าเอาท์พุต + Output To ส่งออกไปที่ + Duplicate Layer เลเยอร์ซ้ำ + Contract สัญญา + + Animation Options ตัวเลือกภาพเคลื่อนไหว + Entire Document เอกสารทั้งหมด + Filter Options ตัวเลือกฟิลเตอร์ + Layer Bounds ขอบเขตของเลเยอร์ + Layers Panel Options ตัวเลือกแผงเลเยอร์ + Lock Layers ล็อคเลเยอร์ + New Artboard… อาร์ตบอร์ดใหม่… + New Group… กลุ่มใหม่… + Prevent Auto-Nesting ป้องกันการซ้อนอัตโนมัติ + Show Layers แสดงเลเยอร์ + Thumbnail Contents เนื้อหาภาพขนาดย่อ + Thumbnail Size ขนาดรูปขนาดย่อ + Use Default Masks on Fill Layers ใช้มาสก์เริ่มต้นในการเติมเลเยอร์ + Expand New Effects ขยายเอฟเฟกต์ใหม่ + Add “copy” to Copied Layers and Groups เพิ่ม "คัดลอก" ลงในเลเยอร์และกลุ่มที่คัดลอก + Preview on canvas when reordering layers ดูตัวอย่างบนผืนผ้าใบเมื่อเรียงลำดับเลเยอร์ใหม่ + Show layer mask badges แสดงป้ายเลเยอร์มาสก์ + +# Smart Filter editors + Blending Options ({filter}) ตัวเลือกการผสม ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) ซ่อนเอฟเฟกต์ของเลเยอร์และฟิลเตอร์อัจฉริยะ ({key}-click: ทุกเลเยอร์) + Show the layer's effects and Smart Filters ({key}-click: all layers) แสดงเอฟเฟกต์ของเลเยอร์และฟิลเตอร์อัจฉริยะ ({key}-click: ทุกเลเยอร์) + Wait for the current job to finish. รอให้งานปัจจุบันเสร็จสิ้น + The document or Smart Filter changed. Reopen its dialog. เอกสารหรือฟิลเตอร์อัจฉริยะมีการเปลี่ยนแปลง เปิดกล่องโต้ตอบอีกครั้ง + Opacity must be between 0% and 100%. ความทึบต้องอยู่ระหว่าง 0% ถึง 100% + Dissolve is approximated: it blends without Photoshop's dither. การละลายเป็นการประมาณ: มันผสมกันโดยไม่ต้องใช้ไดเทอร์ของ Photoshop + Loading fonts… กำลังโหลดแบบอักษร… + Response curve เส้นโค้งการตอบสนอง + Tool เครื่องมือ + Simulate Pressure จำลองความดัน + Stroke Path… เส้นทางโรคหลอดเลือดสมอง… + Background Eraser ยางลบพื้นหลัง + Healing Brush แปรงรักษา + Spot Healing Brush แปรงรักษาเฉพาะจุด + Smudge รอยเปื้อน + Sponge ฟองน้ำ + Amount: จำนวน: + Protect: ป้องกัน: + Protect skin tones ปกป้องสีผิว + Edit points to modify the curve แก้ไขจุดเพื่อปรับเปลี่ยนเส้นโค้ง + Draw to modify the curve วาดเพื่อปรับเปลี่ยนเส้นโค้ง +curves Smooth เรียบเนียน + Smooth the curve values ปรับค่าเส้นโค้งให้เรียบ + Drag to draw the curve · {key}-click draws a straight line ลากเพื่อวาดเส้นโค้ง · {key}-คลิก วาดเส้นตรง + Grid Size: ขนาดตาราง: + Vanishing Point: click four corners to create a plane จุดที่หายไป: คลิกมุมทั้งสี่เพื่อสร้างระนาบ + Vanishing Point is not active Vanishing Point ใช้งานไม่ได้ + Apply Vanishing Point ({key}) ใช้ Vanishing Point ({key}) + Fix the red plane first: its corners must form a convex quad แก้ไขระนาบสีแดงก่อน: มุมของมันจะต้องเป็นรูปสี่เหลี่ยมนูน + Alt-click a plane to set the clone source คลิก Alt บนระนาบเพื่อตั้งค่าแหล่งที่มาของโคลน + Too many clone strokes: apply Vanishing Point first มีจังหวะโคลนมากเกินไป: ใช้ Vanishing Point ก่อน +# Select and Mask view modes (#1754) + Onion Skin ผิวหัวหอม + Marching Ants มดเดินขบวน + On Black บนสีดำ + On White บนสีขาว + On Layers บนเลเยอร์ + Show Edge แสดงขอบ + Show Original แสดงต้นฉบับ + F cycles the views; hold X to see the image F วนรอบมุมมอง; กด X ค้างไว้เพื่อดูภาพ +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) ปรับแต่งแปรงขอบ (R) + Expand Detection Area ขยายพื้นที่การตรวจจับ + Restore Original Edge คืนค่าขอบดั้งเดิม + Paint over hair or fur on the image; Alt paints the other mode ทาสีทับผมหรือขนสัตว์บนภาพ Alt วาดภาพโหมดอื่น + Clear Brush Strokes ล้างจังหวะแปรง + Show rulers แสดงผู้ปกครอง + New Crop Preset… ใหม่ Crop Preset… + Delete Crop Preset… ลบค่าที่ตั้งไว้ครอบตัด… + Select the crop presets to delete: เลือกค่าที่ตั้งไว้ครอบตัดที่จะลบ: + Select the crop presets to delete เลือกค่าที่ตั้งไว้ครอบตัดที่จะลบ + Enter a name for the crop preset ป้อนชื่อสำหรับค่าที่ตั้งไว้ครอบตัด + There is no crop size or ratio to save ไม่มีขนาดหรืออัตราส่วนการครอบตัดที่จะบันทึก + Presentation การนำเสนอ + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove คลิกเพื่อวาง · คลิก Alt หรือลากนอกแคนวาสเพื่อลบ + No document open ไม่มีการเปิดเอกสาร + {n} of {max} sample points จุดตัวอย่าง {n} จาก {max} + +# History panel menu (#2966) + New Snapshot… สแนปชอตใหม่… + History Options… ตัวเลือกประวัติ… +# History panel › History Options… (#2966) + Allow Non-Linear History อนุญาตประวัติที่ไม่ใช่เชิงเส้น + Make Layer Visibility Changes Undoable ทำให้การเปลี่ยนแปลงการมองเห็นเลเยอร์ไม่สามารถยกเลิกได้ +# Print dialog (#2697) + Emulsion Down อิมัลชันลง + Functions ฟังก์ชั่น + Print Resolution: ความละเอียดในการพิมพ์: + Use Previous Layer to Create Clipping Mask ใช้เลเยอร์ก่อนหน้าเพื่อสร้างรูปแบบการตัด + Fill with {mode}-neutral color (white) เติมด้วย {mode} - สีที่เป็นกลาง (สีขาว) + Fill with {mode}-neutral color (black) เติมด้วย {mode} - สีที่เป็นกลาง (สีดำ) + Fill with {mode}-neutral color (50% gray) เติมด้วย {mode} - สีที่เป็นกลาง (สีเทา 50%) + (No neutral color exists for {mode} mode.) (ไม่มีสีที่เป็นกลางสำหรับโหมด {mode}) + Apply mask to layer before removing? ทามาสก์เป็นเลเยอร์ก่อนถอด? + The layer or its mask changed. Drag the mask onto the Trash again. เลเยอร์หรือมาสก์เปลี่ยนไป ลากมาสก์ไปที่ถังขยะอีกครั้ง + Delete the vector mask? ลบเวกเตอร์มาสก์ใช่ไหม +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} ส่งออก {n} ตัวอย่างไปยัง {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} นำเข้า {n} แปรงจาก {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} นำเข้า {n} ตัวอย่างจาก {file} +@plural Played {n} step|Played {n} steps เล่นไปแล้ว {n} ก้าว +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk อัปเดตวัตถุอัจฉริยะที่เชื่อมโยง {n} เปลี่ยนแปลงบนดิสก์ +@plural Saved for Web: {n} file|Saved for Web: {n} files บันทึกสำหรับเว็บ: {n} ไฟล์ +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} เลเยอร์ที่บันทึกโดยคอมพ์นี้ถูกลบแล้ว อัปเดตคอมพ์หรือล้างคำเตือน +@plural {n} plane|{n} planes {n} ระนาบ +@plural {n} preset|{n} presets {n} ที่ตั้งไว้ล่วงหน้า +@plural {n} sample point removed|{n} sample points removed {n} คะแนนตัวอย่างถูกลบออก +@plural {n} slice|{n} slices {n} ชิ้น +@plural {n} step|{n} steps {n} ขั้นตอน +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} คำเตือนที่ตั้งไว้ล่วงหน้าของแปรงเพิ่มเติม) +@plural {what} with {n} warning|{what} with {n} warnings {what} with {n} warnings +# View › Guides › New Guide… › Color + Light Blue ฟ้าอ่อน + Light Red สีแดงอ่อน + Medium Blue ฟ้ากลาง + Light Gray สีเทาอ่อน + Object Finder ตัวค้นหาวัตถุ + No object found there — drag a rectangle around it ไม่พบวัตถุตรงนั้น ให้ลากสี่เหลี่ยมรอบๆ สิ่งนั้น + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels เลเยอร์ที่ใช้งานอยู่ไม่มีพิกเซลที่นี่ - เปิดตัวอย่างเลเยอร์ทั้งหมดหรือเลือกเลเยอร์ที่มีพิกเซล + Remove after each stroke ลบออกหลังจากแต่ละจังหวะ + Apply the removal ({key}) ใช้การลบ ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) จังหวะที่จะลบ: {n} (Enter ใช้, Esc ยกเลิก) + Load Contours… โหลดรูปทรง… + Replace Contours… แทนที่รูปทรง… + Save Contours… บันทึกรูปทรง… + Delete Contour ลบคอนทัวร์ + Loaded contours from {file} โหลดรูปทรงจาก {file} + Saved contours to {file} รูปทรงที่บันทึกไว้ใน {file} + Show home recent files แสดงไฟล์ล่าสุดที่บ้าน + Clear Brush Controls ล้างการควบคุมแปรง + Reset All Locked Settings รีเซ็ตการตั้งค่าที่ถูกล็อคทั้งหมด + Copy Texture to Other Tools คัดลอกพื้นผิวไปยังเครื่องมืออื่น ๆ + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… โหลดการดำเนินการ… + Save Actions… บันทึกการดำเนินการ… + New Action… แอคชั่นใหม่… + Start Recording เริ่มการบันทึก + Stop Recording หยุดการบันทึก + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} โหลดการกระทำ {n} รายการจาก {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} ขั้นตอนยังไม่มีเทียบเท่ากับ PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} บันทึกการกระทำ {n} ลงใน {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} ขั้นตอนที่ไม่มี Photoshop เทียบเท่าเหลืออยู่ + + File Naming การตั้งชื่อไฟล์ + Starting serial กำลังเริ่มซีเรียส +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… แทรกหยุด… + Save Set… บันทึกชุด… + Message: ข้อความ: + Allow Continue อนุญาตให้ดำเนินการต่อ + Show dialog during playback แสดงกล่องโต้ตอบระหว่างการเล่น + Select an action first เลือกการกระทำก่อน + Action playback stopped การเล่นแอ็คชั่นหยุดลง + Step {step} is waiting for its dialog ขั้นตอนที่ {step} กำลังรอกล่องโต้ตอบ +@plural {n} action|{n} actions {n} การกระทำ + Background (Default) พื้นหลัง (ค่าเริ่มต้น) + Content-Aware Fill การเติมการรับรู้เนื้อหา +# Batch / Image Processor + Include All Subfolders รวมโฟลเดอร์ย่อยทั้งหมด + Auto Add/Delete เพิ่ม/ลบอัตโนมัติ + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. อย่าทำเครื่องหมายที่ Mirror Image ในการตั้งค่าการพิมพ์…: การพลิกทั้งสองครั้งจะถูกยกเลิก + Print Settings… การตั้งค่าการพิมพ์… + Remember to turn off the printer's own colour management in Print Settings… อย่าลืมปิดการจัดการสีของเครื่องพิมพ์ในการตั้งค่าการพิมพ์… + Sent to {printer} Sent to {printer} + set in Print Settings… ตั้งค่าไว้ในการตั้งค่าการพิมพ์… + ICO is limited to 256 × 256 pixels ICO จำกัดขนาดไว้ที่ 256 × 256 พิกเซล + Calibration การสอบเทียบ + Red Primary ประถมสีแดง + Green Primary ประถมศึกษาสีเขียว + Blue Primary ประถมศึกษาสีน้ำเงิน + Write xmp sidecars เขียน xmp sidecars + Set the tone sliders from the image ตั้งค่าแถบเลื่อนโทนสีจากภาพ + Settings from {name} การตั้งค่าจาก {name} + Settings from {name}; {count} not supported: {names} การตั้งค่าจาก {name}; ไม่รองรับ {count}: {names} + Could not write {name}: {error} ไม่สามารถเขียน {name}: {error} + Kept {name}: written by another application เก็บ {name}: เขียนโดยแอปพลิเคชันอื่น + Shadow Tint เฉดสีเงา +# History panel › History Brush source (#2966) + Paints from the History panel's source state สีจากสถานะแหล่งที่มาของแผงประวัติ + Sets the source for the history brush ตั้งค่าแหล่งที่มาสำหรับแปรงประวัติ + +# Dynamic selection context menu and appearance favorites + Select Inverse เลือก ผกผัน + Pro (Light) โปร (เบา) + Pro (Light Gray) Pro (สีเทาอ่อน) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label ป้ายกำกับ + Date and Time วันที่และเวลา + Scale Units หน่วยสเกล + Scale Factor สเกลแฟคเตอร์ + Area พื้นที่ + Perimeter เส้นรอบวง + Circularity ความเป็นวงกลม + Gray Value (Minimum) ค่าสีเทา (ขั้นต่ำ) + Gray Value (Maximum) ค่าสีเทา (สูงสุด) + Gray Value (Mean) ค่าสีเทา (ค่าเฉลี่ย) + Gray Value (Median) ค่าสีเทา (ค่ามัธยฐาน) + Integrated Density ความหนาแน่นรวม + Length ความยาว + Show Favorite Fonts แสดงแบบอักษรโปรด + No favorite fonts yet: click the star beside a font to add it. ยังไม่มีแบบอักษรโปรด: คลิกดาวข้างแบบอักษรเพื่อเพิ่ม + Favorite fonts แบบอักษรที่ชื่นชอบ + Add to Favorites เพิ่มในรายการโปรด + Remove from Favorites ลบออกจากรายการโปรด + Show All Fonts แสดงแบบอักษรทั้งหมด + All Classes ทุกเลเยอร์เรียน + Blackletter จดหมายสีดำ + Decorative ตกแต่ง + Font preview: ตัวอย่างแบบอักษร: + Handwritten เขียนด้วยลายมือ + Hide Font Classes ซ่อนคลาสแบบอักษร + Hide Styles ซ่อนสไตล์ + Monospace โมโนสเปซ + No fonts match these filters. ไม่มีแบบอักษรที่ตรงกับฟิลเตอร์เหล่านี้ + No similar fonts: the current font doesn't say what it looks like. ไม่มีแบบอักษรที่คล้ายกัน: แบบอักษรปัจจุบันไม่ได้บอกว่ามีลักษณะอย่างไร + OpenType font แบบอักษร OpenType + Sans Serif แซนเซอริฟ + Script สคริปต์ + Serif เซริฟ + Show Font Classes แสดงคลาสแบบอักษร + Show Similar Fonts แสดงแบบอักษรที่คล้ายกัน + Show Styles แสดงสไตล์ + Show Variable Fonts แสดงแบบอักษรตัวแปร + Slab Serif แผ่นพื้น Serif + TrueType font ฟอนต์ TrueType + Variable font แบบอักษรที่เปลี่ยนแปลงได้ +# Bevel & Emboss shading controls. + Highlight Mode โหมดไฮไลท์ + Highlight Color ไฮไลท์สี + Highlight Opacity ไฮไลท์ความทึบ + Shadow Mode โหมดเงา + Shadow Color สีเงา + Shadow Opacity ความทึบของเงา +# PhotoCraft release checks. + Check for Updates… ตรวจสอบการอัปเดต… + PhotoCraft Updates อัพเดตPhotoCraft + Checking for updates… กำลังตรวจสอบการอัปเดต… + Open the release page to choose an installer for your system. เปิดหน้าเผยแพร่เพื่อเลือกตัวติดตั้งสำหรับระบบของคุณ + Open Download Page เปิดหน้าดาวน์โหลด + PhotoCraft is up to date. PhotoCraft เป็นเวอร์ชันล่าสุดแล้ว + Could not check for updates. ไม่สามารถตรวจสอบการอัปเดตได้ + Updates have not been checked yet. ยังไม่ได้ตรวจสอบการอัปเดต + Check Again ตรวจสอบอีกครั้ง + Automatically Check for Updates ตรวจสอบการอัปเดตโดยอัตโนมัติ + Installed version: {version} เวอร์ชันที่ติดตั้ง: {version} + PhotoCraft {version} is available. PhotoCraft {version} พร้อมใช้งานแล้ว +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) ใช้โปรไฟล์แบบฝัง ({profile}) + Convert to the working space ({profile}) แปลงเป็นพื้นที่ทำงาน ({profile}) + Remember this choice for future images จำตัวเลือกนี้ไว้สำหรับภาพในอนาคต + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. เปิดพรอมต์อีกครั้งด้วย Ask On Mismatch ในแก้ไข > การตั้งค่าสี + Button Mode โหมดปุ่ม + Button Mode On ปุ่มเปิดโหมด + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface อินเทอร์เฟซมืดลง + Lighten Interface เบาลงอินเทอร์เฟซ +# Bevel & Emboss Technique. + Technique เทคนิค + Chisel Hard สิ่วฮาร์ด + Chisel Soft สิ่วซอฟท์ + Open color-less EXR as ACEScg เปิด EXR ไร้สีเป็น ACEScg + Do not append "copy" to filename when saving a copy อย่าเพิ่ม "สำเนา" ต่อท้ายชื่อไฟล์เมื่อบันทึกสำเนา + +# View › Guides › right-click a guide + Edit Selected Guides แก้ไขคำแนะนำที่เลือก + Clear Selected Guides ล้างคำแนะนำที่เลือก + Bicubic Automatic ไบคิวบิกอัตโนมัติ + Bicubic Smoother Bicubic เรียบเนียนยิ่งขึ้น + Bicubic Sharper เครื่องลับ Bicubic + Pasting from other apps is unavailable ไม่สามารถวางจากแอปอื่นได้ + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ไม่สามารถเข้าถึงคลิปบอร์ดของระบบในเซสชันนี้ บน Wayland แซนด์บ็อกซ์ Flatpak จะซ่อนโปรโตคอลที่ภาพจากแอปอื่นถูกอ่านผ่าน + To paste from other apps, start PhotoCraft under XWayland: `{command}` หากต้องการวางจากแอปอื่น ให้เริ่ม PhotoCraft ภายใต้ XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here ไม่มีสิ่งใดที่จะวาง: PhotoCraft ไม่สามารถอ่านคลิปบอร์ดของระบบได้ที่นี่ + Delete this set ลบชุดนี้ + New Set… ชุดใหม่… + PhotoCraft Defaults ค่าเริ่มต้นของ PhotoCraft + Save a list of these shortcuts as a web page บันทึกรายการทางลัดเหล่านี้เป็นหน้าเว็บ + Save the changes to this set บันทึกการเปลี่ยนแปลงในชุดนี้ + Save these shortcuts as a .kys file บันทึกทางลัดเหล่านี้เป็นไฟล์ .kys + Save these shortcuts as a new set บันทึกทางลัดเหล่านี้เป็นชุดใหม่ + Set deleted. ลบชุดแล้ว + Set saved. บันทึกชุดแล้ว + Set: ชุด: + Summarize… สรุป… + There are too many sets: delete one first. มีชุดมากเกินไป โปรดลบออกหนึ่งชุดก่อน + set name ตั้งชื่อ + {set} (modified) {set} (แก้ไขแล้ว) + Random สุ่ม + Show file name in title แสดงชื่อไฟล์ในชื่อเรื่อง + +# Image › Mode › Bitmap (#5559) + 50% Threshold เกณฑ์ 50% + Pattern Dither รูปแบบ Dither + Diffusion Dither การแพร่กระจาย Dither + Halftone Screen หน้าจอฮาล์ฟโทน + Custom Pattern รูปแบบที่กำหนดเอง + Frequency: ความถี่: + Lines/Inch เส้น/นิ้ว + Lines/Centimeter เส้น/เซนติเมตร + Ellipse วงรี + Diamond เพชร + Cross ข้าม + Accept and Go to Conflict ยอมรับและไปสู่ความขัดแย้ง + Changes undone. ยกเลิกการเปลี่ยนแปลงแล้ว + Choose a new shortcut for this command, or leave it without one. เลือกทางลัดใหม่สำหรับคำสั่งนี้ หรือปล่อยไว้โดยไม่มีคำสั่งใดเลย + Put this command's shortcuts back as they were นำทางลัดของคำสั่งนี้กลับมาเหมือนเดิม + Take the shortcut from the other command and select it ใช้ทางลัดจากคำสั่งอื่นแล้วเลือก + Undo Changes เลิกทำการเปลี่ยนแปลง + {shortcut} is reserved by the system and can't be assigned. {shortcut} ถูกสงวนไว้โดยระบบและไม่สามารถกำหนดได้ + Low-Latency Drawing (vsync off, applies at next launch) การวาดเวลาแฝงต่ำ (ปิด vsync มีผลในการเปิดตัวครั้งถัดไป) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. ยังคงมีการใช้ Free Transform อยู่ ลองอีกครั้งเมื่อเสร็จแล้ว + The transform couldn't be applied. ไม่สามารถใช้การแปลงได้ +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. เลเยอร์ของมันต้องการหน่วยความจำ GPU {needed} งบประมาณเหลือไว้ {budget} + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. ตั้งค่าหน่วยความจำ GPU ในการตั้งค่า > ประสิทธิภาพตามที่การ์ดกราฟิกของคุณสามารถเก็บได้ สูงสุด 3/8 ของหน่วยความจำ อัตโนมัติจะติดตามการใช้หน่วยความจำ ภายในหนึ่งในสี่ของหน่วยความจำของคอมพิวเตอร์เครื่องนี้และ 3/8 ของการ์ด และไม่นับเอกสารกับการ์ดที่รู้จักหน่วยความจำ แถบสถานะแสดงหน่วยความจำ GPU เต็มในขณะที่ไม่พอดี + GPU Memory Full หน่วยความจำ GPU เต็ม + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. เลเยอร์ต้องการหน่วยความจำ GPU {needed} และอาจเก็บ {budget} ดังนั้นการแก้ไขที่วาดทั้งเอกสารใหม่จึงดำเนินการบน CPU คลิกเพื่อตั้งค่าหน่วยความจำ GPU + GPU memory budget: {budget} งบประมาณหน่วยความจำ GPU: {budget} + Graphics memory: {size} Graphics memory: {size} + This document's layers: {needed} of {budget} for layers เลเยอร์ของเอกสารนี้: {needed} จาก {budget} สำหรับเลเยอร์ + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU เลเยอร์ของเอกสารนี้จำเป็นต้องมี {needed} แต่อาจเก็บ {budget} ได้: การวาดทั้งเอกสารใหม่ใช้ CPU + GPU Memory (MB, 0 = Automatic) หน่วยความจำ GPU (MB, 0 = อัตโนมัติ) + {path}: the path is not valid Unicode; save under another name. {path}: เส้นทางไม่ถูกต้อง Unicode; บันทึกภายใต้ชื่ออื่น + Replace แทนที่ + {name} was changed by another app after it was opened or saved here. {name} ถูกเปลี่ยนโดยแอปอื่นหลังจากเปิดหรือบันทึกที่นี่ + Replace it with this document, or cancel and use File › Save As to keep both. แทนที่ด้วยเอกสารนี้ หรือยกเลิกและใช้ไฟล์ › บันทึกเป็นเพื่อเก็บทั้งสองอย่าง + +# Dynamic UI labels (fourth pass): filter and adjustment parameters, Camera Raw panels, +# lighting, History/Layers/paths/channels menus, metadata, System Info, status templates and +# dialog text that other catalogs already carry. Written from the meaning of the English labels +# in the ordinary Russian graphics-editor vocabulary; no proprietary resource consulted. + (coming soon) (เร็ว ๆ นี้) + (none) (ไม่มี) + 32-bit Preview Options ตัวเลือกการแสดงตัวอย่าง 32 บิต + 3D… 3 มิติ… + A: ตอบ: + AI Integrations บูรณาการ AI + About {app} About {app} + Accented Edges ขอบเน้น + Action playback failed: {error} การเล่นการกระทำล้มเหลว: {error} + Actual Pixels พิกเซลจริง + Add Mask เพิ่มมาสก์ + Add Noise เพิ่มเสียงรบกวน + Add a mask (from the selection; ⌥ inverts) เพิ่มมาสก์ (จากตัวเลือก; ⌥ กลับด้าน) + Add space after paragraph เพิ่มช่องว่างหลังย่อหน้า + Add space before paragraph เพิ่มช่องว่างก่อนย่อหน้า + Add to selection (⇧) เพิ่มไปยังส่วนที่เลือก (⇧) + Added swatch {name} เพิ่มตัวอย่าง {name} + Adds grain to the soft edges of the brush tip. เพิ่มเกรนที่ขอบนุ่มของปลายแปรง + Adjust Interval For Loops ปรับช่วงเวลาสำหรับลูป + Adjustment Layers เลเยอร์ปรับแต่ง + Adjustment Properties คุณสมบัติการปรับ + Adjustment layers have nothing to transform เลเยอร์การปรับแต่งไม่มีอะไรจะเปลี่ยนแปลง + Adobe PDF Presets… ค่าที่ตั้งล่วงหน้า Adobe PDF… + Adobe-rgb-compat Adobe-rgb-compat + Adwaita (Dark) แอดเวตา (ความมืด) + Adwaita (Light) อัดเวตา (ธาตุเบา) + After หลังจากนั้น + Airbrush: keeps painting while the pointer rests. พู่กัน: คงการวาดภาพในขณะที่ตัวชี้วางอยู่ + All shortcuts reset to Photoshop defaults. ทางลัดทั้งหมดรีเซ็ตเป็นค่าเริ่มต้นของ Photoshop + Altitude ระดับความสูง + Ambience บรรยากาศ + Ambient สิ่งแวดล้อม + Amplitude Max แอมพลิจูดสูงสุด + Amplitude Min แอมพลิจูดต่ำสุด + Android 1080p แอนดรอยด์ 1080p + Angled Strokes จังหวะมุม + Anisotropic แอนไอโซทรอปิก + Any อะไรก็ได้ + Apple Watch 45mm แอปเปิ้ลวอช 45มม + Apply Data Set ใช้ชุดข้อมูล + Apply Image ใช้ภาพ + Apply Mask ใช้มาสก์ + Apply to All นำไปใช้กับทั้งหมด + Apply {option}: {state} ใช้ {option}: {state} + Arbitrary โดยพลการ + Arc Lower อาร์คล่าง + Arc Upper อาร์คตอนบน + Are you sure you want to quit? คุณแน่ใจหรือว่าต้องการเลิก? + Are you sure? คุณแน่ใจเหรอ? + Around Center รอบศูนย์ + Arrangement การจัด + Arrows ลูกศร + Artboard from Group อาร์ตบอร์ดจากกลุ่ม + Artboard from Layers อาร์ตบอร์ดจากเลเยอร์ + Artboards อาร์ตบอร์ด + Artboards to Files อาร์ตบอร์ดเป็นไฟล์ + Artboards to PDF อาร์ตบอร์ดเป็น PDF + Artistic เชิงศิลปะ + Ask On Mismatch ถามเรื่องไม่ตรงกัน + Ask On Missing ถามเมื่อหายไป + Ask On Paste ถามเกี่ยวกับการวาง + Assign Profile กำหนดโปรไฟล์ + At ณ + Author Title ชื่อผู้แต่ง + Auto Scale ปรับขนาดอัตโนมัติ + Auto-Align Layers จัดตำแหน่งเลเยอร์อัตโนมัติ + Auto-Blend Layers ผสมผสานเลเยอร์อัตโนมัติ + Autosave failed: {error} บันทึกอัตโนมัติล้มเหลว: {error} + Back กลับ + Backend: {backend} ({type}) Backend: {backend} ({type}) + Background Level ระดับพื้นหลัง + Bamboo ไม้ไผ่ + Bas Relief บาสรีลีฟ + Base Tree Type ประเภทฐานต้นไม้ + Based On ขึ้นอยู่กับ + Baseline shift การเปลี่ยนแปลงพื้นฐาน + Basic Latin ภาษาละตินขั้นพื้นฐาน + Batch แบทช์ + Before ก่อน + Bend โค้งงอ + Berries เบอร์รี่ + Best ดีที่สุด + Black Clip คลิปดำ + Black Intensity ความเข้มของสีดำ + Blade Curvature ความโค้งของใบมีด + Blast ระเบิด + Bleach Bypass สารฟอกขาวบายพาส + Blend Clipped Layers as Group ผสมผสานเลเยอร์ที่ตัดเป็นกลุ่ม + Blend Interior Effects as Group ผสมผสานเอฟเฟกต์ภายในเป็นกลุ่ม + Blend Text Gamma ผสมผสานแกมมาข้อความ + Blending Mode โหมดการผสม + Bloat บวม + Blocks บล็อก + Blue Yellow ฟ้าเหลือง + Blues บลูส์ + Border ชายแดน + Border Thickness ความหนาของเส้นขอบ + Box Blur กล่องเบลอ + Bpc บีพีซี + Branches Color สีกิ่ง + Branches Height ความสูงของสาขา + Branches Thickness ความหนาของสาขา + Breeze Dark บรีส ดาร์ก + Breeze Light บรีซไลท์ + Brick อิฐ + Bristle Detail รายละเอียดขนแปรง + Browse เรียกดู + Brush Detail รายละเอียดแปรง + Brush Settings… (F5): every option of this brush Brush Settings… (F5): ทุกตัวเลือกของแปรงนี้ + Brush Strokes แปรงจังหวะ + Brush Type ประเภทแปรง + Burlap ผ้ากระสอบ + Calculations การคำนวณ + Camera Raw กล้องดิบ + Camera Raw Filter ฟิลเตอร์ Camera Raw + Cancel transform ยกเลิกการแปลง + Candle Light แสงเทียน + Canvas Size ขนาดผ้าใบ + Case Sensitive คำนึงถึงขนาดตัวพิมพ์ + Ccw ตามเข็มนาฬิกา + Cell Size ขนาดเซลล์ + Center X เซ็นเตอร์เอ็กซ์ + Center Y เซ็นเตอร์ วาย + Chalk Area พื้นที่ชอล์ก + Change Find เปลี่ยนการค้นหา + Changes Saved บันทึกการเปลี่ยนแปลงแล้ว + Changes have not been saved. ยังไม่ได้บันทึกการเปลี่ยนแปลง + Change… เปลี่ยน… + Channel1 ช่อง1 + Channel2 ช่อง2 + Channel3 ช่อง3 + Channel4 ช่อง 4 + Charcoal Area พื้นที่ถ่าน + Charcoal Thickness ความหนาของถ่าน + Check for Updates ตรวจสอบการอัปเดต + Checkerboard กระดานหมากรุก + Choke สำลัก + Choose Folder เลือกโฟลเดอร์ + Circle วงกลม + Cleanliness ความสะอาด + Clear Amount ล้างจำนวนเงิน + Clear Selection ล้างการเลือก + Click sets foreground · right-click sets background คลิกตั้งค่าเบื้องหน้า · คลิกขวาตั้งค่าพื้นหลัง + Click the first point or press ↵ to close · Esc cancels คลิกจุดแรกหรือกด ↵ เพื่อปิด · ยกเลิก Esc + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove คลิกเพื่อเพิ่มจุด · ลากออก หรือ ⌘/Ctrl คลิก หรือ Delete เพื่อลบ + Click to apply, ⌥-click to clear overrides, double-click for Style Options คลิกเพื่อใช้ ⌥ คลิกเพื่อล้างการแทนที่ ดับเบิลคลิกเพื่อดูตัวเลือกสไตล์ + Click to zoom in · ⌥-click to zoom out คลิกเพื่อซูมเข้า · ⌥-คลิกเพื่อซูมออก + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel คลิก: มุม · ลาก: ราบรื่น · คลิกจุดแรก: ปิด · ↩ เสร็จสิ้น · ยกเลิก Esc + Clipping Mask คลิปปิ้งมาสก์ + Clipping Path… เส้นทางการตัด… + Clone โคลน + Clone source set at {x}, {y} แหล่งโคลนตั้งไว้ที่ {x}, {y} + Close Window ปิดหน้าต่าง + Clumped รวมตัวกันเป็นก้อน + Cmyk ซีเอ็มวายเค + Coarse Dots จุดหยาบ + Coated-cmyk เคลือบ-cmyk + Collapse group (⌥-click: all groups) ยุบกลุ่ม (⌥-คลิก: ทุกกลุ่ม) + Color Decontaminate ฆ่าเชื้อสี + Color Halftone ฮาล์ฟโทนสี + Color Indicates Masked Areas สีแสดงถึงบริเวณที่ปิดบัง + Color Indicates Selected Areas สีบ่งบอกถึงพื้นที่ที่เลือก + Color Lookup Tables ตารางค้นหาสี + Color Picker ตัวเลือกสี + Color Picker (Background Color) เครื่องมือเลือกสี (สีพื้นหลัง) + Color Picker (Custom Canvas Color) ตัวเลือกสี (สีผ้าใบที่กำหนดเอง) + Color Picker (Foreground Color) เครื่องมือเลือกสี (สีพื้นหน้า) + Color Sampler Tool เครื่องมือเก็บตัวอย่างสี + Color Settings การตั้งค่าสี + Color Swatches ตัวอย่างสี + Color Table ตารางสี + Color management: {error} Color management: {error} + Colored Pencil ดินสอสี + Comic Book หนังสือการ์ตูน + Commit Perspective Warp (↩) คอมมิตเปอร์สเปคทีฟวาร์ป (↩) + Commit Puppet Warp (↩) กระทำการวาร์ปหุ่นเชิด (↩) + Commit any current edits (⌘↩) ยืนยันการแก้ไขปัจจุบัน (⌘↩) + Commit current crop operation (↵) ยอมรับการดำเนินการครอบตัดปัจจุบัน (↵) + Commit transform ยอมรับการเปลี่ยนแปลง + Commit transform (↩) ยอมรับการแปลง (↩) + Commit warp (↩) คอมมิตวาร์ป (↩) + Compare เปรียบเทียบ + Conditional Mode Change การเปลี่ยนแปลงโหมดตามเงื่อนไข + Confirm ยืนยัน + Conform สอดคล้อง + Constraints ข้อจำกัด + Content-Aware Fill needs a selection on a pixel layer Content-Aware Fill จำเป็นต้องเลือกบนเลเยอร์พิกเซล + Contrasty คอนทราสต์ + Convert แปลง + Convert to Alpha Channel แปลงเป็นช่องอัลฟ่า + Convert to Linked แปลงเป็นลิงค์ + Convert to Profile แปลงเป็นโปรไฟล์ + Cool เจ๋ง + Cooling L B B ระบายความร้อน L B B + Cooling80 คูลลิ่ง80 + Cooling82 คูลลิ่ง82 + Copy Complete Stroke คัดลอกจังหวะที่สมบูรณ์ + Copy Fill คัดลอกเติม + Copyright Url URL ลิขสิทธิ์ + Correct C A ถูกต้อง C A + Correct Distortion การบิดเบือนที่ถูกต้อง + Correct Vignette วิกเน็ตต์ที่ถูกต้อง + Could not rename the action: {error} ไม่สามารถเปลี่ยนชื่อการดำเนินการ: {error} + Could not transform: the layer is empty ไม่สามารถแปลง: เลเยอร์ว่างเปล่า + Could not use Perspective Warp: the layer is empty ไม่สามารถใช้ Perspective Warp ได้: เลเยอร์ว่างเปล่า + Could not use Puppet Warp: no opaque pixels ไม่สามารถใช้ Puppet Warp: ไม่มีพิกเซลทึบแสง + Could not use Puppet Warp: the layer is empty ไม่สามารถใช้ Puppet Warp ได้: เลเยอร์ว่างเปล่า + Couldn't open {name}: {error} ไม่สามารถเปิด {name}: {error} + Couldn't save preferences: {error}. Retrying… ไม่สามารถบันทึกการตั้งค่า: {error} กำลังลองอีกครั้ง… + Crack Brightness แตกความสว่าง + Crack Depth ความลึกของรอยแตก + Crack Spacing ระยะห่างของรอยแตก + Craquelure คราเกวร์ + Create By สร้างโดย + Create Vector Mask สร้างมาสก์เวกเตอร์ + Create Warped Text สร้างข้อความบิดเบี้ยว + Create a new layer (⇧⌘N) สร้างเลเยอร์ใหม่ (⇧⌘N) + Create new brush from the current settings สร้างแปรงใหม่จากการตั้งค่าปัจจุบัน + Create new brush preset from the current settings สร้างแปรงที่ตั้งไว้ล่วงหน้าใหม่จากการตั้งค่าปัจจุบัน + Create… สร้าง… + Cross Process ข้ามกระบวนการ + Crosshatch ครอสแฮทช์ + Crosswise ขวาง + Crystallize ตกผลึก + Currency สกุลเงิน + Curve Darks เส้นโค้งความมืด + Curve Highlights จุดเด่นของเส้นโค้ง + Curve Lights ไฟโค้ง + Curve Shadows เงาโค้ง + Curve Splits การแยกส่วนโค้ง + Custom Branch Color สีสาขาที่กำหนดเอง + Custom Pixel Aspect Ratio อัตราส่วนพิกเซลแบบกำหนดเอง + Custom pattern รูปแบบที่กำหนดเอง + Cutout คัตเอาท์ + Cw ตาม + Cyans สีฟ้า + Cyrillic ซีริลลิก + Daisies ดอกเดซี่ + Dark Gray สีเทาเข้ม + Dark Intensity ความเข้มของความมืด + Dark Rough เข้มหยาบ + Dark Strokes จังหวะมืด + Darken Only เข้มขึ้นเท่านั้น + Darkness ความมืด + Data ข้อมูล + Data Set: {name} ({index}/{total}) ชุดข้อมูล: {name} ({index}/{total}) + Data Sets as Files ชุดข้อมูลเป็นไฟล์ + Date วันที่ + Day For Night วันคืน + De-Interlace เดอ-อินเทอร์เลซ + Decrease Brush Hardness ลดความแข็งของแปรง + Decrease Brush Size ลดขนาดแปรง + Decrease Type Size ลดขนาดตัวอักษร + Decrease Type Size by 10 ลดขนาดตัวอักษรลง 10 + Deep ลึก + Deep Blue ดีพบลู + Deep Emerald มรกตลึก + Deep Red สีแดงเข้ม + Deep Yellow สีเหลืองเข้ม + Default Foreground and Background Colors สีพื้นหน้าและพื้นหลังเริ่มต้น + Default Leaves ใบเริ่มต้น + Default Photoshop Size ขนาด Photoshop เริ่มต้น + Defaults ค่าเริ่มต้น + Define Brush Preset กำหนดพรีเซ็ตแปรง + Define Custom Shape กำหนดรูปร่างที่กำหนดเอง + Define Pattern กำหนดรูปแบบ + Definition คำนิยาม + Defringe ละลาย + Degrees องศา + Delete Channel ลบช่อง + Delete Mask ลบมาสก์ + Delete Workspace ลบพื้นที่ทำงาน + Delete brush ลบแปรง + Delete path ลบเส้นทาง + Delete vector mask ลบมาสก์เวกเตอร์ + Delete… ลบ… + Depth Map แผนที่ความลึก + Depth Mode โหมดความลึก + Diameter เส้นผ่านศูนย์กลาง + Diffuse กระจาย + Diffuse Glow กระจายแสง + Direction ทิศทาง + Direction Balance ทิศทางสมดุล + Disable ปิดการใช้งาน + Disable Mask ปิดการใช้งานมาสก์ + Disable Symmetry Path ปิดการใช้งานเส้นทางสมมาตร + Disabled ปิดการใช้งาน + Discard Changes ยกเลิกการเปลี่ยนแปลง + Displace แทนที่ + Display จอแสดงผล + Display profile not used ไม่ได้ใช้โปรไฟล์การแสดง + Display profiles: {reason} แสดงโปรไฟล์: {reason} + Display-p3 จอแสดงผล-p3 + Distance ระยะทาง + Distortion การบิดเบือน + Distribution การกระจายสินค้า + Do you want to save you made to “{name}” before quitting? คุณต้องการที่จะบันทึกสิ่งที่คุณทำกับ “{name}” ก่อนที่จะเลิกหรือไม่? + Document Sizes ขนาดเอกสาร + Documentation เอกสารประกอบ + Documents เอกสาร + Dont Enlarge อย่าขยาย + Dot จุด + Double Line เส้นคู่ + Down ลง + Draft ร่าง + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels + Drag: draw freehand · Release on the start point: close ลาก: วาดด้วยมือเปล่า · ปล่อยที่จุดเริ่มต้น: ปิด + Driver: {driver} Driver: {driver} + Dry Brush แปรงแห้ง + Duplicate Channel ช่องที่ซ้ำกัน + Duplicate Layers ทำซ้ำเลเยอร์ + Duplication การทำสำเนา + Dust & Scratches ฝุ่นและรอยขีดข่วน + Edge ขอบ + Edge Brightness ความสว่างของขอบ + Edge Extension ส่วนขยายขอบ + Edge Fidelity ความเที่ยงตรงของขอบ + Edge Intensity ความเข้มของขอบ + Edge Only ขอบเท่านั้น + Edge Simplicity ความเรียบง่ายแบบขอบ + Edge Thickness ความหนาของขอบ + Edge Width ความกว้างของขอบ + Edit Toolbar แก้ไขแถบเครื่องมือ + Edit… แก้ไข… + Elevation ระดับความสูง + Eliminate กำจัด + Emboss ลายนูน + Enable เปิดใช้งาน + Enable Mask เปิดใช้งานมาสก์ + End X สิ้นสุด X + End Y จบ Y + Enlarged ขยายใหญ่ขึ้น + Erase with Brush or Pencil (hold) ลบด้วยแปรงหรือดินสอ (กดค้างไว้) + Error เกิดข้อผิดพลาด + Even Fields แม้กระทั่งฟิลด์ + Exit Quick Mask ออกจาก Quick Mask + Expand group (⌥-click: all groups) ขยายกลุ่ม (⌥-คลิก: ทุกกลุ่ม) + Export As ส่งออกเป็น + Export As: {name} ส่งออกเป็น: {name} + Export Contents ส่งออกเนื้อหา + Export Image ส่งออกภาพ + Export Options ตัวเลือกการส่งออก + Export Preferences การตั้งค่าการส่งออก + Export Settings การตั้งค่าการส่งออก + Export to ส่งออกไปที่ + Export/Import Presets ส่งออก/นำเข้าค่าที่ตั้งล่วงหน้า + Exported contents to {path} เนื้อหาที่ส่งออกไปยัง {path} + Exported presets to {path} ส่งออกค่าที่กำหนดล่วงหน้าไปยัง {path} + Exported {path} ส่งออก {path} แล้ว + Exported {path} ({size}) ส่งออก {path} ({size}) + Exporting… กำลังส่งออก… + ExtraBold โดดเด่นเป็นพิเศษ + ExtraLight เอ็กซ์ตร้าไลท์ + Extrude ขับไล่ + Fade จางลง + Faded Film ฟิล์มซีด + Failed ล้มเหลว + Failed to open file ไม่สามารถเปิดไฟล์ได้ + Failed to save file ไม่สามารถบันทึกไฟล์ได้ + Fallback: {reason} Fallback: {reason} + Feather Radius รัศมีขนนก + Fiber Length ความยาวไฟเบอร์ + Fibers เส้นใย + Field Blur สนามเบลอ + File Format รูปแบบไฟล์ + File Info ข้อมูลไฟล์ + File Name ชื่อไฟล์ + File Size ขนาดไฟล์ + File not found ไม่พบไฟล์ + Filename ชื่อไฟล์ + Fill Color เติมสี + Fill Opacity เติมความทึบ + Fill Path… เติมเส้นทาง… + Fill path with foreground color เติมเส้นทางด้วยสีพื้นหน้า + Fill with Background Color เติมสีพื้นหลัง + Fill with Background Color, Preserve Transparency เติมสีพื้นหลัง คงความโปร่งใส + Fill with Foreground Color เติมสีพื้นหน้า + Fill with Foreground Color, Preserve Transparency เติมสีพื้นหน้า รักษาความโปร่งใส + Film Grain เนื้อฟิล์ม + Filter Gallery แกลเลอรีฟิลเตอร์ + Find ค้นหา + Find and Replace Text ค้นหาและแทนที่ข้อความ + Fine ก็ได้ + Fine Dots จุดละเอียด + Finish เสร็จสิ้น + Fit Image พอดีภาพ + Fit to Window พอดีกับหน้าต่าง + Flame เปลวไฟ + Flame Bottom Alignment การจัดตำแหน่งด้านล่างของเปลวไฟ + Flame Lines เส้นเปลวไฟ + Flame Shape รูปร่างเปลวไฟ + Flame Style สไตล์เปลวไฟ + Flame Type ประเภทเปลวไฟ + Flat แบน + Flat Shading การแรเงาแบบแบน + Flower Color สีดอกไม้ + Focal Distance ระยะโฟกัส + Focus โฟกัส + Focus Area พื้นที่โฟกัส + Foreground Level ระดับเบื้องหน้า + Forward ไปข้างหน้า + Forward Warp วาร์ปไปข้างหน้า + Frame กรอบ + Frame {current} / {total} Frame {current} / {total} + Free Transform Path เส้นทางการแปลงอิสระ + Freeze Lasso ตรึง Lasso + Freeze Mask แช่แข็งมาสก์ + Fresco ปูนเปียก + From จาก + From Left จากซ้าย + From Right จากขวา + Frosted ฝ้า + Full Screen เต็มจอ + Fullscreen เต็มจอ + Fuzziness ความคลุมเครือ + GPU backend preference: {backend} GPU backend preference: {backend} + GPU device was lost; using the CPU renderer. อุปกรณ์ GPU สูญหาย โดยใช้ตัวเรนเดอร์ CPU + GPU error; using the CPU renderer. ข้อผิดพลาดของ GPU; โดยใช้ตัวเรนเดอร์ CPU + GPU retry scheduled กำหนดเวลาลอง GPU อีกครั้ง + Gamma แกมมา + Gaussian Blur เกาส์เซียนเบลอ + General Blending การผสมทั่วไป + General Brushes แปรงทั่วไป + Generators เครื่องกำเนิดไฟฟ้า + Generic ทั่วไป + Glass แก้ว + Global Light โกลบอลไลท์ + Gloss เงา + Glow Amount ปริมาณเรืองแสง + Glow Brightness ความสว่างสดใส + Glow Size ขนาดเรืองแสง + Glowing Edges ขอบเรืองแสง + Good ดี + Grade Balance เกรดคงเหลือ + Grade Blending การผสมเกรด + Grade Global เกรดระดับโลก + Grade Highlights จุดเด่นของเกรด + Grade Midtones เกรดมิดโทน + Grade Shadows เกรดเงา + Gradient Options ตัวเลือกการไล่ระดับสี + Grain Amount ปริมาณเมล็ดพืช + Grain Roughness ความหยาบของเมล็ดข้าว + Grain Size ขนาดเกรน + Grain Type ประเภทเกรน + Graininess ความหยาบ + Grainy Dots จุดหยาบ + Graphics adapter อะแดปเตอร์กราฟิก + Graphics adapter: {adapter} Graphics adapter: {adapter} + Gray-gamma-2.2 สีเทาแกมมา-2.2 + Greek ภาษากรีก + Greens ผักใบเขียว + Grid (⌘') ตาราง (⌘') + Grid view มุมมองตาราง + Group from Layers จัดกลุ่มจากเลเยอร์ + Groups กลุ่ม + Grout Width ความกว้างของยาแนว + Guide Layout… เค้าโครงคำแนะนำ… + HDR Toning การปรับสี HDR + Hand Tool (hold) เครื่องมือช่าง (กดค้างไว้) + Hearts หัวใจ + Heptagon เจ็ดเหลี่ยม + Hexagon หกเหลี่ยม + Hidden ซ่อนเร้น + Hide Grid ซ่อนกริด + Hide Others ซ่อนผู้อื่น + Hide the layer's effects (⌥-click: all layers) ซ่อนเอฟเฟกต์ของเลเยอร์ (⌥-คลิก: ทุกเลเยอร์) + Hide {app} Hide {app} + High Pass ผ่านสูง + Highlight ไฮไลท์ + Highlight Amount เน้นจำนวนเงิน + Highlight Area พื้นที่ไฮไลท์ + Highlight Radius เน้นรัศมี + Highlight Strength ไฮไลท์ความแกร่ง + Highlight Tone ไฮไลท์โทน + Highlights from: ไฮไลท์จาก: + Horizontal Distortion การบิดเบือนแนวนอน + Horizontal Only แนวนอนเท่านั้น + Horizontal scale ขนาดแนวนอน + Horizontal type ประเภทแนวนอน + Hotspot ฮอตสปอต + Hsb ฮสบ + Hsl HSL + Hsl Hue Hsl เว้ + Hsl Lum ซล ลัม + Hsl Sat วันเสาร์ + Image Balance ความสมดุลของภาพ + Image Processor หน่วยประมวลผลภาพ + Image compositor: {processor} Image compositor: {processor} + Import Brushes นำเข้าแปรง + Import Brushes… (.abr) นำเข้าแปรง… (.abr) + Import Image นำเข้าภาพ + Import Options ตัวเลือกการนำเข้า + Import Photoshop brushes (.abr) นำเข้าแปรง Photoshop (.abr) + Import Settings นำเข้าการตั้งค่า + Importing {what} from {file}… กำลังนำเข้า {what} จาก {file}… + Importing… กำลังนำเข้า… + In Black สีดำ + In White ในสีขาว + Increase Brush Hardness เพิ่มความแข็งของแปรง + Demosaic เดโมซาอิก + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. วิธีการเติมโมเสกฟิลเตอร์สีของกล้อง จะถูกนำไปใช้เมื่อไฟล์เปิดขึ้น การแสดงตัวอย่างจะแสดงวิธีการเปิดด้วย + AHD (adaptive homogeneity) AHD (ความสม่ำเสมอแบบปรับตัว) + Malvar–He–Cutler มัลวาร์–เฮ–คัทเลอร์ + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. ฮิราคาวะ–สวนสาธารณะ: ขอบที่สะอาดที่สุดและมีขอบสีน้อยที่สุดคือช้าที่สุด + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. การประมาณค่าแบบแก้ไขด้วยการไล่ระดับสี: คมชัดและรวดเร็ว โดยมีขอบสีบางส่วนในรายละเอียดที่ละเอียด + The fastest and the softest, with colour fringing on fine detail. เร็วที่สุดและนุ่มนวลที่สุด พร้อมการไล่สีในรายละเอียดที่ละเอียด + “{name}” already exists. Replace it? “{name}” มีอยู่แล้ว แทนที่มัน? + The name in the save dialog was “{name}”. ชื่อในกล่องโต้ตอบบันทึกคือ “{name}” + Move Up เลื่อนขึ้น + Move Down เลื่อนลง + Previous Brush แปรงก่อนหน้า + Next Brush แปรงถัดไป + First Brush แปรงครั้งแรก + Last Brush แปรงสุดท้าย + Preview size ขนาดตัวอย่าง + Memory Usage การใช้หน่วยความจำ + Per-document RAM limit (MB) ขีดจำกัด RAM ต่อเอกสาร (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM ที่ติดตั้ง: {ram} MB; ขีดจำกัดนี้: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. จำกัดพิกเซลของเอกสารที่เปิดอยู่แต่ละรายการพร้อมประวัติ สถานะการเลิกทำที่เก่าที่สุดจะถูกละทิ้งเมื่อเกินขีดจำกัด + Applies immediately when saved. Memory is not reserved in advance. นำไปใช้ทันทีเมื่อบันทึก หน่วยความจำไม่ได้ถูกจองล่วงหน้า + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. เมื่อตั้งค่าหน่วยความจำ GPU เป็นอัตโนมัติ จะเป็นการจำกัดงบประมาณเลเยอร์ GPU ด้วย งบประมาณดังกล่าวถูกจำกัดไว้ที่หนึ่งในสี่ของ RAM ที่ติดตั้ง และเมื่อทราบแล้ว ก็คือสามในแปดของหน่วยความจำของกราฟิกการ์ด โดยมีขั้นต่ำ 512 MB การเพิ่มขีดจำกัดนี้เกินขีดจำกัดจะไม่เพิ่มงบประมาณ GPU + Command คำสั่ง + Use ใช้ + In Front ข้างหน้า + Behind ด้านหลัง + +# DDS Options (#1327) + DDS Options ตัวเลือก ท.บ + Compression การบีบอัด + Generate Mipmaps สร้าง Mipmap + +# Color Table (.act) dialog + There is no color table to save. ไม่มีตารางสีให้บันทึก +@plural Saved {n} color to {file}|Saved {n} colors to {file} บันทึก {n} สีลงใน {file} + +# Graphics Adapter preference (#1885) + GPU adapter อะแดปเตอร์จีพียู + +# Styles panel: Import / Export Styles (.asl) + Import Styles… นำเข้าสไตล์… + Export Selected Styles… ส่งออกสไตล์ที่เลือก… + Export Styles… ส่งออกรูปแบบ… + {file} is not a style library (.asl) {file} ไม่ใช่ไลบรารีสไตล์ (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} นำเข้า {n} สไตล์จาก {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} ส่งออก {n} สไตล์ไปยัง {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first ยืนยันหรือยกเลิกกล่องโต้ตอบขั้นตอนการดำเนินการที่เปิดอยู่ก่อน + Pro Medium Gray โปรมีเดียมเกรย์ + Solarized แสงอาทิตย์ + Breeze สายลม + Pro Light Gray โปร เทาอ่อน + Pro Light โปร ไลท์ + Resize Windows to Fit ปรับขนาด Windows ให้พอดี + EXR อีเอ็กซ์อาร์ + Increase Brush Size เพิ่มขนาดแปรง + Increase Type Size เพิ่มขนาดตัวอักษร + Increase Type Size by 10 เพิ่มขนาดตัวอักษร 10 + Indent first line เยื้องบรรทัดแรก + Indent left margin เยื้องขอบด้านซ้าย + Indent right margin เยื้องระยะขอบขวา + Indexed จัดทำดัชนีแล้ว + Infinite อนันต์ + Information ข้อมูล + Initial Direction ทิศทางเริ่มต้น + Ink Outlines โครงร่างหมึก + Inner Bevel เอียงด้านใน + Input อินพุต + Input Levels ระดับอินพุต + Input Mode โหมดอินพุต + Install Plug-in ติดตั้งปลั๊กอิน + Intensity ความเข้ม + Intent เจตนา + Interpret Footage ตีความภาพ + Intersect with selection (⇧⌥) ตัดกับส่วนที่เลือก (⇧⌥) + Interval ช่วงเวลา + Invalid panel layout; restored the default dock เค้าโครงแผงไม่ถูกต้อง คืนค่าท่าเรือเริ่มต้น + Invert Mask กลับด้านมาสก์ + Invert Selection สลับการเลือก + Iris Blur ไอริสเบลอ + Ivy ไอวี่ + Jag จ๋า + Join the ArtCraft Discord เข้าร่วม ArtCraft Discord + Join the ArtCraft Discord ({url}) เข้าร่วม ArtCraft Discord ({url}) + Keeps the current texture when switching presets. คงพื้นผิวปัจจุบันไว้เมื่อเปลี่ยนค่าที่ตั้งล่วงหน้า + Kernel เคอร์เนล + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) การจัดช่องไฟ: เมตริก ออปติคัล หรือค่าในหน่วย 1/1000 em (Alt+←/→ ที่เครื่องหมายรูปหมวก) + Keyboard คีย์บอร์ด + Lab แล็บ + Landscape, 10 x 8 แนวนอน 10 x 8 + Landscape, 6 x 4 แนวนอน 6 x 4 + Landscape, 7 x 5 แนวนอน 7 x 5 + Latin 1 ละติน 1 + Latin Extended-A ละตินขยาย-A + Latin Extended-B ละตินขยาย-B + Layer Comps to Files เลเยอร์ Comps เป็นไฟล์ + Layer Content Options ตัวเลือกเนื้อหาเลเยอร์ + Layer Count จำนวนเลเยอร์ + Layer From Background เลเยอร์จากพื้นหลัง + Layer Knocks Out Drop Shadow เลเยอร์ทำให้ Drop Shadow หลุดออกมา + Layer Mask Hides Effects Layer Mask ซ่อนเอฟเฟกต์ + Layer Properties คุณสมบัติของเลเยอร์ + Layer Styles สไตล์เลเยอร์ + Layer from Background เลเยอร์จากพื้นหลัง + Layers to Files เลเยอร์เป็นไฟล์ + Layout เค้าโครง + Leading (set to the font size × auto-leading when Auto) นำหน้า (ตั้งค่าเป็นขนาดตัวอักษร × นำหน้าอัตโนมัติเมื่ออัตโนมัติ) + Leaf Color สีใบ + Learn More เรียนรู้เพิ่มเติม + Leaves Amount จำนวนใบ + Leaves Color สีใบ + Leaves Size ขนาดใบ + Left Diagonal เส้นทแยงมุมซ้าย + Legacy มรดก + Legal กฎหมาย + Lens Blur เลนส์เบลอ + Lens Correction การแก้ไขเลนส์ + Lens Flare เลนส์แฟลร์ + Less น้อยลง + Letter จดหมาย + Level Based ตามระดับ + Library ห้องสมุด + Light Dark Balance สมดุลแสงมืด + Light Direction ทิศทางแสง + Light Intensity ความเข้มของแสง + Light Rough หยาบเบา + Light Type ประเภทแสง + Light X ไลท์ เอ็กซ์ + Light Y ไลท์ วาย + Light Z ไลท์ ซี + Lighten Grout ยาแนวเบาลง + Lighten Only เบาลงเท่านั้น + Lighting แสงสว่าง + Lighting Effects เอฟเฟกต์แสง + Linear Dodge ลิเนียร์ดอดจ์ + Linear Height ความสูงเชิงเส้น + Linear-srgb Linear-srgb + Link ลิงค์ + Liquify ทำให้เป็นของเหลว + List รายการ + List view มุมมองรายการ + Live Preview ดูตัวอย่างสด + Load 3D LUT โหลด 3D LUT + Load Files into Stack โหลดไฟล์ลงในสแต็ก + Load path as a selection โหลดเส้นทางเป็นตัวเลือก + Loading… กำลังโหลด… + Lock All ล็อคทั้งหมด + Lock Image Pixels ล็อคพิกเซลภาพ + Lock Position ตำแหน่งล็อค + Lock Transparent Pixels ล็อคพิกเซลโปร่งใส + Locked ล็อคแล้ว + Long Lines เส้นยาว + Long Strokes จังหวะยาว + Lorem Ipsum ลอเรม อิปซัม + Lower ล่าง + Lum ลุม + Lut ลุต + Magentas สีม่วงแดง + Make Selection from Path ทำการเลือกจากเส้นทาง + Make Selection… ทำการเลือก… + Make Symmetry Path สร้างเส้นทางสมมาตร + Make Work Path… สร้างเส้นทางการทำงาน… + Make work path from selection สร้างเส้นทางการทำงานจากการคัดเลือก + Manual คู่มือ + Map Document เอกสารแผนที่ + Map Layer เลเยอร์แผนที่ + Map Path เส้นทางแผนที่ + Mask Channel ช่องมาสก์ + Mask Incomplete Blocks มาสก์บล็อกที่ไม่สมบูรณ์ + Mask None มาสก์ ไม่มี + Masks มาสก์ + Match Color จับคู่สี + Math Symbols สัญลักษณ์ทางคณิตศาสตร์ + Max Offset แม็กซ์ออฟเซ็ต + Max Radius รัศมีสูงสุด + Measurement Scale สเกลการวัด + Measurement Scale Marker เครื่องหมายมาตราส่วนการวัด + Medium Dots จุดกลาง + Medium Gray สีเทากลาง + Medium Lines เส้นกลาง + Medium Strokes จังหวะปานกลาง + Merge Channels… รวมช่อง… + Merge Spot Channel รวมช่องสปอต + Merge to HDR Pro ผสานเป็น HDR Pro + Metallic เมทัลลิค + Metrics เมตริก + Mezzotint เมซโซตินท์ + Middle กลาง + Midtone มิดโทน + Monitor Profile ตรวจสอบโปรไฟล์ + Monitor profile in use on {display}: {profile} ตรวจสอบโปรไฟล์ที่ใช้งานบน {display}: {profile} + Monitor profile in use: {profile} ตรวจสอบโปรไฟล์ที่ใช้งานอยู่: {profile} + Monitor profile on {display} (main window): {profile} ตรวจสอบโปรไฟล์บน {display} (หน้าต่างหลัก): {profile} + Monitor profile on {display}: {profile} ตรวจสอบโปรไฟล์บน {display}: {profile} + Monitor profile: {profile} ตรวจสอบโปรไฟล์: {profile} + Mono Contrast คอนทราสต์โมโน + Monochromatic สีเดียว + More เพิ่มเติม + Mosaic โมเสก + Mosaic Tiles กระเบื้องโมเสก + Motion Blur โมชั่นเบลอ + Mouse เม้าส์ + Move Layer ย้ายเลเยอร์ + Movie Prime หนังไพร์ม + Multiple Flames Along Path เปลวไฟหลายลูกตามเส้นทาง + Multiple Flames One Direction เปลวไฟหลายดวงในทิศทางเดียว + Multiple Flames Path Directions ทิศทางเส้นทางเปลวไฟหลายจุด + Multiple Flames Various Length เปลวไฟหลายลูก ความยาวหลากหลาย + My Brushes แปรงของฉัน + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Wayland ยังไม่รองรับการลากและวางไฟล์เนทิฟ ใช้ไฟล์ › เปิดหรือเรียกใช้ PhotoCraft ภายใต้ XWayland ด้วย `WAYLAND_DISPLAY= photocraft` + Nearest ใกล้ที่สุด + Neon Glow นีออนโกลว์ + Neutralize ทำให้เป็นกลาง + Neutrals ความเป็นกลาง + New Brush Preset พรีเซ็ตแปรงใหม่ + New Channel… ช่องใหม่… + New Guide คู่มือใหม่ + New Guide Layout เค้าโครงคำแนะนำใหม่ + New Spot Channel… ช่องสปอตใหม่… + New Video Layer from File เลเยอร์วิดีโอใหม่จากไฟล์ + New Window หน้าต่างใหม่ + New Workspace พื้นที่ทำงานใหม่ + New document เอกสารใหม่ + New document… เอกสารใหม่… + New document… ⌘N เอกสารใหม่… ⌘N + Next ถัดไป + No Document ไม่มีเอกสาร + No Image ไม่มีภาพ + No Layers ไม่มีเลเยอร์ + No Results ไม่มีผลลัพธ์ + No Results Found ไม่พบผลลัพธ์ + No Selection ไม่มีการคัดเลือก + No custom shape "{name}": pick one in the options bar or Window › Shapes ไม่มีรูปร่างที่กำหนดเอง "{name}": เลือกหนึ่งรูปร่างในแถบตัวเลือกหรือหน้าต่าง › รูปร่าง + No layers ไม่มีเลเยอร์ + No recent files ไม่มีไฟล์ล่าสุด + No selection ไม่มีการเลือก + Noise Color เสียงรบกวนสี + Noise Color Detail รายละเอียดสีเสียงรบกวน + Noise Luminance ความสว่างของสัญญาณรบกวน + Noise Luminance Detail รายละเอียดความสว่างของสัญญาณรบกวน + Nothing to Redo ไม่มีอะไรจะทำซ้ำ + Nothing to Undo ไม่มีอะไรจะเลิกทำ + Nothing to paste: the clipboard holds no image ไม่มีอะไรจะวาง: คลิปบอร์ดไม่มีภาพ + Number Of Levels จำนวนระดับ + Numbers ตัวเลข + Ocean Ripple ระลอกคลื่นมหาสมุทร + Octagon แปดเหลี่ยม + Odd Fields สนามคี่ + Oil Paint สีน้ำมัน + On เปิด + One Flame Along Path เปลวไฟหนึ่งดวงตามเส้นทาง + Open As เปิดเป็น + Open File เปิดไฟล์ + Open Folder เปิดโฟลเดอร์ + Open Image เปิดภาพ + Open Recent is unavailable on the web เปิดล่าสุดไม่พร้อมใช้งานบนเว็บ + Opened {url} เปิดแล้ว {url} + Opening กำลังเปิด + Open… ⌘O เปิด… ⌘O + Operation canceled การดำเนินการถูกยกเลิก + Operation cancelled การดำเนินการถูกยกเลิก + Operation completed การดำเนินการเสร็จสมบูรณ์ + Operation failed การดำเนินการล้มเหลว + Optical ออปติคัล + Original Ratio อัตราส่วนเดิม + Out Black ออกสีดำ + Out From Center ออกจากศูนย์ + Out Of Gamut ออกจากขอบเขต + Out White ออกขาว + Out of memory หน่วยความจำไม่เพียงพอ + Outer Bevel เอียงด้านนอก + Output Levels ระดับเอาท์พุท + Oval วงรี + Overlay Edge ขอบซ้อนทับ + Overlay: Rule of Thirds ภาพซ้อนทับ: กฎสามส่วน + PSD พีเอสดี + Package แพ็คเกจ + Page Setup… การตั้งค่าหน้า… + Paint Daubs ทาสีแต้ม + Paints from the document's opening state สีจากสถานะเปิดเอกสาร + Palette Knife มีดแผงสี + Panel แผง + Paper กระดาษ + Paper Brightness ความสว่างของกระดาษ + Parallel ขนาน + Paste Complete Stroke วางจังหวะที่สมบูรณ์ + Paste Fill วางเติม + Patchwork การเย็บปะติดปะต่อกัน + Path Blur เส้นทางเบลอ + Paths to Illustrator เส้นทางสู่ Illustrator + Pattern Fill… เติมลวดลาย… + Pattern Type ประเภทรูปแบบ + Pencil Width ความกว้างของดินสอ + Pentagon เพนตากอน + Percentage เปอร์เซ็นต์ + Permission denied การอนุญาตถูกปฏิเสธ + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft พยายามอีกครั้ง จนกว่าการบันทึกจะสำเร็จ การเปลี่ยนแปลงการตั้งค่าจะสูญหายไปเมื่อปิด + Photomerge ผสานภาพ + Pick Color From Image เลือกสีจากภาพ + Picture Frame กรอบรูป + Pillow Emboss หมอนลายนูน + Pinch หยิก + Pins หมุด + Place Embedded สถานที่ฝังตัว + Place Linked สถานที่ที่เชื่อมโยง + Place Scale Marker วางเครื่องหมายมาตราส่วน + Plane ระนาบ + Planes ระนาบ + Plastic Wrap พลาสติกห่อ + Platform: {os} {arch} แพลตฟอร์ม: {os} {arch} + Please wait… กรุณารอสักครู่… + Point จุด + Point Curve เส้นโค้งจุด + Point Curve Blue จุดโค้งสีน้ำเงิน + Point Curve Green จุดโค้งสีเขียว + Point Curve Red จุดโค้งสีแดง + Pointed ชี้ + Pointillize ชี้นิ้ว + Polar Coordinates พิกัดเชิงขั้ว + Polar To Rectangular ขั้วเป็นสี่เหลี่ยม + Policy Cmyk นโยบาย CMYK + Policy Gray นโยบายสีเทา + Policy Rgb นโยบาย RGB + Pond Ripples ระลอกคลื่น + Portrait, 4 x 6 แนวตั้ง, 4 x 6 + Portrait, 5 x 7 แนวตั้ง 5 x 7 + Postcard โปสการ์ด + Poster โปสเตอร์ + Poster Edges ขอบโปสเตอร์ + Posterization การทำโปสเตอร์ + Preferences can't be saved ไม่สามารถบันทึกการตั้งค่าได้ + Preferences saved บันทึกการตั้งค่าแล้ว + Preferences were reset: {error} ค่ากำหนดถูกรีเซ็ต: {error} + Preferences… การตั้งค่า… + Preserve อนุรักษ์ + Preset Manager ผู้จัดการที่ตั้งไว้ล่วงหน้า + Press keys กดปุ่ม + Preview Image ดูตัวอย่างภาพ + Previous ก่อนหน้า + Prime105 ไพรม์105 + Prime35 ไพรม์35 + Printed to {path} พิมพ์ไปที่ {path} + Pro (Dark) โปร (มืด) + Pro (Medium Gray) Pro (สีเทากลาง) + Processing… กำลังประมวลผล… + Profile โปรไฟล์ + Proof Setup… การตั้งค่าการพิสูจน์… + Prophoto-compat Prophoto-เข้ากันได้ + Pucker รอยย่น + Push Left กดซ้าย + Pyramids ปิรามิด + Quick Actions การดำเนินการด่วน + Quit เลิก + Quit PhotoCraft ออกจากPhotoCraft + Quit {app} Quit {app} + R G B อาร์ จี บี + R g b อาร์ จี บี + Radial เรเดียล + Radial Blur เรเดียลเบลอ + Radius X รัศมี X + Radius Y รัศมี Y + Ran script {name} รันสคริปต์ {name} + Randomize Length สุ่มความยาว + Randomize Shapes สุ่มรูปร่าง + Rasterize Fill Content เติมเนื้อหาแบบแรสเตอร์ + Rasterize Smart Object แรสเตอร์วัตถุอัจฉริยะ + Ratio อัตราส่วน + Raw Defaults ค่าเริ่มต้นดิบ + Reading การอ่าน + Ready พร้อม + Rec2020 Rec2020 + Recent Files ไฟล์ล่าสุด + Reconstruct All สร้างใหม่ทั้งหมด + Recovered {name} กู้คืน {name} แล้ว + Rectangular Selection การเลือกสี่เหลี่ยม + Rectangular To Polar สี่เหลี่ยมถึงขั้ว + Red Cyan แดง ฟ้า + Reds สีแดง + Reduce Color Noise ลดสัญญาณรบกวนสี + Reduce Noise ลดเสียงรบกวน + Reflected สะท้อนออกมา + Relief บรรเทา + Relink to File เชื่อมโยงไปยังไฟล์ + Remove All ลบทั้งหมด + Remove Jpeg Artifact ลบสิ่งประดิษฐ์ Jpeg + Rename Brush เปลี่ยนชื่อแปรง + Rename Channel… เปลี่ยนชื่อช่อง… + Rename Group เปลี่ยนชื่อกลุ่ม + Rename Tool Preset เปลี่ยนชื่อเครื่องมือที่ตั้งไว้ล่วงหน้า + Render Video เรนเดอร์วิดีโอ + Rendering… กำลังแสดงผล… + Reorder Layer เรียงลำดับเลเยอร์ใหม่ + Replace Color เปลี่ยนสี + Replace Contents แทนที่เนื้อหา + Replace Footage แทนที่ภาพ + Report a Bug รายงานข้อผิดพลาด + Report an Issue รายงานปัญหา + Reset Alignment รีเซ็ตการจัดตำแหน่ง + Reset All รีเซ็ตทั้งหมด + Reset Preferences รีเซ็ตการตั้งค่า + Resolve Missing Fonts แก้ไขแบบอักษรที่หายไป + Restart รีสตาร์ท + Restart Required จำเป็นต้องรีสตาร์ท + Resume ดำเนินการต่อ + Retry ลองอีกครั้ง + Rgb RGB + Ridges สันเขา + Right Diagonal เส้นทแยงมุมขวา + Ripple ระลอกคลื่น + Ripple Magnitude ขนาดระลอกคลื่น + Ripple Size ขนาดระลอกคลื่น + Rope เชือก + Roses กุหลาบ + Rough Pastels สีพาสเทลหยาบ + Row Order ลำดับแถว + Rulers (⌘R) ผู้ปกครอง (⌘R) + Sample Size ขนาดตัวอย่าง + Sampled Colors สีตัวอย่าง + Sandstone หินทราย + Sat ส + Save As บันทึกเป็น + Save Changes บันทึกการเปลี่ยนแปลง + Save File บันทึกไฟล์ + Save Image บันทึกภาพ + Save Path บันทึกเส้นทาง + Save Pixel Aspect Ratio บันทึกอัตราส่วนพิกเซล + Save a Copy บันทึกสำเนา + Save before closing? บันทึกก่อนปิด? + Save for Web (Legacy) บันทึกสำหรับเว็บ (ดั้งเดิม) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. บันทึกงานของคุณและรีสตาร์ท PhotoCraft เพื่อลองเร่งความเร็ว GPU อีกครั้ง การเรนเดอร์ CPU ยังคงทำงานอยู่สำหรับเซสชันนี้ + Saved a copy as {path} บันทึกสำเนาเป็น {path} + Saved {path}; image assets: {n} in {dir} Saved {path}; image assets: {n} in {dir} + Saving… กำลังบันทึก… + Scale Effects สเกลเอฟเฟกต์ + Scaling การปรับขนาด + Scallops หอยเชลล์ + Script Events Manager ผู้จัดการกิจกรรมสคริปต์ + Scroll menu down เลื่อนเมนูลง + Scroll menu up เลื่อนเมนูขึ้น + Search commands คำสั่งค้นหา + Search commands (⌘K) คำสั่งค้นหา (⌘K) + Search commands, tools and panels ค้นหาคำสั่ง เครื่องมือ และแผงควบคุม + Search menus เมนูค้นหา + Seed เมล็ดพันธุ์ + Select Custom Color… เลือกสีที่กำหนดเอง… + Select Data Points เลือกจุดข้อมูล + Select Layer เลือกเลเยอร์ + Select and Mask เลือกและมาสก์ + Color Aware การรับรู้สี + Decontaminate Colors กำจัดสีที่ปนเปื้อน + Edge Detection การตรวจจับขอบ + Global Refinements การปรับแต่งระดับโลก + High Quality Preview ดูตัวอย่างคุณภาพสูง + Object Aware วัตถุรับรู้ + Object Aware needs a segmentation model, which isn't available yet Object Aware จำเป็นต้องมีโมเดลการแบ่งส่วน ซึ่งยังไม่มีให้บริการ + Real-Time Refinement การปรับแต่งแบบเรียลไทม์ + Refine Edge Brush Tool ปรับแต่งเครื่องมือแปรงขอบ + Refine Hair ปรับแต่งเส้นผม + Refine Hair needs hair detection, which isn't available yet Refine Hair จำเป็นต้องมีการตรวจจับเส้นผม ซึ่งยังไม่มีให้บริการ + Refine Mode โหมดปรับแต่ง + Select and Mask was closed: another layer was selected ปิดเลือกและมาสก์แล้ว: มีการเลือกเลเยอร์อื่น + Select and Mask was closed: its document is no longer the active one ปิดเลือกและมาสก์แล้ว: เอกสารนั้นไม่ใช่เอกสารที่ใช้งานอยู่ + View Mode โหมดมุมมอง + Selected at launch: {backend} เลือกเมื่อเปิดตัว: {backend} + SemiBold กึ่งหนา + Sensitivity ความไว + Sent to the printer ({spooler}) ส่งไปยังเครื่องพิมพ์ ({spooler}) + Sepia ซีเปีย + Services บริการ + Set Colors ตั้งค่าสี + Set Measurement Scale ตั้งค่ามาตราส่วนการวัด + Sgray สเกรย์ + Shadow เงา + Shadow Amount ปริมาณเงา + Shadow Intensity ความเข้มของเงา + Shadow Radius รัศมีเงา + Shadow Tone โทนสีเงา + Shadows up to: เงาสูงสุด: + Shadows/Highlights เงา/ไฮไลท์ + Shallow ตื้น + Shape Blur รูปร่างเบลอ + Sharpen Amount เพิ่มความคมชัด + Sharpen Detail คมชัดรายละเอียด + Sharpen Details คมชัดรายละเอียด + Sharpen Masking เหลามาสกิ้ง + Sharpen Radius เพิ่มรัศมี + Shear เฉือน + Shine ส่องแสง + Short Lines เส้นสั้น + Short Strokes จังหวะสั้น + Shortcut ทางลัด + Shortcuts ทางลัด + Show All แสดงทั้งหมด + Show Extras Options แสดงตัวเลือกพิเศษ + Show Guides แสดงคำแนะนำ + Show Guides (⌘;) แสดงคำแนะนำ (⌘;) + Show Rulers แสดงผู้ปกครอง + Show the layer's effects (⌥-click: all layers) แสดงเอฟเฟกต์ของเลเยอร์ (⌥-คลิก: ทุกเลเยอร์) + Simple เรียบง่าย + Simulate Paper จำลองกระดาษ + Sine ไซน์ + Sketch ร่าง + Skip ข้าม + Slice Options ตัวเลือกชิ้น + Smart Blur สมาร์ทเบลอ + Smart Sharpen สมาร์ทคมชัด + Smoothness ความเรียบเนียน + Smudge Stick รอยเปื้อนสติ๊ก + Snap to Grid จัดชิดไปที่กริด + Snowflakes เกล็ดหิมะ + Soften นุ่มลง + Softness ความนุ่มนวล + Software adapter อะแดปเตอร์ซอฟต์แวร์ + Solarized Dark แสงอาทิตย์มืด + Solid Color สีทึบ + Solid Front Faces ใบหน้าด้านหน้าแข็ง + Source Channel ช่องทางต้นทาง + Source Invert แหล่งที่มากลับด้าน + Source Layer เลเยอร์แหล่งที่มา + Source1 Channel ที่มา1ช่อง + Source1 Invert แหล่งที่มา 1 ผกผัน + Source2 Channel ที่มา2ช่อง + Source2 Invert Source2 ผกผัน + Source: set ที่มา: set + Space อวกาศ + Sparkle สปาร์คเคิล + Spatter โปรยลงมา + Speckle จุด + Speed ความเร็ว + Spherize ทรงกลม + Spin สปิน + Spin Blur หมุนเบลอ + Split Channels แยกช่อง + Spot สปอต + Spray Radius รัศมีสเปรย์ + Sprayed Strokes พ่นจังหวะ + Spread การแพร่กระจาย + Sprinkles โรย + Square Size ขนาดสี่เหลี่ยมจัตุรัส + Square, 12 x 12 สี่เหลี่ยมจัตุรัส 12 x 12 + Square, 5 x 5 สี่เหลี่ยมจัตุรัส 5 x 5 + Squareness ความเหลี่ยม + Stagger โซเซ + Stained Glass กระจกสี + Star ดาว + Stars ดาว + Start X เริ่ม X + Start Y เริ่ม Y + Statistics สถิติ + Step {step} ({id}) failed: {error} ขั้นตอนที่ {step} ({id}) ล้มเหลว: {error} + Stippled แต้ม + Stops หยุด + Strength ความแข็งแกร่ง + Stretch ยืดกล้ามเนื้อ + Stroke Color สีเส้นโครงร่าง + Stroke Detail รายละเอียดจังหวะ + Stroke Direction ทิศทางจังหวะ + Stroke Length ความยาวช่วงชัก + Stroke Pressure ความดันโรคหลอดเลือดสมอง + Stroke Size ขนาดจังหวะ + Stroke Width ความกว้างของเส้นขีด + Stroke path with brush เส้นทางสโตรกด้วยแปรง + Strokes จังหวะ + Studio (Dark) สตูดิโอ (มืด) + Studio (Light) สตูดิโอ (ไลท์) + Stylization การจัดสไตล์ + Subtract Shapes at Overlap ลบรูปร่างที่ทับซ้อนกัน + Subtract from selection (⌥) ลบออกจากส่วนที่เลือก (⌥) + Sumi-e ซูมิ-อี + Surface Blur พื้นผิวเบลอ + Switch Foreground and Background Colors สลับสีพื้นหน้าและพื้นหลัง + System Info ข้อมูลระบบ + Tablet แท็บเล็ต + Tabloid แท็บลอยด์ + Taper เรียว + Target เป้าหมาย + Target Channel 3 เป้าหมายช่อง 3 + Target Channel 4 เป้าหมายช่อง 4 + Target Channel 5 เป้าหมายช่อง 5 + Target Channel 6 เป้าหมายช่อง 6 + Target Channel 7 เป้าช่อง7 + Target Channel 8 เป้าช่อง8 + Target Channel 9 เป้าช่อง 9 + Target Composite Channel เป้าหมายช่องทางคอมโพสิต + Target X เป้าหมาย X + Target Y เป้าหมาย Y + Teal Orange น้าส้ม + Technology Previews… ตัวอย่างเทคโนโลยี… + Temporary ชั่วคราว + Text Layer เลเยอร์ข้อความ + Texture Coverage ความครอบคลุมของพื้นผิว + Texturizer Texturizer + Thaw Mask ละลายมาสก์ + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. แคนวาสจะแสดงเป็น sRGB เลือกโปรไฟล์จอภาพใน แก้ไข > การตั้งค่าสี + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ผืนผ้าใบบน {display} แสดงเป็น sRGB เลือกโปรไฟล์จอภาพใน แก้ไข > การตั้งค่าสี + The current gradient (pick one in Window › Gradients) การไล่ระดับสีปัจจุบัน (เลือกหนึ่งในหน้าต่าง › การไล่ระดับสี) + The document does not have an embedded colour profile. Working space: {working}. The document does not have an embedded colour profile. Working space: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} เอกสารมีโปรไฟล์สีฝังอยู่ซึ่งไม่ตรงกับพื้นที่ทำงาน \n ฝังอยู่: {embedded} \n ทำงาน: {working} + The file could not be opened. ไม่สามารถเปิดไฟล์ได้ + The file could not be saved. ไม่สามารถบันทึกไฟล์ได้ + Thickness ความหนา + This Layer: เลเยอร์นี้: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? สมาร์ทออบเจ็กต์นี้ต้องได้รับการแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์วัตถุอัจฉริยะหรือไม่ + This adjustment ({key}) is kept as imported and can't be edited yet. การปรับเปลี่ยนนี้ ({key}) ถูกเก็บไว้เป็นการนำเข้าและยังไม่สามารถแก้ไขได้ + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? เลเยอร์เติมนี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาที่กรอกจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์เลเยอร์หรือไม่? + This layer must be rasterized before proceeding. เลเยอร์นี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ + This session: {reason} เซสชั่นนี้: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? เลเยอร์รูปร่างนี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ จะไม่สามารถแก้ไขได้ในรูปแบบรูปร่างอีกต่อไป เปลี่ยนรูปร่างใหม่หรือไม่? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? เลเยอร์ตัวอักษรนี้ต้องได้รับการแรสเตอร์ก่อนดำเนินการต่อ ข้อความจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์ประเภทหรือไม่ + Tile Size ขนาดกระเบื้อง + Tiles กระเบื้อง + Tilt-Shift เอียง-Shift + Tint Color สีอ่อน + Tiny Lens เลนส์จิ๋ว + Title ชื่อเรื่อง + To ถึง + To Center สู่ศูนย์ + Toggle Layer Mask Overlay สลับการซ้อนทับเลเยอร์มาสก์ + Toggle the Brush Settings panel สลับแผงการตั้งค่าแปรง + Toggle the Brush Settings panel (F5) สลับแผงการตั้งค่าแปรง (F5) + Tool Options ตัวเลือกเครื่องมือ + Trace Contour ติดตามรูปร่าง + Tracking (1/1000 em) การติดตาม (1/1000 em) + Transformation การเปลี่ยนแปลง + Transition การเปลี่ยนแปลง + Transparency Shapes Layer เลเยอร์รูปร่างโปร่งใส + Trap ดักสี + Tree ต้นไม้ + Trim ตัดแต่ง + Turbulent วุ่นวาย + Twirl หมุนวน + Twirl Clockwise หมุนตามเข็มนาฬิกา + Twirl Counterclockwise หมุนทวนเข็มนาฬิกา + Type Options ตัวเลือกตัวอักษร + Unaltered ไม่เปลี่ยนแปลง + Undefined Areas พื้นที่ที่ไม่ได้กำหนด + Underlying Layer: เลเยอร์พื้นฐาน: + Underpainting การทาสีด้านล่าง + Underwater ใต้น้ำ + Ungroup ยกเลิกการจัดกลุ่ม + Uniform เครื่องแบบ + Unite Shapes at Overlap รวมรูปร่างที่ทับซ้อนกัน + Unlink ยกเลิกการเชื่อมโยง + Unlock ปลดล็อค + Unlocked ปลดล็อคแล้ว + Unsaved Changes การเปลี่ยนแปลงที่ไม่ได้บันทึก + Unsharp Mask มาสก์ที่ไม่คมชัด + Unsupported file format รูปแบบไฟล์ที่ไม่รองรับ + Unsupported format รูปแบบที่ไม่รองรับ + Up ขึ้น + Update อัปเดต + Updates อัพเดท + Upper บน + Use Custom Color ใช้สีที่กำหนดเอง + Use File › Open to open an image. ใช้ไฟล์ › เปิดเพื่อเปิดภาพ + Use Selection In Source ใช้การเลือกในแหล่งที่มา + Use Selection In Target ใช้การเลือกในเป้าหมาย + Uses the foreground and background colors. ใช้สีพื้นหน้าและพื้นหลัง + Vanishing Point จุดที่หายไป + Variable Data Sets ชุดข้อมูลตัวแปร + Vector Mask Hides Effects เวกเตอร์มาสก์ซ่อนเอฟเฟกต์ + Vertical Distortion การบิดเบือนแนวตั้ง + Vertical Only แนวตั้งเท่านั้น + Vertical Type Tool เครื่องมือตัวอักษรแนวตั้ง + Vertical scale สเกลแนวตั้ง + Vertical type ประเภทแนวตั้ง + Video Frames to Layers เฟรมวิดีโอเป็นเลเยอร์ + Vignette Amount จำนวนบทความสั้น + Vignette Feather ขนนกวิกเน็ตต์ + Vignette Highlights ไฮไลท์บทความสั้น + Vignette Midpoint วิกเน็ตต์จุดกึ่งกลาง + Vignette Roundness วิกเน็ตต์ ความกลม + Vignette Style สไตล์วิกเน็ตต์ + Vine Color สีเถาวัลย์ + Vine With Flowers เถาวัลย์กับดอกไม้ + Vine With Leaves เถาวัลย์กับใบไม้ + Violent รุนแรง + W: ว: + WIA Support การสนับสนุน WIA + Warm อบอุ่น + Warming Filter (85) แผ่นฟิลเตอร์ความร้อน (85) + Warming L B A อุ่นเครื่อง L B A + Warming81 ภาวะโลกร้อน81 + Warming85 อุ่นเครื่อง85 + Warmth ความอบอุ่น + Warning คำเตือน + Warp Text ข้อความวาร์ป + Watercolor สีน้ำ + Watercolour look: lighter interior, darker rims. รูปลักษณ์แบบสีน้ำ: ภายในสีสว่างกว่า ขอบล้อสีเข้มกว่า + Wavelength Max ความยาวคลื่นสูงสุด + Wavelength Min ความยาวคลื่นต่ำสุด + Waves คลื่น + Web Large เว็บใหญ่ + Web Minimum เว็บขั้นต่ำ + Web Most Common เว็บที่พบบ่อยที่สุด + WebP (lossless) WebP (แบบไม่สูญเสีย) + Wetness ความเปียกชื้น + White Clip คลิปขาว + White Intensity ความเข้มของสีขาว + White Is High สีขาวอยู่สูง + Whole Word ทั้งคำ + Wide Blurry กว้าง เบลอ + Wide Sharp กว้างคม + Wind ลม + Window renderer: {renderer} ตัวแสดงผลหน้าต่าง: {renderer} + Working ทำงาน + Working Cmyk การทำงานของ Cmyk + Working Gray ทำงานสีเทา + Working Rgb RGB ทำงาน + Working-cmyk การทำงาน-cmyk + Workspaces พื้นที่ทำงาน + Wrap ห่อ + Yellows สีเหลือง + ZigZag ซิกแซก + Zigzag ซิกแซก + Zoom In (hold, drag to scrub) ซูมเข้า (กดค้าง ลากเพื่อขัด) + Zoom Out (hold) ซูมออก (กดค้างไว้) + auto อัตโนมัติ + blue สีน้ำเงิน + brushes แปรง + fallback ทางเลือก + gradients การไล่ระดับสี + gray สีเทา + green สีเขียว + layer mask มาสก์เลเยอร์ + manual คู่มือ + none ไม่มี + off ปิด + on บน + orange สีส้ม + previous reading kept; the last re-read failed: {reason} การอ่านครั้งก่อนเก็บไว้; การอ่านซ้ำครั้งล่าสุดล้มเหลว: {reason} + red สีแดง + unknown ไม่ทราบ + unknown type ประเภทที่ไม่รู้จัก + vector mask มาสก์เวกเตอร์ + violet สีม่วง + yellow สีเหลือง + {index} of {total} {index} of {total} + {label} is not available: {reason} {label} ไม่พร้อมใช้งาน: {reason} + {message} (notes: {n}; {warning}) {message} (notes: {n}; {warning}) + {shortcut} assigned. มอบหมาย {shortcut} แล้ว + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} ถูกใช้แล้วโดย {commands} และจะถูกลบออกจากมันเมื่อคุณคลิกตกลง + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ นำทาง ↵ วิ่ง esc ปิด + ✓ Color Indicates Masked Areas ✓ สีหมายถึงบริเวณที่ถูกปิดบัง + ✓ Color Indicates Selected Areas ✓ สีบ่งบอกถึงพื้นที่ที่เลือก +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear ชัดเจน +@id edit.purge.undo เลิกทำ +@id edit.stroke โรคหลอดเลือดสมอง +@id edit.transform.scale สเกล +@id file.close ปิด +@id filter.sharpen.sharpen ลับคม +@id layer.layerMask.apply สมัคร +@id select.all เลือกทั้งหมด +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} นำเข้า {n} แปรงจาก {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} นำเข้า {n} การไล่ระดับสีจาก {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} นำเข้า {n} รูปแบบจาก {file} +cameraRaw Balance ยอดคงเหลือ +cameraRaw Clarity ความชัดเจน +cameraRaw Color สี +cameraRaw Color Mixer เครื่องผสมสี +cameraRaw Contrast คอนทราสต์ +cameraRaw Curve เส้นโค้ง +cameraRaw Dehaze ลดหมอกควัน +cameraRaw Detail รายละเอียด +cameraRaw Effects ผลกระทบ +cameraRaw Exposure การเปิดรับแสง +cameraRaw Feather ขนนก +cameraRaw Highlights ไฮไลท์ +cameraRaw Luminance ความสว่าง +cameraRaw Masking การกำบัง +cameraRaw Midpoint จุดกึ่งกลาง +cameraRaw Midtones มิดโทน +cameraRaw Radius รัศมี +cameraRaw Roundness ความกลม +cameraRaw Saturation ความอิ่มตัว +cameraRaw Shadows เงา +cameraRaw Size ขนาด +cameraRaw Temperature อุณหภูมิ +cameraRaw Texture พื้นผิว +cameraRaw Tint โทนสี +cameraRaw Whites คนผิวขาว +colorRange Invert กลับสี +document Orientation ปฐมนิเทศ +layerLabel Magenta สีม่วงแดง +layerLabel No Color ไม่มีสี +layerLabel Red สีแดง +layers Fill เติม +layers Fill: กรอก: +navigator Zoom ซูม +shape Appearance ลักษณะที่ปรากฏ +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction ทิศทางของย่อหน้าอัตโนมัติ + Left-to-right paragraph direction ทิศทางของย่อหน้าจากซ้ายไปขวา + Right-to-left paragraph direction ทิศทางของย่อหน้าจากขวาไปซ้าย + Not rendered: this adjustment ({key}) is kept as imported. ไม่แสดงผล: การปรับนี้ ({key}) จะถูกเก็บไว้เป็นการนำเข้า +# Print preview image (#8236) + Print preview image ภาพตัวอย่างก่อนพิมพ์ +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube เว้ คิวบ์ + Brightness Cube ลูกบาศก์ความสว่าง + Color Wheel วงล้อสี + RGB Sliders แถบเลื่อน RGB + HSB Sliders สไลเดอร์ HSB + CMYK Sliders แถบเลื่อน CMYK + Lab Sliders แล็บสไลเดอร์ + Web Color Sliders ตัวเลื่อนสีของเว็บ + Copy Color as HTML คัดลอกสีเป็น HTML + Copy Color's Hex Code คัดลอกรหัสเลขฐานสิบหกของสี + RGB Spectrum สเปกตรัม RGB + CMYK Spectrum สเปกตรัม CMYK + Grayscale Ramp ทางลาดระดับสีเทา + Current Colors สีปัจจุบัน + Make Ramp Web Safe ทำให้ทางลาดเว็บปลอดภัย +# Art History Brush (#213). + Art History Brush Tool เครื่องมือแปรงประวัติศาสตร์ศิลปะ + Tight Short สั้นแน่น + Tight Medium แน่นปานกลาง + Tight Long แน่นยาว + Loose Medium หลวมปานกลาง + Loose Long ยาวหลวม + Dab ตบเบาๆ + Tight Curl หยิกแน่น + Loose Curl ลอนหลวม + Stylized dabs from a history state แต้มเก๋ๆ จากสถานะประวัติศาสตร์ + Font size presets ตั้งค่าขนาดตัวอักษรล่วงหน้า + Leading presets ค่าที่ตั้งล่วงหน้าเลเยอร์นำ + Tracking presets ติดตามที่ตั้งไว้ล่วงหน้า + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering การเรนเดอร์ซีพียู + Calculating… กำลังคำนวณ… + Click to refresh histogram with uncached data คลิกเพื่อรีเฟรชฮิสโตแกรมด้วยข้อมูลที่ไม่ได้แคช + Convert to Shape… แปลงเป็นรูปร่าง… + Cooling Filter (80) ฟิลเตอร์ความเย็น (80) + Cooling Filter (82) ฟิลเตอร์ความเย็น (82) + Cooling Filter (LBB) ฟิลเตอร์ความเย็น (LBB) + Count: จำนวน: + Delete Hidden Layers ลบเลเยอร์ที่ซ่อนอยู่ + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. ลากบนภาพเพื่อเพิ่มข้อจำกัด เลื่อนแนวนอน/แนวตั้ง; คลิกขวาเพื่อลบ + Drawn on the CPU: {reason}. Click for System Info. วาดบน CPU: {reason} คลิกเพื่อดูข้อมูลระบบ + Exported {n} files ส่งออก {n} ไฟล์แล้ว + Fit Layer on Screen พอดีเลเยอร์บนหน้าจอ + Flow: การไหล: + Level: ระดับ: + Percentile: เปอร์เซ็นไทล์: + Refine Edge… ปรับขอบ… + Release Notes บันทึกประจำรุ่น + Remember Settings จำการตั้งค่า + Reset Essentials รีเซ็ตสิ่งจำเป็น + Rounded Rectangle Tool เครื่องมือสี่เหลี่ยมผืนผ้าโค้งมน + Show Channels in Color แสดงช่องเป็นสี + Show Statistics แสดงสถิติ + Source: ที่มา: + Status Bar แถบสถานะ + Tile Horizontally กระเบื้องแนวนอน + Tile Vertically ปูกระเบื้องในแนวตั้ง + Uncached Refresh รีเฟรชที่ไม่ถูกแคช + Update and Restart อัปเดตและรีสตาร์ท + Warming Filter (81) แผ่นฟิลเตอร์ความร้อน (81) + Warming Filter (LBA) แผ่นฟิลเตอร์ความร้อน (LBA) +@plural {n} document|{n} documents {n} เอกสาร +@plural {n} pixel|{n} pixels {n} พิกเซล +cameraRaw Before (Y) ก่อน (ญ) +cameraRaw Camera Raw ({name}) กล้องดิบ ({name}) +cameraRaw Camera Raw Filter ({layer}) ฟิลเตอร์ Camera Raw ({layer}) +cameraRaw Camera Raw isn't open Camera Raw ไม่เปิด +cameraRaw Cancel ยกเลิก +cameraRaw Cancelled opening {name} ยกเลิกการเปิด {name} +cameraRaw Cancelled {label} ยกเลิก {label} +cameraRaw Color Priority ลำดับความสำคัญของสี +cameraRaw Developing {name} กำลังพัฒนา {name} +cameraRaw Full-resolution preview unavailable ไม่สามารถแสดงตัวอย่างแบบเต็มความละเอียดได้ +cameraRaw Grain ธัญพืช +cameraRaw Highlight Clipping Warning (O) คำเตือนการตัดไฮไลท์ (O) +cameraRaw Highlight Priority เน้นลำดับความสำคัญ +cameraRaw Load Settings… โหลดการตั้งค่า… +cameraRaw Load settings from a Camera Raw .xmp file โหลดการตั้งค่าจากไฟล์ Camera Raw .xmp +cameraRaw Loaded Camera Raw settings from {file} โหลดการตั้งค่า Camera Raw จาก {file} +cameraRaw Noise Reduction ลดเสียงรบกวน +cameraRaw OK ตกลง +cameraRaw Open เปิด +cameraRaw Opened {name} Opened {name} +cameraRaw Paint Overlay การทาสีทับซ้อน +cameraRaw Save Settings… บันทึกการตั้งค่า… +cameraRaw Save these settings as a Camera Raw .xmp file บันทึกการตั้งค่าเหล่านี้เป็นไฟล์ Camera Raw .xmp +cameraRaw Shadow Clipping Warning (U) คำเตือนเงาตัด (U) +cameraRaw Sharpening การลับคม +cameraRaw Show Lab Color Readouts แสดงการอ่านค่าสีของแล็บ +cameraRaw Show Red at 3 o'clock โชว์สีแดงเวลา 3 นาฬิกา +cameraRaw Show Selected Region แสดงภูมิภาคที่เลือก +cameraRaw Show Skin Tone Indicator แสดงตัวบ่งชี้สีผิว +cameraRaw Show Vectorscope แสดงเวคเตอร์สโคป +cameraRaw Tone Histogram โทนฮิสโตแกรม +cameraRaw Vignetting วิกเนต +cameraRaw White Balance: As Shot สมดุลแสงขาว: เท่าที่ถูกถ่าย + +# Strings main added after this catalog was written (integration batch 20). + Background (default) พื้นหลัง (ค่าเริ่มต้น) + Transparent (default) โปร่งใส (ค่าเริ่มต้น) + The path was kept as the Work Path เก็บเส้นทางไว้เป็นเส้นทางการทำงานแล้ว + The path was discarded ทิ้งเส้นทางแล้ว diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 007d756f89..ee2567d21e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -95,6 +95,7 @@ Right Sağ Open Recent Son Kullanılanları Aç Clear Recent Files Son Dosyaları Temizle + Clear Recent File List Son Dosyaları Temizle Remove from Recent Son Kullanılanlardan Kaldır Remove {name} from Recent {name} öğesini Son Kullanılanlardan kaldır Close Kapat @@ -103,6 +104,10 @@ Restore Geri yükle Close All Tümünü Kapat Close Others Diğerlerini Kapat + New Document… Yeni Belge… + Open Document… Aç… + Consolidate All to Here Tümünü Buraya Birleştir + Move to New Window Yeni Pencereye Taşı Save Kaydet Save As… Farklı Kaydet… Save a Copy… Bir Kopyasını Kaydet… @@ -154,6 +159,7 @@ Create Smart Object after Loading Layers Katmanları Yükledikten Sonra Akıllı Nesne Oluştur Reveal in Finder Finder'da Göster Show in Explorer Explorer'da Göster + Reveal in Explorer Explorer'da Göster Show in Folder Klasörde Göster File Info… Dosya Bilgileri… Print… Yazdır… @@ -242,6 +248,7 @@ Units & Rulers… Birimler ve Cetveller… Guides, Grid & Slices… Kılavuzlar, Izgaralar ve Dilimler… Plug-ins… Eklentiler… + Plugins… Eklentiler… Type… Metin… Enhanced Controls… Gelişmiş Kontroller… Camera Raw… Camera Raw… @@ -295,6 +302,8 @@ Match Color… Rengi Eşleştir… Replace Color… Rengi Değiştir… Equalize Eşitle + Equalize Selected Area Only Yalnızca Seçili Alanı Eşitle + Equalize Entire Image Based On Selected Area Tüm Görüntüyü Seçili Alana Göre Eşitle Auto Tone Otomatik Ton Auto Contrast Otomatik Kontrast Auto Color Otomatik Renk @@ -397,6 +406,8 @@ Smart Object Akıllı Nesne Convert to Smart Object Akıllı Nesneye Dönüştür Convert to Embedded Gömülüye Dönüştür + Embed Linked Bağlantılıyı Göm + Embed All Linked Tüm Bağlantılıları Göm Convert to Layers Katmanlara Dönüştür Convert to Linked… Bağlantılıya Dönüştür… Edit Contents İçeriği Düzenle @@ -469,7 +480,9 @@ Unite Shapes Şekil Alanlarını Birleştir Subtract Front Shape Ön Şekli Çıkar Intersect Shape Areas Şekil Alanlarını Kesiştir + Unite Shapes at Overlap Şekil Alanlarını Kesiştir Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut + Subtract Shapes at Overlap Çakışan Şekilleri Hariç Tut Merge Shape Components Şekil Bileşenlerini Birleştir Path operations Yol işlemleri Align Hizala @@ -546,7 +559,6 @@ Deselect Seçimi Kaldır Reselect Yeniden Seç Inverse Ters Çevir - Inverse Selection Ters Seçim Similar Benzer Color Range… Renk Aralığı… Focus Area… Odaklanma Alanı… @@ -652,6 +664,7 @@ Minimum… Minimum… Offset… Kaydırma… Plug-ins Eklentiler + Plugins Eklentiler Proof Setup Prova Kurulumu Proof Colors Prova Renkleri Gamut Warning Gamut Uyarısı @@ -660,12 +673,12 @@ Working Magenta Plate Çalışma Macenta Plakası Working Yellow Plate Çalışma Sarı Plakası Working Black Plate Çalışma Siyah Plakası - Working CMY Plate Çalışma CMY Plakası - Legacy Macintosh RGB Eski Macintosh RGB - Internet Standard RGB İnternet Standardı RGB + Working CMY Plates Çalışma CMY Plakaları + Legacy Macintosh RGB (Gamma 1.8) Eski Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) İnternet Standardı RGB (sRGB) Monitor RGB Monitör RGB - Color Blindness — Protanopia-type Renk Körlüğü — Protanopi tipi - Color Blindness — Deuteranopia-type Renk Körlüğü — Döteranopi tipi + Color Blindness - Protanopia-type Renk Körlüğü - Protanopi tipi + Color Blindness - Deuteranopia-type Renk Körlüğü - Döteranopi tipi Zoom In Yakınlaştır Zoom Out Uzaklaştır Fit on Screen Ekrana Sığdır @@ -744,7 +757,6 @@ Consolidate All to Tabs Tümünü Sekmelerde Birleştir Float in Window Pencerede Yüzdür Float All in Windows Tümünü Pencerelerde Yüzdür - New Window for Document Belge için Yeni Pencere Workspace Çalışma alanı Essentials Temel Öğeler Graphic and Web Grafik ve Web @@ -810,6 +822,12 @@ Timeline Zaman Çizelgesi Tool Presets Araç Ön Ayarları Tools Araçlar + Contextual Task Bar Bağlamsal Görev Çubuğu + Hide bar Çubuğu gizle + Reset bar position Çubuk konumunu sıfırla + Pin bar position Çubuk konumunu sabitle + Move bar Çubuğu taşı + More options Diğer seçenekler About PhotoCraft PhotoCraft Hakkında PhotoCraft Website PhotoCraft Web Sitesi PhotoCraft on GitHub GitHub'da PhotoCraft @@ -964,7 +982,7 @@ Click to add a point · drag off or {key}-click or Delete to remove Nokta eklemek için tıklayın · kaldırmak için dışarı sürükleyin, {key}-tıklayın veya Delete'e basın Click to apply, {key}-click to clear overrides, double-click for Style Options Uygulamak için tıklayın, geçersiz kılmaları temizlemek için {key}-tıklayın, Stil Seçenekleri için çift tıklayın Click to edit the gradient Degradeyi düzenlemek için tıklayın - Click to restore the current colour Geçerli rengi geri yüklemek için tıklayın + Click to restore the current color Geçerli rengi geri yüklemek için tıklayın Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Yakınlaştırmak için tıklayın · uzaklaştırmak için {key}-tıklayın · yakınlaştırmak/uzaklaştırmak için sağa/sola sürükleyin Click with the Note tool to add a note. Not eklemek için Not aracıyla tıklayın. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · İlk noktaya tıklayın: kapat · {key} bitiş · Esc iptal @@ -1046,7 +1064,7 @@ Darks Koyu Tonlar Data Sets Veri Kümeleri Default Varsayılan - Default colours (D) Varsayılan renkler (D) + Default Foreground and Background Colors (D) Varsayılan Ön Plan ve Arka Plan Renkleri (D) Default printer Varsayılan yazıcı Default restored. Varsayılan geri yüklendi. Define Tanımla @@ -1327,7 +1345,7 @@ Opacity Jitter Opaklık Titreşimi Opacity: Opaklık: Open a document to define variables. Değişkenleri tanımlamak için bir belge açın. - Open a document to select a colour range. Bir renk aralığı seçmek için bir belge açın. + Open a document to select a color range. Bir renk aralığı seçmek için bir belge açın. Open a document to use the timeline. Zaman çizelgesini kullanmak için bir belge açın. Optical Optik Optimized Optimize edilmiş @@ -1403,7 +1421,6 @@ Purity Saflık Quality Kalite Quick Actions Hızlı Eylemler - Quick Export As PNG PNG Olarak Hızlı Dışa Aktarma Radial Gradient Radyal Degrade Radius Yarıçap Radius: Yarıçap: @@ -1542,7 +1559,7 @@ Subtract from Sample Örnekten Çıkar Subtract from selection ({key}) Seçimden çıkar ({key}) Suggestions: Öneriler: - Swap colours (X) Renkleri değiştir (X) + Switch Foreground and Background Colors (X) Ön Plan ve Arka Plan Renklerini Değiştir (X) Swaps height and width Yüksekliği ve genişliği değiştirir Switch between free transform and warp modes Serbest dönüştürme ve bükme modları arasında geçiş yapın Switch theme Temayı değiştir @@ -1644,7 +1661,7 @@ Choke Daraltma Classic Klasik Click to add text Metin eklemek için tıklayın - Click to fill similar colours Benzer renkleri doldurmak için tıklayın + Click to fill similar colors Benzer renkleri doldurmak için tıklayın Clone Stamp Tool Klonlama Damgası Aracı Color Burn Renk Yakma Color Dodge Renk Soldurma @@ -1745,6 +1762,7 @@ Move Tool Taşıma Aracı Could not use the move tool because the layer is locked. Katman kilitli olduğundan taşıma aracı kullanılamadı. Could not complete the Free Transform command because the layer is locked. Katman kilitli olduğundan Serbest Dönüştürme komutu tamamlanamadı. + Could not transform because the layer is locked. Katman kilitli olduğundan dönüştürme yapılamadı. Multiply Çoğalt My Brushes Fırçalarım Neutrals Nötrler @@ -1803,7 +1821,7 @@ Rectangular Marquee Tool Dikdörtgen Seçim Çerçevesi Aracı Reds Kırmızılar Reflected Yansıyan - Sample: foreground colour Örnek: ön plan rengi + Sample: foreground color Örnek: ön plan rengi Sampled Colors Örneklenen Renkler Satin Saten Save Pixel Aspect Ratio Piksel En Boy Oranını Kaydet @@ -1824,6 +1842,7 @@ Soften Yumuşat Source Kaynak Source: set Kaynak: ayarlandı + Source: {name} Kaynak: {name} Sponge Tool Sünger Aracı Spot Healing Brush Tool Nokta Düzeltme Fırçası Aracı Spread Yayılma @@ -1841,7 +1860,6 @@ Transfer Aktarım Triangle Tool Üçgen Aracı Unsaved changes Kaydedilmemiş değişiklikler - Uses the foreground and background colours. Ön plan ve arka plan renklerini kullanır. Web Web Web Large Web Büyük Web Minimum Web Minimumu @@ -1878,6 +1896,7 @@ Allow scripts to connect Komut dosyalarının bağlanmasına izin ver Always Her zaman Always create smart objects when placing Yerleştirirken daima akıllı nesneler yaratın + Skip transform when placing Yerleştirirken dönüştürmeyi atla Animated zoom Animasyonlu yakınlaştırma Apply auto tone Otomatik tonu uygula Ask Sor @@ -1900,6 +1919,11 @@ Show canvas dots Tuval noktalarını göster Clip thumbnails to layer bounds Küçük resimleri katman sınırlarına kırp Clip Thumbnails to Layer Bounds Küçük resimleri katman sınırlarına kırp + No Thumbnails Küçük Resim Yok + Small Thumbnails Küçük Boyutlu Küçük Resimler + Medium Thumbnails Orta Boyutlu Küçük Resimler + Large Thumbnails Büyük Boyutlu Küçük Resimler + Clip Thumbnails to Document Bounds Küçük resimleri belge sınırlarına kırp Canvas color Tuval rengi Canvas custom color Tuval özel rengi Color picker Renk seçici @@ -2105,7 +2129,7 @@ Graphics Grafik Reset GPU Backend GPU Arka Ucunu Sıfırla Applies at next launch. Bir sonraki açılışta geçerlidir. - Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın + Click to sample the foreground color · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın {key}-click to set the source Kaynağı ayarlamak için {key} tuşuna basarak tıklayın {key} to subtract {key} çıkarmak için Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın) @@ -2164,7 +2188,7 @@ Gradient Options Degrade Seçenekleri Align with layer Katmanla hizala Reset Alignment Hizalamayı Sıfırla - Centre the gradient (offset 0, 0) Degradeyi ortalayın (kaydırma 0, 0) + Center the gradient (offset 0, 0) Degradeyi ortalayın (kaydırma 0, 0) Gradient Editor Degrade Düzenleyici Gradient picker Degrade seçici Gradient stops Degrade durakları @@ -2198,7 +2222,7 @@ Alt-click to define a source point to clone from Kopyalanacak kaynak noktayı tanımlamak için Alt tuşuna basarak tıklayın No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Seçim yapılabilecek yol yok: Kalem aracıyla bir yol çizin veya Yollar panelinden birini seçin. Cancel opening Açılışı iptal et - Cancelled opening {name} {name} açılışı iptal edildi + Canceled opening {name} {name} açılışı iptal edildi Damaged Photoshop document Hasarlı Photoshop belgesi The document “{name}” may be damaged. “{name}” belgesi hasarlı olabilir. Some layers, pixels or masks could not be read. Do you want to continue opening it? Bazı katmanlar, pikseller veya maskeler okunamadı. Açmaya devam etmek istiyor musunuz? @@ -2207,14 +2231,14 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. “{name}” belgesinin gömülü renk profili geçerli {mode} çalışma renk uzayıyla eşleşmiyor. Working: {working} Çalışma renk uzayı: {working} Developing {name} {name} geliştiriliyor - Cancelled {label} {label} iptal edildi + Canceled {label} {label} iptal edildi Esc to cancel İptal etmek için Esc Opened {name} {name} açıldı Opening {name} {name} açılıyor Saved {name} {name} kaydedildi Saving {name} {name} kaydediliyor A save to {name} is still running; try again when it finishes {name} konumuna kaydetme işlemi hâlâ devam ediyor; bittiğinde tekrar dene - {label} is writing the file and can no longer be cancelled {label} dosyayı yazıyor ve artık iptal edilemez + {label} is writing the file and can no longer be canceled {label} dosyayı yazıyor ve artık iptal edilemez Opening {name}… {name} açılıyor… Opening… Açılıyor… Reading… Okunuyor… @@ -2243,6 +2267,7 @@ History Brush Geçmiş Fırçası Paints from the History panel's source state Geçmiş panelinin kaynak durumundan boyar Sets the source for the history brush Geçmiş fırçasının kaynağını ayarlar + The History Brush source was deleted: set a new source in the History panel Geçmiş fırçasının kaynağı silindi: Geçmiş panelinde yeni bir kaynak ayarlayın Color Replacement Renk Değiştirme New Group Yeni Grup New Group from Layers… Katmanlardan Yeni Grup… @@ -2260,6 +2285,7 @@ Punctuation Noktalama işaretleri Make Work Path Çalışma Yolu Oluştur Make Selection Seçim Yap + Make Shape Şekil Yap Fill Path Yolu Doldur Stroke Path Kontur Yolu Delete Path Yolu Sil @@ -2278,7 +2304,7 @@ Pixel Piksel Adjustment Ayarlama Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Noktaları sürükleyin (kement) veya tıklayın (çokgen) · {add} ekle · {sub} çıkar - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarlar yeniden boyutlandırır ({ratio} oran, {centre} merkez) · çizerken Space taşır · ok tuşları hafifçe kaydırır · X yönü değiştirir · {commit} onaylar · Esc iptal eder + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarlar yeniden boyutlandırır ({ratio} oran, {centre} merkez) · çizerken Space taşır · ok tuşları hafifçe kaydırır · X yönü değiştirir · {commit} onaylar · Esc iptal eder {mode} Color · {bits} bit {mode} Renk · {bits} bit Scrubby Zoom Sürüklemeli Yakınlaştırma Auto Erase Otomatik Silme @@ -2370,7 +2396,7 @@ Discard Layers and Save a Copy Katmanları At ve Kopyasını Kaydet Don't show again Bir daha gösterme Save failed: {error} Kaydetme başarısız oldu: {error} - Save cancelled Kaydetme iptal edildi + Save canceled Kaydetme iptal edildi Style {name} saved {name} stili kaydedildi Make Default Varsayılan Yap Reset to Default Varsayılana Sıfırla @@ -2503,7 +2529,7 @@ fontWeight Italic İtalik Rotate View Görünümü Döndür Rotate View Tool Görünümü Döndürme Aracı Reset View Görünümü Sıfırla - Drag around the centre to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkez etrafında sürükleyin · Shift 15° ile sınırlar + Drag around the center to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkez etrafında sürükleyin · Shift 15° ile sınırlar @id select.all Tümünü Seç @id edit.stroke Kontur… @id edit.transform.scale Ölçekle @@ -2538,7 +2564,6 @@ cameraRaw Color Renk Remove All Tümünü Kaldır Unlock Kilidi Aç Enable Etkinleştir - Enable animations Animasyonları etkinleştir Disable Devre dışı bırak On Açık Selected Seçilen @@ -3374,6 +3399,8 @@ cameraRaw Color Renk Condensed Dar Conditional Mode Change Koşullu Mod Değişikliği Background (Default) Arka plan (varsayılan) + Background (default) Arka plan (varsayılan) + Transparent (default) Saydam (varsayılan) Content-Aware Fill İçeriğe Duyarlı Dolgu Convert Frames > Video Layers Kareleri Dönüştür > Video Katmanları Convert to Linked Bağlantılıya Dönüştür @@ -3686,8 +3713,8 @@ cameraRaw Whites Beyazlar @id filter.sharpen.sharpen Keskinleştir Good iyi Best En iyi - Out of gamut for printing: click for the nearest printable colour Yazdırma için gamın dışında: yazdırılabilir en yakın renk için tıklayın - Not a web colour: click for the nearest web colour Web rengi değil: en yakın web rengi için tıklayın + Out of gamut for printing: click for the nearest printable color Yazdırma için gamın dışında: yazdırılabilir en yakın renk için tıklayın + Not a web color: click for the nearest web color Web rengi değil: en yakın web rengi için tıklayın Adjustment Properties Ayarlama Özellikleri Brush Stroke Fırça Vuruşu Default Foreground and Background Colors Varsayılan Ön Plan ve Arka Plan Renkleri @@ -3980,7 +4007,7 @@ cameraData Lens Lens Auto Add/Delete Otomatik Ekle/Sil Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Yazdırma Ayarlarında ayrıca Ayna Görüntüsünü açmayın: iki yansıtma birbirini iptal eder. Print Settings… Yazdırma Ayarları… - Remember to turn off the printer's own colour management in Print Settings… Yazdırma Ayarlarında yazıcının kendi renk yönetimini kapatmayı unutmayın… + Remember to turn off the printer's own color management in Print Settings… Yazdırma Ayarlarında yazıcının kendi renk yönetimini kapatmayı unutmayın… Sent to {printer} {printer} yazıcısına gönderildi set in Print Settings… Yazdırma Ayarlarında belirlenir… ICO is limited to 256 × 256 pixels ICO 256 × 256 piksel ile sınırlıdır @@ -4137,12 +4164,12 @@ cameraData Lens Lens Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır Demosaic Demozaikleme - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kameranın renk filtresi mozaiğinin nasıl doldurulduğu. Dosya açılırken uygulanır; önizleme dosyanın açıldığı yöntemi gösterir. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kameranın renk filtresi mozaiğinin nasıl doldurulduğu. Dosya açılırken uygulanır; önizleme dosyanın açıldığı yöntemi gösterir. AHD (adaptive homogeneity) AHD (uyarlamalı homojenlik) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: en temiz kenarlar ve en az renk saçağı, en yavaş. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradyan düzeltmeli ara değerleme: keskin ve hızlı, ince ayrıntılarda biraz renk saçağı. - The fastest and the softest, with colour fringing on fine detail. En hızlı ve en yumuşak, ince ayrıntılarda renk saçağı. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: en temiz kenarlar ve en az renk saçağı, en yavaş. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Gradyan düzeltmeli ara değerleme: keskin ve hızlı, ince ayrıntılarda biraz renk saçağı. + The fastest and the softest, with color fringing on fine detail. En hızlı ve en yumuşak, ince ayrıntılarda renk saçağı. “{name}” already exists. Replace it? '{name}' zaten var. Değiştirilsin mi? The name in the save dialog was “{name}”. Kaydetme penceresindeki ad '{name}' idi. Previous Brush Önceki fırça @@ -4225,6 +4252,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Yazı tipi boyutu hazır ayarları Leading presets Satır aralığı hazır ayarları Tracking presets İzleme hazır ayarları + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Geçersiz sayısal giriş. {min}% ile {max}% arasında bir tam sayı gerekiyor. Önceki değer eklendi. Color Aware Renge Duyarlı Decontaminate Colors Renkleri Arındır @@ -4254,3 +4283,78 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Durak Rengi Shapes can't use patterns yet Şekiller henüz desen kullanamaz Edit Brush… Fırçayı Düzenle… + +# Action Stop message (#8386) + Message İleti +# Playback Options (#5212) + Playback Options… Oynatma Seçenekleri… + Playback Options Oynatma Seçenekleri + Accelerated Hızlandırılmış + Step by Step Adım adım + Pause For: Şu kadar duraklat: + Pause For Şu kadar duraklat + seconds saniye + Create a single history state Tek bir geçmiş durumu oluştur + Generator enabled Oluşturucu etkin + Move Up Yukarı taşı + Move Down Aşağı taşı + Grid view Izgara görünümü + List view Liste görünümü + On your computer Bilgisayarınızda + New file Yeni dosya + Learn Öğrenin + Website Web sitesi + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Çerçeve afin dönüşümleri destekler; perspektif dönüşümleri için içindeki görüntüyü seçin + A frame supports affine transforms; select its image to warp it Çerçeve afin dönüşümleri destekler; çarpıtmak için içindeki görüntüyü seçin + Convert to Frame… Çerçeveye Dönüştür… + Create a new custom frame Yeni özel çerçeve oluştur + Create a new polygonal frame Yeni çokgen çerçeve oluştur + Create a new triangular frame Yeni üçgen çerçeve oluştur + Fit Content to Frame İçeriği Çerçeveye Sığdır + Fit Frame to Content Çerçeveyi İçeriğe Sığdır + Frame coordinates must be finite and within 600000 pixels Çerçeve koordinatları sonlu olmalı ve 600000 piksel sınırları içinde kalmalıdır + Import Image… Görüntü İçe Aktar… + Invalid frame transform Geçersiz çerçeve dönüşümü + Invalid nested file drop Geçersiz iç içe dosya bırakma + New Frame Yeni Çerçeve + Replace Image… Görüntüyü Değiştir… + The frame is locked Çerçeve kilitli + The frame is unavailable Çerçeve kullanılamıyor + +# Channels panel menu (#11451) + New Channel… Yeni Kanal… + Duplicate Channel Kanalı Çoğalt + Delete Channel Kanalı Sil + New Spot Channel… Yeni Spot Kanal… + Merge Spot Channel Spot Kanalı Birleştir + Channel Options… Kanal Seçenekleri… + Split Channels Kanalları Ayır + Merge Channels… Kanalları Birleştir… + Specify Channels: Kanalları Belirt: + Not enough grayscale documents for this mode. Bu mod için yeterli gri tonlamalı belge yok. + Pick a different document for each channel. Her kanal için farklı bir belge seçin. + A picked document is no longer open. Seçilen belgelerden biri artık açık değil. + A picked document is no longer a grayscale document of the same size. Seçilen belgelerden biri artık aynı boyutta gri tonlamalı bir belge değil. +# Move tool options bar (#11381) + Align top edges Üst kenarları hizala + Align vertical centers Dikey merkezleri hizala + Align bottom edges Alt kenarları hizala + Distribute vertically Dikey olarak dağıt + Distribute horizontally Yatay olarak dağıt + Show layer bounds on hover Üzerine gelince katman sınırlarını göster + Expand layer group on click Tıklayınca katman grubunu genişlet + Show hover bounds from Layers Katmanlar'dan üzerine gelme sınırlarını göster + Saved Camera Raw settings to {file} Camera Raw ayarları {file} dosyasına kaydedildi + Save the settings to each raw's sidecar without opening it Ayarları her raw dosyasının yan dosyasına kaydet, açmadan + No settings saved: these raws can't keep a sidecar here Hiçbir şey kaydedilmedi: bu raw dosyaları burada yan dosyaya sahip olamaz + Some settings could not be saved; the raws stay open Bazı ayarlar kaydedilemedi; raw dosyaları açık kalıyor + Nothing to save: these raws are at their default settings Kaydedilecek bir şey yok: bu raw dosyaları varsayılan ayarlarda +# Artboard tool options bar (#11385) + Distribute Widths Genişlikleri Dağıt + Distribute Heights Yükseklikleri Dağıt + Auto-nest Layers Katmanları Otomatik İç İçe Yerleştir + Auto-size Canvas Tuvali Otomatik Boyutlandır + Keep Relative Position During Layer Reordering Katmanları Yeniden Sıralarken Göreli Konumu Koru + Shrinkwrap Canvas on Save Kaydederken Tuvali İçeriğe Göre Küçült diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 68128b8584..8035a7f224 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -255,6 +255,7 @@ Right Правий Open Recent Нещодавні файли Clear Recent Files Очистити список нещодавніх файлів + Clear Recent File List Очистити список нещодавніх файлів Remove from Recent Вилучити з нещодавніх Remove {name} from Recent Вилучити {name} з нещодавніх Close Закрити @@ -263,6 +264,10 @@ Restore Відновити Close All Закрити всі Close Others Закрити інші + New Document… Новий документ… + Open Document… Відкрити… + Consolidate All to Here Об'єднати все тут + Move to New Window Перемістити в нове вікно Save Зберегти Save As… Зберегти як… Save a Copy… Зберегти копію… @@ -275,7 +280,6 @@ Export As… Експортувати як… Export Експорт Export… Експортувати… - Quick Export As PNG Швидкий експорт у PNG Quick Export as PNG Швидкий експорт у PNG Place Embedded… Розмістити вбудований… Place Linked… Розмістити пов'язаний… @@ -457,7 +461,6 @@ All Усе Deselect Зняти виділення Reselect Відновити виділення - Inverse Selection Інвертувати виділення Inverse Інвертувати Color Range… Колірний діапазон… Select and Mask… Виділити та маскувати… @@ -646,7 +649,7 @@ Match Location Узгодити розташування Match Rotation Узгодити поворот Match All Узгодити все - New Window for Document Нове вікно для документа + New Window Нове вікно Workspace Робочий простір Extensions Розширення Panels Панелі @@ -676,6 +679,12 @@ Timeline Шкала часу Tool Presets Набори інструментів Tools Інструменти + Contextual Task Bar Контекстна панель завдань + Hide bar Сховати панель + Reset bar position Скинути положення панелі + Pin bar position Закріпити положення панелі + Move bar Перемістити панель + More options Інші параметри Libraries Бібліотеки 3D 3D Toolbars Панелі інструментів @@ -730,8 +739,8 @@ Background Фон Set foreground color Установити колір переднього плану Set background color Установити колір фону - Swap colours (X) Поміняти кольори місцями (X) - Default colours (D) Типові кольори (D) + Switch Foreground and Background Colors (X) Поміняти кольори місцями (X) + Default Foreground and Background Colors (D) Типові кольори (D) Brush Tool Options Параметри пензля Opacity Непрозорість Flow Потік @@ -816,6 +825,7 @@ Units & Rulers… Одиниці та лінійки… Guides, Grid & Slices… Напрямні, сітка та фрагменти… Plug-ins… Плагіни… + Plugins… Плагіни… Technology Previews Попередній перегляд технологій Scratch Disks… Робочі диски… Export Preferences… Налаштування експорту… @@ -957,6 +967,8 @@ Match Color… Узгодити колір… Replace Color… Замінити колір… Equalize Вирівняти гістограму + Equalize Selected Area Only Вирівняти лише виділену область + Equalize Entire Image Based On Selected Area Вирівняти все зображення за виділеною областю HDR Toning… Тонування HDR… Shadows/Highlights… Тіні/світлі ділянки… Amount Величина @@ -1038,8 +1050,8 @@ ACES ACES Industry Standard Галузевий стандарт Web Веб - Internet Standard RGB Стандартний RGB для інтернету - Legacy Macintosh RGB Застарілий Macintosh RGB + Internet Standard RGB (sRGB) Стандартний RGB для інтернету (sRGB) + Legacy Macintosh RGB (Gamma 1.8) Застарілий Macintosh RGB (Gamma 1.8) Monitor RGB RGB монітора Windows LCD Windows LCD Coated CMYK CMYK для крейдованого паперу @@ -1308,6 +1320,7 @@ Path to Illustrator… Контур до Illustrator… Make Work Path Створити робочий контур Make Selection Створити виділення + Make Shape Створити фігуру Fill Path Залити контур Stroke Path Обвести контур Clipping Path Відсічний контур @@ -1682,7 +1695,7 @@ Revert “{name}” to the last saved version? Your changes will be lost. Повернути «{name}» до останньої збереженої версії? Ваші зміни буде втрачено. {key} to subtract {key}, щоб відняти ↑↓ navigate {key} run esc close ↑↓ навігація {key} виконати esc закрити - Click to restore the current colour Клацніть, щоб відновити поточний колір + Click to restore the current color Клацніть, щоб відновити поточний колір Click with the Note tool to add a note. Клацніть інструментом «Нотатка», щоб додати нотатку. Drag a rectangle around the object Намалюйте прямокутник навколо об'єкта Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Перетягніть на зображенні, щоб додати обмеження; Shift для горизонталі/вертикалі; клацніть правою кнопкою на обмеженні, щоб змінити або видалити його. @@ -1706,6 +1719,7 @@ Move Tool Інструмент «Переміщення» Could not use the move tool because the layer is locked. Не вдалося використати інструмент «Переміщення», бо шар заблоковано. Could not complete the Free Transform command because the layer is locked. Не вдалося виконати команду «Вільна трансформація», бо шар заблоковано. + Could not transform because the layer is locked. Не вдалося трансформувати, бо шар заблоковано. Rectangular Marquee Tool Інструмент «Прямокутне виділення» Elliptical Marquee Tool Інструмент «Еліптичне виділення» Single Row Marquee Tool Інструмент «Виділення одного рядка» @@ -1763,10 +1777,10 @@ Drag to move the active layer Перетягніть, щоб перемістити активний шар Drag to pan · hold Space with any tool Перетягніть для панорамування · утримуйте пробіл із будь-яким інструментом Drag to draw a gradient Перетягніть, щоб намалювати градієнт - Click to fill similar colours Клацніть, щоб залити подібні кольори + Click to fill similar colors Клацніть, щоб залити подібні кольори Click to add text Клацніть, щоб додати текст Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Перетягування (ласо) або клацання точок (багатокутне) · {add} додати · {sub} відняти - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Намалюйте рамку кадрування · перетягування всередині переміщує · краї змінюють розмір ({ratio} пропорції, {centre} центр) · пробіл переміщує під час малювання · стрілки зсувають · X змінює орієнтацію · {commit} підтверджує · Esc скасовує + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Намалюйте рамку кадрування · перетягування всередині переміщує · краї змінюють розмір ({ratio} пропорції, {centre} центр) · пробіл переміщує під час малювання · стрілки зсувають · X змінює орієнтацію · {commit} підтверджує · Esc скасовує {mode} Color · {bits} bit Колір {mode} · {bits} біт Drag to move the layer Перетягніть, щоб перемістити шар Fit Screen Умістити на екрані @@ -1798,7 +1812,6 @@ Appearance Вигляд Text Position: Положення тексту: Text color Колір тексту - Uses the foreground and background colours. Використовує кольори переднього плану та фону. Answer the unsaved-changes prompt first Спочатку дайте відповідь у діалозі незбережених змін Unsaved changes Незбережені зміни Don't Save Не зберігати @@ -1818,10 +1831,11 @@ name назва new новий Go to Layer Перейти до шару - Open a document to select a colour range. Відкрийте документ, щоб виділити колірний діапазон. + Open a document to select a color range. Відкрийте документ, щоб виділити колірний діапазон. Saved Збережено Reveal in Finder Показати у Finder Show in Explorer Показати у Провіднику + Reveal in Explorer Показати у Провіднику Show in Folder Показати в теці Before (Y) До (Y) Save Settings… Зберегти налаштування… @@ -1979,12 +1993,14 @@ Character Styles Panel Панель стилів символів Clear Canvas Guides Очистити напрямні полотна Clear Selected Artboard Guides Очистити напрямні вибраної монтажної області - Color Blindness — Deuteranopia-type Порушення сприйняття кольору — дейтеранопія - Color Blindness — Protanopia-type Порушення сприйняття кольору — протанопія + Color Blindness - Deuteranopia-type Порушення сприйняття кольору - дейтеранопія + Color Blindness - Protanopia-type Порушення сприйняття кольору - протанопія Color Table… Таблиця кольорів… Combine Shapes Об'єднати фігури Contract… Стиснути… Convert to Embedded Перетворити на вбудований + Embed Linked Вбудувати пов'язаний + Embed All Linked Вбудувати всі пов'язані Convert to Layers Перетворити на шари Convert to Linked… Перетворити на пов'язаний… Count Підрахунок @@ -2007,6 +2023,7 @@ Enhanced Controls… Розширені елементи керування… Entropy Ентропія Exclude Overlapping Shapes Виключити перекриття фігур + Subtract Shapes at Overlap Виключити перекриття фігур Exit Вийти Expand… Розширити… Extra Large Дуже великий @@ -2032,6 +2049,7 @@ Indexed Color… Індексований колір… Integrations… Інтеграції… Intersect Shape Areas Перетин областей фігур + Unite Shapes at Overlap Перетин областей фігур Kurtosis Ексцес Large Великий Layer Edges Краї шару @@ -2073,6 +2091,7 @@ Pixelate Пікселізація Place Scale Marker… Розмістити маркер масштабу… Plug-ins Плагіни + Plugins Плагіни Print One Copy Надрукувати одну копію Print… Друк… Range Діапазон @@ -2117,7 +2136,7 @@ Vertical Centers Вертикальні центри Video Frames to Layers… Відеокадри в шари… WIA Support… Підтримка WIA… - Working CMY Plate Робоча форма CMY + Working CMY Plates Робочі форми CMY Absolute Абсолютний Action Дія Adaptive Адаптивний @@ -2150,7 +2169,7 @@ Canvas Полотно Capture Зберегти стан Center Crop Marks Центральні мітки обрізання - Centre the gradient (offset 0, 0) Центрувати градієнт (зсув 0, 0) + Center the gradient (offset 0, 0) Центрувати градієнт (зсув 0, 0) Gradient Editor Редактор градієнтів Gradient picker Вибір градієнта Gradient stops Точки градієнта @@ -2307,7 +2326,7 @@ Roughness Шорсткість Roundness Округлість Rulers & Grids Лінійки та сітки - Sample: foreground colour Зразок: колір переднього плану + Sample: foreground color Зразок: колір переднього плану Sampling: Background Swatch Вибірка: зразок фону Sampling: Continuous Вибірка: безперервна Sampling: Once Вибірка: одноразова @@ -2373,6 +2392,7 @@ Allow scripts to connect Дозволити підключення скриптів Always Завжди Always create smart objects when placing Завжди створювати смартоб'єкти під час розміщення + Skip transform when placing Пропускати трансформацію під час розміщення Animated zoom Анімоване масштабування Apply auto tone Застосовувати автоматичну тонову корекцію Ask Запитувати @@ -2394,6 +2414,11 @@ Show canvas dots Показувати крапки полотна Clip thumbnails to layer bounds Обрізати мініатюри до меж шару Clip Thumbnails to Layer Bounds Обрізати мініатюри до меж шару + No Thumbnails Без мініатюр + Small Thumbnails Малі мініатюри + Medium Thumbnails Середні мініатюри + Large Thumbnails Великі мініатюри + Clip Thumbnails to Document Bounds Обрізати мініатюри до меж документа Canvas color Колір полотна Canvas custom color Довільний колір полотна Classic Класична @@ -2520,7 +2545,6 @@ Show only crosshair while painting Показувати лише перехрестя під час малювання Show slice numbers Показувати номери фрагментів Show tooltips Показувати підказки - Enable animations Увімкнути анімацію Show highlight on rollover Показувати підсвічування під час наведення Show bounding box when dragging layer Показувати рамку під час перетягування шару System title bar Системний рядок заголовка @@ -2562,7 +2586,7 @@ Alt-click to define a source point to clone from Alt+клацання, щоб визначити точку джерела клонування No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Немає контуру для створення виділення: намалюйте його пером або виберіть на панелі контурів. Cancel opening Скасувати відкриття - Cancelled opening {name} Відкриття {name} скасовано + Canceled opening {name} Відкриття {name} скасовано Damaged Photoshop document Пошкоджений документ Photoshop The document “{name}” may be damaged. Документ «{name}», можливо, пошкоджено. Some layers, pixels or masks could not be read. Do you want to continue opening it? Не вдалося прочитати деякі шари, пікселі або маски. Продовжити відкриття? @@ -2572,7 +2596,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. Вбудований колірний профіль документа «{name}» не відповідає поточному робочому простору {mode}. Working: {working} Робочий простір: {working} Developing {name} Обробка {name} - Cancelled {label} Скасовано: {label} + Canceled {label} Скасовано: {label} Esc to cancel Esc для скасування Opened {name} Відкрито: {name} Opening {name} Відкриття {name} @@ -2580,7 +2604,7 @@ Saving {name} Збереження {name} Saving {percent} Збереження {percent} A save to {name} is still running; try again when it finishes Збереження в {name} ще триває; спробуйте ще раз, коли воно завершиться - {label} is writing the file and can no longer be cancelled {label}: триває запис файлу, скасувати вже не можна + {label} is writing the file and can no longer be canceled {label}: триває запис файлу, скасувати вже не можна Opening {name}… Відкриття {name}… Opening… Відкриття… Reading… Читання… @@ -2671,7 +2695,7 @@ cameraRaw Roughness Шорсткість Discard Layers and Save a Copy Відкинути шари та зберегти копію Don't show again Більше не показувати Save failed: {error} Не вдалося зберегти: {error} - Save cancelled Збереження скасовано + Save canceled Збереження скасовано Couldn't place {name}: {error} Не вдалося розмістити {name}: {error} Magnetic Lasso Tool Інструмент «Магнітне ласо» Frequency Частота @@ -2800,7 +2824,7 @@ fontWeight Italic Курсивний Impressionist Імпресіоністичний Rotate View Tool Інструмент «Поворот перегляду» Reset View Скинути перегляд - Drag around the centre to rotate the view · Shift constrains to 15° Перетягніть навколо центру, щоб повернути перегляд · Shift обмежує крок до 15° + Drag around the center to rotate the view · Shift constrains to 15° Перетягніть навколо центру, щоб повернути перегляд · Shift обмежує крок до 15° (coming soon) (незабаром) Add a layer style Додати стиль шару Move effect up Перемістити ефект вгору @@ -2995,6 +3019,7 @@ fontWeight Italic Курсивний Soft М'який Soften Пом'якшити Source: set Джерело: задано + Source: {name} Джерело: {name} Spread Поширення Square, 12 x 12 Квадратна, 12 x 12 Square, 5 x 5 Квадратна, 5 x 5 @@ -3013,7 +3038,7 @@ fontWeight Italic Курсивний Import Brushes… (.abr or image) Імпортувати пензлі… (.abr або зображення) Import Brush from Image… Імпортувати пензель із зображення… System Info Інформація про систему - Click to sample the foreground colour · {key}-click for background Клацання бере зразок переднього плану · {key}+клацання для фону + Click to sample the foreground color · {key}-click for background Клацання бере зразок переднього плану · {key}+клацання для фону Create Warped Text Створити деформований текст Rasterize Smart Object Растеризувати смартоб'єкт Rasterize Fill Content Растеризувати вміст заливки @@ -3680,9 +3705,9 @@ cameraRaw Masking Маскування All Layers No Adjustments Усі шари без коригувань Color to Alpha… Колір у прозорість… Current & Below No Adjustments Поточний і нижчі без коригувань - Not a web colour: click for the nearest web colour Колір поза вебпалітрою: клацніть, щоб вибрати найближчий колір із неї + Not a web color: click for the nearest web color Колір поза вебпалітрою: клацніть, щоб вибрати найближчий колір із неї Now drag the selection onto another area Тепер перетягніть виділення на іншу ділянку - Out of gamut for printing: click for the nearest printable colour Колір поза колірним охопленням друку: клацніть, щоб вибрати найближчий колір, придатний для друку + Out of gamut for printing: click for the nearest printable color Колір поза колірним охопленням друку: клацніть, щоб вибрати найближчий колір, придатний для друку Point Sample Точкова вибірка Sample Size: Розмір вибірки: Show Sampling Ring Показувати кільце вибірки @@ -3984,11 +4009,13 @@ curves Smooth Згладити Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій Background (Default) Фон (типовий) + Background (default) Фон (типовий) + Transparent (default) Прозорий (типовий) Include All Subfolders Включити всі вкладені папки Auto Add/Delete Автододавання/видалення Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не вмикайте також дзеркальне відображення в параметрах друку: два відображення скасовують одне одного. Print Settings… Параметри друку… - Remember to turn off the printer's own colour management in Print Settings… Не забудьте вимкнути керування кольором самого принтера в параметрах друку… + Remember to turn off the printer's own color management in Print Settings… Не забудьте вимкнути керування кольором самого принтера в параметрах друку… Sent to {printer} Надіслано на {printer} set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів @@ -4036,6 +4063,7 @@ curves Smooth Згладити Automatically Check for Updates Автоматично перевіряти оновлення Paints from the History panel's source state Малює з вихідного стану панелі «Історія» Sets the source for the history brush Задає джерело для пензля історії + The History Brush source was deleted: set a new source in the History panel Джерело для пензля історії було видалено: задайте нове джерело на панелі «Історія» Highlight Mode Режим світлих тонів Highlight Color Колір світлих тонів Highlight Opacity Непрозорість світлих тонів @@ -4132,12 +4160,12 @@ curves Smooth Згладити This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? Demosaic Демозаїк - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Як відновлюється мозаїка кольорового фільтра камери. Застосовується під час відкриття файлу; попередній перегляд показує метод, з яким файл було відкрито. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Як відновлюється мозаїка кольорового фільтра камери. Застосовується під час відкриття файлу; попередній перегляд показує метод, з яким файл було відкрито. AHD (adaptive homogeneity) AHD (адаптивна однорідність) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Хіракава–Паркс: найчистіші краї й найменше кольорової облямівки, найповільніший. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Інтерполяція з корекцією градієнта: різка й швидка, з невеликою кольоровою облямівкою на дрібних деталях. - The fastest and the softest, with colour fringing on fine detail. Найшвидший і найм'якший, з кольоровою облямівкою на дрібних деталях. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Хіракава–Паркс: найчистіші краї й найменше кольорової облямівки, найповільніший. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Інтерполяція з корекцією градієнта: різка й швидка, з невеликою кольоровою облямівкою на дрібних деталях. + The fastest and the softest, with color fringing on fine detail. Найшвидший і найм'якший, з кольоровою облямівкою на дрібних деталях. Memory Usage Використання пам’яті Per-document RAM limit (MB) Ліміт RAM на документ (МБ) Installed RAM: {ram} MB; this limit: {percent}% Встановлено RAM: {ram} МБ; цей ліміт: {percent}% @@ -4206,6 +4234,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Набори розмірів шрифту Leading presets Набори інтерліньяжу Tracking presets Набори трекінгу + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Неприпустиме числове значення. Потрібне ціле число від {min}% до {max}%. Вставлено попереднє значення. Clear Selection Очистити виділення Color Aware З урахуванням кольору @@ -4231,3 +4261,80 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Колір контрольної точки Shapes can't use patterns yet Фігури поки не можуть використовувати візерунки Edit Brush… Редагувати пензель… + +# Action Stop message (#8386) + Message Повідомлення +# Playback Options (#5212) + Playback Options… Параметри відтворення… + Playback Options Параметри відтворення + Accelerated Прискорене + Step by Step Покрокове + Pause For: Пауза на: + Pause For Пауза на + seconds с + Create a single history state Створювати один стан історії + Drag to move the active shape's path or the Work Path Перетягніть, щоб перемістити контур активної фігури або робочий контур + Generator enabled Генератор увімкнено + Move Up Перемістити вгору + Move Down Перемістити вниз + Grid view Режим сітки + List view Режим списку + On your computer На вашому комп’ютері + New file Новий файл + Learn Навчання + No recent files Немає останніх файлів + Website Вебсайт + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Рамка підтримує афінні перетворення; для перспективних перетворень виберіть її зображення + A frame supports affine transforms; select its image to warp it Рамка підтримує афінні перетворення; для деформації виберіть її зображення + Convert to Frame… Перетворити на рамку… + Create a new custom frame Створити нову довільну рамку + Create a new polygonal frame Створити нову багатокутну рамку + Create a new triangular frame Створити нову трикутну рамку + Fit Content to Frame Припасувати вміст до рамки + Fit Frame to Content Припасувати рамку до вмісту + Frame coordinates must be finite and within 600000 pixels Координати рамки мають бути скінченними та в межах 600000 пікселів + Import Image… Імпортувати зображення… + Invalid frame transform Неприпустиме перетворення рамки + Invalid nested file drop Неприпустиме вкладене перетягування файлу + New Frame Нова рамка + Replace Image… Замінити зображення… + The frame is locked Рамку заблоковано + The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + New Channel… Новий канал… + Duplicate Channel Дублювати канал + Delete Channel Видалити канал + New Spot Channel… Новий канал плашкового кольору… + Merge Spot Channel Об'єднати канал плашкового кольору + Channel Options… Параметри каналу… + Split Channels Розділити канали + Merge Channels… Об'єднати канали… + Specify Channels: Вказати канали: + Not enough grayscale documents for this mode. Недостатньо документів у градаціях сірого для цього режиму. + Pick a different document for each channel. Виберіть окремий документ для кожного каналу. + A picked document is no longer open. Один із вибраних документів більше не відкрито. + A picked document is no longer a grayscale document of the same size. Один із вибраних документів більше не є документом у градаціях сірого того самого розміру. +# Move tool options bar (#11381) + Align top edges Вирівняти верхні краї + Align vertical centers Вирівняти центри по вертикалі + Align bottom edges Вирівняти нижні краї + Distribute vertically Розподілити по вертикалі + Distribute horizontally Розподілити по горизонталі + Show layer bounds on hover Показувати межі шару під час наведення + Expand layer group on click Розгортати групу шарів за клацанням + Show hover bounds from Layers Показувати межі під час наведення з панелі «Шари» + Saved Camera Raw settings to {file} Налаштування Camera Raw збережено в {file} + Save the settings to each raw's sidecar without opening it Зберегти налаштування в супровідний файл кожного raw, не відкриваючи його + No settings saved: these raws can't keep a sidecar here Нічого не збережено: ці raw тут не можуть мати супровідного файлу + Some settings could not be saved; the raws stay open Не вдалося зберегти деякі налаштування; raw залишаються відкритими + Nothing to save: these raws are at their default settings Нічого зберігати: ці raw мають типові налаштування +# Artboard tool options bar (#11385) + Distribute Widths Розподілити ширину + Distribute Heights Розподілити висоту + Auto-nest Layers Автоматично вкладати шари + Auto-size Canvas Автоматично змінювати розмір полотна + Keep Relative Position During Layer Reordering Зберігати відносне положення під час зміни порядку шарів + Shrinkwrap Canvas on Save Зменшувати полотно до вмісту під час збереження diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv new file mode 100644 index 0000000000..4d5eaf7961 --- /dev/null +++ b/crates/ui-egui/src/i18n/ur.tsv @@ -0,0 +1,4574 @@ +# Urdu (ur) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists +# the language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# modern Urdu computing and photo-editing vocabulary (تہہ, برش, انتخاب, کینوس ...). No proprietary +# localisation resource, string table or product UI was consulted or copied; add entries the same +# way. Urdu shares much Perso-Arabic vocabulary with Farsi; where a shared term fit naturally the +# choice was cross-checked against fa.tsv, but phrasing and grammar follow Urdu usage throughout, +# not Farsi's. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in +# Latin letters, as do key names and placeholders; the trailing "…" of dialog commands is kept. +# Urdu plural rule (as used in casual software UI): one|other. +# Machine-translated with Claude (Anthropic) for @tronicum; not yet reviewed by a native speaker. + + Mask Options… ماسک کے اختیارات… + Layer Mask Display Options… تہہ ماسک کی نمائش کے اختیارات… + Channel Color چینل کا رنگ + Color Indicates: رنگ ظاہر کرتا ہے: + Solidity: ٹھوس پن: + Masked Areas ماسک شدہ حصے + Selected Areas منتخب حصے + Ink Characteristics سیاہی کی خصوصیات + Paper Width کاغذ کی چوڑائی + Paper Height کاغذ کی اونچائی + Columns کالم + Rows قطاریں + Number تعداد + Margin حاشیہ + Top: اوپر: + Left: بائیں: + Bottom: نیچے: + Right: دائیں: + Target: ہدف: + Selected Artboards منتخب آرٹ بورڈز + Center Columns کالم درمیان میں کریں + Clear Existing Guides موجودہ رہنما لکیریں ہٹائیں + Invalid guide layout رہنما ترتیب درست نہیں + Leave empty for automatic size خودکار حجم کے لیے خالی چھوڑیں + 5 Columns, 3 Rows 5 کالم، 3 قطاریں + 8 Columns 8 کالم + 12 Columns 12 کالم + File فائل + Edit ترمیم + Image تصویر + Layer تہہ + Type متن + Select انتخاب + Selected منتخب + Scope دائرہ کار + Top Left اوپر بائیں + Top Right اوپر دائیں + Bottom Left نیچے بائیں + Bottom Right نیچے دائیں + Filter فلٹر + View منظر + Window ونڈو + Help مدد + (coming soon) (جلد آ رہا ہے) + New… نیا… + New from Clipboard کلپ بورڈ سے نیا + Open… کھولیں… + Open As… بطور کھولیں… + Open EXR Parts… EXR پارٹس کھولیں… + Add beauty precomp from the AOVs AOVs سے بیوٹی پری کمپ شامل کریں + Both دونوں + Left بائیں + Right دائیں + Open Recent حالیہ کھولیں + Clear Recent Files حالیہ فائلیں صاف کریں + Remove from Recent حالیہ سے ہٹائیں + Remove {name} from Recent {name} کو حالیہ سے ہٹائیں + Close بند کریں + Minimize چھوٹا کریں + Maximize بڑا کریں + Restore بحال کریں + Close All سب بند کریں + Close Others دیگر بند کریں + Save محفوظ کریں + Save As… محفوظ کریں بطور… + Save a Copy… ایک نقل محفوظ کریں… + {name} can't keep everything in this document {name} اس دستاویز میں سب کچھ محفوظ نہیں رکھ سکتا + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. کوئی ایسا فارمیٹ چنیں جو اسے محفوظ رکھے، جیسے فوٹوشاپ (PSD)، یا ایک نقل محفوظ کریں۔ + Revert واپس لوٹائیں + Export برآمد کریں + Quick Export as PNG فوری طور پر PNG کے بطور برآمد کریں + Export As… برآمد کریں بطور… + Export Preferences… برآمدی ترجیحات… + Save for Web (Legacy)… ویب کے لیے محفوظ کریں (پرانا)… + Artboards to Files… آرٹ بورڈز کو فائلوں میں… + Artboards to PDF… آرٹ بورڈز کو PDF میں… + Layers to Files… تہیں فائلوں میں… + Layer Comps to Files… تہہ کمپوزیشنز فائلوں میں… + Color Lookup Tables… رنگ تلاش جدولیں… + Data Sets as Files… ڈیٹا سیٹس بطور فائلیں… + Paths to Illustrator… پاتھ الیسٹریٹر کو… + Render Video… ویڈیو رینڈر کریں… + Generate تخلیق کریں + Image Assets تصویری اثاثے + Place Embedded… ایمبیڈڈ رکھیں… + Place Linked… لنک شدہ رکھیں… + Package… پیکج… + Automate خودکار بنائیں + Batch… بیچ… + Create Droplet… ڈراپلیٹ بنائیں… + Crop and Straighten Photos تصاویر تراشیں اور سیدھی کریں + Contact Sheet II… کانٹیکٹ شیٹ II… + Conditional Mode Change… مشروط موڈ تبدیلی… + Fit Image… تصویر فٹ کریں… + Lens Correction… لینز تصحیح… + Merge to HDR Pro… HDR پرو میں ضم کریں… + Photomerge… فوٹو مرج… + Scripts اسکرپٹس + Image Processor… امیج پروسیسر… + Delete All Empty Layers تمام خالی تہیں حذف کریں + Flatten All Layer Effects تمام تہہ اثرات چپٹے کریں + Flatten All Masks تمام ماسک چپٹے کریں + Script Events Manager… اسکرپٹ ایونٹس مینیجر… + Import درآمد کریں + Browse… براؤز کریں… + Source Files ماخذ فائلیں + Use: استعمال کریں: + Files فائلیں + Folder فولڈر + Add Open Files کھلی فائلیں شامل کریں + Sort By Name نام کے مطابق ترتیب دیں + Attempt to Automatically Align Source Images ماخذ تصاویر کو خودکار طور پر ہم آہنگ کرنے کی کوشش کریں + Create Smart Object after Loading Layers تہیں لوڈ کرنے کے بعد سمارٹ آبجیکٹ بنائیں + Reveal in Finder فائنڈر میں دکھائیں + Show in Explorer ایکسپلورر میں دکھائیں + Show in Folder فولڈر میں دکھائیں + File Info… فائل کی معلومات… + Print… پرنٹ کریں… + Print One Copy ایک کاپی پرنٹ کریں + Exit اختتام + Undo کالعدم کریں + Redo دوبارہ کریں + Toggle Last State آخری حالت بدلیں + Fade… فیڈ… + Cut کاٹیں + Copy کاپی کریں + Copy Merged ضم شدہ کاپی کریں + Paste چسپاں کریں + Paste Special خصوصی چسپاں + Paste in Place اسی جگہ چسپاں کریں + Paste Into اندر چسپاں کریں + Paste Outside باہر چسپاں کریں + Paste Lorem Ipsum لورم اپسم چسپاں کریں + Clear صاف کریں + Search… تلاش کریں… + Check Spelling… املا جانچیں… + Find and Replace Text… متن تلاش اور تبدیل کریں… + Add to Dictionary لغت میں شامل کریں + No Spelling Suggestions کوئی املا تجویز نہیں + Fill… بھریں… + Stroke… لکیر… + Content-Aware Fill… مواد سے آگاہ بھرائی… + Delete and Fill Selection انتخاب حذف کریں اور بھریں + Content-Aware Scale مواد سے آگاہ پیمانہ + Puppet Warp پپٹ وارپ + Perspective Warp تناظری وارپ + Free Transform آزاد ہیئت تبدیلی + Free Transform a Copy ایک نقل کی آزاد ہیئت تبدیلی + Transform ہیئت تبدیلی + Again دوبارہ + Transform Again on a Copy ایک نقل پر دوبارہ ہیئت تبدیلی + Scale پیمانہ + Rotate گھمائیں + Set horizontal skew افقی ترچھا پن مقرر کریں + Set vertical skew عمودی ترچھا پن مقرر کریں + Skew ترچھا کریں + Distort مسخ کریں + Perspective تناظر + Warp موڑ + Flip Horizontal افقی پلٹیں + Flip Vertical عمودی پلٹیں + Rotate 180° 180° گھمائیں + Rotate 90° Clockwise 90° گھڑی وار گھمائیں + Rotate 90° Counter Clockwise 90° گھڑی کے خلاف گھمائیں + 180° 180° + 90° Clockwise 90° گھڑی وار + 90° Counter Clockwise 90° گھڑی کے خلاف + Arbitrary… صوابدیدی… + Auto-Align Layers… تہیں خودکار ہم آہنگ کریں… + Auto-Blend Layers… تہیں خودکار ملائیں… + Define Brush Preset… برش پری سیٹ متعین کریں… + Define Pattern… نمونہ متعین کریں… + Define Custom Shape… مخصوص شکل متعین کریں… + Purge صاف کریں + Clipboard کلپ بورڈ + Histories تاریخیں + Video Cache ویڈیو کیشے + All سب + Presets پری سیٹس + Preset Manager… پری سیٹ مینیجر… + Migrate Presets پری سیٹس منتقل کریں + Export/Import Presets… پری سیٹس برآمد/درآمد کریں… + Color Settings… رنگ کی ترتیبات… + Assign Profile… پروفائل تفویض کریں… + Convert to Profile… پروفائل میں تبدیل کریں… + Keyboard Shortcuts… کی بورڈ شارٹ کٹس… + Menus… مینیوز… + Toolbar… ٹول بار… + Preferences ترجیحات + General… عام… + Interface… انٹرفیس… + Workspace… ورک اسپیس… + Tools… ٹولز… + History Log… تاریخ لاگ… + File Handling… فائل ہینڈلنگ… + Export… برآمد… + Performance… کارکردگی… + Scratch Disks… اسکریچ ڈسکس… + Cursors… کرسرز… + Transparency & Gamut… شفافیت اور رنگ دائرہ کار… + Units & Rulers… اکائیاں اور رولرز… + Guides, Grid & Slices… رہنما لکیریں، گرڈ اور سلائسز… + Plug-ins… پلگ انز… + Type… متن… + Enhanced Controls… بہتر کنٹرولز… + Camera Raw… کیمرہ را… + Raw Defaults… را ڈیفالٹس… + Integrations… انضمام… + AI Integrations… AI انضمام… + Install Plug-in… پلگ ان نصب کریں… + Language Options زبان کے اختیارات + Settings… ترتیبات… + Mode موڈ + Bitmap بٹ میپ + Grayscale گرے اسکیل + Duotone ڈوٹون + Indexed Color انڈیکسڈ کلر + RGB Color RGB رنگ + CMYK Color CMYK رنگ + Lab Color Lab رنگ + Multichannel ملٹی چینل + 8 Bits/Channel 8 بٹس فی چینل + 8 bit 8 بٹ + Integer عدد صحیح + 16 Bits/Channel 16 بٹس فی چینل + 16 bit 16 بٹ + 32 Bits/Channel 32 بٹس فی چینل + 32 bit (float) 32 بٹ (فلوٹ) + Floating point فلوٹنگ پوائنٹ + Color Table… رنگ جدول… + Bitmap… بٹ میپ… + Duotone… ڈوٹون… + Indexed Color… انڈیکسڈ کلر… + Adjustments ایڈجسٹمنٹس + Brightness/Contrast… چمک/تباین… + Levels… لیولز… + Curves… وکر… + Exposure… نمائش… + Vibrance… رونق… + Hue/Saturation… رنگت/اشباع… + Color Balance… رنگ توازن… + Black & White… سیاہ و سفید… + Photo Filter… فوٹو فلٹر… + Channel Mixer… چینل مکسر… + Color Lookup… رنگ تلاش… + Invert معکوس کریں + Posterize… پوسٹرائز… + Threshold… حد… + Gradient Map… گریڈیئنٹ نقشہ… + Selective Color… منتخب رنگ… + Shadows/Highlights… سائے/نمایاں حصے… + HDR Toning… HDR ٹوننگ… + Desaturate بے رنگ کریں + Match Color… رنگ ملائیں… + Replace Color… رنگ بدلیں… + Equalize برابر کریں + Auto Tone خودکار ٹون + Auto Contrast خودکار تباین + Auto Color خودکار رنگ + Image Size… تصویر کا حجم… + Canvas Size… کینوس کا حجم… + Image Rotation تصویر کی گردش + Flip Canvas Horizontal کینوس کو افقی پلٹیں + Flip Canvas Vertical کینوس کو عمودی پلٹیں + Crop تراشیں + Trim… تراش… + Reveal All سب ظاہر کریں + Duplicate… نقل بنائیں… + Apply Image… تصویر لاگو کریں… + Calculations… حسابات… + Variables متغیرات + Define… متعین کریں… + Data Sets… ڈیٹا سیٹس… + Apply Data Set… ڈیٹا سیٹ لاگو کریں… + Variable Data Sets… متغیر ڈیٹا سیٹس… + Trap… ٹریپ… + Analysis تجزیہ + Set Measurement Scale… پیمائش کا پیمانہ مقرر کریں… + Select Data Points… ڈیٹا پوائنٹس منتخب کریں… + Record Measurements پیمائشیں ریکارڈ کریں + Ruler Tool رولر ٹول + Count Tool شمار ٹول + Place Scale Marker… پیمانہ نشان رکھیں… + Custom… مخصوص… + New نیا + Layer… تہہ… + Group… گروپ… + Group from Layers… تہوں سے گروپ… + New Group from Layers… تہوں سے نیا گروپ… + Artboard… آرٹ بورڈ… + Artboard from Group… گروپ سے آرٹ بورڈ… + Artboard from Layers… تہوں سے آرٹ بورڈ… + Layer via Copy کاپی کے ذریعے تہہ + Layer via Cut کٹ کے ذریعے تہہ + Layer From Background… پس منظر سے تہہ… + Layer from Background… پس منظر سے تہہ… + Background from Layer تہہ سے پس منظر + Background From Layer تہہ سے پس منظر + New Shape Layer نئی شکل تہہ + New Type Layer نئی متن تہہ + New Fill Layer نئی بھرائی تہہ + New Adjustment Layer نئی ایڈجسٹمنٹ تہہ + New Layer Based Slice نئی تہہ پر مبنی سلائس + New Smart Object via Copy کاپی کے ذریعے نیا سمارٹ آبجیکٹ + New Blank Video Layer نئی خالی ویڈیو تہہ + New Video Layer from File… فائل سے نئی ویڈیو تہہ… + Duplicate Layer… تہہ کی نقل بنائیں… + Delete Layer تہہ حذف کریں + Delete حذف کریں + Rename Layer تہہ کا نام تبدیل کریں + Create Layer تہہ بنائیں + Layer Style تہہ کا اسٹائل + Blending Options… امتزاج کے اختیارات… + Bevel & Emboss… بیول اور ایمبوس… + Use Global Light عالمی روشنی استعمال کریں + Overprint اوور پرنٹ + Inner Shadow… اندرونی سایہ… + Inner Glow… اندرونی چمک… + Satin… ساٹن… + Color Overlay… رنگ اوورلے… + Gradient Overlay… گریڈیئنٹ اوورلے… + Pattern Overlay… نمونہ اوورلے… + Outer Glow… بیرونی چمک… + Drop Shadow… ڈراپ شیڈو… + Copy Layer Style تہہ کا اسٹائل کاپی کریں + Paste Layer Style تہہ کا اسٹائل چسپاں کریں + Clear Layer Style تہہ کا اسٹائل صاف کریں + Global Light… عالمی روشنی… + Hide All Effects تمام اثرات چھپائیں + Scale Effects… اثرات کا پیمانہ… + Smart Filter سمارٹ فلٹر + Convert for Smart Filters سمارٹ فلٹرز کے لیے تبدیل کریں + Clear Smart Filters سمارٹ فلٹرز صاف کریں + Disable Smart Filters سمارٹ فلٹرز غیر فعال کریں + Delete Filter Mask فلٹر ماسک حذف کریں + Disable Filter Mask فلٹر ماسک غیر فعال کریں + Solid Color… ٹھوس رنگ… + Gradient… گریڈیئنٹ… + Pattern… نمونہ… + Layer Mask تہہ ماسک + Vector Mask ویکٹر ماسک + Reveal Selection انتخاب ظاہر کریں + Hide Selection انتخاب چھپائیں + Hide All سب چھپائیں + From Transparency شفافیت سے + Delete Vector Mask ویکٹر ماسک حذف کریں + Enable Layer Mask تہہ ماسک فعال کریں + Disable Layer Mask تہہ ماسک غیر فعال کریں + Enable Vector Mask ویکٹر ماسک فعال کریں + Link Layer Mask تہہ ماسک مربوط کریں + Unlink Layer Mask تہہ ماسک غیر مربوط کریں + Link Vector Mask ویکٹر ماسک مربوط کریں + Add Vector Mask ویکٹر ماسک شامل کریں + Vector Mask from Current Path موجودہ پاتھ سے ویکٹر ماسک + Create Clipping Mask کلپنگ ماسک بنائیں + Release Clipping Mask کلپنگ ماسک ختم کریں + Smart Objects سمارٹ آبجیکٹس + Smart Object سمارٹ آبجیکٹ + Convert to Smart Object سمارٹ آبجیکٹ میں تبدیل کریں + Convert to Embedded ایمبیڈڈ میں تبدیل کریں + Convert to Layers تہوں میں تبدیل کریں + Convert to Linked… لنک شدہ میں تبدیل کریں… + Edit Contents مواد میں ترمیم کریں + Replace Contents… مواد تبدیل کریں… + Relink to File… فائل سے دوبارہ لنک کریں… + Export Contents… مواد برآمد کریں… + Update Modified Content تبدیل شدہ مواد اپ ڈیٹ کریں + Update All Modified Content تمام تبدیل شدہ مواد اپ ڈیٹ کریں + Stack Mode اسٹیک موڈ + Choose Stack Mode: اسٹیک موڈ چنیں: + Entropy اینٹروپی + Kurtosis کرٹوسس + Maximum زیادہ سے زیادہ + Mean اوسط + Median درمیانی قدر + Minimum کم سے کم + Range حد + Skewness سکیونیس + Standard Deviation معیاری انحراف + Summation مجموعہ + Variance ویری ئنس + Video Layers ویڈیو تہیں + Interpret Footage… فوٹیج کی تشریح کریں… + Replace Footage… فوٹیج تبدیل کریں… + Reload Frame فریم دوبارہ لوڈ کریں + Show Altered Video تبدیل شدہ ویڈیو دکھائیں + Restore Frame فریم بحال کریں + Restore All Frames تمام فریم بحال کریں + Insert Blank Frame خالی فریم شامل کریں + Delete Frame فریم حذف کریں + Duplicate Frame فریم کی نقل بنائیں + Frame from Layers تہوں سے فریم + Video Frames to Layers… ویڈیو فریمز کو تہوں میں… + Rasterize ریسٹرائز کریں + Rasterize Type متن ریسٹرائز کریں + Rasterize Type Layer متن تہہ ریسٹرائز کریں + Rasterize Shape شکل ریسٹرائز کریں + New Guide Layout… نیا رہنما خاکہ… + New Guides From Shape شکل سے نئی رہنما لکیریں + Lock Layers… تہیں مقفل کریں… + Link Layers تہیں مربوط کریں + Select Linked Layers مربوط تہیں منتخب کریں + Merge Layers تہیں ضم کریں + Merge Down نیچے ضم کریں + Merge Group گروپ ضم کریں + Merge Visible نظر آنے والی ضم کریں + Stamp Visible نظر آنے والی اسٹیمپ کریں + Select Layer Above اوپر والی تہہ منتخب کریں + Select Layer Below نیچے والی تہہ منتخب کریں + Select Top Layer سب سے اوپر کی تہہ منتخب کریں + Select Bottom Layer سب سے نیچے کی تہہ منتخب کریں + Add Layer Above to Selection اوپر کی تہہ انتخاب میں شامل کریں + Add Layer Below to Selection نیچے کی تہہ انتخاب میں شامل کریں + Stamp Down نیچے اسٹیمپ کریں + Flatten Image تصویر چپٹی کریں + Matting میٹنگ + Color Decontaminate… رنگ آلودگی دور کریں… + Remove Black Matte کالا میٹ ہٹائیں + Remove White Matte سفید میٹ ہٹائیں + Defringe… کنارہ صفائی… + Group Layers تہیں گروپ کریں + Ungroup Layers تہوں کا گروپ ختم کریں + Hide Layers تہیں چھپائیں + Arrange ترتیب + Bring to Front سب سے آگے لائیں + Bring Forward آگے لائیں + Send Backward پیچھے بھیجیں + Send to Back سب سے پیچھے بھیجیں + Reverse الٹ کریں + Combine Shapes شکلیں یکجا کریں + Unite Shapes شکلیں ملائیں + Subtract Front Shape اگلی شکل منفی کریں + Intersect Shape Areas شکل کے حصے ملائیں + Exclude Overlapping Shapes اوورلیپنگ شکلیں خارج کریں + Merge Shape Components شکل اجزاء ضم کریں + Path operations پاتھ عملیات + Align ترتیب + Top Edges اوپر کے کنارے + Vertical Centers عمودی مراکز + Bottom Edges نیچے کے کنارے + Left Edges بائیں کنارے + Horizontal Centers افقی مراکز + Right Edges دائیں کنارے + Distribute تقسیم کریں + Vertically عمودی طور پر + Horizontally افقی طور پر + Layer Edges تہہ کے کنارے + Isolate Layers تہیں الگ کریں + Layer Content Options… تہہ مواد کے اختیارات… + Fill Content مواد بھریں + Deselect Layers تہیں غیر منتخب کریں + Find Layers تہیں تلاش کریں + Layer Comps تہہ کمپوزیشنز + Layers تہیں + All Layers تمام تہیں + Hidden Layers چھپی ہوئی تہیں + Mask All Objects تمام اشیاء ماسک کریں + Subject موضوع + Sky آسمان + Panels پینلز + Character Panel کریکٹر پینل + Paragraph Panel پیراگراف پینل + Character Styles Panel کریکٹر اسٹائلز پینل + Paragraph Styles Panel پیراگراف اسٹائلز پینل + Glyphs Panel گلفس پینل + Anti-Alias اینٹی ایلیاس + Sharp تیز + Crisp صاف + Strong مضبوط + Smooth ملائم + Windows LCD ونڈوز LCD + Windows ونڈوز + Orientation سمت بندی + Horizontal افقی + Vertical عمودی + OpenType OpenType + Standard Ligatures معیاری لگیچرز + Contextual Alternates سیاق و سباق کے مطابق متبادل + Discretionary Ligatures اختیاری لگیچرز + Swash سواش + Oldstyle پرانے انداز + Stylistic Alternates اسلوبیاتی متبادل + Titling Alternates عنوانی متبادل + Ornaments زیورات + Ordinals ترتیبی اعداد + Fractions کسور + Create Work Path ورک پاتھ بنائیں + Convert to Shape شکل میں تبدیل کریں + Warp Text… متن موڑیں… + Convert to Point Text پوائنٹ متن میں تبدیل کریں + Convert to Paragraph Text پیراگراف متن میں تبدیل کریں + Default Features ڈیفالٹ خصوصیات + East Asian Features مشرقی ایشیائی خصوصیات + Middle Eastern Features مشرق وسطیٰ کی خصوصیات + Middle Eastern & South Asian Composer مشرق وسطیٰ اور جنوبی ایشیائی کمپوزر + Update All Text Layers تمام متن تہیں اپ ڈیٹ کریں + Replace All Missing Fonts تمام غائب فونٹس تبدیل کریں + Resolve Missing Fonts… غائب فونٹس حل کریں… + Font Preview Size فونٹ پیش منظر کا حجم + None کوئی نہیں + Small چھوٹا + Medium درمیانہ + Large بڑا + Extra Large بہت بڑا + Huge وسیع + Load Default Type Styles ڈیفالٹ متن اسٹائلز لوڈ کریں + Save Default Type Styles ڈیفالٹ متن اسٹائلز محفوظ کریں + Deselect غیر منتخب کریں + Reselect دوبارہ منتخب کریں + Inverse الٹ + Inverse Selection انتخاب الٹائیں + Similar مماثل + Color Range… رنگ کی حد… + Focus Area… فوکس ایریا… + Select and Mask… منتخب کریں اور ماسک کریں… + Smart Radius سمارٹ ریڈیس + Shift Edge کنارہ منتقل کریں + Decontaminate آلودگی دور کریں + Output آؤٹ پٹ + Refine Edge کنارہ بہتر کریں + Modify تبدیل کریں + Border… بارڈر… + Smooth… ملائم کریں… + Expand… وسیع کریں… + Contract… تنگ کریں… + Feather… فیدر… + Grow بڑھائیں + Transform Selection انتخاب کی ہیئت تبدیل کریں + Edit in Quick Mask Mode کوئیک ماسک موڈ میں ترمیم کریں + Load Selection… انتخاب لوڈ کریں… + Save Selection… انتخاب محفوظ کریں… + Match All سب سے مماثل + Last Filter آخری فلٹر + Filter Gallery… فلٹر گیلری… + Adaptive Wide Angle… اڈاپٹیو وائیڈ اینگل… + Camera Raw Filter… کیمرہ را فلٹر… + Liquify… لیکویفائی… + Vanishing Point… وینشنگ پوائنٹ… + Blur دھندلا پن + Blur Gallery دھندلا پن گیلری + Field Blur… فیلڈ دھندلاپن… + No pin selected کوئی پن منتخب نہیں + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. پن شامل کرنے کے لیے تصویر پر کلک کریں؛ اسے منتقل کرنے کے لیے پن کو گھسیٹیں، یا اس کا دھندلاپن بدلنے کے لیے اس کی انگوٹھی کو۔ حذف منتخب پن ہٹا دیتا ہے۔ + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. حجم بدلنے اور گھمانے کے لیے خاکے کے ہینڈلز کو گھسیٹیں، کونوں کو گول کرنے کے لیے مربع کو، اور دھندلاپن کہاں سے شروع ہو یہ طے کرنے کے لیے اندر کے نقطوں کو۔ + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. بینڈ کو منتقل کرنے کے لیے پن کو گھسیٹیں، یا اس کا دھندلاپن بدلنے کے لیے اس کی انگوٹھی کو۔ تیز بینڈ کو چوڑا کرنے کے لیے ٹھوس لکیریں، منتقلی طے کرنے کے لیے دھرائی لکیریں، اور اسے گھمانے کے لیے نقطے گھسیٹیں۔ + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. حجم بدلنے اور گھمانے کے لیے خاکے کے ہینڈلز کو گھسیٹیں؛ انگوٹھی دھندلاپن کا زاویہ طے کرتی ہے۔ + Blur Angle دھندلاپن کا زاویہ + Iris Blur… آئیرس دھندلاپن… + Tilt-Shift… ٹلٹ شفٹ… + Path Blur… پاتھ دھندلاپن… + Spin Blur… اسپن دھندلاپن… + Blur More مزید دھندلا کریں + Box Blur… باکس دھندلاپن… + Gaussian Blur… گاؤسی دھندلاپن… + Lens Blur… لینز دھندلاپن… + Motion Blur… حرکت دھندلاپن… + Radial Blur… شعاعی دھندلاپن… + Shape Blur… شکل دھندلاپن… + Smart Blur… سمارٹ دھندلاپن… + Surface Blur… سطحی دھندلاپن… + Average اوسط + Displace… نقل مکانی… + Pinch… چٹکی… + Polar Coordinates… قطبی متناسقات… + Ripple… لہر… + Shear… قینچی پن… + Spherize… کروی بنائیں… + Twirl… بل دیں… + Wave… موج… + ZigZag… زگ زیگ… + Noise شور + Add Noise… شور شامل کریں… + Despeckle دھبے ختم کریں + Dust & Scratches… گرد و خراش… + Median… درمیانی قدر… + Reduce Noise… شور کم کریں… + Pixelate پکسلیٹ کریں + Color Halftone… رنگین ہاف ٹون… + Crystallize… کرسٹلائز کریں… + Facet فیسٹ + Fragment ٹکڑے کریں + Mezzotint… میزوٹنٹ… + Mosaic… موزیک… + Pointillize… پوائنٹلائز… + Render رینڈر + Clouds بادل + Difference Clouds فرق کے بادل + Fibers… ریشے… + Flame… شعلہ… + Lens Flare… لینز فلیئر… + Lighting Effects… روشنی کے اثرات… + Relight… دوبارہ روشنی دیں… + Picture Frame… تصویری فریم… + Tree… درخت… + Sharpen تیز کریں + Sharpen Edges کنارے تیز کریں + Sharpen More مزید تیز کریں + Smart Sharpen… سمارٹ شارپن… + Unsharp Mask… ان شارپ ماسک… + High Pass… ہائی پاس… + Stylize اسٹائلائز + Diffuse… پھیلائیں… + Emboss… ایمبوس… + Extrude… نکالیں… + Find Edges کنارے تلاش کریں + Oil Paint… آئل پینٹ… + Solarize سولرائز + Tiles… ٹائلز… + Trace Contour… خاکہ کھینچیں… + Wind… ہوا… + Video ویڈیو + De-Interlace… ڈی انٹرلیس… + NTSC Colors NTSC رنگ + Other دیگر + Maximum… زیادہ سے زیادہ… + Minimum… کم سے کم… + Offset… آفسیٹ… + Plug-ins پلگ انز + Proof Setup پروف سیٹ اپ + Proof Colors پروف رنگ + Gamut Warning رینج کی تنبیہ + Working CMYK ورکنگ CMYK + Working Cyan Plate ورکنگ سیان پلیٹ + Working Magenta Plate ورکنگ میجنٹا پلیٹ + Working Yellow Plate ورکنگ یلو پلیٹ + Working Black Plate ورکنگ بلیک پلیٹ + Working CMY Plate ورکنگ CMY پلیٹ + Legacy Macintosh RGB لیگیسی میکنٹوش RGB + Internet Standard RGB انٹرنیٹ معیاری RGB + Monitor RGB مانیٹر RGB + Color Blindness — Protanopia-type رنگ اندھا پن — پروٹانوپیا قسم + Color Blindness — Deuteranopia-type رنگ اندھا پن — ڈیوٹیرانوپیا قسم + Zoom In بڑا کریں + Zoom Out چھوٹا کریں + Fit on Screen اسکرین پر فٹ کریں + Fit Layer(s) on Screen تہہ(یں) اسکرین پر فٹ کریں + Fit Artboard on Screen آرٹ بورڈ اسکرین پر فٹ کریں + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size پرنٹ حجم + Pixel Aspect Ratio پکسل تناسب + Pixel Aspect Ratio Correction پکسل تناسب تصحیح + Custom Pixel Aspect Ratio… مخصوص پکسل تناسب… + Square مربع + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC وائیڈ اسکرین (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL وائیڈ اسکرین (1.46) + Anamorphic 2:1 (2) اینامورفک 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… 32-بٹ پیش منظر کے اختیارات… + Screen Mode اسکرین موڈ + Standard Screen Mode معیاری اسکرین موڈ + Full Screen Mode With Menu Bar مینیو بار کے ساتھ فل اسکرین موڈ + Full Screen Mode فل اسکرین موڈ + Cycle Screen Mode اسکرین موڈ تبدیل کریں + Show دکھائیں + Extras اضافی اشیاء + Selection Edges انتخاب کے کنارے + Target Path ہدف پاتھ + Current Path موجودہ پاتھ + Grid گرڈ + Guides رہنما لکیریں + Canvas Guides کینوس رہنما لکیریں + Artboard Guides آرٹ بورڈ رہنما لکیریں + Count شمار + Smart Guides سمارٹ رہنما لکیریں + Slices سلائسز + Notes نوٹس + Pixel Grid پکسل گرڈ + Pixel Art پکسل آرٹ + Pixel Art Preview پکسل آرٹ پیش منظر + Pattern Preview نمونہ پیش منظر + Brush Preview برش پیش منظر + Mesh میش + Edit Pins پنز میں ترمیم کریں + Document Bounds دستاویز کی حدود + Show Extras Options… اضافی اختیارات دکھائیں… + Rulers رولرز + Snap اسنیپ + Snap To اسنیپ کریں + Lock Guides رہنما لکیریں مقفل کریں + Clear Guides رہنما لکیریں صاف کریں + Clear Selected Artboard Guides منتخب آرٹ بورڈ رہنما لکیریں صاف کریں + Clear Canvas Guides کینوس رہنما لکیریں صاف کریں + New Guide… نئی رہنما لکیر… + Lock Slices سلائسز مقفل کریں + Clear Slices سلائسز صاف کریں + Match Zoom زوم مماثل کریں + Match Location مقام مماثل کریں + Match Rotation گردش مماثل کریں + Cascade ترتیب شدہ + Tile ٹائل + Tile All Horizontally سب کو افقی طور پر ٹائل کریں + Tile All Vertically سب کو عمودی طور پر ٹائل کریں + 2-up Horizontal 2 اوپر افقی + 2-up Vertical 2 اوپر عمودی + 3-up Horizontal 3 اوپر افقی + 3-up Stacked 3 اوپر اسٹیکڈ + 3-up Vertical 3 اوپر عمودی + 4-up 4 اوپر + 6-up 6 اوپر + Consolidate All to Tabs سب کو ٹیبز میں یکجا کریں + Float in Window ونڈو میں تیرتا رکھیں + Float All in Windows سب کو ونڈوز میں تیرتا رکھیں + New Window for Document دستاویز کے لیے نئی ونڈو + Workspace ورک اسپیس + Essentials بنیادی + Graphic and Web گرافک اور ویب + Painting پینٹنگ + Photography فوٹوگرافی + New Workspace… نیا ورک اسپیس… + Delete Workspace… ورک اسپیس حذف کریں… + Reset Workspace ورک اسپیس ری سیٹ کریں + Reset Tool ٹول ری سیٹ کریں + Reset All Tools تمام ٹولز ری سیٹ کریں + Reset all tools to their default settings? کیا تمام ٹولز کو ان کی ڈیفالٹ ترتیبات پر ری سیٹ کرنا ہے؟ + Lock Workspace ورک اسپیس مقفل کریں + Theme تھیم + Pro Theme پرو تھیم + Pro Medium Gray Theme پرو میڈیم گرے تھیم + Pro Light Gray Theme پرو لائٹ گرے تھیم + Pro Light Theme پرو لائٹ تھیم + Studio Theme اسٹوڈیو تھیم + Studio Light Theme اسٹوڈیو لائٹ تھیم + Adwaita Light Theme ایڈویٹا لائٹ تھیم + Adwaita Dark Theme ایڈویٹا ڈارک تھیم + Classic Theme کلاسک تھیم + Solarized dark سولرائزڈ ڈارک + Solarized Dark Theme سولرائزڈ ڈارک تھیم + Breeze dark بریز ڈارک + Breeze Dark Theme بریز ڈارک تھیم + Breeze light بریز لائٹ + Breeze Light Theme بریز لائٹ تھیم + Soft dark نرم ڈارک + Soft light نرم لائٹ + Soft Dark Theme نرم ڈارک تھیم + Soft Light Theme نرم لائٹ تھیم + Next Theme اگلا تھیم + Actions ایکشنز + Brush Settings برش کی ترتیبات + Brushes برشز + Channels چینلز + Character کریکٹر + Character Styles کریکٹر اسٹائلز + Clone Source کلون ماخذ + Color رنگ + Glyphs گلفس + Gradients گریڈیئنٹس + Histogram ہسٹوگرام + History تاریخ + Info معلومات + Measurement Log پیمائش لاگ + Modifier Keys موڈیفائر کیز + Navigator نیویگیٹر + Options اختیارات + Paragraph پیراگراف + Paragraph Styles پیراگراف اسٹائلز + Paths پاتھس + Patterns نمونے + Properties خصوصیات + Shapes شکلیں + Styles اسٹائلز + Swatches نمونے (سویچز) + Timeline ٹائم لائن + Tool Presets ٹول پری سیٹس + Tools ٹولز + About PhotoCraft PhotoCraft کے بارے میں + PhotoCraft Website PhotoCraft ویب سائٹ + PhotoCraft on GitHub PhotoCraft GitHub پر + ArtCraft Website ArtCraft ویب سائٹ + Join the ArtCraft Discord… ArtCraft ڈسکارڈ میں شامل ہوں… + Report an Issue… مسئلہ رپورٹ کریں… + WIA Support… WIA سپورٹ… + Apply لاگو کریں + HSB/HSL HSB/HSL + Invert… معکوس کریں… + Load Files into Stack… اسٹیک میں فائلیں لوڈ کریں… + Motion حرکت + Notes… نوٹس… + Other… دیگر… + Remove Warp Split وارپ اسپلٹ ہٹائیں + Shape شکل + Split Warp Crosswise وارپ کو آڑے رخ تقسیم کریں + Split Warp Horizontally وارپ کو افقی طور پر تقسیم کریں + Split Warp Vertically وارپ کو عمودی طور پر تقسیم کریں + Statistics… اعداد و شمار… + Refine Strokes لکیریں بہتر کریں + Edits ترامیم +@plural {n} item|{n} items {n} چیز|{n} چیزیں + Auto خودکار + (print to the printer) (پرنٹر پر پرنٹ کریں) + + Add variable + متغیر شامل کریں + 30 frames @ 30 fps 30 فریمز @ 30 fps + 3D LUT File 3D LUT فائل + Absolute مطلق + Absolute Colorimetric مطلق کلری میٹرک + Action عمل + Adaptive موافق + Adaptive Wide Angle isn't open اڈاپٹیو وائیڈ اینگل کھلا نہیں ہے + Add شامل کریں + Add Layer Mask تہہ ماسک شامل کریں + Add Stop اسٹاپ شامل کریں + Add a layer style تہہ کا اسٹائل شامل کریں + Move effect up اثر اوپر منتقل کریں + Move effect down اثر نیچے منتقل کریں + Delete effect اثر حذف کریں + Add a mask (from the selection; {key} inverts) ماسک شامل کریں (انتخاب سے؛ {key} الٹ دیتا ہے) + Add an adjustment ایڈجسٹمنٹ شامل کریں + Add disk ڈسک شامل کریں + Add space after paragraph پیراگراف کے بعد خالی جگہ شامل کریں + Add space before paragraph پیراگراف سے پہلے خالی جگہ شامل کریں + Add to Sample نمونے میں شامل کریں + Add to selection ({key}) انتخاب میں شامل کریں ({key}) + Adjust for Zoom زوم کے لیے ایڈجسٹ کریں + Adjustment visible ایڈجسٹمنٹ نظر آتی ہے + Adobe RGB (1998) compatible Adobe RGB (1998) مطابق + Align and Distribute ترتیب اور تقسیم + Align and distribute ترتیب اور تقسیم کریں + Align horizontal centers افقی مراکز ترتیب دیں + Align left edges بائیں کنارے ترتیب دیں + Align right edges دائیں کنارے ترتیب دیں + Aligned ترتیب شدہ + All Caps تمام بڑے حروف + All Slices تمام سلائسز + All User Slices تمام صارف سلائسز + All shortcuts reset to PhotoCraft defaults. تمام شارٹ کٹس PhotoCraft کی ڈیفالٹ ترتیب پر ری سیٹ ہو گئے۔ + Alt Alt + Always use pressure for opacity ہمیشہ دھندلاپن کے لیے دباؤ استعمال کریں + Always use pressure for size ہمیشہ حجم کے لیے دباؤ استعمال کریں + Amount مقدار + Anchor: اینکر: + Angle زاویہ + Angle Gradient زاویہ گریڈیئنٹ + Angle Jitter زاویہ جیٹر + Answer the unsaved-changes prompt first پہلے غیر محفوظ شدہ تبدیلیوں کا اشارہ حل کریں + Anti-alias اینٹی ایلیاس + Appearance ظاہری شکل + Appearance (Layer Style) ظاہری شکل (تہہ کا اسٹائل) + Apply Layer Mask تہہ ماسک لاگو کریں + Add Mask to Selection ماسک کو انتخاب میں شامل کریں + Subtract Mask from Selection انتخاب سے ماسک منہا کریں + Intersect Mask with Selection ماسک کو انتخاب کے ساتھ ملائیں + Apply Per Tip فی ٹِپ لاگو کریں + Apply next selected layer comp اگلی منتخب تہہ کمپوزیشن لاگو کریں + Apply previous selected layer comp پچھلی منتخب تہہ کمپوزیشن لاگو کریں + Artboard آرٹ بورڈ + Artboard Background Color آرٹ بورڈ کا پس منظر رنگ + Assign working space ورکنگ اسپیس تفویض کریں + Author: مصنف: + Auto Hide خودکار چھپائیں + Auto-Select: خودکار انتخاب: + Automatically level near-horizontal line segments قریب افقی لائن حصوں کو خودکار طور پر برابر کریں + Automatically straighten and level خودکار طور پر سیدھا اور برابر کریں + Automatically straighten near-vertical line segments قریب عمودی لائن حصوں کو خودکار طور پر سیدھا کریں + Background پس منظر + Background Color پس منظر کا رنگ + Background Contents پس منظر کا مواد + Custom background color مخصوص پس منظر کا رنگ + Balance توازن + Baseline بیس لائن + Baseline shift بیس لائن شفٹ + Basic بنیادی + Before (Y) پہلے (Y) + Save Settings… ترتیبات محفوظ کریں… + Load Settings… ترتیبات لوڈ کریں… + Save these settings as a Camera Raw .xmp file ان ترتیبات کو کیمرہ را .xmp فائل کے طور پر محفوظ کریں + Load settings from a Camera Raw .xmp file کیمرہ را .xmp فائل سے ترتیبات لوڈ کریں + Loaded Camera Raw settings from {file} {file} سے کیمرہ را ترتیبات لوڈ ہو گئیں + Loaded Camera Raw settings from {file} ({n} not supported) {file} سے کیمرہ را ترتیبات لوڈ ہو گئیں ({n} سپورٹڈ نہیں) + Begin recording ریکارڈنگ شروع کریں + Bend: موڑ: + Bicubic بائی کیوبک + Bicubic (smooth gradients) بائی کیوبک (ملائم گریڈیئنٹس) + Automatic خودکار + Bicubic Smoother (enlargement) بائی کیوبک اسموتھر (بڑھاؤ) + Bicubic Sharper (reduction) بائی کیوبک شارپر (کمی) + Bilinear بائی لینیئر + Black سیاہ + Black & White سیاہ و سفید + Black Point Compensation بلیک پوائنٹ معاوضہ + Black, White سیاہ، سفید + Blacks سیاہیاں + Blending امتزاج + Blending Options امتزاج کے اختیارات + Blue نیلا + Blue, Red, Yellow نیلا، سرخ، پیلا + Both Axes دونوں محور + Bottom نیچے + Brightness روشنی + Brightness Jitter روشنی جیٹر + Brightness/Contrast چمک/تباین + Bring the selected pin forward منتخب پن آگے لائیں + Brush Preset picker برش پری سیٹ چنندہ + Brush Reconstruct Options برش ری کنسٹرکٹ کے اختیارات + Brush Tool Options برش ٹول کے اختیارات + Cache Level: کیشے لیول: + Camera Raw isn't open کیمرہ را کھلا نہیں ہے + Cancel منسوخ کریں + Cancel (Esc) منسوخ کریں (Esc) + Manage submitted jobs in the system print queue سسٹم پرنٹ قطار میں جمع شدہ کام منظم کریں + Cancel any current edits (Esc) کوئی بھی موجودہ ترمیم منسوخ کریں (Esc) + Cancel current crop operation (Esc) موجودہ تراش کا عمل منسوخ کریں (Esc) + Cancel transform (Esc) ہیئت تبدیلی منسوخ کریں (Esc) + Cancel warp (Esc) موڑ منسوخ کریں (Esc) + Canvas کینوس + Canvas extension color: کینوس توسیع کا رنگ: + Capture قبضہ کریں + Capture the visibility, position and style of every layer with + below, then switch between versions. ہر تہہ کی نظر آنے کی حالت، پوزیشن اور اسٹائل کو + کے ساتھ نیچے قبضہ کریں، پھر ورژنز کے درمیان سوئچ کریں۔ + Catch-up on Stroke End لکیر کے اختتام پر کیچ اپ + Center مرکز + Center Crop Marks مرکزی کراپ نشانات + Center text متن مرکز میں کریں + Change تبدیل کریں + Change All سب تبدیل کریں + Change Screen Mode (F) اسکرین موڈ تبدیل کریں (F) + Change To: اس میں تبدیل کریں: + Channel Mixer چینل مکسر + Channel: چینل: + Check All Layers تمام تہیں چیک کریں + Check Spelling املا چیک کریں + Clarity وضاحت + Clear All سب صاف کریں + Clear Override اووررائیڈ صاف کریں + Clear Samples نمونے صاف کریں + Click sets foreground · right-click sets background کلک پیش منظر مقرر کرتا ہے · دایاں کلک پس منظر مقرر کرتا ہے + Click the first point or press {key} to close · Esc cancels پہلے نقطے پر کلک کریں یا بند کرنے کے لیے {key} دبائیں · Esc منسوخ کرتا ہے + Click to add a point · drag off or {key}-click or Delete to remove نقطہ شامل کرنے کے لیے کلک کریں · ہٹانے کے لیے باہر گھسیٹیں یا {key}-کلک یا Delete + Click to apply, {key}-click to clear overrides, double-click for Style Options لاگو کرنے کے لیے کلک کریں، اووررائیڈز صاف کرنے کے لیے {key}-کلک، اسٹائل اختیارات کے لیے ڈبل کلک + Click to edit the gradient گریڈیئنٹ میں ترمیم کے لیے کلک کریں + Click to restore the current colour موجودہ رنگ بحال کرنے کے لیے کلک کریں + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out بڑا کرنے کے لیے کلک کریں · چھوٹا کرنے کے لیے {key}-کلک · دائیں/بائیں گھسیٹ کر بڑا/چھوٹا کریں + Click with the Note tool to add a note. نوٹ شامل کرنے کے لیے نوٹ ٹول سے کلک کریں۔ + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel کلک: کونا · گھسیٹنا: ہموار · پہلے نقطے پر کلک: بند کریں · {key} مکمل · Esc منسوخ + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel کلک: کونا · گھسیٹنا: ہموار · {alt}-گھسیٹنا: ہینڈل توڑیں · پہلے نقطے پر کلک یا گھسیٹنا: بند کریں · {key} مکمل · Esc منسوخ + Clip to layer below نیچے کی تہہ پر کلپ کریں + Clipped کلپڈ + Close Style Options اسٹائل اختیارات بند کریں + Cmd Cmd + Coated CMYK کوٹڈ CMYK + Color & Swatches رنگ اور نمونے + Color Balance رنگ توازن + Color Detail رنگ کی تفصیل + Color Grading رنگ گریڈنگ + Color Lookup رنگ تلاش + Color Management رنگ کا انتظام + Color Mixer رنگ مکسر + Color Mode رنگ موڈ + Color Priority رنگ کی ترجیح + Color Range preview رنگ کی حد کا پیش منظر + Color: رنگ: + Colorize رنگین کریں + Colors رنگ + Commit Perspective Warp ({key}) تناظری وارپ کریں ({key}) + Commit Puppet Warp ({key}) پپٹ وارپ کریں ({key}) + Commit any current edits ({key}) کوئی بھی موجودہ ترمیم نافذ کریں ({key}) + Commit current crop operation ({key}) موجودہ تراش کا عمل نافذ کریں ({key}) + Commit transform ({key}) ہیئت تبدیلی نافذ کریں ({key}) + Commit warp ({key}) موڑ نافذ کریں ({key}) + Constant مستقل + Constrain proportions تناسب برقرار رکھیں + Contact Sheet II کانٹیکٹ شیٹ II + Content-Aware مواد سے آگاہ + Contiguous متصل + Contrast تباین + Control: کنٹرول: + Convert to sRGB sRGB میں تبدیل کریں + Copies کاپیاں + Copper تانبا + Copyright کاپی رائٹ + Copyright and Contact Info کاپی رائٹ اور رابطے کی معلومات + Corner Crop Marks کونے کے کراپ نشانات + Corner radius کونے کا ریڈیس + Correction تصحیح + Count Jitter شمار جیٹر + Create بنائیں + Create Droplet ڈراپلیٹ بنائیں + Create Texture ساخت بنائیں + Create Video Timeline ویڈیو ٹائم لائن بنائیں + Create a new count group نیا شمار گروپ بنائیں + Create a new document or open an existing file. نئی دستاویز بنائیں یا موجودہ فائل کھولیں۔ + Create a new group نیا گروپ بنائیں + Create a new layer نئی تہہ بنائیں + Create new action نیا ایکشن بنائیں + Create new channel نیا چینل بنائیں + Create new document from current state موجودہ حالت سے نئی دستاویز بنائیں + Create new fill or adjustment layer نئی بھرائی یا ایڈجسٹمنٹ تہہ بنائیں + Create new gradient نیا گریڈیئنٹ بنائیں + Create new group نیا گروپ بنائیں + Create new layer comp نئی تہہ کمپوزیشن بنائیں + Create new pattern from the selection انتخاب سے نیا نمونہ بنائیں + Create new shape from the current path موجودہ پاتھ سے نئی شکل بنائیں + Create new snapshot نیا اسنیپ شاٹ بنائیں + From: سے: + Full Document مکمل دستاویز + Merged Layers ضم شدہ تہیں + Create new tool preset نیا ٹول پری سیٹ بنائیں + Crop Factor کراپ فیکٹر + Ctrl Ctrl + Current & Below موجودہ اور نیچے + Current Layer موجودہ تہہ + Current Size موجودہ حجم + Current Tool Only صرف موجودہ ٹول + Curve وکر + Curves وکر + Custom مخصوص + Custom Shapes مخصوص شکلیں + Cyan سیان + Cyan · Red سیان · سرخ + Darken گہرا کریں + Darks تاریک حصے + Data Sets ڈیٹا سیٹس + Default ڈیفالٹ + Default colours (D) ڈیفالٹ رنگ (D) + Default printer ڈیفالٹ پرنٹر + Default restored. ڈیفالٹ بحال ہو گیا۔ + Define متعین کریں + Define at least one variable first. پہلے کم از کم ایک متغیر متعین کریں۔ + Dehaze دھند ہٹائیں + Delete Cropped Pixels تراشے گئے پکسلز حذف کریں + Delete Group گروپ حذف کریں + Delete Layer Mask تہہ ماسک حذف کریں + Delete Layers تہیں حذف کریں + Add Shortcut شارٹ کٹ شامل کریں + Delete Shortcut شارٹ کٹ حذف کریں + Delete Timeline ٹائم لائن حذف کریں + Delete Tool Preset ٹول پری سیٹ حذف کریں + Delete count group شمار گروپ حذف کریں + Delete current channel موجودہ چینل حذف کریں + Delete current state موجودہ حالت حذف کریں + Delete effect layer اثر تہہ حذف کریں + Delete layer ‘{name}’? تہہ '{name}' حذف کریں؟ + Delete {count} layers? {names} {count} تہیں حذف کریں؟ {names} + Delete layer? تہہ حذف کریں؟ + 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} تہہ حذف ہو گئی، لیکن تصدیقی ترجیح محفوظ نہیں ہو سکی: {error} + Delete layer تہہ حذف کریں + Delete layer comp تہہ کمپوزیشن حذف کریں + Delete note نوٹ حذف کریں + Delete selected measurements منتخب پیمائشیں حذف کریں + Delete style اسٹائل حذف کریں + Delete tool preset ٹول پری سیٹ حذف کریں + Density کثافت + Density: کثافت: + Depth گہرائی + Depth Jitter گہرائی جیٹر + Description تفصیل + Deselect All سب غیر منتخب کریں + Detail تفصیل + Diamond Gradient ڈائمنڈ گریڈیئنٹ + Difference فرق + Diffusion پھیلاؤ + Discard the embedded profile (don't color manage) ایمبیڈڈ پروفائل مسترد کریں (رنگ منظم نہ کریں) + Discontiguous غیر متصل + Discord ڈسکارڈ + Dismiss برخاست کریں + Display P3 Display P3 + Display Text متن دکھائیں + Dither ڈیدر + Divide… تقسیم کریں… + Document دستاویز + Don't Save محفوظ نہ کریں + No نہیں + Yes ہاں + Done مکمل + Dots نقطے + Drag a rectangle around the object شے کے گرد ایک مستطیل گھسیٹیں + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. پابندی شامل کرنے کے لیے تصویر پر گھسیٹیں؛ افقی/عمودی کے لیے Shift؛ پابندی تبدیل یا حذف کرنے کے لیے اس پر دایاں کلک کریں۔ + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath کلک: اینکر منتخب کریں · گھسیٹنا: اینکر، ہینڈل یا حصہ منتقل کریں · {shift}-کلک: شامل کریں · {alt}-کلک: پورا سب پاتھ + Draw with the Pen tool (P) or make a work path from a selection. پین ٹول (P) سے ڈرائنگ کریں یا انتخاب سے ورک پاتھ بنائیں۔ + Drop an image or PSD anywhere to open it. تصویر یا PSD کو کہیں بھی گھسیٹ کر چھوڑیں تاکہ اسے کھولا جا سکے۔ + Native file drag-and-drop is unavailable نیٹو فائل ڈریگ اینڈ ڈراپ دستیاب نہیں + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. نیٹو فائل ڈریگ اینڈ ڈراپ ابھی تک Wayland پر سپورٹ نہیں ہے۔ فائل > کھولیں استعمال کریں، یا اپنے فائل مینیجر میں تصویر کاپی کریں اور {paste} کے ساتھ چسپاں کریں۔ + To drop files, start PhotoCraft under XWayland: `{command}` فائلیں چھوڑنے کے لیے، PhotoCraft کو XWayland کے تحت شروع کریں: {command} + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. یا ترجیحات > کارکردگی > لینکس ڈسپلے سرور کو X11 پر مقرر کریں: PhotoCraft پھر ہمیشہ XWayland کے تحت شروع ہوتا ہے۔ + Edit › Paste ترمیم > چسپاں کریں + Duplicate Layers… تہیں کی نقل بنائیں… + Duplicate style اسٹائل کی نقل بنائیں + Edit Toolbar… ٹول بار میں ترمیم کریں… + Use File › Open, or paste a copied image with {paste}. فائل > کھولیں استعمال کریں، یا {paste} کے ساتھ کاپی شدہ تصویر چسپاں کریں۔ + Edit in Quick Mask Mode (Q) کوئیک ماسک موڈ میں ترمیم کریں (Q) + Edit in Standard Mode (Q) معیاری موڈ میں ترمیم کریں (Q) + Effects اثرات + Embed Color Profile رنگ پروفائل ایمبیڈ کریں + Embedded Profile Mismatch ایمبیڈڈ پروفائل میں عدم مطابقت + Enable airbrush-style build-up effects ایئر برش طرز کے بلڈ اپ اثرات فعال کریں + Enhance Edge کنارہ بہتر کریں + Entire Font پورا فونٹ + Exact عین مطابق + Expansion: توسیع: + Export Presets پری سیٹس برآمد کریں + Export selected measurements (CSV) منتخب پیمائشیں برآمد کریں (CSV) + Exposure نمائش + Exposure and Gamma نمائش اور گاما + Exposure: نمائش: + Eyedropper آئی ڈراپر + Factor: فیکٹر: + Faux Bold نقلی بولڈ + Faux Italic نقلی ترچھا + Feather فیدر + Fewer Points کم نقطے + File Settings فائل کی ترتیبات + Fill بھریں + Fill: بھریں: + Filter for adjustment layers ایڈجسٹمنٹ تہوں کے لیے فلٹر + Filter for pixel layers پکسل تہوں کے لیے فلٹر + Filter for shape layers شکل تہوں کے لیے فلٹر + Filter for smart objects سمارٹ آبجیکٹس کے لیے فلٹر + Filter for type layers متن تہوں کے لیے فلٹر + Filter: فلٹر: + Finger Painting فنگر پینٹنگ + Fisheye فش آئی + Fit فٹ + Fit Screen اسکرین پر فٹ کریں + Fill Screen اسکرین بھریں + Fit in View منظر میں فٹ کریں + Fixed مقررہ + Fixed Ratio مقررہ تناسب + Fixed Size مقررہ حجم + Flip پلٹیں + Flip X X پلٹیں + Flip X Jitter X پلٹ جیٹر + Flip Y Y پلٹیں + Flip Y Jitter Y پلٹ جیٹر + Flow بہاؤ + Flow Jitter بہاؤ جیٹر + Focal Length فوکل لمبائی + Font فونٹ + Font Size: فونٹ حجم: + Font size فونٹ حجم + Foreground پیش منظر + Foreground to Background پیش منظر سے پس منظر + Foreground/Background Jitter پیش منظر/پس منظر جیٹر + Format فارمیٹ + Frame from Layers… تہوں سے فریم… + Free آزاد + Full Spherical مکمل کروی + Fuzziness: دھندلاپن: + GPU GPU + Gamma Correction گاما تصحیح + Gamma: گاما: + Global عالمی + Glow Color چمک کا رنگ + Gradient Map گریڈیئنٹ نقشہ + Grain دانا + Gray سرمئی + Gray Gamma 2.2 گرے گاما 2.2 + Green سبز + Group گروپ + Hardness سختی + Heart دل + Height اونچائی + Height: اونچائی: + Hide Auto Slices خودکار سلائسز چھپائیں + Hide Properties خصوصیات چھپائیں + Highlight Compression نمایاں حصے کمپریشن + Highlight Priority نمایاں حصے کی ترجیح + Highlights نمایاں حصے + Home ہوم + Horizontal scale افقی پیمانہ + Hue رنگت + Hue Jitter رنگت جیٹر + Hue/Saturation رنگت/اشباع + Hyphenate ہائیفنیٹ کریں + Ignore نظرانداز کریں + Ignore All سب نظرانداز کریں + Image Size تصویر کا حجم + Import Brushes… برشز درآمد کریں… + Gradient Preset Options گریڈیئنٹ پری سیٹ اختیارات + Import .GRD .GRD درآمد کریں + Choose a gradient file (.grd). گریڈیئنٹ فائل (.grd) چنیں۔ + Import brushes (.abr) برشز درآمد کریں (.abr) + Import Presets پری سیٹس درآمد کریں + Imported درآمد شدہ + Indent First پہلی لائن حاشیہ + Indent Left بائیں حاشیہ + Indent Right دائیں حاشیہ + Indent first line پہلی لائن حاشیہ کریں + Indent left margin بائیں حاشیہ کریں + Indent right margin دائیں حاشیہ کریں + Input: ان پٹ: + Inside اندر + Interlaced انٹرلیسڈ + Interpolation: انٹرپولیشن: + Intersect with selection ({key}) انتخاب کے ساتھ جوڑیں ({key}) + Invert All سب معکوس کریں + Invert has no settings. معکوس کی کوئی ترتیبات نہیں۔ + Italic ترچھا + Join us on Discord ہمارے ڈسکارڈ میں شامل ہوں + Justify all سب جسٹیفائی کریں + Justify last centered آخری مرکز میں جسٹیفائی کریں + Justify last left آخری بائیں جسٹیفائی کریں + Justify last right آخری دائیں جسٹیفائی کریں + Kerning کرننگ + Keyboard Shortcuts کی بورڈ شارٹ کٹس + Keyboard Shortcuts and Menus کی بورڈ شارٹ کٹس اور مینیوز + Kind قسم + Label Size لیبل کا حجم + Labels لیبلز + Lanczos (sharp) لینزوس (تیز) + Landscape زمین تزئین + Last Document State آخری دستاویز کی حالت + Layout لے آؤٹ + Leading لیڈنگ + Leading (set to the font size × auto-leading when Auto) لیڈنگ (آٹو ہونے پر فونٹ حجم × آٹو لیڈنگ پر مقرر) + Leave as is (don't color manage) جیسا ہے رہنے دیں (رنگ منظم نہ کریں) + Left align text متن بائیں ترتیب دیں + Length: لمبائی: + Level لیول + Levels لیولز + Lighten ہلکا کریں + Lightness روشنی + Lights روشنیاں + Limits: حدود: + Linear Gradient لکیری گریڈیئنٹ + Link layers تہیں مربوط کریں + Link width and height چوڑائی اور اونچائی مربوط کریں + Liquify is not open لیکویفائی کھلا نہیں ہے + Load لوڈ کریں + Load 3D LUT… 3D LUT لوڈ کریں… + Load channel as selection چینل کو انتخاب کے طور پر لوڈ کریں + Load… لوڈ کریں… + Localized Color Clusters مقامی رنگ کے جھرمٹ + Lock مقفل کریں + Lock all سب مقفل کریں + Lock image pixels تصویر پکسلز مقفل کریں + Lock layer تہہ مقفل کریں + Lock position پوزیشن مقفل کریں + Lock transparent pixels شفاف پکسلز مقفل کریں + Lock: مقفل: + Logical Length: منطقی لمبائی: + Logical Units: منطقی اکائیاں: + Luminance لومینینس + Luminance Detail لومینینس تفصیل + Magenta میجنٹا + Magenta · Green میجنٹا · سبز + Maintain aspect ratio تناسب برقرار رکھیں + Marker Size نشان کا حجم + Mask All سب ماسک کریں + Mask Options ماسک کے اختیارات + Masking ماسکنگ + Master ماسٹر + Matte میٹ + Median: درمیانی قدر: + Menus مینیوز + Mesh Size میش کا حجم + Metadata میٹا ڈیٹا + Metrics میٹرکس + Midpoint درمیانی نقطہ + Midtones درمیانی ٹونز + Midtones from: درمیانی ٹونز از: + Midtones to: درمیانی ٹونز تا: + Minimum Depth کم سے کم گہرائی + Minimum Diameter کم سے کم قطر + Minimum Roundness کم سے کم گولائی + Mode: موڈ: + Monochrome مونوکروم + More Points مزید نقطے + Move effect layer down اثر تہہ نیچے منتقل کریں + Move effect layer up اثر تہہ اوپر منتقل کریں + Move to منتقل کریں تا + Name نام + Name: نام: + Nearest Neighbor قریب ترین پڑوسی + Nearest Neighbor (hard edges) قریب ترین پڑوسی (سخت کنارے) + New Size نیا حجم + New document… نئی دستاویز… + New effect layer نئی اثر تہہ + New selection نیا انتخاب + Next frame اگلا فریم + Next note اگلا نوٹ + No Color بلا رنگ + No Color Management کوئی رنگ انتظام نہیں + No Dither کوئی ڈیدر نہیں + No author کوئی مصنف نہیں + No character style کوئی کریکٹر اسٹائل نہیں + No document کوئی دستاویز نہیں + No matching commands کوئی مماثل حکم نہیں + No presets for the current tool. موجودہ ٹول کے لیے کوئی پری سیٹس نہیں۔ + No presets of this type. اس قسم کے کوئی پری سیٹس نہیں۔ + No properties کوئی خصوصیات نہیں + No tool presets. کوئی ٹول پری سیٹس نہیں۔ + Noise Reduction شور میں کمی + Normal نارمل + Not in Dictionary: لغت میں موجود نہیں: + OK ٹھیک ہے + Offset آفسیٹ + Only Web Colors صرف ویب رنگ + Opacity دھندلاپن + Opacity Jitter دھندلاپن جیٹر + Opacity: دھندلاپن: + Open a document to define variables. متغیرات متعین کرنے کے لیے دستاویز کھولیں۔ + Open a document to select a colour range. رنگ کی حد منتخب کرنے کے لیے دستاویز کھولیں۔ + Open a document to use the timeline. ٹائم لائن استعمال کرنے کے لیے دستاویز کھولیں۔ + Optical آپٹیکل + Optimized بہتر + Option-click to define a source point to clone from کلون کے لیے ماخذ نقطہ متعین کرنے کے لیے Option-کلک کریں + Original اصل + Output Channel: آؤٹ پٹ چینل: + Output Levels: آؤٹ پٹ لیولز: + Output: آؤٹ پٹ: + Outside باہر + Overlay Color اوورلے رنگ + Overlay Opacity اوورلے دھندلاپن + Overlay: Rule of Thirds اوورلے: ایک تہائی کا اصول + Override Pressure دباؤ اووررائیڈ کریں + Override Rotation گردش اووررائیڈ کریں + Override Tilt جھکاؤ اووررائیڈ کریں + Override Tilt X X جھکاؤ اووررائیڈ کریں + Override Tilt Y Y جھکاؤ اووررائیڈ کریں + PRESET DETAILS پری سیٹ کی تفصیلات + PSD PSD + Paint Overlay پینٹ اوورلے + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. پینل کے مقامات ورک اسپیس میں محفوظ ہوتے ہیں۔ کی بورڈ شارٹ کٹس، مینیوز اور ٹول بار اختیاری ہیں۔ + Paper کاغذ + Path پاتھ + Pattern نمونہ + Pattern Size نمونہ حجم + Pause موقوف کریں + Percent فیصد + Perceptual ادراکی + Perspective Warp is not active تناظری وارپ فعال نہیں ہے + Perspective Warp: draw planes along the image's perspective, then switch to Warp تناظری وارپ: تصویر کے تناظر کے ساتھ ہموار سطحیں کھینچیں، پھر وارپ پر سوئچ کریں + Photo Filter فوٹو فلٹر + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft رنگوں کا نظم کرتا ہے + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — ایک اوپن سورس، نیٹو امیج ایڈیٹر جو Rust میں لکھا گیا ہے۔ + Pin Depth: پن کی گہرائی: + Pixel Length: پکسل لمبائی: + Pixels/Centimeter پکسلز/سینٹی میٹر + Pixels/Inch پکسلز/انچ + Pixels: پکسلز: + Play چلائیں + Play selection انتخاب چلائیں + Portrait عمودی زمین تزئین + Position پوزیشن + Position and Size پوزیشن اور حجم + Posterize پوسٹرائز + Preserve Details تفصیلات برقرار رکھیں + Preserve Luminosity چمک برقرار رکھیں + Preset پری سیٹ + Preset Type پری سیٹ کی قسم + Preset: پری سیٹ: + Presets: پری سیٹس: + Press keys… کیز دبائیں… + Pressure دباؤ + Pressure for Opacity دھندلاپن کے لیے دباؤ + Pressure for Size حجم کے لیے دباؤ + Prevent auto-nesting in and out of Artboards and Frames آرٹ بورڈز اور فریمز کے اندر باہر خودکار نیسٹنگ روکیں + Preview پیش منظر + Previous frame پچھلا فریم + Previous note پچھلا نوٹ + Print پرنٹ + Printer پرنٹر + Printer Manages Colors پرنٹر رنگوں کا نظم کرتا ہے + Printer Setup پرنٹر سیٹ اپ + Printing Marks پرنٹنگ نشانات + Progressive پراگریسو + Promote ترقی دیں + Protect Detail تفصیل محفوظ رکھیں + Protect Foreground Color پیش منظر رنگ محفوظ رکھیں + Protect Tones ٹونز محفوظ رکھیں + Proximity Match قربت مطابقت + Pulled String Mode پلڈ اسٹرنگ موڈ + Puppet Warp is not active پپٹ وارپ فعال نہیں ہے + Purity خالص پن + Quality معیار + Quick Actions فوری اعمال + Quick Export As PNG فوری طور پر PNG کے بطور برآمد کریں + Radial Gradient شعاعی گریڈیئنٹ + Radius ریڈیس + Radius: ریڈیس: + Range: حد: + Rate شرح + Recently Used حال ہی میں استعمال شدہ + Reconstruct ری کنسٹرکٹ + Record ● a sequence of edits, then play ▶ it on any document. ترامیم کا ایک سلسلہ ریکارڈ ● کریں، پھر اسے کسی بھی دستاویز پر چلائیں ▶۔ + Red سرخ + Redefine style by current selection موجودہ انتخاب سے اسٹائل دوبارہ متعین کریں + Refine بہتر کریں + Reflected Gradient منعکس شدہ گریڈیئنٹ + Registration Marks رجسٹریشن نشانات + Regular معمولی + Relative نسبتی + Relative Colorimetric نسبتی کلری میٹرک + Remove ہٹائیں + Remove All Pins تمام پنز ہٹائیں + Remove Background پس منظر ہٹائیں + Remove Planes سطحیں ہٹائیں + Remove Tool ٹول ہٹائیں + Remove stop اسٹاپ ہٹائیں + Remove variable متغیر ہٹائیں + Rename نام تبدیل کریں + Rename Group… گروپ کا نام تبدیل کریں… + Rename Layer… تہہ کا نام تبدیل کریں… + Rename Tool Preset… ٹول پری سیٹ کا نام تبدیل کریں… + Rename… نام تبدیل کریں… + Resample ری سیمپل + Reset All to Defaults سب کو ڈیفالٹ پر ری سیٹ کریں + Import Shortcuts… شارٹ کٹس درآمد کریں… + Load a keyboard shortcut set saved as .kys .kys کے طور پر محفوظ کردہ کی بورڈ شارٹ کٹ سیٹ لوڈ کریں + Imported {n} shortcuts from {file}. Click OK to keep them. {file} سے {n} شارٹ کٹس درآمد ہو گئے۔ انہیں رکھنے کے لیے OK پر کلک کریں۔ + {n} commands are not in PhotoCraft. {n} کمانڈز PhotoCraft میں موجود نہیں ہیں۔ + Keyboard shortcuts imported کی بورڈ شارٹ کٹس درآمد ہو گئے + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. آپ کا کی بورڈ شارٹ کٹ سیٹ {set} یہاں استعمال ہو رہا ہے: {n} شارٹ کٹس ڈیفالٹس سے مختلف ہیں۔ ترمیم > کی بورڈ شارٹ کٹس انہیں دکھاتا ہے؛ سب کو ڈیفالٹ پر ری سیٹ کریں اسے واپس لیتا ہے۔ + Import keyboard shortcuts? کی بورڈ شارٹ کٹس درآمد کریں؟ + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft نے اس کمپیوٹر پر ایک اور امیج ایڈیٹر سے آپ کا کی بورڈ شارٹ کٹ سیٹ {set} پایا: اس کے {n} شارٹ کٹس PhotoCraft سے مختلف ہیں۔ اسے درآمد کریں؟ + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. ترمیم > کی بورڈ شارٹ کٹس > سب کو ڈیفالٹ پر ری سیٹ کریں ایک درآمد کو واپس لیتا ہے۔ + Don't Import درآمد نہ کریں + The keyboard shortcut set was not imported: {error} کی بورڈ شارٹ کٹ سیٹ درآمد نہیں ہوئی: {error} + Reset Section سیکشن ری سیٹ کریں + Reset Transform ہیئت تبدیلی ری سیٹ کریں + Reset to defaults ڈیفالٹس پر ری سیٹ کریں + Resolution ریزولوشن + Resolution: ریزولوشن: + Restore All سب بحال کریں + Restore Defaults ڈیفالٹس بحال کریں + Restrictive (Web) پابندی والا (ویب) + Right align text متن دائیں ترتیب دیں + Rigid سخت + Rotate: گھمائیں: + Rotation گردش + Roughness کھردرا پن + Roundness گولائی + Roundness Jitter گولائی جیٹر + Rulers & Grids رولرز اور گرڈز + Sample All Layers تمام تہیں نمونہ لیں + Sample: نمونہ: + Sampling: Background Swatch نمونہ گیری: پس منظر نمونہ + Sampling: Continuous نمونہ گیری: مسلسل + Sampling: Once نمونہ گیری: ایک بار + Saturate اشباع بڑھائیں + Saturation اشباع + Saturation Jitter اشباع جیٹر + Save HTML and Images HTML اور تصاویر محفوظ کریں + Save as PDF PDF کے طور پر محفوظ کریں + Save selection as channel انتخاب کو چینل کے طور پر محفوظ کریں + Save… محفوظ کریں… + Scale to Fit Media میڈیا میں فٹ کرنے کے لیے پیمانہ + Scatter بکھراؤ + Scratch disks اسکریچ ڈسکس + Search تلاش کریں + Search commands کمانڈز تلاش کریں + Search commands, tools and panels… کمانڈز، ٹولز اور پینلز تلاش کریں… + Search fonts فونٹس تلاش کریں + Select All سب منتخب کریں + Select Subject موضوع منتخب کریں + Select a type layer or click into text to insert glyphs گلفس شامل کرنے کے لیے متن تہہ منتخب کریں یا متن میں کلک کریں + Select text to apply a character style کریکٹر اسٹائل لاگو کرنے کے لیے متن منتخب کریں + Select: منتخب کریں: + Selections انتخابات + Selective منتخب + Selective Color منتخب رنگ + Send the selected pin backward منتخب پن پیچھے بھیجیں + Set additional smoothing options اضافی ہمواری اختیارات مقرر کریں + Set background color پس منظر کا رنگ مقرر کریں + Set foreground color پیش منظر کا رنگ مقرر کریں + Set painting symmetry options پینٹنگ سمیٹری اختیارات مقرر کریں + Symmetry Off سمیٹری بند + Set shape fill type شکل بھرائی کی قسم مقرر کریں + Set shape stroke type شکل لکیر کی قسم مقرر کریں + Set the text color متن کا رنگ مقرر کریں + Create warped text موڑا ہوا متن بنائیں + Shadows سائے + Shape: شکل: + Sharpening شارپننگ + Shift Shift + Shortcut removed. شارٹ کٹ ہٹا دیا گیا۔ + Show All Menu Items تمام مینیو آئٹمز دکھائیں + Show Backdrop پس منظر دکھائیں + Show Guides رہنما لکیریں دکھائیں + Show Mask ماسک دکھائیں + Show Mesh میش دکھائیں + Show Overlay اوورلے دکھائیں + Show Transform Controls ہیئت تبدیلی کنٹرولز دکھائیں + Show or hide the Notes panel نوٹس پینل دکھائیں یا چھپائیں + Show: دکھائیں: + Sides اطراف + Sides: اطراف: + Size حجم + Size Jitter حجم جیٹر + Size: حجم: + Slice Options… سلائس اختیارات… + Slices From Guides رہنما لکیروں سے سلائسز + Slices are locked (View › Lock Slices) سلائسز مقفل ہیں (منظر > سلائسز مقفل کریں) + Small Caps چھوٹے بڑے حروف + Smoothing ہمواری + Space After بعد میں جگہ + Space Before پہلے جگہ + Spacing وقفہ + Spell check complete. املا جانچ مکمل ہوئی۔ + Split: تقسیم: + Star ratio ستارہ تناسب + Stop playing/recording چلانا/ریکارڈنگ روکیں + Straighten سیدھا کریں + Straighten Layer تہہ سیدھی کریں + Strength: طاقت: + Strikethrough درمیان لکیر + Stroke لکیر + Stroke Catch-up لکیر کیچ اپ + Stroke: لکیر: + Location مقام + Location: مقام: + Style اسٹائل + Style Options اسٹائل اختیارات + Style Options… اسٹائل اختیارات… + Style: Normal اسٹائل: نارمل + Subtract from Sample نمونے سے منہا کریں + Subtract from selection ({key}) انتخاب سے منہا کریں ({key}) + Suggestions: تجاویز: + Swap colours (X) رنگ تبدیل کریں (X) + Swaps height and width اونچائی اور چوڑائی تبدیل کرتا ہے + Switch between free transform and warp modes آزاد ہیئت تبدیلی اور وارپ موڈز کے درمیان سوئچ کریں + Switch theme تھیم تبدیل کریں + Temperature درجہ حرارت + Tetrahedral ٹیٹراہیڈرل + Text Position: متن کا مقام: + Texture ساخت + Texture Each Tip ہر ٹپ پر ساخت + Threshold حد + Threshold Level حد کی سطح + Thumbnail size (smallest: list) تھمب نیل حجم (سب سے چھوٹا: فہرست) + Tilt X X جھکاؤ + Tilt Y Y جھکاؤ + Tint ٹنٹ + Toggle count group visibility شمار گروپ کی نمائش تبدیل کریں + Toggle text orientation متن کی سمت تبدیل کریں + Tolerance برداشت + Tolerance: برداشت: + Tone: ٹون: + Toolbar ٹول بار + Top اوپر + Total: کل: + Tracking ٹریکنگ + Tracking (1/1000 em) ٹریکنگ (1/1000 em) + Transparency شفافیت + Transparent شفاف + Trilinear ٹرائی لینیئر + Type Styles متن اسٹائلز + Type: قسم: + Underline زیر خط + Untitled بلاعنوان + Untitled-1 بلاعنوان-1 + Update layer comp (visibility, position and appearance) تہہ کمپوزیشن اپ ڈیٹ کریں (نمائش، پوزیشن اور ظاہری شکل) + Use Default ڈیفالٹ استعمال کریں + Use Legacy پرانا استعمال کریں + Use Measurement Scale پیمائش کا پیمانہ استعمال کریں + Variables bind a layer's visibility, text or pixels to a named data slot. متغیرات ایک تہہ کی نمائش، متن یا پکسلز کو ایک نام شدہ ڈیٹا سلاٹ سے جوڑتے ہیں۔ + Vertical scale عمودی پیمانہ + Vibrance رونق + View Options منظر کے اختیارات + Vignetting ویگنیٹنگ + Violet, Orange بنفشی، نارنجی + Visibility نمائش کی حالت + Visible نظر آنے والا + Warp: موڑ: + Web Snap ویب اسنیپ + Weight وزن + Weight: وزن: + White سفید + White Balance: As Shot وائٹ بیلنس: جیسا لیا گیا + Whites سفیدیاں + Width چوڑائی + Slant ترچھا پن + Width: چوڑائی: + Work Path ورک پاتھ + Yellow پیلا + Yellow · Blue پیلا · نیلا + Zoom زوم + Zoom in بڑا کریں + Zoom out چھوٹا کریں + command or shortcut کمانڈ یا شارٹ کٹ + current موجودہ + image file path تصویر فائل کا راستہ + modified ترمیم شدہ + name نام + new نیا + {name} options {name} کے اختیارات + ↑↓ navigate {key} run esc close ↑↓ نیویگیٹ کریں {key} چلائیں esc بند کریں + Version {version} ورژن {version} + New Document نئی دستاویز + New Document With Mask ماسک کے ساتھ نئی دستاویز + New Layer With Mask ماسک کے ساتھ نئی تہہ + New Layer نئی تہہ + Save Preset… پری سیٹ محفوظ کریں… + Preset Name پری سیٹ کا نام + No saved presets yet. ابھی تک کوئی پری سیٹس محفوظ نہیں۔ + Error خرابی + Command کمانڈ + Adjustment layers have nothing to transform ایڈجسٹمنٹ تہوں میں تبدیل کرنے کے لیے کچھ نہیں + Altitude بلندی + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua آکوا + Art & Illustration آرٹ اور خاکہ نگاری + ArtCraft ArtCraft + Background Eraser Tool بیک گراؤنڈ اریزر ٹول + Bevel & Emboss بیول اور ایمبوس + Blend Mode امتزاج موڈ + Blues نیلے رنگ + Blur Tool بلر ٹول + Bold بولڈ + Brush برش + Block بلاک + Brush Pose برش پوز + Brush Tip Shape برش ٹپ کی شکل + Brush Tool برش ٹول + Build-up بلڈ اپ + Burn Tool برن ٹول + Centimeters سینٹی میٹرز + Choke چوک + Classic کلاسک + Click to add text متن شامل کرنے کے لیے کلک کریں + Click to fill similar colours ملتے جلتے رنگ بھرنے کے لیے کلک کریں + Clone Stamp Tool کلون اسٹیمپ ٹول + Color Burn کلر برن + Color Dodge کلر ڈاج + Color Dynamics رنگ کی حرکیات + Color Overlay رنگ اوورلے + Comic Book کامک بک + Conform موافق بنائیں + Content-Aware Fill needs a selection on a pixel layer مواد سے آگاہ بھرائی کو پکسل تہہ پر انتخاب درکار ہے + Couldn't place {name}: {error} {name} رکھا نہیں جا سکا: {error} + Could not transform: the layer is empty ہیئت تبدیل نہیں ہو سکی: تہہ خالی ہے + Could not use Perspective Warp: the layer is empty تناظری وارپ استعمال نہیں ہو سکا: تہہ خالی ہے + Could not use Puppet Warp: no opaque pixels پپٹ وارپ استعمال نہیں ہو سکا: کوئی غیر شفاف پکسل نہیں + Could not use Puppet Warp: the layer is empty پپٹ وارپ استعمال نہیں ہو سکا: تہہ خالی ہے + Crop Tool کراپ ٹول + Crosswise آڑے رخ + Current Tool موجودہ ٹول + Custom Shape Tool مخصوص شکل ٹول + Cyans سیان رنگ + Default PhotoCraft Size ڈیفالٹ PhotoCraft حجم + Delete Workspace ورک اسپیس حذف کریں + Diamond ڈائمنڈ + Dimensions: طول و عرض: + Direction سمت + Distance فاصلہ + Document Dimensions دستاویز کے طول و عرض + Document Profile دستاویز پروفائل + Document Sizes دستاویز کے حجم + Dodge Tool ڈاج ٹول + Down نیچے + Drag to draw a gradient گریڈیئنٹ کھینچنے کے لیے گھسیٹیں + Drag to move the active layer فعال تہہ منتقل کرنے کے لیے گھسیٹیں + Drag to pan · hold Space with any tool پین کرنے کے لیے گھسیٹیں · کسی بھی ٹول کے ساتھ Space دبائے رکھیں + Drop Shadow ڈراپ شیڈو + Dual Brush ڈوئل برش + Edge کنارہ + Efficiency کارکردگی + Efficiency: 100% کارکردگی: 100% + Ellipse Tool بیضوی ٹول + Elliptical Marquee Tool بیضوی مارکی ٹول + Single Row Marquee Tool سنگل رو مارکی ٹول + Single Column Marquee Tool سنگل کالم مارکی ٹول + Emboss ایمبوس + Eraser اریزر + Eraser Tool اریزر ٹول + ExtraBold ExtraBold + ExtraLight ExtraLight + Eyedropper Tool آئی ڈراپر ٹول + Fade فیڈ + Fill Opacity بھرائی کا دھندلاپن + Film & Video فلم اور ویڈیو + From از + General عام + GitHub GitHub + Gradient Overlay گریڈیئنٹ اوورلے + Gradient Tool گریڈیئنٹ ٹول + Greens سبز رنگ + Hand Tool ہینڈ ٹول + Hard سخت + Hard Mix ہارڈ مکس + Healing Brush Tool ہیلنگ برش ٹول + Highlights from: نمایاں حصے از: + History Brush Tool ہسٹری برش ٹول + Horizontal Type Tool افقی ٹائپ ٹول + Horizontal Type Mask Tool افقی ٹائپ ماسک ٹول + Vertical Type Mask Tool عمودی ٹائپ ماسک ٹول + Image Size: تصویر کا حجم: + Inches انچ + Indexed انڈیکسڈ + Initial Direction ابتدائی سمت + Inner Bevel اندرونی بیول + Inner Glow اندرونی چمک + Inner Shadow اندرونی سایہ + Lab Lab + Landscape, 10 x 8 زمین تزئین، 10 x 8 + Landscape, 6 x 4 زمین تزئین، 6 x 4 + Landscape, 7 x 5 زمین تزئین، 7 x 5 + Lasso لیسو + Lasso Tool لیسو ٹول + Layer Count تہہ کی تعداد + Layer Knocks Out Drop Shadow تہہ ڈراپ شیڈو کو ناک آؤٹ کرتی ہے + Legal لیگل + Letter لیٹر + Light ہلکا + Line Tool لائن ٹول + Linear لکیری + Linear Burn لکیری برن + Linear Height لکیری اونچائی + Link with Layer تہہ کے ساتھ لنک کریں + Lorem Ipsum Lorem Ipsum + Magentas میجنٹا رنگ + Magic Eraser Tool میجک اریزر ٹول + Magic Wand Tool میجک وانڈ ٹول + Marquee مارکی + Mean: اوسط: + Measurement Scale پیمائش کا پیمانہ + Measurement Scale Marker پیمائش کے پیمانے کا نشان + Millimeters ملی میٹرز + Mobile موبائل + Move Tool موو ٹول + Could not use the move tool because the layer is locked. موو ٹول استعمال نہیں ہو سکا کیونکہ تہہ مقفل ہے۔ + Could not complete the Free Transform command because the layer is locked. آزاد ہیئت تبدیلی کمانڈ مکمل نہیں ہو سکی کیونکہ تہہ مقفل ہے۔ + Multiply ملٹیپلائی + My Brushes میرے برشز + Neutrals غیر جانبدار رنگ + New Workspace نیا ورک اسپیس + Note Tool نوٹ ٹول + Object Selection Tool آبجیکٹ سلیکشن ٹول + Off بند + Open کھولیں + Orange نارنجی + Original Ratio اصل تناسب + Out Of Gamut گیمٹ سے باہر + Outer Bevel بیرونی بیول + Outer Glow بیرونی چمک + Overlay اوورلے + Paint Bucket Tool پینٹ بکٹ ٹول + Paints from the document's opening state دستاویز کے کھلنے کی حالت سے پینٹ کرتا ہے + Path Selection Tool پاتھ سلیکشن ٹول + Direct Selection Tool ڈائریکٹ سلیکشن ٹول + Add Anchor Point Tool اینکر پوائنٹ شامل کرنے کا ٹول + Delete Anchor Point Tool اینکر پوائنٹ حذف کرنے کا ٹول + Click a segment to add an anchor point · {alt}-click an anchor to delete it اینکر پوائنٹ شامل کرنے کے لیے کسی حصے پر کلک کریں · اسے حذف کرنے کے لیے {alt}-کلک کریں + Click an anchor point to delete it · {alt}-click a segment to add one اینکر پوائنٹ حذف کرنے کے لیے اس پر کلک کریں · ایک شامل کرنے کے لیے کسی حصے پر {alt}-کلک کریں + Pattern Overlay نمونہ اوورلے + Pen Pressure قلم کا دباؤ + Pen Tilt قلم کا جھکاؤ + Pen Tool پین ٹول + Convert Point Tool پوائنٹ تبدیل کرنے کا ٹول + Photo تصویر + PhotoCraft website PhotoCraft ویب سائٹ + Picas پیکاس + Pillow Emboss تکیہ ایمبوس + Pixel Replacement پکسل متبادل + Pixels پکسلز + Points پوائنٹس + Polygon Tool پولیگون ٹول + Polygonal Lasso Tool پولیگونل لیسو ٹول + Portrait, 4 x 6 عمودی زمین تزئین، 4 x 6 + Portrait, 5 x 7 عمودی زمین تزئین، 5 x 7 + PostScript (72 points/inch) پوسٹ اسکرپٹ (72 پوائنٹس/انچ) + Postcard پوسٹ کارڈ + Poster پوسٹر + Pro (Dark) پرو (ڈارک) + Pro (Light) پرو (لائٹ) + Pro (Light Gray) پرو (لائٹ گرے) + Pro (Medium Gray) پرو (میڈیم گرے) + Protect Texture ساخت محفوظ رکھیں + Purple جامنی + Quick Mask کوئیک ماسک + Quick Selection Tool کوئیک سلیکشن ٹول + Radial شعاعی + Rasterize Layer تہہ ریسٹرائز کریں + Rasterize Layers تہیں ریسٹرائز کریں + Ratio تناسب + Recent حالیہ + Rectangle Tool مستطیل ٹول + Rectangular Marquee Tool مستطیل مارکی ٹول + Reds سرخ رنگ + Reflected منعکس شدہ + Sample: foreground colour نمونہ: پیش منظر رنگ + Sampled Colors نمونہ شدہ رنگ + Satin ساٹن + Save Pixel Aspect Ratio پکسل تناسب محفوظ کریں + Scattering بکھراؤ + Scratch Sizes اسکریچ حجم + Select Data Points ڈیٹا پوائنٹس منتخب کریں + Selection انتخاب + SemiBold SemiBold + Shadows up to: سائے تک: + Shape Dynamics شکل کی حرکیات + Sharpen Tool شارپن ٹول + Show Extras Options اضافی اختیارات دکھائیں + Slice Select Tool سلائس سلیکشن ٹول + Slice Tool سلائس ٹول + Smart Filters سمارٹ فلٹرز + Smudge Tool اسمج ٹول + Soft نرم + Soften نرم کریں + Source ماخذ + Source: set ماخذ: مقرر + Sponge Tool اسپنج ٹول + Spot Healing Brush Tool اسپاٹ ہیلنگ برش ٹول + Spread پھیلاؤ + Square, 12 x 12 مربع، 12 x 12 + Square, 5 x 5 مربع، 5 x 5 + Std Dev: معیاری انحراف: + Studio (Dark) اسٹوڈیو (ڈارک) + Studio (Light) اسٹوڈیو (لائٹ) + Stylus Wheel اسٹائلس وہیل + Subtract منہا کریں + Tabloid ٹیبلوئڈ + Text Replacement متن متبادل + Thin پتلا + Traditional (72.27 points/inch) روایتی (72.27 پوائنٹس/انچ) + Transfer منتقلی + Triangle Tool تکون ٹول + Unsaved changes غیر محفوظ شدہ تبدیلیاں + Uses the foreground and background colours. پیش منظر اور پس منظر رنگ استعمال کرتا ہے۔ + Web ویب + Web Large ویب بڑا + Web Minimum ویب کم از کم + Web Most Common ویب سب سے عام + WebP (lossless) WebP (بے نقصان) + Wet Edges گیلے کنارے + Yellows پیلے رنگ + Zoom Tool زوم ٹول + {name} Properties {name} کی خصوصیات + Revert “{name}” to the last saved version? Your changes will be lost. "{name}" کو آخری محفوظ شدہ ورژن پر واپس لائیں؟ آپ کی تبدیلیاں ضائع ہو جائیں گی۔ + Do you want to save the changes you made to “{name}” before quitting? کیا آپ باہر نکلنے سے پہلے "{name}" میں کی گئی تبدیلیاں محفوظ کرنا چاہتے ہیں؟ + Do you want to save the changes you made to “{name}” before closing? کیا آپ بند کرنے سے پہلے "{name}" میں کی گئی تبدیلیاں محفوظ کرنا چاہتے ہیں؟ + Doc: {flat}/{layered} دستاویز: {flat}/{layered} + Scratch: {size} اسکریچ: {size} + Untagged {mode} غیر ٹیگ شدہ {mode} + Invalid {mode} profile غلط {mode} پروفائل + Unnamed {mode} profile بے نام {mode} پروفائل + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch ریزولوشن: {dpi} پکسلز/انچ + Doc: {w} × {h} px دستاویز: {w} × {h} px + Frame {current} / {total} فریم {current} / {total} + Count: {n} شمار: {n} + BLANK DOCUMENT PRESETS ({n}) خالی دستاویز پری سیٹس ({n}) + Constraints ({n}) پابندیاں ({n}) + Align {what} {what} ترتیب دیں +@plural {n} Layer|{n} Layers {n} تہہ|{n} تہیں +@plural {n} layer|{n} layers {n} تہہ|{n} تہیں +@plural Group · {n} layer|Group · {n} layers گروپ · {n} تہہ|گروپ · {n} تہیں + Additional plugins folder اضافی پلگ انز فولڈر + Adobe Adobe + Adobe rgb Adobe rgb + Allow agent control ایجنٹ کنٹرول کی اجازت دیں + Allow scripts to connect اسکرپٹس کو جڑنے کی اجازت دیں + Always ہمیشہ + Always create smart objects when placing رکھنے کے وقت ہمیشہ سمارٹ آبجیکٹس بنائیں + Animated zoom متحرک زوم + Apply auto tone خودکار ٹون لاگو کریں + Ask پوچھیں + Ask before saving layered tiff تہہ دار tiff محفوظ کرنے سے پہلے پوچھیں + Auto collapse icon panels آئیکن پینلز خودکار طور پر سمیٹیں + Auto show hidden panels چھپے پینلز خودکار طور پر دکھائیں + Auto show home screen ہوم اسکرین خودکار طور پر دکھائیں + Autosave خودکار محفوظ کریں + Autosave minutes خودکار محفوظ کرنے کے منٹ + Beep when done مکمل ہونے پر بیپ کریں + Bicubic automatic بائی کیوبک خودکار + Bicubic sharper بائی کیوبک شارپر + Bicubic smoother بائی کیوبک اسموتھر + Bit depth بٹ گہرائی + Brush preview color برش پیش منظر کا رنگ + Cache levels کیشے لیولز + Cache tile size کیشے ٹائل حجم + Camera Raw Defaults کیمرہ را ڈیفالٹس + Canvas border کینوس بارڈر + Show canvas dots کینوس نقطے دکھائیں + Clip thumbnails to layer bounds تھمب نیلز کو تہہ کی حدود میں کلپ کریں + Clip Thumbnails to Layer Bounds تھمب نیلز کو تہہ کی حدود میں کلپ کریں + Canvas color کینوس کا رنگ + Canvas custom color کینوس کا مخصوص رنگ + Color picker رنگ چنندہ + Color space رنگ کی جگہ + Column width کالم کی چوڑائی + Concise مختصر + Control port کنٹرول پورٹ + Convert to srgb srgb میں تبدیل کریں + Cursors کرسرز + Custom dark مخصوص ڈارک + Custom light مخصوص لائٹ + Dark ڈارک + Dark gray گہرا سرمئی + Dashed lines دھرائی لکیریں + Destination منزل + Detailed تفصیلی + Disks ڈسکس + Display p3 Display p3 + Double click layer mask launches select and mask تہہ ماسک پر ڈبل کلک سلیکٹ اینڈ ماسک شروع کرتا ہے + Drop shadow ڈراپ شیڈو + Dynamic color sliders متحرک رنگ سلائیڈرز + East asian مشرقی ایشیائی + Effect cache mb ایفیکٹ کیشے mb + Enable flick panning فلک پیننگ فعال کریں + Enable floating document window docking فلوٹنگ دستاویز ونڈو ڈاکنگ فعال کریں + Enable narrow options bar تنگ اختیارات بار فعال کریں + Enabled فعال + Enhanced Controls بہتر کنٹرولز + Export clipboard کلپ بورڈ برآمد کریں + Extra large بہت بڑا + File Handling فائل ہینڈلنگ + File path فائل کا راستہ + Fill new type layers with placeholder نئی متن تہوں کو جگہ ساز سے بھریں + Font preview فونٹ پیش منظر + Full size tip مکمل حجم ٹپ + Gamut warning color گیمٹ تنبیہ کا رنگ + Gamut warning opacity گیمٹ تنبیہ کا دھندلاپن + Enable Generator جنریٹر فعال کریں + Gif Gif + Glossy چمکدار + Grid color گرڈ کا رنگ + Grid colors گرڈ رنگ + Grid size گرڈ حجم + Grid style گرڈ اسٹائل + Grid unit گرڈ اکائی + Gridline every ہر گرڈ لائن + Guide color رہنما لکیر کا رنگ + Guide style رہنما لکیر اسٹائل + Guides, Grid & Slices رہنما لکیریں، گرڈ اور سلائسز + Gutter گٹر + History Log تاریخ لاگ + History states تاریخ کی حالتیں + Ignore exif profile tag exif پروفائل ٹیگ نظرانداز کریں + Image interpolation تصویر انٹرپولیشن + Image previews تصویر پیش منظر + Integrations انضمام + Interface انٹرفیس + Jpeg quality Jpeg معیار + Jpg Jpg + Language زبان + Large tabs بڑے ٹیبز + Legacy compositing پرانا کمپوزٹنگ + Light gray ہلکا سرمئی + Line لائن + Lines لائنز + Lossless بے نقصان + Lowercase extension چھوٹے حروف کی ایکسٹینشن + Maximize psd compatibility psd مطابقت زیادہ سے زیادہ کریں + Medium gray درمیانہ سرمئی + Memory usage mb میموری استعمال mb + Low resolution previews کم ریزولوشن پیش منظر + Missing glyph protection غائب گلف تحفظ + Modern جدید + Nearest neighbor قریب ترین پڑوسی + Never کبھی نہیں + Normal tip نارمل ٹپ + Open as smart object سمارٹ آبجیکٹ کے طور پر کھولیں + Open in camera raw کیمرہ را میں کھولیں + Open documents as tabs دستاویزات کو ٹیبز کے طور پر کھولیں + Overscroll اوور اسکرول + Performance کارکردگی + Png Png + Point size پوائنٹ حجم + Precise درست + Preserve details تفصیلات محفوظ رکھیں + Print resolution پرنٹ ریزولوشن + Pro پرو + Pro light پرو لائٹ + Pro light gray پرو لائٹ گرے + Pro medium پرو میڈیم + Pro photo پرو فوٹو + Quick export format فوری برآمد فارمیٹ + Quick export location فوری برآمد مقام + Recent file count حالیہ فائل شمار + LUT folder LUT فولڈر + Recent files حالیہ فائلیں + Recent fonts حالیہ فونٹس + Recover on launch لانچ پر بحال کریں + Remember workspace changes ورک اسپیس تبدیلیاں یاد رکھیں + Resize image during place رکھتے وقت تصویر کا حجم تبدیل کریں + Rotate view with trackpad ٹریک پیڈ سے منظر گھمائیں + Same folder وہی فولڈر + Save in background پس منظر میں محفوظ کریں + Scratch Disks اسکریچ ڈسکس + Screen اسکرین + Screen resolution اسکرین ریزولوشن + Scrubby slider acceleration اسکربی سلائیڈر ایکسیلریشن + Sessions only صرف سیشنز + Sharpen for اس کے لیے تیز کریں + Show channels in color چینلز کو رنگ میں دکھائیں + Show crosshair in brush tip برش ٹپ میں کراس ہیئر دکھائیں + Show extension panels ایکسٹینشن پینلز دکھائیں + Show font names in english فونٹ کے نام انگریزی میں دکھائیں + Show menu colors مینیو رنگ دکھائیں + Show only crosshair while painting پینٹنگ کے دوران صرف کراس ہیئر دکھائیں + Show slice numbers سلائس نمبر دکھائیں + Show tooltips ٹول ٹپس دکھائیں + Enable animations اینیمیشنز فعال کریں + Show highlight on rollover رول اوور پر ہائی لائٹ دکھائیں + Show bounding box when dragging layer تہہ گھسیٹتے وقت باؤنڈنگ باکس دکھائیں + System title bar سسٹم ٹائٹل بار + Show transformation values ہیئت تبدیلی کی قدریں دکھائیں + Slice color سلائس رنگ + Smart guide color سمارٹ رہنما لکیر کا رنگ + Smart quotes سمارٹ کوٹس + Snap vector tools and transforms to pixel grid ویکٹر ٹولز اور ہیئت تبدیلیاں پکسل گرڈ پر اسنیپ کریں + Srgb Srgb + Standard معیاری + Studio اسٹوڈیو + Studio light اسٹوڈیو لائٹ + Adwaita Adwaita + Adwaita dark Adwaita dark + Subdivisions ذیلی تقسیمات + System سسٹم + Text engine متن انجن + Text file متن فائل + Tiny بہت چھوٹا + Touch gestures ٹچ اشارے + Transparency & Gamut شفافیت اور رنگ دائرہ کار + Type units متن اکائیاں + UI font size UI فونٹ حجم + UI scale UI پیمانہ + Units & Rulers اکائیاں اور رولرز + Use additional plugins folder اضافی پلگ انز فولڈر استعمال کریں + Use esc to commit نافذ کرنے کے لیے esc استعمال کریں + Use gpu gpu استعمال کریں + Use legacy free transform پرانی آزاد ہیئت تبدیلی استعمال کریں + Use shift key for tool switch ٹول سوئچ کے لیے shift کی استعمال کریں + Vary round brush hardness on hud ہڈ پر گول برش کی سختی مختلف رکھیں + Webp Webp + World ready ورلڈ ریڈی + Zoom clicked point to center کلک شدہ نقطے کو مرکز میں زوم کریں + Zoom resizes windows زوم ونڈوز کا حجم تبدیل کرتا ہے + Zoom with scroll wheel اسکرول وہیل سے زوم کریں + Zoom with trackpad pinch ٹریک پیڈ پنچ سے زوم کریں + Pass Through پاس تھرو + Dissolve تحلیل + Darker Color گہرا رنگ + Linear Dodge (Add) لکیری ڈاج (جمع) + Lighter Color ہلکا رنگ + Soft Light نرم روشنی + Hard Light سخت روشنی + Vivid Light شوخ روشنی + Linear Light لکیری روشنی + Pin Light پن روشنی + Exclusion اخراج + Divide تقسیم + Luminosity چمک +@plural {n} sample|{n} samples {n} نمونہ|{n} نمونے + {added}, {removed} subtracted {added}, {removed} منہا شدہ + Adjustment Preview ایڈجسٹمنٹ پیش منظر + Airbrush: keeps depositing paint while the pointer rests. ایئر برش: پوائنٹر رکنے کے دوران رنگ ڈالتا رہتا ہے۔ + Angle: زاویہ: + Blend If امتزاج اگر + Close Tab Group ٹیب گروپ بند کریں + Collapse سمیٹیں + Collapse All Groups تمام گروپس سمیٹیں + Collapse Panel Group پینل گروپ سمیٹیں + Collapse group ({key}-click: all groups) گروپ سمیٹیں ({key}-کلک: تمام گروپس) + Create new brush from the current settings موجودہ ترتیبات سے نیا برش بنائیں + Create new brush from these settings ان ترتیبات سے نیا برش بنائیں + Update the current brush with these settings ان ترتیبات کے ساتھ موجودہ برش اپ ڈیٹ کریں + Delete brush برش حذف کریں + Drag the arrow to set the angle, the dots to set the roundness زاویہ مقرر کرنے کے لیے تیر گھسیٹیں، گولائی مقرر کرنے کے لیے نقطے + Expand پھیلائیں + Expand Panel Group پینل گروپ پھیلائیں + Expand group ({key}-click: all groups) گروپ پھیلائیں ({key}-کلک: تمام گروپس) + Fade steps فیڈ مراحل + Hide the layer's effects ({key}-click: all layers) تہہ کے اثرات چھپائیں ({key}-کلک: تمام تہیں) + Import Brushes… (.abr or image) برشز درآمد کریں… (.abr یا تصویر) + Import Brush from Image… تصویر سے برش درآمد کریں… + Move Group Down گروپ نیچے منتقل کریں + Move Group Up گروپ اوپر منتقل کریں + Reset the brush to the defaults برش کو ڈیفالٹس پر ری سیٹ کریں + Restore original size اصل حجم بحال کریں + Roundness: گولائی: + Search Brushes برشز تلاش کریں + Show the layer's effects ({key}-click: all layers) تہہ کے اثرات دکھائیں ({key}-کلک: تمام تہیں) + Brush picker برش چنندہ + Erase مٹائیں + Right click with painting tools پینٹنگ ٹولز کے ساتھ دایاں کلک + System Info… سسٹم کی معلومات… + System Info سسٹم کی معلومات + More panels مزید پینلز + More documents مزید دستاویزات + These settings aren't available in PhotoCraft yet. یہ ترتیبات ابھی PhotoCraft میں دستیاب نہیں ہیں۔ + Graphics گرافکس + Reset GPU Backend GPU بیک اینڈ ری سیٹ کریں + Applies at next launch. اگلی بار شروع ہونے پر لاگو ہوتا ہے۔ + Click to sample the foreground colour · {key}-click for background پیش منظر رنگ کا نمونہ لینے کے لیے کلک کریں · پس منظر کے لیے {key}-کلک + {key}-click to set the source ماخذ مقرر کرنے کے لیے {key}-کلک کریں + {key} to subtract منہا کرنے کے لیے {key} + Clone source {n} ({key}-click with Clone Stamp to set) کلون ماخذ {n} ({key}-کلک کلون اسٹیمپ کے ساتھ مقرر کرنے کے لیے) + Source: not set ({key}-click with Clone Stamp) ماخذ: مقرر نہیں ({key}-کلک کلون اسٹیمپ کے ساتھ) + Pixel Layer پکسل تہہ + Type Layer متن تہہ + Shape Layer شکل تہہ + Group Layer گروپ تہہ + Fill Layer بھرائی تہہ + Smart Object Layer سمارٹ آبجیکٹ تہہ + Create Warped Text موڑا ہوا متن بنائیں + Rasterize Smart Object سمارٹ آبجیکٹ ریسٹرائز کریں + Rasterize Fill Content بھرائی مواد ریسٹرائز کریں + Rasterize Layer Style تہہ اسٹائل ریسٹرائز کریں + Unlock: picking a preset replaces these settings غیر مقفل: پری سیٹ چننا ان ترتیبات کی جگہ لے لیتا ہے + Lock: keep these settings when picking another preset مقفل: دوسرا پری سیٹ چنتے وقت ان ترتیبات کو برقرار رکھیں + Diameter: قطر: + Hardness: سختی: + No brush presets کوئی برش پری سیٹس نہیں + Toggle the Brush Settings panel (F5) برش کی ترتیبات پینل تبدیل کریں (F5) + Create new brush preset from the current settings موجودہ ترتیبات سے نیا برش پری سیٹ بنائیں + Brush Settings… (F5): every option of this brush برش کی ترتیبات… (F5): اس برش کا ہر اختیار + Rename Brush… برش کا نام تبدیل کریں… + Delete Brush برش حذف کریں + New Brush Preset… نیا برش پری سیٹ… + Capture Brush Size in Preset پری سیٹ میں برش کا حجم محفوظ کریں + Include Tool Settings ٹول کی ترتیبات شامل کریں + Include Color رنگ شامل کریں + Enter a name for the brush preset برش پری سیٹ کے لیے ایک نام درج کریں + A brush preset with this name already exists اس نام کا برش پری سیٹ پہلے سے موجود ہے + Brush Preset Options برش پری سیٹ کے اختیارات + Group name گروپ کا نام + Brush name برش کا نام + Brush Name برش کا نام + Brush Stroke برش اسٹروک + Brush Tip برش ٹپ + Off: the speed of the pointer sets the spacing بند: پوائنٹر کی رفتار وقفہ مقرر کرتی ہے + Tilt Scale جھکاؤ کا پیمانہ + Brush Projection برش پروجیکشن + Apply the pen's tilt and rotation to the tip shape قلم کا جھکاؤ اور گردش ٹپ کی شکل پر لاگو کریں + Mixer Brush مکسر برش + Mixer Brush Tool مکسر برش ٹول + Color Replacement Tool کلر ری پلیسمنٹ ٹول + Wet گیلا + Current Brush Load موجودہ برش لوڈ + Load Brush برش لوڈ کریں + Clean Brush برش صاف کریں + Load the brush after each stroke ہر اسٹروک کے بعد برش لوڈ کریں + Clean the brush after each stroke ہر اسٹروک کے بعد برش صاف کریں + Mix ملائیں + Wetness Jitter گیلا پن جیٹر + Mix Jitter ملاپ جیٹر + Show the layer mask as an overlay تہہ ماسک کو اوورلے کے طور پر دکھائیں + Toggle visibility نمائش تبدیل کریں + Unlink the mask from the layer ماسک کو تہہ سے غیر مربوط کریں + Link the mask to the layer ماسک کو تہہ سے مربوط کریں + Gradient گریڈیئنٹ + Gradient Options گریڈیئنٹ اختیارات + Align with layer تہہ کے ساتھ ترتیب دیں + Reset Alignment ترتیب ری سیٹ کریں + Centre the gradient (offset 0, 0) گریڈیئنٹ کو مرکز میں کریں (آفسیٹ 0, 0) + Gradient Editor گریڈیئنٹ ایڈیٹر + Gradient picker گریڈیئنٹ چنندہ + Save As Preset پری سیٹ کے طور پر محفوظ کریں + The gradient the Gradient tool paints with وہ گریڈیئنٹ جس سے گریڈیئنٹ ٹول پینٹ کرتا ہے + Adds the current gradient to the Gradients panel موجودہ گریڈیئنٹ کو گریڈیئنٹس پینل میں شامل کرتا ہے + Gradient stops گریڈیئنٹ اسٹاپس + Select a type layer to edit its character and paragraph settings. کریکٹر اور پیراگراف کی ترتیبات میں ترمیم کے لیے متن تہہ منتخب کریں۔ + Text color متن کا رنگ + Type Options متن کے اختیارات + Anti-aliasing method اینٹی ایلیاسنگ طریقہ + Horizontal type افقی متن + Vertical type عمودی متن + Fill path with foreground color پیش منظر رنگ کے ساتھ پاتھ بھریں + Stroke path with brush برش کے ساتھ پاتھ پر لکیر بنائیں + Load path as a selection پاتھ کو بطور انتخاب لوڈ کریں + Make work path from selection انتخاب سے ورک پاتھ بنائیں + Add vector mask ویکٹر ماسک شامل کریں + Create new path نیا پاتھ بنائیں + Delete path پاتھ حذف کریں + Delete vector mask ویکٹر ماسک حذف کریں + CPU (no GPU acceleration) CPU (کوئی GPU ایکسیلریشن نہیں) + DirectX 12 DirectX 12 + GPU backend GPU بیک اینڈ + Linux display server لینکس ڈسپلے سرور + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure ٹیبلٹ دباؤ استعمال کریں + Vulkan Vulkan + Alt-click to define a source point to clone from کلون کے لیے ماخذ نقطہ متعین کرنے کے لیے Alt-کلک کریں + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. انتخاب بنانے کے لیے کوئی پاتھ نہیں: پین ٹول سے ایک بنائیں یا پاتھس پینل میں ایک منتخب کریں۔ + Cancel opening کھلنا منسوخ کریں + Cancelled opening {name} {name} کھولنا منسوخ کر دیا گیا + Damaged Photoshop document خراب شدہ فوٹوشاپ دستاویز + The document “{name}” may be damaged. دستاویز "{name}" خراب ہو سکتی ہے۔ + Some layers, pixels or masks could not be read. Do you want to continue opening it? کچھ تہیں، پکسلز یا ماسک پڑھے نہیں جا سکے۔ کیا آپ اسے کھولنا جاری رکھنا چاہتے ہیں؟ + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. اگر آپ جاری رکھتے ہیں، تو اصل پر محفوظ کرنا ناقابل مطالعہ ڈیٹا کو مستقل طور پر ضائع کر سکتا ہے۔ اصل کو محفوظ رکھنے کے لیے ایک نقل محفوظ کریں۔ + The document “{name}” does not have an embedded {mode} profile. دستاویز "{name}" میں کوئی ایمبیڈڈ {mode} پروفائل نہیں ہے۔ + The document “{name}” has an embedded color profile that does not match the current {mode} working space. دستاویز "{name}" میں ایک ایمبیڈڈ رنگ پروفائل ہے جو موجودہ {mode} ورکنگ اسپیس سے مماثل نہیں ہے۔ + Working: {working} کام جاری: {working} + Developing {name} {name} ڈیویلپ ہو رہا ہے + Cancelled {label} {label} منسوخ ہو گیا + Esc to cancel منسوخ کرنے کے لیے Esc + Opened {name} {name} کھل گیا + Opening {name} {name} کھل رہا ہے + Saved {name} {name} محفوظ ہو گیا + Saving {name} {name} محفوظ ہو رہا ہے + Saving {percent} محفوظ ہو رہا ہے {percent} + A save to {name} is still running; try again when it finishes {name} میں محفوظ کرنا ابھی جاری ہے؛ مکمل ہونے پر دوبارہ کوشش کریں + {label} is writing the file and can no longer be cancelled {label} فائل لکھ رہا ہے اور اب منسوخ نہیں کیا جا سکتا + Opening {name}… {name} کھل رہا ہے… + Opening… کھل رہا ہے… + Reading… پڑھا جا رہا ہے… + Working… کام جاری ہے… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: گرڈ: + Color Range رنگ کی حد + Adaptive Wide Angle اڈاپٹیو وائیڈ اینگل + Chalk & Charcoal چاک اور چارکول + Charcoal چارکول + Chrome کروم + Conté Crayon کونٹے کریون + Graphic Pen گرافک پین + Halftone Pattern ہاف ٹون پیٹرن + Note Paper نوٹ پیپر + Photocopy فوٹو کاپی + Plaster پلاسٹر + Reticulation ریٹیکولیشن + Stamp اسٹیمپ + Torn Edges پھٹے کنارے + Water Paper واٹر پیپر + Move موو + Pencil پنسل + Clone Stamp کلون اسٹیمپ + Dodge ڈاج + Burn برن + Quick Selection کوئیک سلیکشن + History Brush ہسٹری برش + Color Replacement کلر ری پلیسمنٹ + New Group نیا گروپ + Layer Comp تہہ کمپوزیشن + Alpha الفا + Make میک + Model ماڈل + F F + Artist آرٹسٹ + Spot Color اسپاٹ کلر + Roman رومن + Bold Italic بولڈ ترچھا + Justification جسٹیفیکیشن + Symbols علامات + Punctuation رموز اوقاف + Make Work Path ورک پاتھ بنائیں + Make Selection انتخاب بنائیں + Fill Path پاتھ بھریں + Stroke Path پاتھ پر لکیر بنائیں + Delete Path پاتھ حذف کریں + W W + H H + Ruler رولر + New Layer Comp نئی تہہ کمپوزیشن + Duplicate Layer Comp تہہ کمپوزیشن کی نقل بنائیں + Rename Layer Comp تہہ کمپوزیشن کا نام تبدیل کریں + Apply Layer Comp تہہ کمپوزیشن لاگو کریں + Update Layer Comp تہہ کمپوزیشن اپ ڈیٹ کریں + Method طریقہ + Author مصنف + Software سافٹ ویئر + Pencil Tool پنسل ٹول + Pixel پکسل + Adjustment ایڈجسٹمنٹ + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract گھسیٹیں (لیسو) یا نقطوں پر کلک کریں (پولیگونل) · {add} شامل کریں · {sub} منہا کریں + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels کراپ باکس گھسیٹیں · منتقل کرنے کے لیے اندر گھسیٹیں · کنارے حجم بدلتے ہیں ({ratio} تناسب، {centre} مرکز) · ڈرائنگ کے دوران Space منتقل کرتا ہے · تیر دھکیلتے ہیں · X سمت بندی تبدیل کرتا ہے · {commit} نافذ کرتا ہے · Esc منسوخ کرتا ہے + {mode} Color · {bits} bit {mode} رنگ · {bits} بٹ + Scrubby Zoom اسکربی زوم + Auto Erase خودکار اریزر + Color Adaptation رنگ موافقت + Preserve Transparency شفافیت برقرار رکھیں + shortcut شارٹ کٹ + Saved محفوظ شدہ + Arc آرک + Arch آرچ + Bulge ابھار + Shell Lower شیل لوئر + Shell Upper شیل اپر + Flag فلیگ + Fish فش + Wave ویو + Rise رائز + Inflate پھولائیں + Twist موڑیں + Squeeze دبائیں + Mask ماسک + Channel چینل + Rectangle مستطیل + New Channel نیا چینل + Save Selection انتخاب محفوظ کریں + Load Selection انتخاب لوڈ کریں +@id edit.purge.undo تاریخ کالعدم کریں + Rendering Mode رینڈرنگ موڈ + Automatic (recommended) خودکار (تجویز کردہ) + CPU / Compatibility CPU / مطابقت + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. خودکار دستیاب ہونے پر GPU ایکسیلریشن استعمال کرتا ہے اور خرابیوں پر CPU رینڈرنگ پر واپس آ جاتا ہے۔ + Switched to CPU rendering CPU رینڈرنگ پر تبدیل ہو گیا + GPU acceleration could not continue. Your documents are unchanged. GPU ایکسیلریشن جاری نہیں رہ سکا۔ آپ کی دستاویزات تبدیل نہیں ہوئیں۔ + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft CPU تصویری کمپوزیٹر استعمال کر رہا ہے۔ ونڈو پھر بھی آپ کا گرافکس ایڈاپٹر استعمال کر سکتی ہے۔ + Retry GPU requires a restart. Save your work first. GPU دوبارہ کوشش کے لیے دوبارہ آغاز درکار ہے۔ پہلے اپنا کام محفوظ کریں۔ + Retry GPU tries the graphics processor again now. GPU دوبارہ کوشش ابھی گرافکس پروسیسر کو دوبارہ آزماتی ہے۔ + GPU acceleration resumed. GPU ایکسیلریشن بحال ہو گیا۔ + Keep Using CPU CPU استعمال کرتے رہیں + Retry GPU GPU دوبارہ کوشش کریں + Advanced ایڈوانسڈ + GPU Backend GPU بیک اینڈ + Details تفصیلات + Rendering mode رینڈرنگ موڈ + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) فریز لیسو: کسی حصے کو منجمد کرنے کے لیے گھسیٹیں، پگھلانے کے لیے Alt-گھسیٹیں (L) + Shadow Clipping Warning (U) سایہ کلپنگ تنبیہ (U) + Highlight Clipping Warning (O) نمایاں حصہ کلپنگ تنبیہ (O) + Show Lab Color Readouts Lab رنگ ریڈ آؤٹس دکھائیں + Show Vectorscope ویکٹرواسکوپ دکھائیں + Show Red at 3 o'clock سرخ کو 3 بجے کی پوزیشن پر دکھائیں + Show Skin Tone Indicator جلد کے رنگ کا اشارہ دکھائیں + Tone Histogram ٹون ہسٹوگرام + Show Selected Region منتخب خطہ دکھائیں +cameraRaw Light روشنی + Camera Raw Filter ({layer}) کیمرہ را فلٹر ({layer}) + Camera Raw ({name}) کیمرہ را ({name}) +cameraRaw Lights روشن حصے +cameraRaw Darks تاریک حصے +cameraRaw Red سرخ +cameraRaw Orange نارنجی +cameraRaw Yellow پیلا +cameraRaw Green سبز +cameraRaw Aqua آکوا +cameraRaw Blue نیلا +cameraRaw Purple جامنی +cameraRaw Magenta میجنٹا +cameraRaw Blending امتزاج +cameraRaw Tint ٹنٹ +cameraRaw Temperature درجہ حرارت +cameraRaw Amount مقدار + Patch Tool پیچ ٹول + Patch: پیچ: + Lasso around an area, then drag the selection کسی حصے کے گرد لیسو کریں، پھر انتخاب کو گھسیٹیں + Transform On Drop چھوڑنے پر ہیئت تبدیل کریں + Scale or rotate the moved area, then press {key} منتقل شدہ حصے کا پیمانہ یا گردش کریں، پھر {key} دبائیں + Now drag the selection onto another area اب انتخاب کو دوسرے حصے پر گھسیٹیں + Edit Type قسم میں ترمیم کریں + New Style… نیا اسٹائل… + Style {name} saved اسٹائل {name} محفوظ ہو گیا + Create new style (from the selected type) نیا اسٹائل بنائیں (منتخب قسم سے) + Create new style from the selected layer منتخب تہہ سے نیا اسٹائل بنائیں + Make Default ڈیفالٹ بنائیں + Reset to Default ڈیفالٹ پر ری سیٹ کریں + Full-resolution preview unavailable مکمل ریزولوشن پیش منظر دستیاب نہیں + No patterns کوئی نمونے نہیں + Enable {name} {name} فعال کریں + Hold Alt while drawing for straight segments سیدھے حصوں کے لیے ڈرائنگ کے دوران Alt دبائے رکھیں + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts پولیگون نقطوں پر گھسیٹیں یا کلک کریں · سیدھے حصوں کے لیے لیسو کے دوران Alt دبائے رکھیں · {add} شامل کریں · {sub} ڈرائنگ سے پہلے منہا کرتا ہے + Extend بڑھائیں + Structure: ساخت: + Content-Aware Move Tool مواد سے آگاہ موو ٹول + TIFF Options TIFF اختیارات + Targa Options Targa اختیارات + JPEG Options JPEG اختیارات + Image Options تصویر کے اختیارات + Format Options فارمیٹ کے اختیارات + Baseline ("Standard") بیس لائن ("معیاری") + Baseline Optimized بیس لائن اوپٹیمائزڈ + 24 bits/pixel 24 بٹس/پکسل + 32 bits/pixel 32 بٹس/پکسل + Including layers will increase file size. تہیں شامل کرنے سے فائل کا حجم بڑھ جائے گا۔ + Discard Layers and Save a Copy تہیں ضائع کریں اور ایک نقل محفوظ کریں + Don't show again دوبارہ نہ دکھائیں + Save failed: {error} محفوظ کرنا ناکام ہوا: {error} + Save cancelled محفوظ کرنا منسوخ ہوا + Magnetic Lasso Tool میگنیٹک لیسو ٹول + Frequency فریکوئنسی + Use tablet pressure to change pen width قلم کی چوڑائی بدلنے کے لیے ٹیبلٹ دباؤ استعمال کریں + About بارے میں + Contributors شراکت دار + Models ماڈلز + Sort ترتیب دیں + Username صارف نام + Display name ظاہری نام + Real name اصلی نام + Reverse the order ترتیب الٹ دیں + Grab bag گریب بیگ + Table جدول + Name (A–Z) نام (A–Z) + Merged PRs ضم شدہ PRs + Commits کمٹس + Lines added شامل کردہ لائنیں + Lines deleted حذف شدہ لائنیں + Line delta لائن کا فرق + Binary assets added شامل کردہ بائنری اثاثے + Binary assets removed ہٹائے گئے بائنری اثاثے + First commit پہلا کمٹ + First پہلا + Last commit آخری کمٹ + Last آخری + Contributors: {contributors} · Commits: {commits} شراکت دار: {contributors} · کمٹس: {commits} + No contributor data was built into this copy. اس کاپی میں کوئی شراکت دار ڈیٹا شامل نہیں کیا گیا۔ + No model credits were built into this copy. اس کاپی میں کوئی ماڈل کریڈٹس شامل نہیں کیے گئے۔ + Company کمپنی + Version ورژن + % of all commits تمام کمٹس کا % + Lines +/− لائنیں +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs، {commits} کمٹس، +{added} / −{deleted} لائنیں (Δ {delta})، +{binAdded} / −{binDeleted} بائنری اثاثے، {first} – {last} + Create new swatch نیا نمونہ بنائیں + New Swatch… نیا نمونہ… + New Swatch Group… نیا نمونہ گروپ… + Import Swatches… نمونے درآمد کریں… + Replace Swatches… نمونے تبدیل کریں… + Export Swatches… نمونے برآمد کریں… + Export Swatches for Exchange… تبادلے کے لیے نمونے برآمد کریں… + Reset Swatches نمونے ری سیٹ کریں + Add to Swatches نمونوں میں شامل کریں + Blend If: امتزاج اگر: + Current Layer: موجودہ تہہ: + Channels: چینلز: + Knockout ناک آؤٹ + Apply At Canvas Bounds کینوس کی حدود پر لاگو کریں + Color Picker (Text Color) رنگ چنندہ (متن کا رنگ) + Color Picker ({name}) رنگ چنندہ ({name}) + Color Picker (Fill Color) رنگ چنندہ (بھرائی رنگ) + Fill color بھرائی رنگ + Color Picker (Stroke Color) رنگ چنندہ (لکیر رنگ) + Stroke color لکیر رنگ + Pick screen color اسکرین رنگ چنیں + Sample a pixel anywhere on the screen اسکرین پر کہیں بھی پکسل کا نمونہ لیں + Click to pick · Esc to cancel چننے کے لیے کلک کریں · منسوخ کرنے کے لیے Esc + Pick a screen pixel or press Esc to cancel اسکرین پکسل چنیں یا منسوخ کرنے کے لیے Esc دبائیں + Scroll to zoom زوم کے لیے اسکرول کریں + Drag to pan · Scroll to zoom · Double-click to fit پین کرنے کے لیے گھسیٹیں · زوم کے لیے اسکرول کریں · فٹ کرنے کے لیے ڈبل کلک کریں + Show/Hide All Panels تمام پینلز دکھائیں/چھپائیں + Show/Hide Panels پینلز دکھائیں/چھپائیں + Could not open کھل نہیں سکا + {path}: the path is not valid Unicode; rename the file and open it again. {path}: راستہ درست یونیکوڈ نہیں ہے؛ فائل کا نام تبدیل کریں اور دوبارہ کھولیں۔ + Contents: مواد: + Custom Pattern: مخصوص نمونہ: + Foreground Color پیش منظر رنگ + Color… رنگ… + 50% Gray 50% سرمئی + {size} (was {old}) {size} (پہلے {old} تھا) + Cone کون + Cone (Inverted) کون (الٹا) + Domed گنبد نما + Domed (Inverted) گنبد نما (الٹا) + Diagonal (Descending) ترچھا (نیچے جاتا) + Contour Editor کونٹور ایڈیٹر + Anti-aliased اینٹی ایلیاسڈ + Corner کونا + Gloss Contour گلوس کونٹور + Contour کونٹور + Cone - Inverted کون - الٹا + Cove - Deep کوو - گہرا + Cove - Shallow کوو - کم گہرا + Half Round نیم گول + Ring رنگ + Ring - Double رنگ - ڈبل + Rolling Slope - Descending رولنگ ڈھلان - نیچے جاتی + Rounded Steps گول قدمیں + Sawtooth 1 آری دانت 1 + Red Eye Tool ریڈ آئی ٹول + Red Eye ریڈ آئی + Pupil Size پتلی کا حجم + Darken Amount گہرا کرنے کی مقدار + Click a red pupil to neutralize it بے اثر کرنے کے لیے سرخ پتلی پر کلک کریں + open source اوپن سورس + …and {n} more …اور {n} مزید + Eyedroppers: آئی ڈراپرز: + Set Black Point بلیک پوائنٹ مقرر کریں + Set Neutral Gray Point نیوٹرل گرے پوائنٹ مقرر کریں + Set White Point وائٹ پوائنٹ مقرر کریں + Unavailable in this color mode اس رنگ موڈ میں دستیاب نہیں + Selection Preview: انتخاب پیش منظر: + Black Matte بلیک میٹ + White Matte وائٹ میٹ + Seafoam سی فوم + Indigo انڈیگو + Fuchsia فیوشیا + Violet وایلیٹ + Reset ری سیٹ کریں + Large document: redrawing on the CPU بڑی دستاویز: CPU پر دوبارہ ڈرائنگ ہو رہی ہے + Redrawing دوبارہ ڈرائنگ ہو رہی ہے + This redraw took {seconds} s on the CPU. یہ دوبارہ ڈرائنگ CPU پر {seconds} سیکنڈ لگی۔ + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. تبدیلیاں جو پوری دستاویز کو تبدیل کرتی ہیں، جیسے تہہ کا امتزاج موڈ، دھندلاپن یا نمائش، پوری دستاویز کو دوبارہ ڈرائنگ کرتی ہیں۔ + The GPU compositor wasn't used: {reason} GPU کمپوزیٹر استعمال نہیں ہوا: {reason} +fontWeight Thin پتلا +fontWeight ExtraLight بہت ہلکا +fontWeight Light ہلکا +fontWeight Regular عام +fontWeight Medium درمیانہ +fontWeight SemiBold نیم بولڈ +fontWeight Bold بولڈ +fontWeight ExtraBold زیادہ بولڈ +fontWeight Black بہت گہرا +fontWeight Italic ترچھا + Pattern Stamp Tool پیٹرن اسٹیمپ ٹول + Pattern Stamp پیٹرن اسٹیمپ + Impressionist امپریشنسٹ + Rotate View منظر گھمائیں + Rotate View Tool منظر گھمانے کا ٹول + Reset View منظر ری سیٹ کریں + Drag around the centre to rotate the view · Shift constrains to 15° منظر گھمانے کے لیے مرکز کے گرد گھسیٹیں · Shift 15° پر محدود کرتا ہے + Dual Axis دوہرا محور + Diagonal ترچھا + Transform Symmetry سمیٹری تبدیل کریں + Point Sample پوائنٹ نمونہ + 3 by 3 Average 3 بذریعہ 3 اوسط + 5 by 5 Average 5 بذریعہ 5 اوسط + 11 by 11 Average 11 بذریعہ 11 اوسط + 31 by 31 Average 31 بذریعہ 31 اوسط + 51 by 51 Average 51 بذریعہ 51 اوسط + 101 by 101 Average 101 بذریعہ 101 اوسط + Sample Size: نمونہ حجم: + All Layers No Adjustments تمام تہیں بغیر ایڈجسٹمنٹ + Current & Below No Adjustments موجودہ اور نیچے بغیر ایڈجسٹمنٹ + Show Sampling Ring نمونہ گیری رنگ دکھائیں + Out of gamut for printing: click for the nearest printable colour پرنٹنگ کے لیے گیمٹ سے باہر: قریب ترین قابل پرنٹ رنگ کے لیے کلک کریں + Not a web colour: click for the nearest web colour ویب رنگ نہیں: قریب ترین ویب رنگ کے لیے کلک کریں + Color to Alpha… رنگ کو الفا میں… + Transparency Threshold شفافیت کی حد + Opacity Threshold دھندلاپن کی حد + PhotoCraft — Screen Color Picker PhotoCraft — اسکرین رنگ چنندہ + Rasterize svg groups deeper than SVG گروپس کو اس سے گہرے ریسٹرائز کریں + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 اس حد کو بند کر دیتا ہے؛ 100 سطحوں سے گہرے نیسٹڈ SVG گروپس ہمیشہ ریسٹرائز ہوتے ہیں۔ + Rule of Thirds ایک تہائی کا اصول + Triangle تکون + Golden Ratio گولڈن تناسب + Golden Spiral گولڈن اسپائرل + Auto Show Overlay اوورلے خودکار دکھائیں + Always Show Overlay اوورلے ہمیشہ دکھائیں + Never Show Overlay اوورلے کبھی نہ دکھائیں + Cycle Overlay اوورلے تبدیل کریں + Cycle Orientation سمت بندی تبدیل کریں + Set the overlay options for the Crop tool کراپ ٹول کے لیے اوورلے اختیارات مقرر کریں + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-کلک ڈیسک ٹاپ پر گیا، جو Alt-گھسیٹنے سے ونڈوز منتقل کرتا ہے۔ اس کی ونڈو منتقلی کی کی کو Super پر مقرر کریں، یا ونڈو > موڈیفائر کیز میں Alt کو لاک کریں۔ + Color Picker (Layer Style Color) رنگ چنندہ (تہہ اسٹائل رنگ) + Auto Hide Notifications اطلاعات خودکار چھپائیں + Notification Duration (seconds) اطلاع کا دورانیہ (سیکنڈ) + Bundle Arrow-Key Nudges ایرو-کی دھکوں کو جمع کریں + Nudge Bundle Pause (ms) دھکا جمع کرنے کا وقفہ (ms) + Appearance Mode ظاہری شکل موڈ + Dark Theme ڈارک تھیم + Light Theme لائٹ تھیم + Pro Medium پرو میڈیم + Studio Light اسٹوڈیو لائٹ + Next Appearance Mode اگلا ظاہری شکل موڈ + Sync with system سسٹم کے ساتھ ہم آہنگ کریں + Active فعال + Color Picker (Solid Color) رنگ چنندہ (ٹھوس رنگ) + Straighten the image by drawing a line on it اس پر لائن کھینچ کر تصویر سیدھی کریں + Set additional Crop options اضافی کراپ اختیارات مقرر کریں + Show Cropped Area تراشا ہوا حصہ دکھائیں + Enable Crop Shield کراپ شیلڈ فعال کریں + Match Canvas کینوس سے مماثل کریں + Auto Adjust Opacity دھندلاپن خودکار ایڈجسٹ کریں + Crop Shield color کراپ شیلڈ کا رنگ + Color Picker (Crop Shield Color) رنگ چنندہ (کراپ شیلڈ کا رنگ) + From Center مرکز سے + Link radii ریڈیائی طور پر مربوط کریں + Star Ratio: ستارہ تناسب: + Create Rectangle مستطیل بنائیں + Create Ellipse بیضوی بنائیں + Create Triangle تکون بنائیں + Create Polygon پولیگون بنائیں + Pressure curve دباؤ کا وکر + Click to add a point, right-click to remove نقطہ شامل کرنے کے لیے کلک کریں، ہٹانے کے لیے دایاں کلک کریں + Some brush presets could not be loaded کچھ برش پری سیٹس لوڈ نہیں ہو سکے + Retry saving presets پری سیٹس محفوظ کرنے کی دوبارہ کوشش کریں + Brush preset changes are not saved: {error} برش پری سیٹ کی تبدیلیاں محفوظ نہیں ہوئیں: {error} + Keep this tab open to retain your brushes. اپنے برشز برقرار رکھنے کے لیے یہ ٹیب کھلا رکھیں۔ + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. براؤزر اسٹوریج دستیاب نہیں؛ برشز صرف سیشن کے لیے ہیں۔ انہیں برقرار رکھنے کے لیے یہ ٹیب کھلا رکھیں۔ + Saving brush presets… Keep this tab open until saving finishes. برش پری سیٹس محفوظ ہو رہے ہیں… محفوظ ہونے تک یہ ٹیب کھلا رکھیں۔ + Reflect عکس + Glow چمک + Negation نیگیشن + XOR XOR + Color Burn (Paint.NET) کلر برن (Paint.NET) + Color Dodge (Paint.NET) کلر ڈاج (Paint.NET) + Stroke Options لکیر کے اختیارات + Solid ٹھوس + Dashed دھرائی ہوئی + Dotted نقطہ دار + Alignment ترتیب + Caps سرے + Butt / Flat بٹ / فلیٹ + Corners کونے + Miter مائٹر + Round گول + Bevel بیول + Miter limit مائٹر کی حد + Dashed line دھرائی ہوئی لائن + Dash ڈیش + Gap خلا + Dash / gap lengths × stroke width ڈیش / خلا کی لمبائی × لکیر کی چوڑائی + Add pair جوڑا شامل کریں + Remove pair جوڑا ہٹائیں + Dash offset ڈیش آفسیٹ + Open paths use centered strokes کھلے پاتھ درمیان میں لکیر استعمال کرتے ہیں + Use finite lengths and a positive pattern total محدود لمبائیاں اور مثبت پیٹرن کل استعمال کریں + No subject found — try Object Selection or the Lasso کوئی موضوع نہیں ملا — آبجیکٹ سلیکشن یا لیسو آزمائیں + Quick Selection found nothing there کوئیک سلیکشن کو وہاں کچھ نہیں ملا + Object Selection found nothing there — try a tighter rectangle آبجیکٹ سلیکشن کو وہاں کچھ نہیں ملا — تنگ مستطیل آزمائیں + No subject found — try a rectangle around the object کوئی موضوع نہیں ملا — شے کے گرد ایک مستطیل آزمائیں + Click the first point or press {key} to close · {del} removes a point · Esc cancels پہلے نقطے پر کلک کریں یا بند کرنے کے لیے {key} دبائیں · {del} نقطہ ہٹاتا ہے · Esc منسوخ کرتا ہے + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close کلک کریں، پھر کنارے کے ساتھ منتقل ہوں · نقطہ مقرر کرنے کے لیے کلک کریں · سیدھے حصے کے لیے {alt}-کلک · بند کرنے کے لیے ڈبل کلک کریں + Follows only edges this close to the pointer ([ and ] change it) صرف پوائنٹر کے اتنے قریب کناروں کی پیروی کرتا ہے ([ اور ] اسے تبدیل کرتے ہیں) + Higher values follow only edges that contrast sharply with their surroundings زیادہ قدریں صرف وہ کنارے فولو کرتی ہیں جو اپنے گردونواح سے شدید تضاد رکھتے ہیں + How often fastening points are placed by themselves کتنی بار مقررہ نقطے خود بخود رکھے جاتے ہیں + Appearance Settings… ظاہری شکل کی ترتیبات… + Add to favorites پسندیدہ میں شامل کریں + Remove from favorites پسندیدہ سے ہٹائیں + Camera Data کیمرہ ڈیٹا +cameraData Make میک +cameraData Model ماڈل +cameraData Date Taken تصویر لینے کی تاریخ +cameraData Exposure نمائش +cameraData F-Stop F-اسٹاپ +cameraData ISO Speed ISO اسپیڈ +cameraData Focal Length فوکل لمبائی +cameraData Focal Length (35 mm) فوکل لمبائی (35 ملی میٹر) +cameraData Lens لینز + Activate Panel پینل فعال کریں + Change Panel Group پینل گروپ تبدیل کریں + Change Panel Layout پینل لے آؤٹ تبدیل کریں + Close Panel پینل بند کریں + Dock Panel پینل ڈاک کریں + Float Panel پینل فلوٹ کریں + Move Panel پینل منتقل کریں + Group with اس کے ساتھ گروپ کریں + Panel menu پینل مینیو + Resize panel window پینل ونڈو کا حجم بدلیں + Resize panels پینلز کا حجم بدلیں + Distribution تقسیم + Uniform یکساں + Gaussian گاؤسی + Monochromatic مونوکرومیٹک + Seed سیڈ + Strength طاقت + Reduce Color Noise رنگ کا شور کم کریں + Remove Jpeg Artifact Jpeg آرٹیفیکٹ ہٹائیں + Sharpen Details تفصیلات تیز کریں + Reduce Noise شور کم کریں + Use Classic Mode کلاسک موڈ استعمال کریں + Auto Center Preview خودکار مرکز پیش منظر + Next Document اگلی دستاویز + Previous Document پچھلی دستاویز + W x H x Resolution چوڑائی x اونچائی x ریزولوشن + Front Image سامنے کی تصویر + Frame Tool فریم ٹول + Create a new rectangular frame نیا مستطیل فریم بنائیں + Create a new elliptical frame نیا بیضوی فریم بنائیں + Include in playback پلے بیک میں شامل کریں + Perspective Crop Tool تناظری کراپ ٹول + Show Grid گرڈ دکھائیں + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels ایک باکس گھسیٹیں یا چار کونوں پر کلک کریں · کونوں کو ترچھی شکل کے کناروں پر گھسیٹیں · {commit} نافذ کرتا ہے · Esc منسوخ کرتا ہے + Artboard Tool آرٹ بورڈ ٹول + Make Portrait عمودی بنائیں + Make Landscape افقی بنائیں + Add New Artboard نیا آرٹ بورڈ شامل کریں + Freeform Pen Tool فری فارم پین ٹول + Curvature Pen Tool کرویچر پین ٹول + Curve Fit: وکر فٹ: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close گھسیٹیں: آزادانہ ڈرائنگ کریں · {alt}-کلک: سیدھا حصہ · ابتدائی نقطے پر چھوڑیں: بند کریں + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish کلک: ہموار نقطہ · ڈبل کلک: کونا · نقطہ گھسیٹیں: منتقل کریں · پہلے نقطے پر کلک: بند کریں · {key} مکمل + New Crop Preset… نیا کراپ پری سیٹ… + Delete Crop Preset… کراپ پری سیٹ حذف کریں… + Select the crop presets to delete: حذف کرنے کے لیے کراپ پری سیٹس منتخب کریں: + Select the crop presets to delete حذف کرنے کے لیے کراپ پری سیٹس منتخب کریں + Enter a name for the crop preset کراپ پری سیٹ کے لیے ایک نام درج کریں + There is no crop size or ratio to save محفوظ کرنے کے لیے کوئی کراپ حجم یا تناسب نہیں + Scale: پیمانہ: + Snap to Origin اصل مقام پر اسنیپ کریں + Search settings… ترتیبات تلاش کریں… + No matching settings کوئی مماثل ترتیبات نہیں + Sampling Brush Tool سیمپلنگ برش ٹول + Sampling Area Overlay سیمپلنگ ایریا اوورلے + Show Sampling Area سیمپلنگ ایریا دکھائیں + Indicates ظاہر کرتا ہے + Sampling Area سیمپلنگ ایریا + Excluded Area خارج شدہ ایریا + Sampling Area Options سیمپلنگ ایریا کے اختیارات + Rectangular مستطیل + Fill Settings بھرائی کی ترتیبات + Rotation Adaptation گردش موافقت + Very High بہت زیادہ + Full مکمل + Mirror آئینہ + Output Settings آؤٹ پٹ ترتیبات + Output To آؤٹ پٹ تا + Animation Options اینیمیشن کے اختیارات + Entire Document مکمل دستاویز + Filter Options فلٹر کے اختیارات + Layer Bounds تہہ کی حدود + Layers Panel Options تہہ پینل کے اختیارات + Lock Layers تہیں مقفل کریں + New Artboard… نیا آرٹ بورڈ… + New Group… نیا گروپ… + New Layer… نئی تہہ… + Panel Options… پینل کے اختیارات… + Prevent Auto-Nesting خودکار نیسٹنگ روکیں + Show Layers تہیں دکھائیں + Thumbnail Contents تھمب نیل کا مواد + Thumbnail Size تھمب نیل کا حجم + Use Default Masks on Fill Layers بھرائی تہوں پر ڈیفالٹ ماسک استعمال کریں + Expand New Effects نئے اثرات پھیلائیں + Add “copy” to Copied Layers and Groups کاپی شدہ تہوں اور گروپس میں "کاپی" شامل کریں + Preview on canvas when reordering layers تہیں دوبارہ ترتیب دیتے وقت کینوس پر پیش منظر دکھائیں + Show layer mask badges تہہ ماسک بیجز دکھائیں + Blending Options ({filter}) امتزاج کے اختیارات ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) تہہ کے اثرات اور سمارٹ فلٹرز چھپائیں ({key}-کلک: تمام تہیں) + Show the layer's effects and Smart Filters ({key}-click: all layers) تہہ کے اثرات اور سمارٹ فلٹرز دکھائیں ({key}-کلک: تمام تہیں) + Wait for the current job to finish. موجودہ کام مکمل ہونے کا انتظار کریں۔ + The document or Smart Filter changed. Reopen its dialog. دستاویز یا سمارٹ فلٹر تبدیل ہو گیا۔ اس کا ڈائیلاگ دوبارہ کھولیں۔ + Opacity must be between 0% and 100%. دھندلاپن 0% اور 100% کے درمیان ہونا چاہیے۔ + Dissolve is approximated: it blends without Photoshop's dither. تحلیل کا تخمینہ لگایا گیا ہے: یہ فوٹوشاپ کے ڈیدر کے بغیر ملاتا ہے۔ + Loading fonts… فونٹس لوڈ ہو رہے ہیں… + Response curve ردعمل کا وکر + Tool ٹول + Simulate Pressure دباؤ کی تقلید کریں + Stroke Path… پاتھ پر لکیر بنائیں… + Background Eraser بیک گراؤنڈ اریزر + Healing Brush ہیلنگ برش + Spot Healing Brush اسپاٹ ہیلنگ برش + Smudge اسمج + Sponge اسپنج + Amount: مقدار: + Protect: محفوظ رکھیں: + Protect skin tones جلد کے رنگ محفوظ رکھیں + Edit points to modify the curve وکر تبدیل کرنے کے لیے نقطوں میں ترمیم کریں + Draw to modify the curve وکر تبدیل کرنے کے لیے ڈرائنگ کریں +curves Smooth ہموار + Smooth the curve values وکر کی قدریں ہموار کریں + Drag to draw the curve · {key}-click draws a straight line وکر کھینچنے کے لیے گھسیٹیں · {key}-کلک سیدھی لائن کھینچتا ہے + Edit Plane سطح میں ترمیم کریں + Create Plane سطح بنائیں + Grid Size: گرڈ حجم: + Vanishing Point: click four corners to create a plane وینشنگ پوائنٹ: سطح بنانے کے لیے چار کونوں پر کلک کریں + Vanishing Point is not active وینشنگ پوائنٹ فعال نہیں ہے + Apply Vanishing Point ({key}) وینشنگ پوائنٹ لاگو کریں ({key}) + Fix the red plane first: its corners must form a convex quad پہلے سرخ سطح ٹھیک کریں: اس کے کونے ایک محدب چوکور بنانا ضروری ہیں + Alt-click a plane to set the clone source کلون ماخذ مقرر کرنے کے لیے کسی سطح پر Alt-کلک کریں + Too many clone strokes: apply Vanishing Point first بہت زیادہ کلون اسٹروکس: پہلے وینشنگ پوائنٹ لاگو کریں + Onion Skin آنین اسکن + Marching Ants مارچنگ اینٹس + On Black سیاہ پر + On White سفید پر + On Layers تہوں پر + Show Edge کنارہ دکھائیں + Show Original اصل دکھائیں + F cycles the views; hold X to see the image F منظر تبدیل کرتا ہے؛ تصویر دیکھنے کے لیے X دبائے رکھیں + Refine Edge Brush (R) کنارہ بہتر کرنے والا برش (R) + Expand Detection Area تشخیص کا حصہ بڑھائیں + Restore Original Edge اصل کنارہ بحال کریں + Paint over hair or fur on the image; Alt paints the other mode تصویر میں بال یا رواں پر پینٹ کریں؛ Alt دوسرا موڈ پینٹ کرتا ہے + Clear Brush Strokes برش اسٹروکس صاف کریں + Show rulers رولرز دکھائیں + Presentation پریزنٹیشن + Click to place · Alt-click or drag off-canvas to remove رکھنے کے لیے کلک کریں · ہٹانے کے لیے Alt-کلک یا کینوس سے باہر گھسیٹیں + No document open کوئی دستاویز کھلی نہیں + {n} of {max} sample points {max} میں سے {n} نمونہ نقطے + New Snapshot… نیا اسنیپ شاٹ… + History Options… تاریخ کے اختیارات… + Allow Non-Linear History غیر لکیری تاریخ کی اجازت دیں + Make Layer Visibility Changes Undoable تہہ کی نمائش کی تبدیلیوں کو کالعدم کے قابل بنائیں + Emulsion Down ایملشن نیچے + Functions فنکشنز + Print Resolution: پرنٹ ریزولوشن: + Use Previous Layer to Create Clipping Mask کلپنگ ماسک بنانے کے لیے پچھلی تہہ استعمال کریں + Fill with {mode}-neutral color (white) {mode}-نیوٹرل رنگ (سفید) سے بھریں + Fill with {mode}-neutral color (black) {mode}-نیوٹرل رنگ (سیاہ) سے بھریں + Fill with {mode}-neutral color (50% gray) {mode}-نیوٹرل رنگ (50% سرمئی) سے بھریں + (No neutral color exists for {mode} mode.) ({mode} موڈ کے لیے کوئی نیوٹرل رنگ موجود نہیں۔) + Apply mask to layer before removing? ہٹانے سے پہلے ماسک تہہ پر لاگو کریں؟ + The layer or its mask changed. Drag the mask onto the Trash again. تہہ یا اس کا ماسک تبدیل ہو گیا۔ ماسک کو دوبارہ ٹریش پر گھسیٹیں۔ + Delete the vector mask? ویکٹر ماسک حذف کریں؟ +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {file} میں {n} نمونہ برآمد ہوا|{file} میں {n} نمونے برآمد ہوئے +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} سے {n} برش درآمد ہوا|{group} سے {n} برشز درآمد ہوئے +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} سے {n} نمونہ درآمد ہوا|{file} سے {n} نمونے درآمد ہوئے +@plural Played {n} step|Played {n} steps {n} قدم چلایا گیا|{n} قدم چلائے گئے +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk ڈسک پر تبدیل شدہ {n} لنک شدہ سمارٹ آبجیکٹ اپ ڈیٹ ہوا|ڈسک پر تبدیل شدہ {n} لنک شدہ سمارٹ آبجیکٹس اپ ڈیٹ ہوئے +@plural Saved for Web: {n} file|Saved for Web: {n} files ویب کے لیے محفوظ: {n} فائل|ویب کے لیے محفوظ: {n} فائلیں +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. اس کمپوزیشن سے ریکارڈ شدہ {n} تہہ حذف ہو گئی۔ کمپوزیشن اپ ڈیٹ کریں، یا تنبیہ صاف کریں۔|اس کمپوزیشن سے ریکارڈ شدہ {n} تہیں حذف ہو گئیں۔ کمپوزیشن اپ ڈیٹ کریں، یا تنبیہ صاف کریں۔ +@plural {n} plane|{n} planes {n} سطح|{n} سطحیں +@plural {n} preset|{n} presets {n} پری سیٹ|{n} پری سیٹس +@plural {n} sample point removed|{n} sample points removed {n} نمونہ نقطہ ہٹایا گیا|{n} نمونہ نقطے ہٹائے گئے +@plural {n} slice|{n} slices {n} سلائس|{n} سلائسز +@plural {n} step|{n} steps {n} قدم|{n} قدم +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} مزید برش پری سیٹ تنبیہ)|{warning} (+{n} مزید برش پری سیٹ تنبیہات) +@plural {what} with {n} warning|{what} with {n} warnings {what} میں {n} تنبیہ|{what} میں {n} تنبیہات + Light Blue ہلکا نیلا + Light Red ہلکا سرخ + Medium Blue درمیانہ نیلا + Light Gray ہلکا سرمئی + Object Finder آبجیکٹ فائنڈر + No object found there — drag a rectangle around it وہاں کوئی شے نہیں ملی — اس کے گرد ایک مستطیل گھسیٹیں + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels فعال تہہ میں یہاں کوئی پکسل نہیں — تمام تہیں نمونہ لیں فعال کریں یا پکسلز والی تہہ منتخب کریں + Remove after each stroke ہر اسٹروک کے بعد ہٹائیں + Apply the removal ({key}) ہٹانا لاگو کریں ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) ہٹانے کے لیے اسٹروکس: {n} (Enter لاگو کرتا ہے، Esc منسوخ کرتا ہے) + Load Contours… کونٹورز لوڈ کریں… + Replace Contours… کونٹورز تبدیل کریں… + Save Contours… کونٹورز محفوظ کریں… + Delete Contour کونٹور حذف کریں + Loaded contours from {file} {file} سے کونٹورز لوڈ ہو گئے + Saved contours to {file} کونٹورز {file} میں محفوظ ہو گئے +@id select.all سب منتخب کریں + Delete Smart Filters سمارٹ فلٹرز حذف کریں + Enable Smart Filters سمارٹ فلٹرز فعال کریں + Delete Layer… تہہ حذف کریں… + Select Similar Colors ملتے جلتے رنگ منتخب کریں + Canvas rotation کینوس کی گردش + Extensions ایکسٹینشنز + Libraries لائبریریاں + 3D 3D + Toolbars ٹول بارز + Wand وانڈ + Healing ہیلنگ + Pen پین + Hand ہینڈ + Sampler سیمپلر + Magnetic Lasso میگنیٹک لیسو + Art History Brush آرٹ ہسٹری برش + Spot Healing اسپاٹ ہیلنگ + Patch پیچ + Content-Aware Move مواد سے آگاہ موو + Perspective Crop تناظری کراپ + Dodge/Burn ڈاج/برن + Dynamics حرکیات + Tilt جھکاؤ + Defaults restored. ڈیفالٹس بحال ہو گئیں۔ + Stop روکیں + Create new set نیا سیٹ بنائیں + Technology Previews ٹیکنالوجی پیش منظر + Create new layer نئی تہہ بنائیں + Group · {n} layer گروپ · {n} تہہ + General Blend عمومی امتزاج + Advanced Blending ایڈوانسڈ امتزاج + RGB RGB + Spot Channel اسپاٹ چینل + Working Gray Plate ورکنگ گرے پلیٹ + HSL/Grayscale HSL/گرے اسکیل + Tone Curve ٹون وکر + Split Toning اسپلٹ ٹوننگ + Lens Corrections لینز تصحیحات + Camera Calibration کیمرہ کیلیبریشن + Snapshots اسنیپ شاٹس + Recovery بحالی + Fill Light فل لائٹ + Convert to Grayscale گرے اسکیل میں تبدیل کریں + Sharpness شارپنیس + Color Noise Reduction رنگ شور میں کمی + Luminance Smoothness چمک کی ہمواری + Lens لینز + ISO ISO + Focal Length35mm فوکل لمبائی 35mm + Aspect Ratio تناسب + Time وقت + Enter description… وضاحت درج کریں… + ColorSync ColorSync + ProPhoto ProPhoto + ACES ACES + Industry Standard صنعتی معیار + Uncoated CMYK ان کوٹڈ CMYK + Newspaper نیوز پیپر + Working Spaces ورکنگ اسپیسز + Color Management Policies رنگ انتظام کی پالیسیاں + Detect Faces چہرے تلاش کریں + Localize Color Clusters رنگ کے جھرمٹ مقامی کریں + Duplicated selection name نقل شدہ انتخاب کا نام + Operation عمل + New channel only صرف نیا چینل + Color indicates رنگ ظاہر کرتا ہے + Create Snapshot اسنیپ شاٹ بنائیں + Delete Snapshot اسنیپ شاٹ حذف کریں + New Snapshot نیا اسنیپ شاٹ + Snapshot 1 اسنیپ شاٹ 1 + Step Forward آگے قدم + Step Backward پیچھے قدم + Nonlinear History غیر لکیری تاریخ + Set New Snapshot as Default نیا اسنیپ شاٹ ڈیفالٹ کے طور پر مقرر کریں + Automatically Create First Snapshot خودکار طور پر پہلا اسنیپ شاٹ بنائیں + Automatically Create New Snapshot When Saving محفوظ کرتے وقت خودکار طور پر نیا اسنیپ شاٹ بنائیں + Show New Snapshot Dialog by Default ڈیفالٹ کے طور پر نیا اسنیپ شاٹ ڈائیلاگ دکھائیں + Clear History تاریخ صاف کریں + View menu منظر مینیو + Keep view in sync منظر ہم آہنگ رکھیں + {zoom}% {zoom}% + Mouse Controls ماؤس کنٹرولز + Initial Direction of Stroke اسٹروک کی ابتدائی سمت + Direction of Stroke اسٹروک کی سمت + Humidity نمی + Auto Clean Brush برش خودکار صاف کریں + Spacing: وقفہ: + New Layer from Brush برش سے نئی تہہ + New Brush نیا برش + New Tool Preset نیا ٹول پری سیٹ + Auto-Leading خودکار لیڈنگ + Scale Horizontally افقی طور پر پیمانہ کریں + Scale Vertically عمودی طور پر پیمانہ کریں + Mac LCD Mac LCD + Mac Sharp Mac تیز + Mac Crisp Mac صاف + Mac Strong Mac مضبوط + Mac Smooth Mac ملائم + Superscript بالائی اسکرپٹ + Subscript زیریں اسکرپٹ + Ligature لگیچر + Alternative متبادل + Ultra Light الٹرا لائٹ + Extra Light ایکسٹرا لائٹ + Semi Bold سیمی بولڈ + Extra Bold ایکسٹرا بولڈ + Ultra Bold الٹرا بولڈ + Condensed کنڈینسڈ + First Line Indent پہلی لائن حاشیہ + Hanging Punctuation ہینگنگ رموز اوقاف + Roman Hanging Punctuation رومن ہینگنگ رموز اوقاف + Avoid Widows/Orphans ودوز/اورفنز سے گریز کریں + Compose کمپوز کریں + Adobe Single-line Composer Adobe سنگل لائن کمپوزر + Adobe Every-line Composer Adobe ایوری لائن کمپوزر + Word Spacing لفظ کا وقفہ + Letter Spacing حرف کا وقفہ + Glyph Scaling گلف اسکیلنگ + Auto-leading % خودکار لیڈنگ % + Single Word Justification سنگل لفظ جسٹیفیکیشن + Justify جسٹیفائی + Glyph گلف + Alternates for Current Selection موجودہ انتخاب کے متبادل + Digits ہندسے + Path: پاتھ: + Path to Illustrator… پاتھ الیسٹریٹر کو… + Clipping Path کلپنگ پاتھ + New Path نیا پاتھ + Duplicate Path پاتھ کی نقل بنائیں + Rename Path… پاتھ کا نام تبدیل کریں… + Auto-Hide خودکار چھپائیں + X X + Y Y + Frame Sync فریم سنک + Quick Sync فوری سنک + Audio آڈیو + Frame Duration فریم کا دورانیہ + Loop لوپ + Split at Playhead پلے ہیڈ پر تقسیم کریں + Flatten Frames Into Clips فریمز کو کلپس میں چپٹا کریں + Flatten Frames Into Layers فریمز کو تہوں میں چپٹا کریں + Convert Frames > Video Layers فریمز > ویڈیو تہوں میں تبدیل کریں + New Video Layer نئی ویڈیو تہہ + New Video Group نیا ویڈیو گروپ + Limit Playback پلے بیک محدود کریں + Enable Timeline Quick Config ٹائم لائن فوری ترتیب فعال کریں + Set Start Timecode… آغاز ٹائم کوڈ مقرر کریں… + Timecode ٹائم کوڈ + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time موجودہ وقت + Out باہر + In اندر + Duration دورانیہ + Repeat دہرائیں + Play Once ایک بار چلائیں + Info Options… معلومات کے اختیارات… + First Color Readout پہلا رنگ ریڈ آؤٹ + Second Color Readout دوسرا رنگ ریڈ آؤٹ + Actual Color حقیقی رنگ + Grayscale Slider گرے اسکیل سلائیڈر + Web Color ویب رنگ + HSB Color HSB رنگ + Total Ink کل سیاہی + Opacity Mask دھندلاپن ماسک + Document Size دستاویز کا حجم + Timing وقت بندی + Status Information حالت کی معلومات + Saved Multichannel محفوظ شدہ ملٹی چینل + Mouse Coordinates ماؤس کوآرڈینیٹس + Ruler Units رولر اکائیاں + Hide چھپائیں + Restore last viewed comp آخری دیکھی گئی کمپوزیشن بحال کریں + Select All Layers تمام تہیں منتخب کریں + Keep proportions تناسب برقرار رکھیں + Interpolation انٹرپولیشن + Caption کیپشن + Caption Credits کیپشن کریڈٹس + Keywords کلیدی الفاظ + Copyright Info URL کاپی رائٹ معلومات URL + Copyright Status کاپی رائٹ کی حالت + Copyright Notice کاپی رائٹ نوٹس + Document Title دستاویز کا عنوان + Copyrighted کاپی رائٹ شدہ + Public Domain عوامی ڈومین + Unknown نامعلوم + Credit کریڈٹ + Transmission Reference ٹرانسمیشن حوالہ + Urgency عجلت + High زیادہ + Low کم + Category زمرہ + Supplemental Categories ضمنی زمرے + Object Name / Title شے کا نام / عنوان + Date Created تاریخ تخلیق + City شہر + Province / State صوبہ / ریاست + Country ملک + Byline بائی لائن + Byline Title بائی لائن کا عنوان + Phone فون + Email ای میل + URLs URLs + Last Modified آخری ترمیم + EXIF EXIF + GPS GPS + Camera Model کیمرہ ماڈل + Camera Make کیمرہ میک + EXIF Version EXIF ورژن + Exposure Program نمائش پروگرام + Aperture اپرچر + Shutter Speed شٹر اسپیڈ + Metering Mode میٹرنگ موڈ + Light Source روشنی کا ماخذ + Flash فلیش + Lens Info لینز کی معلومات + ISO Speed ISO اسپیڈ + Subject Distance موضوع کا فاصلہ + DateTime Original اصل تاریخ وقت + Create Date تاریخ تخلیق + Modify Date تاریخ ترمیم + Raw Data را ڈیٹا + Import… درآمد کریں… + PDF Presentation… PDF پریزنٹیشن… + Create Multiple Picture Packages متعدد پکچر پیکجز بنائیں + Picture Package… پکچر پیکج… + Web Photo Gallery… ویب فوٹو گیلری… + Choose… چنیں… + Select Folder… فولڈر منتخب کریں… + Override Action "Save" Commands ایکشن "محفوظ کریں" کمانڈز اووررائیڈ کریں + File Naming فائل کا نام + Compatibility مطابقت + Mac OS Mac OS + Unix Unix + Include ICC Profile ICC پروفائل شامل کریں + Error File خرابی کی فائل + Stop For Errors خرابیوں پر روکیں + Log Errors To File خرابیاں فائل میں لاگ کریں + Errors خرابیاں + Settings ترتیبات + Settings: ترتیبات: + Working Space ورکنگ اسپیس + RGB: RGB: + CMYK: CMYK: + Gray: گرے: + Spot: اسپاٹ: + Advanced Controls ایڈوانسڈ کنٹرولز + Desaturate Monitor Colors By مانیٹر رنگوں کو بے رنگ کریں بذریعہ + Blend RGB Colors Using Gamma 1.0 گاما 1.0 استعمال کرتے ہوئے RGB رنگ ملائیں + Profile Mismatches: Ask When Opening پروفائل عدم مطابقت: کھولتے وقت پوچھیں + Ask When Pasting چسپاں کرتے وقت پوچھیں + Missing Profiles: Ask When Opening غائب پروفائلز: کھولتے وقت پوچھیں + Embed profiles پروفائلز ایمبیڈ کریں + Ask Before Saving Saved Embedded ایمبیڈڈ محفوظ کرنے سے پہلے پوچھیں + Coerce to sRGB sRGB میں تبدیل کریں + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Minimalistic مینیملسٹک + Expand All Groups تمام گروپس پھیلائیں + Photo (2 × 3 in) فوٹو (2 × 3 انچ) + Photo (4 × 6 in) فوٹو (4 × 6 انچ) + Photo (5 × 7 in) فوٹو (5 × 7 انچ) + Photo (8 × 10 in) فوٹو (8 × 10 انچ) + Recent Items حالیہ اشیاء + Delete Preset… پری سیٹ حذف کریں… + Preset Name: پری سیٹ کا نام: + Width and Height چوڑائی اور اونچائی + Color Profile رنگ پروفائل + Units اکائیاں + Create new count group نیا شمار گروپ بنائیں + Adjustment Layer ایڈجسٹمنٹ تہہ + Drag to move the layer تہہ منتقل کرنے کے لیے گھسیٹیں + Go to Layer تہہ پر جائیں + After (X) بعد میں (X) + Reference point location حوالہ نقطے کا مقام + Toggle reference point حوالہ نقطہ تبدیل کریں + Add Pins پنز شامل کریں + Delete Pins پنز حذف کریں + Rotate Auto خودکار گھمائیں + Rotate Fixed مقررہ گھمائیں + Select Plane سطح منتخب کریں + Measure پیمائش کریں + Heal ہیل کریں + Heal Off ہیل بند + Heal On ہیل آن + Heal Auto ہیل خودکار + Blend امتزاج + Brush Size برش کا حجم + Heal Color ہیل رنگ + Result نتیجہ + Duplicate نقل بنائیں + Fast تیز + Accurate درست + Add to میں شامل کریں + Subtract from سے منہا کریں + Intersect with کے ساتھ جوڑیں + Capture Comp کمپوزیشن قبضہ کریں + No Layer Comps کوئی تہہ کمپوزیشن نہیں + Adobe PDF Presets… Adobe PDF پری سیٹس… + 3D… 3D… + Technology Previews… ٹیکنالوجی پیش منظر… + Add a mask (from the selection; ⌥ inverts) ماسک شامل کریں (انتخاب سے؛ ⌥ الٹ دیتا ہے) + Add to selection (⇧) انتخاب میں شامل کریں (⇧) + Airbrush: keeps painting while the pointer rests. ایئر برش: پوائنٹر رکنے کے دوران پینٹنگ جاری رکھتا ہے۔ + Click the first point or press ↵ to close · Esc cancels پہلے نقطے پر کلک کریں یا بند کرنے کے لیے ↵ دبائیں · Esc منسوخ کرتا ہے + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove نقطہ شامل کرنے کے لیے کلک کریں · ہٹانے کے لیے باہر گھسیٹیں یا ⌘/Ctrl-کلک یا Delete + Click to apply, ⌥-click to clear overrides, double-click for Style Options لاگو کرنے کے لیے کلک کریں، اووررائیڈز صاف کرنے کے لیے ⌥-کلک، اسٹائل اختیارات کے لیے ڈبل کلک + Click to zoom in · ⌥-click to zoom out بڑا کرنے کے لیے کلک کریں · چھوٹا کرنے کے لیے ⌥-کلک + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel کلک: کونا · گھسیٹنا: ہموار · پہلے نقطے پر کلک: بند کریں · ↩ مکمل · Esc منسوخ + Commit Perspective Warp (↩) تناظری وارپ نافذ کریں (↩) + Commit Puppet Warp (↩) پپٹ وارپ نافذ کریں (↩) + Commit any current edits (⌘↩) کوئی بھی موجودہ ترمیم نافذ کریں (⌘↩) + Commit current crop operation (↵) موجودہ تراش کا عمل نافذ کریں (↵) + Commit transform (↩) ہیئت تبدیلی نافذ کریں (↩) + Commit warp (↩) موڑ نافذ کریں (↩) + Create a new layer (⇧⌘N) نئی تہہ بنائیں (⇧⌘N) + Custom pattern مخصوص نمونہ + General Brushes عمومی برشز + Grid (⌘') گرڈ (⌘') + Intersect with selection (⇧⌥) انتخاب کے ساتھ جوڑیں (⇧⌥) + New document… ⌘N نئی دستاویز… ⌘N + Open… ⌘O کھولیں… ⌘O + Rulers (⌘R) رولرز (⌘R) + Search commands (⌘K) کمانڈز تلاش کریں (⌘K) + Show Guides (⌘;) رہنما لکیریں دکھائیں (⌘;) + Subtract from selection (⌥) انتخاب سے منہا کریں (⌥) + Uses the foreground and background colors. پیش منظر اور پس منظر رنگ استعمال کرتا ہے۔ + ↑↓ navigate ↵ run esc close ↑↓ نیویگیٹ کریں ↵ چلائیں esc بند کریں + Adds grain to the soft edges of the brush tip. برش ٹپ کے نرم کناروں پر دانا شامل کرتا ہے۔ + Keeps the current texture when switching presets. پری سیٹس تبدیل کرتے وقت موجودہ ساخت برقرار رکھتا ہے۔ + Watercolour look: lighter interior, darker rims. واٹر کلر شکل: ہلکا اندرونی حصہ، گہرے کنارے۔ + Collapse group (⌥-click: all groups) گروپ سمیٹیں (⌥-کلک: تمام گروپس) + Expand group (⌥-click: all groups) گروپ پھیلائیں (⌥-کلک: تمام گروپس) + Hide the layer's effects (⌥-click: all layers) تہہ کے اثرات چھپائیں (⌥-کلک: تمام تہیں) + Show the layer's effects (⌥-click: all layers) تہہ کے اثرات دکھائیں (⌥-کلک: تمام تہیں) +cameraRaw Blacks سیاہیاں +cameraRaw Masking ماسکنگ +@id edit.stroke لکیر… +@id edit.transform.scale پیمانہ +cameraRaw Luminance لومینینس +cameraRaw Color رنگ +cameraRaw Vibrance رونق +cameraRaw Color Grading رنگ گریڈنگ +cameraRaw Global عالمی +cameraRaw Hue رنگت +cameraRaw Luminance Detail لومینینس تفصیل +cameraRaw Color Detail رنگ کی تفصیل +cameraRaw Roughness کھردرا پن +cameraRaw Whites سفیدیاں +layerLabel No Color بلا رنگ + Preferences… ترجیحات… + Actual Pixels اصل پکسلز + Clipping Mask کلپنگ ماسک + Tool Options ٹول کے اختیارات + Color Swatches رنگ کے نمونے + Linear Dodge لکیری ڈاج + Preview Image پیش منظر تصویر + Save As بطور محفوظ کریں + Remove All سب ہٹائیں + Unlock غیر مقفل کریں + Enable فعال کریں + Disable غیر فعال کریں + On آن + Clear Selection انتخاب صاف کریں + Invert Selection انتخاب الٹائیں + Select Inverse انتخاب الٹ منتخب کریں + Contract تنگ کریں + Sample Size نمونہ حجم + Gamma گاما + Input ان پٹ + Input Levels ان پٹ لیولز + Output Levels آؤٹ پٹ لیولز + Layer Styles تہہ اسٹائلز + Blending Mode امتزاج موڈ + Stroke Width لکیر کی چوڑائی + Shadow سایہ + Adjustment Layers ایڈجسٹمنٹ تہیں + Text Layer متن تہہ + Documents دستاویزات + File Name فائل کا نام + File Format فائل فارمیٹ + Compression کمپریشن + Artboards آرٹ بورڈز + Snap to Grid گرڈ پر اسنیپ کریں + Full Screen فل اسکرین + Fullscreen فل اسکرین + Workspaces ورک اسپیسز + Show All سب دکھائیں + Hidden چھپا ہوا + Duplicate Layer تہہ کی نقل بنائیں + Ungroup گروپ ختم کریں + Guide Layout… رہنما خاکہ… + Page Setup… صفحہ سیٹ اپ… + Reset Preferences ترجیحات ری سیٹ کریں + Documentation دستاویزات + Check for Updates اپ ڈیٹس چیک کریں + Loading… لوڈ ہو رہا ہے… + Processing… پروسیسنگ ہو رہی ہے… + Saving… محفوظ ہو رہا ہے… + Exporting… برآمد ہو رہا ہے… + Please wait… براہ کرم انتظار کریں… + No selection کوئی انتخاب نہیں + No layers کوئی تہیں نہیں + No recent files کوئی حالیہ فائلیں نہیں + Unsupported file format غیر سپورٹڈ فائل فارمیٹ + Failed to open file فائل کھولنا ناکام ہوا + Failed to save file فائل محفوظ کرنا ناکام ہوا + Operation canceled عمل منسوخ ہوا + Changes have not been saved. تبدیلیاں محفوظ نہیں ہوئیں۔ + Are you sure? کیا آپ کو یقین ہے؟ + Discard Changes تبدیلیاں ضائع کریں + Open File فائل کھولیں + Save File فائل محفوظ کریں + Save Changes تبدیلیاں محفوظ کریں + Rendering… رینڈر ہو رہا ہے… + Importing… درآمد ہو رہا ہے… + Ready تیار + Failed ناکام + Warning تنبیہ + Information معلومات + Retry دوبارہ کوشش کریں + Continue جاری رکھیں + Skip چھوڑیں + Resume دوبارہ شروع کریں + Reset All سب ری سیٹ کریں + Defaults ڈیفالٹس + Apply to All سب پر لاگو کریں + Confirm تصدیق کریں + Finish مکمل کریں + Back پیچھے + Next اگلا + Previous پچھلا + More مزید + Less کم + Manual دستی + Disabled غیر فعال + Locked مقفل + Unlocked غیر مقفل + Delete… حذف کریں… + Create… بنائیں… + Edit… ترمیم کریں… + Change… تبدیل کریں… + Transformation ہیئت تبدیلی + Scaling پیمانہ کاری + Canvas Size کینوس کا حجم + Percentage فیصد + Degrees درجے + Middle درمیان + Diameter قطر + Intensity شدت + Sensitivity حساسیت + Softness نرمی + Feather Radius فیدر ریڈیس + Library لائبریری + Layer Properties تہہ کی خصوصیات + Groups گروپس + Masks ماسکس + Add Mask ماسک شامل کریں + Delete Mask ماسک حذف کریں + Invert Mask ماسک الٹائیں + Enable Mask ماسک فعال کریں + Disable Mask ماسک غیر فعال کریں + Apply Mask ماسک لاگو کریں + Import Settings ترتیبات درآمد کریں + Export Settings ترتیبات برآمد کریں + Shortcuts شارٹ کٹس + Keyboard کی بورڈ + Mouse ماؤس + Tablet ٹیبلٹ + Display ڈسپلے + Panel پینل + Hide Grid گرڈ چھپائیں + Fit to Window ونڈو پر فٹ کریں + Live Preview لائیو پیش منظر + Before پہلے + After بعد + Compare موازنہ + File Size فائل کا حجم + Filename فائل کا نام + Open Folder فولڈر کھولیں + Choose Folder فولڈر چنیں + Browse براؤز کریں + Find تلاش کریں + Replace تبدیل کریں + Date تاریخ + Learn More مزید جانیں + Report a Bug بگ رپورٹ کریں + Updates اپ ڈیٹس + Update اپ ڈیٹ + Restart Required دوبارہ آغاز درکار ہے + Restart دوبارہ آغاز کریں + Quit چھوڑیں + Quit PhotoCraft PhotoCraft چھوڑیں + Close Window ونڈو بند کریں + New Window نئی ونڈو + Open Image تصویر کھولیں + Save Image تصویر محفوظ کریں + Export Image تصویر برآمد کریں + Import Image تصویر درآمد کریں + Export As بطور برآمد کریں + Export to برآمد کریں تا + Export Options برآمدی اختیارات + Import Options درآمدی اختیارات + No Selection کوئی انتخاب نہیں + No Image کوئی تصویر نہیں + No Layers کوئی تہیں نہیں + No Results کوئی نتائج نہیں + No Results Found کوئی نتائج نہیں ملے + Nothing to Undo کالعدم کرنے کے لیے کچھ نہیں + Nothing to Redo دوبارہ کرنے کے لیے کچھ نہیں + Changes Saved تبدیلیاں محفوظ ہو گئیں + Save before closing? بند کرنے سے پہلے محفوظ کریں؟ + Are you sure you want to quit? کیا آپ واقعی چھوڑنا چاہتے ہیں؟ + The file could not be opened. فائل کھلی نہیں جا سکی۔ + The file could not be saved. فائل محفوظ نہیں ہو سکی۔ + Unsupported format غیر سپورٹڈ فارمیٹ + Out of memory میموری ختم + Permission denied اجازت مسترد + File not found فائل نہیں ملی + Operation failed عمل ناکام ہوا + Operation completed عمل مکمل ہوا + Operation cancelled عمل منسوخ ہوا + Sharpen Amount شارپن کی مقدار + Sharpen Radius شارپن ریڈیس + Sharpen Detail شارپن تفصیل + Sharpen Masking شارپن ماسکنگ + Noise Luminance شور لومینینس + Noise Luminance Detail شور لومینینس تفصیل + Noise Color شور رنگ + Noise Color Detail شور رنگ تفصیل + Grain Amount دانے کی مقدار + Grain Size دانے کا حجم + Grain Roughness دانے کا کھردرا پن + Vignette Roundness ویگنیٹ گولائی + Vignette Feather ویگنیٹ فیدر + Vignette Highlights ویگنیٹ نمایاں حصے + Vignette Style ویگنیٹ اسٹائل + Planes سطحیں + Plane سطح + Shadow Amount سائے کی مقدار + Shadow Tone سائے کا ٹون + Shadow Radius سائے کا ریڈیس + Highlight Amount نمایاں حصے کی مقدار + Highlight Tone نمایاں حصے کا ٹون + Highlight Radius نمایاں حصے کا ریڈیس + Midtone درمیانی ٹون + Black Clip بلیک کلپ + White Clip وائٹ کلپ + Fuzziness مبہم پن + Source Layer ماخذ تہہ + Neutralize بے اثر کریں + Use Selection In Source ماخذ میں انتخاب استعمال کریں + Use Selection In Target ہدف میں انتخاب استعمال کریں + Flame Type شعلے کی قسم + Randomize Length بے ترتیب لمبائی + Interval وقفہ + Adjust Interval For Loops لوپس کے لیے وقفہ ایڈجسٹ کریں + Use Custom Color مخصوص رنگ استعمال کریں + Flame Lines شعلے کی لائنیں + Flame Bottom Alignment شعلے کی نیچے کی ترتیب + Flame Style شعلے کا اسٹائل + Flame Shape شعلے کی شکل + Randomize Shapes شکلیں بے ترتیب کریں + Frame فریم + Arrangement ترتیب + Thickness موٹائی + Vine Color بیل کا رنگ + Flower Color پھول کا رنگ + Leaf Color پتے کا رنگ + Base Tree Type بنیادی درخت کی قسم + Light Direction روشنی کی سمت + Leaves Amount پتوں کی مقدار + Leaves Size پتوں کا حجم + Branches Height شاخوں کی اونچائی + Branches Thickness شاخوں کی موٹائی + Default Leaves ڈیفالٹ پتے + Leaves Color پتوں کا رنگ + Custom Branch Color مخصوص شاخ کا رنگ + Branches Color شاخوں کا رنگ + Flat Shading فلیٹ شیڈنگ + Date and Time تاریخ اور وقت + Scale Units پیمانے کی اکائیاں + Scale Factor پیمانے کا فیکٹر + Area رقبہ + Perimeter فریم کا احاطہ + Circularity دائرہ پن + Gray Value (Minimum) گرے قدر (کم از کم) + Gray Value (Maximum) گرے قدر (زیادہ سے زیادہ) + Gray Value (Mean) گرے قدر (اوسط) + Gray Value (Median) گرے قدر (درمیانی) + Integrated Density مجموعی کثافت + Length لمبائی + In Black اندر سیاہ + In White اندر سفید + Out Black باہر سیاہ + Out White باہر سفید + Tint Color ٹنٹ رنگ + Stops اسٹاپس + Center X مرکز X + Center Y مرکز Y + Cell Size سیل کا حجم + Generators جنریٹرز + Wavelength Min ویو لینتھ کم از کم + Wavelength Max ویو لینتھ زیادہ سے زیادہ + Amplitude Min ایمپلیٹیوڈ کم از کم + Amplitude Max ایمپلیٹیوڈ زیادہ سے زیادہ + Undefined Areas غیر متعین حصے + Preserve محفوظ رکھیں + Max Radius زیادہ سے زیادہ ریڈیس + Depth Mode گہرائی موڈ + Solid Front Faces ٹھوس سامنے کے چہرے + Mask Incomplete Blocks نامکمل بلاکس ماسک کریں + Stylization اسٹائلائزیشن + Cleanliness صفائی + Bristle Detail برسل کی تفصیل + Lighting روشنی + Shine چمک + Max Offset زیادہ سے زیادہ آفسیٹ + Map Document نقشہ دستاویز + Map Path نقشہ پاتھ + Map Layer نقشہ تہہ + Ridges دھاریاں + Light Type روشنی کی قسم + Light X روشنی X + Light Y روشنی Y + Light Z روشنی Z + Target X ہدف X + Target Y ہدف Y + Hotspot ہاٹ اسپاٹ + Elevation بلندی + Ambient ایمبیئنٹ + Warmth گرمائش + Gloss چمک + Metallic میٹالک + Ambience ماحول + White Is High سفید بلند ہے + Blade Curvature بلیڈ کا خم + Depth Map گہرائی کا نقشہ + Focal Distance فوکل فاصلہ + Focus فوکس + Transition منتقلی + Radius X ریڈیس X + Radius Y ریڈیس Y + Pins پنز + Speed رفتار + Taper ٹیپر + Start X آغاز X + Start Y آغاز Y + End X اختتام X + End Y اختتام Y + Kernel کرنل + Input Mode ان پٹ موڈ + Row Order قطار کی ترتیب + Eliminate ختم کریں + Create By اس کے ذریعے بنائیں + List فہرست + Pencil Width پنسل کی چوڑائی + Stroke Pressure اسٹروک کا دباؤ + Paper Brightness کاغذ کی چمک + Number Of Levels سطحوں کی تعداد + Edge Simplicity کنارے کی سادگی + Edge Fidelity کنارے کی وفاداری + Brush Detail برش کی تفصیل + Highlight Area نمایاں حصے کا رقبہ + Glow Size چمک کا حجم + Glow Brightness چمک کی روشنی + Brush Type برش کی قسم + Stroke Size اسٹروک کا حجم + Stroke Detail اسٹروک کی تفصیل + Highlight Strength نمایاں حصے کی طاقت + Smoothness ہمواری + Edge Thickness کنارے کی موٹائی + Edge Intensity کنارے کی شدت + Posterization پوسٹرائزیشن + Stroke Length اسٹروک کی لمبائی + Relief ریلیف + Definition وضاحت + Texture Coverage ساخت کا احاطہ + Shadow Intensity سائے کی شدت + Edge Width کنارے کی چوڑائی + Edge Brightness کنارے کی روشنی + Direction Balance سمت کا توازن + Black Intensity سیاہ کی شدت + White Intensity سفید کی شدت + Dark Intensity تاریک کی شدت + Custom Pixel Aspect Ratio مخصوص پکسل تناسب + 32-bit Preview Options 32-بٹ پیش منظر کے اختیارات + New Guide نئی رہنما لکیر + Join the ArtCraft Discord ArtCraft ڈسکارڈ میں شامل ہوں + Report an Issue مسئلہ رپورٹ کریں + Load Files into Stack اسٹیک میں فائلیں لوڈ کریں + Statistics اعداد و شمار + Duplicate Layers تہیں کی نقل بنائیں + Edit Toolbar ٹول بار میں ترمیم کریں + Import Brushes برشز درآمد کریں + Load 3D LUT 3D LUT لوڈ کریں + Press keys کیز دبائیں + Rename Group گروپ کا نام تبدیل کریں + Rename Tool Preset ٹول پری سیٹ کا نام تبدیل کریں + Search commands, tools and panels کمانڈز، ٹولز اور پینلز تلاش کریں + Slice Options سلائس اختیارات + Rename Brush برش کا نام تبدیل کریں + Opening کھل رہا ہے + Reading پڑھا جا رہا ہے + Working کام جاری ہے + Accented Edges نمایاں کنارے + Angled Strokes ترچھی لکیریں + Anisotropic اینیسوٹروپک + Around Center مرکز کے گرد + Artistic فنکارانہ + Bamboo بانس + Bas Relief باس ریلیف + Berries بیریاں + Blast دھماکہ + Bleach Bypass بلیچ بائی پاس + Blocks بلاکس + Brick اینٹ + Brush Strokes برش کی لکیریں + Burlap برلیپ + Candle Light شمع کی روشنی + Circle دائرہ + Clumped اکٹھا شدہ + Coarse Dots موٹے نقطے + Colored Pencil رنگین پنسل + Contrasty تضاد والا + Cool ٹھنڈا + Craquelure کریکیلر + Cross کراس + Cross Process کراس پراسیس + Crosshatch کراس ہیچ + Cutout کٹ آؤٹ + Daisies ڈیزیز + Dark Rough گہرا کھردرا + Dark Strokes گہری لکیریں + Darken Only صرف گہرا کریں + Day For Night دن کو رات کے طور پر + Deep Blue گہرا نیلا + Deep Emerald گہرا زمردی + Deep Red گہرا سرخ + Deep Yellow گہرا پیلا + Dot نقطہ + Double Line دوہری لائن + Draft ڈرافٹ + Dry Brush خشک برش + Duplication نقل + Edge Extension کنارے کی توسیع + Edge Only صرف کنارہ + Enlarged بڑا کیا گیا + Even Fields یکساں فیلڈز + Faded Film مدھم فلم + Film Grain فلم کا دانا + Fine باریک + Fine Dots باریک نقطے + Flat فلیٹ + Fresco فریسکو + From Left بائیں سے + From Right دائیں سے + Frosted برفیلا + Generic عمومی + Glass شیشہ + Glowing Edges چمکتے کنارے + Grainy Dots دانے دار نقطے + Hearts دل + Heptagon ہیپٹاگون + Hexagon ہیکساگون + Horizontal Only صرف افقی + Infinite لامحدود + Ink Outlines سیاہی کے خاکے + Ivy آیوی + Left Diagonal بائیں ترچھا + Level Based سطح پر مبنی + Light Rough ہلکا کھردرا + Lighten Only صرف ہلکا کریں + Long Lines لمبی لائنیں + Long Strokes لمبی لکیریں + Lower نیچے + Medium Dots درمیانے نقطے + Medium Lines درمیانی لائنیں + Medium Strokes درمیانی لکیریں + Mono Contrast مونو تضاد + Mosaic Tiles موزیک ٹائلز + Multiple Flames Along Path پاتھ کے ساتھ متعدد شعلے + Multiple Flames One Direction ایک سمت میں متعدد شعلے + Multiple Flames Path Directions متعدد شعلے پاتھ سمتیں + Multiple Flames Various Length مختلف لمبائی کے متعدد شعلے + Octagon آکٹاگون + Odd Fields طاق فیلڈز + One Flame Along Path پاتھ کے ساتھ ایک شعلہ + Out From Center مرکز سے باہر + Oval بیضوی + Overlay Edge اوورلے کنارہ + Palette Knife پیلیٹ نائف + Parallel متوازی + Patchwork پیچ ورک + Pentagon پینٹاگون + Point نقطہ + Pointed نوکدار + Pond Ripples تالاب کی لہریں + Pyramids اہرام + Random بے ترتیب + Right Diagonal دائیں ترچھا + Rope رسی + Roses گلاب + Sandstone ریت پتھر + Scallops اسکیلپس + Sepia سیپیا + Short Lines مختصر لائنیں + Short Strokes مختصر لکیریں + Simple سادہ + Light Intensity روشنی کی شدت + Spray Radius اسپرے ریڈیس + Stroke Direction اسٹروک کی سمت + Graininess دانے دار پن + Glow Amount چمک کی مقدار + Clear Amount صفائی کی مقدار + Distortion بگاڑ + Ripple Size لہر کا حجم + Ripple Magnitude لہر کی شدت + Charcoal Area چارکول کا رقبہ + Chalk Area چاک کا رقبہ + Charcoal Thickness چارکول کی موٹائی + Light Dark Balance روشن اور تاریک توازن + Foreground Level پیش منظر کی سطح + Background Level پس منظر کی سطح + Pattern Type پیٹرن کی قسم + Image Balance تصویر کا توازن + Darkness تاریکی + Fiber Length ریشے کی لمبائی + Crack Spacing دراڑ کا وقفہ + Crack Depth دراڑ کی گہرائی + Crack Brightness دراڑ کی روشنی + Grain Type دانے کی قسم + Tile Size ٹائل کا حجم + Grout Width گراؤٹ کی چوڑائی + Lighten Grout گراؤٹ ہلکا کریں + Square Size مربع کا حجم + Border Thickness بارڈر کی موٹائی + Correct Distortion بگاڑ درست کریں + Correct Vignette ویگنیٹ درست کریں + Correct C A C A درست کریں + Auto Scale خودکار پیمانہ + Red Cyan سرخ سیان + Blue Yellow نیلا پیلا + Vignette Amount ویگنیٹ کی مقدار + Vignette Midpoint ویگنیٹ کا درمیانی نقطہ + Constraints پابندیاں + Curve Highlights وکر نمایاں حصے + Curve Lights وکر روشن حصے + Curve Darks وکر تاریک حصے + Curve Shadows وکر سائے + Curve Splits وکر تقسیمات + Point Curve پوائنٹ وکر + Point Curve Red پوائنٹ وکر سرخ + Point Curve Green پوائنٹ وکر سبز + Point Curve Blue پوائنٹ وکر نیلا + Hsl Hue Hsl رنگت + Hsl Sat Hsl اشباع + Hsl Lum Hsl چمک + Grade Shadows گریڈ سائے + Grade Midtones گریڈ درمیانی ٹونز + Grade Highlights گریڈ نمایاں حصے + Grade Global گریڈ عالمی + Grade Blending گریڈ امتزاج + Grade Balance گریڈ توازن + Sine سائن + Sketch خاکہ + Snowflakes برف کے گالے + Sparkle چمک + Spatter چھڑکاؤ + Speckle دھبہ + Spin اسپن + Sprinkles چھڑکاؤ + Squareness مربع پن + Stagger ترتیب وار + Stained Glass رنگین شیشہ + Star ستارہ + Stars ستارے + Stippled نقطہ دار + Stretch کھینچیں + Teal Orange فیروزی نارنجی + Tiny Lens چھوٹا لینز + To Center مرکز کی طرف + Unaltered غیر تبدیل شدہ + Underpainting انڈر پینٹنگ + Underwater زیر آب + Upper بالائی + Vertical Only صرف عمودی + Vine With Flowers پھولوں والی بیل + Vine With Leaves پتوں والی بیل + Violent شدید + Warm گرم + Watercolor واٹر کلر + Waves لہریں + Wide Blurry وسیع دھندلا + Wide Sharp وسیع تیز + Wrap لپیٹیں + Zigzag زگ زیگ + Wetness گیلا پن + AI Integrations AI انضمام + Add Noise شور شامل کریں + Apply Data Set ڈیٹا سیٹ لاگو کریں + Apply Image تصویر لاگو کریں + Arbitrary صوابدیدی + Artboard from Group گروپ سے آرٹ بورڈ + Artboard from Layers تہوں سے آرٹ بورڈ + Artboards to Files آرٹ بورڈز کو فائلوں میں + Artboards to PDF آرٹ بورڈز کو PDF میں + Assign Profile پروفائل تفویض کریں + At بجے + Auto-Align Layers تہیں خودکار ہم آہنگ کریں + Auto-Blend Layers تہیں خودکار ملائیں + Batch بیچ + Border بارڈر + Box Blur باکس دھندلاپن + Calculations حسابات + Camera Raw کیمرہ را + Camera Raw Filter کیمرہ را فلٹر + Channel1 چینل1 + Channel2 چینل2 + Channel3 چینل3 + Channel4 چینل4 + Clone کلون + Color Decontaminate رنگ آلودگی دور کریں + Color Halftone رنگین ہاف ٹون + Color Lookup Tables رنگ تلاش جدولیں + Color Settings رنگ کی ترتیبات + Color Table رنگ جدول + Conditional Mode Change مشروط موڈ تبدیلی + Content-Aware Fill مواد سے آگاہ بھرائی + Convert to Linked لنک شدہ میں تبدیل کریں + Convert to Profile پروفائل میں تبدیل کریں + Crystallize کرسٹلائز کریں + Data ڈیٹا + Data Sets as Files ڈیٹا سیٹس بطور فائلیں + De-Interlace ڈی انٹرلیس + Define Brush Preset برش پری سیٹ متعین کریں + Define Custom Shape مخصوص شکل متعین کریں + Define Pattern نمونہ متعین کریں + Defringe کنارہ صفائی + Diffuse پھیلائیں + Diffuse Glow پھیلتی چمک + Displace نقل مکانی + Do you want to save you made to “{name}” before quitting? کیا آپ باہر نکلنے سے پہلے "{name}" میں کی گئی تبدیلیاں محفوظ کرنا چاہتے ہیں؟ + Dust & Scratches گرد و خراش + Export Contents مواد برآمد کریں + Export Preferences برآمدی ترجیحات + Export/Import Presets پری سیٹس برآمد/درآمد کریں + Extrude نکالیں + Fibers ریشے + Field Blur فیلڈ دھندلاپن + File Info فائل کی معلومات + Filter Gallery فلٹر گیلری + Find and Replace Text متن تلاش اور تبدیل کریں + Fit Image تصویر فٹ کریں + Flame شعلہ + Focus Area فوکس ایریا + Gaussian Blur گاؤسی دھندلاپن + Global Light عالمی روشنی + Group from Layers تہوں سے گروپ + HDR Toning HDR ٹوننگ + High Pass ہائی پاس + Highlight نمایاں حصہ + Image Processor امیج پروسیسر + Install Plug-in پلگ ان نصب کریں + Interpret Footage فوٹیج کی تشریح کریں + Iris Blur آئیرس دھندلاپن + Jag جیگ + Label لیبل + Layer Comps to Files تہہ کمپوزیشنز فائلوں میں + Layer Content Options تہہ مواد کے اختیارات + Layer From Background پس منظر سے تہہ + Layer from Background پس منظر سے تہہ + Layers to Files تہیں فائلوں میں + Legacy پرانا + Lens Blur لینز دھندلاپن + Lens Correction لینز تصحیح + Lens Flare لینز فلیئر + Lighting Effects روشنی کے اثرات + Liquify لیکویفائی + Lum چمک + Match Color رنگ ملائیں + Merge to HDR Pro HDR پرو میں ضم کریں + Mezzotint میزوٹنٹ + Mosaic موزیک + Motion Blur حرکت دھندلاپن + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. نیٹو فائل ڈریگ اینڈ ڈراپ ابھی تک Wayland پر سپورٹ نہیں ہے۔ فائل > کھولیں استعمال کریں، یا PhotoCraft کو XWayland کے تحت `WAYLAND_DISPLAY= photocraft` کے ساتھ چلائیں۔ + Neon Glow نیون چمک + New Guide Layout نیا رہنما خاکہ + New Video Layer from File فائل سے نئی ویڈیو تہہ + Ocean Ripple اوشین رپل + Oil Paint آئل پینٹ + Open As بطور کھولیں + Package پیکج + Paint Daubs پینٹ ڈاب + Path Blur پاتھ دھندلاپن + Paths to Illustrator پاتھ الیسٹریٹر کو + Photomerge فوٹو مرج + Picture Frame تصویری فریم + Pinch چٹکی + Place Embedded ایمبیڈڈ رکھیں + Place Linked لنک شدہ رکھیں + Place Scale Marker پیمانہ نشان رکھیں + Plastic Wrap پلاسٹک ریپ + Pointillize پوائنٹلائز کریں + Polar Coordinates قطبی متناسقات + Polar To Rectangular قطبی سے مستطیل + Poster Edges پوسٹر کنارے + Preset Manager پری سیٹ مینیجر + Profile پروفائل + Radial Blur شعاعی دھندلاپن + Raw Defaults را ڈیفالٹس + Rectangular To Polar مستطیل سے قطبی + Relink to File فائل سے دوبارہ لنک کریں + Render Video ویڈیو رینڈر کریں + Replace Color رنگ بدلیں + Replace Contents مواد تبدیل کریں + Replace Footage فوٹیج تبدیل کریں + Resolve Missing Fonts غائب فونٹس حل کریں + Ripple لہر + Rough Pastels موٹے پیسٹلز + Sat اشباع + Save a Copy ایک نقل محفوظ کریں + Save for Web (Legacy) ویب کے لیے محفوظ کریں (پرانا) + Scale Effects اثرات کا پیمانہ + Script Events Manager اسکرپٹ ایونٹس مینیجر + Search menus مینیوز تلاش کریں + Select and Mask منتخب کریں اور ماسک کریں + Set Measurement Scale پیمائش کا پیمانہ مقرر کریں + Shadows/Highlights سائے/نمایاں حصے + Shape Blur شکل دھندلاپن + Shear قینچی پن + Smart Blur سمارٹ دھندلاپن + Smart Sharpen سمارٹ شارپن + Smudge Stick اسمج اسٹک + Solid Color ٹھوس رنگ + Spherize کروی بنائیں + Spin Blur اسپن دھندلاپن + Spot اسپاٹ + Sprayed Strokes چھڑکی ہوئی لکیریں + Strokes اسٹروکس + Sumi-e سومی-ای + Surface Blur سطحی دھندلاپن + Texturizer ٹیکسچرائزر + Tiles ٹائلز + Tilt-Shift ٹلٹ شفٹ + Trace Contour خاکہ کھینچیں + Trap ٹریپ + Tree درخت + Trim تراش + Turbulent ہنگامہ خیز + Twirl بل دیں + Unsharp Mask ان شارپ ماسک + Use File › Open to open an image. تصویر کھولنے کے لیے فائل > کھولیں استعمال کریں۔ + Vanishing Point وینشنگ پوائنٹ + Variable Data Sets متغیر ڈیٹا سیٹس + Video Frames to Layers ویڈیو فریمز کو تہوں میں + WIA Support WIA سپورٹ + Warp Text متن موڑیں + Wind ہوا + Vertical Type Tool عمودی ٹائپ ٹول + About {app} {app} کے بارے میں + Hide {app} {app} چھپائیں + Quit {app} {app} چھوڑیں + Services سروسز + Hide Others دیگر چھپائیں + Lut Lut + Cooling L B B کولنگ L B B + Cooling80 کولنگ80 + Cooling82 کولنگ82 + Hsb Hsb + Hsl Hsl + Movie Prime موی پرائم + Prime105 پرائم105 + Prime35 پرائم35 + Warming L B A وارمنگ L B A + Warming81 وارمنگ81 + Warming85 وارمنگ85 +@id filter.sharpen.sharpen تیز کریں + Good اچھا + Best بہترین + Adjustment Properties ایڈجسٹمنٹ کی خصوصیات + Default Foreground and Background Colors ڈیفالٹ پیش منظر اور پس منظر رنگ + Move Layer تہہ منتقل کریں + Rectangular Selection مستطیل انتخاب + Reorder Layer تہہ کی ترتیب نو + Select Layer تہہ منتخب کریں + Set Colors رنگ مقرر کریں + Switch Foreground and Background Colors پیش منظر اور پس منظر رنگ تبدیل کریں + Target ہدف + Make Work Path… ورک پاتھ بنائیں… + Create Vector Mask ویکٹر ماسک بنائیں + Make Selection… انتخاب بنائیں… + Fill Path… پاتھ بھریں… + Clipping Path… کلپنگ پاتھ… + Free Transform Path پاتھ آزاد ہیئت تبدیلی + Unite Shapes at Overlap اوورلیپ پر شکلیں یکجا کریں + Subtract Shapes at Overlap اوورلیپ پر شکلیں منہا کریں + Copy Fill بھرائی کاپی کریں + Copy Complete Stroke مکمل لکیر کاپی کریں + Paste Fill بھرائی چسپاں کریں + Paste Complete Stroke مکمل لکیر چسپاں کریں + Make Symmetry Path سمیٹری پاتھ بنائیں + Disable Symmetry Path سمیٹری پاتھ غیر فعال کریں + Select Custom Color… مخصوص رنگ منتخب کریں… + Color Picker (Custom Canvas Color) رنگ چنندہ (مخصوص کینوس رنگ) + Duplicate Channel چینل کی نقل بنائیں + Delete Channel چینل حذف کریں + Rename Channel… چینل کا نام تبدیل کریں… + Merge Spot Channel اسپاٹ چینل ضم کریں + Convert to Alpha Channel الفا چینل میں تبدیل کریں + Exit Quick Mask کوئیک ماسک سے باہر نکلیں + New Channel… نیا چینل… + New Spot Channel… نیا اسپاٹ چینل… + Split Channels چینلز تقسیم کریں + Merge Channels… چینلز ضم کریں… + Color Indicates Masked Areas رنگ ماسک شدہ حصوں کی نشاندہی کرتا ہے + Color Indicates Selected Areas رنگ منتخب حصوں کی نشاندہی کرتا ہے + ✓ Color Indicates Masked Areas ✓ رنگ ماسک شدہ حصوں کی نشاندہی کرتا ہے + ✓ Color Indicates Selected Areas ✓ رنگ منتخب حصوں کی نشاندہی کرتا ہے + Up اوپر + To تا + Shallow کم گہرا + Deep زیادہ گہرا + Blend Interior Effects as Group اندرونی اثرات کو گروپ کے طور پر ملائیں + Blend Clipped Layers as Group کلپڈ تہوں کو گروپ کے طور پر ملائیں + Transparency Shapes Layer شفافیت شکل تہہ + Layer Mask Hides Effects تہہ ماسک اثرات چھپاتا ہے + Vector Mask Hides Effects ویکٹر ماسک اثرات چھپاتا ہے + General Blending عمومی امتزاج + Underlying Layer: بنیادی تہہ: + Nothing to paste: the clipboard holds no image چسپاں کرنے کے لیے کچھ نہیں: کلپ بورڈ میں کوئی تصویر نہیں + Preferences saved ترجیحات محفوظ ہو گئیں + Invalid panel layout; restored the default dock غلط پینل لے آؤٹ؛ ڈیفالٹ ڈاک بحال ہو گیا + Preferences were reset: {error} ترجیحات ری سیٹ ہو گئیں: {error} + Recovered {name} {name} بحال ہوا + Couldn't save preferences: {error}. Retrying… ترجیحات محفوظ نہیں ہو سکیں: {error}۔ دوبارہ کوشش ہو رہی ہے… + Autosave failed: {error} خودکار محفوظ کرنا ناکام ہوا: {error} + Exported presets to {path} پری سیٹس {path} میں برآمد ہو گئے + Ran script {name} اسکرپٹ {name} چلایا گیا + Sent to the printer ({spooler}) پرنٹر کو بھیجا گیا ({spooler}) + Printed to {path} {path} پر پرنٹ ہوا + Exported {path} ({size}) {path} برآمد ہوا ({size}) + Exported {path} {path} برآمد ہوا + Added swatch {name} نمونہ {name} شامل ہوا + Step {step} ({id}) failed: {error} قدم {step} ({id}) ناکام ہوا: {error} + Could not rename the action: {error} ایکشن کا نام تبدیل نہیں ہو سکا: {error} + Action playback failed: {error} ایکشن چلانا ناکام ہوا: {error} + Clone source set at {x}, {y} کلون ماخذ {x}, {y} پر مقرر ہوا + Color management: {error} رنگ کا انتظام: {error} + Opened {url} {url} کھلا + No custom shape "{name}": pick one in the options bar or Window › Shapes کوئی مخصوص شکل "{name}" نہیں: اختیارات بار میں یا ونڈو > شکلیں میں ایک چنیں + Saved a copy as {path} ایک نقل {path} کے طور پر محفوظ ہوئی + Exported contents to {path} مواد {path} میں برآمد ہوا + {label} is not available: {reason} {label} دستیاب نہیں: {reason} + Saved {path}; image assets: {n} in {dir} {path} محفوظ ہوا؛ تصویری اثاثے: {n} بذریعہ {dir} + {message} (notes: {n}; {warning}) {message} (نوٹس: {n}; {warning}) + Preferences can't be saved ترجیحات محفوظ نہیں ہو سکتیں + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft دوبارہ کوشش جاری رکھتا ہے؛ جب تک محفوظ کرنا کامیاب نہ ہو، بند ہونے پر ترجیحی تبدیلیاں ضائع ہو جاتی ہیں۔ + GPU retry scheduled GPU دوبارہ کوشش شیڈول ہو گئی + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. اپنا کام محفوظ کریں اور GPU ایکسیلریشن کی دوبارہ کوشش کے لیے PhotoCraft کو دوبارہ شروع کریں۔ CPU رینڈرنگ اس سیشن کے لیے فعال رہتی ہے۔ + GPU device was lost; using the CPU renderer. GPU ڈیوائس ضائع ہو گئی؛ CPU رینڈرر استعمال ہو رہا ہے۔ + GPU error; using the CPU renderer. GPU خرابی؛ CPU رینڈرر استعمال ہو رہا ہے۔ + Couldn't open {name}: {error} {name} کھل نہیں سکا: {error} + Toggle the Brush Settings panel برش کی ترتیبات پینل تبدیل کریں + New Brush Preset نیا برش پری سیٹ + Grid view گرڈ منظر + List view فہرست منظر + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink غیر مربوط کریں + Link مربوط کریں + Commit transform ہیئت تبدیلی نافذ کریں + Cancel transform ہیئت تبدیلی منسوخ کریں + Scroll menu up مینیو اوپر اسکرول کریں + Scroll menu down مینیو نیچے اسکرول کریں + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? آگے بڑھنے سے پہلے اس متن تہہ کو ریسٹرائز کرنا ضروری ہے۔ اس کا متن مزید قابل ترمیم نہیں رہے گا۔ کیا قسم کو ریسٹرائز کریں؟ + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? آگے بڑھنے سے پہلے اس شکل تہہ کو ریسٹرائز کرنا ضروری ہے۔ یہ مزید شکل کے طور پر قابل ترمیم نہیں رہے گی۔ کیا شکل کو ریسٹرائز کریں؟ + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? آگے بڑھنے سے پہلے اس سمارٹ آبجیکٹ کو ریسٹرائز کرنا ضروری ہے۔ اس کا مواد مزید قابل ترمیم نہیں رہے گا۔ کیا سمارٹ آبجیکٹ کو ریسٹرائز کریں؟ + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? آگے بڑھنے سے پہلے اس بھرائی تہہ کو ریسٹرائز کرنا ضروری ہے۔ اس کا بھرائی مواد مزید قابل ترمیم نہیں رہے گا۔ کیا تہہ کو ریسٹرائز کریں؟ + This layer must be rasterized before proceeding. آگے بڑھنے سے پہلے اس تہہ کو ریسٹرائز کرنا ضروری ہے۔ + Color Picker (Background Color) رنگ چنندہ (پس منظر کا رنگ) + Color Picker (Foreground Color) رنگ چنندہ (پیش منظر کا رنگ) + Adwaita (Light) Adwaita (لائٹ) + Adwaita (Dark) Adwaita (ڈارک) + Open Recent is unavailable on the web ویب پر حالیہ کھولیں دستیاب نہیں + Graphics adapter: {adapter} گرافکس ایڈاپٹر: {adapter} + Backend: {backend} ({type}) بیک اینڈ: {backend} ({type}) + Driver: {driver} ڈرائیور: {driver} + GPU backend preference: {backend} GPU بیک اینڈ ترجیح: {backend} + Selected at launch: {backend} آغاز پر منتخب شدہ: {backend} + Image compositor: {processor} تصویری کمپوزیٹر: {processor} + Fallback: {reason} فال بیک: {reason} + This session: {reason} یہ سیشن: {reason} + Window renderer: {renderer} ونڈو رینڈرر: {renderer} + Software adapter سافٹ ویئر ایڈاپٹر + Graphics adapter گرافکس ایڈاپٹر + unknown type نامعلوم قسم + Platform: {os} {arch} پلیٹ فارم: {os} {arch} + previous reading kept; the last re-read failed: {reason} پچھلی ریڈنگ برقرار رکھی گئی؛ آخری دوبارہ پڑھنا ناکام ہوا: {reason} + Monitor profile: {profile} مانیٹر پروفائل: {profile} + Display profiles: {reason} ڈسپلے پروفائلز: {reason} + Monitor profile in use on {display}: {profile} {display} پر استعمال شدہ مانیٹر پروفائل: {profile} + Monitor profile in use: {profile} استعمال شدہ مانیٹر پروفائل: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. کینوس sRGB کے طور پر دکھایا جاتا ہے۔ ترمیم > رنگ کی ترتیبات میں مانیٹر پروفائل چنیں۔ + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. {display} پر کینوس sRGB کے طور پر دکھایا جاتا ہے۔ ترمیم > رنگ کی ترتیبات میں مانیٹر پروفائل چنیں۔ + Display profile not used ڈسپلے پروفائل استعمال نہیں ہوا + Monitor profile on {display} (main window): {profile} {display} پر مانیٹر پروفائل (مین ونڈو): {profile} + Monitor profile on {display}: {profile} {display} پر مانیٹر پروفائل: {profile} + fallback فال بیک + The document does not have an embedded colour profile. Working space: {working}. دستاویز میں کوئی ایمبیڈڈ رنگ پروفائل نہیں ہے۔ ورکنگ اسپیس: {working}۔ + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} دستاویز میں ایک ایمبیڈڈ رنگ پروفائل ہے جو ورکنگ اسپیس سے مماثل نہیں ہے۔\nایمبیڈڈ: {embedded}\nورکنگ: {working} + Importing {what} from {file}… {file} سے {what} درآمد ہو رہا ہے… +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file} سے {n} برش درآمد ہوا|{file} سے {n} برشز درآمد ہوئے +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file} سے {n} گریڈیئنٹ درآمد ہوا|{file} سے {n} گریڈیئنٹس درآمد ہوئے +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file} سے {n} نمونہ درآمد ہوا|{file} سے {n} نمونے درآمد ہوئے + Data Set: {name} ({index}/{total}) ڈیٹا سیٹ: {name} ({index}/{total}) + (none) (کوئی نہیں) +navigator Zoom زوم + Apply {option}: {state} {option} لاگو کریں: {state} + This adjustment ({key}) is kept as imported and can't be edited yet. یہ ایڈجسٹمنٹ ({key}) درآمد شدہ کے طور پر رکھی گئی ہے اور ابھی ترمیم کے قابل نہیں۔ + Join the ArtCraft Discord ({url}) ArtCraft ڈسکارڈ میں شامل ہوں ({url}) + {index} of {total} {total} میں سے {index} + {shortcut} assigned. {shortcut} تفویض ہو گیا۔ + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} پہلے سے {commands} کے زیر استعمال ہے اور OK پر کلک کرنے پر اس سے ہٹا دیا جائے گا۔ + The current gradient (pick one in Window › Gradients) موجودہ گریڈیئنٹ (ونڈو > گریڈیئنٹس میں ایک چنیں) + auto خودکار + Baseline Shift بیس لائن شفٹ + Breeze Dark بریز ڈارک + Breeze Light بریز لائٹ + brushes برشز + Color Picker رنگ چنندہ + Current موجودہ + Dark Gray گہرا سرمئی + Fill Color بھرائی رنگ + gradients گریڈیئنٹس + Graphic and web گرافک اور ویب + layer mask تہہ ماسک + Lock All سب مقفل کریں + Lock Image Pixels تصویر پکسلز مقفل کریں + Lock Position پوزیشن مقفل کریں + Lock Transparent Pixels شفاف پکسلز مقفل کریں + manual دستی + Medium Gray درمیانہ سرمئی + New document نئی دستاویز + New Selection نیا انتخاب + No Document کوئی دستاویز نہیں + Note tool نوٹ ٹول + off بند + on آن + Recent Files حالیہ فائلیں + Rgb Rgb + Shortcut شارٹ کٹ + Show Rulers رولرز دکھائیں + Solarized Dark سولرائزڈ ڈارک + sRGB sRGB + Stroke Color لکیر رنگ + unknown نامعلوم + Unsaved Changes غیر محفوظ شدہ تبدیلیاں + vector mask ویکٹر ماسک + ZigZag زگ زیگ + Show home recent files ہوم حالیہ فائلیں دکھائیں + Clear Brush Controls برش کنٹرولز صاف کریں + Reset All Locked Settings تمام مقفل ترتیبات ری سیٹ کریں + Copy Texture to Other Tools ساخت کو دوسرے ٹولز میں کاپی کریں + Load Actions… ایکشنز لوڈ کریں… + Save Actions… ایکشنز محفوظ کریں… + New Action… نیا ایکشن… + Start Recording ریکارڈنگ شروع کریں + Stop Recording ریکارڈنگ روکیں + {name} (Photoshop) {name} (فوٹوشاپ) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} سے {n} ایکشن لوڈ ہوا|{file} سے {n} ایکشنز لوڈ ہوئے +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} قدم کا ابھی PhotoCraft میں کوئی مساوی نہیں|{n} قدموں کا ابھی PhotoCraft میں کوئی مساوی نہیں +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ایکشن {file} میں محفوظ ہوا|{n} ایکشنز {file} میں محفوظ ہوئے +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} قدم جس کا فوٹوشاپ میں کوئی مساوی نہیں چھوڑ دیا گیا|{n} قدم جن کا فوٹوشاپ میں کوئی مساوی نہیں چھوڑ دیے گئے + Starting serial ابتدائی سیریل + Insert Stop… اسٹاپ شامل کریں… + Save Set… سیٹ محفوظ کریں… + Message: پیغام: + Allow Continue جاری رکھنے کی اجازت دیں + Show dialog during playback پلے بیک کے دوران ڈائیلاگ دکھائیں + Select an action first پہلے ایک ایکشن منتخب کریں + Action playback stopped ایکشن پلے بیک رک گیا + Step {step} is waiting for its dialog قدم {step} اپنے ڈائیلاگ کا انتظار کر رہا ہے +@plural {n} action|{n} actions {n} ایکشن|{n} ایکشنز + Background (Default) پس منظر (ڈیفالٹ) + Include All Subfolders تمام ذیلی فولڈرز شامل کریں + Auto Add/Delete خودکار شامل/حذف کریں + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. پرنٹ ترتیبات… میں مرر امیج بھی نشان زد نہ کریں: دونوں پلٹیں ایک دوسرے کو منسوخ کر دیتی ہیں۔ + Print Settings… پرنٹ ترتیبات… + Remember to turn off the printer's own colour management in Print Settings… پرنٹر کا اپنا رنگ انتظام بند کرنا یاد رکھیں، پرنٹ ترتیبات… + Sent to {printer} {printer} کو بھیجا گیا + set in Print Settings… پرنٹ ترتیبات میں مقرر… + ICO is limited to 256 × 256 pixels ICO 256 × 256 پکسلز تک محدود ہے + Show Favorite Fonts پسندیدہ فونٹس دکھائیں + No favorite fonts yet: click the star beside a font to add it. ابھی تک کوئی پسندیدہ فونٹس نہیں: شامل کرنے کے لیے فونٹ کے ساتھ ستارے پر کلک کریں۔ + Favorite fonts پسندیدہ فونٹس + Add to Favorites پسندیدہ میں شامل کریں + Remove from Favorites پسندیدہ سے ہٹائیں + Show All Fonts تمام فونٹس دکھائیں + All Classes تمام کلاسز + Blackletter بلیک لیٹر + Decorative آرائشی + Font preview: فونٹ پیش منظر: + Handwritten ہاتھ سے لکھا ہوا + Hide Font Classes فونٹ کلاسز چھپائیں + Hide Styles اسٹائلز چھپائیں + Monospace مونو اسپیس + No fonts match these filters. ان فلٹرز سے کوئی فونٹ مماثل نہیں۔ + No similar fonts: the current font doesn't say what it looks like. کوئی مماثل فونٹس نہیں: موجودہ فونٹ یہ نہیں بتاتا کہ یہ کیسا نظر آتا ہے۔ + OpenType font OpenType فونٹ + Sans Serif سینز سیرف + Script اسکرپٹ + Serif سیرف + Show Font Classes فونٹ کلاسز دکھائیں + Show Similar Fonts مماثل فونٹس دکھائیں + Show Styles اسٹائلز دکھائیں + Show Variable Fonts متغیر فونٹس دکھائیں + Slab Serif سلیب سیرف + TrueType font TrueType فونٹ + Variable font متغیر فونٹ + Calibration کیلیبریشن + Red Primary سرخ بنیادی + Green Primary سبز بنیادی + Blue Primary نیلا بنیادی + Write xmp sidecars xmp سائیڈ کار لکھیں + Set the tone sliders from the image تصویر سے ٹون سلائیڈرز مقرر کریں + Settings from {name} {name} سے ترتیبات + Settings from {name}; {count} not supported: {names} {name} سے ترتیبات؛ {count} سپورٹڈ نہیں: {names} + Could not write {name}: {error} {name} نہیں لکھا جا سکا: {error} + Kept {name}: written by another application {name} رکھا گیا: دوسری ایپلیکیشن نے لکھا تھا + Shadow Tint سائے کا ٹنٹ + Check for Updates… اپ ڈیٹس چیک کریں… + PhotoCraft Updates PhotoCraft اپ ڈیٹس + Checking for updates… اپ ڈیٹس چیک ہو رہی ہیں… + Open the release page to choose an installer for your system. اپنے سسٹم کے لیے انسٹالر چننے کے لیے ریلیز صفحہ کھولیں۔ + Open Download Page ڈاؤن لوڈ صفحہ کھولیں + PhotoCraft is up to date. PhotoCraft اپ ٹو ڈیٹ ہے۔ + Could not check for updates. اپ ڈیٹس چیک نہیں ہو سکیں۔ + Updates have not been checked yet. اپ ڈیٹس ابھی تک چیک نہیں کی گئیں۔ + Check Again دوبارہ چیک کریں + Automatically Check for Updates خودکار طور پر اپ ڈیٹس چیک کریں + Installed version: {version} انسٹال شدہ ورژن: {version} + PhotoCraft {version} is available. PhotoCraft {version} دستیاب ہے۔ + Paints from the History panel's source state ہسٹری پینل کی ماخذ حالت سے پینٹ کرتا ہے + Sets the source for the history brush ہسٹری برش کے لیے ماخذ مقرر کرتا ہے + Highlight Mode نمایاں حصے کا موڈ + Highlight Color نمایاں حصے کا رنگ + Highlight Opacity نمایاں حصے کا دھندلاپن + Shadow Mode سائے کا موڈ + Shadow Color سائے کا رنگ + Shadow Opacity سائے کا دھندلاپن + Use the embedded profile ({profile}) ایمبیڈڈ پروفائل ({profile}) استعمال کریں + Convert to the working space ({profile}) ورکنگ اسپیس ({profile}) میں تبدیل کریں + Remember this choice for future images مستقبل کی تصاویر کے لیے یہ انتخاب یاد رکھیں + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. ترمیم > رنگ کی ترتیبات میں Ask On Mismatch سے اشارہ دوبارہ آن کریں۔ + Button Mode بٹن موڈ + Button Mode On بٹن موڈ آن + Darken Interface انٹرفیس تاریک کریں + Lighten Interface انٹرفیس ہلکا کریں + Technique تکنیک + Chisel Hard چیزل سخت + Chisel Soft چیزل نرم + Open color-less EXR as ACEScg بے رنگ EXR کو ACEScg کے طور پر کھولیں + Do not append "copy" to filename when saving a copy نقل محفوظ کرتے وقت فائل کے نام میں "copy" شامل نہ کریں + Edit Selected Guides منتخب رہنما لکیریں ترمیم کریں + Clear Selected Guides منتخب رہنما لکیریں صاف کریں + Bicubic Automatic بائی کیوبک خودکار + Bicubic Smoother بائی کیوبک اسموتھر + Bicubic Sharper بائی کیوبک شارپر + Pasting from other apps is unavailable دوسری ایپس سے چسپاں کرنا دستیاب نہیں + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft اس سیشن میں سسٹم کلپ بورڈ تک نہیں پہنچ سکا۔ Wayland پر، Flatpak سینڈ باکس وہ پروٹوکول چھپاتا ہے جس سے دوسری ایپس کی تصاویر پڑھی جاتی ہیں۔ + To paste from other apps, start PhotoCraft under XWayland: `{command}` دوسری ایپس سے چسپاں کرنے کے لیے، PhotoCraft کو XWayland کے تحت شروع کریں: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here چسپاں کرنے کے لیے کچھ نہیں: PhotoCraft یہاں سسٹم کلپ بورڈ نہیں پڑھ سکتا + Delete this set یہ سیٹ حذف کریں + New Set… نیا سیٹ… + PhotoCraft Defaults PhotoCraft ڈیفالٹس + Save a list of these shortcuts as a web page ان شارٹ کٹس کی فہرست ویب صفحے کے طور پر محفوظ کریں + Save the changes to this set اس سیٹ میں تبدیلیاں محفوظ کریں + Save these shortcuts as a .kys file یہ شارٹ کٹس .kys فائل کے طور پر محفوظ کریں + Save these shortcuts as a new set یہ شارٹ کٹس نئے سیٹ کے طور پر محفوظ کریں + Set deleted. سیٹ حذف ہو گیا۔ + Set saved. سیٹ محفوظ ہو گیا۔ + Set: سیٹ: + Summarize… خلاصہ کریں… + There are too many sets: delete one first. بہت زیادہ سیٹس ہیں: پہلے ایک حذف کریں۔ + set name سیٹ کا نام + {set} (modified) {set} (ترمیم شدہ) + Show file name in title ٹائٹل میں فائل کا نام دکھائیں + 50% Threshold 50% حد + Pattern Dither پیٹرن ڈیدر + Diffusion Dither ڈفیوزن ڈیدر + Halftone Screen ہاف ٹون اسکرین + Custom Pattern مخصوص نمونہ + Frequency: فریکوئنسی: + Lines/Inch لائنیں/انچ + Lines/Centimeter لائنیں/سینٹی میٹر + Ellipse بیضوی + Accept and Go to Conflict قبول کریں اور تنازع پر جائیں + Changes undone. تبدیلیاں واپس لی گئیں۔ + Choose a new shortcut for this command, or leave it without one. اس کمانڈ کے لیے نیا شارٹ کٹ چنیں، یا اسے بغیر کسی کے چھوڑ دیں۔ + Put this command's shortcuts back as they were اس کمانڈ کے شارٹ کٹس کو جیسے تھے واپس رکھیں + Take the shortcut from the other command and select it دوسری کمانڈ سے شارٹ کٹ لیں اور اسے منتخب کریں + Undo Changes تبدیلیاں کالعدم کریں + {shortcut} is reserved by the system and can't be assigned. {shortcut} سسٹم کے لیے محفوظ ہے اور تفویض نہیں کیا جا سکتا۔ + Low-Latency Drawing (vsync off, applies at next launch) کم تاخیر ڈرائنگ (vsync بند، اگلی بار شروع ہونے پر لاگو ہوتا ہے) + Free Transform is still being applied; try again when it has finished. آزاد ہیئت تبدیلی ابھی لاگو ہو رہی ہے؛ مکمل ہونے پر دوبارہ کوشش کریں۔ + The transform couldn't be applied. ہیئت تبدیلی لاگو نہیں ہو سکی۔ + Its layers need {needed} of GPU memory; the budget leaves them {budget}. اس کی تہوں کو {needed} GPU میموری درکار ہے؛ بجٹ انہیں {budget} چھوڑتا ہے۔ + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. ترجیحات > کارکردگی میں GPU میموری کو اس قدر مقرر کریں جو آپ کا گرافکس کارڈ رکھ سکے، زیادہ سے زیادہ اس کی میموری کا 3/8۔ خودکار میموری استعمال کی پیروی کرتا ہے، اس کمپیوٹر کی میموری کے ایک چوتھائی اور کارڈ کی میموری کے 3/8 کے اندر، اور کسی ایسے کارڈ کے خلاف دستاویز کو شمار نہیں کرتا جس کی میموری معلوم ہو۔ جب وہ فٹ نہیں ہوتیں تو اسٹیٹس بار GPU میموری بھرا ہوا دکھاتا ہے۔ + GPU Memory Full GPU میموری بھری ہوئی + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. تہوں کو {needed} GPU میموری درکار ہے اور شاید {budget} رکھ سکیں، لہٰذا وہ تبدیلیاں جو پوری دستاویز کو دوبارہ ڈرائنگ کرتی ہیں CPU پر ہوتی ہیں۔ GPU میموری مقرر کرنے کے لیے کلک کریں۔ + GPU memory budget: {budget} GPU میموری بجٹ: {budget} + Graphics memory: {size} گرافکس میموری: {size} + This document's layers: {needed} of {budget} for layers اس دستاویز کی تہیں: تہوں کے لیے {budget} میں سے {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU اس دستاویز کی تہوں کو {needed} درکار ہے لیکن شاید {budget} رکھ سکیں: پوری دستاویز کی دوبارہ ڈرائنگ CPU استعمال کرتی ہے + GPU Memory (MB, 0 = Automatic) GPU میموری (MB, 0 = خودکار) + {path}: the path is not valid Unicode; save under another name. {path}: راستہ درست یونیکوڈ نہیں ہے؛ دوسرے نام سے محفوظ کریں۔ + {name} was changed by another app after it was opened or saved here. {name} کو یہاں کھولنے یا محفوظ کرنے کے بعد دوسری ایپ نے تبدیل کیا۔ + Replace it with this document, or cancel and use File › Save As to keep both. اسے اس دستاویز سے تبدیل کریں، یا منسوخ کریں اور دونوں رکھنے کے لیے فائل > محفوظ کریں بطور استعمال کریں۔ + Decrease Brush Size برش کا حجم کم کریں + Increase Brush Size برش کا حجم بڑھائیں + Decrease Brush Hardness برش کی سختی کم کریں + Increase Brush Hardness برش کی سختی بڑھائیں + Demosaic ڈیموزیک + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. کیمرے کا رنگ فلٹر موزیک کس طرح بھرا جاتا ہے۔ یہ فائل کھلنے پر لاگو ہوتا ہے؛ پیش منظر وہ طریقہ دکھاتا ہے جس سے یہ کھلی تھی۔ + AHD (adaptive homogeneity) AHD (موافق یکسانیت) + Malvar–He–Cutler مالوار–ہی–کٹلر + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. ہیراکاوا–پارکس: صاف ترین کنارے اور کم ترین رنگ کی دھاریاں، سب سے سست۔ + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. گریڈیئنٹ درست انٹرپولیشن: تیز اور سریع، باریک تفصیل پر کچھ رنگ کی دھاریوں کے ساتھ۔ + The fastest and the softest, with colour fringing on fine detail. سب سے تیز اور سب سے نرم، باریک تفصیل پر رنگ کی دھاریوں کے ساتھ۔ + “{name}” already exists. Replace it? "{name}" پہلے سے موجود ہے۔ اسے تبدیل کریں؟ + The name in the save dialog was “{name}”. محفوظ کرنے کے ڈائیلاگ میں نام "{name}" تھا۔ + Previous Brush پچھلا برش + Next Brush اگلا برش + First Brush پہلا برش + Last Brush آخری برش + Preview size پیش منظر کا حجم + Memory Usage میموری استعمال + Per-document RAM limit (MB) فی دستاویز RAM حد (MB) + Installed RAM: {ram} MB; this limit: {percent}% انسٹال شدہ RAM: {ram} MB؛ یہ حد: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. ہر کھلی دستاویز کے پکسلز کے ساتھ تاریخ کو محدود کرتا ہے۔ حد سے تجاوز ہونے پر سب سے پرانی کالعدم حالتیں ضائع کر دی جاتی ہیں۔ + Applies immediately when saved. Memory is not reserved in advance. محفوظ ہونے پر فوری طور پر لاگو ہوتا ہے۔ میموری پہلے سے محفوظ نہیں کی جاتی۔ + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU میموری خودکار پر مقرر ہونے کے ساتھ، یہ GPU تہہ بجٹ کو بھی محدود کرتا ہے۔ یہ بجٹ انسٹال شدہ RAM کے ایک چوتھائی اور، جب معلوم ہو، گرافکس کارڈ کی میموری کے 3/8 تک محدود ہے، کم از کم 512 MB کے ساتھ۔ اس حد کو ان حدود سے زیادہ بڑھانا GPU بجٹ میں اضافہ نہیں کرتا۔ + Use استعمال کریں + In Front سامنے + Behind پیچھے + DDS Options DDS اختیارات + Generate Mipmaps Mipmaps بنائیں + There is no color table to save. محفوظ کرنے کے لیے کوئی رنگ جدول نہیں۔ +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file} میں {n} رنگ محفوظ ہوا|{file} میں {n} رنگ محفوظ ہوئے + GPU adapter GPU ایڈاپٹر + Import Styles… اسٹائلز درآمد کریں… + Export Selected Styles… منتخب اسٹائلز برآمد کریں… + Export Styles… اسٹائلز برآمد کریں… + {file} is not a style library (.asl) {file} اسٹائل لائبریری (.asl) نہیں ہے +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file} سے {n} اسٹائل درآمد ہوا|{file} سے {n} اسٹائلز درآمد ہوئے +@plural Exported {n} style to {file}|Exported {n} styles to {file} {file} میں {n} اسٹائل برآمد ہوا|{file} میں {n} اسٹائلز برآمد ہوئے + Confirm or cancel the open action step dialog first پہلے کھلے ایکشن قدم ڈائیلاگ کی تصدیق یا منسوخی کریں + Pro Medium Gray پرو میڈیم گرے + Solarized سولرائزڈ + Breeze بریز + Pro Light Gray پرو لائٹ گرے + Pro Light پرو لائٹ + Resize Windows to Fit ونڈوز کا حجم فٹ کرنے کے لیے بدلیں + EXR EXR + Automatic paragraph direction خودکار پیراگراف سمت + Left-to-right paragraph direction بائیں سے دائیں پیراگراف سمت + Right-to-left paragraph direction دائیں سے بائیں پیراگراف سمت + Not rendered: this adjustment ({key}) is kept as imported. رینڈر نہیں ہوا: یہ ایڈجسٹمنٹ ({key}) درآمد شدہ کے طور پر رکھی گئی ہے۔ + Print preview image پرنٹ پیش منظر تصویر + Hue Cube رنگت کیوب + Brightness Cube چمک کیوب + Color Wheel رنگ کا پہیہ + RGB Sliders RGB سلائیڈرز + HSB Sliders HSB سلائیڈرز + CMYK Sliders CMYK سلائیڈرز + Lab Sliders Lab سلائیڈرز + Web Color Sliders ویب رنگ سلائیڈرز + Copy Color as HTML رنگ کو HTML کے طور پر کاپی کریں + Copy Color's Hex Code رنگ کا ہیکس کوڈ کاپی کریں + RGB Spectrum RGB سپیکٹرم + CMYK Spectrum CMYK سپیکٹرم + Grayscale Ramp گرے اسکیل ریمپ + Current Colors موجودہ رنگ + Make Ramp Web Safe ریمپ کو ویب سیف بنائیں + The path was kept as the Work Path پاتھ ورک پاتھ کے طور پر رکھا گیا + The path was discarded پاتھ ضائع کر دیا گیا + Art History Brush Tool آرٹ ہسٹری برش ٹول + Tight Short تنگ مختصر + Tight Medium تنگ درمیانہ + Tight Long تنگ لمبا + Loose Medium ڈھیلا درمیانہ + Loose Long ڈھیلا لمبا + Dab ڈیب + Tight Curl تنگ کرل + Loose Curl ڈھیلا کرل + Stylized dabs from a history state ہسٹری حالت سے اسٹائلائزڈ ڈیبز + Font size presets فونٹ حجم پری سیٹس + Leading presets لیڈنگ پری سیٹس + Tracking presets ٹریکنگ پری سیٹس + Color Aware رنگ سے آگاہ + Decontaminate Colors رنگ آلودگی دور کریں + Edge Detection کنارہ کی شناخت + Global Refinements عالمی بہتری + High Quality Preview اعلی معیار پیش منظر + Object Aware آبجیکٹ سے آگاہ + Real-Time Refinement ریئل ٹائم بہتری + Refine Edge Brush Tool کنارہ بہتر کرنے والا برش ٹول + Refine Hair بال بہتر کریں + Refine Mode بہتری کا موڈ + Remember Settings ترتیبات یاد رکھیں + View Mode منظر کا موڈ + Object Aware needs a segmentation model, which isn't available yet آبجیکٹ سے آگاہ کو سیگمنٹیشن ماڈل درکار ہے، جو ابھی دستیاب نہیں + Refine Hair needs hair detection, which isn't available yet بال بہتر کرنے کے لیے بال کی شناخت درکار ہے، جو ابھی دستیاب نہیں + Select and Mask was closed: its document is no longer the active one منتخب کریں اور ماسک کریں بند ہو گیا: اس کی دستاویز مزید فعال نہیں ہے + Select and Mask was closed: another layer was selected منتخب کریں اور ماسک کریں بند ہو گیا: ایک اور تہہ منتخب کی گئی + Select Layer Under Cursor کرسر کے نیچے تہہ منتخب کریں + Drag to move the selected path منتخب پاتھ منتقل کرنے کے لیے گھسیٹیں + Drag to move the active shape's path or the Work Path فعال شکل کا پاتھ یا ورک پاتھ منتقل کرنے کے لیے گھسیٹیں + Export As: {name} بطور برآمد کریں: {name} + Color Sampler Tool کلر سیمپلر ٹول + Proof Setup… پروف سیٹ اپ… + Toggle Layer Mask Overlay تہہ ماسک اوورلے تبدیل کریں + Increase Type Size قسم کا حجم بڑھائیں + Decrease Type Size قسم کا حجم کم کریں + Increase Type Size by 10 قسم کا حجم 10 سے بڑھائیں + Decrease Type Size by 10 قسم کا حجم 10 سے کم کریں + Make Selection from Path پاتھ سے انتخاب بنائیں + Target Composite Channel ہدف کمپوزٹ چینل + Target Channel 3 ہدف چینل 3 + Target Channel 4 ہدف چینل 4 + Target Channel 5 ہدف چینل 5 + Target Channel 6 ہدف چینل 6 + Target Channel 7 ہدف چینل 7 + Target Channel 8 ہدف چینل 8 + Target Channel 9 ہدف چینل 9 + Fill with Foreground Color پیش منظر رنگ سے بھریں + Fill with Background Color پس منظر رنگ سے بھریں + Fill with Foreground Color, Preserve Transparency پیش منظر رنگ سے بھریں، شفافیت برقرار رکھیں + Fill with Background Color, Preserve Transparency پس منظر رنگ سے بھریں، شفافیت برقرار رکھیں + Temporary عارضی + Space Space + Hand Tool (hold) ہینڈ ٹول (دبائے رکھیں) + Zoom In (hold, drag to scrub) زوم ان (دبائے رکھیں، اسکرب کے لیے گھسیٹیں) + Zoom Out (hold) زوم آؤٹ (دبائے رکھیں) + Erase with Brush or Pencil (hold) برش یا پنسل سے مٹائیں (دبائے رکھیں) + Author Title مصنف کا عنوان + Copyright Url کاپی رائٹ URL + Title عنوان + Working-cmyk ورکنگ-cmyk + Coated-cmyk کوٹڈ-cmyk + Linear-srgb لکیری-srgb + Gray-gamma-2.2 گرے-گاما-2.2 + Adobe-rgb-compat Adobe-rgb-مطابق + Prophoto-compat Prophoto-مطابق + Display-p3 Display-p3 + Intent ارادہ + Bpc Bpc + Simulate Paper کاغذ کی تقلید کریں + Warming Filter (85) وارمنگ فلٹر (85) + Cw Cw + Ccw Ccw + Nearest قریب ترین + Mask Channel ماسک چینل + R G B R G B + Source Channel ماخذ چینل + Source Invert ماخذ الٹائیں + Source1 Channel ماخذ1 چینل + Source1 Invert ماخذ1 الٹائیں + Source2 Channel ماخذ2 چینل + Source2 Invert ماخذ2 الٹائیں + W: W: + A: A: + Pattern Fill… نمونہ بھرائی… + Any کوئی بھی + Cmyk Cmyk + Dont Enlarge بڑا نہ کریں + Change Find تلاش تبدیل کریں + Case Sensitive حروف کی بڑائی چھوٹائی کا خیال + Whole Word مکمل لفظ + Forward آگے + Based On کی بنیاد پر + This Layer: یہ تہہ: + Checkerboard چیکر بورڈ + Arc Lower آرک لوئر + Arc Upper آرک اپر + Bend موڑ + Horizontal Distortion افقی بگاڑ + Vertical Distortion عمودی بگاڑ + Move Up اوپر منتقل کریں + Move Down نیچے منتقل کریں + CPU Rendering CPU رینڈرنگ + Calculating… حساب ہو رہا ہے… + Click to refresh histogram with uncached data بغیر کیشے کے ڈیٹا کے ساتھ ہسٹوگرام تازہ کرنے کے لیے کلک کریں + Count: شمار: + Drawn on the CPU: {reason}. Click for System Info. CPU پر ڈرائن ہوا: {reason}۔ سسٹم کی معلومات کے لیے کلک کریں۔ + Level: سطح: + Percentile: پرسنٹائل: + Release Notes ریلیز نوٹس + Show Channels in Color چینلز کو رنگ میں دکھائیں + Show Statistics اعداد و شمار دکھائیں + Source: ماخذ: + Uncached Refresh بغیر کیشے کے تازہ کریں + Update and Restart اپ ڈیٹ کریں اور دوبارہ شروع کریں + +# Color picker, gradient map and font list strings added on main + No matching fonts کوئی مماثل فونٹ نہیں + Recently Used Colors حال ہی میں استعمال شدہ رنگ + Shapes can't use patterns yet شکلیں ابھی نمونے استعمال نہیں کر سکتیں + Stop Color اسٹاپ کا رنگ + +# Strings added on main after the catalog was completed (2026-11-06) + A command was cancelled or stopped during batch processing. Continue with the next file or stop? بیچ پروسیسنگ کے دوران ایک کمانڈ منسوخ یا روک دی گئی۔ اگلی فائل کے ساتھ جاری رکھیں یا روک دیں؟ + A file is still being placed; try again when it has finished. ایک فائل ابھی رکھی جا رہی ہے؛ مکمل ہونے کے بعد دوبارہ کوشش کریں۔ + A frame supports affine transforms; select its image for perspective transforms فریم صرف affine تبدیلیوں کی حمایت کرتا ہے؛ پرسپیکٹو تبدیلیوں کے لیے اس کی تصویر منتخب کریں + A frame supports affine transforms; select its image to warp it فریم صرف affine تبدیلیوں کی حمایت کرتا ہے؛ وارپ کرنے کے لیے اس کی تصویر منتخب کریں + Accelerated تیز رفتار + Answer the open import question first, then import this file again. پہلے درآمد کا زیرِ التوا سوال جواب دیں، پھر یہ فائل دوبارہ درآمد کریں۔ + Arrowhead options تیر کے سرے کے اختیارات + Arrowheads تیر کے سرے + Attribute خصوصیت + Background (default) پس منظر (ڈیفالٹ) + Batch preview بیچ کا پیش منظر + Cancelled placing {name} {name} کو رکھنا منسوخ ہو گیا + Channel Options… چینل کے اختیارات… + Concavity مقعریت + Convert to Frame… فریم میں تبدیل کریں… + Create a new custom frame نیا حسبِ ضرورت فریم بنائیں + Create a new polygonal frame نیا کثیرالاضلاع فریم بنائیں + Create a new triangular frame نیا مثلثی فریم بنائیں + Create a single history state ایک واحد تاریخ کی حالت بنائیں + Data Sets as Files is unavailable on the web ڈیٹا سیٹس بطور فائلیں ویب پر دستیاب نہیں + Dropped files weren't placed: their document was closed. چھوڑی گئی فائلیں نہیں رکھی گئیں: ان کی دستاویز بند ہو چکی تھی۔ + Dropped files weren't placed: too many are waiting for their document. چھوڑی گئی فائلیں نہیں رکھی گئیں: بہت سی فائلیں اپنی دستاویز کی منتظر ہیں۔ + Effect اثر + Empty خالی + End اختتام + Filter for embedded smart objects ایمبیڈڈ سمارٹ آبجیکٹس کے لیے فلٹر کریں + Filter for linked smart objects that are missing غائب لنک شدہ سمارٹ آبجیکٹس کے لیے فلٹر کریں + Filter for smart objects linked locally مقامی طور پر لنک شدہ سمارٹ آبجیکٹس کے لیے فلٹر کریں + Fit Content to Frame مواد کو فریم میں فٹ کریں + Fit Frame to Content فریم کو مواد میں فٹ کریں + Frame coordinates must be finite and within 600000 pixels فریم کے نقاط محدود اور 600000 پکسلز کے اندر ہونے چاہئیں + High Memory Photoshop Document زیادہ میموری والی Photoshop دستاویز + Image Compression تصویر کی کمپریشن + Import Image… تصویر درآمد کریں… + Import as New Group نئے گروپ کے طور پر درآمد کریں + Import as New Group keeps “{group}” and adds the file as a new group beside it. نئے گروپ کے طور پر درآمد “{group}” کو برقرار رکھتا ہے اور فائل کو اس کے ساتھ ایک نئے گروپ کے طور پر شامل کرتا ہے۔ + Invalid frame transform فریم کی غلط تبدیلی + Invalid nested file drop غلط نیسٹڈ فائل ڈراپ + LZW LZW + Layer Effects تہہ کے اثرات + Learn سیکھیں + Lining لائننگ + Linked لنک شدہ + New Frame نیا فریم + New file نئی فائل + No Adv. Blending ایڈوانسڈ امتزاج نہیں + No Layer Effects تہہ کے اثرات نہیں + No Layer Mask تہہ ماسک نہیں + No Vector Mask ویکٹر ماسک نہیں + No import is waiting. Import the file again. کوئی درآمد زیرِ التوا نہیں۔ فائل دوبارہ درآمد کریں۔ + Not Clipped کلپڈ نہیں + Not Empty خالی نہیں + Not Linked لنک شدہ نہیں + Not Locked مقفل نہیں + Not Visible نظر نہ آنے والا + Numerals اعداد + On your computer آپ کے کمپیوٹر پر + Open anyway پھر بھی کھولیں + Opening this document uses more memory and may slow down your computer or other running applications. یہ دستاویز کھولنے میں زیادہ میموری استعمال ہوتی ہے اور یہ آپ کے کمپیوٹر یا دیگر چلتی ایپلیکیشنز کو سست کر سکتی ہے۔ + Opening “{name}” requires an estimated {est} GiB of memory ({avail} GiB available). “{name}” کھولنے کے لیے اندازاً {est} GiB میموری درکار ہے ({avail} GiB دستیاب)۔ + Opening “{name}” requires an estimated {est} GiB of memory. The available memory could not be measured. “{name}” کھولنے کے لیے اندازاً {est} GiB میموری درکار ہے۔ دستیاب میموری کی پیمائش نہیں ہو سکی۔ + Pause For اس کے لیے توقف + Pause For: اس کے لیے توقف: + Playback Options… پلے بیک کے اختیارات… + Proportional متناسب + Proportional Lining متناسب لائننگ + Proportional Oldstyle متناسب پرانے انداز + Replace Image… تصویر تبدیل کریں… + Replace deletes everything in “{group}” first, including presets you edited or moved there. This can't be undone. تبدیل کرنا پہلے “{group}” میں موجود سب کچھ حذف کر دیتا ہے، بشمول وہ پری سیٹس جو آپ نے وہاں ترمیم یا منتقل کیے۔ اسے واپس نہیں کیا جا سکتا۔ + Save Image Pyramid تصویری اہرام محفوظ کریں + Save Transparency شفافیت محفوظ کریں + Set سیٹ + Set 1 سیٹ 1 + Set 10 سیٹ 10 + Set 11 سیٹ 11 + Set 12 سیٹ 12 + Set 13 سیٹ 13 + Set 14 سیٹ 14 + Set 15 سیٹ 15 + Set 16 سیٹ 16 + Set 17 سیٹ 17 + Set 18 سیٹ 18 + Set 19 سیٹ 19 + Set 2 سیٹ 2 + Set 20 سیٹ 20 + Set 3 سیٹ 3 + Set 4 سیٹ 4 + Set 5 سیٹ 5 + Set 6 سیٹ 6 + Set 7 سیٹ 7 + Set 8 سیٹ 8 + Set 9 سیٹ 9 + Set as default ڈیفالٹ کے طور پر سیٹ کریں + Source: {name} ماخذ: {name} + Specify Channels: چینلز متعین کریں: + Start آغاز + Step by Step قدم بہ قدم + Stylistic Sets اسلوبیاتی سیٹس + Tabular جدولی + Tabular Lining جدولی لائننگ + Tabular Oldstyle جدولی پرانے انداز + The GPU compositor is still being prepared GPU کمپوزیٹر ابھی تیار ہو رہا ہے + The History Brush source was deleted: set a new source in the History panel ہسٹری برش کا ماخذ حذف کر دیا گیا: ہسٹری پینل میں نیا ماخذ مقرر کریں + The frame is locked فریم مقفل ہے + The frame is unavailable فریم دستیاب نہیں + Transparent (default) شفاف (ڈیفالٹ) + Use these settings for new arrows نئے تیروں کے لیے یہ ترتیبات استعمال کریں + Website ویب سائٹ + ZIP ZIP + seconds سیکنڈ + {message} into “{group}” {message} “{group}” میں + {ok} written to {dir} {ok} کو {dir} میں لکھا گیا + {set} from {file} replaced the one from {earlier} {file} سے {set} نے {earlier} والے کی جگہ لے لی +@plural The brush group “{group}” already has {n} brush of yours.|The brush group “{group}” already has {n} brushes of yours. برش گروپ “{group}” میں پہلے ہی آپ کا {n} برش موجود ہے۔|برش گروپ “{group}” میں پہلے ہی آپ کے {n} برشز موجود ہیں۔ +@plural The gradient group “{group}” already exists ({n} gradient).|The gradient group “{group}” already exists ({n} gradients). گریڈیئنٹ گروپ “{group}” پہلے سے موجود ہے ({n} گریڈیئنٹ)۔|گریڈیئنٹ گروپ “{group}” پہلے سے موجود ہے ({n} گریڈیئنٹس)۔ +@plural {n} action file loaded|{n} action files loaded {n} ایکشن فائل لوڈ ہوئی|{n} ایکشن فائلیں لوڈ ہوئیں +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} فائل لوڈ نہیں ہو سکی: {errors}|{n} فائلیں لوڈ نہیں ہو سکیں: {errors} +@plural {what}: {n} Image Assets output failed|{what}: {n} Image Assets outputs failed {what}: {n} تصویری اثاثہ آؤٹ پٹ ناکام ہوا|{what}: {n} تصویری اثاثے آؤٹ پٹس ناکام ہوئے diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d868ab099a..f1e4723709 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -188,10 +188,15 @@ Check Spelling… Kiểm tra chính tả… Clear Xóa Clear Recent Files Xóa tệp gần đây + Clear Recent File List Xóa tệp gần đây Clipboard Bảng tạm Close Đóng Close All Đóng tất cả Close Others Đóng các tệp khác + New Document… Tài liệu mới… + Open Document… Mở… + Consolidate All to Here Gom tất cả vào đây + Move to New Window Di chuyển sang cửa sổ mới Color Lookup Tables… Bảng tra màu… Color Settings… Cài đặt màu… Conditional Mode Change… Đổi chế độ có điều kiện… @@ -275,6 +280,7 @@ Place Embedded… Đặt nhúng… Place Linked… Đặt liên kết… Plug-ins… Plug-in… + Plugins… Plug-in… Preferences Tùy chọn Preset Manager… Quản lý thiết lập sẵn… Presets Thiết lập sẵn @@ -373,6 +379,8 @@ Color Table… Bảng màu… Combine Shapes Kết hợp hình Convert to Embedded Chuyển thành nhúng + Embed Linked Nhúng mục liên kết + Embed All Linked Nhúng tất cả mục liên kết Convert to Layers Chuyển thành lớp Convert to Linked… Chuyển thành liên kết… Convert to Smart Object Chuyển thành đối tượng thông minh @@ -403,7 +411,10 @@ Enable Vector Mask Bật mặt nạ vector Entropy Entropy Equalize Cân bằng histogram + Equalize Entire Image Based On Selected Area Cân bằng histogram toàn bộ hình ảnh theo vùng chọn + Equalize Selected Area Only Chỉ cân bằng histogram vùng chọn Exclude Overlapping Shapes Loại phần chồng hình + Subtract Shapes at Overlap Loại phần chồng hình Export Contents… Xuất nội dung… Exposure… Phơi sáng… Fill Content Nội dung tô @@ -438,6 +449,7 @@ Insert Blank Frame Chèn khung trống Interpret Footage… Diễn giải footage… Intersect Shape Areas Giao vùng hình + Unite Shapes at Overlap Giao vùng hình Invert Đảo màu Invert… Đảo màu… Kurtosis Kurtosis @@ -568,7 +580,6 @@ Grow Mở rộng theo màu Horizontal Ngang Huge Cực lớn - Inverse Selection Đảo vùng chọn Isolate Layers Cô lập lớp Language Options Tùy chọn ngôn ngữ Large Lớn @@ -712,8 +723,8 @@ Clear Guides Xóa đường căn Clear Selected Artboard Guides Xóa đường căn artboard đã chọn Clear Slices Xóa lát cắt - Color Blindness — Deuteranopia-type Mù màu — kiểu Deuteranopia - Color Blindness — Protanopia-type Mù màu — kiểu Protanopia + Color Blindness - Deuteranopia-type Mù màu - kiểu Deuteranopia + Color Blindness - Protanopia-type Mù màu - kiểu Protanopia Count Đếm Custom Pixel Aspect Ratio… Tỷ lệ khung pixel tùy chỉnh… D1/DV NTSC (0.91) D1/DV NTSC (0.91) @@ -733,10 +744,10 @@ Grid Lưới Guides Đường căn HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) - Internet Standard RGB RGB chuẩn Internet + Internet Standard RGB (sRGB) RGB chuẩn Internet (sRGB) Layer Edges Cạnh lớp Layers Các lớp - Legacy Macintosh RGB RGB Macintosh cũ + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh cũ (Gamma 1.8) Lock Guides Khóa đường căn Lock Slices Khóa lát cắt Mesh Lưới @@ -766,7 +777,7 @@ Standard Screen Mode Chế độ màn hình chuẩn Target Path Đường dẫn đích Working Black Plate Bản Black làm việc - Working CMY Plate Bản CMY làm việc + Working CMY Plates Bản CMY làm việc Working CMYK CMYK làm việc Working Cyan Plate Bản Cyan làm việc Working Magenta Plate Bản Magenta làm việc @@ -813,7 +824,7 @@ Modifier Keys Phím bổ trợ Motion Chuyển động Navigator Điều hướng - New Window for Document Cửa sổ mới cho tài liệu + New Window Cửa sổ mới New Workspace… Không gian làm việc mới… Next Theme Chủ đề tiếp theo Options Thanh tùy chọn @@ -844,6 +855,12 @@ Timeline Dòng thời gian Tool Presets Thiết lập sẵn công cụ Tools Công cụ + Contextual Task Bar Thanh tác vụ theo ngữ cảnh + Hide bar Ẩn thanh + Reset bar position Đặt lại vị trí thanh + Pin bar position Ghim vị trí thanh + Move bar Di chuyển thanh + More options Tùy chọn khác Workspace Không gian làm việc Move Tool Công cụ Di chuyển Rectangular Marquee Tool Công cụ Vùng chọn chữ nhật @@ -893,8 +910,8 @@ Custom Shape Tool Công cụ Hình tùy chỉnh Set foreground color Đặt màu nền trước Set background color Đặt màu nền - Swap colours (X) Đổi màu (X) - Default colours (D) Màu mặc định (D) + Switch Foreground and Background Colors (X) Đổi chỗ màu trước và màu sau (X) + Default Foreground and Background Colors (D) Màu trước và màu sau mặc định (D) Edit Toolbar… Sửa thanh công cụ… Edit in Quick Mask Mode (Q) Chỉnh sửa ở chế độ Mặt nạ nhanh (Q) Edit in Standard Mode (Q) Chỉnh sửa ở chế độ chuẩn (Q) @@ -1016,9 +1033,9 @@ Drag to move the active layer Kéo để di chuyển lớp đang chọn Drag to pan · hold Space with any tool Kéo để xem toàn cảnh · giữ Space với mọi công cụ Drag to draw a gradient Kéo để vẽ chuyển sắc - Click to fill similar colours Bấm để tô các màu tương tự + Click to fill similar colors Bấm để tô các màu tương tự Click to add text Bấm để thêm chữ - Click to sample the foreground colour · {key}-click for background Bấm để lấy mẫu màu nền trước · {key}-bấm cho nền + Click to sample the foreground color · {key}-click for background Bấm để lấy mẫu màu nền trước · {key}-bấm cho nền Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Bấm để phóng to · {key}-bấm để thu nhỏ · kéo phải/trái để thu phóng Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Kéo hoặc bấm điểm đa giác · giữ Alt khi Lasso để có đoạn thẳng · {add} thêm · {sub} trước khi vẽ sẽ trừ Hold Alt while drawing for straight segments Giữ Alt khi vẽ để có đoạn thẳng @@ -1095,7 +1112,6 @@ Release Clipping Mask Nhả mặt nạ cắt Merge Down Gộp xuống Merge Group Gộp nhóm - Quick Export As PNG Xuất nhanh dạng PNG Rename Layer… Đổi tên lớp… Frame from Layers… Frame từ các lớp… Create new document from current state Tạo tài liệu mới từ trạng thái hiện tại @@ -1348,7 +1364,7 @@ Diamond Hình thoi Align with layer Căn với lớp Reset Alignment Đặt lại căn chỉnh - Centre the gradient (offset 0, 0) Căn giữa chuyển sắc (dịch lệch 0, 0) + Center the gradient (offset 0, 0) Căn giữa chuyển sắc (dịch lệch 0, 0) The current gradient (pick one in Window › Gradients) Chuyển sắc hiện tại (chọn trong Cửa sổ › Chuyển sắc) Click to edit the gradient Bấm để chỉnh chuyển sắc Create new gradient Tạo chuyển sắc mới @@ -1388,6 +1404,7 @@ ✓ Color Indicates Selected Areas ✓ Màu chỉ vùng được chọn Work Path Đường dẫn làm việc Make Selection Tạo vùng chọn + Make Shape Tạo hình Fill Path Tô đường dẫn Stroke Path Đi nét đường dẫn Save Path Lưu đường dẫn @@ -1605,6 +1622,7 @@ cameraRaw Shadows Vùng tối Picas Pica Pixels Pixel Plug-ins Plug-in + Plugins Plug-in Png PNG Point size Cỡ point Points Point @@ -1685,7 +1703,7 @@ cameraRaw Shadows Vùng tối Manage submitted jobs in the system print queue Quản lý các lệnh in đã gửi trong hàng đợi in của hệ thống Esc to cancel Esc để hủy Cancel opening Hủy mở tệp - Cancelled opening {name} Đã hủy mở {name} + Canceled opening {name} Đã hủy mở {name} Damaged Photoshop document Tài liệu Photoshop bị hỏng The document “{name}” may be damaged. Tài liệu “{name}” có thể đã bị hỏng. Some layers, pixels or masks could not be read. Do you want to continue opening it? Không thể đọc một số lớp, pixel hoặc mặt nạ. Bạn có muốn tiếp tục mở không? @@ -1694,10 +1712,10 @@ cameraRaw Shadows Vùng tối The document “{name}” does not have an embedded {mode} profile. Tài liệu “{name}” không có hồ sơ {mode} nhúng. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Tài liệu “{name}” có hồ sơ màu nhúng không khớp với không gian làm việc {mode} hiện tại. Working: {working} Không gian làm việc: {working} - Cancelled {label} Đã hủy {label} + Canceled {label} Đã hủy {label} Cancel transform (Esc) Hủy biến đổi (Esc) Cancel warp (Esc) Hủy biến dạng (Esc) - Save cancelled Đã hủy lưu + Save canceled Đã hủy lưu Spell check complete. Đã kiểm tra chính tả xong. Save failed: {error} Không thể lưu: {error} Couldn't place {name}: {error} Không thể đặt {name}: {error} @@ -2039,11 +2057,11 @@ document Orientation Hướng trang Eyedropper Ống hút màu Add to Sample Thêm vào mẫu Subtract from Sample Bớt khỏi mẫu - Sample: foreground colour Mẫu: màu nền trước + Sample: foreground color Mẫu: màu nền trước {added}, {removed} subtracted {added}, đã bớt {removed} Clear Samples Xóa mẫu Selection Vùng chọn - Open a document to select a colour range. Mở một tài liệu để chọn phạm vi màu. + Open a document to select a color range. Mở một tài liệu để chọn phạm vi màu. Sampled Colors Màu lấy mẫu Reds Đỏ Yellows Vàng @@ -2108,7 +2126,7 @@ layers Fill: Độ tô: Channels: Kênh: Click a red pupil to neutralize it Nhấp vào đồng tử đỏ để trung hòa Click to pick · Esc to cancel Nhấp để chọn · Esc để hủy - Click to restore the current colour Nhấp để khôi phục màu hiện tại + Click to restore the current color Nhấp để khôi phục màu hiện tại Clone source {n} ({key}-click with Clone Stamp to set) Nguồn nhân bản {n} ({key}+nhấp bằng Dấu nhân bản để đặt) Color Picker (Layer Style Color) Ống hút màu (Màu kiểu lớp) Color Picker (Text Color) Ống hút màu (Màu chữ) @@ -2164,13 +2182,13 @@ layers Fill: Độ tô: No subject found — try a rectangle around the object Không tìm thấy chủ thể — thử khung chữ nhật quanh đối tượng No subject found — try Object Selection or the Lasso Không tìm thấy chủ thể — thử Chọn đối tượng hoặc Lasso No tool presets. Không có thiết lập sẵn công cụ. - Not a web colour: click for the nearest web colour Không phải màu web: nhấp để lấy màu web gần nhất + Not a web color: click for the nearest web color Không phải màu web: nhấp để lấy màu web gần nhất Now drag the selection onto another area Bây giờ kéo vùng chọn sang vùng khác Object Selection found nothing there — try a tighter rectangle Chọn đối tượng không tìm thấy gì — thử khung chữ nhật chặt hơn Only Web Colors Chỉ màu web Open paths use centered strokes Đường dẫn mở dùng nét căn giữa Option-click to define a source point to clone from Option+nhấp để chỉ định điểm nguồn nhân bản - Out of gamut for printing: click for the nearest printable colour Ngoài gamut in: nhấp để lấy màu in được gần nhất + Out of gamut for printing: click for the nearest printable color Ngoài gamut in: nhấp để lấy màu in được gần nhất Pick screen color Lấy màu màn hình Pick Color From Image Lấy màu từ hình ảnh Point Sample Mẫu điểm @@ -2213,7 +2231,7 @@ layers Fill: Độ tô: Unavailable in this color mode Không dùng được ở chế độ màu này Update the current brush with these settings Cập nhật cọ hiện tại với các thiết lập này Use finite lengths and a positive pattern total Dùng độ dài hữu hạn và tổng họa tiết dương - Uses the foreground and background colours. Dùng màu nền trước và màu nền sau. + Uses the foreground and background colors. Dùng màu nền trước và màu nền sau. Weight Độ dày Zoom in Phóng to Zoom out Thu nhỏ @@ -2300,7 +2318,7 @@ layers Fill: Độ tô: Untagged {mode} {mode} chưa gắn hồ sơ Use File › Open, or paste a copied image with {paste}. Dùng Tệp › Mở, hoặc dán ảnh đã sao chép bằng {paste}. Vector Mask from Current Path Mặt nạ vector từ đường dẫn hiện tại - {label} is writing the file and can no longer be cancelled {label} đang ghi tệp và không thể hủy nữa + {label} is writing the file and can no longer be canceled {label} đang ghi tệp và không thể hủy nữa {name} options Tùy chọn {name} {n} commands are not in PhotoCraft. {n} lệnh không có trong PhotoCraft. {size} (was {old}) {size} (trước là {old}) @@ -2614,6 +2632,7 @@ shape Appearance Diện mạo (coming soon) (sắp có) Add beauty precomp from the AOVs Thêm precomp beauty từ các AOV Show in Explorer Hiện trong Explorer + Reveal in Explorer Hiện trong Explorer Show in Folder Hiện trong thư mục Delete and Fill Selection Xoá và tô vùng chọn Transform Again on a Copy Biến đổi lại trên một bản sao @@ -2699,6 +2718,7 @@ shape Appearance Diện mạo Measurement Scale Marker Dấu tỉ lệ đo Could not use the move tool because the layer is locked. Không thể dùng công cụ di chuyển vì lớp đã bị khoá. Could not complete the Free Transform command because the layer is locked. Không thể hoàn tất lệnh Biến đổi tự do vì lớp đã bị khoá. + Could not transform because the layer is locked. Không thể biến đổi vì lớp đã bị khoá. My Brushes Cọ của tôi Neutrals Vùng trung tính New Workspace Không gian làm việc mới @@ -2726,6 +2746,7 @@ shape Appearance Diện mạo Smart Filters Các bộ lọc thông minh Soft Mềm Source: set Nguồn: bộ + Source: {name} Nguồn: {name} Square, 12 x 12 Vuông, 12 x 12 Square, 5 x 5 Vuông, 5 x 5 Studio (Dark) Studio (Tối) @@ -2743,6 +2764,7 @@ shape Appearance Diện mạo Allow agent control Cho phép agent điều khiển Allow scripts to connect Cho phép script kết nối Always create smart objects when placing Luôn tạo đối tượng thông minh khi đặt + Skip transform when placing Bỏ qua biến đổi khi đặt Animated zoom Thu phóng có chuyển động Apply auto tone Áp dụng tự động tông Auto collapse icon panels Tự động thu gọn các bảng biểu tượng @@ -2754,6 +2776,11 @@ shape Appearance Diện mạo Show canvas dots Hiện chấm vùng vẽ Clip thumbnails to layer bounds Cắt hình thu nhỏ theo biên của lớp Clip Thumbnails to Layer Bounds Cắt hình thu nhỏ theo biên của lớp + No Thumbnails Không có hình thu nhỏ + Small Thumbnails Hình thu nhỏ cỡ nhỏ + Medium Thumbnails Hình thu nhỏ cỡ vừa + Large Thumbnails Hình thu nhỏ cỡ lớn + Clip Thumbnails to Document Bounds Cắt hình thu nhỏ theo biên của tài liệu Color picker Bộ chọn màu Color space Không gian màu Column width Độ rộng cột @@ -2881,7 +2908,7 @@ fontWeight ExtraBold Rất đậm fontWeight Black Đen fontWeight Italic Nghiêng Rotate View Tool Công cụ xoay khung nhìn - Drag around the centre to rotate the view · Shift constrains to 15° Kéo vòng quanh tâm để xoay khung nhìn · Shift giới hạn ở 15° + Drag around the center to rotate the view · Shift constrains to 15° Kéo vòng quanh tâm để xoay khung nhìn · Shift giới hạn ở 15° Transparency Threshold Ngưỡng trong suốt Opacity Threshold Ngưỡng độ mờ Rasterize svg groups deeper than Raster hoá các nhóm svg sâu hơn @@ -2928,7 +2955,7 @@ fontWeight Italic Nghiêng Dash Nét đứt Gap Khoảng hở {mode} Color · {bits} bit Màu {mode} · {bits} bit - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kéo hộp cắt · kéo bên trong để di chuyển · cạnh để đổi kích thước ({ratio} tỉ lệ, {centre} giữa) · Space di chuyển khi đang vẽ · phím mũi tên dịch nhẹ · X đổi chiều · {commit} chốt · Esc huỷ + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kéo hộp cắt · kéo bên trong để di chuyển · cạnh để đổi kích thước ({ratio} tỉ lệ, {centre} giữa) · Space di chuyển khi đang vẽ · phím mũi tên dịch nhẹ · X đổi chiều · {commit} chốt · Esc huỷ Camera Data Dữ liệu máy ảnh cameraData Make Hãng sản xuất cameraData Model Kiểu máy @@ -3660,7 +3687,6 @@ cameraData Lens Ống kính New Document With Mask Tài liệu mới có mặt nạ New Layer With Mask Lớp mới có mặt nạ Convert Point Tool Công cụ Chuyển đổi điểm - Enable animations Bật hiệu ứng chuyển động Gloss Contour Đường bao bóng Cone - Inverted Hình nón - Đảo ngược Cove - Deep Lõm - Sâu @@ -3744,6 +3770,8 @@ curves Smooth Làm mượt Clear History Xóa lịch sử History Options… Tùy chọn lịch sử… Background (Default) Nền (mặc định) + Background (default) Nền (mặc định) + Transparent (default) Trong suốt (mặc định) Allow Non-Linear History Cho phép lịch sử không tuyến tính Make Layer Visibility Changes Undoable Cho phép hoàn tác thay đổi hiển thị lớp Presentation Trình bày @@ -3754,10 +3782,14 @@ curves Smooth Làm mượt Auto Add/Delete Tự động thêm/xóa Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Đừng bật thêm Ảnh phản chiếu trong thiết lập in: hai lần lật sẽ triệt tiêu nhau. Print Settings… Thiết lập in… - Remember to turn off the printer's own colour management in Print Settings… Nhớ tắt quản lý màu riêng của máy in trong thiết lập in… + Remember to turn off the printer's own color management in Print Settings… Nhớ tắt quản lý màu riêng của máy in trong thiết lập in… Sent to {printer} Đã gửi tới {printer} set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel +# History panel › History Options… (#2966) + Automatically Create First Snapshot Tự động tạo ảnh chụp trạng thái đầu tiên + Automatically Create New Snapshot When Saving Tự động tạo ảnh chụp trạng thái mới khi lưu + Show New Snapshot Dialog by Default Mặc định hiện hộp thoại Ảnh chụp trạng thái mới Memory usage percent Tỷ lệ sử dụng bộ nhớ Show Favorite Fonts Hiển thị phông chữ yêu thích No favorite fonts yet: click the star beside a font to add it. Chưa có phông chữ yêu thích: nhấp vào ngôi sao bên cạnh phông chữ để thêm. @@ -3802,6 +3834,7 @@ curves Smooth Làm mượt Automatically Check for Updates Tự động kiểm tra bản cập nhật Paints from the History panel's source state Vẽ từ trạng thái nguồn của bảng Lịch sử Sets the source for the history brush Đặt nguồn cho cọ lịch sử + The History Brush source was deleted: set a new source in the History panel Nguồn của cọ lịch sử đã bị xóa: đặt nguồn mới trong bảng Lịch sử Highlight Mode Chế độ vùng sáng Highlight Color Màu vùng sáng Highlight Opacity Độ mờ vùng sáng @@ -3888,6 +3921,7 @@ curves Smooth Làm mượt {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau) Reference point location Vị trí điểm tham chiếu + Toggle reference point Bật/tắt điểm tham chiếu Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất. The transform couldn't be applied. Không thể áp dụng phép biến đổi. Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}. @@ -3909,12 +3943,12 @@ curves Smooth Làm mượt Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ Demosaic Giải khảm - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cách lấp đầy khảm lọc màu của máy ảnh. Áp dụng khi mở tệp; bản xem trước hiển thị phương pháp đã dùng khi mở. + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cách lấp đầy khảm lọc màu của máy ảnh. Áp dụng khi mở tệp; bản xem trước hiển thị phương pháp đã dùng khi mở. AHD (adaptive homogeneity) AHD (đồng nhất thích ứng) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: cạnh sạch nhất và ít viền màu nhất, chậm nhất. - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Nội suy hiệu chỉnh gradient: sắc nét và nhanh, có chút viền màu ở chi tiết nhỏ. - The fastest and the softest, with colour fringing on fine detail. Nhanh nhất và mềm nhất, có viền màu ở chi tiết nhỏ. + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks: cạnh sạch nhất và ít viền màu nhất, chậm nhất. + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. Nội suy hiệu chỉnh gradient: sắc nét và nhanh, có chút viền màu ở chi tiết nhỏ. + The fastest and the softest, with color fringing on fine detail. Nhanh nhất và mềm nhất, có viền màu ở chi tiết nhỏ. “{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế? The name in the save dialog was “{name}”. Tên trong hộp thoại lưu là “{name}”. Previous Brush Cọ trước @@ -4001,6 +4035,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Cỡ chữ đặt sẵn Leading presets Giãn dòng đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Giá trị số không hợp lệ. Cần một số nguyên từ {min}% đến {max}%. Đã chèn giá trị trước đó. Clear Selection Xóa vùng chọn Color Aware Nhận biết màu @@ -4027,3 +4063,72 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Màu điểm dừng Shapes can't use patterns yet Hình chưa thể dùng mẫu tô Edit Brush… Chỉnh sửa cọ… + +# Action Stop message (#8386) + Message Thông báo +# Playback Options (#5212) + Playback Options… Tùy chọn phát… + Playback Options Tùy chọn phát + Accelerated Tăng tốc + Step by Step Từng bước + Pause For: Tạm dừng trong: + Pause For Tạm dừng trong + seconds giây + Create a single history state Tạo một trạng thái lịch sử duy nhất + Generator enabled Đã bật bộ tạo + Move Up Di chuyển lên + Move Down Di chuyển xuống + Grid view Chế độ xem lưới + List view Chế độ xem danh sách + On your computer Trên máy tính của bạn + New file Tệp mới + Learn Học hỏi + No recent files Không có tệp gần đây + Website Trang web + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms Khung hỗ trợ các phép biến đổi affine; chọn ảnh trong khung để biến đổi phối cảnh + A frame supports affine transforms; select its image to warp it Khung hỗ trợ các phép biến đổi affine; chọn ảnh trong khung để làm cong + Convert to Frame… Chuyển thành khung… + Create a new custom frame Tạo khung tùy chỉnh mới + Create a new polygonal frame Tạo khung đa giác mới + Create a new triangular frame Tạo khung tam giác mới + Fit Content to Frame Khớp nội dung với khung + Fit Frame to Content Khớp khung với nội dung + Frame coordinates must be finite and within 600000 pixels Tọa độ khung phải hữu hạn và nằm trong giới hạn 600000 pixel + Import Image… Nhập ảnh… + Invalid frame transform Phép biến đổi khung không hợp lệ + Invalid nested file drop Thả tệp lồng nhau không hợp lệ + New Frame Khung mới + Replace Image… Thay ảnh… + The frame is locked Khung đã bị khóa + The frame is unavailable Khung không khả dụng + +# Channels panel menu (#11451) + Channel Options… Tùy chọn kênh… + Specify Channels: Chỉ định kênh: + Not enough grayscale documents for this mode. Không đủ tài liệu thang xám cho chế độ này. + Pick a different document for each channel. Chọn một tài liệu khác cho mỗi kênh. + A picked document is no longer open. Một tài liệu đã chọn không còn mở. + A picked document is no longer a grayscale document of the same size. Một tài liệu đã chọn không còn là tài liệu thang xám cùng kích thước. +# Move tool options bar (#11381) + Align top edges Căn các cạnh trên + Align vertical centers Căn các tâm dọc + Align bottom edges Căn các cạnh dưới + Distribute vertically Phân bố theo chiều dọc + Distribute horizontally Phân bố theo chiều ngang + Show layer bounds on hover Hiện ranh giới lớp khi di chuột + Expand layer group on click Mở rộng nhóm lớp khi nhấp + Show hover bounds from Layers Hiện ranh giới khi di chuột từ bảng Lớp + Saved Camera Raw settings to {file} Đã lưu cài đặt Camera Raw vào {file} + Save the settings to each raw's sidecar without opening it Lưu thiết lập vào tệp sidecar của từng ảnh raw mà không mở + No settings saved: these raws can't keep a sidecar here Không lưu được gì: các ảnh raw này không thể có tệp sidecar ở đây + Some settings could not be saved; the raws stay open Không thể lưu một số cài đặt; các ảnh raw vẫn mở + Nothing to save: these raws are at their default settings Không có gì để lưu: các ảnh raw này đang ở cài đặt mặc định +# Artboard tool options bar (#11385) + Distribute Widths Phân bố chiều rộng + Distribute Heights Phân bố chiều cao + Auto-nest Layers Tự động lồng các lớp + Auto-size Canvas Tự động đổi kích thước vùng vẽ + Keep Relative Position During Layer Reordering Giữ vị trí tương đối khi sắp xếp lại lớp + Shrinkwrap Canvas on Save Thu nhỏ vùng vẽ theo nội dung khi lưu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9b554acfa..28c6119f66 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -174,6 +174,7 @@ Right 右 Open Recent 打开最近文件 Clear Recent Files 清除最近文件 + Clear Recent File List 清除最近文件 Remove from Recent 从最近文件中移除 Remove {name} from Recent 从最近文件中移除 {name} Close 关闭 @@ -182,6 +183,10 @@ Restore 向下还原 Close All 关闭全部 Close Others 关闭其他 + New Document… 新建文档… + Open Document… 打开… + Consolidate All to Here 将所有窗口合并到此处 + Move to New Window 移到新窗口 Save 保存 Save As… 另存为… Save a Copy… 保存副本… @@ -234,6 +239,7 @@ Create Smart Object after Loading Layers 载入图层后创建智能对象 Reveal in Finder 在 Finder 中显示 Show in Explorer 在文件资源管理器中显示 + Reveal in Explorer 在文件资源管理器中显示 Show in Folder 在文件夹中显示 File Info… 文件信息… Print… 打印… @@ -323,6 +329,7 @@ Units & Rulers… 单位与标尺… Guides, Grid & Slices… 参考线、网格与切片… Plug-ins… 插件… + Plugins… 插件… Type… 文字… 3D… 3D… Enhanced Controls… 增强控件… @@ -378,6 +385,8 @@ Match Color… 匹配颜色… Replace Color… 替换颜色… Equalize 色调均化 + Equalize Selected Area Only 仅色调均化选区 + Equalize Entire Image Based On Selected Area 基于选区色调均化整个图像 Auto Tone 自动色调 Auto Contrast 自动对比度 Auto Color 自动颜色 @@ -482,6 +491,8 @@ Smart Object 智能对象 Convert to Smart Object 转换为智能对象 Convert to Embedded 转换为嵌入对象 + Embed Linked 嵌入链接对象 + Embed All Linked 嵌入所有链接对象 Convert to Layers 转换为图层 Convert to Linked… 转换为链接对象… Edit Contents 编辑内容 @@ -555,7 +566,9 @@ Unite Shapes 合并形状 Subtract Front Shape 减去前面形状 Intersect Shape Areas 形状区域相交 + Unite Shapes at Overlap 形状区域相交 Exclude Overlapping Shapes 排除重叠形状 + Subtract Shapes at Overlap 排除重叠形状 Merge Shape Components 合并形状组件 Path operations 路径操作 Align 对齐 @@ -632,7 +645,6 @@ Deselect 取消选择 Reselect 重新选择 Inverse 反向 - Inverse Selection 反选选区 Similar 选择相似 Color Range… 色彩范围… Focus Area… 焦点区域… @@ -740,6 +752,7 @@ Minimum… 最小值… Offset… 位移… Plug-ins 插件 + Plugins 插件 Proof Setup 校样设置 Proof Colors 校样颜色 Gamut Warning 色域警告 @@ -748,12 +761,12 @@ Working Magenta Plate 工作洋红版 Working Yellow Plate 工作黄色版 Working Black Plate 工作黑色版 - Working CMY Plate 工作 CMY 版 - Legacy Macintosh RGB 旧版 Macintosh RGB - Internet Standard RGB 互联网标准 RGB + Working CMY Plates 工作 CMY 版 + Legacy Macintosh RGB (Gamma 1.8) 旧版 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) 互联网标准 RGB (sRGB) Monitor RGB 显示器 RGB - Color Blindness — Protanopia-type 色盲 — 红色盲型 - Color Blindness — Deuteranopia-type 色盲 — 绿色盲型 + Color Blindness - Protanopia-type 色盲 - 红色盲型 + Color Blindness - Deuteranopia-type 色盲 - 绿色盲型 Zoom In 放大 Zoom Out 缩小 Fit on Screen 适合屏幕 @@ -842,7 +855,7 @@ Consolidate All to Tabs 将所有窗口合并为选项卡 Float in Window 在窗口中浮动 Float All in Windows 使所有文档在窗口中浮动 - New Window for Document 为文档新建窗口 + New Window 新建窗口 Workspace 工作区 Essentials 基本功能 Graphic and Web 图形与 Web @@ -905,6 +918,12 @@ Timeline 时间轴 Tool Presets 工具预设 Tools 工具 + Contextual Task Bar 上下文任务栏 + Hide bar 隐藏栏 + Reset bar position 重置栏位置 + Pin bar position 固定栏位置 + Move bar 移动栏 + More options 更多选项 About PhotoCraft 关于 PhotoCraft PhotoCraft Website PhotoCraft 网站 PhotoCraft on GitHub PhotoCraft 的 GitHub 页面 @@ -1084,7 +1103,7 @@ Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove 单击添加点 · 拖离、按 ⌘/Ctrl 单击或按 Delete 移除 Click to apply, ⌥-click to clear overrides, double-click for Style Options 单击应用,按 ⌥ 单击清除覆盖设置,双击打开样式选项 Click to edit the gradient 单击编辑渐变 - Click to restore the current colour 单击恢复当前颜色 + Click to restore the current color 单击恢复当前颜色 Click to zoom in · ⌥-click to zoom out 单击放大 · 按 ⌥ 单击缩小 Click with the Note tool to add a note. 使用注释工具单击以添加注释。 Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel 单击:角点 · 拖动:平滑点 · 单击起点:闭合 · ↩ 完成 · Esc 取消 @@ -1171,7 +1190,7 @@ Darks 暗部 Data Sets 数据组 Default 默认 - Default colours (D) 默认颜色(D) + Default Foreground and Background Colors (D) 默认颜色(D) Default printer 默认打印机 Default restored. 已恢复默认值。 Define 定义 @@ -1459,7 +1478,7 @@ Opacity Jitter 不透明度抖动 Opacity: 不透明度: Open a document to define variables. 打开文档以定义变量。 - Open a document to select a colour range. 打开文档以选择色彩范围。 + Open a document to select a color range. 打开文档以选择色彩范围。 Open a document to use the timeline. 打开文档以使用时间轴。 Open… ⌘O 打开… ⌘O Optical 视觉 @@ -1536,7 +1555,6 @@ Purity 纯度 Quality 品质 Quick Actions 快速操作 - Quick Export As PNG 快速导出为 PNG Radial Gradient 径向渐变 Radius 半径 Radius: 半径: @@ -1681,7 +1699,7 @@ Subtract from Sample 从采样中减去 Subtract from selection (⌥) 从选区中减去 (⌥) Suggestions: 建议: - Swap colours (X) 交换颜色 (X) + Switch Foreground and Background Colors (X) 交换颜色 (X) Swaps height and width 交换高度和宽度 Switch between free transform and warp modes 在自由变换和变形模式之间切换 Switch theme 切换主题 @@ -1789,7 +1807,7 @@ Choke 收缩 Classic 经典 Click to add text 单击以添加文字 - Click to fill similar colours 单击以填充相近颜色 + Click to fill similar colors 单击以填充相近颜色 Clone Stamp Tool 仿制图章工具 Color Burn 颜色加深 Color Dodge 颜色减淡 @@ -1894,6 +1912,7 @@ Move Tool 移动工具 Could not use the move tool because the layer is locked. 无法使用移动工具,因为图层已锁定。 Could not complete the Free Transform command because the layer is locked. 无法完成“自由变换”命令,因为图层已锁定。 + Could not transform because the layer is locked. 无法变换,因为图层已锁定。 Multiply 正片叠底 My Brushes 我的画笔 Neutrals 中性色 @@ -1952,7 +1971,7 @@ Rectangular Marquee Tool 矩形选框工具 Reds 红色 Reflected 对称 - Sample: foreground colour 采样:前景色 + Sample: foreground color 采样:前景色 Sampled Colors 采样颜色 Satin 光泽 Save Pixel Aspect Ratio 保存像素长宽比 @@ -1973,6 +1992,7 @@ Soften 柔化 Source 源 Source: set 源:已设置 + Source: {name} 源:{name} Sponge Tool 海绵工具 Spot Healing Brush Tool 污点修复画笔工具 Spread 扩展 @@ -1992,7 +2012,6 @@ Triangle Tool 三角形工具 Up 向上 Unsaved changes 未保存的更改 - Uses the foreground and background colours. 使用前景色和背景色。 Watercolour look: lighter interior, darker rims. 水彩效果:内部较浅,边缘较深。 Web Web Web Large Web 大尺寸 @@ -2029,6 +2048,7 @@ Allow scripts to connect 允许脚本连接 Always 始终 Always create smart objects when placing 置入时始终创建智能对象 + Skip transform when placing 置入时跳过变换 Animated zoom 动画缩放 Apply auto tone 应用自动色调 Ask 询问 @@ -2051,6 +2071,11 @@ Show canvas dots 显示画布圆点 Clip thumbnails to layer bounds 将缩略图裁剪到图层边界 Clip Thumbnails to Layer Bounds 将缩略图裁剪到图层边界 + No Thumbnails 无缩略图 + Small Thumbnails 小缩略图 + Medium Thumbnails 中缩略图 + Large Thumbnails 大缩略图 + Clip Thumbnails to Document Bounds 将缩略图裁剪到文档边界 Canvas color 画布颜色 Canvas custom color 画布自定颜色 Color picker 拾色器 @@ -2168,7 +2193,6 @@ Show only crosshair while painting 绘画时仅显示十字线 Show slice numbers 显示切片编号 Show tooltips 显示工具提示 - Enable animations 启用动画 Show highlight on rollover 鼠标悬停时显示高亮 Show bounding box when dragging layer 拖动图层时显示边界框 System title bar 系统标题栏 @@ -2315,7 +2339,7 @@ cameraRaw Blending 混合 Graphics 图形 Reset GPU Backend 重置 GPU 后端 Applies at next launch. 下次启动时生效。 - Click to sample the foreground colour · {key}-click for background 单击拾取前景色 · 按 {key} 单击拾取背景色 + Click to sample the foreground color · {key}-click for background 单击拾取前景色 · 按 {key} 单击拾取背景色 {key}-click to set the source 按 {key} 单击设置源点 {key} to subtract 按 {key} 从选区中减去 Clone source {n} ({key}-click with Clone Stamp to set) 仿制源 {n}(使用仿制图章时按 {key} 单击设置) @@ -2374,7 +2398,7 @@ cameraRaw Blending 混合 Gradient Options 渐变选项 Align with layer 与图层对齐 Reset Alignment 重置对齐 - Centre the gradient (offset 0, 0) 将渐变居中(偏移量 0, 0) + Center the gradient (offset 0, 0) 将渐变居中(偏移量 0, 0) Gradient Editor 渐变编辑器 Gradient picker 渐变拾色器 Save As Preset 存储为预设 @@ -2406,7 +2430,7 @@ cameraRaw Blending 混合 Vulkan Vulkan No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 没有可用于生成选区的路径:请用钢笔工具绘制路径,或在路径面板中选择路径。 Cancel opening 取消打开 - Cancelled opening {name} 已取消打开 {name} + Canceled opening {name} 已取消打开 {name} Damaged Photoshop document 已损坏的 Photoshop 文档 The document “{name}” may be damaged. 文档“{name}”可能已损坏。 Some layers, pixels or masks could not be read. Do you want to continue opening it? 无法读取某些图层、像素或蒙版。是否继续打开? @@ -2416,7 +2440,7 @@ cameraRaw Blending 混合 The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文档“{name}”嵌入的颜色配置文件与当前的 {mode} 工作空间不匹配。 Working: {working} 工作空间:{working} Developing {name} 正在处理 {name} - Cancelled {label} 已取消 {label} + Canceled {label} 已取消 {label} Esc to cancel 按 Esc 取消 Opened {name} 已打开 {name} Opening {name} 正在打开 {name} @@ -2424,7 +2448,7 @@ cameraRaw Blending 混合 Saving {name} 正在保存 {name} Saving {percent} 正在保存 {percent} A save to {name} is still running; try again when it finishes 仍在保存到 {name};请在完成后重试 - {label} is writing the file and can no longer be cancelled {label} 正在写入文件,已无法取消 + {label} is writing the file and can no longer be canceled {label} 正在写入文件,已无法取消 Opening {name}… 正在打开 {name}… Opening… 正在打开… Reading… 正在读取… @@ -2474,7 +2498,7 @@ cameraRaw Blending 混合 Discard Layers and Save a Copy 扔掉图层并存储副本 Don't show again 不再显示 Save failed: {error} 存储失败:{error} - Save cancelled 已取消存储 + Save canceled 已取消存储 Magnetic Lasso Tool 磁性套索工具 Frequency 频率 Use tablet pressure to change pen width 使用绘图板压力以更改钢笔宽度 @@ -2608,7 +2632,7 @@ fontWeight Italic 斜体 Rotate View 旋转视图 Rotate View Tool 旋转视图工具 Reset View 重置视图 - Drag around the centre to rotate the view · Shift constrains to 15° 绕中心拖动以旋转视图 · Shift 限制为 15° + Drag around the center to rotate the view · Shift constrains to 15° 绕中心拖动以旋转视图 · Shift 限制为 15° Dual Axis 双轴 Diagonal 对角线 Transform Symmetry 变换对称轴 @@ -2623,8 +2647,8 @@ fontWeight Italic 斜体 All Layers No Adjustments 所有图层(不含调整) Current & Below No Adjustments 当前及下方图层(不含调整) Show Sampling Ring 显示采样环 - Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色 - Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色 + Out of gamut for printing: click for the nearest printable color 超出打印色域:单击使用最接近的可打印颜色 + Not a web color: click for the nearest web color 不是 Web 颜色:单击使用最接近的 Web 颜色 Color to Alpha… 颜色转为 Alpha… Transparency Threshold 透明度阈值 Opacity Threshold 不透明度阈值 @@ -2718,7 +2742,7 @@ fontWeight Italic 斜体 How often fastening points are placed by themselves 自动放置紧固点的频率 Scrubby Zoom 细微缩放 {mode} Color · {bits} bit {mode} 颜色 · {bits} 位 - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 拖移绘制裁剪框 · 在框内拖移以移动 · 拖移边缘调整大小({ratio} 比例,{centre} 中心)· 绘制时按 Space 移动 · 方向键微移 · X 切换方向 · {commit} 确认 · Esc 取消 + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 拖移绘制裁剪框 · 在框内拖移以移动 · 拖移边缘调整大小({ratio} 比例,{centre} 中心)· 绘制时按 Space 移动 · 方向键微移 · X 切换方向 · {commit} 确认 · Esc 取消 Appearance Settings… 外观设置… Add to favorites 添加到收藏 Remove from favorites 从收藏中移除 @@ -2938,15 +2962,20 @@ curves Smooth 平滑 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 Background (Default) 背景(默认) + Background (default) 背景(默认) + Transparent (default) 透明(默认) Content-Aware Fill 内容识别填充 Include All Subfolders 包含所有子文件夹 Auto Add/Delete 自动添加/删除 Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 不要同时勾选打印设置中的镜像:两次翻转会相互抵消。 Print Settings… 打印设置… - Remember to turn off the printer's own colour management in Print Settings… 请记得在打印设置中关闭打印机自身的色彩管理… + Remember to turn off the printer's own color management in Print Settings… 请记得在打印设置中关闭打印机自身的色彩管理… Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + Automatically Create First Snapshot 自动创建第一幅快照 + Automatically Create New Snapshot When Saving 存储时自动创建新快照 + Show New Snapshot Dialog by Default 默认显示新建快照对话框 Memory usage percent 内存使用比例 (%) Show Favorite Fonts 显示收藏的字体 No favorite fonts yet: click the star beside a font to add it. 还没有收藏的字体:点击字体旁边的星标即可添加。 @@ -2991,6 +3020,7 @@ curves Smooth 平滑 Automatically Check for Updates 自动检查更新 Paints from the History panel's source state 从历史记录面板的源状态绘画 Sets the source for the history brush 设置历史记录画笔的源 + The History Brush source was deleted: set a new source in the History panel 历史记录画笔的源已被删除:请在“历史记录”面板中设置新的源 Highlight Mode 高光模式 Highlight Color 高光颜色 Highlight Opacity 高光不透明度 @@ -3016,6 +3046,7 @@ curves Smooth 平滑 Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 Make Selection 建立选区 + Make Shape 建立形状 Bicubic Automatic 双三次(自动) Bicubic Smoother 双三次(较平滑) Bicubic Sharper 双三次(较锐利) @@ -3059,6 +3090,7 @@ curves Smooth 平滑 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) Reference point location 参考点位置 + Toggle reference point 切换参考点 Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 The transform couldn't be applied. 无法应用变换。 Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。 @@ -3080,12 +3112,12 @@ curves Smooth 平滑 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 Demosaic 去马赛克 - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填补相机彩色滤镜阵列的马赛克。在打开文件时应用;预览显示打开时所用的方法。 + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填补相机彩色滤镜阵列的马赛克。在打开文件时应用;预览显示打开时所用的方法。 AHD (adaptive homogeneity) AHD(自适应同质性) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:边缘最干净、色边最少,速度最慢。 - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 梯度校正插值:锐利且快速,细节处有些许色边。 - The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,细节处有色边。 + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks:边缘最干净、色边最少,速度最慢。 + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. 梯度校正插值:锐利且快速,细节处有些许色边。 + The fastest and the softest, with color fringing on fine detail. 最快也最柔和,细节处有色边。 “{name}” already exists. Replace it? “{name}”已存在。要替换它吗? The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。 Previous Brush 上一个画笔 @@ -3177,6 +3209,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 字号预设 Leading presets 行距预设 Tracking presets 字距调整预设 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 数值输入无效。需要介于 {min}% 和 {max}% 之间的整数。已插入先前的值。 Clear Selection 清除选区 Color Aware 颜色识别 @@ -3205,3 +3239,83 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color 纯色 Tint Color 色调颜色 Edit Brush… 编辑画笔… + +# Action Stop message (#8386) + Message 信息 +# Playback Options (#5212) + Playback Options… 回放选项… + Playback Options 回放选项 + Accelerated 加速 + Step by Step 逐步 + Pause For: 暂停: + Pause For 暂停 + seconds 秒 + Create a single history state 创建单个历史记录状态 + Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径 + Generator enabled 启用生成器 + Move Up 上移 + Move Down 下移 + Grid view 网格视图 + List view 列表视图 + On your computer 在您的计算机上 + New file 新建文件 + Learn 学习 + No recent files 没有最近使用的文件 + Website 网站 + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms 框架支持仿射变换;要进行透视变换,请选择框架中的图像 + A frame supports affine transforms; select its image to warp it 框架支持仿射变换;要进行变形,请选择框架中的图像 + Convert to Frame… 转换为框架… + Create a new custom frame 创建新的自定义框架 + Create a new polygonal frame 创建新的多边形框架 + Create a new triangular frame 创建新的三角形框架 + Fit Content to Frame 使内容适合框架 + Fit Frame to Content 使框架适合内容 + Frame coordinates must be finite and within 600000 pixels 框架坐标必须为有限值,且在 600000 像素范围内 + Import Image… 导入图像… + Invalid frame transform 无效的框架变换 + Invalid nested file drop 无效的嵌套文件拖放 + New Frame 新建框架 + Replace Image… 替换图像… + The frame is locked 框架已锁定 + The frame is unavailable 框架不可用 + Frame 框架 + X X + Y Y + +# Channels panel menu (#11451) + New Channel… 新建通道… + Duplicate Channel 复制通道 + Delete Channel 删除通道 + New Spot Channel… 新建专色通道… + Merge Spot Channel 合并专色通道 + Channel Options… 通道选项… + Split Channels 分离通道 + Merge Channels… 合并通道… + Specify Channels: 指定通道: + Not enough grayscale documents for this mode. 灰度文档不足,无法使用此模式。 + Pick a different document for each channel. 请为每个通道选择不同的文档。 + A picked document is no longer open. 所选的一个文档已不再打开。 + A picked document is no longer a grayscale document of the same size. 所选的一个文档已不再是同样大小的灰度文档。 +# Move tool options bar (#11381) + Align top edges 顶端对齐 + Align vertical centers 垂直居中对齐 + Align bottom edges 底端对齐 + Distribute vertically 垂直分布 + Distribute horizontally 水平分布 + Show layer bounds on hover 悬停时显示图层边界 + Expand layer group on click 单击时展开图层组 + Show hover bounds from Layers 显示来自图层面板的悬停边界 + Saved Camera Raw settings to {file} 已将 Camera Raw 设置保存到 {file} + Save the settings to each raw's sidecar without opening it 将设置保存到每个 Raw 的附属文件,不打开它 + No settings saved: these raws can't keep a sidecar here 未保存任何内容:这些 Raw 在此处无法拥有附属文件 + Some settings could not be saved; the raws stay open 部分设置无法保存;Raw 保持打开 + Nothing to save: these raws are at their default settings 无需保存:这些 Raw 均为默认设置 +# Artboard tool options bar (#11385) + Distribute Widths 分布宽度 + Distribute Heights 分布高度 + Auto-nest Layers 自动嵌套图层 + Auto-size Canvas 自动调整画布大小 + Keep Relative Position During Layer Reordering 重新排列图层时保持相对位置 + Shrinkwrap Canvas on Save 保存时将画布缩至内容大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9379418b9e..8d2c5697f1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -176,6 +176,7 @@ Right 右 Open Recent 最近開啟的檔案 Clear Recent Files 清除最近檔案 + Clear Recent File List 清除最近檔案 Remove from Recent 從最近檔案中移除 Remove {name} from Recent 從最近檔案中移除 {name} Close 關閉 @@ -184,6 +185,10 @@ Restore 向下還原 Close All 全部關閉 Close Others 關閉其他 + New Document… 新增文件… + Open Document… 開啟… + Consolidate All to Here 將所有視窗合併至此處 + Move to New Window 移至新視窗 Save 儲存 Save As… 另存新檔… Save a Copy… 儲存拷貝… @@ -236,6 +241,7 @@ Create Smart Object after Loading Layers 載入圖層後建立智慧型物件 Reveal in Finder 在 Finder 中顯示 Show in Explorer 在檔案總管中顯示 + Reveal in Explorer 在檔案總管中顯示 Show in Folder 在資料夾中顯示 File Info… 檔案資訊… Print… 列印… @@ -324,6 +330,7 @@ Units & Rulers… 單位和尺標… Guides, Grid & Slices… 參考線、格點和切片… Plug-ins… 增效模組… + Plugins… 增效模組… Type… 文字… Enhanced Controls… 增強控制項… Camera Raw… Camera Raw… @@ -377,6 +384,8 @@ Match Color… 符合顏色… Replace Color… 取代顏色… Equalize 均勻分佈 + Equalize Selected Area Only 僅均勻分佈選取範圍 + Equalize Entire Image Based On Selected Area 依據選取範圍均勻分佈整個影像 Auto Tone 自動色調 Auto Contrast 自動對比 Auto Color 自動色彩 @@ -481,6 +490,8 @@ Smart Object 智慧型物件 Convert to Smart Object 轉換為智慧型物件 Convert to Embedded 轉換為嵌入 + Embed Linked 嵌入連結 + Embed All Linked 嵌入所有連結 Convert to Layers 轉換為圖層 Convert to Linked… 轉換為連結… Edit Contents 編輯內容 @@ -554,7 +565,9 @@ Unite Shapes 聯集形狀 Subtract Front Shape 減去前方形狀 Intersect Shape Areas 交集形狀區域 + Unite Shapes at Overlap 交集形狀區域 Exclude Overlapping Shapes 排除重疊形狀 + Subtract Shapes at Overlap 排除重疊形狀 Merge Shape Components 合併形狀元件 Path operations 路徑操作 Align 對齊 @@ -631,7 +644,6 @@ Deselect 取消選取 Reselect 重新選取 Inverse 反轉 - Inverse Selection 反轉選取範圍 Similar 相近色 Color Range… 顏色範圍… Focus Area… 焦點區域… @@ -739,6 +751,7 @@ Minimum… 最小… Offset… 畫面錯位… Plug-ins 增效模組 + Plugins 增效模組 Proof Setup 校樣設定 Proof Colors 校樣色彩 Gamut Warning 色域警告 @@ -747,12 +760,12 @@ Working Magenta Plate 使用中的洋紅版 Working Yellow Plate 使用中的黃色版 Working Black Plate 使用中的黑色版 - Working CMY Plate 使用中的 CMY 版 - Legacy Macintosh RGB 舊版 Macintosh RGB - Internet Standard RGB 網際網路標準 RGB + Working CMY Plates 使用中的 CMY 版 + Legacy Macintosh RGB (Gamma 1.8) 舊版 Macintosh RGB (Gamma 1.8) + Internet Standard RGB (sRGB) 網際網路標準 RGB (sRGB) Monitor RGB 顯示器 RGB - Color Blindness — Protanopia-type 色盲 — 紅色盲型 - Color Blindness — Deuteranopia-type 色盲 — 綠色盲型 + Color Blindness - Protanopia-type 色盲 - 紅色盲型 + Color Blindness - Deuteranopia-type 色盲 - 綠色盲型 Zoom In 放大顯示 Zoom Out 縮小顯示 Fit on Screen 顯示全頁 @@ -841,7 +854,7 @@ Consolidate All to Tabs 全部合併至標籤頁 Float in Window 在視窗中浮動 Float All in Windows 全部在視窗中浮動 - New Window for Document 為文件新增視窗 + New Window 新增視窗 Workspace 工作區 Essentials 基本功能 Graphic and Web 繪圖和網頁 @@ -904,6 +917,12 @@ Timeline 時間軸 Tool Presets 工具預設集 Tools 工具 + Contextual Task Bar 內容相關工作列 + Hide bar 隱藏工作列 + Reset bar position 重設工作列位置 + Pin bar position 固定工作列位置 + Move bar 移動工作列 + More options 更多選項 About PhotoCraft 關於 PhotoCraft PhotoCraft Website PhotoCraft 網站 PhotoCraft on GitHub GitHub 上的 PhotoCraft @@ -1067,7 +1086,7 @@ Click to add a point · drag off or {key}-click or Delete to remove 按一下新增點 · 拖出、{key}+按一下或按 Delete 以移除 Click to apply, {key}-click to clear overrides, double-click for Style Options 按一下套用,{key}+按一下清除覆寫,連按兩下開啟樣式選項 Click to edit the gradient 按一下以編輯漸層 - Click to restore the current colour 按一下以還原目前顏色 + Click to restore the current color 按一下以還原目前顏色 Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out 按一下放大 · {key}+按一下縮小 · 向右/向左拖曳以放大/縮小 Click with the Note tool to add a note. 使用註解工具按一下以新增註解。 Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel 按一下:轉角 · 拖曳:平滑 · 按一下第一點:閉合 · {key} 完成 · Esc 取消 @@ -1154,7 +1173,7 @@ Darks 暗部 Data Sets 資料組 Default 預設 - Default colours (D) 預設顏色 (D) + Default Foreground and Background Colors (D) 預設顏色 (D) Default printer 預設印表機 Default restored. 已還原預設值。 Define 定義 @@ -1437,7 +1456,7 @@ Opacity Jitter 不透明度快速變換 Opacity: 不透明度: Open a document to define variables. 請開啟文件以定義變數。 - Open a document to select a colour range. 請開啟文件以選取顏色範圍。 + Open a document to select a color range. 請開啟文件以選取顏色範圍。 Open a document to use the timeline. 請開啟文件以使用時間軸。 Optical 視覺 Optimized 最佳化 @@ -1513,7 +1532,6 @@ Purity 純度 Quality 品質 Quick Actions 快速動作 - Quick Export As PNG 快速匯出為 PNG Radial Gradient 放射狀漸層 Radius 半徑 Radius: 半徑: @@ -1657,7 +1675,7 @@ Subtract from Sample 從樣本中減去 Subtract from selection ({key}) 從選取範圍減去 ({key}) Suggestions: 建議: - Swap colours (X) 切換顏色 (X) + Switch Foreground and Background Colors (X) 切換顏色 (X) Swaps height and width 交換高度和寬度 Switch between free transform and warp modes 在任意變形和彎曲模式間切換 Switch theme 切換主題 @@ -1762,7 +1780,7 @@ Choke 收縮 Classic 經典 Click to add text 按一下以新增文字 - Click to fill similar colours 按一下以填滿相近顏色 + Click to fill similar colors 按一下以填滿相近顏色 Clone Stamp Tool 仿製印章工具 Color Burn 加深顏色 Color Dodge 加亮顏色 @@ -1864,6 +1882,7 @@ Move Tool 移動工具 Could not use the move tool because the layer is locked. 無法使用移動工具,因為圖層已鎖定。 Could not complete the Free Transform command because the layer is locked. 無法完成「任意變形」指令,因為圖層已鎖定。 + Could not transform because the layer is locked. 無法變形,因為圖層已鎖定。 Multiply 色彩增值 My Brushes 我的筆刷 Neutrals 中間色 @@ -1922,7 +1941,7 @@ Rectangular Marquee Tool 矩形選取畫面工具 Reds 紅色 Reflected 反射 - Sample: foreground colour 取樣:前景色 + Sample: foreground color 取樣:前景色 Sampled Colors 取樣的顏色 Satin 緞面 Save Pixel Aspect Ratio 儲存像素外觀比例 @@ -1943,6 +1962,7 @@ Soften 柔化 Source 來源 Source: set 來源:已設定 + Source: {name} 來源:{name} Sponge Tool 海綿工具 Spot Healing Brush Tool 污點修復筆刷工具 Spread 展開 @@ -1960,7 +1980,7 @@ Transfer 轉換 Triangle Tool 三角形工具 Unsaved changes 未儲存的變更 - Uses the foreground and background colours. 使用前景色和背景色。 + Uses the foreground and background colors. 使用前景色和背景色。 Web 網頁 Web Large 網頁 (大) Web Minimum 網頁 (最小) @@ -1997,6 +2017,7 @@ Allow scripts to connect 允許指令碼連線 Always 一律 Always create smart objects when placing 置入時一律建立智慧型物件 + Skip transform when placing 置入時略過變形 Animated zoom 動畫縮放 Apply auto tone 套用自動色調 Ask 詢問 @@ -2019,6 +2040,11 @@ Show canvas dots 顯示畫布圓點 Clip thumbnails to layer bounds 將縮圖裁切至圖層邊界 Clip Thumbnails to Layer Bounds 將縮圖裁切至圖層邊界 + No Thumbnails 無縮圖 + Small Thumbnails 小縮圖 + Medium Thumbnails 中縮圖 + Large Thumbnails 大縮圖 + Clip Thumbnails to Document Bounds 將縮圖裁切至文件邊界 Canvas color 畫布顏色 Canvas custom color 畫布自訂顏色 Color picker 檢色器 @@ -2136,7 +2162,6 @@ Show only crosshair while painting 繪圖時僅顯示十字線 Show slice numbers 顯示切片編號 Show tooltips 顯示工具提示 - Enable animations 啟用動畫 Show highlight on rollover 滑鼠懸停時顯示醒目提示 Show bounding box when dragging layer 拖曳圖層時顯示邊界框 System title bar 系統標題列 @@ -2228,7 +2253,7 @@ Graphics 圖形 Reset GPU Backend 重設 GPU 後端 Applies at next launch. 下次啟動時套用。 - Click to sample the foreground colour · {key}-click for background 按一下以取樣前景色 · {key}+按一下取樣背景色 + Click to sample the foreground color · {key}-click for background 按一下以取樣前景色 · {key}+按一下取樣背景色 {key}-click to set the source {key}+按一下以設定來源 {key} to subtract 按 {key} 以減去 Clone source {n} ({key}-click with Clone Stamp to set) 仿製來源 {n} (使用仿製印章時 {key}+按一下以設定) @@ -2290,7 +2315,7 @@ Gradient Options 漸層選項 Align with layer 與圖層對齊 Reset Alignment 重設對齊 - Centre the gradient (offset 0, 0) 置中漸層 (位移 0, 0) + Center the gradient (offset 0, 0) 置中漸層 (位移 0, 0) Gradient Editor 漸層編輯器 Gradient picker 漸層挑選器 Save As Preset 儲存為預設 @@ -2323,7 +2348,7 @@ Alt-click to define a source point to clone from 按 Alt 鍵並按一下以定義仿製來源點 No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 沒有可用來建立選取範圍的路徑:請使用鋼筆工具繪製,或在路徑面板中選取一個路徑。 Cancel opening 取消開啟 - Cancelled opening {name} 已取消開啟 {name} + Canceled opening {name} 已取消開啟 {name} Damaged Photoshop document 已損毀的 Photoshop 文件 The document “{name}” may be damaged. 文件「{name}」可能已損毀。 Some layers, pixels or masks could not be read. Do you want to continue opening it? 無法讀取部分圖層、像素或遮色片。是否繼續開啟? @@ -2333,7 +2358,7 @@ The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文件「{name}」嵌入的色彩描述檔與目前的 {mode} 工作空間不符。 Working: {working} 工作空間:{working} Developing {name} 正在處理 {name} - Cancelled {label} 已取消 {label} + Canceled {label} 已取消 {label} Esc to cancel 按 Esc 取消 Opened {name} 已開啟 {name} Opening {name} 正在開啟 {name} @@ -2341,7 +2366,7 @@ Saving {name} 正在儲存 {name} Saving {percent} 正在儲存 {percent} A save to {name} is still running; try again when it finishes 仍在儲存到 {name};請在完成後再試一次 - {label} is writing the file and can no longer be cancelled {label} 正在寫入檔案,已無法取消 + {label} is writing the file and can no longer be canceled {label} 正在寫入檔案,已無法取消 Opening {name}… 正在開啟 {name}… Opening… 正在開啟… Reading… 正在讀取… @@ -2421,7 +2446,7 @@ cameraRaw Blending 混合 Discard Layers and Save a Copy 放棄圖層並儲存拷貝 Don't show again 不再顯示 Save failed: {error} 儲存失敗:{error} - Save cancelled 已取消儲存 + Save canceled 已取消儲存 Magnetic Lasso Tool 磁性套索工具 Frequency 頻率 Use tablet pressure to change pen width 使用繪圖板壓力以變更鋼筆寬度 @@ -2555,7 +2580,7 @@ fontWeight Italic 斜體 Rotate View 旋轉檢視 Rotate View Tool 旋轉檢視工具 Reset View 重設檢視 - Drag around the centre to rotate the view · Shift constrains to 15° 繞中心拖曳以旋轉檢視 · Shift 限制為 15° + Drag around the center to rotate the view · Shift constrains to 15° 繞中心拖曳以旋轉檢視 · Shift 限制為 15° Dual Axis 雙軸 Diagonal 對角線 Transform Symmetry 變形對稱軸 @@ -2570,8 +2595,8 @@ fontWeight Italic 斜體 All Layers No Adjustments 所有圖層(不含調整) Current & Below No Adjustments 目前及以下(不含調整) Show Sampling Ring 顯示取樣環 - Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色 - Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色 + Out of gamut for printing: click for the nearest printable color 超出列印色域:按一下以使用最接近的可列印顏色 + Not a web color: click for the nearest web color 不是網頁顏色:按一下以使用最接近的網頁顏色 Color to Alpha… 顏色轉為 Alpha… Transparency Threshold 透明度臨界值 Opacity Threshold 不透明度臨界值 @@ -2665,7 +2690,7 @@ fontWeight Italic 斜體 How often fastening points are placed by themselves 自動放置固定點的頻率 Scrubby Zoom 細微縮放 {mode} Color · {bits} bit {mode} 色彩 · {bits} 位元 - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 拖移繪製裁切框 · 在框內拖移以移動 · 拖移邊緣調整大小({ratio} 比例,{centre} 中心)· 繪製時按 Space 移動 · 方向鍵微移 · X 切換方向 · {commit} 確認 · Esc 取消 + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} center) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels 拖移繪製裁切框 · 在框內拖移以移動 · 拖移邊緣調整大小({ratio} 比例,{centre} 中心)· 繪製時按 Space 移動 · 方向鍵微移 · X 切換方向 · {commit} 確認 · Esc 取消 Appearance Settings… 外觀設定… Add to favorites 加入最愛 Remove from favorites 從最愛移除 @@ -2899,15 +2924,20 @@ curves Smooth 平滑 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 Background (Default) 背景(預設) + Background (default) 背景(預設) + Transparent (default) 透明(預設) Content-Aware Fill 內容感知填滿 Include All Subfolders 包含所有子資料夾 Auto Add/Delete 自動增加/刪除 Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 請勿同時勾選列印設定中的鏡像:兩次翻轉會互相抵消。 Print Settings… 列印設定… - Remember to turn off the printer's own colour management in Print Settings… 請記得在列印設定中關閉印表機本身的色彩管理… + Remember to turn off the printer's own color management in Print Settings… 請記得在列印設定中關閉印表機本身的色彩管理… Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + Automatically Create First Snapshot 自動建立第一個快照 + Automatically Create New Snapshot When Saving 儲存時自動建立新快照 + Show New Snapshot Dialog by Default 預設顯示新增快照對話框 Memory usage percent 記憶體使用比例 (%) Show Favorite Fonts 顯示我的最愛字體 No favorite fonts yet: click the star beside a font to add it. 尚無我的最愛字體:按一下字體旁的星號即可加入。 @@ -2952,6 +2982,7 @@ curves Smooth 平滑 Automatically Check for Updates 自動檢查更新 Paints from the History panel's source state 從步驟記錄面板的來源狀態繪畫 Sets the source for the history brush 設定步驟記錄筆刷的來源 + The History Brush source was deleted: set a new source in the History panel 步驟記錄筆刷的來源已刪除:請在步驟記錄面板中設定新的來源 Highlight Mode 亮部模式 Highlight Color 亮部顏色 Highlight Opacity 亮部不透明度 @@ -2977,6 +3008,7 @@ curves Smooth 平滑 Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 Make Selection 建立選取範圍 + Make Shape 建立形狀 Bicubic Automatic 雙三次 (自動) Bicubic Smoother 雙三次 (較平滑) Bicubic Sharper 雙三次 (較銳利) @@ -3020,6 +3052,7 @@ curves Smooth 平滑 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) Reference point location 參考點位置 + Toggle reference point 切換參考點 Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 The transform couldn't be applied. 無法套用變形。 Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。 @@ -3041,12 +3074,12 @@ curves Smooth 平滑 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 Demosaic 去馬賽克 - How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填補相機彩色濾鏡陣列的馬賽克。在開啟檔案時套用;預覽顯示開啟時所用的方法。 + How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填補相機彩色濾鏡陣列的馬賽克。在開啟檔案時套用;預覽顯示開啟時所用的方法。 AHD (adaptive homogeneity) AHD(自適應同質性) Malvar–He–Cutler Malvar–He–Cutler - Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:邊緣最乾淨、色邊最少,速度最慢。 - Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 梯度校正插值:銳利且快速,細節處有些許色邊。 - The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,細節處有色邊。 + Hirakawa–Parks: the cleanest edges and the least color fringing, the slowest. Hirakawa–Parks:邊緣最乾淨、色邊最少,速度最慢。 + Gradient-corrected interpolation: sharp and fast, with some color fringing on fine detail. 梯度校正插值:銳利且快速,細節處有些許色邊。 + The fastest and the softest, with color fringing on fine detail. 最快也最柔和,細節處有色邊。 “{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎? The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。 Previous Brush 上一個筆刷 @@ -3133,6 +3166,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 字體大小預設 Leading presets 行距預設 Tracking presets 字距調整預設 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 數值輸入無效。需要介於 {min}% 和 {max}% 之間的整數。已插入先前的值。 Clear Selection 清除選取範圍 Color Aware 顏色感知 @@ -3161,3 +3196,83 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color 純色 Tint Color 色調顏色 Edit Brush… 編輯筆刷… + +# Action Stop message (#8386) + Message 訊息 +# Playback Options (#5212) + Playback Options… 回放選項… + Playback Options 回放選項 + Accelerated 加速 + Step by Step 逐步 + Pause For: 暫停: + Pause For 暫停 + seconds 秒 + Create a single history state 建立單一步驟記錄狀態 + Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑 + Generator enabled 啟用 Generator + Move Up 上移 + Move Down 下移 + Grid view 格線檢視 + List view 清單檢視 + On your computer 在您的電腦上 + New file 新增檔案 + Learn 學習 + No recent files 沒有最近使用的檔案 + Website 網站 + +# Frame Tool and other branch translations. + A frame supports affine transforms; select its image for perspective transforms 框架支援仿射變形;若要進行透視變形,請選取框架中的影像 + A frame supports affine transforms; select its image to warp it 框架支援仿射變形;若要進行彎曲變形,請選取框架中的影像 + Convert to Frame… 轉換為框架… + Create a new custom frame 建立新的自訂框架 + Create a new polygonal frame 建立新的多邊形框架 + Create a new triangular frame 建立新的三角形框架 + Fit Content to Frame 使內容符合框架 + Fit Frame to Content 使框架符合內容 + Frame coordinates must be finite and within 600000 pixels 框架座標必須為有限值,且在 600000 像素範圍內 + Import Image… 讀入影像… + Invalid frame transform 無效的框架變形 + Invalid nested file drop 無效的巢狀檔案拖放 + New Frame 新增框架 + Replace Image… 取代影像… + The frame is locked 框架已鎖定 + The frame is unavailable 框架無法使用 + Frame 框架 + X X + Y Y + +# Channels panel menu (#11451) + New Channel… 新增色版… + Duplicate Channel 複製色版 + Delete Channel 刪除色版 + New Spot Channel… 新增特別色色版… + Merge Spot Channel 合併特別色色版 + Channel Options… 色版選項… + Split Channels 分離色版 + Merge Channels… 合併色版… + Specify Channels: 指定色版: + Not enough grayscale documents for this mode. 灰階文件不足,無法使用此模式。 + Pick a different document for each channel. 請為每個色版選擇不同的文件。 + A picked document is no longer open. 所選的一個文件已不再開啟。 + A picked document is no longer a grayscale document of the same size. 所選的一個文件已不再是相同大小的灰階文件。 +# Move tool options bar (#11381) + Align top edges 對齊頂端 + Align vertical centers 垂直置中對齊 + Align bottom edges 對齊底端 + Distribute vertically 垂直分佈 + Distribute horizontally 水平分佈 + Show layer bounds on hover 滑過時顯示圖層邊界 + Expand layer group on click 按一下時展開圖層群組 + Show hover bounds from Layers 顯示來自圖層面板的滑過邊界 + Saved Camera Raw settings to {file} 已將 Camera Raw 設定儲存到 {file} + Save the settings to each raw's sidecar without opening it 將設定儲存到每個 Raw 的附屬檔案,不開啟它 + No settings saved: these raws can't keep a sidecar here 未儲存任何內容:這些 Raw 在此處無法擁有附屬檔案 + Some settings could not be saved; the raws stay open 部分設定無法儲存;Raw 保持開啟 + Nothing to save: these raws are at their default settings 無需儲存:這些 Raw 均為預設設定 +# Artboard tool options bar (#11385) + Distribute Widths 均分寬度 + Distribute Heights 均分高度 + Auto-nest Layers 自動巢狀圖層 + Auto-size Canvas 自動調整版面尺寸 + Keep Relative Position During Layer Reordering 重新排列圖層時保持相對位置 + Shrinkwrap Canvas on Save 儲存時將版面縮至內容大小 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index d261e762cd..93103b34a3 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -50,6 +50,13 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("convert-point", include_bytes!("../../../assets/icons/convert-point.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), ("crop", include_bytes!("../../../assets/icons/crop.svg")), + // Original: the Crop tool's overlay glyphs (#10889), on the Lucide grid and stroke. + ("crop-overlay-diagonal", include_bytes!("../../../assets/icons/crop-overlay-diagonal.svg")), + ("crop-overlay-golden-ratio", include_bytes!("../../../assets/icons/crop-overlay-golden-ratio.svg")), + ("crop-overlay-golden-spiral", include_bytes!("../../../assets/icons/crop-overlay-golden-spiral.svg")), + ("crop-overlay-grid", include_bytes!("../../../assets/icons/crop-overlay-grid.svg")), + ("crop-overlay-thirds", include_bytes!("../../../assets/icons/crop-overlay-thirds.svg")), + ("crop-overlay-triangle", include_bytes!("../../../assets/icons/crop-overlay-triangle.svg")), ("diamond", include_bytes!("../../../assets/icons/diamond.svg")), ("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")), ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), @@ -75,6 +82,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("grid-2x2", include_bytes!("../../../assets/icons/grid-2x2.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), + ("history-brush", include_bytes!("../../../assets/icons/history-brush.svg")), ("house", include_bytes!("../../../assets/icons/house.svg")), ("image", include_bytes!("../../../assets/icons/image.svg")), ("info", include_bytes!("../../../assets/icons/info.svg")), @@ -118,6 +126,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("pencil", include_bytes!("../../../assets/icons/pencil.svg")), ("pentagon", include_bytes!("../../../assets/icons/pentagon.svg")), ("perspective-crop", include_bytes!("../../../assets/icons/perspective-crop.svg")), + ("pin", include_bytes!("../../../assets/icons/pin.svg")), ("pipette", include_bytes!("../../../assets/icons/pipette.svg")), ("play", include_bytes!("../../../assets/icons/play.svg")), ("pause", include_bytes!("../../../assets/icons/pause.svg")), @@ -127,6 +136,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")), ("rectangle-vertical", include_bytes!("../../../assets/icons/rectangle-vertical.svg")), ("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")), + ("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")), ("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")), ("ruler", include_bytes!("../../../assets/icons/ruler.svg")), ("sampler", include_bytes!("../../../assets/icons/sampler.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0a42a03527..c102064a7f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -23,6 +23,18 @@ pub fn exists(name: &str) -> bool { white_icons().contains(name) } +/// Rasterize an embedded icon from the white icon set at exact pixel dimensions `[width, height]`. +pub(crate) fn rasterize_svg(name: &str, width: u32, height: u32) -> Option { + if width == 0 || height == 0 { + return None; + } + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { + return None; + }; + egui_extras::image::load_svg_bytes_with_size(bytes.as_ref(), egui::load::SizeHint::Size { width, height, maintain_aspect_ratio: true }, &Default::default()) + .ok() +} + /// Panel footer buttons share one glyph per action and one size, so the Layers footer and the /// Swatches, Gradients and Patterns footers read the same, as in Photoshop (#10656). pub const FOOTER_SIZE: f32 = 26.0; @@ -35,6 +47,18 @@ pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> { egui::Image::new(white_icons().source(name)).fit_to_exact_size(Vec2::splat(size)).tint(tint) } +/// Rasterize the same styled SVG used by the toolbar for a composed native cursor. +pub(crate) fn rasterize(name: &str, side: u32) -> Result { + if !white_icons().contains(name) { + return Err(format!("Cursor icon {name:?} is missing")); + } + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { + return Err(format!("Cursor icon {name:?} has no embedded SVG bytes")); + }; + egui_extras::image::load_svg_bytes_with_size(&bytes, egui::load::SizeHint::Width(side), &Default::default()) + .map_err(|error| format!("Could not rasterize cursor icon {name:?}: {error}")) +} + /// Paint an icon centred in `rect`. pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { let r = Rect::from_center_size(rect.center(), Vec2::splat(size)); @@ -133,7 +157,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::RedEye => "eye", Tool::CloneStamp => "stamp", Tool::PatternStamp => "grid-3x3", - Tool::HistoryBrush => "clock", + Tool::HistoryBrush => "history-brush", Tool::ArtHistoryBrush => "sparkles", Tool::Blur => "droplet", Tool::Sharpen => "triangle", diff --git a/crates/ui-egui/src/input/blend_keys.rs b/crates/ui-egui/src/input/blend_keys.rs index 5a126268b7..fb3d5d47b5 100644 --- a/crates/ui-egui/src/input/blend_keys.rs +++ b/crates/ui-egui/src/input/blend_keys.rs @@ -32,8 +32,7 @@ pub enum Pick { Mode(BlendMode), /// Painting tools only: paint transparency. Clear, - /// Painting tools only: paint behind the layer's pixels. Not implemented yet, so ⇧⌥Q is taken - /// (it must not reach another binding) and does nothing. + /// Brush and Pencil: paint behind the layer's pixels. Behind, } @@ -265,7 +264,23 @@ pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { let want = match pick { Pick::Mode(m) => Some(m), Pick::Clear => None, - Pick::Behind => return, + Pick::Behind => { + let item = crate::brush_mode_menu::Item::Behind; + let tool = app.ui.tool; + if app.session.active().is_none() || !crate::brush_mode_menu::enabled(app, item) { + return; + } + let Some((id, params)) = crate::brush_mode_menu::engine_call(app, tool, item) else { return }; + crate::paint_mouse::sync_tool_brush(app); + if app.run(id, params).is_ok() { + match tool { + Tool::Brush => app.ui.tool_options.brush_clear = false, + Tool::Pencil => app.ui.tool_options.pencil_clear = false, + _ => {} + } + } + return; + } }; match target(app.ui.tool) { Target::Tool(modes) if modes.contains(&want) => set_tool_mode(app, want), @@ -280,3 +295,7 @@ pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../brush_behind_key_tests.rs"] +mod brush_behind_key_tests; diff --git a/crates/ui-egui/src/input/hold_keys/tests.rs b/crates/ui-egui/src/input/hold_keys/tests.rs index 8a2fbec54e..61d78d9ed3 100644 --- a/crates/ui-egui/src/input/hold_keys/tests.rs +++ b/crates/ui-egui/src/input/hold_keys/tests.rs @@ -612,7 +612,8 @@ fn cmd_alt_drag_duplicates_the_layer_then_moves_the_copy() { assert_eq!(bounds(&h, orig), Rect::new(100, 100, 160, 160), "the original stays"); assert_eq!(bounds(&h, copy), Rect::new(140, 120, 200, 180), "the copy moved"); assert_eq!(st.history.past_len(), h0 + 1, "one undo step"); - assert_eq!(st.history.undo_label(), Some(crate::move_mods::DUPLICATE_MOVE_LABEL)); + // A drag that copies a layer is one "Move" step, as in Photoshop (#11383). + assert_eq!(st.history.undo_label(), Some(tl!("Move"))); assert_eq!(h.state().ui.tool, tool); } } diff --git a/crates/ui-egui/src/input/opacity_keys.rs b/crates/ui-egui/src/input/opacity_keys.rs index e9d8f0f33e..7f02c0c77b 100644 --- a/crates/ui-egui/src/input/opacity_keys.rs +++ b/crates/ui-egui/src/input/opacity_keys.rs @@ -32,7 +32,7 @@ enum Target { fn target(tool: Tool) -> Target { match tool { - Tool::Brush | Tool::Eraser => Target::Brush { flow: true }, + Tool::Brush | Tool::Eraser | Tool::CloneStamp | Tool::HistoryBrush => Target::Brush { flow: true }, Tool::Pencil => Target::Brush { flow: false }, Tool::Gradient | Tool::PaintBucket => Target::ToolOpacity, // Their options-bar number is a strength, exposure or tolerance, not this opacity. @@ -107,6 +107,9 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let shift = shift ^ (build_up && matches!(target(tool), Target::Brush { flow: true })); match (target(app.ui.tool), shift) { (Target::Brush { .. }, false) => { + // Clone Stamp's and History Brush's Opacity fields (and their strokes) start at 1%, so + // `00` sets the 1% they actually paint with rather than showing a 0% they can't do. + let v = if matches!(tool, Tool::CloneStamp | Tool::HistoryBrush) { v.max(0.01) } else { v }; let _ = app.run("tools.setBrush", json!({ "brush": { "opacity": v } })); } // The options bar's Flow field starts at 1%. @@ -201,6 +204,32 @@ mod tests { assert!((e.session.tools.brush.flow - 0.2).abs() < 1e-6); } + #[test] + fn history_brush_and_clone_stamp_digits_set_brush_opacity_and_flow() { + for tool in [Tool::HistoryBrush, Tool::CloneStamp] { + let mut a = app(tool); + press(&mut a, 7, false, 0.0); + assert!((a.session.tools.brush.opacity - 0.7).abs() < 1e-6); + press(&mut a, 4, true, 1000.0); + assert!((a.session.tools.brush.flow - 0.4).abs() < 1e-6); + assert_eq!(layer(&a), (1.0, 1.0), "the layer is untouched"); + } + } + + #[test] + fn double_zero_gives_history_brush_and_clone_stamp_their_one_percent_minimum() { + for tool in [Tool::HistoryBrush, Tool::CloneStamp] { + let mut a = app(tool); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert!((a.session.tools.brush.opacity - 0.01).abs() < 1e-6, "{tool:?}"); + } + let mut a = app(Tool::Brush); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert!(a.session.tools.brush.opacity.abs() < 1e-6, "the Brush itself still goes to 0%"); + } + #[test] fn other_tools_set_the_layer_or_their_own_opacity() { let mut a = app(Tool::Move); diff --git a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs index 633bb89ca8..ce7e3fcc39 100644 --- a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs @@ -386,6 +386,8 @@ fn alternate_keys_run_their_command() { ("Cmd+Shift+I", "select.inverse"), ("Cmd++", "view.zoomIn"), ("Shift+Backspace", "edit.fill"), + ("Cmd+Alt+0", "view.actualPixels"), + ("F9", "window.panel.actions"), ] { press(&mut h, key); let log = take_log(&h.ctx); diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 509bfbd27c..c3ef377874 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -485,9 +485,9 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if crate::canvas::cancel_mixer_on_context_change(app, ctx) { return false; } - // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings - // it back later (#2608). - app.ui.remember_group_tool(); + // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key and its + // toolbar slot bring it back later (#2608). A temporary tool is no choice, so it isn't noted. + app.ui.remember_group_tool(crate::tool_memory::selected(app)); crate::canvas::polygon_cancel_stale(app); // Camera Raw is modal like Photoshop's filter dialog: no application shortcut (Save, Undo, // tools) runs beneath it, and it handles its own keys (Y, U, O, S). Unlike the other dialogs @@ -620,6 +620,9 @@ fn handle_modal_canvas_keys(app: &mut PhotocraftApp, ctx: &egui::Context, focus: if focus == Focus::None && crate::perspective_crop_ui::keys(app, ctx) { return true; } + if focus == Focus::None && crate::frame_tool::keys(app, ctx) { + return true; + } // Move tool / Free Transform: arrows nudge (⇧ ×10, ⌥ duplicates first). if focus == Focus::None && crate::move_mods::arrow_keys(app, ctx) { return true; @@ -705,6 +708,11 @@ fn handle_pen_and_work_path_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f crate::vector_ui::pen_end(app); return true; } + // A shape box handle drag: Esc drops it, the shapes keep their size (#11107). + if app.ui.shape_box.is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + crate::shape_bounds_box::cancel(app); + return true; + } // A finished work path shown by the Pen or Path Selection: ⌫/Delete deletes it, as in // Photoshop (#5702), never Edit › Clear on the pixels under it. if focus != crate::shortcut_dispatch::Focus::DeletingWidget && crate::vector_ui::delete_key_deletes_work_path(app) { @@ -737,6 +745,10 @@ fn handle_object_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool if crate::slice_ui::keys(app, ctx) { return true; } + // A note selected with the Note tool: ⌫/Delete asks to delete the note, not the layer (#11788). + if crate::analysis_ui::note_keys(app, ctx) { + return true; + } // A selected smart filter row: ⌫/Delete removes the filter, not its smart object (#4751). crate::smart_ui::keys(app, ctx) } @@ -788,6 +800,10 @@ fn handle_registry_and_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f return true; } } + if app.ui.frame_tool.drag.is_some() && pressed(Key::Escape) { + crate::frame_tool::cancel(app); + return true; + } // Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop). if crate::lasso_ui::active(app) { let mods = ctx.input(|i| i.modifiers); @@ -813,13 +829,17 @@ fn handle_registry_and_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f return true; } // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only - // makes the next one, so Esc goes on to leave full screen (#7315). - let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + // makes the next one, so Esc goes on to leave full screen (#7315). A gesture on it is still cancelled (#9275). + let untouched = app.ui.view.screen_mode != "standard" + && app.ui.polygon.is_empty() + && app.crop.default_frame + && app.crop.drag.is_none() + && !crate::crop_straighten::mode(app); if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); app.ui.polygon_doc = None; - crate::crop_ui::cancel(app); + crate::crop_ui::dismiss(app); return true; } } @@ -835,7 +855,7 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { // and inline type returned above, transform, pen, lasso and crop consumed it, and these own // theirs later in the frame: the palette, the canvas and layer menus, the brush picker, // Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any open egui popup - // and the toolbar tool flyout. A canvas drag in progress (a marquee being drawn) keeps it too. + // and the toolbar tool and "…" flyouts. A canvas drag in progress (a marquee being drawn) keeps it too. let esc_taken = app.drag.is_some() || app.ui.palette_open || app.ui.canvas_tool_menu.is_some() @@ -847,7 +867,8 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { || app.smart_svg.modal_open() || app.distort.gallery.is_some() || ctx.any_popup_open() - || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some()); + || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some()) + || crate::panels::toolbar::more_flyout_open(ctx); // Both full screen modes (with and without the menu bar) return to Standard, as in Photoshop // (#4116), through the menu command so the window and the Screen Mode check follow. if !esc_taken && app.ui.view.screen_mode != "standard" && pressed(Key::Escape) { @@ -898,12 +919,18 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { let before = app.ui.tool; - let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); + let mut group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); + // Shift+key (the cycle and the fallback to the group's first tool) uses the toolbar + // flyout's order; plain keys keep the `Tool::ALL` order. + if cycle_shift { + group.sort_by_key(|x| crate::panels::toolbar::toolbar_position(*x)); + } app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), + None => app.ui.group_tools.iter().copied().find(|last| group.contains(last)).unwrap_or(group[0]), }; + crate::tool_memory::ended_by_tool(app); // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); crate::spring_tools::pressed(app, t.key(), before, plain, now); diff --git a/crates/ui-egui/src/input/shortcuts/tests.rs b/crates/ui-egui/src/input/shortcuts/tests.rs index cfb3203f10..fd68edff51 100644 --- a/crates/ui-egui/src/input/shortcuts/tests.rs +++ b/crates/ui-egui/src/input/shortcuts/tests.rs @@ -72,6 +72,20 @@ fn shift_key_tool_switch_is_the_default() { assert_ne!(app.ui.tool, Tool::Type); } +/// A tool with a shortcut of its own leaves its letter (#4086) but stays in the history: the letter +/// falls back on the last tool it still picks, not on its group's first. +#[test] +fn a_tool_key_passes_over_a_remapped_tool_to_the_last_one_it_picks() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.polygonLasso", "value": "F6"})).unwrap(); + app.ui.remember_group_tool(Tool::MagneticLasso); + app.ui.remember_group_tool(Tool::PolygonLasso); + press_key(&mut app, &ctx, Key::L, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagneticLasso); +} + /// #3109: on a German layout Ö / # (physical `;` / `\`) step the brush size and ⇧Ö / ⇧# /// (⇧# types `'`) its hardness, as Photoshop's German shortcuts. #[test] diff --git a/crates/ui-egui/src/input/stylus.rs b/crates/ui-egui/src/input/stylus.rs index 73191e4ebd..1899a0db09 100644 --- a/crates/ui-egui/src/input/stylus.rs +++ b/crates/ui-egui/src/input/stylus.rs @@ -8,7 +8,8 @@ //! `egui::Event::Touch { force }` alongside the emulated pointer. [`Stylus::update`] reads it. //! winit drops the pen's tilt and rotation, so those stay 0, and reports pressure 0 (a tap //! lighter than the pen's first pressure level) as no force, which [`Stylus::update`] reads as -//! pressure 0 once it knows a pen is in use. +//! pressure 0 once it knows a pen is in use. The desktop app's pointer message hook +//! (`photocraft-tablet`) reports which end of the pen is in use through `Services::pen_end`. //! - **Web**: eframe forwards touch force but not pen pointer events, so the web runner listens //! to `pointerdown`/`pointermove` itself and writes `pressure`, `tiltX`, `tiltY`, `twist` and //! the eraser button of `pointerType == "pen"` events into the [`StylusFeed`]. @@ -317,9 +318,10 @@ impl Stylus { } /// Did the pen just flip to its eraser end (`Some(true)`) or back to its tip (`Some(false)`)? - /// Each flip is reported once; a mouse in between changes nothing. - pub(crate) fn take_end_flip(&mut self) -> Option { - let eraser = self.feed.get()?.eraser; + /// Each flip is reported once; a mouse in between changes nothing. `platform_end` is the end + /// a platform reports outside the feed (Windows), read when the feed has no pen. + pub(crate) fn take_end_flip(&mut self, platform_end: Option) -> Option { + let eraser = self.feed.get().map(|s| s.eraser).or(platform_end)?; let flipped = self.end.map_or(eraser, |e| e != eraser); self.end = Some(eraser); flipped.then_some(eraser) @@ -332,10 +334,13 @@ impl Stylus { if app.drag.is_some() { return false; } - match app.stylus.take_end_flip() { + let platform_end = app.services.pen_end.as_ref().and_then(|f| f()); + match app.stylus.take_end_flip(platform_end) { Some(true) if app.ui.tool != Tool::Eraser => { app.stylus.tool_before_eraser = Some(app.ui.tool); app.ui.tool = Tool::Eraser; + // A tool switch like any other: Add New Artboard's placing mode ends (#11386). + crate::tool_memory::ended_by_tool(app); true } Some(false) => match app.stylus.tool_before_eraser.take() { diff --git a/crates/ui-egui/src/input/stylus/tests.rs b/crates/ui-egui/src/input/stylus/tests.rs index f5c872f4af..8ab18692c9 100644 --- a/crates/ui-egui/src/input/stylus/tests.rs +++ b/crates/ui-egui/src/input/stylus/tests.rs @@ -350,3 +350,62 @@ fn eraser_end_switches_tools_once_and_never_mid_drag() { assert!(!Stylus::sync_eraser_tool(&mut app)); assert_eq!(app.ui.tool, Tool::Eraser); } + +/// Windows reports the pen's end through `Services::pen_end`, not the feed (#2285): flipping the +/// pen switches tools the same way, and a feed sample (automation, another backend) wins. +#[test] +fn the_platform_pen_end_switches_to_the_eraser_and_back() { + use crate::state::Tool; + use std::sync::Arc; + use std::sync::atomic::{AtomicU8, Ordering}; + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", serde_json::json!({"width": 60, "height": 60})).unwrap(); + app.ui.tool = Tool::Brush; + // 0 = no pen, 1 = tip, 2 = eraser end. + let end = Arc::new(AtomicU8::new(0)); + let read = end.clone(); + app.services.pen_end = Some(Box::new(move || match read.load(Ordering::Relaxed) { + 1 => Some(false), + 2 => Some(true), + _ => None, + })); + assert!(!Stylus::sync_eraser_tool(&mut app), "no pen in range"); + end.store(1, Ordering::Relaxed); + assert!(!Stylus::sync_eraser_tool(&mut app), "the tip keeps the tool"); + end.store(2, Ordering::Relaxed); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Eraser); + assert!(!Stylus::sync_eraser_tool(&mut app), "reported once"); + // Out of range and back with the eraser end: still the Eraser, nothing to switch. + end.store(0, Ordering::Relaxed); + assert!(!Stylus::sync_eraser_tool(&mut app)); + end.store(2, Ordering::Relaxed); + assert!(!Stylus::sync_eraser_tool(&mut app)); + end.store(1, Ordering::Relaxed); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Brush, "the tip restores the previous tool"); + // A feed sample is read before the platform's end. + end.store(2, Ordering::Relaxed); + app.stylus.feed.set(Some(PenSample { pressure: 0.5, eraser: false, ..Default::default() })); + assert!(!Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Brush); +} + +/// The pen's eraser end is a tool switch like any other: Add New Artboard's placing mode ends +/// (#11386), and coming back to the Artboard tool on the tip leaves it off. +#[test] +fn the_eraser_end_ends_the_artboard_placing_mode() { + use crate::state::Tool; + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", serde_json::json!({"width": 60, "height": 60})).unwrap(); + app.ui.tool = Tool::Artboard; + app.ui.artboard_tool.adding = true; + let pen = |eraser| Some(PenSample { pressure: 0.5, eraser, ..Default::default() }); + app.stylus.feed.set(pen(true)); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert!(!app.ui.artboard_tool.adding, "the eraser end ends the mode"); + app.stylus.feed.set(pen(false)); + assert!(Stylus::sync_eraser_tool(&mut app)); + assert_eq!(app.ui.tool, Tool::Artboard); + assert!(!app.ui.artboard_tool.adding); +} diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index fb708d9438..e70f05d257 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -651,6 +651,31 @@ fn alt_scroll_zooms_when_the_modifier_state_misses_the_held_alt() { } } +/// #9479: like Photoshop (measured: one notch moves the view in a single frame and nothing +/// after), a wheel notch pans the canvas the whole step at once, with no glide: plain, and +/// sideways with ⇧ or ⌘/Ctrl. +#[test] +fn a_notch_pans_the_canvas_at_once_without_a_glide() { + let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + for (m, axis) in [(Modifiers::NONE, 1), (Modifiers::SHIFT, 0), (ctrl, 0), (cmd, 0)] { + let mut h = harness(); + let p = h.state().last_canvas_rect.center(); + h.hover_at(p); + h.run_steps(2); + let notch = h.ctx.options(|o| o.input_options.line_scroll_speed); + let (z, c0) = (zoom(&h), h.state().ui.views[0].center); + wheel(&h, -1.0, m); + h.run_steps(1); + let c1 = h.state().ui.views[0].center; + assert!(((c1[axis] - c0[axis]) * z - notch).abs() < 0.5, "{m:?}: the whole notch in the first frame: {c0:?} -> {c1:?} at {z}, notch {notch}"); + assert_eq!(c1[1 - axis], c0[1 - axis], "{m:?}: along one axis"); + h.run_steps(30); + assert_eq!(h.state().ui.views[0].center, c1, "{m:?}: no glide after it"); + assert_eq!(zoom(&h), z, "{m:?}: never zooms"); + } +} + #[test] fn type_tool_alt_arrows_kern_the_pair() { use photocraft_doc::text::Kerning; @@ -1006,20 +1031,56 @@ fn eyedropper_and_alt_sampling_show_a_pipette() { assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, desktop)); } -/// The Paint Bucket shows a bucket instead of the crosshair (#3968); Precise Other Cursors keeps -/// the crosshair. +/// The bucket and fill target share one pointer bitmap or one painted image, and Precise Other Cursors hides both. #[test] fn paint_bucket_shows_a_bucket_cursor() { + fn cursor_area_visible(h: &Harness<'static, PhotocraftApp>, id: &str) -> bool { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(id)); + h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)) + } + let mut h = harness(); let p = h.state().last_canvas_rect.center(); h.state_mut().ui.tool = crate::state::Tool::PaintBucket; h.hover_at(p); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the bucket replaces the pointer"); + + let output = h.output(); + if cfg!(not(target_arch = "wasm32")) { + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Crosshair, "the bitmap keeps a visible fallback"); + let cursor = output.platform_output.cursor_image.as_ref().expect("native root viewport uses the unified bucket bitmap"); + let [hot_x, hot_y] = cursor.hotspot; + let hot_pixel = (usize::from(hot_y) * usize::from(cursor.size[0]) + usize::from(hot_x)) * 4 + 3; + assert_eq!(cursor.rgba[hot_pixel], 0, "the Gradient-style crosshair leaves its exact bitmap hotspot open"); + let side = usize::from(cursor.size[0]); + assert!( + cursor.rgba.as_chunks::<4>().0.iter().enumerate().any(|(i, pixel)| { + let x = (i % side) as u16; + let y = (i / side) as u16; + pixel[3] > 200 && x + 2 < hot_x && y + 2 < hot_y + }), + "the bucket artwork is opaque above-left of the same hotspot" + ); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "native cursor has no frame-painted icon overlay"); + assert!(!cursor_area_visible(&h, "pc-tool-cursor"), "native cursor does not create a painted cursor Area"); + } else { + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::None, "the painted image hides the OS arrow"); + assert!(cursor_area_visible(&h, "pc-tool-cursor"), "scaled or web cursor uses one unified image Area"); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "the old icon overlay is absent"); + } + assert_eq!(h.ctx.pointer_latest_pos(), Some(p), "the click target stays at the cursor hotspot"); + h.state_mut().run("prefs.set", json!({"values": {"cursors.other": "precise"}})).unwrap(); - h.hover_at(p); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Crosshair, "Precise keeps the crosshair"); + let precise = h.output(); + assert_eq!(precise.platform_output.cursor_icon, egui::CursorIcon::Crosshair); + assert_eq!( + precise.platform_output.cursor_image.is_some(), + cfg!(not(target_arch = "wasm32")), + "Precise uses its crosshair bitmap on native and the system cursor on web" + ); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "Precise omits the bucket artwork"); + assert!(!cursor_area_visible(&h, "pc-tool-cursor"), "Precise omits the painted bucket image"); } #[test] @@ -1214,6 +1275,20 @@ fn escape_with_the_palette_open_does_not_deselect() { assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the palette"); } +/// The toolbar's "…" flyout owns Esc (it closes there, `toolbar_more_slot_flyout_tests`), so the +/// selection stays. +#[test] +fn escape_with_the_toolbar_more_flyout_open_does_not_deselect() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("toolbar-more-flyout-open"), true)); + h.run_steps(1); + assert!(crate::panels::toolbar::more_flyout_open(&h.ctx)); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the flyout"); +} + /// Targa Options owns Esc (it cancels the save), so the selection stays. #[test] fn escape_with_targa_options_open_does_not_deselect() { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 620574f909..c4ff5397c2 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -70,6 +70,17 @@ pub(crate) struct OpenBatch { opened: Vec, } +/// A file being placed in the background (File › Place, or a file dropped on the canvas): Free +/// Transform starts on its layer when the job lands, and the files dropped after it follow. +#[derive(Clone, Debug)] +pub struct PlaceJob { + pub job: JobId, + /// Display name (the file name). + pub name: String, + /// The document it lands in: Free Transform starts only while that one is in view. + pub doc: DocId, +} + /// The job command of a background save. pub const SAVE_JOB: &str = "file.save"; @@ -165,6 +176,9 @@ pub struct JobsUi { pub focus: Option, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option, + /// The file being placed in the background, if any. One at a time: files dropped meanwhile + /// wait in the app's drop queue. + pub place: Option, /// Cached read-only canvas image of the current folder Batch input. batch_preview_texture: Option<(JobId, u64, egui::TextureHandle)>, } @@ -173,7 +187,10 @@ pub struct JobsUi { /// (the result is then `{"job": id, "pending": true}` and the edit lands when [`tick`] applies /// it), else synchronously like [`photocraft_engine::Session::execute`]. pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result { - if !app.background_jobs { + // A place is also a job where there are no threads (the web build): the session steps it a + // slice per frame, and every other command stays synchronous there. + let place = matches!(id, "file.placeEmbedded" | "file.placeLinked") && place_in_background(app); + if !app.background_jobs && !place { return app.session.execute(id, params).map_err(|e| e.to_string()); } match app.session.start(id, params).map_err(|e| e.to_string())? { @@ -188,6 +205,69 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result bool { + app.background_jobs || app.session.cooperative_jobs() +} + +/// Place a file's bytes in the background (File › Place for a file with no path to read, a dropped +/// file read by the browser): like [`run`] for `file.placeEmbedded`, the result is +/// `{"job": id, "pending": true}` and the layer lands when [`tick`] applies the job. `linked` +/// makes it Place Linked to that path. Hand the result to [`track_place`]. +pub(crate) fn start_place_bytes(app: &mut PhotocraftApp, name: &str, bytes: Vec, linked: Option, params: &Value) -> Result { + if app.jobs.place.is_some() { + return Err(tl!("A file is still being placed; try again when it has finished.").into()); + } + match app.session.start_place_bytes(name, bytes, linked, params.clone()).map_err(|e| e.to_string())? { + Started::Done(v) => Ok(v), + Started::Job(job) => { + app.jobs.last_started = Some(job); + // The status bar's progress readout names the job; clear any old message. + app.ui.status.clear(); + app.ui.status_error = false; + Ok(json!({"job": job.0, "pending": true})) + } + } +} + +/// Follow the place that `started` (the result of [`run`] or [`start_place_bytes`] for the file +/// `name`) began in the background: when it lands, Free Transform starts on its layer and the +/// next dropped file is placed (see `on_event`). False when it already ran (nothing to follow). +pub(crate) fn track_place(app: &mut PhotocraftApp, name: &str, started: &Value) -> bool { + let job = started.get("pending").and_then(Value::as_bool).filter(|pending| *pending).and_then(|_| started.get("job")?.as_u64()); + let (Some(job), Some(doc)) = (job, app.session.active().map(|st| st.doc.id)) else { return false }; + app.jobs.place = Some(PlaceJob { job: JobId(job), name: crate::file_open::display_name(name), doc }); + true +} + +/// A background place ended: start Free Transform on the layer it made, or report why not, then +/// go on with the files dropped meanwhile. +fn finish_place(app: &mut PhotocraftApp, ctx: &egui::Context, place: PlaceJob, outcome: JobOutcome) { + app.sync_views(); + match outcome { + JobOutcome::Done(result) => { + // The tab can change while the file is placed: the box opens only on the document it + // was placed in, and never over another one. An image placed into a frame has none. + if app.ui.transform.is_none() + && result.get("frame").and_then(Value::as_u64).is_none() + && app.session.active().is_some_and(|st| st.doc.id == place.doc) + && let Err(e) = crate::transform_tool::begin_placed(app, ctx) + { + app.ui.status = e; + } + } + JobOutcome::Failed(err) => app.place_failed(&place.name, &err), + JobOutcome::Cancelled if app.session.documents().iter().any(|st| st.doc.id == place.doc) => { + app.ui.status = crate::i18n::fmt(tl!("Cancelled placing {name}"), &[("name", &place.name)]); + app.ui.status_error = false; + } + JobOutcome::Cancelled => {} + } + app.place_next_dropped(ctx); +} + /// Open a file in the background: a tab appears at once and shows the progress; the document /// replaces it when decoded. `path` is remembered for File › Save and Open Recent. `slot` (a file /// dropped on the tabs) and `order` (its place in a set opened together) survive an inline @@ -286,6 +366,7 @@ pub fn start_save( // The engine's wording for a command refused while a job runs. return Err(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", j.label)); } + crate::app::ensure_memory_to_save(&st.doc, &path)?; let name = crate::file_open::display_name(&path); // One writer per file: an older snapshot must not land after a newer one. if app.jobs.saves.iter().any(|s| s.path == path) { @@ -359,7 +440,7 @@ fn finish_save(app: &mut PhotocraftApp, ctx: &egui::Context, save: SaveJob, labe } JobOutcome::Cancelled => { app.jobs.close_after_saves = false; - app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", label)]); + app.ui.status = crate::i18n::fmt(tl!("Canceled {label}"), &[("label", label)]); app.ui.status_error = false; } } @@ -457,13 +538,13 @@ pub fn try_cancel(app: &mut PhotocraftApp, id: JobId) -> Result<(), CancelRefuse // A save that has started replacing the file runs to the end: cancelling it now would leave // the file written but the document marked unsaved. if r == Err(CancelRefused::Committing) { - app.ui.status = crate::i18n::fmt(tl!("{label} is writing the file and can no longer be cancelled"), &[("label", label.as_deref().unwrap_or_default())]); + app.ui.status = crate::i18n::fmt(tl!("{label} is writing the file and can no longer be canceled"), &[("label", label.as_deref().unwrap_or_default())]); app.ui.status_error = false; } if r.is_ok() { // The event (and its status text) arrives with the next poll; say so now as well. if let Some(l) = label { - app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &l)]); + app.ui.status = crate::i18n::fmt(tl!("Canceled {label}"), &[("label", &l)]); app.ui.status_error = false; } } @@ -508,6 +589,12 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { if crate::camera_raw_ui::on_redevelop_event(app, &e) { return; } + if app.jobs.place.as_ref().is_some_and(|p| p.job == e.id) + && let Some(place) = app.jobs.place.take() + { + finish_place(app, ctx, place, e.outcome); + return; + } if e.command == SAVE_JOB && let Some(i) = app.jobs.saves.iter().position(|s| s.job == e.id) { @@ -532,7 +619,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } JobOutcome::Failed(err) => app.open_failed(&tab.name, &err), JobOutcome::Cancelled => { - app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &tab.name)]); + app.ui.status = crate::i18n::fmt(tl!("Canceled opening {name}"), &[("name", &tab.name)]); app.ui.status_error = false; } } @@ -556,7 +643,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { crate::file_ui::print_status(app, result); } JobOutcome::Done(_) if cancelled_batch => { - app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &e.label)]); + app.ui.status = crate::i18n::fmt(tl!("Canceled {label}"), &[("label", &e.label)]); app.ui.status_error = false; } JobOutcome::Done(_) => { @@ -572,7 +659,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { notices::error(app, format!("{}: {err}", e.label)); } JobOutcome::Cancelled => { - app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &e.label)]); + app.ui.status = crate::i18n::fmt(tl!("Canceled {label}"), &[("label", &e.label)]); app.ui.status_error = false; } } @@ -844,7 +931,13 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(j) => j, None => { let Some(j) = app.session.active_job() else { return }; - if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + // A save shows its progress in the tab and the status bar; so does a place, which + // leaves the view free to zoom and pan while the file is decoded. + let place = place_uses_status_bar( + &j.command, + !app.ui.view.hides_chrome() && app.ui.panels.status_bar && job_in_view(app).is_some_and(|shown| shown.id == j.id), + ); + if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || place || app.jobs.saves.iter().any(|s| s.job == j.id) { return; } j @@ -893,7 +986,7 @@ fn batch_interrupt_dialog(app: &mut PhotocraftApp, ctx: &egui::Context, id: JobI ui.set_width(390.0); ui.label(RichText::new(tl!("Batch")).font(crate::theme::semibold(15.0))); ui.add_space(8.0); - ui.label(RichText::new(tl!("A command was cancelled or stopped during batch processing. Continue with the next file or stop?")).color(t.text_dim)); + ui.label(RichText::new(tl!("A command was canceled or stopped during batch processing. Continue with the next file or stop?")).color(t.text_dim)); ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::primary_button(ui, tl!("Continue"), 96.0).clicked() { @@ -1007,6 +1100,7 @@ pub fn inspect(app: &PhotocraftApp) -> Value { "running": app.session.jobs(), "opening": app.jobs.opens.iter().map(|o| json!({"job": o.job.0, "name": o.name, "path": o.path})).collect::>(), "saving": app.jobs.saves.iter().map(|s| json!({"job": s.job.0, "path": s.path})).collect::>(), + "placing": app.jobs.place.as_ref().map(|p| json!({"job": p.job.0, "name": p.name})), "focus": app.jobs.focus.map(|j| j.0), "background": app.background_jobs, }) @@ -1019,3 +1113,20 @@ pub fn bytes(b: &[u8]) -> OpenSource { #[cfg(test)] mod tests; + +/// Placements keep the canvas accessible when progress and Cancel are visible in the status bar. +fn place_uses_status_bar(command: &str, status_bar_visible: bool) -> bool { + status_bar_visible && matches!(command, "file.placeEmbedded" | "file.placeLinked") +} + +#[cfg(test)] +mod place_progress_tests { + #[test] + fn placement_modal_depends_on_command_and_visible_progress() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + assert!(super::place_uses_status_bar(command, true)); + assert!(!super::place_uses_status_bar(command, false)); + } + assert!(!super::place_uses_status_bar("filter.blur", true)); + } +} diff --git a/crates/ui-egui/src/jobs_ui/tests.rs b/crates/ui-egui/src/jobs_ui/tests.rs index 764936b46f..452d2aeca2 100644 --- a/crates/ui-egui/src/jobs_ui/tests.rs +++ b/crates/ui-egui/src/jobs_ui/tests.rs @@ -124,7 +124,7 @@ fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { h.run_steps(3); assert!(h.state().session.job(job).is_none()); assert!(Arc::ptr_eq(&h.state().session.active().unwrap().doc, &doc_before)); - assert!(h.state().ui.status.contains("Cancelled"), "{}", h.state().ui.status); + assert!(h.state().ui.status.contains("Canceled"), "{}", h.state().ui.status); assert!(h.query_by_label("Esc to cancel").is_none(), "the dialog closed"); } @@ -549,7 +549,7 @@ fn a_failed_or_cancelled_foreground_save_leaves_the_document_unsaved() { assert!(gates.written().is_empty(), "nothing written"); let st = h.state().session.active().unwrap(); assert!(st.is_dirty() && st.path.is_none()); - assert_eq!(h.state().ui.status, "Cancelled Saving g.psd"); + assert_eq!(h.state().ui.status, "Canceled Saving g.psd"); assert!(crate::jobs_ui::foreground_save(h.state()).is_none()); } @@ -573,7 +573,7 @@ fn a_failed_save_or_one_cancelled_while_encoding_leaves_the_document_unsaved() { assert!(gates.written().is_empty(), "nothing written"); let st = h.state().session.active().unwrap(); assert!(st.is_dirty() && st.path.is_none()); - assert_eq!(h.state().ui.status, "Cancelled Saving b.psd"); + assert_eq!(h.state().ui.status, "Canceled Saving b.psd"); } /// Step until the save in `h` has started writing. @@ -595,7 +595,7 @@ fn a_save_that_has_started_writing_cannot_be_cancelled() { crate::jobs_ui::cancel(h.state_mut(), job); h.key_press(egui::Key::Escape); h.run_steps(2); - assert_eq!(h.state().ui.status, "Saving w.psd is writing the file and can no longer be cancelled"); + assert_eq!(h.state().ui.status, "Saving w.psd is writing the file and can no longer be canceled"); assert!(h.state().session.job(job).is_some(), "still running, the document still locked"); gates.write.store(true, Ordering::Relaxed); @@ -659,7 +659,7 @@ fn jobs_cancel_before_a_save_writes_stops_it_and_leaves_the_target_untouched() { assert_eq!(std::fs::read(&path).unwrap(), b"old", "target untouched"); let st = h.state().session.active().unwrap(); assert!(st.is_dirty() && st.path.is_none(), "the document is not marked saved"); - assert_eq!(h.state().ui.status, "Cancelled Saving w.psd"); + assert_eq!(h.state().ui.status, "Canceled Saving w.psd"); } #[test] @@ -679,7 +679,7 @@ fn jobs_cancel_during_the_write_reports_the_save_as_not_cancelled_and_it_finishe let refused = json!([{"job": save.0, "reason": "committing"}]); let v = control(&mut h, "jobs.cancel", json!({"job": save.0})); assert_eq!(v["result"], json!({"cancelled": 0, "notCancelled": refused}), "{v}"); - assert_eq!(h.state().ui.status, "Saving w.psd is writing the file and can no longer be cancelled"); + assert_eq!(h.state().ui.status, "Saving w.psd is writing the file and can no longer be canceled"); // Cancel everything: the other job stops, the save is reported as left running. let v = control(&mut h, "jobs.cancel", json!({})); assert_eq!(v["result"], json!({"cancelled": 1, "notCancelled": refused}), "{v}"); @@ -879,7 +879,7 @@ fn cancelled_batch_with_partial_results_reports_cancellation_without_beeping() { let (reply_tx, reply_rx) = std::sync::mpsc::channel(); h.state_mut().jobs.waiters.push((id, reply_tx)); crate::jobs_ui::on_event(h.state_mut(), &ctx, event); - assert!(h.state().ui.status.contains("Cancelled"), "{}", h.state().ui.status); + assert!(h.state().ui.status.contains("Canceled"), "{}", h.state().ui.status); assert_eq!(BEEPS.load(Ordering::Relaxed), 0); let reply = reply_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!(reply["ok"], false); diff --git a/crates/ui-egui/src/layers_empty_area_menu_tests.rs b/crates/ui-egui/src/layers_empty_area_menu_tests.rs new file mode 100644 index 0000000000..c426a7e2c8 --- /dev/null +++ b/crates/ui-egui/src/layers_empty_area_menu_tests.rs @@ -0,0 +1,230 @@ +//! #11404: a right-click on the empty area below the last layer row opens the thumbnail size and +//! clipping choices; a left click there still deselects, and a layer row keeps its own menu. + +use egui::{PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::LayerThumbnailSize; + +const SIZES: [&str; 4] = ["No Thumbnails", "Small Thumbnails", "Medium Thumbnails", "Large Thumbnails"]; +const CLIPS: [&str; 2] = ["Clip Thumbnails to Layer Bounds", "Clip Thumbnails to Document Bounds"]; + +fn panel(open_document: bool) -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + if open_document { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(6); + h +} + +/// A point in the Layers panel well below its last row (above the panel's bottom buttons). +fn empty_point(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + let button = h.get_by_label("Create a new layer").rect(); + pos2(button.center().x - 20.0, button.top() - 80.0) +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, button: PointerButton) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +fn open_menu(h: &mut Harness<'static, PhotocraftApp>) { + let p = empty_point(h); + click(h, p, PointerButton::Secondary); +} + +fn pick(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(4); +} + +fn menu_open(h: &Harness<'static, PhotocraftApp>) -> bool { + SIZES.iter().chain(CLIPS.iter()).any(|l| h.query_by_label(l).is_some()) +} + +fn checked(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_label(label).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +fn size(h: &Harness<'static, PhotocraftApp>) -> LayerThumbnailSize { + h.state().ui.layers_panel_options.thumbnail_size +} + +fn clip(h: &Harness<'static, PhotocraftApp>) -> bool { + h.state().session.prefs().interface.clip_thumbnails_to_layer_bounds +} + +#[test] +fn the_menu_lists_the_six_entries_in_order_with_a_separator() { + let mut h = panel(true); + open_menu(&mut h); + let ys: Vec = SIZES.iter().chain(CLIPS.iter()).map(|l| h.get_by_label(l).rect().top()).collect(); + assert_eq!(ys.len(), 6); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "entries top to bottom in the issue's order: {ys:?}"); + let step = ys[1] - ys[0]; + assert!(ys[4] - ys[3] > step + 1.0, "the separator sits between Large Thumbnails and the clip entries: {ys:?}"); +} + +#[test] +fn the_current_size_and_clip_choice_are_the_ones_checked() { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": true})).unwrap(); + h.run_steps(2); + open_menu(&mut h); + assert!(menu_open(&h)); + assert!(checked(&h, "Large Thumbnails"), "Large is the current size"); + for other in ["No Thumbnails", "Small Thumbnails", "Medium Thumbnails"] { + assert!(!checked(&h, other), "{other}"); + } + assert!(checked(&h, "Clip Thumbnails to Layer Bounds")); + assert!(!checked(&h, "Clip Thumbnails to Document Bounds")); + + pick(&mut h, "Clip Thumbnails to Document Bounds"); + assert!(!clip(&h)); + open_menu(&mut h); + assert!(checked(&h, "Clip Thumbnails to Document Bounds")); + assert!(!checked(&h, "Clip Thumbnails to Layer Bounds")); +} + +#[test] +fn each_size_pick_sets_the_panel_option_and_saves_it() { + for (label, want) in [ + ("No Thumbnails", LayerThumbnailSize::None), + ("Small Thumbnails", LayerThumbnailSize::Small), + ("Large Thumbnails", LayerThumbnailSize::Large), + ("Medium Thumbnails", LayerThumbnailSize::Medium), + ] { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = + if want == LayerThumbnailSize::Large { LayerThumbnailSize::Small } else { LayerThumbnailSize::Large }; + h.run_steps(2); + open_menu(&mut h); + pick(&mut h, label); + assert_eq!(size(&h), want, "{label}"); + assert!(!menu_open(&h), "{label}: the menu closes"); + let saved = h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + let saved = saved.expect("the choice is saved as Panel Options saves it"); + assert_eq!(saved, serde_json::to_value(&h.state().ui.layers_panel_options).unwrap(), "{label}"); + } +} + +/// Picking the size that is already checked closes the menu and leaves the saved options as they +/// were. The harness cannot observe a skipped write directly, so this asserts the saved value is +/// unchanged. +#[test] +fn picking_the_checked_size_closes_the_menu_and_changes_nothing() { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + h.run_steps(2); + crate::panels::layers_panel_ui::persist_options(h.state_mut()); + let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + let before = saved(&h); + assert!(before.is_some(), "an entry is saved to begin with"); + open_menu(&mut h); + assert!(checked(&h, "Large Thumbnails")); + pick(&mut h, "Large Thumbnails"); + assert!(!menu_open(&h), "the menu closes"); + assert_eq!(size(&h), LayerThumbnailSize::Large); + assert_eq!(saved(&h), before, "the saved options are unchanged"); +} + +/// The saved entry can be missing (a preferences reset, an older file) while the panel still +/// shows the size; picking the checked row writes it back. +#[test] +fn picking_the_checked_size_restores_a_missing_saved_entry() { + let mut h = panel(true); + h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + h.run_steps(2); + h.state_mut().session.prefs.edit(|p| { + p.dialogs.remove("ui.layersPanelOptions"); + }); + let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned(); + assert!(saved(&h).is_none(), "the saved entry is gone"); + open_menu(&mut h); + assert!(checked(&h, "Large Thumbnails")); + pick(&mut h, "Large Thumbnails"); + assert!(!menu_open(&h), "the menu closes"); + let restored = saved(&h).expect("the pick writes the entry back"); + assert_eq!(restored, serde_json::to_value(&h.state().ui.layers_panel_options).unwrap()); +} + +#[test] +fn each_clip_pick_sets_the_preference() { + let mut h = panel(true); + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": false})).unwrap(); + h.run_steps(2); + assert!(!clip(&h)); + open_menu(&mut h); + pick(&mut h, "Clip Thumbnails to Layer Bounds"); + assert!(clip(&h)); + assert_eq!(crate::panels::layers_panel_ui::thumbnail_contents(h.state()), crate::state::LayerThumbnailContents::LayerBounds, "Panel Options agrees"); + open_menu(&mut h); + pick(&mut h, "Clip Thumbnails to Document Bounds"); + assert!(!clip(&h)); + assert_eq!(crate::panels::layers_panel_ui::thumbnail_contents(h.state()), crate::state::LayerThumbnailContents::EntireDocument); +} + +#[test] +fn a_left_click_on_the_empty_area_still_deselects_and_a_right_click_does_not() { + let mut h = panel(true); + let selected = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().selected_layers().len(); + h.run_steps(3); + let before = selected(&h); + assert!(before > 0); + open_menu(&mut h); + assert!(menu_open(&h)); + assert_eq!(selected(&h), before, "a right-click does not deselect"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let p = empty_point(&h); + click(&mut h, p, PointerButton::Primary); + assert_eq!(selected(&h), 0, "a left click deselects every layer"); +} + +#[test] +fn with_no_document_nothing_opens() { + // The Layers panel is not drawn without a document, so take its rect from the same layout + // with one open. + let rect = crate::panel_docking::probe::panel_rect(&panel(true).ctx, "layers").expect("the Layers panel is drawn"); + let mut h = panel(false); + let p = rect.center(); + assert!(rect.contains(p)); + click(&mut h, p, PointerButton::Secondary); + assert!(!menu_open(&h)); +} + +#[test] +fn a_right_click_on_a_layer_row_still_opens_the_layer_menu() { + let mut h = panel(true); + let row = h.get_all_by_label("Layer 1").next().expect("the new layer's row").rect(); + click(&mut h, row.center(), PointerButton::Secondary); + assert!(h.query_by_label("Duplicate Layer…").is_some() || h.query_by_label("Duplicate Layer").is_some(), "the layer menu opened"); + assert!(h.query_by_label("No Thumbnails").is_none(), "not the empty-area menu"); +} + +/// A filter that matches no layer draws no row: the whole list is empty space, and its +/// right-click still opens the menu. +#[test] +fn with_no_row_shown_by_the_filter_the_right_click_still_opens_the_menu() { + let mut h = panel(true); + h.state_mut().ui.layer_filter_by = crate::layer_filter_ui::FilterBy::Name; + h.state_mut().ui.layer_filter_name = "no layer has this name".into(); + h.run_steps(4); + assert!(h.query_by_label("Layer 1").is_none(), "the filter hides every row"); + open_menu(&mut h); + assert!(menu_open(&h), "the empty list's right-click opens the menu"); +} diff --git a/crates/ui-egui/src/levels_clipping.rs b/crates/ui-egui/src/levels_clipping.rs new file mode 100644 index 0000000000..66a334e4a6 --- /dev/null +++ b/crates/ui-egui/src/levels_clipping.rs @@ -0,0 +1,405 @@ +//! Photoshop's Levels clipping display (#3328). While Alt is held and the black or white Input +//! slider of the Image › Adjustments › Levels dialog is dragged, the canvas shows what the input +//! levels clip instead of the preview: +//! - black slider: the image turns white; a channel the input levels clip to black drops to 0, so +//! pixels clipped in every channel are black and pixels clipped in red alone are cyan; +//! - white slider: the image turns black; a channel clipped to white rises to 255, so red alone is +//! red and all three are white. +//! +//! Releasing Alt or the mouse shows the preview again; the document is never touched. Grayscale +//! documents show black and white only. CMYK and Lab documents, and commands that edit a channel, +//! a layer mask or a non-pixel layer, keep the normal preview. +//! +//! The display is an opaque texture over the visible part of the image, at about the screen's +//! resolution: one texel per block of `factor`×`factor` document pixels. A block shows a channel +//! clipped when any pixel in it is, so a lone clipped highlight stays visible zoomed out. Clipping +//! is a threshold on each channel's sample (the input stretch is monotone), so the target's +//! per-block minimum and maximum are scanned once per view and a slider move only compares them +//! with the new thresholds. +//! +//! State is the dialog field `__clipping` (`"shadows"` / `"highlights"`): [`sync`] sets it when an +//! Alt-drag starts and clears it when the drag ends, and automation can set it with +//! `ui.dialog.set`. OK never sees it (`filter_dialog::params_of` drops `__` keys). + +use egui::{Color32, Mesh, TextureOptions, pos2}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_compose::adjust::{levels_float, levels_q}; +use photocraft_doc::adjust::{LevelsChannel, ToneSpace}; +use photocraft_doc::{Adjustment, DocId, LayerContent, LayerId}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; + +/// The dialog field that turns the display on. +pub const FIELD: &str = "__clipping"; + +/// A pixel counts where the selection covers at least this much (the command blends the rest). +const SELECTED: f32 = 0.5; + +/// Which input slider's clipping is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ClipEnd { + /// The black slider: what the input levels clip to black. + Shadows, + /// The white slider: what they clip to white. + Highlights, +} + +impl ClipEnd { + /// The [`FIELD`] value that asks for this end. + fn name(self) -> &'static str { + match self { + ClipEnd::Shadows => "shadows", + ClipEnd::Highlights => "highlights", + } + } + + /// The display a `__clipping` field value asks for (anything else: off). + pub fn parse(v: Option<&Value>) -> Option { + match v.and_then(Value::as_str) { + Some("shadows") => Some(ClipEnd::Shadows), + Some("highlights") => Some(ClipEnd::Highlights), + _ => None, + } + } +} + +/// Mirror an Alt-drag on the Levels dialog's black or white Input slider (editor state under +/// `mem`) into the dialog's [`FIELD`]. Only changes are written, so a display turned on through +/// automation stays until it is cleared or a drag ends. +pub fn sync(ctx: &egui::Context, mem: egui::Id, fields: &mut Map) { + if fields.get("__adjust").and_then(Value::as_str) != Some("levels") { + return; + } + let held = ctx.input(|i| i.modifiers.alt && i.pointer.primary_down()); + let live = match crate::adjust_editors::levels_drag(ctx, mem) { + Some(0) if held => Some(ClipEnd::Shadows), + Some(2) if held => Some(ClipEnd::Highlights), + _ => None, + }; + let last_id = mem.with("levels-clipping"); + let last: Option = ctx.data(|d| d.get_temp(last_id)).flatten(); + if live == last { + return; + } + ctx.data_mut(|d| d.insert_temp(last_id, live)); + match live { + Some(end) => { + fields.insert(FIELD.into(), json!(end.name())); + } + None => { + fields.remove(FIELD); + } + } + // The canvas paints before the dialogs: show the change on the next frame. + ctx.request_repaint(); +} + +/// Which ends the Levels input levels clip a sample `v` (0..1) of one channel to, as +/// (black, white): the channel's own input range or, after it, the composite's. `quantum` is the +/// document's (whole levels, as the compositor rounds them; None: 32-bit float, unclamped). Gamma +/// and the output levels don't matter: what reaches an end of an input range is flattened there, +/// wherever the output levels then put it. +pub fn classify(master: &LevelsChannel, channel: &LevelsChannel, v: f32, quantum: Option) -> (bool, bool) { + let eval = |ch: &LevelsChannel, v: f32| if quantum.is_some() { levels_q(ch, v, quantum) } else { levels_float(ch, v) }; + let full = |ch: &LevelsChannel| LevelsChannel { out_black: 0.0, out_white: 1.0, ..ch.clone() }; + let stretch = |ch: &LevelsChannel| LevelsChannel { gamma: 1.0, ..full(ch) }; + let own = eval(&stretch(channel), v); + let composite = eval(&stretch(master), eval(&full(channel), v)); + (own <= 0.0 || composite <= 0.0, own >= 1.0 || composite >= 1.0) +} + +/// A sample as a 16-bit key: exact for 8- and 16-bit documents (their samples are on this grid), +/// 32-bit samples clamped to 0..1 (beyond is clipped either way). Non-finite: None. +fn key(v: f32) -> Option { + v.is_finite().then(|| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16) +} + +/// Per channel (R, G, B): the highest key clipped to black and the lowest clipped to white (None: +/// no key clips that way). +pub type Thresholds = [(Option, Option); 3]; + +/// The [`Thresholds`] of the Levels `adj` in a document of `depth`. None for anything but Levels +/// in the RGB channel space (which Grayscale documents use too). +pub fn thresholds(adj: &Adjustment, depth: SampleType) -> Option { + let Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. } = adj else { return None }; + let quantum = photocraft_compose::adjustment_quantum(depth); + let at = |ch: &LevelsChannel, k: u16| classify(master, ch, f32::from(k) / 65535.0, quantum); + Some(std::array::from_fn(|c| { + let ch = &per_channel[c]; + // Black clipping holds below a key and white clipping above one (the stretch is monotone). + let black = (at(ch, 0).0).then(|| last_true(|k| at(ch, k).0)); + let white = (at(ch, u16::MAX).1).then(|| first_true(|k| at(ch, k).1)); + (black, white) + })) +} + +/// The largest key where `f` holds, given it holds at 0 and stops holding at most once. +fn last_true(f: impl Fn(u16) -> bool) -> u16 { + let (mut lo, mut hi) = (0u32, u32::from(u16::MAX) + 1); + while hi - lo > 1 { + let mid = (lo + hi) / 2; + if f(mid as u16) { + lo = mid; + } else { + hi = mid; + } + } + lo as u16 +} + +/// The smallest key where `f` holds, given it holds at `u16::MAX` and starts holding at most once. +fn first_true(f: impl Fn(u16) -> bool) -> u16 { + let (mut lo, mut hi) = (-1i32, i32::from(u16::MAX)); + while hi - lo > 1 { + let mid = (lo + hi) / 2; + if f(mid as u16) { + hi = mid; + } else { + lo = mid; + } + } + hi as u16 +} + +/// Per block of the scanned area: each channel's minimum (0..3) and maximum (3..6) key over the +/// pixels that count. A block with none has min > max. +type Extremes = [u16; 6]; +const NONE: Extremes = [u16::MAX, u16::MAX, u16::MAX, 0, 0, 0]; + +/// The target's per-block extremes over `rect` (document pixels, `factor`×`factor` blocks). +pub struct Stats { + rect: Rect, + factor: u32, + size: [usize; 2], + blocks: Vec, +} + +/// Scan `surface` (a layer's pixels) over `rect` in `factor`×`factor` blocks. Pixels count when +/// visible (alpha > 0, finite) and, with a `selection`, at least half selected. +pub fn scan(surface: &Surface, selection: Option<&Surface>, rect: Rect, factor: u32) -> Stats { + let factor = factor.max(1); + let f = factor as usize; + let size = [rect.width().div_ceil(factor) as usize, rect.height().div_ceil(factor) as usize]; + let mut blocks = vec![NONE; size[0] * size[1]]; + let row = |by: usize, out: &mut [Extremes]| { + let y0 = rect.y0.saturating_add((by * f) as i32); + let band = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add(factor as i32).min(rect.y1)); + let width = band.width() as usize; + let mut px = vec![[0.0f32; 4]; width * band.height() as usize]; + surface.read_rgba_into(band, &mut px); + let mut coverage = Vec::new(); + let stride = selection.map_or(1, |s| s.channels().max(1)); + if let Some(s) = selection { + s.read_region_into(band, &mut coverage); + } + for (i, p) in px.iter().enumerate() { + let visible = p[3] > 0.0; + let selected = selection.is_none() || coverage.get(i * stride).is_some_and(|k| *k >= SELECTED); + if !visible || !selected { + continue; + } + let (Some(r), Some(g), Some(b)) = (key(p[0]), key(p[1]), key(p[2])) else { continue }; + let Some(e) = out.get_mut((i % width.max(1)) / f) else { continue }; + for (c, k) in [r, g, b].into_iter().enumerate() { + e[c] = e[c].min(k); + e[c + 3] = e[c + 3].max(k); + } + } + }; + if size[0] > 0 { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + blocks.par_chunks_mut(size[0]).enumerate().for_each(|(by, out)| row(by, out)); + } + #[cfg(target_arch = "wasm32")] + for (by, out) in blocks.chunks_mut(size[0]).enumerate() { + row(by, out); + } + } + Stats { rect, factor, size, blocks } +} + +/// The display colour of a pixel clipped to black in the channels of `black` and to white in +/// those of `white` (bit 0 red, 1 green, 2 blue). +pub fn colour(end: ClipEnd, black: u8, white: u8) -> Color32 { + let on = |mask: u8, c: u8| if mask & (1 << c) != 0 { 255 } else { 0 }; + match end { + ClipEnd::Shadows => { + let m = 7 ^ black; + Color32::from_rgb(on(m, 0), on(m, 1), on(m, 2)) + } + ClipEnd::Highlights => Color32::from_rgb(on(white, 0), on(white, 1), on(white, 2)), + } +} + +/// The display of `stats` for `end` under the per-channel `thresholds`, one colour per block (in +/// parallel off the web: this runs on every slider move). +pub fn image(stats: &Stats, thresholds: &Thresholds, end: ClipEnd) -> Vec { + // Integer bounds (none clipped: below every key / above every key) and the 8 possible colours. + let lo = thresholds.map(|(lo, _)| lo.map_or(-1, i32::from)); + let hi = thresholds.map(|(_, hi)| hi.map_or(i32::from(u16::MAX) + 1, i32::from)); + let palette: [Color32; 8] = std::array::from_fn(|m| match end { + ClipEnd::Shadows => colour(end, m as u8, 0), + ClipEnd::Highlights => colour(end, 0, m as u8), + }); + let block = |e: &Extremes| { + let mut mask = 0usize; + for c in 0..3 { + let (min, max) = (i32::from(e[c]), i32::from(e[c + 3])); + let clipped = match end { + ClipEnd::Shadows => min <= max && min <= lo[c], + ClipEnd::Highlights => min <= max && max >= hi[c], + }; + mask |= usize::from(clipped) << c; + } + palette[mask] + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + stats.blocks.par_iter().map(block).collect() + } + #[cfg(target_arch = "wasm32")] + { + stats.blocks.iter().map(block).collect() + } +} + +/// What the display needs from the app: the end shown and the Levels adjustment, for the active +/// document's target layer. +struct Request { + end: ClipEnd, + adj: Adjustment, + doc: std::sync::Arc, + revision: u64, + target: LayerId, +} + +/// The open Levels dialog's fields, if any. A Smart Filter's Levels (`__smartFilter`) edits the +/// filter, not the layer's pixels, so it has no display (`adjust_preview` keeps it off the layer +/// path too). +fn dialog(app: &PhotocraftApp) -> Option<&Map> { + app.ui.dialogs.iter().map(|d| &d.fields).find(|f| f.get("__adjust").and_then(Value::as_str) == Some("levels") && !f.contains_key("__smartFilter")) +} + +/// The display the open Levels dialog asks for on document `idx`. None when no Levels dialog +/// shows clipping, `idx` isn't the active document, or the document can't show it: CMYK and Lab, +/// a channel, mask or Quick Mask target, or a target that isn't a pixel layer. +fn request(app: &PhotocraftApp, idx: usize) -> Option { + let fields = dialog(app)?; + let end = ClipEnd::parse(fields.get(FIELD))?; + let params = crate::filter_dialog::params_of(fields); + if app.session.active_index() != Some(idx) || crate::adjust_preview::targets_channel(app, idx, "levels", ¶ms) { + return None; + } + let st = app.session.documents().get(idx)?; + if !matches!(st.doc.mode, ColorMode::Rgb | ColorMode::Grayscale) { + return None; + } + let target = st.active_layer?; + if !matches!(st.doc.layer(target).map(|l| &l.content), Some(LayerContent::Raster(_))) { + return None; + } + let adj = photocraft_engine::adjust_params::from_params("levels", ¶ms, None, st.doc.mode).ok()?; + Some(Request { end, adj, doc: st.doc.clone(), revision: st.revision, target }) +} + +/// The display's per-view state on the app. +pub struct Cache { + /// What `stats` scanned: document, revision, target, area and block size. + key: (DocId, u64, LayerId, Rect, u32), + stats: Stats, + /// The end and thresholds the texture shows. + drawn: Option<(ClipEnd, Thresholds)>, + tex: Option, +} + +/// Most blocks the display builds (2048 × 2048). A 2560×1440 canvas still gets one block per +/// device pixel, a bigger one bigger blocks: the block stats stay under 50 MB, the texture at +/// 16 MB, and a slider move near the 2 ms `levels_clipping_bench` measures for 3.7M blocks. +const MAX_BLOCKS: u64 = 2048 * 2048; + +/// The visible part of the document in `xf` and the block size giving about one texel per screen +/// pixel (`ppp` physical pixels per point), within `max_side` texels a side and [`MAX_BLOCKS`]. +fn view_area(xf: &ViewXform, bounds: Rect, ppp: f32, max_side: u32) -> Option<(Rect, u32)> { + let device_zoom = xf.zoom * ppp; + if !(device_zoom.is_finite() && device_zoom > 0.0) { + return None; + } + let visible = crate::canvas::visible_doc_rect(xf).intersect(&bounds); + if visible.is_empty() { + return None; + } + let max_side = max_side.max(1); + let (w, h) = (visible.width(), visible.height()); + let budget = ((u64::from(w) * u64::from(h)) as f64 / MAX_BLOCKS as f64).sqrt().ceil() as u32; + let mut factor = ((1.0 / device_zoom).floor().clamp(1.0, 65536.0) as u32).max(w.div_ceil(max_side)).max(h.div_ceil(max_side)).max(budget); + // Snap to the block grid so panning keeps the blocks where they were. That can widen the area + // by up to a block, so grow the blocks until the snapped area fits. + loop { + let f = i32::try_from(factor).ok()?; + let r = Rect::new(visible.x0.div_euclid(f) * f, visible.y0.div_euclid(f) * f, visible.x1, visible.y1).intersect(&bounds); + let (bw, bh) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); + if bw <= max_side && bh <= max_side && u64::from(bw) * u64::from(bh) <= MAX_BLOCKS { + return Some((r, factor)); + } + factor = factor.checked_add(1)?; + } +} + +/// Draw the Levels clipping display over the image in canvas view `xf` of document `idx`, if one +/// is asked for. Drops the cache once no Levels dialog is open. Only the main canvas calls it: +/// the one cache follows that view. +pub fn paint(app: &mut PhotocraftApp, ctx: &egui::Context, painter: &egui::Painter, xf: &ViewXform, idx: usize) { + if dialog(app).is_none() { + app.levels_clipping = None; + return; + } + // A background job holds the workers and may replace the document: no thresholds, scan or + // image until it ends. + if app.session.has_jobs() { + return; + } + let Some(req) = request(app, idx) else { return }; + let Some(thr) = thresholds(&req.adj, req.doc.pixel_format().sample) else { return }; + let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, 8192); + let Some((rect, factor)) = view_area(xf, req.doc.bounds(), ctx.pixels_per_point(), max_side) else { return }; + let key = (req.doc.id, req.revision, req.target, rect, factor); + if app.levels_clipping.as_ref().is_none_or(|c| c.key != key) { + let Some(LayerContent::Raster(surface)) = req.doc.layer(req.target).map(|l| &l.content) else { return }; + let stats = scan(surface, req.doc.selection.as_ref(), rect, factor); + let tex = app.levels_clipping.take().and_then(|c| c.tex); + app.levels_clipping = Some(Cache { key, stats, drawn: None, tex }); + } + let Some(cache) = app.levels_clipping.as_mut() else { return }; + if cache.drawn != Some((req.end, thr)) || cache.tex.is_none() { + let img = egui::ColorImage::new(cache.stats.size, image(&cache.stats, &thr, req.end)); + match &mut cache.tex { + Some(t) => t.set(img, TextureOptions::NEAREST), + None => cache.tex = Some(ctx.load_texture("levels-clipping", img, TextureOptions::NEAREST)), + } + cache.drawn = Some((req.end, thr)); + } + let Some(tex) = cache.tex.as_ref().map(egui::TextureHandle::id) else { return }; + // The texture covers whole blocks; the last row and column may reach past the area. + let s = &cache.stats; + let f = s.factor as f32; + let uv = [s.rect.width() as f32 / (s.size[0] as f32 * f).max(1.0), s.rect.height() as f32 / (s.size[1] as f32 * f).max(1.0)]; + let (x0, y0, x1, y1) = (s.rect.x0 as f32, s.rect.y0 as f32, s.rect.x1 as f32, s.rect.y1 as f32); + let mut mesh = Mesh::with_texture(tex); + for (p, t) in [((x0, y0), (0.0, 0.0)), ((x1, y0), (uv[0], 0.0)), ((x1, y1), (uv[0], uv[1])), ((x0, y1), (0.0, uv[1]))] { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(p.0, p.1), uv: pos2(t.0, t.1), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); +} + +#[cfg(test)] +#[path = "levels_clipping_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/levels_clipping_tests.rs b/crates/ui-egui/src/levels_clipping_tests.rs new file mode 100644 index 0000000000..bed7134a4e --- /dev/null +++ b/crates/ui-egui/src/levels_clipping_tests.rs @@ -0,0 +1,625 @@ +use super::*; +use egui_kittest::Harness; +use photocraft_color::{Color, PixelFormat}; +use photocraft_compose::Buffer; +use photocraft_compose::adjust::{Transfer, apply_depth}; +use photocraft_doc::{Document, Size}; + +const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + +fn lv(in_black: f32, gamma: f32, in_white: f32) -> LevelsChannel { + LevelsChannel { in_black: in_black / 255.0, gamma, in_white: in_white / 255.0, ..LevelsChannel::default() } +} + +fn levels(master: LevelsChannel, per_channel: [LevelsChannel; 3]) -> Adjustment { + Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, black: LevelsChannel::default() } +} + +/// Samples to try at `depth`: every 8-bit level, a 16-bit sweep, and float values past both ends. +fn samples(depth: SampleType) -> Vec { + match depth { + SampleType::U8 => (0..=255).map(|n| n as f32 / 255.0).collect(), + SampleType::U16 => (0..=65535).step_by(13).map(|n| n as f32 / 65535.0).collect(), + SampleType::F32 => (-40..=1100).map(|n| n as f32 / 1000.0).collect(), + } +} + +/// With gamma 1 and the output levels at their defaults, a channel is clipped exactly where the +/// compositor's Levels result sits at an end: 0 for black, 1 for white (the 8-bit LUT staircase +/// and the 32-bit formula; 16-bit against the whole-level chain the LUT samples). +#[test] +fn clipping_matches_the_levels_result() { + let master = lv(30.0, 1.0, 200.0); + let channels = [lv(10.0, 1.0, 240.0), LevelsChannel::default(), lv(60.0, 1.0, 120.0)]; + let adj = levels(master.clone(), channels.clone()); + for depth in DEPTHS { + let quantum = photocraft_compose::adjustment_quantum(depth); + for v in samples(depth) { + let result: [f32; 3] = if depth == SampleType::U16 { + std::array::from_fn(|c| levels_q(&master, levels_q(&channels[c], v, quantum), quantum)) + } else { + let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [v, v, v, 1.0]); + apply_depth(&adj, &mut b, Transfer::for_document(ColorMode::Rgb, depth), Some(depth)); + [b.px[0][0], b.px[0][1], b.px[0][2]] + }; + for (c, (ch, r)) in channels.iter().zip(result).enumerate() { + let (black, white) = classify(&master, ch, v, quantum); + assert_eq!(black, r <= 0.0, "{depth:?} v={v} channel {c}: {r}"); + assert_eq!(white, r >= 1.0, "{depth:?} v={v} channel {c}: {r}"); + } + } + } +} + +/// Gamma and the output levels don't change what the input levels clip, so the display tests the +/// input-range clamps rather than the final value. Narrowed output levels never reach black or +/// white, and gamma below 1 rounds the 8-bit levels just above the black point to black although +/// the slider keeps them. (Gamma above 1 reaches white at the top: its toe is at black.) +#[test] +fn gamma_and_output_levels_do_not_hide_clipping() { + let q = Some(255.0); + let master = LevelsChannel { gamma: 1.5, ..lv(20.0, 1.0, 200.0) }; + let plain = lv(20.0, 1.0, 200.0); + let outputs = LevelsChannel { out_black: 25.0 / 255.0, out_white: 230.0 / 255.0, gamma: 0.6, ..lv(20.0, 1.0, 200.0) }; + let crushed = LevelsChannel { gamma: 0.5, ..lv(20.0, 1.0, 200.0) }; + assert!(levels_q(&outputs, 0.0, q) > 0.0 && levels_q(&outputs, 1.0, q) < 1.0, "narrowed outputs never reach black or white"); + assert_eq!(levels_q(&crushed, 21.0 / 255.0, q), 0.0, "gamma 0.5 rounds level 21 to black"); + for n in 0..=255 { + let v = n as f32 / 255.0; + let id = LevelsChannel::default(); + let want = (n <= 20, n >= 200); + assert_eq!(classify(&master, &id, v, q), want, "gamma 1.5 composite, level {n}"); + assert_eq!(classify(&plain, &id, v, q), want, "level {n}"); + assert_eq!(classify(&outputs, &id, v, q), want, "outputs and gamma 0.6, level {n}"); + assert_eq!(classify(&crushed, &id, v, q), want, "gamma 0.5 composite, level {n}"); + assert_eq!(classify(&id, &master, v, q), want, "gamma 1.5 channel, level {n}"); + } +} + +/// The per-channel thresholds give the same answer as [`classify`] for every key tried. +#[test] +fn thresholds_agree_with_classify() { + let cases = [ + levels(lv(30.0, 1.0, 200.0), Default::default()), + levels(LevelsChannel::default(), [lv(10.0, 0.4, 240.0), lv(0.0, 2.5, 90.0), lv(128.0, 1.0, 130.0)]), + levels(lv(5.0, 3.0, 250.0), [LevelsChannel { out_black: 0.2, out_white: 0.9, ..lv(40.0, 0.7, 220.0) }, lv(0.0, 1.0, 255.0), lv(200.0, 9.0, 255.0)]), + ]; + for adj in &cases { + let Adjustment::Levels { master, per_channel, .. } = adj else { panic!("Levels") }; + for depth in DEPTHS { + let quantum = photocraft_compose::adjustment_quantum(depth); + let thr = thresholds(adj, depth).unwrap(); + let keys: Vec = match depth { + SampleType::U8 => (0..=255u16).map(|n| n * 257).collect(), + _ => (0..=65535u16).step_by(7).chain([u16::MAX]).collect(), + }; + for k in keys { + for (c, (ch, (lo, hi))) in per_channel.iter().zip(thr).enumerate() { + let (black, white) = classify(master, ch, f32::from(k) / 65535.0, quantum); + assert_eq!(black, lo.is_some_and(|lo| k <= lo), "{depth:?} key {k} channel {c} black"); + assert_eq!(white, hi.is_some_and(|hi| k >= hi), "{depth:?} key {k} channel {c} white"); + } + } + } + } +} + +/// Photoshop's threshold display: black slider, a clipped channel drops to 0 on white; white +/// slider, a clipped channel rises to 255 on black. +#[test] +fn colours_follow_the_clipped_channels() { + let rgb = Color32::from_rgb; + assert_eq!(colour(ClipEnd::Shadows, 0, 7), Color32::WHITE); + assert_eq!(colour(ClipEnd::Shadows, 0b001, 0), rgb(0, 255, 255), "red alone: cyan"); + assert_eq!(colour(ClipEnd::Shadows, 0b110, 0), rgb(255, 0, 0), "green and blue: red"); + assert_eq!(colour(ClipEnd::Shadows, 7, 0), Color32::BLACK); + assert_eq!(colour(ClipEnd::Highlights, 7, 0), Color32::BLACK); + assert_eq!(colour(ClipEnd::Highlights, 0, 0b001), rgb(255, 0, 0), "red alone: red"); + assert_eq!(colour(ClipEnd::Highlights, 0, 0b011), rgb(255, 255, 0)); + assert_eq!(colour(ClipEnd::Highlights, 0, 7), Color32::WHITE); +} + +fn surface(fmt: PixelFormat, w: u32, h: u32, px: impl Fn(u32, u32) -> Vec) -> Surface { + let mut s = Surface::new(fmt); + let data: Vec = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| px(x, y)).collect(); + s.write_region(Rect::new(0, 0, w as i32, h as i32), &data); + s +} + +/// A block shows what any counted pixel in it clips; transparent, unselected (under half) and +/// non-finite pixels don't count. +#[test] +fn blocks_keep_lone_extremes_of_the_pixels_that_count() { + let s = surface(PixelFormat::RGBA32F, 8, 8, |x, y| match (x, y) { + (5, 5) => vec![0.0, 0.5, 0.5, 1.0], + (1, 1) => vec![1.0, 1.0, 1.0, 0.0], + (2, 2) => vec![f32::NAN, 1.0, 1.0, 1.0], + (7, 1) => vec![1.0, 1.0, 1.0, 1.0], + _ => vec![0.5, 0.5, 0.5, 1.0], + }); + let thr = [(key(0.1), key(0.9)); 3]; + let grey = surface(PixelFormat::GRAY8, 8, 8, |x, _| vec![if x < 6 { 1.0 } else { 0.4 }]); + for (selection, top_right) in [(None, Color32::WHITE), (Some(&grey), Color32::BLACK)] { + let stats = scan(&s, selection, Rect::new(0, 0, 8, 8), 4); + assert_eq!(stats.size, [2, 2]); + let black = image(&stats, &thr, ClipEnd::Shadows); + assert_eq!(black, vec![Color32::WHITE, Color32::WHITE, Color32::WHITE, Color32::from_rgb(0, 255, 255)], "{selection:?}"); + let white = image(&stats, &thr, ClipEnd::Highlights); + assert_eq!(white, vec![Color32::BLACK, top_right, Color32::BLACK, Color32::BLACK], "{selection:?}"); + } +} + +/// Gray documents' Levels act on one channel, so the display is black and white only. +#[test] +fn gray_layers_show_black_and_white_only() { + let s = surface(PixelFormat::GRAYA8, 16, 1, |x, _| vec![x as f32 / 15.0, 1.0]); + let adj = levels(lv(100.0, 1.0, 180.0), Default::default()); + let thr = thresholds(&adj, SampleType::U8).unwrap(); + let stats = scan(&s, None, Rect::new(0, 0, 16, 1), 1); + for end in [ClipEnd::Shadows, ClipEnd::Highlights] { + for (x, c) in image(&stats, &thr, end).into_iter().enumerate() { + let v = x as f32 / 15.0 * 255.0; + let clipped = if end == ClipEnd::Shadows { v <= 100.0 } else { v >= 180.0 }; + assert!(c == Color32::BLACK || c == Color32::WHITE, "x={x}: {c:?}"); + assert_eq!(c == colour(end, 7, 7), clipped, "{end:?} x={x}"); + } + } +} + +/// Out-of-range and non-finite settings never panic. +#[test] +fn hostile_settings_do_not_panic() { + let bad = LevelsChannel { in_black: f32::NAN, in_white: -3.0, gamma: 0.0, out_black: f32::INFINITY, out_white: f32::NEG_INFINITY }; + for adj in [levels(bad.clone(), [bad.clone(), LevelsChannel::default(), lv(255.0, 100.0, 0.0)]), levels(LevelsChannel::default(), Default::default())] { + for depth in DEPTHS { + let _ = thresholds(&adj, depth); + } + } + let cmyk = + Adjustment::Levels { master: LevelsChannel::default(), per_channel: Default::default(), space: ToneSpace::Cmyk, black: LevelsChannel::default() }; + assert!(thresholds(&cmyk, SampleType::U8).is_none()); + assert!(thresholds(&Adjustment::Invert, SampleType::U8).is_none()); + for params in [json!({"inBlack": 1e9, "inWhite": -5, "gamma": 0}), json!({"red": {"gamma": 100, "inWhite": 0}}), json!({"inBlack": "x"})] { + if let Ok(adj) = photocraft_engine::adjust_params::from_params("levels", ¶ms, None, ColorMode::Rgb) { + let _ = thresholds(&adj, SampleType::U16); + } + } +} + +/// A 16×8 document: the background's red channel ramps 0..1 left to right, green is 0.5, blue 1. +fn ramp_app(mode: ColorMode, depth: SampleType) -> PhotocraftApp { + let mut doc = Document::with_background("t", Size::new(16, 8), mode, depth, Color::rgba(1.0, 1.0, 1.0, 1.0)); + let gray = mode == ColorMode::Grayscale; + if let Some(s) = doc.layers[0].surface_mut() { + let data: Vec = + (0..8).flat_map(|_| 0..16).flat_map(|x| if gray { vec![x as f32 / 15.0, 1.0] } else { vec![x as f32 / 15.0, 0.5, 1.0, 1.0] }).collect(); + s.write_region(Rect::new(0, 0, 16, 8), &data); + } + let target = doc.layers[0].id; + let mut session = photocraft_engine::Session::new(); + session.add_document(doc, None); + session.select_layer(target).unwrap(); + PhotocraftApp::new(session, crate::Services::default()) +} + +fn open_levels(app: &mut PhotocraftApp, params: Value, end: Option<&str>) -> u64 { + let id = crate::adjust_dialog::open(app, "image.adjustments.levels").unwrap(); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + if let Value::Object(p) = params { + fields.extend(p); + } + if let Some(end) = end { + fields.insert(FIELD.into(), json!(end)); + } + id +} + +/// A canvas view of the 16×8 document at `zoom` points per pixel (1 pixel per point). +fn view(zoom: f32) -> ViewXform { + ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(16.0 * zoom, 8.0 * zoom)), + zoom, + center: [8.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + } +} + +/// [`paint`] in a frame of its own (so its mesh doesn't spill into a later frame's shapes), then +/// the colours of the blocks it shows. +fn paint_view(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform) -> Option> { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = egui::Painter::new(ui.ctx().clone(), egui::LayerId::background(), egui::Rect::EVERYTHING); + paint(app, ui.ctx(), &painter, xf, 0); + }); + out.textures_delta.clear(); + let c = app.levels_clipping.as_ref()?; + let (end, thr) = c.drawn?; + Some(image(&c.stats, &thr, end)) +} + +/// A view at one device pixel per document pixel whose visible document rect (with +/// `visible_doc_rect`'s 2 px margin) is exactly `x0..x1` × `y0..y1`. +fn xf_over(x0: i32, x1: i32, y0: i32, y1: i32) -> ViewXform { + let size = egui::vec2((x1 - x0 - 4) as f32, (y1 - y0 - 4) as f32); + ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, size), + zoom: 1.0, + center: [(x0 + x1) as f32 / 2.0, (y0 + y1) as f32 / 2.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + } +} + +/// Snapping the area to the block grid can widen it by up to a block; the blocks grow until the +/// snapped area still fits the texture limit (here a 2048 px device limit) on both sides. +#[test] +fn the_snapped_area_fits_the_texture_limit() { + let bounds = Rect::new(0, 0, 20000, 20000); + let (r, f) = view_area(&xf_over(1, 4097, 0, 100), bounds, 1.0, 2048).unwrap(); + assert_eq!((r.x0, f), (0, 3), "4096 px from x 1: blocks of 2 would need 2049"); + for (x0, y0) in [(0, 0), (1, 1), (2, 3), (5, 7), (1023, 2047)] { + for span in 4090..4100 { + let (x1, y1) = (x0 + span, y0 + span / 2); + let (r, f) = view_area(&xf_over(x0, x1, y0, y1), bounds, 1.0, 2048).unwrap(); + let fi = f as i32; + assert!(r.width().div_ceil(f) <= 2048 && r.height().div_ceil(f) <= 2048, "{x0},{y0} span {span}: {r:?} / {f}"); + assert!(r.x0 % fi == 0 && r.y0 % fi == 0, "snapped to the grid: {r:?} / {f}"); + assert!(r.x0 <= x0 && r.y0 <= y0 && r.x1 >= x1 && r.y1 >= y1, "covers the view: {r:?}"); + } + } +} + +/// However big the display, the display builds at most `MAX_BLOCKS` blocks: an 8K canvas at +/// 100 % gets bigger blocks instead of a 33M-texel texture, while 2560×1440 keeps one block per +/// device pixel. +#[test] +fn a_huge_display_grows_the_blocks() { + let bounds = Rect::new(0, 0, 20000, 20000); + let (_, f) = view_area(&xf_over(0, 2564, 0, 1444), bounds, 1.0, 8192).unwrap(); + assert_eq!(f, 1, "2560×1440: one block per device pixel"); + for (w, h) in [(7680, 4320), (8192, 8192), (15360, 8640)] { + let (r, f) = view_area(&xf_over(0, w, 0, h), bounds, 1.0, 8192).unwrap(); + let blocks = u64::from(r.width().div_ceil(f)) * u64::from(r.height().div_ceil(f)); + assert!(blocks <= MAX_BLOCKS, "{w}×{h}: {blocks} blocks of {f}"); + assert!(blocks > MAX_BLOCKS / 4, "{w}×{h}: no coarser than needed ({blocks} blocks of {f})"); + } +} + +/// The main canvas's view of the 16×8 document at 10 device pixels per pixel. +fn main_view(app: &mut PhotocraftApp) -> crate::state::View { + app.sync_views(); + crate::state::View { zoom: 10.0, center: [8.0, 4.0], fit_pending: false, fill_pending: false, ..app.ui.views[0].clone() } +} + +/// The shapes of one 160×80 canvas frame of document 0 in `view`. +fn canvas_frame(ctx: &egui::Context, app: &mut PhotocraftApp, view: &crate::state::View, primary: bool) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + crate::canvas::canvas_view(app, ui, 0, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(160.0, 80.0)), view.clone(), primary); + }); + out.textures_delta.clear(); + out.shapes +} + +/// How many meshes among `shapes` (shape lists included) draw texture `tex`. +fn meshes_with(shapes: &[egui::epaint::ClippedShape], tex: egui::TextureId) -> usize { + fn count(s: &egui::Shape, tex: egui::TextureId) -> usize { + match s { + egui::Shape::Mesh(m) => usize::from(m.texture_id == tex), + egui::Shape::Vec(v) => v.iter().map(|s| count(s, tex)).sum(), + _ => 0, + } + } + shapes.iter().map(|c| count(&c.shape, tex)).sum() +} + +/// The display keeps one cache, sized to the main canvas's view: an extra document window's +/// canvas (not primary) neither draws it nor replaces that cache with its own view. +#[test] +fn only_the_main_canvas_draws_the_display() { + let ctx = egui::Context::default(); + let mut app = ramp_app(ColorMode::Rgb, SampleType::U8); + open_levels(&mut app, json!({"inBlack": 100}), Some("shadows")); + let main = main_view(&mut app); + let other = crate::state::View { zoom: 0.25, ..main.clone() }; + let shapes = canvas_frame(&ctx, &mut app, &main, true); + let cache = app.levels_clipping.as_ref().expect("the main canvas builds the display"); + let (key, tex) = (cache.key, cache.tex.as_ref().map(egui::TextureHandle::id).expect("its texture")); + assert_eq!(meshes_with(&shapes, tex), 1, "the main canvas draws the display"); + let shapes = canvas_frame(&ctx, &mut app, &other, false); + assert_eq!(meshes_with(&shapes, tex), 0, "an extra window doesn't draw it"); + assert_eq!(app.levels_clipping.as_ref().map(|c| c.key), Some(key), "nor replaces the main canvas's cache"); +} + +/// The display covers the view at about one texel per screen pixel, in every depth and in Gray, +/// and goes away with the dialog. +#[test] +fn paint_shows_the_target_clipping_for_the_view() { + let ctx = egui::Context::default(); + for (mode, depth) in + [(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Grayscale, SampleType::U8)] + { + let mut app = ramp_app(mode, depth); + let id = open_levels(&mut app, json!({"inBlack": 100, "inWhite": 200}), Some("shadows")); + let px = paint_view(&mut app, &ctx, &view(10.0)).unwrap_or_else(|| panic!("{mode:?} {depth:?}")); + assert_eq!(app.levels_clipping.as_ref().unwrap().stats.size, [16, 8]); + let gray = mode == ColorMode::Grayscale; + let clipped = if gray { Color32::BLACK } else { Color32::from_rgb(0, 255, 255) }; + for (x, p) in px.iter().take(16).enumerate() { + let want = if x as f32 / 15.0 * 255.0 <= 100.0 { clipped } else { Color32::WHITE }; + assert_eq!(*p, want, "{mode:?} {depth:?} shadows x={x}"); + } + app.ui.dialog_mut(id).unwrap().fields.insert(FIELD.into(), json!("highlights")); + let px = paint_view(&mut app, &ctx, &view(10.0)).unwrap(); + for (x, p) in px.iter().skip(16 * 7).enumerate() { + let red = x as f32 / 15.0 * 255.0 >= 200.0; + let want = match (gray, red) { + (true, true) => Color32::WHITE, + (true, false) => Color32::BLACK, + (false, true) => Color32::from_rgb(255, 0, 255), + (false, false) => Color32::from_rgb(0, 0, 255), + }; + assert_eq!(*p, want, "{mode:?} {depth:?} highlights x={x}"); + } + // Zoomed out, a texel covers a block of pixels. + paint_view(&mut app, &ctx, &view(0.25)).unwrap(); + assert_eq!(app.levels_clipping.as_ref().unwrap().stats.factor, 4); + assert_eq!(app.levels_clipping.as_ref().unwrap().stats.size, [4, 2]); + // Off with the dialog still open: the cached texture stays but no canvas frame draws it. + let main = main_view(&mut app); + let shapes = canvas_frame(&ctx, &mut app, &main, true); + let tex = app.levels_clipping.as_ref().and_then(|c| c.tex.as_ref()).map(egui::TextureHandle::id).unwrap(); + assert_eq!(meshes_with(&shapes, tex), 1, "{mode:?} {depth:?}: on, it draws"); + app.ui.dialog_mut(id).unwrap().fields.insert(FIELD.into(), Value::Null); + assert!(request(&app, 0).is_none()); + let shapes = canvas_frame(&ctx, &mut app, &main, true); + assert_eq!(meshes_with(&shapes, tex), 0, "{mode:?} {depth:?}: off, it draws nothing"); + assert!(app.levels_clipping.is_some(), "{mode:?} {depth:?}: the cache stays while the dialog is open"); + // Closed: the cache goes. + app.ui.close_dialog(id); + paint_view(&mut app, &ctx, &view(10.0)); + assert!(app.levels_clipping.is_none(), "{mode:?} {depth:?}"); + } +} + +/// CMYK and Lab documents, a Quick Mask, and a target that isn't a pixel layer keep the normal +/// preview. +#[test] +fn ineligible_documents_show_no_display() { + for mode in ["cmyk", "lab"] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8, "mode": mode})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + open_levels(&mut app, json!({}), Some("shadows")); + assert!(request(&app, 0).is_none(), "{mode}"); + } + let mut app = ramp_app(ColorMode::Rgb, SampleType::U8); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.quick_mask.is_some()); + open_levels(&mut app, json!({}), Some("shadows")); + assert!(request(&app, 0).is_none(), "Quick Mask"); + let mut app = ramp_app(ColorMode::Rgb, SampleType::U8); + app.run("layer.newFillLayer.solidColor", json!({"color": "#808080"})).unwrap(); + open_levels(&mut app, json!({}), Some("shadows")); + assert!(request(&app, 0).is_none(), "fill layer"); +} + +/// While a background job runs the display draws nothing and neither scans nor builds its +/// texture. It comes back once the job is done. +#[test] +fn a_running_job_holds_the_display() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + let ctx = egui::Context::default(); + let mut app = ramp_app(ColorMode::Rgb, SampleType::U8); + open_levels(&mut app, json!({"inBlack": 100}), Some("shadows")); + let gate = Arc::new(AtomicBool::new(false)); + let open = gate.clone(); + let started = app + .session + .start_job( + "test.slow", + json!({}), + "Slow", + false, + move |job| { + while !open.load(Ordering::Relaxed) { + job.check()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Ok(()) + }, + |_, ()| Ok(json!({})), + ) + .unwrap(); + let photocraft_engine::jobs::Started::Job(job) = started else { panic!("ran inline") }; + assert!(app.session.has_jobs()); + assert!(paint_view(&mut app, &ctx, &view(10.0)).is_none()); + assert!(app.levels_clipping.is_none(), "no scan and no texture while the job runs"); + gate.store(true, Ordering::Relaxed); + app.session.wait_job(job).unwrap(); + assert!(!app.session.has_jobs()); + assert!(paint_view(&mut app, &ctx, &view(10.0)).is_some(), "back once the job is done"); +} + +/// Closing the last document drops the display's cache: no canvas is left to paint it away. +#[test] +fn closing_the_last_document_drops_the_cache() { + let ctx = egui::Context::default(); + let mut app = ramp_app(ColorMode::Rgb, SampleType::U8); + open_levels(&mut app, json!({"inBlack": 100}), Some("shadows")); + assert!(paint_view(&mut app, &ctx, &view(10.0)).is_some()); + app.run("file.close", json!({})).unwrap(); + assert!(app.session.documents().is_empty()); + assert!(app.levels_clipping.is_none()); +} + +/// A Smart Filter's Levels dialog edits the filter, not the layer's pixels: no display, and its +/// cache goes as if no Levels dialog were open, even over a pixel layer. +#[test] +fn smart_filter_levels_show_no_display() { + let ctx = egui::Context::default(); + let mut app = ramp_app(ColorMode::Rgb, SampleType::U8); + let id = open_levels(&mut app, json!({"inBlack": 100}), Some("shadows")); + assert!(paint_view(&mut app, &ctx, &view(10.0)).is_some(), "a plain Levels dialog shows it"); + let layer = app.session.active().and_then(|s| s.active_layer).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("__smartFilter".into(), json!({"layer": layer.0})); + assert!(request(&app, 0).is_none()); + assert!(paint_view(&mut app, &ctx, &view(10.0)).is_none()); + assert!(app.levels_clipping.is_none(), "the cache goes"); +} + +fn dialog_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h +} + +fn clipping(h: &Harness<'static, PhotocraftApp>) -> Option { + h.state().ui.dialogs.first().and_then(|d| d.fields.get(FIELD).cloned()) +} + +fn press(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2, pressed: bool, modifiers: egui::Modifiers) { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn modifiers(h: &mut Harness<'static, PhotocraftApp>, m: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + h.run_steps(1); +} + +/// Alt-dragging the black slider shows the shadows display while both are held, the white slider +/// the highlights; the gamma slider, a drag without Alt and the Properties panel show nothing. +/// The document stays untouched throughout. +#[test] +fn alt_drag_on_the_black_and_white_sliders_turns_the_display_on() { + let mut h = dialog_harness(ramp_app(ColorMode::Rgb, SampleType::U8)); + let original = h.state().session.active().unwrap().doc.clone(); + let id = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.levels").unwrap(); + h.run_steps(3); + let track = h.ctx.data(|d| d.get_temp::(egui::Id::unique(("adjust-dialog", "levels")).with("levels-slider"))).unwrap(); + let at = |level: f32| egui::pos2(track.left() + level / 255.0 * track.width(), track.center().y + 4.0); + let alt = egui::Modifiers::ALT; + // Black slider: on while Alt and the button are held, off with either. + modifiers(&mut h, alt); + h.event(egui::Event::PointerMoved(at(0.0))); + press(&mut h, at(0.0), true, alt); + assert_eq!(clipping(&h), Some(json!("shadows"))); + h.event(egui::Event::PointerMoved(at(60.0))); + h.run_steps(1); + let in_black = h.state().ui.dialogs[0].fields["inBlack"].as_f64().unwrap(); + assert!((55.0..=65.0).contains(&in_black), "the drag still moves the slider: {in_black}"); + assert_eq!(clipping(&h), Some(json!("shadows"))); + modifiers(&mut h, egui::Modifiers::NONE); + assert_eq!(clipping(&h), None, "releasing Alt shows the preview"); + modifiers(&mut h, alt); + assert_eq!(clipping(&h), Some(json!("shadows")), "Alt again"); + press(&mut h, at(60.0), false, alt); + assert_eq!(clipping(&h), None, "releasing the mouse shows the preview"); + // White slider. + h.event(egui::Event::PointerMoved(at(255.0))); + press(&mut h, at(255.0), true, alt); + assert_eq!(clipping(&h), Some(json!("highlights"))); + press(&mut h, at(255.0), false, alt); + assert_eq!(clipping(&h), None); + // Gamma slider (halfway between 60 and 255 at gamma 1). + let gamma = 60.0 + (255.0 - 60.0) / 2.0; + h.event(egui::Event::PointerMoved(at(gamma))); + press(&mut h, at(gamma), true, alt); + assert_eq!(clipping(&h), None, "the gamma slider has no display"); + press(&mut h, at(gamma), false, alt); + // No Alt. + modifiers(&mut h, egui::Modifiers::NONE); + h.event(egui::Event::PointerMoved(at(60.0))); + press(&mut h, at(60.0), true, egui::Modifiers::NONE); + assert_eq!(clipping(&h), None, "no Alt, no display"); + press(&mut h, at(60.0), false, egui::Modifiers::NONE); + assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &original)); + assert!(h.state().ui.dialogs.iter().any(|d| d.id == id)); +} + +/// Set through automation, the display stays until cleared; it never re-keys the live preview, +/// touches the document or reaches OK. +#[test] +fn the_display_never_reaches_the_document() { + let commit = |end: Option<&str>| { + let mut h = dialog_harness(ramp_app(ColorMode::Rgb, SampleType::U8)); + let original = h.state().session.active().unwrap().doc.clone(); + let steps = h.state().session.active().unwrap().history.past_len(); + let id = open_levels(h.state_mut(), json!({"inBlack": 60, "gamma": 1.2}), None); + h.run_steps(3); + let _ = crate::adjust_preview::display_doc(h.state_mut(), 0); + let key = crate::adjust_preview::shown_key(h.state()); + if let Some(end) = end { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(FIELD.into(), json!(end)); + h.run_steps(3); + assert_eq!(clipping(&h), Some(json!(end)), "an automation-set display stays"); + let _ = crate::adjust_preview::display_doc(h.state_mut(), 0); + assert_eq!(crate::adjust_preview::shown_key(h.state()), key, "the preview is not re-keyed"); + assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &original)); + } + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1); + let mut px = Vec::new(); + st.doc.layers[0].surface().unwrap().read_region_into(Rect::new(0, 0, 16, 8), &mut px); + px + }; + let plain = commit(None); + for end in ["shadows", "highlights"] { + assert_eq!(commit(Some(end)), plain, "{end}: OK commits only the levels"); + } +} + +/// Scan once per view, then per slider move thresholds + colours (the texture upload comes on +/// top). `CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo test +/// --release -p photocraft-ui-egui --lib levels_clipping_bench -- --ignored --nocapture` +#[test] +#[ignore = "benchmark"] +fn levels_clipping_bench() { + let adj = levels(lv(40.0, 1.3, 215.0), [lv(5.0, 1.0, 250.0), LevelsChannel::default(), lv(0.0, 0.8, 240.0)]); + for (w, h) in [(6000u32, 4000u32), (7360, 4912)] { + let mut s = Surface::new(PixelFormat::RGBA8); + for y0 in (0..h).step_by(256) { + let rows = 256.min(h - y0); + let data: Vec = (0..rows * w) + .flat_map(|i| { + let (x, y) = (i % w, y0 + i / w); + let n = ((x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503)) >> 7) % 64; + let v = (x as f32 / w as f32 * 0.8 + n as f32 / 255.0).min(1.0); + [v, 1.0 - v, (y as f32 / h as f32), 1.0] + }) + .collect(); + s.write_region(Rect::new(0, y0 as i32, w as i32, (y0 + rows) as i32), &data); + } + let fit = w.div_ceil(2400); + for (name, rect, factor) in [("fit", Rect::new(0, 0, w as i32, h as i32), fit), ("100%", Rect::new(1000, 1000, 3560, 2440), 1)] { + let best = |f: &mut dyn FnMut()| { + (0..5) + .map(|_| { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .fold(f64::MAX, f64::min) + }; + let scan_ms = best(&mut || { + std::hint::black_box(scan(&s, None, rect, factor)); + }); + let stats = scan(&s, None, rect, factor); + let update_ms = best(&mut || { + let thr = thresholds(&adj, SampleType::U8).unwrap(); + std::hint::black_box(egui::ColorImage::new(stats.size, image(&stats, &thr, ClipEnd::Shadows))); + }); + println!( + "{} MP {name} (1/{factor}, {}×{} texels): scan {scan_ms:.1} ms, update {update_ms:.1} ms", + (w * h) / 1_000_000, + stats.size[0], + stats.size[1] + ); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b9a1380e9a..7d5d2a90e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,16 +29,21 @@ pub mod canvas; pub mod control; pub mod dialogs; pub mod dock; +pub(crate) mod edit_session; pub mod file_dialog; pub mod file_open; pub mod frame_stages; +pub mod gpu_memo; pub mod gpu_status; +pub mod home_recent_ui; pub mod i18n; mod icon_data; pub mod icons; pub mod jobs_ui; pub(crate) mod last_brush; +pub mod levels_clipping; pub mod links; +pub mod memory_warning; pub mod menu_catalog; pub mod menus; pub mod monitor_status; @@ -145,12 +150,14 @@ pub use dialogs::disk_change_prompt; pub use dialogs::distort_ui; pub use dialogs::duplicate_layer_ui; pub use dialogs::edit_history; +pub use dialogs::equalize_ui; pub use dialogs::export_dialog; pub use dialogs::file_ui; pub use dialogs::fill_ui; pub use dialogs::filter_dialog; use dialogs::filter_preview_ui; pub use dialogs::gallery_ui; +use dialogs::guide_edit; pub use dialogs::guide_layout_ui; pub use dialogs::history_options_ui; pub use dialogs::jpeg_options_ui; @@ -255,8 +262,8 @@ pub use services::{ AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn, CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, - LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, - SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, + LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PenEndFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, + SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, TabletPollFn, WindowFrameFn, WriteFn, }; pub use text::bidi_text; pub use text::cjk_fonts; @@ -281,6 +288,7 @@ pub use tools::crop_presets; pub use tools::crop_shield; pub use tools::crop_size; pub use tools::crop_straighten; +pub use tools::crop_task_bar; pub use tools::crop_ui; pub use tools::direct_select; pub use tools::eraser_ui; @@ -301,6 +309,7 @@ pub use tools::perspective_crop_ui; pub use tools::perspective_ui; pub use tools::puppet_ui; pub use tools::retouch_ui; +pub use tools::shape_bounds_box; use tools::shape_gradient_ui; use tools::shape_options; use tools::shape_paint_ui; @@ -356,6 +365,7 @@ pub struct PhotocraftApp { info_samplers: Option, /// Display profile readings (#569). monitors: monitor_status::State, + pub(crate) thumbnail_cache: std::sync::Arc>, checker: Option, drag: Option, /// An owned vector drag was cancelled (`canvas::cancel_stale_vector_drag`): the rest of its @@ -422,6 +432,11 @@ pub struct PhotocraftApp { pub(crate) brush_resize: Option, /// A ⌘⌥⌃-click layer pick is in progress; its drag and release are swallowed (`quick_pick`). pub(crate) quick_pick: bool, + /// The layer a canvas click picked the Move tool way (its Auto-Select, or a ⌘-click from another + /// tool) and the engine's active-layer change count right after that pick + /// (`Session::active_layer_changes`). The Layers panel's reveal reads it when it next draws, and + /// accepts it only if nothing changed the active layer since, however long ago (`layer_reveal`, #11381). + pub(crate) move_pick: Option<(photocraft_doc::LayerId, u64)>, /// The next tool `Down` is an Alt+right-drag that resizes the brush (#297). `tool_event` /// takes it on every event, so a press another handler consumes can't leave it set. pub(crate) brush_resize_armed: bool, @@ -452,6 +467,8 @@ pub struct PhotocraftApp { /// the frame the pointer leaves, so a long stationary hover never counts as elapsed time /// (#2022); set by `notices::show`. pub(crate) notices_hovered: bool, + /// The low-memory warning's schedule and state (`memory_warning`). + pub(crate) low_memory: memory_warning::Watch, /// Apply theme on first frame. styled: bool, /// Whether the window uses an integrated (transparent) macOS title bar. @@ -478,8 +495,11 @@ pub struct PhotocraftApp { pub(crate) drop_canvas_rect: Option, /// The document tab strip last frame: a drop there opens the file at the slot under it. pub(crate) tab_strip: Option, - /// Files dropped on the canvas still to place, one Free Transform at a time. - pub(crate) drop_places: std::collections::VecDeque, + pub(crate) frame_tool_ctx: Option, + pub(crate) frame_preview: Option, + /// Files dropped on the canvas still to place, one Free Transform at a time, each with the + /// document it was dropped on. + pub(crate) drop_places: std::collections::VecDeque<(DocId, file_open::DropPlace)>, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, /// The document `ui.selected_path` belongs to, as of the last [`Self::sync_views`]: a Paths @@ -491,9 +511,10 @@ pub struct PhotocraftApp { /// The document area each layer thumbnail in `thumbs` frames (the canvas, or the layer's /// bounds with Clip Thumbnails to Layer Bounds; #4281). thumb_frames: HashMap, - /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each - /// is made once (`snapshot_thumb`, #2966). - snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, + /// History snapshot thumbnails by (document, snapshot id), each with the snapshot document it + /// shows (`snapshot_thumb`, #2966): made once, and again only when that is a different one (a + /// preserved-ID reopen, or the first snapshot refreshed for a served font, #6087). + snapshot_thumbs: HashMap<(DocId, u64), (std::sync::Weak, egui::TextureHandle)>, /// Snapshots whose live layer/mask keys were last used to prune thumbnail handles. thumb_documents: Vec<(DocId, std::sync::Weak)>, /// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms. @@ -504,6 +525,7 @@ pub struct PhotocraftApp { pub(crate) proxy_uploaded: Option<(DocId, u64)>, /// Selection outline cache: (doc, revision, segments). /// Live filter preview (proxy document with the filter applied). + pub(crate) guide_edit: Option, pub(crate) filter_preview: Option, pub(crate) smart_filter_targets: smart_filter_dialog::Targets, #[cfg(not(target_arch = "wasm32"))] @@ -519,6 +541,8 @@ pub struct PhotocraftApp { /// The Properties panel's live adjustment-layer drag: its damage region and coverage /// (`adjust_preview::live_key`). pub(crate) live_adjust_preview: Option, + /// The Levels dialog's Alt-drag clipping display over the canvas. + pub(crate) levels_clipping: Option, /// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame. pub(crate) synthetic: Vec, /// The one synthetic batch currently being delivered. @@ -539,6 +563,8 @@ pub struct PhotocraftApp { /// makes shortcut- and dialog-triggered commands pass the same policy as /// direct control calls. pub(crate) automation_input: bool, + /// The direct control-command route keeps Actions playback synchronous and dialog-free. + pub(crate) programmatic_playback: bool, /// How many `run_command` calls are on the stack. Only the outermost one writes the frame's /// command stage: a nested run (`file.save` → `layer.smartObjects.saveContents`, /// `actions.play` → its steps) is already inside the outer call's time. @@ -580,9 +606,9 @@ pub struct PhotocraftApp { pub(crate) gradient: gradient_ui::LiveGradient, /// Filter › Camera Raw Filter dialog (camera_raw_ui). pub(crate) camera_raw: Option, - /// A raw file just opened interactively, waiting for the open-time Camera Raw dialog (shown - /// on the next frame, which has the egui context). - pub(crate) pending_raw_open: Option, + /// Raws just opened interactively, waiting in the open-time Camera Raw dialog (shown on the + /// next frame, which has the egui context); several share it in a filmstrip. + pub(crate) raw_shelf: Vec, /// The open-time re-develops (or their Camera Raw steps) running in the background, one per /// raw being opened. pub(crate) raw_redevelop: Vec, @@ -590,9 +616,14 @@ pub struct PhotocraftApp { pub(crate) wide_angle: Option, /// ⌘Z history of each open parameter dialog (edit_history). pub(crate) dialog_edits: edit_history::DialogEdits, + /// What each open Color Picker's live target was when it opened, to put back on Cancel + /// (`color_picker_ui::live_preview`). + pub(crate) color_picker_live: Vec, /// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the /// egui context is available again (rasterize_prompt). pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, + /// A command clicked while Free Transform commits in the background, run when it lands. + pub(crate) after_transform_commit: Option<(String, serde_json::Value)>, /// Last mirrored or imported OS image; this can predate the current internal copy. os_clip_sig: Option, /// Last observed SVG bytes, to distinguish an unchanged OS source from a newer internal copy. @@ -698,6 +729,8 @@ pub struct PhotocraftApp { pub(crate) prompt_order: std::collections::VecDeque, /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). pub(crate) held_preset_import: Option, + pub(crate) held_record_again: Option, + pub(crate) held_insert_menu: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). @@ -739,6 +772,15 @@ impl SyntheticTarget { } } +#[cfg(test)] +mod artboard_add_mode_tests; +#[cfg(test)] +mod brush_options_tooltips_tests; +#[cfg(test)] +mod brush_picker_escape_tests; +#[cfg(test)] +mod shape_bounds_box_tests; + #[cfg(test)] mod lifecycle_tests; @@ -762,6 +804,8 @@ mod input_tests; #[cfg(test)] mod empty_workspace_tests; +#[cfg(test)] +mod home_chrome_tests; #[cfg(test)] mod pen_variants_tests; @@ -773,18 +817,33 @@ mod layer_shortcut_tests; #[cfg(test)] mod live_retouch_tests; #[cfg(test)] +mod lock_layers_shortcut_tests; +#[cfg(test)] mod pencil_tests; #[cfg(test)] +mod smart_objects_menu_tests; +#[cfg(test)] mod tool_options_theme_tests; #[cfg(test)] +mod tool_shift_cycle_order_tests; +#[cfg(test)] mod tool_shortcut_tests; +#[cfg(test)] +mod toolbar_group_order_tests; +#[cfg(test)] +mod toolbar_more_slot_flyout_tests; #[cfg(test)] mod pixel_aspect_tests; +#[cfg(test)] +mod transform_controls_pointer_tests; #[cfg(test)] mod transform_fx_tests; +#[cfg(test)] +mod transform_commit_click_tests; + #[cfg(test)] mod layer_props_ui_tests; #[cfg(test)] @@ -794,12 +853,24 @@ mod transform_switch_tests; #[cfg(test)] mod transform_undo_tests; +#[cfg(test)] +mod transform_rotate_snap_tests; + #[cfg(test)] mod transform_type_tests; +#[cfg(test)] +mod transform_reference_point_tests; + +#[cfg(test)] +mod transform_relative_position_tests; + #[cfg(test)] mod transform_several_tests; +#[cfg(test)] +mod transform_selection_rules_tests; + #[cfg(test)] mod psd_open_tests; #[cfg(test)] @@ -808,6 +879,14 @@ mod save_identity_tests; #[cfg(test)] mod move_auto_select_tests; +#[cfg(test)] +mod move_target_default_tests; + +#[cfg(test)] +mod move_history_names_tests; +#[cfg(test)] +mod move_shift_diagonal_tests; + #[cfg(test)] mod new_group_button_tests; @@ -828,6 +907,9 @@ mod blur_pins_tests; #[cfg(test)] mod move_outline_tests; +#[cfg(test)] +mod move_bar_distribute_gear_tests; + #[cfg(test)] mod layer_thumb_bounds_tests; #[cfg(test)] @@ -850,6 +932,9 @@ mod blend_dropdown_wheel_tests; #[cfg(test)] mod paths_scroll_tests; +#[cfg(test)] +mod blend_dropdown_groups_tests; + #[cfg(test)] mod options_bar_mode_wheel_tests; @@ -859,9 +944,14 @@ mod field_wheel_app_tests; #[cfg(test)] mod dropdown_press_tests; +#[cfg(test)] +mod marquee_press_mods_tests; #[cfg(test)] mod marquee_tests; +#[cfg(test)] +mod single_line_marquee_move_tests; + #[cfg(test)] mod guide_cursor_tests; @@ -883,10 +973,14 @@ mod stamp_tests; #[cfg(test)] mod alt_click_tests; #[cfg(test)] +mod artboard_first_board_tests; +#[cfg(test)] mod status_bar_tests; #[cfg(test)] mod stroke_timing_tests; +#[cfg(test)] +mod polygon_lasso_step_snap_tests; #[cfg(test)] mod polygon_lasso_tests; @@ -896,6 +990,8 @@ mod window_frame_tests; #[cfg(test)] mod browse_field_tests; #[cfg(test)] +mod export_quality_tests; +#[cfg(test)] mod options_hit_area_tests; #[cfg(test)] mod select_and_mask_button_tests; @@ -903,5 +999,20 @@ mod select_and_mask_button_tests; #[cfg(test)] mod saved_path_edit_tests; +#[cfg(test)] +mod menu_item_labels_tests; #[cfg(test)] mod pen_path_ownership_tests; + +#[cfg(test)] +mod artboard_options_bar_tests; +#[cfg(test)] +mod doc_tab_menu_tests; +#[cfg(test)] +mod layers_empty_area_menu_tests; +#[cfg(test)] +mod preferences_submenu_tests; +#[cfg(test)] +mod selection_move_step_tests; +#[cfg(test)] +mod view_guides_submenu_tests; diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 2042d996e0..dc935b3da0 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -9,6 +9,7 @@ use crate::PhotocraftApp; pub const DISCORD: &str = "https://discord.gg/artcraft"; pub const ARTCRAFT_WEBSITE: &str = "https://getartcraft.com"; pub const APP_PAGE: &str = "https://getartcraft.com/apps/photocraft"; +pub const TUTORIALS: &str = "https://getartcraft.com/tutorials"; pub const GITHUB: &str = "https://github.com/storytold/photocraft"; pub const ISSUES: &str = "https://github.com/storytold/photocraft/issues"; diff --git a/crates/ui-egui/src/lock_layers_shortcut_tests.rs b/crates/ui-egui/src/lock_layers_shortcut_tests.rs new file mode 100644 index 0000000000..8d5f215e19 --- /dev/null +++ b/crates/ui-egui/src/lock_layers_shortcut_tests.rs @@ -0,0 +1,143 @@ +//! Ctrl+/ (Cmd+/ on the Mac) opens Layer > Lock Layers..., and the Layer menu and the Layers panel +//! menu show the command's current key. Plain / still toggles Lock Transparent Pixels (#11397). + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn locks(app: &PhotocraftApp) -> photocraft_doc::Locks { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().locks +} + +fn set_shortcut(app: &mut PhotocraftApp, value: &str) { + app.session.prefs.edit(|prefs| prefs.shortcuts.insert("layer.lockLayers".into(), value.into())); +} + +#[test] +fn lock_layers_defaults_to_command_slash_in_the_engine_and_the_menu() { + assert_eq!(photocraft_engine::commands::find("layer.lockLayers").unwrap().shortcut, Some("Cmd+/")); + let row = crate::menu_catalog::CATALOG.iter().find(|c| c.3 == "layer.lockLayers").unwrap(); + assert_eq!(row.2, Some("Cmd+/")); + assert_eq!(crate::shortcuts::default_shortcut("layer.lockLayers").as_deref(), Some("Cmd+/")); +} + +#[test] +fn command_slash_runs_what_the_menu_item_runs() { + let mut via_key = app(); + press(&mut via_key, Key::Slash, Modifiers::COMMAND); + let mut via_menu = app(); + crate::menus::invoke(&mut via_menu, &egui::Context::default(), "layer.lockLayers", json!({})).unwrap(); + assert_eq!(locks(&via_key), locks(&via_menu)); + assert!(locks(&via_key).all, "Lock Layers... with no choices locks the layer"); +} + +#[test] +fn command_slash_does_nothing_when_the_menu_item_is_greyed() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(!crate::menus::is_enabled(&app, "layer.lockLayers")); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(app.session.active().is_none()); + assert!(app.ui.layers_panel_dialog.is_none()); +} + +/// Every text the Layers panel menu paints in one frame (the row labels and their shortcut texts). +fn painted_text(app: &mut PhotocraftApp) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput::default()); + let mut ui = egui::Ui::new( + ctx.clone(), + egui::Id::unique("lock-layers-panel-menu"), + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 900.0))), + ); + crate::panels::layer_row_ui::panel_menu(app, &mut ui); + // The texture deltas go nowhere in a test: clear them, or epaint's drop check complains. + let mut pass = ctx.end_pass(); + pass.textures_delta.clear(); + let mut out = Vec::new(); + for clipped in pass.shapes { + collect(&clipped.shape, &mut out); + } + out +} + +#[test] +fn panel_menu_row_shows_the_current_key_and_follows_a_change() { + let mut app = app(); + let default_key = crate::shortcuts::pretty("Cmd+/"); + assert_eq!(crate::shortcuts::shortcut_label(&app, "layer.lockLayers"), Some(default_key.clone())); + // The panel menu paints the key beside Lock Layers… + let texts = painted_text(&mut app); + assert!(texts.iter().any(|t| t == "Lock Layers…"), "{texts:?}"); + assert!(texts.contains(&default_key), "the default key is painted: {texts:?}"); + // A changed key is painted instead of the default. + set_shortcut(&mut app, "Cmd+Shift+L"); + let changed = crate::shortcuts::pretty("Cmd+Shift+L"); + assert_eq!(crate::shortcuts::shortcut_label(&app, "layer.lockLayers"), Some(changed.clone())); + let texts = painted_text(&mut app); + assert!(texts.contains(&changed), "{texts:?}"); + assert!(!texts.contains(&default_key), "the old key is gone: {texts:?}"); + // A removed key paints no key at all. + let mut app = app_with_removed_key(); + let texts = painted_text(&mut app); + assert!(texts.iter().any(|t| t == "Lock Layers…"), "{texts:?}"); + assert!(!texts.contains(&default_key) && !texts.contains(&changed), "{texts:?}"); +} + +#[test] +fn the_key_can_be_changed_and_removed() { + let mut app = app(); + set_shortcut(&mut app, "Cmd+Shift+L"); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(!locks(&app).all, "the old key no longer runs the command"); + press(&mut app, Key::L, Modifiers::COMMAND | Modifiers::SHIFT); + assert!(locks(&app).all, "the new key does"); + let mut app = app_with_removed_key(); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(!locks(&app).all); + assert_eq!(crate::shortcuts::shortcut_label(&app, "layer.lockLayers"), None); +} + +fn app_with_removed_key() -> PhotocraftApp { + let mut app = app(); + set_shortcut(&mut app, ""); + app +} + +#[test] +fn plain_slash_toggles_lock_transparency_and_command_slash_does_not() { + let mut app = app(); + press(&mut app, Key::Slash, Modifiers::NONE); + assert!(locks(&app).transparency && !locks(&app).all); + press(&mut app, Key::Slash, Modifiers::NONE); + assert!(!locks(&app).transparency); + press(&mut app, Key::Slash, Modifiers::COMMAND); + assert!(locks(&app).all); + assert!(!locks(&app).transparency, "Ctrl+/ leaves Lock Transparent Pixels alone"); +} diff --git a/crates/ui-egui/src/marquee_press_mods_tests.rs b/crates/ui-egui/src/marquee_press_mods_tests.rs new file mode 100644 index 0000000000..235dc2dfe4 --- /dev/null +++ b/crates/ui-egui/src/marquee_press_mods_tests.rs @@ -0,0 +1,163 @@ +//! A Shift or Alt held from the start of a Rectangular or Elliptical Marquee drag picks the +//! selection mode only when the document already has a selection (#11389). Without one the same +//! keys constrain the shape from the first move, as in the reference application: Shift squares to +//! the drag's shorter side, Alt draws from the centre. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + harness_with_step_dt(tool, 0.25) +} + +fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).with_step_dt(step_dt).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // 100 %: one document pixel per point, so drags land on whole pixels. + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [400.0, 300.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn view_xform(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + view_xform(h).to_screen(x, y) +} + +fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + h.run_steps(1); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, m: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m }); + h.run_steps(1); +} + +/// Move the pointer to document point `(x, y)` in a few steps. +fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); +} + +fn press_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(h, p, true, m); + // Past egui's click distance, so the drag starts at the press point. + move_to(h, x + 8.0, y + 6.0); +} + +fn release_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + move_to(h, x, y); + button(h, screen(h, x, y), false, m); + h.run_steps(2); +} + +fn selection(h: &Harness<'static, PhotocraftApp>) -> Rect { + h.state().session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds()) +} + +/// The two-row readout shows `W: {w} px` and `H: {ht} px` (Photoshop's format). +fn readout(h: &Harness<'static, PhotocraftApp>, w: i32, ht: i32) -> bool { + let labels = h.query_by_label("W:").is_some() && h.query_by_label("H:").is_some(); + let count = |v: i32| h.query_all_by_label(&format!("{v} px")).count(); + let values = if w == ht { count(w) == 2 } else { count(w) == 1 && count(ht) == 1 }; + labels && values && h.query_all_by_label_contains(" px").count() == 2 +} + +/// Drag a marquee from `from` to `to` with `m` held from before the press to after the release. +fn drag_held(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, from: [f32; 2], to: [f32; 2]) { + mods(h, m); + press_at(h, from[0], from[1], m); + move_to(h, to[0], to[1]); + release_at(h, to[0], to[1], m); + mods(h, Modifiers::NONE); +} + +#[test] +fn without_a_selection_keys_held_from_the_press_constrain_the_shape() { + let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE }; + let cases = [ + // ⇧ from the press: a 200 × 100 drag selects a 100 × 100 square (the shorter side). + (Modifiers::SHIFT, [100.0, 100.0], [300.0, 200.0], Rect::new(100, 100, 200, 200)), + // ⌥ from the press: drawn from the centre. + (Modifiers::ALT, [400.0, 300.0], [500.0, 350.0], Rect::new(300, 250, 500, 350)), + // Both: a square from the centre, half extent the shorter drag. + (shift_alt, [400.0, 300.0], [500.0, 350.0], Rect::new(350, 250, 450, 350)), + ]; + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (m, from, to, want) in cases { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + drag_held(&mut h, m, from, to); + assert_eq!(selection(&h), want, "{tool:?} with {m:?} held from the press"); + } + } +} + +#[test] +fn the_live_readout_shows_the_constrained_shape_from_the_first_move() { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = false; + mods(&mut h, Modifiers::SHIFT); + press_at(&mut h, 100.0, 100.0, Modifiers::SHIFT); + move_to(&mut h, 300.0, 200.0); + assert!(readout(&h, 100, 100), "the readout agrees with what the release selects"); + release_at(&mut h, 300.0, 200.0, Modifiers::SHIFT); + mods(&mut h, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(100, 100, 200, 200)); +} + +#[test] +fn with_a_selection_keys_held_from_the_press_still_pick_the_mode() { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + // ⇧ adds, and the added rectangle is as drawn, not squared: the union reaches 600. + drag_held(&mut h, Modifiers::SHIFT, [400.0, 100.0], [600.0, 150.0]); + assert_eq!(selection(&h), Rect::new(100, 100, 600, 300)); + let st = h.state().session.active().unwrap(); + let sel = st.doc.selection.as_ref().unwrap(); + assert!(sel.sample_channel(150, 150, 0) > 0.99 && sel.sample_channel(550, 125, 0) > 0.99 && sel.sample_channel(550, 250, 0) < 0.01); +} diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 87ff48ddb4..5a42aab0fc 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -454,6 +454,115 @@ fn the_live_outline_matches_the_committed_selection_in_a_rotated_view() { } } +/// The closed outlines the last frame drew as a solid white line under black dashes: the Type +/// tools' drag box (four corners, the first repeated). +fn white_box_outlines(h: &Harness<'static, PhotocraftApp>) -> Vec> { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Path(p) + if matches!(p.stroke.color, egui::epaint::ColorMode::Solid(col) if col == egui::Color32::WHITE) + && p.points.len() == 5 + && p.points.first() == p.points.last() => + { + Some(p.points.clone()) + } + _ => None, + }) + .collect() +} + +/// The slanted edges the last frame drew as marching ants (#7530): each is a white base segment +/// under black dashes (`ants::shapes`); upright edges go into a pixel mesh instead. The Move tool's +/// layer marquee in a rotated view. +fn white_ant_edges(h: &Harness<'static, PhotocraftApp>) -> Vec<[Pos2; 2]> { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::LineSegment { points, stroke } if stroke.color == egui::Color32::WHITE => Some(*points), + _ => None, + }) + .collect() +} + +/// Every drawn corner of `outline` maps back, through the view, to the document corner of `want` +/// ([x0, y0, x1, y1]) with the same index, clockwise from the top-left. +fn assert_doc_box(xf: &ViewXform, outline: &[Pos2], want: [f64; 4], what: &str) { + let corners = [[want[0], want[1]], [want[2], want[1]], [want[2], want[3]], [want[0], want[3]]]; + for (i, (p, w)) in outline.iter().zip(corners).enumerate() { + let [x, y] = xf.to_doc(*p); + assert!( + (x - w[0]).abs() <= 1.0 && (y - w[1]).abs() <= 1.0, + "{what}: corner {i} drawn at {p:?} is document ({x:.1}, {y:.1}), the box's corner is {w:?}" + ); + } +} + +/// #7063: in a rotated view the Type tools' drag box previews the document-axis text box the +/// release creates. An upright screen box through two of its corners is another shape (at 45° a +/// dragged square previews as a sliver). +#[test] +fn the_type_drag_box_matches_the_committed_text_box_in_a_rotated_view() { + for rotation in [30.0, 45.0] { + let mut h = harness(Tool::Type); + h.state_mut().ui.extras.snap = false; + h.state_mut().ui.views[0].rotation = rotation; + h.run_steps(2); + press_at(&mut h, 50.0, 50.0, Modifiers::NONE); + move_to(&mut h, 150.0, 90.0); + let xf = view_xform(&h); + let drawn = white_box_outlines(&h); + assert_eq!(drawn.len(), 1, "{rotation}°: one text box is previewed: {drawn:?}"); + release_at(&mut h, 150.0, 90.0, Modifiers::NONE); + // The text box the release created, in document pixels. + let committed = { + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let photocraft_doc::LayerContent::Text(t) = &layer.content else { panic!("{rotation}°: a type layer") }; + let photocraft_doc::text::TextShape::Box { x, y, width, height } = t.shape else { panic!("{rotation}°: paragraph text") }; + let (tx, ty) = (t.transform.m[4], t.transform.m[5]); + [tx + f64::from(x), ty + f64::from(y), tx + f64::from(x + width), ty + f64::from(y + height)] + }; + assert!((committed[0] - 50.0).abs() <= 1.0 && (committed[3] - 90.0).abs() <= 1.0, "{rotation}°: the drag made {committed:?}"); + assert_doc_box(&xf, &drawn[0], committed, &format!("{rotation}°: the Type preview")); + } +} + +/// #7063: in a rotated view the Move tool's layer marquee (#2844) previews the document-axis box +/// `layer.selectInRect` gets on the release, not an upright screen box through two of its corners. +#[test] +fn the_move_layer_marquee_follows_the_document_axes_in_a_rotated_view() { + for rotation in [30.0, 45.0] { + let mut h = harness(Tool::Move); + let app = h.state_mut(); + app.ui.extras.snap = false; + app.ui.tool_options.move_auto_select = true; + app.ui.views[0].rotation = rotation; + h.run_steps(2); + // On the locked Background, so the drag draws the marquee (#4058). + press_at(&mut h, 50.0, 50.0, Modifiers::NONE); + move_to(&mut h, 150.0, 90.0); + assert!(h.state().layer_box.is_some(), "{rotation}°: the drag draws the layer marquee"); + let xf = view_xform(&h); + // Four slanted ant edges, each from one corner of the document box to the next. An upright + // screen box would have none (its edges go into the ants' pixel mesh). + let edges = white_ant_edges(&h); + assert_eq!(edges.len(), 4, "{rotation}°: the marquee's four edges are drawn slanted: {edges:?}"); + let ends: Vec = edges.iter().map(|e| e[0]).collect(); + assert_doc_box(&xf, &ends, [50.0, 50.0, 150.0, 90.0], &format!("{rotation}°: the layer marquee (edge starts)")); + let ends: Vec = edges.iter().map(|e| e[1]).collect(); + assert_doc_box( + &xf, + &ends[3..].iter().chain(&ends[..3]).copied().collect::>(), + [50.0, 50.0, 150.0, 90.0], + &format!("{rotation}°: the layer marquee (edge ends)"), + ); + release_at(&mut h, 150.0, 90.0, Modifiers::NONE); + } +} + /// 800 %, snapping off, the view centred on (30, 30): the pointer lands between pixel edges. fn zoomed(tool: Tool) -> Harness<'static, PhotocraftApp> { let mut h = harness(tool); @@ -694,7 +803,7 @@ fn shift_constrains_a_floating_selection_drag() { } /// ⌘-drag cuts the selected pixels into a floating piece (`select.float`): shown at the pointer -/// while dragging, moved again by plain drags, put back by Undo, dropped by Deselect. +/// while dragging, moved again by ⌘-drags, put back by Undo, dropped by Deselect. #[test] fn cmd_drag_floats_the_selected_pixels() { use crate::canvas::{ToolEvent, tool_event}; @@ -710,8 +819,8 @@ fn cmd_drag_floats_the_selected_pixels() { tool_event(&mut app, ToolEvent::Up { x: 35.0, y: 20.0 }, Modifiers::COMMAND); assert_eq!(offset(&app), Some((15, 0))); assert_eq!(alpha(&doc(&app), 12, 20), 1.0, "the document waits for the drop"); - // A plain drag on the piece moves it again. - drag(&mut app, [30.0, 20.0], [30.0, 30.0], Modifiers::NONE); + // A ⌘-drag on the piece moves it again. + drag(&mut app, [30.0, 20.0], [30.0, 30.0], Modifiers::COMMAND); assert_eq!(offset(&app), Some((15, 10))); assert_eq!(app.session.active().unwrap().history.past_len(), steps); // Undo puts it back. @@ -727,6 +836,29 @@ fn cmd_drag_floats_the_selected_pixels() { assert_eq!(labels.first().map(String::as_str), Some("Move"), "{labels:?}"); } +/// #11368: after the Move tool moves the selected pixels, a plain marquee drag inside the +/// selection moves only the outline, as in Photoshop: the pixels are dropped where they are (one +/// Move step), then the outline moves on its own (one Move Selection step). +#[test] +fn a_marquee_drag_after_moving_pixels_moves_only_the_outline() { + let (mut app, layer) = painted(); + let alpha = |app: &PhotocraftApp, x, y| app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().rgba(x, y)[3]; + let sel = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + let floating = |app: &PhotocraftApp| photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some(); + let steps = app.session.active().unwrap().history.past_len(); + app.ui.tool = Tool::Move; + drag(&mut app, [20.0, 20.0], [35.0, 20.0], Modifiers::NONE); + assert!(floating(&app), "the Move tool leaves the moved pixels floating"); + app.ui.tool = Tool::RectMarquee; + drag(&mut app, [30.0, 20.0], [30.0, 30.0], Modifiers::NONE); + assert!(!floating(&app), "the press dropped the piece"); + assert_eq!(sel(&app), Some(Rect::new(25, 20, 45, 40)), "the outline moved down"); + assert!(alpha(&app, 40, 15) == 1.0 && alpha(&app, 40, 35) == 0.0, "the pixels stay where the Move tool left them"); + assert_eq!(alpha(&app, 12, 20), 0.0, "the cut stays cut"); + let labels: Vec = app.session.active().unwrap().history.entries().into_iter().skip(steps + 1).map(|e| e.to_string()).collect(); + assert_eq!(labels, ["Move", "Move Selection"]); +} + /// Through the real canvas (mouse events): a drag inside the ants moves the selection. #[test] fn mouse_drag_inside_the_selection_moves_it() { @@ -961,7 +1093,8 @@ fn selected_pixels_move_off_the_background_but_not_off_a_fully_locked_layer() { /// Photoshop's cursor over a selection: the move cursor where a marquee press moves the outline, /// scissors where ⌘ (or the Move tool) cuts the selected pixels, a double arrow where ⌥ copies -/// them, a hollow arrowhead while they are dragged, and a plain arrow over the floating piece. +/// them, a hollow arrowhead while they are dragged, and a plain arrow over the floating piece where +/// a press would drag it (the Move tool, ⌘). #[test] fn selection_cursor_follows_the_modifiers_and_the_floating_piece() { use crate::canvas::{SelCursor, selection_cursor}; @@ -985,7 +1118,8 @@ fn selection_cursor_follows_the_modifiers_and_the_floating_piece() { tool_event(&mut app, ToolEvent::Move { x: 35.0, y: 20.0, pressure: 1.0 }, Modifiers::COMMAND); assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], Modifiers::COMMAND), Some(SelCursor::Dragging)); tool_event(&mut app, ToolEvent::Up { x: 35.0, y: 20.0 }, Modifiers::COMMAND); - assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], none), Some(SelCursor::Piece)); + assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], none), Some(SelCursor::Outline), "a plain drag moves the outline (#11368)"); + assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], Modifiers::COMMAND), Some(SelCursor::Piece)); assert_eq!(selection_cursor(&app, Tool::Move, [35.0, 20.0], none), Some(SelCursor::Piece)); assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], cmd_alt), Some(SelCursor::Copy), "a copy of the piece"); assert_eq!(selection_cursor(&app, Tool::RectMarquee, inside, none), None, "where it was cut from"); diff --git a/crates/ui-egui/src/memory_warning.rs b/crates/ui-egui/src/memory_warning.rs new file mode 100644 index 0000000000..366656d33e --- /dev/null +++ b/crates/ui-egui/src/memory_warning.rs @@ -0,0 +1,137 @@ +//! A standing warning while the web build's memory is nearly full (#10024). +//! +//! On the web a failed allocation aborts the module and the page freezes without a message. The +//! big operations refuse up front when they won't fit (`photocraft_engine::memory_gate`); this +//! tells the user earlier, while there is still room to save: when the module's memory is within +//! [`WARN_BELOW`] of the wasm32 ceiling, a notice that stays until dismissed asks them to save and +//! free memory. The answer comes from [`photocraft_raster::headroom::below`], which reads the +//! size of linear memory and allocates nothing. Linear memory never shrinks, so on the web the +//! warning doesn't re-arm once shown (only `?free-mb` or a test can make memory recover). It is always "plenty" on +//! the desktop, so the warning never shows there. + +use photocraft_raster::headroom; + +use crate::PhotocraftApp; + +/// Warn when less than this can be allocated. +pub const WARN_BELOW: u64 = 512 << 20; + +/// Warn again only after this much (or more) was available again, so a figure hovering around +/// the threshold doesn't flicker the notice on and off. +pub const REARM_ABOVE: u64 = 768 << 20; + +/// Seconds between checks. +const CHECK_EVERY: f64 = 2.0; + +/// The notice's title (an English source string, translated when drawn). +pub const TITLE: &str = "Memory is nearly full"; + +/// What a check decided. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + /// Nothing to do. + Idle, + /// Memory just ran low: post the notice. + Warn, + /// Memory recovered: the warning is armed again, and the old notice is stale. + Rearm, +} + +/// The warning's memory: when it last looked, and whether it has warned and not yet re-armed. +#[derive(Clone, Debug, Default)] +pub struct Watch { + next_check: f64, + warned: bool, +} + +impl Watch { + /// `true` when it is time to look again at `now` (seconds); then schedules the next look. + pub fn due(&mut self, now: f64) -> bool { + if now < self.next_check { + return false; + } + self.next_check = now + CHECK_EVERY; + true + } + + /// `true` between a warning and its re-arming. + pub fn warned(&self) -> bool { + self.warned + } + + /// Advances with what a check found: `below_warn` is "less than [`WARN_BELOW`] is available", + /// `below_rearm` is "less than [`REARM_ABOVE`] is available". + pub fn step(&mut self, below_warn: bool, below_rearm: bool) -> Step { + if !self.warned { + if below_warn { + self.warned = true; + return Step::Warn; + } + return Step::Idle; + } + if below_rearm { + return Step::Idle; + } + self.warned = false; + Step::Rearm + } +} + +/// Run once per frame: at most every two seconds, and only while a document is open, posts the +/// warning when memory is nearly full, and takes it down again once memory has recovered. +pub fn tick(app: &mut PhotocraftApp, now: f64) { + if app.session.active().is_none() || !app.low_memory.due(now) { + return; + } + let below_warn = headroom::below(WARN_BELOW); + let below_rearm = if app.low_memory.warned() { headroom::below(REARM_ABOVE) } else { below_warn }; + match app.low_memory.step(below_warn, below_rearm) { + Step::Idle => {} + Step::Warn => post(app), + Step::Rearm => app.ui.notices.retain(|n| n.title != TITLE), + } +} + +fn post(app: &mut PhotocraftApp) { + if app.ui.notices.iter().any(|n| n.title == TITLE) { + return; + } + let lines = vec![ + "Save your work now: if the browser runs out of memory this page stops, and changes since the last save can't be recovered.".to_owned(), + "To make room, close documents you don't need or use Edit › Purge › All.".to_owned(), + ]; + let id = crate::notices::post(app, TITLE, lines, true, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) { + notice.sticky = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warns_once_then_waits_for_the_higher_threshold() { + let mut w = Watch::default(); + assert_eq!(w.step(false, false), Step::Idle); + assert_eq!(w.step(true, true), Step::Warn); + // Staying low, or hovering between the two thresholds, doesn't warn again. + assert_eq!(w.step(true, true), Step::Idle); + assert_eq!(w.step(false, true), Step::Idle); + assert_eq!(w.step(true, true), Step::Idle); + // Rising above the higher threshold re-arms; dropping again warns again. + assert_eq!(w.step(false, false), Step::Rearm); + assert_eq!(w.step(false, false), Step::Idle); + assert_eq!(w.step(true, true), Step::Warn); + } + + #[test] + fn checks_are_spaced_two_seconds_apart() { + let mut w = Watch::default(); + assert!(w.due(0.0)); + assert!(!w.due(1.0)); + assert!(!w.due(1.99)); + assert!(w.due(2.0)); + assert!(!w.due(3.0)); + } +} diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 09f732dc7a..3838a70bcf 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -148,7 +148,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Edit"], "Menus…", Some("Cmd+Alt+Shift+M"), "edit.menus"), (&["Edit"], "Toolbar…", None, "edit.toolbar"), (&["Edit"], "---", None, "---"), - (&["Edit", "Preferences"], "Settings…", Some("Cmd+K"), "edit.preferences.general"), + (&["Edit", "Preferences"], "General…", Some("Cmd+K"), "edit.preferences.general"), + (&["Edit", "Preferences"], "---", None, "---"), (&["Edit", "Preferences"], "AI Integrations…", None, "edit.preferences.integrations"), (&["Edit", "Preferences"], "Interface…", None, "edit.preferences.interface"), (&["Edit", "Preferences"], "Workspace…", None, "edit.preferences.workspace"), @@ -162,10 +163,11 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Edit", "Preferences"], "Transparency & Gamut…", None, "edit.preferences.transparencyAndGamut"), (&["Edit", "Preferences"], "Units & Rulers…", None, "edit.preferences.unitsAndRulers"), (&["Edit", "Preferences"], "Guides, Grid & Slices…", None, "edit.preferences.guidesGridAndSlices"), - (&["Edit", "Preferences"], "Plug-ins…", None, "edit.preferences.plugIns"), + (&["Edit", "Preferences"], "Plugins…", None, "edit.preferences.plugIns"), (&["Edit", "Preferences"], "Type…", None, "edit.preferences.type"), (&["Edit", "Preferences"], "Enhanced Controls…", None, "edit.preferences.enhancedControls"), - (&["Edit", "Preferences"], "Raw Defaults…", None, "edit.preferences.rawDefaults"), + (&["Edit", "Preferences"], "---", None, "---"), + (&["Edit", "Preferences"], "Camera Raw…", None, "edit.preferences.rawDefaults"), (&["Image", "Mode"], "Bitmap", None, "image.mode.bitmap"), (&["Image", "Mode"], "Grayscale", None, "image.mode.grayscale"), (&["Image", "Mode"], "Duotone", None, "image.mode.duotone"), @@ -263,7 +265,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer"], "Quick Export as PNG", Some("Cmd+Shift+'"), "layer.quickExportAsPng"), (&["Layer"], "Export As…", Some("Cmd+Alt+Shift+'"), "layer.exportAs"), (&["Layer"], "---", None, "---"), - (&["Layer"], "Rename Layer", None, "layer.renameLayer"), + (&["Layer"], "Rename Layer…", None, "layer.renameLayer"), (&["Layer", "Layer Style"], "Blending Options…", None, "layer.layerStyle.blendingOptions"), (&["Layer", "Layer Style"], "Bevel & Emboss…", None, "layer.layerStyle.bevelEmboss"), (&["Layer", "Layer Style"], "Stroke…", None, "layer.layerStyle.stroke"), @@ -346,12 +348,14 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Smart Objects"], "Update All Modified Content", None, "layer.smartObjects.updateAllModifiedContent"), (&["Layer", "Smart Objects"], "---", None, "---"), (&["Layer", "Smart Objects"], "Edit Contents", None, "layer.smartObjects.editContents"), + (&["Layer", "Smart Objects"], "Reset Transform", None, "layer.smartObjects.resetTransform"), (&["Layer", "Smart Objects"], "Relink to File…", None, "layer.smartObjects.relinkToFile"), (&["Layer", "Smart Objects"], "Replace Contents…", None, "layer.smartObjects.replaceContents"), (&["Layer", "Smart Objects"], "Export Contents…", None, "layer.smartObjects.exportContents"), (&["Layer", "Smart Objects"], "---", None, "---"), + (&["Layer", "Smart Objects"], "Embed Linked", None, "layer.smartObjects.convertToEmbedded"), + (&["Layer", "Smart Objects"], "Embed All Linked", None, "layer.smartObjects.embedAllLinked"), (&["Layer", "Smart Objects"], "Convert to Linked…", None, "layer.smartObjects.convertToLinked"), - (&["Layer", "Smart Objects"], "Convert to Embedded", None, "layer.smartObjects.convertToEmbedded"), (&["Layer", "Smart Objects"], "Convert to Layers", None, "layer.smartObjects.convertToLayers"), (&["Layer", "Smart Objects"], "---", None, "---"), (&["Layer", "Smart Objects", "Stack Mode"], "Entropy", None, "layer.smartObjects.stackMode.entropy"), @@ -367,10 +371,6 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Smart Objects", "Stack Mode"], "Variance", None, "layer.smartObjects.stackMode.variance"), (&["Layer", "Smart Objects"], "---", None, "---"), (&["Layer", "Smart Objects"], "Rasterize", None, "layer.smartObjects.rasterize"), - (&["Layer", "Smart Objects"], "---", None, "---"), - (&["Layer", "Smart Objects"], "Warp", None, "layer.smartObjects.warp"), - (&["Layer", "Smart Objects"], "Perspective Warp", None, "layer.smartObjects.perspectiveWarp"), - (&["Layer", "Smart Objects"], "Puppet Warp", None, "layer.smartObjects.puppetWarp"), (&["Layer", "Video Layers"], "New Blank Video Layer", None, "layer.videoLayers.newBlankVideoLayer"), (&["Layer", "Video Layers"], "New Video Layer from File…", None, "layer.videoLayers.newVideoLayerFromFile"), (&["Layer", "Video Layers"], "---", None, "---"), @@ -413,8 +413,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Arrange"], "Reverse", None, "layer.arrange.reverse"), (&["Layer", "Combine Shapes"], "Unite Shapes", None, "layer.combineShapes.unite"), (&["Layer", "Combine Shapes"], "Subtract Front Shape", None, "layer.combineShapes.subtractFrontShape"), - (&["Layer", "Combine Shapes"], "Intersect Shape Areas", None, "layer.combineShapes.intersectShapeAreas"), - (&["Layer", "Combine Shapes"], "Exclude Overlapping Shapes", None, "layer.combineShapes.excludeOverlappingShapes"), + (&["Layer", "Combine Shapes"], "Unite Shapes at Overlap", None, "layer.combineShapes.intersectShapeAreas"), + (&["Layer", "Combine Shapes"], "Subtract Shapes at Overlap", None, "layer.combineShapes.excludeOverlappingShapes"), (&["Layer", "Combine Shapes"], "---", None, "---"), (&["Layer", "Combine Shapes"], "Merge Shape Components", None, "layer.combineShapes.mergeShapeComponents"), (&["Layer", "Align"], "Top Edges", None, "layer.align.topEdges"), @@ -435,7 +435,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Distribute"], "Horizontally", None, "layer.distribute.horizontally"), (&["Layer", "Distribute"], "Vertically", None, "layer.distribute.vertically"), (&["Layer"], "---", None, "---"), - (&["Layer"], "Lock Layers…", None, "layer.lockLayers"), + (&["Layer"], "Lock Layers…", Some("Cmd+/"), "layer.lockLayers"), (&["Layer"], "---", None, "---"), (&["Layer"], "Link Layers", None, "layer.linkLayers"), (&["Layer"], "Select Linked Layers", None, "layer.selectLinkedLayers"), @@ -483,6 +483,26 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Type", "OpenType", "Numerals"], "Tabular Lining", None, "type.openType.numerals.tabularLining"), (&["Type", "OpenType", "Numerals"], "Proportional Oldstyle", None, "type.openType.numerals.proportionalOldstyle"), (&["Type", "OpenType", "Numerals"], "Tabular Oldstyle", None, "type.openType.numerals.tabularOldstyle"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 1", None, "type.openType.stylisticSets.set1"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 2", None, "type.openType.stylisticSets.set2"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 3", None, "type.openType.stylisticSets.set3"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 4", None, "type.openType.stylisticSets.set4"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 5", None, "type.openType.stylisticSets.set5"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 6", None, "type.openType.stylisticSets.set6"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 7", None, "type.openType.stylisticSets.set7"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 8", None, "type.openType.stylisticSets.set8"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 9", None, "type.openType.stylisticSets.set9"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 10", None, "type.openType.stylisticSets.set10"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 11", None, "type.openType.stylisticSets.set11"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 12", None, "type.openType.stylisticSets.set12"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 13", None, "type.openType.stylisticSets.set13"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 14", None, "type.openType.stylisticSets.set14"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 15", None, "type.openType.stylisticSets.set15"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 16", None, "type.openType.stylisticSets.set16"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 17", None, "type.openType.stylisticSets.set17"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 18", None, "type.openType.stylisticSets.set18"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 19", None, "type.openType.stylisticSets.set19"), + (&["Type", "OpenType", "Stylistic Sets"], "Set 20", None, "type.openType.stylisticSets.set20"), (&["Type"], "---", None, "---"), (&["Type"], "Create Work Path", None, "type.createWorkPath"), (&["Type"], "Convert to Shape", None, "type.convertToShape"), @@ -515,7 +535,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Select"], "All", Some("Cmd+A"), "select.all"), (&["Select"], "Deselect", Some("Cmd+D"), "select.deselect"), (&["Select"], "Reselect", Some("Cmd+Shift+D"), "select.reselect"), - (&["Select"], "Inverse Selection", None, "select.inverse"), + (&["Select"], "Inverse", None, "select.inverse"), (&["Select"], "Convert to Shape", None, "select.convertToShape"), (&["Select"], "---", None, "---"), (&["Select"], "All Layers", Some("Cmd+Alt+A"), "select.allLayers"), @@ -617,12 +637,12 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Filter", "Stylize"], "Wind…", None, "filter.stylize.wind"), (&["Filter", "Video"], "De-Interlace…", None, "filter.video.deInterlace"), (&["Filter", "Video"], "NTSC Colors", None, "filter.video.ntscColors"), - (&["Filter", "Other…"], "Custom…", None, "filter.other.custom"), - (&["Filter", "Other…"], "HSB/HSL", None, "filter.other.hsbHsl"), - (&["Filter", "Other…"], "High Pass…", None, "filter.other.highPass"), - (&["Filter", "Other…"], "Maximum…", None, "filter.other.maximum"), - (&["Filter", "Other…"], "Minimum…", None, "filter.other.minimum"), - (&["Filter", "Other…"], "Offset…", None, "filter.other.offset"), + (&["Filter", "Other"], "Custom…", None, "filter.other.custom"), + (&["Filter", "Other"], "HSB/HSL", None, "filter.other.hsbHsl"), + (&["Filter", "Other"], "High Pass…", None, "filter.other.highPass"), + (&["Filter", "Other"], "Maximum…", None, "filter.other.maximum"), + (&["Filter", "Other"], "Minimum…", None, "filter.other.minimum"), + (&["Filter", "Other"], "Offset…", None, "filter.other.offset"), (&["View", "Proof Setup"], "Custom…", None, "view.proofSetup.custom"), (&["View", "Proof Setup"], "---", None, "---"), (&["View", "Proof Setup"], "Working CMYK", None, "view.proofSetup.workingCmyk"), @@ -630,14 +650,14 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["View", "Proof Setup"], "Working Magenta Plate", None, "view.proofSetup.workingMagentaPlate"), (&["View", "Proof Setup"], "Working Yellow Plate", None, "view.proofSetup.workingYellowPlate"), (&["View", "Proof Setup"], "Working Black Plate", None, "view.proofSetup.workingBlackPlate"), - (&["View", "Proof Setup"], "Working CMY Plate", None, "view.proofSetup.workingCmyPlate"), + (&["View", "Proof Setup"], "Working CMY Plates", None, "view.proofSetup.workingCmyPlate"), (&["View", "Proof Setup"], "---", None, "---"), - (&["View", "Proof Setup"], "Legacy Macintosh RGB", None, "view.proofSetup.legacyMacintoshRgb"), - (&["View", "Proof Setup"], "Internet Standard RGB", None, "view.proofSetup.internetStandardRgb"), + (&["View", "Proof Setup"], "Legacy Macintosh RGB (Gamma 1.8)", None, "view.proofSetup.legacyMacintoshRgb"), + (&["View", "Proof Setup"], "Internet Standard RGB (sRGB)", None, "view.proofSetup.internetStandardRgb"), (&["View", "Proof Setup"], "Monitor RGB", None, "view.proofSetup.monitorRgb"), (&["View", "Proof Setup"], "---", None, "---"), - (&["View", "Proof Setup"], "Color Blindness — Protanopia-type", None, "view.proofSetup.colorBlindnessProtanopia"), - (&["View", "Proof Setup"], "Color Blindness — Deuteranopia-type", None, "view.proofSetup.colorBlindnessDeuteranopia"), + (&["View", "Proof Setup"], "Color Blindness - Protanopia-type", None, "view.proofSetup.colorBlindnessProtanopia"), + (&["View", "Proof Setup"], "Color Blindness - Deuteranopia-type", None, "view.proofSetup.colorBlindnessDeuteranopia"), (&["View"], "Proof Colors", Some("Cmd+Y"), "view.proofColors"), (&["View"], "Gamut Warning", Some("Cmd+Shift+Y"), "view.gamutWarning"), (&["View"], "---", None, "---"), @@ -700,13 +720,15 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["View", "Snap To"], "---", None, "---"), (&["View", "Snap To"], "All", None, "view.snapTo.all"), (&["View"], "---", None, "---"), - (&["View"], "Lock Guides", Some("Cmd+Alt+;"), "view.lockGuides"), - (&["View"], "Clear Guides", None, "view.clearGuides"), - (&["View"], "Clear Selected Artboard Guides", None, "view.clearSelectedArtboardGuides"), - (&["View"], "Clear Canvas Guides", None, "view.clearCanvasGuides"), - (&["View"], "New Guide…", None, "view.newGuide"), - (&["View"], "New Guide Layout…", None, "view.newGuideLayout"), - (&["View"], "New Guides From Shape", None, "view.newGuidesFromShape"), + (&["View", "Guides"], "Edit Selected Guides", None, "view.editSelectedGuides"), + (&["View", "Guides"], "Lock Guides", Some("Cmd+Alt+;"), "view.lockGuides"), + (&["View", "Guides"], "Clear Guides", None, "view.clearGuides"), + (&["View", "Guides"], "Clear Selected Guides", None, "view.clearSelectedGuides"), + (&["View", "Guides"], "Clear Selected Artboard Guides", None, "view.clearSelectedArtboardGuides"), + (&["View", "Guides"], "Clear Canvas Guides", None, "view.clearCanvasGuides"), + (&["View", "Guides"], "New Guide…", None, "view.newGuide"), + (&["View", "Guides"], "New Guide Layout…", None, "view.newGuideLayout"), + (&["View", "Guides"], "New Guides From Shape", None, "view.newGuidesFromShape"), (&["View"], "---", None, "---"), (&["View"], "Lock Slices", None, "view.lockSlices"), (&["View"], "Clear Slices", None, "view.clearSlices"), @@ -730,7 +752,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Window", "Arrange"], "Float in Window", None, "window.arrange.floatInWindow"), (&["Window", "Arrange"], "Float All in Windows", None, "window.arrange.floatAllInWindows"), (&["Window", "Arrange"], "---", None, "---"), - (&["Window", "Arrange"], "New Window for Document", None, "window.arrange.newWindowForDocument"), + (&["Window", "Arrange"], "New Window", None, "window.arrange.newWindowForDocument"), (&["Window", "Arrange"], "---", None, "---"), (&["Window", "Arrange"], "Match Zoom", None, "window.arrange.matchZoom"), (&["Window", "Arrange"], "Match Location", None, "window.arrange.matchLocation"), @@ -783,4 +805,5 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Window"], "---", None, "---"), (&["Window"], "Options", None, "window.panel.options"), (&["Window"], "Tools", None, "window.panel.tools"), + (&["Window"], "Contextual Task Bar", None, "window.panel.contextualTaskBar"), ]; diff --git a/crates/ui-egui/src/menu_item_labels_tests.rs b/crates/ui-egui/src/menu_item_labels_tests.rs new file mode 100644 index 0000000000..6e09df739f --- /dev/null +++ b/crates/ui-egui/src/menu_item_labels_tests.rs @@ -0,0 +1,82 @@ +//! #11399: the Combine Shapes, Open Recent, Arrange and Proof Setup items carry the reference +//! application's names, in the live menu tree and in the engine's command labels. + +use crate::PhotocraftApp; +use crate::menus::menu_items; + +/// (command id, label the live menu must show). +const LABELS: [(&str, &str); 7] = [ + ("layer.combineShapes.intersectShapeAreas", "Unite Shapes at Overlap"), + ("layer.combineShapes.excludeOverlappingShapes", "Subtract Shapes at Overlap"), + ("file.clearRecent", "Clear Recent File List"), + ("window.arrange.newWindowForDocument", "New Window"), + ("view.proofSetup.workingCmyPlate", "Working CMY Plates"), + ("view.proofSetup.legacyMacintoshRgb", "Legacy Macintosh RGB (Gamma 1.8)"), + ("view.proofSetup.internetStandardRgb", "Internet Standard RGB (sRGB)"), +]; + +fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) +} + +#[test] +fn live_menu_shows_the_reference_names() { + let app = app(); + let items = menu_items(&app); + for (id, label) in LABELS { + let item = items.iter().find(|i| i.id == id).unwrap_or_else(|| panic!("{id} is in the menu tree")); + assert_eq!(item.label, label, "{id}"); + } +} + +#[test] +fn open_recent_clear_item_is_labelled_with_and_without_recent_files() { + let mut app = app(); + app.ui.recent_files = vec!["/tmp/a.png".into()]; + let items = menu_items(&app); + let clear = items.iter().find(|i| i.id == "file.clearRecent").expect("clear item"); + assert_eq!(clear.label, "Clear Recent File List"); + assert!(clear.enabled); + assert_eq!(clear.path, vec!["File".to_string(), "Open Recent".to_string()]); + // With nothing to clear the item stays in the submenu, greyed. + app.ui.recent_files.clear(); + let items = menu_items(&app); + let clear = items.iter().find(|i| i.id == "file.clearRecent").expect("clear item"); + assert_eq!(clear.label, "Clear Recent File List"); + assert!(!clear.enabled); +} + +#[test] +fn engine_command_labels_match_the_menu() { + let specs = photocraft_engine::cmd::command_specs(); + // The two Combine Shapes and the three Proof Setup commands have an engine command under the + // same id as the menu row. + for (id, label) in LABELS { + if let Some(spec) = specs.iter().find(|s| s.id == id) { + assert_eq!(spec.label, label, "engine label of {id}"); + } + } + let engine_ids = [ + "layer.combineShapes.intersectShapeAreas", + "layer.combineShapes.excludeOverlappingShapes", + "view.proofSetup.workingCmyPlate", + "view.proofSetup.legacyMacintoshRgb", + ]; + for id in engine_ids { + assert!(specs.iter().any(|s| s.id == id), "{id} is an engine command"); + } + // The shell command for the extra window is registered under its own id. + let shell = photocraft_engine::shell_commands::SHELL_COMMANDS.iter().find(|c| c.0 == "window.newWindowForDocument").expect("shell command"); + assert_eq!(shell.1, "New Window"); +} + +#[test] +fn history_step_names_and_other_labels_are_unchanged() { + // The Home screen keeps its own button label (#11399 "not measured"). + let src = include_str!("home_recent_ui.rs"); + assert!(src.contains("tl!(\"Clear Recent Files\")")); + // Merge Shape Components stays in Combine Shapes. + let app = app(); + let items = menu_items(&app); + assert!(items.iter().any(|i| i.id == "layer.combineShapes.mergeShapeComponents")); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6d92287cd7..95a57d9588 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -117,6 +117,33 @@ mod open_recent_tests { assert!(invoke(&mut app, &ctx, "file.removeRecent", json!({})).is_err(), "needs a path"); } + #[test] + fn push_recent_evicts_thumbnails_beyond_cap() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + { + let mut cache = app.thumbnail_cache.lock().unwrap(); + for i in 0..25 { + cache.entries.insert( + format!("/tmp/item_{i}.png"), + crate::home_recent_ui::ThumbnailEntry { + key: crate::home_recent_ui::CacheKey::OpenDoc { revision: 1 }, + texture: None, + #[cfg(not(target_arch = "wasm32"))] + last_checked: std::time::Instant::now(), + }, + ); + } + } + for i in 0..25 { + app.push_recent(&format!("/tmp/item_{i}.png")); + } + let cache = app.thumbnail_cache.lock().unwrap(); + for i in 0..5 { + assert!(!cache.entries.contains_key(&format!("/tmp/item_{i}.png")), "evicted item_{i} must be removed from cache"); + } + assert_eq!(app.ui.recent_files.len(), 20); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn opening_a_file_records_it_as_recent() { @@ -176,7 +203,7 @@ mod reveal_label_tests { let expected = if cfg!(target_os = "macos") { "Reveal in Finder" } else if cfg!(windows) { - "Show in Explorer" + "Reveal in Explorer" } else { "Show in Folder" }; diff --git a/crates/ui-egui/src/menus/enable_rules.rs b/crates/ui-egui/src/menus/enable_rules.rs index 6abf64fd1d..a13d9059fe 100644 --- a/crates/ui-egui/src/menus/enable_rules.rs +++ b/crates/ui-egui/src/menus/enable_rules.rs @@ -16,8 +16,10 @@ fn background_blocks(id: &str, has_selection: bool) -> bool { // Global Light and Hide All Effects are document-wide. return !matches!(id, "layer.layerStyle.globalLight" | "layer.layerStyle.hideAllEffects" | "layer.layerStyle.copyLayerStyle"); } - // Transforms need a selection on the Background (they then move the selected pixels). - if !has_selection && (id == "edit.freeTransform" || (id.starts_with("edit.transform.") && id != "edit.transform.again")) { + // Transforms and Content-Aware Scale need a selection on the Background (they then move the + // selected pixels). + if !has_selection && (matches!(id, "edit.freeTransform" | "edit.contentAwareScale") || (id.starts_with("edit.transform.") && id != "edit.transform.again")) + { return true; } matches!( @@ -27,7 +29,6 @@ fn background_blocks(id: &str, has_selection: bool) -> bool { | "layer.mergeDown" | "layer.lockLayers" | "layer.maskAllObjects" - | "edit.contentAwareScale" | "edit.puppetWarp" | "edit.perspectiveWarp" ) @@ -126,10 +127,14 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool { if transform && (target.is_object() || target == "quickMask") { return false; } - // Several layers selected: one box moves them all, the Background left out (#6501). It doesn't - // warp, and doesn't distort while editable type is among them. + // Several layers selected: one box moves them all (#6501); with the Background among them it + // refuses as locked when it opens (#11101). It doesn't warp, and doesn't distort while + // editable type is among them. if transform { let targets = photocraft_engine::transform_cmds::selection_targets(st); + // Judged on the layers the box moves, linked ones included (#11217): one linked layer + // selected is a box over its link group, which doesn't warp either. + let targets = photocraft_engine::transform_cmds::box_targets(&st.doc, &targets); if targets.len() > 1 || targets.first() != st.active_layer.as_ref() { let text = targets.iter().any(|t| st.doc.layer(*t).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))); return match id { @@ -215,6 +220,24 @@ mod tests { assert!(!disabled(&app, "edit.freeTransform") && !disabled(&app, "edit.transform.flipHorizontal")); } + /// Photoshop treats Content-Aware Scale like Free Transform: on the Background it needs a + /// selection, and then scales the selected pixels. + #[test] + fn content_aware_scale_on_the_background_needs_a_selection() { + let mut s = session(); + assert!(off(&s, "edit.contentAwareScale")); + s.execute("select.all", json!({})).unwrap(); + assert!(!off(&s, "edit.contentAwareScale")); + let mut app = crate::PhotocraftApp::new(s, crate::Services::default()); + assert!(!disabled(&app, "edit.contentAwareScale")); + let r = app.session.execute("edit.contentAwareScale", json!({"scaleX": 70})).unwrap(); + assert_eq!(r["to"], json!([14, 20])); + // A normal layer takes it without a selection. + app.session.execute("select.deselect", json!({})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + assert!(!disabled(&app, "edit.contentAwareScale")); + } + /// With several layers selected, Rasterize is judged by the whole selection, not by the layer /// that happens to be active (or was right-clicked). #[test] @@ -362,6 +385,27 @@ mod tests { assert_eq!(checked, selections.len() * 2 * commands.len()); } + /// #11217 review: one linked layer selected opens a box over its link group, which refuses + /// Warp, so the menu greys Warp the same way it does for several selected layers; an unlinked + /// layer keeps it. + #[test] + fn warp_is_greyed_for_a_layer_with_linked_partners() { + let mut s = session(); + let id = |r: serde_json::Value| photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); + let a = id(s.execute("layer.new.layer", json!({})).unwrap()); + let b = id(s.execute("layer.new.layer", json!({})).unwrap()); + let c = id(s.execute("layer.new.layer", json!({})).unwrap()); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let mut app = crate::PhotocraftApp::new(s, crate::Services::default()); + assert!(disabled(&app, "edit.transform.warp"), "a linked layer warps with its partner: not available"); + assert!(!disabled(&app, "edit.freeTransform") && !disabled(&app, "edit.transform.distort"), "the box itself is"); + app.session.execute("layer.select", json!({"layer": c.0})).unwrap(); + assert!(!disabled(&app, "edit.transform.warp"), "an unlinked layer warps"); + } + #[test] fn ordinary_layers_keep_their_items() { let mut s = session(); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index a089803be8..9e2da7e511 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -10,6 +10,7 @@ pub(crate) fn panel_alias(id: &str) -> Option<&'static str> { "navigator" | "info" | "histogram" => "window.toggle.navigator", "tools" => "window.toggle.toolbar", "options" => "window.toggle.options", + "contextualTaskBar" => "window.toggle.contextualTaskBar", "brushSettings" => "window.toggle.brushSettings", "brushes" => "window.toggle.brushes", _ => return None, @@ -56,12 +57,32 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va Ok(r) } +/// Layer › New › Convert to Frame without params: asks for the frame's name and size first. +fn frame_convert_dialog(app: &mut PhotocraftApp, id: &str) -> Result { + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("select a shape or type layer")?; + let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, layer); + let mut fixed = serde_json::Map::new(); + fixed.insert("layer".into(), json!(layer.id.0)); + let dialog = crate::filter_dialog::open_with_spec(app, id, tl!("New Frame"), r#"{"name":text,"width":1..300000=100,"height":1..300000=100}"#, fixed); + if let Some(d) = app.ui.dialog_mut(dialog) { + d.fields.insert("__preview".into(), json!(false)); + d.fields.insert("name".into(), json!("Frame")); + d.fields.insert("width".into(), json!(b.width().max(1))); + d.fields.insert("height".into(), json!(b.height().max(1))); + } + Ok(json!({"dialog":dialog})) +} + /// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it. fn invoke_early_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if let Some(tool) = crate::tool_shortcuts::tool(id) { crate::tool_memory::select(app, tool); return Some(Ok(json!({"tool": tool}))); } + if id == "layer.frame.convert" && params.as_object().is_none_or(|o| o.is_empty()) { + return Some(frame_convert_dialog(app, id)); + } if id == "help.checkForUpdates" { return Some(crate::updates_ui::check(app, ctx, params.clone())); } @@ -187,10 +208,16 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, // ⌘↩ / Ctrl+Enter (#306): load the path selected in the Paths panel (or the one being // drawn) as a selection. "path.toSelection" if params.get("name").is_none() => crate::vector_ui::path_to_selection(app, params.clone()), - // ⌘⇧↩ / ⌘⌥↩ load the same path, adding it to or subtracting it from the selection. - "path.addToSelection" | "path.subtractFromSelection" if params.get("name").is_none() => { + // ⌘⇧↩ / ⌘⌥↩ / ⇧⌥⌘↩ load the same path, adding it to, subtracting it from or intersecting + // it with the selection. + "path.addToSelection" | "path.subtractFromSelection" | "path.intersectWithSelection" if params.get("name").is_none() => { let mut p = params.as_object().cloned().unwrap_or_default(); - p.insert("mode".into(), json!(if id == "path.addToSelection" { "add" } else { "subtract" })); + let mode = match id { + "path.addToSelection" => "add", + "path.subtractFromSelection" => "subtract", + _ => "intersect", + }; + p.insert("mode".into(), json!(mode)); crate::vector_ui::path_to_selection(app, Value::Object(p)) } // Layer › Rename Layer from a menu starts in-place renaming in the Layers panel. @@ -395,25 +422,43 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::new_group_ui::open(app, id)})) } // Channels › Channel Options… without params: the dialog on the targeted alpha/spot - // channel, Layer Mask Display Options on a targeted layer mask, or Quick Mask Options while - // in Quick Mask (with params: the engine directly). + // channel (also in Quick Mask mode, where its row is the selected one), Quick Mask Options + // in Quick Mask otherwise, or Layer Mask Display Options on a targeted layer mask (with + // params: the engine directly). crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { let mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document is open")?; let channel = match st.channel_view.target { - _ if st.doc.quick_mask.is_some() => json!("quickMask"), photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), + _ if st.doc.quick_mask.is_some() => json!("quickMask"), _ if mask => json!("mask"), _ => return Err("target an alpha or spot channel, or a layer mask, first".into()), }; let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; Ok(json!({"dialog": dialog})) } + // Channels › New Channel… without params: its dialog (with params: the engine). The + // panel's New button makes one at once, as Photoshop's does. + crate::channel_options_ui::NEW_CHANNEL if params.as_object().is_none_or(|o| o.is_empty()) => { + let dialog = crate::channel_options_ui::open_new_channel(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } + // Channels › Merge Channels… without params: its dialog, which picks the mode and the + // documents (with params: the engine). + crate::dialogs::merge_channels_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + if let Some(Err(why)) = photocraft_engine::commands::find(id).map(|c| (c.enabled)(&app.session)) { + return Err(why); + } + let dialog = crate::dialogs::merge_channels_ui::open(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Channels › New Spot Channel… without params: its dialog (with params: the engine). crate::channel_options_ui::NEW_SPOT if params.as_object().is_none_or(|o| o.is_empty()) => { let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; Ok(json!({"dialog": dialog})) } + // Select › Find Layers: the Layers panel's Name filter (#10884). + "select.findLayers" => crate::layer_filter_ui::find::invoke(app, ctx, params), // Photoshop's "Select and Mask…" is the engine's select.refineEdge. // Select › Color Range… from the menu: the dialog (with params: the engine directly). "select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})), @@ -590,6 +635,10 @@ fn type_command_target(app: &PhotocraftApp) -> Option { fn invoke_view_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { match id { "view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => Some(app.run(id, params.clone())), + "view.editSelectedGuides" => Some(crate::new_guide_ui::open_edit(app).map(|_| Value::Null)), + "view.clearSelectedGuides" => { + Some(crate::guide_select::delete_selected(app).and_then(|done| if done { Ok(Value::Null) } else { Err("No guide is selected".to_string()) })) + } "view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => { let extras = &mut app.ui.extras; let slot = match id { @@ -646,6 +695,10 @@ fn invoke_view_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p Some(crate::sizing::open(app, sz).map(|dialog| json!({"dialog": dialog})).ok_or_else(|| "no document".to_string())) } "view.newGuide" if params.as_object().is_none_or(|object| object.is_empty()) => Some(Ok(json!({"dialog": crate::new_guide_ui::open(app)}))), + // Image › Adjustments › Equalize with a selection asks what to equalize, as in Photoshop. + crate::equalize_ui::COMMAND if params.as_object().is_none_or(|object| object.is_empty()) && crate::equalize_ui::asks(app) => { + Some(Ok(json!({"dialog": crate::equalize_ui::open(app)}))) + } fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|object| object.is_empty()) => { Some(Ok(json!({"dialog": crate::filter_dialog::open(app, fl)}))) } @@ -763,6 +816,7 @@ fn toggle_panel(app: &mut PhotocraftApp, id: &str) -> Result { "navigator" => &mut panels.navigator, "toolbar" => &mut panels.toolbar, "options" => &mut panels.options_bar, + "contextualTaskBar" => &mut panels.context_task_bar, "brushSettings" => &mut panels.brush_settings, "brushes" => &mut panels.brushes, "dock" => &mut panels.dock, diff --git a/crates/ui-egui/src/menus/items.rs b/crates/ui-egui/src/menus/items.rs index 37944cca5f..4c36419c90 100644 --- a/crates/ui-egui/src/menus/items.rs +++ b/crates/ui-egui/src/menus/items.rs @@ -36,8 +36,8 @@ pub fn is_live(id: &str) -> bool { } /// Commands outside the catalogue that belong right after a catalogue item: `(id, after)`. -/// The command takes that item's submenu path, so it joins Photoshop's submenu even where the -/// catalogue spells it differently (Filter › "Other…"). +/// The command takes that item's submenu path, so it joins the catalogue's submenu rather than +/// opening a second one spelled from its own id (Filter › Other). const PLACE_AFTER: &[(&str, &str)] = &[ ("file.newFromClipboard", "file.new"), ("filter.render.relight", "filter.render.lightingEffects"), @@ -154,7 +154,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { } recent.push(MenuItem { id: "file.clearRecent".into(), - label: "Clear Recent Files".into(), + label: "Clear Recent File List".into(), path: rp.clone(), shortcut: None, enabled: !app.ui.recent_files.is_empty(), diff --git a/crates/ui-egui/src/menus/menu_keys.rs b/crates/ui-egui/src/menus/menu_keys.rs index cfdf2e9297..c2e90d1337 100644 --- a/crates/ui-egui/src/menus/menu_keys.rs +++ b/crates/ui-egui/src/menus/menu_keys.rs @@ -109,6 +109,19 @@ fn key_char(key: Key) -> Option { } } +/// The ASCII access key a displayed letter can represent. Turkic `i` uppercases to `İ`, +/// which is not an ASCII key; dotless `ı` can represent `I`. Key codes themselves stay ASCII. +pub(crate) fn label_key(c: char) -> Option { + let lang = crate::i18n::current(); + let upper = if matches!(lang.code(), "az" | "tr") && matches!(c, 'i' | 'ı' | 'İ') { + let mut bytes = [0; 4]; + lang.uppercase(c.encode_utf8(&mut bytes)).chars().next()? + } else { + c.to_ascii_uppercase() + }; + upper.is_ascii_alphanumeric().then_some(upper) +} + /// Mnemonics for `labels`, unique within the group: the preferred letter when the label has it, /// else the first unused ASCII letter or digit, trying the first character of each word before /// the others. Compared case-insensitively; the returned letter is upper case. @@ -116,13 +129,13 @@ pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> let mut used: Vec = Vec::new(); let mut out = Vec::with_capacity(labels.len()); for (i, label) in labels.iter().enumerate() { - let upper: Vec = label.chars().map(|c| c.to_ascii_uppercase()).collect(); + let upper: Vec = label.chars().filter_map(label_key).collect(); let has = |c: char| upper.contains(&c.to_ascii_uppercase()); let mut pick = preferred.get(i).copied().flatten().map(|c| c.to_ascii_uppercase()).filter(|&c| has(c) && !used.contains(&c)); if pick.is_none() { let starts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|w| w.chars().next()); let rest = label.chars(); - pick = starts.chain(rest).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).find(|c| !used.contains(c)); + pick = starts.chain(rest).filter_map(label_key).find(|c| !used.contains(c)); } if let Some(c) = pick { used.push(c); @@ -139,13 +152,13 @@ pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> /// do in those languages ("文件(F)", #5863). Mnemonics stay unique within the group, and a shown /// "(F)" is always the row's key. pub fn localized(labels: &[String], english: &[&str], preferred: &[Option]) -> (Vec, Vec>) { - let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric()); + let typed = |l: &str| l.chars().any(|c| label_key(c).is_some()); // The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…". let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) { Some((base, tail)) => format!("{base}({k}){tail}"), None => format!("{l}({k})"), }; - let english_keys = assign(english, preferred); + let english_keys = crate::i18n::with_language(crate::i18n::Lang::EN, || assign(english, preferred)); let mut shown = Vec::with_capacity(labels.len()); let mut pref = Vec::with_capacity(labels.len()); for (i, label) in labels.iter().enumerate() { @@ -181,7 +194,7 @@ pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, let color = color.unwrap_or(Color32::PLACEHOLDER); let font = egui::TextStyle::Button.resolve(ui.style()); let plain = egui::TextFormat { font_id: font.clone(), color, ..Default::default() }; - let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| label_key(*c) == Some(m)).map(|(i, c)| (i, c.len_utf8()))); let Some((i, len)) = at else { return egui::RichText::new(format!("{prefix}{label}")).color(color).into(); }; @@ -225,7 +238,7 @@ pub fn menu_row_text(ui: &Ui, checked: bool, label: &str, mnemonic: Option job.append(CHECK_MARK, 0.0, plain.clone()); lead = 0.0; } - let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| label_key(*c) == Some(m)).map(|(i, c)| (i, c.len_utf8()))); let parts = match at { Some((i, len)) => [label.get(..i), label.get(i..i + len), label.get(i + len..)], None => [Some(label), None, None], diff --git a/crates/ui-egui/src/menus/menu_keys/harness_tests.rs b/crates/ui-egui/src/menus/menu_keys/harness_tests.rs index f4a5b796a7..3e60bbe1d1 100644 --- a/crates/ui-egui/src/menus/menu_keys/harness_tests.rs +++ b/crates/ui-egui/src/menus/menu_keys/harness_tests.rs @@ -280,6 +280,34 @@ fn underlined_text_marks_the_mnemonic_once_and_keeps_the_prefix() { out.textures_delta.clear(); } +/// Access keys preserve visible spelling and underline only the locale-correct I pair. +#[test] +fn azerbaijani_and_turkish_mnemonics_keep_the_two_i_pairs_distinct() { + for code in ["az", "tr"] { + let lang = crate::i18n::Lang::from_code(code).unwrap(); + crate::i18n::with_language(lang, || { + assert_eq!(crate::menu_keys::assign(&["ix", "ı"], &[Some('I'), Some('I')]), [Some('X'), Some('I')]); + assert_eq!(crate::menu_keys::localized(&["i".into()], &["Image"], &[Some('I')]), (vec!["i(I)".into()], vec![Some('I')])); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + for text in [crate::menu_keys::mnemonic_text(ui, "", "İıi", Some('I'), None), crate::menu_keys::menu_row_text(ui, false, "İıi", Some('I'))] + { + let egui::WidgetText::LayoutJob(job) = text else { panic!("an access key makes a layout job") }; + assert!(job.text.ends_with("İıi"), "visible text is retained verbatim"); + let underlined: Vec<&str> = job + .sections + .iter() + .filter(|s| s.format.underline != egui::Stroke::NONE) + .map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]) + .collect(); + assert_eq!(underlined, ["ı"]); + } + }); + out.textures_delta.clear(); + }); + } +} + /// The whole app: with the bar focused a letter opens a menu instead of picking a tool, and the /// arrows don't nudge the layer. #[test] diff --git a/crates/ui-egui/src/menus/native_menu.rs b/crates/ui-egui/src/menus/native_menu.rs index 4dad63c448..88106d1304 100644 --- a/crates/ui-egui/src/menus/native_menu.rs +++ b/crates/ui-egui/src/menus/native_menu.rs @@ -761,6 +761,8 @@ pub(crate) fn state_hash(app: &PhotocraftApp, input: Option) -> u64 { let ui = &app.ui; (&ui.recent_files, &ui.workspace, ui.palette_open, ui.transform.is_some(), ui.text_edit.is_some(), ui.dialogs.len()).hash(&mut h); (app.discard.is_some(), app.camera_raw.is_some(), crate::distort_ui::modal_workspace(app)).hash(&mut h); + // Edit / Clear Selected Guides are enabled only while a guide is selected. + crate::guide_select::selected(app).hash(&mut h); serde_json::to_string(&(&ui.panels, &ui.extras, &ui.view, ui.theme, ui.tool)).unwrap_or_default().hash(&mut h); s.prefs().interface.language.hash(&mut h); h.finish() diff --git a/crates/ui-egui/src/menus/parity.rs b/crates/ui-egui/src/menus/parity.rs index 2aa25e9b40..36a4d7bd28 100644 --- a/crates/ui-egui/src/menus/parity.rs +++ b/crates/ui-egui/src/menus/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 640; +pub const FLOOR: usize = 661; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/crates/ui-egui/src/menus/search.rs b/crates/ui-egui/src/menus/search.rs index f51172892d..0636c1d754 100644 --- a/crates/ui-egui/src/menus/search.rs +++ b/crates/ui-egui/src/menus/search.rs @@ -5,18 +5,24 @@ use super::state::translated_menu_label; /// Most results the Help menu search lists. pub(super) const HELP_SEARCH_MAX: usize = 15; -/// How well `query` (lowercase) matches a menu item: its label starting with it, then a word of +/// How well `query` matches an English menu item without case distinctions: its label starting with it, then a word of /// the label starting with it, then anywhere in the label, then anywhere in its menu path. /// `None` when it doesn't match at all. pub fn search_rank(query: &str, label: &str, path: &[String]) -> Option { - let l = label.to_lowercase(); - if l.starts_with(query) { + search_rank_in(crate::i18n::Lang::EN, query, label, path) +} + +/// Compare the query, label and path under the same language's casing rules. +fn search_rank_in(lang: crate::i18n::Lang, query: &str, label: &str, path: &[String]) -> Option { + let query = lang.lowercase(query); + let l = lang.lowercase(label); + if l.starts_with(&query) { Some(0) - } else if l.split(|c: char| !c.is_alphanumeric()).any(|w| w.starts_with(query)) { + } else if l.split(|c: char| !c.is_alphanumeric()).any(|w| w.starts_with(&query)) { Some(1) - } else if l.contains(query) { + } else if l.contains(&query) { Some(2) - } else if path.iter().any(|p| p.to_lowercase().contains(query)) { + } else if path.iter().any(|p| lang.contains_ignore_case(p, &query)) { Some(3) } else { None @@ -27,14 +33,14 @@ pub fn search_rank(query: &str, label: &str, path: &[String]) -> Option { /// labels and their `lang` translations match, so a search works in the UI language and in /// English; untranslated strings are simply their English text. pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::Lang) -> Vec<&'a MenuItem> { - let q = query.trim().to_lowercase(); + let q = query.trim(); if q.is_empty() { return Vec::new(); } let rank = |it: &MenuItem| { - let english = search_rank(&q, &it.label, &it.path); + let english = search_rank(q, &it.label, &it.path); let path: Vec = it.path.iter().map(|p| crate::i18n::tr(lang, p).to_string()).collect(); - let local = search_rank(&q, &translated_menu_label(lang, it), &path); + let local = search_rank_in(lang, q, &translated_menu_label(lang, it), &path); english.into_iter().chain(local).min() }; let mut hits: Vec<(u8, usize, &MenuItem)> = diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index c932ca092c..2dd79b06b5 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -80,6 +80,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { // "Custom…" is the full Proof Setup dialog. "view.proofSetup.custom" => app.session.active().is_some(), "view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true, + "view.editSelectedGuides" | "view.clearSelectedGuides" => crate::guide_select::selected(app).is_some(), "edit.cut" | "edit.copy" if app.ui.text_edit.is_some() => crate::type_tool::selected_range(app).is_some(), "edit.paste" | "select.all" if app.ui.text_edit.is_some() => true, // An image copied in another app can only be seen by reading the OS clipboard, which happens @@ -198,6 +199,7 @@ pub(super) fn checked(app: &PhotocraftApp, id: &str) -> Option { "window.toggle.dock" => p.dock, "window.togglePanels" => p.toolbar || p.options_bar || p.dock, "window.toggle.options" => p.options_bar, + "window.toggle.contextualTaskBar" => p.context_task_bar, "window.toggle.brushSettings" => p.brush_settings, "window.toggle.brushes" => p.brushes, _ => return None, @@ -211,7 +213,7 @@ pub(crate) fn reveal_label() -> &'static str { if cfg!(target_os = "macos") { "Reveal in Finder" } else if cfg!(windows) { - "Show in Explorer" + "Reveal in Explorer" } else { "Show in Folder" } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index c3ec327b15..69399fc068 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -123,10 +123,12 @@ fn color_to_alpha_sits_in_filter_other_after_offset() { let (offset, c2a) = (at("filter.other.offset"), at("filter.other.colorToAlpha")); assert_eq!(c2a, offset + 1); assert_eq!(items[c2a].path, items[offset].path); + assert_eq!(items[offset].path, ["Filter", "Other"]); assert_eq!(items[c2a].label, "Color to Alpha…"); assert!(items[c2a].enabled, "the Background becomes a layer when the filter runs"); let others: Vec<_> = items.iter().filter(|i| i.path.first().is_some_and(|p| p == "Filter") && i.path.len() == 2).map(|i| &i.path[1]).collect(); - assert!(!others.iter().any(|p| p.as_str() == "Other"), "no duplicate Other submenu: {others:?}"); + assert!(others.iter().any(|p| p.as_str() == "Other"), "the catalogue's Other submenu: {others:?}"); + assert!(!others.iter().any(|p| p.as_str() == "Other…"), "no second submenu spelled Other…: {others:?}"); } #[test] @@ -310,6 +312,19 @@ fn help_search_matches_the_ui_language_and_english() { assert!(ids("zzzzqqq").is_empty()); } +/// Azerbaijani menu text and paths keep dotted I distinct; English aliases still work. +#[test] +fn azerbaijani_help_search_uses_the_language_of_each_alias() { + let az = crate::i18n::Lang::from_code("az").unwrap(); + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let items = menu_items(&app); + let hits = |query: &str| search_items(&items, query, az).iter().map(|item| item.id.clone()).collect::>(); + assert!(!hits("ŞƏKİL").is_empty(), "uppercase dotted I matches Şəkil paths"); + assert_eq!(hits("ŞƏKİL"), hits("şəkil")); + assert!(hits("ŞƏKIL").is_empty(), "dotless I must not match dotted i"); + assert!(hits("DISTORT").contains(&"edit.transform.distort".to_string()), "English aliases keep English casing"); +} + /// #1447, #1438: a click on Help leaves its search field focused, so typing searches at once, /// and clicking into the field and typing keeps the menu open. Whole app, real clicks (press /// and release in separate frames), with the custom title bar (Windows, Linux) and without. diff --git a/crates/ui-egui/src/menus/view_cmds.rs b/crates/ui-egui/src/menus/view_cmds.rs index bbe985ab6e..6194417a22 100644 --- a/crates/ui-egui/src/menus/view_cmds.rs +++ b/crates/ui-egui/src/menus/view_cmds.rs @@ -18,7 +18,7 @@ mod dialogs; mod dispatch; mod options; -pub use arrange::cells; +pub use arrange::{Border, borders, cells, drag_border}; pub(crate) use arrange::{document_is_floating, float_window}; pub(crate) use dialogs::write_copy; #[cfg(test)] @@ -106,6 +106,9 @@ pub struct ViewOptions { pub pixel_art_preview: bool, /// Window › Arrange layout of the document area: "tabs" or a tile/n-up layout id. pub arrange: String, + /// Where the borders between arranged documents were dragged (#10101): fractions of the + /// area, column borders then row borders; empty (or not fitting the layout) is even cells. + pub arrange_splits: Vec, /// Type › Font Preview Size: "small" | "medium" | "large" | "extraLarge" | "huge". pub font_preview_size: String, /// Type › Language Options: "defaultFeatures" | "eastAsianFeatures" | "middleEasternFeatures", @@ -138,6 +141,7 @@ impl Default for ViewOptions { pattern_preview: false, pixel_art_preview: false, arrange: "tabs".into(), + arrange_splits: Vec::new(), font_preview_size: "medium".into(), language_features: "defaultFeatures".into(), middle_eastern_composer: false, diff --git a/crates/ui-egui/src/menus/view_cmds/arrange.rs b/crates/ui-egui/src/menus/view_cmds/arrange.rs index 9cf7a61d80..514c6e79b3 100644 --- a/crates/ui-egui/src/menus/view_cmds/arrange.rs +++ b/crates/ui-egui/src/menus/view_cmds/arrange.rs @@ -48,9 +48,14 @@ pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize, f wid } +/// The Window › Arrange items: a layout (2-up, 4-up, Tiled…), which starts from even cells and +/// refits every document, or one of the window commands beside them — Consolidate All to Tabs, +/// New Window, Float in Window, Float All in Windows, Match Zoom and the rest. pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { if LAYOUTS.contains(&k) { app.ui.view.arrange = k.to_string(); + // A new arrangement starts from even cells. + app.ui.view.arrange_splits.clear(); // Each document fits its new cell. app.ui.views.iter_mut().for_each(|v| v.fit_pending = true); return Ok(json!({"arrange": k})); @@ -107,7 +112,11 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result = app.session.documents().iter().map(|d| [d.doc.size.width, d.doc.size.height]).collect(); - let apply = |v: &mut crate::state::View, doc: usize| { + // A default-mode crop turns the active view's camera; that turn is the crop's, not the + // view's own rotation (which picking the Crop tool made 0), so it isn't copied (#9308). + let crop = crate::crop_mode::turns_view(app); + let rotation = if crop { 0.0 } else { src.rotation }; + let apply = |v: &mut crate::state::View, doc: usize, cropped: bool| { if zoom { v.zoom = crate::zoom_levels::clamp(src.zoom, sizes.get(doc).copied().unwrap_or(v.doc_size)); v.request_zoom_animation(animated_zoom); @@ -115,14 +124,15 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result Err(format!("unknown arrangement {k}")), } @@ -132,43 +142,164 @@ pub(crate) fn document_is_floating(app: &PhotocraftApp, doc: usize) -> bool { app.ui.windows.iter().any(|w| w.open && w.floating && w.document == doc) } -/// Screen rectangles for `n` documents in an arrangement (`None` = tabs). The active document -/// always gets a cell; n-up layouts show that many documents starting from the active one. -pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option> { - use craft_ui::layout::{SplitAxis, SplitSize, split_rect}; - - if n < 2 || !LAYOUTS.contains(&layout) { - return None; - } - let grid = |cols: usize, rows: usize, count: usize| -> Vec { - let (w, h) = (rect.width() / cols as f32, rect.height() / rows as f32); - (0..count.min(cols * rows)) - .map(|i| egui::Rect::from_min_size(rect.min + egui::vec2((i % cols) as f32 * w, (i / cols) as f32 * h), egui::vec2(w, h))) - .collect() - }; - Some(match layout { - "tileAllVertically" => grid(n, 1, n), - "tileAllHorizontally" => grid(1, n, n), - "twoUpVertical" => { - let panes = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![panes.first, panes.second] - } - "twoUpHorizontal" => { - let panes = split_rect(rect, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![panes.first, panes.second] - } - "threeUpVertical" => grid(3, 1, 3), - "threeUpHorizontal" => grid(1, 3, 3), - "threeUpStacked" => { - let columns = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?; - let rows = split_rect(columns.second, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![columns.first, rows.first, rows.second] - } - "fourUp" => grid(2, 2, 4), - "sixUp" => grid(3, 2, 6), - _ => { - let cols = (n as f32).sqrt().ceil() as usize; - grid(cols, n.div_ceil(cols), n) +/// Screen rectangles for `n` documents in an arrangement (`None` = tabs), with the borders +/// where they were left (`splits`, [`ViewOptions::arrange_splits`](super::ViewOptions)). The +/// active document always gets a cell; n-up layouts show that many documents starting from the +/// active one. +pub fn cells(layout: &str, rect: egui::Rect, n: usize, splits: &[f32]) -> Option> { + let shape = Shape::of(layout, n)?; + let (xs, ys) = shape.lines(rect, splits); + Some(match shape { + Shape::Grid { cols, rows, count } => (0..count.min(cols * rows)) + .filter_map(|i| { + let (c, r) = (i % cols, i / cols); + Some(egui::Rect::from_min_max(egui::pos2(*xs.get(c)?, *ys.get(r)?), egui::pos2(*xs.get(c + 1)?, *ys.get(r + 1)?))) + }) + .collect(), + // One tall cell on the left; the right column is cut in two. + Shape::Stacked => { + let (x, y) = (xs.get(1).copied().unwrap_or(rect.center().x), ys.get(1).copied().unwrap_or(rect.center().y)); + vec![ + egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.max.y)), + egui::Rect::from_min_max(egui::pos2(x, rect.min.y), egui::pos2(rect.max.x, y)), + egui::Rect::from_min_max(egui::pos2(x, y), rect.max), + ] } }) } + +/// A border between arranged documents that can be dragged, as in Photoshop (#10101). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Border { + /// Which of the arrangement's splits it is: the index for [`drag_border`]. + pub split: usize, + /// It runs top to bottom (and moves left and right). + pub vertical: bool, + /// Its line on screen: zero wide (or tall) across the cells it divides. + pub line: egui::Rect, +} + +/// The borders of an arrangement, as laid out by [`cells`]. +pub fn borders(layout: &str, rect: egui::Rect, n: usize, splits: &[f32]) -> Vec { + let Some(shape) = Shape::of(layout, n) else { return Vec::new() }; + let (xs, ys) = shape.lines(rect, splits); + let inner = |v: &[f32]| v.get(1..v.len().saturating_sub(1)).unwrap_or_default().to_vec(); + let (inner_x, inner_y) = (inner(&xs), inner(&ys)); + let vertical = inner_x.iter().enumerate().map(|(i, &x)| Border { + split: i, + vertical: true, + line: egui::Rect::from_min_max(egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)), + }); + // 3-up Stacked's row border spans only the right column. + let left = match shape { + Shape::Stacked => inner_x.first().copied().unwrap_or(rect.min.x), + Shape::Grid { .. } => rect.min.x, + }; + let horizontal = inner_y.iter().enumerate().map(|(i, &y)| Border { + split: inner_x.len() + i, + vertical: false, + line: egui::Rect::from_min_max(egui::pos2(left, y), egui::pos2(rect.max.x, y)), + }); + vertical.chain(horizontal).collect() +} + +/// Move border `split` of the arrangement to `pointer` (screen points), keeping every cell at +/// least [`MIN_CELL`] points across where the area allows. `splits` is rewritten in full, so a +/// stale or damaged list starts again from even cells. +pub fn drag_border(layout: &str, rect: egui::Rect, n: usize, splits: &mut Vec, split: usize, pointer: egui::Pos2) { + let Some(shape) = Shape::of(layout, n) else { return }; + let (cols, rows) = shape.counts(); + let mut fractions = shape.fractions(splits); + // The borders on the dragged one's axis are `fractions[first..first + count]`. + let (axis_start, axis_len, first, count, pos) = if split < cols - 1 { + (rect.min.x, rect.width(), 0, cols - 1, pointer.x) + } else if split < (cols - 1) + (rows - 1) { + (rect.min.y, rect.height(), cols - 1, rows - 1, pointer.y) + } else { + return; + }; + if !(axis_len > 0.0 && pos.is_finite()) { + return; + } + let own = fractions.get(first..first + count).map(<[f32]>::to_vec).unwrap_or_default(); + let k = split - first; + // [`MIN_CELL`] in fractions of this axis, whatever the number of cells: capping it by the + // even spacing would let a cell fall under the minimum on a long axis (#10101 review). + let gap = MIN_CELL / axis_len; + let lo = if k == 0 { 0.0 } else { own.get(k - 1).copied().unwrap_or(0.0) } + gap; + let hi = own.get(k + 1).copied().unwrap_or(1.0) - gap; + // The neighbours leave no room for a cell of the minimum width: the border stays where it is, + // rather than taking a value out of order that would invalidate the whole list. + if lo > hi { + return; + } + if let Some(f) = fractions.get_mut(split) { + *f = ((pos - axis_start) / axis_len).clamp(lo, hi); + } + *splits = fractions; +} + +/// The narrowest a cell is dragged to, in points. +pub const MIN_CELL: f32 = 80.0; + +/// An arrangement as columns and rows of cells. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Shape { + /// `cols` × `rows` even cells, the first `count` of them used. + Grid { cols: usize, rows: usize, count: usize }, + /// 3-up Stacked: two columns, the right one in two rows. + Stacked, +} + +impl Shape { + fn of(layout: &str, n: usize) -> Option { + if n < 2 || !LAYOUTS.contains(&layout) { + return None; + } + let grid = |cols: usize, rows: usize, count: usize| Shape::Grid { cols, rows, count }; + Some(match layout { + "tileAllVertically" => grid(n, 1, n), + "tileAllHorizontally" => grid(1, n, n), + "twoUpVertical" => grid(2, 1, 2), + "twoUpHorizontal" => grid(1, 2, 2), + "threeUpVertical" => grid(3, 1, 3), + "threeUpHorizontal" => grid(1, 3, 3), + "threeUpStacked" => Shape::Stacked, + "fourUp" => grid(2, 2, 4), + "sixUp" => grid(3, 2, 6), + _ => { + let cols = (n as f32).sqrt().ceil() as usize; + grid(cols, n.div_ceil(cols), n) + } + }) + } + + fn counts(self) -> (usize, usize) { + match self { + Shape::Grid { cols, rows, .. } => (cols, rows), + Shape::Stacked => (2, 2), + } + } + + /// The split positions as fractions: the column borders, then the row borders. `splits` + /// is used only when it fits this shape (as many values, each axis increasing within 0–1); + /// otherwise the cells are even. + fn fractions(self, splits: &[f32]) -> Vec { + let (cols, rows) = self.counts(); + let even = |k: usize| (1..k).map(move |i| i as f32 / k as f32); + let ordered = |v: &[f32]| v.iter().all(|f| f.is_finite() && *f > 0.0 && *f < 1.0) && v.windows(2).all(|w| matches!(w, [a, b] if a < b)); + let fit = splits.len() == (cols - 1) + (rows - 1) && splits.get(..cols - 1).is_some_and(ordered) && splits.get(cols - 1..).is_some_and(ordered); + if fit { splits.to_vec() } else { even(cols).chain(even(rows)).collect() } + } + + /// Every column line's x and every row line's y on screen, the area's edges included. + fn lines(self, rect: egui::Rect, splits: &[f32]) -> (Vec, Vec) { + let (cols, _) = self.counts(); + let fractions = self.fractions(splits); + let (fx, fy) = fractions.split_at((cols - 1).min(fractions.len())); + let at = |start: f32, len: f32, inner: &[f32]| { + std::iter::once(start).chain(inner.iter().map(|f| start + len * f)).chain(std::iter::once(start + len)).collect::>() + }; + (at(rect.min.x, rect.width(), fx), at(rect.min.y, rect.height(), fy)) + } +} diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 45c3d5447f..16cefbd87b 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -66,8 +66,10 @@ pub(super) fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: if let Value::Object(m) = fields { f.extend(m); } - // Snapshot user-facing params at open so Alt+Reset can restore them. - let orig: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + // Snapshot user-facing params at open so Alt+Reset can restore them, with the Custom + // quality rows' inactive state. + let orig: Map = + f.iter().filter(|(k, _)| !k.starts_with("__") || RESET_SHELL_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); f.insert("__formOrig".into(), Value::Object(orig)); app.ui.open_dialog(DialogKind::Command, f) } @@ -166,18 +168,21 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { ui.label(label(&k)); let mut custom = v.as_f64().is_some(); - let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; - ui.horizontal(|ui| { - crate::widgets::checkbox(ui, &mut custom, tl!("Custom")); - ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); - }); + let mut quality = v.as_f64().or_else(|| f.get(BATCH_QUALITY).and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, true); // None preserves the command's existing JPEG and lossless WebP defaults. // The inactive custom value is shell state, never a command parameter. - f.insert("__batchQuality".into(), json!(quality)); + f.insert(BATCH_QUALITY.into(), json!(quality)); f.insert(k, if custom { json!(quality) } else { Value::Null }); ui.end_row(); continue; } + if k == "quality" && f.contains_key(JPEG_QUALITY) { + ui.label(label(&k)); + jpeg_quality_field(ui, f); + ui.end_row(); + continue; + } let new = match &v { Value::Array(_) | Value::Object(_) | Value::Null => continue, Value::Bool(b) => { @@ -191,9 +196,17 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let mut s = s.clone(); match choices.get(&k).and_then(Value::as_array) { Some(opts) => { - let opts: Vec<(String, String)> = opts.iter().filter_map(Value::as_str).map(|o| (o.to_string(), label(o))).collect(); + // Action and set names are the user's own text, never catalogue entries. + let action_names = matches!(k.as_str(), "action" | "set") + && matches!(f.get("__command").and_then(Value::as_str), Some("file.automate.batch" | "file.automate.createDroplet")); + let opts: Vec<(String, String)> = + opts.iter().filter_map(Value::as_str).map(|o| (o.to_string(), if action_names { o.to_string() } else { label(o) })).collect(); let refs: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); - crate::widgets::dropdown(ui, &format!("form-{k}"), &label(&k), &mut s, &refs, 200.0); + if action_names { + crate::widgets::dropdown_literal(ui, &format!("form-{k}"), &label(&k), &mut s, &refs, 200.0); + } else { + crate::widgets::dropdown(ui, &format!("form-{k}"), &label(&k), &mut s, &refs, 200.0); + } } None => { if file_info && matches!(k.as_str(), "description" | "keywords" | "copyright") { @@ -238,6 +251,42 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { camera_data(ui, f); } +/// Batch's inactive Custom quality (#3956): shell state, never a command parameter. +const BATCH_QUALITY: &str = "__batchQuality"; +/// An export-to-files dialog's Custom checkbox and its quality (#11481), kept while the format +/// is not JPEG or Custom is off: shell state, never command parameters. +const JPEG_CUSTOM: &str = "__jpegCustom"; +const JPEG_QUALITY: &str = "__jpegQuality"; +/// The shell keys Alt+Reset restores along with a form's params. +const RESET_SHELL_KEYS: [&str; 3] = [BATCH_QUALITY, JPEG_CUSTOM, JPEG_QUALITY]; + +/// A Custom checkbox and the 0..12 Quality field it enables (Batch, the exports to files). Off, +/// the command gets no quality and keeps its own defaults; `usable` false greys out both. +fn custom_quality_row(ui: &mut egui::Ui, custom: &mut bool, quality: &mut f32, usable: bool) { + ui.add_enabled_ui(usable, |ui| { + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, custom, tl!("Custom")); + ui.add_enabled_ui(*custom, |ui| crate::widgets::value_field(ui, quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); + }); + }); +} + +/// Layers, Layer Comps and Artboards to Files: the JPEG quality, sent only for a JPEG with Custom +/// on. Left alone, the export writes what the command writes with no quality (the source JPEG's +/// own quality, else the encoder default), as Batch does; a quality would also make a WebP lossy +/// (its default is lossless), and PNG, PSD, TIFF and BMP have none. +fn jpeg_quality_field(ui: &mut egui::Ui, f: &mut Map) { + let jpeg = f.get("format").and_then(Value::as_str).is_some_and(|x| x.eq_ignore_ascii_case("jpg") || x.eq_ignore_ascii_case("jpeg")); + let sent = f.get("quality").and_then(Value::as_f64); + let mut custom = sent.is_some() || f.get(JPEG_CUSTOM).and_then(Value::as_bool).unwrap_or(false); + let mut quality = sent.or_else(|| f.get(JPEG_QUALITY).and_then(Value::as_f64)).unwrap_or(10.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, jpeg); + let quality = quality.round().clamp(0.0, 12.0) as i64; + f.insert(JPEG_CUSTOM.into(), json!(custom)); + f.insert(JPEG_QUALITY.into(), json!(quality)); + f.insert("quality".into(), if jpeg && custom { json!(quality) } else { Value::Null }); +} + /// File Info's read-only Camera Data section (`__camera`, from the document's EXIF/XMP). fn camera_data(ui: &mut egui::Ui, f: &Map) { let Some(camera) = f.get("__camera").and_then(Value::as_object).filter(|c| !c.is_empty()) else { return }; @@ -260,6 +309,7 @@ fn default_dir(app: &PhotocraftApp) -> String { app.session.active().and_then(|d| d.path.as_deref()).and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())).unwrap_or_else(|| ".".into()) } +/// Handle menu commands that need a shell action or a parameter dialog before execution. pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if id == "file.closeAll" || id == "file.closeOthers" { // Keep the remaining document's view (views are index-aligned with documents). @@ -314,7 +364,10 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para app.refocus(doc)?; let linked = linked.then(|| name.clone()); let result = app.place_bytes(&name, bytes, linked)?; - crate::transform_tool::begin_placed(app, &ctx)?; + // In the background, Free Transform starts when the layer has landed. + if result.get("frame").and_then(Value::as_u64).is_none() && !crate::jobs_ui::track_place(app, &name, &result) { + crate::transform_tool::begin_placed(app, &ctx)?; + } Ok(result) })) } @@ -416,7 +469,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; dialog( app, - json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}), + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "quality": null, JPEG_CUSTOM: false, + JPEG_QUALITY: 10, "visibleOnly": true, "trim": false, "__order": ["dir", "prefix", "visibleOnly", "format", "quality", "trim"]}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), ) } @@ -424,9 +478,10 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; let prefix = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string(); let fields = if id == "file.export.layerCompsToFiles" { - json!({"dir": dir, "prefix": prefix, "format": "png", "selectedOnly": false}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "selectedOnly": false, + "__order": ["dir", "prefix", "selectedOnly", "format", "quality"]}) } else { - json!({"dir": dir, "prefix": prefix, "format": "png"}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "__order": ["dir", "prefix", "format", "quality"]}) }; dialog(app, fields, json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) } @@ -435,6 +490,18 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let stem = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string(); dialog(app, json!({"path": format!("{dir}/{stem}.pdf"), "quality": 10}), json!({})) } + "file.export.dataSetsAsFiles" => { + #[cfg(target_arch = "wasm32")] + { + let _ = (app, dir, doc); + Some(Err(tl!("Data Sets as Files is unavailable on the web").to_string())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let (_, _, _) = doc?; + dialog(app, json!({"dir": dir, "naming": "{name}", "format": "png"}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) + } + } "layer.new.artboard" => { let (w, h, _) = doc?; let presets: Vec<&str> = std::iter::once("").chain(photocraft_engine::artboard_cmds::PRESETS.iter().map(|p| p.0)).collect(); @@ -486,20 +553,21 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let Some(action) = crate::actions::selected_action(app) else { return Some(Err("record an action in the Actions panel first".into())); }; - let steps = crate::actions::action_steps(action); - let name = action.name.clone(); - dialog( - app, - json!({ - "action": name, "steps": steps, "source": "folder", "input": dir, - "includeSubfolders": false, "__labels": subfolders_label(), - "output": format!("{dir}/batch"), "format": "same", "quality": null, - "__batchQuality": 8, "naming": "", "startingSerial": 1, - "stopOnError": false, "errorLog": "", - "__order": ["action", "source", "input", "includeSubfolders", "output", "format", "naming", "startingSerial", "quality", "stopOnError", "errorLog"] - }), - json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"], "source": ["folder", "open"]}), - ) + let (name, set) = (action.name.clone(), action.set.clone()); + let (names, sets) = crate::actions::action_form_choices(app); + let mut fields = json!({ + "action": name, "source": "folder", "input": dir, + "includeSubfolders": false, "__labels": subfolders_label(), + "output": format!("{dir}/batch"), "format": "same", "quality": null, + "__batchQuality": 8, "naming": "", "startingSerial": 1, + "stopOnError": false, "errorLog": "", + "__order": ["set", "action", "source", "input", "includeSubfolders", "output", "format", "naming", "startingSerial", "quality", "stopOnError", "errorLog"] + }); + // The Set dropdown picks between same-named actions; an action outside any set has none. + if let Some(set) = set { + fields["set"] = json!(set); + } + dialog(app, fields, json!({"set": sets, "action": names, "format": ["same", "png", "jpg", "psd", "tiff", "webp"], "source": ["folder", "open"]})) } // Channel workflow dialogs (see channels_panel.rs for the channel references). "select.saveSelection" | "select.loadSelection" | "image.applyImage" | "image.calculations" => { @@ -632,6 +700,7 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { /// are any. pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result { let settings = choices.apply(app.save_settings()); + crate::app::ensure_memory_to_save(doc, path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/menus/view_cmds/dispatch.rs b/crates/ui-egui/src/menus/view_cmds/dispatch.rs index d103ef5f14..f1e9b2770f 100644 --- a/crates/ui-egui/src/menus/view_cmds/dispatch.rs +++ b/crates/ui-egui/src/menus/view_cmds/dispatch.rs @@ -103,6 +103,8 @@ pub(super) fn is_enabled_with_engine(app: &PhotocraftApp, id: &str, engine_enabl // Batch's dialog is built from an Actions-panel action. Keep it disabled until one // exists instead of opening a menu item that can only report "record an action". "file.automate.batch" => crate::actions::selected_action(app).is_some() && engine_enabled(), + #[cfg(target_arch = "wasm32")] + "file.export.dataSetsAsFiles" => false, i if wraps(i) => engine_enabled(), _ => true, }) @@ -203,11 +205,19 @@ fn type_checked(app: &PhotocraftApp, id: &str) -> Option { "ornaments" => "ornm", "ordinals" => "ordn", "fractions" => "frac", - _ => return None, + set => stylistic_set_tag(set.strip_prefix("stylisticSets.set")?)?, }; Some(photocraft_engine::type_extra_cmds::feature_on_at(t, start, tag)) } +/// `ss01`..`ss20` for the digits of a Stylistic Sets item id (`1`..`20`, no leading zeros). +fn stylistic_set_tag(n: &str) -> Option<&'static str> { + if n.starts_with('0') || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + photocraft_engine::type_extra_cmds::STYLISTIC_SET_TAGS.get(n.parse::().ok()?.checked_sub(1)?).copied() +} + /// Engine commands that get a dialog or file picker here when invoked without parameters. fn wraps(id: &str) -> bool { matches!( @@ -236,6 +246,7 @@ fn wraps(id: &str) -> bool { | "file.export.layerCompsToFiles" | "file.export.artboardsToFiles" | "file.export.artboardsToPdf" + | "file.export.dataSetsAsFiles" | "layer.new.artboard" | "file.export.colorLookupTables" | "view.newGuideLayout" diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index aebc91f702..67c823b5c1 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -40,6 +40,29 @@ fn place_embedded_enablement_does_not_require_native_doc() { } } +#[test] +fn data_sets_as_files_enablement_respects_target_platform() { + let (app, _) = app_with(1); + let enabled = is_enabled_with_engine(&app, "file.export.dataSetsAsFiles", || true); + if cfg!(target_arch = "wasm32") { + assert_eq!(enabled, Some(false)); + } else { + assert_eq!(enabled, Some(true)); + } +} + +#[test] +fn data_sets_as_files_dialog_respects_target_platform() { + let (mut app, ctx) = app_with(1); + app.run("image.variables.dataSets", json!({"dataSets": [{"name": "Set 1", "values": []}]})).unwrap(); + let res = front(&mut app, &ctx, "file.export.dataSetsAsFiles", &json!({})); + if cfg!(target_arch = "wasm32") { + assert!(matches!(res, Some(Err(msg)) if msg.contains("unavailable on the web"))); + } else { + assert!(matches!(res, Some(Ok(v)) if v.get("dialog").is_some())); + } +} + #[test] fn show_snap_extras_and_screen_modes_are_state() { let (mut app, ctx) = app_with(1); @@ -160,14 +183,14 @@ fn print_size_uses_the_screen_resolution_preference() { #[test] fn arrange_layouts_floating_windows_and_matching() { let (mut app, ctx) = app_with(3); - assert!(cells("tabs", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).is_none()); + assert!(cells("tabs", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).is_none()); menu(&mut app, &ctx, "window.arrange.threeUpStacked", json!({})).unwrap(); assert_eq!(checked(&app, "window.arrange.threeUpStacked"), Some(true)); - let c = cells(&app.ui.view.arrange, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).unwrap(); + let c = cells(&app.ui.view.arrange, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).unwrap(); assert_eq!(c.len(), 3); assert_eq!(c[0].height(), 600.0); assert_eq!(c[1].height(), 300.0); - let six = cells("sixUp", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).unwrap(); + let six = cells("sixUp", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).unwrap(); assert_eq!((six.len(), six[1].min.x), (6, 300.0)); menu(&mut app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); assert_eq!(app.ui.windows.len(), 3); @@ -209,13 +232,109 @@ fn match_zoom_clamps_to_each_documents_zoom_range() { fn two_up_and_stacked_cells_keep_their_existing_boundaries() { let rect = egui::Rect::from_min_max(egui::pos2(40.0, 75.0), egui::pos2(940.0, 675.0)); let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - assert_eq!(cells("twoUpVertical", rect, 2).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]); - assert_eq!(cells("twoUpHorizontal", rect, 2).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]); + assert_eq!(cells("twoUpVertical", rect, 2, &[]).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]); + assert_eq!(cells("twoUpHorizontal", rect, 2, &[]).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]); assert_eq!( - cells("threeUpStacked", rect, 3).unwrap(), + cells("threeUpStacked", rect, 3, &[]).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 375.0), region(490.0, 375.0, 940.0, 675.0)] ); - assert!(cells("twoUpVertical", rect, 1).is_none()); + assert!(cells("twoUpVertical", rect, 1, &[]).is_none()); +} + +#[test] +fn dragged_arrange_borders_resize_the_cells_beside_them() { + // #10101: in 2-up and the other arrangements the border between documents drags, as in + // Photoshop, and the documents on either side take the new sizes. + use super::arrange::{Border, MIN_CELL}; + let near = |a: &[egui::Rect], b: &[egui::Rect]| { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01) + }; + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 600.0)); + let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + let mut splits = Vec::new(); + let b = borders("twoUpVertical", rect, 2, &splits); + assert_eq!(b, vec![Border { split: 0, vertical: true, line: region(450.0, 0.0, 450.0, 600.0) }]); + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(300.0, 250.0)); + assert!(near(&cells("twoUpVertical", rect, 2, &splits).unwrap(), &[region(0.0, 0.0, 300.0, 600.0), region(300.0, 0.0, 900.0, 600.0)])); + assert!((borders("twoUpVertical", rect, 2, &splits)[0].line.min.x - 300.0).abs() < 0.01); + // No cell is dragged narrower than MIN_CELL, nor past the area. + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(-500.0, 0.0)); + assert!((cells("twoUpVertical", rect, 2, &splits).unwrap()[0].width() - MIN_CELL).abs() < 0.01); + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(899.0, 0.0)); + assert!((cells("twoUpVertical", rect, 2, &splits).unwrap()[1].width() - MIN_CELL).abs() < 0.01); + + // 4-up: a column border and a row border, each across the whole area. + let mut splits = Vec::new(); + let b = borders("fourUp", rect, 4, &splits); + assert_eq!(b.iter().map(|b| (b.split, b.vertical)).collect::>(), vec![(0, true), (1, false)]); + drag_border("fourUp", rect, 4, &mut splits, 1, egui::pos2(10.0, 450.0)); + let c = cells("fourUp", rect, 4, &splits).unwrap(); + assert!(near(&[c[0], c[3]], &[region(0.0, 0.0, 450.0, 450.0), region(450.0, 450.0, 900.0, 600.0)])); + + // 3-up Stacked: the row border is only across the right column, and follows the column. + let mut splits = Vec::new(); + drag_border("threeUpStacked", rect, 3, &mut splits, 0, egui::pos2(600.0, 0.0)); + drag_border("threeUpStacked", rect, 3, &mut splits, 1, egui::pos2(700.0, 200.0)); + assert!(near( + &cells("threeUpStacked", rect, 3, &splits).unwrap(), + &[region(0.0, 0.0, 600.0, 600.0), region(600.0, 0.0, 900.0, 200.0), region(600.0, 200.0, 900.0, 600.0)] + )); + assert!(near(&[borders("threeUpStacked", rect, 3, &splits)[1].line], &[region(600.0, 200.0, 900.0, 200.0)])); + + // Tiled: a border stops short of its neighbours, so the cells keep their order. + let mut splits = Vec::new(); + drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(800.0, 0.0)); + let c = cells("threeUpVertical", rect, 3, &splits).unwrap(); + assert!((c[0].max.x - (600.0 - MIN_CELL)).abs() < 0.01 && (c[1].width() - MIN_CELL).abs() < 0.01); + + // #10101 review: the limit is MIN_CELL itself, not the even spacing. On a 300 pt axis a + // 2-up cell may still be dragged down to 80 pt, where capping the gap at 0.5/(n+1) would + // have stopped it at 75. + let narrow = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(300.0, 200.0)); + let mut splits = Vec::new(); + drag_border("twoUpVertical", narrow, 2, &mut splits, 0, egui::pos2(-100.0, 0.0)); + assert!((cells("twoUpVertical", narrow, 2, &splits).unwrap()[0].width() - MIN_CELL).abs() < 0.01); + + // And when the neighbours leave no room for a cell of that width, the border stays put and + // the list keeps its order rather than taking a value from the wrong side of them. + let tight = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 200.0)); + let mut splits = vec![0.45, 0.55]; + drag_border("threeUpVertical", tight, 3, &mut splits, 1, egui::pos2(10.0, 0.0)); + assert_eq!(splits, vec![0.45, 0.55], "no room: unchanged"); + let mut splits = vec![0.45, 0.55]; + drag_border("threeUpVertical", tight, 3, &mut splits, 0, egui::pos2(190.0, 0.0)); + assert_eq!(splits, vec![0.45, 0.55], "and from the other side too"); +} + +#[test] +fn damaged_or_stale_arrange_splits_lay_out_even_cells() { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)); + let even = cells("threeUpVertical", rect, 3, &[]).unwrap(); + // From a settings file or another layout: wrong count, out of order, outside 0–1, NaN. + for bad in [vec![0.5], vec![0.6, 0.3], vec![0.0, 0.5], vec![0.3, 1.5], vec![f32::NAN, 0.7], vec![0.2, 0.4, 0.6]] { + assert_eq!(cells("threeUpVertical", rect, 3, &bad).unwrap(), even, "{bad:?}"); + assert_eq!(borders("threeUpVertical", rect, 3, &bad).len(), 2); + // A drag starts again from even cells. + let mut splits = bad.clone(); + drag_border("threeUpVertical", rect, 3, &mut splits, 1, egui::pos2(700.0, 0.0)); + assert_eq!(cells("threeUpVertical", rect, 3, &splits).unwrap()[0], even[0], "{bad:?}"); + } + // A split that isn't the layout's, or a pointer off the numbers, changes nothing. + let mut splits = vec![0.25, 0.5]; + drag_border("threeUpVertical", rect, 3, &mut splits, 2, egui::pos2(700.0, 0.0)); + drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(f32::NAN, 0.0)); + drag_border("threeUpVertical", egui::Rect::NOTHING, 3, &mut splits, 0, egui::pos2(10.0, 0.0)); + assert_eq!(splits, vec![0.25, 0.5]); + assert!(borders("tabs", rect, 3, &[]).is_empty()); +} + +#[test] +fn choosing_an_arrangement_starts_from_even_cells() { + let (mut app, ctx) = app_with(2); + menu(&mut app, &ctx, "window.arrange.twoUpVertical", json!({})).unwrap(); + app.ui.view.arrange_splits = vec![0.3]; + menu(&mut app, &ctx, "window.arrange.twoUpHorizontal", json!({})).unwrap(); + assert!(app.ui.view.arrange_splits.is_empty()); } #[test] @@ -275,6 +394,26 @@ fn numerals_checkmark_follows_the_selection() { assert_eq!(checked(&app, "type.openType.numerals.notAStyle"), None); } +#[test] +fn stylistic_set_checkmark_follows_the_toggle() { + let (mut app, ctx) = app_with(1); + let id = "type.openType.stylisticSets.set4"; + assert!(!crate::menus::is_enabled(&app, id)); + assert_eq!(checked(&app, id), None, "no type layer"); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Type", "size": 24})).unwrap(); + assert!(crate::menus::is_enabled(&app, id)); + assert_eq!(checked(&app, id), Some(false)); + menu(&mut app, &ctx, id, json!({})).unwrap(); + assert_eq!(checked(&app, id), Some(true)); + assert_eq!(checked(&app, "type.openType.stylisticSets.set5"), Some(false)); + menu(&mut app, &ctx, id, json!({})).unwrap(); + assert_eq!(checked(&app, id), Some(false)); + for bad in ["set0", "set21", "set04", "set", "set-1", "setx", "set100"] { + // Not through `checked`, which only finds catalog items: these ids must reach the resolver. + assert_eq!(super::dispatch::checked(&app, &format!("type.openType.stylisticSets.{bad}")), None, "{bad}"); + } +} + #[test] fn batch_menu_requires_a_recorded_action() { let (mut app, ctx) = app_with(1); @@ -289,6 +428,93 @@ fn batch_menu_requires_a_recorded_action() { assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["action"], "Invert"); } +/// Two actions of one name in sets A and B (a loaded library): the Batch form lists the name once +/// and both sets, starts on the selected action's set, and runs that set's action. +#[test] +fn batch_menu_runs_the_selected_sets_action_among_same_named_ones() { + let (mut app, ctx) = app_with(0); + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("source.png").to_string_lossy().into_owned(); + let output = dir.path().join("out").to_string_lossy().into_owned(); + app.run("file.new", json!({"width": 2, "height": 1})).unwrap(); + app.run("file.saveACopy", json!({"path": input})).unwrap(); + let mut list = Vec::new(); + for (set, step) in [("A", "image.adjustments.invert"), ("B", "image.imageRotation.90cw")] { + let mut action = photocraft_engine::actions_cmds::Action::new("Retouch", vec![(step.into(), json!({}))]); + action.set = Some(set.into()); + list.push(action); + } + app.session.actions.list = list; + app.ui.actions.selected = Some(1); + let id = menu(&mut app, &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(fields["__choices"]["action"], json!(["Retouch"])); + assert_eq!(fields["__choices"]["set"], json!(["A", "B"])); + assert_eq!((&fields["action"], &fields["set"]), (&json!("Retouch"), &json!("B"))); + fields.insert("input".into(), json!([input.clone()])); + fields.insert("output".into(), json!(output)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let saved = std::fs::read_dir(&output).unwrap().next().unwrap().unwrap().path(); + let image = photocraft_io::import("out.png", &std::fs::read(saved).unwrap()).unwrap().document; + assert_eq!((image.size.width, image.size.height), (1, 2), "set B's action (a rotation) ran"); +} + +/// Choosing a different action changes the output; cancelled or deleted choices write nothing. +#[test] +fn batch_menu_runs_the_action_chosen_in_the_form() { + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, _) = app_with(0); + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("source.png").to_string_lossy().into_owned(); + let output = dir.path().join("out").to_string_lossy().into_owned(); + app.run("file.new", json!({"width": 2, "height": 1})).unwrap(); + app.run("file.saveACopy", json!({"path": input})).unwrap(); + app.session.actions.list = vec![ + photocraft_engine::actions_cmds::Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))]), + photocraft_engine::actions_cmds::Action::new("RotateRGB", vec![("image.imageRotation.90cw".into(), json!({}))]), + ]; + app.ui.actions.selected = Some(0); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + let ctx = h.ctx.clone(); + let id = menu(h.state_mut(), &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(id).unwrap().fields; + assert!(!fields.contains_key("steps")); + fields.insert("input".into(), json!([input])); + fields.insert("output".into(), json!(output)); + h.run_steps(4); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Action").click(); + h.run_steps(2); + h.get_by_label("RotateRGB").click(); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["action"], "RotateRGB"); + let result = crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(result["errors"], json!([])); + let image = photocraft_io::import("source.png", &std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!((image.size.width, image.size.height), (1, 2)); + + let cancelled = dir.path().join("cancelled"); + let id = menu(h.state_mut(), &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("output".into(), json!(cancelled.to_string_lossy())); + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + assert!(!cancelled.exists()); + + let id = menu(h.state_mut(), &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(id).unwrap().fields; + fields.insert("action".into(), json!("RotateRGB")); + fields.insert("output".into(), json!(cancelled.to_string_lossy())); + h.state_mut().session.actions.list.retain(|action| action.name != "RotateRGB"); + assert!(crate::dialogs::confirm(h.state_mut(), id).unwrap_err().contains("no action named")); + assert!(!cancelled.exists()); +} + #[test] fn engine_commands_open_their_dialogs() { let (mut app, ctx) = app_with(2); @@ -477,6 +703,88 @@ fn batch_dialogs_include_all_subfolders() { let _ = std::fs::remove_dir_all(&dir); } +/// #10682 / #10113: an action recorded with the Type tool (a new type layer, typed in key by key) +/// runs in Batch on every file of the folder and saves each into the destination. +#[test] +fn batch_runs_an_action_recorded_with_the_type_tool() { + let (mut app, ctx) = app_with(1); + let dir = std::env::temp_dir().join(format!("photocraft-ui-batch-type-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in")).unwrap(); + for name in ["a.png", "b.png"] { + app.run("file.saveACopy", json!({"path": dir.join("in").join(name).to_string_lossy()})).unwrap(); + } + app.run("actions.record", json!({"name": "Caption"})).unwrap(); + let id = app.run("type.create", json!({"x": 10, "y": 40, "text": "", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + for (i, c) in "Hi".chars().enumerate() { + app.run("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": c.to_string()}, "coalesce": "typing"})).unwrap(); + } + app.run("actions.stop", json!({})).unwrap(); + app.run("file.saveACopy", json!({"path": dir.join("ref.png").to_string_lossy()})).unwrap(); + assert_eq!(crate::actions::selected_action(&app).map(|a| a.steps.len()), Some(1), "one step for the typing"); + // CodeRabbit on #10730: and that step makes the layer with the text typed. + assert_eq!(crate::actions::selected_action(&app).and_then(|a| a.steps.first()).map(|(_, p)| p["text"].clone()), Some(json!("Hi"))); + let d = menu(&mut app, &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut app.ui.dialog_mut(d).unwrap().fields; + fields.insert("input".into(), json!(dir.join("in").to_string_lossy())); + fields.insert("output".into(), json!(dir.join("out").to_string_lossy())); + let r = crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(r.get("failed").is_none_or(|f| f.as_array().is_some_and(Vec::is_empty)), "{r}"); + for name in ["a.png", "b.png"] { + assert!(dir.join("out").join(name).is_file(), "{name} saved: {r}"); + // CodeRabbit on #10730: the action ran on each file: the typed text is in its pixels. + let pixels = |folder: &str| photocraft_codecs::decode(&std::fs::read(dir.join(folder).join(name)).unwrap()).unwrap().data().to_vec(); + assert_ne!(pixels("out"), pixels("in"), "{name}: the text was laid on it"); + } + // CodeRabbit on #10730: all of it, as typed by hand: the document the action was recorded + // on (the same image, with the text typed into it) has the pixels Batch wrote. + let decoded = |path: std::path::PathBuf| photocraft_codecs::decode(&std::fs::read(path).unwrap()).unwrap().data().to_vec(); + for name in ["a.png", "b.png"] { + assert_eq!(decoded(dir.join("out").join(name)), decoded(dir.join("ref.png")), "{name}: Batch laid the whole text, as it was typed by hand"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// #10682: a Batch whose action fails on every file said nothing; the status bar and a notice now +/// name each file that failed and why. +#[test] +fn a_batch_that_fails_on_its_files_says_which_and_why() { + let (mut app, ctx) = app_with(1); + let dir = std::env::temp_dir().join(format!("photocraft-ui-batch-fails-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in")).unwrap(); + for name in ["a.png", "b.png"] { + app.run("file.saveACopy", json!({"path": dir.join("in").join(name).to_string_lossy()})).unwrap(); + } + // A step for a layer that only the recording document has. + app.session + .actions + .list + .push(photocraft_engine::actions_cmds::Action::new("Stale", vec![("layer.setProps".into(), json!({"layer": 424_242, "opacity": 50}))])); + let d = menu(&mut app, &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut app.ui.dialog_mut(d).unwrap().fields; + fields.insert("input".into(), json!(dir.join("in").to_string_lossy())); + fields.insert("output".into(), json!(dir.join("out").to_string_lossy())); + let notices = app.ui.notices.len(); + let r = crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(app.ui.status_error, "the status bar reports it: {}", app.ui.status); + let notice = app.ui.notices.get(notices).expect("a notice lists the files"); + assert_eq!(notice.lines.len(), 2); + // CodeRabbit on #10730: a failure, not a warning, and each file by its whole path. + assert!(notice.error && notice.title.ends_with("2 files failed"), "{}", notice.title); + let path = |name: &str| format!("{}: ", dir.join("in").join(name).to_string_lossy()); + assert!(notice.lines[0].starts_with(&path("a.png")) && notice.lines[1].starts_with(&path("b.png")), "{:?}", notice.lines); + // CodeRabbit on #10730: each line is its file's error, word for word, path then reason. + let errors = r["errors"].as_array().unwrap(); + for (line, e) in notice.lines.iter().zip(errors) { + assert!(!e["error"].as_str().unwrap_or_default().trim().is_empty(), "{e}"); + assert_eq!(*line, format!("{}: {}", e["file"].as_str().unwrap(), e["error"].as_str().unwrap())); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Float in Window takes the document and the screen position a dragged-off tab was dropped at /// (#3733); bad params are errors, not panics. #[test] @@ -634,6 +942,29 @@ fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { assert_eq!(app.ui.view.screen_mode, "standard"); } +/// #9275: a gesture on the default frame is something to cancel, so Esc in full screen cancels it +/// (as in Standard Screen Mode) and full screen stays. +#[test] +fn esc_in_full_screen_cancels_a_crop_drag_on_the_default_frame() { + use crate::canvas::{ToolEvent, tool_event}; + let none = egui::Modifiers::NONE; + // Inside draws a new box, on the corner handle resizes, outside turns. + for (what, from) in [("draw", [20.0, 20.0]), ("resize", [100.0, 80.0]), ("turn", [130.0, 110.0])] { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 50.0, pressure: 1.0 }, none); + assert!(app.crop.drag.is_some() && app.crop.default_frame, "{what}: mid-gesture on the default frame"); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen", "{what}: Esc cancels the gesture, full screen stays"); + assert!(app.crop.drag.is_none(), "{what}: the gesture is cancelled"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 50.0 }, none); + assert!(!crate::crop_ui::pending(&app), "{what}: the release leaves no pending crop"); + } +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); @@ -779,6 +1110,50 @@ fn arranged_documents_keep_their_tiles() { assert_eq!(docs(&mut app, 1), vec![0, 1]); } +/// #10101: dragging the border between two arranged documents moves it. Grabbed inside the +/// other document's edge, it doesn't make that document the active one. +#[test] +fn dragging_the_border_between_arranged_documents_resizes_them() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = crate::state::Tool::Brush; + // The right-hand document is active; the grab below is inside the left one's edge. + app.session.set_active(1); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let area = h.state().last_canvas_rect; + let n = h.state().session.documents().len(); + let border = borders("twoUpVertical", area, n, &[])[0].line; + // On the grip, 2 pt inside the left document (its cell ends 1 pt short of the border). + let from = border.center() - egui::vec2(2.0, 0.0); + let to = from - egui::vec2(200.0, 0.0); + let ev = |h: &mut Harness<'_, PhotocraftApp>, e: egui::Event| h.input_mut().events.push(e); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + ev(&mut h, egui::Event::PointerMoved(from)); + h.run(); + ev(&mut h, button(from, true)); + h.run(); + for step in 1..=10 { + ev(&mut h, egui::Event::PointerMoved(from + (to - from) * (step as f32 / 10.0))); + h.run(); + } + ev(&mut h, button(to, false)); + h.run_steps(2); + let cells = cells("twoUpVertical", area, n, &h.state().ui.view.arrange_splits).unwrap(); + assert!((cells[0].max.x - to.x).abs() < 1.0, "the border follows the pointer: {cells:?}, pointer at {}", to.x); + assert_eq!(h.state().session.active_index(), Some(1), "grabbing the border activates no document"); +} + /// #6017: rulers showed in the active tile only; the other tile had none and no room for them. #[test] fn every_arranged_tile_has_rulers() { @@ -873,3 +1248,52 @@ fn full_screen_modes_need_a_document() { leave_full_screen_without_document(&mut app, &ctx); assert_eq!(app.ui.view.screen_mode, "standard"); } + +/// #10101 review: the grip overlaps the *active* document's edge too, and a press there must +/// drag the border without painting. The cells the canvases are actually drawn in follow. +#[test] +fn grabbing_the_border_inside_the_active_document_drags_it_without_painting() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = crate::state::Tool::Brush; + // The right-hand document is active, and the grab below is inside *its* edge. + app.session.set_active(1); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let area = h.state().last_canvas_rect; + let n = h.state().session.documents().len(); + let border = borders("twoUpVertical", area, n, &[])[0].line; + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().map_or(0, |d| d.history.entries().len()); + let before = steps(&h); + // 2 pt into the right-hand (active) document, where its canvas would otherwise take the press. + let from = border.center() + egui::vec2(2.0, 0.0); + let to = from + egui::vec2(150.0, 0.0); + let ev = |h: &mut Harness<'_, PhotocraftApp>, e: egui::Event| h.input_mut().events.push(e); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + ev(&mut h, egui::Event::PointerMoved(from)); + h.run(); + ev(&mut h, button(from, true)); + h.run(); + for step in 1..=10 { + ev(&mut h, egui::Event::PointerMoved(from + (to - from) * (step as f32 / 10.0))); + h.run(); + } + ev(&mut h, button(to, false)); + h.run_steps(2); + assert_eq!(steps(&h), before, "the press dragged the border and painted nothing"); + assert_eq!(h.state().session.active_index(), Some(1), "the active document stays active"); + // And the cells the documents are drawn in follow the border, not just the stored fractions. + let shown = crate::canvas::arranged_cells(h.state(), area).expect("two documents are arranged"); + assert!((shown[0].1.max.x - to.x).abs() < 2.0, "the left cell ends at the border: {:?}, pointer at {}", shown[0].1, to.x); + assert!((shown[1].1.min.x - to.x).abs() < 2.0, "and the right one starts there: {:?}", shown[1].1); +} diff --git a/crates/ui-egui/src/menus/workspace_ui.rs b/crates/ui-egui/src/menus/workspace_ui.rs index b5a67b1800..d58f039786 100644 --- a/crates/ui-egui/src/menus/workspace_ui.rs +++ b/crates/ui-egui/src/menus/workspace_ui.rs @@ -588,6 +588,8 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); app.ui.theme = theme; + // The workspace's docks, not the Home screen (which draws none). + app.session.edit_prefs(|p| p.general.auto_show_home_screen = false); app.ui.panels.menu_bar = false; app.ui.panels.rail = false; app diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 792c30e50e..68cc7453c7 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -51,10 +51,10 @@ fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modif } #[test] -fn auto_select_is_on_by_default_with_the_group_target() { +fn auto_select_is_on_by_default_with_the_layer_target() { let o = ToolOptions::default(); assert!(o.move_auto_select, "Photoshop ships with Auto-Select checked"); - assert_eq!(o.move_target, "group", "Photoshop defaults Auto-Select to Group (#4056)"); + assert_eq!(o.move_target, "layer", "Reset Tool in Photoshop gives Auto-Select: Layer (#11380)"); // A saved tool preset that predates the option gets the default too. let mut v = serde_json::to_value(&o).unwrap(); v.as_object_mut().unwrap().remove("move_auto_select"); @@ -257,3 +257,48 @@ fn shift_click_adds_shape_layers_one_after_another() { selected.sort_by_key(|l| l.0); assert_eq!(selected, ids); } + +/// Opens B's transform controls (Show Transform Controls on) and resizes the box to twice B's +/// size from its top-left corner, so it covers (40..72)². +fn resized_box() -> (PhotocraftApp, LayerId, LayerId) { + let (mut app, a, b) = app(); + app.ui.tool_options.move_show_transform = true; + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad[2] = [t.quad[2][0] + 16.0, t.quad[2][1] + 16.0]; + t.quad[1][0] += 16.0; + t.quad[3][1] += 16.0; + (app, a, b) +} + +/// A plain click (press and release without moving) at document point `p`. +fn click(app: &mut PhotocraftApp, p: [f64; 2]) { + crate::canvas::tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::NONE); + crate::canvas::tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, egui::Modifiers::NONE); +} + +/// #8208, #11100, as measured in the reference application: a click outside a resized box on +/// another layer's pixels commits the resize as one Free Transform step and keeps the transformed +/// layer selected; the second click on that layer is the one that picks it. +#[test] +fn a_click_on_another_layer_accepts_a_resized_box_and_the_next_one_picks_it() { + let (mut app, a, b) = resized_box(); + click(&mut app, [16.0, 16.0]); + assert!(app.ui.transform.is_none(), "the click on A accepted the box"); + assert_eq!(bounds(&app, b), Rect::new(40, 40, 72, 72), "B keeps its resize"); + let last = app.session.active().unwrap().history.entries().last().map(ToString::to_string); + assert_eq!(last.as_deref(), Some("Free Transform")); + assert_eq!(active(&app), Some(b), "the accepting click leaves B selected"); + click(&mut app, [16.0, 16.0]); + assert_eq!(active(&app), Some(a), "the second click picks A"); +} + +/// #11100: a click outside the box where only the Background has pixels keeps it open, B still +/// selected. +#[test] +fn a_click_on_the_background_alone_keeps_the_resized_box_open() { + let (mut app, _, b) = resized_box(); + click(&mut app, [30.0, 30.0]); + assert!(app.ui.transform.is_some(), "the box stays open"); + assert_eq!(active(&app), Some(b)); +} diff --git a/crates/ui-egui/src/move_bar_distribute_gear_tests.rs b/crates/ui-egui/src/move_bar_distribute_gear_tests.rs new file mode 100644 index 0000000000..45ebb91969 --- /dev/null +++ b/crates/ui-egui/src/move_bar_distribute_gear_tests.rs @@ -0,0 +1,438 @@ +//! #11381: the Move tool's options bar. The two Distribute buttons, the "..." icon panel (it +//! closes on a second click, not on Escape) and the gear menu with its three check items. + +use egui::{Rect as ScreenRect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +fn paint(app: &mut PhotocraftApp, r: Rect) -> LayerId { + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(r, &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.active().unwrap().active_layer.unwrap() +} + +fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect { + let doc = &app.session.active().unwrap().doc; + photocraft_compose::bounds::content_bounds(doc.layer(id).unwrap().surface().unwrap()) +} + +fn select_all(app: &mut PhotocraftApp, ids: &[LayerId]) { + app.session.execute("layer.select", json!({"layer": ids[0].0})).unwrap(); + for id in &ids[1..] { + app.session.execute("layer.select", json!({"layer": id.0, "mode": "toggle"})).unwrap(); + } +} + +/// A 120x120 document with three layers side by side and on top of each other unevenly, the +/// Move tool current. Returns the app and the layers, bottom to top. +fn app() -> (PhotocraftApp, [LayerId; 3]) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 120, "height": 120})).unwrap(); + let a = paint(&mut app, Rect::new(0, 0, 10, 10)); + let b = paint(&mut app, Rect::new(12, 50, 32, 54)); + let c = paint(&mut app, Rect::new(70, 60, 100, 80)); + app.sync_views(); + app.ui.tool = Tool::Move; + (app, [a, b, c]) +} + +/// The Move tool's align controls and gear, as the options bar lays them out. +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(700.0, 200.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| { + ui.horizontal(|ui| { + crate::align_bar::options(app, ui); + crate::align_bar::gear(app, ui); + }); + }); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h +} + +fn rect_of(h: &Harness<'static, PhotocraftApp>, label: &str) -> ScreenRect { + h.get_by_label(label).rect() +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(4); +} + +const BAR: [&str; 10] = [ + "Align left edges", + "Align horizontal centers", + "Align right edges", + "Distribute vertically", + "Align top edges", + "Align vertical centers", + "Align bottom edges", + "Distribute horizontally", + "Align and distribute", + "Options", +]; + +#[test] +fn bar_buttons_and_tooltips_are_in_the_measured_order() { + let (app, _) = app(); + let h = harness(app); + let xs: Vec = BAR.iter().map(|l| rect_of(&h, l).left()).collect(); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "left to right: {BAR:?} at {xs:?}"); + let ys: Vec = BAR.iter().map(|l| rect_of(&h, l).center().y).collect(); + assert!(ys.iter().all(|y| (y - ys[0]).abs() < 1.0), "one row: {ys:?}"); +} + +#[test] +fn distribute_vertically_button_runs_its_command() { + let (mut app, [a, b, c]) = app(); + select_all(&mut app, &[a, b, c]); + let mut h = harness(app); + assert!(!h.get_by_label("Distribute vertically").accesskit_node().is_disabled()); + click(&mut h, "Distribute vertically"); + // The same result as Layer > Distribute > Vertically: spans 0..80, heights 34, gaps 23. + assert_eq!(bounds(h.state(), b), Rect::new(12, 33, 32, 37)); + assert_eq!(bounds(h.state(), a), Rect::new(0, 0, 10, 10)); +} + +#[test] +fn distribute_horizontally_button_runs_its_command() { + let (mut app, [a, b, c]) = app(); + select_all(&mut app, &[a, b, c]); + let mut h = harness(app); + assert!(!h.get_by_label("Distribute horizontally").accesskit_node().is_disabled()); + click(&mut h, "Distribute horizontally"); + // Spans 0..100, widths 60, gaps 20: the middle layer starts at 30. + assert_eq!(bounds(h.state(), b), Rect::new(30, 50, 50, 54)); + assert_eq!(bounds(h.state(), c), Rect::new(70, 60, 100, 80)); +} + +#[test] +fn distribute_buttons_are_greyed_with_too_few_layers_like_the_menu() { + let (mut app, [a, b, c]) = app(); + select_all(&mut app, &[a, b]); + let mut h = harness(app); + for label in ["Distribute vertically", "Distribute horizontally"] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} with two layers"); + } + for cmd in ["layer.distribute.vertically", "layer.distribute.horizontally"] { + assert!(!h.state().session.is_enabled(cmd), "{cmd} is off in the menu too"); + } + // Align still works with two layers. + assert!(!h.get_by_label("Align left edges").accesskit_node().is_disabled()); + select_all(h.state_mut(), &[a, b, c]); + h.run_steps(2); + assert!(!h.get_by_label("Distribute vertically").accesskit_node().is_disabled()); +} + +#[test] +fn ellipsis_panel_holds_align_and_distribute_and_closes_on_a_second_click_not_escape() { + let (app, _) = app(); + let mut h = harness(app); + assert!(h.query_by_label("Align: Left Edges").is_none(), "closed at first"); + click(&mut h, "Align and distribute"); + assert!(crate::align_bar::panel_is_open(&h.ctx)); + + let align = ["Left Edges", "Horizontal Centers", "Right Edges", "Top Edges", "Vertical Centers", "Bottom Edges"]; + let distribute = ["Top Edges", "Vertical Centers", "Bottom Edges", "Left Edges", "Horizontal Centers", "Right Edges"]; + let tips: Vec = align.iter().map(|l| format!("Align: {l}")).chain(distribute.iter().map(|l| format!("Distribute: {l}"))).collect(); + let rects: Vec = tips.iter().map(|t| rect_of(&h, t)).collect(); + // Align row above the Distribute row; each row runs left to right. + assert!(rects[..6].windows(2).all(|w| w[0].left() < w[1].left()), "align row: {rects:?}"); + assert!(rects[6..].windows(2).all(|w| w[0].left() < w[1].left()), "distribute row: {rects:?}"); + assert!(rects[5].bottom() <= rects[6].top() + 0.5, "the Distribute group is below the Align group"); + // The panel hangs under the "..." button. + assert!(rects[0].top() > rect_of(&h, "Align and distribute").bottom() - 0.5); + + // Escape leaves it open. + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(crate::align_bar::panel_is_open(&h.ctx)); + assert!(h.query_by_label("Align: Left Edges").is_some()); + + // A second click on the button closes it. + click(&mut h, "Align and distribute"); + assert!(!crate::align_bar::panel_is_open(&h.ctx)); + assert!(h.query_by_label("Align: Left Edges").is_none()); +} + +#[test] +fn panel_icons_run_their_commands() { + let (mut app, [a, b, c]) = app(); + select_all(&mut app, &[a, b, c]); + let mut h = harness(app); + click(&mut h, "Align and distribute"); + click(&mut h, "Distribute: Left Edges"); + // Left edges 0, 12, 70 -> the middle one moves to 35. + assert_eq!(bounds(h.state(), b), Rect::new(35, 50, 55, 54)); +} + +#[test] +fn gear_menu_has_three_check_items_on_by_default_that_switch_the_options() { + let (app, _) = app(); + let mut h = harness(app); + click(&mut h, "Options"); + for label in ["Show layer bounds on hover", "Expand layer group on click", "Show hover bounds from Layers"] { + assert!(h.query_by_label(label).is_some(), "{label} is in the gear menu"); + } + click(&mut h, "Show layer bounds on hover"); + assert!(!h.state().ui.tool_options.move_show_hover_bounds); + click(&mut h, "Expand layer group on click"); + assert!(!h.state().ui.tool_options.move_expand_group_on_click); + click(&mut h, "Show hover bounds from Layers"); + assert!(!h.state().ui.tool_options.move_show_layers_hover_bounds); +} + +#[test] +fn gear_toggles_default_on_are_saved_and_reset() { + let o = ToolOptions::default(); + assert!(o.move_show_hover_bounds && o.move_expand_group_on_click && o.move_show_layers_hover_bounds); + + // Saved with the Move tool options: they are in the serde form that ui.inspect and ui.set use, + // and an older save without them reads as on. + let mut v = serde_json::to_value(&o).unwrap(); + for key in ["move_show_hover_bounds", "move_expand_group_on_click", "move_show_layers_hover_bounds"] { + assert_eq!(v[key], json!(true), "{key}"); + v[key] = json!(false); + } + let off: ToolOptions = serde_json::from_value(v.clone()).unwrap(); + assert!(!off.move_show_hover_bounds && !off.move_expand_group_on_click && !off.move_show_layers_hover_bounds); + for key in ["move_show_hover_bounds", "move_expand_group_on_click", "move_show_layers_hover_bounds"] { + v.as_object_mut().unwrap().remove(key); + } + let old: ToolOptions = serde_json::from_value(v).unwrap(); + assert!(old.move_show_hover_bounds && old.move_expand_group_on_click && old.move_show_layers_hover_bounds); + + // Tool > Reset Tool puts them back. + let (mut app, _) = app(); + app.ui.tool_options = off; + app.ui.tool_options.feather = 12.0; + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "tool.reset", json!({})).unwrap(); + let o = &app.ui.tool_options; + assert!(o.move_show_hover_bounds && o.move_expand_group_on_click && o.move_show_layers_hover_bounds); + assert_eq!(o.feather, 12.0, "another tool's option is left alone"); +} + +#[test] +fn show_layer_bounds_on_hover_switches_the_canvas_outline() { + let (mut app, [_, b, _]) = app(); + let b_bounds = bounds(&app, b); + assert_eq!(crate::move_hover::target(&mut app, Some([14.0, 52.0]), false, false), Some(b_bounds)); + app.ui.tool_options.move_show_hover_bounds = false; + assert_eq!(crate::move_hover::target(&mut app, Some([14.0, 52.0]), false, false), None); + assert_eq!(app.canvas_hover_layer, None); + app.ui.tool_options.move_show_hover_bounds = true; + assert_eq!(crate::move_hover::target(&mut app, Some([14.0, 52.0]), false, false), Some(b_bounds)); +} + +#[test] +fn show_hover_bounds_from_layers_switches_the_layers_panel_outline() { + let (mut app, [a, _, _]) = app(); + app.hover_layer = Some(a); + let a_bounds = bounds(&app, a); + assert_eq!(crate::move_hover::active_outline(&mut app, None, false, false), Some(a_bounds)); + app.ui.tool_options.move_show_layers_hover_bounds = false; + assert_eq!(crate::move_hover::active_outline(&mut app, None, false, false), None); + // The canvas outline is a separate switch. + assert!(crate::move_hover::active_outline(&mut app, Some([14.0, 52.0]), false, false).is_some()); + // Preferences > Show Highlight on Rollover stays the master switch. + app.ui.tool_options.move_show_layers_hover_bounds = true; + app.session.edit_prefs(|p| p.tools.show_highlight_on_rollover = false); + assert_eq!(crate::move_hover::active_outline(&mut app, None, false, false), None); +} + +fn expanded(app: &PhotocraftApp, group: LayerId) -> bool { + match &app.session.active().unwrap().doc.layer(group).unwrap().content { + photocraft_doc::LayerContent::Group(g) => g.expanded, + _ => panic!("not a group"), + } +} + +#[test] +fn expand_layer_group_on_click_switches_the_opening_of_closed_parent_groups() { + for on in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let inner = paint(&mut app, Rect::new(8, 8, 24, 24)); + app.session.execute("layer.groupLayers", json!({})).unwrap(); + let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap(); + app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap(); + app.session.execute("layer.select", json!({"layer": group.0})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_expand_group_on_click = on; + let ctx = egui::Context::default(); + // The first frame only records the active layer; then a canvas click picks the inner one. + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), None); + // The Move tool picks the layer under the pointer (Auto-Select targets layers, not groups). + app.ui.tool_options.move_target = "layer".into(); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 12.0, y: 12.0, pressure: 1.0 }, egui::Modifiers::default()); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Up { x: 12.0, y: 12.0 }, egui::Modifiers::default()); + // The row is still revealed (scrolled to) either way. + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert_eq!(expanded(&app, group), on, "Expand layer group on click = {on}"); + } +} + +/// A 64x64 document: `inner` painted at (8..24)^2 inside a closed group, the Background-less +/// group selected, `tool` current and the gear option set to `on`. The reveal has seen the frame. +fn closed_group_doc(tool: Tool, on: bool) -> (PhotocraftApp, LayerId, LayerId, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let inner = paint(&mut app, Rect::new(8, 8, 24, 24)); + app.session.execute("layer.groupLayers", json!({})).unwrap(); + let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap(); + app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap(); + app.session.execute("layer.select", json!({"layer": group.0})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app.ui.tool_options.move_expand_group_on_click = on; + app.ui.tool_options.move_target = "layer".into(); + let ctx = egui::Context::default(); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), None); + (app, inner, group, ctx) +} + +/// A press and release at (12, 12) with Ctrl/Cmd held (a temporary Move from another tool). +fn ctrl_click(app: &mut PhotocraftApp) { + use crate::canvas::{ToolEvent, tool_event}; + let mods = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + tool_event(app, ToolEvent::Down { x: 12.0, y: 12.0, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: 12.0, y: 12.0 }, mods); +} + +#[test] +fn expand_layer_group_on_click_off_keeps_the_group_closed_for_a_ctrl_click_from_another_tool() { + // Auto-Select off, so the Ctrl-click is the Move way of picking from the Brush. + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.ui.tool_options.move_auto_select = false; + ctrl_click(&mut app); + assert_eq!(app.session.active().unwrap().active_layer, Some(inner), "the Ctrl-click picked the layer"); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner), "the row still scrolls into view"); + assert!(!expanded(&app, group), "the group stays closed"); +} + +#[test] +fn expand_layer_group_on_click_on_opens_the_group_for_a_ctrl_click_from_another_tool() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, true); + app.ui.tool_options.move_auto_select = false; + ctrl_click(&mut app); + assert_eq!(app.session.active().unwrap().active_layer, Some(inner)); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(expanded(&app, group), "the group opens"); +} + +#[test] +fn expand_layer_group_on_click_off_keeps_the_group_closed_for_a_move_tool_click() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Move, false); + use crate::canvas::{ToolEvent, tool_event}; + tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 12.0, pressure: 1.0 }, egui::Modifiers::default()); + tool_event(&mut app, ToolEvent::Up { x: 12.0, y: 12.0 }, egui::Modifiers::default()); + assert_eq!(app.session.active().unwrap().active_layer, Some(inner)); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(!expanded(&app, group)); +} + +#[test] +fn expand_layer_group_on_click_off_still_opens_the_group_for_other_active_layer_changes() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.run("layer.select", json!({"layer": inner.0})).unwrap(); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(expanded(&app, group), "a command that changes the active layer reveals it"); +} + +#[test] +fn expand_layer_group_on_click_governs_a_ctrl_click_pick_of_a_position_locked_layer() { + for on in [false, true] { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, on); + app.ui.tool_options.move_auto_select = false; + app.session.execute("layer.setProps", json!({"layer": inner.0, "locks": {"position": true}})).unwrap(); + ctrl_click(&mut app); + assert_eq!(app.session.active().unwrap().active_layer, Some(inner), "the Ctrl-click picked the locked layer (on = {on})"); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert_eq!(expanded(&app, group), on, "Expand layer group on click = {on}"); + } +} + +#[test] +fn a_pick_marker_the_hidden_layers_panel_never_read_does_not_hide_a_later_command_change() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.ui.tool_options.move_auto_select = false; + // A Move-way pick of the inner layer while the Layers panel draws nothing (no `track`). + ctrl_click(&mut app); + assert_eq!(app.session.active().unwrap().active_layer, Some(inner)); + // Frames pass without the panel, then commands change the layer twice and land on it again. + app.frame += 5; + app.run("layer.select", json!({"layer": group.0})).unwrap(); + app.run("layer.select", json!({"layer": inner.0})).unwrap(); + // The panel is shown again: the change was a command's, so the group opens. + app.frame += 1; + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(expanded(&app, group), "a command change after a stale pick expands the group"); +} + +#[test] +fn a_pick_marker_survives_any_number_of_frames_while_the_layers_panel_is_hidden() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.ui.tool_options.move_auto_select = false; + ctrl_click(&mut app); + app.frame += 5; + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(!expanded(&app, group), "the pick is still the latest change of the active layer"); +} + +#[test] +fn commands_after_a_pick_in_the_same_frame_or_the_next_make_it_a_command_change() { + for gap in [0, 1] { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.ui.tool_options.move_auto_select = false; + ctrl_click(&mut app); + app.frame += gap; + app.run("layer.select", json!({"layer": group.0})).unwrap(); + app.run("layer.select", json!({"layer": inner.0})).unwrap(); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(expanded(&app, group), "selects {gap} frame(s) after the pick reveal the layer"); + } +} + +#[test] +fn a_pick_marker_is_read_once_and_governs_the_next_change_only() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.ui.tool_options.move_auto_select = false; + ctrl_click(&mut app); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(!expanded(&app, group)); + app.run("layer.select", json!({"layer": group.0})).unwrap(); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(group)); + app.run("layer.select", json!({"layer": inner.0})).unwrap(); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(expanded(&app, group), "a later command change opens the group"); +} + +#[test] +fn direct_selection_calls_after_a_pick_make_it_a_command_change() { + let (mut app, inner, group, ctx) = closed_group_doc(Tool::Brush, false); + app.ui.tool_options.move_auto_select = false; + ctrl_click(&mut app); + app.session.select_layer(group).unwrap(); + app.session.select_layer(inner).unwrap(); + assert_eq!(crate::layer_reveal::track(&mut app, &ctx), Some(inner)); + assert!(expanded(&app, group), "a direct selection away and back is not a pick"); +} diff --git a/crates/ui-egui/src/move_history_names_tests.rs b/crates/ui-egui/src/move_history_names_tests.rs new file mode 100644 index 0000000000..520414bef3 --- /dev/null +++ b/crates/ui-egui/src/move_history_names_tests.rs @@ -0,0 +1,173 @@ +//! #11383: the Move tool's History steps have the reference application's names: a drag and an +//! Alt+drag copy of a layer are "Move", arrow-key nudges are "Nudge" (one per press, or one per +//! run with Bundle Arrow-Key Nudges on), an Alt+drag of selected pixels is "Duplicate". An +//! Alt+arrow nudge keeps "Duplicate + Move" (not measured). + +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::state::Tool; + +/// A 64x64 document with one painted layer (8..24)^2, selected, and the Move tool active. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = false; + // Snapping (to the document centre with a 1:1 test view) would shift the expected offsets. + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app +} + +fn bounds(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> Rect { + app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds() +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + let mid = [from[0] + by[0] / 2.0, from[1] + by[1] / 2.0]; + crate::canvas::tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, mods); + let end = [from[0] + by[0], from[1] + by[1]]; + crate::canvas::tool_event(app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, mods); + crate::canvas::tool_event(app, ToolEvent::Up { x: end[0], y: end[1] }, mods); +} + +fn press(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers, time: f64) { + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.time = time; + i.modifiers = mods; + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }); + }); + assert!(crate::move_mods::arrow_keys(app, &ctx)); +} + +fn history(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn newest(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().pop() +} + +/// The five presses of the reference measurement: ->, Shift+->, down, Shift+down, <-. +fn five_presses(app: &mut PhotocraftApp) { + let keys = [ + (egui::Key::ArrowRight, egui::Modifiers::NONE), + (egui::Key::ArrowRight, egui::Modifiers::SHIFT), + (egui::Key::ArrowDown, egui::Modifiers::NONE), + (egui::Key::ArrowDown, egui::Modifiers::SHIFT), + (egui::Key::ArrowLeft, egui::Modifiers::NONE), + ]; + for (i, (key, mods)) in keys.into_iter().enumerate() { + press(app, key, mods, 1.0 + 0.1 * i as f64); + } +} + +#[test] +fn a_drag_of_a_layer_is_one_move_step() { + let mut app = app(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + drag(&mut app, [16.0, 16.0], [10.0, 4.0], egui::Modifiers::NONE); + assert_eq!(bounds(&app, id), Rect::new(18, 12, 34, 28)); + assert_eq!(history(&app), h0 + 1); + assert_eq!(newest(&app).as_deref(), Some("Move")); +} + +#[test] +fn an_alt_drag_copy_of_a_layer_is_one_move_step() { + let mut app = app(); + let orig = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + drag(&mut app, [16.0, 16.0], [20.0, 10.0], egui::Modifiers::ALT); + let copy = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(copy, orig, "the copy is selected"); + assert_eq!(bounds(&app, copy), Rect::new(28, 18, 44, 34), "at the offset"); + assert_eq!(bounds(&app, orig), Rect::new(8, 8, 24, 24), "the original stays"); + assert_eq!(history(&app), h0 + 1, "the copy and the move are one step"); + assert_eq!(newest(&app).as_deref(), Some("Move")); +} + +#[test] +fn five_arrow_presses_with_bundling_off_are_five_nudge_steps() { + let mut app = app(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + five_presses(&mut app); + assert_eq!(bounds(&app, id), Rect::new(8 + 1 + 10 - 1, 8 + 1 + 10, 24 + 10, 24 + 11)); + assert_eq!(history(&app), h0 + 5); + let entries = app.session.active().unwrap().history.entries(); + assert_eq!(&entries[entries.len() - 5..], ["Nudge"; 5]); +} + +#[test] +fn five_arrow_presses_with_bundling_on_are_one_nudge_step() { + let mut app = app(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = true); + let h0 = history(&app); + five_presses(&mut app); + assert_eq!(history(&app), h0 + 1); + assert_eq!(newest(&app).as_deref(), Some("Nudge")); +} + +#[test] +fn an_alt_drag_of_selected_pixels_is_one_duplicate_step() { + let mut app = app(); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap(); + let h0 = history(&app); + drag(&mut app, [16.0, 16.0], [20.0, 10.0], egui::Modifiers::ALT); + // The copied piece floats until something drops it. + app.session.execute("select.drop", json!({})).unwrap(); + assert_eq!(history(&app), h0 + 1, "the copy and its move are one step"); + assert_eq!(newest(&app).as_deref(), Some("Duplicate")); +} + +#[test] +fn a_blank_or_overlong_step_name_drops_as_move() { + let mut app = app(); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap(); + for name in [" ", &"x".repeat(65)] { + app.session.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "stepName": name})).unwrap(); + app.session.execute("select.drop", json!({})).unwrap(); + assert_eq!(newest(&app).as_deref(), Some("Move"), "{name:?}"); + } + app.session.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "stepName": "Duplicate"})).unwrap(); + app.session.execute("select.drop", json!({})).unwrap(); + assert_eq!(newest(&app).as_deref(), Some("Duplicate")); +} + +#[test] +fn an_alt_arrow_nudge_keeps_duplicate_plus_move() { + let mut app = app(); + let h0 = history(&app); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::ALT, 1.0); + assert_eq!(history(&app), h0 + 1); + assert_eq!(newest(&app).as_deref(), Some("Duplicate + Move")); +} + +/// A zero offset moves nothing: no step is added, and the step on top (here the paint) keeps its +/// name. +#[test] +fn a_zero_nudge_renames_no_earlier_step() { + let mut app = app(); + for bundle in [false, true] { + app.session.edit_prefs(|p| p.tools.bundle_nudges = bundle); + let before: Vec = app.session.active().unwrap().history.entries().to_vec(); + crate::move_mods::nudge(&mut app, 0.0, 0.0, false); + let after: Vec = app.session.active().unwrap().history.entries().to_vec(); + assert_eq!(after, before, "bundle {bundle}"); + } +} diff --git a/crates/ui-egui/src/move_shift_diagonal_tests.rs b/crates/ui-egui/src/move_shift_diagonal_tests.rs new file mode 100644 index 0000000000..0929069ede --- /dev/null +++ b/crates/ui-egui/src/move_shift_diagonal_tests.rs @@ -0,0 +1,112 @@ +//! Shift+drag of a layer with the Move tool locks to the nearest of the eight directions (#11382). +//! +//! Measured in the reference application (layer Red, 300x200 px, Auto-Select on, target Layer): +//! Shift+drag +100,+30 moves +100,0 and Shift+drag +80,+80 moves +80,+80, each as one "Move" +//! step. With Alt as well, the copy lands at the same offset. + +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::state::Tool; + +/// A 400x300 document with one painted layer covering (20, 20, 120, 80), selected. The drag +/// starts inside it, so Auto-Select picks the same layer. +fn app() -> (PhotocraftApp, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 20, 120, 80), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.tool = Tool::Move; + // Snapping would shift the expected offsets. + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + (app, id) +} + +fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect { + app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds() +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + let mid = [from[0] + by[0] / 2.0, from[1] + by[1] / 2.0]; + crate::canvas::tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, mods); + let end = [from[0] + by[0], from[1] + by[1]]; + crate::canvas::tool_event(app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, mods); + crate::canvas::tool_event(app, ToolEvent::Up { x: end[0], y: end[1] }, mods); +} + +const START: Rect = Rect::new(20, 20, 120, 80); +const FROM: [f64; 2] = [60.0, 50.0]; + +fn shifted(dx: i32, dy: i32) -> Rect { + Rect::new(START.x0 + dx, START.y0 + dy, START.x1 + dx, START.y1 + dy) +} + +/// Runs a plain Shift drag and checks the layer moved by `expect`. +fn shift_drag(by: [f64; 2], expect: [i32; 2]) { + let (mut app, id) = app(); + assert_eq!(bounds(&app, id), START); + drag(&mut app, FROM, by, egui::Modifiers::SHIFT); + assert_eq!(bounds(&app, id), shifted(expect[0], expect[1])); + assert_eq!(app.session.active().unwrap().active_layer, Some(id)); +} + +/// Runs an Alt+Shift drag and checks the copy landed at `expect` and is selected. +fn alt_shift_drag(by: [f64; 2], expect: [i32; 2]) { + let (mut app, id) = app(); + assert_eq!(bounds(&app, id), START); + let n0 = app.session.active().unwrap().doc.layers.len(); + drag(&mut app, FROM, by, egui::Modifiers::SHIFT | egui::Modifiers::ALT); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), n0 + 1, "one copy"); + let copy = st.active_layer.unwrap(); + assert_ne!(copy, id, "the copy is selected"); + assert_eq!(bounds(&app, copy), shifted(expect[0], expect[1])); + assert_eq!(bounds(&app, id), START, "the original stays"); +} + +#[test] +fn shift_drag_mostly_horizontal_locks_to_the_axis() { + // Reference: Shift+drag +100,+30 moves +100,0. + shift_drag([100.0, 30.0], [100, 0]); +} + +#[test] +fn shift_drag_on_the_diagonal_moves_along_it() { + // Reference: Shift+drag +80,+80 moves +80,+80 (it was 0,+80 before). + shift_drag([80.0, 80.0], [80, 80]); +} + +#[test] +fn shift_drag_near_the_axis_stays_on_the_axis() { + // Same rule as the +100,+30 case: a small offset off the axis does not leave it. + shift_drag([100.0, 5.0], [100, 0]); +} + +#[test] +fn alt_shift_drag_mostly_horizontal_copies_along_the_axis() { + // Reference rule for +100,+30, applied to the duplicate and move. + alt_shift_drag([100.0, 30.0], [100, 0]); +} + +#[test] +fn alt_shift_drag_on_the_diagonal_copies_along_it() { + // Reference: the copy lands at +80,+80. + alt_shift_drag([80.0, 80.0], [80, 80]); +} + +#[test] +fn alt_shift_drag_near_the_axis_copies_along_the_axis() { + alt_shift_drag([100.0, 5.0], [100, 0]); +} diff --git a/crates/ui-egui/src/move_target_default_tests.rs b/crates/ui-egui/src/move_target_default_tests.rs new file mode 100644 index 0000000000..b22b649ecd --- /dev/null +++ b/crates/ui-egui/src/move_target_default_tests.rs @@ -0,0 +1,75 @@ +//! The Move tool's Auto-Select target (#11380): fresh options and Tool › Reset Tool give Layer, +//! as the reference application's Reset Tool does; the dropdown lists Group, then Layer; a saved +//! choice is kept. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app +} + +#[test] +fn fresh_options_and_reset_tool_give_auto_select_on_with_the_layer_target() { + let o = ToolOptions::default(); + assert!(o.move_auto_select); + assert_eq!(o.move_target, "layer"); + let mut app = app(); + app.ui.tool_options.move_target = "group".into(); + app.ui.tool_options.move_auto_select = false; + crate::menus::invoke(&mut app, &egui::Context::default(), crate::tools::tool_reset::RESET, json!({})).unwrap(); + assert!(app.ui.tool_options.move_auto_select); + assert_eq!(app.ui.tool_options.move_target, "layer", "Reset Tool gives Layer"); +} + +#[test] +fn a_saved_choice_is_kept_and_only_a_missing_key_gets_layer() { + let mut v = serde_json::to_value(ToolOptions::default()).unwrap(); + v["move_target"] = json!("group"); + let back: ToolOptions = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(back.move_target, "group", "a saved Group stays Group"); + v.as_object_mut().unwrap().remove("move_target"); + let back: ToolOptions = serde_json::from_value(v).unwrap(); + assert_eq!(back.move_target, "layer"); +} + +#[test] +fn the_target_dropdown_lists_group_then_layer() { + let mut h = Harness::builder().with_size(vec2(1600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let combo = h + .get_all_by_role(Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some("Layer") || n.accesskit_node().label().as_deref() == Some("Layer")) + .expect("the Auto-Select target dropdown shows Layer"); + let below = combo.rect().max.y; + combo.click(); + h.run_steps(4); + let item_y = |label: &str| { + h.get_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| r.min.y >= below - 0.5) + .map(|r| r.min.y) + .next() + .unwrap_or_else(|| panic!("a {label} entry in the open list")) + }; + assert!(item_y("Group") < item_y("Layer"), "Group is listed above Layer"); +} diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 4b3ae45cac..32c4d22999 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -35,6 +35,10 @@ pub struct Notice { /// and is reset while the pointer rests on the stack (issue #2022). `None` until first shown. #[serde(default, skip_serializing_if = "Option::is_none")] pub shown_at: Option, + /// Stays until dismissed (or until whatever it warns of has passed), whatever the auto-hide + /// setting: for a warning the user must not miss, such as nearly full memory. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub sticky: bool, } impl Notice { @@ -48,14 +52,23 @@ impl Notice { /// Show a notice (newest last); returns its id. pub fn post(app: &mut PhotocraftApp, title: impl Into, lines: Vec, error: bool, dismiss_pref: Option<&str>) -> u64 { let id = app.ui.alloc_id(); - app.ui.notices.push(Notice { id, title: title.into(), lines, error, dismiss_pref: dismiss_pref.map(str::to_owned), args: Vec::new(), shown_at: None }); + app.ui.notices.push(Notice { + id, + title: title.into(), + lines, + error, + dismiss_pref: dismiss_pref.map(str::to_owned), + args: Vec::new(), + shown_at: None, + sticky: false, + }); cap_notices(app); id } fn cap_notices(app: &mut PhotocraftApp) { while app.ui.notices.len() > MAX_NOTICES { - let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none()).unwrap_or(0); + let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none() && !notice.sticky).unwrap_or(0); app.ui.notices.remove(oldest_temporary); } } @@ -120,7 +133,11 @@ pub fn clipboard_unreadable(app: &mut PhotocraftApp) { /// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and /// which edits will do it again (#1015). pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Option<&crate::render_path::CpuReason>) { - if ms.is_nan() || ms < SLOW_CPU_REFRESH_MS || app.ui.slow_refresh_noticed.contains(&doc) { + if matches!(reason, Some(crate::render_path::CpuReason::CompositorBuilding)) + || ms.is_nan() + || ms < SLOW_CPU_REFRESH_MS + || app.ui.slow_refresh_noticed.contains(&doc) + { return; } app.ui.slow_refresh_noticed.push(doc); @@ -167,6 +184,18 @@ pub fn io_warnings(app: &mut PhotocraftApp, what: &str, warnings: &[String]) { post(app, title, warnings.to_vec(), false, None); } +/// Report the files a folder run (Batch, Image Processor, …) could not process, one line each: +/// the status bar shows the first as an error, and a notice titled with how many failed lists +/// them all until dismissed (#10682). +pub fn file_failures(app: &mut PhotocraftApp, what: &str, failures: &[String]) { + let Some(first) = failures.first() else { return }; + let n = failures.len(); + let title = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n as u64, "{what}: {n} file failed", "{what}: {n} files failed"), &[("what", what)]); + app.ui.status = if n == 1 { format!("{what}: {first}") } else { format!("{title}: {first} …") }; + app.ui.status_error = true; + post(app, title, failures.to_vec(), true, None); +} + /// Report a failed file operation: the status bar shows it as an error and a notice keeps it on /// screen until dismissed. pub fn error(app: &mut PhotocraftApp, message: String) { @@ -186,7 +215,7 @@ fn expire(app: &mut PhotocraftApp, now: f64, hovered: bool, just_left: bool, aut } let mut expired = Vec::new(); let mut next = f64::INFINITY; - for n in &mut app.ui.notices { + for n in app.ui.notices.iter_mut().filter(|n| !n.sticky) { if n.shown_at.is_none() { n.shown_at = Some(now); } @@ -402,6 +431,14 @@ mod tests { assert!(app.ui.notices.is_empty()); } + #[test] + fn preparing_compositor_does_not_warn_about_large_documents() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + slow_cpu_refresh(&mut app, 1, 60_000.0, Some(&crate::render_path::CpuReason::CompositorBuilding)); + assert!(app.ui.notices.is_empty()); + assert!(app.ui.slow_refresh_noticed.is_empty()); + } + #[test] fn a_slow_cpu_refresh_is_explained_once_per_document() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/nudge_bundle_tests.rs b/crates/ui-egui/src/nudge_bundle_tests.rs index d18bbddafa..1e1f0542ba 100644 --- a/crates/ui-egui/src/nudge_bundle_tests.rs +++ b/crates/ui-egui/src/nudge_bundle_tests.rs @@ -48,8 +48,8 @@ fn tapping_an_arrow_key_ten_times_is_one_history_step() { assert_eq!(bounds(&h).x0, start.x0 + 10); assert_eq!(steps(&h), n0 + 1, "ten taps, one state"); let entries = h.state().session.active().unwrap().history.entries(); - assert_eq!(entries.last().map(String::as_str), Some("Move")); - assert_eq!(entries.iter().filter(|e| *e == "Move").count(), 1); + assert_eq!(entries.last().map(String::as_str), Some("Nudge")); + assert_eq!(entries.iter().filter(|e| *e == "Nudge").count(), 1); h.state_mut().session.undo(); assert_eq!(bounds(&h), start, "one Undo takes all ten pixels back"); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a5266684c..0d40e2280b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -33,7 +33,7 @@ mod dock; pub mod histogram_panel; mod history; pub mod history_menu_ui; -mod info; +pub(crate) mod info; mod layer_drag; pub mod layer_filter_ui; pub mod layer_menu_ui; @@ -44,6 +44,7 @@ pub mod layer_row_ui; pub(super) mod layer_transfer; pub mod layer_tree_ui; mod layers; +mod layers_empty_menu; pub mod layers_panel_ui; pub(super) mod mask_props_ui; pub mod mask_thumbs_ui; @@ -60,7 +61,7 @@ pub mod swatches_ui; pub(super) mod timeline_ui; pub(crate) mod title_bar; pub(super) mod titlebar; -mod toolbar; +pub(crate) mod toolbar; pub mod type_panels_ui; pub(super) mod variables_ui; @@ -103,6 +104,7 @@ use options_bar::options_bar_height; pub(crate) use options_bar::{FIXED_SIZE_MAX_PX, opt_label}; pub use panel_state::{migrate_panel_state, panel_state_id, remember_panel_state}; pub use properties::properties_window; +pub(crate) use properties::selection_mode_buttons; #[cfg(test)] use properties::{adjustments_grid, properties_body}; pub use status_bar::{status_bar, status_bar_heights}; @@ -110,7 +112,10 @@ pub use status_bar::{status_bar, status_bar_heights}; use title_bar::title_x; pub use title_bar::{sync_window_title, system_title_bar, title_bar, window_title}; #[cfg(test)] -use toolbar::{STUDIO_CHIP, TOOL_SECTIONS, chips_height, color_chips, tool_flyout_drag_open, tool_flyout_release, toolbar_icon_size, visible_sections}; +use toolbar::{ + STUDIO_CHIP, TOOL_SECTIONS, chips_height, color_chips, next_slot_tool, slot_tool, tool_flyout_drag_open, tool_flyout_release, toolbar_icon_size, + visible_sections, +}; pub use toolbar::{paint_toolbar_flyout_indicator, toolbar}; fn c32(c: [f32; 4]) -> Color32 { @@ -260,6 +265,13 @@ mod title_tests { } } +/// Whether blend mode `m` starts one of the groups Photoshop's blend mode lists draw a line above: +/// Normal, Dissolve (and Pass Through, Clear) | darken | lighten | contrast | difference | colour +/// (#5532). The first mode of a list never gets a line. +pub(crate) fn starts_blend_group(m: BlendMode) -> bool { + matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) +} + fn label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); ui.label(RichText::new(s).color(t.text_dim)); @@ -820,7 +832,7 @@ mod snapshot_row_tests { let p = trash(&h); click(&mut h, p); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 1, "the trash deleted the snapshot (the first snapshot stays)"); + assert_eq!(st.snapshots.len(), 1, "the trash deleted the snapshot, leaving the first one"); assert_eq!(st.history.entries().len(), steps, "and left the states alone"); // An edit after the click deselects the snapshot: the trash deletes the state. @@ -834,7 +846,7 @@ mod snapshot_row_tests { let p = trash(&h); click(&mut h, p); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 2, "the snapshot is kept"); + assert_eq!(st.snapshots.len(), 2, "the snapshot is kept, after the first one"); assert_eq!(st.history.entries().len(), steps - 1, "the current state is deleted"); } @@ -847,7 +859,7 @@ mod snapshot_row_tests { let to = trash(&h); drag(&mut h, egui::pos2(row.x, row.y), to); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 1, "the dragged snapshot is deleted (the first snapshot stays)"); + assert_eq!(st.snapshots.len(), 1, "the dragged snapshot is deleted, leaving the first one"); assert_eq!(st.history.entries().len(), steps, "no state is"); // The second-to-last state: it and the state after it go, as with Delete. @@ -860,7 +872,7 @@ mod snapshot_row_tests { drag(&mut h, egui::pos2(row.x, row.y), to); let st = h.state().session.active().unwrap(); assert_eq!(st.history.entries().len(), 1, "the dragged state and the ones after it are deleted"); - assert_eq!(st.snapshots.len(), 2, "the snapshot is kept"); + assert_eq!(st.snapshots.len(), 2, "the snapshot is kept, after the first one"); } /// With Allow Non-Linear History and only the oldest state current, a click on the trash has @@ -999,6 +1011,102 @@ mod snapshot_row_tests { assert!(h.state().ui.dialogs.is_empty()); } + /// History Options › Show New Snapshot Dialog by Default (#2966): a plain click on the camera + /// opens New Snapshot as ⌥-click does, and ⌥-click still opens it (Photoshop's button always + /// prompts with the option on). Off, a plain click takes the snapshot at once. + #[test] + fn show_new_snapshot_dialog_by_default_makes_a_click_open_the_dialog() { + for (dialog_by_default, modifiers) in [(true, egui::Modifiers::NONE), (true, egui::Modifiers::ALT), (false, egui::Modifiers::NONE)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("history.options", json!({"showNewSnapshotDialogByDefault": dialog_by_default})).unwrap(); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + history(app, ui); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(4); + let mut buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.sort_by(|a, b| b.center().x.total_cmp(&a.center().x)); + let camera = buttons[1].center(); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(camera)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: camera, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); + let snaps = h.state().session.active().unwrap().snapshots.len(); + let dialog = h.state().ui.dialogs.last().is_some_and(|d| crate::snapshot_dialog::owns(&d.fields)); + if dialog_by_default { + assert!(dialog, "{modifiers:?}: the dialog opens"); + assert_eq!(snaps, 1, "{modifiers:?}: no snapshot until OK"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().snapshots.last().unwrap().name, "Snapshot 1"); + } else { + assert!(!dialog, "off: no dialog"); + assert_eq!(snaps, 2, "off: the click takes the snapshot"); + } + } + } + + /// History Options › Automatically Create First Snapshot: on, the panel's top row is a + /// snapshot named after the document; off, there is none above the opening state. + #[test] + fn the_first_snapshot_row_follows_the_option() { + for first in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("history.options", json!({"automaticallyCreateFirstSnapshot": first})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let name = app.session.active().unwrap().doc.name.clone(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + assert_eq!(h.query_by_label(&name).is_some(), first, "a snapshot row named after the document: {first}"); + } + } + + /// Snapshot thumbnails belong to the snapshot's document (#6087): closing a document drops + /// them, and a document reopened with the same id (a `.pcraft` keeps it) gets fresh ones + /// rather than the closed document's, even before the views are synced. + #[test] + fn snapshot_thumbnails_follow_their_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let doc = (*app.session.active().unwrap().doc).clone(); + let doc_id = doc.id; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(2); + let thumb = |h: &Harness<'static, PhotocraftApp>| h.state().snapshot_thumbs.get(&(doc_id, 0)).map(|(_, t)| t.id()); + let first = thumb(&h).expect("the first snapshot's thumbnail"); + // Closed: its thumbnails go with it. + h.state_mut().session.close(0); + h.state_mut().sync_views(); + assert!(h.state().snapshot_thumbs.keys().all(|(d, _)| *d != doc_id), "closing drops them"); + h.state_mut().session.add_document(doc.clone(), None); + h.run_steps(2); + let reopened = thumb(&h).expect("a thumbnail for the reopened document"); + assert_ne!(reopened, first); + // Closed and reopened between two frames, with no sync in between: still a fresh one. + h.state_mut().session.close(0); + h.state_mut().session.add_document(doc, None); + assert_eq!(h.state().session.active().unwrap().doc.id, doc_id, "same id"); + h.run_steps(2); + assert_ne!(thumb(&h).unwrap(), reopened); + } + /// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not /// carry the edit over to the other document's snapshot with the same id. #[test] @@ -1523,7 +1631,10 @@ mod embedding_tests { fn whole_app(rail: bool, menu_bar: bool) -> egui_kittest::Harness<'static, PhotocraftApp> { let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut session = photocraft_engine::Session::new(); + // An empty workspace with its docks, not the Home screen. + session.edit_prefs(|p| p.general.auto_show_home_screen = false); + let mut app = PhotocraftApp::new(session, crate::Services::default()); app.ui.panels.rail = rail; app.ui.panels.menu_bar = menu_bar; app diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index c627b52a2e..73675c83f6 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -74,6 +74,22 @@ pub fn selected_action(app: &PhotocraftApp) -> Option<&Action> { idx.and_then(|i| list.get(i)).or_else(|| selected_set_actions(app).first().and_then(|i| list.get(*i))).or_else(|| list.first()) } +/// The Set and Action dropdowns of the Batch and Create Droplet dialogs, as in the reference +/// application: each action name once and each set once, in list order. A loaded library can hold +/// one name in two sets, so the dialog sends the set too and the command picks that set's action. +pub fn action_form_choices(app: &PhotocraftApp) -> (Vec, Vec) { + let (mut names, mut sets) = (Vec::new(), Vec::new()); + for action in &app.session.actions.list { + if !names.contains(&action.name) { + names.push(action.name.clone()); + } + if let Some(set) = action.set.as_ref().filter(|set| !sets.contains(*set)) { + sets.push(set.clone()); + } + } + (names, sets) +} + /// The selected set folder, if no action is selected and the set still exists. fn selected_set(app: &PhotocraftApp) -> Option<&str> { if app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()) { @@ -100,16 +116,18 @@ fn playback_targets(app: &PhotocraftApp) -> Vec { /// Play `targets` one after another, stopping at the first that fails or can't start. One that /// pauses for a step dialog of its own hands the rest to that dialog, which plays them after its OK -/// (Cancel stops them all, as it stops one action). A pause inside an action it calls resumes only -/// the called action, so the set stops there rather than run its next action on an unfinished one. Returns the last result with `ran` counting the steps +/// (Cancel stops them all, as it stops one action), also from inside an action it calls: that one +/// continues into its callers first. A Stop with its dialog off ends the set. Returns the last result with `ran` counting the steps /// of all of them plus `before` (steps the same Play ran before a pause), or `None` when there was nothing to play. fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Result, String> { + // A new Play ends a Step by Step or Pause For playback that is waiting for its next step. + playback::stop(app); let (mut last, mut ran) = (None, before); for (n, &i) in targets.iter().enumerate() { let name = app.session.actions.list.get(i).map(|a| a.name.clone()); let mut v = app.run("actions.play", json!({"action": i}))?; ran = ran.saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); - let failed = v.get("failed").is_some_and(Value::is_object); + let failed = v.get("failed").is_some_and(Value::is_object) || v.get("stopped").is_some_and(Value::is_object); let paused = v.get("paused").filter(|p| p.is_object()).and_then(waiting_dialog).cloned(); if let Some(o) = v.as_object_mut() { o.insert("ran".into(), json!(ran)); @@ -122,7 +140,7 @@ fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Resu let rest: Vec = targets.get(n + 1..).unwrap_or_default().iter().filter_map(|i| list.get(*i).cloned()).collect(); let token = paused_token(app, &dialog); if let Some(entry) = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)) - && name.as_ref() == Some(&entry.action) + && name.as_deref() == Some(entry.outermost()) { entry.then = rest; entry.ran = ran; @@ -136,15 +154,17 @@ fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Resu Ok(last) } -/// The dialog a paused playback waits at: a step dialog by id, or Check Spelling. +/// The dialog a paused playback waits at: a step dialog by id, Check Spelling, or Step by Step and +/// Pause For's wait before the next step. fn waiting_dialog(paused: &Value) -> Option<&Value> { - paused.get("dialog").filter(|d| d.is_u64() || d.as_str() == Some(crate::type_panels_ui::SPELLING)) + paused.get("dialog").filter(|d| d.is_u64() || matches!(d.as_str(), Some(crate::type_panels_ui::SPELLING | playback::PLAYBACK_TIMER))) } /// The token of the paused playback waiting at `dialog` (see [`waiting_dialog`]). fn paused_token(app: &mut PhotocraftApp, dialog: &Value) -> Option { match dialog.as_u64() { Some(id) => app.ui.dialog_mut(id).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64), + None if dialog.as_str() == Some(playback::PLAYBACK_TIMER) => playback::waiting(app), None => app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), } } @@ -187,15 +207,220 @@ pub(crate) fn assign_shortcut(app: &mut PhotocraftApp, name: &str, shortcut: &st /// Replay edits synchronously, with view steps handled by the shell in the same order. /// Engine steps keep the headless action semantics (no dialogs or background jobs). pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { - play_owned(app, params, None) + // A new Play ends a Step by Step or Pause For playback that is waiting for its next step (none + // waits while a playback runs its steps, so a Play Action step never ends its own). + playback::stop(app); + play_owned(app, params, None, &[]) +} + +/// How a step that called another action left its caller. +enum Called { + /// The called action finished: the caller goes on. + Done, + Failed(Value), + /// It waits at a dialog that now carries this caller as well. + Paused(Value), + /// A Stop with its dialog off ended it, and the caller with it. + Stopped(Value), + /// The step ran, and Step by Step or Pause For waits before the next one ([`playback::paced`]). + Paced(Value), +} + +/// Read the result of step `i` (`id`) of `caller`: a nested failure, a pause (the caller joins the +/// paused playback's [`PausedPlayback::callers`], so it continues after the called action) or a +/// silent stop end the caller there. +fn called(app: &mut PhotocraftApp, caller: &str, i: usize, id: &str, value: &Value, target: Option) -> Called { + if let Some(error) = actions_cmds::nested_failure(id, value).or_else(|| actions_cmds::save_target_closed(&app.session, id, target)) { + // Keep the same child-result detail as headless Actions and Scripts. + return Called::Failed(json!({"step": i, "id": id, "error": error, "result": value})); + } + if let Some(inner) = value.get("paused").filter(|p| p.is_object()) { + if let Some(token) = waiting_dialog(inner).cloned().and_then(|dialog| paused_token(app, &dialog)) + && let Some(entry) = app.paused_playbacks.paused.get_mut(&token) + { + if entry.callers.len() >= MAX_CALLERS { + return Called::Failed(json!({"step": i, "id": id, "error": format!("action nesting exceeds {MAX_CALLERS} levels")})); + } + entry.callers.push((caller.to_string(), i.saturating_add(1))); + } + return Called::Paused(inner.clone()); + } + if matches!(id, "actions.play" | "actions.conditional") + && let Some(stop) = value.get("stopped").filter(|s| s.is_object()) + { + return Called::Stopped(json!({"step": i, "id": id, "stopped": stop})); + } + Called::Done +} + +/// File `c` under the playback's ending, or count the step when it ran: whether the playback ends. +fn end_at(c: Called, ran: &mut u64, failed: &mut Option, paused: &mut Option, stopped: &mut Option) -> bool { + match c { + Called::Done => { + *ran += 1; + return false; + } + Called::Failed(f) => *failed = Some(f), + Called::Paused(p) => *paused = Some(p), + Called::Stopped(s) => *stopped = Some(s), + Called::Paced(p) => { + *ran += 1; + *paused = Some(p); + } + } + true +} + +/// A resumed playback goes on in the action this one called (`inner`, see [`play_owned`]) inside +/// this one's playback frame, so its recursion checks still see this caller; the call was step +/// `from - 1`. `None` when there is nothing to replay. +fn replay_inner(app: &mut PhotocraftApp, caller: &str, from: usize, inner: &[(String, usize)]) -> Option { + let ((name, child_from), rest) = inner.split_first()?; + let step = from.saturating_sub(1); + let target = app.session.active().map(|d| d.doc.id); + Some(match play_owned(app, &json!({"action": name, "from": child_from}), None, rest) { + Ok(value) => called(app, caller, step, "actions.play", &value, target), + Err(error) => Called::Failed(json!({"step": step, "id": "actions.play", "error": error})), + }) +} + +type Player = fn(&mut PhotocraftApp, &Value) -> Result; + +/// The shell's player for a step that plays another action: Play Action, or a Conditional's branch. +fn nested_call(id: &str) -> Option { + match id { + "actions.play" => Some(play), + "actions.conditional" => Some(conditional), + _ => None, + } +} + +/// A Conditional step: test its condition as the engine does, then play the branch it picks +/// through [`play`] like a Play Action step, so the branch's steps keep their pacing, step dialogs +/// and Stop messages. The result is the engine command's, with the branch's `paused` or `stopped`. +/// Malformed params go to the engine command, which refuses them with its own error. +fn conditional(app: &mut PhotocraftApp, p: &Value) -> Result { + let branch = |key: &str| match p.get(key) { + None | Some(Value::Null) => Some(None), + Some(Value::String(n)) => Some(Some(n.clone()).filter(|n| !n.trim().is_empty())), + Some(_) => None, + }; + let (Some(condition), Some(then), Some(otherwise)) = (p.get("if").and_then(Value::as_str), branch("then"), branch("else")) else { + return app.session.execute("actions.conditional", p.clone()).map_err(|e| e.to_string()); + }; + let Ok(holds) = photocraft_engine::actions_edit_cmds::evaluate(&app.session, condition) else { + return app.session.execute("actions.conditional", p.clone()).map_err(|e| e.to_string()); + }; + let played = if holds { then } else { otherwise }; + let mut out = json!({"if": condition, "holds": holds, "played": played}); + if let Some(name) = played { + let r = play(app, &json!({"action": name}))?; + for key in ["failed", "paused", "stopped"] { + if let Some(v) = r.get(key) { + out[key] = v.clone(); + } + } + out["ran"] = r.get("ran").cloned().unwrap_or(json!(0)); + out["action"] = json!(name); + } + Ok(out) +} + +/// Step `i` of `action` is a Stop: with its dialog toggle on it shows its message and pauses +/// (Continue, when the step allows it, resumes after it); with the toggle off it ends the playback +/// without a word, as in the reference application. +fn stop_step(app: &mut PhotocraftApp, action: &Action, i: usize, p: &Value) -> Called { + let id = photocraft_engine::atn_cmds::STOP_MESSAGE; + if !action.dialogs.contains(&i) { + return Called::Stopped(json!({"step": i, "id": id})); + } + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let allow = p.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); + if let Some(entry) = app.paused_playbacks.paused.get_mut(&token) { + entry.allow_continue = allow; + } + let message = p.get("message").and_then(Value::as_str).unwrap_or_default(); + let fields = json!({STOP_DIALOG: true, "__label": "Message", "message": message, "allowContinue": allow, RESUME: token}); + let dialog = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Called::Paused(json!({"step": i, "id": id, "dialog": dialog})) +} + +/// Let the engine handle programmatic playback and stateless items. Interactive Play opens +/// the settings editor and retains its command, document and receiver until confirmation. +fn pause_menu_item(app: &mut PhotocraftApp, action: &str, from: usize, id: &str, params: &Value) -> Result, String> { + use photocraft_engine::atn_cmds::{MENU_ITEM, menu_item_command, menu_item_needs_settings}; + + if id != MENU_ITEM || !playback::is_interactive(app) { + return Ok(None); + } + let command = menu_item_command(params).map_err(|error| error.to_string())?; + if !menu_item_needs_settings(command) { + return Ok(None); + } + let defaults = json!({}); + authorize_owned(app, command, &defaults, app.session.authorize, app.automation_input)?; + app.session.precheck(command, &defaults).map_err(|error| error.to_string())?; + let receiver = app.session.active().and_then(|st| st.active_layer); + let doc = app.session.active().map(|st| st.doc.id); + let dialog = open_step_editor(app, command)?; + let token = app.paused_playbacks.pause(action.to_owned(), from, app.session.authorize); + if let Some(d) = app.ui.dialog_mut(dialog) { + d.fields.insert(RESUME.into(), json!(token)); + } + if let Some(p) = app.paused_playbacks.paused.get_mut(&token) { + p.menu_editor = Some((dialog, command.to_owned())); + p.filter_receiver = Some(receiver); + p.doc = doc; + } + Ok(Some(dialog)) +} + +/// Route playback and Record Again to the same settings editor used by the command. +fn open_step_editor(app: &mut PhotocraftApp, command: &str) -> Result { + if crate::sizing::is_sizing(command) { + crate::sizing::open(app, command).ok_or_else(|| tl!("No document.").into()) + } else if crate::filter_dialog::has_dialog(command) || crate::adjust_dialog::has_dialog(command) { + Ok(crate::dialogs::open_command_dialog(app, command, &label_of(command))) + } else { + crate::filter_dialog::open(app, command).ok_or_else(|| tl!("The step has no command editor.").into()) + } +} + +/// Open the recorded step's editor and retain the receiver for confirmation. +fn pause_recorded_step(app: &mut PhotocraftApp, action: &Action, i: usize, id: &str, p: &Value) -> Value { + let label = label_of(id); + let active_layer = app.session.active().and_then(|st| st.active_layer); + let dialog = crate::dialogs::open_command_dialog(app, id, &label); + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let mut receiver = None; + if let Some(d) = app.ui.dialog_mut(dialog) { + if let Some(o) = p.as_object() { + // `__` keys are the dialog's own markers (`params_of` drops them), not step values. + d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + d.fields.insert(RESUME.into(), json!(token)); + // Kept for every implicit step, also one opened on a document target: the dialog + // may switch it to a layer-owned one, and `run_step_dialog` checks the receiver + // for the target it confirms. + if p.get("layer").is_none_or(Value::is_null) { + receiver = Some(active_layer); + } + } + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.filter_receiver = receiver; + } + json!({"step": i, "id": id, "dialog": dialog}) } /// `suspended` is the recording a paused part of the same call took ([`PausedPlayback::record`]); it comes -/// back, with that named call journaled, only when the whole call ends. -fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) -> Result { +/// back, with that named call journaled, only when the whole call ends. `inner` are the called +/// actions a resumed playback replays first, outermost first, each `(action, from)`: the first is +/// the one this action's step before `from` called ([`resume`]). +fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option, inner: &[(String, usize)]) -> Result { let (action, from) = match actions_cmds::playback_plan(&app.session, params) { Ok(plan) => plan, Err(e) => { + actions_cmds::finish_pending_history(&mut app.session); restore_recording(app, suspended.as_ref()); return Err(e.to_string()); } @@ -203,6 +428,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) let recording = match actions_cmds::begin_playback(&mut app.session, &action) { Ok(r) => r, Err(e) => { + actions_cmds::finish_pending_history(&mut app.session); restore_recording(app, suspended.as_ref()); return Err(e.to_string()); } @@ -214,9 +440,14 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) }) .or(suspended); let mut ran = 0u64; - let mut failed = None; - let mut paused = None; - for (i, (id, p)) in action.enabled_steps().skip_while(|(i, _)| *i < from) { + let interactive = playback::is_interactive(app); + let (mut failed, mut paused, mut stopped) = (None, None, None); + let ended = match replay_inner(app, &action.name, from, inner) { + // The call was step `from - 1`: a paced Play waits after it as after any other step. + Some(c) => end_at(playback::paced(app, &action, from.saturating_sub(1), "actions.play", ran, c), &mut ran, &mut failed, &mut paused, &mut stopped), + None => false, + }; + for (i, (id, p)) in action.enabled_steps().skip_while(|(i, _)| *i < from).filter(|_| !ended) { if !actions_cmds::consume_playback_step(&mut app.session) { failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", actions_cmds::MAX_PLAYBACK_STEPS)})); break; @@ -237,27 +468,39 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); break; } + // A Stop shows its message or ends the playback (`stop_step`). Agents keep the headless + // semantics of the engine command. + if id == photocraft_engine::atn_cmds::STOP_MESSAGE && interactive { + end_at(stop_step(app, &action, i, p), &mut ran, &mut failed, &mut paused, &mut stopped); + break; + } + match pause_menu_item(app, &action.name, i + 1, id, p) { + Ok(Some(dialog)) => { + paused = Some(json!({"step": i, "id": id, "dialog": dialog})); + break; + } + Err(error) => { + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + Ok(None) => {} + } // The dialog toggle: show the step's dialog with its recorded values and continue // from the next step when it is confirmed (`resume`). Agents play headlessly. - if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) { - let label = label_of(id); - let dialog = crate::dialogs::open_command_dialog(app, id, &label); - let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); - if let Some(d) = app.ui.dialog_mut(dialog) { - if let Some(o) = p.as_object() { - // `__` keys are the dialog's own markers (`params_of` drops them), not step values. - d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); - } - d.fields.insert(RESUME.into(), json!(token)); - } - paused = Some(json!({"step": i, "id": id, "dialog": dialog})); + if action.dialogs.contains(&i) + && interactive + && (crate::filter_dialog::has_dialog(id) + || crate::adjust_dialog::has_dialog(id) + || (id == crate::equalize_ui::COMMAND && crate::equalize_ui::asks(app))) + { + paused = Some(pause_recorded_step(app, &action, i, id, p)); break; } // Check Spelling's dialog opens with Check All Layers as recorded; Done continues the action. // While the dialog is open (for another action or by hand), this step fails and the open // one keeps its place and ignored words. // A correction (`action` set) opens no dialog and runs below. - if action.dialogs.contains(&i) && !app.automation_input && id == "edit.checkSpelling" && p.get("action").is_none() { + if action.dialogs.contains(&i) && interactive && id == "edit.checkSpelling" && p.get("action").is_none() { if let Some(open) = app.ui.type_panels.spell.as_ref() { let error = if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }; failed = Some(json!({"step": i, "id": id, "error": error})); @@ -278,8 +521,8 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) break; } let target = app.session.active().map(|d| d.doc.id); - let result = if id == "actions.play" { - play(app, p) + let result = if let Some(call) = nested_call(id) { + call(app, p) } else if actions_cmds::saves_smart_contents(&app.session, id) { app.session.execute(actions_cmds::SAVE_CONTENTS, json!({})).map_err(|e| e.to_string()) } else if actions_cmds::shell_save_command(id) { @@ -299,17 +542,13 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) app.session.execute(id, p.clone()).map_err(|e| e.to_string()) }; match result { + // A called action that paused for a dialog continues here after it once confirmed. Ok(value) => { - if let Some(error) = actions_cmds::nested_failure(id, &value).or_else(|| actions_cmds::save_target_closed(&app.session, id, target)) { - failed = Some(json!({"step": i, "id": id, "error": error})); + // Step by Step and Pause For: the canvas shows this step before the next one runs. + let c = called(app, &action.name, i, id, &value, target); + if end_at(playback::paced(app, &action, i, id, ran, c), &mut ran, &mut failed, &mut paused, &mut stopped) { break; } - if let Some(inner) = value.get("paused").filter(|p| p.is_object()) { - // A called action paused for a dialog; it resumes alone when confirmed. - paused = Some(inner.clone()); - break; - } - ran += 1; } Err(error) => { failed = Some(json!({"step": i, "id": id, "error": error})); @@ -317,24 +556,37 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } } } + let history = if failed.is_none() && paused.is_some() { actions_cmds::suspend_history(&mut app.session) } else { None }; actions_cmds::finish_playback(&mut app.session, None, &action, from); match paused.as_ref().and_then(waiting_dialog).cloned() { // Paused for a dialog: the call goes on after it, so recording stays suspended. Some(dialog) if failed.is_none() => { let token = paused_token(app, &dialog); let entry = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)); - match (owner, entry) { - (Some(owner), Some(p)) => p.record = Some(owner), + match entry { + Some(p) => { + p.record = owner; + p.history = history; + // Innermost first: each frame of the paused call adds the action it played. + p.played.push(action.clone()); + } // No continuation to carry the recording: the call ends here. - (owner, _) => restore_recording(app, owner.as_ref()), + _ => { + drop(history); + restore_recording(app, owner.as_ref()); + } } } - _ => restore_recording(app, owner.as_ref()), + _ => { + drop(history); + restore_recording(app, owner.as_ref()); + } } - Ok(match (failed, paused) { - (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), - (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), - (None, None) => json!({"action": action.name, "ran": ran}), + Ok(match (failed, paused, stopped) { + (Some(f), _, _) => json!({"action": action.name, "ran": ran, "failed": f}), + (None, Some(p), _) => json!({"action": action.name, "ran": ran, "paused": p}), + (None, None, Some(s)) => json!({"action": action.name, "ran": ran, "stopped": s}), + (None, None, None) => json!({"action": action.name, "ran": ran}), }) } @@ -404,7 +656,16 @@ fn begin_recording(app: &mut PhotocraftApp, append: bool) { } pub(crate) fn report_play(app: &mut PhotocraftApp, v: &Value) { + if v.get("stopped").is_some_and(Value::is_object) { + app.ui.status = tl!("Action playback stopped").to_string(); + app.ui.status_error = false; + return; + } if let Some(paused) = v.get("paused").filter(|p| p.is_object()) { + if paused.get("dialog").and_then(Value::as_str) == Some(playback::PLAYBACK_TIMER) { + // Step by Step or Pause For between two steps: the last report comes at the end. + return; + } let step = paused.get("step").and_then(Value::as_u64).unwrap_or(0); let template = "Step {step} is waiting for its dialog"; app.ui.status = crate::i18n::fmt(tl!(template), &[("step", &(step + 1).to_string())]); @@ -483,6 +744,8 @@ fn step_label(id: &str, params: &Value) -> String { let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) + } else if id == photocraft_engine::atn_cmds::MENU_ITEM { + photocraft_engine::atn_cmds::menu_item_command(params).map_or_else(|_| label_of(id), label_of) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { @@ -758,6 +1021,54 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) -> bool { + fields.contains_key(STOP_DIALOG) +} + +pub fn stop_message_body(ui: &mut egui::Ui, fields: &serde_json::Map) { + ui.set_min_width(300.0); + ui.add(egui::Label::new(fields.get("message").and_then(Value::as_str).unwrap_or_default()).wrap()); +} + +/// Continue (the default) and Stop, or Stop alone when the step doesn't allow continuing. +/// `Some(true)` continues, `Some(false)` stops. ↩ answers the default button from the frame after +/// the dialog appeared, so the ↩ that started the playback doesn't answer it too; the mark lives in +/// the dialog's own fields and goes with it. +pub fn stop_message_buttons(ui: &mut egui::Ui, fields: &mut serde_json::Map, interactive: bool) -> Option { + use crate::widgets::{ButtonRole, DialogButton, dialog_buttons}; + let allow = fields.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); + let shown_before = fields.insert(STOP_SEEN.into(), json!(true)).is_some(); + let clicked = if allow { + dialog_buttons(ui, &[DialogButton::new(ButtonRole::Default, tl!("Continue"), 84.0), DialogButton::new(ButtonRole::Cancel, tl!("Stop"), 84.0)]) + } else { + dialog_buttons(ui, &[DialogButton::new(ButtonRole::Default, tl!("Stop"), 84.0)]) + }; + match clicked { + Some(ButtonRole::Default) => Some(allow), + Some(_) => Some(false), + None if interactive && shown_before && ui.input(|i| i.key_pressed(egui::Key::Enter)) => Some(allow), + None => None, + } +} + +/// The private field [`stop_message_buttons`] marks a drawn Stop message with. +const STOP_SEEN: &str = "__actionStopSeen"; + +/// Continue (OK, ↩, automation): the action goes on after the Stop when the step allows it, as +/// recorded; otherwise it ends there like Stop. +pub fn stop_message_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result { + let allow = fields.get(RESUME).and_then(Value::as_u64).and_then(|t| app.paused_playbacks.paused.get(&t)).is_some_and(|p| p.allow_continue); + if allow { + resume(app, fields)?; + } else { + stopped(app, fields); + } + Ok(json!({"continued": allow})) +} + /// The key a paused playback leaves in its dialog's fields: the token of its [`PausedPlayback`]. pub const RESUME: &str = "__resumeAction"; @@ -794,7 +1105,9 @@ fn suspended(app: &PhotocraftApp) -> bool { /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) { + if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.recordAgain" | "actions.allowToolRecording") + && suspended(app) + { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -802,13 +1115,44 @@ pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), Strin /// A step dialog's command failed: the action ends there. pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let record = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t)).and_then(|p| p.record); + let paused = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t)); + let record = paused.and_then(|p| { + drop(p.history); + p.record + }); + actions_cmds::finish_pending_history(&mut app.session); restore_recording(app, record.as_ref()); } /// The automation gate a session installs ([`photocraft_engine::Session::authorize`]). type Gate = fn(&str, &Value) -> photocraft_engine::Result<()>; +/// Deferred Actions dialogs retain their caller's gate and also honor current permissions. +fn authorize_owned(app: &PhotocraftApp, id: &str, params: &Value, gate: Option, automated: bool) -> Result<(), String> { + if let Some(gate) = gate { + gate(id, params).map_err(|e| e.to_string())?; + } + if let Some(current) = app.session.authorize { + current(id, params).map_err(|e| e.to_string())?; + } + if (automated || app.automation_input || app.session.authorize.is_some()) + && let Some(check) = app.services.automation_command.as_ref() + { + check(id, params)?; + } + Ok(()) +} + +/// A function-pointer dispatch gate cannot carry two different policies into nested jobs. +/// Changed gates and distinct simultaneous Session/service gates both end this continuation; +/// hosts that need both policies must supply one composed gate at both boundaries. +fn continuation_gate(captured: Option, live: Option) -> Result, String> { + if captured.zip(live).is_some_and(|(before, now)| !std::ptr::fn_addr_eq(before, now)) { + return Err("the authorization context changed or uses policies that cannot be combined for this paused action".into()); + } + Ok(live.or(captured)) +} + /// A playback paused at a step dialog: the action and step it continues from, and the gate it /// was playing under (`None` for one a person started). #[derive(Clone, Debug)] @@ -822,6 +1166,7 @@ pub(crate) struct PausedPlayback { /// box, automation), which end the call through `resume`, `abandoned` or `stopped`, so the /// recording comes back exactly once. record: Option, + history: Option, /// The actions still to play after this one, as they were when Play started a whole set. /// Actions have no id, so the whole action stands in for one: an action deleted and made again /// under the same name, or edited, while the dialog is open no longer matches. @@ -832,6 +1177,36 @@ pub(crate) struct PausedPlayback { /// The document a pause at Check Spelling was playing on. That dialog doesn't block the /// document tabs, so Done ([`resume_spelling`]) continues only on this document. doc: Option, + /// An implicit filter/adjustment step resolves this active layer at the pause. The outer Option marks + /// that binding even when there was no active layer; explicit-layer and other dialogs omit it. + filter_receiver: Option>, + /// Descriptor-free menu playback owns this settings dialog and the concrete command it opened. + menu_editor: Option<(u64, String)>, + /// The actions that called this one through Play Action steps, nearest first, each with the + /// step it continues from: confirming the dialog finishes `action`, then each of them in turn + /// (at most [`MAX_CALLERS`]). + callers: Vec<(String, usize)>, + /// A Stop step's Allow Continue, kept here rather than in the dialog's fields, which automation + /// may rewrite: without it, confirming the message only ends the playback. + allow_continue: bool, + /// Parked by Step by Step or Pause For after a step that ran (counted in `ran`), not at a dialog + /// whose own step runs on confirm. + timed: bool, + /// The paused action and each of its callers as they were when it paused, innermost first. + /// The continuation goes on by name, so an action edited, deleted or replaced by a new one of + /// the same name meanwhile ends it there instead of playing the new action's tail ([`resume`]). + played: Vec, +} + +/// The longest caller chain a paused playback keeps; [`actions_cmds::playback_plan`] refuses deeper +/// nesting anyway. +const MAX_CALLERS: usize = 16; + +impl PausedPlayback { + /// The action the person (or agent) started: the last caller, else the paused action. + fn outermost(&self) -> &str { + self.callers.last().map_or(self.action.as_str(), |(name, _)| name.as_str()) + } } /// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the @@ -840,20 +1215,160 @@ pub(crate) struct PausedPlayback { pub(crate) struct PausedPlaybacks { last: u64, paused: std::collections::HashMap, + /// The Step by Step or Pause For playback waiting for its next step ([`playback::tick`]). + timer: Option, } impl PausedPlaybacks { + /// Allocate an opaque token retaining the action, resume position and captured authorization gate. fn pause(&mut self, action: String, from: usize, gate: Option) -> u64 { self.last = self.last.wrapping_add(1); - self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0, doc: None }); + self.paused.insert( + self.last, + PausedPlayback { + action, + from, + gate, + record: None, + history: None, + then: Vec::new(), + ran: 0, + doc: None, + filter_receiver: None, + menu_editor: None, + callers: Vec::new(), + allow_continue: false, + timed: false, + played: Vec::new(), + }, + ); self.last } + /// Consume the continuation named by a numeric dialog token; malformed or expired tokens identify no playback. fn take(&mut self, token: &Value) -> Option { token.as_u64().and_then(|token| self.paused.remove(&token)) } } +/// Whether a dialog command's `target` belongs to the document rather than to a layer: alpha +/// channels and Quick Mask do; masks and colour components still belong to a layer. +fn document_target(params: &Value) -> bool { + params.get("target").is_some_and(|target| target.as_str() == Some("quickMask") || target.get("channel").and_then(Value::as_u64).is_some()) +} + +/// Before a preview or confirmation evaluates settings, check the actual editor and its permissions. +pub(crate) fn authorize_panel_preview( + app: &PhotocraftApp, + dialog: u64, + fields: &serde_json::Map, + command: &str, + params: &Value, +) -> Result<(), String> { + record_again::authorize_preview(app, dialog, command, params)?; + if let Some((token, paused)) = app.paused_playbacks.paused.iter().find(|(_, p)| p.menu_editor.as_ref().is_some_and(|(id, _)| *id == dialog)) { + if fields.get(RESUME).and_then(Value::as_u64) != Some(*token) { + return Err("the menu item dialog changed".into()); + } + check_menu_editor(app, paused, command, params)?; + authorize_owned(app, command, params, paused.gate, paused.gate.is_some())?; + } + Ok(()) +} + +/// Check that preview and confirmation still target the command and receiver that opened the editor. +fn check_menu_editor(app: &PhotocraftApp, paused: &PausedPlayback, command: &str, params: &Value) -> Result<(), String> { + if let Some((_, original)) = &paused.menu_editor + && (original != command + || app.session.active().map(|st| st.doc.id) != paused.doc + || (!document_target(params) && paused.filter_receiver != Some(app.session.active().and_then(|st| st.active_layer)))) + { + return Err("the menu item command or its document changed".into()); + } + Ok(()) +} + +/// Admit the final dialog command before resuming history or starting a worker. The captured +/// policy judges the edited parameters; a current live gate still governs dispatch and nested +/// work. With no live gate, reinstall the captured gate for job admission and rendering. +pub(crate) fn run_step_dialog(app: &mut PhotocraftApp, fields: &serde_json::Map, command: &str, params: Value) -> Result { + let Some(token) = fields.get(RESUME) else { return app.run(command, params) }; + let token = token.as_u64().ok_or("this dialog doesn't belong to a paused action")?; + let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?; + check_menu_editor(app, paused, command, ¶ms)?; + if let Some(receiver) = paused.filter_receiver + && !document_target(¶ms) + && app.session.active().and_then(|st| st.active_layer) != receiver + { + return Err("the paused command's receiver changed while its dialog was open".into()); + } + let captured = paused.gate; + let saved = app.session.authorize; + let service = if app.automation_input { app.services.automation_authorize } else { None }; + for authorize in [captured, saved, service].into_iter().flatten() { + authorize(command, ¶ms).map_err(|e| e.to_string())?; + } + if app.automation_input + && let Some(authorize) = app.services.automation_command.as_ref() + { + authorize(command, ¶ms)?; + } + let gate = continuation_gate(captured, continuation_gate(saved, service)?)?; + prepare_history(app, fields)?; + app.session.authorize = gate; + let result = app.run(command, params); + app.session.authorize = saved; + result +} + +/// Bind the dialog's own edit to its suspended action before it runs or starts a worker. +pub(crate) fn prepare_history(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { + let Some(token) = fields.get(RESUME).and_then(Value::as_u64) else { return Ok(()) }; + let history = app.paused_playbacks.paused.get_mut(&token).ok_or("this dialog doesn't belong to a paused action")?.history.take(); + if let Some(history) = history + && let Err(e) = history.resume(&mut app.session) + { + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.history = Some(history); + } + return Err(e.to_string()); + } + Ok(()) +} + +/// Check Spelling owns each correction only while it runs; the open window owns a bound token. +pub(crate) fn spelling_change(app: &mut PhotocraftApp, params: Value) -> Result { + let Some(token) = app.ui.type_panels.spell.as_ref().and_then(|dialog| dialog.resume) else { + return app.run("edit.checkSpelling", params); + }; + let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?; + if paused.doc.is_some_and(|doc| Some(doc) != app.session.active().map(|state| state.doc.id)) { + return Err("the active document changed while Check Spelling was open".into()); + } + let history = paused.history.clone(); + let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]); + let result = run_step_dialog(app, &fields, "edit.checkSpelling", params); + // A rejected admission did not resume this continuation. Keep the original token intact. + if app.paused_playbacks.paused.get(&token).is_some_and(|paused| paused.history.is_some()) { + return result; + } + if let Some(history) = history { + match history.suspend_after_edit(&mut app.session) { + Ok(history) => { + let paused = app.paused_playbacks.paused.get_mut(&token).ok_or("the spelling continuation ended during its correction")?; + paused.history = Some(history); + } + Err(error) => { + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.history = Some(history); + } + return Err(error.to_string()); + } + } + } + result +} + /// Check Spelling's Done for the action paused at its step: the action continues when the document /// it was playing on is still the active one, and otherwise ends there with the reason in the /// status bar. Returns `Ok` when nothing failed. @@ -881,10 +1396,8 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) return Err("this dialog doesn't belong to a paused action".into()); }; let owner = paused.record.clone(); - // The dialog's command may run as a background job; the next step needs its result. - if let Some(doc) = app.session.active().map(|d| d.doc.id) - && let Some(job) = app.session.job_on(doc) - && let Err(e) = app.session.wait_job(job.id) + if let Some(history) = &paused.history + && let Err(e) = history.resume(&mut app.session) { restore_recording(app, owner.as_ref()); let template = "Action playback failed: {error}"; @@ -892,23 +1405,78 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) app.ui.status_error = true; return Err(e.to_string()); } - let params = json!({"action": paused.action, "from": paused.from}); + // The dialog's command may run as a background job; the next step needs its result. + if let Some(doc) = app.session.active().map(|d| d.doc.id) + && let Some(job) = app.session.job_on(doc) + && let Err(e) = app.session.wait_job(job.id) + { + actions_cmds::finish_pending_history(&mut app.session); + restore_recording(app, owner.as_ref()); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + app.ui.status_error = true; + return Err(e.to_string()); + } let saved = app.session.authorize; - app.session.authorize = paused.gate.or(saved); - let mut played = play_owned(app, ¶ms, owner); - // Paused again at a later dialog of the same action (not of one it calls, which resumes - // alone): that dialog carries the rest of the set. + let service = if app.automation_input { app.services.automation_authorize } else { None }; + let remaining = !paused.then.is_empty() + || !paused.callers.is_empty() + || app + .session + .actions + .list + .iter() + .find(|action| action.name == paused.action) + .is_some_and(|action| action.enabled_steps().any(|(i, _)| i >= paused.from)); + let gate = + if remaining { continuation_gate(saved, service).and_then(|live| continuation_gate(paused.gate, live)) } else { Ok(saved.or(service).or(paused.gate)) }; + let gate = match gate { + Ok(gate) => gate, + Err(error) => { + // The admitted dialog edit has completed; preserve it while rejecting the tail. + actions_cmds::finish_pending_history(&mut app.session); + restore_recording(app, owner.as_ref()); + app.ui.status = error.clone(); + app.ui.status_error = true; + return Err(error); + } + }; + // The rest plays by name: refuse it when an action of the paused call changed meanwhile, so a + // new action of the same name never plays from the old one's step. With nothing left to play, + // the call simply ends. (Another document coming to the front fails `history.resume` above.) + let list = &app.session.actions.list; + if remaining && paused.played.iter().any(|a| list.iter().find(|b| b.name == a.name) != Some(a)) { + let error = "the action changed while it was paused"; + actions_cmds::finish_pending_history(&mut app.session); + restore_recording(app, owner.as_ref()); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", error)]); + app.ui.status_error = true; + return Err(error.into()); + } + // The paused action goes on, then each action that called it, inside its callers' frames: + // replay starts at the outermost, which replays the inner ones first. + let frames: Vec<(String, usize)> = paused.callers.iter().rev().cloned().chain(std::iter::once((paused.action.clone(), paused.from))).collect(); + let Some(((outer, outer_from), inner)) = frames.split_first() else { + return Err("this dialog doesn't belong to a paused action".into()); + }; + let params = json!({"action": outer, "from": outer_from}); + // A dialog's own step runs on its confirm; a paced wait's step already ran and was counted. + let own = u64::from(!paused.timed); + app.session.authorize = gate; + let mut played = play_owned(app, ¶ms, owner, inner); + // Paused again at a later dialog of the same call: that dialog carries the rest of the set. if let Ok(v) = &played && let Some(dialog) = v.get("paused").and_then(waiting_dialog) && let Some(token) = paused_token(app, dialog) && let Some(entry) = app.paused_playbacks.paused.get_mut(&token) - && entry.action == paused.action + && entry.outermost() == paused.outermost() { entry.then = paused.then.clone(); - entry.ran = paused.ran.saturating_add(1).saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); + entry.ran = paused.ran.saturating_add(own).saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); } // The count covers the steps this Play ran before the pause, and the step the dialog ran. - let ran = paused.ran.saturating_add(1).saturating_add(played.as_ref().ok().and_then(|v| v.get("ran")).and_then(Value::as_u64).unwrap_or(0)); + let ran = paused.ran.saturating_add(own).saturating_add(played.as_ref().ok().and_then(|v| v.get("ran")).and_then(Value::as_u64).unwrap_or(0)); if let Ok(Some(o)) = played.as_mut().map(Value::as_object_mut) && o.contains_key("ran") { @@ -921,6 +1489,7 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) && !paused.then.is_empty() && v.get("failed").is_none() && v.get("paused").is_none() + && v.get("stopped").is_none() { let list = &app.session.actions.list; let rest: Vec = paused.then.iter().map_while(|then| list.iter().position(|a| a == then)).collect(); @@ -967,6 +1536,14 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.button(tl!("New Set…")).clicked() { act = Some("newSet"); } + let can_duplicate_set = + !recording && app.ui.actions.selected_set.as_ref().is_some_and(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)); + if ui.add_enabled(can_delete || can_duplicate_set, egui::Button::new(tl!("Duplicate"))).clicked() { + act = Some("duplicate"); + } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { + act = Some("delete"); + } if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { act = Some("play"); } @@ -977,13 +1554,30 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if ui.button(tl!("Start Recording")).clicked() { act = Some("record"); } - if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { - act = Some("delete"); + if ui.add_enabled(selected && app.ui.actions.selected_step.is_some() && !recording, egui::Button::new(tl!("Record Again"))).clicked() { + act = Some("recordAgain"); } ui.separator(); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Menu Item…"))).clicked() { + act = Some("insertMenu"); + } if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { act = Some("stop"); } + let path_selected = + app.ui.selected_path.as_deref().is_some_and(|name| photocraft_engine::actions_edit_cmds::selected_recording_path(&app.session, name).is_ok()); + if ui.add_enabled(selected && path_selected, egui::Button::new(tl!("Insert Path"))).clicked() { + act = Some("insertPath"); + } + ui.separator(); + // Enabled with nothing selected, as in the reference application; not while a paced Play waits. + if ui.add_enabled(!playback::pending(app), egui::Button::new(tl!("Playback Options…"))).clicked() { + act = Some("playbackOptions"); + } + let mut tools = app.session.actions.record_tools; + if crate::widgets::checkbox(ui, &mut tools, tl!("Allow Tool Recording")).changed() { + act = Some("toolRecording"); + } ui.separator(); if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); @@ -1018,9 +1612,18 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { delete_selection(app); Ok(Value::Null) } + "duplicate" => duplicate_selection(app), + "recordAgain" => record_again::open(app), + "toolRecording" => app.run("actions.allowToolRecording", json!({"enabled": tools})), + "insertPath" => app.run( + "actions.insertPath", + json!({"action": app.ui.actions.selected, "at": app.ui.actions.selected_step.map(|i| i.saturating_add(1)), "name": app.ui.selected_path}), + ), + "insertMenu" => menu_item::open(app), "load" => load_dialog(app), "newSet" => new_set_dialog(app), "stop" => insert_stop_dialog(app), + "playbackOptions" => playback::open_dialog(app), _ => save_dialog(app, None), }; if let Err(e) = r @@ -1080,6 +1683,47 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { } } +/// Keep the selection and disclosure rows on the new copy after the engine edits the list. +fn duplicate_selection(app: &mut PhotocraftApp) -> Result { + if app.ui.actions.selected.is_none() + && let Some(set) = app.ui.actions.selected_set.clone() + { + let count = app.session.actions.list.len(); + let result = app.run("actions.duplicate", json!({"set": set}))?; + if let (Some(at), Some(added)) = (result["index"].as_u64(), result["actions"].as_u64()) + && let (Ok(at), Ok(added)) = (usize::try_from(at), usize::try_from(added)) + && at <= count + { + app.ui.actions.expanded.resize(count, false); + app.ui.actions.expanded.splice(at..at, std::iter::repeat_n(false, added)); + } + app.ui.actions.selected_set = result["set"].as_str().map(str::to_owned); + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + return Ok(result); + } + let action = app.ui.actions.selected.ok_or_else(|| tl!("Select an action first").to_string())?; + let step = app.ui.actions.selected_step; + let count = app.session.actions.list.len(); + let expanded = app.ui.actions.expanded.get(action).copied().unwrap_or(false); + let result = app.run("actions.duplicate", json!({"action": action, "step": step}))?; + let index = result["index"].as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("the copy has no action index")?; + app.ui.actions.selected = Some(index); + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if step.is_none() { + app.ui.actions.expanded.resize(count, false); + if index <= app.ui.actions.expanded.len() { + app.ui.actions.expanded.insert(index, expanded); + } + } else { + app.ui.actions.expanded.resize(count, false); + if let Some(expanded) = app.ui.actions.expanded.get_mut(index) { + *expanded = true; + } + } + Ok(result) +} + fn delete_selection(app: &mut PhotocraftApp) { let Some(action) = app.ui.actions.selected else { return }; let step = app.ui.actions.selected_step; @@ -1487,7 +2131,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let recording_now = app.session.actions.recording.is_some(); - if crate::icons::button(ui, "square", 24.0, false, tl!("Stop playing/recording")).clicked() && recording_now { + if crate::icons::button(ui, "square", 24.0, false, tl!("Stop playing/recording")).clicked() && !playback::stop(app) && recording_now { let _ = app.run("actions.stop", json!({})); } let (r, rec) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); @@ -1637,5 +2281,15 @@ fn draw_step_controls(ui: &mut egui::Ui, rect: Rect, step: usize, label: &str, c (check.on_hover_text(tl!("Include in playback")), dialog.on_hover_text(tl!("Show dialog during playback"))) } +pub(crate) mod menu_item; +pub(crate) mod playback; +pub(crate) mod record_again; + #[cfg(test)] mod tests; + +#[cfg(test)] +mod stop_tests; + +#[cfg(test)] +mod playback_tests; diff --git a/crates/ui-egui/src/panels/actions/menu_item.rs b/crates/ui-egui/src/panels/actions/menu_item.rs new file mode 100644 index 0000000000..d18a5edeb3 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/menu_item.rs @@ -0,0 +1,87 @@ +//! Choose an existing mapped menu event without borrowing current command settings. +use crate::{PhotocraftApp, state::DialogKind}; +use serde_json::{Map, Value, json}; +const COMMAND: &str = "actions.insertMenuItem"; +const MARK: &str = "__insertMenuItem"; + +/// Keep a chooser tied to its original action, library and caller. +pub(crate) struct Held { + dialog: u64, + revision: u64, + target: Value, + gate: Option, + automated: bool, +} + +/// Register the chooser with the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether this dialog id still owns a deferred panel operation. +pub(crate) fn owns_dialog(app: &PhotocraftApp, dialog: u64) -> bool { + app.held_insert_menu.as_ref().is_some_and(|held| held.dialog == dialog) +} + +/// Open without recording a step or sampling the selected command's current settings. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_insert_menu.is_some() { + return Err(tl!("Confirm or cancel the pending menu item first.").into()); + } + let action = app.ui.actions.selected.filter(|i| *i < app.session.actions.list.len()).ok_or(tl!("Select an action first."))?; + let target = json!({"action":action,"at":app.ui.actions.selected_step.map(|i|i.saturating_add(1))}); + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let automated = app.automation_input || gate.is_some(); + let fields = json!({MARK:true,"__command":COMMAND,"__label":"Insert Menu Item","id":"image.imageSize"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_insert_menu = Some(Held { dialog, revision: app.session.actions.rev, target, gate, automated }); + Ok(json!({"dialog":dialog})) +} + +/// List only menu identities the existing ATN event table can encode unambiguously. +pub(crate) fn body(ui: &mut egui::Ui, fields: &mut Map) { + ui.label(tl!("Choose a menu command. Playback asks for its settings when needed.")); + let mut id = fields.get("id").and_then(Value::as_str).unwrap_or("image.imageSize").to_string(); + craft_ui::ComboBox::from_id_salt("actions-insert-menu").selected_text(super::label_of(&id)).show_ui(ui, |ui| { + for command in photocraft_engine::atn_cmds::menu_item_commands() { + ui.selectable_value(&mut id, command.to_string(), super::label_of(command)); + } + }); + fields.insert("id".into(), json!(id)); +} + +/// Consent inserts only into the captured action and uses both retained and current authority. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_insert_menu.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No menu item belongs to this chooser.").into()); + } + let held = app.held_insert_menu.take().ok_or(tl!("No menu item is waiting."))?; + if held.revision != app.session.actions.rev || fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { + return Err(tl!("The action or insertion command changed. Request Insert Menu Item again.").into()); + } + let mut params = held.target; + let action = params["action"].as_u64().and_then(|index| usize::try_from(index).ok()); + params["id"] = fields.get("id").cloned().unwrap_or(Value::Null); + super::authorize_owned(app, COMMAND, ¶ms, held.gate, held.automated)?; + let saved = app.session.authorize; + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Insert Menu Item again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, params); + app.session.authorize = saved; + let result = result?; + app.ui.actions.selected = action; + app.ui.actions.selected_set = None; + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + Ok(result) +} + +/// Only its own Cancel may release the chooser's target. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_insert_menu.as_ref().is_some_and(|h| h.dialog == dialog) { + app.held_insert_menu = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/playback.rs b/crates/ui-egui/src/panels/actions/playback.rs new file mode 100644 index 0000000000..340906a060 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/playback.rs @@ -0,0 +1,207 @@ +//! Playback Options: the Actions panel menu's dialog, and the Step by Step and Pause For pacing of a +//! person's Play. +//! +//! A paced Play is still one playback: after each step [`super::play_owned`] parks the rest of the +//! action like a step dialog does (a [`super::PausedPlayback`] token holding the suspended history and +//! recording), and [`tick`] resumes it once the wait is over, so the steps share one history state +//! and the canvas redraws between them. Esc, the panel's Stop button, an edit of the action list or a +//! new recording end it there, keeping the steps already applied. Programmatic playback never waits. + +use serde_json::{Map, Value, json}; +use web_time::{Duration, Instant}; + +use photocraft_engine::prefs::{Actions, PlaybackPerformance}; + +use crate::PhotocraftApp; + +/// The marker a paced playback leaves where a step dialog's id would be (see [`super::waiting_dialog`]). +pub(crate) const PLAYBACK_TIMER: &str = "__playbackTimer"; + +/// The field marking a Playback Options dialog. +const PLAYBACK_OPTIONS: &str = "__actionsPlaybackOptions"; + +/// Step by Step's gap between two steps: the reference application takes about 1.15 s per step +/// with nothing to redraw (measured 2026-11-05), and adds Pause For's seconds to it. +pub(crate) const STEP_GAP: Duration = Duration::from_secs(1); + +/// The paced playback waiting for its next step: its [`super::PausedPlaybacks`] token, when it goes +/// on, and the action list's revision it was parked at. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Timer { + pub(crate) token: u64, + pub(crate) due: Instant, + rev: u64, +} + +/// Native Play and control-channel menu clicks may open editors and wait between steps. +pub(super) fn is_interactive(app: &PhotocraftApp) -> bool { + !app.automation_input && !app.programmatic_playback +} + +/// How long a person's Play waits after a step, or `None` when it goes straight on (Accelerated, +/// and always for agents). +pub(super) fn step_pause(app: &PhotocraftApp) -> Option { + if !is_interactive(app) { + return None; + } + let a = &app.session.prefs().actions; + match a.performance { + PlaybackPerformance::Accelerated => None, + PlaybackPerformance::StepByStep => Some(STEP_GAP), + PlaybackPerformance::Pause => Some(STEP_GAP.saturating_add(Duration::from_secs(u64::from(a.pause_seconds())))), + } +} + +/// Step `i` of `action` ended as `c` (`ran` steps of it before): when it simply ran and Step by +/// Step or Pause For waits before the next enabled step, park the rest ([`super::Called::Paced`]). +pub(super) fn paced(app: &mut PhotocraftApp, action: &super::Action, i: usize, id: &str, ran: u64, c: super::Called) -> super::Called { + if !matches!(c, super::Called::Done) || !action.enabled_steps().any(|(j, _)| j > i) { + return c; + } + match step_pause(app) { + Some(wait) => super::Called::Paced(pace(app, &action.name, i, id, ran.saturating_add(1), wait)), + None => c, + } +} + +/// Park `action` after its step `i` (`ran` steps of it so far): it continues from the next step +/// after `wait`. Returns the `paused` value [`super::play_owned`] reports. +fn pace(app: &mut PhotocraftApp, action: &str, i: usize, id: &str, ran: u64, wait: Duration) -> Value { + let rev = app.session.actions.rev; + let gate = app.session.authorize; + let pb = &mut app.paused_playbacks; + let token = pb.pause(action.to_string(), i.saturating_add(1), gate); + if let Some(entry) = pb.paused.get_mut(&token) { + entry.timed = true; + entry.ran = ran; + } + let due = Instant::now().checked_add(wait).unwrap_or_else(Instant::now); + // One paced playback at a time: Play ends a waiting one before it starts (`stop`). + if let Some(old) = pb.timer.replace(Timer { token, due, rev }) { + pb.paused.remove(&old.token); + } + json!({"step": i, "id": id, "dialog": PLAYBACK_TIMER}) +} + +/// The token of the paced playback that is waiting, if any. +pub(super) fn waiting(app: &PhotocraftApp) -> Option { + app.paused_playbacks.timer.map(|t| t.token) +} + +/// Whether a paced playback is waiting for its next step. +pub(crate) fn pending(app: &PhotocraftApp) -> bool { + app.paused_playbacks.timer.is_some() +} + +/// End a waiting paced playback where it is: the steps it applied stay, in one history state when +/// the option is on. Returns whether one was waiting. +pub(crate) fn stop(app: &mut PhotocraftApp) -> bool { + let Some(timer) = app.paused_playbacks.timer.take() else { return false }; + let fields = Map::from_iter([(super::RESUME.to_string(), json!(timer.token))]); + super::stopped(app, &fields); + true +} + +/// Once per frame: play the next step when its wait is over, or end the playback on Esc, an edit of +/// the action list or a new recording. +pub(crate) fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(timer) = app.paused_playbacks.timer else { return }; + let esc = app.ui.dialogs.is_empty() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + let changed = timer.rev != app.session.actions.rev || app.session.actions.recording.is_some(); + if esc || changed || !app.paused_playbacks.paused.contains_key(&timer.token) { + stop(app); + return; + } + let now = Instant::now(); + // A dialog opened meanwhile (Playback Options itself, say) holds the next step until it closes. + if now < timer.due || !app.ui.dialogs.is_empty() { + ctx.request_repaint_after(timer.due.saturating_duration_since(now).max(Duration::from_millis(100))); + return; + } + app.paused_playbacks.timer = None; + let fields = Map::from_iter([(super::RESUME.to_string(), json!(timer.token))]); + // `resume` reports the outcome in the status bar. + let _ = super::resume(app, &fields); + ctx.request_repaint(); +} + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(PLAYBACK_OPTIONS) +} + +/// Playback Options…: the saved options, Pause For's seconds kept while another mode is chosen. +pub(super) fn open_dialog(app: &mut PhotocraftApp) -> Result { + let a = &app.session.prefs().actions; + let fields = json!({ + PLAYBACK_OPTIONS: true, + "__label": "Playback Options", + "performance": a.performance.name(), + "seconds": a.pause_seconds(), + "singleState": a.create_single_history_state, + }); + let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Ok(json!({"dialog": id})) +} + +/// Performance (Accelerated, Step by Step, Pause For: n seconds) and History (Create a single +/// history state), laid out as the reference application's dialog. +pub fn body(ui: &mut egui::Ui, fields: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + crate::widgets::section_label(ui, tl!("Performance")); + crate::widgets::hairline(ui); + let mut performance = fields.get("performance").and_then(Value::as_str).and_then(PlaybackPerformance::parse).unwrap_or_default(); + let before = performance; + ui.indent("playback-options-performance", |ui| { + ui.radio_value(&mut performance, PlaybackPerformance::Accelerated, tl!("Accelerated")); + ui.radio_value(&mut performance, PlaybackPerformance::StepByStep, tl!("Step by Step")); + ui.horizontal(|ui| { + ui.radio_value(&mut performance, PlaybackPerformance::Pause, tl!("Pause For:")); + let range = Actions::PAUSE_SECONDS; + if performance == PlaybackPerformance::Pause { + let mut seconds = f32::from(seconds_of(fields)); + let r = crate::widgets::value_field_prec(ui, &mut seconds, f32::from(*range.start())..=f32::from(*range.end()), "", 40.0, 0); + if r.accessible_name(tl!("Pause For")).changed() { + fields.insert("seconds".into(), json!(clamp_seconds(f64::from(seconds)))); + } + } else { + // Empty and greyed until Pause For is chosen, as in the reference application. + ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).desired_width(40.0)).accessible_name(tl!("Pause For")); + } + ui.add_enabled(performance == PlaybackPerformance::Pause, egui::Label::new(tl!("seconds"))); + }); + }); + if performance != before { + fields.insert("performance".into(), json!(performance.name())); + } + ui.add_space(4.0); + crate::widgets::section_label(ui, tl!("History")); + crate::widgets::hairline(ui); + let mut single = fields.get("singleState").and_then(Value::as_bool).unwrap_or(true); + ui.indent("playback-options-history", |ui| { + if ui.checkbox(&mut single, tl!("Create a single history state")).changed() { + fields.insert("singleState".into(), json!(single)); + } + }); +} + +/// A whole number of seconds within range: the reference application's field takes only digits and +/// puts the closest valid value in. +fn clamp_seconds(v: f64) -> u8 { + let range = Actions::PAUSE_SECONDS; + if !v.is_finite() { + return *range.start(); + } + v.round().clamp(f64::from(*range.start()), f64::from(*range.end())) as u8 +} + +fn seconds_of(fields: &Map) -> u8 { + fields.get("seconds").and_then(Value::as_f64).map_or(*Actions::PAUSE_SECONDS.start(), clamp_seconds) +} + +/// OK: store the options (`actions.setPlayback`). Performance and Create a single history state go +/// as the dialog holds them, so the command refuses a value automation set that the dialog can't +/// show (`null`, an unknown mode) instead of saving a default in its place. +pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let field = |key: &str| fields.get(key).cloned().unwrap_or(Value::Null); + app.run("actions.setPlayback", json!({"performance": field("performance"), "seconds": seconds_of(fields), "singleState": field("singleState")})) +} diff --git a/crates/ui-egui/src/panels/actions/playback_tests.rs b/crates/ui-egui/src/panels/actions/playback_tests.rs new file mode 100644 index 0000000000..c502e17fc0 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/playback_tests.rs @@ -0,0 +1,302 @@ +//! #5212: Playback Options. Step by Step and Pause For play the whole action from one Play, with a +//! frame (and the wait) between steps, in one history state when Create a single history state is +//! on; Esc and the Stop button end them keeping the steps applied. Measured in the reference +//! application 2026-11-05. +use super::*; +use web_time::{Duration, Instant}; + +fn names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).filter(|n| n != "Background").collect() +} + +/// `Three` makes layers A, B and C. +fn app_with(performance: &str, seconds: u8, single: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.run("actions.setPlayback", json!({"performance": performance, "seconds": seconds, "singleState": single})).unwrap(); + let steps = ["A", "B", "C"].map(|n| ("layer.new.layer".to_string(), json!({"name": n}))).to_vec(); + app.session.actions.list.push(Action::new("Three", steps)); + app +} + +/// One frame of the app's loop as far as pacing goes, with `events` as its input. +fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(raw, |ui| playback::tick(app, ui.ctx())).drop_without_applying_deltas(); +} + +/// Let the wait run out, then one frame. +fn next_step(app: &mut PhotocraftApp, ctx: &egui::Context) { + if let Some(t) = app.paused_playbacks.timer.as_mut() { + t.due = Instant::now(); + } + frame(app, ctx, Vec::new()); +} + +fn esc() -> egui::Event { + egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } +} + +fn assert_idle(app: &PhotocraftApp) { + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + assert!(app.paused_playbacks.paused.is_empty()); + assert!(app.paused_playbacks.timer.is_none()); +} + +/// No continuation keeps the history policy leased once a playback ended. +fn assert_released(app: &mut PhotocraftApp) { + let single = app.session.prefs().actions.create_single_history_state; + app.run("actions.setHistory", json!({"singleState": single})).unwrap(); +} + +#[test] +fn step_by_step_plays_every_step_from_one_play_with_a_frame_between() { + for single in [true, false] { + let mut app = app_with("step", 1, single); + let ctx = egui::Context::default(); + let past = app.session.active().unwrap().history.past_len(); + let v = play(&mut app, &json!({"action": "Three"})).unwrap(); + assert_eq!(v["ran"], 1, "{v}"); + assert_eq!(names(&app), ["A"], "the first step is shown before the next runs"); + let due = app.paused_playbacks.timer.unwrap().due.saturating_duration_since(Instant::now()); + assert!(due > Duration::from_millis(500) && due <= playback::STEP_GAP, "{due:?}"); + frame(&mut app, &ctx, Vec::new()); + assert_eq!(names(&app), ["A"], "nothing runs before the wait is over"); + next_step(&mut app, &ctx); + assert_eq!(names(&app), ["A", "B"]); + next_step(&mut app, &ctx); + assert_eq!(names(&app), ["A", "B", "C"]); + assert!(app.paused_playbacks.timer.is_none(), "no wait after the last step"); + assert_idle(&app); + assert_released(&mut app); + assert_eq!(app.ui.status, "Played 3 steps"); + let st = app.session.active().unwrap(); + if single { + assert_eq!(st.history.past_len(), past + 1, "one history state for the whole play"); + assert_eq!(st.history.undo_label(), Some("Action: Three")); + assert!(app.session.undo()); + assert!(names(&app).is_empty(), "one undo takes back every step"); + } else { + assert_eq!(st.history.past_len(), past + 3, "one state per step with the option off"); + } + } +} + +#[test] +fn pause_for_waits_its_seconds_after_each_step_but_the_last() { + let mut app = app_with("pause", 5, true); + let ctx = egui::Context::default(); + play(&mut app, &json!({"action": "Three"})).unwrap(); + assert_eq!(names(&app), ["A"], "no wait before the first step"); + let due = app.paused_playbacks.timer.unwrap().due.saturating_duration_since(Instant::now()); + assert!(due > Duration::from_millis(5500) && due <= playback::STEP_GAP + Duration::from_secs(5), "{due:?}"); + frame(&mut app, &ctx, Vec::new()); + assert_eq!(names(&app), ["A"]); + next_step(&mut app, &ctx); + assert_eq!(names(&app), ["A", "B"]); + assert!(app.paused_playbacks.timer.is_some(), "waits again between B and C"); + next_step(&mut app, &ctx); + assert_eq!(names(&app), ["A", "B", "C"]); + assert_idle(&app); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Action: Three")); +} + +#[test] +fn accelerated_and_agents_play_at_once() { + let mut app = app_with("accelerated", 1, true); + assert_eq!(play(&mut app, &json!({"action": "Three"})).unwrap()["ran"], 3); + assert_idle(&app); + let mut app = app_with("pause", 60, true); + app.automation_input = true; + assert_eq!(play(&mut app, &json!({"action": "Three"})).unwrap()["ran"], 3, "agents never wait"); + assert_idle(&app); +} + +#[test] +fn esc_or_stop_ends_the_playback_and_keeps_the_steps_applied() { + for how in ["esc", "stop button", "list edited", "new play"] { + let mut app = app_with("pause", 3, true); + let ctx = egui::Context::default(); + let past = app.session.active().unwrap().history.past_len(); + play(&mut app, &json!({"action": "Three"})).unwrap(); + next_step(&mut app, &ctx); + assert_eq!(names(&app), ["A", "B"]); + match how { + "esc" => frame(&mut app, &ctx, vec![esc()]), + "stop button" => assert!(playback::stop(&mut app)), + "list edited" => { + app.run("actions.newSet", json!({"name": "Other"})).unwrap(); + frame(&mut app, &ctx, Vec::new()); + } + _ => { + app.session.actions.list.push(Action::new("One", vec![("layer.new.layer".into(), json!({"name": "D"}))])); + app.run("actions.setPlayback", json!({"performance": "accelerated"})).unwrap(); + play(&mut app, &json!({"action": "One"})).unwrap(); + } + } + next_step(&mut app, &ctx); + let expected: &[&str] = if how == "new play" { &["A", "B", "D"] } else { &["A", "B"] }; + assert_eq!(names(&app), expected, "{how}: C never runs"); + assert_idle(&app); + assert_released(&mut app); + let st = app.session.active().unwrap(); + let added = if how == "new play" { 2 } else { 1 }; + assert_eq!(st.history.past_len(), past + added, "{how}: the applied steps stay one history state"); + if how != "new play" { + assert_eq!(app.ui.status, "Action playback stopped", "{how}"); + } + } +} + +#[test] +fn an_edit_between_steps_ends_the_playback_without_joining_it() { + let mut app = app_with("step", 1, true); + let ctx = egui::Context::default(); + play(&mut app, &json!({"action": "Three"})).unwrap(); + app.run("layer.new.layer", json!({"name": "Mine"})).unwrap(); + next_step(&mut app, &ctx); + assert_eq!(names(&app), ["A", "Mine"], "the action doesn't go on over a document someone changed"); + assert!(app.ui.status_error, "{}", app.ui.status); + assert_idle(&app); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("New Layer")); +} + +/// A called action is paced too, and its caller goes on after it in the same playback. +#[test] +fn nested_and_set_playback_stay_one_play() { + let mut app = app_with("step", 1, true); + let ctx = egui::Context::default(); + app.session.actions.list.push(Action::new( + "Caller", + vec![ + ("layer.new.layer".into(), json!({"name": "Before"})), + ("actions.play".into(), json!({"action": "Three"})), + ("layer.new.layer".into(), json!({"name": "After"})), + ], + )); + let past = app.session.active().unwrap().history.past_len(); + play_targets(&mut app, &[1], 0).unwrap(); + // One step per frame, also into and out of the called action. + let mut seen = vec![names(&app)]; + while app.paused_playbacks.timer.is_some() { + next_step(&mut app, &ctx); + seen.push(names(&app)); + } + assert_eq!(seen.iter().map(Vec::len).collect::>(), [1, 2, 3, 4, 5], "{seen:?}"); + assert_eq!(names(&app), ["Before", "A", "B", "C", "After"]); + assert_idle(&app); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Action: Caller")); + // Counted as Accelerated counts it: the Play Action step is one step of its caller. + assert_eq!(app.ui.status, "Played 3 steps"); + let mut fast = app_with("accelerated", 1, true); + fast.session.actions.list.push(app.session.actions.list[1].clone()); + let v = play_targets(&mut fast, &[1], 0).unwrap().unwrap(); + assert_eq!(v["ran"], 3, "{v}"); +} + +#[test] +fn the_panel_menu_opens_playback_options_and_ok_saves_them() { + let mut app = app_with("accelerated", 1, true); + let opened = playback::open_dialog(&mut app).unwrap(); + let id = opened["dialog"].as_u64().unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + assert_eq!(crate::dialogs::title(d), "Playback Options"); + assert_eq!(d.fields["performance"], "accelerated"); + assert_eq!(d.fields["seconds"], 1); + assert_eq!(d.fields["singleState"], true); + d.fields.insert("performance".into(), json!("pause")); + d.fields.insert("seconds".into(), json!(75)); + d.fields.insert("singleState".into(), json!(false)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let a = &app.session.prefs().actions; + assert_eq!(a.performance, photocraft_engine::prefs::PlaybackPerformance::Pause); + assert_eq!(a.pause_seconds, 60, "a number past the range takes the closest one"); + assert!(!a.create_single_history_state); + // Cancel keeps them. + let id = playback::open_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("performance".into(), json!("step")); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert_eq!(app.session.prefs().actions.performance, photocraft_engine::prefs::PlaybackPerformance::Pause); +} + +#[test] +fn the_dialog_shows_the_reference_layout() { + use egui_kittest::{Harness, kittest::Queryable}; + let app = app_with("step", 9, true); + let mut h = Harness::builder().with_size(egui::vec2(520.0, 420.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + playback::open_dialog(h.state_mut()).unwrap(); + h.run_steps(3); + for label in ["Performance", "Accelerated", "Step by Step", "Pause For:", "seconds", "History", "Create a single history state", "OK", "Cancel"] { + assert!(h.query_by_label(label).is_some(), "{label}"); + } + h.get_by_label("Pause For:").click(); + h.run_steps(2); + let id = h.state().ui.dialogs[0].id; + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["performance"], "pause"); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["seconds"], 9, "Pause For shows the kept seconds"); + h.get_by_label("Create a single history state").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + let a = &h.state().session.prefs().actions; + assert_eq!((a.performance, a.pause_seconds, a.create_single_history_state), (photocraft_engine::prefs::PlaybackPerformance::Pause, 9, false)); +} + +#[test] +fn a_paced_play_clears_an_earlier_playback_error() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = app_with("step", 1, true); + app.ui.status = "Step 2 (layer.select) failed: no such layer".into(); + app.ui.status_error = true; + let mut h = Harness::builder().with_size(egui::vec2(400.0, 320.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(3); + h.get_by_label("Play selection").click(); + h.run_steps(3); + assert_eq!(names(h.state()), ["A"], "the play waits after its first step"); + assert!(h.state().paused_playbacks.timer.is_some()); + assert!(!h.state().ui.status_error, "the old failure is gone: {}", h.state().ui.status); + assert!(h.state().ui.status.is_empty(), "{}", h.state().ui.status); + // A status that isn't an error stays (a step's own message, say). + let mut app = app_with("step", 1, true); + app.ui.status = "Saved".into(); + play_targets(&mut app, &[0], 0).map(|v| report_play(&mut app, &v.unwrap())).unwrap(); + assert_eq!(app.ui.status, "Saved"); +} + +#[test] +fn playback_options_ok_refuses_values_automation_set_that_the_dialog_cannot_show() { + use photocraft_engine::prefs::PlaybackPerformance; + let control = |app: &mut PhotocraftApp, method: &str, params: Value| { + let (request, _) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &egui::Context::default(), &request) { + crate::control::Outcome::Done(v) => v, + _ => panic!("{method} answers at once"), + } + }; + for (field, value) in + [("performance", Value::Null), ("performance", json!("fast")), ("performance", json!(2)), ("singleState", Value::Null), ("singleState", json!("yes"))] + { + let mut app = app_with("step", 4, false); + let id = playback::open_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(control(&mut app, "ui.dialog.set", json!({"dialog": id, "field": field, "value": value}))["ok"], true); + let r = control(&mut app, "ui.dialog.confirm", json!({"dialog": id})); + assert!(r["error"].is_string(), "{field} = {value}: {r}"); + let a = &app.session.prefs().actions; + assert_eq!( + (a.performance, a.pause_seconds, a.create_single_history_state), + (PlaybackPerformance::StepByStep, 4, false), + "{field} = {value} saves nothing" + ); + } + // Valid values through the same path are saved. + let mut app = app_with("step", 4, false); + let id = playback::open_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap(); + control(&mut app, "ui.dialog.set", json!({"dialog": id, "field": "performance", "value": "accelerated"})); + control(&mut app, "ui.dialog.set", json!({"dialog": id, "field": "singleState", "value": true})); + assert_eq!(control(&mut app, "ui.dialog.confirm", json!({"dialog": id}))["ok"], true); + let a = &app.session.prefs().actions; + assert_eq!((a.performance, a.create_single_history_state), (PlaybackPerformance::Accelerated, true)); +} diff --git a/crates/ui-egui/src/panels/actions/record_again.rs b/crates/ui-egui/src/panels/actions/record_again.rs new file mode 100644 index 0000000000..d5eda23863 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/record_again.rs @@ -0,0 +1,133 @@ +//! One editor owns one original step; previews and confirmation retain its authority. +use photocraft_engine::{ + actions_edit_cmds, + doc::{DocId, LayerId}, +}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; + +const COMMAND: &str = "actions.recordAgain"; +const MARK: &str = "__recordAgain"; + +/// Selection, document and authority are private rather than editable dialog parameters. +pub(crate) struct Held { + dialog: u64, + selection: Value, + command: String, + document: Option<(DocId, u64, Option)>, + gate: Option, + automated: bool, +} + +/// The marker uses the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether this dialog id still owns a deferred panel operation. +pub(crate) fn owns_dialog(app: &PhotocraftApp, dialog: u64) -> bool { + app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) +} + +/// Apply captured and current permissions to the actual operation and final settings. +fn authorize(app: &PhotocraftApp, held: &Held, id: &str, params: &Value) -> Result<(), String> { + super::authorize_owned(app, id, params, held.gate, held.automated) +} + +/// Refuse stale selection or a different document before preview or confirmation can edit it. +fn unchanged(app: &PhotocraftApp, held: &Held) -> Result<(), String> { + super::suspended_guard(app, COMMAND)?; + if held.selection.get("revision").and_then(Value::as_u64) != Some(app.session.actions.rev) + || app.session.actions.recording.is_some() + || app.session.actions.playing > 0 + { + return Err(tl!("The actions changed. Request Record Again again.").into()); + } + if app.session.active().map(|s| (s.doc.id, s.revision, s.active_layer)) != held.document { + return Err(tl!("The document changed. Request Record Again again.").into()); + } + Ok(()) +} + +/// Open the existing command editor and seed its controls from this step's recorded values. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_record_again.is_some() { + return Err(tl!("Confirm or cancel the pending Record Again editor first.").into()); + } + let action = app.ui.actions.selected.ok_or(tl!("Select an action step first."))?; + let step = app.ui.actions.selected_step.ok_or(tl!("Select an action step first."))?; + let selection = json!({"action": action, "step": step, "revision": app.session.actions.rev}); + let target = actions_edit_cmds::record_again_target(&app.session, &selection).map_err(|e| e.to_string())?; + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let mut held = Held { + dialog: 0, + selection, + command: target.command, + document: app.session.active().map(|s| (s.doc.id, s.revision, s.active_layer)), + gate, + automated: app.automation_input || gate.is_some(), + }; + unchanged(app, &held)?; + authorize(app, &held, COMMAND, &held.selection)?; + authorize(app, &held, &held.command, &target.params)?; + let resolved = photocraft_engine::actions_cmds::playback_params(&app.session, &held.command, &target.params).map_err(|e| e.to_string())?; + app.session.precheck(&held.command, &resolved).map_err(|e| e.to_string())?; + let dialog = super::open_step_editor(app, &held.command)?; + let fields = &mut app.ui.dialog_mut(dialog).ok_or(tl!("The step editor could not be opened."))?.fields; + if let Some(params) = target.params.as_object() { + fields.extend(params.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + fields.insert(MARK.into(), json!(true)); + held.dialog = dialog; + app.held_record_again = Some(held); + Ok(json!({"dialog": dialog})) +} + +/// Guard both actual preview routes before parsing LUT files or evaluating a command. +pub(crate) fn authorize_preview(app: &PhotocraftApp, dialog: u64, command: &str, params: &Value) -> Result<(), String> { + let Some(held) = app.held_record_again.as_ref().filter(|h| h.dialog == dialog) else { return Ok(()) }; + unchanged(app, held)?; + if command != held.command { + return Err(tl!("The Record Again command changed.").into()); + } + authorize(app, held, command, params) +} + +/// Apply through the registered engine command, retaining flags and replacing only after success. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_record_again.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No recorded step belongs to this editor.").into()); + } + let held = app.held_record_again.take().ok_or(tl!("No step is waiting."))?; + unchanged(app, &held)?; + if fields.get("__command").and_then(Value::as_str) != Some(held.command.as_str()) + || fields.get("__adjust").and_then(Value::as_str).is_some_and(|kind| format!("image.adjustments.{kind}") != held.command) + { + return Err(tl!("The Record Again command changed.").into()); + } + let params = crate::filter_dialog::params_of(fields); + let mut request = held.selection.clone(); + request["params"] = params.clone(); + authorize(app, &held, COMMAND, &request)?; + authorize(app, &held, &held.command, ¶ms)?; + let saved = app.session.authorize; + // Different policy functions must not replace the original caller's nested-command gate. + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Record Again again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, request); + app.session.authorize = saved; + app.filter_preview = None; + result +} + +/// A different dialog cannot discard this editor's selection or authority. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) { + app.held_record_again = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/stop_tests.rs b/crates/ui-egui/src/panels/actions/stop_tests.rs new file mode 100644 index 0000000000..a1c7455d1a --- /dev/null +++ b/crates/ui-egui/src/panels/actions/stop_tests.rs @@ -0,0 +1,384 @@ +//! #8386: a Stop step with its dialog toggle on shows its message with Continue (when allowed) +//! and Stop; with the toggle off it ends the playback without a word; a pause inside a called +//! action continues into its callers. Behaviour measured in the reference application +//! (2026-11-05). +use super::*; +use photocraft_engine::atn_cmds::STOP_MESSAGE; + +const MESSAGE: &str = "Check the mask before continuing"; + +fn names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).filter(|n| n != "Background").collect() +} + +fn count(app: &PhotocraftApp, name: &str) -> usize { + names(app).iter().filter(|n| *n == name).count() +} + +/// `Stop Test` = Prefix, Stop, Suffix, saved to `.atn` and loaded back, so the step and its dialog +/// toggle come the way a recorded file brings them. +fn stop_app(allow: bool, with_dialog: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + let mut action = Action::new( + "Stop Test", + vec![ + ("layer.new.layer".into(), json!({"name": "Prefix"})), + (STOP_MESSAGE.into(), json!({"message": MESSAGE, "allowContinue": allow})), + ("layer.new.layer".into(), json!({"name": "Suffix"})), + ], + ); + if with_dialog { + action.dialogs.insert(1); + } + let (bytes, _) = photocraft_engine::atn_cmds::export("Stops", &[&action], &[], false).unwrap(); + app.run("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(app.session.actions.list[0].dialogs.contains(&1), with_dialog); + app +} + +/// Grandparent and Parent each make a prefix layer, call the next one in, then make a suffix. +fn nested_app(allow: bool) -> PhotocraftApp { + let mut app = stop_app(allow, true); + for (name, child) in [("Parent", "Stop Test"), ("Grandparent", "Parent")] { + app.session.actions.list.push(Action::new( + name, + vec![ + ("layer.new.layer".into(), json!({"name": format!("{name} prefix")})), + ("actions.play".into(), json!({"action": child})), + ("layer.new.layer".into(), json!({"name": format!("{name} suffix")})), + ], + )); + } + app +} + +fn assert_idle(app: &PhotocraftApp) { + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + assert!(app.paused_playbacks.paused.is_empty()); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn a_stop_with_its_dialog_shows_the_message_and_continue_or_stop_decides() { + for allow in [true, false] { + for confirm in [true, false] { + let mut app = stop_app(allow, true); + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + assert_eq!(v["ran"], 1, "{v}"); + assert_eq!(v["paused"]["step"], 1, "{v}"); + let id = v["paused"]["dialog"].as_u64().unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + assert!(owns_stop_message(&d.fields)); + assert_eq!(d.fields["message"], MESSAGE); + assert_eq!(d.fields["allowContinue"], allow); + assert_eq!(crate::dialogs::title(d), "Message"); + assert_eq!(names(&app), ["Prefix"], "the steps before the Stop stay applied"); + if confirm { + crate::dialogs::confirm(&mut app, id).unwrap(); + } else { + crate::dialogs::cancel(&mut app, id).unwrap(); + } + let continued = allow && confirm; + assert_eq!(names(&app), if continued { vec!["Prefix", "Suffix"] } else { vec!["Prefix"] }, "allow {allow}, confirm {confirm}"); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_idle(&app); + // One history state for the whole play, Continue or not. + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Action: Stop Test")); + assert!(app.session.undo()); + assert!(names(&app).is_empty()); + } + } +} + +#[test] +fn automation_cannot_turn_a_hard_stop_into_continue() { + let mut app = stop_app(false, true); + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("allowContinue".into(), json!(true)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(names(&app), ["Prefix"], "the step's own Allow Continue decides"); + assert_idle(&app); +} + +#[test] +fn a_stop_with_its_dialog_off_ends_the_playback_silently() { + for allow in [true, false] { + let mut app = stop_app(allow, false); + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + assert!(v.get("failed").is_none(), "no error: {v}"); + assert_eq!(v["stopped"]["step"], 1, "{v}"); + assert_eq!(names(&app), ["Prefix"]); + report_play(&mut app, &v); + assert!(!app.ui.status_error); + assert_eq!(app.ui.status, "Action playback stopped"); + assert_idle(&app); + } +} + +#[test] +fn agents_keep_the_engine_stop_semantics() { + for allow in [true, false] { + for with_dialog in [true, false] { + let mut app = stop_app(allow, with_dialog); + app.automation_input = true; + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + assert!(v.get("paused").is_none() && v.get("stopped").is_none(), "{v}"); + assert_eq!(v.get("failed").is_none(), allow, "{v}"); + assert_eq!(count(&app, "Suffix"), usize::from(allow)); + assert_idle(&app); + } + } +} + +#[test] +fn the_stop_dialog_shows_its_buttons_and_enter_answers_the_default_only_once_shown() { + use egui_kittest::{Harness, kittest::Queryable}; + for (allow, button) in [(true, Some("Continue")), (true, Some("Stop")), (false, None)] { + let app = stop_app(allow, true); + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + play(h.state_mut(), &json!({"action": "Stop Test"})).unwrap(); + // The Enter that started the play arrives with the dialog's first frame: it answers nothing. + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1, "allow {allow}"); + assert!(h.query_by_label(MESSAGE).is_some()); + assert_eq!(h.query_by_role_and_label(egui::accesskit::Role::Button, "Continue").is_some(), allow); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, "Stop").is_some()); + match button { + Some(label) => h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(), + None => h.key_press(egui::Key::Enter), + } + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty(), "allow {allow}, {button:?}"); + let continued = button == Some("Continue"); + assert_eq!(count(h.state(), "Suffix"), usize::from(continued)); + } +} + +#[test] +fn a_stop_restores_a_suspended_recording_once_for_every_exit() { + for allow in [true, false] { + for confirm in [true, false] { + let mut app = stop_app(allow, true); + app.run("actions.record", json!({"name": "Owner"})).unwrap(); + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + assert!(app.session.actions.recording.is_none()); + if confirm { + crate::dialogs::confirm(&mut app, id).unwrap(); + } else { + crate::dialogs::cancel(&mut app, id).unwrap(); + } + assert!(app.session.actions.recording.is_some()); + app.run("actions.stop", json!({})).unwrap(); + let owner = app.session.actions.list.iter().find(|a| a.name == "Owner").unwrap(); + assert_eq!(owner.steps, vec![("actions.play".into(), json!({"action": "Stop Test"}))]); + assert_idle(&app); + } + } +} + +#[test] +fn continue_inside_a_called_action_finishes_every_caller_once() { + let mut app = nested_app(true); + // A second pause in Parent, after the call, keeps Grandparent's continuation too. + let parent = app.session.actions.list.iter_mut().find(|a| a.name == "Parent").unwrap(); + parent.steps.insert(2, (STOP_MESSAGE.into(), json!({"message": "Parent pause", "allowContinue": true}))); + parent.dialogs.insert(2); + let history = app.session.active().unwrap().history.past_len(); + app.run("actions.record", json!({"name": "Owner"})).unwrap(); + let v = play(&mut app, &json!({"action": "Grandparent"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + assert_eq!(names(&app), ["Grandparent prefix", "Parent prefix", "Prefix"]); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(count(&app, "Suffix"), 1, "the child's suffix runs once"); + assert_eq!(app.ui.dialogs.len(), 1, "Parent goes on into its own Stop"); + assert_eq!(app.ui.dialogs[0].fields["message"], "Parent pause"); + assert!(app.session.actions.recording.is_none(), "recording stays suspended through both pauses"); + let id = app.ui.dialogs[0].id; + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_idle(&app); + let edits: Vec<_> = app.session.journal.iter().filter(|(id, _)| id == "layer.new.layer").map(|(_, p)| p["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(edits, ["Grandparent prefix", "Parent prefix", "Prefix", "Suffix", "Parent suffix", "Grandparent suffix"]); + // One history state for the whole call, named after the action that was played. + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), history + 1); + assert_eq!(st.history.undo_label(), Some("Action: Grandparent")); + assert!(app.session.actions.recording.is_some()); + app.run("actions.stop", json!({})).unwrap(); + let owner = app.session.actions.list.iter().find(|a| a.name == "Owner").unwrap(); + assert_eq!(owner.steps, vec![("actions.play".into(), json!({"action": "Grandparent"}))]); +} + +#[test] +fn stop_a_hard_stop_or_a_failing_child_ends_every_caller() { + for (allow, confirm, fail) in [(true, false, false), (false, true, false), (true, true, true)] { + let mut app = nested_app(allow); + if fail { + app.session.actions.list[0].steps[2] = ("layer.select".into(), json!({"layer": 999_999})); + } + let v = play(&mut app, &json!({"action": "Grandparent"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + if confirm { + let _ = crate::dialogs::confirm(&mut app, id); + } else { + crate::dialogs::cancel(&mut app, id).unwrap(); + } + assert!(!names(&app).iter().any(|n| n.ends_with("uffix")), "{allow} {confirm} {fail}: {:?}", names(&app)); + assert_eq!(app.ui.status_error, fail, "{}", app.ui.status); + assert_idle(&app); + } +} + +#[test] +fn a_silent_stop_inside_a_called_action_ends_its_callers() { + let mut app = nested_app(true); + app.session.actions.list[0].dialogs.clear(); + let v = play(&mut app, &json!({"action": "Grandparent"})).unwrap(); + assert!(v.get("failed").is_none(), "{v}"); + assert!(v["stopped"].is_object(), "{v}"); + assert_eq!(names(&app), ["Grandparent prefix", "Parent prefix", "Prefix"]); + assert_idle(&app); +} + +#[test] +fn a_resumed_called_action_still_refuses_to_call_its_paused_callers() { + let mut app = nested_app(true); + app.session.actions.list[0].steps[2] = ("actions.play".into(), json!({"action": "Grandparent"})); + let v = play(&mut app, &json!({"action": "Grandparent"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + let _ = crate::dialogs::confirm(&mut app, id); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("recursive"), "{}", app.ui.status); + assert_eq!(count(&app, "Grandparent prefix"), 1, "no caller's prefix runs again"); + assert!(!names(&app).iter().any(|n| n.ends_with("suffix"))); + assert_idle(&app); +} + +#[test] +fn a_filter_dialog_inside_a_called_action_continues_its_callers_after_ok() { + let mut app = nested_app(true); + app.session.actions.list[0].steps[1] = ("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})); + app.background_jobs = true; + let v = play(&mut app, &json!({"action": "Grandparent"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(names(&app), ["Grandparent prefix", "Parent prefix", "Prefix", "Suffix", "Parent suffix", "Grandparent suffix"]); + assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status); + assert_idle(&app); +} + +#[test] +fn a_caller_chain_is_bounded() { + let mut app = stop_app(true, true); + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + let dialog = v["paused"]["dialog"].clone(); + let token = paused_token(&mut app, &dialog).unwrap(); + app.paused_playbacks.paused.get_mut(&token).unwrap().callers = vec![("Missing".into(), 1); MAX_CALLERS]; + let r = called(&mut app, "One more", 0, "actions.play", &json!({"paused": v["paused"].clone()}), None); + assert!(matches!(r, Called::Failed(_)), "a caller past the bound fails the call"); + // A caller that no longer exists ends the resumed playback with an error, nothing else runs. + app.paused_playbacks.paused.get_mut(&token).unwrap().callers = vec![("Missing".into(), 1)]; + let id = dialog.as_u64().unwrap(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(count(&app, "Suffix"), 0); + assert_idle(&app); +} + +/// `If` makes Before, runs `Stop Test` when `condition` holds (`Other` when not), then makes After. +fn conditional_app(condition: &str) -> PhotocraftApp { + let mut app = stop_app(true, true); + app.session.actions.list.push(Action::new("Other", vec![("layer.new.layer".into(), json!({"name": "Other"}))])); + app.session.actions.list.push(Action::new( + "If", + vec![ + ("layer.new.layer".into(), json!({"name": "Before"})), + ("actions.conditional".into(), json!({"if": condition, "then": "Stop Test", "else": "Other"})), + ("layer.new.layer".into(), json!({"name": "After"})), + ], + )); + app +} + +#[test] +fn a_conditional_branch_plays_through_the_panel_and_its_stop_shows_its_message() { + for confirm in [true, false] { + let mut app = conditional_app("always"); + let v = play(&mut app, &json!({"action": "If"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap_or_else(|| panic!("the branch's Stop waits at its message: {v}")); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["message"], MESSAGE); + assert_eq!(names(&app), ["Before", "Prefix"]); + if confirm { + crate::dialogs::confirm(&mut app, id).unwrap(); + } else { + crate::dialogs::cancel(&mut app, id).unwrap(); + } + let expected: &[&str] = if confirm { &["Before", "Prefix", "Suffix", "After"] } else { &["Before", "Prefix"] }; + assert_eq!(names(&app), expected, "confirm {confirm}: Continue finishes the branch, then its caller"); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_idle(&app); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Action: If")); + } + // The other branch and a silent Stop in the branch, as for Play Action. + let mut app = conditional_app("never"); + let v = play(&mut app, &json!({"action": "If"})).unwrap(); + assert_eq!(v["ran"], 3, "{v}"); + assert_eq!(names(&app), ["Before", "Other", "After"]); + assert_idle(&app); + let mut app = conditional_app("always"); + app.session.actions.list[0].dialogs.clear(); + let v = play(&mut app, &json!({"action": "If"})).unwrap(); + assert!(v["stopped"].is_object() && v.get("failed").is_none(), "{v}"); + assert_eq!(names(&app), ["Before", "Prefix"], "a Stop without its dialog ends the caller too"); + assert_idle(&app); + // A bad condition still fails the step with the engine's error. + let mut app = conditional_app("sideways"); + let v = play(&mut app, &json!({"action": "If"})).unwrap(); + assert!(v["failed"]["error"].as_str().is_some_and(|e| e.contains("unknown condition")), "{v}"); + assert_eq!(names(&app), ["Before"]); + assert_idle(&app); +} + +#[test] +fn a_paused_action_replaced_under_the_same_name_does_not_continue() { + for replaced in ["Stop Test", "Grandparent"] { + let mut app = nested_app(true); + let v = play(&mut app, &json!({"action": "Grandparent"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + let i = app.session.actions.list.iter().position(|a| a.name == replaced).unwrap(); + let steps = (0..4).map(|n| ("layer.new.layer".to_string(), json!({"name": format!("Replacement {n}")}))).collect(); + app.session.actions.list[i] = Action::new(replaced, steps); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{replaced}"); + assert_eq!(names(&app), ["Grandparent prefix", "Parent prefix", "Prefix"], "{replaced}: nothing of either action's tail runs"); + assert!(app.ui.status_error && app.ui.status.contains("changed"), "{}", app.ui.status); + assert_idle(&app); + } +} + +#[test] +fn a_stop_message_continues_only_on_the_document_it_paused_on() { + // In both history modes: the binding comes from the history continuation, which the outermost + // playback captures whether or not it makes a single history state. + for (single, back) in [(true, false), (true, true), (false, false), (false, true)] { + let mut app = stop_app(true, true); + app.run("actions.setPlayback", json!({"performance": "accelerated", "singleState": single})).unwrap(); + let v = play(&mut app, &json!({"action": "Stop Test"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + if back { + app.run("document.activate", json!({"document": 0})).unwrap(); + } + let r = crate::dialogs::confirm(&mut app, id); + assert_eq!(r.is_ok(), back, "single {single}: {r:?}"); + assert_eq!(app.ui.status_error, !back, "single {single}: {}", app.ui.status); + app.run("document.activate", json!({"document": 0})).unwrap(); + assert_eq!(names(&app), if back { vec!["Prefix", "Suffix"] } else { vec!["Prefix"] }, "single {single}"); + app.run("document.activate", json!({"document": 1})).unwrap(); + assert!(names(&app).is_empty(), "the other document gets no step"); + assert_idle(&app); + } +} diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index cfe2214de2..d9711a4fc2 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -16,6 +16,214 @@ fn action_set_bytes(set: &str, action: &str) -> Vec { .unwrap() } +/// Dialog confirmation, worker completion and cancellation retain the original action boundary. +#[test] +fn single_history_dialog_confirmation_and_cancel() { + for (single, confirm, background) in [(true, true, false), (true, true, true), (true, false, false), (false, true, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap(); + app.run("actions.setHistory", json!({"singleState":single})).unwrap(); + app.background_jobs = background; + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); + let mut action = Action::new( + "Dialog", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("filter.blur.gaussianBlur".into(), json!({"radius":1})), + ("layer.renameLayer".into(), json!({"name":"Suffix"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + let paused = play(&mut app, &json!({"action":"Dialog"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), count + 1); + if confirm { + crate::dialogs::confirm(&mut app, dialog).unwrap(); + } else { + crate::dialogs::cancel(&mut app, dialog).unwrap(); + } + let st = app.session.active().unwrap(); + let after = st.doc.clone(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if confirm { "Suffix" } else { "Prefix" }); + if single { + assert_eq!(st.history.past_len(), count + 1); + assert_eq!(st.history.undo_label(), Some("Action: Dialog")); + } else { + assert!(st.history.past_len() >= count + 2); + } + assert!(app.session.undo()); + if single { + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } else { + assert_ne!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + assert!(app.session.redo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + let count = app.session.active().unwrap().history.past_len(); + app.session.execute("layer.renameLayer", json!({"name":"Outside"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), count + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// A stale dialog must reject its own edit before it can change an unrelated document/history. +#[test] +fn single_history_dialog_refuses_intervening_edit_or_document_switch() { + for (single_state, switch) in [(true, false), (true, true), (false, false), (false, true)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap(); + app.run("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let mut action = + Action::new("Bound", vec![("layer.renameLayer".into(), json!({"name":"Prefix"})), ("filter.blur.gaussianBlur".into(), json!({"radius":1}))]); + action.dialogs.insert(1); + app.session.actions.list.push(action); + let paused = play(&mut app, &json!({"action":0})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + if switch { + app.session.execute("file.new", json!({"width":2,"height":2})).unwrap(); + } else { + app.session.execute("layer.renameLayer", json!({"name":"Unrelated"})).unwrap(); + } + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.entries(); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err()); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before)); + assert_eq!(app.session.active().unwrap().history.entries(), history); + assert!(app.filter_preview.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } +} + +/// Prepare a real paused spelling step with a known document and history boundary, under either grouping policy. +fn paused_history_spelling(single: bool, gate: Option) -> (PhotocraftApp, std::sync::Arc, usize) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":300,"height":100})).unwrap(); + app.run("type.create", json!({"x":10,"y":50,"text":"Teh brwon"})).unwrap(); + app.run("actions.setHistory", json!({"singleState":single})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let past = app.session.active().unwrap().history.past_len(); + let mut action = Action::new( + "Spell", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("edit.checkSpelling".into(), json!({"allLayers":false})), + ("layer.renameLayer".into(), json!({"name":"Tail"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + app.session.authorize = gate; + let paused = play(&mut app, &json!({"action":"Spell"})).unwrap(); + assert_eq!(paused["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{paused}"); + (app, before, past) +} + +#[test] +/// Repeated corrections share one owned history entry; a rejected correction cannot lose earlier admitted edits. +fn paused_spelling_repeated_changes_and_errors_keep_one_owned_history() { + for single in [false, true] { + let (mut app, before, past) = paused_history_spelling(single, None); + let paused_doc = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.entries(); + assert!(spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"wrong","replace":"The"})).is_err()); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &paused_doc)); + assert_eq!(app.session.active().unwrap().history.entries(), rows); + for params in + [json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"}), json!({"action":"changeAll","word":"brwon","replace":"brown"})] + { + spelling_change(&mut app, params).unwrap(); + let token = app.ui.type_panels.spell.as_ref().unwrap().resume.unwrap(); + assert!(app.paused_playbacks.paused[&token].history.is_some()); + assert_eq!(app.session.actions.playing, 0); + } + let st = app.session.active().unwrap(); + let texts: Vec<_> = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, layer)| match &layer.content { + photocraft_doc::LayerContent::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["The brown"]); + let before_tail = st.doc.clone(); + crate::type_panels_ui::close_spelling(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(app.paused_playbacks.paused.is_empty()); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), past + if single { 1 } else { 4 }); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "Tail"); + if single { + assert_eq!(st.history.undo_label(), Some("Action: Spell")); + } + let after = st.doc.clone(); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), if single { before.as_ref() } else { before_tail.as_ref() }); + assert!(app.session.redo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + app.run("layer.renameLayer", json!({"name":"Outside"})).unwrap(); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +fn deny_second_spelling_change(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "edit.checkSpelling" && params["word"] == "brwon" { Err(photocraft_engine::EngineError::Other("original spelling gate".into())) } else { Ok(()) } +} + +#[test] +/// Reject a spelling continuation after document/history drift or captured-policy denial before any correction. +fn paused_spelling_rejects_external_edit_switch_and_original_gate_before_change() { + for single in [false, true] { + for scenario in ["edit", "switch", "gate"] { + let gate = (scenario == "gate").then_some(deny_second_spelling_change as Gate); + let (mut app, _, _) = paused_history_spelling(single, gate); + spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); + let corrected = app.session.active().unwrap().doc.clone(); + if scenario == "edit" { + let past = app.session.active().unwrap().history.past_len(); + app.run("layer.renameLayer", json!({"name":"External"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1, "idle edits have their own history"); + } else if scenario == "switch" { + app.run("file.new", json!({"width":4,"height":4})).unwrap(); + } + // The original playback gate still applies even if the live gate has gone away. + app.session.authorize = None; + let before: Vec<_> = app.session.documents().iter().map(|st| (st.doc.clone(), st.history.entries())).collect(); + let error = spelling_change(&mut app, json!({"action":"changeAll","word":"brwon","replace":"brown"})).unwrap_err(); + if scenario == "gate" { + assert!(error.contains("original spelling gate"), "{error}"); + } + for (state, (doc, rows)) in app.session.documents().iter().zip(&before) { + assert!(std::sync::Arc::ptr_eq(&state.doc, doc), "{scenario}"); + assert_eq!(&state.history.entries(), rows); + } + crate::type_panels_ui::close_spelling(&mut app); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + if scenario == "gate" { + assert_eq!(app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().name, "Tail"); + } else { + assert!(!app.session.documents().iter().any(|st| st.doc.walk().iter().any(|(_, _, layer)| layer.name == "Tail"))); + assert!(app.ui.status_error, "{}", app.ui.status); + if scenario == "edit" { + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref()); + } else { + assert!(app.session.documents().iter().any(|st| std::sync::Arc::ptr_eq(&st.doc, &corrected))); + } + } + } + } +} + #[test] /// Browser multi-selection must not be truncated to the first file. fn bulk_load_imports_every_chosen_action_file() { @@ -431,6 +639,7 @@ fn drag_steps_and_action_headers_reorders_and_keeps_selection() { assert_eq!(h.state().ui.actions.selected, Some(0)); } +/// Shell playback retains nested failures and Batch file outcomes without running later steps. #[test] fn nested_shell_actions_include_view_steps_and_propagate_failures() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -456,6 +665,42 @@ fn nested_shell_actions_include_view_steps_and_propagate_failures() { assert!(r["failed"]["error"].as_str().unwrap().contains("denied nested view")); assert!(app.session.actions.playback_stack.is_empty()); assert_eq!(app.session.actions.playing, 0); + + app.session.authorize = None; + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("valid.png"); + let corrupt = dir.path().join("corrupt.png"); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "png", &Default::default()).unwrap().bytes; + std::fs::write(&input, bytes).unwrap(); + std::fs::write(&corrupt, b"not a PNG").unwrap(); + let before = app.session.documents().len(); + for mixed in [true, false] { + let output = dir.path().join(if mixed { "mixed" } else { "success" }); + let inputs = if mixed { vec![&input, &corrupt] } else { vec![&input] }; + let batch = json!({"input": inputs, "output": output, "steps": [], "format": "png"}); + let index = app.session.actions.list.len(); + let name = if mixed { "Mixed Batch" } else { "Successful Batch" }; + app.session.actions.list.push(Action::new(name, vec![("file.automate.batch".into(), batch), ("file.new".into(), json!({"width": 2, "height": 2}))])); + let result = app.run("actions.play", json!({"action": index})).unwrap(); + if mixed { + assert_eq!(result["ran"], 0, "{result}"); + let batch = &result["failed"]["result"]; + let files = batch["files"].as_array().unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(std::path::Path::new(files[0].as_str().unwrap()), output.join("valid.png").as_path()); + assert_eq!(batch["errors"].as_array().unwrap().len(), 1); + assert_eq!(batch["errors"][0]["file"], json!(corrupt)); + report_play(&mut app, &result); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("1 file(s) failed") && app.ui.status.contains("1 saved") && app.ui.status.contains("corrupt.png")); + assert_eq!(app.session.documents().len(), before, "a failed Batch stops before file.new"); + } else { + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(result["ran"], 2); + assert_eq!(app.session.documents().len(), before + 1, "a successful Batch continues"); + } + assert!(output.join("valid.png").exists(), "successful files remain available"); + } } #[test] @@ -1108,10 +1353,12 @@ fn a_set_paused_at_a_dialog_ends_at_an_action_replaced_under_its_name() { /// the called action resumes alone, so the next action of the set must not run on a caller that /// never finished. #[test] -fn a_set_paused_inside_a_called_action_stops_there() { +fn a_set_paused_inside_a_called_action_continues_into_its_caller_then_the_set() { use egui_kittest::kittest::Queryable; let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); let mut a = Action::new("A", vec![("actions.play".into(), json!({"action": "C"})), ("layer.new.layer".into(), json!({"name": "a-tail"}))]); a.set = Some("S".into()); let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "b"}))]); @@ -1128,7 +1375,21 @@ fn a_set_paused_inside_a_called_action_stops_there() { let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("C's blur dialog"); crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); h.run_steps(2); - assert_eq!(layer_names(h.state()), ["c"], "C finished; B didn't run ahead of A's tail"); + // #8386, measured: confirming a dialog inside a called action continues the caller after + // the call, and the set's next action after that. + assert_eq!(layer_names(h.state()), ["c", "a-tail", "b"], "C finished, then A's tail, then B"); + // One history state per played action: A with its call into C, then B. + assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 2); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Action: B")); + let after = h.state().session.active().unwrap().doc.clone(); + assert!(h.state_mut().session.undo()); + assert_eq!(layer_names(h.state()), ["c", "a-tail"]); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Action: A")); + assert!(h.state_mut().session.undo()); + assert_eq!(h.state().session.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(h.state_mut().session.redo()); + assert!(h.state_mut().session.redo()); + assert_eq!(h.state().session.active().unwrap().doc.as_ref(), after.as_ref()); } /// #10459 fix-up: a set folder activated without a pointer (keyboard, accessibility) still @@ -1523,6 +1784,7 @@ fn insert_stop_dialog_adds_a_stop_after_the_selected_step() { crate::dialogs::confirm(&mut app, id).unwrap(); let steps = &app.session.actions.list[0].steps; assert_eq!(steps[1], (photocraft_engine::atn_cmds::STOP_MESSAGE.to_string(), json!({"message": "Check the mask", "allowContinue": true}))); + assert!(app.session.actions.list[0].dialogs.contains(&1), "an inserted Stop has its dialog toggle on"); assert_eq!((app.ui.actions.selected, app.ui.actions.selected_step), (Some(0), Some(1))); assert_eq!(step_label(&steps[1].0, &steps[1].1), "Stop: Check the mask"); // Without a selected step the stop goes last; the panel's menu wiring is exercised by name. @@ -1774,6 +2036,37 @@ fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { assert!(v.get("paused").is_none()); } +#[test] +fn equalize_step_with_a_selection_opens_its_choices() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 8, "width": 16, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": [0.5, 0.5, 0.5]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + let mut action = Action::new("Equalize", vec![(crate::equalize_ui::COMMAND.into(), json!({}))]); + action.dialogs.insert(0); + app.session.actions.list.push(action); + let gray = |app: &PhotocraftApp, x: i32, y: i32| { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[0] + }; + + let result = play(&mut app, &json!({"action": "Equalize"})).unwrap(); + let id = result["paused"]["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert!(crate::equalize_ui::owns(fields)); + assert_eq!(fields["selectedAreaOnly"], true); + assert!((gray(&app, 12, 1) - 0.4).abs() < 0.01, "nothing changes before OK"); + + // OK with the entire image chosen maps the pixels outside the selection too. + app.ui.dialog_mut(id).unwrap().fields.insert("selectedAreaOnly".into(), json!(false)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(gray(&app, 12, 1) < 0.05 && gray(&app, 12, 12) > 0.95, "outside the selection too"); +} + /// An 8×6 document, a Child action whose first step (a blur) shows its dialog and whose /// second step adds a "Suffix" layer, and recording into Owner started. fn recording_with_dialog_child() -> PhotocraftApp { @@ -2029,6 +2322,465 @@ fn deny_blur(id: &str, _: &Value) -> photocraft_engine::Result<()> { if id == "filter.blur.gaussianBlur" { Err(photocraft_engine::EngineError::Other("blur denied".into())) } else { Ok(()) } } +/// Permit the recorded small blur but reject an edited radius, so admission is tested against final dialog parameters. +fn deny_wide_blur(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" && params["radius"].as_f64().is_some_and(|radius| radius > 1.0) { + Err(photocraft_engine::EngineError::Other("edited blur radius denied".into())) + } else { + Ok(()) + } +} + +/// Build a two-tone raster whose blur changes pixels, with a real action step configured to open its dialog. +fn gated_blur_fixture(single: bool, background: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16, "background": "#000000"})).unwrap(); + app.run("select.rect", json!({"x": 8, "y": 0, "width": 8, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("actions.setHistory", json!({"singleState": single})).unwrap(); + app.background_jobs = background; + let mut action = Action::new("Gated blur", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1}))]); + action.dialogs.insert(0); + app.session.actions.list.push(action); + app +} + +/// Simulate a temporary control gate: capture it at the pause and release the session gate before later confirmation. +fn pause_gated_blur(app: &mut PhotocraftApp, gate: Option) -> u64 { + app.session.authorize = gate; + let paused = play(app, &json!({"action": "Gated blur"})).unwrap(); + app.session.authorize = None; // The temporary control gate ends before a later human OK. + paused["paused"]["dialog"].as_u64().unwrap() +} + +#[test] +/// Denied edited parameters must change neither pixels nor history and must admit no background worker. +fn a_paused_dialog_authorizes_edited_params_before_history_or_worker_admission() { + for (single, background) in [(true, false), (true, true), (false, false)] { + let mut app = gated_blur_fixture(single, background); + let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur)); + app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(4)); + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + let count = app.session.active().unwrap().history.past_len(); + let error = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert!(error.contains("edited blur radius denied"), "{error}"); + let state = app.session.active().unwrap(); + assert_eq!(state.doc.as_ref(), before.as_ref(), "denial changes no pixels"); + assert_eq!(state.revision, revision); + assert_eq!(state.history.past_len(), count); + assert!(app.session.job_on(state.doc.id).is_none(), "no worker was admitted"); + assert!(app.session.authorize.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + app.run("layer.renameLayer", json!({"name": "Outside"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), count + 1, "no action scope leaked"); + } +} + +#[test] +/// Allowed captured edits and unrelated manual dialogs retain their normal synchronous and worker behavior. +fn an_allowed_paused_edit_and_an_ordinary_manual_dialog_still_run() { + for (paused, background) in [(true, false), (true, true), (false, false)] { + let mut app = gated_blur_fixture(true, background); + let before = app.session.active().unwrap().doc.clone(); + let dialog = if paused { + pause_gated_blur(&mut app, Some(deny_wide_blur)) + } else { + crate::dialogs::open_command_dialog(&mut app, "filter.blur.gaussianBlur", "Gaussian Blur") + }; + app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(if paused { 1 } else { 4 })); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let state = app.session.active().unwrap(); + assert_ne!(state.doc.layers[0].surface().unwrap().pixel(7, 8), before.layers[0].surface().unwrap().pixel(7, 8)); + assert!(app.session.job_on(state.doc.id).is_none(), "the resumed action waited for its worker"); + assert!(app.session.authorize.is_none(), "the transient captured gate was restored"); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } +} + +#[test] +/// A paused action's captured permission cannot bypass a stricter live policy on confirmation. +fn a_stricter_current_gate_still_denies_the_captured_dialog_edit() { + for service in [false, true] { + let mut app = gated_blur_fixture(true, true); + let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur)); + let before = app.session.active().unwrap().doc.clone(); + if service { + app.automation_input = true; + app.services.automation_authorize = Some(deny_blur); + } else { + app.session.authorize = Some(deny_blur); + } + let error = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert!(error.contains("blur denied"), "{error}"); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + if service { + assert!(app.session.authorize.is_none()); + } else { + assert!(app.session.authorize.unwrap()("filter.blur.gaussianBlur", &json!({"radius": 1})).is_err(), "the live gate was restored"); + } + assert!(app.paused_playbacks.paused.is_empty()); + } +} + +#[test] +/// Spelling corrections consult live Session and automation-service policies before resuming the owned history. +fn paused_spelling_checks_the_live_gate_before_resuming_history() { + fn deny_correction(id: &str, p: &Value) -> photocraft_engine::Result<()> { + if id == "edit.checkSpelling" && p["action"] == "changeAll" { Err(photocraft_engine::EngineError::Other("live spelling gate".into())) } else { Ok(()) } + } + for service in 0..3 { + let (mut app, _, _) = paused_history_spelling(true, Some(deny_second_spelling_change)); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.entries(); + let token = app.ui.type_panels.spell.as_ref().unwrap().resume.unwrap(); + if service != 1 { + app.session.authorize = None; + } + match service { + 0 => app.session.authorize = Some(deny_correction), + 1 => { + app.automation_input = true; + app.services.automation_authorize = Some(deny_correction); + } + _ => { + app.automation_input = true; + app.services.automation_command = Some(Box::new(|id, _| if id == "edit.checkSpelling" { Err("live spelling gate".into()) } else { Ok(()) })); + } + } + assert!(spelling_change(&mut app, json!({"action":"changeAll","word":"Teh","replace":"The"})).is_err()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(app.session.active().unwrap().history.entries(), rows); + assert!(app.paused_playbacks.paused.get(&token).unwrap().history.is_some(), "denial must leave the bound continuation untouched"); + assert!(app.session.execute("actions.setHistory", json!({"singleState":false})).is_err()); + let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]); + abandoned(&mut app, &fields); + app.session.execute("actions.setHistory", json!({"singleState":false})).unwrap(); + } +} + +#[test] +/// A changed authority context cancels both direct and nested tails while keeping the admitted prefix undoable. +fn a_changed_live_gate_cannot_be_replaced_for_the_remaining_action() { + fn deny_tail(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "layer.renameLayer" { Err(photocraft_engine::EngineError::Other("live tail denied".into())) } else { Ok(()) } + } + for service in [false, true] { + for nested in [false, true] { + let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur)); + if nested { + app.session.actions.list.push(Action::new("Nested tail", vec![("layer.renameLayer".into(), json!({"name":"Forbidden tail"}))])); + app.session.actions.list[0].steps[2] = ("actions.play".into(), json!({"action":"Nested tail"})); + } + spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); + let corrected = app.session.active().unwrap().doc.clone(); + if service { + app.automation_input = true; + app.services.automation_authorize = Some(deny_tail); + } else { + app.session.authorize = Some(deny_tail); + } + crate::type_panels_ui::close_spelling(&mut app); + assert!(app.ui.status_error && app.ui.status.contains("authorization context changed"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref(), "the admitted prefix remains, with no tail edit"); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } + } +} + +#[test] +/// Distinct captured/live gates fail before applying the dialog or starting a worker, even if the edit is allowed. +fn a_changed_context_is_rejected_before_dialog_or_worker_admission() { + fn allow_all(_: &str, _: &Value) -> photocraft_engine::Result<()> { + Ok(()) + } + for background in [false, true] { + let mut app = gated_blur_fixture(true, background); + let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur)); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.entries(); + app.session.authorize = Some(allow_all); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err_and(|e| e.contains("authorization context changed"))); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(app.session.active().unwrap().history.entries(), rows); + assert!(app.session.job_on(before.id).is_none()); + } +} + +#[test] +/// The same gate, a released temporary gate and a newly installed gate permit supported continuation paths. +fn unchanged_and_released_gates_allow_the_action_tail() { + for scenario in ["released", "same", "new"] { + let mut app = gated_blur_fixture(true, false); + app.session.actions.list[0].steps.push(("layer.renameLayer".into(), json!({"name":"Allowed tail"}))); + let dialog = pause_gated_blur(&mut app, (scenario != "new").then_some(deny_wide_blur as Gate)); + match scenario { + "same" | "new" => app.session.authorize = Some(deny_wide_blur), + _ => {} + } + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let state = app.session.active().unwrap(); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().name, "Allowed tail"); + } +} + +#[test] +/// Distinct configured policies permit manual confirmation but cannot be combined for automated dispatch. +/// The identical-gate automated control proves the supported composed-policy path still completes and undoes once. +fn configured_gates_require_one_composed_policy_for_automated_resume() { + fn deny_file_open(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "file.open" { Err(photocraft_engine::EngineError::Other("file open denied".into())) } else { Ok(()) } + } + assert!(!std::ptr::fn_addr_eq(deny_wide_blur as Gate, deny_file_open as Gate)); + for (automated, identical) in [(false, false), (true, false), (true, true)] { + let mut app = gated_blur_fixture(true, false); + app.session.actions.list[0].steps.push(("layer.renameLayer".into(), json!({"name":"Allowed tail"}))); + app.session.authorize = Some(deny_wide_blur); + let service: Gate = if identical { deny_wide_blur } else { deny_file_open }; + app.services.automation_authorize = Some(service); + // The service is configured before the real local pause, but becomes effective only + // for the automated OK. Both callbacks allow this edit and its remaining command. + for gate in [deny_wide_blur as Gate, deny_file_open as Gate] { + gate("filter.blur.gaussianBlur", &json!({"radius":1})).unwrap(); + gate("layer.renameLayer", &json!({"name":"Allowed tail"})).unwrap(); + } + let dialog = play(&mut app, &json!({"action":"Gated blur"})).unwrap()["paused"]["dialog"].as_u64().unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.past_len(); + app.automation_input = automated; + let result = crate::dialogs::confirm(&mut app, dialog); + let state = app.session.active().unwrap(); + if automated && !identical { + assert!(result.is_err_and(|error| error.contains("policies that cannot be combined"))); + assert_eq!(state.doc.as_ref(), before.as_ref()); + assert_eq!(state.history.past_len(), rows); + assert!(app.session.job_on(before.id).is_none()); + } else { + result.unwrap(); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().name, "Allowed tail"); + assert_eq!(state.history.past_len(), rows + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + assert!(std::ptr::fn_addr_eq(app.session.authorize.unwrap(), deny_wide_blur as Gate)); + assert!(std::ptr::fn_addr_eq(app.services.automation_authorize.unwrap(), service)); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } +} + +#[test] +/// A policy change after the final spelling step must not discard completed edits when no tail remains to dispatch. +fn a_changed_gate_after_the_last_spelling_step_keeps_completed_edits() { + let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur)); + app.session.actions.list[0].steps.truncate(2); + spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); + let corrected = app.session.active().unwrap().doc.clone(); + app.session.authorize = Some(|_, _| Ok(())); + crate::type_panels_ui::close_spelling(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref()); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); +} + +#[test] +/// Implicit layer-owned adjustments reject a receiver switch; explicit receivers change only their intended surface. +/// An omitted target separately inherits the mask captured when its dialog opens. +fn paused_adjustments_bind_implicit_pixel_and_mask_receivers() { + for targeting in ["implicit", "null", "explicit"] { + for mask in [false, true] { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.run("edit.fill", json!({"color":"#808080"})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("edit.fill", json!({"color":"#808080","target":"mask"})).unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap()); + // Native mask eligibility checks the active layer, so keep both receivers eligible. + // The assertions below still require the explicitly named A mask alone to change. + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.session.select_layer(a).unwrap(); + let mut params = json!({"brightness":40,"target":"pixels"}); + if mask { + params["target"] = json!("mask"); + } + if targeting == "explicit" { + params["layer"] = json!(a.0); + } else if targeting == "null" { + params["layer"] = Value::Null; + } + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), params); + let dialog = pause_gated_blur(&mut app, None); + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + if targeting == "explicit" { + result.unwrap(); + let after = &app.session.active().unwrap().doc; + assert_eq!(after.layer(b).unwrap(), before.layer(b).unwrap()); + if mask { + assert_ne!(after.layer(a).unwrap().mask, before.layer(a).unwrap().mask); + assert_eq!(after.layer(a).unwrap().surface(), before.layer(a).unwrap().surface()); + } else { + assert_ne!(after.layer(a).unwrap().surface(), before.layer(a).unwrap().surface()); + assert_eq!(after.layer(a).unwrap().mask, before.layer(a).unwrap().mask); + } + } else { + assert!(result.is_err_and(|e| e.contains("receiver changed"))); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + } + } + + // Batch 16 review: a dialog paused on a document target (an alpha channel) and switched to + // the layer's pixels checks the layer that was active at the pause. + { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap()); + app.session.select_layer(a).unwrap(); + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40,"target":{"channel":0}})); + let dialog = pause_gated_blur(&mut app, None); + app.ui.dialog_mut(dialog).unwrap().fields.insert("target".into(), json!("pixels")); + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + assert!(result.is_err_and(|e| e.contains("receiver changed"))); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + + // An omitted target inherits the mask selected when the paused dialog opens. This is + // distinct from native playback without a dialog, which resolves the current channel. + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("edit.fill", json!({"color":"#808080","target":"mask"})).unwrap(); + assert!(app.layer_mask_targeted()); + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40})); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.past_len(); + let dialog = pause_gated_blur(&mut app, None); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["target"], "mask"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let state = app.session.active().unwrap(); + assert_ne!(state.doc.layer(a).unwrap().mask, before.layer(a).unwrap().mask); + assert_eq!(state.doc.layer(a).unwrap().surface(), before.layer(a).unwrap().surface()); + assert_eq!(state.history.past_len(), rows + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); +} + +#[test] +/// Document-owned alpha channels remain valid targets when the active layer changes during the pause. +fn a_document_channel_adjustment_survives_an_active_layer_change() { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap()); + app.run("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + app.run("edit.fill", json!({"color":"#808080","target":{"channel":0}})).unwrap(); + app.session.select_layer(a).unwrap(); + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40,"target":{"channel":0}})); + let dialog = pause_gated_blur(&mut app, None); + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let after = &app.session.active().unwrap().doc; + assert_eq!(after.layers, before.layers); + assert_ne!(after.channels, before.channels); +} + +#[test] +/// A public Session layer switch invalidates an implicit receiver while explicit-layer playback remains usable. +fn an_implicit_paused_filter_refuses_a_public_api_receiver_change() { + for single in [false, true] { + for explicit in [false, true] { + let mut app = gated_blur_fixture(single, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.session.select_layer(a).unwrap(); + if explicit { + app.session.actions.list[0].steps[0].1["layer"] = json!(a.0); + } + let dialog = pause_gated_blur(&mut app, None); + // Supported host/session API, not a modal-blocked native menu/control command. + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + let after = &app.session.active().unwrap().doc; + if explicit { + result.unwrap(); + assert_ne!(after.layer(a).unwrap().surface().unwrap().pixel(7, 8), before.layer(a).unwrap().surface().unwrap().pixel(7, 8)); + assert_eq!(after.layer(b).unwrap().surface().unwrap().pixel(7, 8), before.layer(b).unwrap().surface().unwrap().pixel(7, 8)); + } else { + assert!(result.is_err_and(|e| e.contains("receiver changed"))); + assert_eq!(after.as_ref(), before.as_ref(), "neither layer may be edited after stale implicit targeting"); + } + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } + } +} + +#[test] +/// Changes to other selected layers do not invalidate the unchanged active receiver captured by a filter dialog. +fn an_implicit_filter_keeps_its_receiver_when_only_other_selected_layers_change() { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().unwrap()); + app.session.select_layer(a).unwrap(); + let dialog = pause_gated_blur(&mut app, None); + app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + let before = app.session.active().unwrap().doc.clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let after = &app.session.active().unwrap().doc; + assert_ne!(after.layer(a).unwrap().surface().unwrap().pixel(7, 8), before.layer(a).unwrap().surface().unwrap().pixel(7, 8)); + assert_eq!(after.layer(b).unwrap().surface().unwrap().content_bounds(), before.layer(b).unwrap().surface().unwrap().content_bounds()); +} + +fn deny_displace_file(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.distort.displace" && params.get("mapPath").is_some() { + Err(photocraft_engine::EngineError::Other("chosen displacement map denied".into())) + } else { + Ok(()) + } +} + +#[test] +/// Authorize a deferred displacement-map path before file access, worker admission or continuation history changes. +fn a_deferred_displace_map_is_authorized_before_reading_or_resuming_history() { + let mut app = recording_with_displace_child(); + app.background_jobs = true; + let (show, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec!["denied-map.png".into()]))]); + app.services.file_dialog = Some(show); + app.session.authorize = Some(deny_displace_file); + let dialog = pause_child(&mut app); + app.session.authorize = None; + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.file_dialog_open()); + app.poll_file_dialog(&egui::Context::default(), None); + assert!(app.ui.status.contains("chosen displacement map denied"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(app.session.active().unwrap().history.past_len(), count); + assert!(app.session.authorize.is_none()); + assert!(app.session.job_on(before.id).is_none()); + assert_displace_call_finished(&mut app, 0); +} + #[test] fn a_step_the_gate_denies_opens_no_dialog() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/adjust_ui.rs b/crates/ui-egui/src/panels/adjust_ui.rs index 2d70592c52..1074cbfd24 100644 --- a/crates/ui-egui/src/panels/adjust_ui.rs +++ b/crates/ui-egui/src/panels/adjust_ui.rs @@ -1,85 +1,18 @@ -//! Properties-panel editors for the Selective Color and Color Lookup adjustment layers. +//! Properties-panel editor for the Color Lookup adjustment layer. //! -//! Both commit through `layer.setAdjustment` (which merges these kinds' params into the current -//! values). Selective Color previews live while a slider drags, via `app.live_adjust` carrying -//! the full parameter set; Color Lookup commits on every change (its table isn't in the params). +//! It commits through `layer.setAdjustment` (which merges the params into the current values) on +//! every change, since its table isn't in the params. The other kinds, Selective Color included, +//! are edited by `adjust_editors`. use std::sync::{Arc, Mutex, PoisonError}; use photocraft_doc::{Adjustment, LayerId}; -use photocraft_engine::adjust_cmds::RANGES; use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::theme::Tokens; use crate::widgets; -const RANGE_LABELS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"]; - -/// The full parameter set of a Selective Color adjustment (every range as `[c, m, y, k]`). -pub fn selective_values(adj: &Adjustment) -> Value { - let Adjustment::SelectiveColor { relative, adjustments } = adj else { - return json!({}); - }; - let mut v = json!({"method": if *relative { "relative" } else { "absolute" }}); - for (i, key) in RANGES.iter().enumerate() { - v[*key] = json!(adjustments[i]); - } - v -} - -pub fn selective_color_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) { - let t = Tokens::get(ui.ctx()); - let committed = selective_values(adj); - let mut values = match &app.live_adjust { - Some((l, v)) if *l == id && v.get("reds").is_some() => v.clone(), - _ => committed, - }; - let key = egui::Id::unique(("selc-range", id.0)); - let mut range: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Colors")).color(t.text_dim)); - let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect(); - if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), tl!("Color"), &mut range, &opts, 150.0) { - ui.data_mut(|d| d.insert_temp(key, range)); - } - }); - ui.add_space(4.0); - let mut live = false; - let mut commit = false; - for (k, label) in [tl!("Cyan"), tl!("Magenta"), tl!("Yellow"), tl!("Black")].iter().enumerate() { - let mut v = values[RANGES[range]][k].as_f64().unwrap_or(0.0) as f32; - let r = widgets::slider_row(ui, label, &mut v, -100.0..=100.0, "%", None); - if r.changed() { - values[RANGES[range]][k] = json!(v.round()); - live = true; - } - if r.drag_stopped() || (r.changed() && !r.dragged()) { - commit = true; - } - } - ui.add_space(4.0); - let mut method = values["method"].as_str().unwrap_or("relative").to_string(); - ui.horizontal(|ui| { - for (m, label) in [("relative", tl!("Relative")), ("absolute", tl!("Absolute"))] { - if ui.radio(method == m, label).clicked() && method != m { - method = m.to_string(); - values["method"] = json!(m); - commit = true; - } - } - }); - if live { - app.live_adjust = Some((id, values.clone())); - } - if commit { - let mut p = values; - p["layer"] = json!(id.0); - let _ = app.run("layer.setAdjustment", p); - app.live_adjust = None; - } -} - /// Ask the platform for one LUT. Send embedded text through the same engine command on /// desktop and web, so the result doesn't depend on ambient filesystem permissions. fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result { diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d618999d9f..9c07f56255 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -1,11 +1,13 @@ -use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets}; +use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, starts_blend_group, vec2, widgets}; /// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the -/// mode. Returns the mode picked this frame. +/// mode. Returns the mode picked this frame. A list of every blend mode draws the Layers panel's +/// group lines (#5532; Clear starts none); a short one (the Healing Brush's) draws none. pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { let opts: Vec<(Option, &str)> = modes.iter().map(|m| (*m, m.map_or("Clear", BlendMode::label))).collect(); + let grouped = BlendMode::LAYER_MODES.iter().all(|m| modes.contains(&Some(*m))); let mut mode = current; - let chosen = widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0; + let chosen = widgets::dropdown_wheel_grouped(ui, id, tl!("Mode"), &mut mode, &opts, 96.0, |m| grouped && m.is_some_and(starts_blend_group)).0; chosen.last().copied() } @@ -21,7 +23,7 @@ pub(super) fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraf } else { (Some(b.mode), false) }; - if !widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0.is_empty() { + if !widgets::dropdown_wheel_grouped(ui, id, tl!("Mode"), &mut mode, &opts, 96.0, |m| m.0.is_some_and(starts_blend_group)).0.is_empty() { *clear = mode == (None, false); b.behind = mode.1; if mode.1 { @@ -34,21 +36,27 @@ pub(super) fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraf } /// Options-bar Smoothing % (the brush's stroke smoothing; the live stroke and the commit use it). -pub(super) fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) { +pub(super) fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) -> egui::Response { let mut sm = (b.smoothing.amount * 100.0).round(); - if widgets::value_field_named(ui, tl!("Smoothing"), &mut sm, 0.0..=100.0, "%", width).changed() { + let r = widgets::value_field_named(ui, tl!("Smoothing"), &mut sm, 0.0..=100.0, "%", width); + if r.changed() { b.smoothing.enabled = true; b.smoothing.amount = (sm / 100.0).clamp(0.0, 1.0); } + r } /// Options-bar gear beside Smoothing: Photoshop's smoothing options popup, edited on the brush /// like the Smoothing % (so they stay per tool and reach the live stroke and the commit). -pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) { +/// `brush` adds the Brush's "Smoothing Options" title (#11813); the Eraser keeps its popup as it was. +pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, brush: bool) { let resp = icons::button(ui, "settings", 24.0, false, tl!("Set additional smoothing options")); let resp = crate::brush_picker::named(resp, "Set additional smoothing options"); egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let s = &mut b.smoothing; + if brush { + ui.label(egui::RichText::new(tl!("Smoothing Options")).strong()); + } widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End")); @@ -117,7 +125,9 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker"))); - if resp.on_hover_text(tl!("Brush Preset picker")).clicked() { + // The Brush's own text (#11813); the other tools keep theirs. + let tip = if state.tool == crate::state::Tool::Brush { tl!("Click to open the Brush Preset picker") } else { tl!("Brush Preset picker") }; + if resp.on_hover_text(tip).clicked() { if state.brush_picker.is_some() { crate::brush_picker::close(state); } else { diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs index e0e15cb4f5..164443600c 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1876,3 +1876,48 @@ fn checkboxes_show_a_pressed_state_from_their_label() { assert!(*h.state(), "the release checks it"); assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t))); } + +#[test] +fn tip_geometry_edits_and_arrow_drag_route_only_changed_fields_through_the_brush_command() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"angle": -23.0, "roundness": 0.473, "hardness": 0.65, "spacing": 0.31, "flipX": true}})).unwrap(); + h.run_steps(3); + let mut expected = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.angle = -22.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": -22.0_f32})); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.roundness = 0.48; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"roundness": 0.48_f32})); + let c = h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap(); + let a = expected.angle.to_radians(); + h.drag_at(c + vec2(a.cos(), -a.sin()) * 35.0); + h.run_steps(1); + h.hover_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + h.drop_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + expected.angle = 90.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": 90.0_f32})); + assert_eq!(h.state().session.journal.len(), journal + 3, "one command per field edit and arrow gesture"); +} diff --git a/crates/ui-egui/src/panels/brush_preview.rs b/crates/ui-egui/src/panels/brush_preview.rs index f77975d45c..ba345d51e7 100644 --- a/crates/ui-egui/src/panels/brush_preview.rs +++ b/crates/ui-egui/src/panels/brush_preview.rs @@ -296,7 +296,9 @@ mod tests { #[test] fn preview_draws_a_stroke_that_fits() { - let mut b = BrushSettings { size: 400.0, ..Default::default() }; + // The preview's S-curve carries a pressure ramp, so the coverage window below is a + // tapering stroke's: Pressure for Size is off by default since #11809, so the brush asks. + let mut b = BrushSettings { size: 400.0, pressure_size: true, ..Default::default() }; let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]); assert_eq!(px.len(), 120 * 40 * 4); let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).count(); diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index da96b7474a..7026f83352 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -325,26 +325,31 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], }); ui.add_space(4.0); ui.horizontal(|ui| { - ellipse_widget(ui, &mut b.angle, &mut b.roundness); - ui.add_space(8.0); - ui.vertical(|ui| { + // Reserve the widget's column so both numeric rows share the same right edge. + let fields_width = (ui.available_width() - 84.0 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(vec2(fields_width, 84.0), egui::Layout::top_down(egui::Align::Max), |ui| { ui.add_space(10.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); - let mut a = b.angle; - if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { - b.angle = a.round().clamp(-180.0, 180.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut a = b.angle; + if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { + b.angle = a.round().clamp(-180.0, 180.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); + }); }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); - let mut r = (b.roundness * 100.0).round(); - if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { - b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut r = (b.roundness * 100.0).round(); + if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { + b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); + }); }); }); + ellipse_widget(ui, &mut b.angle, &mut b.roundness); }); ui.add_space(6.0); ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index b9a221f04e..abed333e02 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -25,8 +25,12 @@ fn minimum_brush() -> BrushSettings { } fn section_harness(section: usize, b: BrushSettings) -> Harness<'static, BrushSettings> { + section_harness_width(section, 340.0, b) +} + +fn section_harness_width(section: usize, width: f32, b: BrushSettings) -> Harness<'static, BrushSettings> { let mut ready = false; - let mut h = Harness::builder().with_size(vec2(340.0, 1100.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(width, 1100.0)).build_ui_state( move |ui, b: &mut BrushSettings| { if !ready { crate::PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); @@ -335,3 +339,100 @@ fn ellipse_widget_drags_angle_and_roundness() { let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(500.0, 0.0), 90.0, 0.2); assert_eq!(r, 1.0); } + +fn tip_geometry_center(h: &Harness<'static, BrushSettings>) -> egui::Pos2 { + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap() +} + +#[test] +fn tip_geometry_fields_precede_the_widget_and_labels_align_in_all_themes() { + for kind in theme::ThemeKind::ALL { + for width in [280.0, 340.0] { + let b = BrushSettings { angle: -23.0, roundness: 0.473, ..Default::default() }; + let mut h = section_harness_width(0, width, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let centre = tip_geometry_center(&h); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap().rect(); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap().rect(); + let angle_label = caption_rect(&h, "Angle:"); + let roundness_label = caption_rect(&h, "Roundness:"); + assert!(angle.right() + 2.0 < centre.x - 40.0 && roundness.right() + 2.0 < centre.x - 40.0, "{kind:?}, {width}: fields belong left of the widget"); + assert!((angle.left() - roundness.left()).abs() < 0.5, "{kind:?}, {width}: one numeric column"); + assert!((angle_label.right() - roundness_label.right()).abs() < 0.5, "{kind:?}, {width}: labels align to the right"); + assert!((angle_label.center().y - angle.center().y).abs() < 2.0 && (roundness_label.center().y - roundness.center().y).abs() < 2.0); + assert_eq!(h.state(), &b, "layout does not round imported values"); + } + } +} + +#[test] +fn tip_geometry_numeric_edits_keep_units_limits_and_other_settings() { + let mut b = BrushSettings { angle: -23.0, roundness: 0.473, hardness: 0.65, spacing: 0.31, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + angle.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.angle = -22.0; + assert_eq!(h.state(), &b); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + roundness.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.roundness = 0.48; + assert_eq!(h.state(), &b, "percentage edits retain degrees and the rest of the brush"); + for (angle, key) in [(-180.0, egui::Key::ArrowDown), (180.0, egui::Key::ArrowUp)] { + b.angle = angle; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(angle))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "angle stays within -180..180 degrees"); + } + for (value, key) in [(1.0, egui::Key::ArrowDown), (100.0, egui::Key::ArrowUp)] { + b.roundness = value / 100.0; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(value))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "roundness stays within 1..100 percent"); + } +} + +#[test] +fn tip_geometry_pointer_drags_keep_angle_and_roundness_independent() { + let mut b = BrushSettings { roundness: 0.5, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let c = tip_geometry_center(&h); + drag(&mut h, c + vec2(35.0, 0.0), c + vec2(0.0, -35.0)); + b.angle = 90.0; + assert_eq!(h.state(), &b, "the arrow changes only the angle"); + let handle = c + roundness_handles(b.angle, b.roundness)[0]; + // Acquire the dot before moving beyond its hit radius. + h.hover_at(handle); + h.run_steps(2); + h.drag_at(handle); + h.run_steps(1); + h.hover_at(handle + vec2(-6.5, 0.0)); + h.run_steps(2); + h.hover_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + h.drop_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + b.roundness = 0.8; + assert!((h.state().roundness - b.roundness).abs() < 1e-5, "a real handle drag keeps percentage units"); + b.roundness = h.state().roundness; + assert_eq!(h.state(), &b, "the dot changes only the roundness"); +} diff --git a/crates/ui-egui/src/panels/brushes_tab/tests.rs b/crates/ui-egui/src/panels/brushes_tab/tests.rs index 7f8de2b568..81437f53a1 100644 --- a/crates/ui-egui/src/panels/brushes_tab/tests.rs +++ b/crates/ui-egui/src/panels/brushes_tab/tests.rs @@ -12,6 +12,7 @@ fn dragging_a_brush_shows_its_tip_preview() { for theme in crate::theme::ThemeKind::ALL { for mode in 0..3 { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = false); app.ui.tool = crate::Tool::Brush; app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); app.ui.panels.brush_settings = mode != 2; @@ -58,6 +59,7 @@ fn dragging_a_brush_shows_its_tip_preview() { #[test] fn dragging_a_brush_to_the_list_edge_scrolls_the_list() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = false); app.ui.panels.brush_settings = true; app.ui.brush_tab = 1; app.ui.brushes_panel.view = BrushesView::List; @@ -409,6 +411,7 @@ fn the_preview_slider_scales_the_previews_not_the_brush_size() { let mut sizes = Vec::new(); for scale in [0.5f32, 1.0, 2.0] { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = false); app.ui.panels.brush_settings = true; app.ui.brush_tab = 1; app.ui.brushes_panel.view = if grid { BrushesView::Grid } else { BrushesView::List }; diff --git a/crates/ui-egui/src/panels/channels_panel.rs b/crates/ui-egui/src/panels/channels_panel.rs index 3baa1b99bf..fd6ad08178 100644 --- a/crates/ui-egui/src/panels/channels_panel.rs +++ b/crates/ui-egui/src/panels/channels_panel.rs @@ -90,13 +90,15 @@ struct RowInteractions<'a> { masked: Option<&'a photocraft_doc::Layer>, actions: &'a mut Vec<(String, Value)>, mask_click: &'a mut Option, + /// Whether Merge Channels… can run (enough grayscale documents of one size). + merge_enabled: bool, } fn handle_row_interactions(ui: &mut egui::Ui, mut row: RowInteractions<'_>) { let rename_id = egui::Id::unique(("chan-rename", row.doc.id.0, format!("{:?}", row.row.reference()))); handle_channel_row_click(ui, &mut row); handle_channel_row_double_click(ui, &mut row, rename_id); - row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.actions)); + row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.merge_enabled, row.actions)); } fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) { @@ -167,7 +169,14 @@ fn handle_alpha_double_click(ui: &mut egui::Ui, row: &mut RowInteractions<'_>, i } } -fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, masked: Option<&photocraft_doc::Layer>, actions: &mut Vec<(String, Value)>) { +fn draw_row_menu( + ui: &mut egui::Ui, + row: Row, + doc: &photocraft_doc::Document, + masked: Option<&photocraft_doc::Layer>, + merge_enabled: bool, + actions: &mut Vec<(String, Value)>, +) { crate::widgets::menu_scroll(ui, |ui| { ui.set_min_width(210.0); match row { @@ -177,11 +186,11 @@ fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, ma Row::LayerMask => draw_mask_menu(ui, masked, actions), } ui.separator(); - item(ui, actions, "New Channel…", "channel.new", json!({})); + item(ui, actions, "New Channel…", "ui.newChannel", json!({})); item(ui, actions, "New Spot Channel…", "ui.newSpotChannel", json!({})); ui.separator(); item(ui, actions, "Split Channels", "channel.split", json!({})); - item(ui, actions, "Merge Channels…", "channel.merge", json!({})); + item_enabled(ui, actions, "Merge Channels…", "ui.mergeChannels", json!({}), merge_enabled); }); } @@ -288,14 +297,14 @@ fn row_presentation(row: Row, context: &RowPresentationContext<'_>, mask_visible !context.view.quick_mask_hidden, context.view.target == ChannelTarget::Composite, ), - Row::LayerMask => { - (context.masked.map_or_else(String::new, |layer| format!("{} Mask", layer.name)), usize::MAX, None, mask_visible, context.mask_targeted) - } + Row::LayerMask => (context.masked.map_or_else(String::new, mask_row_name), usize::MAX, None, mask_visible, context.mask_targeted), }; RowPresentation { name, thumb_idx, slot, visible, selected } } pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // Greyed in the rows' menu as in the panel menu and the command palette. + let merge_enabled = crate::menus::is_enabled(app, "channel.merge"); let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { ui.add_space(6.0); @@ -316,10 +325,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The active layer's mask (Photoshop lists it, in italics, below the colour channels). let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()); let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?))); - let mask_targeted = masked.is_some() - && (app.ui.mask_target || view.target == ChannelTarget::LayerMask) - && (view.target == ChannelTarget::Composite || view.target == ChannelTarget::LayerMask) - && !quick; + let mask_targeted = mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick); // Multichannel images are their ink channels only (no composite or colour rows). let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel; let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] }; @@ -436,6 +442,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { masked, actions: &mut actions, mask_click: &mut mask_click, + merge_enabled, }, ); } @@ -467,13 +474,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }; // Photoshop's order from the left: Load as selection, Save selection, New, Delete. widgets::panel_footer(ui, |ui| { - let target_ref = match view.target { - ChannelTarget::Alpha(i) => json!(i), - ChannelTarget::Color(k) => json!({ "color": k }), - ChannelTarget::Composite => json!("composite"), - ChannelTarget::LayerMask => json!("mask"), - ChannelTarget::VectorMask => json!("vectorMask"), - }; + let target_ref = target_reference(view.target); let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")); if trash.clicked() { actions.push(("channel.delete".into(), json!({}))); @@ -510,6 +511,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } + // The row menu's New Channel… and Merge Channels… ask first, as the panel menu's do + // (the footer's New button makes a channel at once). + let asks = match id.as_str() { + "ui.newChannel" => Some("channel.new"), + "ui.mergeChannels" => Some("channel.merge"), + _ => None, + }; + if let Some(cmd) = asks { + if let Err(e) = crate::menus::invoke(app, &ctx, cmd, json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + continue; + } if id == "ui.newSpotChannel" { crate::channel_options_ui::open_new_spot(app); continue; @@ -526,6 +541,91 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The engine reference of the targeted channel (`channel.*` and `select.loadSelection` take it). +fn target_reference(target: ChannelTarget) -> Value { + match target { + ChannelTarget::Alpha(i) => json!(i), + ChannelTarget::Color(k) => json!({ "color": k }), + ChannelTarget::Composite => json!("composite"), + ChannelTarget::LayerMask => json!("mask"), + ChannelTarget::VectorMask => json!("vectorMask"), + } +} + +/// Whether the selected layer's mask row is the one shown targeted: the layer has a mask, and it +/// (not the composite) is what painting edits, outside Quick Mask. +fn mask_row_targeted(has_mask: bool, mask_target: bool, target: ChannelTarget, quick: bool) -> bool { + has_mask && (mask_target || target == ChannelTarget::LayerMask) && matches!(target, ChannelTarget::Composite | ChannelTarget::LayerMask) && !quick +} + +/// The layer mask row's name, " Mask", as Photoshop lists it. +fn mask_row_name(layer: &photocraft_doc::Layer) -> String { + format!("{} Mask", layer.name) +} + +/// The Channels panel's own items at the top of its panel menu (☰, #11451), in Photoshop's +/// order: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, +/// Channel Options…, Split Channels, Merge Channels…. The ones that act on one channel take the +/// targeted row and are greyed without one (a colour composite, several colour channels). New +/// Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs, as from +/// the command palette; Split Channels runs at once. +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + use crate::layers_panel_ui::run_menu_command; + let target = MenuTarget::of(app); + run_menu_command(app, ui, tl!("New Channel…"), "channel.new", Some(json!({}))); + run_menu_command(app, ui, tl!("Duplicate Channel"), "channel.duplicate", target.duplicate); + run_menu_command(app, ui, tl!("Delete Channel"), "channel.delete", target.alpha.map(|i| json!({ "channel": i }))); + ui.separator(); + run_menu_command(app, ui, tl!("New Spot Channel…"), "channel.newSpot", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Spot Channel"), "channel.mergeSpot", target.spot.map(|i| json!({ "channel": i }))); + run_menu_command(app, ui, tl!("Channel Options…"), "channel.options", target.options.then(|| json!({}))); + ui.separator(); + run_menu_command(app, ui, tl!("Split Channels"), "channel.split", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Channels…"), "channel.merge", Some(json!({}))); +} + +/// What the panel menu's single-channel items act on: the targeted row. +#[derive(Default)] +struct MenuTarget { + /// The `channel.duplicate` params: the targeted row as its footer's New button copies it. + duplicate: Option, + /// The targeted alpha or spot channel. + alpha: Option, + /// The targeted spot channel. + spot: Option, + /// Whether Channel Options… has something to show (an alpha / spot channel, Quick Mask or a + /// layer mask), as the menu bar's command decides. + options: bool, +} + +impl MenuTarget { + fn of(app: &PhotocraftApp) -> Self { + let mask = app.layer_mask_targeted(); + let Some(st) = app.session.active() else { return Self::default() }; + let view = &st.channel_view; + let alpha = match view.target { + ChannelTarget::Alpha(i) if i < st.doc.channels.len() => Some(i), + _ => None, + }; + let quick = st.doc.quick_mask.is_some(); + let masked = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| l.mask.is_some()); + let duplicate = match view.target { + // The composite targeted in Quick Mask mode: the Quick Mask row is the selected one. + ChannelTarget::Composite if quick => Some(json!({ "channel": "quickMask" })), + _ if mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick) => { + masked.and_then(|layer| drop_action(Row::LayerMask, &mask_row_name(layer), Some(layer.id.0), Footer::New)).map(|(_, p)| p) + } + ChannelTarget::Alpha(_) if alpha.is_some() => Some(json!({ "channel": target_reference(view.target) })), + ChannelTarget::Color(_) if view.selected_colors.len() <= 1 => Some(json!({ "channel": target_reference(view.target) })), + // A one-channel image (Grayscale, Bitmap, Duotone): the composite is its channel. + ChannelTarget::Composite if channel_cmds::color_count(&st.doc) == 1 => Some(json!({ "channel": "composite" })), + _ => None, + }; + let spot = alpha.filter(|&i| st.doc.channels.get(i).is_some_and(|c| c.spot.is_some())); + Self { duplicate, alpha, spot, options: alpha.is_some() || quick || mask } + } +} + /// The footer buttons a row can be dropped on. #[derive(Clone, Copy)] enum Footer { @@ -575,7 +675,12 @@ pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) { } fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) { - if ui.button(tl!(&label)).clicked() { + item_enabled(ui, actions, label, cmd, p, true); +} + +/// [`item`], greyed unless `enabled`. +fn item_enabled(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value, enabled: bool) { + if ui.add_enabled(enabled, egui::Button::new(tl!(&label))).clicked() { actions.push((cmd.into(), p)); ui.close(); } diff --git a/crates/ui-egui/src/panels/channels_panel/tests.rs b/crates/ui-egui/src/panels/channels_panel/tests.rs index 3d57762d03..123efd3ade 100644 --- a/crates/ui-egui/src/panels/channels_panel/tests.rs +++ b/crates/ui-egui/src/panels/channels_panel/tests.rs @@ -440,3 +440,212 @@ fn recording_a_colour_row_click_saves_as_photoshop_select() { assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}"); } } + +/// The panel menu's single-channel items act on the targeted channel (#11451): a targeted spot +/// channel can be duplicated, given options, merged or deleted; a colour channel duplicated. +#[test] +fn panel_menu_items_act_on_the_targeted_channel() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "fromSelection": false})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(!h.get_by_label(label).accesskit_node().is_disabled(), "{label} acts on the targeted spot channel"); + } + h.get_by_label("Channel Options…").click(); + h.run_steps(2); + assert!(h.state().ui.dialogs.iter().any(|d| crate::channel_options_ui::owns(&d.fields) && d.fields["channel"] == json!(0))); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink", "Ink copy"]); + h.get_by_label("Delete Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy"], "the targeted channel is deleted"); + h.state_mut().run("channel.target", json!({"channel": {"color": 1}})).unwrap(); + h.run_steps(2); + assert!(h.get_by_label("Delete Channel").accesskit_node().is_disabled(), "colour channels aren't deleted here"); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy", "Green copy"]); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +fn alpha_names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect() +} + +fn menu_app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24, "mode": mode})).unwrap(); + app +} + +fn menu_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + h +} + +fn menu_disabled(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + use egui_kittest::kittest::{NodeT, Queryable}; + h.get_by_label(label).accesskit_node().is_disabled() +} + +fn menu_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, label: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_label(label).click(); + h.run_steps(2); + assert!(!h.state().ui.status_error, "{label}: {}", h.state().ui.status); +} + +/// A grayscale image's composite is its one channel: Duplicate Channel copies it, as the row +/// menu does; an RGB composite stays greyed. +#[test] +fn panel_menu_duplicates_the_gray_composite() { + assert!(menu_disabled(&menu_harness(menu_app("rgb")), "Duplicate Channel"), "nothing to duplicate on the RGB composite"); + let mut h = menu_harness(menu_app("grayscale")); + assert!(!menu_disabled(&h, "Duplicate Channel")); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Gray copy"]); +} + +/// Duplicate Channel copies a targeted Quick Mask or layer mask, as dropping the row on New does +/// (the mask's copy is " Mask copy"). +#[test] +fn panel_menu_duplicates_quick_mask_and_layer_mask() { + let mut app = menu_app("rgb"); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the Quick Mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(h.state().session.active().unwrap().doc.channels.len(), 1); + + let mut app = menu_app("rgb"); + app.run("layer.new.layer", json!({"name": "Masked"})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the layer mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Masked Mask copy"]); +} + +/// In Quick Mask mode, Channel Options… on a targeted alpha channel opens that channel's options, +/// from the panel menu and the command palette (`menus::invoke`) alike; with the composite +/// targeted it is Quick Mask's. +#[test] +fn channel_options_in_quick_mask_take_the_targeted_alpha() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!("quickMask"), "the composite targeted: Quick Mask Options"); + app.ui.dialogs.clear(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!(0), "the dispatcher opens the alpha's options"); + app.ui.dialogs.clear(); + let mut h = menu_harness(app); + menu_click(&mut h, "Channel Options…"); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["channel"], json!(0), "so does the panel menu"); +} + +/// Duplicate Channel waits for one channel: not several colour channels at once. Without a +/// document every item is greyed. +#[test] +fn panel_menu_greys_items_without_one_channel_or_a_document() { + let mut app = menu_app("rgb"); + app.run("channel.target", json!({"channel": {"color": 0}})).unwrap(); + app.run("channel.target", json!({"channel": {"color": 1}, "extend": true})).unwrap(); + assert_eq!(app.session.active().unwrap().channel_view.selected_colors.len(), 2); + assert!(menu_disabled(&menu_harness(app), "Duplicate Channel"), "two colour channels are targeted"); + let h = menu_harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + for label in [ + "New Channel…", + "Duplicate Channel", + "Delete Channel", + "New Spot Channel…", + "Merge Spot Channel", + "Channel Options…", + "Split Channels", + "Merge Channels…", + ] { + assert!(menu_disabled(&h, label), "{label} waits for a document"); + } +} + +/// Merge Spot Channel prints the targeted spot channel into the image and removes it: a solid +/// blue ink over the left half turns those pixels blue and leaves the rest white. +#[test] +fn panel_menu_merges_the_targeted_spot_channel() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "color": "#0000ff", "solidity": 100})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("channel.target", json!({"channel": 1})).unwrap(); + let pixel = |app: &PhotocraftApp, x: usize, y: usize| { + let doc = &app.session.active().unwrap().doc; + let comp = photocraft_compose::render(doc, doc.bounds()); + let p = comp.px[y * doc.size.width as usize + x]; + [p[0], p[1], p[2]].map(|v| (v * 255.0).round() as u8) + }; + assert_eq!((pixel(&app, 4, 4), pixel(&app, 24, 4)), ([255, 255, 255], [255, 255, 255]), "the ink is not in the image yet"); + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Spot Channel"); + assert_eq!(alpha_names(h.state()), ["Alpha 1"], "the spot channel is merged, the alpha kept"); + assert_eq!(pixel(h.state(), 4, 4), [0, 0, 255], "the ink is printed where the channel had it"); + assert_eq!(pixel(h.state(), 24, 4), [255, 255, 255], "and nowhere else"); +} + +/// Merge Channels… asks first, as in Photoshop: the dialog picks the mode and the document for +/// each channel; nothing merges until OK. With params the command runs directly. +#[test] +fn panel_menu_merge_channels_opens_its_dialog() { + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Channels…"); + assert_eq!(h.state().session.documents().len(), 3, "nothing is merged before OK"); + let d = h.state().ui.dialogs.last().expect("the Merge Channels dialog"); + assert_eq!(d.fields["__label"], json!("Merge Channels…")); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("mode".into(), json!("lab")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 1, "the sources close"); + assert_eq!(docs[0].doc.mode, photocraft_doc::ColorMode::Lab, "in the mode picked"); + + let ctx = egui::Context::default(); + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let r = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap(); + assert!(r["dialog"].is_u64() && app.session.documents().len() == 3, "the dispatcher asks too"); + app.ui.dialogs.clear(); + crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({"mode": "rgb"})).unwrap(); + assert!(app.ui.dialogs.is_empty() && app.session.documents().len() == 1, "explicit params merge directly"); +} + +/// Two grayscale documents fill no mode: Merge Channels… is greyed in the panel menu, and the +/// dispatcher says why instead of opening the dialog; a third one enables it. +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut app = menu_app("grayscale"); + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + let ctx = egui::Context::default(); + let why = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap_err(); + assert!(why.contains("three") && app.ui.dialogs.is_empty(), "{why}"); + let mut h = menu_harness(app); + assert!(menu_disabled(&h, "Merge Channels…"), "two documents are not enough"); + h.state_mut().run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + h.run_steps(2); + assert!(!menu_disabled(&h, "Merge Channels…"), "three are"); +} diff --git a/crates/ui-egui/src/panels/chrome_ui.rs b/crates/ui-egui/src/panels/chrome_ui.rs index 82a70fd408..27251b2b51 100644 --- a/crates/ui-egui/src/panels/chrome_ui.rs +++ b/crates/ui-egui/src/panels/chrome_ui.rs @@ -18,6 +18,13 @@ pub struct ChromeState { /// Home screen shown over the open documents (Options bar › Home); holds the document count /// when it was opened, so opening or creating a document leaves it. pub home: Option, + /// View mode for recent files on the Home screen: true = visual thumbnail card grid, false = text list. + #[serde(default = "yes")] + pub home_recent_grid: bool, +} + +fn yes() -> bool { + true } impl ChromeState { @@ -37,7 +44,7 @@ impl ChromeState { impl Default for ChromeState { fn default() -> Self { - Self { status_info: "dimensions".into(), home: None } + Self { status_info: "dimensions".into(), home: None, home_recent_grid: true } } } diff --git a/crates/ui-egui/src/panels/color_panel_ui.rs b/crates/ui-egui/src/panels/color_panel_ui.rs index 22665b2150..e05bac9440 100644 --- a/crates/ui-egui/src/panels/color_panel_ui.rs +++ b/crates/ui-egui/src/panels/color_panel_ui.rs @@ -352,8 +352,8 @@ pub(crate) fn alerts(app: &mut PhotocraftApp, ui: &mut egui::Ui, col: Rect, rgb: let background = app.ui.color_panel.background; let mut y = col.top() + CHIPS_W + 2.0; let warnings = [ - (out_of_gamut(rgb), "Out of gamut for printing: click for the nearest printable colour", false), - (if web { not_web_safe(rgb) } else { None }, "Not a web colour: click for the nearest web colour", true), + (out_of_gamut(rgb), "Out of gamut for printing: click for the nearest printable color", false), + (if web { not_web_safe(rgb) } else { None }, "Not a web color: click for the nearest web color", true), ]; for (slot, (near, tip, cube)) in warnings.into_iter().enumerate() { let Some(near) = near else { continue }; diff --git a/crates/ui-egui/src/panels/color_tests.rs b/crates/ui-egui/src/panels/color_tests.rs index 30ecfaf3e7..9900221099 100644 --- a/crates/ui-egui/src/panels/color_tests.rs +++ b/crates/ui-egui/src/panels/color_tests.rs @@ -160,11 +160,11 @@ fn studio_color_chips_keep_default_and_swap_buttons_below() { ); PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(2); - h.get_by_label("Swap colours (X)").click(); + h.get_by_label("Switch Foreground and Background Colors (X)").click(); h.run_steps(2); assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0], "{kind:?}"); assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0], "{kind:?}"); - h.get_by_label("Default colours (D)").click(); + h.get_by_label("Default Foreground and Background Colors (D)").click(); h.run_steps(2); assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0], "{kind:?}"); assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "{kind:?}"); @@ -219,7 +219,7 @@ fn swap_and_default_colors_recolour_selected_type() { ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); h.run_steps(2); - h.get_by_label("Swap colours (X)").click(); + h.get_by_label("Switch Foreground and Background Colors (X)").click(); h.run_steps(2); assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0]); assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0]); @@ -228,7 +228,7 @@ fn swap_and_default_colors_recolour_selected_type() { assert_eq!((rgb(h.state(), id, 6), rgb(h.state(), id, 10)), (blue, blue), "selected: {selected}"); // Default Colors: the selection takes black, the new foreground. h.state_mut().run("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); - h.get_by_label("Default colours (D)").click(); + h.get_by_label("Default Foreground and Background Colors (D)").click(); h.run_steps(2); assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); assert_eq!(rgb(h.state(), id, 6), [0, 0, 0, 255], "selected: {selected}"); diff --git a/crates/ui-egui/src/panels/compact_visual_regressions.rs b/crates/ui-egui/src/panels/compact_visual_regressions.rs index c4fdf59abc..40845fa460 100644 --- a/crates/ui-egui/src/panels/compact_visual_regressions.rs +++ b/crates/ui-egui/src/panels/compact_visual_regressions.rs @@ -94,7 +94,7 @@ fn tall_toolbar_keeps_the_colour_chips_under_the_tools() { for kind in theme::ThemeKind::ALL { let harness = toolbar_harness_sized(kind, 1.0, 1600.0); let last = harness.get_by_label("Edit Toolbar…").rect(); - let swap = harness.get_by_label("Swap colours (X)").rect(); + let swap = harness.get_by_label("Switch Foreground and Background Colors (X)").rect(); assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}"); } } diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 939ef07e6b..38d3797014 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -144,9 +144,11 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } let can_snap = app.session.is_enabled("history.newSnapshot"); - // ⌥-click opens New Snapshot (name, From) instead, as in Photoshop. + // ⌥-click opens New Snapshot (name, From) instead, as in Photoshop; with History + // Options › Show New Snapshot Dialog by Default a plain click does too. + let dialog_by_default = photocraft_engine::history_options_cmds::show_new_snapshot_dialog(&app.session); if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { - if ui.input(|i| i.modifiers.alt) { + if dialog_by_default || ui.input(|i| i.modifiers.alt) { snapshot_dialog = true; } else { footer_cmd = Some(("history.newSnapshot", json!({}))); @@ -160,11 +162,11 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ctx.input(|i| i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(history_drag_key())); } - // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under - // its box would leave it transforming pixels that changed. if snapshot_dialog { crate::snapshot_dialog::open(app); } + // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under + // its box would leave it transforming pixels that changed. // Selecting a snapshot changes the document, so it waits for an open transform like the rows. let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); if let Some((cmd, p)) = footer_cmd.filter(|_| app.ui.transform.is_none()).or(snap_cmd) { @@ -253,17 +255,18 @@ pub(crate) fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::Doc live.then_some(id) } -/// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and -/// name: a click returns to the snapshot (a new state) and selects it, a double-click or -/// right-click › Rename… edits the name in place (Enter or a click elsewhere keeps it, Escape -/// cancels), right-click › Delete removes it, and a drag onto the trash too (#5324). Returns the -/// command a row asks for. +/// The History panel's snapshot rows (#2966), at the top of the panel (the first is the document +/// as opened), each with its thumbnail and name: a click returns to the snapshot (a new state) +/// and selects it, a double-click or right-click › Rename… edits the name in place (Enter or a +/// click elsewhere keeps it, Escape cancels), right-click › Delete removes it, and a drag onto the +/// trash too (#5324). Returns the command a row asks for. fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option) -> Option<(&'static str, Value)> { let t = Tokens::get(ui.ctx()); let st = app.session.active()?; let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); let source = photocraft_engine::history_source_cmds::source(st); let live: Vec = snaps.iter().map(|s| s.id).collect(); + app.retain_snapshot_thumbs(doc_id, &live); // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. let rename_id = egui::Id::unique(("history-snapshot-rename", doc_id)); let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten(); @@ -283,7 +286,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option bool { if cmd == "window.panel.close" { return !app.session.prefs().workspace_locked; } - // An open Free Transform owns the history (see `panels::history`). - app.ui.transform.is_none() && app.session.is_enabled(cmd) + // An open Free Transform owns the history (see `panels::history`), and a drawn crop frame + // holds its jumps back (`crop_ui::holds_history`). + app.ui.transform.is_none() && !crate::crop_ui::holds_history(app, cmd) && app.session.is_enabled(cmd) } /// Draw the History items of the panel menu and run the one clicked. @@ -158,6 +159,26 @@ mod tests { } } + /// A drawn crop frame greys the items that move the document under it (#9285). + #[test] + fn jumps_wait_for_a_drawn_crop_frame() { + let mut app = app(); + app.ui.tool = crate::state::Tool::Crop; + app.ui.crop_rect = Some([4.0, 4.0, 40.0, 30.0]); + app.crop.default_frame = false; + app.session.execute("history.stepBackward", json!({})).unwrap(); + let mut h = harness(app); + for label in ["Step Backward", "Step Forward", "Delete"] { + assert!(is_disabled(&h, label), "{label}"); + } + assert!(!is_disabled(&h, "New Snapshot…"), "a snapshot leaves the document as it is"); + h.state_mut().crop.default_frame = true; + h.run_steps(2); + assert!(!is_disabled(&h, "Step Backward"), "the tool's own frame around the canvas holds nothing back"); + click(&mut h, "Step Backward"); + assert_eq!(states(&h), (2, 2)); + } + #[test] fn new_snapshot_adds_a_snapshot_and_leaves_the_states() { let mut h = harness(app()); diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 56a60e82d0..eeca9db7e3 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -23,6 +23,12 @@ pub(crate) fn pointer_sample(app: &mut PhotocraftApp, (x, y): (i32, i32)) -> Opt Some(v) } +/// The Info panel's W and H for a size of `wh` px (the selection's or the pending crop frame's): +/// both go through the ruler unit's formatter at `dpi`, percent of the document's `extent`. +pub(crate) fn wh_readout(ur: &photocraft_engine::prefs::UnitsAndRulers, dpi: f64, extent: [f64; 2], wh: [f64; 2]) -> [String; 2] { + [ur.format(wh[0], dpi, extent[0]), ur.format(wh[1], dpi, extent[1])] +} + /// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size. pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -37,8 +43,12 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let dpi = st.doc.resolution_dpi as f64; let fx = move |px: f64| units.format(px, dpi, size.width as f64); let units_y = app.session.prefs().units_and_rulers.clone(); + let units_wh = app.session.prefs().units_and_rulers.clone(); let fy = move |px: f64| units_y.format(px, dpi, size.height as f64); - let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds); + let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds).map(|r| [r.width() as f64, r.height() as f64]); + // With the Crop tool, W and H are the pending frame's, as in Photoshop (#3145). + let crop = crate::crop_ui::info_size(app); + let wh = crop.or(sel); let pos = hover_pixel(app); // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). @@ -75,8 +85,9 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Measured from the rulers' zero point (#2955). row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); - row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); - row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); + let [w, h] = wh.map_or_else(|| [dash(), dash()], |s| wh_readout(&units_wh, dpi, [size.width as f64, size.height as f64], s)); + row(&mut cols[1], "W:", w); + row(&mut cols[1], "H:", h); }); widgets::hairline(ui); ui.label( diff --git a/crates/ui-egui/src/panels/layer_drag.rs b/crates/ui-egui/src/panels/layer_drag.rs index f778ccec75..aea09ab6ee 100644 --- a/crates/ui-egui/src/panels/layer_drag.rs +++ b/crates/ui-egui/src/panels/layer_drag.rs @@ -154,6 +154,22 @@ pub(super) fn layer_drag_and_drop( "below" }; let Some(st) = app.session.active() else { return }; + if position == "into" && l.frame.is_some() { + let selected = st.selected_layers(); + if selected.len() > 1 && selected.iter().any(|id| id.0 == dragged) { + return; + } + let source = LayerId(dragged); + if st.doc.layer(source).is_none_or(|l| !matches!(l.content, photocraft_doc::LayerContent::Raster(_) | photocraft_doc::LayerContent::Smart(_))) { + return; + } + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + if released { + app.reorder_preview = None; + actions.push(("layer.frame.setContent".into(), json!({"layer":l.id.0,"content":dragged,"copy":copy}))); + } + return; + } let mut payload = layer_drop_payload(dragged, l.id, position, &st.selected_layers()); if background_drop_refused(&st.doc, &payload, l.id, position, copy) { return; @@ -298,6 +314,23 @@ pub(super) fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, } } +/// A layer mask's thumbnail dropped on another layer or group moves the mask there, ⌥ copies it, +/// as in Photoshop (#10485). A row that has a layer mask already doesn't take the drop (Photoshop +/// asks whether to replace it). Only the part of the row the list shows takes it: a row scrolled +/// out of view still lies under the panel's header or footer. +pub(super) fn mask_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) { + let Some((from, MaskKind::Pixel)) = ctx.data(|d| d.get_temp::(mask_drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if from == l.id.0 || l.mask.is_some() || !rect.intersect(ui.clip_rect()).contains(p) { + return; + } + let t = Tokens::get(ctx); + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push(("layer.layerMask.moveTo".into(), json!({"layer": from, "to": l.id.0, "copy": ctx.input(|i| i.modifiers.alt)}))); + } +} + /// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). The eye /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). pub(super) fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index e7916010e8..ba7898bbec 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,13 +1,15 @@ //! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the -//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode -//! picks one blend mode, Color one layer colour label (#5898), Smart Object toggles linked, missing -//! and embedded smart objects, Artboard lists only the artboards that hold the selected layers. The -//! criteria live in `UiState` (`layer_filter*`), so the control channel can read and set them. +//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Effect +//! picks one effect kind (#10880), Mode picks one blend mode, Attribute one layer attribute or +//! its opposite (#10881), Color one layer colour label (#5898), Smart Object toggles linked, +//! missing and embedded smart objects, Artboard lists only the artboards that hold the selected +//! layers, Group every group with its contents (#10883). The criteria live in `UiState` +//! (`layer_filter*`), so the control channel can read and set them. use std::collections::HashSet; use photocraft_color::BlendMode; -use photocraft_doc::{Document, LabelColor, Layer, LayerContent, LayerId, SmartSource}; +use photocraft_doc::{Document, Fill, LabelColor, Layer, LayerContent, LayerId, SmartSource}; use serde::{Deserialize, Serialize}; use crate::state::UiState; @@ -20,10 +22,13 @@ pub enum FilterBy { #[default] Kind, Name, + Effect, Mode, + Attribute, Color, SmartObject, Artboard, + Group, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -34,14 +39,20 @@ pub struct Filter { /// As typed; matched trimmed and ignoring case. pub name: String, pub mode: BlendMode, + pub effect: effect::EffectKind, + pub attribute: attribute::Attribute, pub color: LabelColor, /// The Smart Object toggles that are on, from [`SMART`]. pub smart: Vec, /// The linked files the Smart Object filter's Missing toggle lists /// ([`photocraft_engine::Session::missing_links`]). pub missing: HashSet, - /// The rows the Artboard filter lists ([`board_rows`]); empty lists every row. + /// The rows the Artboard filter ([`board_rows`]) or the Group filter ([`group::listed`]) + /// lists; empty lists every row under Artboard and none under Group. pub board_rows: HashSet, + /// The empty layers the Attribute filter's Empty and Not Empty use + /// ([`attribute::empty_layers`]); `None` reads each layer's pixels. + pub empty: Option>>, } impl Filter { @@ -51,42 +62,68 @@ impl Filter { kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode, + effect: ui.layer_filter_effect, + attribute: ui.layer_filter_attribute, color: ui.layer_filter_color, smart: ui.layer_filter_smart.clone(), missing: HashSet::new(), board_rows: HashSet::new(), + empty: None, } } /// Returns [`Filter::of`] for `app`'s settings, with the active document's missing linked /// files while the panel filters by Smart Object, and `board_rows` set to the rows of the - /// artboards that hold the active document's selected layers while it filters by Artboard. - pub fn of_app(app: &PhotocraftApp) -> Self { + /// artboards in `doc` that hold `selection` while it filters by Artboard, and `empty` set + /// while it filters by Empty or Not Empty. The Layers panel passes the document and the + /// selection it already read for the frame. + pub fn with_selection(app: &PhotocraftApp, doc: &Document, selection: &[LayerId]) -> Self { let mut f = Self::of(&app.ui); if f.by == FilterBy::SmartObject { f.missing = app.session.missing_links(); } - if f.by == FilterBy::Artboard - && let Some(d) = app.session.active() - { - f.board_rows = board_rows(&d.doc, &d.selected_layers()); + if f.by == FilterBy::Artboard { + f.board_rows = board_rows(doc, selection); + } + if f.by == FilterBy::Group { + f.board_rows = group::listed(doc); + } + if f.by == FilterBy::Attribute { + f.empty = attribute::empty_layers(app, f.attribute); } f } - /// Whether the panel is filtered. Like Photoshop, Mode and Color always filter (by Normal and - /// by no colour at first); Kind, Name and Smart Object only once a toggle is on or some text is - /// typed; Artboard only while some layers are selected and each of them is in an artboard. + /// Returns [`Filter::with_selection`] for the active document and its selected layers, or + /// [`Filter::of`] with no artboard rows when no document is open. + pub fn of_app(app: &PhotocraftApp) -> Self { + match app.session.active() { + Some(d) => Self::with_selection(app, &d.doc, &d.selected_layers()), + None => Self::of(&app.ui), + } + } + + /// Whether the panel is filtered. Like Photoshop, Effect, Mode, Attribute and Color always + /// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first), and so does Group; Kind, Name and + /// Smart Object only once a toggle is on or some text is typed; Artboard only while some layers + /// are selected and each of them is in an artboard. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Mode | FilterBy::Color => true, + FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true, FilterBy::SmartObject => !self.smart.is_empty(), FilterBy::Artboard => !self.board_rows.is_empty(), + FilterBy::Group => true, } } + /// Whether the panel lists the contents of closed groups: while it is filtered, except by + /// Group, which lists whole groups and keeps each one open or closed as it is (#10883). + pub fn show_all(&self) -> bool { + self.active() && self.by != FilterBy::Group + } + /// Whether `l` is listed under this filter. pub fn shows(&self, l: &Layer) -> bool { if !self.active() { @@ -95,7 +132,9 @@ impl Filter { match self.by { FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), + FilterBy::Effect => self.effect.shows(l), FilterBy::Mode => l.blend == self.mode, + FilterBy::Attribute => self.attribute.shows(l, self.empty.as_deref()), FilterBy::Color => l.label == self.color, FilterBy::SmartObject => { let LayerContent::Smart(sm) = &l.content else { return false }; @@ -106,6 +145,7 @@ impl Filter { }) } FilterBy::Artboard => self.board_rows.contains(&l.id), + FilterBy::Group => self.board_rows.contains(&l.id), } } } @@ -151,20 +191,23 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; /// not there, and embedded in the document. pub const SMART: [&str; 3] = ["linked", "missing", "embedded"]; -/// `ui.set {layerFilter: {by?, kinds?, name?, mode?, color?, smart?}}`: `current` with the given -/// fields replaced, or an error naming the bad field (nothing changes then). `by` is `kind`, -/// `name`, `mode`, `color`, `smartObject` or `artboard`; `kinds` a list of [`KINDS`]; `mode` a -/// blend mode name such as `Multiply` or `Pass Through`; `color` a label colour id (`none`, `red`, +/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}}`: +/// `current` with the given fields replaced, or an error naming the bad field (nothing changes +/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`, +/// `artboard` or `group`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of +/// the layer blend modes such as `Multiply` (not Pass Through, which the Mode list doesn't offer, +/// #10878); `attribute` an [`attribute::Attribute`] id; `color` a label colour id (`none`, `red`, /// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its -/// own: the selected layers pick the artboards. +/// own: the selected layers pick the artboards; nor has Group. pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; - if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode", "color", "smart"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode, color, smart)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, attribute, color, smart)")); } let mut f = Filter::of(current); if let Some(by) = o.get("by") { - f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, mode, color, smartObject or artboard".to_string())?; + f.by = serde_json::from_value(by.clone()) + .map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject, artboard or group".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -183,9 +226,16 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result = BlendMode::LAYER_MODES.iter().map(|m| m.label()).collect(); + format!("layerFilter.mode: `{name}` is not a layer blend mode the filter lists (modes: {})", modes.join(", ")) + })?; + } + if let Some(e) = o.get("effect") { + f.effect = effect::parse(e)?; + } + if let Some(a) = o.get("attribute") { + f.attribute = attribute::parse(a)?; } if let Some(color) = o.get("color") { let id = color.as_str().ok_or("layerFilter.color must be a label colour id")?; @@ -215,6 +265,8 @@ pub fn apply(ui: &mut UiState, f: Filter) { ui.layer_filter = f.kinds; ui.layer_filter_name = f.name; ui.layer_filter_mode = f.mode; + ui.layer_filter_effect = f.effect; + ui.layer_filter_attribute = f.attribute; ui.layer_filter_color = f.color; ui.layer_filter_smart = f.smart; } @@ -223,7 +275,11 @@ pub fn apply(ui: &mut UiState, f: Filter) { fn kind(l: &Layer) -> &'static str { match &l.content { LayerContent::Raster(_) => "pixel", - LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", + LayerContent::Adjustment(_) => "adjustment", + // A fill layer (Layer › New Fill Layer) matches no Kind button, as in the reference + // application, except a solid colour with a vector mask, which lists as a shape (#10878). + LayerContent::Fill(Fill::Solid(_)) if l.vector_mask.is_some() => "shape", + LayerContent::Fill(_) => "", LayerContent::Text(_) => "type", LayerContent::Shape(_) => "shape", LayerContent::Smart(_) => "smart", @@ -238,10 +294,13 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let by = [ (FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), + (FilterBy::Effect, tl!("Effect")), (FilterBy::Mode, tl!("Mode")), + (FilterBy::Attribute, tl!("Attribute")), (FilterBy::Color, tl!("Color")), (FilterBy::SmartObject, tl!("Smart Object")), (FilterBy::Artboard, tl!("Artboard")), + (FilterBy::Group, tl!("Group")), ]; widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); @@ -264,15 +323,16 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } - FilterBy::Name => { - let width = ui.available_width().max(48.0); - widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); - } + FilterBy::Name => find::name_field(app, ui), + FilterBy::Effect => effect::controls(app, ui), FilterBy::Mode => { - let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); + // The 27 layer modes, without Pass Through: a group set to Pass Through is never + // listed by Mode (#10878). + let modes: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.into_iter().map(|m| (m, m.label())).collect(); let width = ui.available_width().max(96.0); widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width); } + FilterBy::Attribute => attribute::controls(app, ui), FilterBy::Color => { // Photoshop's list: None, then the label colours in the Layers panel menu's order. let colors: Vec<(LabelColor, &str)> = @@ -298,9 +358,34 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // Artboard has no controls: the selected layers pick the artboards ([`board_rows`]). FilterBy::Artboard => {} + // Group has no controls either: it lists every group ([`group::listed`]). + FilterBy::Group => {} } }); + selection::follow(app, ui.ctx()); } +pub mod effect; +pub mod find; +mod group; +pub mod selection; + +#[cfg(test)] +mod effect_tests; + +pub mod attribute; + +#[cfg(test)] +mod attribute_tests; +#[cfg(test)] +mod find_tests; +#[cfg(test)] +mod selection_tests; #[cfg(test)] mod tests; + +#[cfg(test)] +mod kind_mode_tests; + +#[cfg(test)] +mod group_tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs new file mode 100644 index 0000000000..10e8edc541 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -0,0 +1,260 @@ +//! The Layers panel's Attribute filter (#10881): lists the layers with one attribute (visible, +//! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +use photocraft_color::SampleType; +use photocraft_doc::{AdvancedBlending, Document, Layer, LayerContent, LayerId}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The attributes the Attribute filter picks from, in Photoshop's order: the nine attributes, +/// then their opposites. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Attribute { + #[default] + Visible, + Locked, + Empty, + Linked, + Clipped, + LayerMask, + VectorMask, + LayerEffects, + AdvancedBlending, + NotVisible, + NotLocked, + NotEmpty, + NotLinked, + NotClipped, + NoLayerMask, + NoVectorMask, + NoLayerEffects, + NoAdvancedBlending, +} + +impl Attribute { + pub const ALL: [Attribute; 18] = [ + Attribute::Visible, + Attribute::Locked, + Attribute::Empty, + Attribute::Linked, + Attribute::Clipped, + Attribute::LayerMask, + Attribute::VectorMask, + Attribute::LayerEffects, + Attribute::AdvancedBlending, + Attribute::NotVisible, + Attribute::NotLocked, + Attribute::NotEmpty, + Attribute::NotLinked, + Attribute::NotClipped, + Attribute::NoLayerMask, + Attribute::NoVectorMask, + Attribute::NoLayerEffects, + Attribute::NoAdvancedBlending, + ]; + + /// The id `ui.set {layerFilter: {attribute}}` takes. + pub fn id(self) -> &'static str { + match self { + Attribute::Visible => "visible", + Attribute::Locked => "locked", + Attribute::Empty => "empty", + Attribute::Linked => "linked", + Attribute::Clipped => "clipped", + Attribute::LayerMask => "layerMask", + Attribute::VectorMask => "vectorMask", + Attribute::LayerEffects => "layerEffects", + Attribute::AdvancedBlending => "advancedBlending", + Attribute::NotVisible => "notVisible", + Attribute::NotLocked => "notLocked", + Attribute::NotEmpty => "notEmpty", + Attribute::NotLinked => "notLinked", + Attribute::NotClipped => "notClipped", + Attribute::NoLayerMask => "noLayerMask", + Attribute::NoVectorMask => "noVectorMask", + Attribute::NoLayerEffects => "noLayerEffects", + Attribute::NoAdvancedBlending => "noAdvancedBlending", + } + } + + fn label(self) -> &'static str { + match self { + Attribute::Visible => tl!("Visible"), + Attribute::Locked => tl!("Locked"), + Attribute::Empty => tl!("Empty"), + Attribute::Linked => tl!("Linked"), + Attribute::Clipped => tl!("Clipped"), + Attribute::LayerMask => tl!("Layer Mask"), + Attribute::VectorMask => tl!("Vector Mask"), + Attribute::LayerEffects => tl!("Layer Effects"), + Attribute::AdvancedBlending => tl!("Advanced Blending"), + Attribute::NotVisible => tl!("Not Visible"), + Attribute::NotLocked => tl!("Not Locked"), + Attribute::NotEmpty => tl!("Not Empty"), + Attribute::NotLinked => tl!("Not Linked"), + Attribute::NotClipped => tl!("Not Clipped"), + Attribute::NoLayerMask => tl!("No Layer Mask"), + Attribute::NoVectorMask => tl!("No Vector Mask"), + Attribute::NoLayerEffects => tl!("No Layer Effects"), + Attribute::NoAdvancedBlending => tl!("No Adv. Blending"), + } + } + + /// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers + /// (and groups) its positive entry doesn't. `empty` is the document's empty layers + /// ([`empty_layers_of`]); without it, Empty reads `l`'s pixels without knowing the canvas, so a + /// layer with an opaque default pixel is never empty. + pub fn shows(self, l: &Layer, empty: Option<&HashSet>) -> bool { + match self { + Attribute::Visible => l.visible, + // Any lock counts, the Background's too. + Attribute::Locked => l.locks.any(), + // A pixel layer with no visible pixels; an empty group isn't listed. + Attribute::Empty => empty.map_or_else(|| is_empty(l, None), |set| set.contains(&l.id)), + Attribute::Linked => l.link_group.is_some(), + // The clipped layer, not its base. + Attribute::Clipped => l.clipped, + Attribute::LayerMask => l.mask.is_some(), + // A shape layer's outline is its vector mask. + Attribute::VectorMask => l.vector_mask.is_some() || matches!(l.content, LayerContent::Shape(_)), + // Also a layer whose only effect is turned off, unlike the Effect filter. + Attribute::LayerEffects => !l.effects.items.is_empty(), + Attribute::AdvancedBlending => advanced_blending(l), + Attribute::NotVisible => !Attribute::Visible.shows(l, empty), + Attribute::NotLocked => !Attribute::Locked.shows(l, empty), + Attribute::NotEmpty => !Attribute::Empty.shows(l, empty), + Attribute::NotLinked => !Attribute::Linked.shows(l, empty), + Attribute::NotClipped => !Attribute::Clipped.shows(l, empty), + Attribute::NoLayerMask => !Attribute::LayerMask.shows(l, empty), + Attribute::NoVectorMask => !Attribute::VectorMask.shows(l, empty), + Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l, empty), + Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l, empty), + } + } +} + +/// Whether `l` is a pixel layer with no visible pixels; an empty group isn't listed. Stored pixels +/// past the canvas count (they are the layer's, Reveal All brings them back); `canvas` only bounds +/// the check for a layer whose default pixel is opaque (see [`has_visible_pixels`]). +fn is_empty(l: &Layer, canvas: Option) -> bool { + matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s, canvas)) +} + +/// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps +/// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes, +/// #9379). A surface without an alpha channel covers everything. With an opaque default pixel, +/// every part of `canvas` without a stored tile shows it, so only stored tiles that cover the +/// canvas and are clear on it make the layer empty (pixels outside the canvas are left out there, +/// as the default reaches beyond it anyway); without a `canvas` such a layer always counts. +fn has_visible_pixels(s: &Surface, canvas: Option) -> bool { + let fmt = s.format(); + if !fmt.alpha { + return true; + } + let clip = match (s.default_pixel().last().is_none_or(|a| *a > 0.0), canvas) { + (false, _) => None, + (true, Some(canvas)) if covers(s, canvas) => Some(canvas), + (true, _) => return true, + }; + let (n, bpp, size) = (fmt.channels(), fmt.bytes_per_pixel(), fmt.sample.bytes()); + let Some(at) = n.checked_sub(1).and_then(|i| i.checked_mul(size)).filter(|_| bpp > 0) else { return false }; + // Integer alpha is above 0 where any of its bytes is, so a block of pixels is OR-ed together + // (which vectorises) before looking for one; only float alpha needs decoding. + let visible = |block: &[u8]| match fmt.sample { + SampleType::F32 => block.chunks_exact(bpp).any(|px| px.get(at..at + size).is_some_and(|a| a.try_into().is_ok_and(|b| f32::from_ne_bytes(b) > 0.0))), + SampleType::U8 | SampleType::U16 => { + block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0 + } + }; + let tile_visible = |c: &TileCoord, t: &Arc| { + let bytes = t.bytes(); + let r = c.rect(); + let Some(clip) = clip.filter(|clip| !clip.contains_rect(&r)) else { return bytes.chunks(bpp.saturating_mul(1024)).any(visible) }; + // Only the part of the tile on the canvas, row by row. + let on = r.intersect(&clip); + let row = (TILE_SIZE as usize).saturating_mul(bpp); + let (x0, x1) = ((on.x0 - r.x0) as usize * bpp, (on.x1 - r.x0) as usize * bpp); + (on.y0 - r.y0..on.y1 - r.y0).any(|y| bytes.get(y as usize * row + x0..y as usize * row + x1).is_some_and(visible)) + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + let tiles: Vec<_> = s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).collect(); + tiles.par_iter().any(|(c, t)| tile_visible(c, t)) + } + #[cfg(target_arch = "wasm32")] + s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).any(|(c, t)| tile_visible(c, t)) +} + +/// Whether `s` stores a tile for every part of `canvas`. +fn covers(s: &Surface, canvas: Rect) -> bool { + if canvas.is_empty() { + return true; + } + let (first, last) = (TileCoord::containing(canvas.x0, canvas.y0), TileCoord::containing(canvas.x1 - 1, canvas.y1 - 1)); + let needed = (i64::from(last.tx) - i64::from(first.tx) + 1) * (i64::from(last.ty) - i64::from(first.ty) + 1); + let stored = s.tiles().filter(|(c, _)| (first.tx..=last.tx).contains(&c.tx) && (first.ty..=last.ty).contains(&c.ty)).count(); + i64::try_from(stored).is_ok_and(|stored| stored == needed) +} + +/// The empty pixel layers of `doc`. +pub fn empty_layers_of(doc: &Document) -> HashSet { + let canvas = Some(doc.bounds()); + doc.walk().into_iter().filter(|(_, _, l)| is_empty(l, canvas)).map(|(_, _, l)| l.id).collect() +} + +/// The document [`empty_layers`] last read and what it found. A `Weak` keeps the document's +/// allocation from being reused while it is held, and makes `Arc::make_mut` copy the document on +/// its next edit even when nothing else holds it, so the same allocation always means the same +/// layers and pixels. +static EMPTY: Mutex> = Mutex::new(None); + +type EmptyCache = (Weak, Arc>); + +/// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per +/// document: a layer erased to nothing has every pixel read, too much for every repaint of the +/// Layers panel. Keyed by the document itself rather than its revision, which also moves when only +/// the selection does. +pub fn empty_layers(app: &PhotocraftApp, attribute: Attribute) -> Option>> { + if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) { + return None; + } + let d = app.session.active()?; + let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((doc, set)) = cache.as_ref() + && std::ptr::eq(doc.as_ptr(), Arc::as_ptr(&d.doc)) + { + return Some(set.clone()); + } + let set = Arc::new(empty_layers_of(&d.doc)); + *cache = Some((Arc::downgrade(&d.doc), set.clone())); + Some(set) +} + +/// Whether any Advanced Blending option is away from its default: a channel left out, Knockout, +/// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range. +/// Fill opacity alone doesn't count. +fn advanced_blending(l: &Layer) -> bool { + l.excluded_channels != 0 || l.advanced != AdvancedBlending::default() || !l.blend_if.is_default() +} + +/// `layerFilter.attribute`: the attribute named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || Attribute::ALL.map(Attribute::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.attribute must be one of {}", ids()))?; + Attribute::ALL.into_iter().find(|a| a.id() == name).ok_or_else(|| format!("layerFilter.attribute: unknown attribute `{name}` (attributes: {})", ids())) +} + +/// The Attribute filter's controls: the attribute dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let attributes: Vec<(Attribute, &str)> = Attribute::ALL.into_iter().map(|a| (a, a.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-attribute", tl!("Attribute"), &mut app.ui.layer_filter_attribute, &attributes, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs new file mode 100644 index 0000000000..fad5460990 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -0,0 +1,321 @@ +//! #10881: the Layers panel's Attribute filter, against the cases measured in Photoshop. + +use std::collections::BTreeSet; + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::{BlendRange, Document, Group, Knockout, Layer, LayerContent, LayerMask, VectorMask}; +use serde_json::json; + +use super::attribute::Attribute; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// One layer per measured case. Every pixel layer has pixels except "empty"; the adjustment, fill +/// and shape layers come from their commands, so they carry the masks those give them. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let pixel_layers = [ + "pixels", + "hidden", + "position lock", + "all locked", + "empty", + "linked a", + "linked b", + "base", + "masked", + "vector masked", + "fx", + "fx off", + "fill 50", + "adv channels", + "adv knockout", + "adv interior", + "adv clipped", + "adv shapes", + "adv layer mask", + "adv vector mask", + "adv blend if", + ]; + for name in pixel_layers { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + match name { + "base" => { + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + rename_active(&mut s, "clipped"); + s.execute("layer.createClippingMask", json!({})).unwrap(); + } + "fx" => { + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + } + "fx off" => { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "params": {"enabled": false}}]})).unwrap(); + } + _ => {} + } + } + s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); + rename_active(&mut s, "fill"); + s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + rename_active(&mut s, "shape"); + + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + let full = photocraft_geom::Rect::new(0, 0, 64, 48); + for name in pixel_layers.into_iter().filter(|n| *n != "empty") { + let l = layer(doc, name); + let LayerContent::Raster(surface) = &mut l.content else { panic!("{name} is a pixel layer") }; + surface.fill_rect(full, &RED); + } + layer(doc, "hidden").visible = false; + layer(doc, "position lock").locks.position = true; + layer(doc, "all locked").locks.all = true; + layer(doc, "linked a").link_group = Some(7); + layer(doc, "linked b").link_group = Some(7); + layer(doc, "masked").mask = Some(LayerMask::reveal_all()); + layer(doc, "vector masked").vector_mask = Some(VectorMask { path: Default::default(), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + layer(doc, "fill 50").fill_opacity = 0.5; + layer(doc, "adv channels").excluded_channels = 0b010; + layer(doc, "adv knockout").advanced.knockout = Knockout::Shallow; + layer(doc, "adv interior").advanced.blend_interior = true; + layer(doc, "adv clipped").advanced.blend_clipped = false; + layer(doc, "adv shapes").advanced.transparency_shapes = false; + layer(doc, "adv layer mask").advanced.layer_mask_hides_effects = true; + layer(doc, "adv vector mask").advanced.vector_mask_hides_effects = true; + layer(doc, "adv blend if").blend_if.ranges = vec![[BlendRange { black: [40, 40], white: [255, 255] }, BlendRange::FULL]]; + // A visible group holding a pixel layer, and an empty group. + let mut inside = Layer::new("inside", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut inside.content { + surface.fill_rect(full, &RED); + } + doc.layers.push(Layer::new("group", LayerContent::Group(Group { children: vec![inside], expanded: true, artboard: None, frame_stroke: None }))); + doc.layers.push(Layer::new("empty group", LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }))); + // A hidden group whose child is visible: Visible goes by the layer's own flag. + let mut child = Layer::new("in hidden group", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut child.content { + surface.fill_rect(full, &RED); + } + let mut hidden_group = Layer::new("hidden group", LayerContent::Group(Group { children: vec![child], expanded: true, artboard: None, frame_stroke: None })); + hidden_group.visible = false; + doc.layers.push(hidden_group); + s +} + +fn layer<'a>(doc: &'a mut Document, name: &str) -> &'a mut Layer { + let id = doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}")); + doc.layer_mut(id).unwrap() +} + +fn rename_active(s: &mut photocraft_engine::Session, name: &str) { + let d = s.active_mut().unwrap(); + let id = d.active_layer.unwrap(); + std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().name = name.into(); +} + +/// The names `attribute` lists. +fn listed(doc: &Document, attribute: Attribute) -> BTreeSet { + let mut filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() }); + // As the panel does ([`Filter::of_app`]): Empty knows the canvas. + filter.empty = Some(std::sync::Arc::new(super::attribute::empty_layers_of(doc))); + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn names(list: &[&str]) -> BTreeSet { + list.iter().map(|n| n.to_string()).collect() +} + +fn all(doc: &Document) -> BTreeSet { + doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect() +} + +#[test] +fn each_attribute_lists_the_measured_layers() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + let but = |gone: &[&str]| -> BTreeSet { everything.difference(&names(gone)).cloned().collect() }; + // Every visible layer and every visible group. + assert_eq!(listed(doc, Attribute::Visible), but(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::Visible).contains("in hidden group"), "a visible child of a hidden group"); + // Any lock counts, the Background's too. + assert_eq!(listed(doc, Attribute::Locked), names(&["position lock", "all locked", "Background"])); + // A pixel layer without pixels; the empty group isn't listed. + assert_eq!(listed(doc, Attribute::Empty), names(&["empty"])); + assert_eq!(listed(doc, Attribute::Linked), names(&["linked a", "linked b"])); + // The clipped Levels layer, not its base. + assert_eq!(listed(doc, Attribute::Clipped), names(&["clipped"])); + // The adjustment and fill layers count with the masks they're created with. + assert_eq!(listed(doc, Attribute::LayerMask), names(&["masked", "clipped", "fill"])); + // A shape layer counts as having a vector mask. + assert_eq!(listed(doc, Attribute::VectorMask), names(&["vector masked", "shape"])); + // A layer whose only effect is turned off counts. + assert_eq!(listed(doc, Attribute::LayerEffects), names(&["fx", "fx off"])); + // Each Advanced Blending option on its own; Fill opacity alone doesn't count. + assert_eq!( + listed(doc, Attribute::AdvancedBlending), + names(&["adv channels", "adv knockout", "adv interior", "adv clipped", "adv shapes", "adv layer mask", "adv vector mask", "adv blend if"]) + ); +} + +#[test] +fn a_layer_erased_completely_is_empty() { + // The Eraser only clears alpha, so the erased pixels keep their colour bytes. + for depth in [8, 16, 32] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let name = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).next().unwrap(); + let erase = |s: &mut photocraft_engine::Session, x: i32| { + s.execute("paint.stroke", json!({"points": [[x, -10], [x, 40]], "size": 30, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + }; + erase(&mut s, 10); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[]), "{depth}: partly erased"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[&name]), "{depth}"); + erase(&mut s, 30); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[&name]), "{depth}: erased completely"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[]), "{depth}"); + } +} + +#[test] +fn a_layer_with_an_opaque_default_pixel_is_not_empty() { + // No stored tiles, but the default pixel covers the whole canvas. + let mut doc = Document::new("Opaque default", photocraft_geom::Size::new(40, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let opaque = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + doc.layers.push(Layer::new("opaque", LayerContent::Raster(opaque))); + doc.layers.push(Layer::new("clear", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8)))); + assert_eq!(listed(&doc, Attribute::Empty), names(&["clear"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["opaque"])); +} + +#[test] +fn an_opaque_default_layer_erased_on_the_whole_canvas_is_empty() { + // 300 px wide: the second column of tiles reaches past the canvas, where the default stays. + let mut doc = Document::new("Opaque erased", photocraft_geom::Size::new(300, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let clear = [1.0, 0.0, 0.0, 0.0]; + for (name, erased) in [("erased", photocraft_geom::Rect::new(0, 0, 300, 30)), ("half erased", photocraft_geom::Rect::new(0, 0, 200, 30))] { + let mut s = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + s.fill_rect(erased, &clear); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + let mut dot = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + dot.fill_rect(photocraft_geom::Rect::new(0, 0, 300, 30), &clear); + dot.fill_rect(photocraft_geom::Rect::new(299, 29, 300, 30), &RED); + doc.layers.push(Layer::new("one pixel", LayerContent::Raster(dot))); + // The half erased layer has no tile over x 256..300, so the default shows there. + assert_eq!(listed(&doc, Attribute::Empty), names(&["erased"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["half erased", "one pixel"])); +} + +#[test] +fn the_empty_layers_are_found_once_per_document() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter_by = FilterBy::Attribute; + app.ui.layer_filter_attribute = Attribute::Empty; + let first = Filter::of_app(&app).empty.expect("found for Empty"); + assert!(first.is_empty(), "the filled layer has pixels"); + let again = Filter::of_app(&app).empty.unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same document reuses the result"); + // Selecting a layer moves the revision but changes no pixels. + let id = app.session.active().unwrap().doc.layers[0].id; + app.session.select_layer(id).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &Filter::of_app(&app).empty.unwrap()), "a selection reuses the result"); + app.session.execute("paint.stroke", json!({"points": [[20, -10], [20, 40]], "size": 60, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + let after = Filter::of_app(&app); + assert_eq!(after.empty.as_ref().map(|set| set.len()), Some(1), "an edit finds them again"); + let doc = &app.session.active().unwrap().doc; + assert!(doc.walk().into_iter().all(|(_, _, l)| after.shows(l)), "the erased layer is listed"); + app.ui.layer_filter_attribute = Attribute::NotEmpty; + assert!(Filter::of_app(&app).empty.is_some(), "Not Empty uses them too"); + // Another document, with no empty layer, gets its own result. + app.session.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + assert_eq!(Filter::of_app(&app).empty.map(|set| set.len()), Some(0), "the new document's layers"); + app.ui.layer_filter_attribute = Attribute::Visible; + assert!(Filter::of_app(&app).empty.is_none(), "other attributes don't read pixels"); +} + +#[test] +fn each_opposite_lists_the_rest_groups_included() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + for (positive, negative) in Attribute::ALL[..9].iter().zip(&Attribute::ALL[9..]) { + let rest: BTreeSet = everything.difference(&listed(doc, *positive)).cloned().collect(); + assert_eq!(listed(doc, *negative), rest, "{negative:?} lists what {positive:?} doesn't"); + } + // The three measured ones, spelled out. + assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::NotLocked).contains("group"), "groups are listed too"); + assert!(!listed(doc, Attribute::NotLocked).contains("Background")); + assert_eq!(listed(doc, Attribute::NotLinked), everything.difference(&names(&["linked a", "linked b"])).cloned().collect()); +} + +#[test] +fn attribute_filters_at_once_with_visible() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, ..UiState::default() }); + assert_eq!(f.attribute, Attribute::Visible); + assert!(f.active(), "Attribute filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_attribute_by_id() { + let mut ui = UiState::default(); + for attribute in Attribute::ALL { + let f = super::patched(&ui, &json!({"by": "attribute", "attribute": attribute.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_attribute), (FilterBy::Attribute, attribute)); + } + assert_eq!(Attribute::NoAdvancedBlending.id(), "noAdvancedBlending"); + let err = super::patched(&ui, &json!({"attribute": "hidden"})).unwrap_err(); + assert!(err.contains("hidden") && err.contains("notVisible") && err.contains("noAdvancedBlending"), "{err}"); + assert!(super::patched(&ui, &json!({"attribute": 3})).is_err()); + assert!(super::patched(&ui, &json!({"attribute": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_attribute_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_attribute: Attribute::NotLinked, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_attribute"], "notLinked"); + saved.as_object_mut().unwrap().remove("layer_filter_attribute"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_attribute, Attribute::Visible); +} + +/// The names of the rows the Layers panel drew. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> BTreeSet { + let doc = &h.state().session.active().unwrap().doc; + let rows = crate::layer_row_ui::recorded(&h.ctx); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_attribute_picked_in_the_dropdown() { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Attribute").last().expect("Attribute in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Attribute); + assert!(!drawn(&h).contains("hidden"), "Visible at first"); + h.get_by_value("Visible").click(); + h.run_steps(4); + h.query_all_by_label("Not Visible").last().expect("Not Visible in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_attribute, Attribute::NotVisible); + assert_eq!(drawn(&h), names(&["hidden", "hidden group"])); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/effect.rs b/crates/ui-egui/src/panels/layer_filter_ui/effect.rs new file mode 100644 index 0000000000..1ce24754ae --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/effect.rs @@ -0,0 +1,95 @@ +//! The Layers panel's Effect filter (#10880): lists the layers with an enabled effect of one kind. +//! Overlay covers Color, Gradient and Pattern Overlay, as in Photoshop. + +use photocraft_doc::{Effect, Layer}; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The effect kinds the Effect filter picks from, in Photoshop's order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EffectKind { + #[default] + BevelEmboss, + Stroke, + InnerShadow, + InnerGlow, + Satin, + Overlay, + OuterGlow, + DropShadow, +} + +impl EffectKind { + pub const ALL: [EffectKind; 8] = [ + EffectKind::BevelEmboss, + EffectKind::Stroke, + EffectKind::InnerShadow, + EffectKind::InnerGlow, + EffectKind::Satin, + EffectKind::Overlay, + EffectKind::OuterGlow, + EffectKind::DropShadow, + ]; + + /// The id `ui.set {layerFilter: {effect}}` takes. + pub fn id(self) -> &'static str { + match self { + EffectKind::BevelEmboss => "bevelEmboss", + EffectKind::Stroke => "stroke", + EffectKind::InnerShadow => "innerShadow", + EffectKind::InnerGlow => "innerGlow", + EffectKind::Satin => "satin", + EffectKind::Overlay => "overlay", + EffectKind::OuterGlow => "outerGlow", + EffectKind::DropShadow => "dropShadow", + } + } + + fn label(self) -> &'static str { + match self { + EffectKind::BevelEmboss => tl!("Bevel & Emboss"), + EffectKind::Stroke => tl!("Stroke"), + EffectKind::InnerShadow => tl!("Inner Shadow"), + EffectKind::InnerGlow => tl!("Inner Glow"), + EffectKind::Satin => tl!("Satin"), + EffectKind::Overlay => tl!("Overlay"), + EffectKind::OuterGlow => tl!("Outer Glow"), + EffectKind::DropShadow => tl!("Drop Shadow"), + } + } + + fn matches(self, e: &Effect) -> bool { + matches!( + (self, e), + (EffectKind::BevelEmboss, Effect::BevelEmboss(_)) + | (EffectKind::Stroke, Effect::Stroke(_)) + | (EffectKind::InnerShadow, Effect::InnerShadow(_)) + | (EffectKind::InnerGlow, Effect::InnerGlow(_)) + | (EffectKind::Satin, Effect::Satin(_)) + | (EffectKind::Overlay, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::PatternOverlay { .. }) + | (EffectKind::OuterGlow, Effect::OuterGlow(_)) + | (EffectKind::DropShadow, Effect::DropShadow(_)) + ) + } + + /// Whether `l` has an enabled effect of this kind; a turned-off one doesn't count. + pub fn shows(self, l: &Layer) -> bool { + l.effects.items.iter().any(|e| e.enabled() && self.matches(e)) + } +} + +/// `layerFilter.effect`: the kind named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || EffectKind::ALL.map(EffectKind::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.effect must be one of {}", ids()))?; + EffectKind::ALL.into_iter().find(|k| k.id() == name).ok_or_else(|| format!("layerFilter.effect: unknown effect `{name}` (effects: {})", ids())) +} + +/// The Effect filter's controls: the effect kind dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let kinds: Vec<(EffectKind, &str)> = EffectKind::ALL.into_iter().map(|k| (k, k.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-effect", tl!("Effect"), &mut app.ui.layer_filter_effect, &kinds, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs new file mode 100644 index 0000000000..326de21e4e --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs @@ -0,0 +1,128 @@ +//! #10880: the Layers panel's Effect filter, against the cases measured in Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::Document; +use serde_json::json; + +use super::effect::EffectKind; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +/// One pixel layer per effect kind (the three overlays each on their own), "shadow off" whose +/// Drop Shadow is turned off, and "plain" without effects, over the Background. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for (name, kind, params) in [ + ("plain", "", json!({})), + ("bevel", "bevelEmboss", json!({})), + ("stroke", "stroke", json!({})), + ("inner shadow", "innerShadow", json!({})), + ("inner glow", "innerGlow", json!({})), + ("satin", "satin", json!({})), + ("color", "colorOverlay", json!({"color": "#ff8000"})), + ("gradient", "gradientOverlay", json!({})), + ("pattern", "patternOverlay", json!({})), + ("outer glow", "outerGlow", json!({})), + ("shadow", "dropShadow", json!({})), + ("shadow off", "dropShadow", json!({"enabled": false})), + ] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + if !kind.is_empty() { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]})).unwrap(); + } + } + s +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn by_effect(effect: EffectKind) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Effect, layer_filter_effect: effect, ..UiState::default() }) +} + +#[test] +fn each_effect_lists_the_layers_with_it_enabled() { + let s = session(); + let doc = &s.active().unwrap().doc; + for (effect, want) in [ + (EffectKind::BevelEmboss, &["bevel"][..]), + (EffectKind::Stroke, &["stroke"]), + (EffectKind::InnerShadow, &["inner shadow"]), + (EffectKind::InnerGlow, &["inner glow"]), + (EffectKind::Satin, &["satin"]), + // Overlay covers Color, Gradient and Pattern Overlay. + (EffectKind::Overlay, &["pattern", "gradient", "color"]), + (EffectKind::OuterGlow, &["outer glow"]), + // A turned-off Drop Shadow doesn't count. + (EffectKind::DropShadow, &["shadow"]), + ] { + assert_eq!(listed(doc, &by_effect(effect)), want, "{effect:?}"); + } +} + +#[test] +fn effect_filters_at_once_with_bevel_and_emboss() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Effect, ..UiState::default() }); + assert_eq!(f.effect, EffectKind::BevelEmboss); + assert!(f.active(), "Effect filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_effect_by_id() { + let mut ui = UiState::default(); + for effect in EffectKind::ALL { + let f = super::patched(&ui, &json!({"by": "effect", "effect": effect.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_effect), (FilterBy::Effect, effect)); + } + let err = super::patched(&ui, &json!({"effect": "colorOverlay"})).unwrap_err(); + assert!(err.contains("colorOverlay") && err.contains("bevelEmboss") && err.contains("dropShadow"), "{err}"); + assert!(super::patched(&ui, &json!({"effect": 3})).is_err()); + assert!(super::patched(&ui, &json!({"effect": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_effect_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_effect: EffectKind::Satin, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_effect"], "satin"); + saved.as_object_mut().unwrap().remove("layer_filter_effect"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_effect, EffectKind::BevelEmboss); +} + +/// The names of the rows the Layers panel drew, top to bottom. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> Vec { + let doc = &h.state().session.active().unwrap().doc; + let mut rows = crate::layer_row_ui::recorded(&h.ctx); + rows.sort_by(|a, b| a.row.top().total_cmp(&b.row.top())); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_effect_picked_in_the_dropdown() { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 1200.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Effect").last().expect("Effect in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Effect); + assert_eq!(drawn(&h), ["bevel"], "Bevel & Emboss at first"); + h.get_by_value("Bevel & Emboss").click(); + h.run_steps(4); + h.query_all_by_label("Drop Shadow").last().expect("Drop Shadow in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_effect, EffectKind::DropShadow); + assert_eq!(drawn(&h), ["shadow"]); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find.rs b/crates/ui-egui/src/panels/layer_filter_ui/find.rs new file mode 100644 index 0000000000..ee437906cf --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find.rs @@ -0,0 +1,80 @@ +//! #10884: Select › Find Layers (⌥⇧⌘F) filters the Layers panel by Name, as in Photoshop: it shows +//! the Layers panel, switches the filter to Name and puts the caret in its field with the text +//! selected, so typing replaces it. The layer selection is not changed. With a `name` param +//! (scripts and agents) it sets the Name text to it instead of focusing the field. + +use serde_json::{Value, json}; + +use super::FilterBy; +use crate::{PhotocraftApp, widgets}; + +/// The egui memory flag: focus the Name field the next time it is drawn. +fn focus_flag() -> egui::Id { + egui::Id::unique("layer-filter-find-focus") +} + +/// Run `select.findLayers`: the engine checks `params` and lists the layers the Name text matches; +/// then the Name filter is applied. Without a `name` the text is kept, and the Layers panel is +/// shown with the field focused. +pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Result { + let given = params.get("name").is_some_and(|n| !n.is_null()); + // `run` keeps the Name text when no `name` is given ([`params_for_run`]) and applies the + // reply's Name filter ([`apply`]). + let reply = app.run("select.findLayers", params)?; + if !given { + // The panel docking layout (when on) shows the Layers panel itself, else the dock group. + if app.ui.docking.is_some() { + if let Some(shown) = crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": "layers"})) { + shown?; + } + } else { + *crate::dock::Group::Layers.tab_mut(&mut app.ui.dock_tabs) = 0; + crate::dock::reveal(app, crate::dock::Group::Layers); + } + // A hidden filter row (Filter Options › Hide) stays hidden, as in Photoshop, and showing it + // later doesn't put the caret in the field: only a drawn row takes the focus. + if app.ui.layers_panel_options.show_filters { + ctx.data_mut(|d| d.insert_temp(focus_flag(), true)); + } + } + Ok(reply) +} + +/// The params `select.findLayers` runs with: without a `name` the panel's Name text is kept, so the +/// reply lists the layers the panel lists however the command runs (the menu, the shortcut, +/// `engine.execute` or an action step). +pub(crate) fn params_for_run(app: &PhotocraftApp, params: Value) -> Value { + if params.get("name").is_some_and(|n| !n.is_null()) { + return params; + } + let mut params = if params.is_object() { params } else { json!({}) }; + params["name"] = json!(app.ui.layer_filter_name); + params +} + +/// Apply the Name filter in a `select.findLayers` reply to the Layers panel: switch to Name and set +/// the text when the reply has one. [`PhotocraftApp::run`] calls it after every Find Layers. +pub(crate) fn apply(app: &mut PhotocraftApp, reply: &Value) { + app.ui.layer_filter_by = FilterBy::Name; + if let Some(name) = reply["layerFilter"]["name"].as_str() { + app.ui.layer_filter_name = name.to_string(); + } +} + +/// The Name filter's text field, taking the focus with its text selected after [`invoke`]. The +/// focus waits for a frame without a pointer press or release: egui drops the focus at the end of +/// a frame in which the pointer was clicked elsewhere, as it is when the menu item is clicked. +pub fn name_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let id = ui.scope_id().with("layer-filter-name"); + let pending = ui.ctx().data(|d| d.get_temp::(focus_flag())).unwrap_or(false); + let clicking = ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released() || i.pointer.any_down()); + if pending && clicking { + ui.ctx().request_repaint(); + } else if pending { + ui.ctx().data_mut(|d| d.remove_temp::(focus_flag())); + ui.memory_mut(|m| m.request_focus(id)); + widgets::field_tab::select_all(ui.ctx(), id, &app.ui.layer_filter_name); + } + let width = ui.available_width().max(48.0); + widgets::text_field(ui, id, &mut app.ui.layer_filter_name, tl!("Search"), width, false); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs new file mode 100644 index 0000000000..8a7a00d854 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs @@ -0,0 +1,192 @@ +//! #10884: Select › Find Layers opens the Layers panel's Name filter, against the cases measured in +//! Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; + +/// "LinkA", "Other" and "LinkB" over the Background, "Other" selected. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for name in ["LinkA", "Other", "LinkB"] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let other = id(&h, "Other"); + h.state_mut().session.execute("layer.select", json!({"layer": other.0})).unwrap(); + h.run_steps(4); + h +} + +fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId { + h.state() + .session + .active() + .unwrap() + .doc + .walk() + .into_iter() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| l.id) + .unwrap_or_else(|| panic!("no layer {name}")) +} + +fn selected(h: &Harness<'_, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().selected_layers() +} + +/// The names the panel's filter lists, top to bottom. +fn listed(h: &Harness<'_, PhotocraftApp>) -> Vec { + let f = Filter::of_app(h.state()); + let doc = &h.state().session.active().unwrap().doc; + crate::layer_tree_ui::display_rows(doc, f.active()).into_iter().filter(|(_, l)| f.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn find(h: &mut Harness<'_, PhotocraftApp>, params: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + let reply = crate::menus::invoke(h.state_mut(), &ctx, "select.findLayers", params).unwrap(); + h.run_steps(4); + reply +} + +#[test] +fn the_menu_item_shows_the_layers_panel_and_focuses_the_name_field() { + let mut h = harness(); + h.state_mut().ui.panels.layers = false; + h.state_mut().ui.layer_filter_name = "oth".into(); + h.run_steps(4); + let before = selected(&h); + find(&mut h, json!({})); + assert_eq!(selected(&h), before, "Find Layers does not change the layer selection"); + assert!(h.state().ui.panels.layers, "the Layers panel is shown"); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); + // The existing text is selected, so typing replaces it. + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [], "the typed filter hides \"Other\", which is deselected (#10879); no match is selected"); +} + +#[test] +fn a_name_param_sets_the_filter_text_without_changing_the_selection() { + let mut h = harness(); + let link_a = id(&h, "LinkA"); + h.state_mut().session.execute("layer.select", json!({"layer": link_a.0})).unwrap(); + h.run_steps(4); + let reply = find(&mut h, json!({"name": "link"})); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the matching layers"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [link_a], "LinkA stays selected; LinkB, also listed, isn't selected"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} + +#[test] +fn the_shortcut_runs_find_layers() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); +} + +#[test] +fn clicking_the_menu_item_leaves_the_caret_in_the_name_field() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.get_by_label("Select").click(); + h.run_steps(4); + h.get_by_label("Find Layers Ctrl+Alt+Shift+F").click(); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin", "typing goes to the Name field"); +} + +#[test] +fn the_shortcut_shows_a_layers_tab_closed_from_the_window_menu() { + let mut h = harness(); + use egui_kittest::kittest::Queryable; + h.get_by_label("Window").click(); + h.run_steps(4); + h.get_by_label("✔ Layers F7").click(); + h.run_steps(4); + assert!(!h.state().ui.panels.layers, "Window › Layers closed the panel"); + h.state_mut().ui.layer_filter_name = "lin".into(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the panel is back, focused, with its text selected"); +} + +#[test] +fn the_menu_item_shows_a_layers_panel_closed_in_the_docking_layout() { + let mut h = harness(); + h.state_mut().run("window.panel.move", json!({"panel": "swatches", "anchor": "layers", "zone": "top"})).unwrap(); + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + h.run_steps(4); + let shown = |h: &Harness<'_, PhotocraftApp>| h.state().ui.docking.as_ref().is_some_and(|w| w.layout.contains(&"layers".to_string())); + assert!(!shown(&h), "the Layers panel is closed"); + h.state_mut().ui.layer_filter_name = "lin".into(); + find(&mut h, json!({})); + assert!(shown(&h), "Find Layers shows the Layers panel again"); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the Name field has the focus with its text selected"); +} + +#[test] +fn a_hidden_filter_row_stays_hidden_and_does_not_take_the_focus_later() { + // Measured in Photoshop: with Filter Options › Hide, Find Layers switches to Name but leaves + // the row hidden, and showing the row afterwards does not put the caret in the field. + let mut h = harness(); + h.state_mut().ui.layers_panel_options.show_filters = false; + h.state_mut().ui.layer_filter_name = "lin".into(); + h.run_steps(4); + find(&mut h, json!({})); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(!h.state().ui.layers_panel_options.show_filters, "the filter row stays hidden"); + h.state_mut().ui.layers_panel_options.show_filters = true; + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "showing the row later does not focus the Name field"); + assert_eq!(h.state().ui.layer_filter_name, "lin"); +} + +#[test] +fn engine_execute_without_a_name_lists_what_the_panel_lists() { + let mut h = harness(); + h.state_mut().ui.layer_filter_name = "link".into(); + let reply = h.state_mut().run("select.findLayers", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "link", "the Name text is kept"); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the layers the kept text matches"); + assert_eq!(reply["layerFilter"], json!({"by": "name", "name": "link"})); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); +} + +#[test] +fn engine_execute_sets_the_panel_filter_too() { + // `engine.execute` runs engine commands through `PhotocraftApp::run`, not `menus::invoke`. + let mut h = harness(); + h.state_mut().ui.layer_filter_by = FilterBy::Kind; + h.state_mut().run("select.findLayers", json!({"name": "link"})).unwrap(); + h.run_steps(4); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [], "the filter hides \"Other\" (#10879) and selects none of the matches"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group.rs b/crates/ui-egui/src/panels/layer_filter_ui/group.rs new file mode 100644 index 0000000000..8e0e5ed571 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group.rs @@ -0,0 +1,22 @@ +//! The Layers panel's Group filter (#10883): every group with its whole subtree; layers in no +//! group are hidden. An artboard row is not a group here, but the groups inside it are. + +use std::collections::HashSet; + +use photocraft_doc::{Document, Layer, LayerContent, LayerId}; + +/// The ids of the layers the Group filter lists. The tree is walked once, with a stack rather +/// than recursion, so a deep document can't overflow it. +pub fn listed(doc: &Document) -> HashSet { + let mut out = HashSet::new(); + let mut pending: Vec<(&Layer, bool)> = doc.layers.iter().map(|l| (l, false)).collect(); + while let Some((l, inside)) = pending.pop() { + let artboard = matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some()); + let inside = inside || (l.is_group() && !artboard); + if inside { + out.insert(l.id); + } + pending.extend(l.children().unwrap_or_default().iter().map(|c| (c, inside))); + } + out +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs new file mode 100644 index 0000000000..c2f9ebb727 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/group_tests.rs @@ -0,0 +1,144 @@ +//! #10883: the Layers panel's Group filter. + +use photocraft_engine::Session; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +fn new_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +/// The app with `s` open and the Layers panel filtered by Group. +fn app(s: Session) -> PhotocraftApp { + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter_by = FilterBy::Group; + app +} + +fn id(s: &Session, name: &str) -> u64 { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0).unwrap_or_else(|| panic!("no layer {name}")) +} + +fn select(s: &mut Session, name: &str) { + let layer = id(s, name); + s.execute("layer.select", json!({"layer": layer})).unwrap(); +} + +/// A new layer above the one selected (inside its group, if it is in one). +fn add(s: &mut Session, name: &str) { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); +} + +/// Groups the layer `name`. +fn group(s: &mut Session, name: &str, group_name: &str) { + select(s, name); + s.execute("layer.groupLayers", json!({"name": group_name})).unwrap(); +} + +fn set_expanded(s: &mut Session, name: &str, expanded: bool) { + let layer = id(s, name); + s.execute("layer.setExpanded", json!({"layer": layer, "expanded": expanded})).unwrap(); +} + +/// The rows the panel draws, top to bottom, two spaces per level: the rows and the filter the +/// Layers panel uses for the frame. +fn listed(app: &PhotocraftApp) -> Vec { + let doc = &app.session.active().unwrap().doc; + let f = Filter::of_app(app); + crate::layer_tree_ui::panel_rows(app, doc).into_iter().filter(|(_, l)| f.shows(l)).map(|(depth, l)| format!("{}{}", " ".repeat(depth), l.name)).collect() +} + +/// The selected layers' names. +fn selected(app: &PhotocraftApp) -> Vec { + let st = app.session.active().unwrap(); + st.selected_layers().iter().filter_map(|id| st.doc.layer(*id)).map(|l| l.name.clone()).collect() +} + +/// Top Pixel · Outer { Inner { Deep } } · Background. +fn nested() -> Session { + let mut s = new_session(); + add(&mut s, "Top Pixel"); + add(&mut s, "Deep"); + group(&mut s, "Deep", "Inner"); + group(&mut s, "Inner", "Outer"); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + s +} + +#[test] +fn lists_nested_groups_with_their_contents_and_hides_loose_layers() { + let app = app(nested()); + assert!(Filter::of_app(&app).active(), "Group filters at once"); + assert_eq!(listed(&app), ["Outer", " Inner", " Deep"]); +} + +#[test] +fn collapsed_groups_stay_collapsed() { + let mut app = app(nested()); + set_expanded(&mut app.session, "Inner", false); + assert_eq!(listed(&app), ["Outer", " Inner"], "a collapsed nested group hides its children"); + set_expanded(&mut app.session, "Outer", false); + assert_eq!(listed(&app), ["Outer"], "a collapsed group hides everything inside"); + set_expanded(&mut app.session, "Outer", true); + assert_eq!(listed(&app), ["Outer", " Inner"], "each group keeps its own state"); +} + +#[test] +fn an_artboard_is_not_listed_but_its_groups_are() { + let mut s = new_session(); + add(&mut s, "G child"); + group(&mut s, "G child", "Plain G"); + select(&mut s, "Background"); + add(&mut s, "A child"); + s.execute("layer.new.artboardFromLayers", json!({"name": "Artboard 1"})).unwrap(); + select(&mut s, "A child"); + add(&mut s, "GA child"); + group(&mut s, "GA child", "G in AB"); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + let mut app = app(s); + assert_eq!(listed(&app), [" G in AB", " GA child", "Plain G", " G child"]); + set_expanded(&mut app.session, "Artboard 1", false); + assert_eq!(listed(&app), ["Plain G", " G child"], "a collapsed artboard hides the groups inside it"); +} + +#[test] +fn the_list_does_not_depend_on_the_selection() { + let mut app = app(nested()); + select(&mut app.session, "Deep"); + let with_deep = listed(&app); + app.session.execute("select.deselectLayers", json!({})).unwrap(); + assert_eq!(listed(&app), with_deep, "same list with nothing selected"); +} + +#[test] +fn applying_it_keeps_a_selected_layer_in_a_group_and_deselects_a_loose_one() { + let mut app = app(nested()); + select(&mut app.session, "Deep"); + super::selection::deselect_hidden(&mut app); + assert_eq!(selected(&app), ["Deep"], "a listed layer stays selected"); + select(&mut app.session, "Top Pixel"); + super::selection::deselect_hidden(&mut app); + assert!(selected(&app).is_empty(), "a layer in no group is hidden, so it is deselected"); +} + +#[test] +fn group_is_set_by_name_through_the_control_channel() { + let ui = UiState::default(); + let f = super::patched(&ui, &json!({"by": "group"})).unwrap(); + assert_eq!(f.by, FilterBy::Group); + let err = super::patched(&ui, &json!({"by": "nope"})).unwrap_err(); + assert!(err.contains("group"), "the error lists group: {err}"); +} + +#[test] +fn lists_empty_groups_and_nothing_without_groups() { + let mut app = app(new_session()); + assert!(listed(&app).is_empty(), "no group: nothing is listed"); + app.session.execute("layer.new.group", json!({"name": "Empty Group"})).unwrap(); + assert_eq!(listed(&app), ["Empty Group"], "an empty group is listed as a group row"); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/kind_mode_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/kind_mode_tests.rs new file mode 100644 index 0000000000..260bda8812 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/kind_mode_tests.rs @@ -0,0 +1,117 @@ +//! #10878: the Kind and Mode filters list the same layers as the reference application. Fill +//! layers (solid colour without a vector shape, gradient, pattern) match no Kind button, a solid +//! colour fill with a vector mask lists as a shape, and the Mode list has no Pass Through. + +use photocraft_color::BlendMode; +use photocraft_doc::Document; +use serde_json::json; + +use super::{Filter, FilterBy, KINDS}; +use crate::state::UiState; + +fn new_session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +/// Make a layer with `cmd`, name it `name` and return its id. +fn make(s: &mut photocraft_engine::Session, cmd: &str, params: serde_json::Value, name: &str) -> u64 { + let id = s.execute(cmd, params).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + id +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn kinds(kinds: &[&str]) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Kind, layer_filter: kinds.iter().map(|k| k.to_string()).collect(), ..UiState::default() }) +} + +fn mode(mode: BlendMode) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Mode, layer_filter_mode: mode, ..UiState::default() }) +} + +/// Every kind of fill layer, a solid fill with a vector mask, and a Gradient Map adjustment. +fn fills() -> photocraft_engine::Session { + let mut s = new_session(); + make(&mut s, "layer.newFillLayer.solidColor", json!({"color": "#112233"}), "Solid"); + let shaped = make(&mut s, "layer.newFillLayer.solidColor", json!({"color": "#445566"}), "Solid shape"); + s.execute("layer.vectorMask.revealAll", json!({"layer": shaped})).unwrap(); + make(&mut s, "layer.newFillLayer.gradient", json!({}), "Gradient"); + make(&mut s, "layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"}), "Pattern"); + make(&mut s, "layer.newAdjustmentLayer.gradientMap", json!({}), "Gradient Map"); + s +} + +#[test] +fn fill_layers_match_no_kind_button_and_a_shaped_solid_fill_is_a_shape() { + let s = fills(); + let doc = &s.active().unwrap().doc; + // Each button on its own: the measured table, row by row. + assert_eq!(listed(doc, &kinds(&["adjustment"])), ["Gradient Map"], "only the adjustment layer is an adjustment"); + assert_eq!(listed(doc, &kinds(&["shape"])), ["Solid shape"], "a solid fill with a vector mask lists as a shape"); + assert!(listed(doc, &kinds(&["pixel"])).iter().all(|n| n == "Background"), "no fill layer is a pixel layer"); + // With all five buttons on, the plain solid, gradient and pattern fills are still not listed. + let all = listed(doc, &kinds(&KINDS)); + for fill in ["Solid", "Gradient", "Pattern"] { + assert!(!all.iter().any(|n| n == fill), "{fill} matches no Kind button: {all:?}"); + } + assert!(all.iter().any(|n| n == "Solid shape") && all.iter().any(|n| n == "Gradient Map")); +} + +#[test] +fn a_normal_group_is_listed_by_mode_and_a_pass_through_group_never_is() { + let mut s = new_session(); + make(&mut s, "layer.new.layer", json!({}), "Child"); + let normal = s.execute("layer.groupLayers", json!({"name": "Normal group"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": normal, "blend": "Normal"})).unwrap(); + let child = { + let doc = &s.active().unwrap().doc; + doc.walk().into_iter().find(|(_, _, l)| l.name == "Child").map(|(_, _, l)| l.id.0).unwrap() + }; + s.execute("layer.setProps", json!({"layer": child, "blend": "Multiply"})).unwrap(); + make(&mut s, "layer.new.layer", json!({}), "Other"); + s.execute("layer.groupLayers", json!({"name": "Pass group"})).unwrap(); + let doc = &s.active().unwrap().doc; + let pass = doc.walk().into_iter().find(|(_, _, l)| l.name == "Pass group").map(|(_, _, l)| l.blend).unwrap(); + assert_eq!(pass, BlendMode::PassThrough, "a new group passes through"); + + let normal_rows = listed(doc, &mode(BlendMode::Normal)); + assert!(normal_rows.iter().any(|n| n == "Normal group"), "a Normal group is listed under Normal: {normal_rows:?}"); + assert!(!normal_rows.iter().any(|n| n == "Child"), "its Multiply child is not"); + assert!(listed(doc, &mode(BlendMode::Multiply)).iter().any(|n| n == "Child"), "a Multiply layer inside a group is listed under Multiply"); + assert!(!normal_rows.iter().any(|n| n == "Pass group"), "a Pass Through group is not"); + for m in BlendMode::LAYER_MODES { + assert!(!listed(doc, &mode(m)).iter().any(|n| n == "Pass group"), "no mode lists the Pass Through group ({m:?})"); + } +} + +#[test] +fn the_mode_filter_takes_no_pass_through() { + let ui = UiState::default(); + let err = super::patched(&ui, &json!({"mode": "Pass Through"})).unwrap_err(); + assert!(err.contains("Pass Through"), "the error names what was asked for: {err}"); + for m in BlendMode::LAYER_MODES { + assert!(err.contains(m.label()), "the error names the valid mode {}: {err}", m.label()); + } + // The 27 layer modes still work. + assert_eq!(BlendMode::LAYER_MODES.len(), 27); + assert_eq!(super::patched(&ui, &json!({"mode": "Multiply"})).unwrap().mode, BlendMode::Multiply); + assert_eq!(super::patched(&ui, &json!({"mode": "Normal"})).unwrap().mode, BlendMode::Normal); +} + +#[test] +fn a_stored_pass_through_filter_mode_loads_as_normal() { + let mut stored = serde_json::to_value(UiState::default()).unwrap(); + stored["layer_filter_mode"] = serde_json::to_value(BlendMode::PassThrough).unwrap(); + let ui: UiState = serde_json::from_value(stored.clone()).unwrap(); + assert_eq!(ui.layer_filter_mode, BlendMode::Normal); + // Any other stored mode is kept as it was. + stored["layer_filter_mode"] = serde_json::to_value(BlendMode::Screen).unwrap(); + let ui: UiState = serde_json::from_value(stored).unwrap(); + assert_eq!(ui.layer_filter_mode, BlendMode::Screen); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/selection.rs b/crates/ui-egui/src/panels/layer_filter_ui/selection.rs new file mode 100644 index 0000000000..50eb4f29f1 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/selection.rs @@ -0,0 +1,50 @@ +//! #10879: applying the Layers panel filter, or changing its criteria while it filters, deselects +//! the selected layers it no longer lists, as in Photoshop. The listed ones stay selected (one of +//! them becomes the active layer when the active one is hidden), and turning the filter off changes +//! nothing. Like any layer selection this is no history step. A layer selected later, while the +//! filter hides it, stays selected: the filter only acts when it changes. + +use photocraft_doc::LayerId; +use serde_json::json; + +use super::Filter; +use crate::PhotocraftApp; + +/// Deselect the layers the filter hides when its criteria changed since the last call. The +/// criteria are kept in `ctx`'s memory, so a change from the filter row or from `ui.set +/// {layerFilter}` is seen the next time the row is drawn; with nothing kept yet (the row's first +/// draw, e.g. after `ui.set` while the panel was closed) a filter that is on counts as applied. +pub fn follow(app: &mut PhotocraftApp, ctx: &egui::Context) { + let id = egui::Id::unique("layer-filter-applied"); + let now = Filter::of(&app.ui); + let before = ctx.data_mut(|d| { + let before = d.get_temp::(id); + d.insert_temp(id, now.clone()); + before + }); + if before.is_none_or(|b| b != now) && now.active() { + deselect_hidden(app); + } +} + +/// Deselect the active document's selected layers that [`Filter::of_app`] doesn't list: through +/// `select.deselectLayers` when none stays listed, else by keeping only the listed ones. +pub fn deselect_hidden(app: &mut PhotocraftApp) { + let f = Filter::of_app(app); + if !f.active() { + return; + } + let Some(st) = app.session.active() else { return }; + let selected = st.selected_layers(); + let listed: Vec = selected.iter().copied().filter(|id| st.doc.layer(*id).is_some_and(|l| f.shows(l))).collect(); + if listed.len() == selected.len() && st.active_layer.is_none_or(|a| listed.contains(&a)) { + return; + } + if listed.is_empty() { + let _ = app.run("select.deselectLayers", json!({})); + return; + } + let active = st.active_layer.filter(|a| listed.contains(a)).or(listed.last().copied()); + let anchor = st.layer_anchor.filter(|a| listed.contains(a)).or(active); + let _ = photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, listed, active, anchor); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/selection_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/selection_tests.rs new file mode 100644 index 0000000000..a468741190 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/selection_tests.rs @@ -0,0 +1,138 @@ +//! #10879: applying a Layers panel filter deselects the selected layers it hides, against the +//! cases measured in Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +use super::FilterBy; +use crate::PhotocraftApp; + +/// "Red" and "Deep Pixel" (pixel layers) and "Hello" (type) over the Background, in the panel. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Deep Pixel"})).unwrap(); + let text = s.execute("type.create", json!({"text": "Hello", "x": 4, "y": 20, "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": text, "name": "Hello"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + h +} + +fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId { + h.state() + .session + .active() + .unwrap() + .doc + .walk() + .into_iter() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| l.id) + .unwrap_or_else(|| panic!("no layer {name}")) +} + +fn select(h: &mut Harness<'_, PhotocraftApp>, names: &[&str]) { + for (i, name) in names.iter().enumerate() { + let layer = id(h, name).0; + h.state_mut().session.execute("layer.select", json!({"layer": layer, "mode": if i == 0 { "replace" } else { "add" }})).unwrap(); + } + h.run_steps(4); +} + +/// The selected layers' names, bottom to top, and the active layer's. +fn selection(h: &Harness<'_, PhotocraftApp>) -> (Vec, Option) { + let st = h.state().session.active().unwrap(); + let name = |id: LayerId| st.doc.layer(id).unwrap().name.clone(); + (st.selected_layers().into_iter().map(name).collect(), st.active_layer.map(name)) +} + +fn none() -> (Vec, Option) { + (Vec::new(), None) +} + +#[test] +fn a_filter_that_hides_the_selected_layer_deselects_it_and_turning_it_off_keeps_that() { + let mut h = harness(); + select(&mut h, &["Red"]); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(selection(&h), none(), "Kind › Type hides Red: no layer is selected"); + h.state_mut().ui.layer_filter.clear(); + h.run_steps(4); + assert_eq!(selection(&h), none(), "turning the filter off does not bring the selection back"); +} + +/// Batch 16 review: a filter already on when the row is first drawn (set before the panel drew, +/// as `ui.set {layerFilter}` can) deselects the layers it hides too. +#[test] +fn a_filter_on_before_the_rows_first_draw_deselects_the_hidden_layers() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("type.create", json!({"text": "Hello", "x": 4, "y": 20, "size": 12})).unwrap(); + let red = s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == "Red").map(|(_, _, l)| l.id).unwrap(); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter = vec!["type".into()]; + app + }); + h.run_steps(8); + assert_eq!(selection(&h), none(), "Kind › Type hides Red from the first draw on"); +} + +#[test] +fn a_filter_that_lists_none_of_the_selected_layers_deselects_them_all() { + let mut h = harness(); + select(&mut h, &["Hello", "Deep Pixel"]); + h.state_mut().ui.layer_filter_by = FilterBy::SmartObject; + h.state_mut().ui.layer_filter_smart = vec!["embedded".into()]; + h.run_steps(4); + assert_eq!(selection(&h), none(), "Smart Object › Embedded matches neither"); +} + +#[test] +fn the_listed_layers_stay_selected_and_one_of_them_becomes_active() { + let mut h = harness(); + select(&mut h, &["Red", "Hello", "Deep Pixel"]); + assert_eq!(selection(&h).1.as_deref(), Some("Deep Pixel")); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(selection(&h), (vec!["Hello".to_string()], Some("Hello".to_string()))); + let history = h.state().session.active().unwrap().history.entries().len(); + // Changing the criteria while the filter is on acts again: Name "zzzz" lists nothing. + h.state_mut().ui.layer_filter_by = FilterBy::Name; + h.state_mut().ui.layer_filter_name = "zzzz".into(); + h.run_steps(4); + assert_eq!(selection(&h), none()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), history, "deselecting is no history step"); +} + +#[test] +fn a_layer_selected_while_the_filter_hides_it_stays_selected() { + let mut h = harness(); + select(&mut h, &["Hello"]); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(selection(&h).0, ["Hello"]); + select(&mut h, &["Red"]); + assert_eq!(selection(&h).0, ["Red"], "the filter only acts when it is applied"); +} + +#[test] +fn a_filter_set_through_the_control_channel_deselects_too() { + let mut h = harness(); + select(&mut h, &["Red"]); + let f = super::patched(&h.state().ui, &json!({"by": "name", "name": "hello"})).unwrap(); + super::apply(&mut h.state_mut().ui, f); + h.run_steps(4); + assert_eq!(selection(&h), none()); +} diff --git a/crates/ui-egui/src/panels/layer_menu_ui.rs b/crates/ui-egui/src/panels/layer_menu_ui.rs index f274e28620..6158ba8997 100644 --- a/crates/ui-egui/src/panels/layer_menu_ui.rs +++ b/crates/ui-egui/src/panels/layer_menu_ui.rs @@ -59,11 +59,14 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { // Photoshop's New Group dialog. v.push(Some((tl!("Group from Layers…"), "layer.new.groupFromLayers"))); v.push(None); - v.push(Some((tl!("Quick Export As PNG"), "layer.quickExportAsPng"))); + v.push(Some((tl!("Quick Export as PNG"), "layer.quickExportAsPng"))); v.push(Some((tl!("Export As…"), "layer.exportAs"))); v.push(None); v.push(Some((tl!("Artboard from Layers…"), "layer.new.artboardFromLayers"))); v.push(Some((tl!("Frame from Layers…"), "layer.new.frameFromLayers"))); + if !multi && matches!(l.content, LayerContent::Shape(_) | LayerContent::Text(_)) { + v.push(Some((tl!("Convert to Frame…"), "layer.frame.convert"))); + } v.push(None); v.push(Some((tl!("Convert to Smart Object"), "layer.smartObjects.convertToSmartObject"))); if multi { @@ -73,10 +76,15 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { v.push(Some(("Rasterize Layers", "layer.rasterize.layer"))); } else { match &l.content { - LayerContent::Text(_) => v.push(Some(("Rasterize Type", "layer.rasterize.type"))), + LayerContent::Text(_) => { + v.push(Some(("Rasterize Type", "layer.rasterize.type"))); + v.push(Some((tl!("Create Work Path"), "type.createWorkPath"))); + v.push(Some((tl!("Convert to Shape"), "type.convertToShape"))); + } LayerContent::Shape(_) => v.push(Some(("Rasterize Layer", "layer.rasterize.shape"))), LayerContent::Smart(_) => { v.push(Some((tl!("Edit Contents"), "layer.smartObjects.editContents"))); + v.push(Some((tl!("Reset Transform"), "layer.smartObjects.resetTransform"))); v.push(Some((tl!("Replace Contents…"), "layer.smartObjects.replaceContents"))); v.push(Some((tl!("Relink to File…"), "layer.smartObjects.relinkToFile"))); v.push(Some((tl!("Convert to Layers"), "layer.smartObjects.convertToLayers"))); diff --git a/crates/ui-egui/src/panels/layer_menu_ui/tests.rs b/crates/ui-egui/src/panels/layer_menu_ui/tests.rs index a7ab70a76e..11c29e58ba 100644 --- a/crates/ui-egui/src/panels/layer_menu_ui/tests.rs +++ b/crates/ui-egui/src/panels/layer_menu_ui/tests.rs @@ -271,11 +271,37 @@ fn edit_contents_is_available_only_for_smart_objects() { let ids: Vec<_> = entries(&layer(&s), false, false).into_iter().flatten().map(|e| e.1).collect(); let edit = ids.iter().position(|id| *id == "layer.smartObjects.editContents").expect("Edit Contents entry"); assert_eq!( - ids[edit + 1..edit + 5], - ["layer.smartObjects.replaceContents", "layer.smartObjects.relinkToFile", "layer.smartObjects.convertToLayers", "layer.rasterize.smartObject"] + ids[edit + 1..edit + 6], + [ + "layer.smartObjects.resetTransform", + "layer.smartObjects.replaceContents", + "layer.smartObjects.relinkToFile", + "layer.smartObjects.convertToLayers", + "layer.rasterize.smartObject" + ], + "Reset Transform follows Edit Contents, as in the reference flyout" ); } +/// A type layer's menu offers Photoshop's outline conversions after Rasterize Type, and Convert +/// to Shape from it turns the text into a shape layer. +#[test] +fn type_layer_menu_converts_to_shape() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("type.create", json!({"text": "Hi", "size": 24, "x": 8, "y": 32})).unwrap(); + let layer = |s: &photocraft_engine::Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().clone() + }; + let ids: Vec<_> = entries(&layer(&s), false, false).into_iter().flatten().map(|e| e.1).collect(); + let r = ids.iter().position(|id| *id == "layer.rasterize.type").expect("Rasterize Type entry"); + assert_eq!(ids[r + 1..r + 3], ["type.createWorkPath", "type.convertToShape"]); + assert!(!entries(&layer(&s), true, false).iter().flatten().any(|e| e.1 == "type.convertToShape"), "the selection menu keeps one Rasterize item"); + s.execute("type.convertToShape", json!({})).unwrap(); + assert!(matches!(layer(&s).content, LayerContent::Shape(_))); +} + /// Right-clicking inside a multi-layer selection offers one Rasterize item for the whole /// selection, whatever kind the clicked layer is, and no single-layer Smart Object items. #[test] @@ -289,12 +315,14 @@ fn multi_selection_menu_rasterizes_the_selection() { let smart = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); let ids = |multi| entries(&smart, multi, false).into_iter().flatten().map(|e| e.1).collect::>(); assert!(ids(false).contains(&"layer.smartObjects.editContents"), "a single smart object keeps its items"); + assert!(ids(false).contains(&"layer.smartObjects.resetTransform"), "and its Reset Transform"); let multi = ids(true); assert!(multi.contains(&"layer.rasterize.layer")); for single in [ "layer.rasterize.smartObject", "layer.rasterize.shape", "layer.smartObjects.editContents", + "layer.smartObjects.resetTransform", "layer.smartObjects.replaceContents", "layer.smartObjects.relinkToFile", "layer.smartObjects.convertToLayers", @@ -394,7 +422,7 @@ fn multi_selection_menu_greys_what_acts_on_one_layer() { assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} works on one layer only"); } for label in [ - "Quick Export As PNG", + "Quick Export as PNG", "Export As…", "Add Layer Mask", "Duplicate Layers…", diff --git a/crates/ui-egui/src/panels/layer_props_ui.rs b/crates/ui-egui/src/panels/layer_props_ui.rs index 6f500049bc..315e762a1b 100644 --- a/crates/ui-egui/src/panels/layer_props_ui.rs +++ b/crates/ui-egui/src/panels/layer_props_ui.rs @@ -51,21 +51,65 @@ fn edit_tolerance(layer: &Layer, u: LenUnit, extent: f64) -> f32 { /// A number field that reports a value once committed (drag released, Enter, focus lost). `label` is its own /// label and the one stacked with it in the same column ("W" over "X"), which share a centre. -/// `len` is the unit shown and its 100% extent (for percent); `name` is the field's accessible name. +/// `id` names the field and the layer it edits. +/// `len` is the unit shown, its 100% extent (for percent) and the length a percentage typed into a +/// length field is a share of (`50%` in a px W is half the current width, as in Photoshop, +/// #10784); `name` is the field's accessible name. // The explicit accessible name accompanies the existing numeric-field parameters. #[expect(clippy::too_many_arguments)] -fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), name: &str, current: f32, width: f32, len: (LenUnit, f64), tol: f32) -> Option { - let (u, extent) = len; +fn field(ui: &mut egui::Ui, id: (&str, u64), label: (&str, &str), name: &str, current: f32, width: f32, len: (LenUnit, f64, f64), tol: f32) -> Option { + let (u, extent, typed_pct) = len; + let (id, layer) = id; stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0); let key = ui.make_persistent_id(("layer-props-field", id)); + // A wheeled value waiting for its gesture to end is its layer's, held while it is drawn. + // It is dropped rather than applied when another layer was made active in the meantime, when + // a pass went by without the field (the section collapsed, a layer without these fields + // shown), however briefly, or when what it edits changed underneath it (an Undo): applied + // later it would be a stale edit. + let wheel_key = key.with("wheel"); + let pass = ui.ctx().cumulative_pass_nr(); + let held = ui.data(|d| d.get_temp::<(u64, u64, u32)>(wheel_key)); + let pending = held.is_some_and(|(l, drawn, from)| l == layer && pass.saturating_sub(drawn) <= 1 && from == current.to_bits()); + if held.is_some() && !pending { + ui.data_mut(|d| { + d.remove::<(u64, u64, u32)>(wheel_key); + d.remove::(key); + }); + } let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); - let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width).accessible_name(tl!(name)); + // A field showing percent keeps its own 100%, so a length typed into it converts as shown. + // Otherwise the current size, while the field is edited: clicking from W into a linked H + // commits W as H gains focus, and H's `50%` must take half the height W left. Only the size + // a commit was read against is kept, for the next frame: egui reads the text once more the + // frame after it commits, which against the new size would apply `50%` twice. + let pct_key = key.with("pct"); + let typed_extent = if u.unit == photocraft_engine::prefs::Unit::Percent { extent } else { ui.data(|d| d.get_temp(pct_key)).unwrap_or(typed_pct) }; + let units = widgets::FieldUnits { unit: u.unit, dpi: u.dpi, ppi: u.ppi }; + let resp = widgets::value_field_len(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width, units, typed_extent).accessible_name(tl!(name)); + if resp.lost_focus() { + ui.data_mut(|d| d.insert_temp(pct_key, typed_extent)); + } else { + ui.data_mut(|d| d.remove::(pct_key)); + } + // The wheel over the field is held like a drag: each notch applied as its own edit resampled + // the layer again from the last result, and a fast scroll destroyed it (#9898). + let wheeled = resp.changed() || pending; + if wheeled && !resp.dragged() && !resp.has_focus() && crate::field_wheel::field_gesture_running(ui.ctx()) { + ui.data_mut(|d| { + d.insert_temp(key, v); + d.insert_temp(wheel_key, (layer, pass, current.to_bits())); + }); + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::field_wheel::GESTURE_GAP)); + return None; + } if resp.dragged() || resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; } + let wheel_ended = ui.data_mut(|d| d.remove_temp::<(u64, u64, u32)>(wheel_key)).is_some(); ui.data_mut(|d| d.remove::(key)); - if !(resp.drag_stopped() || resp.lost_focus() || resp.changed()) { + if !(resp.drag_stopped() || resp.lost_focus() || resp.changed() || wheel_ended) { return None; } let px = u.px(v, extent) as f32; @@ -167,24 +211,24 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", ("W", "X"), "Width", (bb[2] - bb[0]) as f32, w, (u, dw), tw) { + if let Some(v) = field(ui, ("w", layer.id.0), ("W", "X"), "Width", (bb[2] - bb[0]) as f32, w, (u, dw, bb[2] - bb[0]), tw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked, tw).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", ("H", "Y"), "Height", (bb[3] - bb[1]) as f32, w, (u, dh), th) { + if let Some(v) = field(ui, ("h", layer.id.0), ("H", "Y"), "Height", (bb[3] - bb[1]) as f32, w, (u, dh, bb[3] - bb[1]), th) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked, th).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", ("X", "W"), "X", bb[0] as f32, w, (u, dw), 0.5) { + if let Some(v) = field(ui, ("x", layer.id.0), ("X", "W"), "X", bb[0] as f32, w, (u, dw, dw), 0.5) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - bb[0] as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", ("Y", "H"), "Y", bb[1] as f32, w, (u, dh), 0.5) { + if let Some(v) = field(ui, ("y", layer.id.0), ("Y", "H"), "Y", bb[1] as f32, w, (u, dh, dh), 0.5) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - bb[1] as f32).round() as i32}))); } }); @@ -348,6 +392,446 @@ mod tests { assert!((at("H") - at("Y")).abs() < 0.01, "H and Y: {centres:?}"); } + /// #9898: wheeling over W applied each notch as its own Free Transform, resampling the layer + /// again from the last result until a fast scroll destroyed it. A wheel gesture is one edit, + /// applied when it stops, from the pixels it started with. + #[test] + fn wheeling_the_width_is_one_transform_when_the_wheel_stops() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + crate::field_wheel::begin_frame(ui.ctx()); + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let history = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let width = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + h.state_mut().layer_frame_bounds(&layer, layer.surface().unwrap()).width() + }; + let before = history(&h); + assert_eq!(width(&mut h), 100); + let at = h.get_by_label("Width").rect().center(); + h.hover_at(at); + h.run_steps(1); + for _ in 0..8 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + assert_eq!(history(&h), before, "nothing applied while the wheel turns"); + assert_eq!(width(&mut h), 100); + h.run_steps(12); + assert_eq!(history(&h), before + 1, "one edit for the whole gesture"); + assert_eq!(width(&mut h), 108); + } + + /// A wheeled width still waiting for its gesture to end is dropped, not applied to another + /// layer made active in the meantime (or to its own when it is made active again). + #[test] + fn a_wheeled_width_is_not_applied_to_another_layer() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + let mut layers = Vec::new(); + for (x, w) in [(10, 100), (120, 40)] { + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": x, "y": 10, "width": w, "height": 40})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + layers.push(app.session.active().unwrap().active_layer.unwrap()); + } + app.run("layer.select", json!({"layer": layers[0].0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + crate::field_wheel::begin_frame(ui.ctx()); + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let widths = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let doc = h.state().session.active().unwrap().doc.clone(); + layers + .iter() + .map(|id| doc.layer(*id).unwrap().clone()) + .map(|l| h.state_mut().layer_frame_bounds(&l, l.surface().unwrap()).width()) + .collect::>() + }; + assert_eq!(widths(&mut h), vec![100, 40]); + let at = h.get_by_label("Width").rect().center(); + h.hover_at(at); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.state_mut().run("layer.select", json!({"layer": layers[1].0})).unwrap(); + h.run_steps(12); + assert_eq!(widths(&mut h), vec![100, 40], "the other layer took the first one's wheeled width"); + h.state_mut().run("layer.select", json!({"layer": layers[0].0})).unwrap(); + h.run_steps(12); + assert_eq!(widths(&mut h), vec![100, 40], "the dropped width came back on its own layer"); + } + + /// A wheeled width whose field stops being drawn before the gesture ends (Transform + /// collapsed) is dropped, not applied as a stale edit when the field shows again. + #[test] + fn a_wheeled_width_left_undrawn_is_dropped() { + use egui_kittest::kittest::Queryable; + let hide = egui::Id::unique("test-hide-layer-props"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(0.05).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + crate::field_wheel::begin_frame(ui.ctx()); + if ui.data(|d| d.get_temp::(hide)).unwrap_or(false) { + return; + } + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let history = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let before = history(&h); + let at = h.get_by_label("Width").rect().center(); + h.hover_at(at); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.ctx.data_mut(|d| d.insert_temp(hide, true)); + h.run_steps(40); + h.ctx.data_mut(|d| d.insert_temp(hide, false)); + h.run_steps(12); + assert_eq!(history(&h), before, "the stale wheeled width was applied when the field showed again"); + } + + /// An Undo while a wheeled width waits drops it: applied afterwards it would redo the edit + /// the Undo just took back, under a new history step. + #[test] + fn a_wheeled_width_is_dropped_by_an_undo_during_the_gesture() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let width = |app: &mut PhotocraftApp| { + let st = app.session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + app.layer_frame_bounds(&layer, layer.surface().unwrap()).width() + }; + let original = width(&mut app); + let st = app.session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let bb = frame(&layer, app.layer_frame_bounds(&layer, layer.surface().unwrap())); + let p = transform_params(layer.id.0, bb, Some((bb[2] - bb[0]) as f32 + 20.0), None, false, 0.5).unwrap(); + app.run("edit.transform", p).unwrap(); + assert!(width(&mut app) > original); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + crate::field_wheel::begin_frame(ui.ctx()); + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let at = h.get_by_label("Width").rect().center(); + h.hover_at(at); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.state_mut().run("edit.undo", json!({})).unwrap(); + let history = h.state().session.active().unwrap().history.past_len(); + h.run_steps(40); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history, "the wheeled width was applied after the Undo"); + assert_eq!(width(h.state_mut()), original, "the Undo stands"); + } + + /// A wheeled width is dropped when a layer without these fields (a group) is shown, even for + /// a moment: going back to the layer before the gesture ends must not apply it. + #[test] + fn a_wheeled_width_is_dropped_by_a_moment_on_a_group() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.new.group", json!({})).unwrap(); + let group = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": layer.0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + crate::field_wheel::begin_frame(ui.ctx()); + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let history = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let at = h.get_by_label("Width").rect().center(); + h.hover_at(at); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.state_mut().run("layer.select", json!({"layer": group.0})).unwrap(); + h.run_steps(2); + h.state_mut().run("layer.select", json!({"layer": layer.0})).unwrap(); + let before = history(&h); + h.run_steps(40); + assert_eq!(history(&h), before, "the wheeled width was applied after a moment on the group"); + } + + /// Moving straight from wheeling W to wheeling H is still W's gesture: W's value lands as + /// soon as the wheel turns off it, and H doesn't step until a gesture of its own, so the + /// two never apply from the same original box together. + #[test] + fn wheeling_width_then_height_applies_each_once() { + use egui_kittest::kittest::Queryable; + let setup = || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app + }; + let app = setup(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + crate::field_wheel::begin_frame(ui.ctx()); + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let history = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let size = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let r = h.state_mut().layer_frame_bounds(&layer, layer.surface().unwrap()); + (r.width(), r.height()) + }; + let before = history(&h); + let notch = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + }; + let (w_at, h_at) = (h.get_by_label("Width").rect().center(), h.get_by_label("Height").rect().center()); + // What W's four notches alone make, and H's two after them, applied the same way on a copy + // of the document. + let (w_only, then_h) = { + let mut app = setup(); + let st = app.session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let bb = frame(&layer, app.layer_frame_bounds(&layer, layer.surface().unwrap())); + let p = transform_params(layer.id.0, bb, Some((bb[2] - bb[0]) as f32 + 4.0), None, true, 0.5).unwrap(); + app.run("edit.transform", p).unwrap(); + let st = app.session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let r = app.layer_frame_bounds(&layer, layer.surface().unwrap()); + let w_only = (r.width(), r.height()); + let bb = frame(&layer, r); + let p = transform_params(layer.id.0, bb, None, Some((bb[3] - bb[1]) as f32 + 2.0), true, 0.5).unwrap(); + app.run("edit.transform", p).unwrap(); + let st = app.session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let r = app.layer_frame_bounds(&layer, layer.surface().unwrap()); + (w_only, (r.width(), r.height())) + }; + h.hover_at(w_at); + h.run_steps(1); + for _ in 0..4 { + notch(&mut h); + } + h.hover_at(h_at); + h.run_steps(1); + for _ in 0..4 { + notch(&mut h); + } + h.run_steps(20); + assert_eq!(history(&h), before + 1, "W applied once, H not stepped by W's gesture"); + assert_eq!(size(&mut h), w_only, "W's four notches, linked, and nothing from H"); + // A new gesture over H is H's own. + for _ in 0..2 { + notch(&mut h); + } + h.run_steps(20); + assert_eq!(history(&h), before + 2); + assert_eq!(size(&mut h), then_h, "H's two notches, linked"); + } + + /// Types each `(name, text)` of `edits` in turn into the Transform field named `name` of an + /// 80 × 40 px rectangle shape at (10, 10) in a 200 × 120 document, with the rulers in `unit`, + /// and returns its path's [x, y, w, h] (a shape's path scales exactly, where resampled pixels + /// would blur its edge). Each edit is committed with Enter. + fn type_into_transform_fields(unit: photocraft_engine::prefs::Unit, edits: &[(&str, &str)]) -> [f64; 4] { + type_into_transform_fields_by(unit, edits, true) + } + + /// [`type_into_transform_fields`], committing each edit with Enter when `enter`, or else by + /// clicking into the next field (the last edit still with Enter). + fn type_into_transform_fields_by(unit: photocraft_engine::prefs::Unit, edits: &[(&str, &str)], enter: bool) -> [f64; 4] { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.prefs.edit(|p| p.units_and_rulers.rulers = unit); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 80, 40], "fill": "#336699"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + for (i, (name, text)) in edits.iter().enumerate() { + h.get_by_label(name).click(); + h.run(); + for c in text.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } + if enter || i + 1 == edits.len() { + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + } + let st = h.state().session.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("still a shape") }; + let c = sh.path.control_bounds().unwrap(); + [c.0, c.1, c.2 - c.0, c.3 - c.1].map(|v| (v * 100.0).round() / 100.0) + } + + /// #10784: a percentage typed into a W or H showing a length is that share of the layer's + /// size, and into X or Y that share of the document, as in Photoshop; never that many pixels. + #[test] + fn transform_fields_take_a_typed_percentage() { + use photocraft_engine::prefs::Unit; + // Linked W/H: half the width halves the height too (typed live, `50` alone would be 50 px). + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "50%")]), [10.0, 10.0, 40.0, 20.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Height", "150%")]), [10.0, 10.0, 120.0, 60.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("X", "50%")]), [100.0, 10.0, 80.0, 40.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Y", "50%")]), [10.0, 60.0, 80.0, 40.0]); + // A second edit takes its percentage of the size the first one left. + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "50%"), ("Width", "50%")]), [10.0, 10.0, 20.0, 10.0]); + // Clicking into the linked H commits W as H takes the focus: H's `50%` is half the height + // W left (#10802 review), and the same from H into W. + assert_eq!(type_into_transform_fields_by(Unit::Pixels, &[("Width", "50%"), ("Height", "50%")], false), [10.0, 10.0, 20.0, 10.0]); + assert_eq!(type_into_transform_fields_by(Unit::Pixels, &[("Height", "50%"), ("Width", "50%")], false), [10.0, 10.0, 20.0, 10.0]); + // And with Enter between them. + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "50%"), ("Height", "50%")]), [10.0, 10.0, 20.0, 10.0]); + // In inches too (72 dpi: 80 px is 1.11 in). + assert_eq!(type_into_transform_fields(Unit::Inches, &[("Width", "50%")]), [10.0, 10.0, 40.0, 20.0]); + // A plain number and another length are unchanged. + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "60")]), [10.0, 10.0, 60.0, 30.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "1in")]), [10.0, 10.0, 72.0, 36.0]); + // Rulers in percent show W as a share of the 200 px document; a typed length is that length. + assert_eq!(type_into_transform_fields(Unit::Percent, &[("Width", "40px")]), [10.0, 10.0, 40.0, 20.0]); + assert_eq!(type_into_transform_fields(Unit::Percent, &[("Width", "25")]), [10.0, 10.0, 50.0, 25.0]); + } + #[test] fn transform_fields_show_and_take_the_ruler_unit() { use photocraft_engine::prefs::Unit; diff --git a/crates/ui-egui/src/panels/layer_reveal.rs b/crates/ui-egui/src/panels/layer_reveal.rs index 2b9976de1b..9ae5eb01cb 100644 --- a/crates/ui-egui/src/panels/layer_reveal.rs +++ b/crates/ui-egui/src/panels/layer_reveal.rs @@ -27,6 +27,11 @@ pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option { let index = app.session.active_index()?; let active = app.session.active()?.active_layer; let now = Seen(index, active); + // The marker says the latest active-layer change was a Move-way pick of that layer: it holds only + // while no later change happened (the engine's change count is unchanged), however many frames a + // hidden panel missed, and whether or not the final layer is the same id. + let changes = app.session.active_layer_changes(); + let pick = app.move_pick.take().filter(|&(id, at)| Some(id) == active && at == changes).map(|(id, _)| id); let before = ctx.data(|d| d.get_temp::(seen_id())); ctx.data_mut(|d| d.insert_temp(seen_id(), now)); match before { @@ -34,7 +39,12 @@ pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option { _ => return None, } let id = active?; - for group in closed_ancestors(app, id) { + // Expand layer group on click (Move gear menu, #11381) governs a canvas click that picked this + // layer the Move tool way (Auto-Select, or a ⌘-click from another tool); every other change + // opens the groups. The row still scrolls into view. + let move_pick = pick == Some(id); + let expand = !move_pick || app.ui.tool_options.move_expand_group_on_click; + for group in closed_ancestors(app, id).into_iter().filter(|_| expand) { // A view change (no history step); a failure just leaves the group closed. let _ = app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true})); } diff --git a/crates/ui-egui/src/panels/layer_row.rs b/crates/ui-egui/src/panels/layer_row.rs index cb29276091..e9b984d81f 100644 --- a/crates/ui-egui/src/panels/layer_row.rs +++ b/crates/ui-egui/src/panels/layer_row.rs @@ -1,4 +1,4 @@ -use super::layer_drag::{MaskDrag, fx_drop, layer_drag_and_drop, mask_drag_key, start_fx_drag, starts_mask_drag}; +use super::layer_drag::{MaskDrag, fx_drop, layer_drag_and_drop, mask_drag_key, mask_drop, start_fx_drag, starts_mask_drag}; use super::layers::simple_lock_toggle; use super::{ Color32, CornerRadius, Layer, LayerContent, PhotocraftApp, Pos2, Rect, Sense, Stroke, StrokeKind, Tokens, Value, icons, json, pos2, theme, vec2, widgets, @@ -104,6 +104,59 @@ fn swept_pointer_range(ctx: &egui::Context) -> Option<(f32, f32)> { Some(((p.y - delta.y).min(p.y), (p.y - delta.y).max(p.y))) } +/// A Frame row's second thumbnail (its image) beside the frame outline's: paints both targets' +/// brackets and handles the click that picks Frame / Image / Both and a double-click that opens +/// a smart-object image. Returns whether the click was taken. +#[allow(clippy::too_many_arguments)] +fn frame_content_thumb( + app: &mut PhotocraftApp, + ctx: &egui::Context, + ui: &egui::Ui, + painter: &egui::Painter, + (doc, l, rect): (&photocraft_doc::Document, &Layer, Rect), + (thumb, x, ts, selected): (Rect, f32, f32, bool), + resp: &egui::Response, + actions: &mut Vec<(String, Value)>, +) -> bool { + let t = Tokens::get(ctx); + let target = crate::frame_tool::target(app, l); + if selected && target != crate::frame_tool::Target::Content { + crate::mask_thumbs_ui::paint_brackets(painter, thumb, t.text); + } + let content_thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts)); + let child = l.children().and_then(|c| c.first()); + if let Some(child) = child { + draw_layer_thumb(app, ctx, ui, doc, child, content_thumb, false); + } else { + icons::paint(ui, content_thumb, "frame-placeholder", ts.min(24.0), t.icon); + } + if selected && target != crate::frame_tool::Target::Frame { + crate::mask_thumbs_ui::paint_brackets(painter, content_thumb, t.text); + } + let pos = resp.interact_pointer_pos(); + let on_content = pos.is_some_and(|p| content_thumb.contains(p)); + let mut clicked = false; + if resp.clicked() && !ui.input(|i| i.modifiers.command) && (on_content || pos.is_some_and(|p| thumb.contains(p))) { + let target = if ui.input(|i| i.modifiers.shift) { + crate::frame_tool::Target::Both + } else if on_content { + crate::frame_tool::Target::Content + } else { + crate::frame_tool::Target::Frame + }; + crate::frame_tool::select(app, l.id, target); + clicked = true; + } + if resp.double_clicked() + && on_content + && let Some(child) = child + && matches!(child.content, LayerContent::Smart(_)) + { + actions.push(("layer.smartObjects.editContents".into(), json!({"layer": child.id.0}))); + } + clicked +} + /// Marker in the clipped row's existing 12-point slot, beside its thumbnail. pub(super) fn paint_clipping_indicator(painter: &egui::Painter, at: Pos2, color: Color32) { let tip = at + vec2(5.0, 3.0); @@ -130,6 +183,7 @@ pub(super) fn layer_row( row: RowSel, actions: &mut Vec<(String, Value)>, ) -> Rect { + let row = RowSel { selected: row.selected || (l.frame.is_some() && crate::frame_tool::selected(app).is_some_and(|f| f.id == l.id)), ..row }; let selected = row.selected; let t = Tokens::get(ctx); // The Layers Panel Options thumbnail size controls rows and both layer/mask thumbnails. @@ -153,6 +207,7 @@ pub(super) fn layer_row( let mask_drag = mask_drag.is_some(); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); fx_drop(ctx, ui, l, rect, actions); + mask_drop(ctx, ui, l, rect, actions); crate::smart_ui::drop_on_row(ctx, ui, l, rect, actions); if resp.drag_started() && !mask_drag { crate::layer_transfer::begin_from_panel(app, ctx, l.id); @@ -197,6 +252,11 @@ pub(super) fn layer_row( draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary); x += ts + 6.0; } + let mut frame_clicked = false; + if l.frame.is_some() && ts > 0.0 { + frame_clicked = frame_content_thumb(app, ctx, ui, &painter, (doc, l, rect), (thumb, x, ts, selected), &resp, actions); + x += ts + 6.0; + } // With thumbnails disabled the labels and disclosure controls remain interactive. let masks = if ts > 0.0 { crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions) } else { Default::default() }; let rc = RowCtx { l, doc, row, rect, ts, thumb, resp: &resp, masks: &masks }; @@ -229,6 +289,9 @@ pub(super) fn layer_row( if !m.command { return None; } + if l.frame.is_some() && thumb.contains(p) { + return Some(crate::mask_thumbs_ui::load_params(l, crate::mask_thumbs_ui::MaskKind::Vector, m)); + } if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } @@ -253,7 +316,12 @@ pub(super) fn layer_row( actions.push(("select.loadSelection".into(), p)); } else if let Some(cmd) = mask_toggle { actions.push(cmd); - } else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked { + } else if resp.clicked() && !frame_clicked && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked { + if l.frame.is_some() { + app.ui.frame_tool.selected = Some(l.id.0); + app.ui.frame_tool.target = + if l.children().is_some_and(|c| !c.is_empty()) { crate::frame_tool::Target::Both } else { crate::frame_tool::Target::Frame }; + } select_row_on_click(app, ui, &rc, actions); } // Double-click: the name renames in place (over the row's full height, not just the glyphs: @@ -398,6 +466,7 @@ fn paint_row_name(painter: &egui::Painter, t: &Tokens, rc: &RowCtx<'_>, x: f32, if !is_pixel { let sub = match &l.content { LayerContent::Adjustment(a) => tl!(a.label()).to_string(), + LayerContent::Group(_) if l.frame.is_some() => tl!("Frame").to_string(), LayerContent::Group(g) => crate::i18n::trn(crate::i18n::current(), g.children.len() as u64, "Group · {n} layer", "Group · {n} layers"), other => tl!(other.kind_name()).to_string(), }; @@ -453,7 +522,16 @@ fn select_row_on_click(app: &mut PhotocraftApp, ui: &egui::Ui, rc: &RowCtx<'_>, /// Double-click: rename, un-background, open the thumbnail's settings or the row's Layer Style. fn handle_row_double_click(app: &mut PhotocraftApp, ctx: &egui::Context, rc: &RowCtx<'_>, name_rect: Option, actions: &mut Vec<(String, Value)>) { let (l, doc, resp, masks, ts, thumb, rect) = (rc.l, rc.doc, rc.resp, rc.masks, rc.ts, rc.thumb, rc.rect); - if resp.double_clicked() { + // A frame row's thumbnails are handled in the row; its name still renames in place. + if resp.double_clicked() + && l.frame.is_some() + && name_rect.is_some_and(|n| resp.interact_pointer_pos().is_some_and(|p| Rect::from_x_y_ranges(n.x_range(), rect.y_range()).expand(2.0).contains(p))) + && let Some(done) = crate::layer_row_ui::start_rename(ctx, l.id.0, &l.name) + { + // One rename at a time: starting this one commits any other (#314). + actions.push(done); + } + if resp.double_clicked() && l.frame.is_none() { let pos = resp.interact_pointer_pos(); let on = |r: Rect| pos.is_some_and(|p| r.expand(2.0).contains(p)); if crate::doc_props_ui::is_background(doc, l) { @@ -528,6 +606,20 @@ pub(super) fn layer_thumb_fit(cell: Rect, w: u32, h: u32) -> (Rect, Rect) { fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, doc: &photocraft_doc::Document, l: &Layer, rect: Rect, selected: bool) { let t = Tokens::get(ctx); let p = ui.painter(); + if let Some(f) = &l.frame { + let path = f.path.clone(); + for line in photocraft_vector::flatten::flatten_path(&path, 0.01) { + let mut points: Vec = + line.pts.iter().map(|&(x, y)| pos2(rect.left() + x as f32 * rect.width(), rect.top() + y as f32 * rect.height())).collect(); + if line.closed + && let Some(p) = points.first().copied() + { + points.push(p); + } + ui.painter().add(egui::Shape::line(points, Stroke::new(1.0, t.icon))); + } + return; + } match &l.content { LayerContent::Adjustment(_) | LayerContent::Group(_) => { p.rect_filled(rect, 6.0, t.field); diff --git a/crates/ui-egui/src/panels/layer_row_ui.rs b/crates/ui-egui/src/panels/layer_row_ui.rs index 82d924bacf..18258c46ea 100644 --- a/crates/ui-egui/src/panels/layer_row_ui.rs +++ b/crates/ui-egui/src/panels/layer_row_ui.rs @@ -277,21 +277,25 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } } ui.separator(); - run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage"); + run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage", Some(json!({}))); ui.separator(); - if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() { + let mut lock_layers = egui::Button::new(tl!("Lock Layers…")); + if let Some(key) = crate::shortcuts::shortcut_label(app, "layer.lockLayers") { + lock_layers = lock_layers.shortcut_text(key); + } + if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), lock_layers).clicked() { app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true }); ui.close(); } - run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer"); + run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer", Some(json!({}))); let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible))); if any_hidden { - run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers"); + run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers", Some(json!({}))); } else { - run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers"); + run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers", Some(json!({}))); } ui.separator(); - run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard"); + run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard", Some(json!({}))); ui.separator(); ui.menu_button(tl!("Filter Options"), |ui| { let mut show = app.ui.layers_panel_options.show_filters; diff --git a/crates/ui-egui/src/panels/layer_transfer.rs b/crates/ui-egui/src/panels/layer_transfer.rs index c9e45225f4..7534d63fbc 100644 --- a/crates/ui-egui/src/panels/layer_transfer.rs +++ b/crates/ui-egui/src/panels/layer_transfer.rs @@ -149,6 +149,20 @@ fn drop(app: &mut PhotocraftApp, ctx: &egui::Context, dest: usize, at: Option<[f clear(ctx); let docs = app.session.documents(); let Some(src) = docs.iter().position(|d| d.doc.id == t.source) else { return }; + if dest == src + && let Some(at) = at + { + if let Some(frame) = crate::frame_tool::frame_at(app, at) + && let [source] = t.layers.as_slice() + { + let r = app.run("layer.frame.setContent", json!({"layer":frame.0,"content":source})); + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } + } + return; + } let Some(to) = docs.get(dest).filter(|_| dest != src) else { return }; let same_size = to.doc.size == docs[src].doc.size; let mut p = json!({"document": dest, "source": src, "layers": t.layers}); diff --git a/crates/ui-egui/src/panels/layer_tree_ui.rs b/crates/ui-egui/src/panels/layer_tree_ui.rs index 858fe432fc..1d7586f4c6 100644 --- a/crates/ui-egui/src/panels/layer_tree_ui.rs +++ b/crates/ui-egui/src/panels/layer_tree_ui.rs @@ -24,6 +24,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> { for l in layers.iter().rev() { out.push((depth, l)); if let LayerContent::Group(g) = &l.content + && l.frame.is_none() && (g.expanded || show_all) { rec(&g.children, depth + 1, show_all, out); @@ -39,7 +40,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> { /// Mask session's working text, which is no layer to the user: Photoshop shows only the mask /// overlay while you type (#3036). pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> { - let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).active()); + let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).show_all()); if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) { rows.retain(|(_, l)| l.id.0 != working); } @@ -54,6 +55,9 @@ pub fn triangle_rect(row: Rect, x: f32) -> Rect { /// Paint and handle a group row's disclosure triangle at `*x` (advancing it). Returns true when /// it was clicked this frame, so the row doesn't also treat the click as a selection. pub fn disclosure(ui: &mut egui::Ui, row: Rect, x: &mut f32, l: &Layer, actions: &mut Vec<(String, Value)>) -> bool { + if l.frame.is_some() { + return false; + } let LayerContent::Group(g) = &l.content else { return false }; let hit = triangle_rect(row, *x); let resp = ui.interact(hit, ui.scope_id().with(("disclosure", l.id.0)), Sense::click()); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index ea5d672058..cc71b6884b 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -4,7 +4,9 @@ use super::layer_drag::{ }; use super::layer_row::{RowSel, layer_row}; use super::options_bar::{LAYER_PCT_W, body_text_width, pct_action, scrub_pct_label}; -use super::{BlendMode, Layer, LayerId, MaskKind, PhotocraftApp, Rect, Sense, Tokens, Value, empty, icons, json, label, pos2, theme, vec2, widgets}; +use super::{ + BlendMode, Layer, LayerId, MaskKind, PhotocraftApp, Rect, Sense, Tokens, Value, empty, icons, json, label, pos2, starts_blend_group, theme, vec2, widgets, +}; // ----------------------------------------------------------------------------- layers @@ -58,7 +60,7 @@ fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let mut m = BlendMode::Normal; let options = blend_options(false, m); - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width); + widgets::dropdown_wheel_grouped(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width, |m| starts_blend_group(*m)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = 100.0; widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -154,7 +156,9 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let (chosen, hovered) = - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width); + widgets::dropdown_wheel_grouped(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width, |m| { + starts_blend_group(*m) + }); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { let mut p = targets.clone(); @@ -262,6 +266,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let held = ctx.data(|d| { d.get_temp::(egui::Id::unique("layer-drag")).is_some() || d.get_temp::(fx_drag_key()).is_some() + || d.get_temp::(mask_drag_key()).is_some() || d.get_temp::(crate::smart_ui::drag_key()).is_some() }); let dragging = held && ctx.input(|i| i.pointer.primary_down()); @@ -271,7 +276,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.scroll_with_delta(vec2(0.0, delta)); ctx.request_repaint(); } - let filter = crate::layer_filter_ui::Filter::of_app(app); + let filter = crate::layer_filter_ui::Filter::with_selection(app, &doc, &selection); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last @@ -314,8 +319,21 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A click on that empty space deselects every layer, as in Photoshop (#8100). It // starts under the last layer's sub-rows, which take their own clicks. let empty = Rect::from_min_max(pos2(below.left(), rows_bottom), below.max); - if empty.height() > 0.0 && ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { - actions.push(("select.deselectLayers".into(), json!({}))); + if empty.height() > 0.0 { + let response = ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()); + if response.clicked() && !selection.is_empty() { + actions.push(("select.deselectLayers".into(), json!({}))); + } + // A right-click there opens the thumbnail size and clipping choices (#11404). + response.context_menu(|ui| super::layers_empty_menu::show(app, ui)); + } + } else { + // No row drawn (a filter that matches no layer): the whole list is empty space, + // and its right-click opens the same menu. + let empty = Rect::from_min_max(pos2(ui.max_rect().left(), rows_bottom), pos2(ui.max_rect().right(), ui.clip_rect().bottom())); + if empty.height() > 0.0 && empty.width() > 0.0 { + let response = ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()); + response.context_menu(|ui| super::layers_empty_menu::show(app, ui)); } } // Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The diff --git a/crates/ui-egui/src/panels/layers_empty_menu.rs b/crates/ui-egui/src/panels/layers_empty_menu.rs new file mode 100644 index 0000000000..17c1c0d6e0 --- /dev/null +++ b/crates/ui-egui/src/panels/layers_empty_menu.rs @@ -0,0 +1,43 @@ +//! The menu a right-click on the empty area below the last layer row opens (#11404): the +//! thumbnail size and what a thumbnail frames. Both are the settings Panel Options... edits. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::LayerThumbnailSize; + +/// The four sizes, in menu order. +pub(super) fn sizes() -> [(LayerThumbnailSize, &'static str); 4] { + [ + (LayerThumbnailSize::None, tl!("No Thumbnails")), + (LayerThumbnailSize::Small, tl!("Small Thumbnails")), + (LayerThumbnailSize::Medium, tl!("Medium Thumbnails")), + (LayerThumbnailSize::Large, tl!("Large Thumbnails")), + ] +} + +pub(super) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let current = app.ui.layers_panel_options.thumbnail_size; + for (size, label) in sizes() { + let mut on = current == size; + if crate::widgets::checkbox(ui, &mut on, label).clicked() { + app.ui.layers_panel_options.thumbnail_size = size; + super::layers_panel_ui::persist_options(app); + ui.close(); + } + } + ui.separator(); + let clip = app.session.prefs().interface.clip_thumbnails_to_layer_bounds; + for (to_layer, label) in [(true, tl!("Clip Thumbnails to Layer Bounds")), (false, tl!("Clip Thumbnails to Document Bounds"))] { + let mut on = clip == to_layer; + if crate::widgets::checkbox(ui, &mut on, label).clicked() { + if clip != to_layer + && let Err(e) = app.run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": to_layer})) + { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } + } +} diff --git a/crates/ui-egui/src/panels/layers_panel_ui.rs b/crates/ui-egui/src/panels/layers_panel_ui.rs index d640e3850d..a7dc5f9fb2 100644 --- a/crates/ui-egui/src/panels/layers_panel_ui.rs +++ b/crates/ui-egui/src/panels/layers_panel_ui.rs @@ -9,9 +9,13 @@ use crate::state::{LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog const OPTIONS_PREF: &str = "ui.layersPanelOptions"; -/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. +/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. A pick of the +/// current value writes nothing unless the saved entry is missing or stale. pub fn persist_options(app: &mut PhotocraftApp) { if let Ok(value) = serde_json::to_value(&app.ui.layers_panel_options) { + if app.session.prefs().dialogs.get(OPTIONS_PREF) == Some(&value) { + return; + } app.session.prefs.edit(|p| { p.dialogs.insert(OPTIONS_PREF.into(), value); }); @@ -27,11 +31,17 @@ pub fn restore_options(app: &mut PhotocraftApp) { } } -/// The real menu command dispatcher handles rename's in-place editor and other shell routing. -pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) { - if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() { +/// A panel-menu item running menu command `id` with `params` (`{}` for none) through the real +/// menu command dispatcher, which handles rename's in-place editor, opens a command's dialog where +/// it has one and does other shell routing. Greyed when `params` is `None` (nothing to act on) or +/// where the menu bar greys the command. +pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str, params: Option) { + let on = params.is_some() && crate::menus::is_enabled(app, id); + if ui.add_enabled(on, egui::Button::new(label)).clicked() + && let Some(params) = params + { let ctx = ui.ctx().clone(); - if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/panels/mask_thumbs_ui.rs b/crates/ui-egui/src/panels/mask_thumbs_ui.rs index c19b14928d..e232fdcf99 100644 --- a/crates/ui-egui/src/panels/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/panels/mask_thumbs_ui.rs @@ -49,6 +49,9 @@ impl MaskKind { /// `(kind, enabled, linked)` for each of `l`'s masks, in row order. pub fn masks(l: &Layer) -> Vec<(MaskKind, bool, bool)> { + if l.frame.is_some() { + return Vec::new(); + } let mut v = Vec::with_capacity(2); if let Some(m) = &l.mask { v.push((MaskKind::Pixel, m.enabled, m.linked)); @@ -61,13 +64,13 @@ pub fn masks(l: &Layer) -> Vec<(MaskKind, bool, bool)> { /// Width `l`'s masks take in a row with `ts`-point thumbnails (chain + thumbnail + gap each). pub fn width(l: &Layer, ts: f32) -> f32 { - masks(l).len() as f32 * (CHAIN_W + ts + 6.0) + if l.frame.is_some() { ts + 6.0 } else { masks(l).len() as f32 * (CHAIN_W + ts + 6.0) } } /// Thumbnail size for `l`'s row: `ts`, shrunk (down to 14 pt) when the layer and mask /// thumbnails would leave the name less than its minimum of the `room` they share. pub fn thumb_size(l: &Layer, ts: f32, room: f32) -> f32 { - let n = masks(l).len() as f32; + let n = if l.frame.is_some() { 1.0 } else { masks(l).len() as f32 }; let need = ts + 6.0 + width(l, ts) + crate::layer_row_ui::MIN_NAME_W; let short = need - room; if short <= 0.0 { ts } else { (ts - short / (1.0 + n)).clamp(14.0_f32.min(ts), ts) } diff --git a/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs index aeb8538256..49352643a0 100644 --- a/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs @@ -844,28 +844,225 @@ fn selecting_an_adjustment_layer_from_another_layer_targets_its_mask() { // The reference application: an adjustment layer picked by its name targets its mask; a // masked pixel layer targets its contents; the content thumbnail still targets contents. for command in ["layer.newAdjustmentLayer.hueSaturation", "layer.newAdjustmentLayer.curves"] { - let (mut s, masked, other) = session(); - s.execute(command, json!({})).unwrap(); - let id = s.active().unwrap().active_layer.unwrap().0; - let mut h = harness(s, 0, 1.0, 360.0); - assert!(layer(&h, id).mask.is_some(), "{command}: the adjustment layer has a mask"); - let name_of = - |h: &Harness<'_, PhotocraftApp>, l: u64| crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == l).unwrap().name.unwrap().center(); - let at = name_of(&h, other); - click_with(&mut h, at, Modifiers::NONE); - let at = name_of(&h, id); - click_with(&mut h, at, Modifiers::NONE); - assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id); - assert!(h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a name click targets the mask"); - assert_eq!(crate::canvas::paint_target(h.state()), json!("mask")); - // The content thumbnail targets the contents. - let thumb = layer_thumb_center(&h, id); - click_with(&mut h, thumb, Modifiers::NONE); - assert!(!h.state().ui.mask_target, "{command}: the content thumbnail targets contents"); - // A masked pixel layer picked by its name targets its contents. - let at = name_of(&h, masked); - click_with(&mut h, at, Modifiers::NONE); - assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, masked); - assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a pixel layer targets its contents"); + for ppp in [1.0, 2.0] { + let (mut s, masked, other) = session(); + s.execute(command, json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + let path = json!({"subpaths": [{"knots": [[20, 10], [120, 10], [120, 90], [20, 90]]}]}); + s.execute("layer.vectorMask.add", json!({"layer": id, "path": path})).unwrap(); + let mut h = harness(s, 0, ppp, 360.0); + assert!(layer(&h, id).mask.is_some(), "{command}: the adjustment layer has a mask"); + let name_of = |h: &Harness<'_, PhotocraftApp>, l: u64| { + crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == l).unwrap().name.unwrap().center() + }; + // Each mask thumbnail targets its own mask. + for kind in [MaskKind::Pixel, MaskKind::Vector] { + let at = mask_rect(&h, id, kind).center(); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().ui.mask_target, kind == MaskKind::Pixel, "{command} @{ppp}x {kind:?}"); + assert_eq!(h.state().ui.vector_mask_target, kind == MaskKind::Vector, "{command} @{ppp}x {kind:?}"); + } + let at = name_of(&h, other); + click_with(&mut h, at, Modifiers::NONE); + let at = name_of(&h, id); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id); + assert!(h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x: a name click targets the mask"); + assert_eq!(crate::canvas::paint_target(h.state()), json!("mask")); + // Fill goes to the mask and leaves the adjustment as it is. + let content = layer(&h, id).content; + h.state_mut().run("edit.fill", json!({"contents": "black"})).unwrap(); + assert_eq!(layer(&h, id).content, content, "{command} @{ppp}x: filling must preserve the adjustment"); + assert_eq!(layer(&h, id).mask.unwrap().surface.sample_channel(50, 50, 0), 0.0, "{command} @{ppp}x: Fill edits the mask"); + // The content thumbnail targets the contents. + let thumb = layer_thumb_center(&h, id); + click_with(&mut h, thumb, Modifiers::NONE); + assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x: the content thumbnail targets contents"); + // A masked pixel layer picked by its name targets its contents. + let at = name_of(&h, masked); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, masked); + assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x: a pixel layer targets its contents"); + } } } + +/// #10485: dragging the layer mask's thumbnail onto a group's row moves the mask to the group, and +/// ⌥ copies it, as in Photoshop. It had no effect. +#[test] +fn dragging_the_mask_thumbnail_onto_a_group_moves_or_copies_it() { + for copy in [false, true] { + let (mut s, masked, shape) = session(); + let group = s.execute("layer.groupLayers", json!({"layer": shape, "name": "Grp"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": masked})).unwrap(); + let mut h = harness(s, 0, 1.0, 290.0); + let from = mask_rect(&h, masked, MaskKind::Pixel).center(); + let to = h.get_by_label("Grp").rect().center(); + let modifiers = if copy { Modifiers::ALT } else { Modifiers::NONE }; + h.hover_at(from); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + for k in 1..=8 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + assert!(layer(&h, group).mask.is_some(), "copy: {copy}: the group has the mask"); + assert_eq!(layer(&h, masked).mask.is_some(), copy, "copy: {copy}: the original stays only with ⌥"); + } +} + +/// #10485: a layer mask's thumbnail dropped on a row that has a layer mask already (with or +/// without ⌥), or back on its own row, does nothing: no command runs, so no error, no history step, +/// and both masks stay as they were. Photoshop asks before replacing a mask; the panel doesn't offer +/// the drop at all. +#[test] +fn dropping_a_mask_thumbnail_on_a_masked_row_or_its_own_row_does_nothing() { + for (target, copy) in [("Badge", false), ("Badge", true), ("Masked", false), ("Masked", true)] { + let (mut s, masked, shape) = session(); + s.execute("layer.layerMask.hideAll", json!({"layer": shape})).unwrap(); + s.execute("layer.select", json!({"layer": masked})).unwrap(); + let mut h = harness(s, 0, 1.0, 290.0); + let doc = h.state().session.active().unwrap().doc.clone(); + let history = h.state().session.active().unwrap().history.entries(); + let (mask_masked, mask_shape) = (layer(&h, masked).mask, layer(&h, shape).mask); + let from = mask_rect(&h, masked, MaskKind::Pixel).center(); + let to = h.get_by_label(target).rect().center(); + let modifiers = if copy { Modifiers::ALT } else { Modifiers::NONE }; + h.hover_at(from); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + // Out of the thumbnail and back onto the target, so the drop on its own row is a drag. + let away = from + vec2(0.0, 40.0); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(from + (away - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + for k in 1..=8 { + h.event(egui::Event::PointerMoved(away + (to - away) * (k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + let case = format!("onto {target}, copy: {copy}"); + assert!(!h.state().ui.status_error, "{case}: no refused command: {}", h.state().ui.status); + assert_eq!(layer(&h, masked).mask, mask_masked, "{case}: the dragged mask stays"); + assert_eq!(layer(&h, shape).mask, mask_shape, "{case}: the target keeps its own mask"); + let st = h.state().session.active().unwrap(); + assert!(st.doc == doc, "{case}: the document is unchanged"); + assert_eq!(st.history.entries(), history, "{case}: no history step"); + } +} + +/// #10485: a layer mask dragged to the Layers list's bottom edge scrolls the list, as a layer +/// drag does, so it can be dropped on a layer scrolled out of view. The list stayed put. +#[test] +fn dragging_a_mask_to_the_list_edge_scrolls_to_rows_out_of_view() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + for i in 0..40 { + s.execute("layer.new.layer", json!({"name": format!("Filler {i}")})).unwrap(); + } + let masked = s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerMask.revealAll", json!({"layer": masked})).unwrap(); + let mut h = harness(s, 0, 1.0, 290.0); + let window_bottom = h.ctx.content_rect().bottom(); + assert!(h.get_by_label("Target").rect().top() > window_bottom, "the target starts out of view"); + let from = mask_rect(&h, masked, MaskKind::Pixel).center(); + let start = h.get_by_label("Masked").rect(); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + vec2(0.0, 12.0))); + h.run_steps(2); + // Held at the bottom of the window, below the list's last visible row. + let bottom = pos2(from.x, window_bottom - 2.0); + for _ in 0..240 { + h.event(egui::Event::PointerMoved(bottom)); + h.run_steps(1); + } + let moved = h.get_by_label("Masked").rect(); + assert!(moved.top() < start.top() - 1.0, "the list scrolled down: {start:?} → {moved:?}"); + let to = h.get_by_label("Target").rect().center(); + assert!(to.y < window_bottom, "the target scrolled into view: {to:?}"); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(bottom + (to - bottom) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(layer(&h, target).mask.is_some(), "the target has the mask"); + assert!(layer(&h, masked).mask.is_none(), "moved, not copied"); +} + +/// #10485: a layer mask dropped outside the Layers list, over the panel's header, doesn't land on +/// the row scrolled out of view underneath it. The rows take drops only where the list shows them. +#[test] +fn dropping_a_mask_over_the_panel_header_skips_rows_scrolled_out_of_view() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let masked = s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerMask.revealAll", json!({"layer": masked})).unwrap(); + for i in 0..40 { + s.execute("layer.new.layer", json!({"name": format!("Filler {i}")})).unwrap(); + } + s.execute("layer.select", json!({"layer": masked})).unwrap(); + let mut h = harness(s, 0, 1.0, 290.0); + // Scrolled to the bottom: "Masked" shows, the top rows went up under the header. + h.hover_at(h.get_by_label("Filler 39").rect().center()); + h.run_steps(2); + for _ in 0..80 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + } + h.run_steps(10); + let window = h.ctx.content_rect(); + let fill = h.get_all_by_label("Fill").next().unwrap().rect(); + let from = mask_rect(&h, masked, MaskKind::Pixel).center(); + assert!(from.y > fill.bottom() && from.y < window.bottom(), "the masked row shows: {from:?}"); + // The empty header space between Blend Mode and Opacity, over a row scrolled out of view. + let (blend, opacity) = (h.get_by_label("Blend Mode").rect(), h.get_all_by_label("Opacity").next().unwrap().rect()); + let over = pos2((blend.right() + opacity.left()) / 2.0, blend.center().y); + let (target, row) = (0..40) + .map(|i| format!("Filler {i}")) + .map(|name| { + let rect = h.get_by_label(&name).rect(); + (name, rect) + }) + .find(|(_, rect)| rect.contains(over)) + .expect("a row lies under the header"); + assert!(row.bottom() <= fill.bottom(), "{target} is scrolled out of the list: {row:?}"); + let target = h.state().session.active().unwrap().doc.layers.iter().find(|l| l.name == target).unwrap().id.0; + let doc = h.state().session.active().unwrap().doc.clone(); + let history = h.state().session.active().unwrap().history.entries(); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + // Up through the list, clear of its edges (which scroll it), then out over the header and + // released there at once. + let inside = pos2(from.x, fill.bottom() + 48.0); + for k in 1..=8 { + h.event(egui::Event::PointerMoved(from + (inside - from) * (k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerMoved(over)); + h.event(egui::Event::PointerButton { pos: over, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(layer(&h, target).mask.is_none(), "the row under the header took no mask"); + assert!(layer(&h, masked).mask.is_some(), "the mask stays where it was"); + let st = h.state().session.active().unwrap(); + assert!(st.doc == doc, "the document is unchanged"); + assert_eq!(st.history.entries(), history, "no history step"); +} diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index fc67eccaca..1d29b571a7 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -1,6 +1,6 @@ use super::brush_controls::{brush_preset_chip, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown}; use super::properties::{select_and_mask_button, selection_mode_buttons}; -use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, vec2, widgets}; +use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, starts_blend_group, vec2, widgets}; use photocraft_paint::BrushSettings; // Custom controls also need to bring keyboard focus into a scroll viewport. @@ -99,7 +99,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); } - if matches!(tool, Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing) { + if matches!(tool, Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing | Tool::ArtHistoryBrush) { let before = app.session.tools.brush.clone(); let mut brush = before.clone(); // Mode is the tool's own brush's, kept per tool like its size. @@ -111,11 +111,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush.mode = m; } } - if tool == Tool::CloneStamp { + if matches!(tool, Tool::CloneStamp | Tool::HistoryBrush) { let width = if t.pro { 62.0 } else { 66.0 }; percent_field(ui, tl!("Opacity"), &mut brush.opacity, 1.0..=100.0, width); percent_field(ui, tl!("Flow"), &mut brush.flow, 1.0..=100.0, width); } + // Art History has no Mode (the engine paints Normal) and no Flow. + if tool == Tool::ArtHistoryBrush { + let width = if t.pro { 62.0 } else { 66.0 }; + percent_field(ui, tl!("Opacity"), &mut brush.opacity, 1.0..=100.0, width); + } crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &brush); widgets::vline(ui, 22.0); } @@ -153,6 +158,9 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, Tool::Brush | Tool::Eraser if t.pro => { // A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770). let block = crate::eraser_ui::block_mode(app, app.ui.tool); + // The Brush's own tooltips (#11813); the Eraser keeps its texts. + let brush = app.ui.tool == Tool::Brush; + let tip = |r: egui::Response, text: &str| if brush { r.on_hover_text(text) } else { r }; ui.add_enabled_ui(!block, |ui| { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); @@ -165,26 +173,32 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); + // The dropdown's button, by the id its combo box takes from "brush-mode". + if let Some(mode) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("brush-mode"))) { + tip(mode, tl!("Painting Mode")); + } } ui.add_enabled_ui(!block, |ui| { - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { + tip(percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0), tl!("Set opacity for stroke")); + let pressure = pressure_button(ui, b.pressure_opacity, tl!("Always use pressure for opacity"), brush); + if tip(pressure, tl!("Always use Pressure for Opacity. When off, Brush Preset controls pressure")).clicked() { b.pressure_opacity = !b.pressure_opacity; } }); // A Pencil- or Block-mode Eraser is always full flow, without build-up (Photoshop). ui.add_enabled_ui(!block && !crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| { - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); + tip(percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0), tl!("Set flow rate for stroke")); let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); if crate::brush_picker::named(airbrush, "Enable airbrush-style build-up effects").clicked() { b.build_up = !b.build_up; } }); opt_label(ui, tl!("Smoothing")); - smoothing_field(ui, b, 58.0); - smoothing_options(ui, b); + tip(smoothing_field(ui, b, 58.0), tl!("Set smoothing for stroke\nUse higher values to reduce the shakiness of brush strokes")); + smoothing_options(ui, b, brush); widgets::vline(ui, 22.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_size, tl!("Always use pressure for size")).clicked() { + let pressure = pressure_button(ui, b.pressure_size, tl!("Always use pressure for size"), brush); + if tip(pressure, tl!("Always use Pressure for Size. When off, Brush Preset controls pressure")).clicked() { b.pressure_size = !b.pressure_size; } } @@ -218,23 +232,37 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0); }); widgets::vline(ui, 22.0); + // The Brush's own tooltips here too (#11813); the Eraser keeps its texts. + let brush = app.ui.tool == Tool::Brush; + let tip = |r: egui::Response, text: &str| if brush { r.on_hover_text(text) } else { r }; if app.ui.tool == Tool::Eraser { crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); } else { opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); + if let Some(mode) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("brush-mode"))) { + tip(mode, tl!("Painting Mode")); + } } // A Pencil-mode Eraser is always hard and at full flow (Photoshop). let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool); ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0)); - ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)); - ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)); + let opacity = ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)).inner; + tip(opacity, tl!("Set opacity for stroke")); + let flow = ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)).inner; + tip(flow, tl!("Set flow rate for stroke")); opt_label(ui, tl!("Smoothing")); - smoothing_field(ui, b, 66.0); + tip(smoothing_field(ui, b, 66.0), tl!("Set smoothing for stroke\nUse higher values to reduce the shakiness of brush strokes")); widgets::vline(ui, 22.0); - widgets::toggle(ui, &mut b.pressure_size, tl!("Pressure for Size")); - widgets::toggle(ui, &mut b.pressure_opacity, tl!("Pressure for Opacity")); + tip( + widgets::toggle(ui, &mut b.pressure_size, tl!("Pressure for Size")), + tl!("Always use Pressure for Size. When off, Brush Preset controls pressure"), + ); + tip( + widgets::toggle(ui, &mut b.pressure_opacity, tl!("Pressure for Opacity")), + tl!("Always use Pressure for Opacity. When off, Brush Preset controls pressure"), + ); } Tool::MixerBrush => { brush_preset_chip(ui, b, &mut app.ui); @@ -326,7 +354,7 @@ fn marquee_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// Lasso family: selection mode, feather, anti-alias, magnetic settings and the drawing hints. fn lasso_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - selection_mode_buttons(app, ui); + selection_mode_buttons(&mut app.ui.selection_mode, ui); widgets::vline(ui, 22.0); opt_label(ui, tl!("Feather")); widgets::value_field(ui, &mut app.ui.tool_options.feather, 0.0..=1000.0, "px", 62.0); @@ -374,7 +402,7 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: lasso_options(app, ui); } Tool::MagicWand => { - selection_mode_buttons(app, ui); + selection_mode_buttons(&mut app.ui.selection_mode, ui); widgets::vline(ui, 22.0); opt_label(ui, tl!("Tolerance")); widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); @@ -444,7 +472,9 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); // The wheel over it steps the mode, as over the Layers panel's (#2061). - widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", tl!("Blend Mode"), &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + widgets::dropdown_wheel_grouped(ui, "gradient-blend-mode", tl!("Blend Mode"), &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0, |m| { + starts_blend_group(*m) + }); opt_label(ui, tl!("Opacity")); let r = widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); // Live while scrubbed, one undo step per scrub (#5012). @@ -469,9 +499,11 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: // Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919). crate::crop_presets::dropdown(app, ui); let o = &mut app.ui.tool_options; + let mut turn = false; let mut ratio_base = None; if o.crop_ratio == crate::crop_size::WHR { - crate::crop_size::fields(o, ui); + // ⇄ turns the frame with W and H, as with a ratio. + turn = crate::crop_size::fields(o, ui); } else { // Keep partial edits in ToolOptions so the first side survives a frame; // Original Ratio shows the document's numbers, as Photoshop does. @@ -483,6 +515,7 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: let (cw, ch) = (fw.changed(), fh.changed()); if swap { std::mem::swap(&mut rw, &mut rh); + turn = true; } if cw || ch || swap { crate::crop_size::set_custom_ratio_fields(o, rw, rh); @@ -508,16 +541,22 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: let pick_shield_color = crate::crop_shield::options_button(&mut o.crop_shield, ui); widgets::checkbox(ui, &mut o.crop_delete, tl!("Delete Cropped Pixels")); // Fill: what a turned or extending crop puts where the image doesn't reach - // (#3823). Generative Expand is not offered. + // (#3823). Generative Expand is not offered. The first entry names the outcome as in + // Photoshop: the background colour, or transparency with Delete Cropped Pixels off + // (#10872); both store `crop_content_aware: false`. ui.label(tl!("Fill:")); + let delete_cropped_pixels = o.crop_delete; widgets::dropdown( ui, "crop-fill", tl!("Fill:"), &mut o.crop_content_aware, - &[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))], + &[(false, crate::crop_ui::fill_label(delete_cropped_pixels)), (true, tl!("Content-Aware Fill"))], 150.0, ); + if turn { + crate::crop_ui::turn_frame(app); + } if crate::crop_ui::preset_ratio(app) != before_ratio { match ratio_base { Some(base) => crate::crop_ui::apply_ratio_from(app, base), @@ -540,7 +579,12 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: crate::canvas::commit_crop(app); } if icons::button(ui, "ban", 26.0, false, tl!("Cancel current crop operation (Esc)")).clicked() { - crate::crop_ui::cancel(app); + crate::crop_ui::dismiss(app); + } + // Resets the box to its first frame; nothing to reset until it is changed. + let changed = crate::crop_ui::modified(app); + if ui.add_enabled_ui(changed, |ui| icons::button(ui, "undo-2", 26.0, false, tl!("Reset"))).inner.clicked() { + crate::crop_ui::reset(app); } }); } @@ -590,13 +634,14 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, "move-target", tl!("Auto-Select:"), &mut o.move_target, - &[("layer".to_string(), tl!("Layer")), ("group".to_string(), tl!("Group"))], + &[("group".to_string(), tl!("Group")), ("layer".to_string(), tl!("Layer"))], 76.0, ); widgets::vline(ui, 22.0); widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls")); widgets::vline(ui, 22.0); crate::align_bar::options(app, ui); + crate::align_bar::gear(app, ui); } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), Tool::Zoom => { @@ -632,7 +677,7 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, } } Tool::RotateView => { - hint(ui, tl!("Drag around the centre to rotate the view · Shift constrains to 15°")); + hint(ui, tl!("Drag around the center to rotate the view · Shift constrains to 15°")); if let Some(i) = app.session.active_index() { ui.label(tl!("Angle")); let mut angle = app.ui.views.get(i).map(|v| v.rotation).unwrap_or(0.0); @@ -774,12 +819,20 @@ fn fixed_size_field(ui: &mut egui::Ui, px: &mut f32, units: widgets::FieldUnits, picked } -fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { +fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) -> egui::Response { opt_label(ui, label); let mut percent = *value * 100.0; - if widgets::value_field_named(ui, label, &mut percent, range, "%", width).changed() { + let r = widgets::value_field_named(ui, label, &mut percent, range, "%", width); + if r.changed() { *value = percent / 100.0; } + r +} + +/// A pressure toggle named `name` (for screen readers and tests); its tooltip is `name`, unless +/// `own_tip` leaves the tooltip to the caller (the Brush's own texts, #11813). +fn pressure_button(ui: &mut egui::Ui, on: bool, name: &str, own_tip: bool) -> egui::Response { + icons::button_with_icon_size_tip(ui, "circle-dot", 24.0, (24.0f32 * 0.52).round(), on, name, !own_tip) } fn hint(ui: &mut egui::Ui, s: &str) { diff --git a/crates/ui-egui/src/panels/panel_docking.rs b/crates/ui-egui/src/panels/panel_docking.rs index ebddecde2c..6029ae52b4 100644 --- a/crates/ui-egui/src/panels/panel_docking.rs +++ b/crates/ui-egui/src/panels/panel_docking.rs @@ -1206,6 +1206,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::histogram_panel::panel_menu(app, ui); ui.separator(); } + "channels" => { + crate::channels_panel::panel_menu(app, ui); + ui.separator(); + } _ => {} } } diff --git a/crates/ui-egui/src/panels/panel_docking/tests.rs b/crates/ui-egui/src/panels/panel_docking/tests.rs index fb2fb9bd79..886e6f0f8a 100644 --- a/crates/ui-egui/src/panels/panel_docking/tests.rs +++ b/crates/ui-egui/src/panels/panel_docking/tests.rs @@ -763,6 +763,46 @@ fn actions_panel_menu_includes_load_and_save_actions() { assert!(menu.query_all_by_label("New Action…").count() > 0); } +/// The Channels panel menu (☰) has the channel items, not only the rows' context menu (#11451): +/// New Spot Channel… and New Channel… open their dialogs, and OK in New Channel adds a channel. +#[test] +fn channels_panel_menu_opens_new_spot_and_adds_a_channel() { + use egui_kittest::kittest::NodeT; + let mut app = self::app(); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut ready = false; + let mut menu = Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "channels"); + }, + app, + ); + menu.run_steps(2); + let disabled = |menu: &Harness<'static, PhotocraftApp>, label: &str| menu.get_by_label(label).accesskit_node().is_disabled(); + // The composite is targeted: nothing to duplicate, delete or give options to. + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(disabled(&menu, label), "{label} waits for a targeted channel"); + } + menu.get_by_label("New Spot Channel…").click(); + menu.run_steps(2); + let dialogs = &menu.state().ui.dialogs; + assert!(dialogs.iter().any(|d| crate::channel_options_ui::owns_new_spot(&d.fields)), "New Spot Channel… opens its dialog"); + menu.state_mut().ui.dialogs.clear(); + menu.get_by_label("New Channel…").click(); + menu.run_steps(2); + assert!(menu.state().session.active().unwrap().doc.channels.is_empty(), "New Channel… asks first"); + let dialog = menu.state().ui.dialogs.last().expect("the New Channel dialog"); + assert_eq!(dialog.fields["__label"], json!("New Channel…")); + let id = dialog.id; + crate::dialogs::confirm(menu.state_mut(), id).unwrap(); + assert_eq!(menu.state().session.active().unwrap().doc.channels.len(), 1, "OK adds an alpha channel"); + assert!(!menu.state().ui.status_error, "{}", menu.state().ui.status); +} + #[test] fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 0607c5ca30..9d17b2afac 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -122,15 +122,28 @@ fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { }); ui.add_space(6.0); } - let mut o = layer.opacity * 100.0; - let r = widgets::slider_row(ui, tl!("Opacity"), &mut o, 0.0..=100.0, "%", None); - if r.drag_stopped() || (r.changed() && !r.dragged()) { - let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "opacity": o / 100.0})); + pct_slider(app, ui, layer, "opacity", tl!("Opacity"), layer.opacity); + pct_slider(app, ui, layer, "fill", crate::i18n::tr_ctx(crate::i18n::current(), "layers", "Fill"), layer.fill_opacity); +} + +/// An Opacity or Fill (`key`) slider, applied as it moves: a drag is one history step, as with the +/// Layers panel's sliders. Applying only when the drag stopped wrote back the old value, since the +/// slider takes the pointer only while it is dragged or clicked, not on the release (#9683, #7601). +fn pct_slider(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer, key: &str, label: &str, current: f32) { + let mut v = current * 100.0; + let r = widgets::slider_row(ui, label, &mut v, 0.0..=100.0, "%", None); + let stamp = r.id.with("drag-stamp"); + if r.drag_started() { + let n = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| d.insert_temp(stamp, n)); } - let mut f = layer.fill_opacity * 100.0; - let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "layers", "Fill"), &mut f, 0.0..=100.0, "%", None); - if r.drag_stopped() || (r.changed() && !r.dragged()) { - let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "fill": f / 100.0})); + if r.changed() { + let drag = if r.dragged() { ui.data(|d| d.get_temp::(stamp)) } else { None }; + let (id, p) = super::options_bar::pct_action(&json!({"layer": layer.id.0}), key, v, drag); + if let Err(e) = app.run(&id, p) { + app.ui.status = e; + app.ui.status_error = true; + } } } @@ -151,6 +164,10 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && crate::mask_props_ui::targeted(app, layer) { crate::props_layout::header(ui, layer); + if layer.frame.is_some() { + crate::frame_tool::properties(app, ui, layer); + return; + } crate::mask_props_ui::properties(app, ui, layer); return; } @@ -186,6 +203,8 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) { adjustment_controls(app, ui, id, adj); } else if layer.artboard().is_some() { crate::artboard_ui::properties(app, ui, layer); + } else if layer.frame.is_some() { + crate::frame_tool::properties(app, ui, layer); } else if t.pro { // Transform, Align, the kind's sections and Quick Actions. crate::layer_props_ui::properties(app, ui, layer); @@ -253,8 +272,9 @@ pub(super) fn select_and_mask_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) } } -/// New / add / subtract / intersect selection buttons (shared by selection tools). -pub(super) fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { +/// New / add / subtract / intersect selection buttons (shared by selection tools, and the Patch +/// and Content-Aware Move tools, which lasso their area). +pub(crate) fn selection_mode_buttons(mode: &mut u8, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; for (i, (icon, tip)) in [ ("square", tl!("New selection").to_string()), @@ -265,9 +285,79 @@ pub(super) fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) .iter() .enumerate() { - if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() { - app.ui.selection_mode = i as u8; + if icons::button(ui, icon, 24.0, *mode == i as u8, tip).clicked() { + *mode = i as u8; } } ui.spacing_mut().item_spacing.x = 8.0; } + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + + use super::*; + + /// #9683, #7601: dragging the Studio Properties panel's Opacity or Fill slider bounced back to + /// the old value on release. A drag keeps where it was let go, as one history step. + #[test] + fn opacity_and_fill_sliders_keep_a_drag() { + for key in ["Opacity", "Fill"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ALL[0]); + return; + } + let st = app.session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + layer_controls(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let value = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + if key == "Opacity" { l.opacity } else { l.fill_opacity } + }; + let steps = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.entries().len(); + let before = steps(&h); + assert!((value(&h) - 1.0).abs() < 1e-6); + let r = h + .query_all_by_role(Role::Slider) + .find(|n| n.accesskit_node().label().as_deref() == Some(key)) + .map(|n| n.rect()) + .unwrap_or_else(|| panic!("no {key} slider")); + // The press lands away from the current value and the release moves on again, so the + // press and release frames change the value as well as the drag. + let (from, to) = (egui::pos2(r.left() + r.width() * 0.8, r.center().y), egui::pos2(r.left() + r.width() * 0.35, r.center().y)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + let mut last = value(&h); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + let v = value(&h); + assert!(v < last, "{key}: the value follows the drag, not only the release ({last} to {v})"); + last = v; + } + // The pointer moves on and releases in one frame, as a quick flick does. + let release = to - egui::vec2(r.width() * 0.05, 0.0); + h.hover_at(release); + h.drop_at(release); + h.run_steps(3); + let v = value(&h); + assert!(v > 0.15 && v < last, "{key}: the release position wasn't applied ({last} to {v})"); + assert_eq!(steps(&h), before + 1, "{key}: one history step for the drag"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!((value(&h) - 1.0).abs() < 1e-6, "{key}: one undo reverts the whole drag"); + } + } +} diff --git a/crates/ui-egui/src/panels/props_layout.rs b/crates/ui-egui/src/panels/props_layout.rs index 08d3f79e6b..8d604995bc 100644 --- a/crates/ui-egui/src/panels/props_layout.rs +++ b/crates/ui-egui/src/panels/props_layout.rs @@ -43,6 +43,9 @@ pub fn kind_icon(content: &LayerContent) -> &'static str { /// "Type Layer", "Curves", "Artboard"… pub fn kind_label(layer: &Layer) -> String { + if layer.frame.is_some() { + return tl!("Frame").into(); + } match &layer.content { LayerContent::Adjustment(a) => a.label().to_string(), LayerContent::Group(g) if g.artboard.is_some() => "Artboard".to_string(), diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 7fda8c2ee4..34258ab909 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -507,6 +507,7 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; + let mode = app.session.active().map(|st| st.doc.mode); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); if let Value::Object(p) = &f.params { @@ -516,6 +517,9 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } } } + if let Some(mode) = mode { + crate::adjust_dialog::seed_from_recorded(&mut d.fields, &f.params, mode); + } } } @@ -778,6 +782,61 @@ mod tests { assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0)); } + /// #11463: a Selective Color smart filter recorded in the single-colour form, re-edited. Its + /// old `cyan` and `relative` must not survive replacement and override the edit when the filter + /// renders and reopens. + #[test] + fn a_selective_color_smart_filter_in_the_old_form_keeps_its_edit() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#2030c0"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("image.adjustments.selectiveColor", json!({"colors": "blues", "cyan": 30, "relative": false})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.background_jobs = false; + let recorded = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?}"), + }; + let sm = recorded(&app); + assert_eq!(sm.smart_filters[0].params.get("cyan").and_then(Value::as_f64), Some(30.0), "recorded in the old form"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + open_editor(h.state_mut(), &ctx, id, &sm, 0); + let dialog = h.state().ui.dialogs.last().unwrap().id; + h.run_steps(3); + let fields = &h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + assert!(!fields.contains_key("cyan") && !fields.contains_key("relative"), "folded: {fields:?}"); + assert_eq!(fields["colors"], json!("blues"), "normalization keeps the selected colour"); + assert_eq!(fields["blues"][0].as_f64(), Some(30.0)); + assert_eq!(fields["method"], json!("absolute")); + // Edit the row and the Method, then OK. + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("blues".into(), json!([10.0, 0.0, 0.0, 0.0])); + fields.insert("method".into(), json!("relative")); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + let sm = recorded(h.state()); + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + let photocraft_doc::Adjustment::SelectiveColor { relative, adjustments } = + photocraft_engine::adjust_cmds::selective_from_params(&sm.smart_filters[0].params, None).unwrap() + else { + panic!("Selective Color params"); + }; + assert!(relative, "the edited Method renders"); + assert_eq!(adjustments[4], [10.0, 0.0, 0.0, 0.0], "the edited row renders"); + // And reopens as edited. + open_editor(h.state_mut(), &ctx, id, &sm, 0); + let dialog = h.state().ui.dialogs.last().unwrap().id; + h.run_steps(3); + let fields = &h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + assert_eq!(fields["colors"], json!("blues"), "reopening keeps the selected colour"); + assert_eq!(fields["blues"][0].as_f64(), Some(10.0)); + assert_eq!(fields["method"], json!("relative")); + } + /// #6324: a smart filter opened for editing previews, and its preview replaces the filter in /// the stack (as OK does) instead of stacking a second one. #[test] diff --git a/crates/ui-egui/src/panels/smart_ui/editor_tests.rs b/crates/ui-egui/src/panels/smart_ui/editor_tests.rs index 6e0b9d14f8..26b80f561b 100644 --- a/crates/ui-egui/src/panels/smart_ui/editor_tests.rs +++ b/crates/ui-egui/src/panels/smart_ui/editor_tests.rs @@ -103,7 +103,7 @@ fn adjustment_editor_reopens_stored_parameters_and_updates_instead_of_appending( super::open_editor(&mut app, &ctx, layer.0, &sm, 2); let id = app.ui.dialogs.last().unwrap().id; let fields = &mut app.ui.dialog_mut(id).unwrap().fields; - assert_eq!(fields["hue"], json!(35)); + assert_eq!(fields["hue"].as_f64(), Some(35.0)); assert!(crate::adjust_dialog::owns(fields)); fields.insert("hue".into(), json!(75)); let idx = app.session.active_index().unwrap(); @@ -122,6 +122,109 @@ fn adjustment_editor_reopens_stored_parameters_and_updates_instead_of_appending( assert!(app.ui.dialogs.is_empty(), "Invert has no parameter editor"); } +/// Records `command` on a Smart Object in a `mode` document, opens the filter's editor, lets it +/// draw and confirms with no edit. The filter's stored params afterwards. +fn stored_after_confirming_unedited(mode: &str, command: &str, recorded: &serde_json::Value) -> serde_json::Value { + let (mut app, layer) = app(8, mode); + app.run(command, recorded.clone()).unwrap(); + let sm = smart(&app, layer).clone(); + let index = sm.smart_filters.len() - 1; + assert_eq!(sm.smart_filters[index].command, command); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + super::open_editor(h.state_mut(), &ctx, layer.0, &sm, index); + let dialog = h.state().ui.dialogs.last().unwrap().id; + h.run_steps(3); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + let after = smart(h.state(), layer); + assert_eq!(after.smart_filters.len(), sm.smart_filters.len(), "{command}: no filter was stacked"); + after.smart_filters[index].params.clone() +} + +/// Opening an adjustment smart filter and pressing OK changes nothing it renders, whichever of a +/// setting's equivalent keys it was recorded with: the keys the dialog starts with must not be +/// read over the recorded ones. +#[test] +fn confirming_an_unedited_adjustment_smart_filter_keeps_what_it_renders() { + use photocraft_doc::ColorMode::{Cmyk, Lab, Rgb}; + use photocraft_engine::adjust_params::{default_for, from_params}; + let dim = json!([[0, 0], [128, 64], [255, 255]]); + let lift = json!([[0, 40], [255, 255]]); + let drawn: Vec = (0..photocraft_doc::adjust::CURVE_MAP_LEN).map(|level| level as f64 * 0.5).collect(); + let jagged: Vec = (0..photocraft_doc::adjust::CURVE_MAP_LEN).map(|level| if level % 2 == 0 { 40.0 } else { 200.0 }).collect(); + let cases = [ + ("lab", Lab, "curves", json!({"pencil": true, "smooth": 2, "points": jagged})), + ("rgb", Rgb, "curves", json!({"pencil": true, "smooth": 2, "points": jagged})), + ("lab", Lab, "curves", json!({"points": dim})), + ("lab", Lab, "curves", json!({"points": dim, "a": lift})), + ("lab", Lab, "curves", json!({"lightness": dim})), + ("lab", Lab, "curves", json!({"pencil": true, "points": drawn})), + ("lab", Lab, "levels", json!({"inBlack": 30, "gamma": 1.5})), + ("lab", Lab, "levels", json!({"inWhite": 200, "a": {"inBlack": 12}})), + ("lab", Lab, "levels", json!({"lightness": {"inBlack": 30}})), + ("rgb", Rgb, "curves", json!({"points": dim, "red": lift})), + ("rgb", Rgb, "curves", json!({"gray": dim})), + ("rgb", Rgb, "curves", json!({"pencil": true, "points": drawn})), + ("rgb", Rgb, "levels", json!({"inWhite": 200, "blue": {"gamma": 0.8}})), + ("cmyk", Cmyk, "curves", json!({"points": dim})), + ("cmyk", Cmyk, "curves", json!({"black": dim})), + ("cmyk", Cmyk, "levels", json!({"inBlack": 30})), + ("rgb", Rgb, "photoFilter", json!({"filter": "red"})), + ("rgb", Rgb, "channelMixer", json!({"monochrome": true})), + ("rgb", Rgb, "hueSaturation", json!({"colorize": true})), + ("rgb", Rgb, "brightnessContrast", json!({"brightness": 40})), + ]; + let mut changed = Vec::new(); + for (mode, color_mode, kind, recorded) in cases { + let read = |params: &serde_json::Value| from_params(kind, params, None, color_mode).unwrap(); + assert_ne!(read(&recorded), default_for(kind, color_mode).unwrap(), "{mode} {kind} {recorded}: the case must record something"); + let stored = stored_after_confirming_unedited(mode, &format!("image.adjustments.{kind}"), &recorded); + if read(&stored) != read(&recorded) { + changed.push(format!("{mode} {kind} {recorded} became {stored}")); + } + } + assert!(changed.is_empty(), "{}", changed.join("\n")); +} + +/// The editor's controls show what the filter renders: in Lab the top-level `points` and Levels +/// keys are the Lightness channel, which the dialog edits under `lightness`. +#[test] +fn a_lab_tone_smart_filter_opens_with_its_settings_on_the_lightness_channel() { + let (mut app, layer) = app(8, "lab"); + let dim = json!([[0.0, 0.0], [128.0, 64.0], [255.0, 255.0]]); + app.run("image.adjustments.curves", json!({"points": dim})).unwrap(); + app.run("image.adjustments.levels", json!({"inBlack": 30})).unwrap(); + let sm = smart(&app, layer).clone(); + let ctx = egui::Context::default(); + + super::open_editor(&mut app, &ctx, layer.0, &sm, 2); + let dialog = app.ui.dialogs.last().unwrap(); + assert_eq!(dialog.fields["lightness"], dim); + assert_eq!(dialog.fields["points"], dim, "both keys say the same"); + let id = dialog.id; + crate::dialogs::cancel(&mut app, id).unwrap(); + + super::open_editor(&mut app, &ctx, layer.0, &sm, 3); + let fields = &app.ui.dialogs.last().unwrap().fields; + assert_eq!(fields["lightness"]["inBlack"].as_f64(), Some(30.0)); + assert_eq!(fields["inBlack"].as_f64(), Some(30.0), "both keys say the same"); +} + +/// Settings the engine can't read still open, as recorded, for OK to report on; the dialog and the +/// Smart Filter are left as they are, without a panic. +#[test] +fn an_unreadable_adjustment_smart_filter_opens_with_its_recorded_values() { + let (mut app, layer) = app(8, "lab"); + app.run("image.adjustments.curves", json!({"points": [[0, 0], [255, 255]]})).unwrap(); + let mut sm = smart(&app, layer).clone(); + sm.smart_filters[2].params = json!({"points": "not a curve"}); + super::open_editor(&mut app, &egui::Context::default(), layer.0, &sm, 2); + let fields = &app.ui.dialogs.last().unwrap().fields; + assert_eq!(fields["points"], json!("not a curve")); + assert!(fields.contains_key("__smartFilter")); +} + /// The Smart Filters mask and the layer mask of one layer are cached as separate thumbnails. #[test] fn smart_and_layer_masks_have_separate_thumbnail_cache_entries() { @@ -185,3 +288,114 @@ fn filter_editor_starts_missing_parameters_from_defaults_not_remembered_values() let id = crate::filter_dialog::open(&mut app, command).unwrap(); assert_eq!(app.ui.dialog_mut(id).unwrap().fields["reduceNoise"].as_f64(), Some(60.0)); } + +/// A smart object in a `mode` document with `command` recorded as its one smart filter, and its layer id. +fn adjustment_filter(mode: &str, command: &str, params: serde_json::Value) -> (PhotocraftApp, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute(command, params).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.background_jobs = false; + (app, id) +} + +/// The smart object on layer `id`. +fn recorded(app: &PhotocraftApp, id: u64) -> photocraft_doc::SmartObject { + match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {}", other.kind_name()), + } +} + +/// The app in a harness with the editor of the layer's first smart filter open, and the dialog's id. +fn edit_first_filter(app: PhotocraftApp, id: u64) -> (Harness<'static, PhotocraftApp>, u64) { + let sm = recorded(&app, id); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + super::open_editor(h.state_mut(), &ctx, id, &sm, 0); + let dialog = h.state().ui.dialogs.last().unwrap().id; + h.run_steps(3); + (h, dialog) +} + +/// Monochrome off removes `gray` from the dialog's values; a stored one read over the Red row +/// would keep the old mix, whatever the Red sliders say, on OK and on every later edit. +#[test] +fn a_channel_mixer_smart_filter_turned_off_monochrome_keeps_its_red_row() { + let (app, id) = adjustment_filter("rgb", "image.adjustments.channelMixer", json!({"monochrome": true, "gray": [30, 30, 40, 0]})); + let (mut h, dialog) = edit_first_filter(app, id); + h.get_by_label("Monochrome").click(); + h.run_steps(3); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + assert_eq!(fields.get("monochrome"), Some(&json!(false))); + assert!(!fields.contains_key("gray"), "the editor removed the gray row: {fields:?}"); + fields.insert("red".into(), json!([70.0, 30.0, 0.0, 0.0])); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + + let sm = recorded(h.state(), id); + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + let adjustment = photocraft_engine::adjust_params::from_params("channelMixer", &sm.smart_filters[0].params, None, photocraft_doc::ColorMode::Rgb).unwrap(); + let photocraft_doc::Adjustment::ChannelMixer { matrix, monochrome } = adjustment else { panic!("Channel Mixer params") }; + assert!(!monochrome); + assert!(matrix[0].iter().zip([0.7, 0.3, 0.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-5), "the edited Red row renders: {:?}", matrix[0]); +} + +/// In Lab, top-level `points` aliases Lightness and the editor removes it on an edit. A stored +/// one must not override the edit: the Lightness the editor sends is what the filter renders. +#[test] +fn a_lab_curves_smart_filter_keeps_a_lightness_edit() { + let old = json!([[0, 0], [128, 64], [255, 255]]); + let (app, id) = adjustment_filter("lab", "image.adjustments.curves", json!({"points": old, "lightness": old})); + let (mut h, dialog) = edit_first_filter(app, id); + // Add a point on the Lightness curve, the channel the dialog shows first. + let graph = h.ctx.data(|d| d.get_temp::(crate::adjust_dialog::dialog_mem("curves").with("curves-graph"))).unwrap(); + click(&mut h, egui::pos2(graph.left() + graph.width() * 0.75, graph.top() + graph.height() * 0.1)); + let fields = &h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + assert!(!fields.contains_key("points"), "the editor removed the alias: {fields:?}"); + let edited = fields["lightness"].clone(); + assert_ne!(edited, old, "the click edited Lightness"); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + + let sm = recorded(h.state(), id); + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + let read = |params: &serde_json::Value| photocraft_engine::adjust_params::from_params("curves", params, None, photocraft_doc::ColorMode::Lab).unwrap(); + let photocraft_doc::Adjustment::Curves { per_channel, .. } = read(&sm.smart_filters[0].params) else { panic!("Curves params") }; + let photocraft_doc::Adjustment::Curves { per_channel: wanted, .. } = read(&json!({"lightness": edited})) else { panic!("Curves params") }; + assert_eq!(per_channel[0], wanted[0], "the edited Lightness renders"); + assert!(sm.smart_filters[0].params.get("points").is_none(), "the removed alias is not stored"); +} + +/// Emptying a text field removes its key from the Color Lookup dialog, which draws from the +/// command's schema. A stored `data` would be read over the `lut` chosen instead. +#[test] +fn a_color_lookup_smart_filter_with_its_data_cleared_uses_the_chosen_lut() { + let mut cube = String::from("LUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{} {} {}\n", 1 - r, 1 - g, 1 - b)); + } + } + } + let (app, id) = adjustment_filter("rgb", "image.adjustments.colorLookup", json!({"data": cube, "fileName": "invert.cube"})); + let (mut h, dialog) = edit_first_filter(app, id); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + assert_eq!(fields.get("data"), Some(&json!(cube)), "opens on the recorded settings"); + fields.insert("data".into(), json!("")); + fields.insert("lut".into(), json!("warm")); + h.run_steps(2); + let fields = &h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + assert!(!fields.contains_key("data"), "the dialog removed the emptied field"); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + + let sm = recorded(h.state(), id); + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + let params = &sm.smart_filters[0].params; + assert!(params.get("data").is_none(), "the removed data is not stored: {params}"); + assert_eq!(params.get("lut"), Some(&json!("warm"))); +} diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index 6ad68bda02..12f85de2c1 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -6,7 +6,7 @@ use super::{Align2, Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, StrokeKin /// Tool groups separated by hairlines, Photoshop order. /// Photoshop toolbar: divider-separated sections of slots; each slot is a flyout group whose button /// shows the group's last-used tool. -pub(super) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ +pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Move, Tool::Artboard]], &[ &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee], @@ -17,8 +17,8 @@ pub(super) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Eyedropper, Tool::ColorSampler, Tool::Ruler, Tool::Note, Tool::Count], ], &[ - &[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], - &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement], + &[Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], + &[Tool::Brush, Tool::Pencil, Tool::ColorReplacement, Tool::MixerBrush], &[Tool::CloneStamp, Tool::PatternStamp], &[Tool::HistoryBrush, Tool::ArtHistoryBrush], &[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser], @@ -35,20 +35,33 @@ pub(super) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Hand, Tool::RotateView], &[Tool::Zoom]], ]; -/// The tool a slot shows: the current tool if it belongs to the slot, else the last one used. -fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool { +/// The tool a slot shows: the current tool if it belongs to the slot, else the last one used +/// (`group_tools`, most recent first), else the slot's first tool. +pub(super) fn slot_tool(current: Tool, group_tools: &[Tool], slot: &[Tool]) -> Tool { if slot.contains(¤t) { - ui.data_mut(|d| d.insert_temp(key, current)); return current; } - ui.data(|d| d.get_temp::(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0]) + group_tools.iter().chain(slot).copied().find(|t| slot.contains(t)).unwrap_or(current) +} + +/// Next tool in a toolbar flyout slot after `from`, wrapping (Photoshop Alt/Option-click). An +/// empty slot returns `from` unchanged rather than dividing by zero. +pub(crate) fn next_slot_tool(slot: &[Tool], from: Tool) -> Tool { + let i = slot.iter().position(|&t| t == from).unwrap_or(0); + i.checked_add(1).and_then(|n| n.checked_rem(slot.len())).and_then(|n| slot.get(n)).copied().unwrap_or(from) +} + +/// Position of `tool` in [`TOOL_SECTIONS`] read top to bottom, slot by slot. Tools outside the +/// toolbar sort last. Shift+letter orders a key's group by this so it steps like the flyout. +pub(crate) fn toolbar_position(tool: Tool) -> usize { + TOOL_SECTIONS.iter().flat_map(|section| section.iter()).flat_map(|slot| slot.iter()).position(|&t| t == tool).unwrap_or(usize::MAX) } /// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the /// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A /// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is /// dropped, and so is a section with no slots. Each slot carries its index in `TOOL_SECTIONS`, -/// so its remembered tool and flyout stay put when other slots are hidden. +/// so its flyout stays put when other slots are hidden. pub(super) fn visible_sections(hidden: &[String]) -> Vec)>> { let hidden: Vec = hidden.iter().map(String::as_str).filter_map(Tool::from_name).collect(); let mut index = 0usize; @@ -80,6 +93,63 @@ pub(super) fn tool_flyout_release(row: Rect, pointer: Option, released: bo released && from_button && pointer.is_some_and(|p| row.contains(p)) } +fn more_flyout_id() -> egui::Id { + egui::Id::unique("toolbar-more-flyout-open") +} + +/// Whether the "…" button's flyout is open: it owns Escape then (`shortcuts::handle`). +pub(crate) fn more_flyout_open(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(more_flyout_id())).unwrap_or(false) +} + +/// Right-click on the "…" button opens a flyout with one row, "Edit Toolbar…", drawn like a tool +/// flyout. The row runs `edit.toolbar` and closes the flyout; Escape or a press outside closes it +/// without running anything. +fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::Response, t: &Tokens) { + let open_id = more_flyout_id(); + let open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); + if button.secondary_clicked() { + ui.data_mut(|d| d.insert_temp(open_id, !open)); + ui.ctx().request_repaint(); + return; + } + if !open { + return; + } + let (pivot, pos) = if crate::direction::is_rtl() { + (egui::Align2::RIGHT_BOTTOM, button.rect.left_bottom() - vec2(6.0, 0.0)) + } else { + (egui::Align2::LEFT_BOTTOM, button.rect.right_bottom() + vec2(6.0, 0.0)) + }; + let area = egui::Area::new(egui::Id::unique("toolbar-more-flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + let label = tl!("Edit Toolbar…"); + let label_w = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), t.text).size().x; + let (r, row) = ui.allocate_exact_size(vec2((label_w + 24.0).max(180.0), 26.0), Sense::click()); + row.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + if row.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text(pos2(r.left() + 12.0, r.center().y), Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); + row.clicked() + }) + }); + if area.inner.inner { + ui.data_mut(|d| d.remove::(open_id)); + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + let pressed_outside = + ui.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !button.rect.contains(p))); + if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::(open_id)); + } +} + pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; @@ -156,7 +226,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for (slot_index, slot) in row.iter() { let key = egui::Id::unique(("tool-slot", *slot_index)); - let tool = slot_tool(ui, app.ui.tool, slot, key); + let tool = slot_tool(app.ui.tool, &app.ui.group_tools, slot); let sel = slot.contains(&app.ui.tool); let tip = match crate::tool_shortcuts::shortcut(app, tool) { Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), @@ -167,19 +237,59 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if slot.len() > 1 { paint_toolbar_flyout_indicator(ui, &t, &resp); } - if resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key) { - crate::tool_memory::select(app, tool); + // Plain click selects the shown tool; Alt/Option-click cycles the + // flyout slot to the next tool, as Shift+letter does for the key group. + // Prefer the primary-release event's modifiers (Alt may already be + // up), and only a release on this slot — the frame's event list is + // shared with every other widget. + let click_mods = + ui.input(|i| { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { + pos, button: egui::PointerButton::Primary, pressed: false, modifiers, .. + } if resp.rect.contains(*pos) => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: a plain click after a + // modified click within max_double_click_delay still reports + // double_clicked(). Remember the previous click so Hand fit / + // Zoom 100% only run for two plain clicks. + let prev_mod_id = key.with("click-mod"); + let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); + let slot_click = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key); + if slot_click { + let pick = if slot.len() > 1 && alt { + let from = if slot.contains(&app.ui.tool) { app.ui.tool } else { tool }; + next_slot_tool(slot, from) + } else { + tool + }; + crate::tool_memory::select(app, pick); } // Double-clicking the Hand tool fits the image on screen (Photoshop). - if resp.double_clicked() && tool == Tool::Hand { + // Modified clicks only cycle / select: a second rapid Alt-click, or a + // plain click that egui pairs with a prior modified click, must not + // count as Hand/Zoom double-click after cycling onto those tools. + let plain_double = resp.double_clicked() && !modified && !prev_modified; + if plain_double && tool == Tool::Hand { crate::tool_memory::select(app, tool); // With no document open there is nothing to fit. let _ = app.run("view.fitOnScreen", json!({})); } - if resp.double_clicked() && tool == Tool::Zoom { + if plain_double && tool == Tool::Zoom { crate::tool_memory::select(app, tool); let _ = app.run("view.actualPixels", json!({})); } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); + } // Right-click, long-press, or press-drag away opens the flyout (Photoshop). let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); @@ -298,9 +408,14 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } } - if icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { - let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); + let more = icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")); + if more.clicked() + && let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) + { + app.ui.status = e; + app.ui.status_error = true; } + more_slot_flyout(app, ui, &more, &t); reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); @@ -559,13 +674,13 @@ pub(super) fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_column p.rect_stroke(b, 1.0, Stroke::new(1.0, ink(d)), StrokeKind::Inside); } if let Some(d) = &d { - d.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Default colours (D)"))); + d.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Default Foreground and Background Colors (D)"))); } - s.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Swap colours (X)"))); - if s.on_hover_text(tl!("Swap colours (X)")).clicked() { + s.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Switch Foreground and Background Colors (X)"))); + if s.on_hover_text(tl!("Switch Foreground and Background Colors (X)")).clicked() { chip_command(app, "tools.swapColors"); } - if d.is_some_and(|d| d.on_hover_text(tl!("Default colours (D)")).clicked()) { + if d.is_some_and(|d| d.on_hover_text(tl!("Default Foreground and Background Colors (D)")).clicked()) { chip_command(app, "tools.defaultColors"); } } diff --git a/crates/ui-egui/src/panels/type_flyout_tests.rs b/crates/ui-egui/src/panels/type_flyout_tests.rs index 7678f4263e..1243992b66 100644 --- a/crates/ui-egui/src/panels/type_flyout_tests.rs +++ b/crates/ui-egui/src/panels/type_flyout_tests.rs @@ -293,8 +293,8 @@ fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); assert!(j.contains(&Tool::RedEye), "{j:?}"); assert_eq!(j.last(), Some(&Tool::RedEye)); - // Remove leads the flyout, so the slot shows it until another J tool is picked. - assert_eq!(j.first(), Some(&Tool::Remove)); + // Spot Healing Brush leads the flyout, so the slot shows it until another J tool is picked. + assert_eq!(j.first(), Some(&Tool::SpotHealing)); } #[test] @@ -348,8 +348,8 @@ fn long_press_brush_selects_color_replacement() { frame(&mut app, &ctx, 0.1, vec![]); let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap(); - // Photoshop's Brush group, Color Replacement after the Mixer Brush. - assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]); + // Photoshop's Brush group, Color Replacement before the Mixer Brush. + assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::ColorReplacement, Tool::MixerBrush]); let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; let mut buttons: Vec = ctx.viewport(|v| { v.prev_pass @@ -369,8 +369,8 @@ fn long_press_brush_selects_color_replacement() { frame(&mut app, &ctx, 1.45, vec![]); let key = egui::Id::unique(("tool-slot", index)); let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); - // The last of the four 26-point rows. - let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 20.0); + // The third of the four 26-point rows. + let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 46.0); frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]); frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); assert_eq!(app.ui.tool, Tool::ColorReplacement); @@ -426,3 +426,237 @@ fn long_press_y_button_selects_art_history() { frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); assert_eq!(app.ui.tool, Tool::ArtHistoryBrush); } + +#[test] +fn next_slot_tool_wraps_around_the_flyout() { + // One-tool slot: Alt-click is a no-op (still “cycles” to itself). + assert_eq!(next_slot_tool(&[Tool::Frame], Tool::Frame), Tool::Frame); + // Two-tool slot (Hand ↔ RotateView). + let two = [Tool::Hand, Tool::RotateView]; + assert_eq!(next_slot_tool(&two, Tool::Hand), Tool::RotateView); + assert_eq!(next_slot_tool(&two, Tool::RotateView), Tool::Hand, "wrap"); + // Three-tool slot. + let three = [Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso]; + assert_eq!(next_slot_tool(&three, Tool::Lasso), Tool::PolygonLasso); + assert_eq!(next_slot_tool(&three, Tool::PolygonLasso), Tool::MagneticLasso); + assert_eq!(next_slot_tool(&three, Tool::MagneticLasso), Tool::Lasso, "wrap"); + // Four-tool marquee flyout: starts from the current tool, not always the first. + let slot = [Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee]; + assert_eq!(next_slot_tool(&slot, Tool::RectMarquee), Tool::EllipseMarquee); + assert_eq!(next_slot_tool(&slot, Tool::EllipseMarquee), Tool::SingleRowMarquee); + assert_eq!(next_slot_tool(&slot, Tool::SingleColumnMarquee), Tool::RectMarquee); + assert_eq!(next_slot_tool(&slot, Tool::Brush), Tool::EllipseMarquee, "unknown from → after the first"); + // Empty slot: no panic, the current tool stays. + assert_eq!(next_slot_tool(&[], Tool::Brush), Tool::Brush); +} + +#[test] +fn slot_tool_is_the_current_tool_else_the_slots_last_else_its_first() { + let lasso = [Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso]; + assert_eq!(slot_tool(Tool::Move, &[], &lasso), Tool::Lasso); + let used = [Tool::Brush, Tool::PolygonLasso, Tool::Lasso]; + assert_eq!(slot_tool(Tool::Move, &used, &lasso), Tool::PolygonLasso); + assert_eq!(slot_tool(Tool::MagneticLasso, &used, &lasso), Tool::MagneticLasso); + // Polygonal Lasso hidden in Edit › Toolbar: the slot falls back on the next one used. + assert_eq!(slot_tool(Tool::Move, &used, &[Tool::Lasso, Tool::MagneticLasso]), Tool::Lasso); + // Slots don't line up with keys: Blur's slot and the two single marquees have no key at all, + // and the Hand slot has two. + let used = [Tool::Sharpen, Tool::SingleRowMarquee, Tool::RotateView]; + assert_eq!(slot_tool(Tool::Move, &used, &[Tool::Blur, Tool::Sharpen, Tool::Smudge]), Tool::Sharpen); + let marquee = [Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee]; + assert_eq!(slot_tool(Tool::Move, &used, &marquee), Tool::SingleRowMarquee); + assert_eq!(slot_tool(Tool::Move, &used, &[Tool::Hand, Tool::RotateView]), Tool::RotateView); + // Empty slot: no panic, the current tool stays. + assert_eq!(slot_tool(Tool::Brush, &used, &[]), Tool::Brush); +} + +/// #3569: after a restart a slot is still on the tool it was left on, whichever tool is selected: +/// a click picks that tool, not the slot's first. +#[test] +fn slots_come_back_on_their_last_tool_after_a_restart() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.prefs.edit(|p| p.group_tools = json!(["PolygonLasso", "PaintBucket", "Pencil", "Sharpen", "SingleRowMarquee"])); + crate::tool_memory::restore(&mut app); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let slots = [ + (Tool::Brush, Tool::Pencil), + (Tool::Gradient, Tool::PaintBucket), + (Tool::Lasso, Tool::PolygonLasso), + (Tool::Blur, Tool::Sharpen), + (Tool::RectMarquee, Tool::SingleRowMarquee), + ]; + for (i, (first, last)) in slots.into_iter().enumerate() { + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&first)).unwrap(); + let at = buttons[index].center(); + let t = 1.0 + i as f64; + frame(&mut app, &ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, t + 0.05, vec![pointer(at, false)]); + frame(&mut app, &ctx, t + 0.1, vec![]); + assert_eq!(app.ui.tool, last); + } +} + +/// Edit › Toolbar hidden tools shrink the Alt-click cycle to the remaining flyout members. +#[test] +fn visible_sections_drop_hidden_tools_from_alt_click_slots() { + let hidden = vec!["EllipseMarquee".into(), "SingleRowMarquee".into()]; + let sections = visible_sections(&hidden); + let marquee = sections + .iter() + .flat_map(|s| s.iter()) + .find(|(_, tools)| tools.contains(&Tool::RectMarquee)) + .map(|(_, tools)| tools.as_slice()) + .expect("marquee slot remains"); + assert_eq!(marquee, &[Tool::RectMarquee, Tool::SingleColumnMarquee]); + assert_eq!(next_slot_tool(marquee, Tool::RectMarquee), Tool::SingleColumnMarquee); + assert_eq!(next_slot_tool(marquee, Tool::SingleColumnMarquee), Tool::RectMarquee, "wrap over the visible pair"); +} + +/// Photoshop: Alt/Option-click a toolbar button cycles its flyout tools without opening the menu. +#[test] +fn alt_click_cycles_the_tool_flyout_slot() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::RectMarquee)).unwrap(); + let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap(); + assert_eq!(slot, &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_some(); + app.ui.tool = Tool::RectMarquee; + // Plain click keeps the shown tool. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true, Default::default())]); + frame(&mut app, &ctx, 1.05, vec![pointer(at, false, Default::default())]); + frame(&mut app, &ctx, 1.1, vec![]); + assert_eq!(app.ui.tool, Tool::RectMarquee); + assert!(!flyout(&ctx)); + // Alt-click advances through every flyout entry, including ones Shift+M skips. + // Clear i.modifiers before the release while keeping Alt on the release event, so a + // regression that only reads i.modifiers (and ignores the pointer-release modifiers) fails. + for (t, want) in [(1.5, Tool::EllipseMarquee), (2.0, Tool::SingleRowMarquee), (2.5, Tool::SingleColumnMarquee), (3.0, Tool::RectMarquee)] { + frame(&mut app, &ctx, t, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, t + 0.05, vec![egui::Event::ModifiersChanged(Default::default()), pointer(at, false, alt)]); + frame(&mut app, &ctx, t + 0.1, vec![]); + assert_eq!(app.ui.tool, want, "alt-click at {t}"); + assert!(!flyout(&ctx), "alt-click must not open the flyout"); + } + // From another tool, Alt-click advances from the slot's remembered (shown) tool: Rect after the wrap. + app.ui.tool = Tool::Brush; + frame(&mut app, &ctx, 4.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 4.05, vec![egui::Event::ModifiersChanged(Default::default()), pointer(at, false, alt)]); + frame(&mut app, &ctx, 4.1, vec![]); + assert_eq!(app.ui.tool, Tool::EllipseMarquee, "inactive slot cycles from its shown tool"); +} + +/// #10301 CodeRabbit: two rapid Alt-clicks on Hand ↔ RotateView must not fit-on-screen. +#[test] +fn rapid_alt_clicks_on_hand_slot_do_not_fit_on_screen() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Hand)).unwrap(); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + app.ui.tool = Tool::RotateView; + let zoom_before = app.ui.views.first().map(|v| v.zoom).unwrap_or(1.0); + // Two Alt-clicks within egui's double-click window: cycle Hand → RotateView, never fit-on-screen. + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.02, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.04, vec![]); + assert_eq!(app.ui.tool, Tool::Hand); + frame(&mut app, &ctx, 1.08, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.10, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.12, vec![]); + assert_eq!(app.ui.tool, Tool::RotateView, "second Alt-click cycles back; must not stick on Hand from double-click"); + let zoom_after = app.ui.views.first().map(|v| v.zoom).unwrap_or(1.0); + assert_eq!(zoom_after, zoom_before, "Alt double-click must not run view.fitOnScreen"); +} + +/// #10301 CodeRabbit: plain click after Alt-click within the double-click window must not fit. +#[test] +fn plain_click_after_alt_on_hand_slot_does_not_fit_on_screen() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + // Far from fit-on-screen so a real Hand double-click is observable. + if let Some(v) = app.ui.views.first_mut() { + v.zoom = 4.0; + } + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Hand)).unwrap(); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let none = egui::Modifiers::default(); + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + app.ui.tool = Tool::RotateView; + let zoom_of = |app: &PhotocraftApp| app.ui.views.first().map(|v| v.zoom).unwrap_or(1.0); + assert_eq!(zoom_of(&app), 4.0); + // Alt-click cycles to Hand; a plain click soon after is egui's "second" click of a double. + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.02, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.04, vec![]); + assert_eq!(app.ui.tool, Tool::Hand); + frame(&mut app, &ctx, 1.08, vec![egui::Event::ModifiersChanged(none), egui::Event::PointerMoved(at), pointer(at, true, none)]); + frame(&mut app, &ctx, 1.10, vec![egui::Event::ModifiersChanged(none), pointer(at, false, none)]); + frame(&mut app, &ctx, 1.12, vec![]); + assert_eq!(app.ui.tool, Tool::Hand, "plain click keeps Hand selected"); + assert_eq!(zoom_of(&app), 4.0, "plain click paired with prior Alt-click must not fitOnScreen"); +} diff --git a/crates/ui-egui/src/panels/type_panels_ui.rs b/crates/ui-egui/src/panels/type_panels_ui.rs index 0a58ee8542..607e4f6c64 100644 --- a/crates/ui-egui/src/panels/type_panels_ui.rs +++ b/crates/ui-egui/src/panels/type_panels_ui.rs @@ -47,6 +47,9 @@ pub struct SpellDialog { pub ignore: Vec, pub change_to: String, pub all_layers: bool, + /// Single-layer checks remain bound to their opening layer across refreshes and toggles. + #[serde(skip)] + pub layer: Option, pub message: String, /// The token of the action playback that opened the dialog at a Check Spelling step: Done /// continues it ([`crate::actions::resume`]). @@ -763,10 +766,18 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // ------------------------------------------------------------------ spelling +/// Refresh the opening layer when checking one layer; an absent opening receiver must not adopt the newly active layer. fn refresh_spelling(app: &mut PhotocraftApp) { let Some(d) = app.ui.type_panels.spell.clone() else { return }; - let p = json!({ "action": "list", "allLayers": d.all_layers, "ignore": d.ignore, "suggestions": 8 }); - let items = run(app, "edit.checkSpelling", p).and_then(|r| r["misspellings"].as_array().cloned()).unwrap_or_default(); + let mut p = json!({ "action": "list", "allLayers": d.all_layers, "ignore": d.ignore, "suggestions": 8 }); + if let Some(layer) = d.layer.filter(|_| !d.all_layers) { + p["layer"] = json!(layer.0); + } + let items = if !d.all_layers && d.layer.is_none() { + Vec::new() + } else { + run(app, "edit.checkSpelling", p).and_then(|r| r["misspellings"].as_array().cloned()).unwrap_or_default() + }; if let Some(s) = app.ui.type_panels.spell.as_mut() { s.items = items; s.change_to = s.items.get(s.index).and_then(|i| i["suggestions"][0].as_str()).unwrap_or("").to_string(); @@ -776,9 +787,14 @@ fn refresh_spelling(app: &mut PhotocraftApp) { /// Opens Edit › Check Spelling (returns the misspellings). Opening it again while an action waits /// at a Check Spelling step keeps that action's token, so Done still continues it. pub fn open_spelling(app: &mut PhotocraftApp, all_layers: bool) -> Result { - app.session.execute("edit.checkSpelling", json!({ "action": "list", "allLayers": all_layers })).map_err(|e| e.to_string())?; let resume = app.ui.type_panels.spell.as_ref().and_then(|d| d.resume); - app.ui.type_panels.spell = Some(SpellDialog { all_layers, resume, ..Default::default() }); + let layer = if resume.is_some() { app.ui.type_panels.spell.as_ref().and_then(|d| d.layer) } else { app.session.active().and_then(|st| st.active_layer) }; + let mut params = json!({"action":"list","allLayers":all_layers}); + if !all_layers { + params["layer"] = json!(layer.ok_or("no layer was selected for this spelling check")?.0); + } + app.session.execute("edit.checkSpelling", params).map_err(|e| e.to_string())?; + app.ui.type_panels.spell = Some(SpellDialog { all_layers, layer, resume, ..Default::default() }); refresh_spelling(app); let d = app.ui.type_panels.spell.as_ref().map(|d| d.items.clone()).unwrap_or_default(); Ok(json!({ "dialog": SPELLING, "misspellings": d })) @@ -802,6 +818,16 @@ pub fn close_spelling(app: &mut PhotocraftApp) { } } +/// Bind single-layer Change All to the listed item; all-layer corrections intentionally omit a layer restriction. +fn spelling_change_all_params(item: &Value, word: &str, replace: &str, all_layers: bool) -> Value { + let mut params = json!({"action":"changeAll", "word":word, "replace":replace, "allLayers":all_layers}); + if !all_layers { + params["layer"] = item["layer"].clone(); + } + params +} + +/// Render spelling navigation and corrections using the retained opening receiver and paused-action continuation. fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let Some(d) = app.ui.type_panels.spell.clone() else { return }; @@ -930,9 +956,12 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let p = if a == "change" { json!({ "action": "change", "layer": i["layer"], "start": i["start"], "end": i["end"], "word": word, "replace": change_to }) } else { - json!({ "action": "changeAll", "word": word, "replace": change_to, "allLayers": all_layers }) + spelling_change_all_params(i, &word, &change_to, all_layers) }; - let _ = run(app, "edit.checkSpelling", p); + if let Err(error) = crate::actions::spelling_change(app, p) { + app.ui.status = error; + app.ui.status_error = true; + } refresh_spelling(app); } } @@ -968,6 +997,61 @@ mod tests { assert!(app.ui.type_panels.glyphs); } + #[test] + /// Single-layer Change All edits the listed text layer even after the host selects another layer. + fn change_all_keeps_the_listed_layer_after_a_session_selection_change() { + for all_layers in [false, true] { + let mut app = app_with_text("Teh cat"); + let listed = app.session.active().unwrap().active_layer.unwrap(); + app.run("type.create", json!({"x":10,"y":90,"text":"Teh dog"})).unwrap(); + let other = app.session.active().unwrap().active_layer.unwrap(); + app.session.select_layer(listed).unwrap(); + menu(&mut app, "edit.checkSpelling", &json!({"allLayers":false})).unwrap().unwrap(); + app.session.select_layer(other).unwrap(); + refresh_spelling(&mut app); + let item = app.ui.type_panels.spell.as_ref().unwrap().items[0].clone(); + assert_eq!(item["layer"], listed.0); + let params = spelling_change_all_params(&item, "Teh", "The", all_layers); + if all_layers { + assert!(params.get("layer").is_none()); + } + crate::actions::spelling_change(&mut app, params).unwrap(); + refresh_spelling(&mut app); + assert!(app.ui.type_panels.spell.as_ref().unwrap().items.is_empty()); + let doc = &app.session.active().unwrap().doc; + for (id, expected) in [(listed, "The cat"), (other, if all_layers { "The dog" } else { "Teh dog" })] { + let photocraft_doc::LayerContent::Text(text) = &doc.layer(id).unwrap().content else { panic!("not text") }; + assert_eq!(text.text, expected); + } + } + } + + #[test] + /// Refreshing and toggling Check All Layers must retain the original single-layer receiver, including the absent-layer case. + fn spelling_refresh_and_all_layers_toggles_keep_the_opening_layer() { + let mut app = app_with_text("Teh cat"); + let original = app.session.active().unwrap().active_layer.unwrap(); + app.run("type.create", json!({"x":10,"y":90,"text":"Teh dog"})).unwrap(); + let other = app.session.active().unwrap().active_layer.unwrap(); + app.session.select_layer(original).unwrap(); + open_spelling(&mut app, false).unwrap(); + app.session.select_layer(other).unwrap(); + for all_layers in [false, true, false] { + app.ui.type_panels.spell.as_mut().unwrap().all_layers = all_layers; + refresh_spelling(&mut app); + let items = &app.ui.type_panels.spell.as_ref().unwrap().items; + assert!(items.iter().any(|item| item["layer"] == original.0)); + assert_eq!(items.iter().any(|item| item["layer"] == other.0), all_layers); + } + app.session.restore_layer_selection(None, &[], None).unwrap(); + open_spelling(&mut app, true).unwrap(); + assert!(app.ui.type_panels.spell.as_ref().unwrap().layer.is_none()); + app.session.select_layer(other).unwrap(); + app.ui.type_panels.spell.as_mut().unwrap().all_layers = false; + refresh_spelling(&mut app); + assert!(app.ui.type_panels.spell.as_ref().unwrap().items.is_empty(), "a missing opening target must not adopt the new active layer"); + } + #[test] fn glyph_insertion_and_recent() { let mut app = app_with_text("ab"); diff --git a/crates/ui-egui/src/patch_sample_all_default_tests.rs b/crates/ui-egui/src/patch_sample_all_default_tests.rs new file mode 100644 index 0000000000..6a78f6111a --- /dev/null +++ b/crates/ui-egui/src/patch_sample_all_default_tests.rs @@ -0,0 +1,64 @@ +//! The Patch tool's Content-Aware Sample All Layers starts on (#12125): a fresh start and Reset +//! Tool give the Patch tool Sample All Layers on while other tools stay off, and saved settings +//! keep their stored value. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{SampleAllLayers, Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + PhotocraftApp::new(s, crate::Services::default()) +} + +#[test] +fn patch_sample_all_layers_starts_on_and_other_tools_start_off() { + let defaults = SampleAllLayers::default(); + assert!(defaults.patch); + assert!(defaults.get(Tool::Patch)); + for tool in Tool::ALL { + if tool != Tool::Patch { + assert!(!defaults.get(tool), "{tool:?} should default to Sample All Layers off"); + } + } + + let app = app(); + assert!(app.ui.tool_options.sample_all.patch); +} + +#[test] +fn reset_tool_restores_patch_sample_all_layers_to_on() { + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::Patch; + app.ui.tool_options.sample_all.patch = false; + app.ui.tool_options.sample_all.magic_wand = true; + + crate::menus::invoke(&mut app, &ctx, super::RESET, json!({})).unwrap(); + assert!(app.ui.tool_options.sample_all.patch, "Reset Tool restores Patch Sample All Layers to on"); + assert!(app.ui.tool_options.sample_all.magic_wand, "other tools keep their Sample All Layers setting"); +} + +#[test] +fn saved_settings_keep_stored_patch_sample_all_layers_value() { + let saved_off: ToolOptions = serde_json::from_value(json!({ + "sample_all": { + "patch": false, + "magic_wand": true + } + })) + .unwrap(); + assert!(!saved_off.sample_all.patch); + assert!(saved_off.sample_all.magic_wand); + + let missing_patch: ToolOptions = serde_json::from_value(json!({ + "sample_all": { + "magic_wand": true + } + })) + .unwrap(); + assert!(missing_patch.sample_all.patch); + assert!(missing_patch.sample_all.magic_wand); +} diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 4b5cf04d61..aefc2bb3c7 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -210,6 +210,181 @@ fn the_pen_carries_on_an_open_path_from_either_end() { assert_eq!((work.subpaths.len(), work.subpaths[1].closed, work.subpaths[1].knots.len()), (2, true, 3)); } +/// #10726: after another tool, the Curvature Pen carries an open path on as in Photoshop: a click on +/// its endpoint selects it, and the next click continues the path from there. The subpath is +/// extended in one History step, its segments keep their shape, and the new anchors take smooth +/// curvature handles. +#[test] +fn the_curvature_pen_carries_on_an_open_path() { + let mut app = app(Tool::Pen); + click(&mut app, 20.0, 20.0); + // A curve: the middle anchor's handles dragged out further than the Curvature Pen would set. + tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 10.0 }, Modifiers::NONE); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let before = work_path(&app).subpaths[0].knots.clone(); + assert_ne!(before[1].out_ctrl, before[1].anchor, "the middle anchor has handles"); + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + let at = steps(&app); + app.ui.tool = Tool::CurvaturePen; + click(&mut app, 80.0, 80.0); + assert!(app.ui.pen.is_none(), "the click selects the endpoint, it draws nothing"); + click(&mut app, 140.0, 120.0); + assert!(app.ui.pen.as_ref().is_some_and(|p| p.curvature && p.knots.len() == 4), "the next click carries the path on"); + click(&mut app, 200.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + let knots = &work.subpaths[0].knots; + let anchors: Vec<[f64; 2]> = knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, [[20.0, 20.0], [80.0, 20.0], [80.0, 80.0], [140.0, 120.0], [200.0, 80.0]]); + assert_eq!(steps(&app), at + 1, "one History step"); + // The Pen's straight segments keep their shape: the first knots are as they were, and the + // endpoint keeps its incoming handle. + assert_eq!((knots[0], knots[1]), (before[0], before[1])); + assert_eq!((knots[2].anchor, knots[2].in_ctrl), (before[2].anchor, before[2].in_ctrl)); + // The new anchor in the middle is smooth: its handles lie either side of it. + let mid = &knots[3]; + assert!(mid.in_ctrl != mid.anchor && mid.out_ctrl != mid.anchor, "{mid:?}"); + assert!((mid.in_ctrl.x - mid.anchor.x) * (mid.out_ctrl.x - mid.anchor.x) < 0.0, "handles on either side: {mid:?}"); + // While it draws on, the Curvature Pen moves only the new anchors: a press on an old corner + // adds an anchor there. + click(&mut app, 200.0, 80.0); + click(&mut app, 260.0, 40.0); + click(&mut app, 80.0, 20.0); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(7), "a new anchor, the old one untouched"); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots[1][0]), Some([80.0, 20.0])); +} + +/// The work path set to `subpaths`, in one step. +fn set_work_path(app: &mut PhotocraftApp, subpaths: Vec) { + app.session + .edit("path", |doc, _| { + doc.work_path = Some(Path::new(subpaths)); + Ok(()) + }) + .unwrap(); +} + +/// Direct Selection's selection: anchor `[subpath, knot]` of `target`, alone. +fn select_anchor(app: &mut PhotocraftApp, target: crate::direct_select::PathRef, anchor: [usize; 2]) { + app.ui.direct_selection.target = Some(target); + app.ui.direct_selection.anchors = vec![anchor]; + // Made now, on this document: not a stale selection the next press drops. + app.ui.direct_selection.owner = crate::direct_select::Owner::current(app); +} + +/// CodeRabbit on #10744: two open subpaths end on the same point, and the Curvature Pen carries on +/// the one whose endpoint is selected, not the first one ending there. +#[test] +fn the_curvature_pen_carries_on_the_selected_subpath_of_two_that_end_together() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 80.0)]), Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [1, 1]); + click(&mut app, 140.0, 150.0); + assert_eq!(app.ui.pen.as_ref().and_then(|p| p.resumes.as_ref()).map(|r| r.index), Some(1), "the selected subpath"); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.iter().map(|s| s.knots.len()).collect::>(), [2, 3], "the second subpath grew"); +} + +/// Photoshop: an open Curvature Pen arc carried on from its endpoint goes on as a new Curvature +/// run from that endpoint. The old segments keep their shape, the endpoint keeps its incoming +/// handle, and its outgoing handle and the new knots are those of a path drawn from the endpoint +/// on, so the join is a cusp where both arcs meet, not bent smooth. +#[test] +fn a_carried_on_endpoint_starts_a_new_curvature_run() { + let draw = |points: &[(f64, f64)]| { + let mut app = app(Tool::CurvaturePen); + for &(x, y) in points { + click(&mut app, x, y); + } + assert!(crate::vector_ui::pen_commit(&mut app, false), "the commit goes through"); + app + }; + let fresh = work_path(&draw(&[(400.0, 300.0), (550.0, 150.0), (700.0, 300.0)])).subpaths[0].knots.clone(); + let mut app = draw(&[(100.0, 300.0), (250.0, 150.0), (400.0, 300.0)]); + let before = work_path(&app).subpaths[0].knots.clone(); + click(&mut app, 400.0, 300.0); + assert!(app.ui.pen.is_none(), "the click selects the endpoint, it draws nothing"); + click(&mut app, 550.0, 150.0); + click(&mut app, 700.0, 300.0); + assert!(crate::vector_ui::pen_commit(&mut app, false), "the commit goes through"); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + let knots = &work.subpaths[0].knots; + assert_eq!(knots.len(), 5, "carried on by the new anchors"); + assert_eq!((knots[0], knots[1]), (before[0], before[1]), "the old segments keep their shape"); + assert_eq!((knots[2].anchor, knots[2].in_ctrl), (before[2].anchor, before[2].in_ctrl), "the endpoint keeps its incoming handle"); + assert_eq!(knots[2].out_ctrl, fresh[0].out_ctrl, "the endpoint goes on as a new run's first knot: {knots:?}"); + assert_eq!((knots[3], knots[4]), (fresh[1], fresh[2]), "the new knots are those of a new run"); + // Both arcs meet at the endpoint from above: a cusp, as in Photoshop. + let k = &knots[2]; + assert!(k.in_ctrl.y < k.anchor.y && k.out_ctrl.y < k.anchor.y, "{k:?}"); +} + +/// CodeRabbit on #10744: a curved path carried on from its first endpoint runs backwards, each +/// knot's handles swapped, and that endpoint keeps the handle it had towards the path. +#[test] +fn a_curved_path_carries_on_from_its_first_endpoint_with_its_handles_swapped() { + use photocraft_doc::vector::{Knot, Subpath}; + use photocraft_geom::Point; + let mut app = app(Tool::CurvaturePen); + let start = Knot::smooth(Point::new(20.0, 20.0), Point::new(10.0, 30.0), Point::new(30.0, 10.0)); + let middle = Knot::smooth(Point::new(50.0, 60.0), Point::new(40.0, 50.0), Point::new(60.0, 70.0)); + set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![start, middle, Knot::corner(80.0, 80.0)], op: Default::default() }]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 0]); + click(&mut app, 0.0, 100.0); + let pen = app.ui.pen.as_ref().expect("the path is carried on"); + assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(0)); + let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); + assert_eq!(anchors, [[80.0, 80.0], [50.0, 60.0], [20.0, 20.0], [0.0, 100.0]], "backwards, then the new anchor"); + assert_eq!((pen.knots[1][1], pen.knots[1][2]), ([60.0, 70.0], [40.0, 50.0]), "the curved middle knot's handles are swapped"); + assert_eq!(pen.knots[2][1], [30.0, 10.0], "the endpoint's handle towards the path: its old outgoing one"); +} + +/// CodeRabbit on #10744: a double-click on a knot of the path carried on (not the endpoint it +/// carries on from) changes nothing: those knots keep their handles. +#[test] +fn toggling_a_carried_knot_changes_nothing() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 2]); + click(&mut app, 140.0, 120.0); + let before = app.ui.pen.as_ref().map(|p| (p.unlinked.clone(), p.knots.clone())); + crate::vector_ui::curvature_toggle(&mut app, 80.0, 20.0); + assert_eq!(app.ui.pen.as_ref().map(|p| (p.unlinked.clone(), p.knots.clone())), before, "the old corner stays as it was, handles and all"); +} + +/// CodeRabbit on #10744: in Path mode the Pen carries on the work path, so an endpoint selected +/// on a shape layer, even one on the same point as the work path's, is not carried on: the +/// press starts a new path rather than carrying on the work path from there. +#[test] +fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let shape = app.session.active().and_then(|st| st.active_layer).expect("a shape layer").0; + app.ui.tool_options.vector_mode = "path".into(); + // The Paths panel on no path: the Pen draws on the work path, not the layer's. + app.ui.selected_path = None; + set_work_path(&mut app, vec![Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); + // The tool first: switching tools drops a Direct Selection selection. + app.ui.tool = Tool::CurvaturePen; + select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); + assert!(crate::direct_select::selected_open_end(&app).is_some(), "the shape's endpoint is selected"); + click(&mut app, 140.0, 150.0); + let pen = app.ui.pen.as_ref().expect("a new path"); + assert!(pen.resumes.is_none() && pen.knots.len() == 1, "{pen:?}"); +} + /// #6593 review: carrying on a subpath keeps its path operation, and a draft whose subpath changed /// meanwhile refuses without overwriting newer work. The historical test name is retained /// for reviewed-head provenance; captured-revision validation supersedes its stale fallback. diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index aea5743215..6cc618e8fa 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -259,7 +259,7 @@ fn the_eraser_in_block_mode_erases_a_16_screen_px_square() { } #[test] -fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { +fn b_cycles_brush_pencil_color_replacement_and_mixer_brush() { let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -279,9 +279,9 @@ fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { press(&mut h, Modifiers::NONE); assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::NONE); - assert_eq!(h.state().ui.tool, Tool::MixerBrush); - press(&mut h, Modifiers::NONE); assert_eq!(h.state().ui.tool, Tool::ColorReplacement); + press(&mut h, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); // Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool. h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); press(&mut h, Modifiers::SHIFT); @@ -289,11 +289,11 @@ fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { press(&mut h, Modifiers::SHIFT); assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::SHIFT); - assert_eq!(h.state().ui.tool, Tool::MixerBrush); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); press(&mut h, Modifiers::SHIFT); - assert_eq!(h.state().ui.tool, Tool::ColorReplacement); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); press(&mut h, Modifiers::NONE); - assert_eq!(h.state().ui.tool, Tool::ColorReplacement); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil)); assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush)); } diff --git a/crates/ui-egui/src/polygon_lasso_step_snap_tests.rs b/crates/ui-egui/src/polygon_lasso_step_snap_tests.rs new file mode 100644 index 0000000000..cedd61c69e --- /dev/null +++ b/crates/ui-egui/src/polygon_lasso_step_snap_tests.rs @@ -0,0 +1,192 @@ +//! Polygonal Lasso History step and Shift snap (#11393): the step is named "Polygonal Lasso" +//! however the polygon closes, and a Shift diagonal keeps the shorter of the two offsets on both +//! axes, as Photoshop does. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, constrained_polygon_point, tool_event}; +use crate::state::Tool; + +fn app_with(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool = tool; + app +} + +fn ev(app: &mut PhotocraftApp, e: ToolEvent, m: Modifiers) { + tool_event(app, e, m); +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + ev(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + ev(app, ToolEvent::Up { x, y }, m); +} + +fn last_step(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().last().cloned() +} + +fn bounds(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()) +} + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = app_with(Tool::PolygonLasso); + app.ui.extras.rulers = false; + // 60 fps, so a double-click's two clicks fall inside egui's 0.3 s window. + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // 100 %: one document pixel per point, the area around (200, 200) in view. + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 200.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +fn screen_click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } +} + +/// Three corners, well apart in time so they aren't a double-click. +fn three_corners(h: &mut Harness<'static, PhotocraftApp>) { + for (x, y) in [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0)] { + screen_click(h, x, y); + h.run_steps(30); + } +} + +#[test] +fn closing_on_the_start_point_records_a_polygonal_lasso_step() { + let mut app = app_with(Tool::PolygonLasso); + for (x, y) in [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 100.0)] { + click(&mut app, x, y, Modifiers::NONE); + } + assert!(app.ui.polygon.is_empty(), "the start point closes the polygon"); + assert_eq!(bounds(&app), Some(Rect::new(100, 100, 300, 300))); + assert_eq!(last_step(&app).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn closing_with_enter_records_a_polygonal_lasso_step() { + let mut h = harness(); + three_corners(&mut h); + let before = h.state().session.active().unwrap().history.entries().len(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(h.state().ui.polygon.is_empty(), "Enter closes the polygon"); + assert!(bounds(h.state()).is_some()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), before + 1, "one step"); + assert_eq!(last_step(h.state()).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn closing_with_a_double_click_records_a_polygonal_lasso_step() { + let mut h = harness(); + three_corners(&mut h); + screen_click(&mut h, 100.0, 300.0); + screen_click(&mut h, 100.0, 300.0); + h.run_steps(1); + assert!(h.state().ui.polygon.is_empty(), "the double-click closes the polygon"); + assert!(bounds(h.state()).is_some()); + assert_eq!(last_step(h.state()).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn the_freehand_lasso_still_records_a_lasso_step() { + let mut app = app_with(Tool::Lasso); + ev(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(200.0, 100.0), (300.0, 100.0), (300.0, 200.0), (300.0, 300.0), (200.0, 300.0)] { + ev(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, Modifiers::NONE); + } + ev(&mut app, ToolEvent::Up { x: 200.0, y: 300.0 }, Modifiers::NONE); + assert!(bounds(&app).is_some()); + assert_eq!(last_step(&app).as_deref(), Some("Lasso")); + // And the engine's own default, for scripts that leave `polygonal` out. + app.run("select.lasso", json!({"points": [[10, 10], [60, 10], [10, 60]]})).unwrap(); + assert_eq!(last_step(&app).as_deref(), Some("Lasso")); +} + +/// The reference measurement: from 100,100 a Shift-click at 200,170 lands on (170,170), and the +/// closed selection's right edge is at 170. +#[test] +fn a_shift_click_at_200_170_from_100_100_places_the_vertex_at_170_170() { + let mut app = app_with(Tool::PolygonLasso); + click(&mut app, 100.0, 100.0, Modifiers::NONE); + click(&mut app, 200.0, 170.0, Modifiers::SHIFT); + assert_eq!(app.ui.polygon, vec![[100.0, 100.0], [170.0, 170.0]]); + click(&mut app, 100.0, 300.0, Modifiers::NONE); + crate::canvas::commit_polygon(&mut app); + assert_eq!(bounds(&app).map(|r| (r.x0, r.x1)), Some((100, 170)), "{:?}", bounds(&app)); + assert_eq!(last_step(&app).as_deref(), Some("Polygonal Lasso")); +} + +#[test] +fn each_diagonal_keeps_the_shorter_offset_with_its_signs() { + let from = [100.0, 100.0]; + // One case per diagonal, the shorter offset on either axis. + for (to, want) in [ + ([200.0, 170.0], [170.0, 170.0]), // down-right, dy shorter + ([30.0, 200.0], [30.0, 170.0]), // down-left, dx shorter + ([0.0, 30.0], [30.0, 30.0]), // up-left, dy shorter + ([170.0, 0.0], [170.0, 30.0]), // up-right, dx shorter + ] { + assert_eq!(constrained_polygon_point(from, to), want, "{to:?}"); + } +} + +/// Axis snaps aren't measured in the reference, so they keep today's rule: the segment's length. +#[test] +fn horizontal_and_vertical_snaps_keep_the_segment_length() { + let from = [100.0, 100.0]; + let length = 100.0_f64.hypot(20.0); + for (to, want) in [ + ([200.0, 120.0], [100.0 + length, 100.0]), + ([80.0, 200.0], [100.0, 100.0 + length]), + ([0.0, 80.0], [100.0 - length, 100.0]), + ([120.0, 0.0], [100.0, 100.0 - length]), + ] { + let got = constrained_polygon_point(from, to); + assert!((got[0] - want[0]).abs() < 1e-9 && (got[1] - want[1]).abs() < 1e-9, "{to:?} → {got:?}"); + } +} diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index f3f713ed6f..326b23c800 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -635,7 +635,8 @@ fn shift_constrains_polygonal_lasso_segments_but_unmodified_clicks_remain_free() ev(&mut app, "up", 140.0, 120.0, Modifiers::SHIFT); let diagonal = app.ui.polygon[1]; assert!((diagonal[0] - diagonal[1]).abs() < 1e-8, "45-degree direction: {diagonal:?}"); - assert!((diagonal[0] - 100.0 - 40.0_f64.hypot(20.0) / 2.0_f64.sqrt()).abs() < 1e-8); + // The diagonal keeps the shorter offset (20) on both axes (#11393). + assert!((diagonal[0] - 120.0).abs() < 1e-8, "{diagonal:?}"); // Releasing Shift restores free placement rather than constraining subsequent points. ev(&mut app, "down", 155.0, 175.0, Modifiers::NONE); ev(&mut app, "up", 155.0, 175.0, Modifiers::NONE); @@ -652,7 +653,9 @@ fn shift_polygonal_lasso_snap_has_all_eight_directions_and_handles_zero_length() let dy = end[1] - origin[1]; let angle = dy.atan2(dx) / std::f64::consts::FRAC_PI_4; assert!((angle - angle.round()).abs() < 1e-9, "{delta:?} → {end:?}"); - assert!((dx.hypot(dy) - delta[0].hypot(delta[1])).abs() < 1e-8); + // Axis snaps keep the length; diagonals keep the shorter offset on both axes (#11393). + let kept = if angle.round().rem_euclid(2.0) == 1.0 { delta[0].abs().min(delta[1].abs()) * std::f64::consts::SQRT_2 } else { delta[0].hypot(delta[1]) }; + assert!((dx.hypot(dy) - kept).abs() < 1e-8, "{delta:?} → {end:?}"); } assert_eq!(crate::canvas::constrained_polygon_point(origin, origin), origin); } @@ -751,3 +754,67 @@ fn a_held_temporary_tool_keeps_the_polygon() { h.run_steps(3); assert_eq!(h.state().ui.polygon.len(), 2, "released"); } + +/// #6578: the last corner of a small polygon lands within 8 points of the first one, but it is a +/// new vertex, not a close; clicking on the first vertex still closes. +#[test] +fn the_last_corner_of_a_small_polygon_does_not_close_it() { + let mut app = polygon_app(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + assert_eq!(app.point_zoom(), 1.0); + let corners = [[100.0, 100.0], [106.0, 100.0], [106.0, 106.0], [100.0, 106.0]]; + for [x, y] in corners { + ev(&mut app, "down", x, y, Modifiers::NONE); + ev(&mut app, "up", x, y, Modifiers::NONE); + } + assert_eq!(app.ui.polygon.len(), 4, "four corners, still open: {:?}", app.ui.polygon); + assert!(app.session.active().unwrap().doc.selection.is_none()); + ev(&mut app, "down", 100.0, 100.0, Modifiers::NONE); + ev(&mut app, "up", 100.0, 100.0, Modifiers::NONE); + assert!(app.ui.polygon.is_empty(), "a click on the first vertex closes"); + let bounds = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert!(near(bounds, 100, 100, 106, 106), "a square, not a triangle: {bounds:?}"); +} + +/// Large outlines keep the full 8-point reach; tiny ones still close on their first vertex. The +/// reach follows the size of the whole outline, not its shortest edge (#7629). +#[test] +fn polygon_close_reach_scales_with_small_outlines_only() { + let reach = crate::canvas::polygon_close_reach; + let square = |s: f64| [[0.0, 0.0], [s, 0.0], [s, s], [0.0, s]]; + assert_eq!(reach(square(100.0), 1.0), 8.0); + assert_eq!(reach(square(100.0), 2.0), 4.0); + assert_eq!(reach(square(32.0), 1.0), 8.0); + assert_eq!(reach(square(6.0), 1.0), 1.5); + assert_eq!(reach(square(1.0), 1.0), 1.0); + assert_eq!(crate::canvas::polygon_close_reach([[f64::NAN, 0.0]], 1.0), 8.0); + assert_eq!(crate::canvas::polygon_close_reach([[f64::NAN, 0.0], [0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], 1.0), 2.5); +} + +/// A large polygon with one short edge keeps the full reach: only a small outline shrinks it. +#[test] +fn polygon_close_reach_ignores_one_short_edge_of_a_large_outline() { + let reach = crate::canvas::polygon_close_reach; + assert_eq!(reach([[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [96.0, 101.0]], 1.0), 8.0); + // The outline of #7629's test (about 40 points across): the click 5 points from the first vertex still closes. + assert!(reach([[50.0, 50.0], [90.0, 50.0], [90.0, 90.0], [85.0, 91.0]], 1.0) > 5.0); +} + +#[test] +fn narrow_and_rotated_polygons_keep_the_fourth_corner() { + for corners in [[[100.0, 100.0], [200.0, 100.0], [200.0, 106.0], [100.0, 106.0]], [[100.0, 100.0], [160.0, 180.0], [155.2, 183.6], [95.2, 103.6]]] { + let mut app = polygon_app(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + for [x, y] in corners { + ev(&mut app, "down", x, y, Modifiers::NONE); + ev(&mut app, "up", x, y, Modifiers::NONE); + } + assert_eq!(app.ui.polygon.len(), 4, "{corners:?}"); + assert!(app.session.active().unwrap().doc.selection.is_none()); + ev(&mut app, "down", 100.0, 100.0, Modifiers::NONE); + assert!(app.ui.polygon.is_empty()); + assert!(app.session.active().unwrap().doc.selection.is_some()); + } +} diff --git a/crates/ui-egui/src/preferences_submenu_tests.rs b/crates/ui-egui/src/preferences_submenu_tests.rs new file mode 100644 index 0000000000..65b54dc4eb --- /dev/null +++ b/crates/ui-egui/src/preferences_submenu_tests.rs @@ -0,0 +1,87 @@ +//! #11398: Edit › Preferences says "Plugins…" and "Camera Raw…" and has the reference +//! application's two separators (after the first entry and before Camera Raw…). + +use crate::menu_catalog::CATALOG; + +/// The submenu's rows, top to bottom: (label or "---", command id). +fn rows() -> Vec<(&'static str, &'static str)> { + CATALOG.iter().filter(|e| e.0 == ["Edit", "Preferences"]).map(|e| (e.1, e.3)).collect() +} + +#[test] +fn preferences_submenu_reads_in_the_reference_order_with_two_separators() { + let labels: Vec<&str> = rows().into_iter().map(|r| r.0).collect(); + // Row 1 ("Settings…") is #8239's: whatever it says, it is first and followed by a separator. + assert_eq!( + &labels[1..], + [ + "---", + "AI Integrations…", + "Interface…", + "Workspace…", + "Tools…", + "History Log…", + "File Handling…", + "Export…", + "Performance…", + "Scratch Disks…", + "Cursors…", + "Transparency & Gamut…", + "Units & Rulers…", + "Guides, Grid & Slices…", + "Plugins…", + "Type…", + "Enhanced Controls…", + "---", + "Camera Raw…", + ] + ); +} + +#[test] +fn preferences_submenu_ids_are_unchanged() { + let ids: Vec<&str> = rows().into_iter().map(|r| r.1).collect(); + assert_eq!( + ids, + [ + "edit.preferences.general", + "---", + "edit.preferences.integrations", + "edit.preferences.interface", + "edit.preferences.workspace", + "edit.preferences.tools", + "edit.preferences.historyLog", + "edit.preferences.fileHandling", + "edit.preferences.export", + "edit.preferences.performance", + "edit.preferences.scratchDisks", + "edit.preferences.cursors", + "edit.preferences.transparencyAndGamut", + "edit.preferences.unitsAndRulers", + "edit.preferences.guidesGridAndSlices", + "edit.preferences.plugIns", + "edit.preferences.type", + "edit.preferences.enhancedControls", + "---", + "edit.preferences.rawDefaults", + ] + ); +} + +#[test] +fn engine_labels_match_the_catalogue_for_the_renamed_rows() { + for id in ["edit.preferences.plugIns", "edit.preferences.rawDefaults"] { + let catalogue = CATALOG.iter().find(|e| e.3 == id).unwrap_or_else(|| panic!("{id} missing from the catalogue")).1; + let engine = photocraft_engine::commands::find(id).unwrap_or_else(|| panic!("{id} missing from the engine")).label; + assert_eq!(engine, catalogue, "{id}"); + } + assert_eq!(photocraft_engine::commands::find("edit.preferences.plugIns").unwrap().label, "Plugins…"); + assert_eq!(photocraft_engine::commands::find("edit.preferences.rawDefaults").unwrap().label, "Camera Raw…"); +} + +#[test] +fn preferences_dialog_page_names_follow_the_menu() { + let title = |key: &str| photocraft_engine::prefs::SECTIONS.iter().find(|(k, _)| *k == key).map(|(_, t)| *t); + assert_eq!(title("plugIns"), Some("Plugins")); + assert_eq!(title("rawDefaults"), Some("Camera Raw Defaults")); +} diff --git a/crates/ui-egui/src/prefs/prefs_ui.rs b/crates/ui-egui/src/prefs/prefs_ui.rs index e37efea17f..127812585a 100644 --- a/crates/ui-egui/src/prefs/prefs_ui.rs +++ b/crates/ui-egui/src/prefs/prefs_ui.rs @@ -53,6 +53,9 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + pub(crate) tool_colors: Option<[[[f32; 4]; 2]; 2]>, + pub(crate) tool_cmyk_picks: crate::tool_memory::CmykPicks, + pub(crate) group_tools: Option>, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, } diff --git a/crates/ui-egui/src/prefs/prefs_ui/appearance.rs b/crates/ui-egui/src/prefs/prefs_ui/appearance.rs index 87405f606e..69bb926142 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/appearance.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/appearance.rs @@ -185,21 +185,6 @@ pub(super) fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) { } } -/// Interface › Enable Animations: off, egui's fades and slides take no time and scroll areas jump -/// straight to their target (#3273). Re-checked every frame because a theme change rebuilds the -/// style; both the dark and light styles are set, so a system appearance switch keeps it. -pub(super) fn sync_animations(app: &PhotocraftApp, ctx: &egui::Context) { - let on = app.session.prefs().interface.enable_animations; - let time = if on { egui::Style::default().animation_time } else { 0.0 }; - if ctx.global_style().animation_time != time { - let scroll = if on { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() }; - ctx.all_styles_mut(|s| { - s.animation_time = time; - s.scroll_animation = scroll; - }); - } -} - /// Resolve the saved appearance mode without changing it or either theme slot. pub(crate) fn sync_appearance(app: &mut PhotocraftApp, ctx: &egui::Context) { // A restored or embedding-supplied pin must not suppress native appearance events. diff --git a/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs b/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs index c1519b48d9..7f3586af55 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs @@ -101,6 +101,8 @@ pub fn apply(app: &mut PhotocraftApp, id: u64) -> Result { return Err("Apply is only available for Preferences".into()); } let fields = d.fields.clone(); + // As OK does: an open colour picker would leave a previewed colour in the applied values. + crate::dialogs::confirm_preflight(app, id)?; let result = confirm(app, &fields)?; let values = Value::Object(preference_values(app.session.prefs())); if let Some(d) = app.ui.dialog_mut(id) { diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index 2accc50371..9737cbb329 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -101,8 +101,11 @@ pub(super) fn humanize(key: &str) -> String { .replace("Lut ", "LUT ") } +/// The visible label of a stored choice value (`windowsInk` -> "Windows Ink (default)"). pub(super) fn choice_label(v: &str) -> String { match v { + "windowsInk" => "Windows Ink (default)".into(), + "winTab" => "WinTab".into(), "exr" => "EXR".into(), "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), @@ -148,22 +151,27 @@ pub(super) fn color_of(s: &str) -> Color32 { /// Search the same visible, editable preference labels used in the dialog. pub(super) fn preference_matches(section: &str, key: &str, query: &str) -> bool { let label = humanize(key); - format!("{section}.{key}").to_lowercase().contains(query) || label.to_lowercase().contains(query) || tl!(&label).to_lowercase().contains(query) + let en = crate::i18n::Lang::EN; + en.contains_ignore_case(&format!("{section}.{key}"), query) + || en.contains_ignore_case(&label, query) + || crate::i18n::current().contains_ignore_case(tl!(&label), query) + // The Tablet input row and its Pen Tablet note are the visible terms, not just the key. + || (cfg!(target_os = "windows") && section == "tools" && key == "tabletInput" && en.contains_ignore_case("windows ink pen tablet wintab tablet input", query)) } pub(super) fn preference_sections(values: &Value, search: &str) -> Vec<(&'static str, &'static str)> { - let query = search.trim().to_lowercase(); + let query = search.trim(); SECTIONS .iter() .copied() .filter(|(id, title)| { query.is_empty() - || title.to_lowercase().contains(&query) - || tl!(title).to_lowercase().contains(&query) + || crate::i18n::Lang::EN.contains_ignore_case(title, query) + || crate::i18n::current().contains_ignore_case(tl!(title), query) || values .get(*id) .and_then(Value::as_object) - .is_some_and(|fields| fields.keys().any(|key| !prefs::is_hidden(&format!("{id}.{key}")) && preference_matches(id, key, &query))) + .is_some_and(|fields| fields.keys().any(|key| !prefs::is_hidden(&format!("{id}.{key}")) && preference_matches(id, key, query))) }) .collect() } @@ -230,8 +238,8 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option, key: &str) -> bool } /// The pause that bundles arrow-key nudges means nothing with Bundle Arrow-Key Nudges off (#2259). +/// WinTab is a Windows-only opt-in. pub(super) fn tools_field_visible(obj: &Map, key: &str) -> bool { - key != "nudgeBundlePauseMs" || obj.get("bundleNudges").and_then(Value::as_bool) != Some(false) + match key { + "tabletInput" => cfg!(target_os = "windows"), + "nudgeBundlePauseMs" => obj.get("bundleNudges").and_then(Value::as_bool) != Some(false), + _ => true, + } } /// A titled, outlined group of settings (Photoshop's Interface page has Appearance, @@ -374,7 +387,7 @@ pub(super) fn field_visible(section: &str, obj: &Map, k: &str, qu // The browser presents frames itself: Low-Latency Drawing does nothing there. || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) - || (section == "tools" && !tools_field_visible(obj, k)); + || (section == "tools" && (!tools_field_visible(obj, k) || k == "tabletInput")); (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) @@ -392,20 +405,43 @@ pub(super) fn section_fields( system: Option, search: &str, ) { - let query = search.to_lowercase(); + let query = search.trim(); let mut keys: Vec = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect(); keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned()); if section == "interface" { - interface_groups(ui, obj, &keys, lang, system, &query); + interface_groups(ui, obj, &keys, lang, system, query); return; } - if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { + if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", query)) { rendering_mode_row(ui, obj); } - if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) { + if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", query)) { memory_usage_row(ui, obj); } - keys.retain(|k| field_visible(section, obj, k, &query)); + // WinTab is an explicit Windows choice; Windows Ink stays the default everywhere else. + if section == "tools" + && cfg!(target_os = "windows") + && (query.is_empty() || preference_matches(section, "tabletInput", query)) + && obj.contains_key("tabletInput") + { + let t = Tokens::get(ui.ctx()); + pref_group(ui, "Pen Tablet", |ui| { + egui::Grid::new("prefs-grid-pen-tablet").num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| { + field_rows(ui, section, obj, vec!["tabletInput".to_string()], lang); + }); + ui.add( + egui::Label::new( + RichText::new(tl!( + "WinTab reads pressure, tilt, rotation and the eraser end from the tablet driver. Turn off Windows Ink in your tablet's software utility (for example Wacom Center) before using it." + )) + .small() + .color(t.text_dim), + ) + .wrap(), + ); + }); + } + keys.retain(|k| field_visible(section, obj, k, query)); egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); } @@ -457,7 +493,12 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); let mut cur = s.clone(); - crate::widgets::dropdown(ui, &format!("pref-{path}"), tl!(&label), &mut cur, &pairs, 220.0); + ui.vertical(|ui| { + crate::widgets::dropdown(ui, &format!("pref-{path}"), tl!(&label), &mut cur, &pairs, 220.0); + if read_at_launch(&path) { + ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); + } + }); obj.insert(k, json!(cur)); } Value::String(s) if prefs::is_color(&path) => { @@ -522,6 +563,15 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke if ui.small_button(tl!("Add disk")).clicked() { items.push(json!({"enabled": true, "path": ""})); } + let scratch = photocraft_raster::spill::stats(); + if scratch.free_space_unmeasured { + let dir = scratch.dir.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| tl!("Unknown").into()); + let warning = crate::i18n::fmt( + tl!("The current scratch directory ({path}) doesn't report its free space, so PhotoCraft can't stop before it fills up, and large files get no extra room from it without a Scratch Size Limit. This warning applies to the current configuration; changes above take effect on Apply or OK. Set a limit or use a local disk."), + &[("path", &dir)], + ); + ui.add(egui::Label::new(RichText::new(warning).color(t.warning)).wrap()); + } }); obj.insert(k, Value::Array(items)); } @@ -537,3 +587,19 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke ui.end_row(); } } + +#[cfg(test)] +mod tests { + use super::preference_matches; + + /// The Tablet input row matches its visible search terms. + #[test] + fn wintab_row_matches_its_visible_terms() { + if cfg!(target_os = "windows") { + for query in ["windows ink", "pen tablet", "wintab", "tablet input"] { + assert!(preference_matches("tools", "tabletInput", query), "{query}"); + } + } + assert!(preference_matches("tools", "useTabletPressure", "tablet pressure")); + } +} diff --git a/crates/ui-egui/src/prefs/prefs_ui/graphics.rs b/crates/ui-egui/src/prefs/prefs_ui/graphics.rs index 166f5ed299..45b2e9ccdb 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/graphics.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/graphics.rs @@ -136,6 +136,8 @@ pub(super) fn read_at_launch(path: &str) -> bool { "interface.systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), // The desktop app decides on the control server before its window opens. "integrations.allowAgentControl" | "integrations.controlPort" => cfg!(not(target_arch = "wasm32")), + // The WinTab context is opened once, when the window starts. + "tools.tabletInput" => cfg!(target_os = "windows"), _ => false, } } diff --git a/crates/ui-egui/src/prefs/prefs_ui/save.rs b/crates/ui-egui/src/prefs/prefs_ui/save.rs index 9dba6f78f2..d236af92b4 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/save.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/save.rs @@ -1,4 +1,4 @@ -use super::appearance::{canvas_style, sync_animations, sync_appearance, sync_display_scale, sync_tooltips}; +use super::appearance::{canvas_style, sync_appearance, sync_display_scale, sync_tooltips}; use super::startup::{load, presets_store, recovery, sync_rulers}; use super::{DocId, HashMap, PhotocraftApp, Value, prefs}; @@ -14,7 +14,6 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::theme::set_ui_font_size(ctx, app.session.prefs().interface.ui_font_size); presets_store(app); sync_tooltips(app, ctx); - sync_animations(app, ctx); app.sync_recent(); // Monotonic, not `ctx.input(|i| i.time)`: logic also runs on hidden ticks, where egui's input // time is that of the last UI pass. @@ -117,8 +116,16 @@ fn write_prefs(app: &mut PhotocraftApp, ours: &Value) -> Result<(), String> { let stored = app.services.load_prefs.as_mut().and_then(|load| load()).and_then(|t| serde_json::from_str::(&t).ok()); let text = match (stored, &app.prefs_rt.saved_value) { (Some(mut theirs @ Value::Object(_)), Some(base)) => { - let base = &without_stale_migration(base, &theirs); - merge_changes(base, ours, &mut theirs); + let mut base = without_stale_migration(base, &theirs); + // Older files have no toolColors object. Diff the first saved swatches against + // their actual defaults so an unchanged field cannot overwrite another window's edit. + if matches!(base.get("toolColors"), None | Some(Value::Null)) + && ours.get("toolColors").is_some_and(Value::is_object) + && let Some(fields) = base.as_object_mut() + { + fields.insert("toolColors".into(), crate::tool_memory::default_colors()); + } + merge_changes(&base, ours, &mut theirs); serde_json::to_string_pretty(&theirs) } _ => serde_json::to_string_pretty(ours), diff --git a/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs b/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs index f59bf278e8..13b4cfc55a 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs @@ -48,6 +48,18 @@ fn field(h: &Harness<'_, PhotocraftApp>, id: u64, name: &str) -> Value { h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null) } +/// The actual shortcut grid matches Azerbaijani uppercase text and English command aliases. +#[test] +fn azerbaijani_shortcuts_search_handles_dotted_i_and_english() { + let (mut h, id) = dialog(1.0, "image.imageSize", "ŞƏKİL ÖLÇÜSÜ"); + h.state_mut().run("prefs.set", json!({"path":"interface.language", "value":"az"})).unwrap(); + h.run_steps(4); + h.get_by_label(" Şəkil ölçüsü…"); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("filter".into(), json!("IMAGE SIZE")); + h.run_steps(3); + h.get_by_label(" Şəkil ölçüsü…"); +} + #[test] fn toolbar_shortcut_can_be_captured_and_restored_on_restart() { let (mut h, id) = dialog(1.0, "tools.select.pencil", "Pencil Tool"); @@ -106,7 +118,7 @@ fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { press(&mut app, Key::B, Modifiers::NONE); assert_eq!(app.ui.tool, Tool::Brush, "a removed tool cannot return through group memory"); press(&mut app, Key::B, Modifiers::SHIFT); - assert_eq!(app.ui.tool, Tool::MixerBrush, "Shift cycling skips a removed tool"); + assert_eq!(app.ui.tool, Tool::ColorReplacement, "Shift cycling skips a removed tool"); app.run("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); app.ui.tool = Tool::Brush; @@ -114,7 +126,7 @@ fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { assert_eq!(app.ui.tool, Tool::Pencil, "reset restores the default group"); app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); press(&mut app, Key::B, Modifiers::NONE); - assert_eq!(app.ui.tool, Tool::MixerBrush, "plain-key cycling still follows the preference"); + assert_eq!(app.ui.tool, Tool::ColorReplacement, "plain-key cycling still follows the preference"); } #[test] diff --git a/crates/ui-egui/src/prefs/prefs_ui/shortcuts.rs b/crates/ui-egui/src/prefs/prefs_ui/shortcuts.rs index 3c0bf1d4a4..3f6679f0a6 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/shortcuts.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/shortcuts.rs @@ -324,7 +324,7 @@ pub(super) fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut } } } - let needle = filter.to_ascii_lowercase(); + let needle = filter.trim(); egui::ScrollArea::vertical().max_height(420.0).id_salt("shortcuts-scroll").show(ui, |ui| { let mut last_top = String::new(); egui::Grid::new("shortcut-grid").num_columns(3).spacing([12.0, 3.0]).striped(true).show(ui, |ui| { @@ -336,10 +336,12 @@ pub(super) fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut let alts = eff_alts(&alt_overrides, id, cur.as_deref()); let (shown_path, shown_label) = shown(id, label, path); // Match what is shown and the English name (commands are documented in English). - let hay = - format!("{} {} {} {} {} {}", shown_path.join(" "), shown_label, path.join(" "), label, cur.clone().unwrap_or_default(), alts.join(" ")) - .to_lowercase(); - if !needle.is_empty() && !hay.contains(&needle) && !id.to_ascii_lowercase().contains(&needle) { + let local = format!("{} {}", shown_path.join(" "), shown_label); + let english = format!("{} {} {} {} {id}", path.join(" "), label, cur.clone().unwrap_or_default(), alts.join(" ")); + if !needle.is_empty() + && !crate::i18n::current().contains_ignore_case(&local, needle) + && !crate::i18n::Lang::EN.contains_ignore_case(&english, needle) + { continue; } if tab == 0 && path.is_empty() && def.is_none() && cur.is_none() && alts.is_empty() { diff --git a/crates/ui-egui/src/prefs/prefs_ui/startup.rs b/crates/ui-egui/src/prefs/prefs_ui/startup.rs index 16b945b49e..78b3217dc2 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/startup.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/startup.rs @@ -204,7 +204,7 @@ pub(crate) fn on_recovery_event(app: &mut PhotocraftApp, e: &JobEvent) -> bool { // Native bundle decoding cannot stop midway. Cancel admission and the rest of the // batch so another large decoder isn't started while this worker finishes. app.prefs_rt.recovery_queue.clear(); - app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", tl!(&e.label))]); + app.ui.status = crate::i18n::fmt(tl!("Canceled {label}"), &[("label", tl!(&e.label))]); app.ui.status_error = false; } } diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 21994b7352..7064c8a286 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -142,6 +142,19 @@ fn preferences_search_matches_editable_fields_across_sections() { assert_eq!(preference_sections(&values, "").len(), SECTIONS.len()); } +/// The preview language controls translated search; English schema paths keep their aliases. +#[test] +fn azerbaijani_preferences_search_keeps_dotted_i_and_schema_aliases() { + let az = crate::i18n::Lang::from_code("az").unwrap(); + crate::i18n::with_language(az, || { + let values = prefs::Preferences::default().to_json(); + assert!(preference_sections(&values, "İNTERFEYS").iter().any(|(id, _)| *id == "interface")); + assert!(super::fields::preference_matches("interface", "language", "DİL")); + assert!(!super::fields::preference_matches("interface", "language", "DIL")); + assert!(super::fields::preference_matches("interface", "uiScale", "INTERFACE.UISCALE")); + }); +} + #[test] fn preferences_dialog_width_follows_its_resizable_area() { let prefs = serde_json::json!({"__prefsui": "prefs"}); @@ -1126,6 +1139,125 @@ fn selected_tool_excludes_temporary_switches() { assert_eq!(stored(&store)["selectedTool"], json!("PolygonLasso")); } +/// #3569: a restart keeps the last tool of every group, not only the selected tool's. +#[test] +fn group_tools_survive_a_restart() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + for tool in [Tool::Brush, Tool::Pencil, Tool::PaintBucket, Tool::PolygonLasso, Tool::Pencil, Tool::Zoom] { + app.ui.tool = tool; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + } + assert_eq!(stored(&store)["groupTools"], json!(["Zoom", "Pencil", "PolygonLasso", "PaintBucket", "Brush", "Move"]), "most recent first, each once"); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool, Tool::Zoom); + assert_eq!(app2.ui.group_tools, app.ui.group_tools); + assert_eq!(app2.ui.group_tool('B'), Some(Tool::Pencil), "the group's latest tool"); + assert_eq!(app2.ui.group_tool('G'), Some(Tool::PaintBucket)); + assert_eq!(app2.ui.group_tool('L'), Some(Tool::PolygonLasso)); +} + +#[test] +fn group_tools_are_flushed_on_exit_without_another_frame() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + app.ui.tool = Tool::Pencil; + crate::tool_memory::persist(&mut app, &egui::Context::default()); + app.ui.tool = Tool::PaintBucket; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.group_tool('B'), Some(Tool::Pencil)); + assert_eq!(app2.ui.group_tool('G'), Some(Tool::PaintBucket)); +} + +/// A frame as far as the tools go: the key handling, then what is remembered. +fn tool_frame(app: &mut PhotocraftApp, ctx: &egui::Context, events: Vec) { + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + crate::shortcuts::handle(app, ctx); + ctx.end_pass().textures_delta.clear(); + crate::tool_memory::persist(app, ctx); + tick(app, ctx); +} + +/// A tool picked during a drag that ends on the tool it began with: the selected tool never +/// changed, but that tool's group did. +#[test] +fn group_tools_are_saved_when_the_selected_tool_stays_the_same() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let pointer = |pressed| egui::Event::PointerButton { pos: egui::Pos2::ZERO, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + app.ui.tool = Tool::Brush; + tool_frame(&mut app, &ctx, vec![]); + tool_frame(&mut app, &ctx, vec![pointer(true)]); + app.ui.tool = Tool::PaintBucket; + tool_frame(&mut app, &ctx, vec![]); + app.ui.tool = Tool::Brush; + tool_frame(&mut app, &ctx, vec![]); + tool_frame(&mut app, &ctx, vec![pointer(false)]); + assert_eq!(stored(&store)["selectedTool"], json!("Brush")); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.group_tool('G'), Some(Tool::PaintBucket)); +} + +/// The pen's eraser end and a held tool letter don't change what a group shows or brings back, +/// neither during the session nor when quitting while they are in use. +#[test] +fn group_tools_exclude_temporary_switches() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + for tool in [Tool::MagicEraser, Tool::Brush] { + app.ui.tool = tool; + tool_frame(&mut app, &ctx, vec![]); + } + app.stylus.tool_before_eraser = Some(Tool::Brush); + app.ui.tool = Tool::Eraser; + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.group_tool('E'), Some(Tool::MagicEraser), "the pen's eraser end"); + app.stylus.tool_before_eraser = None; + app.ui.tool = Tool::Brush; + let press = egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + tool_frame(&mut app, &ctx, vec![press]); + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.tool, Tool::Zoom, "Z is still down"); + assert_eq!(app.ui.group_tool('Z'), None, "a held letter"); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["groupTools"], json!(["Brush", "MagicEraser", "Move"])); +} + +/// A selected tool this version doesn't know starts on the default tool. The saved list stays as +/// it is, names this version doesn't know included, until the next tool is picked. +#[test] +fn a_launch_alone_saves_no_group_tools() { + use crate::state::Tool; + let saved = json!({"selectedTool": "FutureTool", "groupTools": ["Pencil", "FutureTool", "PaintBucket"]}); + let (mut app, store) = app_with_saved(Some(saved.to_string())); + let ctx = egui::Context::default(); + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(stored(&store)["groupTools"], saved["groupTools"]); + app.ui.tool = Tool::Brush; + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(stored(&store)["groupTools"], json!(["Brush", "Move", "Pencil", "PaintBucket"])); +} + +/// A damaged or hand-edited `groupTools` costs no other preference, and what reads back counts. +#[test] +fn damaged_group_tools_keep_what_reads_back() { + use crate::state::Tool; + for bad in [json!(null), json!("Pencil"), json!(42), json!({"B": "Pencil"})] { + let (app, _) = app_with_saved(Some(json!({"groupTools": bad, "toolOptions": {"feather": 12.0}}).to_string())); + assert_eq!(app.ui.group_tools, [Tool::Move], "{bad}"); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + let saved = json!({"groupTools": ["FutureTool", "Pencil", 7, null, {}, "PaintBucket", "Pencil"]}); + let (app, _) = app_with_saved(Some(saved.to_string())); + assert_eq!(app.ui.group_tools, [Tool::Move, Tool::Pencil, Tool::PaintBucket]); +} + #[test] fn tool_options_survive_a_restart() { let (mut app, store) = app_with_store(); @@ -1300,7 +1432,7 @@ fn remembered_tool_brushes_survive_damage() { } /// #6212: Reset Tool forgets that tool's remembered options and brush; Reset All Tools -/// forgets every tool's. +/// forgets every tool's: each painting tool keeps only its own starting brush (#11810). #[test] fn reset_tools_clear_the_remembered_options_and_brushes() { use crate::state::Tool; @@ -1339,10 +1471,12 @@ fn reset_tools_clear_the_remembered_options_and_brushes() { tick(&mut app, &ctx); let saved = stored(&store); assert!(saved["toolOptions"].is_null(), "{saved}"); - assert!(saved["toolBrushes"]["brushes"].as_object().is_none_or(|m| m.is_empty()), "{saved}"); + let starting = + |brushes: &[(Tool, photocraft_engine::BrushSettings)]| brushes.iter().all(|(t, b)| *b == crate::paint_mouse::fresh_brush(*t, b) && b.size != 77.0); + assert!(starting(&app.ui.tool_brushes), "{saved}"); let (app2, _) = app_with_saved(store.lock().unwrap().clone()); assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); - assert!(app2.ui.tool_brushes.is_empty()); + assert!(!app2.ui.tool_brushes.is_empty() && starting(&app2.ui.tool_brushes), "remembered across a restart"); } #[test] @@ -1415,28 +1549,27 @@ fn show_tooltips_preferences_turn_tooltips_off() { } #[test] -fn enable_animations_off_stops_fades_and_scroll_glides_and_is_remembered() { - let on = egui::Style::default().animation_time; - let both = |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time); - // Saved off: the first frame after a restart applies it to both appearance styles. - let (mut app, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string())); - let ctx = egui::Context::default(); - tick(&mut app, &ctx); - assert_eq!(both(&ctx), [0.0, 0.0]); - assert_eq!(ctx.global_style().scroll_animation.duration.max, 0.0); - // A theme change rebuilds the style; the next frame turns animations off again. - app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight); - tick(&mut app, &ctx); - assert_eq!(both(&ctx), [0.0, 0.0]); - app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).unwrap(); - tick(&mut app, &ctx); - assert_eq!(both(&ctx), [on, on]); - assert!(on > 0.0 && ctx.global_style().scroll_animation.duration.max > 0.0); - // A hand-edited value of the wrong type doesn't crash: the defaults (animations on) stay. - let (mut bad, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": "nope"}}).to_string())); - let ctx2 = egui::Context::default(); - tick(&mut bad, &ctx2); - assert_eq!(both(&ctx2), [on, on]); +fn a_saved_enable_animations_loads_and_leaves_egui_animating() { + // #9479: Interface › Enable animations is gone (Photoshop has none). A file saved with it + // off still loads whole, and egui's fades and scroll glides keep their defaults. + let style = egui::Style::default(); + let both = + |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| (ctx.style_of(t).animation_time, ctx.style_of(t).scroll_animation.duration.max)); + let expected = (style.animation_time, style.scroll_animation.duration.max); + assert!(expected.0 > 0.0 && expected.1 > 0.0); + for value in [json!(false), json!(true), json!("nope")] { + let saved = json!({"version": 2, "interface": {"enableAnimations": value, "showTooltips": false}}); + let (mut app, _) = app_with_saved(Some(saved.to_string())); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + assert!(app.ui.status.is_empty(), "{value}: {}", app.ui.status); + assert!(!app.session.prefs().interface.show_tooltips, "{value}: the rest of the file loads"); + assert_eq!(both(&ctx), [expected, expected], "{value}"); + app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight); + tick(&mut app, &ctx); + assert_eq!(both(&ctx), [expected, expected], "{value}: after a theme change"); + assert!(app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).is_err()); + } } #[test] @@ -2022,6 +2155,203 @@ fn edit_dialogs_open() { let d = crate::menus::invoke(&mut app, &ctx, "edit.presets.presetManager", json!({})).unwrap()["dialog"].as_u64().unwrap(); assert!(crate::dialogs::confirm(&mut app, d).is_ok()); } + +#[test] +fn tool_colors_first_saves_keep_other_instances_separate_edits() { + for initial in + [None, Some("{}".into()), Some(json!({"toolColors": null}).to_string()), Some(json!({"toolColors": crate::tool_memory::default_colors()}).to_string())] + { + for background_first in [false, true] { + let store = Arc::new(Mutex::new(initial.clone())); + let (mut foreground, mut background) = (app_on(&store, Arc::default()), app_on(&store, Arc::default())); + let fg = [0.25, 0.5, 0.75, 1.0]; + let bg = [0.75, 0.5, 0.25, 1.0]; + foreground.run("tools.setColors", json!({"foreground": fg})).unwrap(); + background.run("tools.setColors", json!({"background": bg})).unwrap(); + let (first, second) = if background_first { (&mut background, &mut foreground) } else { (&mut foreground, &mut background) }; + crate::tool_memory::save_on_exit(first).unwrap(); + crate::tool_memory::save_on_exit(second).unwrap(); + // Further saves must still keep the other window's independent field. + save_preferences(first).unwrap(); + save_preferences(second).unwrap(); + let restored = app_on(&store, Arc::default()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + assert_eq!(restored.session.tools.other_colors, photocraft_engine::ToolState::MASK_COLORS); + } + } +} + +#[test] +fn tool_colors_survive_restart_before_the_first_frame() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let fg = [0.2, 0.4, 0.6, 0.8]; + let bg = [0.9, 0.7, 0.3, 1.0]; + app.run("tools.setColors", json!({"foreground": fg, "background": bg})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + let rev = app.session.prefs.rev(); + crate::tool_memory::persist(&mut app, &ctx); + assert_eq!(app.session.prefs.rev(), rev, "unchanged colours do not rewrite preferences"); +} + +#[test] +fn tool_colors_survive_restart_after_rejecting_an_invalid_preset() { + for bad_channel in [json!(1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let (mut app, store) = app_with_store(); + let color = [0.2, 0.4, 0.6, 0.8]; + app.run("tools.setColors", json!({"foreground": color})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + app.run( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "move", "options": {"foreground": [bad_channel, 0.4, 0.6, 1.0]} + }), + ) + .unwrap(); + assert!(app.run("tool.presets.select", json!({"preset": "Invalid"})).is_err()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["toolColors"]["foreground"], json!(color)); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, color); + } +} + +#[test] +fn tool_colors_preserve_finite_hdr_components() { + let (mut app, store) = app_with_store(); + let fg = [1.5, -0.1, 0.6, 1.0]; + app.run("tools.setColors", json!({"foreground": fg})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); +} + +#[test] +fn tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + let cyan = [1.0, 0.0, 0.0, 0.0]; + let magenta = [0.0, 1.0, 0.0, 0.0]; + let fg = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + let bg = photocraft_color::convert::cmyk_to_rgb(magenta).map(|c| c.clamp(0.0, 1.0)); + app.run( + "tools.setColors", + json!({ + "foreground": fg, "foregroundCmyk": cyan, "background": bg, "backgroundCmyk": magenta + }), + ) + .unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + app.session.tools.target_mask(true); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(fg), Some(cyan)); + assert_eq!(restored.session.tools.picked_cmyk(bg), Some(magenta)); + for (target, expected) in [("foreground", magenta), ("background", cyan)] { + crate::color_picker_ui::open(&mut restored, target); + assert_eq!(restored.ui.dialogs.last().unwrap().fields["__cmyk"], json!(expected)); + } + restored.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + restored.run("image.mode.cmyk", json!({})).unwrap(); + for (command, expected) in [("edit.fillForeground", magenta), ("edit.fillBackground", cyan)] { + restored.run(command, json!({})).unwrap(); + let st = restored.session.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let pixel = surface.read_region(photocraft_geom::Rect::new(0, 0, 1, 1)); + assert!(pixel.iter().take(4).zip(expected).all(|(a, b)| (a - b).abs() < 0.01), "{command}: {pixel:?}"); + } +} + +#[test] +fn tool_colors_save_changed_cmyk_inks_even_when_rgba_is_unchanged() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let rgb = [0.2, 0.4, 0.6]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": [1, 0, 0, 0]})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let rev = app.session.prefs.rev(); + let inks = [0.1, 0.2, 0.3, 0.4]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": inks})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + assert!(app.session.prefs.rev() > rev); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(rgb), Some(inks)); +} + +#[test] +fn tool_colors_restore_only_bounded_valid_cmyk_picks() { + let limit = photocraft_engine::ToolState::CMYK_PICKS; + let mut picks: Vec = (0..limit + 3).map(|i| json!([[i as f32 / 16.0, 0.2, 0.3], [0.1, 0.2, 0.3, 0.4]])).collect(); + picks.extend([json!("bad"), json!([[0, 0, 0], [1, 0, 0]]), json!([[0, 0, 0], [2, 0, 0, 0]]), json!([[1e100, 0, 0], [1, 0, 0, 0]])]); + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": [0.2, 0.4, 0.6, 1.0], "cmykPicks": picks} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.session.tools.cmyk_picks.len(), limit); + assert_eq!(app.session.tools.picked_cmyk([0.0, 0.2, 0.3]), None); + assert_eq!(app.session.tools.picked_cmyk([(limit + 2) as f32 / 16.0, 0.2, 0.3]), Some([0.1, 0.2, 0.3, 0.4])); +} + +#[test] +fn tool_colors_flush_swaps_and_defaults_on_exit_without_another_frame() { + let (mut app, store) = app_with_store(); + app.run("tools.setColors", json!({"foreground": "#336699", "background": "#cc8844"})).unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, restored_store) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, app.session.tools.foreground); + assert_eq!(restored.session.tools.background, app.session.tools.background); + restored.run("tools.defaultColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut restored).unwrap(); + let (defaults, _) = app_with_saved(restored_store.lock().unwrap().clone()); + assert_eq!(defaults.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(defaults.session.tools.background, [1.0; 4]); +} + +#[test] +fn tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + app.session.tools.background = [0.9, 0.7, 0.3, 1.0]; + let pixels = [app.session.tools.foreground, app.session.tools.background]; + app.session.tools.target_mask(true); + app.session.tools.foreground = [0.5, 0.5, 0.5, 1.0]; + let masks = [app.session.tools.foreground, app.session.tools.background]; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], pixels); + restored.session.tools.target_mask(true); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], masks); +} + +#[test] +fn tool_colors_ignore_invalid_swatches_without_losing_valid_preferences() { + for bad in [json!(null), json!("red"), json!([1, 0, 0]), json!([1e100, 0, 0, 1]), json!([0, "bad", 0, 1])] { + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": bad, "background": [0.2, 0.4, 0.6, 1.0]}, + "toolOptions": {"feather": 12.0} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + let (app, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.0}}).to_string())); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [1.0; 4]); +} + #[test] fn reset_section_on_type_keeps_the_favourite_fonts() { // The favourites are the font menu's library, not a Type setting. diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 719c760f35..02c18313f5 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -758,6 +758,27 @@ fn memory_prompt_states_unknown_capacity_and_the_eight_gib_warning() { assert!(app.ui.dialogs.is_empty()); } +/// The prompt warns when opening the file would leave less than 1 GiB of the measured free +/// memory, and stays quiet when there is room to spare. +#[test] +fn memory_prompt_warns_when_little_memory_would_be_left() { + let gb = 1u64 << 30; + let mut app = app(); + let available = 3 * gb + gb / 2; + let id = store_preflight(&mut app, "tight.psd", 3 * gb, Some(available)); + let tab = crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(3), name: "tight.psd".into(), path: None, slot: None, order: None }; + crate::jobs_ui::finish_open_tab( + &mut app, + &tab, + &json!({"pendingPreflight": id, "estimated": 3 * gb, "availableRam": available, "capacityUnknown": false, "uncertainCapacityWarning": false}), + ) + .unwrap(); + let message = app.ui.dialogs[0].fields["message"].as_str().unwrap(); + assert!(message.contains("Little memory would be left") && message.contains("may run out of memory"), "{message}"); + let dialog = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); +} + /// #10897: reopening an ordinary file whose high-memory prompt is already waiting shows that /// prompt instead of queueing a second one and a second open; a template still opens again as /// another Untitled, like one that is still loading. diff --git a/crates/ui-egui/src/render_path.rs b/crates/ui-egui/src/render_path.rs index fe42e50667..9aae3cdb6d 100644 --- a/crates/ui-egui/src/render_path.rs +++ b/crates/ui-egui/src/render_path.rs @@ -32,6 +32,8 @@ pub enum CpuReason { CanvasTooLarge { bytes: u64, budget: u64 }, /// The driver couldn't build the compositor's pipelines (such as DX12 FXC X3507, #8733). CompositorBuild(String), + /// The GPU compositor is being built in the background. + CompositorBuilding, /// The device was lost (the fault's text). DeviceLost(String), /// An uncaptured GPU error (the fault's text). @@ -61,6 +63,7 @@ impl CpuReason { CpuReason::OverBudget { .. } => "overBudget", CpuReason::CanvasTooLarge { .. } => "canvasTooLarge", CpuReason::CompositorBuild(_) => "compositorBuild", + CpuReason::CompositorBuilding => "compositorBuilding", CpuReason::DeviceLost(_) => "deviceLost", CpuReason::GpuError(_) => "gpuError", CpuReason::Unsupported(_) => "unsupported", @@ -87,6 +90,7 @@ impl fmt::Display for CpuReason { CpuReason::CanvasTooLarge { bytes, budget } => { write!(f, "the canvas needs {} of GPU textures, budget {}; drawing it on the CPU canvas", fmt_bytes(*bytes), fmt_bytes(*budget)) } + CpuReason::CompositorBuilding => f.write_str(tl!("The GPU compositor is still being prepared")), CpuReason::CompositorBuild(e) => write!(f, "the GPU compositor couldn't be built: {e}"), CpuReason::DeviceLost(e) | CpuReason::GpuError(e) | CpuReason::Unsupported(e) => f.write_str(e), CpuReason::AdjustPreview(why) => write!(f, "the adjustment preview uses the CPU proxy: {why}"), @@ -137,6 +141,9 @@ pub struct RenderPath { } impl RenderPath { + pub(crate) fn compositor_building_docs(&self) -> Vec { + self.docs.iter().filter_map(|(&doc, reason)| matches!(reason, CpuReason::CompositorBuilding).then_some(doc)).collect() + } /// Why document `doc` is drawn on the CPU now, or None on the GPU: the window's reason, else /// the view's, else the canvas's, else the adjustment preview's. A software adapter still runs /// the GPU canvas, so a document that left even that names its own reason first. diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index f23a440d4d..f59464d6d1 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -298,6 +298,56 @@ fn save_settings_start_at_the_opened_files_encoding() { assert!(app.save_settings().webp_lossless); } +/// The names of the active document's History panel snapshots. +fn snapshots(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().snapshots.iter().map(|s| s.name.clone()).collect() +} + +/// History Options › Automatically Create New Snapshot When Saving (#2966) through the desktop +/// save path: a save adds one snapshot, saving again unchanged adds none, Save a Copy adds none. +#[test] +fn a_save_adds_one_snapshot_and_a_copy_none() { + let mut app = app(); + app.run("history.options", json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + let before = snapshots(&app).len(); + app.save_as(Some("saved.psd".into())).unwrap(); + assert_eq!(snapshots(&app).len(), before + 1, "the save adds a snapshot"); + let st = app.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.snapshots.last().unwrap().doc, &st.doc), "of the document as saved"); + app.save_as(Some("saved.psd".into())).unwrap(); + assert_eq!(snapshots(&app).len(), before + 1, "saving an unchanged document adds no duplicate"); + + app.run("layer.new.layer", json!({})).unwrap(); + app.write_document("copy.psd".into(), &ExportSettings::default(), true).unwrap(); + assert_eq!(snapshots(&app).len(), before + 1, "Save a Copy adds none"); + app.save_as(Some("saved.psd".into())).unwrap(); + assert_eq!(snapshots(&app).len(), before + 2); +} + +/// A background save that finishes while another document is active snapshots the document it +/// saved, not the active one. +#[test] +fn a_background_save_snapshots_its_own_document() { + let mut app = app(); + app.run("history.options", json!({"automaticallyCreateNewSnapshotWhenSaving": true})).unwrap(); + let (saved_doc, revision) = { + let st = app.session.active().unwrap(); + (st.doc.id, st.revision) + }; + let saved_snaps = snapshots(&app).len(); + app.run("file.new", json!({"width": 4, "height": 4, "name": "Untitled-2"})).unwrap(); + let active = app.active_doc_id().unwrap(); + assert_ne!(active, saved_doc); + let active_snaps = snapshots(&app).len(); + // What jobs_ui does when the save job of `saved_doc` ends. + app.saved(saved_doc, revision, "background.psd", &[], false); + assert_eq!(app.active_doc_id(), Ok(active), "the active document stays active"); + assert_eq!(snapshots(&app).len(), active_snaps, "the active document gets no snapshot"); + let st = app.session.documents().iter().find(|st| st.doc.id == saved_doc).unwrap(); + assert_eq!(st.snapshots.len(), saved_snaps + 1, "the saved document gets one"); + assert_eq!(st.path.as_deref(), Some("background.psd")); +} + /// A browser download is only requested: the document takes the name but keeps its unsaved /// changes, so the leave-page guard stays armed (#4962). A confirmed write still saves it. #[test] diff --git a/crates/ui-egui/src/selection_move_step_tests.rs b/crates/ui-egui/src/selection_move_step_tests.rs new file mode 100644 index 0000000000..5c3ec336fd --- /dev/null +++ b/crates/ui-egui/src/selection_move_step_tests.rs @@ -0,0 +1,99 @@ +//! #11390: dragging the selection outline with a marquee or lasso records "Move Selection", as the +//! reference application does; Select > Transform Selection keeps "Transform Selection". + +use egui::Modifiers; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// A 400 x 300 document with 100,100 to 300,300 selected and `tool` active. +fn app_with_selection(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool = tool; + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + app +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +} + +fn bounds(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()) +} + +fn last_step(app: &PhotocraftApp) -> String { + app.session.active().unwrap().history.entries().last().cloned().unwrap() +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn check_drag_records_move_selection(tool: Tool) { + let mut app = app_with_selection(tool); + let before = past(&app); + drag(&mut app, [200.0, 200.0], [300.0, 250.0]); + assert_eq!(bounds(&app), Some(Rect::new(200, 150, 400, 350)), "moved by +100,+50"); + assert_eq!(past(&app), before + 1, "one step"); + assert_eq!(last_step(&app), "Move Selection"); +} + +#[test] +fn rectangular_marquee_drag_inside_records_move_selection() { + check_drag_records_move_selection(Tool::RectMarquee); +} + +#[test] +fn elliptical_marquee_drag_inside_records_move_selection() { + check_drag_records_move_selection(Tool::EllipseMarquee); +} + +#[test] +fn lasso_drag_inside_records_move_selection() { + check_drag_records_move_selection(Tool::Lasso); +} + +#[test] +fn undo_after_the_drag_restores_the_outline() { + let mut app = app_with_selection(Tool::RectMarquee); + drag(&mut app, [200.0, 200.0], [300.0, 250.0]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&app), Some(Rect::new(100, 100, 300, 300))); +} + +#[test] +fn transform_selection_keeps_its_step_name() { + let mut app = app_with_selection(Tool::RectMarquee); + app.run("select.transformSelection", json!({"dx": 100, "dy": 50})).unwrap(); + assert_eq!(bounds(&app), Some(Rect::new(200, 150, 400, 350))); + assert_eq!(last_step(&app), "Transform Selection"); +} + +#[test] +fn a_click_without_moving_still_deselects() { + let mut app = app_with_selection(Tool::RectMarquee); + drag(&mut app, [200.0, 200.0], [200.0, 200.0]); + assert_eq!(bounds(&app), None); + assert_eq!(last_step(&app), "Deselect"); +} + +#[test] +fn an_unknown_step_name_is_refused() { + let mut app = app_with_selection(Tool::RectMarquee); + let before = past(&app); + for name in [json!("Delete Layer"), json!(""), json!(3)] { + assert!(app.run("select.transformSelection", json!({"dx": 10, "dy": 0, "stepName": name})).is_err()); + } + assert_eq!(past(&app), before); + assert_eq!(bounds(&app), Some(Rect::new(100, 100, 300, 300))); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 7f05fdc1de..e869f3f7e0 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -8,15 +8,17 @@ pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer the TIFF, Targa, DDS or JPEG /// The format answers a save takes instead of asking: the Targa bits per pixel (`"tgaBits"`), /// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`), JPEG Options (`"quality"` 0–12, -/// `"jpegFormat"`) and TIFF Options (`"tiffCompression"`, `"tiffTransparency"`, `"tiffPyramid"`, -/// `"tiffLayers"`). `None` asks in Save As and takes the default in automation saves, which never -/// prompt. +/// `"jpegFormat"`), TIFF Options (`"tiffCompression"`, `"tiffTransparency"`, `"tiffPyramid"`, +/// `"tiffLayers"`) and a PSD/PSB's Maximize Compatibility (`"maximizeCompatibility"`). `None` +/// asks in Save As and takes the default in automation saves, which never prompt (Maximize +/// Compatibility: Preferences › File Handling decides). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct FormatChoices { pub tga_bits: Option, pub dds: Option, pub jpeg: Option, pub tiff: Option, + pub maximize_compatibility: Option, } impl FormatChoices { @@ -27,6 +29,7 @@ impl FormatChoices { dds: dds_options_ui::options_param(params, cmd)?, jpeg: jpeg_options_ui::choice_param(params, cmd)?, tiff: tiff_options_ui::choice_param(params, cmd)?, + maximize_compatibility: maximize_param(params, cmd)?, }) } @@ -38,12 +41,18 @@ impl FormatChoices { dds: dds_options_ui::options_param(params, cmd)?, jpeg: jpeg_options_ui::recorded_choice(params, cmd)?, tiff: tiff_options_ui::choice_param(params, cmd)?, + maximize_compatibility: maximize_param(params, cmd)?, }) } /// The export settings with these answers applied. pub fn apply(self, settings: ExportSettings) -> ExportSettings { - let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), dds: self.dds.unwrap_or(settings.dds), ..settings }; + let settings = ExportSettings { + tga_bits: self.tga_bits.or(settings.tga_bits), + dds: self.dds.unwrap_or(settings.dds), + psd_maximize_compatibility: self.maximize_compatibility.unwrap_or(settings.psd_maximize_compatibility), + ..settings + }; let settings = match self.tiff { Some(t) => t.apply(settings), None => settings, @@ -68,9 +77,17 @@ impl FormatChoices { if let Some(t) = self.tiff { t.record(step); } + if let Some(maximize) = self.maximize_compatibility { + step["maximizeCompatibility"] = maximize.into(); + } } } +/// `"maximizeCompatibility"` in command or control call `cmd`'s params, read as the engine reads it. +fn maximize_param(params: &Value, cmd: &str) -> Result, String> { + photocraft_engine::file_cmds::maximize_compatibility_param(params, cmd).map_err(|e| e.to_string()) +} + /// Decode a file: (document, warnings about anything approximated or dropped). pub type ImportFn = Box Result<(Document, Vec), String>>; @@ -79,6 +96,9 @@ pub type ImportFn = Box Re pub struct ExportSettings { /// JPEG quality 1–100 (None = codec default). pub jpeg_quality: Option, + /// JPEG chroma sampling: `true` is 4:2:0, `false` keeps full 4:4:4 colour detail. + /// Export As keeps full chroma above quality 50 so saturated text and graphics stay sharp. + pub jpeg_chroma_subsampling: bool, /// JPEG Options › Format Options: baseline, baseline optimized or progressive. pub jpeg_format: photocraft_codecs::JpegFormat, /// WebP: lossless (VP8L) rather than lossy (VP8 at [`Self::webp_quality`]). @@ -133,6 +153,7 @@ impl Default for ExportSettings { fn default() -> Self { ExportSettings { jpeg_quality: None, + jpeg_chroma_subsampling: true, jpeg_format: photocraft_codecs::JpegFormat::Baseline, webp_lossless: true, webp_quality: None, @@ -163,6 +184,7 @@ impl ExportSettings { if let Some(q) = self.jpeg_quality { opts.encode.jpeg_quality = q; } + opts.encode.jpeg_chroma_subsampling = self.jpeg_chroma_subsampling; opts.encode.jpeg_format = self.jpeg_format; opts.encode.webp_lossless = self.webp_lossless; if let Some(q) = self.webp_quality { @@ -331,6 +353,8 @@ pub struct ExternalVectorSource { pub type OpenExternalSvgFn = Box Result, String>>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; +/// Shared queue of browser canvas drops with the document captured at the drop event. +pub type PlaceInbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). pub type LoadTextFn = Box Option>; @@ -398,6 +422,13 @@ pub type CaptionStyleFn = Box CaptionStyle>; /// Physical right mouse button or stylus barrel button down (Windows; read for brush resize). pub type RightButtonDownFn = Box bool + Send + Sync>; +/// Which end of the pen is in use, `Some(true)` = eraser, `None` = no pen ([`Services::pen_end`]). +pub type PenEndFn = Box Option + Send + Sync>; + +/// Polls the platform's optional WinTab packet queue into the app's stylus feed (Windows), with +/// whether the app window is focused (to re-assert WinTab overlap priority on activation). +pub type TabletPollFn = Box; + /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. #[derive(Default)] @@ -461,7 +492,7 @@ pub struct Services { /// Files delivered asynchronously for a drop onto the active document's canvas (web /// drag-and-drop, routed by [`PhotocraftApp::dropped_on_canvas`]): placed as layers like a /// desktop canvas drop (Place Embedded, each in Free Transform). - pub place_inbox: Option, + pub place_inbox: Option, /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, @@ -475,6 +506,11 @@ pub struct Services { /// the web (see `prefs_ui`). pub load_prefs: Option, pub save_prefs: Option, + /// Load/save the bounded deterministic GPU compositor failure memo (native only). + pub load_gpu_memo: Option, + pub save_gpu_memo: Option, + /// Compiler identity included in GPU memo keys (for example `fxc` or `dxc:`). + pub shader_compiler: Option, /// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`). pub photoshop_shortcuts: Option, pub system_theme: Option, @@ -515,6 +551,11 @@ pub struct Services { pub caption_style: Option, /// Physical right mouse button or pen barrel button currently down. pub right_button_down: Option, + /// Which end of the pen is in use, where the platform reports it outside the stylus feed + /// (Windows: the feed carries no pen samples there). Read for the eraser-end tool switch. + pub pen_end: Option, + /// WinTab packet poll (Windows with Windows Ink disabled; see `photocraft_tablet::wintab`). + pub tablet_poll: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. diff --git a/crates/ui-egui/src/shape_bounds_box_tests.rs b/crates/ui-egui/src/shape_bounds_box_tests.rs new file mode 100644 index 0000000000..9c4d4ff322 --- /dev/null +++ b/crates/ui-egui/src/shape_bounds_box_tests.rs @@ -0,0 +1,487 @@ +//! #11107: the box round several selected shape layers (Path Selection and the shape tools), its +//! handles scaling them, measured against the reference application: two rectangles +//! 100,98–403,301 and 420,318–723,451 scaled by dragging the bottom-right handle by (−100, −60). + +use egui::Modifiers; +use photocraft_doc::LayerContent; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, tool_event}; +use crate::shape_bounds_box::scale_for; +use crate::state::Tool; + +const NONE: Modifiers = Modifiers::NONE; + +/// A 1:1 view of the 1000 × 700 document, turned by `rotation` degrees. +fn view(rotation: f32) -> ViewXform { + ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1000.0, 700.0)), + zoom: 1.0, + center: [500.0, 350.0], + flip: false, + rotation, + aspect: 1.0, + } +} + +fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { + crate::shape_bounds_box::cursor(app, &view(0.0), p) +} + +/// A 1000 × 700 document with the two measured rectangles, both selected. +fn app_with_two_shapes(tool: Tool) -> (PhotocraftApp, u64, u64) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 700, "background": "white"})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [100, 98, 303, 203], "fill": "#e02020"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [420, 318, 303, 133], "fill": "#2050e0"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": a.0})).unwrap(); + app.run("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap(); + app.ui.tool = tool; + (app, a.0, b.0) +} + +fn bounds(app: &PhotocraftApp, id: u64) -> (f64, f64, f64, f64) { + let st = app.session.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { panic!("a shape layer") }; + sh.path.control_bounds().unwrap() +} + +fn near(got: (f64, f64, f64, f64), want: [f64; 4]) { + let got = [got.0, got.1, got.2, got.3]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0), "{got:?} vs {want:?}"); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, NONE); + let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0]; + tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, NONE); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, NONE); +} + +#[test] +fn the_measured_drag_gives_the_reference_bounds_with_every_tool() { + for tool in [Tool::Rectangle, Tool::PathSelection, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line] { + let (mut app, a, b) = app_with_two_shapes(tool); + let steps = app.session.active().unwrap().history.past_len(); + drag(&mut app, [723.0, 451.0], [623.0, 391.0]); + near(bounds(&app, a), [100.0, 98.0, 354.0, 268.0]); + near(bounds(&app, b), [368.0, 282.0, 621.0, 394.0]); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "{tool:?}: one history step"); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Transform Path"), "{tool:?}"); + assert!(app.ui.transform.is_none() && app.ui.shape_box.is_none(), "{tool:?}: no Free Transform session opens"); + // The shapes stay live. + for id in [a, b] { + let LayerContent::Shape(sh) = &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { panic!() }; + assert!(sh.live.is_some(), "{tool:?}: still a live shape"); + } + } +} + +#[test] +fn an_edge_handle_scales_one_way_and_undo_restores_both_shapes() { + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + // The right edge's middle, dragged 100 px left: x scales about the left edge (100). + drag(&mut app, [723.0, 274.5], [623.0, 274.5]); + let s = 523.0 / 623.0; + near(bounds(&app, a), [100.0, 98.0, 100.0 + 303.0 * s, 301.0]); + near(bounds(&app, b), [100.0 + 320.0 * s, 318.0, 100.0 + 623.0 * s, 451.0]); + app.run("edit.undo", json!({})).unwrap(); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); +} + +#[test] +fn a_click_without_movement_changes_nothing() { + let (mut app, ..) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + drag(&mut app, [723.0, 451.0], [723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert!(app.ui.shape_box.is_none()); +} + +#[test] +fn one_selected_shape_gets_the_box_and_other_tools_none() { + // Photoshop shows the box for a single shape too: its corner handle scales that shape. + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + app.run("layer.select", json!({"layer": a})).unwrap(); + assert_eq!(cursor(&app, [403.0, 301.0]), Some(egui::CursorIcon::ResizeNwSe)); + assert!(cursor(&app, [723.0, 451.0]).is_none(), "the other shape's corner is not a handle"); + drag(&mut app, [403.0, 301.0], [303.0, 241.0]); + let s = 1.0 + (-100.0 * 303.0 + -60.0 * 203.0) / (303.0f64 * 303.0 + 203.0 * 203.0); + near(bounds(&app, a), [100.0, 98.0, 100.0 + 303.0 * s, 98.0 + 203.0 * s]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.entries().last().map(String::as_str), Some("Transform Path")); + + // Another tool has none. + let (mut app, ..) = app_with_two_shapes(Tool::Move); + assert!(cursor(&app, [723.0, 451.0]).is_none()); + app.ui.tool = Tool::Brush; + assert!(cursor(&app, [723.0, 451.0]).is_none()); +} + +#[test] +fn the_handles_show_their_scale_cursors() { + use egui::CursorIcon::{ResizeHorizontal, ResizeNeSw, ResizeNwSe, ResizeVertical}; + let (app, ..) = app_with_two_shapes(Tool::PathSelection); + let at = |p: [f64; 2]| cursor(&app, p); + assert_eq!(at([100.0, 98.0]), Some(ResizeNwSe)); + assert_eq!(at([723.0, 451.0]), Some(ResizeNwSe)); + assert_eq!(at([723.0, 98.0]), Some(ResizeNeSw)); + assert_eq!(at([100.0, 451.0]), Some(ResizeNeSw)); + assert_eq!(at([411.5, 98.0]), Some(ResizeVertical)); + assert_eq!(at([723.0, 274.5]), Some(ResizeHorizontal)); + assert_eq!(at([411.5, 274.5]), None, "inside the box the tool's own arrow stays, and outside it nothing rotates"); + assert_eq!(at([760.0, 480.0]), None); +} + +#[test] +fn a_corner_radius_widget_wins_over_the_box() { + // The widget of the second rectangle's bottom-right corner sits inside the corner, not on the + // box's handle: a press on it rounds the corners, no scale starts. + let (mut app, ..) = app_with_two_shapes(Tool::Rectangle); + let widget = [713.0, 441.0]; + tool_event(&mut app, ToolEvent::Down { x: widget[0], y: widget[1], pressure: 1.0 }, NONE); + assert!(app.ui.corner_radius.is_some() && app.ui.shape_box.is_none()); + tool_event(&mut app, ToolEvent::Up { x: widget[0], y: widget[1] }, NONE); + // A press right on the handle scales, not rounds. + tool_event(&mut app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_some() && app.ui.corner_radius.is_none()); +} + +#[test] +fn scale_for_projects_a_corner_drag_onto_the_diagonal() { + let r = [100.0, 98.0, 723.0, 451.0]; + // The measured drag: both ways by the same factor, (−100, −60) on this 623 × 353 box. + let [sx, sy] = scale_for(r, [1, 1], [-100.0, -60.0]); + assert_eq!(sx, sy); + assert!((sx - 0.8372).abs() < 1e-3, "{sx}"); + // The top-left corner pulls the other way. + let [s, _] = scale_for(r, [-1, -1], [100.0, 60.0]); + assert!((s - 0.8372).abs() < 1e-3, "{s}"); + // A drag along the other diagonal changes nothing. + assert_eq!(scale_for(r, [1, 1], [353.0, -623.0]), [1.0, 1.0]); + // Edges scale one way; the box can't be flipped or blown up without bound. + assert_eq!(scale_for(r, [1, 0], [-100.0, 500.0]), [1.0 - 100.0 / 623.0, 1.0]); + assert_eq!(scale_for(r, [0, 1], [50.0, -60.0]), [1.0, 1.0 - 60.0 / 353.0]); + assert_eq!(scale_for(r, [1, 0], [-9000.0, 0.0]), [0.01, 1.0]); + assert_eq!(scale_for(r, [1, 1], [f64::NAN, 0.0]), [1.0, 1.0]); + assert_eq!(scale_for(r, [0, 0], [10.0, 10.0]), [1.0, 1.0]); +} + +#[test] +fn a_rotated_view_turns_the_handle_cursors() { + use egui::CursorIcon::{ResizeHorizontal, ResizeNeSw, ResizeNwSe, ResizeVertical}; + let (app, ..) = app_with_two_shapes(Tool::PathSelection); + let at = |rotation: f32, p: [f64; 2]| crate::shape_bounds_box::cursor(&app, &view(rotation), p); + assert_eq!(at(0.0, [723.0, 451.0]), Some(ResizeNwSe)); + // A quarter turn swaps the diagonals and the edge arrows. + assert_eq!(at(90.0, [723.0, 451.0]), Some(ResizeNeSw)); + assert_eq!(at(90.0, [723.0, 98.0]), Some(ResizeNwSe)); + assert_eq!(at(90.0, [723.0, 274.5]), Some(ResizeVertical)); + assert_eq!(at(90.0, [411.5, 98.0]), Some(ResizeHorizontal)); +} + +#[test] +fn hidden_paths_hide_the_box_and_its_handles_take_no_press() { + // View › Extras off, or View › Show › Target Path off. + for (extras, target_path) in [(false, true), (true, false)] { + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + app.ui.view.extras = extras; + app.ui.view.show.target_path = target_path; + assert!(cursor(&app, [723.0, 451.0]).is_none(), "extras {extras}"); + tool_event(&mut app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_none(), "extras {extras}: no scale drag starts"); + tool_event(&mut app, ToolEvent::Up { x: 723.0, y: 451.0 }, NONE); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + } +} + +/// Press the bottom-right handle and drag it, without releasing. +fn press_and_drag(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + tool_event(app, ToolEvent::Move { x: 623.0, y: 391.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_some()); +} + +fn release_changes_nothing(app: &mut PhotocraftApp, a: u64, b: u64, steps: usize, why: &str) { + tool_event(app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE); + assert!(app.ui.shape_box.is_none(), "{why}"); + near(bounds(app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(app, b), [420.0, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "{why}: no history step"); +} + +#[test] +fn escape_or_a_tool_change_cancels_a_handle_drag() { + // Esc. + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + // Through the shortcut handler, as the key arrives in the app. + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: NONE }], + ..Default::default() + }); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + assert!(app.ui.shape_box.is_none()); + release_changes_nothing(&mut app, a, b, steps, "Esc"); + // The next press works again. + drag(&mut app, [723.0, 451.0], [623.0, 391.0]); + near(bounds(&app, a), [100.0, 98.0, 354.0, 268.0]); + + // Another tool, picked mid-drag. + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + app.ui.tool = Tool::Brush; + app.sync_views(); + assert!(app.ui.shape_box.is_none()); + release_changes_nothing(&mut app, a, b, steps, "tool change"); + + // Even without a frame between the change and the release. + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + app.ui.tool = Tool::Move; + release_changes_nothing(&mut app, a, b, steps, "tool change, same frame"); +} + +#[test] +fn a_document_switch_or_an_undo_cancels_a_handle_drag() { + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + let steps = app.session.active().unwrap().history.past_len(); + press_and_drag(&mut app); + app.run("file.new", json!({"width": 1000, "height": 700, "background": "white"})).unwrap(); + app.sync_views(); + assert!(app.ui.shape_box.is_none()); + let other = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE); + assert_eq!(app.session.active().unwrap().history.past_len(), other, "nothing lands in the other document"); + assert!(app.session.set_active(0)); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // An edit under the drag (Undo here): the release applies nothing to the changed shapes. + let (mut app, ..) = app_with_two_shapes(Tool::PathSelection); + press_and_drag(&mut app); + app.run("edit.undo", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE); + assert!(app.ui.shape_box.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +/// With background jobs on, as in the desktop app, `edit.transform` lands after `run` returns. +/// The scale is still one step named "Transform Path", and the step before it (the second +/// shape's "New Shape Layer") keeps its name. Renaming the top step after `run` named the +/// shape's step "Transform Path" and left the scale as "Free Transform". +#[test] +fn with_background_jobs_the_scale_step_is_named_and_the_one_before_is_kept() { + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + app.background_jobs = true; + let before: Vec = app.session.active().unwrap().history.entries().to_vec(); + drag(&mut app, [723.0, 451.0], [623.0, 391.0]); + let ctx = egui::Context::default(); + for _ in 0..2000 { + crate::jobs_ui::tick(&mut app, &ctx); + if !app.session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!app.session.has_jobs(), "the transform job never landed"); + near(bounds(&app, a), [100.0, 98.0, 354.0, 268.0]); + near(bounds(&app, b), [368.0, 282.0, 621.0, 394.0]); + let after: Vec = app.session.active().unwrap().history.entries().to_vec(); + assert_eq!(after.len(), before.len() + 1, "one history step: {after:?}"); + assert_eq!(after[..before.len()], before[..], "the earlier steps keep their names"); + assert_eq!(after.last().map(String::as_str), Some("Transform Path")); +} + +/// A switch to another tool during a handle drag ends the drag, also when the new tool shows the +/// box too; the release scales nothing and is still the box's (it doesn't reach the new tool). +#[test] +fn a_tool_switch_during_a_drag_ends_it_and_takes_its_release() { + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + let (ba, bb) = (bounds(&app, a), bounds(&app, b)); + tool_event(&mut app, ToolEvent::Down { x: 723.0, y: 451.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: 623.0, y: 391.0, pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_some()); + app.ui.tool = Tool::PathSelection; + assert!(crate::shape_bounds_box::pointer(&mut app, ToolEvent::Up { x: 623.0, y: 391.0 }, NONE), "the release is the box's"); + assert!(app.ui.shape_box.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!((bounds(&app, a), bounds(&app, b)), (ba, bb)); +} + +/// #11473: a side scales from anywhere along its line, not only from its middle handle, and from +/// just outside it; inside the box the press stays the tool's. On the box 100,98–723,451. +#[test] +fn a_side_is_grabbed_anywhere_along_its_line() { + use egui::CursorIcon::{ResizeHorizontal, ResizeNwSe, ResizeVertical}; + let (app, ..) = app_with_two_shapes(Tool::Rectangle); + let at = |p: [f64; 2]| cursor(&app, p); + // On the left line, far from its middle (274.5) and the corners. + assert_eq!(at([100.0, 150.0]), Some(ResizeHorizontal)); + assert_eq!(at([100.0, 400.0]), Some(ResizeHorizontal)); + assert_eq!(at([723.0, 130.0]), Some(ResizeHorizontal)); + assert_eq!(at([200.0, 98.0]), Some(ResizeVertical)); + assert_eq!(at([650.0, 451.0]), Some(ResizeVertical)); + // Up to 5 screen points outside the line: less than a handle, the rest stays the tool's. + assert_eq!(at([95.0, 150.0]), Some(ResizeHorizontal)); + assert_eq!(at([650.0, 456.0]), Some(ResizeVertical)); + assert_eq!(at([94.0, 150.0]), None); + // Just inside, the Rectangle tool draws and Path Selection selects. + assert_eq!(at([101.0, 150.0]), None); + assert_eq!(at([650.0, 450.0]), None); + // A corner keeps its press where the two sides' bands meet it. + assert_eq!(at([96.0, 95.0]), Some(ResizeNwSe)); + assert_eq!(at([103.0, 101.0]), Some(ResizeNwSe)); + + // A drag from there scales that side about the opposite one. + let (mut app, a, b) = app_with_two_shapes(Tool::Rectangle); + let steps = app.session.active().unwrap().history.past_len(); + drag(&mut app, [100.0, 150.0], [150.0, 160.0]); + assert!(app.ui.shape_box.is_none() && app.ui.corner_radius.is_none()); + let s = 1.0 - 50.0 / 623.0; + near(bounds(&app, a), [150.0, 98.0, 723.0 - 320.0 * s, 301.0]); + near(bounds(&app, b), [723.0 - 303.0 * s, 318.0, 723.0, 451.0]); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + // The bottom side, pressed just outside its line, scales y about the top. + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + drag(&mut app, [300.0, 455.0], [310.0, 405.0]); + let s = 1.0 - 50.0 / 353.0; + near(bounds(&app, a), [100.0, 98.0, 403.0, 98.0 + 203.0 * s]); + near(bounds(&app, b), [420.0, 98.0 + 220.0 * s, 723.0, 98.0 + 353.0 * s]); +} + +/// The side band is screen sized: at 200 % it reaches half as far into the document. +#[test] +fn the_side_band_keeps_its_screen_size_when_zoomed() { + let (mut app, ..) = app_with_two_shapes(Tool::PathSelection); + app.sync_views(); + app.ui.views[0].zoom = 2.0; + assert_eq!(cursor(&app, [97.5, 150.0]), Some(egui::CursorIcon::ResizeHorizontal)); + assert_eq!(cursor(&app, [97.0, 150.0]), None); +} + +#[test] +fn edge_hit_picks_the_nearest_side_within_its_band() { + use crate::shape_bounds_box::edge_hit; + let r = [0.0, 0.0, 100.0, 50.0]; + assert_eq!(edge_hit(r, [0.0, 20.0], 11.0, 1e-3), Some([-1, 0])); + assert_eq!(edge_hit(r, [111.0, 20.0], 11.0, 1e-3), Some([1, 0])); + assert_eq!(edge_hit(r, [40.0, -5.0], 11.0, 1e-3), Some([0, -1])); + assert_eq!(edge_hit(r, [40.0, 50.0], 11.0, 1e-3), Some([0, 1])); + // Inside, only rounding off the line. + assert_eq!(edge_hit(r, [0.0005, 20.0], 11.0, 1e-3), Some([-1, 0])); + assert_eq!(edge_hit(r, [1.0, 20.0], 11.0, 1e-3), None); + // A side is a segment: past its end, the distance runs to its end point. + assert_eq!(edge_hit(r, [-8.0, -8.0], 11.0, 1e-3), None); + assert_eq!(edge_hit(r, [-5.0, 60.0], 11.0, 1e-3), None); + assert_eq!(edge_hit(r, [-5.0, 55.0], 11.0, 1e-3).map(|h| h[0] == -1 || h[1] == 1), Some(true)); + // Non-finite points hit nothing. + assert_eq!(edge_hit(r, [f64::NAN, 20.0], 11.0, 1e-3), None); + assert_eq!(edge_hit(r, [f64::NAN, 0.0], 11.0, 1e-3), None); + assert_eq!(edge_hit(r, [f64::INFINITY, 20.0], 11.0, 1e-3), None); +} + +/// The two measured rectangles selected, and an unselected one starting 3 px right of their box +/// (726,200–876,300). Returns the app, the selected two and the neighbour. +fn app_with_a_neighbour(tool: Tool) -> (PhotocraftApp, u64, u64, u64) { + let (mut app, a, b) = app_with_two_shapes(tool); + app.run("shape.create", json!({"kind": "rect", "rect": [726, 200, 150, 100], "fill": "#20a040"})).unwrap(); + let n = app.session.active().unwrap().active_layer.unwrap().0; + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + (app, a, b, n) +} + +/// #11473 review: the side band takes no press from the tool next to the box. Outside the band a +/// press is the tool's; inside it, a press on a shape the box doesn't hold is too: Path Selection +/// selects that shape, the Rectangle tool draws, neither scales the selection. +#[test] +fn a_press_on_a_neighbouring_shape_or_past_the_band_stays_the_tools() { + let (app, ..) = app_with_a_neighbour(Tool::PathSelection); + for p in [[726.5, 250.0], [729.0, 250.0], [731.0, 120.0]] { + assert_eq!(cursor(&app, p), None, "{p:?}"); + } + // The band still works where there is no other shape. + assert_eq!(cursor(&app, [726.5, 120.0]), Some(egui::CursorIcon::ResizeHorizontal)); + // A neighbour with no fill is picked by its outline, within Path Selection's tolerance. + let (mut app, a, b) = app_with_two_shapes(Tool::PathSelection); + app.run("shape.create", json!({"kind": "rect", "rect": [726, 200, 150, 100], "fill": null, "stroke": {"width": 2}})).unwrap(); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + for p in [[724.0, 250.0], [727.5, 250.0]] { + assert_eq!(cursor(&app, p), None, "{p:?}"); + } + + // Path Selection: a click on the neighbour, inside the band and past it, selects it. + for p in [[726.5, 250.0], [729.0, 250.0]] { + let (mut app, a, b, n) = app_with_a_neighbour(Tool::PathSelection); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_none(), "{p:?}: no scale starts"); + tool_event(&mut app, ToolEvent::Up { x: p[0], y: p[1] }, NONE); + let st = app.session.active().unwrap(); + assert_eq!(st.selected_layers(), vec![photocraft_doc::LayerId(n)], "{p:?}: the neighbour is selected"); + assert_eq!(st.history.past_len(), steps, "{p:?}: nothing scaled"); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + } + + // The Rectangle tool: a drag from the neighbour, or from past the band, draws a new shape. + for p in [[726.5, 250.0], [731.0, 120.0]] { + let (mut app, a, b, _) = app_with_a_neighbour(Tool::Rectangle); + let layers = app.session.active().unwrap().doc.walk().len(); + tool_event(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, NONE); + assert!(app.ui.shape_box.is_none(), "{p:?}: no scale starts"); + tool_event(&mut app, ToolEvent::Move { x: p[0] + 40.0, y: p[1] + 30.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: p[0] + 60.0, y: p[1] + 40.0 }, NONE); + assert_eq!(app.session.active().unwrap().doc.walk().len(), layers + 1, "{p:?}: a new shape is drawn"); + near(bounds(&app, a), [100.0, 98.0, 403.0, 301.0]); + near(bounds(&app, b), [420.0, 318.0, 723.0, 451.0]); + } +} + +/// #11473 review: the pick under the side band skips a shape's outline test where the point is +/// out of reach of it, and still finds every shape it found before: an outline within the pick +/// tolerance just past the shape's box, and a fill's pixels past the path (an outside stroke). +#[test] +fn the_pick_still_finds_shapes_just_past_their_path_bounds() { + use crate::vector_ui::pick_shape_at; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 700, "background": "white"})).unwrap(); + // A stroke-only ellipse 200,200–400,300: picked by its outline alone. + app.run("shape.create", json!({"kind": "ellipse", "rect": [200, 200, 200, 100], "fill": null, "stroke": {"width": 2}})).unwrap(); + let e = app.session.active().unwrap().active_layer.unwrap(); + // A filled rectangle 500,400–600,500 with a 20 px stroke outside its path. + app.run("shape.create", json!({"kind": "rect", "rect": [500, 400, 100, 100], "fill": "#20a040", "stroke": {"width": 20, "align": "outside"}})).unwrap(); + let r = app.session.active().unwrap().active_layer.unwrap(); + let doc = &app.session.active().unwrap().doc; + let pick = |p: [f64; 2], tol: f64| pick_shape_at(doc, p, tol); + // Within the tolerance of the ellipse's extreme anchors, outside its box. + assert_eq!(pick([405.5, 250.0], 6.0), Some(e)); + assert_eq!(pick([300.0, 194.5], 6.0), Some(e)); + assert_eq!(pick([406.5, 250.0], 6.0), None); + assert_eq!(pick([300.0, 193.5], 6.0), None); + // Within it of a curve, off the anchors: the ellipse's outline at 45°. + let (cx, cy) = (300.0 + 100.0 * std::f64::consts::FRAC_1_SQRT_2, 250.0 + 50.0 * std::f64::consts::FRAC_1_SQRT_2); + assert_eq!(pick([cx + 4.0, cy], 6.0), Some(e)); + // The stroke's pixels up to 20 px past the rectangle's path, far past the tolerance. + assert_eq!(pick([612.0, 450.0], 1.0), Some(r)); + assert_eq!(pick([550.0, 381.5], 1.0), Some(r)); + assert_eq!(pick([625.0, 450.0], 1.0), None); + assert_eq!(pick([550.0, 378.5], 1.0), None); +} diff --git a/crates/ui-egui/src/single_line_marquee_move_tests.rs b/crates/ui-egui/src/single_line_marquee_move_tests.rs new file mode 100644 index 0000000000..1f6037cc82 --- /dev/null +++ b/crates/ui-egui/src/single_line_marquee_move_tests.rs @@ -0,0 +1,376 @@ +//! Single Row / Single Column Marquee drags through the real canvas (#11392): a drag that starts +//! inside the selection moves it, as the other marquees' does; one that starts outside, a click +//! inside, and a ⇧ / ⌥ press still select the line under the release. + +use egui::Modifiers; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// An 800 × 600 document with snapping off, so the drags land where they are sent. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.ui.extras.snap = false; + app +} + +/// A drag with `tool` from `a` to `b`, as the canvas sends it. +fn drag(app: &mut PhotocraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], m: Modifiers) { + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m); + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: b[0], y: b[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: b[0], y: b[1] }, m); +} + +fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2], m: Modifiers) { + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, m); +} + +fn bounds(app: &PhotocraftApp) -> Rect { + app.session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds()) +} + +fn selected(app: &PhotocraftApp, x: i32, y: i32) -> bool { + app.session.active().unwrap().doc.selection.as_ref().is_some_and(|s| s.sample_channel(x, y, 0) > 0.5) +} + +fn step(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.undo_label().map(str::to_string) +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +/// The active layer's pixel at `(x, y)`. +fn pixel(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) +} + +/// Rows 150 and 250 (New, then ⇧ adds). +fn two_rows(app: &mut PhotocraftApp) { + click(app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + click(app, Tool::SingleRowMarquee, [200.0, 150.5], Modifiers::SHIFT); + assert_eq!(bounds(app), Rect::new(0, 150, 800, 251)); +} + +/// Columns 200 and 300. +fn two_columns(app: &mut PhotocraftApp) { + click(app, Tool::SingleColumnMarquee, [300.5, 250.0], Modifiers::NONE); + click(app, Tool::SingleColumnMarquee, [200.5, 150.0], Modifiers::SHIFT); + assert_eq!(bounds(app), Rect::new(200, 0, 301, 600)); +} + +/// What the Rectangular Marquee's move of the same selection gives: where the part moved past the +/// canvas goes, and the History step's name. +fn rect_marquee_move(select: impl Fn(&mut PhotocraftApp), a: [f64; 2], b: [f64; 2]) -> (Rect, Option) { + let mut app = app(); + select(&mut app); + drag(&mut app, Tool::RectMarquee, a, b, Modifiers::NONE); + (bounds(&app), step(&app)) +} + +#[test] +fn single_row_drag_from_inside_moves_the_selection() { + let mut app = app(); + two_rows(&mut app); + let before = steps(&app); + drag(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], [400.0, 320.5], Modifiers::NONE); + assert!(selected(&app, 450, 220) && selected(&app, 450, 320), "both rows moved by +100,+70"); + assert!(!selected(&app, 450, 150) && !selected(&app, 450, 250), "the old rows are gone"); + assert!(!selected(&app, 50, 220), "moved right by 100"); + assert_eq!(steps(&app), before + 1, "one step"); + assert_eq!((bounds(&app), step(&app)), rect_marquee_move(two_rows, [300.0, 250.5], [400.0, 320.5]), "as the Rectangular Marquee moves it"); +} + +#[test] +fn single_column_drag_from_inside_moves_the_selection() { + let mut app = app(); + two_columns(&mut app); + let before = steps(&app); + drag(&mut app, Tool::SingleColumnMarquee, [300.5, 250.0], [400.5, 320.0], Modifiers::NONE); + assert!(selected(&app, 300, 400) && selected(&app, 400, 400), "both columns moved by +100,+70"); + assert!(!selected(&app, 200, 400), "the old column 200 is gone"); + assert!(!selected(&app, 300, 30), "moved down by 70"); + assert_eq!(steps(&app), before + 1, "one step"); + assert_eq!((bounds(&app), step(&app)), rect_marquee_move(two_columns, [300.5, 250.0], [400.5, 320.0]), "as the Rectangular Marquee moves it"); +} + +#[test] +fn drag_from_outside_selects_the_line_at_the_release() { + let mut app = app(); + two_rows(&mut app); + drag(&mut app, Tool::SingleRowMarquee, [100.0, 100.5], [100.0, 200.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 200, 800, 201)); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + drag(&mut app, Tool::SingleColumnMarquee, [100.5, 100.0], [150.5, 100.0], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(150, 0, 151, 600)); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +#[test] +fn click_inside_selects_the_line_under_the_pointer() { + let mut app = app(); + two_rows(&mut app); + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251), "row 250 alone, not deselected"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + click(&mut app, Tool::SingleColumnMarquee, [200.5, 150.0], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(200, 0, 201, 600), "column 200 alone, not deselected"); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +/// With snapping on, a click inside is still a click when the selection's edge is within reach of +/// the canvas edge: the snapped release moved both rows up 3 px ("Transform Selection") instead, +/// and the Rectangular Marquee's selection with them instead of deselecting. +#[test] +fn click_inside_with_snapping_on_selects_the_line() { + let mut app = app(); + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + click(&mut app, Tool::SingleRowMarquee, [200.0, 3.5], Modifiers::SHIFT); + assert_eq!(bounds(&app), Rect::new(0, 3, 800, 251)); + app.ui.extras.snap = true; + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251)); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + app.run("select.rect", json!({"x": 3, "y": 3, "width": 100, "height": 50})).unwrap(); + click(&mut app, Tool::RectMarquee, [50.5, 30.5], Modifiers::NONE); + assert!(app.session.active().unwrap().doc.selection.is_none(), "deselected, not moved onto the edge"); +} + +/// A drag from inside that snapping pulls back to where it started (the selection's edge on a +/// guide, 2 px away) is not a click: the selection stays, with no step. It took the click's path: +/// one line replaced the rows, and the Rectangular Marquee deselected. A floating piece is dropped +/// where it is by the press, which drags the outline (#11368). +#[test] +fn a_drag_snapped_back_to_its_start_leaves_the_selection() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + app.ui.extras.snap = false; + two_rows(&mut app); + app.ui.extras.snap = true; + let before = steps(&app); + drag(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], [300.0, 252.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 251), "both rows where they were"); + assert_eq!(steps(&app), before, "no step"); + + drag(&mut app, Tool::RectMarquee, [300.0, 250.5], [300.0, 252.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 251), "the Rectangular Marquee keeps them too"); + + app.run("select.rect", json!({"x": 100, "y": 150, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + drag(&mut app, Tool::SingleRowMarquee, [200.0, 200.5], [200.0, 202.5], Modifiers::NONE); + assert!(!floats(&app), "the press dropped the piece where it was (#11368)"); + assert_eq!(bounds(&app), Rect::new(100, 150, 300, 250)); + assert_eq!(pixel(&app, 200, 200), [1.0, 0.0, 0.0, 1.0], "the dropped pixels are where they were"); +} + +/// A drag from inside that goes away and comes back to the press is not a click either: the +/// selection stays where it was, with no step. Only the release was checked, so it took the +/// click's path (one line replaced the rows, the Rectangular Marquee deselected). A floating piece +/// is dropped where it is by the press, which drags the outline (#11368). +#[test] +fn a_drag_that_returns_to_its_start_leaves_the_selection() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let out_and_back = |app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]| { + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: p[0] + 60.0, y: p[1] + 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: p[0] + 0.2, y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: p[0] + 0.2, y: p[1] }, Modifiers::NONE); + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.ui.extras.snap = false; + two_rows(&mut app); + let before = steps(&app); + out_and_back(&mut app, Tool::SingleRowMarquee, [300.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 251), "both rows where they were"); + assert_eq!(steps(&app), before, "no step"); + + two_columns(&mut app); + out_and_back(&mut app, Tool::SingleColumnMarquee, [300.5, 250.0]); + assert_eq!(bounds(&app), Rect::new(200, 0, 301, 600)); + + out_and_back(&mut app, Tool::RectMarquee, [300.5, 250.0]); + assert_eq!(bounds(&app), Rect::new(200, 0, 301, 600), "the Rectangular Marquee keeps them too"); + + app.run("select.rect", json!({"x": 100, "y": 150, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + out_and_back(&mut app, Tool::SingleRowMarquee, [200.0, 200.5]); + assert!(!floats(&app), "the press dropped the piece where it was (#11368)"); + assert_eq!(bounds(&app), Rect::new(100, 150, 300, 250)); + assert_eq!(pixel(&app, 200, 200), [1.0, 0.0, 0.0, 1.0], "the dropped pixels are where they were"); +} + +/// A click whose release drifts under a pixel from the press (a zoomed-in hand jitter) is still a +/// click with snapping on: only an exactly still release ended unsnapped, so the jittered one +/// snapped the rows onto the canvas edge ("Transform Selection") and the Rectangular Marquee's +/// selection with them instead of deselecting. +#[test] +fn a_jittered_click_inside_with_snapping_on_is_still_a_click() { + let jittered_click = |app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]| { + app.ui.tool = tool; + let q = [p[0] + 0.2, p[1] - 0.3]; + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: q[0], y: q[1] }, Modifiers::NONE); + }; + let mut app = app(); + click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE); + click(&mut app, Tool::SingleRowMarquee, [200.0, 3.5], Modifiers::SHIFT); + app.ui.extras.snap = true; + jittered_click(&mut app, Tool::SingleRowMarquee, [300.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251), "row 250 alone, not both rows moved up 3 px"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + app.run("select.rect", json!({"x": 3, "y": 3, "width": 100, "height": 50})).unwrap(); + jittered_click(&mut app, Tool::RectMarquee, [50.5, 30.5]); + assert!(app.session.active().unwrap().doc.selection.is_none(), "deselected, not moved onto the edge"); +} + +/// With snapping on, a click inside the selection snaps the line onto a guide 2 px away, as a +/// click outside it does; the press began as a move, which kept it on the pointer. +#[test] +fn click_inside_snaps_the_line_to_a_guide() { + let mut app = app(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 252})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 302})).unwrap(); + // The selections are made unsnapped; only the click under test snaps. + let snapped_click = |app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]| { + app.ui.extras.snap = true; + click(app, tool, p, Modifiers::NONE); + app.ui.extras.snap = false; + }; + + snapped_click(&mut app, Tool::SingleRowMarquee, [600.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 252, 800, 253), "outside the selection: snapped to the guide"); + + two_rows(&mut app); + snapped_click(&mut app, Tool::SingleRowMarquee, [600.0, 250.5]); + assert_eq!(bounds(&app), Rect::new(0, 252, 800, 253), "inside: snapped to the guide too"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + snapped_click(&mut app, Tool::SingleColumnMarquee, [300.5, 500.0]); + assert_eq!(bounds(&app), Rect::new(302, 0, 303, 600), "a column inside snaps to the guide"); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); + + // No guide within reach: the line under the pointer, not the selection's own edge. + two_rows(&mut app); + snapped_click(&mut app, Tool::SingleRowMarquee, [600.0, 150.5]); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 151)); +} + +#[test] +fn combining_drag_from_inside_selects_a_line() { + let mut app = app(); + two_rows(&mut app); + // ⇧ adds the row at the release; nothing moves. + drag(&mut app, Tool::SingleRowMarquee, [300.0, 250.5], [400.0, 320.5], Modifiers::SHIFT); + assert!(selected(&app, 10, 150) && selected(&app, 10, 250) && selected(&app, 10, 320)); + // ⌥ subtracts the row under the release. + drag(&mut app, Tool::SingleRowMarquee, [300.0, 320.5], [350.0, 250.5], Modifiers::ALT); + assert!(selected(&app, 10, 150) && !selected(&app, 10, 250) && selected(&app, 10, 320)); + + two_columns(&mut app); + drag(&mut app, Tool::SingleColumnMarquee, [300.5, 250.0], [400.5, 320.0], Modifiers::SHIFT); + assert!(selected(&app, 200, 10) && selected(&app, 300, 10) && selected(&app, 400, 10)); +} + +/// Ctrl held at the release turns snapping off for a click inside, as it does during a drag: the +/// line under the pointer, not the guide 2 px away. +#[test] +fn ctrl_release_inside_does_not_snap_the_line_to_a_guide() { + let mut app = app(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 252})).unwrap(); + two_rows(&mut app); + app.ui.extras.snap = true; + app.ui.tool = Tool::SingleRowMarquee; + tool_event(&mut app, ToolEvent::Down { x: 600.0, y: 250.5, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 600.0, y: 250.5 }, Modifiers::CTRL); + assert_eq!(bounds(&app), Rect::new(0, 250, 800, 251)); +} + +/// A click on a floating piece drops it where it is and selects the line under the pointer, as +/// before a press on the piece dragged it: a click inside the selection selects a line with these +/// tools. It only dropped an unmoved piece (keeping its outline) and left a moved one floating. +#[test] +fn click_on_a_floating_piece_drops_it_and_selects_the_line() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.ui.extras.snap = false; + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + click(&mut app, Tool::SingleRowMarquee, [200.0, 150.5], Modifiers::NONE); + assert!(!floats(&app), "an unmoved piece is dropped"); + assert_eq!(bounds(&app), Rect::new(0, 150, 800, 151), "row 150, not the piece's outline"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.run("select.float", json!({"dx": 50, "dy": 20})).unwrap(); + click(&mut app, Tool::SingleColumnMarquee, [250.5, 170.0], Modifiers::NONE); + assert!(!floats(&app), "a moved piece is dropped where it is"); + assert_eq!(bounds(&app), Rect::new(250, 0, 251, 600)); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +/// With snapping on, a drag pressed just outside a selected row is a drag from outside: a new row +/// at the release. The snapped press landed on the row's edge, which counted as inside, so the drag +/// moved the selection instead. +#[test] +fn a_press_that_snaps_onto_the_selection_is_still_outside_it() { + let mut app = app(); + two_rows(&mut app); + app.ui.extras.snap = true; + drag(&mut app, Tool::SingleRowMarquee, [300.0, 148.5], [300.0, 100.5], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 100, 800, 101), "a new row at the release, not both rows moved up"); + assert_eq!(step(&app).as_deref(), Some("Single Row Marquee")); + + two_columns(&mut app); + drag(&mut app, Tool::SingleColumnMarquee, [198.5, 300.0], [100.5, 300.0], Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(100, 0, 101, 600)); + assert_eq!(step(&app).as_deref(), Some("Single Column Marquee")); +} + +/// A Move-tool drag of a floating piece that snapping pulls back to its start, released exactly at +/// the press, is not a click: the piece keeps floating. The Move tool's click shortcut dropped it. +#[test] +fn a_snapped_back_move_tool_drag_keeps_the_piece_floating() { + let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 150, "width": 200, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + app.ui.extras.snap = true; + app.ui.tool = Tool::Move; + let p = [200.0, 200.5]; + tool_event(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: p[0], y: p[1] + 2.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: p[0], y: p[1] }, Modifiers::NONE); + assert!(floats(&app), "the piece still floats"); + assert!(app.move_preview.is_none(), "no preview left from the drag"); +} diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs index e65123e989..722fc58f60 100644 --- a/crates/ui-egui/src/smart_filter_rows_tests.rs +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -297,6 +297,11 @@ fn a_hidden_filter_row_is_deselected() { h.state_mut().ui.layer_filter_name.clear(); h.run_steps(4); assert!(!selected(&h, "Gaussian Blur")); + // The Name filter hid Smart A too, so it deselected it (#10879): select it again. + assert!(h.state().session.active().unwrap().selected_layers().is_empty()); + h.state_mut().session.execute("layer.select", json!({"layer": a})).unwrap(); + h.run_steps(4); + assert!(!selected(&h, "Gaussian Blur"), "selecting the layer does not bring the filter row back"); h.key_press(egui::Key::Delete); h.run_steps(4); let st = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/smart_objects_menu_tests.rs b/crates/ui-egui/src/smart_objects_menu_tests.rs new file mode 100644 index 0000000000..91addfd6b3 --- /dev/null +++ b/crates/ui-egui/src/smart_objects_menu_tests.rs @@ -0,0 +1,104 @@ +//! Layer > Smart Objects menu rows, labels and order (#11401). + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::menus::{MenuItem, menu_items}; + +fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) +} + +fn submenu(app: &PhotocraftApp) -> Vec { + menu_items(app).into_iter().filter(|i| i.path == ["Layer", "Smart Objects"]).collect() +} + +fn reveal() -> &'static str { + if cfg!(target_os = "macos") { + "Reveal in Finder" + } else if cfg!(windows) { + "Reveal in Explorer" + } else { + "Show in Folder" + } +} + +#[test] +fn the_rows_follow_the_reference_order() { + let labels: Vec = submenu(&app()).into_iter().map(|i| i.label).collect(); + let expected = [ + "Convert to Smart Object", + "New Smart Object via Copy", + "---", + reveal(), + "---", + "Update Modified Content", + "Update All Modified Content", + "---", + "Edit Contents", + "Reset Transform", + "Relink to File…", + "Replace Contents…", + "Export Contents…", + "---", + "Embed Linked", + "Embed All Linked", + "Convert to Linked…", + "Convert to Layers", + "---", + "---", + "Rasterize", + ]; + assert_eq!(labels, expected, "{labels:?}"); +} + +#[test] +fn embed_rows_use_the_new_labels_and_the_ids_stay() { + let items = submenu(&app()); + let by_id = |id: &str| items.iter().find(|i| i.id == id).unwrap_or_else(|| panic!("{id} missing")); + assert_eq!(by_id("layer.smartObjects.convertToEmbedded").label, "Embed Linked"); + assert_eq!(by_id("layer.smartObjects.embedAllLinked").label, "Embed All Linked"); + assert_eq!(by_id("layer.smartObjects.revealInFinder").label, reveal()); +} + +#[cfg(windows)] +#[test] +fn windows_says_reveal_in_explorer() { + assert_eq!(crate::menus::reveal_label(), "Reveal in Explorer"); +} + +#[test] +fn the_warp_rows_are_gone_from_the_submenu_but_the_commands_stay() { + let app = app(); + for id in ["layer.smartObjects.warp", "layer.smartObjects.perspectiveWarp", "layer.smartObjects.puppetWarp"] { + assert!(!submenu(&app).iter().any(|i| i.id == id), "{id}"); + assert!(photocraft_engine::cmd::command_specs().iter().any(|c| c.id == id), "{id} stays registered"); + } + let all = menu_items(&app); + for id in ["edit.puppetWarp", "edit.perspectiveWarp", "edit.transform.warp"] { + assert!(all.iter().any(|i| i.id == id), "{id} stays in the Edit menu"); + } +} + +#[test] +fn embed_all_linked_is_disabled_without_a_linked_smart_object_and_enabled_with_one() { + let mut app = app(); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.session + .edit("setup", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.sync_views(); + let enabled = |app: &PhotocraftApp, id: &str| submenu(app).iter().find(|i| i.id == id).unwrap().enabled; + assert!(!enabled(&app, "layer.smartObjects.embedAllLinked")); + let path = std::env::temp_dir().join(format!("pc-so-menu-{}.pcraft", std::process::id())); + app.run("layer.smartObjects.convertToLinked", json!({"path": path.to_str().unwrap()})).unwrap(); + app.sync_views(); + assert!(enabled(&app, "layer.smartObjects.embedAllLinked")); + assert!(enabled(&app, "layer.smartObjects.convertToEmbedded")); + std::fs::remove_file(&path).ok(); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 43539ecf89..256a88bb38 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,6 +9,9 @@ mod tool; mod tool_options; mod transform; +#[cfg(test)] +mod type_default_size_tests; + pub use color_panel::{ColorPanelMode, ColorPanelState, ColorRamp, DockTabs}; pub use panel_state::{ CameraRawPreviewState, CameraRawScopeState, CurvesEditorState, LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog, LayersPanelOptions, @@ -16,7 +19,7 @@ pub use panel_state::{ }; pub use tool::Tool; pub use tool_options::{SampleAllLayers, ToolOptions}; -use tool_options::{default_brush_tool, default_layer_filter_mode, yes}; +use tool_options::{default_brush_tool, default_layer_filter_mode, layer_filter_mode_or_normal, yes}; pub use transform::{ BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeInsertionStyle, TypeTransform, }; @@ -65,6 +68,26 @@ pub struct Panels { /// floating window rather than a modal so the canvas stays usable while a gradient is edited. #[serde(default)] pub gradient_editor: bool, + /// Window › Contextual Task Bar (#10890): the small floating bar under the Crop tool's frame + /// (`crop_task_bar`). Its ⋯ menu's Hide bar clears it too. + #[serde(default = "yes")] + pub context_task_bar: bool, + /// Where that bar sits: following its frame, or dragged or pinned (`widgets::task_bar`). + #[serde(default)] + pub task_bar: TaskBarPlace, +} + +/// Where the contextual task bar sits (#10890). Part of [`Panels`], so it is kept with the panel +/// layout across restarts, and `ui.set {panels: {task_bar: …}}` sets it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct TaskBarPlace { + /// Pin bar position: the bar stays where it is while its frame changes. + #[serde(default)] + pub pinned: bool, + /// The bar's top-left corner in points from the canvas area's top-left, once it was dragged + /// by its grip or pinned; `None` follows the frame. Reset bar position clears both. + #[serde(default)] + pub at: Option<[f32; 2]>, } impl Default for Panels { @@ -88,6 +111,8 @@ impl Default for Panels { rail: true, menu_bar: true, gradient_editor: false, + context_task_bar: true, + task_bar: TaskBarPlace::default(), } } } @@ -205,8 +230,11 @@ pub struct DocWindow { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, - /// The last tool used in each shortcut group (the tools sharing a key, [`Tool::key`]), at most - /// one per group: the group's key brings it back, as in Photoshop (#2608). + /// The tools used so far, most recent first and each one once. The first tool of a shortcut + /// group (the tools sharing a key, [`Tool::key`]) is the one its key brings back, and the first + /// tool of a toolbar slot is the one the slot shows, as in Photoshop (#2608, #3569). One list + /// answers both because they don't line up: the Marquee slot holds tools with and without a + /// key, the Hand slot two keys. Kept between launches in `Preferences::group_tools`. #[serde(default)] pub group_tools: Vec, /// A tool letter still held (spring-loaded tool, #2672): the tool to give back on release. @@ -338,6 +366,9 @@ pub struct UiState { /// A live rectangle's corner radius widget being dragged (TOOL-214-12). #[serde(skip)] pub corner_radius: Option, + /// The bounding box of several selected shape layers, a handle being dragged. + #[serde(skip)] + pub shape_box: Option, /// Selected row in the Paths panel ("work" or a saved path name). #[serde(default)] pub selected_path: Option, @@ -365,8 +396,14 @@ pub struct UiState { #[serde(default)] pub layer_filter_name: String, /// Layers panel blend-mode filter. - #[serde(default = "default_layer_filter_mode")] + #[serde(default = "default_layer_filter_mode", deserialize_with = "layer_filter_mode_or_normal")] pub layer_filter_mode: photocraft_color::BlendMode, + /// Layers panel effect filter (#10880). + #[serde(default)] + pub layer_filter_effect: crate::layer_filter_ui::effect::EffectKind, + /// Layers panel attribute filter (#10881). + #[serde(default)] + pub layer_filter_attribute: crate::layer_filter_ui::attribute::Attribute, /// Layers panel colour filter (#5898): the label colour listed (None: layers without one). #[serde(default)] pub layer_filter_color: photocraft_doc::LabelColor, @@ -396,6 +433,10 @@ pub struct UiState { /// Frame tool (see `frame_tool`). #[serde(default)] pub frame_tool: crate::frame_tool::FrameToolUi, + /// The contextual task bar as drawn in the last frame: its screen rect `[x0, y0, x1, y1]` in + /// points, `None` while no bar shows (`ui.inspect` › `taskBar`). + #[serde(skip)] + pub task_bar_shown: Option<[f32; 4]>, /// Artboard tool (see `artboard_tool`). pub artboard_tool: crate::artboard_tool::ArtboardToolUi, /// Modifier Keys panel, custom pixel aspect ratios, workspace dialogs (see `workspace_ui`). @@ -571,6 +612,7 @@ impl Default for UiState { timeline: Default::default(), slices: Default::default(), frame_tool: Default::default(), + task_bar_shown: None, artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), @@ -582,12 +624,15 @@ impl Default for UiState { layer_filter_by: Default::default(), layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), + layer_filter_effect: Default::default(), + layer_filter_attribute: Default::default(), layer_filter_color: Default::default(), pen: None, pen_doc: None, stroke_editor: None, direct_selection: Default::default(), corner_radius: None, + shape_box: None, selected_path: None, hidden_work_path: None, panels: Panels::default(), @@ -636,16 +681,16 @@ impl Default for UiState { } impl UiState { - /// Records the current tool as the last one used in its shortcut group. - pub fn remember_group_tool(&mut self) { - let tool = self.tool; - if !self.group_tools.contains(&tool) { - self.group_tools.retain(|t| t.key() != tool.key()); - self.group_tools.push(tool); + /// Records `tool` as the last one used in its shortcut group and toolbar slot. + pub fn remember_group_tool(&mut self, tool: Tool) { + if self.group_tools.first() != Some(&tool) { + self.group_tools.retain(|t| *t != tool); + self.group_tools.insert(0, tool); } } /// The last tool used in the shortcut group of `key`, if any. + #[cfg(test)] pub fn group_tool(&self, key: char) -> Option { self.group_tools.iter().copied().find(|t| t.key() == key) } @@ -731,10 +776,10 @@ mod tests { assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask)); assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); - assert_eq!(Tool::DeleteAnchorPoint.key(), 'P'); + assert_eq!(Tool::DeleteAnchorPoint.key(), ''); assert_eq!(Tool::from_name("convertPoint"), Some(Tool::ConvertPoint)); assert_eq!(Tool::from_name("Convert Point Tool"), Some(Tool::ConvertPoint)); - assert_eq!(Tool::ConvertPoint.key(), 'P', "it shares the Pen's key"); + assert_eq!(Tool::ConvertPoint.key(), '', "P never reaches it"); assert!(!Tool::ConvertPoint.is_brushlike()); assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove)); assert_eq!(Tool::Remove.key(), 'J'); diff --git a/crates/ui-egui/src/state/tool.rs b/crates/ui-egui/src/state/tool.rs index cd96f525a1..8dda7ce231 100644 --- a/crates/ui-egui/src/state/tool.rs +++ b/crates/ui-egui/src/state/tool.rs @@ -99,8 +99,8 @@ impl Tool { Tool::Count, Tool::Brush, Tool::Pencil, - Tool::MixerBrush, Tool::ColorReplacement, + Tool::MixerBrush, Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser, @@ -113,8 +113,8 @@ impl Tool { Tool::Hand, Tool::RotateView, Tool::Zoom, - Tool::Remove, Tool::SpotHealing, + Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, @@ -284,7 +284,9 @@ impl Tool { Tool::SingleRowMarquee | Tool::SingleColumnMarquee => '\0', Tool::Dodge | Tool::Burn | Tool::Sponge => 'O', Tool::QuickSelection | Tool::ObjectSelection => 'W', - Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => 'P', + Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen => 'P', + // In the Pen flyout, but P never reaches them. + Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => '', Tool::PathSelection | Tool::DirectSelection => 'A', Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U', } diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index bf5303e0d5..e5db1af422 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -96,6 +96,9 @@ pub struct ToolOptions { /// draws it as "combine"), "combine", "subtract", "intersect" or "exclude". #[serde(default = "default_pen_path_op")] pub pen_path_op: String, + /// Shape tools: "shape" (a shape layer) or "path" (the work path), as Photoshop's tool mode + /// (#9453). Shape by default, where the Pen's own mode defaults to Path. + pub shape_mode: String, /// Shape tools: fill with the foreground colour, stroke width (0 = none), rectangle corner /// radius, polygon sides, line weight. pub shape_fill: bool, @@ -142,14 +145,33 @@ pub struct ToolOptions { #[serde(default = "one")] pub slice_height: f32, /// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls. - /// Auto-Select is on with the "group" target by default, as in current Photoshop; - /// ⌘/Ctrl-click inverts it. + /// Auto-Select is on with the "layer" target by default, as Photoshop's Reset Tool leaves it + /// (measured for #11380; #4056 had asked for "group"); ⌘/Ctrl-click inverts it. #[serde(default = "yes")] pub move_auto_select: bool, #[serde(default = "default_move_target")] pub move_target: String, #[serde(default)] pub move_show_transform: bool, + /// Move tool options bar, gear menu (#11381), all on by default: outline the layer under the + /// pointer, open the closed groups of a layer picked by a canvas click, and outline the layer + /// whose Layers panel row is hovered. Preferences > Show Highlight on Rollover still gates + /// the two outlines. + #[serde(default = "yes")] + pub move_show_hover_bounds: bool, + #[serde(default = "yes")] + pub move_expand_group_on_click: bool, + #[serde(default = "yes")] + pub move_show_layers_hover_bounds: bool, + /// Transform options bar: Toggle Reference Point. Off by default, as in Photoshop: the point + /// isn't drawn and isn't a handle, so a drag from the middle of the box moves the content; + /// transforms still turn and scale about it (#11098). + #[serde(default)] + pub transform_reference_point: bool, + /// Transform options bar: X and Y show the reference point's offset from where the box started + /// instead of its position (Photoshop's relative positioning, off by default, #11679). + #[serde(default)] + pub transform_relative_position: bool, /// Last interpolation selected in the Transform options bar; unset follows General's default. #[serde(default)] pub transform_interpolation: Option, @@ -165,7 +187,7 @@ pub struct ToolOptions { pub crop_custom_ratio_height: f32, #[serde(default = "yes")] pub crop_delete: bool, - /// Crop options bar › Fill: false = Background (Default), true = Content-Aware Fill (the area a + /// Crop options bar › Fill: false = Background (default), true = Content-Aware Fill (the area a /// rotated or extending crop uncovers is filled from its surroundings, #3823). #[serde(default)] pub crop_content_aware: bool, @@ -184,7 +206,8 @@ pub struct ToolOptions { #[serde(default = "yes")] pub perspective_crop_grid: bool, /// Crop overlay (#1919): the guide in the crop box, when it shows and its orientation - /// (Photoshop's defaults: Rule of Thirds, Auto Show Overlay). See `crop_overlay`. + /// (the reference application's defaults: Rule of Thirds, Always Show Overlay, #10894). See + /// `crop_overlay`. #[serde(default)] pub crop_overlay: crate::crop_overlay::CropOverlay, #[serde(default)] @@ -273,7 +296,7 @@ fn default_crop_resolution_unit() -> String { } fn default_move_target() -> String { - "group".into() + "layer".into() } fn default_pen_path_op() -> String { @@ -313,6 +336,14 @@ pub(super) fn default_layer_filter_mode() -> photocraft_color::BlendMode { photocraft_color::BlendMode::Normal } +/// A stored Mode filter set to Pass Through, which the Mode list offered until #10878, loads as +/// Normal: Pass Through isn't a mode the filter lists any more. +pub(super) fn layer_filter_mode_or_normal<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + use serde::Deserialize; + let mode = photocraft_color::BlendMode::deserialize(d)?; + Ok(if mode == photocraft_color::BlendMode::PassThrough { photocraft_color::BlendMode::Normal } else { mode }) +} + fn one_u32() -> u32 { 1 } @@ -322,10 +353,10 @@ fn default_eyedropper_sample() -> String { } /// Each tool's Sample All Layers (the Paint Bucket calls it All Layers). Photoshop keeps one per -/// tool, all off by default: turning it on for the Magic Wand must not leave Blur or Smudge -/// copying the composite onto the active layer (#5524). The Mixer Brush keeps its own with the -/// brush (`MixerSettings`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +/// tool, all off by default except the Patch tool's Content-Aware Sample All Layers (#5524, +/// #12125): turning it on for the Magic Wand must not leave Blur or Smudge copying the composite +/// onto the active layer. The Mixer Brush keeps its own with the brush (`MixerSettings`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct SampleAllLayers { pub magic_wand: bool, @@ -342,6 +373,25 @@ pub struct SampleAllLayers { pub smudge: bool, } +impl Default for SampleAllLayers { + fn default() -> Self { + Self { + magic_wand: false, + quick_selection: false, + object_selection: false, + paint_bucket: false, + magic_eraser: false, + spot_healing: false, + patch: true, + content_aware_move: false, + remove: false, + blur: false, + sharpen: false, + smudge: false, + } + } +} + impl SampleAllLayers { /// `tool`'s setting; false for a tool without the option. pub fn get(mut self, tool: Tool) -> bool { @@ -390,7 +440,7 @@ impl Default for ToolOptions { bucket_clear: false, type_font: "Inter".into(), type_style: "Regular".into(), - type_size: 48.0, + type_size: 50.0, type_aa: "sharp".into(), type_align: "left".into(), type_style_defaults: serde_json::Map::new(), @@ -421,6 +471,7 @@ impl Default for ToolOptions { enhance_edge: false, vector_mode: "path".into(), pen_path_op: default_pen_path_op(), + shape_mode: "shape".into(), shape_fill: true, stroke_width: 0.0, shape_stroke: Default::default(), @@ -446,6 +497,11 @@ impl Default for ToolOptions { move_auto_select: true, move_target: default_move_target(), move_show_transform: false, + move_show_hover_bounds: true, + move_expand_group_on_click: true, + move_show_layers_hover_bounds: true, + transform_reference_point: false, + transform_relative_position: false, transform_interpolation: None, crop_ratio: String::new(), crop_custom_ratio_width: 0.0, diff --git a/crates/ui-egui/src/state/transform.rs b/crates/ui-egui/src/state/transform.rs index fe381c336b..83ebb0a9b6 100644 --- a/crates/ui-egui/src/state/transform.rs +++ b/crates/ui-egui/src/state/transform.rs @@ -73,8 +73,8 @@ pub struct TransformSession { pub skew_v: f64, /// Free Transform of several selected layers (#6501): every layer the box moves, in /// `edit.transform`'s `layers` order. Empty when it moves `layer` alone. `layer` stays the - /// active layer, which need not be one of them (the Background is left out, a selected group - /// carries its selected children). + /// active layer, which need not be one of them (a selected group carries its selected + /// children). #[serde(default)] pub layers: Vec, } diff --git a/crates/ui-egui/src/state/type_default_size_tests.rs b/crates/ui-egui/src/state/type_default_size_tests.rs new file mode 100644 index 0000000000..79b62d862b --- /dev/null +++ b/crates/ui-egui/src/state/type_default_size_tests.rs @@ -0,0 +1,89 @@ +//! BUG-TYPE-TOOL-DEFAULT-FONT-SIZE (#12051): the Type tool starts at 50 pt. +//! +//! Measured in the reference application: after Reset All Tools the Type tool's size is 50 pt, +//! the same in an 800x600 document at 72 ppi, a 3000x2000 document at 300 ppi and a 100x100 +//! document at 72 ppi, and the scripted tool options report `size=50` in points. A size saved by +//! an older version (which shipped 48 pt) is a remembered choice, not a default, and stays. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +/// The size in points the scripted tool options report: `ui.inspect`'s `toolOptions` is +/// `ToolOptions` serialized (`control/inspect.rs`), and its serde name is `type_size`. +fn scripted_size(o: &ToolOptions) -> f64 { + let v = serde_json::to_value(o).unwrap_or_default(); + v["type_size"].as_f64().unwrap_or_default() +} + +/// An app with one document of the given size and resolution. +fn app_with_document(width: u32, height: u32, resolution: f64) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": width, "height": height, "resolution": resolution})).unwrap(); + app.sync_views(); + app +} + +#[test] +fn a_fresh_install_starts_the_type_tool_at_50_pt() { + assert_eq!(ToolOptions::default().type_size, 50.0, "the shipped default is 50 pt, not 48 pt"); + assert_eq!(scripted_size(&ToolOptions::default()), 50.0, "the scripted tool options report size=50"); +} + +/// Reset Tool (the options bar's far-left menu) puts the field back to the default, and the +/// default is 50 pt for every flavour of the Type tool. +#[test] +fn reset_tool_gives_50_pt_for_every_type_tool() { + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + let mut o = ToolOptions { type_size: 12.0, ..ToolOptions::default() }; + crate::tool_reset::reset_options(&mut o, tool); + assert_eq!(o.type_size, 50.0, "{tool:?} › Reset Tool"); + } +} + +#[test] +fn reset_tool_and_reset_all_tools_give_50_pt_in_the_running_app() { + let ctx = egui::Context::default(); + for tool in [Tool::Type, Tool::VerticalType] { + let mut app = app_with_document(800, 600, 72.0); + app.ui.tool = tool; + app.ui.tool_options.type_size = 12.0; + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{tool:?} › Reset Tool"); + assert_eq!(scripted_size(&app.ui.tool_options), 50.0, "{tool:?} › Reset Tool, scripted"); + + app.ui.tool_options.type_size = 12.0; + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{tool:?} › Reset All Tools"); + } +} + +/// The default is a constant of the tool, not a function of the document: the measured reference +/// values are 800x600 at 72 ppi, 3000x2000 at 300 ppi and 100x100 at 72 ppi. +#[test] +fn the_default_size_does_not_depend_on_the_document() { + for (width, height, dpi) in [(800u32, 600u32, 72.0f64), (3000, 2000, 300.0), (100, 100, 72.0)] { + let app = app_with_document(width, height, dpi); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{width}x{height} at {dpi} ppi"); + assert_eq!(scripted_size(&app.ui.tool_options), 50.0, "{width}x{height} at {dpi} ppi: scripted tool options"); + } +} + +/// A tool option saved by an older version is a remembered choice: a saved 48 stays 48. +#[test] +fn a_size_saved_by_an_older_version_keeps_its_value() { + let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + restarted.session.prefs.edit(|p| p.tool_options = json!({ "type_size": 48.0 })); + crate::tool_memory::restore(&mut restarted); + assert_eq!(restarted.ui.tool_options.type_size, 48.0, "a saved 48 pt is not overwritten by the new default"); +} + +/// The default is also the fallback for a damaged remembered value (`tool_memory::sanitize`, +/// the second site #12051 names), so it follows it to 50 pt. +#[test] +fn a_damaged_saved_size_falls_back_to_50_pt() { + let mut o = ToolOptions { type_size: f32::NAN, ..ToolOptions::default() }; + crate::tool_memory::sanitize(&mut o); + assert_eq!(o.type_size, 50.0, "a damaged value takes the default"); +} diff --git a/crates/ui-egui/src/text/direction.rs b/crates/ui-egui/src/text/direction.rs index 44bc7cf67f..d662df8532 100644 --- a/crates/ui-egui/src/text/direction.rs +++ b/crates/ui-egui/src/text/direction.rs @@ -54,6 +54,11 @@ pub fn direction_scope(dir: Dir) -> impl Drop { } fn env_override() -> Option { + // Unit tests follow the language or a `direction_scope`, whatever the developer's shell sets + // (the value is cached per process, so a test can't clear it). + if cfg!(test) { + return None; + } static ENV: OnceLock> = OnceLock::new(); *ENV.get_or_init(|| parse_dir(&std::env::var("PHOTOCRAFT_UI_DIRECTION").ok()?)) } diff --git a/crates/ui-egui/src/text/direction/tests.rs b/crates/ui-egui/src/text/direction/tests.rs index c15b1927f6..0414e0e51c 100644 --- a/crates/ui-egui/src/text/direction/tests.rs +++ b/crates/ui-egui/src/text/direction/tests.rs @@ -6,6 +6,7 @@ use super::*; use crate::PhotocraftApp; use crate::i18n::{self, Lang}; use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_text::scripts; use serde_json::json; #[test] @@ -17,9 +18,10 @@ fn right_to_left_scripts_by_language_code() { for code in ["en", "ja", "zh-hans", "pt-br", "ku", "arn", ""] { assert_eq!(i18n::dir_for_code(code), Dir::Ltr, "{code}"); } - // Arabic is the one right-to-left catalog; every other registered language stays left to right. + // Arabic, Hebrew and Urdu are the right-to-left catalogs; every other registered language stays + // left to right. for lang in Lang::all() { - let want = if lang.code() == "ar" { Dir::Rtl } else { Dir::Ltr }; + let want = if matches!(lang.code(), "ar" | "he" | "ur") { Dir::Rtl } else { Dir::Ltr }; assert_eq!(lang.dir(), want, "{}", lang.code()); } } @@ -62,7 +64,11 @@ fn env_values_parse_strictly() { } fn harness() -> Harness<'static, PhotocraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + harness_with(Harness::builder()) +} + +fn harness_with(builder: egui_kittest::HarnessBuilder) -> Harness<'static, PhotocraftApp> { + let mut h = builder.with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ProMedium); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 64, "height": 64})).expect("new document"); @@ -113,6 +119,45 @@ fn the_shell_mirrors_in_a_right_to_left_ui() { } } +/// Choosing a right-to-left catalog (not just forcing the direction) mirrors the shell, and the +/// shell's fonts draw its letters; English mirrors back. Panel interiors and dialogs stay +/// left-to-right for now (module docs), so only the dock side is checked. +#[test] +fn hebrew_and_urdu_mirror_the_shell_and_draw_their_letters() { + let _lang = i18n::language_scope(Lang::EN); + for (code, letter) in [("he", 'ש'), ("ur", 'ر')] { + let lang = Lang::from_code(code).expect("registered language"); + // Builds with craft-fonts (CI: `CRAFT_FONTS_REQUIRED`) draw these letters from the first + // frame and check them. Without it the host's fonts decide, so the harness allows tofu + // and only the mirroring is checked. + let fonts = craft_font_covers(letter); + if !fonts { + eprintln!("{code}: no craft-fonts face for {letter}; glyphs not checked (build with CRAFT_FONTS_DIR)"); + } + let mut h = if fonts { harness() } else { harness_with(Harness::builder().allow_missing_glyphs()) }; + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("choose language"); + h.run_steps(4); + assert_eq!(i18n::current(), lang); + assert!(is_rtl(), "{code} draws right-to-left"); + assert!(dock_x(&h) < 600.0, "{code}: the dock moves to the left"); + for label in ["File", "Help", "Layers"] { + let text = i18n::tr(lang, label); + assert!(!text.is_ascii(), "{code}: {label} is translated"); + assert!(!fonts || h.ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(12.0), text)), "{code}: {text} has glyphs"); + } + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "en"})).expect("back to English"); + h.run_steps(4); + assert!(!is_rtl() && dock_x(&h) > 600.0, "{code} -> en: the shell mirrors back"); + } +} + +/// Whether craft-fonts has a face for `letter`. Only those are served in the first frame (the +/// eager provider); installed fonts arrive a frame later through the lazy loader, which the +/// harness's strict missing-glyph policy doesn't wait for (`cjk_fonts` tests). +fn craft_font_covers(letter: char) -> bool { + photocraft_text::craft_fonts::CRAFT_FONTS.iter().any(|f| !f.bytes.is_empty() && scripts::covering_face(f.bytes, letter).is_some()) +} + #[test] fn arrow_keys_step_through_the_menu_bar_in_screen_order() { let _lang = i18n::language_scope(Lang::EN); diff --git a/crates/ui-egui/src/text/served_fonts.rs b/crates/ui-egui/src/text/served_fonts.rs index e457fc3bf2..57ce3264f8 100644 --- a/crates/ui-egui/src/text/served_fonts.rs +++ b/crates/ui-egui/src/text/served_fonts.rs @@ -9,13 +9,14 @@ use std::sync::{Arc, Mutex, PoisonError}; use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; use egui::{FontData, FontFamily}; -use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; +use photocraft_doc::{LayerContent, TextLayer}; +use photocraft_engine::TypeLayerRef; pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests}; use crate::PhotocraftApp; /// Layers whose document was busy with a background job when their font arrived. -static PENDING: Mutex> = Mutex::new(Vec::new()); +static PENDING: Mutex> = Mutex::new(Vec::new()); /// Served script fallbacks in the UI's font stack, by egui font name, unscaled: kept so the stack /// `theme::install_fonts` rebuilds on a language change includes them again. @@ -89,17 +90,7 @@ pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(Str let families: Vec = delivered.iter().map(|(f, _)| f.clone()).collect(); // Decided before the fonts are in: "shows our own layout" compares the layer's pixels // with what the engine draws now, that is with the fallback font. - for st in app.session.documents() { - for (_, _, l) in st.doc.walk() { - if let LayerContent::Text(t) = &l.content - && redraws_for(t, &families) - && crate::type_tool::shows_own_layout(&st.doc, t) - && !layers.contains(&(st.doc.id, l.id)) - { - layers.push((st.doc.id, l.id)); - } - } - } + pick(&app.session, &families, &mut layers); let fallbacks = photocraft_text::served::script_fallback_families(); for (family, bytes) in &delivered { if fallbacks.contains(family) { @@ -120,6 +111,36 @@ pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(Str } } +/// Adds to `layers` the type layers to redraw once `families` arrive, in every open document and +/// in its first History snapshot (the document as opened), which is checked on its own: since +/// then its layers may have been edited (a PSD layer re-rendered) or deleted (#6087). +fn pick(session: &photocraft_engine::Session, families: &[String], layers: &mut Vec) { + for st in session.documents() { + let first = st.snapshots.iter().find(|s| s.id == 0).map(|s| &s.doc); + // While it still is the open document, a layer picked there is picked in the snapshot too + // (without checking it twice): a busy document is refreshed later, and by then a finished + // job can have moved the open document on. + let shared = first.is_some_and(|f| Arc::ptr_eq(f, &st.doc)); + let first = first.filter(|_| !shared); + for (doc, first_snapshot) in std::iter::once((&st.doc, false)).chain(first.map(|d| (d, true))) { + for (_, _, l) in doc.walk() { + if let LayerContent::Text(t) = &l.content + && redraws_for(t, families) + && crate::type_tool::shows_own_layout(doc, t) + { + let also = (shared && !first_snapshot).then_some(true); + for first_snapshot in std::iter::once(first_snapshot).chain(also) { + let key = TypeLayerRef { doc: st.doc.id, layer: l.id, first_snapshot }; + if !layers.contains(&key) { + layers.push(key); + } + } + } + } + } + } +} + /// Should the layer be redrawn when `families` arrive: it uses one of them, or its text is in a /// script one of them is the served fallback for? fn redraws_for(t: &TextLayer, families: &[String]) -> bool { @@ -151,6 +172,34 @@ mod tests { assert!(uses_family(&psd, &["Lato".into()])); } + /// #6087: the first History snapshot is picked on its own, so a type layer deleted since the + /// document opened still gets the font there, and one still shared with the open document is + /// picked in both without being checked twice. + #[test] + fn the_first_snapshot_is_picked_on_its_own() { + use photocraft_doc::LayerId; + use serde_json::json; + let family = "Served Test Pick"; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24, "font": family})).unwrap()["layer"].as_u64().unwrap()); + let doc = (*s.active().unwrap().doc).clone(); + s.add_document(doc, None); + let doc_id = s.active().unwrap().doc.id; + let key = |first_snapshot| TypeLayerRef { doc: doc_id, layer: id, first_snapshot }; + let picked = |s: &photocraft_engine::Session| { + let mut layers = Vec::new(); + pick(s, &[family.into()], &mut layers); + layers.retain(|l| l.doc == doc_id); + layers + }; + assert_eq!(picked(&s), [key(false), key(true)], "the snapshot still is the open document"); + s.execute("layer.new.layer", json!({})).unwrap(); + assert_eq!(picked(&s), [key(false), key(true)]); + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + assert_eq!(picked(&s), [key(true)], "deleted from the open document, still in the snapshot"); + } + #[test] fn a_layer_in_a_script_redraws_when_that_scripts_fallback_arrives() { // Thai: other tests use other scripts (the served catalog is process-wide). diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 2b05c523d6..6b31332d50 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -1,4 +1,4 @@ -//! Selected tool and tool options remembered between launches, as in Photoshop (#2814). +//! Selected tool, colours and tool options remembered between launches, as in Photoshop (#2814). //! //! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults, //! so a default changed in a later release still reaches users who never touched that option. @@ -11,6 +11,10 @@ //! `paint_mouse::sync_tool_brush`) is kept the same way in `Preferences::tool_brushes` (#6212), //! with the tool that owns the session brush (that brush itself is `Preferences::last_brush`). //! Reset Tool and Reset All Tools change both, so the next save forgets what they reset. +//! +//! The tools used last (`UiState::group_tools`) are kept the same way in +//! `Preferences::group_tools` (#3569), so a tool picked from a flyout stays on its toolbar slot +//! and its key after a restart. A name this version doesn't know is skipped, as with the options. use photocraft_engine::BrushSettings; use serde_json::{Map, Value, json}; @@ -21,17 +25,32 @@ use crate::state::{Tool, ToolOptions}; /// The tool whose brush the session brush is, and the other painting tools' brushes. pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); +/// The sRGB stand-ins and exact inks of recently picked CMYK colours, oldest first. +pub(crate) type CmykPicks = Vec<([f32; 3], [f32; 4])>; + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; app.ui.spring = None; + if app.ui.tool == Tool::Frame && tool != Tool::Frame { + crate::frame_tool::cancel(app); + } app.ui.tool = tool; + ended_by_tool(app); +} + +/// Add New Artboard's placing mode ends with the Artboard tool (#11386): every place that sets +/// `ui.tool` calls this right after, so there is one rule. +pub(crate) fn ended_by_tool(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Artboard { + app.ui.artboard_tool.adding = false; + } } /// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's /// eraser end do, so remember the tool they will return to instead. A different explicit choice /// (or beginning a type edit) owns the tool and stands, just as in `spring_tools::tick`. -fn selected(app: &PhotocraftApp) -> Tool { +pub(crate) fn selected(app: &PhotocraftApp) -> Tool { let tool = if app.ui.tool == Tool::Eraser { app.stylus.tool_before_eraser.unwrap_or(app.ui.tool) } else { app.ui.tool }; if let Some(s) = &app.ui.spring && tool == s.tool @@ -51,8 +70,9 @@ fn changed(o: &ToolOptions) -> Value { if diff.is_empty() { Value::Null } else { Value::Object(diff) } } -/// Remember the selected tool and its options once the user lets go of the pointer. Run every -/// frame, so choices made through the control channel or a tool preset are remembered too. +/// Remember the selected tool, the tools used last and the options once the user lets go of the +/// pointer. Run every frame, so choices made through the control channel or a tool preset are +/// remembered too. pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { if ctx.input(|i| i.pointer.any_down()) { return; @@ -68,6 +88,8 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_colors(app, ctx); + persist_group_tools(app, ctx, tool); persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; @@ -79,6 +101,101 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Keep pixel and mask swatches separately, even when the app exits with a mask targeted. +fn colors(app: &PhotocraftApp) -> [[[f32; 4]; 2]; 2] { + let t = &app.session.tools; + let current = [t.foreground, t.background]; + if t.mask_targeted { [t.other_colors, current] } else { [current, t.other_colors] } +} + +fn persist_colors(app: &mut PhotocraftApp, ctx: &egui::Context) { + let now = colors(app); + if app.prefs_rt.tool_colors == Some(now) && app.prefs_rt.tool_cmyk_picks == app.session.tools.cmyk_picks { + return; + } + app.prefs_rt.tool_colors = Some(now); + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + let saved = color_value(now, &app.prefs_rt.tool_cmyk_picks); + app.session.prefs.edit(|p| p.tool_colors = saved); + // Panels and shortcuts can change colours after prefs_ui::tick already ran this frame. + ctx.request_repaint(); +} + +fn color_value(pairs: [[[f32; 4]; 2]; 2], cmyk_picks: &CmykPicks) -> Value { + let [[foreground, background], [mask_foreground, mask_background]] = pairs; + json!({ + "foreground": foreground, "background": background, + "maskForeground": mask_foreground, "maskBackground": mask_background, + "cmykPicks": cmyk_picks, + }) +} + +/// A missing legacy value represents these defaults, not an indivisible null leaf. +pub(crate) fn default_colors() -> Value { + let tools = photocraft_engine::ToolState::default(); + color_value([[tools.foreground, tools.background], tools.other_colors], &tools.cmyk_picks) +} + +fn restore_colors(app: &mut PhotocraftApp) { + let saved = &app.session.prefs().tool_colors; + let mut pairs = colors(app); + let [[foreground, background], [mask_foreground, mask_background]] = &mut pairs; + for (key, color) in [("foreground", foreground), ("background", background), ("maskForeground", mask_foreground), ("maskBackground", mask_background)] { + if let Some(v) = saved.get(key) { + match serde_json::from_value::<[f32; 4]>(v.clone()) { + Ok(c) if c.iter().all(|x| x.is_finite()) => *color = c, + _ => log::warn!("Preferences: ignoring the remembered `{key}` colour"), + } + } + } + // Parse only a bounded number of valid entries, newest first, before mutating the session. + // `remember_cmyk` deduplicates entries and enforces the same bound as during a live session. + let picks: Option = saved.get("cmykPicks").and_then(Value::as_array).map(|entries| { + entries + .iter() + .rev() + .filter_map(|v| match serde_json::from_value::<([f32; 3], [f32; 4])>(v.clone()) { + Ok((rgb, inks)) if rgb.iter().all(|x| x.is_finite()) && inks.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)) => Some((rgb, inks)), + _ => { + log::warn!("Preferences: ignoring a malformed remembered CMYK colour"); + None + } + }) + .take(photocraft_engine::ToolState::CMYK_PICKS) + .collect() + }); + let [pixels, mask] = pairs; + let (current, other) = if app.session.tools.mask_targeted { (mask, pixels) } else { (pixels, mask) }; + [app.session.tools.foreground, app.session.tools.background] = current; + app.session.tools.other_colors = other; + if let Some(picks) = picks { + app.session.tools.cmyk_picks.clear(); + for (rgb, inks) in picks.into_iter().rev() { + app.session.tools.remember_cmyk(rgb, inks); + } + } + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + app.prefs_rt.tool_colors = Some(pairs); +} + +/// Remember the tools used last when the list changes, whether or not the selected tool did: a +/// tool picked during a drag that ends on the tool it began with changes only the list. +fn persist_group_tools(app: &mut PhotocraftApp, ctx: &egui::Context, tool: Tool) { + // Noted here as well: no frame follows a tool picked right before quitting. + app.ui.remember_group_tool(tool); + if app.prefs_rt.group_tools.as_ref() == Some(&app.ui.group_tools) { + return; + } + app.prefs_rt.group_tools = Some(app.ui.group_tools.clone()); + if let Ok(now) = serde_json::to_value(&app.ui.group_tools) + && app.session.prefs().group_tools != now + { + app.session.prefs.edit(|p| p.group_tools = now); + // As for the selected tool: prefs_ui::tick ran before the panels. + ctx.request_repaint(); + } +} + /// Remember the painting tools' own brushes when they change (a compare per frame otherwise: /// tip bitmaps are shared, so equal brushes compare by pointer). A brush that can't be remembered /// yet (a sampled tip too big to embed, before the preset store takes it) keeps what was saved. @@ -153,7 +270,7 @@ fn restore_brushes(app: &mut PhotocraftApp) { app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); } -/// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +/// Flush the final tool state on normal exit, even if no further frame ran after a change. /// The brush first, as in a frame, so the saved brush owner matches the saved brush. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { let ctx = egui::Context::default(); @@ -162,19 +279,35 @@ pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { crate::prefs_ui::save_preferences(app) } +/// Restore the tools used last, keeping their order. Anything that isn't a tool is skipped with a +/// warning: the rest of the list still holds. +fn restore_group_tools(app: &mut PhotocraftApp) { + let saved = app.session.prefs().group_tools.clone(); + if !saved.is_null() && !saved.is_array() { + log::warn!("Preferences: the remembered group tools are not a list; using the defaults"); + } + for v in saved.as_array().into_iter().flatten().rev() { + match serde_json::from_value::(v.clone()) { + Ok(tool) => app.ui.remember_group_tool(tool), + Err(_) => log::warn!("Preferences: ignoring the remembered group tool {v}"), + } + } +} + /// Restore the selected tool before the first frame and its options field by field. pub fn restore(app: &mut PhotocraftApp) { + restore_group_tools(app); let saved = app.session.prefs().selected_tool.clone(); if !saved.is_null() { match serde_json::from_value::(saved) { - Ok(tool) => { - app.ui.tool = tool; - app.ui.remember_group_tool(); - } + Ok(tool) => app.ui.tool = tool, Err(err) => log::warn!("Preferences: ignoring the remembered selected tool: {err}"), } } + // Here rather than on the first frame, so a launch alone has nothing to save. + app.ui.remember_group_tool(app.ui.tool); app.prefs_rt.selected_tool = Some(app.ui.tool); + app.prefs_rt.group_tools = Some(app.ui.group_tools.clone()); let saved = app.session.prefs().tool_options.clone(); if let Some(saved) = saved.as_object() && let Ok(Value::Object(mut cur)) = serde_json::to_value(&app.ui.tool_options) @@ -195,6 +328,7 @@ pub fn restore(app: &mut PhotocraftApp) { log::warn!("Preferences: the remembered tool options are not an object; using the defaults"); } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); + restore_colors(app); restore_brushes(app); } diff --git a/crates/ui-egui/src/tool_shift_cycle_order_tests.rs b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs new file mode 100644 index 0000000000..83c3ca33cc --- /dev/null +++ b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs @@ -0,0 +1,164 @@ +//! Shift+letter steps through a key's tool group in the toolbar flyout's order, wrapping, and the +//! anchor point tools have no letter so P never reaches them. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::panels::toolbar::{TOOL_SECTIONS, toolbar_position}; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn shift_press(app: &mut PhotocraftApp, key: Key) { + let modifiers = Modifiers::SHIFT; + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +/// Every lettered tool group in toolbar order, with its letter. +fn groups() -> Vec<(char, Vec)> { + let mut keys: Vec = Tool::ALL.iter().map(|t| t.key()).filter(|k| *k != '\0').collect(); + keys.sort_unstable(); + keys.dedup(); + keys.into_iter() + .map(|k| { + let mut group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == k && toolbar_position(*t) != usize::MAX).collect(); + group.sort_by_key(|t| toolbar_position(*t)); + (k, group) + }) + .collect() +} + +fn key_for(letter: char) -> Key { + crate::spring_tools::key_of(letter).unwrap() +} + +fn sequence(letter: char, steps: usize) -> Vec { + let mut app = app(); + app.ui.tool = Tool::Hand; + (0..steps) + .map(|_| { + shift_press(&mut app, key_for(letter)); + app.ui.tool + }) + .collect() +} + +#[test] +fn from_every_tool_shift_letter_lands_on_the_next_tool_in_toolbar_order() { + for (letter, group) in groups() { + for (i, from) in group.iter().enumerate() { + let mut app = app(); + app.ui.tool = *from; + shift_press(&mut app, key_for(letter)); + let want = group[(i + 1) % group.len()]; + assert_eq!(app.ui.tool, want, "Shift+{letter} from {from:?}"); + } + } +} + +#[test] +fn shift_letter_wraps_from_the_last_tool_to_the_first() { + for (letter, group) in groups() { + let mut app = app(); + app.ui.tool = *group.last().unwrap(); + shift_press(&mut app, key_for(letter)); + assert_eq!(app.ui.tool, group[0], "Shift+{letter} wraps"); + } +} + +#[test] +fn the_first_shift_letter_from_another_tool_lands_on_the_groups_first_tool() { + for (letter, group) in groups() { + let mut app = app(); + app.ui.tool = Tool::Hand; + if group.contains(&Tool::Hand) { + continue; + } + shift_press(&mut app, key_for(letter)); + assert_eq!(app.ui.tool, group[0], "Shift+{letter} from the Hand tool"); + } +} + +#[test] +fn w_cycles_object_selection_quick_selection_magic_wand() { + assert_eq!(sequence('W', 4), vec![Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand, Tool::ObjectSelection]); +} + +#[test] +fn t_cycles_horizontal_vertical_vertical_mask_horizontal_mask() { + assert_eq!(sequence('T', 5), vec![Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask, Tool::Type]); +} + +#[test] +fn p_cycles_the_three_pens_and_never_reaches_an_anchor_point_tool() { + let seen = sequence('P', 7); + assert_eq!(&seen[..4], &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::Pen]); + for t in seen { + assert!(!matches!(t, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint), "{t:?}"); + } +} + +#[test] +fn the_anchor_point_tools_have_no_letter_but_stay_in_the_pen_flyout() { + for t in [Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint] { + assert_eq!(t.key(), '\0', "{t:?}"); + assert!(TOOL_SECTIONS.iter().flat_map(|s| s.iter()).any(|slot| slot.contains(&t)), "{t:?} stays in the toolbar"); + } +} + +#[test] +fn the_cycle_order_is_the_flattened_toolbar_order() { + let flat: Vec = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).flat_map(|slot| slot.iter().copied()).collect(); + for (i, t) in flat.iter().enumerate() { + assert_eq!(toolbar_position(*t), i, "{t:?}"); + } +} + +#[test] +fn with_the_shift_switch_preference_off_plain_w_keeps_the_old_order() { + let mut app = app(); + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + app.ui.tool = Tool::MagicWand; + let mut seen = Vec::new(); + for _ in 0..3 { + let ctx = egui::Context::default(); + let modifiers = Modifiers::NONE; + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: Key::W, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + seen.push(app.ui.tool); + } + assert_eq!(seen, vec![Tool::QuickSelection, Tool::ObjectSelection, Tool::MagicWand]); +} + +#[test] +fn a_plain_w_from_outside_the_group_keeps_selecting_what_it_selected_before() { + let mut app = app(); + app.ui.tool = Tool::Move; + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: Key::W, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + assert_eq!(app.ui.tool, Tool::MagicWand, "the first W tool in Tool::ALL, not the first in the flyout"); +} diff --git a/crates/ui-egui/src/toolbar_group_order_tests.rs b/crates/ui-egui/src/toolbar_group_order_tests.rs new file mode 100644 index 0000000000..46db63bcb3 --- /dev/null +++ b/crates/ui-egui/src/toolbar_group_order_tests.rs @@ -0,0 +1,145 @@ +//! The healing and brush slots list their tools in the reference order, the healing slot starts on +//! Spot Healing Brush, and J, B, Alt-click and Shift+letter all follow that order. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::panels::toolbar::{TOOL_SECTIONS, next_slot_tool, toolbar}; +use crate::state::Tool; + +const HEALING: [Tool; 6] = [Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; +const BRUSH: [Tool; 4] = [Tool::Brush, Tool::Pencil, Tool::ColorReplacement, Tool::MixerBrush]; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn slot_with(tool: Tool) -> &'static [Tool] { + TOOL_SECTIONS.iter().flat_map(|s| s.iter()).find(|slot| slot.contains(&tool)).copied().unwrap() +} + +fn shift_sequence(key: Key, steps: usize) -> Vec { + let mut app = app(); + app.ui.tool = Tool::Hand; + (0..steps) + .map(|_| { + press(&mut app, key, Modifiers::SHIFT); + app.ui.tool + }) + .collect() +} + +#[test] +fn the_healing_slot_lists_spot_healing_first() { + assert_eq!(slot_with(Tool::Healing), &HEALING); +} + +#[test] +fn the_brush_slot_lists_color_replacement_before_mixer_brush() { + assert_eq!(slot_with(Tool::Brush), &BRUSH); +} + +#[test] +fn a_fresh_healing_slot_shows_spot_healing() { + assert_eq!(slot_with(Tool::Remove)[0], Tool::SpotHealing); +} + +#[test] +fn j_on_fresh_settings_selects_spot_healing() { + let mut app = app(); + app.ui.tool = Tool::Hand; + press(&mut app, Key::J, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::SpotHealing); +} + +#[test] +fn tool_all_orders_the_two_pairs_like_the_slots() { + let pos = |t: Tool| Tool::ALL.iter().position(|x| *x == t).unwrap(); + assert!(pos(Tool::SpotHealing) < pos(Tool::Remove)); + assert!(pos(Tool::ColorReplacement) < pos(Tool::MixerBrush)); +} + +#[test] +fn alt_click_steps_both_slots_in_the_new_order_and_wraps() { + for order in [&HEALING[..], &BRUSH[..]] { + for (i, from) in order.iter().enumerate() { + assert_eq!(next_slot_tool(slot_with(*from), *from), order[(i + 1) % order.len()], "from {from:?}"); + } + } +} + +#[test] +fn shift_j_follows_the_reference_sequence() { + let want: Vec = HEALING.iter().chain(&HEALING[..1]).copied().collect(); + assert_eq!(shift_sequence(Key::J, 7), want); +} + +#[test] +fn shift_b_follows_the_reference_sequence() { + let want: Vec = BRUSH.iter().chain(&BRUSH[..1]).copied().collect(); + assert_eq!(shift_sequence(Key::B, 5), want); +} + +fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) { + let mut out = ctx.run_ui( + egui::RawInput { + time: Some(time), + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 1800.0))), + ..Default::default() + }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); +} + +/// Alt-click on the slot button, through the pointer, steps the reordered healing and brush slots. +#[test] +fn pointer_alt_click_steps_the_healing_and_brush_slots_in_the_new_order() { + let mut app = app(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if crate::theme::Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let alt = Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let mut t = 1.0; + for order in [&HEALING[..], &BRUSH[..]] { + let index = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).position(|slot| slot.contains(&order[0])).unwrap(); + let at = buttons[index].center(); + app.ui.tool = order[0]; + for want in order.iter().cycle().skip(1).take(order.len()) { + frame(&mut app, &ctx, t, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, t + 0.05, vec![egui::Event::ModifiersChanged(Default::default()), pointer(at, false, alt)]); + frame(&mut app, &ctx, t + 0.1, vec![]); + assert_eq!(app.ui.tool, *want, "alt-click at {t}"); + t += 0.5; + } + } +} diff --git a/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs b/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs new file mode 100644 index 0000000000..f34623ea97 --- /dev/null +++ b/crates/ui-egui/src/toolbar_more_slot_flyout_tests.rs @@ -0,0 +1,125 @@ +//! Right-click on the toolbar's "…" button opens a one-row flyout, "Edit Toolbar…"; the row runs +//! `edit.toolbar`, Escape or a press outside closes it, and a left click still opens Edit Toolbar. + +use egui::{Event, Pos2, Rect, vec2}; + +use crate::PhotocraftApp; +use crate::panels::toolbar::{TOOL_SECTIONS, toolbar}; +use crate::theme::Tokens; + +fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) { + let mut out = ctx.run_ui( + egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); +} + +fn setup() -> (PhotocraftApp, egui::Context, Pos2) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(bx, bx) && w.sense.senses_click()).map(|w| w.rect).collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + // The "…" button sits right below the last tool slot. + let slots = TOOL_SECTIONS.iter().map(|s| s.len()).sum::(); + let at = buttons[slots].center(); + (app, ctx, at) +} + +fn button(pos: Pos2, which: egui::PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos, button: which, pressed, modifiers: Default::default() } +} + +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, at: Pos2, t: f64) { + frame(app, ctx, t, vec![Event::PointerMoved(at), button(at, egui::PointerButton::Secondary, true)]); + frame(app, ctx, t + 0.05, vec![button(at, egui::PointerButton::Secondary, false)]); + frame(app, ctx, t + 0.1, vec![]); +} + +fn open(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(egui::Id::unique("toolbar-more-flyout-open"))).unwrap_or(false) +} + +fn flyout(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-more-flyout"))) +} + +#[test] +fn right_click_on_more_opens_a_flyout_with_one_row() { + let (mut app, ctx, at) = setup(); + assert!(!open(&ctx)); + right_click(&mut app, &ctx, at, 1.0); + assert!(open(&ctx)); + let rect = flyout(&ctx).expect("the flyout is drawn"); + assert!(rect.height() < 50.0, "one 26 point row, not a list: {rect:?}"); + assert!(app.ui.dialogs.is_empty(), "opening the flyout runs nothing"); +} + +#[test] +fn choosing_edit_toolbar_runs_it_and_closes_the_flyout() { + let (mut app, ctx, at) = setup(); + right_click(&mut app, &ctx, at, 1.0); + let row = flyout(&ctx).unwrap().center(); + frame(&mut app, &ctx, 1.95, vec![Event::PointerMoved(row)]); + frame(&mut app, &ctx, 2.0, vec![button(row, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 2.05, vec![]); + frame(&mut app, &ctx, 2.1, vec![button(row, egui::PointerButton::Primary, false)]); + frame(&mut app, &ctx, 2.15, vec![]); + assert_eq!(app.ui.dialogs.len(), 1, "edit.toolbar opened its dialog"); + assert!(!open(&ctx)); +} + +#[test] +fn escape_closes_the_flyout_without_running_anything() { + let (mut app, ctx, at) = setup(); + right_click(&mut app, &ctx, at, 1.0); + let key = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, &ctx, 2.0, vec![key]); + assert!(!open(&ctx)); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn a_press_outside_closes_the_flyout_without_running_anything() { + let (mut app, ctx, at) = setup(); + right_click(&mut app, &ctx, at, 1.0); + let away = Pos2::new(900.0, 900.0); + frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(away), button(away, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 2.05, vec![button(away, egui::PointerButton::Primary, false)]); + assert!(!open(&ctx)); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn a_left_click_still_opens_edit_toolbar_directly() { + let (mut app, ctx, at) = setup(); + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), button(at, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 1.05, vec![button(at, egui::PointerButton::Primary, false)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert_eq!(app.ui.dialogs.len(), 1); + assert!(!open(&ctx), "no flyout for a left click"); +} + +#[test] +fn a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar() { + let (mut app, ctx, at) = setup(); + app.automation_input = true; + app.services.automation_command = Some(Box::new(|id, _| if id == "edit.toolbar" { Err("toolbar edits denied".into()) } else { Ok(()) })); + right_click(&mut app, &ctx, at, 1.0); + let row = flyout(&ctx).unwrap().center(); + frame(&mut app, &ctx, 1.95, vec![Event::PointerMoved(row)]); + frame(&mut app, &ctx, 2.0, vec![button(row, egui::PointerButton::Primary, true)]); + frame(&mut app, &ctx, 2.05, vec![]); + frame(&mut app, &ctx, 2.1, vec![button(row, egui::PointerButton::Primary, false)]); + frame(&mut app, &ctx, 2.15, vec![]); + assert!(app.ui.dialogs.is_empty(), "the denied command opened nothing"); + assert!(!open(&ctx)); + assert_eq!(app.ui.status, "toolbar edits denied"); + assert!(app.ui.status_error); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index e15ab5385c..9878bf41a4 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -14,6 +14,7 @@ pub mod crop_presets; pub mod crop_shield; pub mod crop_size; pub mod crop_straighten; +pub mod crop_task_bar; pub mod crop_ui; pub mod direct_select; pub mod eraser_ui; @@ -34,6 +35,7 @@ pub mod perspective_crop_ui; pub mod perspective_ui; pub mod puppet_ui; pub mod retouch_ui; +pub mod shape_bounds_box; pub(super) mod shape_gradient_ui; pub(super) mod shape_options; pub(super) mod shape_paint_ui; @@ -53,3 +55,6 @@ pub(super) mod type_transform; pub mod vector_ui; pub mod warp_preview; pub mod zoom_tool; + +#[cfg(test)] +mod crop_options_labels_tests; diff --git a/crates/ui-egui/src/tools/align_bar.rs b/crates/ui-egui/src/tools/align_bar.rs index ebe159471e..927236903b 100644 --- a/crates/ui-egui/src/tools/align_bar.rs +++ b/crates/ui-egui/src/tools/align_bar.rs @@ -1,6 +1,11 @@ //! The Move tool's Align and Distribute controls (Photoshop shows them whenever the Move tool is //! active; they enable once enough layers are selected). Every button dispatches an existing //! `layer.align.*` / `layer.distribute.*` command. +//! +//! The bar reads, after Show Transform Controls: Align left, horizontal centres, right and +//! Distribute vertically; Align top, vertical centres, bottom and Distribute horizontally; the +//! "..." button (a compact icon panel); the gear (the Move tool's three hover and group toggles, +//! #11381). use serde_json::json; @@ -8,7 +13,7 @@ use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; /// (command id, icon, label). The labels are the Layer menu's, already translated. -const ALIGN: [(&str, &str, &str); 6] = [ +pub(crate) const ALIGN: [(&str, &str, &str); 6] = [ ("layer.align.leftEdges", "align-edge-left", "Left Edges"), ("layer.align.horizontalCenters", "align-edge-hcenter", "Horizontal Centers"), ("layer.align.rightEdges", "align-edge-right", "Right Edges"), @@ -17,49 +22,114 @@ const ALIGN: [(&str, &str, &str); 6] = [ ("layer.align.bottomEdges", "align-edge-bottom", "Bottom Edges"), ]; -const DISTRIBUTE: [(&str, &str, &str); 8] = [ +/// The panel's Distribute group: top, vertical centres, bottom, left, horizontal centres, right. +const DISTRIBUTE: [(&str, &str, &str); 6] = [ ("layer.distribute.topEdges", "distribute-top", "Top Edges"), ("layer.distribute.verticalCenters", "distribute-vcenter", "Vertical Centers"), ("layer.distribute.bottomEdges", "distribute-bottom", "Bottom Edges"), ("layer.distribute.leftEdges", "distribute-left", "Left Edges"), ("layer.distribute.horizontalCenters", "distribute-hcenter", "Horizontal Centers"), ("layer.distribute.rightEdges", "distribute-right", "Right Edges"), - ("layer.distribute.horizontally", "distribute-horizontal", "Horizontally"), - ("layer.distribute.vertically", "distribute-vertical", "Vertically"), ]; -/// The six align icons followed by a menu holding both groups with icons and names. -/// Each icon uses 20 pt artwork inside a 28 pt hit target for high-DPI legibility -/// without changing enabled-state or command dispatch (#8849). +/// One bar button: (command id, icon, tooltip). +type BarButton = (&'static str, &'static str, &'static str); + +/// The two button groups of the bar, in order. The tooltips are the reference application's. +fn bar_groups() -> [[BarButton; 4]; 2] { + [ + [ + ("layer.align.leftEdges", "align-edge-left", tl!("Align left edges")), + ("layer.align.horizontalCenters", "align-edge-hcenter", tl!("Align horizontal centers")), + ("layer.align.rightEdges", "align-edge-right", tl!("Align right edges")), + ("layer.distribute.vertically", "distribute-vertical", tl!("Distribute vertically")), + ], + [ + ("layer.align.topEdges", "align-edge-top", tl!("Align top edges")), + ("layer.align.verticalCenters", "align-edge-vcenter", tl!("Align vertical centers")), + ("layer.align.bottomEdges", "align-edge-bottom", tl!("Align bottom edges")), + ("layer.distribute.horizontally", "distribute-horizontal", tl!("Distribute horizontally")), + ], + ] +} + +/// Whether the "..." panel is open: a click on the button toggles it and nothing else closes it +/// (Escape included), as in the reference application. +pub(crate) fn panel_open_id() -> egui::Id { + egui::Id::unique("move-align-panel-open") +} + +pub(crate) fn panel_is_open(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(panel_open_id())).unwrap_or(false) +} + +/// The two groups of four icons, then the "..." button and its panel. Each icon uses 20 pt +/// artwork inside a 28 pt hit target for high-DPI legibility without changing enabled-state or +/// command dispatch (#8849). pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); ui.spacing_mut().item_spacing.x = 0.0; - for (cmd, icon, label) in ALIGN { - let tip = format!("{}: {}", tl!("Align"), tl!(label)); - let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button_with_icon_size(ui, icon, 28.0, 20.0, false, &tip)).inner.clicked() { - let _ = app.run(cmd, json!({})); + for group in bar_groups() { + for (cmd, icon, tip) in group { + let on = app.session.is_enabled(cmd); + if ui.add_enabled_ui(on, |ui| icons::button_with_icon_size(ui, icon, 28.0, 20.0, false, tip)).inner.clicked() { + let _ = app.run(cmd, json!({})); + } } + ui.add_space(6.0); + } + let more = icons::button_with_icon_size(ui, "ellipsis", 28.0, 20.0, false, tl!("Align and distribute")); + let ctx = ui.ctx().clone(); + let open = panel_is_open(&ctx); + if more.clicked() { + ctx.data_mut(|d| d.insert_temp(panel_open_id(), !open)); + } + if panel_is_open(&ctx) { + panel(app, &ctx, more.rect); } ui.add_space(6.0); - let more = icons::button_with_icon_size(ui, "ellipsis", 28.0, 20.0, false, tl!("Align and distribute")); - egui::Popup::menu(&more).show(|ui| { +} + +/// The compact icon panel under the "..." button: Align (6) and Distribute (6). The Distribute +/// Spacing group (#10715) and the Align To dropdown (#10358) go below these as further `group` +/// rows. +fn panel(app: &mut PhotocraftApp, ctx: &egui::Context, anchor: egui::Rect) { + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::unique("move-align-panel")).order(egui::Order::Foreground).fixed_pos(anchor.left_bottom() + egui::vec2(0.0, 2.0)).show( + ctx, + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 2.0); + let mut group = |ui: &mut egui::Ui, title: &str, kind: &str, items: &[(&str, &str, &str)]| { + ui.label(egui::RichText::new(title).small().color(t.text_dim)); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (cmd, icon, label) in items { + let tip = format!("{kind}: {}", tl!(label)); + let on = app.session.is_enabled(cmd); + if ui.add_enabled_ui(on, |ui| icons::button_with_icon_size(ui, icon, 28.0, 20.0, false, &tip)).inner.clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + }; + group(ui, tl!("Align"), tl!("Align"), &ALIGN); + ui.add_space(4.0); + group(ui, tl!("Distribute"), tl!("Distribute"), &DISTRIBUTE); + }); + }, + ); +} + +/// The gear after the "..." button: a menu of the Move tool's three check items, all on by +/// default and saved with the tool options. +pub fn gear(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let gear = icons::button_with_icon_size(ui, "settings", 28.0, 20.0, false, tl!("Options")); + egui::Popup::menu(&gear).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.set_min_width(200.0); - let mut section = |ui: &mut egui::Ui, title: &str, items: &[(&str, &str, &str)]| { - ui.label(egui::RichText::new(title).small().color(t.text_dim)); - for (cmd, icon, label) in items { - let on = app.session.is_enabled(cmd); - let tint = if on { t.icon } else { t.text_dim }; - let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label)); - if ui.add_enabled(on, button).clicked() { - let _ = app.run(cmd, json!({})); - ui.close(); - } - } - }; - section(ui, tl!("Align"), &ALIGN); - ui.separator(); - section(ui, tl!("Distribute"), &DISTRIBUTE); + let o = &mut app.ui.tool_options; + ui.checkbox(&mut o.move_show_hover_bounds, tl!("Show layer bounds on hover")); + ui.checkbox(&mut o.move_expand_group_on_click, tl!("Expand layer group on click")); + ui.checkbox(&mut o.move_show_layers_hover_bounds, tl!("Show hover bounds from Layers")); }); } @@ -70,9 +140,12 @@ mod tests { #[test] fn every_button_is_a_live_command_with_an_icon() { let specs = photocraft_engine::command_specs(); - for (cmd, icon, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { - assert!(specs.iter().any(|c| c.id == *cmd), "{cmd} is not a command"); + let bar: Vec = bar_groups().into_iter().flatten().collect(); + let panel = ALIGN.iter().chain(DISTRIBUTE.iter()); + for (cmd, icon) in bar.iter().map(|b| (b.0, b.1)).chain(panel.map(|b| (b.0, b.1))) { + assert!(specs.iter().any(|c| c.id == cmd), "{cmd} is not a command"); assert!(icons::exists(icon), "{icon} is not embedded"); } + assert!(icons::exists("settings")); } } diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index 6cad2b2158..86e7d5fa82 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -29,9 +29,13 @@ pub enum Drag { Count(usize, usize, [f64; 2]), /// Moving note `index`: grab offset from its position. Note(usize, [f64; 2]), - /// Moving Color Sampler point `index` from where the press landed (a click, not a drag, does - /// nothing; dragging off the canvas removes the point). + /// Moving Color Sampler point `index`; the second field is where the pointer is now, where its + /// marker is drawn (#11729). The release moves the point there (a click on it too); releasing + /// off the canvas removes it. Sampler(usize, [f64; 2]), + /// A new Color Sampler point following the pointer, placed where the drag ends if that is on + /// the canvas (#11729). + SamplerNew([f64; 2]), } /// View state of the analysis tools and panels. @@ -230,6 +234,23 @@ fn set_ruler(app: &mut PhotocraftApp, r: Ruler) { let _ = app.run("image.analysis.rulerTool", json!({"start": r.start, "end": r.end, "protractor": r.protractor})); } +#[cfg(test)] +#[path = "note_show_on_new_tests.rs"] +mod note_show_on_new_tests; + +/// Create a note at the rounded document position using the Note tool's options. +/// After `notes.add` succeeds, select the note and open the Notes panel. Enable Show Notes +/// while leaving the master Extras switch alone. +pub(crate) fn create_note(app: &mut PhotocraftApp, x: f64, y: f64) -> Result { + let a = &app.ui.analysis; + let params = json!({"x": x.round(), "y": y.round(), "author": a.note_author, "color": a.note_color}); + let result = app.run("notes.add", params)?; + app.ui.analysis.note_selected = result["index"].as_u64().map(|v| v as usize); + app.ui.analysis.notes = true; + app.ui.view.show.notes = true; + Ok(result) +} + /// Pointer input for the Ruler, Count, Note and Color Sampler tools. Returns true when consumed. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { let tool = app.active_tool(); @@ -318,12 +339,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> app.ui.analysis.drag = Some(Drag::Note(i, [x - doc.notes[i].position[0], y - doc.notes[i].position[1]])); } None => { - let a = &app.ui.analysis; - let p = json!({"x": x.round(), "y": y.round(), "author": a.note_author, "color": [a.note_color[0], a.note_color[1], a.note_color[2]]}); - if let Ok(r) = app.run("notes.add", p) { - app.ui.analysis.note_selected = r["index"].as_u64().map(|v| v as usize); - app.ui.analysis.notes = true; - } + let _ = create_note(app, x, y); } } } @@ -335,6 +351,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } } } + // As in Photoshop (#11729): the press only starts a drag and the release decides. A new + // point follows the pointer and lands where the drag ends; a press on a point drags it + // there (a click on it moves it to the clicked pixel); Escape cancels either drag. (Tool::ColorSampler, ToolEvent::Down { x, y, .. }) => { let p = [x, y]; match nearest_sampler(&samplers, p, tol) { @@ -342,34 +361,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> Some(i) if mods.alt => { let _ = app.run("view.colorSamplers.delete", json!({"index": i})); } - // A plain click on a point starts a move (committed on release). Some(i) => app.ui.analysis.drag = Some(Drag::Sampler(i, p)), - // ⌥-click on empty space does nothing; a plain click inside places a new point. - None if mods.alt => {} - None if (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y) => { - let r = app.run("view.colorSamplers.add", json!({"x": x, "y": y})); - status_on_err(app, r); - } - // A click outside the canvas places nothing, as in Photoshop. - None => {} + // Off a point (⌥ or not, on the pasteboard too) a new point starts following the pointer. + None => app.ui.analysis.drag = Some(Drag::SamplerNew(p)), + } + } + (Tool::ColorSampler, ToolEvent::Move { x, y, .. }) => { + if let Some(Drag::Sampler(_, at) | Drag::SamplerNew(at)) = &mut app.ui.analysis.drag { + *at = [x, y]; } } (Tool::ColorSampler, ToolEvent::Up { x, y }) => { - if let Some(Drag::Sampler(i, from)) = app.ui.analysis.drag.take() { - if dist(from, [x, y]) <= 0.5 { - return true; // a click on a point, not a drag: nothing to do - } - // Dropped inside the canvas: move it. Dropped outside: remove just that point. - let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); - let r = if inside { - app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})) - } else { - app.run("view.colorSamplers.delete", json!({"index": i})) - }; - status_on_err(app, r); - } + let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); + let r = match app.ui.analysis.drag.take() { + // Released inside the canvas: move it there. Outside: remove just that point. + Some(Drag::Sampler(i, _)) if inside => app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})), + Some(Drag::Sampler(i, _)) => app.run("view.colorSamplers.delete", json!({"index": i})), + // A new point is placed where the drag ends; ending outside the canvas places none. + Some(Drag::SamplerNew(_)) if inside => app.run("view.colorSamplers.add", json!({"x": x, "y": y})), + _ => return true, + }; + status_on_err(app, r); } - (Tool::ColorSampler, ToolEvent::Move { .. }) => {} _ => {} } true @@ -466,7 +479,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } // Color Sampler points (#1046): persistent numbered markers, drawn whatever the active tool is. - for (i, p) in app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default().iter().enumerate() { + // While one is dragged its marker follows the pointer, and a new one shows the next number + // there (#11729), unless the limit is reached and the release cannot place it. + let mut samplers = app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default(); + match app.ui.analysis.drag { + Some(Drag::Sampler(i, at)) if app.ui.tool == Tool::ColorSampler && i < samplers.len() => samplers[i] = at, + Some(Drag::SamplerNew(at)) if app.ui.tool == Tool::ColorSampler && samplers.len() < photocraft_engine::sampler_cmds::MAX_SAMPLERS => samplers.push(at), + _ => {} + } + for (i, p) in samplers.iter().enumerate() { let at = xf.to_screen(p[0] as f32, p[1] as f32); // A small target reticle, double-stroked so it reads on any pixel. for (w, c) in [(3.0, Color32::from_black_alpha(170)), (1.0, Color32::WHITE)] { @@ -821,11 +842,8 @@ fn notes_panel(app: &mut PhotocraftApp, ctx: &egui::Context) { if let Some(p) = edit { let _ = app.run("notes.set", p); } - if delete - && let Some(i) = sel - && app.run("notes.delete", json!({"index": i})).is_ok() - { - app.ui.analysis.note_selected = (n > 1).then(|| i.min(n - 2)); + if delete && let Some(i) = sel { + ask_delete_note(app, i); } if close { app.ui.analysis.notes = false; @@ -988,3 +1006,110 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { #[cfg(test)] pub(crate) mod tests; + +#[cfg(test)] +mod sampler_drag_tests; + +/// Marks the "Delete this note?" alert (#11788). +const DELETE_NOTE_MARK: &str = "__deleteNote"; +/// Its "Don't show again", kept with the other dialog preferences. +const DELETE_NOTE_PREF: &str = "notes.delete.confirmation"; + +/// Delete note `index` of the active document, as the Note tool's ⌫/Delete and the Notes panel's +/// trash do: after the alert "Delete this note?" ([Yes] [No], "Don't show again"), or at once once +/// that is ticked. Nothing is selected afterwards. +pub fn ask_delete_note(app: &mut PhotocraftApp, index: usize) { + let skip = app.session.prefs().dialogs.get(DELETE_NOTE_PREF).and_then(|v| v.get("skip")).and_then(Value::as_bool) == Some(true); + let Some(doc) = app.session.active().map(|s| s.doc.id.0) else { return }; + if skip { + delete_note(app, index); + return; + } + if app.ui.dialogs.iter().any(|d| owns_delete_note(&d.fields)) { + return; + } + let mut f = Map::new(); + f.insert(DELETE_NOTE_MARK.into(), json!(true)); + f.insert("__command".into(), json!("notes.delete")); + f.insert("__label".into(), json!(tl!("Delete Note"))); + f.insert("document".into(), json!(doc)); + f.insert("index".into(), json!(index)); + f.insert("skip".into(), json!(false)); + app.ui.open_dialog(crate::state::DialogKind::Command, f); +} + +fn delete_note(app: &mut PhotocraftApp, index: usize) { + match app.run("notes.delete", json!({"index": index})) { + Ok(_) => app.ui.analysis.note_selected = None, + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } +} + +pub fn owns_delete_note(fields: &Map) -> bool { + fields.contains_key(DELETE_NOTE_MARK) +} + +pub fn delete_note_body(ui: &mut egui::Ui, fields: &mut Map) { + ui.label(tl!("Delete this note?")); + ui.add_space(8.0); + let mut skip = fields.get("skip").and_then(Value::as_bool).unwrap_or(false); + ui.checkbox(&mut skip, tl!("Don't show again")); + fields.insert("skip".into(), json!(skip)); +} + +/// Yes: the note goes (one "Delete Note" step), and "Don't show again" is remembered. +pub fn confirm_delete_note(app: &mut PhotocraftApp, fields: &Map) -> Result { + let index = fields.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or(tl!("No note to delete"))?; + let doc = app.session.active().map(|s| (s.doc.id.0, s.doc.notes.len())); + if doc.map(|d| d.0) != fields.get("document").and_then(Value::as_u64) || doc.is_none_or(|d| index >= d.1) { + return Err(tl!("The document or its notes changed. Please delete the note again.").into()); + } + let result = app.run("notes.delete", json!({"index": index}))?; + app.ui.analysis.note_selected = None; + if fields.get("skip").and_then(Value::as_bool) == Some(true) { + app.session.edit_prefs(|prefs| { + prefs.dialogs.insert(DELETE_NOTE_PREF.into(), json!({"skip": true})); + }); + if let Err(error) = crate::prefs_ui::save_preferences(app) { + app.ui.status = crate::i18n::fmt(tl!("Note deleted, but the preference could not be saved: {error}"), &[("error", &error)]); + app.ui.status_error = true; + } + } + Ok(result) +} + +/// The Note tool with a note selected: ⌫/Delete asks to delete the note (#11788), never the +/// layer (Delete Layer) or the pixels (Edit › Clear). Text fields keep the keys. +pub fn note_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Escape during a Color Sampler drag cancels it: no new point, or the dragged one stays (#11729). + // A tool picked mid-drag gets the release, so the drag is over: drop it and leave Escape alone. + if matches!(app.ui.analysis.drag, Some(Drag::Sampler(..) | Drag::SamplerNew(_))) { + if app.ui.tool != Tool::ColorSampler { + app.ui.analysis.drag = None; + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + app.ui.analysis.drag = None; + return true; + } + } + let Some(index) = app.ui.analysis.note_selected else { return false }; + if app.ui.tool != Tool::Note || app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { + return false; + } + if app.session.active().is_none_or(|s| index >= s.doc.notes.len()) { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + ask_delete_note(app, index); + true +} + +#[cfg(test)] +#[path = "note_delete_key_tests.rs"] +mod note_delete_key_tests; diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs new file mode 100644 index 0000000000..4c66548ddc --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs @@ -0,0 +1,246 @@ +//! #11729: Color Sampler drag gestures, as in Photoshop. A new point follows the pointer and lands +//! where the drag ends, a press on a point drags it (a click on it moves it to the clicked pixel), +//! ⌥-click off a point adds one, and Escape cancels either drag. + +use super::*; +use egui::Modifiers; + +const NONE: Modifiers = Modifiers::NONE; +const ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: false, mac_cmd: false, command: false }; + +/// A 200 × 100 document with the Color Sampler tool. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::ColorSampler; + app +} + +fn down(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); +} + +fn mv(app: &mut PhotocraftApp, x: f64, y: f64) { + crate::canvas::tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, NONE); +} + +fn up(app: &mut PhotocraftApp, x: f64, y: f64) { + crate::canvas::tool_event(app, ToolEvent::Up { x, y }, NONE); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + down(app, from[0], from[1], NONE); + mv(app, (from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0); + mv(app, to[0], to[1]); + up(app, to[0], to[1]); +} + +fn samplers(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.session.active().unwrap().color_samplers.clone() +} + +/// The pixel each point sits on (the stored position floors to it). +fn pixels(app: &PhotocraftApp) -> Vec<[i64; 2]> { + samplers(app).iter().map(|p| [p[0].floor() as i64, p[1].floor() as i64]).collect() +} + +/// Escape through the app's shortcut handling, as the canvas gets it. +fn escape(app: &mut PhotocraftApp) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(NONE), + egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: NONE }, + ], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn a_new_point_follows_the_pointer_and_lands_where_the_drag_ends() { + let mut app = app(); + down(&mut app, 120.0, 40.0, NONE); + assert!(samplers(&app).is_empty(), "nothing is placed on the press"); + mv(&mut app, 135.0, 50.0); + assert_eq!(app.ui.analysis.drag, Some(Drag::SamplerNew([135.0, 50.0])), "the marker follows the pointer"); + mv(&mut app, 150.0, 60.0); + up(&mut app, 150.0, 60.0); + assert_eq!(pixels(&app), vec![[150, 60]]); + assert_eq!(app.ui.analysis.drag, None); +} + +#[test] +fn a_drag_ending_off_the_canvas_places_nothing_and_one_from_the_pasteboard_places_one() { + let mut app = app(); + drag(&mut app, [150.0, 50.0], [230.0, 50.0]); + assert!(samplers(&app).is_empty(), "ended on the pasteboard"); + drag(&mut app, [-20.0, 50.0], [30.0, 40.0]); + assert_eq!(pixels(&app), vec![[30, 40]], "started on the pasteboard, ended on the canvas"); +} + +#[test] +fn a_plain_click_off_a_point_still_places_one_at_that_pixel() { + let mut app = app(); + down(&mut app, 12.4, 7.9, NONE); + up(&mut app, 12.4, 7.9); + assert_eq!(pixels(&app), vec![[12, 7]]); + // A click on the pasteboard places nothing. + down(&mut app, -5.0, 7.0, NONE); + up(&mut app, -5.0, 7.0); + assert_eq!(samplers(&app).len(), 1); +} + +#[test] +fn dragging_a_point_moves_its_marker_live_and_the_point_on_release() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 100, "y": 20})).unwrap(); + let before = samplers(&app); + down(&mut app, before[0][0], before[0][1], NONE); + mv(&mut app, 70.0, 30.0); + assert_eq!(app.ui.analysis.drag, Some(Drag::Sampler(0, [70.0, 30.0])), "the marker follows the pointer"); + assert_eq!(samplers(&app), before, "the point itself moves on release"); + mv(&mut app, 80.0, 20.0); + up(&mut app, 80.0, 20.0); + assert_eq!(pixels(&app), vec![[80, 20], [100, 20]], "#1 moved, #2 untouched, numbering kept"); +} + +#[test] +fn a_click_on_a_point_two_pixels_off_moves_it_to_the_clicked_pixel() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let p = samplers(&app)[0]; + down(&mut app, p[0] + 2.0, p[1] + 1.0, NONE); + up(&mut app, p[0] + 2.0, p[1] + 1.0); + assert_eq!(pixels(&app), vec![[52, 51]]); +} + +#[test] +fn dragging_a_point_off_the_document_still_deletes_it() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 100, "y": 20})).unwrap(); + let p = samplers(&app)[0]; + drag(&mut app, p, [-30.0, 50.0]); + assert_eq!(pixels(&app), vec![[100, 20]]); +} + +#[test] +fn alt_click_off_a_point_adds_one_and_on_a_point_deletes_it() { + let mut app = app(); + down(&mut app, 60.0, 30.0, ALT); + up(&mut app, 60.0, 30.0); + assert_eq!(pixels(&app), vec![[60, 30]], "⌥-click off a point adds one, like a plain click"); + let p = samplers(&app)[0]; + down(&mut app, p[0], p[1], ALT); + up(&mut app, p[0], p[1]); + assert!(samplers(&app).is_empty(), "⌥-click on a point deletes it"); +} + +#[test] +fn escape_cancels_either_drag_and_leaves_the_list_unchanged() { + // A new point: Escape mid-drag, then the release places nothing. + let mut app = app(); + down(&mut app, 40.0, 40.0, NONE); + mv(&mut app, 60.0, 50.0); + escape(&mut app); + assert_eq!(app.ui.analysis.drag, None); + up(&mut app, 60.0, 50.0); + assert!(samplers(&app).is_empty(), "no new point"); + // An existing point stays where it was. + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let before = samplers(&app); + down(&mut app, before[0][0], before[0][1], NONE); + mv(&mut app, 90.0, 10.0); + escape(&mut app); + up(&mut app, 90.0, 10.0); + assert_eq!(samplers(&app), before, "the dragged point stays put"); +} + +#[test] +fn the_dragged_and_the_new_marker_are_drawn_at_the_pointer() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + // The marker numbers the overlay paints, with where they are. + let numbers_at = |app: &PhotocraftApp| -> Vec<(String, egui::Pos2)> { + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf)); + o.textures_delta.clear(); + o.shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.pos)), + _ => None, + }) + .collect() + }; + let p = samplers(&app)[0]; + down(&mut app, p[0], p[1], NONE); + mv(&mut app, 120.0, 30.0); + let at = xf.to_screen(120.0, 30.0); + let shown = numbers_at(&app); + assert!(shown.iter().any(|(t, q)| t == "1" && (q.x - at.x).abs() < 20.0 && (q.y - at.y).abs() < 20.0), "#1 follows the pointer: {shown:?}"); + up(&mut app, 120.0, 30.0); + down(&mut app, 20.0, 80.0, NONE); + mv(&mut app, 30.0, 70.0); + let at = xf.to_screen(30.0, 70.0); + let shown = numbers_at(&app); + assert!(shown.iter().any(|(t, q)| t == "2" && (q.x - at.x).abs() < 20.0 && (q.y - at.y).abs() < 20.0), "the new #2 follows the pointer: {shown:?}"); +} + +#[test] +fn at_the_ten_point_limit_a_drag_off_a_point_previews_no_new_marker() { + let mut app = app(); + for i in 0..10 { + app.run("view.colorSamplers.add", json!({"x": 10 + 15 * i, "y": 10})).unwrap(); + } + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + down(&mut app, 50.0, 70.0, NONE); + mv(&mut app, 60.0, 80.0); + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(&app, ui.painter(), &xf)); + o.textures_delta.clear(); + let numbers: Vec = o + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_owned()), + _ => None, + }) + .collect(); + assert!(!numbers.iter().any(|t| t == "11"), "no 11th marker that the release cannot place: {numbers:?}"); + up(&mut app, 60.0, 80.0); + assert_eq!(samplers(&app).len(), 10); +} + +#[test] +fn a_sampler_drag_whose_release_went_to_another_tool_leaves_escape_alone() { + let mut app = app(); + app.run("select.all", json!({})).unwrap(); + down(&mut app, 40.0, 40.0, NONE); + mv(&mut app, 60.0, 50.0); + // A tool letter mid-drag: the release goes to the new tool. + crate::tool_memory::select(&mut app, Tool::Hand); + up(&mut app, 60.0, 50.0); + escape(&mut app); + assert_eq!(app.ui.analysis.drag, None, "the stale drag is dropped"); + assert!(app.session.active().unwrap().doc.selection.is_none(), "Escape reached Deselect, not the stale sampler drag"); +} diff --git a/crates/ui-egui/src/tools/anchor_tools/tests.rs b/crates/ui-egui/src/tools/anchor_tools/tests.rs index 02d0f45dc8..d7cd7256a4 100644 --- a/crates/ui-egui/src/tools/anchor_tools/tests.rs +++ b/crates/ui-egui/src/tools/anchor_tools/tests.rs @@ -44,13 +44,9 @@ fn click(app: &mut PhotocraftApp, at: [f64; 2], mods: Modifiers) { } #[test] -fn both_tools_join_the_pen_group() { +fn the_anchor_tools_have_no_letter() { let group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == 'P').collect(); - assert_eq!( - group, - vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint], - "P cycles Pen → Freeform → Curvature → Add → Delete → Convert Point" - ); + assert_eq!(group, vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen], "P cycles Pen → Freeform → Curvature and never reaches the anchor point tools"); let mut app = app(Tool::Brush); app.ui.tool = Tool::from_name("addAnchorPoint").unwrap(); assert_eq!(app.ui.tool, Tool::AddAnchorPoint); diff --git a/crates/ui-egui/src/tools/artboard_tool.rs b/crates/ui-egui/src/tools/artboard_tool.rs index 81dd67bd02..36909acb71 100644 --- a/crates/ui-egui/src/tools/artboard_tool.rs +++ b/crates/ui-egui/src/tools/artboard_tool.rs @@ -3,9 +3,10 @@ //! selected board's edge and corner handles to resize it (⇧ on a corner keeps its proportions). //! The "+" buttons beside the selected board's sides add a board of the same size there (⌥: a copy //! with its contents). The options bar sets the selected board's size (preset, W/H, -//! portrait/landscape) and adds a board of the same size beside the last one. Every change is one -//! engine command (`layer.new.artboard`, `layer.artboard.set`, `layer.artboard.addBeside`), so each -//! gesture is one undo step; this module only turns pointer gestures into them. +//! portrait/landscape) and switches on Add New Artboard's placing mode (a click puts a board of the +//! bar's size at the click). Every change is one engine command (`layer.new.artboard`, +//! `layer.artboard.set`, `layer.artboard.addBeside`), so each gesture is one undo step; this module +//! only turns pointer gestures into them. use egui::{CursorIcon, Pos2, Rect, Stroke, vec2}; use photocraft_doc::{Document, Layer, LayerId}; @@ -18,6 +19,9 @@ use crate::crop_ui::{Hit, hit}; use crate::state::Tool; use crate::theme::Tokens; +mod add_mode; +pub(crate) mod bar; + /// How far from an edge (screen points) a press still grabs its handle. const HANDLE_PX: f64 = 6.0; /// A press that moves less than this (screen points) is a click, not a drawn board. @@ -30,12 +34,47 @@ const PLUS_RADIUS_PX: f64 = 8.0; /// direction from the board's centre. const SIDES: [(&str, [f64; 2]); 4] = [("top", [0.0, -1.0]), ("right", [1.0, 0.0]), ("bottom", [0.0, 1.0]), ("left", [-1.0, 0.0])]; -/// Artboard tool state (serde: readable through the control channel). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// Artboard tool state (serde: readable through the control channel). The fields after `drag` are +/// the tool's options (#11385), which Reset Tool puts back to [`ArtboardToolUi::default`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ArtboardToolUi { + /// Add New Artboard's placing mode: a click on empty canvas puts a board there (#11386). + pub adding: bool, #[serde(skip)] pub drag: Option, + /// The bar's size preset (empty: Custom) and, with no board selected, the size it shows. + pub preset: String, + pub width: u32, + pub height: u32, + /// The background of a board the tool draws: `white`, `black`, `transparent` or `custom`. + pub background: String, + /// `#rrggbb` of the `custom` background. + pub background_color: String, + /// The gear menu's four entries. + pub auto_nest: bool, + pub auto_size_canvas: bool, + pub keep_relative_position: bool, + pub shrinkwrap_on_save: bool, +} + +impl Default for ArtboardToolUi { + fn default() -> Self { + let (preset, width, height) = bar::DEFAULT_SIZE; + Self { + adding: false, + drag: None, + preset: preset.to_string(), + width, + height, + background: "white".to_string(), + background_color: "#ffffff".to_string(), + auto_nest: true, + auto_size_canvas: true, + keep_relative_position: true, + shrinkwrap_on_save: true, + } + } } /// A gesture in progress. Board rects are `[x0, y0, x1, y1]` in document pixels. @@ -184,7 +223,11 @@ fn follow(d: &mut ArtboardDrag, p: [f64; 2], mods: egui::Modifiers) { pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, // so it drags a guide or the layer (#6288). A drag already under way keeps its events. - if app.ui.tool != Tool::Artboard || (app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard) { + if app.ui.tool != Tool::Artboard { + add_mode::leave(app); + return false; + } + if app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard { return false; } let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; @@ -222,10 +265,19 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> let result = match d { ArtboardDrag::New { start, cur } => { let screen = (cur[0] - start[0]).hypot(cur[1] - start[1]) * f64::from(app.point_zoom()); - if screen < CLICK_PX || r[2] <= r[0] || r[3] <= r[1] { + if screen < CLICK_PX { + // A click: with Add New Artboard's mode on, it places a board. + if let Some(placed) = add_mode::click(app, start) { + report(app, placed); + } return true; } - app.run("layer.new.artboard", json!({"rect": xywh(r)})) + if r[2] <= r[0] || r[3] <= r[1] { + return true; + } + let mut p = bar::with_background(app, json!({"rect": xywh(r)})); + p["takeBackground"] = json!(true); + app.run("layer.new.artboard", p) } ArtboardDrag::Move { id, rect, .. } if r != rect => app.run("layer.artboard.set", json!({"layer": id.0, "x": r[0], "y": r[1]})), // Resizing leaves the contents where they are, as in Photoshop. @@ -288,53 +340,37 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } -/// The Artboard tool's options bar: the selected board's size preset, W/H, Make Portrait / Make -/// Landscape, and Add New Artboard. +/// The Artboard tool's options bar: the size preset, W/H, Make Portrait / Make Landscape, the +/// background, Add New Artboard, the Align panel and the gear menu (the new controls are in `bar`). pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { if tool != Tool::Artboard { + add_mode::leave(app); return false; } - let t = Tokens::get(ui.ctx()); let board = active_board(app); - let preset = board.and_then(|(id, _)| app.session.active()?.doc.layer(id)?.artboard().map(|a| a.preset.clone())); - let mut edit: Option = None; - ui.add_enabled_ui(board.is_some(), |ui| { - let (id, r) = board.unwrap_or((LayerId(0), [0, 0, 0, 0])); - ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim)); - let mut preset = preset.unwrap_or_default(); - let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))]; - opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); - if crate::widgets::dropdown(ui, "artboard-tool-preset", tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { - edit = Some(json!({"layer": id.0, "preset": preset})); - } - let (mut w, mut h) = ((r[2] - r[0]) as f32, (r[3] - r[1]) as f32); - ui.label(egui::RichText::new(tl!("W")).color(t.text_dim)); - let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Width")).changed(); - ui.label(egui::RichText::new(tl!("H")).color(t.text_dim)); - let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Height")).changed(); - if cw || ch { - edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)})); - } - if crate::widgets::secondary_button(ui, tl!("Make Portrait"), 0.0).clicked() && w > h { - edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); - } - if crate::widgets::secondary_button(ui, tl!("Make Landscape"), 0.0).clicked() && h > w { - edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); - } - }); + let edit = bar::size_group(app, ui, board); if let Some(p) = edit { let r = app.run("layer.artboard.set", p); report(app, r); } + bar::background(app, ui); crate::widgets::vline(ui, 22.0); let has_doc = app.session.active().is_some(); - if ui.add_enabled_ui(has_doc, |ui| crate::widgets::secondary_button(ui, tl!("Add New Artboard"), 0.0)).inner.clicked() { - // A board the size of the selected one, right of the rightmost board (the canvas size - // without a selected board). - let p = board.map_or(json!({}), |(_, r)| json!({"width": r[2] - r[0], "height": r[3] - r[1]})); - let r = app.run("layer.new.artboard", p); - report(app, r); + // Drawn as the primary button while the placing mode is on. + let adding = add_mode::active(app); + let button = ui + .add_enabled_ui(has_doc, |ui| { + if adding { + crate::widgets::primary_button(ui, tl!("Add New Artboard"), 0.0) + } else { + crate::widgets::secondary_button(ui, tl!("Add New Artboard"), 0.0) + } + }) + .inner; + if button.clicked() { + add_mode::toggle(app); } + bar::align_and_gear(app, ui); true } diff --git a/crates/ui-egui/src/tools/artboard_tool/add_mode.rs b/crates/ui-egui/src/tools/artboard_tool/add_mode.rs new file mode 100644 index 0000000000..10df5eaaa5 --- /dev/null +++ b/crates/ui-egui/src/tools/artboard_tool/add_mode.rs @@ -0,0 +1,43 @@ +//! The options bar's Add New Artboard placing mode (#11386): the button switches the mode on, and +//! while it is on a click on empty canvas puts a board of the bar's size with its top-left corner +//! at the click. The engine's `layer.new.artboard` does the work; this only turns the click into +//! its `rect`. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// True while the Artboard tool is current and its placing mode is on. +pub(super) fn active(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Artboard && app.ui.artboard_tool.adding +} + +/// The Add New Artboard button: turns the mode on, or off when it is already on. +pub(super) fn toggle(app: &mut PhotocraftApp) { + app.ui.artboard_tool.adding = !app.ui.artboard_tool.adding; +} + +/// Another tool is current: the mode ends. +pub(super) fn leave(app: &mut PhotocraftApp) { + app.ui.artboard_tool.adding = false; +} + +/// A click at document point `p` on empty canvas: one `layer.new.artboard` with the bar's size and +/// its top-left corner at the click, rounded to whole pixels. None when the mode is off. +pub(super) fn click(app: &mut PhotocraftApp, p: [f64; 2]) -> Option> { + if !active(app) { + return None; + } + app.session.active()?; + let [w, h] = super::bar::bar_size(app); + let rect = [p[0].round() as i64, p[1].round() as i64, w, h]; + let mut params = json!({"rect": rect}); + // With no board selected the size is the tool option's, so the board also keeps its preset + // name (the engine stores it and, with an explicit `rect`, the rect still decides the size). + let preset = &app.ui.artboard_tool.preset; + if !preset.is_empty() && super::active_board(app).is_none() { + params["preset"] = json!(preset); + } + Some(app.run("layer.new.artboard", super::bar::with_background(app, params))) +} diff --git a/crates/ui-egui/src/tools/artboard_tool/bar.rs b/crates/ui-egui/src/tools/artboard_tool/bar.rs new file mode 100644 index 0000000000..e42199577e --- /dev/null +++ b/crates/ui-egui/src/tools/artboard_tool/bar.rs @@ -0,0 +1,343 @@ +//! The Artboard tool's options bar controls (#11385): the size group (preset, W, H, Make Portrait, +//! Make Landscape) that edits the selected board, or the tool's own options when no board is +//! selected; the background swatch and dropdown for the boards the tool draws; the Align panel; and +//! the gear menu. The options live in [`ArtboardToolUi`](super::ArtboardToolUi). + +use egui::Color32; +use photocraft_doc::{ArtboardBackground, Layer, LayerId}; +use photocraft_engine::artboard_cmds::PRESETS; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, icons}; + +/// The size preset a fresh bar (and Reset Tool) shows, with its width and height. +pub const DEFAULT_SIZE: (&str, u32, u32) = ("iPhone 8/7/6", 750, 1334); + +/// The largest board side the bar accepts (the engine's limit). +const MAX_SIDE: f32 = 300_000.0; + +/// The Align panel: three groups of entries. An entry is a label and the `layer.align.*` command +/// it runs; Distribute Widths / Heights have none yet (their behaviour is not measured), so they +/// show disabled. +pub const ALIGN_PANEL: [&[(&str, Option<&str>)]; 3] = [ + &[ + ("Left Edges", Some("layer.align.leftEdges")), + ("Horizontal Centers", Some("layer.align.horizontalCenters")), + ("Right Edges", Some("layer.align.rightEdges")), + ], + &[ + ("Top Edges", Some("layer.align.topEdges")), + ("Vertical Centers", Some("layer.align.verticalCenters")), + ("Bottom Edges", Some("layer.align.bottomEdges")), + ], + &[("Distribute Widths", None), ("Distribute Heights", None)], +]; + +/// The gear menu's entries, in order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Gear { + AutoNest, + AutoSizeCanvas, + KeepRelativePosition, + ShrinkwrapOnSave, +} + +/// Each gear entry and its label. +pub const GEAR: [(Gear, &str); 4] = [ + (Gear::AutoNest, "Auto-nest Layers"), + (Gear::AutoSizeCanvas, "Auto-size Canvas"), + (Gear::KeepRelativePosition, "Keep Relative Position During Layer Reordering"), + (Gear::ShrinkwrapOnSave, "Shrinkwrap Canvas on Save"), +]; + +impl Gear { + /// Is the entry checked? + pub fn checked(self, o: &super::ArtboardToolUi) -> bool { + match self { + Gear::AutoNest => o.auto_nest, + Gear::AutoSizeCanvas => o.auto_size_canvas, + Gear::KeepRelativePosition => o.keep_relative_position, + Gear::ShrinkwrapOnSave => o.shrinkwrap_on_save, + } + } + + /// Only Auto-nest Layers does something yet (the Move tool's auto-nesting, #10512); the others + /// are drawn checked and disabled until their behaviour is measured. + pub fn enabled(self) -> bool { + self == Gear::AutoNest + } +} + +/// "The bar's size": the selected board's size, else the tool option's. `[width, height]` in pixels. +pub fn bar_size(app: &PhotocraftApp) -> [i64; 2] { + match super::active_board(app) { + Some((_, r)) => [r[2].saturating_sub(r[0]), r[3].saturating_sub(r[1])], + None => [i64::from(app.ui.artboard_tool.width), i64::from(app.ui.artboard_tool.height)], + } +} + +/// `params` for `layer.new.artboard` plus the bar's background: `background`, and `color` for Other…. +pub fn with_background(app: &PhotocraftApp, mut params: Value) -> Value { + let o = &app.ui.artboard_tool; + params["background"] = json!(o.background); + if o.background == "custom" { + params["color"] = json!(o.background_color); + } + params +} + +/// Choose size preset `name` as the tool option: its size, no board involved. Unknown names and +/// Custom (empty) change nothing. +pub fn set_preset(app: &mut PhotocraftApp, name: &str) { + if let Some((n, w, h)) = PRESETS.iter().find(|(n, _, _)| *n == name) { + let o = &mut app.ui.artboard_tool; + (o.preset, o.width, o.height) = (n.to_string(), *w, *h); + } +} + +/// Set the tool option size to `w` x `h` (clamped to 1..=300000); the preset becomes Custom, or +/// stays when the size is still the preset's. +pub fn set_size(app: &mut PhotocraftApp, w: f32, h: f32) { + let clamp = |v: f32| if v.is_finite() { v.round().clamp(1.0, MAX_SIDE) as u32 } else { 1 }; + let (w, h) = (clamp(w), clamp(h)); + let o = &mut app.ui.artboard_tool; + (o.width, o.height) = (w, h); + if !PRESETS.iter().any(|(n, pw, ph)| *n == o.preset && (*pw, *ph) == (w, h)) { + o.preset.clear(); + } +} + +/// The size preset the bar shows for board `id`: the name stored on the board ("" = Custom). +pub fn board_preset(app: &PhotocraftApp, id: LayerId) -> String { + app.session.active().and_then(|st| st.doc.layer(id)).and_then(Layer::artboard).map(|a| a.preset.clone()).unwrap_or_default() +} + +/// The selected boards: each selected layer's artboard, once, in document order. +fn selected_boards(app: &PhotocraftApp) -> Vec { + let Some(st) = app.session.active() else { return Vec::new() }; + let mut ids: Vec = Vec::new(); + for l in st.selected_layers() { + if let Some(b) = st.doc.artboard_of(l) + && !ids.contains(&b) + { + ids.push(b); + } + } + ids +} + +/// The background the bar shows as `(name, #rrggbb)`: the first selected board's, else the tool +/// option's. The colour is the custom one (or the option's remembered one for the named ones). +pub fn shown_background(app: &PhotocraftApp) -> (String, String) { + let o = &app.ui.artboard_tool; + let first = selected_boards(app).first().and_then(|id| app.session.active()?.doc.layer(*id)?.artboard().map(|a| a.background)); + match first { + Some(ArtboardBackground::Custom(c)) => { + let [r, g, b, _] = c.to_rgba8(); + ("custom".to_string(), format!("#{r:02x}{g:02x}{b:02x}")) + } + Some(b) => (b.name().to_string(), o.background_color.clone()), + None => (o.background.clone(), o.background_color.clone()), + } +} + +/// Choose background `name` (`white`, `black`, `transparent` or `custom` with `color`, `#rrggbb`). +/// It is the tool option for the next drawn board, and with boards selected also the background of +/// every selected board in one `layer.artboard.set` step ("Edit Artboard" / "Edit Artboards"). +pub fn set_background(app: &mut PhotocraftApp, name: &str, color: &str) -> Result { + let custom = name == "custom"; + if custom { + crate::color_picker_ui::parse_hex(color).ok_or("not a colour")?; + } + let mut boards = selected_boards(app); + // A board whose custom colour already has this RGB keeps its own value: the swatch shows it + // as 8-bit RGB, so OK in the picker without a change must not flatten its alpha or precision. + if custom && let Some(want) = crate::color_picker_ui::parse_hex(color) { + let hex = |c: [f32; 3]| c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let st = app.session.active(); + boards.retain(|id| match st.and_then(|st| st.doc.layer(*id)?.artboard().map(|a| a.background)) { + Some(ArtboardBackground::Custom(c)) => { + let [r, g, b, _] = c.to_rgba8(); + [r, g, b] != hex(want) + } + _ => true, + }); + } + let mut out = json!({}); + if !boards.is_empty() { + let mut params = json!({"layers": boards.iter().map(|b| b.0).collect::>(), "background": name}); + if custom { + params["color"] = json!(color); + } + out = app.run("layer.artboard.set", params)?; + } + let o = &mut app.ui.artboard_tool; + o.background = name.to_string(); + if custom { + o.background_color = color.to_string(); + } + Ok(out) +} + +/// OK in the Color Picker opened by Other…: the custom background is `color` (`#rrggbb`), for the +/// selected boards (if any) and the next drawn one. +pub fn set_custom_background(app: &mut PhotocraftApp, color: &str) -> Result { + set_background(app, "custom", color)?; + Ok(json!({"color": color})) +} + +/// [`set_custom_background`] for OK in the picker. `unchanged` (the result is the colour the picker +/// opened with, after any Only Web Colors snap) leaves every selected board alone, with no command +/// and no history step: only the tool option for the next board follows. Otherwise boards that +/// already have the picked RGB are skipped one by one ([`set_background`]). +pub fn set_custom_background_from_picker(app: &mut PhotocraftApp, color: &str, unchanged: bool) -> Result { + if !unchanged { + return set_custom_background(app, color); + } + crate::color_picker_ui::parse_hex(color).ok_or("not a colour")?; + let o = &mut app.ui.artboard_tool; + o.background = "custom".to_string(); + o.background_color = color.to_string(); + Ok(json!({"color": color})) +} + +/// Run Align panel entry `index` of group `group` on the selected boards. None for an entry that +/// has no command yet. +pub fn run_align(app: &mut PhotocraftApp, group: usize, index: usize) -> Option> { + let cmd = ALIGN_PANEL.get(group)?.get(index)?.1?; + Some(app.run(cmd, json!({}))) +} + +/// The swatch colour of a background (`name`, with `hex` for custom). +fn background_color(name: &str, hex: &str) -> Color32 { + match name { + "black" => Color32::BLACK, + "transparent" => Color32::TRANSPARENT, + "custom" => crate::color_picker_ui::parse_hex(hex).map_or(Color32::WHITE, rgb32), + _ => Color32::WHITE, + } +} + +fn rgb32(c: [f32; 3]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// Opens the Color Picker for the custom background. +fn pick_background(app: &mut PhotocraftApp) { + let rgb = crate::color_picker_ui::parse_hex(&shown_background(app).1).unwrap_or([1.0; 3]); + let title = crate::color_picker_ui::title_for(tl!("Color")); + crate::color_picker_ui::open_for_target(app, "artboardBackground", &title, rgb); +} + +/// Size, W, H, Make Portrait and Make Landscape. With a board selected they show and edit the +/// board (the returned `layer.artboard.set` params); with none, Size, W and H edit the tool's own +/// options and nothing is returned. +pub fn size_group(app: &mut PhotocraftApp, ui: &mut egui::Ui, board: Option<(LayerId, [i64; 4])>) -> Option { + let t = Tokens::get(ui.ctx()); + let mut edit: Option = None; + let (mut preset, mut w, mut h) = match board { + Some((id, r)) => (board_preset(app, id), (r[2] - r[0]) as f32, (r[3] - r[1]) as f32), + None => (app.ui.artboard_tool.preset.clone(), app.ui.artboard_tool.width as f32, app.ui.artboard_tool.height as f32), + }; + let id = board.map_or(LayerId(0), |b| b.0); + ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim)); + let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))]; + opts.extend(PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); + if crate::widgets::dropdown(ui, "artboard-tool-preset", tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { + if board.is_some() { + edit = Some(json!({"layer": id.0, "preset": preset})); + } else { + set_preset(app, &preset); + } + } + ui.label(egui::RichText::new(tl!("W")).color(t.text_dim)); + let cw = crate::widgets::value_field(ui, &mut w, 1.0..=MAX_SIDE, "px", 64.0).accessible_name(tl!("Width")).changed(); + ui.label(egui::RichText::new(tl!("H")).color(t.text_dim)); + let ch = crate::widgets::value_field(ui, &mut h, 1.0..=MAX_SIDE, "px", 64.0).accessible_name(tl!("Height")).changed(); + if cw || ch { + if board.is_some() { + edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)})); + } else { + set_size(app, w, h); + } + } + ui.add_enabled_ui(board.is_some(), |ui| { + if crate::widgets::secondary_button(ui, tl!("Make Portrait"), 0.0).clicked() && w > h { + edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); + } + if crate::widgets::secondary_button(ui, tl!("Make Landscape"), 0.0).clicked() && h > w { + edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); + } + }); + edit +} + +/// The background swatch and its dropdown (White, Black, Transparent, Other…). With boards selected +/// they show the first selected board's background and a change recolours every selected board in +/// one history step (`layer.artboard.set`; "Edit Artboard" for one, "Edit Artboards" for several); +/// either way it is also the tool option, the background of the next drawn board. With no board +/// selected it edits the tool option only. +pub fn background(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let (name, hex) = shown_background(app); + if crate::widgets::color_swatch_button(ui, background_color(&name, &hex), tl!("Artboard Background Color")).clicked() { + pick_background(app); + } + let mut bg = name; + let opts = [ + ("white".to_string(), tl!("White")), + ("black".to_string(), tl!("Black")), + ("transparent".to_string(), tl!("Transparent")), + ("custom".to_string(), tl!("Other…")), + ]; + if crate::widgets::dropdown(ui, "artboard-tool-bg", tl!("Background"), &mut bg, &opts, 120.0) { + if bg == "custom" { + pick_background(app); + } else if let Err(e) = set_background(app, &bg, &hex) { + app.ui.status = e; + app.ui.status_error = true; + } + } +} + +/// The Align button with its panel, and the gear button with its menu. +pub fn align_and_gear(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::vline(ui, 22.0); + let align = icons::button_with_icon_size(ui, "align-edge-left", 28.0, 20.0, false, tl!("Align")); + egui::Popup::menu(&align).show(|ui| { + ui.set_min_width(200.0); + for (g, group) in ALIGN_PANEL.iter().enumerate() { + if g > 0 { + ui.separator(); + } + for (i, (label, cmd)) in group.iter().enumerate() { + let on = cmd.is_some_and(|c| app.session.is_enabled(c)); + let icon = match cmd { + Some(c) => crate::align_bar::ALIGN.iter().find(|a| a.0 == *c).map_or("align-edge-left", |a| a.1), + None if *label == "Distribute Widths" => "distribute-horizontal", + None => "distribute-vertical", + }; + let tint = if on { t.icon } else { t.text_dim }; + let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label)); + if ui.add_enabled(on, button).clicked() { + if let Some(Err(e)) = run_align(app, g, i) { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } + } + } + }); + let gear = icons::button_with_icon_size(ui, "settings", 28.0, 20.0, false, tl!("Options")); + egui::Popup::menu(&gear).show(|ui| { + for (item, label) in GEAR { + let mut on = item.checked(&app.ui.artboard_tool); + if ui.add_enabled(item.enabled(), egui::Checkbox::new(&mut on, tl!(label))).changed() && item == Gear::AutoNest { + app.ui.artboard_tool.auto_nest = on; + } + } + }); +} diff --git a/crates/ui-egui/src/tools/brush_picker.rs b/crates/ui-egui/src/tools/brush_picker.rs index e417dc5a77..3e59a90444 100644 --- a/crates/ui-egui/src/tools/brush_picker.rs +++ b/crates/ui-egui/src/tools/brush_picker.rs @@ -82,9 +82,45 @@ pub(crate) fn picker_id() -> egui::Id { /// All opening paths protect the same frame, including the control channel and options bar. pub(crate) fn open(ui: &mut crate::state::UiState, ctx: &egui::Context, at: [f32; 2]) { ui.brush_picker = Some(at); + // A new opening takes the brush as it is then, not an earlier opening's. + ctx.data_mut(|d| d.remove_temp::(opened_id())); crate::canvas_popup::mark_open(ctx, picker_id()); } +pub(crate) fn opened_id() -> egui::Id { + picker_id().with("opened-with") +} + +/// What Escape in the picker goes back to (#11816): the Size and Hardness it opened with (or a +/// picked preset brought), and the values its own fields last typed. Escape reverts a value only +/// while it is still the typed one, so a change from elsewhere (`]`, the options bar) stays. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Opened { + opened: [f32; 2], + typed: [f32; 2], +} + +impl Opened { + pub(crate) fn new(b: &photocraft_engine::BrushSettings) -> Self { + Self { opened: [b.size, b.hardness], typed: [b.size, b.hardness] } + } + + pub(crate) fn typed(&mut self, b: &photocraft_engine::BrushSettings) { + self.typed = [b.size, b.hardness]; + } + + pub(crate) fn revert(&self, now: &photocraft_engine::BrushSettings) -> photocraft_engine::BrushSettings { + let mut b = now.clone(); + if b.size == self.typed[0] { + b.size = self.opened[0]; + } + if b.hardness == self.typed[1] { + b.hardness = self.opened[1]; + } + b + } +} + /// The options-bar brush chip that shows and hides the picker. A press on it isn't an outside /// press that closes the picker: the chip's click toggles it. pub fn chip_id() -> egui::Id { diff --git a/crates/ui-egui/src/tools/cas_transform.rs b/crates/ui-egui/src/tools/cas_transform.rs index 1e1a0f51ff..362f226724 100644 --- a/crates/ui-egui/src/tools/cas_transform.rs +++ b/crates/ui-egui/src/tools/cas_transform.rs @@ -295,4 +295,24 @@ mod tests { let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); assert_eq!([b.x0, b.y0, b.width() as i32, b.height() as i32], [8, 8, 24, 24]); } + + /// On the Background the box's preview shows the vacated area in the background colour, as + /// the command leaves it. + #[test] + fn on_the_background_the_preview_vacates_to_the_background_colour() { + for depth in [8, 16] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "white"})).unwrap(); + app.session.tools.background = [0.0, 0.0, 1.0, 1.0]; + app.run("select.rect", json!({"x": 8, "y": 8, "width": 40, "height": 24})).unwrap(); + app.sync_views(); + crate::menus::invoke(&mut app, &ctx, COMMAND, json!({})).unwrap(); + let pv = app.transform_preview.as_ref().unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let px = |x, y| pv.doc.layer(id).unwrap().surface().unwrap().rgba(x, y); + assert_eq!(px(20, 20), [0.0, 0.0, 1.0, 1.0], "{depth}-bit: under the box"); + assert_eq!(px(4, 4), [1.0, 1.0, 1.0, 1.0], "{depth}-bit: outside it"); + } + } } diff --git a/crates/ui-egui/src/tools/crop_mode.rs b/crates/ui-egui/src/tools/crop_mode.rs index cc946b30b9..14eb64c8d3 100644 --- a/crates/ui-egui/src/tools/crop_mode.rs +++ b/crates/ui-egui/src/tools/crop_mode.rs @@ -3,7 +3,8 @@ //! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on //! screen and the image turns behind it. A drag outside the box turns the image about the box's //! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise -//! positive), a drag inside moves the image under the box (the box stays put; with View › Snap +//! positive) and keeps the frame inside the turned image ([`keep_inside`]; not with ⌘ held), a +//! drag inside moves the image under the box (the box stays put; with View › Snap //! on, the upright frame's edges and centre snap as a Classic Mode frame's do, #8580), the arrow //! keys nudge the image, and the handles resize the box on screen as usual. With **Auto Center //! Preview** on (the default), a box being resized keeps its centre where it is on screen (also @@ -114,6 +115,74 @@ pub fn image_turn(frame_angle: f64) -> f64 { if a == 0.0 { 0.0 } else { a } } +/// How far (document px) a turned frame's corner may lie past the canvas and still count as on +/// it: rounding, not a visible gap. +const FIT_TOLERANCE: f64 = 1e-6; + +/// Frame `rect` turned `deg` degrees about its centre, kept inside a `cw`×`ch` canvas (#10869): +/// `rect` itself when it fits; else moved the shortest way in; else shrunk about its centre, +/// keeping its proportions, to the largest size that fits anywhere, then moved in. `None` when a +/// size or the angle isn't finite, or a size isn't positive. +pub fn kept_inside(rect: [f64; 4], deg: f64, cw: f64, ch: f64) -> Option<[f64; 4]> { + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + if ![w, h, cw, ch, deg].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 || cw <= 0.0 || ch <= 0.0 { + return None; + } + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + // Half the turned frame's bounds: its corners are on the canvas when these are. + let (ex, ey) = ((w * c + h * s) / 2.0, (w * s + h * c) / 2.0); + let m = crate::crop_ui::center(rect); + // A frame that fits stays bit for bit, also one whose corners a rounding error puts a hair + // past the edges (a frame fitted by an earlier turn touches them). + let fits = |v: f64, e: f64, len: f64| v - e >= -FIT_TOLERANCE && v + e <= len + FIT_TOLERANCE; + if fits(m[0], ex, cw) && fits(m[1], ey, ch) { + return Some(rect); + } + // The scale that makes the bounds fit the canvas; a rounding error short of 1 (180°) fits. + let k = (cw / (2.0 * ex)).min(ch / (2.0 * ey)); + let k = if k >= 1.0 - 1e-9 { 1.0 } else { k }; + // Each axis on its own: the nearest centre that keeps the bounds on the canvas. + let along = |v: f64, e: f64, len: f64| if 2.0 * e >= len { len / 2.0 } else { v.clamp(e, len - e) }; + let n = [along(m[0], ex * k, cw), along(m[1], ey * k, ch)]; + let r = if k == 1.0 { + // Kept size: moved by exactly the offset. + let d = [n[0] - m[0], n[1] - m[1]]; + [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]] + } else { + let (hw, hh) = (w * k / 2.0, h * k / 2.0); + [n[0] - hw, n[1] - hh, n[0] + hw, n[1] + hh] + }; + r.iter().all(|v| v.is_finite()).then_some(r) +} + +/// A default-mode turn keeps the frame inside the turned image, as in Photoshop (#10869): the +/// frame the drag started from, `rect` (turned `from` degrees then), keeps its size and place +/// when it fits the image at the frame's angle now, else [`kept_inside`] moves it in or shrinks +/// it. Each step starts from the press frame, so turning back gives the frame back. The view +/// moves with the frame's centre, so the box stays put on screen. With ⌘ (Ctrl) `held` the frame +/// stays as it was at the press and ↵ fills the uncovered corners. +/// +/// Not measured in Photoshop, so they keep the frame as before: Fill set to Content-Aware Fill, +/// and a frame that already reached past the canvas at the press. +pub fn keep_inside(app: &mut PhotocraftApp, rect: [f64; 4], from: f64, held: bool) { + let Some(now) = finite_frame(app) else { return }; + let Some((cw, ch)) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; + let inside_at_press = kept_inside(rect, from, cw, ch) == Some(rect); + let want = if held || app.ui.tool_options.crop_content_aware || !inside_at_press { + rect + } else { + kept_inside(rect, crate::crop_ui::angle(app), cw, ch).unwrap_or(rect) + }; + if want == now { + return; + } + // Moves the frame's centre and the view together (the box stays put), then sets the size. + let (a, b) = (crate::crop_ui::center(now), crate::crop_ui::center(want)); + shift_image(app, [a[0] - b[0], a[1] - b[1]]); + app.ui.crop_rect = Some(want); +} + /// A live-centred resize ([`live_resize`]): the frame `rect` and handle (`hx`, `hy`) at the press, /// grabbed at document point `start`; the pointer now at `q` (in the press view); the frame's turn /// `deg`, the aspect `ratio` to hold and ⌥ (resize about the centre). diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 5f059917c9..cb5041a2fe 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -458,6 +458,88 @@ fn commit_is_the_same_in_both_modes_and_the_view_stays_upright() { assert!(out[0].1 == out[1].1, "same pixels"); } +/// #9308: the default mode's camera turn is the crop's, not a view rotation, so Match Rotation +/// and Match All copy 0 to the other views and leave the active view showing the box upright. +#[test] +fn match_rotation_leaves_a_pending_crops_turn_out() { + let ctx = egui::Context::default(); + for id in ["window.arrange.matchRotation", "window.arrange.matchAll"] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + app.sync_views(); + assert!(app.session.set_active(0)); + app.sync_views(); + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + app.ui.views[1].rotation = 15.0; + let view = app.ui.views[1].clone(); + app.ui.windows.push(crate::state::DocWindow { id: 7, document: 1, view, open: true, position: None, floating: false, focused: false }); + with_frame(&mut app); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "{id}: the view turned with the frame"); + let r = crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap(); + assert_eq!(r["rotation"], 0.0, "{id}: {r}"); + assert_eq!(app.ui.views[1].rotation, 0.0, "{id}: the other document's view"); + assert_eq!(app.ui.windows[0].view.rotation, 0.0, "{id}: the floating window"); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "{id}: the crop still drives its own view"); + crate::canvas::commit_crop(&mut app); + app.ui.tool = Tool::Brush; + assert_eq!(view_rotation(&app), 0.0, "{id}: after ↵"); + assert_eq!(app.ui.views[1].rotation, 0.0, "{id}: the other view isn't left turned"); + } +} + +/// Match Zoom and Match Location copy no rotation, so with a crop turning the active view they +/// report its camera's turn as it is and leave every view's rotation alone (#9308). +#[test] +fn match_zoom_and_location_report_the_turned_camera() { + let ctx = egui::Context::default(); + for id in ["window.arrange.matchZoom", "window.arrange.matchLocation"] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + app.sync_views(); + assert!(app.session.set_active(0)); + app.sync_views(); + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + app.ui.views[1].rotation = 15.0; + with_frame(&mut app); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + let turned = view_rotation(&app); + assert!((turned - 90.0).abs() < 0.01, "{id}: the view turned with the frame"); + let r = crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap(); + assert!((r["rotation"].as_f64().unwrap() - f64::from(turned)).abs() < 0.01, "{id}: {r}"); + assert_eq!(app.ui.views[1].rotation, 15.0, "{id}: the other view keeps its rotation"); + } +} + +/// #9202: switching documents drops a turned pending crop and turns its own document's view +/// upright, as Esc would, not only the view of the document switched to. +#[test] +fn switching_documents_turns_the_dropped_crops_view_upright() { + // The crop on the first document, then on the second. + for (on, other) in [(0, 1), (1, 0)] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + app.sync_views(); + assert!(app.session.set_active(on)); + app.sync_views(); + let v = &mut app.ui.views[on]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + with_frame(&mut app); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "crop on {on}: the view turned with the frame"); + assert!(app.session.set_active(other)); + app.sync_views(); + assert_eq!(app.ui.crop_rect, None, "crop on {on}: the turned crop is dropped"); + app.ui.tool = Tool::Brush; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.session.set_active(on)); + app.sync_views(); + assert_eq!(view_rotation(&app), 0.0, "crop on {on}: its document isn't left turned"); + } +} + #[test] fn picking_the_crop_tool_turns_a_rotated_view_upright() { let mut app = app(); @@ -537,7 +619,7 @@ fn arrows_nudge_the_image_shift_by_ten_and_x_recentres() { #[test] fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { let mut app = app(); - crate::crop_ui::ensure_frame(&mut app); + with_frame(&mut app); app.ui.tool_options.crop_shield.show_cropped_area = false; assert!(crate::crop_shield::hides_outside(&app)); app.ui.tool_options.crop_shield.classic_mode = true; @@ -554,9 +636,9 @@ fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { } #[test] -fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { +fn p_toggles_classic_mode_while_a_crop_is_in_progress() { let mut app = app(); - crate::crop_ui::ensure_frame(&mut app); + with_frame(&mut app); let press = |app: &mut PhotocraftApp| { let ctx = egui::Context::default(); ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: NONE })); @@ -579,6 +661,29 @@ fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { assert!(!classic(&app)); } +/// After a commit the tool shows no frame until the next press, which brings its untouched default +/// frame back: H and P are the Hand and Pen keys there, as when the tool is picked, not Show +/// Cropped Area and Classic Mode (#9731, #6536). +#[test] +fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { + for (k, tool) in [(egui::Key::H, Tool::Hand), (egui::Key::P, Tool::Pen)] { + let mut app = app(); + with_frame(&mut app); + key(&mut app, egui::Key::Enter, NONE); + assert_eq!(app.session.active().map(|st| st.doc.size), Some(Size::new(60, 40)), "the crop committed"); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_none(), "the commit hides the frame"); + crate::crop_ui::show_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "a press brings the default frame back"); + key(&mut app, k, NONE); + assert_eq!(app.ui.tool, tool, "{k:?}"); + let s = &app.ui.tool_options.crop_shield; + assert!(s.show_cropped_area && !s.classic_mode, "{k:?} changed no crop option"); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_none(), "leaving the tool drops the default frame"); + } +} + #[test] fn straighten_turns_the_image_so_the_line_is_level_on_screen() { let mut app = app(); @@ -716,7 +821,8 @@ fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() { let v = &mut app.ui.views[0]; (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0); app.ui.extras.snap = true; - crate::crop_ui::ensure_frame(&mut app); + // A press brings back the default frame the commit hid. + crate::crop_ui::show_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); if ratio { app.ui.tool_options.crop_ratio = "original".into(); diff --git a/crates/ui-egui/src/tools/crop_options_labels_tests.rs b/crates/ui-egui/src/tools/crop_options_labels_tests.rs new file mode 100644 index 0000000000..5c8b6fd2dc --- /dev/null +++ b/crates/ui-egui/src/tools/crop_options_labels_tests.rs @@ -0,0 +1,266 @@ +//! #10872: the Crop options bar's Fill entry follows Delete Cropped Pixels, and the preset +//! dropdown shows the temporary "Selection" entry while the frame is the untouched selection's. +//! +//! Kept in its own module so the batch that lands the crop-parity items does not touch the shared +//! `tests.rs` files. + +use egui_kittest::Harness; +use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; +use serde_json::json; + +use crate::state::Tool; +use crate::{PhotocraftApp, Services, crop_presets, crop_ui}; + +fn app() -> PhotocraftApp { + let doc = Document::with_background("crop labels", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut app = PhotocraftApp::new(s, Services::default()); + app.ui.tool = Tool::Crop; + app +} + +/// Every text shape the harness drew this frame, popups included. +fn drawn(h: &Harness<'_, State>) -> Vec { + fn collect(shape: &egui::Shape, text: &mut Vec) { + match shape { + egui::Shape::Text(shape) => text.push(shape.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + let mut text = Vec::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut text); + } + text +} + +/// The preset dropdown alone, so the only ComboBox in the harness is ours. +fn dropdown_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + Harness::builder().with_size(egui::vec2(460.0, 900.0)).with_max_steps(64).build_ui_state(|ui, app: &mut PhotocraftApp| crop_presets::dropdown(app, ui), app) +} + +#[test] +fn the_fill_label_follows_delete_cropped_pixels() { + assert_eq!(crop_ui::fill_label(true), "Background (default)"); + assert_eq!(crop_ui::fill_label(false), "Transparent (default)"); +} + +#[test] +fn the_options_bar_shows_the_fill_label_for_the_checkbox() { + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 300.0)) + .with_max_steps(64) + // The whole options bar has number fields the app itself does not name for accessibility. + .with_accessibility_check(false) + .build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"name": "crop fill", "width": 200, "height": 100})).expect("new document"); + app.ui.tool = Tool::Crop; + app + }); + h.run_steps(6); + h.state_mut().ui.tool_options.crop_delete = false; + h.run_steps(2); + assert!(drawn(&h).iter().any(|t| t == "Transparent (default)"), "off: {:?}", drawn(&h)); + h.state_mut().ui.tool_options.crop_delete = true; + h.run_steps(2); + assert!(drawn(&h).iter().any(|t| t == "Background (default)"), "on: {:?}", drawn(&h)); +} + +#[test] +fn the_selection_entry_stands_only_on_the_untouched_selection_frame() { + // A selection when the Crop tool is picked: the default frame is its bounds, and the entry + // names it. + let mut selected = app(); + selected.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut selected); + assert!(selected.crop.default_frame && selected.crop.frame_from_selection); + assert!(crop_presets::shows_selection(&selected)); + + // Editing the frame clears the default-frame state, as the first press or drag does. + selected.crop.default_frame = false; + assert!(!crop_presets::shows_selection(&selected)); + + // No selection: the default frame is the whole canvas, which has no Selection entry. + let mut plain = app(); + crop_ui::ensure_frame(&mut plain); + assert!(plain.crop.default_frame && !plain.crop.frame_from_selection); + assert!(!crop_presets::shows_selection(&plain)); + + // Cancelling drops the frame, so the entry goes with it. + let mut cancelled = app(); + cancelled.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut cancelled); + crop_ui::cancel(&mut cancelled); + assert!(!crop_presets::shows_selection(&cancelled)); +} + +/// The dropdown itself lists "Selection" above Ratio while it stands, and not after the frame is +/// edited. +#[test] +fn the_preset_dropdown_lists_selection_while_the_frame_is_the_selections() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT as _, Queryable}; + + let mut selected = app(); + selected.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut selected); + let mut h = dropdown_harness(selected); + h.run(); + h.get_by_role(Role::ComboBox).click(); + h.run(); + assert!(drawn(&h).iter().any(|t| t == "Selection"), "open list: {:?}", drawn(&h)); + // Above the Ratio presets, and checked: the entry names the frame, so it must read as the + // current one (#10872). + let selection = h.get_by_label("Selection"); + let presets = h.get_by_label("W x H x Resolution"); + assert!(selection.rect().top() < presets.rect().top(), "Selection sits above the Ratio presets"); + assert_eq!(selection.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "Selection is checked"); + + // The frame being edited takes the entry away. + h.state_mut().crop.default_frame = false; + h.run(); + assert!(!drawn(&h).iter().any(|t| t == "Selection"), "edited frame: {:?}", drawn(&h)); +} + +/// Choosing "Selection" (measured in the reference application, #10872): the frame stays, the +/// crop becomes live with free Ratio, and the entry is gone. +#[test] +fn choosing_selection_makes_the_frame_a_live_crop() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut selected = app(); + selected.run("select.rect", json!({"x": 50, "y": 20, "width": 100, "height": 60})).expect("select"); + crop_ui::ensure_frame(&mut selected); + let frame = selected.ui.crop_rect; + assert_eq!(frame, Some([50.0, 20.0, 150.0, 80.0])); + let mut h = dropdown_harness(selected); + h.run(); + h.get_by_role(Role::ComboBox).click(); + h.run(); + h.get_by_label("Selection").click(); + h.run(); + let app = h.state(); + assert_eq!(app.ui.crop_rect, frame, "the frame stays"); + assert!(app.ui.tool_options.crop_ratio.is_empty(), "free Ratio"); + assert!(!app.crop.default_frame && app.crop.editing, "a live crop"); + assert!(!crop_presets::shows_selection(app), "the entry is gone"); +} + +/// A ratio picked on the untouched selection frame replaces it with the largest centred frame of +/// that ratio on the canvas, not one fitted into the selection (measured, #10872). +#[test] +fn a_ratio_picked_on_the_selection_frame_fits_the_canvas() { + let mut selected = app(); + selected.run("select.rect", json!({"x": 10, "y": 10, "width": 60, "height": 40})).expect("select"); + crop_ui::ensure_frame(&mut selected); + selected.ui.tool_options.crop_ratio = "1:1".into(); + crop_ui::apply_ratio(&mut selected); + assert_eq!(selected.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(!crop_presets::shows_selection(&selected)); +} + +/// A picked ratio takes the check mark instead of the temporary entry. +#[test] +fn a_picked_ratio_hides_the_selection_entry() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut selected = app(); + selected.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut selected); + selected.ui.tool_options.crop_ratio = "1:1".into(); + // The options bar fits the untouched frame to the picked ratio; it is no longer the + // selection's bounds. + crop_ui::apply_ratio(&mut selected); + assert!(!crop_presets::shows_selection(&selected), "a fitted frame is not the selection's"); + // Clearing the ratio leaves the fitted frame, so the entry must not come back. + selected.ui.tool_options.crop_ratio.clear(); + assert!(!crop_presets::shows_selection(&selected), "the entry does not return"); + let mut h = dropdown_harness(selected); + h.run(); + h.get_by_role(Role::ComboBox).click(); + h.run(); + assert!(!drawn(&h).iter().any(|t| t == "Selection"), "a picked ratio: {:?}", drawn(&h)); +} + +/// The closed dropdown names the checked entry: "Selection" while the untouched frame is the +/// selection's (as the reference application's options bar shows), the mode once it is edited. +#[test] +fn the_closed_preset_dropdown_reads_selection_while_it_stands() { + let mut selected = app(); + selected.ui.tool_options.crop_ratio.clear(); + selected.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut selected); + let mut h = dropdown_harness(selected); + h.run(); + assert!(drawn(&h).iter().any(|t| t == "Selection"), "closed: {:?}", drawn(&h)); + h.state_mut().crop.default_frame = false; + h.run(); + assert!(!drawn(&h).iter().any(|t| t == "Selection"), "edited frame: {:?}", drawn(&h)); + // A picked ratio takes the entry's place, on the button too. + let mut ratio = app(); + ratio.ui.tool_options.crop_ratio = "1:1".into(); + ratio.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut ratio); + let mut h = dropdown_harness(ratio); + h.run(); + assert!(!drawn(&h).iter().any(|t| t == "Selection"), "ratio: {:?}", drawn(&h)); +} + +/// A saved free-Ratio crop preset matches the options while the Selection entry stands, but only +/// Selection is checked; once the frame is edited the preset is checked again. +#[test] +fn only_selection_is_checked_while_it_stands() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT as _, Queryable}; + + let mut selected = app(); + selected.ui.tool_options.crop_ratio.clear(); + selected.run("tool.presets.new", json!({"name": "Free Crop", "tool": "crop", "options": {"toolOptions": {"crop_ratio": ""}}})).expect("preset"); + selected.run("select.all", json!({})).expect("select all"); + crop_ui::ensure_frame(&mut selected); + let mut h = dropdown_harness(selected); + h.run(); + h.get_by_role(Role::ComboBox).click(); + h.run(); + assert_eq!(h.get_by_label("Selection").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.get_by_label("Free Crop").accesskit_node().toggled(), Some(Toggled::False), "one current entry"); + h.state_mut().crop.default_frame = false; + h.run(); + assert_eq!(h.get_by_label("Free Crop").accesskit_node().toggled(), Some(Toggled::True), "no Selection entry: the preset is current"); +} + +/// Undo and Redo of the box put back whether its default frame was the selection's: undoing a +/// Reset to a selection back to the whole-canvas frame has no Selection entry. +#[test] +fn box_undo_restores_whether_the_frame_came_from_a_selection() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = app(); + crop_ui::ensure_frame(&mut app); + let canvas = app.ui.crop_rect; + assert_eq!(canvas, Some([0.0, 0.0, 200.0, 100.0])); + assert!(!crop_presets::shows_selection(&app)); + // Edit the frame: drag its bottom-right corner in. + tool_event(&mut app, ToolEvent::Down { x: 200.0, y: 100.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 150.0, y: 80.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 150.0, y: 80.0 }, egui::Modifiers::NONE); + assert!(!app.crop.default_frame, "edited"); + app.run("select.rect", json!({"x": 50, "y": 20, "width": 100, "height": 60})).expect("select"); + crop_ui::reset(&mut app); + assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 150.0, 80.0])); + assert!(crop_presets::shows_selection(&app), "reset to the selection's frame"); + app.run("edit.undo", json!({})).expect("undo"); + app.run("edit.undo", json!({})).expect("undo"); + assert_eq!(app.ui.crop_rect, canvas, "back to the whole-canvas frame"); + assert!(app.crop.default_frame); + assert!(!crop_presets::shows_selection(&app), "the canvas frame has no Selection entry"); + app.run("edit.redo", json!({})).expect("redo"); + app.run("edit.redo", json!({})).expect("redo"); + assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 150.0, 80.0])); + assert!(crop_presets::shows_selection(&app), "redo brings the selection's frame back"); +} diff --git a/crates/ui-egui/src/tools/crop_overlay.rs b/crates/ui-egui/src/tools/crop_overlay.rs index c93f5e12e1..d35b5634ed 100644 --- a/crates/ui-egui/src/tools/crop_overlay.rs +++ b/crates/ui-egui/src/tools/crop_overlay.rs @@ -9,7 +9,7 @@ //! The state lives in `ToolOptions` (`crop_overlay`, `crop_overlay_show`, //! `crop_overlay_orientation`), which `ui.inspect` reports and `ui.set` drives. -use egui::{Key, Modifiers}; +use egui::{Key, Modifiers, Rect, pos2, vec2}; use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; @@ -55,6 +55,19 @@ impl CropOverlay { } } + /// The icon the overlay menus and the options-bar button show (#10889), drawn in the house + /// icon style with the frame's crop marks around the guide it names. + pub fn icon(self) -> &'static str { + match self { + Self::Thirds => "crop-overlay-thirds", + Self::Grid => "crop-overlay-grid", + Self::Diagonal => "crop-overlay-diagonal", + Self::Triangle => "crop-overlay-triangle", + Self::GoldenRatio => "crop-overlay-golden-ratio", + Self::GoldenSpiral => "crop-overlay-golden-spiral", + } + } + pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|k| k.id() == id) } @@ -81,8 +94,10 @@ impl CropOverlay { #[serde(rename_all = "camelCase")] pub enum OverlayShow { /// Only while the crop box is being dragged (drawn, moved, resized or turned). - #[default] Auto, + /// The whole time a crop is pending, as the reference application starts out (its Reset Tool + /// brings back Always Show Overlay, #10894). + #[default] Always, Never, } @@ -234,15 +249,145 @@ fn golden_spiral() -> Polyline { pts.into_iter().map(|[x, y]| [(x / PHI).clamp(0.0, 1.0), y.clamp(0.0, 1.0)]).collect() } -/// The guides the pending crop box shows now: none when the options say not to. +/// Full-frame guides, or none when the options say not to. Canvas painting uses +/// [`current_guides_for_frame`] to restrict the turning grid to its viewport. pub fn current_guides(app: &PhotocraftApp, w: f32, h: f32) -> Vec { + current_guides_visible(app, w, h, Rect::from_min_size(egui::Pos2::ZERO, vec2(w, h))) +} + +/// Guides for the frame actually being painted. Use the caller's quad and clip: +/// canvas navigation may not have written its local camera back to UI state yet. +pub fn current_guides_for_frame(app: &PhotocraftApp, q: [egui::Pos2; 4], clip: Rect) -> Vec { + let (w, h) = (q[0].distance(q[1]), q[0].distance(q[3])); + let Some(visible) = visible_frame(q, clip, w, h) else { return Vec::new() }; + current_guides_visible(app, w, h, visible) +} + +fn current_guides_visible(app: &PhotocraftApp, w: f32, h: f32, visible: Rect) -> Vec { let o = &app.ui.tool_options; - if app.ui.tool != Tool::Crop || !o.crop_overlay_show.shows(app.crop.drag.is_some()) { + // A turn or a straighten line in progress shows its guides from the start, whether or not the + // frame has changed yet: they are how the gesture is aimed. Otherwise the overlay waits for + // the crop's first change (#10930). + let gesture = crate::crop_ui::turning(app).is_some() || app.crop.straighten.line.is_some(); + if app.ui.tool != Tool::Crop || !(crop_started(app) || gesture) || !o.crop_overlay_show.shows(app.crop.drag.is_some()) { return Vec::new(); } + // Classic Mode and Straighten were not measured: only the default mode's turn + // replaces the chosen composition overlay (#10931). + if !o.crop_shield.classic_mode && crate::crop_ui::turning(app).is_some() { + return turn_grid(w, h, visible); + } guides(o.crop_overlay, o.crop_overlay_orientation, w, h) } +/// Whether the crop has started: its frame was changed since it was made (it is no longer the +/// untouched default frame), or a drag is changing it now. Until then the reference application shows +/// only the frame, with no shield and no overlay in any show mode; both start with the first change +/// and stay until the crop is committed or cancelled (#10930). Classic Mode wasn't measured and +/// keeps the behaviour it had. +/// +/// Within a drag, the first change is what counts: a drag that changes the frame and brings it back +/// to where it started keeps the started look until it ends, rather than flickering back to the +/// untouched one (noted in review). Once it ends, `crop_ui::release` decides, as before. +pub fn crop_started(app: &PhotocraftApp) -> bool { + app.ui.tool_options.crop_shield.classic_mode || !app.crop.default_frame || (app.crop.drag.is_some() && app.crop.drag_moved) || drag_changed(app) +} + +/// Whether the drag in progress has changed the frame since its press. A press that hasn't moved +/// anything yet is not the first change, so it leaves the untouched look (#10930). The tests mirror +/// the ones `crop_ui` uses when a drag ends. +pub(crate) fn drag_changed(app: &PhotocraftApp) -> bool { + use crate::crop_ui::CropDrag; + match &app.crop.drag { + None => false, + Some(CropDrag::Draw { anchor, cur, .. }) => anchor != cur, + Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) => app.ui.crop_rect != Some(*rect), + Some(CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. }) => crate::crop_ui::angle(app) != *from, + } +} + +/// How the crop frame's outline and handles are drawn (#10930). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameLook { + /// Before the first change: a dashed outline, black on light grey like a selection's, and grey + /// handles. + Untouched, + /// Once the crop has started: a solid, thin, light outline and the same grey handles. + Started, + /// Classic Mode, which wasn't measured: the solid white outline and handles it always had. + Classic, +} + +/// The look of the pending crop frame. +pub fn frame_look(app: &PhotocraftApp) -> FrameLook { + if app.ui.tool_options.crop_shield.classic_mode { + FrameLook::Classic + } else if crop_started(app) { + FrameLook::Started + } else { + FrameLook::Untouched + } +} + +/// The canvas's visible extent in the crop frame's own screen-point axes. The +/// inverse basis also handles a flipped/rotated view and pixel aspect correction. +fn visible_frame(q: [egui::Pos2; 4], clip: Rect, w: f32, h: f32) -> Option { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 { + return None; + } + if !clip.is_finite() || !clip.is_positive() { + return None; + } + let (u, v) = (q[1] - q[0], q[3] - q[0]); + let det = f64::from(u.x) * f64::from(v.y) - f64::from(u.y) * f64::from(v.x); + if !det.is_finite() || det.abs() < f64::EPSILON { + return None; + } + let mut visible = Rect::NOTHING; + for p in [clip.left_top(), clip.right_top(), clip.right_bottom(), clip.left_bottom()] { + let d = p - q[0]; + let x = (f64::from(d.x) * f64::from(v.y) - f64::from(d.y) * f64::from(v.x)) / det * f64::from(w); + let y = (f64::from(u.x) * f64::from(d.y) - f64::from(u.y) * f64::from(d.x)) / det * f64::from(h); + if !(x.is_finite() && y.is_finite()) { + return None; + } + visible.extend_with(pos2(x.clamp(0.0, f64::from(w)) as f32, y.clamp(0.0, f64::from(h)) as f32)); + } + Some(visible) +} + +/// The temporary turning grid: 48 logical screen points between lines, centred +/// on the frame. `visible` is in the frame's screen-point axes, before mapping +/// back to its unit square. Generate only visible lines, however far the frame +/// is zoomed or panned, rather than truncating around its offscreen centre. +pub fn turn_grid(w: f32, h: f32, visible: Rect) -> Vec { + const SPACING: f64 = 48.0; + // Covers 196,608 logical points per axis; bound hostile viewport dimensions, + // not the size of the projected document or the grid's distance from centre. + const MAX_VISIBLE_LINES: usize = 4096; + if !(w.is_finite() && h.is_finite() && visible.is_finite()) || w < 1.0 || h < 1.0 { + return Vec::new(); + } + let visible = visible.intersect(Rect::from_min_size(egui::Pos2::ZERO, vec2(w, h))); + if !visible.is_positive() { + return Vec::new(); + } + let axis = |len: f32, lo: f32, hi: f32, vertical: bool| { + let len = f64::from(len); + let mid = len * 0.5; + // Keep the centre-based phase, but index directly into the visible range. + // Lines coincident with the frame outline need not be drawn twice. + let first = ((f64::from(lo) - mid) / SPACING).ceil().max((-mid / SPACING).floor() + 1.0); + let last = ((f64::from(hi) - mid) / SPACING).floor().min((mid / SPACING).ceil() - 1.0); + let count = (last - first + 1.0).clamp(0.0, MAX_VISIBLE_LINES as f64) as usize; + (0..count).map(move |i| { + let f = ((mid + (first + i as f64) * SPACING) / len) as f32; + if vertical { vec![[f, 0.0], [f, 1.0]] } else { vec![[0.0, f], [1.0, f]] } + }) + }; + axis(w, visible.min.x, visible.max.x, true).chain(axis(h, visible.min.y, visible.max.y, false)).collect() +} + /// O: the next overlay (the orientation starts over). pub fn cycle(o: &mut ToolOptions) { o.crop_overlay = o.crop_overlay.next(); @@ -283,32 +428,44 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// The options bar's overlay button and its menu (Photoshop's "Set the overlay options for the /// Crop tool"). pub fn options_button(o: &mut ToolOptions, ui: &mut egui::Ui) { - let r = - crate::icons::button(ui, "grid-3x3", 24.0, false, &format!("{} ({})", tl!("Set the overlay options for the Crop tool"), tl!(o.crop_overlay.label()))); + let r = crate::icons::button( + ui, + o.crop_overlay.icon(), + 24.0, + false, + &format!("{} ({})", tl!("Set the overlay options for the Crop tool"), tl!(o.crop_overlay.label())), + ); egui::Popup::menu(&r).show(|ui| { ui.set_min_width(220.0); - let item = |ui: &mut egui::Ui, on: bool, label: &str, key: Option<&str>| { - let mut b = egui::Button::new(format!("{} {}", if on { "✔" } else { " " }, label)); + // Check mark, then the entry's icon (the overlays have one, #10889), then the label. + let item = |ui: &mut egui::Ui, on: bool, icon: Option<&str>, label: &str, key: Option<&str>| { + let mut atoms = egui::Atoms::default(); + atoms.push_right(format!("{} ", if on { "✔" } else { " " })); + if let Some(name) = icon { + atoms.push_right(crate::icons::image(name, 14.0, crate::theme::Tokens::get(ui.ctx()).icon)); + } + atoms.push_right(label.to_owned()); + let mut b = egui::Button::new(atoms); if let Some(k) = key { b = b.shortcut_text(crate::shortcuts::pretty(k)); } ui.add(b).clicked() }; for k in CropOverlay::ALL { - if item(ui, o.crop_overlay == k, tl!(k.label()), None) { + if item(ui, o.crop_overlay == k, Some(k.icon()), tl!(k.label()), None) { set_overlay(o, k); ui.close(); } } ui.separator(); for s in OverlayShow::ALL { - if item(ui, o.crop_overlay_show == s, tl!(s.label()), None) { + if item(ui, o.crop_overlay_show == s, None, tl!(s.label()), None) { o.crop_overlay_show = s; ui.close(); } } ui.separator(); - if item(ui, false, tl!("Cycle Overlay"), Some("O")) { + if item(ui, false, None, tl!("Cycle Overlay"), Some("O")) { cycle(o); ui.close(); } @@ -438,7 +595,7 @@ mod tests { #[test] fn cycling_overlays_and_orientations() { let mut o = ToolOptions::default(); - assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Auto, 0)); + assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Always, 0)); let mut seen = vec![o.crop_overlay]; for _ in 0..6 { cycle(&mut o); @@ -539,6 +696,8 @@ mod tests { #[test] fn the_overlay_shows_by_mode_and_gesture() { let mut app = crop_app(); + // Auto is no longer the default (#10894): set it for the Auto half of this test. + app.ui.tool_options.crop_overlay_show = OverlayShow::Auto; assert!(current_guides(&app, 300.0, 200.0).is_empty(), "Auto: no guides without a drag"); app.crop.drag = Some(crate::crop_ui::CropDrag::Move { start: [0.0, 0.0], rect: [0.0, 0.0, 10.0, 10.0] }); assert_eq!(current_guides(&app, 300.0, 200.0).len(), 4, "Auto: the thirds while dragging"); @@ -547,6 +706,9 @@ mod tests { app.crop.drag = None; app.ui.tool_options.crop_overlay_show = OverlayShow::Always; app.ui.tool_options.crop_overlay = CropOverlay::Triangle; + assert!(current_guides(&app, 300.0, 200.0).is_empty(), "Always: still nothing on the untouched frame (#10930)"); + // The frame has been changed, so the crop is pending: Always keeps showing after release. + app.crop.default_frame = false; assert_eq!(current_guides(&app, 300.0, 200.0).len(), 3); app.ui.tool = Tool::Brush; assert!(current_guides(&app, 300.0, 200.0).is_empty(), "a pending frame under another tool shows no guide"); @@ -555,7 +717,7 @@ mod tests { #[test] fn old_state_files_get_photoshops_defaults() { let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap(); - assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Auto, 0)); + assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Always, 0)); let o: ToolOptions = serde_json::from_str(r#"{"crop_overlay": "goldenSpiral", "crop_overlay_show": "always", "crop_overlay_orientation": 3}"#).unwrap(); assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::GoldenSpiral, OverlayShow::Always, 3)); } diff --git a/crates/ui-egui/src/tools/crop_presets.rs b/crates/ui-egui/src/tools/crop_presets.rs index 85077aea88..a68f577425 100644 --- a/crates/ui-egui/src/tools/crop_presets.rs +++ b/crates/ui-egui/src/tools/crop_presets.rs @@ -37,6 +37,9 @@ const KEYS: [&str; 5] = ["crop_ratio", "crop_width", "crop_height", "crop_resolu /// One entry of the preset dropdown (and of the Crop tool's canvas menu). #[derive(Clone, Debug, PartialEq)] pub(crate) enum Choice { + /// The temporary "Selection" entry (#10872): the untouched frame came from a selection's + /// bounds. Picking it changes nothing; it labels the frame the crop is still showing. + Selection, /// A built-in key (`crop_ratio` value, size preset or Front Image), see [`crop_size::chosen`]. Builtin(String), /// A saved crop preset, by name. @@ -132,13 +135,24 @@ fn button_label(o: &ToolOptions, builtin: &[(String, &'static str)], users: &[(S tl!(label).to_string() } +/// Whether the preset dropdown shows its temporary "Selection" entry (#10872): the frame is the +/// untouched default one and it came from a selection's bounds. Editing the frame clears +/// `default_frame`, so the entry goes away; Photoshop shows it checked while it stands. +pub(crate) fn shows_selection(app: &PhotocraftApp) -> bool { + app.crop.default_frame && app.crop.frame_from_selection +} + /// The options bar's preset dropdown: the built-in entries ([`crop_size::dropdown_options`]), the /// saved crop presets before Front Image, then New Crop Preset… and Delete Crop Preset…. pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let builtin = crop_size::dropdown_options(); let users = user_presets(app); + // The temporary "Selection" entry stands above Ratio while the untouched frame is the + // selection's and no ratio presets it (#10872); a picked ratio takes the check mark instead. + let selection_entry = shows_selection(app) && app.ui.tool_options.crop_ratio.is_empty(); let o = &app.ui.tool_options; - let label = button_label(o, &builtin, &users); + // The closed control names the entry that is checked: "Selection" while it stands. + let label = if selection_entry { tl!("Selection").to_string() } else { button_label(o, &builtin, &users) }; let current_user = current_user(app); let can_new = savable(o).is_some(); let mut chosen: Option = None; @@ -146,14 +160,17 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let combo = craft_ui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| { crate::press_menu::above_parent_ui(parent_layer, ui); + if selection_entry && ui.selectable_label(true, tl!("Selection")).clicked() { + chosen = Some(Choice::Selection); + } let (before, after): (Vec<_>, Vec<_>) = builtin.iter().partition(|(k, _)| k != crop_size::FRONT); for (k, l) in &before { - if ui.selectable_label(current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() { + if ui.selectable_label(!selection_entry && current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() { chosen = Some(Choice::Builtin(k.clone())); } } for (name, _) in &users { - if ui.selectable_label(current_user.as_deref() == Some(name), name.as_str()).clicked() { + if ui.selectable_label(!selection_entry && current_user.as_deref() == Some(name), name.as_str()).clicked() { chosen = Some(Choice::User(name.clone())); } } @@ -179,6 +196,13 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// What picking a dropdown entry does; the Crop tool's canvas menu picks through it too. pub(crate) fn apply_choice(app: &mut PhotocraftApp, choice: Choice) { match choice { + // Measured in the reference application (#10872): choosing it keeps the frame but makes + // it a live crop with free Ratio, as an edit does, so the entry goes away. + Choice::Selection => { + app.ui.tool_options.crop_ratio.clear(); + crop_size::select_custom_ratio(&mut app.ui.tool_options); + crate::crop_ui::take_selection_frame(app); + } Choice::Builtin(k) => { app.ui.tool_options.crop_ratio = k; // A size preset or Front Image fills W x H x Resolution (#2443). @@ -552,6 +576,8 @@ mod tests { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut app = app(); whr(&mut app, "", "", "", PX_PER_IN); + // Only the options bar's dropdown: the contextual task bar shows the same one (#10890). + app.ui.panels.context_task_bar = false; app }); h.run_steps(4); diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index 4ffaf19062..d652ebf9d4 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -112,7 +112,13 @@ pub fn canvas_color(app: &PhotocraftApp, ctx: &egui::Context) -> Color32 { /// The shield over the pending crop box now ([`fill`]). pub fn current(app: &PhotocraftApp, ctx: &egui::Context) -> Option { - fill(&app.ui.tool_options.crop_shield, canvas_color(app, ctx), app.crop.drag.is_some()) + let s = &app.ui.tool_options.crop_shield; + // No shield before the crop has started: an untouched frame shows only itself (#10930). + // Show Cropped Area off still hides everything outside the frame, as it does once started. + if s.shows_cropped_area() && !crate::crop_overlay::crop_started(app) { + return None; + } + fill(s, canvas_color(app, ctx), app.crop.drag.is_some()) } /// Show Cropped Area is off with the Crop tool showing a box: nothing outside it shows. @@ -120,11 +126,13 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && !app.ui.tool_options.crop_shield.shows_cropped_area() } -/// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in +/// H with a crop in progress (a frame drawn or changed) and no drag toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic -/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. +/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's, +/// also over the untouched default frame the tool shows when picked or after a commit (#6536, +/// #9731). pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { + if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.default_frame || app.crop.drag.is_some() { return false; } if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { @@ -311,6 +319,8 @@ mod tests { #[test] fn match_canvas_is_the_black_full_screen_pasteboard() { let mut app = crop_app(); + // A shield shows only once the crop has started (#10930); this test is about its colour. + app.crop.default_frame = false; let ctx = egui::Context::default(); let theme_canvas = crate::theme::Tokens::get(&ctx).canvas; assert_ne!(theme_canvas, Color32::BLACK); @@ -335,8 +345,10 @@ mod tests { } #[test] - fn h_toggles_show_cropped_area_while_the_crop_tool_shows_a_box() { + fn h_toggles_show_cropped_area_while_a_crop_is_in_progress() { let mut app = crop_app(); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; assert!(!hides_outside(&app)); press_h(&mut app); assert_eq!(app.ui.tool, Tool::Crop, "H doesn't switch to the Hand tool"); @@ -354,6 +366,12 @@ mod tests { press_h(&mut app); assert_eq!(app.ui.tool, Tool::Hand); assert!(app.ui.tool_options.crop_shield.show_cropped_area); + // The untouched default frame (#9731). + let mut app = crop_app(); + assert!(app.crop.default_frame); + press_h(&mut app); + assert_eq!(app.ui.tool, Tool::Hand); + assert!(app.ui.tool_options.crop_shield.show_cropped_area); // Another tool. let mut app = crop_app(); app.ui.tool = Tool::Brush; diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index ee1b939e15..3709ec6767 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -232,6 +232,8 @@ pub fn clear_ratio(o: &mut ToolOptions) { clear(o); } else { o.crop_ratio.clear(); + o.crop_custom_ratio_width = 0.0; + o.crop_custom_ratio_height = 0.0; } } @@ -313,11 +315,15 @@ fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width } /// The W x H x Resolution fields of the options bar: W, ⇄, H, the resolution and its unit. -pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { +/// Returns true when ⇄ swapped W and H, so the Crop tool turns its frame with them. +pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) -> bool { text_field(ui, "crop-width", &mut o.crop_width, tl!("Width"), 64.0, normalize_length); - if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { + // Always swaps (Perspective Crop shows the fields without the mode); true in W x H x Resolution + // mode, where the Crop tool turns its frame with W and H. + let swapped = crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() && { swap_fields(o); - } + o.crop_ratio == WHR + }; text_field(ui, "crop-height", &mut o.crop_height, tl!("Height"), 64.0, normalize_length); text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution); let units = [(PX_PER_IN.to_string(), "px/in"), (PX_PER_CM.to_string(), "px/cm")]; @@ -329,6 +335,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { o.crop_resolution = crate::widgets::fmt_num2(v); } } + swapped } #[cfg(test)] @@ -385,6 +392,26 @@ mod tests { assert_eq!(width / height, 4.0 / 3.0); } + #[test] + fn clear_ratio_discards_complete_and_partial_ratio_fields() { + for (width, height) in [(16.0, 9.0), (4.0, 0.0), (0.0, 3.0)] { + let mut options = ToolOptions::default(); + set_custom_ratio_fields(&mut options, width, height); + clear_ratio(&mut options); + assert!(options.crop_ratio.is_empty()); + assert_eq!(custom_ratio_fields(&options, (300.0, 200.0)), (0.0, 0.0)); + set_custom_ratio_fields(&mut options, 5.0, 0.0); + assert!(options.crop_ratio.is_empty(), "entering a new width must not reuse the old height"); + assert_eq!(custom_ratio_fields(&options, (300.0, 200.0)), (5.0, 0.0)); + } + let mut options = whr("4 in", "6 in", "300", PX_PER_IN); + clear_ratio(&mut options); + assert_eq!( + (options.crop_ratio.as_str(), options.crop_width.as_str(), options.crop_height.as_str(), options.crop_resolution.as_str()), + (WHR, "", "", "") + ); + } + #[test] fn custom_ratio_keeps_three_decimals() { // #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop. diff --git a/crates/ui-egui/src/tools/crop_straighten.rs b/crates/ui-egui/src/tools/crop_straighten.rs index 5f663f2e6b..939953fd02 100644 --- a/crates/ui-egui/src/tools/crop_straighten.rs +++ b/crates/ui-egui/src/tools/crop_straighten.rs @@ -8,11 +8,13 @@ //! - Holding ⌘ (Ctrl off the Mac) with the Crop tool is a temporary Straighten: a ⌘-drag draws the //! line; releasing ⌘ gives the normal crop gestures back. //! -//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle -//! and fits the frame to the image ([`fitted`]); in Classic Mode the frame then shows turned over -//! the image, in the default mode the image turns behind the upright box (`crop_mode`). It is only a pending frame edit: -//! ↵ commits it and Esc cancels it as any crop. A line shorter than [`MIN_LINE_PX`] screen points -//! does nothing. +//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle. +//! The button's line also fits the frame to the image ([`fitted`]); a ⌘-held line keeps the +//! frame's size and position, so ↵ fills the corners the turn uncovers, as Photoshop does +//! (measured: an 800 × 500 frame stays 800 × 500, #10870). In Classic Mode the frame then shows +//! turned over the image, in the default mode the image turns behind the upright box +//! (`crop_mode`). It is only a pending frame edit: ↵ commits it and Esc cancels it as any crop. A +//! line shorter than [`MIN_LINE_PX`] screen points does nothing. use egui::{Modifiers, Pos2}; @@ -87,10 +89,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => { let Some([a, _]) = app.crop.straighten.line.take() else { return false }; - // One line per click of the button. - app.crop.straighten.armed = false; + // One line per click of the button. Only the button's line fits the frame (also with + // ⌘ held while it is armed); a ⌘ line keeps the frame's size, even when ⌘ is let go + // before the mouse button. + let fit = std::mem::take(&mut app.crop.straighten.armed); let zoom = f64::from(app.point_zoom()); - apply(app, a, [x, y], zoom); + apply(app, a, [x, y], zoom, fit); true } } @@ -129,15 +133,23 @@ pub fn fitted(w: f64, h: f64, cw: f64, ch: f64, deg: f64) -> Option<[f64; 4]> { Some([cx - hw, cy - hh, cx + hw, cy + hh]) } -/// The line from `a` to `b` (document px, at `zoom` screen points per px) straightens the frame. -fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64) { +/// The line from `a` to `b` (document px, at `zoom` screen points per px) straightens the frame, +/// fitting it to the image when `fit` (the options-bar button) and keeping its size and position +/// otherwise (⌘ held). +fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64, fit: bool) { let min_len = if zoom.is_finite() && zoom > 0.0 { MIN_LINE_PX / zoom } else { MIN_LINE_PX }; let Some(deg) = frame_angle(a, b, min_len) else { return }; let Some(size) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; - // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame - // takes the canvas's. - let (w, h) = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())).map_or(size, |r| (r[2] - r[0], r[3] - r[1])); - let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; + let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())); + let r = if fit { + // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame + // takes the canvas's. + let (w, h) = frame.map_or(size, |r| (r[2] - r[0], r[3] - r[1])); + let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; + r + } else { + frame.unwrap_or([0.0, 0.0, size.0, size.1]) + }; if app.ui.crop_rect == Some(r) && crate::crop_ui::angle(app) == deg { return; } diff --git a/crates/ui-egui/src/tools/crop_task_bar.rs b/crates/ui-egui/src/tools/crop_task_bar.rs new file mode 100644 index 0000000000..728cd69422 --- /dev/null +++ b/crates/ui-egui/src/tools/crop_task_bar.rs @@ -0,0 +1,86 @@ +//! The Crop tool's contextual task bar (#10890): what the Crop tool puts in the reusable bar of +//! `widgets::task_bar`, under the crop frame, as in the reference application. Left to right: +//! +//! 1. the grip (the widget's); +//! 2. *Generative Expand goes here* once generative fill comes through the shared AI layer (#41, +//! #1199); until then it is left out, not shown disabled; +//! 3. the preset dropdown: the options bar's own (`crop_presets::dropdown`), so the two stay in +//! sync; +//! 4. Straighten: the options bar's Straighten button; +//! 5. the ⋯ menu (the widget's); +//! 6. while a crop is pending ([`crop_ui::pending`]): Cancel and Done, which the options bar's +//! cancel and commit buttons do. +//! +//! Shown while the Crop tool shows a frame on the active document, Window › Contextual Task Bar +//! is ticked and no dialog is open. + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::crop_ui; +use crate::state::Tool; +use crate::widgets::task_bar; + +/// The Straighten icon. TODO(#10888): the spirit-level glyph once that options-bar item lands. +const STRAIGHTEN_ICON: &str = "ruler"; + +/// The crop frame on screen: the bounds of its (possibly turned) corners. +fn frame_on_screen(app: &PhotocraftApp, xf: &ViewXform) -> Option { + let r = app.ui.crop_rect?; + let pts = crop_ui::corners(r, app.ui.crop_angle).map(|[x, y]| xf.to_screen(x as f32, y as f32)); + Some(egui::Rect::from_points(&pts)) +} + +/// The bar shows: the Crop tool with a frame, the Window toggle on, no dialog over the canvas. +fn wanted(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop && app.ui.panels.context_task_bar && app.ui.dialogs.is_empty() && app.session.active().is_some() +} + +/// Whether the bar can show now: [`wanted`] and a crop frame. `ui.inspect` checks it, so a bar +/// hidden by `ui.set` before the next draw no longer reports last frame's rect. +pub fn eligible(app: &PhotocraftApp) -> bool { + wanted(app) && app.ui.crop_rect.is_some() +} + +/// Draws the bar under the active document's crop frame (the canvas's overlay pass calls it on +/// that document's tile). +pub fn draw(app: &mut PhotocraftApp, ui: &mut egui::Ui, xf: &ViewXform) { + if !wanted(app) { + return; + } + let Some(anchor) = frame_on_screen(app, xf) else { return }; + let ctx = ui.ctx().clone(); + let mut place = app.ui.panels.task_bar; + let shown = task_bar::show(&ctx, &mut place, anchor, xf.rect, |ui, more| contents(app, ui, more)); + app.ui.panels.task_bar = place; + let Some(shown) = shown else { return }; + if shown.hide { + app.ui.panels.context_task_bar = false; + return; + } + let r = shown.rect; + app.ui.task_bar_shown = Some([r.min.x, r.min.y, r.max.x, r.max.y]); +} + +/// The Crop tool's controls in the bar. +fn contents(app: &mut PhotocraftApp, ui: &mut egui::Ui, more: &mut task_bar::MoreMenu) { + // Generative Expand (#41) goes here, before the preset dropdown. + let before_ratio = crop_ui::preset_ratio(app); + // Its own id: the options bar shows the same dropdown. + ui.push_id("crop-task-bar", |ui| crate::crop_presets::dropdown(app, ui)); + if crop_ui::preset_ratio(app) != before_ratio { + crop_ui::apply_ratio(app); + } + let armed = app.crop.straighten.armed; + if crate::icons::button_with_icon_size_tip(ui, STRAIGHTEN_ICON, task_bar::BUTTON, 14.0, armed, tl!("Straighten"), false).clicked() { + crate::crop_straighten::toggle(app); + } + more.show(ui); + if crop_ui::pending(app) { + if ui.button(tl!("Cancel")).clicked() { + crop_ui::dismiss(app); + } + if ui.button(tl!("Done")).clicked() { + crate::canvas::commit_crop(app); + } + } +} diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 003debc441..fc6326769f 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -49,10 +49,24 @@ pub struct CropState { /// `UiState::crop_rect` is the untouched default frame ([`ensure_frame`]): a drag inside it /// draws a new frame (as in Photoshop) instead of moving it, and committing it does nothing. pub default_frame: bool, + /// That untouched default frame came from a selection's bounds (not the whole canvas): the + /// preset dropdown names it "Selection" until the frame is edited (#10872). + pub frame_from_selection: bool, /// The document (index and size) the default frame was made for. frame_for: Option<(usize, u32, u32)>, + /// The document (id and size) whose frame [`hide_frame`] hid: no default frame is made for it + /// while this matches. Keyed by id, not tab index, so closing it never hides the frame of the + /// tab that takes its place. + dismissed: Option<(photocraft_doc::DocId, u32, u32)>, + /// The pending frame [`reset`] left when a W x H x Resolution preset kept it a crop: Reset + /// stays disabled while the frame is still that one. + reset_to: Option<[f64; 4]>, /// The frame is being edited (pressed since it was made): the canvas shows what lies past it. pub editing: bool, + /// The drag in progress has changed the frame at some point, even if it has since come back to + /// where it started: the crop has started, and its look stays for the rest of the drag + /// (`crop_overlay::crop_started`, #10930). + pub drag_moved: bool, /// The document the pending frame (or gesture) belongs to ([`cancel_stale`], #1918). doc: Option, /// The options-bar Straighten button and ⌘-drag (`crop_straighten`). @@ -71,6 +85,10 @@ struct CropStep { rect: Option<[f64; 4]>, angle: f64, default_frame: bool, + /// That default frame came from a selection's bounds ([`CropState::frame_from_selection`]). + frame_from_selection: bool, + /// The canvas shows the pixels past the frame ([`shows_beyond_canvas`]). + editing: bool, } /// A crop gesture in progress. Rects are `[x0, y0, x1, y1]` in document coordinates. @@ -94,8 +112,9 @@ pub enum CropDrag { MoveImage { start: [f64; 2], rect: [f64; 4], view: Option }, /// Default mode, outside the box: the image turns about the frame's `center` (which stays put /// on screen). `grab` is the pointer's direction from it at the press (radians, in the press - /// `view`), `from` the frame's angle then (degrees). - TurnImage { center: [f64; 2], grab: f64, from: f64, view: Option }, + /// `view`), `from` the frame's angle then (degrees) and `rect` the frame then, which the turn + /// keeps inside the turned image (`crop_mode::keep_inside`). + TurnImage { center: [f64; 2], grab: f64, from: f64, rect: [f64; 4], view: Option }, } /// What the pointer is over, relative to a crop frame. @@ -268,7 +287,20 @@ pub(crate) fn apply_ratio(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() { return; } + // A ratio or size chosen after a commit fits a frame, not nothing. + show_frame(app); frame(app); + // On the untouched frame of a selection's bounds, a picked ratio drops it for the largest + // centred frame of that ratio on the canvas, as measured in the reference application (#10872). + if app.crop.default_frame + && app.crop.frame_from_selection + && preset_ratio(app).is_some() + && let Some(b) = app.session.active().map(|st| st.doc.bounds()) + && !b.is_empty() + { + app.ui.crop_rect = Some([f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)]); + app.crop.frame_from_selection = false; + } fit_frame(app); crate::crop_mode::sync_frame(app); } @@ -304,6 +336,9 @@ fn fit_frame(app: &mut PhotocraftApp) { // stays a pending crop even on the default frame. if fitted != r && !sized && app.crop.default_frame { app.ui.crop_rect = Some(fitted); + // The frame is no longer the selection's bounds, so the temporary "Selection" entry + // must not come back when the ratio is cleared again (#10872). + app.crop.frame_from_selection = false; } else { keyed(app, fitted); } @@ -363,6 +398,14 @@ pub fn set_space(app: &mut PhotocraftApp, down: bool) { app.crop.space = down; } +/// The options bar's Fill entry (#10872): what a turned or extending crop puts where the image +/// doesn't reach. With Delete Cropped Pixels on that is the background colour; with it off it is +/// transparency, as in Photoshop. Both are the stored `false` of `crop_content_aware`; only the +/// label changes. +pub fn fill_label(delete_cropped_pixels: bool) -> &'static str { + if delete_cropped_pixels { tl!("Background (default)") } else { tl!("Transparent (default)") } +} + /// As in Photoshop, the Crop tool always shows a frame: on picking the tool, and after a crop is /// cancelled or committed, it frames the selection's bounds, or the whole canvas. In default mode /// the view then follows the frame being edited (`crop_mode::sync`). @@ -371,6 +414,19 @@ pub fn ensure_frame(app: &mut PhotocraftApp) { crate::crop_mode::sync(app, None); } +/// The frame a new crop starts from: the selection's bounds, or the whole canvas. +/// The default frame: the selection's bounds, or the whole canvas; and whether it is the +/// selection's (the preset dropdown's temporary "Selection" entry, #10872). +fn default_rect(app: &PhotocraftApp) -> Option<([f64; 4], bool)> { + let st = app.session.active()?; + let canvas = st.doc.bounds(); + // Computed once per new frame, not per frame drawn. + let selection = st.doc.selection.as_ref().map(|s| s.content_bounds().intersect(&canvas)).filter(|b| !b.is_empty()); + let from_selection = selection.is_some(); + let r = selection.unwrap_or(canvas); + (!r.is_empty()).then(|| ([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)], from_selection)) +} + fn frame(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop { app.crop.editing = false; @@ -380,6 +436,7 @@ fn frame(app: &mut PhotocraftApp) { if app.crop.default_frame { app.ui.crop_rect = None; app.crop.default_frame = false; + app.crop.frame_from_selection = false; } if app.ui.crop_rect.is_some() && !keeps_pending(app) { commit_on_leave(app); @@ -401,15 +458,15 @@ fn frame(app: &mut PhotocraftApp) { if app.ui.crop_rect.is_some() || app.crop.drag.is_some() { return; } - let Some(st) = app.session.active() else { return }; - let canvas = st.doc.bounds(); - // Computed once per new frame, not per frame drawn. - let r = st.doc.selection.as_ref().map(|s| s.content_bounds().intersect(&canvas)).filter(|b| !b.is_empty()).unwrap_or(canvas); - if r.is_empty() { + // A committed crop or Esc hid the frame: it stays hidden for this document until the next press. + if app.crop.dismissed.is_some() && app.crop.dismissed == dismiss_key(app) { return; } - app.ui.crop_rect = Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]); + app.crop.dismissed = None; + let Some((r, from_selection)) = default_rect(app) else { return }; + app.ui.crop_rect = Some(r); app.crop.default_frame = true; + app.crop.frame_from_selection = from_selection; app.crop.frame_for = key; app.crop.editing = false; app.crop.undo.clear(); @@ -436,6 +493,9 @@ fn commit_on_leave(app: &mut PhotocraftApp) { cancel(app); } app.ui.tool = tool; + // Committed by leaving the tool: back on it the default frame shows at once, as in Photoshop + // (only a commit with the Crop tool still picked hides it). + app.crop.dismissed = None; } /// A crop gesture is in progress: Space repositions the frame rather than panning. @@ -443,6 +503,29 @@ pub fn active(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_some() } +/// A drag that sizes or moves the frame over the image, which scrolls the view when held past the +/// canvas edge (#4183). Not the turns, nor the default mode's image moves and live-centred resizes: +/// those drive the camera themselves. A new frame's drag that hasn't left the size of a click (a +/// long press that slipped past the edge) doesn't either: scrolling would draw a frame under the +/// still pointer. +pub fn autoscrolls(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop + && match app.crop.drag { + Some(CropDrag::Draw { anchor, cur, .. }) => !is_click(app, anchor, cur), + Some(CropDrag::Move { .. } | CropDrag::Resize { view: None, .. }) => true, + _ => false, + } +} + +/// A new-frame drag shorter than this (screen points) on both axes is still a click. +const CLICK_PX: f64 = 3.0; + +/// Document points `a` and `b` lie within [`CLICK_PX`] screen points of each other on both axes. +fn is_click(app: &PhotocraftApp, a: [f64; 2], b: [f64; 2]) -> bool { + let click = CLICK_PX / (app.point_zoom() as f64).max(0.01); + (b[0] - a[0]).abs() <= click && (b[1] - a[1]).abs() <= click +} + /// Cancel the pending crop: the frame, any gesture on it and the default-frame state. With the /// Crop tool still picked, [`ensure_frame`] then makes the active document a new default frame. pub fn cancel(app: &mut PhotocraftApp) { @@ -457,6 +540,76 @@ pub fn cancel(app: &mut PhotocraftApp) { crate::crop_straighten::reset(app); } +/// After a committed crop, with the Crop tool picked: no new default frame for the cropped +/// document until a press on the canvas (or a ratio), so its corners don't make the applied crop +/// look still pending. Picking the tool again, also after another tool, keeps it hidden, as in +/// Photoshop. +pub fn hide_frame(app: &mut PhotocraftApp) { + if app.ui.tool == Tool::Crop { + app.crop.dismissed = dismiss_key(app); + } +} + +/// Ends [`hide_frame`] and makes the default frame now, so what follows acts on it as on any +/// default frame: a press records it as the box's Undo step, a ratio fits it. +pub(crate) fn show_frame(app: &mut PhotocraftApp) { + if app.crop.dismissed.take().is_some() { + frame(app); + } +} + +/// Esc or the options bar's cancel: [`cancel`], then [`hide_frame`], so the frame disappears as in +/// Photoshop (#10370) and stays hidden as after a commit. +pub fn dismiss(app: &mut PhotocraftApp) { + cancel(app); + hide_frame(app); +} + +/// The active document's identity for [`CropState::dismissed`]. +fn dismiss_key(app: &PhotocraftApp) -> Option<(photocraft_doc::DocId, u32, u32)> { + app.session.active().map(|st| (st.doc.id, st.doc.size.width, st.doc.size.height)) +} + +/// The frame was drawn, moved, resized or turned since it was last framed by default, so +/// [`reset`] has something to undo. +pub fn modified(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop + && app.ui.crop_rect.is_some() + && app.crop.drag.is_none() + && (app.ui.crop_angle != 0.0 || (!app.crop.default_frame && app.ui.crop_rect != app.crop.reset_to)) +} + +/// Reset the crop box (the options bar's Reset): the default frame again, upright, with the +/// straighten line gone. The tool stays up and the old frame is a box Undo step, unlike +/// [`cancel`], which drops the box's history. +pub fn reset(app: &mut PhotocraftApp) { + if !modified(app) { + return; + } + let Some((rect, from_selection)) = default_rect(app) else { return }; + let before = step_of(app); + app.ui.crop_rect = Some(rect); + app.ui.crop_angle = 0.0; + app.crop.default_frame = true; + app.crop.frame_from_selection = from_selection; + app.crop.frame_for = app.session.active_index().zip(app.session.active()).map(|(i, st)| (i, st.doc.size.width, st.doc.size.height)); + app.crop.editing = false; + app.crop.press_step = None; + claim(app); + crate::crop_straighten::reset(app); + // As a new default frame is: held to the ratio preset (or output size) picked in the bar. + fit_frame(app); + // A W x H x Resolution preset keeps the reset frame a pending crop (`fit_frame`), so remember + // it: Reset has nothing more to do until it changes. + app.crop.reset_to = app.ui.crop_rect.filter(|_| !app.crop.default_frame); + if step_of(app) != before { + app.crop.undo.push(before); + app.crop.redo.clear(); + } + crate::crop_mode::sync_frame(app); + crate::crop_mode::auto_center(app); +} + /// Drop the box's own Undo/Redo (leaving the tool, cancelling or committing a crop). pub fn clear_history(app: &mut PhotocraftApp) { app.crop.undo.clear(); @@ -466,7 +619,13 @@ pub fn clear_history(app: &mut PhotocraftApp) { /// The frame's current state, as an undo step. fn step_of(app: &PhotocraftApp) -> CropStep { - CropStep { rect: app.ui.crop_rect, angle: app.ui.crop_angle, default_frame: app.crop.default_frame } + CropStep { + rect: app.ui.crop_rect, + angle: app.ui.crop_angle, + default_frame: app.crop.default_frame, + frame_from_selection: app.crop.frame_from_selection, + editing: app.crop.editing, + } } /// Put a frame state back. @@ -474,6 +633,8 @@ fn apply_step(app: &mut PhotocraftApp, s: CropStep) { app.ui.crop_rect = s.rect; app.ui.crop_angle = s.angle; app.crop.default_frame = s.default_frame; + app.crop.frame_from_selection = s.frame_from_selection; + app.crop.editing = s.editing; } /// The pending frame, its box Undo/Redo and the view's turn, kept while `image.crop` runs so a @@ -519,6 +680,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Intercept Undo/Redo/Toggle Last State while the Crop tool owns box history, for every caller /// (menus, shortcuts, the control channel, MCP) — as `transform_tool::intercept` does. pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { + if holds_history(app, id) { + return Some(Err("Commit or cancel the crop first".into())); + } if app.ui.tool != Tool::Crop || (app.crop.undo.is_empty() && app.crop.redo.is_empty()) { return None; } @@ -530,6 +694,13 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option bool { + pending(app) && matches!(id, "history.selectState" | "history.selectSnapshot" | "history.stepBackward" | "history.stepForward" | "history.deleteState") +} + /// Undo (or redo) one box step. fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value { let s = if redo { app.crop.redo.pop() } else { app.crop.undo.pop() }; @@ -616,6 +787,16 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { let pending = app.ui.crop_rect.is_some() || app.crop.drag.is_some(); // A frame made before any document was claimed (control channel, tests) is the active one's. if pending && app.crop.doc.is_some() && app.crop.doc != active { + // Default mode turned the frame's own view, which `cancel` doesn't reach: it turns the + // active one upright, and `ui.views` is still in the order of `view_docs` here (#9202). + if !crate::crop_mode::classic(app) { + let doc = app.crop.doc; + let synced = app.view_docs.iter().position(|d| Some(*d) == doc); + let i = synced.or_else(|| app.session.documents().iter().position(|st| Some(st.doc.id) == doc)); + if let Some(v) = i.and_then(|i| app.ui.views.get_mut(i)) { + v.rotation = 0.0; + } + } cancel(app); } app.crop.doc = active; @@ -690,12 +871,30 @@ pub fn sizing(app: &PhotocraftApp) -> Option<[f64; 2]> { frame_size(app.ui.crop_rect?) } +/// The Info panel's W and H while the Crop tool shows a frame: the frame's size (its own, before +/// any turn), as Photoshop's Info panel shows it, so its ratio can be matched (#3145). `None` +/// with another tool or no frame, and the panel shows the selection's size. +pub fn info_size(app: &PhotocraftApp) -> Option<[f64; 2]> { + if app.ui.tool != Tool::Crop { + return None; + } + frame_size(app.ui.crop_rect?) +} + /// The Crop tool owns the arrow keys and X: a pending frame and no gesture in progress. pub fn takes_keys(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_none() && app.ui.transform.is_none() && app.ui.crop_rect.is_some_and(|r| r.iter().all(|v| v.is_finite())) } /// The frame now set by the keys: a real frame (no longer the default), being edited. +/// The dropdown's temporary "Selection" entry chosen (#10872): the untouched frame on the +/// selection's bounds becomes a live crop, unchanged. +pub(crate) fn take_selection_frame(app: &mut PhotocraftApp) { + if let Some(r) = app.ui.crop_rect.filter(|_| app.crop.default_frame && app.crop.frame_from_selection) { + keyed(app, r); + } +} + fn keyed(app: &mut PhotocraftApp, r: [f64; 4]) { if r.iter().all(|v| v.is_finite()) { app.ui.crop_rect = Some(r); @@ -732,6 +931,18 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] { [c[0] - hh, c[1] - hw, c[0] + hh, c[1] + hw] } +/// The options bar's ⇄ with a ratio set: the frame turns with the ratio about its centre, as X +/// does, so fitting the swapped ratio afterwards doesn't shrink it (#7738). +pub(crate) fn turn_frame(app: &mut PhotocraftApp) { + // A square frame doesn't change, so an untouched default frame stays one (and isn't + // committed on leaving the tool). + if let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app) && preset_ratio(app).is_some()) + && swapped(r) != r + { + keyed(app, swapped(r)); + } +} + /// The options-bar ratio `key` with its sides swapped (what the bar's ⇄ button does), `None` when /// no ratio is set; "original" becomes the document's size turned on its side. pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option { @@ -751,8 +962,11 @@ pub fn swap_orientation(app: &mut PhotocraftApp) -> bool { { app.ui.tool_options.crop_ratio = k; } - // The frame turns first: fitting the swapped ratio inside the old frame would shrink it. - keyed(app, swapped(r)); + // The frame turns first: fitting the swapped ratio inside the old frame would shrink it. A + // square frame doesn't change, so an untouched default frame stays one. + if swapped(r) != r { + keyed(app, swapped(r)); + } if preset_ratio(app).is_some() { apply_ratio(app); } else { @@ -785,7 +999,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// Pointer input for the Crop tool. Returns true when the event was its. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if app.ui.tool != Tool::Crop { - app.crop.drag = None; + end_drag(app); return false; } let p = match ev { @@ -803,10 +1017,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// Press, drag and release of the frame. Returns true when the frame needs syncing to the view. fn track(app: &mut PhotocraftApp, ev: ToolEvent, p: [f64; 2], mods: Modifiers) -> bool { match ev { - ToolEvent::Down { .. } => begin_drag(app, p), - ToolEvent::Move { .. } => update(app, p, mods), + ToolEvent::Down { .. } => { + app.crop.drag_moved = false; + begin_drag(app, p); + } + ToolEvent::Move { .. } => { + update(app, p, mods); + app.crop.drag_moved |= crate::crop_overlay::drag_changed(app); + } ToolEvent::Up { .. } => { update(app, p, mods); + // The release's own position counts toward whether the gesture changed the frame. + app.crop.drag_moved |= crate::crop_overlay::drag_changed(app); let Some(drag) = app.crop.drag.take() else { return false }; release(app, drag); } @@ -820,6 +1042,11 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; if !crate::crop_straighten::pointer(app, ev, mods) { return false; } + // A press it takes brings the hidden frame back like any other press, even when the line is + // too short to turn the frame. + if matches!(ev, ToolEvent::Down { .. }) { + show_frame(app); + } claim(app); // Default mode: the image turns so the line ends up level. // The turn is one box Undo step, like any other frame gesture. @@ -839,6 +1066,7 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; /// Starts the drag a press at `p` selects: resize, move, turn or draw. fn begin_drag(app: &mut PhotocraftApp, p: [f64; 2]) { + show_frame(app); claim(app); app.crop.editing = true; app.crop.press_step = Some(step_of(app)); @@ -875,7 +1103,7 @@ fn outside_drag(app: &PhotocraftApp, p: [f64; 2], rect: [f64; 4], deg: f64) -> C let c = center(rect); let grab = (p[1] - c[1]).atan2(p[0] - c[0]); if crate::crop_mode::moves_image(app) { - CropDrag::TurnImage { center: c, grab, from: deg, view: crate::crop_mode::press_view(app) } + CropDrag::TurnImage { center: c, grab, from: deg, rect, view: crate::crop_mode::press_view(app) } } else { // Outside the frame a drag turns it, as in Photoshop (#1792). CropDrag::Rotate { center: c, grab, from: deg } @@ -885,9 +1113,16 @@ fn outside_drag(app: &PhotocraftApp, p: [f64; 2], rect: [f64; 4], deg: f64) -> C /// Ends `drag`: keeps a valid frame or restores the previous one, and records an undo step when it changed. fn release(app: &mut PhotocraftApp, drag: CropDrag) { let ok = app.ui.crop_rect.is_some_and(size_ok); - // A click outside the untouched default frame (no turn) leaves it the default. - let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) - && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); + // A click outside the untouched default frame (no turn) leaves it the default. A drawn, moved + // or resized frame has changed when the gesture changed it at some point (`drag_moved`), not + // when it ends unlike its start: a click inside the frame or on a handle draws or resizes + // nothing, and an Alt-drag from the centre can draw the very frame it started from (noted in + // review). + let changed = match drag { + CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } => from != angle(app), + CropDrag::MoveImage { rect, .. } => app.ui.crop_rect != Some(rect), + CropDrag::Draw { .. } | CropDrag::Move { .. } | CropDrag::Resize { .. } => app.crop.drag_moved, + }; if ok && changed { app.crop.default_frame = false; } @@ -899,6 +1134,25 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { push_press_step(app); } +/// Another tool was picked while a drag on the frame is held (a tool key, #9223): the gesture ends +/// where the pointer last moved it, as a release there would, so a box drawn over the untouched +/// default frame counts as drawn and leaving the tool commits it. +pub fn end_drag(app: &mut PhotocraftApp) { + let Some(drag) = app.crop.drag.take() else { return }; + let tool = std::mem::replace(&mut app.ui.tool, Tool::Crop); + release(app, drag); + crate::crop_mode::sync_frame(app); + app.ui.tool = tool; +} + +/// [`end_drag`] once another tool is current, except while the pen's eraser end stands in for the +/// Crop tool (`stylus`): the gesture goes on when the tip comes back. +pub fn end_drag_on_leave(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Crop && app.stylus.tool_before_eraser != Some(Tool::Crop) { + end_drag(app); + } +} + /// On release: the frame state at the press becomes a box Undo step when the gesture changed it. fn push_press_step(app: &mut PhotocraftApp) { if let Some(before) = app.crop.press_step.take() @@ -943,13 +1197,14 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bool { match app.crop.drag { Some(CropDrag::Rotate { center, grab, from }) => app.ui.crop_angle = turned_angle(center, grab, from, p, mods.shift), - Some(CropDrag::TurnImage { center, grab, from, view }) => { + Some(CropDrag::TurnImage { center, grab, from, rect, view }) => { // The image turns with the pointer's sweep on screen, so the frame turns against it. let at = crate::crop_mode::to_press(app, view, p); let turned = from - ((at[1] - center[1]).atan2(at[0] - center[0]) - grab).to_degrees(); if turned.is_finite() { app.ui.crop_angle = normalized_angle(if mods.shift { (turned / 15.0).round() * 15.0 } else { turned }); crate::crop_mode::sync_frame(app); + crate::crop_mode::keep_inside(app, rect, from, mods.command); } } Some(CropDrag::MoveImage { start, rect, view }) => { diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index d70e2f5792..93e2de2556 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -109,6 +109,110 @@ fn picking_the_tool_frames_the_canvas_or_the_selection() { assert_eq!(app.ui.crop_rect, None); } +/// #9730: the options bar's Reset puts the box back to its first frame, as an Undo step. +#[test] +fn reset_frames_the_canvas_again_and_can_be_undone() { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(!modified(&app), "the untouched default frame has nothing to reset"); + reset(&mut app); + assert!(app.crop.undo.is_empty(), "so resetting it adds no step"); + + drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE); + assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0])); + assert!(modified(&app)); + reset(&mut app); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0])); + assert!(!modified(&app) && app.crop.default_frame); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100), "nothing is cropped"); + + // The box's own Undo brings the drawn frame back, and Redo resets it again. + assert!(!shows_beyond_canvas(&app), "the reset frame is not being edited"); + assert!(intercept(&mut app, "edit.undo").is_some()); + assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0])); + assert!(shows_beyond_canvas(&app), "the drawn frame is being edited again: the canvas shows the pixels past it"); + assert!(intercept(&mut app, "edit.redo").is_some()); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0])); + + // A turned default frame is a changed one too: Reset makes it upright. + app.ui.crop_angle = 0.3; + assert!(modified(&app)); + reset(&mut app); + assert_eq!((app.ui.crop_angle, modified(&app)), (0.0, false)); + + // With a selection, the first frame is its bounds. + drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + reset(&mut app); + assert_eq!(app.ui.crop_rect, Some([10.0, 10.0, 40.0, 30.0])); + + // Another tool has no box to reset. + drag(&mut app, &[[60.0, 30.0], [100.0, 60.0]], NONE); + app.ui.tool = Tool::Brush; + assert!(!modified(&app)); +} + +/// Reset frames the canvas as a new frame is: held to the ratio preset picked in the bar. +#[test] +fn reset_holds_the_frame_to_the_ratio_preset() { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE); + let drawn = Some([50.0, 20.0, 120.0, 70.0]); + assert_eq!(app.ui.crop_rect, drawn); + // A 1:1 preset on the 200 x 100 canvas: the first frame is the largest square, centred. + app.ui.tool_options.crop_ratio = "1:1".into(); + reset(&mut app); + assert_eq!(app.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0]), "not the whole canvas, a 2:1 frame under a 1:1 preset"); + assert!(!modified(&app) && app.crop.default_frame); + assert!(intercept(&mut app, "edit.undo").is_some()); + assert_eq!(app.ui.crop_rect, drawn, "Undo brings the drawn frame back"); +} + +/// A W x H x Resolution preset keeps the reset frame a pending crop (it sets an output size), so +/// Commit still crops; Reset has nothing more to do on it until the frame changes again. +#[test] +fn reset_under_an_output_size_leaves_nothing_to_reset() { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + let o = &mut app.ui.tool_options; + o.crop_ratio = crate::crop_size::WHR.into(); + (o.crop_width, o.crop_height, o.crop_resolution) = ("100 px".into(), "100 px".into(), "72".into()); + drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE); + assert!(modified(&app)); + reset(&mut app); + assert_eq!(app.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(pending(&app), "the output size is still a crop to commit"); + assert!(!modified(&app), "and Reset has nothing more to do"); + drag(&mut app, &[[60.0, 10.0], [90.0, 40.0]], NONE); + assert!(modified(&app), "a new frame can be reset again"); +} + +/// #9730: the Crop tool's options bar has a Reset button beside Cancel and Commit, live once +/// the frame was changed. +#[test] +fn the_options_bar_reset_button_resets_a_changed_frame() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.state_mut().ui.tool = Tool::Crop; + h.run_steps(8); + let button = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset").accesskit_node().is_disabled(); + assert!(button(&h), "an untouched frame has nothing to reset"); + h.state_mut().ui.crop_rect = Some([50.0, 20.0, 120.0, 70.0]); + h.state_mut().crop.default_frame = false; + h.run_steps(4); + assert!(!button(&h), "a changed frame can be reset"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset").click(); + h.run_steps(4); + assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0])); + assert!(button(&h)); +} + #[test] fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { // #1789: selection, C, ↵ did nothing, as the untouched frame was always treated as a no-op. @@ -122,8 +226,16 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { assert_eq!(doc.size, Size::new(30, 40), "{depth:?}"); assert_eq!(doc.depth, depth); assert!(doc.selection.is_none(), "cropping to the selection deselects, as in Photoshop"); - // The new default frame is the whole (cropped) canvas: ↵ on it does nothing. + // No frame until a press, also after another tool and back; then the default one is the + // whole (cropped) canvas, and ↵ on it does nothing. ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picking the tool again keeps it hidden"); + show_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 30.0, 40.0])); let steps = app.session.active().unwrap().history.past_len(); crate::canvas::commit_crop(&mut app); @@ -132,6 +244,49 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { } } +/// A commit hides the frame; picking the Crop tool again keeps it hidden, as in Photoshop; a ratio +/// or a press brings back the default frame, and the press's crop keeps its box Undo and Redo. +#[test] +fn a_hidden_frame_comes_back_on_a_ratio_or_a_press_with_redo_intact() { + let committed = || { + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 60.0]); + crate::canvas::commit_crop(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "hidden after the commit"); + app + }; + let whole = Some([0.0, 0.0, 100.0, 50.0]); + // Picking the Crop tool while it is still picked, or after another tool: still hidden. + let mut app = committed(); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "re-picked"); + crate::tool_memory::select(&mut app, Tool::Move); + ensure_frame(&mut app); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picked after another tool"); + show_frame(&mut app); + assert_eq!(app.ui.crop_rect, whole); + assert!(app.crop.default_frame); + // A ratio fits the frame it brings back. + let mut app = committed(); + app.ui.tool_options.crop_ratio = "1:1".into(); + apply_ratio(&mut app); + assert_eq!(app.ui.crop_rect, Some([25.0, 0.0, 75.0, 50.0])); + // A drawn crop undoes to the default frame, and redoes. + let mut app = committed(); + drag(&mut app, &[[10.0, 10.0], [40.0, 30.0]], NONE); + let drawn = app.ui.crop_rect; + assert_eq!(drawn, Some([10.0, 10.0, 40.0, 30.0])); + assert!(intercept(&mut app, "edit.undo").is_some()); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, whole, "undo goes back to the default frame"); + assert!(intercept(&mut app, "edit.redo").is_some()); + assert_eq!(app.ui.crop_rect, drawn, "and redo still has the drawn frame"); +} + #[test] fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() { let mut app = app(SampleType::U8); @@ -152,7 +307,7 @@ fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() { assert!(shows_beyond_canvas(&app)); crate::canvas::commit_crop(&mut app); ensure_frame(&mut app); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 50.0, 30.0])); + assert_eq!(app.ui.crop_rect, None, "no frame until the next press"); assert!(!shows_beyond_canvas(&app)); // Or until another tool is picked. drag(&mut app, &[[5.0, 5.0], [20.0, 20.0]], NONE); @@ -341,6 +496,27 @@ fn moving_the_frame_snaps_its_edges() { assert_eq!(app.ui.crop_rect, Some([0.0, 10.0, 40.0, 40.0]), "left edge snaps to the document edge"); } +/// A click on the frame (released where it was pressed) changes nothing, as a Move-tool click +/// doesn't move the layer: snapping the release would pull an edge within reach onto the +/// document's, so a double-click to commit cropped another rect. +#[test] +fn a_click_on_the_frame_or_a_handle_never_snaps_it() { + let frame = [5.0, 10.0, 105.0, 60.0]; + for classic in [true, false] { + // Inside (Classic: moves the frame; default: moves the image), the left edge's handle, the top-left corner's. + for p in [[50.0, 30.0], [5.0, 35.0], [5.0, 10.0]] { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = true; + app.ui.crop_rect = Some(frame); + app.crop.default_frame = false; + drag(&mut app, &[p], NONE); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}, click at {p:?}"); + assert!(!crate::menus::is_enabled(&app, "edit.undo"), "classic {classic}, click at {p:?}: no box step"); + } + } +} + /// #3145: Edit › Undo while a crop is pending steps the *box*, not the document (Photoshop keeps /// Undo live during a crop), so a moved frame comes back. #[test] @@ -377,6 +553,41 @@ fn box_undo_and_redo_grey_out_when_their_stack_is_empty() { assert!(crate::menus::is_enabled(&app, "edit.redo")); } +/// A History jump under a drawn frame would resize the document while the frame keeps its +/// coordinates, so Enter cropped the old ones (#9285): the panel's rows, snapshots and menu +/// wait for the crop, as they do for Free Transform. +#[test] +fn history_jumps_wait_for_a_pending_crop() { + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + let mut app = app(SampleType::U8); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("image.canvasSize", json!({"width": 300, "height": 200})).unwrap(); + let snapshot = app.session.active().unwrap().snapshots[0].id; + app.ui.crop_rect = Some([220.0, 120.0, 290.0, 190.0]); + app.crop.default_frame = false; + for (cmd, p) in [ + ("history.selectState", json!({"state": 0})), + ("history.selectSnapshot", json!({"snapshot": snapshot})), + ("history.stepBackward", json!({})), + ("history.deleteState", json!({})), + ] { + assert!(app.run(cmd, p).is_err(), "{cmd} waits for the crop"); + assert_eq!(size(&app), Some((300, 200)), "{cmd} left the document as it was"); + } + assert_eq!(app.ui.crop_rect, Some([220.0, 120.0, 290.0, 190.0]), "the frame stays"); + // Without a drawn frame (the tool's own frame around the canvas) History works as before. + app.crop.default_frame = true; + app.run("history.selectState", json!({"state": 0})).unwrap(); + assert_eq!(size(&app), Some((200, 100))); + // Step Forward has a state to redo now; a drawn frame holds it back too. + app.crop.default_frame = false; + assert!(app.run("history.stepForward", json!({})).is_err(), "history.stepForward waits for the crop"); + assert_eq!(size(&app), Some((200, 100))); + app.crop.default_frame = true; + app.run("history.stepForward", json!({})).unwrap(); + assert_eq!(size(&app), Some((300, 200))); +} + #[test] fn handles_resize_with_shift_and_alt() { let mut app = app(SampleType::U8); @@ -510,6 +721,226 @@ fn space_on_the_canvas_moves_the_frame_not_the_view() { assert!(st.crop.drag.is_none()); } +/// Real egui input: a handle dragged past the canvas edge and held there scrolls the view, as a +/// marquee drag does (#4183), so the frame can reach the part of the image that was off screen. +#[test] +fn a_handle_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The right edge's middle handle: document (100, 25). + let p0 = h.state().ui.views[0].center; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: p0, flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 25.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Resize { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[2] > 175.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(out, false)); + h.step(); +} + +/// A press that started off the canvas (a panel slider beside it) doesn't scroll the view, +/// even with a crop drag still recorded: only a drag on the canvas auto-scrolls. +#[test] +fn a_press_beside_the_canvas_does_not_scroll_it_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + // Left over from a drag a tool switch didn't end. + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The canvas takes the left 400 points; the rest stands in for a panel. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p = pos2(500.0, 100.0); + h.event(Event::PointerMoved(p)); + h.event(button(p, true)); + h.step(); + h.event(Event::PointerMoved(pos2(550.0, 100.0))); + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "the view stayed put"); + h.event(button(pos2(550.0, 100.0), false)); + h.step(); +} + +/// The 200x100 test document at 400% (document x 25..175 shows in a 600-point canvas). +fn zoomed_view(height: u32) -> crate::state::View { + crate::state::View { + zoom: 4.0, + center: [100.0, height as f32 / 2.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, height], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + } +} + +/// A press held just inside the canvas edge (past egui's click time, so egui starts a drag) that +/// slips a point past it is still a click: it draws no frame and doesn't scroll the view, which +/// would grow a frame under the still pointer. A new frame dragged past the edge does scroll. +#[test] +fn a_held_click_at_the_canvas_edge_draws_no_frame_and_does_not_scroll() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![zoomed_view(100)]; + a.ui.extras.rulers = false; + // The canvas starts 100 points from the window's left, so its left edge (no scrollbar there) + // has room past it. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let frame = h.state().ui.crop_rect; + assert!(h.state().crop.default_frame, "{frame:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert!(matches!(h.state().crop.drag, Some(CropDrag::Draw { .. })), "egui started a drag: {:?}", h.state().crop.drag); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.ui.crop_rect, frame, "the click drew no frame"); + assert!(st.crop.default_frame); + // A new frame dragged from inside the canvas past its edge does scroll. + let start = pos2(300.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(240.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Draw { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the frame drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[0] < 25.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); +} + +/// A scrollbar drag is the scrollbar's, even with a crop drag still recorded (left over from a +/// drag a tool switch didn't end): dragged out past the canvas edge, it doesn't auto-scroll the +/// view as a crop drag would. +#[test] +fn a_scrollbar_drag_past_the_canvas_edge_does_not_scroll_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let doc = Document::with_background("crop", Size::new(200, 200), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::Crop; + a.ui.tool_options.crop_shield.classic_mode = true; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x200 at 400% in a 600x400 canvas: both scrollbars show. + a.ui.views = vec![zoomed_view(200)]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The window is taller than the canvas, so the pointer can leave it below the bottom bar. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 500.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().ui.view.shows_scrollbars()); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The horizontal bar's thumb (the view is centred, so is the thumb), dragged straight down + // out of the canvas: the bar keeps x, and nothing else may move the view. + let thumb = pos2(294.0, 400.0 - crate::canvas::scrollbars::THICKNESS / 2.0); + h.event(Event::PointerMoved(thumb)); + h.event(button(thumb, true)); + h.step(); + for p in [pos2(294.0, 420.0), pos2(294.0, 480.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 100.0], "the view stayed put"); + h.event(button(pos2(294.0, 480.0), false)); + h.step(); +} + /// #1918: a pending crop belongs to the document it was drawn on. File › New, opening a file /// or switching tabs cancels it; it never shows on, or crops, another document. /// A turned pending frame is dropped with its angle when another document becomes active, and @@ -829,6 +1260,126 @@ fn the_stylus_eraser_and_a_gesture_in_progress_keep_the_frame_pending() { assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); } +/// A tool key pressed while a drag on the frame is held (⇧C to Perspective Crop): the next +/// tool's events end the gesture, even a tool that takes every event before the Crop tool's +/// handler, and leaving then commits the frame as it does for any other tool (#3368). +#[test] +fn a_tool_switch_during_a_drag_ends_it_and_commits_the_frame() { + for tool in [Tool::PerspectiveCrop, Tool::Ruler, Tool::Slice, Tool::Frame, Tool::Artboard, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[20.0, 20.0], [120.0, 80.0]], NONE); + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 50.0, pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?}"); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 60), "{tool:?}"); + } +} + +/// The same mid-drag tool switch on the untouched default frame: a box drawn over it, or the +/// frame resized by a handle, is an edit and commits on leave; a box too small to keep goes back +/// to the default frame, which leaving drops (#9223). +#[test] +fn a_tool_switch_during_a_drag_on_the_default_frame_keeps_the_edit() { + let cases: [(&[[f64; 2]], Size); 3] = [ + (&[[20.0, 20.0], [120.0, 80.0]], Size::new(100, 60)), + (&[[200.0, 100.0], [150.0, 80.0]], Size::new(150, 80)), + (&[[20.0, 20.0], [20.0, 20.0]], Size::new(200, 100)), + ]; + for (path, size) in cases { + for tool in [Tool::PerspectiveCrop, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame); + tool_event(&mut app, ToolEvent::Down { x: path[0][0], y: path[0][1], pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: path[1][0], y: path[1][1], pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?} {path:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?} {path:?}"); + assert_eq!(app.session.active().unwrap().doc.size, size, "{tool:?} {path:?}"); + } + } +} + +/// Real egui input: a tool picked while a box is being drawn over the untouched default frame +/// (a tool key mid-drag) commits that box on leaving, whichever tool it is; the canvas ends the +/// gesture before the frame sees the switch, also for the Hand, whose events never reach +/// `tool_event` (#9223). +#[test] +fn a_tool_switch_during_a_real_drag_commits_the_drawn_box() { + use egui::{Event, PointerButton, pos2}; + for tool in [Tool::Brush, Tool::PerspectiveCrop, Tool::Hand] { + let mut a = app(SampleType::U8); + a.sync_views(); + let v = &mut a.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.doc_size, v.rotation) = (2.0, [100.0, 50.0], false, [200, 100], 0.0); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().crop.default_frame, "{tool:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p0 = pos2(200.0, 150.0); + h.event(Event::PointerMoved(p0)); + h.event(button(p0, true)); + h.step(); + for p in [pos2(220.0, 170.0), pos2(260.0, 190.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + assert!(h.state().crop.drag.is_some(), "{tool:?}"); + h.state_mut().ui.tool = tool; + h.event(Event::PointerMoved(pos2(270.0, 195.0))); + h.step(); + h.event(button(pos2(270.0, 195.0), false)); + h.run_steps(2); + let st = h.state(); + assert!(st.crop.drag.is_none(), "{tool:?}"); + assert_eq!(st.ui.crop_rect, None, "{tool:?}"); + assert_eq!(st.session.active().unwrap().doc.size, Size::new(30, 20), "{tool:?}: the drawn 60x40 screen px at 200%"); + } +} + +/// A press inside the untouched default frame that never moves draws nothing, so the frame +/// stays the default one and leaving drops it: no crop to the selection's bounds, released or +/// cut short by a tool switch (#9223). +#[test] +fn a_press_that_never_moves_leaves_the_default_frame_untouched() { + for switch in [false, true] { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([10.0, 20.0, 40.0, 60.0])); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, NONE); + if switch { + app.ui.tool = Tool::Brush; + end_drag_on_leave(&mut app); + } else { + tool_event(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }, NONE); + assert!(app.crop.default_frame, "a click is no edit"); + app.ui.tool = Tool::Brush; + } + ensure_frame(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "switch: {switch}"); + assert_eq!(app.ui.crop_rect, None, "switch: {switch}"); + } +} + /// Image › Crop with a pending crop commits the frame (the one Image item Photoshop keeps). #[test] fn image_crop_menu_commits_a_pending_crop() { @@ -872,7 +1423,7 @@ fn enter_during_a_drag_ends_the_drag() { tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, NONE); ensure_frame(&mut app); assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + assert_eq!(app.ui.crop_rect, None); assert!(!pending(&app)); } @@ -1133,6 +1684,94 @@ fn x_swaps_the_frame_orientation_not_the_colours() { assert!(!swap_orientation(&mut app)); } +/// #7738: with a ratio set, the options bar's ⇄ turns the frame with the ratio, as X does, +/// instead of fitting the swapped ratio inside the old frame; a second click brings it back. +#[test] +fn the_swap_button_turns_the_ratio_frame() { + use egui_kittest::kittest::Queryable; + let a = app(SampleType::U8); + let mut h = swap_button_harness(a, "16:9", [60.0, 27.5, 140.0, 72.5]); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); +} + +/// With W x H x Resolution the bar's ⇄ swaps W and H and turns the frame with them, as X +/// does, instead of fitting the turned size inside the old frame. +#[test] +fn the_swap_button_turns_a_size_preset_frame() { + use egui_kittest::kittest::Queryable; + let whr = |a: &mut PhotocraftApp| { + let o = &mut a.ui.tool_options; + (o.crop_width, o.crop_height, o.crop_resolution) = ("1600 px".into(), "900 px".into(), "72".into()); + }; + let mut a = app(SampleType::U8); + whr(&mut a); + let mut h = swap_button_harness(a, crate::crop_size::WHR, [60.0, 27.5, 140.0, 72.5]); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + let o = &h.state().ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str()), (crate::crop_size::WHR, "900 px", "1600 px")); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); + // X does the same. + let mut a = app(SampleType::U8); + whr(&mut a); + a.ui.tool_options.crop_ratio = crate::crop_size::WHR.into(); + a.ui.crop_rect = Some([60.0, 27.5, 140.0, 72.5]); + assert!(swap_orientation(&mut a)); + assert_eq!((a.ui.tool_options.crop_width.as_str(), a.ui.tool_options.crop_height.as_str()), ("900 px", "1600 px")); + assert!(near(a.ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "X: 45 x 80, not shrunk: {:?}", a.ui.crop_rect); +} + +/// A square default frame doesn't turn, so ⇄ leaves it untouched: not edited, and leaving the +/// tool drops it instead of cropping. +#[test] +fn the_swap_button_leaves_a_square_default_frame_untouched() { + use egui_kittest::kittest::Queryable; + let mut a = app(SampleType::U8); + a.crop.default_frame = true; + a.crop.editing = false; + let mut h = swap_button_harness(a, "1:1", [50.0, 0.0, 150.0, 100.0]); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert_eq!(h.state().ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(h.state().crop.default_frame && !h.state().crop.editing, "still the untouched default frame"); + // X, the same swap from the keyboard. + let mut a = app(SampleType::U8); + a.crop.default_frame = true; + a.crop.editing = false; + a.crop.frame_for = a.session.active_index().zip(a.session.active()).map(|(i, st)| (i, st.doc.size.width, st.doc.size.height)); + a.ui.tool_options.crop_ratio = "1:1".into(); + a.ui.crop_rect = Some([50.0, 0.0, 150.0, 100.0]); + assert!(swap_orientation(&mut a)); + assert_eq!(a.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(a.crop.default_frame && !a.crop.editing, "X keeps it untouched too"); +} + +fn swap_button_harness(mut a: PhotocraftApp, ratio: &str, rect: [f64; 4]) -> egui_kittest::Harness<'static, PhotocraftApp> { + a.ui.tool_options.crop_ratio = ratio.into(); + a.ui.crop_rect = Some(rect); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + a, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run_steps(4); + h +} + /// #1919: W × H beside the pointer while the frame is drawn or resized (its own size when /// turned), not while it moves or turns or between gestures. #[test] @@ -1165,3 +1804,201 @@ fn readout_shows_the_frame_size_while_drawing_or_resizing() { assert_eq!(frame_size([f64::NAN, 0.0, 1.0, 1.0]), None); assert_eq!(frame_size([-f64::MAX, 0.0, f64::MAX, 1.0]), None); } + +/// Esc through the real shortcut handler. +fn press_esc(app: &mut PhotocraftApp) { + let key = egui::Key::Escape; + let input = egui::RawInput { + focused: true, + events: vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + egui::Context::default().run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn esc_hides_the_frame_until_a_press() { + // #10370: Esc cancelled the crop but the next frame made the default frame again. It now hides + // the frame as a commit does: picking the tool again keeps it hidden, a press brings it back. + for classic in [false, true] { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_shield.classic_mode = classic; + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + // The untouched default frame: Esc hides it, and it stays hidden frame after frame. + press_esc(&mut app); + for _ in 0..3 { + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "classic={classic}"); + } + // A press on the canvas draws a new frame. + drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE); + assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0])); + // A drawn frame: Esc hides it too, and crops nothing. + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); + // Leaving the tool after Esc crops nothing, and back on it the frame stays hidden. + app.ui.tool = Tool::Brush; + ensure_frame(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100), "classic={classic}"); + app.ui.tool = Tool::Crop; + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picking the tool again keeps it hidden"); + // A press brings it back; the options bar's cancel hides it as well. + show_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + dismiss(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + // A plain cancel (stale document) does not hide it. + show_frame(&mut app); + cancel(&mut app); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some()); + } +} + +#[test] +fn closing_a_dismissed_document_never_hides_the_next_tabs_frame() { + // A same-size tab that takes the closed document's index must still show its frame. + let mut app = app(SampleType::U8); + let doc = Document::with_background("other", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + app.session.add_document(doc, None); + assert!(app.session.set_active(0)); + ensure_frame(&mut app); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert!(app.session.close(0).is_some()); + assert_eq!(app.session.active_index(), Some(0)); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); +} + +#[test] +fn a_straighten_press_ends_the_dismissal() { + // Straighten takes the press before `begin_drag`; a click too short to level anything still + // ends the dismissal. + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + app.crop.straighten.armed = true; + drag(&mut app, &[[50.0, 20.0], [50.5, 20.0]], NONE); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some()); +} + +#[test] +fn undoing_a_crop_after_esc_brings_the_frame_back() { + // Esc crops nothing, so it adds no step; an Undo that changes the canvas size shows a frame for it. + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[20.0, 10.0], [70.0, 30.0], [120.0, 60.0]], NONE); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); + ensure_frame(&mut app); + let steps = app.session.active().unwrap().history.entries().len(); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); +} + +#[test] +fn an_undo_that_keeps_the_canvas_size_leaves_the_frame_hidden() { + // Only a size change brings the frame back: undoing an edit that keeps the size doesn't. + let mut app = app(SampleType::U8); + app.run("image.adjustments.invert", json!({})).unwrap(); + ensure_frame(&mut app); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); +} + +/// #3145: the Info panel shows the pending frame's W × H while the Crop tool is active +/// (between gestures too, and its own size when turned), and nothing for other tools. +#[test] +fn info_panel_shows_the_pending_frame_size() { + let mut app = app(SampleType::U8); + app.ui.tool = Tool::Crop; + app.ui.crop_rect = None; + assert_eq!(info_size(&app), None, "no frame"); + app.ui.crop_rect = Some([20.0, 20.0, 100.0, 65.4]); + assert_eq!(info_size(&app), Some([80.0, 45.0]), "rounded as ↵ crops it"); + app.ui.crop_angle = 30.0; + assert_eq!(info_size(&app), Some([80.0, 45.0]), "a turned frame keeps its own size"); + app.ui.crop_rect = Some([f64::NAN, 0.0, 1.0, 1.0]); + assert_eq!(info_size(&app), None, "a broken frame shows nothing"); + app.ui.crop_rect = Some([20.0, 20.0, 100.0, 60.0]); + app.ui.tool = Tool::Move; + assert_eq!(info_size(&app), None, "another tool shows the selection instead"); +} + +/// #3145 review: the crop frame's W and H use the Info panel's formatter for the ruler unit, +/// the same one the selection's go through. +#[test] +fn info_size_is_shown_in_the_ruler_unit() { + use crate::panels::info::wh_readout; + use photocraft_engine::prefs::{Unit, UnitsAndRulers}; + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([0.0, 0.0, 144.0, 72.0]); + let wh = info_size(&app).unwrap(); + let mut ur = UnitsAndRulers::default(); + let extent = [200.0, 100.0]; + ur.rulers = Unit::Pixels; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["144", "72"]); + ur.rulers = Unit::Inches; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["2", "1"]); + assert_eq!(wh_readout(&ur, 144.0, extent, wh), ["1", "0.5"], "at the document's resolution"); + ur.rulers = Unit::Centimeters; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["5.08", "2.54"]); + ur.rulers = Unit::Percent; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["72", "72"], "W of the width, H of the height"); +} + +/// #3145 review: after the crop is cancelled or committed, or the tool is left, W and H are the +/// selection's again. With the Crop tool still picked, the new default frame is the selection's +/// bounds (or the canvas), as in Photoshop, so the panel shows the same size. After a commit that +/// frame waits for the next press. +#[test] +fn info_size_returns_to_the_selection_after_cancel_or_commit() { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 20, "y": 10, "width": 80, "height": 40})).unwrap(); + // Cancel: no frame, then the new default frame frames the selection. + ensure_frame(&mut app); + app.ui.crop_rect = Some([10.0, 10.0, 50.0, 40.0]); + assert_eq!(info_size(&app), Some([40.0, 30.0])); + cancel(&mut app); + assert_eq!(info_size(&app), None, "cancelled: the panel falls back to the selection"); + ensure_frame(&mut app); + assert_eq!(info_size(&app), Some([80.0, 40.0]), "the new frame is the selection's bounds"); + // Commit: the document is cropped and the frame is gone. + app.run("select.deselect", json!({})).unwrap(); + cancel(&mut app); + drag(&mut app, &[[10.0, 10.0], [50.0, 40.0]], NONE); + assert_eq!(info_size(&app), Some([40.0, 30.0])); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(40, 30)); + assert_eq!(info_size(&app), None, "committed: no frame"); + ensure_frame(&mut app); + assert_eq!(info_size(&app), None, "committed: the frame stays hidden"); + // A press brings it back. + show_frame(&mut app); + assert_eq!(info_size(&app), Some([40.0, 30.0]), "the new frame is the cropped canvas"); + // Leaving the tool: the panel shows the selection, whatever the frame. + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + assert_eq!(info_size(&app), None); +} diff --git a/crates/ui-egui/src/tools/direct_select.rs b/crates/ui-egui/src/tools/direct_select.rs index 86df553ddc..ba8ab3b8ac 100644 --- a/crates/ui-egui/src/tools/direct_select.rs +++ b/crates/ui-egui/src/tools/direct_select.rs @@ -241,6 +241,18 @@ fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool { !mods.command && (tool == Tool::ConvertPoint || (tool == Tool::Pen && mods.alt && pen_idle)) } +/// The selected anchor when it is the only one and an endpoint of an open subpath of the active +/// path: where the Curvature Pen carries the path on from (#10726), as Photoshop does after a +/// click, or ⌘/Ctrl-click, on the last point. The path, the subpath's index and the knot's. +pub(crate) fn selected_open_end(app: &PhotocraftApp) -> Option<(PathRef, usize, usize)> { + let ds = &app.ui.direct_selection; + let ([[s, k]], Some(target)) = (ds.anchors.as_slice(), ds.target) else { return None }; + let (_, path) = active_paths(app).into_iter().find(|(r, _)| *r == target)?; + let sub = path.subpaths.get(*s).filter(|sub| !sub.closed)?; + sub.knots.get(*k).filter(|_| *k == 0 || *k + 1 == sub.knots.len())?; + Some((target, *s, *k)) +} + /// Is the Curvature Pen idle (no path being drawn, no ⌘/Ctrl)? Then it edits the active path in /// place, as in Photoshop: a press on one of its anchors drags it, with no modifier. fn curvature_idle(app: &PhotocraftApp, mods: Modifiers) -> bool { diff --git a/crates/ui-egui/src/tools/eyedropper_ui.rs b/crates/ui-egui/src/tools/eyedropper_ui.rs index 10fcaaa603..4e4c3d71c4 100644 --- a/crates/ui-egui/src/tools/eyedropper_ui.rs +++ b/crates/ui-egui/src/tools/eyedropper_ui.rs @@ -50,7 +50,7 @@ pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); ui.label( egui::RichText::new(crate::i18n::fmt( - tl!("Click to sample the foreground colour · {key}-click for background"), + tl!("Click to sample the foreground color · {key}-click for background"), &[("key", &crate::shortcuts::pretty("Alt"))], )) .color(t.text_faint), diff --git a/crates/ui-egui/src/tools/frame_tool.rs b/crates/ui-egui/src/tools/frame_tool.rs index b2102b1cfb..190b0509c2 100644 --- a/crates/ui-egui/src/tools/frame_tool.rs +++ b/crates/ui-egui/src/tools/frame_tool.rs @@ -1,190 +1,870 @@ -//! Frame tool (K): drag on the canvas to draw a rectangular or elliptical frame (the options bar -//! picks the shape). ⇧ makes it a square or circle, ⌥ draws it from the centre. Drawn over the -//! selected layer's content, the frame takes that layer in, as in Photoshop; else it is an -//! empty placeholder. Each frame is one engine command (`layer.new.frame`), so one undo step; this -//! module only turns the drag into it. - -use egui::{Pos2, Stroke}; -use photocraft_doc::LayerContent; +//! Frame Tool: persistent placeholders and independently selectable frame/image geometry. +use crate::{ + PhotocraftApp, + canvas::{ToolEvent, ViewXform}, + state::Tool, + theme::Tokens, +}; +use egui::{CursorIcon, Pos2, Stroke, StrokeKind}; +use photocraft_doc::{DocId, Frame, Layer, LayerContent, LayerId}; +use photocraft_geom::{Affine, Point, Rect}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; -use crate::PhotocraftApp; -use crate::canvas::{ToolEvent, ViewXform}; -use crate::state::Tool; -use crate::theme::Tokens; - -/// A press that moves less than this (screen points) is a click, not a drawn frame. const CLICK_PX: f64 = 3.0; - -/// Frame tool state (serde: readable and settable through the control channel). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +const HANDLE_PX: f64 = 7.0; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Target { + #[default] + Frame, + Content, + Both, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct FrameToolUi { - /// Options bar: draw elliptical frames (else rectangular). + /// Kept for old saved UI state; the current shape selector also supports polygons/custom shapes. pub ellipse: bool, - /// The drag in progress: press and current point (document pixels), ⇧ and ⌥ at the last event. + pub shape: String, + pub sides: u32, + pub stroke: Option, + pub target: Target, + pub selected: Option, #[serde(skip)] pub drag: Option, + #[serde(skip)] + pub auto_transform: bool, } - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct FrameDrag { - pub start: [f64; 2], - pub cur: [f64; 2], - pub square: bool, - pub centred: bool, -} - -/// The frame a drag leaves, `[x0, y0, x1, y1]` on whole pixels. -fn drawn(d: &FrameDrag) -> [i64; 4] { - let (mut dx, mut dy) = (d.cur[0] - d.start[0], d.cur[1] - d.start[1]); - if d.square { - let side = dx.abs().max(dy.abs()); - (dx, dy) = (side.copysign(dx), side.copysign(dy)); +impl Default for FrameToolUi { + fn default() -> Self { + Self { ellipse: false, shape: "rectangle".into(), sides: 5, stroke: None, target: Target::Frame, selected: None, drag: None, auto_transform: false } } - let (a, b) = - if d.centred { ([d.start[0] - dx, d.start[1] - dy], [d.start[0] + dx, d.start[1] + dy]) } else { (d.start, [d.start[0] + dx, d.start[1] + dy]) }; +} +#[derive(Clone, Debug, PartialEq)] +pub enum FrameDrag { + New { start: [f64; 2], cur: [f64; 2], square: bool, centred: bool, doc: DocId, revision: u64 }, + Edit { layer: LayerId, doc: DocId, revision: u64, original: Box, proposed: Affine, start: [f64; 2], mode: EditMode }, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EditMode { + Move, + Resize(i8, i8), + Rotate, +} +pub(crate) struct Preview { + doc: std::sync::Arc, + revision: u64, + key: u64, +} + +fn drawn(start: [f64; 2], cur: [f64; 2], square: bool, centred: bool) -> [i64; 4] { + let [sx, sy] = start; + let [x, y] = cur; + let (mut dx, mut dy) = (x - sx, y - sy); + if square { + let side = dx.abs().max(dy.abs()); + dx = side.copysign(dx); + dy = side.copysign(dy); + } + let (a, b) = if centred { ([sx - dx, sy - dy], [sx + dx, sy + dy]) } else { (start, [sx + dx, sy + dy]) }; [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64) } - -/// The selected pixel, type, shape or smart object layer the frame `r` takes in: one whose content -/// it overlaps and that can move (not the Background or a position-locked layer). +pub fn selected(app: &PhotocraftApp) -> Option<&Layer> { + let st = app.session.active()?; + let id = photocraft_engine::frame_cmds::frame_of(&st.doc, st.active_layer?)?; + st.doc.layer(id) +} +fn path_visible(doc: &photocraft_doc::Document, path: &[usize]) -> bool { + (1..=path.len()).all(|n| path.get(..n).and_then(|p| doc.layer_at(p)).is_some_and(|l| l.visible)) +} +fn visible(app: &PhotocraftApp, id: LayerId) -> bool { + app.session.active().is_some_and(|st| st.doc.path_of(id).is_some_and(|p| path_visible(&st.doc, &p))) +} fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { let st = app.session.active()?; let l = st.doc.layer(st.active_layer?)?; - let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); - if !framable || l.locks.position || l.locks.all { + if !matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)) || st.doc.is_background(l.id) { return None; } - let b = l.surface()?.content_bounds(); - let overlaps = i64::from(b.x0) < r[2] && r[0] < i64::from(b.x1) && i64::from(b.y0) < r[3] && r[1] < i64::from(b.y1); - (!b.is_empty() && overlaps).then_some(l.id.0) + let locks = st.doc.effective_locks(l.id); + if locks.position || locks.all { + return None; + } + // Another frame's image stays in its frame; the drag draws an empty frame instead. + if photocraft_engine::frame_cmds::frame_of(&st.doc, l.id).is_some() { + return None; + } + let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, l); + (!b.is_empty() && i64::from(b.x0) < r[2] && r[0] < i64::from(b.x1) && i64::from(b.y0) < r[3] && r[1] < i64::from(b.y1)).then_some(l.id.0) +} +fn report(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } +} +pub(crate) fn select(app: &mut PhotocraftApp, id: LayerId, target: Target) { + let r = select_result(app, id, target); + if let Err(e) = r { + report(app, Err(e)); + } +} +pub(crate) fn select_result(app: &mut PhotocraftApp, id: LayerId, target: Target) -> Result<(), String> { + let child = app.session.active().and_then(|st| st.doc.layer(id)).and_then(|l| l.children()).and_then(|c| c.first()).map(|l| l.id); + let layer = if target == Target::Content { child.unwrap_or(id) } else { id }; + app.run("layer.select", json!({"layer":layer.0}))?; + app.ui.frame_tool.target = if child.is_none() { Target::Frame } else { target }; + app.ui.frame_tool.selected = Some(id.0); + app.ui.mask_target = false; + app.ui.vector_mask_target = false; + app.ui.selected_path = None; + Ok(()) +} +pub(crate) fn target(app: &PhotocraftApp, l: &Layer) -> Target { + if app.ui.frame_tool.selected != Some(l.id.0) { + if app.session.active().is_some_and(|st| st.active_layer != Some(l.id)) { Target::Content } else { Target::Frame } + } else { + app.ui.frame_tool.target + } +} +fn shape_params(app: &PhotocraftApp, r: [i64; 4]) -> Value { + let [x, y, x1, y1] = r; + let shape = if app.ui.frame_tool.ellipse { + "ellipse" + } else if app.ui.frame_tool.shape.is_empty() { + "rectangle" + } else { + &app.ui.frame_tool.shape + }; + let mut p = json!({"x":x,"y":y,"width":x1.saturating_sub(x),"height":y1.saturating_sub(y),"shape":shape,"sides":app.ui.frame_tool.sides.clamp(3,100)}); + if shape == "custom" { + let name = app.ui.presets_ui.shape(); + if let Some(sh) = photocraft_engine::presets::shapes::all_groups(&app.session).iter().flat_map(|g| &g.items).find(|s| s.name == name) { + p["path"] = photocraft_engine::vector_cmds::path_json(&sh.path); + } + } + if let Some(s) = &app.ui.frame_tool.stroke { + p["stroke"] = stroke_json(s); + } + p +} +fn outline(app: &PhotocraftApp, r: [i64; 4]) -> Option { + let p = shape_params(app, r); + let shape = p.get("shape").and_then(Value::as_str)?; + if shape == "custom" { + let sh = photocraft_engine::vector_cmds::parse_path(p.get("path")?).ok()?; + let (x0, y0, x1, y1) = sh.control_bounds()?; + if x1 <= x0 || y1 <= y0 { + return None; + } + let [x, y, xe, ye] = r; + let (sx, sy) = ((xe - x) as f64 / (x1 - x0), (ye - y) as f64 / (y1 - y0)); + return Some(sh.transform(&Affine { m: [sx, 0.0, 0.0, sy, x as f64 - x0 * sx, y as f64 - y0 * sy] })); + } + let [x, y, xe, ye] = r; + photocraft_engine::vector_cmds::shape_path(&json!({"kind":match shape{"ellipse"=>"ellipse","triangle"|"polygon"=>"polygon",_=>"rect"},"rect":[x,y,xe-x,ye-y],"sides":if shape=="triangle"{3}else{app.ui.frame_tool.sides.clamp(3,100)}})).ok() +} +fn handles(f: &Frame) -> [(Point, i8, i8); 8] { + [(0., 0., -1, -1), (0.5, 0., 0, -1), (1., 0., 1, -1), (1., 0.5, 1, 0), (1., 1., 1, 1), (0.5, 1., 0, 1), (0., 1., -1, 1), (0., 0.5, -1, 0)] + .map(|(x, y, hx, hy)| (f.transform.apply(Point::new(x, y)), hx, hy)) +} +fn control_hit(f: &Frame, p: [f64; 2], zoom: f64) -> Option { + if let Some((_, hx, hy)) = handles(f).into_iter().find(|(q, _, _)| (q.x - p[0]).hypot(q.y - p[1]) * zoom <= HANDLE_PX) { + return Some(EditMode::Resize(hx, hy)); + } + let local = f.transform.inverse()?.apply(Point::new(p[0], p[1])); + if (!(0.0..=1.0).contains(&local.x) || !(0.0..=1.0).contains(&local.y)) && f.corners().iter().any(|q| (q.x - p[0]).hypot(q.y - p[1]) * zoom <= 20.0) { + return Some(EditMode::Rotate); + } + None +} +fn on_border(f: &Frame, p: [f64; 2], zoom: f64) -> bool { + let path = f.outline(); + photocraft_vector::flatten::flatten_path(&path, 0.5 / zoom.max(0.01)).iter().any(|line| { + let segments = line.pts.windows(2).filter_map(|s| Some((*s.first()?, *s.get(1)?))); + let closing = line.closed.then(|| Some((*line.pts.last()?, *line.pts.first()?))).flatten(); + segments.chain(closing).any(|((ax, ay), (bx, by))| { + let (dx, dy) = (bx - ax, by - ay); + let n = dx * dx + dy * dy; + let t = if n > 0.0 { ((p[0] - ax) * dx + (p[1] - ay) * dy) / n } else { 0.0 }.clamp(0.0, 1.0); + (p[0] - ax - t * dx).hypot(p[1] - ay - t * dy) * zoom <= 5.0 + }) + }) +} +fn inside(f: &Frame, p: [f64; 2]) -> bool { + let (x, y) = (p[0].floor() as i32, p[1].floor() as i32); + let (Some(x1), Some(y1)) = (x.checked_add(1), y.checked_add(1)) else { return false }; + photocraft_vector::path_coverage(&f.outline(), Rect::new(x, y, x1, y1)).first().is_some_and(|v| *v > 0.0) +} +pub(crate) fn frame_at(app: &PhotocraftApp, p: [f64; 2]) -> Option { + let st = app.session.active()?; + let zoom = f64::from(app.point_zoom()); + st.doc + .walk() + .into_iter() + .rev() + .find(|(path, _, l)| { + path_visible(&st.doc, path) + && l.frame.as_ref().is_some_and(|f| { + let b = f.bounds().inflate((20.0 / zoom.max(0.01)).ceil() as i32); + p[0] >= f64::from(b.x0) + && p[0] <= f64::from(b.x1) + && p[1] >= f64::from(b.y0) + && p[1] <= f64::from(b.y1) + && (inside(f, p) || on_border(f, p, zoom)) + }) + }) + .map(|(_, _, l)| l.id) +} +fn edit_transform(f: &Frame, mode: EditMode, start: [f64; 2], p: [f64; 2], mods: egui::Modifiers) -> Affine { + match mode { + EditMode::Move => { + let (mut dx, mut dy) = (p[0] - start[0], p[1] - start[1]); + if mods.shift { + if dx.abs() > dy.abs() { dy = 0.0 } else { dx = 0.0 } + } + Affine::translate(dx.round(), dy.round()).mul(&f.transform) + } + EditMode::Rotate => { + let center = f.transform.apply(Point::new(0.5, 0.5)); + let mut a = (p[1] - center.y).atan2(p[0] - center.x) - (start[1] - center.y).atan2(start[0] - center.x); + if mods.shift { + let step = std::f64::consts::PI / 12.; + a = (a / step).round() * step; + } + Affine::translate(center.x, center.y).mul(&Affine::rotate(a)).mul(&Affine::translate(-center.x, -center.y)).mul(&f.transform) + } + EditMode::Resize(hx, hy) => { + let Some(inv) = f.transform.inverse() else { return f.transform }; + let (s, c) = (inv.apply(Point::new(start[0], start[1])), inv.apply(Point::new(p[0], p[1]))); + let (dx, dy) = (c.x - s.x, c.y - s.y); + let (mut x0, mut y0, mut x1, mut y1) = (0., 0., 1., 1.); + if hx < 0 { + x0 += dx; + if mods.alt { + x1 -= dx; + } + } else if hx > 0 { + x1 += dx; + if mods.alt { + x0 -= dx; + } + } + if hy < 0 { + y0 += dy; + if mods.alt { + y1 -= dy; + } + } else if hy > 0 { + y1 += dy; + if mods.alt { + y0 -= dy; + } + } + if mods.shift { + let k = if hx == 0 { + (y1 - y0).abs() + } else if hy == 0 { + (x1 - x0).abs() + } else { + (x1 - x0).abs().max((y1 - y0).abs()) + }; + let anchor = |lo: f64, hi: f64, h: i8| { + if mods.alt || h == 0 { + ((lo + hi - k) / 2., (lo + hi + k) / 2.) + } else if h < 0 { + (hi - k, hi) + } else { + (lo, lo + k) + } + }; + (x0, x1) = anchor(x0, x1, hx); + (y0, y1) = anchor(y0, y1, hy); + } + let [a, b, c, d, _, _] = f.transform.m; + let minimum = |n: f64, size: f64| n.signum().copysign(if n == 0.0 { 1.0 } else { n }) * n.abs().max(1.0 / size.max(1.0)); + let (w, h) = (minimum(x1 - x0, a.hypot(b)), minimum(y1 - y0, c.hypot(d))); + f.transform.mul(&Affine { m: [w, 0., 0., h, x0, y0] }) + } + } +} +fn update_preview(app: &mut PhotocraftApp) { + let Some(FrameDrag::Edit { layer, doc, revision, proposed, .. }) = app.ui.frame_tool.drag.as_ref() else { return }; + let Some(st) = app.session.active().filter(|st| st.doc.id == *doc && st.revision == *revision) else { + app.frame_preview = None; + return; + }; + let (id, revision, proposed) = (*layer, *revision, *proposed); + let mut shown = (*st.doc).clone(); + if let Some(l) = shown.layer_mut(id) { + if let Some(f) = &mut l.frame { + f.transform = proposed; + if !f.valid() { + app.frame_preview = None; + return; + } + } + photocraft_engine::frame_cmds::refresh(l); + } + let key = app.frame_preview.as_ref().map_or(1, |p| p.key.wrapping_add(1)); + app.frame_preview = Some(Preview { doc: std::sync::Arc::new(shown), revision, key }); +} +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(std::sync::Arc, u64)> { + if let Some(t) = app.ui.transform.clone().filter(frame_transform) { + let transform = transformed_frame(app, &t).ok()?; + let st = app.session.documents().get(idx)?; + if app.transform_preview.as_ref().is_none_or(|pv| pv.doc.id != st.doc.id) { + return None; + } + use std::hash::Hasher; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + hash.write_u64(t.session); + for value in transform.m { + hash.write_u64(value.to_bits()); + } + let key = hash.finish(); + if app.frame_preview.as_ref().is_none_or(|p| p.doc.id != st.doc.id || p.revision != st.revision || p.key != key) { + let mut doc = (*st.doc).clone(); + let l = doc.layer_mut(LayerId(t.layer))?; + l.frame.as_mut()?.transform = transform; + photocraft_engine::frame_cmds::refresh(l); + app.frame_preview = Some(Preview { doc: std::sync::Arc::new(doc), revision: st.revision, key }); + } + } + let st = app.session.documents().get(idx)?; + let p = app.frame_preview.as_ref()?; + (app.ui.tool == Tool::Frame && st.doc.id == p.doc.id && st.revision == p.revision).then(|| (p.doc.clone(), (1 << 57) | p.key)) +} +pub fn cancel(app: &mut PhotocraftApp) { + app.ui.frame_tool.drag = None; + app.frame_preview = None; +} +/// A native double click (also driven by ui.click count=2) toggles content-only selection. +pub fn double_click(app: &mut PhotocraftApp, p: [f64; 2]) { + let Some(id) = frame_at(app, p) else { return }; + let next = if selected(app).is_some_and(|l| l.id == id && target(app, l) == Target::Content) { Target::Both } else { Target::Content }; + select(app, id, next); + cancel(app); } - -/// Pointer input for the Frame tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, - // so it drags a guide or the layer (#6288). A drag already under way keeps its events. - if app.ui.tool != Tool::Frame || (app.ui.frame_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Frame) { + if app.ui.tool != Tool::Frame { return false; } - let (square, centred) = (mods.shift, mods.alt); + if app.ui.frame_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Frame { + return false; + } + let (x, y) = match ev { + ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => (x, y), + }; + if !x.is_finite() || !y.is_finite() || x.abs() > 600_000.0 || y.abs() > 600_000.0 { + cancel(app); + app.ui.status = tl!("Frame coordinates must be finite and within 600000 pixels").into(); + app.ui.status_error = true; + return true; + } + if app.ui.transform.is_none() { + app.ui.frame_tool.auto_transform = false; + } match ev { ToolEvent::Down { x, y, .. } => { - if app.session.active().is_some() { - app.ui.frame_tool.drag = Some(FrameDrag { start: [x, y], cur: [x, y], square, centred }); + cancel(app); + let p = [x, y]; + let zoom = f64::from(app.point_zoom()); + let active = selected(app).map(|l| (l.id, l.frame.clone(), target(app, l))); + let selected_control = + active.as_ref().and_then(|(id, f, t)| (*t == Target::Frame).then(|| Some((*id, control_hit(f.as_ref()?, p, zoom)?))).flatten()); + let content_control = active.as_ref().is_some_and(|(id, _, t)| { + if *t != Target::Content { + return false; + } + let Some(st) = app.session.active() else { return false }; + let Some(child) = st.doc.layer(*id).and_then(|l| l.children()).and_then(|c| c.first()) else { return false }; + let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, child); + let (x0, y0, x1, y1) = (f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)); + [(x0, y0), ((x0 + x1) / 2., y0), (x1, y0), (x1, (y0 + y1) / 2.), (x1, y1), ((x0 + x1) / 2., y1), (x0, y1), (x0, (y0 + y1) / 2.)] + .iter() + .any(|(x, y)| (x - p[0]).hypot(y - p[1]) * zoom <= HANDLE_PX) + }); + let id = selected_control + .map(|(id, _)| id) + .or_else(|| content_control.then(|| active.as_ref().map(|(id, _, _)| *id)).flatten()) + .or_else(|| frame_at(app, p)); + if let Some(id) = id { + let Some(st) = app.session.active() else { return true }; + let Some(l) = st.doc.layer(id) else { return true }; + let Some(f) = l.frame.clone() else { return true }; + let locks = st.doc.effective_locks(id); + if locks.all || locks.position { + app.ui.status = tl!("The frame is locked").into(); + app.ui.status_error = true; + return true; + } + let t = if content_control { + Target::Content + } else if selected_control.is_some() || on_border(&f, p, zoom) || l.children().is_none_or(|c| c.is_empty()) { + Target::Frame + } else if active.as_ref().is_some_and(|(prev, _, t)| *prev == id && *t == Target::Content) { + Target::Content + } else { + Target::Both + }; + select(app, id, t); + if t != Target::Frame { + // The canvas's own context: preview textures loaded into another never draw. + let Some(ctx) = app.frame_tool_ctx.clone() else { return true }; + match crate::transform_tool::begin(app, &ctx) { + Ok(()) => { + app.ui.frame_tool.auto_transform = true; + crate::transform_tool::pointer(app, ev, mods); + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } + return true; + } + let Some(st) = app.session.active() else { return true }; + app.ui.frame_tool.drag = Some(FrameDrag::Edit { + layer: id, + doc: st.doc.id, + revision: st.revision, + original: f.clone(), + proposed: f.transform, + start: p, + mode: selected_control.map_or(EditMode::Move, |(_, m)| m), + }); + } else if let Some(st) = app.session.active() { + app.ui.frame_tool.drag = + Some(FrameDrag::New { start: p, cur: p, square: mods.shift, centred: mods.alt, doc: st.doc.id, revision: st.revision }); } } ToolEvent::Move { x, y, .. } => { if let Some(d) = &mut app.ui.frame_tool.drag { - *d = FrameDrag { cur: [x, y], square, centred, ..*d }; + match d { + FrameDrag::New { cur, square, centred, .. } => { + *cur = [x, y]; + *square = mods.shift; + *centred = mods.alt; + } + FrameDrag::Edit { original, proposed, start, mode, .. } => *proposed = edit_transform(original, *mode, *start, [x, y], mods), + } } + update_preview(app); } ToolEvent::Up { x, y } => { - let Some(mut d) = app.ui.frame_tool.drag.take() else { return true }; - d.cur = [x, y]; - let screen = (d.cur[0] - d.start[0]).hypot(d.cur[1] - d.start[1]) * f64::from(app.point_zoom()); - let r = drawn(&d); - if screen < CLICK_PX || r[2] <= r[0] || r[3] <= r[1] { + let Some(d) = app.ui.frame_tool.drag.take() else { return true }; + app.frame_preview = None; + let (doc, revision) = match &d { + FrameDrag::New { doc, revision, .. } | FrameDrag::Edit { doc, revision, .. } => (*doc, *revision), + }; + if !app.session.active().is_some_and(|st| st.doc.id == doc && st.revision == revision) { return true; } - let mut p = json!({ - "x": r[0], "y": r[1], "width": r[2] - r[0], "height": r[3] - r[1], - "shape": if app.ui.frame_tool.ellipse { "ellipse" } else { "rectangle" }, - }); - if let Some(id) = content_layer(app, r) { - p["layer"] = json!(id); - } - if let Err(e) = app.run("layer.new.frame", p) { - app.ui.status = e; - app.ui.status_error = true; + match d { + FrameDrag::New { start, .. } => { + if (x - start[0]).hypot(y - start[1]) * f64::from(app.point_zoom()) < CLICK_PX { + return true; + } + let r = drawn(start, [x, y], mods.shift, mods.alt); + if r[2] <= r[0] || r[3] <= r[1] { + return true; + } + let mut p = shape_params(app, r); + if let Some(id) = content_layer(app, r) { + p["layer"] = json!(id); + } + match app.run("layer.new.frame", p) { + Ok(v) => { + if let Some(id) = v.get("layer").and_then(Value::as_u64) { + select(app, LayerId(id), Target::Frame); + } + } + Err(e) => report(app, Err(e)), + } + } + FrameDrag::Edit { layer, original, start, mode, .. } => { + let transform = edit_transform(&original, mode, start, [x, y], mods); + if transform != original.transform { + let r = app.run("layer.frame.set", json!({"layer":layer.0,"transform":transform.m})); + report(app, r); + } + } } } } true } - -/// The frame a drag in progress would leave. +fn paint_path(painter: &egui::Painter, xf: &ViewXform, path: &photocraft_doc::Path, stroke: Stroke) { + for line in photocraft_vector::flatten::flatten_path(path, 0.5 / f64::from(xf.zoom).max(0.01)) { + let mut points: Vec = line.pts.iter().map(|&(x, y)| xf.to_screen(x as f32, y as f32)).collect(); + if line.closed + && let Some(first) = points.first().copied() + { + points.push(first); + } + painter.add(egui::Shape::line(points, stroke)); + } +} pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { - let Some(d) = app.ui.frame_tool.drag.filter(|_| app.ui.tool == Tool::Frame) else { return }; let t = Tokens::get(painter.ctx()); - let r = drawn(&d).map(|v| v as f32); let stroke = Stroke::new(1.5, t.accent); - let points: Vec = if app.ui.frame_tool.ellipse { - let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0); - (0..=64).map(|i| i as f32 * std::f32::consts::TAU / 64.0).map(|a| xf.to_screen(cx + rx * a.cos(), cy + ry * a.sin())).collect() - } else { - [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3]), (r[0], r[1])].into_iter().map(|(x, y)| xf.to_screen(x, y)).collect() + if let Some(st) = app.session.active() { + for (path, _, l) in st.doc.walk() { + if !path_visible(&st.doc, &path) { + continue; + } + let Some(f) = &l.frame else { continue }; + if l.children().is_some_and(|c| c.is_empty()) { + let q = f.corners().map(|p| xf.to_screen(p.x as f32, p.y as f32)); + paint_path(painter, xf, &f.outline(), Stroke::new(1., t.text_faint)); + painter.line_segment([q[0], q[2]], Stroke::new(1., t.text_faint)); + painter.line_segment([q[1], q[3]], Stroke::new(1., t.text_faint)); + } + } + } + if app.ui.tool != Tool::Frame { + return; + } + if let Some(FrameDrag::New { start, cur, square, centred, .. }) = &app.ui.frame_tool.drag { + if let Some(path) = outline(app, drawn(*start, *cur, *square, *centred)) { + paint_path(painter, xf, &path, stroke); + } + return; + } + let Some(l) = selected(app) else { return }; + if !visible(app, l.id) { + return; + } + let Some(original) = &l.frame else { return }; + let mut f = original.clone(); + if let Some(t) = app.ui.transform.as_ref().filter(|t| frame_transform(t)) + && let Ok(transform) = transformed_frame(app, t) + { + f.transform = transform; + } + if let Some(FrameDrag::Edit { proposed, .. }) = &app.ui.frame_tool.drag { + f.transform = *proposed; + } + if !f.valid() { + return; + } + paint_path(painter, xf, &f.outline(), stroke); + if app.ui.transform.is_some() { + return; + } + let target = target(app, l); + if target == Target::Content { + if let Some(st) = app.session.active() + && let Some(child) = l.children().and_then(|c| c.first()) + { + let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, child); + let r = egui::Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32)); + painter.rect_stroke(r, 0., stroke, StrokeKind::Inside); + for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { + paint_handle(painter, p, &t); + } + } + } else if target == Target::Frame { + for (p, _, _) in handles(&f) { + paint_handle(painter, xf.to_screen(p.x as f32, p.y as f32), &t); + } + } +} +fn paint_handle(p: &egui::Painter, at: Pos2, t: &Tokens) { + let r = egui::Rect::from_center_size(at, egui::vec2(6., 6.)); + p.rect_filled(r, 0., t.card); + p.rect_stroke(r, 0., Stroke::new(1., t.accent), StrokeKind::Inside); +} +pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { + let zoom = f64::from(app.point_zoom()); + if let Some(l) = selected(app) + && let Some(f) = &l.frame + && target(app, l) == Target::Frame + && let Some(hit) = control_hit(f, p, zoom) + { + return Some(match hit { + EditMode::Resize(0, _) => CursorIcon::ResizeVertical, + EditMode::Resize(_, 0) => CursorIcon::ResizeHorizontal, + EditMode::Resize(x, y) if x == y => CursorIcon::ResizeNwSe, + EditMode::Resize(..) => CursorIcon::ResizeNeSw, + _ => CursorIcon::Crosshair, + }); + } + frame_at(app, p).map(|_| CursorIcon::Move) +} +fn stroke_json(s: &photocraft_doc::ShapeStroke) -> Value { + let fill = match &s.paint { + photocraft_doc::Fill::Solid(c) => { + let [r, g, b] = c.to_rgb(); + json!([r, g, b, c.alpha]) + } + _ => Value::Null, }; - painter.add(egui::Shape::line(points, stroke)); + json!({"color":fill,"width":s.width,"opacity":s.opacity*100.,"align":match s.align{photocraft_doc::StrokeAlign::Inside=>"inside",photocraft_doc::StrokeAlign::Outside=>"outside",_=>"center"}}) +} +fn stroke_controls(ui: &mut egui::Ui, value: &mut Option) -> bool { + let before = value.clone(); + let mut enabled = value.is_some(); + if crate::widgets::checkbox(ui, &mut enabled, tl!("Stroke")).changed() { + *value = enabled.then(photocraft_doc::ShapeStroke::default); + } + if let Some(s) = value { + let c = match s.paint { + photocraft_doc::Fill::Solid(c) => c, + _ => photocraft_doc::Color::BLACK, + }; + let [r, g, b] = c.to_rgb(); + let mut rgba = [r, g, b, c.alpha]; + if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { + s.paint = photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3])); + } + crate::widgets::value_field_named(ui, tl!("Stroke:"), &mut s.width, 0.0..=1000.0, "px", 58.0); + craft_ui::ComboBox::from_id_salt("frame-stroke-align") + .selected_text(match s.align { + photocraft_doc::StrokeAlign::Inside => tl!("Inside"), + photocraft_doc::StrokeAlign::Outside => tl!("Outside"), + _ => tl!("Center"), + }) + .show_ui(ui, |ui| { + for (a, label) in [ + (photocraft_doc::StrokeAlign::Inside, tl!("Inside")), + (photocraft_doc::StrokeAlign::Center, tl!("Center")), + (photocraft_doc::StrokeAlign::Outside, tl!("Outside")), + ] { + ui.selectable_value(&mut s.align, a, label); + } + }); + } + *value != before } - -/// The Frame tool's options bar: Create a new rectangular frame / elliptical frame. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { if tool != Tool::Frame { return false; } - let ellipse = app.ui.frame_tool.ellipse; - if crate::icons::button(ui, "square", 24.0, !ellipse, tl!("Create a new rectangular frame")).clicked() { - app.ui.frame_tool.ellipse = false; + app.frame_tool_ctx = Some(ui.ctx().clone()); + for (icon, shape, label) in [ + ("square", "rectangle", tl!("Create a new rectangular frame")), + ("circle", "ellipse", tl!("Create a new elliptical frame")), + ("triangle", "triangle", tl!("Create a new triangular frame")), + ("pentagon", "polygon", tl!("Create a new polygonal frame")), + ("shapes", "custom", tl!("Create a new custom frame")), + ] { + let current = if app.ui.frame_tool.ellipse { + "ellipse" + } else if app.ui.frame_tool.shape.is_empty() { + "rectangle" + } else { + &app.ui.frame_tool.shape + }; + if crate::icons::button(ui, icon, 24., current == shape, label).clicked() { + app.ui.frame_tool.shape = shape.into(); + app.ui.frame_tool.ellipse = shape == "ellipse"; + } } - if crate::icons::button(ui, "circle", 24.0, ellipse, tl!("Create a new elliptical frame")).clicked() { - app.ui.frame_tool.ellipse = true; + if app.ui.frame_tool.shape == "polygon" { + ui.label(tl!("Sides:")); + let mut n = app.ui.frame_tool.sides.clamp(3, 100) as f32; + crate::widgets::value_field_named(ui, tl!("Sides:"), &mut n, 3.0..=100.0, "", 44.); + app.ui.frame_tool.sides = n as u32; + } + if app.ui.frame_tool.shape == "custom" { + crate::preset_panels::shape_picker(app, ui); + } + crate::widgets::vline(ui, 22.); + let gesture = crate::vector_ui::paint_gesture(ui, ui.scope_id().with("frame-options-gesture")); + let coalesce = format!("frame-stroke-{}-{gesture}", ui.scope_id().value()); + let mut stroke = selected(app).and_then(|l| l.frame.as_ref()).map_or_else(|| app.ui.frame_tool.stroke.clone(), |f| f.stroke.clone()); + if stroke_controls(ui, &mut stroke) { + app.ui.frame_tool.stroke = stroke.clone(); + if let Some(id) = selected(app).map(|l| l.id) { + let params = stroke.as_ref().map(stroke_json).unwrap_or(Value::Null); + let r = app.run("layer.frame.set", json!({"layer":id.0,"stroke":params,"coalesce":coalesce})); + report(app, r); + } } true } - -#[cfg(test)] -mod tests { - use super::*; - use crate::canvas::tool_event; - use photocraft_doc::LayerId; - - fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { - let m = egui::Modifiers::NONE; - tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); - tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); - tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +/// Frame-aware Properties: W/H/X/Y edit the container; the content can be selected/fitted/replaced. +pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer) { + let Some(f) = &l.frame else { return }; + if app.session.active().is_some_and(|st| { + let locks = st.doc.effective_locks(l.id); + locks.all || locks.position + }) { + ui.disable(); } - - /// K picks the Frame tool; a rectangular drag makes one frame (one undo step) clipped to the - /// drawn rect; with the elliptical button the frame's corners are hidden. - #[test] - fn frame_tool_draws_rectangular_and_elliptical_frames() { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.sync_views(); - app.ui.extras.snap = false; - let ctx = egui::Context::default(); - let raw = egui::RawInput { - events: vec![egui::Event::Key { key: egui::Key::K, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], - ..Default::default() - }; - ctx.begin_pass(raw); - crate::shortcuts::handle(&mut app, &ctx); - ctx.end_pass().textures_delta.clear(); - assert_eq!(app.ui.tool, Tool::Frame); - - let frame = |app: &PhotocraftApp| { - let st = app.session.active().unwrap(); - let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); - assert!(matches!(l.content, LayerContent::Group(_)) && l.name.starts_with("Frame"), "the new frame is selected"); - (l.id, l.mask.clone().unwrap()) - }; - let layers = app.session.active().unwrap().doc.layers.len(); - drag(&mut app, [20.0, 30.0], [120.0, 110.0]); - let (_, m) = frame(&app); - assert_eq!((m.value(20, 30), m.value(119, 109), m.value(19, 30), m.value(120, 109)), (1.0, 1.0, 0.0, 0.0)); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "one undo takes the frame back"); - - app.ui.frame_tool.ellipse = true; - drag(&mut app, [20.0, 30.0], [120.0, 110.0]); - let (id, m): (LayerId, _) = frame(&app); - assert_eq!((m.value(70, 70), m.value(20, 30)), (1.0, 0.0), "centre shown, corner hidden"); - // A click draws nothing. - drag(&mut app, [300.0, 250.0], [300.0, 250.0]); - assert_eq!(frame(&app).0, id); + let gesture = crate::vector_ui::paint_gesture(ui, ui.scope_id().with("frame-properties-gesture")); + let coalesce = format!("frame-properties-{}-{gesture}", l.id.0); + let [a, b, c, d, x, y] = f.transform.m; + let (w, h) = (a.hypot(b), c.hypot(d)); + let mut values = [w as f32, h as f32, x as f32, y as f32]; + let mut changed = false; + ui.label(tl!("Frame")); + ui.horizontal(|ui| { + for (i, label) in [(0, tl!("W")), (1, tl!("H"))] { + ui.label(label); + if let Some(v) = values.get_mut(i) { + changed |= crate::widgets::value_field_named(ui, label, v, 1.0..=300_000.0, "px", 66.).changed(); + } + } + }); + ui.horizontal(|ui| { + for (i, label) in [(2, tl!("X")), (3, tl!("Y"))] { + ui.label(label); + if let Some(v) = values.get_mut(i) { + changed |= crate::widgets::value_field_named(ui, label, v, -300_000.0..=300_000.0, "px", 66.).changed(); + } + } + }); + if changed && w > 0. && h > 0. { + let [nw, nh, nx, ny] = values; + let r = app.run( + "layer.frame.set", + json!({"layer":l.id.0,"transform":[a*f64::from(nw)/w,b*f64::from(nw)/w,c*f64::from(nh)/h,d*f64::from(nh)/h,nx,ny],"coalesce":coalesce}), + ); + report(app, r); + } + let mut stroke = f.stroke.clone(); + ui.horizontal_wrapped(|ui| { + if stroke_controls(ui, &mut stroke) { + let r = app.run("layer.frame.set", json!({"layer":l.id.0,"stroke":stroke.as_ref().map(stroke_json).unwrap_or(Value::Null),"coalesce":coalesce})); + report(app, r); + } + }); + ui.separator(); + ui.horizontal_wrapped(|ui| { + if ui.selectable_label(target(app, l) == Target::Frame, tl!("Frame")).clicked() { + select(app, l.id, Target::Frame); + } + if l.children().is_some_and(|c| !c.is_empty()) { + if ui.selectable_label(target(app, l) == Target::Content, tl!("Image")).clicked() { + select(app, l.id, Target::Content); + } + if ui.selectable_label(target(app, l) == Target::Both, tl!("Both")).clicked() { + select(app, l.id, Target::Both); + } + } + }); + if ui.button(if l.children().is_some_and(|c| !c.is_empty()) { tl!("Replace Image…") } else { tl!("Import Image…") }).clicked() { + select(app, l.id, Target::Frame); + let ctx = ui.ctx().clone(); + let r = crate::menus::invoke(app, &ctx, "file.placeEmbedded", json!({})); + report(app, r); + } + if l.children().is_some_and(|c| !c.is_empty()) { + for (mode, label) in [("contentToFrame", tl!("Fit Content to Frame")), ("frameToContent", tl!("Fit Frame to Content"))] { + if ui.button(label).clicked() { + let r = app.run("layer.frame.fit", json!({"layer":l.id.0,"mode":mode})); + report(app, r); + } + } } } +#[cfg(test)] +mod tests; + +/// A compact on-canvas import/replace bar, following the currently selected frame. +pub fn task_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, xf: &ViewXform) { + if app.ui.tool != Tool::Frame || app.ui.frame_tool.drag.is_some() || app.ui.transform.is_some() || !app.ui.dialogs.is_empty() { + return; + } + let Some(l) = selected(app) else { return }; + if !visible(app, l.id) { + return; + } + let (id, empty) = (l.id, l.children().is_none_or(|c| c.is_empty())); + let Some(f) = &l.frame else { return }; + let bounds = f.bounds(); + let p = xf.to_screen((f64::from(bounds.x0) + f64::from(bounds.x1)) as f32 / 2., bounds.y1 as f32); + let width = if empty { 170.0 } else { 290.0 }; + let at = egui::pos2( + (p.x - width / 2.).clamp(xf.rect.left() + 8., (xf.rect.right() - width - 8.).max(xf.rect.left() + 8.)), + (p.y + 18.).min(xf.rect.bottom() - 42.).max(xf.rect.top() + 8.), + ); + let ctx = ui.ctx().clone(); + let t = Tokens::get(&ctx); + egui::Area::new(egui::Id::unique("frame-context-bar")).order(egui::Order::Middle).fixed_pos(at).show(&ctx, |ui| { + egui::Frame::NONE.fill(t.card).stroke(Stroke::new(1., t.field_border)).corner_radius(t.radius).inner_margin(6.).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button(if empty { tl!("Import Image…") } else { tl!("Replace Image…") }).clicked() { + select(app, id, Target::Frame); + let r = crate::menus::invoke(app, &ctx, "file.placeEmbedded", json!({})); + report(app, r); + } + if !empty && ui.button(tl!("Fit Content to Frame")).clicked() { + let r = app.run("layer.frame.fit", json!({"layer":id.0})); + report(app, r); + } + }); + }); + }); +} + +/// Free Transform on the frame thumbnail edits the clipping geometry, leaving its image intact. +pub(crate) fn frame_transform(t: &crate::state::TransformSession) -> bool { + t.target.as_ref().and_then(|v| v.get("frame")).and_then(Value::as_u64) == Some(t.layer) +} +pub(crate) fn transformed_frame(app: &PhotocraftApp, t: &crate::state::TransformSession) -> Result { + if t.warp.is_some() { + return Err(tl!("A frame supports affine transforms; select its image to warp it").into()); + } + let [x0, y0, x1, y1] = t.rect; + let (w, h) = (x1 - x0, y1 - y0); + let [q0, q1, q2, q3] = t.quad; + if w <= 0. + || h <= 0. + || !t.quad.iter().flatten().all(|v| v.is_finite()) + || ((q1[0] + q3[0] - q0[0]) - q2[0]).abs() > 0.001 + || ((q1[1] + q3[1] - q0[1]) - q2[1]).abs() > 0.001 + { + return Err(tl!("A frame supports affine transforms; select its image for perspective transforms").into()); + } + let (a, b, c, d) = ((q1[0] - q0[0]) / w, (q1[1] - q0[1]) / w, (q3[0] - q0[0]) / h, (q3[1] - q0[1]) / h); + let map = Affine { m: [a, b, c, d, q0[0] - a * x0 - c * y0, q0[1] - b * x0 - d * y0] }; + let original = app + .session + .active() + .and_then(|st| st.doc.layer(LayerId(t.layer))) + .and_then(|l| l.frame.as_ref()) + .ok_or_else(|| tl!("The frame is unavailable").to_string())?; + let mut f = original.clone(); + f.transform = map.mul(&f.transform); + if !f.valid() { + return Err(tl!("Invalid frame transform").into()); + } + Ok(f.transform) +} + +/// Arrow keys respect which thumbnail is selected; Shift moves ten document pixels. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + if app.ui.tool != Tool::Frame + || app.ui.transform.is_some() + || app.ui.frame_tool.drag.is_some() + || ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.alt) + { + return false; + } + let Some(l) = selected(app) else { return false }; + let (id, t, frame, child) = (l.id, target(app, l), l.frame.clone(), l.children().and_then(|c| c.first()).map(|l| l.id)); + for (key, dx, dy) in [(egui::Key::ArrowLeft, -1., 0.), (egui::Key::ArrowRight, 1., 0.), (egui::Key::ArrowUp, 0., -1.), (egui::Key::ArrowDown, 0., 1.)] { + for mods in [egui::Modifiers::SHIFT, egui::Modifiers::NONE] { + if ctx.input_mut(|i| i.consume_key(mods, key)) { + let k = if mods.shift { 10. } else { 1. }; + app.nudge_bundle.now = ctx.input(|i| i.time); + let (command, params) = if t == Target::Frame { + let Some(mut f) = frame.clone() else { return true }; + f.transform = Affine::translate(dx * k, dy * k).mul(&f.transform); + ("layer.frame.set", json!({"layer":id.0,"transform":f.transform.m})) + } else { + ("layer.translate", json!({"layer":if t==Target::Content{child.unwrap_or(id).0}else{id.0},"dx":dx*k,"dy":dy*k})) + }; + let r = crate::nudge_bundle::run(app, "frame", command, params); + report(app, r); + return true; + } + } + } + false +} diff --git a/crates/ui-egui/src/tools/frame_tool/tests.rs b/crates/ui-egui/src/tools/frame_tool/tests.rs new file mode 100644 index 0000000000..f0be9a649f --- /dev/null +++ b/crates/ui-egui/src/tools/frame_tool/tests.rs @@ -0,0 +1,572 @@ +use super::*; +use crate::canvas::tool_event; +use photocraft_doc::LayerId; + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + let m = egui::Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +} + +/// K picks the Frame tool; a rectangular drag makes one frame (one undo step) clipped to the +/// drawn rect; with the elliptical button the frame's corners are hidden. +#[test] +fn frame_tool_draws_rectangular_and_elliptical_frames() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::K, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + assert_eq!(app.ui.tool, Tool::Frame); + + let frame = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert!(matches!(l.content, LayerContent::Group(_)) && l.name.starts_with("Frame"), "the new frame is selected"); + (l.id, TestMask(l.vector_mask.as_ref().unwrap().path.clone())) + }; + let layers = app.session.active().unwrap().doc.layers.len(); + drag(&mut app, [20.0, 30.0], [120.0, 110.0]); + let (_, m) = frame(&app); + assert_eq!((m.value(20, 30), m.value(119, 109), m.value(19, 30), m.value(120, 109)), (1.0, 1.0, 0.0, 0.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "one undo takes the frame back"); + + app.ui.frame_tool.ellipse = true; + drag(&mut app, [20.0, 30.0], [120.0, 110.0]); + let (id, m): (LayerId, _) = frame(&app); + assert_eq!((m.value(70, 70), m.value(20, 30)), (1.0, 0.0), "centre shown, corner hidden"); + // A click draws nothing. + drag(&mut app, [300.0, 250.0], [300.0, 250.0]); + assert_eq!(frame(&app).0, id); +} + +struct TestMask(photocraft_doc::Path); +impl TestMask { + fn value(&self, x: i32, y: i32) -> f32 { + photocraft_vector::path_coverage(&self.0, Rect::new(x, y, x + 1, y + 1))[0] + } +} + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool = Tool::Frame; + app +} + +#[test] +fn markers_survive_release_and_handles_resize_the_frame_without_creating_another() { + let mut app = app(); + drag(&mut app, [20., 30.], [120., 110.]); + let id = selected(&app).unwrap().id; + let count = app.session.active().unwrap().doc.layer_count(); + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(400., 300.)), + zoom: 1.0, + center: [200., 150.], + flip: false, + rotation: 0., + aspect: 1., + }; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| draw_overlay(&app, ui.painter(), &xf)); + output.textures_delta.clear(); + assert!(app.ui.frame_tool.drag.is_none()); + assert!(output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Rect(_))).count() >= 8, "eight visible handles after release"); + drag(&mut app, [120., 110.], [145., 130.]); + assert_eq!(app.session.active().unwrap().doc.layer_count(), count, "resizing does not create another frame"); + assert_eq!(selected(&app).unwrap().id, id); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().transform.m, [125., 0., 0., 100., 20., 30.]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().transform.m, [100., 0., 0., 80., 20., 30.]); +} + +#[test] +fn dragging_border_moves_only_frame_and_escape_discards_live_preview() { + let mut app = app(); + drag(&mut app, [20., 30.], [120., 110.]); + let before = selected(&app).unwrap().frame.clone(); + let entries = app.session.active().unwrap().history.entries(); + pointer(&mut app, ToolEvent::Down { x: 50., y: 30., pressure: 1. }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Move { x: 80., y: 50., pressure: 1. }, egui::Modifiers::NONE); + assert_eq!(selected(&app).unwrap().frame, before, "preview never changes document"); + assert!(app.frame_preview.is_some()); + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + assert!(app.frame_preview.is_none()); + assert!(app.ui.frame_tool.drag.is_none()); + assert_eq!(app.session.active().unwrap().history.entries(), entries); + drag(&mut app, [50., 30.], [80., 50.]); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().transform.m, [100., 0., 0., 80., 50., 50.]); +} + +#[test] +fn frame_content_and_both_selection_follow_clicks_and_double_clicks() { + let mut app = app(); + let source = app + .session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + let frame = selected(&app).unwrap().id; + let original = selected(&app).unwrap().frame.clone(); + double_click(&mut app, [65., 65.]); + assert_eq!(app.ui.frame_tool.target, Target::Content); + assert_eq!(app.session.active().unwrap().active_layer, Some(source)); + double_click(&mut app, [65., 65.]); + assert_eq!(app.ui.frame_tool.target, Target::Both); + assert_eq!(app.session.active().unwrap().active_layer, Some(frame)); + drag(&mut app, [50., 30.], [65., 40.]); + assert_eq!(app.ui.frame_tool.target, Target::Frame); + assert_ne!(selected(&app).unwrap().frame, original); + let source_before = app.session.active().unwrap().doc.layer(source).unwrap().clone(); + double_click(&mut app, [70., 75.]); + let f = selected(&app).unwrap().frame.clone(); + let ctx = egui::Context::default(); + crate::PhotocraftApp::setup_context(&ctx, Default::default()); + ctx.begin_pass(Default::default()); + app.frame_tool_ctx = Some(ctx.clone()); + drag(&mut app, [70., 75.], [85., 85.]); + ctx.end_pass().textures_delta.clear(); + assert!(!app.ui.frame_tool.auto_transform); + assert!(app.ui.transform.is_none()); + assert_eq!(selected(&app).unwrap().frame, f, "image-only transform leaves the mask"); + assert_ne!(app.session.active().unwrap().doc.layer(source).unwrap(), &source_before); +} + +#[test] +fn modifiers_shape_selection_and_invalid_control_params_are_consistent() { + let mut app = app(); + let ctx = egui::Context::default(); + let call = |app: &mut PhotocraftApp, p: Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + let crate::control::Outcome::Done(v) = crate::control::handle(app, &ctx, &req) else { panic!("immediate state update") }; + v + }; + assert_eq!(call(&mut app, json!({"frameShape":"triangle","frameSides":6}))["ok"], true); + assert_eq!(call(&mut app, json!({"frameShape":"invalid","tool":"brush"}))["ok"], false); + assert_eq!(app.ui.tool, Tool::Frame); + assert_eq!(call(&mut app, json!({"frameSides":100000}))["ok"], false); + let mods = egui::Modifiers { shift: true, alt: true, ..Default::default() }; + pointer(&mut app, ToolEvent::Down { x: 100., y: 100., pressure: 1. }, mods); + pointer(&mut app, ToolEvent::Up { x: 130., y: 120. }, mods); + let f = selected(&app).unwrap().frame.as_ref().unwrap(); + assert_eq!(f.bounds(), Rect::new(70, 70, 130, 130)); + assert_eq!(TestMask(f.outline()).value(70, 70), 0.0, "triangle rather than bounding rectangle"); + let frame = selected(&app).unwrap().id; + let content = app + .session + .edit("Image", |doc, _| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(20, 20, 100, 100), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + Ok(id) + }) + .unwrap(); + app.run("layer.frame.setContent", json!({"layer":frame.0,"content":content.0})).unwrap(); + assert_eq!(call(&mut app, json!({"frameTarget":"content"}))["ok"], true); + assert_eq!(app.session.active().unwrap().active_layer, Some(content)); + assert_eq!(call(&mut app, json!({"frameTarget":"both"}))["ok"], true); + assert_eq!(app.session.active().unwrap().active_layer, Some(frame)); +} + +#[test] +fn frame_creation_handles_render_at_native_scales() { + let Some(out) = std::env::var_os("PHOTOCRAFT_FRAME_SNAPSHOTS") else { return }; + let _guard = photocraft_testkit::gpu_lock(); + let out = std::path::PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + for theme in crate::theme::ThemeKind::ALL { + for (width, height) in [(1280.0, 720.0), (800.0, 480.0)] { + for scale in [1.0, 2.0] { + let mut app = app(); + app.ui.theme = theme; + app.run("prefs.set", json!({"path":"interface.theme","value":theme.id()})).unwrap(); + app.custom_titlebar = true; + app.session.prefs.edit(|p| p.general.animated_zoom = false); + drag(&mut app, [20.0, 30.0], [180.0, 130.0]); + let empty = selected(&app).unwrap().id; + let source = app + .session + .edit("Synthetic image", |doc, active| { + let mut layer = Layer::raster("Sample", doc.pixel_format()); + for y in 0..100 { + for x in 0..100 { + layer.surface_mut().unwrap().write_pixel(x + 200, y + 60, &[x as f32 / 100.0, y as f32 / 100.0, 0.7, 1.0]); + } + } + let id = layer.id; + doc.layers.push(layer); + *active = Some(id); + Ok(id) + }) + .unwrap(); + app.run("layer.new.frame",json!({"x":210,"y":70,"width":120,"height":160,"shape":"ellipse","layer":source.0,"stroke":{"color":"#50a0ef","width":4,"align":"outside"}})).unwrap(); + select(&mut app, empty, Target::Frame); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, height)).with_pixels_per_point(scale).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }); + h.run_steps(6); + assert_eq!(h.state().ui.theme, theme); + let img = h.render().unwrap(); + assert_eq!((img.width(), img.height()), ((width * scale) as u32, (height * scale) as u32)); + img.save(out.join(format!("frame-{theme:?}-{width}-{scale}x.png"))).unwrap(); + } + } + } +} + +#[test] +fn free_transform_on_the_frame_thumbnail_previews_and_commits_only_the_clip() { + let mut app = app(); + let source = app + .session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + let frame = selected(&app).unwrap().id; + let original = app.session.active().unwrap().doc.layer(source).unwrap().clone(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + ctx.begin_pass(Default::default()); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(frame_transform(app.ui.transform.as_ref().unwrap())); + app.ui.transform.as_mut().unwrap().quad = [[40., 40.], [90., 40.], [90., 80.], [40., 80.]]; + let (shown, _) = crate::canvas::display_doc(&mut app, 0); + assert_eq!(shown.layer(frame).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(40, 40, 90, 80)); + assert_eq!(shown.layer(source).unwrap(), &original); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(20, 30, 120, 110)); + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none()); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(40, 40, 90, 80)); + assert_eq!(app.session.active().unwrap().doc.layer(source).unwrap(), &original); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(20, 30, 120, 110)); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + app.ui.transform.as_mut().unwrap().quad = [[50., 50.], [100., 50.], [100., 90.], [50., 90.]]; + crate::transform_tool::cancel(&mut app); + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(20, 30, 120, 110)); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn real_canvas_clicks_select_both_then_double_click_selects_the_image() { + let mut app = app(); + app.session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + let frame = selected(&app).unwrap().id; + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(egui::vec2(1000., 700.)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + app.frame_tool_ctx = Some(ui.ctx().clone()); + crate::canvas::document_area(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + let p = ViewXform::active(h.state()).unwrap().to_screen(65., 65.); + for press in [true, false] { + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: press, modifiers: egui::Modifiers::NONE }); + h.step(); + } + assert_eq!(h.state().ui.frame_tool.target, Target::Both); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(frame)); + for press in [true, false] { + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: press, modifiers: egui::Modifiers::NONE }); + h.step(); + } + assert_eq!(h.state().ui.frame_tool.target, Target::Content); + assert_ne!(h.state().session.active().unwrap().active_layer, Some(frame)); +} + +#[test] +fn hostile_frame_pointer_coordinates_never_publish_an_invalid_preview() { + let mut app = app(); + app.ui.frame_tool.shape = "custom".into(); + let before = app.session.active().unwrap().doc.clone(); + for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, 1e30, -1e30] { + assert!(pointer(&mut app, ToolEvent::Down { x, y: 30., pressure: 1. }, Default::default())); + pointer(&mut app, ToolEvent::Move { x: -x, y: 40., pressure: 1. }, Default::default()); + pointer(&mut app, ToolEvent::Up { x, y: 50. }, Default::default()); + assert!(app.ui.frame_tool.drag.is_none()); + assert!(app.frame_preview.is_none()); + assert_eq!(app.session.active().unwrap().doc.layers, before.layers); + } +} + +#[test] +fn frame_keyboard_nudges_change_only_the_selected_thumbnail() { + let mut app = app(); + app.session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + let frame = selected(&app).unwrap().id; + let child = selected(&app).unwrap().children().unwrap()[0].clone(); + let ctx = egui::Context::default(); + for (key, mods) in [(egui::Key::ArrowRight, egui::Modifiers::NONE), (egui::Key::ArrowDown, egui::Modifiers::SHIFT)] { + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }], + ..Default::default() + }); + assert!(keys(&mut app, &ctx)); + ctx.end_pass().textures_delta.clear(); + } + assert_eq!(selected(&app).unwrap().frame.as_ref().unwrap().bounds(), Rect::new(21, 40, 121, 120)); + assert_eq!(app.session.active().unwrap().doc.layer(child.id).unwrap(), &child); + select(&mut app, frame, Target::Content); + let f = selected(&app).unwrap().frame.clone(); + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ..Default::default() + }); + assert!(keys(&mut app, &ctx)); + ctx.end_pass().textures_delta.clear(); + assert_eq!(selected(&app).unwrap().frame, f); + assert_ne!(app.session.active().unwrap().doc.layer(child.id).unwrap(), &child); +} + +#[test] +fn hidden_frame_ancestors_hide_placeholders_and_handles() { + let mut app = app(); + drag(&mut app, [20., 30.], [120., 110.]); + let id = selected(&app).unwrap().id; + app.session + .edit("Hide group", |doc, _| { + let frame = doc.remove(id).unwrap(); + let mut parent = Layer::group("Hidden", vec![frame]); + parent.visible = false; + doc.layers.push(parent); + Ok(()) + }) + .unwrap(); + assert_eq!(frame_at(&app, [50., 50.]), None); + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(400., 300.)), + zoom: 1., + center: [200., 150.], + flip: false, + rotation: 0., + aspect: 1., + }; + let mut output = ctx.run_ui(Default::default(), |ui| draw_overlay(&app, ui.painter(), &xf)); + output.textures_delta.clear(); + assert!(output.shapes.iter().all(|s| matches!(s.shape, egui::Shape::Noop))); +} + +#[test] +fn drawing_a_frame_with_another_frames_image_selected_leaves_that_image_in_place() { + let mut app = app(); + let source = app + .session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + let first = selected(&app).unwrap().id; + double_click(&mut app, [65., 65.]); + assert_eq!(app.session.active().unwrap().active_layer, Some(source)); + // Starts outside the first frame but overlaps the image's own bounds. + drag(&mut app, [150., 150.], [250., 250.]); + let doc = &app.session.active().unwrap().doc; + assert_eq!(photocraft_engine::frame_cmds::frame_of(doc, source), Some(first), "the image stays in its frame"); + let second = selected(&app).unwrap(); + assert_ne!(second.id, first); + assert!(second.children().unwrap().is_empty(), "the new frame is empty"); +} + +/// Esc during a frame's content drag ends its automatic transform: a Free Transform started +/// afterwards waits for Enter instead of committing on its first pointer release. +#[test] +fn cancelling_a_frame_content_drag_clears_its_auto_commit() { + let mut app = app(); + app.session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + double_click(&mut app, [65., 65.]); + let ctx = egui::Context::default(); + crate::PhotocraftApp::setup_context(&ctx, Default::default()); + ctx.begin_pass(Default::default()); + app.frame_tool_ctx = Some(ctx.clone()); + tool_event(&mut app, ToolEvent::Down { x: 70., y: 75., pressure: 1.0 }, egui::Modifiers::NONE); + assert!(app.ui.frame_tool.auto_transform, "a content drag transforms automatically"); + crate::transform_tool::cancel(&mut app); + ctx.end_pass().textures_delta.clear(); + assert!(app.ui.transform.is_none()); + assert!(!app.ui.frame_tool.auto_transform); +} + +/// Double-clicking a frame's content thumbnail opens a Smart Object's contents; a pixel layer +/// inside a frame (as PSD files can hold) has no contents to open. +#[test] +fn double_clicking_frame_content_opens_only_smart_objects() { + use egui::{Event, PointerButton, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let photo = s.execute("layer.new.layer", json!({"name": "Photo"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let smart = s.execute("layer.new.frame", json!({"x": 10, "y": 10, "width": 100, "height": 80, "layer": photo})).unwrap()["layer"].as_u64().unwrap(); + let pixels = s.execute("layer.new.frame", json!({"x": 200, "y": 10, "width": 100, "height": 80})).unwrap()["layer"].as_u64().unwrap(); + s.edit("Pixels", |doc, _| { + let l = Layer::raster("Pixels", doc.pixel_format()); + doc.layer_mut(LayerId(pixels)).and_then(Layer::children_mut).unwrap().push(l); + Ok(()) + }) + .unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let double_click_content = |h: &mut egui_kittest::Harness, layer: u64| { + // Past egui's double-click window, so the last pair's clicks don't run on into these. + h.run_steps(30); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == layer).unwrap(); + let thumb = row.thumbnail.unwrap(); + let at = thumb.center() + vec2(thumb.width() + 6.0, 0.0); + h.hover_at(at); + h.run_steps(1); + for _ in 0..2 { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + }; + double_click_content(&mut h, pixels); + assert_eq!(h.state().session.documents().len(), 1, "a pixel layer has no contents to open"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + double_click_content(&mut h, smart); + assert_eq!(h.state().session.documents().len(), 2, "the Smart Object's contents open"); +} + +#[test] +fn filled_frame_closing_border_selects_frame() { + let mut app = app(); + app.session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 200), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + double_click(&mut app, [65., 65.]); + pointer(&mut app, ToolEvent::Down { x: 20., y: 50., pressure: 1. }, egui::Modifiers::NONE); + assert_eq!(app.ui.frame_tool.target, Target::Frame); + assert!(matches!(app.ui.frame_tool.drag, Some(FrameDrag::Edit { .. }))); +} + +#[test] +fn overhanging_content_handles_start_transform_and_cancel() { + let mut app = app(); + app.session + .edit("Photo", |doc, active| { + let mut l = Layer::raster("Photo", doc.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 100), &[1., 0., 0., 1.]); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(id) + }) + .unwrap(); + drag(&mut app, [20., 30.], [120., 110.]); + double_click(&mut app, [65., 65.]); + let ctx = egui::Context::default(); + crate::PhotocraftApp::setup_context(&ctx, Default::default()); + ctx.begin_pass(Default::default()); + app.frame_tool_ctx = Some(ctx.clone()); + let st = app.session.active().unwrap(); + let frame = selected(&app).unwrap(); + let child = frame.children().unwrap().first().unwrap(); + let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, child); + assert!(b.x0 < 20, "image must overhang the frame: {b:?}"); + let entries = st.history.entries(); + for (x, y) in [(f64::from(b.x0), f64::from(b.y0)), (f64::from(b.x0), f64::from(b.y0 + b.y1) / 2.)] { + tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1. }, egui::Modifiers::NONE); + assert!(app.ui.transform.is_some()); + assert!(app.ui.frame_tool.auto_transform); + crate::transform_tool::cancel(&mut app); + assert!(app.ui.transform.is_none()); + } + assert_eq!(app.session.active().unwrap().history.entries(), entries); + ctx.end_pass().textures_delta.clear(); +} diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index 1978f27086..c3d64441c9 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -995,7 +995,7 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let dragging: Option = ui.data(|d| d.get_temp(key)); let mut preview = None; if let (Some(m), Some(pos)) = (dragging, resp.interact_pointer_pos()) { - let off = if pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0 { 1.0 } else { 0.0 }; + let off = if off_strip(pos, bar) { 1.0 } else { 0.0 }; let loc = loc_of(pos.x); let edit = match m { Marker::Color(i) if off > 0.0 && stops.len() > 2 => json!({"action": "delete", "index": i}), @@ -1038,3 +1038,9 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi #[cfg(test)] mod tests; + +/// A stop dragged to `pos` is off the gradient strip `bar` and comes off (removed on release): +/// below the stops or above the strip, by the same distance in every gradient editor. +pub(crate) fn off_strip(pos: egui::Pos2, bar: egui::Rect) -> bool { + pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0 +} diff --git a/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs b/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs index b5a16773f1..608f3d12ae 100644 --- a/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs @@ -70,7 +70,7 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if widgets::checkbox(ui, &mut value, "Align with layer").changed() { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "align": value})); } - if widgets::secondary_button(ui, "Reset Alignment", ui.available_width()).on_hover_text(tl!("Centre the gradient (offset 0, 0)")).clicked() { + if widgets::secondary_button(ui, "Reset Alignment", ui.available_width()).on_hover_text(tl!("Center the gradient (offset 0, 0)")).clicked() { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "offset": [0, 0]})); } } diff --git a/crates/ui-egui/src/tools/lasso_ui.rs b/crates/ui-egui/src/tools/lasso_ui.rs index 38dfaecdb1..5bca66d06e 100644 --- a/crates/ui-egui/src/tools/lasso_ui.rs +++ b/crates/ui-egui/src/tools/lasso_ui.rs @@ -171,17 +171,21 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Down { .. } => { if active(app) { // A click on the first point, or on the last one again, closes the outline. Aiming - // at the first point is a grab; clicking the last one again is the same click - // repeated, so it has only to allow for a pixel of hand jitter. Sharing the grab - // radius made every segment shorter than it impossible to draw, because the click - // meant to place the next vertex closed the outline instead (#7629). - let zoom = app.point_zoom().max(0.01) as f64; - let near = |q: [f64; 2], reach: f64| (q[0] - p[0]).hypot(q[1] - p[1]) < reach / zoom; - let again = |d: &Drag| d.points.last().is_some_and(|l| near([l[0], l[1]], SAME_POINT)); - let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start, 8.0) || again(d))); + // at the first point is a grab, with a reach scaled to the size of the whole outline + // (`canvas::polygon_close_reach`, #6578); clicking the last one again is the same + // click repeated, so it has only to allow for a pixel of hand jitter. Sharing the + // grab radius made every segment shorter than it impossible to draw, because the + // click meant to place the next vertex closed the outline instead (#7629). + let point_zoom = app.point_zoom(); + let zoom = point_zoom.max(0.01) as f64; + let start_reach = |d: &Drag| crate::canvas::polygon_close_reach(d.points.iter().map(|q| [q[0], q[1]]), point_zoom); + let near = |q: [f64; 2], reach: f64| (q[0] - p[0]).hypot(q[1] - p[1]) < reach; + let at_start = |d: &Drag| near(d.start, start_reach(d)); + let again = |d: &Drag| d.points.last().is_some_and(|l| near([l[0], l[1]], SAME_POINT / zoom)); + let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (at_start(d) || again(d))); if close { // A path closes on its start point (a selection outline always closes). - if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && near(d.start, 8.0)) { + if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && at_start(d)) { let (start, n) = (d.start, d.points.len()); append(d, start); if let Some(lasso) = d.lasso.as_mut().filter(|_| d.points.len() > n) { diff --git a/crates/ui-egui/src/tools/lasso_ui/tests.rs b/crates/ui-egui/src/tools/lasso_ui/tests.rs index af1d45a2e0..816d661152 100644 --- a/crates/ui-egui/src/tools/lasso_ui/tests.rs +++ b/crates/ui-egui/src/tools/lasso_ui/tests.rs @@ -442,6 +442,28 @@ fn clicking_the_last_point_again_closes_the_outline() { assert!(app.session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(150, 100, 0) > 0.9); } +/// #6578: Alt-clicking the four corners of a small square leaves the outline open (the last corner +/// lands within 8 points of the first, but is a new vertex); a click on the first point closes it. +#[test] +fn alt_clicking_the_last_corner_of_a_small_shape_does_not_close_it() { + let mut app = app(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + assert_eq!(app.point_zoom(), 1.0); + for [x, y] in [[100.0, 100.0], [106.0, 100.0], [106.0, 106.0], [100.0, 106.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + assert!(active(&app), "corner ({x}, {y}): the outline stays open"); + } + assert_eq!(app.drag.as_ref().unwrap().points.len(), 4, "four corners"); + assert!(app.session.active().unwrap().doc.selection.is_none()); + event(&mut app, "down", 100.0, 100.0, Modifiers::ALT); + assert!(!active(&app), "a click on the first point closes"); + let b = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + let close_to = |v: i32, want: i32| v.abs_diff(want) <= 1; + assert!(close_to(b.x0, 100) && close_to(b.y0, 100) && close_to(b.x1, 106) && close_to(b.y1, 106), "a square, not a triangle: {b:?}"); +} + #[test] fn shift_drag_constrains_freeform_pen_to_a_straight_line() { let mut app = app(); @@ -483,3 +505,22 @@ fn real_canvas_cmd_drag_outside_the_selection_moves_the_layer() { assert!(app.session.active().unwrap().doc.selection.is_none(), "no selection was made"); assert_eq!((alpha(105, 120), alpha(165, 120)), (0.0, 1.0), "the layer moved 30 px to the right"); } + +#[test] +fn alt_clicking_narrow_and_rotated_polygons_keeps_the_fourth_corner() { + for corners in [[[100.0, 100.0], [200.0, 100.0], [200.0, 106.0], [100.0, 106.0]], [[100.0, 100.0], [160.0, 180.0], [155.2, 183.6], [95.2, 103.6]]] { + let mut app = app(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + for [x, y] in corners { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + assert!(active(&app), "{corners:?}"); + } + assert_eq!(app.drag.as_ref().unwrap().points.len(), 4); + assert!(app.session.active().unwrap().doc.selection.is_none()); + event(&mut app, "down", 100.0, 100.0, Modifiers::ALT); + assert!(!active(&app)); + assert!(app.session.active().unwrap().doc.selection.is_some()); + } +} diff --git a/crates/ui-egui/src/tools/magnetic_lasso_ui.rs b/crates/ui-egui/src/tools/magnetic_lasso_ui.rs index 28ef343212..f669f61368 100644 --- a/crates/ui-egui/src/tools/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/tools/magnetic_lasso_ui.rs @@ -6,8 +6,11 @@ //! often at a higher Frequency. A click fastens a point; ⌥-click draws a straight segment and //! ⌥-drag a freehand one. ⌫ or Delete removes the last fastening point. Clicking the first point, //! a double-click or ↩ closes the border along the edges (⌥ with a straight segment); Esc cancels. -//! [ and ] narrow or widen the detection width by 1 px. As in Photoshop, the border follows the -//! active layer's pixels (the composite for a layer without pixels of its own). +//! [ and ] narrow or widen the detection width by 1 px. The border follows the edges of the visible +//! composite, whichever layer is active (hidden layers add none); documents that are not RGB keep +//! following the active layer's own pixels. In a non-RGB document an active group or adjustment +//! layer has no pixel surface of its own, so the tool follows the composite then instead of that +//! layer's pixels (`pixel_layer` and `read` in `photocraft_engine::magnetic_cmds`). //! //! The border closes through `select.magneticLasso` with `"trace": false`: the selection is exactly //! the outline drawn, in one history step, and an action replays that outline. @@ -55,6 +58,8 @@ pub struct Runtime { source: Option, /// Pointer moves were batched (`PhotocraftApp::defer_live_stroke`): trace once at the end. pending: bool, + /// The mouse button is down (pressed in this tool, not yet released): points fasten closer. + held: bool, } /// Detection settings from the options bar, for an event with pen `pressure`. @@ -68,12 +73,26 @@ fn settings(app: &PhotocraftApp, pressure: f32) -> Settings { Settings::new(width, o.magnetic_contrast / 100.0) } -/// How far the border runs before a point fastens by itself (document px): 8 screen points at -/// Frequency 100, 98 at 0, 47 at the default 57. -fn spacing(app: &PhotocraftApp) -> f64 { +/// How far the border runs before a point fastens by itself (document px), as the reference +/// application measures it (#11687). The stored spacing is `floor(40 - 0.35 x Frequency)`: 40 at +/// Frequency 0, 20 at the default 57, 5 at 100. +/// +/// - Button held: the stored value in document px, whatever the zoom (so on screen it grows with +/// the zoom: 40 px at 200 %, 60 at 300 %). Below 100 % the reference is uneven (about 8 to 10 +/// screen px at Frequency 57) and no rule was pinned; the stored document value is kept. +/// - Button up: `4 x stored + 4` screen points at 100 % and above (164, 84 and 24), and half of +/// that below 100 %, at any zoom, so it is divided by the zoom into document px. +fn spacing(app: &PhotocraftApp, held: bool) -> f64 { let f = app.ui.tool_options.magnetic_frequency; let f = if f.is_finite() { f64::from(f.clamp(0.0, 100.0)) } else { 57.0 }; - (8.0 + (100.0 - f) * 0.9) / f64::from(app.point_zoom().max(0.01)) + let stored = (40.0 - 0.35 * f).floor(); + if held { + return stored; + } + let zoom = f64::from(app.point_zoom().max(0.01)); + let screen = 4.0 * stored + 4.0; + let screen = if zoom < 1.0 { screen / 2.0 } else { screen }; + screen / zoom } /// Within this distance of the first point (document px), a click closes the border. @@ -104,14 +123,24 @@ fn trace(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], guide: &[[f64; 2 /// Pointer events while the Magnetic Lasso is the tool (it takes them all). pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + // A release ends the held-button spacing whoever takes it (the temporary Move included). + if matches!(ev, ToolEvent::Up { .. }) { + app.magnetic.held = false; + } // ⌘ held makes it the Move tool (`hold_keys::cmd_moves`): nothing to take then. if app.active_tool() != Tool::MagneticLasso { return false; } match ev { - ToolEvent::Down { x, y, pressure } if x.is_finite() && y.is_finite() => down(app, [x, y], pressure, mods), + ToolEvent::Down { x, y, pressure } if x.is_finite() && y.is_finite() => { + down(app, [x, y], pressure, mods); + app.magnetic.held = true; + } ToolEvent::Move { x, y, pressure } if x.is_finite() && y.is_finite() => moved(app, [x, y], pressure), - ToolEvent::Up { .. } => end_freehand(app), + ToolEvent::Up { .. } => { + app.magnetic.held = false; + end_freehand(app); + } _ => {} } true @@ -175,7 +204,7 @@ fn moved(app: &mut PhotocraftApp, p: [f64; 2], pressure: f32) { return; } let s = settings(app, pressure); - let step = (spacing(app) / 2.0).max(1.0); + let step = (spacing(app, app.magnetic.held) / 2.0).max(1.0); let last = app.ui.magnetic.trail.last().copied().unwrap_or(p); // A long jump (a fast flick, automation) is walked in steps, so points keep fastening along it. let n = ((dist(last, p) / step).ceil() as usize).clamp(1, 256); @@ -217,7 +246,7 @@ fn extend_trail(app: &mut PhotocraftApp, q: [f64; 2], s: Settings) { fn follow(app: &mut PhotocraftApp, q: [f64; 2], s: Settings, fasten: bool) { extend_trail(app, q, s); let target = snap(app, q, s); - let every = spacing(app); + let every = spacing(app, app.magnetic.held); for _ in 0..64 { let Some(&from) = app.ui.magnetic.path.last() else { return }; let guide = app.ui.magnetic.trail.clone(); @@ -365,8 +394,15 @@ pub fn replaces_selection(app: &PhotocraftApp) -> bool { app.ui.magnetic.active() && app.ui.magnetic.mode == "replace" } -/// Once a frame: a border left behind by a tool or document switch is dropped. -pub fn frame(app: &mut PhotocraftApp) { +/// Once a frame of a document canvas (`primary_down`: its primary pointer button is down; `active_canvas`: +/// it is the canvas of the active document, the one that owns a press in progress): a border left behind by a tool or document switch is dropped. +pub fn frame(app: &mut PhotocraftApp, primary_down: bool, active_canvas: bool) { + // The button is up: the held-button spacing ends even when the release went to the temporary + // Move (Ctrl pressed during the drag) and never reached this tool. Only the active canvas + // says so: another window's button being up says nothing about the drag in this one. + if active_canvas && !primary_down { + app.magnetic.held = false; + } let m = &app.ui.magnetic; if m.active() && (app.ui.tool != Tool::MagneticLasso || app.session.active().map(|d| d.doc.id.0) != Some(m.doc)) { cancel(app); @@ -375,7 +411,7 @@ pub fn frame(app: &mut PhotocraftApp) { /// Keys while the Magnetic Lasso is the tool: ↩ closes the border along the edges (⌥↩ straight), /// Esc cancels it, ⌫ / Delete remove the last fastening point, and [ / ] change the detection width -/// by 1 px. Returns whether a key was taken. +/// by 1 px (Shift+[ / Shift+] jump to 1 / 256 px). Returns whether a key was taken. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::MagneticLasso { return false; @@ -399,6 +435,17 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { return true; } } + // Shift+[ / Shift+] jump to the limits; they come first because the plain keys match with Shift held too. + let w = &mut app.ui.tool_options.magnetic_width; + // On a US layout the backend may report Shift+[ as the logical `{` key (egui's `OpenCurlyBracket`) with Shift set; a bare `{` (Programmer Dvorak) is left alone. + if pressed(Modifiers::SHIFT, Key::OpenBracket) || pressed(Modifiers::SHIFT, Key::OpenCurlyBracket) { + *w = 1.0; + return true; + } + if pressed(Modifiers::SHIFT, Key::CloseBracket) || pressed(Modifiers::SHIFT, Key::CloseCurlyBracket) { + *w = 256.0; + return true; + } let step = if pressed(Modifiers::NONE, Key::OpenBracket) { -1.0 } else if pressed(Modifiers::NONE, Key::CloseBracket) { @@ -406,7 +453,6 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } else { return false; }; - let w = &mut app.ui.tool_options.magnetic_width; *w = if w.is_finite() { (w.round() + step).clamp(1.0, 256.0) } else { 10.0 }; true } diff --git a/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs b/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs index 11d5137621..b5e41a5e1b 100644 --- a/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs +++ b/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs @@ -25,6 +25,8 @@ fn app() -> PhotocraftApp { }) .unwrap(); app.ui.tool = Tool::MagneticLasso; + // Frequency 100 (a point about every 24 px with the button up, #11687): the 80 x 70 square is small. + app.ui.tool_options.magnetic_frequency = 100.0; app } @@ -77,7 +79,7 @@ fn tracing_around_a_shape_selects_it() { assert!(on_square(m.path[0], 0.01), "the first point snaps onto the edge: {:?}", m.path[0]); hover_along(&mut app, &AROUND[..5]); let m = &app.ui.magnetic; - // Points fastened by themselves on the way (about every 47 px at Frequency 57). + // Points fastened by themselves on the way (about every 24 px at Frequency 100). assert!(m.anchors.len() >= 5, "{:?}", m.anchors); for p in m.path.iter().chain(&m.live) { assert!(on_square(*p, 1.0), "{p:?} is off the square's edge"); @@ -199,16 +201,16 @@ fn agents_see_the_border_in_progress() { fn switching_tools_or_documents_drops_the_border() { let mut app = app(); click(&mut app, 52.0, 37.0, Modifiers::NONE); - frame(&mut app); + frame(&mut app, true, true); assert!(app.ui.magnetic.active()); app.ui.tool = Tool::Lasso; - frame(&mut app); + frame(&mut app, true, true); assert!(!app.ui.magnetic.active()); app.ui.tool = Tool::MagneticLasso; click(&mut app, 52.0, 37.0, Modifiers::NONE); app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); app.sync_views(); - frame(&mut app); + frame(&mut app, true, true); assert!(!app.ui.magnetic.active()); } @@ -244,6 +246,41 @@ fn width_follows_pen_pressure_only_when_asked_and_only_with_a_pen() { assert_eq!(settings(&app, f32::NAN).width, 21.0); } +/// Starts a border at the top edge of the square, presses the button, then lets `release` end the +/// press; walks along the edge in 2 px steps and returns how many fastening points the border has. +fn points_after_walk(release: impl FnOnce(&mut PhotocraftApp)) -> usize { + let mut a = app(); + tool_event(&mut a, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + release(&mut a); + for x in (52..=128).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: 40.0, pressure: 1.0 }, Modifiers::NONE); + } + a.ui.magnetic.anchors.len() +} + +#[test] +fn the_button_up_spacing_applies_after_a_release_the_temporary_move_took() { + // Baseline: an ordinary release, then the button-up spacing (~24 px at Frequency 100). + let plain = points_after_walk(|a| tool_event(a, ToolEvent::Up { x: 50.0, y: 40.0 }, Modifiers::NONE)); + assert!(plain <= 6, "button up: {plain} points"); + // The release arrives while the temporary Move (Ctrl held) is the active tool. + let moved = points_after_walk(|a| { + a.tool_override = Some(Tool::Move); + tool_event(a, ToolEvent::Up { x: 50.0, y: 40.0 }, Modifiers::COMMAND); + a.tool_override = None; + }); + assert!(moved <= 6, "after a release taken by the Move: {moved} points"); + // The canvas frame also clears it: the button is up, whoever saw the release. + let framed = points_after_walk(|a| frame(a, false, true)); + assert!(framed <= 6, "after a frame with the button up: {framed} points"); + // Another (inactive) canvas with its own button up leaves a live drag alone. + let other = points_after_walk(|a| frame(a, false, false)); + assert!(other > 10, "an inactive canvas's frame must not end the hold: {other} points"); + // A frame with the button still down keeps the held-button spacing. + let held = points_after_walk(|a| frame(a, true, true)); + assert!(held > 10, "button held: {held} points"); +} + #[test] fn hostile_input_never_panics() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/move_box.rs b/crates/ui-egui/src/tools/move_box.rs index 05f12222b4..20edea5850 100644 --- a/crates/ui-egui/src/tools/move_box.rs +++ b/crates/ui-egui/src/tools/move_box.rs @@ -82,8 +82,7 @@ pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { /// The marquee as marching ants. pub(crate) fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(b) = app.layer_box else { return }; - let r = egui::Rect::from_two_pos(xf.to_screen(b.start[0] as f32, b.start[1] as f32), xf.to_screen(b.end[0] as f32, b.end[1] as f32)); - crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true); + crate::tool_feedback::draw_ants(painter, &crate::canvas::doc_box_corners(xf, b.start, b.end), true); } #[cfg(test)] diff --git a/crates/ui-egui/src/tools/move_hover.rs b/crates/ui-egui/src/tools/move_hover.rs index aef1f315ef..f60bc71c6a 100644 --- a/crates/ui-egui/src/tools/move_hover.rs +++ b/crates/ui-egui/src/tools/move_hover.rs @@ -30,6 +30,7 @@ pub(crate) struct Hover { pub(crate) fn target(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option { if !app.session.prefs().tools.show_highlight_on_rollover || app.ui.tool != Tool::Move + || !app.ui.tool_options.move_show_hover_bounds || pressed || app.drag.is_some() || app.guide_drag.is_some() @@ -102,6 +103,7 @@ pub(crate) fn active_outline(app: &mut PhotocraftApp, at: Option<[f64; 2]>, comm } // A layer hovered in the Layers panel takes precedence over canvas rollover. if let Some(id) = app.hover_layer + && (app.ui.tool != Tool::Move || app.ui.tool_options.move_show_layers_hover_bounds) && app.drag.is_none() && app.guide_drag.is_none() && app.ui.transform.is_none() diff --git a/crates/ui-egui/src/tools/move_mods.rs b/crates/ui-egui/src/tools/move_mods.rs index 4c96702694..b4237c2a29 100644 --- a/crates/ui-egui/src/tools/move_mods.rs +++ b/crates/ui-egui/src/tools/move_mods.rs @@ -1,12 +1,14 @@ //! Move-tool modifier keys, as in Photoshop (#90): //! -//! - ⇧ while dragging locks the move to the dominant axis (horizontal or vertical). The axis is -//! re-evaluated as the drag goes on, so moving far enough the other way switches it; a small -//! hysteresis keeps it from flickering near the diagonal. Free Transform uses the same rule with -//! eight directions ([`constrain`]). +//! - ⇧ while dragging locks the move to the nearest of the eight directions (the axes and the 45° +//! diagonals), as the reference application does when a layer is dragged (#11382): +100,+30 +//! moves +100,0 and +80,+80 moves +80,+80. The direction is re-evaluated as the drag goes on, +//! so moving far enough another way switches it; a small hysteresis keeps it from flickering +//! near the switch-over angle. Free Transform uses the same rule ([`constrain`]). //! - ⌥ held when the drag starts duplicates the selected layer(s) (groups included) once the //! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one -//! history step ("Duplicate + Move"). ⇧⌥ combines both. +//! history step ("Move", as in Photoshop; an ⌥-arrow nudge keeps "Duplicate + Move"). ⇧⌥ +//! combines both. //! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free //! Transform is active they nudge the box instead. A run of nudges is one History state //! (`nudge_bundle`, #2259). @@ -23,8 +25,10 @@ use crate::PhotocraftApp; use crate::canvas::ToolEvent; use crate::state::Tool; -/// Directions ⇧ locks a Move-tool drag to: horizontal and vertical (Photoshop's Move tool). -pub const MOVE_DIRECTIONS: u32 = 4; +/// Directions ⇧ locks a Move-tool drag of a layer to: the axes and the 45° diagonals. The +/// reference application moves +80,+80 for a ⇧-drag of +80,+80, so an axis-only lock (four +/// directions) does not match it (#11382). +pub const MOVE_DIRECTIONS: u32 = 8; /// Directions ⇧ locks a Free Transform body drag, or a Move-tool drag of selected pixels, to: the /// axes and the 45° diagonals. pub const TRANSFORM_DIRECTIONS: u32 = 8; @@ -34,7 +38,7 @@ const HYSTERESIS_DEG: f64 = 8.0; /// ⌥-drag duplicates only once the pointer has moved this far (screen points), so an ⌥-click /// without a drag doesn't make a copy (Photoshop). const DUPLICATE_THRESHOLD: f64 = 2.0; -/// History label of an ⌥-drag / ⌥-nudge. +/// History label of an ⌥-nudge. An ⌥-drag of a layer is a plain "Move", as in Photoshop (#11383). pub const DUPLICATE_MOVE_LABEL: &str = "Duplicate + Move"; /// Locks the drag offset `d` to the nearest of `n` evenly spaced directions (0° = right). `prev` @@ -105,8 +109,8 @@ fn duplicate(app: &mut PhotocraftApp) -> Option { } } -/// Merges every history step after `from` into one "Duplicate + Move" step. -fn fold_history(app: &mut PhotocraftApp, from: usize) { +/// Merges every history step after `from` into one step called `label`. +fn fold_history(app: &mut PhotocraftApp, from: usize, label: &str) { let Some(st) = app.session.active_mut() else { return }; while st.history.past_len() > from + 1 { if !st.history.purge_last() { @@ -114,7 +118,7 @@ fn fold_history(app: &mut PhotocraftApp, from: usize) { } } if st.history.past_len() == from + 1 { - st.history.set_current_label(DUPLICATE_MOVE_LABEL); + st.history.set_current_label(label); } } @@ -185,7 +189,7 @@ pub(crate) fn duplicated(app: &PhotocraftApp) -> bool { pub fn finish(app: &mut PhotocraftApp) { let st = std::mem::take(&mut app.move_mods); if let Some(from) = st.dup_from { - fold_history(app, from); + fold_history(app, from, tl!("Move")); } } @@ -198,7 +202,25 @@ pub fn abandon(app: &mut PhotocraftApp) { } } -/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px; +/// The document pixels one plain arrow press moves layers by with the Move tool: one screen +/// pixel, as in Photoshop (#9511). Zoomed out that is several document pixels (2 at 50 %, 4 at +/// 25 %; 1 at 66.7 %: whole pixels only), zoomed in still one. No cap: the zoom bottoms out +/// with the document's shorter side 2 px on screen (`zoom_levels::min`), so a step is at most +/// half that side. ⇧'s 10 px don't scale. Free Transform keeps 1 px. +fn screen_pixel_step(app: &PhotocraftApp) -> f64 { + let zoom = f64::from(app.current_zoom()); + if app.ui.transform.is_some() || !zoom.is_finite() || zoom <= 0.0 { + return 1.0; + } + // A ladder rung is a hair off in f32 (0.1 % is 1 / 999.99995): snap a reciprocal within f32 + // precision of a whole number to it, and floor any other zoom (0.3333334 is 2, not 3). + let (r, whole) = (1.0 / zoom, (1.0 / zoom).round()); + let steps = if (r - whole).abs() <= r * f64::from(f32::EPSILON) { whole } else { r.floor() }; + steps.max(1.0) +} + +/// Arrow keys with the Move tool (or while Free Transform is active): nudge one screen pixel +/// (1 px at 100 %, [`screen_pixel_step`]), ⇧ 10 px; /// ⌥ duplicates the layers first. ⌘-arrows (Ctrl off the Mac) do the same from most other tools, /// where ⌘ is the temporary Move tool (`hold_keys::cmd_nudges`, #2474). Returns true when a key /// was used. @@ -218,7 +240,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { // Most specific first: `consume_key` ignores extra ⇧ / ⌥ (`matches_logically`). for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] { if ctx.input_mut(|i| i.consume_key(base | mods, key)) { - let k = if mods.shift { 10.0 } else { 1.0 }; + let k = if mods.shift { 10.0 } else { screen_pixel_step(app) }; app.nudge_bundle.now = ctx.input(|i| i.time); nudge(app, ux * k, uy * k, mods.alt); return true; @@ -269,6 +291,7 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { return; } let from = if duplicate_first { duplicate(app) } else { None }; + let revision = app.session.active().map(|st| st.revision); let moved = if duplicate_first { // A copy is a state of its own, folded with its move below. app.nudge_bundle.reset(); @@ -276,11 +299,20 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { } else { crate::nudge_bundle::run(app, "layers", "layer.translate", json!({"dx": dx, "dy": dy})) }; - if let Err(e) = moved { - app.ui.status = e; + match moved { + // The reference names an arrow-key move "Nudge"; a bundled run is one step with that name. + // Only a move that changed the document: a zero offset records nothing, and the state + // on top is then another edit's. + Ok(_) if from.is_none() => { + if let Some(st) = app.session.active_mut().filter(|st| Some(st.revision) != revision) { + st.history.set_current_label(tl!("Nudge")); + } + } + Ok(_) => {} + Err(e) => app.ui.status = e, } if let Some(from) = from { - fold_history(app, from); + fold_history(app, from, DUPLICATE_MOVE_LABEL); } } diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index e42e24875e..c7591abf9e 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -90,7 +90,7 @@ fn alt_drag_duplicates_and_moves_the_copy_in_one_step() { assert_eq!(bounds(&app, copy), photocraft_geom::Rect::new(18, 13, 34, 29)); let st = app.session.active().unwrap(); assert_eq!(st.history.past_len(), h0 + 1, "one undo step"); - assert_eq!(st.history.undo_label(), Some(DUPLICATE_MOVE_LABEL)); + assert_eq!(st.history.undo_label(), Some("Move")); assert!(app.session.undo()); assert_eq!(app.session.active().unwrap().doc.layers.len(), n0); assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24)); @@ -115,7 +115,7 @@ fn alt_drag_keeps_the_later_states_in_non_linear_history() { assert!(app.session.undo()); let mut expected = app.session.active().unwrap().history.entries(); expected.extend(app.session.active().unwrap().history.redo_labels().map(str::to_string)); - expected.push(DUPLICATE_MOVE_LABEL.into()); + expected.push("Move".into()); drag(&mut app, &[[16.0, 16.0], [20.0, 16.0], [26.0, 21.0]], egui::Modifiers::ALT); assert_eq!(app.session.active().unwrap().history.entries(), expected, "the later state stays"); } @@ -328,13 +328,35 @@ fn a_run_of_arrow_nudges_is_one_history_step() { assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34)); assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); - assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move")); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge")); assert!(app.session.undo()); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); assert!(app.session.redo()); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34), "and one Redo does it again"); } +/// #9511: zoomed out, where each press moves several document pixels, a run of nudges is still +/// one History state. +#[test] +fn a_run_of_arrow_nudges_is_one_history_step_zoomed_out() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.views[0].zoom = 0.5; + let h0 = history(&app); + // Twelve taps of 2 px each at 50 %, then ⇧ for ten more px: still the same run. + for i in 0..12 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 5.0 + 0.2 * f64::from(i))); + } + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34)); + assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge")); + assert!(app.session.undo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); + assert!(app.session.redo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34), "and one Redo does it again"); +} + /// A pause longer than Nudge Bundle Pause starts a new run; a longer pause setting keeps it. #[test] fn a_pause_ends_the_run() { @@ -434,7 +456,7 @@ fn alt_nudges_do_not_join_a_run() { press(&mut app, egui::Key::ArrowRight, egui::Modifiers::ALT); press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); assert_eq!(history(&app), h0 + 3); - assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move"), "the run after the copy is a state of its own"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge"), "the run after the copy is a state of its own"); } /// #2259: nudges of the selection outline bundle the same way. @@ -500,6 +522,19 @@ fn free_transform_nudges_bundle_into_one_undo_step() { assert_eq!(quad(&app), moved(2.0, 0.0)); } +/// #9511: Free Transform keeps Photoshop's 1 document pixel per arrow press at any zoom; only +/// the Move tool's nudge is one screen pixel. +#[test] +fn free_transform_nudges_one_document_pixel_zoomed_out() { + let mut app = app_with_layer(); + app.ui.views[0].zoom = 0.5; + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0.map(|q| [q[0] + 1.0, q[1]]), "→ at 50 % moves the box 1 px"); +} + /// Arrow keys while editing text must not nudge layers, leaving the keys to the type tool (#5355). #[test] fn arrow_keys_ignored_during_text_edit() { @@ -523,3 +558,37 @@ fn arrow_keys_ignored_during_text_edit() { assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT)); assert_eq!(bounds(&app, id), b0, "arrow keys do not nudge while editing text"); } + +/// #9511: as in Photoshop, a plain arrow nudge is one screen pixel, so zoomed out it moves the +/// layer several document pixels (floor of 1 / zoom: 4 at 25 %, 2 at 50 %, 1 at 66.7 %, 1 at +/// 100 % and above, up to 1600 %); ⇧ is 10 document pixels at any zoom. +#[test] +fn a_nudge_is_one_screen_pixel() { + for (zoom, step) in [(0.25, 4), (0.5, 2), (2.0 / 3.0, 1), (1.0, 1), (2.0, 1), (16.0, 1)] { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.views[0].zoom = zoom; + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(left_edge(&app, id), 8 + step, "→ at {zoom}"); + assert!(press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::SHIFT)); + assert_eq!(left_edge(&app, id), 8 + step - 10, "⇧← at {zoom}"); + } +} + +/// #9511: below 0.1 % (a large document zooms out until its shorter side is 2 px) the nudge is +/// still one screen pixel: 6000 document pixels at a 12,000 × 12,000 px document's lowest zoom, +/// not capped at 1000. The ladder's f32 rungs (0.1 %, 0.2 %) still give whole steps (1000, +/// not 999). +#[test] +fn a_nudge_is_one_screen_pixel_below_a_tenth_of_a_percent() { + let lowest = crate::zoom_levels::min([12_000, 12_000]); + // 0.3333334 is just above a third, off the ladder (pinch, Zoom tool drag): 2.9999994 screen + // pixels' worth floors to 2, while the ladder's own f32 third still gives 3 (#10438 review). + for (zoom, step) in [(0.002, 500), (0.001, 1000), (0.0005, 2000), (lowest, 6000), (0.333_333_4, 2), (1.0 / 3.0, 3)] { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.ui.views[0].zoom = zoom; + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(left_edge(&app, id), 8 + step, "→ at {zoom}"); + } +} diff --git a/crates/ui-egui/src/tools/note_delete_key_tests.rs b/crates/ui-egui/src/tools/note_delete_key_tests.rs new file mode 100644 index 0000000000..9a1630e4e7 --- /dev/null +++ b/crates/ui-egui/src/tools/note_delete_key_tests.rs @@ -0,0 +1,107 @@ +//! #11788: with the Note tool, ⌫/Delete on a selected note asks "Delete this note?" and deletes +//! the note, never the layer; the Notes panel's trash asks the same. + +use super::*; + +/// A document with a second layer and one note, the Note tool on and the note selected. +fn app() -> PhotocraftApp { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + s.execute("notes.add", json!({"x": 10, "y": 12, "text": "fix this"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::Note; + app.ui.analysis.note_selected = Some(0); + app +} + +/// A key through the app's shortcut handling, as the canvas gets it. +fn press(app: &mut PhotocraftApp, key: egui::Key) { + let ctx = egui::Context::default(); + let m = egui::Modifiers::NONE; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layers.len() +} + +fn notes(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.notes.len() +} + +fn alert(app: &PhotocraftApp) -> Option { + app.ui.dialogs.iter().find(|d| owns_delete_note(&d.fields)).map(|d| d.id) +} + +#[test] +fn delete_with_a_note_selected_asks_and_deletes_the_note_not_the_layer() { + for key in [egui::Key::Delete, egui::Key::Backspace] { + let mut app = app(); + let steps = app.session.active().unwrap().history.past_len(); + press(&mut app, key); + let id = alert(&app).unwrap_or_else(|| panic!("{key:?}: the alert")); + assert_eq!((layers(&app), notes(&app)), (2, 1), "{key:?}: nothing changes before the answer"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!((layers(&app), notes(&app)), (2, 0), "{key:?}: the note goes, the layers stay"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1); + assert_eq!(st.history.undo_label(), Some("Delete Note")); + assert_eq!(app.ui.analysis.note_selected, None, "nothing selected after"); + } +} + +#[test] +fn no_keeps_the_document_and_history() { + let mut app = app(); + let steps = app.session.active().unwrap().history.past_len(); + press(&mut app, egui::Key::Delete); + let id = alert(&app).expect("the alert"); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert_eq!((layers(&app), notes(&app)), (2, 1)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +#[test] +fn dont_show_again_skips_the_alert_next_time() { + let mut app = app(); + app.run("notes.add", json!({"x": 30, "y": 30})).unwrap(); + press(&mut app, egui::Key::Delete); + let id = alert(&app).expect("the alert"); + app.ui.dialog_mut(id).unwrap().fields.insert("skip".into(), json!(true)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(notes(&app), 1); + // The panel's trash goes the same way: no alert, the note is deleted at once. + ask_delete_note(&mut app, 0); + assert!(alert(&app).is_none()); + assert_eq!((layers(&app), notes(&app)), (2, 0)); +} + +#[test] +fn the_notes_panel_trash_asks_too() { + let mut app = app(); + ask_delete_note(&mut app, 0); + assert!(alert(&app).is_some()); + assert_eq!(notes(&app), 1, "nothing goes before Yes"); +} + +#[test] +fn without_a_selected_note_delete_keeps_its_usual_route() { + let mut app = app(); + app.ui.analysis.note_selected = None; + press(&mut app, egui::Key::Delete); + assert!(alert(&app).is_none()); + assert_eq!(notes(&app), 1, "the note stays"); + // Another tool with a note still selected: the note isn't asked about. + let mut other = self::app(); + other.ui.tool = Tool::Move; + press(&mut other, egui::Key::Delete); + assert!(alert(&other).is_none()); + assert_eq!(notes(&other), 1); +} diff --git a/crates/ui-egui/src/tools/note_show_on_new_tests.rs b/crates/ui-egui/src/tools/note_show_on_new_tests.rs new file mode 100644 index 0000000000..eb9d876fe8 --- /dev/null +++ b/crates/ui-egui/src/tools/note_show_on_new_tests.rs @@ -0,0 +1,114 @@ +use std::cell::Cell; +use std::rc::Rc; + +use super::{PhotocraftApp, Tool, ToolEvent, json}; +use crate::control::{ControlRequest, Outcome, handle}; + +/// Open a document with the Note tool active and no notes yet. +fn note_tool() -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.ui.tool = Tool::Note; + (app, egui::Context::default()) +} + +/// Send a click or drag to the canvas in document coordinates. +fn gesture(app: &mut PhotocraftApp, [x, y]: [f64; 2], [end_x, end_y]: [f64; 2]) { + crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + if [x, y] != [end_x, end_y] { + crate::canvas::tool_event(app, ToolEvent::Move { x: end_x, y: end_y, pressure: 1.0 }, egui::Modifiers::NONE); + } + crate::canvas::tool_event(app, ToolEvent::Up { x: end_x, y: end_y }, egui::Modifiers::NONE); +} + +/// A new note reveals both icons, and the menu can hide them again. +#[test] +fn creating_a_note_rechecks_show_notes_and_reveals_both_notes() { + let (mut app, ctx) = note_tool(); + crate::menus::invoke(&mut app, &ctx, "view.show.notes", json!({})).unwrap(); + app.run("notes.add", json!({"x": 20, "y": 20})).unwrap(); + assert!(!app.ui.view.show.notes && !app.ui.analysis.notes); + assert!(!app.ui.view.shows(app.ui.view.show.notes)); + + app.ui.analysis.note_author = "QA".into(); + app.ui.analysis.note_color = [0.25, 0.5, 1.0]; + gesture(&mut app, [130.0, 70.0], [130.0, 70.0]); + + let notes = &app.session.active().unwrap().doc.notes; + assert_eq!(notes.len(), 2); + assert_eq!(notes[0].position, [20.0, 20.0]); + assert_eq!(notes[1].position, [130.0, 70.0]); + assert_eq!(notes[1].author, "QA"); + assert_eq!(notes[1].color.to_rgb(), [0.25, 0.5, 1.0]); + assert_eq!(app.ui.analysis.note_selected, Some(1)); + assert!(app.ui.analysis.notes && app.ui.view.shows(app.ui.view.show.notes)); + + crate::menus::invoke(&mut app, &ctx, "view.show.notes", json!({})).unwrap(); + assert!(!app.ui.view.shows(app.ui.view.show.notes)); +} + +/// Selecting or moving a stored note keeps Show Notes off. +#[test] +fn selecting_and_dragging_an_existing_note_preserve_hidden_notes() { + let (mut app, ctx) = note_tool(); + app.run("notes.add", json!({"x": 30, "y": 40, "author": "QA"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "view.show.notes", json!({})).unwrap(); + + gesture(&mut app, [32.0, 42.0], [32.0, 42.0]); + assert!(!app.ui.view.show.notes); + assert_eq!(app.ui.analysis.note_selected, Some(0)); + assert_eq!(app.ui.analysis.note_author, "QA"); + + gesture(&mut app, [32.0, 42.0], [62.0, 52.0]); + assert!(!app.ui.view.show.notes); + let notes = &app.session.active().unwrap().doc.notes; + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].position, [60.0, 50.0]); +} + +/// Creating a note rechecks Show Notes while Extras still suppresses the overlay. +#[test] +fn creating_a_note_leaves_extras_off() { + let (mut app, ctx) = note_tool(); + crate::menus::invoke(&mut app, &ctx, "view.show.notes", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.show.notes && !app.ui.view.extras); + + gesture(&mut app, [130.0, 70.0], [130.0, 70.0]); + + assert_eq!(app.session.active().unwrap().doc.notes.len(), 1); + assert!(app.ui.view.show.notes); + assert!(!app.ui.view.extras && !app.ui.view.shows(app.ui.view.show.notes)); +} + +/// A denied canvas creation preserves the selected note and visibility settings. +#[test] +fn rejected_note_creation_does_not_change_visibility_or_selection() { + let (mut app, ctx) = note_tool(); + app.run("notes.add", json!({"x": 20, "y": 20})).unwrap(); + app.run("notes.add", json!({"x": 70, "y": 20})).unwrap(); + gesture(&mut app, [72.0, 22.0], [72.0, 22.0]); + assert_eq!(app.ui.analysis.note_selected, Some(1)); + app.ui.view.show.notes = false; + app.ui.analysis.notes = false; + app.ui.view.extras = false; + + let denials = Rc::new(Cell::new(0)); + let observed_denials = Rc::clone(&denials); + app.services.automation_command = Some(Box::new(move |id, _| { + if id == "notes.add" { + observed_denials.set(observed_denials.get() + 1); + return Err("note creation denied".into()); + } + Ok(()) + })); + app.automation_input = true; + let (request, _reply) = ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": 130, "y": 70}, {"kind": "up", "x": 130, "y": 70}]})); + let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("ui.pointer should reply immediately") }; + + assert_eq!(reply["ok"], true); + assert_eq!(denials.get(), 1); + assert!(!app.ui.view.show.notes && !app.ui.analysis.notes && !app.ui.view.extras); + assert_eq!(app.ui.analysis.note_selected, Some(1)); + assert_eq!(app.session.active().unwrap().doc.notes.len(), 2); +} diff --git a/crates/ui-egui/src/tools/path_components.rs b/crates/ui-egui/src/tools/path_components.rs index 33aef52d8d..7fd6fd55f7 100644 --- a/crates/ui-egui/src/tools/path_components.rs +++ b/crates/ui-egui/src/tools/path_components.rs @@ -166,16 +166,27 @@ pub(crate) fn press(app: &mut PhotocraftApp, at: [f64; 2], shift: bool) { /// components, one History step. `None` with none or all of them selected: the whole path moves /// instead, which keeps a live rectangle or ellipse live. pub(crate) fn move_selected(app: &mut PhotocraftApp, d: [f64; 2]) -> Option> { - let (r, path, heads) = selected(app)?; - if target(app).map(|(t, _)| t) != Some(r) || heads.len() >= path.components().len() { - return None; - } + let (r, _) = moving_selected(app)?; let mut params = r.params(app); params["anchors"] = json!(app.ui.direct_selection.anchors); params["move"] = json!(d); Some(run_selected_command(app, "path.moveAnchors", params)) } +/// The path [`move_selected`] moves components of, with some but not all of them selected. +fn moving_selected(app: &PhotocraftApp) -> Option<(PathRef, Path)> { + let (r, path, heads) = selected(app)?; + (target(app).map(|(t, _)| t) == Some(r) && heads.len() < path.components().len()).then_some((r, path)) +} + +/// The path [`move_selected`] leaves after a drag by `d`, for the drag's live preview: the +/// selected components moved with their handles, as `path.moveAnchors` moves them. +pub(crate) fn moved_selected(app: &PhotocraftApp, d: [f64; 2]) -> Option<(PathRef, Path)> { + let (r, mut path) = moving_selected(app)?; + photocraft_vector::edit::move_anchors(&mut path, &app.ui.direct_selection.anchors, d).ok()?; + Some((r, path)) +} + /// Do `+` and `-` pick a Path Operation with `tool`? The tools whose options bar has the menu. fn has_keys(tool: Tool) -> bool { matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index 2a696bd5ed..ed60c90d41 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -53,6 +53,24 @@ pub fn pending(app: &PhotocraftApp) -> bool { app.ui.perspective_crop.is_some() || !app.pcrop.clicks.is_empty() || app.pcrop.drag.is_some() } +/// A box, corner or quad drag in progress: held past the canvas edge, it scrolls the view (#4183). +/// A press that hasn't moved further than a click doesn't: scrolling would turn a corner-placing +/// click near the edge into a box. +pub fn dragging(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::PerspectiveCrop + && match app.pcrop.drag { + Some(Drag::New { start, cur }) => !is_click(app, start, cur), + Some(_) => true, + None => false, + } +} + +/// A new-box press released within [`CLICK_PX`] screen points of where it began places a corner. +fn is_click(app: &PhotocraftApp, start: [f64; 2], cur: [f64; 2]) -> bool { + let click = crate::distort_ui::tolerance(app) * CLICK_PX / 8.0; + (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click +} + pub fn cancel(app: &mut PhotocraftApp) { app.ui.perspective_crop = None; app.pcrop.drag = None; @@ -121,8 +139,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { ToolEvent::Up { .. } => { drag_to(app, p); if let Some(Drag::New { start, cur }) = app.pcrop.drag { - let click = tol * CLICK_PX / 8.0; - if (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click { + if is_click(app, start, cur) { app.pcrop.clicks.push(start); if let [a, b, c, d] = app.pcrop.clicks[..] { app.ui.perspective_crop = Some(ordered([a, b, c, d])); @@ -327,6 +344,202 @@ mod tests { assert_eq!(app.ui.perspective_crop, None); } + /// Real egui input: a corner dragged past the canvas edge and held there scrolls the view, as a + /// marquee drag does (#4183), so the corner can reach the part of the image that was off screen. + #[test] + fn a_corner_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // The top-right corner: document (100, 10). + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::Corner { index: 1, .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.perspective_crop.is_some_and(|q| q[1][0] > 175.0), "the corner reached past what showed: {:?}", st.ui.perspective_crop); + h.event(button(out, false)); + h.step(); + } + + /// ⌘Space (Ctrl+Space off the Mac) pressed during a corner drag held past the canvas edge + /// selects the temporary Zoom, which takes the pointer from the drag: the view no longer + /// scrolls for the corner the Zoom froze. + #[test] + fn a_temporary_zoom_during_a_corner_drag_stops_the_scrolling() { + use egui::{Event, Key, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(800, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 800x100 at 400% in a 600x400 canvas: document x 25..175 shows, with room to scroll right. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [800, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true, Modifiers::NONE)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + // Scrolling while the crop tool has the pointer. + let before = h.state().ui.views[0].center; + h.run_steps(2); + assert!(h.state().ui.views[0].center[0] > before[0], "the corner drag scrolls: {:?} -> {:?}", before, h.state().ui.views[0].center); + let zoom = Modifiers::COMMAND; + let zoom = if cfg!(target_os = "macos") { Modifiers { mac_cmd: true, ..zoom } } else { Modifiers { ctrl: true, ..zoom } }; + h.event(Event::ModifiersChanged(zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: zoom }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn)); + assert!(h.state().pcrop.drag.is_some(), "the corner drag is still recorded"); + let before = h.state().ui.views[0].center; + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, before, "the temporary Zoom's press doesn't scroll for the corner"); + h.event(button(out, false, zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: zoom }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + } + + /// A corner-placing click pressed just inside the canvas edge that slips a point past it doesn't + /// scroll the view (scrolling would turn it into a box); a new box dragged past the edge does. + #[test] + fn a_click_at_the_canvas_edge_places_a_corner_without_scrolling() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas 100 points from the window's left: document x 25..175 + // shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // Pressed a point inside the left edge (the scrollbars sit right and bottom), slipping two + // points past it: still a click. + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + // Held past egui's click time, the press becomes a drag that hasn't left the click size. + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.pcrop.clicks.len(), 1, "the click placed a corner: {:?}", st.pcrop.clicks); + assert_eq!(st.ui.perspective_crop, None); + // A new box dragged from inside the canvas past its edge does scroll. + let start = pos2(200.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(140.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::New { .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the box drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index 2b1a4f916a..540cf5879a 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -50,7 +50,11 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { /// History Brush params for the live preview and the commit (without `points` and `target`): the /// options bar's Mode, which is the tool's own brush's. pub(crate) fn history_brush_params(app: &PhotocraftApp) -> Value { - json!({"mode": app.session.tools.brush.mode.label()}) + json!({ + "mode": app.session.tools.brush.mode.label(), + "opacity": app.session.tools.brush.opacity * 100.0, + "flow": app.session.tools.brush.flow * 100.0, + }) } /// The command and options-bar params (without `points` and `target`) of a sequential-dab tool: @@ -158,6 +162,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m "style": o.art_history_style, "area": o.art_history_area, "tolerance": o.art_history_tolerance, + "opacity": app.session.tools.brush.opacity * 100.0, }), ), Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge => match dab_params(app, tool) { @@ -308,6 +313,30 @@ pub fn patch_drags_selection(app: &PhotocraftApp, at: [f64; 2], mods: egui::Modi sel.sample_channel(at[0].floor() as i32, at[1].floor() as i32, 0) > 0.0 } +/// A Patch or Content-Aware Move lasso that makes a new selection is being drawn: the selection it +/// replaces is hidden from the press on, as in Photoshop, rather than shown beside the new outline +/// until the release (#11024). Adding, subtracting and intersecting keep it visible. A drag pressed +/// on another document hides nothing, even before `cancel_stale` drops it (the active document can +/// change between `sync_views` and the canvas paint). +pub fn patch_replaces_selection(app: &PhotocraftApp) -> bool { + app.drag.as_ref().is_some_and(|d| { + matches!(d.tool, Tool::Patch | Tool::ContentAwareMove) + && d.document.as_ref().is_none_or(|doc| doc.is_active(app)) + && !patch_drags_selection(app, d.start, d.modifiers) + && crate::canvas::selection_mode(app, d.modifiers) == "replace" + }) +} + +/// A Patch or Content-Aware Move drag belongs to the document it was pressed on: once another one +/// is active it is dropped, so it neither hides that document's selection nor lassos or patches it +/// on release, and its own document shows its selection again (#11024). A document reopened with +/// the same ID is another one too. +pub fn cancel_stale(app: &mut PhotocraftApp) { + if app.drag.as_ref().is_some_and(|d| matches!(d.tool, Tool::Patch | Tool::ContentAwareMove) && d.document.as_ref().is_some_and(|doc| !doc.is_active(app))) { + app.drag = None; + } +} + /// Whole-pixel offset for a drag from `start` to `end`, limited so the dragged outline stays on the /// canvas (the engine rejects a patch or move that leaves it). pub fn patch_offset(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> [i32; 2] { @@ -537,8 +566,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } Tool::Patch => { - // Photoshop's options bar: Patch: Normal (Source / Destination) or Content-Aware - // (Structure, Color, Sample All Layers). + // Photoshop's options bar: the lasso's selection mode, then Patch: Normal (Source / + // Destination) or Content-Aware (Structure, Color, Sample All Layers). + crate::panels::selection_mode_buttons(&mut app.ui.selection_mode, ui); + crate::widgets::vline(ui, 22.0); opt(ui, tl!("Patch:")); let kinds = [("normal".to_string(), tl!("Normal")), ("contentAware".to_string(), tl!("Content-Aware"))]; let mut kind = if o.patch_content_aware { "contentAware" } else { "normal" }.to_string(); @@ -562,6 +593,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo opt(ui, tl!("Lasso around an area, then drag the selection")); } Tool::ContentAwareMove => { + // The lasso's selection mode leads, as on the Patch tool's bar (#11024). + crate::panels::selection_mode_buttons(&mut app.ui.selection_mode, ui); + crate::widgets::vline(ui, 22.0); opt(ui, tl!("Mode:")); let modes = [("move".to_string(), tl!("Move")), ("extend".to_string(), tl!("Extend"))]; crate::widgets::dropdown(ui, "cam-mode", tl!("Mode"), &mut o.cam_mode, &modes, 90.0); @@ -665,7 +699,28 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.cr_tolerance, 0.0..=100.0, "%", 62.0); crate::widgets::checkbox(ui, &mut o.cr_anti_alias, tl!("Anti-alias")); } - Tool::HistoryBrush => opt(ui, tl!("Paints from the History panel's source state")), + Tool::HistoryBrush => { + let label = app.session.active().and_then(|st| match photocraft_engine::history_source_cmds::source(st)? { + photocraft_engine::history_source_cmds::HistorySource::Snapshot(id) => { + let s = st.snapshots.iter().find(|s| s.id == id)?; + Some(crate::i18n::fmt(tl!("Source: {name}"), &[("name", &s.name)])) + } + photocraft_engine::history_source_cmds::HistorySource::State(idx) => { + let entries = st.history.entries(); + let name = + if idx < entries.len() { entries.get(idx).cloned() } else { st.history.redo_labels().nth(idx - entries.len()).map(str::to_string) }?; + if idx == 0 && name == "Open" { + Some(tl!("Paints from the document's opening state").to_string()) + } else { + Some(crate::i18n::fmt(tl!("Source: {name}"), &[("name", &name)])) + } + } + }); + match label { + Some(text) => opt(ui, &text), + None => opt(ui, tl!("The History Brush source was deleted: set a new source in the History panel")), + } + } Tool::ArtHistoryBrush => { opt(ui, tl!("Style")); let styles = [ diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index a9659eb23d..375f8a410a 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -377,6 +377,221 @@ fn patch_tool_lassoes_then_drags_the_patch() { assert!(px[0] > 0.95 && px[1] > 0.95 && px[2] > 0.95, "blemish patched with the white background: {px:?}"); } +/// #11024: a new Patch (or Content-Aware Move) lasso hides the selection it replaces from the +/// press on, and the release leaves only the new area selected. ⇧ keeps the old one and adds. +#[test] +fn a_new_patch_lasso_hides_and_replaces_the_old_selection() { + let lasso = |app: &mut PhotocraftApp, x0: f64, m: egui::Modifiers, release: bool| { + tool_event(app, ToolEvent::Down { x: x0, y: 20.0, pressure: 1.0 }, m); + for [x, y] in [[x0 + 16.0, 20.0], [x0 + 16.0, 40.0], [x0, 40.0]] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + if release { + tool_event(app, ToolEvent::Up { x: x0, y: 40.0 }, m); + } + }; + let selected = |app: &PhotocraftApp, x: i32| app.session.active().unwrap().doc.selection.as_ref().is_some_and(|s| s.sample_channel(x, 30, 0) > 0.0); + for tool in [Tool::Patch, Tool::ContentAwareMove] { + let mut app = app(); + app.ui.tool = tool; + lasso(&mut app, 10.0, egui::Modifiers::NONE, true); + assert!(selected(&app, 18), "{tool:?}: the first lasso made a selection"); + assert!(!patch_replaces_selection(&app), "{tool:?}: nothing is being drawn"); + + lasso(&mut app, 60.0, egui::Modifiers::NONE, false); + assert!(patch_replaces_selection(&app), "{tool:?}: the old outline is hidden while the new one is drawn"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, egui::Modifiers::NONE); + assert!(!patch_replaces_selection(&app), "{tool:?}: the new selection shows after the release"); + assert!(selected(&app, 68) && !selected(&app, 18), "{tool:?}: the new area replaced the old one"); + + lasso(&mut app, 10.0, egui::Modifiers::SHIFT, false); + assert!(!patch_replaces_selection(&app), "{tool:?}: ⇧ adds, so the old outline stays"); + tool_event(&mut app, ToolEvent::Up { x: 10.0, y: 40.0 }, egui::Modifiers::SHIFT); + assert!(selected(&app, 68) && selected(&app, 18), "{tool:?}: ⇧ added to the selection"); + } +} + +/// The screen boxes of the marching ants painted in one canvas frame (meshes of black and white). +fn painted_ants(app: &mut PhotocraftApp) -> Vec { + painted_ants_of(app, 0) +} + +/// [`painted_ants`] for the document at `idx`. +fn painted_ants_of(app: &mut PhotocraftApp, idx: usize) -> Vec { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0)); + let view = crate::state::View { zoom: 2.0, center: [50.0, 30.0], doc_size: [100, 60], fit_pending: false, ..Default::default() }; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + crate::canvas::canvas_view(app, ui, idx, rect, view.clone(), true); + }); + output.textures_delta.clear(); + let black_and_white = + |m: &egui::Mesh| m.vertices.iter().any(|v| v.color == egui::Color32::BLACK) && m.vertices.iter().any(|v| v.color == egui::Color32::WHITE); + output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(m) if black_and_white(m) => Some(egui::Rect::from_points(&m.vertices.iter().map(|v| v.pos).collect::>())), + _ => None, + }) + .collect() +} + +/// #11024 on the canvas: while a new Patch lasso is drawn, no ants are painted where the selection +/// it replaces is, only along the lasso; a ⇧ lasso, which adds, keeps them. +#[test] +fn a_new_patch_lasso_paints_no_ants_on_the_old_selection() { + for tool in [Tool::Patch, Tool::ContentAwareMove] { + for (m, kept) in [(egui::Modifiers::NONE, false), (egui::Modifiers::SHIFT, true)] { + let mut app = app(); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 16, "height": 20})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + let old = painted_ants(&mut app).into_iter().reduce(|a, b| a.union(b)).expect("the selection has its ants"); + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 20.0, pressure: 1.0 }, m); + for [x, y] in [[76.0, 20.0], [76.0, 40.0], [60.0, 40.0]] { + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + let ants = painted_ants(&mut app); + assert!(ants.iter().any(|a| !a.intersects(old)), "{tool:?} {m:?}: the lasso being drawn has its ants: {ants:?}"); + assert_eq!(ants.iter().any(|a| a.intersects(old)), kept, "{tool:?} {m:?}: ants on the old selection at {old:?}: {ants:?}"); + } + } +} + +/// The ⌥ badge asks every frame whether anything is selected: the canvas caches the selection's +/// bounds even while its ants are hidden (Selection Edges off, a new Patch lasso), so that is +/// never a scan per frame (#11024). +#[test] +fn hidden_ants_still_cache_the_selection_bounds() { + let cached = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let fingerprint = crate::surface_fingerprint(st.doc.selection.as_ref().unwrap()); + app.bounds_cache.get(&(u64::MAX - st.doc.id.0)).is_some_and(|(f, b)| *f == fingerprint && !b.is_empty()) + }; + let mut app = app(); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 16, "height": 20})).unwrap(); + app.sync_views(); + app.ui.view.show.selection_edges = false; + assert!(painted_ants(&mut app).is_empty(), "Selection Edges off"); + assert!(cached(&app), "cached with Selection Edges off"); + + app.ui.view.show.selection_edges = true; + app.ui.tool = Tool::Patch; + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 76.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + app.bounds_cache.clear(); + painted_ants(&mut app); + assert!(patch_replaces_selection(&app) && cached(&app), "cached while a new Patch lasso hides the ants"); +} + +/// A Patch or Content-Aware Move drag belongs to the document it was pressed on: switching to +/// another drops it, so it doesn't hide that document's ants or land on it (#11024). +#[test] +fn a_patch_drag_is_dropped_when_another_document_is_active() { + for tool in [Tool::Patch, Tool::ContentAwareMove] { + let mut app = app(); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 16, "height": 20})).unwrap(); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 76.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + app.sync_views(); + assert!(patch_replaces_selection(&app), "{tool:?}: the drag is still on its own document"); + + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("select.rect", json!({"x": 50, "y": 10, "width": 30, "height": 30})).unwrap(); + app.sync_views(); + assert!(app.drag.is_none() && !patch_replaces_selection(&app), "{tool:?}: dropped on the switch"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, egui::Modifiers::NONE); + let sel = app.session.active().unwrap().doc.selection.clone().unwrap(); + assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(50, 10, 80, 40), "{tool:?}: the other document's selection is untouched"); + } +} + +/// The active document can change after `sync_views` (a control request, a menu command) and +/// before the canvas paints: a Patch drag pressed on the old one must not hide the new one's ants +/// for that frame (#11024 review). +#[test] +fn a_patch_drag_on_another_document_hides_no_ants_before_it_is_dropped() { + for tool in [Tool::Patch, Tool::ContentAwareMove] { + let mut app = app(); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 16, "height": 20})).unwrap(); + // The other document's selection is clear of the press, so the drag would lasso it. + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap(); + let other = app.session.active_index().unwrap(); + assert!(app.session.set_active(0)); + app.sync_views(); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 76.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + app.sync_views(); + + assert!(app.session.set_active(other)); + assert!(app.drag.is_some(), "{tool:?}: not dropped yet; sync_views has not run"); + assert!(!patch_replaces_selection(&app), "{tool:?}: the drag belongs to the other document"); + assert!(!painted_ants_of(&mut app, other).is_empty(), "{tool:?}: the newly active document shows its ants"); + } +} + +/// A document closed and reopened with the same ID mid-drag is another document, whether it comes +/// back at the same revision or is edited past it: the drag is dropped and its release neither +/// lassos nor patches the reopened one. An ordinary edit keeps the drag (#11024 review). +#[test] +fn a_patch_drag_is_dropped_when_its_document_is_reopened_with_the_same_id() { + for tool in [Tool::Patch, Tool::ContentAwareMove] { + // (reopened, revisions the reopened document is edited past the pressed one) + for (reopen, edits) in [(false, 0), (true, 0), (true, 1)] { + let mut app = app(); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 16, "height": 20})).unwrap(); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 76.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + if reopen { + let closed = app.session.close(0).unwrap(); + app.session.add_document((*closed.doc).clone(), None); + app.session.active_mut().unwrap().revision = closed.revision + edits; + assert_eq!(app.session.active().unwrap().doc.id, closed.doc.id, "a reopened document may keep its ID"); + } else { + app.run("layer.new.layer", json!({})).unwrap(); + } + app.sync_views(); + assert_eq!(app.drag.is_some(), !reopen, "{tool:?}: reopened {reopen}, edited {edits}"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, egui::Modifiers::NONE); + let sel = app.session.active().unwrap().doc.selection.clone().unwrap(); + let lassoed = sel.content_bounds() != photocraft_geom::Rect::new(10, 20, 26, 40); + assert_eq!(lassoed, !reopen, "{tool:?}: reopened {reopen}, edited {edits}: {:?}", sel.content_bounds()); + } + } +} + +/// #11024: the Patch and Content-Aware Move cursors show + with ⇧ and − with ⌥, like the Lasso's, +/// but nothing over the selection without them, where a press drags the patch. +#[test] +fn patch_cursor_shows_the_selection_mode_badge() { + use crate::tool_feedback::{Badge, badge}; + let (none, shift, alt) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::ALT); + for tool in [Tool::Patch, Tool::ContentAwareMove] { + let mut app = app(); + app.ui.tool = tool; + app.hover_doc = Some([80.0, 30.0]); + assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}"); + assert_eq!(badge(&app, tool, alt), None, "{tool:?}: nothing selected, so ⌥ has nothing to subtract"); + assert_eq!(badge(&app, tool, none), None, "{tool:?}: a new selection has no badge"); + + app.run("select.rect", json!({"x": 10, "y": 20, "width": 20, "height": 20})).unwrap(); + assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}"); + assert_eq!(badge(&app, tool, alt), Some(Badge::Subtract), "{tool:?}"); + assert_eq!(badge(&app, tool, egui::Modifiers::SHIFT | egui::Modifiers::ALT), Some(Badge::Intersect), "{tool:?}"); + app.ui.selection_mode = 1; + assert_eq!(badge(&app, tool, none), Some(Badge::Add), "{tool:?}: the options-bar mode outside the selection"); + app.hover_doc = Some([20.0, 30.0]); + assert_eq!(badge(&app, tool, none), None, "{tool:?}: a press over the selection drags the patch"); + assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}: ⇧ over the selection adds"); + } +} + #[test] fn patch_tool_content_aware_fills_from_where_it_is_dropped() { let mut app = app(); @@ -762,16 +977,16 @@ fn create_a_new_layer_sends_new_layer_to_the_command() { #[test] fn red_eye_is_in_the_j_flyout() { - let j = [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; + let j = [Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; assert!(j.contains(&Tool::RedEye)); assert!(j.iter().all(|t| t.key() == 'J')); } #[test] -fn j_cycles_from_remove() { +fn j_cycles_from_spot_healing() { // J from another group picks the first J tool, and repeated J goes on in this order. let group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == 'J').collect(); - assert_eq!(group, [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]); + assert_eq!(group, [Tool::SpotHealing, Tool::Remove, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]); } #[test] @@ -837,6 +1052,7 @@ fn art_history_options_bar_defaults() { h.get_by_label("Style"); h.get_by_label("Area"); h.get_by_label("Tolerance"); + assert!(h.query_all_by_label("Opacity").next().is_some(), "the options bar has an Opacity field"); assert_eq!(h.state().ui.tool_options.art_history_style, "tightMedium"); assert_eq!(h.state().ui.tool_options.art_history_area, 50.0); assert_eq!(h.state().ui.tool_options.art_history_tolerance, 0.0); @@ -852,3 +1068,97 @@ fn art_history_stroke_runs_the_command() { drag(&mut app, Tool::ArtHistoryBrush); assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("paint.artHistoryBrush")); } + +#[test] +fn art_history_brush_opacity_reaches_the_command() { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[0, 30], [100, 30]], "size": 20, "color": "#3060c0"})).unwrap(); + app.ui.tool = Tool::ArtHistoryBrush; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"opacity": 0.4}})).unwrap(); + drag(&mut app, Tool::ArtHistoryBrush); + let (id, params) = app.session.journal.last().unwrap(); + assert_eq!(id, "paint.artHistoryBrush"); + let opacity = params["opacity"].as_f64().unwrap(); + assert!((opacity - 40.0).abs() < 1e-3, "{opacity}"); +} + +#[test] +fn art_history_brush_keeps_its_own_opacity_and_reset_tool_restores_it() { + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::ArtHistoryBrush; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"opacity": 0.4}})).unwrap(); + app.ui.tool = Tool::Brush; + crate::paint_mouse::sync_tool_brush(&mut app); + assert_eq!(app.session.tools.brush.opacity, 1.0, "the Brush has its own opacity"); + app.ui.tool = Tool::ArtHistoryBrush; + crate::paint_mouse::sync_tool_brush(&mut app); + assert_eq!(app.session.tools.brush.opacity, 0.4, "Art History keeps its own"); + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + assert_eq!(app.session.tools.brush.opacity, 1.0); +} + +#[test] +fn history_brush_opacity_and_flow_reach_the_painted_pixels() { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[10, 10]], "size": 16, "color": "#ff0000"})).unwrap(); + app.run("paint.pencil", json!({"points": [[10, 10]], "size": 16, "color": "#00ff00"})).unwrap(); + app.ui.tool = Tool::HistoryBrush; + app.run("tools.setBrush", json!({"brush": {"opacity": 0.5, "flow": 0.5}})).unwrap(); + assert!(finish_stroke(&mut app, Tool::HistoryBrush, &[[10.0, 10.0, 1.0]], egui::Modifiers::NONE)); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (id, params) = app.session.journal.last().unwrap(); + assert_eq!(id, "paint.historyBrush"); + assert_eq!(params["opacity"], 50.0); + assert_eq!(params["flow"], 50.0); +} + +#[test] +fn history_brush_options_bar_shows_source_name() { + use crate::theme::ThemeKind; + use egui::vec2; + use egui_kittest::kittest::Queryable; + let mut app = app(); + app.ui.tool = Tool::HistoryBrush; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::Studio); + h.run_steps(4); + h.get_by_label("Paints from the document's opening state"); + + // Now set a snapshot as the source + h.state_mut().run("history.newSnapshot", json!({"name": "MySnap"})).unwrap(); + h.state_mut().run("history.setSource", json!({"snapshot": 1})).unwrap(); + h.run_steps(4); + h.get_by_label("Source: MySnap"); + + // Now set an explicit state as the source + h.state_mut().run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + h.state_mut().run("history.setSource", json!({"state": 1})).unwrap(); + h.run_steps(4); + h.get_by_label("Source: Fill"); + + // Deleting the source snapshot leaves source(st) as None, displaying the missing-source hint + h.state_mut().run("history.setSource", json!({"snapshot": 1})).unwrap(); + h.state_mut().run("history.deleteSnapshot", json!({"snapshot": 1})).unwrap(); + h.run_steps(4); + h.get_by_label("The History Brush source was deleted: set a new source in the History panel"); + + // Discarding a redo state source also leaves source(st) as None + h.state_mut().run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + // Set source to the latest state (state 2), then undo, then paint new stroke to discard redo + h.state_mut().run("history.setSource", json!({"state": 2})).unwrap(); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("paint.pencil", json!({"points": [[10, 10]], "size": 1, "color": "#0000ff"})).unwrap(); + h.run_steps(4); + h.get_by_label("The History Brush source was deleted: set a new source in the History panel"); +} diff --git a/crates/ui-egui/src/tools/shape_bounds_box.rs b/crates/ui-egui/src/tools/shape_bounds_box.rs new file mode 100644 index 0000000000..ee137bc541 --- /dev/null +++ b/crates/ui-egui/src/tools/shape_bounds_box.rs @@ -0,0 +1,310 @@ +//! A bounding box with eight handles over the selected shape layers, as in Photoshop: with +//! Path Selection or a shape tool (Rectangle, Ellipse, Triangle, Polygon, Line) active and one or +//! more shape layers selected, the union of their path bounds shows as a box. Dragging a handle +//! previews the scaled outlines and, on release, scales every selected shape about the opposite +//! handle with one `edit.transform` (which keeps live shapes live), as one History step named +//! "Transform Path". No Free Transform session opens, and there is no rotate zone. A corner +//! scales both ways, the pointer projected onto the box diagonal as Free Transform's corner does +//! (#11096); an edge scales one way, grabbed at its middle handle or anywhere along its line +//! (#11473). The corner radius widgets (`corner_radius`) keep priority on a press. + +use egui::{Color32, CursorIcon, Modifiers, Pos2, Stroke, vec2}; +use photocraft_doc::LayerContent; +use photocraft_geom::Affine; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; +use crate::state::Tool; +use crate::tool_cursor; + +/// The scale a drag can reach, as `path.transform`'s. +const MIN_SCALE: f64 = 0.01; +const MAX_SCALE: f64 = 100.0; +/// A handle is grabbed within this many screen points. +const GRAB_PX: f64 = 7.0; +/// Away from the handles, a side is grabbed anywhere along its line and up to this many screen +/// points outside it (#11473). Inside the box only the line itself counts: a press just inside +/// still selects or draws, as the tool does there. The band stays narrower than a handle: unlike +/// Free Transform's box, which rotates everywhere outside, this box leaves the outside to the tool +/// (select or move another shape, draw a new one, start a marquee). +const EDGE_PX: f64 = 5.0; +/// How far inside the box a point still counts as on its line, in screen points: rounding of the +/// pointer through the view, not a grab area. +const LINE_PX: f64 = 1e-3; +/// Path Selection's pick tolerance on a press, in screen points (`tool_input`, `path_ops`): a +/// shape's outline within it is that shape's, so a stroke-only neighbour keeps its press too. +const PICK_PX: f64 = 6.0; + +/// A handle drag in progress. +#[derive(Clone, Debug, PartialEq)] +pub struct Drag { + /// The document and its revision at the press: a drag outlives neither a switch to another + /// document nor an edit under it (Undo, say). + doc: photocraft_doc::DocId, + revision: u64, + /// The tool it started with: a switch to another tool ends it, even one that shows the box. + tool: Tool, + /// The selected shape layers, as at the press. + layers: Vec, + /// Their union box `[x0, y0, x1, y1]` at the press. + rect: [f64; 4], + /// The grabbed handle: `-1` left or top, `1` right or bottom, `0` the middle. + handle: [i8; 2], + /// Where the press was. + start: [f64; 2], + /// The scale so far, per axis. + scale: [f64; 2], +} + +/// The tools that show the box, while View › Extras and View › Show › Target Path show paths +/// (the overlay draws it only then, so a hidden handle must not take a press either). +fn shows(app: &PhotocraftApp) -> bool { + app.ui.transform.is_none() + && app.ui.view.shows(app.ui.view.show.target_path) + && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) +} + +/// The selected shape layers and the box round their paths. `None` unless at least one layer is +/// selected and all are shape layers. +fn selection(app: &PhotocraftApp) -> Option<(Vec, [f64; 4])> { + let st = app.session.active()?; + let ids = st.selected_layers(); + if ids.is_empty() { + return None; + } + let mut layers = Vec::with_capacity(ids.len()); + let mut rect: Option<[f64; 4]> = None; + for id in ids { + let l = st.doc.layer(id)?; + let LayerContent::Shape(sh) = &l.content else { return None }; + let (x0, y0, x1, y1) = sh.path.control_bounds()?; + rect = Some(rect.map_or([x0, y0, x1, y1], |r| [r[0].min(x0), r[1].min(y0), r[2].max(x1), r[3].max(y1)])); + layers.push(l.id.0); + } + let r = rect.filter(|r| r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1])?; + Some((layers, r)) +} + +/// The eight handles of `r`: their `[hx, hy]` and where they sit. +fn handles(r: [f64; 4]) -> [([i8; 2], [f64; 2]); 8] { + let [x0, y0, x1, y1] = r; + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + [ + ([-1, -1], [x0, y0]), + ([0, -1], [cx, y0]), + ([1, -1], [x1, y0]), + ([1, 0], [x1, cy]), + ([1, 1], [x1, y1]), + ([0, 1], [cx, y1]), + ([-1, 1], [x0, y1]), + ([-1, 0], [x0, cy]), + ] +} + +/// The point a handle's drag scales about: the opposite handle (the box's centre on a middle axis). +fn fixed_point(r: [f64; 4], handle: [i8; 2]) -> [f64; 2] { + let pick = |h: i8, lo: f64, hi: f64| match h { + 1 => lo, + -1 => hi, + _ => (lo + hi) / 2.0, + }; + [pick(handle[0], r[0], r[2]), pick(handle[1], r[1], r[3])] +} + +/// The scale a drag of `handle` by `d` document px gives the box `r`, per axis. A corner scales +/// both ways by the same factor, the drag projected onto the box diagonal; an edge middle scales +/// one way. +pub(crate) fn scale_for(r: [f64; 4], handle: [i8; 2], d: [f64; 2]) -> [f64; 2] { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + let clamp = |s: f64| if s.is_finite() { s.clamp(MIN_SCALE, MAX_SCALE) } else { 1.0 }; + match handle { + [0, 0] => [1.0, 1.0], + [hx, 0] => [clamp(1.0 + d[0] * f64::from(hx) / w), 1.0], + [0, hy] => [1.0, clamp(1.0 + d[1] * f64::from(hy) / h)], + [hx, hy] => { + let diag = [f64::from(hx) * w, f64::from(hy) * h]; + let s = clamp(1.0 + (d[0] * diag[0] + d[1] * diag[1]) / (diag[0] * diag[0] + diag[1] * diag[1])); + [s, s] + } + } +} + +/// `r` after scaling by `s` about `fixed`. +fn scaled_rect(r: [f64; 4], fixed: [f64; 2], s: [f64; 2]) -> [f64; 4] { + let at = |v: f64, f: f64, s: f64| f + (v - f) * s; + let (a, b) = (at(r[0], fixed[0], s[0]), at(r[2], fixed[0], s[0])); + let (c, d) = (at(r[1], fixed[1], s[1]), at(r[3], fixed[1], s[1])); + [a.min(b), c.min(d), a.max(b), c.max(d)] +} + +fn zoom(app: &PhotocraftApp) -> f64 { + f64::from(app.point_zoom().max(0.01)) +} + +/// The handle within grabbing distance of document point `p`, the nearest; else the side whose +/// line it is on or just outside, as that side's middle handle, unless an unselected shape is +/// there. The handles come first, so a corner keeps its press where the sides' bands meet it. +fn hit(app: &PhotocraftApp, p: [f64; 2]) -> Option<[i8; 2]> { + if !shows(app) { + return None; + } + let (layers, r) = selection(app)?; + let zoom = zoom(app); + let tol = GRAB_PX / zoom; + handles(r) + .into_iter() + .map(|(h, at)| (h, (at[0] - p[0]).hypot(at[1] - p[1]))) + .filter(|(_, d)| *d <= tol) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(h, _)| h) + .or_else(|| edge_hit(r, p, EDGE_PX / zoom, LINE_PX / zoom).filter(|_| !on_other_shape(app, &layers, p, PICK_PX / zoom))) +} + +/// Whether the topmost shape at document point `p` is one the box doesn't hold, picked as Path +/// Selection picks: a press on a neighbouring shape in a side's band selects or draws there, as +/// the tool does, instead of scaling the selection. +fn on_other_shape(app: &PhotocraftApp, layers: &[u64], p: [f64; 2], tol: f64) -> bool { + app.session.active().and_then(|st| crate::vector_ui::pick_shape_at(&st.doc, p, tol)).is_some_and(|id| !layers.contains(&id.0)) +} + +/// The side of the box `r` nearest document point `p`, within `band` outside the box or within +/// `line` inside it: `[-1, 0]` left, `[1, 0]` right, `[0, -1]` top, `[0, 1]` bottom. +pub(crate) fn edge_hit(r: [f64; 4], p: [f64; 2], band: f64, line: f64) -> Option<[i8; 2]> { + let [x0, y0, x1, y1] = r; + let [x, y] = p; + // `f64::max` drops a NaN, so `beyond` would put a NaN point on a side's span. + if !(x.is_finite() && y.is_finite()) { + return None; + } + let inside = x > x0 && x < x1 && y > y0 && y < y1; + let reach = if inside { line } else { band }; + // How far `v` lies beyond the span `lo..=hi`: a side is a segment, not a whole line. + let beyond = |v: f64, lo: f64, hi: f64| (lo - v).max(v - hi).max(0.0); + let (past_x, past_y) = (beyond(x, x0, x1), beyond(y, y0, y1)); + let sides = [([-1, 0], (x - x0).hypot(past_y)), ([1, 0], (x - x1).hypot(past_y)), ([0, -1], (y - y0).hypot(past_x)), ([0, 1], (y - y1).hypot(past_x))]; + sides.into_iter().filter(|(_, d)| *d <= reach).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(h, _)| h) +} + +/// The scale cursor over a handle at document point `p`, or while one is dragged, turned with the +/// view (its rotation, flip and pixel aspect) as Free Transform's. +pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2]) -> Option { + let (handle, r) = match app.ui.shape_box.as_ref() { + Some(d) => (d.handle, d.rect), + None => (hit(app, p)?, selection(app)?.1), + }; + let [x0, y0, x1, y1] = r; + let quad = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + Some(match handle { + [-1, -1] => tool_cursor::corner_resize(&quad, 0, xf), + [1, -1] => tool_cursor::corner_resize(&quad, 1, xf), + [1, 1] => tool_cursor::corner_resize(&quad, 2, xf), + [-1, 1] => tool_cursor::corner_resize(&quad, 3, xf), + [_, 0] => tool_cursor::resize([1.0, 0.0], xf), + _ => tool_cursor::resize([0.0, 1.0], xf), + }) +} + +/// Drop a handle drag without applying it (Esc, or its document or tool gone); the rest of the +/// press, its moves and release, does nothing. +pub fn cancel(app: &mut PhotocraftApp) { + if app.ui.shape_box.take().is_some() { + app.cancelled_vector_drag = true; + } +} + +/// Cancel a handle drag whose tool, document or document revision is no longer the one it +/// started on, or whose box is hidden now. Run every frame from `sync_views`. +pub fn cancel_stale(app: &mut PhotocraftApp) { + let Some(d) = app.ui.shape_box.as_ref() else { return }; + let current = app.session.active().is_some_and(|st| st.doc.id == d.doc && st.revision == d.revision); + if !current || app.ui.tool != d.tool || !shows(app) { + cancel(app); + } +} + +/// Canvas pointer events for the box; false when the event is not its. +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + match ev { + ToolEvent::Down { x, y, .. } => { + if mods.command { + return false; + } + let Some(handle) = hit(app, [x, y]) else { return false }; + let Some((layers, rect)) = selection(app) else { return false }; + let Some((doc, revision)) = app.session.active().map(|st| (st.doc.id, st.revision)) else { return false }; + app.ui.shape_box = Some(Drag { doc, revision, tool: app.ui.tool, layers, rect, handle, start: [x, y], scale: [1.0, 1.0] }); + // The press may have started the Move tool's snapping, which a box drag has none of. + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + app.move_mods = Default::default(); + true + } + ToolEvent::Move { x, y, .. } => { + let Some(d) = app.ui.shape_box.as_mut() else { return false }; + d.scale = scale_for(d.rect, d.handle, [x - d.start[0], y - d.start[1]]); + true + } + ToolEvent::Up { x, y } => { + let had = app.ui.shape_box.is_some(); + cancel_stale(app); + if app.ui.shape_box.is_none() { + // The release of a drag that was just dropped is still the box's: it must not + // reach the tool that is active now. + return had; + } + pointer(app, ToolEvent::Move { x, y, pressure: 1.0 }, mods); + let Some(d) = app.ui.shape_box.take() else { return true }; + // A click without movement changes nothing. + if d.scale == [1.0, 1.0] { + return true; + } + let fixed = fixed_point(d.rect, d.handle); + let params = json!({ + "layers": d.layers, + "rect": d.rect, + "scaleX": d.scale[0] * 100.0, + "scaleY": d.scale[1] * 100.0, + "reference": fixed, + // Named by the command: with background jobs on, the step lands after `run` + // returns, so a rename here would rename the step before it. + "stepName": photocraft_engine::transform_cmds::TRANSFORM_PATH, + }); + if let Err(e) = app.run("edit.transform", params) { + app.ui.status = e; + app.ui.status_error = true; + } + true + } + } +} + +/// The box and its handles, and while a handle is dragged the scaled outlines. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { + if !shows(app) { + return; + } + let Some((_, rect)) = selection(app) else { return }; + let mut shown = rect; + if let Some(d) = app.ui.shape_box.as_ref() { + let fixed = fixed_point(d.rect, d.handle); + shown = scaled_rect(d.rect, fixed, d.scale); + let map = Affine { m: [d.scale[0], 0.0, 0.0, d.scale[1], fixed[0] * (1.0 - d.scale[0]), fixed[1] * (1.0 - d.scale[1])] }; + if let Some(st) = app.session.active() { + for id in &d.layers { + if let Some(LayerContent::Shape(sh)) = st.doc.layer(photocraft_doc::LayerId(*id)).map(|l| &l.content) { + crate::vector_ui::draw_outline(painter, &sh.path.transform(&map), to_scr, accent); + } + } + } + } + // Through all four corners: under a rotated view two opposite ones don't span the box. + let [x0, y0, x1, y1] = shown; + let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(to_scr); + painter.add(egui::Shape::closed_line(corners.to_vec(), Stroke::new(1.0, accent))); + for (_, at) in handles(shown) { + let h = egui::Rect::from_center_size(to_scr(at), vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside); + } +} diff --git a/crates/ui-egui/src/tools/shape_gradient_ui.rs b/crates/ui-egui/src/tools/shape_gradient_ui.rs index e2fbfdb679..6b9ed453e2 100644 --- a/crates/ui-egui/src/tools/shape_gradient_ui.rs +++ b/crates/ui-egui/src/tools/shape_gradient_ui.rs @@ -348,7 +348,7 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ let pos = resp.interact_pointer_pos(); if let Some(pos) = pos { // Dragged well off the strip, a stop is deleted (while at least two remain), as in Photoshop. - let off = pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0; + let off = crate::gradient_ui::off_strip(pos, bar); let loc = loc_of(pos.x); let edit = match drag.marker { Marker::Color(i) if off && start_cs.len() > 2 => json!({"action": "delete", "index": i}), diff --git a/crates/ui-egui/src/tools/shape_options.rs b/crates/ui-egui/src/tools/shape_options.rs index f7ca997539..8aabe45922 100644 --- a/crates/ui-egui/src/tools/shape_options.rs +++ b/crates/ui-egui/src/tools/shape_options.rs @@ -32,7 +32,9 @@ fn selected_shape(session: &photocraft_engine::Session) -> Option } } -pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) { +/// The Shape tools' options. `paint` shows Fill and Stroke: off in Path mode, where a drag draws +/// only an outline, but the tool's geometry (radius, sides, weight, custom shape) stays. +pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool, paint: bool) { let t = Tokens::get(ui.ctx()); let lbl = |ui: &mut egui::Ui, s: &str| { let label = ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0)); @@ -40,7 +42,8 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool crate::widgets::scrub_option_label(ui, &label); }; let o = &mut app.ui.tool_options; - let target = selected_shape(&app.session); + // Path mode edits only the next outline: an active shape layer is not the target. + let target = if paint { selected_shape(&app.session) } else { None }; // Fill and Stroke edit the selected shape layer too (#3347), and still set the next shape's. let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); let (mut has_fill, fill, stroke) = match &target { @@ -61,40 +64,43 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool let gesture = paint_gesture(ui, gesture_key); let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}:{}:{gesture}", s.doc, s.layer.0, gesture_key.value())); let mut edit: Option = None; - lbl(ui, tl!("Fill:")); - if crate::widgets::checkbox(ui, &mut has_fill, tl!("Fill")).changed() { - o.shape_fill = has_fill; - edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }})); - } - let fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); - lbl(ui, tl!("Stroke:")); - let stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); - if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() { - o.stroke_width = width; - // A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke. - if stroke.is_some() || width > 0.0 { - let mut p = json!({"width": width}); + let (mut fill_color, mut stroke_color) = (None, None); + if paint { + lbl(ui, tl!("Fill:")); + if crate::widgets::checkbox(ui, &mut has_fill, tl!("Fill")).changed() { + o.shape_fill = has_fill; + edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }})); + } + fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); + lbl(ui, tl!("Stroke:")); + stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); + if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() { + o.stroke_width = width; + // A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke. + if stroke.is_some() || width > 0.0 { + let mut p = json!({"width": width}); + if stroke.is_none() { + p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param); + } + edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")})); + } + } + let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer)); + if let Some(change) = + crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled) + { + let mut p = change.options.params(); + if !stroke_enabled { + p["width"] = json!(3.0); + } if stroke.is_none() { p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param); } - edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")})); + edit = Some(json!({"stroke": p, "coalesce": change.coalesce})); + o.shape_stroke = change.options; + o.stroke_width = enabled_stroke_width(o.stroke_width); } } - let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer)); - if let Some(change) = - crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled) - { - let mut p = change.options.params(); - if !stroke_enabled { - p["width"] = json!(3.0); - } - if stroke.is_none() { - p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param); - } - edit = Some(json!({"stroke": p, "coalesce": change.coalesce})); - o.shape_stroke = change.options; - o.stroke_width = enabled_stroke_width(o.stroke_width); - } match tool { Tool::Rectangle => { crate::widgets::vline(ui, 22.0); diff --git a/crates/ui-egui/src/tools/slice_ui.rs b/crates/ui-egui/src/tools/slice_ui.rs index f56e330f0a..3be02a6d81 100644 --- a/crates/ui-egui/src/tools/slice_ui.rs +++ b/crates/ui-egui/src/tools/slice_ui.rs @@ -229,6 +229,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) - true } +/// The slice colour from Preferences › Guides, Grid & Slices. +pub fn slice_color(app: &PhotocraftApp) -> Color32 { + hex(&app.session.prefs().guides_grid_and_slices.slice_color, Color32::from_rgb(0x38, 0xb5, 0xff)) +} + fn hex(s: &str, fallback: Color32) -> Color32 { let s = s.trim_start_matches('#'); let b = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); @@ -246,7 +251,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform return; } let prefs = &app.session.prefs().guides_grid_and_slices; - let color = hex(&prefs.slice_color, Color32::from_rgb(0x38, 0xb5, 0xff)); + let color = slice_color(app); let numbers = prefs.show_slice_numbers; let selected_color = Color32::from_rgb(0xff, 0xb0, 0x00); let to_screen = |r: photocraft_geom::Rect| Rect::from_two_pos(xf.to_screen(r.x0 as f32, r.y0 as f32), xf.to_screen(r.x1 as f32, r.y1 as f32)); diff --git a/crates/ui-egui/src/tools/symmetry_ui.rs b/crates/ui-egui/src/tools/symmetry_ui.rs index 95e6b61c61..53413abb72 100644 --- a/crates/ui-egui/src/tools/symmetry_ui.rs +++ b/crates/ui-egui/src/tools/symmetry_ui.rs @@ -167,7 +167,10 @@ fn mode_button(ui: &mut egui::Ui, mode: SymmetryMode, checked: bool) -> egui::Re } pub fn menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let current = app.session.active().and_then(|s| s.symmetry.clone()); - let resp = icons::button_with_icon_size(ui, "painting-symmetry", 24.0, 14.0, current.is_some(), tl!("Set painting symmetry options")); + // The Brush has its own text (#11813); the name stays for screen readers and the other tools. + let brush = app.ui.tool == crate::state::Tool::Brush; + let resp = icons::button_with_icon_size_tip(ui, "painting-symmetry", 24.0, 14.0, current.is_some(), tl!("Set painting symmetry options"), !brush); + let resp = if brush { resp.on_hover_text(tl!("Set symmetry options for Painting")) } else { resp }; let resp = crate::brush_picker::named(resp, "Set painting symmetry options"); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(220.0); diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 4a6ee2c2d8..9ce92dc2bb 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -13,8 +13,9 @@ //! Smudge). Resetting one of those tools resets the shared field too. Sample All Layers is kept per //! tool, as in Photoshop, so resetting a tool resets only its own. //! [`reset_options`] lists the fields of each tool. The selection tools also go back to New -//! Selection, and the painting tools (the ones with a brush in the options bar) get the default -//! brush, keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have +//! Selection, and the painting tools (the ones with a brush in the options bar) get their starting +//! brush (`paint_mouse::fresh_brush`: the Brush tool's 13 px soft round, the default brush for the +//! others), keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have //! nothing to reset. use serde_json::{Map, Value, json}; @@ -69,8 +70,16 @@ pub fn confirm(app: &mut PhotocraftApp) -> Result { /// Reset Tool: `tool`'s options, its selection mode and its brush. fn reset_tool(app: &mut PhotocraftApp, tool: Tool) { + if tool == Tool::Frame { + crate::frame_tool::cancel(app); + app.ui.frame_tool = Default::default(); + } + if tool == Tool::Artboard { + app.ui.artboard_tool = Default::default(); + } reset_options(&mut app.ui.tool_options, tool); - if is_selection_tool(tool) { + // Patch and Content-Aware Move lasso in the shared selection mode their bars show (#11024). + if is_selection_tool(tool) || matches!(tool, Tool::Patch | Tool::ContentAwareMove) { app.ui.selection_mode = 0; } if crate::paint_mouse::has_brush_picker(tool) { @@ -83,14 +92,25 @@ fn reset_tool(app: &mut PhotocraftApp, tool: Tool) { /// Reset All Tools: every option, the selection mode and every tool's brush. fn reset_all(app: &mut PhotocraftApp) { + crate::frame_tool::cancel(app); + app.ui.frame_tool = Default::default(); app.ui.tool_options = ToolOptions::default(); + app.ui.artboard_tool = Default::default(); app.ui.selection_mode = 0; - app.ui.tool_brushes.clear(); reset_session_brush(app); + // Every other painting tool gets its own starting brush. Left without one, it would take the + // live brush when picked (`sync_tool_brush`), and the tools' starting tips differ (#11810). + let (live, current) = (app.ui.brush_tool, app.session.tools.brush.clone()); + app.ui.tool_brushes = Tool::ALL + .iter() + .filter(|t| **t != live && crate::paint_mouse::has_brush_picker(**t)) + .map(|t| (*t, crate::paint_mouse::fresh_brush(*t, ¤t))) + .collect(); } +/// The live brush is `app.ui.brush_tool`'s: give it that tool's starting brush. fn reset_session_brush(app: &mut PhotocraftApp) { - app.session.tools.brush = crate::paint_mouse::fresh_brush(&app.session.tools.brush); + app.session.tools.brush = crate::paint_mouse::fresh_brush(app.ui.brush_tool, &app.session.tools.brush); app.session.tools.brush_gesture = None; app.session.tools.current_preset = None; app.session.tools.brush_changed(); @@ -139,10 +159,12 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { /// The serialized name of `tool`'s own Sample All Layers inside `sample_all`, if it has one: a /// tool preset stores that one alone, not the other tools' (#5524, #7356). pub fn sample_all_key(tool: Tool) -> Option { - let mut probe = crate::state::SampleAllLayers::default(); - *probe.slot(tool)? = true; - let Ok(Value::Object(m)) = serde_json::to_value(probe) else { return None }; - m.into_iter().find(|(_, v)| v.as_bool() == Some(true)).map(|(k, _)| k) + let base = crate::state::SampleAllLayers::default(); + let mut probe = base; + let slot = probe.slot(tool)?; + *slot = !*slot; + let (Ok(Value::Object(b)), Ok(Value::Object(m))) = (serde_json::to_value(base), serde_json::to_value(probe)) else { return None }; + m.into_iter().find(|(k, v)| b.get(k) != Some(v)).map(|(k, _)| k) } /// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and @@ -156,7 +178,17 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { }}; } match tool { - Tool::Move => reset!(move_auto_select, move_target, move_show_transform, transform_interpolation), + Tool::Move => reset!( + move_auto_select, + move_target, + move_show_transform, + move_show_hover_bounds, + move_expand_group_on_click, + move_show_layers_hover_bounds, + transform_interpolation, + transform_reference_point, + transform_relative_position + ), Tool::RectMarquee | Tool::EllipseMarquee => reset!(feather, anti_alias, marquee_style, marquee_width, marquee_height, marquee_unit), // The single-pixel marquees are never anti-aliased: Feather is their only option. Tool::SingleRowMarquee | Tool::SingleColumnMarquee => reset!(feather), @@ -212,7 +244,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::CurvaturePen => reset!(vector_mode, pen_path_op), Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit), Tool::Line => reset!( - vector_mode, + shape_mode, shape_fill, stroke_width, shape_stroke, @@ -228,7 +260,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { arrow_concavity, ), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { - reset!(vector_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) + reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler | Tool::Ruler @@ -300,6 +332,20 @@ mod tests { assert!(app.ui.dialogs.is_empty()); } + /// The Patch and Content-Aware Move bars show the shared selection mode, so Reset Tool on + /// either sets it back to New, as on the selection tools (#11024). + #[test] + fn reset_tool_resets_the_patch_selection_mode() { + let ctx = egui::Context::default(); + for tool in [Tool::Patch, Tool::ContentAwareMove] { + let mut app = app(); + app.ui.selection_mode = 2; + app.ui.tool = tool; + crate::menus::invoke(&mut app, &ctx, RESET, json!({})).unwrap(); + assert_eq!(app.ui.selection_mode, 0, "{tool:?}"); + } + } + /// Sample All Layers is per tool (#5524): resetting Blur turns off only Blur's. #[test] fn reset_tool_resets_only_its_own_sample_all_layers() { @@ -326,3 +372,7 @@ mod tests { assert_eq!((keys.len(), distinct.len()), (12, 12), "one key per tool with the option"); } } + +#[cfg(test)] +#[path = "../patch_sample_all_default_tests.rs"] +mod patch_sample_all_default_tests; diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index bf11ca303e..a5748ee7e7 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -48,7 +48,7 @@ pub(crate) use draw::draw_handle_cursor; use draw::scale_values; #[cfg(test)] pub(crate) use draw::{Readout, live_readout, styled_frame}; -pub(crate) use draw::{carries_selection_of, selection_ants}; +pub(crate) use draw::{carries_selection_of, moves_selection, selection_ants}; pub use draw::{draw_content, draw_overlay}; #[cfg(test)] use draw::{front_vertices, handle_anchor}; @@ -59,7 +59,7 @@ use options_bar::{ set_size_unit, size_pct, size_shown, size_unit, warp_grid_id, }; pub use options_bar::{options_bar, reset_warp}; -pub(crate) use pointer::{Gesture, Hit, hit, rotate_cursor_dir}; +pub(crate) use pointer::{Gesture, Hit, box_hit, hit, rotate_cursor_dir}; #[cfg(test)] use pointer::{accepts_outside_click, carry_pivot, near_rotate_corner, point_on_quad, rotation_degrees}; pub use pointer::{cursor, pointer}; @@ -228,6 +228,9 @@ struct Follow { revision: u64, /// The moving layers hidden in `doc` (nothing was lifted out of them). hidden: Vec, + /// The Layers panel's selection when the box opened, sorted; `None` when the box doesn't + /// follow it. Any change to it applies the box (`end_if_left`, #11101). + selected: Option>, } /// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls) @@ -246,6 +249,16 @@ pub fn handle_tolerance(app: &PhotocraftApp) -> f64 { HANDLE_PX / app.point_zoom().max(0.01) as f64 } +/// End a handle drag whose release a modal sampler consumed, preserving the transform. Its snapping +/// ends too, so the canvas stops drawing the drag's alignment lines. +pub(crate) fn release_handle_drag(app: &mut PhotocraftApp) { + if let Some(preview) = app.transform_preview.as_mut() { + preview.gesture = None; + } + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); +} + /// The box as one undo step restores it. #[derive(Clone, Debug, PartialEq)] struct Step { diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index d81856e164..bcdd5a0a5f 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -29,10 +29,16 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> let id = st.active_layer.ok_or("no active layer")?; // Several layers selected: one box moves them all, as in Photoshop (#6501). let targets = photocraft_engine::transform_cmds::selection_targets(st); + // The selection takes its linked layers along, as `edit.transform` does: in Photoshop the box + // over one linked layer frames and moves the whole link group (#11217), but not a linked + // Background (`box_targets`). Unlinked, unchanged. + let targets = photocraft_engine::transform_cmds::box_targets(&doc, &targets); if targets != [id] { return begin_several(app, ctx, doc, revision, id, targets); } let layer = doc.layer(id).ok_or("no layer")?; + let frame_only = + app.ui.tool == crate::state::Tool::Frame && layer.frame.is_some() && crate::frame_tool::target(app, layer) == crate::frame_tool::Target::Frame; if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) { // Photoshop refuses before the box opens rather than when it is committed (#2585). app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); @@ -74,12 +80,12 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv); app.transform_preview = Some(TransformPreview { session, - doc: Arc::new(pd), + doc: if frame_only { doc.clone() } else { Arc::new(pd) }, texture, opacity: layer.opacity * layer.fill_opacity, - above: Some(above), - styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), - follow: Some(Follow { revision, hidden: if lifted.is_none() { vec![id] } else { Vec::new() } }), + above: (!frame_only).then_some(above), + styled: (styled && !frame_only).then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), + follow: Some(Follow { revision, hidden: if lifted.is_none() && !frame_only { vec![id] } else { Vec::new() }, selected: selected_now(app) }), committing: None, gesture: None, warp_drag: None, @@ -102,7 +108,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> interpolation: default_interpolation(app).into(), warp: None, selection: false, - target: None, + target: frame_only.then(|| json!({"frame": id.0})), path: None, made: None, mode: Default::default(), @@ -130,7 +136,8 @@ pub(super) fn interpolation_preference(interpolation: &str) -> Option, revision: u64, active: LayerId, ids: Vec) -> Result<(), String> { use photocraft_engine::transform_cmds as tc; - if ids.iter().any(|id| tc::locked_for_transform(&doc, *id)) { + // The Background among them refuses the same way, rather than being left out (#11101). + if tc::background_among(&doc, &ids).is_some() || ids.iter().any(|id| tc::locked_for_transform(&doc, *id)) { app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); return Err(LOCKED.into()); } @@ -186,7 +193,7 @@ fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc