diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094c1f3893..bec29abaf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # No test or fuzz run may print a page: the engine refuses to spool, and if something ever + # bypasses that, lp fails loudly on a printer that does not exist. + PHOTOCRAFT_NO_PRINT: "1" + LPDEST: photocraft-no-such-printer # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85d0cd2c81..bfdca9e3e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1ea4034dbe..23629ff23b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,6 +10,7 @@ next to them. | Path | Title | Author | Source | License | |---|---|---|---|---| +| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | | MIT (X.Org app/rgb) | | `crates/ui-egui/src/i18n/el.tsv` | Greek 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/de.tsv` | German 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/it.tsv` | Italian 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) | @@ -19,10 +20,13 @@ next to them. | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese 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/uk.tsv` | Ukrainian 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/cs.tsv` | Czech 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/hu.tsv` | Hungarian 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/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/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) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -35,6 +39,7 @@ next to them. | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -50,6 +55,7 @@ next to them. | `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code | | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | +| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work | ### Optional build input: craft-fonts (not files in this repo) diff --git a/Cargo.lock b/Cargo.lock index dceb5602e1..6820d6dee5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,6 +1121,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1743,6 +1751,7 @@ dependencies = [ "miniz_oxide 0.8.9", "num-complex", "pulp", + "rayon-core", "smallvec", "zune-inflate", ] @@ -2331,9 +2340,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heic-rs" -version = "0.1.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" dependencies = [ "rayon", ] @@ -4182,7 +4191,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.7.0" +version = "0.8.2" dependencies = [ "arboard", "async-io", @@ -4211,12 +4220,14 @@ dependencies = [ "photocraft-raster", "photocraft-tablet", "photocraft-ui-egui", + "resvg", "rfd", "ron", "serde_json", "winit", "winresource", "winsafe", + "wl-clipboard-rs", "x11rb", "xcap", "zbus", @@ -4224,7 +4235,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.7.0" +version = "0.8.2" dependencies = [ "crc32fast", "flate2", @@ -4234,7 +4245,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4247,7 +4258,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.7.0" +version = "0.8.2" dependencies = [ "base64", "cap-std", @@ -4269,7 +4280,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4283,7 +4294,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.7.0" +version = "0.8.2" dependencies = [ "moxcms", "rayon", @@ -4291,7 +4302,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.7.0" +version = "0.8.2" dependencies = [ "exr", "flate2", @@ -4312,7 +4323,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-cms", "serde", @@ -4320,7 +4331,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.7.0" +version = "0.8.2" dependencies = [ "criterion", "photocraft-color", @@ -4335,7 +4346,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4346,9 +4357,10 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.7.0" +version = "0.8.2" dependencies = [ "blake3", + "craft-print", "exr", "flate2", "photocraft-affinity", @@ -4380,11 +4392,12 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.7.0" +version = "0.8.2" dependencies = [ "blake3", "crc32fast", "flate2", + "libc", "photocraft-codecs", "photocraft-color", "photocraft-doc", @@ -4400,14 +4413,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.7.0" +version = "0.8.2" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.7.0" +version = "0.8.2" dependencies = [ "half", "log", @@ -4424,14 +4437,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.7.0" +version = "0.8.2" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.7.0" +version = "0.8.2" dependencies = [ "blake3", "crc32fast", @@ -4460,14 +4473,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4478,7 +4491,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4493,7 +4506,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.7.0" +version = "0.8.2" dependencies = [ "flate2", "photocraft-psd", @@ -4504,7 +4517,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4514,7 +4527,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.7.0" +version = "0.8.2" dependencies = [ "flate2", "image", @@ -4529,7 +4542,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.7.0" +version = "0.8.2" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4541,7 +4554,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.7.0" +version = "0.8.2" dependencies = [ "memory-stats", "photocraft-color", @@ -4552,7 +4565,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.7.0" +version = "0.8.2" dependencies = [ "flate2", "parley", @@ -4566,7 +4579,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.7.0" +version = "0.8.2" dependencies = [ "base64", "craft-ui", @@ -4574,6 +4587,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "epaint_default_fonts", "flate2", "log", "photocraft-algo", @@ -4605,7 +4619,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.7.0" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-doc", @@ -4616,7 +4630,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.7.0" +version = "0.8.2" dependencies = [ "eframe", "egui", @@ -7449,7 +7463,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.2" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 856d9372b6..f96b5cb468 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } # external +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" @@ -50,7 +51,9 @@ zune-core = "0.5" zune-jpeg = "0.5" proptest = "1" eframe = { version = "0.36", default-features = false } -egui = "0.36" +# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated +# (egui_fonts.rs; the web build's size gate). +egui = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" log = "0.4" @@ -81,13 +84,14 @@ codegen-units = 1 # The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the # size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every -# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O -# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic -# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for -# the native app shell). packaging/web/package.sh fails the build above 24 MiB. +# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates, +# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters +# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured). +# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` +# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB. [profile.wasm-release] inherits = "release" -opt-level = "s" +opt-level = "z" lto = "fat" codegen-units = 1 strip = "debuginfo" @@ -95,20 +99,35 @@ strip = "debuginfo" [profile.wasm-release.package."*"] opt-level = "s" -# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below -# retain their speed-oriented optimization level. -[profile.wasm-release.package.photocraft-ui-egui] -opt-level = "z" +# Decoding and rasterizing keep their speed. +[profile.wasm-release.package.photocraft-codecs] +opt-level = "s" +[profile.wasm-release.package.photocraft-raw] +opt-level = "s" +[profile.wasm-release.package.photocraft-heif] +opt-level = "s" +[profile.wasm-release.package.photocraft-vector] +opt-level = "s" -# Keep browser command dispatch, import/export glue, and the entry point compact. Native -# builds and the speed-oriented pixel-processing overrides below retain their settings. -[profile.wasm-release.package.photocraft-engine] +# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once +# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation +# (runs when a pipeline is created). "z" keeps the wasm well under the size gate. +[profile.wasm-release.package.photocraft-doc] +opt-level = "z" +[profile.wasm-release.package.photocraft-psd] +opt-level = "z" +[profile.wasm-release.package.photocraft-format] +opt-level = "z" +[profile.wasm-release.package.photocraft-affinity] +opt-level = "z" +[profile.wasm-release.package.serde_json] opt-level = "z" -[profile.wasm-release.package.photocraft-io] +[profile.wasm-release.package.usvg] opt-level = "z" -[profile.wasm-release.package.photocraft-web] +[profile.wasm-release.package.naga] opt-level = "z" +# Pixel processing keeps its speed-oriented optimization level. [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] @@ -121,3 +140,4 @@ opt-level = 3 opt-level = 3 [profile.wasm-release.package.photocraft-paint] opt-level = 3 + diff --git a/README.md b/README.md index 0455744b05..5fd2650415 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable. + PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.

