diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd8e1ec3d0..c15bc9542b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,22 @@ on: description: "Run every testkit sweep case (runtime comparison and integration validation)" type: boolean default: false + color_range: + description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)" + type: boolean + default: false + selection_combining: + description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)" + type: boolean + default: false + brush_tip: + description: "Compare cached round-tip rasterization on 24 MP" + type: boolean + default: false + brush_tip_base: + description: "Baseline commit for the round-tip comparison" + type: string + default: "main" selection_morphology: description: "Compare selection morphology on 36 MP (Linux, exact outputs)" type: boolean @@ -26,6 +42,10 @@ on: fuzz_seconds: description: "Seconds per fuzz target" default: "600" + dry_mixer: + description: "Compare the live Mixer Brush against the origin/main merge base on 24 MP (Linux)" + type: boolean + default: false # The token is only used to check out the code: no job writes to the repository. permissions: @@ -430,6 +450,36 @@ jobs: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts + color-range-performance: + if: inputs.color_range == true + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: color-range-perf + - name: Compare exact outputs and release timings on 24 MP + run: | + set -o pipefail + cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: color-range-performance + path: color-range-perf.txt + selection-morphology: if: inputs.selection_morphology == true runs-on: ubuntu-latest @@ -465,7 +515,8 @@ jobs: path: selection-morphology-perf.txt selection-morphology-quick-perf: - if: inputs.selection_morphology == true + if: inputs.selection_morphology == true || inputs.color_range == true || inputs.selection_combining == true || inputs.brush_tip == true || inputs.dry_mixer == true + name: quick performance suite # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by # bevel_bench, so its required GPU rows cannot be measured there. runs-on: macos-15 @@ -487,3 +538,128 @@ jobs: path: | target/perf/*.json target/perf/summary.md + + selection-combining: + if: inputs.selection_combining == true + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: selection-combining-perf + - name: Compare exact outputs and release timings on 36 MP + run: | + set -o pipefail + cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: selection-combining-performance + path: selection-combining-perf.txt + + brush-tip: + if: inputs.brush_tip == true + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + path: after + - uses: actions/checkout@v7 + with: + persist-credentials: false + ref: ${{ inputs.brush_tip_base }} + path: before + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: | + before -> target + after -> target + key: brush-tip-release + - name: Build the same benchmark against both versions + run: | + cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs + cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench + cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench + - name: Compare round and transformed tips on 24 MP + run: | + set -o pipefail + for size in 200 2000; do + for angle in 0 0.7; do + for version in before after; do + echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt + "$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt + done + done + done + - uses: actions/upload-artifact@v7 + if: always() + with: + name: brush-tip-performance + path: brush-tip.txt + dry-mixer: + if: inputs.dry_mixer == true + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + RAYON_NUM_THREADS: "4" + CARGO_PROFILE_RELEASE_LTO: "false" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Pin baseline to the merge base with origin/main + id: baseline + run: | + git remote add upstream https://github.com/storytold/photocraft.git + git fetch --no-tags upstream main + base=$(git merge-base HEAD upstream/main) + echo "sha=$base" >> "$GITHUB_OUTPUT" + git worktree add --detach "$RUNNER_TEMP/photocraft-baseline" "$base" + mkdir -p "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples" + cp crates/engine/examples/bench_dry_mixer.rs "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples/bench_dry_mixer.rs" + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + - name: Benchmark baseline and candidate on this runner + run: | + set -o pipefail + echo "baseline=${{ steps.baseline.outputs.sha }}" | tee dry-mixer-perf.txt + echo "candidate=${GITHUB_SHA}" | tee -a dry-mixer-perf.txt + CARGO_TARGET_DIR="$RUNNER_TEMP/target-baseline" cargo run --release --manifest-path "$RUNNER_TEMP/photocraft-baseline/Cargo.toml" -p photocraft-engine --example bench_dry_mixer | tee baseline-mixer.txt + CARGO_TARGET_DIR="$RUNNER_TEMP/target-candidate" cargo run --release -p photocraft-engine --example bench_dry_mixer | tee candidate-mixer.txt + cat baseline-mixer.txt candidate-mixer.txt >> dry-mixer-perf.txt + python3 - <<'PY' + import re + def fingerprints(path): + rows = open(path).readlines() + result = {} + for row in rows: + match = re.search(r'wet=\s*(\d+)% sample_all=\s*(\w+)\s+size=\s*(\d+).*blake3=(\w+)', row) + if match: + result[match.group(1, 2, 3)] = match.group(4) + return result + before, after = fingerprints('baseline-mixer.txt'), fingerprints('candidate-mixer.txt') + assert len(before) == 12 and before == after, 'baseline/candidate mixer output or state differs' + PY + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: dry-mixer-performance + path: dry-mixer-perf.txt diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ddbc0940a5..87d0b180fe 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,10 @@ next to them. | `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/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) | | `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) | diff --git a/Cargo.lock b/Cargo.lock index 8f4fd3a185..2b8affbfd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5023,6 +5023,7 @@ dependencies = [ name = "photocraft-algo" version = "0.17.0" dependencies = [ + "criterion", "photocraft-color", "photocraft-geom", "photocraft-raster", @@ -5295,6 +5296,7 @@ dependencies = [ "photocraft-geom", "photocraft-raster", "rayon", + "rustc-hash 2.1.3", "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 720b16776b..34d3bf0db9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" +rustc-hash = "2.1.3" criterion = { version = "0.8", default-features = false } getrandom = "0.3" # Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump). diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index bdaf249bc9..02bb1bb702 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -83,7 +83,10 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> h.session.actions.list = set.clone(); h.open(input)?; for (id, p) in &actions { - if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { + if photocraft_engine::actions_cmds::shell_view_command(id) + || photocraft_engine::actions_cmds::shell_save_command(id) + || photocraft_engine::actions_cmds::noop_step(&h.session, id, p) + { continue; } let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?; diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 9d730b4635..95d4c28288 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -93,6 +93,11 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> } let cmds = command_list(a)?; for (id, params) in cmds { + // A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802). + if photocraft_engine::actions_cmds::noop_step(&h.session, &id, ¶ms) { + print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?; + 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) { return Err(CliError::NestedFailure { id, message }); diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 2c06a488fd..e592d552e2 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1421,3 +1421,78 @@ fn control_token_env_vars_are_the_flags_fallbacks() { let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", "")); assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}"); } + +/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744), +/// so a script that deselects after Reveal Selection still finishes and saves. +#[test] +fn run_skips_deselect_with_nothing_selected() { + let folder = tmp("deselect-run"); + let native = folder.join("out.pcraft").to_string_lossy().to_string(); + let Invoke { code, stdout, stderr } = invoke(&[ + "run", + "--new", + r#"{"width":16,"height":16}"#, + "--cmd", + "select.rect", + "--params", + r#"{"x":0,"y":0,"width":8,"height":16}"#, + "--cmd", + "layer.new.layer", + "--cmd", + "layer.layerMask.revealSelection", + "--cmd", + "select.deselect", + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"after"}"#, + "--out", + &native, + ]); + assert_eq!(code, 0, "{stderr}"); + let results: Vec = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(results.len(), 5, "every command reports a result: {stdout}"); + assert_eq!(results[3], json!({"command": "select.deselect", "result": null})); + let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}"); + + // Deselect with a selection still deselects: Inverse afterwards finds nothing to invert. + let Invoke { code, stderr, .. } = invoke(&[ + "run", + "--new", + r#"{"width":16,"height":16}"#, + "--cmd", + "select.all", + "--cmd", + "select.deselect", + "--cmd", + "select.inverse", + "--out", + &native, + ]); + assert_eq!(code, 1, "{stderr}"); + assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}"); + let _ = std::fs::remove_dir_all(&folder); +} + +/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the +/// same action does in the Actions panel (#6744). +#[test] +fn batch_skips_deselect_with_nothing_selected() { + let dir = tmp("deselect-batch"); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap(); + let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]); + assert_eq!(code, 0, "{stdout}{stderr}"); + assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}"); + let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml index af50c1d20e..767c8044d9 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -23,3 +23,8 @@ rayon = { workspace = true } [dev-dependencies] photocraft-testkit = { workspace = true } serde_json = { workspace = true } +criterion = { workspace = true } + +[[bench]] +name = "blur" +harness = false diff --git a/crates/algo/benches/blur.rs b/crates/algo/benches/blur.rs new file mode 100644 index 0000000000..3165b40cd0 --- /dev/null +++ b/crates/algo/benches/blur.rs @@ -0,0 +1,33 @@ +//! `cargo bench -p photocraft-algo` +use criterion::{Criterion, criterion_group, criterion_main}; +use photocraft_algo::{FilterParams, apply}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn textured(n: u32) -> Surface { + let r = Rect::new(0, 0, n as i32, n as i32); + let mut s = Surface::new(PixelFormat::RGBA8); + let bytes: Vec = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect(); + s.write_interleaved(r, &bytes); + s +} + +/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including +/// the variance-matched kernels below radius 1 (#8544). +fn gaussian_direct(c: &mut Criterion) { + let n = 2048; + let s = textured(n); + let area = Rect::new(0, 0, n as i32, n as i32); + for radius in [0.3f32, 0.8, 3.0] { + let p = FilterParams::GaussianBlur { radius }; + c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None))); + } +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(10); + targets = gaussian_direct +} +criterion_main!(benches); diff --git a/crates/algo/examples/bench_photo.rs b/crates/algo/examples/bench_photo.rs index dc0354886d..11d29e9afd 100644 --- a/crates/algo/examples/bench_photo.rs +++ b/crates/algo/examples/bench_photo.rs @@ -122,6 +122,24 @@ fn main() { }; wideangle::apply(&s, r, &p, Interp::Bicubic) }); + // The mesh solve alone, on the dialog's 800 x 533 proxy. + let proxy = Rect::new(0, 0, 800, 533); + let solve_case = |constraints: Vec| { + let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints, ..Default::default() }; + wideangle::solve(&p, proxy) + }; + time("wide angle solve: 800x533 proxy, H + V", &only, || { + solve_case(vec![ + Constraint { a: [60.0, 80.0], b: [740.0, 80.0], orientation: Orientation::Horizontal }, + Constraint { a: [100.0, 60.0], b: [100.0, 470.0], orientation: Orientation::Vertical }, + ]) + }); + time("wide angle solve: 800x533 proxy, 2 free", &only, || { + solve_case(vec![ + Constraint { a: [60.0, 80.0], b: [740.0, 95.0], orientation: Orientation::Free }, + Constraint { a: [100.0, 60.0], b: [110.0, 470.0], orientation: Orientation::Free }, + ]) + }); time("hdr: merge 3 exposures + local adaptation", &only, || { let shots: Vec> = [0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect(); diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index bd762d073f..98054c3fea 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec { k.into_iter().map(|v| v / s).collect() } +/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole +/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a +/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the +/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4% +/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05. +pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec { + if !(0.05..1.0).contains(&radius) { + return gaussian_kernel(radius); + } + let variance = |k: &[f32]| { + let r = (k.len() / 2) as f32; + k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() + }; + // Sampled at `width` with one fixed half-width for every radius below 1: its variance then + // rises steadily with `width` (the search can't settle on a step where taps are added), and + // the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the + // half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too. + let half = 3; + let sampled = |width: f32| { + let k: Vec = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect(); + let s: f32 = k.iter().sum(); + k.into_iter().map(|v| v / s).collect::>() + }; + let target = radius * radius; + // The sampled kernel's variance only falls short of sigma², so the width lies above `radius`. + let (mut lo, mut hi) = (radius, radius + 1.0); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + if variance(&sampled(mid)) < target { + lo = mid; + } else { + hi = mid; + } + } + sampled(0.5 * (lo + hi)) +} + /// Separable convolution of `src` (premultiplied internally) for `out`. /// `src` must cover `out` grown by the kernel radii. pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec { @@ -111,7 +148,7 @@ pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec box_passes(src, out, ctx.alpha, &boxes), None => { - let k = gaussian_kernel(radius); + let k = gaussian_blur_kernel(radius); conv_sep(src, out, &k, &k, ctx.alpha) } } diff --git a/crates/algo/src/blur/tests.rs b/crates/algo/src/blur/tests.rs index 32256bc954..028ac65dcc 100644 --- a/crates/algo/src/blur/tests.rs +++ b/crates/algo/src/blur/tests.rs @@ -2,6 +2,76 @@ use super::*; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_raster::Surface; +/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel +/// path) visibly softens a hard edge, more with each step, as the kernel predicts. +#[test] +fn small_gaussian_radii_soften_a_rendered_edge() { + let canvas = Rect::new(0, 0, 20, 4); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]); + let mut last = 0.0f32; + for radius in [0.1f32, 0.2, 0.3] { + let p = FilterParams::GaussianBlur { radius }; + assert!(box_widths(&p).is_none(), "the direct-kernel path"); + let out = crate::apply(&s, &p, canvas, canvas, None); + // The last black pixel takes the white side's share of the kernel. + let got = out.pixel(9, 1)[0]; + let k = gaussian_blur_kernel(radius); + let want: f32 = k[k.len() / 2 + 1..].iter().sum(); + assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}"); + assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}"); + last = got; + } +} + +/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly +/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged. +#[test] +fn small_gaussian_radii_blur_smoothly() { + let spread = |k: &[f32]| { + let r = (k.len() / 2) as f32; + k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() + }; + let mut last = 0.0f32; + for step in 1..=20 { + let radius = step as f32 * 0.05; + let k = gaussian_blur_kernel(radius); + assert!((k.iter().sum::() - 1.0).abs() < 1e-5, "normalised at {radius}"); + if radius < 1.0 { + assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k)); + } + // A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre. + let c = k.len() / 2; + let edge: f32 = k[c + 1..].iter().sum(); + assert!(edge > last, "radius {radius}: edge {edge} after {last}"); + if radius > 0.1 { + assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last); + } + last = edge; + } + assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs"); + // No step where the variance-matched range hands over to plain sampling. + let edge = |r: f32| { + let k = gaussian_blur_kernel(r); + k[k.len() / 2 + 1..].iter().sum::() + }; + assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0)); + // No dip where a per-radius width used to add taps (1/3 and 2/3). + for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] { + assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1); + } + // Exact variance right up to the hand-over, past where `gaussian_kernel` changes width. + for radius in [0.98f32, 0.995, 0.998, 0.9995] { + let k = gaussian_blur_kernel(radius); + assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k)); + } + for radius in [1.0, 1.5, 3.0, 4.0] { + assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}"); + } + assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]); +} + #[test] fn radial_blur_samples_large_arcs_beyond_64_intervals() { let bounds = Rect::new(0, 0, 1000, 1000); diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index 7316088079..96c6f7b434 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -62,68 +62,213 @@ impl Normal { } } -/// A sparse least-squares problem `min Σ w²(row·x − rhs)²`, solved by conjugate gradients on the -/// normal equations (CGLS). Used for mesh warps with thousands of unknowns. -#[derive(Default)] -pub(crate) struct SparseLs { - pub n: usize, - rows: Vec<(Vec<(usize, f64)>, f64)>, +/// A compressed sparse row matrix (symmetric, used by CG). +pub(crate) struct Csr { + pub rows: Vec, + pub cols: Vec, + pub vals: Vec, + pub diag: Vec, } -impl SparseLs { - pub fn new(n: usize) -> Self { - SparseLs { n, rows: Vec::new() } - } - pub fn add(&mut self, row: Vec<(usize, f64)>, rhs: f64, w: f64) { - self.rows.push((row.into_iter().map(|(i, c)| (i, c * w)).collect(), rhs * w)); - } - /// CGLS from `x0`; at most `iters` iterations. - pub fn solve(&self, x0: &[f64], iters: usize) -> Vec { - let n = self.n; - let mut x = x0.to_vec(); - let ax = |x: &[f64]| -> Vec { self.rows.iter().map(|(r, _)| r.iter().map(|&(i, c)| c * x[i]).sum()).collect() }; - let atv = |v: &[f64]| -> Vec { - let mut out = vec![0.0; n]; - for ((r, _), vi) in self.rows.iter().zip(v) { - for &(i, c) in r { - out[i] += c * vi; +impl Csr { + pub fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr { + t.sort_by_key(|a| (a.0, a.1)); + let mut rows = vec![0usize; n + 1]; + let mut cols = Vec::with_capacity(t.len()); + let mut vals: Vec = Vec::with_capacity(t.len()); + let mut last = (usize::MAX, usize::MAX); + for (i, j, v) in t { + if (i, j) == last { + *vals.last_mut().unwrap_or(&mut 0.0) += v; + continue; + } + last = (i, j); + cols.push(j); + vals.push(v); + rows[i + 1] = cols.len(); + } + for i in 0..n { + rows[i + 1] = rows[i + 1].max(rows[i]); + } + let mut diag = vec![1.0; n]; + for (i, d) in diag.iter_mut().enumerate() { + for k in rows[i]..rows[i + 1] { + if cols[k] == i && vals[k] > 0.0 { + *d = vals[k]; } } - out - }; - let mut r: Vec = self.rows.iter().zip(ax(&x)).map(|((_, b), a)| b - a).collect(); - let mut s = atv(&r); - let mut p = s.clone(); - let mut gamma: f64 = s.iter().map(|v| v * v).sum(); - let g0 = gamma.max(1e-30); - for _ in 0..iters { - if gamma <= g0 * 1e-20 { + } + Csr { rows, cols, vals, diag } + } + + pub fn mul(&self, x: &[f64], out: &mut [f64]) { + for (i, o) in out.iter_mut().enumerate() { + let mut s = 0.0; + for k in self.rows[i]..self.rows[i + 1] { + s += self.vals[k] * x[self.cols[k]]; + } + *o = s; + } + } + + /// Jacobi-preconditioned conjugate gradients, warm-started from `x`. + pub fn solve(&self, b: &[f64], x: &mut [f64]) { + let n = b.len(); + let mut r = vec![0.0; n]; + self.mul(x, &mut r); + for i in 0..n { + r[i] = b[i] - r[i]; + } + let bn: f64 = b.iter().map(|v| v * v).sum::().sqrt().max(1e-30); + let mut z: Vec = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect(); + let mut p = z.clone(); + let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); + let mut ap = vec![0.0; n]; + for _ in 0..(4 * n).clamp(50, 2000) { + if r.iter().map(|v| v * v).sum::().sqrt() <= 1e-12 * bn { break; } - let q = ax(&p); - let qq: f64 = q.iter().map(|v| v * v).sum(); - if qq <= 1e-300 { + self.mul(&p, &mut ap); + let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum(); + if pap.abs() < 1e-300 { break; } - let alpha = gamma / qq; + let alpha = rz / pap; for i in 0..n { x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; } - for (ri, qi) in r.iter_mut().zip(&q) { - *ri -= alpha * qi; - } - s = atv(&r); - let g1: f64 = s.iter().map(|v| v * v).sum(); - let beta = g1 / gamma; - gamma = g1; for i in 0..n { - p[i] = s[i] + beta * p[i]; + z[i] = r[i] / self.diag[i]; + } + let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); + let beta = rz2 / rz; + rz = rz2; + for i in 0..n { + p[i] = z[i] + beta * p[i]; } } - x } } +/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee +/// order, so the system's bandwidth is about one breadth-first front): factored once per pin +/// set, then each global step is two triangular solves. +pub(crate) struct BandChol { + n: usize, + p: usize, + /// Row `i` holds `L[i][i - p ..= i]`. + l: Vec, +} + +impl BandChol { + /// `None` when the band would be too large (or the matrix isn't positive definite). + pub fn new(a: &Csr) -> Option { + let n = a.diag.len(); + let mut p = 0; + for i in 0..n { + for k in a.rows[i]..a.rows[i + 1] { + p = p.max(i.abs_diff(a.cols[k])); + } + } + if n.saturating_mul(p + 1) > 40_000_000 { + return None; + } + let w = p + 1; + let mut l = vec![0.0f64; n * w]; + for i in 0..n { + for k in a.rows[i]..a.rows[i + 1] { + let j = a.cols[k]; + if j <= i { + l[i * w + (j + p - i)] = a.vals[k]; + } + } + } + for i in 0..n { + let lo = i.saturating_sub(p); + for j in lo..=i { + let jlo = j.saturating_sub(p).max(lo); + let mut s = l[i * w + (j + p - i)]; + for k in jlo..j { + s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)]; + } + if i == j { + if s <= 0.0 || !s.is_finite() { + return None; + } + l[i * w + p] = s.sqrt(); + } else { + l[i * w + (j + p - i)] = s / l[j * w + p]; + } + } + } + Some(BandChol { n, p, l }) + } + + #[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices + pub fn solve(&self, b: &[f64], x: &mut [f64]) { + let (n, p, w) = (self.n, self.p, self.p + 1); + let mut y = b.to_vec(); + for i in 0..n { + let mut s = y[i]; + for k in i.saturating_sub(p)..i { + s -= self.l[i * w + (k + p - i)] * y[k]; + } + y[i] = s / self.l[i * w + p]; + } + for i in (0..n).rev() { + let mut s = y[i]; + for k in i + 1..(i + p + 1).min(n) { + s -= self.l[k * w + (i + p - k)] * x[k]; + } + x[i] = s / self.l[i * w + p]; + } + } +} + +/// Preconditioned conjugate gradients on the SPD system `A x = b`, warm-started from `x`. +/// `a(v, out)` writes `A·v` and `m(r, out)` writes `M⁻¹·r`. Stops when `‖r‖ ≤ tol·‖b‖`, when the +/// system stops being usable (non-finite or non-positive curvature), or after `max_iter` +/// iterations. Returns the iterations used. +pub(crate) fn pcg(a: impl Fn(&[f64], &mut [f64]), m: impl Fn(&[f64], &mut [f64]), b: &[f64], x: &mut [f64], tol: f64, max_iter: usize) -> usize { + let dot = |u: &[f64], v: &[f64]| -> f64 { u.iter().zip(v).map(|(p, q)| p * q).sum() }; + let n = b.len().min(x.len()); + let mut r = vec![0.0; n]; + a(x, &mut r); + for (ri, bi) in r.iter_mut().zip(b) { + *ri = bi - *ri; + } + let stop = tol * dot(b, b).sqrt().max(1e-30); + let mut z = vec![0.0; n]; + m(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot(&r, &z); + let mut ap = vec![0.0; n]; + for it in 0..max_iter { + if dot(&r, &r).sqrt() <= stop || rz <= 1e-300 || !rz.is_finite() { + return it; + } + a(&p, &mut ap); + let pap = dot(&p, &ap); + if pap <= 1e-300 || !pap.is_finite() { + return it; + } + let alpha = rz / pap; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + m(&r, &mut z); + let rz2 = dot(&r, &z); + let beta = rz2 / rz; + rz = rz2; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + } + max_iter +} + /// Runs `f(y, row)` for every row of a `w × h × ch` buffer, in parallel on native targets. pub(crate) fn par_rows(buf: &mut [T], w: usize, ch: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { let stride = (w * ch).max(1); @@ -251,22 +396,50 @@ mod tests { } #[test] - fn dense_and_sparse_solvers_agree() { + fn dense_solver_fits_a_line_and_rejects_singular_systems() { // Overdetermined line fit y = 2x + 1. let mut ne = Normal::new(2); - let mut sp = SparseLs::new(2); for x in 0..10 { - let y = 2.0 * x as f64 + 1.0; - ne.add(&[(0, x as f64), (1, 1.0)], y, 1.0); - sp.add(vec![(0, x as f64), (1, 1.0)], y, 1.0); + ne.add(&[(0, x as f64), (1, 1.0)], 2.0 * x as f64 + 1.0, 1.0); } let a = ne.solve().unwrap(); - let b = sp.solve(&[0.0, 0.0], 50); assert!((a[0] - 2.0).abs() < 1e-9 && (a[1] - 1.0).abs() < 1e-9); - assert!((b[0] - 2.0).abs() < 1e-6 && (b[1] - 1.0).abs() < 1e-6, "{b:?}"); assert!(solve_dense(2, vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 2.0]).is_none()); } + /// A tridiagonal SPD system: identity preconditioning needs many iterations, the exact + /// banded Cholesky preconditioner one or two; both match the dense solve. + #[test] + fn pcg_matches_the_dense_solve_and_an_exact_preconditioner_converges_at_once() { + let n = 40; + let mut trip = Vec::new(); + for i in 0..n { + trip.push((i, i, 2.5)); + if i + 1 < n { + trip.push((i, i + 1, -1.0)); + trip.push((i + 1, i, -1.0)); + } + } + let k = Csr::from_triplets(n, trip); + let mut dense = vec![0.0; n * n]; + for i in 0..n { + for e in k.rows[i]..k.rows[i + 1] { + dense[i * n + k.cols[e]] = k.vals[e]; + } + } + let b: Vec = (0..n).map(|i| (i as f64 * 0.7).sin()).collect(); + let want = solve_dense(n, dense, b.clone()).unwrap(); + let mul = |v: &[f64], out: &mut [f64]| k.mul(v, out); + let mut x = vec![0.0; n]; + let slow = pcg(mul, |r, z| z.copy_from_slice(r), &b, &mut x, 1e-12, 500); + assert!(x.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "identity-preconditioned result differs"); + let chol = BandChol::new(&k).unwrap(); + let mut y = vec![0.0; n]; + let fast = pcg(mul, |r, z| chol.solve(r, z), &b, &mut y, 1e-12, 500); + assert!(y.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "cholesky-preconditioned result differs"); + assert!(fast <= 2 && slow > fast, "iterations: exact {fast}, identity {slow}"); + } + #[test] fn srgb_round_trip_and_sampling() { for v in [0.0f32, 0.01, 0.2, 0.5, 1.0] { diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index c3c5c3693c..a1b8cbd91d 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -26,6 +26,7 @@ use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; +use crate::photo_util::{BandChol, Csr}; use crate::transform::Interp; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -744,170 +745,6 @@ fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec { order } -/// A compressed sparse row matrix (symmetric, used by CG). -struct Csr { - rows: Vec, - cols: Vec, - vals: Vec, - diag: Vec, -} - -impl Csr { - fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr { - t.sort_by_key(|a| (a.0, a.1)); - let mut rows = vec![0usize; n + 1]; - let mut cols = Vec::with_capacity(t.len()); - let mut vals: Vec = Vec::with_capacity(t.len()); - let mut last = (usize::MAX, usize::MAX); - for (i, j, v) in t { - if (i, j) == last { - *vals.last_mut().unwrap_or(&mut 0.0) += v; - continue; - } - last = (i, j); - cols.push(j); - vals.push(v); - rows[i + 1] = cols.len(); - } - for i in 0..n { - rows[i + 1] = rows[i + 1].max(rows[i]); - } - let mut diag = vec![1.0; n]; - for (i, d) in diag.iter_mut().enumerate() { - for k in rows[i]..rows[i + 1] { - if cols[k] == i && vals[k] > 0.0 { - *d = vals[k]; - } - } - } - Csr { rows, cols, vals, diag } - } - - fn mul(&self, x: &[f64], out: &mut [f64]) { - for (i, o) in out.iter_mut().enumerate() { - let mut s = 0.0; - for k in self.rows[i]..self.rows[i + 1] { - s += self.vals[k] * x[self.cols[k]]; - } - *o = s; - } - } - - /// Jacobi-preconditioned conjugate gradients, warm-started from `x`. - fn solve(&self, b: &[f64], x: &mut [f64]) { - let n = b.len(); - let mut r = vec![0.0; n]; - self.mul(x, &mut r); - for i in 0..n { - r[i] = b[i] - r[i]; - } - let bn: f64 = b.iter().map(|v| v * v).sum::().sqrt().max(1e-30); - let mut z: Vec = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect(); - let mut p = z.clone(); - let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); - let mut ap = vec![0.0; n]; - for _ in 0..(4 * n).clamp(50, 2000) { - if r.iter().map(|v| v * v).sum::().sqrt() <= 1e-12 * bn { - break; - } - self.mul(&p, &mut ap); - let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum(); - if pap.abs() < 1e-300 { - break; - } - let alpha = rz / pap; - for i in 0..n { - x[i] += alpha * p[i]; - r[i] -= alpha * ap[i]; - } - for i in 0..n { - z[i] = r[i] / self.diag[i]; - } - let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); - let beta = rz2 / rz; - rz = rz2; - for i in 0..n { - p[i] = z[i] + beta * p[i]; - } - } - } -} - -/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee -/// order, so the system's bandwidth is about one breadth-first front): factored once per pin -/// set, then each global step is two triangular solves. -struct BandChol { - n: usize, - p: usize, - /// Row `i` holds `L[i][i - p ..= i]`. - l: Vec, -} - -impl BandChol { - /// `None` when the band would be too large (or the matrix isn't positive definite). - fn new(a: &Csr) -> Option { - let n = a.diag.len(); - let mut p = 0; - for i in 0..n { - for k in a.rows[i]..a.rows[i + 1] { - p = p.max(i.abs_diff(a.cols[k])); - } - } - if n.saturating_mul(p + 1) > 40_000_000 { - return None; - } - let w = p + 1; - let mut l = vec![0.0f64; n * w]; - for i in 0..n { - for k in a.rows[i]..a.rows[i + 1] { - let j = a.cols[k]; - if j <= i { - l[i * w + (j + p - i)] = a.vals[k]; - } - } - } - for i in 0..n { - let lo = i.saturating_sub(p); - for j in lo..=i { - let jlo = j.saturating_sub(p).max(lo); - let mut s = l[i * w + (j + p - i)]; - for k in jlo..j { - s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)]; - } - if i == j { - if s <= 0.0 || !s.is_finite() { - return None; - } - l[i * w + p] = s.sqrt(); - } else { - l[i * w + (j + p - i)] = s / l[j * w + p]; - } - } - } - Some(BandChol { n, p, l }) - } - - #[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices - fn solve(&self, b: &[f64], x: &mut [f64]) { - let (n, p, w) = (self.n, self.p, self.p + 1); - let mut y = b.to_vec(); - for i in 0..n { - let mut s = y[i]; - for k in i.saturating_sub(p)..i { - s -= self.l[i * w + (k + p - i)] * y[k]; - } - y[i] = s / self.l[i * w + p]; - } - for i in (0..n).rev() { - let mut s = y[i]; - for k in i + 1..(i + p + 1).min(n) { - s -= self.l[k * w + (i + p - k)] * x[k]; - } - x[i] = s / self.l[i * w + p]; - } - } -} - /// A pin bound to the mesh: barycentric weights on a triangle's vertices. #[derive(Clone, Debug)] struct Bound { diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index ceb9c332ad..3b22ed05a5 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -639,53 +639,162 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 { /// Converts gray values (0..=1, row-major `w` wide) to 0/1 in place. pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { - let h = gray.len().checked_div(w).unwrap_or(0); - match method { - BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }), - BitmapMethod::Pattern => { - for (i, v) in gray.iter_mut().enumerate() { - *v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 }; + BitmapStrips::new(w, method).convert(gray); +} + +/// Image › Mode › Bitmap fed in strips of whole rows, top to bottom, so a huge image never needs a +/// full-size gray plane (#8477): [`BitmapStrips::convert`] turns each strip to 0/1 in place, +/// carrying error diffusion's error into the next strip. The result is the same however the image +/// is cut into strips. +pub struct BitmapStrips<'a> { + w: usize, + kind: StripKind<'a>, + /// Image row of the next strip's first row. + y: usize, +} + +enum StripKind<'a> { + Threshold, + Pattern, + /// Floyd–Steinberg: the error each pixel of the last converted row passes down. + Diffusion { + errs: Vec, + }, + /// The spot function's values over a cell, sorted (its CDF ranks coverage by tone). + Halftone { + cell: f32, + sin: f32, + cos: f32, + shape: HalftoneShape, + table: Vec, + }, + /// Custom Pattern's threshold map, `mw` x `mh`, tiled from the top-left corner. + Custom { + map: &'a [f32], + mw: usize, + mh: usize, + }, +} + +impl<'a> BitmapStrips<'a> { + pub fn new(w: usize, method: BitmapMethod) -> Self { + let kind = match method { + BitmapMethod::Threshold => StripKind::Threshold, + BitmapMethod::Pattern => StripKind::Pattern, + BitmapMethod::Diffusion => StripKind::Diffusion { errs: Vec::new() }, + BitmapMethod::Halftone { cell, angle, shape } => { + let (sin, cos) = angle.to_radians().sin_cos(); + // Rank the spot function over the cell so coverage is proportional to the tone. + let mut table: Vec = + (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect(); + table.sort_by(f32::total_cmp); + StripKind::Halftone { cell: cell.max(2.0), sin, cos, shape, table } } + }; + Self { w, kind, y: 0 } + } + + /// Bitmap's Custom Pattern: each gray value turns white where it reaches the threshold `map` + /// (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the image's top-left corner, + /// black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%. + pub fn custom(w: usize, map: &'a [f32], mw: usize, mh: usize) -> Self { + let fits = mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len()); + let kind = if fits { StripKind::Custom { map, mw, mh } } else { StripKind::Threshold }; + Self { w, kind, y: 0 } + } + + /// Converts the next rows (gray 0..=1, `w` wide; a short last row is allowed) to 0/1 in place. + pub fn convert(&mut self, gray: &mut [f32]) { + let (w, y0) = (self.w, self.y); + if w == 0 { + // No rows to lay a pattern or diffuse along: a 50% threshold. + gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }); + return; } - BitmapMethod::Diffusion => { - let mut buf: Vec = gray.to_vec(); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let old = buf[i]; - let new = if old >= 0.5 { 1.0 } else { 0.0 }; - let e = old - new; - gray[i] = new; - if x + 1 < w { - buf[i + 1] += e * 7.0 / 16.0; - } - if y + 1 < h { - if x > 0 { - buf[i + w - 1] += e * 3.0 / 16.0; - } - buf[i + w] += e * 5.0 / 16.0; - if x + 1 < w { - buf[i + w + 1] += e / 16.0; - } - } + self.y = y0.saturating_add(gray.len().div_ceil(w)); + // Every method but diffusion is pointwise: rows convert in parallel. + let par = gray.len() >= 1 << 16; + match &mut self.kind { + StripKind::Threshold => crate::par::chunks_mut(gray, w, par, |_, row| { + row.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }); + }), + StripKind::Pattern => crate::par::chunks_mut(gray, w, par, |r, row| { + for (x, v) in row.iter_mut().enumerate() { + *v = if *v > bayer8(x, y0 + r) + 0.5 { 1.0 } else { 0.0 }; + } + }), + StripKind::Diffusion { errs } => { + // The previous strip's last row passes its error down to this strip's first. + let first = w.min(gray.len()); + pass_down(errs, gray.get_mut(..first).unwrap_or_default()); + let mut rest = gray; + while !rest.is_empty() { + let n = w.min(rest.len()); + let (row, tail) = std::mem::take(&mut rest).split_at_mut(n); + diffuse_row(row, errs); + let below = w.min(tail.len()); + pass_down(errs, tail.get_mut(..below).unwrap_or_default()); + rest = tail; } } - } - BitmapMethod::Halftone { cell, angle, shape } => { - let cell = cell.max(2.0); - let (s, c) = angle.to_radians().sin_cos(); - // Rank the spot function over the cell so coverage is proportional to the tone. - let mut table: Vec = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect(); - table.sort_by(f32::total_cmp); - let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32; - for (i, v) in gray.iter_mut().enumerate() { - let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5); - let (rx, ry) = (x * c + y * s, -x * s + y * c); - let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0; - let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0; - // Ink (black) covers where the spot function exceeds the gray level. - *v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 }; + StripKind::Halftone { cell, sin, cos, shape, table } => { + let (cell, s, c, shape, table) = (*cell, *sin, *cos, *shape, &*table); + let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32; + crate::par::chunks_mut(gray, w, par, |r, row| { + let y = (y0 + r) as f32 + 0.5; + for (x, v) in row.iter_mut().enumerate() { + let x = x as f32 + 0.5; + let (rx, ry) = (x * c + y * s, -x * s + y * c); + let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0; + let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0; + // Ink (black) covers where the spot function exceeds the gray level. + *v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 }; + } + }); } + StripKind::Custom { map, mw, mh } => { + let (map, mw, mh) = (&**map, *mw, *mh); + crate::par::chunks_mut(gray, w, par, |r, row| { + let base = (y0 + r) % mh * mw; + for (x, v) in row.iter_mut().enumerate() { + let t = map.get(base + x % mw).copied().unwrap_or(0.5); + *v = if *v >= t { 1.0 } else { 0.0 }; + } + }); + } + } + } +} + +/// One Floyd–Steinberg row, in place: each pixel takes its error from the left (7/16), and +/// `errs` gets the error it passes down to the next row ([`pass_down`]). +fn diffuse_row(row: &mut [f32], errs: &mut Vec) { + errs.clear(); + for x in 0..row.len() { + let old = row[x]; + let new = if old >= 0.5 { 1.0 } else { 0.0 }; + let e = old - new; + row[x] = new; + if let Some(next) = row.get_mut(x + 1) { + *next += e * 7.0 / 16.0; + } + errs.push(e); + } +} + +/// Adds the error `errs` of a row to the row below: 1/16 from the pixel up-left, 5/16 from the +/// one above, 3/16 from the one up-right, in that order (the order the row's pixels were +/// converted in), so the result is the same whether or not the rows are in the same strip. +fn pass_down(errs: &[f32], below: &mut [f32]) { + for (t, v) in below.iter_mut().enumerate() { + if let Some(e) = t.checked_sub(1).and_then(|l| errs.get(l)) { + *v += e / 16.0; + } + if let Some(e) = errs.get(t) { + *v += e * 5.0 / 16.0; + } + if let Some(e) = errs.get(t + 1) { + *v += e * 3.0 / 16.0; } } } @@ -719,15 +828,10 @@ pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec { out } -/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it -/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the -/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%. +/// Bitmap's Custom Pattern over a whole image (gray 0..=1, row-major `w` wide), see +/// [`BitmapStrips::custom`]. An image `w` of 0 thresholds at 50%. pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) { - let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len()); - for (i, v) in gray.iter_mut().enumerate() { - let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 }; - *v = if *v >= t { 1.0 } else { 0.0 }; - } + BitmapStrips::custom(w, map, mw, mh).convert(gray); } #[cfg(test)] diff --git a/crates/algo/src/quantize/tests.rs b/crates/algo/src/quantize/tests.rs index 14afa17f56..2bf4713917 100644 --- a/crates/algo/src/quantize/tests.rs +++ b/crates/algo/src/quantize/tests.rs @@ -440,3 +440,110 @@ fn custom_pattern_tiles_from_the_top_left() { to_bitmap_custom(&mut g, 0, &map, 2, 1); assert_eq!(g, [1.0]); } + +/// `gray` converted by `conv` in strips of `rows` rows each, cycling through `rows`. +fn in_strips(gray: &[f32], w: usize, rows: &[usize], mut conv: BitmapStrips<'_>) -> Vec { + let mut out = gray.to_vec(); + let mut rest = out.as_mut_slice(); + for r in rows.iter().cycle() { + if rest.is_empty() { + break; + } + let (strip, tail) = rest.split_at_mut((r * w).min(rest.len())); + conv.convert(strip); + rest = tail; + } + out +} + +#[test] +fn bitmap_strips_match_the_whole_image_however_it_is_cut() { + // #8477: strip boundaries must preserve diffusion state and keep patterns/screens anchored. + let (w, h) = (37, 29); + let gray: Vec = (0..w * h).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 / h as f32 * 0.3).fract()).collect(); + let map = custom_pattern_thresholds(&[0.1, 0.9, 0.4, 0.6, 0.2, 0.8]); + let methods = [ + BitmapMethod::Threshold, + BitmapMethod::Pattern, + BitmapMethod::Diffusion, + BitmapMethod::Halftone { cell: 5.5, angle: 30.0, shape: HalftoneShape::Ellipse }, + ]; + for rows in [&[1][..], &[3], &[5, 2, 7], &[h]] { + for method in methods { + let mut whole = gray.clone(); + to_bitmap(&mut whole, w, method); + assert!(whole.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}"); + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}"); + } + let mut whole = gray.clone(); + to_bitmap_custom(&mut whole, w, &map, 3, 2); + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::custom(w, &map, 3, 2)), whole, "custom in strips of {rows:?}"); + } +} + +#[test] +fn bitmap_strips_match_the_whole_image_with_a_ragged_last_row() { + // A short final row still receives the error from the row above, including in its own strip. + let w = 37; + let gray: Vec = (0..w * 5 + 11).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 * 0.13).fract()).collect(); + for method in [BitmapMethod::Diffusion, BitmapMethod::Threshold, BitmapMethod::Pattern] { + let mut whole = gray.clone(); + to_bitmap(&mut whole, w, method); + for rows in [&[1][..], &[5], &[2, 3], &[5, 1]] { + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}"); + } + } +} + +/// Floyd–Steinberg over the whole image as `to_bitmap` did before strips (#8477). +fn diffusion_reference(gray: &mut [f32], w: usize) { + let h = gray.len() / w; + let mut buf = gray.to_vec(); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let old = buf[i]; + let new = if old >= 0.5 { 1.0 } else { 0.0 }; + let error = old - new; + gray[i] = new; + if x + 1 < w { + buf[i + 1] += error * 7.0 / 16.0; + } + if y + 1 < h { + if x > 0 { + buf[i + w - 1] += error * 3.0 / 16.0; + } + buf[i + w] += error * 5.0 / 16.0; + if x + 1 < w { + buf[i + w + 1] += error / 16.0; + } + } + } + } +} + +#[test] +fn bitmap_diffusion_in_strips_is_bit_identical_to_one_pass() { + // Keep error accumulation order identical; one rounding change can flip many later pixels. + let (w, h) = (173, 61); + let gray: Vec = (0..w * h).map(|i| ((i % w) as f32 * 0.013 + (i / w) as f32 * 0.007 + ((i * 7919) % 101) as f32 * 0.001).fract()).collect(); + let mut expected = gray.clone(); + diffusion_reference(&mut expected, w); + for rows in [&[1][..], &[4, 9], &[h]] { + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, BitmapMethod::Diffusion)), expected, "strips of {rows:?}"); + } +} + +#[test] +fn bitmap_strips_handle_empty_and_ragged_input() { + // A zero width thresholds at 50%, and a short final row is converted too. + for method in [BitmapMethod::Pattern, BitmapMethod::Diffusion, BitmapMethod::Halftone { cell: 4.0, angle: 0.0, shape: HalftoneShape::Round }] { + let mut gray = vec![0.4, 0.6]; + to_bitmap(&mut gray, 0, method); + assert_eq!(gray, [0.0, 1.0], "{method:?}"); + let mut gray = vec![0.3; 7]; + to_bitmap(&mut gray, 3, method); + assert!(gray.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}: {gray:?}"); + to_bitmap(&mut [], 3, method); + } +} diff --git a/crates/algo/src/segment.rs b/crates/algo/src/segment.rs index f586453db4..958d3d0f6a 100644 --- a/crates/algo/src/segment.rs +++ b/crates/algo/src/segment.rs @@ -252,14 +252,28 @@ pub fn grid_cut_cancellable( weight: impl Fn(f32) -> f32, cancel: &dyn Fn() -> bool, ) -> Option> { - grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel) + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel, false) +} + +/// [`grid_cut_cancellable`] solved with Boykov–Kolmogorov instead of push/relabel: faster when +/// the data costs come from colour models (GrabCut and the band re-cut), where terminal links +/// are few and paths short. The minimum cut value is the same. +pub fn grid_cut_bk_cancellable( + img: &RgbImage, + cost_fg: &[f32], + cost_bg: &[f32], + fixed: &[u8], + weight: impl Fn(f32) -> f32, + cancel: &dyn Fn() -> bool, +) -> Option> { + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel, true) } mod edge_starts; // The const parameter also runs the previous full scan in before/after workflow tests. fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec { - grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default() + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, &|| false, false).unwrap_or_default() } fn grid_cut_impl( @@ -269,6 +283,7 @@ fn grid_cut_impl( fixed: &[u8], weight: impl Fn(f32) -> f32, cancel: &dyn Fn() -> bool, + bk: bool, ) -> Option> { let (w, h) = (img.w as i32, img.h as i32); let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) }; @@ -336,7 +351,9 @@ fn grid_cut_impl( if cancel() { return None; } - if OPTIMIZED { + if OPTIMIZED && bk { + g.maxflow_bk_with(cancel)?; + } else if OPTIMIZED { g.maxflow_with(cancel)?; } else { g.maxflow_bk(); @@ -733,7 +750,7 @@ fn refine_band( } let beta = contrast_beta(&img); // `grid_cut`'s weight, with the cut stopping on cancel. - let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; + let cut = grid_cut_bk_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; let (cw, ch) = (t.width() as usize, t.height() as usize); let mut core = Vec::with_capacity(cw * ch); for yy in 0..ch { diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 95ed668e68..249763ad49 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -10,7 +10,7 @@ use photocraft_geom::Rect; use photocraft_raster::{Cancelled, Interrupt}; use super::gmm::{DEFAULT_REG, Gmm}; -use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample}; +use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_bk_cancellable, subsample}; /// Definite background. pub const BG: u8 = 0; @@ -67,7 +67,7 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter }; let (cf, cb) = data_costs(img, &fg, &bg); // `grid_cut`'s weight, with the cut stopping on cancel. - let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; + let cut = grid_cut_bk_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; let mut changed = 0usize; for (t, fgnd) in trimap.iter_mut().zip(&cut) { if *t == PR_BG || *t == PR_FG { diff --git a/crates/algo/src/segment/maxflow.rs b/crates/algo/src/segment/maxflow.rs index dcde4ebdfb..6fef7f8faf 100644 --- a/crates/algo/src/segment/maxflow.rs +++ b/crates/algo/src/segment/maxflow.rs @@ -140,11 +140,22 @@ impl Graph { self.bk(cancel) } + /// [`Graph::maxflow_with`] that always runs Boykov–Kolmogorov. It is the faster solver on + /// the 8-connected colour-model grids of GrabCut (about 2.3x on a 107k-pixel working image); + /// push/relabel wins on Quick Selection's many area-cost terminals, which keep + /// [`Graph::maxflow_with`]. + pub fn maxflow_bk_with(&mut self, cancel: &dyn Fn() -> bool) -> Option { + self.bk(cancel) + } + pub(super) fn maxflow_bk(&mut self) -> f64 { self.bk(&never).unwrap_or(self.flow) } fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option { + if cancel() { + return None; + } let n = self.node_count(); let mut queue: VecDeque = VecDeque::new(); let mut orphans: VecDeque = VecDeque::new(); @@ -219,7 +230,7 @@ impl Graph { self.adopt(o as usize, time, &mut queue, &mut orphans); } } - Some(self.flow) + if cancel() { None } else { Some(self.flow) } } fn augment(&mut self, m: usize, orphans: &mut VecDeque) { @@ -408,3 +419,51 @@ impl Graph { s } } + +#[cfg(test)] +mod cancellation_tests { + use super::{CANCEL_EVERY, Graph}; + use std::cell::Cell; + + #[test] + fn bk_checks_cancellation_on_short_runs() { + let mut graph = Graph::with_capacity(2, 0); + graph.add_tweights(0, 1.0, 0.0); + graph.add_tweights(1, 0.0, 1.0); + assert!(graph.maxflow_bk_with(&|| true).is_none()); + + let calls = Cell::new(0); + assert!( + graph + .maxflow_bk_with(&|| { + calls.set(calls.get() + 1); + calls.get() == 2 + }) + .is_none() + ); + assert_eq!(calls.get(), 2, "short run must check before returning"); + } + + #[test] + fn bk_checks_cancellation_during_a_long_run() { + let mut graph = Graph::with_capacity(CANCEL_EVERY as usize + 1, 0); + for i in 0..graph.node_count() { + graph.add_tweights(i, 1.0, 0.0); + } + let calls = Cell::new(0); + assert!( + graph + .maxflow_bk_with(&|| { + calls.set(calls.get() + 1); + calls.get() == 2 + }) + .is_none() + ); + // The first check is at entry; more than CANCEL_EVERY active nodes ensure the + // second is the periodic poll, before the solver can finish. + assert_eq!(calls.get(), 2); + // Without the periodic poll the final check would be the second call. The poll stops the + // run with the last queued node unprocessed, still marked active. + assert!(graph.active[CANCEL_EVERY as usize], "periodic cancel must stop before the last queued node is processed"); + } +} diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index 8306b6e662..6ff0dfe398 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -265,7 +265,7 @@ fn cut_levels( let (w, h) = (img.w, img.h); let zeros = vec![0.0f32; w * h]; if w * h <= direct_px || w < 8 || h < 8 { - return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel); + return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel, false); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let coarse_img = img.downsample(2); @@ -310,7 +310,7 @@ fn cut_levels( } }) .collect(); - super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel) + super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel, false) } /// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel. diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 135c47b223..07f52b815b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -288,6 +288,65 @@ fn grabcut_trimap_on_disc() { assert!(iou > 0.95, "IoU {iou}"); } +#[test] +fn grabcut_small_disc_mask_is_pinned() { + // The mask GrabCut produced with the push/relabel solver, before the switch to BK. Min-cut + // ties may break differently between solvers, so any drift here is a visible change. + let (img, _) = disc_scene(32, 24, 16.0, 12.0, 8.0, 3); + let mut tri = vec![grabcut::BG; img.w * img.h]; + for y in 2..22 { + for x in 6..26 { + tri[y * img.w + x] = grabcut::PR_FG; + } + } + grabcut::grabcut(&img, &mut tri, 8).unwrap(); + let got: Vec = tri.chunks(img.w).map(|r| r.iter().map(|t| if *t == grabcut::PR_FG || *t == grabcut::FG { '#' } else { '.' }).collect()).collect(); + let want = [ + "................................", + "................................", + "................................", + "................................", + ".............######.............", + "...........##########...........", + "..........############..........", + ".........##############.........", + ".........##############.........", + "........################........", + "........################........", + "........################........", + "........################........", + "........################........", + "........################........", + ".........##############.........", + ".........##############.........", + "..........############..........", + "...........##########...........", + ".............######.............", + "................................", + "................................", + "................................", + "................................", + ]; + assert_eq!(got, want); +} + +#[test] +fn grabcut_large_disc_mask_is_pinned() { + // 5168 free nodes with terminal links both ways, so the pre-BK solver was push/relabel here. + // The count and FNV-1a hash are of the mask that push/relabel produced at the merge base. + let (img, _) = disc_scene(96, 80, 48.0, 40.0, 26.0, 3); + let mut tri = vec![grabcut::BG; img.w * img.h]; + for y in 6..74 { + for x in 10..86 { + tri[y * img.w + x] = grabcut::PR_FG; + } + } + grabcut::grabcut(&img, &mut tri, 8).unwrap(); + let fg: Vec = tri.iter().map(|t| *t == grabcut::PR_FG || *t == grabcut::FG).collect(); + let hash = fg.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)); + assert_eq!((fg.iter().filter(|b| **b).count(), hash), (2128, 0x62fd_c64a_0ee4_4e75)); +} + #[test] fn object_select_disc_full_resolution() { let (img, truth) = disc_scene(200, 160, 100.0, 80.0, 50.0, 2); @@ -1068,3 +1127,87 @@ fn hole_fill_without_specks_fills_object_coloured_holes_only() { assert!(out[25 * w + 25], "a speck of the object's colour fills"); assert!(!out[10 * w + 10], "a speck of the background's colour stays open"); } + +/// Energy of a labelling: data costs plus the contrast weights of separated 8-neighbours. +fn grid_energy(img: &RgbImage, cf: &[f32], cb: &[f32], cut: &[bool], weight: impl Fn(f32) -> f32) -> f64 { + let (w, h) = (img.w as i32, img.h as i32); + let mut e = 0.0f64; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) as usize; + e += f64::from(if cut[i] { cf[i] } else { cb[i] }); + for (dx, dy, len) in NB { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && nx < w && ny < h { + let j = (ny * w + nx) as usize; + if cut[i] != cut[j] { + e += f64::from(weight(d2(img.px[i], img.px[j])) / len); + } + } + } + } + } + e +} + +#[test] +fn bk_and_default_grid_cut_tie_reaches_the_same_energy() { + // Hard background down the left edge, hard foreground down the right, uniform pixels and equal + // data costs: a vertical cut at any column separates the borders at the same energy, so only + // the solvers' tie-breaking decides the labelling. + let (w, h) = (120usize, 100usize); + let img = RgbImage::from_fn(w, h, |_, _| [0.5, 0.5, 0.5]); + let (cf, cb) = (vec![1.0f32; w * h], vec![1.0f32; w * h]); + let fixed: Vec = (0..w * h) + .map(|i| match i % w { + 0 => HARD_BG, + x if x == w - 1 => HARD_FG, + _ => FREE, + }) + .collect(); + let weight = |_: f32| 2.0; + // Two distinct candidate labellings (vertical cuts at different columns) tie at the minimum. + let split = |col: usize| -> Vec { (0..w * h).map(|i| i % w >= col).collect() }; + let (e30, e90) = (grid_energy(&img, &cf, &cb, &split(30), weight), grid_energy(&img, &cf, &cb, &split(90), weight)); + assert!((e30 - e90).abs() <= 1e-6 * e30.abs().max(1.0), "candidates must tie: {e30} vs {e90}"); + assert_ne!(split(30), split(90)); + let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut"); + let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut"); + let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight)); + assert!((ea - e30).abs() <= 1e-3 * e30.abs().max(1.0), "default {ea} is not the tied minimum {e30}"); + assert!((eb - e30).abs() <= 1e-3 * e30.abs().max(1.0), "bk {eb} is not the tied minimum {e30}"); + // The solvers may pick different minimum cuts here; only the energy is guaranteed. + assert!(a.iter().any(|&l| l) && b.iter().any(|&l| l), "both label the foreground border"); +} + +#[test] +fn bk_and_default_grid_cut_reach_the_same_minimum_energy() { + // Large enough (>= 4096 free nodes, both terminals) that the default solver is push/relabel. + let (w, h) = (120usize, 100usize); + let mut rng = Rng::new(7); + let img = RgbImage::from_fn(w, h, |x, y| { + let inside = (x as f32 - 60.0).powi(2) + (y as f32 - 50.0).powi(2) < 1600.0; + let base = if inside { [0.8, 0.3, 0.2] } else { [0.2, 0.4, 0.7] }; + base.map(|v| v + 0.08 * rng.normal()) + }); + let cf: Vec = img.px.iter().map(|p| 4.0 * d2(*p, [0.8, 0.3, 0.2])).collect(); + let cb: Vec = img.px.iter().map(|p| 4.0 * d2(*p, [0.2, 0.4, 0.7])).collect(); + let fixed: Vec = (0..w * h) + .map(|i| { + if i % w == 0 { + HARD_BG + } else if i == (50 * w + 60) { + HARD_FG + } else { + FREE + } + }) + .collect(); + let weight = |d: f32| 50.0 * (-8.0 * d).exp(); + let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut"); + let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut"); + let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight)); + assert!((ea - eb).abs() <= 1e-3 * ea.abs().max(1.0), "default {ea} vs bk {eb}"); + assert_eq!(a, b, "same labelling, not just the same energy"); + assert!(grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| true).is_none(), "BK honours cancel"); +} diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index d6e1837dd1..a86fc52966 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -134,18 +134,30 @@ pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface { surface_from_masks(area, m, None, |_, v| v) } +mod combine_mask; +pub use combine_mask::combine_band; + /// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected. pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option { if area.is_empty() { return None; } + // Replace already writes straight from the input mask to tiles; it needs no old mask. + if mode == SelectionMode::Replace || (old.is_none() && mode == SelectionMode::Add) { + let s = mask_to_surface(new, area); + return (s.tile_count() > 0).then_some(s); + } + let old = old?; + if old.format() == PixelFormat::GRAY8 { + return combine_mask::gray8(Some(old), new, area, mode, false); + } // Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is // exact. Below half an 8-bit step is nothing. let q = |v: f32| f32::from(coverage8(v)) / 255.0; let s = match mode { SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v), _ => { - let prev = mask_from_surface(old, area); + let prev = mask_from_surface(Some(old), area); match mode { SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))), SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)), @@ -381,6 +393,9 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() }; let new = new.unwrap_or(&empty); let b = new.bbox; + if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) { + return combine_mask::gray8_bytes(old, &new.mask, b, mode); + } let prev_u8 = |s: &Surface| -> Vec { let mut v = Vec::new(); s.read_region_into(b, &mut v); @@ -761,11 +776,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { } mod distance; +mod inverse; mod lasso; mod marquee; mod morphology; pub use distance::edt; +pub use inverse::inverse_surface; pub(crate) use lasso::clip_segment; pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface}; pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface}; diff --git a/crates/algo/src/selection/combine_mask.rs b/crates/algo/src/selection/combine_mask.rs new file mode 100644 index 0000000000..bbe4c65738 --- /dev/null +++ b/crates/algo/src/selection/combine_mask.rs @@ -0,0 +1,112 @@ +//! Float mask inputs meet stored GRAY8 selections at the tile boundary. No dense copy of +//! the previous selection or of the combined result is needed. + +use super::{SelectionMode, combine, coverage8}; +use crate::photo_util::par_map; +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use std::sync::Arc; + +/// Combines a mask covering only `band` (zero elsewhere). Add and Subtract retain the old +/// selection outside it, including pixels sharing a tile with the band's edges. Replace +/// and Intersect return only the selection inside the band. Missing mask samples read zero. +pub fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, mode: SelectionMode) -> Option { + let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract); + if !keep || old.is_none() { + return combine(old, mask, band, mode); + } + if band.is_empty() { + return old.cloned(); + } + if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) { + return gray8(old, mask, band, mode, true); + } + // Preserve the format and conversion behavior of unusual selection surfaces. The normal + // GRAY8 path above never materializes these band-sized buffers. + let inside = combine(old, mask, band, mode); + let mut out = old.cloned()?; + let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |s| s.read_region(band)); + if out.channels() == 1 { + out.write_region(band, &values); + } else { + // A selection uses its first channel as coverage. Broadcast it when writing back to + // an unusual multichannel surface, rather than passing a short buffer to write_region. + let pixels: Vec = values.iter().flat_map(|v| std::iter::repeat_n(*v, out.channels())).collect(); + out.write_region(band, &pixels); + } + out.prune(); + (out.tile_count() > 0).then_some(out) +} + +pub(super) fn gray8(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode, keep_outside: bool) -> Option { + build(old, area, mode, keep_outside, |i| coverage8(mask.get(i).copied().unwrap_or(0.0))) +} + +pub(super) fn gray8_bytes(old: Option<&Surface>, mask: &[u8], area: Rect, mode: SelectionMode) -> Option { + let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract); + if area.is_empty() { + return if keep { old.cloned().filter(|s| !s.content_bounds().is_empty()) } else { None }; + } + build(old, area, mode, keep, |i| mask.get(i).copied().unwrap_or(0)) +} + +fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: bool, sample: impl Fn(usize) -> u8 + Sync) -> Option { + let mut out = if keep_outside { old.cloned().unwrap_or_else(|| Surface::new(PixelFormat::GRAY8)) } else { Surface::new(PixelFormat::GRAY8) }; + let default = old.and_then(|s| s.default_bytes().first()).copied().unwrap_or(0); + let out_default = out.default_bytes().first().copied().unwrap_or(0); + let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0])); + let untouched = out.solid_tile(&[f32::from(out_default) / 255.0]); + let coords: Vec = area.tiles().collect(); + let width = area.width() as usize; + let ts = TILE_SIZE as usize; + let built = par_map(coords.len(), |k| { + let tc = *coords.get(k)?; + let rect = tc.rect(); + let cut = rect.intersect(&area); + let previous = old.and_then(|s| s.tile(tc)); + let mut tile = Tile::clone(if keep_outside { previous.unwrap_or(&untouched) } else { &blank }); + for y in cut.y0..cut.y1 { + let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize; + let len = cut.width() as usize; + let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue }; + let src = previous.and_then(|t| t.bytes().get(offset..offset + len)); + let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize)); + for (i, d) in dst.iter_mut().enumerate() { + let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0); + let prev = src.and_then(|s| s.get(i)).copied().unwrap_or(default); + *d = match mode { + SelectionMode::Replace => new, + SelectionMode::Add => prev.max(new), + SelectionMode::Subtract => prev.saturating_sub(new), + SelectionMode::Intersect => prev.min(new), + }; + } + } + let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b))); + let tile = if lo == out_default && hi == out_default { + None + } else if previous.is_some_and(|p| p.bytes() == tile.bytes()) { + previous.cloned() + } else if lo == u8::MAX { + Some(Arc::clone(&full)) + } else { + Some(Arc::new(tile)) + }; + Some((tc, tile)) + }); + for (tc, tile) in built.into_iter().flatten() { + match tile { + Some(tile) => out.put_tiles([(tc, tile)]), + None if keep_outside && out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())), + None => {} + } + } + if keep_outside { + out.prune(); + } + (out.tile_count() > 0).then_some(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/selection/combine_mask/tests.rs b/crates/algo/src/selection/combine_mask/tests.rs new file mode 100644 index 0000000000..5c55d9d90a --- /dev/null +++ b/crates/algo/src/selection/combine_mask/tests.rs @@ -0,0 +1,179 @@ +use super::*; +use crate::selection::{Region, combine_region, mask_from_surface, surface_from_masks}; +use photocraft_color::{ColorMode, SampleType}; + +const MODES: [SelectionMode; 4] = [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect]; + +// The pre-refactor float formulas, including quantization before the binary operation. +fn reference(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option { + if area.is_empty() { + return None; + } + if mode == SelectionMode::Replace { + let s = surface_from_masks(area, mask, None, |_, v| v); + return (s.tile_count() > 0).then_some(s); + } + let prev = mask_from_surface(old, area); + let s = surface_from_masks(area, mask, Some(&prev), |p, v| { + let q = f32::from(coverage8(v)) / 255.0; + match mode { + SelectionMode::Replace => v, + SelectionMode::Add => p.max(q), + SelectionMode::Subtract => (p - q).max(0.0), + SelectionMode::Intersect => p.min(q), + } + }); + (s.tile_count() > 0).then_some(s) +} + +fn reference_band(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option { + let inside = reference(old, mask, area, mode); + let Some(old) = old.filter(|_| matches!(mode, SelectionMode::Add | SelectionMode::Subtract)) else { return inside }; + if area.is_empty() { + return Some(old.clone()); + } + let values = inside.map_or_else(|| vec![0.0; area.width() as usize * area.height() as usize], |s| s.read_region(area)); + let mut out = old.clone(); + out.write_region(area, &values); + out.prune(); + (out.tile_count() > 0).then_some(out) +} + +#[test] +fn every_byte_pair_matches_float_combining() { + let area = Rect::new(0, 0, 256, 256); + let prev: Vec = (0..65536).map(|i| (i / 256) as f32 / 255.0).collect(); + let mask: Vec = (0..65536).map(|i| (i % 256) as f32 / 255.0).collect(); + let old = crate::selection::mask_to_surface(&prev, area); + for mode in MODES { + assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode), "{mode:?}"); + } +} + +#[test] +fn cropped_bands_match_dense_writeback_with_defaults_and_short_masks() { + let values = [f32::NAN, f32::NEG_INFINITY, -0.2, 0.0, 0.49 / 255.0, 0.5 / 255.0, 0.51 / 255.0, 0.5, 1.0, 1.2, f32::INFINITY]; + for area in [Rect::new(-259, -2, 259, 3), Rect::new(255, 255, 258, 259), Rect::new(0, 0, 512, 256), Rect::EMPTY] { + let n = area.width() as usize * area.height() as usize; + let mask: Vec = (0..n).map(|i| values[i % values.len()]).collect(); + for default in [0.0, 0.5, 1.0] { + let mut old = Surface::with_default(PixelFormat::GRAY8, &[default]); + old.fill_rect(Rect::new(-300, -20, 300, 270), &[0.75]); + old.write_pixel(1024, 1024, &[1.0]); + old.write_pixel(-257, 0, &[0.0]); + let snapshot = old.clone(); + for prev in [None, Some(&old)] { + for len in [0, n / 2, n] { + for mode in MODES { + let mask = &mask[..len]; + assert_eq!(combine(prev, mask, area, mode), reference(prev, mask, area, mode), "{area:?} {mode:?} {default} {len}"); + assert_eq!(combine_band(prev, mask, area, mode), reference_band(prev, mask, area, mode), "band {area:?} {mode:?} {default} {len}"); + } + } + } + assert_eq!(old, snapshot, "combining must leave undo snapshots unchanged"); + } + } +} + +#[test] +fn unusual_selection_formats_keep_their_float_behavior() { + let area = Rect::new(-2, -1, 3, 2); + for sample in SampleType::ALL { + for color in [ColorMode::Grayscale, ColorMode::Rgb] { + let mut old = Surface::with_default(PixelFormat::new(color, sample, false), &vec![0.123; if color == ColorMode::Rgb { 3 } else { 1 }]); + old.write_pixel(0, 0, &vec![0.754; old.channels()]); + for mode in MODES { + let mask = [0.5; 15]; + assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode)); + if color == ColorMode::Grayscale { + assert_eq!(combine_band(Some(&old), &mask, area, mode), reference_band(Some(&old), &mask, area, mode)); + } else { + let out = combine_band(Some(&old), &mask, area, mode).unwrap(); + let inside = reference(Some(&old), &mask, area, mode).unwrap(); + let expected = inside.pixel(0, 0)[0]; + let encoded = Surface::with_default(out.format(), &vec![expected; out.channels()]); + assert_eq!(out.pixel(0, 0), encoded.pixel(0, 0)); + if matches!(mode, SelectionMode::Add | SelectionMode::Subtract) { + assert_eq!(out.pixel(20, 20), old.pixel(20, 20)); + } + } + } + } + } +} + +#[test] +fn region_bytes_match_float_bands() { + let bbox = Rect::new(-257, 255, 258, 258); + let mask: Vec = (0..bbox.width() * bbox.height()).map(|i| (i % 256) as u8).collect(); + let floats: Vec = mask.iter().map(|b| f32::from(*b) / 255.0).collect(); + let region = Region { bbox, mask }; + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(Rect::new(-300, 250, 300, 270), &[0.75]); + for prev in [None, Some(&old)] { + for mode in MODES { + assert_eq!(combine_region(prev, Some(®ion), mode), reference_band(prev, &floats, bbox, mode)); + assert_eq!(combine_region(prev, None, mode), reference_band(prev, &[], Rect::EMPTY, mode)); + } + } +} + +#[test] +fn tiles_stay_shared_or_are_pruned() { + let area = Rect::new(0, 0, 768, 256); + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(area, &[1.0]); + let tc = TileCoord::new(0, 0); + let unchanged = combine(Some(&old), &[], area, SelectionMode::Add).unwrap(); + assert!(Arc::ptr_eq(unchanged.tile(tc).unwrap(), old.tile(tc).unwrap())); + let band = Rect::new(257, 1, 260, 3); + let changed = combine_band(Some(&old), &[1.0; 6], band, SelectionMode::Subtract).unwrap(); + assert!(Arc::ptr_eq(changed.tile(tc).unwrap(), old.tile(tc).unwrap())); + assert_eq!(changed.pixel(256, 1), vec![1.0]); + assert_eq!(changed.pixel(257, 1), vec![0.0]); + assert_eq!(changed.pixel(260, 1), vec![1.0]); + let full = combine(None, &vec![1.0; 768 * 256], area, SelectionMode::Replace).unwrap(); + assert!(Arc::ptr_eq(full.tile(tc).unwrap(), full.tile(TileCoord::new(1, 0)).unwrap())); + assert!(combine_band(Some(&old), &vec![1.0; 768 * 256], area, SelectionMode::Subtract).is_none()); + assert!(combine(None, &[], area, SelectionMode::Replace).is_none()); +} + +/// Run only in release on CI: compares the former implementation, including dense reads and +/// band writeback, against the byte path with identical 36 MP inputs and exact output checks. +#[test] +#[ignore = "36 MP release performance comparison; run on GitHub Actions"] +fn gray8_combining_release_comparison() { + use std::{hint::black_box, time::Instant}; + let area = Rect::new(0, 0, 6000, 6000); + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(Rect::new(100, 100, 5900, 5900), &[0.75]); + println!("36 MP; median of three release runs including allocation; exact output checks; previous dense mask alone = 144000000 bytes"); + for (case, size) in [("sparse", 50), ("dense", 4000), ("full", 6000)] { + let mut mask = vec![0.0; 6000 * 6000]; + let start = (6000 - size) / 2; + for row in mask.as_chunks_mut::<6000>().0.iter_mut().skip(start).take(size) { + row[start..start + size].fill(0.5); + } + for mode in MODES { + for band in [false, true] { + let baseline = if band { reference_band } else { reference }; + let direct = if band { combine_band } else { combine }; + assert_eq!(direct(Some(&old), &mask, area, mode), baseline(Some(&old), &mask, area, mode)); + let mut medians = [0.0; 2]; + for (median, op) in medians.iter_mut().zip([baseline, direct]) { + let mut runs = [0.0; 3]; + for ms in &mut runs { + let start = Instant::now(); + let out = black_box(op(Some(black_box(&old)), black_box(&mask), area, mode)); + *ms = start.elapsed().as_secs_f64() * 1000.0; + drop(out); + } + runs.sort_by(f64::total_cmp); + *median = runs[1]; + } + println!("{case} {mode:?} band={band}: old={:.2}ms new={:.2}ms speedup={:.2}x exact=true", medians[0], medians[1], medians[0] / medians[1]); + } + } + } +} diff --git a/crates/algo/src/selection/inverse.rs b/crates/algo/src/selection/inverse.rs new file mode 100644 index 0000000000..e92cc918d2 --- /dev/null +++ b/crates/algo/src/selection/inverse.rs @@ -0,0 +1,104 @@ +//! Tile-wise selection inversion, with shared solid tiles and no canvas-sized buffers. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use photocraft_color::{PixelFormat, read_sample}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; + +use crate::photo_util::par_map; + +use super::coverage8; + +/// Invert coverage within `area`, returning a zero-default GRAY8 selection with nothing +/// outside it. Missing tiles become shared solid tiles; selected solid tiles disappear. +/// Each distinct interior source tile is inverted only once, preserving sharing and snapshots. +/// Selections are GRAY8; other formats use their first channel, quantized to selection coverage. +pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { + let mut out = Surface::new(PixelFormat::GRAY8); + if area.is_empty() { + return out; + } + let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0])); + let default = coverage8(1.0 - source.default_pixel().first().copied().unwrap_or(0.0)); + let missing = match default { + 0 => None, + 255 => Some(Arc::clone(&full)), + v => Some(out.solid_tile(&[f32::from(v) / 255.0])), + }; + // Solid fills share one payload across many coordinates. Cache by payload, rather than + // scanning/copying the same 64 KiB for every tile. OnceLock allows parallel tile workers. + let cache: HashMap<_, OnceLock>>> = + source.tiles().filter(|(c, _)| area.contains_rect(&c.rect())).map(|(_, t)| (Arc::as_ptr(t) as usize, OnceLock::new())).collect(); + let format = source.format(); + let value = |t: &Tile, i: usize| { + if format == PixelFormat::GRAY8 { + t.bytes().get(i).map_or(default, |b| 255 - b) + } else { + let bpp = format.bytes_per_pixel(); + t.bytes().get(i * bpp..(i + 1) * bpp).map_or(default, |pixel| coverage8(1.0 - read_sample(pixel, format.sample, 0))) + } + }; + let coords: Vec = area.tiles().collect(); + let built = par_map(coords.len(), |k| { + let tc = *coords.get(k)?; + let rect = tc.rect(); + let cut = rect.intersect(&area); + let input = source.tile(tc); + // At i32::MAX the coordinate rectangle is saturated and omits the tile's last + // column/row, so it must still take the clipped path. + if cut == rect && cut.width() == TILE_SIZE as u32 && cut.height() == TILE_SIZE as u32 { + let Some(input) = input else { return missing.clone().map(|t| (tc, t)) }; + let cell = cache.get(&(Arc::as_ptr(input) as usize))?; + let tile = cell.get_or_init(|| { + if format == PixelFormat::GRAY8 { + if input.bytes() == full.bytes() { + return None; + } + if input.bytes() == blank.bytes() { + return Some(Arc::clone(&full)); + } + let mut tile = Tile::clone(input); + for b in tile.bytes_mut() { + *b = 255 - *b; + } + return Some(Arc::new(tile)); + } + let mut tile = Tile::clone(&blank); + for (i, b) in tile.bytes_mut().iter_mut().enumerate() { + *b = value(input, i); + } + canonical(tile, &blank, &full) + }); + return tile.clone().map(|t| (tc, t)); + } + // Boundary tiles start at zero so no inverted coverage leaks past the canvas. + let mut tile = Tile::clone(&blank); + let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize); + for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize { + let start = y * TILE_SIZE as usize + x0; + if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) { + for (i, b) in (start..).zip(row) { + *b = input.map_or(default, |t| value(t, i)); + } + } + } + canonical(tile, &blank, &full).map(|t| (tc, t)) + }); + out.put_tiles(built.into_iter().flatten()); + out +} + +fn canonical(tile: Tile, blank: &Arc, full: &Arc) -> Option> { + if tile.bytes() == blank.bytes() { + None + } else if tile.bytes() == full.bytes() { + Some(Arc::clone(full)) + } else { + Some(Arc::new(tile)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/selection/inverse/tests.rs b/crates/algo/src/selection/inverse/tests.rs new file mode 100644 index 0000000000..2a7d94ab07 --- /dev/null +++ b/crates/algo/src/selection/inverse/tests.rs @@ -0,0 +1,85 @@ +use super::*; +use photocraft_color::{ColorMode, SampleType}; + +fn reference(source: &Surface, area: Rect) -> Surface { + let data: Vec = source.read_region(area).chunks_exact(source.channels()).map(|p| 1.0 - p[0]).collect(); + let mut out = Surface::new(PixelFormat::GRAY8); + out.write_region(area, &data); + out.prune(); + out +} + +#[test] +fn inverse_matches_float_reference_and_clips_boundary_tiles() { + // Negative, misaligned and empty bounds, including stored pixels beyond the canvas. + for area in [Rect::new(-17, -11, 530, 270), Rect::new(0, 0, 512, 256), Rect::new(3, 5, 4, 6), Rect::EMPTY] { + for sample in SampleType::ALL { + for default in [0.0, 0.25, 1.0] { + let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); + let mut source = Surface::with_default(fmt, &[default]); + source.fill_rect(Rect::new(-100, -100, 260, 120), &[1.0]); + // Every encoded GRAY8 value, including feathered coverage at tile seams. + let values: Vec = (0..256).map(|v| v as f32 / 255.0).collect(); + source.write_region(Rect::new(240, 125, 496, 126), &values); + source.write_pixel(750, 280, &[0.7]); + let got = inverse_surface(&source, area); + let want = reference(&source, area); + let window = Rect::new(-30, -20, 800, 290); + assert_eq!(got.to_interleaved(window), want.to_interleaved(window), "{sample:?} {default} {area:?}"); + assert_eq!(got.default_bytes(), &[0]); + assert_eq!(got.tile_count(), want.tile_count(), "zero tiles must be pruned"); + } + } + } +} + +#[test] +fn inverse_preserves_sparse_storage_and_shared_payloads() { + let area = Rect::new(0, 0, 2048, 1024); + let empty = Surface::new(PixelFormat::GRAY8); + let selected = inverse_surface(&empty, area); + assert_eq!(selected.tile_count(), 32); + let full = selected.tile(TileCoord::new(0, 0)).unwrap(); + assert!(selected.tiles().all(|(_, t)| Arc::ptr_eq(t, full)), "missing tiles share one solid payload"); + let cleared = inverse_surface(&selected, area); + assert_eq!(cleared.tile_count(), 0, "fully selected tiles disappear"); + + let mut mixed = Surface::new(PixelFormat::GRAY8); + mixed.write_pixel(10, 11, &[0.25]); + let payload = mixed.tile(TileCoord::new(0, 0)).unwrap().clone(); + mixed.put_tiles(area.tiles().map(|c| (c, payload.clone()))); + let snapshot = mixed.clone(); + let inverted = inverse_surface(&mixed, area); + let result = inverted.tile(TileCoord::new(0, 0)).unwrap(); + assert!(inverted.tiles().all(|(_, t)| Arc::ptr_eq(t, result)), "shared mixed tiles are inverted once"); + assert!(!Arc::ptr_eq(result, &payload)); + assert_eq!(mixed.to_interleaved(area), snapshot.to_interleaved(area)); + assert_eq!(inverse_surface(&inverted, area).to_interleaved(area), mixed.to_interleaved(area)); +} + +#[test] +fn inverse_of_unpruned_zero_tiles_is_shared_full() { + let mut source = Surface::new(PixelFormat::GRAY8); + let area = Rect::new(0, 0, 768, 256); + source.fill_rect(Rect::new(0, 0, 256, 256), &[0.0]); + source.fill_rect(Rect::new(256, 0, 512, 256), &[1.0]); + let out = inverse_surface(&source, area); + assert_eq!(out.tile_count(), 2); + assert!(out.tile(TileCoord::new(1, 0)).is_none()); + assert!(Arc::ptr_eq(out.tile(TileCoord::new(0, 0)).unwrap(), out.tile(TileCoord::new(2, 0)).unwrap())); +} + +#[test] +fn inverse_clips_at_coordinate_limits_and_uses_first_channel() { + for area in [Rect::new(i32::MAX - 255, 0, i32::MAX, 256), Rect::new(0, i32::MAX - 255, 256, i32::MAX), Rect::new(i32::MIN, -1, i32::MIN + 3, 2)] { + let source = Surface::new(PixelFormat::GRAY8); + let out = inverse_surface(&source, area); + assert_eq!(out.to_interleaved(area), reference(&source, area).to_interleaved(area)); + assert_eq!(out.sample_channel(area.x1, area.y0, 0), 0.0); + assert_eq!(out.sample_channel(area.x0, area.y1, 0), 0.0); + } + let area = Rect::new(0, 0, 513, 260); + let mut source = Surface::with_default(PixelFormat::RGBA32F, &[0.25, 0.5, 0.75, 1.0]); + source.fill_rect(Rect::new(10, 10, 400, 200), &[0.75, 1.0, 0.0, 0.5]); + assert_eq!(inverse_surface(&source, area).to_interleaved(area), reference(&source, area).to_interleaved(area)); +} diff --git a/crates/algo/src/wideangle.rs b/crates/algo/src/wideangle.rs index ba3fafcc71..19da93fc24 100644 --- a/crates/algo/src/wideangle.rs +++ b/crates/algo/src/wideangle.rs @@ -22,7 +22,7 @@ use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; -use crate::photo_util::SparseLs; +use crate::photo_util::{BandChol, Csr, par_rows, pcg, solve_dense}; use crate::transform::Interp; /// Lens projection of the source image. @@ -234,95 +234,293 @@ impl WideMesh { } } -/// Solves the content-preserving warp for `p` over `frame`. -pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { - let cam = Camera::new(p, frame); - let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64); - let cells = 48.0; - let cell = fw.max(fh) / cells; - let nx = ((fw / cell).round() as usize).clamp(2, 96); - let ny = ((fh / cell).round() as usize).clamp(2, 96); - let (cw, ch) = (fw / nx as f64, fh / ny as f64); - let w1 = nx + 1; - let nv = w1 * (ny + 1); - let src: Vec<[f64; 2]> = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect(); - let curves: Vec> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect(); - let mut x: Vec = src.iter().flat_map(|s| [s[0], s[1]]).collect(); - // Bilinear weights of a source point in the grid. - let weights = |q: [f64; 2]| -> [(usize, f64); 4] { - let gx = ((q[0] - frame.x0 as f64) / cw).clamp(0.0, nx as f64 - 1e-9); - let gy = ((q[1] - frame.y0 as f64) / ch).clamp(0.0, ny as f64 - 1e-9); +/// Weight of the line rows (applied squared in the normal equations). +const LINE_WEIGHT: f64 = 10.0; +/// Weight of the weak anchoring to the source (it fixes the global similarity). +const ANCHOR_WEIGHT: f64 = 0.02; +/// Grid cells along the longer side of the frame. +const GRID_CELLS: f64 = 48.0; + +/// A sparse row `Σ coef·x[idx]`. +type Row = Vec<(usize, f64)>; +/// A solver for `(K + LᵀL) x = rhs` given the grid, the line rows `L` and a warm start. +type LinSolve<'a> = dyn FnMut(&Grid, &[Row], &mut Vec) + 'a; + +/// The warp's grid over a frame; the unknowns are the output positions of its vertices, +/// interleaved `[x0, y0, x1, y1, …]`. +struct Grid { + frame: Rect, + nx: usize, + ny: usize, + cw: f64, + ch: f64, + w1: usize, + nv: usize, + /// Source position of each vertex. + src: Vec<[f64; 2]>, +} + +impl Grid { + fn new(frame: Rect, cells: f64) -> Grid { + let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64); + let cell = fw.max(fh) / cells; + let nx = ((fw / cell).round() as usize).clamp(2, 96); + let ny = ((fh / cell).round() as usize).clamp(2, 96); + let (cw, ch) = (fw / nx as f64, fh / ny as f64); + let w1 = nx + 1; + let nv = w1 * (ny + 1); + let src = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect(); + Grid { frame, nx, ny, cw, ch, w1, nv, src } + } + + /// Bilinear weights of a source point in the grid. + fn weights(&self, q: [f64; 2]) -> [(usize, f64); 4] { + let gx = ((q[0] - self.frame.x0 as f64) / self.cw).clamp(0.0, self.nx as f64 - 1e-9); + let gy = ((q[1] - self.frame.y0 as f64) / self.ch).clamp(0.0, self.ny as f64 - 1e-9); let (i, j) = (gx.floor() as usize, gy.floor() as usize); let (tx, ty) = (gx - i as f64, gy - j as f64); - let a = j * w1 + i; - [(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + w1, (1.0 - tx) * ty), (a + w1 + 1, tx * ty)] - }; - let tris = { - let mut t = Vec::new(); - for j in 0..ny { - for i in 0..nx { - let a = j * w1 + i; - t.push([a, a + 1, a + w1 + 1]); - t.push([a, a + w1 + 1, a + w1]); - // Both diagonals so the shape term is symmetric. - t.push([a, a + 1, a + w1]); - t.push([a + 1, a + w1 + 1, a + w1]); + let a = j * self.w1 + i; + [(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + self.w1, (1.0 - tx) * ty), (a + self.w1 + 1, tx * ty)] + } + + /// The anchoring right-hand side `ANCHOR_WEIGHT² · src`, interleaved. + fn anchor_rhs(&self) -> Vec { + self.src.iter().flat_map(|s| [ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[0], ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[1]]).collect() + } +} + +/// The two shape rows (x then y) that pin vertex `i0` to `v1 + u (v2 − v1) + v R90 (v2 − v1)`, +/// with `(u, v)` taken from the source positions. Vertex ids are whatever the caller numbers its +/// unknowns by; the rows' right-hand sides are zero. +fn shape_rows(i: [usize; 3], p: [[f64; 2]; 3]) -> [[(usize, f64); 5]; 2] { + let (e, d) = ([p[2][0] - p[1][0], p[2][1] - p[1][1]], [p[0][0] - p[1][0], p[0][1] - p[1][1]]); + let l2 = e[0] * e[0] + e[1] * e[1]; + let u = (d[0] * e[0] + d[1] * e[1]) / l2; + let v = (d[0] * -e[1] + d[1] * e[0]) / l2; + let (i0, i1, i2) = (i[0], i[1], i[2]); + [ + // x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0 + [(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)], + // y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0 + [(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)], + ] +} + +/// The four triangles of a cell over its corners `[a, a + 1, a + w1, a + w1 + 1]` (as 0..4); +/// both diagonals are used so the shape term is symmetric. +const CELL_TRIS: [[usize; 3]; 4] = [[0, 1, 3], [0, 3, 2], [0, 1, 2], [1, 3, 2]]; + +/// Every shape row of the grid (with global vertex ids), for reference solves. +#[cfg(test)] +fn all_shape_rows(g: &Grid) -> Vec { + let mut out = Vec::new(); + for j in 0..g.ny { + for i in 0..g.nx { + let a = j * g.w1 + i; + let corner = [a, a + 1, a + g.w1, a + g.w1 + 1]; + for t in CELL_TRIS { + for rot in 0..3 { + let ids = [corner[t[rot]], corner[t[(rot + 1) % 3]], corner[t[(rot + 2) % 3]]]; + for row in shape_rows(ids, ids.map(|k| g.src[k])) { + out.push(row.to_vec()); + } + } } } - t - }; + } + out +} + +/// The line rows (already multiplied by `LINE_WEIGHT`): every sample of a constraint's curve is +/// tied to the curve's first point along the constraint's normal. +fn line_rows(g: &Grid, curves: &[Vec<[f64; 2]>], active: &[usize], normals: &[[f64; 2]], p: &WideAngle) -> Vec { + let mut rows = Vec::new(); + for (ci, &k) in active.iter().enumerate() { + let Some(c) = curves.get(k) else { continue }; + let n = match p.constraints.get(k).map(|c| c.orientation) { + Some(Orientation::Horizontal) => [0.0, 1.0], + Some(Orientation::Vertical) => [1.0, 0.0], + _ => normals.get(ci).copied().unwrap_or([0.0, 1.0]), + }; + let Some(first) = c.first() else { continue }; + let w0 = g.weights(*first); + for q in c.iter().skip(1) { + let mut row: Row = Vec::with_capacity(16); + for &(i, w) in &g.weights(*q) { + row.push((2 * i, LINE_WEIGHT * n[0] * w)); + row.push((2 * i + 1, LINE_WEIGHT * n[1] * w)); + } + for &(i, w) in &w0 { + row.push((2 * i, -LINE_WEIGHT * n[0] * w)); + row.push((2 * i + 1, -LINE_WEIGHT * n[1] * w)); + } + rows.push(row); + } + } + rows +} + +/// Above this many line rows the low-rank update's dense `rows × rows` system gets too big and +/// the solve falls back to conjugate gradients. +const MAX_UPDATE_ROWS: usize = 256; + +/// The part of the normal equations that doesn't depend on the constraints: the shape and +/// anchor terms `K = Σ rᵀr + ANCHOR_WEIGHT²·I`, assembled and factored once per solve. +struct StaticSystem { + k: Csr, + chol: Option, + rhs: Vec, + /// `K⁻¹ · rhs`, the solution without any line rows. + base: Vec, +} + +impl StaticSystem { + fn new(g: &Grid) -> StaticSystem { + let n = 2 * g.nv; + let mut trip: Vec<(usize, usize, f64)> = Vec::with_capacity(g.nx * g.ny * 64 + n); + for j in 0..g.ny { + for i in 0..g.nx { + let a = j * g.w1 + i; + let corner = [a, a + 1, a + g.w1, a + g.w1 + 1]; + // The cell's 24 shape rows only involve its own 8 unknowns: accumulate their + // 8 × 8 normal block (unknown `2·c + axis` is corner `c`'s coordinate). + let mut m = [[0.0f64; 8]; 8]; + for t in CELL_TRIS { + for rot in 0..3 { + let local = [t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]]; + for row in shape_rows(local, local.map(|c| g.src[corner[c]])) { + for &(si, ca) in &row { + for &(sj, cb) in &row { + m[si][sj] += ca * cb; + } + } + } + } + } + for (si, mi) in m.iter().enumerate() { + for (sj, &v) in mi.iter().enumerate() { + trip.push((2 * corner[si / 2] + si % 2, 2 * corner[sj / 2] + sj % 2, v)); + } + } + } + } + for d in 0..n { + trip.push((d, d, ANCHOR_WEIGHT * ANCHOR_WEIGHT)); + } + let k = Csr::from_triplets(n, trip); + let chol = BandChol::new(&k); + let rhs = g.anchor_rhs(); + let mut base = vec![0.0; n]; + if let Some(c) = &chol { + c.solve(&rhs, &mut base); + } + StaticSystem { k, chol, rhs, base } + } + + /// Solves `(K + LᵀL) x = rhs` for the line rows `L = lines`. With `K`'s factor this is the + /// exact low-rank update `x = K⁻¹b − K⁻¹Lᵀ (I + L K⁻¹ Lᵀ)⁻¹ L K⁻¹b` (one parallel solve per + /// line row). Without it, or with very many rows, conjugate gradients warm-started from `x`. + /// A solve that goes non-finite leaves `x` as it was. + fn solve(&self, lines: &[Row], x: &mut Vec) { + let n = self.rhs.len(); + let updated = match &self.chol { + Some(chol) if lines.len() <= MAX_UPDATE_ROWS => self.solve_update(chol, lines), + _ => None, + }; + let solved = match updated { + Some(v) => v, + None => { + let mut v = x.clone(); + self.solve_cg(lines, &mut v); + v + } + }; + if solved.len() == n && solved.iter().all(|v| v.is_finite()) { + *x = solved; + } + } + + fn solve_update(&self, chol: &BandChol, lines: &[Row]) -> Option> { + let (n, m) = (self.rhs.len(), lines.len()); + if m == 0 { + return Some(self.base.clone()); + } + // Column `j` of `K⁻¹Lᵀ` is `K⁻¹` applied to line row `j`. + let mut cols = vec![0.0; n * m]; + par_rows(&mut cols, n, 1, |j, col| { + let mut dense = vec![0.0; n]; + for &(i, c) in lines.get(j).into_iter().flatten() { + if let Some(d) = dense.get_mut(i) { + *d += c; + } + } + chol.solve(&dense, col); + }); + let dot_row = |row: &Row, v: &[f64]| -> f64 { row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum() }; + // S = I + L K⁻¹ Lᵀ, and t = L K⁻¹ b. + let mut s = vec![0.0; m * m]; + for (a, row) in lines.iter().enumerate() { + for (b, col) in cols.chunks(n).enumerate() { + s[a * m + b] = dot_row(row, col) + if a == b { 1.0 } else { 0.0 }; + } + } + let t: Vec = lines.iter().map(|row| dot_row(row, &self.base)).collect(); + let z = solve_dense(m, s, t)?; + let mut x = self.base.clone(); + for (zj, col) in z.iter().zip(cols.chunks(n)) { + for (xi, ci) in x.iter_mut().zip(col) { + *xi -= zj * ci; + } + } + Some(x) + } + + fn solve_cg(&self, lines: &[Row], x: &mut [f64]) { + let op = |v: &[f64], out: &mut [f64]| { + self.k.mul(v, out); + for row in lines { + let t: f64 = row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum(); + for &(i, c) in row { + if let Some(o) = out.get_mut(i) { + *o += c * t; + } + } + } + }; + match &self.chol { + Some(chol) => { + pcg(op, |r, z| chol.solve(r, z), &self.rhs, x, 1e-8, 500); + } + // The band was too wide to factor: Jacobi-preconditioned, and many more iterations. + None => { + let diag = &self.k.diag; + pcg(op, |r, z| z.iter_mut().zip(r.iter().zip(diag)).for_each(|(z, (r, d))| *z = r / d), &self.rhs, x, 1e-8, 4000); + } + } + } +} + +/// Solves the content-preserving warp for `p` over `frame`. +pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { + let mut system: Option = None; + solve_with(p, frame, GRID_CELLS, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x)) +} + +/// [`solve`] with the grid resolution and the linear solver for `(K + LᵀL) x = rhs` (given the +/// current line rows and a warm start) chosen by the caller. +fn solve_with(p: &WideAngle, frame: Rect, cells: f64, linsolve: &mut LinSolve<'_>) -> WideMesh { + let cam = Camera::new(p, frame); + let g = Grid::new(frame, cells); + let (nx, ny, nv, src) = (g.nx, g.ny, g.nv, &g.src); + let curves: Vec> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect(); + let mut x: Vec = src.iter().flat_map(|s| [s[0], s[1]]).collect(); let active: Vec = (0..curves.len()).filter(|&k| curves[k].len() >= 3).collect(); if !active.is_empty() { let mut normals: Vec<[f64; 2]> = active.iter().map(|_| [0.0, 1.0]).collect(); for round in 0..4 { - let mut ls = SparseLs::new(2 * nv); - // Shape: v0 = v1 + u (v2 − v1) + v R90 (v2 − v1), with (u, v) from the source. - for t in &tris { - for rot in 0..3 { - let (i0, i1, i2) = (t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]); - let (p0, p1, p2) = (src[i0], src[i1], src[i2]); - let e = [p2[0] - p1[0], p2[1] - p1[1]]; - let l2 = e[0] * e[0] + e[1] * e[1]; - let d = [p0[0] - p1[0], p0[1] - p1[1]]; - let u = (d[0] * e[0] + d[1] * e[1]) / l2; - let v = (d[0] * -e[1] + d[1] * e[0]) / l2; - // x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0 - ls.add(vec![(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)], 0.0, 1.0); - // y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0 - ls.add(vec![(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)], 0.0, 1.0); - } - } - // Weak anchoring to the source (fixes the global similarity). - for (k, s) in src.iter().enumerate() { - ls.add(vec![(2 * k, 1.0)], s[0], 0.02); - ls.add(vec![(2 * k + 1, 1.0)], s[1], 0.02); - } - // Lines. - for (ci, &k) in active.iter().enumerate() { - let c = &curves[k]; - let n = match p.constraints[k].orientation { - Orientation::Horizontal => [0.0, 1.0], - Orientation::Vertical => [1.0, 0.0], - Orientation::Free => normals[ci], - }; - let w0 = weights(c[0]); - for q in &c[1..] { - let wq = weights(*q); - let mut row: Vec<(usize, f64)> = Vec::with_capacity(16); - for &(i, w) in &wq { - row.push((2 * i, n[0] * w)); - row.push((2 * i + 1, n[1] * w)); - } - for &(i, w) in &w0 { - row.push((2 * i, -n[0] * w)); - row.push((2 * i + 1, -n[1] * w)); - } - ls.add(row, 0.0, 10.0); - } - } - x = ls.solve(&x, 400 + 200 * round); + linsolve(&g, &line_rows(&g, &curves, &active, &normals, p), &mut x); // Re-estimate free normals from the current end points. - let pos = |q: [f64; 2]| -> [f64; 2] { weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) }; + let pos = |q: [f64; 2]| -> [f64; 2] { g.weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) }; let mut changed = false; for (ci, &k) in active.iter().enumerate() { let c = &curves[k]; @@ -350,7 +548,7 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { let mut mesh = WideMesh { nx, ny, verts, curves, residual: 0.0 }; // Straightness residual: distance of curve samples from the chord of their mapped ends. let mut worst = 0.0f64; - for (k, cv) in mesh.curves.iter().enumerate() { + for cv in mesh.curves.iter() { if cv.len() < 3 { continue; } @@ -361,7 +559,6 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { for q in &pts { worst = worst.max(((q[0] - a[0]) * d[1] - (q[1] - a[1]) * d[0]).abs() / l); } - let _ = k; } mesh.residual = worst; mesh @@ -433,6 +630,108 @@ mod tests { assert!((c[0] - 300.0).abs() < 80.0 && (c[1] - 250.0).abs() < 40.0, "{c:?}"); } + /// The exact solution of the same normal equations (dense, small grids only). + fn dense_linsolve(g: &Grid, lines: &[Row], x: &mut Vec) { + use crate::photo_util::Normal; + let mut ne = Normal::new(2 * g.nv); + for row in all_shape_rows(g) { + ne.add(&row, 0.0, 1.0); + } + for k in 0..2 * g.nv { + ne.add(&[(k, 1.0)], g.src[k / 2][k % 2], ANCHOR_WEIGHT); + } + for row in lines { + ne.add(row, 0.0, 1.0); + } + *x = ne.solve().expect("the anchored system is positive definite"); + } + + #[test] + fn direct_solve_matches_the_dense_reference() { + let frame = Rect::new(0, 0, 240, 160); + for constraints in [ + vec![ + Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal }, + Constraint { a: [40.0, 30.0], b: [45.0, 130.0], orientation: Orientation::Free }, + ], + vec![ + Constraint { a: [20.0, 120.0], b: [220.0, 110.0], orientation: Orientation::Free }, + Constraint { a: [200.0, 20.0], b: [205.0, 140.0], orientation: Orientation::Vertical }, + ], + ] { + let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() }; + let mut system: Option = None; + let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x)); + let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve); + assert_eq!(fast.verts.len(), exact.verts.len()); + let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max); + assert!(worst < 0.05, "worst vertex difference {worst} px"); + assert!(fast.residual <= exact.residual + 0.05, "residual {} vs exact {}", fast.residual, exact.residual); + } + } + + /// More line rows than the low-rank update takes falls back to conjugate gradients, which + /// still agrees with the exact solution. + #[test] + fn many_constraints_use_the_conjugate_gradient_fallback() { + let frame = Rect::new(0, 0, 240, 160); + let constraints: Vec = (0..12) + .map(|k| Constraint { + a: [20.0 + 4.0 * k as f64, 15.0 + 10.0 * k as f64], + b: [210.0 - 3.0 * k as f64, 18.0 + 10.0 * k as f64], + orientation: Orientation::Horizontal, + }) + .collect(); + let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() }; + let mut rows = 0; + let mut system: Option = None; + let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| { + rows = lines.len(); + system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x) + }); + assert!(rows > MAX_UPDATE_ROWS, "{rows} line rows"); + let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve); + let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max); + assert!(worst < 0.05, "worst vertex difference {worst} px"); + } + + /// Without a banded factor (band too wide) the Jacobi-preconditioned fallback still solves. + #[test] + fn unfactored_system_falls_back_to_jacobi_cg() { + let frame = Rect::new(0, 0, 240, 160); + let p = WideAngle { + model: WideModel::Fisheye, + focal_length: 8.0, + constraints: vec![Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal }], + ..Default::default() + }; + let mut system: Option = None; + let jacobi = solve_with(&p, frame, 12.0, &mut |g, lines, x| { + let sys = system.get_or_insert_with(|| { + let mut sys = StaticSystem::new(g); + sys.chol = None; + sys + }); + sys.solve(lines, x) + }); + let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve); + let worst = jacobi.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max); + assert!(worst < 0.1, "worst vertex difference {worst} px"); + } + + #[test] + fn hostile_frames_and_constraints_solve_to_finite_meshes() { + let wild = Constraint { a: [-1e6, 5e5], b: [1e6, -5e5], orientation: Orientation::Free }; + let same = Constraint { a: [3.0, 3.0], b: [3.0, 3.0], orientation: Orientation::Free }; + let nan = Constraint { a: [f64::NAN, 1.0], b: [2.0, f64::INFINITY], orientation: Orientation::Horizontal }; + for frame in [Rect::new(0, 0, 1, 1), Rect::new(0, 0, 2, 200), Rect::new(0, 0, 400, 3), Rect::new(-50, -20, 90, 60)] { + let p = + WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints: vec![wild.clone(), same.clone(), nan.clone()], ..Default::default() }; + let mesh = solve(&p, frame); + assert!(mesh.verts.iter().all(|(o, s)| o.iter().chain(s).all(|v| v.is_finite())), "{frame:?}"); + } + } + #[test] fn no_constraints_is_identity_and_renders() { use photocraft_color::PixelFormat; diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index e09115416c..e119e22548 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -306,42 +306,73 @@ fn is_latin1_keyword(k: &str) -> bool { pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); - let color = match img.layout() { + let bpp = img.layout().channels() * img.sample_type().bytes(); + // Big-endian sample bytes. + let mut data = std::borrow::Cow::Borrowed(img.data()); + if img.sample_type() == SampleType::U16 { + to_big_endian(data.to_mut()); + } + let mut out = Vec::new(); + { + let mut writer = start(&mut out, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?; + if opts.png_interlaced { + let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; + write_idat(&mut writer, &idat, MAX_IDAT)?; + } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) { + write_idat(&mut writer, &idat, MAX_IDAT)?; + } else { + writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?; + } + writer.finish().map_err(|e| CodecError::encode(F, e))?; + } + Ok(out) +} + +/// 16-bit native-endian samples to PNG's big-endian order, in place. +fn to_big_endian(data: &mut [u8]) { + for c in data.as_chunks_mut::<2>().0 { + let v = u16::from_ne_bytes([c[0], c[1]]); + c.copy_from_slice(&v.to_be_bytes()); + } +} + +/// Writes the signature and every chunk ahead of the image data for a `w` × `h` PNG of +/// `layout` and `sample` (already the encode plan's), with its profile and metadata. +fn start( + out: W, + (w, h): (u32, u32), + (layout, sample): (ChannelLayout, SampleType), + icc: Option<&Vec>, + meta: &Metadata, + opts: &EncodeOptions, +) -> Result, CodecError> { + let color = match layout { ChannelLayout::Gray => png::ColorType::Grayscale, ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha, ChannelLayout::Rgb => png::ColorType::Rgb, ChannelLayout::Rgba => png::ColorType::Rgba, l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), }; - let depth = match img.sample_type() { + let depth = match sample { SampleType::U8 => png::BitDepth::Eight, SampleType::U16 => png::BitDepth::Sixteen, s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), }; - // Big-endian sample bytes. - let mut data = std::borrow::Cow::Borrowed(img.data()); - if depth == png::BitDepth::Sixteen { - for c in data.to_mut().as_chunks_mut::<2>().0 { - let v = u16::from_ne_bytes([c[0], c[1]]); - c.copy_from_slice(&v.to_be_bytes()); - } - } - let mut info = png::Info::with_size(w, h); info.color_type = color; info.bit_depth = depth; info.interlaced = opts.png_interlaced; if opts.embed_icc - && let Some(icc) = &img.icc + && let Some(icc) = icc { info.icc_profile = Some(icc.clone().into()); } if opts.embed_metadata { - if let Some(exif) = &img.meta.exif { + if let Some(exif) = &meta.exif { // The pixels are written as they are shown: never let a viewer rotate them again. - info.exif_metadata = Some(crate::resolution::export_exif(exif, img.meta.dpi).into_owned().into()); + info.exif_metadata = Some(crate::resolution::export_exif(exif, meta.dpi).into_owned().into()); } - if let Some((x, y)) = img.meta.dpi + if let Some((x, y)) = meta.dpi && x > 0.0 && y > 0.0 { @@ -349,51 +380,150 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result png::Compression::NoCompression, - PngCompression::Fast => png::Compression::Fast, - PngCompression::Default => png::Compression::Balanced, - PngCompression::Best => png::Compression::High, - }); - if opts.embed_metadata { - for (k, v) in &img.meta.text { - let latin1_text = v.chars().all(|c| (c as u32) < 256); - let res = if is_latin1_keyword(k) && latin1_text { - encoder.add_text_chunk(k.clone(), v.clone()) - } else if is_latin1_keyword(k) { - encoder.add_itxt_chunk(k.clone(), v.clone()) - } else { - continue; - }; - res.map_err(|e| CodecError::encode(F, e))?; - } - if let Some(xmp) = &img.meta.xmp { - encoder - .add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, img.meta.dpi).into_owned()) - .map_err(|e| CodecError::encode(F, e))?; - } + let mut encoder = png::Encoder::with_info(out, info).map_err(|e| CodecError::encode(F, e))?; + encoder.set_compression(match opts.png_compression { + PngCompression::None => png::Compression::NoCompression, + PngCompression::Fast => png::Compression::Fast, + PngCompression::Default => png::Compression::Balanced, + PngCompression::Best => png::Compression::High, + }); + if opts.embed_metadata { + for (k, v) in &meta.text { + let latin1_text = v.chars().all(|c| (c as u32) < 256); + let res = if is_latin1_keyword(k) && latin1_text { + encoder.add_text_chunk(k.clone(), v.clone()) + } else if is_latin1_keyword(k) { + encoder.add_itxt_chunk(k.clone(), v.clone()) + } else { + continue; + }; + res.map_err(|e| CodecError::encode(F, e))?; } - let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; - if let Some(stereo) = opts.png_stereo { - // Ancillary chunk before IDAT, as the spec recommends for `sTER`. - writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + if let Some(xmp) = &meta.xmp { + encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, meta.dpi).into_owned()).map_err(|e| CodecError::encode(F, e))?; } - if opts.png_interlaced { - let bpp = img.layout().channels() * img.sample_type().bytes(); - let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; - writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?; - } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) { - writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?; - } else { - writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?; - } - writer.finish().map_err(|e| CodecError::encode(F, e))?; } - Ok(out) + let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = opts.png_stereo { + // Ancillary chunk before IDAT, as the spec recommends for `sTER`. + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } + Ok(writer) +} + +/// Largest IDAT chunk written. A chunk holds at most 2^31 - 1 bytes, so a zlib stream past that +/// (a multi-gigapixel image) is split over several IDAT chunks, which readers join back. +const MAX_IDAT: usize = 1 << 30; + +/// `zlib` as IDAT chunks of at most `max` bytes. +fn write_idat(writer: &mut png::Writer, zlib: &[u8], max: usize) -> Result<(), CodecError> { + for part in zlib.chunks(max.max(1)) { + writer.write_chunk(png::chunk::IDAT, part).map_err(|e| CodecError::encode(F, e))?; + } + Ok(()) +} + +/// A PNG encoded a band of rows at a time, for images too large to hold whole (a multi-gigapixel +/// print): each band is converted to the encode plan, filtered and deflated on all cores as it +/// arrives and written as IDAT chunks, so only about one band is ever held. The deflate bands are +/// [`parallel_idat`]'s, so the zlib stream is the one [`crate::encode`] writes for a large image. +/// Never interlaced (Adam7 needs the whole image). +pub struct PngRows { + writer: png::Writer, + plan: Plan, + width: u32, + height: u32, + /// Rows received so far. + rows: u32, + level: PngCompression, + /// Converted, big-endian rows that don't fill a deflate band yet. + pending: Vec, + /// The last row deflated: the row above `pending` for the filters. + prev: Vec, + adler: u32, + /// Whether the zlib header is written. + started: bool, +} + +impl PngRows { + /// Writes the header of a `first.width()` × `height` PNG to `out`. The layout, sample type, + /// profile and metadata come from `first`, the first band (not written yet: [`Self::push`] + /// it like the others), through the same plan as [`crate::encode`]. + pub fn new(out: W, first: &Image, height: u32, opts: &EncodeOptions) -> Result { + if opts.png_interlaced { + return Err(CodecError::unsupported(F, "an interlaced PNG can't be written a band of rows at a time")); + } + let width = first.width(); + if width == 0 || height == 0 { + return Err(CodecError::InvalidImage("cannot encode an empty image".into())); + } + let plan = crate::fidelity::plan(first, F, opts); + let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?; + Ok(Self { writer, plan, width, height, rows: 0, level: opts.png_compression, pending: Vec::new(), prev: Vec::new(), adler: 1, started: false }) + } + + /// Encodes the next rows of the image: `band` is as wide as the image, in any layout and + /// sample type (converted like the first band). + pub fn push(&mut self, band: &Image) -> Result<(), CodecError> { + if band.width() != self.width { + return Err(CodecError::InvalidImage(format!("a {} px wide band for a {} px wide image", band.width(), self.width))); + } + let rows = self.rows.checked_add(band.height()).filter(|&r| r <= self.height); + let Some(rows) = rows else { + return Err(CodecError::InvalidImage(format!("more than the image's {} rows", self.height))); + }; + let img = band.converted(self.plan.layout, self.plan.sample); + let at = self.pending.len(); + self.pending.try_reserve(img.data().len()).map_err(|_| CodecError::encode(F, "not enough memory for a band of rows"))?; + self.pending.extend_from_slice(img.data()); + if self.plan.sample == SampleType::U16 { + to_big_endian(self.pending.get_mut(at..).unwrap_or_default()); + } + self.rows = rows; + self.deflate_pending() + } + + /// Deflates the whole bands held, and the rest once every row is in. + fn deflate_pending(&mut self) -> Result<(), CodecError> { + let bpp = self.plan.layout.channels() * self.plan.sample.bytes(); + let stride = self.width as usize * bpp; + let per_band = band_rows(stride); + let held = self.pending.len() / stride.max(1); + let finish = self.rows == self.height; + let take = if finish { held } else { held / per_band * per_band }; + if take == 0 { + return Ok(()); + } + let rows = self.pending.get(..take * stride).unwrap_or_default(); + let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice()); + let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads()) + .ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?; + let mut zlib = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::() + 6); + if !self.started { + zlib.extend_from_slice(&zlib_header(self.level)); + self.started = true; + } + for p in &parts { + zlib.extend_from_slice(&p.z); + self.adler = adler32_combine(self.adler, p.adler, p.len); + } + if finish { + zlib.extend_from_slice(&self.adler.to_be_bytes()); + } + write_idat(&mut self.writer, &zlib, MAX_IDAT)?; + self.prev.clear(); + self.prev.extend_from_slice(rows.get(rows.len() - stride..).unwrap_or_default()); + self.pending.drain(..take * stride); + Ok(()) + } + + /// Ends the file. Fails when fewer rows than the image's height were pushed. + pub fn finish(self) -> Result<(), CodecError> { + if self.rows != self.height || !self.pending.is_empty() { + return Err(CodecError::InvalidImage(format!("{} of the image's {} rows were written", self.rows, self.height))); + } + self.writer.finish().map_err(|e| CodecError::encode(F, e)) + } } /// Build the zlib stream for an Adam7-interlaced image (filter type 0). @@ -417,13 +547,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression y += dy; } } - let lvl = match level { - PngCompression::None => flate2::Compression::none(), - PngCompression::Fast => FAST, - PngCompression::Default => flate2::Compression::default(), - PngCompression::Best => flate2::Compression::best(), - }; - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), lvl); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), deflate_level(level)); z.write_all(&raw).map_err(|e| CodecError::encode(F, e))?; z.finish().map_err(|e| CodecError::encode(F, e)) } @@ -447,44 +571,108 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress if cfg!(target_arch = "wasm32") || data.len() < PARALLEL_MIN_BYTES || level == PngCompression::None || w == 0 || h == 0 { return None; } - let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32); - banded_idat(data, w, h, bpp, level, threads) + banded_idat(data, w, h, bpp, level, encode_threads()) } /// Filtered bytes per deflate band. The bands depend only on the image, never on the number of /// threads, so the same image and options give the same bytes whatever the machine's core count. const BAND_BYTES: usize = 512 << 10; +/// The deflate effort for `level`. +fn deflate_level(level: PngCompression) -> flate2::Compression { + match level { + PngCompression::None => flate2::Compression::none(), + PngCompression::Fast => FAST, + PngCompression::Default => flate2::Compression::default(), + PngCompression::Best => flate2::Compression::best(), + } +} + +/// The zlib stream header: deflate, 32 K window; FLEVEL only advertises the effort. +fn zlib_header(level: PngCompression) -> [u8; 2] { + match level { + PngCompression::None | PngCompression::Fast => [0x78, 0x01], + PngCompression::Best => [0x78, 0xDA], + PngCompression::Default => [0x78, 0x9C], + } +} + +/// Rows per deflate band for rows of `stride` bytes. +fn band_rows(stride: usize) -> usize { + (BAND_BYTES / stride.saturating_add(1)).max(1) +} + +/// Worker threads for the banded encoders: one on wasm (no threads), else the machine's cores. +fn encode_threads() -> usize { + if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) } +} + /// [`parallel_idat`] on `threads` worker threads. fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option> { let stride = w * bpp; - let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1); - let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect(); - let lvl = match level { - PngCompression::Fast => FAST, - PngCompression::Best => flate2::Compression::best(), - _ => flate2::Compression::default(), + let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?; + let mut out = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::() + 6); + out.extend_from_slice(&zlib_header(level)); + let mut adler = 1u32; + for p in &parts { + out.extend_from_slice(&p.z); + adler = adler32_combine(adler, p.adler, p.len); + } + out.extend_from_slice(&adler.to_be_bytes()); + Some(out) +} + +/// One band of rows filtered and deflated: the raw deflate bytes, and the Adler-32 and length of +/// the filtered bytes they hold. +struct Deflated { + z: Vec, + adler: u32, + len: usize, +} + +/// `rows` (whole rows of `stride` bytes; `prev` is the row above the first, if any) filtered and +/// deflated in independent bands of `band_rows` rows on up to `threads` workers (inline for one). +/// The last band finishes the deflate stream when `finish`; every other band ends on a sync flush. +/// `None` when a band did not deflate in one call ([`deflate_band`]). +#[allow(clippy::too_many_arguments)] +fn deflate_rows( + rows: &[u8], + prev: Option<&[u8]>, + stride: usize, + bpp: usize, + band_rows: usize, + lvl: flate2::Compression, + finish: bool, + threads: usize, +) -> Option> { + let h = rows.len() / stride.max(1); + let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect(); + let band = |&(y0, y1): &(usize, usize)| -> Option { + let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1)); + let mut scratch = vec![0u8; stride]; + for y in y0..y1 { + let row = rows.get(y * stride..(y + 1) * stride)?; + let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev }; + filter_row(row, above, bpp, &mut scratch, &mut filtered); + } + let adler = adler32(1, &filtered); + let z = deflate_band(&filtered, lvl, finish && y1 == h)?; + Some(Deflated { z, adler, len: filtered.len() }) }; + if threads <= 1 || bands.len() <= 1 { + return bands.iter().map(band).collect(); + } let next = std::sync::atomic::AtomicUsize::new(0); - type Band = (usize, Vec, u32, usize); - let mut parts: Vec = std::thread::scope(|sc| { - let workers: Vec<_> = (0..threads.min(bands.len()).max(1)) + let mut parts: Vec<(usize, Deflated)> = std::thread::scope(|sc| { + let workers: Vec<_> = (0..threads.min(bands.len())) .map(|_| { sc.spawn(|| { - let mut out: Vec = Vec::new(); + let mut out = Vec::new(); loop { let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(&(y0, y1)) = bands.get(i) else { break }; - let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1)); - let mut scratch = vec![0u8; stride]; - for y in y0..y1 { - let row = &data[y * stride..(y + 1) * stride]; - let prev = (y > 0).then(|| &data[(y - 1) * stride..y * stride]); - filter_row(row, prev, bpp, &mut scratch, &mut filtered); - } - let adler = adler32(1, &filtered); - let Some(z) = deflate_band(&filtered, lvl, y1 == h) else { return Vec::new() }; - out.push((i, z, adler, filtered.len())); + let Some(b) = bands.get(i) else { break }; + let Some(d) = band(b) else { return Vec::new() }; + out.push((i, d)); } out }) @@ -496,20 +684,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio return None; } parts.sort_by_key(|p| p.0); - let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::() + 6); - // zlib header: deflate, 32 K window; FLEVEL only advertises the effort. - out.extend_from_slice(match level { - PngCompression::Fast => &[0x78, 0x5E], - PngCompression::Best => &[0x78, 0xDA], - _ => &[0x78, 0x9C], - }); - let mut adler = 1u32; - for (_, z, a, len) in &parts { - out.extend_from_slice(z); - adler = adler32_combine(adler, *a, *len); - } - out.extend_from_slice(&adler.to_be_bytes()); - Some(out) + Some(parts.into_iter().map(|p| p.1).collect()) } /// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band. @@ -978,3 +1153,132 @@ mod parallel_tests { } } } + +#[cfg(test)] +mod streamed_tests { + use super::*; + + /// (bytes ahead of the first IDAT, the IDAT payloads joined, how many IDAT chunks). + fn split(png: &[u8]) -> (Vec, Vec, usize) { + let (mut at, mut head, mut zlib, mut count) = (8, None, Vec::new(), 0); + while at + 12 <= png.len() { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + if &png[at + 4..at + 8] == b"IDAT" { + head.get_or_insert_with(|| png[..at].to_vec()); + zlib.extend_from_slice(&png[at + 8..at + 8 + len]); + count += 1; + } + at += 12 + len; + } + (head.unwrap(), zlib, count) + } + + fn noise(n: usize, seed: u32) -> Vec { + (0..n).map(|i| ((i / 3) as u32 ^ seed).wrapping_mul(2_654_435_761).rotate_left(i as u32 % 7) as u8 / 3 + (i % 97) as u8).collect() + } + + /// `img` pushed in bands of `heights` rows (the last one takes the rest). + fn streamed(img: &Image, heights: &[u32], opts: &EncodeOptions) -> Vec { + let (w, h) = img.dimensions(); + let row = img.data().len() / h as usize; + let mut bands = Vec::new(); + let mut y = 0; + for &n in heights.iter().chain(std::iter::once(&u32::MAX)) { + let n = n.min(h - y); + if n == 0 { + continue; + } + let data = img.data()[y as usize * row..(y + n) as usize * row].to_vec(); + bands.push(Image::from_raw(w, n, img.layout(), img.sample_type(), data).unwrap().with_icc(img.icc.clone()).with_meta(img.meta.clone())); + y += n; + } + let mut out = Vec::new(); + let mut rows = PngRows::new(&mut out, &bands[0], h, opts).unwrap(); + for b in &bands { + rows.push(b).unwrap(); + } + rows.finish().unwrap(); + out + } + + /// Bands of any height give the zlib stream, header chunks and pixels the whole-image encoder + /// gives, at 8 and 16 bits, with a profile, resolution, text and XMP. + #[test] + fn streamed_rows_match_the_whole_image_encoder() { + for (layout, sample, ch, bytes) in [(ChannelLayout::Rgba, SampleType::U8, 4usize, 1usize), (ChannelLayout::Rgb, SampleType::U16, 3, 2)] { + let (w, h) = (1201u32, 1013u32); + let meta = Metadata { + dpi: Some((300.0, 300.0)), + text: vec![("Title".into(), "Tent".into())], + xmp: Some("".into()), + ..Default::default() + }; + let img = Image::from_raw(w, h, layout, sample, noise(w as usize * h as usize * ch * bytes, 7)) + .unwrap() + .with_icc(Some((0..600u32).map(|i| (i % 251) as u8).collect())) + .with_meta(meta); + for level in [PngCompression::Fast, PngCompression::Default] { + let opts = EncodeOptions { png_compression: level, ..Default::default() }; + let whole = crate::encode(&img, Format::Png, &opts).unwrap(); + let (head, zlib, _) = split(&whole); + for heights in [&[1u32, 77, 300][..], &[256, 256, 256], &[1013]] { + let png = streamed(&img, heights, &opts); + let (h2, z2, _) = split(&png); + assert!(h2 == head, "{layout:?} {level:?} {heights:?}: header chunks differ"); + assert!(z2 == zlib, "{layout:?} {level:?} {heights:?}: zlib stream differs"); + assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data()); + } + } + } + } + + /// Small images, no compression and bands in another sample type (converted by the plan). + #[test] + fn small_uncompressed_and_converted_bands_round_trip() { + let img = Image::from_raw(3, 5, ChannelLayout::GrayA, SampleType::U8, noise(30, 3)).unwrap(); + for level in [PngCompression::None, PngCompression::Best] { + let opts = EncodeOptions { png_compression: level, ..Default::default() }; + let png = streamed(&img, &[2, 1], &opts); + assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data(), "{level:?}"); + } + // Float bands are written with 16 bits, as `encode` writes them. + let f = img.convert(ChannelLayout::GrayA, SampleType::F32); + let png = streamed(&f, &[4], &EncodeOptions::default()); + let back = decode(&png, &Limits::default()).unwrap(); + assert_eq!(back.sample_type(), SampleType::U16); + assert_eq!(back.convert(ChannelLayout::GrayA, SampleType::U8).data(), img.data()); + } + + /// A zlib stream longer than one chunk may hold is split over IDAT chunks that read back. + #[test] + fn long_streams_split_over_several_idat_chunks() { + let img = Image::from_raw(64, 40, ChannelLayout::Rgb, SampleType::U8, noise(64 * 40 * 3, 9)).unwrap(); + let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let (_, zlib, _) = split(&whole); + let mut out = Vec::new(); + let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap(); + write_idat(&mut w, &zlib, 100).unwrap(); + w.finish().unwrap(); + let (_, joined, count) = split(&out); + assert_eq!(count, zlib.len().div_ceil(100)); + assert!(joined == zlib); + assert_eq!(decode(&out, &Limits::default()).unwrap().data(), img.data()); + } + + /// Bands that don't fit the image, missing rows and interlacing are errors, not panics. + #[test] + fn bands_that_dont_fit_are_errors() { + let band = Image::from_raw(4, 2, ChannelLayout::Rgb, SampleType::U8, vec![0; 24]).unwrap(); + let opts = EncodeOptions::default(); + assert!(PngRows::new(Vec::new(), &band, 0, &opts).is_err()); + assert!(PngRows::new(Vec::new(), &band, 3, &EncodeOptions { png_interlaced: true, ..Default::default() }).is_err()); + let mut rows = PngRows::new(Vec::new(), &band, 3, &opts).unwrap(); + rows.push(&band).unwrap(); + // Two more rows than the image holds. + assert!(rows.push(&band).is_err()); + let narrow = Image::from_raw(3, 1, ChannelLayout::Rgb, SampleType::U8, vec![0; 9]).unwrap(); + assert!(rows.push(&narrow).is_err()); + // Two of three rows. + assert!(rows.finish().is_err()); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 1ae285e7ce..44ec622ef4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -29,7 +29,7 @@ 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, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; +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::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index c25867c938..d2c020a76f 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -31,8 +31,8 @@ pub use color::{ use tone::LUT_SIZE; pub(crate) use tone::apply_tone; pub use tone::{ - TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, float_tone_extrapolates, float_tone_identity, levels, levels_float, - levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q, + TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, curve_samples, float_tone_extrapolates, float_tone_identity, levels, + levels_float, levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q, }; use tone::{lut, map_rgb}; diff --git a/crates/compose/src/adjust/tone.rs b/crates/compose/src/adjust/tone.rs index 5debc98a83..5c57608e02 100644 --- a/crates/compose/src/adjust/tone.rs +++ b/crates/compose/src/adjust/tone.rs @@ -402,11 +402,19 @@ pub fn curve_map_lut(points: &[CurvePoint]) -> Vec { /// overshoot between points, as Photoshop's does), constant outside the /// first/last point, clamped to 0..=1, sampled into a LUT. pub fn curve_lut(points: &[CurvePoint]) -> Vec { + curve_samples::(points) +} + +/// The same natural spline as [`curve_lut`], sampled directly at `N` evenly spaced inputs +/// including both endpoints. UI graphs use a smaller table without building the rendering LUT. +/// Zero samples returns an empty table; one sample evaluates at input zero. +pub fn curve_samples(points: &[CurvePoint]) -> Vec { + let last = N.saturating_sub(1).max(1) as f32; let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect(); pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); if pts.len() < 2 { - return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect(); + return (0..N).map(|i| i as f32 / last).collect(); } let n = pts.len(); // Second derivatives of the natural spline (tridiagonal solve). @@ -430,9 +438,9 @@ pub fn curve_lut(points: &[CurvePoint]) -> Vec { m2[i] = d[i] - c[i] * m2[i + 1]; } } - (0..LUT_SIZE) + (0..N) .map(|k| { - let xv = k as f32 / (LUT_SIZE - 1) as f32; + let xv = k as f32 / last; if xv <= pts[0].0 { return pts[0].1.clamp(0.0, 1.0); } diff --git a/crates/compose/src/effects/bevel.rs b/crates/compose/src/effects/bevel.rs index 6d2352622d..ea07fbd709 100644 --- a/crates/compose/src/effects/bevel.rs +++ b/crates/compose/src/effects/bevel.rs @@ -108,8 +108,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern || (), |_, y, row, _| { for (x, o) in row.iter_mut().enumerate() { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); + let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; *o += k * if t.invert { 1.0 - l } else { l }; } diff --git a/crates/compose/src/effects/paint.rs b/crates/compose/src/effects/paint.rs index 6bd78df7de..99b0c6aa0b 100644 --- a/crates/compose/src/effects/paint.rs +++ b/crates/compose/src/effects/paint.rs @@ -339,8 +339,7 @@ pub(super) fn paint_pattern( m, |i| { let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5); - let (u, v) = place.map(x, y); - tile.sample(u, v) + tile.sample_area(&place, x, y) }, blend, opacity, diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b66ab0ad1e..b351bdef06 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -3,19 +3,32 @@ //! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas //! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with //! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile -//! exactly and scaled ones stay seamless. +//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an +//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a +//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20; +/// What one rendering call may keep of its patterns' working copies: every pattern it paints with +/// stays converted for the call (four of the largest 8-bit ones on desktop), so none is converted +/// once per output tile. A ceiling, not an allocation: only the patterns a call paints with are +/// converted. Past it a pattern is converted under one lock and shared while in use (see +/// [`PreparedPatterns::get`]). The web build keeps a quarter of that. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const PREPARED_PATTERN_BYTES: usize = 1 << 30; +/// See the desktop value above. +#[cfg(target_arch = "wasm32")] +pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20; const MAX_PREPARED_PATTERNS: usize = 64; -/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this -/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the -/// cap it is skipped instead. Independent of the cache budget so small patterns still convert. -const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20; +/// Absolute cap on a single pattern's working copy on the uncached path: the document's own cap +/// on what paints (`Pattern::is_paintable`). A pattern above it is skipped, not converted. +const MAX_UNCACHED_PATTERN_BYTES: usize = photocraft_doc::pattern::MAX_WORKING_BYTES as usize; +// A call keeps at least two of the largest, or a second large fill is converted for every tile. +const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); type PreparedSlot = Arc>>>; struct PreparedState { @@ -23,6 +36,15 @@ struct PreparedState { reserved_bytes: usize, } +/// Patterns past the call's budget: converted one at a time under this lock, and shared by the +/// workers using one at the same moment (a `Weak` per pattern), so the extra copies in memory are +/// the patterns in use, not one per worker. +#[derive(Default)] +struct OverBudget { + live: Mutex>>, + warned: AtomicBool, +} + /// Demand-prepared pixels owned by one rendering call, never by the document. pub(crate) struct PreparedPatterns<'a> { patterns: &'a [Pattern], @@ -30,11 +52,18 @@ pub(crate) struct PreparedPatterns<'a> { /// The colour mode the patterns are painted into (see [`pattern_rgba`]). mode: ColorMode, state: Mutex, + over_budget: OverBudget, } impl<'a> PreparedPatterns<'a> { pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self { - Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) } + Self { + patterns, + budget, + mode: ColorMode::Rgb, + state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }), + over_budget: OverBudget::default(), + } } /// Patterns painted into a document of colour mode `mode`. @@ -52,10 +81,7 @@ impl<'a> PreparedPatterns<'a> { if !p.is_paintable() { return None; } - let bytes = usize::try_from(p.width) - .ok() - .and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h))) - .and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>())); + let bytes = usize::try_from(p.working_bytes()).ok(); // The borrowed immutable slice pins these addresses for the complete call. let key = std::ptr::from_ref(p) as usize; let slot = { @@ -82,14 +108,40 @@ impl<'a> PreparedPatterns<'a> { // Run on the requesting worker to preserve its active CMYK profile. slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone() } - // Uncached path: a pattern whose full conversion would exceed an absolute cap is - // not materialized at all. Otherwise every output tile would re-expand the entire - // sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and - // memory even though cache admission already refused to retain it. The cap is - // independent of the (possibly zero) cache budget, so small patterns still convert - // on demand when nothing is cached. + // Uncached path: a pattern whose working copy would exceed an absolute cap is not + // materialized at all. One past the call's budget is converted under one lock, and + // shared while some worker still holds it, so workers painting tiles at the same + // time do not each convert their own copy (CPU and memory), and it says so once. + // The cap is independent of the (possibly zero) cache budget, so small patterns + // still convert on demand when nothing is cached. None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None, - None => Tile::new_in(p, self.mode).map(Arc::new), + None => { + // The lock is held through the conversion, so conversions of different + // over-budget patterns run one at a time. That is the point (one extra copy + // being built at once, not one per worker) and fine for a fallback: patterns + // that fit the budget never come here. + let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(shared) = live.get(&key).and_then(Weak::upgrade) { + return Some(shared); + } + if !self.over_budget.warned.swap(true, Ordering::Relaxed) { + // Written, not printed: a warning must never panic, even with no stderr. + #[cfg(not(target_arch = "wasm32"))] + { + use std::io::Write as _; + let _ = writeln!( + std::io::stderr(), + "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it", + p.display_name(), + self.budget >> 20 + ); + } + } + let tile = Tile::new_in(p, self.mode).map(Arc::new)?; + live.retain(|_, w| w.strong_count() > 0); + live.insert(key, Arc::downgrade(&tile)); + Some(tile) + } } } } @@ -101,29 +153,80 @@ impl<'a> PreparedPatterns<'a> { /// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't /// [paintable](Pattern::is_paintable). pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> { - let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else { - return Vec::new(); - }; - let mut px = vec![[0.0f32; 4]; n]; - p.surface.read_rgba_into(p.rect(), &mut px); - let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); - if paints_in && p.surface.format().mode != mode { - let fmt = PixelFormat::new(mode, SampleType::F32, true); - let mut v = [0.0f32; 5]; - for q in &mut px { - let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); - *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); - } + let mut px = Vec::new(); + if p.is_paintable() && pattern_rgba_rows(p, mode, 0, p.height, &mut px).is_none() { + px = Vec::new(); } px } -/// A pattern converted once to straight-alpha RGBA for sampling. +/// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large +/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. `None` when the +/// memory for the rows cannot be had (on the web a failed allocation aborts the tab, so it is +/// asked for, not assumed). +pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) -> Option<()> { + let bottom = bottom.min(p.height); + let top = top.min(bottom); + out.clear(); + let pixels = (p.width as usize).checked_mul((bottom - top) as usize)?; + out.try_reserve_exact(pixels).ok()?; + out.resize(pixels, [0.0f32; 4]); + p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out); + let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); + if paints_in && p.surface.format().mode != mode { + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let mut v = [0.0f32; 5]; + for q in out.iter_mut() { + let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); + *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); + } + } + Some(()) +} + +/// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded +/// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size +/// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that +/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. `None` +/// when the memory cannot be had. +fn quantized_rgba(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Option> { + let mut out = Vec::new(); + out.try_reserve_exact((p.width as usize).checked_mul(p.height as usize)?).ok()?; + let mut band = Vec::new(); + for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band)?; + out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])])); + } + Some(out) +} + +/// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as. +const UNIT8: [f32; 256] = { + let mut table = [0.0f32; 256]; + let mut i = 0; + while i < 256 { + table[i] = i as f32 / 255.0; + i += 1; + } + table +}; + +/// A pattern's pixels as the sampler holds them. +enum Texels { + /// Premultiplied `f32` RGBA, 16 bytes per pixel: any pattern that fits. + Premultiplied(Vec<[f32; 4]>), + /// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): a + /// large 8-bit pattern, with the values the expanded copy would hold. + Rgba8(Vec<[u8; 4]>), + /// Straight RGBA16, 8 bytes per pixel, premultiplied as each tap reads it: a large 16-bit one. + Rgba16(Vec<[u16; 4]>), +} + +/// A pattern converted once for sampling. pub struct Tile { w: usize, h: usize, - /// Premultiplied RGBA. - px: Vec<[f32; 4]>, + texels: Texels, } impl Tile { @@ -137,26 +240,83 @@ impl Tile { if !p.is_paintable() { return None; } + Tile::build(p, mode, p.pixel_count().saturating_mul(16) > photocraft_doc::pattern::MAX_WORKING_BYTES) + } + + /// [`Tile::new_in`], held at the pattern's own depth when `compact` and it has a smaller form. + fn build(p: &Pattern, mode: ColorMode, compact: bool) -> Option { let (w, h) = (p.width as usize, p.height as usize); - let mut px = pattern_rgba(p, mode); - for q in &mut px { - for c in 0..3 { - q[c] *= q[3]; + let texels = match p.surface.format().sample { + // A large pattern is held at its own depth (`Pattern::working_bytes`). + SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)?), + SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)?), + _ => { + let mut px = pattern_rgba(p, mode); + if px.len() as u64 != p.pixel_count() { + return None; + } + for q in &mut px { + for c in 0..3 { + q[c] *= q[3]; + } + } + Texels::Premultiplied(px) } - } - Some(Tile { w, h, px }) + }; + Some(Tile { w, h, texels }) } #[inline] fn at(&self, x: i64, y: i64) -> [f32; 4] { let xi = x.rem_euclid(self.w as i64) as usize; let yi = y.rem_euclid(self.h as i64) as usize; - self.px[yi * self.w + xi] + let i = yi * self.w + xi; + match &self.texels { + Texels::Premultiplied(px) => px.get(i).copied().unwrap_or_default(), + Texels::Rgba8(px) => { + let [r, g, b, a] = px.get(i).copied().unwrap_or_default(); + let a = UNIT8[usize::from(a)]; + [UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a] + } + Texels::Rgba16(px) => { + let [r, g, b, a] = px.get(i).copied().unwrap_or_default(); + let unit = |v: u16| f32::from(v) / 65535.0; + let a = unit(a); + [unit(r) * a, unit(g) * a, unit(b) * a, a] + } + } } /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). /// Returns straight alpha. pub fn sample(&self, u: f64, v: f64) -> [f32; 4] { + unpremultiply(self.sample_premul(u, v)) + } + + /// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's + /// footprint in the tile when the placement shrinks it. Returns straight alpha. + pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] { + let n = pixel_taps(place.inv_scale); + if n == 1 { + let (u, v) = place.map(x, y); + return self.sample(u, v); + } + let mut acc = [0.0f32; 4]; + for j in 0..n { + for i in 0..n { + let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n)); + let q = self.sample_premul(u, v); + for c in 0..4 { + acc[c] += q[c]; + } + } + } + let k = 1.0 / (n * n) as f32; + unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k]) + } + + /// Bilinear, wrapping sample, premultiplied. + fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] { let (u, v) = (u - 0.5, v - 0.5); let (x0, y0) = (u.floor(), v.floor()); let (fx, fy) = ((u - x0) as f32, (v - y0) as f32); @@ -177,13 +337,32 @@ impl Tile { } } } - if acc[3] <= 0.0 { - return [0.0; 4]; - } - [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] + acc } } +/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). +const MAX_TAPS: usize = 8; + +/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers +/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. +fn pixel_taps(inv_scale: f64) -> usize { + let s = inv_scale as f32; + if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 } +} + +/// Offset in document pixels of tap `i` of `n`, centred on the pixel. +fn tap_offset(i: usize, n: usize) -> f64 { + (i as f64 + 0.5) / n as f64 - 0.5 +} + +fn unpremultiply(acc: [f32; 4]) -> [f32; 4] { + if acc[3] <= 0.0 { + return [0.0; 4]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] +} + /// Where a pattern sits: document point → tile coordinates. #[derive(Clone, Copy, Debug)] pub struct Placement { @@ -234,8 +413,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> { let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize); for y in rect.y0..rect.y1 { for x in rect.x0..rect.x1 { - let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5); - out.push(tile.sample(u, v)); + out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5)); } } out @@ -282,6 +460,114 @@ mod tests { assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none()); } + /// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands. A pattern that + /// fits is expanded as before; a large one is held at its own depth, so Define Pattern's + /// 8000 x 8000 fits at 8 bits. (Only the size is declared: the patterns themselves are empty.) + #[test] + fn the_most_paintable_pixels_still_expand() { + let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern { + id: name.into(), + name: name.into(), + width, + height, + surface: Surface::new(format), + }; + let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let list = [ + sparse("max8", (8000, 8000), PixelFormat::RGBA8), + sparse("max16", (5792, 5792), PixelFormat::RGBA16), + sparse("max32", (4096, 4096), float), + sparse("over8", (9000, 8000), PixelFormat::RGBA8), + sparse("over16", (8000, 8000), PixelFormat::RGBA16), + sparse("over32", (4097, 4096), float), + ]; + assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, true, false, false, false]); + let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES); + let held = |name: &str| prepared.get(name, "").map(|t| (matches!(t.texels, Texels::Rgba8(_)), matches!(t.texels, Texels::Rgba16(_)))); + assert_eq!(held("max8"), Some((true, false)), "an 8-bit 8000 x 8000 pattern expands to 4 bytes a pixel"); + assert_eq!(held("max16"), Some((false, true)), "a 16-bit one to 8"); + for name in ["over8", "over16", "over32"] { + assert!(prepared.get(name, "").is_none(), "{name} is skipped, not allocated"); + } + assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap"); + } + + /// A render that paints with two large patterns (a pattern fill and a pattern overlay, say) + /// converts each once, not once per output tile: both stay in the call's budget. A third that + /// does not fit is converted when asked for (correct, just not kept). + #[test] + fn two_large_patterns_are_each_converted_once_per_call() { + let sparse = |name: &str| Pattern { id: name.into(), name: name.into(), width: 5000, height: 5000, surface: Surface::new(PixelFormat::RGBA8) }; + let list = [sparse("a"), sparse("b"), sparse("c")]; + assert!(list.iter().all(|p| p.working_bytes() == 100_000_000)); + let prepared = PreparedPatterns::new(&list, 250_000_000); + let same = |name: &str| { + let (first, second) = (prepared.get(name, "").unwrap(), prepared.get(name, "").unwrap()); + Arc::ptr_eq(&first, &second) + }; + assert!(same("a"), "the first is kept"); + assert!(same("b"), "and the second"); + // A third past the budget is converted under one lock and shared while a worker holds it, + // so concurrent tiles use one copy; it is converted again once nobody holds it. + assert!(same("c"), "shared while in use"); + let held = prepared.get("c", "").unwrap(); + let other = prepared.get("c", "").unwrap(); + assert!(Arc::ptr_eq(&held, &other), "two workers at once get one copy"); + drop((held, other)); + assert!(prepared.get("c", "").is_some(), "and it converts again after"); + // The real budget holds two of the largest paintable ones. + assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); + } + + /// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit + /// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers. + #[test] + fn the_compact_copies_read_the_same_as_the_expanded_one() { + for v in 0..=255u8 { + assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v); + assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0); + } + for v in 0..=u16::MAX { + assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v); + } + for (format, grid) in [(PixelFormat::RGBA8, 255.0f32), (PixelFormat::RGBA16, 65535.0)] { + let mut s = Surface::new(format); + for y in 0..16i32 { + for x in 0..16i32 { + let level = |v: i32| (v as f32 / 255.0 * grid).round() / grid; + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[level(x * 17), level(y * 17), level((x + y) * 8), level(255 - x * 13 - y * 3)]); + } + } + let p = Pattern::new("ramp", s, 16, 16); + let (expanded, compact) = (Tile::build(&p, ColorMode::Rgb, false).unwrap(), Tile::build(&p, ColorMode::Rgb, true).unwrap()); + assert!(matches!(expanded.texels, Texels::Premultiplied(_))); + assert!(matches!((&compact.texels, grid as u32), (Texels::Rgba8(_), 255) | (Texels::Rgba16(_), 65535))); + for y in 0..16 { + for x in 0..16 { + assert_eq!(compact.at(x, y), expanded.at(x, y), "{grid} ({x}, {y})"); + } + } + // And rendered, scaled, rotated and shifted across the wrap. + let place = Placement::new(Rect::new(3, 5, 40, 40), true, (2.0, 1.0), 0.6, 20.0); + assert_eq!(render(&compact, &place, Rect::new(-9, -7, 30, 25)), render(&expanded, &place, Rect::new(-9, -7, 30, 25))); + } + } + + /// Memory for the rows is asked for, not assumed: a pattern whose rows cannot be had (here a + /// declared size past what any allocation could be) gives `None`, never an aborted allocation. + #[test] + fn rows_that_cannot_be_allocated_give_none() { + let huge = Pattern { id: "huge".into(), name: "huge".into(), width: u32::MAX, height: u32::MAX, surface: Surface::new(PixelFormat::RGBA8) }; + let mut out = Vec::new(); + assert!(pattern_rgba_rows(&huge, ColorMode::Rgb, 0, u32::MAX, &mut out).is_none()); + assert!(quantized_rgba(&huge, ColorMode::Rgb, |v| v as u8).is_none()); + assert!(pattern_rgba(&huge, ColorMode::Rgb).is_empty()); + assert!(Tile::new(&huge).is_none()); + let small = Pattern { id: "small".into(), name: "small".into(), width: 3, height: 2, surface: Surface::new(PixelFormat::RGBA8) }; + assert_eq!(pattern_rgba_rows(&small, ColorMode::Rgb, 0, 2, &mut out), Some(())); + assert_eq!(out.len(), 6); + } + #[test] fn link_phase_scale_and_angle_move_the_origin() { let t = Tile::new(&checker()).unwrap(); @@ -299,6 +585,29 @@ mod tests { assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + #[test] + fn minified_pattern_is_filtered_not_aliased() { + // 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels. + // The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single + // bilinear tap would return pure white. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0); + let (u, _) = place.map(0.5, 0.5); + assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe"); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); + } + // 100 % stays exact. + let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0); + let row = render(&t, &one, Rect::new(0, 0, 2, 1)); + assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7f939c84a5..9b71af68f2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -834,6 +834,32 @@ fn threshold_posterize_levels_curves() { assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75); } +#[test] +fn curve_samples_preserve_spline_and_endpoint_behavior() { + use photocraft_doc::adjust::CurvePoint; + let points = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }]; + // Analytic natural spline: at x=1/4 and 3/4 the output is 11/16. + assert_eq!(adjust::curve_samples::<5>(&points), vec![0.0, 0.6875, 1.0, 0.6875, 0.0]); + let low = adjust::curve_samples::<256>(&points); + let full = adjust::curve_lut(&points); + assert_eq!(low.len(), 256); + assert_eq!(full.len(), 4096); + // Both grids share these inputs exactly (17/255 == 273/4095). + for k in 0..=15 { + assert_eq!(low[k * 17], full[k * 273]); + } + + let unsorted = [CurvePoint { input: 0.75, output: 0.8 }, CurvePoint { input: 0.25, output: 0.2 }]; + let table = adjust::curve_samples::<5>(&unsorted); + for (actual, expected) in table.iter().zip([0.2, 0.2, 0.5, 0.8, 0.8]) { + assert!((actual - expected).abs() < 1e-6); + } + assert_eq!(adjust::curve_samples::<3>(&[]), vec![0.0, 0.5, 1.0]); + assert_eq!(adjust::curve_samples::<3>(&points[..1]), vec![0.0, 0.5, 1.0]); + assert!(adjust::curve_samples::<0>(&points).is_empty()); + assert_eq!(adjust::curve_samples::<1>(&points), vec![0.0]); +} + #[test] fn hue_saturation_roundtrips_and_desaturates() { let c = [0.8, 0.3, 0.1]; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 33589e731e..16bf865cc5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1015,12 +1015,17 @@ impl Document { /// Pixel format for new raster layers in this document. pub fn pixel_format(&self) -> PixelFormat { - let mode = match self.mode { + Self::pixel_format_for(self.mode, self.depth) + } + + /// [`Self::pixel_format`] of a document in `mode` at `depth`. + pub fn pixel_format_for(mode: ColorMode, depth: SampleType) -> PixelFormat { + let mode = match mode { ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb, ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale, m => m, }; - PixelFormat::new(mode, self.depth, true) + PixelFormat::new(mode, depth, true) } pub fn bounds(&self) -> Rect { diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 9a43c0a467..7d46274638 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -2,16 +2,30 @@ //! and brush textures. Pure data; rendering lives in `photocraft-compose`, the PSD mapping //! (`Patt`/`Pat2`/`Pat3` global blocks) in `photocraft-io`. +use photocraft_color::SampleType; use photocraft_geom::Rect; use photocraft_raster::Surface; -/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000). +/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000). pub const MAX_EDGE: u32 = 30_000; -/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel -/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing. +/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). Larger +/// patterns paint nothing. pub const MAX_PAINT_PIXELS: u64 = 1 << 26; +/// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose` +/// checks the two agree at build time). See [`Pattern::working_bytes`]. A browser tab has far less +/// memory than a desktop process, and a failed allocation there aborts the tab, so the web build +/// holds half as much: an 8-bit pattern paints up to 33,554,432 px (about 5792²) there. +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_WORKING_BYTES: u64 = 256 << 20; +/// See the desktop value above. +#[cfg(target_arch = "wasm32")] +pub const MAX_WORKING_BYTES: u64 = 128 << 20; + +/// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel. +pub const BAND_ROWS: u32 = 256; + /// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format /// (depth and colour model are the pattern's own, as in Photoshop). #[derive(Clone, Debug, PartialEq)] @@ -36,13 +50,18 @@ impl Pattern { /// defining a pattern is deterministic and replayable). pub fn new(name: impl Into, surface: Surface, width: u32, height: u32) -> Pattern { let name = name.into(); - let r = Rect::new(0, 0, width as i32, height as i32); - let px = surface.read_region(r); let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes()); h1 = fnv(h1, &width.to_le_bytes()); h1 = fnv(h1, &height.to_le_bytes()); - for v in &px { - h1 = fnv(h1, &v.to_bits().to_le_bytes()); + // The samples row by row, a band at a time: the same sequence as one read of the whole + // tile, without a full-size `f32` copy of a large pattern. + let mut band = Vec::new(); + for top in (0..height).step_by(BAND_ROWS as usize) { + let bottom = (top + BAND_ROWS).min(height); + surface.read_region_into(Rect::new(0, top as i32, width as i32, bottom as i32), &mut band); + for v in &band { + h1 = fnv(h1, &v.to_bits().to_le_bytes()); + } } let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes()); let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); @@ -70,9 +89,61 @@ impl Pattern { u64::from(self.width) * u64::from(self.height) } - /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]). + /// Bytes of working copy the compositor holds to paint this pattern. One that fits is expanded + /// to premultiplied `f32` RGBA (16 per pixel). A pattern too large for that is held at its own + /// depth instead, straight RGBA at 4 bytes per pixel for 8 bits and 8 for 16 bits: exactly its + /// values, in a quarter or half the memory. A float one has no smaller exact form. + pub fn working_bytes(&self) -> u64 { + working_bytes_for(self.pixel_count(), self.surface.format().sample) + } + + /// Why this pattern paints nothing, for the person or agent who chose it; `None` when it + /// paints. A pattern too large for the compositor's working copy renders as transparent, so + /// every command that picks a pattern, and the import that brought it in, says so. + pub fn paint_problem(&self) -> Option { + if self.is_paintable() { + return None; + } + if self.is_empty() { + return Some("it is empty".into()); + } + let sample = self.surface.format().sample; + Some(format!( + "it is {} × {} px at {} bits per channel, and a pattern of that depth paints up to {} px in all", + self.width, + self.height, + sample.bytes() * 8, + max_paint_pixels(sample) + )) + } + + /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and + /// [`MAX_WORKING_BYTES`]). pub fn is_paintable(&self) -> bool { - !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS + !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES + } +} + +/// [`Pattern::working_bytes`] of a pattern of `pixels` pixels with `sample` samples. +pub fn working_bytes_for(pixels: u64, sample: SampleType) -> u64 { + let expanded = pixels.saturating_mul(16); + if expanded <= MAX_WORKING_BYTES { + return expanded; + } + match sample { + SampleType::U8 => pixels.saturating_mul(4), + SampleType::U16 => pixels.saturating_mul(8), + SampleType::F32 => expanded, + } +} + +/// Most pixels a pattern with `sample` samples may have and still paint: [`MAX_PAINT_PIXELS`] for +/// 8 bits, fewer where the working copy is larger per pixel. +pub fn max_paint_pixels(sample: SampleType) -> u64 { + match sample { + SampleType::U8 => MAX_PAINT_PIXELS, + SampleType::U16 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 8), + SampleType::F32 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 16), } } @@ -118,4 +189,61 @@ mod tests { assert!(find(&list, "nope", "water").is_some()); assert!(find(&list, "nope", "").is_none()); } + + /// The id is a hash of every sample in row-major order; reading a tall pattern a band of rows + /// at a time must give the id one whole read gave, or saved documents would lose their + /// pattern links. + #[test] + fn the_id_of_a_tall_pattern_matches_a_single_read_of_it() { + let (w, h) = (7u32, BAND_ROWS * 2 + 37); + let mut s = Surface::new(PixelFormat::RGBA8); + for y in 0..h as i32 { + for x in 0..w as i32 { + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[(x % 5) as f32 / 4.0, (y % 7) as f32 / 6.0, ((x + y) % 3) as f32 / 2.0, 1.0]); + } + } + let name = "tall"; + let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes()); + h1 = fnv(h1, &w.to_le_bytes()); + h1 = fnv(h1, &h.to_le_bytes()); + for v in s.read_region(Rect::new(0, 0, w as i32, h as i32)) { + h1 = fnv(h1, &v.to_bits().to_le_bytes()); + } + let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", s.format()).as_bytes()); + let expected = + format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); + assert_eq!(Pattern::new(name, s, w, h).id, expected); + } + + #[test] + fn working_bytes_keep_a_pattern_exact_in_less_memory() { + let sparse = + |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; + let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + // What fits is expanded as before. + assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 1600); + assert_eq!(sparse(float, 4096, 4096).working_bytes(), MAX_WORKING_BYTES); + // Past that, a pattern is held at its own depth. + assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).working_bytes(), 256_000_000); + assert_eq!(sparse(PixelFormat::RGBA16, 5000, 5000).working_bytes(), 200_000_000); + assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable()); + assert!(sparse(PixelFormat::RGBA16, 5792, 5792).is_paintable()); + assert!(!sparse(PixelFormat::RGBA16, 8000, 8000).is_paintable(), "512 MB at 16 bits"); + assert!(!sparse(float, 4097, 4096).is_paintable(), "float has no smaller exact form"); + assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable()); + assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable()); + } + + #[test] + fn a_pattern_that_paints_nothing_says_why() { + let sparse = + |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; + let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).paint_problem(), None); + assert_eq!(sparse(PixelFormat::RGBA8, 0, 4).paint_problem().as_deref(), Some("it is empty")); + let reason = sparse(float, 5000, 5000).paint_problem().expect("float 5000 x 5000 paints nothing"); + assert!(reason.contains("5000 × 5000") && reason.contains("32 bits") && reason.contains("16777216"), "{reason}"); + let reason = sparse(PixelFormat::RGBA16, 8000, 8000).paint_problem().expect("16-bit 8000 x 8000 paints nothing"); + assert!(reason.contains("16 bits") && reason.contains("33554432"), "{reason}"); + } } diff --git a/crates/engine/examples/bench_dry_mixer.rs b/crates/engine/examples/bench_dry_mixer.rs new file mode 100644 index 0000000000..539053022f --- /dev/null +++ b/crates/engine/examples/bench_dry_mixer.rs @@ -0,0 +1,86 @@ +//! Release benchmark for the live Mixer Brush on a 24 MP document. +//! Setup and checksum are outside the timed command. Run with +//! `cargo run --release -p photocraft-engine --example bench_dry_mixer`. + +use std::time::Instant; + +use photocraft_engine::{EngineError, Session}; +use photocraft_geom::Rect; +use serde_json::json; + +const W: i32 = 6000; +const H: i32 = 4000; +const SIZES: [u32; 3] = [32, 128, 512]; +const REPEATS: usize = 4; + +fn fingerprint(session: &Session) -> Result> { + let state = session.active().ok_or("no active document")?; + let id = state.active_layer.ok_or("no active layer")?; + let surface = state.doc.layer(id).and_then(|layer| layer.surface()).ok_or("no raster surface")?; + let pixels = surface.read_region(Rect::new(0, 0, W, H)); + let mut hash = blake3::Hasher::new(); + for channel in pixels { + hash.update(&channel.to_le_bytes()); + } + hash.update(&serde_json::to_vec(&session.tools.mixer)?); + Ok(hash.finalize().to_hex().to_string()) +} + +fn run() -> Result<(), Box> { + for wet in [0, 100] { + for sample_all in [false, true] { + for size in SIZES { + let mut session = Session::new(); + session.execute("file.new", json!({"width": W, "height": H, "depth": 8, "mode": "rgb"}))?; + session.execute("layer.new.layer", json!({}))?; + session.edit("benchmark source", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no source layer".into()))?; + let surface = doc.layer_mut(id).and_then(|layer| layer.surface_mut()).ok_or_else(|| EngineError::Other("no source pixels".into()))?; + for x in (0..W).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.25, 0.08, 1.0] } else { [0.08, 0.35, 0.9, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(W), H), &color); + } + Ok(()) + })?; + session.execute("layer.new.layer", json!({}))?; + + let params = json!({ + "points": [[1200, 2000], [4800, 2000]], + "size": size, + "spacing": 0.25, + "wet": wet, + "load": 50, + "mix": 50, + "flow": 100, + "sampleAllLayers": sample_all, + "cleanAfterStroke": true + }); + let mut timings = Vec::with_capacity(REPEATS); + let mut output = String::new(); + for repeat in 0..REPEATS { + let start = Instant::now(); + session.execute("paint.mixerBrush", params.clone())?; + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + if repeat > 0 { + timings.push(elapsed_ms); + } + output = fingerprint(&session)?; + session.execute("edit.undo", json!({}))?; + } + timings.sort_by(f64::total_cmp); + println!( + "wet={wet:3}% sample_all={sample_all:5} size={size:3} median_ms={:.2} min_ms={:.2} max_ms={:.2} blake3={output}", + timings[1], timings[0], timings[2] + ); + } + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/engine/examples/bench_selection_inverse.rs b/crates/engine/examples/bench_selection_inverse.rs new file mode 100644 index 0000000000..00f5aaa037 --- /dev/null +++ b/crates/engine/examples/bench_selection_inverse.rs @@ -0,0 +1,109 @@ +//! End-to-end timing of `select.inverse` on a 6000×4000 document. +//! +//! ```sh +//! cargo run --release -p photocraft-engine --example bench_selection_inverse -- [--quick] [--reps 11] +//! ``` +//! +//! Three rows: inverting a rectangular marquee, an elliptical marquee (central 2/3 of the +//! canvas) and `select.all`. Setup is never timed — only the `execute("select.inverse")` +//! call between `Instant`s. After the timed op (before deselect) the resulting selection +//! surface is inspected for storage diagnostics: unique tile payloads (distinct `Arc` +//! allocations behind the coverage surface) and allocated tile count. + +use std::collections::HashSet; +use std::time::Instant; + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +type Res = Result<(), Box>; + +fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a String> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)) +} + +/// Nearest-rank percentile of a pre-sorted slice. +fn pct(sorted: &[f64], p: f64) -> f64 { + let n = sorted.len(); + let idx = ((p / 100.0) * n as f64).ceil() as usize; + sorted.get(idx.saturating_sub(1).min(n.saturating_sub(1))).copied().unwrap_or(0.0) +} + +/// Selection setup for one row; each previous sample already deselected. +fn setup(s: &mut Session, w: i32, h: i32, ellipse: Option) -> Result> { + let params = match ellipse { + Some(ellipse) => { + // Central 2/3 of the canvas. + let x = w / 6; + let y = h / 6; + json!({ + "x": x, + "y": y, + "width": w.saturating_mul(2) / 3, + "height": h.saturating_mul(2) / 3, + "ellipse": ellipse, + }) + } + None => json!({}), // select.all + }; + let id = if ellipse.is_some() { "select.rect" } else { "select.all" }; + let v = s.execute(id, params)?; + Ok(v) +} + +/// Storage diagnostics of the current selection surface: distinct tile payloads and tiles held. +fn diagnostics(s: &Session) -> Result<(usize, usize), Box> { + let Some(state) = s.active() else { + return Err("no active document after select.inverse".into()); + }; + let Some(sel) = state.doc.selection.as_ref() else { + return Err("no selection after select.inverse".into()); + }; + let mut payloads: HashSet = HashSet::new(); + let mut count = 0usize; + for (_, tile) in sel.tiles() { + payloads.insert(std::sync::Arc::as_ptr(tile) as usize); + count += 1; + } + Ok((payloads.len(), count)) +} + +/// One measured sample: setup, time ONLY `select.inverse`, print diagnostics, deselect. +fn sample(s: &mut Session, w: i32, h: i32, ellipse: Option) -> Result<(f64, usize, usize), Box> { + setup(s, w, h, ellipse)?; + let t = Instant::now(); + s.execute("select.inverse", json!({}))?; + let ms = t.elapsed().as_secs_f64() * 1000.0; + let (payloads, tiles) = diagnostics(s)?; + s.execute("select.deselect", json!({}))?; + Ok((ms, payloads, tiles)) +} + +fn main() -> Res { + let args: Vec = std::env::args().collect(); + let (w, h) = if args.iter().any(|a| a == "--quick") { (1200, 800) } else { (6000, 4000) }; + let reps = arg(&args, "--reps").and_then(|v| v.parse::().ok()).unwrap_or(11).clamp(1, 100); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "transparent"}))?; + + let rows: [(&str, Option); 3] = [("Inverse rectangular", Some(false)), ("Inverse ellipse", Some(true)), ("Inverse all", None)]; + for (label, ellipse) in rows { + // Warm-up: one untimed sample to page in allocations and caches. + sample(&mut s, w, h, ellipse)?; + let mut times: Vec = Vec::with_capacity(reps); + for _ in 0..reps { + let (ms, payloads, tiles) = sample(&mut s, w, h, ellipse)?; + times.push(ms); + println!("sample: {ms:8.2} ms (selection tiles: {payloads} unique payloads, {tiles} allocated)"); + } + times.sort_by(|a, b| a.total_cmp(b)); + println!( + "{label:<22} ({w}×{h}, {reps} reps) p50 {p50:8.2} ms p95 {p95:8.2} ms max {max:8.2} ms", + p50 = pct(×, 50.0), + p95 = pct(×, 95.0), + max = pct(×, 100.0), + ); + } + Ok(()) +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1598870479..27c7ca3ce3 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -2,7 +2,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::Surface; use serde_json::{Map, Value, json}; @@ -168,7 +168,7 @@ pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, lay use ops::{arrange, merge_group, 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, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; +pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; pub fn command_specs() -> &'static [CommandSpec] { ®istry().specs @@ -365,12 +365,7 @@ fn build() -> Vec { cmd!("select.inverse", "Inverse", ["Select"], Some("Cmd+Shift+I"), "{}", has_selection, |s, _| { s.edit("Inverse", |doc, _| { let old = doc.selection.take().unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8)); - let b = doc.bounds(); - let data: Vec = old.read_region(b).into_iter().map(|v| 1.0 - v).collect(); - let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8); - m.write_region(b, &data); - m.prune(); - doc.selection = Some(m); + doc.selection = Some(photocraft_algo::selection::inverse_surface(&old, doc.bounds())); Ok(()) })?; Ok(Value::Null) @@ -419,15 +414,34 @@ fn build() -> Vec { } let old = doc.selection.take(); let combined = match (mode.as_str(), old) { - ("add", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.max(b)), - ("subtract", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a * (1.0 - b)), + ("add", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a.max(b)), + ("subtract", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a * (1.0 - b)), ("intersect", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.min(b)), // With no existing selection, neither operation can select new pixels. ("subtract" | "intersect", None) => Surface::new(photocraft_color::PixelFormat::GRAY8), _ => shape, }; - // Nothing selected on the canvas (e.g. dragged entirely outside it) deselects. - doc.selection = (!combined.content_bounds().is_empty()).then_some(combined); + // Sparse Add/Subtract keeps untouched pixels outside the canvas. Only pixels + // inside the document count when deciding whether a selection remains. + let has_on_canvas_pixels = (combined.default_bytes().first().copied().unwrap_or(0) != 0 && { + let canvas_tiles = u64::from(area.width().div_ceil(TILE_SIZE as u32)) * u64::from(area.height().div_ceil(TILE_SIZE as u32)); + let stored = combined.tiles().filter(|(coord, _)| !coord.rect().intersect(&area).is_empty()).count() as u64; + stored < canvas_tiles + }) || combined.tiles().any(|(coord, tile)| { + let tile_rect = coord.rect(); + let overlap = tile_rect.intersect(&area); + if overlap.is_empty() { + return false; + } + let x0 = (overlap.x0 - tile_rect.x0) as usize; + let x1 = (overlap.x1 - tile_rect.x0) as usize; + (overlap.y0 - tile_rect.y0..overlap.y1 - tile_rect.y0).any(|y| { + let row_start = y as usize * TILE_SIZE as usize; + let start = row_start + x0; + tile.bytes().get(start..row_start + x1).is_some_and(|row| row.iter().any(|&v| v != 0)) + }) + }); + doc.selection = has_on_canvas_pixels.then_some(combined); Ok(()) })?; Ok(Value::Null) @@ -778,13 +792,22 @@ fn build() -> Vec { "Solid Color…", ["Layer", "New Fill Layer"], None, - r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, + r##"{"color":"#rrggbb"=foreground,"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document's fill holds exactly these; default the foreground's CMYK pick),"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; + // The inks the colour was picked as, so CMYK 50/50/50/100 stays 50/50/50/100 instead + // of what its sRGB stand-in converts back to through the document profile (#8568). + let inks = match cmyk_param("layer.newFillLayer.solidColor", p, "cmyk")? { + Some(k) => Some(k), + None => s.tools.picked_cmyk([c[0], c[1], c[2]]), + }; let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { - let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); + let fill = match inks { + Some(k) if doc.mode == ColorMode::Cmyk => Fill::Solid(Color { mode: ColorMode::Cmyk, c: k, alpha: c[3] }), + _ => Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode), + }; let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); @@ -923,17 +946,7 @@ fn build() -> Vec { r##"{"foreground":"#rrggbb"|[r,g,b,a?]?,"background":same?,"foregroundCmyk":[c,m,y,k]? (inks 0..1 the foreground was picked as: a CMYK document is filled with exactly these),"backgroundCmyk":same?}"##, always, |s, p| { - let cmyk = |key: &str| -> Result> { - let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; - let inks: Option> = v - .as_array() - .filter(|a| a.len() == 4) - .and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect()); - match inks.as_deref() { - Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])), - _ => Err(bad("tools.setColors", format!("`{key}` must be [c, m, y, k] numbers 0..1"))), - } - }; + let cmyk = |key: &str| cmyk_param("tools.setColors", p, key); let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; let background = color_param("tools.setColors", p, "background", s.tools.background)?; diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 46536e4024..5467e0bdbe 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -369,8 +369,8 @@ fn stop(s: &mut Session, _p: &Value) -> Result { /// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an /// open document and nothing selected (actions often start with it to clear any selection, #6744), /// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that -/// is already on top (#8453). -fn noop_step(s: &Session, id: &str, params: &Value) -> bool { +/// is already on top (#8453). The CLI's `run` and `batch` scripts skip these too (#8802). +pub fn noop_step(s: &Session, id: &str, params: &Value) -> bool { let Some(d) = s.active() else { return false }; let dir = match id { "select.deselect" => return d.doc.selection.is_none(), diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index f501b27c40..e0f165afcd 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -291,12 +291,16 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS return Ok(l.bounds); } let sel = doc.selection.clone(); + let table = crate::mode_cmds::paint_color_table(doc, p); let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; let mut brush = brush; erase_locked(&mut brush, lock, bg); if auto_erase { + // A stroke in the table's colour starts on what the foreground painted as. + let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg)); apply_auto_erase(&mut brush, surf, pts.first(), fg, bg); } + let unpainted = table.as_ref().map(|_| surf.clone()); let damage = if let Some(axis) = &symmetry { let reflected = axis.reflected_passes(&pts); let copies: Vec<_> = reflected @@ -321,6 +325,9 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS } else { render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom) }; + if let (Some(table), Some(pre)) = (&table, &unpainted) { + crate::mode_cmds::keep_in_color_table(table, pre, surf, damage); + } Ok(damage) }) })?; @@ -600,9 +607,11 @@ impl LiveStroke { let base = (st.doc.id, st.revision); let mut doc = (*st.doc).clone(); let sel = doc.selection.clone(); + let table = crate::mode_cmds::paint_color_table(&doc, p); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?; erase_locked(&mut brush, lock, s.tools.background); if pencil && flag(p, "autoErase", false) { + let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg)); apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background); } let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom); @@ -676,7 +685,7 @@ impl LiveStroke { check_coords(pts, &self.cmd)?; self.points.extend_from_slice(pts); let cmyk = self.cmyk.clone(); - photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result { self.renderer.push(pts); if let Some(symmetry) = &self.symmetry { for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) { @@ -722,7 +731,13 @@ impl LiveStroke { dmg = dmg.union(&self.tail); } Ok(dmg) - }) + })?; + // The preview shows the pixels the commit keeps (see `stroke_with`). + if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; + crate::mode_cmds::keep_in_color_table(&table, &self.pre, surf, dmg); + } + Ok(dmg) } } diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live.rs b/crates/engine/src/cmd/brush_cmds/mixer_live.rs index 3c15001553..586479039d 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live.rs @@ -89,7 +89,7 @@ impl Core { } // Render the visible document including the active layer exactly once, before // deposition. Masks, transforms, blend modes and adjustments use the compositor. - let sample = self.settings.sample_all_layers.then(|| { + let sample = (self.settings.sample_all_layers && self.settings.samples_canvas(dab)).then(|| { let buf = photocraft_compose::render(&self.doc, r); let fmt = self.pre.format(); let mut sample = Region::new(r, fmt.channels()); diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs b/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs index d0c04c30e3..af8e754ce2 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs @@ -70,6 +70,29 @@ fn mixer_live_empty_layer_exact_commit_undo_source_preservation_and_chunking() { } } +/// A dry live stroke commits exactly its preview for either sampling setting and supports undo. +#[test] +fn dry_mixer_live_matches_commit_with_and_without_all_layer_sampling() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for sample_all in [false, true] { + let mut s = fixture(fmt); + let before = target(&s); + let p = + json!({"points":[[40,48],[80,48],[120,48]],"size":24,"wet":0,"sampleAllLayers":sample_all,"cleanAfterStroke":false,"loadAfterStroke":false}); + let live = LiveMixer::begin(&s, &p).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let bounds = s.active().unwrap().doc.bounds(); + let preview = live.doc.layer(id).unwrap().surface().unwrap().read_region(bounds); + s.execute("paint.mixerBrush", p).unwrap(); + assert_eq!(preview, target(&s).read_region(bounds)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(before.read_region(bounds), target(&s).read_region(bounds)); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(preview, target(&s).read_region(bounds)); + } + } +} + /// Current-layer mixing matches the frozen kernel; dropping a preview and cleaning the load paint nothing. #[test] fn mixer_live_current_layer_matches_reference_and_cancels() { diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index ae0a090a40..aee1ecc7e0 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -61,6 +61,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result { freed = freed.saturating_add(stages); items.push("smart filter cache"); } + let sources = crate::smart_cmds::purge_source_cache(); + if sources > 0 { + freed = freed.saturating_add(sources); + items.push("smart object source cache"); + } let strokes = photocraft_compose::shape_split::purge_cache(); if strokes > 0 { freed = freed.saturating_add(strokes); @@ -86,7 +91,11 @@ pub(super) fn can_purge_histories(s: &Session) -> std::result::Result<(), String pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> { can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| { - if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 { + if photocraft_compose::effect_cache_bytes() > 0 + || photocraft_compose::masks::cache_bytes() > 0 + || crate::smart_cmds::stack_cache_bytes() > 0 + || crate::smart_cmds::source_cache_bytes() > 0 + { Ok(()) } else { Err("nothing to purge".into()) diff --git a/crates/engine/src/cmd/layer_multi_cmds/align.rs b/crates/engine/src/cmd/layer_multi_cmds/align.rs index e7f77fc8fb..e33b11b24a 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/align.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/align.rs @@ -1,4 +1,4 @@ -use super::damage::layer_bounds; +use super::damage::align_bounds; use super::moves::{move_layers, with_links}; use super::selection::{selected, top_level}; use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json}; @@ -44,7 +44,7 @@ impl Edge { /// Selected top-level layers that have content bounds, bottom-to-top. fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> { - top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect() + top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect() } pub(super) fn align(s: &mut Session, p: &Value, kind: &str) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/damage.rs b/crates/engine/src/cmd/layer_multi_cmds/damage.rs index bad2821d5e..8440fab75b 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/damage.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/damage.rs @@ -1,3 +1,5 @@ +use photocraft_color::{PixelFormat, SampleType}; + use super::{Document, Layer, LayerContent, Rect, Session, VideoData}; /// Calls `f` with the rect of every tile that differs between two surfaces of the same format and @@ -580,8 +582,9 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) { st.last_damage = step_damage(before, &st.doc); } -/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their -/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none. +/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or +/// the union of a group's children. Fill and adjustment layers have none. Cheap: Layers list, +/// artboards, copy and slices call it for every layer. pub fn layer_bounds(l: &Layer) -> Option { let r = match &l.content { LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), @@ -589,3 +592,68 @@ pub fn layer_bounds(l: &Layer) -> Option { }; (!r.is_empty()).then_some(r) } + +/// The bounds Align and Distribute move by, in a document of bounds `canvas`: [`layer_bounds`], +/// except that a shape layer is measured over everything it draws, beyond the canvas too (its +/// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that +/// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity +/// stop measures as it renders, and `sample` is the document's sample type (see +/// [`shape_pixel_bounds`]). +pub(crate) fn align_bounds(l: &Layer, canvas: Rect, sample: SampleType) -> Option { + let r = match &l.content { + LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas, sample)).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas, sample).unwrap_or_else(|| { + let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY); + // A fill that shows everywhere covers the canvas, rendered yet or not. + if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached } + }), + _ => return layer_bounds(l), + }; + (!r.is_empty()).then_some(r) +} + +/// Whether the shape's fill shows everywhere (an inverted path, or below 100 % density), so it has +/// no extent of its own. +fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool { + sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0)) +} + +/// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and +/// stroke bounds stand in. +const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; + +/// Pixels [`shape_pixel_bounds`] renders at a time. +const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20; + +/// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the +/// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it +/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over +/// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill +/// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and +/// keeps the canvas-clipped measure (`None`). +pub(super) fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option { + if unbounded_fill(sh) { + return None; + } + let reach = photocraft_compose::shape_bounds(sh)?.inflate(1); + if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX { + return photocraft_compose::hard_shape_bounds(sh); + } + // Gradients lay out in the document's frame (as the cached render does); the feather and the + // clip cover the shape's whole reach, beyond the canvas too. + let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas); + // Alpha at the document's own precision, as the document stores it: an 8-bit render drops the + // faint stroke of a 16-bit or float document (the opaque body then aligns by a different + // extent than the document draws), and a float one would find alpha an 8-bit document never + // stores. A band of rows at a time keeps the working copy a few MB however large the shape. + let format = PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true); + let band_rows = i32::try_from((SHAPE_BOUNDS_BAND_PX / u64::from(reach.width().max(1))).max(1)).unwrap_or(i32::MAX); + let mut bounds = Rect::EMPTY; + let mut top = reach.y0; + while top < reach.y1 { + let bottom = top.saturating_add(band_rows).min(reach.y1); + bounds = bounds.union(&shape.render(format, Rect::new(reach.x0, top, reach.x1, bottom)).content_bounds()); + top = bottom; + } + (!bounds.is_empty()).then_some(bounds) +} diff --git a/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs b/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs index 3982eb9cff..f58ea7d6ae 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs @@ -242,16 +242,18 @@ fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() { assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all"); } - s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); for (cmd, params) in KINDS { + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); let (mask, vector) = create(&mut s, cmd, params, None); let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask")); assert!(!vector, "{cmd}"); assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals"); assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides"); + assert!(s.active().unwrap().doc.selection.is_none(), "{cmd}: the selection went into the mask (#8801)"); } // An explicit path wins over the selection, as with the option on. + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); for selected in [true, false] { if !selected { s.execute("select.deselect", json!({})).unwrap(); diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index a983837cc6..a3c055cf7b 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -1,3 +1,6 @@ +use photocraft_color::SampleType; + +use super::damage::{align_bounds, shape_pixel_bounds}; use super::*; #[test] @@ -636,6 +639,86 @@ fn align_each_edge() { } } +/// #8660: a shape partly off the canvas aligns by everything it draws, not the part on the +/// canvas, so one click aligns the layers and a second moves nothing. +#[test] +fn align_uses_a_shape_layers_full_extent_off_the_canvas() { + let mut s = session(8); + let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds(), SampleType::U8).unwrap(); + let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds(), SampleType::U8); + assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render"); + // Everything else keeps the cheap measure of the cached render (no re-render per layer). + assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas"); + let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50)); + select_all(&mut s, &[shape, pixels]); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50)); + assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30)); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing"); + // Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer. + s.execute("layer.align.horizontalCenters", json!({})).unwrap(); + assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30)); + assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50)); + // A feathered shape is measured by its soft edge too, which reaches past the path. + let soft = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 50, 40, 20], "fill": "#00ff00"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("feather", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { + sh.feather = 4.0; + } + Ok(()) + }) + .unwrap(); + // Measured as Align does, in the document's frame: the feather off the canvas still counts. + let b = drawn(&s, soft); + assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}"); + // A fill that shows everywhere keeps the canvas-clipped measure. + s.edit("invert", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { + sh.feather = 0.0; + sh.path.inverted = true; + } + Ok(()) + }) + .unwrap(); + let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), SampleType::U8), None); + // Without a cached render yet, such a fill still measures as the canvas, not as empty. + let mut uncached = doc(&s).layer(soft).unwrap().clone(); + if let LayerContent::Shape(sh) = &mut uncached.content { + sh.cache = None; + } + assert_eq!(align_bounds(&uncached, doc(&s).bounds(), SampleType::U8), Some(doc(&s).bounds())); +} + +/// Review of #8779: shape extents are measured at the document's own alpha precision. A faint +/// stroke (0.001 opacity) is stored by a 16-bit or float document, so it counts in the extent +/// and Align moves the opaque body as the document draws it; an 8-bit document stores it as +/// nothing, so it does not widen the measure. +#[test] +fn align_bounds_follow_the_documents_alpha_precision() { + for (depth, sample, expected) in [ + (8, SampleType::U8, Rect::new(60, 60, 120, 120)), + (16, SampleType::U16, Rect::new(50, 50, 130, 130)), + (32, SampleType::F32, Rect::new(50, 50, 130, 130)), + ] { + let mut s = session(depth); + let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [60, 60, 60, 60], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("faint stroke", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(id).map(|l| &mut l.content) { + sh.stroke = Some(photocraft_doc::ShapeStroke { width: 20.0, opacity: 0.001, ..Default::default() }); + } + Ok(()) + }) + .unwrap(); + assert_eq!(doc(&s).pixel_format().sample, sample); + let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("shape") }; + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), sample), Some(expected), "depth {depth}"); + assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}"); + } +} + #[test] fn align_single_layer_to_selection() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index dddac7ab62..f298893b33 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -63,7 +63,7 @@ pub fn blend_from_str(s: &str) -> Option { /// The active selection as the mask of a layer being created, or None without a selection. /// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on -/// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it. +/// the selected area only (#1250). [`mask_new_layer`] then drops the selection (#8801). pub(crate) fn selection_mask(doc: &Document) -> Result> { doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose() } @@ -88,9 +88,10 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool /// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the /// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its -/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). -/// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { +/// layer mask ([`selection_mask`]) and is dropped, as Reveal Selection drops it (#5251, #8801); +/// without either, a white mask reveals the whole layer (#1768). A path that isn't there is an +/// error. +pub(crate) fn mask_new_layer(doc: &mut Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -101,9 +102,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => { - l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; - } + None => match selection_mask(doc)? { + Some(mask) => { + l.mask = Some(mask); + doc.selection = None; + } + None => l.mask = default_mask.then(LayerMask::reveal_all).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?, + }, } Ok(()) } diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 413b768846..ac30023d92 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -12,7 +12,7 @@ use photocraft_algo::transform::{Homography, Interp}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Affine; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -191,9 +191,7 @@ fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> { /// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format. fn single_layer(doc: &mut Document, active: &mut Option, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) { - doc.mode = mode; - doc.depth = SampleType::U8; - let fmt = doc.pixel_format(); + let fmt = storage_format(mode); // Reuse one format-sized pixel buffer instead of allocating a Vec for every pixel. let mut pixel = vec![0.0; fmt.channels()]; let mut data = Vec::new(); @@ -201,14 +199,28 @@ fn single_layer(doc: &mut Document, active: &mut Option, mode: ColorMod photocraft_raster::from_rgba_into(&fmt, *q, &mut pixel); data.extend_from_slice(&pixel); } - let mut l = Layer::raster(name, fmt); + let mut surf = Surface::new(fmt); + surf.write_region(doc.bounds(), &data); + single_layer_surface(doc, active, mode, surf, name, background); +} + +/// The pixel format of an 8-bit document in `mode`. +fn storage_format(mode: ColorMode) -> PixelFormat { + Document::pixel_format_for(mode, SampleType::U8) +} + +/// [`single_layer`] with the layer's pixels already in a surface of [`storage_format`]`(mode)`. +fn single_layer_surface(doc: &mut Document, active: &mut Option, mode: ColorMode, pixels: Surface, name: &str, background: bool) { + doc.mode = mode; + doc.depth = SampleType::U8; + let mut l = Layer::raster(name, doc.pixel_format()); if background { l.locks.transparency = true; l.locks.position = true; } // A new raster layer always has pixels. if let Some(surf) = l.surface_mut() { - surf.write_region(doc.bounds(), &data); + *surf = pixels; } *active = Some(l.id); doc.layers = vec![l]; @@ -411,14 +423,70 @@ fn color_table(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The colour table painting on `p`'s target must stay inside: an Indexed Color document's, +/// when the target is layer pixels (masks and channels are gray, not indexed). +pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option { + let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; + doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed) +} + +/// The colour `c` paints as in a document with `table` (see [`keep_in_color_table`]). +pub(crate) fn table_color(table: &ColorTable, c: [f32; 4]) -> [f32; 4] { + let mut px = [c]; + if !table.colors.is_empty() { + quantize::quantize(&mut px, 1, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + } + let [c] = px; + c +} + +/// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest +/// colour-table entry, as the Indexed Color conversion does with no dither, so painting never +/// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas. +pub(crate) fn keep_in_color_table(table: &ColorTable, pre: &Surface, surf: &mut Surface, r: Rect) { + if table.colors.is_empty() { + return; + } + let fmt = surf.format(); + let n = fmt.channels(); + let mut enc = [0.0f32; 8]; + for tc in r.tiles() { + let t = tc.rect().intersect(&r); + if t.is_empty() || !surf.has_tiles_in(t) { + continue; + } + let (before, mut data) = (pre.read_region(t), surf.read_region(t)); + let (mut changed, mut px) = (Vec::new(), Vec::new()); + for (i, (q, b)) in data.chunks_exact(n).zip(before.chunks_exact(n)).enumerate() { + if q != b { + changed.push(i); + px.push(photocraft_raster::to_rgba(&fmt, q)); + } + } + if px.is_empty() { + continue; + } + let w = px.len(); + quantize::quantize(&mut px, w, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + let mut mapped = changed.into_iter().zip(px).peekable(); + for (i, q) in data.chunks_exact_mut(n).enumerate() { + if let Some((_, c)) = mapped.next_if(|(j, _)| *j == i) { + photocraft_raster::from_rgba_into(&fmt, c, &mut enc); + q.iter_mut().zip(enc).for_each(|(d, v)| *d = v); + } + } + surf.write_region(t, &data); + } +} + // ---------- Bitmap ---------- /// Bitmap's Output resolution range (ppi): what a new document may have. const BITMAP_RESOLUTION: std::ops::RangeInclusive = 1.0..=crate::document_preset_cmds::MAX_RESOLUTION; -/// Bytes per pixel of the Bitmap conversion's full-canvas working buffers at their peak (see -/// `bitmap`), on top of the document itself. -const BITMAP_WORK_BYTES_PER_PX: u64 = 40; +/// Bytes per pixel the Bitmap conversion holds besides the document: its 8-bit gray + alpha +/// result. The composite is converted in bands of [`photocraft_compose::BAND_PIXELS`] (#8477). +const BITMAP_WORK_BYTES_PER_PX: u64 = 2; /// Bitmap's Custom Pattern: a pattern's threshold map (see /// [`quantize::custom_pattern_thresholds`]) with its width and height. @@ -463,12 +531,9 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { }; (side(ow)?, side(oh)?) }; - if (nw, nh) != (ow, oh) { - // Besides the resampled document, the conversion holds an RGBA f32 composite (16 B/px; 32 - // while it is laid over white), the gray plane (4) and the RGBA f32 output (16) at the new - // size: budget them all before allocating, not just the resample. - crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?; - } + // Besides the (resampled) document, the conversion holds its result at the new size: budget + // both before allocating. + crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?; let dpi = output as f32; let mut custom = None; let method = match str_or(p, "method", "diffusion") { @@ -504,15 +569,33 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic); } doc.resolution_dpi = dpi; - let px = composite(doc, true); - let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); let w = doc.size.width as usize; - match &custom { - Some((map, mw, mh)) => quantize::to_bitmap_custom(&mut gray, w, map, *mw, *mh), - None => quantize::to_bitmap(&mut gray, w, method), - } - let out: Vec<[f32; 4]> = gray.iter().map(|g| [*g, *g, *g, 1.0]).collect(); - single_layer(doc, active, ColorMode::Bitmap, &out, "Background", true); + let mut strips = match &custom { + Some((map, mw, mh)) => quantize::BitmapStrips::custom(w, map, *mw, *mh), + None => quantize::BitmapStrips::new(w, method), + }; + // Band by band, top to bottom: a full-canvas f32 composite of a 30000 px square image is + // 14 GB, and allocating it (and its copies) aborted the app (#8477). + let fmt = storage_format(ColorMode::Bitmap); + let n = fmt.channels(); + let mut out = Surface::new(fmt); + let (mut gray, mut data) = (Vec::new(), Vec::new()); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), std::convert::Infallible> { + gray.clear(); + gray.extend(band.px.iter().map(|q| { + // Over white, as the flattened image. + let a = q[3]; + photocraft_color::convert::rgb_to_gray([q[0] * a + (1.0 - a), q[1] * a + (1.0 - a), q[2] * a + (1.0 - a)]) + })); + strips.convert(&mut gray); + data.resize(gray.len() * n, 0.0); + for (g, px) in gray.iter().zip(data.chunks_exact_mut(n)) { + photocraft_raster::from_rgba_into(&fmt, [*g, *g, *g, 1.0], px); + } + out.write_region(band.rect, &data); + Ok(()) + }); + single_layer_surface(doc, active, ColorMode::Bitmap, out, "Background", true); Ok(()) })?; let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 21836ff5ae..0d3f9b3cea 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -525,15 +525,13 @@ fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() { #[test] fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() { - // 100 x 100 px at 1 ppi to 200 ppi is 20000 x 20000 px: 400 MP. Resampling an 8/16-bit gray - // document to it fits the resample budget (under 2 GB), but the conversion's f32 working - // buffers at that size (~40 B/px, 16 GB) don't: it is refused before anything is allocated. + // 100 x 100 px at 1 ppi to 1000 ppi is 100000 x 100000 px: 10 gigapixels. The resampled + // document and the conversion's 8-bit result at that size are refused before anything is + // allocated. for depth in [8, 16] { let mut s = ramp(100, 100, depth, 1.0); - let d = &s.active().unwrap().doc; - assert!(crate::image_cmds::check_resample_budget("t", d, 20_000, 20_000, "").is_ok(), "{depth}"); let before = s.active().unwrap().history.past_len(); - let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 200})).unwrap_err().to_string(); + let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 1000})).unwrap_err().to_string(); assert!(e.contains("megapixels") && e.contains("Output resolution"), "{depth}: {e}"); assert_eq!(s.active().unwrap().history.past_len(), before, "{depth}"); let d = doc(&s); @@ -554,6 +552,108 @@ fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() } } +#[test] +fn bitmap_budgets_a_conversion_at_the_document_resolution_too() { + // #8477: without a resample nothing was budgeted, and a huge document's full-size f32 + // working buffers aborted the app. 60000 x 60000 px (3.6 gigapixels, a plain white + // background shares one tile) needs more than the 8 GiB budget at any depth: refused. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60_000, "height": 60_000, "mode": "grayscale"})).unwrap(); + let before = s.active().unwrap().history.past_len(); + let e = s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap_err().to_string(); + assert!(e.contains("megapixels"), "{e}"); + assert_eq!(s.active().unwrap().history.past_len(), before); + assert_eq!(doc(&s).mode, ColorMode::Grayscale); +} + +/// The Bitmap conversion of `d` the pre-#8477 way, from one full-canvas composite over white. +fn bitmap_oracle(d: &Document, method: Option, custom: Option<(&[f32], usize, usize)>) -> Vec { + let px = photocraft_compose::flatten(d).over_background([1.0; 3]).px; + let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); + let w = d.size.width as usize; + match (method, custom) { + (_, Some((map, mw, mh))) => quantize::to_bitmap_custom(&mut gray, w, map, mw, mh), + (Some(m), None) => quantize::to_bitmap(&mut gray, w, m), + (None, None) => {} + } + gray +} + +fn bitmap_pixels(d: &Document) -> Vec { + let surf = d.layers[0].surface().unwrap(); + let mut out = Vec::new(); + for y in 0..d.size.height as i32 { + for x in 0..d.size.width as i32 { + let p = surf.pixel(x, y); + assert_eq!(p[1], 1.0, "({x}, {y}) is opaque"); + out.push(p[0]); + } + } + out +} + +#[test] +fn bitmap_in_bands_matches_one_full_canvas_conversion() { + // #8477: the composite is converted in bands (32768 px wide: 256 rows each, so 300 rows are + // two). Diffusion carries its error across the band edge, and pattern, halftone and custom + // pattern stay anchored to the image's rows: the result equals one full-canvas conversion. + let (w, h) = (32_768u32, 300u32); + let mut s = session(w, h, 8, "grayscale"); + paint(&mut s, |x, y| { + let v = ((x % 509) as f32 / 509.0 * 0.6 + (y as f32 / 300.0) * 0.4).fract(); + [v, v, v, 1.0] + }); + add_pattern(&mut s, "Cells", 3, 2, &[10, 200, 90, 250, 150, 40]); + let cases = [ + ("threshold", None, Some(BitmapMethod::Threshold)), + ("pattern", None, Some(BitmapMethod::Pattern)), + ("diffusion", None, Some(BitmapMethod::Diffusion)), + ("halftone", Some(53.0), None), + ("customPattern", None, None), + ]; + for (method, freq, m) in cases { + let before = doc(&s).clone(); + let mut p = json!({"method": method}); + if let Some(f) = freq { + p["frequency"] = json!(f); + p["angle"] = json!(30); + p["shape"] = json!("diamond"); + } + if method == "customPattern" { + p["pattern"] = json!("Cells"); + } + s.execute("image.mode.bitmap", p.clone()).unwrap(); + let want = match method { + "halftone" => { + let cell = (before.resolution_dpi / 53.0).max(2.0); + bitmap_oracle(&before, Some(BitmapMethod::Halftone { cell, angle: 30.0, shape: HalftoneShape::Diamond }), None) + } + "customPattern" => { + let (map, mw, mh) = custom_pattern_map(&s, "t", &p).unwrap(); + bitmap_oracle(&before, None, Some((&map, mw, mh))) + } + _ => bitmap_oracle(&before, m, None), + }; + // Grayscale stores 0/1 the way `from_rgba` turns the old RGBA output into gray. + let want: Vec = want.iter().map(|g| photocraft_raster::from_rgba(&storage_format(ColorMode::Bitmap), [*g, *g, *g, 1.0])[0]).collect(); + let got = bitmap_pixels(doc(&s)); + let diff = got.iter().zip(&want).filter(|(a, b)| (**a - **b).abs() > 1e-6).count(); + assert_eq!(diff, 0, "{method}: {diff} of {} pixels differ", got.len()); + s.execute("edit.undo", json!({})).unwrap(); + } +} + +#[test] +fn bitmap_lays_a_transparent_composite_over_white() { + // Transparent pixels flatten over white before the conversion: an empty canvas is white, + // 25%-opaque black is light (75% gray) and thresholds to white, 75%-opaque black to black. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 3, "height": 1, "mode": "grayscale", "background": "transparent"})).unwrap(); + paint(&mut s, |x, _| [0.0, 0.0, 0.0, [0.0, 0.25, 0.75][x as usize]]); + s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap(); + assert_eq!(bitmap_pixels(doc(&s)), [1.0, 1.0, 0.0]); +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); @@ -749,3 +849,95 @@ fn color_table_rejects_an_invalid_transparent_index_without_editing() { s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap(); assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0)); } + +/// A white 16×16 document in Indexed Color with the two-entry black/white table (#8669's repro). +fn black_white_indexed(transparency: bool) -> Session { + let mut s = Session::new(); + let background = if transparency { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 16, "height": 16, "background": background})).unwrap(); + paint(&mut s, |x, _| { + if x < 2 { + [0.0; 4] + } else if x < 4 { + [0.0, 0.0, 0.0, 1.0] + } else { + [1.0; 4] + } + }); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "transparency": transparency, "dither": "none"})).unwrap(); + s +} + +#[test] +fn brush_stroke_stays_in_indexed_color_table() { + let mut s = black_white_indexed(false); + assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[0, 0, 0], [255, 255, 255]]); + s.execute("paint.stroke", json!({"points": [[8, 8, 1]], "size": 8, "color": "#ff0000", "opacity": 1, "flow": 1})).unwrap(); + // Red's nearest entry is black. + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); + // Soft edges and partial opacity blend, and the blends map back onto the table too. + s.execute("paint.stroke", json!({"points": [[4, 12], [14, 12]], "size": 6, "hardness": 0.0, "color": "#808080", "opacity": 0.5})).unwrap(); + let table = doc(&s).color_table.clone().unwrap(); + assert!(colours(doc(&s)).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(doc(&s))); + assert_eq!(doc(&s).mode, ColorMode::Indexed); +} + +#[test] +fn brush_stroke_keeps_indexed_transparency_binary() { + let mut s = black_white_indexed(true); + let table = doc(&s).color_table.clone().unwrap(); + assert!(table.transparent.is_some()); + s.execute("paint.stroke", json!({"points": [[0, 8], [15, 8]], "size": 8, "hardness": 0.0, "color": "#ff0000", "opacity": 0.7})).unwrap(); + let d = doc(&s); + let surf = d.layers[0].surface().unwrap(); + for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) { + let a = surf.rgba(x, y)[3]; + assert!(a == 0.0 || a == 1.0, "alpha {a} at ({x},{y})"); + } + assert!(colours(d).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(d)); +} + +#[test] +fn live_brush_preview_matches_indexed_commit() { + let mut s = black_white_indexed(false); + let params = json!({"points": [[4, 8], [12, 8]], "size": 7, "hardness": 0.0, "color": "#ff0000", "opacity": 0.6, "smoothing": 0.0, "seed": 1}); + let preview = crate::brush_cmds::LiveStroke::begin(&s, ¶ms).unwrap(); + let table = doc(&s).color_table.clone().unwrap(); + assert!(colours(&preview.doc).iter().all(|c| table.colors.contains(c)), "preview off the table: {:?}", colours(&preview.doc)); + s.execute("paint.stroke", params).unwrap(); + let (live, committed) = (preview.doc.layers[0].surface().unwrap(), doc(&s).layers[0].surface().unwrap()); + for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) { + assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})"); + } +} + +#[test] +fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() { + let mut s = black_white_indexed(false); + // An off-table pixel inside the stroke's bounds but away from its dabs (Paint Bucket can + // still leave one) is not the stroke's to remap. + s.edit("setup", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let px = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]); + surf.write_region(Rect::new(13, 2, 14, 3), &px); + Ok(()) + }) + .unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 2], [14, 14]], "size": 2, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap(); + let surf = doc(&s).layers[0].surface().unwrap(); + assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn pencil_auto_erase_knows_the_indexed_foreground() { + let mut s = black_white_indexed(false); + let pencil = json!({"points": [[8, 8]], "size": 3, "color": "#ff0000", "autoErase": true}); + s.execute("paint.pencil", pencil.clone()).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); + // Red painted as black, so a stroke starting there auto-erases to the (white) background. + let preview = crate::brush_cmds::LiveStroke::begin_with(&s, "paint.pencil", &pencil).unwrap(); + assert_eq!(preview.doc.layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]); + s.execute("paint.pencil", pencil).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]); +} diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 7d78bacccc..f5d644dae7 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -69,6 +69,19 @@ pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { check(cmd, value, 0) } +/// `p[key]` as CMYK inks 0..1 (`[c, m, y, k]`), the values a colour was picked as, or `None` +/// when absent or null. A CMYK document stores exactly these instead of what the colour's sRGB +/// stand-in converts back to (#4196, #8568). +pub(crate) fn cmyk_param(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let inks: Option> = + v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect()); + match inks.as_deref() { + Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])), + _ => Err(bad(cmd, format!("`{key}` must be [c, m, y, k] numbers 0..1"))), + } +} + pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { diff --git a/crates/engine/src/cmd/pattern_cmds.rs b/crates/engine/src/cmd/pattern_cmds.rs index 4bc972556a..447e4c8dc7 100644 --- a/crates/engine/src/cmd/pattern_cmds.rs +++ b/crates/engine/src/cmd/pattern_cmds.rs @@ -149,7 +149,17 @@ pub fn resolve(s: &Session, key: &str) -> Option { pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result { let key = p.get("pattern").and_then(Value::as_str).unwrap_or(""); - resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))) + let pat = resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))?; + paintable(cmd, pat) +} + +/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as +/// transparent, so a command that would use one says so up front. +fn paintable(cmd: &str, pat: Pattern) -> Result { + match pat.paint_problem() { + Some(why) => Err(bad(cmd, format!("pattern \"{}\" would paint nothing: {why}", pat.display_name()))), + None => Ok(pat), + } } /// Copies `pat` into the document's patterns unless it is already there (by id). @@ -168,6 +178,7 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => { let key = if id.is_empty() { name } else { id }; let pat = resolve(s, &key).ok_or_else(|| bad("layer.layerStyle.patternOverlay", format!("no pattern \"{key}\" (see pattern.list)")))?; + let pat = paintable("layer.layerStyle.patternOverlay", pat)?; Ok((Effect::PatternOverlay { common, name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase }, Some(pat))) } other => Ok((other, None)), @@ -180,6 +191,7 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value { "id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height, "mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha, "inDocument": in_doc, "inLibrary": in_library, + "paintable": p.is_paintable(), "paintProblem": p.paint_problem(), }) } @@ -190,19 +202,45 @@ fn list(s: &mut Session, _: &Value) -> Result { Ok(Value::Array(out)) } -/// The visible composite over `r` as a surface in the document's pixel format (with alpha). -fn composite_surface(doc: &Document, r: Rect) -> Surface { - let buf = photocraft_compose::render(doc, r); +/// Pixels composited at a time by [`composite_surface`]: a band's render and conversion buffers +/// stay a few tens of MB however large the pattern. +const BAND_PIXELS: u32 = 1 << 20; + +/// The visible composite over `r` as a surface in the document's pixel format (with alpha), and +/// whether every pixel of it is opaque. +fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) { + composite_surface_in_bands(doc, r, (BAND_PIXELS / r.width().max(1)).max(1)) +} + +/// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a +/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full +/// `f32` copy) from holding several full-size copies at once. Opacity is judged on the stored +/// samples, as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as 255. +fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) { let df = doc.pixel_format(); let fmt = PixelFormat::new(df.mode, df.sample, true); let n = fmt.channels(); - let mut px = vec![0.0f32; buf.px.len() * n]; - for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { - photocraft_raster::from_rgba_into(&fmt, *p, o); - } + let (width, height) = (r.width(), r.height()); let mut s = Surface::new(fmt); - s.write_region(Rect::new(0, 0, r.width() as i32, r.height() as i32), &px); - s + let mut opaque = true; + let mut px = Vec::new(); + let mut top = 0; + while top < height { + let rows = band_rows.min(height - top); + let buf = photocraft_compose::render(doc, Rect::new(r.x0, r.y0 + top as i32, r.x1, r.y0 + (top + rows) as i32)); + px.clear(); + px.resize(buf.px.len() * n, 0.0f32); + for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { + photocraft_raster::from_rgba_into(&fmt, *p, o); + } + let band = Rect::new(0, top as i32, width as i32, (top + rows) as i32); + s.write_region(band, &px); + if opaque { + opaque = s.read_region(band).chunks_exact(n).all(|q| q[n - 1] >= 1.0); + } + top += rows; + } + (s, opaque) } /// The name Define Pattern gives a pattern without one: the active document's name without its @@ -219,6 +257,11 @@ pub fn default_name(s: &Session) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base) } +/// Longest edge Define Pattern accepts. Its area is limited too, to what the compositor holds +/// (`Pattern::working_bytes`): 8000 x 8000 at 8 bits, and fewer pixels at 16 bits and float, +/// where the working copy is larger per pixel. What is accepted always paints. +const MAX_DEFINE_EDGE: u32 = 8000; + fn define(s: &mut Session, p: &Value) -> Result { let cmd = "edit.definePattern"; let st = s.active().ok_or(EngineError::NoDocument)?; @@ -234,14 +277,20 @@ fn define(s: &mut Session, p: &Value) -> Result { if r.is_empty() { return Err(bad(cmd, "the area is empty")); } - if r.width() > 4000 || r.height() > 4000 { - return Err(bad(cmd, "patterns are limited to 4000 × 4000 px")); + if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE { + return Err(bad(cmd, "patterns are limited to 8000 × 8000 px")); } - let mut surf = composite_surface(doc, r); + // A pattern the compositor could not hold would define but paint nothing: 8 bits go to the + // edge limit, 16-bit documents to 33,554,432 px in all, float to 16,777,216 (4096 × 4096). + let sample = doc.pixel_format().sample; + let max_px = photocraft_doc::pattern::max_paint_pixels(sample); + if u64::from(r.width()) * u64::from(r.height()) > max_px { + return Err(bad(cmd, format!("patterns in a {}-bit document are limited to {max_px} px in all", sample.bytes() * 8))); + } + let (mut surf, opaque) = composite_surface(doc, r); // Opaque patterns drop their alpha channel (as Photoshop does). - let fmt = surf.format(); - let px = surf.read_region(Rect::new(0, 0, r.width() as i32, r.height() as i32)); - if px.chunks_exact(fmt.channels()).all(|q| q[fmt.channels() - 1] >= 1.0) { + if opaque { + let fmt = surf.format(); surf = surf.convert(PixelFormat::new(fmt.mode, fmt.sample, false)); } let name = match p.get("name").and_then(Value::as_str) { @@ -369,15 +418,15 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result { return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint")); }; // Brush textures are grey: luminance of the composite over white. - let mut px = vec![[0.0f32; 4]; n]; - pat.surface.read_rgba_into(pat.rect(), &mut px); - let lum: Vec = px - .iter() - .map(|q| { + // Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel. + let mut lum: Vec = Vec::new(); + lum.try_reserve_exact(n).map_err(|_| bad("brush.texturePattern", "not enough memory for the pattern"))?; + for_each_band(&pat, pat.width, pat.height, |band| { + lum.extend(band.iter().map(|q| { let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2]; l * q[3] + (1.0 - q[3]) - }) - .collect(); + })); + }); let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum); let t = &mut s.tools.brush.texture; t.pattern = photocraft_paint::Pattern::Tile(tile); @@ -393,13 +442,25 @@ pub fn average_color(p: &Pattern) -> Color { // A pattern too large to paint is averaged over its paintable top-left corner. let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32; let (w, h) = (p.width.min(side), p.height.min(side)); - let mut px = vec![[0.0f32; 4]; w as usize * h as usize]; - p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px); - let n = px.len().max(1) as f32; - let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]); + let mut s = [0.0f32; 3]; + for_each_band(p, w, h, |band| s = band.iter().fold(s, |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]])); + let n = (u64::from(w) * u64::from(h)).max(1) as f32; Color::rgb(s[0] / n, s[1] / n, s[2] / n) } +/// Calls `f` with the straight RGBA pixels of the top-left `w × h` corner of `p`, a band of rows +/// at a time in row-major order. +fn for_each_band(p: &Pattern, w: u32, h: u32, mut f: impl FnMut(&[[f32; 4]])) { + let mut band = Vec::new(); + for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h); + band.clear(); + band.resize(w as usize * (bottom - top) as usize, [0.0f32; 4]); + p.surface.read_rgba_into(Rect::new(0, top as i32, w as i32, bottom as i32), &mut band); + f(&band); + } +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -551,6 +612,128 @@ mod tests { } } + /// Review of the 8000 px cap: compositing a band of rows at a time gives the pixels and the + /// opacity of one pass, at every depth, whatever the band height. + #[test] + fn banded_compositing_matches_a_single_pass() { + for depth in [8u64, 16, 32] { + for transparent in [false, true] { + let mut s = Session::new(); + let background = if transparent { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": background})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 4, "width": 20, "height": 13})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let doc = &s.active().unwrap().doc; + let area = Rect::new(2, 1, 38, 29); + let all = Rect::new(0, 0, 36, 28); + let (whole, whole_opaque) = composite_surface_in_bands(doc, area, 28); + for band_rows in [1, 5, 27] { + let (banded, opaque) = composite_surface_in_bands(doc, area, band_rows); + assert_eq!(opaque, whole_opaque, "depth {depth}, transparent {transparent}, bands of {band_rows}"); + assert_eq!(banded.read_region(all), whole.read_region(all), "depth {depth}, transparent {transparent}, bands of {band_rows}"); + } + assert_eq!(whole_opaque, !transparent); + } + } + } + + /// Review of the banded composite: opacity is the stored samples', so an alpha an 8-bit + /// document stores as 255 is opaque and a float document's 0.999 is not. + #[test] + fn alpha_that_stores_as_opaque_counts_as_opaque() { + for (depth, opaque) in [(8u64, true), (32, false)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.999})).unwrap(); + let (_, all_opaque) = composite_surface_in_bands(&s.active().unwrap().doc, Rect::new(0, 0, 8, 8), 3); + assert_eq!(all_opaque, opaque, "depth {depth}"); + } + } + + /// Review of #8902: what Define Pattern accepts, the compositor paints. Areas over + /// 4096 x 4096, past the compositor's whole-copy cap, define and fill at 8 and 16 bits (held + /// at the pattern's own depth); a depth that has no smaller exact form is refused up front + /// with its limit, instead of defining a pattern that paints nothing. + #[test] + fn defined_patterns_over_4096_squared_paint_or_are_refused() { + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": depth})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); + assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable()), "depth {depth}"); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "depth {depth}: {:?}", [r, g, b, a]); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 32})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("32-bit") && e.contains("16777216"), "{e}"); + assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "nothing was defined"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 6000, "height": 5600, "depth": 16})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("16-bit") && e.contains("33554432"), "{e}"); + } + + /// A pattern of the largest area Define Pattern accepts at 8 bits is paintable: held at 4 + /// bytes a pixel, 256 MB, as the compositor's cap allows, and it renders. + #[test] + fn an_8000_by_8000_pattern_is_defined_and_paints() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap(); + let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap(); + assert_eq!((p.width, p.height), (8000, 8000)); + assert!(p.is_paintable() && p.working_bytes() == 256_000_000); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Largest"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]); + } + + /// Review of #8902: a pattern too large to paint is named by every command that would use it + /// and by pattern.list, instead of rendering as transparency with no message. + #[test] + fn a_pattern_that_would_paint_nothing_is_reported() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + // A 32-bit 5000 x 5000 pattern (declared only: 400 MB as a working copy, past the cap). + let surface = photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA32F); + s.patterns.items.push(Pattern { id: "huge".into(), name: "Huge".into(), width: 5000, height: 5000, surface }); + let list = s.execute("pattern.list", json!({})).unwrap(); + let huge = list.as_array().unwrap().iter().find(|p| p["name"] == "Huge").unwrap().clone(); + assert_eq!(huge["paintable"], json!(false)); + assert!(huge["paintProblem"].as_str().unwrap().contains("32 bits"), "{huge}"); + for (id, params) in [ + ("layer.newFillLayer.pattern", json!({"pattern": "Huge"})), + ("layer.layerStyle.patternOverlay", json!({"pattern": "Huge"})), + ("edit.fill", json!({"contents": "pattern", "pattern": "Huge"})), + ("brush.texturePattern", json!({"pattern": "Huge"})), + ] { + let e = s.execute(id, params).expect_err(id).to_string(); + assert!(e.contains("Huge") && e.contains("would paint nothing"), "{id}: {e}"); + } + let e = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Huge"})).unwrap_err().to_string(); + assert!(e.contains("would paint nothing") && e.contains("5000 × 5000"), "{e}"); + assert!(list.as_array().unwrap().iter().any(|p| p["name"] != "Huge" && p["paintable"] == json!(true)), "built-in patterns paint"); + } + + #[test] + fn define_pattern_accepts_edges_over_4000_up_to_8000() { + for (w, ok) in [(4100, true), (8000, true), (8001, false)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": 2, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let r = s.execute("edit.definePattern", json!({})); + assert_eq!(r.is_ok(), ok, "width {w}: {r:?}"); + } + } + #[test] fn pattern_fill_layer_renders_at_every_depth_and_undoes() { for depth in [8u64, 16, 32] { @@ -642,6 +825,6 @@ mod tests { let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) }; s.patterns.items.push(huge); let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err(); - assert!(e.to_string().contains("too large"), "{e}"); + assert!(e.to_string().contains("would paint nothing") && e.to_string().contains("65536 × 65536"), "{e}"); } } diff --git a/crates/engine/src/cmd/print_cmds.rs b/crates/engine/src/cmd/print_cmds.rs index 37b4e78e92..ab8f32b8b8 100644 --- a/crates/engine/src/cmd/print_cmds.rs +++ b/crates/engine/src/cmd/print_cmds.rs @@ -250,8 +250,8 @@ enum SpoolError { /// Sends the job and says where it went and through what. A named print server takes the IPP /// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the -/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had -/// no route either. A route that exists but fails (an unknown printer, a refused job) is an +/// Windows spooler) when it is there, or the app's spooler when CUPS lists no printer for `lp`; +/// else CUPS over IPP; and the app's spooler when that had no route either. A route that exists but fails (an unknown printer, a refused job) is an /// error, never a fall-through. fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> { let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other); @@ -261,7 +261,19 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - } if let Some(name) = system_spooler() { require_windows_pdf()?; - return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other); + // lpstat is CUPS, not a Windows driver, so it runs outside SPOOLER. A listing that + // can't finish in time can't tell, so lp gets the job as it did before the check. + let route = match name { + "lp" => lp_printers(LP_LISTING_TIMEOUT).map_or_else(|| Some(lp.clone()), |list| lp_route(lp, &list)), + _ => Some(lp.clone()), + }; + return match (route, spooler) { + (Some(lp), _) => with_spooler(|| craft_print::submit(&job.pdf, &lp)).map(|m| (m, name.to_string())).map_err(other), + // CUPS lists no printer. IPP would ask the same CUPS, or (it ignores client.conf) + // another server whose printer the user never chose, so only the portal is left. + (None, Some(sp)) => app(sp), + (None, None) => Err(other("no printers found; set one up in the system's printer settings, or print to PDF")), + }; } } match cups_spool(job) { @@ -274,6 +286,78 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - } } +/// How long a print job waits for CUPS' printer list (`lpstat`, whose `-e` browses the network). +const LP_LISTING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The most a printer listing may print; a longer one counts as no answer. +const LP_LISTING_CAP: u64 = 1 << 20; + +/// CUPS' printers as `craft_print::printers` lists them, or `None` when that can't be told: an +/// `lpstat` can't start, outlasts `timeout`, prints more than `LP_LISTING_CAP`, or `lpstat -e` +/// fails (CUPS unreachable, or listing forbidden by policy). `lpstat -p -d`'s own status is not +/// a failure: it exits 1 whenever there are no queues, as on a host with only driverless +/// printers. Each `lpstat` is killed and reaped at the deadline, so a hung one never outlives +/// the job. +fn lp_printers(timeout: std::time::Duration) -> Option> { + let deadline = std::time::Instant::now() + timeout; + let (_, queues) = output_by(craft_print::lpstat_command(), deadline)?; + // `lpstat -e`, as craft-print runs it (its builder is private): the driverless printers. + let mut driverless = std::process::Command::new("lpstat"); + driverless.arg("-e").env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "Craft"); + let (listed, available) = output_by(driverless, deadline)?; + listed.then(|| craft_print::printers_parsed(&queues, Some(&available))) +} + +/// Whether `command` succeeded, and its standard output; `None` when it can't start, prints more +/// than `LP_LISTING_CAP`, or is still running at `deadline`, when it is killed and reaped. The +/// output is read on its own thread, so a full pipe can't stall the wait, and that thread ends +/// when the process does. +fn output_by(mut command: std::process::Command, deadline: std::time::Instant) -> Option<(bool, String)> { + use std::io::Read as _; + use std::process::Stdio; + let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let mut stdout = child.stdout.take()?; + let (sender, receiver) = std::sync::mpsc::channel(); + let reader = std::thread::Builder::new().name("print-listing".into()).spawn(move || { + let mut out = Vec::new(); + // One byte over the cap tells an oversized listing apart; stopping closes the pipe. + let _ = sender.send((&mut stdout).take(LP_LISTING_CAP + 1).read_to_end(&mut out).map(|_| out)); + }); + let status = reader.ok().and_then(|_| { + loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)), + _ => break None, + } + } + }); + let Some(status) = status else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let out = receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())).ok()?.ok()?; + let fits = u64::try_from(out.len()).is_ok_and(|n| n <= LP_LISTING_CAP); + fits.then(|| (status.success(), String::from_utf8_lossy(&out).into_owned())) +} + +/// The job for `lp`, or `None` when no printer was asked for and CUPS lists none: `lp` has +/// nowhere to send it, and the portal still may (a Flatpak bundles `lp`). A printer asked for by +/// name always goes to `lp`, which knows it even where CUPS won't list it, and the portal could +/// not honour the name. With no printer asked for, no default and one printer, that printer, as +/// `resolve_printer` picks for IPP. +fn lp_route(lp: &craft_print::Job, printers: &[craft_print::Printer]) -> Option { + let mut lp = lp.clone(); + if lp.printer.is_none() + && !printers.iter().any(|p| p.default) + && let [only] = printers + { + lp.printer = Some(only.name.clone()); + } + (lp.printer.is_some() || !printers.is_empty()).then_some(lp) +} + /// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command /// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the /// browser. diff --git a/crates/engine/src/cmd/print_cmds/tests.rs b/crates/engine/src/cmd/print_cmds/tests.rs index 6208b53ec5..177d1305ba 100644 --- a/crates/engine/src/cmd/print_cmds/tests.rs +++ b/crates/engine/src/cmd/print_cmds/tests.rs @@ -678,6 +678,14 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n", ).unwrap(); std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + // `lp` takes a job only when CUPS lists a printer (`lp_route`), so the fake CUPS lists its queues. + let lpstat = dir.path().join("lpstat"); + std::fs::write( + &lpstat, + b"#!/bin/sh\nif [ \"$1\" = -e ]; then printf 'Office\\nRefuse\\n'; else printf 'printer Office is idle.\\nprinter Refuse is idle.\\n'; fi\n", + ) + .unwrap(); + std::fs::set_permissions(&lpstat, std::fs::Permissions::from_mode(0o700)).unwrap(); let result = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "cmd::print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) .env(CHILD, dir.path()) @@ -972,6 +980,50 @@ fn a_preferred_app_spooler_takes_the_job() { assert!(std::path::Path::new(&out).exists()); } +#[cfg(unix)] +#[test] +fn a_printer_listing_past_its_deadline_is_killed_and_reaped() { + let soon = || std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut echo = std::process::Command::new("sh"); + echo.args(["-c", "echo printer Office is idle."]); + assert_eq!(output_by(echo, soon()), Some((true, "printer Office is idle.\n".to_string()))); + let mut failed = std::process::Command::new("sh"); + failed.args(["-c", "exit 1"]); + assert_eq!(output_by(failed, soon()), Some((false, String::new())), "a failed listing is told apart from an empty one"); + let mut flood = std::process::Command::new("sh"); + flood.args(["-c", "head -c 2000000 /dev/zero"]); + assert_eq!(output_by(flood, soon()), None, "an oversized listing is no answer"); + // A hung lpstat: given up at the deadline, and killed, so nothing is left waiting on it. + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let mut hung = std::process::Command::new("sh"); + hung.arg("-c").arg(format!("echo $$ > '{}'; exec sleep 30", pid_file.display())); + let started = std::time::Instant::now(); + assert_eq!(output_by(hung, started + std::time::Duration::from_millis(300)), None); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "the deadline must cut the wait short"); + let pid = std::fs::read_to_string(&pid_file).unwrap(); + // `kill -0` fails for a process that no longer exists, on every Unix. + let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).stderr(std::process::Stdio::null()).status().unwrap().success(); + assert!(!alive, "the hung process must be killed and reaped"); + assert_eq!(output_by(std::process::Command::new("/nonexistent/lpstat"), soon()), None); +} + +#[test] +fn lp_takes_the_job_only_when_cups_lists_a_printer_and_picks_the_only_one() { + let p = |name: &str, default: bool| craft_print::Printer { name: name.into(), default, two_sided: None, description: String::new() }; + let job = craft_print::Job::default(); + // No printers: no lp route, so the portal still gets the job. + assert!(lp_route(&job, &[]).is_none()); + assert_eq!(lp_route(&job, &[p("Office", false)]).and_then(|j| j.printer), Some("Office".into())); + assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", true)]).and_then(|j| j.printer), None, "lp picks the default itself"); + assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", false)]).and_then(|j| j.printer), None); + let asked = craft_print::Job { printer: Some("Lab".into()), ..Default::default() }; + assert_eq!(lp_route(&asked, &[p("Office", false)]).and_then(|j| j.printer), Some("Lab".into())); + // A named printer goes to lp even when CUPS lists none: listing can be forbidden while + // printing is not, and the portal can't honour a name. + assert_eq!(lp_route(&asked, &[]).and_then(|j| j.printer), Some("Lab".into())); +} + #[cfg(not(target_os = "windows"))] mod cups { use super::*; diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 362e889fa9..e8d206285e 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -13,6 +13,11 @@ use crate::{EngineError, Result, Session}; mod modify_window; +#[cfg(test)] +mod color_range_performance; +#[cfg(test)] +mod color_range_window_tests; + fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } @@ -45,15 +50,18 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM } /// RGBA pixels the tools sample: the composite, or the active layer. -fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { +fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; - let area = d.doc.bounds(); + let area = window.map_or(d.doc.bounds(), |r| r.intersect(&d.doc.bounds())); // Match the decoders' raster budget before either the layer or composite path allocates. let len = area.size().area(); let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); } + if area.is_empty() { + return Ok((area, Vec::new())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { @@ -149,31 +157,6 @@ fn parse_color(p: &Value) -> [f32; 3] { } } -/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point -/// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { - let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; - let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; - let w = area.width() as usize; - let mut out = Vec::with_capacity(pts.len().min(4096)); - for pt in pts { - let xy = pt.as_array().and_then(|a| match a.as_slice() { - [x, y] => Some((x.as_f64()?, y.as_f64()?)), - _ => None, - }); - let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else { - return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])"))); - }; - let (xi, yi) = (x.floor() as i32, y.floor() as i32); - let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None }; - let Some(&[r, g, b, _]) = q else { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); - }; - out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); - } - Ok(out) -} - /// Composite RGB (the compositor's output profile for the document) → CIE Lab D50. fn lab_transform(s: &Session) -> Result> { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -346,6 +329,23 @@ impl ColorRangeSamples { } } +/// Replace refines the existing selection; no output outside its nonzero bounds survives. +fn color_range_area(doc: &Document, m: SelectionMode) -> Rect { + let canvas = doc.bounds(); + let Some(selection) = doc.selection.as_ref().filter(|_| m == SelectionMode::Replace) else { return canvas }; + // content_bounds describes differences from the default, not implicit selected pixels. + if selection.default_pixel().first().is_some_and(|v| *v != 0.0) { + return canvas; + } + // content_bounds orders raw tile coordinates by their differences. Reject coordinates + // outside the pixel-addressable range before those differences can overflow. + let valid = (i32::MIN / photocraft_geom::TILE_SIZE)..=(i32::MAX / photocraft_geom::TILE_SIZE); + if selection.tiles().any(|(tile, _)| !valid.contains(&tile.tx) || !valid.contains(&tile.ty)) { + return canvas; + } + selection.content_bounds().intersect(&canvas) +} + fn color_range(s: &mut Session, p: &Value) -> Result { const CMD: &str = "select.colorRange"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -357,17 +357,16 @@ fn color_range(s: &mut Session, p: &Value) -> Result { if b(p, "detectFaces", false) { return Err(bad("detectFaces: face detection isn't available yet; select without it".into())); } + let area = color_range_area(&s.active().ok_or(EngineError::NoDocument)?.doc, selection_mode); let (area, mut mask) = match preset { "sampledColors" => { - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. // Like Photoshop's, they may sample anywhere on the canvas: an active selection only // limits the result (scope_color_range), not where colours are picked (#5244). - let plus = eyedropper_samples(p, "points", area, &px, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; - let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; - let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); + let query = ColorRangeSamples::new(s, p)?; + let mask = query.coverage(&px, w, 1.0, [area.x0 as f32, area.y0 as f32]); (area, mask) } "reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => { @@ -379,11 +378,11 @@ fn color_range(s: &mut Session, p: &Value) -> Result { "blues" => 240.0, _ => 300.0, }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::hue_range(&px, center)) } "skinTones" => { - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::skin_tones(&px, fuzz(40.0).min(200.0))) } "highlights" | "midtones" | "shadows" => { @@ -406,7 +405,7 @@ fn color_range(s: &mut Session, p: &Value) -> Result { .filter(|(l, h)| l <= h) .ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?, }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0)) } "outOfGamut" => { @@ -458,16 +457,16 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result { if canvas.size().area() > MAX_MODIFY_PIXELS as u64 { return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() }); } + // Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach at most ceil(r). + let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize }; // Keep both mask conversion and tiled replacement within the affected region. The // zero halo makes internal window edges inert; nonzero edges can only be canvas edges, // so the existing Apply effect at canvas bounds padding still has the same meaning. - let area = if matches!(op, "expand" | "contract" | "border") { modify_window::area(doc, r) } else { canvas }; + let area = modify_window::area(doc, reach as f32); let m = sel::mask_from_surface(doc.selection.as_ref(), area); let (w, h) = (area.width() as usize, area.height() as usize); - // Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach r. A margin past that - // keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both - // readings agree there. - let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize }; + // A margin past the reach keeps the padded edge out of reach. A selection clear of the + // canvas edge needs none: both readings agree there. let pad = if touches_edge(&m, w, h) { reach.saturating_add(2) } else { 0 }; let grown = |n: usize| pad.checked_mul(2).and_then(|p| n.checked_add(p)); let (Some(pw), Some(ph)) = (grown(w), grown(h)) else { @@ -545,7 +544,7 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec { /// the selected pixels' colour range widened by the tolerance. 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))?; + 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) { @@ -656,16 +655,7 @@ fn polygon_rows(pts: &[(f32, f32)], area: Rect) -> Rect { /// whole-canvas combine gives: Replace and Intersect are empty outside the band, and Add and /// Subtract keep the old selection there. pub(crate) fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: SelectionMode) -> Option { - let inside = sel::combine(old, mask, band, m); - let Some(old) = old.filter(|_| matches!(m, SelectionMode::Add | SelectionMode::Subtract)) else { return inside }; - if band.is_empty() { - return Some(old.clone()); - } - let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |w| w.read_region(band)); - let mut s = old.clone(); - s.write_region(band, &values); - s.prune(); - (s.tile_count() > 0).then_some(s) + sel::combine_band(old, mask, band, m) } /// Keep dense feather masks local to the drawn shape. Clip before inflating: pixels outside diff --git a/crates/engine/src/cmd/selection_cmds/color_range_performance.rs b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs new file mode 100644 index 0000000000..639b7af4c6 --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs @@ -0,0 +1,70 @@ +//! Opt-in release comparison; full-canvas buffers belong on a CI runner. +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Layer, LayerContent, Size}; +use std::time::Instant; + +fn reference(s: &mut Session, p: &Value) { + let (canvas, px) = sample_pixels(s, true, None).unwrap(); + // Previous path: eyedropper colour came from the full composite already in memory. + let [r, g, b, _] = px[1500 * canvas.width() as usize + 2000]; + let point = sel::RangeSample { color: [r, g, b], at: Some((2000.0, 1500.0)) }; + let query = ColorRangeSamples::with_samples(s, p, canvas, &[point], &[]).unwrap(); + let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [0.0, 0.0]); + drop(px); + scope_color_range(&mut mask, &s.active().unwrap().doc, canvas, SelectionMode::Replace); + set_selection(s, "Color Range", canvas, mask, SelectionMode::Replace).unwrap(); +} + +#[test] +#[ignore = "24 MP release comparison: run on GitHub Actions"] +fn color_range_release_comparison() { + let mut doc = Document::new("Color Range performance", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8); + let mut pixels = Surface::with_default(PixelFormat::RGBA8, &[0.65, 0.3, 0.2, 1.0]); + pixels.fill_rect(Rect::new(3000, 2000, 3600, 2400), &[0.2, 0.3, 0.65, 1.0]); + doc.layers.push(Layer::new("image", LayerContent::Raster(pixels))); + let p = json!({"points": [[2000, 1500]], "fuzziness": 40, "localized": true, "range": 80}); + // Small, 1%, 4%, and full selections: include dense fallback cost in the comparison. + for bounds in [Rect::new(2975, 1975, 3025, 2025), Rect::new(2700, 1800, 3300, 2200), Rect::new(2400, 1600, 3600, 2400), doc.bounds()] { + let mut selection = Surface::new(PixelFormat::GRAY8); + selection.fill_rect(bounds, &[128.0 / 255.0]); + doc.selection = Some(selection); + let mut old_times = Vec::new(); + let mut new_times = Vec::new(); + let mut expected = None; + // One warmup pair, followed by three alternating measured pairs. + for round in 0..4 { + for old in if round % 2 == 0 { [true, false] } else { [false, true] } { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + let now = Instant::now(); + if old { + reference(&mut session, &p); + } else { + session.execute("select.colorRange", p.clone()).unwrap(); + } + let elapsed = now.elapsed().as_secs_f64() * 1000.0; + if round > 0 { + if old { old_times.push(elapsed) } else { new_times.push(elapsed) } + } + // Outside timing, compare every stored sample over the whole canvas. + let actual = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()); + if let Some(ref expected) = expected { + assert_eq!(&actual, expected); + } else { + expected = Some(actual); + } + } + } + old_times.sort_by(f64::total_cmp); + new_times.sort_by(f64::total_cmp); + let (old, new) = (old_times[1], new_times[1]); + println!( + "24 MP Color Range {}x{}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x rgba_bytes={} exact=true", + bounds.width(), + bounds.height(), + old / new, + bounds.size().area() * 16 + ); + } +} diff --git a/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs new file mode 100644 index 0000000000..d14ecc7e31 --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs @@ -0,0 +1,211 @@ +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Size; +use serde_json::json; + +fn fixture(depth: SampleType, p3: bool) -> Session { + let mut s = Session::new(); + let mut doc = Document::with_background("color range", Size::new(64, 48), ColorMode::Rgb, depth, photocraft_doc::Color::WHITE); + if p3 { + doc.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()); + } else { + doc.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); + } + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(0, 0, 64, 48), &[0.92, 0.92, 0.92, 1.0]); + bg.fill_rect(Rect::new(7, 8, 22, 23), &[0.85, 0.12, 0.08, 1.0]); + bg.fill_rect(Rect::new(35, 8, 50, 23), &[0.12, 0.72, 0.18, 1.0]); + bg.fill_rect(Rect::new(7, 29, 22, 44), &[0.12, 0.20, 0.86, 1.0]); + bg.fill_rect(Rect::new(35, 29, 50, 44), &[0.72, 0.48, 0.31, 1.0]); + s.add_document(doc, None); + s +} + +fn coverage_at(s: &Session, x: i32, y: i32) -> f32 { + s.active().and_then(|d| d.doc.selection.as_ref()).map_or(0.0, |m| m.sample_channel(x, y, 0)) +} + +fn full_sampled_reference(s: &Session, params: &Value) -> Vec { + let doc = &s.active().unwrap().doc; + let canvas = doc.bounds(); + let (_, px) = sample_pixels(s, b(params, "sampleAllLayers", true), None).unwrap(); + // Reproduce the previous command: read eyedropper colours from the full composite. + let samples = |key: &str| -> Vec { + params + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|point| { + let x = point[0].as_f64().unwrap().floor() as usize; + let y = point[1].as_f64().unwrap().floor() as usize; + let pixel = px[y * canvas.width() as usize + x]; + sel::RangeSample { color: [pixel[0], pixel[1], pixel[2]], at: Some((x as f32, y as f32)) } + }) + .collect() + }; + let query = ColorRangeSamples::with_samples(s, params, canvas, &samples("points"), &samples("subtractPoints")).unwrap(); + let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [canvas.x0 as f32, canvas.y0 as f32]); + if b(params, "invert", false) { + mask.iter_mut().for_each(|v| *v = 1.0 - *v); + } + scope_color_range(&mut mask, doc, canvas, mode(params)); + sel::mask_from_surface(sel::combine(doc.selection.as_ref(), &mask, canvas, mode(params)).as_ref(), canvas) +} + +#[test] +fn sampled_color_cropped_results_match_full_canvas_for_depths_profiles_and_sample_options() { + for (depth, p3) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|depth| [false, true].map(|p3| (depth, p3))) { + for params in [ + json!({"points": [[40, 15]], "fuzziness": 12}), + json!({"points": [[40, 15]], "subtractPoints": [[10, 15]], "order": ["-", "+"], "fuzziness": 18}), + json!({"points": [[40, 15]], "localized": true, "range": 80, "invert": true}), + json!({"points": [[10, 15]], "localized": true, "range": 25}), + json!({"points": [[40, 15]], "sampleAllLayers": false, "fuzziness": 0}), + ] { + let mut s = fixture(depth, p3); + s.execute("select.rect", json!({"x": 3, "y": 5, "width": 22, "height": 25})).unwrap(); + let expected = full_sampled_reference(&s, ¶ms); + s.execute("select.colorRange", params.clone()).unwrap(); + let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()); + assert_eq!(actual, expected, "depth={depth:?} p3={p3} params={params}"); + } + } +} + +#[test] +fn range_presets_are_scoped_and_non_replace_modes_keep_full_canvas_semantics() { + for preset in ["reds", "yellows", "greens", "cyans", "blues", "magentas", "highlights", "midtones", "shadows", "skinTones"] { + let mut s = fixture(SampleType::U8, false); + s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap(); + s.execute("select.colorRange", json!({"select": preset})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 0.0, "preset {preset} escaped selection"); + } + let mut s = fixture(SampleType::U8, false); + s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap(); + s.execute("select.colorRange", json!({"points": [[40, 15]], "fuzziness": 0, "mode": "add"})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 0.0); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 1.0); +} + +#[test] +fn window_defaults_and_hostile_tiles_fall_back_and_empty_selection_is_safe() { + let canvas = Rect::new(0, 0, 64, 48); + let mut doc = Document::new("window", Size::new(64, 48), ColorMode::Rgb, SampleType::U8); + doc.selection = Some(Surface::with_default(PixelFormat::GRAY8, &[1.0])); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas); + doc.selection = Some(Surface::new(PixelFormat::GRAY8)); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), Rect::new(0, 0, 0, 0)); + use photocraft_geom::TileCoord; + use std::sync::Arc; + let mut hostile = Surface::new(PixelFormat::GRAY8); + let tile = hostile.solid_tile(&[1.0]); + hostile.put_tiles([(TileCoord::new(i32::MIN, i32::MIN), Arc::clone(&tile)), (TileCoord::new(i32::MAX, i32::MAX), tile)]); + doc.selection = Some(hostile); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas); + assert_eq!(color_range_area(&doc, SelectionMode::Add), canvas); + doc.selection = Some(Surface::new(PixelFormat::GRAY8)); + let mut s = Session::new(); + s.add_document(doc, None); + assert!(sample_pixels(&s, true, Some(color_range_area(&s.active().unwrap().doc, SelectionMode::Replace))).unwrap().1.is_empty()); + let err = s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": -1})).unwrap_err(); + assert!(err.to_string().contains("tonalRange")); + for params in [json!({"points": [[64, 0]]}), json!({"points": "invalid"}), json!({"localized": true}), json!({"order": ["+"]})] { + assert!(s.execute("select.colorRange", params).is_err()); + } + s.execute("select.colorRange", json!({"points": [[1, 1]], "invert": true})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn sparse_large_selection_samples_only_its_clipped_window() { + let mut doc = Document::new("sparse", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8); + let selection = sel::mask_to_surface(&vec![1.0; 24 * 20], Rect::new(2990, 1990, 3014, 2010)); + doc.selection = Some(selection); + let mut s = Session::new(); + s.add_document(doc, None); + let win = color_range_area(&s.active().unwrap().doc, SelectionMode::Replace); + assert_eq!(win, Rect::new(2990, 1990, 3014, 2010)); + let (area, pixels) = sample_pixels(&s, true, Some(win)).unwrap(); + assert_eq!(pixels.len(), area.size().area() as usize); + s.execute("select.colorRange", json!({"select": "highlights"})).unwrap(); +} + +#[test] +fn cropped_range_matches_full_canvas_combinations_with_soft_holes_and_clipped_masks() { + let canvas = Rect::new(0, 0, 64, 48); + for case in 0..6 { + for selection_mode in ["replace", "add", "subtract", "intersect"] { + for preset in ["sampledColors", "reds", "greens", "blues", "highlights", "midtones", "shadows", "skinTones"] { + for invert in [false, true] { + let mut s = fixture(SampleType::U16, true); + let mut selection = match case { + 0 => Surface::new(PixelFormat::GRAY8), + 1 => Surface::with_default(PixelFormat::GRAY8, &[128.0 / 255.0]), + _ => sel::mask_to_surface(&vec![128.0 / 255.0; 30 * 30], Rect::new(-5, 5, 25, 35)), + }; + if case == 3 { + selection.fill_rect(Rect::new(8, 10, 15, 20), &[0.0]); + } + if case == 5 { + selection = Surface::new(PixelFormat::GRAY8); + } + if case == 4 { + selection = sel::mask_to_surface(&[1.0; 4], Rect::new(-8, -8, -6, -6)); + } + let original = (case != 0).then_some(selection); + let mut prior = s.active().unwrap().doc.as_ref().clone(); + prior.selection = original; + let before = sel::mask_from_surface(prior.selection.as_ref(), canvas); + let mut unselected = prior.clone(); + unselected.selection = None; + let mut full = Session::new(); + full.add_document(unselected, None); + let params = json!({"select": preset, "points": [[40, 15]], "fuzziness": 40, "invert": invert}); + full.execute("select.colorRange", params.clone()).unwrap(); + let mut mask = sel::mask_from_surface(full.active().unwrap().doc.selection.as_ref(), canvas); + let m = SelectionMode::parse(selection_mode); + scope_color_range(&mut mask, &prior, canvas, m); + let expected = sel::combine(prior.selection.as_ref(), &mask, canvas, m); + s.edit("prior selection", |doc, _| { + doc.selection = prior.selection.clone(); + Ok(()) + }) + .unwrap(); + let mut params = params; + params["mode"] = json!(selection_mode); + s.execute("select.colorRange", params).unwrap(); + let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas); + assert_eq!(actual, sel::mask_from_surface(expected.as_ref(), canvas), "case {case}, {selection_mode}, {preset}, invert {invert}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), actual); + } + } + } + } +} + +#[test] +fn cropped_samples_match_full_composite_with_translucent_layers_and_active_layer_sampling() { + use photocraft_doc::{Layer, LayerContent}; + for all_layers in [false, true] { + let mut s = fixture(SampleType::F32, true); + s.edit("overlay", |doc, _| { + let mut pixels = Surface::new(PixelFormat::RGBA32F); + pixels.fill_rect(Rect::new(0, 0, 64, 48), &[0.3, 0.1, 0.6, 0.35]); + doc.layers.push(Layer::new("overlay", LayerContent::Raster(pixels))); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 6, "width": 20, "height": 30})).unwrap(); + let params = json!({"points": [[40, 15], [10, 15]], "subtractPoints": [[40, 35]], "sampleAllLayers": all_layers, "localized": true, "range": 70}); + let expected = full_sampled_reference(&s, ¶ms); + s.execute("select.colorRange", params).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()), expected); + } +} diff --git a/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs b/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs index 4f8fe8fce6..f26cd4c739 100644 --- a/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs @@ -17,13 +17,17 @@ fn full_canvas_reference(doc: &Document, op: &str, radius: f32, at_bounds: bool) let canvas = doc.bounds(); let m = sel::mask_from_surface(doc.selection.as_ref(), canvas); let (w, h) = (canvas.width() as usize, canvas.height() as usize); - let pad = if touches_edge(&m, w, h) { radius.ceil() as usize + 2 } else { 0 }; + let reach = if op == "feather" { sel::feather_reach(radius) } else { radius.ceil() as usize }; + let pad = if touches_edge(&m, w, h) { reach + 2 } else { 0 }; let (pw, ph) = (w + 2 * pad, h + 2 * pad); let padded = if pad == 0 { m } else { pad_mask(&m, w, h, pad, op == "border" || at_bounds) }; let out = match op { "expand" => sel::expand(&padded, pw, ph, radius), "contract" => sel::contract(&padded, pw, ph, radius), - _ => sel::border(&padded, pw, ph, radius), + "border" => sel::border(&padded, pw, ph, radius), + "smooth" => sel::smooth(&padded, pw, ph, radius), + "feather" => sel::feather(&padded, pw, ph, radius), + _ => panic!("unknown modify operation: {op}"), }; let out = if pad == 0 { out } else { crop_mask(&out, pw, pad, w, h) }; sel::combine(None, &out, canvas, SelectionMode::Replace) @@ -36,9 +40,11 @@ fn sparse_36mp_command_only_materializes_the_selection_halo() { let doc = document(6000, 6000, selection); assert_eq!(area(&doc, 5.0), Rect::new(2994, 2994, 3056, 3056)); assert_eq!(area(&doc, 5.0).size().area(), 62 * 62); - let mut session = Session::new(); - session.add_document(doc, None); - for op in ["expand", "contract", "border"] { + assert_eq!(area(&doc, sel::feather_reach(5.0) as f32), Rect::new(2984, 2984, 3066, 3066)); + assert_eq!(area(&doc, sel::feather_reach(5.0) as f32).size().area(), 82 * 82); + for op in ["expand", "contract", "border", "smooth", "feather"] { + let mut session = Session::new(); + session.add_document(doc.clone(), None); session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap(); assert!(session.active().unwrap().doc.selection.is_some()); } @@ -71,8 +77,11 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() { selection.write_pixel(-10, -10, &[1.0]); } let doc = document(75, 61, selection); - for radius in [1.0, 5.0, 9.5] { - for op in ["expand", "contract", "border"] { + for radius in [0.1, 1.0, 1.5, 5.0, 9.5, 25.0] { + for op in ["expand", "contract", "border", "smooth", "feather"] { + if radius < 1.0 && op != "feather" { + continue; + } for at_bounds in [false, true] { let expected = full_canvas_reference(&doc, op, radius, at_bounds); let mut session = Session::new(); @@ -91,21 +100,79 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() { } } +#[test] +fn cropped_blurs_preserve_soft_masks_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat { sample: depth, ..PixelFormat::GRAY8 }; + for default in [0.0, 0.25] { + let mut selection = Surface::with_default(format, &[default]); + for y in 21..39 { + for x in 28..47 { + selection.write_pixel(x, y, &[((x * 13 + y * 7) % 17) as f32 / 16.0]); + } + } + let mut doc = Document::new("soft modify", Size::new(83, 67), ColorMode::Rgb, depth); + doc.selection = Some(selection); + for op in ["smooth", "feather"] { + for radius in [1.0, 2.5, 9.5] { + for at_bounds in [false, true] { + let expected = full_canvas_reference(&doc, op, radius, at_bounds); + let mut session = Session::new(); + session.add_document(doc.clone(), None); + session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap(); + assert_eq!( + sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), + sel::mask_from_surface(expected.as_ref(), doc.bounds()), + "{depth:?} default {default} {op} radius {radius} bounds {at_bounds}" + ); + } + } + } + } + } +} + +#[test] +fn cropped_blurs_preserve_single_pixel_and_thin_canvases() { + for (w, h) in [(1, 1), (1, 19), (23, 1)] { + let mut selection = Surface::new(PixelFormat::GRAY8); + selection.write_pixel(w as i32 / 2, h as i32 / 2, &[1.0]); + let doc = document(w, h, selection); + for op in ["smooth", "feather"] { + for radius in [1.0, 9.5] { + for at_bounds in [false, true] { + let expected = full_canvas_reference(&doc, op, radius, at_bounds); + let mut session = Session::new(); + session.add_document(doc.clone(), None); + session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap(); + assert_eq!( + sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), + sel::mask_from_surface(expected.as_ref(), doc.bounds()), + "{w}x{h} {op} radius {radius} bounds {at_bounds}" + ); + } + } + } + } +} + #[test] fn cropped_replacement_keeps_undo_and_redo() { let canvas = Rect::new(0, 0, 75, 61); let selection = sel::mask_to_surface(&vec![1.0; 10 * 10], Rect::new(30, 25, 40, 35)); let doc = document(75, 61, selection); let before = sel::mask_from_surface(doc.selection.as_ref(), canvas); - let mut session = Session::new(); - session.add_document(doc, None); - session.execute("select.modify.expand", json!({"radius": 5})).unwrap(); - let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas); - assert_ne!(before, after); - session.execute("edit.undo", json!({})).unwrap(); - assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before); - session.execute("edit.redo", json!({})).unwrap(); - assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after); + for op in ["expand", "smooth", "feather"] { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap(); + let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas); + assert_ne!(before, after, "{op}"); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before, "{op}"); + session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after, "{op}"); + } } /// Isolate command-window savings: both paths use the optimized algorithm kernels, while @@ -114,43 +181,46 @@ fn cropped_replacement_keeps_undo_and_redo() { #[ignore = "36 MP release benchmark; run on GitHub Actions"] fn sparse_modify_release_comparison() { use std::time::Instant; - let selection = sel::mask_to_surface(&vec![1.0; 50 * 50], Rect::new(3000, 3000, 3050, 3050)); - let doc = document(6000, 6000, selection); - for radius in [3.0, 50.0] { - for op in ["expand", "contract", "border"] { - let reference = full_canvas_reference(&doc, op, radius, false); - let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds()); - let mut old_times = Vec::new(); - let mut new_times = Vec::new(); - for round in 0..3 { - for old in if round % 2 == 0 { [true, false] } else { [false, true] } { - let mut session = Session::new(); - session.add_document(doc.clone(), None); - let now = Instant::now(); - if old { - let result = full_canvas_reference(&doc, op, radius, false); - session - .edit("Modify selection", |doc, _| { - doc.selection = result; - Ok(()) - }) - .unwrap(); - } else { - session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap(); + for (kind, bounds) in [("sparse", Rect::new(3000, 3000, 3050, 3050)), ("dense", Rect::new(1000, 1000, 5000, 5000)), ("full", Rect::new(0, 0, 6000, 6000))] { + let selection = sel::mask_to_surface(&vec![1.0; bounds.size().area() as usize], bounds); + let doc = document(6000, 6000, selection); + let ops: &[&str] = if kind == "sparse" { &["expand", "contract", "border", "smooth", "feather"] } else { &["smooth", "feather"] }; + for radius in [3.0, 50.0] { + for &op in ops { + let reference = full_canvas_reference(&doc, op, radius, false); + let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds()); + let mut old_times = Vec::new(); + let mut new_times = Vec::new(); + for round in 0..3 { + for old in if round % 2 == 0 { [true, false] } else { [false, true] } { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + let now = Instant::now(); + if old { + let result = full_canvas_reference(&doc, op, radius, false); + session + .edit("Modify selection", |doc, _| { + doc.selection = result; + Ok(()) + }) + .unwrap(); + } else { + session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap(); + } + let elapsed = now.elapsed().as_secs_f64() * 1000.0; + if old { + old_times.push(elapsed) + } else { + new_times.push(elapsed) + } + assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected); } - let elapsed = now.elapsed().as_secs_f64() * 1000.0; - if old { - old_times.push(elapsed) - } else { - new_times.push(elapsed) - } - assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected); } + old_times.sort_by(f64::total_cmp); + new_times.sort_by(f64::total_cmp); + let (old, new) = (old_times[1], new_times[1]); + println!("36 MP {kind} command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new); } - old_times.sort_by(f64::total_cmp); - new_times.sort_by(f64::total_cmp); - let (old, new) = (old_times[1], new_times[1]); - println!("36 MP command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new); } } } @@ -165,7 +235,7 @@ fn corrupt_tile_coordinates_do_not_panic_while_finding_the_window() { let doc = document(75, 61, selection); // Without the guard, content_bounds overflows while sorting these coordinates. assert_eq!(area(&doc, 5.0), doc.bounds()); - for op in ["expand", "contract", "border"] { + for op in ["expand", "contract", "border", "smooth", "feather"] { let mut session = Session::new(); session.add_document(doc.clone(), None); session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap(); diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs index 0b28681172..4cb1b62cbe 100644 --- a/crates/engine/src/cmd/selection_cmds/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/tests.rs @@ -60,7 +60,7 @@ fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { None, ); for all_layers in [false, true] { - assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + assert!(sample_pixels(&s, all_layers, None).unwrap_err().to_string().contains("too large to sample")); } assert!(!s.active().unwrap().history.can_undo()); } @@ -275,7 +275,7 @@ fn color_range_prepared_query_samples_visible_layers_not_the_active_layer() { clear_selection(&mut s); let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0}); let query = ColorRangeSamples::new(&s, &p).unwrap(); - let (area, px) = sample_pixels(&s, all_layers).unwrap(); + let (area, px) = sample_pixels(&s, all_layers, None).unwrap(); let shown = query.coverage(&px, area.width() as usize, 1.0, [0.0, 0.0]); s.execute("select.colorRange", p).unwrap(); assert_eq!(shown[7 * 40 + 7], coverage(&s, 7, 7)); diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index f19813e3ab..30425d04b4 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -149,7 +149,7 @@ pub(crate) use rendering::source_box; pub use rendering::{apply_smart_filters, purge_stack_cache, refresh, refresh_layer, render, render_below_filter, stack_cache_bytes, stack_image}; #[cfg(test)] use rendering::{blend_surfaces, place, render_in}; -pub use source::{SourceImage, source_bytes, source_decode_count, source_image, svg_source_image}; +pub use source::{SourceImage, purge_source_cache, source_bytes, source_cache_bytes, source_decode_count, source_image, svg_source_image}; use source::{authorize_source, authorized_source_bytes}; pub(crate) use source::{restore_unrendered, take_caches}; diff --git a/crates/engine/src/cmd/smart_cmds/contents.rs b/crates/engine/src/cmd/smart_cmds/contents.rs index 07a009bf0e..cce8b7002a 100644 --- a/crates/engine/src/cmd/smart_cmds/contents.rs +++ b/crates/engine/src/cmd/smart_cmds/contents.rs @@ -177,6 +177,11 @@ pub(crate) fn on_close(s: &mut Session, index: usize) { let _ = commit_child(s, index); } s.smart_links.retain(|l| l.child != id && l.parent != id); + // Decoded sources are shared by content hash across documents, so the closing document's + // can't be told apart: drop them all. Other documents decode again on their next re-render. + if s.docs.get(index).is_some_and(|st| st.doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)))) { + super::purge_source_cache(); + } } pub(super) fn is_smart_child(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index a5dd8e8f1b..614c0ef20e 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -1,5 +1,5 @@ use super::filters::smart; -use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface, svg_source_image, vector_scale}; +use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface_from_buffer, svg_source_image, vector_scale}; 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, @@ -47,7 +47,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc photocraft_doc::StackMode::Variance => Stat::Variance, }; let buf = photocraft_compose::Buffer { rect: bounds, px: photocraft_algo::stack::combine(&frames, stat) }; - let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds }; + let img = SourceImage { surface: Arc::new(source_surface_from_buffer(&doc, &buf, fmt)?), bounds }; cache_put(key, img.clone()); Ok(img) } diff --git a/crates/engine/src/cmd/smart_cmds/source.rs b/crates/engine/src/cmd/smart_cmds/source.rs index d7d895b28d..4c37d585cd 100644 --- a/crates/engine/src/cmd/smart_cmds/source.rs +++ b/crates/engine/src/cmd/smart_cmds/source.rs @@ -1,3 +1,7 @@ +use std::sync::PoisonError; + +use photocraft_geom::TILE_SIZE; + use super::{ Arc, ColorMode, Document, Layer, LayerContent, LayerId, Metadata, Mutex, PixelFormat, Rect, Result, SampleType, Session, SmartObject, SmartSource, Surface, base_name, decode_source, json, other, @@ -72,22 +76,35 @@ struct CacheEntry { key: CacheKey, image: SourceImage, decodes: u32, + /// The surface's real tile bytes, counted once when it is cached. + bytes: usize, } /// Decoded source composites by content hash, most recent last. Re-filtering or re-transforming /// a smart object never decodes its contents again. static SOURCE_CACHE: Mutex> = Mutex::new(Vec::new()); +#[cfg(not(target_arch = "wasm32"))] const CACHE_ENTRIES: usize = 32; +#[cfg(target_arch = "wasm32")] +const CACHE_ENTRIES: usize = 4; +/// Accounted tile bytes; a source larger than this on its own isn't kept. +#[cfg(not(target_arch = "wasm32"))] const CACHE_BYTES: usize = 1 << 30; +#[cfg(target_arch = "wasm32")] +const CACHE_BYTES: usize = 384 << 20; + +fn cache() -> std::sync::MutexGuard<'static, Vec> { + SOURCE_CACHE.lock().unwrap_or_else(PoisonError::into_inner) +} pub(super) fn cache_key(bytes: &[u8], fmt: PixelFormat) -> CacheKey { (*blake3::hash(bytes).as_bytes(), fmt) } pub(super) fn cache_get(key: &CacheKey) -> Option { - let mut c = SOURCE_CACHE.lock().ok()?; + let mut c = cache(); let i = c.iter().position(|e| e.key == *key)?; let e = c.remove(i); let img = e.image.clone(); @@ -96,14 +113,22 @@ pub(super) fn cache_get(key: &CacheKey) -> Option { } pub(super) fn cache_put(key: CacheKey, image: SourceImage) { - let Ok(mut c) = SOURCE_CACHE.lock() else { return }; + cache_put_within(key, image, CACHE_BYTES); +} + +/// [`cache_put`] with `limit` as the byte budget. +fn cache_put_within(key: CacheKey, image: SourceImage, limit: usize) { + let bytes = super::stack_cache::surface_bytes(&image.surface); + let mut c = cache(); let decodes = match c.iter().position(|e| e.key == key) { Some(i) => c.remove(i).decodes + 1, None => 1, }; - c.push(CacheEntry { key, image, decodes }); - let size = |e: &CacheEntry| e.image.bounds.width() as usize * e.image.bounds.height() as usize * e.key.1.channels() * 4; - while c.len() > CACHE_ENTRIES || (c.len() > 1 && c.iter().map(size).sum::() > CACHE_BYTES) { + if bytes > limit { + return; + } + c.push(CacheEntry { key, image, decodes, bytes }); + while c.len() > CACHE_ENTRIES || c.iter().map(|e| e.bytes).sum::() > limit { c.remove(0); } } @@ -111,7 +136,21 @@ pub(super) fn cache_put(key: CacheKey, image: SourceImage) { /// How many times these contents were decoded at this pixel format (0 = never / evicted). pub fn source_decode_count(bytes: &[u8], fmt: PixelFormat) -> u32 { let key = cache_key(bytes, fmt); - SOURCE_CACHE.lock().ok().and_then(|c| c.iter().find(|e| e.key == key).map(|e| e.decodes)).unwrap_or(0) + cache().iter().find(|e| e.key == key).map(|e| e.decodes).unwrap_or(0) +} + +/// Bytes held by cached source composites. +pub fn source_cache_bytes() -> usize { + cache().iter().map(|e| e.bytes).sum() +} + +/// Drop every cached source composite (Edit › Purge › All, closing a document with smart +/// objects). Returns the bytes released. +pub fn purge_source_cache() -> usize { + let mut c = cache(); + let freed = c.iter().map(|e| e.bytes).sum(); + c.clear(); + freed } pub(crate) fn take_caches(layers: &mut [Layer]) -> Vec<(LayerId, Surface)> { @@ -155,15 +194,82 @@ pub(super) fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelForm s } -pub(super) fn source_surface(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { +/// A source's pixels in `fmt` from a flattened `buf` (the stack-mode combine, which has no +/// document to composite). +pub(super) fn source_surface_from_buffer(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { + let raster = |f| buffer_to_surface(buf, f); + convert_source(src, fmt, raster) +} + +/// `src` composited straight into `fmt` in blocks, without the full-size RGBA buffer +/// [`photocraft_compose::flatten`] allocates (16 bytes a pixel on top of the surface). +pub(super) fn flattened_source(src: &Document, fmt: PixelFormat) -> Result { + let b = src.bounds(); + if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(other(format!("the smart object's {}×{} contents are too large to render", b.width(), b.height()))); + } + // The banded compositor composites CMYK in inks and Lab in L, a and b: not the same pixels as + // the RGBA buffer, so those modes keep the buffer path. + let native = |m| matches!(m, ColorMode::Cmyk | ColorMode::Lab); + if native(src.mode) || native(fmt.mode) { + return source_surface_from_buffer(src, &photocraft_compose::flatten(src), fmt); + } + convert_source(src, fmt, |f| composite_in_blocks(src, f)) +} + +/// Pixels per block of [`composite_in_blocks`]: the compositor's own bands are 8 Mpx, which for +/// most sources is the whole image again (a band and its f32 conversion are 32 bytes a pixel). +const BLOCK_PIXELS: u64 = 1 << 20; + +/// [`photocraft_compose::flatten_to_surface`] of `src`, composited a block at a time so the float +/// composite and its conversion never cover more than [`BLOCK_PIXELS`]. Blocks are whole tiles, +/// so each block's tiles move into the result as they are: nothing is allocated outside the +/// block, and nothing is copied. +fn composite_in_blocks(src: &Document, fmt: PixelFormat) -> Surface { + let b = src.bounds(); + let (bw, bh) = block_size(b.width()); + let mut out = Surface::new(fmt); + // On the tile grid even if the bounds aren't, so no tile is shared by two blocks. + let mut y = b.y0 - b.y0.rem_euclid(TILE_SIZE); + while y < b.y1 { + let y1 = y.saturating_add(bh); + let mut x = b.x0 - b.x0.rem_euclid(TILE_SIZE); + while x < b.x1 { + let x1 = x.saturating_add(bw); + let block = Rect::new(x, y, x1, y1).intersect(&b); + if !block.is_empty() { + let mut part = photocraft_compose::composite_to_surface(src, block, fmt, None); + out.put_tiles(part.take_tiles(block)); + } + x = x1; + } + y = y1; + } + out.prune(); + out +} + +/// Width and height of a block of [`composite_in_blocks`] for a `width`-pixel source: whole tiles, +/// the full width (up to [`BLOCK_PIXELS`] over one row of tiles) and as many rows of tiles as fit. +fn block_size(width: u32) -> (i32, i32) { + let tile = TILE_SIZE.unsigned_abs() as u64; + let across = u64::from(width.max(1)).div_ceil(tile) * tile; + let bw = across.min((BLOCK_PIXELS / tile / tile).max(1) * tile); + let bh = (BLOCK_PIXELS / bw / tile).max(1) * tile; + (i32::try_from(bw).unwrap_or(TILE_SIZE), i32::try_from(bh).unwrap_or(TILE_SIZE)) +} + +/// Runs the gray and 32-bit conversions a source needs to reach `fmt`; `raster` yields the +/// source's composite at a pixel format. +fn convert_source(src: &Document, fmt: PixelFormat, raster: impl FnOnce(PixelFormat) -> Surface) -> Result { let gray = fmt.mode == ColorMode::Grayscale && src.pixel_format().mode != ColorMode::Grayscale; let linear = fmt.sample == SampleType::F32 && src.depth != SampleType::F32; if !gray && !linear { - return Ok(buffer_to_surface(buf, fmt)); + return Ok(raster(fmt)); } let mut doc = Document::new(src.name.clone(), src.size, src.mode, src.depth); doc.icc_profile = src.icc_profile.clone(); - doc.layers = vec![Layer::new("source", LayerContent::Raster(buffer_to_surface(buf, src.pixel_format())))]; + doc.layers = vec![Layer::new("source", LayerContent::Raster(raster(src.pixel_format())))]; if gray { let gray_profile = crate::color_cmds::working_profile(ColorMode::Grayscale); crate::color_cmds::convert_document(&mut doc, &gray_profile, photocraft_cms::Intent::RelativeColorimetric, true)?; @@ -213,8 +319,49 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result SourceImage { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, side, side), &[0.2, 0.4, 0.6, 1.0]); + SourceImage { surface: Arc::new(s), bounds: Rect::new(0, 0, side, side) } + } + + #[test] + fn a_source_larger_than_the_limit_is_returned_but_not_cached() { + let key = cache_key(b"oversize", PixelFormat::RGBA8); + let img = image(256); + let bytes = super::super::stack_cache::surface_bytes(&img.surface); + cache_put_within(key, img.clone(), bytes - 1); + assert!(cache_get(&key).is_none(), "{bytes} bytes against a limit of {}", bytes - 1); + cache_put_within(key, img, bytes); + assert!(cache_get(&key).is_some()); + } + + #[test] + fn blocks_are_whole_tiles_within_their_pixel_budget_however_wide_the_source() { + for width in [1u32, 255, 4096, 5000, 300_000, u32::MAX] { + let (bw, bh) = block_size(width); + assert!(bw > 0 && bh > 0 && bw % TILE_SIZE == 0 && bh % TILE_SIZE == 0, "{width}: {bw}×{bh}"); + assert!(u64::from(bw.unsigned_abs()) * u64::from(bh.unsigned_abs()) <= BLOCK_PIXELS, "{width}: {bw}×{bh}"); + } + assert_eq!(block_size(4096), (4096, 256)); + assert_eq!(block_size(1000), (1024, 1024)); + assert_eq!(block_size(300_000), (4096, 256)); + } + + #[test] + fn accounting_counts_the_real_tile_bytes() { + let img = image(256); + let bytes = super::super::stack_cache::surface_bytes(&img.surface); + // 8-bit RGBA is 4 bytes a pixel (plus the default pixel), not the 16 of an f32 estimate. + assert!((256 * 256 * 4..256 * 256 * 5).contains(&bytes), "{bytes}"); + } +} diff --git a/crates/engine/src/cmd/smart_cmds/stack_cache.rs b/crates/engine/src/cmd/smart_cmds/stack_cache.rs index b155e5282c..2822f01027 100644 --- a/crates/engine/src/cmd/smart_cmds/stack_cache.rs +++ b/crates/engine/src/cmd/smart_cmds/stack_cache.rs @@ -30,7 +30,10 @@ struct Entry { static STAGES: Mutex> = Mutex::new(Vec::new()); const ENTRIES: usize = 64; /// Accounted tile bytes; a stage larger than this on its own isn't kept. +#[cfg(not(target_arch = "wasm32"))] pub const BUDGET: usize = 768 << 20; +#[cfg(target_arch = "wasm32")] +pub const BUDGET: usize = 256 << 20; /// The first stage: what placing `bytes` (the source contents) through the smart object's /// transform, warp or perspective gives at pixel format `fmt` on `canvas`. @@ -88,7 +91,8 @@ pub fn purge() -> usize { freed } -fn surface_bytes(s: &Surface) -> usize { +/// Bytes a surface holds: its default pixel and every tile. +pub(super) fn surface_bytes(s: &Surface) -> usize { // Tiles shared with the document (or another stage) are charged anyway: the cache is what // keeps them alive once the document moves on. s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.bytes().len())) diff --git a/crates/engine/src/cmd/smart_cmds/tests.rs b/crates/engine/src/cmd/smart_cmds/tests.rs index 594d8ac3dd..f024c72783 100644 --- a/crates/engine/src/cmd/smart_cmds/tests.rs +++ b/crates/engine/src/cmd/smart_cmds/tests.rs @@ -2,6 +2,7 @@ use super::*; mod filter_mask_paint; mod selection; +mod source_flatten; mod stack_cache; const DEPTHS: [u64; 3] = [8, 16, 32]; diff --git a/crates/engine/src/cmd/smart_cmds/tests/source_flatten.rs b/crates/engine/src/cmd/smart_cmds/tests/source_flatten.rs new file mode 100644 index 0000000000..6d3f3a9e23 --- /dev/null +++ b/crates/engine/src/cmd/smart_cmds/tests/source_flatten.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::cmd::smart_cmds::source::{flattened_source, source_surface_from_buffer}; + +/// A source document of `mode`/`depth` with a textured, partly transparent block on a layer. +fn source(mode: ColorMode, depth: SampleType) -> Document { + let mut doc = Document::new("source", Size::new(W as u32, H as u32), mode, depth); + doc.layers = vec![Layer::new("block", LayerContent::Raster(block(&doc.pixel_format(), Rect::new(4, 3, 50, 40))))]; + doc +} + +fn block(fmt: &PixelFormat, r: Rect) -> Surface { + let mut s = Surface::new(*fmt); + let mut data = Vec::new(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let v = ((x * 7 + y * 13) % 23) as f32 / 22.0; + let a = if (x + y) % 5 == 0 { 0.5 } else { 1.0 }; + data.extend(photocraft_raster::from_rgba(fmt, [v, 1.0 - v, ((x * y) % 7) as f32 / 6.0, a])); + } + } + s.write_region(r, &data); + s +} + +/// Everything the smart object cache would hold, bit for bit. +fn pixels(s: &Surface, bounds: Rect) -> Vec { + s.read_region(bounds).iter().map(|v| v.to_bits()).collect() +} + +/// The composite through the full-size RGBA buffer, the way sources were rendered before. +fn through_buffer(doc: &Document, fmt: PixelFormat) -> Surface { + source_surface_from_buffer(doc, &photocraft_compose::flatten(doc), fmt).unwrap() +} + +fn same(label: &str, doc: &Document, fmt: PixelFormat) { + let (old, new) = (through_buffer(doc, fmt), flattened_source(doc, fmt).unwrap()); + assert_eq!(new.format(), old.format(), "{label}: format"); + assert!(pixels(&old, doc.bounds()) == pixels(&new, doc.bounds()), "{label}: pixels differ"); +} + +#[test] +fn rgb_sources_match_at_every_depth() { + for depth in SampleType::ALL { + let doc = source(ColorMode::Rgb, depth); + same(&format!("rgb {depth:?}"), &doc, doc.pixel_format()); + } +} + +#[test] +fn a_gray_source_in_an_rgb_document_and_back_matches() { + let gray = source(ColorMode::Grayscale, SampleType::U8); + same("gray into rgb", &gray, PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true }); + let rgb = source(ColorMode::Rgb, SampleType::U8); + same("rgb into gray", &rgb, PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::U8, alpha: true }); +} + +#[test] +fn an_8_bit_source_in_a_32_bit_document_matches() { + let doc = source(ColorMode::Rgb, SampleType::U8); + same("8 into 32", &doc, PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true }); +} + +/// The banded compositor composites CMYK in inks and Lab in L, a and b, and lands up to 16 and 1 +/// steps (8-bit) off the RGBA buffer on visible pixels, so these sources keep the buffer path. +#[test] +fn a_cmyk_source_matches() { + let doc = source(ColorMode::Cmyk, SampleType::U8); + same("cmyk", &doc, doc.pixel_format()); +} + +#[test] +fn a_lab_source_matches() { + let doc = source(ColorMode::Lab, SampleType::U8); + same("lab", &doc, doc.pixel_format()); +} + +#[test] +fn a_layered_source_with_a_mask_blend_mode_and_effect_matches() { + for depth in [SampleType::U8, SampleType::F32] { + let mut doc = source(ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut top = Layer::new("top", LayerContent::Raster(block(&fmt, Rect::new(20, 10, 60, 46)))); + top.blend = BlendMode::Multiply; + top.opacity = 0.7; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(30, 0, 64, 48), &[0.25]); + top.mask = Some(mask); + doc.layers.push(top); + let mut shadowed = Layer::new("shadowed", LayerContent::Raster(block(&fmt, Rect::new(8, 20, 24, 36)))); + shadowed.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + shadowed.effects.enabled = true; + doc.layers.push(shadowed); + same(&format!("layered {depth:?}"), &doc, fmt); + } +} + +#[test] +fn a_source_past_the_render_limit_is_refused_not_emptied() { + let doc = Document::new("huge", Size::new(60_000, 60_000), ColorMode::Rgb, SampleType::U8); + assert!(flattened_source(&doc, doc.pixel_format()).is_err()); +} + +#[test] +fn an_rgb_source_in_a_cmyk_or_lab_document_matches() { + let doc = source(ColorMode::Rgb, SampleType::U8); + for mode in [ColorMode::Cmyk, ColorMode::Lab] { + same(&format!("rgb into {mode:?}"), &doc, PixelFormat { mode, sample: SampleType::U8, alpha: true }); + } +} + +/// Blocks are 256 rows of the full width at 4096 px, and split at x = 4096 at 5000 px: the shadow +/// and mask cross the block edges both ways. +#[test] +fn effects_across_block_edges_match() { + for (width, depth) in [(4096, SampleType::U8), (4096, SampleType::F32), (5000, SampleType::U8)] { + let mut doc = Document::new("wide", Size::new(width, 700), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let x0 = if width > 4096 { 3800 } else { 100 }; + let mut base = Layer::new("base", LayerContent::Raster(block(&fmt, Rect::new(x0, 200, x0 + 800, 560)))); + let mut glow = photocraft_doc::Effect::default_drop_shadow(); + if let photocraft_doc::Effect::DropShadow(d) = &mut glow { + d.size = 40.0; + d.distance = 30.0; + } + base.effects.items.push(glow); + base.effects.enabled = true; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(0, 240, width as i32, 270), &[0.3]); + base.mask = Some(mask); + doc.layers = vec![base]; + same(&format!("blocks {width} {depth:?}"), &doc, fmt); + } +} diff --git a/crates/engine/src/cmd/solid_fill_cmds.rs b/crates/engine/src/cmd/solid_fill_cmds.rs index efa43f6116..85d941760d 100644 --- a/crates/engine/src/cmd/solid_fill_cmds.rs +++ b/crates/engine/src/cmd/solid_fill_cmds.rs @@ -4,7 +4,7 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::{Fill, LayerContent}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param}; +use crate::commands::{CommandSpec, check_pixels_unlocked, cmyk_param, layer_param}; use crate::{EngineError, Result, Session}; pub const SET: &str = "layer.fill.solidColor.set"; @@ -57,7 +57,13 @@ fn set(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Fill(Fill::Solid(original)) = layer.content else { return Err(bad("the target must be a Solid Color Fill layer")) }; - let color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?; + let mut color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?; + // The inks the colour was picked as: a CMYK document keeps them exactly (#8568). + if let Some(k) = cmyk_param(SET, p, "cmyk")? + && st.doc.mode == ColorMode::Cmyk + { + color = Color { mode: ColorMode::Cmyk, c: k, alpha: color.alpha }; + } if color != original { check_pixels_unlocked(&st.doc, id)?; if st.doc.effective_locks(id).transparency && color.alpha != original.alpha { @@ -78,7 +84,7 @@ pub fn specs() -> Vec { label: "Solid Color…", menu: &[], shortcut: None, - params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32}}; RGB values without alpha preserve it; stored Color preserves its model and precision"##, + params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), run: set, journal: true, @@ -89,6 +95,47 @@ pub fn specs() -> Vec { mod tests { use super::*; + /// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly + /// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to + /// through the document profile. RGB documents ignore the inks. + #[test] + fn cmyk_inks_survive_solid_color_fills_in_a_cmyk_document() { + let inks = [0.5, 0.5, 0.5, 1.0]; + let hex = |k: [f32; 4]| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(k).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + format!("#{r:02x}{g:02x}{b:02x}") + }; + let stored = |s: &Session, id: u64| match s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Fill(Fill::Solid(c)) => c, + _ => panic!("not a solid fill"), + }; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks), "cmyk": inks})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 }); + // The sRGB stand-in alone does drift: that is what the inks prevent. + let drifted = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks)})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(stored(&s, drifted).c, inks); + // Editing keeps the new inks too. + let edit = [0.2, 0.3, 0.4, 0.5]; + s.execute(SET, json!({"layer": id, "color": hex(edit), "cmyk": edit})).unwrap(); + assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: edit, alpha: 1.0 }); + // A foreground picked as CMYK seeds a new fill with its inks, as fills do (#4196). + s.execute("tools.setColors", json!({"foreground": hex(edit), "foregroundCmyk": edit})).unwrap(); + let seeded = s.execute("layer.newFillLayer.solidColor", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&s, seeded).c, edit); + for bad in [json!([0.1, 0.2, 0.3]), json!("50,50,50,100"), json!([0.1, 0.2, 0.3, f64::NAN])] { + assert!(s.execute("layer.newFillLayer.solidColor", json!({"cmyk": bad})).is_err(), "{bad}"); + assert!(s.execute(SET, json!({"layer": id, "color": "#000000", "cmyk": bad})).is_err(), "{bad}"); + } + // An RGB document takes the colour, not the inks. + let mut rgb = Session::new(); + rgb.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let id = rgb.execute("layer.newFillLayer.solidColor", json!({"color": "#336699", "cmyk": inks})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&rgb, id).mode, ColorMode::Rgb); + } + #[test] fn edit_preserves_layer_and_masks_at_all_depths_and_undo_redo() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index f8ff204257..e9ed16f245 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -345,7 +345,6 @@ fn cached_gradient_fill() -> Session { let mut s = session(); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.edit("cache", |doc, active| { let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); let l = doc.layer_mut(active.unwrap()).unwrap(); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a3a4af7327..afdf86f4fa 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -67,7 +67,7 @@ impl DocState { } /// Pixel bytes of the document, the tiles only its history holds and the tiles only its - /// History panel snapshots hold. + /// History panel snapshots hold; embedded smart object contents count too. pub fn memory_bytes(&self) -> usize { let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect(); self.history.pixel_bytes(&self.doc).saturating_add(self.history.extra_bytes(&self.doc, &snaps)) diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index 643ed46057..1406e0a384 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -49,6 +49,18 @@ pub struct KysSet { pub windows: bool, } +/// The set name in the root `filename` attribute (`$$$/.../{name}={name}`). The name may itself +/// contain `/` or `=`, so split on the key prefix and the doubled name, not on the last separator. +fn filename_to_name(f: &str) -> &str { + let Some(rest) = f.strip_prefix("$$$/") else { return f.rsplit(['=', '/']).next().unwrap_or(f).trim() }; + let rest = rest.strip_prefix("FileName/Presets/KeyboardCustomization/").unwrap_or(rest); + let mid = rest.len() / 2; + if rest.len() % 2 == 1 && rest.is_char_boundary(mid) && rest.as_bytes()[mid] == b'=' && rest[..mid] == rest[mid + 1..] { + return rest[..mid].trim(); + } + rest.split_once('=').map_or(rest, |(_, n)| n).trim() +} + /// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows /// without a name or a shortcut are skipped. pub fn parse(text: &str) -> Result { @@ -61,7 +73,7 @@ pub fn parse(text: &str) -> Result { if root.tag_name().name() != "photoshop-keyboard-shortcuts" { return Err("not a keyboard shortcut set (.kys): no root".into()); } - let name = root.attribute("filename").map(|f| f.rsplit(['=', '/']).next().unwrap_or(f).trim()).filter(|n| !n.is_empty()).map(str::to_string); + let name = root.attribute("filename").map(filename_to_name).filter(|n| !n.is_empty()).map(str::to_string); let mut set = KysSet { name, ..Default::default() }; for node in root.children().filter(|n| n.is_element()) { match node.tag_name().name() { @@ -119,6 +131,7 @@ pub fn to_ours(set: &KysSet, sc: &str) -> Option { } let (mods, key) = match sc.strip_suffix("++") { Some(m) => (m, "+"), + None if sc == "+" => ("", "+"), None => sc.rsplit_once('+').unwrap_or(("", sc)), }; let mods: Vec<&str> = mods @@ -262,6 +275,7 @@ pub fn photoshop_key(sc: &str) -> Option { // The key is the last part; `Cmd++` ends in an empty part, so take it from the string. let (mods, key) = match n.strip_suffix("++") { Some(m) => (m, "+"), + None if n == "+" => ("", "+"), None => n.rsplit_once('+').unwrap_or(("", n.as_str())), }; let has = |m: &str| mods.split('+').any(|x| x == m); @@ -309,6 +323,13 @@ pub fn write(name: &str, entries: &[Entry]) -> String { mod tests { use super::*; + #[test] + fn set_name_with_separators_round_trips() { + for n in ["Retouch 1/2", "a=b", "x/y=z"] { + assert_eq!(parse(&write(n, &[])).unwrap().name.as_deref(), Some(n)); + } + } + const SET: &str = "\u{feff} \tCmd+L @@ -323,6 +344,28 @@ mod tests { Candidate { id: id.into(), label: label.into(), default: default.map(str::to_string) } } + #[test] + fn a_bare_plus_key_is_kept_when_saving() { + assert_eq!(photoshop_key("+").as_deref(), Some("+")); + assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++")); + let e = Entry { id: "tools.zoom".into(), label: "Zoom Tool".into(), tool: true, keys: vec!["+".into()] }; + assert!(write("Mine", &[e]).contains(">+")); + } + + #[test] + fn a_bare_plus_command_survives_a_windows_set_with_a_ctrl_row() { + let xml = "\ + Ctrl+C\ + +\ + "; + let set = parse(xml).unwrap(); + assert!(set.windows); + assert_eq!(to_ours(&set, "+").as_deref(), Some("+")); + let p = plan([c("edit.copy", "Copy", Some("Cmd+C")), c("view.zoomIn", "Zoom In", None)], &set); + assert_eq!(p.set.get("view.zoomIn").map(String::as_str), Some("+")); + assert!(p.unreadable.is_empty()); + } + #[test] fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() { let set = parse(SET).unwrap(); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 38401ff468..38dc5908f1 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -562,6 +562,16 @@ impl Default for Export { /// [`Performance::memory_usage_mb`]'s default. 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. +const WASM_MEMORY_USAGE_CAP_MB: u32 = 1536; + +/// 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 } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Performance { @@ -617,10 +627,16 @@ impl Performance { self.rendering_mode.unwrap_or_else(|| if !self.use_gpu || self.gpu_backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto }) } + /// [`Performance::memory_usage_mb`] as the build can honour it: capped on the web, where the + /// stored preference (a saved 8192 still loads) is larger than the module's address space. + pub fn effective_memory_usage_mb(&self) -> u32 { + honoured_memory_usage_mb(self.memory_usage_mb, cfg!(target_arch = "wasm32")) + } + /// Pixel memory a document and its History may hold (Memory Usage), in bytes: beyond it /// the oldest history states are dropped. pub fn history_budget_bytes(&self) -> usize { - (self.memory_usage_mb as usize).saturating_mul(1 << 20) + (self.effective_memory_usage_mb() as usize).saturating_mul(1 << 20) } } @@ -1314,7 +1330,7 @@ impl Session { st.trim_history(); } photocraft_compose::set_effect_cache_budget(budget << 20); - crate::transform_cmds::set_budget(u64::from(self.prefs().performance.memory_usage_mb) << 20); + 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()); crate::plugin_cmds::sync_prefs(self); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6f17a21970..fc96369ee4 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -1033,3 +1033,35 @@ fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { assert!(s.load_prefs_json(&deep).is_ok()); assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); } + +/// A saved Memory Usage is kept as stored, and means the same on desktop; only the web build +/// caps what it honours. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_history_budget_is_the_stored_memory_usage_on_native() { + for mb in [0u32, 1, 1536, 8192, 65536, u32::MAX] { + let p = Performance { memory_usage_mb: mb, ..Performance::default() }; + assert_eq!(p.effective_memory_usage_mb(), mb); + assert_eq!(p.history_budget_bytes(), (mb as usize) << 20, "{mb} MB"); + } + let mut s = Session::new(); + s.load_prefs_json(r#"{"performance": {"memoryUsageMb": 8192}}"#).unwrap(); + assert_eq!(s.prefs().performance.memory_usage_mb, 8192); + assert_eq!(s.prefs().performance.history_budget_bytes(), 8192 << 20); +} + +/// The web build honours Memory Usage up to its cap: below and at it as stored, above it the cap. +#[test] +fn the_web_build_caps_memory_usage() { + for (stored, honoured) in [ + (0, 0), + (1024, 1024), + (WASM_MEMORY_USAGE_CAP_MB, WASM_MEMORY_USAGE_CAP_MB), + (WASM_MEMORY_USAGE_CAP_MB + 1, WASM_MEMORY_USAGE_CAP_MB), + (8192, WASM_MEMORY_USAGE_CAP_MB), + (u32::MAX, WASM_MEMORY_USAGE_CAP_MB), + ] { + assert_eq!(honoured_memory_usage_mb(stored, true), honoured, "web, {stored} MB"); + assert_eq!(honoured_memory_usage_mb(stored, false), stored, "desktop, {stored} MB"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a55e696e17..64ed09e9e2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -265,8 +265,9 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() { assert_eq!(mask.value(10, 10), 1.0, "{id} at {depth}-bit: the mask hides the selected area"); assert_eq!(mask.value(2, 2), 0.0, "{id} at {depth}-bit: the mask reveals outside the selection"); assert!(mask.linked && mask.enabled, "{id}"); - // The selection stays, as after Layer › Layer Mask › Reveal Selection. - assert!(d.doc.selection.is_some(), "{id} dropped the selection"); + // The selection went into the mask, so Photoshop drops it, as Layer › Layer Mask › + // Reveal Selection does (#5251, #8801). + assert!(d.doc.selection.is_none(), "{id} at {depth}-bit kept the selection"); // The white canvas is untouched outside the selection. assert_eq!(px(&mut s, 2, 2), vec![1.0, 1.0, 1.0, 1.0], "{id} at {depth}-bit changed pixels outside the selection"); if blackens { @@ -274,6 +275,29 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() { } s.execute("edit.undo", json!({})).unwrap(); assert!(s.active().unwrap().doc.layer(layer).is_none(), "{id}: undo keeps the layer"); + let sel = s.active().unwrap().doc.selection.as_ref().unwrap_or_else(|| panic!("{id}: undo does not bring the selection back")); + assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "{id}: undo restores the same selection"); + } + } +} + +#[test] +fn an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer() { + // #8801: the selection that masked a new fill or adjustment layer is gone, so the next + // pixel edit on another layer is not limited to it. + for (id, params) in [("layer.newFillLayer.solidColor", json!({"color": "#000000"})), ("layer.newAdjustmentLayer.invert", json!({}))] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#3366cc"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute(id, params).unwrap(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + s.execute("layer.selectBottom", json!({})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("image.adjustments.desaturate", json!({})).unwrap(); + for x in [5, 35] { + let p = px(&mut s, x, 10); + assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: ({x}, 10) is not grey: {p:?}"); } } } @@ -351,6 +375,7 @@ fn new_fill_and_adjustment_layers_take_the_active_path_as_their_vector_mask() { let want = if path_name == "work" { d.doc.work_path.clone().unwrap() } else { d.doc.paths[0].path.clone() }; assert_eq!(vm.path, want, "{id}: the vector mask is the path"); assert!(l.mask.is_none(), "{id}: the path wins over the selection"); + assert!(d.doc.selection.is_some(), "{id}: an unused selection stays"); for (x, y) in [(2, 2), (42, 32), (30, 30)] { assert_eq!(px(&mut s, x, y), vec![1.0, 1.0, 1.0, 1.0], "{id} ({path_name}) at {depth}-bit changed pixels outside the path at ({x}, {y})"); } @@ -433,6 +458,34 @@ fn marquee_dragged_past_the_canvas_stops_at_its_edge() { assert_eq!(bounds(&mut s), Value::Null); } +#[test] +fn subtract_clears_selection_when_only_disjoint_off_canvas_pixels_remain() { + use photocraft_geom::Rect; + use photocraft_raster::Surface; + let mut s = session_with_doc(); // 64 × 48 + let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8); + // Simulate a moved sparse selection: one remnant on either side of the canvas and one + // in-canvas region that the subtract marquee will remove. The off-canvas bounds straddle + // the canvas, so testing only their bounding-box intersection would incorrectly keep it. + selection.fill_rect(Rect::new(-4, 10, 0, 14), &[1.0]); + selection.fill_rect(Rect::new(20, 10, 24, 14), &[1.0]); + selection.fill_rect(Rect::new(64, 10, 68, 14), &[1.0]); + Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(selection); + + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap(); + + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn subtract_clears_selection_with_nonzero_default_when_canvas_is_empty() { + use photocraft_raster::Surface; + let mut s = session_with_doc(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0])); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + #[test] fn marquee_steps_are_named_after_their_tool() { // #513: the elliptical marquee shares `select.rect` but records its own step name. @@ -506,6 +559,41 @@ fn undo_and_redo_restore_the_targeted_layers() { assert_eq!(target(&s), copies); } +#[test] +fn selection_inverse_preserves_coverage_history_and_empty_selection() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 530, "height": 270, "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 250, "y": 10, "width": 30, "height": 30, "ellipse": true})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let area = before.bounds(); + let original = before.selection.as_ref().unwrap().to_interleaved(area); + s.execute("select.inverse", json!({})).unwrap(); + let after = s.active().unwrap(); + let inverted = after.doc.selection.as_ref().unwrap(); + assert_eq!(inverted.to_interleaved(area), original.iter().map(|b| 255 - b).collect::>()); + assert_eq!(inverted.sample_channel(530, 269, 0), 0.0); + assert_eq!(inverted.sample_channel(529, 270, 0), 0.0); + assert_eq!(after.last_damage, Some(photocraft_geom::Rect::EMPTY)); + assert_eq!(before.selection.as_ref().unwrap().to_interleaved(area), original); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original); + assert!(s.redo()); + s.execute("select.inverse", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original); + + s.execute("select.all", json!({})).unwrap(); + s.execute("select.inverse", json!({})).unwrap(); + let empty = s.active().unwrap().doc.selection.as_ref().unwrap(); + assert_eq!(empty.tile_count(), 0); + // Keep Some(empty), as before: the next Inverse selects the whole canvas again. + s.execute("select.inverse", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), area); + } + let mut s = Session::new(); + assert!(s.execute("select.inverse", json!({})).is_err()); +} + #[test] fn selection_modes() { let mut s = session_with_doc(); diff --git a/crates/engine/tests/it/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs index 867acaaf21..12731d7088 100644 --- a/crates/engine/tests/it/empty_selection_combine.rs +++ b/crates/engine/tests/it/empty_selection_combine.rs @@ -91,3 +91,42 @@ fn subtracting_from_an_existing_selection_preserves_other_pixels_and_history() { assert_eq!(coverage(&session, 12, 12), removed); } } + +#[test] +fn marquee_add_subtract_match_dense_combination_across_tiles() { + use photocraft_algo::selection as sel; + use photocraft_color::PixelFormat; + use photocraft_geom::Rect; + use photocraft_raster::Surface; + + // A partial edge tile, a shape crossing a tile boundary, and an untouched distant tile. + let area = Rect::from_xywh(0, 0, 777, 263); + let r = Rect::from_xywh(247, -5, 24, 32); + for depth in [8, 16, 32] { + for ellipse in [false, true] { + for mode in ["add", "subtract"] { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 777, "height": 263, "depth": depth})).unwrap(); + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(Rect::from_xywh(240, 0, 40, 40), &[0.6]); + old.fill_rect(Rect::from_xywh(768, 256, 9, 7), &[0.4]); + std::sync::Arc::make_mut(&mut session.active_mut().unwrap().doc).selection = Some(old.clone()); + let shape = if ellipse { + sel::ellipse_surface(r, area, true) + } else { + let mut shape = Surface::new(PixelFormat::GRAY8); + shape.fill_rect(r.intersect(&area), &[1.0]); + shape + }; + let expected = sel::combine_surfaces(&old, &shape, area, |a, b| if mode == "add" { a.max(b) } else { a * (1.0 - b) }); + session.execute("select.rect", json!({"x": 247, "y": -5, "width": 24, "height": 32, "ellipse": ellipse, "mode": mode})).unwrap(); + let pixels = |session: &Session| session.active().unwrap().doc.selection.as_ref().unwrap().read_region(area); + assert_eq!(pixels(&session), expected.read_region(area), "depth={depth}, ellipse={ellipse}, mode={mode}"); + assert!(session.undo()); + assert_eq!(pixels(&session), old.read_region(area)); + assert!(session.redo()); + assert_eq!(pixels(&session), expected.read_region(area)); + } + } + } +} diff --git a/crates/engine/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs new file mode 100644 index 0000000000..14977beece --- /dev/null +++ b/crates/engine/tests/it/integration_batch.rs @@ -0,0 +1,34 @@ +//! Cross-PR acceptance checks for the merged parity batch: each test pins one fix through the +//! public command surface, so a bad merge between the PRs shows up here. +use photocraft_engine::Session; +use serde_json::json; + +fn session(width: u32, height: u32) -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": width, "height": height})).unwrap(); + session +} + +#[test] +fn a_new_fill_layer_consumes_the_selection_it_took_as_mask() { + let mut s = session(32, 24); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 8})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() { + let mut s = session(4500, 8); + s.execute("edit.definePattern", json!({"name": "Wide"})).unwrap(); + let mut s = session(8001, 8); + assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err()); +} + +#[test] +fn gaussian_blur_accepts_radii_below_one_without_failing() { + for radius in [0.1, 0.3, 0.7, 1.0] { + let mut s = session(24, 16); + s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap(); + } +} diff --git a/crates/engine/tests/it/layer_mask_depth.rs b/crates/engine/tests/it/layer_mask_depth.rs index 7093164a5f..cbe4138532 100644 --- a/crates/engine/tests/it/layer_mask_depth.rs +++ b/crates/engine/tests/it/layer_mask_depth.rs @@ -45,12 +45,10 @@ fn newly_created_layer_masks_follow_document_depth() { assert_eq!(m.value(20, 20), white); if selected { assert_eq!(m.value(0, 0), 0.0); - if command == "layer.layerMask.revealSelection" { - // The selection became the mask: Photoshop drops it, as Hide Selection does. - assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); - } else { - assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); - } + // The selection became the mask: Photoshop drops it, as Hide Selection does + // (#5251; new fill and adjustment layers too, #8801). + assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); + assert!(before.selection.is_some()); } else { assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse"); assert_eq!(m.value(-300_000, 300_000), white); @@ -72,7 +70,6 @@ fn soft_mask_edits_match_after_native_and_psd_reopen() { let mut s = session(depth, "rgb"); s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap(); s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let before = s.active().unwrap().doc.clone(); let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap(); let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap(); @@ -138,7 +135,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() { before.selection.as_ref().unwrap().read_region(before.bounds()), "{depth}-bit coverage is unchanged" ); - let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() }; + // The selection went into the mask, except for the Gradient tool's fill layer. + let kept = if command == "gradient.fill.create" { before.selection.clone() } else { None }; assert_eq!(after.selection, kept, "{command}"); assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask"); } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 25455e539c..f6a8360a4f 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -37,6 +37,7 @@ mod guide_layout_limit; mod guide_layout_preview; mod hdr_gamma_direction; mod huge_doc_flatten; +mod integration_batch; mod jpeg_exif_resolution; mod keyboard_shortcut_bulk; mod kra; diff --git a/crates/engine/tests/it/merge_transparent_bounds.rs b/crates/engine/tests/it/merge_transparent_bounds.rs index c796007233..03e9473fb2 100644 --- a/crates/engine/tests/it/merge_transparent_bounds.rs +++ b/crates/engine/tests/it/merge_transparent_bounds.rs @@ -101,7 +101,6 @@ fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() { let (mut s, a, _) = scene(mode, depth); s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap(); let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap()); - s.execute("select.deselect", json!({})).unwrap(); (s, a, fill) }; for &(mode, depth) in CASES { diff --git a/crates/engine/tests/smart_source_cache.rs b/crates/engine/tests/smart_source_cache.rs new file mode 100644 index 0000000000..ec28b21292 --- /dev/null +++ b/crates/engine/tests/smart_source_cache.rs @@ -0,0 +1,92 @@ +//! #10030: the decoded-source cache counts the bytes it holds, and Purge All and closing a +//! document with smart objects release them. One integration-test process keeps the global +//! cache isolated from parallel unit tests; the tests here take turns on it. +use std::sync::Mutex; + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; +use photocraft_engine::{Session, file_cmds, smart_cmds}; +use serde_json::json; + +static TURN: Mutex<()> = Mutex::new(()); + +const SIDE: u32 = 256; + +fn png() -> Vec { + let px = (0..SIDE * SIDE).flat_map(|i| [(i % SIDE) as u8, (i / SIDE) as u8, ((i * 7) % 251) as u8, 255]); + let img = Image::from_raw(SIDE, SIDE, ChannelLayout::Rgba, SampleType::U8, px.collect()).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap() +} + +/// Every cache `edit.purge.all` clears, emptied. +fn empty_caches() { + smart_cmds::purge_source_cache(); + smart_cmds::purge_stack_cache(); + photocraft_compose::purge_effect_cache(); + photocraft_compose::masks::purge_cache(); + photocraft_compose::shape_split::purge_cache(); +} + +fn session_with_smart_object(bytes: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": SIDE, "height": SIDE})).unwrap(); + file_cmds::place_bytes(&mut s, "art.png", bytes.to_vec(), None, &json!({})).unwrap(); + s +} + +fn decode(s: &Session, bytes: &[u8]) { + smart_cmds::source_image("art.png", bytes, s.active().unwrap().doc.pixel_format()).unwrap(); +} + +#[test] +fn the_cache_counts_real_tile_bytes() { + let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + empty_caches(); + let bytes = png(); + let s = session_with_smart_object(&bytes); + decode(&s, &bytes); + let held = smart_cmds::source_cache_bytes(); + let px = (SIDE * SIDE) as usize; + assert!(held >= 4 * px && held < 5 * px, "{held} bytes for {px} 8-bit RGBA pixels"); + assert_eq!(smart_cmds::purge_source_cache(), held); + assert_eq!(smart_cmds::source_cache_bytes(), 0); +} + +#[test] +fn purge_all_reports_and_empties_the_source_cache() { + let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + empty_caches(); + let bytes = png(); + let mut s = session_with_smart_object(&bytes); + // Nothing else holds anything to purge: only the source cache enables Purge All. + s.execute("edit.purge.histories", json!({})).unwrap(); + s.clipboard = None; + assert!(!s.is_enabled("edit.purge.all")); + decode(&s, &bytes); + assert!(smart_cmds::source_cache_bytes() > 0); + assert!(s.is_enabled("edit.purge.all")); + let purged = s.execute("edit.purge.all", json!({})).unwrap(); + assert!(purged["purged"].as_array().unwrap().contains(&json!("smart object source cache")), "{purged}"); + assert_eq!(smart_cmds::source_cache_bytes(), 0); + assert!(!s.is_enabled("edit.purge.all")); +} + +#[test] +fn closing_a_document_with_a_smart_object_empties_the_source_cache() { + let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + empty_caches(); + let bytes = png(); + let mut s = session_with_smart_object(&bytes); + decode(&s, &bytes); + assert!(smart_cmds::source_cache_bytes() > 0); + s.execute("file.close", json!({})).unwrap(); + assert_eq!(smart_cmds::source_cache_bytes(), 0); + + // A document without smart objects leaves it alone. + let mut plain = Session::new(); + plain.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let s = session_with_smart_object(&bytes); + decode(&s, &bytes); + plain.execute("file.close", json!({})).unwrap(); + assert!(smart_cmds::source_cache_bytes() > 0); + empty_caches(); +} diff --git a/crates/engine/tests/smart_source_peak.rs b/crates/engine/tests/smart_source_peak.rs new file mode 100644 index 0000000000..ea74b88e0f --- /dev/null +++ b/crates/engine/tests/smart_source_peak.rs @@ -0,0 +1,50 @@ +//! #10030: decoding a smart object's source (what its first re-render does) composites it in +//! tile-aligned blocks, so the peak memory growth stays a small multiple of the source's pixels, not the 32 +//! bytes a pixel the full-size RGBA buffer and its f32 copy took on top of the decoded document. +//! +//! The one test here runs in its own process: it reads the process's peak RSS (`VmHWM`, Linux +//! only) and nothing else may allocate meanwhile. + +#![cfg(target_os = "linux")] + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +fn peak_rss_bytes() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|l| l.starts_with("VmHWM:"))?; + let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kib * 1024) +} + +const SIDE: u32 = 4096; + +fn png() -> Vec { + let px = (0..SIDE * SIDE).flat_map(|i| { + let (x, y) = (i % SIDE, i / SIDE); + [(x * 5 + y) as u8, ((y * 7) ^ x) as u8, ((x * y) % 251) as u8, 255 - ((x / 64 + y / 64) % 4 * 40) as u8] + }); + let img = Image::from_raw(SIDE, SIDE, ChannelLayout::Rgba, SampleType::U8, px.collect()).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap() +} + +#[test] +fn decoding_a_smart_object_source_stays_within_16_bytes_a_source_pixel() { + let bytes = png(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let fmt = s.active().unwrap().doc.pixel_format(); + // Writing 5 resets the peak RSS to the current RSS; skip where the kernel doesn't allow it. + if std::fs::write("/proc/self/clear_refs", "5").is_err() { + return; + } + let Some(before) = peak_rss_bytes() else { return }; + let img = smart_cmds::source_image("big.png", &bytes, fmt).unwrap(); + let grown = peak_rss_bytes().unwrap().saturating_sub(before); + assert_eq!(img.bounds.width(), SIDE); + + let limit = 16 * u64::from(SIDE) * u64::from(SIDE); + eprintln!("peak RSS growth {} MiB for {} px ({:.1} B/px)", grown >> 20, SIDE * SIDE, grown as f64 / f64::from(SIDE * SIDE)); + assert!(grown < limit, "peak RSS grew by {grown} bytes, over {limit} (16 B/px)"); +} diff --git a/crates/format/tests/it/autosave.rs b/crates/format/tests/it/autosave.rs index 26be1c9d76..6eeb48d66f 100644 --- a/crates/format/tests/it/autosave.rs +++ b/crates/format/tests/it/autosave.rs @@ -34,19 +34,26 @@ fn background_save_outcomes_report_failure_then_recovery() { let root = temp_dir("retry-failed-write"); let blocked = root.join("recovery"); std::fs::write(&blocked, b"not a directory").unwrap(); - let saver = Autosaver::new(&blocked, "doc"); + // Not dropped on a failure: dropping joins the worker, which waits for a write in progress, + // so a stuck write would hold the test past its own bound until nextest kills it. + let saver = std::mem::ManuallyDrop::new(Autosaver::new(&blocked, "doc")); let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8)); saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap(); + // The bound only turns a worker that never reports into a failure instead of a hang. A real + // write flushes every tile to disk and took over 30 s on a busy Windows CI runner (normally + // 2-3 s), so it is generous, and still under the 10 minutes after which nextest's `ci` + // profile kills a test. + const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5 * 60); let receive = |saver: &Autosaver| { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let deadline = std::time::Instant::now() + PATIENCE; while std::time::Instant::now() < deadline { if let Some(outcome) = saver.take_completed().into_iter().next() { return outcome; } std::thread::sleep(std::time::Duration::from_millis(10)); } - panic!("autosave worker did not report its completed write within 30 seconds"); + panic!("autosave worker did not report its completed write within {PATIENCE:?}"); }; let (revision, result) = receive(&saver); assert_eq!(revision, 7); @@ -60,7 +67,7 @@ fn background_save_outcomes_report_failure_then_recovery() { result.unwrap(); assert_eq!(list_recovery(&blocked).len(), 1); assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc); - drop(saver); + drop(std::mem::ManuallyDrop::into_inner(saver)); std::fs::remove_dir_all(root).unwrap(); } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 1a475827e8..060d12b5d9 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1116,15 +1116,10 @@ fn wrap(i: i32, n: i32) -> i32 { return ((i % n) + n) % n; } -// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin), -// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. -fn pattern_sample(d: vec2) -> vec4 { - let dx = f32(d.x) + 0.5 - op.p3.x; - let dy = f32(d.y) + 0.5 - op.p3.y; - let c = op.p3.z; - let s = op.p3.w; - let u = (dx * c - dy * s) * op.p4.x - 0.5; - let v = (dx * s + dy * c) * op.p4.x - 0.5; +// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul). +fn pattern_tap(u0: f32, v0: f32) -> vec4 { + let u = u0 - 0.5; + let v = v0 - 0.5; let x0 = floor(u); let y0 = floor(v); let fx = u - x0; @@ -1143,6 +1138,28 @@ fn pattern_sample(d: vec2) -> vec4 { acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]); } } + return acc; +} + +// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin), +// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel +// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU. +fn pattern_sample(d: vec2) -> vec4 { + var n = 1; + if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); } + let c = op.p3.z; + let s = op.p3.w; + var acc = vec4(0.0); + for (var j = 0; j < n; j++) { + for (var i = 0; i < n; i++) { + let ox = (f32(i) + 0.5) / f32(n) - 0.5; + let oy = (f32(j) + 0.5) / f32(n) - 0.5; + let dx = f32(d.x) + 0.5 + ox - op.p3.x; + let dy = f32(d.y) + 0.5 + oy - op.p3.y; + acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x); + } + } + acc = acc / f32(n * n); if (acc.a <= 0.0) { return vec4(0.0); } return vec4(acc.rgb / acc.a, min(acc.a, 1.0)); } diff --git a/crates/gpu/src/consts.rs b/crates/gpu/src/consts.rs index 40081a55c8..536aea8a49 100644 --- a/crates/gpu/src/consts.rs +++ b/crates/gpu/src/consts.rs @@ -33,6 +33,9 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30; /// GPU memory pattern textures may hold before the least recently painted are dropped (those the /// current frame paints with always stay). pub const PATTERN_BUDGET: u64 = 256 << 20; +/// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which +/// holds a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`). +pub(crate) const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20; /// A pattern texture no frame has painted with for this many frames is dropped. pub const PATTERN_IDLE_FRAMES: u64 = 240; diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index 3cb4fad401..15217ed236 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -1,5 +1,5 @@ use super::batch::tile_grid; -use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; +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::paging::{cell_rect, field_reaches, first_error, grid_rects}; use super::textures::{FxEntry, Resident, Tex, TexKind}; @@ -386,19 +386,32 @@ impl Compositor { if s.lut.as_ref().is_some_and(|l| l.len() > 4096) { return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name))); } + if let Some(pat) = &s.pattern { + self.check_pattern(pat)?; + } } } } for pass in &p.passes { - if let Some(pat) = pass.pattern - && (pat.width > self.device_max || pat.height > self.device_max) - { - return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); + if let Some(pat) = pass.pattern { + self.check_pattern(pat)?; } } Ok(()) } + /// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on + /// the CPU compositor. + fn check_pattern(&self, pat: &photocraft_doc::Pattern) -> Result<(), Unsupported> { + if pat.width > self.device_max || pat.height > self.device_max { + return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); + } + if u64::from(pat.width) * u64::from(pat.height) * 16 > MAX_PATTERN_TEXTURE_BYTES { + return Err(Unsupported(format!("pattern `{}` too large for a GPU texture; the CPU path paints it", pat.name))); + } + Ok(()) + } + /// Drop GPU textures of a closed document. pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 28249eee39..843f082407 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2633,6 +2633,27 @@ fn closed_documents_release_their_pattern_textures() { check(&mut g, &a, "the pattern is uploaded again after being forgotten"); } +/// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit +/// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which +/// holds a large 8-bit pattern at a quarter of the texture's size. +#[test] +fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = Document::new("big pattern", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + // 100 MB as RGBA8, 400 MB as an RGBA32F texture. + let surface = photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]); + let pat = Pattern { id: "big".into(), name: "big".into(), width: 5000, height: 5000, surface }; + assert!(pat.is_paintable()); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "the GPU path declines it"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "nothing was uploaded"); + assert!(photocraft_compose::flatten(&d).px.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]), "the CPU compositor paints it"); +} + /// #4971: pattern textures are bounded while their documents stay open, by a byte budget and /// by age. What the current frame paints with is never dropped. #[test] @@ -2712,7 +2733,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() { fn pattern_fill_layers() { let Some(mut g) = gpu() else { return }; let pat = checker_pattern(); - for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] { + for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] { let mut d = base_doc(70, 50); d.patterns.push(pat.clone()); let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) }; diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 36aff72a3a..175ce73c94 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -69,9 +69,15 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [ (BlendMode::Luminosity, "luminosity"), ]; -/// A blend-mode enum from either its four-character code or its long string ID. +/// A blend-mode enum from its four-character code, its long string ID, or the layer record's key: +/// Photoshop 2026 writes `vLit`, `lddg`, `lbrn`… as an effect's `Md ` for some modes (#8567). pub(crate) fn blend_from_id(v: &[u8]) -> Option { - BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m) + BLEND_NAMES + .iter() + .chain(&BLEND_LONG_NAMES) + .find(|(_, n)| n.as_bytes() == v) + .map(|(m, _)| *m) + .or_else(|| <[u8; 4]>::try_from(v).ok().and_then(BlendMode::from_psd_key)) } fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode { diff --git a/crates/io/src/effects_map/tests.rs b/crates/io/src/effects_map/tests.rs index 3944739fb8..d2488ae82e 100644 --- a/crates/io/src/effects_map/tests.rs +++ b/crates/io/src/effects_map/tests.rs @@ -198,6 +198,35 @@ fn blend_names_are_unique() { } } +#[test] +fn layer_record_blend_keys_parse() { + for (key, mode) in [ + (b"vLit", BlendMode::VividLight), + (b"lLit", BlendMode::LinearLight), + (b"pLit", BlendMode::PinLight), + (b"hMix", BlendMode::HardMix), + (b"lbrn", BlendMode::LinearBurn), + (b"lddg", BlendMode::LinearDodge), + (b"dkCl", BlendMode::DarkerColor), + (b"lgCl", BlendMode::LighterColor), + (b"fsub", BlendMode::Subtract), + (b"fdiv", BlendMode::Divide), + ] { + assert_eq!(blend_from_id(key), Some(mode), "{}", String::from_utf8_lossy(key)); + } + assert_eq!(blend_from_id(b"zzzz"), None); + // The issue's Color Overlay: Vivid Light stored as `vLit`, not left as Normal. + let key = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) }; + let d = Descriptor::new("null") + .with("masterFXSwitch", Value::Boolean(true)) + .with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", key("vLit")))); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes()); + let (_, fx) = parse_lfx2(&data).unwrap(); + let modes: Vec = fx.iter().filter_map(|e| if let Effect::ColorOverlay { common, .. } = e { Some(common.blend) } else { None }).collect(); + assert_eq!(modes, vec![BlendMode::VividLight]); +} + #[test] fn long_string_blend_ids_parse() { // Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 55f602a6e9..35190a9880 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -334,7 +334,16 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: /// (keeping CMYK / depth exactly) instead of going through the compositor. pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> { let [l] = &doc.layers[..] else { return None }; - let ok = l.visible + match &l.content { + LayerContent::Raster(s) if plain_layer(l, doc.mode) && s.format() == doc.pixel_format() => Some(s), + _ => None, + } +} + +/// A visible, unmasked, normal, fully opaque layer with no effects or blending options: on its +/// own, its pixels are the composite. +fn plain_layer(l: &Layer, mode: ColorMode) -> bool { + l.visible && l.opacity >= 1.0 && l.fill_opacity >= 1.0 && l.mask.is_none() @@ -342,12 +351,8 @@ pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> { && l.effects.items.is_empty() && l.effects.psd_raw.is_none() && matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough) - && photocraft_compose::channel_weights(l, doc.mode).is_none() - && !photocraft_compose::blend_if_active(l, doc.mode); - match (&l.content, ok) { - (LayerContent::Raster(s), true) if s.format() == doc.pixel_format() => Some(s), - _ => None, - } + && photocraft_compose::channel_weights(l, mode).is_none() + && !photocraft_compose::blend_if_active(l, mode) } pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout { @@ -378,45 +383,64 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result, cmyk_target: bool, lab_target: bool) -> Result { + let src = FlatSource::new(doc, warnings, cmyk_target, lab_target)?; let (w, h) = (doc.size.width, doc.size.height); - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); - let n = (w as usize) * (h as usize); - // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it - // goes through the sRGB composite, which flattens nothing when there is a single plain layer. - let single = single_layer(doc); - let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); - let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); - let img = if let Some(s) = native { - // Native path: keep model and depth. The surface's encoded samples are the codec's raw - // native-endian samples, copied a band of rows at a time (alpha dropped when opaque). - let ch = fmt.channels(); - let opaque = opaque_surface(s, canvas); - let layout = layout_for(fmt.mode, !opaque); - let bps = fmt.sample.bytes(); - let keep = if opaque { (ch - 1) * bps } else { ch * bps }; - let mut data = try_buffer(n, keep)?; - let rows = (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32; - let mut y = 0; - while y < h as i32 { - let y1 = y.saturating_add(rows).min(h as i32); - let band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); - if opaque { - for px in band.chunks_exact(ch * bps) { - data.extend_from_slice(&px[..keep]); - } - } else { - data.extend_from_slice(&band); - } - y = y1; + // 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. + let alpha = src.native.is_none_or(|s| !opaque_surface(s, doc.bounds())); + 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| { + opaque &= !translucent; + data.extend_from_slice(&band); + Ok(()) + })?; + let mut layout = src.layout(alpha); + if src.native.is_none() && opaque { + drop_alpha(&mut data, layout.color_channels() * bytes, bytes); + layout = src.layout(false); + } + Ok(Image::from_raw(w, h, layout, src.sample, data)?.with_icc(src.icc.clone()).with_meta(src.meta())) +} + +/// 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. +struct FlatSource<'a> { + doc: &'a Document, + /// The single plain layer, written in its own model and depth ([`single_layer`]). + native: Option<&'a Surface>, + /// The model of the pixels: the document's for the single layer, else CMYK, gray or RGB. + mode: ColorMode, + sample: CSample, + /// A CMYK composite mixed on ink values by the native CMYK compositor, so opaque pixels keep + /// their exact inks ([`photocraft_compose::cmyk::renders_inks`]). + inks: bool, + /// The CMYK profile any other CMYK composite is converted back through, for a CMYK target. + space: Option>, + icc: Option>, + /// Rows per band; 0 picks them from the size (tests ask for small bands). + rows: i32, +} + +impl<'a> FlatSource<'a> { + /// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels. + fn new(doc: &'a Document, warnings: &mut Vec, cmyk_target: bool, lab_target: bool) -> Result { + let fmt = doc.pixel_format(); + let sample = csample(fmt.sample); + // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it + // goes through the sRGB composite, which flattens nothing when there is a single plain layer. + let single = single_layer(doc); + let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); + let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); + if native.is_some() { + return Ok(Self { doc, native, mode: fmt.mode, sample, inks: false, space: None, icc, rows: 0 }); } - Image::from_raw(w, h, layout, csample(fmt.sample), data)? - } else { if single.is_none() { let count = doc.layer_count(); warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept")); } - let cs = csample(fmt.sample); if fmt.mode == ColorMode::Cmyk && cmyk_target { // A CMYK target is written as CMYK with the document's own profile (#2220), which it // therefore needs; RGB-only targets keep the compositor's own handling (built-in @@ -435,130 +459,141 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target if icc.is_none() { icc = Some(profile.to_bytes().to_vec()); } - let (data, opaque) = cmyk_composite(doc, canvas, cs, n, warnings)?; - Image::from_raw(w, h, layout_for(ColorMode::Cmyk, !opaque), cs, data)? - } else { - let gray = fmt.mode == ColorMode::Grayscale; - if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) { - // RGB-only targets use the sRGB composite directly, without separating CMYK; the - // compositor renders Lab documents in sRGB. Either way the file is tagged sRGB. - warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); - icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + let inks = photocraft_compose::cmyk::renders_inks(doc); + let space = if inks { + None + } else { + // Through RGB and back: the colour model is kept, exact ink values are not. + warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string()); + photocraft_compose::cmyk_space(doc) + }; + return Ok(Self { doc, native: None, mode: ColorMode::Cmyk, sample, inks, space, icc, rows: 0 }); + } + if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) { + // RGB-only targets use the sRGB composite directly, without separating CMYK; the + // compositor renders Lab documents in sRGB. Either way the file is tagged sRGB. + warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); + icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + } + let mode = if fmt.mode == ColorMode::Grayscale { ColorMode::Grayscale } else { ColorMode::Rgb }; + Ok(Self { doc, native: None, mode, sample, inks: false, space: None, icc, rows: 0 }) + } + + fn layout(&self, alpha: bool) -> ChannelLayout { + layout_for(self.mode, alpha) + } + + /// The document's EXIF, XMP, text and resolution. + fn meta(&self) -> codecs::Metadata { + let doc = self.doc; + codecs::Metadata { + exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), + xmp: doc.metadata.xmp.as_deref().map(str::to_owned), + text: doc.metadata.text.clone(), + dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), + ..Default::default() + } + } + + /// Every row of the document, top to bottom, as bands of interleaved native-endian samples + /// in [`Self::layout`]`(alpha)`: `sink(rows, samples, translucent)`, where `translucent` says + /// 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> { + let doc = self.doc; + let (w, h) = (doc.size.width, doc.size.height); + let fmt = doc.pixel_format(); + if let Some(s) = self.native { + // Native path: keep model and depth. The surface's encoded samples are the codec's raw + // native-endian samples, copied a band of rows at a time (alpha dropped when opaque). + let ch = fmt.channels(); + let bps = fmt.sample.bytes(); + let keep = (ch - 1) * bps; + 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 { + 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 { + compact_alpha(&mut band, keep, bps); + } + sink((y1 - y) as u32, band, alpha)?; + y = y1; } - let colors = if gray { 1 } else { 3 }; - // Rendered and quantised in bands (no full-size float composite), with alpha; the - // alpha is dropped afterwards, in place, when every pixel turned out opaque. - let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; - let mut opaque = true; - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[3] >= 1.0); + return Ok(()); + } + let cs = self.sample; + let put = move |out: &mut Vec, v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + 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> { + let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum()); + for part in parts { + data.extend_from_slice(&part); + } + if !alpha { + compact_alpha(&mut data, colors * cs.bytes(), cs.bytes()); + } + sink(rows, data, translucent) + }; + if self.inks { + // 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()); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); + for p in band.px.get(range).unwrap_or_default() { + for v in p { + put(&mut out, *v); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); } out }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) + emit(band.rect.height(), parts, translucent) }); - if opaque { - drop_alpha(&mut data, colors * cs.bytes(), cs.bytes()); - } - let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque); - Image::from_raw(w, h, layout, cs, data)? + return rendered.unwrap_or_else(|| Err(IoError::Unsupported("the CMYK compositor can't mix this document on its inks".into()))); } - }; - let meta = codecs::Metadata { - exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), - xmp: doc.metadata.xmp.as_deref().map(str::to_owned), - text: doc.metadata.text.clone(), - dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), - ..Default::default() - }; - Ok(img.with_icc(icc).with_meta(meta)) -} - -/// A CMYK document's composite as interleaved CMYK + alpha samples (`cs`), with whether every -/// pixel came out opaque (the caller then drops the alpha). Stacks the native CMYK compositor -/// handles are mixed on their ink values, so opaque pixels keep their exact inks; any other -/// document is composited in RGB and converted back through its own CMYK profile, as PSD -/// export's merged image is. Rendered and quantised in bands on every core. -fn cmyk_composite(doc: &Document, canvas: Rect, cs: CSample, n: usize, warnings: &mut Vec) -> Result<(Vec, bool), IoError> { - let mut data = try_buffer(n, 5 * cs.bytes())?; - let mut opaque = true; - let put = |out: &mut Vec, v: f32| { - let v = if v.is_finite() { v } else { 0.0 }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), - } - }; - let native = photocraft_compose::cmyk::render_cmyk_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[4] >= 1.0); - let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); - for p in band.px.get(range).unwrap_or_default() { - for v in p { - put(&mut out, *v); - } - } - out - }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) - }); - if native.is_none() { - // Through RGB and back: the colour model is kept, exact ink values are not. - warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string()); - let space = photocraft_compose::cmyk_space(doc); - let to = PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true); - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[3] >= 1.0); + 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| { - // The composite came through the document's CMYK profile: convert back through it. + // 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() * 5 * cs.bytes()); - let mut v = [0.0f32; 5]; + 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() { - photocraft_raster::from_rgba_into(&to, *p, &mut v); - for x in v { - put(&mut out, x); + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(&mut out, *value); + } + continue; + } else if gray { + put(&mut out, photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(&mut out, p[0]); + put(&mut out, p[1]); + put(&mut out, p[2]); } + put(&mut out, p[3]); } out }) }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) - }); + emit(band.rect.height(), parts, translucent) + }) } - if opaque { - drop_alpha(&mut data, 4 * cs.bytes(), cs.bytes()); - } - Ok((data, opaque)) } /// An empty buffer with room for `pixels × bytes_per_pixel` bytes, or an error (not an abort) @@ -570,15 +605,21 @@ pub(crate) fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result, color: usize, alpha: usize) { + compact_alpha(data, color, alpha); + data.shrink_to_fit(); +} + +/// [`drop_alpha`] keeping the allocation (a band about to be handed on). +fn compact_alpha(data: &mut Vec, color: usize, alpha: usize) { let stride = color + alpha; let n = data.len() / stride; for i in 0..n { data.copy_within(i * stride..i * stride + color, i * color); } data.truncate(n * color); - data.shrink_to_fit(); } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). @@ -620,23 +661,47 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu /// [`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 { - use photocraft_cms::{Builtin, Intent}; if let Some(r) = export_mode_specific(doc, format, opts)? { return Ok(r); } + if format == Format::Png && streams_png(doc) { + return export_png_streamed(doc, opts, ctl, 0); + } 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)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(composite_share(format)); - if opts.xmp == XmpEmbed::None { - // Export As's Metadata: None: the packet lists the text of every type layer and one id - // per placed document (#647). - img.meta.xmp = None; - // Free-form descriptions can contain the same sensitive text as an XMP packet. - img.meta.text.clear(); + 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)?; } + if format == Format::Dds { + img = dds_image(doc, img, &mut warnings)?; + } + if format == Format::Tiff { + warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img)); + } + encode_image(&img, format, opts, warnings) +} + +/// Export As's Metadata: None drops the XMP packet, which lists the text of every type layer and +/// one id per placed document (#647), and the free-form descriptions, which can hold the same +/// sensitive text. +fn strip_xmp(meta: &mut codecs::Metadata, opts: &ExportOptions) { + if opts.xmp == XmpEmbed::None { + meta.xmp = None; + meta.text.clear(); + } +} + +/// `img` made storable as `format`: matted over white without alpha support, CMYK converted for a +/// format without CMYK, colours converted to what an untagged file means. Pixel by pixel, so a +/// band of rows gets what the whole image would (and the same warnings). +fn prepare_flat(mut img: Image, format: Format, warnings: &mut Vec) -> Result { + use photocraft_cms::{Builtin, Intent}; if img.layout().has_alpha() && !format.caps().alpha { // Flattened over white, as saving a transparent document without transparency does. img = matte_over_white(&img)?; @@ -666,16 +731,120 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti warnings.push(format!("colours converted to sGray; {format:?} can't embed the document's colour profile")); } } - if format == Format::Tga { - img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; + Ok(img) +} + +/// Decoded pixel bytes past which a PNG export is written a band of rows at a time +/// ([`export_png_streamed`]) instead of from one image buffer (about 90 MP of 8-bit RGB). +const STREAM_PNG_BYTES: u64 = 256 << 20; + +/// Whether a PNG export of `doc` streams: its pixels would take more than [`STREAM_PNG_BYTES`]. +fn streams_png(doc: &Document) -> bool { + let fmt = doc.pixel_format(); + doc.size.area().saturating_mul((fmt.channels() * fmt.sample.bytes()) as u64) > STREAM_PNG_BYTES +} + +/// A PNG export rendered, converted and encoded a band of rows at a time, so no full-size image +/// is held: the only way a multi-gigapixel print (a 117504 × 22464 px banner is 7.9 GB of 8-bit +/// RGB) can be written as PNG (#8456). The pixels are the ones [`export_flat`] writes for a +/// smaller document, with these differences: Interlaced is turned off (Adam7 needs the whole +/// image), and a composite whose bottom layer isn't an opaque pixel layer keeps its alpha even +/// 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. + 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)? { + 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())), } - if format == Format::Dds { - img = dds_image(doc, img, &mut warnings)?; +} + +/// Whether the bottom layer is a plain pixel layer opaque over the whole canvas (a Background +/// layer): the layers above it add coverage, so the composite is (most likely) opaque too. +fn opaque_bottom(doc: &Document) -> bool { + doc.layers.first().is_some_and(|l| plain_layer(l, doc.mode) && matches!(&l.content, LayerContent::Raster(s) if opaque_surface(s, doc.bounds()))) +} + +/// [`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. +fn stream_png( + doc: &Document, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + alpha: bool, + rows: i32, + progress: &mut f32, +) -> Result, IoError> { + let format = Format::Png; + let (w, h) = (doc.size.width, doc.size.height); + 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 src = FlatSource { rows, ..FlatSource::new(doc, &mut warnings, cmyk_target, lab_target)? }; + let mut encode = opts.encode.clone(); + let mut late = Vec::new(); + if encode.png_interlaced { + encode.png_interlaced = false; + late.push(format!("written without interlacing: a {w} × {h} px PNG is written a band of rows at a time")); } - if format == Format::Tiff { - warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img)); + // One blank row through the same steps as the bands gives the file's layout, profile and + // metadata (and their warnings) before anything is rendered. + let mut meta = src.meta(); + strip_xmp(&mut meta, opts); + let row = try_buffer(w as usize, src.layout(alpha).channels() * src.sample.bytes()).map(|mut v| { + v.resize(v.capacity(), 0); + v + })?; + let first = prepare_flat(Image::from_raw(w, 1, src.layout(alpha), src.sample, row)?.with_icc(src.icc.clone()).with_meta(meta), format, &mut warnings)?; + let mut fidelity: Vec = Vec::new(); + let mut note = |img: &Image| -> Result<(), IoError> { + for f in codecs::fidelity_warnings_with(img, format, &encode) { + if f.is_fatal() { + return Err(IoError::Unsupported(f.to_string())); + } + let f = f.to_string(); + if !fidelity.contains(&f) { + fidelity.push(f); + } + } + Ok(()) + }; + note(&first)?; + 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| { + if see_through && !alpha { + translucent = true; + return Err(IoError::Cancelled); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut band_warnings = Vec::new(); + let band = prepare_flat(Image::from_raw(w, rows, src.layout(alpha), src.sample, data)?.with_icc(src.icc.clone()), format, &mut band_warnings)?; + note(&band)?; + png.push(&band)?; + done += u64::from(rows); + *progress = progress.max(done as f32 / h as f32); + ctl.progress(*progress); + Ok(()) + }); + if translucent { + return Ok(None); } - encode_image(&img, format, opts, warnings) + streamed?; + png.finish()?; + warnings.extend(fidelity); + warnings.extend(late); + Ok(Some(ExportResult { bytes, warnings })) } /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an @@ -1065,3 +1234,154 @@ mod cmyk_band_tests { assert_eq!(first[3], 255); } } + +#[cfg(test)] +mod streamed_png_tests { + use super::*; + use photocraft_raster::Interrupt; + + fn layer(doc: &Document, fills: &[(Rect, &[f32])]) -> Layer { + let mut s = Surface::new(doc.pixel_format()); + for (r, px) in fills { + s.fill_rect(*r, px); + } + Layer::new("Layer", LayerContent::Raster(s)) + } + + fn decoded(bytes: &[u8]) -> Image { + codecs::decode(bytes).unwrap() + } + + /// The bytes ahead of the first IDAT: signature, header, profile and metadata chunks. + fn head(png: &[u8]) -> &[u8] { + &png[..png.windows(4).position(|w| w == b"IDAT").unwrap() - 4] + } + + /// Streamed in bands of `rows`, `doc` gives the pixels, warnings and header chunks of the + /// whole-image export. + fn same_as_whole(doc: &Document, rows: i32, opts: &ExportOptions) -> ExportResult { + let whole = export_flat(doc, Format::Png, opts).unwrap(); + let streamed = export_png_streamed(doc, opts, &Interrupt::NONE, rows).unwrap(); + let (a, b) = (decoded(&whole.bytes), decoded(&streamed.bytes)); + assert_eq!((b.dimensions(), b.layout(), b.sample_type()), (a.dimensions(), a.layout(), a.sample_type())); + assert!(a.data() == b.data(), "{}: pixels differ", doc.name); + assert_eq!(streamed.warnings, whole.warnings); + assert!(head(&streamed.bytes) == head(&whole.bytes), "{}: header chunks differ", doc.name); + streamed + } + + #[test] + fn a_composite_over_an_opaque_bottom_layer_streams_as_rgb() { + let mut doc = Document::new("composite", Size::new(300, 700), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 300.0; + doc.metadata.xmp = Some("".into()); + doc.metadata.text = vec![("Title".into(), "Tent".into())]; + let bottom = layer(&doc, &[(doc.bounds(), &[0.9, 0.5, 0.1, 1.0]), (Rect::new(0, 300, 300, 420), &[0.1, 0.2, 0.8, 1.0])]); + let mut top = layer(&doc, &[(Rect::new(40, 100, 260, 650), &[0.2, 0.7, 0.3, 0.5])]); + top.opacity = 0.7; + top.blend = BlendMode::Multiply; + doc.layers = vec![bottom, top]; + assert!(opaque_bottom(&doc)); + for xmp in [XmpEmbed::All, XmpEmbed::None] { + let r = same_as_whole(&doc, 256, &ExportOptions { xmp, ..Default::default() }); + assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgb); + } + } + + #[test] + fn single_layers_stream_in_their_own_model_and_depth() { + // Transparent RGBA, opaque CMYK (written as sRGB through its profile), 16-bit gray and + // float RGB out of range (written with 16 bits, clipped, with the warning). + let mut rgba = Document::new("rgba", Size::new(130, 333), ColorMode::Rgb, SampleType::U8); + rgba.layers = vec![layer(&rgba, &[(Rect::new(10, 20, 120, 300), &[0.3, 0.6, 0.9, 0.4])])]; + let mut cmyk = Document::new("cmyk", Size::new(97, 211), ColorMode::Cmyk, SampleType::U8); + cmyk.icc_profile = Some(photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes()); + cmyk.layers = vec![layer(&cmyk, &[(cmyk.bounds(), &[0.3, 0.6, 0.1, 0.2, 1.0]), (Rect::new(0, 50, 97, 99), &[0.0, 0.9, 0.9, 0.0, 1.0])])]; + let mut gray = Document::new("gray", Size::new(64, 190), ColorMode::Grayscale, SampleType::U16); + gray.layers = vec![layer(&gray, &[(gray.bounds(), &[0.25, 1.0]), (Rect::new(0, 0, 64, 77), &[0.6123, 1.0])])]; + let mut hdr = Document::new("hdr", Size::new(50, 120), ColorMode::Rgb, SampleType::F32); + hdr.layers = vec![layer(&hdr, &[(hdr.bounds(), &[1.5, 0.25, -0.1, 1.0])])]; + for doc in [&rgba, &cmyk, &gray, &hdr] { + assert!(single_layer(doc).is_some(), "{}", doc.name); + same_as_whole(doc, 37, &ExportOptions::default()); + } + let r = same_as_whole(&hdr, 37, &ExportOptions::default()); + assert!(r.warnings.iter().any(|w| w.contains("clip")), "{:?}", r.warnings); + } + + /// Without an opaque bottom layer the composite keeps its alpha, even when every pixel is + /// opaque (the whole-image export finds that out and drops it). + #[test] + fn a_composite_without_an_opaque_bottom_layer_keeps_its_alpha() { + let mut doc = Document::new("masked", Size::new(80, 300), ColorMode::Rgb, SampleType::U8); + let mut bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]); + bottom.mask = Some(photocraft_doc::LayerMask { + surface: Surface::with_default(PixelFormat::GRAY8, &[1.0]), + enabled: true, + linked: true, + density: 1.0, + feather: 0.0, + }); + doc.layers = vec![bottom, layer(&doc, &[(Rect::new(0, 0, 40, 40), &[1.0, 0.0, 0.0, 1.0])])]; + assert!(!opaque_bottom(&doc)); + let whole = decoded(&export_flat(&doc, Format::Png, &ExportOptions::default()).unwrap().bytes); + let streamed = decoded(&export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::NONE, 256).unwrap().bytes); + assert_eq!((whole.layout(), streamed.layout()), (ChannelLayout::Rgb, ChannelLayout::Rgba)); + assert!(whole.convert(ChannelLayout::Rgba, CSample::U8).data() == streamed.data()); + } + + /// A knockout through an opaque bottom layer makes the guess wrong: the export starts again + /// with alpha and keeps the hole, and its progress never goes back. + #[test] + fn a_wrong_opacity_guess_starts_again_with_alpha() { + // The hole is in the fourth band: three are written before the guess turns out wrong. + let mut doc = Document::new("knockout", Size::new(64, 1000), ColorMode::Rgb, SampleType::U8); + let bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]); + let mut hole = layer(&doc, &[(Rect::new(8, 800, 56, 960), &[1.0, 1.0, 1.0, 1.0])]); + hole.fill_opacity = 0.0; + hole.advanced.knockout = photocraft_doc::Knockout::Deep; + doc.layers = vec![bottom, hole]; + assert!(opaque_bottom(&doc)); + assert!(stream_png(&doc, &ExportOptions::default(), &Interrupt::NONE, false, 256, &mut 0.0).unwrap().is_none()); + let r = same_as_whole(&doc, 256, &ExportOptions::default()); + assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgba); + + let seen = std::sync::Mutex::new(Vec::new()); + let report = |f: f32| seen.lock().unwrap().push(f); + let never = || false; + export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::new(&never, &report), 256).unwrap(); + let seen = seen.into_inner().unwrap(); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "progress went back: {seen:?}"); + assert_eq!(seen.last(), Some(&1.0)); + } + + #[test] + fn interlacing_is_turned_off_with_a_warning() { + let mut doc = Document::new("interlaced", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])]; + let mut opts = ExportOptions::default(); + opts.encode.png_interlaced = true; + let r = export_png_streamed(&doc, &opts, &Interrupt::NONE, 7).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("without interlacing")), "{:?}", r.warnings); + let whole = decoded(&export_flat(&doc, Format::Png, &opts).unwrap().bytes); + assert!(decoded(&r.bytes).data() == whole.data()); + } + + #[test] + fn a_cancelled_stream_stops_with_cancelled() { + let mut doc = Document::new("cancel", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])]; + let yes = || true; + let r = export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::cancel_only(&yes), 7); + assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); + } + + #[test] + fn only_large_documents_stream() { + let doc = |w, h, depth| Document::new("size", Size::new(w, h), ColorMode::Rgb, depth); + assert!(!streams_png(&doc(6000, 4000, SampleType::U8))); + assert!(streams_png(&doc(10_000, 8000, SampleType::U8))); + assert!(streams_png(&doc(8000, 6000, SampleType::U16))); + assert!(streams_png(&doc(117_504, 22_464, SampleType::U8))); + } +} diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index eeda1943cc..4f0b5fe54d 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -110,6 +110,12 @@ pub fn to_psd(p: &Pattern) -> PsdPattern { } } +/// A warning for each of `patterns` too large to paint: such a pattern fills with transparency, +/// so the file says so on opening. +pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec { + patterns.iter().filter_map(|p| Some(format!("pattern \"{}\" would paint nothing: {}", p.display_name(), p.paint_problem()?))).collect() +} + /// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All /// blocks share one memory budget, so repeating a block can't multiply it. pub fn from_global_blocks(doc: &Document) -> Vec { @@ -278,3 +284,26 @@ mod tests { assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks); } } + +#[cfg(test)] +mod unpaintable_tests { + use super::*; + use photocraft_doc::PixelFormat; + use photocraft_raster::Surface; + + #[test] + fn a_pattern_too_large_to_paint_is_named_on_opening() { + let sparse = |name: &str, format: PixelFormat, side: u32| Pattern { + id: name.into(), + name: name.into(), + width: side, + height: side, + surface: Surface::new(format), + }; + let patterns = + [sparse("Small", PixelFormat::RGBA8, 64), sparse("Huge float", PixelFormat::RGBA32F, 5000), sparse("Largest 8-bit", PixelFormat::RGBA8, 8000)]; + let warnings = unpaintable_warnings(&patterns); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}"); + } +} diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 633d7f19c2..a88773e05c 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1122,6 +1122,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do )); } doc.patterns = crate::pattern_map::from_global_blocks(doc); + warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns)); doc.notes = crate::annotations_map::notes_from_blocks(doc); if crate::annotations_map::anno_unreadable(doc) { warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into()); diff --git a/crates/io/src/smart_map/halftone_tests.rs b/crates/io/src/smart_map/halftone_tests.rs index 931483c55b..d4f67dbfec 100644 --- a/crates/io/src/smart_map/halftone_tests.rs +++ b/crates/io/src/smart_map/halftone_tests.rs @@ -154,6 +154,19 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() { } } +/// Keeps a Gallery stack opaque when its own descriptor holds a setting beside the effect list, +/// which a save would otherwise drop. +#[test] +fn halftone_pattern_unknown_outer_setting_stays_verbatim() { + let valid = native_effect("ScrD", 2, 5); + let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7)); + let item = native_item(gallery); + let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap(); + let filter = filter_from_item(&parsed); + assert_eq!(filter.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&filter).unwrap().to_bytes(), item.to_bytes()); +} + /// Rejects unsupported export values instead of silently changing their meaning. #[test] fn halftone_pattern_invalid_export_settings_are_reported() { diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index 55bd79ec06..f8f3f50e3f 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -710,7 +710,11 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { let mut params = Map::new(); if g.key == "halftonePattern" { // Do not turn an unfamiliar shape or malformed setting into the engine's defaults: - // keeping the whole item opaque preserves it through a subsequent save. + // keeping the whole item opaque preserves it through a subsequent save. That covers + // the stack's own descriptor too: a key beside `GEfs` would not be written back. + if f.get("GEfs").is_some() && !(f.class_id.is(GALLERY_CLASS) && only_keys(f, &["GEfs"])) { + return None; + } if !e.class_id.is(GALLERY_CLASS) || !only_keys(e, &["GEfk", "HlSz", "Cntr", "ScrT", "GELv"]) || !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft")) diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index 8b85c4f3ba..d873d4a3e2 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -199,14 +199,22 @@ type Reopened = (String, Option, Vec); /// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one /// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's /// index and font features (but `calt`, on by default and written on as Photoshop does), and -/// "Hi"'s style run in the saved `Txt2`. +/// "Hi"'s style run in the saved `Txt2`. The kept layers are styled as their objects are (font +/// and size too), so their objects are saved unchanged, which this checks. fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec, E) { let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); for (text, index) in kept { let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]); + // `photoshop_txt2` writes MyriadPro-Regular at 12 pt (the document is at 72 dpi). + t.runs[0].style.size_pt = 12.0; + t.runs[0].style.postscript_name = Some("MyriadPro-Regular".into()); + 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, *index).unwrap())); + let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap(); + // As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415: + // a layer whose style disagrees with its object is saved as a style edit). + photocraft_text::psd::apply_txt2(&mut t, &raw, &photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap()); + t.psd_raw = Some(std::sync::Arc::new(raw)); doc.layers.push(Layer::new(*text, LayerContent::Text(t))); } doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))]; @@ -228,6 +236,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect()) }) .collect(); + let source = photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap(); + let object = |txt2: &E, i: i32| txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(usize::try_from(i).ok()?)).cloned(); + for (text, index) in kept { + assert_eq!(object(&txt2, *index), object(&source, *index), "{text}: the kept layer's object is saved unchanged"); + } let hi = usize::try_from(layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap()).unwrap(); let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone(); (layers, run) diff --git a/crates/io/tests/it/text_corpus.rs b/crates/io/tests/it/text_corpus.rs index 32e914d051..b0deed7746 100644 --- a/crates/io/tests/it/text_corpus.rs +++ b/crates/io/tests/it/text_corpus.rs @@ -315,3 +315,65 @@ fn created_text_layer_roundtrips_through_psd() { assert_eq!(bt.transform, t.transform); assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10)); } + +/// Review of #8888: the older Photoshop's text objects (`/DocumentObjects /TextObjects`) in the +/// real psd-tools `text.psd`. Saving the type layer unedited leaves them (and the font list they +/// index) exactly as the file has them; editing only the size changes the size of the legacy style +/// run and nothing else in it, and the object's text, paragraph runs and view are untouched. +#[cfg(feature = "corpus")] +#[test] +fn corpus_text_psd_legacy_objects_are_edited_in_place() { + use photocraft_text::engine_data::Value as E; + + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/text.psd"); + assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display()); + let bytes = std::fs::read(&path).unwrap(); + let doc = photocraft_io::import("text.psd", &bytes).unwrap().document; + let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()).expect("text.psd has a Txt2 block"); + let mut all = Vec::new(); + walk(&doc.layers, &mut all); + let layer = all.iter().find_map(|l| if let LayerContent::Text(t) = &l.content { Some(t.clone()) } else { None }).expect("a type layer"); + + let before = photocraft_text::psd::parse_txt2(&txt2).expect("the file's Txt2 parses"); + let legacy = |block: &E| block.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).cloned().expect("the legacy object"); + let features = |entry: &E| { + entry + .path(&["Model", "StyleRun", "RunArray"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|r| r.path(&["RunData", "StyleSheet", "Features"])) + .cloned() + }; + let items = |value: &E| if let E::Dict(items) = value { items.clone() } else { Vec::new() }; + + // Unedited: the legacy object and the font list are as the file has them. + let unedited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &layer)], Some(&txt2), doc.resolution_dpi)).unwrap(); + assert!(legacy(&unedited) == legacy(&before), "an unedited layer leaves the legacy text object alone"); + assert!(unedited.path(&["DocumentResources", "FontSet"]) == before.path(&["DocumentResources", "FontSet"]), "and the font list it indexes"); + + // Edited: only the size. + let mut bigger = layer.clone(); + for run in &mut bigger.runs { + run.style.size_pt += 7.0; + } + let edited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &bigger)], Some(&txt2), doc.resolution_dpi)).unwrap(); + let (was, now) = (features(&legacy(&before)).expect("features"), features(&legacy(&edited)).expect("features")); + let size = |f: &E| f.get("FontSize").and_then(E::as_f64).expect("FontSize"); + assert!((size(&now) - size(&was) - 7.0).abs() < 1e-6, "the legacy size follows the edit: {} -> {}", size(&was), size(&now)); + let rest = |f: &E| items(f).into_iter().filter(|(k, _)| k != "FontSize").collect::>(); + let changed: Vec = + rest(&was).iter().zip(rest(&now).iter()).filter(|(a, b)| a != b).map(|(a, b)| format!("{}: {:?} -> {:?}", a.0, a.1, b.1)).collect(); + assert!(rest(&was) == rest(&now), "every other style key of the run is as the file has it; changed: {changed:?}"); + let others = |entry: &E, key: &str| entry.path(&["Model", key]).cloned(); + for key in ["Text", "ParagraphRun", "KernRun"] { + assert!(others(&legacy(&edited), key) == others(&legacy(&before), key), "{key} is untouched"); + } + assert!(legacy(&edited).get("View") == legacy(&before).get("View"), "the view is untouched"); + let lengths = |entry: &E| { + entry + .path(&["Model", "StyleRun", "RunArray"]) + .and_then(E::as_array) + .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64)).collect::>()) + }; + assert!(lengths(&legacy(&edited)) == lengths(&legacy(&before)), "the runs still cover the text"); +} diff --git a/crates/io/tests/large_png_export.rs b/crates/io/tests/large_png_export.rs new file mode 100644 index 0000000000..797efab049 --- /dev/null +++ b/crates/io/tests/large_png_export.rs @@ -0,0 +1,68 @@ +//! PNG export of a multi-gigapixel print (#8456): a 136 ft × 26 ft banner at 72 ppi is +//! 117504 × 22464 px (2.64 GP, 7.9 GB of 8-bit RGB), past the 2^30 px a whole-image export could +//! hold. It is written a band of rows at a time, its zlib stream split over IDAT chunks. + +use std::io::Read; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export}; +use photocraft_raster::Surface; + +/// The IHDR fields and the IDAT payloads of `png`. +fn chunks(png: &[u8]) -> ((u32, u32, u8, u8), Vec<&[u8]>) { + let (mut at, mut ihdr, mut idat) = (8, None, Vec::new()); + while at + 12 <= png.len() { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + let data = &png[at + 8..at + 8 + len]; + match &png[at + 4..at + 8] { + b"IHDR" => ihdr = Some((u32::from_be_bytes(data[0..4].try_into().unwrap()), u32::from_be_bytes(data[4..8].try_into().unwrap()), data[8], data[9])), + b"IDAT" => idat.push(data), + _ => {} + } + at += 12 + len; + } + (ihdr.unwrap(), idat) +} + +/// Run with `cargo test --release -p photocraft-io --test large_png_export -- --ignored`; with +/// `COMPOSITE=1` a translucent layer on top makes the export composite the banner. +/// Measured 2026-10-22 (20 threads): 12.3 s and 0.49 GB peak RSS, composited 29 s and 1.2 GB; the +/// whole-image export took 12.9 s and 8.0 GB, composited 43 s and 10.3 GB. +#[test] +#[ignore = "encodes 7.9 GB of pixels; run in release"] +fn a_banner_over_two_gigapixels_exports_as_png() { + const W: u32 = 117_504; + const H: u32 = 22_464; + let mut doc = Document::new("Tent banner", Size::new(W, H), ColorMode::Rgb, SampleType::U8); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(doc.bounds(), &[1.0, 1.0, 1.0, 1.0]); + s.fill_rect(Rect::new(0, 15_000, W as i32, H as i32), &[0.1, 0.45, 0.8, 1.0]); + s.fill_rect(Rect::new(40_000, 2_000, 77_504, 14_000), &[0.55, 0.05, 0.08, 1.0]); + doc.layers.push(Layer::new("Background", LayerContent::Raster(s))); + if std::env::var_os("COMPOSITE").is_some() { + let mut t = Surface::new(doc.pixel_format()); + t.fill_rect(Rect::new(10_000, 5_000, 100_000, 20_000), &[0.9, 0.7, 0.1, 0.6]); + doc.layers.push(Layer::new("Text", LayerContent::Raster(t))); + } + + let started = std::time::Instant::now(); + let r = export(&doc, "banner.png", &ExportOptions::default()).unwrap(); + eprintln!("exported {} MB in {:.1} s; warnings {:?}", r.bytes.len() >> 20, started.elapsed().as_secs_f64(), r.warnings); + + let ((w, h, depth, color), idat) = chunks(&r.bytes); + assert_eq!((w, h, depth, color), (W, H, 8, 2), "8-bit RGB"); + // The zlib stream inflates (Adler-32 checked) to a filter byte and W RGB pixels per row. + let joined = idat.iter().flat_map(|c| c.iter().copied()).collect::>(); + let mut z = flate2::read::ZlibDecoder::new(joined.as_slice()); + let (mut buf, mut total) = (vec![0u8; 1 << 20], 0u64); + loop { + let n = z.read(&mut buf).unwrap(); + if n == 0 { + break; + } + total += n as u64; + } + assert_eq!(total, u64::from(H) * (1 + u64::from(W) * 3)); +} diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 822af3804c..bc5dd20ce9 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -6,7 +6,8 @@ //! same approach Photoshop's History panel exposes to users (one state per step). //! //! Memory is bounded by `max_states` plus an approximate byte budget. The current document's -//! tiles and tiles only history holds count against it, including selection masks. +//! tiles and tiles only history holds count against it, including selection masks, and so do the +//! embedded contents of smart objects (shared `Arc` bytes, counted once however many states hold them). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -48,8 +49,8 @@ pub struct History { /// Lazily initialized when a byte budget is used; unlimited histories pay no counting cost. tiles: Option, pub max_states: usize, - /// Pixel memory budget in bytes for the current document plus the tiles only history holds - /// (0 = unlimited). [`History::trim`] drops the oldest states beyond it. + /// Pixel memory budget in bytes for the current document plus the tiles (and embedded smart + /// object contents) only history holds (0 = unlimited). [`History::trim`] drops the oldest states beyond it. pub max_bytes: usize, /// Label of the step that produced the current document. current_label: String, @@ -534,8 +535,8 @@ impl History { self.held_states().map(|s| tile_bytes(&s.doc, &mut seen)).sum() } - /// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`] - /// bounds by [`History::max_bytes`]). + /// Pixel bytes of `current` plus the tiles only history holds, embedded smart object contents + /// included (what [`History::trim`] bounds by [`History::max_bytes`]). pub fn pixel_bytes(&self, current: &Document) -> usize { if let Some(tiles) = &self.tiles { return usize::try_from(tiles.bytes_with(current)).unwrap_or(usize::MAX); @@ -571,24 +572,25 @@ impl History { } /// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the - /// tiles only history holds (newest states first). The oldest undo states that don't fit are - /// dropped; the most recent one is always kept so the last step can be undone. The History - /// Brush's source state ([`History::set_brush_source`]) is never dropped: its unique tiles are - /// counted first, and the newest states fill what remains. Returns how many states were - /// dropped. + /// tiles only history holds (newest states first), both with embedded smart object contents. + /// The oldest undo states that don't fit are dropped; the most recent one is always kept so + /// the last step can be undone. The History Brush's source state ([`History::set_brush_source`]) + /// is never dropped: its unique tiles are counted first, and the newest states fill what + /// remains. Returns how many states were dropped. pub fn trim(&mut self, current: &Document) -> usize { self.trim_with(current, &[]) } - /// [`History::trim`] counting the tiles only `pinned` documents hold (History panel - /// snapshots, which outlive the states) against the budget too, so they can't take memory - /// beyond it unseen (#4530). The most recent state is still always kept. + /// [`History::trim`] counting the tiles (and embedded smart object contents) only `pinned` + /// documents hold (History panel snapshots, which outlive the states) against the budget too, + /// so they can't take memory beyond it unseen (#4530). The most recent state is still always + /// kept. pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize { // While Clear History can be undone, the cleared states are what history holds: the - // oldest of them go first, as the list's would. - let (undo, redo) = match &mut self.cleared { - Some(c) => (&mut c.undo, &c.redo), - None => (&mut self.undo, &self.redo), + // oldest of them go first, as the list's would. The source set before it stays pinned. + let (undo, redo, brush_source) = match &mut self.cleared { + Some(c) => (&mut c.undo, &c.redo, c.brush_source), + None => (&mut self.undo, &self.redo, self.brush_source), }; if self.max_bytes == 0 || undo.len() <= 1 { return 0; @@ -622,7 +624,7 @@ impl History { } // The History Brush's source is kept whatever its age, so its own tiles come first; the // newest states then fill what remains of the budget (the most recent one always). - let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id)); + let source = brush_source.and_then(|id| undo.iter().position(|s| s.id == id)); if let Some(state) = source.and_then(|i| undo.get(i)) { total = total.saturating_add(tile_bytes(&state.doc, &mut seen)); } @@ -632,7 +634,7 @@ impl History { continue; } total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); - if total > self.max_bytes && keep >= 1 { + if total > self.max_bytes && (keep >= 1 || source == Some(undo.len() - 1)) { break; } keep += 1; @@ -666,14 +668,13 @@ fn merge_ids(into: &mut Vec, from: &[LayerId]) { } } -/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`. +/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) and the +/// embedded contents of its smart objects not already in `seen`. fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { let mut n = 0usize; - tiles::surfaces(doc, |surface| { - for (_, tile) in surface.tiles() { - if seen.insert(Arc::as_ptr(tile) as usize) { - n = n.saturating_add(tile.bytes().len()); - } + tiles::blocks(doc, |ptr, len| { + if seen.insert(ptr) { + n = n.saturating_add(len); } }); n diff --git a/crates/ops/src/tests.rs b/crates/ops/src/tests.rs index b71b2b9ec4..a99d1d557a 100644 --- a/crates/ops/src/tests.rs +++ b/crates/ops/src/tests.rs @@ -197,6 +197,53 @@ fn clear_states_resets_the_brush_source_until_undo() { assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); } +/// The byte budget keeps the source set before Clear History among the cleared states, so +/// Undo Clear History brings it back as the History Brush's source. +#[test] +fn byte_budget_keeps_the_brush_source_of_the_cleared_states() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + assert!(h.set_brush_source(1)); + let source = h.state(1).unwrap(); + assert!(h.clear_states()); + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 3); + assert_counter(&h, &cur); + assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared"); + cur = h.undo(cur).unwrap().0; + assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step"); + assert_eq!(h.brush_source(), Some(0)); + assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state"); +} + +/// When the History Brush's source is the newest past state, it is the state the byte budget +/// always keeps, and no older state is kept beside it. +#[test] +fn byte_budget_counts_a_newest_brush_source_as_the_kept_state() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + assert!(h.set_brush_source(4)); + let source = h.state(4).unwrap(); + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 4); + assert_counter(&h, &cur); + assert_eq!(h.past_len(), 1); + assert_eq!(h.brush_source(), Some(0)); + assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source)); +} + #[test] fn new_edit_clears_redo() { let mut h = History::default(); @@ -655,6 +702,110 @@ fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() { assert_counter(&h, &cur); } +const TILE: usize = 256 * 256 * 4; +const SOURCE: usize = 1_000_000; + +fn embedded(len: usize) -> photocraft_doc::SmartSource { + photocraft_doc::SmartSource::Embedded { file_name: "source.pcraft".into(), bytes: Arc::new(vec![7; len]) } +} + +/// `base()` with one smart object holding `source`; returns the document and the layer's id. +fn smart_document(source: photocraft_doc::SmartSource) -> (Document, photocraft_doc::LayerId) { + use photocraft_doc::{Affine, LayerContent, SmartObject}; + let mut doc = base(); + let layer = Layer::new("Smart", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, None))); + let id = layer.id; + doc.insert_above(None, layer); + (doc, id) +} + +fn replace_source(doc: &mut Document, id: photocraft_doc::LayerId, source: photocraft_doc::SmartSource) { + if let Some(photocraft_doc::LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.source = source; + } +} + +#[test] +fn embedded_smart_object_contents_count_toward_history_bytes() { + let (with, _) = smart_document(embedded(SOURCE)); + let without = base(); + let mut h = History::default(); + assert_eq!(h.pixel_bytes(&with), h.pixel_bytes(&without) + SOURCE, "uncounted history walks the document"); + assert_eq!(h.pixel_bytes(&without), TILE); + // With a budget the counter keeps the figure incrementally. + let mut cur = Arc::new(with.clone()); + h.max_bytes = usize::MAX; + edit(&mut h, &mut cur, "Rename", |d| d.name = "renamed".into()); + edit(&mut h, &mut cur, "Rename again", |d| d.name = "renamed again".into()); + h.trim(&cur); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE); + assert_counter(&h, &cur); + // A linked source holds no bytes of its own. + let (linked, _) = smart_document(photocraft_doc::SmartSource::Linked { path: "a.png".into() }); + assert_eq!(History::default().pixel_bytes(&linked), TILE); +} + +#[test] +fn states_sharing_one_embedded_source_count_it_once() { + let (doc, _) = smart_document(embedded(SOURCE)); + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + for i in 0..10 { + edit(&mut h, &mut cur, &format!("Rename {i}"), |d| d.name = format!("renamed {i}")); + } + h.trim(&cur); + assert_eq!(h.past_len(), 10); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE); + assert_eq!(h.unique_bytes(&cur), 0); + assert_counter(&h, &cur); + // A duplicated layer shares the same bytes too. + edit(&mut h, &mut cur, "Duplicate", |d| { + if let Some(copy) = d.layers.last().cloned() { + d.insert_above(None, copy); + } + }); + h.trim(&cur); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE); + assert_counter(&h, &cur); +} + +#[test] +fn a_replaced_embedded_source_counts_until_the_state_holding_it_is_trimmed() { + const REPLACED: usize = 400_000; + let (doc, id) = smart_document(embedded(SOURCE)); + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + edit(&mut h, &mut cur, "Replace Contents", |d| replace_source(d, id, embedded(REPLACED))); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE + REPLACED, "the state before still holds the old contents"); + assert_eq!(h.unique_bytes(&cur), SOURCE); + edit(&mut h, &mut cur, "Rename", |d| d.name = "renamed".into()); + h.trim(&cur); + assert_counter(&h, &cur); + h.max_bytes = TILE + REPLACED; + assert_eq!(h.trim(&cur), 1, "dropping the oldest state releases the old contents"); + assert_eq!(h.pixel_bytes(&cur), TILE + REPLACED); + assert_counter(&h, &cur); +} + +#[test] +fn embedded_contents_push_old_states_out_of_the_budget() { + let (doc, id) = smart_document(embedded(SOURCE)); + let mut cur = Arc::new(doc); + let mut h = History::new(50); + for i in 0..5 { + // Each step swaps in contents of its own; only the tile is shared between states. + edit(&mut h, &mut cur, &format!("Replace {i}"), |d| replace_source(d, id, embedded(SOURCE))); + } + assert_eq!(h.trim(&cur), 0, "unlimited by default"); + assert_eq!(h.pixel_bytes(&cur), TILE + 6 * SOURCE); + // The current contents and the two newest states fit. + h.max_bytes = TILE + 3 * SOURCE; + assert_eq!(h.trim(&cur), 3); + assert_eq!(h.past_len(), 2); + assert_eq!(h.pixel_bytes(&cur), TILE + 3 * SOURCE); + assert_counter(&h, &cur); +} + #[test] fn selection_tiles_count_current_and_history_without_double_counting() { use photocraft_doc::{PixelFormat, Surface}; diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index ec191f301a..50243cc8d8 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -1,7 +1,7 @@ //! Tile references owned by history snapshots. The snapshots keep these addresses alive; //! COW edits cannot change a retained tile in place. Count occurrences (including aliases //! within one document), so removing a state releases a tile only after its last reference. -use photocraft_doc::{Document, LayerContent, Surface}; +use photocraft_doc::{Document, LayerContent, SmartSource, Surface}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -47,29 +47,42 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { } } +/// Every shared allocation `doc` holds that history can retain, as `visit(address, length)`: +/// the tiles of [`surfaces`] and the embedded contents of smart objects (an `Arc` that undo +/// states and duplicated layers share, replaced by Replace Contents). +pub(crate) fn blocks(doc: &Document, mut visit: impl FnMut(usize, usize)) { + surfaces(doc, |surface| { + for (_, tile) in surface.tiles() { + visit(Arc::as_ptr(tile) as usize, tile.bytes().len()); + } + }); + for (_, _, layer) in doc.walk() { + if let LayerContent::Smart(smart) = &layer.content + && let SmartSource::Embedded { bytes, .. } = &smart.source + { + visit(Arc::as_ptr(bytes) as usize, bytes.len()); + } + } +} + impl Tiles { pub(crate) fn add(&mut self, doc: &Document) { - surfaces(doc, |surface| { - for (_, tile) in surface.tiles() { - let count = self.refs.entry(Arc::as_ptr(tile) as usize).or_insert_with(|| { - self.bytes += tile.bytes().len() as u128; - (0, tile.bytes().len()) - }); - count.0 += 1; - } + blocks(doc, |ptr, len| { + let count = self.refs.entry(ptr).or_insert_with(|| { + self.bytes += len as u128; + (0, len) + }); + count.0 += 1; }); } pub(crate) fn remove(&mut self, doc: &Document) { - surfaces(doc, |surface| { - for (_, tile) in surface.tiles() { - let key = Arc::as_ptr(tile) as usize; - if let Some(count) = self.refs.get_mut(&key) { - count.0 -= 1; - if count.0 == 0 { - self.bytes -= count.1 as u128; - self.refs.remove(&key); - } + blocks(doc, |ptr, _| { + if let Some(count) = self.refs.get_mut(&ptr) { + count.0 -= 1; + if count.0 == 0 { + self.bytes -= count.1 as u128; + self.refs.remove(&ptr); } } }); @@ -85,12 +98,9 @@ impl Tiles { pub(crate) fn bytes_with(&self, current: &Document) -> u128 { let mut total = self.bytes; let mut seen = HashSet::new(); - surfaces(current, |surface| { - for (_, tile) in surface.tiles() { - let key = Arc::as_ptr(tile) as usize; - if !self.refs.contains_key(&key) && seen.insert(key) { - total += tile.bytes().len() as u128; - } + blocks(current, |ptr, len| { + if !self.refs.contains_key(&ptr) && seen.insert(ptr) { + total += len as u128; } }); total diff --git a/crates/paint/Cargo.toml b/crates/paint/Cargo.toml index b72880c40c..f07ffeefaf 100644 --- a/crates/paint/Cargo.toml +++ b/crates/paint/Cargo.toml @@ -12,6 +12,7 @@ photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-raster = { workspace = true } serde = { workspace = true } +rustc-hash = { workspace = true } # Large tips rasterize, accumulate and composite in parallel (the same pixels as serially). [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/paint/examples/big_dab_bench.rs b/crates/paint/examples/big_dab_bench.rs index a31265224c..b8231b9003 100644 --- a/crates/paint/examples/big_dab_bench.rs +++ b/crates/paint/examples/big_dab_bench.rs @@ -1,5 +1,5 @@ //! Where a large brush's time goes: one dab of a 2000 px tip, by stage. -//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness]` +//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness] [angle]` use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer}; @@ -10,26 +10,41 @@ fn main() { let args: Vec = std::env::args().collect(); let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(2000.0); let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); - let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; + let angle: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let brush = BrushSettings { size, hardness, angle, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; let fmt = PixelFormat::RGBA8; let mut pre = Surface::new(fmt); - pre.fill_rect(Rect::new(0, 0, 4928, 3280), &[0.5, 0.5, 0.5, 1.0]); + pre.fill_rect(Rect::new(0, 0, 6000, 4000), &[0.5, 0.5, 0.5, 1.0]); let mut target = pre.clone(); // Stage 1: the tip's coverage alone. let ctx = BrushContext::new(&brush); - let dab = Dab::round(photocraft_geom::Point::new(2464.0, 1640.0), size / 2.0, 1.0); + let mut dab = Dab::round(photocraft_geom::Point::new(3000.0, 2000.0), size / 2.0, 1.0); + dab.angle = angle; let rect = ctx.dab_rect(&dab, false); let mut cov = Vec::new(); - let t = Instant::now(); + // Warm the reusable coverage buffer and take the median of seven rasterizations. ctx.rasterize(&dab, false, rect, &mut cov); - let raster = t.elapsed(); - println!("tip {size} px, hardness {hardness}: dab rect {}x{} = {:.1} MP", rect.width(), rect.height(), rect.width() as f64 * rect.height() as f64 / 1e6); - println!(" rasterize (coverage): {:>7.1} ms", raster.as_secs_f64() * 1e3); + let mut samples = Vec::new(); + for _ in 0..7 { + let t = Instant::now(); + ctx.rasterize(std::hint::black_box(&dab), false, rect, &mut cov); + std::hint::black_box(&cov); + samples.push(t.elapsed()); + } + samples.sort(); + let raster = samples.get(3).copied().unwrap_or_default(); + println!( + "tip {size} px, hardness {hardness}, angle {angle} (24 MP document): dab rect {}x{} = {:.1} MP", + rect.width(), + rect.height(), + rect.width() as f64 * rect.height() as f64 / 1e6 + ); + println!(" rasterize (coverage): {:>7.3} ms", raster.as_secs_f64() * 1e3); // Stage 2+3: push through the renderer (rasterize + accumulate), then composite. let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); let t = Instant::now(); // Two points a pixel apart: the generator places the first dab at once (a lone point waits). - r.push(&[StrokePoint::new(2464.0, 1640.0, 1.0), StrokePoint::new(2465.0, 1640.0, 1.0)]); + 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); let t = Instant::now(); @@ -38,7 +53,7 @@ fn main() { println!(" composite (dirty tiles): {:>7.1} ms over {}x{}", comp.as_secs_f64() * 1e3, dmg.width(), dmg.height()); // A second dab half a spacing away, as a stroke does: the same again. let t = Instant::now(); - r.push(&[StrokePoint::new(2464.0 + f64::from(size) * 0.25, 1640.0, 1.0), StrokePoint::new(2466.0 + f64::from(size) * 0.25, 1640.0, 1.0)]); + r.push(&[StrokePoint::new(3000.0 + f64::from(size) * 0.25, 2000.0, 1.0), StrokePoint::new(3002.0 + f64::from(size) * 0.25, 2000.0, 1.0)]); let dmg = r.composite(&pre, &mut target, None, false, false); println!(" next dab push+composite: {:>7.1} ms over {}x{}", t.elapsed().as_secs_f64() * 1e3, dmg.width(), dmg.height()); } diff --git a/crates/paint/examples/coverage_bench.rs b/crates/paint/examples/coverage_bench.rs new file mode 100644 index 0000000000..469738c4c8 --- /dev/null +++ b/crates/paint/examples/coverage_bench.rs @@ -0,0 +1,53 @@ +//! 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. +use std::hint::black_box; +use std::time::Instant; + +use photocraft_geom::Rect; +use photocraft_paint::render::CoverageMap; + +fn run(size: i32, moving: bool) -> (f64, f32) { + let mut map = CoverageMap::new(3); + // A long stroke has already visited 2048 tiles, including negative coordinates. + for y in -16..16 { + for x in -32..32 { + map.accumulate(Rect::new(x * 64, y * 64, x * 64 + 1, y * 64 + 1), &[0.1], 0.8, None, None); + } + } + 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 dabs = 4000; + let start = Instant::now(); + for i in 0..dabs { + let (x, y) = if moving { ((i * 17) % (6016 - size), (i * 7) % (6016 - size)) } else { (i % 31 - 15, i % 29 - 14) }; + map.accumulate(black_box(Rect::new(x, y, x + size, y + size)), black_box(&vals), 0.8, None, None); + } + let micros = start.elapsed().as_secs_f64() * 1e6 / f64::from(dabs); + let checksum = black_box(map.get(0, 0) + map.get(128, 128)); + (micros, checksum) +} + +fn main() { + for size in [20, 50, 128, 256] { + for moving in [false, true] { + black_box(run(size, moving)); + let mut times = Vec::new(); + let mut checksum = 0.0; + for _ in 0..5 { + let (micros, value) = run(size, moving); + times.push(micros); + checksum += value; + } + times.sort_by(f64::total_cmp); + if let Some(median) = times.get(2) { + println!("size={size} moving={moving} us_per_dab={median:.3} checksum={checksum:.7}"); + } + } + } +} diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 74d74be893..214a28177d 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -166,6 +166,45 @@ pub fn tip_shape(radius: f32, hardness: f32) -> (f32, f32) { ((r0 * radius).min(radius - edge).max(0.0), (s * radius).max(edge).max(1e-3)) } +/// Dab-invariant round-tip geometry, shared by all pixels and subpixel samples. +#[derive(Clone, Copy)] +pub(crate) struct TipProfile { + core: f32, + shoulder: f32, + reach: f32, +} + +impl TipProfile { + #[inline] + pub(crate) fn new(radius: f32, hardness: f32) -> Self { + let (core, shoulder) = tip_shape(radius, hardness); + Self { core, shoulder, reach: core + 2.6 * shoulder } + } + + #[inline] + pub(crate) fn reach(self) -> f32 { + self.reach + } + + #[inline] + pub(crate) fn falloff(self, d: f32) -> f32 { + if d <= self.core { + return 1.0; + } + // Keep division: multiplying by a cached reciprocal changes coverage bits. + let x = (d - self.core) / self.shoulder; + soft_exp(x * x) + } + + #[inline] + pub(crate) fn coverage(self, d: f32) -> f32 { + if d.is_nan() || d >= self.reach + 0.5 { + return 0.0; + } + self.falloff(d) + } +} + /// `exp(-u)` as `(1 − u/64)⁶⁴` by six squarings: within 1/255 of `exp` where it matters, exact /// arithmetic (the same on every platform) and exactly 0 from `u = 64`. #[inline] @@ -180,12 +219,7 @@ fn soft_exp(u: f32) -> f32 { /// Opacity of a round tip at distance `d` from its centre, edge included (see [`tip_shape`]). #[inline] pub fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { - let (r0, s) = tip_shape(radius, hardness); - if d <= r0 { - return 1.0; - } - let x = (d - r0) / s; - soft_exp(x * x) + TipProfile::new(radius, hardness).falloff(d) } /// How far from its centre a round tip still paints (where [`tip_falloff`] drops below ~0.1 %). @@ -207,11 +241,7 @@ pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 { /// Coverage of a round dab at distance `d` from its centre. #[inline] pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { - // NaN distances paint nothing. - if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 { - return 0.0; - } - tip_falloff(d, radius, hardness) + TipProfile::new(radius, hardness).coverage(d) } /// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface). diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index 32b4c265ea..d11e4fa10c 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -39,6 +39,18 @@ impl Default for MixerSettings { } } +impl MixerSettings { + /// Effective per-dab wetness, including Transfer's Wetness control. + pub fn effective_wet(&self, dab: &crate::Dab) -> f32 { + self.wet.clamp(0.0, 1.0) * dab.wet.clamp(0.0, 1.0) + } + + /// Whether this dab can update the pickup well from the canvas. + pub fn samples_canvas(&self, dab: &crate::Dab) -> bool { + self.effective_wet(dab) > 0.0 + } +} + /// Paint carried between strokes (session tool state). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -158,7 +170,7 @@ pub fn apply_mixer_dab( state: &mut MixerState, lock_transparency: bool, ) { - let sample = sample.map(|surface| { + let sample = sample.filter(|_| m.samples_canvas(&fp.dab)).map(|surface| { let source = crate::retouch::Region::read(surface, work.rect); if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha { source @@ -198,37 +210,38 @@ pub fn apply_mixer_dab_region( let res = state.reservoir.map(native); let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 }; let mut pickup: Option> = state.pickup.map(native); - let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); - let mut acc = vec![0.0f32; n]; + let (mix, flow, load) = (m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); let mut paint = vec![0.0f32; n]; // Transfer › Wetness / Mix Jitter scale the settings per dab. - let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); + let (wet, mix) = (m.effective_wet(&fp.dab), mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. - acc.iter_mut().for_each(|v| *v = 0.0); - let (mut wsum, mut asum) = (0.0f32, 0.0f32); let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let w = fp.at(x, y); - if w <= 0.0 { - continue; + if wet > 0.0 { + let mut acc = vec![0.0f32; n]; + let (mut wsum, mut asum) = (0.0f32, 0.0f32); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let w = fp.at(x, y); + if w <= 0.0 { + continue; + } + let px = sample.unwrap_or(work).px(x, y); + let al = a.map_or(1.0, |ai| px[ai]); + for c in 0..nc { + acc[c] += px[c] * w * al; + } + wsum += w; + asum += w * al; } - let px = sample.unwrap_or(work).px(x, y); - let al = a.map_or(1.0, |ai| px[ai]); - for c in 0..nc { - acc[c] += px[c] * w * al; - } - wsum += w; - asum += w * al; } - } - if asum > 1e-6 && wet > 0.0 { - let ca = asum / wsum.max(1e-6); - let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); - pickup = Some(match pickup.take() { - None => canvas, - Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), - }); + if asum > 1e-6 { + let ca = asum / wsum.max(1e-6); + let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); + pickup = Some(match pickup.take() { + None => canvas, + Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), + }); + } } let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 }; let wp = if pickup.is_some() { mix * wet } else { 0.0 }; diff --git a/crates/paint/src/mixer/reference_tests.rs b/crates/paint/src/mixer/reference_tests.rs index 73242fa2f9..0f6f0a2a82 100644 --- a/crates/paint/src/mixer/reference_tests.rs +++ b/crates/paint/src/mixer/reference_tests.rs @@ -152,3 +152,56 @@ fn contiguous_mixer_matches_frozen_kernel_across_models_depths_and_spatial_loads } } } + +/// Dry dabs must preserve both the painted pixels and the carried pickup across transitions. +#[test] +fn dry_and_wet_transitions_match_frozen_kernel() { + use crate::retouch::Region; + use crate::{BrushContext, BrushSettings, Dab}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_geom::Point; + + let rect = Rect::new(0, 0, 48, 48); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, depth, alpha); + let mut surface = Surface::new(fmt); + surface.fill_rect(rect, &from_rgba(&fmt, [0.25, 0.5, 0.75, 0.6])); + let mut source = Surface::new(PixelFormat::RGBA32F); + source.fill_rect(rect, &[0.7, 0.1, 0.3, 0.8]); + let ctx = BrushContext::new(&BrushSettings { size: 16.0, ..Default::default() }); + for spatial in [false, true] { + for loaded in [false, true] { + for sample in [None, Some(&source)] { + for lock in [false, true] { + let mut actual = Region::read(&surface, rect); + let mut expected = actual.clone(); + let mut state = MixerState::default(); + if loaded { + state.load([0.8, 0.2, 0.1, 1.0]); + } + if spatial { + state.spatial = Some(vec![[0.2, 0.8, 0.4, 0.7]; 4096]); + } + let mut expected_state = state.clone(); + for (i, (base_wet, dab_wet, mix)) in + [(0.7, 1.0, 0.0), (0.0, 1.0, 0.7), (0.7, 0.0, 0.7), (0.7, 1.0, 0.7)].into_iter().enumerate() + { + let mut dab = Dab::round(Point::new(20.0 + i as f64, 20.0), 8.0, 1.0); + dab.wet = dab_wet; + let fp = ctx.footprint(&dab, i); + let settings = MixerSettings { wet: base_wet, mix, ..Default::default() }; + reference(&mut expected, &fp, fmt, sample, &settings, &mut expected_state, lock); + apply_mixer_dab(&mut actual, &fp, fmt, sample, &settings, &mut state, lock); + assert_eq!(actual, expected, "{mode:?} {depth:?} alpha={alpha} spatial={spatial} loaded={loaded} lock={lock} dab={i}"); + assert_eq!(state, expected_state, "mixer state after dab {i}"); + } + } + } + } + } + } + } + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 24f7259a76..1a721b7146 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -7,9 +7,11 @@ //! buffer is sparse and compositing always starts from the pre-stroke pixels, strokes can be fed in //! chunks and re-composited incrementally for interactive painting (see [`StrokeRenderer`]). -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; +use rustc_hash::FxHashMap; + use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -19,7 +21,7 @@ use crate::dynamics::{DabGenerator, grid_cell}; use crate::retouch::{Footprint, alpha_index, over_native}; use crate::rng::hash2; use crate::tile::{Mips, PatternImage}; -use crate::{Dab, StrokePoint, dab_coverage, tip_falloff, tip_reach}; +use crate::{Dab, StrokePoint, TipProfile}; /// Salt of the 8-bit dither's per-pixel noise. const DITHER_SALT: u64 = 0x0000_6469_7468_6572; @@ -311,7 +313,8 @@ impl BrushContext { let r = d.radius; let ro = d.roundness.max(0.5 / r).min(1.0); let rm = (r * ro).max(0.5); - let reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) }; + let profile = TipProfile::new(rm, hardness); + let reach = if aliased || mips.is_some() { rm } else { profile.reach().max(rm) }; let reach2 = (reach + 0.5) * (reach + 0.5); // Sampled-tip mapping. let (level, inv_scale, tw, th) = match mips { @@ -342,7 +345,9 @@ impl BrushContext { if !(mips.is_some() || small || (aliased && b.square) || noise || tex_tip || wet) && depth.is_none() && proj.is_none() - && d.angle == 0.0 + // Rotation preserves a circle's distance; ellipses still need the transform. + && (d.angle == 0.0 || ro == 1.0) + && d.angle.is_finite() && d.radius.is_finite() && d.roundness.is_finite() && rm.is_finite() @@ -352,7 +357,7 @@ impl BrushContext { && d.alpha.is_finite() && w > 0 { - rasterize_round(d, rect, out, hardness, aliased); + rasterize_round(d, rect, out, profile, aliased); return; } for yy in 0..h { @@ -382,7 +387,7 @@ impl BrushContext { 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 += tip_falloff(sd, rm, hardness); + sum += profile.falloff(sd); } } (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) @@ -393,7 +398,7 @@ impl BrushContext { continue; } let dd = d2.sqrt(); - let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { dab_coverage(dd, rm, hardness) }; + let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; (val, dd / rm) } Some(m) => { @@ -475,10 +480,28 @@ struct CovTile { col: Vec, } +impl CovTile { + fn new() -> Self { + Self { cov: vec![0.0; (COV_TILE * COV_TILE) as usize], depth: Vec::new(), col: Vec::new() } + } +} + +/// Preserve both signed coordinates without collisions. FxHash mixes the packed word so +/// horizontal and vertical runs both distribute across the table's buckets. +#[inline] +fn tile_key((tx, ty): (i32, i32)) -> u64 { + (u64::from(tx as u32) << 32) | u64::from(ty as u32) +} + +#[inline] +fn tile_coords(key: u64) -> (i32, i32) { + ((key >> 32) as i32, key as i32) +} + /// A sparse tiled coverage (+ optional colour) buffer. #[derive(Clone, Debug, Default)] pub struct CoverageMap { - tiles: HashMap<(i32, i32), CovTile>, + tiles: FxHashMap, nc: usize, bounds: Rect, dirty: HashSet<(i32, i32)>, @@ -494,14 +517,14 @@ impl CoverageMap { #[inline] pub fn get(&self, x: i32, y: i32) -> f32 { let (tx, ty) = (x.div_euclid(COV_TILE), y.div_euclid(COV_TILE)); - self.tiles.get(&(tx, ty)).map_or(0.0, |t| t.cov[((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize]) + self.tiles.get(&tile_key((tx, ty))).map_or(0.0, |t| t.cov[((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize]) } /// Wet Edges depth at a pixel (see [`CovTile::depth`]); `None` when the stroke has none. #[inline] pub fn depth(&self, x: i32, y: i32) -> Option { let (tx, ty) = (x.div_euclid(COV_TILE), y.div_euclid(COV_TILE)); - self.tiles.get(&(tx, ty)).and_then(|t| t.depth.get(((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize).copied()) + self.tiles.get(&tile_key((tx, ty))).and_then(|t| t.depth.get(((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize).copied()) } /// Accumulate dab values over `rect`: flow builds up towards the dab's opacity ceiling @@ -517,61 +540,56 @@ impl CoverageMap { let (t0x, t0y) = (rect.x0.div_euclid(COV_TILE), rect.y0.div_euclid(COV_TILE)); let (t1x, t1y) = ((rect.x1 - 1).div_euclid(COV_TILE), (rect.y1 - 1).div_euclid(COV_TILE)); let want_col = color.is_some() && nc > 0; - // The tiles this dab touches (a tile with no value in it is skipped), each independent of - // the others: a large dab's tiles accumulate in parallel, to the same values. - let mut keys: Vec<((i32, i32), Rect)> = Vec::new(); - for ty in t0y..=t1y { - for tx in t0x..=t1x { + let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc }; + // Iterate directly for small dabs: no work-list allocations or removing existing tiles. + let keys = (t0y..=t1y).flat_map(|ty| { + (t0x..=t1x).map(move |tx| { let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&rect); - if tr.is_empty() { - continue; - } - keys.push(((tx, ty), tr)); - } - } - let touched = map_items(&keys, keys.len() * (COV_TILE * COV_TILE) as usize, |(_, tr)| { - (tr.y0..tr.y1).any(|y| { - let row = (y - rect.y0) as usize * w; - vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0) + ((tx, ty), tr) }) }); - // The touched tiles leave the map while they accumulate and go back after: the dab's cost - // follows the tiles under it (one to four for a small tip), not the size of the map, which a - // long stroke grows to thousands of tiles. - let n = (COV_TILE * COV_TILE) as usize; - let mut tiles: Vec<(Rect, (i32, i32), CovTile)> = keys - .into_iter() - .zip(touched) - .filter(|(_, t)| *t) - .map(|((key, tr), _)| { - let mut tile = self.tiles.remove(&key).unwrap_or_else(|| CovTile { cov: vec![0.0; n], depth: Vec::new(), col: Vec::new() }); - if want_col && tile.col.is_empty() { - tile.col = vec![0.0; n * nc]; - } - if depth.is_some() && tile.depth.is_empty() { - tile.depth = vec![0.0; n]; - } - self.dirty.insert(key); - (tr, key, tile) - }) - .collect(); - let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc }; - // The serial path stays a plain loop over an inlined step (see `rasterize_round`). #[cfg(not(target_arch = "wasm32"))] - if parallel(tiles.len() * n) { - use rayon::prelude::*; - tiles.par_iter_mut().for_each(|(tr, key, tile)| dab.tile(*tr, *key, tile)); - } else { - for (tr, key, tile) in &mut tiles { - dab.tile(*tr, *key, tile); + { + let n = (COV_TILE * COV_TILE) as usize; + let count = (i64::from(t1x) - i64::from(t0x) + 1) as usize; + let count = count.saturating_mul((i64::from(t1y) - i64::from(t0y) + 1) as usize); + if parallel(count.saturating_mul(n)) { + let keys: Vec<_> = keys.collect(); + let touched = map_items(&keys, count.saturating_mul(n), |(_, tr)| dab.touches(*tr)); + 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. + 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) + }) + .collect(); + tiles.par_iter_mut().for_each(|(key, tr, tile)| { + dab.prepare(tile); + dab.tile(*tr, *key, tile); + }); + for (key, _, tile) in tiles { + self.tiles.insert(tile_key(key), tile); + } + } else { + self.accumulate_serial(keys.into_iter(), dab); + } + return; } } - #[cfg(target_arch = "wasm32")] - for (tr, key, tile) in &mut tiles { - dab.tile(*tr, *key, tile); - } - for (_, key, tile) in tiles { - self.tiles.insert(key, tile); + self.accumulate_serial(keys.filter(|(_, tr)| dab.touches(*tr)), dab); + } + + fn accumulate_serial(&mut self, keys: impl Iterator, dab: DabValues<'_>) { + for (key, tr) in keys { + let tile = self.tiles.entry(tile_key(key)).or_insert_with(CovTile::new); + dab.prepare(tile); + dab.tile(tr, key, tile); + self.dirty.insert(key); } } @@ -584,7 +602,7 @@ impl CoverageMap { /// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the /// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own. fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self { - let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone())); + let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(&tile_key(*k))).or_else(|| self.tiles.get(&tile_key(*k))).map(|t| (tile_key(*k), t.clone())); Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() } } @@ -595,7 +613,7 @@ impl CoverageMap { for (&key, source) in &other.tiles { let Some(target) = self.tiles.get_mut(&key) else { self.tiles.insert(key, source.clone()); - self.dirty.insert(key); + self.dirty.insert(tile_coords(key)); continue; }; if target.depth.is_empty() { @@ -615,7 +633,7 @@ impl CoverageMap { } } } - self.dirty.insert(key); + self.dirty.insert(tile_coords(key)); } } } @@ -654,6 +672,23 @@ struct DabValues<'a> { } impl DabValues<'_> { + fn touches(self, tr: Rect) -> bool { + (tr.y0..tr.y1).any(|y| { + let row = (y - self.rect.y0) as usize * self.w; + self.vals[row + (tr.x0 - self.rect.x0) as usize..row + (tr.x1 - self.rect.x0) as usize].iter().any(|&v| v > 0.0) + }) + } + + fn prepare(self, tile: &mut CovTile) { + let n = (COV_TILE * COV_TILE) as usize; + if self.color.is_some() && tile.col.is_empty() { + tile.col = vec![0.0; n * self.nc]; + } + if self.depth.is_some() && tile.depth.is_empty() { + tile.depth = vec![0.0; n]; + } + } + /// Adds the dab over `tr` (within tile `(tx, ty)`) to `tile`. #[inline(always)] fn tile(self, tr: Rect, (tx, ty): (i32, i32), tile: &mut CovTile) { @@ -756,9 +791,9 @@ impl StrokeRenderer { /// Every coverage tile (stroke and Dual Brush), into `keys`. fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) { - keys.extend(self.cov.tiles.keys()); + keys.extend(self.cov.tiles.keys().copied().map(tile_coords)); if let Some(d) = &self.dual { - keys.extend(d.tiles.keys()); + keys.extend(d.tiles.keys().copied().map(tile_coords)); } } @@ -835,7 +870,7 @@ impl StrokeRenderer { target.write_region(old, ®ion); dmg = dmg.union(&old); } - if merged.cov.tiles.contains_key(&(tx, ty)) { + if merged.cov.tiles.contains_key(&tile_key((tx, ty))) { dmg = dmg.union(&tile.intersect(&after)); } } @@ -974,7 +1009,7 @@ impl StrokeRenderer { } } let subset = |m: &CoverageMap| CoverageMap { - tiles: keys.iter().filter_map(|k| m.tiles.get(k).map(|t| (*k, t.clone()))).collect(), + tiles: keys.iter().filter_map(|k| m.tiles.get(&tile_key(*k)).map(|t| (tile_key(*k), t.clone()))).collect(), nc: m.nc, bounds: m.bounds, dirty: HashSet::new(), @@ -996,7 +1031,7 @@ impl StrokeRenderer { }; t.raster_pending(); // Tiles whose coverage the tail doesn't change still composite the same: redraw them all. - t.cov.dirty.extend(t.cov.tiles.keys().copied()); + t.cov.dirty.extend(t.cov.tiles.keys().copied().map(tile_coords)); Some(t) } @@ -1007,7 +1042,7 @@ impl StrokeRenderer { } for ty in r.y0.div_euclid(COV_TILE)..=(r.y1 - 1).div_euclid(COV_TILE) { for tx in r.x0.div_euclid(COV_TILE)..=(r.x1 - 1).div_euclid(COV_TILE) { - if self.cov.tiles.contains_key(&(tx, ty)) { + if self.cov.tiles.contains_key(&tile_key((tx, ty))) { self.cov.dirty.insert((tx, ty)); } } @@ -1054,9 +1089,9 @@ impl StrokeRenderer { let (t1x, t1y) = ((r.x1 - 1).div_euclid(COV_TILE), (r.y1 - 1).div_euclid(COV_TILE)); let span = (i64::from(t1x) - i64::from(t0x) + 1).saturating_mul(i64::from(t1y) - i64::from(t0y) + 1); if span > self.cov.tiles.len() as i64 { - return self.cov.tiles.keys().any(|&(tx, ty)| (t0x..=t1x).contains(&tx) && (t0y..=t1y).contains(&ty)); + return self.cov.tiles.keys().copied().map(tile_coords).any(|(tx, ty)| (t0x..=t1x).contains(&tx) && (t0y..=t1y).contains(&ty)); } - (t0y..=t1y).any(|ty| (t0x..=t1x).any(|tx| self.cov.tiles.contains_key(&(tx, ty)))) + (t0y..=t1y).any(|ty| (t0x..=t1x).any(|tx| self.cov.tiles.contains_key(&tile_key((tx, ty))))) } /// Dense final coverage over the stroke bounds. @@ -1064,7 +1099,8 @@ impl StrokeRenderer { let b = self.bounds(); let w = b.width() as usize; let mut out = vec![0.0f32; w * b.height() as usize]; - for (&(tx, ty), t) in &self.cov.tiles { + for (&key, t) in &self.cov.tiles { + let (tx, ty) = tile_coords(key); let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&b); for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { @@ -1087,7 +1123,7 @@ impl StrokeRenderer { pub fn composite(&mut self, pre: &Surface, target: &mut Surface, selection: Option<&Surface>, lock_transparency: bool, all: bool) -> Rect { let keys: Vec<(i32, i32)> = if all { self.cov.dirty.clear(); - let mut k: Vec<_> = self.cov.tiles.keys().copied().collect(); + let mut k: Vec<_> = self.cov.tiles.keys().copied().map(tile_coords).collect(); k.sort_unstable(); k } else { @@ -1133,7 +1169,7 @@ impl StrokeRenderer { let mut items: Vec<((i32, i32), Rect, Vec)> = batch.iter().map(|&key| (key, Rect::EMPTY, pool.pop().unwrap_or_default())).collect(); let composite_tile = |(key, done, region): &mut ((i32, i32), Rect, Vec)| { let (tx, ty) = *key; - let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return }; + let Some(tile) = self.cov.tiles.get(&tile_key((tx, ty))) else { return }; let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); if tr.is_empty() { return; @@ -1244,9 +1280,10 @@ pub fn render_stroke( r.composite(&pre, target, selection, lock_transparency, true) } -// The zero-angle transform only changes signs; squaring gives the same radial distance. +// With zero angle, the transform only changes signs. For circles, any rotation preserves +// radial distance. Both cases can omit the transform (projection is excluded by the caller). #[inline(never)] -fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: bool) { +fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], profile: TipProfile, aliased: bool) { let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) }; let ro = d.roundness.max(0.5 / d.radius).min(1.0); let rm = (d.radius * ro).max(0.5); @@ -1254,8 +1291,8 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: if w == 0 { return; } - let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) }; - let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), hardness, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 }; + let reach = if aliased { rm } else { profile.reach().max(rm) }; + let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), profile, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 }; // Rows are independent: a large tip's rows run in parallel, to the same values. The serial // path stays a plain loop over an inlined row: the same code as before the threads (a closure // passed to a helper cost a small tip half its rasterization time). @@ -1278,7 +1315,7 @@ struct RoundTip { ro: f32, rm: f32, reach2: f32, - hardness: f32, + profile: TipProfile, alpha: f32, aliased: bool, x0: i32, @@ -1300,7 +1337,7 @@ impl RoundTip { for (xx, dst) in row.iter_mut().enumerate() { let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx; let d2 = (dx * self.ro).powi(2) + dy * dy; - let val = if d2 >= self.reach2 { 0.0 } else { tip_falloff(d2.sqrt(), self.rm, self.hardness) }; + let val = if d2 >= self.reach2 { 0.0 } else { self.profile.falloff(d2.sqrt()) }; *dst = if val <= 0.0 { 0.0 } else { val * self.alpha }; } } @@ -1309,3 +1346,6 @@ impl RoundTip { #[cfg(test)] mod reference; + +#[cfg(test)] +mod coverage_tests; diff --git a/crates/paint/src/render/coverage_tests.rs b/crates/paint/src/render/coverage_tests.rs new file mode 100644 index 0000000000..0b992308ad --- /dev/null +++ b/crates/paint/src/render/coverage_tests.rs @@ -0,0 +1,190 @@ +use super::*; + +use std::collections::{HashMap, HashSet}; + +#[test] +fn packed_tile_coordinates_round_trip_without_collisions() { + let values = [i32::MIN, i32::MIN + 1, -1_000_003, -1, 0, 1, 1_000_003, i32::MAX - 1, i32::MAX]; + let coords: Vec<_> = values.iter().flat_map(|&x| values.iter().map(move |&y| (x, y))).collect(); + let keys: HashSet<_> = coords.iter().copied().map(tile_key).collect(); + assert_eq!(keys.len(), coords.len()); + for coord in coords { + assert_eq!(tile_coords(tile_key(coord)), coord); + } +} + +struct DabInput { + rect: Rect, + vals: Vec, + ceil: f32, + color: Option>, + depth: Option>, +} + +fn serial(map: &mut CoverageMap, dab: &DabInput) { + if dab.rect.is_empty() { + return; + } + map.bounds = map.bounds.union(&dab.rect); + let (x0, y0) = (dab.rect.x0.div_euclid(COV_TILE), dab.rect.y0.div_euclid(COV_TILE)); + let (x1, y1) = ((dab.rect.x1 - 1).div_euclid(COV_TILE), (dab.rect.y1 - 1).div_euclid(COV_TILE)); + let w = dab.rect.width() as usize; + let values = DabValues { vals: &dab.vals, rect: dab.rect, w, ceil: dab.ceil, color: dab.color.as_deref(), depth: dab.depth.as_deref(), nc: map.nc }; + let keys = (y0..=y1).flat_map(|ty| { + (x0..=x1).map(move |tx| { + let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&dab.rect); + ((tx, ty), tr) + }) + }); + map.accumulate_serial(keys.filter(|(_, tr)| values.touches(*tr)), values); +} + +fn pixel_index(rect: Rect, x: i32, y: i32) -> usize { + (y - rect.y0) as usize * rect.width() as usize + (x - rect.x0) as usize +} + +fn make_dab(rect: Rect, seed: usize, color: bool, depth: bool) -> DabInput { + let w = rect.width() as usize; + let h = rect.height() as usize; + let n = w * h; + let vals = (0..n).map(|i| if (i + seed).is_multiple_of(11) { 0.0 } else { (((i * 7 + seed * 3) % 13) as f32 + 1.0) / 17.0 }).collect(); + let colors = color.then(|| vec![seed as f32 / 10.0, 0.75]); + let depths = depth.then(|| (0..n).map(|i| ((i * 3 + seed * 2) % 17) as f32 / 16.0).collect()); + DabInput { rect, vals, ceil: if seed.is_multiple_of(2) { 0.72 } else { 0.93 }, color: colors, depth: depths } +} + +#[derive(Default)] +struct OraclePixel { + cov: f32, + color: [f32; 2], + has_color: bool, + depth: Option, +} + +fn apply_oracle(oracle: &mut HashMap<(i32, i32), OraclePixel>, dab: &DabInput) { + for y in dab.rect.y0..dab.rect.y1 { + for x in dab.rect.x0..dab.rect.x1 { + let i = pixel_index(dab.rect, x, y); + let v = dab.vals[i]; + if v <= 0.0 { + continue; + } + let p = oracle.entry((x, y)).or_default(); + if let Some(depth) = dab.depth.as_ref().and_then(|d| d.get(i)) { + p.depth = Some(p.depth.map_or(*depth, |old| old.max(*depth))); + } + let old = p.cov; + let new = if dab.ceil > old { old + v * (dab.ceil - old) } else { old }; + if new > old { + if let Some(color) = &dab.color { + let k = (new - old) / (1.0 - old); + for (channel, out) in p.color.iter_mut().enumerate() { + *out = color[channel] * k + *out * (1.0 - k); + } + p.has_color = true; + } + p.cov = new; + } + } + } +} + +#[test] +fn overlapping_accumulation_matches_dense_pixel_oracle_and_serial_path() { + let mut zero_only = make_dab(Rect::new(384, 0, 386, 2), 6, true, true); + zero_only.vals.fill(0.0); + let dabs = [ + make_dab(Rect::new(-67, -3, 68, 67), 1, false, false), // crosses negative and positive tile edges + make_dab(Rect::new(-70, -130, 260, 195), 2, true, true), // lazily enables colour and depth across more tiles + make_dab(Rect::new(62, 60, 70, 68), 3, true, true), + make_dab(Rect::new(-70, 64, -63, 70), 4, false, true), // depth continues after coverage ceiling + make_dab(Rect::new(192, 0, 193, 1), 5, true, true), // revisit an existing tile + zero_only, // all-zero tile must be skipped + ]; + let mut actual = CoverageMap::new(2); + let mut expected_serial = CoverageMap::new(2); + let mut oracle = HashMap::new(); + let mut bounds = Rect::default(); + for dab in &dabs { + actual.accumulate(dab.rect, &dab.vals, dab.ceil, dab.color.as_deref(), dab.depth.as_deref()); + serial(&mut expected_serial, dab); + apply_oracle(&mut oracle, dab); + bounds = bounds.union(&dab.rect); + } + + assert_eq!(actual.bounds(), bounds); + assert_eq!(actual.bounds(), expected_serial.bounds()); + let expected_dirty: HashSet<_> = expected_serial.dirty.iter().copied().collect(); + assert_eq!(actual.take_dirty().into_iter().collect::>(), expected_dirty); + assert_eq!(expected_serial.take_dirty().into_iter().collect::>(), expected_dirty); + + let tile_coords_actual: HashSet<_> = actual.tiles.keys().copied().map(tile_coords).collect(); + assert_eq!(tile_coords_actual, expected_dirty); + assert!(!tile_coords_actual.contains(&(6, 0))); // the zero-only tile was never allocated + assert!(tile_coords_actual.len() >= 16); + + for y in -130..195 { + for x in -70..260 { + let i = pixel_index_for_tile(x, y); + let tx = x.div_euclid(COV_TILE); + let ty = y.div_euclid(COV_TILE); + let a = actual.tiles.get(&tile_key((tx, ty))); + let b = expected_serial.tiles.get(&tile_key((tx, ty))); + if let (Some(a), Some(b)) = (a, b) { + assert_eq!(a.cov[i].to_bits(), b.cov[i].to_bits(), "coverage at ({x}, {y})"); + assert_eq!(a.depth.get(i).map(|v| v.to_bits()), b.depth.get(i).map(|v| v.to_bits()), "depth at ({x}, {y})"); + assert_eq!( + a.col.get(i * 2..i * 2 + 2).map(|s| [s[0].to_bits(), s[1].to_bits()]), + b.col.get(i * 2..i * 2 + 2).map(|s| [s[0].to_bits(), s[1].to_bits()]), + "color at ({x}, {y})" + ); + } else { + assert!(a.is_none() && b.is_none()); + } + let p = oracle.get(&(x, y)); + let expected_cov = p.map_or(0.0, |p| p.cov); + assert_eq!(actual.get(x, y).to_bits(), expected_cov.to_bits(), "dense oracle coverage at ({x}, {y})"); + let expected_depth = p.and_then(|p| p.depth).unwrap_or(0.0); + assert_eq!(actual.depth(x, y).unwrap_or(0.0).to_bits(), expected_depth.to_bits(), "dense oracle depth at ({x}, {y})"); + if let Some(p) = p.filter(|p| p.has_color) { + let color = &actual.tiles[&tile_key((tx, ty))].col; + assert_eq!(color[i * 2].to_bits(), p.color[0].to_bits(), "dense oracle color at ({x}, {y})"); + assert_eq!(color[i * 2 + 1].to_bits(), p.color[1].to_bits(), "dense oracle color at ({x}, {y})"); + } + } + } + + // Depth still records a later dab even where coverage already reached its ceiling. + let (x, y) = (-67, 64); + let mut repeated = CoverageMap::new(0); + let values = [1.0]; + repeated.accumulate(Rect::new(x, y, x + 1, y + 1), &values, 0.5, None, None); + repeated.accumulate(Rect::new(x, y, x + 1, y + 1), &values, 0.5, None, Some(&[0.2])); + repeated.accumulate(Rect::new(x, y, x + 1, y + 1), &values, 0.5, None, Some(&[0.8])); + assert_eq!(repeated.get(x, y), 0.5); + assert_eq!(repeated.depth(x, y), Some(0.8)); +} + +fn pixel_index_for_tile(x: i32, y: i32) -> usize { + (y.rem_euclid(COV_TILE) * COV_TILE + x.rem_euclid(COV_TILE)) as usize +} + +#[test] +fn sparse_large_footprint_only_allocates_and_dirties_nonzero_tiles() { + let rect = Rect::new(-128, -128, 256, 256); + let mut vals = vec![0.0; rect.width() as usize * rect.height() as usize]; + vals[pixel_index(rect, -1, 64)] = 0.5; + let mut map = CoverageMap::new(0); + map.accumulate(rect, &vals, 0.8, Some(&[1.0]), None); + assert_eq!(map.tiles.len(), 1); + assert_eq!(map.take_dirty(), vec![(-1, 1)]); + assert_eq!(map.get(-1, 64), 0.4); + assert!(map.tiles.values().all(|tile| tile.col.is_empty() && tile.depth.is_empty())); + vals.fill(0.0); + map.accumulate(rect, &vals, 0.8, None, None); + assert!(map.take_dirty().is_empty()); + assert_eq!(map.tiles.len(), 1); + let bounds = map.bounds(); + map.accumulate(Rect::new(0, 0, 0, 0), &[], 0.8, None, None); + assert_eq!(map.bounds(), bounds); +} diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs index 10172e6fea..e10fdd099f 100644 --- a/crates/paint/src/render/reference.rs +++ b/crates/paint/src/render/reference.rs @@ -1,6 +1,7 @@ //! Frozen scalar rasterizer used only as a pixel oracle for the computed-tip fast path. use super::*; use crate::tile::GrayTile; +use crate::{soft_exp, tip_reach, tip_shape}; use photocraft_geom::Point; impl BrushContext { @@ -161,6 +162,64 @@ fn computed_tips_match_scalar_bits() { } } +#[test] +fn circular_tips_ignore_finite_rotation() { + for aliased in [false, true] { + for hardness in [0.0, 0.5, 1.0] { + let ctx = BrushContext::new(&BrushSettings { aliased, hardness, ..Default::default() }); + for radius in [3.0, 15.25, 150.0] { + for roundness in [1.0, 1.5] { + let mut d = Dab::round(Point::new(-31.25, 63.75), radius, 0.37); + d.roundness = roundness; + let rect = ctx.dab_rect(&d, false); + let expected: Vec<_> = [false, true] + .into_iter() + .map(|dual| { + let mut coverage = Vec::new(); + ctx.rasterize_reference(&d, dual, rect, &mut coverage, None); + coverage + }) + .collect(); + for angle in [-2.3, 0.7, std::f32::consts::FRAC_PI_2, std::f32::consts::PI] { + for (flip_x, flip_y) in [(false, false), (true, false), (false, true), (true, true)] { + d.angle = angle; + d.flip_x = flip_x; + d.flip_y = flip_y; + for (dual, expected) in [false, true].into_iter().zip(&expected) { + let mut actual = vec![0.75; 19]; + ctx.rasterize(&d, dual, rect, &mut actual); + assert_eq!(actual.len(), expected.len()); + let mismatch = actual.iter().zip(expected).enumerate().find(|(_, (a, b))| a.to_bits() != b.to_bits()); + assert!( + mismatch.is_none(), + "circle changed with rotation: {mismatch:?}; {d:?}, aliased={aliased}, hardness={hardness}, dual={dual}" + ); + } + } + } + } + } + } + } +} + +#[test] +fn rotated_ellipses_and_projected_circles_match_scalar_bits() { + for aliased in [false, true] { + let ctx = BrushContext::new(&BrushSettings { aliased, hardness: 0.5, ..Default::default() }); + for (roundness, proj_scale) in [(0.4, 1.0), (1.0, 0.6)] { + for angle in [0.0, 0.7, -2.3] { + let mut d = Dab::round(Point::new(-31.25, 63.75), 15.25, 0.37); + d.roundness = roundness; + d.angle = angle; + d.proj_scale = proj_scale; + d.proj_angle = 0.3; + compare(&ctx, &d, false, ctx.dab_rect(&d, false), false); + } + } + } +} + #[test] fn large_corner_dabs_keep_off_canvas_coverage() { for (hardness, aliased) in [(0.0, false), (1.0, false), (1.0, true)] { @@ -243,3 +302,58 @@ fn computed_tips_match_at_pixel_and_float_boundaries() { } } } + +#[test] +fn cached_profiles_match_scalar_for_small_and_transformed_dabs() { + for hardness in [-0.5, 0.0, 0.035, 0.37, 0.95, 1.0, 1.5, f32::NAN, f32::INFINITY] { + let ctx = BrushContext::new(&BrushSettings { hardness, ..Default::default() }); + for radius in [0.5, 1.25, 2.99, 3.0, 7.5, 100.0] { + for dual in [false, true] { + for with_depth in [false, true] { + for angle in [0.0, 0.7] { + let mut d = Dab::round(Point::new(4.25, -3.75), radius, 0.73); + d.angle = angle; + d.roundness = 0.6; + compare(&ctx, &d, dual, ctx.dab_rect(&d, dual), with_depth); + } + } + } + } + } +} + +// 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); + if d <= r0 { + return 1.0; + } + let x = (d - r0) / s; + soft_exp(x * x) +} + +fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { + // NaN distances paint nothing. + if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 { + return 0.0; + } + tip_falloff(d, radius, hardness) +} + +#[test] +fn cached_profile_preserves_public_coverage_at_boundaries() { + for radius in [-1.0, 0.0, 0.5, 3.0, 100.0, f32::NAN, f32::INFINITY] { + for hardness in [-1.0, 0.0, 0.035, 0.37, 1.0, 2.0, f32::NAN, f32::INFINITY] { + let profile = TipProfile::new(radius, hardness); + let (core, shoulder) = tip_shape(radius, hardness); + for d in [-1.0, -0.0, core, core + shoulder, profile.reach(), profile.reach() + 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] { + let expected = dab_coverage(d, radius, hardness); + assert_eq!(profile.coverage(d).to_bits(), expected.to_bits()); + assert_eq!(crate::dab_coverage(d, radius, hardness).to_bits(), expected.to_bits()); + let expected = tip_falloff(d, radius, hardness); + let actual = crate::tip_falloff(d, radius, hardness); + assert!(actual.to_bits() == expected.to_bits() || (actual.is_nan() && expected.is_nan())); + } + } + } +} diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index 19f3af1886..4acd08ac50 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -11,6 +11,10 @@ pub enum Value { Dict(Vec<(String, Value)>), Array(Vec), String(String), + /// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte). + /// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop + /// rejects a `Txt2` whose raw strings come back as UTF-16 (#9091). + RawString(String), Int(i64), Real(f64), Bool(bool), @@ -67,7 +71,7 @@ impl Value { } pub fn as_str(&self) -> Option<&str> { match self { - Value::String(s) | Value::Name(s) => Some(s), + Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s), _ => None, } } @@ -173,7 +177,7 @@ impl Parser<'_> { } } } - Ok(Value::String(decode_string(&raw))) + Ok(decode_string(&raw)) } Some(b'/') => Ok(Value::Name(self.name())), Some(_) => { @@ -205,12 +209,12 @@ impl Parser<'_> { } } -fn decode_string(raw: &[u8]) -> String { +fn decode_string(raw: &[u8]) -> Value { if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { let u: Vec = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - String::from_utf16_lossy(&u) + Value::String(String::from_utf16_lossy(&u)) } else { - raw.iter().map(|&c| c as char).collect() + Value::RawString(raw.iter().map(|&c| c as char).collect()) } } @@ -293,7 +297,18 @@ fn write_value(v: &Value, depth: usize, out: &mut Vec) { } out.extend_from_slice(b" ]"); } - Value::String(s) => { + Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => { + out.push(b'('); + for c in s.chars() { + let c = u32::from(c) as u8; + if matches!(c, b'(' | b')' | b'\\') { + out.push(b'\\'); + } + out.push(c); + } + out.push(b')'); + } + Value::String(s) | Value::RawString(s) => { out.push(b'('); let mut raw = vec![0xFE, 0xFF]; for u in s.encode_utf16() { @@ -382,6 +397,17 @@ mod tests { assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1)); } + #[test] + fn raw_strings_are_written_back_as_the_same_bytes() { + let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap(); + assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into()))); + assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)")); + assert_eq!(v.get("3"), Some(&Value::String("h".into()))); + let bytes = write(&v); + assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap(), v); + } + #[test] fn write_parse_roundtrip() { let v = parse(SAMPLE).unwrap(); diff --git a/crates/text/src/psd/build.rs b/crates/text/src/psd/build.rs index e4097c7de8..c92a3cffea 100644 --- a/crates/text/src/psd/build.rs +++ b/crates/text/src/psd/build.rs @@ -155,19 +155,9 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> { out } -/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, -/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set -/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which -/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking -/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode -/// (`/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> { - if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { - return None; - } - let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; +/// The engine text of `layer` (`\r` breaks and the trailing `\r`) as `(character run, UTF-16 +/// length)` spans: one per run that has text. +fn char_spans(layer: &TextLayer) -> Vec<(usize, i64)> { let runs = layer.char_runs(); let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); let mut spans: Vec<(usize, i64)> = Vec::new(); @@ -183,6 +173,24 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> _ => spans.push((ri, n)), } } + spans +} + +/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, +/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set +/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which +/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking +/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode +/// (`/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> { + if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let spans = char_spans(layer); let default = CharStyle::default(); let mut out = Vec::with_capacity(spans.len()); let mut looked_up = std::collections::HashMap::new(); @@ -220,6 +228,278 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Some(out) } +/// Whether the style runs of a `Txt2` text model (`/6 /0`) agree with `layer`'s character runs +/// on every key [`txt2_style_runs`] writes, the auto-kern mode aside ([`with_modes`] refits that +/// on its own): font (by name, through `fonts`, the block's font list), size, faux bold and +/// italic, leading, scales, tracking, fill colour and the OpenType keys. A key the file's run +/// lacks, or holds as another type, is no evidence of an edit and is skipped, so an unedited +/// save still gets the file's block back byte for byte (#2374); a key that disagrees means the +/// layer's style was edited since the file was written (#8415). +fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bool { + let Some(file_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return true }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + style_runs_match(layer, file_runs, "1", &["0", "0", "6"], |s, style| txt2_style_agrees(s, style, fonts, k)) +} + +/// [`txt2_style_matches`] for the older Photoshop's object at `/DocumentObjects /TextObjects` +/// (`entry`): its `Model /StyleRun /RunArray` runs hold the style under `RunData /StyleSheet +/// /Features`, named keys, with the run length under `Length`. `fonts` is the block's +/// `DocumentResources /FontSet /Resources`. +fn td_style_matches(layer: &TextLayer, entry: &E, fonts: &[E], dpi: f32) -> bool { + let Some(file_runs) = entry.path(&["Model", "StyleRun", "RunArray"]).and_then(E::as_array) else { return true }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + style_runs_match(layer, file_runs, "Length", &["RunData", "StyleSheet", "Features"], |s, style| td_style_agrees(s, style, fonts, k)) +} + +/// Whether `file_runs` (a run array whose runs hold their length under `len_key` and their style +/// at `style_path`) agree, run for run over the layer's engine text, with the layer's character +/// runs by `agrees`. A run without a style dictionary says nothing. +fn style_runs_match(layer: &TextLayer, file_runs: &[E], len_key: &str, style_path: &[&str], agrees: impl Fn(&CharStyle, &E) -> bool) -> bool { + let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get(len_key)).and_then(E::as_i64).unwrap_or(0); + let runs = layer.char_runs(); + let default = CharStyle::default(); + let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); + let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len)); + let (mut fi, mut left) = (0usize, run_len(0)); + // Each (layer run, file run) pair is compared once, however long it is. + let mut compared = None; + for (b, ch) in ed_text.char_indices() { + while b >= run_end && ri + 1 < runs.len() { + ri += 1; + run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len)); + } + while left <= 0 && fi + 1 < file_runs.len() { + fi += 1; + left = run_len(fi); + } + if compared != Some((ri, fi)) { + let s = runs.get(ri).map_or(&default, |r| &r.style); + if let Some(style) = file_runs.get(fi).and_then(|r| r.path(style_path)) + && !agrees(s, style) + { + return false; + } + compared = Some((ri, fi)); + } + left = left.saturating_sub(ch.len_utf16() as i64); + } + true +} + +/// Whether one `Txt2` style dictionary agrees with `s` (see [`txt2_style_matches`]); `k` is the +/// document's points per PostScript point, as in [`txt2_style_runs`]. +fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want))); + let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want); + let font = style.get("0").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)).and_then(|f| f.path(&["0", "0", "0"])); + if font.and_then(E::as_str).is_some_and(|name| name != postscript_for(s)) { + return false; + } + let auto_leading = s.leading_pt.is_none(); + if !(number("1", s.size_pt * k) && flag("2", s.faux_bold) && flag("3", s.faux_italic) && flag("4", auto_leading)) { + return false; + } + // An automatic leading's value is Photoshop's own; only a set one is ours to compare. + if style.get("4").and_then(E::as_bool) == Some(false) && !auto_leading && !number("5", s.leading_pt.unwrap_or(0.0) * k) { + return false; + } + if !(number("6", s.horizontal_scale) && number("7", s.vertical_scale) && number("8", s.tracking.round())) { + return false; + } + if let Some(paint) = style.get("53").and_then(|p| p.get("0")) { + let (kind, values) = fill_color(&s.color); + if paint.get("0").and_then(E::as_i64).is_some_and(|v| v != kind) { + return false; + } + if let Some(have) = paint.get("1").and_then(E::as_array) { + let same = + have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b))); + if !same { + return false; + } + } + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + if !TXT2_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) { + return false; + } + // A mask beyond the 20 sets is ignored on import (`add_txt2_features`), so here too. + 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) +} + +/// EngineData's `FontCaps` code of a style: 0 normal, 1 small caps, 2 all caps. +fn caps_code(s: &CharStyle) -> i64 { + match s.caps { + Caps::Normal => 0, + Caps::SmallCaps => 1, + Caps::AllCaps => 2, + } +} + +/// EngineData's `FontBaseline` code of a style: 0 normal, 1 superscript, 2 subscript. +fn baseline_code(s: &CharStyle) -> i64 { + match s.baseline { + FontBaseline::Normal => 0, + FontBaseline::Superscript => 1, + FontBaseline::Subscript => 2, + } +} + +/// The OpenType flags of a legacy style (`Features`), by key, and the feature each turns on: the +/// named keys of [`TXT2_FEATURES`]. +const TD_FEATURES: [(&str, &str); 7] = [ + ("ContextualLigatures", "calt"), + ("Fractions", "frac"), + ("Ordinals", "ordn"), + ("Swash", "swsh"), + ("Titling", "titl"), + ("StylisticAlternates", "salt"), + ("Ornaments", "ornm"), +]; + +/// The legacy key of the Stylistic Sets bit mask, as [`TXT2_STYLISTIC_SETS`] is for `Txt2`'s own. +const TD_STYLISTIC_SETS: &str = "StylisticSets"; + +/// The PostScript name of font `index` in a legacy font list (`DocumentResources /FontSet +/// /Resources`: `<< /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (name) /Type n >> >> >>`). +fn td_font_name(fonts: &[E], index: i64) -> Option<&str> { + fonts.get(usize::try_from(index).ok()?)?.path(&["Resource", "Identifier", "Name"])?.as_str() +} + +/// Whether one legacy `Features` dictionary agrees with `s`: [`txt2_style_agrees`] for the named +/// keys. A key the file's run lacks, or holds as another type, is no evidence of an edit. +fn td_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want))); + let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want); + if style.get("Font").and_then(E::as_i64).and_then(|i| td_font_name(fonts, i)).is_some_and(|name| name != postscript_for(s)) { + return false; + } + let auto_leading = s.leading_pt.is_none(); + if !(number("FontSize", s.size_pt * k) && flag("FauxBold", s.faux_bold) && flag("FauxItalic", s.faux_italic) && flag("AutoLeading", auto_leading)) { + return false; + } + if style.get("AutoLeading").and_then(E::as_bool) == Some(false) && !auto_leading && !number("Leading", s.leading_pt.unwrap_or(0.0) * k) { + return false; + } + if !(number("HorizontalScale", s.horizontal_scale) && number("VerticalScale", s.vertical_scale) && number("Tracking", s.tracking.round())) { + return false; + } + // Per run, like the rest: a baseline shift, the case and baseline position, the underline and + // strikethrough (a non-zero position is on, whichever position the file chose), the ligatures + // and the auto-kern mode. + let whole = |key: &str, want: i64| style.get(key).and_then(E::as_i64).is_none_or(|v| v == want); + let on = |key: &str, want: bool| style.get(key).and_then(E::as_i64).is_none_or(|v| (v != 0) == want); + if !(number("BaselineShift", s.baseline_shift_pt * k) + && whole("FontCaps", caps_code(s)) + && whole("FontBaseline", baseline_code(s)) + && on("UnderlinePosition", s.underline) + && on("StrikethroughPosition", s.strikethrough) + && flag("Ligatures", s.ligatures) + && flag("DiscretionaryLigatures", s.discretionary_ligatures) + && whole("AutoKern", txt2_mode(s))) + { + return false; + } + if let Some(paint) = style.get("FillColor").and_then(|p| p.get("Color")) { + let (kind, values) = fill_color(&s.color); + if paint.get("Type").and_then(E::as_i64).is_some_and(|v| v != kind) { + return false; + } + if let Some(have) = paint.get("Values").and_then(E::as_array) { + let same = + have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b))); + if !same { + return false; + } + } + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + if !TD_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) { + return false; + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + style.get(TD_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) +} + +/// The index of the PostScript font `name` in a legacy font list, appending it (shaped like the +/// list's first entry, with its font type) when missing. `None` for an empty list. +fn td_font(fonts: &mut Vec, name: &str) -> Option { + if let Some(i) = fonts.iter().position(|f| f.path(&["Resource", "Identifier", "Name"]).and_then(E::as_str) == Some(name)) { + return i64::try_from(i).ok(); + } + let mut entry = fonts.first()?.clone(); + entry.get_mut("Resource")?.get_mut("Identifier")?.set("Name", E::String(name.to_string())); + fonts.push(entry); + i64::try_from(fonts.len() - 1).ok() +} + +/// `entry` (an object at `/DocumentObjects /TextObjects`) with its `Model /StyleRun` runs remade +/// from the layer's character runs: the entry's first style run with its `Features` set from each +/// run's own, every other key Photoshop wrote kept, as [`txt2_style_runs`] does for the numbered +/// objects. Photoshop re-renders a type layer from this copy when the file has one, so a +/// style-only edit left here showed the old style on reopen. `None` when the first run has no +/// `Features` dictionary, or is larger than a template may be: the entry is then left as the file +/// has it. +fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec, dpi: f32) -> Option { + let base = entry.path(&["Model", "StyleRun", "RunArray"])?.as_array()?.first()?; + if !matches!(base.path(&["RunData", "StyleSheet", "Features"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let default = CharStyle::default(); + let mut out = Vec::new(); + for (ri, n) in char_spans(layer) { + let s = runs.get(ri).map_or(&default, |r| &r.style); + let mut run = base.clone(); + let features = run.get_mut("RunData")?.get_mut("StyleSheet")?.get_mut("Features")?; + if let Some(font) = td_font(fonts, &postscript_for(s)) { + features.set("Font", E::Int(font)); + } + features.set("FontSize", real(s.size_pt * k)); + features.set("FauxBold", E::Bool(s.faux_bold)); + features.set("FauxItalic", E::Bool(s.faux_italic)); + features.set("AutoLeading", E::Bool(s.leading_pt.is_none())); + // An automatic leading's value is Photoshop's own (`.01` in the files seen): kept. + if let Some(leading) = s.leading_pt { + features.set("Leading", real(leading * k)); + } + features.set("HorizontalScale", real(s.horizontal_scale)); + features.set("VerticalScale", real(s.vertical_scale)); + features.set("Tracking", E::Int(s.tracking.round() as i64)); + features.set("AutoKern", E::Int(txt2_mode(s))); + features.set("BaselineShift", real(s.baseline_shift_pt * k)); + features.set("FontCaps", E::Int(caps_code(s))); + features.set("FontBaseline", E::Int(baseline_code(s))); + // On keeps the position the file chose (a non-zero value), else the first one. + for (key, on) in [("UnderlinePosition", s.underline), ("StrikethroughPosition", s.strikethrough)] { + let kept = features.get(key).and_then(E::as_i64).filter(|v| *v != 0).unwrap_or(1); + features.set(key, E::Int(if on { kept } else { 0 })); + } + features.set("Ligatures", E::Bool(s.ligatures)); + features.set("DiscretionaryLigatures", E::Bool(s.discretionary_ligatures)); + if let Some(paint @ E::Dict(_)) = features.get_mut("FillColor").and_then(|p| p.get_mut("Color")) { + let (kind, values) = fill_color(&s.color); + paint.set("Type", E::Int(kind)); + paint.set("Values", E::Array(values)); + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + for (key, tag) in TD_FEATURES { + features.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt"))); + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + features.set(TD_STYLISTIC_SETS, E::Int(sets)); + run.set("Length", E::Int(n)); + out.push(run); + } + let mut entry = entry.clone(); + entry.get_mut("Model")?.get_mut("StyleRun")?.set("RunArray", E::Array(out)); + Some(entry) +} + /// 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. @@ -290,6 +570,10 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // in place, never grown: a layer the block has no entry for has no stale copy to fix. let mut td: Option> = prev.as_ref().and_then(|p| p.path(&["DocumentObjects", "TextObjects"])).and_then(E::as_array).map(<[E]>::to_vec); let mut td_changed = false; + // The legacy block's font list (`DocumentResources /FontSet /Resources`), which its style runs index. + let mut td_fonts: Vec = + prev.as_ref().and_then(|p| p.path(&["DocumentResources", "FontSet", "Resources"])).and_then(E::as_array).map(<[E]>::to_vec).unwrap_or_default(); + 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(); // Looked up once per save, not per layer: a crafted block with many objects must not cost @@ -332,12 +616,27 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi *slot = fixed; td_changed = true; } + // And its character style, when it no longer says what the layer says: a style-only + // edit (font, size, faux bold, …) left the old style here, and Photoshop reads it back. + if let Some(entry) = entries.get_mut(at) + && !td_style_matches(layer, entry, &td_fonts, dpi) + && let Some(restyled) = td_restyled(entry, layer, &mut td_fonts, dpi) + { + *entry = restyled; + td_changed = true; + } } // A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so // it is replaced like a missing one (below) and re-saving such a file repairs it, whether // or not the layer's text changed. let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs); - let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); + // Kept only while its style runs still say what the layer says (#8415): Photoshop reads + // the character style from them, so a style-only edit (font, faux bold, size, …) must + // restyle the object the way an edited one is, or the file's old style shows on reopen. + let kept = slot.filter(|o| { + let model = o.get("0"); + model.and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()) && model.is_some_and(|m| txt2_style_matches(layer, m, &fonts, dpi)) + }); // An edited object (#6968): the file's object at this slot with its text changed. It keeps // the file's structure — paragraph runs, the view naming its frame, the rest — with every // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in @@ -444,6 +743,11 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi && let Some(objects @ E::Dict(_)) = outer.get_mut("DocumentObjects") { objects.set("TextObjects", E::Array(entries)); + if td_fonts.len() != td_fonts_before + && let Some(set @ E::Dict(_)) = outer.get_mut("DocumentResources").and_then(|r| r.get_mut("FontSet")) + { + set.set("Resources", E::Array(td_fonts)); + } } // Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers). if let (Some(bytes), Some(p)) = (previous, &prev) diff --git a/crates/text/src/psd/edit.rs b/crates/text/src/psd/edit.rs index 1b1b90e0ff..ba08ab1e79 100644 --- a/crates/text/src/psd/edit.rs +++ b/crates/text/src/psd/edit.rs @@ -36,7 +36,7 @@ fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool { fits_budget(x, nodes, bytes) }), E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)), - E::String(s) | E::Name(s) => { + E::String(s) | E::RawString(s) | E::Name(s) => { let Some(rem) = bytes.checked_sub(s.len()) else { return false }; *bytes = rem; true @@ -150,7 +150,7 @@ pub(super) fn td_edited_object(object: &E, text: &str) -> Option { let E::Dict(entries) = model else { return None }; // The text must be there to replace: refitted runs over a text the model doesn't hold would // be a half-edited object. - if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_))) { + if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_) | E::RawString(_))) { return None; } let total = utf16_len(text) as usize; diff --git a/crates/text/src/psd/import.rs b/crates/text/src/psd/import.rs index 374c7408e5..e29ec75fb6 100644 --- a/crates/text/src/psd/import.rs +++ b/crates/text/src/psd/import.rs @@ -32,19 +32,32 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { }) else { return; }; - let Some(model) = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.get("0")) else { - return; + // The numbered object, else the older Photoshop's at `/DocumentObjects /TextObjects` (named + // keys: run lengths under `Length`, the style under `RunData /StyleSheet /Features`, the + // auto-kern mode under `AutoKern`). + let numbered = txt2 + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(|a| a.get(index)) + .and_then(|o| o.get("0")) + .and_then(|model| Some((model.get("0").and_then(E::as_str)?, model.path(&["6", "0"]).and_then(E::as_array)?))); + let legacy = || { + let model = txt2.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|a| a.get(index))?.get("Model")?; + Some((model.get("Text").and_then(E::as_str)?, model.path(&["StyleRun", "RunArray"]).and_then(E::as_array)?)) }; - let Some(text) = model.get("0").and_then(E::as_str) else { - return; + let (text, style_runs, is_numbered) = match numbered { + Some((text, runs)) => (text, runs, true), + None => { + let Some((text, runs)) = legacy() else { return }; + (text, runs, false) + } }; if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text { return; } - let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { - return; - }; - let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6"]))).collect(); + let (len_key, style_path, kern_key): (&str, &[&str], &str) = + if is_numbered { ("1", &["0", "0", "6"], "11") } else { ("Length", &["RunData", "StyleSheet", "Features"], "AutoKern") }; + let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get(len_key).and_then(E::as_f64).unwrap_or(0.0), r.path(style_path))).collect(); let lens = utf16_to_byte_lengths(text, &styles.iter().map(|m| m.0).collect::>()); // Txt2 style per byte segment, then split the model runs at segment boundaries. let mut segs: Vec<(usize, Option<&E>)> = lens.into_iter().zip(styles.iter().map(|m| m.1)).collect(); @@ -65,13 +78,13 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let txt2_style = segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end); // Txt2 has the exact mode (EngineData's reading also marks neighbours of manual // kerns manual, like Photoshop's legacy reader). - match txt2_style.and_then(|st| st.get("11")).and_then(E::as_i64) { + match txt2_style.and_then(|st| st.get(kern_key)).and_then(E::as_i64) { Some(0) => style.kerning = Kerning::Off, Some(1) => style.kerning = Kerning::Metrics, Some(2) => style.kerning = Kerning::Optical, _ => {} } - if let Some(st) = txt2_style { + if let Some(st) = txt2_style.filter(|_| is_numbered) { add_txt2_features(&mut style, st); } let len = piece_end - at; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 9dd58a55d4..b63a45b4ae 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1436,6 +1436,56 @@ fn edited_text_keeps_the_files_txt2_object() { } } +/// #8415: a style-only edit (`type.setStyle` with no text change) reaches the file's `Txt2`. +/// Photoshop reads the character style from the object's style runs, and an object whose text +/// still matched kept the file's runs, so Photoshop reopened the layer in its old font with faux +/// bold still on. A layer whose style agrees with the runs still gets the block back byte for +/// byte (#2374); one whose font or faux bold changed is restyled like an edited object, and the +/// glyph pen positions laid out in the old style are dropped. +#[test] +fn style_only_edit_restyles_the_kept_txt2_object() { + use crate::engine_data::Value as E; + // Strings in the block are UTF-16 with a BOM; every character here is below U+0100. + let utf16 = |s: &str| format!("(\u{fe}\u{ff}{})", s.chars().map(|c| format!("\0{c}")).collect::()); + let font = |name: &str| format!("<< /0 << /99 /CoolTypeFont /0 << /0 {} /2 1 >> >> >> ", utf16(name)); + // One object as Photoshop writes it: paragraph runs, style runs, a view and pen positions. + let prev = format!( + "\n\n/98 << /0 14 >> /0 << /1 << /0 [ {}{}] >> >> /1 << /1 [ << /0 << /0 {} /5 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> >> >> /1 3 >> ] >> /6 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 true /3 false /11 1 >> >> >> /1 3 >> ] >> >> /1 << /0 [ << /0 0 >> ] >> /21 << /1 [ 1.25 2.5 ] >> >> ] >>", + font("Arial-Black"), + font("ArialMT"), + utf16("AV\r"), + ); + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let style = CharStyle { postscript_name: Some("Arial-Black".into()), size_pt: 12.0, faux_bold: true, ..Default::default() }; + let t = styled("AV", style.clone()); + assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev, "an unedited style keeps the file's block"); + + let font_name = |out: &E, run: &E| { + let i = run.path(&["0", "0", "6", "0"]).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("a font index"); + out.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|f| f.get(i)).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str).map(str::to_owned) + }; + for (what, edited, want_font) in [ + ("faux bold off", CharStyle { faux_bold: false, ..style.clone() }, "Arial-Black"), + ( + "font and faux bold", + CharStyle { postscript_name: Some("AppleSDGothicNeo-Heavy".into()), faux_bold: false, ..style.clone() }, + "AppleSDGothicNeo-Heavy", + ), + ] { + let t = styled("AV", edited); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("AV\r"), "{what}: the text is unchanged"); + let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap(); + assert!(!runs.is_empty(), "{what}: the object has style runs"); + for run in runs { + assert_eq!(run.path(&["0", "0", "6", "2"]).and_then(E::as_bool), Some(false), "{what}: faux bold is off in Txt2"); + assert_eq!(font_name(&out, run).as_deref(), Some(want_font), "{what}: Txt2 names the layer's font"); + } + assert!(object.get("21").is_none(), "{what}: pen positions of the old style are dropped"); + } +} + /// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an /// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`, /// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out. @@ -3158,3 +3208,285 @@ fn per_colour_passes_match_full_size_passes() { } } } + +/// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the +/// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs +/// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`. +fn styled_era_txt2(text: &str, font: &str, size: f64, auto_kern: i64) -> Vec { + use crate::engine_data::Value as E; + let font_entry = |name: &str| { + E::Dict(vec![( + "Resource".into(), + E::Dict(vec![ + ("StreamTag".into(), E::Name("CoolTypeFont".into())), + ("Identifier".into(), E::Dict(vec![("Name".into(), E::String(name.into())), ("Type".into(), E::Int(1))])), + ]), + )]) + }; + let features = E::Dict(vec![ + ("Font".into(), E::Int(0)), + ("FontSize".into(), E::Real(size)), + ("FauxBold".into(), E::Bool(false)), + ("FauxItalic".into(), E::Bool(false)), + ("AutoLeading".into(), E::Bool(true)), + ("Leading".into(), E::Real(0.01)), + ("HorizontalScale".into(), E::Real(1.0)), + ("VerticalScale".into(), E::Real(1.0)), + ("Tracking".into(), E::Int(0)), + ("AutoKern".into(), E::Int(auto_kern)), + ("BaselineShift".into(), E::Real(0.0)), + ("FontCaps".into(), E::Int(0)), + ("FontBaseline".into(), E::Int(0)), + ("UnderlinePosition".into(), E::Int(0)), + ("StrikethroughPosition".into(), E::Int(0)), + ("Ligatures".into(), E::Bool(true)), + ("DiscretionaryLigatures".into(), E::Bool(false)), + ("ContextualLigatures".into(), E::Bool(true)), + ("StylisticSets".into(), E::Int(0)), + ( + "FillColor".into(), + E::Dict(vec![ + ("StreamTag".into(), E::Name("SimplePaint".into())), + ( + "Color".into(), + E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![E::Real(1.0), E::Real(0.0), E::Real(0.0), E::Real(0.0)]))]), + ), + ]), + ), + ]); + let total = format!("{text}\r").encode_utf16().count() as i64; + let run = |len: i64, data: E| E::Dict(vec![("RunData".into(), data), ("Length".into(), E::Int(len))]); + let placeholder = || E::Dict(vec![("0".into(), E::String(String::new()))]); + let style_data = E::Dict(vec![( + "StyleSheet".into(), + E::Dict(vec![("Name".into(), E::String(String::new())), ("Parent".into(), E::Int(0)), ("Features".into(), features)]), + )]); + let entry = E::Dict(vec![ + ( + "Model".into(), + E::Dict(vec![ + ("Text".into(), E::String(format!("{text}\r"))), + ("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), + ("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, style_data)]))])), + ("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), + ]), + ), + ("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])), + ]); + crate::engine_data::write_bare(&[ + ("DocumentResources".into(), E::Dict(vec![("FontSet".into(), E::Dict(vec![("Resources".into(), E::Array(vec![font_entry(font)]))]))])), + ("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![entry]))])), + ]) +} + +/// The length and `Features` of each style run of the first legacy object. +fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data::Value)> { + use crate::engine_data::Value as E; + block + .path(&["DocumentObjects", "TextObjects"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|e| e.path(&["Model", "StyleRun", "RunArray"])) + .and_then(E::as_array) + .map(|rs| { + rs.iter() + .map(|r| (r.get("Length").and_then(E::as_i64).unwrap_or(-1), r.path(&["RunData", "StyleSheet", "Features"]).cloned().unwrap_or(E::Int(0)))) + .collect() + }) + .unwrap_or_default() +} + +/// Review of #8888 (CodeRabbit, psd.rs): a style-only edit (no text change) restyles the older +/// Photoshop's copy too, which Photoshop re-renders from. Its font list gains the new font, the +/// run keeps every key Photoshop wrote, and the lengths still cover the text. +#[test] +fn a_style_only_edit_restyles_the_photoshop_era_objects() { + use crate::engine_data::Value as E; + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); + let style = CharStyle { + postscript_name: Some("Courier".into()), + size_pt: 20.0, + faux_bold: true, + color: Color::rgb(0.0, 0.5, 1.0), + tracking: 25.0, + ..Default::default() + }; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev))).unwrap(); + let runs = era_runs(&out); + assert_eq!(runs.len(), 1); + let (length, f) = &runs[0]; + assert_eq!(*length, 7, "the run covers the text and its trailing return"); + assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0)); + assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true)); + assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25)); + assert_eq!(f.get("Leading").and_then(E::as_f64), Some(0.01), "an automatic leading keeps Photoshop's own value"); + assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1)); + assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4)); + assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept"); + // The font went into the block's list and the run names it. + let fonts = out.path(&["DocumentResources", "FontSet", "Resources"]).and_then(E::as_array).expect("the font list"); + let index = f.get("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("the font index"); + let name = |i: usize| fonts.get(i).and_then(|r| r.path(&["Resource", "Identifier", "Name"])).and_then(E::as_str); + assert_eq!(name(index), Some("Courier")); + assert_eq!(name(0), Some("ArialMT"), "the file's font stays first"); + // The text did not change. + let text = + out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str); + assert_eq!(text, Some("Line 1\r")); +} + +/// A layer whose style already agrees with the file's run leaves the legacy objects as they are. +#[test] +fn an_unedited_style_leaves_the_photoshop_era_objects_alone() { + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); + let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev)); + assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap())); +} + +/// Both object lists, as CodeRabbit asked: the numbered object and the older copy are restyled by +/// the same style-only edit. +#[test] +fn a_style_only_edit_restyles_both_object_lists() { + use crate::engine_data::Value as E; + let mut prev = photoshop_txt2(&[(4, 1)]); + prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0, 1)); + let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() }; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap(); + let legacy = era_runs(&out); + let first = legacy.first().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64), f.get("FauxItalic").and_then(E::as_bool))); + assert_eq!(first, Some((4, Some(31.0), Some(true)))); + let numbered = out + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|o| o.path(&["0", "6", "0"])) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|r| r.path(&["0", "0", "6"])); + assert_eq!(numbered.and_then(|s| s.get("1")).and_then(E::as_f64), Some(31.0), "the numbered object is restyled too"); +} + +/// Two character runs give two style runs in the legacy copy, with the lengths of the runs. +#[test] +fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() { + use crate::engine_data::Value as E; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() }; + let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() }; + let t = runs_of("Hello", &[(2, small), (3, big)]); + let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); + let seen: Vec<(i64, Option)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect(); + assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return"); +} + +/// Review of #8888: every key the legacy restyle writes is written per run, so a multi-run edit +/// does not copy the first run's baseline shift, case, underline or strikethrough to the others. +#[test] +fn each_photoshop_era_style_run_gets_its_own_values() { + use crate::engine_data::Value as E; + use photocraft_doc::text::{Caps, FontBaseline}; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let decorated = CharStyle { baseline_shift_pt: 3.0, caps: Caps::SmallCaps, baseline: FontBaseline::Superscript, underline: true, ..base.clone() }; + let struck = CharStyle { strikethrough: true, caps: Caps::AllCaps, ligatures: false, discretionary_ligatures: true, ..base.clone() }; + let t = runs_of("Hello", &[(2, decorated), (2, base.clone()), (1, struck)]); + let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); + let key = |i: usize, k: &str| runs.get(i).and_then(|(_, f)| f.get(k)).cloned(); + assert_eq!(runs.iter().map(|r| r.0).collect::>(), vec![2, 2, 2]); + assert_eq!(key(0, "BaselineShift").and_then(|v| v.as_f64()), Some(3.0)); + assert_eq!(key(1, "BaselineShift").and_then(|v| v.as_f64()), Some(0.0), "the second run is not the first"); + assert_eq!([0, 1, 2].map(|i| key(i, "FontCaps").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(2)]); + assert_eq!([0, 1, 2].map(|i| key(i, "FontBaseline").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(0)]); + assert_eq!([0, 1, 2].map(|i| key(i, "UnderlinePosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(true), Some(false), Some(false)]); + assert_eq!([0, 1, 2].map(|i| key(i, "StrikethroughPosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(false), Some(false), Some(true)]); + assert_eq!([0, 1, 2].map(|i| key(i, "Ligatures").and_then(|v| v.as_bool())), [Some(true), Some(true), Some(false)]); + assert_eq!([0, 1, 2].map(|i| key(i, "DiscretionaryLigatures").and_then(|v| v.as_bool())), [Some(false), Some(false), Some(true)]); + let _ = E::Int(0); +} + +/// Each of those keys alone is enough to restyle the legacy copy (an edit of only that key), and +/// a layer that agrees on all of them leaves the block's bytes alone. +#[test] +fn every_legacy_style_key_is_compared() { + use photocraft_doc::text::{Caps, FontBaseline, Kerning}; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let unedited = crate::psd::build_txt2(&[(0, &styled("Hello", base.clone()))], Some(&prev)); + assert!( + era_runs(&crate::psd::parse_txt2(&unedited).unwrap()) == era_runs(&crate::psd::parse_txt2(&prev).unwrap()), + "an agreeing layer leaves the legacy copy as it is" + ); + let edits = [ + CharStyle { baseline_shift_pt: 2.0, ..base.clone() }, + CharStyle { caps: Caps::AllCaps, ..base.clone() }, + CharStyle { baseline: FontBaseline::Subscript, ..base.clone() }, + CharStyle { underline: true, ..base.clone() }, + CharStyle { strikethrough: true, ..base.clone() }, + CharStyle { ligatures: false, ..base.clone() }, + CharStyle { discretionary_ligatures: true, ..base.clone() }, + CharStyle { kerning: Kerning::Optical, ..base.clone() }, + ]; + for (i, edit) in edits.into_iter().enumerate() { + let out = crate::psd::build_txt2(&[(0, &styled("Hello", edit))], Some(&prev)); + assert!(era_runs(&crate::psd::parse_txt2(&out).unwrap()) != era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "edit {i} restyles the legacy copy"); + } +} + +/// Review of #8888: the legacy `AutoKern` mode is read on import, as `/11` is for the numbered +/// objects, so the layer a legacy file opens as agrees with its legacy copy. +#[test] +fn legacy_auto_kern_is_read_on_import() { + use photocraft_doc::text::Kerning; + let t = styled("AVA", CharStyle::default()); + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); + for (mode, want) in [(0, Kerning::Off), (1, Kerning::Metrics), (2, Kerning::Optical)] { + let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVA", "ArialMT", 13.0, mode)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert!( + back.char_runs().iter().all(|r| r.style.kerning == want), + "AutoKern {mode}: {:?}", + back.char_runs().iter().map(|r| r.style.kerning).collect::>() + ); + } + // Other text, or another object, changes nothing. + let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVX", "ArialMT", 13.0, 2)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics)); +} + +/// Review of the batch (FredApps, with #9104): a style-only save rewrites `Txt2`, and the raw +/// (non-BOM) strings Photoshop wrote in it, `/2 (0100)` and `/3 (01110)` under `/98`, must come +/// back as the same bytes: re-encoded as `FE FF` UTF-16 they make Photoshop refuse the file. +#[test] +fn a_style_only_save_keeps_the_raw_strings_of_the_file() { + let mut prev = photoshop_txt2(&[(4, 1)]); + let version = b"/98 << /0 14 >>"; + let at = prev.windows(version.len()).position(|w| w == version).expect("the version key"); + prev.splice(at..at + version.len(), b"/98 << /0 14 /2 (0100) /3 (01110) >>".iter().copied()); + // A size the file's runs do not have: the block is rewritten. + let style = CharStyle { size_pt: 31.0, faux_bold: false, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev)); + assert_ne!(out, prev, "the style edit rewrites the block"); + let has = |needle: &[u8]| out.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(0100)") && has(b"(01110)"), "the raw strings come back as the same bytes: {:?}", String::from_utf8_lossy(&out)); + assert!(!has(b"(\xfe\xff\x000\x001\x000\x000)"), "and not as UTF-16"); +} + +/// The same for the older Photoshop's objects: restyling the legacy copy leaves a raw string +/// elsewhere in the block as the file wrote it. +#[test] +fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() { + use crate::engine_data::Value as E; + let era = crate::psd::parse_txt2(&styled_era_txt2("Line 1", "ArialMT", 13.0, 1)).unwrap(); + let E::Dict(mut items) = era else { panic!("a dictionary") }; + items.push(("98".into(), E::Dict(vec![("0".into(), E::Int(14)), ("2".into(), E::RawString("0100".into()))]))); + let prev = crate::engine_data::write_bare(&items); + let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 24.0, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev)); + assert!(out.windows(6).any(|w| w == b"(0100)"), "{:?}", String::from_utf8_lossy(&out)); + 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"); +} diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 7fa07a10bc..8429b1c466 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -22,6 +22,8 @@ //! `--right-click-at X,Y` opens a screen-space context menu after the script, including panel //! and document-tab menus that are outside the document-coordinate control pointer. //! `--click-at X,Y` opens a screen-space menu (for example the top Select menu) after the script. +//! `--hover-at X,Y` leaves the pointer there without a click, for what a tool shows under it (the +//! brush outline, the Clone Source overlay). use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; use photocraft_ui_egui::{PhotocraftApp, Services}; @@ -170,6 +172,10 @@ fn main() { harness.event(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE }); harness.run_steps(4); } + if let Some((x, y)) = arg(&args, "--hover-at").and_then(|s| s.split_once(',').and_then(|(x, y)| Some((x.parse::().ok()?, y.parse::().ok()?)))) { + harness.event(egui::Event::PointerMoved(egui::pos2(x, y))); + harness.run_steps(4); + } let t_settle = std::time::Instant::now(); while t_settle.elapsed() < std::time::Duration::from_millis(settle_ms) { harness.step(); diff --git a/crates/ui-egui/src/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 92b8ee2802..32e9b19fcc 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -6,14 +6,73 @@ use super::{Color32, DRect, PhotocraftApp, Pos2, Stroke, Tool, json, pos2}; pub(crate) struct ClonePreviewCache { pub(super) key: String, - texture: egui::TextureHandle, + /// The brush a clipped overlay took its coverage from: a hardness or tip change redraws it. + pub(super) brush: Option, + /// The document area the texture covers. + pub(super) rect: DRect, + pub(super) texture: egui::TextureHandle, } -/// A transient source image beneath the brush outline; never part of the document render. +/// The most document pixels one dab's coverage is rendered over for the overlay: a 1000 px brush, +/// the largest the overlay draws, at hardness 0 reaches about 1.73 × its radius (`tip_reach`). +const CLONE_PREVIEW_MAX_PIXELS: u64 = 1800 * 1800; + +/// The largest side `retouch_cmds::clone_preview_periodic` samples, for the overlay's area and for +/// the source area it reads (`Mapping::source_rect`: the overlay's, mapped, with a 2 px apron). +const CLONE_PREVIEW_MAX_SIDE: u32 = 1024; + +/// The coverage of one dab of `brush` at `at`, as the brush engine paints it with opacity and flow +/// at full: its tip, angle, roundness and hardness (a soft tip reaches past its radius). The area +/// it covers and its `width × height` coverage values in 0..1. A very large soft tip, or a source +/// the Clone Source panel scales down (which reads a larger area than it covers), is cropped +/// around `at` to the sampling budget, keeping the middle of the dab. +fn clone_dab_coverage( + brush: &photocraft_paint::BrushSettings, + at: [f64; 2], + map: &photocraft_engine::presets::clone_source::Mapping, +) -> Option<(DRect, Vec)> { + let brush = photocraft_paint::BrushSettings { opacity: 1.0, flow: 1.0, erase: false, ..brush.clone() }; + let stroke = photocraft_paint::Stroke { brush, points: vec![photocraft_paint::StrokePoint::new(at[0], at[1], 1.0)] }; + let (rect, cov) = photocraft_paint::retouch::stroke_coverage_within(&stroke, CLONE_PREVIEW_MAX_PIXELS)?; + let fits = |r: DRect| r.width() <= CLONE_PREVIEW_MAX_SIDE && r.height() <= CLONE_PREVIEW_MAX_SIDE; + let (cx, cy) = (at[0].floor() as i32, at[1].floor() as i32); + let around = |half: i32| rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half))); + // The source area scales with the overlay's: shrink by what overflows, a few times for the + // apron's rounding. + let mut half = i32::try_from((CLONE_PREVIEW_MAX_SIDE - 4) / 2).unwrap_or(510); + let mut window = around(half); + for _ in 0..8 { + let src = map.source_rect(window); + if fits(window) && fits(src) { + break; + } + let over = f64::from(src.width().max(src.height())) / f64::from(CLONE_PREVIEW_MAX_SIDE - 4); + half = ((f64::from(half) / over.max(1.0)).floor() as i32).saturating_sub(2).max(1); + window = around(half); + } + if window.is_empty() || !fits(window) || !fits(map.source_rect(window)) { + return None; + } + if window == rect { + return Some((rect, cov)); + } + let stride = rect.width() as usize; + let mut cropped = Vec::with_capacity(window.width() as usize * window.height() as usize); + for y in window.y0..window.y1 { + let row = (y - rect.y0) as usize * stride; + let from = row + (window.x0 - rect.x0) as usize; + cropped.extend_from_slice(cov.get(from..from + window.width() as usize)?); + } + Some((window, cropped)) +} + +/// A transient source image beneath the brush outline; never part of the document render. The +/// Clone Source panel drives the Clone Stamp and the Healing Brush alike, so both show it. pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, at: [f64; 2], output: Option) { use photocraft_engine::presets::clone_source::{Mapping, overlay_blend, transform_matrix}; let overlay = app.session.presets.clone.overlay.clone(); - if app.ui.tool != Tool::CloneStamp || !overlay.show || (overlay.auto_hide && app.drag.is_some()) { + let tool = app.ui.tool; + if !matches!(tool, Tool::CloneStamp | Tool::Healing) || !overlay.show || (overlay.auto_hide && app.drag.is_some()) { return; } let slot = app.session.presets.clone.active().clone(); @@ -22,30 +81,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte if !radius.is_finite() || radius <= 0.0 || radius > 500.0 || !at.iter().all(|v| v.is_finite() && v.abs() < 1_000_000.0) { return; } + // View › Pattern Preview repeats the source for the Clone Stamp only (`paint.cloneStamp`'s + // `wrap`); the Healing Brush samples it as it is. + let wrap = tool == Tool::CloneStamp && app.ui.view.pattern_preview; let stroke_start = app.drag.as_ref().map(|d| d.start); let anchor = if app.ui.tool_options.clone_aligned { slot.anchor.or(stroke_start) } else { stroke_start }.unwrap_or(at); let map = Mapping { source: (source[0], source[1]), anchor: (anchor[0], anchor[1]), m: transform_matrix(slot.scale, slot.rotation, slot.flip_h, slot.flip_v) }; - let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32); let Some(st) = app.session.active() else { return }; let (display, display_key) = canvas_display(app, &st.doc, output); - // A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its - // outline instead of a circle, as Photoshop does with Clipped on. - let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None }; - let tip_key = tip_rings.as_ref().map(|r| { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h)); - (h.finish(), radius.to_bits(), at.map(f64::to_bits)) - }); let key = format!( - "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}", - st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview + "{:?}:{}:{:?}:{:?}:{map:?}:{}:{}:{}:{}:{wrap}:{}:{}", + st.doc.id, + st.revision, + st.active_layer, + at.map(f64::to_bits), + app.ui.tool_options.clone_sample, + display_key, + overlay.invert, + overlay.blend, + overlay.clipped, + radius.to_bits() ); - if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { - let Ok(mut buf) = - photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview) - else { + let brush = overlay.clipped.then_some(&app.session.tools.brush); + if app.clone_preview.as_ref().is_none_or(|c| c.key != key || c.brush.as_ref() != brush) { + // Clipped (Photoshop's default): the dab the tool paints here, so a soft brush previews + // soft and a shaped tip keeps its outline. Unclipped: the brush's whole square. + let (rect, coverage) = match brush { + Some(b) => { + let Some((rect, coverage)) = clone_dab_coverage(b, at, &map) else { return }; + (rect, Some(coverage)) + } + None => ( + DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32), + None, + ), + }; + let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, wrap) else { return; }; if overlay.invert { @@ -59,55 +131,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte // composite, whatever Sample reads), so Difference turns black where they line up. if overlay.blend != "normal" { let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] }; - let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else { + let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", wrap) else { return; }; for (px, d) in buf.px.iter_mut().zip(&under.px) { *px = overlay_blend(&overlay.blend, *px, *d); } } - if let Some(rings) = &tip_rings { - let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]); - for (px, inside) in buf.px.iter_mut().zip(mask) { - if !inside { - *px = [0.0; 4]; - } + if let Some(coverage) = &coverage { + for (px, c) in buf.px.iter_mut().zip(coverage) { + px[3] *= c.clamp(0.0, 1.0); } } let image = display_image(display.as_deref(), &buf); + let brush = brush.cloned(); match app.clone_preview.as_mut() { Some(cache) => { cache.texture.set(image, canvas_texture_options(1)); cache.key = key; + cache.brush = brush; + cache.rect = rect; + } + None => { + let texture = painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)); + app.clone_preview = Some(ClonePreviewCache { key, brush, rect, texture }); } - None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)) }), } } let Some(cache) = &app.clone_preview else { return }; + let rect = cache.rect; let tint = Color32::from_white_alpha((overlay.opacity.clamp(0.0, 100.0) * 2.55).round() as u8); - // A textured triangle fan provides a circular clip, including mirrored canvas views. + // A textured quad over the covered area: its texels carry the clip, in mirrored and rotated + // canvas views too. let mut mesh = egui::Mesh::with_texture(cache.texture.id()); - let vertex = |x: f64, y: f64| egui::epaint::Vertex { - pos: xf.to_screen(x as f32, y as f32), - uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32), - color: tint, - }; - if !overlay.clipped || tip_rings.is_some() { - for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] { - mesh.vertices.push(vertex(f64::from(x), f64::from(y))); - } - mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); - painter.add(egui::Shape::mesh(mesh)); - return; - } - mesh.vertices.push(vertex(at[0], at[1])); - for i in 0..=64 { - let angle = f64::from(i) * std::f64::consts::TAU / 64.0; - mesh.vertices.push(vertex(at[0] + radius * angle.cos(), at[1] + radius * angle.sin())); - } - for i in 1..=64 { - mesh.indices.extend_from_slice(&[0, i, i + 1]); + for (x, y, u, v) in [(rect.x0, rect.y0, 0.0, 0.0), (rect.x1, rect.y0, 1.0, 0.0), (rect.x1, rect.y1, 1.0, 1.0), (rect.x0, rect.y1, 0.0, 1.0)] { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: tint }); } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); painter.add(egui::Shape::mesh(mesh)); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 2f4d2bdfc5..80b9c502a9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1175,70 +1175,151 @@ fn dragging_a_tab_off_the_strip_floats_it_in_a_window() { } } +/// An app with a 64 × 64 white document, a clone source and the Clone Stamp at `size` px. +fn clone_overlay_app(size: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.session.tools.brush.size = size; + app +} + +/// One frame of the overlay at (32, 32): its shapes and texture uploads. +fn clone_overlay_frame(ctx: &egui::Context, app: &mut PhotocraftApp, xf: &ViewXform) -> egui::FullOutput { + ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); + draw_clone_preview(app, &painter, xf, [32.0, 32.0], None); + }) +} + +/// The overlay texture a frame uploaded and the alpha at a document pixel, read from the +/// document area the overlay covers (transparent outside it). +fn clone_overlay_alpha(app: &PhotocraftApp, output: &mut egui::FullOutput) -> impl Fn(i32, i32) -> u8 + use<> { + let cache = app.clone_preview.as_ref().expect("an overlay was drawn"); + let image = output + .textures_delta + .set + .get(&cache.texture.id()) + .and_then(|deltas| deltas.last()) + .map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), + }) + .expect("the overlay uploads a texture"); + output.textures_delta.clear(); + let rect = cache.rect; + assert_eq!(image.size, [rect.width() as usize, rect.height() as usize], "the texture covers the overlay's area"); + move |x, y| { + if x < rect.x0 || y < rect.y0 || x >= rect.x1 || y >= rect.y1 { + return 0; + } + image.pixels[(y - rect.y0) as usize * image.width() + (x - rect.x0) as usize].a() + } +} + +fn clone_overlay_view(side: f32, zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(side, side)), zoom, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 } +} + #[test] fn clone_overlay_is_circular_and_respects_visibility() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); let revision = app.session.active().unwrap().revision; - let mut output = ctx.run_ui(Default::default(), |ui| { - let ctx = ui.ctx(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); - output.textures_delta.clear(); - let mesh = output - .shapes - .iter() - .find_map(|s| match &s.shape { - egui::Shape::Mesh(m) => Some(m), - _ => None, - }) - .unwrap(); - assert_eq!(mesh.indices.len(), 64 * 3); - let center = xf.to_screen(32.0, 32.0); - assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01)); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "the overlay is drawn"); + let alpha = clone_overlay_alpha(&app, &mut output); + assert_eq!(alpha(32, 32), 255, "the centre shows the source"); + assert_eq!(alpha(32 + 8, 32), 255, "inside the hard 10 px radius"); + assert_eq!(alpha(32 + 8, 32 + 8), 0, "the brush square's corner, outside the circle, is clipped"); + assert_eq!(alpha(32 + 12, 32), 0, "past the hard tip's rim"); assert_eq!(app.session.active().unwrap().revision, revision); assert!(app.session.presets.clone.active().anchor.is_none()); let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone(); app.ui.view.pattern_preview = true; - let mut periodic_output = ctx.run_ui(Default::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); + let mut periodic_output = clone_overlay_frame(&ctx, &mut app, &xf); assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels"); assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit"); periodic_output.textures_delta.clear(); assert_eq!(app.session.active().unwrap().revision, revision); app.session.presets.clone.overlay.show = false; - let mut output = ctx.run_ui(Default::default(), |ui| { - let ctx = ui.ctx(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); output.textures_delta.clear(); assert!(output.shapes.iter().all(|s| !matches!(s.shape, egui::Shape::Mesh(_)))); } +/// A soft brush previews soft: the overlay fades towards the rim with the brush's hardness, as +/// the stroke it previews does, and reaches past the radius as a soft tip paints. A brush +/// change redraws an overlay that hasn't moved. +#[test] +fn clone_overlay_follows_the_brush_hardness() { + let ctx = egui::Context::default(); + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let hard = clone_overlay_alpha(&app, &mut output); + app.session.tools.brush.hardness = 0.0; + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let soft = clone_overlay_alpha(&app, &mut output); + assert!(hard(32, 32) == 255 && soft(32, 32) > 240, "both show the source at the centre: {} {}", hard(32, 32), soft(32, 32)); + assert_eq!(hard(32 + 8, 32), 255); + assert!(soft(32 + 8, 32) < 200, "a soft tip fades before its rim: {}", soft(32 + 8, 32)); + assert!(soft(32 + 4, 32) > soft(32 + 8, 32), "and fades outwards"); + assert!(hard(32 + 12, 32) == 0 && soft(32 + 13, 32) > 0, "a soft tip reaches past the radius, a hard one doesn't"); + // The largest brush the overlay draws still shows at its softest. + let mut big = clone_overlay_app(1000.0); + big.session.tools.brush.hardness = 0.0; + let mut output = clone_overlay_frame(&ctx, &mut big, &xf); + assert_eq!(clone_overlay_alpha(&big, &mut output)(32, 32), 255, "a 1000 px soft brush previews too"); +} + +/// A scaled Clone Source reads a larger source area than the overlay covers: a large brush's +/// overlay shrinks around the pointer until that area fits the sampling budget, rather than +/// vanishing. +#[test] +fn clone_overlay_of_a_scaled_source_stays_within_the_sampling_budget() { + let ctx = egui::Context::default(); + let xf = clone_overlay_view(100.0, 1.0); + for (size, scale) in [(600.0, 200), (1000.0, 300), (1000.0, 50)] { + let mut app = clone_overlay_app(size); + app.run("cloneSource.set", json!({"width": scale, "height": scale})).unwrap(); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "{size} px at {scale} %: the overlay shows"); + assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{size} px at {scale} %: the source under the pointer"); + } +} + +/// The Healing Brush shares the Clone Stamp's source and overlay (Photoshop's Clone Source +/// panel drives both); other tools show none. +#[test] +fn clone_overlay_shows_for_the_healing_brush_too() { + let ctx = egui::Context::default(); + let xf = clone_overlay_view(100.0, 1.0); + for (tool, shown) in [(Tool::Healing, true), (Tool::CloneStamp, true), (Tool::Brush, false), (Tool::SpotHealing, false)] { + let mut app = clone_overlay_app(20.0); + app.ui.tool = tool; + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert_eq!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), shown, "{tool:?}"); + if shown { + assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{tool:?}: the source shows under the brush"); + } + output.textures_delta.clear(); + } +} + #[test] fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 4.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(200.0, 4.0); for invert in [false, true] { app.session.presets.clone.overlay.invert = invert; let mut output = ctx.run_ui(Default::default(), |ui| { draw_clone_preview(&mut app, ui.painter(), &xf, [32.0, 32.0], None); }); - let upload = output.textures_delta.set.values().flatten().find(|d| d.image.size() == [20, 20]).expect("clone overlay upload"); + let id = app.clone_preview.as_ref().unwrap().texture.id(); + let upload = output.textures_delta.set.get(&id).and_then(|d| d.last()).expect("clone overlay upload"); let options = upload.options; output.textures_delta.clear(); assert_eq!(options.magnification, egui::TextureFilter::Nearest, "invert={invert}"); @@ -1249,27 +1330,20 @@ fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { #[test] fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; - // The centre texel of the overlay texture uploaded for `mode`. + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); + // The overlay texel at the brush centre for `mode`. let mut centre = |mode: &str| { app.run("cloneSource.overlay", json!({"blend": mode})).unwrap(); - let mut output = ctx.run_ui(Default::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); - // The brush's 20 × 20 px square (the font atlas may upload too). - let image = output.textures_delta.set.values().flatten().find_map(|d| match &d.image { - egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), - _ => None, + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let cache = app.clone_preview.as_ref().unwrap(); + let image = output.textures_delta.set.get(&cache.texture.id()).and_then(|d| d.last()).map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), }); output.textures_delta.clear(); let image = image.expect("the overlay uploads a texture"); - image.pixels[image.width() * (image.height() / 2) + image.width() / 2] + let rect = cache.rect; + image.pixels[(32 - rect.y0) as usize * image.width() + (32 - rect.x0) as usize] }; // A white source over the white image: Normal shows white, Difference black. assert_eq!(centre("normal"), Color32::WHITE); @@ -1281,32 +1355,14 @@ fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { #[test] fn clone_overlay_is_clipped_to_a_shaped_tip() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; + let mut app = clone_overlay_app(20.0); app.session.tools.brush.roundness = 0.25; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; - let mut output = ctx.run_ui(Default::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); - let image = output - .textures_delta - .set - .values() - .flatten() - .find_map(|d| match &d.image { - egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), - _ => None, - }) - .expect("the overlay uploads a texture"); - output.textures_delta.clear(); - let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a(); - assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source"); - assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped"); - assert_eq!(alpha(1, 10), 255, "the tip's long axis stays"); + let xf = clone_overlay_view(100.0, 1.0); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let alpha = clone_overlay_alpha(&app, &mut output); + assert_eq!(alpha(32, 32), 255, "the tip's centre shows the source"); + assert_eq!(alpha(32, 24), 0, "inside the circle but outside the flat tip: clipped"); + assert!(alpha(25, 32) > 240, "the tip's long axis stays: {}", alpha(25, 32)); } #[test] diff --git a/crates/ui-egui/src/canvas/textures.rs b/crates/ui-egui/src/canvas/textures.rs index f11c4338e9..c6aaeb436b 100644 --- a/crates/ui-egui/src/canvas/textures.rs +++ b/crates/ui-egui/src/canvas/textures.rs @@ -625,7 +625,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: /// Memory Usage and GPU Memory (Preferences › Performance), in bytes. fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { let performance = &app.session.prefs().performance; - (u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) + (u64::from(performance.effective_memory_usage_mb()).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) } /// The document pixels a view shows, with a margin for filtering. Uses all four canvas corners diff --git a/crates/ui-egui/src/dialogs/adjust_editors/curves.rs b/crates/ui-egui/src/dialogs/adjust_editors/curves.rs index 4b1867e83b..e813c44ab3 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/curves.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/curves.rs @@ -106,6 +106,47 @@ pub(super) const CURVE_HANDLE_SIZE: f32 = 7.0; const CURVE_GRAPH_PADDING: f32 = 5.0; +const CURVE_SAMPLES: usize = 256; + +/// One entry per editor/channel, independent of selection, tint and stroke width. +#[derive(Clone, Default)] +struct CurvePolyline { + points: Vec<[f32; 2]>, + graph: Option, + line: Vec, +} + +impl CurvePolyline { + /// Returns whether the screen geometry changed. Moving the graph also invalidates screen + /// coordinates (for example when scrolling the Properties panel or moving the dialog). + fn update(&mut self, points: &[[f32; 2]], graph: Rect) -> bool { + if self.points == points && self.graph == Some(graph) { + return false; + } + let cp: Vec = + points.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + self.line = photocraft_compose::adjust::curve_samples::(&cp) + .into_iter() + .enumerate() + .map(|(i, y)| pos2(graph.left() + i as f32 / (CURVE_SAMPLES - 1) as f32 * graph.width(), graph.bottom() - y * graph.height())) + .collect(); + self.points.clear(); + self.points.extend_from_slice(points); + self.graph = Some(graph); + true + } +} + +fn cached_curve_line(ctx: &egui::Context, id: egui::Id, points: &[[f32; 2]], graph: Rect) -> Vec { + ctx.data_mut(|d| { + let cache = d.get_temp_mut_or_default::(id); + cache.update(points, graph); + // egui owns the submitted path. Only this small copy is needed on a cache hit; + // normalization, spline solving, sampling and screen mapping are all skipped. + cache.line.clone() + }) +} + /// The room left on screen for the Curves dialog's other rows (title, channel and mode, Input and /// Output, eyedroppers, buttons) when its graph is as tall as it can be. const CURVES_CHROME: f32 = 360.0; @@ -290,13 +331,8 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let vbar = Rect::from_min_max(pos2(full.left(), graph.top()), pos2(full.left() + 8.0, graph.bottom())); gradient_bar(&p, hbar, Color32::BLACK, bar_color(&chan, &t), false); gradient_bar(&p, vbar, Color32::BLACK, bar_color(&chan, &t), true); - let draw_curve = |pts: &[[f32; 2]], color: Color32, width: f32| { - let cp: Vec = - pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); - let lut = photocraft_compose::adjust::curve_lut(&cp); - let n = lut.len().max(2); - let line: Vec = - (0..n).step_by((n / 256).max(1)).map(|i| to_scr([i as f32 / (n - 1) as f32 * 255.0, lut.get(i).copied().unwrap_or(0.0) * 255.0])).collect(); + let draw_curve = |key: &str, pts: &[[f32; 2]], color: Color32, width: f32| { + let line = cached_curve_line(ui.ctx(), cx.mem.with(("curves-polyline", key)), pts, graph); p.add(egui::Shape::line(line, Stroke::new(width, color))); }; let draw_map = |map: &[f32], color: Color32, width: f32| { @@ -316,7 +352,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } let cp = read_curve(v, c.key); if cp.len() > 2 || cp.first().is_some_and(|q| q[1] != 0.0) || cp.last().is_some_and(|q| q[1] != 255.0) { - draw_curve(&cp, bar_color(c, &t).gamma_multiply(0.8), 1.0); + draw_curve(c.key, &cp, bar_color(c, &t).gamma_multiply(0.8), 1.0); } } } @@ -324,7 +360,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { if pencil { draw_map(&map, color, 1.5); } else { - draw_curve(&pts, color, 1.5); + draw_curve(key, &pts, color, 1.5); } for (i, q) in pts.iter().enumerate().filter(|_| !pencil) { let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE)); @@ -386,3 +422,99 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } e } + +#[cfg(test)] +mod polyline_tests { + use super::*; + + #[test] + fn curve_polyline_reuses_static_geometry_and_refreshes_edits_and_rect() { + let mut cache = CurvePolyline::default(); + let mut points = [[0.0, 0.0], [128.0, 180.0], [255.0, 255.0]]; + let graph = Rect::from_min_size(pos2(20.0, 30.0), vec2(256.0, 256.0)); + assert!(cache.update(&points, graph)); + assert_eq!(cache.line.len(), CURVE_SAMPLES); + assert_eq!(cache.line.first(), Some(&graph.left_bottom())); + assert_eq!(cache.line.last(), Some(&graph.right_top())); + let line = cache.line.clone(); + assert!(!cache.update(&points, graph)); + assert_eq!(cache.line, line); + + points[1][1] = 80.0; + assert!(cache.update(&points, graph)); + assert_ne!(cache.line, line); + points[1][1] = 180.0; // Undo/preset restore must also invalidate. + assert!(cache.update(&points, graph)); + assert_eq!(cache.line, line); + + let moved = graph.translate(vec2(13.0, 25.0)); + assert!(cache.update(&points, moved)); + for (before, after) in line.iter().zip(&cache.line) { + assert!((*after - (*before + vec2(13.0, 25.0))).length() < 1e-4); + } + let resized = Rect::from_min_size(moved.min, vec2(128.0, 180.0)); + assert!(cache.update(&points, resized)); + assert_eq!(cache.line.last(), Some(&resized.right_top())); + assert!(!cache.update(&points, resized)); + } + + #[test] + fn curve_polyline_memory_is_separate_per_editor_and_channel() { + let ctx = egui::Context::default(); + let graph = Rect::from_min_size(Pos2::ZERO, vec2(256.0, 256.0)); + let identity = [[0.0, 0.0], [255.0, 255.0]]; + let inverted = [[0.0, 255.0], [255.0, 0.0]]; + let editor = egui::Id::unique("dialog"); + let master = editor.with(("curves-polyline", "points")); + let red = editor.with(("curves-polyline", "red")); + let other_editor = egui::Id::unique("properties").with(("curves-polyline", "points")); + let line = cached_curve_line(&ctx, master, &identity, graph); + assert_ne!(cached_curve_line(&ctx, red, &inverted, graph), line); + assert_ne!(cached_curve_line(&ctx, other_editor, &inverted, graph), line); + assert_eq!(cached_curve_line(&ctx, master, &identity, graph), line); + ctx.data_mut(|d| { + assert!(!d.get_temp_mut_or_default::(master).update(&identity, graph)); + assert!(!d.get_temp_mut_or_default::(red).update(&inverted, graph)); + assert!(!d.get_temp_mut_or_default::(other_editor).update(&inverted, graph)); + }); + assert_ne!(cached_curve_line(&ctx, master, &inverted, graph), line); + } + + #[test] + #[ignore = "release timing probe; run on GitHub Actions with --release --ignored --nocapture"] + fn curve_polyline_release_comparison() { + use std::{hint::black_box, time::Instant}; + let graph = Rect::from_min_size(pos2(20.0, 30.0), vec2(300.0, 300.0)); + let points = [[0.0, 0.0], [64.0, 48.0], [192.0, 208.0], [255.0, 255.0]]; + let iterations = 10_000; + let start = Instant::now(); + for _ in 0..iterations { + let cp: Vec<_> = black_box(&points).iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&cp); + let line: Vec<_> = lut + .iter() + .enumerate() + .step_by(16) + .map(|(i, y)| pos2(graph.left() + i as f32 / 4095.0 * graph.width(), graph.bottom() - y * graph.height())) + .collect(); + black_box(line); + } + let old = start.elapsed(); + let start = Instant::now(); + for _ in 0..iterations { + let mut cache = CurvePolyline::default(); + cache.update(black_box(&points), graph); + black_box(cache.line.clone()); + } + let miss = start.elapsed(); + let ctx = egui::Context::default(); + let id = egui::Id::unique("curve-perf"); + cached_curve_line(&ctx, id, &points, graph); + let start = Instant::now(); + for _ in 0..iterations { + black_box(cached_curve_line(&ctx, id, black_box(&points), graph)); + } + let hit = start.elapsed(); + println!("{iterations} polylines: old 4096 samples {old:?}; direct 256 samples {miss:?}; cached screen line {hit:?}"); + } +} diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index 46ecbce0e8..ac026d9e6a 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui.rs +++ b/crates/ui-egui/src/dialogs/color_picker_ui.rs @@ -321,6 +321,13 @@ where (hex(to_rgb(v)) == hex(rgb)).then_some(v) } +/// The CMYK values typed into the picker, while they still match its colour: the inks a command +/// should keep in a CMYK document (#4196, #8568). +pub(crate) fn picked_cmyk(f: &Map) -> Option<[f32; 4]> { + let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex)?; + kept::<4>(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb) +} + /// Current colour, keeping the stored HSB, Lab or CMYK values while they still match `color`. pub fn current(f: &Map) -> Components { let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]); diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 7fd65f68c8..b949b3b293 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -90,7 +90,14 @@ fn remember(app: &mut PhotocraftApp, f: &Map) { } pub fn open(app: &mut PhotocraftApp) -> Result { - let st = app.session.active().ok_or("no document")?; + let size = app.session.active().ok_or("no document")?.doc.size; + open_sized(app, size.width, size.height) +} + +/// The dialog with its size fields fitted to a `src_w` × `src_h` source: the document, or the +/// layer Layer › Export As exports. Fitting the document first would replace a remembered pixel +/// size the document can't take with the percent, even when the layer can take it. +fn open_sized(app: &mut PhotocraftApp, src_w: u32, src_h: u32) -> Result { let mut f = Map::new(); f.insert("__export".into(), json!(true)); f.insert("__label".into(), json!("Export As")); @@ -106,42 +113,54 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("lockAspect".into(), json!(true)); f.insert("profile".into(), json!("document")); f.insert("embedProfile".into(), json!(true)); - f.insert("width".into(), json!(st.doc.size.width)); - f.insert("height".into(), json!(st.doc.size.height)); - f.insert("__w".into(), json!(st.doc.size.width)); - f.insert("__h".into(), json!(st.doc.size.height)); + f.insert("width".into(), json!(src_w)); + f.insert("height".into(), json!(src_h)); + f.insert("__w".into(), json!(src_w)); + f.insert("__h".into(), json!(src_h)); seed_remembered(app, &mut f); - let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height"); - if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) { + fit_size(&mut f, f64::from(src_w), f64::from(src_h)); + Ok(app.ui.open_dialog(DialogKind::Command, f)) +} + +/// The size fields for exporting a `src_w` × `src_h` source. A remembered percent scales it. +/// Remembered exact pixels with Constrain proportions keep their width and take the height from +/// this source, as ticking the checkbox does: the last export's proportions would stretch an +/// image of another shape (#8738). Unlinked, the exact pixels are the size the user asked for. +/// A linked width this source can't reach within the Scale field's 1..=1000 % falls back to the +/// remembered percent. +fn fit_size(f: &mut Map, src_w: f64, src_h: f64) { + if s(f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height") { + if !f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true) { + return; + } + if let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true) + && (1.0..=1000.0).contains(&size.scale) + { + write_size(f, size, "pixels"); + return; + } + } + if let Ok(size) = resize_from_scale(src_w, src_h, n(f, "scale", 100.0)) { f.insert("width".into(), json!(size.width)); f.insert("height".into(), json!(size.height)); - if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 { + if (n(f, "scale", 100.0) - size.scale).abs() > 1e-9 { f.insert("scale".into(), json!(size.scale)); } f.insert("sizing".into(), json!("scale")); } - Ok(app.ui.open_dialog(DialogKind::Command, f)) } /// Layer › Export As…: the same dialog for just the active layer (trimmed to its pixels). pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Result { let st = app.session.active().ok_or("no document")?; let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?; - let id = open(app)?; + // The size fields follow the layer's trimmed size, so the dialog shows and edits the size + // the layer exports at. + let id = open_sized(app, ldoc.size.width, ldoc.size.height)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { let f = &mut d.fields; f.insert("__layer".into(), json!(layer.0)); f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); - f.insert("__w".into(), json!(ldoc.size.width)); - f.insert("__h".into(), json!(ldoc.size.height)); - // `open` seeded width and height from the document; in scale mode they follow the layer's - // trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels - // (sizing "pixels") are a size the user asked for, so those stay. - if s(f, "sizing") != "pixels" - && let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0)) - { - write_size(f, size, "scale"); - } } Ok(id) } @@ -342,17 +361,18 @@ pub(crate) fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result Result { let src_w = src_w.max(1.0); let src_h = src_h.max(1.0); let (width, height) = if edited_width { let width = finite_px(width)?; - let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? }; + let height = if lock { finite_px((src_h * width / src_w).max(1.0))? } else { finite_px(height)? }; (width, height) } else { let height = finite_px(height)?; - let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? }; + let width = if lock { finite_px((src_w * height / src_h).max(1.0))? } else { finite_px(width)? }; (width, height) }; let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 }; diff --git a/crates/ui-egui/src/dialogs/export_dialog/tests.rs b/crates/ui-egui/src/dialogs/export_dialog/tests.rs index b015c97f85..cf79a0e38c 100644 --- a/crates/ui-egui/src/dialogs/export_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/export_dialog/tests.rs @@ -74,8 +74,9 @@ fn a_replayed_export_needs_a_path_and_a_known_format() { } /// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the -/// size fields from the document, and the layer replaces them in scale mode. A remembered -/// percent scales the layer; remembered exact pixels stay the size the user asked for. +/// size fields to the document, and the layer refits them. A remembered percent scales the +/// layer; remembered exact pixels stay the size the user asked for, in the layer's proportions +/// when they are linked. #[test] fn layer_export_as_opens_with_the_layer_size() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -94,10 +95,105 @@ fn layer_export_as_opens_with_the_layer_size() { let f = &app.ui.dialog_mut(id).unwrap().fields; assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0))); - app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80}))); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80, "lockAspect": false}))); let id = open_layer(&mut app, layer).unwrap(); let f = &app.ui.dialog_mut(id).unwrap().fields; assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0))); + + // With Constrain proportions the remembered width stays and the height follows the layer. + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 50, "height": 80, "lockAspect": true}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(30.0))); + assert_eq!(f["sizing"], "pixels"); +} + +/// #8738: a pixel size remembered from a landscape export, with Constrain proportions on, +/// keeps its width and takes the height from the portrait image being exported, as ticking +/// the checkbox does; unlinked, both sides are the exact size asked for. A recorded action +/// step is not the remembered size: it replays with exactly the pixels it was recorded with. +#[test] +fn a_remembered_pixel_size_keeps_this_images_proportions() { + let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = sizes.clone(); + let services = crate::Services { + export: Some(Box::new(move |doc, _, _| { + log.borrow_mut().push((doc.size.width, doc.size.height)); + Ok((Vec::new(), Vec::new())) + })), + write: Some(Box::new(|_, _| Ok(()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 30, "height": 40})).unwrap(); + let landscape = json!({"sizing": "pixels", "width": 20, "height": 15, "scale": 50, "lockAspect": true}); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone())); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(27.0))); + assert_eq!(f["sizing"], "pixels"); + assert!((f["scale"].as_f64().unwrap() - 200.0 / 3.0).abs() < 1e-9, "{}", f["scale"]); + + let mut unlinked = landscape.clone(); + unlinked["lockAspect"] = json!(false); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), unlinked)); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(15.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone())); + let mut step = landscape; + step["path"] = json!("/x.png"); + step["format"] = json!("png"); + replay(&mut app, &step).unwrap(); + assert_eq!(*sizes.borrow(), [(20, 15)]); +} + +/// A remembered linked width this image can't take within the Scale field's 1..=1000 % +/// (2000 px from a 4000 px photo, on a 100 px image) falls back to the remembered percent. +#[test] +fn a_remembered_pixel_size_out_of_scale_falls_back_to_the_percent() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 2000, "height": 1500, "scale": 50, "lockAspect": true}))); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(50.0))); + assert_eq!((f["sizing"].as_str(), f["scale"].as_f64()), (Some("scale"), Some(50.0))); +} + +/// A remembered linked width whose height rounds below one pixel keeps its width: the height +/// is one pixel, as the Scale field gives at the same 10 %, and not the remembered percent. +#[test] +fn a_remembered_pixel_width_keeps_a_one_pixel_height() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 1})).unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 100, "height": 75, "scale": 100, "lockAspect": true}))); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(100.0), Some(1.0))); + assert_eq!(f["sizing"], "pixels"); + // The same for an edited height on a 1 × 1000 image. + let size = resize_from_side(1.0, 1000.0, 1.0, 100.0, true, false).unwrap(); + assert_eq!((size.width, size.height), (1.0, 100.0)); +} + +/// Layer › Export As fits the remembered size to the layer, not to the document: a remembered +/// linked width the tall document can't take (800 px wide is 320 000 px high) still fits the +/// 100 × 10 layer at 800 × 80. +#[test] +fn a_layer_export_fits_the_remembered_size_to_the_layer() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 40000})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 10})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 800, "height": 600, "scale": 50, "lockAspect": true}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(800.0), Some(80.0))); + assert_eq!(f["sizing"], "pixels"); } /// Only the dialog's own settings are remembered: not the destination, the document size or diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index c7d38f46ee..62082b4f8d 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -139,10 +139,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>(), None), Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) { Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))), - Err(error) => { - ui.label(error); - (vec![0; (w * h * 4) as usize], None) - } + // The error shows on the row above the panes; a label here sat beside the pane + // and widened the dialog (#8830). + Err(_) => (vec![0; (w * h * 4) as usize], None), }, }; let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); diff --git a/crates/ui-egui/src/dialogs/new_group_ui/tests.rs b/crates/ui-egui/src/dialogs/new_group_ui/tests.rs index 2ff93afd3d..788e566d5e 100644 --- a/crates/ui-egui/src/dialogs/new_group_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_group_ui/tests.rs @@ -124,13 +124,3 @@ fn typing_replaces_the_selected_name_and_enter_confirms() { assert!(h.state().ui.dialogs.is_empty()); assert_eq!(groups(h.state())[0].name, "Clouds"); } - -#[test] -fn new_group_dialog_labels_are_translated_in_every_catalog() { - const LABELS: &[&str] = &["New Group…", "New Group from Layers…", "Name:", "Color:", "Mode:", "Opacity:", "Pass Through"]; - for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) { - for label in LABELS { - assert!(crate::i18n::has(lang, label), "{} is missing New Group dialog label: {label}", lang.code()); - } - } -} 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 7df542fe74..2210058b54 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -744,32 +744,25 @@ fn ok_after_a_layer_switch_leaves_the_new_layer_alone() { assert!(app.distort.select_mask.is_none()); } -/// The reason a stale workspace was closed is user-visible text: it is shown in the status bar and -/// returned to the control channel in the language in use, and every catalogue has it (#8896). +/// A stale workspace is closed with a reason that is shown in the status bar and returned to the +/// control channel (#8896). #[test] -fn the_reason_a_stale_workspace_closed_is_translated() { +fn the_reason_a_stale_workspace_closed_is_reported() { let document = "Select and Mask was closed: its document is no longer the active one"; let layer = "Select and Mask was closed: another layer was selected"; - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - for (reason, english) in [("document", document), ("layer", layer)] { - assert!(crate::i18n::has(lang, english), "{}: no row for the {reason} reason", lang.code()); - let shown = crate::i18n::tr(lang, english); - let mut app = if reason == "document" { two_documents() } else { app_with_selection() }; - let first = app.session.active().unwrap().active_layer.unwrap(); - let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap()); - app.run("layer.select", json!({"layer": first.0})).unwrap(); - open(&mut app).unwrap(); - crate::i18n::with_language(lang, || { - match other { - Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))), - None => assert!(app.session.set_active(1)), - } - let err = commit(&mut app).expect_err(&format!("{} / {reason}: the stale OK is refused", lang.code())); - assert_eq!(err, shown, "{}: the error is translated", lang.code()); - assert_eq!(app.ui.status, shown, "{}: the status is translated", lang.code()); - }); - assert_ne!(shown, english, "{}: the {reason} reason reads differently from English", lang.code()); + for (reason, english) in [("document", document), ("layer", layer)] { + let mut app = if reason == "document" { two_documents() } else { app_with_selection() }; + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap()); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + match other { + Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))), + None => assert!(app.session.set_active(1)), } + let err = commit(&mut app).expect_err(&format!("{reason}: the stale OK is refused")); + assert_eq!(err, english, "{reason}: the error"); + assert_eq!(app.ui.status, english, "{reason}: the status"); } } diff --git a/crates/ui-egui/src/dialogs/solid_fill_ui.rs b/crates/ui-egui/src/dialogs/solid_fill_ui.rs index 5d9112abab..37844b86c2 100644 --- a/crates/ui-egui/src/dialogs/solid_fill_ui.rs +++ b/crates/ui-egui/src/dialogs/solid_fill_ui.rs @@ -27,7 +27,14 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { let layer = st.active_layer?; let LayerContent::Fill(Fill::Solid(color)) = st.doc.layer(layer)?.content else { return None }; let params = json!({"layer": layer.0, "document": st.doc.id.0}); - Some(color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params)) + let id = color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params); + // A CMYK fill reopens showing its own inks, not ones recomputed from its sRGB stand-in (#8568). + if color.mode == photocraft_color::ColorMode::Cmyk + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.insert("__cmyk".into(), json!(color.c)); + } + Some(id) } /// Creating a Solid Color fill is transactional: the document is unchanged until OK. @@ -57,7 +64,8 @@ pub(crate) fn confirm_new(app: &mut PhotocraftApp, f: &Map) -> Re return Err("the target document is no longer active".into()); } let color = f.get("color").and_then(Value::as_str).ok_or("missing Solid Color colour")?; - app.run("layer.newFillLayer.solidColor", json!({"color": color})) + // CMYK typed into the picker stays those inks in a CMYK document (#8568). + app.run("layer.newFillLayer.solidColor", json!({"color": color, "cmyk": color_picker_ui::picked_cmyk(f)})) } pub(crate) fn owns(f: &Map) -> bool { @@ -72,14 +80,39 @@ pub(crate) fn thumbnail_color(app: &PhotocraftApp, document: DocId, layer: Layer .filter(|d| owns(&d.fields)) .find_map(|d| { let params = d.fields.get("__params")?; - if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 || d.fields.get("color") == d.fields.get("__orig") { + if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 { return None; } - cmds::color_param(d.fields.get("color")?, original, app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode).ok() + let mode = app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode; + picked_color(&d.fields, original, mode) }) .unwrap_or(original) } +/// The colour the picker would commit to a fill of `original` in a `mode` document, built as the +/// engine builds it: the picked hex, and in a CMYK document the inks it was picked as (#8568). +/// `None` while the picker still shows the fill as it is ([`is_edit`]). +fn picked_color(f: &Map, original: Color, mode: photocraft_color::ColorMode) -> Option { + if !is_edit(f, original, mode) { + return None; + } + let color = cmds::color_param(f.get("color")?, original, mode).ok()?; + Some(color_picker_ui::picked_cmyk(f).filter(|_| mode == photocraft_color::ColorMode::Cmyk).map_or(color, |c| Color { + mode: photocraft_color::ColorMode::Cmyk, + c, + alpha: color.alpha, + })) +} + +/// Whether the picker differs from the fill it edits, inks included: different inks can share a +/// display colour (CMYK 0/0/0/100 and 50/50/50/100 both read black), so the hex alone doesn't +/// tell, and an RGB or Lab fill given inks in a CMYK document is an edit too. +fn is_edit(f: &Map, original: Color, mode: photocraft_color::ColorMode) -> bool { + let cmyk = photocraft_color::ColorMode::Cmyk; + let inks_differ = mode == cmyk && color_picker_ui::picked_cmyk(f).is_some_and(|k| original.mode != cmyk || original.c != k); + f.get("color") != f.get("__orig") || inks_differ +} + /// No-op OK preserves imported high-precision / non-RGB colour values rather than quantising /// them to the picker's display hex. A stale document must never receive the edit. pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { @@ -89,15 +122,18 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result return Err("the target document is no longer active".into()); } let layer = LayerId(params.get("layer").and_then(Value::as_u64).ok_or("missing Solid Color layer")?); - if !st.doc.layer(layer).is_some_and(|l| matches!(l.content, LayerContent::Fill(Fill::Solid(_)))) { + let Some(LayerContent::Fill(Fill::Solid(original))) = st.doc.layer(layer).map(|l| &l.content) else { return Err("the target Solid Color Fill layer no longer exists".into()); - } - if f.get("color") == f.get("__orig") { + }; + if !is_edit(f, *original, st.doc.mode) { return Ok(json!({"layer": layer.0, "unchanged": true})); } let color = f.get("color").cloned().ok_or("missing color")?; let object = params.as_object_mut().ok_or("invalid Solid Color target")?; object.insert("color".into(), color); + if let Some(inks) = color_picker_ui::picked_cmyk(f) { + object.insert("cmyk".into(), json!(inks)); + } app.run(cmds::SET, params) } @@ -109,12 +145,12 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, inks: [f32; 4]| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}"))); + f.insert("__cmyk".into(), json!(inks)); + }; + let fill = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Fill(Fill::Solid(c)) => c, + _ => panic!("not a solid fill"), + } + }; + let inks = [0.5, 0.5, 0.5, 1.0]; + let id = open_new(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, inks); + confirm_new(&mut app, &f).unwrap(); + assert_eq!(fill(&app), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 }); + + let edit = [0.25, 0.75, 0.0, 0.1]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, edit); + confirm(&mut app, &f).unwrap(); + assert_eq!(fill(&app).c, edit); + // Reopening the picker shows the stored inks. + let id = open(&mut app).unwrap(); + let shown = color_picker_ui::current(&app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields); + assert_eq!(shown.cmyk, edit); + + // Other inks with the same display colour are still an edit. + let near = [0.5, 0.5, 0.499, 1.0]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, near); + confirm(&mut app, &f).unwrap(); + let rich = [0.5, 0.5, 0.5, 1.0]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, rich); + assert_eq!(f.get("color"), f.get("__orig"), "both inks show as the same colour"); + confirm(&mut app, &f).unwrap(); + assert_eq!(fill(&app).c, rich); +} + +fn with_inks(f: &mut serde_json::Map, inks: [f32; 4]) { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}"))); + f.insert("__cmyk".into(), json!(inks)); +} + +/// Inks that share the picker's hex with the fill's own are still a preview and a thumbnail, as +/// they are an edit on OK (#8568). +#[test] +fn same_hex_inks_preview_and_thumbnail_as_ok_commits_them() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let near = [0.5, 0.5, 0.499, 1.0]; + let id = open_new(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + with_inks(&mut f, near); + confirm_new(&mut app, &f).unwrap(); + + let rich = [0.5, 0.5, 0.5, 1.0]; + let id = open(&mut app).unwrap(); + with_inks(&mut app.ui.dialog_mut(id).unwrap().fields, rich); + let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!(fields.get("color"), fields.get("__orig"), "both inks show as the same colour"); + + let st = app.session.active().unwrap(); + let (document, layer) = (st.doc.id, st.active_layer.unwrap()); + let LayerContent::Fill(Fill::Solid(original)) = st.doc.layer(layer).unwrap().content else { panic!("not a solid fill") }; + assert_eq!(thumbnail_color(&app, document, layer, original).c, rich); + let (shown, _) = display_doc(&mut app, 0).expect("a preview of the new inks"); + let LayerContent::Fill(Fill::Solid(previewed)) = shown.layer(layer).unwrap().content else { panic!("not a solid fill") }; + assert_eq!(previewed.c, rich); +} + +/// An RGB fill inside a CMYK document that the picker gives inks at the unchanged hex is an edit; +/// in an RGB document, which ignores the inks, it is not. +#[test] +fn inks_given_to_a_non_cmyk_fill_in_a_cmyk_document_are_an_edit() { + let original = Color::rgba(0.2, 0.3, 0.4, 1.0); + let mut f = serde_json::Map::new(); + with_inks(&mut f, [0.3, 0.2, 0.1, 0.4]); + f.insert("__orig".into(), f["color"].clone()); + assert!(is_edit(&f, original, ColorMode::Cmyk)); + assert!(!is_edit(&f, original, ColorMode::Rgb)); + assert!(picked_color(&f, original, ColorMode::Cmyk).is_some_and(|c| c.mode == ColorMode::Cmyk && c.c == [0.3, 0.2, 0.1, 0.4])); +} diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index a70d6acd8e..52149ab8e0 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1472,3 +1472,15 @@ fn save_a_copy_appends_copy_unless_the_preference_says_not_to() { ); } } + +#[test] +fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + assert_eq!(crate::menus::invoke(&mut app, &ctx, "file.import.variableDataSets", json!({})).unwrap(), json!({"fileDialog": "open"})); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }, _)] if e == &["csv", "txt"])); + answer(&open, Some(FileDialogAnswer::Contents("sets.csv".into(), b"headline\nHello\nWorld\n".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); +} diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 1a233fd7cb..ef9613057a 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -835,7 +835,10 @@ fn open_paths_reports_in_the_interface_language_before_the_first_frame() { app.session.edit_prefs(|p| p.interface.language = "ar".into()); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(app.open_paths(&paths), 1); - assert_eq!(app.ui.status, "تم استيراد 3 نقشات من launch"); + let ar = crate::i18n::Lang::from_code("ar").unwrap(); + let want = + crate::i18n::fmt(&crate::i18n::trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), &[("n", "3"), ("file", "launch")]); + assert_eq!(app.ui.status, want); crate::i18n::set_current(crate::i18n::Lang::EN); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d490de7b8e..c9e188484b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -199,7 +199,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 23] = [ +pub static LANGUAGES: [LangInfo; 27] = [ 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() }, @@ -237,6 +237,16 @@ pub static LANGUAGES: [LangInfo; 23] = [ // Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The // chrome mirrors right to left (`dir_for_code`), and egui lays each label out right to left. LangInfo { code: "ar", name: "العربية", source: catalog!("ar"), plural: plural_arabic, complete_menus: true, catalog: OnceLock::new() }, + // Swedish; `sv-SE` and `sv-FI` locales resolve here. + LangInfo { code: "sv", name: "Svenska", source: catalog!("sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Finnish; `fi-FI` resolves here (Swedish in Finland, `sv-FI`, stays Swedish). One/other: the + // partitive singular follows any number but 1 ("1 taso", "2 tasoa"). + LangInfo { code: "fi", name: "Suomi", source: catalog!("fi"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Danish; `da-DK` and the other `da-*` locales resolve here. + LangInfo { code: "da", name: "Dansk", source: catalog!("da"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // 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() }, ]; impl LangInfo { @@ -366,6 +376,11 @@ fn candidates(tag: &str) -> Vec { // The only Portuguese catalog is Brazilian; other regions use it rather than English. out.insert(out.len() - 1, "pt-br".to_string()); } + if matches!(primary, "no" | "nn") && !out.iter().any(|c| c == "nb") { + // The only Norwegian catalog is Bokmål; the macrolanguage code and Nynorsk use it rather + // than English. + out.insert(out.len() - 1, "nb".to_string()); + } out } diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 115e541f29..bcc690f7b7 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, -Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR -Apache-2.0, at your option. +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. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -45,3 +45,15 @@ Arabic translations: PhotoCraft contributors, combined from the catalogs contrib @hicbest, @hasan-sdaif, @NibrasAz7, @vfggf95565, @syrrevshin4202-prog and @test-bit-sys and reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's English labels, and no proprietary translation resources were used. +Swedish translations: PhotoCraft contributors, combined from the catalogs contributed by @kler and +@davvarn and reviewed with Claude (Anthropic) as an AI contributor; original translations of +PhotoCraft's English labels, and no proprietary translation resources were used. +Finnish translations: PhotoCraft contributors, combined from the catalogs contributed by @Janiz-zzz +and @metuuu and reviewed with Claude (Anthropic) as an AI contributor; original translations of +PhotoCraft's English labels, and no proprietary translation resources were used. +Danish translations: PhotoCraft contributors, based on the catalog contributed by +@pechnikov2026-star, completed and reviewed with Claude (Anthropic) as an AI contributor; original +translations of PhotoCraft's English labels, and no proprietary translation resources were used. +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. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv new file mode 100644 index 0000000000..0078bd4b0a --- /dev/null +++ b/crates/ui-egui/src/i18n/da.tsv @@ -0,0 +1,4774 @@ +# Danish (da) 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. +# +# Based on the community Danish catalog in #7267, completed for the current UI: its rows were +# checked and corrected, the strings added since were translated, and one term is used per concept +# across the UI (lag, markering, maske, gradient, blød kant, transformér/transformering, vridning, +# gitter, hjælpelinje, tavle, lærred, udklipningsmaske, arbejdsområde, pensel, blandingstilstand, +# justering, kanal, sti, historik, forudindstilling, øjebliksbillede, form, kontur, nål …). +# Clean-room: written from the plain meaning of the English labels using ordinary Danish computing +# vocabulary; no proprietary localisation resource, string table or product UI was consulted or +# copied. Labels use sentence case, commands are imperatives and the user is addressed as "du". +# Product, format and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab …) and key caps +# keep their spelling; the trailing "…" of dialog commands is kept. Plural rule: one|other +# (n != 1 -> other). Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskeindstillinger… + Layer Mask Display Options… Visningsindstillinger for lagmaske… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfarve + Color Indicates: Farve angiver: + Solidity: Dækning: + Ink Characteristics Trykfarveegenskaber + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papirbredde + Paper Height Papirhøjde + Rows Rækker + Number Antal + Margin Margen + Top: Øverst: + Left: Venstre: + Bottom: Nederst: + Right: Højre: + Target: Mål: + Selected Artboards Markerede tavler + Center Columns Centrér kolonner + Clear Existing Guides Ryd eksisterende hjælpelinjer + Invalid guide layout Ugyldigt layout for hjælpelinjer + Leave empty for automatic size Lad stå tom for automatisk størrelse + 5 Columns, 3 Rows 5 kolonner, 3 rækker + 8 Columns 8 kolonner + 12 Columns 12 kolonner + + File Filer + Edit Redigér + Image Billede + Layer Lag + Type Tekst + Select Markér + Selected Markeret + Scope Omfang + Top Left Øverst til venstre + Top Right Øverst til højre + Bottom Left Nederst til venstre + Bottom Right Nederst til højre + Filter Filter + View Vis + Window Vindue + Help Hjælp + +# File + New… Ny… + New from Clipboard Ny fra udklipsholder + New Ny + Open… Åbn… + Open Åbn + Open As… Åbn som… + Open EXR Parts… Åbn EXR-dele… + Add beauty precomp from the AOVs Tilføj beauty-precomp fra AOV'erne + Both Begge + Left Venstre + Right Højre + Open Recent Åbn seneste + Clear Recent Files Ryd seneste filer + Remove from Recent Fjern fra seneste + Remove {name} from Recent Fjern {name} fra seneste + Close Luk + Minimize Minimér + Maximize Maksimér + Restore Gendan + Close All Luk alle + Close Others Luk andre + Save Gem + Save As… Gem som… + Save a Copy… Gem en kopi… + {name} can't keep everything in this document {name} kan ikke bevare alt i dette dokument + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Vælg et format, der bevarer det, f.eks. Photoshop (PSD), eller gem en kopi. + Save… Gem… + Revert Vend tilbage + Import Importér + Import Presets Importér forudindstillinger + 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… + Package… Pak… + Save for Web (Legacy)… Gem til web (ældre)… + Generate Generér + Image Assets Billedaktiver + +# Edit + Undo Fortryd + Redo Annullér fortryd + Fade… Ton ud… + Cut Klip + Copy Kopiér + Copy Merged Kopiér flettet + Paste Indsæt + Paste Special Indsæt speciel + Paste Into Indsæt i + Paste Outside Indsæt udenfor + Paste in Place Indsæt på stedet + Clear Ryd + Search Søg + Search… Søg… + Check Spelling… Kontrollér stavning… + Fill… Udfyld… + Stroke… Kontur… + Content-Aware Fill… Indholdsbevidst udfyldning… + Delete and Fill Selection Slet og udfyld markering + Define Brush Preset… Definér penselforudindstilling… + Define Pattern… Definér mønster… + Define Custom Shape… Definér brugerdefineret form… + Purge Tøm + +# Image + Image Size… Billedstørrelse… + Image Size Billedstørrelse + Canvas Size… Lærredsstørrelse… + Image Rotation Billedrotation + Rotate 90° Clockwise Rotér 90° med uret + Rotate 90° Counter Clockwise Rotér 90° mod uret + Rotate 180° Rotér 180° + Flip Canvas Horizontal Vend lærredet vandret + Flip Canvas Vertical Vend lærredet lodret + Crop Beskær + Crop and Straighten Photos Beskær og udret fotos + Trim… Trim… + Reveal All Vis alt + Hide Selection Skjul markering + Auto Tone Automatisk tone + Auto Contrast Automatisk kontrast + Auto Color Automatisk farve + Adjustments Justeringer + 16 Bits/Channel 16 bit/kanal + 32 Bits/Channel 32 bit/kanal + 8 Bits/Channel 8 bit/kanal + 8 bit 8-bit + Integer Heltal + 16 bit 16-bit + 32 bit (float) 32 bit (flydende komma) + Floating point Flydende komma + Mode Tilstand + Grayscale Gråtoner + RGB Color RGB-farve + CMYK Color CMYK-farve + Lab Color Lab-farve + Indexed Color Indekserede farver + Multichannel Multikanal + Bitmap… Bitmap… + Bitmap Bitmap + Duotone… Duotone… + Duotone Duotone + Trap… Trap… + +# Layer + Layer via Copy Lag via kopiering + Layer via Cut Lag via udklipning + Duplicate Layer… Duplikér lag… + Duplicate… Duplikér… + Delete Slet + Delete Layer Slet lag + Delete Layers Slet lag + Rename Layer Omdøb lag + Layer From Background… Lag fra baggrund… + Layer from Background… Lag fra baggrund… + Background from Layer Baggrund fra lag + Background From Layer Baggrund fra lag + Group Layers Gruppér lag + Group… Gruppe… + Ungroup Layers Ophæv gruppering af lag + Group from Layers… Gruppe fra lag… + New Group from Layers… Ny gruppe fra lag… + Hide Layers Skjul lag + Arrange Arrangér + Bring to Front Placér forrest + Bring Forward Flyt fremad + Send Backward Flyt bagud + Send to Back Placér bagerst + Convert to Smart Object Konvertér til smartobjekt + Convert for Smart Filters Konvertér til smartfiltre + New Smart Object via Copy Nyt smartobjekt via kopi + Edit Contents Redigér indhold + Replace Contents… Erstat indhold… + Export Contents… Eksportér indhold… + Rasterize Rasterisér + Rasterize Type Rasterisér tekst + Rasterize Shape Rasterisér form + Rasterize Layer Rasterisér lag + Rasterize Layers Rasterisér lag + New Adjustment Layer Nyt justeringslag + New Fill Layer Nyt udfyldningslag + Layer Mask Lagmaske + Add Layer Mask Tilføj lagmaske + Delete Layer Mask Slet lagmaske + Enable Layer Mask Aktivér lagmaske + Disable Layer Mask Deaktivér lagmaske + Apply Layer Mask Anvend lagmaske + Add Mask to Selection Tilføj maske til markering + Subtract Mask from Selection Træk maske fra markering + Intersect Mask with Selection Skær maske med markering + Link Layer Mask Sammenkæd lagmaske + Unlink Layer Mask Fjern sammenkædning af lagmaske + Vector Mask Vektormaske + Add Vector Mask Tilføj vektormaske + Delete Vector Mask Slet vektormaske + Enable Vector Mask Aktivér vektormaske + Link Vector Mask Sammenkæd vektormaske + Create Clipping Mask Opret udklipningsmaske + Release Clipping Mask Ophæv udklipningsmaske + New Shape Layer Nyt formlag + New Type Layer Nyt tekstlag + New Video Layer from File… Nyt videolag fra fil… + New Blank Video Layer Nyt tomt videolag + Smart Filters Smartfiltre + Delete Smart Filters Slet smartfiltre + Enable Smart Filters Aktivér smartfiltre + Disable Smart Filters Deaktivér smartfiltre + Clear Smart Filters Ryd smartfiltre + Delete Filter Mask Slet filtermaske + Disable Filter Mask Deaktivér filtermaske + New Layer Based Slice Nyt lagbaseret udsnit + Layer Style Lagstil + Blending Options… Blandingsindstillinger… + Blending Options Blandingsindstillinger + Drop Shadow… Slagskygge… + Inner Shadow… Indre skygge… + Outer Glow… Ydre glød… + Inner Glow… Indre glød… + Bevel & Emboss… Facet og relief… + Use Global Light Brug globalt lys + Overprint Overtryk + Satin… Satin… + Color Overlay… Farveoverlejring… + Gradient Overlay… Gradientoverlejring… + Pattern Overlay… Mønsteroverlejring… + Bevel & Emboss Facet og relief + Inner Shadow Indre skygge + Inner Glow Indre glød + Satin Satin + Color Overlay Farveoverlejring + Gradient Overlay Gradientoverlejring + Pattern Overlay Mønsteroverlejring + Outer Glow Ydre glød + Drop Shadow Slagskygge + Copy Layer Style Kopiér lagstil + Paste Layer Style Indsæt lagstil + Clear Layer Style Ryd lagstil + Layer Content Options… Indstillinger for lagindhold… + Scale Effects… Skalér effekter… + New Layer… Nyt lag… + Delete Layer… Slet lag… + +# Type + Font Skrifttype + Font Preview Size Størrelse på skrifttypeeksempler + Orientation Orientering + Horizontal Vandret + Vertical Lodret + Rasterize Type Layer Rasterisér tekstlag + Create Work Path Opret arbejdssti + Convert to Shape Konvertér til form + Convert to Paragraph Text Konvertér til afsnitstekst + Convert to Point Text Konvertér til punkttekst + Warp Text… Vrid tekst… + Character Panel Tegnpanel + Paragraph Panel Afsnitspanel + Check Spelling Kontrollér stavning + Language Options Sprogindstillinger + Find and Replace Text… Find og erstat tekst… + Add to Dictionary Føj til ordbog + No Spelling Suggestions Ingen staveforslag + Paste Lorem Ipsum Indsæt Lorem Ipsum + Glyphs Panel Glyfpanel + Type Styles Tekststile + Character Styles Tegnstile + Paragraph Styles Afsnitsstile + Load Default Type Styles Indlæs standardtekststile + Save Default Type Styles Gem standardtekststile + Type… Tekst… + +# Select + 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… +# Select and Mask dialog labels + Smart Radius Smart radius + Shift Edge Forskyd kant + Decontaminate Fjern forurening + Output Output + Refine Edge Forfin kant + Select Subject Markér motiv + Sky Himmel + Focus Area… Fokusområde… + Subject Motiv + Color Range Farveområde + Similar Lignende + Grow Forøg + Select Similar Colors Markér lignende farver + Load Selection… Indlæs markering… + Save Selection… Gem markering… + New Selection Ny markering + Select All Markér alt + Deselect All Fjern markering af alle + Deselect Layers Fjern markering af lag + Find Layers Find lag + Isolate Layers Isolér lag + Select Linked Layers Markér sammenkædede lag + All Layers Alle lag + Current & Below Aktuelt og nedenunder + Current Layer Aktuelt lag + Refine Strokes Finjustér strøg + Edits Redigeringer + +# Filter + Last Filter Seneste filter + Filter Gallery… Filtergalleri… + Liquify… Gør flydende… + Camera Raw Filter… Camera Raw-filter… + Adaptive Wide Angle… Adaptiv vidvinkel… + Lens Correction… Objektivkorrektion… + Vanishing Point… Forsvindingspunkt… + Oil Paint… Oliemaleri… + Blur Gallery Sløringsgalleri + Field Blur… Feltsløring… +# Field Blur pins (#3373) + No pin selected Ingen nål markeret + 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. Klik på billedet for at tilføje en nål; træk i en nål for at flytte den eller i dens ring for at ændre dens sløring. Delete fjerner den markerede nål. + 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. Træk i håndtagene på konturen for at ændre størrelsen og dreje den, i firkanten for at afrunde hjørnerne og i punkterne indeni for at angive, hvor sløringen starter. + 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. Træk i nålen for at flytte båndet eller i dens ring for at ændre dens sløring. Træk i de ubrudte linjer for at udvide det skarpe bånd, i de stiplede linjer for at angive overgangen og i punkterne for at dreje det. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Træk i håndtagene på konturen for at ændre størrelsen og dreje den; ringen angiver sløringsvinklen. + Blur Angle Sløringsvinkel + Iris Blur… Irissløring… + Tilt-Shift… Tilt-Shift… + Path Blur… Stisløring… + Spin Blur… Rotationssløring… + Auto-Align Layers… Justér lag automatisk… + Auto-Blend Layers… Bland lag automatisk… + Photomerge… Photomerge… + Content-Aware Scale Indholdsbevidst skalering + Adaptive Wide Angle Adaptiv vidvinkel + Apply Image… Anvend billede… + Calculations… Beregninger… + Crystallize… Krystallisér… + Facet Facet + Fragment Fragment + Mosaic… Mosaik… + Pointillize… Pointillér… + Chalk & Charcoal Kridt og kul + Charcoal Kul + Chrome Krom + Conté Crayon Conté-crayon + Graphic Pen Grafisk pen + Halftone Pattern Rastermønster + Note Paper Notespapir + Photocopy Fotokopi + Plaster Puds + Reticulation Retikulering + Stamp Stempel + Torn Edges Flossede kanter + Water Paper Vandfarvepapir + Clouds Skyer + Difference Clouds Differenceskyer + Fibers… Fibre… + Lens Flare… Objektivblænding… + Picture Frame… Billedramme… + Tree… Træ… + Flame… Flamme… + Sharpen Edges Skærp kanter + Dust & Scratches… Støv og ridser… + Noise Støj + Add Noise… Tilføj støj… + Reduce Noise… Reducér støj… + Despeckle Fjern pletter + Median… Median… + Median Median + Maximum… Maksimum… + Maximum Maksimum + Minimum… Minimum… + Minimum Minimum + Custom… Brugerdefineret… + High Pass… Højpas… + Emboss… Relief… + Extrude… Ekstrudér… + Find Edges Find kanter + Solarize Solarisér + Tiles… Fliser… + Trace Contour… Spor kontur… + Wind… Vind… + Offset… Forskydning… + Pinch… Klem… + Polar Coordinates… Polære koordinater… + Ripple… Krusning… + Shear… Skævvridning… + Spherize… Kugleform… + Twirl… Hvirvel… + Wave… Bølge… + ZigZag… Zigzag… + Average Gennemsnit + Blur More Slør mere + Box Blur… Bokssløring… + Gaussian Blur… Gaussisk sløring… + Motion Blur… Bevægelsessløring… + Radial Blur… Radial sløring… + Shape Blur… Formsløring… + Smart Blur… Smart sløring… + Surface Blur… Overfladesløring… + Sharpen Skærp + Sharpen More Skærp mere + Unsharp Mask… Uskarp maskering… + Smart Sharpen… Smart skærpning… + Diffuse… Diffundér… + Mezzotint… Mezzotint… + Color Halftone… Farverastrering… + Layer… Lag… + +# View + Proof Setup Korrekturopsætning + Proof Colors Korrekturfarver + Pixel Aspect Ratio Pixelsideforhold + Pixel Aspect Ratio Correction Korrektion af pixelsideforhold + Zoom In Zoom ind + Zoom Out Zoom ud + Fit on Screen Tilpas til skærmen + 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 Udskriftsstørrelse + Screen Mode Skærmtilstand + Standard Screen Mode Standardskærmtilstand + Full Screen Mode With Menu Bar Fuldskærmstilstand med menulinje + Full Screen Mode Fuldskærmstilstand + Extras Ekstraer + Show Vis + Rulers Linealer + Grid Gitter + Guides Hjælpelinjer + Slices Udsnit + Snap Fastgør + Snap To Fastgør til + Lock Guides Lås hjælpelinjer + Clear Guides Ryd hjælpelinjer + New Guide… Ny hjælpelinje… + Lock Slices Lås udsnit + Clear Slices Ryd udsnit + New Guide Layout… Nyt layout for hjælpelinjer… + New Guides From Shape Nye hjælpelinjer fra form + Canvas rotation Lærredsrotation + Zoom in Zoom ind + Zoom out Zoom ud + +# Window + Cascade Kaskade + Tile Side om side + 2-up Horizontal 2-up vandret + 3-up Horizontal 3-up vandret + 2-up Vertical 2-up lodret + 3-up Vertical 3-up lodret + 3-up Stacked 3-up stablet + 4-up 4-up + 6-up 6-up + Consolidate All to Tabs Saml alle i faner + Match Zoom Afstem zoom + 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 + Actions Handlinger + Brush Settings Penselindstillinger + Brushes Pensler + Channels Kanaler + Character Tegn + Clone Source Kloningskilde + Color Mixer Farvemikser + Glyphs Glyffer + Gradients Gradienter + Histogram Histogram + History Historik + Info Info + Layer Comps Lagkompositioner + Layers Lag + Measurement Log Målingslog + Notes Noter + Navigator Navigator + Paragraph Afsnit + Paths Stier + Patterns Mønstre + Properties Egenskaber + Styles Stile + Swatches Farveprøver + Timeline Tidslinje + Tool Presets Værktøjsforudindstillinger + Tools Værktøjer + Libraries Biblioteker + 3D 3D + Toolbars Værktøjslinjer + Hide All Skjul alt + +# Help + About PhotoCraft Om PhotoCraft + PhotoCraft Website PhotoCrafts websted + PhotoCraft on GitHub PhotoCraft på GitHub + Report an Issue… Rapportér et problem… + ArtCraft Website ArtCrafts websted + Join the ArtCraft Discord… Deltag i ArtCrafts Discord… + System Info… Systemoplysninger… + +# Tools and options bar + Move Flyt + Marquee Markeringsramme + Lasso Lasso + Wand Tryllestav + Eyedropper Pipette + Brush Pensel + Block Blok + Pencil Blyant + Eraser Viskelæder + Clone Stamp Kloningsstempel + Healing Reparation + Blur Sløring + Dodge Lysne + Burn Mørkne + Gradient Gradient + Shape Form + Pen Pen + Hand Hånd + Zoom Zoom + Sampler Prøvetager + Ruler Tool Linealværktøj + Count Tool Tælleværktøj + Note tool Noteværktøj + Quick Selection Hurtig markering + Magnetic Lasso Magnetisk lasso + Mixer Brush Blandingspensel + Mixer Brush Tool Blandingspenselværktøj + Color Replacement Tool Farveerstatningsværktøj + History Brush Historikpensel + Art History Brush Kunstnerisk historikpensel + Pattern Stamp Mønsterstempel + Spot Healing Pletreparation + Red Eye Røde øjne + Patch Lap + Content-Aware Move Indholdsbevidst flytning + Rotate View Rotér visning + Perspective Crop Perspektivbeskæring + Color Replacement Farveerstatning + Dodge/Burn Lysne/Mørkne + Foreground Forgrund + Background Baggrund + Set foreground color Angiv forgrundsfarve + Set background color Angiv baggrundsfarve + Swap colours (X) Ombyt farver (X) + Default colours (D) Standardfarver (D) + Brush Tool Options Penselværktøjsindstillinger + Opacity Opacitet + Flow Gennemstrømning + Smoothing Udjævning + Size Størrelse + Hardness Hårdhed + Angle: Vinkel: + Roundness: Rundhed: + Spacing Afstand + Dynamics Dynamik + Pressure Tryk + Tilt Hældning + Rotation Rotation + Align Justér + Aligned Justeret + +# Panels, dialogs and common widgets + OK OK + Cancel Annullér + Cancel (Esc) Annullér (Esc) + Manage submitted jobs in the system print queue Administrér sendte job i systemets udskriftskø + Done Færdig + Apply Anvend + Reset Nulstil + Reset Section Nulstil sektion + Reset All to Defaults Nulstil alle til standardværdierne + Import Shortcuts… Importér genveje… + Load a keyboard shortcut set saved as .kys Indlæs et tastaturgenvejssæt, der er gemt som .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Importerede {n} genveje fra {file}. Klik på OK for at beholde dem. + {n} commands are not in PhotoCraft. {n} kommandoer findes ikke i PhotoCraft. + Keyboard shortcuts imported Tastaturgenveje importeret + 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. Dit tastaturgenvejssæt {set} er i brug her: {n} genveje afviger fra standarden. Redigér › Tastaturgenveje viser dem; Nulstil alle til standardværdierne fortryder dette. + Import keyboard shortcuts? Importér tastaturgenveje? + 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 fandt dit tastaturgenvejssæt {set} fra et andet billedredigeringsprogram på denne computer: {n} af genvejene afviger fra PhotoCrafts. Vil du importere det? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Redigér › Tastaturgenveje › Nulstil alle til standardværdierne fortryder en import. + Don't Import Importér ikke + The keyboard shortcut set was not imported: {error} Sættet med tastaturgenveje blev ikke importeret: {error} + Defaults restored. Standardindstillingerne er gendannet. + Restore Defaults Gendan standardindstillinger + Restore original size Gendan oprindelig størrelse + Use Default Brug standard + Dismiss Luk + Preview Forhåndsvisning + Options Indstillinger + Settings… Indstillinger… + Rename… Omdøb… + Load… Indlæs… + Define… Definér… + Custom Brugerdefineret + Default Standard + None Ingen + Add Tilføj + Remove Fjern + Remove Background Fjern baggrund + Rename Omdøb + Ignore Ignorér + Ignore All Ignorér alle + Change Ændr + Change All Ændr alle + Next frame Næste billede + Previous frame Forrige billede + Play Afspil + Pause Pause + Stop Stop + Begin recording Start optagelse + Stop playing/recording Stop afspilning/optagelse + Record ● a sequence of edits, then play ▶ it on any document. Optag ● en række redigeringer, og afspil ▶ den derefter på et hvilket som helst dokument. + Create new action Opret ny handling + Play selection Afspil det markerede + Create new set Opret nyt sæt + Export selected measurements (CSV) Eksportér markerede målinger (CSV) + Record Measurements Registrér målinger + Delete selected measurements Slet markerede målinger + Select Data Points… Markér datapunkter… + Set Measurement Scale… Angiv måleskala… + Use Measurement Scale Brug måleskala + +# Preferences dialog + Preferences Præferencer + General… Generelt… + Interface… Grænseflade… + File Handling… Filhåndtering… + Performance… Ydeevne… + Cursors… Markører… + Transparency & Gamut… Gennemsigtighed og gamut… + Units & Rulers… Enheder og linealer… + Guides, Grid & Slices… Hjælpelinjer, gitter og udsnit… + Plug-ins… Plug-ins… + Technology Previews Teknologieksempler + Scratch Disks… Arbejdsdiske… + Export Preferences… Eksportpræferencer… + Keyboard Shortcuts… Tastaturgenveje… + Menus… Menuer… + Export/Import Presets… Eksportér/importér forudindstillinger… + Additional plugins folder Ekstra plug-in-mappe + Install Plug-in… Installér plug-in… + Export Presets Eksportér forudindstillinger + Import brushes (.abr) Importér pensler (.abr) + Migrate Presets Overfør forudindstillinger + Thumbnail size (smallest: list) Miniaturestørrelse (mindste: liste) + Reset GPU Backend Nulstil GPU-backend + Applies at next launch. Anvendes ved næste opstart. + +# Document and canvas status bar + Untitled Uden navn + Untitled-1 Uden navn-1 + Doc: {flat}/{layered} Dok.: {flat}/{layered} + Scratch: {size} Arbejdsdisk: {size} + Efficiency: 100% Effektivitet: 100 % + Dimensions: Dimensioner: + Width Bredde + Slant Skråning + Height Højde + Width: Bredde: + Height: Højde: + Resolution Opløsning + Resolution: Opløsning: + Resolution: {dpi} pixels/inch Opløsning: {dpi} pixels/tomme + {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: Pixels: + Pixel Length: Pixellængde: + Logical Length: Logisk længde: + Logical Units: Logiske enheder: + Length: Længde: + Interpolation: Interpolation: + Resample Omsampling + Constrain proportions Bevar proportioner + Link width and height Sammenkæd bredde og højde + Maintain aspect ratio Bevar sideforhold + Current Size Aktuel størrelse + New Size Ny størrelse + Fit Tilpas + Percent Procent + Pixels/Inch Pixels/tomme + Pixels/Centimeter Pixels/centimeter + Relative Relativ + Canvas extension color: Farve til udvidelse af lærred: + Anchor: Anker: + Both Axes Begge akser + Horizontally Vandret + Vertically Lodret + +# Layers panel + Layers to Files… Lag til filer… + Duplicate Layers… Duplikér lag… + Group name Gruppenavn + New Group Ny gruppe + Layer Comp Lagkomposition + Create new layer Opret nyt lag + Create new group Opret ny gruppe + Create new channel Opret ny kanal + Create new path Opret ny sti + Toggle visibility Slå synlighed til/fra + Clipped Klippet + Group · {n} layer Gruppe · {n} lag + Lock: keep these settings when picking another preset Låst: bevar disse indstillinger, når du vælger en anden forudindstilling + Unlock: picking a preset replaces these settings Låst op: valg af en forudindstilling erstatter disse indstillinger + Show the layer's effects ({key}-click: all layers) Vis lagets effekter ({key}-klik: alle lag) + Hide the layer's effects ({key}-click: all layers) Skjul lagets effekter ({key}-klik: alle lag) + Expand group ({key}-click: all groups) Udvid gruppe ({key}-klik: alle grupper) + Collapse group ({key}-click: all groups) Fold gruppe sammen ({key}-klik: alle grupper) + Effects Effekter + Blending Blanding + Blend If Bland hvis + General Blend Generel blanding + Advanced Blending Avanceret blanding + Opacity: Opacitet: + Fill: Udfyldning: + Lock: Lås: + Smart Filter Smartfilter + Fill Content Udfyldningsindhold + Adjustment Preview Justeringsforhåndsvisning + +# Channels panel + Channel: Kanal: + Channel Mixer… Kanalmikser… + Output Channel: Outputkanal: + RGB RGB + Red Rød + Green Grøn + Blue Blå + Alpha Alfa + Load channel as selection Indlæs kanal som markering + Save selection as channel Gem markering som kanal + Delete current channel Slet aktuel kanal + Spot Channel Spotkanal + Mask All Maskér alt + Mask All Objects Maskér alle objekter + +# Color picker and swatches + Color Farve + Color: Farve: + Colors Farver + HSB/HSL HSB/HSL + No Color Ingen farve + Set shape fill type Angiv udfyldningstype for form + Set shape stroke type Angiv konturtype for form + Set the text color Angiv tekstfarven + Create warped text Opret tekst med vridning + Only Web Colors Kun webfarver + Overlay Color Overlejringsfarve + Overlay Opacity Overlejringsopacitet + Glow Color Glødefarve + Web Snap Webtilpasning + +# Filters and adjustments dialogs + Brightness Lysstyrke + Contrast Kontrast + Brightness/Contrast… Lysstyrke/kontrast… + Levels… Niveauer… + Curves… Kurver… + Curve Kurve + Exposure… Eksponering… + Exposure Eksponering + Vibrance… Livskraft… + Vibrance Livskraft + Hue/Saturation… Farvetone/mætning… + Hue Farvetone + Saturation Mætning + Lightness Lyshed + Color Balance… Farvebalance… + Black & White… Sort/hvid… + Photo Filter… Fotofilter… + Color Lookup… Farveopslag… + Color Lookup Tables… Farveopslagstabeller… + 3D LUT File 3D LUT-fil + Load 3D LUT… Indlæs 3D LUT… + Invert… Invertér… + Invert Invertér + Invert has no settings. Invertér har ingen indstillinger. + Threshold… Tærskel… + Threshold Level Tærskelniveau + Posterize… Posterisér… + Gradient Map… Gradientkort… + Selective Color… Selektiv farve… + Match Color… Afstem farve… + Replace Color… Erstat farve… + Equalize Udlign + HDR Toning… HDR-toning… + Shadows/Highlights… Skygger/højlys… + Amount Mængde + Radius Radius + Radius: Radius: + Tolerance: Tolerance: + Density Tæthed + Density: Tæthed: + Feather Blød kant + Feather… Blød kant… + Anti-alias Antialiasering + Anti-Alias Antialiasering + Contiguous Sammenhængende + Sample All Layers Prøvetag alle lag + Protect Foreground Color Beskyt forgrundsfarven + Matte Mat + Matting Kantbehandling + Color Decontaminate… Fjern farveforurening… + Remove Black Matte Fjern sort mat + Remove White Matte Fjern hvid mat + Defringe… Fjern kantfarver… + Source Kilde + Working CMYK Arbejds-CMYK + Working Cyan Plate Arbejdscyanplade + Working Magenta Plate Arbejdsmagentaplade + Working Yellow Plate Arbejdsgulplade + Working Black Plate Arbejdssortplade + Working Gray Plate Arbejdsgråplade + +# Camera Raw and lens dialogs + Basic Grundlæggende + Detail Detalje + HSL/Grayscale HSL/Gråtoner + Tone Curve Tonekurve + Split Toning Opdelt toning + Lens Corrections Objektivkorrektioner + Camera Calibration Kamerakalibrering + Presets Forudindstillinger + Snapshots Øjebliksbilleder + Temperature Temperatur + Tint Nuance + Exposure: Eksponering: + Recovery Gendannelse + Fill Light Udfyldningslys + Blacks Sorte + Clarity Klarhed + Convert to Grayscale Konvertér til gråtoner + Auto Auto + Auto collapse icon panels Fold ikonpaneler sammen automatisk + Auto show hidden panels Vis skjulte paneler automatisk + Auto show home screen Vis startskærm automatisk + Sharpness Skarphed + Noise Reduction Støjreduktion + Color Noise Reduction Farvestøjreduktion + Luminance Detail Luminansdetalje + Color Detail Farvedetalje + Luminance Smoothness Luminansglathed + Vignetting Vignettering + White Balance: As Shot Hvidbalance: Som fotograferet + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw er ikke åben + Dehaze Fjern dis + Texture Tekstur + Focal Length Brændvidde + Make Producent + Model Model + Lens Objektiv + F F + ISO ISO + Focal Length35mm Brændvidde 35 mm + Aspect Ratio Sideforhold + Time Tid + Artist Kunstner + Copyright Ophavsret + Enter description… Angiv beskrivelse… + Adobe RGB (1998) compatible Adobe RGB (1998)-kompatibel + Gray Gamma 2.2 Grå gamma 2.2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Industristandard + Web Web + Internet Standard RGB Internetstandard-RGB + Legacy Macintosh RGB Ældre Macintosh RGB + Monitor RGB Skærm-RGB + Windows LCD Windows LCD + Coated CMYK Bestrøget CMYK + Uncoated CMYK Ubestrøget CMYK + Newspaper Avis + Working Spaces Arbejdsfarverum + Color Management Policies Farvestyringspolitikker + Assign working space Tildel arbejdsfarverum + Leave as is (don't color manage) Lad være som det er (brug ikke farvestyring) + Discard the embedded profile (don't color manage) Kassér den indlejrede profil (brug ikke farvestyring) + Embed Color Profile Indlejr farveprofil + Assign Profile… Tildel profil… + Convert to Profile… Konvertér til profil… + PhotoCraft Manages Colors PhotoCraft styrer farver + Printer Manages Colors Printeren styrer farver + (print to the printer) (udskriv til printeren) + Absolute Colorimetric Absolut kolorimetrisk + Relative Colorimetric Relativ kolorimetrisk + Perceptual Perceptuel + Black Point Compensation Sortpunktskompensation + Use Legacy Brug ældre + Dither Dither + No Dither Ingen dither + Diffusion Diffusion + Pattern Mønster + Fuzziness: Uklarhed: + Range: Område: + Select: Markér: + Detect Faces Registrér ansigter + Localize Color Clusters Lokalisér farveklynger + Sample: Prøve: + Add to Sample Tilføj til prøve + Subtract from Sample Træk fra prøve + Selections Markeringer + Duplicated selection name Duplikeret markeringsnavn + Operation Operation + New channel only Kun ny kanal + Masked Areas Maskerede områder + Selected Areas Markerede områder + Spot Color Spotfarve + Color indicates Farve angiver + +# Adjustments panel + Curves Kurver + Levels Niveauer + Color Balance Farvebalance + Black & White Sort/hvid + Photo Filter Fotofilter + Color Lookup Farveopslag + Threshold Tærskel + Posterize Posterisér + Brightness/Contrast Lysstyrke/kontrast + Channel Mixer Kanalmikser + Gradient Map Gradientkort + Selective Color Selektiv farve + +# History panel + History Log… Historiklog… + Create Snapshot Opret øjebliksbillede + Delete Snapshot Slet øjebliksbillede + New Snapshot Nyt øjebliksbillede + Snapshot 1 Øjebliksbillede 1 + Step Forward Trin fremad + Step Backward Trin tilbage + Histories Historik + Nonlinear History Ikke-lineær historik + Set New Snapshot as Default Angiv nyt øjebliksbillede som standard + Automatically Create First Snapshot Opret automatisk det første øjebliksbillede + Automatically Create New Snapshot When Saving Opret automatisk et nyt øjebliksbillede, når der gemmes + Show New Snapshot Dialog by Default Vis dialogen Nyt øjebliksbillede som standard + Clear History Ryd historik + +# Navigator panel + Panel Options… Panelindstillinger… + View menu Menuen Vis + Keep view in sync Hold visningen synkroniseret + {zoom}% {zoom}% + +# Brush Settings panel + Brush name Penselnavn + Brush Preview Penselforhåndsvisning + Brush Tip Shape Penselspidsform + Shape Dynamics Formdynamik + Scattering Spredning + Dual Brush Dobbelt pensel + Color Dynamics Farvedynamik + Transfer Overførsel + Brush Pose Penselholdning + Mouse Controls Musestyring + Wet Edges Våde kanter + Build-up Opbygning + Protect Texture Beskyt tekstur + Blend Mode Blandingstilstand + Size Jitter Størrelsesjitter + Minimum Diameter Minimumsdiameter + Tilt Scale Tilt-skala + Angle Jitter Vinkeljitter + Roundness Jitter Rundhedsjitter + Minimum Roundness Minimumsrundhed + Flip X Jitter Vend X-jitter + Flip Y Jitter Vend Y-jitter + Brush Projection Penselprojektion + Control: Kontrol: + Off: the speed of the pointer sets the spacing Fra: markørens hastighed bestemmer afstanden + Fade steps Udtoningstrin + Pen Pressure Pennetryk + Pen Tilt Pennehældning + Stylus Wheel Stylushjul + Initial Direction of Stroke Startretning for strøg + Direction of Stroke Strøgretning + Opacity Jitter Opacitetsjitter + Flow Jitter Gennemstrømningsjitter + Humidity Fugtighed + Load Indlæs + Mix Blanding + Auto Clean Brush Rengør penslen automatisk + Wet Våd + Current Brush Load Aktuel penselfyldning + Load Brush Fyld pensel + Clean Brush Rengør pensel + Load the brush after each stroke Fyld penslen efter hvert strøg + Clean the brush after each stroke Rengør penslen efter hvert strøg + Rate Hastighed + Airbrush: keeps depositing paint while the pointer rests. Airbrush: bliver ved med at afsætte maling, mens markøren hviler. + Pulled String Mode Trukket tråd-tilstand + Catch-up on Stroke End Indhent ved strøgets afslutning + Adjust for Zoom Justér for zoom + Apply Per Tip Anvend pr. penselspids + Hardness: Hårdhed: + Spacing: Afstand: + Size: Størrelse: + Flip X Vend X + Flip Y Vend Y + Brush Tip Penselspids + Diameter: Diameter: + New Layer from Brush Nyt lag fra pensel + Rename Brush… Omdøb pensel… + Delete Brush Slet pensel + Import Brushes… Importér pensler… + Import Brushes… (.abr or image) Importér pensler… (.abr eller billede) + Import Brush from Image… Importér pensel fra billede… + Gradient Preset Options Indstillinger for gradientforudindstilling + Import .GRD Importér .GRD + Choose a gradient file (.grd). Vælg en gradientfil (.grd). + Brush Name Penselnavn + Brush Stroke Penselstrøg + New Brush Preset… Ny penselforudindstilling… + Capture Brush Size in Preset Gem penselstørrelse i forudindstillingen + Include Tool Settings Medtag værktøjsindstillinger + Include Color Medtag farve + Enter a name for the brush preset Angiv et navn for penselforudindstillingen + A brush preset with this name already exists Der findes allerede en penselforudindstilling med dette navn + Brush Preset Options Indstillinger for penselforudindstilling + New Brush Ny pensel + Create new brush from these settings Opret ny pensel ud fra disse indstillinger + Update the current brush with these settings Opdatér den aktuelle pensel med disse indstillinger + Reset the brush to the defaults Nulstil penslen til standardindstillingerne + No brush presets Ingen penselforudindstillinger + Override Pressure Tilsidesæt tryk + Override Tilt Tilsidesæt hældning + Override Tilt X Tilsidesæt hældning X + Override Tilt Y Tilsidesæt hældning Y + Override Rotation Tilsidesæt rotation + Apply the pen's tilt and rotation to the tip shape Anvend pennens hældning og rotation på spidsens form + +# Tool presets + Preset Type Forudindstillingstype + Preset Manager… Forudindstillingsadministrator… + Rename Tool Preset… Omdøb værktøjsforudindstilling… + Delete Tool Preset Slet værktøjsforudindstilling + New Tool Preset Ny værktøjsforudindstilling + Create new tool preset Opret ny værktøjsforudindstilling + Current Tool Only Kun aktuelt værktøj + No tool presets. Ingen værktøjsforudindstillinger. + No presets for the current tool. Ingen forudindstillinger for det aktuelle værktøj. + No presets of this type. Ingen forudindstillinger af denne type. + Preset: Forudindstilling: + Preset Forudindstilling + Presets: Forudindstillinger: + +# Paragraph and character panels + Font Size: Skriftstørrelse: + Font size Skriftstørrelse + Type: Type: + Style Stil + Style: Normal Stil: Normal + Tracking Spatiering + Kerning Knibning + Leading Linjeafstand + Auto-Leading Automatisk linjeafstand + Scale Horizontally Skalér vandret + Scale Vertically Skalér lodret + Baseline Shift Grundlinjeforskydning + Language Sprog + Anti-aliasing method Antialiasingmetode + Sharp Skarp + Crisp Klar + Strong Stærk + Smooth Blød + Mac LCD Mac LCD + Mac Sharp Mac skarp + Mac Crisp Mac klar + Mac Strong Mac stærk + Mac Smooth Mac blød + Faux Bold Falsk fed + Faux Italic Falsk kursiv + All Caps Versaler + Small Caps Små versaler + Superscript Hævet skrift + Subscript Sænket skrift + Underline Understreget + Strikethrough Gennemstreget + Oldstyle Medievalcifre + Ligature Ligatur + Alternative Alternativ + Hyphenate Orddeling + Roman Roman + Italic Kursiv + Bold Fed + Bold Italic Fed kursiv + Light Lys + Medium Mellem + Black Sort + Thin Tynd + Ultra Light Ultralet + Extra Light Ekstra let + Semi Bold Halvfed + Extra Bold Ekstra fed + Ultra Bold Ultrafed + Condensed Smal + Left align text Venstrejustér tekst + Center text Centrér tekst + Right align text Højrejustér tekst + Justify last left Justér med sidste linje til venstre + Justify last centered Justér med sidste linje centreret + Justify last right Justér med sidste linje til højre + Justify all Justér alle linjer + Indent Left Indryk venstre + Indent Right Indryk højre + First Line Indent Indrykning af første linje + Space Before Mellemrum før + Space After Mellemrum efter + Hanging Punctuation Hængende tegnsætning + Roman Hanging Punctuation Romersk hængende tegnsætning + Avoid Widows/Orphans Undgå enlige linjer + Compose Komponér + Adobe Single-line Composer Adobe-enkeltlinjeopsætter + Adobe Every-line Composer Adobe-flerlinjeopsætter + Justification Justering + Word Spacing Ordafstand + Letter Spacing Bogstavafstand + Glyph Scaling Glyfskalering + Auto-leading % Automatisk linjeafstand % + Single Word Justification Enkelordsjustering + Alignment Justering + Indent First Indryk første linje + Center Centrér + Justify Justér + +# Glyphs panel + Glyph Glyf + Select a type layer or click into text to insert glyphs Markér et tekstlag, eller klik i teksten for at indsætte glyffer + Entire Font Hele skrifttypen + Alternates for Current Selection Alternativer til den aktuelle markering + Recently Used Senest brugte + Digits Cifre + Symbols Symboler + Punctuation Tegnsætning + OpenType OpenType + Standard Ligatures Standardligaturer + Discretionary Ligatures Valgfrie ligaturer + Contextual Alternates Kontekstuelle alternativer + Swash Snirkler + Titling Alternates Titelalternativer + Ornaments Ornamenter + Fractions Brøker + Ordinals Ordenstal + East Asian Features Østasiatiske funktioner + Middle Eastern Features Mellemøstlige funktioner + +# Paths panel + Path Sti + Path: Sti: + Work Path Arbejdssti + Path to Illustrator… Sti til Illustrator… + Make Work Path Opret arbejdssti + Make Selection Opret markering + Fill Path Udfyld sti + Stroke Path Lav kontur på sti + Clipping Path Udklipningssti + New Path Ny sti + Duplicate Path Duplikér sti + Rename Path… Omdøb sti… + Delete Path Slet sti + Current Path Aktuel sti + Vector Mask from Current Path Vektormaske fra aktuel sti + Create new shape from the current path Opret ny form fra den aktuelle sti + Drag to move the active shape's path or the Work Path Træk for at flytte den aktive forms sti eller arbejdsstien + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: markér anker · Træk: flyt anker, håndtag eller segment · {shift}-klik: tilføj · {alt}-klik: hele delstien + Draw with the Pen tool (P) or make a work path from a selection. Tegn med penværktøjet (P), eller opret en arbejdssti ud fra en markering. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Kilde: ikke angivet ({key}-klik med kloningsstempel) + Clone source {n} ({key}-click with Clone Stamp to set) Kloningskilde {n} ({key}-klik med kloningsstempel for at angive) + {key}-click to set the source {key}-klik for at angive kilden + Option-click to define a source point to clone from Option-klik for at angive et kildepunkt at klone fra + Show Overlay Vis overlejring + Auto-Hide Skjul automatisk + W B + H H + Angle Vinkel + Scale Skalér + X X + Y Y + Offset Forskydning + Reset Transform Nulstil transformering + Rotate Rotér + Set horizontal skew Angiv vandret skævhed + Set vertical skew Angiv lodret skævhed + Flip Horizontal Vend vandret + Flip Vertical Vend lodret + Frame Sync Billedsynkronisering + Quick Sync Hurtig synkronisering + Enabled Aktiveret + +# Timeline panel + Create Video Timeline Opret videotidslinje + Open a document to use the timeline. Åbn et dokument for at bruge tidslinjen. + Video Layers Videolag + Video Video + Audio Lyd + Frame Duration Billedvarighed + 30 frames @ 30 fps 30 billeder @ 30 fps + Loop Sløjfe + Render Video… Gengiv video… + Split at Playhead Opdel ved afspilningsmarkør + Delete Timeline Slet tidslinje + Reload Frame Genindlæs billede + Restore Frame Gendan billede + Insert Blank Frame Indsæt tomt billede + Duplicate Frame Duplikér billede + Delete Frame Slet billede + Restore All Frames Gendan alle billeder + Flatten Frames Into Clips Fladgør billeder til klip + Flatten Frames Into Layers Fladgør billeder til lag + Convert Frames > Video Layers Konvertér billeder > videolag + New Video Layer Nyt videolag + New Video Group Ny videogruppe + Interpret Footage… Tolk materiale… + Replace Footage… Erstat materiale… + Show Altered Video Vis ændret video + Limit Playback Begræns afspilning + Enable Timeline Quick Config Aktivér hurtigopsætning af tidslinjen + Set Start Timecode… Angiv starttidskode… + Timecode Tidskode + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Aktuel tid + Out Ud + In Ind + Duration Varighed + Repeat Gentag + Play Once Afspil én gang + Video Cache Videocache + +# Info panel + Info Options… Infoindstillinger… + First Color Readout Første farveaflæsning + Second Color Readout Anden farveaflæsning + Actual Color Faktisk farve + Grayscale Slider Gråtoneskyder + Web Color Webfarve + HSB Color HSB-farve + Total Ink Samlet trykfarve + Opacity Mask Opacitetsmaske + Ruler Lineal + Current Tool Aktuelt værktøj + Document Size Dokumentstørrelse + Document Profile Dokumentprofil + Document Dimensions Dokumentdimensioner + Scratch Sizes Arbejdsdiskstørrelser + Efficiency Effektivitet + Timing Tidsmåling + Status Information Statusinformation + Saved Multichannel Gemt som multikanal + Mouse Coordinates Musekoordinater + Ruler Units Linealenheder + Hide Skjul + +# Layer Comps panel + Layer Comps to Files… Lagkompositioner til filer… + Apply next selected layer comp Anvend næste markerede lagkomposition + Apply previous selected layer comp Anvend forrige markerede lagkomposition + Update layer comp (visibility, position and appearance) Opdatér lagkomposition (synlighed, placering og udseende) + New Layer Comp Ny lagkomposition + Duplicate Layer Comp Duplikér lagkomposition + Delete layer comp Slet lagkomposition + Rename Layer Comp Omdøb lagkomposition + Create new layer comp Opret ny lagkomposition + Capture the visibility, position and style of every layer with + below, then switch between versions. Optag synlighed, placering og stil for hvert lag med + nedenfor, og skift derefter mellem versionerne. + Apply Layer Comp Anvend lagkomposition + Update Layer Comp Opdatér lagkomposition + Restore last viewed comp Gendan senest viste komposition + Last Document State Sidste dokumenttilstand + Visibility Synlighed + Position Placering + Appearance (Layer Style) Udseende (lagstil) + +# Analysis and variables + Analysis Analyse + Variables Variabler + Variable Data Sets… Variable datasæt… + Data Sets… Datasæt… + Data Sets Datasæt + Data Sets as Files… Datasæt som filer… + Apply Data Set… Anvend datasæt… + Define Definér + + Add variable + Tilføj variabel + Remove variable Fjern variabel + Define at least one variable first. Definér mindst én variabel først. + Open a document to define variables. Åbn et dokument for at definere variabler. + Variables bind a layer's visibility, text or pixels to a named data slot. Variabler binder et lags synlighed, tekst eller pixels til en navngiven dataplads. + Pixel Replacement Pixeludskiftning + Text Replacement Teksterstatning + Name Navn + Method Metode + Select All Layers Markér alle lag + Fill Udfyld + Keep proportions Bevar proportioner + Interpolation Interpolation + Author: Forfatter: + Author Forfatter + Name: Navn: + Caption Billedtekst + Caption Credits Billedtekstkrediteringer + Description Beskrivelse + Keywords Nøgleord + Copyright Info URL URL til ophavsretsinformation + Copyright Status Ophavsretsstatus + Copyright Notice Ophavsretsmeddelelse + Document Title Dokumenttitel + Copyrighted Ophavsretligt beskyttet + Public Domain Offentlig ejendom + Unknown Ukendt + Copyright and Contact Info Ophavsret og kontaktoplysninger + Credit Kreditering + Transmission Reference Transmissionsreference + Urgency Prioritet + High Høj + Normal Normal + Lossless Tabsfri + Low Lav + Category Kategori + Supplemental Categories Supplerende kategorier + Object Name / Title Objektnavn / titel + Date Created Oprettelsesdato + City By + Location Placering + Province / State Provins / delstat + Country Land + Byline Byline + Byline Title Byline-titel + Phone Telefon + Email E-mail + URLs URL'er + Last Modified Senest ændret + EXIF EXIF + GPS GPS + Camera Data Kameradata + Camera Model Kameramodel + Camera Make Kameramærke + EXIF Version EXIF-version + Exposure Program Eksponeringsprogram + Aperture Blænde + Shutter Speed Lukkerhastighed + Metering Mode Måletilstand + Light Source Lyskilde + Flash Blitz + Lens Info Objektivinfo + ISO Speed ISO-hastighed + Subject Distance Motivafstand + DateTime Original Oprindelig dato/tid + Create Date Oprettelsesdato + Modify Date Ændringsdato + Software Software + Raw Data Rådata + Version {version} Version {version} + Advanced Avanceret + Import… Importér… + +# Automate + Automate Automatér + Batch… Batch… + Create Droplet… Opret droplet… + Create Droplet Opret droplet + PDF Presentation… PDF-præsentation… + Create Multiple Picture Packages Opret flere billedpakker + Contact Sheet II Kontaktark II + Contact Sheet II… Kontaktark II… + Conditional Mode Change… Betinget tilstandsændring… + Fit Image… Tilpas billede… + Picture Package… Billedpakke… + Web Photo Gallery… Webfotogalleri… + Merge to HDR Pro… Flet til HDR Pro… + Load Files into Stack… Indlæs filer i stak… + Scripts Scripts + Script Events Manager… Scripthændelsesadministrator… + Image Processor… Billedprocessor… + Destination Destination + Choose… Vælg… + Select Folder… Vælg mappe… + Override Action "Save" Commands Tilsidesæt handlingens "Gem"-kommandoer + Compatibility Kompatibilitet + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Medtag ICC-profil + Error File Fejlfil + Stop For Errors Stop ved fejl + Log Errors To File Skriv fejl til fil + Errors Fejl + +# Color Settings dialog + Color Settings… Farveindstillinger… + Settings Indstillinger + Settings: Indstillinger: + Working Space Arbejdsfarverum + RGB: RGB: + CMYK: CMYK: + Gray: Grå: + Spot: Spot: + Advanced Controls Avancerede kontrolelementer + Desaturate Monitor Colors By Reducér skærmfarver med + Blend RGB Colors Using Gamma 1.0 Bland RGB-farver ved hjælp af gamma 1,0 + Profile Mismatches: Ask When Opening Profiluoverensstemmelser: Spørg ved åbning + Ask When Pasting Spørg ved indsættelse + Missing Profiles: Ask When Opening Manglende profiler: Spørg ved åbning + Embed profiles Indlejr profiler + Ask Before Saving Saved Embedded Spørg, før gemt indlejret gemmes + Coerce to sRGB Tving til 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 + +# Themes + Theme Tema + Switch theme Skift tema + Next Theme Næste tema + Classic Theme Klassisk tema + Solarized dark Solarized mørk + Solarized Dark Theme Solarized mørkt tema + Breeze dark Breeze mørk + Breeze Dark Theme Breeze mørkt tema + Breeze light Breeze lys + Breeze Light Theme Breeze lyst tema + Soft dark Soft mørk + Soft light Soft lys + Soft Dark Theme Soft mørkt tema + Soft Light Theme Soft lyst tema + Studio Theme Studio-tema + Studio Light Theme Studio lyst tema + Adwaita Light Theme Adwaita lyst tema + Adwaita Dark Theme Adwaita mørkt tema + Pro Theme Pro-tema + Pro Medium Gray Theme Pro mellemgråt tema + Pro Light Gray Theme Pro lysegråt tema + Pro Light Theme Pro lyst tema + +# Workspaces + Workspace… Arbejdsområde… + Essentials Grundlæggende + New Workspace… Nyt arbejdsområde… + Delete Workspace… Slet arbejdsområde… + Reset Workspace Nulstil arbejdsområde + Reset Tool Nulstil værktøj + Reset All Tools Nulstil alle værktøjer + Reset all tools to their default settings? Nulstil alle værktøjer til deres standardindstillinger? + Lock Workspace Lås arbejdsområde + Photography Fotografering + Graphic and web Grafik og web + Painting Maling + Motion Bevægelse + Minimalistic Minimalistisk + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panelplaceringer gemmes i arbejdsområdet. Tastaturgenveje, menuer og værktøjslinje er valgfrie. + Keyboard Shortcuts Tastaturgenveje + Keyboard Shortcuts and Menus Tastaturgenveje og menuer + Menus Menuer + More panels Flere paneler + More documents Flere dokumenter + Collapse All Groups Fold alle grupper sammen + Expand All Groups Udvid alle grupper + Collapse Fold sammen + Expand Udvid + Collapse Panel Group Fold panelgruppe sammen + Expand Panel Group Udvid panelgruppe + Close Tab Group Luk fanegruppe + +# New Document dialog + PRESET DETAILS FORUDINDSTILLINGSDETALJER + BLANK DOCUMENT PRESETS ({n}) TOMME DOKUMENTFORUDINDSTILLINGER ({n}) + Create a new document or open an existing file. Opret et nyt dokument, eller åbn en eksisterende fil. + Drop an image or PSD anywhere to open it. Slip et billede eller en PSD hvor som helst for at åbne det. + Use File › Open, or paste a copied image with {paste}. Brug Filer › Åbn, eller indsæt et kopieret billede med {paste}. + Native file drag-and-drop is unavailable Indbygget træk-og-slip af filer er ikke tilgængeligt + 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}. Indbygget træk-og-slip af filer understøttes endnu ikke på Wayland. Brug Filer › Åbn, eller kopiér billedet i din filhåndtering, og indsæt det med {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` For at slippe filer skal du starte PhotoCraft under XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Eller sæt Præferencer › Ydeevne › Linux-visningsserver til X11: PhotoCraft starter derefter altid under XWayland. + Edit › Paste Redigér › Indsæt + Photo Foto + Print Udskriv + Art & Illustration Kunst og illustration + Mobile Mobil + Film & Video Film og video + Default PhotoCraft Size PhotoCraft-standardstørrelse + Clipboard Udklipsholder + Photo (2 × 3 in) Foto (2 × 3 tommer) + Photo (4 × 6 in) Foto (4 × 6 tommer) + Photo (5 × 7 in) Foto (5 × 7 tommer) + Photo (8 × 10 in) Foto (8 × 10 tommer) + Landscape Liggende + Portrait Stående + Recent Items Seneste elementer + Save Preset… Gem forudindstilling… + Delete Preset… Slet forudindstilling… + Preset Name: Navn på forudindstilling: + Width and Height Bredde og højde + Background Contents Baggrundsindhold + Custom background color Brugerdefineret baggrundsfarve + White Hvid + Background Color Baggrundsfarve + Transparent Gennemsigtig + Color Mode Farvetilstand + Depth Dybde + Color Profile Farveprofil + Create Opret + Document Dokument + Units Enheder + Pixels Pixels + Inches Tommer + Centimeters Centimeter + Millimeters Millimeter + Points Punkter + Picas Pica + Columns Kolonner + +# Placeholders, tooltips, status and keyboard hints + {name} options {name}-indstillinger + Align {what} Justér {what} + Untagged {mode} {mode} uden profil + Invalid {mode} profile Ugyldig {mode}-profil + Unnamed {mode} profile Unavngiven {mode}-profil + Constraints ({n}) Begrænsninger ({n}) + Count: {n} Antal: {n} + Click to add a point · drag off or {key}-click or Delete to remove Klik for at tilføje et punkt · træk væk, {key}-klik eller Delete for at fjerne + Click to apply, {key}-click to clear overrides, double-click for Style Options Klik for at anvende, {key}-klik for at fjerne tilsidesættelser, dobbeltklik for stilindstillinger + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: hjørne · Træk: glat · Klik på det første punkt: luk · {key} afslut · Esc annullér + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klik: hjørne · Træk: glat · {alt}-træk: knæk håndtaget · Klik eller træk i det første punkt: luk · {key} afslut · Esc annullér + Commit Perspective Warp ({key}) Anvend perspektivvridning ({key}) + Commit Puppet Warp ({key}) Anvend marionetvridning ({key}) + Commit any current edits ({key}) Anvend alle aktuelle redigeringer ({key}) + Commit transform ({key}) Anvend transformering ({key}) + Commit warp ({key}) Anvend vridning ({key}) + Cancel any current edits (Esc) Annullér alle aktuelle redigeringer (Esc) + Cancel transform (Esc) Annullér transformering (Esc) + Cancel warp (Esc) Annullér vridning (Esc) + Do you want to save the changes you made to “{name}” before closing? Vil du gemme de ændringer, du har foretaget i »{name}«, før du lukker? + 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 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. + Drag the arrow to set the angle, the dots to set the roundness Træk i pilen for at indstille vinklen, og i prikkerne for at indstille rundheden + Create new pattern from the selection Opret nyt mønster fra markeringen + Create new gradient Opret ny gradient + Create new count group Opret ny tællegruppe + Search commands, tools and panels… Søg i kommandoer, værktøjer og paneler… + Search Brushes Søg i pensler + Search fonts Søg i skrifttyper + No matching commands Ingen kommandoer passer + No document Intet dokument + Pixel Layer Pixellag + Type Layer Tekstlag + Shape Layer Formlag + Group Layer Gruppelag + Fill Layer Udfyldningslag + Smart Object Layer Smartobjektlag + Adjustment Layer Justeringslag + {name} Properties Egenskaber for {name} + 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. + 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 + Single Column Marquee Tool Markeringsværktøj til enkelt kolonne + Brush Tool Penselværktøj + Pencil Tool Blyantværktøj + Eraser Tool Viskelæderværktøj + Background Eraser Tool Baggrundsviskelæderværktøj + Magic Eraser Tool Magisk viskelæderværktøj + Eyedropper Tool Pipetteværktøj + Note Tool Noteværktøj + Lasso Tool Lassoværktøj + Polygonal Lasso Tool Polygonlassoværktøj + Magic Wand Tool Tryllestavsværktøj + Crop Tool Beskæringsværktøj + Slice Tool Udsnitsværktøj + Slice Select Tool Udsnitsmarkeringsværktøj + Gradient Tool Gradientværktøj + Paint Bucket Tool Malerspandsværktøj + Horizontal Type Tool Vandret tekstværktøj + Horizontal Type Mask Tool Vandret tekstmaskeværktøj + Vertical Type Mask Tool Lodret tekstmaskeværktøj + Hand Tool Håndværktøj + Zoom Tool Zoomværktøj + Spot Healing Brush Tool Pletreparationspenselværktøj + Healing Brush Tool Reparationspenselværktøj + Clone Stamp Tool Kloningsstempelværktøj + History Brush Tool Historikpenselværktøj + Blur Tool Sløringsværktøj + Sharpen Tool Skærpeværktøj + Smudge Tool Udtværingsværktøj + Dodge Tool Lysningsværktøj + Burn Tool Mørkningsværktøj + Sponge Tool Svampværktøj + Quick Selection Tool Hurtigt markeringsværktøj + Object Selection Tool Objektmarkeringsværktøj + Pen Tool Penværktøj + Convert Point Tool Punktkonverteringsværktøj + Path Selection Tool Stimarkeringsværktøj + Direct Selection Tool Direkte markeringsværktøj + Add Anchor Point Tool Værktøjet Tilføj ankerpunkt + Delete Anchor Point Tool Værktøjet Slet ankerpunkt + Click a segment to add an anchor point · {alt}-click an anchor to delete it Klik på et segment for at tilføje et ankerpunkt · {alt}-klik på et anker for at slette det + Click an anchor point to delete it · {alt}-click a segment to add one Klik på et ankerpunkt for at slette det · {alt}-klik på et segment for at tilføje et + Rectangle Tool Rektangelværktøj + Ellipse Tool Ellipseværktøj + Triangle Tool Trekantværktøj + Polygon Tool Polygonværktøj + Line Tool Linjeværktøj + Custom Shape Tool Brugerdefineret formværktøj + Pixel Pixel + Group Gruppe + Adjustment Justering + Smart Object Smartobjekt + 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 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 + {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 + Fill Screen Fyld skærm + Scrubby Zoom Zoom ved træk + Auto Erase Automatisk sletning + Color Adaptation Farvetilpasning + Preserve Transparency Bevar gennemsigtighed + Reverse Omvendt + Align with layer Justér med laget + Join us on Discord Mød os på Discord + PhotoCraft website PhotoCraft-websted + ArtCraft ArtCraft + GitHub GitHub + No character style Ingen tegnstil + No author Ingen forfatter + Not in Dictionary: Ikke i ordbogen: + Suggestions: Forslag: + Spell check complete. Stavekontrol fuldført. + Check All Layers Kontrollér alle lag + Select a type layer to edit its character and paragraph settings. Markér et tekstlag for at redigere dets tegn- og afsnitsindstillinger. + Select text to apply a character style Markér tekst for at anvende en tegnstil + Redefine style by current selection Omdefinér stil ud fra aktuel markering + Duplicate style Duplikér stil + Delete style Slet stil + Style Options Stilindstillinger + Style Options… Stilindstillinger… + Close Style Options Luk stilindstillinger + Appearance Udseende + Text Position: Tekstplacering: + Text color Tekstfarve + Uses the foreground and background colours. 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 + No Nej + Yes Ja + These settings aren't available in PhotoCraft yet. Disse indstillinger er endnu ikke tilgængelige i PhotoCraft. + Shortcut removed. Genvej fjernet. + All shortcuts reset to PhotoCraft defaults. Alle genveje er nulstillet til PhotoCrafts standardindstillinger. + Add Shortcut Tilføj genvej + Delete Shortcut Slet genvej + Press keys… Tryk på taster… + shortcut genvej + command or shortcut kommando eller genvej + current aktuel + image file path sti til billedfil + modified ændret + 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. + Saved Gemt + Reveal in Finder Vis i Finder + Show in Explorer Vis i Stifinder + Show in Folder Vis i mappe + Before (Y) Før (Y) + Save Settings… Gem indstillinger… + Load Settings… Indlæs indstillinger… + Save these settings as a Camera Raw .xmp file Gem disse indstillinger som en Camera Raw .xmp-fil + Load settings from a Camera Raw .xmp file Indlæs indstillinger fra en Camera Raw .xmp-fil + Loaded Camera Raw settings from {file} Indlæste Camera Raw-indstillinger fra {file} + Loaded Camera Raw settings from {file} ({n} not supported) Indlæste Camera Raw-indstillinger fra {file} ({n} understøttes ikke) + After (X) Efter (X) + Original Original + Current Aktuel + {added}, {removed} subtracted {added}, {removed} fratrukket + Filter: Filter: + +# Transform / warp overlays + Free Transform Fri transformering + Free Transform a Copy Fri transformering af en kopi + Transform Transformér + Transform Selection Transformér markering + Switch between free transform and warp modes Skift mellem fri transformering og vridning + Perspective Perspektiv + Skew Skævvrid + Distort Forvræng + Rotate: Rotér: + Flip Vend + Warp: Vridning: + Warp Vridning + Bend: Bøjning: + Reference point location Referencepunktets placering + Toggle reference point Slå referencepunkt til/fra + Free Fri + Arc Bue + Arch Hvælving + Bulge Bulning + Shell Lower Nedre skal + Shell Upper Øvre skal + Flag Flag + Fish Fisk + Wave Bølge + Rise Hævning + Fisheye Fiskeøje + Inflate Opblæs + Twist Snoning + Squeeze Sammenpresning + Corner radius Hjørneradius + Split Warp Crosswise Opdel vridning på tværs + Split Warp Horizontally Opdel vridning vandret + Split Warp Vertically Opdel vridning lodret + Remove Warp Split Fjern vridningsopdeling + Puppet Warp Marionetvridning + Puppet Warp is not active Marionetvridning er ikke aktiv + Perspective Warp Perspektivvridning + Perspective Warp is not active Perspektivvridning er ikke aktiv + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivvridning: tegn planer langs billedets perspektiv, og skift derefter til Vridning + Remove Planes Fjern planer + Edit Pins Redigér nåle + Add Pins Tilføj nåle + Delete Pins Slet nåle + Remove All Pins Fjern alle nåle + Remove Tool Fjernelsesværktøj + Pin Depth: Nåledybde: + Bring the selected pin forward Flyt den markerede nål fremad + Send the selected pin backward Flyt den markerede nål bagud + Rotate Auto Rotér automatisk + Rotate Fixed Rotér med fast vinkel + Adaptive Wide Angle isn't open Adaptiv vidvinkel er ikke åben + Liquify is not open Gør flydende er ikke åben + +# Vanishing Point + Create Plane Opret plan + Edit Plane Redigér plan + Select Plane Markér plan + Measure Mål + Heal Reparér + Off Fra + Symmetry Off Ingen symmetri + Heal Off Reparation fra + Heal On Reparation til + Heal Auto Reparation automatisk + Blend Blanding + Brush Size Penselstørrelse + Heal Color Reparationsfarve + +# Misc dialog strings + Mask Maske + Channel Kanal + Rectangle Rektangel + Subtract Fratræk + Multiply Multiplicer + Divide Dividér + Result Resultat + New Document Nyt dokument + New Document With Mask Nyt dokument med maske + New Layer With Mask Nyt lag med maske + New Layer Nyt lag + Preset Name Navn på forudindstilling + No saved presets yet. Der er endnu ingen gemte forudindstillinger. + New Channel Ny kanal + Selection Markering + Save Selection Gem markering + Load Selection Indlæs markering + Duplicate Duplikér + Fast Hurtig + Accurate Nøjagtig + Add to Føj til + Subtract from Træk fra + Intersect with Skær med + +# Blend modes + Pass Through Passér igennem + Dissolve Opløst + Darken Mørkere + Color Burn Farvebrænding + Linear Burn Lineær brænding + Darker Color Mørkere farve + Lighten Lysere + Screen Skærm + Color Dodge Farvelysning + Linear Dodge (Add) Lineær lysning (addition) + Lighter Color Lysere farve + Overlay Overlejring + Soft Light Blødt lys + Hard Light Hårdt lys + Vivid Light Levende lys + Linear Light Lineært lys + Pin Light Nålelys + Hard Mix Hård blanding + Difference Differens + Exclusion Udelukkelse + Luminosity Luminans + +# Saved views and other bits + Capture Comp Gem komposition + No Layer Comps Ingen lagkompositioner + +# Plurals +@plural {n} item|{n} items {n} element|{n} elementer +@plural {n} Layer|{n} Layers {n} lag|{n} lag +@plural {n} layer|{n} layers {n} lag|{n} lag +@plural Group · {n} layer|Group · {n} layers Gruppe · {n} lag|Gruppe · {n} lag +@plural {n} sample|{n} samples {n} prøve|{n} prøver + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… Indstillinger for 32-bit-forhåndsvisning… + 90° Clockwise 90° med uret + 90° Counter Clockwise 90° mod uret + AI Integrations… AI-integrationer… + Again Igen + Transform Again on a Copy Transformér igen på en kopi + Anamorphic 2:1 (2) Anamorfisk 2:1 (2) + Arbitrary… Vilkårlig vinkel… + Artboard Guides Tavlehjælpelinjer + Artboard from Group… Tavle fra gruppe… + Artboard from Layers… Tavle fra lag… + Artboards to Files… Tavler til filer… + Artboards to PDF… Tavler til PDF… + Artboard… Tavle… + Border… Kant… + Bottom Edges Nederste kanter + Browse… Gennemse… + Source Files Kildefiler + Use: Brug: + Files Filer + Folder Mappe + Add Open Files Tilføj åbne filer + Sort By Name Sortér efter navn + Attempt to Automatically Align Source Images Forsøg at justere kildebilleder automatisk + Create Smart Object after Loading Layers Opret smartobjekt efter indlæsning af lag + Canvas Guides Lærredshjælpelinjer + 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 Table… Farvetabel… + Combine Shapes Kombinér former + Contract… Formindsk… + Convert to Embedded Konvertér til indlejret + Convert to Layers Konvertér til lag + Convert to Linked… Konvertér til sammenkædet… + Count Optælling + Create Layer Opret lag + Custom Pixel Aspect Ratio… Brugerdefineret pixelsideforhold… + Cycle Screen Mode Skift skærmtilstand + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC Widescreen (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL Widescreen (1.46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + De-Interlace… Fjern interlace… + Default Features Standardfunktioner + Delete All Empty Layers Slet alle tomme lag + Desaturate Fjern mætning + Displace… Forskyd… + Distribute Fordel + Document Bounds Dokumentgrænser + Edit in Quick Mask Mode Redigér i hurtigmasketilstand + Enhanced Controls… Udvidede kontrolelementer… + Entropy Entropi + Exclude Overlapping Shapes Udeluk overlappende former + Exit Afslut + Expand… Udvid… + Extra Large Ekstra stor + File Info… Filoplysninger… + Fit Artboard on Screen Tilpas tavle til skærmen + Fit Layer(s) on Screen Tilpas lag til skærmen + Flatten All Layer Effects Fladgør alle lageffekter + Flatten All Masks Fladgør alle masker + Flatten Image Fladgør billede + Float All in Windows Frigør alle i vinduer + Float in Window Frigør i vindue + Frame from Layers Billede fra lag + From Transparency Fra gennemsigtighed + Gamut Warning Gamutadvarsel + Global Light… Globalt lys… + Gradient… Gradient… + Graphic and Web Grafik og web + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + Hidden Layers Skjulte lag + Hide All Effects Skjul alle effekter + Horizontal Centers Vandrette midtpunkter + Huge Enorm + Indexed Color… Indekserede farver… + Integrations… Integrationer… + Intersect Shape Areas Skær formområder + Kurtosis Kurtosis + Large Stor + Layer Edges Lagkanter + Left Edges Venstre kanter + Lens Blur… Objektivsløring… + Lighting Effects… Lyseffekter… + Link Layers Sammenkæd lag + Lock Layers… Lås lag… + Mean Middelværdi + Merge Down Flet ned + Merge Group Flet gruppe + Merge Layers Flet lag + Merge Shape Components Flet formkomponenter + Merge Visible Flet synlige + Stamp Visible Stempl synlige + Select Layer Above Markér lag ovenover + Select Layer Below Markér lag nedenunder + Select Top Layer Markér øverste lag + Select Bottom Layer Markér nederste lag + Add Layer Above to Selection Føj lag ovenover til markeringen + Add Layer Below to Selection Føj lag nedenunder til markeringen + Stamp Down Stempl ned + Mesh Net + Middle Eastern & South Asian Composer Mellemøstlig og sydasiatisk opsætter + Modifier Keys Modifikationstaster + Modify Modificér + NTSC Colors NTSC-farver + Notes… Noter… + Other Andre + Other… Andet… + Paragraph Styles Panel Afsnitsstilpanel + Paths to Illustrator… Stier til Illustrator… + Pattern Preview Mønsterforhåndsvisning + Pattern… Mønster… + Pixel Art Pixelkunst + Pixel Art Preview Pixelkunstforhåndsvisning + Pixel Grid Pixelgitter + Pixelate Pixelér + Place Scale Marker… Placér skalamarkør… + Plug-ins Plug-ins + Print One Copy Udskriv én kopi + Print… Udskriv… + Range Spændvidde + Raw Defaults… Raw-standarder… + Relink to File… Sammenkæd med fil igen… + Relight… Belys igen… + Render Gengiv + Replace All Missing Fonts Erstat alle manglende skrifttyper + Resolve Missing Fonts… Håndtér manglende skrifttyper… + Reveal Selection Vis markering + Right Edges Højre kanter + Selection Edges Markeringskanter + Shapes Former + Show Extras Options… Indstillinger for ekstraer… + Skewness Skævhed + Small Lille + Smart Guides Smarte hjælpelinjer + Smart Objects Smartobjekter + Smooth… Udjævn… + Solid Color… Ensfarvet… + Square Kvadratisk + Stack Mode Staktilstand + Choose Stack Mode: Vælg staktilstand: + Standard Deviation Standardafvigelse + Statistics… Statistik… + Stylistic Alternates Stilistiske alternativer + Stylize Stilisér + Subtract Front Shape Træk forreste form fra + Summation Sum + Target Path Målsti + Tile All Horizontally Alle vandret side om side + Tile All Vertically Alle lodret side om side + Toggle Last State Skift til seneste tilstand + Toolbar… Værktøjslinje… + Tools… Værktøjer… + Top Edges Øverste kanter + Unite Shapes Foren former + Update All Modified Content Opdatér alt ændret indhold + Update All Text Layers Opdatér alle tekstlag + Update Modified Content Opdatér ændret indhold + Variance Varians + Vertical Centers Lodrette midtpunkter + Video Frames to Layers… Videobilleder til lag… + WIA Support… WIA-support… + Working CMY Plate Arbejds-CMY-plade + +# tl! literals (third pass) + Absolute Absolut + Action Handling + Adaptive Adaptiv + Add Stop Tilføj stop + Add disk Tilføj disk + Align and Distribute Justér og fordel + All Slices Alle udsnit + All User Slices Alle brugerudsnit + Alt Alt + Artboard Tavle + Artboard Background Color Baggrundsfarve for tavle + Auto Hide Skjul automatisk + Automatically level near-horizontal line segments Ret automatisk næsten vandrette linjesegmenter ud + Automatically straighten and level Ret ud og gør vandret automatisk + Automatically straighten near-vertical line segments Ret automatisk næsten lodrette linjesegmenter ud + Balance Balance + Baseline Grundlinje + Bicubic Bikubisk + Bicubic (smooth gradients) Bikubisk (glatte gradienter) + Automatic Automatisk + Bicubic Smoother (enlargement) Bikubisk glattere (forstørrelse) + Bicubic Sharper (reduction) Bikubisk skarpere (formindskelse) + Bilinear Bilineær + Black, White Sort, hvid + Blue, Red, Yellow Blå, rød, gul + Bottom Nederst + Brightness Jitter Lysstyrkejitter + Brush Reconstruct Options Indstillinger for penselrekonstruktion + Cache Level: Cacheniveau: + Canvas Lærred + Capture Optag + Center Crop Marks Centrerede beskæringsmærker + Centre 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 + Clear Override Fjern tilsidesættelse + Clear Samples Ryd prøver + Cmd Cmd + Color Grading Farvegradering + Color Management Farvestyring + Color Priority Farveprioritet + Color Range preview Forhåndsvisning af farveområde + Colorize Farvelæg + Constant Konstant + Content-Aware Indholdsbevidst + Convert to sRGB Konvertér til sRGB + Copies Kopier + Copper Kobber + Corner Crop Marks Hjørnebeskæringsmærker + Correction Korrektion + Count Jitter Antalsjitter + Create Texture Opret tekstur + Create a new count group Opret en ny optællingsgruppe + Crop Factor Beskæringsfaktor + Ctrl Ctrl + Custom Shapes Brugerdefinerede former + Cyan Cyan + Cyan · Red Cyan · Rød + Darks Mørke + Default printer Standardprinter + Default restored. Standarden er gendannet. + Delete Group Slet gruppe + Delete count group Slet optællingsgruppe + Delete effect layer Slet effektlag + Delete note Slet note + Delete tool preset Slet værktøjsforudindstilling + Depth Jitter Dybdejitter + Discontiguous Usammenhængende + Discord Discord + Display P3 Display P3 + Display Text Vis tekst + Divide… Opdel… + Do you want to save the changes you made to “{name}” before quitting? Vil du gemme de ændringer, du har foretaget i »{name}«, før du afslutter? + Edit Toolbar… Redigér værktøjslinje… + Edit in Quick Mask Mode (Q) Redigér i hurtigmasketilstand (Q) + Edit in Standard Mode (Q) Redigér i standardtilstand (Q) + Embedded Profile Mismatch Indlejret profil matcher ikke + Enhance Edge Forbedr kant + Exact Nøjagtig + Expansion: Udvidelse: + Exposure and Gamma Eksponering og gamma + Factor: Faktor: + Fewer Points Færre punkter + File Settings Filindstillinger + Finger Painting Fingermaling + Fit in View Tilpas til visning + Fixed Fast + Foreground to Background Forgrund til baggrund + Foreground/Background Jitter Forgrund/baggrund-jitter + Format Format + Frame from Layers… Billede fra lag… + Full Spherical Fuld sfærisk + Gamma Correction Gammakorrektion + Gamma: Gamma: + Global Global + Grain Korn + Graphics Grafik + Gray Grå + Heart Hjerte + Hide Auto Slices Skjul automatiske udsnit + Highlight Compression Højlyskomprimering + Highlight Priority Højlysprioritet + Highlights Højlys + Home Startside + Hue Jitter Farvetonejitter + Image Size: Billedstørrelse: + Imported Importeret + Input: Input: + Interlaced Interlace + Invert All Invertér alle + Label Size Etiketstørrelse + Labels Etiketter + Lanczos (sharp) Lanczos (skarp) + Level Niveau + Lights Lyse + Limits: Grænser: + Link the mask to the layer Sammenkæd masken med laget + Localized Color Clusters Lokaliserede farveklynger + Luminance Luminans + Magenta Magenta + Magenta · Green Magenta · Grøn + Marker Size Mærkestørrelse + Mask Options Maskeindstillinger + Masking Maskering + Master Master + Mean: Middelværdi: + Median: Median: + Mesh Size Netstørrelse + Metadata Metadata + Midpoint Midtpunkt + Midtones Mellemtoner + Midtones from: Mellemtoner fra: + Midtones to: Mellemtoner til: + Minimum Depth Minimumsdybde + Mix Jitter Blandingsjitter + Mode: Tilstand: + Monochrome Monokrom + More Points Flere punkter + Move Group Down Flyt gruppe ned + Move Group Up Flyt gruppe op + Move effect layer down Flyt effektlag ned + Move effect layer up Flyt effektlag op + Move to Flyt til + Nearest Neighbor Nærmeste nabo + Nearest Neighbor (hard edges) Nærmeste nabo (hårde kanter) + New effect layer Nyt effektlag + Next note Næste note + No Color Management Ingen farvestyring + Optimized Optimeret + Output Levels: Outputniveauer: + Output: Output: + Paint Overlay Malingsoverlejring + Pattern Size Mønsterstørrelse + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — et native open source-billedredigeringsprogram skrevet i Rust. + Position and Size Placering og størrelse + Preserve Details Bevar detaljer + Preserve Luminosity Bevar luminans + Previous note Forrige note + Printer Printer + Printer Setup Printeropsætning + Printing Marks Trykmærker + Progressive Progressiv + Promote Forfrem + Protect Detail Beskyt detaljer + Protect Tones Beskyt toner + Proximity Match Nærhedsmatchning + Purity Renhed + Quality Kvalitet + Recent Seneste + Reconstruct Rekonstruér + Refine Finjustér + Registration Marks Registreringsmærker + Regular Normal + Remove stop Fjern stop + Rename Group… Omdøb gruppe… + Rename Layer… Omdøb lag… + Restore All Gendan alle + Restrictive (Web) Restriktiv (web) + Rigid Stiv + Roughness Ruhed + Roundness Rundhed + Rulers & Grids Linealer og gitter + Sample: foreground colour Prøve: forgrundsfarve + Sampling: Background Swatch Prøvetagning: baggrundsfarveprøve + Sampling: Continuous Prøvetagning: fortløbende + Sampling: Once Prøvetagning: én gang + Saturate Øg mætning + Saturation Jitter Mætningsjitter + Save HTML and Images Gem HTML og billeder + Save as PDF Gem som PDF + Scale to Fit Media Skalér til medie + Scatter Spredning + Scratch disks Arbejdsdiske + Selective Selektiv + Shadows Skygger + Shape: Form: + Sharpening Skærpning + Shift Shift + Show All Menu Items Vis alle menupunkter + Show Backdrop Vis baggrund + Show Mask Vis maske + Show Mesh Vis net + Show or hide the Notes panel Vis eller skjul panelet Noter + Show the layer mask as an overlay Vis lagmasken som en overlejring + Show: Vis: + Sides Sider + Sides: Sider: + Slice Options… Udsnitsindstillinger… + Slices From Guides Udsnit fra hjælpelinjer + Slices are locked (View › Lock Slices) Udsnit er låst (Vis › Lås udsnit) + Split: Opdel: + Star ratio Stjerneforhold + Std Dev: Std.afv.: + Straighten Ret ud + Straighten Layer Ret lag ud + Strength: Styrke: + Stroke Kontur + Stroke Catch-up Strøgindhentning + Stroke: Kontur: + Location: Placering: + Inside Indvendig + Outside Udvendig + Tetrahedral Tetraedrisk + Texture Each Tip Tekstur pr. spids +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradienteditor + Gradient picker Gradientvælger + Save As Preset Gem som forudindstilling + The gradient the Gradient tool paints with Den gradient, som gradientværktøjet maler med + Adds the current gradient to the Gradients panel Føjer den aktuelle gradient til gradientpanelet + Gradient stops Gradientstop + Click to edit the gradient Klik for at redigere gradienten + Tilt X Hældning X + Tilt Y Hældning Y + Toggle count group visibility Slå synlighed for optællingsgruppe til/fra + Toggle text orientation Skift tekstretning + Tone: Tone: + Toolbar Værktøjslinje + Top Øverst + Total: I alt: + Transparency Gennemsigtighed + Trilinear Trilineær + Unlink the mask from the layer Fjern sammenkædningen mellem masken og laget + View Options Visningsindstillinger + Violet, Orange Lilla, orange + Visible Synlig + Weight Vægt + Weight: Vægt: + Wetness Jitter Vådhedsjitter + Whites Hvide + Yellow Gul + Yellow · Blue Gul · Blå + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe rgb + Allow agent control Tillad agentstyring + Allow scripts to connect Tillad scripts at oprette forbindelse + Always Altid + Always create smart objects when placing Opret altid smartobjekter ved placering + Animated zoom Animeret zoom + Apply auto tone Anvend automatisk tone + Ask Spørg + Ask before saving layered tiff Spørg, før TIFF med lag gemmes + Autosave Gem automatisk + Autosave minutes Minutter mellem automatisk gemning + Beep when done Bip ved fuldførelse + Bicubic automatic Bikubisk automatisk + Bicubic sharper Bikubisk skarpere + Bicubic smoother Bikubisk glattere + Bit depth Bitdybde + Brush picker Penselvælger + Brush preview color Forhåndsvisningsfarve for pensel + CPU (no GPU acceleration) CPU (ingen GPU-acceleration) + Cache levels Cacheniveauer + Cache tile size Flisestørrelse for cache + Camera Raw Defaults Camera Raw-standarder + Canvas border Lærredskant + 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 + Canvas color Lærredsfarve + Canvas custom color Brugerdefineret lærredsfarve + Classic Klassisk + Color picker Farvevælger + Color space Farverum + Column width Kolonnebredde + Concise Kortfattet + Control port Kontrolport + Convert to srgb Konvertér til srgb + Cursors Markører + Custom dark Brugerdefineret mørk + Custom light Brugerdefineret lys + Dark Mørk + Dark gray Mørkegrå + Dashed lines Stiplede linjer + Detailed Detaljeret + DirectX 12 DirectX 12 + Disks Diske + Display p3 Display p3 + Dots Prikker + Double click layer mask launches select and mask Dobbeltklik på lagmaske åbner Markér og maskér + Drop shadow Slagskygge + Dynamic color sliders Dynamiske farveskydere + East asian Østasiatisk + Effect cache mb Effektcache mb + Enable flick panning Aktivér panorering med svirp + Enable floating document window docking Aktivér dokning af flydende dokumentvinduer + Enable narrow options bar Aktivér smal indstillingslinje + Enhanced Controls Udvidede kontrolelementer + Erase Visk ud + Export clipboard Eksportér udklipsholder + Extra large Ekstra stor + File Handling Filhåndtering + File path Filsti + Fill new type layers with placeholder Udfyld nye tekstlag med pladsholder + Font preview Skrifttypeforhåndsvisning + Full size tip Spids i fuld størrelse + GPU backend GPU-backend + Linux display server Linux-visningsserver + X11 X11 + Gamut warning color Farve til gamutadvarsel + Gamut warning opacity Opacitet for gamutadvarsel + General Generelt + Enable Generator Aktivér Generator + Gif Gif + Glossy Blank + Grid color Gitterfarve + Grid colors Gitterfarver + Grid size Gitterstørrelse + Grid style Gitterstil + Grid unit Gitterenhed + Gridline every Gitterlinje hver + Guide color Hjælpelinjefarve + Guide style Hjælpelinjestil + Guides, Grid & Slices Hjælpelinjer, gitter og udsnit + Gutter Spaltemellemrum + History Log Historiklog + History states Historiktilstande + Ignore exif profile tag Ignorér exif-profiltag + Image interpolation Billedinterpolation + Image previews Billedforhåndsvisninger + Integrations Integrationer + Interface Grænseflade + Jpeg quality Jpeg-kvalitet + Jpg Jpg + Large tabs Store faner + Legacy compositing Ældre sammensætning + Light gray Lysegrå + Line Linje + Lines Linjer + Lowercase extension Filendelse med små bogstaver + Maximize psd compatibility Maksimér psd-kompatibilitet + Medium gray Mellemgrå + Memory usage mb Hukommelsesforbrug mb + Low resolution previews Forhåndsvisninger i lav opløsning + Metal Metal + Missing glyph protection Beskyttelse mod manglende glyffer + Modern Moderne + Nearest neighbor Nærmeste nabo + Never Aldrig + Normal tip Normal spids + Open as smart object Åbn som smartobjekt + Open in camera raw Åbn i Camera Raw + Open documents as tabs Åbn dokumenter som faner + OpenGL OpenGL + Orange Orange + Overscroll Rul forbi kanten + Performance Ydeevne + Png Png + Point size Punktstørrelse + PostScript (72 points/inch) PostScript (72 punkter/tomme) + Precise Præcis + Preserve details Bevar detaljer + Print resolution Udskriftsopløsning + Pro Pro + Pro light Pro lys + Pro light gray Pro lysegrå + Pro medium Pro mellem + Pro photo Pro photo + Purple Lilla + Quick export format Format til hurtig eksport + Quick export location Placering for hurtig eksport + Recent file count Antal seneste filer + LUT folder LUT-mappe + Recent files Seneste filer + Recent fonts Seneste skrifttyper + Recover on launch Gendan ved opstart + Remember workspace changes Husk ændringer i arbejdsområde + Resize image during place Ændr billedstørrelse ved placering + Right click with painting tools Højreklik med maleværktøjer + Rotate view with trackpad Rotér visning med pegeplade + Same folder Samme mappe + Save in background Gem i baggrunden + Scratch Disks Arbejdsdiske + Screen resolution Skærmopløsning + Scrubby slider acceleration Acceleration af skydere ved træk + Sessions only Kun sessionen + Sharpen for Skærp til + Show channels in color Vis kanaler i farve + Show crosshair in brush tip Vis trådkors i penselspids + Show extension panels Vis udvidelsespaneler + Show font names in english Vis skrifttypenavne på engelsk + Show menu colors Vis menufarver + 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 + Show transformation values Vis transformeringsværdier + Slice color Udsnitsfarve + Smart guide color Farve til smarte hjælpelinjer + Smart quotes Smarte anførselstegn + Snap vector tools and transforms to pixel grid Fastgør vektorværktøjer og transformeringer til pixelgitter + Srgb Srgb + Standard Standard + Studio Studio + Studio light Studio lys + Adwaita Adwaita + Adwaita dark Adwaita mørk + Subdivisions Underopdelinger + System System + Text engine Tekstmotor + Text file Tekstfil + Tiny Meget lille + Touch gestures Berøringsbevægelser + Traditional (72.27 points/inch) Traditionel (72,27 punkter/tomme) + Transparency & Gamut Gennemsigtighed og gamut + Type units Tekstenheder + UI font size UI-skriftstørrelse + UI scale UI-skala + Units & Rulers Enheder og linealer + Use additional plugins folder Brug ekstra plug-in-mappe + Use esc to commit Brug Esc til at bekræfte + Use gpu Brug GPU + Use legacy free transform Brug ældre fri transformering + Use shift key for tool switch Brug Shift-tasten til at skifte værktøj + Use tablet pressure Brug tablettryk + Vary round brush hardness on hud Variér hårdhed for rund pensel i HUD + Vulkan Vulkan + Webp Webp + World ready World ready + Zoom clicked point to center Zoom, så det klikkede punkt kommer i midten + Zoom resizes windows Zoom ændrer vinduesstørrelse + Zoom with scroll wheel Zoom med rullehjul + Zoom with trackpad pinch Zoom med knibning på pegeplade + 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} + 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? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Hvis du fortsætter, kan du ved at gemme oven i originalen miste data, der ikke kunne læses, for altid. Gem en kopi for at bevare originalen. + Continue Fortsæt + The document “{name}” does not have an embedded {mode} profile. Dokumentet »{name}« har ikke en indlejret {mode}-profil. + 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} + Esc to cancel Esc for at annullere + Opened {name} Åbnede {name} + Opening {name} Åbner {name} + Saved {name} Gemte {name} + 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 + Opening {name}… Åbner {name}… + Opening… Åbner… + Reading… Læser… + Working… Arbejder… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Gitter: +# Rendering mode and recovery + Rendering Mode Gengivelsestilstand + Automatic (recommended) Automatisk (anbefalet) + CPU / Compatibility CPU / kompatibilitet + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatisk bruger GPU-acceleration, når det er muligt, og falder tilbage til CPU-gengivelse ved fejl. + Switched to CPU rendering Skiftede til CPU-gengivelse + GPU acceleration could not continue. Your documents are unchanged. GPU-accelerationen kunne ikke fortsætte. Dine dokumenter er uændrede. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft bruger CPU-billedsammensætteren. Vinduet bruger muligvis stadig din grafikadapter. + Retry GPU requires a restart. Save your work first. Genstart kræves for at prøve GPU igen. Gem dit arbejde først. + Retry GPU tries the graphics processor again now. Prøv GPU igen forsøger at bruge grafikprocessoren igen nu. + GPU acceleration resumed. GPU-accelerationen er genoptaget. + Keep Using CPU Fortsæt med CPU + Retry GPU Prøv GPU igen + GPU Backend GPU-backend + Details Detaljer + GPU GPU + Rendering mode Gengivelsestilstand + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Fryselasso: træk for at fryse et område, Alt-træk for at tø det op (L) + Shadow Clipping Warning (U) Advarsel om skyggeklipning (U) + Highlight Clipping Warning (O) Advarsel om højlysklipning (O) + Show Lab Color Readouts Vis Lab-farveaflæsninger + Show Vectorscope Vis vektorskop + Show Red at 3 o'clock Vis rødt kl. 3 + Show Skin Tone Indicator Vis indikator for hudtone + Tone Histogram Tonehistogram + Show Selected Region Vis markeret område +cameraRaw Light Lys +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Turkis +cameraRaw Amount Mængde +cameraRaw Blacks Sorte +cameraRaw Vibrance Livskraft +cameraRaw Color Grading Farvegradering +cameraRaw Lights Lyse +cameraRaw Darks Mørke +cameraRaw Global Global +cameraRaw Red Rød +cameraRaw Orange Orange +cameraRaw Yellow Gul +cameraRaw Green Grøn +cameraRaw Aqua Turkis +cameraRaw Blue Blå +cameraRaw Purple Lilla +cameraRaw Magenta Magenta +cameraRaw Hue Farvetone +cameraRaw Blending Blanding +cameraRaw Luminance Detail Luminansdetaljer +cameraRaw Color Detail Farvedetaljer +cameraRaw Roughness Ruhed + Patch Tool Lappeværktøj + Patch: Lap: + Lasso around an area, then drag the selection Tegn med lassoen rundt om et område, og træk derefter markeringen + Transform On Drop Transformér ved slip + Scale or rotate the moved area, then press {key} Skalér eller rotér det flyttede område, og tryk derefter på {key} + Now drag the selection onto another area Træk nu markeringen hen til et andet område + Edit Type Redigér tekst + + New Style… Ny stil… + Style {name} saved Stilen {name} er gemt + + Create new style (from the selected type) Opret ny stil (fra den markerede tekst) + Create new style from the selected layer Opret ny stil ud fra det markerede lag + + Make Default Gør til standard + Reset to Default Nulstil til standard + Full-resolution preview unavailable Forhåndsvisning i fuld opløsning er ikke tilgængelig + No patterns Ingen mønstre + Enable {name} Aktivér {name} + Hold Alt while drawing for straight segments Hold Alt nede, mens du tegner, for at få lige segmenter + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Træk eller klik polygonpunkter · hold Alt nede under lassoering for lige segmenter · {add} tilføj · {sub} før du tegner trækker fra + Extend Udvid + Structure: Struktur: + Content-Aware Move Tool Indholdsbevidst flytteværktøj + TIFF Options TIFF-indstillinger + Targa Options Targa-indstillinger + JPEG Options JPEG-indstillinger + Image Options Billedindstillinger + Format Options Formatindstillinger + Baseline ("Standard") Grundlæggende ("Standard") + Baseline Optimized Grundlæggende optimeret + 24 bits/pixel 24 bit/pixel + 32 bits/pixel 32 bit/pixel + Including layers will increase file size. Medtagning af lag øger filstørrelsen. + 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 + Couldn't place {name}: {error} Kunne ikke placere {name}: {error} + Magnetic Lasso Tool Magnetisk lassoværktøj + Frequency Frekvens + Use tablet pressure to change pen width Brug tablettryk til at ændre penbredden + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About Om + Contributors Bidragydere + Models Modeller + Sort Sortér + Username Brugernavn + Display name Visningsnavn + Real name Rigtigt navn + Reverse the order Vend rækkefølgen om + Grab bag Lidt af hvert + Table Tabel + Name (A–Z) Navn (A–Z) + Merged PRs Flettede PR'er + Commits Commits + Lines added Tilføjede linjer + Lines deleted Slettede linjer + Line delta Linjedelta + Binary assets added Tilføjede binære filer + Binary assets removed Fjernede binære filer + First commit Første commit + First Første + Last commit Seneste commit + Last Seneste + Contributors: {contributors} · Commits: {commits} Bidragydere: {contributors} · Commits: {commits} + No contributor data was built into this copy. Der blev ikke bygget bidragyderdata ind i denne kopi. + No model credits were built into this copy. Der blev ikke bygget modelkreditter ind i denne kopi. + Company Virksomhed + Version Version + % of all commits % af alle commits + Lines +/− Linjer +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR'er, {commits} commits, +{added} / −{deleted} linjer (Δ {delta}), +{binAdded} / −{binDeleted} binære filer, {first} – {last} + +# Swatches panel + Create new swatch Opret ny farveprøve + New Swatch… Ny farveprøve… + New Swatch Group… Ny farveprøvegruppe… + Import Swatches… Importér farveprøver… + Replace Swatches… Erstat farveprøver… + Export Swatches… Eksportér farveprøver… + Export Swatches for Exchange… Eksportér farveprøver til udveksling… + Reset Swatches Nulstil farveprøver + Add to Swatches Føj til farveprøver +# Layer Style > Blending Options (Advanced Blending) + Blend If: Bland hvis: + Channels: Kanaler: + Knockout Udstansning + Apply At Canvas Bounds Anvend ved lærredets grænser + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Farvevælger (tekstfarve) + Color Picker ({name}) Farvevælger ({name}) + Color Picker (Fill Color) Farvevælger (udfyldningsfarve) + Fill color Udfyldningsfarve + Color Picker (Stroke Color) Farvevælger (konturfarve) + Stroke color Konturfarve + Pick screen color Vælg skærmfarve + Sample a pixel anywhere on the screen Tag en prøve af en pixel hvor som helst på skærmen + Click to pick · Esc to cancel Klik for at vælge · Esc for at annullere + Pick a screen pixel or press Esc to cancel Vælg en pixel på skærmen, eller tryk på Esc for at annullere + Scroll to zoom Rul for at zoome + Drag to pan · Scroll to zoom · Double-click to fit Træk for at panorere · Rul for at zoome · Dobbeltklik for at tilpasse + Show/Hide All Panels Vis/skjul alle paneler + Show/Hide Panels Vis/skjul paneler + Could not open Kunne ikke åbne + {path}: the path is not valid Unicode; rename the file and open it again. {path}: stien er ikke gyldig Unicode; omdøb filen, og åbn den igen. + +# Fill dialog, layer style contours and Image Size + Contents: Indhold: + Custom Pattern: Brugerdefineret mønster: + Foreground Color Forgrundsfarve + Color… Farve… + 50% Gray 50 % grå + {size} (was {old}) {size} (var {old}) + Cone Kegle + Cone (Inverted) Kegle (omvendt) + Domed Kuppelformet + Domed (Inverted) Kuppelformet (omvendt) + Diagonal (Descending) Diagonal (faldende) + Contour Editor Kontureditor + Anti-aliased Antialiaseret + Corner Hjørne + Gloss Contour Glanskontur + Contour Kontur + Cone - Inverted Kegle - omvendt + Cove - Deep Bugt - dyb + Cove - Shallow Bugt - lav + Gaussian Gaussisk + Half Round Halvrund + Ring Ring + Ring - Double Ring - dobbelt + Rolling Slope - Descending Bølgende skråning - faldende + Rounded Steps Afrundede trin + Sawtooth 1 Savtand 1 + Linear Lineær + + Red Eye Tool Værktøj til røde øjne + Pupil Size Pupilstørrelse + Darken Amount Mørkningsgrad + Click a red pupil to neutralize it Klik på en rød pupil for at neutralisere den + open source open source + …and {n} more …og {n} flere + + Eyedroppers: Pipetter: + Set Black Point Angiv sortpunkt + Set Neutral Gray Point Angiv neutralt gråpunkt + Set White Point Angiv hvidpunkt + Unavailable in this color mode Ikke tilgængelig i denne farvetilstand + Selection Preview: Forhåndsvisning af markering: + Black Matte Sort mat + White Matte Hvid mat + Quick Mask Hurtig maske + +# Layer colour labels. + Seafoam Havskum + Indigo Indigo + Fuchsia Fuchsia + Violet Violet + Large document: redrawing on the CPU Stort dokument: gentegner på CPU'en + Redrawing Gentegner + This redraw took {seconds} s on the CPU. Denne gentegning tog {seconds} s på CPU'en. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Redigeringer, der ændrer hele dokumentet, f.eks. et lags blandingsstilstand, opacitet eller synlighed, gentegner det hele. + The GPU compositor wasn't used: {reason} GPU-sammensætteren blev ikke brugt: {reason} +fontWeight Thin Tynd +fontWeight ExtraLight Ekstra let +fontWeight Light Let +fontWeight Regular Normal +fontWeight Medium Medium +fontWeight SemiBold Halvfed +fontWeight Bold Fed +fontWeight ExtraBold Ekstra fed +fontWeight Black Sort +fontWeight Italic Kursiv + +# Pattern Stamp tool + Pattern Stamp Tool Mønsterstempelværktøj + 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° + +# Painting symmetry presets. + Dual Axis Dobbelt akse + Diagonal Diagonal + Transform Symmetry Transformeringssymmetri + Set painting symmetry options Angiv indstillinger for symmetri ved maling + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprøve + 3 by 3 Average 3 x 3 gennemsnit + 5 by 5 Average 5 x 5 gennemsnit + 11 by 11 Average 11 x 11 gennemsnit + 31 by 31 Average 31 x 31 gennemsnit + 51 by 51 Average 51 x 51 gennemsnit + 101 by 101 Average 101 x 101 gennemsnit + Sample Size: Prøvestørrelse: + 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 + +# 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 + Color to Alpha… Farve til alfa… + Transparency Threshold Grænseværdi for gennemsigtighed + Opacity Threshold Grænseværdi for opacitet + PhotoCraft — Screen Color Picker PhotoCraft — Skærmfarvevælger + + Rasterize svg groups deeper than Rasterisér svg-grupper dybere end + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 deaktiverer denne grænseværdi; SVG-grupper, der er indlejret dybere end 100 niveauer, rasteriseres altid. + Rule of Thirds Tredjedelsregel + Triangle Trekant + Golden Ratio Gyldne snit + Golden Spiral Gylden spiral + Auto Show Overlay Vis overlejring automatisk + Always Show Overlay Vis altid overlejring + Never Show Overlay Vis aldrig overlejring + Cycle Overlay Skift mellem overlejringer + Cycle Orientation Skift orientering + Set the overlay options for the Crop tool Angiv overlejringsindstillingerne for beskæringsværktøjet + 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 gik til skrivebordet, som flytter vinduer med Alt-træk. Sæt tasten til vinduesflytning til Super, eller lås Alt fast i Vindue › Modifikationstaster. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Farvevælger (lagstilsfarve) + Auto Hide Notifications Skjul meddelelser automatisk + Notification Duration (seconds) Varighed af meddelelser (sekunder) + Bundle Arrow-Key Nudges Saml piletasteskub + Nudge Bundle Pause (ms) Pause mellem piletasteskub (ms) + Appearance Mode Udseendetilstand + Dark Theme Mørkt tema + Light Theme Lyst tema + Pro Medium Pro Medium + Studio Light Studio Light + Next Appearance Mode Næste udseendetilstand + Sync with system Synkronisér med systemet + Active Aktiv + Color Picker (Solid Color) Farvevælger (ensfarvet) + Straighten the image by drawing a line on it Ret billedet ud ved at tegne en linje på det + Set additional Crop options Angiv flere beskæringsindstillinger + Show Cropped Area Vis beskåret område + Enable Crop Shield Aktivér beskæringsafskærmning + Match Canvas Afstem med lærred + Auto Adjust Opacity Justér opacitet automatisk + Crop Shield color Farve til beskæringsafskærmning + Color Picker (Crop Shield Color) Farvevælger (farve til beskæringsafskærmning) + From Center Fra midten + Link radii Sammenkæd radier + Star Ratio: Stjerneforhold: + Create Rectangle Opret rektangel + Create Ellipse Opret ellipse + Create Triangle Opret trekant + Create Polygon Opret polygon + Pressure curve Trykkurve + Click to add a point, right-click to remove Klik for at tilføje et punkt, højreklik for at fjerne + Soft Blød + Hard Hård + Some brush presets could not be loaded Nogle penselforudindstillinger kunne ikke indlæses + Retry saving presets Prøv at gemme forudindstillinger igen + Brush preset changes are not saved: {error} Ændringer i penselforudindstillinger gemmes ikke: {error} + Keep this tab open to retain your brushes. Hold denne fane åben for at bevare dine pensler. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Browserlager er utilgængeligt; penslerne gælder kun for sessionen. Hold denne fane åben for at bevare dem. + Saving brush presets… Keep this tab open until saving finishes. Gemmer penselforudindstillinger… Hold denne fane åben, indtil gemningen er afsluttet. + Reflect Refleksion + Glow Glød + Negation Negation + XOR XOR + Color Burn (Paint.NET) Farvebrænding (Paint.NET) + Color Dodge (Paint.NET) Farvelysning (Paint.NET) + Stroke Options Konturindstillinger + Solid Ubrudt + Dashed Stiplet + Dotted Prikket + Caps Linjeender + Butt / Flat Stump / Flad + Corners Hjørner + Miter Spids + Round Rund + Bevel Affaset + Miter limit Spidsgrænse + Dashed line Stiplet linje + Dash Streg + Gap Mellemrum + Dash / gap lengths × stroke width Længder for streg/mellemrum × konturbredde + Add pair Tilføj par + Remove pair Fjern par + Dash offset Forskydning af stregmønster + Open paths use centered strokes Åbne stier bruger centreret kontur + Use finite lengths and a positive pattern total Brug endelige længder og en positiv samlet mønsterværdi + No subject found — try Object Selection or the Lasso Fandt intet motiv — prøv Objektmarkering eller lassoen + Quick Selection found nothing there Hurtig markering fandt intet der + Object Selection found nothing there — try a tighter rectangle Objektmarkering fandt intet der — prøv et strammere rektangel + No subject found — try a rectangle around the object Fandt intet motiv — prøv et rektangel rundt om objektet + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Slet lag »{name}«? + Delete {count} layers? {names} Slet {count} lag? {names} + Delete layer? Slet lag? + The document is closed Dokumentet er lukket + The document or selected layers changed. Please request deletion again. Dokumentet eller de markerede lag er ændret. Prøv at slette igen. + Missing delete command Slettekommando mangler + Invalid delete command Ugyldig slettekommando + Layer deleted, but confirmation preference could not be saved: {error} Lag slettet, men bekræftelsesindstillingen kunne ikke gemmes: {error} + Add a layer style Tilføj en lagstil + Add a mask (from the selection; {key} inverts) Tilføj en maske (fra markeringen; {key} inverterer) + Add an adjustment Tilføj en justering + Add to selection ({key}) Tilføj til markering ({key}) + Adjustment visible Justering synlig + Align and distribute Justér og fordel + Align horizontal centers Justér vandrette midtpunkter + Align left edges Justér venstre kanter + Align right edges Justér højre kanter + Always use pressure for opacity Brug altid tryk til opacitet + Always use pressure for size Brug altid tryk til størrelse + Angle Gradient Vinkelgradient + Auto-Select: Markér automatisk: + Brush Preset picker Penselforudindstillingsvælger + Cancel current crop operation (Esc) Annullér aktuel beskæringshandling (Esc) + Click the first point or press {key} to close · Esc cancels Klik på det første punkt, eller tryk på {key} for at lukke · Esc annullerer + Click the first point or press {key} to close · {del} removes a point · Esc cancels Klik på det første punkt, eller tryk på {key} for at lukke · {del} fjerner et punkt · Esc annullerer + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klik, og bevæg dig derefter langs en kant · klik for at fastgøre et punkt · {alt}-klik for et lige segment · dobbeltklik for at lukke + Clip to layer below Klip til laget nedenunder + Color & Swatches Farve og farveprøver + Commit current crop operation ({key}) Anvend aktuel beskæringshandling ({key}) + Create a new group Opret en ny gruppe + Create a new layer Opret et nyt lag + Create new document from current state Opret nyt dokument fra den aktuelle tilstand + Create new fill or adjustment layer Opret nyt udfyldnings- eller justeringslag + Create new snapshot Opret nyt øjebliksbillede + From: Fra: + Full Document Hele dokumentet + Merged Layers Flettede lag + Delete Cropped Pixels Slet beskårne pixels + Delete current state Slet aktuel tilstand + Delete layer Slet lag + Diamond Gradient Diamantgradient + Doc: {w} × {h} px Dok.: {w} × {h} px + Enable airbrush-style build-up effects Aktivér ophobningseffekter i airbrush-stil + Filter for adjustment layers Filtrér efter justeringslag + Filter for pixel layers Filtrér efter pixellag + Filter for shape layers Filtrér efter formlag + Filter for smart objects Filtrér efter smartobjekter + Filter for type layers Filtrér efter tekstlag + Fixed Ratio Fast forhold + Fixed Size Fast størrelse + Follows only edges this close to the pointer ([ and ] change it) Følger kun kanter, der er så tæt på markøren ([ og ] ændrer det) + Hide Properties Skjul egenskaber + Higher values follow only edges that contrast sharply with their surroundings Højere værdier følger kun kanter, der står i skarp kontrast til omgivelserne + How often fastening points are placed by themselves Hvor ofte fastgøringspunkter placeres af sig selv + Hue/Saturation Farvetone/mætning + Intersect with selection ({key}) Skær med markering ({key}) + Kind Type + Linear Gradient Lineær gradient + Link layers Sammenkæd lag + Lock Lås + Lock all Lås alt + Lock image pixels Lås billedpixels + Lock layer Lås lag + Lock position Lås placering + Lock transparent pixels Lås gennemsigtige pixels + New selection Ny markering + No properties Ingen egenskaber + Pressure for Opacity Tryk for opacitet + Pressure for Size Tryk for størrelse + Prevent auto-nesting in and out of Artboards and Frames Forhindr automatisk indlejring i og ud af tavler og rammer + Radial Gradient Radial gradient + Reflected Gradient Reflekteret gradient + Reset to defaults Nulstil til standard + Set additional smoothing options Angiv yderligere udjævningsindstillinger + Show Transform Controls Vis transformeringskontroller + Subtract from selection ({key}) Træk fra markering ({key}) + Swaps height and width Ombytter højde og bredde + Tolerance Tolerance + Add vector mask Tilføj vektormaske + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klik for at zoome ind · {key}-klik for at zoome ud · træk til højre/venstre for at zoome ind/ud + Appearance Settings… Indstillinger for udseende… + Add to favorites Føj til favoritter + Remove from favorites Fjern fra favoritter +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Mærke +cameraData Model Model +cameraData Date Taken Optagelsesdato +cameraData Exposure Eksponering +cameraData F-Stop Blænde +cameraData ISO Speed ISO-hastighed +cameraData Focal Length Brændvidde +cameraData Focal Length (35 mm) Brændvidde (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktivér panel + Change Panel Group Skift panelgruppe + Change Panel Layout Skift panellayout + Close Panel Luk panel + Dock Panel Dok panel + Float Panel Frigør panel + Move Panel Flyt panel + Group with Gruppér med + Panel menu Panelmenu + Resize panel window Ændr størrelse på panelvindue + Resize panels Ændr størrelse på paneler + Use Classic Mode Brug klassisk tilstand + Auto Center Preview Centrér forhåndsvisning automatisk + Next Document Næste dokument + Previous Document Forrige dokument + W x H x Resolution B x H x opløsning + Front Image Forreste billede + Frame Tool Rammeværktøj + Create a new rectangular frame Opret en ny rektangulær ramme + Create a new elliptical frame Opret en ny elliptisk ramme + Include in playback Medtag i afspilning + Perspective Crop Tool Perspektivbeskæringsværktøj + Show Grid Vis gitter + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Træk en ramme, eller klik fire hjørner · træk hjørnerne hen på kanterne af en skæv form · {commit} bekræfter · Esc annullerer + Artboard Tool Tavleværktøj + Make Portrait Gør stående + Make Landscape Gør liggende + Add New Artboard Tilføj ny tavle + Freeform Pen Tool Frihåndspenværktøj + Curvature Pen Tool Krumningspenværktøj + Curve Fit: Kurvetilpasning: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Træk: tegn frihånd · {alt}-klik: lige segment · Slip på startpunktet: luk + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: glat punkt · Dobbeltklik: hjørne · Træk et punkt: flyt · Klik på det første punkt: luk · {key} afslut + Scale: Skalering: + Snap to Origin Fastgør til origo + Link with Layer Sammenkæd med lag + Rasterize Layer Style Rasterisér lagstil + Search settings… Søg i indstillinger… + No matching settings Ingen indstillinger passer +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Prøvetagningspenselværktøj + Sampling Area Overlay Overlejring af prøvetagningsområde + Show Sampling Area Vis prøvetagningsområde + Indicates Angiver + Sampling Area Prøvetagningsområde + Excluded Area Udeladt område + Sampling Area Options Indstillinger for prøvetagningsområde + Rectangular Rektangulær + Fill Settings Udfyldningsindstillinger + Rotation Adaptation Rotationstilpasning + Very High Meget høj + Full Fuld + Mirror Spejl + Output Settings Outputindstillinger + Output To Output til + Duplicate Layer Duplikér lag + Contract Formindsk + + Animation Options Animationsindstillinger + Entire Document Hele dokumentet + Filter Options Filterindstillinger + Layer Bounds Laggrænser + Layers Panel Options Indstillinger for panelet Lag + Lock Layers Lås lag + New Artboard… Ny tavle… + New Group… Ny gruppe… + Prevent Auto-Nesting Forhindr automatisk indlejring + Show Layers Vis lag + Thumbnail Contents Miniatureindhold + Thumbnail Size Miniaturestørrelse + Use Default Masks on Fill Layers Brug standardmasker på udfyldningslag + Expand New Effects Udvid nye effekter + Add “copy” to Copied Layers and Groups Tilføj »kopi« til kopierede lag og grupper + Preview on canvas when reordering layers Forhåndsvis på lærredet, når lagenes rækkefølge ændres + Show layer mask badges Vis mærker for lagmasker + +# Smart Filter editors + Blending Options ({filter}) Blandingsindstillinger ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Skjul lagets effekter og smartfiltre ({key}-klik: alle lag) + Show the layer's effects and Smart Filters ({key}-click: all layers) Vis lagets effekter og smartfiltre ({key}-klik: alle lag) + Wait for the current job to finish. Vent, til det aktuelle job er færdigt. + The document or Smart Filter changed. Reopen its dialog. Dokumentet eller smartfilteret er ændret. Åbn dialogen igen. + Opacity must be between 0% and 100%. Opaciteten skal være mellem 0 % og 100 %. + Dissolve is approximated: it blends without Photoshop's dither. Opløst er tilnærmet: det blander uden Photoshops dither. + Loading fonts… Indlæser skrifttyper… + Response curve Responskurve + Tool Værktøj + Simulate Pressure Simulér tryk + Stroke Path… Lav kontur på sti… + Background Eraser Baggrundsviskelæder + Healing Brush Reparationspensel + Spot Healing Brush Pletreparationspensel + Smudge Udtvær + Sponge Svamp + Amount: Mængde: + Protect: Beskyt: + Protect skin tones Beskyt hudtoner + Edit points to modify the curve Redigér punkter for at ændre kurven + Draw to modify the curve Tegn for at ændre kurven +curves Smooth Udjævn + Smooth the curve values Udjævn kurveværdierne + Drag to draw the curve · {key}-click draws a straight line Træk for at tegne kurven · {key}-klik tegner en lige linje + Grid Size: Gitterstørrelse: + Vanishing Point: click four corners to create a plane Forsvindingspunkt: klik på fire hjørner for at oprette et plan + Vanishing Point is not active Forsvindingspunktet er ikke aktivt + Apply Vanishing Point ({key}) Anvend forsvindingspunkt ({key}) + Fix the red plane first: its corners must form a convex quad Ret det røde plan først: dets hjørner skal danne en konveks firkant + Alt-click a plane to set the clone source Alt-klik på et plan for at angive kloningskilden + Too many clone strokes: apply Vanishing Point first For mange kloningsstrøg: anvend forsvindingspunkt først +# Select and Mask view modes (#1754) + Onion Skin Løgskind + Marching Ants Marcherende myrer + On Black På sort + On White På hvidt + On Layers På lag + Show Edge Vis kant + Show Original Vis original + F cycles the views; hold X to see the image F skifter mellem visningerne; hold X nede for at se billedet +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Kantforfiningspensel (R) + Expand Detection Area Udvid detektionsområde + Restore Original Edge Gendan oprindelig kant + Paint over hair or fur on the image; Alt paints the other mode Mal over hår eller pels på billedet; Alt maler i den anden tilstand + Clear Brush Strokes Ryd penselstrøg + Show rulers Vis linealer + New Crop Preset… Ny beskæringsforudindstilling… + Delete Crop Preset… Slet beskæringsforudindstilling… + Select the crop presets to delete: Vælg de beskæringsforudindstillinger, der skal slettes: + Select the crop presets to delete Vælg de beskæringsforudindstillinger, der skal slettes + Enter a name for the crop preset Angiv et navn til beskæringsforudindstillingen + There is no crop size or ratio to save Der er ingen beskæringsstørrelse eller -forhold at gemme + Presentation Præsentation + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klik for at placere · Alt-klik eller træk uden for lærredet for at fjerne + No document open Intet åbent dokument + {n} of {max} sample points {n} af {max} prøvepunkter + +# History panel menu (#2966) + New Snapshot… Nyt øjebliksbillede… + History Options… Historikindstillinger… +# History panel › History Options… (#2966) + Allow Non-Linear History Tillad ikke-lineær historik + Make Layer Visibility Changes Undoable Gør ændringer af lagsynlighed fortrydelige +# Print dialog (#2697) + Emulsion Down Emulsion nedad + Functions Funktioner + Print Resolution: Udskriftsopløsning: + Use Previous Layer to Create Clipping Mask Brug forrige lag til at oprette udklipningsmaske + Fill with {mode}-neutral color (white) Udfyld med {mode}-neutral farve (hvid) + Fill with {mode}-neutral color (black) Udfyld med {mode}-neutral farve (sort) + Fill with {mode}-neutral color (50% gray) Udfyld med {mode}-neutral farve (50 % grå) + (No neutral color exists for {mode} mode.) (Der findes ingen neutral farve til {mode}-tilstanden.) + Apply mask to layer before removing? Anvend masken på laget, før den fjernes? + The layer or its mask changed. Drag the mask onto the Trash again. Laget eller dets maske er ændret. Træk masken hen på papirkurven igen. + Delete the vector mask? Slet vektormasken? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Eksporterede {n} farveprøve til {file}|Eksporterede {n} farveprøver til {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importerede {n} pensel fra {group}|Importerede {n} pensler fra {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importerede {n} farveprøve fra {file}|Importerede {n} farveprøver fra {file} +@plural Played {n} step|Played {n} steps Afspillede {n} trin|Afspillede {n} trin +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Opdaterede {n} sammenkædet smartobjekt, der er ændret på disken|Opdaterede {n} sammenkædede smartobjekter, der er ændret på disken +@plural Saved for Web: {n} file|Saved for Web: {n} files Gemt til web: {n} fil|Gemt til web: {n} filer +@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} lag, der er registreret af denne komposition, blev slettet. Opdatér kompositionen, eller fjern advarslen.|{n} lag, der er registreret af denne komposition, blev slettet. Opdatér kompositionen, eller fjern advarslen. +@plural {n} plane|{n} planes {n} plan|{n} planer +@plural {n} preset|{n} presets {n} forudindstilling|{n} forudindstillinger +@plural {n} sample point removed|{n} sample points removed {n} prøvepunkt fjernet|{n} prøvepunkter fjernet +@plural {n} slice|{n} slices {n} udsnit|{n} udsnit +@plural {n} step|{n} steps {n} trin|{n} trin +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} yderligere advarsel om penselforudindstillinger)|{warning} (+{n} yderligere advarsler om penselforudindstillinger) +@plural {what} with {n} warning|{what} with {n} warnings {what} med {n} advarsel|{what} med {n} advarsler +# View › Guides › New Guide… › Color + Light Blue Lyseblå + Light Red Lysrød + Medium Blue Mellemblå + Light Gray Lysegrå + Object Finder Objektfinder + No object found there — drag a rectangle around it Intet objekt fundet der — træk et rektangel omkring det + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Det aktive lag har ingen pixels her — slå Prøvetag alle lag til, eller markér et lag med pixels + Remove after each stroke Fjern efter hvert strøg + Apply the removal ({key}) Anvend fjernelsen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Strøg, der skal fjernes: {n} (Enter anvender, Esc annullerer) + Load Contours… Indlæs konturer… + Replace Contours… Erstat konturer… + Save Contours… Gem konturer… + Delete Contour Slet kontur + Loaded contours from {file} Indlæste konturer fra {file} + Saved contours to {file} Gemte konturer i {file} + Show home recent files Vis seneste filer på startsiden + Clear Brush Controls Ryd penselkontroller + Reset All Locked Settings Nulstil alle låste indstillinger + Copy Texture to Other Tools Kopiér tekstur til andre værktøjer + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Indlæs handlinger… + Save Actions… Gem handlinger… + New Action… Ny handling… + Start Recording Start optagelse + Stop Recording Stop optagelse + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Indlæste {n} handling fra {file}|Indlæste {n} handlinger fra {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} trin har ingen PhotoCraft-ækvivalent endnu|{n} trin har ingen PhotoCraft-ækvivalent endnu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Gemte {n} handling i {file}|Gemte {n} handlinger i {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} trin uden Photoshop-ækvivalent udeladt|{n} trin uden Photoshop-ækvivalent udeladt + + File Naming Filnavngivning + Starting serial Startserienummer +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Indsæt stop… + Save Set… Gem sæt… + Message: Meddelelse: + Allow Continue Tillad fortsættelse + Show dialog during playback Vis dialog under afspilning + Select an action first Vælg en handling først + Action playback stopped Afspilning af handlingen er stoppet + 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) + Content-Aware Fill Indholdsbevidst udfyldning +# Batch / Image Processor + Include All Subfolders Medtag alle undermapper + Auto Add/Delete Tilføj/fjern automatisk + +# 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… + 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 + Calibration Kalibrering + Red Primary Rød primærfarve + Green Primary Grøn primærfarve + Blue Primary Blå primærfarve + Write xmp sidecars Skriv xmp-sidefiler + Set the tone sliders from the image Indstil toneskyderne ud fra billedet + Settings from {name} Indstillinger fra {name} + Settings from {name}; {count} not supported: {names} Indstillinger fra {name}; {count} understøttes ikke: {names} + Could not write {name}: {error} Kunne ikke skrive {name}: {error} + Kept {name}: written by another application Beholdt {name}: skrevet af et andet program + Shadow Tint Skyggenuance +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maler ud fra kildetilstanden i panelet Historik + Sets the source for the history brush Angiver kilden til historikpenslen + +# Dynamic selection context menu and appearance favorites + Select Inverse Markér omvendt + Pro (Light) Pro (lys) + Pro (Light Gray) Pro (lysegrå) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Etiket + Date and Time Dato og klokkeslæt + Scale Units Skalaenheder + Scale Factor Skaleringsfaktor + Area Område + Perimeter Omkreds + Circularity Cirkularitet + Gray Value (Minimum) Gråværdi (minimum) + Gray Value (Maximum) Gråværdi (maksimum) + Gray Value (Mean) Gråværdi (gennemsnit) + Gray Value (Median) Gråværdi (median) + Integrated Density Integreret tæthed + Length Længde + Show Favorite Fonts Vis foretrukne skrifttyper + No favorite fonts yet: click the star beside a font to add it. Ingen foretrukne skrifttyper endnu: klik på stjernen ud for en skrifttype for at tilføje den. + Favorite fonts Foretrukne skrifttyper + Add to Favorites Føj til foretrukne + Remove from Favorites Fjern fra foretrukne + Show All Fonts Vis alle skrifttyper + All Classes Alle klasser + Blackletter Fraktur + Decorative Dekorativ + Font preview: Skrifttypeeksempel: + Handwritten Håndskrevet + Hide Font Classes Skjul skrifttypeklasser + Hide Styles Skjul snit + Monospace Fast bredde + No fonts match these filters. Ingen skrifttyper passer til disse filtre. + No similar fonts: the current font doesn't say what it looks like. Ingen lignende skrifttyper: den aktuelle skrifttype oplyser ikke, hvordan den ser ud. + OpenType font OpenType-skrifttype + Sans Serif Sans serif + Script Skriveskrift + Serif Serif + Show Font Classes Vis skrifttypeklasser + Show Similar Fonts Vis lignende skrifttyper + Show Styles Vis snit + Show Variable Fonts Vis variable skrifttyper + Slab Serif Egyptienne + TrueType font TrueType-skrifttype + Variable font Variabel skrifttype +# Bevel & Emboss shading controls. + Highlight Mode Højlystilstand + Highlight Color Højlysfarve + Highlight Opacity Højlysopacitet + Shadow Mode Skyggetilstand + Shadow Color Skyggefarve + Shadow Opacity Skyggeopacitet +# PhotoCraft release checks. + Check for Updates… Søg efter opdateringer… + PhotoCraft Updates PhotoCraft-opdateringer + Checking for updates… Søger efter opdateringer… + Open the release page to choose an installer for your system. Åbn udgivelsessiden for at vælge et installationsprogram til dit system. + Open Download Page Åbn downloadsiden + PhotoCraft is up to date. PhotoCraft er opdateret. + Could not check for updates. Kunne ikke søge efter opdateringer. + Updates have not been checked yet. Der er endnu ikke søgt efter opdateringer. + Check Again Søg igen + Automatically Check for Updates Søg automatisk efter opdateringer + Installed version: {version} Installeret version: {version} + PhotoCraft {version} is available. PhotoCraft {version} er tilgængelig. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Brug den indlejrede profil ({profile}) + Convert to the working space ({profile}) Konvertér til arbejdsfarverummet ({profile}) + Remember this choice for future images Husk dette valg til fremtidige billeder + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Slå meddelelsen til igen via Spørg ved uoverensstemmelse under Redigér › Farveindstillinger. + Button Mode Knaptilstand + Button Mode On Knaptilstand slået til + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Gør brugerfladen mørkere + Lighten Interface Gør brugerfladen lysere +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Hård mejsel + Chisel Soft Blød mejsel + Open color-less EXR as ACEScg Åbn farveløs EXR som ACEScg + Do not append "copy" to filename when saving a copy Tilføj ikke »kopi« til filnavnet, når der gemmes en kopi + +# View › Guides › right-click a guide + Edit Selected Guides Redigér markerede hjælpelinjer + Clear Selected Guides Ryd markerede hjælpelinjer + Bicubic Automatic Bikubisk automatisk + Bicubic Smoother Bikubisk glattere + Bicubic Sharper Bikubisk skarpere + Pasting from other apps is unavailable Indsættelse fra andre programmer er ikke tilgængelig + 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 kan ikke få adgang til systemets udklipsholder i denne session. På Wayland skjuler en Flatpak-sandkasse den protokol, som billeder fra andre programmer læses igennem. + To paste from other apps, start PhotoCraft under XWayland: `{command}` For at indsætte fra andre programmer skal du starte PhotoCraft under XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Intet at indsætte: PhotoCraft kan ikke læse systemets udklipsholder her + Delete this set Slet dette sæt + New Set… Nyt sæt… + PhotoCraft Defaults PhotoCraft-standarder + Save a list of these shortcuts as a web page Gem en liste over disse genveje som en webside + Save the changes to this set Gem ændringerne i dette sæt + Save these shortcuts as a .kys file Gem disse genveje som en .kys-fil + Save these shortcuts as a new set Gem disse genveje som et nyt sæt + Set deleted. Sættet er slettet. + Set saved. Sættet er gemt. + Set: Sæt: + Summarize… Opsummér… + There are too many sets: delete one first. Der er for mange sæt: slet ét først. + set name sætnavn + {set} (modified) {set} (ændret) + Random Tilfældig + Show file name in title Vis filnavn i titlen + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 % tærskel + Pattern Dither Mønsterdither + Diffusion Dither Diffusionsdither + Halftone Screen Halvtoneraster + Custom Pattern Brugerdefineret mønster + Frequency: Frekvens: + Lines/Inch Linjer/tomme + Lines/Centimeter Linjer/centimeter + Ellipse Ellipse + Diamond Diamant + Cross Kors + Accept and Go to Conflict Acceptér og gå til konflikten + Changes undone. Ændringerne er fortrudt. + Choose a new shortcut for this command, or leave it without one. Vælg en ny genvej til denne kommando, eller lad den være uden. + Put this command's shortcuts back as they were Gendan denne kommandos genveje, som de var + Take the shortcut from the other command and select it Tag genvejen fra den anden kommando, og vælg den + Undo Changes Fortryd ændringer + {shortcut} is reserved by the system and can't be assigned. {shortcut} er reserveret af systemet og kan ikke tildeles. + Low-Latency Drawing (vsync off, applies at next launch) Tegning med lav latenstid (vsync slået fra, anvendes ved næste start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Fri transformering anvendes stadig; prøv igen, når den er færdig. + The transform couldn't be applied. Transformeringen kunne ikke anvendes. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Dets lag kræver {needed} GPU-hukommelse; budgettet efterlader dem {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. Angiv GPU-hukommelse i Præferencer › Ydeevne til det, dit grafikkort kan rumme, højst 3/8 af dets hukommelse. Automatisk følger Hukommelsesforbrug inden for en fjerdedel af denne computers hukommelse og 3/8 af kortets og medregner ikke dokumentet på et kort med kendt hukommelse. Statuslinjen viser GPU-hukommelse fuld, når de ikke kan rummes. + GPU Memory Full GPU-hukommelse fuld + 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. Lagene kræver {needed} GPU-hukommelse og kan rumme {budget}, så redigeringer, der gentegner hele dokumentet, sker på CPU'en. Klik for at angive GPU-hukommelse. + GPU memory budget: {budget} GPU-hukommelsesbudget: {budget} + Graphics memory: {size} Grafikhukommelse: {size} + This document's layers: {needed} of {budget} for layers Dette dokuments lag: {needed} af {budget} til lag + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokuments lag kræver {needed}, men kan rumme {budget}: gentegninger af hele dokumentet bruger CPU'en + GPU Memory (MB, 0 = Automatic) GPU-hukommelse (MB, 0 = automatisk) + {path}: the path is not valid Unicode; save under another name. {path}: stien er ikke gyldig Unicode; gem under et andet navn. + Replace Erstat + {name} was changed by another app after it was opened or saved here. {name} blev ændret af et andet program, efter at den blev åbnet eller gemt her. + Replace it with this document, or cancel and use File › Save As to keep both. Erstat den med dette dokument, eller annullér, og brug Filer › Gem som for at beholde begge. + +# 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) (kommer snart) + (none) (ingen) + 32-bit Preview Options Indstillinger for 32-bit-forhåndsvisning + 3D… 3D… + A: A: + AI Integrations AI-integrationer + About {app} Om {app} + Accented Edges Accentuerede kanter + Action playback failed: {error} Afspilning af handlingen mislykkedes: {error} + Actual Pixels Faktiske pixels + Add Mask Tilføj maske + Add Noise Tilføj støj + Add a mask (from the selection; ⌥ inverts) Tilføj en maske (fra markeringen; ⌥ inverterer) + Add space after paragraph Tilføj mellemrum efter afsnit + Add space before paragraph Tilføj mellemrum før afsnit + Add to selection (⇧) Tilføj til markering (⇧) + Added swatch {name} Tilføjede farveprøven {name} + Adds grain to the soft edges of the brush tip. Tilføjer korn til penselspidsens bløde kanter. + Adjust Interval For Loops Justér interval for sløjfer + Adjustment Layers Justeringslag + Adjustment Properties Justeringsegenskaber + Adjustment layers have nothing to transform Justeringslag har intet at transformere + Adobe PDF Presets… Adobe PDF-forudindstillinger… + Adobe-rgb-compat Adobe-rgb-kompat + Adwaita (Dark) Adwaita (mørk) + Adwaita (Light) Adwaita (lys) + After Efter + Airbrush: keeps painting while the pointer rests. Airbrush: bliver ved med at male, mens markøren hviler. + All shortcuts reset to Photoshop defaults. Alle genveje er nulstillet til Photoshop-standarderne. + Altitude Højde + Ambience Ambiance + Ambient Omgivende + Amplitude Max Maks. amplitude + Amplitude Min Min. amplitude + Android 1080p Android 1080p + Angled Strokes Vinklede strøg + Anisotropic Anisotropisk + Any Alle + Apple Watch 45mm Apple Watch 45mm + Apply Data Set Anvend datasæt + Apply Image Anvend billede + Apply Mask Anvend maske + Apply to All Anvend på alle + Apply {option}: {state} Anvend {option}: {state} + Arbitrary Vilkårlig + Arc Lower Nedre bue + Arc Upper Øvre bue + Are you sure you want to quit? Er du sikker på, at du vil afslutte? + Are you sure? Er du sikker? + Around Center Rundt om midten + Arrangement Arrangement + Arrows Pile + Artboard from Group Tavle fra gruppe + Artboard from Layers Tavle fra lag + Artboards Tavler + Artboards to Files Tavler til filer + Artboards to PDF Tavler til PDF + Artistic Kunstnerisk + Ask On Mismatch Spørg ved uoverensstemmelse + Ask On Missing Spørg ved manglende + Ask On Paste Spørg ved indsættelse + Assign Profile Tildel profil + At Ved + Author Title Forfattertitel + Auto Scale Skalér automatisk + Auto-Align Layers Justér lag automatisk + Auto-Blend Layers Bland lag automatisk + Autosave failed: {error} Automatisk gemning mislykkedes: {error} + Back Tilbage + Backend: {backend} ({type}) Backend: {backend} ({type}) + Background Level Baggrundsniveau + Bamboo Bambus + Bas Relief Basrelief + Base Tree Type Grundtrætype + Based On Baseret på + Baseline shift Grundlinjeforskydning + Basic Latin Grundlæggende latin + Batch Batch + Before Før + Bend Bøjning + Berries Bær + Best Bedste + Black Clip Sortklipning + Black Intensity Sortintensitet + Blade Curvature Bladkrumning + Blast Blæst + Bleach Bypass Bleach Bypass + Blend Clipped Layers as Group Bland udklippede lag som gruppe + Blend Interior Effects as Group Bland indre effekter som gruppe + Blend Text Gamma Bland tekstgamma + Blending Mode Blandingstilstand + Bloat Pust op + Blocks Blokke + Blue Yellow Blå-gul + Blues Blå + Border Kant + Border Thickness Kanttykkelse + Box Blur Bokssløring + Bpc Bpc + Branches Color Grenfarve + Branches Height Grenhøjde + Branches Thickness Grentykkelse + Breeze Dark Breeze Dark + Breeze Light Breeze Light + Brick Mursten + Bristle Detail Børstedetaljer + Browse Gennemse + Brush Detail Penseldetaljer + Brush Settings… (F5): every option of this brush Penselindstillinger… (F5): alle indstillinger for denne pensel + Brush Strokes Penselstrøg + Brush Type Penseltype + Burlap Sækkelærred + Calculations Beregninger + Camera Raw Camera Raw + Camera Raw Filter Camera Raw-filter + Cancel transform Annullér transformering + Candle Light Stearinlys + Canvas Size Lærredsstørrelse + Case Sensitive Forskel på store og små bogstaver + Ccw Mod uret + Cell Size Cellestørrelse + Center X Midtpunkt X + Center Y Midtpunkt Y + Chalk Area Kridtområde + Change Find Ændr søgning + Changes Saved Ændringer gemt + Changes have not been saved. Ændringerne er ikke blevet gemt. + Change… Ændr… + Channel1 Kanal1 + Channel2 Kanal2 + Channel3 Kanal3 + Channel4 Kanal4 + Charcoal Area Kulområde + Charcoal Thickness Kultykkelse + Check for Updates Søg efter opdateringer + Checkerboard Skakbræt + Choke Indsnævring + Choose Folder Vælg mappe + Circle Cirkel + Cleanliness Renhed + Clear Amount Klarhedsgrad + Clear Selection Ryd markering + Click sets foreground · right-click sets background Klik sætter forgrund · højreklik sætter baggrund + Click the first point or press ↵ to close · Esc cancels Klik på det første punkt, eller tryk på ↵ for at lukke · Esc annullerer + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Klik for at tilføje et punkt · træk væk, eller ⌘/Ctrl-klik eller Delete for at fjerne + Click to apply, ⌥-click to clear overrides, double-click for Style Options Klik for at anvende, ⌥-klik for at rydde tilsidesættelser, dobbeltklik for stilindstillinger + Click to zoom in · ⌥-click to zoom out Klik for at zoome ind · ⌥-klik for at zoome ud + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klik: hjørne · Træk: glat · Klik på første punkt: luk · ↩ afslut · Esc annullér + Clipping Mask Udklipningsmaske + Clipping Path… Udklipningssti… + Clone Klon + Clone source set at {x}, {y} Kloningskilde angivet ved {x}, {y} + Close Window Luk vindue + Clumped Klumpet + Cmyk Cmyk + Coarse Dots Grove prikker + Coated-cmyk Bestrøget-cmyk + Collapse group (⌥-click: all groups) Fold gruppe sammen (⌥-klik: alle grupper) + Color Decontaminate Fjern farveforurening + Color Halftone Farverastrering + Color Indicates Masked Areas Farve angiver maskerede områder + Color Indicates Selected Areas Farve angiver markerede områder + Color Lookup Tables Farveopslagstabeller + Color Picker Farvevælger + Color Picker (Background Color) Farvevælger (baggrundsfarve) + Color Picker (Custom Canvas Color) Farvevælger (brugerdefineret lærredsfarve) + Color Picker (Foreground Color) Farvevælger (forgrundsfarve) + Color Sampler Tool Farveprøvetagerværktøj + Color Settings Farveindstillinger + Color Swatches Farveprøver + Color Table Farvetabel + Color management: {error} Farvestyring: {error} + Colored Pencil Farvet blyant + Comic Book Tegneserie + Commit Perspective Warp (↩) Anvend perspektivvridning (↩) + Commit Puppet Warp (↩) Anvend marionetvridning (↩) + Commit any current edits (⌘↩) Anvend alle aktuelle redigeringer (⌘↩) + Commit current crop operation (↵) Anvend aktuel beskæring (↵) + Commit transform Anvend transformering + Commit transform (↩) Anvend transformering (↩) + Commit warp (↩) Anvend vridning (↩) + Compare Sammenlign + Conditional Mode Change Betinget tilstandsændring + Confirm Bekræft + Conform Tilpas + Constraints Begrænsninger + Content-Aware Fill needs a selection on a pixel layer Indholdsbevidst udfyldning kræver en markering på et pixellag + Contrasty Kontrastfuld + Convert Konvertér + Convert to Alpha Channel Konvertér til alfakanal + Convert to Linked Konvertér til sammenkædet + Convert to Profile Konvertér til profil + Cool Kølig + Cooling L B B Afkøling LBB + Cooling80 Afkøling 80 + Cooling82 Afkøling 82 + Copy Complete Stroke Kopiér komplet strøg + Copy Fill Kopiér udfyldning + Copyright Url Ophavsrets-URL + Correct C A Korrigér kromatisk aberration + Correct Distortion Korrigér forvrængning + Correct Vignette Korrigér vignettering + Could not rename the action: {error} Kunne ikke omdøbe handlingen: {error} + Could not transform: the layer is empty Kunne ikke transformere: laget er tomt + Could not use Perspective Warp: the layer is empty Kunne ikke bruge perspektivvridning: laget er tomt + Could not use Puppet Warp: no opaque pixels Kunne ikke bruge marionetvridning: ingen uigennemsigtige pixels + Could not use Puppet Warp: the layer is empty Kunne ikke bruge marionetvridning: laget er tomt + Couldn't open {name}: {error} Kunne ikke åbne {name}: {error} + Couldn't save preferences: {error}. Retrying… Kunne ikke gemme præferencer: {error}. Prøver igen… + Crack Brightness Revnelysstyrke + Crack Depth Revnedybde + Crack Spacing Revneafstand + Craquelure Krakelering + Create By Oprettet af + Create Vector Mask Opret vektormaske + Create Warped Text Opret tekst med vridning + Create a new layer (⇧⌘N) Opret et nyt lag (⇧⌘N) + Create new brush from the current settings Opret ny pensel ud fra de aktuelle indstillinger + Create new brush preset from the current settings Opret ny penselforudindstilling ud fra de aktuelle indstillinger + Create… Opret… + Cross Process Krydsfremkaldelse + Crosshatch Krydsskravering + Crosswise På tværs + Crystallize Krystallisér + Currency Valuta + Curve Darks Kurve – mørke + Curve Highlights Kurve – højlys + Curve Lights Kurve – lyse + Curve Shadows Kurve – skygger + Curve Splits Kurve – opdelinger + Custom Branch Color Brugerdefineret grenfarve + Custom Pixel Aspect Ratio Brugerdefineret pixelsideforhold + Custom pattern Brugerdefineret mønster + Cutout Udklip + Cw Med uret + Cyans Cyan + Cyrillic Kyrillisk + Daisies Margueritter + Dark Gray Mørkegrå + Dark Intensity Mørkeintensitet + Dark Rough Mørk og ru + Dark Strokes Mørke strøg + Darken Only Gør kun mørkere + Darkness Mørke + Data Data + Data Set: {name} ({index}/{total}) Datasæt: {name} ({index}/{total}) + Data Sets as Files Datasæt som filer + Date Dato + Day For Night Dag til nat + De-Interlace Fjern interlace + Decrease Brush Hardness Formindsk penselhårdheden + Decrease Brush Size Formindsk penselstørrelsen + Decrease Type Size Formindsk tekststørrelsen + Decrease Type Size by 10 Formindsk tekststørrelsen med 10 + Deep Dyb + Deep Blue Dyb blå + Deep Emerald Dyb smaragd + Deep Red Dyb rød + Deep Yellow Dyb gul + Default Foreground and Background Colors Standard forgrunds- og baggrundsfarver + Default Leaves Standardblade + Default Photoshop Size Photoshop-standardstørrelse + Defaults Standardindstillinger + Define Brush Preset Definér penselforudindstilling + Define Custom Shape Definér brugerdefineret form + Define Pattern Definér mønster + Definition Definition + Defringe Fjern kantfarver + Degrees Grader + Delete Channel Slet kanal + Delete Mask Slet maske + Delete Workspace Slet arbejdsområde + Delete brush Slet pensel + Delete path Slet sti + Delete vector mask Slet vektormaske + Delete… Slet… + Depth Map Dybdekort + Depth Mode Dybdetilstand + Diameter Diameter + Diffuse Diffusér + Diffuse Glow Diffus glød + Direction Retning + Direction Balance Retningsbalance + Disable Deaktivér + Disable Mask Deaktivér maske + Disable Symmetry Path Deaktivér symmetristi + Disabled Deaktiveret + Discard Changes Forkast ændringer + Displace Forskyd + Display Visning + Display profile not used Skærmprofil bruges ikke + Display profiles: {reason} Skærmprofiler: {reason} + Display-p3 Display-p3 + Distance Afstand + Distortion Forvrængning + Distribution Fordeling + Do you want to save you made to “{name}” before quitting? Vil du gemme dine ændringer i »{name}«, før du afslutter? + Document Sizes Dokumentstørrelser + Documentation Dokumentation + Documents Dokumenter + Dont Enlarge Forstør ikke + Dot Prik + Double Line Dobbelt linje + Down Ned + Draft Kladde + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Træk en beskæringsramme · træk indeni for at flytte · kanterne ændrer størrelsen ({ratio}-forhold, {centre} centreret) · Mellemrumstasten flytter, mens du tegner · {commit} anvender · Esc annullerer + Drag: draw freehand · Release on the start point: close Træk: tegn frihånd · Slip på startpunktet: luk + Driver: {driver} Driver: {driver} + Dry Brush Tør pensel + Duplicate Channel Duplikér kanal + Duplicate Layers Duplikér lag + Duplication Duplikering + Dust & Scratches Støv og ridser + Edge Kant + Edge Brightness Kantlysstyrke + Edge Extension Kantudvidelse + Edge Fidelity Kantnøjagtighed + Edge Intensity Kantintensitet + Edge Only Kun kanter + Edge Simplicity Kantforenkling + Edge Thickness Kanttykkelse + Edge Width Kantbredde + Edit Toolbar Redigér værktøjslinje + Edit… Redigér… + Elevation Elevation + Eliminate Eliminér + Emboss Relief + Enable Aktivér + Enable Mask Aktivér maske + End X Slutpunkt X + End Y Slutpunkt Y + Enlarged Forstørret + Erase with Brush or Pencil (hold) Visk ud med pensel eller blyant (hold nede) + Error Fejl + Even Fields Lige felter + Exit Quick Mask Afslut hurtig maske + Expand group (⌥-click: all groups) Udvid gruppe (⌥-klik: alle grupper) + Export As Eksportér som + Export As: {name} Eksportér som: {name} + Export Contents Eksportér indhold + Export Image Eksportér billede + Export Options Eksportindstillinger + Export Preferences Eksportpræferencer + Export Settings Eksportindstillinger + Export to Eksportér til + Export/Import Presets Eksportér/importér forudindstillinger + Exported contents to {path} Eksporterede indholdet til {path} + Exported presets to {path} Eksporterede forudindstillinger til {path} + Exported {path} Eksporterede {path} + Exported {path} ({size}) Eksporterede {path} ({size}) + Exporting… Eksporterer… + ExtraBold Ekstra fed + ExtraLight Ekstra let + Extrude Ekstrudér + Fade Udtoning + Faded Film Falmet film + Failed Mislykkedes + Failed to open file Kunne ikke åbne filen + Failed to save file Kunne ikke gemme filen + Fallback: {reason} Reserveløsning: {reason} + Feather Radius Radius for blød kant + Fiber Length Fiberlængde + Fibers Fibre + Field Blur Feltsløring + File Format Filformat + File Info Filoplysninger + File Name Filnavn + File Size Filstørrelse + File not found Filen blev ikke fundet + Filename Filnavn + Fill Color Udfyldningsfarve + Fill Opacity Udfyldningsopacitet + Fill Path… Udfyld sti… + Fill path with foreground color Udfyld sti med forgrundsfarve + Fill with Background Color Udfyld med baggrundsfarve + Fill with Background Color, Preserve Transparency Udfyld med baggrundsfarve, bevar gennemsigtighed + Fill with Foreground Color Udfyld med forgrundsfarve + Fill with Foreground Color, Preserve Transparency Udfyld med forgrundsfarve, bevar gennemsigtighed + Film Grain Filmkorn + Filter Gallery Filtergalleri + Find Find + Find and Replace Text Find og erstat tekst + Fine Fin + Fine Dots Fine prikker + Finish Afslut + Fit Image Tilpas billede + Fit to Window Tilpas til vindue + Flame Flamme + Flame Bottom Alignment Justering af flammens bund + Flame Lines Flammelinjer + Flame Shape Flammeform + Flame Style Flammestil + Flame Type Flammetype + Flat Flad + Flat Shading Flad skygning + Flower Color Blomsterfarve + Focal Distance Fokusafstand + Focus Fokus + Focus Area Fokusområde + Foreground Level Forgrundsniveau + Forward Fremad + Forward Warp Vrid fremad + Frame Billede + Frame {current} / {total} Billede {current} / {total} + Free Transform Path Fri transformering af sti + Freeze Lasso Fryselasso + Freeze Mask Frysemaske + Fresco Fresko + From Fra + From Left Fra venstre + From Right Fra højre + Frosted Matteret + Full Screen Fuld skærm + Fullscreen Fuldskærm + Fuzziness Uklarhed + GPU backend preference: {backend} GPU-backend-præference: {backend} + GPU device was lost; using the CPU renderer. GPU-enheden gik tabt; bruger CPU-gengiveren. + GPU error; using the CPU renderer. GPU-fejl; bruger CPU-gengiveren. + GPU retry scheduled Nyt GPU-forsøg planlagt + Gamma Gamma + Gaussian Blur Gaussisk sløring + General Blending Generel blanding + General Brushes Generelle pensler + Generators Generatorer + Generic Generisk + Glass Glas + Global Light Globalt lys + Gloss Glans + Glow Amount Glødmængde + Glow Brightness Glødlysstyrke + Glow Size Glødstørrelse + Glowing Edges Glødende kanter + Good God + Grade Balance Graderingsbalance + Grade Blending Graderingsblanding + Grade Global Global gradering + Grade Highlights Gradering af højlys + Grade Midtones Gradering af mellemtoner + Grade Shadows Gradering af skygger + Gradient Options Gradientindstillinger + Grain Amount Kornmængde + Grain Roughness Kornruhed + Grain Size Kornstørrelse + Grain Type Korntype + Graininess Kornethed + Grainy Dots Kornede prikker + Graphics adapter Grafikadapter + Graphics adapter: {adapter} Grafikadapter: {adapter} + Gray-gamma-2.2 Grå-gamma-2.2 + Greek Græsk + Greens Grønne + Grid (⌘') Gitter (⌘') + Grid view Gittervisning + Group from Layers Gruppe fra lag + Groups Grupper + Grout Width Fugebredde + Guide Layout… Hjælpelinjelayout… + HDR Toning HDR-toning + Hand Tool (hold) Håndværktøj (hold nede) + Hearts Hjerter + Heptagon Syvkant + Hexagon Sekskant + Hidden Skjult + Hide Grid Skjul gitter + Hide Others Skjul andre + Hide the layer's effects (⌥-click: all layers) Skjul lagets effekter (⌥-klik: alle lag) + Hide {app} Skjul {app} + High Pass Højpas + Highlight Højlys + Highlight Amount Højlysmængde + Highlight Area Højlysområde + Highlight Radius Højlysradius + Highlight Strength Højlysstyrke + Highlight Tone Højlystone + Highlights from: Højlys fra: + Horizontal Distortion Vandret forvrængning + Horizontal Only Kun vandret + Horizontal scale Vandret skalering + Horizontal type Vandret tekst + Hotspot Hotspot + Hsb HSB + Hsl HSL + Hsl Hue HSL-farvetone + Hsl Lum HSL-luminans + Hsl Sat HSL-mætning + Image Balance Billedbalance + Image Processor Billedprocessor + Image compositor: {processor} Billedsammensætter: {processor} + Import Brushes Importér pensler + Import Brushes… (.abr) Importér pensler… (.abr) + Import Image Importér billede + Import Options Importindstillinger + Import Photoshop brushes (.abr) Importér Photoshop-pensler (.abr) + Import Settings Importér indstillinger + Importing {what} from {file}… Importerer {what} fra {file}… + Importing… Importerer… + In Black Input-sort + 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. + 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. + “{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 + Move Down Flyt ned + Previous Brush Forrige pensel + Next Brush Næste pensel + First Brush Første pensel + Last Brush Sidste pensel + Preview size Forhåndsvisningsstørrelse + Memory Usage Hukommelsesforbrug + Per-document RAM limit (MB) RAM-grænse pr. dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installeret RAM: {ram} MB; denne grænse: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrænser hvert åbent dokuments pixels plus historik. De ældste fortrydelsestilstande kasseres, når grænsen overskrides. + Applies immediately when saved. Memory is not reserved in advance. Træder i kraft med det samme, når der gemmes. Hukommelsen reserveres ikke på forhånd. + 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. Når GPU-hukommelse er sat til Automatisk, begrænser dette også GPU-lagbudgettet. Budgettet er højst en fjerdedel af den installerede RAM og, når den kendes, tre ottendedele af grafikkortets hukommelse, dog mindst 512 MB. Hvis du hæver denne grænse ud over disse lofter, øges GPU-budgettet ikke. + Command Kommando + Use Brug + In Front Foran + Behind Bagved + +# DDS Options (#1327) + DDS Options DDS-indstillinger + Compression Komprimering + Generate Mipmaps Generér mipmaps + +# Color Table (.act) dialog + There is no color table to save. Der er ingen farvetabel at gemme. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Gemte {n} farve i {file}|Gemte {n} farver i {file} + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Importér stile… + Export Selected Styles… Eksportér markerede stile… + Export Styles… Eksportér stile… + {file} is not a style library (.asl) {file} er ikke et stilbibliotek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importerede {n} stil fra {file}|Importerede {n} stile fra {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Eksporterede {n} stil til {file}|Eksporterede {n} stile til {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bekræft eller annullér først den åbne dialog for handlingstrinnet + Pro Medium Gray Pro mellemgrå + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro lysegrå + Pro Light Pro lys + Resize Windows to Fit Tilpas vinduesstørrelse + EXR EXR + Increase Brush Size Forøg penselstørrelsen + Increase Type Size Forøg tekststørrelsen + Increase Type Size by 10 Forøg tekststørrelsen med 10 + Indent first line Indryk første linje + Indent left margin Indryk venstre margen + Indent right margin Indryk højre margen + Indexed Indekseret + Infinite Uendelig + Information Information + Initial Direction Startretning + Ink Outlines Blækkonturer + Inner Bevel Indre facet + Input Input + Input Levels Inputniveauer + Input Mode Inputtilstand + Install Plug-in Installér plug-in + Intensity Intensitet + Intent Hensigt + Interpret Footage Tolk materiale + Intersect with selection (⇧⌥) Skær med markering (⇧⌥) + Interval Interval + Invalid panel layout; restored the default dock Ugyldigt panellayout; standarddokken er gendannet + Invert Mask Invertér maske + Invert Selection Invertér markering + Iris Blur Irissløring + Ivy Vedbend + Jag Hak + Join the ArtCraft Discord Deltag i ArtCrafts Discord + Join the ArtCraft Discord ({url}) Deltag i ArtCrafts Discord ({url}) + Keeps the current texture when switching presets. Beholder den aktuelle tekstur ved skift af forudindstilling. + Kernel Kerne + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Knibning: metrik, optisk eller en værdi i 1/1000 em (Alt+←/→ ved indsætningspunktet) + Keyboard Tastatur + Lab Lab + Landscape, 10 x 8 Liggende, 10 x 8 + Landscape, 6 x 4 Liggende, 6 x 4 + Landscape, 7 x 5 Liggende, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin udvidet-A + Latin Extended-B Latin udvidet-B + Layer Comps to Files Lagkompositioner til filer + Layer Content Options Indstillinger for lagindhold + Layer Count Antal lag + Layer From Background Lag fra baggrund + Layer Knocks Out Drop Shadow Lag udstanser slagskygge + Layer Mask Hides Effects Lagmaske skjuler effekter + Layer Properties Lagegenskaber + Layer Styles Lagstile + Layer from Background Lag fra baggrund + Layers to Files Lag til filer + Layout Layout + Leading (set to the font size × auto-leading when Auto) Linjeafstand (sættes til skriftstørrelse × automatisk linjeafstand ved Auto) + Leaf Color Bladfarve + Learn More Få mere at vide + Leaves Amount Bladmængde + Leaves Color Bladfarve + Leaves Size Bladstørrelse + Left Diagonal Venstre diagonal + Legacy Ældre + Legal Legal + Lens Blur Objektivsløring + Lens Correction Objektivkorrektion + Lens Flare Objektivblænding + Less Mindre + Letter Letter + Level Based Niveaubaseret + Library Bibliotek + Light Dark Balance Lys/mørk-balance + Light Direction Lysretning + Light Intensity Lysintensitet + Light Rough Lys og ru + Light Type Lystype + Light X Lys-X + Light Y Lys-Y + Light Z Lys-Z + Lighten Grout Lysere fuge + Lighten Only Gør kun lysere + Lighting Belysning + Lighting Effects Lyseffekter + Linear Dodge Lineær lysning + Linear Height Lineær højde + Linear-srgb Lineær-srgb + Link Sammenkæd + Liquify Gør flydende + List Liste + List view Listevisning + Live Preview Live-forhåndsvisning + Load 3D LUT Indlæs 3D LUT + Load Files into Stack Indlæs filer i stak + Load path as a selection Indlæs sti som markering + Loading… Indlæser… + Lock All Lås alle + Lock Image Pixels Lås billedpixels + Lock Position Lås placering + Lock Transparent Pixels Lås gennemsigtige pixels + Locked Låst + Long Lines Lange linjer + Long Strokes Lange strøg + Lorem Ipsum Lorem Ipsum + Lower Nedre + Lum Luminans + Lut LUT + Magentas Magenta + Make Selection from Path Opret markering fra sti + Make Selection… Opret markering… + Make Symmetry Path Opret symmetristi + Make Work Path… Opret arbejdssti… + Make work path from selection Opret arbejdssti ud fra markering + Manual Manuelt + Map Document Kortlæg dokument + Map Layer Kortlæg lag + Map Path Kortlæg sti + Mask Channel Maskekanal + Mask Incomplete Blocks Maskér ufuldstændige blokke + Mask None Ingen maske + Masks Masker + Match Color Afstem farve + Math Symbols Matematiske symboler + Max Offset Maks. forskydning + Max Radius Maks. radius + Measurement Scale Måleskala + Measurement Scale Marker Måleskalamarkør + Medium Dots Mellemstore prikker + Medium Gray Mellemgrå + Medium Lines Mellemstore linjer + Medium Strokes Mellemstore strøg + Merge Channels… Flet kanaler… + Merge Spot Channel Flet spotkanal + Merge to HDR Pro Flet til HDR Pro + Metallic Metallisk + Metrics Metrik + Mezzotint Mezzotint + Middle Midten + Midtone Mellemtone + Monitor Profile Skærmprofil + Monitor profile in use on {display}: {profile} Skærmprofil i brug på {display}: {profile} + Monitor profile in use: {profile} Skærmprofil i brug: {profile} + Monitor profile on {display} (main window): {profile} Skærmprofil på {display} (hovedvindue): {profile} + Monitor profile on {display}: {profile} Skærmprofil på {display}: {profile} + Monitor profile: {profile} Skærmprofil: {profile} + Mono Contrast Monokontrast + Monochromatic Monokrom + More Mere + Mosaic Mosaik + Mosaic Tiles Mosaikfliser + Motion Blur Bevægelsessløring + Mouse Mus + Move Layer Flyt lag + Movie Prime Movie Prime + Multiple Flames Along Path Flere flammer langs sti + Multiple Flames One Direction Flere flammer i én retning + Multiple Flames Path Directions Flere flammer i stiens retninger + Multiple Flames Various Length Flere flammer med forskellig længde + My Brushes Mine pensler + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Indbygget træk-og-slip af filer understøttes endnu ikke på Wayland. Brug Filer › Åbn, eller kør PhotoCraft under XWayland med `WAYLAND_DISPLAY= photocraft`. + Nearest Nærmeste + Neon Glow Neonglød + Neutralize Neutralisér + Neutrals Neutrale + New Brush Preset Ny penselforudindstilling + New Channel… Ny kanal… + New Guide Ny hjælpelinje + New Guide Layout Nyt hjælpelinjelayout + New Spot Channel… Ny spotkanal… + New Video Layer from File Nyt videolag fra fil + New Window Nyt vindue + New Workspace Nyt arbejdsområde + New document Nyt dokument + New document… Nyt dokument… + New document… ⌘N Nyt dokument… ⌘N + Next Næste + No Document Intet dokument + No Image Intet billede + No Layers Ingen lag + No Results Ingen resultater + No Results Found Fandt ingen resultater + No Selection Ingen markering + No custom shape "{name}": pick one in the options bar or Window › Shapes Ingen brugerdefineret form »{name}«: vælg en på indstillingslinjen eller i Vindue › Former + No layers Ingen lag + No recent files Ingen seneste filer + No selection Ingen markering + Noise Color Farvestøj + Noise Color Detail Farvestøjsdetaljer + Noise Luminance Luminansstøj + Noise Luminance Detail Luminansstøjdetaljer + Nothing to Redo Intet at gøre om + Nothing to Undo Intet at fortryde + Nothing to paste: the clipboard holds no image Intet at indsætte: udklipsholderen indeholder ikke noget billede + Number Of Levels Antal niveauer + Numbers Tal + Ocean Ripple Havkrusning + Octagon Ottekant + Odd Fields Ulige felter + Oil Paint Oliemaleri + On Til + One Flame Along Path Én flamme langs sti + Open As Åbn som + Open File Åbn fil + Open Folder Åbn mappe + Open Image Åbn billede + Open Recent is unavailable on the web Åbn seneste er ikke tilgængelig i webversionen + Opened {url} Åbnede {url} + Opening Åbner + Open… ⌘O Åbn… ⌘O + Operation canceled Operationen blev annulleret + Operation cancelled Operationen blev annulleret + Operation completed Operationen er fuldført + Operation failed Operationen mislykkedes + Optical Optisk + Original Ratio Oprindeligt forhold + Out Black Output-sort + Out From Center Ud fra centrum + Out Of Gamut Uden for gamut + Out White Output-hvid + Out of memory Ikke nok hukommelse + Outer Bevel Ydre facet + Output Levels Outputniveauer + Oval Oval + Overlay Edge Overlejrede kanter + Overlay: Rule of Thirds Overlejring: tredjedelsreglen + PSD PSD + Package Pakke + Page Setup… Sideopsætning… + Paint Daubs Malingsklatter + Paints from the document's opening state Maler fra dokumentets tilstand ved åbning + Palette Knife Paletkniv + Panel Panel + Paper Papir + Paper Brightness Papirlysstyrke + Parallel Parallel + Paste Complete Stroke Indsæt komplet strøg + Paste Fill Indsæt udfyldning + Patchwork Patchwork + Path Blur Stisløring + Paths to Illustrator Stier til Illustrator + Pattern Fill… Mønsterudfyldning… + Pattern Type Mønstertype + Pencil Width Blyantbredde + Pentagon Femkant + Percentage Procent + Permission denied Adgang nægtet + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft bliver ved med at prøve igen; indtil det lykkes at gemme, går ændringer i præferencerne tabt, når programmet lukkes. + Photomerge Photomerge + Pick Color From Image Vælg farve fra billede + Picture Frame Billedramme + Pillow Emboss Puderelief + Pinch Klemning + Pins Nåle + Place Embedded Placér indlejret + Place Linked Placér sammenkædet + Place Scale Marker Placér skalamarkør + Plane Plan + Planes Planer + Plastic Wrap Plastfolie + Platform: {os} {arch} Platform: {os} {arch} + Please wait… Vent et øjeblik… + Point Punkt + Point Curve Punktkurve + Point Curve Blue Punktkurve blå + Point Curve Green Punktkurve grøn + Point Curve Red Punktkurve rød + Pointed Spids + Pointillize Pointillér + Polar Coordinates Polære koordinater + Polar To Rectangular Polar til rektangulær + Policy Cmyk CMYK-politik + Policy Gray Gråtonepolitik + Policy Rgb RGB-politik + Pond Ripples Damkrusninger + Portrait, 4 x 6 Stående, 4 x 6 + Portrait, 5 x 7 Stående, 5 x 7 + Postcard Postkort + Poster Plakat + Poster Edges Plakatkanter + Posterization Posterisering + Preferences can't be saved Præferencer kan ikke gemmes + Preferences saved Præferencer gemt + Preferences were reset: {error} Præferencerne blev nulstillet: {error} + Preferences… Præferencer… + Preserve Bevar + Preset Manager Forudindstillingsadministrator + Press keys Tryk på taster + Preview Image Forhåndsvisningsbillede + Previous Forrige + Prime105 Prime105 + Prime35 Prime35 + Printed to {path} Udskrevet til {path} + Pro (Dark) Pro (mørk) + Pro (Medium Gray) Pro (mellemgrå) + Processing… Behandler… + Profile Profil + Proof Setup… Korrekturopsætning… + Prophoto-compat Prophoto-kompat + Pucker Rynk + Push Left Skub til venstre + Pyramids Pyramider + Quick Actions Hurtige handlinger + Quit Afslut + Quit PhotoCraft Afslut PhotoCraft + Quit {app} Afslut {app} + R G B R G B + R g b R g b + Radial Radial + Radial Blur Radial sløring + Radius X Radius X + Radius Y Radius Y + Ran script {name} Kørte scriptet {name} + Randomize Length Tilfældig længde + Randomize Shapes Tilfældige former + Rasterize Fill Content Rasterisér udfyldningsindhold + Rasterize Smart Object Rasterisér smartobjekt + Ratio Forhold + Raw Defaults Raw-standarder + Reading Læser + Ready Klar + Rec2020 Rec2020 + Recent Files Seneste filer + Reconstruct All Rekonstruér alt + Recovered {name} Gendannede {name} + Rectangular Selection Rektangulær markering + Rectangular To Polar Rektangulær til polar + Red Cyan Rød-cyan + Reds Røde + Reduce Color Noise Reducér farvestøj + Reduce Noise Reducér støj + Reflected Reflekteret + Relief Relief + Relink to File Sammenkæd med fil igen + Remove All Fjern alle + Remove Jpeg Artifact Fjern JPEG-artefakt + Rename Brush Omdøb pensel + Rename Channel… Omdøb kanal… + Rename Group Omdøb gruppe + Rename Tool Preset Omdøb værktøjsforudindstilling + Render Video Gengiv video + Rendering… Gengiver… + Reorder Layer Omorganisér lag + Replace Color Erstat farve + Replace Contents Erstat indhold + Replace Footage Erstat materiale + Report a Bug Rapportér en fejl + Report an Issue Rapportér et problem + Reset Alignment Nulstil justering + Reset All Nulstil alle + Reset Preferences Nulstil præferencer + Resolve Missing Fonts Håndtér manglende skrifttyper + Restart Genstart + Restart Required Genstart påkrævet + Resume Genoptag + Retry Prøv igen + Rgb RGB + Ridges Kamme + Right Diagonal Højre diagonal + Ripple Krusning + Ripple Magnitude Krusningsstyrke + Ripple Size Krusningsstørrelse + Rope Reb + Roses Roser + Rough Pastels Ru pasteller + Row Order Rækkefølge + Rulers (⌘R) Linealer (⌘R) + Sample Size Prøvestørrelse + Sampled Colors Prøvetagne farver + Sandstone Sandsten + Sat Mætning + Save As Gem som + Save Changes Gem ændringer + Save File Gem fil + Save Image Gem billede + Save Path Gem sti + Save Pixel Aspect Ratio Gem pixelsideforhold + Save a Copy Gem en kopi + Save before closing? Gem før lukning? + Save for Web (Legacy) Gem til web (ældre) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Gem dit arbejde, og genstart PhotoCraft for at prøve GPU-acceleration igen. CPU-gengivelse er stadig aktiv i denne session. + Saved a copy as {path} Gemte en kopi som {path} + Saved {path}; image assets: {n} in {dir} Gemte {path}; billedaktiver: {n} i {dir} + Saving… Gemmer… + Scale Effects Skalér effekter + Scaling Skalering + Scallops Muslingekanter + Script Events Manager Scripthændelsesadministrator + Scroll menu down Rul ned i menuen + Scroll menu up Rul op i menuen + Search commands Søg i kommandoer + Search commands (⌘K) Søg i kommandoer (⌘K) + Search commands, tools and panels Søg i kommandoer, værktøjer og paneler + Search menus Søg i menuer + Seed Startværdi + Select Custom Color… Vælg brugerdefineret farve… + Select Data Points Markér datapunkter + Select Layer Markér lag + Select and Mask Markér og maskér + Color Aware Farvebevidst + Decontaminate Colors Fjern farveforurening + Edge Detection Kantregistrering + Global Refinements Globale forfininger + High Quality Preview Forhåndsvisning i høj kvalitet + Object Aware Objektbevidst + Object Aware needs a segmentation model, which isn't available yet Objektbevidst kræver en segmenteringsmodel, som endnu ikke er tilgængelig + Real-Time Refinement Forfining i realtid + Refine Edge Brush Tool Kantforfiningspenselværktøj + Refine Hair Forfin hår + Refine Hair needs hair detection, which isn't available yet Forfin hår kræver hårregistrering, som endnu ikke er tilgængelig + Refine Mode Forfiningstilstand + Select and Mask was closed: another layer was selected Markér og maskér blev lukket: et andet lag blev markeret + Select and Mask was closed: its document is no longer the active one Markér og maskér blev lukket: dets dokument er ikke længere det aktive + View Mode Visningstilstand + Selected at launch: {backend} Valgt ved opstart: {backend} + SemiBold Halvfed + Sensitivity Følsomhed + Sent to the printer ({spooler}) Sendt til printeren ({spooler}) + Sepia Sepia + Services Tjenester + Set Colors Indstil farver + Set Measurement Scale Indstil måleskala + Sgray Sgray + Shadow Skygge + Shadow Amount Skyggemængde + Shadow Intensity Skyggeintensitet + Shadow Radius Skyggeradius + Shadow Tone Skyggetone + Shadows up to: Skygger op til: + Shadows/Highlights Skygger/højlys + Shallow Lav + Shape Blur Formsløring + Sharpen Amount Skærpningsmængde + Sharpen Detail Skærpningsdetalje + Sharpen Details Skærpningsdetaljer + Sharpen Masking Skærpningsmaskering + Sharpen Radius Skærpningsradius + Shear Skævvridning + Shine Skær + Short Lines Korte linjer + Short Strokes Korte strøg + Shortcut Genvej + Shortcuts Genveje + Show All Vis alle + Show Extras Options Vis indstillinger for ekstraer + Show Guides Vis hjælpelinjer + Show Guides (⌘;) Vis hjælpelinjer (⌘;) + Show Rulers Vis linealer + Show the layer's effects (⌥-click: all layers) Vis lagets effekter (⌥-klik: alle lag) + Simple Enkel + Simulate Paper Simulér papir + Sine Sinus + Sketch Skitse + Skip Spring over + Slice Options Udsnitsindstillinger + Smart Blur Smart sløring + Smart Sharpen Smart skærpning + Smoothness Glathed + Smudge Stick Udtværingspind + Snap to Grid Fastgør til gitter + Snowflakes Snefnug + Soften Blødgør + Softness Blødhed + Software adapter Softwareadapter + Solarized Dark Solarized Dark + Solid Color Ensfarvet + Solid Front Faces Solide frontflader + Source Channel Kildekanal + Source Invert Invertér kilde + Source Layer Kildelag + Source1 Channel Kilde 1-kanal + Source1 Invert Invertér kilde 1 + Source2 Channel Kilde 2-kanal + Source2 Invert Invertér kilde 2 + Source: set Kilde: angivet + Space Mellemrum + Sparkle Glimt + Spatter Stænk + Speckle Prikvirkning + Speed Hastighed + Spherize Kugleform + Spin Drej + Spin Blur Rotationssløring + Split Channels Opdel kanaler + Spot Plet + Spray Radius Sprøjteradius + Sprayed Strokes Sprøjtede strøg + Spread Udbredelse + Sprinkles Strøsler + Square Size Kvadratstørrelse + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Firkantethed + Stagger Forskudt + Stained Glass Blyindfattet glas + Star Stjerne + Stars Stjerner + Start X Start-X + Start Y Start-Y + Statistics Statistik + Step {step} ({id}) failed: {error} Trin {step} ({id}) mislykkedes: {error} + Stippled Prikket + Stops Stop + Strength Styrke + Stretch Stræk + Stroke Color Strøgfarve + Stroke Detail Strøgdetalje + Stroke Direction Strøgretning + Stroke Length Strøglængde + Stroke Pressure Strøgtryk + Stroke Size Strøgstørrelse + Stroke Width Strøgbredde + Stroke path with brush Lav kontur på sti med pensel + Strokes Strøg + Studio (Dark) Studio (mørk) + Studio (Light) Studio (lys) + Stylization Stilisering + Subtract Shapes at Overlap Træk former fra ved overlap + Subtract from selection (⌥) Træk fra markering (⌥) + Sumi-e Sumi-e + Surface Blur Overfladesløring + Switch Foreground and Background Colors Byt om på forgrunds- og baggrundsfarver + System Info Systemoplysninger + Tablet Tablet + Tabloid Tabloid + Taper Afsmalning + Target Mål + Target Channel 3 Sæt kanal 3 som mål + Target Channel 4 Sæt kanal 4 som mål + Target Channel 5 Sæt kanal 5 som mål + Target Channel 6 Sæt kanal 6 som mål + Target Channel 7 Sæt kanal 7 som mål + Target Channel 8 Sæt kanal 8 som mål + Target Channel 9 Sæt kanal 9 som mål + Target Composite Channel Sæt sammensat kanal som mål + Target X Mål-X + Target Y Mål-Y + Teal Orange Blågrøn-orange + Technology Previews… Teknologieksempler… + Temporary Midlertidig + Text Layer Tekstlag + Texture Coverage Teksturdækning + Texturizer Teksturering + Thaw Mask Optøningsmaske + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lærredet vises som sRGB. Vælg en skærmprofil under Redigér › Farveindstillinger. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lærredet på {display} vises som sRGB. Vælg en skærmprofil under Redigér › Farveindstillinger. + The current gradient (pick one in Window › Gradients) Den aktuelle gradient (vælg en i Vindue › Gradienter) + The document does not have an embedded colour profile. Working space: {working}. Dokumentet har ingen indlejret farveprofil. Arbejdsfarverum: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokumentet har en indlejret farveprofil, der ikke stemmer overens med arbejdsfarverummet.\nIndlejret: {embedded}\nArbejdsfarverum: {working} + The file could not be opened. Filen kunne ikke åbnes. + The file could not be saved. Filen kunne ikke gemmes. + Thickness Tykkelse + This Layer: Dette lag: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjekt skal rasteriseres, før du fortsætter. Dets indhold kan ikke længere redigeres. Vil du rasterisere smartobjektet? + This adjustment ({key}) is kept as imported and can't be edited yet. Denne justering ({key}) bevares som importeret og kan ikke redigeres endnu. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette udfyldningslag skal rasteriseres, før du fortsætter. Dets udfyldningsindhold kan ikke længere redigeres. Vil du rasterisere laget? + This layer must be rasterized before proceeding. Dette lag skal rasteriseres, før du fortsætter. + This session: {reason} Denne session: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Dette formlag skal rasteriseres, før du fortsætter. Det kan ikke længere redigeres som form. Vil du rasterisere formen? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Dette tekstlag skal rasteriseres, før du fortsætter. Dets tekst kan ikke længere redigeres. Vil du rasterisere teksten? + Tile Size Flisestørrelse + Tiles Fliser + Tilt-Shift Tilt-shift + Tint Color Toningsfarve + Tiny Lens Lille objektiv + Title Titel + To Til + To Center Mod centrum + Toggle Layer Mask Overlay Slå lagmaskeoverlejring til/fra + Toggle the Brush Settings panel Slå panelet Penselindstillinger til/fra + Toggle the Brush Settings panel (F5) Slå panelet Penselindstillinger til/fra (F5) + Tool Options Værktøjsindstillinger + Trace Contour Spor kontur + Tracking (1/1000 em) Spatiering (1/1000 em) + Transformation Transformering + Transition Overgang + Transparency Shapes Layer Gennemsigtighed former lag + Trap Trap + Tree Træ + Trim Trim + Turbulent Turbulent + Twirl Hvirvel + Twirl Clockwise Drej med uret + Twirl Counterclockwise Drej mod uret + Type Options Tekstindstillinger + Unaltered Uændret + Undefined Areas Udefinerede områder + Underlying Layer: Underliggende lag: + Underpainting Undermalering + Underwater Under vandet + Ungroup Ophæv gruppering + Uniform Ensartet + Unite Shapes at Overlap Foren former ved overlap + Unlink Fjern sammenkædning + Unlock Lås op + Unlocked Ikke låst + Unsaved Changes Ugemte ændringer + Unsharp Mask Uskarp maskering + Unsupported file format Ikke-understøttet filformat + Unsupported format Ikke-understøttet format + Up Op + Update Opdatér + Updates Opdateringer + Upper Øvre + Use Custom Color Brug brugerdefineret farve + 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 + Vertical Distortion Lodret forvrængning + Vertical Only Kun lodret + Vertical Type Tool Lodret tekstværktøj + Vertical scale Lodret skalering + Vertical type Lodret tekst + Video Frames to Layers Videobilleder til lag + Vignette Amount Vignetteringsmængde + Vignette Feather Blød kant på vignettering + Vignette Highlights Vignettering af højlys + Vignette Midpoint Vignetteringsmidtpunkt + Vignette Roundness Vignetteringsrundhed + Vignette Style Vignetteringsstil + Vine Color Rankefarve + Vine With Flowers Ranke med blomster + Vine With Leaves Ranke med blade + Violent Voldsom + W: B: + WIA Support WIA-support + Warm Varm + Warming Filter (85) Opvarmningsfilter (85) + Warming L B A Opvarmning LBA + Warming81 Opvarmning 81 + Warming85 Opvarmning 85 + Warmth Varmhed + Warning Advarsel + Warp Text Vrid tekst + Watercolor Vandfarve + Watercolour look: lighter interior, darker rims. Vandfarvelook: lysere indre, mørkere kanter. + Wavelength Max Maks. bølgelængde + Wavelength Min Min. bølgelængde + Waves Bølger + Web Large Web stor + Web Minimum Web minimum + Web Most Common Web mest almindelige + WebP (lossless) WebP (tabsfri) + Wetness Vådhed + White Clip Hvidklipning + White Intensity Hvidintensitet + White Is High Hvidt er højt + Whole Word Hele ord + Wide Blurry Bredt og sløret + Wide Sharp Bredt og skarpt + Wind Vind + Window renderer: {renderer} Vinduesgengiver: {renderer} + Working Arbejder + Working Cmyk Arbejds-CMYK + Working Gray Arbejds-grå + Working Rgb Arbejds-RGB + Working-cmyk Arbejds-cmyk + Workspaces Arbejdsområder + Wrap Ombryd + Yellows Gule + ZigZag Zigzag + Zigzag Zigzag + Zoom In (hold, drag to scrub) Zoom ind (hold nede, træk for at zoome) + Zoom Out (hold) Zoom ud (hold nede) + auto auto + blue blå + brushes pensler + fallback reserve + gradients gradienter + gray grå + green grøn + layer mask lagmaske + manual manuelt + none ingen + off fra + on til + orange orange + previous reading kept; the last re-read failed: {reason} forrige aflæsning bevaret; den seneste genaflæsning mislykkedes: {reason} + red rød + unknown ukendt + unknown type ukendt type + vector mask vektormaske + violet violet + yellow gul + {index} of {total} {index} af {total} + {label} is not available: {reason} {label} er ikke tilgængelig: {reason} + {message} (notes: {n}; {warning}) {message} (bemærkninger: {n}; {warning}) + {shortcut} assigned. {shortcut} tildelt. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} bruges allerede af {commands} og fjernes dér, når du klikker på OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ navigér ↵ kør esc luk + ✓ Color Indicates Masked Areas ✓ Farve angiver maskerede områder + ✓ Color Indicates Selected Areas ✓ Farve angiver markerede områder +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Slet +@id edit.purge.undo Fortryd +@id edit.stroke Kontur… +@id edit.transform.scale Skalér +@id file.close Luk +@id filter.sharpen.sharpen Skærp +@id layer.layerMask.apply Anvend +@id select.all Markér alt +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importerede {n} pensel fra {file}|Importerede {n} pensler fra {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importerede {n} gradient fra {file}|Importerede {n} gradienter fra {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importerede {n} mønster fra {file}|Importerede {n} mønstre fra {file} +cameraRaw Balance Balance +cameraRaw Clarity Klarhed +cameraRaw Color Farve +cameraRaw Color Mixer Farvemikser +cameraRaw Contrast Kontrast +cameraRaw Curve Kurve +cameraRaw Dehaze Fjern dis +cameraRaw Detail Detaljer +cameraRaw Effects Effekter +cameraRaw Exposure Eksponering +cameraRaw Feather Blød kant +cameraRaw Highlights Højlys +cameraRaw Luminance Luminans +cameraRaw Masking Maskering +cameraRaw Midpoint Midtpunkt +cameraRaw Midtones Mellemtoner +cameraRaw Radius Radius +cameraRaw Roundness Rundhed +cameraRaw Saturation Mætning +cameraRaw Shadows Skygger +cameraRaw Size Størrelse +cameraRaw Temperature Temperatur +cameraRaw Texture Tekstur +cameraRaw Tint Nuance +cameraRaw Whites Hvide +colorRange Invert Invertér +document Orientation Orientering +layerLabel Magenta Magenta +layerLabel No Color Ingen farve +layerLabel Red Rød +layers Fill Udfyldning +layers Fill: Udfyldning: +navigator Zoom Zoom +shape Appearance Udseende +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatisk afsnitsretning + Left-to-right paragraph direction Afsnitsretning fra venstre mod højre + Right-to-left paragraph direction Afsnitsretning fra højre mod venstre + Not rendered: this adjustment ({key}) is kept as imported. Ikke gengivet: denne justering ({key}) bevares som importeret. +# Print preview image (#8236) + Print preview image Forhåndsvisningsbillede til udskrift +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Farvetonekube + Brightness Cube Lysstyrkekube + Color Wheel Farvehjul + RGB Sliders RGB-skydere + HSB Sliders HSB-skydere + CMYK Sliders CMYK-skydere + Lab Sliders Lab-skydere + Web Color Sliders Webfarveskydere + Copy Color as HTML Kopiér farve som HTML + Copy Color's Hex Code Kopiér farvens hex-kode + RGB Spectrum RGB-spektrum + CMYK Spectrum CMYK-spektrum + Grayscale Ramp Gråtonerampe + Current Colors Aktuelle farver + Make Ramp Web Safe Gør rampe websikker +# Art History Brush (#213). + Art History Brush Tool Kunstnerisk historikpenselværktøj + Tight Short Stram kort + Tight Medium Stram mellem + Tight Long Stram lang + Loose Medium Løs mellem + Loose Long Løs lang + Dab Dup + Tight Curl Stram krølle + Loose Curl Løs krølle + Stylized dabs from a history state Stiliserede dup fra en historiktilstand + Font size presets Forudindstillinger for skriftstørrelse + Leading presets Forudindstillinger for linjeafstand + Tracking presets Forudindstillinger for spatiering + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-gengivelse + Calculating… Beregner… + Click to refresh histogram with uncached data Klik for at opdatere histogrammet med ikke-cachelagrede data + Convert to Shape… Konvertér til form… + Cooling Filter (80) Afkølingsfilter (80) + Cooling Filter (82) Afkølingsfilter (82) + Cooling Filter (LBB) Afkølingsfilter (LBB) + Count: Antal: + Delete Hidden Layers Slet skjulte lag + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Træk på billedet for at tilføje en begrænsning; Shift for vandret/lodret; højreklik for at slette. + Drawn on the CPU: {reason}. Click for System Info. Tegnet på CPU'en: {reason}. Klik for at se Systemoplysninger. + Exported {n} files Eksporterede {n} filer + Fit Layer on Screen Tilpas lag til skærmen + Flow: Gennemstrømning: + Level: Niveau: + Percentile: Percentil: + Refine Edge… Forfin kant… + Release Notes Udgivelsesnoter + Remember Settings Husk indstillinger + Reset Essentials Nulstil Grundlæggende + Rounded Rectangle Tool Afrundet rektangelværktøj + Show Channels in Color Vis kanaler i farve + Show Statistics Vis statistik + Source: Kilde: + Status Bar Statuslinje + Tile Horizontally Side om side vandret + Tile Vertically Side om side lodret + Uncached Refresh Opdatering uden cache + Update and Restart Opdatér og genstart + Warming Filter (81) Opvarmningsfilter (81) + Warming Filter (LBA) Opvarmningsfilter (LBA) +@plural {n} document|{n} documents {n} dokument|{n} dokumenter +@plural {n} pixel|{n} pixels {n} pixel|{n} pixels +cameraRaw Before (Y) Før (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 er ikke åben +cameraRaw Cancel Annullér +cameraRaw Cancelled opening {name} Annullerede åbning af {name} +cameraRaw Cancelled {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 +cameraRaw Grain Korn +cameraRaw Highlight Clipping Warning (O) Advarsel om højlysklipning (O) +cameraRaw Highlight Priority Højlysprioritet +cameraRaw Load Settings… Indlæs indstillinger… +cameraRaw Load settings from a Camera Raw .xmp file Indlæs indstillinger fra en Camera Raw .xmp-fil +cameraRaw Loaded Camera Raw settings from {file} Indlæste Camera Raw-indstillinger fra {file} +cameraRaw Noise Reduction Støjreduktion +cameraRaw OK OK +cameraRaw Open Åbn +cameraRaw Opened {name} Åbnede {name} +cameraRaw Paint Overlay Malingsoverlejring +cameraRaw Save Settings… Gem indstillinger… +cameraRaw Save these settings as a Camera Raw .xmp file Gem disse indstillinger som en Camera Raw .xmp-fil +cameraRaw Shadow Clipping Warning (U) Advarsel om skyggeklipning (U) +cameraRaw Sharpening Skærpning +cameraRaw Show Lab Color Readouts Vis Lab-farveaflæsninger +cameraRaw Show Red at 3 o'clock Vis rødt kl. 3 +cameraRaw Show Selected Region Vis markeret område +cameraRaw Show Skin Tone Indicator Vis indikator for hudtone +cameraRaw Show Vectorscope Vis vektorskop +cameraRaw Tone Histogram Tonehistogram +cameraRaw Vignetting Vignettering +cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv new file mode 100644 index 0000000000..ab213849f6 --- /dev/null +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -0,0 +1,4775 @@ +# Finnish (fi) 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. +# +# Combined from the community Finnish catalogs in #5666 and #6961: each row takes the best of their +# translations, with one term per concept across the UI (taso, valinta, maski, liukuväri, +# pehmennys, muunnos, vapaa muunnos, ruudukko, apuviiva, piirtoalusta, kangas, leikkausmaski, +# työtila, sivellin, sekoitustila, säätö, kanava, polku, historia, asiakirja, suodatin, reunaviiva, +# taivutus, edustaväri, tasoasetelma, heittovarjo …). Clean-room: written from the plain meaning of +# the English labels; no proprietary localisation resource was consulted or copied. +# Labels use sentence case, commands are imperatives, and pointer hints say what an action does +# ("Lisää piste napsauttamalla"). Product, format and technology names (PhotoCraft, ArtCraft, +# OpenType, CMYK, Lab …) and key caps keep their spelling; the trailing "…" of dialog commands is +# kept. Numbers follow Finnish style (decimal comma, a space before %). Plural rule: one|other +# (n != 1 -> other); the other form takes the partitive singular ("1 taso", "2 tasoa"). +# Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskin asetukset… + Layer Mask Display Options… Tasomaskin näyttöasetukset… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanavaväri + Color Indicates: Väri ilmaisee: + Solidity: Kiinteys: + Ink Characteristics Painovärin ominaisuudet + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Paperin leveys + Paper Height Paperin korkeus + Rows Rivit + Number Määrä + Margin Marginaali + Top: Yläreuna: + Left: Vasen: + Bottom: Alareuna: + Right: Oikea: + Target: Kohde: + Selected Artboards Valitut piirtoalustat + Center Columns Keskitä sarakkeet + Clear Existing Guides Poista nykyiset apuviivat + Invalid guide layout Virheellinen apuviiva-asettelu + Leave empty for automatic size Jätä tyhjäksi, niin koko määräytyy automaattisesti + 5 Columns, 3 Rows 5 saraketta, 3 riviä + 8 Columns 8 saraketta + 12 Columns 12 saraketta + + File Tiedosto + Edit Muokkaa + Image Kuva + Layer Taso + Type Teksti + Select Valitse + Selected Valittu + Scope Alue + Top Left Vasen yläkulma + Top Right Oikea yläkulma + Bottom Left Vasen alakulma + Bottom Right Oikea alakulma + Filter Suodatin + View Näytä + Window Ikkuna + Help Ohje + +# File + New… Uusi… + New from Clipboard Uusi leikepöydältä + New Uusi + Open… Avaa… + Open Avaa + Open As… Avaa muodossa… + Open EXR Parts… Avaa EXR-osat… + Add beauty precomp from the AOVs Lisää beauty-esikompositio AOV-kanavista + Both Molemmat + Left Vasen + Right Oikea + Open Recent Viimeksi avatut + Clear Recent Files Tyhjennä viimeisimmät tiedostot + Remove from Recent Poista viimeisimmistä + Remove {name} from Recent Poista {name} viimeisimmistä + Close Sulje + Minimize Pienennä + Maximize Suurenna + Restore Palauta + Close All Sulje kaikki + Close Others Sulje muut + Save Tallenna + Save As… Tallenna nimellä… + Save a Copy… Tallenna kopio… + {name} can't keep everything in this document {name} ei voi säilyttää kaikkea tässä asiakirjassa + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Valitse muoto, joka säilyttää kaiken, kuten Photoshop (PSD), tai tallenna kopio. + Save… Tallenna… + Revert Palauta tallennettu versio + Import Tuo + Import Presets Tuo esiasetukset + 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ä… + Package… Paketoi… + Save for Web (Legacy)… Tallenna verkkoa varten (vanha)… + Generate Luo + Image Assets Kuvaresurssit + +# Edit + Undo Kumoa + Redo Tee uudelleen + Fade… Häivytä… + Cut Leikkaa + Copy Kopioi + Copy Merged Kopioi yhdistettynä + Paste Liitä + Paste Special Liitä määräten + Paste Into Liitä valinnan sisään + Paste Outside Liitä valinnan ulkopuolelle + Paste in Place Liitä paikalleen + Clear Tyhjennä + Search Haku + Search… Etsi… + Check Spelling… Tarkista oikeinkirjoitus… + Fill… Täytä… + Stroke… Piirrä reunaviiva… + Content-Aware Fill… Sisältötietoinen täyttö… + Delete and Fill Selection Poista ja täytä valinta + Define Brush Preset… Määritä siveltimen esiasetus… + Define Pattern… Määritä kuvio… + Define Custom Shape… Määritä mukautettu muoto… + Purge Tyhjennä muisti + +# Image + Image Size… Kuvan koko… + Image Size Kuvan koko + Canvas Size… Kankaan koko… + Image Rotation Kuvan kierto + Rotate 90° Clockwise Kierrä 90° myötäpäivään + Rotate 90° Counter Clockwise Kierrä 90° vastapäivään + Rotate 180° Kierrä 180° + Flip Canvas Horizontal Peilaa kangas vaakasuunnassa + Flip Canvas Vertical Peilaa kangas pystysuunnassa + Crop Rajaa + Crop and Straighten Photos Rajaa ja suorista kuvat + Trim… Karsi reunat… + Reveal All Paljasta kaikki + Hide Selection Piilota valinta + Auto Tone Automaattisävy + Auto Contrast Automaattikontrasti + Auto Color Automaattiväri + Adjustments Säädöt + 16 Bits/Channel 16 bittiä/kanava + 32 Bits/Channel 32 bittiä/kanava + 8 Bits/Channel 8 bittiä/kanava + 8 bit 8-bittinen + Integer Kokonaisluku + 16 bit 16-bittinen + 32 bit (float) 32-bittinen (liukuluku) + Floating point Liukuluku + Mode Tila + Grayscale Harmaasävy + RGB Color RGB-väri + CMYK Color CMYK-väri + Lab Color Lab-väri + Indexed Color Indeksoitu väri + Multichannel Monikanava + Bitmap… Bittikartta… + Bitmap Bittikartta + Duotone… Kaksisävy… + Duotone Kaksisävy + Trap… Trappaus… + +# Layer + Layer via Copy Taso kopioimalla + Layer via Cut Taso leikkaamalla + Duplicate Layer… Monista taso… + Duplicate… Monista… + Delete Poista + Delete Layer Poista taso + Delete Layers Poista tasot + Rename Layer Nimeä taso uudelleen + Layer From Background… Taso taustasta… + Layer from Background… Taso taustasta… + Background from Layer Tausta tasosta + Background From Layer Tausta tasosta + Group Layers Ryhmittele tasot + Group… Ryhmä… + Ungroup Layers Pura tasojen ryhmittely + Group from Layers… Ryhmä tasoista… + New Group from Layers… Uusi ryhmä tasoista… + Hide Layers Piilota tasot + Arrange Järjestä + Bring to Front Tuo etualalle + Bring Forward Siirrä eteenpäin + Send Backward Siirrä taaksepäin + Send to Back Vie taustalle + Convert to Smart Object Muunna älykkääksi objektiksi + Convert for Smart Filters Muunna älykkäitä suodattimia varten + New Smart Object via Copy Uusi älykäs objekti kopioimalla + Edit Contents Muokkaa sisältöä + Replace Contents… Korvaa sisältö… + Export Contents… Vie sisältö… + Rasterize Rasteroi + Rasterize Type Rasteroi teksti + Rasterize Shape Rasteroi muoto + Rasterize Layer Rasteroi taso + Rasterize Layers Rasteroi tasot + New Adjustment Layer Uusi säätötaso + New Fill Layer Uusi täyttötaso + Layer Mask Tasomaski + Add Layer Mask Lisää tasomaski + Delete Layer Mask Poista tasomaski + Enable Layer Mask Ota tasomaski käyttöön + Disable Layer Mask Ota tasomaski pois käytöstä + Apply Layer Mask Käytä tasomaskia + Add Mask to Selection Lisää maski valintaan + Subtract Mask from Selection Vähennä maski valinnasta + Intersect Mask with Selection Leikkaa maski valinnan kanssa + Link Layer Mask Linkitä tasomaski + Unlink Layer Mask Poista tasomaskin linkitys + Vector Mask Vektorimaski + Add Vector Mask Lisää vektorimaski + Delete Vector Mask Poista vektorimaski + Enable Vector Mask Ota vektorimaski käyttöön + Link Vector Mask Linkitä vektorimaski + Create Clipping Mask Luo leikkausmaski + Release Clipping Mask Vapauta leikkausmaski + New Shape Layer Uusi muototaso + New Type Layer Uusi tekstitaso + New Video Layer from File… Uusi videotaso tiedostosta… + New Blank Video Layer Uusi tyhjä videotaso + Smart Filters Älykkäät suodattimet + Delete Smart Filters Poista älykkäät suodattimet + Enable Smart Filters Ota älykkäät suodattimet käyttöön + Disable Smart Filters Ota älykkäät suodattimet pois käytöstä + Clear Smart Filters Tyhjennä älykkäät suodattimet + Delete Filter Mask Poista suodattimen maski + Disable Filter Mask Ota suodattimen maski pois käytöstä + New Layer Based Slice Uusi tasopohjainen viipale + Layer Style Tasotyyli + Blending Options… Sekoitusasetukset… + Blending Options Sekoitusasetukset + Drop Shadow… Heittovarjo… + Inner Shadow… Sisävarjo… + Outer Glow… Ulkohehku… + Inner Glow… Sisähehku… + Bevel & Emboss… Viiste ja kohokuvio… + Use Global Light Käytä yleistä valoa + Overprint Päällepainatus + Satin… Satiini… + Color Overlay… Väripeitto… + Gradient Overlay… Liukuväripeitto… + Pattern Overlay… Kuviopeitto… + Bevel & Emboss Viiste ja kohokuvio + Inner Shadow Sisävarjo + Inner Glow Sisähehku + Satin Satiini + Color Overlay Väripeitto + Gradient Overlay Liukuväripeitto + Pattern Overlay Kuviopeitto + Outer Glow Ulkohehku + Drop Shadow Heittovarjo + Copy Layer Style Kopioi tasotyyli + Paste Layer Style Liitä tasotyyli + Clear Layer Style Tyhjennä tasotyyli + Layer Content Options… Tason sisällön asetukset… + Scale Effects… Skaalaa tehosteet… + New Layer… Uusi taso… + Delete Layer… Poista taso… + +# Type + Font Fontti + Font Preview Size Fontin esikatselukoko + Orientation Suunta + Horizontal Vaakasuuntainen + Vertical Pystysuuntainen + Rasterize Type Layer Rasteroi tekstitaso + Create Work Path Luo työpolku + Convert to Shape Muunna muodoksi + Convert to Paragraph Text Muunna kappaletekstiksi + Convert to Point Text Muunna pistetekstiksi + Warp Text… Taivuta tekstiä… + Character Panel Merkkipaneeli + Paragraph Panel Kappalepaneeli + Check Spelling Tarkista oikeinkirjoitus + Language Options Kieliasetukset + Find and Replace Text… Etsi ja korvaa teksti… + Add to Dictionary Lisää sanakirjaan + No Spelling Suggestions Ei oikeinkirjoitusehdotuksia + Paste Lorem Ipsum Liitä Lorem ipsum -teksti + Glyphs Panel Glyfipaneeli + Type Styles Tekstityylit + Character Styles Merkkityylit + Paragraph Styles Kappaletyylit + Load Default Type Styles Lataa tekstin oletustyylit + Save Default Type Styles Tallenna tekstin oletustyylit + Type… Teksti… + +# Select + 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… +# Select and Mask dialog labels + Smart Radius Älykäs säde + Shift Edge Reunan siirto + Decontaminate Puhdista värit + Output Tulos + Refine Edge Tarkenna reunaa + Select Subject Valitse kohde + Sky Taivas + Focus Area… Tarkennusalue… + Subject Kohde + Color Range Värialue + Similar Valitse samankaltaiset + Grow Kasvata + Select Similar Colors Valitse samankaltaiset värit + Load Selection… Lataa valinta… + Save Selection… Tallenna valinta… + New Selection Uusi valinta + Select All Valitse kaikki + Deselect All Poista kaikki valinnat + Deselect Layers Poista tasojen valinta + Find Layers Etsi tasoja + Isolate Layers Eristä tasot + Select Linked Layers Valitse linkitetyt tasot + All Layers Kaikki tasot + Current & Below Nykyinen ja alemmat + Current Layer Nykyinen taso + Refine Strokes Tarkenna vetoja + Edits Muokkaukset + +# Filter + Last Filter Viimeisin suodatin + Filter Gallery… Suodatingalleria… + Liquify… Nesteytä… + Camera Raw Filter… Camera Raw -suodatin… + Adaptive Wide Angle… Mukautuva laajakulma… + Lens Correction… Objektiivin korjaus… + Vanishing Point… Pakopiste… + Oil Paint… Öljymaalaus… + Blur Gallery Sumennusgalleria + Field Blur… Kenttäsumennus… +# Field Blur pins (#3373) + No pin selected Nastaa ei ole valittu + 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. Lisää nasta napsauttamalla kuvaa. Siirrä nastaa vetämällä sitä ja muuta sumennusta vetämällä sen rengasta. Delete poistaa valitun nastan. + 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. Muuta kokoa ja kierrä vetämällä ääriviivan kahvoja, pyöristä kulmia vetämällä neliötä ja aseta sumennuksen alkukohta vetämällä sisäpuolen pisteitä. + 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. Siirrä kaistaa vetämällä nastaa tai muuta sen sumennusta vetämällä rengasta. Levennä terävää kaistaa vetämällä yhtenäisiä viivoja, aseta siirtymä vetämällä katkoviivoja ja kierrä vetämällä pisteitä. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Muuta kokoa ja kierrä vetämällä ääriviivan kahvoja; rengas asettaa sumennuksen kulman. + Blur Angle Sumennuskulma + Iris Blur… Iirissumennus… + Tilt-Shift… Kallistus ja siirto… + Path Blur… Polkusumennus… + Spin Blur… Pyörähdyssumennus… + Auto-Align Layers… Kohdista tasot automaattisesti… + Auto-Blend Layers… Sulauta tasot automaattisesti… + Photomerge… Photomerge… + Content-Aware Scale Sisältötietoinen skaalaus + Adaptive Wide Angle Mukautuva laajakulma + Apply Image… Käytä kuvaa… + Calculations… Laskutoimitukset… + Crystallize… Kiteytä… + Facet Fasetti + Fragment Sirpale + Mosaic… Mosaiikki… + Pointillize… Pointillismi… + Chalk & Charcoal Liitu ja hiili + Charcoal Hiili + Chrome Kromi + Conté Crayon Conté-liitu + Graphic Pen Grafiikkakynä + Halftone Pattern Rasterikuvio + Note Paper Muistiopaperi + Photocopy Valokopio + Plaster Kipsi + Reticulation Verkkoutuminen + Stamp Leimasin + Torn Edges Revityt reunat + Water Paper Akvarellipaperi + Clouds Pilvet + Difference Clouds Erotuspilvet + Fibers… Kuidut… + Lens Flare… Linssiheijastus… + Picture Frame… Kuvakehys… + Tree… Puu… + Flame… Liekit… + Sharpen Edges Terävöitä reunat + Dust & Scratches… Pöly ja naarmut… + Noise Kohina + Add Noise… Lisää kohinaa… + Reduce Noise… Vähennä kohinaa… + Despeckle Poista täplät + Median… Mediaani… + Median Mediaani + Maximum… Maksimi… + Maximum Maksimi + Minimum… Minimi… + Minimum Minimi + Custom… Mukautettu… + High Pass… Ylipäästö… + Emboss… Kohokuvio… + Extrude… Pursota… + Find Edges Etsi reunat + Solarize Solarisoi + Tiles… Laatat… + Trace Contour… Jäljitä ääriviiva… + Wind… Tuuli… + Offset… Siirtymä… + Pinch… Nipistä… + Polar Coordinates… Napakoordinaatit… + Ripple… Väreily… + Shear… Vinouta… + Spherize… Palloita… + Twirl… Pyörre… + Wave… Aalto… + ZigZag… Siksak… + Average Keskiarvo + Blur More Sumenna lisää + Box Blur… Laatikkosumennus… + Gaussian Blur… Gauss-sumennus… + Motion Blur… Liikesumennus… + Radial Blur… Säteittäinen sumennus… + Shape Blur… Muotosumennus… + Smart Blur… Älykäs sumennus… + Surface Blur… Pintasumennus… + Sharpen Terävöitä + Sharpen More Terävöitä lisää + Unsharp Mask… Epäterävä maski… + Smart Sharpen… Älykäs terävöitys… + Diffuse… Hajauta… + Mezzotint… Mezzotinto… + Color Halftone… Värirasteri… + Layer… Taso… + +# View + Proof Setup Koevedoksen asetukset + Proof Colors Koevedosvärit + Pixel Aspect Ratio Pikselin kuvasuhde + Pixel Aspect Ratio Correction Pikselin kuvasuhteen korjaus + Zoom In Lähennä + Zoom Out Loitonna + Fit on Screen Sovita näyttöön + 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 Tulostuskoko + Screen Mode Näyttötila + Standard Screen Mode Tavallinen näyttötila + Full Screen Mode With Menu Bar Koko näytön tila valikkorivillä + Full Screen Mode Koko näytön tila + Extras Apuvälineet + Show Näytä + Rulers Viivaimet + Grid Ruudukko + Guides Apuviivat + Slices Viipaleet + Snap Tartu + Snap To Tartu kohteeseen + Lock Guides Lukitse apuviivat + Clear Guides Poista apuviivat + New Guide… Uusi apuviiva… + Lock Slices Lukitse viipaleet + Clear Slices Poista viipaleet + New Guide Layout… Uusi apuviiva-asettelu… + New Guides From Shape Uudet apuviivat muodosta + Canvas rotation Kankaan kierto + Zoom in Lähennä + Zoom out Loitonna + +# Window + Cascade Limittäin + Tile Rinnakkain + 2-up Horizontal 2 allekkain + 3-up Horizontal 3 allekkain + 2-up Vertical 2 rinnakkain + 3-up Vertical 3 rinnakkain + 3-up Stacked 3, kaksi pinossa + 4-up 4 ruudukossa + 6-up 6 ruudukossa + Consolidate All to Tabs Kokoa kaikki välilehdiksi + Match Zoom Täsmäytä zoomaus + 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 + Actions Toiminnot + Brush Settings Siveltimen asetukset + Brushes Siveltimet + Channels Kanavat + Character Merkki + Clone Source Kloonauslähde + Color Mixer Värisekoitin + Glyphs Glyfit + Gradients Liukuvärit + Histogram Histogrammi + History Historia + Info Tiedot + Layer Comps Tasoasetelmat + Layers Tasot + Measurement Log Mittausloki + Notes Muistiinpanot + Navigator Navigaattori + Paragraph Kappale + Paths Polut + Patterns Kuviot + Properties Ominaisuudet + Styles Tyylit + Swatches Värimallit + Timeline Aikajana + Tool Presets Työkalujen esiasetukset + Tools Työkalut + Libraries Kirjastot + 3D 3D + Toolbars Työkalurivit + Hide All Piilota kaikki + +# Help + About PhotoCraft Tietoja PhotoCraftista + PhotoCraft Website PhotoCraft-verkkosivusto + PhotoCraft on GitHub PhotoCraft GitHubissa + Report an Issue… Ilmoita ongelmasta… + ArtCraft Website ArtCraft-verkkosivusto + Join the ArtCraft Discord… Liity ArtCraftin Discordiin… + System Info… Järjestelmän tiedot… + +# Tools and options bar + Move Siirto + Marquee Valintakehys + Lasso Lasso + Wand Sauva + Eyedropper Pipetti + Brush Sivellin + Block Lohko + Pencil Lyijykynä + Eraser Pyyhekumi + Clone Stamp Kloonausleimasin + Healing Korjaus + Blur Sumennus + Dodge Vaalennus + Burn Tummennus + Gradient Liukuväri + Shape Muoto + Pen Kynä + Hand Käsi + Zoom Zoomaus + Sampler Näytteenotin + Ruler Tool Viivaintyökalu + Count Tool Laskentatyökalu + Note tool Muistiinpanotyökalu + Quick Selection Pikavalinta + Magnetic Lasso Magneettinen lasso + Mixer Brush Sekoitussivellin + Mixer Brush Tool Sekoitussivellintyökalu + Color Replacement Tool Värinkorvaustyökalu + History Brush Historiasivellin + Art History Brush Taidehistoriasivellin + Pattern Stamp Kuvioleimasin + Spot Healing Pistekorjaus + Red Eye Punasilmäisyyden poisto + Patch Paikkaus + Content-Aware Move Sisältötietoinen siirto + Rotate View Kierrä näkymää + Perspective Crop Perspektiivirajaus + Color Replacement Värinkorvaus + Dodge/Burn Vaalenna/tummenna + Foreground Edustaväri + 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) + Brush Tool Options Sivellintyökalun asetukset + Opacity Peittävyys + Flow Virtaus + Smoothing Tasoitus + Size Koko + Hardness Kovuus + Angle: Kulma: + Roundness: Pyöreys: + Spacing Välistys + Dynamics Dynamiikka + Pressure Paine + Tilt Kallistus + Rotation Kierto + Align Tasaa + Aligned Kohdistettu + +# Panels, dialogs and common widgets + OK OK + Cancel Peruuta + Cancel (Esc) Peruuta (Esc) + Manage submitted jobs in the system print queue Hallitse lähetettyjä töitä järjestelmän tulostusjonossa + Done Valmis + Apply Käytä + Reset Nollaa + Reset Section Nollaa osio + Reset All to Defaults Palauta kaikki oletuksiin + Import Shortcuts… Tuo pikanäppäimet… + Load a keyboard shortcut set saved as .kys Lataa .kys-muodossa tallennettu pikanäppäinsarja + Imported {n} shortcuts from {file}. Click OK to keep them. Tuotiin {n} pikanäppäintä tiedostosta {file}. Säilytä ne napsauttamalla OK. + {n} commands are not in PhotoCraft. {n} komentoa ei ole PhotoCraftissa. + Keyboard shortcuts imported Pikanäppäimet tuotu + 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. Pikanäppäinsarjasi {set} on käytössä: {n} pikanäppäintä poikkeaa oletuksista. Muokkaa › Pikanäppäimet näyttää ne; Palauta kaikki oletuksiin kumoaa tämän. + Import keyboard shortcuts? Tuodaanko pikanäppäimet? + 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 löysi tältä tietokoneelta toisen kuvankäsittelyohjelman pikanäppäinsarjan {set}: {n} sen pikanäppäintä poikkeaa PhotoCraftin pikanäppäimistä. Tuodaanko se? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Muokkaa › Pikanäppäimet › Palauta kaikki oletuksiin kumoaa tuonnin. + Don't Import Älä tuo + The keyboard shortcut set was not imported: {error} Pikanäppäinsarjaa ei tuotu: {error} + Defaults restored. Oletukset palautettu. + Restore Defaults Palauta oletukset + Restore original size Palauta alkuperäinen koko + Use Default Käytä oletusta + Dismiss Sulje + Preview Esikatselu + Options Asetukset + Settings… Asetukset… + Rename… Nimeä uudelleen… + Load… Lataa… + Define… Määritä… + Custom Mukautettu + Default Oletus + None Ei mitään + Add Lisää + Remove Poista + Remove Background Poista tausta + Rename Nimeä uudelleen + Ignore Ohita + Ignore All Ohita kaikki + Change Muuta + Change All Muuta kaikki + Next frame Seuraava ruutu + Previous frame Edellinen ruutu + Play Toista + Pause Tauko + Stop Pysäytä + Begin recording Aloita nauhoitus + Stop playing/recording Lopeta toisto/nauhoitus + Record ● a sequence of edits, then play ▶ it on any document. Nauhoita ● muokkaussarja ja toista ▶ se missä tahansa asiakirjassa. + Create new action Luo uusi toiminto + Play selection Toista valittu + Create new set Luo uusi sarja + Export selected measurements (CSV) Vie valitut mittaukset (CSV) + Record Measurements Tallenna mittaukset + Delete selected measurements Poista valitut mittaukset + Select Data Points… Valitse datapisteet… + Set Measurement Scale… Aseta mittakaava… + Use Measurement Scale Käytä mittakaavaa + +# Preferences dialog + Preferences Asetukset + General… Yleiset… + Interface… Käyttöliittymä… + File Handling… Tiedostojen käsittely… + Performance… Suorituskyky… + Cursors… Osoittimet… + Transparency & Gamut… Läpinäkyvyys ja väriskaala… + Units & Rulers… Yksiköt ja viivaimet… + Guides, Grid & Slices… Apuviivat, ruudukko ja viipaleet… + Plug-ins… Laajennukset… + Technology Previews Teknologian esikatselut + Scratch Disks… Työlevyt… + Export Preferences… Vientiasetukset… + Keyboard Shortcuts… Pikanäppäimet… + Menus… Valikot… + Export/Import Presets… Vie/tuo esiasetuksia… + Additional plugins folder Lisälaajennusten kansio + Install Plug-in… Asenna laajennus… + Export Presets Vie esiasetukset + Import brushes (.abr) Tuo siveltimiä (.abr) + Migrate Presets Siirrä esiasetukset + Thumbnail size (smallest: list) Pikkukuvan koko (pienin: luettelo) + Reset GPU Backend Nollaa GPU-taustajärjestelmä + Applies at next launch. Astuu voimaan seuraavalla käynnistyksellä. + +# Document and canvas status bar + Untitled Nimetön + Untitled-1 Nimetön-1 + Doc: {flat}/{layered} Asiak.: {flat}/{layered} + Scratch: {size} Työlevy: {size} + Efficiency: 100% Tehokkuus: 100 % + Dimensions: Mitat: + Width Leveys + Slant Kaltevuus + Height Korkeus + Width: Leveys: + Height: Korkeus: + Resolution Resoluutio + Resolution: Resoluutio: + Resolution: {dpi} pixels/inch Resoluutio: {dpi} pikseliä/tuuma + {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: Pikselit: + Pixel Length: Pikselipituus: + Logical Length: Looginen pituus: + Logical Units: Loogiset yksiköt: + Length: Pituus: + Interpolation: Interpolointi: + Resample Uudelleennäytteistä + Constrain proportions Säilytä mittasuhteet + Link width and height Linkitä leveys ja korkeus + Maintain aspect ratio Säilytä kuvasuhde + Current Size Nykyinen koko + New Size Uusi koko + Fit Sovita + Percent Prosentti + Pixels/Inch Pikseliä/tuuma + Pixels/Centimeter Pikseliä/senttimetri + Relative Suhteellinen + Canvas extension color: Kankaan laajennusväri: + Anchor: Ankkuri: + Both Axes Molemmat akselit + Horizontally Vaakasuunnassa + Vertically Pystysuunnassa + +# Layers panel + Layers to Files… Tasot tiedostoiksi… + Duplicate Layers… Monista tasot… + Group name Ryhmän nimi + New Group Uusi ryhmä + Layer Comp Tasoasetelma + Create new layer Luo uusi taso + Create new group Luo uusi ryhmä + Create new channel Luo uusi kanava + Create new path Luo uusi polku + Toggle visibility Vaihda näkyvyyttä + Clipped Leikattu + Group · {n} layer Ryhmä · {n} taso + Lock: keep these settings when picking another preset Lukitse: säilytä nämä asetukset, kun valitaan toinen esiasetus + Unlock: picking a preset replaces these settings Avaa lukitus: esiasetuksen valitseminen korvaa nämä asetukset + Show the layer's effects ({key}-click: all layers) Näytä tason tehosteet ({key}-napsautus: kaikki tasot) + Hide the layer's effects ({key}-click: all layers) Piilota tason tehosteet ({key}-napsautus: kaikki tasot) + Expand group ({key}-click: all groups) Laajenna ryhmä ({key}-napsautus: kaikki ryhmät) + Collapse group ({key}-click: all groups) Tiivistä ryhmä ({key}-napsautus: kaikki ryhmät) + Effects Tehosteet + Blending Sekoitus + Blend If Sekoita, jos + General Blend Yleinen sekoitus + Advanced Blending Sekoituksen lisäasetukset + Opacity: Peittävyys: + Fill: Täyttö: + Lock: Lukitse: + Smart Filter Älykäs suodatin + Fill Content Täyttösisältö + Adjustment Preview Säädön esikatselu + +# Channels panel + Channel: Kanava: + Channel Mixer… Kanavasekoitin… + Output Channel: Lähtökanava: + RGB RGB + Red Punainen + Green Vihreä + Blue Sininen + Alpha Alfa + Load channel as selection Lataa kanava valintana + Save selection as channel Tallenna valinta kanavana + Delete current channel Poista nykyinen kanava + Spot Channel Spottivärikanava + Mask All Maskaa kaikki + Mask All Objects Maskaa kaikki objektit + +# Color picker and swatches + Color Väri + Color: Väri: + Colors Värit + HSB/HSL HSB/HSL + No Color Ei väriä + Set shape fill type Aseta muodon täyttötyyppi + Set shape stroke type Aseta muodon reunaviivatyyppi + Set the text color Aseta tekstin väri + Create warped text Luo taivutettu teksti + Only Web Colors Vain verkkovärit + Overlay Color Peiteväri + Overlay Opacity Peitteen peittävyys + Glow Color Hehkun väri + Web Snap Tarttuminen verkkoväreihin + +# Filters and adjustments dialogs + Brightness Kirkkaus + Contrast Kontrasti + Brightness/Contrast… Kirkkaus/kontrasti… + Levels… Sävytasot… + Curves… Käyrät… + Curve Käyrä + Exposure… Valotus… + Exposure Valotus + Vibrance… Eloisuus… + Vibrance Eloisuus + Hue/Saturation… Sävy/värikylläisyys… + Hue Sävy + Saturation Värikylläisyys + Lightness Vaaleus + Color Balance… Väritasapaino… + Black & White… Mustavalkoinen… + Photo Filter… Kuvasuodatin… + Color Lookup… Värihakutaulukko… + Color Lookup Tables… Värihakutaulukot… + 3D LUT File 3D LUT -tiedosto + Load 3D LUT… Lataa 3D LUT… + Invert… Invertoi… + Invert Invertoi + Invert has no settings. Invertoinnilla ei ole asetuksia. + Threshold… Kynnysarvo… + Threshold Level Kynnystaso + Posterize… Posterisoi… + Gradient Map… Liukuvärikartta… + Selective Color… Valikoiva väri… + Match Color… Täsmäytä värit… + Replace Color… Korvaa väri… + Equalize Tasaa + HDR Toning… HDR-sävytys… + Shadows/Highlights… Varjot/korostukset… + Amount Määrä + Radius Säde + Radius: Säde: + Tolerance: Toleranssi: + Density Tiheys + Density: Tiheys: + Feather Pehmennä reunat + Feather… Pehmennä reunoja… + Anti-alias Antialiasointi + Anti-Alias Antialiasointi + Contiguous Yhtenäinen + Sample All Layers Ota näyte kaikilta tasoilta + Protect Foreground Color Suojaa edustaväri + Matte Mattaväri + Matting Reunojen puhdistus + Color Decontaminate… Puhdista värit… + Remove Black Matte Poista musta reunus + Remove White Matte Poista valkoinen reunus + Defringe… Poista reunaväristymät… + Source Lähde + Working CMYK Työ-CMYK + Working Cyan Plate Työ-syaanilevy + Working Magenta Plate Työ-magentalevy + Working Yellow Plate Työ-keltalevy + Working Black Plate Työ-mustalevy + Working Gray Plate Harmaa työlevy + +# Camera Raw and lens dialogs + Basic Perusasetukset + Detail Yksityiskohdat + HSL/Grayscale HSL/harmaasävy + Tone Curve Sävykäyrä + Split Toning Jaettu sävytys + Lens Corrections Objektiivikorjaukset + Camera Calibration Kameran kalibrointi + Presets Esiasetukset + Snapshots Tilannevedokset + Temperature Lämpötila + Tint Sävytys + Exposure: Valotus: + Recovery Palautus + Fill Light Täytevalo + Blacks Mustat + Clarity Selkeys + Convert to Grayscale Muunna harmaasävyiseksi + Auto Auto + Auto collapse icon panels Tiivistä kuvakepaneelit automaattisesti + Auto show hidden panels Näytä piilotetut paneelit automaattisesti + Auto show home screen Näytä aloitusnäyttö automaattisesti + Sharpness Terävyys + Noise Reduction Kohinan vaimennus + Color Noise Reduction Värikohinan vähennys + Luminance Detail Luminanssin yksityiskohdat + Color Detail Värin yksityiskohdat + Luminance Smoothness Luminanssin tasaisuus + Vignetting Vinjetointi + White Balance: As Shot Valkotasapaino: kuvauksen mukainen + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw ei ole auki + Dehaze Usvanpoisto + Texture Tekstuuri + Focal Length Polttoväli + Make Valmistaja + Model Malli + Lens Objektiivi + F F + ISO ISO + Focal Length35mm Polttoväli 35 mm + Aspect Ratio Kuvasuhde + Time Aika + Artist Taiteilija + Copyright Tekijänoikeus + Enter description… Kirjoita kuvaus… + Adobe RGB (1998) compatible Adobe RGB (1998) -yhteensopiva + Gray Gamma 2.2 Harmaa gamma 2,2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Alan standardi + Web Verkko + Internet Standard RGB Internetin standardi-RGB + Legacy Macintosh RGB Vanha Macintosh RGB + Monitor RGB Näytön RGB + Windows LCD Windows LCD + Coated CMYK CMYK, päällystetty paperi + Uncoated CMYK Päällystämätön CMYK + Newspaper Sanomalehti + Working Spaces Työväriavaruudet + Color Management Policies Värinhallintakäytännöt + Assign working space Määritä työavaruus + Leave as is (don't color manage) Jätä ennalleen (ei värinhallintaa) + Discard the embedded profile (don't color manage) Hylkää upotettu profiili (ei värinhallintaa) + Embed Color Profile Upota väriprofiili + Assign Profile… Määritä profiili… + Convert to Profile… Muunna profiiliksi… + PhotoCraft Manages Colors PhotoCraft hallitsee värejä + Printer Manages Colors Tulostin hallitsee värejä + (print to the printer) (tulosta tulostimelle) + Absolute Colorimetric Absoluuttinen kolorimetrinen + Relative Colorimetric Suhteellinen kolorimetrinen + Perceptual Havaintoon perustuva + Black Point Compensation Mustan pisteen kompensointi + Use Legacy Käytä vanhaa menetelmää + Dither Ditteröinti + No Dither Ei ditteröintiä + Diffusion Diffuusio + Pattern Kuvio + Fuzziness: Sumeus: + Range: Alue: + Select: Valitse: + Detect Faces Tunnista kasvot + Localize Color Clusters Paikallista väriryhmät + Sample: Näyte: + Add to Sample Lisää näytteeseen + Subtract from Sample Vähennä näytteestä + Selections Valinnat + Duplicated selection name Valinnan nimi on jo käytössä + Operation Toiminto + New channel only Vain uusi kanava + Masked Areas Maskatut alueet + Selected Areas Valitut alueet + Spot Color Spottiväri + Color indicates Väri osoittaa + +# Adjustments panel + Curves Käyrät + Levels Sävytasot + Color Balance Väritasapaino + Black & White Mustavalkoinen + Photo Filter Kuvasuodatin + Color Lookup Värihakutaulukko + Threshold Kynnysarvo + Posterize Posterisoi + Brightness/Contrast Kirkkaus/kontrasti + Channel Mixer Kanavasekoitin + Gradient Map Liukuvärikartta + Selective Color Valikoiva väri + +# History panel + History Log… Historialoki… + Create Snapshot Luo tilannevedos + Delete Snapshot Poista tilannevedos + New Snapshot Uusi tilannevedos + Snapshot 1 Tilannevedos 1 + Step Forward Askel eteenpäin + Step Backward Askel taaksepäin + Histories Historiat + Nonlinear History Epälineaarinen historia + Set New Snapshot as Default Aseta uusi tilannevedos oletukseksi + Automatically Create First Snapshot Luo ensimmäinen tilannevedos automaattisesti + Automatically Create New Snapshot When Saving Luo uusi tilannevedos automaattisesti tallennettaessa + Show New Snapshot Dialog by Default Näytä uuden tilannevedoksen valintaikkuna oletuksena + Clear History Tyhjennä historia + +# Navigator panel + Panel Options… Paneelin asetukset… + View menu Näytä-valikko + Keep view in sync Pidä näkymä synkronoituna + {zoom}% {zoom} % + +# Brush Settings panel + Brush name Siveltimen nimi + Brush Preview Siveltimen esikatselu + Brush Tip Shape Siveltimen kärjen muoto + Shape Dynamics Muodon dynamiikka + Scattering Sironta + Dual Brush Kaksoissivellin + Color Dynamics Väridynamiikka + Transfer Siirto + Brush Pose Siveltimen asento + Mouse Controls Hiiren ohjaimet + Wet Edges Märät reunat + Build-up Kertyminen + Protect Texture Suojaa tekstuuri + Blend Mode Sekoitustila + Size Jitter Koon vaihtelu + Minimum Diameter Vähimmäishalkaisija + Tilt Scale Kallistuksen skaalaus + Angle Jitter Kulman vaihtelu + Roundness Jitter Pyöreyden vaihtelu + Minimum Roundness Vähimmäispyöreys + Flip X Jitter X-peilauksen vaihtelu + Flip Y Jitter Y-peilauksen vaihtelu + Brush Projection Siveltimen projektio + Control: Ohjaus: + Off: the speed of the pointer sets the spacing Pois päältä: osoittimen nopeus määrittää välistyksen + Fade steps Häivytysvaiheet + Pen Pressure Kynän paine + Pen Tilt Kynän kallistus + Stylus Wheel Piirtokynän rulla + Initial Direction of Stroke Vedon alkusuunta + Direction of Stroke Vedon suunta + Opacity Jitter Peittävyyden vaihtelu + Flow Jitter Virtauksen vaihtelu + Humidity Kosteus + Load Lataa + Mix Sekoitus + Auto Clean Brush Puhdista sivellin automaattisesti + Wet Märkä + Current Brush Load Siveltimessä oleva maali + Load Brush Lisää maalia siveltimeen + Clean Brush Puhdista sivellin + Load the brush after each stroke Lisää maalia siveltimeen jokaisen vedon jälkeen + Clean the brush after each stroke Puhdista sivellin jokaisen vedon jälkeen + Rate Nopeus + Airbrush: keeps depositing paint while the pointer rests. Ilmasivellin: lisää maalia, kun osoitin on paikallaan. + Pulled String Mode Vedettävän narun tila + Catch-up on Stroke End Ota osoitin kiinni vedon lopussa + Adjust for Zoom Säädä zoomauksen mukaan + Apply Per Tip Käytä kärkikohtaisesti + Hardness: Kovuus: + Spacing: Välistys: + Size: Koko: + Flip X Peilaa X + Flip Y Peilaa Y + Brush Tip Siveltimen kärki + Diameter: Halkaisija: + New Layer from Brush Uusi taso siveltimestä + Rename Brush… Nimeä sivellin uudelleen… + Delete Brush Poista sivellin + Import Brushes… Tuo siveltimiä… + Import Brushes… (.abr or image) Tuo siveltimiä… (.abr tai kuva) + Import Brush from Image… Tuo sivellin kuvasta… + Gradient Preset Options Liukuvärin esiasetusten asetukset + Import .GRD Tuo .GRD + Choose a gradient file (.grd). Valitse liukuväritiedosto (.grd). + Brush Name Siveltimen nimi + Brush Stroke Siveltimenveto + New Brush Preset… Uusi siveltimen esiasetus… + Capture Brush Size in Preset Sisällytä siveltimen koko esiasetukseen + Include Tool Settings Sisällytä työkalun asetukset + Include Color Sisällytä väri + Enter a name for the brush preset Syötä nimi siveltimen esiasetukselle + A brush preset with this name already exists Tällä nimellä on jo olemassa siveltimen esiasetus + Brush Preset Options Siveltimen esiasetuksen asetukset + New Brush Uusi sivellin + Create new brush from these settings Luo uusi sivellin näistä asetuksista + Update the current brush with these settings Päivitä nykyinen sivellin näillä asetuksilla + Reset the brush to the defaults Palauta sivellin oletuksiin + No brush presets Ei siveltimen esiasetuksia + Override Pressure Pakota paine + Override Tilt Pakota kallistus + Override Tilt X Pakota kallistus X + Override Tilt Y Pakota kallistus Y + Override Rotation Pakota kierto + Apply the pen's tilt and rotation to the tip shape Käytä kynän kallistusta ja kiertoa kärjen muotoon + +# Tool presets + Preset Type Esiasetustyyppi + Preset Manager… Esiasetusten hallinta… + Rename Tool Preset… Nimeä työkalun esiasetus uudelleen… + Delete Tool Preset Poista työkalun esiasetus + New Tool Preset Uusi työkalun esiasetus + Create new tool preset Luo uusi työkalun esiasetus + Current Tool Only Vain nykyinen työkalu + No tool presets. Ei työkalujen esiasetuksia. + No presets for the current tool. Nykyiselle työkalulle ei ole esiasetuksia. + No presets of this type. Tämän tyyppisiä esiasetuksia ei ole. + Preset: Esiasetus: + Preset Esiasetus + Presets: Esiasetukset: + +# Paragraph and character panels + Font Size: Fonttikoko: + Font size Fonttikoko + Type: Tyyppi: + Style Tyyli + Style: Normal Tyyli: normaali + Tracking Kirjainväli + Kerning Parivälistys + Leading Riviväli + Auto-Leading Automaattinen riviväli + Scale Horizontally Skaalaa vaakasuunnassa + Scale Vertically Skaalaa pystysuunnassa + Baseline Shift Perusviivan siirto + Language Kieli + Anti-aliasing method Antialiasointimenetelmä + Sharp Terävä + Crisp Selkeä + Strong Voimakas + Smooth Pehmeä + Mac LCD Mac LCD + Mac Sharp Mac terävä + Mac Crisp Mac selkeä + Mac Strong Mac vahva + Mac Smooth Mac pehmeä + Faux Bold Keinolihavointi + Faux Italic Keinokursivointi + All Caps Isot kirjaimet + Small Caps Kapiteelit + Superscript Yläindeksi + Subscript Alaindeksi + Underline Alleviivaus + Strikethrough Yliviivaus + Oldstyle Tekstinumerot + Ligature Ligatuuri + Alternative Vaihtoehtoinen + Hyphenate Tavuta + Roman Normaali + Italic Kursivoitu + Bold Lihavoitu + Bold Italic Lihavoitu kursivoitu + Light Vaalea + Medium Keskikokoinen + Black Musta + Thin Ohut + Ultra Light Ultrakevyt + Extra Light Erittäin kevyt + Semi Bold Puolilihava + Extra Bold Erittäin lihava + Ultra Bold Ultralihava + Condensed Tiivistetty + Left align text Tasaa teksti vasemmalle + Center text Keskitä teksti + Right align text Tasaa teksti oikealle + Justify last left Tasaa molemmat reunat, viimeinen rivi vasemmalle + Justify last centered Tasaa molemmat reunat, viimeinen rivi keskitetty + Justify last right Tasaa molemmat reunat, viimeinen rivi oikealle + Justify all Tasaa kaikki rivit + Indent Left Vasen sisennys + Indent Right Oikea sisennys + First Line Indent Ensimmäisen rivin sisennys + Space Before Väli ennen + Space After Väli jälkeen + Hanging Punctuation Riippuva välimerkitys + Roman Hanging Punctuation Latinalainen riippuva välimerkitys + Avoid Widows/Orphans Vältä leski- ja orporivejä + Compose Sommittele + Adobe Single-line Composer Adobe yksirivinen asettelija + Adobe Every-line Composer Adobe kaikkien rivien asettelija + Justification Tasaus + Word Spacing Sanaväli + Letter Spacing Merkkiväli + Glyph Scaling Glyfien skaalaus + Auto-leading % Automaattinen riviväli % + Single Word Justification Yksittäisen sanan tasaus + Alignment Tasaus + Indent First Ensimmäisen rivin sisennys + Center Keskitä + Justify Tasaa + +# Glyphs panel + Glyph Glyfi + Select a type layer or click into text to insert glyphs Valitse tekstitaso tai napsauta tekstiä lisätäksesi glyfejä + Entire Font Koko fontti + Alternates for Current Selection Nykyisen valinnan vaihtoehtomerkit + Recently Used Viimeksi käytetyt + Digits Numerot + Symbols Symbolit + Punctuation Välimerkit + OpenType OpenType + Standard Ligatures Vakioligatuurit + Discretionary Ligatures Valinnaiset ligatuurit + Contextual Alternates Kontekstuaaliset vaihtoehdot + Swash Kiemura + Titling Alternates Otsikkovaihtoehdot + Ornaments Koristeet + Fractions Murtoluvut + Ordinals Järjestysluvut + East Asian Features Itä-Aasian ominaisuudet + Middle Eastern Features Lähi-idän ominaisuudet + +# Paths panel + Path Polku + Path: Polku: + Work Path Työpolku + Path to Illustrator… Polku Illustratoriin… + Make Work Path Luo työpolku + Make Selection Luo valinta + Fill Path Täytä polku + Stroke Path Piirrä polun reunaviiva + Clipping Path Leikkauspolku + New Path Uusi polku + Duplicate Path Monista polku + Rename Path… Nimeä polku uudelleen… + Delete Path Poista polku + Current Path Nykyinen polku + Vector Mask from Current Path Vektorimaski nykyisestä polusta + Create new shape from the current path Luo uusi muoto nykyisestä polusta + Drag to move the active shape's path or the Work Path Siirrä aktiivisen muodon polkua tai työpolkua vetämällä + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Napsautus: valitse ankkuri · Veto: siirrä ankkuria, kahvaa tai segmenttiä · {shift}-napsautus: lisää · {alt}-napsautus: koko alipolku + Draw with the Pen tool (P) or make a work path from a selection. Piirrä kynätyökalulla (P) tai luo työpolku valinnasta. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Lähde: ei asetettu ({key}-napsautus kloonausleimasimella) + Clone source {n} ({key}-click with Clone Stamp to set) Kloonauslähde {n} (aseta {key}-napsautuksella kloonausleimasimella) + {key}-click to set the source Aseta lähde {key}-napsautuksella + Option-click to define a source point to clone from Määritä kloonauksen lähdepiste Option-napsautuksella + Show Overlay Näytä peite + Auto-Hide Piilota automaattisesti + W L + H K + Angle Kulma + Scale Skaalaus + X X + Y Y + Offset Siirtymä + Reset Transform Nollaa muunnos + Rotate Kierrä + Set horizontal skew Aseta vaakasuuntainen vinous + Set vertical skew Aseta pystysuuntainen vinous + Flip Horizontal Peilaa vaakasuunnassa + Flip Vertical Peilaa pystysuunnassa + Frame Sync Ruutujen synkronointi + Quick Sync Pikasynkronointi + Enabled Käytössä + +# Timeline panel + Create Video Timeline Luo videon aikajana + Open a document to use the timeline. Avaa asiakirja käyttääksesi aikajanaa. + Video Layers Videotasot + Video Video + Audio Ääni + Frame Duration Ruudun kesto + 30 frames @ 30 fps 30 ruutua @ 30 fps + Loop Silmukka + Render Video… Renderöi video… + Split at Playhead Jaa toistokohdassa + Delete Timeline Poista aikajana + Reload Frame Lataa ruutu uudelleen + Restore Frame Palauta ruutu + Insert Blank Frame Lisää tyhjä ruutu + Duplicate Frame Monista ruutu + Delete Frame Poista ruutu + Restore All Frames Palauta kaikki ruudut + Flatten Frames Into Clips Yhdistä ruudut leikkeiksi + Flatten Frames Into Layers Yhdistä ruudut tasoiksi + Convert Frames > Video Layers Muunna ruudut > videotasot + New Video Layer Uusi videotaso + New Video Group Uusi videoryhmä + Interpret Footage… Tulkitse kuvamateriaali… + Replace Footage… Korvaa kuvamateriaali… + Show Altered Video Näytä muokattu video + Limit Playback Rajoita toistoa + Enable Timeline Quick Config Ota aikajanan pika-asetukset käyttöön + Set Start Timecode… Aseta aloitusaikakoodi… + Timecode Aikakoodi + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Nykyinen aika + Out Ulos + In Sisään + Duration Kesto + Repeat Toista reunapikselit + Play Once Toista kerran + Video Cache Videovälimuisti + +# Info panel + Info Options… Tietopaneelin asetukset… + First Color Readout Ensimmäinen värilukema + Second Color Readout Toinen värilukema + Actual Color Todellinen väri + Grayscale Slider Harmaasävyn liukusäädin + Web Color Web-väri + HSB Color HSB-väri + Total Ink Musteen kokonaismäärä + Opacity Mask Peittävyysmaski + Ruler Viivain + Current Tool Nykyinen työkalu + Document Size Asiakirjan koko + Document Profile Asiakirjan profiili + Document Dimensions Asiakirjan mitat + Scratch Sizes Työlevyn koot + Efficiency Tehokkuus + Timing Ajoitus + Status Information Tilatiedot + Saved Multichannel Tallennettu monikanavaisena + Mouse Coordinates Hiiren koordinaatit + Ruler Units Viivaimen yksiköt + Hide Piilota + +# Layer Comps panel + Layer Comps to Files… Tasoasetelmat tiedostoiksi… + Apply next selected layer comp Käytä seuraavaa valittua tasoasetelmaa + Apply previous selected layer comp Käytä edellistä valittua tasoasetelmaa + Update layer comp (visibility, position and appearance) Päivitä tasoasetelma (näkyvyys, sijainti ja ulkoasu) + New Layer Comp Uusi tasoasetelma + Duplicate Layer Comp Monista tasoasetelma + Delete layer comp Poista tasoasetelma + Rename Layer Comp Nimeä tasoasetelma uudelleen + Create new layer comp Luo uusi tasoasetelma + Capture the visibility, position and style of every layer with + below, then switch between versions. Kaappaa jokaisen tason näkyvyys, sijainti ja tyyli alla olevalla +-painikkeella ja vaihda sitten versioiden välillä. + Apply Layer Comp Käytä tasoasetelmaa + Update Layer Comp Päivitä tasoasetelma + Restore last viewed comp Palauta viimeksi katsottu asetelma + Last Document State Asiakirjan viimeisin tila + Visibility Näkyvyys + Position Sijainti + Appearance (Layer Style) Ulkoasu (tasotyyli) + +# Analysis and variables + Analysis Analyysi + Variables Muuttujat + Variable Data Sets… Muuttuvat tietojoukot… + Data Sets… Tietojoukot… + Data Sets Tietojoukot + Data Sets as Files… Tietojoukot tiedostoiksi… + Apply Data Set… Käytä tietojoukkoa… + Define Määritä + + Add variable + Lisää muuttuja + Remove variable Poista muuttuja + Define at least one variable first. Määritä ensin vähintään yksi muuttuja. + Open a document to define variables. Avaa asiakirja määrittääksesi muuttujia. + Variables bind a layer's visibility, text or pixels to a named data slot. Muuttujat sitovat tason näkyvyyden, tekstin tai pikselit nimettyyn tietopaikkaan. + Pixel Replacement Pikseleiden korvaus + Text Replacement Tekstin korvaus + Name Nimi + Method Menetelmä + Select All Layers Valitse kaikki tasot + Fill Täyttö + Keep proportions Säilytä mittasuhteet + Interpolation Interpolointi + Author: Tekijä: + Author Tekijä + Name: Nimi: + Caption Kuvateksti + Caption Credits Kuvatekstin tekijät + Description Kuvaus + Keywords Avainsanat + Copyright Info URL Tekijänoikeustietojen URL + Copyright Status Tekijänoikeuden tila + Copyright Notice Tekijänoikeusilmoitus + Document Title Asiakirjan otsikko + Copyrighted Tekijänoikeussuojattu + Public Domain Tekijänoikeudeton + Unknown Tuntematon + Copyright and Contact Info Tekijänoikeus- ja yhteystiedot + Credit Tekijätieto + Transmission Reference Lähetysviite + Urgency Kiireellisyys + High Korkea + Normal Normaali + Lossless Häviötön + Low Matala + Category Luokka + Supplemental Categories Lisäluokat + Object Name / Title Objektin nimi / otsikko + Date Created Luontipäivä + City Kaupunki + Location Sijainti + Province / State Maakunta / osavaltio + Country Maa + Byline Tekijä + Byline Title Tekijän titteli + Phone Puhelin + Email Sähköposti + URLs URL-osoitteet + Last Modified Viimeksi muokattu + EXIF EXIF + GPS GPS + Camera Data Kameratiedot + Camera Model Kameran malli + Camera Make Kameran valmistaja + EXIF Version EXIF-versio + Exposure Program Valotusohjelma + Aperture Aukko + Shutter Speed Suljinaika + Metering Mode Mittaustapa + Light Source Valonlähde + Flash Salama + Lens Info Objektiivin tiedot + ISO Speed ISO-herkkyys + Subject Distance Kohteen etäisyys + DateTime Original Alkuperäinen päivämäärä ja aika + Create Date Luontipäivä + Modify Date Muokkauspäivä + Software Ohjelmisto + Raw Data Raakadata + Version {version} Versio {version} + Advanced Lisäasetukset + Import… Tuo… + +# Automate + Automate Automaatio + Batch… Eräajo… + Create Droplet… Luo pikkusovellus… + Create Droplet Luo pikkusovellus + PDF Presentation… PDF-esitys… + Create Multiple Picture Packages Luo useita kuvapaketteja + Contact Sheet II Vedosarkki II + Contact Sheet II… Vedosarkki II… + Conditional Mode Change… Ehdollinen tilanvaihto… + Fit Image… Sovita kuva… + Picture Package… Kuvapaketti… + Web Photo Gallery… Verkkokuvagalleria… + Merge to HDR Pro… Yhdistä HDR Pro -kuvaksi… + Load Files into Stack… Lataa tiedostot pinoon… + Scripts Komentosarjat + Script Events Manager… Komentosarjatapahtumien hallinta… + Image Processor… Kuvaprosessori… + Destination Kohde + Choose… Valitse… + Select Folder… Valitse kansio… + Override Action "Save" Commands Ohita toimintojen ”Tallenna”-komennot + Compatibility Yhteensopivuus + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Sisällytä ICC-profiili + Error File Virhetiedosto + Stop For Errors Pysäytä virheisiin + Log Errors To File Kirjaa virheet tiedostoon + Errors Virheet + +# Color Settings dialog + Color Settings… Väriasetukset… + Settings Asetukset + Settings: Asetukset: + Working Space Työväriavaruus + RGB: RGB: + CMYK: CMYK: + Gray: Harmaa: + Spot: Spottiväri: + Advanced Controls Lisäasetukset + Desaturate Monitor Colors By Vähennä näytön värien kylläisyyttä + Blend RGB Colors Using Gamma 1.0 Sekoita RGB-värit gammalla 1,0 + Profile Mismatches: Ask When Opening Profiilien ristiriidat: kysy avattaessa + Ask When Pasting Kysy liitettäessä + Missing Profiles: Ask When Opening Puuttuvat profiilit: kysy avattaessa + Embed profiles Upota profiilit + Ask Before Saving Saved Embedded Kysy ennen upotetun tallentamista + Coerce to sRGB Pakota sRGB:ksi + 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 + +# Themes + Theme Teema + Switch theme Vaihda teemaa + Next Theme Seuraava teema + Classic Theme Klassinen teema + Solarized dark Tumma Solarized + Solarized Dark Theme Tumma Solarized -teema + Breeze dark Tumma Breeze + Breeze Dark Theme Tumma Breeze -teema + Breeze light Vaalea Breeze + Breeze Light Theme Vaalea Breeze -teema + Soft dark Pehmeä tumma + Soft light Pehmeä vaalea + Soft Dark Theme Pehmeä tumma teema + Soft Light Theme Pehmeä vaalea teema + Studio Theme Studio-teema + Studio Light Theme Vaalea Studio-teema + Adwaita Light Theme Vaalea Adwaita -teema + Adwaita Dark Theme Tumma Adwaita -teema + Pro Theme Pro-teema + Pro Medium Gray Theme Keskiharmaa Pro-teema + Pro Light Gray Theme Vaaleanharmaa Pro-teema + Pro Light Theme Vaalea Pro-teema + +# Workspaces + Workspace… Työtila… + Essentials Perusteet + New Workspace… Uusi työtila… + Delete Workspace… Poista työtila… + Reset Workspace Nollaa työtila + Reset Tool Nollaa työkalu + Reset All Tools Nollaa kaikki työkalut + Reset all tools to their default settings? Nollataanko kaikki työkalut oletusasetuksiin? + Lock Workspace Lukitse työtila + Photography Valokuvaus + Graphic and web Grafiikka ja verkko + Painting Maalaus + Motion Liike + Minimalistic Minimalistinen + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Paneelien sijainnit tallennetaan työtilaan. Pikanäppäimet, valikot ja työkalurivi ovat valinnaisia. + Keyboard Shortcuts Pikanäppäimet + Keyboard Shortcuts and Menus Pikanäppäimet ja valikot + Menus Valikot + More panels Muut paneelit + More documents Muut asiakirjat + Collapse All Groups Tiivistä kaikki ryhmät + Expand All Groups Laajenna kaikki ryhmät + Collapse Tiivistä + Expand Laajenna + Collapse Panel Group Tiivistä paneeliryhmä + Expand Panel Group Laajenna paneeliryhmä + Close Tab Group Sulje välilehtiryhmä + +# New Document dialog + PRESET DETAILS ESIASETUKSEN TIEDOT + BLANK DOCUMENT PRESETS ({n}) TYHJÄN ASIAKIRJAN ESIASETUKSET ({n}) + Create a new document or open an existing file. Luo uusi asiakirja tai avaa olemassa oleva tiedosto. + Drop an image or PSD anywhere to open it. Avaa kuva tai PSD pudottamalla se mihin tahansa. + Use File › Open, or paste a copied image with {paste}. Käytä komentoa Tiedosto › Avaa tai liitä kopioitu kuva komennolla {paste}. + Native file drag-and-drop is unavailable Tiedostojen vetäminen ja pudottaminen ei ole käytettävissä + 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}. Tiedostojen vetämistä ja pudottamista ei vielä tueta Waylandissa. Käytä komentoa Tiedosto › Avaa tai kopioi kuva tiedostonhallinnassa ja liitä se komennolla {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Jos haluat pudottaa tiedostoja, käynnistä PhotoCraft XWaylandissa: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Tai aseta Asetukset › Suorituskyky › Linux-näyttöpalvelin arvoksi X11: PhotoCraft käynnistyy tällöin aina XWaylandissa. + Edit › Paste Muokkaa › Liitä + Photo Valokuva + Print Tulosta + Art & Illustration Taide ja kuvitus + Mobile Mobiili + Film & Video Elokuva ja video + Default PhotoCraft Size PhotoCraftin oletuskoko + Clipboard Leikepöytä + Photo (2 × 3 in) Valokuva (2 × 3 in) + Photo (4 × 6 in) Valokuva (4 × 6 in) + Photo (5 × 7 in) Valokuva (5 × 7 in) + Photo (8 × 10 in) Valokuva (8 × 10 in) + Landscape Vaaka + Portrait Pysty + Recent Items Viimeisimmät kohteet + Save Preset… Tallenna esiasetus… + Delete Preset… Poista esiasetus… + Preset Name: Esiasetuksen nimi: + Width and Height Leveys ja korkeus + Background Contents Taustan sisältö + Custom background color Mukautettu taustaväri + White Valkoinen + Background Color Taustaväri + Transparent Läpinäkyvä + Color Mode Väritila + Depth Syvyys + Color Profile Väriprofiili + Create Luo + Document Asiakirja + Units Yksiköt + Pixels Pikselit + Inches Tuumat + Centimeters Senttimetrit + Millimeters Millimetrit + Points Pisteet + Picas Picat + Columns Sarakkeet + +# Placeholders, tooltips, status and keyboard hints + {name} options {name}: asetukset + Align {what} Tasaa {what} + Untagged {mode} Profiiliton {mode} + Invalid {mode} profile Virheellinen {mode}-profiili + Unnamed {mode} profile Nimetön {mode}-profiili + Constraints ({n}) Rajoitteet ({n}) + Count: {n} Määrä: {n} + Click to add a point · drag off or {key}-click or Delete to remove Lisää piste napsauttamalla · poista vetämällä pois, {key}-napsautuksella tai Deletellä + Click to apply, {key}-click to clear overrides, double-click for Style Options Käytä napsauttamalla, tyhjennä ohitukset {key}-napsautuksella, tyyliasetukset kaksoisnapsautuksella + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Napsauta: kulma · Vedä: pehmeä · Napsauta ensimmäistä pistettä: sulje · {key} lopeta · Esc peruuta + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Napsauta: kulma · Vedä: pehmeä · {alt}-vedä: katkaise kahva · Napsauta tai vedä ensimmäistä pistettä: sulje · {key} lopeta · Esc peruuta + Commit Perspective Warp ({key}) Vahvista perspektiivin taivutus ({key}) + Commit Puppet Warp ({key}) Vahvista nukketaivutus ({key}) + Commit any current edits ({key}) Vahvista nykyiset muokkaukset ({key}) + Commit transform ({key}) Vahvista muunnos ({key}) + Commit warp ({key}) Vahvista taivutus ({key}) + Cancel any current edits (Esc) Peruuta nykyiset muokkaukset (Esc) + Cancel transform (Esc) Peruuta muunnos (Esc) + Cancel warp (Esc) Peruuta taivutus (Esc) + Do you want to save the changes you made to “{name}” before closing? Tallennetaanko kohteeseen ”{name}” tehdyt muutokset ennen sulkemista? + 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 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. + Drag the arrow to set the angle, the dots to set the roundness Vedä nuolta asettaaksesi kulman, pisteitä asettaaksesi pyöreyden + Create new pattern from the selection Luo uusi kuvio valinnasta + Create new gradient Luo uusi liukuväri + Create new count group Luo uusi laskentaryhmä + Search commands, tools and panels… Etsi komentoja, työkaluja ja paneeleja… + Search Brushes Etsi siveltimiä + Search fonts Etsi fontteja + No matching commands Ei vastaavia komentoja + No document Ei asiakirjaa + Pixel Layer Pikselitaso + Type Layer Tekstitaso + Shape Layer Muototaso + Group Layer Ryhmätaso + Fill Layer Täyttötaso + Smart Object Layer Älykäs objekti -taso + Adjustment Layer Säätötaso + {name} Properties {name}: ominaisuudet + 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. + Rectangular Marquee Tool Suorakulmainen valintakehys + Elliptical Marquee Tool Elliptinen valintakehys + Single Row Marquee Tool Yhden rivin valintakehys + Single Column Marquee Tool Yhden sarakkeen valintakehys + Brush Tool Sivellintyökalu + Pencil Tool Lyijykynätyökalu + Eraser Tool Pyyhekumityökalu + Background Eraser Tool Taustapyyhekumi + Magic Eraser Tool Taikapyyhekumi + Eyedropper Tool Pipettityökalu + Note Tool Muistiinpanotyökalu + Lasso Tool Lassotyökalu + Polygonal Lasso Tool Monikulmiolasso + Magic Wand Tool Taikasauva + Crop Tool Rajaustyökalu + Slice Tool Viipaletyökalu + Slice Select Tool Viipaleen valintatyökalu + Gradient Tool Liukuvärityökalu + Paint Bucket Tool Maaliämpäri + Horizontal Type Tool Vaakasuuntainen tekstityökalu + Horizontal Type Mask Tool Vaakasuuntainen tekstimaskityökalu + Vertical Type Mask Tool Pystysuuntainen tekstimaskityökalu + Hand Tool Käsityökalu + Zoom Tool Zoomaustyökalu + Spot Healing Brush Tool Pistekorjaussivellin + Healing Brush Tool Korjaussivellin + Clone Stamp Tool Kloonausleimasin + History Brush Tool Historiasivellin + Blur Tool Sumennustyökalu + Sharpen Tool Terävöitystyökalu + Smudge Tool Tahraustyökalu + Dodge Tool Vaalennustyökalu + Burn Tool Tummennustyökalu + Sponge Tool Sienityökalu + Quick Selection Tool Pikavalintatyökalu + Object Selection Tool Objektinvalintatyökalu + Pen Tool Kynätyökalu + Convert Point Tool Muunna piste -työkalu + Path Selection Tool Polun valintatyökalu + Direct Selection Tool Suoravalintatyökalu + Add Anchor Point Tool Lisää ankkuripiste -työkalu + Delete Anchor Point Tool Poista ankkuripiste -työkalu + Click a segment to add an anchor point · {alt}-click an anchor to delete it Lisää ankkuripiste napsauttamalla segmenttiä · poista ankkuri {alt}-napsautuksella + Click an anchor point to delete it · {alt}-click a segment to add one Poista ankkuripiste napsauttamalla sitä · lisää ankkuri segmenttiin {alt}-napsautuksella + Rectangle Tool Suorakulmiotyökalu + Ellipse Tool Ellipsityökalu + Triangle Tool Kolmiotyökalu + Polygon Tool Monikulmiotyökalu + Line Tool Viivatyökalu + Custom Shape Tool Mukautetun muodon työkalu + Pixel Pikseli + Group Ryhmä + Adjustment Säätö + Smart Object Älykäs objekti + 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 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 + {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 + Fill Screen Täytä näyttö + Scrubby Zoom Vetozoomaus + Auto Erase Automaattinen pyyhintä + Color Adaptation Värin mukautus + Preserve Transparency Säilytä läpinäkyvyys + Reverse Käännä järjestys + Align with layer Tasaa tason kanssa + Join us on Discord Liity Discordiin + PhotoCraft website PhotoCraft-verkkosivusto + ArtCraft ArtCraft + GitHub GitHub + No character style Ei merkkityyliä + No author Ei tekijää + Not in Dictionary: Ei sanastossa: + Suggestions: Ehdotukset: + Spell check complete. Oikeinkirjoituksen tarkistus valmis. + Check All Layers Tarkista kaikki tasot + Select a type layer to edit its character and paragraph settings. Valitse tekstitaso muokataksesi sen merkki- ja kappaleasetuksia. + Select text to apply a character style Valitse teksti, johon merkkityyli käytetään + Redefine style by current selection Määritä tyyli uudelleen nykyisen valinnan mukaan + Duplicate style Monista tyyli + Delete style Poista tyyli + Style Options Tyyliasetukset + Style Options… Tyyliasetukset… + Close Style Options Sulje tyyliasetukset + Appearance Ulkoasu + Text Position: Tekstin sijainti: + Text color Tekstin väri + Uses the foreground and background colours. 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 + No Ei + Yes Kyllä + These settings aren't available in PhotoCraft yet. Nämä asetukset eivät ole vielä käytettävissä PhotoCraftissa. + Shortcut removed. Pikanäppäin poistettu. + All shortcuts reset to PhotoCraft defaults. Kaikki pikanäppäimet palautettu PhotoCraftin oletusasetuksiin. + Add Shortcut Lisää pikanäppäin + Delete Shortcut Poista pikanäppäin + Press keys… Paina näppäimiä… + shortcut pikanäppäin + command or shortcut komento tai pikanäppäin + current nykyinen + image file path kuvatiedoston polku + modified muokattu + name nimi + new uusi + Go to Layer Siirry tasoon + Open a document to select a colour range. Avaa asiakirja valitaksesi värialueen. + Saved Tallennettu + Reveal in Finder Näytä Finderissa + Show in Explorer Näytä Resurssienhallinnassa + Show in Folder Näytä kansiossa + Before (Y) Ennen (Y) + Save Settings… Tallenna asetukset… + Load Settings… Lataa asetukset… + Save these settings as a Camera Raw .xmp file Tallenna nämä asetukset Camera Raw .xmp -tiedostona + Load settings from a Camera Raw .xmp file Lataa asetukset Camera Raw .xmp -tiedostosta + Loaded Camera Raw settings from {file} Camera Raw -asetukset ladattu tiedostosta {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw -asetukset ladattu tiedostosta {file} ({n} tukematonta) + After (X) Jälkeen (X) + Original Alkuperäinen + Current Nykyinen + {added}, {removed} subtracted {added}, {removed} vähennetty + Filter: Suodatin: + +# Transform / warp overlays + Free Transform Vapaa muunnos + Free Transform a Copy Vapaa muunnos kopiolle + Transform Muunna + Transform Selection Muunna valinta + Switch between free transform and warp modes Vaihda vapaan muunnoksen ja taivutustilan välillä + Perspective Perspektiivi + Skew Vinouta + Distort Vääristys + Rotate: Kierto: + Flip Peilaa + Warp: Taivutus: + Warp Taivutus + Bend: Kaarevuus: + Reference point location Viitepisteen sijainti + Toggle reference point Näytä/piilota viitepiste + Free Vapaa + Arc Kaari + Arch Holvikaari + Bulge Pullistuma + Shell Lower Alakuori + Shell Upper Yläkuori + Flag Lippu + Fish Kala + Wave Aalto + Rise Nousu + Fisheye Kalansilmä + Inflate Paisutus + Twist Kierre + Squeeze Puristus + Corner radius Kulman säde + Split Warp Crosswise Jaa taivutus poikittain + Split Warp Horizontally Jaa taivutus vaakasuunnassa + Split Warp Vertically Jaa taivutus pystysuunnassa + Remove Warp Split Poista taivutuksen jako + Puppet Warp Nukketaivutus + Puppet Warp is not active Nukketaivutus ei ole aktiivinen + Perspective Warp Perspektiivin taivutus + Perspective Warp is not active Perspektiivin taivutus ei ole aktiivinen + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektiivin taivutus: piirrä perspektiivitasot kuvan perspektiivin mukaan ja vaihda sitten Taivutus-tilaan + Remove Planes Poista perspektiivitasot + Edit Pins Muokkaa nastoja + Add Pins Lisää nastoja + Delete Pins Poista nastat + Remove All Pins Poista kaikki nastat + Remove Tool Poistotyökalu + Pin Depth: Nastan syvyys: + Bring the selected pin forward Tuo valittu nasta eteen + Send the selected pin backward Siirrä valittua nastaa taaksepäin + Rotate Auto Kierrä automaattisesti + Rotate Fixed Kiinteä kierto + Adaptive Wide Angle isn't open Mukautuva laajakulma ei ole auki + Liquify is not open Nesteytys ei ole auki + +# Vanishing Point + Create Plane Luo perspektiivitaso + Edit Plane Muokkaa perspektiivitasoa + Select Plane Valitse perspektiivitaso + Measure Mittaa + Heal Korjaa + Off Pois + Symmetry Off Symmetria pois päältä + Heal Off Korjaus: pois + Heal On Korjaus: päällä + Heal Auto Korjaus: automaattinen + Blend Sekoitus + Brush Size Siveltimen koko + Heal Color Korjaus: väri + +# Misc dialog strings + Mask Maski + Channel Kanava + Rectangle Suorakulmio + Subtract Vähennys + Multiply Kertominen + Divide Jakaminen + Result Tulos + New Document Uusi asiakirja + New Document With Mask Uusi asiakirja maskilla + New Layer With Mask Uusi taso maskilla + New Layer Uusi taso + Preset Name Esiasetuksen nimi + No saved presets yet. Tallennettuja esiasetuksia ei vielä ole. + New Channel Uusi kanava + Selection Valinta + Save Selection Tallenna valinta + Load Selection Lataa valinta + Duplicate Monista + Fast Nopea + Accurate Tarkka + Add to Lisää kohteeseen + Subtract from Vähennä + Intersect with Leikkaa + +# Blend modes + Pass Through Läpäisy + Dissolve Liuota + Darken Tummenna + Color Burn Väritummennus + Linear Burn Lineaarinen tummennus + Darker Color Tummempi väri + Lighten Vaalenna + Screen Rasteri + Color Dodge Värivaalennus + Linear Dodge (Add) Lineaarinen vaalennus (lisää) + Lighter Color Vaaleampi väri + Overlay Peitto + Soft Light Pehmeä valo + Hard Light Kova valo + Vivid Light Elävä valo + Linear Light Lineaarinen valo + Pin Light Pistevalo + Hard Mix Kova sekoitus + Difference Erotus + Exclusion Poissulku + Luminosity Luminanssi + +# Saved views and other bits + Capture Comp Tallenna asetelma + No Layer Comps Ei tasoasetelmia + +# Plurals +@plural {n} item|{n} items {n} kohde|{n} kohdetta +@plural {n} Layer|{n} Layers {n} taso|{n} tasoa +@plural {n} layer|{n} layers {n} taso|{n} tasoa +@plural Group · {n} layer|Group · {n} layers Ryhmä · {n} taso|Ryhmä · {n} tasoa +@plural {n} sample|{n} samples {n} näyte|{n} näytettä + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… 32-bittisen esikatselun asetukset… + 90° Clockwise 90° myötäpäivään + 90° Counter Clockwise 90° vastapäivään + AI Integrations… Tekoälyintegraatiot… + Again Uudelleen + Transform Again on a Copy Muunna uudelleen kopiolle + Anamorphic 2:1 (2) Anamorfinen 2:1 (2) + Arbitrary… Vapaa kulma… + Artboard Guides Piirtoalustan apuviivat + Artboard from Group… Piirtoalusta ryhmästä… + Artboard from Layers… Piirtoalusta tasoista… + Artboards to Files… Piirtoalustat tiedostoiksi… + Artboards to PDF… Piirtoalustat PDF-tiedostoksi… + Artboard… Piirtoalusta… + Border… Reunus… + Bottom Edges Alareunat + Browse… Selaa… + Source Files Lähdetiedostot + Use: Käytä: + Files Tiedostot + Folder Kansio + Add Open Files Lisää avoimet tiedostot + Sort By Name Järjestä nimen mukaan + Attempt to Automatically Align Source Images Yritä kohdistaa lähdekuvat automaattisesti + Create Smart Object after Loading Layers Luo älykäs objekti tasojen lataamisen jälkeen + Canvas Guides Kankaan apuviivat + 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 Table… Väritaulukko… + Combine Shapes Yhdistä muodot + Contract… Supista… + Convert to Embedded Muunna upotetuksi + Convert to Layers Muunna tasoiksi + Convert to Linked… Muunna linkitetyksi… + Count Laskuri + Create Layer Luo taso + Custom Pixel Aspect Ratio… Mukautettu pikselin kuvasuhde… + Cycle Screen Mode Vaihda näyttötilaa + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC -laajakuva (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL -laajakuva (1,46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + De-Interlace… Lomituksen poisto… + Default Features Oletusominaisuudet + Delete All Empty Layers Poista kaikki tyhjät tasot + Desaturate Poista värikylläisyys + Displace… Syrjäytä… + Distribute Jaa tasaisesti + Document Bounds Asiakirjan rajat + Edit in Quick Mask Mode Muokkaa pikamaskitilassa + Enhanced Controls… Laajennetut säädöt… + Entropy Entropia + Exclude Overlapping Shapes Poissulje päällekkäiset muodot + Exit Lopeta + Expand… Laajenna… + Extra Large Erittäin suuri + File Info… Tiedoston tiedot… + Fit Artboard on Screen Sovita piirtoalusta näyttöön + Fit Layer(s) on Screen Sovita taso(t) näyttöön + Flatten All Layer Effects Litistä kaikki tasotehosteet + Flatten All Masks Litistä kaikki maskit + Flatten Image Litistä kuva + Float All in Windows Kelluta kaikki ikkunoissa + Float in Window Kelluta ikkunassa + Frame from Layers Kehys tasoista + From Transparency Läpinäkyvyydestä + Gamut Warning Väriskaalavaroitus + Global Light… Yleinen valo… + Gradient… Liukuväri… + Graphic and Web Grafiikka ja verkko + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Hidden Layers Piilotetut tasot + Hide All Effects Piilota kaikki tehosteet + Horizontal Centers Vaakakeskikohdat + Huge Valtava + Indexed Color… Indeksoitu väri… + Integrations… Integraatiot… + Intersect Shape Areas Leikkaa muotoalueet + Kurtosis Huipukkuus + Large Suuri + Layer Edges Tasojen reunat + Left Edges Vasemmat reunat + Lens Blur… Linssisumennus… + Lighting Effects… Valaistustehosteet… + Link Layers Linkitä tasot + Lock Layers… Lukitse tasot… + Mean Keskiarvo + Merge Down Yhdistä alaspäin + Merge Group Yhdistä ryhmä + Merge Layers Yhdistä tasot + Merge Shape Components Yhdistä muodon osat + Merge Visible Yhdistä näkyvät + Stamp Visible Leimaa näkyvät + Select Layer Above Valitse yläpuolinen taso + Select Layer Below Valitse alapuolinen taso + Select Top Layer Valitse ylin taso + Select Bottom Layer Valitse alin taso + Add Layer Above to Selection Lisää yläpuolinen taso valintaan + Add Layer Below to Selection Lisää alapuolinen taso valintaan + Stamp Down Leimaa alaspäin + Mesh Verkko + Middle Eastern & South Asian Composer Lähi-idän ja Etelä-Aasian ladonta + Modifier Keys Muokkausnäppäimet + Modify Muokkaa + NTSC Colors NTSC-värit + Notes… Muistiinpanot… + Other Muut + Other… Muu… + Paragraph Styles Panel Kappaletyylipaneeli + Paths to Illustrator… Polut Illustratoriin… + Pattern Preview Kuvion esikatselu + Pattern… Kuvio… + Pixel Art Pikselitaide + Pixel Art Preview Pikselitaiteen esikatselu + Pixel Grid Pikseliruudukko + Pixelate Pikselöinti + Place Scale Marker… Sijoita mittakaavamerkki… + Plug-ins Laajennukset + Print One Copy Tulosta yksi kopio + Print… Tulosta… + Range Alue + Raw Defaults… Raw-oletusarvot… + Relink to File… Linkitä uudelleen tiedostoon… + Relight… Uudelleenvalaistus… + Render Renderöi + Replace All Missing Fonts Korvaa kaikki puuttuvat fontit + Resolve Missing Fonts… Korjaa puuttuvat fontit… + Reveal Selection Paljasta valinta + Right Edges Oikeat reunat + Selection Edges Valinnan reunat + Shapes Muodot + Show Extras Options… Näytä apuvälineiden asetukset… + Skewness Vinous + Small Pieni + Smart Guides Älykkäät apuviivat + Smart Objects Älykkäät objektit + Smooth… Tasoita… + Solid Color… Yhtenäinen väri… + Square Neliö + Stack Mode Pinotila + Choose Stack Mode: Valitse pinotila: + Standard Deviation Keskihajonta + Statistics… Tilastot… + Stylistic Alternates Tyylivaihtoehdot + Stylize Stilisoi + Subtract Front Shape Vähennä edessä oleva muoto + Summation Summa + Target Path Kohdepolku + Tile All Horizontally Kaikki allekkain + Tile All Vertically Kaikki rinnakkain + Toggle Last State Vaihda viimeisin tila + Toolbar… Työkalurivi… + Tools… Työkalut… + Top Edges Yläreunat + Unite Shapes Yhdistä muodot + Update All Modified Content Päivitä kaikki muokattu sisältö + Update All Text Layers Päivitä kaikki tekstitasot + Update Modified Content Päivitä muokattu sisältö + Variance Varianssi + Vertical Centers Pystykeskikohdat + Video Frames to Layers… Videoruudut tasoiksi… + WIA Support… WIA-tuki… + Working CMY Plate Työ-CMY-levy + +# tl! literals (third pass) + Absolute Absoluuttinen + Action Toiminto + Adaptive Mukautuva + Add Stop Lisää liukuvärin piste + Add disk Lisää levy + Align and Distribute Tasaa ja jaa tasaisesti + All Slices Kaikki viipaleet + All User Slices Kaikki käyttäjän viipaleet + Alt Alt + Artboard Piirtoalusta + Artboard Background Color Piirtoalustan taustaväri + Auto Hide Piilota automaattisesti + Automatically level near-horizontal line segments Oikaise lähes vaakasuorat janat automaattisesti + Automatically straighten and level Suorista ja oikaise automaattisesti + Automatically straighten near-vertical line segments Suorista lähes pystysuorat janat automaattisesti + Balance Tasapaino + Baseline Perusviiva + Bicubic Bikubinen + Bicubic (smooth gradients) Bikubinen (tasaiset liukuvärit) + Automatic Automaattinen + Bicubic Smoother (enlargement) Bikubinen pehmeämpi (suurennus) + Bicubic Sharper (reduction) Bikubinen terävämpi (pienennys) + Bilinear Bilineaarinen + Black, White Musta, valkoinen + Blue, Red, Yellow Sininen, punainen, keltainen + Bottom Alareuna + Brightness Jitter Kirkkauden vaihtelu + Brush Reconstruct Options Siveltimen rekonstruointiasetukset + Cache Level: Välimuistitaso: + Canvas Kangas + Capture Kaappaa + Center Crop Marks Keskikohdan rajausmerkit + Centre 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 + Clear Override Tyhjennä ohitus + Clear Samples Tyhjennä näytteet + Cmd Cmd + Color Grading Värimäärittely + Color Management Värinhallinta + Color Priority Väriprioriteetti + Color Range preview Värialueen esikatselu + Colorize Väritä + Constant Vakio + Content-Aware Sisältötietoinen + Convert to sRGB Muunna sRGB:ksi + Copies Kopiot + Copper Kupari + Corner Crop Marks Kulmarajausmerkit + Correction Korjaus + Count Jitter Määrän vaihtelu + Create Texture Luo tekstuuri + Create a new count group Luo uusi laskentaryhmä + Crop Factor Rajauskerroin + Ctrl Ctrl + Custom Shapes Mukautetut muodot + Cyan Syaani + Cyan · Red Syaani · punainen + Darks Tummat sävyt + Default printer Oletustulostin + Default restored. Oletus palautettu. + Delete Group Poista ryhmä + Delete count group Poista laskentaryhmä + Delete effect layer Poista tehostetaso + Delete note Poista muistiinpano + Delete tool preset Poista työkalun esiasetus + Depth Jitter Syvyyden vaihtelu + Discontiguous Epäyhtenäinen + Discord Discord + Display P3 Display P3 + Display Text Näytä teksti + Divide… Jaa… + Do you want to save the changes you made to “{name}” before quitting? Tallennetaanko kohteeseen ”{name}” tehdyt muutokset ennen lopettamista? + Edit Toolbar… Muokkaa työkaluriviä… + Edit in Quick Mask Mode (Q) Muokkaa pikamaskitilassa (Q) + Edit in Standard Mode (Q) Muokkaa vakiotilassa (Q) + Embedded Profile Mismatch Upotettu profiili ei täsmää + Enhance Edge Paranna reunaa + Exact Täsmällinen + Expansion: Laajennus: + Exposure and Gamma Valotus ja gamma + Factor: Kerroin: + Fewer Points Vähemmän pisteitä + File Settings Tiedostoasetukset + Finger Painting Sormimaalaus + Fit in View Sovita näkymään + Fixed Kiinteä + Foreground to Background Edustaväristä taustaväriin + Foreground/Background Jitter Edusta-/taustavärin vaihtelu + Format Muoto + Frame from Layers… Kehys tasoista… + Full Spherical Täysin pallomainen + Gamma Correction Gammakorjaus + Gamma: Gamma: + Global Yleinen + Grain Rakeisuus + Graphics Grafiikka + Gray Harmaa + Heart Sydän + Hide Auto Slices Piilota automaattiviipaleet + Highlight Compression Korostusten tiivistys + Highlight Priority Korostusten prioriteetti + Highlights Korostukset + Home Koti + Hue Jitter Sävyn vaihtelu + Image Size: Kuvan koko: + Imported Tuotu + Input: Syöte: + Interlaced Lomitettu + Invert All Invertoi kaikki + Label Size Nimikkeen koko + Labels Nimikkeet + Lanczos (sharp) Lanczos (terävä) + Level Vaaita + Lights Vaaleat sävyt + Limits: Rajat: + Link the mask to the layer Linkitä maski tasoon + Localized Color Clusters Paikalliset väriryhmät + Luminance Luminanssi + Magenta Magenta + Magenta · Green Magenta · vihreä + Marker Size Merkinnän koko + Mask Options Maskin asetukset + Masking Maskaus + Master Kaikki värit + Mean: Keskiarvo: + Median: Mediaani: + Mesh Size Verkon koko + Metadata Metatiedot + Midpoint Keskikohta + Midtones Keskisävyt + Midtones from: Keskisävyt alkaen: + Midtones to: Keskisävyt päättyen: + Minimum Depth Vähimmäissyvyys + Mix Jitter Sekoituksen vaihtelu + Mode: Tila: + Monochrome Yksivärinen + More Points Lisää pisteitä + Move Group Down Siirrä ryhmää alaspäin + Move Group Up Siirrä ryhmää ylöspäin + Move effect layer down Siirrä tehostetasoa alaspäin + Move effect layer up Siirrä tehostetasoa ylöspäin + Move to Siirrä ryhmään + Nearest Neighbor Lähin naapuri + Nearest Neighbor (hard edges) Lähin naapuri (terävät reunat) + New effect layer Uusi tehostetaso + Next note Seuraava muistiinpano + No Color Management Ei värinhallintaa + Optimized Optimoitu + Output Levels: Lähtötasot: + Output: Lähtö: + Paint Overlay Maalipeite + Pattern Size Kuvion koko + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — avoimen lähdekoodin natiivi kuvankäsittelyohjelma, joka on kirjoitettu Rustilla. + Position and Size Sijainti ja koko + Preserve Details Säilytä yksityiskohdat + Preserve Luminosity Säilytä luminanssi + Previous note Edellinen muistiinpano + Printer Tulostin + Printer Setup Tulostimen asetukset + Printing Marks Tulostusmerkit + Progressive Progressiivinen + Promote Ylennä + Protect Detail Suojaa yksityiskohdat + Protect Tones Suojaa sävyt + Proximity Match Lähialueen vastaavuus + Purity Puhtaus + Quality Laatu + Recent Viimeisimmät + Reconstruct Rekonstruoi + Refine Tarkennus + Registration Marks Kohdistusmerkit + Regular Normaali + Remove stop Poista liukuvärin piste + Rename Group… Nimeä ryhmä uudelleen… + Rename Layer… Nimeä taso uudelleen… + Restore All Palauta kaikki + Restrictive (Web) Rajoitettu (verkko) + Rigid Jäykkä + Roughness Karheus + Roundness Pyöreys + Rulers & Grids Viivaimet ja ruudukot + Sample: foreground colour Näyte: edustaväri + Sampling: Background Swatch Näytteistys: taustan värimalli + Sampling: Continuous Näytteistys: jatkuva + Sampling: Once Näytteistys: kerran + Saturate Kyllästä + Saturation Jitter Värikylläisyyden vaihtelu + Save HTML and Images Tallenna HTML ja kuvat + Save as PDF Tallenna PDF-muodossa + Scale to Fit Media Sovita paperiin + Scatter Sironta + Scratch disks Työlevyt + Selective Valikoiva + Shadows Varjot + Shape: Muoto: + Sharpening Terävöitys + Shift Shift + Show All Menu Items Näytä kaikki valikkokohdat + Show Backdrop Näytä tausta + Show Mask Näytä maski + Show Mesh Näytä verkko + Show or hide the Notes panel Näytä tai piilota Muistiinpanot-paneeli + Show the layer mask as an overlay Näytä tasomaski peitteenä + Show: Näytä: + Sides Sivut + Sides: Sivut: + Slice Options… Viipaleen asetukset… + Slices From Guides Viipaleet apuviivoista + Slices are locked (View › Lock Slices) Viipaleet on lukittu (Näytä › Lukitse viipaleet) + Split: Jako: + Star ratio Tähtisuhde + Std Dev: Keskihajonta: + Straighten Suorista + Straighten Layer Suorista taso + Strength: Voimakkuus: + Stroke Reunaviiva + Stroke Catch-up Osoittimen kiinniotto + Stroke: Reunaviiva: + Location: Sijainti: + Inside Sisäpuolella + Outside Ulkopuolella + Tetrahedral Tetraedrinen + Texture Each Tip Tekstuuri jokaiselle kärjelle +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Liukuvärieditori + Gradient picker Liukuvärin valitsin + Save As Preset Tallenna esiasetuksena + The gradient the Gradient tool paints with Liukuväri, jolla liukuvärityökalu maalaa + Adds the current gradient to the Gradients panel Lisää nykyisen liukuvärin Liukuvärit-paneeliin + Gradient stops Liukuvärin pisteet + Click to edit the gradient Muokkaa liukuväriä napsauttamalla + Tilt X Kallistus X + Tilt Y Kallistus Y + Toggle count group visibility Vaihda laskentaryhmän näkyvyys + Toggle text orientation Vaihda tekstin suuntausta + Tone: Sävyalue: + Toolbar Työkalurivi + Top Yläreuna + Total: Yhteensä: + Transparency Läpinäkyvyys + Trilinear Trilineaarinen + Unlink the mask from the layer Poista maskin linkitys tasosta + View Options Näkymäasetukset + Violet, Orange Violetti, oranssi + Visible Näkyvissä + Weight Paino + Weight: Paino: + Wetness Jitter Märkyyden vaihtelu + Whites Valkoiset sävyt + Yellow Keltainen + Yellow · Blue Keltainen · sininen + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Salli agentin ohjaus + Allow scripts to connect Salli komentosarjojen yhdistää + Always Aina + Always create smart objects when placing Luo aina älykkäitä objekteja sijoitettaessa + Animated zoom Animoitu zoomaus + Apply auto tone Käytä automaattisävyä + Ask Kysy + Ask before saving layered tiff Kysy ennen tasoja sisältävän TIFF-tiedoston tallentamista + Autosave Automaattinen tallennus + Autosave minutes Automaattisen tallennuksen väli (min) + Beep when done Äänimerkki, kun valmis + Bicubic automatic Bikubinen automaattinen + Bicubic sharper Bikubinen terävämpi + Bicubic smoother Bikubinen pehmeämpi + Bit depth Bittisyvyys + Brush picker Siveltimen valitsin + Brush preview color Siveltimen esikatseluväri + CPU (no GPU acceleration) CPU (ei GPU-kiihdytystä) + Cache levels Välimuistitasot + Cache tile size Välimuistin laatan koko + Camera Raw Defaults Camera Raw -oletukset + Canvas border Kankaan reunus + 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 + Canvas color Kankaan väri + Canvas custom color Kankaan mukautettu väri + Classic Klassinen + Color picker Värinvalitsin + Color space Väriavaruus + Column width Sarakkeen leveys + Concise Tiivis + Control port Ohjausportti + Convert to srgb Muunna sRGB-muotoon + Cursors Osoittimet + Custom dark Mukautettu tumma + Custom light Mukautettu vaalea + Dark Tumma + Dark gray Tummanharmaa + Dashed lines Katkoviivat + Detailed Yksityiskohtainen + DirectX 12 DirectX 12 + Disks Levyt + Display p3 Display P3 + Dots Pisteet + Double click layer mask launches select and mask Tasomaskin kaksoisnapsautus avaa Valitse ja maskaa -tilan + Drop shadow Heittovarjo + Dynamic color sliders Dynaamiset värisäätimet + East asian Itä-Aasian + Effect cache mb Tehostevälimuisti (Mt) + Enable flick panning Ota pyyhkäisypanorointi käyttöön + Enable floating document window docking Ota kelluvien asiakirjaikkunoiden telakointi käyttöön + Enable narrow options bar Ota kapea asetuspalkki käyttöön + Enhanced Controls Laajennetut säädöt + Erase Pyyhi + Export clipboard Vie leikepöytä + Extra large Erittäin suuri + File Handling Tiedostojen käsittely + File path Tiedostopolku + Fill new type layers with placeholder Täytä uudet tekstitasot paikkamerkkitekstillä + Font preview Fontin esikatselu + Full size tip Täysikokoinen kärki + GPU backend GPU-taustajärjestelmä + Linux display server Linux-näyttöpalvelin + X11 X11 + Gamut warning color Väriskaalavaroituksen väri + Gamut warning opacity Väriskaalavaroituksen peittävyys + General Yleiset + Enable Generator Ota Generator käyttöön + Gif GIF + Glossy Kiiltävä + Grid color Ruudukon väri + Grid colors Ruudukon värit + Grid size Ruudukon koko + Grid style Ruudukon tyyli + Grid unit Ruudukon yksikkö + Gridline every Ruudukkoviiva joka + Guide color Apuviivan väri + Guide style Apuviivan tyyli + Guides, Grid & Slices Apuviivat, ruudukko ja viipaleet + Gutter Palstaväli + History Log Historialoki + History states Historiatilat + Ignore exif profile tag Ohita EXIF-profiilitunniste + Image interpolation Kuvan interpolointi + Image previews Kuvan esikatselut + Integrations Integraatiot + Interface Käyttöliittymä + Jpeg quality JPEG-laatu + Jpg JPG + Large tabs Suuret välilehdet + Legacy compositing Vanha koostaminen + Light gray Vaaleanharmaa + Line Viiva + Lines Viivat + Lowercase extension Tiedostopääte pienillä kirjaimilla + Maximize psd compatibility Maksimoi PSD-yhteensopivuus + Medium gray Keskiharmaa + Memory usage mb Muistin käyttö (Mt) + Low resolution previews Matalaresoluutioiset esikatselut + Metal Metal + Missing glyph protection Puuttuvan glyfin suojaus + Modern Moderni + Nearest neighbor Lähin naapuri + Never Ei koskaan + Normal tip Normaali kärki + Open as smart object Avaa älykkäänä objektina + Open in camera raw Avaa Camera Raw'ssa + Open documents as tabs Avaa asiakirjat välilehtinä + OpenGL OpenGL + Orange Oranssi + Overscroll Ylivieritys + Performance Suorituskyky + Png PNG + Point size Pistekoko + PostScript (72 points/inch) PostScript (72 pistettä/tuuma) + Precise Täsmällinen + Preserve details Säilytä yksityiskohdat + Print resolution Tulostusresoluutio + Pro Pro + Pro light Pro vaalea + Pro light gray Pro vaaleanharmaa + Pro medium Pro keskiharmaa + Pro photo ProPhoto + Purple Purppura + Quick export format Pikaviennin muoto + Quick export location Pikaviennin sijainti + Recent file count Viimeisimpien tiedostojen määrä + LUT folder LUT-kansio + Recent files Viimeisimmät tiedostot + Recent fonts Viimeisimmät fontit + Recover on launch Palauta käynnistettäessä + Remember workspace changes Muista työtilan muutokset + Resize image during place Muuta kuvan kokoa sijoitettaessa + Right click with painting tools Hiiren oikea painike maalaustyökaluissa + Rotate view with trackpad Kierrä näkymää kosketuslevyllä + Same folder Sama kansio + Save in background Tallenna taustalla + Scratch Disks Työlevyt + Screen resolution Näytön resoluutio + Scrubby slider acceleration Raahattavan liukusäätimen kiihdytys + Sessions only Vain istunnot + Sharpen for Terävöityksen kohde + Show channels in color Näytä kanavat värillisinä + Show crosshair in brush tip Näytä hiusristikko siveltimen kärjessä + Show extension panels Näytä laajennuspaneelit + Show font names in english Näytä fonttien nimet englanniksi + Show menu colors Näytä valikon värit + 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 + Show transformation values Näytä muunnosarvot + Slice color Viipaleen väri + Smart guide color Älykkään apuviivan väri + Smart quotes Älykkäät lainausmerkit + Snap vector tools and transforms to pixel grid Vektorityökalut ja muunnokset tarttuvat pikseliruudukkoon + Srgb sRGB + Standard Vakio + Studio Studio + Studio light Studio vaalea + Adwaita Adwaita + Adwaita dark Adwaita tumma + Subdivisions Alijaot + System Järjestelmä + Text engine Tekstimoottori + Text file Tekstitiedosto + Tiny Erittäin pieni + Touch gestures Kosketuseleet + Traditional (72.27 points/inch) Perinteinen (72,27 pistettä/tuuma) + Transparency & Gamut Läpinäkyvyys ja väriskaala + Type units Tekstiyksiköt + UI font size Käyttöliittymän fonttikoko + UI scale Käyttöliittymän skaalaus + Units & Rulers Yksiköt ja viivaimet + Use additional plugins folder Käytä lisälaajennusten kansiota + Use esc to commit Vahvista Esc-näppäimellä + Use gpu Käytä GPU:ta + Use legacy free transform Käytä vanhaa vapaata muunnosta + Use shift key for tool switch Käytä Shift-näppäintä työkalun vaihtoon + Use tablet pressure Käytä piirtopöydän painetta + Vary round brush hardness on hud Säädä pyöreän siveltimen kovuutta HUD-säätimellä + Vulkan Vulkan + Webp WebP + World ready Kansainvälinen + Zoom clicked point to center Keskitä napsautettu piste zoomattaessa + Zoom resizes windows Zoomaus muuttaa ikkunoiden kokoa + Zoom with scroll wheel Zoomaa hiiren rullalla + Zoom with trackpad pinch Zoomaa kosketuslevyn nipistyksellä + 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 + 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? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Jos jatkat, alkuperäisen päälle tallentaminen voi hävittää lukukelvottomat tiedot pysyvästi. Säilytä alkuperäinen tallentamalla kopio. + Continue Jatka + The document “{name}” does not have an embedded {mode} profile. Asiakirjassa ”{name}” ei ole upotettua {mode}-profiilia. + 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} + Esc to cancel Peruuta painamalla Esc + Opened {name} Avattu {name} + Opening {name} Avataan tiedostoa {name} + Saved {name} Tallennettu {name} + 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 + Opening {name}… Avataan tiedostoa {name}… + Opening… Avataan… + Reading… Luetaan… + Working… Käsitellään… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Ruudukko: +# Rendering mode and recovery + Rendering Mode Renderöintitila + Automatic (recommended) Automaattinen (suositeltu) + CPU / Compatibility CPU / yhteensopivuus + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automaattinen käyttää GPU-kiihdytystä, kun se on käytettävissä, ja siirtyy virhetilanteissa CPU-renderöintiin. + Switched to CPU rendering Siirryttiin CPU-renderöintiin + GPU acceleration could not continue. Your documents are unchanged. GPU-kiihdytystä ei voitu jatkaa. Asiakirjasi ovat ennallaan. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft käyttää CPU-kuvankoostinta. Ikkuna voi silti käyttää näytönohjainta. + Retry GPU requires a restart. Save your work first. GPU:n uudelleenyritys vaatii uudelleenkäynnistyksen. Tallenna työsi ensin. + Retry GPU tries the graphics processor again now. Yritä GPU:ta uudelleen -toiminto kokeilee grafiikkasuoritinta heti uudelleen. + GPU acceleration resumed. GPU-kiihdytys on taas käytössä. + Keep Using CPU Jatka CPU:n käyttöä + Retry GPU Yritä GPU:ta uudelleen + GPU Backend GPU-taustajärjestelmä + Details Tiedot + GPU GPU + Rendering mode Renderöintitila + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Jäädytyslasso: jäädytä alue vetämällä, sulata Alt-vedolla (L) + Shadow Clipping Warning (U) Varjojen leikkautumisvaroitus (U) + Highlight Clipping Warning (O) Korostusten leikkautumisvaroitus (O) + Show Lab Color Readouts Näytä Lab-väriarvot + Show Vectorscope Näytä vektoriskooppi + Show Red at 3 o'clock Näytä punainen kello kolmen kohdalla + Show Skin Tone Indicator Näytä ihonsävyn osoitin + Tone Histogram Sävyhistogrammi + Show Selected Region Näytä valittu alue +cameraRaw Light Valo +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw -suodatin ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Turkoosi +cameraRaw Amount Määrä +cameraRaw Blacks Mustat +cameraRaw Vibrance Eloisuus +cameraRaw Color Grading Värimäärittely +cameraRaw Lights Vaaleat sävyt +cameraRaw Darks Tummat sävyt +cameraRaw Global Yleinen +cameraRaw Red Punainen +cameraRaw Orange Oranssi +cameraRaw Yellow Keltainen +cameraRaw Green Vihreä +cameraRaw Aqua Turkoosi +cameraRaw Blue Sininen +cameraRaw Purple Purppura +cameraRaw Magenta Magenta +cameraRaw Hue Sävy +cameraRaw Blending Sekoitus +cameraRaw Luminance Detail Luminanssin yksityiskohdat +cameraRaw Color Detail Värin yksityiskohdat +cameraRaw Roughness Karheus + Patch Tool Paikkaustyökalu + Patch: Paikkaus: + Lasso around an area, then drag the selection Rajaa alue lassolla ja vedä sitten valintaa + Transform On Drop Muunna pudotettaessa + Scale or rotate the moved area, then press {key} Skaalaa tai kierrä siirrettyä aluetta ja paina sitten {key} + Now drag the selection onto another area Vedä valinta nyt toiselle alueelle + Edit Type Muokkaa tekstiä + + New Style… Uusi tyyli… + Style {name} saved Tyyli {name} tallennettu + + Create new style (from the selected type) Luo uusi tyyli (valitusta tekstistä) + Create new style from the selected layer Luo uusi tyyli valitusta tasosta + + Make Default Aseta oletukseksi + Reset to Default Palauta oletukseen + Full-resolution preview unavailable Täyden resoluution esikatselu ei ole käytettävissä + No patterns Ei kuvioita + Enable {name} Ota {name} käyttöön + Hold Alt while drawing for straight segments Pidä Alt-näppäintä painettuna piirtäessäsi suoria segmenttejä varten + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Vedä tai napsauta monikulmion pisteitä · pidä Alt painettuna lassoa käytettäessä suorille segmenteille · {add} lisää · {sub} ennen piirtämistä vähentää + Extend Laajennus + Structure: Rakenne: + Content-Aware Move Tool Sisältötietoinen siirtotyökalu + TIFF Options TIFF-asetukset + Targa Options Targa-asetukset + JPEG Options JPEG-asetukset + Image Options Kuva-asetukset + Format Options Muotoasetukset + Baseline ("Standard") Perusmuotoinen (”vakio”) + Baseline Optimized Perusmuotoinen, optimoitu + 24 bits/pixel 24 bittiä/pikseli + 32 bits/pixel 32 bittiä/pikseli + Including layers will increase file size. Tasojen sisällyttäminen kasvattaa tiedostokokoa. + 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 + Couldn't place {name}: {error} Kohteen {name} sijoittaminen epäonnistui: {error} + Magnetic Lasso Tool Magneettinen lassotyökalu + Frequency Taajuus + Use tablet pressure to change pen width Muuta kynän leveyttä piirtopöydän paineella + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About Tietoja + Contributors Tekijät + Models Mallit + Sort Järjestä + Username Käyttäjätunnus + Display name Näyttönimi + Real name Oikea nimi + Reverse the order Käänteinen järjestys + Grab bag Sekalaiset + Table Taulukko + Name (A–Z) Nimi (A–Z) + Merged PRs Yhdistetyt PR:t + Commits Kommitit + Lines added Lisätyt rivit + Lines deleted Poistetut rivit + Line delta Rivien muutos + Binary assets added Lisätyt binääriresurssit + Binary assets removed Poistetut binääriresurssit + First commit Ensimmäinen kommitti + First Ensimmäinen + Last commit Viimeisin kommitti + Last Viimeisin + Contributors: {contributors} · Commits: {commits} Tekijät: {contributors} · Kommitit: {commits} + No contributor data was built into this copy. Tähän kopioon ei ole koottu tekijätietoja. + No model credits were built into this copy. Tähän kopioon ei ole koottu mallien tekijätietoja. + Company Yritys + Version Versio + % of all commits % kaikista kommiteista + Lines +/− Rivit +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR:ää, {commits} kommittia, +{added} / −{deleted} riviä (Δ {delta}), +{binAdded} / −{binDeleted} binääritiedostoa, {first} – {last} + +# Swatches panel + Create new swatch Luo uusi värimalli + New Swatch… Uusi värimalli… + New Swatch Group… Uusi värimalliryhmä… + Import Swatches… Tuo värimalleja… + Replace Swatches… Korvaa värimallit… + Export Swatches… Vie värimallit… + Export Swatches for Exchange… Vie värimallit vaihtoa varten… + Reset Swatches Palauta värimallit + Add to Swatches Lisää värimalleihin +# Layer Style > Blending Options (Advanced Blending) + Blend If: Sekoita jos: + Channels: Kanavat: + Knockout Läpileikkaus + Apply At Canvas Bounds Käytä myös kankaan reunoilla + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Värinvalitsin (tekstin väri) + Color Picker ({name}) Värinvalitsin ({name}) + Color Picker (Fill Color) Värinvalitsin (täyttöväri) + Fill color Täyttöväri + Color Picker (Stroke Color) Värinvalitsin (reunaviivan väri) + Stroke color Reunaviivan väri + Pick screen color Poimi näytön väri + Sample a pixel anywhere on the screen Poimi pikseli mistä tahansa näytöltä + Click to pick · Esc to cancel Poimi napsauttamalla · Esc peruuttaa + Pick a screen pixel or press Esc to cancel Poimi näytön pikseli tai peruuta painamalla Esc + Scroll to zoom Zoomaa vierittämällä + Drag to pan · Scroll to zoom · Double-click to fit Panoroi vetämällä · zoomaa vierittämällä · sovita kaksoisnapsauttamalla + Show/Hide All Panels Näytä/piilota kaikki paneelit + Show/Hide Panels Näytä/piilota paneelit + Could not open Avaaminen epäonnistui + {path}: the path is not valid Unicode; rename the file and open it again. {path}: polku ei ole kelvollista Unicodea; nimeä tiedosto uudelleen ja avaa se sitten. + +# Fill dialog, layer style contours and Image Size + Contents: Sisältö: + Custom Pattern: Mukautettu kuvio: + Foreground Color Edustaväri + Color… Väri… + 50% Gray 50 % harmaa + {size} (was {old}) {size} (aiemmin {old}) + Cone Kartio + Cone (Inverted) Kartio (käänteinen) + Domed Kupera + Domed (Inverted) Kupera (käänteinen) + Diagonal (Descending) Diagonaalinen (laskeva) + Contour Editor Kontuurimuokkain + Anti-aliased Antialiasoitu + Corner Kulma + Gloss Contour Kiiltokontuuri + Contour Kontuuri + Cone - Inverted Kartio - käänteinen + Cove - Deep Kolo - syvä + Cove - Shallow Kolo - matala + Gaussian Gauss + Half Round Puolipyöreä + Ring Rengas + Ring - Double Rengas - kaksinkertainen + Rolling Slope - Descending Aaltoileva rinne - laskeva + Rounded Steps Pyöristetyt portaat + Sawtooth 1 Sahalaita 1 + Linear Lineaarinen + + Red Eye Tool Punasilmätyökalu + Pupil Size Pupillin koko + Darken Amount Tummennusmäärä + Click a red pupil to neutralize it Neutraloi punainen pupilli napsauttamalla sitä + open source avoin lähdekoodi + …and {n} more …ja {n} muuta + + Eyedroppers: Pipetit: + Set Black Point Aseta mustapiste + Set Neutral Gray Point Aseta neutraali harmaapiste + Set White Point Aseta valkopiste + Unavailable in this color mode Ei käytettävissä tässä väritilassa + Selection Preview: Valinnan esikatselu: + Black Matte Musta pohja + White Matte Valkoinen pohja + Quick Mask Pikamaski + +# Layer colour labels. + Seafoam Merenvaahto + Indigo Indigo + Fuchsia Fuksia + Violet Violetti + Large document: redrawing on the CPU Suuri asiakirja: uudelleenpiirto suorittimella + Redrawing Piirretään uudelleen + This redraw took {seconds} s on the CPU. Tämä uudelleenpiirto kesti {seconds} s suorittimella. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Koko asiakirjaa muuttavat muokkaukset, kuten tason sekoitustila, peittävyys tai näkyvyys, piirtävät sen kokonaan uudelleen. + The GPU compositor wasn't used: {reason} GPU-koostinta ei käytetty: {reason} +fontWeight Thin Ohut +fontWeight ExtraLight Erittäin kevyt +fontWeight Light Kevyt +fontWeight Regular Normaali +fontWeight Medium Keskivahva +fontWeight SemiBold Puolilihavoitu +fontWeight Bold Lihavoitu +fontWeight ExtraBold Erittäin lihavoitu +fontWeight Black Raskas +fontWeight Italic Kursivoitu + +# Pattern Stamp tool + Pattern Stamp Tool Kuvioleimasin + 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 + +# Painting symmetry presets. + Dual Axis Kaksi akselia + Diagonal Diagonaalinen + Transform Symmetry Muunnossymmetria + Set painting symmetry options Aseta maalauksen symmetria-asetukset + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Pistenäyte + 3 by 3 Average 3 × 3 -keskiarvo + 5 by 5 Average 5 × 5 -keskiarvo + 11 by 11 Average 11 × 11 -keskiarvo + 31 by 31 Average 31 × 31 -keskiarvo + 51 by 51 Average 51 × 51 -keskiarvo + 101 by 101 Average 101 × 101 -keskiarvo + Sample Size: Näytteen koko: + 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 + +# 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 + Color to Alpha… Väri alfaksi… + Transparency Threshold Läpinäkyvyyskynnys + Opacity Threshold Peittävyyskynnys + PhotoCraft — Screen Color Picker PhotoCraft — näytön värinvalitsin + + Rasterize svg groups deeper than Rasteroi SVG-ryhmät, joiden syvyys on yli + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 poistaa tämän kynnyksen käytöstä; yli 100 tason syvyyteen sisäkkäiset SVG-ryhmät rasteroidaan aina. + Rule of Thirds Kolmanneksen sääntö + Triangle Kolmio + Golden Ratio Kultainen leikkaus + Golden Spiral Kultainen spiraali + Auto Show Overlay Näytä peite automaattisesti + Always Show Overlay Näytä peite aina + Never Show Overlay Älä näytä peitettä koskaan + Cycle Overlay Vaihda peitettä + Cycle Orientation Vaihda suuntaa + Set the overlay options for the Crop tool Aseta rajaustyökalun peitteen asetukset + 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-napsautus meni työpöydälle, joka siirtää ikkunoita Alt-vedolla. Aseta sen ikkunansiirtonäppäimeksi Super tai lukitse Alt kohdassa Ikkuna › Muokkausnäppäimet. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Värinvalitsin (tasotyylin väri) + Auto Hide Notifications Piilota ilmoitukset automaattisesti + Notification Duration (seconds) Ilmoituksen kesto (sekuntia) + Bundle Arrow-Key Nudges Yhdistä nuolinäppäinsiirrot + Nudge Bundle Pause (ms) Siirtojen yhdistämisen tauko (ms) + Appearance Mode Ulkoasutila + Dark Theme Tumma teema + Light Theme Vaalea teema + Pro Medium Pro (keskiharmaa) + Studio Light Studio vaalea + Next Appearance Mode Seuraava ulkoasutila + Sync with system Synkronoi järjestelmän kanssa + Active Aktiivinen + Color Picker (Solid Color) Värinvalitsin (yhtenäinen väri) + Straighten the image by drawing a line on it Suorista kuva piirtämällä siihen viiva + Set additional Crop options Aseta lisärajausasetuksia + Show Cropped Area Näytä pois rajattava alue + Enable Crop Shield Ota rajaussuoja käyttöön + Match Canvas Sama kuin kangas + Auto Adjust Opacity Säädä peittävyyttä automaattisesti + Crop Shield color Rajaussuojan väri + Color Picker (Crop Shield Color) Värinvalitsin (rajaussuojan väri) + From Center Keskeltä + Link radii Linkitä säteet + Star Ratio: Tähden suhde: + Create Rectangle Luo suorakulmio + Create Ellipse Luo ellipsi + Create Triangle Luo kolmio + Create Polygon Luo monikulmio + Pressure curve Painekäyrä + Click to add a point, right-click to remove Lisää piste napsauttamalla, poista napsauttamalla hiiren kakkospainikkeella + Soft Pehmeä + Hard Kova + Some brush presets could not be loaded Joitakin siveltimen esiasetuksia ei voitu ladata + Retry saving presets Yritä esiasetusten tallennusta uudelleen + Brush preset changes are not saved: {error} Siveltimen esiasetusten muutoksia ei ole tallennettu: {error} + Keep this tab open to retain your brushes. Pidä tämä välilehti auki, jotta siveltimet säilyvät. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Selaimen tallennustila ei ole käytettävissä; siveltimet säilyvät vain tämän istunnon ajan. Pidä tämä välilehti auki, jotta ne säilyvät. + Saving brush presets… Keep this tab open until saving finishes. Tallennetaan siveltimen esiasetuksia… Pidä tämä välilehti auki, kunnes tallennus päättyy. + Reflect Heijastus + Glow Hehku + Negation Negaatio + XOR XOR + Color Burn (Paint.NET) Väritummennus (Paint.NET) + Color Dodge (Paint.NET) Värivaalennus (Paint.NET) + Stroke Options Reunaviivan asetukset + Solid Yhtenäinen + Dashed Katkoviiva + Dotted Pisteviiva + Caps Päätystyylit + Butt / Flat Tylppä / suora + Corners Kulmat + Miter Jiiri + Round Pyöreä + Bevel Viiste + Miter limit Jiiriraja + Dashed line Katkoviiva + Dash Viiva + Gap Väli + Dash / gap lengths × stroke width Viivan / välin pituudet × reunaviivan leveys + Add pair Lisää pari + Remove pair Poista pari + Dash offset Katkoviivan siirtymä + Open paths use centered strokes Avoimet polut käyttävät keskitettyjä reunaviivoja + Use finite lengths and a positive pattern total Käytä äärellisiä pituuksia ja positiivista kuvion kokonaispituutta + No subject found — try Object Selection or the Lasso Kohdetta ei löytynyt – kokeile objektin valintaa tai lassoa + Quick Selection found nothing there Pikavalinta ei löytänyt sieltä mitään + Object Selection found nothing there — try a tighter rectangle Objektin valinta ei löytänyt sieltä mitään – kokeile tiukempaa suorakulmiota + No subject found — try a rectangle around the object Kohdetta ei löytynyt – kokeile suorakulmiota objektin ympärille + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Poistetaanko taso ’{name}’? + Delete {count} layers? {names} Poistetaanko {count} tasoa? {names} + Delete layer? Poistetaanko taso? + The document is closed Asiakirja on suljettu + The document or selected layers changed. Please request deletion again. Asiakirja tai valitut tasot muuttuivat. Pyydä poistoa uudelleen. + Missing delete command Poistokomento puuttuu + Invalid delete command Virheellinen poistokomento + Layer deleted, but confirmation preference could not be saved: {error} Taso poistettiin, mutta vahvistusasetusta ei voitu tallentaa: {error} + Add a layer style Lisää tasotyyli + Add a mask (from the selection; {key} inverts) Lisää maski (valinnasta; {key} kääntää) + Add an adjustment Lisää säätö + Add to selection ({key}) Lisää valintaan ({key}) + Adjustment visible Säätö näkyvissä + Align and distribute Tasaa ja jaa tasaisesti + Align horizontal centers Tasaa vaakakeskikohdat + Align left edges Tasaa vasemmat reunat + Align right edges Tasaa oikeat reunat + Always use pressure for opacity Ohjaa peittävyyttä aina paineella + Always use pressure for size Ohjaa kokoa aina paineella + Angle Gradient Kulmaliukuväri + Auto-Select: Automaattivalinta: + Brush Preset picker Siveltimen esiasetusten valitsin + Cancel current crop operation (Esc) Peruuta nykyinen rajaus (Esc) + Click the first point or press {key} to close · Esc cancels Sulje napsauttamalla ensimmäistä pistettä tai painamalla {key} · Esc peruuttaa + Click the first point or press {key} to close · {del} removes a point · Esc cancels Napsauta ensimmäistä pistettä tai sulje painamalla {key} · {del} poistaa pisteen · Esc peruuttaa + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Napsauta ja liiku reunaa pitkin · kiinnitä piste napsauttamalla · {alt}-napsautus: suora segmentti · sulje kaksoisnapsauttamalla + Clip to layer below Leikkaa alla olevaan tasoon + Color & Swatches Väri ja värimallit + Commit current crop operation ({key}) Vahvista nykyinen rajaus ({key}) + Create a new group Luo uusi ryhmä + Create a new layer Luo uusi taso + Create new document from current state Luo uusi asiakirja nykyisestä tilasta + Create new fill or adjustment layer Luo uusi täyttö- tai säätötaso + Create new snapshot Luo uusi tilannevedos + From: Lähde: + Full Document Koko asiakirja + Merged Layers Yhdistetyt tasot + Delete Cropped Pixels Poista rajatut pikselit + Delete current state Poista nykyinen tila + Delete layer Poista taso + Diamond Gradient Timanttiliukuväri + Doc: {w} × {h} px Asiak.: {w} × {h} px + Enable airbrush-style build-up effects Ota käyttöön airbrush-tyyliset kertymätehosteet + Filter for adjustment layers Suodata säätötasot + Filter for pixel layers Suodata pikselitasot + Filter for shape layers Suodata muototasot + Filter for smart objects Suodata älykkäät objektit + Filter for type layers Suodata tekstitasot + Fixed Ratio Kiinteä kuvasuhde + Fixed Size Kiinteä koko + Follows only edges this close to the pointer ([ and ] change it) Seuraa vain näin lähellä osoitinta olevia reunoja ([ ja ] muuttavat) + Hide Properties Piilota ominaisuudet + Higher values follow only edges that contrast sharply with their surroundings Korkeammat arvot seuraavat vain reunoja, joiden kontrasti ympäristöön on jyrkkä + How often fastening points are placed by themselves Kuinka usein kiinnityspisteitä lisätään automaattisesti + Hue/Saturation Sävy/värikylläisyys + Intersect with selection ({key}) Leikkaa valinnan kanssa ({key}) + Kind Tyyppi + Linear Gradient Lineaarinen liukuväri + Link layers Linkitä tasot + Lock Lukitse + Lock all Lukitse kaikki + Lock image pixels Lukitse kuvan pikselit + Lock layer Lukitse taso + Lock position Lukitse sijainti + Lock transparent pixels Lukitse läpinäkyvät pikselit + New selection Uusi valinta + No properties Ei ominaisuuksia + Pressure for Opacity Paine peittävyydelle + Pressure for Size Paine koolle + Prevent auto-nesting in and out of Artboards and Frames Estä automaattinen sisäkkäistys piirtoalustoihin ja kehyksiin ja niistä pois + Radial Gradient Säteittäinen liukuväri + Reflected Gradient Heijastettu liukuväri + Reset to defaults Palauta oletukset + Set additional smoothing options Aseta tasoituksen lisäasetukset + Show Transform Controls Näytä muunnoskahvat + Subtract from selection ({key}) Vähennä valinnasta ({key}) + Swaps height and width Vaihtaa korkeuden ja leveyden + Tolerance Toleranssi + Add vector mask Lisää vektorimaski + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Lähennä napsauttamalla · loitonna {key}-napsautuksella · lähennä/loitonna vetämällä oikealle/vasemmalle + Appearance Settings… Ulkoasuasetukset… + Add to favorites Lisää suosikkeihin + Remove from favorites Poista suosikeista +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Valmistaja +cameraData Model Malli +cameraData Date Taken Kuvauspäivä +cameraData Exposure Valotusaika +cameraData F-Stop Aukko +cameraData ISO Speed ISO-herkkyys +cameraData Focal Length Polttoväli +cameraData Focal Length (35 mm) Polttoväli (35 mm) +cameraData Lens Objektiivi +# Shared panel docking + Activate Panel Aktivoi paneeli + Change Panel Group Vaihda paneeliryhmää + Change Panel Layout Vaihda paneelin asettelua + Close Panel Sulje paneeli + Dock Panel Telakoi paneeli + Float Panel Irrota paneeli + Move Panel Siirrä paneelia + Group with Ryhmitä paneelin kanssa + Panel menu Paneelivalikko + Resize panel window Muuta paneeli-ikkunan kokoa + Resize panels Muuta paneelien kokoa + Use Classic Mode Käytä klassista tilaa + Auto Center Preview Keskitä esikatselu automaattisesti + Next Document Seuraava asiakirja + Previous Document Edellinen asiakirja + W x H x Resolution L × K × resoluutio + Front Image Etummainen kuva + Frame Tool Kehystyökalu + Create a new rectangular frame Luo uusi suorakulmainen kehys + Create a new elliptical frame Luo uusi elliptinen kehys + Include in playback Sisällytä toistoon + Perspective Crop Tool Perspektiivirajaustyökalu + Show Grid Näytä ruudukko + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Vedä laatikko tai napsauta neljää kulmaa · vedä kulmat vinoutuneen muodon reunoille · {commit} vahvistaa · Esc peruuttaa + Artboard Tool Piirtoalustatyökalu + Make Portrait Muuta pystysuuntaiseksi + Make Landscape Muuta vaakasuuntaiseksi + Add New Artboard Lisää uusi piirtoalusta + Freeform Pen Tool Vapaamuotoinen kynätyökalu + Curvature Pen Tool Kaarevuuskynätyökalu + Curve Fit: Käyrän sovitus: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Vedä: piirrä vapaalla kädellä · {alt}-napsautus: suora segmentti · Vapauta aloituspisteessä: sulje + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Napsauta: pehmeä piste · Kaksoisnapsauta: kulma · Vedä pistettä: siirrä · Napsauta ensimmäistä pistettä: sulje · {key} lopeta + Scale: Skaalaus: + Snap to Origin Tartu origoon + Link with Layer Linkitä tasoon + Rasterize Layer Style Rasteroi tasotyyli + Search settings… Hae asetuksia… + No matching settings Ei vastaavia asetuksia +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Näytteistyssivellin + Sampling Area Overlay Näytteistysalueen peite + Show Sampling Area Näytä näytteistysalue + Indicates Ilmaisee + Sampling Area Näytteistysalue + Excluded Area Poissuljettu alue + Sampling Area Options Näytteistysalueen asetukset + Rectangular Suorakulmainen + Fill Settings Täyttöasetukset + Rotation Adaptation Kierron mukautus + Very High Erittäin korkea + Full Täysi + Mirror Peilaus + Output Settings Tulosasetukset + Output To Tuloksen kohde + Duplicate Layer Monista taso + Contract Supista + + Animation Options Animaatioasetukset + Entire Document Koko asiakirja + Filter Options Suodatusasetukset + Layer Bounds Tason rajat + Layers Panel Options Tasot-paneelin asetukset + Lock Layers Lukitse tasot + New Artboard… Uusi piirtoalusta… + New Group… Uusi ryhmä… + Prevent Auto-Nesting Estä automaattinen sisäkkäistys + Show Layers Näytä tasot + Thumbnail Contents Pikkukuvan sisältö + Thumbnail Size Pikkukuvan koko + Use Default Masks on Fill Layers Käytä oletusmaskeja täyttötasoissa + Expand New Effects Laajenna uudet tehosteet + Add “copy” to Copied Layers and Groups Lisää ”kopio” kopioituihin tasoihin ja ryhmiin + Preview on canvas when reordering layers Esikatsele kankaalla tasojen järjestystä muutettaessa + Show layer mask badges Näytä tasomaskien merkit + +# Smart Filter editors + Blending Options ({filter}) Sekoitusasetukset ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Piilota tason tehosteet ja älykkäät suodattimet ({key}-napsautus: kaikki tasot) + Show the layer's effects and Smart Filters ({key}-click: all layers) Näytä tason tehosteet ja älykkäät suodattimet ({key}-napsautus: kaikki tasot) + Wait for the current job to finish. Odota, että nykyinen työ valmistuu. + The document or Smart Filter changed. Reopen its dialog. Asiakirja tai älykäs suodatin muuttui. Avaa sen valintaikkuna uudelleen. + Opacity must be between 0% and 100%. Peittävyyden on oltava välillä 0–100 %. + Dissolve is approximated: it blends without Photoshop's dither. Liuota on likimääräinen: se sekoittaa ilman Photoshopin ditteröintiä. + Loading fonts… Ladataan fontteja… + Response curve Vastekäyrä + Tool Työkalu + Simulate Pressure Simuloi painetta + Stroke Path… Piirrä polun reunaviiva… + Background Eraser Taustapyyhekumi + Healing Brush Korjaussivellin + Spot Healing Brush Pistekorjaussivellin + Smudge Tahraus + Sponge Sieni + Amount: Määrä: + Protect: Suojaa: + Protect skin tones Suojaa ihonsävyt + Edit points to modify the curve Muokkaa käyrää muokkaamalla pisteitä + Draw to modify the curve Muokkaa käyrää piirtämällä +curves Smooth Tasoita + Smooth the curve values Tasoita käyrän arvot + Drag to draw the curve · {key}-click draws a straight line Piirrä käyrä vetämällä · {key}-napsautus piirtää suoran viivan + Grid Size: Ruudukon koko: + Vanishing Point: click four corners to create a plane Pakopiste: napsauta neljää kulmaa luodaksesi perspektiivitason + Vanishing Point is not active Pakopiste ei ole aktiivinen + Apply Vanishing Point ({key}) Käytä pakopistettä ({key}) + Fix the red plane first: its corners must form a convex quad Korjaa ensin punainen perspektiivitaso: sen kulmien täytyy muodostaa kupera nelikulmio + Alt-click a plane to set the clone source Alt-napsauta perspektiivitasoa asettaaksesi kloonauslähteen + Too many clone strokes: apply Vanishing Point first Liikaa kloonausvetoja: käytä ensin Pakopiste-suodatinta +# Select and Mask view modes (#1754) + Onion Skin Sipulinkuori + Marching Ants Marssivat muurahaiset + On Black Mustalla + On White Valkoisella + On Layers Tasoilla + Show Edge Näytä reuna + Show Original Näytä alkuperäinen + F cycles the views; hold X to see the image F vaihtaa näkymiä; pidä X painettuna nähdäksesi kuvan +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Reunan tarkennussivellin (R) + Expand Detection Area Laajenna tunnistusaluetta + Restore Original Edge Palauta alkuperäinen reuna + Paint over hair or fur on the image; Alt paints the other mode Maalaa kuvan hiusten tai turkin päälle; Alt maalaa toisessa tilassa + Clear Brush Strokes Tyhjennä siveltimenvedot + Show rulers Näytä viivaimet + New Crop Preset… Uusi rajausesiasetus… + Delete Crop Preset… Poista rajausesiasetus… + Select the crop presets to delete: Valitse poistettavat rajausesiasetukset: + Select the crop presets to delete Valitse poistettavat rajausesiasetukset + Enter a name for the crop preset Anna rajausesiasetukselle nimi + There is no crop size or ratio to save Tallennettavaa rajauskokoa tai -suhdetta ei ole + Presentation Esitystapa + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Sijoita napsauttamalla · poista Alt-napsautuksella tai vetämällä kankaan ulkopuolelle + No document open Ei avointa asiakirjaa + {n} of {max} sample points {n}/{max} näytepistettä + +# History panel menu (#2966) + New Snapshot… Uusi tilannevedos… + History Options… Historian asetukset… +# History panel › History Options… (#2966) + Allow Non-Linear History Salli epälineaarinen historia + Make Layer Visibility Changes Undoable Tee tasojen näkyvyysmuutoksista kumottavia +# Print dialog (#2697) + Emulsion Down Emulsio alaspäin + Functions Funktiot + Print Resolution: Tulostusresoluutio: + Use Previous Layer to Create Clipping Mask Luo leikkausmaski edellisen tason avulla + Fill with {mode}-neutral color (white) Täytä {mode}-neutraalilla värillä (valkoinen) + Fill with {mode}-neutral color (black) Täytä {mode}-neutraalilla värillä (musta) + Fill with {mode}-neutral color (50% gray) Täytä {mode}-neutraalilla värillä (50 % harmaa) + (No neutral color exists for {mode} mode.) (Tilalle {mode} ei ole neutraalia väriä.) + Apply mask to layer before removing? Käytetäänkö maskia tasoon ennen poistamista? + The layer or its mask changed. Drag the mask onto the Trash again. Taso tai sen maski muuttui. Vedä maski uudelleen roskakoriin. + Delete the vector mask? Poistetaanko vektorimaski? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Viety {n} värimalli tiedostoon {file}|Viety {n} värimallia tiedostoon {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Tuotu {n} sivellin ryhmästä {group}|Tuotu {n} sivellintä ryhmästä {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Tuotu {n} värimalli tiedostosta {file}|Tuotu {n} värimallia tiedostosta {file} +@plural Played {n} step|Played {n} steps Suoritettu {n} vaihe|Suoritettu {n} vaihetta +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Päivitetty {n} levyllä muuttunut linkitetty älykäs objekti|Päivitetty {n} levyllä muuttunutta linkitettyä älykästä objektia +@plural Saved for Web: {n} file|Saved for Web: {n} files Tallennettu verkkoa varten: {n} tiedosto|Tallennettu verkkoa varten: {n} tiedostoa +@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} tämän asetelman tallentama taso poistettiin. Päivitä asetelma tai tyhjennä varoitus.|{n} tämän asetelman tallentamaa tasoa poistettiin. Päivitä asetelma tai tyhjennä varoitus. +@plural {n} plane|{n} planes {n} perspektiivitaso|{n} perspektiivitasoa +@plural {n} preset|{n} presets {n} esiasetus|{n} esiasetusta +@plural {n} sample point removed|{n} sample points removed {n} näytepiste poistettu|{n} näytepistettä poistettu +@plural {n} slice|{n} slices {n} viipale|{n} viipaletta +@plural {n} step|{n} steps {n} vaihe|{n} vaihetta +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} muu siveltimen esiasetuksen varoitus)|{warning} (+{n} muuta siveltimen esiasetuksen varoitusta) +@plural {what} with {n} warning|{what} with {n} warnings {what} ({n} varoitus)|{what} ({n} varoitusta) +# View › Guides › New Guide… › Color + Light Blue Vaaleansininen + Light Red Vaalea punainen + Medium Blue Keskisininen + Light Gray Vaaleanharmaa + Object Finder Objektien etsijä + No object found there — drag a rectangle around it Kohdasta ei löytynyt objektia — vedä suorakulmio sen ympärille + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktiivisella tasolla ei ole tässä pikseleitä — ota käyttöön ”Ota näyte kaikilta tasoilta” tai valitse taso, jossa on pikseleitä + Remove after each stroke Poista jokaisen vedon jälkeen + Apply the removal ({key}) Käytä poistoa ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Poistettavat vedot: {n} (Enter vahvistaa, Esc peruuttaa) + Load Contours… Lataa kontuureja… + Replace Contours… Korvaa kontuurit… + Save Contours… Tallenna kontuurit… + Delete Contour Poista kontuuri + Loaded contours from {file} Kontuurit ladattu tiedostosta {file} + Saved contours to {file} Kontuurit tallennettu tiedostoon {file} + Show home recent files Näytä viimeisimmät tiedostot aloitusnäkymässä + Clear Brush Controls Nollaa siveltimen asetukset + Reset All Locked Settings Nollaa kaikki lukitut asetukset + Copy Texture to Other Tools Kopioi tekstuuri muihin työkaluihin + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Lataa toiminnot… + Save Actions… Tallenna toiminnot… + New Action… Uusi toiminto… + Start Recording Aloita nauhoitus + Stop Recording Lopeta nauhoitus + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Ladattu {n} toiminto tiedostosta {file}|Ladattu {n} toimintoa tiedostosta {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} vaiheella ei ole vielä vastaavuutta PhotoCraftissa|{n} vaiheella ei ole vielä vastaavuutta PhotoCraftissa +@plural Saved {n} action to {file}|Saved {n} actions to {file} Tallennettu {n} toiminto tiedostoon {file}|Tallennettu {n} toimintoa tiedostoon {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} vaihe jätettiin pois, koska sillä ei ole Photoshop-vastinetta|{n} vaihetta jätettiin pois, koska niillä ei ole Photoshop-vastinetta + + File Naming Tiedostojen nimeäminen + Starting serial Aloitusnumero +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Lisää pysäytys… + Save Set… Tallenna sarja… + Message: Viesti: + Allow Continue Salli jatkaminen + Show dialog during playback Näytä valintaikkuna toiston aikana + Select an action first Valitse ensin toiminto + Action playback stopped Toiminnon toisto pysäytetty + Step {step} is waiting for its dialog Vaihe {step} odottaa valintaikkunaansa +@plural {n} action|{n} actions {n} toiminto|{n} toimintoa + Background (Default) Tausta (oletus) + Content-Aware Fill Sisältötietoinen täyttö +# Batch / Image Processor + Include All Subfolders Sisällytä kaikki alikansiot + Auto Add/Delete Automaattinen lisäys/poisto + +# 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… + 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ä + Calibration Kalibrointi + Red Primary Punainen pääväri + Green Primary Vihreä pääväri + Blue Primary Sininen pääväri + Write xmp sidecars Kirjoita XMP-sivutiedostot + Set the tone sliders from the image Aseta sävysäätimet kuvan perusteella + Settings from {name} Asetukset tiedostosta {name} + Settings from {name}; {count} not supported: {names} Asetukset tiedostosta {name}; {count} tukematonta: {names} + Could not write {name}: {error} Tiedostoa {name} ei voitu kirjoittaa: {error} + Kept {name}: written by another application Tiedosto {name} säilytettiin: sen on kirjoittanut toinen ohjelma + Shadow Tint Varjojen sävytys +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maalaa Historia-paneelin lähdetilasta + Sets the source for the history brush Asettaa historiasiveltimen lähteen + +# Dynamic selection context menu and appearance favorites + Select Inverse Käännä valinta + Pro (Light) Pro (vaalea) + Pro (Light Gray) Pro (vaaleanharmaa) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Tunniste + Date and Time Päivämäärä ja aika + Scale Units Mittakaavayksiköt + Scale Factor Mittakaavakerroin + Area Pinta-ala + Perimeter Ympärysmitta + Circularity Pyöreys + Gray Value (Minimum) Harmaa-arvo (minimi) + Gray Value (Maximum) Harmaa-arvo (maksimi) + Gray Value (Mean) Harmaa-arvo (keskiarvo) + Gray Value (Median) Harmaa-arvo (mediaani) + Integrated Density Integroitu tiheys + Length Pituus + Show Favorite Fonts Näytä suosikkifontit + No favorite fonts yet: click the star beside a font to add it. Ei vielä suosikkifontteja: lisää fontti napsauttamalla sen vieressä olevaa tähteä. + Favorite fonts Suosikkifontit + Add to Favorites Lisää suosikkeihin + Remove from Favorites Poista suosikeista + Show All Fonts Näytä kaikki fontit + All Classes Kaikki luokat + Blackletter Fraktuura + Decorative Koristefontti + Font preview: Fontin esikatselu: + Handwritten Käsinkirjoitettu + Hide Font Classes Piilota fonttiluokat + Hide Styles Piilota tyylit + Monospace Tasalevyinen + No fonts match these filters. Mikään fontti ei vastaa näitä suodattimia. + No similar fonts: the current font doesn't say what it looks like. Ei samankaltaisia fontteja: nykyinen fontti ei kerro, miltä se näyttää. + OpenType font OpenType-fontti + Sans Serif Groteski + Script Kaunokirjoitus + Serif Antiikva + Show Font Classes Näytä fonttiluokat + Show Similar Fonts Näytä samankaltaiset fontit + Show Styles Näytä tyylit + Show Variable Fonts Näytä muuttuvat fontit + Slab Serif Egyptienne + TrueType font TrueType-fontti + Variable font Muuttuva fontti +# Bevel & Emboss shading controls. + Highlight Mode Korostuksen sekoitustila + Highlight Color Korostuksen väri + Highlight Opacity Korostuksen peittävyys + Shadow Mode Varjon sekoitustila + Shadow Color Varjon väri + Shadow Opacity Varjon peittävyys +# PhotoCraft release checks. + Check for Updates… Tarkista päivitykset… + PhotoCraft Updates PhotoCraftin päivitykset + Checking for updates… Tarkistetaan päivityksiä… + Open the release page to choose an installer for your system. Avaa julkaisusivu ja valitse järjestelmällesi sopiva asennusohjelma. + Open Download Page Avaa lataussivu + PhotoCraft is up to date. PhotoCraft on ajan tasalla. + Could not check for updates. Päivityksiä ei voitu tarkistaa. + Updates have not been checked yet. Päivityksiä ei ole vielä tarkistettu. + Check Again Tarkista uudelleen + Automatically Check for Updates Tarkista päivitykset automaattisesti + Installed version: {version} Asennettu versio: {version} + PhotoCraft {version} is available. PhotoCraft {version} on saatavilla. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Käytä upotettua profiilia ({profile}) + Convert to the working space ({profile}) Muunna työavaruuteen ({profile}) + Remember this choice for future images Muista tämä valinta tuleville kuville + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Ota kysely uudelleen käyttöön kohdasta Kysy täsmäämättömyydestä valikossa Muokkaa › Väriasetukset. + Button Mode Painiketila + Button Mode On Painiketila käytössä + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Tummenna käyttöliittymää + Lighten Interface Vaalenna käyttöliittymää +# Bevel & Emboss Technique. + Technique Tekniikka + Chisel Hard Kova talttaus + Chisel Soft Pehmeä talttaus + Open color-less EXR as ACEScg Avaa EXR ilman väriavaruustietoa ACEScg-muotoisena + Do not append "copy" to filename when saving a copy Älä lisää tiedostonimeen ”kopio”, kun tallennat kopion + +# View › Guides › right-click a guide + Edit Selected Guides Muokkaa valittuja apuviivoja + Clear Selected Guides Poista valitut apuviivat + Bicubic Automatic Bikubinen automaattinen + Bicubic Smoother Bikubinen pehmeämpi + Bicubic Sharper Bikubinen terävämpi + Pasting from other apps is unavailable Liittäminen muista sovelluksista ei ole käytettävissä + 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 ei pääse järjestelmän leikepöydälle tässä istunnossa. Waylandissa Flatpak-hiekkalaatikko piilottaa protokollan, jonka kautta muiden sovellusten kuvat luetaan. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Jos haluat liittää muista sovelluksista, käynnistä PhotoCraft XWaylandin alla: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Ei liitettävää: PhotoCraft ei pysty lukemaan järjestelmän leikepöytää täällä + Delete this set Poista tämä sarja + New Set… Uusi sarja… + PhotoCraft Defaults PhotoCraft-oletukset + Save a list of these shortcuts as a web page Tallenna luettelo näistä pikanäppäimistä verkkosivuna + Save the changes to this set Tallenna muutokset tähän sarjaan + Save these shortcuts as a .kys file Tallenna nämä pikanäppäimet .kys-tiedostona + Save these shortcuts as a new set Tallenna nämä pikanäppäimet uutena sarjana + Set deleted. Sarja poistettu. + Set saved. Sarja tallennettu. + Set: Sarja: + Summarize… Yhteenveto… + There are too many sets: delete one first. Sarjoja on liikaa: poista ensin yksi. + set name sarjan nimi + {set} (modified) {set} (muokattu) + Random Satunnainen + Show file name in title Näytä tiedostonimi otsikkorivillä + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 %:n kynnysarvo + Pattern Dither Kuvioditteröinti + Diffusion Dither Diffuusioditteröinti + Halftone Screen Rasteri + Custom Pattern Mukautettu kuvio + Frequency: Taajuus: + Lines/Inch Viivaa/tuuma + Lines/Centimeter Viivaa/senttimetri + Ellipse Ellipsi + Diamond Timantti + Cross Risti + Accept and Go to Conflict Hyväksy ja siirry ristiriitaan + Changes undone. Muutokset kumottu. + Choose a new shortcut for this command, or leave it without one. Valitse tälle komennolle uusi pikanäppäin tai jätä se ilman. + Put this command's shortcuts back as they were Palauta tämän komennon pikanäppäimet entiselleen + Take the shortcut from the other command and select it Ota pikanäppäin toiselta komennolta ja valitse se + Undo Changes Kumoa muutokset + {shortcut} is reserved by the system and can't be assigned. {shortcut} on järjestelmän varaama eikä sitä voi määrittää. + Low-Latency Drawing (vsync off, applies at next launch) Matalan viiveen piirto (vsync pois päältä, astuu voimaan seuraavalla käynnistyksellä) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Vapaata muunnosta toteutetaan vielä; yritä uudelleen, kun se on valmis. + The transform couldn't be applied. Muunnosta ei voitu toteuttaa. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sen tasot tarvitsevat {needed} GPU-muistia; budjetti jättää niille {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. Aseta GPU-muisti kohdassa Asetukset › Suorituskyky arvoon, joka mahtuu näytönohjaimesi muistiin, enintään 3/8 sen muistista. Automaattinen seuraa muistin käyttöä, enintään neljäsosa tämän tietokoneen muistista ja 3/8 kortin muistista, eikä laske asiakirjaa sellaisen kortin kuormaksi, jonka muisti tunnetaan. Tilarivillä näkyy GPU-muisti täynnä, kun tasot eivät mahdu. + GPU Memory Full GPU-muisti täynnä + 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. Tasot tarvitsevat {needed} GPU-muistia ja saavat käyttää {budget}, joten koko asiakirjan uudelleenpiirtävät muokkaukset tehdään suorittimella. Aseta GPU-muisti napsauttamalla. + GPU memory budget: {budget} GPU-muistibudjetti: {budget} + Graphics memory: {size} Grafiikkamuisti: {size} + This document's layers: {needed} of {budget} for layers Tämän asiakirjan tasot: {needed} / {budget} tasoille + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Tämän asiakirjan tasot tarvitsevat {needed}, mutta saavat käyttää {budget}: koko asiakirjan uudelleenpiirrot käyttävät suoritinta + GPU Memory (MB, 0 = Automatic) GPU-muisti (Mt, 0 = automaattinen) + {path}: the path is not valid Unicode; save under another name. {path}: polku ei ole kelvollista Unicodea; tallenna toisella nimellä. + Replace Korvaa + {name} was changed by another app after it was opened or saved here. Toinen ohjelma on muuttanut tiedostoa {name} sen jälkeen, kun se avattiin tai tallennettiin täällä. + Replace it with this document, or cancel and use File › Save As to keep both. Korvaa se tällä asiakirjalla, tai peruuta ja käytä komentoa Tiedosto › Tallenna nimellä, jos haluat säilyttää molemmat. + +# 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) (tulossa pian) + (none) (ei mitään) + 32-bit Preview Options 32-bittisen esikatselun asetukset + 3D… 3D… + A: K: + AI Integrations Tekoälyintegraatiot + About {app} Tietoja: {app} + Accented Edges Korostetut reunat + Action playback failed: {error} Toiminnon toisto epäonnistui: {error} + Actual Pixels Todelliset pikselit + Add Mask Lisää maski + Add Noise Lisää kohinaa + Add a mask (from the selection; ⌥ inverts) Lisää maski (valinnasta; ⌥ kääntää) + Add space after paragraph Lisää väli kappaleen jälkeen + Add space before paragraph Lisää väli ennen kappaletta + Add to selection (⇧) Lisää valintaan (⇧) + Added swatch {name} Värimalli {name} lisätty + Adds grain to the soft edges of the brush tip. Lisää rakeisuutta siveltimen kärjen pehmeisiin reunoihin. + Adjust Interval For Loops Säädä silmukoiden väliä + Adjustment Layers Säätötasot + Adjustment Properties Säädön ominaisuudet + Adjustment layers have nothing to transform Säätötasoilla ei ole mitään muunnettavaa + Adobe PDF Presets… Adobe PDF -esiasetukset… + Adobe-rgb-compat Adobe RGB -yhteensopiva + Adwaita (Dark) Adwaita (tumma) + Adwaita (Light) Adwaita (vaalea) + After Jälkeen + Airbrush: keeps painting while the pointer rests. Ilmasivellin: maalaa edelleen, kun osoitin on paikallaan. + All shortcuts reset to Photoshop defaults. Kaikki pikanäppäimet palautettu Photoshopin oletuksiin. + Altitude Korkeus + Ambience Ympäristövalo + Ambient Ympäristö + Amplitude Max Amplitudi max + Amplitude Min Amplitudi min + Android 1080p Android 1080p + Angled Strokes Kulmikkaat vedot + Anisotropic Anisotrooppinen + Any Mikä tahansa + Apple Watch 45mm Apple Watch 45mm + Apply Data Set Käytä tietojoukkoa + Apply Image Käytä kuvaa + Apply Mask Käytä maskia + Apply to All Käytä kaikkiin + Apply {option}: {state} Käytä {option}: {state} + Arbitrary Mielivaltainen + Arc Lower Alakaari + Arc Upper Yläkaari + Are you sure you want to quit? Haluatko varmasti lopettaa? + Are you sure? Oletko varma? + Around Center Keskipisteen ympäri + Arrangement Järjestely + Arrows Nuolet + Artboard from Group Piirtoalusta ryhmästä + Artboard from Layers Piirtoalusta tasoista + Artboards Piirtoalustat + Artboards to Files Piirtoalustat tiedostoiksi + Artboards to PDF Piirtoalustat PDF:ksi + Artistic Taiteellinen + Ask On Mismatch Kysy ristiriidassa + Ask On Missing Kysy puuttuessa + Ask On Paste Kysy liitettäessä + Assign Profile Määritä profiili + At Kohdassa + Author Title Tekijän nimike + Auto Scale Automaattinen skaalaus + Auto-Align Layers Kohdista tasot automaattisesti + Auto-Blend Layers Sulauta tasot automaattisesti + Autosave failed: {error} Automaattinen tallennus epäonnistui: {error} + Back Takaisin + Backend: {backend} ({type}) Taustajärjestelmä: {backend} ({type}) + Background Level Taustavärin taso + Bamboo Bambu + Bas Relief Matala reliefi + Base Tree Type Puun perustyyppi + Based On Perustuu + Baseline shift Perusviivan siirtymä + Basic Latin Latinalainen perusmerkistö + Batch Eräajo + Before Ennen + Bend Taivutus + Berries Marjat + Best Paras + Black Clip Mustan leikkaus + Black Intensity Mustan voimakkuus + Blade Curvature Lamellien kaarevuus + Blast Puhallus + Bleach Bypass Valkaisun ohitus + Blend Clipped Layers as Group Sekoita leikkausmaskin tasot ryhmänä + Blend Interior Effects as Group Sekoita sisäiset tehosteet ryhmänä + Blend Text Gamma Tekstin sekoitusgamma + Blending Mode Sekoitustila + Bloat Pullista + Blocks Lohkot + Blue Yellow Sini/keltainen + Blues Siniset + Border Reunus + Border Thickness Reunuksen paksuus + Box Blur Laatikkosumennus + Bpc Mustan pisteen kompensointi + Branches Color Oksien väri + Branches Height Oksien korkeus + Branches Thickness Oksien paksuus + Breeze Dark Breeze (tumma) + Breeze Light Breeze (vaalea) + Brick Tiili + Bristle Detail Harjaksen yksityiskohta + Browse Selaa + Brush Detail Siveltimen yksityiskohdat + Brush Settings… (F5): every option of this brush Siveltimen asetukset… (F5): tämän siveltimen kaikki asetukset + Brush Strokes Siveltimenvedot + Brush Type Siveltimen tyyppi + Burlap Juuttikangas + Calculations Laskutoimitukset + Camera Raw Camera Raw + Camera Raw Filter Camera Raw -suodatin + Cancel transform Peruuta muunnos + Candle Light Kynttilänvalo + Canvas Size Kankaan koko + Case Sensitive Huomioi kirjainkoko + Ccw Vastapäivään + Cell Size Solukoko + Center X Keskipiste X + Center Y Keskipiste Y + Chalk Area Liidun alue + Change Find Etsi ja muuta + Changes Saved Muutokset tallennettu + Changes have not been saved. Muutoksia ei ole tallennettu. + Change… Muuta… + Channel1 Kanava 1 + Channel2 Kanava 2 + Channel3 Kanava 3 + Channel4 Kanava 4 + Charcoal Area Hiilen alue + Charcoal Thickness Hiilen paksuus + Check for Updates Tarkista päivitykset + Checkerboard Shakkiruudukko + Choke Kuristus + Choose Folder Valitse kansio + Circle Ympyrä + Cleanliness Puhtaus + Clear Amount Tyhjennyksen määrä + Clear Selection Tyhjennä valinta + Click sets foreground · right-click sets background Napsautus asettaa edustavärin · napsautus hiiren kakkospainikkeella asettaa taustavärin + Click the first point or press ↵ to close · Esc cancels Sulje napsauttamalla ensimmäistä pistettä tai painamalla ↵ · Esc peruuttaa + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Lisää piste napsauttamalla · poista vetämällä pois, ⌘/Ctrl-napsautuksella tai Delete-näppäimellä + Click to apply, ⌥-click to clear overrides, double-click for Style Options Käytä napsauttamalla, tyhjennä ohitukset ⌥-napsautuksella, tyyliasetukset kaksoisnapsautuksella + Click to zoom in · ⌥-click to zoom out Lähennä napsauttamalla · loitonna ⌥-napsautuksella + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Napsautus: kulma · Veto: pyöristetty · Ensimmäisen pisteen napsautus: sulje · ↩ valmis · Esc peruuta + Clipping Mask Leikkausmaski + Clipping Path… Leikkauspolku… + Clone Kloonaa + Clone source set at {x}, {y} Kloonauslähde asetettu kohtaan {x}, {y} + Close Window Sulje ikkuna + Clumped Kasautunut + Cmyk CMYK + Coarse Dots Karkeat pisteet + Coated-cmyk Päällystetty CMYK + Collapse group (⌥-click: all groups) Tiivistä ryhmä (⌥-napsautus: kaikki ryhmät) + Color Decontaminate Värien puhdistus + Color Halftone Värirasteri + Color Indicates Masked Areas Väri osoittaa maskatut alueet + Color Indicates Selected Areas Väri osoittaa valitut alueet + Color Lookup Tables Värihakutaulukot + Color Picker Värinvalitsin + Color Picker (Background Color) Värinvalitsin (taustaväri) + Color Picker (Custom Canvas Color) Värinvalitsin (mukautettu kankaan väri) + Color Picker (Foreground Color) Värinvalitsin (edustaväri) + Color Sampler Tool Värinäytetyökalu + Color Settings Väriasetukset + Color Swatches Värimallit + Color Table Väritaulukko + Color management: {error} Värinhallinta: {error} + Colored Pencil Värikynä + Comic Book Sarjakuva + Commit Perspective Warp (↩) Vahvista perspektiivin taivutus (↩) + Commit Puppet Warp (↩) Vahvista nukketaivutus (↩) + Commit any current edits (⌘↩) Vahvista nykyiset muokkaukset (⌘↩) + Commit current crop operation (↵) Vahvista nykyinen rajaus (↵) + Commit transform Vahvista muunnos + Commit transform (↩) Vahvista muunnos (↩) + Commit warp (↩) Vahvista taivutus (↩) + Compare Vertaa + Conditional Mode Change Ehdollinen tilan muutos + Confirm Vahvista + Conform Mukauta + Constraints Rajoitteet + Content-Aware Fill needs a selection on a pixel layer Sisältötietoinen täyttö tarvitsee valinnan pikselitasolla + Contrasty Kontrastikas + Convert Muunna + Convert to Alpha Channel Muunna alfakanavaksi + Convert to Linked Muunna linkitetyksi + Convert to Profile Muunna profiiliin + Cool Viileä + Cooling L B B Viilentävä LBB + Cooling80 Viilentävä 80 + Cooling82 Viilentävä 82 + Copy Complete Stroke Kopioi koko reunaviiva + Copy Fill Kopioi täyttö + Copyright Url Tekijänoikeuden URL + Correct C A Korjaa väripoikkeama + Correct Distortion Korjaa vääristymä + Correct Vignette Korjaa vinjetointi + Could not rename the action: {error} Toimintoa ei voitu nimetä uudelleen: {error} + Could not transform: the layer is empty Muuntaminen epäonnistui: taso on tyhjä + Could not use Perspective Warp: the layer is empty Perspektiivin taivutusta ei voitu käyttää: taso on tyhjä + Could not use Puppet Warp: no opaque pixels Nukketaivutusta ei voitu käyttää: ei läpinäkymättömiä pikseleitä + Could not use Puppet Warp: the layer is empty Nukketaivutusta ei voitu käyttää: taso on tyhjä + Couldn't open {name}: {error} Kohdetta {name} ei voitu avata: {error} + Couldn't save preferences: {error}. Retrying… Asetuksia ei voitu tallentaa: {error}. Yritetään uudelleen… + Crack Brightness Halkeamien kirkkaus + Crack Depth Halkeamien syvyys + Crack Spacing Halkeamien väli + Craquelure Krakelyyri + Create By Luontitapa + Create Vector Mask Luo vektorimaski + Create Warped Text Luo taivutettu teksti + Create a new layer (⇧⌘N) Luo uusi taso (⇧⌘N) + Create new brush from the current settings Luo uusi sivellin nykyisistä asetuksista + Create new brush preset from the current settings Luo uusi siveltimen esiasetus nykyisistä asetuksista + Create… Luo… + Cross Process Ristiinkehitys + Crosshatch Ristiviivoitus + Crosswise Poikittain + Crystallize Kiteytä + Currency Valuutta + Curve Darks Käyrä: tummat + Curve Highlights Käyrä: korostukset + Curve Lights Käyrä: vaaleat + Curve Shadows Käyrä: varjot + Curve Splits Käyrä: jakopisteet + Custom Branch Color Mukautettu oksien väri + Custom Pixel Aspect Ratio Mukautettu pikselien kuvasuhde + Custom pattern Mukautettu kuvio + Cutout Leikekuva + Cw Myötäpäivään + Cyans Syaanit + Cyrillic Kyrilliset + Daisies Päivänkakkarat + Dark Gray Tummanharmaa + Dark Intensity Tummien voimakkuus + Dark Rough Tumma karhea + Dark Strokes Tummat vedot + Darken Only Vain tummennus + Darkness Tummuus + Data Tiedot + Data Set: {name} ({index}/{total}) Tietojoukko: {name} ({index}/{total}) + Data Sets as Files Tietojoukot tiedostoiksi + Date Päivämäärä + Day For Night Päivä yöksi + De-Interlace Lomituksen poisto + Decrease Brush Hardness Vähennä siveltimen kovuutta + Decrease Brush Size Pienennä siveltimen kokoa + Decrease Type Size Pienennä tekstin kokoa + Decrease Type Size by 10 Pienennä tekstin kokoa 10:llä + Deep Syvä + Deep Blue Syvä sininen + Deep Emerald Syvä smaragdi + Deep Red Syvä punainen + Deep Yellow Syvä keltainen + Default Foreground and Background Colors Oletusarvoiset edusta- ja taustavärit + Default Leaves Oletuslehdet + Default Photoshop Size Photoshopin oletuskoko + Defaults Oletukset + Define Brush Preset Määritä siveltimen esiasetus + Define Custom Shape Määritä mukautettu muoto + Define Pattern Määritä kuvio + Definition Määritelmä + Defringe Poista reunaväristymät + Degrees Astetta + Delete Channel Poista kanava + Delete Mask Poista maski + Delete Workspace Poista työtila + Delete brush Poista sivellin + Delete path Poista polku + Delete vector mask Poista vektorimaski + Delete… Poista… + Depth Map Syvyyskartta + Depth Mode Syvyystila + Diameter Halkaisija + Diffuse Hajauta + Diffuse Glow Hajautettu hehku + Direction Suunta + Direction Balance Suuntatasapaino + Disable Poista käytöstä + Disable Mask Poista maski käytöstä + Disable Symmetry Path Poista symmetriapolku käytöstä + Disabled Ei käytössä + Discard Changes Hylkää muutokset + Displace Syrjäytä + Display Näyttö + Display profile not used Näyttöprofiilia ei käytetä + Display profiles: {reason} Näyttöprofiilit: {reason} + Display-p3 Display P3 + Distance Etäisyys + Distortion Vääristymä + Distribution Jakauma + Do you want to save you made to “{name}” before quitting? Haluatko tallentaa tiedostoon ”{name}” tekemäsi muutokset ennen lopettamista? + Document Sizes Asiakirjan koot + Documentation Dokumentaatio + Documents Asiakirjat + Dont Enlarge Älä suurenna + Dot Piste + Double Line Kaksoisviiva + Down Alas + Draft Luonnos + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Vedä rajausruutu · siirrä vetämällä sisältä · reunat muuttavat kokoa (suhde {ratio}, keskitys {centre}) · Välilyönti siirtää piirrettäessä · {commit} vahvistaa · Esc peruuttaa + Drag: draw freehand · Release on the start point: close Veto: piirrä vapaalla kädellä · Vapauta aloituspisteessä: sulje + Driver: {driver} Ajuri: {driver} + Dry Brush Kuiva sivellin + Duplicate Channel Monista kanava + Duplicate Layers Monista tasot + Duplication Monistus + Dust & Scratches Pöly ja naarmut + Edge Reuna + Edge Brightness Reunan kirkkaus + Edge Extension Reunan laajennus + Edge Fidelity Reunan tarkkuus + Edge Intensity Reunan voimakkuus + Edge Only Vain reuna + Edge Simplicity Reunan yksinkertaisuus + Edge Thickness Reunan paksuus + Edge Width Reunan leveys + Edit Toolbar Muokkaa työkaluriviä + Edit… Muokkaa… + Elevation Korkeuskulma + Eliminate Poista + Emboss Kohokuvio + Enable Ota käyttöön + Enable Mask Ota maski käyttöön + End X Loppupiste X + End Y Loppupiste Y + Enlarged Suurennettu + Erase with Brush or Pencil (hold) Pyyhi siveltimellä tai lyijykynällä (pidä painettuna) + Error Virhe + Even Fields Parilliset kentät + Exit Quick Mask Poistu pikamaskista + Expand group (⌥-click: all groups) Laajenna ryhmä (⌥-napsautus: kaikki ryhmät) + Export As Vie nimellä + Export As: {name} Vie nimellä: {name} + Export Contents Vie sisältö + Export Image Vie kuva + Export Options Vientiasetukset + Export Preferences Viennin oletusasetukset + Export Settings Vientiasetukset + Export to Vie kohteeseen + Export/Import Presets Vie/tuo esiasetukset + Exported contents to {path} Sisältö viety kohteeseen {path} + Exported presets to {path} Esiasetukset viety kohteeseen {path} + Exported {path} Viety {path} + Exported {path} ({size}) Viety {path} ({size}) + Exporting… Viedään… + ExtraBold Erittäin lihavoitu + ExtraLight Erittäin kevyt + Extrude Pursota + Fade Häivytys + Faded Film Haalistunut filmi + Failed Epäonnistui + Failed to open file Tiedoston avaaminen epäonnistui + Failed to save file Tiedoston tallentaminen epäonnistui + Fallback: {reason} Varajärjestelmä: {reason} + Feather Radius Pehmennyssäde + Fiber Length Kuidun pituus + Fibers Kuidut + Field Blur Kenttäsumennus + File Format Tiedostomuoto + File Info Tiedoston tiedot + File Name Tiedostonimi + File Size Tiedostokoko + File not found Tiedostoa ei löytynyt + Filename Tiedostonimi + Fill Color Täyttöväri + Fill Opacity Täytön peittävyys + Fill Path… Täytä polku… + Fill path with foreground color Täytä polku edustavärillä + Fill with Background Color Täytä taustavärillä + Fill with Background Color, Preserve Transparency Täytä taustavärillä, säilytä läpinäkyvyys + Fill with Foreground Color Täytä edustavärillä + Fill with Foreground Color, Preserve Transparency Täytä edustavärillä, säilytä läpinäkyvyys + Film Grain Filmin rae + Filter Gallery Suodatingalleria + Find Etsi + Find and Replace Text Etsi ja korvaa tekstiä + Fine Hieno + Fine Dots Hienot pisteet + Finish Viimeistely + Fit Image Sovita kuva + Fit to Window Sovita ikkunaan + Flame Liekki + Flame Bottom Alignment Liekin alareunan tasaus + Flame Lines Liekin viivat + Flame Shape Liekin muoto + Flame Style Liekin tyyli + Flame Type Liekkityyppi + Flat Litteä + Flat Shading Tasainen varjostus + Flower Color Kukan väri + Focal Distance Tarkennusetäisyys + Focus Tarkennus + Focus Area Tarkennusalue + Foreground Level Edustavärin taso + Forward Eteenpäin + Forward Warp Eteenpäin taivutus + Frame Kehys + Frame {current} / {total} Ruutu {current} / {total} + Free Transform Path Polun vapaa muunnos + Freeze Lasso Jäädytyslasso + Freeze Mask Jäädytysmaski + Fresco Fresko + From Alkuväri + From Left Vasemmalta + From Right Oikealta + Frosted Huurrettu + Full Screen Koko näyttö + Fullscreen Koko näyttö + Fuzziness Sumeus + GPU backend preference: {backend} GPU-taustajärjestelmän valinta: {backend} + GPU device was lost; using the CPU renderer. GPU-laite menetettiin; käytetään CPU-renderöijää. + GPU error; using the CPU renderer. GPU-virhe; käytetään CPU-renderöijää. + GPU retry scheduled GPU:n uusi yritys ajastettu + Gamma Gamma + Gaussian Blur Gauss-sumennus + General Blending Yleinen sekoitus + General Brushes Yleiset siveltimet + Generators Generaattorit + Generic Yleinen + Glass Lasi + Global Light Yleinen valo + Gloss Kiilto + Glow Amount Hehkun määrä + Glow Brightness Hehkun kirkkaus + Glow Size Hehkun koko + Glowing Edges Hehkuvat reunat + Good Hyvä + Grade Balance Sävytyksen tasapaino + Grade Blending Sävytyksen sekoitus + Grade Global Sävytys: yleinen + Grade Highlights Sävytys: korostukset + Grade Midtones Sävytys: keskisävyt + Grade Shadows Sävytys: varjot + Gradient Options Liukuvärin asetukset + Grain Amount Rakeen määrä + Grain Roughness Rakeen karheus + Grain Size Rakeen koko + Grain Type Rakeen tyyppi + Graininess Rakeisuus + Grainy Dots Rakeiset pisteet + Graphics adapter Näytönohjain + Graphics adapter: {adapter} Näytönohjain: {adapter} + Gray-gamma-2.2 Harmaa (gamma 2,2) + Greek Kreikka + Greens Vihreät + Grid (⌘') Ruudukko (⌘') + Grid view Ruudukkonäkymä + Group from Layers Ryhmä tasoista + Groups Ryhmät + Grout Width Saumalaastin leveys + Guide Layout… Apuviiva-asettelu… + HDR Toning HDR-sävytys + Hand Tool (hold) Käsityökalu (pidä painettuna) + Hearts Sydämet + Heptagon Seitsemänkulmio + Hexagon Kuusikulmio + Hidden Piilotettu + Hide Grid Piilota ruudukko + Hide Others Piilota muut + Hide the layer's effects (⌥-click: all layers) Piilota tason tehosteet (⌥-napsautus: kaikki tasot) + Hide {app} Piilota {app} + High Pass Ylipäästö + Highlight Korostus + Highlight Amount Korostusten määrä + Highlight Area Korostuksen alue + Highlight Radius Korostusten säde + Highlight Strength Korostuksen voimakkuus + Highlight Tone Korostusten sävyalue + Highlights from: Korostukset alkaen: + Horizontal Distortion Vaakasuuntainen vääristymä + Horizontal Only Vain vaakasuunnassa + Horizontal scale Vaakasuuntainen skaalaus + Horizontal type Vaakasuuntainen teksti + Hotspot Kuuma piste + Hsb HSB + Hsl HSL + Hsl Hue HSL-sävy + Hsl Lum HSL-luminanssi + Hsl Sat HSL-kylläisyys + Image Balance Kuvan tasapaino + Image Processor Kuvaprosessori + Image compositor: {processor} Kuvan koostin: {processor} + Import Brushes Tuo siveltimiä + Import Brushes… (.abr) Tuo siveltimiä… (.abr) + Import Image Tuo kuva + Import Options Tuontiasetukset + Import Photoshop brushes (.abr) Tuo Photoshop-siveltimiä (.abr) + Import Settings Tuo asetukset + Importing {what} from {file}… Tuodaan {what} tiedostosta {file}… + Importing… Tuodaan… + In Black Tulon musta + 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. + 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. + “{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 + Move Down Siirrä alas + Previous Brush Edellinen sivellin + Next Brush Seuraava sivellin + First Brush Ensimmäinen sivellin + Last Brush Viimeinen sivellin + Preview size Esikatselun koko + Memory Usage Muistin käyttö + Per-document RAM limit (MB) Asiakirjakohtainen RAM-raja (Mt) + Installed RAM: {ram} MB; this limit: {percent}% Asennettu RAM: {ram} Mt; tämä raja: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Rajoittaa kunkin avoimen asiakirjan pikseleitä ja historiaa. Vanhimmat kumoamistilat hylätään, kun raja ylittyy. + Applies immediately when saved. Memory is not reserved in advance. Tulee voimaan heti, kun asetukset tallennetaan. Muistia ei varata etukäteen. + 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. Kun GPU-muisti on asetettu automaattiseksi, tämä rajoittaa myös GPU:n tasobudjettia. Budjetin yläraja on neljäsosa asennetusta RAM-muistista ja, jos tiedossa, kolme kahdeksasosaa näytönohjaimen muistista, vähintään kuitenkin 512 Mt. Tämän rajan nostaminen näiden ylärajojen yli ei kasvata GPU-budjettia. + Command Komento + Use Käytä + In Front Edessä + Behind Takana + +# DDS Options (#1327) + DDS Options DDS-asetukset + Compression Pakkaus + Generate Mipmaps Luo mipmap-tasot + +# Color Table (.act) dialog + There is no color table to save. Tallennettavaa väritaulukkoa ei ole. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Tallennettu {n} väri tiedostoon {file}|Tallennettu {n} väriä tiedostoon {file} + +# Graphics Adapter preference (#1885) + GPU adapter Näytönohjain + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Tuo tyylejä… + Export Selected Styles… Vie valitut tyylit… + Export Styles… Vie tyylit… + {file} is not a style library (.asl) {file} ei ole tyylikirjasto (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Tuotu {n} tyyli tiedostosta {file}|Tuotu {n} tyyliä tiedostosta {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Viety {n} tyyli tiedostoon {file}|Viety {n} tyyliä tiedostoon {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Vahvista tai peruuta ensin avoinna oleva toimintovaiheen valintaikkuna + Pro Medium Gray Pro keskiharmaa + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro vaaleanharmaa + Pro Light Pro vaalea + Resize Windows to Fit Muuta ikkunoiden koko sopivaksi + EXR EXR + Increase Brush Size Suurenna siveltimen kokoa + Increase Type Size Suurenna tekstin kokoa + Increase Type Size by 10 Suurenna tekstin kokoa 10:llä + Indent first line Sisennä ensimmäinen rivi + Indent left margin Vasen sisennys + Indent right margin Oikea sisennys + Indexed Indeksoitu + Infinite Ääretön + Information Tiedot + Initial Direction Alkusuunta + Ink Outlines Musteääriviivat + Inner Bevel Sisäinen viiste + Input Tulo + Input Levels Tulotasot + Input Mode Syöttötila + Install Plug-in Asenna laajennus + Intensity Voimakkuus + Intent Muuntotapa + Interpret Footage Tulkitse kuvamateriaali + Intersect with selection (⇧⌥) Leikkaa valinnan kanssa (⇧⌥) + Interval Väli + Invalid panel layout; restored the default dock Virheellinen paneeliasettelu; oletustelakka palautettu + Invert Mask Käännä maski + Invert Selection Käännä valinta + Iris Blur Iiris-sumennus + Ivy Muratti + Jag Rosoisuus + Join the ArtCraft Discord Liity ArtCraftin Discordiin + Join the ArtCraft Discord ({url}) Liity ArtCraftin Discordiin ({url}) + Keeps the current texture when switching presets. Säilyttää nykyisen tekstuurin esiasetusta vaihdettaessa. + Kernel Ydin + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Parivälistys: metriikka, optinen tai arvo 1/1000 em (Alt+←/→ kohdistimessa) + Keyboard Näppäimistö + Lab Lab + Landscape, 10 x 8 Vaaka, 10 x 8 + Landscape, 6 x 4 Vaaka, 6 x 4 + Landscape, 7 x 5 Vaaka, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin, laajennettu A + Latin Extended-B Latin, laajennettu B + Layer Comps to Files Tasoasetelmat tiedostoiksi + Layer Content Options Tason sisällön asetukset + Layer Count Tasojen määrä + Layer From Background Taso taustasta + Layer Knocks Out Drop Shadow Taso peittää heittovarjon + Layer Mask Hides Effects Tasomaski piilottaa tehosteet + Layer Properties Tason ominaisuudet + Layer Styles Tasotyylit + Layer from Background Taso taustasta + Layers to Files Tasot tiedostoiksi + Layout Asettelu + Leading (set to the font size × auto-leading when Auto) Riviväli (Automaattinen-tilassa fonttikoko × automaattinen riviväli) + Leaf Color Lehden väri + Learn More Lisätietoja + Leaves Amount Lehtien määrä + Leaves Color Lehtien väri + Leaves Size Lehtien koko + Left Diagonal Vasen vinottainen + Legacy Vanha + Legal Legal + Lens Blur Linssisumennus + Lens Correction Objektiivin korjaus + Lens Flare Objektiivin heijastus + Less Vähemmän + Letter Letter + Level Based Kirkkauden mukaan + Library Kirjasto + Light Dark Balance Vaalea-tumma-tasapaino + Light Direction Valon suunta + Light Intensity Valon voimakkuus + Light Rough Vaalea karhea + Light Type Valotyyppi + Light X Valo X + Light Y Valo Y + Light Z Valo Z + Lighten Grout Vaalenna saumalaasti + Lighten Only Vain vaalennus + Lighting Valaistus + Lighting Effects Valaistustehosteet + Linear Dodge Lineaarinen vaalennus + Linear Height Lineaarinen korkeus + Linear-srgb Lineaarinen sRGB + Link Linkitä + Liquify Nesteytä + List Luettelo + List view Luettelonäkymä + Live Preview Reaaliaikainen esikatselu + Load 3D LUT Lataa 3D-LUT + Load Files into Stack Lataa tiedostot pinoon + Load path as a selection Lataa polku valintana + Loading… Ladataan… + Lock All Lukitse kaikki + Lock Image Pixels Lukitse kuvapikselit + Lock Position Lukitse sijainti + Lock Transparent Pixels Lukitse läpinäkyvät pikselit + Locked Lukittu + Long Lines Pitkät viivat + Long Strokes Pitkät vedot + Lorem Ipsum Lorem Ipsum + Lower Alempi + Lum Luminanssi + Lut LUT + Magentas Magentat + Make Selection from Path Luo valinta polusta + Make Selection… Luo valinta… + Make Symmetry Path Luo symmetriapolku + Make Work Path… Luo työpolku… + Make work path from selection Luo työpolku valinnasta + Manual Manuaalinen + Map Document Kartta-asiakirja + Map Layer Kartoita taso + Map Path Kartan polku + Mask Channel Maskikanava + Mask Incomplete Blocks Maskaa vajaat lohkot + Mask None Ei maskia + Masks Maskit + Match Color Täsmäytä värit + Math Symbols Matemaattiset symbolit + Max Offset Suurin siirtymä + Max Radius Suurin säde + Measurement Scale Mittakaava + Measurement Scale Marker Mittakaavan merkki + Medium Dots Keskikokoiset pisteet + Medium Gray Keskiharmaa + Medium Lines Keskipitkät viivat + Medium Strokes Keskipitkät vedot + Merge Channels… Yhdistä kanavat… + Merge Spot Channel Yhdistä spottivärikanava + Merge to HDR Pro Yhdistä HDR Pro -kuvaksi + Metallic Metallinen + Metrics Metriikka + Mezzotint Mezzotinto + Middle Keskellä + Midtone Keskisävy + Monitor Profile Näyttöprofiili + Monitor profile in use on {display}: {profile} Käytössä oleva näyttöprofiili näytöllä {display}: {profile} + Monitor profile in use: {profile} Käytössä oleva näyttöprofiili: {profile} + Monitor profile on {display} (main window): {profile} Näyttöprofiili näytöllä {display} (pääikkuna): {profile} + Monitor profile on {display}: {profile} Näyttöprofiili näytöllä {display}: {profile} + Monitor profile: {profile} Näytön profiili: {profile} + Mono Contrast Monokromikontrasti + Monochromatic Yksivärinen + More Lisää + Mosaic Mosaiikki + Mosaic Tiles Mosaiikkilaatat + Motion Blur Liikesumennus + Mouse Hiiri + Move Layer Siirrä tasoa + Movie Prime Elokuvaobjektiivi + Multiple Flames Along Path Useita liekkejä polkua pitkin + Multiple Flames One Direction Useita liekkejä yhteen suuntaan + Multiple Flames Path Directions Useita liekkejä polun suuntiin + Multiple Flames Various Length Useita eripituisia liekkejä + My Brushes Omat siveltimet + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Tiedostojen natiivia vedä ja pudota -toimintoa ei vielä tueta Waylandissa. Käytä komentoa Tiedosto › Avaa tai suorita PhotoCraft XWaylandissa komennolla `WAYLAND_DISPLAY= photocraft`. + Nearest Lähin + Neon Glow Neonhehku + Neutralize Neutraloi + Neutrals Neutraalit + New Brush Preset Uusi siveltimen esiasetus + New Channel… Uusi kanava… + New Guide Uusi apuviiva + New Guide Layout Uusi apuviiva-asettelu + New Spot Channel… Uusi spottivärikanava… + New Video Layer from File Uusi videotaso tiedostosta + New Window Uusi ikkuna + New Workspace Uusi työtila + New document Uusi asiakirja + New document… Uusi asiakirja… + New document… ⌘N Uusi asiakirja… ⌘N + Next Seuraava + No Document Ei asiakirjaa + No Image Ei kuvaa + No Layers Ei tasoja + No Results Ei tuloksia + No Results Found Tuloksia ei löytynyt + No Selection Ei valintaa + No custom shape "{name}": pick one in the options bar or Window › Shapes Mukautettua muotoa ”{name}” ei ole: valitse muoto asetuspalkista tai kohdasta Ikkuna › Muodot + No layers Ei tasoja + No recent files Ei viimeisimpiä tiedostoja + No selection Ei valintaa + Noise Color Kohinan väri + Noise Color Detail Kohinan värin yksityiskohdat + Noise Luminance Kohinan luminanssi + Noise Luminance Detail Kohinan luminanssin yksityiskohdat + Nothing to Redo Ei uudelleen tehtävää + Nothing to Undo Ei kumottavaa + Nothing to paste: the clipboard holds no image Ei liitettävää: leikepöydällä ei ole kuvaa + Number Of Levels Sävytasojen määrä + Numbers Numerot + Ocean Ripple Meren väreily + Octagon Kahdeksankulmio + Odd Fields Parittomat kentät + Oil Paint Öljymaalaus + On Päällä + One Flame Along Path Yksi liekki polkua pitkin + Open As Avaa muodossa + Open File Avaa tiedosto + Open Folder Avaa kansio + Open Image Avaa kuva + Open Recent is unavailable on the web Avaa viimeisimmät ei ole käytettävissä verkossa + Opened {url} Avattu {url} + Opening Avaus + Open… ⌘O Avaa… ⌘O + Operation canceled Toiminto peruttu + Operation cancelled Toiminto peruttu + Operation completed Toiminto valmis + Operation failed Toiminto epäonnistui + Optical Optinen + Original Ratio Alkuperäinen kuvasuhde + Out Black Lähdön musta + Out From Center Keskipisteestä ulospäin + Out Of Gamut Väriskaalan ulkopuolella + Out White Lähdön valkoinen + Out of memory Muisti loppui + Outer Bevel Ulkoinen viiste + Output Levels Lähtötasot + Oval Soikio + Overlay Edge Peitereuna + Overlay: Rule of Thirds Peite: kolmanneksen sääntö + PSD PSD + Package Paketti + Page Setup… Sivun asetukset… + Paint Daubs Maalitäplät + Paints from the document's opening state Maalaa asiakirjan avaustilasta + Palette Knife Palettiveitsi + Panel Paneeli + Paper Paperi + Paper Brightness Paperin kirkkaus + Parallel Rinnakkainen + Paste Complete Stroke Liitä koko reunaviiva + Paste Fill Liitä täyttö + Patchwork Tilkkutyö + Path Blur Polkusumennus + Paths to Illustrator Polut Illustratoriin + Pattern Fill… Kuviotäyttö… + Pattern Type Kuvion tyyppi + Pencil Width Lyijykynän leveys + Pentagon Viisikulmio + Percentage Prosenttiosuus + Permission denied Käyttöoikeus evätty + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft yrittää uudelleen; ennen onnistunutta tallennusta tehdyt asetusmuutokset menetetään, kun sovellus suljetaan. + Photomerge Photomerge + Pick Color From Image Poimi väri kuvasta + Picture Frame Kuvakehys + Pillow Emboss Tyynykohokuvio + Pinch Purista + Pins Nastat + Place Embedded Sijoita upotettuna + Place Linked Sijoita linkitettynä + Place Scale Marker Sijoita mittakaavamerkki + Plane Perspektiivitaso + Planes Perspektiivitasot + Plastic Wrap Muovikalvo + Platform: {os} {arch} Alusta: {os} {arch} + Please wait… Odota… + Point Piste + Point Curve Pistekäyrä + Point Curve Blue Pistekäyrä sininen + Point Curve Green Pistekäyrä vihreä + Point Curve Red Pistekäyrä punainen + Pointed Terävä + Pointillize Pointillismi + Polar Coordinates Napakoordinaatit + Polar To Rectangular Napakoordinaateista suorakulmaisiin + Policy Cmyk CMYK-käytäntö + Policy Gray Harmaasävykäytäntö + Policy Rgb RGB-käytäntö + Pond Ripples Lammen väreet + Portrait, 4 x 6 Pysty, 4 x 6 + Portrait, 5 x 7 Pysty, 5 x 7 + Postcard Postikortti + Poster Juliste + Poster Edges Julistereunat + Posterization Posterisointi + Preferences can't be saved Asetuksia ei voi tallentaa + Preferences saved Asetukset tallennettu + Preferences were reset: {error} Asetukset palautettiin oletuksiin: {error} + Preferences… Asetukset… + Preserve Säilytä + Preset Manager Esiasetusten hallinta + Press keys Paina näppäimiä + Preview Image Esikatselukuva + Previous Edellinen + Prime105 105 mm kiinteä + Prime35 35 mm kiinteä + Printed to {path} Tulostettu kohteeseen {path} + Pro (Dark) Pro (tumma) + Pro (Medium Gray) Pro (keskiharmaa) + Processing… Käsitellään… + Profile Profiili + Proof Setup… Koevedoksen asetukset… + Prophoto-compat ProPhoto-yhteensopiva + Pucker Kutista + Push Left Työnnä vasemmalle + Pyramids Pyramidit + Quick Actions Pikatoiminnot + Quit Lopeta + Quit PhotoCraft Lopeta PhotoCraft + Quit {app} Lopeta {app} + R G B R G B + R g b R g b + Radial Säteittäinen + Radial Blur Säteittäinen sumennus + Radius X Säde X + Radius Y Säde Y + Ran script {name} Komentosarja {name} suoritettu + Randomize Length Satunnaista pituus + Randomize Shapes Satunnaista muodot + Rasterize Fill Content Rasteroi täyttösisältö + Rasterize Smart Object Rasteroi älykäs objekti + Ratio Kuvasuhde + Raw Defaults Raw-oletukset + Reading Luetaan + Ready Valmis + Rec2020 Rec2020 + Recent Files Viimeisimmät tiedostot + Reconstruct All Rekonstruoi kaikki + Recovered {name} Palautettu {name} + Rectangular Selection Suorakulmainen valinta + Rectangular To Polar Suorakulmaisesta napakoordinaatteihin + Red Cyan Puna/syaani + Reds Punaiset + Reduce Color Noise Vähennä värikohinaa + Reduce Noise Vähennä kohinaa + Reflected Heijastettu + Relief Reliefi + Relink to File Linkitä uudelleen tiedostoon + Remove All Poista kaikki + Remove Jpeg Artifact Poista JPEG-artefaktit + Rename Brush Nimeä sivellin uudelleen + Rename Channel… Nimeä kanava uudelleen… + Rename Group Nimeä ryhmä uudelleen + Rename Tool Preset Nimeä työkalun esiasetus uudelleen + Render Video Renderöi video + Rendering… Renderöidään… + Reorder Layer Järjestä taso uudelleen + Replace Color Korvaa väri + Replace Contents Korvaa sisältö + Replace Footage Korvaa kuvamateriaali + Report a Bug Ilmoita virheestä + Report an Issue Ilmoita ongelmasta + Reset Alignment Palauta tasaus + Reset All Palauta kaikki + Reset Preferences Palauta oletusasetukset + Resolve Missing Fonts Korjaa puuttuvat fontit + Restart Käynnistä uudelleen + Restart Required Uudelleenkäynnistys tarvitaan + Resume Jatka + Retry Yritä uudelleen + Rgb RGB + Ridges Harjanteet + Right Diagonal Oikea vinottainen + Ripple Väreily + Ripple Magnitude Väreilyn voimakkuus + Ripple Size Väreilyn koko + Rope Köysi + Roses Ruusut + Rough Pastels Karkeat pastellit + Row Order Rivijärjestys + Rulers (⌘R) Viivaimet (⌘R) + Sample Size Näytteen koko + Sampled Colors Poimitut värit + Sandstone Hiekkakivi + Sat Kyll. + Save As Tallenna nimellä + Save Changes Tallenna muutokset + Save File Tallenna tiedosto + Save Image Tallenna kuva + Save Path Tallenna polku + Save Pixel Aspect Ratio Tallenna pikselin kuvasuhde + Save a Copy Tallenna kopio + Save before closing? Tallennetaanko ennen sulkemista? + Save for Web (Legacy) Tallenna verkkoa varten (vanha) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Tallenna työsi ja käynnistä PhotoCraft uudelleen, niin GPU-kiihdytystä yritetään uudelleen. CPU-renderöinti pysyy käytössä tämän istunnon ajan. + Saved a copy as {path} Kopio tallennettu nimellä {path} + Saved {path}; image assets: {n} in {dir} Tallennettu {path}; kuvaresurssit: {n} kansiossa {dir} + Saving… Tallennetaan… + Scale Effects Skaalaa tehosteet + Scaling Skaalaus + Scallops Simpukkareunus + Script Events Manager Komentosarjatapahtumien hallinta + Scroll menu down Vieritä valikkoa alas + Scroll menu up Vieritä valikkoa ylös + Search commands Etsi komentoja + Search commands (⌘K) Hae komentoja (⌘K) + Search commands, tools and panels Hae komentoja, työkaluja ja paneeleja + Search menus Hae valikoista + Seed Siemen + Select Custom Color… Valitse mukautettu väri… + Select Data Points Valitse datapisteet + Select Layer Valitse taso + Select and Mask Valitse ja maskaa + Color Aware Värit huomioiva + Decontaminate Colors Puhdista värit + Edge Detection Reunojen tunnistus + Global Refinements Yleiset tarkennukset + High Quality Preview Korkealaatuinen esikatselu + Object Aware Objektit huomioiva + Object Aware needs a segmentation model, which isn't available yet Objektit huomioiva -tila vaatii segmentointimallin, joka ei ole vielä käytettävissä + Real-Time Refinement Reaaliaikainen tarkennus + Refine Edge Brush Tool Reunan tarkennussivellin + Refine Hair Tarkenna hiuksia + Refine Hair needs hair detection, which isn't available yet Tarkenna hiuksia -toiminto vaatii hiusten tunnistuksen, joka ei ole vielä käytettävissä + Refine Mode Tarkennustila + Select and Mask was closed: another layer was selected Valitse ja maskaa suljettiin: toinen taso valittiin + Select and Mask was closed: its document is no longer the active one Valitse ja maskaa suljettiin: sen asiakirja ei ole enää aktiivinen + View Mode Näkymätila + Selected at launch: {backend} Valittu käynnistettäessä: {backend} + SemiBold Puolilihavoitu + Sensitivity Herkkyys + Sent to the printer ({spooler}) Lähetetty tulostimelle ({spooler}) + Sepia Seepia + Services Palvelut + Set Colors Aseta värit + Set Measurement Scale Aseta mittakaava + Sgray sGray + Shadow Varjo + Shadow Amount Varjojen määrä + Shadow Intensity Varjon voimakkuus + Shadow Radius Varjojen säde + Shadow Tone Varjojen sävyalue + Shadows up to: Varjot enintään: + Shadows/Highlights Varjot/korostukset + Shallow Matala + Shape Blur Muotosumennus + Sharpen Amount Terävöityksen määrä + Sharpen Detail Terävöityksen yksityiskohta + Sharpen Details Terävöitä yksityiskohtia + Sharpen Masking Terävöityksen maskaus + Sharpen Radius Terävöityksen säde + Shear Vinouta + Shine Loiste + Short Lines Lyhyet viivat + Short Strokes Lyhyet vedot + Shortcut Pikanäppäin + Shortcuts Pikanäppäimet + Show All Näytä kaikki + Show Extras Options Näytä apuvälineiden asetukset + Show Guides Näytä apuviivat + Show Guides (⌘;) Näytä apuviivat (⌘;) + Show Rulers Näytä viivaimet + Show the layer's effects (⌥-click: all layers) Näytä tason tehosteet (⌥-napsautus: kaikki tasot) + Simple Yksinkertainen + Simulate Paper Simuloi paperia + Sine Siniaalto + Sketch Luonnos + Skip Ohita + Slice Options Viipaleen asetukset + Smart Blur Älykäs sumennus + Smart Sharpen Älykäs terävöitys + Smoothness Pehmeys + Smudge Stick Hankaustikku + Snap to Grid Tartu ruudukkoon + Snowflakes Lumihiutaleet + Soften Pehmennys + Softness Pehmeys + Software adapter Ohjelmistosovitin + Solarized Dark Solarized (tumma) + Solid Color Yhtenäinen väri + Solid Front Faces Umpinaiset etupinnat + Source Channel Lähdekanava + Source Invert Käänteinen lähde + Source Layer Lähdetaso + Source1 Channel Lähteen 1 kanava + Source1 Invert Käänteinen lähde 1 + Source2 Channel Lähteen 2 kanava + Source2 Invert Käänteinen lähde 2 + Source: set Lähde: asetettu + Space Välilyönti + Sparkle Kipinöinti + Spatter Roiske + Speckle Pilkutus + Speed Nopeus + Spherize Palloita + Spin Kierto + Spin Blur Pyörityssumennus + Split Channels Erota kanavat + Spot Pistemäinen + Spray Radius Ruiskutuksen säde + Sprayed Strokes Ruiskutetut vedot + Spread Levitys + Sprinkles Strösselit + Square Size Ruudun koko + Square, 12 x 12 Neliö, 12 x 12 + Square, 5 x 5 Neliö, 5 x 5 + Squareness Neliömäisyys + Stagger Porrastus + Stained Glass Lasimaalaus + Star Tähti + Stars Tähdet + Start X Alku X + Start Y Alku Y + Statistics Tilastot + Step {step} ({id}) failed: {error} Vaihe {step} ({id}) epäonnistui: {error} + Stippled Pisteytetty + Stops Pysäytyspisteet + Strength Voimakkuus + Stretch Venytä + Stroke Color Reunaviivan väri + Stroke Detail Vedon yksityiskohdat + Stroke Direction Vedon suunta + Stroke Length Vedon pituus + Stroke Pressure Vedon paine + Stroke Size Vedon koko + Stroke Width Vedon leveys + Stroke path with brush Piirrä polun reunaviiva siveltimellä + Strokes Vedot + Studio (Dark) Studio (tumma) + Studio (Light) Studio (vaalea) + Stylization Stilisointi + Subtract Shapes at Overlap Vähennä muodot päällekkäisyydessä + Subtract from selection (⌥) Vähennä valinnasta (⌥) + Sumi-e Sumi-e + Surface Blur Pintasumennus + Switch Foreground and Background Colors Vaihda edusta- ja taustaväri keskenään + System Info Järjestelmän tiedot + Tablet Piirtopöytä + Tabloid Tabloid + Taper Kaventuminen + Target Kohde + Target Channel 3 Valitse kohteeksi kanava 3 + Target Channel 4 Valitse kohteeksi kanava 4 + Target Channel 5 Valitse kohteeksi kanava 5 + Target Channel 6 Valitse kohteeksi kanava 6 + Target Channel 7 Valitse kohteeksi kanava 7 + Target Channel 8 Valitse kohteeksi kanava 8 + Target Channel 9 Valitse kohteeksi kanava 9 + Target Composite Channel Valitse kohteeksi yhdistelmäkanava + Target X Kohde X + Target Y Kohde Y + Teal Orange Sinivihreä ja oranssi + Technology Previews… Teknologian esikatselut… + Temporary Väliaikainen + Text Layer Tekstitaso + Texture Coverage Tekstuurin peitto + Texturizer Tekstuurisoija + Thaw Mask Sulatusmaski + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Kangas näytetään sRGB-muodossa. Valitse näyttöprofiili kohdassa Muokkaa › Väriasetukset. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Näytön {display} kangas näytetään sRGB-muodossa. Valitse näyttöprofiili kohdassa Muokkaa › Väriasetukset. + The current gradient (pick one in Window › Gradients) Nykyinen liukuväri (valitse kohdassa Ikkuna › Liukuvärit) + The document does not have an embedded colour profile. Working space: {working}. Asiakirjassa ei ole upotettua väriprofiilia. Työväriavaruus: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Asiakirjan upotettu väriprofiili ei vastaa työväriavaruutta.\nUpotettu: {embedded}\nTyöväriavaruus: {working} + The file could not be opened. Tiedostoa ei voitu avata. + The file could not be saved. Tiedostoa ei voitu tallentaa. + Thickness Paksuus + This Layer: Tämä taso: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Tämä älykäs objekti on rasteroitava ennen jatkamista. Sen sisältöä ei voi enää muokata. Rasteroidaanko älykäs objekti? + This adjustment ({key}) is kept as imported and can't be edited yet. Tämä säätö ({key}) säilytetään tuotuna, eikä sitä voi vielä muokata. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Tämä täyttötaso on rasteroitava ennen jatkamista. Sen täyttösisältöä ei voi enää muokata. Rasteroidaanko taso? + This layer must be rasterized before proceeding. Tämä taso on rasteroitava ennen jatkamista. + This session: {reason} Tämä istunto: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Tämä muototaso on rasteroitava ennen jatkamista. Sitä ei voi enää muokata muotona. Rasteroidaanko muoto? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Tämä tekstitaso on rasteroitava ennen jatkamista. Sen tekstiä ei voi enää muokata. Rasteroidaanko teksti? + Tile Size Laatan koko + Tiles Laatat + Tilt-Shift Kallistus ja siirto + Tint Color Sävytysväri + Tiny Lens Pieni linssi + Title Otsikko + To Loppuväri + To Center Keskipistettä kohti + Toggle Layer Mask Overlay Näytä/piilota tasomaskin peite + Toggle the Brush Settings panel Näytä tai piilota Siveltimen asetukset -paneeli + Toggle the Brush Settings panel (F5) Näytä tai piilota Siveltimen asetukset -paneeli (F5) + Tool Options Työkalun asetukset + Trace Contour Jäljitä ääriviiva + Tracking (1/1000 em) Kirjainväli (1/1000 em) + Transformation Muunnos + Transition Siirtymä + Transparency Shapes Layer Läpinäkyvyys muotoilee tason + Trap Trappaus + Tree Puu + Trim Karsi reunat + Turbulent Pyörteinen + Twirl Pyörre + Twirl Clockwise Pyöritä myötäpäivään + Twirl Counterclockwise Pyöritä vastapäivään + Type Options Tekstiasetukset + Unaltered Muuttamaton + Undefined Areas Määrittelemättömät alueet + Underlying Layer: Alla oleva taso: + Underpainting Pohjamaalaus + Underwater Vedenalainen + Ungroup Pura ryhmitys + Uniform Tasainen + Unite Shapes at Overlap Yhdistä muodot päällekkäisyydessä + Unlink Poista linkitys + Unlock Avaa lukitus + Unlocked Lukitsematon + Unsaved Changes Tallentamattomat muutokset + Unsharp Mask Epäterävä maski + Unsupported file format Tiedostomuotoa ei tueta + Unsupported format Muotoa ei tueta + Up Ylös + Update Päivitä + Updates Päivitykset + Upper Ylempi + Use Custom Color Käytä mukautettua väriä + 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 + Vertical Distortion Pystysuuntainen vääristymä + Vertical Only Vain pystysuunnassa + Vertical Type Tool Pystytekstityökalu + Vertical scale Pystysuuntainen skaalaus + Vertical type Pystysuuntainen teksti + Video Frames to Layers Videoruudut tasoiksi + Vignette Amount Vinjetoinnin määrä + Vignette Feather Vinjetin reunojen pehmennys + Vignette Highlights Vinjetin korostukset + Vignette Midpoint Vinjetoinnin keskikohta + Vignette Roundness Vinjetin pyöreys + Vignette Style Vinjetin tyyli + Vine Color Köynnöksen väri + Vine With Flowers Kukkiva köynnös + Vine With Leaves Lehtevä köynnös + Violent Raju + W: L: + WIA Support WIA-tuki + Warm Lämmin + Warming Filter (85) Lämmityssuodatin (85) + Warming L B A Lämmittävä LBA + Warming81 Lämmittävä 81 + Warming85 Lämmittävä 85 + Warmth Lämpimyys + Warning Varoitus + Warp Text Taivuta tekstiä + Watercolor Vesiväri + Watercolour look: lighter interior, darker rims. Vesiväriefekti: vaaleampi sisus, tummemmat reunat. + Wavelength Max Aallonpituus max + Wavelength Min Aallonpituus min + Waves Aallot + Web Large Verkko, suuri + Web Minimum Verkko, pienin + Web Most Common Verkko, yleisin + WebP (lossless) WebP (häviötön) + Wetness Märkyys + White Clip Valkoisen leikkaus + White Intensity Valkoisen voimakkuus + White Is High Valkoinen on korkea + Whole Word Vain kokonaiset sanat + Wide Blurry Leveä sumea + Wide Sharp Leveä terävä + Wind Tuuli + Window renderer: {renderer} Ikkunan renderöijä: {renderer} + Working Työavaruus + Working Cmyk Työ-CMYK + Working Gray Työ-harmaa + Working Rgb Työ-RGB + Working-cmyk Työ-CMYK + Workspaces Työtilat + Wrap Kierrä ympäri + Yellows Keltaiset + ZigZag Siksak + Zigzag Siksak + Zoom In (hold, drag to scrub) Lähennä (pidä painettuna, vedä zoomataksesi) + Zoom Out (hold) Loitonna (pidä painettuna) + auto automaattinen + blue sininen + brushes siveltimet + fallback varajärjestelmä + gradients liukuvärit + gray harmaa + green vihreä + layer mask tasomaski + manual manuaalinen + none ei mitään + off pois + on päällä + orange oranssi + previous reading kept; the last re-read failed: {reason} edellinen lukema säilytetty; viimeisin uudelleenluku epäonnistui: {reason} + red punainen + unknown tuntematon + unknown type tuntematon tyyppi + vector mask vektorimaski + violet violetti + yellow keltainen + {index} of {total} {index} / {total} + {label} is not available: {reason} {label} ei ole käytettävissä: {reason} + {message} (notes: {n}; {warning}) {message} (huomautuksia: {n}; {warning}) + {shortcut} assigned. {shortcut} määritetty. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} on jo käytössä komennolla {commands}, ja se poistetaan siltä, kun napsautat OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ siirry ↵ suorita esc sulje + ✓ Color Indicates Masked Areas ✓ Väri osoittaa maskatut alueet + ✓ Color Indicates Selected Areas ✓ Väri osoittaa valitut alueet +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Tyhjennä +@id edit.purge.undo Kumoa +@id edit.stroke Piirrä reunaviiva… +@id edit.transform.scale Skaalaa +@id file.close Sulje +@id filter.sharpen.sharpen Terävöitä +@id layer.layerMask.apply Käytä tasomaskia +@id select.all Valitse kaikki +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Tuotiin {n} sivellin tiedostosta {file}|Tuotiin {n} sivellintä tiedostosta {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Tuotiin {n} liukuväri tiedostosta {file}|Tuotiin {n} liukuväriä tiedostosta {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Tuotiin {n} kuvio tiedostosta {file}|Tuotiin {n} kuviota tiedostosta {file} +cameraRaw Balance Tasapaino +cameraRaw Clarity Selkeys +cameraRaw Color Väri +cameraRaw Color Mixer Värisekoitin +cameraRaw Contrast Kontrasti +cameraRaw Curve Käyrä +cameraRaw Dehaze Usvanpoisto +cameraRaw Detail Yksityiskohdat +cameraRaw Effects Tehosteet +cameraRaw Exposure Valotus +cameraRaw Feather Pehmennys +cameraRaw Highlights Korostukset +cameraRaw Luminance Luminanssi +cameraRaw Masking Maskaus +cameraRaw Midpoint Keskipiste +cameraRaw Midtones Keskisävyt +cameraRaw Radius Säde +cameraRaw Roundness Pyöreys +cameraRaw Saturation Värikylläisyys +cameraRaw Shadows Varjot +cameraRaw Size Koko +cameraRaw Temperature Lämpötila +cameraRaw Texture Tekstuuri +cameraRaw Tint Värisävy +cameraRaw Whites Valkoiset +colorRange Invert Käänteinen +document Orientation Suunta +layerLabel Magenta Magenta +layerLabel No Color Ei väriä +layerLabel Red Punainen +layers Fill Täyttö +layers Fill: Täyttö: +navigator Zoom Zoomaus +shape Appearance Ulkoasu +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automaattinen kappaleen suunta + Left-to-right paragraph direction Kappaleen suunta vasemmalta oikealle + Right-to-left paragraph direction Kappaleen suunta oikealta vasemmalle + Not rendered: this adjustment ({key}) is kept as imported. Ei renderöidä: tämä säätö ({key}) säilytetään tuodussa muodossa. +# Print preview image (#8236) + Print preview image Tulostuksen esikatselukuva +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Sävykuutio + Brightness Cube Kirkkauskuutio + Color Wheel Väriympyrä + RGB Sliders RGB-liukusäätimet + HSB Sliders HSB-liukusäätimet + CMYK Sliders CMYK-liukusäätimet + Lab Sliders Lab-liukusäätimet + Web Color Sliders Verkkovärien liukusäätimet + Copy Color as HTML Kopioi väri HTML-muodossa + Copy Color's Hex Code Kopioi värin heksakoodi + RGB Spectrum RGB-spektri + CMYK Spectrum CMYK-spektri + Grayscale Ramp Harmaasävyliukuma + Current Colors Nykyiset värit + Make Ramp Web Safe Rajoita liukuma verkkoturvallisiin väreihin +# Art History Brush (#213). + Art History Brush Tool Taidehistoriasivellintyökalu + Tight Short Tiukka lyhyt + Tight Medium Tiukka keskipitkä + Tight Long Tiukka pitkä + Loose Medium Löysä keskipitkä + Loose Long Löysä pitkä + Dab Täplä + Tight Curl Tiukka kihara + Loose Curl Löysä kihara + Stylized dabs from a history state Tyylitellyt täplät historiatilasta + Font size presets Fonttikoon esiasetukset + Leading presets Rivivälin esiasetukset + Tracking presets Kirjainvälin esiasetukset + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-renderöinti + Calculating… Lasketaan… + Click to refresh histogram with uncached data Napsauta päivittääksesi histogrammin ilman välimuistia + Convert to Shape… Muunna muodoksi… + Cooling Filter (80) Viilennyssuodatin (80) + Cooling Filter (82) Viilennyssuodatin (82) + Cooling Filter (LBB) Viilennyssuodatin (LBB) + Count: Määrä: + Delete Hidden Layers Poista piilotetut tasot + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Lisää rajoite vetämällä kuvassa; Shift vaaka-/pystysuunnalle; poista napsauttamalla hiiren kakkospainikkeella. + Drawn on the CPU: {reason}. Click for System Info. Piirretään suorittimella: {reason}. Avaa järjestelmän tiedot napsauttamalla. + Exported {n} files Vietiin {n} tiedostoa + Fit Layer on Screen Sovita taso näyttöön + Flow: Virtaus: + Level: Sävytaso: + Percentile: Persentiili: + Refine Edge… Tarkenna reunaa… + Release Notes Julkaisutiedot + Remember Settings Muista asetukset + Reset Essentials Nollaa Perusteet + Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu + Show Channels in Color Näytä kanavat värillisinä + Show Statistics Näytä tilastot + Source: Lähde: + Status Bar Tilarivi + Tile Horizontally Allekkain + Tile Vertically Rinnakkain + Uncached Refresh Päivitä ilman välimuistia + Update and Restart Päivitä ja käynnistä uudelleen + Warming Filter (81) Lämmityssuodatin (81) + Warming Filter (LBA) Lämmityssuodatin (LBA) +@plural {n} document|{n} documents {n} asiakirja|{n} asiakirjaa +@plural {n} pixel|{n} pixels {n} pikseli|{n} pikseliä +cameraRaw Before (Y) Ennen (Y) +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 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ä +cameraRaw Grain Rakeisuus +cameraRaw Highlight Clipping Warning (O) Korostusten leikkautumisvaroitus (O) +cameraRaw Highlight Priority Korostusten prioriteetti +cameraRaw Load Settings… Lataa asetukset… +cameraRaw Load settings from a Camera Raw .xmp file Lataa asetukset Camera Raw .xmp -tiedostosta +cameraRaw Loaded Camera Raw settings from {file} Camera Raw -asetukset ladattu tiedostosta {file} +cameraRaw Noise Reduction Kohinan vaimennus +cameraRaw OK OK +cameraRaw Open Avaa +cameraRaw Opened {name} Avattu {name} +cameraRaw Paint Overlay Maalipeite +cameraRaw Save Settings… Tallenna asetukset… +cameraRaw Save these settings as a Camera Raw .xmp file Tallenna nämä asetukset Camera Raw .xmp -tiedostona +cameraRaw Shadow Clipping Warning (U) Varjojen leikkautumisvaroitus (U) +cameraRaw Sharpening Terävöitys +cameraRaw Show Lab Color Readouts Näytä Lab-väriarvot +cameraRaw Show Red at 3 o'clock Näytä punainen kello kolmen kohdalla +cameraRaw Show Selected Region Näytä valittu alue +cameraRaw Show Skin Tone Indicator Näytä ihonsävyn osoitin +cameraRaw Show Vectorscope Näytä vektoriskooppi +cameraRaw Tone Histogram Sävyhistogrammi +cameraRaw Vignetting Vinjetointi +cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index abca0e5791..f5e18da1fd 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -230,7 +230,19 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, P #[test] fn first_launch_renders_the_system_language_without_overwriting_auto() { - for (tag, code) in [("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr")] { + for (tag, code) in [ + ("en-US", "en"), + ("zh-CN", "zh-hans"), + ("ja-JP", "ja"), + ("ko-KR", "ko"), + ("ru-RU", "ru"), + ("uk-UA", "uk"), + ("fr-CA", "fr"), + ("sv-SE", "sv"), + ("fi-FI", "fi"), + ("da-DK", "da"), + ("nb-NO", "nb"), + ] { system::with_system_tags(&[tag], || { with_language(Lang::EN, || { let h = harness(); @@ -244,7 +256,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() { #[test] fn missing_or_unsupported_system_languages_render_english() { - for tags in [vec![], vec!["sv-SE", "sw-KE"]] { + for tags in [vec![], vec!["sw-KE", "is-IS"]] { system::with_system_tags(&tags, || { with_language(Lang::EN, || { let h = harness(); diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv new file mode 100644 index 0000000000..c3ef402e61 --- /dev/null +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -0,0 +1,4774 @@ +# Norwegian Bokmål (nb) 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. +# +# Combined from the community Norwegian Bokmål catalogs in #8072 and #9025: each row takes the best +# of their translations, with one term per concept across the UI (lag, utvalg, maske, gradient, +# myk kant, transformer, rutenett, hjelpelinje, tegneflate, lerret, klippemaske, arbeidsområde, +# pensel, justering, kanal, bane, historikk, alternativer, forvalg, strek, sløring, hurtigtaster …). +# Clean-room: written from the plain meaning of the English labels; no proprietary localisation +# resource was consulted or copied. Labels use sentence case, commands are imperatives and the +# user is addressed as "du". Product, format and technology names (PhotoCraft, ArtCraft, OpenType, +# CMYK, Lab …) and key caps keep their spelling; the trailing "…" of dialog commands is kept. +# Decimal commas and a space before % follow Norwegian typography. Plural rule: one|other +# (n != 1 -> other); nouns like "lag" (layer) have the same form in singular and plural. +# Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskealternativer… + Layer Mask Display Options… Visningsalternativer for lagmaske… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfarge + Color Indicates: Farge angir: + Solidity: Tetthet: + Ink Characteristics Trykkfargeegenskaper + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papirbredde + Paper Height Papirhøyde + Rows Rader + Number Antall + Margin Marg + Top: Topp: + Left: Venstre: + Bottom: Bunn: + Right: Høyre: + Target: Mål: + Selected Artboards Valgte tegneflater + Center Columns Sentrer kolonner + Clear Existing Guides Fjern eksisterende hjelpelinjer + Invalid guide layout Ugyldig hjelpelinjeoppsett + Leave empty for automatic size La stå tomt for automatisk størrelse + 5 Columns, 3 Rows 5 kolonner, 3 rader + 8 Columns 8 kolonner + 12 Columns 12 kolonner + + File Fil + Edit Rediger + Image Bilde + Layer Lag + Type Tekst + Select Velg + Selected Valgt + Scope Omfang + Top Left Øverst til venstre + Top Right Øverst til høyre + Bottom Left Nederst til venstre + Bottom Right Nederst til høyre + Filter Filter + View Vis + Window Vindu + Help Hjelp + +# File + New… Ny… + New from Clipboard Ny fra utklippstavlen + New Ny + Open… Åpne… + Open Åpne + Open As… Åpne som… + Open EXR Parts… Åpne EXR-deler… + Add beauty precomp from the AOVs Legg til beauty-forkomposisjon fra AOV-ene + Both Begge + Left Venstre + Right Høyre + Open Recent Åpne nylig brukte + Clear Recent Files Tøm nylig brukte filer + Remove from Recent Fjern fra nylig brukte + Remove {name} from Recent Fjern {name} fra nylig brukte + Close Lukk + Minimize Minimer + Maximize Maksimer + Restore Gjenopprett + Close All Lukk alle + Close Others Lukk andre + Save Lagre + Save As… Lagre som… + Save a Copy… Lagre en kopi… + {name} can't keep everything in this document {name} kan ikke bevare alt i dette dokumentet + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Velg et format som bevarer det, for eksempel Photoshop (PSD), eller lagre en kopi. + Save… Lagre… + Revert Tilbakestill til lagret + Import Importer + Import Presets Importer forvalg + 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… + Package… Pakk… + Save for Web (Legacy)… Lagre for web (eldre)… + Generate Generer + Image Assets Bilderessurser + +# Edit + Undo Angre + Redo Gjør om + Fade… Ton ned… + Cut Klipp ut + Copy Kopier + Copy Merged Kopier sammenslått + Paste Lim inn + Paste Special Lim inn spesielt + Paste Into Lim inn i + Paste Outside Lim inn utenfor + Paste in Place Lim inn på samme sted + Clear Fjern + Search Søk + Search… Søk… + Check Spelling… Stavekontroll… + Fill… Fyll… + Stroke… Strek… + Content-Aware Fill… Innholdsbasert fyll… + Delete and Fill Selection Slett og fyll utvalg + Define Brush Preset… Definer penselforvalg… + Define Pattern… Definer mønster… + Define Custom Shape… Definer egendefinert form… + Purge Tøm + +# Image + Image Size… Bildestørrelse… + Image Size Bildestørrelse + Canvas Size… Lerretstørrelse… + Image Rotation Bilderotasjon + Rotate 90° Clockwise Roter 90° med klokken + Rotate 90° Counter Clockwise Roter 90° mot klokken + Rotate 180° Roter 180° + Flip Canvas Horizontal Speilvend lerretet vannrett + Flip Canvas Vertical Speilvend lerretet loddrett + Crop Beskjær + Crop and Straighten Photos Beskjær og rett opp bilder + Trim… Fjern kanter… + Reveal All Vis alt + Hide Selection Skjul utvalg + Auto Tone Autotone + Auto Contrast Autokontrast + Auto Color Autofarge + Adjustments Justeringer + 16 Bits/Channel 16 biter/kanal + 32 Bits/Channel 32 biter/kanal + 8 Bits/Channel 8 biter/kanal + 8 bit 8 biter + Integer Heltall + 16 bit 16 biter + 32 bit (float) 32 biter (flyttall) + Floating point Flyttall + Mode Modus + Grayscale Gråtoner + RGB Color RGB-farge + CMYK Color CMYK-farge + Lab Color Lab-farge + Indexed Color Indeksert farge + Multichannel Flerkanal + Bitmap… Punktgrafikk… + Bitmap Punktgrafikk + Duotone… Duotone… + Duotone Duotone + Trap… Overlapping… + +# Layer + Layer via Copy Lag via kopiering + Layer via Cut Lag via utklipp + Duplicate Layer… Dupliser lag… + Duplicate… Dupliser… + Delete Slett + Delete Layer Slett lag + Delete Layers Slett lag + Rename Layer Gi laget nytt navn + Layer From Background… Lag fra bakgrunn… + Layer from Background… Lag fra bakgrunn… + Background from Layer Bakgrunn fra lag + Background From Layer Bakgrunn fra lag + Group Layers Grupper lag + Group… Gruppe… + Ungroup Layers Opphev gruppering av lag + Group from Layers… Gruppe fra lag… + New Group from Layers… Ny gruppe fra lag… + Hide Layers Skjul lag + Arrange Ordne + Bring to Front Flytt fremst + Bring Forward Flytt fremover + Send Backward Flytt bakover + Send to Back Flytt bakerst + Convert to Smart Object Konverter til smartobjekt + Convert for Smart Filters Konverter for smartfiltre + New Smart Object via Copy Nytt smartobjekt via kopiering + Edit Contents Rediger innhold + Replace Contents… Erstatt innhold… + Export Contents… Eksporter innhold… + Rasterize Rasteriser + Rasterize Type Rasteriser tekst + Rasterize Shape Rasteriser form + Rasterize Layer Rasteriser lag + Rasterize Layers Rasteriser lag + New Adjustment Layer Nytt justeringslag + New Fill Layer Nytt fyllag + Layer Mask Lagmaske + Add Layer Mask Legg til lagmaske + Delete Layer Mask Slett lagmaske + Enable Layer Mask Aktiver lagmaske + Disable Layer Mask Deaktiver lagmaske + Apply Layer Mask Bruk lagmaske + Add Mask to Selection Legg maske til utvalget + Subtract Mask from Selection Trekk maske fra utvalget + Intersect Mask with Selection Snitt av maske og utvalg + Link Layer Mask Koble lagmaske + Unlink Layer Mask Koble fra lagmaske + Vector Mask Vektormaske + Add Vector Mask Legg til vektormaske + Delete Vector Mask Slett vektormaske + Enable Vector Mask Aktiver vektormaske + Link Vector Mask Koble vektormaske + Create Clipping Mask Opprett klippemaske + Release Clipping Mask Frigjør klippemaske + New Shape Layer Nytt formlag + New Type Layer Nytt tekstlag + New Video Layer from File… Nytt videolag fra fil… + New Blank Video Layer Nytt tomt videolag + Smart Filters Smartfiltre + Delete Smart Filters Slett smartfiltre + Enable Smart Filters Aktiver smartfiltre + Disable Smart Filters Deaktiver smartfiltre + Clear Smart Filters Fjern smartfiltre + Delete Filter Mask Slett filtermaske + Disable Filter Mask Deaktiver filtermaske + New Layer Based Slice Nytt lagbasert segment + Layer Style Lagstil + Blending Options… Blandingsalternativer… + Blending Options Blandingsalternativer + Drop Shadow… Slagskygge… + Inner Shadow… Indre skygge… + Outer Glow… Ytre glød… + Inner Glow… Indre glød… + Bevel & Emboss… Skråkant og relieff… + Use Global Light Bruk globalt lys + Overprint Overtrykk + Satin… Sateng… + Color Overlay… Fargeoverlegg… + Gradient Overlay… Gradientoverlegg… + Pattern Overlay… Mønsteroverlegg… + Bevel & Emboss Skråkant og relieff + Inner Shadow Indre skygge + Inner Glow Indre glød + Satin Sateng + Color Overlay Fargeoverlegg + Gradient Overlay Gradientoverlegg + Pattern Overlay Mønsteroverlegg + Outer Glow Ytre glød + Drop Shadow Slagskygge + Copy Layer Style Kopier lagstil + Paste Layer Style Lim inn lagstil + Clear Layer Style Fjern lagstil + Layer Content Options… Alternativer for laginnhold… + Scale Effects… Skaler effekter… + New Layer… Nytt lag… + Delete Layer… Slett lag… + +# Type + Font Skrift + Font Preview Size Forhåndsvisningsstørrelse for skrift + Orientation Retning + Horizontal Vannrett + Vertical Loddrett + Rasterize Type Layer Rasteriser tekstlag + Create Work Path Opprett arbeidsbane + Convert to Shape Konverter til form + Convert to Paragraph Text Konverter til avsnittstekst + Convert to Point Text Konverter til punkttekst + Warp Text… Fordrei tekst… + Character Panel Tegnpanel + Paragraph Panel Avsnittspanel + Check Spelling Stavekontroll + Language Options Språkalternativer + Find and Replace Text… Søk og erstatt tekst… + Add to Dictionary Legg til i ordlisten + No Spelling Suggestions Ingen staveforslag + Paste Lorem Ipsum Lim inn lorem ipsum + Glyphs Panel Glyfpanel + Type Styles Tekststiler + Character Styles Tegnstiler + Paragraph Styles Avsnittsstiler + Load Default Type Styles Last inn standard tekststiler + Save Default Type Styles Lagre standard tekststiler + Type… Tekst… + +# Select + 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… +# Select and Mask dialog labels + Smart Radius Smart radius + Shift Edge Forskyv kant + Decontaminate Fjern fargesmitte + Output Utdata + Refine Edge Forbedre kant + Select Subject Velg motiv + Sky Himmel + Focus Area… Fokusområde… + Subject Motiv + Color Range Fargeområde + Similar Lignende + Grow Voks + Select Similar Colors Velg like farger + Load Selection… Last inn utvalg… + Save Selection… Lagre utvalg… + New Selection Nytt utvalg + Select All Velg alt + Deselect All Opphev alle valg + Deselect Layers Opphev lagvalg + Find Layers Finn lag + Isolate Layers Isoler lag + Select Linked Layers Velg koblede lag + All Layers Alle lag + Current & Below Gjeldende og under + Current Layer Gjeldende lag + Refine Strokes Forbedre strøk + Edits Redigeringer + +# Filter + Last Filter Siste filter + Filter Gallery… Filtergalleri… + Liquify… Gjør flytende… + Camera Raw Filter… Camera Raw-filter… + Adaptive Wide Angle… Adaptiv vidvinkel… + Lens Correction… Objektivkorreksjon… + Vanishing Point… Fluktpunkt… + Oil Paint… Oljemaling… + Blur Gallery Sløringsgalleri + Field Blur… Feltsløring… +# Field Blur pins (#3373) + No pin selected Ingen nål valgt + 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. Klikk i bildet for å legge til en nål. Dra en nål for å flytte den, eller ringen for å endre sløringen. Delete fjerner den valgte nålen. + 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. Dra i håndtakene på omrisset for å endre størrelse og rotere, i firkanten for å runde av hjørnene, og i prikkene innenfor for å angi hvor sløringen starter. + 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. Dra nålen for å flytte båndet, eller ringen for å endre sløringen. Dra i de heltrukne linjene for å gjøre det skarpe båndet bredere, i de stiplede linjene for å angi overgangen, og i prikkene for å rotere det. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Dra i håndtakene på omrisset for å endre størrelse og rotere. Ringen angir sløringsvinkelen. + Blur Angle Sløringsvinkel + Iris Blur… Irissløring… + Tilt-Shift… Tilt-shift… + Path Blur… Banesløring… + Spin Blur… Rotasjonssløring… + Auto-Align Layers… Juster lag automatisk… + Auto-Blend Layers… Bland lag automatisk… + Photomerge… Photomerge… + Content-Aware Scale Innholdsbasert skalering + Adaptive Wide Angle Adaptiv vidvinkel + Apply Image… Bruk bilde… + Calculations… Beregninger… + Crystallize… Krystalliser… + Facet Fasett + Fragment Fragmenter + Mosaic… Mosaikk… + Pointillize… Pointillisme… + Chalk & Charcoal Kritt og trekull + Charcoal Trekull + Chrome Krom + Conté Crayon Conté-kritt + Graphic Pen Grafisk penn + Halftone Pattern Halvtonemønster + Note Paper Notatpapir + Photocopy Fotokopi + Plaster Gips + Reticulation Retikulering + Stamp Stempel + Torn Edges Avrevne kanter + Water Paper Vannpapir + Clouds Skyer + Difference Clouds Differanseskyer + Fibers… Fibre… + Lens Flare… Objektivrefleks… + Picture Frame… Bilderamme… + Tree… Tre… + Flame… Flamme… + Sharpen Edges Skjerp kanter + Dust & Scratches… Støv og riper… + Noise Støy + Add Noise… Legg til støy… + Reduce Noise… Reduser støy… + Despeckle Fjern flekker + Median… Median… + Median Median + Maximum… Maksimum… + Maximum Maksimum + Minimum… Minimum… + Minimum Minimum + Custom… Egendefinert… + High Pass… Høypass… + Emboss… Relieff… + Extrude… Ekstruder… + Find Edges Finn kanter + Solarize Solariser + Tiles… Fliser… + Trace Contour… Tegn kontur… + Wind… Vind… + Offset… Forskyvning… + Pinch… Klyp… + Polar Coordinates… Polarkoordinater… + Ripple… Krusning… + Shear… Skjær… + Spherize… Kuleform… + Twirl… Virvel… + Wave… Bølge… + ZigZag… Sikksakk… + Average Gjennomsnitt + Blur More Slør mer + Box Blur… Bokssløring… + Gaussian Blur… Gaussisk sløring… + Motion Blur… Bevegelsessløring… + Radial Blur… Radial sløring… + Shape Blur… Formsløring… + Smart Blur… Smart sløring… + Surface Blur… Overflatesløring… + Sharpen Skjerp + Sharpen More Skjerp mer + Unsharp Mask… Uskarp maske… + Smart Sharpen… Smart skjerping… + Diffuse… Diffuser… + Mezzotint… Mezzotint… + Color Halftone… Fargeraster… + Layer… Lag… + +# View + Proof Setup Korrekturoppsett + Proof Colors Korrekturfarger + Pixel Aspect Ratio Pikselforhold + Pixel Aspect Ratio Correction Korrigering av pikselforhold + Zoom In Zoom inn + Zoom Out Zoom ut + Fit on Screen Tilpass til skjermen + 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 Utskriftsstørrelse + Screen Mode Skjermmodus + Standard Screen Mode Standard skjermmodus + Full Screen Mode With Menu Bar Fullskjermmodus med menylinje + Full Screen Mode Fullskjermmodus + Extras Ekstra + Show Vis + Rulers Linjaler + Grid Rutenett + Guides Hjelpelinjer + Slices Segmenter + Snap Fest + Snap To Fest til + Lock Guides Lås hjelpelinjer + Clear Guides Fjern hjelpelinjer + New Guide… Ny hjelpelinje… + Lock Slices Lås segmenter + Clear Slices Fjern segmenter + New Guide Layout… Nytt hjelpelinjeoppsett… + New Guides From Shape Nye hjelpelinjer fra form + Canvas rotation Lerretrotasjon + Zoom in Zoom inn + Zoom out Zoom ut + +# Window + Cascade Overlappende + Tile Side om side + 2-up Horizontal 2 vannrett + 3-up Horizontal 3 vannrett + 2-up Vertical 2 loddrett + 3-up Vertical 3 loddrett + 3-up Stacked 3 stablet + 4-up 4 vinduer + 6-up 6 vinduer + Consolidate All to Tabs Samle alle i faner + Match Zoom Samkjør zoom + 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 + Actions Handlinger + Brush Settings Penselinnstillinger + Brushes Pensler + Channels Kanaler + Character Tegn + Clone Source Klonekilde + Color Mixer Fargemikser + Glyphs Glyfer + Gradients Gradienter + Histogram Histogram + History Historikk + Info Info + Layer Comps Lagkomposisjoner + Layers Lag + Measurement Log Målelogg + Notes Notater + Navigator Navigator + Paragraph Avsnitt + Paths Baner + Patterns Mønstre + Properties Egenskaper + Styles Stiler + Swatches Fargeprøver + Timeline Tidslinje + Tool Presets Verktøyforvalg + Tools Verktøy + Libraries Biblioteker + 3D 3D + Toolbars Verktøylinjer + Hide All Skjul alt + +# Help + About PhotoCraft Om PhotoCraft + PhotoCraft Website PhotoCrafts nettsted + PhotoCraft on GitHub PhotoCraft på GitHub + Report an Issue… Rapporter et problem… + ArtCraft Website ArtCraft-nettstedet + Join the ArtCraft Discord… Bli med i ArtCraft-Discord… + System Info… Systeminformasjon… + +# Tools and options bar + Move Flytt + Marquee Markeringsramme + Lasso Lasso + Wand Tryllestav + Eyedropper Pipette + Brush Pensel + Block Blokk + Pencil Blyant + Eraser Viskelær + Clone Stamp Klonestempel + Healing Reparasjon + Blur Sløring + Dodge Avskjerming + Burn Etterbelysning + Gradient Gradient + Shape Form + Pen Penn + Hand Hånd + Zoom Zoom + Sampler Fargemåler + Ruler Tool Linjalverktøy + Count Tool Telleverktøy + Note tool Notatverktøy + Quick Selection Hurtigvalg + Magnetic Lasso Magnetisk lasso + Mixer Brush Mikserpensel + Mixer Brush Tool Mikserpenselverktøy + Color Replacement Tool Fargeerstatningsverktøy + History Brush Historikkpensel + Art History Brush Kunstnerisk historikkpensel + Pattern Stamp Mønsterstempel + Spot Healing Punktreparasjon + Red Eye Røde øyne + Patch Lapp + Content-Aware Move Innholdsbasert flytting + Rotate View Roter visning + Perspective Crop Perspektivbeskjæring + Color Replacement Fargeerstatning + Dodge/Burn Avskjerming/etterbelysning + Foreground Forgrunn + Background Bakgrunn + Set foreground color Angi forgrunnsfarge + Set background color Angi bakgrunnsfarge + Swap colours (X) Bytt farger (X) + Default colours (D) Standardfarger (D) + Brush Tool Options Alternativer for penselverktøy + Opacity Dekkevne + Flow Flyt + Smoothing Utjevning + Size Størrelse + Hardness Hardhet + Angle: Vinkel: + Roundness: Rundhet: + Spacing Avstand + Dynamics Dynamikk + Pressure Trykk + Tilt Helling + Rotation Rotasjon + Align Juster + Aligned Justert + +# Panels, dialogs and common widgets + OK OK + Cancel Avbryt + Cancel (Esc) Avbryt (Esc) + Manage submitted jobs in the system print queue Behandle innsendte jobber i systemets utskriftskø + Done Ferdig + Apply Bruk + Reset Tilbakestill + Reset Section Tilbakestill seksjon + Reset All to Defaults Tilbakestill alle til standard + Import Shortcuts… Importer hurtigtaster… + Load a keyboard shortcut set saved as .kys Last inn et hurtigtastsett lagret som .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Importerte {n} hurtigtaster fra {file}. Klikk OK for å beholde dem. + {n} commands are not in PhotoCraft. {n} kommandoer finnes ikke i PhotoCraft. + Keyboard shortcuts imported Hurtigtaster importert + 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. Hurtigtastsettet ditt {set} er i bruk her: {n} hurtigtaster avviker fra standard. Rediger › Hurtigtaster viser dem; Tilbakestill alle til standard angrer dette. + Import keyboard shortcuts? Importere hurtigtaster? + 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 fant hurtigtastsettet {set} fra et annet bilderedigeringsprogram på denne datamaskinen: {n} av hurtigtastene avviker fra PhotoCrafts. Importere det? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Rediger › Hurtigtaster › Tilbakestill alle til standard angrer en import. + Don't Import Ikke importer + The keyboard shortcut set was not imported: {error} Hurtigtastsettet ble ikke importert: {error} + Defaults restored. Standarder gjenopprettet. + Restore Defaults Gjenopprett standarder + Restore original size Gjenopprett opprinnelig størrelse + Use Default Bruk standard + Dismiss Lukk + Preview Forhåndsvisning + Options Alternativer + Settings… Innstillinger… + Rename… Gi nytt navn… + Load… Last inn… + Define… Definer… + Custom Egendefinert + Default Standard + None Ingen + Add Legg til + Remove Fjern + Remove Background Fjern bakgrunn + Rename Gi nytt navn + Ignore Ignorer + Ignore All Ignorer alle + Change Endre + Change All Endre alle + Next frame Neste ramme + Previous frame Forrige ramme + Play Spill av + Pause Pause + Stop Stopp + Begin recording Start opptak + Stop playing/recording Stopp avspilling/opptak + Record ● a sequence of edits, then play ▶ it on any document. Ta opp ● en rekke redigeringer, og spill dem av ▶ på et hvilket som helst dokument. + Create new action Opprett ny handling + Play selection Spill av utvalget + Create new set Opprett nytt sett + Export selected measurements (CSV) Eksporter valgte målinger (CSV) + Record Measurements Registrer målinger + Delete selected measurements Slett valgte målinger + Select Data Points… Velg datapunkter… + Set Measurement Scale… Angi måleskala… + Use Measurement Scale Bruk måleskala + +# Preferences dialog + Preferences Innstillinger + General… Generelt… + Interface… Grensesnitt… + File Handling… Filhåndtering… + Performance… Ytelse… + Cursors… Markører… + Transparency & Gamut… Gjennomsiktighet og fargeomfang… + Units & Rulers… Enheter og linjaler… + Guides, Grid & Slices… Hjelpelinjer, rutenett og segmenter… + Plug-ins… Programtillegg… + Technology Previews Teknologiforhåndsvisninger + Scratch Disks… Arbeidsdisker… + Export Preferences… Eksportinnstillinger… + Keyboard Shortcuts… Hurtigtaster… + Menus… Menyer… + Export/Import Presets… Eksporter/importer forvalg… + Additional plugins folder Ekstra mappe for programtillegg + Install Plug-in… Installer programtillegg… + Export Presets Eksporter forvalg + Import brushes (.abr) Importer pensler (.abr) + Migrate Presets Overfør forvalg + Thumbnail size (smallest: list) Miniatyrstørrelse (minste: liste) + Reset GPU Backend Tilbakestill GPU-motor + Applies at next launch. Trer i kraft ved neste oppstart. + +# Document and canvas status bar + Untitled Uten navn + Untitled-1 Uten navn-1 + Doc: {flat}/{layered} Dok: {flat}/{layered} + Scratch: {size} Arbeidsdisk: {size} + Efficiency: 100% Effektivitet: 100 % + Dimensions: Dimensjoner: + Width Bredde + Slant Skråstilling + Height Høyde + Width: Bredde: + Height: Høyde: + Resolution Oppløsning + Resolution: Oppløsning: + Resolution: {dpi} pixels/inch Oppløsning: {dpi} piksler/tomme + {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: Piksler: + Pixel Length: Piksellengde: + Logical Length: Logisk lengde: + Logical Units: Logiske enheter: + Length: Lengde: + Interpolation: Interpolering: + Resample Resampling + Constrain proportions Behold proporsjoner + Link width and height Koble bredde og høyde + Maintain aspect ratio Behold størrelsesforhold + Current Size Gjeldende størrelse + New Size Ny størrelse + Fit Tilpass + Percent Prosent + Pixels/Inch Piksler/tomme + Pixels/Centimeter Piksler/centimeter + Relative Relativ + Canvas extension color: Farge for lerretutvidelse: + Anchor: Anker: + Both Axes Begge akser + Horizontally Vannrett + Vertically Loddrett + +# Layers panel + Layers to Files… Lag til filer… + Duplicate Layers… Dupliser lag… + Group name Gruppenavn + New Group Ny gruppe + Layer Comp Lagkomposisjon + Create new layer Opprett nytt lag + Create new group Opprett ny gruppe + Create new channel Opprett ny kanal + Create new path Opprett ny bane + Toggle visibility Veksle synlighet + Clipped Klippet + Group · {n} layer Gruppe · {n} lag + Lock: keep these settings when picking another preset Lås: behold disse innstillingene når et annet forvalg velges + Unlock: picking a preset replaces these settings Lås opp: valg av et forvalg erstatter disse innstillingene + Show the layer's effects ({key}-click: all layers) Vis lagets effekter ({key}-klikk: alle lag) + Hide the layer's effects ({key}-click: all layers) Skjul lagets effekter ({key}-klikk: alle lag) + Expand group ({key}-click: all groups) Fold ut gruppe ({key}-klikk: alle grupper) + Collapse group ({key}-click: all groups) Fold sammen gruppe ({key}-klikk: alle grupper) + Effects Effekter + Blending Blanding + Blend If Bland hvis + General Blend Generell blanding + Advanced Blending Avansert blanding + Opacity: Dekkevne: + Fill: Fyll: + Lock: Lås: + Smart Filter Smartfilter + Fill Content Fyllinnhold + Adjustment Preview Forhåndsvisning av justering + +# Channels panel + Channel: Kanal: + Channel Mixer… Kanalmikser… + Output Channel: Utdatakanal: + RGB RGB + Red Rød + Green Grønn + Blue Blå + Alpha Alfa + Load channel as selection Last inn kanal som utvalg + Save selection as channel Lagre utvalg som kanal + Delete current channel Slett gjeldende kanal + Spot Channel Spotkanal + Mask All Masker alt + Mask All Objects Masker alle objekter + +# Color picker and swatches + Color Farge + Color: Farge: + Colors Farger + HSB/HSL HSB/HSL + No Color Ingen farge + Set shape fill type Angi fylltype for form + Set shape stroke type Angi strektype for form + Set the text color Angi tekstfargen + Create warped text Opprett fordreid tekst + Only Web Colors Bare webfarger + Overlay Color Overleggsfarge + Overlay Opacity Overleggsdekkevne + Glow Color Glødfarge + Web Snap Webfesting + +# Filters and adjustments dialogs + Brightness Lysstyrke + Contrast Kontrast + Brightness/Contrast… Lysstyrke/kontrast… + Levels… Nivåer… + Curves… Kurver… + Curve Kurve + Exposure… Eksponering… + Exposure Eksponering + Vibrance… Vibrans… + Vibrance Vibrans + Hue/Saturation… Fargetone/metning… + Hue Fargetone + Saturation Metning + Lightness Lyshet + Color Balance… Fargebalanse… + Black & White… Svart-hvitt… + Photo Filter… Fotofilter… + Color Lookup… Fargeoppslag… + Color Lookup Tables… Fargeoppslagstabeller… + 3D LUT File 3D LUT-fil + Load 3D LUT… Last inn 3D LUT… + Invert… Inverter… + Invert Inverter + Invert has no settings. Inverter har ingen innstillinger. + Threshold… Terskel… + Threshold Level Terskelnivå + Posterize… Posteriser… + Gradient Map… Gradienttilordning… + Selective Color… Selektiv farge… + Match Color… Tilpass farge… + Replace Color… Erstatt farge… + Equalize Utjevn + HDR Toning… HDR-toning… + Shadows/Highlights… Skygger/høylys… + Amount Mengde + Radius Radius + Radius: Radius: + Tolerance: Toleranse: + Density Tetthet + Density: Tetthet: + Feather Myk kant + Feather… Myk kant… + Anti-alias Kantutjevning + Anti-Alias Kantutjevning + Contiguous Sammenhengende + Sample All Layers Ta prøve fra alle lag + Protect Foreground Color Beskytt forgrunnsfarge + Matte Kantfarge + Matting Kantbehandling + Color Decontaminate… Fjern fargesmitte… + Remove Black Matte Fjern svart kant + Remove White Matte Fjern hvit kant + Defringe… Fjern frynser… + Source Kilde + Working CMYK Arbeids-CMYK + Working Cyan Plate Arbeidsplate for cyan + Working Magenta Plate Arbeidsplate for magenta + Working Yellow Plate Arbeidsplate for gul + Working Black Plate Arbeidsplate for svart + Working Gray Plate Arbeidsplate for grå + +# Camera Raw and lens dialogs + Basic Grunnleggende + Detail Detalj + HSL/Grayscale HSL/gråtoner + Tone Curve Tonekurve + Split Toning Delt toning + Lens Corrections Objektivkorreksjoner + Camera Calibration Kamerakalibrering + Presets Forvalg + Snapshots Øyeblikksbilder + Temperature Temperatur + Tint Fargetone + Exposure: Eksponering: + Recovery Gjenoppretting + Fill Light Utfyllingslys + Blacks Svarttoner + Clarity Klarhet + Convert to Grayscale Konverter til gråtoner + Auto Auto + Auto collapse icon panels Skjul ikonpaneler automatisk + Auto show hidden panels Vis skjulte paneler automatisk + Auto show home screen Vis startskjermen automatisk + Sharpness Skarphet + Noise Reduction Støyreduksjon + Color Noise Reduction Fargestøyreduksjon + Luminance Detail Luminansdetalj + Color Detail Fargedetalj + Luminance Smoothness Luminansjevnhet + Vignetting Vignettering + White Balance: As Shot Hvitbalanse: som fotografert + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw er ikke åpen + Dehaze Fjern dis + Texture Tekstur + Focal Length Brennvidde + Make Produsent + Model Modell + Lens Objektiv + F F + ISO ISO + Focal Length35mm Brennvidde 35 mm + Aspect Ratio Størrelsesforhold + Time Tid + Artist Kunstner + Copyright Opphavsrett + Enter description… Angi beskrivelse… + Adobe RGB (1998) compatible Adobe RGB (1998)-kompatibel + Gray Gamma 2.2 Grå gamma 2,2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Bransjestandard + Web Web + Internet Standard RGB Internettstandard RGB + Legacy Macintosh RGB Eldre Macintosh RGB + Monitor RGB Skjerm-RGB + Windows LCD Windows LCD + Coated CMYK Bestrøket CMYK + Uncoated CMYK Ubestrøket CMYK + Newspaper Avis + Working Spaces Arbeidsfargerom + Color Management Policies Retningslinjer for fargestyring + Assign working space Tilordne arbeidsfargerom + Leave as is (don't color manage) La være som det er (ikke bruk fargestyring) + Discard the embedded profile (don't color manage) Forkast den innebygde profilen (ikke bruk fargestyring) + Embed Color Profile Bygg inn fargeprofil + Assign Profile… Tilordne profil… + Convert to Profile… Konverter til profil… + PhotoCraft Manages Colors PhotoCraft styrer farger + Printer Manages Colors Skriveren styrer farger + (print to the printer) (skriv ut på skriveren) + Absolute Colorimetric Absolutt kolorimetrisk + Relative Colorimetric Relativ kolorimetrisk + Perceptual Perseptuell + Black Point Compensation Svartpunktkompensasjon + Use Legacy Bruk eldre + Dither Dithering + No Dither Ingen dithering + Diffusion Diffusjon + Pattern Mønster + Fuzziness: Uklarhet: + Range: Område: + Select: Velg: + Detect Faces Gjenkjenn ansikter + Localize Color Clusters Lokaliser fargeklynger + Sample: Prøve: + Add to Sample Legg til i prøve + Subtract from Sample Trekk fra prøve + Selections Utvalg + Duplicated selection name Duplisert utvalgsnavn + Operation Operasjon + New channel only Bare ny kanal + Masked Areas Maskerte områder + Selected Areas Valgte områder + Spot Color Spotfarge + Color indicates Farge angir + +# Adjustments panel + Curves Kurver + Levels Nivåer + Color Balance Fargebalanse + Black & White Svart-hvitt + Photo Filter Fotofilter + Color Lookup Fargeoppslag + Threshold Terskel + Posterize Posteriser + Brightness/Contrast Lysstyrke/kontrast + Channel Mixer Kanalmikser + Gradient Map Gradienttilordning + Selective Color Selektiv farge + +# History panel + History Log… Historikklogg… + Create Snapshot Opprett øyeblikksbilde + Delete Snapshot Slett øyeblikksbilde + New Snapshot Nytt øyeblikksbilde + Snapshot 1 Øyeblikksbilde 1 + Step Forward Trinn fremover + Step Backward Trinn bakover + Histories Historikker + Nonlinear History Ikke-lineær historikk + Set New Snapshot as Default Angi nytt øyeblikksbilde som standard + Automatically Create First Snapshot Opprett første øyeblikksbilde automatisk + Automatically Create New Snapshot When Saving Opprett nytt øyeblikksbilde automatisk ved lagring + Show New Snapshot Dialog by Default Vis dialogen for nytt øyeblikksbilde som standard + Clear History Tøm historikk + +# Navigator panel + Panel Options… Panelalternativer… + View menu Vis-menyen + Keep view in sync Hold visningen synkronisert + {zoom}% {zoom} % + +# Brush Settings panel + Brush name Penselnavn + Brush Preview Penselforhåndsvisning + Brush Tip Shape Penselspissform + Shape Dynamics Formdynamikk + Scattering Spredning + Dual Brush Dobbel pensel + Color Dynamics Fargedynamikk + Transfer Overføring + Brush Pose Penselstilling + Mouse Controls Musekontroller + Wet Edges Våte kanter + Build-up Oppbygging + Protect Texture Beskytt tekstur + Blend Mode Blandingsmodus + Size Jitter Størrelsesvariasjon + Minimum Diameter Minste diameter + Tilt Scale Hellingsskala + Angle Jitter Vinkelvariasjon + Roundness Jitter Rundhetsvariasjon + Minimum Roundness Minste rundhet + Flip X Jitter Speilvend X-variasjon + Flip Y Jitter Speilvend Y-variasjon + Brush Projection Penselprojeksjon + Control: Kontroll: + Off: the speed of the pointer sets the spacing Av: pekerens hastighet bestemmer avstanden + Fade steps Nedtoningstrinn + Pen Pressure Penntrykk + Pen Tilt Pennhelling + Stylus Wheel Pennhjul + Initial Direction of Stroke Strøkets startretning + Direction of Stroke Strøkets retning + Opacity Jitter Dekkevnevariasjon + Flow Jitter Flytvariasjon + Humidity Fuktighet + Load Last inn + Mix Bland + Auto Clean Brush Rens pensel automatisk + Wet Våt + Current Brush Load Gjeldende pensellast + Load Brush Last inn pensel + Clean Brush Rens pensel + Load the brush after each stroke Last inn penselen etter hvert strøk + Clean the brush after each stroke Rens penselen etter hvert strøk + Rate Hastighet + Airbrush: keeps depositing paint while the pointer rests. Airbrush: fortsetter å legge på maling mens pekeren står i ro. + Pulled String Mode Strukket snor-modus + Catch-up on Stroke End Fullfør ved strøkslutt + Adjust for Zoom Juster for zoom + Apply Per Tip Bruk per spiss + Hardness: Hardhet: + Spacing: Avstand: + Size: Størrelse: + Flip X Speilvend X + Flip Y Speilvend Y + Brush Tip Penselspiss + Diameter: Diameter: + New Layer from Brush Nytt lag fra pensel + Rename Brush… Gi penselen nytt navn… + Delete Brush Slett pensel + Import Brushes… Importer pensler… + Import Brushes… (.abr or image) Importer pensler… (.abr eller bilde) + Import Brush from Image… Importer pensel fra bilde… + Gradient Preset Options Alternativer for gradientforvalg + Import .GRD Importer .GRD + Choose a gradient file (.grd). Velg en gradientfil (.grd). + Brush Name Penselnavn + Brush Stroke Penselstrøk + New Brush Preset… Nytt penselforvalg… + Capture Brush Size in Preset Ta med penselstørrelse i forvalget + Include Tool Settings Ta med verktøyinnstillinger + Include Color Ta med farge + Enter a name for the brush preset Skriv inn et navn på penselforvalget + A brush preset with this name already exists Et penselforvalg med dette navnet finnes allerede + Brush Preset Options Alternativer for penselforvalg + New Brush Ny pensel + Create new brush from these settings Opprett ny pensel fra disse innstillingene + Update the current brush with these settings Oppdater gjeldende pensel med disse innstillingene + Reset the brush to the defaults Tilbakestill penselen til standard + No brush presets Ingen penselforvalg + Override Pressure Overstyr trykk + Override Tilt Overstyr helling + Override Tilt X Overstyr helling X + Override Tilt Y Overstyr helling Y + Override Rotation Overstyr rotasjon + Apply the pen's tilt and rotation to the tip shape Bruk pennens helling og rotasjon på spissformen + +# Tool presets + Preset Type Forvalgstype + Preset Manager… Forvalgsbehandling… + Rename Tool Preset… Gi verktøyforvalget nytt navn… + Delete Tool Preset Slett verktøyforvalg + New Tool Preset Nytt verktøyforvalg + Create new tool preset Opprett nytt verktøyforvalg + Current Tool Only Bare gjeldende verktøy + No tool presets. Ingen verktøyforvalg. + No presets for the current tool. Ingen forvalg for gjeldende verktøy. + No presets of this type. Ingen forvalg av denne typen. + Preset: Forvalg: + Preset Forvalg + Presets: Forvalg: + +# Paragraph and character panels + Font Size: Skriftstørrelse: + Font size Skriftstørrelse + Type: Type: + Style Stil + Style: Normal Stil: Normal + Tracking Tegnavstand + Kerning Kniping + Leading Linjeavstand + Auto-Leading Automatisk linjeavstand + Scale Horizontally Skaler vannrett + Scale Vertically Skaler loddrett + Baseline Shift Grunnlinjeforskyvning + Language Språk + Anti-aliasing method Kantutjevningsmetode + Sharp Skarp + Crisp Klar + Strong Sterk + Smooth Myk + Mac LCD Mac LCD + Mac Sharp Mac skarp + Mac Crisp Mac klar + Mac Strong Mac sterk + Mac Smooth Mac myk + Faux Bold Falsk fet + Faux Italic Falsk kursiv + All Caps Store bokstaver + Small Caps Kapitéler + Superscript Hevetekst + Subscript Senketekst + Underline Understreking + Strikethrough Gjennomstreking + Oldstyle Gammeldagse tall + Ligature Ligatur + Alternative Alternativ + Hyphenate Orddeling + Roman Antikva + Italic Kursiv + Bold Fet + Bold Italic Fet kursiv + Light Lys + Medium Middels + Black Svart + Thin Tynn + Ultra Light Ultra lett + Extra Light Ekstra lett + Semi Bold Halvfet + Extra Bold Ekstra fet + Ultra Bold Ultra fet + Condensed Sammentrengt + Left align text Venstrejuster tekst + Center text Sentrer tekst + Right align text Høyrejuster tekst + Justify last left Juster, siste linje til venstre + Justify last centered Juster, siste linje sentrert + Justify last right Juster, siste linje til høyre + Justify all Juster alle linjer + Indent Left Innrykk venstre + Indent Right Innrykk høyre + First Line Indent Innrykk første linje + Space Before Avstand før + Space After Avstand etter + Hanging Punctuation Hengende tegnsetting + Roman Hanging Punctuation Antikva hengende tegnsetting + Avoid Widows/Orphans Unngå enker/horunger + Compose Komponer + Adobe Single-line Composer Adobe setter for én linje + Adobe Every-line Composer Adobe setter for hver linje + Justification Justering + Word Spacing Ordmellomrom + Letter Spacing Bokstavavstand + Glyph Scaling Glyfskalering + Auto-leading % Automatisk linjeavstand % + Single Word Justification Justering av enkeltord + Alignment Justering + Indent First Innrykk første linje + Center Sentrer + Justify Juster + +# Glyphs panel + Glyph Glyf + Select a type layer or click into text to insert glyphs Velg et tekstlag eller klikk i teksten for å sette inn glyfer + Entire Font Hele skriften + Alternates for Current Selection Alternativer for gjeldende utvalg + Recently Used Nylig brukt + Digits Sifre + Symbols Symboler + Punctuation Tegnsetting + OpenType OpenType + Standard Ligatures Standardligaturer + Discretionary Ligatures Valgfrie ligaturer + Contextual Alternates Kontekstuelle alternativer + Swash Svungne tegn + Titling Alternates Tittelalternativer + Ornaments Ornamenter + Fractions Brøker + Ordinals Ordenstall + East Asian Features Østasiatiske funksjoner + Middle Eastern Features Midtøstlige funksjoner + +# Paths panel + Path Bane + Path: Bane: + Work Path Arbeidsbane + Path to Illustrator… Bane til Illustrator… + Make Work Path Opprett arbeidsbane + Make Selection Opprett utvalg + Fill Path Fyll bane + Stroke Path Strek opp bane + Clipping Path Klippebane + New Path Ny bane + Duplicate Path Dupliser bane + Rename Path… Gi banen nytt navn… + Delete Path Slett bane + Current Path Gjeldende bane + Vector Mask from Current Path Vektormaske fra gjeldende bane + Create new shape from the current path Opprett ny form fra gjeldende bane + Drag to move the active shape's path or the Work Path Dra for å flytte banen til den aktive formen eller arbeidsbanen + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klikk: velg ankerpunkt · Dra: flytt ankerpunkt, håndtak eller segment · {shift}-klikk: legg til · {alt}-klikk: hele delbanen + Draw with the Pen tool (P) or make a work path from a selection. Tegn med pennverktøyet (P) eller lag en arbeidsbane fra et utvalg. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Kilde: ikke angitt ({key}-klikk med klonestempelet) + Clone source {n} ({key}-click with Clone Stamp to set) Klonekilde {n} ({key}-klikk med klonestempelet for å angi) + {key}-click to set the source {key}-klikk for å angi kilden + Option-click to define a source point to clone from Option-klikk for å angi et kildepunkt å klone fra + Show Overlay Vis overlegg + Auto-Hide Skjul automatisk + W B + H H + Angle Vinkel + Scale Skaler + X X + Y Y + Offset Forskyvning + Reset Transform Tilbakestill transformering + Rotate Roter + Set horizontal skew Angi vannrett skråstilling + Set vertical skew Angi loddrett skråstilling + Flip Horizontal Speilvend vannrett + Flip Vertical Speilvend loddrett + Frame Sync Rammesynkronisering + Quick Sync Hurtigsynkronisering + Enabled Aktivert + +# Timeline panel + Create Video Timeline Opprett videotidslinje + Open a document to use the timeline. Åpne et dokument for å bruke tidslinjen. + Video Layers Videolag + Video Video + Audio Lyd + Frame Duration Rammevarighet + 30 frames @ 30 fps 30 bilder @ 30 fps + Loop Løkke + Render Video… Gjengi video… + Split at Playhead Del ved avspillingshodet + Delete Timeline Slett tidslinje + Reload Frame Last inn ramme på nytt + Restore Frame Gjenopprett ramme + Insert Blank Frame Sett inn tom ramme + Duplicate Frame Dupliser ramme + Delete Frame Slett ramme + Restore All Frames Gjenopprett alle rammer + Flatten Frames Into Clips Slå sammen rammer til klipp + Flatten Frames Into Layers Slå sammen rammer til lag + Convert Frames > Video Layers Konverter rammer > videolag + New Video Layer Nytt videolag + New Video Group Ny videogruppe + Interpret Footage… Tolk opptak… + Replace Footage… Erstatt opptak… + Show Altered Video Vis endret video + Limit Playback Begrens avspilling + Enable Timeline Quick Config Aktiver hurtigoppsett for tidslinje + Set Start Timecode… Angi starttidskode… + Timecode Tidskode + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Gjeldende tid + Out Ut + In Inn + Duration Varighet + Repeat Gjenta + Play Once Spill av én gang + Video Cache Videobuffer + +# Info panel + Info Options… Infoalternativer… + First Color Readout Første fargeavlesning + Second Color Readout Andre fargeavlesning + Actual Color Faktisk farge + Grayscale Slider Gråtoneglider + Web Color Webfarge + HSB Color HSB-farge + Total Ink Total fargemengde + Opacity Mask Dekkevnemaske + Ruler Linjal + Current Tool Gjeldende verktøy + Document Size Dokumentstørrelse + Document Profile Dokumentprofil + Document Dimensions Dokumentdimensjoner + Scratch Sizes Arbeidsdiskstørrelser + Efficiency Effektivitet + Timing Tidtaking + Status Information Statusinformasjon + Saved Multichannel Lagret flerkanal + Mouse Coordinates Musekoordinater + Ruler Units Linjalenheter + Hide Skjul + +# Layer Comps panel + Layer Comps to Files… Lagkomposisjoner til filer… + Apply next selected layer comp Bruk neste valgte lagkomposisjon + Apply previous selected layer comp Bruk forrige valgte lagkomposisjon + Update layer comp (visibility, position and appearance) Oppdater lagkomposisjon (synlighet, plassering og utseende) + New Layer Comp Ny lagkomposisjon + Duplicate Layer Comp Dupliser lagkomposisjon + Delete layer comp Slett lagkomposisjon + Rename Layer Comp Gi lagkomposisjonen nytt navn + Create new layer comp Opprett ny lagkomposisjon + Capture the visibility, position and style of every layer with + below, then switch between versions. Registrer synlighet, plassering og stil for hvert lag med + nedenfor, og bytt deretter mellom versjoner. + Apply Layer Comp Bruk lagkomposisjon + Update Layer Comp Oppdater lagkomposisjon + Restore last viewed comp Gjenopprett sist viste komposisjon + Last Document State Siste dokumenttilstand + Visibility Synlighet + Position Posisjon + Appearance (Layer Style) Utseende (lagstil) + +# Analysis and variables + Analysis Analyse + Variables Variabler + Variable Data Sets… Variable datasett… + Data Sets… Datasett… + Data Sets Datasett + Data Sets as Files… Datasett som filer… + Apply Data Set… Bruk datasett… + Define Definer + + Add variable + Legg til variabel + Remove variable Fjern variabel + Define at least one variable first. Definer minst én variabel først. + Open a document to define variables. Åpne et dokument for å definere variabler. + Variables bind a layer's visibility, text or pixels to a named data slot. Variabler knytter et lags synlighet, tekst eller piksler til et navngitt datafelt. + Pixel Replacement Pikselerstatning + Text Replacement Teksterstatning + Name Navn + Method Metode + Select All Layers Velg alle lag + Fill Fyll + Keep proportions Behold proporsjoner + Interpolation Interpolering + Author: Forfatter: + Author Forfatter + Name: Navn: + Caption Bildetekst + Caption Credits Bildetekstkreditering + Description Beskrivelse + Keywords Nøkkelord + Copyright Info URL URL for opphavsrettsinfo + Copyright Status Opphavsrettsstatus + Copyright Notice Opphavsrettsmerknad + Document Title Dokumenttittel + Copyrighted Opphavsrettsbeskyttet + Public Domain Allemannseie + Unknown Ukjent + Copyright and Contact Info Opphavsrett og kontaktinformasjon + Credit Kreditering + Transmission Reference Overføringsreferanse + Urgency Hastegrad + High Høy + Normal Normal + Lossless Tapsfri + Low Lav + Category Kategori + Supplemental Categories Supplerende kategorier + Object Name / Title Objektnavn / tittel + Date Created Opprettet + City By + Location Plassering + Province / State Provins / delstat + Country Land + Byline Byline + Byline Title Byline-tittel + Phone Telefon + Email E-post + URLs URL-er + Last Modified Sist endret + EXIF EXIF + GPS GPS + Camera Data Kameradata + Camera Model Kameramodell + Camera Make Kameramerke + EXIF Version EXIF-versjon + Exposure Program Eksponeringsprogram + Aperture Blenderåpning + Shutter Speed Lukkerhastighet + Metering Mode Målemodus + Light Source Lyskilde + Flash Blits + Lens Info Objektivinfo + ISO Speed ISO-hastighet + Subject Distance Motivavstand + DateTime Original Opprinnelig dato og tid + Create Date Opprettelsesdato + Modify Date Endringsdato + Software Programvare + Raw Data Raw-data + Version {version} Versjon {version} + Advanced Avansert + Import… Importer… + +# Automate + Automate Automatiser + Batch… Satsvis… + Create Droplet… Opprett droplet… + Create Droplet Opprett droplet + PDF Presentation… PDF-presentasjon… + Create Multiple Picture Packages Opprett flere bildepakker + Contact Sheet II Kontaktkopi II + Contact Sheet II… Kontaktkopi II… + Conditional Mode Change… Betinget modusendring… + Fit Image… Tilpass bilde… + Picture Package… Bildepakke… + Web Photo Gallery… Webfotogalleri… + Merge to HDR Pro… Slå sammen til HDR Pro… + Load Files into Stack… Last inn filer i stabel… + Scripts Skript + Script Events Manager… Behandling av skripthendelser… + Image Processor… Bildebehandler… + Destination Mål + Choose… Velg… + Select Folder… Velg mappe… + Override Action "Save" Commands Overstyr «Lagre»-kommandoer i handlinger + Compatibility Kompatibilitet + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Ta med ICC-profil + Error File Feilfil + Stop For Errors Stopp ved feil + Log Errors To File Logg feil til fil + Errors Feil + +# Color Settings dialog + Color Settings… Fargeinnstillinger… + Settings Innstillinger + Settings: Innstillinger: + Working Space Arbeidsfargerom + RGB: RGB: + CMYK: CMYK: + Gray: Grå: + Spot: Spotfarge: + Advanced Controls Avanserte kontroller + Desaturate Monitor Colors By Reduser metningen i skjermfarger med + Blend RGB Colors Using Gamma 1.0 Bland RGB-farger med gamma 1,0 + Profile Mismatches: Ask When Opening Profilavvik: spør ved åpning + Ask When Pasting Spør ved innliming + Missing Profiles: Ask When Opening Manglende profiler: spør ved åpning + Embed profiles Bygg inn profiler + Ask Before Saving Saved Embedded Spør før lagring av lagret innebygd + Coerce to sRGB Tving til 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 + +# Themes + Theme Tema + Switch theme Bytt tema + Next Theme Neste tema + Classic Theme Klassisk tema + Solarized dark Solarized mørk + Solarized Dark Theme Solarized mørkt tema + Breeze dark Breeze mørk + Breeze Dark Theme Breeze mørkt tema + Breeze light Breeze lys + Breeze Light Theme Breeze lyst tema + Soft dark Myk mørk + Soft light Mykt lys + Soft Dark Theme Mykt mørkt tema + Soft Light Theme Mykt lyst tema + Studio Theme Studio-tema + Studio Light Theme Studio lyst tema + Adwaita Light Theme Adwaita lyst tema + Adwaita Dark Theme Adwaita mørkt tema + Pro Theme Pro-tema + Pro Medium Gray Theme Pro mellomgrått tema + Pro Light Gray Theme Pro lysegrått tema + Pro Light Theme Pro lyst tema + +# Workspaces + Workspace… Arbeidsområde… + Essentials Grunnleggende + New Workspace… Nytt arbeidsområde… + Delete Workspace… Slett arbeidsområde… + Reset Workspace Tilbakestill arbeidsområde + Reset Tool Tilbakestill verktøy + Reset All Tools Tilbakestill alle verktøy + Reset all tools to their default settings? Tilbakestille alle verktøy til standardinnstillingene? + Lock Workspace Lås arbeidsområde + Photography Fotografi + Graphic and web Grafikk og web + Painting Maling + Motion Bevegelse + Minimalistic Minimalistisk + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panelplasseringer lagres i arbeidsområdet. Hurtigtaster, menyer og verktøylinje er valgfrie. + Keyboard Shortcuts Hurtigtaster + Keyboard Shortcuts and Menus Hurtigtaster og menyer + Menus Menyer + More panels Flere paneler + More documents Flere dokumenter + Collapse All Groups Fold sammen alle grupper + Expand All Groups Fold ut alle grupper + Collapse Fold sammen + Expand Fold ut + Collapse Panel Group Fold sammen panelgruppe + Expand Panel Group Fold ut panelgruppe + Close Tab Group Lukk fanegruppe + +# New Document dialog + PRESET DETAILS FORVALGSDETALJER + BLANK DOCUMENT PRESETS ({n}) FORVALG FOR TOMT DOKUMENT ({n}) + Create a new document or open an existing file. Opprett et nytt dokument eller åpne en eksisterende fil. + Drop an image or PSD anywhere to open it. Slipp et bilde eller en PSD hvor som helst for å åpne det. + Use File › Open, or paste a copied image with {paste}. Bruk Fil › Åpne, eller lim inn et kopiert bilde med {paste}. + Native file drag-and-drop is unavailable Innebygd dra-og-slipp av filer er ikke tilgjengelig + 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}. Innebygd dra-og-slipp av filer støttes ikke på Wayland ennå. Bruk Fil › Åpne, eller kopier bildet i filbehandleren og lim det inn med {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` For å slippe filer, start PhotoCraft under XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Eller sett Innstillinger › Ytelse › Linux-skjermtjener til X11: da starter PhotoCraft alltid under XWayland. + Edit › Paste Rediger › Lim inn + Photo Foto + Print Skriv ut + Art & Illustration Kunst og illustrasjon + Mobile Mobil + Film & Video Film og video + Default PhotoCraft Size Standard PhotoCraft-størrelse + Clipboard Utklippstavle + Photo (2 × 3 in) Foto (2 × 3 tommer) + Photo (4 × 6 in) Foto (4 × 6 tommer) + Photo (5 × 7 in) Foto (5 × 7 tommer) + Photo (8 × 10 in) Foto (8 × 10 tommer) + Landscape Liggende + Portrait Stående + Recent Items Nylige elementer + Save Preset… Lagre forvalg… + Delete Preset… Slett forvalg… + Preset Name: Navn på forvalg: + Width and Height Bredde og høyde + Background Contents Bakgrunnsinnhold + Custom background color Egendefinert bakgrunnsfarge + White Hvit + Background Color Bakgrunnsfarge + Transparent Gjennomsiktig + Color Mode Fargemodus + Depth Dybde + Color Profile Fargeprofil + Create Opprett + Document Dokument + Units Enheter + Pixels Piksler + Inches Tommer + Centimeters Centimeter + Millimeters Millimeter + Points Punkter + Picas Pica + Columns Kolonner + +# Placeholders, tooltips, status and keyboard hints + {name} options Alternativer for {name} + Align {what} Juster {what} + Untagged {mode} Umerket {mode} + Invalid {mode} profile Ugyldig {mode}-profil + Unnamed {mode} profile Navnløs {mode}-profil + Constraints ({n}) Begrensninger ({n}) + Count: {n} Antall: {n} + Click to add a point · drag off or {key}-click or Delete to remove Klikk for å legge til et punkt · dra bort, {key}-klikk eller Delete for å fjerne + Click to apply, {key}-click to clear overrides, double-click for Style Options Klikk for å bruke, {key}-klikk for å fjerne overstyringer, dobbeltklikk for stilalternativer + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klikk: hjørne · Dra: myk · Klikk på første punkt: lukk · {key} fullfør · Esc avbryt + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klikk: hjørne · Dra: myk · {alt}-dra: bryt håndtak · Klikk eller dra på første punkt: lukk · {key} fullfør · Esc avbryt + Commit Perspective Warp ({key}) Bekreft perspektivfordreining ({key}) + Commit Puppet Warp ({key}) Bekreft marionettfordreining ({key}) + Commit any current edits ({key}) Bekreft pågående redigering ({key}) + Commit transform ({key}) Bekreft transformering ({key}) + Commit warp ({key}) Bekreft fordreining ({key}) + Cancel any current edits (Esc) Avbryt pågående redigering (Esc) + Cancel transform (Esc) Avbryt transformering (Esc) + Cancel warp (Esc) Avbryt fordreining (Esc) + Do you want to save the changes you made to “{name}” before closing? Vil du lagre endringene du gjorde i «{name}» før du lukker? + 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 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 en begrensning for å endre eller slette den. + Drag the arrow to set the angle, the dots to set the roundness Dra pilen for å angi vinkelen, prikkene for å angi rundheten + Create new pattern from the selection Opprett nytt mønster fra utvalget + Create new gradient Opprett ny gradient + Create new count group Opprett ny tellegruppe + Search commands, tools and panels… Søk etter kommandoer, verktøy og paneler… + Search Brushes Søk i pensler + Search fonts Søk etter skrifter + No matching commands Ingen samsvarende kommandoer + No document Ikke noe dokument + Pixel Layer Piksellag + Type Layer Tekstlag + Shape Layer Formlag + Group Layer Gruppelag + Fill Layer Fyllag + Smart Object Layer Smartobjektlag + Adjustment Layer Justeringslag + {name} Properties Egenskaper for {name} + 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. + Rectangular Marquee Tool Rektangulær markeringsramme + Elliptical Marquee Tool Elliptisk markeringsramme + Single Row Marquee Tool Markeringsramme for enkeltrad + Single Column Marquee Tool Markeringsramme for enkeltkolonne + Brush Tool Penselverktøy + Pencil Tool Blyantverktøy + Eraser Tool Viskelærverktøy + Background Eraser Tool Bakgrunnsviskelær + Magic Eraser Tool Magisk viskelær + Eyedropper Tool Pipetteverktøy + Note Tool Notatverktøy + Lasso Tool Lassoverktøy + Polygonal Lasso Tool Polygonlasso + Magic Wand Tool Tryllestav + Crop Tool Beskjæringsverktøy + Slice Tool Segmentverktøy + Slice Select Tool Segmentvalgverktøy + Gradient Tool Gradientverktøy + Paint Bucket Tool Malingsspann + Horizontal Type Tool Vannrett tekstverktøy + Horizontal Type Mask Tool Vannrett tekstmaskeverktøy + Vertical Type Mask Tool Loddrett tekstmaskeverktøy + Hand Tool Håndverktøy + Zoom Tool Zoomverktøy + Spot Healing Brush Tool Punktreparasjonspensel + Healing Brush Tool Reparasjonspensel + Clone Stamp Tool Klonestempel + History Brush Tool Historikkpensel + Blur Tool Sløringsverktøy + Sharpen Tool Skjerpeverktøy + Smudge Tool Utgnidningsverktøy + Dodge Tool Avskjermingsverktøy + Burn Tool Etterbelysningsverktøy + Sponge Tool Svampverktøy + Quick Selection Tool Hurtigvalgverktøy + Object Selection Tool Objektvalgverktøy + Pen Tool Pennverktøy + Convert Point Tool Verktøy for å konvertere punkt + Path Selection Tool Banevalgverktøy + Direct Selection Tool Direktevalgverktøy + Add Anchor Point Tool Verktøy for å legge til ankerpunkt + Delete Anchor Point Tool Verktøy for å slette ankerpunkt + Click a segment to add an anchor point · {alt}-click an anchor to delete it Klikk på et segment for å legge til et ankerpunkt · {alt}-klikk på et ankerpunkt for å slette det + Click an anchor point to delete it · {alt}-click a segment to add one Klikk på et ankerpunkt for å slette det · {alt}-klikk på et segment for å legge til ett + Rectangle Tool Rektangelverktøy + Ellipse Tool Ellipseverktøy + Triangle Tool Trekantverktøy + Polygon Tool Polygonverktøy + Line Tool Linjeverktøy + Custom Shape Tool Verktøy for egendefinert form + Pixel Piksel + Group Gruppe + Adjustment Justering + Smart Object Smartobjekt + 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 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 + {mode} Color · {bits} bit {mode}-farge · {bits} biter + Drag to move the layer Dra for å flytte laget + Fit Screen Tilpass til skjerm + Fill Screen Fyll skjermen + Scrubby Zoom Skrubbezoom + Auto Erase Automatisk visking + Color Adaptation Fargetilpasning + Preserve Transparency Bevar gjennomsiktighet + Reverse Snu rekkefølgen + Align with layer Juster etter lag + Join us on Discord Bli med oss på Discord + PhotoCraft website PhotoCrafts nettsted + ArtCraft ArtCraft + GitHub GitHub + No character style Ingen tegnstil + No author Ingen forfatter + Not in Dictionary: Ikke i ordlisten: + Suggestions: Forslag: + Spell check complete. Stavekontrollen er fullført. + Check All Layers Kontroller alle lag + Select a type layer to edit its character and paragraph settings. Velg et tekstlag for å redigere tegn- og avsnittsinnstillingene. + Select text to apply a character style Velg tekst for å bruke en tegnstil + Redefine style by current selection Omdefiner stilen etter gjeldende utvalg + Duplicate style Dupliser stil + Delete style Slett stil + Style Options Stilalternativer + Style Options… Stilalternativer… + Close Style Options Lukk stilalternativer + Appearance Utseende + Text Position: Tekstplassering: + Text color Tekstfarge + Uses the foreground and background colours. 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 + No Nei + Yes Ja + These settings aren't available in PhotoCraft yet. Disse innstillingene er ikke tilgjengelige i PhotoCraft ennå. + Shortcut removed. Hurtigtasten er fjernet. + All shortcuts reset to PhotoCraft defaults. Alle hurtigtaster er tilbakestilt til PhotoCraft-standard. + Add Shortcut Legg til hurtigtast + Delete Shortcut Slett hurtigtast + Press keys… Trykk på taster… + shortcut hurtigtast + command or shortcut kommando eller hurtigtast + current gjeldende + image file path bildefilbane + modified endret + 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. + Saved Lagret + Reveal in Finder Vis i Finder + Show in Explorer Vis i Utforsker + Show in Folder Vis i mappe + Before (Y) Før (Y) + Save Settings… Lagre innstillinger… + Load Settings… Last inn innstillinger… + Save these settings as a Camera Raw .xmp file Lagre disse innstillingene som en Camera Raw .xmp-fil + Load settings from a Camera Raw .xmp file Last inn innstillinger fra en Camera Raw .xmp-fil + Loaded Camera Raw settings from {file} Lastet inn Camera Raw-innstillinger fra {file} + Loaded Camera Raw settings from {file} ({n} not supported) Lastet inn Camera Raw-innstillinger fra {file} ({n} støttes ikke) + After (X) Etter (X) + Original Original + Current Gjeldende + {added}, {removed} subtracted {added}, {removed} trukket fra + Filter: Filter: + +# Transform / warp overlays + Free Transform Fri transformering + Free Transform a Copy Fri transformering av en kopi + Transform Transformer + Transform Selection Transformer utvalg + Switch between free transform and warp modes Bytt mellom fri transformering og fordreining + Perspective Perspektiv + Skew Skråstill + Distort Forvreng + Rotate: Roter: + Flip Speilvend + Warp: Fordreining: + Warp Fordrei + Bend: Bøy: + Reference point location Plassering av referansepunkt + Toggle reference point Slå av/på referansepunkt + Free Fri + Arc Bue + Arch Hvelving + Bulge Utbuling + Shell Lower Nedre skall + Shell Upper Øvre skall + Flag Flagg + Fish Fisk + Wave Bølge + Rise Stigning + Fisheye Fiskeøye + Inflate Blås opp + Twist Vri + Squeeze Klem + Corner radius Hjørneradius + Split Warp Crosswise Del fordreining på kryss + Split Warp Horizontally Del fordreining vannrett + Split Warp Vertically Del fordreining loddrett + Remove Warp Split Fjern fordreiningsdeling + Puppet Warp Marionettfordreining + Puppet Warp is not active Marionettfordreining er ikke aktiv + Perspective Warp Perspektivfordreining + Perspective Warp is not active Perspektivfordreining er ikke aktiv + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivfordreining: tegn plan langs bildets perspektiv, og bytt deretter til Fordrei + Remove Planes Fjern plan + Edit Pins Rediger nåler + Add Pins Legg til nåler + Delete Pins Slett nåler + Remove All Pins Fjern alle nåler + Remove Tool Fjerneverktøy + Pin Depth: Nåldybde: + Bring the selected pin forward Flytt den valgte nålen fremover + Send the selected pin backward Flytt den valgte nålen bakover + Rotate Auto Roter automatisk + Rotate Fixed Roter fast + Adaptive Wide Angle isn't open Adaptiv vidvinkel er ikke åpen + Liquify is not open Gjør flytende er ikke åpen + +# Vanishing Point + Create Plane Opprett plan + Edit Plane Rediger plan + Select Plane Velg plan + Measure Mål + Heal Reparer + Off Av + Symmetry Off Symmetri av + Heal Off Reparering av + Heal On Reparering på + Heal Auto Reparer automatisk + Blend Bland + Brush Size Penselstørrelse + Heal Color Reparer farge + +# Misc dialog strings + Mask Maske + Channel Kanal + Rectangle Rektangel + Subtract Trekk fra + Multiply Multipliser + Divide Del + Result Resultat + New Document Nytt dokument + New Document With Mask Nytt dokument med maske + New Layer With Mask Nytt lag med maske + New Layer Nytt lag + Preset Name Forvalgsnavn + No saved presets yet. Ingen lagrede forvalg ennå. + New Channel Ny kanal + Selection Utvalg + Save Selection Lagre utvalg + Load Selection Last inn utvalg + Duplicate Dupliser + Fast Rask + Accurate Nøyaktig + Add to Legg til i + Subtract from Trekk fra + Intersect with Skjær med + +# Blend modes + Pass Through Gjennomgående + Dissolve Oppløs + Darken Mørkere + Color Burn Fargebrenning + Linear Burn Lineær brenning + Darker Color Mørkere farge + Lighten Lysere + Screen Skjerm + Color Dodge Fargeavskjerming + Linear Dodge (Add) Lineær avskjerming (legg til) + Lighter Color Lysere farge + Overlay Overlegg + Soft Light Mykt lys + Hard Light Hardt lys + Vivid Light Skarpt lys + Linear Light Lineært lys + Pin Light Punktlys + Hard Mix Hard blanding + Difference Differanse + Exclusion Utelukkelse + Luminosity Luminositet + +# Saved views and other bits + Capture Comp Ta opp komposisjon + No Layer Comps Ingen lagkomposisjoner + +# Plurals +@plural {n} item|{n} items {n} element|{n} elementer +@plural {n} Layer|{n} Layers {n} lag|{n} lag +@plural {n} layer|{n} layers {n} lag|{n} lag +@plural Group · {n} layer|Group · {n} layers Gruppe · {n} lag|Gruppe · {n} lag +@plural {n} sample|{n} samples {n} prøve|{n} prøver + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… Alternativer for 32-biters forhåndsvisning… + 90° Clockwise 90° med klokken + 90° Counter Clockwise 90° mot klokken + AI Integrations… KI-integrasjoner… + Again Igjen + Transform Again on a Copy Transformer igjen på en kopi + Anamorphic 2:1 (2) Anamorf 2:1 (2) + Arbitrary… Vilkårlig… + Artboard Guides Hjelpelinjer for tegneflate + Artboard from Group… Tegneflate fra gruppe… + Artboard from Layers… Tegneflate fra lag… + Artboards to Files… Tegneflater til filer… + Artboards to PDF… Tegneflater til PDF… + Artboard… Tegneflate… + Border… Kant… + Bottom Edges Nedre kanter + Browse… Bla gjennom… + Source Files Kildefiler + Use: Bruk: + Files Filer + Folder Mappe + Add Open Files Legg til åpne filer + Sort By Name Sorter etter navn + Attempt to Automatically Align Source Images Prøv å justere kildebildene automatisk + Create Smart Object after Loading Layers Opprett smartobjekt etter innlasting av lag + Canvas Guides Lerrethjelpelinjer + 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 Table… Fargetabell… + Combine Shapes Kombiner former + Contract… Trekk sammen… + Convert to Embedded Konverter til innebygd + Convert to Layers Konverter til lag + Convert to Linked… Konverter til koblet… + Count Telling + Create Layer Opprett lag + Custom Pixel Aspect Ratio… Egendefinert pikselforhold… + Cycle Screen Mode Bytt skjermmodus + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC bredformat (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL bredformat (1,46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + De-Interlace… Fjern linjesprang… + Default Features Standardfunksjoner + Delete All Empty Layers Slett alle tomme lag + Desaturate Fjern metning + Displace… Forskyv… + Distribute Fordel + Document Bounds Dokumentgrenser + Edit in Quick Mask Mode Rediger i hurtigmaskemodus + Enhanced Controls… Utvidede kontroller… + Entropy Entropi + Exclude Overlapping Shapes Utelat overlappende former + Exit Avslutt + Expand… Utvid… + Extra Large Ekstra stor + File Info… Filinformasjon… + Fit Artboard on Screen Tilpass tegneflate til skjermen + Fit Layer(s) on Screen Tilpass lag til skjermen + Flatten All Layer Effects Slå sammen alle lageffekter + Flatten All Masks Slå sammen alle masker + Flatten Image Slå sammen bildet + Float All in Windows Alle flytende i vinduer + Float in Window Flytende i vindu + Frame from Layers Ramme fra lag + From Transparency Fra gjennomsiktighet + Gamut Warning Fargeomfangsadvarsel + Global Light… Globalt lys… + Gradient… Gradient… + Graphic and Web Grafikk og web + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Hidden Layers Skjulte lag + Hide All Effects Skjul alle effekter + Horizontal Centers Vannrette sentre + Huge Enorm + Indexed Color… Indeksert farge… + Integrations… Integrasjoner… + Intersect Shape Areas Snitt av formområder + Kurtosis Kurtose + Large Stor + Layer Edges Lagkanter + Left Edges Venstre kanter + Lens Blur… Objektivsløring… + Lighting Effects… Lyseffekter… + Link Layers Koble lag + Lock Layers… Lås lag… + Mean Gjennomsnitt + Merge Down Slå sammen nedover + Merge Group Slå sammen gruppe + Merge Layers Slå sammen lag + Merge Shape Components Slå sammen formkomponenter + Merge Visible Slå sammen synlige + Stamp Visible Stemple synlige + Select Layer Above Velg laget over + Select Layer Below Velg laget under + Select Top Layer Velg øverste lag + Select Bottom Layer Velg nederste lag + Add Layer Above to Selection Legg laget over til utvalget + Add Layer Below to Selection Legg laget under til utvalget + Stamp Down Stemple nedover + Mesh Nett + Middle Eastern & South Asian Composer Midtøstlig og sørasiatisk tekstsetter + Modifier Keys Modifikasjonstaster + Modify Endre + NTSC Colors NTSC-farger + Notes… Notater… + Other Annet + Other… Annet… + Paragraph Styles Panel Avsnittsstilpanel + Paths to Illustrator… Baner til Illustrator… + Pattern Preview Mønsterforhåndsvisning + Pattern… Mønster… + Pixel Art Pikselkunst + Pixel Art Preview Forhåndsvisning av pikselkunst + Pixel Grid Pikselrutenett + Pixelate Pikseler + Place Scale Marker… Plasser skalamarkør… + Plug-ins Programtillegg + Print One Copy Skriv ut ett eksemplar + Print… Skriv ut… + Range Område + Raw Defaults… Raw-standarder… + Relink to File… Koble til fil på nytt… + Relight… Nytt lys… + Render Gjengi + Replace All Missing Fonts Erstatt alle manglende skrifter + Resolve Missing Fonts… Løs manglende skrifter… + Reveal Selection Vis utvalg + Right Edges Høyre kanter + Selection Edges Utvalgskanter + Shapes Former + Show Extras Options… Alternativer for Vis ekstra… + Skewness Skjevhet + Small Liten + Smart Guides Smarte hjelpelinjer + Smart Objects Smartobjekter + Smooth… Jevn ut… + Solid Color… Ensfarget… + Square Kvadratisk + Stack Mode Stabelmodus + Choose Stack Mode: Velg stabelmodus: + Standard Deviation Standardavvik + Statistics… Statistikk… + Stylistic Alternates Stilistiske alternativer + Stylize Stiliser + Subtract Front Shape Trekk fra fremste form + Summation Summering + Target Path Målbane + Tile All Horizontally Alle side om side vannrett + Tile All Vertically Alle side om side loddrett + Toggle Last State Veksle siste tilstand + Toolbar… Verktøylinje… + Tools… Verktøy… + Top Edges Øvre kanter + Unite Shapes Foren former + Update All Modified Content Oppdater alt endret innhold + Update All Text Layers Oppdater alle tekstlag + Update Modified Content Oppdater endret innhold + Variance Varians + Vertical Centers Loddrette sentre + Video Frames to Layers… Videorammer til lag… + WIA Support… WIA-støtte… + Working CMY Plate Arbeidsplate for CMY + +# tl! literals (third pass) + Absolute Absolutt + Action Handling + Adaptive Adaptiv + Add Stop Legg til stopp + Add disk Legg til disk + Align and Distribute Juster og fordel + All Slices Alle segmenter + All User Slices Alle brukersegmenter + Alt Alt + Artboard Tegneflate + Artboard Background Color Bakgrunnsfarge for tegneflate + Auto Hide Skjul automatisk + Automatically level near-horizontal line segments Rett opp nesten vannrette linjestykker automatisk + Automatically straighten and level Rett opp og nivellér automatisk + Automatically straighten near-vertical line segments Rett opp nesten loddrette linjestykker automatisk + Balance Balanse + Baseline Grunnlinje + Bicubic Bikubisk + Bicubic (smooth gradients) Bikubisk (jevne overganger) + Automatic Automatisk + Bicubic Smoother (enlargement) Bikubisk jevnere (forstørrelse) + Bicubic Sharper (reduction) Bikubisk skarpere (forminskning) + Bilinear Bilineær + Black, White Svart, hvit + Blue, Red, Yellow Blå, rød, gul + Bottom Bunn + Brightness Jitter Lysstyrkevariasjon + Brush Reconstruct Options Alternativer for penselrekonstruksjon + Cache Level: Buffernivå: + Canvas Lerret + Capture Opptak + Center Crop Marks Sentrerte beskjæringsmerker + Centre 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 + Clear Override Fjern overstyring + Clear Samples Fjern prøver + Cmd Cmd + Color Grading Fargegradering + Color Management Fargestyring + Color Priority Fargeprioritet + Color Range preview Forhåndsvisning av fargeområde + Colorize Fargelegg + Constant Konstant + Content-Aware Innholdsbasert + Convert to sRGB Konverter til sRGB + Copies Eksemplarer + Copper Kobber + Corner Crop Marks Beskjæringsmerker i hjørnene + Correction Korreksjon + Count Jitter Antallsvariasjon + Create Texture Opprett tekstur + Create a new count group Opprett en ny tellegruppe + Crop Factor Beskjæringsfaktor + Ctrl Ctrl + Custom Shapes Egendefinerte former + Cyan Cyan + Cyan · Red Cyan · Rød + Darks Mørke toner + Default printer Standardskriver + Default restored. Standard gjenopprettet. + Delete Group Slett gruppe + Delete count group Slett tellegruppe + Delete effect layer Slett effektlag + Delete note Slett notat + Delete tool preset Slett verktøyforvalg + Depth Jitter Dybdevariasjon + Discontiguous Ikke sammenhengende + Discord Discord + Display P3 Display P3 + Display Text Vis tekst + Divide… Del… + Do you want to save the changes you made to “{name}” before quitting? Vil du lagre endringene du gjorde i «{name}» før du avslutter? + Edit Toolbar… Rediger verktøylinje… + Edit in Quick Mask Mode (Q) Rediger i hurtigmaskemodus (Q) + Edit in Standard Mode (Q) Rediger i standardmodus (Q) + Embedded Profile Mismatch Innebygd profil samsvarer ikke + Enhance Edge Forsterk kant + Exact Nøyaktig + Expansion: Utvidelse: + Exposure and Gamma Eksponering og gamma + Factor: Faktor: + Fewer Points Færre punkter + File Settings Filinnstillinger + Finger Painting Fingermaling + Fit in View Tilpass til visning + Fixed Fast + Foreground to Background Forgrunn til bakgrunn + Foreground/Background Jitter Forgrunn/bakgrunn-variasjon + Format Format + Frame from Layers… Ramme fra lag… + Full Spherical Fullt sfærisk + Gamma Correction Gammakorreksjon + Gamma: Gamma: + Global Global + Grain Korn + Graphics Grafikk + Gray Grå + Heart Hjerte + Hide Auto Slices Skjul automatiske segmenter + Highlight Compression Høylyskomprimering + Highlight Priority Høylysprioritet + Highlights Høylys + Home Hjem + Hue Jitter Fargetonevariasjon + Image Size: Bildestørrelse: + Imported Importert + Input: Inndata: + Interlaced Linjesprang + Invert All Inverter alle + Label Size Etikettstørrelse + Labels Etiketter + Lanczos (sharp) Lanczos (skarp) + Level Nivå + Lights Lyse toner + Limits: Grenser: + Link the mask to the layer Koble masken til laget + Localized Color Clusters Lokaliserte fargeklynger + Luminance Luminans + Magenta Magenta + Magenta · Green Magenta · Grønn + Marker Size Markørstørrelse + Mask Options Maskealternativer + Masking Maskering + Master Hoved + Mean: Gjennomsnitt: + Median: Median: + Mesh Size Nettstørrelse + Metadata Metadata + Midpoint Midtpunkt + Midtones Mellomtoner + Midtones from: Mellomtoner fra: + Midtones to: Mellomtoner til: + Minimum Depth Minste dybde + Mix Jitter Blandingsvariasjon + Mode: Modus: + Monochrome Monokrom + More Points Flere punkter + Move Group Down Flytt gruppen ned + Move Group Up Flytt gruppen opp + Move effect layer down Flytt effektlaget ned + Move effect layer up Flytt effektlaget opp + Move to Flytt til + Nearest Neighbor Nærmeste nabo + Nearest Neighbor (hard edges) Nærmeste nabo (harde kanter) + New effect layer Nytt effektlag + Next note Neste notat + No Color Management Ingen fargestyring + Optimized Optimalisert + Output Levels: Utdatanivåer: + Output: Utdata: + Paint Overlay Malingsoverlegg + Pattern Size Mønsterstørrelse + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft: et bilderedigeringsprogram med åpen kildekode, skrevet i Rust. + Position and Size Posisjon og størrelse + Preserve Details Bevar detaljer + Preserve Luminosity Bevar luminositet + Previous note Forrige notat + Printer Skriver + Printer Setup Skriveroppsett + Printing Marks Utskriftsmerker + Progressive Progressiv + Promote Forfrem + Protect Detail Beskytt detaljer + Protect Tones Beskytt toner + Proximity Match Nærhetstilpasning + Purity Renhet + Quality Kvalitet + Recent Nylig + Reconstruct Rekonstruer + Refine Forbedre + Registration Marks Passmerker + Regular Normal + Remove stop Fjern stopp + Rename Group… Gi gruppen nytt navn… + Rename Layer… Gi laget nytt navn… + Restore All Gjenopprett alle + Restrictive (Web) Begrenset (web) + Rigid Stiv + Roughness Ruhet + Roundness Rundhet + Rulers & Grids Linjaler og rutenett + Sample: foreground colour Prøve: forgrunnsfarge + Sampling: Background Swatch Prøvetaking: bakgrunnsfargeprøve + Sampling: Continuous Prøvetaking: kontinuerlig + Sampling: Once Prøvetaking: én gang + Saturate Øk metning + Saturation Jitter Metningsvariasjon + Save HTML and Images Lagre HTML og bilder + Save as PDF Lagre som PDF + Scale to Fit Media Skaler for å tilpasse til mediet + Scatter Spredning + Scratch disks Arbeidsdisker + Selective Selektiv + Shadows Skygger + Shape: Form: + Sharpening Skjerping + Shift Shift + Show All Menu Items Vis alle menyelementer + Show Backdrop Vis bakgrunn + Show Mask Vis maske + Show Mesh Vis nett + Show or hide the Notes panel Vis eller skjul notatpanelet + Show the layer mask as an overlay Vis lagmasken som et overlegg + Show: Vis: + Sides Sider + Sides: Sider: + Slice Options… Segmentalternativer… + Slices From Guides Segmenter fra hjelpelinjer + Slices are locked (View › Lock Slices) Segmentene er låst (Vis › Lås segmenter) + Split: Deling: + Star ratio Stjerneforhold + Std Dev: Std.avvik: + Straighten Rett opp + Straighten Layer Rett opp lag + Strength: Styrke: + Stroke Strek + Stroke Catch-up Strøkfullføring + Stroke: Strek: + Location: Plassering: + Inside Innenfor + Outside Utenfor + Tetrahedral Tetraedrisk + Texture Each Tip Tekstur per spiss +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradientredigering + Gradient picker Gradientvelger + Save As Preset Lagre som forvalg + The gradient the Gradient tool paints with Gradienten som gradientverktøyet maler med + Adds the current gradient to the Gradients panel Legger gjeldende gradient til i gradientpanelet + Gradient stops Gradientstopp + Click to edit the gradient Klikk for å redigere gradienten + Tilt X Helling X + Tilt Y Helling Y + Toggle count group visibility Veksle synlighet for tellegruppe + Toggle text orientation Veksle tekstretning + Tone: Tone: + Toolbar Verktøylinje + Top Topp + Total: Totalt: + Transparency Gjennomsiktighet + Trilinear Trilineær + Unlink the mask from the layer Koble masken fra laget + View Options Visningsalternativer + Violet, Orange Fiolett, oransje + Visible Synlig + Weight Tykkelse + Weight: Tykkelse: + Wetness Jitter Våthetsvariasjon + Whites Hvittoner + Yellow Gul + Yellow · Blue Gul · Blå + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Tillat agentstyring + Allow scripts to connect Tillat at skript kobler til + Always Alltid + Always create smart objects when placing Opprett alltid smartobjekter ved plassering + Animated zoom Animert zoom + Apply auto tone Bruk autotone + Ask Spør + Ask before saving layered tiff Spør før lagring av TIFF med lag + Autosave Autolagring + Autosave minutes Minutter mellom autolagring + Beep when done Pip når ferdig + Bicubic automatic Bikubisk automatisk + Bicubic sharper Bikubisk skarpere + Bicubic smoother Bikubisk jevnere + Bit depth Bitdybde + Brush picker Penselvelger + Brush preview color Farge for penselforhåndsvisning + CPU (no GPU acceleration) CPU (ingen GPU-akselerasjon) + Cache levels Buffernivåer + Cache tile size Bufferflisstørrelse + Camera Raw Defaults Camera Raw-standarder + Canvas border Lerretkant + Show canvas dots Vis lerretprikker + Clip thumbnails to layer bounds Klipp miniatyrer til laggrensene + Clip Thumbnails to Layer Bounds Klipp miniatyrer til laggrensene + Canvas color Lerretfarge + Canvas custom color Egendefinert lerretfarge + Classic Klassisk + Color picker Fargevelger + Color space Fargerom + Column width Kolonnebredde + Concise Kortfattet + Control port Kontrollport + Convert to srgb Konverter til sRGB + Cursors Markører + Custom dark Egendefinert mørk + Custom light Egendefinert lys + Dark Mørk + Dark gray Mørkegrå + Dashed lines Stiplede linjer + Detailed Detaljert + DirectX 12 DirectX 12 + Disks Disker + Display p3 Display P3 + Dots Prikker + Double click layer mask launches select and mask Dobbeltklikk på lagmaske åpner Velg og masker + Drop shadow Slagskygge + Dynamic color sliders Dynamiske fargeglidebrytere + East asian Østasiatisk + Effect cache mb Effektbuffer (MB) + Enable flick panning Aktiver kastpanorering + Enable floating document window docking Aktiver dokking av flytende dokumentvinduer + Enable narrow options bar Aktiver smal alternativlinje + Enhanced Controls Utvidede kontroller + Erase Visk ut + Export clipboard Eksporter utklippstavle + Extra large Ekstra stor + File Handling Filhåndtering + File path Filbane + Fill new type layers with placeholder Fyll nye tekstlag med plassholdertekst + Font preview Skriftforhåndsvisning + Full size tip Spiss i full størrelse + GPU backend GPU-motor + Linux display server Linux-skjermtjener + X11 X11 + Gamut warning color Farge for fargeomfangsadvarsel + Gamut warning opacity Dekkevne for fargeomfangsadvarsel + General Generelt + Enable Generator Aktiver Generator + Gif GIF + Glossy Blank + Grid color Rutenettfarge + Grid colors Rutenettfarger + Grid size Rutenettstørrelse + Grid style Rutenettstil + Grid unit Rutenettenhet + Gridline every Rutenettlinje for hver + Guide color Hjelpelinjefarge + Guide style Hjelpelinjestil + Guides, Grid & Slices Hjelpelinjer, rutenett og segmenter + Gutter Spaltemellomrom + History Log Historikklogg + History states Historikktilstander + Ignore exif profile tag Ignorer EXIF-profilmerke + Image interpolation Bildeinterpolering + Image previews Bildeforhåndsvisninger + Integrations Integrasjoner + Interface Grensesnitt + Jpeg quality JPEG-kvalitet + Jpg JPG + Large tabs Store faner + Legacy compositing Eldre komposisjon + Light gray Lysegrå + Line Linje + Lines Linjer + Lowercase extension Filetternavn med små bokstaver + Maximize psd compatibility Maksimer PSD-kompatibilitet + Medium gray Mellomgrå + Memory usage mb Minnebruk (MB) + Low resolution previews Forhåndsvisninger i lav oppløsning + Metal Metal + Missing glyph protection Beskyttelse mot manglende glyfer + Modern Moderne + Nearest neighbor Nærmeste nabo + Never Aldri + Normal tip Normal spiss + Open as smart object Åpne som smartobjekt + Open in camera raw Åpne i Camera Raw + Open documents as tabs Åpne dokumenter som faner + OpenGL OpenGL + Orange Oransje + Overscroll Overrulling + Performance Ytelse + Png PNG + Point size Punktstørrelse + PostScript (72 points/inch) PostScript (72 punkter/tomme) + Precise Presis + Preserve details Bevar detaljer + Print resolution Utskriftsoppløsning + Pro Pro + Pro light Pro lys + Pro light gray Pro lysegrå + Pro medium Pro mellom + Pro photo ProPhoto + Purple Lilla + Quick export format Format for hurtigeksport + Quick export location Plassering for hurtigeksport + Recent file count Antall nylig brukte filer + LUT folder LUT-mappe + Recent files Nylig brukte filer + Recent fonts Nylig brukte skrifter + Recover on launch Gjenopprett ved oppstart + Remember workspace changes Husk endringer i arbeidsområdet + Resize image during place Endre bildestørrelse ved plassering + Right click with painting tools Høyreklikk med maleverktøy + Rotate view with trackpad Roter visningen med styreflaten + Same folder Samme mappe + Save in background Lagre i bakgrunnen + Scratch Disks Arbeidsdisker + Screen resolution Skjermoppløsning + Scrubby slider acceleration Akselerasjon for skrubbeglidebryter + Sessions only Bare økter + Sharpen for Skjerp for + Show channels in color Vis kanaler i farger + Show crosshair in brush tip Vis trådkors i penselspissen + Show extension panels Vis utvidelsespaneler + Show font names in english Vis skriftnavn på engelsk + Show menu colors Vis menyfarger + 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 + Show transformation values Vis transformeringsverdier + Slice color Segmentfarge + Smart guide color Farge for smarte hjelpelinjer + Smart quotes Typografiske anførselstegn + Snap vector tools and transforms to pixel grid Fest vektorverktøy og transformeringer til pikselrutenettet + Srgb sRGB + Standard Standard + Studio Studio + Studio light Studio lys + Adwaita Adwaita + Adwaita dark Adwaita mørk + Subdivisions Underinndelinger + System System + Text engine Tekstmotor + Text file Tekstfil + Tiny Bitteliten + Touch gestures Berøringsbevegelser + Traditional (72.27 points/inch) Tradisjonell (72,27 punkter/tomme) + Transparency & Gamut Gjennomsiktighet og fargeomfang + Type units Tekstenheter + UI font size Skriftstørrelse i grensesnittet + UI scale Grensesnittskalering + Units & Rulers Enheter og linjaler + Use additional plugins folder Bruk ekstra mappe for programtillegg + Use esc to commit Bruk Esc for å bekrefte + Use gpu Bruk GPU + Use legacy free transform Bruk eldre fri transformering + Use shift key for tool switch Bruk Shift-tasten for verktøybytte + Use tablet pressure Bruk trykk fra tegnebrett + Vary round brush hardness on hud Varier hardheten til runde pensler i HUD + Vulkan Vulkan + Webp WebP + World ready Verdensklar + Zoom clicked point to center Zoom klikket punkt til midten + Zoom resizes windows Zoom endrer vindusstørrelse + Zoom with scroll wheel Zoom med rullehjulet + Zoom with trackpad pinch Zoom med knip på styreflaten + 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} + 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? Enkelte lag, piksler eller masker kunne ikke leses. Vil du fortsette å åpne det? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Hvis du fortsetter, kan lagring over originalen permanent forkaste uleselige data. Lagre en kopi for å beholde originalen. + Continue Fortsett + The document “{name}” does not have an embedded {mode} profile. Dokumentet «{name}» har ingen innebygd {mode}-profil. + 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} + Esc to cancel Esc for å avbryte + Opened {name} Åpnet {name} + Opening {name} Åpner {name} + Saved {name} Lagret {name} + 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 + Opening {name}… Åpner {name}… + Opening… Åpner… + Reading… Leser… + Working… Arbeider… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Rutenett: +# Rendering mode and recovery + Rendering Mode Gjengivelsesmodus + Automatic (recommended) Automatisk (anbefalt) + CPU / Compatibility CPU / kompatibilitet + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatisk bruker GPU-akselerasjon når det er tilgjengelig, og faller tilbake til CPU-gjengivelse ved feil. + Switched to CPU rendering Byttet til CPU-gjengivelse + GPU acceleration could not continue. Your documents are unchanged. GPU-akselerasjonen kunne ikke fortsette. Dokumentene dine er uendret. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft bruker CPU-bildekomposisjon. Vinduet kan fortsatt bruke grafikkortet ditt. + Retry GPU requires a restart. Save your work first. Prøv GPU på nytt krever omstart. Lagre arbeidet ditt først. + Retry GPU tries the graphics processor again now. Prøv GPU på nytt prøver grafikkprosessoren igjen nå. + GPU acceleration resumed. GPU-akselerasjon gjenopptatt. + Keep Using CPU Fortsett med CPU + Retry GPU Prøv GPU på nytt + GPU Backend GPU-motor + Details Detaljer + GPU GPU + Rendering mode Gjengivelsesmodus + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Fryselasso: dra for å fryse et område, Alt-dra for å tine det (L) + Shadow Clipping Warning (U) Advarsel for skyggeklipping (U) + Highlight Clipping Warning (O) Advarsel for høylysklipping (O) + Show Lab Color Readouts Vis Lab-fargeverdier + Show Vectorscope Vis vektorskop + Show Red at 3 o'clock Vis rød kl. 3 + Show Skin Tone Indicator Vis hudtoneindikator + Tone Histogram Tonehistogram + Show Selected Region Vis valgt område +cameraRaw Light Lys +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Akvamarin +cameraRaw Amount Mengde +cameraRaw Blacks Svarttoner +cameraRaw Vibrance Vibrans +cameraRaw Color Grading Fargegradering +cameraRaw Lights Lyse toner +cameraRaw Darks Mørke toner +cameraRaw Global Global +cameraRaw Red Rød +cameraRaw Orange Oransje +cameraRaw Yellow Gul +cameraRaw Green Grønn +cameraRaw Aqua Akvamarin +cameraRaw Blue Blå +cameraRaw Purple Lilla +cameraRaw Magenta Magenta +cameraRaw Hue Fargetone +cameraRaw Blending Blanding +cameraRaw Luminance Detail Luminansdetalj +cameraRaw Color Detail Fargedetalj +cameraRaw Roughness Ruhet + Patch Tool Lappeverktøy + Patch: Lapp: + Lasso around an area, then drag the selection Lasso rundt et område, og dra deretter utvalget + Transform On Drop Transformer ved slipp + Scale or rotate the moved area, then press {key} Skaler eller roter det flyttede området, og trykk deretter {key} + Now drag the selection onto another area Dra nå utvalget til et annet område + Edit Type Rediger tekst + + New Style… Ny stil… + Style {name} saved Stilen {name} er lagret + + Create new style (from the selected type) Opprett ny stil (fra den valgte teksten) + Create new style from the selected layer Opprett ny stil fra det valgte laget + + Make Default Gjør til standard + Reset to Default Tilbakestill til standard + Full-resolution preview unavailable Forhåndsvisning i full oppløsning er utilgjengelig + No patterns Ingen mønstre + Enable {name} Aktiver {name} + Hold Alt while drawing for straight segments Hold Alt under tegning for rette segmenter + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Dra eller klikk polygonpunkter · hold Alt under lasso for rette segmenter · {add} legger til · {sub} før tegning trekker fra + Extend Utvid + Structure: Struktur: + Content-Aware Move Tool Innholdsbasert flytteverktøy + TIFF Options TIFF-alternativer + Targa Options Targa-alternativer + JPEG Options JPEG-alternativer + Image Options Bildealternativer + Format Options Formatalternativer + Baseline ("Standard") Grunnlinje («Standard») + Baseline Optimized Grunnlinje, optimalisert + 24 bits/pixel 24 biter/piksel + 32 bits/pixel 32 biter/piksel + Including layers will increase file size. Å ta med lag øker filstørrelsen. + 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 + Couldn't place {name}: {error} Kunne ikke plassere {name}: {error} + Magnetic Lasso Tool Magnetlasso + Frequency Frekvens + Use tablet pressure to change pen width Bruk trykk fra tegnebrett til å endre pennbredden + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About Om + Contributors Bidragsytere + Models Modeller + Sort Sorter + Username Brukernavn + Display name Visningsnavn + Real name Ekte navn + Reverse the order Snu rekkefølgen + Grab bag Diverse + Table Tabell + Name (A–Z) Navn (A–Å) + Merged PRs Sammenslåtte PR-er + Commits Commits + Lines added Linjer lagt til + Lines deleted Linjer slettet + Line delta Linjedifferanse + Binary assets added Binærfiler lagt til + Binary assets removed Binærfiler fjernet + First commit Første commit + First Første + Last commit Siste commit + Last Siste + Contributors: {contributors} · Commits: {commits} Bidragsytere: {contributors} · Commits: {commits} + No contributor data was built into this copy. Ingen bidragsyterdata ble bygget inn i denne kopien. + No model credits were built into this copy. Ingen modellkrediteringer ble bygget inn i denne kopien. + Company Selskap + Version Versjon + % of all commits % av alle commits + Lines +/− Linjer +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR-er, {commits} commits, +{added} / −{deleted} linjer (Δ {delta}), +{binAdded} / −{binDeleted} binærfiler, {first} – {last} + +# Swatches panel + Create new swatch Opprett ny fargeprøve + New Swatch… Ny fargeprøve… + New Swatch Group… Ny fargeprøvegruppe… + Import Swatches… Importer fargeprøver… + Replace Swatches… Erstatt fargeprøver… + Export Swatches… Eksporter fargeprøver… + Export Swatches for Exchange… Eksporter fargeprøver for utveksling… + Reset Swatches Tilbakestill fargeprøver + Add to Swatches Legg til i fargeprøver +# Layer Style > Blending Options (Advanced Blending) + Blend If: Bland hvis: + Channels: Kanaler: + Knockout Utsparing + Apply At Canvas Bounds Bruk ved lerretgrensene + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Fargevelger (tekstfarge) + Color Picker ({name}) Fargevelger ({name}) + Color Picker (Fill Color) Fargevelger (fyllfarge) + Fill color Fyllfarge + Color Picker (Stroke Color) Fargevelger (strekfarge) + Stroke color Strekfarge + Pick screen color Hent skjermfarge + Sample a pixel anywhere on the screen Hent en piksel hvor som helst på skjermen + Click to pick · Esc to cancel Klikk for å hente · Esc for å avbryte + Pick a screen pixel or press Esc to cancel Velg en piksel på skjermen eller trykk Esc for å avbryte + Scroll to zoom Rull for å zoome + Drag to pan · Scroll to zoom · Double-click to fit Dra for å panorere · Rull for å zoome · Dobbeltklikk for å tilpasse + Show/Hide All Panels Vis/skjul alle paneler + Show/Hide Panels Vis/skjul paneler + Could not open Kunne ikke åpne + {path}: the path is not valid Unicode; rename the file and open it again. {path}: banen er ikke gyldig Unicode. Gi filen nytt navn og åpne den igjen. + +# Fill dialog, layer style contours and Image Size + Contents: Innhold: + Custom Pattern: Egendefinert mønster: + Foreground Color Forgrunnsfarge + Color… Farge… + 50% Gray 50 % grå + {size} (was {old}) {size} (var {old}) + Cone Kjegle + Cone (Inverted) Kjegle (invertert) + Domed Kuppel + Domed (Inverted) Kuppel (invertert) + Diagonal (Descending) Diagonal (synkende) + Contour Editor Konturredigering + Anti-aliased Kantutjevnet + Corner Hjørne + Gloss Contour Glanskontur + Contour Kontur + Cone - Inverted Kjegle - invertert + Cove - Deep Hulkil - dyp + Cove - Shallow Hulkil - grunn + Gaussian Gaussisk + Half Round Halvrund + Ring Ring + Ring - Double Ring - dobbel + Rolling Slope - Descending Rullende skråning - synkende + Rounded Steps Avrundede trinn + Sawtooth 1 Sagtann 1 + Linear Lineær + + Red Eye Tool Røde øyneverktøy + Pupil Size Pupillstørrelse + Darken Amount Mørkleggingsmengde + Click a red pupil to neutralize it Klikk på en rød pupill for å nøytralisere den + open source åpen kildekode + …and {n} more …og {n} til + + Eyedroppers: Pipetter: + Set Black Point Angi svartpunkt + Set Neutral Gray Point Angi nøytralt gråpunkt + Set White Point Angi hvitpunkt + Unavailable in this color mode Utilgjengelig i denne fargemodusen + Selection Preview: Forhåndsvisning av utvalg: + Black Matte Svart kant + White Matte Hvit kant + Quick Mask Hurtigmaske + +# Layer colour labels. + Seafoam Sjøgrønn + Indigo Indigo + Fuchsia Fuksia + Violet Fiolett + Large document: redrawing on the CPU Stort dokument: tegner på nytt med CPU + Redrawing Tegner på nytt + This redraw took {seconds} s on the CPU. Denne gjentegningen tok {seconds} s på CPU-en. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Redigeringer som endrer hele dokumentet, for eksempel et lags blandingsmodus, dekkevne eller synlighet, tegner alt på nytt. + The GPU compositor wasn't used: {reason} GPU-komposisjon ble ikke brukt: {reason} +fontWeight Thin Tynn +fontWeight ExtraLight Ekstra lett +fontWeight Light Lett +fontWeight Regular Normal +fontWeight Medium Middels +fontWeight SemiBold Halvfet +fontWeight Bold Fet +fontWeight ExtraBold Ekstra fet +fontWeight Black Svært fet (Black) +fontWeight Italic Kursiv + +# Pattern Stamp tool + Pattern Stamp Tool Mønsterstempel + 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° + +# Painting symmetry presets. + Dual Axis Dobbel akse + Diagonal Diagonal + Transform Symmetry Transformer symmetri + Set painting symmetry options Angi symmetrialternativer for maling + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprøve + 3 by 3 Average 3 x 3 gjennomsnitt + 5 by 5 Average 5 x 5 gjennomsnitt + 11 by 11 Average 11 x 11 gjennomsnitt + 31 by 31 Average 31 x 31 gjennomsnitt + 51 by 51 Average 51 x 51 gjennomsnitt + 101 by 101 Average 101 x 101 gjennomsnitt + Sample Size: Prøvestørrelse: + 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 + +# 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 + Color to Alpha… Farge til alfa… + Transparency Threshold Gjennomsiktighetsterskel + Opacity Threshold Dekkevneterskel + PhotoCraft — Screen Color Picker PhotoCraft: skjermfargevelger + + Rasterize svg groups deeper than Rasteriser SVG-grupper dypere enn + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 slår av denne terskelen. SVG-grupper nestet dypere enn 100 nivåer rasteriseres alltid. + Rule of Thirds Tredjedelsregelen + Triangle Trekant + Golden Ratio Det gylne snitt + Golden Spiral Den gylne spiral + Auto Show Overlay Vis overlegg automatisk + Always Show Overlay Vis alltid overlegg + Never Show Overlay Vis aldri overlegg + Cycle Overlay Bytt overlegg + Cycle Orientation Bytt retning + Set the overlay options for the Crop tool Angi overleggsalternativer for beskjæringsverktøyet + 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-klikk gikk til skrivebordet, som flytter vinduer med Alt-dra. Sett vindusflyttetasten til Super, eller lås Alt i Vindu › Modifikasjonstaster. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Fargevelger (lagstilfarge) + Auto Hide Notifications Skjul varsler automatisk + Notification Duration (seconds) Varselvarighet (sekunder) + Bundle Arrow-Key Nudges Samle piltastforskyvninger + Nudge Bundle Pause (ms) Pause for samling av forskyvninger (ms) + Appearance Mode Utseendemodus + Dark Theme Mørkt tema + Light Theme Lyst tema + Pro Medium Pro mellom + Studio Light Studio lys + Next Appearance Mode Neste utseendemodus + Sync with system Synkroniser med systemet + Active Aktiv + Color Picker (Solid Color) Fargevelger (ensfarget) + Straighten the image by drawing a line on it Rett opp bildet ved å tegne en linje på det + Set additional Crop options Angi flere beskjæringsalternativer + Show Cropped Area Vis beskåret område + Enable Crop Shield Aktiver beskjæringsskjold + Match Canvas Tilpass lerretet + Auto Adjust Opacity Juster dekkevne automatisk + Crop Shield color Farge for beskjæringsskjold + Color Picker (Crop Shield Color) Fargevelger (farge for beskjæringsskjold) + From Center Fra midten + Link radii Koble radier + Star Ratio: Stjerneforhold: + Create Rectangle Opprett rektangel + Create Ellipse Opprett ellipse + Create Triangle Opprett trekant + Create Polygon Opprett polygon + Pressure curve Trykkurve + Click to add a point, right-click to remove Klikk for å legge til et punkt, høyreklikk for å fjerne + Soft Myk + Hard Hard + Some brush presets could not be loaded Noen penselforvalg kunne ikke lastes inn + Retry saving presets Prøv å lagre forvalg på nytt + Brush preset changes are not saved: {error} Endringer i penselforvalg lagres ikke: {error} + Keep this tab open to retain your brushes. Hold denne fanen åpen for å beholde penslene dine. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Nettleserlagring er utilgjengelig. Penslene gjelder bare for denne økten. Hold denne fanen åpen for å beholde dem. + Saving brush presets… Keep this tab open until saving finishes. Lagrer penselforvalg… Hold denne fanen åpen til lagringen er ferdig. + Reflect Reflekter + Glow Glød + Negation Negasjon + XOR XOR + Color Burn (Paint.NET) Fargebrenning (Paint.NET) + Color Dodge (Paint.NET) Fargeavskjerming (Paint.NET) + Stroke Options Strekalternativer + Solid Heltrukket + Dashed Stiplet + Dotted Prikket + Caps Ender + Butt / Flat Butt / flat + Corners Hjørner + Miter Gjæring + Round Rund + Bevel Skrå + Miter limit Gjæringsgrense + Dashed line Stiplet linje + Dash Strek + Gap Mellomrom + Dash / gap lengths × stroke width Strek-/mellomromslengder × strekbredde + Add pair Legg til par + Remove pair Fjern par + Dash offset Strekforskyvning + Open paths use centered strokes Åpne baner bruker sentrerte streker + Use finite lengths and a positive pattern total Bruk endelige lengder og en positiv mønstersum + No subject found — try Object Selection or the Lasso Fant ikke noe motiv. Prøv objektvalg eller lassoen + Quick Selection found nothing there Hurtigvalg fant ingenting der + Object Selection found nothing there — try a tighter rectangle Objektvalg fant ingenting der. Prøv et strammere rektangel + No subject found — try a rectangle around the object Fant ikke noe motiv. Prøv et rektangel rundt objektet + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Slette laget «{name}»? + Delete {count} layers? {names} Slette {count} lag? {names} + Delete layer? Slette laget? + The document is closed Dokumentet er lukket + The document or selected layers changed. Please request deletion again. Dokumentet eller de valgte lagene er endret. Be om sletting på nytt. + Missing delete command Slettekommando mangler + Invalid delete command Ugyldig slettekommando + Layer deleted, but confirmation preference could not be saved: {error} Laget ble slettet, men bekreftelsesinnstillingen kunne ikke lagres: {error} + Add a layer style Legg til en lagstil + Add a mask (from the selection; {key} inverts) Legg til en maske (fra utvalget; {key} inverterer) + Add an adjustment Legg til en justering + Add to selection ({key}) Legg til i utvalget ({key}) + Adjustment visible Justering synlig + Align and distribute Juster og fordel + Align horizontal centers Juster vannrette sentre + Align left edges Juster venstre kanter + Align right edges Juster høyre kanter + Always use pressure for opacity Bruk alltid trykk for dekkevne + Always use pressure for size Bruk alltid trykk for størrelse + Angle Gradient Vinkelgradient + Auto-Select: Velg automatisk: + Brush Preset picker Penselforvalgsvelger + Cancel current crop operation (Esc) Avbryt pågående beskjæring (Esc) + Click the first point or press {key} to close · Esc cancels Klikk på det første punktet eller trykk {key} for å lukke · Esc avbryter + Click the first point or press {key} to close · {del} removes a point · Esc cancels Klikk på det første punktet eller trykk {key} for å lukke · {del} fjerner et punkt · Esc avbryter + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klikk, og beveg deretter langs en kant · klikk for å feste et punkt · {alt}-klikk for et rett segment · dobbeltklikk for å lukke + Clip to layer below Klipp til laget under + Color & Swatches Farge og fargeprøver + Commit current crop operation ({key}) Bekreft pågående beskjæring ({key}) + Create a new group Opprett en ny gruppe + Create a new layer Opprett et nytt lag + Create new document from current state Opprett nytt dokument fra gjeldende tilstand + Create new fill or adjustment layer Opprett nytt fyll- eller justeringslag + Create new snapshot Opprett nytt øyeblikksbilde + From: Fra: + Full Document Hele dokumentet + Merged Layers Sammenslåtte lag + Delete Cropped Pixels Slett beskårne piksler + Delete current state Slett gjeldende tilstand + Delete layer Slett lag + Diamond Gradient Diamantgradient + Doc: {w} × {h} px Dok: {w} × {h} px + Enable airbrush-style build-up effects Aktiver oppbyggingseffekter som med airbrush + Filter for adjustment layers Filtrer etter justeringslag + Filter for pixel layers Filtrer etter piksellag + Filter for shape layers Filtrer etter formlag + Filter for smart objects Filtrer etter smartobjekter + Filter for type layers Filtrer etter tekstlag + Fixed Ratio Fast forhold + Fixed Size Fast størrelse + Follows only edges this close to the pointer ([ and ] change it) Følger bare kanter så nær pekeren ([ og ] endrer det) + Hide Properties Skjul egenskaper + Higher values follow only edges that contrast sharply with their surroundings Høyere verdier følger bare kanter som kontrasterer skarpt med omgivelsene + How often fastening points are placed by themselves Hvor ofte festepunkter plasseres av seg selv + Hue/Saturation Fargetone/metning + Intersect with selection ({key}) Snitt med utvalget ({key}) + Kind Type + Linear Gradient Lineær gradient + Link layers Koble lag + Lock Lås + Lock all Lås alt + Lock image pixels Lås bildepiksler + Lock layer Lås lag + Lock position Lås posisjon + Lock transparent pixels Lås gjennomsiktige piksler + New selection Nytt utvalg + No properties Ingen egenskaper + Pressure for Opacity Trykk for dekkevne + Pressure for Size Trykk for størrelse + Prevent auto-nesting in and out of Artboards and Frames Hindre automatisk nesting inn i og ut av tegneflater og rammer + Radial Gradient Radial gradient + Reflected Gradient Speilet gradient + Reset to defaults Tilbakestill til standard + Set additional smoothing options Angi flere utjevningsalternativer + Show Transform Controls Vis transformeringskontroller + Subtract from selection ({key}) Trekk fra utvalget ({key}) + Swaps height and width Bytter om høyde og bredde + Tolerance Toleranse + Add vector mask Legg til vektormaske + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klikk for å zoome inn · {key}-klikk for å zoome ut · dra til høyre/venstre for å zoome inn/ut + Appearance Settings… Utseendeinnstillinger… + Add to favorites Legg til i favoritter + Remove from favorites Fjern fra favoritter +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Produsent +cameraData Model Modell +cameraData Date Taken Opptaksdato +cameraData Exposure Eksponering +cameraData F-Stop Blenderåpning +cameraData ISO Speed ISO-følsomhet +cameraData Focal Length Brennvidde +cameraData Focal Length (35 mm) Brennvidde (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktiver panel + Change Panel Group Bytt panelgruppe + Change Panel Layout Endre paneloppsett + Close Panel Lukk panel + Dock Panel Dokk panel + Float Panel Flytende panel + Move Panel Flytt panel + Group with Grupper med + Panel menu Panelmeny + Resize panel window Endre størrelse på panelvindu + Resize panels Endre størrelse på paneler + Use Classic Mode Bruk klassisk modus + Auto Center Preview Sentrer forhåndsvisning automatisk + Next Document Neste dokument + Previous Document Forrige dokument + W x H x Resolution B x H x oppløsning + Front Image Fremste bilde + Frame Tool Rammeverktøy + Create a new rectangular frame Opprett en ny rektangulær ramme + Create a new elliptical frame Opprett en ny elliptisk ramme + Include in playback Ta med i avspilling + Perspective Crop Tool Perspektivbeskjæringsverktøy + Show Grid Vis rutenett + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Dra en boks eller klikk fire hjørner · dra hjørnene til kantene på en skjev form · {commit} bekrefter · Esc avbryter + Artboard Tool Tegneflateverktøy + Make Portrait Gjør stående + Make Landscape Gjør liggende + Add New Artboard Legg til ny tegneflate + Freeform Pen Tool Frihåndspenn + Curvature Pen Tool Kurvaturpenn + Curve Fit: Kurvetilpasning: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Dra: tegn på frihånd · {alt}-klikk: rett segment · Slipp på startpunktet: lukk + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klikk: mykt punkt · Dobbeltklikk: hjørne · Dra et punkt: flytt · Klikk på første punkt: lukk · {key} fullfør + Scale: Skala: + Snap to Origin Fest til origo + Link with Layer Koble til lag + Rasterize Layer Style Rasteriser lagstil + Search settings… Søk i innstillinger… + No matching settings Ingen samsvarende innstillinger +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Prøvetakingspensel + Sampling Area Overlay Overlegg for prøvetakingsområde + Show Sampling Area Vis prøvetakingsområde + Indicates Angir + Sampling Area Prøvetakingsområde + Excluded Area Utelatt område + Sampling Area Options Alternativer for prøvetakingsområde + Rectangular Rektangulært + Fill Settings Fyllinnstillinger + Rotation Adaptation Rotasjonstilpasning + Very High Svært høy + Full Full + Mirror Speil + Output Settings Utdatainnstillinger + Output To Utdata til + Duplicate Layer Dupliser lag + Contract Trekk sammen + + Animation Options Alternativer for animasjon + Entire Document Hele dokumentet + Filter Options Filteralternativer + Layer Bounds Laggrenser + Layers Panel Options Alternativer for lagpanel + Lock Layers Lås lag + New Artboard… Ny tegneflate… + New Group… Ny gruppe… + Prevent Auto-Nesting Hindre automatisk nesting + Show Layers Vis lag + Thumbnail Contents Miniatyrinnhold + Thumbnail Size Miniatyrstørrelse + Use Default Masks on Fill Layers Bruk standardmasker på fyllag + Expand New Effects Vis nye effekter utvidet + Add “copy” to Copied Layers and Groups Legg til «kopi» i kopierte lag og grupper + Preview on canvas when reordering layers Forhåndsvis på lerretet ved omordning av lag + Show layer mask badges Vis merker for lagmaske + +# Smart Filter editors + Blending Options ({filter}) Blandingsalternativer ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Skjul lagets effekter og smartfiltre ({key}-klikk: alle lag) + Show the layer's effects and Smart Filters ({key}-click: all layers) Vis lagets effekter og smartfiltre ({key}-klikk: alle lag) + Wait for the current job to finish. Vent til gjeldende jobb er ferdig. + The document or Smart Filter changed. Reopen its dialog. Dokumentet eller smartfilteret er endret. Åpne dialogen på nytt. + Opacity must be between 0% and 100%. Dekkevnen må være mellom 0 % og 100 %. + Dissolve is approximated: it blends without Photoshop's dither. Oppløs er tilnærmet: det blander uten Photoshops dithering. + Loading fonts… Laster inn skrifter… + Response curve Responskurve + Tool Verktøy + Simulate Pressure Simuler trykk + Stroke Path… Strek opp bane… + Background Eraser Bakgrunnsviskelær + Healing Brush Reparasjonspensel + Spot Healing Brush Punktreparasjonspensel + Smudge Gni ut + Sponge Svamp + Amount: Mengde: + Protect: Beskytt: + Protect skin tones Beskytt hudtoner + Edit points to modify the curve Rediger punkter for å endre kurven + Draw to modify the curve Tegn for å endre kurven +curves Smooth Jevn ut + Smooth the curve values Jevn ut kurveverdiene + Drag to draw the curve · {key}-click draws a straight line Dra for å tegne kurven · {key}-klikk tegner en rett linje + Grid Size: Rutenettstørrelse: + Vanishing Point: click four corners to create a plane Fluktpunkt: klikk fire hjørner for å opprette et plan + Vanishing Point is not active Fluktpunkt er ikke aktivt + Apply Vanishing Point ({key}) Bruk fluktpunkt ({key}) + Fix the red plane first: its corners must form a convex quad Rett opp det røde planet først: hjørnene må danne en konveks firkant + Alt-click a plane to set the clone source Alt-klikk et plan for å angi klonekilden + Too many clone strokes: apply Vanishing Point first For mange klonestrøk: bruk fluktpunkt først +# Select and Mask view modes (#1754) + Onion Skin Løkskall + Marching Ants Marsjerende maur + On Black På svart + On White På hvitt + On Layers På lag + Show Edge Vis kant + Show Original Vis original + F cycles the views; hold X to see the image F bytter visning, hold X for å se bildet +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Kantforbedringspensel (R) + Expand Detection Area Utvid gjenkjenningsområdet + Restore Original Edge Gjenopprett opprinnelig kant + Paint over hair or fur on the image; Alt paints the other mode Mal over hår eller pels i bildet. Alt maler i den andre modusen + Clear Brush Strokes Fjern penselstrøk + Show rulers Vis linjaler + New Crop Preset… Nytt beskjæringsforvalg… + Delete Crop Preset… Slett beskjæringsforvalg… + Select the crop presets to delete: Velg beskjæringsforvalgene som skal slettes: + Select the crop presets to delete Velg beskjæringsforvalgene som skal slettes + Enter a name for the crop preset Skriv inn et navn på beskjæringsforvalget + There is no crop size or ratio to save Det finnes ingen beskjæringsstørrelse eller -forhold å lagre + Presentation Presentasjon + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klikk for å plassere · Alt-klikk eller dra utenfor lerretet for å fjerne + No document open Ingen dokumenter er åpne + {n} of {max} sample points {n} av {max} prøvepunkter + +# History panel menu (#2966) + New Snapshot… Nytt øyeblikksbilde… + History Options… Historikkalternativer… +# History panel › History Options… (#2966) + Allow Non-Linear History Tillat ikke-lineær historikk + Make Layer Visibility Changes Undoable Gjør endringer i lagsynlighet angrebare +# Print dialog (#2697) + Emulsion Down Emulsjon ned + Functions Funksjoner + Print Resolution: Utskriftsoppløsning: + Use Previous Layer to Create Clipping Mask Bruk forrige lag til å opprette klippemaske + Fill with {mode}-neutral color (white) Fyll med {mode}-nøytral farge (hvit) + Fill with {mode}-neutral color (black) Fyll med {mode}-nøytral farge (svart) + Fill with {mode}-neutral color (50% gray) Fyll med {mode}-nøytral farge (50 % grå) + (No neutral color exists for {mode} mode.) (Det finnes ingen nøytral farge for {mode}-modus.) + Apply mask to layer before removing? Bruke masken på laget før den fjernes? + The layer or its mask changed. Drag the mask onto the Trash again. Laget eller masken er endret. Dra masken til papirkurven på nytt. + Delete the vector mask? Slette vektormasken? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Eksporterte {n} fargeprøve til {file}|Eksporterte {n} fargeprøver til {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importerte {n} pensel fra {group}|Importerte {n} pensler fra {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importerte {n} fargeprøve fra {file}|Importerte {n} fargeprøver fra {file} +@plural Played {n} step|Played {n} steps Spilte av {n} trinn|Spilte av {n} trinn +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Oppdaterte {n} koblet smartobjekt som var endret på disk|Oppdaterte {n} koblede smartobjekter som var endret på disk +@plural Saved for Web: {n} file|Saved for Web: {n} files Lagret for web: {n} fil|Lagret for web: {n} filer +@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} lag registrert av denne komposisjonen er slettet. Oppdater komposisjonen, eller fjern advarselen.|{n} lag registrert av denne komposisjonen er slettet. Oppdater komposisjonen, eller fjern advarselen. +@plural {n} plane|{n} planes {n} plan|{n} plan +@plural {n} preset|{n} presets {n} forvalg|{n} forvalg +@plural {n} sample point removed|{n} sample points removed {n} prøvepunkt fjernet|{n} prøvepunkter fjernet +@plural {n} slice|{n} slices {n} segment|{n} segmenter +@plural {n} step|{n} steps {n} trinn|{n} trinn +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advarsel til om penselforvalg)|{warning} (+{n} advarsler til om penselforvalg) +@plural {what} with {n} warning|{what} with {n} warnings {what} med {n} advarsel|{what} med {n} advarsler +# View › Guides › New Guide… › Color + Light Blue Lyseblå + Light Red Lyserød + Medium Blue Mellomblå + Light Gray Lysegrå + Object Finder Objektfinner + No object found there — drag a rectangle around it Fant ikke noe objekt der. Dra et rektangel rundt det + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Det aktive laget har ingen piksler her. Slå på Ta prøve fra alle lag, eller velg et lag med piksler + Remove after each stroke Fjern etter hvert strøk + Apply the removal ({key}) Bruk fjerningen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Strøk som skal fjernes: {n} (Enter bruker, Esc avbryter) + Load Contours… Last inn konturer… + Replace Contours… Erstatt konturer… + Save Contours… Lagre konturer… + Delete Contour Slett kontur + Loaded contours from {file} Lastet konturer fra {file} + Saved contours to {file} Lagret konturer til {file} + Show home recent files Vis nylig brukte filer på startskjermen + Clear Brush Controls Fjern penselkontroller + Reset All Locked Settings Tilbakestill alle låste innstillinger + Copy Texture to Other Tools Kopier tekstur til andre verktøy + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Last inn handlinger… + Save Actions… Lagre handlinger… + New Action… Ny handling… + Start Recording Start opptak + Stop Recording Stopp opptak + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Lastet inn {n} handling fra {file}|Lastet inn {n} handlinger fra {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} trinn har ingen PhotoCraft-ekvivalent ennå|{n} trinn har ingen PhotoCraft-ekvivalent ennå +@plural Saved {n} action to {file}|Saved {n} actions to {file} Lagret {n} handling til {file}|Lagret {n} handlinger til {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} trinn uten Photoshop-ekvivalent ble utelatt|{n} trinn uten Photoshop-ekvivalent ble utelatt + + File Naming Filnavngiving + Starting serial Startserienummer +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Sett inn stopp… + Save Set… Lagre sett… + Message: Melding: + Allow Continue Tillat Fortsett + Show dialog during playback Vis dialog under avspilling + Select an action first Velg en handling først + Action playback stopped Avspilling av handling stoppet + 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) + Content-Aware Fill Innholdsbasert fyll +# Batch / Image Processor + Include All Subfolders Ta med alle undermapper + Auto Add/Delete Legg til/slett automatisk + +# 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… + 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 + Calibration Kalibrering + Red Primary Rød primærfarge + Green Primary Grønn primærfarge + Blue Primary Blå primærfarge + Write xmp sidecars Skriv XMP-sidefiler + Set the tone sliders from the image Angi toneglidebryterne ut fra bildet + Settings from {name} Innstillinger fra {name} + Settings from {name}; {count} not supported: {names} Innstillinger fra {name}; {count} støttes ikke: {names} + Could not write {name}: {error} Kunne ikke skrive {name}: {error} + Kept {name}: written by another application Beholdt {name}: skrevet av et annet program + Shadow Tint Skyggetoning +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maler fra historikkpanelets kildetilstand + Sets the source for the history brush Angir kilden for historikkpenselen + +# Dynamic selection context menu and appearance favorites + Select Inverse Inverter utvalg + Pro (Light) Pro (lys) + Pro (Light Gray) Pro (lysegrå) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Etikett + Date and Time Dato og klokkeslett + Scale Units Skalaenheter + Scale Factor Skaleringsfaktor + Area Område + Perimeter Omkrets + Circularity Sirkularitet + Gray Value (Minimum) Gråverdi (minimum) + Gray Value (Maximum) Gråverdi (maksimum) + Gray Value (Mean) Gråverdi (gjennomsnitt) + Gray Value (Median) Gråverdi (median) + Integrated Density Integrert tetthet + Length Lengde + Show Favorite Fonts Vis favorittskrifter + No favorite fonts yet: click the star beside a font to add it. Ingen favorittskrifter ennå: klikk på stjernen ved siden av en skrift for å legge den til. + Favorite fonts Favorittskrifter + Add to Favorites Legg til i favoritter + Remove from Favorites Fjern fra favoritter + Show All Fonts Vis alle skrifter + All Classes Alle klasser + Blackletter Frakturskrift + Decorative Dekorativ + Font preview: Skriftforhåndsvisning: + Handwritten Håndskrift + Hide Font Classes Skjul skriftklasser + Hide Styles Skjul stiler + Monospace Fast tegnbredde + No fonts match these filters. Ingen skrifter samsvarer med disse filtrene. + No similar fonts: the current font doesn't say what it looks like. Ingen lignende skrifter: gjeldende skrift oppgir ikke hvordan den ser ut. + OpenType font OpenType-skrift + Sans Serif Sans seriff + Script Skriveskrift + Serif Seriff + Show Font Classes Vis skriftklasser + Show Similar Fonts Vis lignende skrifter + Show Styles Vis stiler + Show Variable Fonts Vis variable skrifter + Slab Serif Blokkseriff + TrueType font TrueType-skrift + Variable font Variabel skrift +# Bevel & Emboss shading controls. + Highlight Mode Høylysmodus + Highlight Color Høylysfarge + Highlight Opacity Høylysdekkevne + Shadow Mode Skyggemodus + Shadow Color Skyggefarge + Shadow Opacity Skyggedekkevne +# PhotoCraft release checks. + Check for Updates… Se etter oppdateringer… + PhotoCraft Updates PhotoCraft-oppdateringer + Checking for updates… Ser etter oppdateringer… + Open the release page to choose an installer for your system. Åpne utgivelsessiden for å velge en installasjonspakke for systemet ditt. + Open Download Page Åpne nedlastingssiden + PhotoCraft is up to date. PhotoCraft er oppdatert. + Could not check for updates. Kunne ikke se etter oppdateringer. + Updates have not been checked yet. Oppdateringer er ikke sjekket ennå. + Check Again Sjekk igjen + Automatically Check for Updates Se etter oppdateringer automatisk + Installed version: {version} Installert versjon: {version} + PhotoCraft {version} is available. PhotoCraft {version} er tilgjengelig. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Bruk den innebygde profilen ({profile}) + Convert to the working space ({profile}) Konverter til arbeidsfargerommet ({profile}) + Remember this choice for future images Husk dette valget for fremtidige bilder + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Slå på spørsmålet igjen med Spør ved avvik i Rediger › Fargeinnstillinger. + Button Mode Knappemodus + Button Mode On Knappemodus på + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Gjør grensesnittet mørkere + Lighten Interface Gjør grensesnittet lysere +# Bevel & Emboss Technique. + Technique Teknikk + Chisel Hard Meisel hard + Chisel Soft Meisel myk + Open color-less EXR as ACEScg Åpne fargeløs EXR som ACEScg + Do not append "copy" to filename when saving a copy Ikke legg til «copy» i filnavnet når du lagrer en kopi + +# View › Guides › right-click a guide + Edit Selected Guides Rediger valgte hjelpelinjer + Clear Selected Guides Fjern valgte hjelpelinjer + Bicubic Automatic Bikubisk automatisk + Bicubic Smoother Bikubisk jevnere + Bicubic Sharper Bikubisk skarpere + Pasting from other apps is unavailable Innliming fra andre programmer er utilgjengelig + 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 får ikke tilgang til systemets utklippstavle i denne økten. På Wayland skjuler en Flatpak-sandkasse protokollen som bilder fra andre programmer leses gjennom. + To paste from other apps, start PhotoCraft under XWayland: `{command}` For å lime inn fra andre programmer, start PhotoCraft under XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Ingenting å lime inn: PhotoCraft kan ikke lese systemets utklippstavle her + Delete this set Slett dette settet + New Set… Nytt sett… + PhotoCraft Defaults PhotoCraft-standarder + Save a list of these shortcuts as a web page Lagre en liste over disse hurtigtastene som en nettside + Save the changes to this set Lagre endringene i dette settet + Save these shortcuts as a .kys file Lagre disse hurtigtastene som en .kys-fil + Save these shortcuts as a new set Lagre disse hurtigtastene som et nytt sett + Set deleted. Settet er slettet. + Set saved. Settet er lagret. + Set: Sett: + Summarize… Oppsummer… + There are too many sets: delete one first. Det er for mange sett: slett ett først. + set name settnavn + {set} (modified) {set} (endret) + Random Tilfeldig + Show file name in title Vis filnavn i tittelen + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 % terskel + Pattern Dither Mønsterdithering + Diffusion Dither Diffusjonsdithering + Halftone Screen Halvtoneraster + Custom Pattern Egendefinert mønster + Frequency: Frekvens: + Lines/Inch Linjer/tomme + Lines/Centimeter Linjer/centimeter + Ellipse Ellipse + Diamond Diamant + Cross Kryss + Accept and Go to Conflict Godta og gå til konflikt + Changes undone. Endringene er angret. + Choose a new shortcut for this command, or leave it without one. Velg en ny hurtigtast for denne kommandoen, eller la den være uten. + Put this command's shortcuts back as they were Sett denne kommandoens hurtigtaster tilbake som de var + Take the shortcut from the other command and select it Ta hurtigtasten fra den andre kommandoen og velg den + Undo Changes Angre endringer + {shortcut} is reserved by the system and can't be assigned. {shortcut} er reservert av systemet og kan ikke tilordnes. + Low-Latency Drawing (vsync off, applies at next launch) Tegning med lav forsinkelse (vsync av, trer i kraft ved neste oppstart) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Fri transformering brukes fortsatt. Prøv igjen når den er ferdig. + The transform couldn't be applied. Transformeringen kunne ikke brukes. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Lagene trenger {needed} GPU-minne. Budsjettet gir dem {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. Sett GPU-minne i Innstillinger › Ytelse til det grafikkortet kan holde, høyst 3/8 av minnet. Automatisk følger Minnebruk, innenfor en fjerdedel av datamaskinens minne og 3/8 av kortets, og regner ikke dokumentet mot et kort med kjent minne. Statuslinjen viser GPU-minne fullt så lenge de ikke får plass. + GPU Memory Full GPU-minne fullt + 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. Lagene trenger {needed} GPU-minne og kan holde {budget}, så redigeringer som tegner hele dokumentet på nytt gjør det på CPU-en. Klikk for å angi GPU-minne. + GPU memory budget: {budget} GPU-minnebudsjett: {budget} + Graphics memory: {size} Grafikkminne: {size} + This document's layers: {needed} of {budget} for layers Dette dokumentets lag: {needed} av {budget} for lag + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokumentets lag trenger {needed}, men kan holde {budget}: gjentegning av hele dokumentet bruker CPU-en + GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + {path}: the path is not valid Unicode; save under another name. {path}: banen er ikke gyldig Unicode; lagre under et annet navn. + Replace Erstatt + {name} was changed by another app after it was opened or saved here. {name} ble endret av et annet program etter at den ble åpnet eller lagret her. + Replace it with this document, or cancel and use File › Save As to keep both. Erstatt den med dette dokumentet, eller avbryt og bruk Fil › Lagre som for å beholde begge. + +# 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) (kommer snart) + (none) (ingen) + 32-bit Preview Options Alternativer for 32-biters forhåndsvisning + 3D… 3D… + A: A: + AI Integrations KI-integrasjoner + About {app} Om {app} + Accented Edges Aksentuerte kanter + Action playback failed: {error} Avspilling av handling mislyktes: {error} + Actual Pixels Faktiske piksler + Add Mask Legg til maske + Add Noise Legg til støy + Add a mask (from the selection; ⌥ inverts) Legg til en maske (fra utvalget; ⌥ inverterer) + Add space after paragraph Legg til mellomrom etter avsnitt + Add space before paragraph Legg til mellomrom før avsnitt + Add to selection (⇧) Legg til i utvalget (⇧) + Added swatch {name} La til fargeprøven {name} + Adds grain to the soft edges of the brush tip. Legger til korn i de myke kantene på penselspissen. + Adjust Interval For Loops Juster intervall for løkker + Adjustment Layers Justeringslag + Adjustment Properties Egenskaper for justering + Adjustment layers have nothing to transform Justeringslag har ingenting å transformere + Adobe PDF Presets… Adobe PDF-forvalg… + Adobe-rgb-compat Adobe RGB-kompatibel + Adwaita (Dark) Adwaita (mørk) + Adwaita (Light) Adwaita (lys) + After Etter + Airbrush: keeps painting while the pointer rests. Airbrush: fortsetter å male mens pekeren hviler. + All shortcuts reset to Photoshop defaults. Alle hurtigtaster tilbakestilt til Photoshop-standard. + Altitude Høyde + Ambience Stemning + Ambient Omgivelseslys + Amplitude Max Maks amplitude + Amplitude Min Min amplitude + Android 1080p Android 1080p + Angled Strokes Vinklede strøk + Anisotropic Anisotropisk + Any Alle + Apple Watch 45mm Apple Watch 45 mm + Apply Data Set Bruk datasett + Apply Image Bruk bilde + Apply Mask Bruk maske + Apply to All Bruk på alle + Apply {option}: {state} Bruk {option}: {state} + Arbitrary Vilkårlig + Arc Lower Nedre bue + Arc Upper Øvre bue + Are you sure you want to quit? Er du sikker på at du vil avslutte? + Are you sure? Er du sikker? + Around Center Rundt midten + Arrangement Ordning + Arrows Piler + Artboard from Group Tegneflate fra gruppe + Artboard from Layers Tegneflate fra lag + Artboards Tegneflater + Artboards to Files Tegneflater til filer + Artboards to PDF Tegneflater til PDF + Artistic Kunstnerisk + Ask On Mismatch Spør ved avvik + Ask On Missing Spør ved manglende + Ask On Paste Spør ved innliming + Assign Profile Tilordne profil + At Ved + Author Title Forfattertittel + Auto Scale Automatisk skalering + Auto-Align Layers Juster lag automatisk + Auto-Blend Layers Bland lag automatisk + Autosave failed: {error} Autolagring mislyktes: {error} + Back Tilbake + Backend: {backend} ({type}) Motor: {backend} ({type}) + Background Level Bakgrunnsnivå + Bamboo Bambus + Bas Relief Basrelieff + Base Tree Type Grunntretype + Based On Basert på + Baseline shift Grunnlinjeforskyvning + Basic Latin Grunnleggende latin + Batch Satsvis + Before Før + Bend Bøy + Berries Bær + Best Beste + Black Clip Svartklipping + Black Intensity Svartintensitet + Blade Curvature Bladkrumning + Blast Eksplosjon + Bleach Bypass Blekeforbikobling + Blend Clipped Layers as Group Bland klippede lag som gruppe + Blend Interior Effects as Group Bland indre effekter som gruppe + Blend Text Gamma Bland tekstgamma + Blending Mode Blandingsmodus + Bloat Blås opp + Blocks Blokker + Blue Yellow Blå-gul + Blues Blåtoner + Border Kant + Border Thickness Kanttykkelse + Box Blur Bokssløring + Bpc BPC + Branches Color Greinfarge + Branches Height Greinhøyde + Branches Thickness Greintykkelse + Breeze Dark Breeze mørk + Breeze Light Breeze lys + Brick Murstein + Bristle Detail Bustdetalj + Browse Bla gjennom + Brush Detail Penseldetalj + Brush Settings… (F5): every option of this brush Penselinnstillinger… (F5): alle alternativer for denne penselen + Brush Strokes Penselstrøk + Brush Type Penseltype + Burlap Jutevev + Calculations Beregninger + Camera Raw Camera Raw + Camera Raw Filter Camera Raw-filter + Cancel transform Avbryt transformering + Candle Light Levende lys + Canvas Size Lerretstørrelse + Case Sensitive Skill mellom store og små bokstaver + Ccw Mot klokken + Cell Size Cellestørrelse + Center X Midtpunkt X + Center Y Midtpunkt Y + Chalk Area Krittområde + Change Find Erstatt og søk + Changes Saved Endringer lagret + Changes have not been saved. Endringene er ikke lagret. + Change… Endre… + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Charcoal Area Trekullområde + Charcoal Thickness Trekulltykkelse + Check for Updates Se etter oppdateringer + Checkerboard Sjakkbrett + Choke Innsnevring + Choose Folder Velg mappe + Circle Sirkel + Cleanliness Renhet + Clear Amount Tømmemengde + Clear Selection Fjern utvalg + Click sets foreground · right-click sets background Klikk angir forgrunn · høyreklikk angir bakgrunn + Click the first point or press ↵ to close · Esc cancels Klikk på det første punktet eller trykk ↵ for å lukke · Esc avbryter + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Klikk for å legge til et punkt · dra bort, ⌘/Ctrl-klikk eller Delete for å fjerne + Click to apply, ⌥-click to clear overrides, double-click for Style Options Klikk for å bruke, ⌥-klikk for å fjerne overstyringer, dobbeltklikk for stilalternativer + Click to zoom in · ⌥-click to zoom out Klikk for å zoome inn · ⌥-klikk for å zoome ut + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klikk: hjørne · Dra: myk · Klikk på første punkt: lukk · ↩ fullfør · Esc avbryt + Clipping Mask Klippemaske + Clipping Path… Klippebane… + Clone Klon + Clone source set at {x}, {y} Klonekilde angitt ved {x}, {y} + Close Window Lukk vindu + Clumped Klumpet + Cmyk CMYK + Coarse Dots Grove prikker + Coated-cmyk Bestrøket CMYK + Collapse group (⌥-click: all groups) Fold sammen gruppe (⌥-klikk: alle grupper) + Color Decontaminate Fjern fargesmitte + Color Halftone Fargeraster + Color Indicates Masked Areas Farge angir maskerte områder + Color Indicates Selected Areas Farge angir valgte områder + Color Lookup Tables Fargeoppslagstabeller + Color Picker Fargevelger + Color Picker (Background Color) Fargevelger (bakgrunnsfarge) + Color Picker (Custom Canvas Color) Fargevelger (egendefinert lerretfarge) + Color Picker (Foreground Color) Fargevelger (forgrunnsfarge) + Color Sampler Tool Fargemålerverktøy + Color Settings Fargeinnstillinger + Color Swatches Fargeprøver + Color Table Fargetabell + Color management: {error} Fargestyring: {error} + Colored Pencil Fargeblyant + Comic Book Tegneserie + Commit Perspective Warp (↩) Bekreft perspektivfordreining (↩) + Commit Puppet Warp (↩) Bekreft marionettfordreining (↩) + Commit any current edits (⌘↩) Bekreft pågående redigering (⌘↩) + Commit current crop operation (↵) Bekreft pågående beskjæring (↵) + Commit transform Bekreft transformering + Commit transform (↩) Bekreft transformering (↩) + Commit warp (↩) Bekreft fordreining (↩) + Compare Sammenlign + Conditional Mode Change Betinget modusendring + Confirm Bekreft + Conform Tilpass + Constraints Begrensninger + Content-Aware Fill needs a selection on a pixel layer Innholdsbasert fyll krever et utvalg på et piksellag + Contrasty Kontrastrik + Convert Konverter + Convert to Alpha Channel Konverter til alfakanal + Convert to Linked Konverter til koblet + Convert to Profile Konverter til profil + Cool Kjølig + Cooling L B B Kjølefilter (LBB) + Cooling80 Kjølefilter (80) + Cooling82 Kjølefilter (82) + Copy Complete Stroke Kopier hele strøket + Copy Fill Kopier fyll + Copyright Url Opphavsretts-URL + Correct C A Korriger kromatisk aberrasjon + Correct Distortion Korriger forvrengning + Correct Vignette Korriger vignettering + Could not rename the action: {error} Kunne ikke gi handlingen nytt navn: {error} + Could not transform: the layer is empty Kunne ikke transformere: laget er tomt + Could not use Perspective Warp: the layer is empty Kunne ikke bruke perspektivfordreining: laget er tomt + Could not use Puppet Warp: no opaque pixels Kunne ikke bruke marionettfordreining: ingen ugjennomsiktige piksler + Could not use Puppet Warp: the layer is empty Kunne ikke bruke marionettfordreining: laget er tomt + Couldn't open {name}: {error} Kunne ikke åpne {name}: {error} + Couldn't save preferences: {error}. Retrying… Kunne ikke lagre innstillingene: {error}. Prøver igjen… + Crack Brightness Sprekklysstyrke + Crack Depth Sprekkdybde + Crack Spacing Sprekkavstand + Craquelure Krakelering + Create By Opprett ved + Create Vector Mask Opprett vektormaske + Create Warped Text Opprett fordreid tekst + Create a new layer (⇧⌘N) Opprett et nytt lag (⇧⌘N) + Create new brush from the current settings Opprett ny pensel fra gjeldende innstillinger + Create new brush preset from the current settings Opprett nytt penselforvalg fra gjeldende innstillinger + Create… Opprett… + Cross Process Kryssfremkalling + Crosshatch Krysskravering + Crosswise På tvers + Crystallize Krystalliser + Currency Valuta + Curve Darks Kurve: mørke toner + Curve Highlights Kurve: høylys + Curve Lights Kurve: lyse toner + Curve Shadows Kurve: skygger + Curve Splits Kurvedelinger + Custom Branch Color Egendefinert greinfarge + Custom Pixel Aspect Ratio Egendefinert pikselforhold + Custom pattern Egendefinert mønster + Cutout Utskjæring + Cw Med klokken + Cyans Cyantoner + Cyrillic Kyrillisk + Daisies Tusenfryd + Dark Gray Mørkegrå + Dark Intensity Mørk intensitet + Dark Rough Mørk ru + Dark Strokes Mørke strøk + Darken Only Bare mørkere + Darkness Mørke + Data Data + Data Set: {name} ({index}/{total}) Datasett: {name} ({index}/{total}) + Data Sets as Files Datasett som filer + Date Dato + Day For Night Dag til natt + De-Interlace Fjern linjesprang + Decrease Brush Hardness Reduser penselhardhet + Decrease Brush Size Reduser penselstørrelse + Decrease Type Size Reduser tekststørrelse + Decrease Type Size by 10 Reduser tekststørrelse med 10 + Deep Dyp + Deep Blue Dyp blå + Deep Emerald Dyp smaragd + Deep Red Dyp rød + Deep Yellow Dyp gul + Default Foreground and Background Colors Standard forgrunns- og bakgrunnsfarge + Default Leaves Standardblader + Default Photoshop Size Standard Photoshop-størrelse + Defaults Standarder + Define Brush Preset Definer penselforvalg + Define Custom Shape Definer egendefinert form + Define Pattern Definer mønster + Definition Definisjon + Defringe Fjern frynser + Degrees Grader + Delete Channel Slett kanal + Delete Mask Slett maske + Delete Workspace Slett arbeidsområde + Delete brush Slett pensel + Delete path Slett bane + Delete vector mask Slett vektormaske + Delete… Slett… + Depth Map Dybdekart + Depth Mode Dybdemodus + Diameter Diameter + Diffuse Diffuser + Diffuse Glow Diffus glød + Direction Retning + Direction Balance Retningsbalanse + Disable Deaktiver + Disable Mask Deaktiver maske + Disable Symmetry Path Deaktiver symmetribane + Disabled Deaktivert + Discard Changes Forkast endringer + Displace Forskyv + Display Skjerm + Display profile not used Skjermprofil ikke brukt + Display profiles: {reason} Skjermprofiler: {reason} + Display-p3 Display-P3 + Distance Avstand + Distortion Forvrengning + Distribution Fordeling + Do you want to save you made to “{name}” before quitting? Vil du lagre endringene du gjorde i «{name}» før du avslutter? + Document Sizes Dokumentstørrelser + Documentation Dokumentasjon + Documents Dokumenter + Dont Enlarge Ikke forstørr + Dot Prikk + Double Line Dobbel linje + Down Ned + Draft Utkast + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Dra en beskjæringsramme · dra innenfor for å flytte · kantene endrer størrelse ({ratio} forhold, {centre} midtpunkt) · mellomrom flytter under tegning · {commit} bekrefter · Esc avbryter + Drag: draw freehand · Release on the start point: close Dra: tegn på frihånd · Slipp på startpunktet: lukk + Driver: {driver} Driver: {driver} + Dry Brush Tørrpensel + Duplicate Channel Dupliser kanal + Duplicate Layers Dupliser lag + Duplication Duplisering + Dust & Scratches Støv og riper + Edge Kant + Edge Brightness Kantlysstyrke + Edge Extension Kantutvidelse + Edge Fidelity Kantpresisjon + Edge Intensity Kantintensitet + Edge Only Bare kant + Edge Simplicity Kantenkelhet + Edge Thickness Kanttykkelse + Edge Width Kantbredde + Edit Toolbar Rediger verktøylinje + Edit… Rediger… + Elevation Høyde + Eliminate Eliminer + Emboss Relieff + Enable Aktiver + Enable Mask Aktiver maske + End X Slutt X + End Y Slutt Y + Enlarged Forstørret + Erase with Brush or Pencil (hold) Visk ut med pensel eller blyant (hold) + Error Feil + Even Fields Like felt + Exit Quick Mask Avslutt hurtigmaske + Expand group (⌥-click: all groups) Fold ut gruppe (⌥-klikk: alle grupper) + Export As Eksporter som + Export As: {name} Eksporter som: {name} + Export Contents Eksporter innhold + Export Image Eksporter bilde + Export Options Eksportalternativer + Export Preferences Eksportinnstillinger + Export Settings Eksportinnstillinger + Export to Eksporter til + Export/Import Presets Eksporter/importer forvalg + Exported contents to {path} Eksporterte innhold til {path} + Exported presets to {path} Eksporterte forvalg til {path} + Exported {path} Eksporterte {path} + Exported {path} ({size}) Eksporterte {path} ({size}) + Exporting… Eksporterer… + ExtraBold Ekstra fet + ExtraLight Ekstra lett + Extrude Ekstruder + Fade Ton ned + Faded Film Falmet film + Failed Mislyktes + Failed to open file Kunne ikke åpne filen + Failed to save file Kunne ikke lagre filen + Fallback: {reason} Reserve: {reason} + Feather Radius Radius for myk kant + Fiber Length Fiberlengde + Fibers Fibrer + Field Blur Feltsløring + File Format Filformat + File Info Filinformasjon + File Name Filnavn + File Size Filstørrelse + File not found Filen ble ikke funnet + Filename Filnavn + Fill Color Fyllfarge + Fill Opacity Fylldekkevne + Fill Path… Fyll bane… + Fill path with foreground color Fyll bane med forgrunnsfarge + Fill with Background Color Fyll med bakgrunnsfarge + Fill with Background Color, Preserve Transparency Fyll med bakgrunnsfarge, behold gjennomsiktighet + Fill with Foreground Color Fyll med forgrunnsfarge + Fill with Foreground Color, Preserve Transparency Fyll med forgrunnsfarge, behold gjennomsiktighet + Film Grain Filmkorn + Filter Gallery Filtergalleri + Find Søk + Find and Replace Text Søk og erstatt tekst + Fine Fin + Fine Dots Fine prikker + Finish Fullfør + Fit Image Tilpass bilde + Fit to Window Tilpass til vinduet + Flame Flamme + Flame Bottom Alignment Justering av flammebunn + Flame Lines Flammelinjer + Flame Shape Flammeform + Flame Style Flammestil + Flame Type Flammetype + Flat Flat + Flat Shading Flat skyggelegging + Flower Color Blomsterfarge + Focal Distance Fokusavstand + Focus Fokus + Focus Area Fokusområde + Foreground Level Forgrunnsnivå + Forward Fremover + Forward Warp Fordrei fremover + Frame Ramme + Frame {current} / {total} Ramme {current} / {total} + Free Transform Path Fri transformering av bane + Freeze Lasso Fryselasso + Freeze Mask Frysemaske + Fresco Fresko + From Fra + From Left Fra venstre + From Right Fra høyre + Frosted Frostet + Full Screen Fullskjerm + Fullscreen Fullskjerm + Fuzziness Uklarhet + GPU backend preference: {backend} Innstilling for GPU-motor: {backend} + GPU device was lost; using the CPU renderer. GPU-enheten gikk tapt; bruker CPU-gjengiveren. + GPU error; using the CPU renderer. GPU-feil; bruker CPU-gjengiveren. + GPU retry scheduled Nytt GPU-forsøk er planlagt + Gamma Gamma + Gaussian Blur Gaussisk sløring + General Blending Generell blanding + General Brushes Generelle pensler + Generators Generatorer + Generic Generisk + Glass Glass + Global Light Globalt lys + Gloss Glans + Glow Amount Glødmengde + Glow Brightness Glødlysstyrke + Glow Size Glødstørrelse + Glowing Edges Glødende kanter + Good God + Grade Balance Graderingsbalanse + Grade Blending Graderingsblanding + Grade Global Gradering global + Grade Highlights Gradering høylys + Grade Midtones Gradering mellomtoner + Grade Shadows Gradering skygger + Gradient Options Gradientalternativer + Grain Amount Kornmengde + Grain Roughness Kornruhet + Grain Size Kornstørrelse + Grain Type Korntype + Graininess Kornhet + Grainy Dots Kornete prikker + Graphics adapter Grafikkadapter + Graphics adapter: {adapter} Grafikkadapter: {adapter} + Gray-gamma-2.2 Grå gamma 2,2 + Greek Gresk + Greens Grønntoner + Grid (⌘') Rutenett (⌘') + Grid view Rutenettvisning + Group from Layers Gruppe fra lag + Groups Grupper + Grout Width Fugebredde + Guide Layout… Hjelpelinjeoppsett… + HDR Toning HDR-toning + Hand Tool (hold) Håndverktøy (hold) + Hearts Hjerter + Heptagon Sjukant + Hexagon Sekskant + Hidden Skjult + Hide Grid Skjul rutenett + Hide Others Skjul andre + Hide the layer's effects (⌥-click: all layers) Skjul lagets effekter (⌥-klikk: alle lag) + Hide {app} Skjul {app} + High Pass Høypass + Highlight Høylys + Highlight Amount Høylysmengde + Highlight Area Høylysområde + Highlight Radius Høylysradius + Highlight Strength Høylysstyrke + Highlight Tone Høylystone + Highlights from: Høylys fra: + Horizontal Distortion Vannrett forvrengning + Horizontal Only Bare vannrett + Horizontal scale Vannrett skalering + Horizontal type Vannrett tekst + Hotspot Aktivt område + Hsb HSB + Hsl HSL + Hsl Hue HSL-fargetone + Hsl Lum HSL-luminans + Hsl Sat HSL-metning + Image Balance Bildebalanse + Image Processor Bildebehandler + Image compositor: {processor} Bildekompositor: {processor} + Import Brushes Importer pensler + Import Brushes… (.abr) Importer pensler… (.abr) + Import Image Importer bilde + Import Options Importalternativer + Import Photoshop brushes (.abr) Importer Photoshop-pensler (.abr) + Import Settings Importer innstillinger + Importing {what} from {file}… Importerer {what} fra {file}… + Importing… Importerer… + In Black I svart + In White I hvitt + Increase Brush Hardness Øk penselhardhet + Demosaic Demosaikking + 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. + 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 fargefrynser, men tregest. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigert interpolering: skarp og rask, med noe fargefrynser på fine detaljer. + The fastest and the softest, with colour fringing on fine detail. Den raskeste og mykeste, med fargefrynser på fine detaljer. + “{name}” already exists. Replace it? «{name}» finnes allerede. Erstatte den? + The name in the save dialog was “{name}”. Navnet i lagredialogen var «{name}». + Move Up Flytt opp + Move Down Flytt ned + Previous Brush Forrige pensel + Next Brush Neste pensel + First Brush Første pensel + Last Brush Siste pensel + Preview size Forhåndsvisningsstørrelse + Memory Usage Minnebruk + Per-document RAM limit (MB) RAM-grense per dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installert RAM: {ram} MB. Denne grensen: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrenser hvert åpne dokuments piksler pluss historikk. De eldste angretilstandene forkastes når grensen overskrides. + Applies immediately when saved. Memory is not reserved in advance. Trer i kraft umiddelbart når den lagres. Minne reserveres ikke på forhånd. + 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. Med GPU-minne satt til Automatisk begrenser dette også GPU-lagbudsjettet. Det budsjettet er begrenset til en fjerdedel av installert RAM og, når det er kjent, tre åttedeler av grafikkortets minne, med et minimum på 512 MB. Å heve denne grensen over de takene øker ikke GPU-budsjettet. + Command Kommando + Use Bruk + In Front Foran + Behind Bak + +# DDS Options (#1327) + DDS Options DDS-alternativer + Compression Komprimering + Generate Mipmaps Generer mipmaps + +# Color Table (.act) dialog + There is no color table to save. Det er ingen fargetabell å lagre. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Lagret {n} farge til {file}|Lagret {n} farger til {file} + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Importer stiler… + Export Selected Styles… Eksporter valgte stiler… + Export Styles… Eksporter stiler… + {file} is not a style library (.asl) {file} er ikke et stilbibliotek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importerte {n} stil fra {file}|Importerte {n} stiler fra {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Eksporterte {n} stil til {file}|Eksporterte {n} stiler til {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bekreft eller avbryt den åpne dialogen for handlingstrinnet først + Pro Medium Gray Pro mellomgrå + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro lysegrå + Pro Light Pro lys + Resize Windows to Fit Tilpass vindusstørrelse + EXR EXR + Increase Brush Size Øk penselstørrelse + Increase Type Size Øk tekststørrelse + Increase Type Size by 10 Øk tekststørrelse med 10 + Indent first line Innrykk første linje + Indent left margin Innrykk venstremarg + Indent right margin Innrykk høyremarg + Indexed Indeksert + Infinite Uendelig + Information Informasjon + Initial Direction Startretning + Ink Outlines Blekkonturer + Inner Bevel Indre skråkant + Input Inndata + Input Levels Inndatanivåer + Input Mode Inndatamodus + Install Plug-in Installer programtillegg + Intensity Intensitet + Intent Gjengivelsesmetode + Interpret Footage Tolk opptak + Intersect with selection (⇧⌥) Snitt med utvalget (⇧⌥) + Interval Intervall + Invalid panel layout; restored the default dock Ugyldig paneloppsett; gjenopprettet standarddokken + Invert Mask Inverter maske + Invert Selection Inverter utvalg + Iris Blur Irissløring + Ivy Eføy + Jag Takking + Join the ArtCraft Discord Bli med i ArtCraft-Discord + Join the ArtCraft Discord ({url}) Bli med i ArtCraft-Discord ({url}) + Keeps the current texture when switching presets. Beholder gjeldende tekstur når du bytter forvalg. + Kernel Kjerne + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kniping: metrisk, optisk eller en verdi i 1/1000 em (Alt+←/→ ved innsettingspunktet) + Keyboard Tastatur + Lab Lab + Landscape, 10 x 8 Liggende, 10 x 8 + Landscape, 6 x 4 Liggende, 6 x 4 + Landscape, 7 x 5 Liggende, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin utvidet-A + Latin Extended-B Latin utvidet-B + Layer Comps to Files Lagkomposisjoner til filer + Layer Content Options Alternativer for laginnhold + Layer Count Antall lag + Layer From Background Lag fra bakgrunn + Layer Knocks Out Drop Shadow Laget slår ut slagskyggen + Layer Mask Hides Effects Lagmaske skjuler effekter + Layer Properties Lagegenskaper + Layer Styles Lagstiler + Layer from Background Lag fra bakgrunn + Layers to Files Lag til filer + Layout Oppsett + Leading (set to the font size × auto-leading when Auto) Linjeavstand (satt til skriftstørrelse × automatisk linjeavstand når Auto) + Leaf Color Bladfarge + Learn More Les mer + Leaves Amount Bladmengde + Leaves Color Bladfarge + Leaves Size Bladstørrelse + Left Diagonal Venstre diagonal + Legacy Eldre + Legal Legal + Lens Blur Objektivsløring + Lens Correction Objektivkorreksjon + Lens Flare Objektivrefleks + Less Mindre + Letter Bokstav + Level Based Nivåbasert + Library Bibliotek + Light Dark Balance Lys/mørk-balanse + Light Direction Lysretning + Light Intensity Lysintensitet + Light Rough Lys ru + Light Type Lystype + Light X Lys X + Light Y Lys Y + Light Z Lys Z + Lighten Grout Lysne fuge + Lighten Only Bare lysere + Lighting Belysning + Lighting Effects Lyseffekter + Linear Dodge Lineær avskjerming + Linear Height Lineær høyde + Linear-srgb Lineær sRGB + Link Koble + Liquify Gjør flytende + List Liste + List view Listevisning + Live Preview Direkte forhåndsvisning + Load 3D LUT Last inn 3D LUT + Load Files into Stack Last inn filer i stabel + Load path as a selection Last inn bane som utvalg + Loading… Laster… + Lock All Lås alt + Lock Image Pixels Lås bildepiksler + Lock Position Lås posisjon + Lock Transparent Pixels Lås gjennomsiktige piksler + Locked Låst + Long Lines Lange linjer + Long Strokes Lange strøk + Lorem Ipsum Lorem Ipsum + Lower Senk + Lum Lum + Lut LUT + Magentas Magentatoner + Make Selection from Path Opprett utvalg fra bane + Make Selection… Opprett utvalg… + Make Symmetry Path Opprett symmetribane + Make Work Path… Opprett arbeidsbane… + Make work path from selection Opprett arbeidsbane fra utvalg + Manual Manuell + Map Document Tilordne dokument + Map Layer Tilordne lag + Map Path Tilordne bane + Mask Channel Maskekanal + Mask Incomplete Blocks Masker ufullstendige blokker + Mask None Ingen maske + Masks Masker + Match Color Tilpass farge + Math Symbols Matematiske symboler + Max Offset Maks forskyvning + Max Radius Maks radius + Measurement Scale Måleskala + Measurement Scale Marker Måleskalamarkør + Medium Dots Middels prikker + Medium Gray Mellomgrå + Medium Lines Middels linjer + Medium Strokes Middels strøk + Merge Channels… Slå sammen kanaler… + Merge Spot Channel Slå sammen spotkanal + Merge to HDR Pro Slå sammen til HDR Pro + Metallic Metallisk + Metrics Metrisk + Mezzotint Mezzotint + Middle Midten + Midtone Mellomtone + Monitor Profile Skjermprofil + Monitor profile in use on {display}: {profile} Skjermprofil i bruk på {display}: {profile} + Monitor profile in use: {profile} Skjermprofil i bruk: {profile} + Monitor profile on {display} (main window): {profile} Skjermprofil på {display} (hovedvindu): {profile} + Monitor profile on {display}: {profile} Skjermprofil på {display}: {profile} + Monitor profile: {profile} Skjermprofil: {profile} + Mono Contrast Monokontrast + Monochromatic Monokromatisk + More Mer + Mosaic Mosaikk + Mosaic Tiles Mosaikkfliser + Motion Blur Bevegelsessløring + Mouse Mus + Move Layer Flytt lag + Movie Prime Film-prime + Multiple Flames Along Path Flere flammer langs banen + Multiple Flames One Direction Flere flammer i én retning + Multiple Flames Path Directions Flere flammer etter banens retning + Multiple Flames Various Length Flere flammer med ulik lengde + My Brushes Mine pensler + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Innebygd dra-og-slipp av filer støttes ikke på Wayland ennå. Bruk Fil › Åpne, eller kjør PhotoCraft under XWayland med `WAYLAND_DISPLAY= photocraft`. + Nearest Nærmeste + Neon Glow Neonglød + Neutralize Nøytraliser + Neutrals Nøytrale + New Brush Preset Nytt penselforvalg + New Channel… Ny kanal… + New Guide Ny hjelpelinje + New Guide Layout Nytt hjelpelinjeoppsett + New Spot Channel… Ny spotkanal… + New Video Layer from File Nytt videolag fra fil + New Window Nytt vindu + New Workspace Nytt arbeidsområde + New document Nytt dokument + New document… Nytt dokument… + New document… ⌘N Nytt dokument… ⌘N + Next Neste + No Document Ikke noe dokument + No Image Ikke noe bilde + No Layers Ingen lag + No Results Ingen resultater + No Results Found Fant ingen resultater + No Selection Ikke noe utvalg + No custom shape "{name}": pick one in the options bar or Window › Shapes Ingen egendefinert form «{name}»: velg en i alternativlinjen eller Vindu › Former + No layers Ingen lag + No recent files Ingen nylig brukte filer + No selection Ikke noe utvalg + Noise Color Støyfarge + Noise Color Detail Støyfargedetalj + Noise Luminance Støyluminans + Noise Luminance Detail Støyluminansdetalj + Nothing to Redo Ingenting å gjøre om + Nothing to Undo Ingenting å angre + Nothing to paste: the clipboard holds no image Ingenting å lime inn: utklippstavlen inneholder ikke noe bilde + Number Of Levels Antall nivåer + Numbers Tall + Ocean Ripple Havbølger + Octagon Åttekant + Odd Fields Oddetallsfelt + Oil Paint Oljemaling + On På + One Flame Along Path Én flamme langs banen + Open As Åpne som + Open File Åpne fil + Open Folder Åpne mappe + Open Image Åpne bilde + Open Recent is unavailable on the web Åpne nylig brukte er ikke tilgjengelig på nettet + Opened {url} Åpnet {url} + Opening Åpner + Open… ⌘O Åpne… ⌘O + Operation canceled Operasjonen ble avbrutt + Operation cancelled Operasjonen ble avbrutt + Operation completed Operasjonen fullført + Operation failed Operasjonen mislyktes + Optical Optisk + Original Ratio Opprinnelig forhold + Out Black Ut svart + Out From Center Ut fra midten + Out Of Gamut Utenfor fargeomfanget + Out White Ut hvit + Out of memory Ikke nok minne + Outer Bevel Ytre skråkant + Output Levels Utdatanivåer + Oval Oval + Overlay Edge Overleggskant + Overlay: Rule of Thirds Overlegg: tredjedelsregelen + PSD PSD + Package Pakke + Page Setup… Sideoppsett… + Paint Daubs Maleklatter + Paints from the document's opening state Maler fra dokumentets åpningstilstand + Palette Knife Palettkniv + Panel Panel + Paper Papir + Paper Brightness Papirlysstyrke + Parallel Parallell + Paste Complete Stroke Lim inn hele strøket + Paste Fill Lim inn fyll + Patchwork Lappeteppe + Path Blur Banesløring + Paths to Illustrator Baner til Illustrator + Pattern Fill… Mønsterfyll… + Pattern Type Mønstertype + Pencil Width Blyantbredde + Pentagon Femkant + Percentage Prosentdel + Permission denied Tilgang nektet + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft prøver på nytt; til en lagring lykkes, går innstillingsendringer tapt når det lukkes. + Photomerge Photomerge + Pick Color From Image Velg farge fra bilde + Picture Frame Bilderamme + Pillow Emboss Puterelieff + Pinch Klyp + Pins Nåler + Place Embedded Plasser innebygd + Place Linked Plasser koblet + Place Scale Marker Plasser skalamarkør + Plane Plan + Planes Plan + Plastic Wrap Plastfolie + Platform: {os} {arch} Plattform: {os} {arch} + Please wait… Vent litt… + Point Punkt + Point Curve Punktkurve + Point Curve Blue Punktkurve blå + Point Curve Green Punktkurve grønn + Point Curve Red Punktkurve rød + Pointed Spiss + Pointillize Pointillisme + Polar Coordinates Polarkoordinater + Polar To Rectangular Polar til rektangulær + Policy Cmyk Retningslinje for CMYK + Policy Gray Retningslinje for grå + Policy Rgb Retningslinje for RGB + Pond Ripples Damkrusninger + Portrait, 4 x 6 Stående, 4 x 6 + Portrait, 5 x 7 Stående, 5 x 7 + Postcard Postkort + Poster Plakat + Poster Edges Plakatkanter + Posterization Posterisering + Preferences can't be saved Innstillingene kan ikke lagres + Preferences saved Innstillinger lagret + Preferences were reset: {error} Innstillingene ble tilbakestilt: {error} + Preferences… Innstillinger… + Preserve Behold + Preset Manager Forvalgsbehandling + Press keys Trykk på taster + Preview Image Forhåndsvis bilde + Previous Forrige + Prime105 Prime 105 + Prime35 Prime 35 + Printed to {path} Skrev ut til {path} + Pro (Dark) Pro (mørk) + Pro (Medium Gray) Pro (mellomgrå) + Processing… Behandler… + Profile Profil + Proof Setup… Korrekturoppsett… + Prophoto-compat ProPhoto-kompatibel + Pucker Rynk + Push Left Skyv venstre + Pyramids Pyramider + Quick Actions Hurtighandlinger + Quit Avslutt + Quit PhotoCraft Avslutt PhotoCraft + Quit {app} Avslutt {app} + R G B R G B + R g b R G B + Radial Radial + Radial Blur Radial sløring + Radius X Radius X + Radius Y Radius Y + Ran script {name} Kjørte skriptet {name} + Randomize Length Tilfeldig lengde + Randomize Shapes Tilfeldige former + Rasterize Fill Content Rasteriser fyllinnhold + Rasterize Smart Object Rasteriser smartobjekt + Ratio Forhold + Raw Defaults Raw-standarder + Reading Leser + Ready Klar + Rec2020 Rec. 2020 + Recent Files Nylige filer + Reconstruct All Rekonstruer alle + Recovered {name} Gjenopprettet {name} + Rectangular Selection Rektangulært utvalg + Rectangular To Polar Rektangulær til polar + Red Cyan Rød-cyan + Reds Rødtoner + Reduce Color Noise Reduser fargestøy + Reduce Noise Reduser støy + Reflected Reflektert + Relief Relieff + Relink to File Koble til fil på nytt + Remove All Fjern alle + Remove Jpeg Artifact Fjern JPEG-artefakter + Rename Brush Gi penselen nytt navn + Rename Channel… Gi kanalen nytt navn… + Rename Group Gi gruppen nytt navn + Rename Tool Preset Gi verktøyforvalget nytt navn + Render Video Gjengi video + Rendering… Gjengir… + Reorder Layer Omordne lag + Replace Color Erstatt farge + Replace Contents Erstatt innhold + Replace Footage Erstatt opptak + Report a Bug Rapporter en feil + Report an Issue Rapporter et problem + Reset Alignment Tilbakestill justering + Reset All Tilbakestill alle + Reset Preferences Tilbakestill innstillinger + Resolve Missing Fonts Løs problemer med manglende skrifter + Restart Start på nytt + Restart Required Omstart kreves + Resume Fortsett + Retry Prøv igjen + Rgb RGB + Ridges Rygger + Right Diagonal Høyre diagonal + Ripple Krusning + Ripple Magnitude Krusningsstyrke + Ripple Size Krusningsstørrelse + Rope Tau + Roses Roser + Rough Pastels Grove pasteller + Row Order Radrekkefølge + Rulers (⌘R) Linjaler (⌘R) + Sample Size Prøvestørrelse + Sampled Colors Prøvefarger + Sandstone Sandstein + Sat Met + Save As Lagre som + Save Changes Lagre endringer + Save File Lagre fil + Save Image Lagre bilde + Save Path Lagre bane + Save Pixel Aspect Ratio Lagre pikselforhold + Save a Copy Lagre en kopi + Save before closing? Lagre før lukking? + Save for Web (Legacy) Lagre for web (eldre) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Lagre arbeidet og start PhotoCraft på nytt for å prøve GPU-akselerasjon igjen. CPU-gjengivelse er fortsatt aktiv i denne økten. + Saved a copy as {path} Lagret en kopi som {path} + Saved {path}; image assets: {n} in {dir} Lagret {path}; bilderessurser: {n} i {dir} + Saving… Lagrer… + Scale Effects Skaler effekter + Scaling Skalering + Scallops Kamskjell + Script Events Manager Behandling av skripthendelser + Scroll menu down Rull menyen ned + Scroll menu up Rull menyen opp + Search commands Søk etter kommandoer + Search commands (⌘K) Søk etter kommandoer (⌘K) + Search commands, tools and panels Søk etter kommandoer, verktøy og paneler + Search menus Søk i menyer + Seed Frø + Select Custom Color… Velg egendefinert farge… + Select Data Points Velg datapunkter + Select Layer Velg lag + Select and Mask Velg og masker + Color Aware Fargebevisst + Decontaminate Colors Fjern fargesmitte + Edge Detection Kantgjenkjenning + Global Refinements Globale forbedringer + High Quality Preview Forhåndsvisning i høy kvalitet + Object Aware Objektbevisst + Object Aware needs a segmentation model, which isn't available yet Objektbevisst krever en segmenteringsmodell, som ikke er tilgjengelig ennå + Real-Time Refinement Forbedring i sanntid + Refine Edge Brush Tool Kantforbedringspensel + Refine Hair Forbedre hår + Refine Hair needs hair detection, which isn't available yet Forbedre hår krever hårgjenkjenning, som ikke er tilgjengelig ennå + Refine Mode Forbedringsmodus + Select and Mask was closed: another layer was selected Velg og masker ble lukket: et annet lag ble valgt + Select and Mask was closed: its document is no longer the active one Velg og masker ble lukket: dokumentet er ikke lenger det aktive + View Mode Visningsmodus + Selected at launch: {backend} Valgt ved oppstart: {backend} + SemiBold Halvfet + Sensitivity Følsomhet + Sent to the printer ({spooler}) Sendt til skriveren ({spooler}) + Sepia Sepia + Services Tjenester + Set Colors Angi farger + Set Measurement Scale Angi måleskala + Sgray sGray + Shadow Skygge + Shadow Amount Skyggemengde + Shadow Intensity Skyggeintensitet + Shadow Radius Skyggeradius + Shadow Tone Skyggetone + Shadows up to: Skygger opptil: + Shadows/Highlights Skygger/høylys + Shallow Grunn + Shape Blur Formsløring + Sharpen Amount Skjerpingsmengde + Sharpen Detail Skjerpingsdetalj + Sharpen Details Skjerpingsdetaljer + Sharpen Masking Skjerpingsmaskering + Sharpen Radius Skjerpingsradius + Shear Skjær + Shine Glans + Short Lines Korte linjer + Short Strokes Korte strøk + Shortcut Hurtigtast + Shortcuts Hurtigtaster + Show All Vis alle + Show Extras Options Alternativer for Vis ekstra + Show Guides Vis hjelpelinjer + Show Guides (⌘;) Vis hjelpelinjer (⌘;) + Show Rulers Vis linjaler + Show the layer's effects (⌥-click: all layers) Vis lagets effekter (⌥-klikk: alle lag) + Simple Enkel + Simulate Paper Simuler papir + Sine Sinus + Sketch Skisse + Skip Hopp over + Slice Options Segmentalternativer + Smart Blur Smart sløring + Smart Sharpen Smart skjerping + Smoothness Jevnhet + Smudge Stick Utgnidningsstift + Snap to Grid Fest til rutenett + Snowflakes Snøfnugg + Soften Mykne + Softness Mykhet + Software adapter Programvareadapter + Solarized Dark Solarized mørk + Solid Color Ensfarget + Solid Front Faces Heltrukne frontflater + Source Channel Kildekanal + Source Invert Inverter kilde + Source Layer Kildelag + Source1 Channel Kilde 1-kanal + Source1 Invert Inverter kilde 1 + Source2 Channel Kilde 2-kanal + Source2 Invert Inverter kilde 2 + Source: set Kilde: angitt + Space Mellomrom + Sparkle Funkling + Spatter Stenk + Speckle Flekker + Speed Hastighet + Spherize Kuleform + Spin Spinn + Spin Blur Rotasjonssløring + Split Channels Del kanaler + Spot Spotfarge + Spray Radius Sprayradius + Sprayed Strokes Spraystrøk + Spread Spredning + Sprinkles Dryss + Square Size Kvadratstørrelse + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Firkanthet + Stagger Forskyvning + Stained Glass Farget glass + Star Stjerne + Stars Stjerner + Start X Start X + Start Y Start Y + Statistics Statistikk + Step {step} ({id}) failed: {error} Trinn {step} ({id}) mislyktes: {error} + Stippled Prikket + Stops Stopp + Strength Styrke + Stretch Strekk + Stroke Color Strekfarge + Stroke Detail Strøkdetalj + Stroke Direction Strøkretning + Stroke Length Strøklengde + Stroke Pressure Strøktrykk + Stroke Size Strekstørrelse + Stroke Width Strekbredde + Stroke path with brush Strek opp banen med pensel + Strokes Strøk + Studio (Dark) Studio (mørk) + Studio (Light) Studio (lys) + Stylization Stilisering + Subtract Shapes at Overlap Trekk fra former ved overlapp + Subtract from selection (⌥) Trekk fra utvalget (⌥) + Sumi-e Sumi-e + Surface Blur Overflatesløring + Switch Foreground and Background Colors Bytt forgrunns- og bakgrunnsfarge + System Info Systeminformasjon + Tablet Tegnebrett + Tabloid Tabloid + Taper Avsmalning + Target Mål + Target Channel 3 Målkanal 3 + Target Channel 4 Målkanal 4 + Target Channel 5 Målkanal 5 + Target Channel 6 Målkanal 6 + Target Channel 7 Målkanal 7 + Target Channel 8 Målkanal 8 + Target Channel 9 Målkanal 9 + Target Composite Channel Målkomposittkanal + Target X Mål X + Target Y Mål Y + Teal Orange Blågrønn/oransje + Technology Previews… Teknologiforhåndsvisninger… + Temporary Midlertidig + Text Layer Tekstlag + Texture Coverage Teksturdekning + Texturizer Teksturering + Thaw Mask Tin maske + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lerretet vises som sRGB. Velg en skjermprofil i Rediger › Fargeinnstillinger. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lerretet på {display} vises som sRGB. Velg en skjermprofil i Rediger › Fargeinnstillinger. + The current gradient (pick one in Window › Gradients) Gjeldende gradient (velg en i Vindu › Gradienter) + The document does not have an embedded colour profile. Working space: {working}. Dokumentet har ingen innebygd fargeprofil. Arbeidsfargerom: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokumentet har en innebygd fargeprofil som ikke samsvarer med arbeidsfargerommet.\nInnebygd: {embedded}\nArbeidsfargerom: {working} + The file could not be opened. Filen kunne ikke åpnes. + The file could not be saved. Filen kunne ikke lagres. + Thickness Tykkelse + This Layer: Dette laget: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjektet må rasteriseres før du fortsetter. Innholdet kan da ikke lenger redigeres. Rasterisere smartobjektet? + This adjustment ({key}) is kept as imported and can't be edited yet. Denne justeringen ({key}) beholdes som importert og kan ikke redigeres ennå. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette fyllaget må rasteriseres før du fortsetter. Fyllinnholdet kan da ikke lenger redigeres. Rasterisere laget? + This layer must be rasterized before proceeding. Dette laget må rasteriseres før du fortsetter. + This session: {reason} Denne økten: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Dette formlaget må rasteriseres før du fortsetter. Det kan da ikke lenger redigeres som form. Rasterisere formen? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Dette tekstlaget må rasteriseres før du fortsetter. Teksten kan da ikke lenger redigeres. Rasterisere teksten? + Tile Size Flisstørrelse + Tiles Fliser + Tilt-Shift Tilt-shift + Tint Color Tonefarge + Tiny Lens Bitteliten linse + Title Tittel + To Til + To Center Til midten + Toggle Layer Mask Overlay Slå overlegg for lagmaske av/på + Toggle the Brush Settings panel Vis/skjul panelet Penselinnstillinger + Toggle the Brush Settings panel (F5) Vis/skjul panelet Penselinnstillinger (F5) + Tool Options Verktøyalternativer + Trace Contour Tegn kontur + Tracking (1/1000 em) Tegnavstand (1/1000 em) + Transformation Transformering + Transition Overgang + Transparency Shapes Layer Gjennomsiktighet former laget + Trap Overlapping + Tree Tre + Trim Fjern kanter + Turbulent Turbulent + Twirl Virvel + Twirl Clockwise Virvel med klokken + Twirl Counterclockwise Virvel mot klokken + Type Options Tekstalternativer + Unaltered Uendret + Undefined Areas Udefinerte områder + Underlying Layer: Underliggende lag: + Underpainting Undermaling + Underwater Under vann + Ungroup Opphev gruppering + Uniform Ensartet + Unite Shapes at Overlap Slå sammen former ved overlapp + Unlink Koble fra + Unlock Lås opp + Unlocked Låst opp + Unsaved Changes Ulagrede endringer + Unsharp Mask Uskarp maske + Unsupported file format Filformatet støttes ikke + Unsupported format Formatet støttes ikke + Up Opp + Update Oppdater + Updates Oppdateringer + Upper Øvre + Use Custom Color Bruk egendefinert farge + 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 + Vertical Distortion Loddrett forvrengning + Vertical Only Bare loddrett + Vertical Type Tool Loddrett tekstverktøy + Vertical scale Loddrett skalering + Vertical type Loddrett tekst + Video Frames to Layers Videorammer til lag + Vignette Amount Vignetteringsmengde + Vignette Feather Vignettering: myk kant + Vignette Highlights Vignettering: høylys + Vignette Midpoint Vignettering: midtpunkt + Vignette Roundness Vignettering: rundhet + Vignette Style Vignetteringsstil + Vine Color Rankefarge + Vine With Flowers Ranke med blomster + Vine With Leaves Ranke med blader + Violent Voldsom + W: B: + WIA Support WIA-støtte + Warm Varm + Warming Filter (85) Oppvarmingsfilter (85) + Warming L B A Oppvarmingsfilter (LBA) + Warming81 Oppvarmingsfilter (81) + Warming85 Oppvarmingsfilter (85) + Warmth Varme + Warning Advarsel + Warp Text Fordrei tekst + Watercolor Akvarell + Watercolour look: lighter interior, darker rims. Akvarellutseende: lysere indre, mørkere kanter. + Wavelength Max Maks bølgelengde + Wavelength Min Min bølgelengde + Waves Bølger + Web Large Web stor + Web Minimum Web minimum + Web Most Common Web vanligst + WebP (lossless) WebP (tapsfri) + Wetness Våthet + White Clip Hvitklipping + White Intensity Hvitintensitet + White Is High Hvit er høyt + Whole Word Hele ordet + Wide Blurry Bred uskarp + Wide Sharp Bred skarp + Wind Vind + Window renderer: {renderer} Vindusgjengiver: {renderer} + Working Arbeidsfargerom + Working Cmyk Arbeids-CMYK + Working Gray Arbeidsgrå + Working Rgb Arbeids-RGB + Working-cmyk Arbeids-CMYK + Workspaces Arbeidsområder + Wrap Linjeskift + Yellows Gultoner + ZigZag Sikksakk + Zigzag Sikksakk + Zoom In (hold, drag to scrub) Zoom inn (hold, dra for å skrubbe) + Zoom Out (hold) Zoom ut (hold) + auto auto + blue blå + brushes pensler + fallback reserve + gradients gradienter + gray grå + green grønn + layer mask lagmaske + manual manuell + none ingen + off av + on på + orange oransje + previous reading kept; the last re-read failed: {reason} forrige lesing beholdt; siste nye lesing mislyktes: {reason} + red rød + unknown ukjent + unknown type ukjent type + vector mask vektormaske + violet fiolett + yellow gul + {index} of {total} {index} av {total} + {label} is not available: {reason} {label} er ikke tilgjengelig: {reason} + {message} (notes: {n}; {warning}) {message} (merknader: {n}; {warning}) + {shortcut} assigned. {shortcut} er tilordnet. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} er allerede i bruk av {commands} og fjernes derfra når du klikker OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ naviger ↵ kjør esc lukk + ✓ Color Indicates Masked Areas ✓ Farge angir maskerte områder + ✓ Color Indicates Selected Areas ✓ Farge angir valgte områder +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Fjern +@id edit.purge.undo Angre +@id edit.stroke Strek… +@id edit.transform.scale Skaler +@id file.close Lukk +@id filter.sharpen.sharpen Skjerp +@id layer.layerMask.apply Bruk +@id select.all Velg alt +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importerte {n} pensel fra {file}|Importerte {n} pensler fra {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importerte {n} gradient fra {file}|Importerte {n} gradienter fra {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importerte {n} mønster fra {file}|Importerte {n} mønstre fra {file} +cameraRaw Balance Balanse +cameraRaw Clarity Klarhet +cameraRaw Color Farge +cameraRaw Color Mixer Fargemikser +cameraRaw Contrast Kontrast +cameraRaw Curve Kurve +cameraRaw Dehaze Fjern dis +cameraRaw Detail Detalj +cameraRaw Effects Effekter +cameraRaw Exposure Eksponering +cameraRaw Feather Myk kant +cameraRaw Highlights Høylys +cameraRaw Luminance Luminans +cameraRaw Masking Maskering +cameraRaw Midpoint Midtpunkt +cameraRaw Midtones Mellomtoner +cameraRaw Radius Radius +cameraRaw Roundness Rundhet +cameraRaw Saturation Metning +cameraRaw Shadows Skygger +cameraRaw Size Størrelse +cameraRaw Temperature Temperatur +cameraRaw Texture Tekstur +cameraRaw Tint Fargestikk +cameraRaw Whites Hvittoner +colorRange Invert Inverter +document Orientation Retning +layerLabel Magenta Magenta +layerLabel No Color Ingen farge +layerLabel Red Rød +layers Fill Fyll +layers Fill: Fyll: +navigator Zoom Zoom +shape Appearance Utseende +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatisk avsnittsretning + Left-to-right paragraph direction Venstre-til-høyre avsnittsretning + Right-to-left paragraph direction Høyre-til-venstre avsnittsretning + Not rendered: this adjustment ({key}) is kept as imported. Ikke gjengitt: denne justeringen ({key}) beholdes som importert. +# Print preview image (#8236) + Print preview image Forhåndsvisningsbilde for utskrift +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Fargetonekube + Brightness Cube Lysstyrkekube + Color Wheel Fargehjul + RGB Sliders RGB-glidebrytere + HSB Sliders HSB-glidebrytere + CMYK Sliders CMYK-glidebrytere + Lab Sliders Lab-glidebrytere + Web Color Sliders Webfarge-glidebrytere + Copy Color as HTML Kopier farge som HTML + Copy Color's Hex Code Kopier fargens hex-kode + RGB Spectrum RGB-spektrum + CMYK Spectrum CMYK-spektrum + Grayscale Ramp Gråtonerampe + Current Colors Gjeldende farger + Make Ramp Web Safe Gjør rampen webtrygg +# Art History Brush (#213). + Art History Brush Tool Kunstnerisk historikkpenselverktøy + Tight Short Stram kort + Tight Medium Stram middels + Tight Long Stram lang + Loose Medium Løs middels + Loose Long Løs lang + Dab Klatt + Tight Curl Stram krøll + Loose Curl Løs krøll + Stylized dabs from a history state Stiliserte klatter fra en historikktilstand + Font size presets Forvalg for skriftstørrelse + Leading presets Forvalg for linjeavstand + Tracking presets Forvalg for tegnavstand + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-gjengivelse + Calculating… Beregner… + Click to refresh histogram with uncached data Klikk for å oppdatere histogrammet med data som ikke er bufret + Convert to Shape… Konverter til form… + Cooling Filter (80) Kjølefilter (80) + Cooling Filter (82) Kjølefilter (82) + Cooling Filter (LBB) Kjølefilter (LBB) + Count: Antall: + Delete Hidden Layers Slett skjulte lag + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Dra i bildet for å legge til en begrensning. Shift for vannrett/loddrett, høyreklikk for å slette. + Drawn on the CPU: {reason}. Click for System Info. Tegnet på CPU-en: {reason}. Klikk for systeminformasjon. + Exported {n} files Eksporterte {n} filer + Fit Layer on Screen Tilpass lag til skjermen + Flow: Flyt: + Level: Nivå: + Percentile: Persentil: + Refine Edge… Forbedre kant… + Release Notes Versjonsmerknader + Remember Settings Husk innstillinger + Reset Essentials Tilbakestill grunnleggende + Rounded Rectangle Tool Verktøy for avrundet rektangel + Show Channels in Color Vis kanaler i farger + Show Statistics Vis statistikk + Source: Kilde: + Status Bar Statuslinje + Tile Horizontally Side om side vannrett + Tile Vertically Side om side loddrett + Uncached Refresh Oppdater uten buffer + Update and Restart Oppdater og start på nytt + Warming Filter (81) Oppvarmingsfilter (81) + Warming Filter (LBA) Oppvarmingsfilter (LBA) +@plural {n} document|{n} documents {n} dokument|{n} dokumenter +@plural {n} pixel|{n} pixels {n} piksel|{n} piksler +cameraRaw Before (Y) Før (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 er ikke åpen +cameraRaw Cancel Avbryt +cameraRaw Cancelled opening {name} Avbrøt åpning av {name} +cameraRaw Cancelled {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 +cameraRaw Grain Korn +cameraRaw Highlight Clipping Warning (O) Advarsel for høylysklipping (O) +cameraRaw Highlight Priority Høylysprioritet +cameraRaw Load Settings… Last inn innstillinger… +cameraRaw Load settings from a Camera Raw .xmp file Last inn innstillinger fra en Camera Raw .xmp-fil +cameraRaw Loaded Camera Raw settings from {file} Lastet inn Camera Raw-innstillinger fra {file} +cameraRaw Noise Reduction Støyreduksjon +cameraRaw OK OK +cameraRaw Open Åpne +cameraRaw Opened {name} Åpnet {name} +cameraRaw Paint Overlay Malingsoverlegg +cameraRaw Save Settings… Lagre innstillinger… +cameraRaw Save these settings as a Camera Raw .xmp file Lagre disse innstillingene som en Camera Raw .xmp-fil +cameraRaw Shadow Clipping Warning (U) Advarsel for skyggeklipping (U) +cameraRaw Sharpening Skjerping +cameraRaw Show Lab Color Readouts Vis Lab-fargeverdier +cameraRaw Show Red at 3 o'clock Vis rød kl. 3 +cameraRaw Show Selected Region Vis valgt område +cameraRaw Show Skin Tone Indicator Vis hudtoneindikator +cameraRaw Show Vectorscope Vis vektorskop +cameraRaw Tone Histogram Tonehistogram +cameraRaw Vignetting Vignettering +cameraRaw White Balance: As Shot Hvitbalanse: som fotografert diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv new file mode 100644 index 0000000000..e904d0da92 --- /dev/null +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -0,0 +1,4772 @@ +# Swedish (sv) 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. +# +# Combined from the community Swedish catalogs in #5985 and #9536: each row takes the best of their +# translations, with one term per concept across the UI (lager, markering, mask, gradient, +# fjädring, ombilda, rutnät, stödlinje, rityta …); the contested terms follow a native Swedish +# speaker's answers to a terminology questionnaire (#10021). Clean-room: written from the plain +# meaning of the English labels; no proprietary localisation resource was consulted or copied. +# Labels use sentence case, commands are imperatives and the user is addressed as "du". Product, +# format and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab …) and key caps keep their +# spelling; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). +# Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskalternativ… + Layer Mask Display Options… Visningsalternativ för lagermask… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfärg + Color Indicates: Färgen anger: + Solidity: Täckning: + Ink Characteristics Tryckfärgsegenskaper + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Pappersbredd + Paper Height Pappershöjd + Rows Rader + Number Antal + Margin Marginal + Top: Överkant: + Left: Vänster: + Bottom: Nederkant: + Right: Höger: + Target: Mål: + Selected Artboards Markerade ritytor + Center Columns Centrera kolumner + Clear Existing Guides Rensa befintliga stödlinjer + Invalid guide layout Ogiltig stödlinjelayout + Leave empty for automatic size Lämna tomt för automatisk storlek + 5 Columns, 3 Rows 5 kolumner, 3 rader + 8 Columns 8 kolumner + 12 Columns 12 kolumner + + File Arkiv + Edit Redigera + Image Bild + Layer Lager + Type Text + Select Markera + Selected Markerade + Scope Omfång + Top Left Uppe till vänster + Top Right Uppe till höger + Bottom Left Nere till vänster + Bottom Right Nere till höger + Filter Filter + View Visa + Window Fönster + Help Hjälp + +# File + New… Nytt… + New from Clipboard Nytt från Urklipp + New Nytt + Open… Öppna… + Open Öppna + Open As… Öppna som… + Open EXR Parts… Öppna EXR-delar… + Add beauty precomp from the AOVs Lägg till beauty-förkomposition från AOV:er + Both Båda + Left Vänster + Right Höger + Open Recent Öppna senaste + Clear Recent Files Rensa senaste filer + Remove from Recent Ta bort från senaste + Remove {name} from Recent Ta bort {name} från senaste + Close Stäng + Minimize Minimera + Maximize Maximera + Restore Återställ + Close All Stäng alla + Close Others Stäng övriga + Save Spara + Save As… Spara som… + Save a Copy… Spara en kopia… + {name} can't keep everything in this document {name} kan inte behålla allt i det här dokumentet + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Välj ett format som behåller det, till exempel Photoshop (PSD), eller spara en kopia. + Save… Spara… + Revert Återgå + Import Importera + Import Presets Importera förinställningar + 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… + Package… Paketera… + Save for Web (Legacy)… Spara för webben (äldre)… + Generate Generera + Image Assets Bildresurser + +# Edit + Undo Ångra + Redo Gör om + Fade… Tona… + Cut Klipp ut + Copy Kopiera + Copy Merged Kopiera sammanfogade + Paste Klistra in + Paste Special Klistra in special + Paste Into Klistra in i + Paste Outside Klistra in utanför + Paste in Place Klistra in på plats + Clear Rensa + Search Sök + Search… Sök… + Check Spelling… Kontrollera stavning… + Fill… Fyll… + Stroke… Linje… + Content-Aware Fill… Innehållsmedveten fyllning… + Delete and Fill Selection Ta bort och fyll markering + Define Brush Preset… Definiera penselförinställning… + Define Pattern… Definiera mönster… + Define Custom Shape… Definiera anpassad form… + Purge Rensa + +# Image + Image Size… Bildstorlek… + Image Size Bildstorlek + Canvas Size… Storlek på arbetsyta… + Image Rotation Bildrotation + Rotate 90° Clockwise Rotera 90° medurs + Rotate 90° Counter Clockwise Rotera 90° moturs + Rotate 180° Rotera 180° + Flip Canvas Horizontal Vänd arbetsytan vågrätt + Flip Canvas Vertical Vänd arbetsytan lodrätt + Crop Beskär + Crop and Straighten Photos Beskär och räta upp foton + Trim… Trimma… + Reveal All Visa allt + Hide Selection Dölj markering + Auto Tone Autoton + Auto Contrast Autokontrast + Auto Color Autofärg + Adjustments Justeringar + 16 Bits/Channel 16 bitar/kanal + 32 Bits/Channel 32 bitar/kanal + 8 Bits/Channel 8 bitar/kanal + 8 bit 8 bitar + Integer Heltal + 16 bit 16 bitar + 32 bit (float) 32 bitar (flyttal) + Floating point Flyttal + Mode Läge + Grayscale Gråskala + RGB Color RGB-färg + CMYK Color CMYK-färg + Lab Color Lab-färg + Indexed Color Indexerad färg + Multichannel Flerkanal + Bitmap… Bitmapp… + Bitmap Bitmapp + Duotone… Duoton… + Duotone Duoton + Trap… Överfyllning… + +# Layer + Layer via Copy Lager via kopiering + Layer via Cut Lager via urklippning + Duplicate Layer… Duplicera lager… + Duplicate… Duplicera… + Delete Ta bort + Delete Layer Ta bort lager + Delete Layers Ta bort lager + Rename Layer Byt namn på lager + Layer From Background… Lager från bakgrund… + Layer from Background… Lager från bakgrund… + Background from Layer Bakgrund från lager + Background From Layer Bakgrund från lager + Group Layers Gruppera lager + Group… Grupp… + Ungroup Layers Dela upp lagergrupp + Group from Layers… Grupp från lager… + New Group from Layers… Ny grupp från lager… + Hide Layers Dölj lager + Arrange Ordna + Bring to Front Flytta längst fram + Bring Forward Flytta framåt + Send Backward Flytta bakåt + Send to Back Flytta längst bak + Convert to Smart Object Konvertera till smart objekt + Convert for Smart Filters Konvertera för smarta filter + New Smart Object via Copy Nytt smart objekt via kopiering + Edit Contents Redigera innehåll + Replace Contents… Ersätt innehåll… + Export Contents… Exportera innehåll… + Rasterize Rasterisera + Rasterize Type Rasterisera text + Rasterize Shape Rasterisera form + Rasterize Layer Rasterisera lager + Rasterize Layers Rasterisera lager + New Adjustment Layer Nytt justeringslager + New Fill Layer Nytt fyllningslager + Layer Mask Lagermask + Add Layer Mask Lägg till lagermask + Delete Layer Mask Ta bort lagermask + Enable Layer Mask Aktivera lagermask + Disable Layer Mask Inaktivera lagermask + Apply Layer Mask Tillämpa lagermask + Add Mask to Selection Lägg till mask i markeringen + Subtract Mask from Selection Subtrahera mask från markering + Intersect Mask with Selection Skär mask med markering + Link Layer Mask Länka lagermask + Unlink Layer Mask Ta bort länk till lagermask + Vector Mask Vektormask + Add Vector Mask Lägg till vektormask + Delete Vector Mask Ta bort vektormask + Enable Vector Mask Aktivera vektormask + Link Vector Mask Länka vektormask + Create Clipping Mask Skapa klippmask + Release Clipping Mask Frigör klippmask + New Shape Layer Nytt formlager + New Type Layer Nytt textlager + New Video Layer from File… Nytt videolager från fil… + New Blank Video Layer Nytt tomt videolager + Smart Filters Smarta filter + Delete Smart Filters Ta bort smarta filter + Enable Smart Filters Aktivera smarta filter + Disable Smart Filters Inaktivera smarta filter + Clear Smart Filters Rensa smarta filter + Delete Filter Mask Ta bort filtermask + Disable Filter Mask Inaktivera filtermask + New Layer Based Slice Ny lagerbaserad skiva + Layer Style Lagerstil + Blending Options… Blandningsalternativ… + Blending Options Blandningsalternativ + Drop Shadow… Skugga… + Inner Shadow… Inre skugga… + Outer Glow… Yttre glöd… + Inner Glow… Inre glöd… + Bevel & Emboss… Fasning och relief… + Use Global Light Använd globalt ljus + Overprint Övertryck + Satin… Satin… + Color Overlay… Färgöverlägg… + Gradient Overlay… Gradientöverlägg… + Pattern Overlay… Mönsteröverlägg… + Bevel & Emboss Fasning och relief + Inner Shadow Inre skugga + Inner Glow Inre glöd + Satin Satin + Color Overlay Färgöverlägg + Gradient Overlay Gradientöverlägg + Pattern Overlay Mönsteröverlägg + Outer Glow Yttre glöd + Drop Shadow Skugga + Copy Layer Style Kopiera lagerstil + Paste Layer Style Klistra in lagerstil + Clear Layer Style Rensa lagerstil + Layer Content Options… Alternativ för lagerinnehåll… + Scale Effects… Skala effekter… + New Layer… Nytt lager… + Delete Layer… Ta bort lager… + +# Type + Font Teckensnitt + Font Preview Size Storlek på förhandsgranskning av teckensnitt + Orientation Orientering + Horizontal Vågrät + Vertical Lodrät + Rasterize Type Layer Rasterisera textlager + Create Work Path Skapa arbetsbana + Convert to Shape Konvertera till form + Convert to Paragraph Text Konvertera till stycketext + Convert to Point Text Konvertera till punkttext + Warp Text… Deformera text… + Character Panel Teckenpanel + Paragraph Panel Panelen Stycke + Check Spelling Kontrollera stavning + Language Options Språkalternativ + Find and Replace Text… Sök och ersätt text… + Add to Dictionary Lägg till i ordlistan + No Spelling Suggestions Inga stavningsförslag + Paste Lorem Ipsum Klistra in Lorem Ipsum + Glyphs Panel Glyfpanelen + Type Styles Textformat + Character Styles Teckenformat + Paragraph Styles Styckeformat + Load Default Type Styles Läs in standardtextformat + Save Default Type Styles Spara standardtextformat + Type… Text… + +# Select + 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… +# Select and Mask dialog labels + Smart Radius Smart radie + Shift Edge Flytta kant + Decontaminate Sanera + Output Utdata + Refine Edge Förfina kant + Select Subject Markera motiv + Sky Himmel + Focus Area… Fokusområde… + Subject Motiv + Color Range Färgintervall + Similar Liknande + Grow Väx + Select Similar Colors Markera liknande färger + Load Selection… Läs in markering… + Save Selection… Spara markering… + New Selection Ny markering + Select All Markera allt + Deselect All Avmarkera alla + Deselect Layers Avmarkera lager + Find Layers Sök efter lager + Isolate Layers Isolera lager + Select Linked Layers Markera länkade lager + All Layers Alla lager + Current & Below Aktuellt och underliggande + Current Layer Aktuellt lager + Refine Strokes Förfina penseldrag + Edits Redigeringar + +# Filter + Last Filter Senaste filter + Filter Gallery… Filtergalleri… + Liquify… Gör flytande… + Camera Raw Filter… Camera Raw-filter… + Adaptive Wide Angle… Adaptiv vidvinkel… + Lens Correction… Objektivkorrigering… + Vanishing Point… Fluktpunkt… + Oil Paint… Oljemålning… + Blur Gallery Oskärpegalleri + Field Blur… Fältoskärpa… +# Field Blur pins (#3373) + No pin selected Ingen nål markerad + 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. Klicka i bilden för att lägga till en nål. Dra en nål för att flytta den, eller dess ring för att ändra oskärpan. Delete tar bort den markerade nålen. + 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. Dra handtagen på konturen för att ändra storlek och vrida, kvadraten för att runda hörnen och punkterna inuti för att ange var oskärpan börjar. + 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. Dra nålen för att flytta bandet, eller dess ring för att ändra oskärpan. Dra de heldragna linjerna för att bredda det skarpa bandet, de streckade linjerna för att ange övergången och punkterna för att vrida det. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Dra handtagen på konturen för att ändra storlek och vrida; ringen anger oskärpevinkeln. + Blur Angle Oskärpevinkel + Iris Blur… Irisoskärpa… + Tilt-Shift… Tilt-shift… + Path Blur… Banoskärpa… + Spin Blur… Snurroskärpa… + Auto-Align Layers… Justera lager automatiskt… + Auto-Blend Layers… Blanda lager automatiskt… + Photomerge… Photomerge… + Content-Aware Scale Innehållsmedveten skalning + Adaptive Wide Angle Adaptiv vidvinkel + Apply Image… Tillämpa bild… + Calculations… Beräkningar… + Crystallize… Kristallisera… + Facet Fasett + Fragment Fragment + Mosaic… Mosaik… + Pointillize… Pointillera… + Chalk & Charcoal Krita och kol + Charcoal Kol + Chrome Krom + Conté Crayon Conté-krita + Graphic Pen Grafisk penna + Halftone Pattern Halvtonsmönster + Note Paper Anteckningspapper + Photocopy Fotokopia + Plaster Gips + Reticulation Retikulering + Stamp Stämpel + Torn Edges Trasiga kanter + Water Paper Vattenpapper + Clouds Moln + Difference Clouds Differensmoln + Fibers… Fibrer… + Lens Flare… Motljus… + Picture Frame… Bildram… + Tree… Träd… + Flame… Flamma… + Sharpen Edges Skärpa kanter + Dust & Scratches… Damm och repor… + Noise Brus + Add Noise… Lägg till brus… + Reduce Noise… Minska brus… + Despeckle Ta bort fläckar + Median… Median… + Median Median + Maximum… Maximum… + Maximum Maximum + Minimum… Minimum… + Minimum Minimum + Custom… Anpassad… + High Pass… Högpass… + Emboss… Relief… + Extrude… Extrudera… + Find Edges Hitta kanter + Solarize Solarisera + Tiles… Plattor… + Trace Contour… Spåra kontur… + Wind… Vind… + Offset… Förskjutning… + Pinch… Nyp… + Polar Coordinates… Polära koordinater… + Ripple… Krusning… + Shear… Skjuva… + Spherize… Sfärisera… + Twirl… Virvel… + Wave… Våg… + ZigZag… Sicksack… + Average Medelvärde + Blur More Mer oskärpa + Box Blur… Rutoskärpa… + Gaussian Blur… Gaussisk oskärpa… + Motion Blur… Rörelseoskärpa… + Radial Blur… Radiell oskärpa… + Shape Blur… Formoskärpa… + Smart Blur… Smart oskärpa… + Surface Blur… Ytoskärpa… + Sharpen Skärpa + Sharpen More Skärpa mer + Unsharp Mask… Oskarp mask… + Smart Sharpen… Smart skärpa… + Diffuse… Diffusera… + Mezzotint… Mezzotint… + Color Halftone… Färgraster… + Layer… Lager… + +# View + Proof Setup Korrekturinställningar + Proof Colors Korrekturfärger + Pixel Aspect Ratio Pixelproportioner + Pixel Aspect Ratio Correction Korrigering av pixelproportioner + Zoom In Zooma in + Zoom Out Zooma ut + Fit on Screen Anpassa till skärmen + 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 Utskriftsstorlek + Screen Mode Skärmläge + Standard Screen Mode Standardskärmläge + Full Screen Mode With Menu Bar Helskärmsläge med menyrad + Full Screen Mode Helskärmsläge + Extras Extrafunktioner + Show Visa + Rulers Linjaler + Grid Rutnät + Guides Stödlinjer + Slices Skivor + Snap Fäst + Snap To Fäst mot + Lock Guides Lås stödlinjer + Clear Guides Rensa stödlinjer + New Guide… Ny stödlinje… + Lock Slices Lås skivor + Clear Slices Rensa skivor + New Guide Layout… Ny stödlinjelayout… + New Guides From Shape Nya stödlinjer från form + Canvas rotation Rotation av arbetsytan + Zoom in Zooma in + Zoom out Zooma ut + +# Window + Cascade Överlappande + Tile Sida vid sida + 2-up Horizontal 2 vågrätt + 3-up Horizontal 3 vågrätt + 2-up Vertical 2 lodrätt + 3-up Vertical 3 lodrätt + 3-up Stacked 3 staplade + 4-up 4 fönster + 6-up 6 fönster + Consolidate All to Tabs Samla alla i flikar + Match Zoom Matcha zoom + 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 + Actions Åtgärder + Brush Settings Penselinställningar + Brushes Penslar + Channels Kanaler + Character Tecken + Clone Source Klonkälla + Color Mixer Färgmixer + Glyphs Glyfer + Gradients Gradienter + Histogram Histogram + History Historik + Info Info + Layer Comps Lagerkompositioner + Layers Lager + Measurement Log Mätlogg + Notes Anteckningar + Navigator Navigator + Paragraph Stycke + Paths Banor + Patterns Mönster + Properties Egenskaper + Styles Stilar + Swatches Färgrutor + Timeline Tidslinje + Tool Presets Verktygsförinställningar + Tools Verktyg + Libraries Bibliotek + 3D 3D + Toolbars Verktygsfält + Hide All Dölj alla + +# Help + About PhotoCraft Om PhotoCraft + PhotoCraft Website PhotoCrafts webbplats + PhotoCraft on GitHub PhotoCraft på GitHub + Report an Issue… Rapportera ett problem… + ArtCraft Website ArtCrafts webbplats + Join the ArtCraft Discord… Gå med i ArtCraft på Discord… + System Info… Systeminformation… + +# Tools and options bar + Move Flytta + Marquee Markeringsram + Lasso Lasso + Wand Trollstav + Eyedropper Pipett + Brush Pensel + Block Block + Pencil Blyertspenna + Eraser Suddgummi + Clone Stamp Klonstämpel + Healing Lagning + Blur Oskärpa + Dodge Skuggning + Burn Efterbelys + Gradient Gradient + Shape Form + Pen Penna + Hand Hand + Zoom Zoom + Sampler Provpunkt + Ruler Tool Linjalverktyg + Count Tool Räkneverktyg + Note tool Anteckningsverktyg + Quick Selection Snabbmarkering + Magnetic Lasso Magnetiskt lasso + Mixer Brush Blandningspensel + Mixer Brush Tool Blandningspenselverktyg + Color Replacement Tool Färgersättningsverktyg + History Brush Historikpensel + Art History Brush Konsthistorikpensel + Pattern Stamp Mönsterstämpel + Spot Healing Punktlagning + Red Eye Röda ögon + Patch Lapp + Content-Aware Move Innehållsmedveten flytt + Rotate View Rotera vy + Perspective Crop Perspektivbeskärning + Color Replacement Färgersättning + Dodge/Burn Skuggning/efterbelysning + Foreground Förgrund + 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) + Brush Tool Options Alternativ för penselverktyget + Opacity Opacitet + Flow Flöde + Smoothing Utjämning + Size Storlek + Hardness Hårdhet + Angle: Vinkel: + Roundness: Rundhet: + Spacing Avstånd + Dynamics Dynamik + Pressure Tryck + Tilt Lutning + Rotation Rotation + Align Justera + Aligned Justerad + +# Panels, dialogs and common widgets + OK OK + Cancel Avbryt + Cancel (Esc) Avbryt (Esc) + Manage submitted jobs in the system print queue Hantera skickade jobb i systemets utskriftskö + Done Klart + Apply Tillämpa + Reset Återställ + Reset Section Återställ avsnitt + Reset All to Defaults Återställ alla till standardvärden + Import Shortcuts… Importera kortkommandon… + Load a keyboard shortcut set saved as .kys Läs in en uppsättning kortkommandon som sparats som .kys + Imported {n} shortcuts from {file}. Click OK to keep them. {n} kortkommandon har importerats från {file}. Klicka på OK för att behålla dem. + {n} commands are not in PhotoCraft. {n} kommandon finns inte i PhotoCraft. + Keyboard shortcuts imported Kortkommandon har importerats + 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. Din kortkommandouppsättning {set} används här: {n} kortkommandon skiljer sig från standardvärdena. Redigera › Kortkommandon visar dem; Återställ alla till standardvärden ångrar detta. + Import keyboard shortcuts? Vill du importera kortkommandon? + 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 hittade din uppsättning kortkommandon {set} från en annan bildredigerare på den här datorn: {n} av dess kortkommandon skiljer sig från PhotoCrafts. Vill du importera den? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Redigera › Kortkommandon › Återställ alla till standardvärden ångrar en import. + Don't Import Importera inte + The keyboard shortcut set was not imported: {error} Kortkommandouppsättningen importerades inte: {error} + Defaults restored. Standardvärdena har återställts. + Restore Defaults Återställ standardvärden + Restore original size Återställ ursprunglig storlek + Use Default Använd standard + Dismiss Stäng + Preview Förhandsgranskning + Options Alternativ + Settings… Inställningar… + Rename… Byt namn… + Load… Läs in… + Define… Definiera… + Custom Anpassad + Default Standard + None Ingen + Add Lägg till + Remove Ta bort + Remove Background Ta bort bakgrund + Rename Byt namn + Ignore Ignorera + Ignore All Ignorera alla + Change Ändra + Change All Ändra alla + Next frame Nästa bildruta + Previous frame Föregående bildruta + Play Spela upp + Pause Pausa + Stop Stoppa + Begin recording Börja spela in + Stop playing/recording Stoppa uppspelning/inspelning + Record ● a sequence of edits, then play ▶ it on any document. Spela in ● en sekvens med redigeringar och spela sedan upp ▶ den i valfritt dokument. + Create new action Skapa ny åtgärd + Play selection Spela upp markering + Create new set Skapa ny uppsättning + Export selected measurements (CSV) Exportera markerade mätningar (CSV) + Record Measurements Registrera mätningar + Delete selected measurements Ta bort markerade mätningar + Select Data Points… Välj datapunkter… + Set Measurement Scale… Ange mätskala… + Use Measurement Scale Använd måttskala + +# Preferences dialog + Preferences Inställningar + General… Allmänt… + Interface… Gränssnitt… + File Handling… Filhantering… + Performance… Prestanda… + Cursors… Markörer… + Transparency & Gamut… Genomskinlighet och färgomfång… + Units & Rulers… Enheter och linjaler… + Guides, Grid & Slices… Stödlinjer, rutnät och skivor… + Plug-ins… Plugin-program… + Technology Previews Teknikförhandsgranskningar + Scratch Disks… Arbetsdiskar… + Export Preferences… Exportinställningar… + Keyboard Shortcuts… Kortkommandon… + Menus… Menyer… + Export/Import Presets… Exportera/importera förinställningar… + Additional plugins folder Ytterligare plugin-mapp + Install Plug-in… Installera plugin… + Export Presets Exportera förinställningar + Import brushes (.abr) Importera penslar (.abr) + Migrate Presets Migrera förinställningar + Thumbnail size (smallest: list) Miniatyrstorlek (minsta: lista) + Reset GPU Backend Återställ GPU-backend + Applies at next launch. Gäller från nästa start. + +# Document and canvas status bar + Untitled Namnlös + Untitled-1 Namnlös-1 + Doc: {flat}/{layered} Dok: {flat}/{layered} + Scratch: {size} Arbetsdisk: {size} + Efficiency: 100% Effektivitet: 100 % + Dimensions: Mått: + Width Bredd + Slant Lutning + Height Höjd + Width: Bredd: + Height: Höjd: + Resolution Upplösning + Resolution: Upplösning: + Resolution: {dpi} pixels/inch Upplösning: {dpi} pixlar/tum + {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: Pixlar: + Pixel Length: Pixellängd: + Logical Length: Logisk längd: + Logical Units: Logiska enheter: + Length: Längd: + Interpolation: Interpolering: + Resample Sampla om + Constrain proportions Behåll proportioner + Link width and height Länka bredd och höjd + Maintain aspect ratio Behåll proportioner + Current Size Aktuell storlek + New Size Ny storlek + Fit Anpassa + Percent Procent + Pixels/Inch Pixlar/tum + Pixels/Centimeter Pixlar/centimeter + Relative Relativ + Canvas extension color: Färg för utökad arbetsyta: + Anchor: Ankare: + Both Axes Båda axlarna + Horizontally Vågrätt + Vertically Lodrätt + +# Layers panel + Layers to Files… Lager till filer… + Duplicate Layers… Duplicera lager… + Group name Gruppnamn + New Group Ny grupp + Layer Comp Lagerkomposition + Create new layer Skapa nytt lager + Create new group Skapa ny grupp + Create new channel Skapa ny kanal + Create new path Skapa ny bana + Toggle visibility Visa/dölj + Clipped Urklippt + Group · {n} layer Grupp · {n} lager + Lock: keep these settings when picking another preset Lås: behåll de här inställningarna när du väljer en annan förinställning + Unlock: picking a preset replaces these settings Lås upp: om du väljer en förinställning ersätts de här inställningarna + Show the layer's effects ({key}-click: all layers) Visa lagrets effekter ({key}-klicka: alla lager) + Hide the layer's effects ({key}-click: all layers) Dölj lagrets effekter ({key}-klicka: alla lager) + Expand group ({key}-click: all groups) Expandera grupp ({key}-klicka: alla grupper) + Collapse group ({key}-click: all groups) Fäll ihop grupp ({key}-klicka: alla grupper) + Effects Effekter + Blending Blandning + Blend If Blandningsvillkor + General Blend Allmän blandning + Advanced Blending Avancerad blandning + Opacity: Opacitet: + Fill: Fyllning: + Lock: Lås: + Smart Filter Smart filter + Fill Content Fyllningsinnehåll + Adjustment Preview Förhandsgranskning av justering + +# Channels panel + Channel: Kanal: + Channel Mixer… Kanalblandare… + Output Channel: Utdatakanal: + RGB RGB + Red Röd + Green Grön + Blue Blå + Alpha Alfa + Load channel as selection Läs in kanal som markering + Save selection as channel Spara markeringen som kanal + Delete current channel Ta bort aktuell kanal + Spot Channel Dekorfärgskanal + Mask All Maskera allt + Mask All Objects Maskera alla objekt + +# Color picker and swatches + Color Färg + Color: Färg: + Colors Färger + HSB/HSL HSB/HSL + No Color Ingen färg + Set shape fill type Ange formens fyllningstyp + Set shape stroke type Ange formens linjetyp + Set the text color Ange textfärg + Create warped text Skapa deformerad text + Only Web Colors Endast webbfärger + Overlay Color Överläggsfärg + Overlay Opacity Överläggsopacitet + Glow Color Glödfärg + Web Snap Fäst mot webbfärger + +# Filters and adjustments dialogs + Brightness Ljusstyrka + Contrast Kontrast + Brightness/Contrast… Ljusstyrka/kontrast… + Levels… Nivåer… + Curves… Kurvor… + Curve Kurva + Exposure… Exponering… + Exposure Exponering + Vibrance… Lyster… + Vibrance Lyster + Hue/Saturation… Nyans/mättnad… + Hue Nyans + Saturation Mättnad + Lightness Ljushet + Color Balance… Färgbalans… + Black & White… Svartvitt… + Photo Filter… Fotofilter… + Color Lookup… Färguppslag… + Color Lookup Tables… Färguppslagstabeller… + 3D LUT File 3D LUT-fil + Load 3D LUT… Läs in 3D-LUT… + Invert… Invertera… + Invert Invertera + Invert has no settings. Invertera har inga inställningar. + Threshold… Tröskelvärde… + Threshold Level Tröskelvärdesnivå + Posterize… Posterisera… + Gradient Map… Gradientkarta… + Selective Color… Selektiv färg… + Match Color… Matcha färg… + Replace Color… Ersätt färg… + Equalize Utjämna + HDR Toning… HDR-toning… + Shadows/Highlights… Skuggor/högdagrar… + Amount Mängd + Radius Radie + Radius: Radie: + Tolerance: Tolerans: + Density Täthet + Density: Täthet: + Feather Fjädring + Feather… Fjädring… + Anti-alias Kantutjämning + Anti-Alias Kantutjämning + Contiguous Sammanhängande + Sample All Layers Ta prov på alla lager + Protect Foreground Color Skydda förgrundsfärg + Matte Kantfärg + Matting Kantbehandling + Color Decontaminate… Färgsanering… + Remove Black Matte Ta bort svart kantfärg + Remove White Matte Ta bort vit kantfärg + Defringe… Ta bort kantfärg… + Source Källa + Working CMYK Arbets-CMYK + Working Cyan Plate Cyan arbetsplåt + Working Magenta Plate Magenta arbetsplåt + Working Yellow Plate Gul arbetsplåt + Working Black Plate Svart arbetsplåt + Working Gray Plate Grå arbetsplåt + +# Camera Raw and lens dialogs + Basic Grundläggande + Detail Detalj + HSL/Grayscale HSL/gråskala + Tone Curve Tonkurva + Split Toning Delad toning + Lens Corrections Objektivkorrigeringar + Camera Calibration Kamerakalibrering + Presets Förinställningar + Snapshots Ögonblicksbilder + Temperature Temperatur + Tint Färgton + Exposure: Exponering: + Recovery Återställning + Fill Light Upplättningsljus + Blacks Svarta + Clarity Klarhet + Convert to Grayscale Konvertera till gråskala + Auto Auto + Auto collapse icon panels Fäll ihop ikonpaneler automatiskt + Auto show hidden panels Visa dolda paneler automatiskt + Auto show home screen Visa startskärmen automatiskt + Sharpness Skärpa + Noise Reduction Brusreducering + Color Noise Reduction Brusreducering för färg + Luminance Detail Luminansdetalj + Color Detail Färgdetalj + Luminance Smoothness Luminansutjämning + Vignetting Vinjettering + White Balance: As Shot Vitbalans: som tagen + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw är inte öppet + Dehaze Ta bort dis + Texture Struktur + Focal Length Brännvidd + Make Tillverkare + Model Modell + Lens Objektiv + F F + ISO ISO + Focal Length35mm Brännvidd (35 mm) + Aspect Ratio Bildförhållande + Time Tid + Artist Konstnär + Copyright Upphovsrätt + Enter description… Ange beskrivning… + Adobe RGB (1998) compatible Kompatibel med Adobe RGB (1998) + Gray Gamma 2.2 Grå gamma 2.2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Branschstandard + Web Webb + Internet Standard RGB Internetstandard-RGB + Legacy Macintosh RGB Äldre Macintosh RGB + Monitor RGB Bildskärms-RGB + Windows LCD Windows LCD + Coated CMYK Bestruket CMYK + Uncoated CMYK Obestruket CMYK + Newspaper Tidningspapper + Working Spaces Arbetsfärgrymder + Color Management Policies Principer för färghantering + Assign working space Tilldela arbetsfärgrymd + Leave as is (don't color manage) Lämna som det är (ingen färghantering) + Discard the embedded profile (don't color manage) Ignorera den inbäddade profilen (ingen färghantering) + Embed Color Profile Bädda in färgprofil + Assign Profile… Tilldela profil… + Convert to Profile… Konvertera till profil… + PhotoCraft Manages Colors PhotoCraft hanterar färger + Printer Manages Colors Skrivaren hanterar färger + (print to the printer) (skriv ut på skrivaren) + Absolute Colorimetric Absolut kolorimetrisk + Relative Colorimetric Relativ kolorimetrisk + Perceptual Perceptuell + Black Point Compensation Svartpunktskompensering + Use Legacy Använd äldre + Dither Dithering + No Dither Ingen dithering + Diffusion Diffusion + Pattern Mönster + Fuzziness: Tolerans: + Range: Intervall: + Select: Markera: + Detect Faces Identifiera ansikten + Localize Color Clusters Lokalisera färgkluster + Sample: Prov: + Add to Sample Lägg till i prov + Subtract from Sample Subtrahera från prov + Selections Markeringar + Duplicated selection name Namn på duplicerad markering + Operation Åtgärd + New channel only Endast ny kanal + Masked Areas Maskerade områden + Selected Areas Markerade områden + Spot Color Dekorfärg + Color indicates Färgen anger + +# Adjustments panel + Curves Kurvor + Levels Nivåer + Color Balance Färgbalans + Black & White Svartvitt + Photo Filter Fotofilter + Color Lookup Färguppslag + Threshold Tröskelvärde + Posterize Posterisera + Brightness/Contrast Ljusstyrka/kontrast + Channel Mixer Kanalblandare + Gradient Map Gradientkarta + Selective Color Selektiv färg + +# History panel + History Log… Historiklogg… + Create Snapshot Skapa ögonblicksbild + Delete Snapshot Ta bort ögonblicksbild + New Snapshot Ny ögonblicksbild + Snapshot 1 Ögonblicksbild 1 + Step Forward Stega framåt + Step Backward Stega bakåt + Histories Historik + Nonlinear History Icke-linjär historik + Set New Snapshot as Default Ange ny ögonblicksbild som standard + Automatically Create First Snapshot Skapa första ögonblicksbilden automatiskt + Automatically Create New Snapshot When Saving Skapa ny ögonblicksbild automatiskt vid sparande + Show New Snapshot Dialog by Default Visa dialogrutan Ny ögonblicksbild som standard + Clear History Rensa historik + +# Navigator panel + Panel Options… Panelalternativ… + View menu Menyn Visa + Keep view in sync Håll vyn synkroniserad + {zoom}% {zoom} % + +# Brush Settings panel + Brush name Penselnamn + Brush Preview Förhandsgranskning av pensel + Brush Tip Shape Penselspetsens form + Shape Dynamics Formdynamik + Scattering Spridning + Dual Brush Dubbel pensel + Color Dynamics Färgdynamik + Transfer Överföring + Brush Pose Penselställning + Mouse Controls Muskontroller + Wet Edges Våta kanter + Build-up Uppbyggnad + Protect Texture Skydda struktur + Blend Mode Blandningsläge + Size Jitter Storleksvariation + Minimum Diameter Minsta diameter + Tilt Scale Lutningsskala + Angle Jitter Vinkelvariation + Roundness Jitter Rundhetsvariation + Minimum Roundness Minsta rundhet + Flip X Jitter Variation för vänd X + Flip Y Jitter Variation för vänd Y + Brush Projection Penselprojektion + Control: Kontroll: + Off: the speed of the pointer sets the spacing Av: pekarens hastighet styr avståndet + Fade steps Toningssteg + Pen Pressure Penntryck + Pen Tilt Pennlutning + Stylus Wheel Pennhjul + Initial Direction of Stroke Penseldragets startriktning + Direction of Stroke Penseldragets riktning + Opacity Jitter Opacitetsvariation + Flow Jitter Flödesvariation + Humidity Fuktighet + Load Läs in + Mix Blandning + Auto Clean Brush Rengör penseln automatiskt + Wet Våt + Current Brush Load Aktuell penselladdning + Load Brush Ladda pensel + Clean Brush Rengör pensel + Load the brush after each stroke Ladda penseln efter varje penseldrag + Clean the brush after each stroke Rengör penseln efter varje penseldrag + Rate Hastighet + Airbrush: keeps depositing paint while the pointer rests. Airbrush: fortsätter att lägga på färg medan pekaren står still. + Pulled String Mode Läge med dragen tråd + Catch-up on Stroke End Hinn ikapp vid penseldragets slut + Adjust for Zoom Anpassa efter zoom + Apply Per Tip Tillämpa per spets + Hardness: Hårdhet: + Spacing: Avstånd: + Size: Storlek: + Flip X Vänd X + Flip Y Vänd Y + Brush Tip Penselspets + Diameter: Diameter: + New Layer from Brush Nytt lager från pensel + Rename Brush… Byt namn på pensel… + Delete Brush Ta bort pensel + Import Brushes… Importera penslar… + Import Brushes… (.abr or image) Importera penslar… (.abr eller bild) + Import Brush from Image… Importera pensel från bild… + Gradient Preset Options Alternativ för gradientförinställningar + Import .GRD Importera .GRD + Choose a gradient file (.grd). Välj en gradientfil (.grd). + Brush Name Penselnamn + Brush Stroke Penseldrag + New Brush Preset… Ny penselförinställning… + Capture Brush Size in Preset Inkludera penselstorlek i förinställning + Include Tool Settings Ta med verktygsinställningar + Include Color Ta med färg + Enter a name for the brush preset Ange ett namn på penselförinställningen + A brush preset with this name already exists Det finns redan en penselförinställning med det här namnet + Brush Preset Options Alternativ för penselförinställningar + New Brush Ny pensel + Create new brush from these settings Skapa ny pensel från dessa inställningar + Update the current brush with these settings Uppdatera den aktuella penseln med de här inställningarna + Reset the brush to the defaults Återställ penseln till standardvärdena + No brush presets Inga penselförinställningar + Override Pressure Åsidosätt tryck + Override Tilt Åsidosätt lutning + Override Tilt X Åsidosätt lutning X + Override Tilt Y Åsidosätt lutning Y + Override Rotation Åsidosätt rotation + Apply the pen's tilt and rotation to the tip shape Tillämpa pennans lutning och rotation på spetsformen + +# Tool presets + Preset Type Typ av förinställning + Preset Manager… Förinställningshanterare… + Rename Tool Preset… Byt namn på verktygsförinställning… + Delete Tool Preset Ta bort verktygsförinställning + New Tool Preset Ny verktygsförinställning + Create new tool preset Skapa ny verktygsförinställning + Current Tool Only Endast aktuellt verktyg + No tool presets. Inga verktygsförinställningar. + No presets for the current tool. Inga förinställningar för det aktuella verktyget. + No presets of this type. Inga förinställningar av den här typen. + Preset: Förinställning: + Preset Förinställning + Presets: Förinställningar: + +# Paragraph and character panels + Font Size: Teckenstorlek: + Font size Teckenstorlek + Type: Typ: + Style Stil + Style: Normal Stil: Normal + Tracking Spärrning + Kerning Kerning + Leading Radavstånd + Auto-Leading Automatiskt radavstånd + Scale Horizontally Skala vågrätt + Scale Vertically Skala lodrätt + Baseline Shift Baslinjeförskjutning + Language Språk + Anti-aliasing method Kantutjämningsmetod + Sharp Skarp + Crisp Krispig + Strong Stark + Smooth Mjuk + Mac LCD Mac LCD + Mac Sharp Mac skarp + Mac Crisp Mac krispig + Mac Strong Mac stark + Mac Smooth Mac jämn + Faux Bold Falsk fetstil + Faux Italic Falsk kursiv + All Caps Versaler + Small Caps Kapitäler + Superscript Upphöjd + Subscript Nedsänkt + Underline Understrykning + Strikethrough Genomstruken + Oldstyle Medievalsiffror + Ligature Ligatur + Alternative Alternativ + Hyphenate Avstava + Roman Roman + Italic Kursiv + Bold Fet + Bold Italic Fet kursiv + Light Ljus + Medium Medel + Black Svart + Thin Tunn + Ultra Light Ultratunn + Extra Light Extratunn + Semi Bold Halvfet + Extra Bold Extrafet + Ultra Bold Ultrafet + Condensed Smal + Left align text Vänsterjustera text + Center text Centrera text + Right align text Högerjustera text + Justify last left Marginaljustera med sista raden till vänster + Justify last centered Marginaljustera med sista raden centrerad + Justify last right Marginaljustera med sista raden till höger + Justify all Marginaljustera alla + Indent Left Indrag vänster + Indent Right Indrag höger + First Line Indent Indrag första raden + Space Before Avstånd före + Space After Avstånd efter + Hanging Punctuation Hängande skiljetecken + Roman Hanging Punctuation Hängande interpunktion (roman) + Avoid Widows/Orphans Undvik änkor/horungar + Compose Komponera + Adobe Single-line Composer Adobe Single-line Composer + Adobe Every-line Composer Adobe Every-line Composer + Justification Marginaljustering + Word Spacing Ordavstånd + Letter Spacing Teckenavstånd + Glyph Scaling Glyfskalning + Auto-leading % Automatiskt radavstånd % + Single Word Justification Justering av enstaka ord + Alignment Justering + Indent First Indrag första raden + Center Center + Justify Marginaljustera + +# Glyphs panel + Glyph Glyf + Select a type layer or click into text to insert glyphs Markera ett textlager eller klicka i texten för att infoga glyfer + Entire Font Hela teckensnittet + Alternates for Current Selection Alternativ för aktuell markering + Recently Used Senast använda + Digits Siffror + Symbols Symboler + Punctuation Skiljetecken + OpenType OpenType + Standard Ligatures Standardligaturer + Discretionary Ligatures Valfria ligaturer + Contextual Alternates Kontextuella alternativ + Swash Swash + Titling Alternates Rubrikalternativ + Ornaments Ornament + Fractions Bråk + Ordinals Ordningstal + East Asian Features Östasiatiska funktioner + Middle Eastern Features Mellanösternfunktioner + +# Paths panel + Path Bana + Path: Bana: + Work Path Arbetsbana + Path to Illustrator… Bana till Illustrator… + Make Work Path Skapa arbetsbana + Make Selection Skapa markering + Fill Path Fyll bana + Stroke Path Linje på bana + Clipping Path Urklippsbana + New Path Ny bana + Duplicate Path Duplicera bana + Rename Path… Byt namn på bana… + Delete Path Ta bort bana + Current Path Aktuell bana + Vector Mask from Current Path Vektormask från aktuell bana + Create new shape from the current path Skapa ny form från den aktuella banan + Drag to move the active shape's path or the Work Path Dra för att flytta den aktiva formens bana eller arbetsbanan + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klick: markera ankarpunkt · Dra: flytta ankarpunkt, handtag eller segment · {shift}-klick: lägg till · {alt}-klick: hela delbanan + Draw with the Pen tool (P) or make a work path from a selection. Rita med pennverktyget (P) eller skapa en arbetsbana från en markering. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Källa: inte angiven ({key}-klicka med klonstämpeln) + Clone source {n} ({key}-click with Clone Stamp to set) Klonkälla {n} ({key}-klicka med klonstämpeln för att ange) + {key}-click to set the source {key}-klicka för att ange källan + Option-click to define a source point to clone from Option-klicka för att ange en källpunkt att klona från + Show Overlay Visa överlägg + Auto-Hide Dölj automatiskt + W B + H H + Angle Vinkel + Scale Skala + X X + Y Y + Offset Förskjutning + Reset Transform Återställ ombildning + Rotate Rotera + Set horizontal skew Ange vågrät snedställning + Set vertical skew Ange lodrät snedställning + Flip Horizontal Vänd vågrätt + Flip Vertical Vänd lodrätt + Frame Sync Bildrutesynkronisering + Quick Sync Snabbsynkronisering + Enabled Aktiverad + +# Timeline panel + Create Video Timeline Skapa videotidslinje + Open a document to use the timeline. Öppna ett dokument för att använda tidslinjen. + Video Layers Videolager + Video Video + Audio Ljud + Frame Duration Bildrutevaraktighet + 30 frames @ 30 fps 30 bildrutor vid 30 bild/s + Loop Slinga + Render Video… Rendera video… + Split at Playhead Dela vid spelmarkören + Delete Timeline Ta bort tidslinje + Reload Frame Läs in bildruta igen + Restore Frame Återställ bildruta + Insert Blank Frame Infoga tom bildruta + Duplicate Frame Duplicera bildruta + Delete Frame Ta bort bildruta + Restore All Frames Återställ alla bildrutor + Flatten Frames Into Clips Sammanfoga bildrutor till klipp + Flatten Frames Into Layers Sammanfoga bildrutor till lager + Convert Frames > Video Layers Konvertera bildrutor > videolager + New Video Layer Nytt videolager + New Video Group Ny videogrupp + Interpret Footage… Tolka material… + Replace Footage… Ersätt material… + Show Altered Video Visa ändrad video + Limit Playback Begränsa uppspelning + Enable Timeline Quick Config Aktivera snabbkonfiguration för tidslinje + Set Start Timecode… Ange starttidskod… + Timecode Tidskod + 30 fps 30 bild/s + 25 fps 25 bild/s + 24 fps 24 bild/s + 15 fps 15 bild/s + 10 fps 10 bild/s + Current Time Aktuell tid + Out Ut + In In + Duration Varaktighet + Repeat Upprepa + Play Once Spela upp en gång + Video Cache Videocache + +# Info panel + Info Options… Infoalternativ… + First Color Readout Första färgavläsning + Second Color Readout Andra färgavläsning + Actual Color Faktisk färg + Grayscale Slider Gråskalereglage + Web Color Webbfärg + HSB Color HSB-färg + Total Ink Total färgmängd + Opacity Mask Opacitetsmask + Ruler Linjal + Current Tool Aktuellt verktyg + Document Size Dokumentstorlek + Document Profile Dokumentprofil + Document Dimensions Dokumentmått + Scratch Sizes Arbetsdiskstorlekar + Efficiency Effektivitet + Timing Tidsinställning + Status Information Statusinformation + Saved Multichannel Sparad flerkanal + Mouse Coordinates Muskoordinater + Ruler Units Linjalenheter + Hide Dölj + +# Layer Comps panel + Layer Comps to Files… Lagerkompositioner till filer… + Apply next selected layer comp Tillämpa nästa markerade lagerkomposition + Apply previous selected layer comp Tillämpa föregående markerade lagerkomposition + Update layer comp (visibility, position and appearance) Uppdatera lagerkomposition (synlighet, position och utseende) + New Layer Comp Ny lagerkomposition + Duplicate Layer Comp Duplicera lagerkomposition + Delete layer comp Ta bort lagerkomposition + Rename Layer Comp Byt namn på lagerkomposition + Create new layer comp Skapa ny lagerkomposition + Capture the visibility, position and style of every layer with + below, then switch between versions. Spara synlighet, position och stil för alla lager med + nedan och växla sedan mellan versionerna. + Apply Layer Comp Använd lagerkomposition + Update Layer Comp Uppdatera lagerkomposition + Restore last viewed comp Återställ senast visade komposition + Last Document State Senaste dokumenttillstånd + Visibility Synlighet + Position Position + Appearance (Layer Style) Utseende (lagerstil) + +# Analysis and variables + Analysis Analys + Variables Variabler + Variable Data Sets… Variabla datauppsättningar… + Data Sets… Datauppsättningar… + Data Sets Datauppsättningar + Data Sets as Files… Datauppsättningar som filer… + Apply Data Set… Tillämpa datauppsättning… + Define Definiera + + Add variable + Lägg till variabel + Remove variable Ta bort variabel + Define at least one variable first. Definiera minst en variabel först. + Open a document to define variables. Öppna ett dokument för att definiera variabler. + Variables bind a layer's visibility, text or pixels to a named data slot. Variabler binder ett lagers synlighet, text eller pixlar till en namngiven dataplats. + Pixel Replacement Pixelersättning + Text Replacement Textersättning + Name Namn + Method Metod + Select All Layers Markera alla lager + Fill Fyllning + Keep proportions Behåll proportioner + Interpolation Interpolering + Author: Författare: + Author Författare + Name: Namn: + Caption Bildtext + Caption Credits Bildtext och kreditering + Description Beskrivning + Keywords Nyckelord + Copyright Info URL URL för upphovsrättsinformation + Copyright Status Upphovsrättsstatus + Copyright Notice Upphovsrättsmeddelande + Document Title Dokumenttitel + Copyrighted Upphovsrättsskyddad + Public Domain Allmän egendom + Unknown Okänd + Copyright and Contact Info Upphovsrätt och kontaktinformation + Credit Kreditering + Transmission Reference Överföringsreferens + Urgency Prioritet + High Hög + Normal Normal + Lossless Förlustfri + Low Låg + Category Kategori + Supplemental Categories Tilläggskategorier + Object Name / Title Objektnamn/titel + Date Created Skapandedatum + City Ort + Location Plats + Province / State Provins / delstat + Country Land + Byline Upphovsperson + Byline Title Upphovspersonens titel + Phone Telefon + Email E-post + URLs URL:er + Last Modified Senast ändrad + EXIF EXIF + GPS GPS + Camera Data Kameradata + Camera Model Kameramodell + Camera Make Kameratillverkare + EXIF Version EXIF-version + Exposure Program Exponeringsprogram + Aperture Bländare + Shutter Speed Slutartid + Metering Mode Mätmetod + Light Source Ljuskälla + Flash Blixt + Lens Info Objektivinfo + ISO Speed ISO-känslighet + Subject Distance Motivavstånd + DateTime Original Ursprungligt datum och tid + Create Date Skapandedatum + Modify Date Ändringsdatum + Software Programvara + Raw Data Rådata + Version {version} Version {version} + Advanced Avancerat + Import… Importera… + +# Automate + Automate Automatisera + Batch… Batch… + Create Droplet… Skapa droplet… + Create Droplet Skapa droplet + PDF Presentation… PDF-presentation… + Create Multiple Picture Packages Skapa flera bildpaket + Contact Sheet II Kontaktkarta II + Contact Sheet II… Kontaktkarta II… + Conditional Mode Change… Villkorsstyrt lägesbyte… + Fit Image… Anpassa bild… + Picture Package… Bildpaket… + Web Photo Gallery… Webbfotogalleri… + Merge to HDR Pro… Sammanfoga till HDR Pro… + Load Files into Stack… Läs in filer i stapel… + Scripts Skript + Script Events Manager… Skripthändelsehanterare… + Image Processor… Bildprocessor… + Destination Mål + Choose… Välj… + Select Folder… Välj mapp… + Override Action "Save" Commands Åsidosätt åtgärdens ”Spara”-kommandon + Compatibility Kompatibilitet + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Bädda in ICC-profil + Error File Felfil + Stop For Errors Stoppa vid fel + Log Errors To File Logga fel till fil + Errors Fel + +# Color Settings dialog + Color Settings… Färginställningar… + Settings Inställningar + Settings: Inställningar: + Working Space Arbetsfärgrymd + RGB: RGB: + CMYK: CMYK: + Gray: Grå: + Spot: Dekorfärg: + Advanced Controls Avancerade kontroller + Desaturate Monitor Colors By Avmätta bildskärmsfärger med + Blend RGB Colors Using Gamma 1.0 Blanda RGB-färger med gamma 1,0 + Profile Mismatches: Ask When Opening Profilavvikelser: Fråga vid öppning + Ask When Pasting Fråga vid inklistring + Missing Profiles: Ask When Opening Saknade profiler: fråga vid öppning + Embed profiles Bädda in profiler + Ask Before Saving Saved Embedded Fråga innan inbäddade sparas + Coerce to sRGB Tvinga till 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 + +# Themes + Theme Tema + Switch theme Byt tema + Next Theme Nästa tema + Classic Theme Klassiskt tema + Solarized dark Solarized mörk + Solarized Dark Theme Solarized mörkt tema + Breeze dark Breeze mörkt + Breeze Dark Theme Mörkt Breeze-tema + Breeze light Breeze ljust + Breeze Light Theme Ljust Breeze-tema + Soft dark Mjukt mörkt + Soft light Mjukt ljust + Soft Dark Theme Mjukt mörkt tema + Soft Light Theme Mjukt ljust tema + Studio Theme Studio-tema + Studio Light Theme Studio Light-tema + Adwaita Light Theme Adwaita ljust tema + Adwaita Dark Theme Adwaita mörkt tema + Pro Theme Pro-tema + Pro Medium Gray Theme Pro mellangrått tema + Pro Light Gray Theme Pro ljusgrått tema + Pro Light Theme Pro ljust tema + +# Workspaces + Workspace… Arbetsyta… + Essentials Grundläggande + New Workspace… Ny arbetsyta… + Delete Workspace… Ta bort arbetsyta… + Reset Workspace Återställ arbetsyta + Reset Tool Återställ verktyg + Reset All Tools Återställ alla verktyg + Reset all tools to their default settings? Vill du återställa alla verktyg till standardinställningarna? + Lock Workspace Lås arbetsyta + Photography Fotografi + Graphic and web Grafik och webb + Painting Målning + Motion Rörelse + Minimalistic Minimalistisk + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panelernas placering sparas i arbetsytan. Kortkommandon, menyer och verktygsfält är valfria. + Keyboard Shortcuts Kortkommandon + Keyboard Shortcuts and Menus Kortkommandon och menyer + Menus Menyer + More panels Fler paneler + More documents Fler dokument + Collapse All Groups Komprimera alla grupper + Expand All Groups Expandera alla grupper + Collapse Komprimera + Expand Expandera + Collapse Panel Group Komprimera panelgrupp + Expand Panel Group Expandera panelgrupp + Close Tab Group Stäng flikgrupp + +# New Document dialog + PRESET DETAILS DETALJER FÖR FÖRINSTÄLLNING + BLANK DOCUMENT PRESETS ({n}) FÖRINSTÄLLNINGAR FÖR TOMMA DOKUMENT ({n}) + Create a new document or open an existing file. Skapa ett nytt dokument eller öppna en befintlig fil. + Drop an image or PSD anywhere to open it. Släpp en bild eller PSD var som helst för att öppna den. + Use File › Open, or paste a copied image with {paste}. Använd Arkiv › Öppna, eller klistra in en kopierad bild med {paste}. + Native file drag-and-drop is unavailable Inbyggt dra och släpp av filer är inte tillgängligt + 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}. Inbyggt dra och släpp av filer stöds inte på Wayland ännu. Använd Arkiv › Öppna, eller kopiera bilden i filhanteraren och klistra in den med {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Starta PhotoCraft under XWayland för att kunna släppa filer: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Eller ställ in Inställningar › Prestanda › Linux-visningsserver på X11: då startar PhotoCraft alltid under XWayland. + Edit › Paste Redigera › Klistra in + Photo Foto + Print Skriv ut + Art & Illustration Konst och illustration + Mobile Mobil + Film & Video Film och video + Default PhotoCraft Size Standardstorlek för PhotoCraft + Clipboard Urklipp + Photo (2 × 3 in) Foto (2 × 3 tum) + Photo (4 × 6 in) Foto (4 × 6 tum) + Photo (5 × 7 in) Foto (5 × 7 tum) + Photo (8 × 10 in) Foto (8 × 10 tum) + Landscape Liggande + Portrait Stående + Recent Items Senaste objekt + Save Preset… Spara förinställning… + Delete Preset… Ta bort förinställning… + Preset Name: Namn på förinställning: + Width and Height Bredd och höjd + Background Contents Bakgrundsinnehåll + Custom background color Anpassad bakgrundsfärg + White Vit + Background Color Bakgrundsfärg + Transparent Genomskinlig + Color Mode Färgläge + Depth Djup + Color Profile Färgprofil + Create Skapa + Document Dokument + Units Enheter + Pixels Pixlar + Inches Tum + Centimeters Centimeter + Millimeters Millimeter + Points Punkter + Picas Pica + Columns Kolumner + +# Placeholders, tooltips, status and keyboard hints + {name} options Alternativ för {name} + Align {what} Justera {what} + Untagged {mode} Otaggad {mode} + Invalid {mode} profile Ogiltig {mode}-profil + Unnamed {mode} profile Namnlös {mode}-profil + Constraints ({n}) Begränsningar ({n}) + Count: {n} Antal: {n} + Click to add a point · drag off or {key}-click or Delete to remove Klicka för att lägga till en punkt · dra bort, {key}-klicka eller tryck på Delete för att ta bort + Click to apply, {key}-click to clear overrides, double-click for Style Options Klicka för att tillämpa, {key}-klicka för att rensa åsidosättningar, dubbelklicka för stilalternativ + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klick: hörn · Dra: mjuk · Klicka på första punkten: stäng · {key} slutför · Esc avbryt + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klick: hörn · Dra: mjuk · {alt}-dra: bryt handtag · Klicka eller dra första punkten: stäng · {key} slutför · Esc avbryt + Commit Perspective Warp ({key}) Verkställ perspektivdeformering ({key}) + Commit Puppet Warp ({key}) Verkställ marionettdeformering ({key}) + Commit any current edits ({key}) Verkställ aktuella ändringar ({key}) + Commit transform ({key}) Verkställ ombildning ({key}) + Commit warp ({key}) Verkställ deformering ({key}) + Cancel any current edits (Esc) Avbryt alla pågående ändringar (Esc) + Cancel transform (Esc) Avbryt ombildning (Esc) + Cancel warp (Esc) Avbryt deformering (Esc) + Do you want to save the changes you made to “{name}” before closing? Vill du spara ändringarna du gjort i ”{name}” innan du stänger? + 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 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. + Drag the arrow to set the angle, the dots to set the roundness Dra pilen för att ange vinkeln och punkterna för att ange rundheten + Create new pattern from the selection Skapa nytt mönster från markeringen + Create new gradient Skapa ny gradient + Create new count group Skapa ny räknegrupp + Search commands, tools and panels… Sök kommandon, verktyg och paneler… + Search Brushes Sök penslar + Search fonts Sök teckensnitt + No matching commands Inga matchande kommandon + No document Inget dokument + Pixel Layer Pixellager + Type Layer Textlager + Shape Layer Formlager + Group Layer Grupplager + Fill Layer Fyllningslager + Smart Object Layer Lager med smart objekt + Adjustment Layer Justeringslager + {name} Properties Egenskaper för {name} + 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. + Rectangular Marquee Tool Verktyget Rektangulär markeringsram + Elliptical Marquee Tool Verktyget Elliptisk markeringsram + Single Row Marquee Tool Verktyget Enradsmarkeringsram + Single Column Marquee Tool Verktyget Enkolumnsmarkeringsram + Brush Tool Penselverktyg + Pencil Tool Blyertspennverktyg + Eraser Tool Suddgummiverktyg + Background Eraser Tool Bakgrundssuddgummiverktyg + Magic Eraser Tool Verktyget Magiskt suddgummi + Eyedropper Tool Pipettverktyg + Note Tool Anteckningsverktyg + Lasso Tool Lassoverktyg + Polygonal Lasso Tool Verktyget Polygonlasso + Magic Wand Tool Trollstavsverktyg + Crop Tool Beskärningsverktyg + Slice Tool Skivverktyg + Slice Select Tool Skivmarkeringsverktyg + Gradient Tool Gradientverktyg + Paint Bucket Tool Färgpytsverktyg + Horizontal Type Tool Verktyget Vågrät text + Horizontal Type Mask Tool Verktyget Vågrät textmask + Vertical Type Mask Tool Verktyget Lodrät textmask + Hand Tool Handverktyg + Zoom Tool Zoomverktyg + Spot Healing Brush Tool Verktyget Punktlagningspensel + Healing Brush Tool Lagningspenselverktyg + Clone Stamp Tool Klonstämpelverktyg + History Brush Tool Historikpenselverktyg + Blur Tool Oskärpeverktyg + Sharpen Tool Skärpeverktyg + Smudge Tool Verktyget Smeta ut + Dodge Tool Skuggningsverktyg + Burn Tool Efterbelysningsverktyg + Sponge Tool Svampverktyg + Quick Selection Tool Snabbmarkeringsverktyg + Object Selection Tool Objektmarkeringsverktyg + Pen Tool Pennverktyg + Convert Point Tool Verktyget Konvertera punkt + Path Selection Tool Banmarkeringsverktyg + Direct Selection Tool Direktmarkeringsverktyg + Add Anchor Point Tool Verktyget Lägg till ankarpunkt + Delete Anchor Point Tool Verktyget Ta bort ankarpunkt + Click a segment to add an anchor point · {alt}-click an anchor to delete it Klicka på ett segment för att lägga till en ankarpunkt · {alt}-klicka på en ankarpunkt för att ta bort den + Click an anchor point to delete it · {alt}-click a segment to add one Klicka på en ankarpunkt för att ta bort den · {alt}-klicka på ett segment för att lägga till en + Rectangle Tool Rektangelverktyg + Ellipse Tool Ellipsverktyg + Triangle Tool Triangelverktyg + Polygon Tool Polygonverktyg + Line Tool Linjeverktyg + Custom Shape Tool Verktyget Anpassad form + Pixel Pixel + Group Grupp + Adjustment Justering + Smart Object Smart objekt + 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 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 + {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 + Fill Screen Fyll skärmen + Scrubby Zoom Dragzoom + Auto Erase Radera automatiskt + Color Adaptation Färganpassning + Preserve Transparency Bevara genomskinlighet + Reverse Omvänd + Align with layer Justera efter lager + Join us on Discord Gå med i vår Discord + PhotoCraft website PhotoCrafts webbplats + ArtCraft ArtCraft + GitHub GitHub + No character style Inget teckenformat + No author Ingen författare + Not in Dictionary: Finns inte i ordlistan: + Suggestions: Förslag: + Spell check complete. Stavningskontrollen är klar. + Check All Layers Kontrollera alla lager + Select a type layer to edit its character and paragraph settings. Markera ett textlager för att redigera dess tecken- och styckeinställningar. + Select text to apply a character style Markera text för att använda ett teckenformat + Redefine style by current selection Omdefiniera format efter aktuell markering + Duplicate style Duplicera stil + Delete style Ta bort stil + Style Options Stilalternativ + Style Options… Stilalternativ… + Close Style Options Stäng stilalternativ + Appearance Utseende + Text Position: Textposition: + Text color Textfärg + Uses the foreground and background colours. 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 + No Nej + Yes Ja + These settings aren't available in PhotoCraft yet. De här inställningarna finns inte i PhotoCraft ännu. + Shortcut removed. Kortkommandot har tagits bort. + All shortcuts reset to PhotoCraft defaults. Alla kortkommandon har återställts till PhotoCrafts standardvärden. + Add Shortcut Lägg till kortkommando + Delete Shortcut Ta bort kortkommando + Press keys… Tryck på tangenter… + shortcut kortkommando + command or shortcut kommando eller kortkommando + current aktuell + image file path sökväg till bildfil + modified ändrad + 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. + Saved Sparat + Reveal in Finder Visa i Finder + Show in Explorer Visa i Utforskaren + Show in Folder Visa i mapp + Before (Y) Före (Y) + Save Settings… Spara inställningar… + Load Settings… Läs in inställningar… + Save these settings as a Camera Raw .xmp file Spara de här inställningarna som en .xmp-fil för Camera Raw + Load settings from a Camera Raw .xmp file Läs in inställningar från en Camera Raw .xmp-fil + Loaded Camera Raw settings from {file} Läste in Camera Raw-inställningar från {file} + Loaded Camera Raw settings from {file} ({n} not supported) Läste in Camera Raw-inställningar från {file} ({n} stöds inte) + After (X) Efter (X) + Original Original + Current Aktuell + {added}, {removed} subtracted {added}, {removed} subtraherade + Filter: Filter: + +# Transform / warp overlays + Free Transform Ombilda fritt + Free Transform a Copy Ombilda en kopia fritt + Transform Ombilda + Transform Selection Ombilda markering + Switch between free transform and warp modes Växla mellan fri ombildning och deformering + Perspective Perspektiv + Skew Snedställ + Distort Förvräng + Rotate: Rotera: + Flip Vänd + Warp: Deformering: + Warp Deformera + Bend: Böjning: + Reference point location Referenspunktens placering + Toggle reference point Växla referenspunkt + Free Fri + Arc Båge + Arch Valv + Bulge Utbuktning + Shell Lower Nedre skal + Shell Upper Övre skal + Flag Flagga + Fish Fisk + Wave Våg + Rise Stigning + Fisheye Fisköga + Inflate Blås upp + Twist Vridning + Squeeze Kläm ihop + Corner radius Hörnradie + Split Warp Crosswise Dela deformering korsvis + Split Warp Horizontally Dela deformering vågrätt + Split Warp Vertically Dela deformering lodrätt + Remove Warp Split Ta bort deformeringsdelning + Puppet Warp Marionettdeformering + Puppet Warp is not active Marionettdeformering är inte aktiv + Perspective Warp Perspektivdeformering + Perspective Warp is not active Perspektivdeformering är inte aktiv + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivdeformering: rita plan längs bildens perspektiv och växla sedan till Deformera + Remove Planes Ta bort plan + Edit Pins Redigera nålar + Add Pins Lägg till nålar + Delete Pins Ta bort nålar + Remove All Pins Ta bort alla nålar + Remove Tool Borttagningsverktyg + Pin Depth: Nåldjup: + Bring the selected pin forward Flytta fram den markerade nålen + Send the selected pin backward Flytta den markerade nålen bakåt + Rotate Auto Rotera automatiskt + Rotate Fixed Fast rotation + Adaptive Wide Angle isn't open Adaptiv vidvinkel är inte öppen + Liquify is not open Gör flytande är inte öppet + +# Vanishing Point + Create Plane Skapa plan + Edit Plane Redigera plan + Select Plane Välj plan + Measure Mät + Heal Laga + Off Av + Symmetry Off Symmetri av + Heal Off Lagning av + Heal On Lagning på + Heal Auto Automatisk lagning + Blend Blanda + Brush Size Penselstorlek + Heal Color Lagningsfärg + +# Misc dialog strings + Mask Mask + Channel Kanal + Rectangle Rektangel + Subtract Subtrahera + Multiply Multiplicera + Divide Dividera + Result Resultat + New Document Nytt dokument + New Document With Mask Nytt dokument med mask + New Layer With Mask Nytt lager med mask + New Layer Nytt lager + Preset Name Namn på förinställning + No saved presets yet. Inga sparade förinställningar än. + New Channel Ny kanal + Selection Markering + Save Selection Spara markering + Load Selection Läs in markering + Duplicate Duplicera + Fast Snabb + Accurate Exakt + Add to Lägg till i + Subtract from Subtrahera från + Intersect with Skär med + +# Blend modes + Pass Through Genomsläpp + Dissolve Upplös + Darken Mörkare + Color Burn Färgbränning + Linear Burn Linjär efterbelysning + Darker Color Mörkare färg + Lighten Ljusare + Screen Skärm + Color Dodge Färgskuggning + Linear Dodge (Add) Linjär skugga (lägg till) + Lighter Color Ljusare färg + Overlay Överlägg + Soft Light Mjukt ljus + Hard Light Hårt ljus + Vivid Light Intensivt ljus + Linear Light Linjärt ljus + Pin Light Punktljus + Hard Mix Hård blandning + Difference Differens + Exclusion Uteslutning + Luminosity Luminositet + +# Saved views and other bits + Capture Comp Spara komposition + No Layer Comps Inga lagerkompositioner + +# Plurals +@plural {n} item|{n} items {n} objekt|{n} objekt +@plural {n} Layer|{n} Layers {n} lager|{n} lager +@plural {n} layer|{n} layers {n} lager|{n} lager +@plural Group · {n} layer|Group · {n} layers Grupp · {n} lager|Grupp · {n} lager +@plural {n} sample|{n} samples {n} prov|{n} prov + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… Alternativ för 32-bitars förhandsgranskning… + 90° Clockwise 90° medurs + 90° Counter Clockwise 90° moturs + AI Integrations… AI-integrationer… + Again Igen + Transform Again on a Copy Ombilda igen på en kopia + Anamorphic 2:1 (2) Anamorfisk 2:1 (2) + Arbitrary… Godtyckligt… + Artboard Guides Stödlinjer för ritytor + Artboard from Group… Rityta från grupp… + Artboard from Layers… Rityta från lager… + Artboards to Files… Ritytor till filer… + Artboards to PDF… Ritytor till PDF… + Artboard… Rityta… + Border… Ram… + Bottom Edges Nederkanter + Browse… Bläddra… + Source Files Källfiler + Use: Använd: + Files Filer + Folder Mapp + Add Open Files Lägg till öppna filer + Sort By Name Sortera efter namn + Attempt to Automatically Align Source Images Försök justera källbilderna automatiskt + Create Smart Object after Loading Layers Skapa smart objekt efter inläsning av lager + Canvas Guides Stödlinjer för arbetsytan + 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 Table… Färgtabell… + Combine Shapes Kombinera former + Contract… Dra ihop… + Convert to Embedded Konvertera till inbäddat + Convert to Layers Konvertera till lager + Convert to Linked… Konvertera till länkat… + Count Antal + Create Layer Skapa lager + Custom Pixel Aspect Ratio… Anpassat pixelförhållande… + Cycle Screen Mode Växla skärmläge + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC bredbild (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL bredbild (1,46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + De-Interlace… Avfläta… + Default Features Standardfunktioner + Delete All Empty Layers Ta bort alla tomma lager + Desaturate Avmätta + Displace… Förskjut… + Distribute Fördela + Document Bounds Dokumentgränser + Edit in Quick Mask Mode Redigera i snabbmaskläge + Enhanced Controls… Utökade kontroller… + Entropy Entropi + Exclude Overlapping Shapes Uteslut överlappande former + Exit Avsluta + Expand… Expandera… + Extra Large Extra stor + File Info… Filinformation… + Fit Artboard on Screen Anpassa ritytan till skärmen + Fit Layer(s) on Screen Anpassa lager till skärmen + Flatten All Layer Effects Sammanfoga alla lagereffekter + Flatten All Masks Sammanfoga alla masker + Flatten Image Kombinera lager + Float All in Windows Gör alla flytande i fönster + Float in Window Gör flytande i fönster + Frame from Layers Ram från lager + From Transparency Från genomskinlighet + Gamut Warning Färgomfångsvarning + Global Light… Globalt ljus… + Gradient… Gradient… + Graphic and Web Grafik och webb + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Hidden Layers Dolda lager + Hide All Effects Dölj alla effekter + Horizontal Centers Vågräta mittpunkter + Huge Enorm + Indexed Color… Indexerad färg… + Integrations… Integrationer… + Intersect Shape Areas Skär formområden + Kurtosis Kurtosis + Large Stor + Layer Edges Lagerkanter + Left Edges Vänsterkanter + Lens Blur… Objektivoskärpa… + Lighting Effects… Ljuseffekter… + Link Layers Länka lager + Lock Layers… Lås lager… + Mean Medelvärde + Merge Down Sammanfoga nedåt + Merge Group Sammanfoga grupp + Merge Layers Sammanfoga lager + Merge Shape Components Sammanfoga formkomponenter + Merge Visible Sammanfoga synliga + Stamp Visible Stämpla synliga + Select Layer Above Markera lagret ovanför + Select Layer Below Markera lagret under + Select Top Layer Markera översta lagret + Select Bottom Layer Markera nedersta lagret + Add Layer Above to Selection Lägg till lagret ovanför i markeringen + Add Layer Below to Selection Lägg till lagret nedanför i markeringen + Stamp Down Stämpla nedåt + Mesh Nät + Middle Eastern & South Asian Composer Radbrytning för Mellanöstern och Sydasien + Modifier Keys Modifieringstangenter + Modify Ändra + NTSC Colors NTSC-färger + Notes… Anteckningar… + Other Annat + Other… Annat… + Paragraph Styles Panel Panelen Styckeformat + Paths to Illustrator… Banor till Illustrator… + Pattern Preview Förhandsgranskning av mönster + Pattern… Mönster… + Pixel Art Pixelgrafik + Pixel Art Preview Förhandsgranskning av pixelgrafik + Pixel Grid Pixelrutnät + Pixelate Pixla + Place Scale Marker… Placera skalmarkör… + Plug-ins Plugin-program + Print One Copy Skriv ut en kopia + Print… Skriv ut… + Range Intervall + Raw Defaults… Raw-standardvärden… + Relink to File… Länka om till fil… + Relight… Ändra belysning… + Render Rendera + Replace All Missing Fonts Ersätt alla saknade teckensnitt + Resolve Missing Fonts… Lös saknade teckensnitt… + Reveal Selection Visa markering + Right Edges Högerkanter + Selection Edges Markeringskanter + Shapes Former + Show Extras Options… Alternativ för extrafunktioner… + Skewness Skevhet + Small Liten + Smart Guides Smarta stödlinjer + Smart Objects Smarta objekt + Smooth… Jämna ut… + Solid Color… Enfärgad… + Square Kvadratisk + Stack Mode Staplingsläge + Choose Stack Mode: Välj staplingsläge: + Standard Deviation Standardavvikelse + Statistics… Statistik… + Stylistic Alternates Stilistiska alternativ + Stylize Stilisera + Subtract Front Shape Subtrahera främre form + Summation Summa + Target Path Målbana + Tile All Horizontally Lägg alla sida vid sida vågrätt + Tile All Vertically Lägg alla sida vid sida lodrätt + Toggle Last State Växla till senaste tillstånd + Toolbar… Verktygsfält… + Tools… Verktyg… + Top Edges Överkanter + Unite Shapes Förena former + Update All Modified Content Uppdatera allt ändrat innehåll + Update All Text Layers Uppdatera alla textlager + Update Modified Content Uppdatera ändrat innehåll + Variance Varians + Vertical Centers Lodräta mittpunkter + Video Frames to Layers… Videobildrutor till lager… + WIA Support… WIA-stöd… + Working CMY Plate CMY-arbetsplåt + +# tl! literals (third pass) + Absolute Absolut + Action Åtgärd + Adaptive Adaptiv + Add Stop Lägg till färgstopp + Add disk Lägg till disk + Align and Distribute Justera och fördela + All Slices Alla skivor + All User Slices Alla användarskivor + Alt Alt + Artboard Rityta + Artboard Background Color Bakgrundsfärg för rityta + Auto Hide Dölj automatiskt + Automatically level near-horizontal line segments Gör nästan vågräta linjesegment vågräta automatiskt + Automatically straighten and level Räta upp och gör vågrätt automatiskt + Automatically straighten near-vertical line segments Räta upp nästan lodräta linjesegment automatiskt + Balance Balans + Baseline Baslinje + Bicubic Bikubisk + Bicubic (smooth gradients) Bikubisk (jämna övergångar) + Automatic Automatisk + Bicubic Smoother (enlargement) Bikubisk mjukare (förstoring) + Bicubic Sharper (reduction) Bikubisk skarpare (förminskning) + Bilinear Bilinjär + Black, White Svart, vit + Blue, Red, Yellow Blå, röd, gul + Bottom Nederkant + Brightness Jitter Variation i ljusstyrka + Brush Reconstruct Options Alternativ för penselrekonstruktion + Cache Level: Cachenivå: + Canvas Arbetsyta + Capture Fånga + Center Crop Marks Centrerade beskärningsmärken + Centre 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 + Clear Override Rensa åsidosättning + Clear Samples Rensa prover + Cmd Cmd + Color Grading Färggradering + Color Management Färghantering + Color Priority Färgprioritet + Color Range preview Förhandsgranskning av färgintervall + Colorize Färglägg + Constant Konstant + Content-Aware Innehållsmedveten + Convert to sRGB Konvertera till sRGB + Copies Kopior + Copper Koppar + Corner Crop Marks Beskärningsmärken i hörnen + Correction Korrigering + Count Jitter Antalsvariation + Create Texture Skapa struktur + Create a new count group Skapa en ny räknegrupp + Crop Factor Beskärningsfaktor + Ctrl Ctrl + Custom Shapes Anpassade former + Cyan Cyan + Cyan · Red Cyan · Röd + Darks Mörka toner + Default printer Standardskrivare + Default restored. Standardvärdet har återställts. + Delete Group Ta bort grupp + Delete count group Ta bort räknegrupp + Delete effect layer Ta bort effektlager + Delete note Ta bort anteckning + Delete tool preset Ta bort verktygsförinställning + Depth Jitter Djupvariation + Discontiguous Ej sammanhängande + Discord Discord + Display P3 Display P3 + Display Text Visa text + Divide… Dela… + Do you want to save the changes you made to “{name}” before quitting? Vill du spara ändringarna du gjort i ”{name}” innan du avslutar? + Edit Toolbar… Redigera verktygsfält… + Edit in Quick Mask Mode (Q) Redigera i snabbmaskläge (Q) + Edit in Standard Mode (Q) Redigera i standardläge (Q) + Embedded Profile Mismatch Inbäddad profil stämmer inte + Enhance Edge Förbättra kant + Exact Exakt + Expansion: Expansion: + Exposure and Gamma Exponering och gamma + Factor: Faktor: + Fewer Points Färre punkter + File Settings Filinställningar + Finger Painting Fingermålning + Fit in View Anpassa till vyn + Fixed Fast + Foreground to Background Förgrund till bakgrund + Foreground/Background Jitter Förgrunds-/bakgrundsvariation + Format Format + Frame from Layers… Ram från lager… + Full Spherical Helt sfärisk + Gamma Correction Gammakorrigering + Gamma: Gamma: + Global Global + Grain Korn + Graphics Grafik + Gray Grå + Heart Hjärta + Hide Auto Slices Dölj automatiska skivor + Highlight Compression Högdagerkomprimering + Highlight Priority Högdagerprioritet + Highlights Högdagrar + Home Start + Hue Jitter Nyansvariation + Image Size: Bildstorlek: + Imported Importerad + Input: Indata: + Interlaced Sammanflätad + Invert All Invertera alla + Label Size Etikettstorlek + Labels Etiketter + Lanczos (sharp) Lanczos (skarp) + Level Nivå + Lights Ljusa toner + Limits: Gränser: + Link the mask to the layer Länka masken till lagret + Localized Color Clusters Lokaliserade färgkluster + Luminance Luminans + Magenta Magenta + Magenta · Green Magenta · Grön + Marker Size Markörstorlek + Mask Options Maskalternativ + Masking Maskering + Master Huvud + Mean: Medelvärde: + Median: Median: + Mesh Size Nätstorlek + Metadata Metadata + Midpoint Mittpunkt + Midtones Mellantoner + Midtones from: Mellantoner från: + Midtones to: Mellantoner till: + Minimum Depth Minsta djup + Mix Jitter Blandningsvariation + Mode: Läge: + Monochrome Monokrom + More Points Fler punkter + Move Group Down Flytta grupp nedåt + Move Group Up Flytta grupp uppåt + Move effect layer down Flytta effektlager nedåt + Move effect layer up Flytta effektlager uppåt + Move to Flytta till + Nearest Neighbor Närmaste granne + Nearest Neighbor (hard edges) Närmaste granne (skarpa kanter) + New effect layer Nytt effektlager + Next note Nästa anteckning + No Color Management Ingen färghantering + Optimized Optimerad + Output Levels: Utdatanivåer: + Output: Utdata: + Paint Overlay Målningsöverlägg + Pattern Size Mönsterstorlek + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — en inbyggd bildredigerare med öppen källkod, skriven i Rust. + Position and Size Position och storlek + Preserve Details Bevara detaljer + Preserve Luminosity Bevara luminositet + Previous note Föregående anteckning + Printer Skrivare + Printer Setup Skrivarinställningar + Printing Marks Tryckmärken + Progressive Progressiv + Promote Höj nivå + Protect Detail Skydda detaljer + Protect Tones Skydda toner + Proximity Match Närhetsmatchning + Purity Renhet + Quality Kvalitet + Recent Senaste + Reconstruct Rekonstruera + Refine Förfina + Registration Marks Passmärken + Regular Normal + Remove stop Ta bort stopp + Rename Group… Byt namn på grupp… + Rename Layer… Byt namn på lager… + Restore All Återställ alla + Restrictive (Web) Begränsad (webb) + Rigid Styv + Roughness Grovhet + Roundness Rundhet + Rulers & Grids Linjaler och rutnät + Sample: foreground colour Prov: förgrundsfärg + Sampling: Background Swatch Provtagning: bakgrundsfärgruta + Sampling: Continuous Provtagning: kontinuerlig + Sampling: Once Provtagning: en gång + Saturate Mätta + Saturation Jitter Mättnadsvariation + Save HTML and Images Spara HTML och bilder + Save as PDF Spara som PDF + Scale to Fit Media Skala för att passa mediet + Scatter Spridning + Scratch disks Arbetsdiskar + Selective Selektiv + Shadows Skuggor + Shape: Form: + Sharpening Skärpning + Shift Shift + Show All Menu Items Visa alla menyalternativ + Show Backdrop Visa bakgrund + Show Mask Visa mask + Show Mesh Visa nät + Show or hide the Notes panel Visa eller dölj panelen Anteckningar + Show the layer mask as an overlay Visa lagermasken som ett överlägg + Show: Visa: + Sides Sidor + Sides: Sidor: + Slice Options… Skivalternativ… + Slices From Guides Skivor från stödlinjer + Slices are locked (View › Lock Slices) Skivorna är låsta (Visa › Lås skivor) + Split: Dela: + Star ratio Stjärnförhållande + Std Dev: Std.avv.: + Straighten Räta upp + Straighten Layer Räta upp lager + Strength: Styrka: + Stroke Linje + Stroke Catch-up Låt penseldraget komma ikapp + Stroke: Linje: + Location: Plats: + Inside Inuti + Outside Utanför + Tetrahedral Tetraedrisk + Texture Each Tip Strukturera varje spets +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradientredigerare + Gradient picker Gradientväljare + Save As Preset Spara som förinställning + The gradient the Gradient tool paints with Gradienten som Gradientverktyget målar med + Adds the current gradient to the Gradients panel Lägger till den aktuella gradienten i panelen Gradienter + Gradient stops Gradientstopp + Click to edit the gradient Klicka för att redigera gradienten + Tilt X Lutning X + Tilt Y Lutning Y + Toggle count group visibility Visa/dölj räknegruppen + Toggle text orientation Växla textorientering + Tone: Ton: + Toolbar Verktygsfält + Top Överkant + Total: Totalt: + Transparency Genomskinlighet + Trilinear Trilinjär + Unlink the mask from the layer Ta bort länken mellan masken och lagret + View Options Visningsalternativ + Violet, Orange Violett, orange + Visible Synlig + Weight Tjocklek + Weight: Tjocklek: + Wetness Jitter Våthetsvariation + Whites Vita + Yellow Gul + Yellow · Blue Gul · Blå + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Tillåt agentstyrning + Allow scripts to connect Tillåt skript att ansluta + Always Alltid + Always create smart objects when placing Skapa alltid smarta objekt vid placering + Animated zoom Animerad zoom + Apply auto tone Tillämpa autoton + Ask Fråga + Ask before saving layered tiff Fråga innan TIFF med lager sparas + Autosave Spara automatiskt + Autosave minutes Minuter för automatisk sparning + Beep when done Pip när det är klart + Bicubic automatic Bikubisk automatisk + Bicubic sharper Bikubisk skarpare + Bicubic smoother Bikubisk mjukare + Bit depth Bitdjup + Brush picker Penselväljare + Brush preview color Färg för penselförhandsgranskning + CPU (no GPU acceleration) CPU (ingen GPU-acceleration) + Cache levels Cachenivåer + Cache tile size Storlek på cacheblock + Camera Raw Defaults Standardinställningar för Camera Raw + Canvas border Arbetsytans kant + 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 + Canvas color Arbetsytans färg + Canvas custom color Anpassad färg för arbetsytan + Classic Klassisk + Color picker Färgväljare + Color space Färgrymd + Column width Kolumnbredd + Concise Koncis + Control port Kontrollport + Convert to srgb Konvertera till sRGB + Cursors Markörer + Custom dark Anpassad mörk + Custom light Anpassad ljus + Dark Mörk + Dark gray Mörkgrå + Dashed lines Streckade linjer + Detailed Detaljerad + DirectX 12 DirectX 12 + Disks Diskar + Display p3 Display P3 + Dots Punkter + Double click layer mask launches select and mask Dubbelklick på lagermask öppnar Markera och maskera + Drop shadow Skugga + Dynamic color sliders Dynamiska färgreglage + East asian Östasiatisk + Effect cache mb Effektcache (MB) + Enable flick panning Aktivera snärtpanorering + Enable floating document window docking Aktivera dockning av flytande dokumentfönster + Enable narrow options bar Aktivera smalt alternativfält + Enhanced Controls Utökade kontroller + Erase Radera + Export clipboard Exportera urklipp + Extra large Extra stor + File Handling Filhantering + File path Filsökväg + Fill new type layers with placeholder Fyll nya textlager med platshållartext + Font preview Förhandsgranskning av teckensnitt + Full size tip Spets i full storlek + GPU backend GPU-backend + Linux display server Linux-visningsserver + X11 X11 + Gamut warning color Varningsfärg för färgomfång + Gamut warning opacity Opacitet för färgomfångsvarning + General Allmänt + Enable Generator Aktivera Generator + Gif GIF + Glossy Glansig + Grid color Rutnätsfärg + Grid colors Rutnätsfärger + Grid size Rutnätsstorlek + Grid style Rutnätsstil + Grid unit Rutnätsenhet + Gridline every Rutnätslinje var + Guide color Stödlinjefärg + Guide style Stödlinjestil + Guides, Grid & Slices Stödlinjer, rutnät och skivor + Gutter Spaltmellanrum + History Log Historiklogg + History states Historiktillstånd + Ignore exif profile tag Ignorera EXIF-profiltagg + Image interpolation Bildinterpolering + Image previews Bildförhandsgranskningar + Integrations Integrationer + Interface Gränssnitt + Jpeg quality JPEG-kvalitet + Jpg JPG + Large tabs Stora flikar + Legacy compositing Äldre sammansättning + Light gray Ljusgrå + Line Linje + Lines Linjer + Lowercase extension Filändelse med gemener + Maximize psd compatibility Maximera PSD-kompatibilitet + Medium gray Mellangrå + Memory usage mb Minnesanvändning (MB) + Low resolution previews Förhandsgranskningar med låg upplösning + Metal Metal + Missing glyph protection Skydd mot saknade glyfer + Modern Modern + Nearest neighbor Närmaste granne + Never Aldrig + Normal tip Normal spets + Open as smart object Öppna som smart objekt + Open in camera raw Öppna i Camera Raw + Open documents as tabs Öppna dokument som flikar + OpenGL OpenGL + Orange Orange + Overscroll Överrullning + Performance Prestanda + Png PNG + Point size Punktstorlek + PostScript (72 points/inch) PostScript (72 punkter/tum) + Precise Exakt + Preserve details Bevara detaljer + Print resolution Utskriftsupplösning + Pro Pro + Pro light Pro ljus + Pro light gray Pro ljusgrå + Pro medium Pro medel + Pro photo ProPhoto + Purple Lila + Quick export format Format för snabbexport + Quick export location Plats för snabbexport + Recent file count Antal senaste filer + LUT folder LUT-mapp + Recent files Senaste filer + Recent fonts Senaste teckensnitt + Recover on launch Återställ vid start + Remember workspace changes Kom ihåg ändringar i arbetsytan + Resize image during place Ändra bildstorlek vid placering + Right click with painting tools Högerklick med målarverktyg + Rotate view with trackpad Rotera vyn med styrplattan + Same folder Samma mapp + Save in background Spara i bakgrunden + Scratch Disks Arbetsdiskar + Screen resolution Skärmupplösning + Scrubby slider acceleration Acceleration för dragreglage + Sessions only Endast sessioner + Sharpen for Skärpa för + Show channels in color Visa kanaler i färg + Show crosshair in brush tip Visa hårkors i penselspetsen + Show extension panels Visa tilläggspaneler + Show font names in english Visa teckensnittsnamn på engelska + Show menu colors Visa menyfärger + 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 + Show transformation values Visa ombildningsvärden + Slice color Skivfärg + Smart guide color Färg på smarta stödlinjer + Smart quotes Typografiska citattecken + Snap vector tools and transforms to pixel grid Fäst vektorverktyg och ombildningar vid pixelrutnätet + Srgb sRGB + Standard Standard + Studio Studio + Studio light Studio ljus + Adwaita Adwaita + Adwaita dark Adwaita mörkt + Subdivisions Underindelningar + System System + Text engine Textmotor + Text file Textfil + Tiny Mycket liten + Touch gestures Pekgester + Traditional (72.27 points/inch) Traditionell (72,27 punkter/tum) + Transparency & Gamut Genomskinlighet och färgomfång + Type units Textenheter + UI font size Teckenstorlek i gränssnittet + UI scale Gränssnittsskala + Units & Rulers Enheter och linjaler + Use additional plugins folder Använd ytterligare plugin-mapp + Use esc to commit Använd Esc för att verkställa + Use gpu Använd GPU + Use legacy free transform Använd äldre fri ombildning + Use shift key for tool switch Använd Shift för att byta verktyg + Use tablet pressure Använd tryckkänslighet på ritplattan + Vary round brush hardness on hud Variera hårdhet för rund pensel på HUD + Vulkan Vulkan + Webp WebP + World ready World-Ready + Zoom clicked point to center Centrera klickad punkt vid zoomning + Zoom resizes windows Zoomning ändrar fönsterstorlek + Zoom with scroll wheel Zooma med rullhjulet + Zoom with trackpad pinch Zooma genom att nypa på styrplattan + 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 + 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? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Om du fortsätter och sparar över originalet kan oläsbara data gå förlorade permanent. Spara en kopia om du vill behålla originalet. + Continue Fortsätt + The document “{name}” does not have an embedded {mode} profile. Dokumentet ”{name}” har ingen inbäddad {mode}-profil. + 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 + Esc to cancel Esc för att avbryta + Opened {name} Öppnade {name} + Opening {name} Öppnar {name} + Saved {name} Sparade {name} + 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 + Opening {name}… Öppnar {name}… + Opening… Öppnar… + Reading… Läser… + Working… Arbetar… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Rutnät: +# Rendering mode and recovery + Rendering Mode Renderingsläge + Automatic (recommended) Automatiskt (rekommenderas) + CPU / Compatibility CPU / kompatibilitet + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatiskt använder GPU-acceleration när det är tillgängligt och återgår till CPU-rendering vid fel. + Switched to CPU rendering Bytte till CPU-rendering + GPU acceleration could not continue. Your documents are unchanged. GPU-accelerationen kunde inte fortsätta. Dina dokument är oförändrade. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft använder CPU-bildkompositören. Fönstret kan fortfarande använda ditt grafikkort. + Retry GPU requires a restart. Save your work first. Att försöka med GPU igen kräver en omstart. Spara ditt arbete först. + Retry GPU tries the graphics processor again now. ”Försök med GPU igen” provar grafikprocessorn på nytt nu. + GPU acceleration resumed. GPU-accelerationen har återupptagits. + Keep Using CPU Fortsätt använda CPU + Retry GPU Försök med GPU igen + GPU Backend GPU-backend + Details Detaljer + GPU GPU + Rendering mode Renderingsläge + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Frys-lasso: dra för att frysa ett område, Alt-dra för att tina det (L) + Shadow Clipping Warning (U) Varning för klippta skuggor (U) + Highlight Clipping Warning (O) Klippningsvarning för högdagrar (O) + Show Lab Color Readouts Visa Lab-färgvärden + Show Vectorscope Visa vektorskop + Show Red at 3 o'clock Visa rött vid klockan 3 + Show Skin Tone Indicator Visa hudtonsindikator + Tone Histogram Tonhistogram + Show Selected Region Visa markerat område +cameraRaw Light Ljus +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Akvamarin +cameraRaw Amount Mängd +cameraRaw Blacks Svarta +cameraRaw Vibrance Lyster +cameraRaw Color Grading Färggradering +cameraRaw Lights Ljusa +cameraRaw Darks Mörka +cameraRaw Global Global +cameraRaw Red Röd +cameraRaw Orange Orange +cameraRaw Yellow Gul +cameraRaw Green Grön +cameraRaw Aqua Akvamarin +cameraRaw Blue Blå +cameraRaw Purple Lila +cameraRaw Magenta Magenta +cameraRaw Hue Nyans +cameraRaw Blending Blandning +cameraRaw Luminance Detail Luminansdetalj +cameraRaw Color Detail Färgdetalj +cameraRaw Roughness Grovhet + Patch Tool Lappverktyg + Patch: Lapp: + Lasso around an area, then drag the selection Rita lasso runt ett område och dra sedan markeringen + Transform On Drop Ombilda vid släpp + Scale or rotate the moved area, then press {key} Skala eller rotera det flyttade området och tryck sedan på {key} + Now drag the selection onto another area Dra nu markeringen till ett annat område + Edit Type Redigera text + + New Style… Ny stil… + Style {name} saved Stilen {name} har sparats + + Create new style (from the selected type) Skapa ny stil (från markerad text) + Create new style from the selected layer Skapa ny stil från det markerade lagret + + Make Default Gör till standard + Reset to Default Återställ till standard + Full-resolution preview unavailable Förhandsgranskning i full upplösning är inte tillgänglig + No patterns Inga mönster + Enable {name} Aktivera {name} + Hold Alt while drawing for straight segments Håll ned Alt medan du ritar för raka segment + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Dra eller klicka på polygonpunkter · håll ned Alt under lasso för raka segment · {add} lägg till · {sub} före ritning subtraherar + Extend Utöka + Structure: Struktur: + Content-Aware Move Tool Verktyget Innehållsmedveten flytt + TIFF Options TIFF-alternativ + Targa Options Targa-alternativ + JPEG Options JPEG-alternativ + Image Options Bildalternativ + Format Options Formatalternativ + Baseline ("Standard") Baslinje ("Standard") + Baseline Optimized Baslinje optimerad + 24 bits/pixel 24 bitar/pixel + 32 bits/pixel 32 bitar/pixel + Including layers will increase file size. Om lager tas med blir filen större. + 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 + Couldn't place {name}: {error} Det gick inte att placera {name}: {error} + Magnetic Lasso Tool Verktyget Magnetiskt lasso + Frequency Frekvens + Use tablet pressure to change pen width Använd tryckkänslighet på ritplattan för att ändra pennans bredd + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About Om + Contributors Medverkande + Models Modeller + Sort Sortera + Username Användarnamn + Display name Visningsnamn + Real name Riktigt namn + Reverse the order Vänd på ordningen + Grab bag Blandat + Table Tabell + Name (A–Z) Namn (A–Ö) + Merged PRs Sammanfogade PR:er + Commits Commits + Lines added Tillagda rader + Lines deleted Borttagna rader + Line delta Raddifferens + Binary assets added Binära resurser tillagda + Binary assets removed Binära resurser borttagna + First commit Första commit + First Första + Last commit Senaste commit + Last Senaste + Contributors: {contributors} · Commits: {commits} Medverkande: {contributors} · Commits: {commits} + No contributor data was built into this copy. Ingen information om medverkande är inbyggd i den här kopian. + No model credits were built into this copy. Inga modellkrediteringar finns inbyggda i den här kopian. + Company Företag + Version Version + % of all commits % av alla commits + Lines +/− Rader +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{binAdded} / −{binDeleted} binära resurser, {first} – {last} + +# Swatches panel + Create new swatch Skapa ny färgruta + New Swatch… Ny färgruta… + New Swatch Group… Ny färgrutegrupp… + Import Swatches… Importera färgrutor… + Replace Swatches… Ersätt färgrutor… + Export Swatches… Exportera färgrutor… + Export Swatches for Exchange… Exportera färgrutor för utbyte… + Reset Swatches Återställ färgrutor + Add to Swatches Lägg till i färgrutor +# Layer Style > Blending Options (Advanced Blending) + Blend If: Blandningsvillkor: + Channels: Kanaler: + Knockout Utsparning + Apply At Canvas Bounds Tillämpa vid arbetsytans gränser + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Färgväljare (textfärg) + Color Picker ({name}) Färgväljare ({name}) + Color Picker (Fill Color) Färgväljare (fyllningsfärg) + Fill color Fyllningsfärg + Color Picker (Stroke Color) Färgväljare (linjefärg) + Stroke color Linjefärg + Pick screen color Hämta färg från skärmen + Sample a pixel anywhere on the screen Ta prov på en pixel var som helst på skärmen + Click to pick · Esc to cancel Klicka för att välja · Esc för att avbryta + Pick a screen pixel or press Esc to cancel Välj en pixel på skärmen eller tryck på Esc för att avbryta + Scroll to zoom Rulla för att zooma + Drag to pan · Scroll to zoom · Double-click to fit Dra för att panorera · Rulla för att zooma · Dubbelklicka för att anpassa + Show/Hide All Panels Visa/dölj alla paneler + Show/Hide Panels Visa/dölj paneler + Could not open Det gick inte att öppna + {path}: the path is not valid Unicode; rename the file and open it again. {path}: sökvägen är inte giltig Unicode; byt namn på filen och öppna den igen. + +# Fill dialog, layer style contours and Image Size + Contents: Innehåll: + Custom Pattern: Anpassat mönster: + Foreground Color Förgrundsfärg + Color… Färg… + 50% Gray 50 % grått + {size} (was {old}) {size} (var {old}) + Cone Kon + Cone (Inverted) Kon (omvänd) + Domed Välvd + Domed (Inverted) Välvd (omvänd) + Diagonal (Descending) Diagonal (fallande) + Contour Editor Konturredigerare + Anti-aliased Kantutjämnad + Corner Hörn + Gloss Contour Glanskontur + Contour Kontur + Cone - Inverted Kon - omvänd + Cove - Deep Hålkäl - djup + Cove - Shallow Hålkäl - grund + Gaussian Gaussisk + Half Round Halvrund + Ring Ring + Ring - Double Ring - dubbel + Rolling Slope - Descending Rullande lutning - fallande + Rounded Steps Avrundade steg + Sawtooth 1 Sågtand 1 + Linear Linjär + + Red Eye Tool Verktyget Röda ögon + Pupil Size Pupillstorlek + Darken Amount Mörkningsgrad + Click a red pupil to neutralize it Klicka på en röd pupill för att neutralisera den + open source öppen källkod + …and {n} more …och {n} till + + Eyedroppers: Pipetter: + Set Black Point Ange svartpunkt + Set Neutral Gray Point Ange neutral gråpunkt + Set White Point Ange vitpunkt + Unavailable in this color mode Inte tillgängligt i det här färgläget + Selection Preview: Förhandsgranskning av markering: + Black Matte Svart kantfärg + White Matte Vit mattering + Quick Mask Snabbmask + +# Layer colour labels. + Seafoam Havsskum + Indigo Indigo + Fuchsia Fuchsia + Violet Violett + Large document: redrawing on the CPU Stort dokument: ritar om på CPU:n + Redrawing Ritar om + This redraw took {seconds} s on the CPU. Den här omritningen tog {seconds} s på CPU:n. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Ändringar som påverkar hela dokumentet, till exempel ett lagers blandningsläge, opacitet eller synlighet, ritar om allt. + The GPU compositor wasn't used: {reason} GPU-kompositören användes inte: {reason} +fontWeight Thin Tunn +fontWeight ExtraLight Extra mager +fontWeight Light Mager +fontWeight Regular Normal +fontWeight Medium Medium +fontWeight SemiBold Halvfet +fontWeight Bold Fet +fontWeight ExtraBold Extrafet +fontWeight Black Svart +fontWeight Italic Kursiv + +# Pattern Stamp tool + Pattern Stamp Tool Mönsterstämpelverktyg + 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° + +# Painting symmetry presets. + Dual Axis Dubbelaxel + Diagonal Diagonal + Transform Symmetry Ombilda symmetri + Set painting symmetry options Ange alternativ för målningssymmetri + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprov + 3 by 3 Average Medelvärde 3 x 3 + 5 by 5 Average Medelvärde 5 x 5 + 11 by 11 Average Medelvärde 11 x 11 + 31 by 31 Average Medelvärde 31 x 31 + 51 by 51 Average Medelvärde 51 x 51 + 101 by 101 Average Medelvärde 101 x 101 + Sample Size: Provstorlek: + 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 + +# 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 + Color to Alpha… Färg till alfa… + Transparency Threshold Tröskelvärde för genomskinlighet + Opacity Threshold Opacitetströskelvärde + PhotoCraft — Screen Color Picker PhotoCraft — Skärmfärgväljare + + Rasterize svg groups deeper than Rasterisera SVG-grupper djupare än + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 stänger av tröskelvärdet; SVG-grupper som är nästlade djupare än 100 nivåer rasteriseras alltid. + Rule of Thirds Tredjedelsregeln + Triangle Triangel + Golden Ratio Gyllene snittet + Golden Spiral Gyllene spiral + Auto Show Overlay Visa överlägg automatiskt + Always Show Overlay Visa alltid överlägg + Never Show Overlay Visa aldrig överlägg + Cycle Overlay Växla överlägg + Cycle Orientation Växla orientering + Set the overlay options for the Crop tool Ange överläggsalternativ för beskärningsverktyget + 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-klicket gick till skrivbordsmiljön, som flyttar fönster med Alt-dra. Ställ in dess tangent för fönsterflytt på Super, eller lås Alt i Fönster › Modifieringstangenter. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Färgväljare (lagerstilsfärg) + Auto Hide Notifications Dölj aviseringar automatiskt + Notification Duration (seconds) Visningstid för aviseringar (sekunder) + Bundle Arrow-Key Nudges Samla stegvisa flyttningar med piltangenterna + Nudge Bundle Pause (ms) Paus för gruppering av stegvisa flyttningar (ms) + Appearance Mode Utseendeläge + Dark Theme Mörkt tema + Light Theme Ljust tema + Pro Medium Pro medel + Studio Light Studio Light + Next Appearance Mode Nästa utseendeläge + Sync with system Synkronisera med systemet + Active Aktiv + Color Picker (Solid Color) Färgväljare (enfärgad) + Straighten the image by drawing a line on it Räta upp bilden genom att rita en linje på den + Set additional Crop options Ange fler beskärningsalternativ + Show Cropped Area Visa beskuret område + Enable Crop Shield Aktivera beskärningsskydd + Match Canvas Anpassa till arbetsytan + Auto Adjust Opacity Justera opacitet automatiskt + Crop Shield color Färg för beskärningsskydd + Color Picker (Crop Shield Color) Färgväljare (färg för beskärningsskydd) + From Center Från mitten + Link radii Länka radier + Star Ratio: Stjärnförhållande: + Create Rectangle Skapa rektangel + Create Ellipse Skapa ellips + Create Triangle Skapa triangel + Create Polygon Skapa polygon + Pressure curve Tryckkurva + Click to add a point, right-click to remove Klicka för att lägga till en punkt, högerklicka för att ta bort + Soft Mjuk + Hard Hård + Some brush presets could not be loaded Vissa penselförinställningar kunde inte läsas in + Retry saving presets Försök spara förinställningar igen + Brush preset changes are not saved: {error} Ändringar i penselförinställningar sparas inte: {error} + Keep this tab open to retain your brushes. Håll den här fliken öppen för att behålla dina penslar. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Webbläsarens lagring är inte tillgänglig, så penslarna finns bara kvar under den här sessionen. Håll fliken öppen för att behålla dem. + Saving brush presets… Keep this tab open until saving finishes. Sparar penselförinställningar… Håll fliken öppen tills sparandet är klart. + Reflect Spegla + Glow Glöd + Negation Negation + XOR XOR + Color Burn (Paint.NET) Färgbränning (Paint.NET) + Color Dodge (Paint.NET) Färgskuggning (Paint.NET) + Stroke Options Linjealternativ + Solid Heldragen + Dashed Streckad + Dotted Prickad + Caps Ändar + Butt / Flat Rak / Platt + Corners Hörn + Miter Gering + Round Rund + Bevel Fasat + Miter limit Geringsgräns + Dashed line Streckad linje + Dash Streck + Gap Mellanrum + Dash / gap lengths × stroke width Streck-/mellanrumslängder × linjebredd + Add pair Lägg till par + Remove pair Ta bort par + Dash offset Streckförskjutning + Open paths use centered strokes Öppna banor använder centrerade linjer + Use finite lengths and a positive pattern total Använd ändliga längder och en positiv mönstersumma + No subject found — try Object Selection or the Lasso Inget motiv hittades — prova objektmarkering eller lasso + Quick Selection found nothing there Snabbmarkering hittade ingenting där + Object Selection found nothing there — try a tighter rectangle Objektmarkering hittade ingenting där — prova en snävare rektangel + No subject found — try a rectangle around the object Inget motiv hittades — prova en rektangel runt objektet + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Vill du ta bort lagret ”{name}”? + Delete {count} layers? {names} Vill du ta bort {count} lager? {names} + Delete layer? Vill du ta bort lagret? + The document is closed Dokumentet är stängt + The document or selected layers changed. Please request deletion again. Dokumentet eller de markerade lagren har ändrats. Begär borttagningen igen. + Missing delete command Borttagningskommando saknas + Invalid delete command Ogiltigt borttagningskommando + Layer deleted, but confirmation preference could not be saved: {error} Lagret togs bort, men bekräftelseinställningen kunde inte sparas: {error} + Add a layer style Lägg till en lagerstil + Add a mask (from the selection; {key} inverts) Lägg till en mask (från markeringen; {key} inverterar) + Add an adjustment Lägg till en justering + Add to selection ({key}) Lägg till i markeringen ({key}) + Adjustment visible Justering synlig + Align and distribute Justera och fördela + Align horizontal centers Justera vågräta mittpunkter + Align left edges Justera vänsterkanter + Align right edges Justera högerkanter + Always use pressure for opacity Använd alltid tryck för opacitet + Always use pressure for size Använd alltid tryck för storlek + Angle Gradient Vinkelgradient + Auto-Select: Markera automatiskt: + Brush Preset picker Väljare för penselförinställningar + Cancel current crop operation (Esc) Avbryt aktuell beskärning (Esc) + Click the first point or press {key} to close · Esc cancels Klicka på första punkten eller tryck på {key} för att stänga · Esc avbryter + Click the first point or press {key} to close · {del} removes a point · Esc cancels Klicka på första punkten eller tryck på {key} för att stänga · {del} tar bort en punkt · Esc avbryter + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klicka och följ sedan en kant · klicka för att fästa en punkt · {alt}-klicka för ett rakt segment · dubbelklicka för att stänga + Clip to layer below Klipp mot lagret under + Color & Swatches Färg och färgrutor + Commit current crop operation ({key}) Verkställ aktuell beskärning ({key}) + Create a new group Skapa en ny grupp + Create a new layer Skapa ett nytt lager + Create new document from current state Skapa nytt dokument från aktuellt tillstånd + Create new fill or adjustment layer Skapa nytt fyllnings- eller justeringslager + Create new snapshot Skapa ny ögonblicksbild + From: Från: + Full Document Hela dokumentet + Merged Layers Sammanfogade lager + Delete Cropped Pixels Ta bort beskurna pixlar + Delete current state Ta bort aktuellt tillstånd + Delete layer Ta bort lager + Diamond Gradient Rombgradient + Doc: {w} × {h} px Dok: {w} × {h} px + Enable airbrush-style build-up effects Aktivera uppbyggnadseffekter som med airbrush + Filter for adjustment layers Filtrera efter justeringslager + Filter for pixel layers Filtrera efter pixellager + Filter for shape layers Filtrera efter formlager + Filter for smart objects Filtrera efter smarta objekt + Filter for type layers Filtrera efter textlager + Fixed Ratio Fast proportion + Fixed Size Fast storlek + Follows only edges this close to the pointer ([ and ] change it) Följer bara kanter så här nära pekaren (ändra med [ och ]) + Hide Properties Dölj egenskaper + Higher values follow only edges that contrast sharply with their surroundings Högre värden följer bara kanter med skarp kontrast mot omgivningen + How often fastening points are placed by themselves Hur ofta fästpunkter placeras automatiskt + Hue/Saturation Nyans/mättnad + Intersect with selection ({key}) Skär med markering ({key}) + Kind Typ + Linear Gradient Linjär gradient + Link layers Länka lager + Lock Lås + Lock all Lås alla + Lock image pixels Lås bildpixlar + Lock layer Lås lager + Lock position Lås position + Lock transparent pixels Lås genomskinliga pixlar + New selection Ny markering + No properties Inga egenskaper + Pressure for Opacity Tryck för opacitet + Pressure for Size Tryck för storlek + Prevent auto-nesting in and out of Artboards and Frames Förhindra automatisk kapsling i och ut ur ritytor och ramar + Radial Gradient Radiell gradient + Reflected Gradient Speglad gradient + Reset to defaults Återställ till standardvärden + Set additional smoothing options Ange fler utjämningsalternativ + Show Transform Controls Visa ombildningskontroller + Subtract from selection ({key}) Subtrahera från markering ({key}) + Swaps height and width Byter plats på höjd och bredd + Tolerance Tolerans + Add vector mask Lägg till vektormask + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klicka för att zooma in · {key}-klicka för att zooma ut · dra åt höger/vänster för att zooma in/ut + Appearance Settings… Utseendeinställningar… + Add to favorites Lägg till i favoriter + Remove from favorites Ta bort från favoriter +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Tillverkare +cameraData Model Modell +cameraData Date Taken Fotograferingsdatum +cameraData Exposure Exponering +cameraData F-Stop Bländartal +cameraData ISO Speed ISO-tal +cameraData Focal Length Brännvidd +cameraData Focal Length (35 mm) Brännvidd (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktivera panel + Change Panel Group Ändra panelgrupp + Change Panel Layout Ändra panellayout + Close Panel Stäng panel + Dock Panel Docka panel + Float Panel Gör panelen flytande + Move Panel Flytta panel + Group with Gruppera med + Panel menu Panelmeny + Resize panel window Ändra storlek på panelfönster + Resize panels Ändra storlek på paneler + Use Classic Mode Använd klassiskt läge + Auto Center Preview Centrera förhandsgranskning automatiskt + Next Document Nästa dokument + Previous Document Föregående dokument + W x H x Resolution B x H x upplösning + Front Image Främre bild + Frame Tool Ramverktyg + Create a new rectangular frame Skapa en ny rektangulär ram + Create a new elliptical frame Skapa en ny elliptisk ram + Include in playback Ta med i uppspelning + Perspective Crop Tool Perspektivbeskärningsverktyg + Show Grid Visa rutnät + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Dra en ruta eller klicka på fyra hörn · dra hörnen till kanterna på en snedställd form · {commit} verkställer · Esc avbryter + Artboard Tool Ritytsverktyg + Make Portrait Gör stående + Make Landscape Gör liggande + Add New Artboard Lägg till ny rityta + Freeform Pen Tool Verktyget Frihandspenna + Curvature Pen Tool Verktyget Krökningspenna + Curve Fit: Kurvanpassning: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Dra: rita på frihand · {alt}-klick: rakt segment · Släpp på startpunkten: stäng + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klick: mjuk punkt · Dubbelklick: hörn · Dra en punkt: flytta · Klicka på första punkten: stäng · {key} slutför + Scale: Skala: + Snap to Origin Fäst vid origo + Link with Layer Länka med lager + Rasterize Layer Style Rasterisera lagerstil + Search settings… Sök inställningar… + No matching settings Inga matchande inställningar +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Provtagningspenselverktyg + Sampling Area Overlay Överlägg för provtagningsområde + Show Sampling Area Visa provtagningsområde + Indicates Anger + Sampling Area Provtagningsområde + Excluded Area Uteslutet område + Sampling Area Options Alternativ för provtagningsområde + Rectangular Rektangulär + Fill Settings Fyllningsinställningar + Rotation Adaptation Rotationsanpassning + Very High Mycket hög + Full Full + Mirror Spegla + Output Settings Utdatainställningar + Output To Utdata till + Duplicate Layer Duplicera lager + Contract Dra ihop + + Animation Options Animeringsalternativ + Entire Document Hela dokumentet + Filter Options Filteralternativ + Layer Bounds Lagergränser + Layers Panel Options Alternativ för panelen Lager + Lock Layers Lås lager + New Artboard… Ny rityta… + New Group… Ny grupp… + Prevent Auto-Nesting Förhindra automatisk kapsling + Show Layers Visa lager + Thumbnail Contents Miniatyrinnehåll + Thumbnail Size Miniatyrstorlek + Use Default Masks on Fill Layers Använd standardmasker på fyllningslager + Expand New Effects Expandera nya effekter + Add “copy” to Copied Layers and Groups Lägg till ”kopia” i kopierade lager och grupper + Preview on canvas when reordering layers Förhandsgranska på arbetsytan när lager ordnas om + Show layer mask badges Visa märken för lagermasker + +# Smart Filter editors + Blending Options ({filter}) Blandningsalternativ ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Dölj lagrets effekter och smarta filter ({key}-klicka: alla lager) + Show the layer's effects and Smart Filters ({key}-click: all layers) Visa lagrets effekter och smarta filter ({key}-klick: alla lager) + Wait for the current job to finish. Vänta tills det aktuella jobbet är klart. + The document or Smart Filter changed. Reopen its dialog. Dokumentet eller det smarta filtret har ändrats. Öppna dialogrutan igen. + Opacity must be between 0% and 100%. Opaciteten måste vara mellan 0 % och 100 %. + Dissolve is approximated: it blends without Photoshop's dither. Upplös är en approximation: den blandar utan Photoshops dithering. + Loading fonts… Läser in teckensnitt… + Response curve Responskurva + Tool Verktyg + Simulate Pressure Simulera tryck + Stroke Path… Linje på bana… + Background Eraser Bakgrundssuddgummi + Healing Brush Lagningspensel + Spot Healing Brush Punktlagningspensel + Smudge Smeta ut + Sponge Svamp + Amount: Mängd: + Protect: Skydda: + Protect skin tones Skydda hudtoner + Edit points to modify the curve Redigera punkter för att ändra kurvan + Draw to modify the curve Rita för att ändra kurvan +curves Smooth Jämna ut + Smooth the curve values Jämna ut kurvvärdena + Drag to draw the curve · {key}-click draws a straight line Dra för att rita kurvan · {key}-klick ritar en rak linje + Grid Size: Rutnätsstorlek: + Vanishing Point: click four corners to create a plane Fluktpunkt: klicka på fyra hörn för att skapa ett plan + Vanishing Point is not active Fluktpunkt är inte aktivt + Apply Vanishing Point ({key}) Tillämpa fluktpunkt ({key}) + Fix the red plane first: its corners must form a convex quad Korrigera det röda planet först: dess hörn måste bilda en konvex fyrhörning + Alt-click a plane to set the clone source Alt-klicka på ett plan för att ange klonkällan + Too many clone strokes: apply Vanishing Point first För många klonpenseldrag: tillämpa Fluktpunkt först +# Select and Mask view modes (#1754) + Onion Skin Lökskal + Marching Ants Marscherande myror + On Black På svart + On White På vitt + On Layers På lager + Show Edge Visa kant + Show Original Visa original + F cycles the views; hold X to see the image F växlar mellan vyerna; håll ned X för att se bilden +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Penseln Förfina kant (R) + Expand Detection Area Utöka identifieringsområde + Restore Original Edge Återställ ursprunglig kant + Paint over hair or fur on the image; Alt paints the other mode Måla över hår eller päls i bilden; Alt målar i det andra läget + Clear Brush Strokes Rensa penseldrag + Show rulers Visa linjaler + New Crop Preset… Ny beskärningsförinställning… + Delete Crop Preset… Ta bort beskärningsförinställning… + Select the crop presets to delete: Markera beskärningsförinställningarna som ska tas bort: + Select the crop presets to delete Markera beskärningsförinställningarna som ska tas bort + Enter a name for the crop preset Ange ett namn på beskärningsförinställningen + There is no crop size or ratio to save Det finns ingen beskärningsstorlek eller proportion att spara + Presentation Presentation + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klicka för att placera · Alt-klicka eller dra utanför arbetsytan för att ta bort + No document open Inget dokument är öppet + {n} of {max} sample points {n} av {max} provpunkter + +# History panel menu (#2966) + New Snapshot… Ny ögonblicksbild… + History Options… Historikalternativ… +# History panel › History Options… (#2966) + Allow Non-Linear History Tillåt icke-linjär historik + Make Layer Visibility Changes Undoable Gör det möjligt att ångra ändringar av lagersynlighet +# Print dialog (#2697) + Emulsion Down Emulsion nedåt + Functions Funktioner + Print Resolution: Utskriftsupplösning: + Use Previous Layer to Create Clipping Mask Använd föregående lager för att skapa klippmask + Fill with {mode}-neutral color (white) Fyll med {mode}-neutral färg (vit) + Fill with {mode}-neutral color (black) Fyll med {mode}-neutral färg (svart) + Fill with {mode}-neutral color (50% gray) Fyll med {mode}-neutral färg (50 % grå) + (No neutral color exists for {mode} mode.) (Det finns ingen neutral färg för läget {mode}.) + Apply mask to layer before removing? Vill du tillämpa masken på lagret innan den tas bort? + The layer or its mask changed. Drag the mask onto the Trash again. Lagret eller dess mask har ändrats. Dra masken till papperskorgen igen. + Delete the vector mask? Ta bort vektormasken? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Exporterade {n} färgruta till {file}|Exporterade {n} färgrutor till {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importerade {n} pensel från {group}|Importerade {n} penslar från {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importerade {n} färgruta från {file}|Importerade {n} färgrutor från {file} +@plural Played {n} step|Played {n} steps Spelade upp {n} steg|Spelade upp {n} steg +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Uppdaterade {n} länkat smart objekt som ändrats på disken|Uppdaterade {n} länkade smarta objekt som ändrats på disken +@plural Saved for Web: {n} file|Saved for Web: {n} files Sparat för webben: {n} fil|Sparat för webben: {n} filer +@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} lager som sparats i den här kompositionen togs bort. Uppdatera kompositionen eller rensa varningen.|{n} lager som sparats i den här kompositionen togs bort. Uppdatera kompositionen eller rensa varningen. +@plural {n} plane|{n} planes {n} plan|{n} plan +@plural {n} preset|{n} presets {n} förinställning|{n} förinställningar +@plural {n} sample point removed|{n} sample points removed {n} provpunkt borttagen|{n} provpunkter borttagna +@plural {n} slice|{n} slices {n} skiva|{n} skivor +@plural {n} step|{n} steps {n} steg|{n} steg +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} varning till om penselförinställningar)|{warning} (+{n} varningar till om penselförinställningar) +@plural {what} with {n} warning|{what} with {n} warnings {what} med {n} varning|{what} med {n} varningar +# View › Guides › New Guide… › Color + Light Blue Ljusblå + Light Red Ljusröd + Medium Blue Mellanblå + Light Gray Ljusgrå + Object Finder Objektsökare + No object found there — drag a rectangle around it Inget objekt hittades där – dra en rektangel runt det + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Det aktiva lagret har inga pixlar här – aktivera Ta prov på alla lager eller markera ett lager med pixlar + Remove after each stroke Ta bort efter varje penseldrag + Apply the removal ({key}) Tillämpa borttagningen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Penseldrag att ta bort: {n} (Enter tillämpar, Esc avbryter) + Load Contours… Läs in konturer… + Replace Contours… Ersätt konturer… + Save Contours… Spara konturer… + Delete Contour Ta bort kontur + Loaded contours from {file} Konturer inlästa från {file} + Saved contours to {file} Konturer sparade i {file} + Show home recent files Visa senaste filer på startsidan + Clear Brush Controls Rensa penselkontroller + Reset All Locked Settings Återställ alla låsta inställningar + Copy Texture to Other Tools Kopiera struktur till andra verktyg + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Läs in åtgärder… + Save Actions… Spara åtgärder… + New Action… Ny åtgärd… + Start Recording Starta inspelning + Stop Recording Stoppa inspelning + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Läste in {n} åtgärd från {file}|Läste in {n} åtgärder från {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} steg saknar ännu motsvarighet i PhotoCraft|{n} steg saknar ännu motsvarighet i PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Sparade {n} åtgärd i {file}|Sparade {n} åtgärder i {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} steg utan motsvarighet i Photoshop utelämnades|{n} steg utan motsvarighet i Photoshop utelämnades + + File Naming Filnamngivning + Starting serial Startnummer +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Infoga stopp… + Save Set… Spara uppsättning… + Message: Meddelande: + Allow Continue Tillåt att fortsätta + Show dialog during playback Visa dialogruta under uppspelning + Select an action first Markera en åtgärd först + Action playback stopped Uppspelning av åtgärd stoppades + 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) + Content-Aware Fill Innehållsmedveten fyllning +# Batch / Image Processor + Include All Subfolders Ta med alla undermappar + Auto Add/Delete Lägg till/ta bort automatiskt + +# 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… + 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 + Calibration Kalibrering + Red Primary Röd primärfärg + Green Primary Grön primärfärg + Blue Primary Blå primärfärg + Write xmp sidecars Skriv XMP-sidofiler + Set the tone sliders from the image Ställ in tonreglagen utifrån bilden + Settings from {name} Inställningar från {name} + Settings from {name}; {count} not supported: {names} Inställningar från {name}; {count} stöds inte: {names} + Could not write {name}: {error} Det gick inte att skriva {name}: {error} + Kept {name}: written by another application Behöll {name}: skrivet av ett annat program + Shadow Tint Skuggtoning +# History panel › History Brush source (#2966) + Paints from the History panel's source state Målar från källtillståndet i panelen Historik + Sets the source for the history brush Anger källan för historikpenseln + +# Dynamic selection context menu and appearance favorites + Select Inverse Omvänd markering + Pro (Light) Pro (ljus) + Pro (Light Gray) Pro (ljusgrå) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Etikett + Date and Time Datum och tid + Scale Units Skalenheter + Scale Factor Skalfaktor + Area Yta + Perimeter Omkrets + Circularity Rundhet + Gray Value (Minimum) Gråvärde (minimum) + Gray Value (Maximum) Gråvärde (maximum) + Gray Value (Mean) Gråvärde (medelvärde) + Gray Value (Median) Gråvärde (median) + Integrated Density Integrerad densitet + Length Längd + Show Favorite Fonts Visa favoritteckensnitt + No favorite fonts yet: click the star beside a font to add it. Inga favoritteckensnitt än: klicka på stjärnan bredvid ett teckensnitt för att lägga till det. + Favorite fonts Favoritteckensnitt + Add to Favorites Lägg till i favoriter + Remove from Favorites Ta bort från favoriter + Show All Fonts Visa alla teckensnitt + All Classes Alla klasser + Blackletter Fraktur + Decorative Dekorativ + Font preview: Förhandsgranskning av teckensnitt: + Handwritten Handskrift + Hide Font Classes Dölj teckensnittsklasser + Hide Styles Dölj stilar + Monospace Fast teckenbredd + No fonts match these filters. Inga teckensnitt matchar de här filtren. + No similar fonts: the current font doesn't say what it looks like. Inga liknande teckensnitt: det aktuella teckensnittet beskriver inte hur det ser ut. + OpenType font OpenType-teckensnitt + Sans Serif Sans serif + Script Skrivstil + Serif Serif + Show Font Classes Visa teckensnittsklasser + Show Similar Fonts Visa liknande teckensnitt + Show Styles Visa stilar + Show Variable Fonts Visa variabla teckensnitt + Slab Serif Slab serif + TrueType font TrueType-teckensnitt + Variable font Variabelt teckensnitt +# Bevel & Emboss shading controls. + Highlight Mode Högdagerläge + Highlight Color Högdagerfärg + Highlight Opacity Högdageropacitet + Shadow Mode Skuggläge + Shadow Color Skuggfärg + Shadow Opacity Skuggopacitet +# PhotoCraft release checks. + Check for Updates… Sök efter uppdateringar… + PhotoCraft Updates PhotoCraft-uppdateringar + Checking for updates… Söker efter uppdateringar… + Open the release page to choose an installer for your system. Öppna versionssidan för att välja ett installationsprogram för ditt system. + Open Download Page Öppna hämtningssida + PhotoCraft is up to date. PhotoCraft är uppdaterat. + Could not check for updates. Det gick inte att söka efter uppdateringar. + Updates have not been checked yet. Ingen sökning efter uppdateringar har gjorts ännu. + Check Again Sök igen + Automatically Check for Updates Sök efter uppdateringar automatiskt + Installed version: {version} Installerad version: {version} + PhotoCraft {version} is available. PhotoCraft {version} finns tillgänglig. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Använd den inbäddade profilen ({profile}) + Convert to the working space ({profile}) Konvertera till arbetsfärgrymden ({profile}) + Remember this choice for future images Kom ihåg det här valet för framtida bilder + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Slå på frågan igen med Fråga vid avvikelse i Redigera › Färginställningar. + Button Mode Knappläge + Button Mode On Knappläge på + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Mörkare gränssnitt + Lighten Interface Ljusare gränssnitt +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Mejsel, hård + Chisel Soft Mejsel, mjuk + Open color-less EXR as ACEScg Öppna EXR utan färginformation som ACEScg + Do not append "copy" to filename when saving a copy Lägg inte till ”kopia” i filnamnet när en kopia sparas + +# View › Guides › right-click a guide + Edit Selected Guides Redigera markerade stödlinjer + Clear Selected Guides Rensa markerade stödlinjer + Bicubic Automatic Bikubisk automatisk + Bicubic Smoother Bikubisk mjukare + Bicubic Sharper Bikubisk skarpare + Pasting from other apps is unavailable Det går inte att klistra in från andra appar + 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 kommer inte åt systemets urklipp i den här sessionen. Under Wayland döljer en Flatpak-sandlåda protokollet som bilder från andra appar läses via. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Starta PhotoCraft under XWayland för att klistra in från andra appar: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Inget att klistra in: PhotoCraft kan inte läsa systemets urklipp här + Delete this set Ta bort den här uppsättningen + New Set… Ny uppsättning… + PhotoCraft Defaults PhotoCraft-standard + Save a list of these shortcuts as a web page Spara en lista över dessa kortkommandon som en webbsida + Save the changes to this set Spara ändringarna i den här uppsättningen + Save these shortcuts as a .kys file Spara dessa kortkommandon som en .kys-fil + Save these shortcuts as a new set Spara dessa kortkommandon som en ny uppsättning + Set deleted. Uppsättningen har tagits bort. + Set saved. Uppsättningen har sparats. + Set: Uppsättning: + Summarize… Sammanfatta… + There are too many sets: delete one first. Det finns för många uppsättningar: ta bort en först. + set name uppsättningens namn + {set} (modified) {set} (ändrad) + Random Slumpmässig + Show file name in title Visa filnamn i titeln + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 % tröskelvärde + Pattern Dither Mönsterdithering + Diffusion Dither Diffusionsdithering + Halftone Screen Halvtonsraster + Custom Pattern Anpassat mönster + Frequency: Frekvens: + Lines/Inch Linjer/tum + Lines/Centimeter Linjer/centimeter + Ellipse Ellips + Diamond Romb + Cross Kors + Accept and Go to Conflict Acceptera och gå till konflikt + Changes undone. Ändringarna har ångrats. + Choose a new shortcut for this command, or leave it without one. Välj ett nytt kortkommando för det här kommandot, eller lämna det utan. + Put this command's shortcuts back as they were Återställ det här kommandots kortkommandon som de var + Take the shortcut from the other command and select it Ta kortkommandot från det andra kommandot och markera det + Undo Changes Ångra ändringar + {shortcut} is reserved by the system and can't be assigned. {shortcut} är reserverat av systemet och kan inte tilldelas. + Low-Latency Drawing (vsync off, applies at next launch) Ritning med låg latens (vsync av, gäller från nästa start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Ombilda fritt håller fortfarande på att tillämpas. Försök igen när den är klar. + The transform couldn't be applied. Ombildningen kunde inte tillämpas. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Dess lager behöver {needed} GPU-minne; budgeten ger dem {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äll in GPU-minne i Inställningar › Prestanda på vad ditt grafikkort rymmer, som mest 3/8 av dess minne. Automatiskt följer Minnesanvändning, inom en fjärdedel av den här datorns minne och 3/8 av kortets, och räknar inte dokumentet mot ett kort vars minne är känt. Statusfältet visar GPU-minne fullt så länge de inte ryms. + GPU Memory Full GPU-minne fullt + 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. Lagren behöver {needed} GPU-minne men får använda {budget}, så redigeringar som ritar om hela dokumentet görs på CPU:n. Klicka för att ställa in GPU-minne. + GPU memory budget: {budget} Budget för GPU-minne: {budget} + Graphics memory: {size} Grafikminne: {size} + This document's layers: {needed} of {budget} for layers Lagren i det här dokumentet: {needed} av {budget} för lager + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Lagren i det här dokumentet behöver {needed} men får använda {budget}: omritningar av hela dokumentet använder CPU:n + GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + {path}: the path is not valid Unicode; save under another name. {path}: sökvägen är inte giltig Unicode; spara under ett annat namn. + Replace Ersätt + {name} was changed by another app after it was opened or saved here. {name} har ändrats av en annan app efter att den öppnades eller sparades här. + Replace it with this document, or cancel and use File › Save As to keep both. Ersätt den med det här dokumentet, eller avbryt och använd Arkiv › Spara som för att behålla båda. + +# 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) (kommer snart) + (none) (ingen) + 32-bit Preview Options Alternativ för 32-bitars förhandsgranskning + 3D… 3D… + A: V: + AI Integrations AI-integrationer + About {app} Om {app} + Accented Edges Accentuerade kanter + Action playback failed: {error} Uppspelning av åtgärd misslyckades: {error} + Actual Pixels Faktiska pixlar + Add Mask Lägg till mask + Add Noise Lägg till brus + Add a mask (from the selection; ⌥ inverts) Lägg till en mask (från markeringen; ⌥ inverterar) + Add space after paragraph Lägg till avstånd efter stycke + Add space before paragraph Lägg till avstånd före stycke + Add to selection (⇧) Lägg till i markeringen (⇧) + Added swatch {name} Färgrutan {name} har lagts till + Adds grain to the soft edges of the brush tip. Lägger till korn i penselspetsens mjuka kanter. + Adjust Interval For Loops Justera intervall för slingor + Adjustment Layers Justeringslager + Adjustment Properties Justeringsegenskaper + Adjustment layers have nothing to transform Justeringslager har inget att ombilda + Adobe PDF Presets… Adobe PDF-förinställningar… + Adobe-rgb-compat Adobe RGB (kompatibel) + Adwaita (Dark) Adwaita (mörkt) + Adwaita (Light) Adwaita (ljust) + After Efter + Airbrush: keeps painting while the pointer rests. Airbrush: fortsätter att måla medan pekaren står still. + All shortcuts reset to Photoshop defaults. Alla kortkommandon har återställts till Photoshops standardvärden. + Altitude Höjd + Ambience Atmosfär + Ambient Omgivande + Amplitude Max Max. amplitud + Amplitude Min Min. amplitud + Android 1080p Android 1080p + Angled Strokes Vinklade penseldrag + Anisotropic Anisotropisk + Any Valfri + Apple Watch 45mm Apple Watch 45 mm + Apply Data Set Tillämpa datauppsättning + Apply Image Använd bild + Apply Mask Använd mask + Apply to All Använd på alla + Apply {option}: {state} Tillämpa {option}: {state} + Arbitrary Godtycklig + Arc Lower Nedre båge + Arc Upper Övre båge + Are you sure you want to quit? Vill du verkligen avsluta? + Are you sure? Är du säker? + Around Center Runt centrum + Arrangement Arrangemang + Arrows Pilar + Artboard from Group Rityta från grupp + Artboard from Layers Rityta från lager + Artboards Ritytor + Artboards to Files Ritytor till filer + Artboards to PDF Ritytor till PDF + Artistic Konstnärligt + Ask On Mismatch Fråga vid avvikelse + Ask On Missing Fråga vid saknad profil + Ask On Paste Fråga vid inklistring + Assign Profile Tilldela profil + At Vid + Author Title Författarens titel + Auto Scale Skala automatiskt + Auto-Align Layers Justera lager automatiskt + Auto-Blend Layers Blanda lager automatiskt + Autosave failed: {error} Automatisk sparning misslyckades: {error} + Back Tillbaka + Backend: {backend} ({type}) Backend: {backend} ({type}) + Background Level Bakgrundsnivå + Bamboo Bambu + Bas Relief Basrelief + Base Tree Type Typ av basträd + Based On Baserat på + Baseline shift Baslinjeförskjutning + Basic Latin Grundläggande latin + Batch Batch + Before Före + Bend Böjning + Berries Bär + Best Bästa + Black Clip Svartklippning + Black Intensity Svartintensitet + Blade Curvature Bladkrökning + Blast Vindstöt + Bleach Bypass Bleach bypass + Blend Clipped Layers as Group Blanda urklippta lager som grupp + Blend Interior Effects as Group Blanda inre effekter som grupp + Blend Text Gamma Blanda textgamma + Blending Mode Blandningsläge + Bloat Blås upp + Blocks Block + Blue Yellow Blå/gul + Blues Blå toner + Border Ram + Border Thickness Kanttjocklek + Box Blur Rutoskärpa + Bpc Svartpunktskompensering + Branches Color Grenfärg + Branches Height Grenhöjd + Branches Thickness Grentjocklek + Breeze Dark Breeze mörkt + Breeze Light Breeze ljust + Brick Tegel + Bristle Detail Borstdetalj + Browse Bläddra + Brush Detail Penseldetalj + Brush Settings… (F5): every option of this brush Penselinställningar… (F5): alla alternativ för den här penseln + Brush Strokes Penseldrag + Brush Type Penseltyp + Burlap Säckväv + Calculations Beräkningar + Camera Raw Camera Raw + Camera Raw Filter Camera Raw-filter + Cancel transform Avbryt ombildning + Candle Light Stearinljus + Canvas Size Storlek på arbetsyta + Case Sensitive Skiftlägeskänslig + Ccw Moturs + Cell Size Cellstorlek + Center X Centrum X + Center Y Centrum Y + Chalk Area Kritområde + Change Find Ersätt och sök + Changes Saved Ändringar sparade + Changes have not been saved. Ändringarna har inte sparats. + Change… Ändra… + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Charcoal Area Kolområde + Charcoal Thickness Koltjocklek + Check for Updates Sök efter uppdateringar + Checkerboard Schackbräde + Choke Krympning + Choose Folder Välj mapp + Circle Cirkel + Cleanliness Renhet + Clear Amount Rensningsgrad + Clear Selection Rensa markering + Click sets foreground · right-click sets background Klick anger förgrund · högerklick anger bakgrund + Click the first point or press ↵ to close · Esc cancels Klicka på första punkten eller tryck på ↵ för att stänga · Esc avbryter + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Klicka för att lägga till en punkt · dra bort, ⌘/Ctrl-klicka eller tryck på Delete för att ta bort + Click to apply, ⌥-click to clear overrides, double-click for Style Options Klicka för att tillämpa, ⌥-klicka för att rensa åsidosättningar, dubbelklicka för stilalternativ + Click to zoom in · ⌥-click to zoom out Klicka för att zooma in · ⌥-klicka för att zooma ut + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klick: hörn · Dra: mjuk · Klicka på första punkten: stäng · ↩ slutför · Esc avbryt + Clipping Mask Klippmask + Clipping Path… Urklippsbana… + Clone Klona + Clone source set at {x}, {y} Klonkälla inställd på {x}, {y} + Close Window Stäng fönster + Clumped Klumpad + Cmyk CMYK + Coarse Dots Grova punkter + Coated-cmyk Bestruket CMYK + Collapse group (⌥-click: all groups) Fäll ihop grupp (⌥-klicka: alla grupper) + Color Decontaminate Färgsanering + Color Halftone Färgraster + Color Indicates Masked Areas Färgen anger maskerade områden + Color Indicates Selected Areas Färgen anger markerade områden + Color Lookup Tables Färguppslagstabeller + Color Picker Färgväljare + Color Picker (Background Color) Färgväljare (bakgrundsfärg) + Color Picker (Custom Canvas Color) Färgväljare (anpassad färg för arbetsytan) + Color Picker (Foreground Color) Färgväljare (förgrundsfärg) + Color Sampler Tool Färgprovsverktyg + Color Settings Färginställningar + Color Swatches Färgrutor + Color Table Färgtabell + Color management: {error} Färghantering: {error} + Colored Pencil Färgpenna + Comic Book Serietidning + Commit Perspective Warp (↩) Verkställ perspektivdeformering (↩) + Commit Puppet Warp (↩) Verkställ marionettdeformering (↩) + Commit any current edits (⌘↩) Verkställ aktuella ändringar (⌘↩) + Commit current crop operation (↵) Verkställ aktuell beskärning (↵) + Commit transform Verkställ ombildning + Commit transform (↩) Verkställ ombildning (↩) + Commit warp (↩) Verkställ deformering (↩) + Compare Jämför + Conditional Mode Change Villkorsstyrt lägesbyte + Confirm Bekräfta + Conform Anpassa + Constraints Begränsningar + Content-Aware Fill needs a selection on a pixel layer Innehållsmedveten fyllning kräver en markering på ett pixellager + Contrasty Kontrastrik + Convert Konvertera + Convert to Alpha Channel Konvertera till alfakanal + Convert to Linked Konvertera till länkat + Convert to Profile Konvertera till profil + Cool Kall + Cooling L B B Kylfilter (LBB) + Cooling80 Kylfilter (80) + Cooling82 Kylfilter (82) + Copy Complete Stroke Kopiera fullständig linje + Copy Fill Kopiera fyllning + Copyright Url Upphovsrätts-URL + Correct C A Korrigera kromatisk aberration + Correct Distortion Korrigera distorsion + Correct Vignette Korrigera vinjettering + Could not rename the action: {error} Det gick inte att byta namn på åtgärden: {error} + Could not transform: the layer is empty Det gick inte att ombilda: lagret är tomt + Could not use Perspective Warp: the layer is empty Det gick inte att använda Perspektivdeformering: lagret är tomt + Could not use Puppet Warp: no opaque pixels Det gick inte att använda Marionettdeformering: inga ogenomskinliga pixlar + Could not use Puppet Warp: the layer is empty Det gick inte att använda Marionettdeformering: lagret är tomt + Couldn't open {name}: {error} Det gick inte att öppna {name}: {error} + Couldn't save preferences: {error}. Retrying… Det gick inte att spara inställningarna: {error}. Försöker igen… + Crack Brightness Sprickornas ljusstyrka + Crack Depth Sprickdjup + Crack Spacing Sprickavstånd + Craquelure Krakelering + Create By Skapa genom + Create Vector Mask Skapa vektormask + Create Warped Text Skapa deformerad text + Create a new layer (⇧⌘N) Skapa ett nytt lager (⇧⌘N) + Create new brush from the current settings Skapa ny pensel från de aktuella inställningarna + Create new brush preset from the current settings Skapa ny penselförinställning från de aktuella inställningarna + Create… Skapa… + Cross Process Korsframkallning + Crosshatch Korsskraffering + Crosswise Tvärs + Crystallize Kristallisera + Currency Valuta + Curve Darks Kurva: mörka toner + Curve Highlights Kurva: högdagrar + Curve Lights Kurva: ljusa toner + Curve Shadows Kurva: skuggor + Curve Splits Kurva: delningspunkter + Custom Branch Color Anpassad grenfärg + Custom Pixel Aspect Ratio Anpassat pixelförhållande + Custom pattern Anpassat mönster + Cutout Urklipp + Cw Medurs + Cyans Cyantoner + Cyrillic Cyrilliska + Daisies Prästkragar + Dark Gray Mörkgrå + Dark Intensity Mörk intensitet + Dark Rough Mörk grov + Dark Strokes Mörka penseldrag + Darken Only Endast mörkare + Darkness Mörker + Data Data + Data Set: {name} ({index}/{total}) Datauppsättning: {name} ({index}/{total}) + Data Sets as Files Datauppsättningar som filer + Date Datum + Day For Night Dag till natt + De-Interlace Avfläta + Decrease Brush Hardness Minska penselns hårdhet + Decrease Brush Size Minska penselstorlek + Decrease Type Size Minska textstorlek + Decrease Type Size by 10 Minska textstorlek med 10 + Deep Djup + Deep Blue Djupblå + Deep Emerald Djup smaragd + Deep Red Djupröd + Deep Yellow Djupgul + Default Foreground and Background Colors Standardfärger för förgrund och bakgrund + Default Leaves Standardlöv + Default Photoshop Size Standardstorlek för Photoshop + Defaults Standardvärden + Define Brush Preset Definiera penselförinställning + Define Custom Shape Definiera anpassad form + Define Pattern Definiera mönster + Definition Definition + Defringe Ta bort kantfärg + Degrees Grader + Delete Channel Ta bort kanal + Delete Mask Ta bort mask + Delete Workspace Ta bort arbetsyta + Delete brush Ta bort pensel + Delete path Ta bort bana + Delete vector mask Ta bort vektormask + Delete… Ta bort… + Depth Map Djupkarta + Depth Mode Djupläge + Diameter Diameter + Diffuse Diffusera + Diffuse Glow Diffust sken + Direction Riktning + Direction Balance Riktningsbalans + Disable Inaktivera + Disable Mask Inaktivera mask + Disable Symmetry Path Inaktivera symmetribana + Disabled Inaktiverad + Discard Changes Ignorera ändringar + Displace Förskjut + Display Bildskärm + Display profile not used Bildskärmsprofilen används inte + Display profiles: {reason} Bildskärmsprofiler: {reason} + Display-p3 Display P3 + Distance Avstånd + Distortion Distorsion + Distribution Fördelning + Do you want to save you made to “{name}” before quitting? Vill du spara ändringarna du gjort i ”{name}” innan du avslutar? + Document Sizes Dokumentstorlekar + Documentation Dokumentation + Documents Dokument + Dont Enlarge Förstora inte + Dot Punkt + Double Line Dubbel linje + Down Nedåt + Draft Utkast + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {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 · {commit} verkställer · Esc avbryter + Drag: draw freehand · Release on the start point: close Dra: rita på frihand · Släpp på startpunkten: stäng + Driver: {driver} Drivrutin: {driver} + Dry Brush Torr pensel + Duplicate Channel Duplicera kanal + Duplicate Layers Duplicera lager + Duplication Duplicering + Dust & Scratches Damm och repor + Edge Kant + Edge Brightness Kantljusstyrka + Edge Extension Kantförlängning + Edge Fidelity Kanttrohet + Edge Intensity Kantintensitet + Edge Only Endast kanter + Edge Simplicity Kantenkelhet + Edge Thickness Kanttjocklek + Edge Width Kantbredd + Edit Toolbar Redigera verktygsfältet + Edit… Redigera… + Elevation Höjd + Eliminate Eliminera + Emboss Relief + Enable Aktivera + Enable Mask Aktivera mask + End X Slut X + End Y Slut Y + Enlarged Förstorad + Erase with Brush or Pencil (hold) Radera med pensel eller blyertspenna (håll ned) + Error Fel + Even Fields Jämna fält + Exit Quick Mask Avsluta snabbmask + Expand group (⌥-click: all groups) Expandera grupp (⌥-klicka: alla grupper) + Export As Exportera som + Export As: {name} Exportera som: {name} + Export Contents Exportera innehåll + Export Image Exportera bild + Export Options Exportalternativ + Export Preferences Exportera inställningar + Export Settings Exportera inställningar + Export to Exportera till + Export/Import Presets Exportera/importera förinställningar + Exported contents to {path} Exporterade innehåll till {path} + Exported presets to {path} Exporterade förinställningar till {path} + Exported {path} Exporterade {path} + Exported {path} ({size}) Exporterade {path} ({size}) + Exporting… Exporterar… + ExtraBold Extrafet + ExtraLight Extratunn + Extrude Extrudera + Fade Tona ut + Faded Film Blekt film + Failed Misslyckades + Failed to open file Det gick inte att öppna filen + Failed to save file Det gick inte att spara filen + Fallback: {reason} Reserv: {reason} + Feather Radius Fjädringsradie + Fiber Length Fiberlängd + Fibers Fibrer + Field Blur Fältoskärpa + File Format Filformat + File Info Filinformation + File Name Filnamn + File Size Filstorlek + File not found Filen hittades inte + Filename Filnamn + Fill Color Fyllningsfärg + Fill Opacity Fyllningsopacitet + Fill Path… Fyll bana… + Fill path with foreground color Fyll banan med förgrundsfärgen + Fill with Background Color Fyll med bakgrundsfärg + Fill with Background Color, Preserve Transparency Fyll med bakgrundsfärg, bevara genomskinlighet + Fill with Foreground Color Fyll med förgrundsfärg + Fill with Foreground Color, Preserve Transparency Fyll med förgrundsfärg, bevara genomskinlighet + Film Grain Filmkorn + Filter Gallery Filtergalleri + Find Sök + Find and Replace Text Sök och ersätt text + Fine Fin + Fine Dots Fina punkter + Finish Slutför + Fit Image Anpassa bild + Fit to Window Anpassa till fönstret + Flame Flamma + Flame Bottom Alignment Justering av flammans nederkant + Flame Lines Flamlinjer + Flame Shape Flamform + Flame Style Flamstil + Flame Type Flamtyp + Flat Platt + Flat Shading Platt skuggning + Flower Color Blomfärg + Focal Distance Fokusavstånd + Focus Fokus + Focus Area Fokusområde + Foreground Level Förgrundsnivå + Forward Framåt + Forward Warp Deformera framåt + Frame Ram + Frame {current} / {total} Bildruta {current} / {total} + Free Transform Path Ombilda bana fritt + Freeze Lasso Frys-lasso + Freeze Mask Frysmask + Fresco Fresk + From Från + From Left Från vänster + From Right Från höger + Frosted Frostad + Full Screen Helskärm + Fullscreen Helskärm + Fuzziness Tolerans + GPU backend preference: {backend} Önskad GPU-backend: {backend} + GPU device was lost; using the CPU renderer. GPU-enheten förlorades; CPU-renderaren används. + GPU error; using the CPU renderer. GPU-fel; CPU-renderaren används. + GPU retry scheduled Nytt GPU-försök schemalagt + Gamma Gamma + Gaussian Blur Gaussisk oskärpa + General Blending Allmän blandning + General Brushes Allmänna penslar + Generators Generatorer + Generic Allmän + Glass Glas + Global Light Globalt ljus + Gloss Glans + Glow Amount Glödmängd + Glow Brightness Glödens ljusstyrka + Glow Size Glödstorlek + Glowing Edges Glödande kanter + Good Bra + Grade Balance Gradering: balans + Grade Blending Gradering: blandning + Grade Global Gradering: global + Grade Highlights Gradering: högdagrar + Grade Midtones Gradering: mellantoner + Grade Shadows Gradering: skuggor + Gradient Options Gradientalternativ + Grain Amount Kornmängd + Grain Roughness Kornets grovhet + Grain Size Kornstorlek + Grain Type Korntyp + Graininess Kornighet + Grainy Dots Korniga punkter + Graphics adapter Grafikkort + Graphics adapter: {adapter} Grafikkort: {adapter} + Gray-gamma-2.2 Grå gamma 2,2 + Greek Grekiska + Greens Gröna + Grid (⌘') Rutnät (⌘') + Grid view Rutnätsvy + Group from Layers Grupp från lager + Groups Grupper + Grout Width Fogbredd + Guide Layout… Stödlinjelayout… + HDR Toning HDR-toning + Hand Tool (hold) Handverktyg (håll ned) + Hearts Hjärtan + Heptagon Heptagon + Hexagon Hexagon + Hidden Dold + Hide Grid Dölj rutnät + Hide Others Göm övriga + Hide the layer's effects (⌥-click: all layers) Dölj lagrets effekter (⌥-klicka: alla lager) + Hide {app} Göm {app} + High Pass Högpass + Highlight Högdager + Highlight Amount Mängd för högdagrar + Highlight Area Högdagerområde + Highlight Radius Högdagerradie + Highlight Strength Högdagerstyrka + Highlight Tone Tonbredd för högdagrar + Highlights from: Högdagrar från: + Horizontal Distortion Vågrät distorsion + Horizontal Only Endast vågrätt + Horizontal scale Vågrät skala + Horizontal type Vågrät text + Hotspot Brännpunkt + Hsb HSB + Hsl HSL + Hsl Hue HSL-nyans + Hsl Lum HSL-luminans + Hsl Sat HSL-mättnad + Image Balance Bildbalans + Image Processor Bildprocessor + Image compositor: {processor} Bildkompositör: {processor} + Import Brushes Importera penslar + Import Brushes… (.abr) Importera penslar… (.abr) + Import Image Importera bild + Import Options Importalternativ + Import Photoshop brushes (.abr) Importera Photoshop-penslar (.abr) + Import Settings Importera inställningar + Importing {what} from {file}… Importerar {what} från {file}… + Importing… Importerar… + In Black Svart in + 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. + 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. + “{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 + Move Down Flytta nedåt + Previous Brush Föregående pensel + Next Brush Nästa pensel + First Brush Första penseln + Last Brush Sista penseln + Preview size Storlek på förhandsgranskning + Memory Usage Minnesanvändning + Per-document RAM limit (MB) RAM-gräns per dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installerat RAM: {ram} MB; den här gränsen: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begränsar pixlarna i varje öppet dokument plus historiken. De äldsta ångrastegen tas bort när gränsen överskrids. + Applies immediately when saved. Memory is not reserved in advance. Gäller direkt när du sparar. Minnet reserveras inte i förväg. + 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. När GPU-minne är inställt på Automatiskt begränsar detta även GPU:ns lagerbudget. Budgeten är begränsad till en fjärdedel av det installerade RAM-minnet och, när det är känt, tre åttondelar av grafikkortets minne, med minst 512 MB. Att höja den här gränsen utöver dessa tak ökar inte GPU-budgeten. + Command Kommando + Use Använd + In Front Framför + Behind Bakom + +# DDS Options (#1327) + DDS Options DDS-alternativ + Compression Komprimering + Generate Mipmaps Generera mipmappar + +# Color Table (.act) dialog + There is no color table to save. Det finns ingen färgtabell att spara. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Sparade {n} färg i {file}|Sparade {n} färger i {file} + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Importera stilar… + Export Selected Styles… Exportera markerade stilar… + Export Styles… Exportera stilar… + {file} is not a style library (.asl) {file} är inte ett stilbibliotek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importerade {n} stil från {file}|Importerade {n} stilar från {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Exporterade {n} stil till {file}|Exporterade {n} stilar till {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bekräfta eller avbryt den öppna dialogrutan för åtgärdssteget först + Pro Medium Gray Pro mellangrå + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ljusgrå + Pro Light Pro ljus + Resize Windows to Fit Anpassa fönstrens storlek + EXR EXR + Increase Brush Size Öka penselstorlek + Increase Type Size Öka textstorlek + Increase Type Size by 10 Öka textstorlek med 10 + Indent first line Indrag för första raden + Indent left margin Indrag för vänstermarginal + Indent right margin Indrag för högermarginal + Indexed Indexerad + Infinite Oändlig + Information Information + Initial Direction Startriktning + Ink Outlines Bläckkonturer + Inner Bevel Inre fasning + Input Indata + Input Levels Ingångsnivåer + Input Mode Inmatningsläge + Install Plug-in Installera plugin + Intensity Intensitet + Intent Återgivningsmetod + Interpret Footage Tolka material + Intersect with selection (⇧⌥) Skär med markering (⇧⌥) + Interval Intervall + Invalid panel layout; restored the default dock Ogiltig panellayout; standarddockningen har återställts + Invert Mask Invertera mask + Invert Selection Omvänd markering + Iris Blur Irisoskärpa + Ivy Murgröna + Jag Taggig + Join the ArtCraft Discord Gå med i ArtCraft på Discord + Join the ArtCraft Discord ({url}) Gå med i ArtCraft på Discord ({url}) + Keeps the current texture when switching presets. Behåller den aktuella texturen när du byter förinställning. + Kernel Kärna + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: Metrik, Optisk eller ett värde i 1/1000 em (Alt+←/→ vid markören) + Keyboard Tangentbord + Lab Lab + Landscape, 10 x 8 Liggande, 10 x 8 + Landscape, 6 x 4 Liggande, 6 x 4 + Landscape, 7 x 5 Liggande, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin Extended-A + Latin Extended-B Latin Extended-B + Layer Comps to Files Lagerkompositioner till filer + Layer Content Options Alternativ för lagerinnehåll + Layer Count Antal lager + Layer From Background Lager från bakgrund + Layer Knocks Out Drop Shadow Lagret sparar ut skuggan + Layer Mask Hides Effects Lagermasken döljer effekter + Layer Properties Lageregenskaper + Layer Styles Lagerstilar + Layer from Background Lager från bakgrund + Layers to Files Lager till filer + Layout Layout + Leading (set to the font size × auto-leading when Auto) Radavstånd (sätts till teckenstorleken × automatiskt radavstånd vid Auto) + Leaf Color Bladfärg + Learn More Läs mer + Leaves Amount Bladmängd + Leaves Color Bladfärg + Leaves Size Bladstorlek + Left Diagonal Vänsterdiagonal + Legacy Äldre + Legal Legal + Lens Blur Linsoskärpa + Lens Correction Objektivkorrigering + Lens Flare Motljus + Less Mindre + Letter Letter + Level Based Nivåbaserad + Library Bibliotek + Light Dark Balance Ljus/mörk-balans + Light Direction Ljusriktning + Light Intensity Ljusintensitet + Light Rough Lätt ojämn + Light Type Ljustyp + Light X Ljus X + Light Y Ljus Y + Light Z Ljus Z + Lighten Grout Gör fogar ljusare + Lighten Only Endast ljusare + Lighting Belysning + Lighting Effects Ljuseffekter + Linear Dodge Linjär skugga + Linear Height Linjär höjd + Linear-srgb Linjär sRGB + Link Länka + Liquify Gör flytande + List Lista + List view Listvy + Live Preview Live-förhandsgranskning + Load 3D LUT Läs in 3D-LUT + Load Files into Stack Läs in filer i stapel + Load path as a selection Läs in bana som markering + Loading… Läser in… + Lock All Lås alla + Lock Image Pixels Lås bildpixlar + Lock Position Lås position + Lock Transparent Pixels Lås genomskinliga pixlar + Locked Låst + Long Lines Långa linjer + Long Strokes Långa penseldrag + Lorem Ipsum Lorem Ipsum + Lower Nedre + Lum Lum + Lut LUT + Magentas Magentatoner + Make Selection from Path Skapa markering från bana + Make Selection… Skapa markering… + Make Symmetry Path Skapa symmetribana + Make Work Path… Skapa arbetsbana… + Make work path from selection Skapa arbetsbana från markeringen + Manual Manuell + Map Document Mappa dokument + Map Layer Mappa lager + Map Path Mappa bana + Mask Channel Maskkanal + Mask Incomplete Blocks Maskera ofullständiga block + Mask None Maskera inget + Masks Masker + Match Color Matcha färg + Math Symbols Matematiska symboler + Max Offset Max. förskjutning + Max Radius Max. radie + Measurement Scale Mätskala + Measurement Scale Marker Mätskalemarkör + Medium Dots Medelstora punkter + Medium Gray Mellangrå + Medium Lines Medellånga linjer + Medium Strokes Medellånga penseldrag + Merge Channels… Sammanfoga kanaler… + Merge Spot Channel Sammanfoga dekorfärgskanal + Merge to HDR Pro Sammanfoga till HDR Pro + Metallic Metallisk + Metrics Metrik + Mezzotint Mezzotint + Middle Mitten + Midtone Mellanton + Monitor Profile Bildskärmsprofil + Monitor profile in use on {display}: {profile} Bildskärmsprofil som används på {display}: {profile} + Monitor profile in use: {profile} Bildskärmsprofil som används: {profile} + Monitor profile on {display} (main window): {profile} Bildskärmsprofil på {display} (huvudfönster): {profile} + Monitor profile on {display}: {profile} Bildskärmsprofil på {display}: {profile} + Monitor profile: {profile} Bildskärmsprofil: {profile} + Mono Contrast Monokontrast + Monochromatic Monokromatisk + More Mer + Mosaic Mosaik + Mosaic Tiles Mosaikplattor + Motion Blur Rörelseoskärpa + Mouse Mus + Move Layer Flytta lager + Movie Prime Film med fast brännvidd + Multiple Flames Along Path Flera lågor längs banan + Multiple Flames One Direction Flera lågor i en riktning + Multiple Flames Path Directions Flera lågor i banans riktningar + Multiple Flames Various Length Flera lågor med olika längd + My Brushes Mina penslar + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Inbyggt dra och släpp av filer stöds inte på Wayland ännu. Använd Arkiv › Öppna, eller kör PhotoCraft under XWayland med `WAYLAND_DISPLAY= photocraft`. + Nearest Närmaste + Neon Glow Neonglöd + Neutralize Neutralisera + Neutrals Neutrala toner + New Brush Preset Ny penselförinställning + New Channel… Ny kanal… + New Guide Ny stödlinje + New Guide Layout Ny stödlinjelayout + New Spot Channel… Ny dekorfärgskanal… + New Video Layer from File Nytt videolager från fil + New Window Nytt fönster + New Workspace Ny arbetsyta + New document Nytt dokument + New document… Nytt dokument… + New document… ⌘N Nytt dokument… ⌘N + Next Nästa + No Document Inget dokument + No Image Ingen bild + No Layers Inga lager + No Results Inga resultat + No Results Found Inga resultat hittades + No Selection Ingen markering + No custom shape "{name}": pick one in the options bar or Window › Shapes Ingen egen form ”{name}”: välj en i alternativfältet eller Fönster › Former + No layers Inga lager + No recent files Inga senaste filer + No selection Ingen markering + Noise Color Färgbrus + Noise Color Detail Färgbrusdetalj + Noise Luminance Luminansbrus + Noise Luminance Detail Luminansbrusdetalj + Nothing to Redo Inget att göra om + Nothing to Undo Inget att ångra + Nothing to paste: the clipboard holds no image Inget att klistra in: urklipp innehåller ingen bild + Number Of Levels Antal nivåer + Numbers Siffror + Ocean Ripple Havskrusning + Octagon Oktagon + Odd Fields Udda fält + Oil Paint Oljemålning + On På + One Flame Along Path En låga längs banan + Open As Öppna som + Open File Öppna fil + Open Folder Öppna mapp + Open Image Öppna bild + Open Recent is unavailable on the web Öppna senaste är inte tillgängligt på webben + Opened {url} Öppnade {url} + Opening Öppnar + Open… ⌘O Öppna… ⌘O + Operation canceled Åtgärden avbröts + Operation cancelled Åtgärden avbröts + Operation completed Åtgärden slutfördes + Operation failed Åtgärden misslyckades + Optical Optisk + Original Ratio Ursprungliga proportioner + Out Black Utdata svart + Out From Center Utåt från centrum + Out Of Gamut Utanför färgomfånget + Out White Utdata vitt + Out of memory Slut på minne + Outer Bevel Yttre fasning + Output Levels Utgångsnivåer + Oval Oval + Overlay Edge Överläggskant + Overlay: Rule of Thirds Överlägg: tredjedelsregeln + PSD PSD + Package Paketera + Page Setup… Utskriftsformat… + Paint Daubs Färgklickar + Paints from the document's opening state Målar från dokumentets tillstånd när det öppnades + Palette Knife Palettkniv + Panel Panel + Paper Papper + Paper Brightness Pappersljusstyrka + Parallel Parallell + Paste Complete Stroke Klistra in fullständig linje + Paste Fill Klistra in fyllning + Patchwork Lapptäcke + Path Blur Banoskärpa + Paths to Illustrator Banor till Illustrator + Pattern Fill… Mönsterfyllning… + Pattern Type Mönstertyp + Pencil Width Pennbredd + Pentagon Pentagon + Percentage Procent + Permission denied Åtkomst nekad + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft fortsätter att försöka; tills en sparning lyckas går ändrade inställningar förlorade när programmet stängs. + Photomerge Photomerge + Pick Color From Image Hämta färg från bilden + Picture Frame Bildram + Pillow Emboss Kuddrelief + Pinch Nyp + Pins Nålar + Place Embedded Placera inbäddad + Place Linked Placera länkad + Place Scale Marker Placera skalmarkör + Plane Plan + Planes Plan + Plastic Wrap Plastfolie + Platform: {os} {arch} Plattform: {os} {arch} + Please wait… Vänta… + Point Punkt + Point Curve Punktkurva + Point Curve Blue Punktkurva blå + Point Curve Green Punktkurva grön + Point Curve Red Punktkurva röd + Pointed Spetsig + Pointillize Pointillera + Polar Coordinates Polära koordinater + Polar To Rectangular Polär till rektangulär + Policy Cmyk Princip för CMYK + Policy Gray Princip för grå + Policy Rgb Princip för RGB + Pond Ripples Vattenringar + Portrait, 4 x 6 Stående, 4 x 6 + Portrait, 5 x 7 Stående, 5 x 7 + Postcard Vykort + Poster Affisch + Poster Edges Affischkanter + Posterization Posterisering + Preferences can't be saved Inställningarna kan inte sparas + Preferences saved Inställningarna har sparats + Preferences were reset: {error} Inställningarna återställdes: {error} + Preferences… Inställningar… + Preserve Bevara + Preset Manager Förinställningshanterare + Press keys Tryck på tangenter + Preview Image Förhandsgranskningsbild + Previous Föregående + Prime105 Fast 105 mm + Prime35 Fast 35 mm + Printed to {path} Skrevs ut till {path} + Pro (Dark) Pro (mörk) + Pro (Medium Gray) Pro (mellangrå) + Processing… Bearbetar… + Profile Profil + Proof Setup… Korrekturinställningar… + Prophoto-compat ProPhoto-kompatibel + Pucker Skrynkla ihop + Push Left Skjut åt vänster + Pyramids Pyramider + Quick Actions Snabbåtgärder + Quit Avsluta + Quit PhotoCraft Avsluta PhotoCraft + Quit {app} Avsluta {app} + R G B RGB + R g b RGB + Radial Radiell + Radial Blur Radiell oskärpa + Radius X Radie X + Radius Y Radie Y + Ran script {name} Körde skriptet {name} + Randomize Length Slumpmässig längd + Randomize Shapes Slumpmässiga former + Rasterize Fill Content Rasterisera fyllningsinnehåll + Rasterize Smart Object Rasterisera smart objekt + Ratio Förhållande + Raw Defaults Raw-standardvärden + Reading Läsning + Ready Klar + Rec2020 Rec2020 + Recent Files Senaste filer + Reconstruct All Rekonstruera alla + Recovered {name} Återställde {name} + Rectangular Selection Rektangulär markering + Rectangular To Polar Rektangulär till polär + Red Cyan Röd/cyan + Reds Röda + Reduce Color Noise Minska färgbrus + Reduce Noise Minska brus + Reflected Speglad + Relief Relief + Relink to File Länka om till fil + Remove All Ta bort alla + Remove Jpeg Artifact Ta bort JPEG-artefakter + Rename Brush Byt namn på pensel + Rename Channel… Byt namn på kanal… + Rename Group Byt namn på grupp + Rename Tool Preset Byt namn på verktygsförinställning + Render Video Rendera video + Rendering… Renderar… + Reorder Layer Ändra lagerordning + Replace Color Ersätt färg + Replace Contents Ersätt innehåll + Replace Footage Ersätt material + Report a Bug Rapportera en bugg + Report an Issue Rapportera ett problem + Reset Alignment Återställ justering + Reset All Återställ alla + Reset Preferences Återställ inställningar + Resolve Missing Fonts Lös saknade teckensnitt + Restart Starta om + Restart Required Omstart krävs + Resume Återuppta + Retry Försök igen + Rgb RGB + Ridges Åsar + Right Diagonal Höger diagonal + Ripple Krusning + Ripple Magnitude Krusningsstyrka + Ripple Size Krusningsstorlek + Rope Rep + Roses Rosor + Rough Pastels Grova pasteller + Row Order Radordning + Rulers (⌘R) Linjaler (⌘R) + Sample Size Provstorlek + Sampled Colors Provtagna färger + Sandstone Sandsten + Sat Mätt. + Save As Spara som + Save Changes Spara ändringar + Save File Spara fil + Save Image Spara bild + Save Path Spara bana + Save Pixel Aspect Ratio Spara pixelbildförhållande + Save a Copy Spara en kopia + Save before closing? Vill du spara innan du stänger? + Save for Web (Legacy) Spara för webben (äldre) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Spara ditt arbete och starta om PhotoCraft för att försöka med GPU-acceleration igen. CPU-rendering används under resten av sessionen. + Saved a copy as {path} Sparade en kopia som {path} + Saved {path}; image assets: {n} in {dir} Sparade {path}; bildresurser: {n} i {dir} + Saving… Sparar… + Scale Effects Skala effekter + Scaling Skalning + Scallops Bågkanter + Script Events Manager Skripthändelsehanterare + Scroll menu down Rulla menyn nedåt + Scroll menu up Rulla menyn uppåt + Search commands Sök kommandon + Search commands (⌘K) Sök kommandon (⌘K) + Search commands, tools and panels Sök efter kommandon, verktyg och paneler + Search menus Sök i menyer + Seed Startvärde + Select Custom Color… Välj anpassad färg… + Select Data Points Markera datapunkter + Select Layer Markera lager + Select and Mask Markera och maskera + Color Aware Färgmedveten + Decontaminate Colors Sanera färger + Edge Detection Kantidentifiering + Global Refinements Globala förfiningar + High Quality Preview Förhandsgranskning med hög kvalitet + Object Aware Objektmedveten + Object Aware needs a segmentation model, which isn't available yet Objektmedveten behöver en segmenteringsmodell, som inte är tillgänglig än + Real-Time Refinement Förfining i realtid + Refine Edge Brush Tool Kantförfiningspenselverktyg + Refine Hair Förfina hår + Refine Hair needs hair detection, which isn't available yet Förfina hår behöver håridentifiering, som inte är tillgänglig än + Refine Mode Förfiningsläge + Select and Mask was closed: another layer was selected Markera och maskera stängdes: ett annat lager markerades + Select and Mask was closed: its document is no longer the active one Markera och maskera stängdes: dess dokument är inte längre det aktiva + View Mode Visningsläge + Selected at launch: {backend} Valdes vid start: {backend} + SemiBold Halvfet + Sensitivity Känslighet + Sent to the printer ({spooler}) Skickat till skrivaren ({spooler}) + Sepia Sepia + Services Tjänster + Set Colors Ange färger + Set Measurement Scale Ange mätskala + Sgray sGray + Shadow Skugga + Shadow Amount Skuggmängd + Shadow Intensity Skuggintensitet + Shadow Radius Skuggradie + Shadow Tone Skuggton + Shadows up to: Skuggor upp till: + Shadows/Highlights Skuggor/högdagrar + Shallow Grund + Shape Blur Formoskärpa + Sharpen Amount Skärpemängd + Sharpen Detail Skärpedetalj + Sharpen Details Skärpa detaljer + Sharpen Masking Skärpemaskering + Sharpen Radius Skärperadie + Shear Skjuva + Shine Glans + Short Lines Korta linjer + Short Strokes Korta streck + Shortcut Kortkommando + Shortcuts Kortkommandon + Show All Visa alla + Show Extras Options Alternativ för extrafunktioner + Show Guides Visa stödlinjer + Show Guides (⌘;) Visa stödlinjer (⌘;) + Show Rulers Visa linjaler + Show the layer's effects (⌥-click: all layers) Visa lagrets effekter (⌥-klicka: alla lager) + Simple Enkel + Simulate Paper Simulera papper + Sine Sinus + Sketch Skiss + Skip Hoppa över + Slice Options Alternativ för skivor + Smart Blur Smart oskärpa + Smart Sharpen Smart skärpa + Smoothness Jämnhet + Smudge Stick Smetkrita + Snap to Grid Fäst vid rutnät + Snowflakes Snöflingor + Soften Mjuka upp + Softness Mjukhet + Software adapter Programvaruadapter + Solarized Dark Solarized mörk + Solid Color Enfärgad + Solid Front Faces Enfärgade framsidor + Source Channel Källkanal + Source Invert Invertera källa + Source Layer Källager + Source1 Channel Kanal för källa 1 + Source1 Invert Invertera källa 1 + Source2 Channel Kanal för källa 2 + Source2 Invert Invertera källa 2 + Source: set Källa: angiven + Space Blanksteg + Sparkle Gnistor + Spatter Stänk + Speckle Fläckar + Speed Hastighet + Spherize Sfärisera + Spin Snurra + Spin Blur Snurroskärpa + Split Channels Dela upp kanaler + Spot Dekor + Spray Radius Sprutradie + Sprayed Strokes Sprutade penseldrag + Spread Spridning + Sprinkles Strössel + Square Size Kvadratstorlek + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Kvadratiskhet + Stagger Förskjut + Stained Glass Målat glas + Star Stjärna + Stars Stjärnor + Start X Start X + Start Y Start Y + Statistics Statistik + Step {step} ({id}) failed: {error} Steg {step} ({id}) misslyckades: {error} + Stippled Punkterad + Stops Stopp + Strength Styrka + Stretch Sträck ut + Stroke Color Linjefärg + Stroke Detail Penseldragsdetalj + Stroke Direction Penseldragsriktning + Stroke Length Penseldragslängd + Stroke Pressure Penseltryck + Stroke Size Penseldragsstorlek + Stroke Width Linjebredd + Stroke path with brush Linje på bana med pensel + Strokes Penseldrag + Studio (Dark) Studio (mörk) + Studio (Light) Studio (ljus) + Stylization Stilisering + Subtract Shapes at Overlap Subtrahera former vid överlappning + Subtract from selection (⌥) Subtrahera från markering (⌥) + Sumi-e Sumi-e + Surface Blur Ytoskärpa + Switch Foreground and Background Colors Växla förgrunds- och bakgrundsfärg + System Info Systeminformation + Tablet Ritplatta + Tabloid Tabloid + Taper Avsmalning + Target Mål + Target Channel 3 Välj kanal 3 + Target Channel 4 Välj kanal 4 + Target Channel 5 Välj kanal 5 + Target Channel 6 Välj kanal 6 + Target Channel 7 Välj kanal 7 + Target Channel 8 Välj kanal 8 + Target Channel 9 Välj kanal 9 + Target Composite Channel Välj sammansatt kanal + Target X Mål X + Target Y Mål Y + Teal Orange Blågrön/orange + Technology Previews… Teknikförhandsgranskningar… + Temporary Tillfällig + Text Layer Textlager + Texture Coverage Strukturtäckning + Texturizer Strukturskapare + Thaw Mask Tina mask + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Arbetsytan visas som sRGB. Välj en bildskärmsprofil i Redigera › Färginställningar. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Arbetsytan på {display} visas som sRGB. Välj en bildskärmsprofil i Redigera › Färginställningar. + The current gradient (pick one in Window › Gradients) Den aktuella gradienten (välj en i Fönster › Gradienter) + The document does not have an embedded colour profile. Working space: {working}. Dokumentet har ingen inbäddad färgprofil. Arbetsfärgrymd: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokumentet har en inbäddad färgprofil som inte matchar arbetsfärgrymden.\nInbäddad: {embedded}\nArbetsfärgrymd: {working} + The file could not be opened. Det gick inte att öppna filen. + The file could not be saved. Det gick inte att spara filen. + Thickness Tjocklek + This Layer: Det här lagret: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Det här smarta objektet måste rasteriseras innan du fortsätter. Innehållet går inte längre att redigera. Vill du rasterisera det smarta objektet? + This adjustment ({key}) is kept as imported and can't be edited yet. Den här justeringen ({key}) behålls som den importerades och kan inte redigeras ännu. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Det här fyllningslagret måste rasteriseras innan du fortsätter. Fyllningsinnehållet går inte längre att redigera. Vill du rasterisera lagret? + This layer must be rasterized before proceeding. Det här lagret måste rasteriseras innan du fortsätter. + This session: {reason} Den här sessionen: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Det här formlagret måste rasteriseras innan du fortsätter. Det går inte längre att redigera som form. Vill du rasterisera formen? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Det här textlagret måste rasteriseras innan du fortsätter. Texten går inte längre att redigera. Vill du rasterisera texten? + Tile Size Rutstorlek + Tiles Rutor + Tilt-Shift Tilt-shift + Tint Color Toningsfärg + Tiny Lens Liten lins + Title Titel + To Till + To Center Mot mitten + Toggle Layer Mask Overlay Visa/dölj lagermaskens överlägg + Toggle the Brush Settings panel Visa/dölj panelen Penselinställningar + Toggle the Brush Settings panel (F5) Visa/dölj panelen Penselinställningar (F5) + Tool Options Verktygsalternativ + Trace Contour Spåra kontur + Tracking (1/1000 em) Spärrning (1/1000 em) + Transformation Ombildning + Transition Övergång + Transparency Shapes Layer Genomskinlighet formar lager + Trap Överfyllning + Tree Träd + Trim Trimma + Turbulent Turbulent + Twirl Virvel + Twirl Clockwise Virvel medurs + Twirl Counterclockwise Virvel moturs + Type Options Textalternativ + Unaltered Oförändrad + Undefined Areas Odefinierade områden + Underlying Layer: Underliggande lager: + Underpainting Undermålning + Underwater Under vatten + Ungroup Dela upp grupp + Uniform Enhetlig + Unite Shapes at Overlap Förena former vid överlappning + Unlink Ta bort länk + Unlock Lås upp + Unlocked Upplåst + Unsaved Changes Osparade ändringar + Unsharp Mask Oskarp mask + Unsupported file format Filformatet stöds inte + Unsupported format Formatet stöds inte + Up Upp + Update Uppdatera + Updates Uppdateringar + Upper Övre + Use Custom Color Använd anpassad färg + 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 + Vertical Distortion Lodrät distorsion + Vertical Only Endast lodrätt + Vertical Type Tool Verktyget Lodrät text + Vertical scale Lodrät skala + Vertical type Lodrät text + Video Frames to Layers Videobildrutor till lager + Vignette Amount Vinjettmängd + Vignette Feather Vinjettfjädring + Vignette Highlights Vinjetthögdagrar + Vignette Midpoint Vinjettmittpunkt + Vignette Roundness Vinjettrundhet + Vignette Style Vinjettstil + Vine Color Rankans färg + Vine With Flowers Ranka med blommor + Vine With Leaves Ranka med blad + Violent Våldsam + W: B: + WIA Support WIA-stöd + Warm Varm + Warming Filter (85) Värmefilter (85) + Warming L B A Värmefilter (LBA) + Warming81 Värmefilter (81) + Warming85 Värmefilter (85) + Warmth Värme + Warning Varning + Warp Text Deformera text + Watercolor Akvarell + Watercolour look: lighter interior, darker rims. Akvarellutseende: ljusare inre, mörkare kanter. + Wavelength Max Max. våglängd + Wavelength Min Min. våglängd + Waves Vågor + Web Large Webb, stor + Web Minimum Webb, minimum + Web Most Common Webb, vanligast + WebP (lossless) WebP (förlustfri) + Wetness Väta + White Clip Vitklippning + White Intensity Vitintensitet + White Is High Vitt är högt + Whole Word Hela ord + Wide Blurry Bred oskarp + Wide Sharp Bred skarp + Wind Vind + Window renderer: {renderer} Fönsterrenderare: {renderer} + Working Arbetsfärgrymd + Working Cmyk Arbets-CMYK + Working Gray Arbetsgrå + Working Rgb Arbets-RGB + Working-cmyk Arbets-CMYK + Workspaces Arbetsytor + Wrap Radbryt + Yellows Gula + ZigZag Sicksack + Zigzag Sicksack + Zoom In (hold, drag to scrub) Zooma in (håll ned, dra för att skrubba) + Zoom Out (hold) Zooma ut (håll ned) + auto auto + blue blå + brushes penslar + fallback reserv + gradients gradienter + gray grå + green grön + layer mask lagermask + manual manuell + none ingen + off av + on på + orange orange + previous reading kept; the last re-read failed: {reason} föregående läsning behölls; den senaste omläsningen misslyckades: {reason} + red röd + unknown okänd + unknown type okänd typ + vector mask vektormask + violet violett + yellow gul + {index} of {total} {index} av {total} + {label} is not available: {reason} {label} är inte tillgängligt: {reason} + {message} (notes: {n}; {warning}) {message} (anteckningar: {n}; {warning}) + {shortcut} assigned. {shortcut} har tilldelats. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} används redan av {commands} och tas bort därifrån när du klickar på OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ navigera ↵ kör esc stäng + ✓ Color Indicates Masked Areas ✓ Färg anger maskerade områden + ✓ Color Indicates Selected Areas ✓ Färg anger markerade områden +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Radera +@id edit.purge.undo Ångra +@id edit.stroke Linje… +@id edit.transform.scale Skala +@id file.close Stäng +@id filter.sharpen.sharpen Skärpa +@id layer.layerMask.apply Tillämpa +@id select.all Markera allt +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importerade {n} pensel från {file}|Importerade {n} penslar från {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importerade {n} gradient från {file}|Importerade {n} gradienter från {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importerade {n} mönster från {file}|Importerade {n} mönster från {file} +cameraRaw Balance Balans +cameraRaw Clarity Klarhet +cameraRaw Color Färg +cameraRaw Color Mixer Färgmixer +cameraRaw Contrast Kontrast +cameraRaw Curve Kurva +cameraRaw Dehaze Ta bort dis +cameraRaw Detail Detalj +cameraRaw Effects Effekter +cameraRaw Exposure Exponering +cameraRaw Feather Fjädring +cameraRaw Highlights Högdagrar +cameraRaw Luminance Luminans +cameraRaw Masking Maskering +cameraRaw Midpoint Mittpunkt +cameraRaw Midtones Mellantoner +cameraRaw Radius Radie +cameraRaw Roundness Rundhet +cameraRaw Saturation Mättnad +cameraRaw Shadows Skuggor +cameraRaw Size Storlek +cameraRaw Temperature Temperatur +cameraRaw Texture Struktur +cameraRaw Tint Färgton +cameraRaw Whites Vita +colorRange Invert Omvänd +document Orientation Orientering +layerLabel Magenta Magenta +layerLabel No Color Ingen färg +layerLabel Red Röd +layers Fill Fyllning +layers Fill: Fyllning: +navigator Zoom Zoom +shape Appearance Utseende +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatisk styckeriktning + Left-to-right paragraph direction Styckeriktning från vänster till höger + Right-to-left paragraph direction Styckeriktning från höger till vänster + Not rendered: this adjustment ({key}) is kept as imported. Renderas inte: den här justeringen ({key}) behålls som importerad. +# Print preview image (#8236) + Print preview image Förhandsgranskningsbild för utskrift +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Nyanskub + Brightness Cube Ljusstyrkekub + Color Wheel Färghjul + RGB Sliders RGB-skjutreglage + HSB Sliders HSB-skjutreglage + CMYK Sliders CMYK-skjutreglage + Lab Sliders Lab-skjutreglage + Web Color Sliders Skjutreglage för webbfärger + Copy Color as HTML Kopiera färg som HTML + Copy Color's Hex Code Kopiera färgens hexkod + RGB Spectrum RGB-spektrum + CMYK Spectrum CMYK-spektrum + Grayscale Ramp Gråskaleramp + Current Colors Aktuella färger + Make Ramp Web Safe Gör rampen webbsäker +# Art History Brush (#213). + Art History Brush Tool Konsthistorikpenselverktyg + Tight Short Tät, kort + Tight Medium Tät, medel + Tight Long Tät, lång + Loose Medium Löst medellångt + Loose Long Löst långt + Dab Avtryck + Tight Curl Tät, lockig + Loose Curl Löst lockigt + Stylized dabs from a history state Stiliserade penselavtryck från ett historiktillstånd + Font size presets Förinställningar för teckenstorlek + Leading presets Förinställningar för radavstånd + Tracking presets Förinställningar för spärrning + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-rendering + Calculating… Beräknar… + Click to refresh histogram with uncached data Klicka för att uppdatera histogrammet med data som inte har cachelagrats + Convert to Shape… Konvertera till form… + Cooling Filter (80) Kylfilter (80) + Cooling Filter (82) Kylfilter (82) + Cooling Filter (LBB) Kylfilter (LBB) + Count: Antal: + Delete Hidden Layers Ta bort dolda lager + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Dra i bilden för att lägga till en begränsning; Shift för vågrätt/lodrätt; högerklicka för att ta bort. + Drawn on the CPU: {reason}. Click for System Info. Ritas på CPU:n: {reason}. Klicka för systeminformation. + Exported {n} files Exporterade {n} filer + Fit Layer on Screen Anpassa lager till skärmen + Flow: Flöde: + Level: Nivå: + Percentile: Percentil: + Refine Edge… Förfina kant… + Release Notes Versionsinformation + Remember Settings Kom ihåg inställningar + Reset Essentials Återställ Grundläggande + Rounded Rectangle Tool Verktyget Rundad rektangel + Show Channels in Color Visa kanaler i färg + Show Statistics Visa statistik + Source: Källa: + Status Bar Statusfält + Tile Horizontally Lägg sida vid sida vågrätt + Tile Vertically Lägg sida vid sida lodrätt + Uncached Refresh Uppdatera utan cache + Update and Restart Uppdatera och starta om + Warming Filter (81) Värmefilter (81) + Warming Filter (LBA) Värmefilter (LBA) +@plural {n} document|{n} documents {n} dokument|{n} dokument +@plural {n} pixel|{n} pixels {n} pixel|{n} pixlar +cameraRaw Before (Y) Före (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 är inte öppet +cameraRaw Cancel Avbryt +cameraRaw Cancelled opening {name} Öppningen av {name} avbröts +cameraRaw Cancelled {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 +cameraRaw Grain Korn +cameraRaw Highlight Clipping Warning (O) Varning för klippning i högdagrar (O) +cameraRaw Highlight Priority Högdagerprioritet +cameraRaw Load Settings… Läs in inställningar… +cameraRaw Load settings from a Camera Raw .xmp file Läs in inställningar från en Camera Raw .xmp-fil +cameraRaw Loaded Camera Raw settings from {file} Läste in Camera Raw-inställningar från {file} +cameraRaw Noise Reduction Brusreducering +cameraRaw OK OK +cameraRaw Open Öppna +cameraRaw Opened {name} Öppnade {name} +cameraRaw Paint Overlay Målningsöverlägg +cameraRaw Save Settings… Spara inställningar… +cameraRaw Save these settings as a Camera Raw .xmp file Spara de här inställningarna som en .xmp-fil för Camera Raw +cameraRaw Shadow Clipping Warning (U) Varning för klippning i skuggor (U) +cameraRaw Sharpening Skärpning +cameraRaw Show Lab Color Readouts Visa Lab-färgvärden +cameraRaw Show Red at 3 o'clock Visa rött vid klockan 3 +cameraRaw Show Selected Region Visa markerat område +cameraRaw Show Skin Tone Indicator Visa hudtonsindikator +cameraRaw Show Vectorscope Visa vektorskop +cameraRaw Tone Histogram Tonhistogram +cameraRaw Vignetting Vinjettering +cameraRaw White Balance: As Shot Vitbalans: som tagen diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index e591984e12..9d59447e57 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -86,13 +86,13 @@ mod tests { (vec!["ja-JP", "en-US"], "ja"), (vec!["fr-CA", "en-US"], "fr"), (vec!["uk-UA", "en-US"], "uk"), - (vec!["sv-SE", "uk_UA.UTF-8", "ru-RU"], "uk"), - (vec!["sv-SE", "ko-KR", "fr-FR"], "ko"), + (vec!["sw-KE", "uk_UA.UTF-8", "ru-RU"], "uk"), + (vec!["sw-KE", "ko-KR", "fr-FR"], "ko"), (vec!["zh-Hant-HK", "zh-CN"], "zh-hant"), (vec!["zh-Hans-CN", "zh-TW"], "zh-hans"), (vec!["en-US", "ru-RU"], "en"), - (vec!["sv-SE", "ar-SA"], "ar"), - (vec!["sv-SE", "sw-KE"], "en"), + (vec!["is-IS", "ar-SA"], "ar"), + (vec!["sw-KE", "is-IS"], "en"), (vec!["de-AT", "en-US"], "de"), (vec!["pt-PT"], "pt-br"), (vec!["it-IT", "en-US"], "it"), @@ -100,8 +100,15 @@ mod tests { (vec!["hu-HU", "en-US"], "hu"), (vec!["tr-TR", "en-US"], "tr"), (vec!["sr-Latn-RS", "en-US"], "sr"), - (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), + (vec!["sw-KE", "sr_RS@latin", "ru-RU"], "sr"), (vec!["hi-IN", "en-US"], "hi"), + (vec!["sv-SE", "en-US"], "sv"), + (vec!["sv_FI.UTF-8", "fi-FI", "en-US"], "sv"), + (vec!["fi-FI", "sv-FI", "en-US"], "fi"), + (vec!["da-DK", "en-US"], "da"), + (vec!["nb-NO", "en-US"], "nb"), + (vec!["nn_NO.UTF-8", "en-US"], "nb"), + (vec!["is-IS", "no-NO"], "nb"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 09d2e096b2..a67be38ff5 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -104,6 +104,8 @@ fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]); assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]); assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]); + assert_eq!(candidates("nn_NO.UTF-8"), ["nn-no", "nb", "nn"]); + assert_eq!(candidates("no"), ["nb", "no"]); } #[test] @@ -135,6 +137,65 @@ fn spanish_resolves_and_pluralises() { assert_eq!(forms, [1, 0, 1]); } +#[test] +fn swedish_resolves_and_pluralises() { + let sv = Lang::from_code("sv").expect("sv registered"); + assert_eq!(sv.name(), "Svenska"); + for tag in ["sv", "SV", "sv-SE", "sv_SE.UTF-8", "sv-FI", "sv_FI.UTF-8", "sv-AX"] { + assert_eq!(lang_from_tag(tag), Some(sv), "{tag}"); + assert_eq!(Lang::from_pref(tag), sv, "{tag}"); + } + let forms: Vec = [0, 1, 2, 21].into_iter().map(sv.0.plural).collect(); + assert_eq!(forms, [1, 0, 1, 1]); + assert_eq!(tr(sv, "File"), "Arkiv"); + assert_eq!(trn(sv, 1, "{n} layer", "{n} layers"), "1 lager"); + assert_eq!(trn(sv, 3, "{n} layer", "{n} layers"), "3 lager"); +} + +#[test] +fn finnish_resolves_and_pluralises() { + let fi = Lang::from_code("fi").expect("fi registered"); + assert_eq!(fi.name(), "Suomi"); + for tag in ["fi", "FI", "fi-FI", "fi_FI.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(fi), "{tag}"); + assert_eq!(Lang::from_pref(tag), fi, "{tag}"); + } + // Filipino and Swedish in Finland don't pick Finnish. + assert_ne!(lang_from_tag("fil"), Some(fi)); + assert_ne!(lang_from_tag("sv-FI"), Some(fi)); + assert_eq!(tr(fi, "File"), "Tiedosto"); + // The partitive singular follows any number but 1. + for (n, layer) in [(0, "tasoa"), (1, "taso"), (2, "tasoa"), (21, "tasoa")] { + assert_eq!(trn(fi, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); + } +} + +#[test] +fn danish_resolves_and_pluralises() { + let da = Lang::from_code("da").expect("da registered"); + assert_eq!(da.name(), "Dansk"); + for tag in ["da", "DA", "da-DK", "da_DK.UTF-8", "da-GL"] { + assert_eq!(lang_from_tag(tag), Some(da), "{tag}"); + assert_eq!(Lang::from_pref(tag), da, "{tag}"); + } + assert_eq!(tr(da, "File"), "Filer"); + assert_eq!(trn(da, 1, "{n} document", "{n} documents"), "1 dokument"); + assert_eq!(trn(da, 3, "{n} document", "{n} documents"), "3 dokumenter"); +} + +#[test] +fn norwegian_resolves_and_pluralises() { + let nb = Lang::from_code("nb").expect("nb registered"); + assert_eq!(nb.name(), "Norsk bokmål"); + for tag in ["nb", "NB", "nb-NO", "nb_NO.UTF-8", "no", "no-NO", "no_NO.UTF-8", "nn", "nn-NO", "nn_NO.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(nb), "{tag}"); + assert_eq!(Lang::from_pref(tag), nb, "{tag}"); + } + assert_eq!(tr(nb, "File"), "Fil"); + assert_eq!(trn(nb, 1, "{n} item", "{n} items"), "1 element"); + assert_eq!(trn(nb, 3, "{n} item", "{n} items"), "3 elementer"); +} + #[test] fn hungarian_resolves_and_keeps_the_singular_after_counts() { let hu = Lang::from_code("hu").expect("hu registered"); @@ -203,7 +264,6 @@ fn arabic_resolves_locales_and_preferences() { assert_eq!(Lang::from_pref(tag), ar, "{tag}"); } assert_eq!(dir_for_code("ar"), Dir::Rtl); - assert_eq!(tr(ar, "File"), "ملف"); } #[test] @@ -212,18 +272,6 @@ fn arabic_plural_rule_has_six_forms() { let counts = [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 199, 1000, u64::MAX]; let forms: Vec = counts.into_iter().map(ar.0.plural).collect(); assert_eq!(forms, [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 3, 4, 4, 5, 4]); - assert_eq!(trn(ar, 2, "{n} layer", "{n} layers"), "2 طبقتان"); - assert_eq!(trn(ar, 5, "{n} layer", "{n} layers"), "5 طبقات"); -} - -/// The preset-import result (`preset_files_ui::open`) picks its plural pair at run time, so the -/// literal-key coverage check can't see these rows. -#[test] -fn arabic_preset_import_results_are_translated() { - let ar = Lang::from_code("ar").expect("ar registered"); - assert_eq!(trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), "تم استيراد 3 نقشات من {file}"); - assert_eq!(trn(ar, 2, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), "تم استيراد 2 تدرجين من {file}"); - assert_eq!(trn(ar, 1, "Imported {n} brush from {file}", "Imported {n} brushes from {file}"), "تم استيراد 1 فرشاة من {file}"); } #[test] diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 00bf1b520c..19e66e6287 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -9,8 +9,8 @@ # escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. # lines starting with # and blank lines are ignored. # -# Status: complete. Every source key is translated (`complete_menus`), and the coverage test in -# xtask/src/i18n_coverage.rs keeps it that way: new `tl!` strings need a Vietnamese row. +# Status: complete when last measured. `cargo xtask i18n-coverage` lists any `tl!` string without a +# Vietnamese row; a missing row shows in English. # # Clean-room: every entry was written from the plain meaning of the English label using ordinary # Vietnamese computing and image-editing vocabulary. No proprietary localisation resource, string diff --git a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs index df8eb96608..3536d58afe 100644 --- a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs @@ -638,10 +638,12 @@ fn delete_with_several_layers_selected_deletes_them_even_with_the_mask_targeted( let first = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap()["layer"].as_u64().unwrap()); let second = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap()); s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap(); + // The new layers took the harness's selection as their masks (#8801): select again. + s.execute("select.all", json!({})).unwrap(); let st = s.active().unwrap(); assert_eq!(st.selected_layers().len(), 2); assert!(st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some()), "the active adjustment layer has a mask"); - assert!(st.doc.selection.is_some(), "the harness keeps a selection"); + assert!(st.doc.selection.is_some(), "a selection is active"); h.state_mut().ui.mask_target = true; h.run_steps(2); let n = names(&h).len(); diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index b6d0ffac31..fff3299099 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -504,6 +504,9 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { // pick an option rather than a tool (#1481). A command shortcut (⌘D, F7…) takes the keyboard // off the bar first and runs, as with a native menu bar (#6573). crate::menu_keys::leave_for_shortcut(ctx); + if app.ui.dialogs.is_empty() && app.discard.is_none() && crate::type_tool::commit_shortcut(app, ctx) { + return false; + } if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) { return false; } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 7987536f74..fdafe87eae 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -214,6 +214,18 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, "file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => { app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string())) } + // File > Import > Variable Data Sets… asks for the CSV (#8816); with a `path` the engine reads it itself. + "file.import.variableDataSets" if params.get("path").and_then(Value::as_str).is_none() => { + let doc = app.active_doc_id()?; + let params = params.clone(); + app.pick_file_bytes_filtered(&["csv", "txt"], move |app, _name, bytes| { + app.refocus(doc)?; + let text = data_sets_text(bytes)?; + let r = photocraft_engine::import_data_sets_text(&mut app.session, &text, ¶ms).map_err(|e| e.to_string()); + app.sync_views(); + r + }) + } "file.save" => { // Writes back only to a layered file that holds the whole document; anything else // goes through Save As, so the original is never overwritten with less (#3806). @@ -445,6 +457,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } } +/// The text of a picked Variable Data Sets file. Invalid UTF-8 is refused, as the engine's +/// path-based import refuses it, so a failed read never replaces the document's data sets with +/// U+FFFD-substituted values. +pub(super) fn data_sets_text(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|e| format!("the data set file is not valid UTF-8: {e}")) +} + fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { app.open_path(path).map(|w| json!({"warnings": w})) } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 2527b0fc3f..c3ec327b15 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1,5 +1,12 @@ +use super::invoke::data_sets_text; use super::*; +#[test] +fn data_set_files_must_be_valid_utf8() { + assert_eq!(data_sets_text(b"DataSet,headline\nS,Jos\xc3\xa9\n".to_vec()).unwrap(), "DataSet,headline\nS,José\n"); + assert!(data_sets_text(b"DataSet,headline\nS\xff,Jos\xe9\n".to_vec()).is_err()); +} + #[test] fn showing_grid_restores_hidden_extras() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 91eb91e5d3..792c30e50e 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -200,12 +200,12 @@ fn every_theme_shows_the_type_options() { ); PhotocraftApp::setup_context(&h.ctx, theme); h.run_steps(4); - let combos: Vec = h - .query_all_by_role(egui::accesskit::Role::ComboBox) + let fields: Vec = h + .query_all_by_role(egui::accesskit::Role::TextInput) .map(|n| format!("{:?} {:?}", n.accesskit_node().label(), n.accesskit_node().value())) .collect(); let font = h.state().ui.tool_options.type_font.clone(); - assert!(combos.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; combo boxes: {combos:?}"); + assert!(fields.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; font fields: {fields:?}"); } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2b9c365a9d..523fc92ec8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1952,6 +1952,45 @@ mod sampler_readout_tests { } } +#[cfg(test)] +mod brush_chip_tests { + use super::brush_controls::brush_preset_chip; + + /// The options-bar chip's tip texture for `brush`, row-major alpha (32 × 32). + fn chip_tip(brush: &photocraft_engine::BrushSettings) -> Vec { + let ctx = egui::Context::default(); + let mut state = crate::state::UiState::default(); + let mut out = ctx.run_ui(Default::default(), |ui| brush_preset_chip(ui, brush, &mut state)); + // `Painter::image` is a textured mesh. + let id = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Mesh(m) if (m.calc_bounds().width() - 14.0).abs() < 0.01 => Some(m.texture_id), + _ => None, + }); + let image = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.last()).map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), + }); + out.textures_delta.clear(); + let image = image.expect("the chip draws its tip as an uploaded texture"); + assert_eq!(image.size, [32, 32]); + image.pixels.iter().map(|p| p.a()).collect() + } + + /// The chip shows the brush's own tip: a soft brush fades from its centre as its dabs do, a + /// hard one is solid to its rim, and a flattened tip is flat, not a round blob. + #[test] + fn the_brush_chip_draws_the_brushs_own_tip() { + let at = |a: &[u8], x: usize, y: usize| a[y * 32 + x]; + let hard = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, ..Default::default() }); + let soft = chip_tip(&photocraft_engine::BrushSettings { hardness: 0.0, ..Default::default() }); + assert!(at(&hard, 16, 16) == 255 && at(&hard, 16 + 11, 16) == 255, "hard: solid to near the rim"); + assert!(at(&soft, 16, 16) > 230, "soft: full at the centre"); + assert!(at(&soft, 16 + 11, 16) < 80, "soft: faint near the rim: {}", at(&soft, 16 + 11, 16)); + assert!(at(&soft, 16 + 6, 16) > at(&soft, 16 + 11, 16), "and fading outwards"); + let flat = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, roundness: 0.3, ..Default::default() }); + assert!(at(&flat, 16 + 11, 16) > 200 && at(&flat, 16, 16 + 11) == 0, "a flattened tip is flat"); + } +} + #[cfg(test)] mod panel_state_migration_tests { use super::{migrate_panel_state, panel_state_id}; diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d6a96112a7..49cc070205 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -1,18 +1,5 @@ use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets}; -/// Photoshop's brush preset picker chip: a soft/hard round tip preview with the size underneath. -/// Draw a round brush tip preview (hard core fading to a soft edge). -fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: Color32) { - let inner = rad * hardness.clamp(0.05, 1.0); - let steps = 8; - for k in (0..=steps).rev() { - let f = k as f32 / steps as f32; - let rr = inner + (rad - inner) * f; - let a = if hardness >= 0.99 { 1.0 } else { (1.0 - f).powf(1.5) }; - p.circle_filled(c, rr, color.gamma_multiply(a)); - } -} - /// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the /// mode. Returns the mode picked this frame. pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { @@ -114,7 +101,11 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS let c = pos2(r.left() + 14.0, r.top() + 11.0); // Photoshop's white tip vanishes on light chrome; light themes use their icon colour. let tip = if t.dark() { Color32::WHITE } else { t.icon }; - brush_tip(ui.painter(), c, 7.0, b.hardness, tip); + // The brush's own tip, as the Brush Preset picker's thumbnails draw it: the engine's falloff + // for its hardness, its angle and roundness, a sampled tip's shape. + let tex = crate::brush_preview::tip_texture(ui.ctx(), "options-bar-chip", &b.tip, (b.hardness, b.angle, b.roundness), 32, tip); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + ui.painter().image(tex.id(), Rect::from_center_size(c, vec2(14.0, 14.0)), uv, Color32::WHITE); 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"))); diff --git a/crates/ui-egui/src/panels/props_layout/tests.rs b/crates/ui-egui/src/panels/props_layout/tests.rs index 88a61c518c..a09b559deb 100644 --- a/crates/ui-egui/src/panels/props_layout/tests.rs +++ b/crates/ui-egui/src/panels/props_layout/tests.rs @@ -147,14 +147,22 @@ fn type_controls_fill_the_panel_at_every_width_and_scale() { let hs = headers(&h); let header = hs.iter().find(|(n, _)| n == "Character").unwrap().1; let what = format!("{ppp}x @ {dw}"); - // Font family and style dropdowns span the section. + // The editable family field and the style dropdown span the section. + let name = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .find(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0) + .expect("editable family field"); + let mut family = name.expand2(egui::vec2(6.0, 2.0)); + family.max.x += crate::widgets::POPUP_ARROW_W; + assert!((family.left() - header.left()).abs() <= 2.0 && (family.right() - header.right()).abs() <= 2.0, "{what}: {family:?} vs {header:?}"); let combos: Vec = h .query_all_by_role(egui::accesskit::Role::ComboBox) .map(|n| n.rect()) .filter(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0) .collect(); - assert!(combos.len() >= 2, "{what}: {combos:?}"); - for r in &combos[..2] { + assert!(!combos.is_empty(), "{what}: {combos:?}"); + for r in &combos[..1] { assert!((r.left() - header.left()).abs() <= 2.0 && (r.right() - header.right()).abs() <= 2.0, "{what}: {r:?} vs {header:?}"); } // Number fields end at the section's right edge (the second column), never past it. diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 40fdf6ed7b..e96ddc220e 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -28,8 +28,6 @@ fn the_generator_checkbox_reads_enable_generator() { h.get_by_label("Enable Generator").click(); h.run_steps(2); assert_eq!(h.state()["generatorEnabled"], true); - let de = crate::i18n::Lang::from_code("de").unwrap(); - assert_eq!(crate::i18n::tr(de, "Enable Generator"), "Generator aktivieren"); } #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index eac69a2263..5f2bb869ce 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -17,7 +17,9 @@ 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}; -pub use transform::{BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeTransform}; +pub use transform::{ + BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeInsertionStyle, TypeTransform, +}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Panels { @@ -215,6 +217,9 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// Font for subsequent typing at this caret, separate from existing character runs. + #[serde(default)] + pub type_insertion_style: Option, /// Clock time in seconds when type cursor blink was last reset (e.g. on keypress/caret move). #[serde(skip)] pub caret_blink_time: f64, @@ -494,6 +499,7 @@ impl Default for UiState { recent_files: Vec::new(), recent_fonts: Vec::new(), text_edit: None, + type_insertion_style: None, caret_blink_time: 0.0, text_edit_return_tool: None, type_transform: None, diff --git a/crates/ui-egui/src/state/transform.rs b/crates/ui-egui/src/state/transform.rs index 96b95bad67..fe381c336b 100644 --- a/crates/ui-egui/src/state/transform.rs +++ b/crates/ui-egui/src/state/transform.rs @@ -119,6 +119,17 @@ pub struct BoxResize { pub aff: photocraft_geom::Affine, } +/// A font choice for subsequent typing, invalidated by navigation or a document edit. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TypeInsertionStyle { + pub document: u64, + pub layer: u64, + pub session: String, + pub revision: u64, + pub caret: usize, + pub style: photocraft_doc::text::CharStyle, +} + /// In-progress inline type editing (Type tool). Offsets are character indices. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { diff --git a/crates/ui-egui/src/tools/align_bar.rs b/crates/ui-egui/src/tools/align_bar.rs index d663283e02..0a8ca4b3be 100644 --- a/crates/ui-egui/src/tools/align_bar.rs +++ b/crates/ui-egui/src/tools/align_bar.rs @@ -35,7 +35,7 @@ pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 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(ui, icon, 24.0, false, &tip)).inner.clicked() { + if ui.add_enabled_ui(on, |ui| icons::button_with_icon_size(ui, icon, 24.0, 16.0, false, &tip)).inner.clicked() { let _ = app.run(cmd, json!({})); } } diff --git a/crates/ui-egui/src/tools/brush_cursor.rs b/crates/ui-egui/src/tools/brush_cursor.rs index 6bc71a78f7..1710679e95 100644 --- a/crates/ui-egui/src/tools/brush_cursor.rs +++ b/crates/ui-egui/src/tools/brush_cursor.rs @@ -134,43 +134,6 @@ pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> O traced } -/// Which texels of the document-pixel `rect` (`x0, y0, x1, y1`, row-major) lie inside the tip -/// `rings` (radius units, even-odd so holes stay clear) when the tip is centred on `at` with -/// `radius` document pixels. The Clone Source overlay uses it to clip to a shaped tip. -pub(crate) fn tip_mask(rings: &[Vec<[f32; 2]>], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec { - let [x0, y0, x1, y1] = rect; - let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0)); - let mut mask = vec![false; w.saturating_mul(h)]; - let mut hits: Vec = Vec::new(); - for row in 0..h { - let y = f64::from(y0) + row as f64 + 0.5; - hits.clear(); - for ring in rings { - for (i, a) in ring.iter().enumerate() { - let Some(b) = ring.get((i + 1) % ring.len()) else { continue }; - let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius); - let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius); - if (ay <= y) != (by <= y) { - hits.push(ax + (y - ay) / (by - ay) * (bx - ax)); - } - } - } - hits.sort_by(f64::total_cmp); - for pair in hits.chunks(2) { - let [a, b] = pair else { continue }; - let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5); - let start = from.ceil().max(0.0) as usize; - let end = ((to.floor() + 1.0).max(0.0) as usize).min(w); - for x in start..end { - if let Some(m) = mask.get_mut(row * w + x) { - *m = true; - } - } - } - } - mask -} - #[cfg(test)] mod tests { use super::*; @@ -295,19 +258,4 @@ mod tests { assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); } } - - /// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear, - /// and a rotated flat tip leaves the corners of the brush square outside. - #[test] - fn tip_mask_follows_the_ring_shape() { - let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; - let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]]; - let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]); - let at = |x: usize, y: usize| mask[y * 20 + x]; - assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square"); - assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square"); - assert!(!at(10, 10) && at(8, 10), "the hole is clear"); - assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole"); - assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless"); - } } diff --git a/crates/ui-egui/src/tools/brush_resize.rs b/crates/ui-egui/src/tools/brush_resize.rs index 1afba1bd1c..0878c6167e 100644 --- a/crates/ui-egui/src/tools/brush_resize.rs +++ b/crates/ui-egui/src/tools/brush_resize.rs @@ -137,14 +137,14 @@ fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { } /// Draw the resize feedback: the brush circle at the anchor, filled with the brush preview -/// colour whose solid core shows the hardness (Photoshop's), and the readout beside the pointer. +/// colour as a dab of the brush's hardness covers (Photoshop's), and the readout beside the pointer. /// Returns true while a resize is in progress (the canvas then hides its cursor). pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) -> bool { let Some(r) = app.brush_resize else { return false }; let b = &app.session.tools.brush; let c = xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32); let radius = (b.size / 2.0 * xf.zoom).max(1.0); - tip_preview(painter, c, radius, b.hardness, preview_tint(app)); + tip_preview(painter, c, b.size / 2.0, xf.zoom, b.hardness, preview_tint(app)); painter.circle_stroke(c, radius + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140))); painter.circle_stroke(c, radius, Stroke::new(1.0, Color32::from_white_alpha(220))); // Photoshop hides the pointer while the circle stays put. @@ -168,32 +168,44 @@ fn preview_tint(app: &PhotocraftApp) -> Color32 { }) } -/// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear -/// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush. -fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) { +/// The round tip as the brush engine paints it: `radius` document pixels at `zoom` screen points +/// per document pixel, solid out to its core and then the engine's falloff (`tip_falloff`) out to +/// its reach, past the radius for a soft tip, so the disc shows what a dab of this hardness covers. +/// One mesh of rings, so its cost doesn't grow with the brush. +fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, zoom: f32, hardness: f32, color: Color32) { + /// Rings across the falloff: the shoulder is smooth, so linear steps between them are close. + const RINGS: usize = 16; + if !(radius.is_finite() && zoom.is_finite() && radius > 0.0 && zoom > 0.0) { + return; + } + let (core, _) = photocraft_paint::tip_shape(radius, hardness); + let reach = photocraft_paint::tip_reach(radius, hardness).max(core); + let outer = reach * zoom; let clip = painter.clip_rect(); - if !clip.expand(radius).contains(c) { + if !clip.expand(outer).contains(c) { return; } // Enough segments for a smooth edge at any size, capped so huge brushes stay cheap. - let n = ((radius * 0.5) as usize).clamp(24, 160); - let inner = radius * hardness.clamp(0.0, 1.0); - let clear = Color32::TRANSPARENT; + let n = ((outer * 0.5) as usize).clamp(24, 160); let mut mesh = egui::Mesh::default(); mesh.colored_vertex(c, color); - for k in 0..n { - let a = k as f32 / n as f32 * std::f32::consts::TAU; - let d = vec2(a.cos(), a.sin()); - mesh.colored_vertex(c + d * inner, color); - mesh.colored_vertex(c + d * radius, clear); + for k in 0..=RINGS { + let d = core + (reach - core) * k as f32 / RINGS as f32; + // The last ring is the reach, where the falloff has run out. + let tint = if k == RINGS { Color32::TRANSPARENT } else { color.gamma_multiply(photocraft_paint::tip_falloff(d, radius, hardness)) }; + for j in 0..n { + let a = j as f32 / n as f32 * std::f32::consts::TAU; + mesh.colored_vertex(c + vec2(a.cos(), a.sin()) * d * zoom, tint); + } } let n = n as u32; - for k in 0..n { - let (i0, o0) = (1 + 2 * k, 2 + 2 * k); - let (i1, o1) = (1 + 2 * ((k + 1) % n), 2 + 2 * ((k + 1) % n)); - mesh.add_triangle(0, i0, i1); - mesh.add_triangle(i0, o0, o1); - mesh.add_triangle(i0, o1, i1); + let at = |ring: u32, j: u32| 1 + ring * n + j % n; + for j in 0..n { + mesh.add_triangle(0, at(0, j), at(0, j + 1)); + for ring in 0..RINGS as u32 { + mesh.add_triangle(at(ring, j), at(ring + 1, j), at(ring + 1, j + 1)); + mesh.add_triangle(at(ring, j), at(ring + 1, j + 1), at(ring, j + 1)); + } } painter.add(mesh); } diff --git a/crates/ui-egui/src/tools/brush_resize/tests.rs b/crates/ui-egui/src/tools/brush_resize/tests.rs index 6967c7bb9a..bf6cacc396 100644 --- a/crates/ui-egui/src/tools/brush_resize/tests.rs +++ b/crates/ui-egui/src/tools/brush_resize/tests.rs @@ -344,6 +344,45 @@ fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() { assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); } +/// The tip's mesh, painted at the origin: (distance from the centre, alpha) per vertex. +fn tip_mesh(radius: f32, zoom: f32, hardness: f32) -> Vec<(f32, u8)> { + let ctx = egui::Context::default(); + let color = Color32::from_rgba_unmultiplied(255, 0, 0, 200); + let mut out = ctx.run_ui(Default::default(), |ui| { + let painter = ui.painter().with_clip_rect(egui::Rect::from_center_size(Pos2::ZERO, vec2(4000.0, 4000.0))); + tip_preview(&painter, Pos2::ZERO, radius, zoom, hardness, color); + }); + out.textures_delta.clear(); + let mesh = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Mesh(m) => Some(m.clone()), + _ => None, + }) + .expect("the tip is drawn"); + mesh.vertices.iter().map(|v| (v.pos.to_vec2().length(), v.color.a())).collect() +} + +/// The disc follows the engine's round tip: a hard one is solid to its rim, a soft one fades +/// as the engine's falloff does and reaches past the radius as its dabs do. +#[test] +fn the_tip_preview_follows_the_brush_falloff() { + for zoom in [1.0, 2.5] { + let hard = tip_mesh(50.0, zoom, 1.0); + assert!(hard.iter().filter(|(d, _)| *d < 48.0 * zoom).all(|(_, a)| *a == 200), "zoom {zoom}: a hard tip is solid inside"); + assert!(hard.iter().all(|(d, a)| *d <= 52.0 * zoom || *a == 0), "zoom {zoom}: and ends at its rim"); + let soft = tip_mesh(50.0, zoom, 0.0); + for (d, a) in &soft { + let want = 200.0 * photocraft_paint::tip_falloff(d / zoom, 50.0, 0.0); + assert!((f32::from(*a) - want).abs() <= 3.0 || *a == 0, "zoom {zoom}: at {d}: {a}, the engine {want}"); + } + let reach = photocraft_paint::tip_reach(50.0, 0.0); + assert!(soft.iter().any(|(d, a)| *d > 60.0 * zoom && *a > 0), "zoom {zoom}: a soft tip shows past its radius"); + assert!(soft.iter().all(|(d, _)| *d <= reach * zoom + 0.5), "zoom {zoom}: and no further than it reaches"); + } +} + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index d157336960..fa291e7417 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -52,6 +52,28 @@ fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { } } +/// A Clone Stamp click paints with the brush's hardness (the Brush popup's), not a hard tip: +/// near the rim a soft brush paints only part of the source, a hard one all of it. +#[test] +fn clone_stamp_paints_with_the_brush_hardness() { + let rim = |hardness: f32| { + let mut app = app(); + app.run("tools.setBrush", json!({"brush": {"size": 24, "hardness": hardness}})).unwrap(); + app.run("paint.pencil", json!({"points": [[15, 30]], "size": 40, "color": "#ff0000"})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.ui.clone_source = Some([15.0, 30.0]); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 70.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + // 9 px from the centre of a 12 px radius: inside a hard tip, on a soft tip's falloff. + 1.0 - active(&app).surface().unwrap().rgba(79, 30)[1] + }; + let (hard, soft) = (rim(1.0), rim(0.0)); + assert!(hard > 0.99, "a hard tip clones fully near its rim: {hard}"); + assert!(soft < 0.8, "a soft tip clones only part of the source there: {soft}"); +} + #[test] fn alt_flips_dodge_burn_and_blur_sharpen() { for (tool, flipped) in [(Tool::Dodge, Tool::Burn), (Tool::Burn, Tool::Dodge), (Tool::Blur, Tool::Sharpen), (Tool::Sharpen, Tool::Blur)] { diff --git a/crates/ui-egui/src/tools/type_font_preview.rs b/crates/ui-egui/src/tools/type_font_preview.rs index bb91b61ea0..a334642c99 100644 --- a/crates/ui-egui/src/tools/type_font_preview.rs +++ b/crates/ui-egui/src/tools/type_font_preview.rs @@ -109,6 +109,11 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc (usize, usize) { #[cfg(test)] mod canvas_tests; +#[cfg(test)] +mod font_commit_tests; #[cfg(test)] mod font_picker_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index a6dd09e44a..fb67c7da4a 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -2179,7 +2179,7 @@ mod multi_layer_formatting { let (app, ids) = fixture(); let before = ids.map(|id| style(&app, id, 0)); let mut h = controls(app, false); - h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.get_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); h.get_by_role(Role::TextInput).type_text("JetBrains"); h.key_press(egui::Key::ArrowDown); @@ -2202,7 +2202,7 @@ mod multi_layer_formatting { assert_eq!(style(h.state(), ids[i], 0), expected, "family changes preserve each peer's face and metrics"); } assert_eq!(style(h.state(), ids[2], 0), before[2]); - h.get_all_by_role(Role::ComboBox).nth(2).unwrap().click(); + h.get_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Smooth")).unwrap().click(); h.run(); h.get_by_label("Sharp").click(); h.run(); @@ -2217,6 +2217,57 @@ mod multi_layer_formatting { assert_ne!(text(h.state(), ids[2]).antialias, AntiAlias::Sharp); } + /// The family field and the style menu describe the selected run, not the layer's first run + /// (#9024), whichever way the selection was made; without a selection the first run shows, and + /// without a type target the tool defaults. Showing them never adds history. "Mé Regular" has + /// a two-byte `é`, so a byte offset taken for a character offset picks the wrong run. + #[test] + fn options_bar_font_and_style_follow_the_selected_run() { + let mut app = new_app(); + let id = LayerId( + app.run("type.create", json!({"text": "Mé Regular", "font": "Inter", "fontStyle": "Medium", "size": 20, "x": 300, "y": 420})).unwrap()["layer"] + .as_u64() + .unwrap(), + ); + app.run("type.setStyle", json!({"layer": id.0, "range": [3, 10], "font": "JetBrains Mono", "fontStyle": "Regular"})).unwrap(); + assert_eq!((style(&app, id, 0).font_family.as_str(), style(&app, id, 5).font_family.as_str()), ("Inter", "JetBrains Mono")); + let edit = |anchor: usize, caret: usize| crate::state::TextEdit { + layer: id.0, + document: None, + caret, + anchor, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }; + let mut h = controls(app, false); + let shown = |h: &Harness<'static, PhotocraftApp>| { + (h.get_all_by_role(Role::TextInput).next().and_then(|n| n.value()).unwrap_or_default(), h.get_by_role_and_label(Role::ComboBox, "Style").value()) + }; + let steps = super::history_len(&h); + let (inter, mono) = (("Inter".to_string(), Some("Medium".to_string())), ("JetBrains Mono".to_string(), Some("Regular".to_string()))); + for (anchor, caret, want) in [(3, 10, &mono), (10, 3, &mono), (0, 2, &inter), (2, 0, &inter)] { + h.state_mut().ui.text_edit = Some(edit(anchor, caret)); + h.run_steps(4); + assert_eq!(&shown(&h), want, "selection {anchor}..{caret}"); + } + // Ending the edit keeps the whole-layer rule: the layer is still selected, its style is the + // first run's (Medium) and, the runs using two families, the family field is blank. + h.state_mut().ui.text_edit = None; + h.run_steps(4); + assert_eq!(shown(&h), (String::new(), inter.1.clone()), "no edit: first run's style, mixed family blank"); + assert_eq!(super::history_len(&h), steps, "displaying the controls adds no history"); + // Control: no type target shows the tool defaults. + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().ui.tool_options.type_font = "JetBrains Mono".into(); + h.state_mut().ui.tool_options.type_style = "Regular".into(); + h.run_steps(4); + assert_eq!(shown(&h), mono, "no type target: the tool defaults"); + } + /// Other engines registering fonts and the served catalog growing (tests running in parallel) /// used to move the key of the font menu's preview cache, so its samples re-rendered and asked /// for a repaint every pass and `Harness::run` never settled (a flaky `exceeded max_steps`). @@ -2239,7 +2290,7 @@ mod multi_layer_formatting { ); PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.get_all_by_role(Role::TextInput).next().unwrap().click(); // The font menu's search caret repaints every pass; step a bounded number of times. h.run_steps(2); h.run_steps(2); @@ -2254,9 +2305,9 @@ mod multi_layer_formatting { let pick = super::super::styles("Inter").into_iter().find(|s| s != "Regular" && s != "Bold Italic").expect("Inter has several styles"); assert_ne!(app.ui.tool_options.type_style, pick); let mut h = controls(app, true); - h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.get_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); - h.get_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("the search field has the focus").type_text("Inter"); + h.get_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("the family field has the focus").type_text("Inter"); h.run(); h.get_all_by_label("Show Styles").next().unwrap().click(); h.run(); diff --git a/crates/ui-egui/src/tools/type_tool/defaults.rs b/crates/ui-egui/src/tools/type_tool/defaults.rs index 203c3e9e93..939ec4966d 100644 --- a/crates/ui-egui/src/tools/type_tool/defaults.rs +++ b/crates/ui-egui/src/tools/type_tool/defaults.rs @@ -1,4 +1,5 @@ use super::editing::edit_home; +use super::insertion_style; use super::{LayerId, PhotocraftApp, TextLayer, hex, json, text_layer}; /// Representative for the displayed properties: the edit layer, else a selected type layer. @@ -204,6 +205,10 @@ pub(super) fn apply_defaults(app: &mut PhotocraftApp, props: &serde_json::Value) /// Apply only the supplied properties to the text-edit scope or selected type layers; without /// a type target, prepare the defaults for the next layer instead. pub(super) fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) { + if insertion_style::set_font(app, &props) { + return; + } + app.ui.type_insertion_style = None; let Some(mut p) = formatting_params(app) else { apply_defaults(app, &props); return; diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index 57c3a3177f..f93826c1d1 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -1,7 +1,10 @@ use super::ime::interrupt_incoming; use super::ime_edit::note_ime_application_change; use super::layout::{hit_layer, hit_offset, shows_own_layout}; -use super::{LayerId, PhotocraftApp, TextEdit, TextLayer, active_doc_id, byte_range, current_text, json, selected_range, session_key, text_layer}; +use super::{ + Arc, FontFieldEdit, HashMap, LayerId, PhotocraftApp, TextEdit, TextLayer, accept_row, active_doc_id, apply, byte_range, current_text, families, + font_labels_id, font_menu_rows, insertion_style, json, selected_range, session_key, text_layer, +}; /// Start editing an existing type layer. Like Photoshop, editing shows the text as the type /// engine lays it out, so a PSD layer is re-rendered first (inside the edit session's history @@ -20,6 +23,7 @@ pub(super) fn begin_edit(app: &mut PhotocraftApp, id: LayerId, key: &str) -> Res /// Edit the active type layer from its thumbnail, selecting all text like Photoshop. Reuse the /// current session when already editing it, so Cancel and undo still cover the whole edit. pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { + app.ui.type_insertion_style = None; let st = app.session.active().ok_or("no document open")?; let id = st.active_layer.ok_or("no active layer")?; let n = text_layer(&st.doc, id).ok_or("active layer is not a type layer")?.text.chars().count(); @@ -142,7 +146,7 @@ pub(super) fn insert(app: &mut PhotocraftApp, s: &str) { return; } crate::type_transform::finish(app); - let applied = app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": a, "end": b, "text": s}, "coalesce": ed.session})).is_ok(); + let applied = insertion_style::replace(app, a, b, &s); if applied { let mut cancelled = false; if let Some(e) = app.ui.text_edit.as_mut() { @@ -257,7 +261,7 @@ impl CharToggle { /// The character styles of the selection, or of the typing style when the text is empty. Empty /// when nothing is selected in non-empty text. -fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { +pub(super) fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { let runs = t.char_runs(); if t.text.is_empty() { return runs.into_iter().take(1).map(|r| r.style).collect(); @@ -350,10 +354,63 @@ pub fn commit(app: &mut PhotocraftApp) { commit_with(app, egui::Modifiers::NONE); } +/// The document's commit shortcut also works while a Type field owns keyboard input. +/// Resolve a pending font query before ending the edit; composition still owns its keys. +pub(crate) fn commit_shortcut(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + let Some(ed) = app.ui.text_edit.as_ref() else { return false }; + let composing = ed.preedit.is_some(); + let mods = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.command => Some(*modifiers), + _ => None, + }) + }); + let Some(mods) = mods else { return false }; + let focused = ctx.memory(|m| m.focused()); + let font_edit = focused.and_then(|id| ctx.data(|d| d.get_temp::(id.with("edit")))); + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Enter, modifiers, .. } if modifiers.command))); + if composing || font_edit.as_ref().is_some_and(|e| e.composing) || ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Ime(_)))) { + // Suppress the document/path shortcut, but leave IME events for their focused editor. + return false; + } + end_if_left(app); + if app.ui.text_edit.is_none() { + return true; + } + if let Some(edit) = font_edit.filter(|e| !e.accepted && !e.text.trim().is_empty() && !e.query.is_empty()) { + let families = families(); + let labels: Option>> = ctx.data(|d| d.get_temp(font_labels_id())); + let (rows, _) = font_menu_rows(&families, labels.as_deref(), &edit.query, &edit.recents); + let selected = + edit.row.filter(|&i| rows.get(i).is_some_and(|(f, _)| **f == edit.current)).or_else(|| rows.iter().position(|(f, _)| **f == edit.current)); + let family = accept_row(&rows, &edit.text, selected).and_then(|i| rows.get(i)).map(|(f, _)| (*f).clone()); + if let Some(family) = family { + photocraft_text::served::request(&family); + app.note_recent_font(&family); + app.ui.tool_options.type_font = family.clone(); + apply(app, ctx, json!({"font": family})); + } + } + if let Some(field) = focused { + ctx.memory_mut(|m| m.surrender_focus(field)); + ctx.data_mut(|d| d.remove::(field.with("edit"))); + } + egui::Popup::close_all(ctx); + ctx.data_mut(|d| { + d.remove::(egui::Id::unique("type-font-hover")); + d.remove::<(String, f64)>(egui::Id::unique("type-font-hover-since")); + }); + crate::type_font_preview::clear(app); + commit_with(app, mods); + true +} + /// [`commit`] with the modifiers held while committing: a Type Mask adds its glyphs to the /// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it /// replaces the selection. pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { + app.ui.type_insertion_style = None; + crate::type_font_preview::clear(app); let current = app.session.active_index(); let Some(home) = app.ui.text_edit.as_ref().map(|ed| edit_home(app, ed)) else { return commit_here(app, mods); @@ -450,6 +507,7 @@ pub fn end_if_left(app: &mut PhotocraftApp) { /// Cancel the editing session: undo it back to where it started (removes a new layer). pub fn cancel(app: &mut PhotocraftApp) { + app.ui.type_insertion_style = None; crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; app.type_ime.clear_preedit(); diff --git a/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs b/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs new file mode 100644 index 0000000000..e6389e4cd2 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs @@ -0,0 +1,579 @@ +//! Selected-range font acceptance, caret typing styles and focused global commit. +use crate::{PhotocraftApp, Services}; +use egui::{Key, Modifiers, accesskit::Role, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::{Value, json}; + +const FONTS: [&str; 2] = ["Inter", "JetBrains Mono"]; + +struct Probe { + app: PhotocraftApp, + character: bool, +} + +impl eframe::App for Probe { + fn logic(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { + crate::shortcuts::handle(&mut self.app, ctx); + crate::type_font_preview::follow_hover(&mut self.app, ctx); + } + fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) { + if self.character { + ui.vertical(|ui| { + ui.set_width(340.0); + super::character_panel(&mut self.app, ui, false); + }); + } + ui.horizontal(|ui| super::options_bar(&mut self.app, ui)); + } +} + +fn layer(doc: &Document, id: LayerId) -> &photocraft_doc::TextLayer { + let LayerContent::Text(t) = &doc.layer(id).unwrap().content else { panic!("text layer") }; + t +} + +fn styles(doc: &Document, id: LayerId) -> Value { + let t = layer(doc, id); + let runs = t.char_runs(); + let mut at = 0; + let mut chars = Vec::new(); + for r in runs { + let end = at + r.len; + for ch in t.text.get(at..end).unwrap().chars() { + chars.push(json!({"char": ch.to_string(), "font": r.style.font_family, + "face": r.style.postscript_name, "weight": r.style.weight, "italic": r.style.italic})); + } + at = end; + } + json!({"text": t.text, "summary": t.font_family, "chars": chars}) +} + +fn saved(h: &Harness<'_, Probe>, id: LayerId) -> Value { + styles(&h.state().app.session.active().unwrap().doc, id) +} + +fn families(s: &Value) -> Vec { + s["chars"].as_array().unwrap().iter().map(|c| c["font"].as_str().unwrap().to_owned()).collect() +} + +fn fields(h: &Harness<'_, Probe>) -> Vec> { + h.query_all_by_role(Role::TextInput).map(|n| n.value()).collect() +} + +fn select(h: &mut Harness<'_, Probe>, range: [usize; 2]) { + let e = h.state_mut().app.ui.text_edit.as_mut().unwrap(); + e.anchor = range[0]; + e.caret = range[1]; +} + +fn fixture(new: bool, pattern: usize, character: bool, content: &str) -> (Harness<'static, Probe>, LayerId) { + fixture_renderer(new, pattern, character, content, false, 1.0) +} + +fn fixture_renderer(new: bool, pattern: usize, character: bool, content: &str, gpu: bool, scale: f32) -> (Harness<'static, Probe>, LayerId) { + let builder = Harness::builder().with_size(vec2(1100.0, 1000.0)).with_pixels_per_point(scale).with_max_steps(64); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.session.edit_prefs(|p| p.type_.font_preview = photocraft_engine::prefs::FontPreview::Off); + Probe { app, character } + }); + let app = &mut h.state_mut().app; + app.run("file.new", json!({"width": 240, "height": 160})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Type; + app.ui.tool_options.type_font = FONTS[pattern % 2].into(); + app.ui.tool_options.type_size = 18.0; + super::pointer_up(app, [30.0, 70.0], [30.0, 70.0]); + super::insert(app, content); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let key = app.ui.text_edit.as_ref().unwrap().session.clone(); + // Build mixed styles through the same live edit session, preserving history/coalescing. + if pattern >= 2 { + app.run("type.setStyle", json!({"layer": id.0, "range": [2,4], "font": FONTS[1-pattern%2], "coalesce": key})).unwrap(); + } + if !new { + super::commit(app); + super::edit_active(app).unwrap(); + } + h.run_steps(3); + (h, id) +} + +fn choose(h: &mut Harness<'_, Probe>, font: &str, accept: &str) { + h.query_all_by_role(Role::TextInput).next().unwrap().click(); + h.run(); + h.query_all_by_role(Role::TextInput).next().unwrap().type_text(font); + h.run(); + match accept { + "enter" => h.key_press(Key::Enter), + "arrow-enter" => { + h.key_press(Key::ArrowDown); + h.run(); + h.key_press(Key::Enter); + } + "click" => h.get_by_label(font).click(), + "hover" => h.get_by_label(font).hover(), + "query" => {} + "tab" => h.key_press(Key::Tab), + "escape" => h.key_press(Key::Escape), + _ => panic!("unknown acceptance"), + } + h.run(); +} + +fn commit(h: &mut Harness<'_, Probe>, method: &str) { + match method { + "checkmark" => { + let label = crate::i18n::fmt("Commit any current edits ({key})", &[("key", &crate::shortcuts::pretty("Cmd+Enter"))]); + h.get_by_role_and_label(Role::Button, &label).click(); + h.run(); + } + "outside" => { + super::pointer_down(&mut h.state_mut().app, 230.0, 150.0, Modifiers::NONE); + h.run(); + } + "direct" => { + super::commit(&mut h.state_mut().app); + h.run(); + } + "shortcut-focused" => { + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run(); + } + "shortcut-unfocused" => { + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run(); + } + "layer-switch" => { + h.state_mut().app.run("layer.new.layer", json!({})).unwrap(); + super::end_if_left(&mut h.state_mut().app); + h.run(); + } + "document-switch" => { + h.state_mut().app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + super::end_if_left(&mut h.state_mut().app); + h.state_mut().app.session.set_active(0); + h.run(); + } + _ => panic!("unknown commit"), + } +} + +#[test] +fn selected_fonts_apply_and_survive_commit_across_controls_and_acceptance_methods() { + let mut cases = 0; + for new in [false, true] { + for pattern in 0..4 { + for character in [false, true] { + for range in [[0, 2], [2, 4], [4, 6], [0, 6], [4, 2], [1, 5]] { + for target in FONTS { + for accept in ["enter", "click", "arrow-enter"] { + for method in ["checkmark", "outside"] { + let label = (new, pattern, character, range, target, accept, method); + let (mut h, id) = fixture(new, pattern, character, "abcdef"); + select(&mut h, range); + h.run(); + let mut expected = families(&saved(&h, id)); + for font in &mut expected[range[0].min(range[1])..range[0].max(range[1])] { + *font = target.into(); + } + choose(&mut h, target, accept); + let applied = saved(&h, id); + assert_eq!(families(&applied), expected, "{label:?}"); + assert_eq!(fields(&h)[0].as_deref(), Some(target), "selected font caption: {label:?}"); + let pixels = layer(&h.state().app.session.active().unwrap().doc, id).cache.clone(); + commit(&mut h, method); + assert!(h.state().app.ui.text_edit.is_none(), "{label:?}"); + assert_eq!(saved(&h, id), applied, "{label:?}"); + assert_eq!(layer(&h.state().app.session.active().unwrap().doc, id).cache, pixels, "{label:?}"); + let uniform = expected.iter().all(|f| f == &expected[0]); + assert_eq!( + fields(&h)[0].clone().unwrap_or_default(), + if uniform { expected[0].clone() } else { String::new() }, + "mixed caption after commit: {label:?}" + ); + super::edit_active(&mut h.state_mut().app).unwrap(); + select(&mut h, range); + h.run(); + assert_eq!(fields(&h)[0].as_deref(), Some(target), "reselected caption: {label:?}"); + cases += 1; + } + } + } + } + } + } + } + assert_eq!(cases, 1152); +} + +#[test] +fn caret_font_choices_style_only_subsequent_typing_including_ime() { + for new in [false, true] { + for character in [false, true] { + for original in 0..2 { + for text in ["abcdef", "AéB👩‍💻", "ab\ncdé"] { + let n = text.chars().count(); + for caret in [0, n / 2, n] { + for accept in ["enter", "click", "arrow-enter"] { + let (mut h, id) = fixture(new, original, character, text); + select(&mut h, [caret, caret]); + h.run(); + let before = saved(&h, id); + let steps = h.state().app.session.active().unwrap().history.entries().len(); + let font = FONTS[1 - original]; + choose(&mut h, font, accept); + assert_eq!(saved(&h, id), before, "a font choice at a caret never restyles existing characters"); + assert_eq!(h.state().app.session.active().unwrap().history.entries().len(), steps); + assert_eq!(fields(&h)[0].as_deref(), Some(font)); + crate::type_font_preview::set(&mut h.state_mut().app, font.into()); + assert!(crate::type_font_preview::display_doc(&mut h.state_mut().app, 0).is_none()); + // Multiple preedit replacements and final commit keep the pending font. + super::ime_update(&mut h.state_mut().app, "é", false); + super::ime_update(&mut h.state_mut().app, "éX", true); + super::insert(&mut h.state_mut().app, "Z"); + let mut expected = families(&before); + expected.splice(caret..caret, std::iter::repeat_n(font.to_string(), 3)); + assert_eq!(families(&saved(&h, id)), expected); + let typed = saved(&h, id); + commit(&mut h, "checkmark"); + assert_eq!(saved(&h, id), typed); + assert!(h.state().app.ui.type_insertion_style.is_none()); + } + } + } + } + } + } +} + +#[test] +fn global_commit_accepts_pending_font_queries_and_leaves_no_focused_field() { + for new in [false, true] { + for character in [false, true] { + for accept in ["enter", "click", "arrow-enter", "query", "hover", "tab", "escape"] { + for method in ["shortcut-focused", "shortcut-unfocused", "layer-switch", "document-switch"] { + let (mut h, id) = fixture(new, 0, character, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, FONTS[1], accept); + commit(&mut h, method); + assert!(h.state().app.ui.text_edit.is_none(), "{new}/{character}/{accept}/{method}"); + let mut expected = vec![FONTS[0].to_string(); 6]; + if matches!(accept, "enter" | "click" | "arrow-enter") || (method == "shortcut-focused" && matches!(accept, "query" | "hover")) { + expected[2..4].fill(FONTS[1].into()); + } + assert_eq!(families(&saved(&h, id)), expected, "{new}/{character}/{accept}/{method}"); + } + } + } + } +} + +#[test] +fn global_commit_clears_hover_before_preview_can_expand_to_the_whole_layer() { + let (mut h, id) = fixture(false, 2, false, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, FONTS[1], "hover"); + h.event(egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.run_steps(1); + assert!(h.state().app.ui.text_edit.is_none()); + let (shown, _) = crate::canvas::display_doc(&mut h.state_mut().app, 0); + assert_eq!(styles(&shown, id), saved(&h, id), "the first committed frame must show real runs, not a full-layer hover preview"); +} + +#[test] +fn caret_style_is_discarded_by_navigation_cancel_undo_and_document_switch() { + for action in ["navigation", "cancel", "undo", "document-switch"] { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + match action { + "navigation" => { + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press(Key::ArrowLeft); + h.run(); + h.key_press(Key::ArrowRight); + h.run(); + super::insert(&mut h.state_mut().app, "Z"); + assert!(families(&saved(&h, id)).iter().all(|f| f == FONTS[0])); + } + "cancel" => { + super::cancel(&mut h.state_mut().app); + assert!(h.state().app.ui.type_insertion_style.is_none()); + } + "undo" => { + h.state_mut().app.run("type.setStyle", json!({"layer":id.0,"range":[0,1],"font":FONTS[1]})).unwrap(); + assert!(h.state_mut().app.session.undo()); + assert!(super::insertion_style::pending(&h.state().app).is_none()); + } + _ => { + commit(&mut h, "document-switch"); + assert!(h.state().app.ui.type_insertion_style.is_none()); + } + } + } +} + +#[test] +fn returning_focus_to_the_same_canvas_caret_keeps_the_chosen_typing_font() { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [6, 6]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + let (layout, aff, text) = super::layout(&mut h.state_mut().app, id).unwrap(); + let (x, top, bottom) = layout.caret(text.len()); + let p = aff.apply(photocraft_geom::Point::new(f64::from(x) - 0.1, f64::from((top + bottom) * 0.5))); + assert!(super::pointer_down(&mut h.state_mut().app, p.x, p.y, Modifiers::NONE)); + super::pointer_up(&mut h.state_mut().app, [p.x, p.y], [p.x, p.y]); + assert_eq!(h.state().app.ui.text_edit.as_ref().unwrap().caret, 6); + assert!(super::insertion_style::pending(&h.state().app).is_some()); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.event(egui::Event::Text("XYZ".into())); + h.run(); + assert_eq!(families(&saved(&h, id)), [FONTS[0], FONTS[0], FONTS[0], FONTS[0], FONTS[0], FONTS[0], FONTS[1], FONTS[1], FONTS[1]]); +} + +#[test] +fn backspace_and_delete_preserve_pending_caret_font() { + for key in [Key::Backspace, Key::Delete] { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press(key); + h.run(); + assert!(super::insertion_style::pending(&h.state().app).is_some()); + h.event(egui::Event::Text("Z".into())); + h.run(); + let expected = if key == Key::Backspace { + vec![FONTS[0], FONTS[0], FONTS[1], FONTS[0], FONTS[0], FONTS[0]] + } else { + vec![FONTS[0], FONTS[0], FONTS[0], FONTS[1], FONTS[0], FONTS[0]] + }; + assert_eq!(families(&saved(&h, id)), expected); + } + + // Multiple backspaces in a row still preserve the pending font + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press(Key::Backspace); + h.run(); + h.key_press(Key::Backspace); + h.run(); + assert!(super::insertion_style::pending(&h.state().app).is_some()); + h.event(egui::Event::Text("Z".into())); + h.run(); + assert_eq!(families(&saved(&h, id)), [FONTS[0], FONTS[1], FONTS[0], FONTS[0], FONTS[0]]); +} + +#[test] +fn commit_shortcut_resolves_a_pending_query_like_enter() { + // "n" matches several families, and the current one is not the first row, so a fallback to + // the first row would apply a family the menu never highlighted. + let query = "n"; + let all = super::families(); + let (rows, _) = super::font_menu_rows(&all, None, query, &[]); + assert!(rows.first().is_some_and(|(f, _)| f.as_str() != FONTS[1]), "the test needs a first row other than the current family"); + assert!(rows.iter().any(|(f, _)| f.as_str() == FONTS[1])); + for accept in ["enter", "shortcut"] { + let (mut h, id) = fixture(false, 1, false, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, query, "query"); + if accept == "enter" { + h.key_press(Key::Enter); + h.run(); + } else { + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_none(), "the shortcut still commits"); + } + assert_eq!(families(&saved(&h, id)), vec![FONTS[1]; 6], "{accept} keeps the highlighted current family"); + } +} + +#[test] +fn commit_does_not_accept_an_invalid_font_or_interrupt_ime() { + for (character, query) in [(false, "no such font zzz"), (true, "no such font zzz"), (false, " "), (true, " ")] { + let (mut h, id) = fixture(false, 0, character, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, query, "query"); + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_none()); + assert_eq!(families(&saved(&h, id)), vec![FONTS[0]; 6]); + super::edit_active(&mut h.state_mut().app).unwrap(); + super::ime_update(&mut h.state_mut().app, "é", false); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run(); + assert!(h.state().app.ui.text_edit.is_some(), "composition owns its confirmation"); + } +} + +#[test] +fn font_field_composition_keeps_confirmation_then_releases_global_commit() { + for cancelled in [false, true] { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); + h.run(); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: FONTS[1].into(), active_range_chars: None })); + h.run(); + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_some()); + assert_eq!(families(&saved(&h, id)), vec![FONTS[0]; 6]); + if cancelled { + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None })); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(egui::Event::Text(FONTS[1].into())); + h.run(); + } else { + h.event(egui::Event::Ime(egui::ImeEvent::Commit(FONTS[1].into()))); + h.run(); + } + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_none()); + assert_eq!(families(&saved(&h, id)), [FONTS[0], FONTS[0], FONTS[1], FONTS[1], FONTS[0], FONTS[0]]); + } +} + +#[test] +fn caret_font_insertion_preserves_variable_axes_and_style_changes_can_clear_weight_axis() { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + h.state_mut().app.run("type.setStyle", json!({"layer":id.0,"variations":{"wght":650,"wdth":95}})).unwrap(); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + super::insert(&mut h.state_mut().app, "X"); + let variations = |app: &PhotocraftApp, at: usize| { + let mut offset = 0; + layer(&app.session.active().unwrap().doc, id) + .char_runs() + .into_iter() + .find_map(|r| { + offset += r.len; + (offset > at).then_some(r.style.variations) + }) + .unwrap() + }; + let axes = variations(&h.state().app, 3); + assert!(axes.iter().any(|v| v.axis == "wght" && v.value == 650.0)); + assert!(axes.iter().any(|v| v.axis == "wdth" && v.value == 95.0)); + let ctx = h.ctx.clone(); + super::apply(&mut h.state_mut().app, &ctx, json!({"fontStyle":"Bold"})); + super::insert(&mut h.state_mut().app, "Y"); + let axes = variations(&h.state().app, 4); + assert!(!axes.iter().any(|v| v.axis == "wght")); + assert!(axes.iter().any(|v| v.axis == "wdth" && v.value == 95.0)); +} + +#[test] +fn caret_font_and_face_persist_across_more_font_choices_and_paste() { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + let ctx = h.ctx.clone(); + super::apply(&mut h.state_mut().app, &ctx, json!({"fontStyle":"Bold"})); + choose(&mut h, FONTS[0], "enter"); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.event(egui::Event::Paste("éX".into())); + h.run(); + let t = saved(&h, id); + for (i, c) in t["chars"].as_array().unwrap().iter().enumerate() { + assert_eq!(c["font"], FONTS[0]); + assert_eq!(c["weight"], if (3..5).contains(&i) { 700 } else { 400 }); + } + let applied = saved(&h, id); + commit(&mut h, "shortcut-focused"); + assert_eq!(saved(&h, id), applied); + for name in ["probe.pcraft", "probe.psd"] { + let doc = h.state().app.session.active().unwrap().doc.clone(); + let out = photocraft_io::export(&doc, name, &Default::default()).unwrap(); + let back = photocraft_io::import(name, &out.bytes).unwrap().document; + let imported_id = back.walk().into_iter().find_map(|(_, _, l)| matches!(l.content, LayerContent::Text(_)).then_some(l.id)).unwrap(); + assert_eq!(families(&styles(&back, imported_id)), families(&applied)); + } + assert!(h.state_mut().app.session.undo()); + assert_eq!(saved(&h, id)["text"], "abcdef"); + assert!(h.state_mut().app.session.redo()); + assert_eq!(saved(&h, id), applied); +} + +#[test] +fn an_existing_empty_layer_keeps_its_font_when_committed() { + for character in [false, true] { + let (mut h, id) = fixture(false, 0, character, "abcdef"); + h.state_mut().app.run("type.edit", json!({"layer":id.0,"text":""})).unwrap(); + select(&mut h, [0, 0]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + commit(&mut h, "checkmark"); + assert_eq!(saved(&h, id)["summary"], FONTS[1]); + super::edit_active(&mut h.state_mut().app).unwrap(); + super::insert(&mut h.state_mut().app, "X"); + assert_eq!(families(&saved(&h, id)), [FONTS[1]]); + } +} + +#[test] +fn font_commit_gpu_snapshots() { + let Some(path) = std::env::var_os("FONT_COMMIT_VISUALS").map(std::path::PathBuf::from) else { return }; + let _gpu = photocraft_testkit::gpu_lock(); + std::fs::create_dir_all(&path).unwrap(); + for character in [false, true] { + for scale in [1.0, 2.0] { + let (mut h, _) = fixture_renderer(false, 0, character, "abcdef", true, scale); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.render().unwrap().save(path.join(format!("selected-{character}-{scale}x.png"))).unwrap(); + commit(&mut h, "checkmark"); + h.render().unwrap().save(path.join(format!("committed-mixed-{character}-{scale}x.png"))).unwrap(); + super::edit_active(&mut h.state_mut().app).unwrap(); + select(&mut h, [2, 4]); + h.run(); + h.render().unwrap().save(path.join(format!("reselected-{character}-{scale}x.png"))).unwrap(); + select(&mut h, [6, 6]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.render().unwrap().save(path.join(format!("caret-{character}-{scale}x.png"))).unwrap(); + } + } +} diff --git a/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs b/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs index 7d24fbef42..7144b2dcfc 100644 --- a/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs @@ -1,4 +1,4 @@ -//! The Type tool's searchable font menu (#1369): it grows back after a search and focuses its search field. +//! The Type tool's editable family field and searchable menu (#9338, #1369). use std::collections::HashMap; @@ -62,10 +62,237 @@ fn popup_rect(h: &Harness<'static, State>) -> Option { } fn open(h: &mut Harness<'static, State>) { - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); } +#[test] +fn clicking_the_name_selects_all_and_the_first_letter_replaces_it() { + let mut h = harness(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + open(&mut h); + let range = egui::TextEdit::load_state(&h.ctx, h.state().button).unwrap().cursor.char_range().unwrap(); + assert_eq!(range.sorted_cursors().map(|c| c.index.0), [0, "Family 00".chars().count()]); + h.event(egui::Event::Text("z".into())); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("z")); + assert_eq!(h.state().current, "Family 00", "typing only filters"); + assert!(h.query_by_label("Family 01").is_none()); + assert!(h.query_by_label("Zeta Sans").is_some()); + assert_eq!(h.query_all_by_role(Role::TextInput).count(), 1, "there is no second search box"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Zeta Sans"); + assert!(popup_rect(&h).is_none()); + assert!(h.get_by_role(Role::TextInput).is_focused()); +} + +#[test] +fn enter_prefers_a_full_name_over_an_earlier_substring_match() { + let mut h = harness(); + h.state_mut().families = vec!["Zeta Sans Display".into(), "Zeta Sans".into()]; + open(&mut h); + h.get_by_role(Role::TextInput).type_text("zEtA sAnS"); + h.run(); + assert_eq!(h.state().current, "Family 00"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Zeta Sans"); +} + +#[test] +fn empty_or_unmatched_input_applies_nothing_and_escape_restores_the_name() { + for query in ["", "no such font"] { + let mut h = harness(); + open(&mut h); + h.key_press(Key::Backspace); + h.run(); + h.event(egui::Event::Text(query.into())); + h.run(); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 00"); + assert!(popup_rect(&h).is_some()); + h.key_press(Key::Escape); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + assert!(!h.get_by_role(Role::TextInput).is_focused()); + assert!(popup_rect(&h).is_none()); + } +} + +#[test] +fn no_matching_fonts_shows_a_compact_message_and_clearing_restores_the_list() { + for preview in [FontPreview::Off, FontPreview::Medium, FontPreview::ExtraLarge] { + let mut h = harness_with(preview); + open(&mut h); + let full = popup_rect(&h).expect("full menu open"); + let field = h.get_by_role(Role::TextInput).rect(); + h.get_by_role(Role::TextInput).type_text("fdsfdsfds"); + h.run(); + let empty = popup_rect(&h).expect("unmatched input keeps the menu open"); + assert!(empty.width() >= field.width(), "{preview:?}: empty popup collapsed to {empty:?}"); + // The list shrinks to its message, so the menu ends with the class list beside it. + let classes = h.get_by_label("Decorative").rect(); + assert!( + empty.height() < full.height() - 100.0 && empty.bottom() < classes.bottom() + 20.0, + "{preview:?}: empty popup retained the full height: {empty:?} vs {full:?}" + ); + assert!(empty.contains(h.get_by_label("No matching fonts").rect().center())); + assert_eq!(h.state().current, "Family 00"); + h.key_press(Key::ArrowDown); + h.key_press(Key::ArrowUp); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 00", "empty-result navigation applies nothing"); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.key_press(Key::Backspace); + h.run(); + let restored = popup_rect(&h).expect("clearing restores the list"); + assert!(h.query_by_label("No matching fonts").is_none()); + assert!(h.query_by_label("Family 01").is_some()); + assert!((restored.width() - full.width()).abs() < 0.5, "{preview:?}: width {restored:?} != {full:?}"); + assert!((restored.height() - full.height()).abs() < 0.5, "{preview:?}: height {restored:?} != {full:?}"); + } +} + +#[test] +fn clicking_elsewhere_discards_unaccepted_input() { + let mut h = harness(); + open(&mut h); + h.get_by_role(Role::TextInput).type_text("Zeta"); + h.run(); + let outside = egui::pos2(550.0, 850.0); + h.hover_at(outside); + h.event(egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(h.state().current, "Family 00"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + assert!(popup_rect(&h).is_none()); +} + +#[test] +fn tab_leaves_the_field_and_discards_its_pending_query() { + let mut h = harness(); + open(&mut h); + h.get_by_role(Role::TextInput).type_text("Zeta"); + h.run(); + h.key_press(Key::Tab); + h.run(); + assert_eq!(h.state().current, "Family 00"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + assert!(popup_rect(&h).is_none()); + assert!(!h.get_by_role(Role::TextInput).is_focused()); +} + +#[test] +fn the_arrow_keeps_press_drag_release_selection() { + let mut h = harness(); + let arrow = h.get_by_role(Role::Button).rect().center(); + h.hover_at(arrow); + h.run(); + h.event(egui::Event::PointerButton { pos: arrow, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(popup_rect(&h).is_some()); + let row = h.get_by_label("Family 03").rect().center(); + h.hover_at(row); + h.run(); + h.event(egui::Event::PointerButton { pos: row, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none()); + assert!(h.get_by_role(Role::TextInput).is_focused()); +} + +#[test] +fn two_fields_keep_their_names_and_queries_independent() { + let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( + |ui, names: &mut [String; 2]| { + let families = vec!["Family 00".to_string(), "Zeta Sans".to_string()]; + for (i, name) in names.iter_mut().enumerate() { + super::family_picker_in(ui, &format!("font-{i}"), name, 170.0, &families, FontPreview::Off, &mut super::FontLibrary::default()); + } + }, + ["Family 00".to_string(), "Family 00".to_string()], + ); + h.run(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); + h.run(); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text("Zeta"); + h.run(); + let values: Vec<_> = h.query_all_by_role(Role::TextInput).map(|n| n.value().unwrap()).collect(); + assert_eq!(values, ["Zeta", "Family 00"]); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state(), &["Zeta Sans", "Family 00"]); + h.query_all_by_role(Role::TextInput).nth(1).unwrap().click(); + h.run(); + assert!(h.query_by_label("Family 00").is_some(), "the peer opens with its own full list"); +} + +#[test] +fn tab_focus_selects_the_name_and_pasted_unicode_can_be_accepted() { + let mut h = harness(); + h.state_mut().families = vec!["Family 00".into(), "Ime Sans".into()]; + h.key_press(Key::Tab); + h.run(); + assert!(h.get_by_role(Role::TextInput).is_focused()); + h.event(egui::Event::Paste("Ime Sans".into())); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Ime Sans")); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Ime Sans"); +} + +#[test] +fn ime_confirmation_does_not_accept_a_font_until_a_separate_enter() { + let mut h = harness(); + h.state_mut().families = vec!["Family 00".into(), "Ime Sans".into()]; + open(&mut h); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "Ime Sans".into(), active_range_chars: None })); + h.run(); + h.key_press(Key::ArrowDown); + h.run(); + h.key_press(Key::ArrowUp); + h.run(); + assert_eq!(h.state().current, "Family 00", "IME arrows do not select a font"); + assert!(h.get_by_role(Role::TextInput).is_focused()); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 00", "IME Enter only confirms composition"); + assert!(popup_rect(&h).is_some()); + assert!(h.get_by_role(Role::TextInput).is_focused()); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("Ime Sans".into()))); + h.run(); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Ime Sans"); + assert!(popup_rect(&h).is_none()); +} + +#[test] +fn dragging_inside_the_name_selects_text_without_picking_a_font() { + let mut h = harness(); + open(&mut h); + let field = h.get_by_role(Role::TextInput).rect(); + let from = egui::pos2(field.left() + 5.0, field.center().y); + let to = egui::pos2(field.left() + 30.0, field.center().y); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run(); + h.hover_at(to); + h.run(); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + let range = egui::TextEdit::load_state(&h.ctx, h.state().button).unwrap().cursor.char_range().unwrap(); + assert!(!range.is_empty()); + assert!(range.as_sorted_char_range().end.0 - range.as_sorted_char_range().start.0 < "Family 00".chars().count()); + assert_eq!(h.state().current, "Family 00"); + assert!(popup_rect(&h).is_some()); +} + /// A native click can leave the opening button held across several rendered frames. fn hold_open(h: &mut Harness<'_, S>, button: egui::Pos2) { h.hover_at(button); @@ -155,10 +382,10 @@ fn the_search_field_stays_put_while_the_list_scrolls() { let scrolled = h.get_by_label("Family 00").rect(); assert!(scrolled.top() < first_row.top() - 1.0, "the list did not scroll: {first_row:?} -> {scrolled:?}"); - // The field did not move with the rows: it is still where it was, inside the menu. + // The name stays in the original control while the list scrolls underneath. let after = h.get_by_role(Role::TextInput).rect(); assert_eq!(search, after, "the search field scrolled away with the list"); - assert!(popup.contains(after.center()), "the search field left the menu"); + assert!(!popup.contains(after.center()), "the editable name stays above the menu"); } /// The wheel past the end of the list, or over the class list, leaves the preview, filters and @@ -234,42 +461,37 @@ fn clearing_a_query_that_matches_nothing_grows_the_menu_back() { } #[test] -fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() { +fn reopening_selects_the_applied_name_and_clicking_it_keeps_the_menu_open() { let mut h = harness(); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); - hold_open(&mut h, button); - assert!(h.get_by_role(Role::TextInput).is_focused(), "search is focused on first open"); + let field = h.get_by_role(Role::TextInput).rect().center(); + hold_open(&mut h, field); + assert!(h.get_by_role(Role::TextInput).is_focused()); h.event(egui::Event::Text("zeta".into())); h.run(); - assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta"), "typing goes straight to the search field"); - // Close and reopen on consecutive passes: the query stays and the field is focused again. - h.input_mut().events.push(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.step(); - assert!(popup_rect(&h).is_none(), "Escape closes the menu"); - h.query_all_by_role(Role::ComboBox).next().unwrap().click_accesskit(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta")); + h.key_press(Key::Escape); h.run(); - let search = h.get_by_role(Role::TextInput); - assert_eq!(search.value().as_deref(), Some("zeta")); - assert!(search.is_focused(), "search is focused on reopen with a query"); - // Clicking the search field (to edit the query) must not close the menu. + assert!(popup_rect(&h).is_none()); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + open(&mut h); + assert!(h.get_by_role(Role::TextInput).is_focused()); + assert!(h.query_by_label("Family 01").is_some(), "reopening starts with the full list"); h.get_by_role(Role::TextInput).click(); h.run(); - assert!(popup_rect(&h).is_some(), "clicking the search field keeps the menu open"); + assert!(popup_rect(&h).is_some(), "editing the name keeps the list open"); h.key_press_modifiers(Modifiers::COMMAND, Key::A); h.get_by_role(Role::TextInput).type_text("Zeta S"); h.run(); - assert!(popup_rect(&h).is_some()); - // Picking a font still closes the menu. h.get_by_label("Zeta Sans").click(); h.run(); assert_eq!(h.state().current, "Zeta Sans"); - assert!(popup_rect(&h).is_none(), "choosing a font closes the menu"); + assert!(popup_rect(&h).is_none()); } #[test] -fn opening_click_release_returns_focus_to_search_before_typing() { +fn opening_arrow_release_returns_focus_to_the_name_before_typing() { let mut h = harness(); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + let button = h.get_by_role(Role::Button).rect().center(); h.hover_at(button); h.run(); h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -277,7 +499,7 @@ fn opening_click_release_returns_focus_to_search_before_typing() { assert!(h.get_by_role(Role::TextInput).is_focused()); // A native opening click can hand focus back to its button when it ends, after the // search has already been focused for several passes. Model that platform handoff. - let id = h.state().button; + let id = h.state().button.with("arrow"); h.ctx.memory_mut(|m| m.request_focus(id)); h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.run(); @@ -308,7 +530,7 @@ fn first_options_bar_open_searches_without_changing_the_tool_or_edited_text() { app }); h.run_steps(8); - let button = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Inter")).unwrap().rect().center(); + let button = h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("Inter")).unwrap().rect().center(); hold_open(&mut h, button); // Native typing carries key events as well as text; an unfocused search would let // the first key switch tools, or replace the selected inline text instead. @@ -493,11 +715,10 @@ fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() { ); crate::PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); // The font search field's caret blinks every pass, so settle with bounded steps instead. h.run_steps(2); h.get_by_role(Role::TextInput).type_text("JetBrains"); - h.key_press(Key::ArrowDown); h.run(); let font = |app: &PhotocraftApp| { let doc = &app.session.active().unwrap().doc; @@ -506,9 +727,10 @@ fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() { _ => panic!("type layer"), } }; - assert_eq!(font(h.state()), "JetBrains Mono"); + assert_eq!(font(h.state()), "Inter", "typing creates no document edit"); h.key_press(Key::Enter); h.run(); + assert_eq!(font(h.state()), "JetBrains Mono"); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(font(h.state()), "Inter"); h.state_mut().run("edit.redo", json!({})).unwrap(); @@ -589,7 +811,7 @@ fn options_bar_hover_previews_on_canvas_without_committing() { let layer = h.state().app.session.active().unwrap().active_layer.unwrap(); h.state_mut().app.ui.tool = crate::state::Tool::Type; h.run_steps(3); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); h.get_by_role(Role::TextInput).type_text("JetBrains Mono"); h.run(); @@ -872,7 +1094,7 @@ fn open_menu_shows_the_current_family_without_animating() { h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::duration(10.0)); h.state_mut().current = "Family 40".into(); h.run(); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run_steps(4); let popup = popup_rect(&h).expect("the menu is open"); let row = h.query_all_by_label("Family 40").next().expect("its row").rect(); @@ -901,7 +1123,7 @@ fn open_menu_arrow_up_on_the_top_recent_row_stays_there() { } /// #9160: when the marked recent row fits below the top of the recent group, the menu opens -/// with the whole group in view, its first row just below the search field. +/// with the whole group in view, its first row at the top of the list. #[test] fn open_menu_shows_the_recent_group_when_the_marked_row_fits_below_it() { use egui_kittest::kittest::NodeT; @@ -916,9 +1138,11 @@ fn open_menu_shows_the_recent_group_when_the_marked_row_fits_below_it() { let first = h.query_all_by_label("Family 10").map(|n| n.rect()).min_by(|a, b| a.top().total_cmp(&b.top())).expect("the recent group's first row"); let row = h.query_all_by_label("Family 12").find(|n| n.accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)).expect("its marked row").rect(); assert!(popup.contains_rect(row) && row.top() >= search.bottom(), "recent #3 is in view: {row:?} in {popup:?}"); + // The class list starts level with the font list, under the preview and filters. + let top = h.get_by_label("All Classes").rect().top(); assert!( - popup.contains_rect(first) && (0.0..=16.0).contains(&(first.top() - search.bottom())), - "the group's first row sits just below the search field: {first:?} under {search:?}" + popup.contains_rect(first) && (-1.0..=8.0).contains(&(first.top() - top)), + "the group's first row sits at the top of the list: {first:?}, list top {top}" ); } @@ -999,7 +1223,7 @@ fn options_bar_menu_arrows_walk_on_while_recording_recent_fonts() { ); crate::PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); let mut seen = vec![h.state().ui.tool_options.type_font.clone()]; for _ in 0..4 { @@ -1018,7 +1242,7 @@ fn options_bar_menu_arrows_walk_on_while_recording_recent_fonts() { #[test] fn wheel_over_the_closed_font_button_steps_through_the_families() { let mut h = harness(); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + let button = h.query_all_by_role(Role::TextInput).next().unwrap().rect().center(); h.hover_at(button); h.run(); let wheel = |h: &mut Harness<'static, State>, y: f32| { @@ -1043,7 +1267,7 @@ fn wheel_over_the_closed_font_button_steps_through_the_families() { #[test] fn wheel_over_the_closed_button_keeps_a_stable_order_with_recents() { let mut h = harness_with_recents(FontPreview::Medium, vec![]); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + let button = h.query_all_by_role(Role::TextInput).next().unwrap().rect().center(); h.hover_at(button); h.run(); let wheel = |h: &mut Harness<'static, State>, y: f32| { @@ -1116,7 +1340,7 @@ fn library_harness_with( state, ); h.run(); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); h } @@ -1429,7 +1653,7 @@ fn a_menu_opened_from_the_right_edge_stays_narrow_after_a_minimize() { state, ); h.run(); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run_steps(4); let width = |h: &Harness<'static, LibState>| h.ctx.memory(|m| m.area_rect(h.state().popup)).map(|r| r.width()); let before = width(&h).expect("menu open"); @@ -1468,7 +1692,7 @@ fn options_bar_hover_previews_with_the_character_picker_also_shown() { let LayerContent::Text(t) = &shown.layer(layer).unwrap().content else { panic!("type layer") }; t.char_runs()[0].style.font_family.clone() }; - let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); + let pickers: Vec<_> = h.query_all_by_role(Role::TextInput).filter(|n| n.value().as_deref() == Some("Inter")).collect(); assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); let button = pickers[0].rect().center(); hold_open(&mut h, button); @@ -1481,3 +1705,45 @@ fn options_bar_hover_previews_with_the_character_picker_also_shown() { h.run(); assert_eq!(font(&mut h), "Inter", "leaving the row restores the committed font"); } + +// FONT_FIELD_VISUAL_FIXTURE_START: kept compatible with the base revision for before/after capture. +#[test] +fn capture_font_field_gpu_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_FONT_FIELD_FIXTURES").map(std::path::PathBuf::from) else { return }; + let _gpu = photocraft_testkit::gpu_lock(); + std::fs::create_dir_all(&directory).unwrap(); + let phase = std::env::var("FONT_FIELD_FIXTURE_PHASE").unwrap_or_else(|_| "after".into()); + for theme in crate::theme::ThemeKind::ALL { + for scale in [1.0, 2.0] { + for width in [140.0, 170.0] { + let mut h = Harness::builder().with_size(vec2(420.0, 560.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, current: &mut String| { + let mut families: Vec = ["Arial", "Consolas", "Inter", "JetBrains Mono"].map(str::to_string).into(); + families.extend((0..60).map(|i| format!("Z Fixture Family {i:02}"))); + font_picker_in(ui, current, width, &families, FontPreview::Medium, &[]); + }, + "Inter".to_string(), + ); + crate::PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let stem = format!("{phase}-{}-{width}-{scale}x", theme.id()); + h.render().unwrap().save(directory.join(format!("{stem}-closed.png"))).unwrap(); + // The base revision has a ComboBox and a separate TextInput inside its popup. + h.query_by_role(Role::TextInput).or_else(|| h.query_by_role(Role::ComboBox)).unwrap().click(); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-selected.png"))).unwrap(); + h.event(egui::Event::Text("C".into())); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-filtered.png"))).unwrap(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(egui::Event::Text("fdsfdsfds".into())); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-no-matches.png"))).unwrap(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.key_press(Key::Backspace); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-cleared.png"))).unwrap(); + } + } + } +} diff --git a/crates/ui-egui/src/tools/type_tool/fonts.rs b/crates/ui-egui/src/tools/type_tool/fonts.rs index 266bdb6f02..7c8162e3f4 100644 --- a/crates/ui-egui/src/tools/type_tool/fonts.rs +++ b/crates/ui-egui/src/tools/type_tool/fonts.rs @@ -338,16 +338,10 @@ pub(super) fn font_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, current: & Some(style) } -/// Maximum height of the font menu: room for the search/filter bar over the font list, so only -/// the list scrolls. -pub(super) const FONT_MENU_HEIGHT: f32 = 520.0; -/// The font list's height (it scrolls inside the menu, under the fixed search field). -const FONT_LIST_HEIGHT: f32 = 440.0; +/// The font list's maximum height: it scrolls inside the menu, under the fixed preview and filters. +pub(super) const FONT_LIST_HEIGHT: f32 = 440.0; /// The font list's width: star, style expander, name, format badge and sample. const FONT_LIST_WIDTH: f32 = 420.0; -/// Room the search row keeps for Clear All, and for the filter buttons when they follow the field. -const FONT_CLEAR_WIDTH: f32 = 84.0; -const FONT_BUTTONS_WIDTH: f32 = 112.0; /// Room right of the font list's viewport for its scroll bar. const FONT_SCROLL_BAR_WIDTH: f32 = 14.0; /// Room for the format badge (O, TT, VAR) left of the sample. @@ -366,6 +360,29 @@ pub(super) fn font_picker_in( family_picker_in(ui, "type-font", current, width, families, preview, &mut FontLibrary { recent: recents.to_vec(), ..FontLibrary::default() }) } +/// The row Enter and ⌘/Ctrl+Enter apply to a pending family query: a full name wins over a +/// substring match (including localized family names), then the highlighted row, then the first. +pub(super) fn accept_row(rows: &[(&String, &str)], text: &str, selected: Option) -> Option { + let exact = text.to_lowercase(); + rows.iter().position(|(f, shown)| f.to_lowercase() == exact || shown.to_lowercase() == exact).or(selected).or_else(|| (!rows.is_empty()).then_some(0)) +} + +/// Transient input belongs to one field, independently of the applied family and its peers. +#[derive(Clone)] +pub(super) struct FontFieldEdit { + pub(super) text: String, + pub(super) query: String, + pub(super) current: String, + pub(super) recents: Vec, + pub(super) row: Option, + pub(super) opening: bool, + pub(super) accepted: bool, + pub(super) composing: bool, + pub(super) menu_height: Option, + /// The current family still has to be scrolled into view after the menu opened. + pub(super) reveal: bool, +} + /// Shared by the Type options, Character, style and Glyphs panels. Selection changes still /// flow through each caller's existing engine command. pub(crate) fn family_picker_in( @@ -377,59 +394,123 @@ pub(crate) fn family_picker_in( preview: photocraft_engine::prefs::FontPreview, library: &mut FontLibrary, ) -> bool { - let mut changed = false; - // A font picked from the open menu (click or Enter): the field keeps the focus afterwards. - let mut picked = false; - let search_id = ui.scope_id().with(("font-search", salt)); - let focus_id = search_id.with("focus"); - let recents_id = search_id.with("recents"); - let row_id = search_id.with("row"); - // The list opens on the press, so a press-drag-release picks a font too (#2735). - let (press, close) = crate::press_menu::PressCombo::before(ui, salt); + let field = ui.make_persistent_id(egui::IdSalt::new(salt)); + let popup = field.with("popup"); + let edit_id = field.with("edit"); + let was_open = egui::Popup::is_id_open(ui.ctx(), popup); + let had_focus = ui.memory(|m| m.has_focus(field)); let labels: Option>> = ui.ctx().data(|d| d.get_temp(font_labels_id())); let labels = labels.as_deref(); - let shown = labels.and_then(|l| l.get(current.as_str())).unwrap_or(current).clone(); - let combo = craft_ui::ComboBox::from_id_salt(salt) - .selected_text(shown) - .width(width) - .height(FONT_MENU_HEIGHT) - .icon(crate::widgets::chevron_icon) - // Clicks inside the menu (the search field, the scroll bar) must not close it (#1369); - // picking a font closes it explicitly below. - .close_behavior(if matches!(close, egui::PopupCloseBehavior::IgnoreClicks) { close } else { egui::PopupCloseBehavior::CloseOnClickOutside }); - - let mut response = combo.show_ui(ui, |ui| { + let label = |f: &str| labels.and_then(|l| l.get(f)).cloned().unwrap_or_else(|| f.to_string()); + let mut edit = + ui.data(|d| d.get_temp::(edit_id)).filter(|e| e.current == *current && (was_open || had_focus)).unwrap_or_else(|| FontFieldEdit { + text: label(current), + query: String::new(), + current: current.clone(), + recents: library.recent.clone(), + row: None, + opening: false, + accepted: false, + composing: false, + menu_height: None, + reveal: false, + }); + // Take navigation before TextEdit interprets it, and before the canvas can see it. + let owns_keys = ui.is_enabled() && (had_focus || was_open); + if had_focus { + ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); + } + // IME confirmation/navigation belongs to composition, even when its current spelling + // happens to be an exact font name. A separate Enter accepts the completed name. + let composing = edit.composing || ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Ime(_)))); + if owns_keys { + ui.input(|i| { + for event in &i.events { + match event { + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => edit.composing = !text.is_empty(), + egui::Event::Ime(egui::ImeEvent::Commit(_)) => edit.composing = false, + _ => {} + } + } + }); + } + let key = |k| owns_keys && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)); + let navigation_down = key(egui::Key::ArrowDown); + let navigation_up = key(egui::Key::ArrowUp); + let down = navigation_down && !composing; + let up = navigation_up && !composing; + let enter = key(egui::Key::Enter) && !composing; + let escape = key(egui::Key::Escape) && !composing; + let tab = owns_keys && ui.input(|i| i.key_pressed(egui::Key::Tab)); + if navigation_down || navigation_up { + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if edit.opening { + ui.memory_mut(|m| m.request_focus(field)); + } + let (input, rect) = crate::widgets::text_field_with_trailing(ui, field, &mut edit.text, tl!("Font"), width, crate::widgets::POPUP_ARROW_W); + let arrow_rect = + egui::Rect::from_min_max(egui::pos2(rect.right() - crate::widgets::POPUP_ARROW_W - 2.0, rect.top() + 2.0), rect.max - egui::vec2(2.0, 2.0)); + let arrow = ui.interact(arrow_rect, field.with("arrow"), egui::Sense::click()); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Font"))); + crate::widgets::chevron_icon(ui, arrow_rect, ui.style().interact(&arrow), was_open); + // The arrow owns press-drag-release; dragging in the name remains normal text selection. + // Its menu is anchored to the full control and keeps the existing stable popup id. + let mut anchor = arrow.clone(); + anchor.id = field; + anchor.rect = rect; + anchor.interact_rect = rect; + if arrow.clicked() && !ui.input(|i| i.pointer.any_click()) { + egui::Popup::toggle_id(ui.ctx(), popup); + } + let mut select_all = input.gained_focus() || (!was_open && input.clicked()); + if (input.gained_focus() && !edit.accepted) || (!was_open && input.clicked()) || input.changed() { + egui::Popup::open_id(ui.ctx(), popup); + } + if input.changed() { + edit.query = edit.text.to_lowercase(); + edit.row = None; + edit.accepted = false; + select_all = false; + } + if escape || tab { + egui::Popup::close_id(ui.ctx(), popup); + if escape { + ui.memory_mut(|m| m.surrender_focus(field)); + } + edit.opening = false; + } + let mut changed = false; + let mut picked = false; + // The name is outside the popup: clicking it to move the caret keeps the list open. + let close = if input.contains_pointer() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; + let response = crate::press_menu::show_with_close_behavior(&anchor, close, |ui, press| { // Positions kept between frames are relative to the menu's left edge: the menu moves // (the window's edge pushes it after a resize), and screen positions from an earlier - // frame would pad the search row out and widen the menu, an empty strip beside the list. + // frame would widen the menu, an empty strip beside the list. let origin = ui.cursor().min.x; - // Freshly opened (not drawn last pass): the preview starts on the current font. - let pass_id = search_id.with("pass"); - let pass = ui.ctx().cumulative_pass_nr(); - let opened = ui.data(|d| d.get_temp::(pass_id)).is_none_or(|p| p.saturating_add(1) < pass); - ui.data_mut(|d| d.insert_temp(pass_id, pass)); - // Each opening focuses the search, including a reopen on the very next pass. Keep - // asking through the invisible sizing pass, until the field receives focus. - let mut focus = ui.data(|d| d.get_temp(focus_id)).unwrap_or(true); - let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - // Consume these before TextEdit and the canvas can interpret them as caret movement. - let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); - let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); - let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - // The filter bar beside the search field, as in Photoshop: the class list toggle, Show - // Variable / Favorite / Similar Fonts and Clear All (`font_filters`). - let filters_id = search_id.with("filters"); + // Each opening reveals the current family once, on its first laid-out pass. + if !was_open { + edit.reveal = true; + } + let revealing = edit.reveal && !ui.is_sizing_pass(); + if revealing { + edit.reveal = false; + } + // The filter bar, as in Photoshop: the class list toggle, Show Variable / Favorite / + // Similar Fonts and Clear All (`font_filters`). The name field itself is the search. + let filters_id = field.with("filters"); let mut filters: crate::font_filters::FontFilters = ui.data(|d| d.get_temp(filters_id)).unwrap_or_default(); // The preview on top: the font last under the pointer, else the current one. - let preview_id = search_id.with("preview-font"); - if opened { + let preview_id = field.with("preview-font"); + if !was_open { ui.data_mut(|d| d.remove::(preview_id)); } let previewed: String = ui.data(|d| d.get_temp(preview_id)).unwrap_or_else(|| current.clone()); - let previewed_label = labels.and_then(|l| l.get(previewed.as_str())).map_or(previewed.as_str(), String::as_str).to_string(); - // Everything above the list is as wide as the class and font lists below, measured last + let previewed_label = label(&previewed); + // Everything above the lists is as wide as the class and font lists below, measured last // frame: never wider, so the menu has no empty strip beside the list. - let (list_left_id, body_id) = (search_id.with("list-left"), search_id.with("body")); + let body_id = field.with("body"); let class_w = crate::font_filters::CLASS_COLUMN_WIDTH; let estimate = FONT_LIST_WIDTH + 16.0 + if filters.hide_classes { 0.0 } else { class_w + 14.0 }; let body: Option = ui.data(|d| d.get_temp(body_id)); @@ -439,59 +520,17 @@ pub(crate) fn family_picker_in( ui.set_max_width(menu_width); } crate::font_filters::preview_banner(ui, &previewed, &previewed_label, library.preview_text.as_deref(), menu_width); - // The filter buttons over the class list, and the search field over the fonts, starting - // where the list does. - let list_left: Option = ui.data(|d| d.get_temp::(list_left_id)).map(|x| origin + x); - let menu_right = body.map(|b| origin + b.right()); - let r = ui - .horizontal(|ui| { - if !filters.hide_classes { - ui.allocate_ui_with_layout(egui::vec2(class_w, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - crate::font_filters::filter_buttons(ui, &mut filters); - }); - } - if let Some(x) = list_left.filter(|_| !filters.hide_classes) { - let pad = x - ui.cursor().min.x; - if pad > 0.0 { - ui.add_space(pad); - } - } - // The field fills the row up to Clear All (and the buttons, which follow it when - // the class list is hidden). - let trailing = FONT_CLEAR_WIDTH + if filters.hide_classes { FONT_BUTTONS_WIDTH } else { 0.0 }; - let search_w = menu_right.map_or(FONT_LIST_WIDTH - trailing, |right| right - ui.cursor().min.x - trailing).clamp(120.0, FONT_LIST_WIDTH); - if focus { - ui.memory_mut(|m| m.request_focus(search_id.with("input"))); - } - let r = ui.add(egui::TextEdit::singleline(&mut q).id(search_id.with("input")).hint_text(tl!("Search fonts")).desired_width(search_w)); - if filters.hide_classes { - crate::font_filters::filter_buttons(ui, &mut filters); - } - crate::font_filters::clear_all(ui, &mut filters); - r - }) - .inner; - if focus { - // The opening click can release after several focused frames. Keep the request - // pending through that release, which can hand focus back to the combo button. - focus = !r.has_focus() || ui.input(|i| i.pointer.primary_down() || i.pointer.primary_released()); - } - ui.data_mut(|d| { - d.insert_temp(focus_id, focus); - d.insert_temp(search_id, q.clone()); + ui.horizontal(|ui| { + crate::font_filters::filter_buttons(ui, &mut filters); + crate::font_filters::clear_all(ui, &mut filters); }); - let ql = q.to_lowercase(); let lookup = crate::font_filters::lookup_in(library.traits.clone()); - // The recent group stays as it was when the menu opened: each arrow step applies (and so - // records) a family, and moving it to the top would send the next step back up. - let opening = ui.data(|d| d.get_temp::>(recents_id).is_none()); - let open_recents: Vec = ui.data_mut(|d| d.get_temp_mut_or_insert_with(recents_id, || library.recent.clone()).clone()); // No recent group while a filter is on: the filters narrow the alphabetical list alone. - let (mut rows, n_recent) = font_menu_rows(families, labels, &ql, if filters.any() { &[] } else { &open_recents }); + let (mut rows, n_recent) = font_menu_rows(families, labels, &edit.query, if filters.any() { &[] } else { &edit.recents }); // Show Similar Fonts compares with the font current when it was turned on (or the menu // opened), as the recent group stays put: each arrow step applies a font, and sorting // around that one sent the next step back to the font before. - let similar_id = search_id.with("similar-to"); + let similar_id = field.with("similar-to"); let reference: String = if filters.similar { ui.data_mut(|d| d.get_temp_mut_or_insert_with(similar_id, || current.clone()).clone()) } else { @@ -499,26 +538,12 @@ pub(crate) fn family_picker_in( current.clone() }; let comparable = crate::font_filters::apply(&mut rows, &filters, &library.favorites, &reference, &lookup); - // A recent family is listed twice. The current family's row is the one the last arrow - // selected while it still shows that family, else its first copy: as in Photoshop, the - // one in the recent group when it has one (#9160), and the arrows walk on from there. - let mut selected = ui - .data(|d| d.get_temp::(row_id)) - .filter(|&i| rows.get(i).is_some_and(|(f, _)| *f == current)) - .or_else(|| rows.iter().position(|(f, _)| *f == current)); + let mut selected = edit.row.filter(|&i| rows.get(i).is_some_and(|(f, _)| *f == current)).or_else(|| rows.iter().position(|(f, _)| *f == current)); // The menu opens on the current family, as Photoshop's does: with the recent group at the // top of the view when the marked row fits below it, else the marked row itself, at the // top of the view when it is not a recent one. Applied on the first laid-out pass (the // popup's first pass only measures), whether or not a row matches, so a missing font or // a filtered list never scrolls the menu later. - let reveal_id = search_id.with("reveal"); - if opening { - ui.data_mut(|d| d.insert_temp(reveal_id, true)); - } - let revealing = ui.data(|d| d.get_temp::(reveal_id)).unwrap_or(false) && !ui.is_sizing_pass(); - if revealing { - ui.data_mut(|d| d.remove::(reveal_id)); - } let reveal = if revealing { selected } else { None }; let reveal_from = reveal.map(|i| if i < n_recent { 0 } else { i }); let mut stepped = None; @@ -529,25 +554,33 @@ pub(crate) fn family_picker_in( None if down => 0, None => rows.len().saturating_sub(1), }; - if let Some((f, _)) = rows.get(next) { + if let Some((f, shown)) = rows.get(next) { changed = **f != *current; current.clone_from(f); - // A served family not fetched yet: start now, as a click does. photocraft_text::served::request(f); - stepped = Some(next); + edit.text = (*shown).to_string(); + edit.row = Some(next); selected = Some(next); - ui.data_mut(|d| d.insert_temp(row_id, next)); + stepped = Some(next); + select_all = true; } } - let accept = enter && rows.iter().any(|(f, _)| *f == current); - picked |= accept; - if accept { - ui.data_mut(|d| d.remove::(search_id)); - ui.close(); + if enter && !edit.text.trim().is_empty() { + // A full name wins over a substring match (including localized family names). + let accept = accept_row(&rows, &edit.text, selected); + if let Some((f, _)) = accept.and_then(|i| rows.get(i)) { + // Acceptance is an application to the selected characters, even when a + // mixed selection's representative already has this family (as row clicks do). + changed = true; + current.clone_from(f); + photocraft_text::served::request(f); + picked = true; + ui.close(); + } } let preview = crate::font_preview::sample(preview); let row_h = preview.map_or(28.0, |(_, [_, h])| (h + 4.0).max(28.0)); - let expanded_id = search_id.with("expanded"); + let expanded_id = field.with("expanded"); let mut expanded: Vec = ui.data(|d| d.get_temp(expanded_id)).unwrap_or_default(); let mut hovered: Option = None; let reveal = if stepped.is_some() { None } else { reveal }; @@ -563,9 +596,16 @@ pub(crate) fn family_picker_in( divider = Some(ui.cursor().min.x + 3.0); ui.add_space(6.0 + ui.spacing().item_spacing.x); } - // The list scrolls under the fixed search field and filters, and shrinks to a short - // search result: the menu grows back with it (craft_ui::ComboBox, #9849). - let list = egui::ScrollArea::vertical().id_salt(search_id.with("list")).max_height(FONT_LIST_HEIGHT).auto_shrink([true, true]); + // The list scrolls under the fixed preview and filters, at its full height or the room + // the window leaves below it. That height is also its minimum, so the menu grows back + // after a search that matched nothing (a popup never grows by itself, egui#5225); the + // row beside the class list is no measure of it. With no rows it shrinks to its message. + let room = ui.ctx().content_rect().bottom() - ui.cursor().top() - 16.0; + let height = FONT_LIST_HEIGHT.min(room).max(row_h); + let mut list = egui::ScrollArea::vertical().id_salt(field.with("list")).auto_shrink([true, true]); + if !rows.is_empty() { + list = list.max_height(height).min_scrolled_height(height).auto_shrink([true, false]); + } // Top to bottom, though the list sits beside the class list. let shown = list.show(ui, |ui| { ui.vertical(|ui| { @@ -581,13 +621,15 @@ pub(crate) fn family_picker_in( ui, if filters.favorites && library.favorites.is_empty() { tl!("No favorite fonts yet: click the star beside a font to add it.") - } else { + } else if filters.any() { tl!("No fonts match these filters.") + } else { + tl!("No matching fonts") }, ); } // The first `n_recent` rows are the recent group, above a line (`font_menu_rows`). - for (i, &(f, label)) in rows.iter().enumerate() { + for (i, &(f, shown)) in rows.iter().enumerate() { let recent_row = i < n_recent; if i == n_recent && n_recent > 0 { ui.separator(); @@ -603,7 +645,7 @@ pub(crate) fn family_picker_in( // row's stroke grew it and pushed the rows below down (#7406). let slot = preview.map_or(0.0, |(size, _)| crate::font_preview::slot_width(ui.ctx(), f, size)); // One row lit, though a recent family is listed twice. - let mut button = egui::Button::selectable(selected == Some(i), label).truncate().stroke(egui::Stroke::NONE); + let mut button = egui::Button::selectable(selected == Some(i), shown).truncate().stroke(egui::Stroke::NONE); let [_, h] = preview.map_or([0.0, 16.0], |(_, b)| b); button = button.right_text(egui::Atom::custom(egui::IdSalt::new(("font-sample", f, recent_row)), egui::vec2(slot + FONT_BADGE_WIDTH, h))); @@ -668,12 +710,11 @@ pub(crate) fn family_picker_in( hovered = Some(f.clone()); } if press.chosen(ui, &response) { - *current = f.clone(); + changed = true; + current.clone_from(f); // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); - changed = true; picked = true; - ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); } @@ -692,12 +733,11 @@ pub(crate) fn family_picker_in( }) .inner; if style_row.clicked() { - *current = f.clone(); + changed = true; + current.clone_from(f); photocraft_text::served::request(f); library.picked_style = Some(style.clone()); - changed = true; picked = true; - ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); } @@ -705,15 +745,19 @@ pub(crate) fn family_picker_in( } }) }); - ui.data_mut(|d| d.insert_temp(list_left_id, shown.inner_rect.left() - origin)); - shown.inner_rect.right() + (shown.inner_rect.right(), (!rows.is_empty()).then_some(shown.inner_rect.height())) }); if let Some(x) = divider { ui.painter().vline(x, body.response.rect.y_range(), ui.visuals().widgets.noninteractive.bg_stroke); } + let (list_right, list_height) = body.inner; + // The list's viewport, while it lists fonts. + if list_height.is_some() { + edit.menu_height = list_height; + } // The lists' own extent, from the class list to the font list's scroll bar: the row // itself stretches with the popup, so it can't tell when the popup is too wide. - let list_right = body.inner + FONT_SCROLL_BAR_WIDTH; + let list_right = list_right + FONT_SCROLL_BAR_WIDTH; let body = egui::Rect::from_min_max(body.response.rect.min, egui::pos2(list_right.min(body.response.rect.right()), body.response.rect.bottom())); ui.data_mut(|d| { d.insert_temp(filters_id, filters); @@ -723,65 +767,29 @@ pub(crate) fn family_picker_in( d.insert_temp(preview_id, f.clone()); } }); - if accept { None } else { hovered } + if picked { None } else { hovered } }); - response.response = response.response.accessible_name(tl!("Font")); - press.after(&response.response); - if !craft_ui::ComboBox::is_open(ui.ctx(), response.response.id) { - ui.data_mut(|d| { - d.remove::(focus_id); - d.remove::>(recents_id); - d.remove::(row_id); - d.remove::(search_id.with("reveal")); - d.remove::(search_id.with("similar-to")); - }); - } - let hovered_font = response.inner.unwrap_or_default(); - - // As in Photoshop, the field keeps the keyboard focus once a font is picked, and ↑ / ↓ on the - // focused, closed field step to the previous / next family, applying each (#7407). The lock - // filter keeps egui from using those arrows to move the focus to another widget. - let field = response.response.id; - if picked { + let open = egui::Popup::is_id_open(ui.ctx(), popup); + if !was_open && open { + edit.recents = library.recent.clone(); + edit.opening = arrow.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()); ui.memory_mut(|m| m.request_focus(field)); + select_all = !input.changed(); } - let open = craft_ui::ComboBox::is_open(ui.ctx(), field); - if !open && ui.is_enabled() && ui.memory(|m| m.has_focus(field)) { - ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); - let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); - let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); - if down || up { - // The lock filter only holds from the frame after the field got the focus: an arrow - // right after a pick was already read by egui as a focus move, applied at the end of - // the frame. Cancel it, so the arrow only steps the font. - ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - } - // The wheel's step: a negative notch is the next family. Steps use a stable order (no - // recent group), so recording the picked family cannot make the next step jump back. - let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); - let (rows, _) = font_menu_rows(families, labels, "", &[]); - if let Some(i) = rows.iter().position(|(f, _)| *f == current) - && let Some(n) = wheel_family_index(i, rows.len(), notches) - && let Some((f, _)) = rows.get(n) - && **f != *current - { - current.clone_from(f); - photocraft_text::served::request(f); - changed = true; - } + // A held opening arrow can regain focus on release; keep the name selected through it. + if edit.opening { + ui.memory_mut(|m| m.request_focus(field)); + select_all |= !input.changed(); + edit.opening = ui.input(|i| i.pointer.primary_down()); } - - // The wheel over the closed button steps through the families, a notch each, like Photoshop's - // options-bar font menu: no need to open the list (#3350). - if !craft_ui::ComboBox::is_open(ui.ctx(), response.response.id) && ui.is_enabled() { - let hovered = response.response.hovered(); + if !open && !escape && ui.is_enabled() { + let notches = if had_focus { f32::from(u8::from(up)) - f32::from(u8::from(down)) } else { 0.0 }; + let hovered = input.hovered() || arrow.hovered(); if hovered { ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); } - let notches = crate::widgets::wheel_notches_of(ui, response.response.id.with("family-wheel"), hovered, 1.0); - // The wheel steps in a stable order: a recent group holding the family it just picked - // would change the order between notches and can jump back to the previous family. - // The open menu still shows the recent families first. + let notches = notches + crate::widgets::wheel_notches_of(ui, field.with("family-wheel"), hovered, 1.0); + // Closed-field navigation stays alphabetical, independent of recent picks (#3350, #7407). let (rows, _) = font_menu_rows(families, labels, "", &[]); if let Some(i) = rows.iter().position(|(f, _)| *f == current) && let Some(n) = wheel_family_index(i, rows.len(), notches) @@ -791,20 +799,42 @@ pub(crate) fn family_picker_in( current.clone_from(f); photocraft_text::served::request(f); changed = true; + select_all |= had_focus; } } - - if salt == "type-font" { + if picked || (!open && (was_open || changed || escape)) { + edit.text = label(current); + edit.query.clear(); + edit.row = None; + edit.accepted = true; + edit.opening = false; + edit.composing = false; + } + if picked { + ui.memory_mut(|m| m.request_focus(field)); + select_all = true; + } + if select_all && !escape { + crate::field_tab::select_all(ui.ctx(), field, &edit.text); + } + if !open { + ui.data_mut(|d| d.remove::(field.with("similar-to"))); + } + edit.current.clone_from(current); + if open || ui.memory(|m| m.has_focus(field)) { + ui.data_mut(|d| d.insert_temp(edit_id, edit)); + } else { + ui.data_mut(|d| d.remove::(edit_id)); + } + if salt == "type-font" && (was_open || open || response.is_some()) { + // Only this popup's owner clears its preview; a closed peer must leave it alone (#5152). let hover_id = egui::Id::unique("type-font-hover"); - if let Some(f) = hovered_font { + if let Some(f) = response.flatten().filter(|_| open) { ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); - } else if craft_ui::ComboBox::is_open(ui.ctx(), response.response.id) { - // Only the open menu clears it: the options bar and the Character section both draw - // this picker, and the closed one must not wipe the other's hover (#5152). + } else { ui.ctx().data_mut(|d| d.remove::(hover_id)); } } - changed } diff --git a/crates/ui-egui/src/tools/type_tool/ime_edit.rs b/crates/ui-egui/src/tools/type_tool/ime_edit.rs index 33296343d3..6cb48d4741 100644 --- a/crates/ui-egui/src/tools/type_tool/ime_edit.rs +++ b/crates/ui-egui/src/tools/type_tool/ime_edit.rs @@ -4,7 +4,8 @@ use super::ime::{ }; use super::layout::layout; use super::{ - CommittedText, ImeObservation, LayerId, PhotocraftApp, Point, Pos2, Range, TextEdit, ViewXform, byte_of, current_text, json, text_char_count, text_layer, + CommittedText, ImeObservation, LayerId, PhotocraftApp, Point, Pos2, Range, TextEdit, ViewXform, byte_of, current_text, insertion_style, json, + text_char_count, text_layer, }; /// UTF-8 byte offsets of the caret, anchor and pre-edit, found in one traversal. @@ -298,7 +299,7 @@ pub(super) fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { let before = if commit { ime_committed(app, &ed) } else { None }; let len = s.chars().count(); crate::type_transform::finish(app); - if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": s}, "coalesce": ed.session})).is_err() { + if !insertion_style::replace(app, start, end, &s) { return; } let mut after_ed = ed.clone(); diff --git a/crates/ui-egui/src/tools/type_tool/ime_tests.rs b/crates/ui-egui/src/tools/type_tool/ime_tests.rs index 5617eebe5b..a63ede17a0 100644 --- a/crates/ui-egui/src/tools/type_tool/ime_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/ime_tests.rs @@ -1051,12 +1051,16 @@ fn reset_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, ResetHarn photocraft_engine::prefs::FontPreview::Medium, &mut FontLibrary::default(), ); - let search = ui.scope_id().with(("font-search", "reset-font")); - state.search = ui.data(|d| d.get_temp::(search)).unwrap_or_default(); - let input = search.with("input"); - state.search_input = Some(input); - state.search_focused = ui.memory(|m| m.has_focus(input)); - state.search_height = ui.data(|d| d.get_temp::(search.with("body"))).map(|r| r.height()); + // The font field is its own text input: it takes focus as a click would, and + // its edit state holds what is typed and the measured popup height. + if !ui.memory(|m| m.has_focus(button)) { + ui.memory_mut(|m| m.request_focus(button)); + } + let edit = ui.data(|d| d.get_temp::(button.with("edit"))); + state.search = edit.as_ref().map(|e| e.text.clone()).unwrap_or_default(); + state.search_input = Some(button); + state.search_focused = ui.memory(|m| m.has_focus(button)); + state.search_height = edit.and_then(|e| e.menu_height); }); } if state.picker_button { @@ -1094,12 +1098,13 @@ fn actual_font_search_owns_stock_input_without_a_speculative_custom_request() { assert!(h.state().search_focused, "the real font search field, not another stock field, owns focus"); assert_eq!(h.ctx.memory(|m| m.focused()), Some(search_input), "the stock IME owner is the focused font search input"); assert!( - h.state().search_height.is_some_and(|height| height > 0.0 && height <= FONT_MENU_HEIGHT), + h.state().search_height.is_some_and(|height| height > 0.0 && height <= FONT_LIST_HEIGHT), "the real font list measured within its bounded popup height" ); h.input_mut().events = vec![completed(first), egui::Event::Text("Family".into())]; h.step(); - assert_eq!(h.state().search, "Family", "the actual font picker keeps its text event"); + // The field opens holding the current family, so the typed text lands after it. + assert_eq!(h.state().search, "Family 00Family", "the actual font picker keeps its text event"); assert_eq!(layer_text(&h.state().app), before); assert_eq!(pending(&h.state().app), None); let stock = h.output().platform_output.ime.as_ref().expect("stock output survives finalization"); diff --git a/crates/ui-egui/src/tools/type_tool/insertion_style.rs b/crates/ui-egui/src/tools/type_tool/insertion_style.rs new file mode 100644 index 0000000000..611c7ab3ad --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/insertion_style.rs @@ -0,0 +1,82 @@ +//! A caret's pending font is view state; inserted runs still travel through `type.edit`. +use super::options_bar::styles_at; +use super::{LayerId, PhotocraftApp, json, text_layer}; +use crate::state::TypeInsertionStyle; + +pub(super) fn pending(app: &PhotocraftApp) -> Option<&TypeInsertionStyle> { + let ed = app.ui.text_edit.as_ref()?; + let p = app.ui.type_insertion_style.as_ref()?; + let st = app.session.active()?; + (st.doc.id.0 == p.document && ed.layer == p.layer && ed.session == p.session && ed.caret == ed.anchor && ed.caret == p.caret && st.revision == p.revision) + .then_some(p) +} + +/// Backspace/Delete select the removed character before replacing it; the typing font survives that. +fn pending_across_deletion(app: &PhotocraftApp, start: usize, end: usize, text: &str) -> Option { + if !text.is_empty() { + return None; + } + let ed = app.ui.text_edit.as_ref()?; + let p = app.ui.type_insertion_style.as_ref()?; + let st = app.session.active()?; + (st.doc.id.0 == p.document && ed.layer == p.layer && ed.session == p.session && st.revision == p.revision && (p.caret == start || p.caret == end)) + .then(|| p.clone()) +} + +pub(super) fn shown(app: &PhotocraftApp, c: &mut photocraft_doc::text::CharStyle) { + if let Some(p) = pending(app) { + c.clone_from(&p.style); + } +} + +pub(super) fn set_font(app: &mut PhotocraftApp, props: &serde_json::Value) -> bool { + if app.ui.text_edit.as_ref().is_none_or(|ed| ed.caret != ed.anchor) || (props.get("font").is_none() && props.get("fontStyle").is_none()) { + return false; + } + crate::type_transform::finish(app); + let Some(ed) = app.ui.text_edit.as_ref() else { return false }; + // Empty layers already have a persistent typing run; there are no earlier letters to protect. + if app.session.active().and_then(|st| text_layer(&st.doc, LayerId(ed.layer))).is_some_and(|t| t.text.is_empty()) { + return false; + } + let Some((mut style, _)) = styles_at(app) else { return false }; + let Some(st) = app.session.active() else { return false }; + photocraft_engine::type_cmds::apply_char_props(&mut style, props); + app.ui.type_insertion_style = + Some(TypeInsertionStyle { document: st.doc.id.0, layer: ed.layer, session: ed.session.clone(), revision: st.revision, caret: ed.caret, style }); + true +} + +/// Replace and style the inserted characters atomically, including paste and IME preedit. +pub(super) fn replace(app: &mut PhotocraftApp, start: usize, end: usize, text: &str) -> bool { + let Some(ed) = app.ui.text_edit.clone() else { return false }; + let pending = pending(app).cloned().or_else(|| pending_across_deletion(app, start, end, text)); + let caret = start.saturating_add(text.chars().count()); + let mut params = json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": text}, "coalesce": ed.session}); + if let Some(p) = &pending + && !text.is_empty() + { + let mut run = json!({"start": start, "end": caret, "font": p.style.font_family, "weight": p.style.weight, "italic": p.style.italic}); + // Setting weight clears an inherited wght axis; restore the chosen font's axes last. + run["variations"] = p.style.variations.iter().map(|v| (v.axis.clone(), json!(v.value))).collect::>().into(); + if let Some(face) = &p.style.postscript_name { + run["postscriptName"] = json!(face); + } + params["runs"] = json!([run]); + } + if let Err(e) = app.run("type.edit", params) { + app.ui.status = e; + app.ui.status_error = true; + return false; + } + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.caret = caret; + ed.anchor = caret; + } + app.ui.type_insertion_style = pending.and_then(|mut p| { + p.caret = caret; + p.revision = app.session.active()?.revision; + Some(p) + }); + true +} diff --git a/crates/ui-egui/src/tools/type_tool/keys.rs b/crates/ui-egui/src/tools/type_tool/keys.rs index deb10f77a1..74c81c2f7d 100644 --- a/crates/ui-egui/src/tools/type_tool/keys.rs +++ b/crates/ui-egui/src/tools/type_tool/keys.rs @@ -245,7 +245,8 @@ fn handle_pressed_key( use egui::Key; match key { - Key::Enter if modifiers.command => commit_with(app, *modifiers), + Key::Enter if modifiers.command && edit.edit.preedit.is_none() => commit_with(app, *modifiers), + Key::Enter if modifiers.command => {} // IME owns confirmation until preedit ends. Key::Enter => insert(app, "\n"), Key::Tab if !modifiers.command && !modifiers.alt && !modifiers.shift => { // egui queues focus navigation before this handler runs; consuming the @@ -258,6 +259,7 @@ fn handle_pressed_key( Key::Escape if app.session.prefs().type_.use_esc_to_commit => commit(app), Key::Escape => cancel(app), Key::A if modifiers.command => { + app.ui.type_insertion_style = None; if let Some(state) = app.ui.text_edit.as_mut() { state.anchor = 0; state.caret = edit.char_count; @@ -386,6 +388,9 @@ fn handle_move_key(app: &mut PhotocraftApp, key: egui::Key, modifiers: &egui::Mo fn set_caret(app: &mut PhotocraftApp, char_count: usize, caret: usize, extend: bool) { if let Some(state) = app.ui.text_edit.as_mut() { + if state.caret != caret.min(char_count) || (!extend && state.anchor != caret.min(char_count)) { + app.ui.type_insertion_style = None; + } state.caret = caret.min(char_count); if !extend { state.anchor = state.caret; diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index e2616982ff..33287d1234 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -2,20 +2,13 @@ use super::defaults::{apply, formatting_params, open_color_picker, shown_scale, use super::editing::{cancel, commit_with}; use super::fonts::{font_picker, fonts_ready, selected_style, style_label, styles}; use super::keys::is_vertical; -use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, json, text_layer}; +use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, insertion_style, json, selected_styles, text_layer}; /// Photoshop's Type options bar. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); - // Show the target layer's (first-run) style, else the tool defaults. - let shown = target(app).and_then(|(id, _)| { - let st = app.session.active()?; - let tl = text_layer(&st.doc, LayerId(id))?; - let run = tl.char_runs().into_iter().next().map(|r| r.style); - // The size at the selection (else the first run), as the layer shows it (#124). - let size = styles_at(app).map_or(tl.size_pt, |(c, _)| c.size_pt) * shown_scale(app); - Some((tl.font_family.clone(), run.as_ref().map(selected_style).unwrap_or_default(), size, styles_at(app).map_or(tl.color, |(c, _)| c.color))) - }); + // Both Type controls describe the selected characters, or the caret's typing style. + let shown = styles_at(app).map(|(c, _)| (shown_family(app, &c), selected_style(&c), c.size_pt * shown_scale(app), c.color)); let o = app.ui.tool_options.clone(); let (mut fam, mut style, size) = match &shown { Some((f, s, z, _)) => (f.clone(), if s.is_empty() { "Regular".into() } else { s.clone() }, *z), @@ -50,7 +43,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { None => apply(app, ui.ctx(), json!({"font": fam})), } } - let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); + let style_family = if fam.is_empty() { styles_at(app).map(|(c, _)| c.font_family).unwrap_or_default() } else { fam.clone() }; + let opts: Vec<(String, String)> = styles(&style_family).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); if crate::widgets::dropdown(ui, "type-style", tl!("Style"), &mut style, &opts_ref, 110.0) { app.ui.tool_options.type_style = style.clone(); @@ -168,13 +162,29 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } -/// Character and paragraph style at the target (selection start, else the layer's first run). -/// The paragraph at a collapsed caret is the caret's, as Photoshop's options bar and Paragraph -/// panel show it. +/// A blank family represents mixed fonts, rather than falsely reporting the first one after +/// commit. At a caret, the current/pending insertion font is the relevant single style. +pub(super) fn shown_family(app: &PhotocraftApp, c: &photocraft_doc::text::CharStyle) -> String { + let same = |s: &photocraft_doc::text::CharStyle| s.font_family == c.font_family; + let Some(st) = target_doc(app) else { return c.font_family.clone() }; + let uniform = if let Some(ed) = &app.ui.text_edit { + if ed.caret == ed.anchor { + true + } else { + text_layer(&st.doc, LayerId(ed.layer)).is_none_or(|t| selected_styles(t, ed.anchor, ed.caret).iter().all(same)) + } + } else { + st.selected_layers().into_iter().filter_map(|id| text_layer(&st.doc, id)).all(|t| t.char_runs().iter().all(|r| same(&r.style))) + }; + if uniform { c.font_family.clone() } else { String::new() } +} + +/// Character and paragraph style at the selection, caret, or first run of selected layers. +/// The paragraph at a collapsed caret is the caret's, as the options bar and Paragraph panel show it. pub(crate) fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle)> { let (layer, range) = target(app)?; let t = text_layer(&target_doc(app)?.doc, LayerId(layer))?; - let at = range.map_or(0, |r| byte_of(&t.text, r[0])); + let at = range.map_or_else(|| app.ui.text_edit.as_ref().map_or(0, |ed| byte_of(&t.text, ed.caret.saturating_sub(1))), |r| byte_of(&t.text, r[0])); let para_at = match (range, &app.ui.text_edit) { (None, Some(ed)) => byte_of(&t.text, ed.caret), _ => at, @@ -191,7 +201,8 @@ pub(crate) fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::Ch }; let runs = t.char_runs(); let paras = t.paragraph_runs(); - let c = runs.get(pick_at(at, runs.iter().map(|r| r.len).collect())).map(|r| r.style.clone())?; + let mut c = runs.get(pick_at(at, runs.iter().map(|r| r.len).collect())).map(|r| r.style.clone())?; + insertion_style::shown(app, &mut c); let p = paras.get(pick_at(para_at, paras.iter().map(|r| r.len).collect())).map(|r| r.style.clone()).unwrap_or_default(); Some((c, p)) } diff --git a/crates/ui-egui/src/tools/type_tool/panel.rs b/crates/ui-egui/src/tools/type_tool/panel.rs index 09ebeb70c9..1caa9913a2 100644 --- a/crates/ui-egui/src/tools/type_tool/panel.rs +++ b/crates/ui-egui/src/tools/type_tool/panel.rs @@ -1,7 +1,8 @@ use super::defaults::{apply, apply_defaults, default_styles, formatting_params, open_color_picker, shown_scale, target, type_unit}; use super::fonts::{axis_sliders, font_picker, fonts_ready, selected_style, style_label, styles}; use super::options_bar::{ - align_glyph, apply_kerning, direction_glyph, direction_items, icon_label, kerning_at, kerning_field, kerning_label, num_field, styles_at, toggle_cell, + align_glyph, apply_kerning, direction_glyph, direction_items, icon_label, kerning_at, kerning_field, kerning_label, num_field, shown_family, styles_at, + toggle_cell, }; use super::{Color32, LayerId, PhotocraftApp, Stroke, json, text_layer}; @@ -229,7 +230,7 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa if character && section(ui, "character", tl!("Character")) { let full = ui.available_width(); let w = field_width(full, 2, LABEL_W); - let mut fam = c.font_family.clone(); + let mut fam = shown_family(app, &c); let mut ready = true; row(ui, &mut |ui| { ready = fonts_ready(ui, full); @@ -250,7 +251,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa return; } let mut style = selected_style(&c); - let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); + let opts: Vec<(String, String)> = + styles(if fam.is_empty() { &c.font_family } else { &fam }).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); if crate::widgets::dropdown(ui, "props-type-style", tl!("Style"), &mut style, &opts_ref, full) { apply(app, ui.ctx(), json!({"fontStyle": style})); diff --git a/crates/ui-egui/src/tools/type_tool/pointer.rs b/crates/ui-egui/src/tools/type_tool/pointer.rs index 39cce1c91c..59d1c71c6d 100644 --- a/crates/ui-egui/src/tools/type_tool/pointer.rs +++ b/crates/ui-egui/src/tools/type_tool/pointer.rs @@ -108,6 +108,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie if let Some(ed) = app.ui.text_edit.clone() { let id = LayerId(ed.layer); if let Some(i) = box_handle_at(app, id, x, y) { + app.ui.type_insertion_style = None; // Snapshot the box and transform now: every move recomputes from them, so ⌥ held // or released mid-drag mirrors about the centre the drag started with. let snap = box_shape(app, id).zip(layout(app, id)).map(|((bx, by, w, h), (_, aff, _))| BoxResize { handle: i, shape: [bx, by, w, h], aff }); @@ -120,6 +121,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie // (left of a line's start, below the last line); only a click outside it commits. if hit_layer(app, x, y) == Some(id) || inside_box(app, id, x, y) { let off = hit_offset(app, id, x, y); + if off != ed.caret || (!mods.shift && off != ed.anchor) { + app.ui.type_insertion_style = None; + } if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; if !mods.shift { @@ -133,6 +137,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } + app.ui.type_insertion_style = None; // The Type Mask tools always start new text: existing type layers are just pixels to them; // so does a ⇧-click with the Type tool, as in Photoshop, to start new text over old. if app.ui.tool.is_type_mask() || mods.shift { @@ -171,6 +176,9 @@ pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } if ed.dragging { let off = hit_offset(app, LayerId(ed.layer), x, y); + if off != ed.caret { + app.ui.type_insertion_style = None; + } if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; } diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index 202c2fd184..31de3650e9 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -1039,11 +1039,14 @@ fn new_fill_layer_takes_the_selected_path_as_its_vector_mask() { // Deselected (or a stale name): the selection is the layer mask, no vector mask. for sel in [None, Some("Gone".to_string())] { app.ui.selected_path = sel; + // The selection goes into the new layer's mask, so each fill layer needs its own (#8801). + app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); let r = app.run("layer.newFillLayer.solidColor", json!({})); let l = layer(&app, r); - assert!(l.vector_mask.is_none() && l.mask.is_some()); + assert!(l.vector_mask.is_none() && l.mask.as_ref().is_some_and(|m| m.value(160, 160) == 1.0 && m.value(5, 5) == 0.0)); } // Make Work Path selects the new work path. + app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); app.run("select.toWorkPath", json!({"tolerance": 2.0})).unwrap(); assert_eq!(app.ui.selected_path.as_deref(), Some("work")); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8be17ebf3d..72a09569f2 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -61,7 +61,9 @@ pub use slider::{RowGestures, second_click, slider, slider_row, slider_row_prec, pub(crate) use slider::{count_notches, wheel_notches_of}; #[cfg(test)] pub(crate) use slider::{curve_fraction, curve_value}; -pub use text_field::{INPUT_EVENT_LIMIT, TEXT_FIELD_H, TEXT_FIELD_LIMIT, cap_text_events, focus_first_field, text_field, text_field_ex}; +pub use text_field::{ + INPUT_EVENT_LIMIT, TEXT_FIELD_H, TEXT_FIELD_LIMIT, cap_text_events, focus_first_field, text_field, text_field_ex, text_field_with_trailing, +}; pub use units::{ FieldUnits, fmt_num, fmt_num_prec, fmt_num2, parse_num, set_field_resolution, unit_decimals, unit_of_key, value_field_extent, value_field_prec, value_field_units, diff --git a/crates/ui-egui/src/widgets/press_menu.rs b/crates/ui-egui/src/widgets/press_menu.rs index 4211464ed6..16db443c53 100644 --- a/crates/ui-egui/src/widgets/press_menu.rs +++ b/crates/ui-egui/src/widgets/press_menu.rs @@ -98,12 +98,17 @@ pub fn above_parent_ui(parent: egui::LayerId, popup_ui: &egui::Ui) { /// Shows `button`'s menu (an `egui::Popup::menu`) with press-drag-release; `content` chooses its /// items with [`PressMenu::item`]. pub fn show(button: &Response, content: impl FnOnce(&mut Ui, &PressMenu) -> R) -> Option { + show_with_close_behavior(button, PopupCloseBehavior::CloseOnClick, content) +} + +/// A press menu whose content owns its close behavior (e.g. a scrollable font list). +pub fn show_with_close_behavior(button: &Response, close_behavior: PopupCloseBehavior, content: impl FnOnce(&mut Ui, &PressMenu) -> R) -> Option { let ctx = button.ctx.clone(); let menu = PressMenu { popup: Popup::default_response_id(button) }; let open = open_command(button, menu.popup); // The release ending the press that opened the menu is a click outside the popup: it must not // close it again. - let close = if button.clicked() && menu.owns_gesture(&ctx) { PopupCloseBehavior::IgnoreClicks } else { PopupCloseBehavior::CloseOnClick }; + let close = if button.clicked() && menu.owns_gesture(&ctx) { PopupCloseBehavior::IgnoreClicks } else { close_behavior }; let shown = Popup::menu(button).open_memory(open).close_behavior(close).show(|ui| content(ui, &menu)).map(|r| r.inner); if shown.is_some() || Popup::is_id_open(&ctx, menu.popup) { above_parent(&ctx, button.layer_id, egui::LayerId::new(egui::Order::Foreground, menu.popup)); diff --git a/crates/ui-egui/src/widgets/tab_strip.rs b/crates/ui-egui/src/widgets/tab_strip.rs index 728486e973..eb40e09309 100644 --- a/crates/ui-egui/src/widgets/tab_strip.rs +++ b/crates/ui-egui/src/widgets/tab_strip.rs @@ -44,7 +44,7 @@ pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[ if resp.hovered() { paint_ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6)); } - crate::icons::paint(&paint_ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim }); + crate::icons::paint(&paint_ui, r, "chevrons-right", 16.0, t.text); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tip)); let resp = resp.on_hover_text(tip); egui::Popup::menu(&resp).show(|ui| { diff --git a/crates/ui-egui/src/widgets/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs index b377fbf098..07b4e2e3c6 100644 --- a/crates/ui-egui/src/widgets/text_field.rs +++ b/crates/ui-egui/src/widgets/text_field.rs @@ -1,5 +1,5 @@ use super::panel::surface; -use super::{Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, theme, vec2}; +use super::{Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, theme, vec2}; /// The most characters a [`text_field`] holds unless its caller sets a limit: far beyond any /// search, name or hex value, yet a pasted multi-gigabyte string is cut at the field instead of @@ -56,6 +56,24 @@ pub fn text_field(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, widt /// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say), or to /// [`TEXT_FIELD_LIMIT`] without one. pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool, char_limit: Option) -> Response { + text_field_in(ui, id, text, hint, [width, 0.0], mono, char_limit).0 +} + +/// A plain text field reserving space at its right for an embedded popup arrow. +/// Returns the editor and the full field rectangle; the arrow gets its own interaction id. +pub fn text_field_with_trailing(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, trailing: f32) -> (Response, Rect) { + text_field_in(ui, id, text, hint, [width, trailing], false, None) +} + +fn text_field_in( + ui: &mut Ui, + id: egui::Id, + text: &mut String, + hint: &str, + [width, trailing]: [f32; 2], + mono: bool, + char_limit: Option, +) -> (Response, Rect) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width.max(16.0), TEXT_FIELD_H), Sense::hover()); surface(ui, rect, t.field, false); @@ -63,7 +81,8 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; - let inner = rect.shrink2(TEXT_FIELD_PAD); + let mut inner = rect.shrink2(TEXT_FIELD_PAD); + inner.max.x = (inner.max.x - trailing).max(inner.min.x + 1.0); let limit = char_limit.unwrap_or(TEXT_FIELD_LIMIT); // A value already past the limit (restored from a state file, set by automation) is shown and // edited as its first `limit` characters, so it is never laid out whole; the caller's string @@ -96,7 +115,7 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w { *text = b; } - response + (response, rect) } pub(super) fn first_field_id() -> egui::Id { diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index d6c21e3ac3..7ccaa1b103 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -567,14 +567,21 @@ pub struct CompiledShape { impl CompiledShape { /// Compiles the shape at flattening tolerance `tol`. `canvas` is the frame gradients - /// without "Align with layer" are laid out in. + /// without "Align with layer" are laid out in, and the area a feather is computed over. pub fn new(shape: &ShapeLayer, tol: f64, canvas: Rect) -> Self { + Self::with_frames(shape, tol, canvas, canvas) + } + + /// [`CompiledShape::new`] with the feather computed over `coverage` while gradients still lay + /// out in `gradient_frame`: measuring a shape beyond the canvas it is drawn in. + pub fn with_frames(shape: &ShapeLayer, tol: f64, coverage: Rect, gradient_frame: Rect) -> Self { let pb = shape.path.control_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0)); - let cb = (f64::from(canvas.x0), f64::from(canvas.y0), f64::from(canvas.x1), f64::from(canvas.y1)); + let g = gradient_frame; + let cb = (f64::from(g.x0), f64::from(g.y0), f64::from(g.x1), f64::from(g.y1)); let area = fill_rasterizer(&shape.path, tol); let mut bounds = None; let fill = shape.fill.as_ref().map(|f| { - let coverage = FillCoverage::new(shape, &area, canvas); + let coverage = FillCoverage::new(shape, &area, coverage); bounds = coverage.bounds(); (coverage, Paint::from_fill(f, pb, cb)) }); diff --git a/docs/color-range-performance.md b/docs/color-range-performance.md new file mode 100644 index 0000000000..939673e1c1 --- /dev/null +++ b/docs/color-range-performance.md @@ -0,0 +1,42 @@ +# Color Range selection sampling + +`select.colorRange` in Replace mode refines an existing selection. Its sampled-colour, +hue, skin-tone and tonal queries render/read only `selection.content_bounds()` clipped +to the canvas. RGBA buffers, Lab conversion, coverage, selection clipping and replacement +use that same rectangle. Invert happens before clipping to the old coverage, including +soft edges and holes. + +Eyedropper points and subtract points still sample anywhere on the original canvas. +Localized Color Clusters retain the full canvas's range and sample positions; the +coverage query receives the sampled rectangle's origin. Without a selection, or with +Add, Subtract or Intersect, sampling covers the full canvas. Nonzero implicit selection +defaults and malformed tile coordinates also use the full canvas. An explicitly empty +selection produces an empty result while still validating query parameters. + +The Out of Gamut preset uses the existing proof gamut-mask path and is not cropped. +Interactive preview resolution and scheduling are unchanged. + +On a 6000×4000 canvas, the RGBA f32 buffer normally contains 24,000,000 pixels +(384,000,000 bytes). A 50×50 selection uses 2,500 pixels (40,000 bytes); a 600×400 +selection uses 240,000 pixels (3,840,000 bytes). Lab input/output and mask buffers +shrink by the same area ratio. These are buffer sizes, not measured peak RSS. + +## Reproduce the comparison + +On a CI runner, run: + +```sh +RAYON_NUM_THREADS=4 cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture +``` + +Or dispatch `ci.yml` with `color_range=true`. It runs the Linux release comparison, +full normal CI, and the general quick performance suite on macOS. The release fixture +is 24 MP with two colour regions and localized eyedropper sampling. It compares the +full-canvas reference with the cropped command for 50×50, 600×400, 1200×800 and +full-canvas soft selections. Each has a warm-up pair and three alternating timed +pairs; reported timings are medians. Both include the undoable selection edit, and the +cropped path includes command dispatch. Every stored coverage sample is compared over +the whole canvas outside timing. No GUI frame latency is measured. + +This optimization does not change an existing scorecard scenario or baseline. The +request's P5 label is unrelated to scorecard P5 (layer visibility). diff --git a/docs/gaps.md b/docs/gaps.md index d8157a6522..ecd19c55c3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -299,6 +299,12 @@ what an agent can't finish alone. - **Estimate:** 150–250 h. ### G2. Interactive performance +- **Progress (2026-10-27):** Smooth and Feather also crop command mask reads and + replacement to the nonzero selection bounds plus their blur reach and a zero margin. + A 50×50 selection at radius 5 uses 62×62 and 82×82 samples respectively on a 36 MP + canvas. Full-canvas reference tests and remote release benchmarks cover both commands; + GUI budgets and readiness percentages are unchanged. + [Method and limits](selection-distance-performance.md#cropped-smooth-and-feather-commands-2026-10-27). - **Measured progress (2026-10-26):** Expand, Contract and Border restrict distance work to the nonzero bounds plus a radius halo. Small radii use capped distances; Contract avoids inverted float masks and Border retains one distance plane. On 36 MP, eight synthetic @@ -400,6 +406,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | G7: Select and Mask workspace (Photoshop's layout, its tools as `select.refineEdge` edits, Real-Time Refinement, High Quality Preview), #7069, #8148; Refine Hair, Object Aware and Presets remain; no estimate changed | diff --git a/docs/integration-testing/reviewed-performance-2026-10-29.json b/docs/integration-testing/reviewed-performance-2026-10-29.json new file mode 100644 index 0000000000..f5e82ad648 --- /dev/null +++ b/docs/integration-testing/reviewed-performance-2026-10-29.json @@ -0,0 +1,93 @@ +{ + "base": "5f9b4391874aabc9deb2fcb905267613e4d93ec8", + "selected": [ + { + "number": 8472, + "head": "f5dfe82a5379ecda8a6094e8a6d9bcdf7d994f41", + "title": "PNG export: documents over 256 MiB of pixels are encoded a band of rows at a time (#8456)" + }, + { + "number": 8488, + "head": "34af1d8e66089bb1f4f6245560397761286e6fbc", + "title": "fix(engine): convert Image \u203a Mode \u203a Bitmap in bands so huge images don't abort (#8477)" + }, + { + "number": 9461, + "head": "2f68e85f9575f7d51712154e0defa0ea9add08dd", + "title": "Make font names directly editable and preserve mixed-font edits" + }, + { + "number": 9793, + "head": "d843f4c34bd48bb2d5646539c2d953e3eaa8e083", + "title": "Select Inverse: invert shared tiles without full-canvas buffers" + }, + { + "number": 9809, + "head": "eccc20d1b215391d5fbf9d3ea04be322188452ba", + "title": "Smooth and Feather: crop to the selection, up to 395\u00d7 faster on small selections" + }, + { + "number": 9813, + "head": "f4742dd8aa540c19b5d07c2bd1452f3d811ab44e", + "title": "perf(selection): use sparse combination for marquee Add and Subtract" + }, + { + "number": 9814, + "head": "32e166b720b7a04df55cb4475c8a4628ab1827eb", + "title": "perf(selection): crop Color Range sampling to the active selection" + }, + { + "number": 9822, + "head": "9722d16909ae5afc257d8543f93ca78569ec70ca", + "title": "perf(selection): combine GRAY8 masks directly in tiles" + }, + { + "number": 9826, + "head": "6daf9731f1fa4ed9f0aa4a81ce9ead17637a1743", + "title": "perf(curves): sample graphs directly and cache channel polylines" + }, + { + "number": 9847, + "head": "325421a28bd6f38d841359e4b7b79435a3b33e30", + "title": "perf(paint): keep circular tips on the fast path with angle jitter" + }, + { + "number": 9850, + "head": "47df5dedeab1cadf6dac34d20a9201bd9cd1dfe7", + "title": "perf(paint): cache round-tip geometry per dab" + }, + { + "number": 9852, + "head": "cc6837355bc1801b0ec71b0e747ac93e7c7f72a8", + "title": "perf(paint): accumulate serial dabs in place with packed tile keys" + }, + { + "number": 9921, + "head": "aae87d353a9d3fe81b476562e9adf1223e8b0a3f", + "title": "perf(algo): solve Adaptive Wide Angle's mesh with a factor-once direct solve" + }, + { + "number": 9924, + "head": "fe6b95f172e1f2cf185546c590f1bfa1c4c5c477", + "title": "Mixer Brush: dry strokes up to 2\u00d7 faster with identical output" + }, + { + "number": 9961, + "head": "706186384a5e49592f73004a7a1a93194c3b2508", + "title": "perf(segment): solve GrabCut and the band re-cut with Boykov-Kolmogorov" + }, + { + "number": 10080, + "head": "e2cd2a335a9d6d7e6cd17b2e7c729a043d94e0f9", + "title": "Smart objects: re-render without full-size float buffers, and bound the caches that held decoded sources" + } + ], + "excluded": [ + 8536, + 9825, + 9969, + 10129 + ], + "validation": "Combined CI pending. Individual PR checks were reviewed; no combined tests or manual tests are claimed.", + "conflict_resolution": "Combined CI workflow inputs, benchmark jobs and quick-performance conditions; all checks retained." +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c4241fcc6..8df40f8d27 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic UI catalog; previously minor: Arabic paragraph direction buttons, #6793) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-29 · **Change:** minor (Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), @@ -54,7 +54,13 @@ Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164 other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse -the Russian rule. +the Russian rule. Swedish `sv` (4,552 rows, added 2026-10-28) covers every measured key and the +other catalogs' dynamic labels; combined from the community catalogs in #5985 and #9536 with one +term per concept; the contested terms follow a native speaker's questionnaire answers. Finnish +`fi` (4,552 rows, combined from #5666 and #6961), Danish `da` (4,552 rows, based on #7267 and +completed for the current UI) and Norwegian Bokmål `nb` (4,552 rows, combined from #8072 and #9025; +`no` and `nn` locales resolve to it), all added 2026-10-29, cover the same keys with one term per +concept; none has had a native-speaker review yet. Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -93,3 +99,5 @@ engine localization or native review is complete. | 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) | | 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) | | 2026-10-28 | minor | Arabic (`ar`) UI catalog: every measured key, six plural forms; layer names and the title bar no longer reversed on egui 0.37 | +| 2026-10-29 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 | +| 2026-10-29 | minor | Finnish (`fi`), Danish (`da`) and Norwegian Bokmål (`nb`) UI catalogs: every measured key plus the dynamic labels, combined from #5666, #6961, #7267, #8072 and #9025 | diff --git a/docs/localization.md b/docs/localization.md index a5f46bc8b6..33d72eeab4 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -29,6 +29,10 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Tiếng Việt | `vi` | one form | | Srpski | `sr` | one / few / many (teens use many) | | हिन्दी | `hi` | one / other | +| Svenska | `sv` | one / other | +| Suomi | `fi` | one / other (partitive after every count but 1) | +| Dansk | `da` | one / other | +| Norsk bokmål | `nb` | one / other | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels. A string a catalog lacks shows in English: @@ -53,6 +57,9 @@ native system locale; an unsupported code follows the same fallback. Regional ta Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it. +Swedish locales from Sweden and Finland (`sv-SE`, `sv-FI`, `sv_FI.UTF-8`) use the Swedish catalog; +Finnish (`fi-FI`) uses the Finnish one. Every Norwegian locale (`nb-NO`, the macrolanguage `no`, +and Nynorsk `nn-NO` from POSIX systems) uses the Bokmål catalog. ### First launch and system language diff --git a/docs/printing.md b/docs/printing.md index 7b5d2a93fe..09b30a5002 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -25,19 +25,23 @@ the preview and generated PDF continue to share `print_cmds::layout`. | Route | When | What the user sees | |---|---|---| -| **`craft-print`** (`lp` on CUPS, the Windows spooler) | Whenever `lp` is on the path (macOS, Linux outside a sandbox) or on Windows | `Sent to the printer (request id is Office-42)` | -| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path (a Flatpak) and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` | -| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | No print server reachable or no printer set up, or always with `PHOTOCRAFT_PRINT_PORTAL=1` | The system print dialog, seeded with the copies and paper; the outcome goes to the log | +| **`craft-print`** (`lp` on CUPS, the Windows spooler) | `lp` is on the path (macOS, Linux, the Flatpak, which bundles it) and CUPS lists a printer, or on Windows | `Sent to the printer (request id is Office-42)` | +| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` | +| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | Linux only (the Flatpak included): no print server reachable or no printer set up (including when `lp` is there but CUPS lists no printer and none was named), or always with `PHOTOCRAFT_PRINT_PORTAL=1`. Elsewhere a missing route is an error | The system print dialog, seeded with the copies and paper; the outcome goes to the log | | **Print to PDF** | `output` given (the Output field of the dialog) | The file; nothing is spooled unless `send` is true | A route that exists but fails (an unknown printer, a job the queue refuses) is an error; only a -missing route (no `lp`, no print server, no printer) moves on to the next one. +missing route (no `lp`, no print server, no printer) moves on to the next one. With no printer +named and no default, the only printer takes the job on the `lp` and IPP routes; Windows needs +a named or default printer. The IPP client is in-process, pure Rust (RFC 8010/8011: `CUPS-Get-Printers`, `CUPS-Get-Classes`, -`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. That -is what makes printing work in the Flatpak: the manifest grants `--socket=cups`, which mounts the -host's CUPS socket, and the sandbox has no `lp`. On a system without CUPS, or in a sandbox -without the socket, the app falls back to the print portal, whose dialog runs outside the sandbox. +`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. It +only sees queues the server has, not driverless network printers, which is why the Flatpak +bundles `lp`/`lpstat` (the manifest's `cups-client` module) and grants `--socket=cups` (the +host's CUPS socket) and `org.freedesktop.Avahi` (`lpstat -e` finds driverless printers). On a +system without CUPS, or in a sandbox without the socket, the app falls back to the print portal, +whose dialog runs outside the sandbox. ## Which printer diff --git a/docs/releasing.md b/docs/releasing.md index 3870022e8f..4be6ffc17d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -263,7 +263,8 @@ Why these formats: ``` Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11 - shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every + shared memory), `dri` for the GPU, the CUPS socket, Avahi and `~/.cups` (read-only) for + printing, and read/write access to Pictures and Documents. Every other file goes through the file-chooser and document portals. Network access enables update checks and makes the optional loopback `--control` server reachable from the host. Host fonts are read from `/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and diff --git a/docs/selection-distance-performance.md b/docs/selection-distance-performance.md index b6b650a40c..3163c2f4a0 100644 --- a/docs/selection-distance-performance.md +++ b/docs/selection-distance-performance.md @@ -145,8 +145,8 @@ selections clear normally. The existing oversized-canvas safety limit remains. Replacement already accepts a rectangle and builds a fresh sparse selection surface, so it writes only affected tiles and retains the existing undoable transaction. The -shared `current_mask` helper and Grow/Similar are unchanged. Smooth and Feather keep -their existing command paths. +shared `current_mask` helper and Grow/Similar are unchanged. Smooth and Feather also +use command windows as described below. Command tests compare against full-canvas reads, padding and replacement for empty/full selections, both canvas-bounds settings, edge-touching masks, holes, fractional coverage, @@ -176,3 +176,32 @@ Border also skips distance planes and coverage passes when its binary mask is un there is no edge inside the supplied rectangle. Extreme library radii retain the finite no-seed sentinel behavior; the command's canvas-edge padding still creates a real edge for Select All. + + +## Cropped Smooth and Feather commands (2026-10-27) + +All five Select › Modify commands now use the same bounded mask read and replacement. +Smooth grows the nonzero selection bounds by `ceil(radius) + 1`. Feather takes the +truncated Gaussian reach from `selection::feather_reach(radius)` and adds the same +one-pixel zero margin: `ceil(3 * radius) + 1` for the accepted command radii. Using the +blur's shared reach keeps command windows and canvas-edge padding in sync with the +kernel. Both windows are clipped to the canvas before any mask allocation. + +For a 50×50 selection at radius 5 on a 6000×6000 canvas, the command supplies 62×62 +samples to Smooth and 82×82 to Feather, instead of 36 million samples. Nonzero implicit +defaults and malformed tile coordinates retain the full-canvas fallback. The existing +canvas-size safety limit still applies. + +The command comparisons above now include Smooth and Feather, fractional radii down +to Feather's 0.1 px minimum, both canvas-bound settings, soft coverage at 8/16/32-bit +sample depths, one-pixel/thin canvases, and undo/redo. They compare stored selection +coverage with the previous full-canvas path. + +Dispatch the fork's CI with `selection_morphology=true` to run the release comparison +and the general quick performance suite. The existing +`sparse_modify_release_comparison` test now measures both blurs on sparse 50×50, +dense 4000×4000 and fully selected masks in a 36 MP canvas, at radii 3 and 50. It +alternates execution order for three rounds, reports median command times and verifies +every output sample outside timing. The full-canvas reference uses the same optimized +blur kernels and includes the undoable edit. GUI rendering and live-preview scheduling +are excluded; these measurements do not update the P26/P27 scorecard budgets. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 503ed28d1c..25f54a8093 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (cropped selection morphology and command mask conversion performance evidence added; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-27 · **Change:** minor (Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -363,6 +363,12 @@ i7-9750H, 12 workers, 24 MP RGBA8, Good quality, amount 1: Spin 66.10 → 16.43 (4.02×), Zoom 19.19 → 5.35 s (3.59×), one paired run each, exact output equality. These are local measurements at amount 1; see [method and limits](radial-blur-performance.md). +2026-10-27: Smooth and Feather command reads and replacement also use selection bounds +plus blur reach and a zero margin. A 50×50 selection at radius 5 supplies 62×62 and +82×82 samples respectively on a 36 MP canvas. Full-canvas reference comparisons and +remote release benchmarks cover both commands; GUI budgets and percentages are unchanged. +See [method and limits](selection-distance-performance.md#cropped-smooth-and-feather-commands-2026-10-27). + 2026-10-26: cropped Expand, Contract and Border measure 1.54–37.82× faster across eight 36 MP synthetic mask cases on GitHub Actions, with exact output equality. Radii up to 5 px use capped distances; Contract avoids inverted float masks and Border retains one distance @@ -580,6 +586,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Select and Mask is Photoshop's workspace with its own tools (#7069, #8148); TOOL-214-1 stays partial (Refine Hair, Object Aware, Presets); percentages and estimates unchanged | | 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a57d85377c..863bae16b8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Free Transform of several selected layers, #6501; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Free Transform of several selected layers, #6501; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -172,6 +172,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | | Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | | Wheel steps a value field, ⇧ ×10: clicked into, wherever the pointer is; not clicked, a gesture that starts over it | partial | `field_wheel.rs` (#5473, #4061). Photoshop: a field takes the wheel once clicked into (shortcut guides, user reports); whether the pointer must stay over it is unconfirmed. Stepping on hover alone is Photopea's, kept deliberately | +| Font-name field: click selects the full name; typing searches in place | done | `type_tool.rs` `family_picker_in`, `type_font_picker_tests.rs` (#9338): one editable name above the list; unmatched queries show a compact message; Enter or a row applies to the selected characters even when the family matches their representative; both controls show the selected span and leave mixed families blank; caret font choices affect subsequent typing; Ctrl/Cmd+Enter accepts a valid pending font query and commits from a focused field; Escape/outside click discards pending text; existing navigation and previews retained | | Type options bar: drag the "T" icon to change the font size | done | `type_tool.rs` `options_bar`: the icon scrubs the size field like the Character panel's icons (Shift x10, Alt fine), at layer or selection scope, one history step per drag, within 1–1296 pt (#6437) | ## Navigation @@ -322,6 +323,8 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) | | 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) | | 2026-10-23 | minor | ⌥ + an adjustment's shortcut (⌥⌘L Levels, ⌥⌘M Curves) opens its dialog at the settings of its last OK, as in Photoshop (#8900); no percentage changed | +| 2026-10-25 | minor | Shared font-family fields support direct name editing and search, with full-name selection on entry, explicit acceptance and cancellation, plus a compact no-match result (#9338); no percentage changed | +| 2026-10-25 | minor | RC2 follow-up: selection-aware font family/style display, explicit keyboard application to mixed selections, pending caret fonts for typing/paste/IME, and Ctrl/Cmd+Enter from focused Type fields (`type_font_commit_tests.rs`); no percentage changed | | 2026-10-14 | minor | ⇧⌥ + letter blend mode keys for the painting tool or every selected layer; ⇧+ / ⇧− now step every selected layer and include Clear for Brush and Pencil | | 2026-10-15 | minor | ⇧⌥ blend mode letters work while a Free Transform box is open, without picking a tool | | 2026-10-14 | minor | Mode dropdowns for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; the blend mode keys reach them, and tools without a blend mode (not Dodge, Burn or Sponge) pass the keys to the layers | diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml index 091e7159eb..7f1cf5d299 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml @@ -27,9 +27,14 @@ finish-args: - --share=network # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri - # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, - # in process). Without it the app shows the system print dialog through the print portal. + # Printing: the host's CUPS socket, for the bundled lp/lpstat (the cups-client module) and the + # in-process IPP client. Without it the app shows the system print dialog through the print portal. - --socket=cups + # Driverless network printers are found through Avahi (`lpstat -e`). + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command @@ -38,6 +43,50 @@ finish-args: - --filesystem=xdg-documents # Deliberately not granted: home/host filesystem access. modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: photocraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index 32e81545fb..5b85503375 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -28,9 +28,14 @@ finish-args: - --share=network # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri - # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, - # in process). Without it the app shows the system print dialog through the print portal. + # Printing: the host's CUPS socket, for the bundled lp/lpstat (the cups-client module) and the + # in-process IPP client. Without it the app shows the system print dialog through the print portal. - --socket=cups + # Driverless network printers are found through Avahi (`lpstat -e`). + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command @@ -41,6 +46,50 @@ finish-args: build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: photocraft buildsystem: simple build-options: diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index b3cde0ea1c..e8d42ca9c7 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -31,6 +31,10 @@ hu tr vi + sv + fi + da + nb CFBundleDisplayName PhotoCraft