🪄 The everyday essentials

@@ -331,8 +331,6 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi | arm64 (Snapdragon and other ARM PCs) | `photocraft--windows-arm64.msi` | `photocraft--windows-arm64-portable.zip` | | x86 (32-bit) | `photocraft--windows-x86.msi` | `photocraft--windows-x86-portable.zip` | -Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so). - Installers and executables are code-signed. ### macOS diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index fe8c62717a..a81cce61ce 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,19 +15,21 @@ pub const USAGE: &str = "\ photocraft-cli: headless Photocraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. + --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] - Apply an action list to every image in a directory. Steps are [id, params] pairs, + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a + set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed @@ -67,17 +69,17 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -213,6 +215,10 @@ fn export_opts(a: &Args) -> Result { if let Some(b) = a.get("--tga-bits") { o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); } + if let Some(b) = a.get("--dpx-bits") { + o.encode.dpx_bits = + Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); + } Ok(o) } @@ -240,7 +246,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { let opts = export_opts(a)?; let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; warn_all(err, &o.warnings); - warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document))); + warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; warn_all(err, &ws); Ok(()) @@ -292,11 +298,16 @@ fn command_list(a: &Args) -> Result, String> { fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let opts = export_opts(a)?; let mut h = Headless::trusted_local(); + // Opening, running commands and exporting can all notice the same missing font: + // one `run` invocation reports each warning line at most once. + let mut reported_fonts = std::collections::HashSet::::new(); match (a.positional.as_slice(), a.get("--new")) { ([file], None) => { let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); + reported_fonts.extend(ws.iter().cloned()); warn_all(err, &ws); + reported_fonts.extend(ws); } ([], Some(new)) => { let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; @@ -308,9 +319,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { if cmds.is_empty() && a.get("--out").is_none() { return Err("run needs at least one --cmd (or --out)".into()); } - let mut reported_fonts = std::collections::HashSet::::new(); for (id, params) in cmds { let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; + if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { + return Err(format!("`{id}`: {e}")); + } let new_warnings: Vec = h .session .active() @@ -339,6 +352,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { if let Some(o) = a.get("--out") { let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); } Ok(()) @@ -384,6 +398,38 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), String> { + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); + if !is_atn { + let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; + let (steps, set) = load_actions(&text, name)?; + return Ok((steps, set, Vec::new())); + } + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; + let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let action = match (name, imp.actions.as_slice()) { + (Some(n), _) => { + imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? + } + (None, [only]) => only, + (None, []) => return Err(format!("{path}: the set holds no actions")), + (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), + }; + let warnings = imp + .unsupported + .iter() + .filter(|u| u.action == action.name) + .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) + .chain(imp.warnings.iter().cloned()) + .collect(); + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, imp.actions, warnings)) +} + /// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a /// trailing separator, through a symbolic link). fn same_dir(a: &Path, b: &Path) -> bool { @@ -392,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool { fn is_input(p: &Path) -> bool { let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub") + matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) }; p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) @@ -402,8 +448,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?; - let (actions, set) = load_actions(&text, a.get("--action"))?; + let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + for w in &warnings { + let _ = writeln!(err, "warning: {w}"); + } let opts = export_opts(a)?; // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). @@ -626,6 +674,11 @@ mod missing_font_warning_tests { let (code, _, stderr) = invoke(&["convert", &ora, &png]); assert_eq!(code, 0, "{stderr}"); assert!(is_input(std::path::Path::new(&ora))); + // Krita documents are batch inputs too (import-only). + for name in ["sketch.kra", "old.KRZ"] { + std::fs::write(folder.join(name), b"").unwrap(); + assert!(is_input(&folder.join(name)), "{name}"); + } let _ = std::fs::remove_dir_all(&folder); } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 5baa83fde3..231eb2c321 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -203,6 +203,50 @@ fn run_commands_and_save() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn run_stops_after_failed_action_without_exporting() { + let d = tmp("run-failed-action"); + let out = d.join("result.png"); + let result = bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "layer.new.layer"]) + .args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#]) + .args(["--cmd", "layer.delete", "--cmd", "actions.stop"]) + // The recorded deletion succeeded with two layers; replay cannot delete the last one. + .args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out) + .output() + .unwrap(); + let error = String::from_utf8_lossy(&result.stderr); + assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout)); + assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}"); + let rows: Vec = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run"); + assert!(!out.exists(), "a failed action must not export a partial result"); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn run_continues_after_successful_action_and_exports() { + let d = tmp("run-successful-action"); + let out = d.join("result.png"); + let (stdout, _) = ok(bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#]) + .args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"]) + .args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out)); + let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap(); + assert_eq!(playback["result"]["ran"], 1); + assert!(playback["result"].get("failed").is_none()); + assert!(rows.iter().any(|row| row["command"] == "document.inspect")); + assert!(out.is_file()); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn run_errors() { let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap(); @@ -411,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() { assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}"); } +/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the +/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049). +#[test] +fn dpx_converts_both_ways_with_the_bits_asked_for() { + let d = tmp("dpx"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] { + let out = d.join("a.dpx"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(b) = bits { + convert.args(["--dpx-bits", b]); + } + ok(&mut convert); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); + } + let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")); + let v: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); + assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back)); + assert!(back.exists()); + for bad in ["24", "0", "abc"] { + let out = d.join("bad.dpx"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}"); + assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); + } +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is /// refused and nothing is written. #[test] @@ -640,3 +718,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() { assert!(!err.contains("panicked"), "{err}"); } } + +/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps +/// PhotoCraft can't play are warned about up front. +#[test] +fn batch_plays_a_photoshop_actions_file() { + use photocraft_engine::actions_cmds::Action; + let d = tmp("atn"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]); + let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]); + let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap(); + assert!(skipped.is_empty()); + let atn = d.join("Set.atn"); + std::fs::write(&atn, bytes).unwrap(); + let out_dir = d.join("out"); + // Two actions: the set needs --action. + let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap(); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr)); + let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)); + assert!(out_dir.join("a.png").exists()); + assert!(!err.contains("warning"), "{err}"); + // The unsupported step is warned about, and fails the file as Photoshop would stop on it. + let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert!(err.contains("Generative Fill"), "{err}"); + assert!(!o.status.success()); +} diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 6b4c62797a..a4ded64181 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -29,7 +29,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit"] } rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/apps/photocraft-web/index.html b/apps/photocraft-web/index.html index 5e734006be..d090cf47da 100644 --- a/apps/photocraft-web/index.html +++ b/apps/photocraft-web/index.html @@ -4,7 +4,7 @@ PhotoCraft - +